<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xml:base="https://martinhicks.dev/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Martin Hicks - Journal</title>
    <link>https://martinhicks.dev/</link>
    <atom:link href="https://martinhicks.dev/feed.xml" rel="self" type="application/rss+xml" />
    <description>Martin Hicks is a software developer from Manchester, UK</description>
    <language>en</language>
    <item>
      <title>I watched DynamoDB change under my conformance suite</title>
      <link>https://martinhicks.dev/articles/dynamodb-changed-its-validation-errors</link>
      <description>&lt;p&gt;My DynamoDB conformance suite went red this week, and the test that failed was real AWS.&lt;/p&gt;
&lt;p&gt;That needs explaining. The suite runs a few hundred tests against real DynamoDB first, records exactly what the real thing does, then runs the same tests against the emulators people reach for locally - DynamoDB Local, LocalStack, Dynalite, my own engine - and scores how closely each one matches. Real DynamoDB is the reference. It sits at 100% by definition, because it&#39;s the thing everything else is measured against. So when the reference itself fails a test, something&#39;s up: either my test is wrong, or the real thing has moved underneath it.&lt;/p&gt;
&lt;p&gt;It was the second one. Real DynamoDB had changed how it words a chunk of its validation errors. The empty-table-name case used to come back as &lt;code&gt;Value &#39;&#39; at &#39;tableName&#39; failed to satisfy constraint...&lt;/code&gt;; now it&#39;s &lt;code&gt;Value at &#39;TableName&#39;...&lt;/code&gt; - the echoed value gone, the field name in PascalCase. A pile of other bespoke messages picked up a &lt;code&gt;1 validation error detected:&lt;/code&gt; envelope they didn&#39;t have before. And &lt;code&gt;PutItem&lt;/code&gt; with a &lt;code&gt;{ NULL: false }&lt;/code&gt; attribute, which used to be rejected outright, is now accepted and quietly stored as &lt;code&gt;{ NULL: true }&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;None of that is written down anywhere. DynamoDB&#39;s exact error strings aren&#39;t part of any published contract. The only way to know what they are is to ask the service and record what it says, which is exactly what the suite does - so it noticed within a week.&lt;/p&gt;
&lt;p&gt;Here&#39;s the part that caught me out. I&#39;d assumed a change like this lands everywhere at once, so I fired the same inputs at four regions to be sure. It doesn&#39;t. eu-west-2 and eu-central-1 return the new wording. us-east-1 and ap-southeast-2 still return the old. Two regions on one side of the line, two on the other.&lt;/p&gt;
&lt;p&gt;Most of that split is cosmetic - the wording moved, not the behaviour. The exception is &lt;code&gt;{ NULL: false }&lt;/code&gt;: two regions now accept a write the other two still reject. That one isn&#39;t the error text reading differently, it&#39;s the service taking different input depending on where you call it, and it&#39;s what makes &amp;quot;which region&amp;quot; change the answer rather than just the message.&lt;/p&gt;
&lt;p&gt;So which is it - has AWS changed DynamoDB, or do these regions just behave differently? I think it&#39;s the first, and that the regional split is the change still in motion rather than a permanent fact of geography. Two things point that way. The two regions that moved moved in exactly the same way - same dropped value, same envelope, same &lt;code&gt;{ NULL: false }&lt;/code&gt; behaviour - which reads like one upstream change propagating outward, not two regions independently drifting apart. And AWS has no reason to deliberately fork European validation from everyone else&#39;s. The simplest story that fits the evidence is a staged rollout that&#39;s reached Europe and not yet reached Virginia or Sydney.&lt;/p&gt;
&lt;p&gt;I can&#39;t prove that from the outside. I won&#39;t know the laggards have caught up until they do, and a regional fault that gets quietly reverted would look much the same from here. But &amp;quot;a change rolling out region by region&amp;quot; fits better than anything else, so that&#39;s how I&#39;m reading it. Which makes the durable point not &amp;quot;DynamoDB differs by region&amp;quot; - if I&#39;m right, that sorts itself out - but &amp;quot;DynamoDB&#39;s validation moves, it&#39;s undocumented, and the only way to know where it&#39;s got to is to ask each region directly.&amp;quot;&lt;/p&gt;
&lt;p&gt;That points at something I&#39;d got wrong in the suite. I&#39;d pinned the exact prose - the full error string, word for word - and the exact prose was never part of any contract AWS owes me. The upstream Smithy model says which constraints exist: this field is required, that one has a minimum length. It says nothing about how the resulting error reads. I was asserting on the one part AWS is free to change without telling anyone, and then acting surprised when it did.&lt;/p&gt;
&lt;p&gt;So the direction I&#39;m taking the suite is to assert the contract and stop pinning the wording. Check that the right exception type comes back, against the right field, for the right violated constraint - the things the model actually promises - and let the surrounding prose vary. Pin less, and the next reword stops being a CI failure and goes back to being the non-event it always should have been.&lt;/p&gt;
&lt;p&gt;There&#39;s a temptation, having seen this, to build a standing &amp;quot;regional drift&amp;quot; monitor: run the strict checks against a row of regions on a schedule, light up whenever they disagree. I&#39;ve decided not to, at least not yet. If this really is a rollout, the divergence trends to zero the moment it finishes everywhere, and I&#39;d be left maintaining a view that watches nothing most of the time. So I took a one-off snapshot instead - that&#39;s how I know the spread is two and two - and I&#39;ll build the live view only if drift turns out to be a recurring pattern rather than a single event I&#39;ve already characterised. No sense turning one honest number into a dozen confusing ones, or standing up infrastructure for something I&#39;ve already had a good look at.&lt;/p&gt;
&lt;p&gt;The smaller, sharper upshot is about the number the suite reports. It&#39;s always really been &amp;quot;conformance to DynamoDB in eu-west-2&amp;quot;, not &amp;quot;conformance to DynamoDB&amp;quot;. I&#39;d quietly treated those as the same thing. They aren&#39;t, and the fix is to make the suite say which region it&#39;s measuring rather than imply a single global DynamoDB that doesn&#39;t exist.&lt;/p&gt;
&lt;p&gt;If you&#39;ve ever written a test that asserts on a DynamoDB error message, or leaned on one in your own code, this is worth sitting with. The string you pinned might be true in your region and not the next one over, and it might not stay true in yours for much longer. Mine changed under me.&lt;/p&gt;
&lt;p&gt;The results are at &lt;a href=&quot;https://paritysuite.org/&quot;&gt;paritysuite.org&lt;/a&gt;, and the suite&#39;s open source if you want to point it at your own region and see what comes back.&lt;/p&gt;
</description>
      <pubDate>Tue, 09 Jun 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/dynamodb-changed-its-validation-errors</guid>
    </item>
    <item>
      <title>Build the squad. Own the platform. The org chart will catch up.</title>
      <link>https://martinhicks.dev/articles/build-the-squad-own-the-platform</link>
      <description>&lt;p&gt;My wife Helen, who works in HR, sent me an episode of &lt;a href=&quot;https://podcasts.apple.com/gb/podcast/hr-disrupted/id1224217097?i=1000767335369&quot;&gt;HR Disrupted&lt;/a&gt; last week - Lucy Adams interviewing Andy Doyle from Kantar on putting AI agents into production HR workflows.&lt;/p&gt;
&lt;p&gt;Andy describes some genuinely impressive work. Kantar had around 4,000 HR policies spread across 63 markets. Twenty-three versions of a single policy were scattered across the intranet, many written by different people at different times, some long out of date. Reviewing and standardising all that by hand would have been the work of years. So they built a set of small, single-purpose agents instead, one to rewrite each policy into a standard format, another to flag where it broke local employment law. The whole lot went into one common framework in a week.&lt;/p&gt;
&lt;p&gt;It&#39;s a real piece of engineering, which is what makes some of the rhetoric around it a shame: you don&#39;t train out hallucination, you constrain it with evals and guardrails. But the operational story underneath holds, and the lesson is in what they did, not how they describe it.&lt;/p&gt;
&lt;p&gt;Listening to the podcast got me thinking less about Kantar and more about the next move for everyone who hears a story like that and wants to do something similar.&lt;/p&gt;
&lt;h2&gt;Why now?&lt;/h2&gt;
&lt;p&gt;The conversation about AI inside large organisations has shifted in the last six months, from a model problem to a deployment one. AWS&#39;s Ishit Vachhrajani describes it as moving from &amp;quot;generate and hope&amp;quot; to systems that plan, decide, and act with human guidance. You can still argue about whether the models are ready, and plenty of people do. But the teams already shipping agents into production - Andy&#39;s at Kantar among them - have stopped waiting for that argument to settle. The hard parts that remain, reliability and hallucination, get handled operationally now - not trained away. Those are deployment problems, not model problems. The bottleneck is the org chart.&lt;/p&gt;
&lt;p&gt;Where in your organisation does this capability live? Who owns it, who governs it, who carries the risk when something goes wrong? These are organisational design and architectural decisions, and the next ten years of AI in your business will be shaped more by how you answer them than by which models you pick.&lt;/p&gt;
&lt;p&gt;And the honest version of where this is heading is bigger than a governance question. The functional org chart - HR over here, IT over there, finance in the corner - has been the default shape of large organisations for the better part of a century. AI may prove to be one of the forces that starts to pull it apart. Get this right and you&#39;re not just deploying agents well, you&#39;re building the first working piece of an organisation that composes around the work itself rather than the functions that have always owned it. The first step towards that is much smaller.&lt;/p&gt;
&lt;p&gt;Faced with the immediate question - where does this capability live - most leaders reach for one of two answers. Both look reasonable. Both have serious problems.&lt;/p&gt;
&lt;h2&gt;Two easy answers that don&#39;t quite work&lt;/h2&gt;
&lt;p&gt;The first is to outsource the capability wholesale. Buy an agentic platform from one of the workflow-automation or RPA vendors who&#39;ve rebadged in the last eighteen months, let them run the platform layer, let their workflows define your agents.&lt;/p&gt;
&lt;p&gt;This is a mistake, and a specific one. Not that you shouldn&#39;t get external help - most organisations will sensibly work with partners. The mistake is handing over the architectural decisions. Rent a vendor&#39;s abstraction and your data, governance, audit, and identity story is whatever they ship. When an agent does something it shouldn&#39;t, the loop back to the people who can change it goes through a support queue. You&#39;ve outsourced the part of your business that&#39;s about to become strategic.&lt;/p&gt;
&lt;p&gt;Building on a hyperscaler is genuinely different. Model behaviour, pricing, and roadmap are externally controlled either way, but on AWS, Azure, or Google you own the IAM, the audit, the cost controls, the integration patterns. You stand on their shoulders without handing over the decisions that shape how your capability evolves.&lt;/p&gt;
&lt;p&gt;The second easy answer is to bolt the capability onto an existing function. Hand it to HR, hand it to IT, hand it to whoever has the closest claim, and declare the strategy done.&lt;/p&gt;
&lt;p&gt;There are really two layers here, and they want different answers. The platform layer - IAM, audit, cost controls, the cloud foundation the agents run on - IT will rightly own as platform engineering. It&#39;s foundational and benefits from standardised delivery, same as any cloud platform.&lt;/p&gt;
&lt;p&gt;The harder question is who owns the capability that uses the platform: designing the agents, deploying them into real business problems, shaping how people interact with them, deciding what good looks like. That&#39;s where the &amp;quot;give it to a function&amp;quot; answers break down.&lt;/p&gt;
&lt;p&gt;Of the single-function answers, HR has the strongest case. Adoption really is the hard part, and behavioural standards and change management live closer to HR than to engineering. None of that is wrong. The problem is that bolting the capability onto any single function inherits that function&#39;s operating pattern, and strategically important work inside a shared service ends up under-invested and slow. The interesting question isn&#39;t &amp;quot;which function owns it?&amp;quot; It&#39;s whether you&#39;re building cross-disciplinary capability that lives in the business, or single-discipline capability that gets delivered to the business.&lt;/p&gt;
&lt;h2&gt;We&#39;ve done this before&lt;/h2&gt;
&lt;p&gt;The thing that should give every senior leader pause is that we&#39;ve already lived through a version of this. It was called Excel.&lt;/p&gt;
&lt;p&gt;Spreadsheets put powerful data manipulation in the hands of capable people with no engineering scaffolding around them. Net positive on the whole, but it also gave us the London Whale loss at JPMorgan and a generation of businesses running critical processes on undocumented workbooks built by an analyst who left two roles ago. We&#39;re still cleaning that up decades later.&lt;/p&gt;
&lt;p&gt;&amp;quot;Buy some licences and see what people can do&amp;quot; is the same energy. Powerful tools, capable hands, no scaffolding. Except an agent doesn&#39;t sit in a workbook waiting to be opened - it acts. Put one on top of a broken process and it won&#39;t just record the mess, it&#39;ll run it. We learned the lesson once. This time the spreadsheet could fire people.&lt;/p&gt;
&lt;h2&gt;What to do instead: build a squad&lt;/h2&gt;
&lt;p&gt;The answer isn&#39;t a new department. It&#39;s a deployment squad - and you can see the shape of one in the Kantar story, even if Andy doesn&#39;t call it that. The top agent builders are spread across HR, payroll, and analytics. The payroll manager who happened to be in the room when the Copilot licences went out became the AI manager. The head of analytics built the finance/HR reconciliation agent herself, eighty hours by her own count. The job title may be useful political cover, but it isn&#39;t the operating model. What Andy describes is a squad, not a function.&lt;/p&gt;
&lt;p&gt;I&#39;m extrapolating here. I haven&#39;t run an internal AI deployment squad inside a large enterprise. What I have watched, up close, is the pattern AWS Professional Services and the Partner Network use to ship production work inside customer organisations - and the forward-deployed engineer roles that Anthropic, OpenAI, and Google are now pitching look like the same shape. The underlying logic is one AWS worked out long ago: access alone isn&#39;t the bottleneck. Deployment is.&lt;/p&gt;
&lt;p&gt;So build your own version. A small, cross-functional squad: platform engineers who can operate like internal forward-deployed engineers, IT people who already know your data and systems, HR expertise on behavioural standards and adoption, and line-of-business owners who know what good looks like. It embeds into specific problems and ships production agents. Not an innovation pod or a tiger team that spins up a demo and disbands. It owns what it ships, it integrates properly with the platform, and it stays.&lt;/p&gt;
&lt;p&gt;The squad needs two things to work.&lt;/p&gt;
&lt;p&gt;The first is platform discipline. The agents it builds are applications. They live on a platform layer that decides who an agent is allowed to be, what it can touch, what records it leaves, what it costs, and what it&#39;s allowed to do with sensitive data. Most of that comes from the cloud platform you already use. What it doesn&#39;t give you, you build and maintain yourself. Be clear from day one about which decisions belong to the platform and which belong to the agent, and resist the urge to rebuild platform-level safeguards inside every individual agent. Andy&#39;s team raced past the audit trail and had to come back to it later; he notes it would have been far more costly in a regulated industry. The discipline matters from the start, not as a retrofit.&lt;/p&gt;
&lt;p&gt;The second is a sponsor senior enough to clear blockers but light enough not to slow it down, with the authority to ship without permission from every adjacent function. Andy had exactly this: permission to think bigger and permission to fail visibly. The sponsor&#39;s risk tolerance is what lets the squad ship and learn. The platform discipline is what decides whether the inevitable failures are recoverable embarrassments or unrecoverable disasters.&lt;/p&gt;
&lt;p&gt;Where the squad sits on the org chart matters less than these two things.&lt;/p&gt;
&lt;p&gt;One thing the podcast skips is how the rest of the organisation reacts. A cross-cutting squad shipping into work that IT, finance, or operations think of as theirs can be read as a threat, even when it&#39;s there to help. Handling that is real work, and it gets easier each time: squads that do it well don&#39;t stay one-offs. They become the prototype for how AI capability spreads, and the platform team scales with them.&lt;/p&gt;
&lt;h2&gt;What this means for HR, and for Helen&lt;/h2&gt;
&lt;p&gt;The more forward-looking parts of the profession have been moving from a roles-based view of the workforce to a skills-based one, where the unit of work is the skill, not the job title, and teams get composed from skills as the business needs them. Mercer&#39;s Jess Von Bank, in a recent People Management feature, pushes HR to design itself from first principles - for what the business needs now, not what the org chart has always done. A deployment squad, assembled from the skills a problem needs, is exactly what that model produces, which is why HR shouldn&#39;t try to own the AI capability but should help compose the team that delivers it. That&#39;s a craft the profession has spent a decade building, and it deserves better than to be flattened into change management.&lt;/p&gt;
&lt;p&gt;For someone like Helen, that means becoming genuinely literate in the platform layer - what agents do, how they fail - without becoming an engineer. The more HR understands how these systems work in practice, the more useful it is to the teams that build and deploy them.&lt;/p&gt;
&lt;p&gt;The squad is the starting move. Build enough of them and the question stops being &amp;quot;which function owns AI&amp;quot; and starts being &amp;quot;why are we organised around functions at all&amp;quot;. That&#39;s a bigger argument, and not one for today - but you don&#39;t have to settle it to start.&lt;/p&gt;
&lt;p&gt;Don&#39;t wait for a reorg to make it official. The future organisation doesn&#39;t arrive by redrawing the chart - it arrives one shipped agent at a time. The org chart will catch up when the work has earned it.&lt;/p&gt;
</description>
      <pubDate>Wed, 03 Jun 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/build-the-squad-own-the-platform</guid>
    </item>
    <item>
      <title>Dynoxide 0.10.0: it runs in the browser now</title>
      <link>https://martinhicks.dev/articles/dynoxide-0100-browser-and-docker</link>
      <description>&lt;p&gt;A DynamoDB-compatible database, running in a browser tab. No server, no network. The data lives in the &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system&quot;&gt;origin private file system&lt;/a&gt;, and the query engine is the same Rust that backs the native build, compiled to WebAssembly.&lt;/p&gt;
&lt;p&gt;That&#39;s the thing I&#39;m most pleased about in dynoxide 0.10.0. It&#39;s a big release, and the first one that isn&#39;t a clean upgrade. So here&#39;s what shipped, why, and what bites you on the way up.&lt;/p&gt;
&lt;h2&gt;A database in the browser&lt;/h2&gt;
&lt;p&gt;Dynoxide started as an engine I wanted to embed in a native app. &amp;quot;Embed it anywhere&amp;quot; was always the pitch. 0.10.0 is the release where the browser counts as anywhere.&lt;/p&gt;
&lt;p&gt;The native build backs onto SQLite through &lt;code&gt;rusqlite&lt;/code&gt;. To run in a browser you need a SQLite that runs in WebAssembly, and that&#39;s &lt;a href=&quot;https://github.com/rhashimoto/wa-sqlite&quot;&gt;wa-sqlite&lt;/a&gt;. Dynoxide talks to it over a wasm-bindgen bridge and persists to OPFS. Build it with &lt;code&gt;--features wasm-sqlite&lt;/code&gt; and you get &lt;code&gt;WasmDatabase&lt;/code&gt;, the same handlers exposed as &lt;code&gt;async fn&lt;/code&gt; with no &lt;code&gt;block_on&lt;/code&gt; in sight.&lt;/p&gt;
&lt;p&gt;What works today: create table, put, get, delete, query, and scan, over base tables and both secondary index types (GSI and LSI). Index maintenance is atomic with the base write, same as native. It&#39;s enough to back a real client-side app.&lt;/p&gt;
&lt;p&gt;What doesn&#39;t, yet: TTL returns a typed &lt;code&gt;Unsupported&lt;/code&gt; error (I haven&#39;t implemented it yet). Streams are planned but not wired - the delivery design is the open question. And &lt;code&gt;TransactWriteItems&lt;/code&gt;, tags, table-setting updates, stats, and bulk import all return a preview &amp;quot;not yet implemented&amp;quot; error. It&#39;s a preview, and I&#39;d rather say so plainly than have you find out mid-build.&lt;/p&gt;
&lt;p&gt;The detail I like most: no cross-origin isolation. SQLite in the browser usually needs COOP/COEP headers, because the common trick makes an async storage API look synchronous through a &lt;code&gt;SharedArrayBuffer&lt;/code&gt;. Dynoxide sidesteps that by running wa-sqlite&#39;s synchronous OPFS VFS inside a Web Worker, where synchronous file handles are available directly.&lt;/p&gt;
&lt;p&gt;So it drops onto ordinary static hosting. No special headers, no cross-origin isolation, no faff.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;npm run build:wasm&lt;/code&gt; gives you a self-contained &lt;code&gt;dist/&lt;/code&gt; of three files, about 1.2 MB total. The harness under &lt;code&gt;harness/&lt;/code&gt; loads the exact same bundled worker a consumer would, so a green harness means the shipping artefact works, not a parallel build that happens to pass.&lt;/p&gt;
&lt;h2&gt;The trait that made it possible&lt;/h2&gt;
&lt;p&gt;You can&#39;t point one engine at both &lt;code&gt;rusqlite&lt;/code&gt; and wa-sqlite if the data layer is welded to &lt;code&gt;rusqlite&lt;/code&gt;. So the real work in 0.10.0 is underneath. There&#39;s a &lt;code&gt;StorageBackend&lt;/code&gt; trait now, and &lt;code&gt;Database&lt;/code&gt; is generic over it - &lt;code&gt;Database&amp;lt;S&amp;gt;&lt;/code&gt;. The native rusqlite backend implements the trait, the browser one is &lt;code&gt;WasmBridgeBackend&lt;/code&gt;, and both go through the same set of SQL builders.&lt;/p&gt;
&lt;p&gt;A query fixed on one is fixed on both. That shared SQL is what lets me trust the browser build without a separate conformance run.&lt;/p&gt;
&lt;p&gt;The handlers went async, because the browser backend is async by nature. Native keeps its old synchronous API. &lt;code&gt;NativeDatabase&lt;/code&gt; drives each handler future to completion with &lt;code&gt;block_on&lt;/code&gt; (via &lt;code&gt;pollster&lt;/code&gt;), and because the native futures never actually suspend, that &lt;code&gt;block_on&lt;/code&gt; never parks a thread.&lt;/p&gt;
&lt;p&gt;It stays safe inside the tokio-based HTTP and MCP servers, and existing native code that names &lt;code&gt;Database&lt;/code&gt; keeps compiling untouched.&lt;/p&gt;
&lt;h2&gt;The Docker image&lt;/h2&gt;
&lt;p&gt;Last time I said a Docker image was &lt;a href=&quot;https://martinhicks.dev/articles/dynoxide-patch-notes-0910-0912&quot;&gt;coming next&lt;/a&gt;. Here it is.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;docker run -p 8000:8000 ghcr.io/nubo-db/dynoxide
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;FROM scratch&lt;/code&gt;, around 5 MB, multi-arch (amd64 and arm64). No shell, no OS, just the static binary. Against DynamoDB Local&#39;s ~225 MB Java image, it&#39;s a clean drop-in for the containerised test suites people actually run, and &lt;a href=&quot;https://github.com/nubo-db/dynoxide/issues/3&quot;&gt;the one someone asked for&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It ships a &lt;code&gt;HEALTHCHECK&lt;/code&gt; backed by a new &lt;code&gt;dynoxide healthcheck&lt;/code&gt; subcommand, so &lt;code&gt;docker ps&lt;/code&gt; and Compose health gates report status without you bolting on &lt;code&gt;curl&lt;/code&gt; or &lt;code&gt;wget&lt;/code&gt; (there&#39;s no shell in the image to run them with anyway). GHCR is the canonical home; Docker Hub and ECR Public get best-effort mirrors on each release.&lt;/p&gt;
&lt;h2&gt;What breaks&lt;/h2&gt;
&lt;p&gt;0.10.0 is the first breaking release, so this part matters.&lt;/p&gt;
&lt;p&gt;If you run the binary, there&#39;s one change to know about: &lt;strong&gt;MCP over HTTP now requires a bearer token on every request.&lt;/strong&gt; A loopback bind generates and persists a token on first run and prints a client-config snippet; a non-loopback bind won&#39;t start without one. Existing HTTP-transport MCP clients break until they send an &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; header. The stdio transport is unchanged, and plain &lt;code&gt;dynoxide serve&lt;/code&gt; (DynamoDB only, no MCP) is unchanged.&lt;/p&gt;
&lt;p&gt;The reason is the obvious one. An MCP server reachable off-loopback with no auth is an open door to whatever it can touch. There&#39;s a &lt;code&gt;SECURITY.md&lt;/code&gt; now that lays out the threat model, the token, and the Host and Origin allowlists behind it.&lt;/p&gt;
&lt;p&gt;If you depend on the Rust crate, there are a few more. &lt;code&gt;DynoxideError&lt;/code&gt; is now &lt;code&gt;#[non_exhaustive]&lt;/code&gt;, so an exhaustive &lt;code&gt;match&lt;/code&gt; needs a &lt;code&gt;_ =&amp;gt;&lt;/code&gt; arm. &lt;code&gt;Database&lt;/code&gt; being generic is source-compatible if you just name &lt;code&gt;Database&lt;/code&gt; (it defaults to the native backend, and there&#39;s a &lt;code&gt;NativeDatabase&lt;/code&gt; alias if you want to be explicit). And &lt;code&gt;mcp::serve_http&lt;/code&gt; takes an &lt;code&gt;HttpOptions&lt;/code&gt; struct now instead of a bare port.&lt;/p&gt;
&lt;h2&gt;The less glamorous half&lt;/h2&gt;
&lt;p&gt;Under the two headline features sits a stack of correctness fixes, most of them surfaced by the &lt;a href=&quot;https://github.com/nubo-db/dynamodb-conformance&quot;&gt;conformance suite&lt;/a&gt; I run alongside dynoxide. A few worth naming:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PartiQL &lt;code&gt;DELETE&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt; now evaluate the whole &lt;code&gt;WHERE&lt;/code&gt; clause, not just the key. The old behaviour could delete a row a filter should have excluded - a genuine data-correctness bug.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DescribeTable&lt;/code&gt; returns a stable &lt;code&gt;TableId&lt;/code&gt; instead of minting a fresh UUID on every call.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Query&lt;/code&gt; and &lt;code&gt;Scan&lt;/code&gt; over a GSI stop dropping items when several entries tie on the index key and the base table has only a partition key.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ConsumedCapacity&lt;/code&gt; now matches AWS on the transactional and PartiQL paths.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Where dynoxide actually stands against real DynamoDB and the other emulators lives at &lt;a href=&quot;https://paritysuite.org/&quot;&gt;paritysuite.org&lt;/a&gt;. It moves as the suite grows and each engine changes, so I&#39;m not going to pin a number here that&#39;s wrong by next week. Go and look at the live one.&lt;/p&gt;
&lt;h2&gt;What&#39;s next&lt;/h2&gt;
&lt;p&gt;Streams in the browser, once I&#39;ve settled how delivery should work without a server to push from. And an npm package for the wasm build, so you can pull it in without wiring up the worker yourself - that path already works, it just isn&#39;t packaged.&lt;/p&gt;
&lt;p&gt;The browser build is the proof I wanted. The engine isn&#39;t tied to one host any more. Native binary, Docker image, or a browser tab: same engine, same SQL, same behaviour.&lt;/p&gt;
</description>
      <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/dynoxide-0100-browser-and-docker</guid>
    </item>
    <item>
      <title>Running iai-callgrind on Apple Silicon</title>
      <link>https://martinhicks.dev/articles/running-iai-callgrind-on-apple-silicon</link>
      <description>&lt;p&gt;My instruction-count benchmarks for &lt;a href=&quot;https://github.com/nubo-db/dynoxide&quot;&gt;Dynoxide&lt;/a&gt;, my embeddable DynamoDB engine in Rust, only ever ran in CI. The wall-clock benchmarks ran happily on my Mac, but the iai-callgrind track (&lt;code&gt;benchmarks/benches/iai_core.rs&lt;/code&gt;) just sat there - because it needs Valgrind, and Valgrind doesn&#39;t run on Apple Silicon. Not &amp;quot;runs badly&amp;quot;, not &amp;quot;needs a flag&amp;quot; - there&#39;s no aarch64 Darwin port at all. So on the machine where I write the code, I was guessing at performance until I pushed.&lt;/p&gt;
&lt;p&gt;Which was a shame, because iai-callgrind is a lovely way to benchmark Rust code. Instead of wall-clock timing, which jitters with whatever else your machine happens to be doing, it counts CPU instructions under Valgrind&#39;s Callgrind. Same input, same count, every time. That makes it brilliant for catching the kind of regression that quietly adds 2% more work - exactly the sort of thing wall-clock noise buries. I wanted that locally, not just as a number CI reported back to me after the fact.&lt;/p&gt;
&lt;p&gt;The fix is Docker. But there are a few catches that&#39;ll cost you an afternoon if nobody tells you about them first, so here they all are.&lt;/p&gt;
&lt;h2&gt;Run it in a native arm64 container&lt;/h2&gt;
&lt;p&gt;Valgrind on aarch64 &lt;em&gt;Linux&lt;/em&gt; works fine. So the move is a native &lt;code&gt;linux/arm64&lt;/code&gt; container, not an emulated x86 one. Emulated x86 Valgrind is punishingly slow - you&#39;d be running an instrumentation engine inside a CPU emulator. A native arm64 container runs at close to CI speed.&lt;/p&gt;
&lt;p&gt;Start a long-lived container with the repo bind-mounted and an isolated target dir on a named volume. Match the &lt;code&gt;rust:&lt;/code&gt; image tag to your project&#39;s toolchain.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;REPO=/absolute/path/to/your/project

docker run -d --name dx-iai --platform linux/arm64 &#92;
  --security-opt seccomp=unconfined &#92;
  -v &amp;quot;$REPO&amp;quot;:/repo &#92;
  -v dx-iai-target:/target &#92;
  -e CARGO_TARGET_DIR=/target -e CARGO_TERM_COLOR=never &#92;
  rust:1.95-bookworm sleep infinity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The isolated &lt;code&gt;CARGO_TARGET_DIR&lt;/code&gt; on a named volume earns its place twice over: it keeps your host &lt;code&gt;target/&lt;/code&gt; clean (it&#39;s a different target triple anyway), and it persists iai-callgrind&#39;s stored results between runs, which is what makes the before/after comparison work later.&lt;/p&gt;
&lt;p&gt;That &lt;code&gt;--security-opt seccomp=unconfined&lt;/code&gt; flag, though, is the one that isn&#39;t optional. Here&#39;s why.&lt;/p&gt;
&lt;h2&gt;The seccomp / personality() trap&lt;/h2&gt;
&lt;p&gt;iai-callgrind runs your benchmark binary under &lt;code&gt;setarch -R&lt;/code&gt; to switch off ASLR - it needs stable memory addresses to get deterministic counts. &lt;code&gt;setarch&lt;/code&gt; does that through the &lt;code&gt;personality()&lt;/code&gt; syscall, and Docker&#39;s default seccomp profile blocks &lt;code&gt;personality()&lt;/code&gt;. Leave the flag off and the run dies with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;setarch: failed to set personality to aarch64: Operation not permitted
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&#39;s a maddening error to land on cold, because nothing about it points at Docker&#39;s security profile. &lt;code&gt;--security-opt seccomp=unconfined&lt;/code&gt; lifts the block, and that single flag is all it needs - Callgrind runs the target on its own synthetic CPU, so there&#39;s no &lt;code&gt;ptrace&lt;/code&gt; and no extra capability to grant.&lt;/p&gt;
&lt;h2&gt;Installing Valgrind and the runner (mind the PATH)&lt;/h2&gt;
&lt;p&gt;Install Valgrind and the iai-callgrind runner inside the container. The runner version has to match the &lt;code&gt;iai-callgrind&lt;/code&gt; crate version pinned in your &lt;code&gt;Cargo.toml&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker exec dx-iai bash -c &#39;
  apt-get update &amp;amp;&amp;amp; apt-get install -y valgrind &amp;amp;&amp;amp;
  cargo install iai-callgrind-runner --version 0.14.2
&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the &lt;code&gt;bash -c&lt;/code&gt;, not &lt;code&gt;bash -lc&lt;/code&gt;. A login shell re-runs &lt;code&gt;/etc/profile&lt;/code&gt;, which resets &lt;code&gt;PATH&lt;/code&gt; and drops the rust image&#39;s cargo bin off the end of it. Use a login shell here and &lt;code&gt;cargo&lt;/code&gt; comes back &amp;quot;command not found&amp;quot; - which is a genuinely baffling thing to debug when you can see cargo sitting right there in the image.&lt;/p&gt;
&lt;h2&gt;Running the benchmarks&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker exec dx-iai bash -c &#39;cd /repo/benchmarks &amp;amp;&amp;amp; cargo bench --bench iai_core --features iai-callgrind&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two more traps:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don&#39;t pass &lt;code&gt;--locked&lt;/code&gt;.&lt;/strong&gt; Resolving the iai-callgrind feature wants to touch &lt;code&gt;Cargo.lock&lt;/code&gt;, and &lt;code&gt;--locked&lt;/code&gt; aborts the whole run before it builds a single thing. The run will modify &lt;code&gt;Cargo.lock&lt;/code&gt; as a side effect; if you&#39;d rather not carry that change, reset it afterwards with &lt;code&gt;git checkout -- benchmarks/Cargo.lock&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don&#39;t pipe the run through &lt;code&gt;| tail&lt;/code&gt; if you care whether it passed.&lt;/strong&gt; The pipe swallows cargo&#39;s exit code, so a failed run cheerfully reports success. Ask me how I know.&lt;/p&gt;
&lt;h2&gt;Before and after&lt;/h2&gt;
&lt;p&gt;iai-callgrind stores results per benchmark in the target dir and auto-compares each run against the last. So the workflow is simple: run on your baseline, change the code, run again. The second run prints the delta per metric - &lt;code&gt;-1.54%&lt;/code&gt;, &lt;code&gt;[-1.01560x]&lt;/code&gt; and so on. The only requirement is that both runs share the same target dir, which is exactly why the setup above parks it on a named volume.&lt;/p&gt;
&lt;p&gt;To compare two branches or commits, check the ref out in the host repo between runs. The container sees the change through the bind mount, no restart needed.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git -C &amp;quot;$REPO&amp;quot; checkout &amp;lt;baseline-ref&amp;gt;
docker exec dx-iai bash -c &#39;cd /repo/benchmarks &amp;amp;&amp;amp; cargo bench --bench iai_core --features iai-callgrind&#39;   # baseline
git -C &amp;quot;$REPO&amp;quot; checkout &amp;lt;changed-ref&amp;gt;
docker exec dx-iai bash -c &#39;cd /repo/benchmarks &amp;amp;&amp;amp; cargo bench --bench iai_core --features iai-callgrind&#39;   # prints the delta
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The build cache lives in the target volume, so only the crates you actually changed recompile between runs.&lt;/p&gt;
&lt;h2&gt;The caveat that actually matters&lt;/h2&gt;
&lt;p&gt;Here&#39;s the one to keep in your head, because it&#39;s the difference between useful numbers and a wild goose chase: arm64 Callgrind instruction counts are not the same as x86 counts. Different architecture, different instructions, different totals. The numbers from your container won&#39;t line up with the x86 figures your CI publishes, and they&#39;re not meant to.&lt;/p&gt;
&lt;p&gt;That&#39;s fine, as long as you use them for what they&#39;re good at. iai-callgrind on your Mac is for &lt;em&gt;relative&lt;/em&gt; comparison - before versus after, on the same machine and the same arch. It is not for checking against an absolute figure from a different architecture. Treat the container&#39;s counts as &amp;quot;did my change make this cheaper or dearer&amp;quot;, never as &amp;quot;does this match the published number&amp;quot;. Get that backwards and you&#39;ll spend time chasing a regression that only exists because you compared arm64 against x86.&lt;/p&gt;
&lt;h2&gt;Cleanup&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker rm -f dx-iai
docker volume rm dx-iai-target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&#39;s it: a native arm64 container, seccomp unconfined so &lt;code&gt;setarch&lt;/code&gt; can disable ASLR, the right shell so cargo stays on &lt;code&gt;PATH&lt;/code&gt;, and a clear head about relative versus absolute counts. None of it is hard once you know the traps. It&#39;s finding the traps that costs you the afternoon.&lt;/p&gt;
&lt;p&gt;If you want to see what&#39;s actually being measured, the &lt;a href=&quot;https://github.com/nubo-db/dynoxide/tree/main/benchmarks&quot;&gt;iai-callgrind suite lives in the Dynoxide repo&lt;/a&gt;. And if DynamoDB tooling is your sort of thing, &lt;a href=&quot;https://dynoxide.dev/&quot;&gt;Dynoxide itself&lt;/a&gt; might be worth a look.&lt;/p&gt;
</description>
      <pubDate>Thu, 28 May 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/running-iai-callgrind-on-apple-silicon</guid>
    </item>
    <item>
      <title>How close is your DynamoDB emulator to AWS?</title>
      <link>https://martinhicks.dev/articles/dynamodb-conformance-org</link>
      <description>&lt;p&gt;My DynamoDB conformance suite has a site now: &lt;a href=&quot;https://paritysuite.org/&quot;&gt;paritysuite.org&lt;/a&gt;. Eight emulators, 684 tests, scored against live AWS DynamoDB.&lt;/p&gt;
&lt;p&gt;The README only ever showed a single snapshot, frozen at whenever I last updated the table. The site shows everything around that snapshot. The homepage is the standings - every target ranked by how closely it matches real DynamoDB, with DynamoDB itself pinned at the top on a flat 100%. Each emulator gets its own page with run-over-run history - not just where a score landed, but where it moved and which tests pushed it. There&#39;s a support matrix that breaks every operation down target by target, and a runs archive going back through each scoring pass. Want to know whether LocalStack handles transactions, or where DynamoDB Local diverges on error wording? It&#39;s a couple of clicks now, not a repo checkout.&lt;/p&gt;
&lt;h2&gt;How it works&lt;/h2&gt;
&lt;p&gt;Every test runs against live AWS DynamoDB first. Whatever the real thing does is recorded as the expected answer, and an emulator only passes if it gives that same answer. The ground truth is never my reading of the docs - it&#39;s what DynamoDB actually returned when I asked it.&lt;/p&gt;
&lt;p&gt;The results split into three tiers, because one number hides too much. &lt;strong&gt;Core&lt;/strong&gt; is the everyday stuff: CRUD, queries, scans, batch operations. &lt;strong&gt;Complete&lt;/strong&gt; adds the documented-but-less-common features like transactions, PartiQL, TTL and streams. &lt;strong&gt;Strict&lt;/strong&gt; is the fiddly end - validation ordering, exact error wording, API limits, legacy shapes. A gap in Core breaks your app. A gap in Strict breaks the CI test that asserts on an error message.&lt;/p&gt;
&lt;p&gt;Everything is checked through the standard AWS SDK against the target&#39;s HTTP endpoint. Nothing reaches inside the implementation. If your application would see it through the SDK, the suite checks it. If it wouldn&#39;t, it doesn&#39;t care.&lt;/p&gt;
&lt;p&gt;And the suite grows. New tests land most weeks, so a score can move because the target changed or because I added coverage that exposed an old gap. A high score only means the target passes the tests that exist; behaviours the suite doesn&#39;t cover yet are blind spots, not passes. The &lt;a href=&quot;https://paritysuite.org/methodology&quot;&gt;methodology page&lt;/a&gt; has the full version.&lt;/p&gt;
&lt;h2&gt;What this run shows&lt;/h2&gt;
&lt;p&gt;The figures below come from the run dated 26 May 2026 - the site always shows the current ones.&lt;/p&gt;
&lt;p&gt;Take LocalStack. It sits at 88% overall, which reads as middling until you split it: 98.6% on Core, 68.7% on Strict. That&#39;s not a mediocre emulator, it&#39;s a genuinely good one for everyday work that comes apart on error fidelity. The headline number flattens two completely different shapes of gap into one figure, which is precisely why the tiers exist. DynamoDB Local lands in almost exactly the same place - 97.7% Core, 69.2% Strict - which is no coincidence: LocalStack runs it under the hood. Same engine, same gaps, right down to the tier split.&lt;/p&gt;
&lt;p&gt;This run also shows the suite breathing. It grew by 29 tests this pass, and the biggest movers all went &lt;em&gt;down&lt;/em&gt; - Floci off 1.3 points, Dynalite 1.2, ExtendDB 1.1. Nothing regressed. The new tests just went looking where the old ones hadn&#39;t. That&#39;s the suite doing its job: coverage sharpens, scores dip, targets fix the gaps and climb back.&lt;/p&gt;
&lt;p&gt;One result is worth singling out. Ask real DynamoDB for item collection metrics on a write with &lt;code&gt;ReturnItemCollectionMetrics: SIZE&lt;/code&gt; and it hands them straight back. Ask DynamoDB Local, LocalStack, Dynalite or Ministack and you get silence - a documented response field they simply don&#39;t implement, AWS&#39;s own emulator included. Dynoxide, ExtendDB and Floci return it correctly. You&#39;d never know unless something was checking.&lt;/p&gt;
&lt;h2&gt;Run it yourself&lt;/h2&gt;
&lt;p&gt;The whole thing is Apache-2.0 and &lt;a href=&quot;https://github.com/nubo-db/dynamodb-conformance&quot;&gt;every test is in the repo&lt;/a&gt;. Anything that speaks the DynamoDB HTTP API can be scored, so if you maintain an emulator, clone it, point it at your endpoint and see what comes back. You&#39;ll almost certainly find something. If you&#39;d rather just see your emulator on the board, there&#39;s a &lt;a href=&quot;https://github.com/nubo-db/dynamodb-conformance/issues/new?template=suggest-target.yml&quot;&gt;suggest a target&lt;/a&gt; form.&lt;/p&gt;
&lt;p&gt;If you spot a behaviour the suite doesn&#39;t cover yet, send a PR. The single rule is that the test has to pass against real DynamoDB first - if AWS rejects it, the test is wrong, not the emulator. That rule is the whole point. Targets are measured against real DynamoDB, never against each other, so two emulators agreeing on the same wrong answer can&#39;t quietly make it the standard. The suite is meant to be an independent reference the whole field can trust, not something any one project owns. No emulator author, me included, gets to mark their own homework.&lt;/p&gt;
&lt;p&gt;And the field is genuinely getting good. Floci has come a long way since late April, from around 61% to the low 90s, with its Tier 3 score hauled up out of the twenties. ExtendDB only joined in late May and landed near the top straight away. It&#39;s an AWS-managed open-source project, Postgres-backed, that speaks the DynamoDB wire protocol. Worth a look.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://dynoxide.dev/&quot;&gt;Dynoxide&lt;/a&gt;, my own engine, sits in there on the same terms as the rest: same tests, no favours. Floci and ExtendDB are a couple of points behind and still climbing, and the whole field is closing on real DynamoDB.&lt;/p&gt;
</description>
      <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/dynamodb-conformance-org</guid>
    </item>
    <item>
      <title>Filing my first security advisory</title>
      <link>https://martinhicks.dev/articles/filing-my-first-security-advisory</link>
      <description>&lt;p&gt;Yesterday I logged into GitHub and noticed a Dependabot alert sitting on dynoxide. DNS rebinding CVE in &lt;code&gt;rmcp&lt;/code&gt;, a transitive dependency. The alert had been raised a few days earlier - I just hadn&#39;t seen it, because I&#39;d never set up email notifications for Dependabot on that repo. First lesson of the day, before the actual lesson of the day.&lt;/p&gt;
&lt;p&gt;I worked through the fix and published a GitHub Security Advisory once the patch release was out. The fix itself was the bit I knew how to do. The GHSA was new ground, and that&#39;s the part I want to write about, because if you maintain something with users and you&#39;ve never filed one, the process is less daunting than I&#39;d built it up to be.&lt;/p&gt;
&lt;h2&gt;The notification gap&lt;/h2&gt;
&lt;p&gt;Worth dealing with this one first.&lt;/p&gt;
&lt;p&gt;Dependabot raises alerts on your repo&#39;s Security tab automatically. By default, GitHub doesn&#39;t email you about them - they appear on the dashboard and that&#39;s it. If you don&#39;t log into the affected repo regularly, you won&#39;t see them.&lt;/p&gt;
&lt;p&gt;The fix is at the repo level. Top right of the repo page, hit Watch → Custom, then tick Security alerts. You&#39;ll start getting emails for security events on that repo.&lt;/p&gt;
&lt;p&gt;I can see why this isn&#39;t the default. If you&#39;ve forked a load of old projects you don&#39;t actually maintain, the last thing you want is an inbox full of alerts for repos that aren&#39;t yours. The cost of that default is that on the repos you &lt;em&gt;do&lt;/em&gt; maintain, you have to opt in deliberately. I hadn&#39;t.&lt;/p&gt;
&lt;p&gt;There&#39;s a second timing thing worth knowing about. Even with email notifications set up, there&#39;s a gap between the upstream CVE being published and your Dependabot alert firing. For mine, the upstream rmcp advisory was published on 29 April. My Dependabot alert came through about nine days later. GitHub&#39;s Advisory Database reviews advisories before they propagate, and Dependabot itself runs on a scan schedule. The delay is mostly outside your control. Knowing it exists at least means you don&#39;t panic when you discover the upstream advisory predates your own alert.&lt;/p&gt;
&lt;h2&gt;What a GHSA actually is&lt;/h2&gt;
&lt;p&gt;A GHSA is a structured record on your repo&#39;s Security tab. Affected package, affected version range, fixed version, severity, CVSS vector, weakness category, description. The structure matters because of what happens when you publish it: GitHub&#39;s advisory database picks it up, and every downstream project that depends on your package gets a Dependabot alert. Often an automated upgrade PR.&lt;/p&gt;
&lt;p&gt;In my case this was the whole point. The named CVE lives in &lt;code&gt;rmcp&lt;/code&gt;. Anyone with a direct rmcp dependency already had an alert from the upstream advisory. But dynoxide users have rmcp transitively - it&#39;s in the lockfile, not the manifest - and the upstream alert doesn&#39;t fire for them. Without a dynoxide-specific GHSA naming the affected version range of &lt;code&gt;dynoxide-rs&lt;/code&gt; and &lt;code&gt;dynoxide&lt;/code&gt;, nothing reaches those users automatically.&lt;/p&gt;
&lt;p&gt;The GHSA isn&#39;t ceremony. It&#39;s the actual distribution mechanism.&lt;/p&gt;
&lt;h2&gt;Order of operations&lt;/h2&gt;
&lt;p&gt;Release first, advisory second.&lt;/p&gt;
&lt;p&gt;The advisory references the patched version. Dependabot&#39;s upgrade PRs for downstream projects will fail if that version doesn&#39;t exist on the registry yet. If you publish the GHSA before the release is live, you&#39;ve broadcast &amp;quot;upgrade to a version that doesn&#39;t exist&amp;quot; to every project that depends on you.&lt;/p&gt;
&lt;p&gt;So: cut the release, wait for CI to push to the registries, verify the patched version is actually available, then publish the advisory. The save-as-draft option on the GHSA form is there for this reason.&lt;/p&gt;
&lt;p&gt;Last step is the social post, after opening the GHSA URL in a private window to check a logged-out viewer can actually see it. Until you hit publish, the advisory is only visible to repo admins and the URL returns nothing useful to anyone else.&lt;/p&gt;
&lt;h2&gt;Filing the form&lt;/h2&gt;
&lt;p&gt;The form lives at &lt;code&gt;Security → Advisories → New draft security advisory&lt;/code&gt;. A few things that tripped me up.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ecosystem.&lt;/strong&gt; The dropdown doesn&#39;t list &amp;quot;cargo&amp;quot; - it lists &amp;quot;Rust&amp;quot;. I scrolled past it twice. The mapping is to crates.io under the hood. npm is named npm.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Package names.&lt;/strong&gt; Must match the registry exactly or Dependabot doesn&#39;t fire. GitHub validates this and shows a small &amp;quot;Package name found on Rust&amp;quot; confirmation when it&#39;s right. For dynoxide that&#39;s &lt;code&gt;dynoxide-rs&lt;/code&gt; (the Cargo crate, because the name &lt;code&gt;dynoxide&lt;/code&gt; was already taken) and &lt;code&gt;dynoxide&lt;/code&gt; (npm).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Version ranges.&lt;/strong&gt; Cargo-style semver: &lt;code&gt;&amp;gt;= 0.9.3, &amp;lt; 0.9.13&lt;/code&gt;. I started at 0.9.3 because that&#39;s when the MCP HTTP transport landed. Earlier versions don&#39;t expose the vulnerable surface, so flagging them would just generate noise alerts for users on older versions who aren&#39;t actually affected.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CVE.&lt;/strong&gt; The upstream CVE-2026-42559 already exists. There&#39;s a field for &amp;quot;I have an existing CVE ID&amp;quot; - use it. Don&#39;t request a new one for the same root cause.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CVSS.&lt;/strong&gt; This was the field I felt least sure about. There&#39;s an in-form calculator and I had a go at deriving my own vector for dynoxide&#39;s exposure context. I marked Attack Complexity as High because the rebinding chain felt fiddly to me. That was wrong - upstream marked it Low, and Low is right, because public DNS rebinding tooling makes the attack cheap to execute. My score came out at 7.5 instead of upstream&#39;s 8.8.&lt;/p&gt;
&lt;p&gt;The mistake was easy to make and easy to fix (GHSAs are editable after publishing), but it would have saved me both the agonising and the error to just match the upstream score in the first place. For a transitive-dep advisory where the exposure mechanism is essentially the same as upstream, divergent scores just confuse downstream readers. Match upstream unless you&#39;ve got a strong reason not to.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CWE.&lt;/strong&gt; Weakness category. The upstream advisory used CWE-346 (Origin Validation Error) and CWE-350 (Reliance on Reverse DNS Resolution). Same advice as CVSS: match upstream rather than reasoning from first principles.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Description.&lt;/strong&gt; Markdown. I used Summary, Impact, Patches, Workarounds, References. The Workarounds section is the one I&#39;d most strongly suggest including - if a user can&#39;t upgrade immediately, knowing how to mitigate is more useful than knowing the CVSS score. For dynoxide that was &amp;quot;use the stdio transport, don&#39;t pass &lt;code&gt;--http&lt;/code&gt;.&amp;quot;&lt;/p&gt;
&lt;h2&gt;The bug&lt;/h2&gt;
&lt;p&gt;The named CVE is DNS rebinding. You&#39;re running &lt;code&gt;dynoxide mcp --http&lt;/code&gt; so your coding agent can talk to a local DynamoDB. You visit a malicious page in another tab. The page&#39;s JavaScript spoofs the Host header in requests to &lt;code&gt;127.0.0.1:PORT/mcp&lt;/code&gt; and the server processes them. The attacker can call any tool the running dynoxide instance exposes, including writes.&lt;/p&gt;
&lt;p&gt;The rmcp 1.4+ fix is a Host-header allowlist: &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;127.0.0.1&lt;/code&gt;, &lt;code&gt;::1&lt;/code&gt;, anything else gets a 403. Upgrade rmcp, done.&lt;/p&gt;
&lt;p&gt;While I was reading the patched rmcp source to make sure I understood what the upgrade actually changed, I clocked something. The Host check closes rebinding. It doesn&#39;t close cross-origin CSRF.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;fetch(&#39;http://127.0.0.1:PORT/mcp&#39;, {
  mode: &#39;no-cors&#39;,
  method: &#39;POST&#39;,
  body: JSON.stringify({ /* call put_item */ })
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The browser sends &lt;code&gt;Host: 127.0.0.1&lt;/code&gt; - legitimately, because that&#39;s the URL it&#39;s connecting to - and &lt;code&gt;Origin: https://evil.com&lt;/code&gt;. The Host check passes. There&#39;s no Origin check by default. The request goes through.&lt;/p&gt;
&lt;p&gt;A pedant&#39;s note: a no-cors POST with a JSON body actually can&#39;t set &lt;code&gt;Content-Type: application/json&lt;/code&gt; (browsers strip it down to &lt;code&gt;text/plain&lt;/code&gt;), so rmcp&#39;s protocol-level Accept and Content-Type checks would bounce this exact request before tool execution. The point still stands. Content-type validation isn&#39;t a security boundary - alternative request shapes that sidestep no-cors&#39;s restrictions (form POSTs, future MCP transport variants) reach the handler with valid framing. Origin is the right layer to enforce same-origin, not content-type.&lt;/p&gt;
&lt;p&gt;This isn&#39;t a flaw in the upstream fix. The CVE is narrowly scoped to DNS rebinding, and the fix closes it. Cross-origin CSRF is a different shape of attack on the same transport surface, and the upstream rmcp team have it tracked as a defence-in-depth follow-up. rmcp 1.6.0 already ships an &lt;code&gt;allowed_origins&lt;/code&gt; field on the same config - it just defaults to empty, which means &amp;quot;skip validation&amp;quot;. Same pattern Host had before 1.4.&lt;/p&gt;
&lt;p&gt;So dynoxide 0.9.13 sets both lists explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;c.allowed_hosts = vec![&amp;quot;localhost&amp;quot;.into(), &amp;quot;127.0.0.1&amp;quot;.into(), &amp;quot;::1&amp;quot;.into()];
c.allowed_origins = vec![&amp;quot;http://localhost&amp;quot;.into(), &amp;quot;http://127.0.0.1&amp;quot;.into()];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Plus a regression test covering all three paths: loopback Host with no Origin (passes, because native MCP clients don&#39;t send Origin), foreign Host (403), foreign Origin (403). The test asserts on the rejection message, not just the status code, so a future rmcp change that returns 403 for some other reason can&#39;t keep the test green while reopening the vulnerability.&lt;/p&gt;
&lt;h2&gt;What I&#39;d tell past-me&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The advisory is a positive signal.&lt;/strong&gt; I was anxious about publishing it. Felt like it made dynoxide look amateur, like I was admitting fault. The opposite is true. Projects with zero advisories either have no users or aren&#39;t handling issues responsibly. Filing one is what well-run projects do.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Match the upstream CVSS and CWEs.&lt;/strong&gt; Already covered above. The recalculation is defensible in theory but the consistent practice is simpler, faster, and less error-prone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Turn on the email notifications.&lt;/strong&gt; Worth the small ongoing noise on the repos you maintain.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;One thing the whole exercise sharpened for me: the HTTP transport doesn&#39;t authenticate callers at all. The Host and Origin allowlists defend against browsers, but anything on the same machine that can reach the loopback port can call any tool. Worth saying why.&lt;/p&gt;
&lt;h2&gt;Why no auth (yet)&lt;/h2&gt;
&lt;p&gt;The MCP HTTP transport doesn&#39;t authenticate callers. That was a deliberate choice, not an oversight. dynoxide&#39;s main job is to be a small fast binary running in a CI job or on your own machine - both isolated environments where the only thing that can reach the loopback port is the process that launched it. The threat model is browser-borne attacks against that port, which is exactly what the Host and Origin allowlists handle. Adding token-based auth on top buys you very little: you&#39;d be issuing yourself credentials to talk to yourself, and self-issued credentials tend to end up in dotfiles or pasted somewhere they shouldn&#39;t be.&lt;/p&gt;
&lt;p&gt;What&#39;s changed the calculation is the Docker image I&#39;ve been working on. A container is a different deployment shape: it&#39;s something you can hand to another developer, drop into a shared environment, or expose on a network where the caller isn&#39;t the process that launched it. The trust boundary widens and the assumptions behind shipping without auth stop holding.&lt;/p&gt;
&lt;p&gt;So auth is the next piece of work. There&#39;s more design space here than I expected - the MCP spec is OAuth 2.1 or nothing, rmcp doesn&#39;t ship an auth hook of its own, and the path that actually fits a single-user local tool (a pre-shared bearer token wired in as a tower middleware) isn&#39;t really what the spec contemplates. More on that when it lands.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;dynoxide &lt;a href=&quot;https://github.com/nubo-db/dynoxide/releases/tag/v0.9.13&quot;&gt;0.9.13&lt;/a&gt; is out. Upgrade if you&#39;re running &lt;code&gt;dynoxide mcp --http&lt;/code&gt; or &lt;code&gt;dynoxide serve --mcp&lt;/code&gt;. The advisory is at &lt;a href=&quot;https://github.com/nubo-db/dynoxide/security/advisories/GHSA-fvh2-gm75-j4j7&quot;&gt;GHSA-fvh2-gm75-j4j7&lt;/a&gt; if you want a look at the finished form. If you&#39;ve never filed a GHSA and you&#39;ve got the option to file a draft on a private repo just to see the form, that&#39;s the cheapest way to take the unknown out of it before you have to do it for real.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Update (evening, same day)&lt;/h2&gt;
&lt;p&gt;One useful incidental find since publishing this article earlier this afternoon...&lt;/p&gt;
&lt;p&gt;The RustSec → GHSA import is one-way: the GitHub Advisory Database imports from RustSec, not the other way round. So if you filed your advisory via the GitHub repository security advisory flow (as I did), it lands in GHSA but not RustSec. Anyone running &lt;code&gt;cargo audit&lt;/code&gt; or &lt;code&gt;cargo deny&lt;/code&gt; doesn&#39;t get an alert.&lt;/p&gt;
&lt;p&gt;Fix: file a RustSec advisory at &lt;a href=&quot;https://github.com/rustsec/advisory-db&quot;&gt;rustsec/advisory-db&lt;/a&gt;. It&#39;s a markdown PR. Takes about 20 minutes. Closes a real gap for Rust users and gives you a second distribution channel for any future advisory.&lt;/p&gt;
&lt;p&gt;My PR is &lt;a href=&quot;https://github.com/rustsec/advisory-db/pull/2852&quot;&gt;rustsec/advisory-db#2852&lt;/a&gt; if you want a worked example.&lt;/p&gt;
</description>
      <pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/filing-my-first-security-advisory</guid>
    </item>
    <item>
      <title>Dynoxide patch notes: 0.9.10 to 0.9.12</title>
      <link>https://martinhicks.dev/articles/dynoxide-patch-notes-0910-0912</link>
      <description>&lt;p&gt;Dynoxide now lives entirely in &lt;a href=&quot;https://github.com/nubo-db/dynoxide&quot;&gt;its own public repo&lt;/a&gt;, out of the private workspace it shared with &lt;a href=&quot;https://nubo.sinovi.uk/&quot;&gt;Nubo&lt;/a&gt;. One repo, one CI pipeline, no shuttling private commits to test against the public version. That&#39;s the work behind 0.9.10 that doesn&#39;t show up in the changelog.&lt;/p&gt;
&lt;p&gt;Three patch releases since.&lt;/p&gt;
&lt;h2&gt;0.9.10: making error messages actually match&lt;/h2&gt;
&lt;p&gt;v0.9.10 closed 16 places where dynoxide&#39;s error strings drifted from real DynamoDB.&lt;/p&gt;
&lt;p&gt;Dynoxide&#39;s whole pitch is &amp;quot;behaves like AWS DynamoDB&amp;quot;. Easy to claim and hard to keep. AWS doesn&#39;t publish a spec for the error strings their SDK clients see; they just emit them, and downstream code parses them.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/nubo-db/dynamodb-conformance&quot;&gt;dynamodb-conformance&lt;/a&gt; project I run alongside dynoxide has been growing a tier-3 suite that asserts on exact error strings. Three rungs of strictness:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Rung 1: literal exact match&lt;/li&gt;
&lt;li&gt;Rung 2: interpolated exact match, with test fixture values substituted in&lt;/li&gt;
&lt;li&gt;Rung 3: anchored regex around the bits AWS controls (e.g. the Java &lt;code&gt;toString&lt;/code&gt; dump of a request body)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;601 tests now. Things you only notice when you assert:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tableName&lt;/code&gt; length validation is per-operation - 1 char on read/write, 3 on &lt;code&gt;CreateTable&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Select&lt;/code&gt; enum order matches AWS rather than alphabetical&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Query&lt;/code&gt; and &lt;code&gt;Scan&lt;/code&gt; &lt;code&gt;Limit=0&lt;/code&gt; messages are deliberately different&lt;/li&gt;
&lt;li&gt;batch and transact empty/oversize requests use the standard &lt;code&gt;1 validation error detected: Value &#39;...&#39; at &#39;X&#39; failed to satisfy constraint: ...&lt;/code&gt; envelope&lt;/li&gt;
&lt;li&gt;&lt;code&gt;UpdateExpression&lt;/code&gt; syntax errors include the AWS &lt;code&gt;near: &amp;quot;...&amp;quot;&lt;/code&gt; window&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Plus one real bug. &lt;code&gt;TransactGetItems&lt;/code&gt; with a missing key was returning HTTP 500 instead of &lt;code&gt;TransactionCanceledException&lt;/code&gt; with a &lt;code&gt;ValidationError&lt;/code&gt; cancellation reason. The dedup loop was calling the server-fault helper before key validation. That&#39;s the kind of bug you only catch when your test suite holds error messages to byte-strictness.&lt;/p&gt;
&lt;h2&gt;0.9.11: MCP server hanging on Ctrl+C&lt;/h2&gt;
&lt;p&gt;v0.9.11 fixed dynoxide hanging on Ctrl+C when an MCP client (Claude Code, Cursor) was connected.&lt;/p&gt;
&lt;p&gt;In a side project, dynoxide runs alongside four other processes via &lt;code&gt;concurrently&lt;/code&gt;: two seed scripts and two vite servers. Plus Claude Code with &lt;code&gt;.mcp.json&lt;/code&gt; pointing at dynoxide&#39;s MCP endpoint.&lt;/p&gt;
&lt;p&gt;Ctrl+C to stop the dev session. &lt;code&gt;npm run dev&lt;/code&gt; again.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;error: failed to bind 127.0.0.1:8000: Address already in use
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A second Ctrl+C would unstick it. Sometimes a &lt;code&gt;pkill -9 dynoxide&lt;/code&gt;. Annoying.&lt;/p&gt;
&lt;p&gt;The MCP server&#39;s shutdown path was using axum&#39;s &lt;code&gt;with_graceful_shutdown(...)&lt;/code&gt;, which drains in-flight connections before returning. Fine for the short-lived AWS-SDK requests on the HTTP side. Not fine for Streamable-HTTP MCP sessions that Claude Code holds open indefinitely. The drain phase waited forever, so the dynoxide process hung, so the port stayed bound, so the next &lt;code&gt;npm run dev&lt;/code&gt; failed.&lt;/p&gt;
&lt;p&gt;The fix: race the cancellation token against the serve future and drop the future on cancel. The listener closes, the function returns, the surrounding &lt;code&gt;tokio::join!&lt;/code&gt; proceeds, the process exits. No drain phase.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;tokio::select! {
    res = serve_fut =&amp;gt; res?,
    _ = ct.cancelled_owned() =&amp;gt; {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The HTTP server keeps &lt;code&gt;with_graceful_shutdown&lt;/code&gt; because AWS-SDK requests drain in milliseconds.&lt;/p&gt;
&lt;h2&gt;0.9.12: TIME_WAIT and the case of the still-bound port&lt;/h2&gt;
&lt;p&gt;v0.9.12 fixed port 8000 staying bound for around 60 seconds after a clean shutdown, if anything had connected during the session.&lt;/p&gt;
&lt;p&gt;Shipped 0.9.11. Tested in the same project. Got the same error.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;error: failed to bind 127.0.0.1:8000: Address already in use
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But this time the dynoxide process had exited cleanly. &amp;quot;Shutting down...&amp;quot; printed, exit code 0. So why was the port still held?&lt;/p&gt;
&lt;p&gt;&lt;code&gt;netstat -an | grep 8000&lt;/code&gt; showed it. &lt;code&gt;TIME_WAIT&lt;/code&gt; entries. Both directions. The seed scripts had opened TCP connections to dynoxide during the run, those went through &lt;code&gt;TIME_WAIT&lt;/code&gt; on close, and the kernel sits on &lt;code&gt;TIME_WAIT&lt;/code&gt; for around 60 seconds so stray packets can&#39;t land on a fresh listener.&lt;/p&gt;
&lt;p&gt;dynoxide&#39;s listener was using &lt;code&gt;socket2&lt;/code&gt; with a comment that read:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;// Deliberately do NOT set SO_REUSEADDR - this is the whole point.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The comment was wrong. &lt;code&gt;SO_REUSEADDR&lt;/code&gt; doesn&#39;t allow two live listeners to share a port. That&#39;s &lt;code&gt;SO_REUSEPORT&lt;/code&gt;. &lt;code&gt;SO_REUSEADDR&lt;/code&gt; only bypasses &lt;code&gt;TIME_WAIT&lt;/code&gt; entries. The port-conflict detection (a TCP connect probe before the bind) still works either way.&lt;/p&gt;
&lt;p&gt;One-line fix:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;#[cfg(unix)]
socket.set_reuse_address(true)?;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Gated to Unix because Windows is different. &lt;code&gt;SO_REUSEADDR&lt;/code&gt; on Windows lets another process hijack an active bind, which is the opposite of what you want. The Windows-correct answer is &lt;code&gt;SO_EXCLUSIVEADDRUSE&lt;/code&gt;, and that&#39;s a different change for a different release.&lt;/p&gt;
&lt;p&gt;After 0.9.12: &lt;code&gt;npm run dev&lt;/code&gt;, Ctrl+C, &lt;code&gt;npm run dev&lt;/code&gt;. Instant.&lt;/p&gt;
&lt;h2&gt;Coming next: a Docker image&lt;/h2&gt;
&lt;p&gt;Someone&#39;s &lt;a href=&quot;https://github.com/nubo-db/dynoxide/issues/3&quot;&gt;asked for an official Docker image&lt;/a&gt;. Hadn&#39;t crossed my mind. If you&#39;re already running a Docker-based dev or CI workflow around DynamoDB Local, a &lt;code&gt;FROM scratch&lt;/code&gt; dynoxide image is an easy drop-in: ~5 MB against the ~225 MB Java image. The release pipeline already builds static linux-musl binaries for npm and Homebrew, so the Docker work is wrapping one of those in a &lt;code&gt;FROM scratch&lt;/code&gt; layer. No shell. No OS. Just the binary.&lt;/p&gt;
&lt;h2&gt;What I take from this&lt;/h2&gt;
&lt;p&gt;If I hadn&#39;t pushed dynoxide to byte-exact error matching, I wouldn&#39;t trust it as a drop-in for DynamoDB Local. I wouldn&#39;t have used it for real. I wouldn&#39;t have hit either of these bugs. The strictness of the conformance work is what gave me the licence to use it in anger.&lt;/p&gt;
&lt;p&gt;And the other thing: comments lie. &amp;quot;This is the whole point&amp;quot; had been sitting in the code since at least v0.9.7. It looked authoritative. It was wrong. The bug stayed latent until I started running dynoxide next to scripts that opened a few thousand TCP connections in succession.&lt;/p&gt;
&lt;p&gt;Worth questioning the &amp;quot;deliberately&amp;quot; comments in your own code. Especially the ones written by past-you.&lt;/p&gt;
</description>
      <pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/dynoxide-patch-notes-0910-0912</guid>
    </item>
    <item>
      <title>A year with my Intel N100 home server: what changed</title>
      <link>https://martinhicks.dev/articles/n100-home-server-a-year-on</link>
      <description>&lt;p&gt;It&#39;s been a year since I &lt;a href=&quot;https://martinhicks.dev/articles/n100-home-server-build&quot;&gt;built my Intel N100 home server&lt;/a&gt;, and a few things have happened since I wrote that post. Some of what I set up is still humming along untouched. Some of it I quietly tore out. And some of what I added wasn&#39;t on the original plan at all.&lt;/p&gt;
&lt;p&gt;This is the honest &amp;quot;what I&#39;d actually do differently&amp;quot; follow-up.&lt;/p&gt;
&lt;h2&gt;The boring infrastructure won&lt;/h2&gt;
&lt;p&gt;The most useful thing I can report is how little I&#39;ve thought about this server. It&#39;s been up for 50+ days at a stretch between reboots - most recently for kernel updates, not because anything broke. Total downtime in a year has been under an hour, all of it self-inflicted.&lt;/p&gt;
&lt;p&gt;The services I set up first are still the ones earning their keep:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AdGuard Home&lt;/strong&gt; is invisible until I open the dashboard. The kids&#39; devices, our laptops, the smart TV - they all quietly stop talking to ad and tracker domains, and nobody in the household notices except when I show off the daily query log.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WireGuard&lt;/strong&gt; for the kids&#39; devices does what I built it to do. They get filtered DNS at school, at friends&#39; houses, on mobile data. I have not had to debug it once.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time Machine over SMB&lt;/strong&gt; has been completely reliable. Three Macs, no failed backups, no sparsebundle corruption.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That alone makes the £250 build worth it for me.&lt;/p&gt;
&lt;h2&gt;What I removed: Unbound&lt;/h2&gt;
&lt;p&gt;The biggest change to the original design was disabling &lt;strong&gt;Unbound&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The premise was great: AdGuard handles filtering, Unbound handles recursive resolution from the root servers down, no single upstream provider sees my full browsing history. Privacy through distribution.&lt;/p&gt;
&lt;p&gt;In practice, the latency was a problem. Popular domains were fine - they cache locally and return instantly. But for the long tail of less-popular domains, recursive resolution means walking the DNS tree on demand, and each hop is a real network round-trip from a UK home connection to wherever the next nameserver lives. By the time you&#39;ve gone root → TLD → authoritative → CDN → CNAME chain, you&#39;ve spent enough milliseconds for a person to notice. Pages felt sluggish to load, and family members were the canary.&lt;/p&gt;
&lt;p&gt;Switching AdGuard to forward directly to a single fast public resolver fixed it instantly. I lost the &amp;quot;no third party sees my queries&amp;quot; property, but I kept the filtering and gained the snap-back responsiveness people actually feel.&lt;/p&gt;
&lt;p&gt;The lesson I&#39;d pass on: &lt;strong&gt;measure DNS perceived latency from someone who doesn&#39;t know how DNS works&lt;/strong&gt;, not from yourself looking at &lt;code&gt;dig&lt;/code&gt; output. Your tolerance is much higher than theirs.&lt;/p&gt;
&lt;h2&gt;What I added: Tailscale&lt;/h2&gt;
&lt;p&gt;The unplanned addition that changed the most was &lt;strong&gt;Tailscale&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Originally I thought WireGuard could do remote access for me too - just spin up a personal config and tunnel home when I need to manage the server. Technically true. In practice, Tailscale&#39;s mesh VPN with magic DNS is so much more pleasant for &amp;quot;just let me reach my server from anywhere&amp;quot; that I never bothered.&lt;/p&gt;
&lt;p&gt;Now the n100 lives at a fixed &lt;code&gt;*.ts.net&lt;/code&gt; name regardless of where I am. I can SSH from a coffee shop without configuring anything. There&#39;s zero port forwarding on the router for my own access - Tailscale handles NAT traversal. SSH itself isn&#39;t exposed to the public internet at all.&lt;/p&gt;
&lt;p&gt;I still keep WireGuard for the kids&#39; devices because that&#39;s a single-purpose dependency I want to fully own. But for me, Tailscale won.&lt;/p&gt;
&lt;h2&gt;What else found its way onto the box&lt;/h2&gt;
&lt;p&gt;Three things I didn&#39;t plan for ended up earning their place:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AirConnect&lt;/strong&gt; turns Chromecast and UPnP speakers into AirPlay targets. We had a couple of speakers that aren&#39;t Apple-aware, and the alternative was buying new hardware or waving phones at the wrong device for ten minutes. AirConnect runs as two systemd services. Worth it. The Google Home speakers we have are sometimes slow and drop out intermittently though, so I might not have perfectly cracked this yet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Netdata&lt;/strong&gt; gave me a real-time monitoring dashboard at &lt;code&gt;http://n100-home:19999/&lt;/code&gt;. I won&#39;t pretend I check it daily - but every time I &lt;em&gt;have&lt;/em&gt; checked it, it&#39;s earned its place. When I wondered whether Time Machine backups were CPU-bound, the answer was right there in a chart. When I worried about thermal throttling on the passively-cooled N100, the temperature traces said no. The investment in installing it is small; the times you need it, you really need it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fail2ban&lt;/strong&gt; is the kind of thing you put on and forget. SSH isn&#39;t exposed to the public internet anyway (Tailscale-only), but Fail2ban means even on my own LAN there&#39;s a hard limit on bad-credential noise. Belt and braces.&lt;/p&gt;
&lt;h2&gt;What I&#39;d change about the build&lt;/h2&gt;
&lt;p&gt;Going back to the hardware, my original post said I&#39;d go SSD-only if I were rebuilding. I&#39;d take that back.&lt;/p&gt;
&lt;p&gt;The 2.5&amp;quot; HDD has absorbed something like 1.3 TB of Time Machine writes over the year. Putting that on an SSD would have eaten into its endurance for no real benefit - Time Machine&#39;s bottleneck is the network, not the disk. The split between fast NVMe for the OS and a slow spinning disk for backups has been quietly correct.&lt;/p&gt;
&lt;p&gt;The other thing I overthought before the build was HDD noise. I went down a rabbit hole comparing &amp;quot;quiet&amp;quot; enterprise drives, almost talked myself into going SSD-only just to dodge the question, and even called the HDD the noisiest part of the system in the original post. In practice, a one-line &lt;code&gt;hdparm -S 120 /dev/sda&lt;/code&gt; (run as a tiny systemd unit at boot) puts the drive into standby after ten minutes of idle, and since Time Machine is the only thing using it, I genuinely can&#39;t hear it from the same room. The compromise that wasn&#39;t.&lt;/p&gt;
&lt;p&gt;The one mild regret on the build is &lt;strong&gt;disk size&lt;/strong&gt;. The boot NVMe is 500 GB and is barely 5% used. I could have got away with 256 GB and saved a few quid. The Time Machine HDD, on the other hand, is now 73% full at 1.3 TB / 1.8 TB. I&#39;ve capped Time Machine at 1.5 TB to leave headroom. Anyone planning the same setup should size the backup disk for at least 3× their largest Mac&#39;s data.&lt;/p&gt;
&lt;h2&gt;The numbers, a year in&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Uptime since last reboot&lt;/td&gt;
&lt;td&gt;50 days (kernel update)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total downtime in a year&lt;/td&gt;
&lt;td&gt;&amp;lt; 1 hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM used&lt;/td&gt;
&lt;td&gt;1.6 GB / 16 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU load average&lt;/td&gt;
&lt;td&gt;~1.0 of 4 cores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle power&lt;/td&gt;
&lt;td&gt;6-8 W&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total annual electricity cost&lt;/td&gt;
&lt;td&gt;~£20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Things that have broken on their own&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;There&#39;s still room for everything I could plausibly throw at it.&lt;/p&gt;
&lt;h2&gt;What&#39;s next&lt;/h2&gt;
&lt;p&gt;The infrastructure has gone quiet, which means there&#39;s headroom. CPU mostly idle, 14 GB of RAM untouched, and a backlog of self-hosted bits I&#39;ve been meaning to try. Nextcloud for files I&#39;d rather not park on someone else&#39;s cloud. Jellyfin for the family media library. Home Assistant to do home automation properly. A few others on the maybe pile.&lt;/p&gt;
&lt;p&gt;Whatever earns its place, you&#39;ll hear about it.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Earlier in this series: &lt;a href=&quot;https://martinhicks.dev/articles/n100-home-server-build&quot;&gt;Building a tiny Intel N100 home server&lt;/a&gt; · &lt;a href=&quot;https://martinhicks.dev/articles/adguard-unbound-dns-home&quot;&gt;Running AdGuard Home and Unbound&lt;/a&gt; · &lt;a href=&quot;https://martinhicks.dev/articles/wireguard-safe-browsing-kids&quot;&gt;WireGuard for safe browsing on kids&#39; devices&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
      <pubDate>Sat, 02 May 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/n100-home-server-a-year-on</guid>
    </item>
    <item>
      <title>Building a DynamoDB conformance suite</title>
      <link>https://martinhicks.dev/articles/dynoxide-conformance-suite</link>
      <description>&lt;p&gt;When I started building &lt;a href=&quot;https://dynoxide.dev/&quot;&gt;Dynoxide&lt;/a&gt; - a DynamoDB-compatible engine in Rust - I kept running into the same problem. I&#39;d implement an operation, write tests against my understanding of DynamoDB&#39;s behaviour, and ship it. Then someone (usually me) would discover that real DynamoDB does something slightly different. A validation error with a different message. A condition expression that fails where I expected it to succeed. An edge case in number precision that I&#39;d never considered.&lt;/p&gt;
&lt;p&gt;Every emulator ships with &amp;quot;DynamoDB compatible&amp;quot; and you&#39;re expected to take their word for it. I needed a way to answer a simple question: does Dynoxide actually behave like DynamoDB?&lt;/p&gt;
&lt;p&gt;No public conformance suite exists for DynamoDB - not from AWS, not from the community.&lt;/p&gt;
&lt;p&gt;So I built one.&lt;/p&gt;
&lt;h2&gt;The idea&lt;/h2&gt;
&lt;p&gt;The principle is straightforward. Write a test. Run it against real DynamoDB. Record the result. That recorded result becomes the ground truth. Then run the same test against an emulator and compare.&lt;/p&gt;
&lt;p&gt;If the emulator&#39;s response matches DynamoDB&#39;s, the test passes. If it doesn&#39;t, the emulator has a conformance gap. No opinions, no guesswork about what the documentation means - just &amp;quot;does this behave the same way the real thing does?&amp;quot;&lt;/p&gt;
&lt;p&gt;I built it as a standalone project - &lt;a href=&quot;https://github.com/nubo-db/dynamodb-conformance&quot;&gt;dynamodb-conformance&lt;/a&gt; - so anyone can clone it, point it at their own DynamoDB-compatible endpoint, and get results. It uses the AWS SDK v3 for TypeScript and vitest as the test runner. Nothing exotic.&lt;/p&gt;
&lt;h2&gt;Tiers&lt;/h2&gt;
&lt;p&gt;Not all DynamoDB operations are equally important. If your emulator can&#39;t do a basic PutItem, nothing else matters. If it doesn&#39;t perfectly replicate the error message format for an obscure validation edge case, that&#39;s less critical.&lt;/p&gt;
&lt;p&gt;So the suite is split into three tiers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tier 1&lt;/strong&gt; covers core CRUD operations - CreateTable, PutItem, GetItem, UpdateItem, DeleteItem, Query, Scan, BatchGetItem, BatchWriteItem. These are the operations every application uses. If an emulator fails here, it&#39;s not usable for real development.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tier 2&lt;/strong&gt; covers advanced features - transactions, PartiQL, streams, tags, TTL, and table updates like billing mode changes. These are features that production applications rely on but that you might not hit in a simple prototype.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tier 3&lt;/strong&gt; covers edge cases and error fidelity - validation ordering, exact error message formatting, API limits, and legacy API behaviour. The things that only matter when you need your emulator to be indistinguishable from the real service.&lt;/p&gt;
&lt;p&gt;This tiered structure means a result like &amp;quot;100% Tier 1, 95% Tier 2, 80% Tier 3&amp;quot; actually tells you something useful. It means core operations work perfectly, advanced features are mostly there, and edge cases have some gaps. A flat &amp;quot;92% overall&amp;quot; hides whether the failures are in PutItem or in obscure validation corner cases.&lt;/p&gt;
&lt;h2&gt;Ground truth&lt;/h2&gt;
&lt;p&gt;The key design decision was making DynamoDB itself the source of truth rather than my interpretation of the documentation.&lt;/p&gt;
&lt;p&gt;Every test in the suite has been run against a real DynamoDB table in eu-west-2. The expected values in the tests aren&#39;t what I &lt;em&gt;think&lt;/em&gt; DynamoDB should return based on reading the docs - they&#39;re what DynamoDB &lt;em&gt;actually&lt;/em&gt; returns. This matters more than you&#39;d expect. DynamoDB&#39;s documentation is good, but it doesn&#39;t cover every edge case. Sometimes the only way to know what DynamoDB does is to try it and see.&lt;/p&gt;
&lt;p&gt;A scheduled CI job runs the full suite against real DynamoDB weekly, so if AWS changes something - a new validation, a different error message, a behavioural tweak - the ground truth stays current. The suite can&#39;t go stale the way a set of hand-written expected values would.&lt;/p&gt;
&lt;h2&gt;Results&lt;/h2&gt;
&lt;p&gt;Here&#39;s where it gets interesting.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Tier 1 (267)&lt;/th&gt;
&lt;th&gt;Tier 2 (93)&lt;/th&gt;
&lt;th&gt;Tier 3 (166)&lt;/th&gt;
&lt;th&gt;Overall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB (eu-west-2)&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;526 / 526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dynoxide&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;526 / 526&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LocalStack&lt;/td&gt;
&lt;td&gt;98.9%&lt;/td&gt;
&lt;td&gt;95.7%&lt;/td&gt;
&lt;td&gt;81.9%&lt;/td&gt;
&lt;td&gt;489 / 526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB Local&lt;/td&gt;
&lt;td&gt;98.9%&lt;/td&gt;
&lt;td&gt;90.3%&lt;/td&gt;
&lt;td&gt;81.9%&lt;/td&gt;
&lt;td&gt;484 / 526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynalite&lt;/td&gt;
&lt;td&gt;98.1%&lt;/td&gt;
&lt;td&gt;10.8%&lt;/td&gt;
&lt;td&gt;92.8%&lt;/td&gt;
&lt;td&gt;426 / 526&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Dynoxide passes every test. 526 out of 526, across all three tiers.&lt;/p&gt;
&lt;p&gt;DynamoDB Local - AWS&#39;s own emulator - fails 42. LocalStack fails 37. Dynalite, which hasn&#39;t been actively maintained for a while, fails 57 (and that Tier 2 number of 10.8% tells you it&#39;s missing entire feature categories like transactions and PartiQL).&lt;/p&gt;
&lt;p&gt;I want to be careful about what this means and what it doesn&#39;t.&lt;/p&gt;
&lt;p&gt;526 tests is a lot, but DynamoDB is a massive service. There are aspects of it that the suite doesn&#39;t cover - DAX compatibility, on-demand capacity semantics, cross-region replication, fine-grained IAM condition keys. &amp;quot;100% conformance on 526 tests&amp;quot; is accurate. &amp;quot;100% DynamoDB compatible&amp;quot; is not, and I wouldn&#39;t claim it.&lt;/p&gt;
&lt;p&gt;What the results do tell you is that for the operations and behaviours the suite covers - which include everything most applications use day to day - Dynoxide matches DynamoDB exactly. Not approximately. Exactly.&lt;/p&gt;
&lt;h2&gt;What DynamoDB Local gets wrong&lt;/h2&gt;
&lt;p&gt;Some of DynamoDB Local&#39;s 42 failures are genuinely surprising. This is AWS&#39;s own emulator. You&#39;d expect it to be the gold standard for local DynamoDB development. In practice, it has real gaps across all three tiers.&lt;/p&gt;
&lt;p&gt;Three of its Tier 1 failures are in ItemCollectionMetrics - it doesn&#39;t return them for PutItem, DeleteItem, or UpdateItem even when you ask for them with &lt;code&gt;ReturnItemCollectionMetrics: SIZE&lt;/code&gt;. That&#39;s not an edge case. That&#39;s a documented feature of the API that silently does nothing.&lt;/p&gt;
&lt;p&gt;In Tier 2, the entire tagging API is broken. All eight tag-related tests fail - TagResource, UntagResource, ListTagsOfResource. If you&#39;re writing infrastructure-as-code that tags tables and you&#39;re testing against DynamoDB Local, none of that is being validated. It also gets PartiQL INSERT semantics wrong - it treats INSERT as an upsert when real DynamoDB correctly rejects an INSERT on an existing item.&lt;/p&gt;
&lt;p&gt;The bulk of the failures (30) are in Tier 3 - validation ordering and error message formatting. DynamoDB Local returns different errors than production for the same bad request. This might sound academic, but if you&#39;re writing error handling code against DynamoDB Local, it might not work the same way against the real thing. You&#39;ll get the right error &lt;em&gt;type&lt;/em&gt; but the wrong message content, or the validations will fire in a different order.&lt;/p&gt;
&lt;p&gt;These are the kind of differences you only discover when you have a conformance suite to catch them. Without one, they hide as production bugs that are impossible to reproduce locally.&lt;/p&gt;
&lt;h2&gt;Why bother?&lt;/h2&gt;
&lt;p&gt;I built this because I needed it for Dynoxide. I wanted confidence that the thing I was building actually worked the way DynamoDB works, not the way I assumed it works. But the suite is useful beyond that.&lt;/p&gt;
&lt;p&gt;If you&#39;re choosing between DynamoDB emulators for your development workflow, you can run the suite yourself and compare. The numbers above aren&#39;t marketing claims - they&#39;re reproducible results from a public repository that anyone can verify.&lt;/p&gt;
&lt;p&gt;If you maintain a DynamoDB emulator or compatibility layer, you can use it to find and fix gaps. Point it at your endpoint, see what fails, fix the failures. The tiered structure tells you which failures matter most.&lt;/p&gt;
&lt;p&gt;And if you&#39;re using DynamoDB Local in your CI pipeline and wondering why a test passes locally but fails against real DynamoDB - the conformance suite might tell you why. The 42 things DynamoDB Local gets wrong are 42 potential sources of exactly that kind of bug.&lt;/p&gt;
&lt;p&gt;526 tests is solid coverage, but there are DynamoDB behaviours the suite doesn&#39;t exercise yet. If you&#39;ve hit a case where an emulator diverges from real DynamoDB and you think it should be covered, open a PR. The only requirement is that the test passes against real DynamoDB - include the results artifact from a run against your own AWS account and I&#39;ll verify it against the ground truth pipeline. More tests make the suite more useful for everyone, not just Dynoxide.&lt;/p&gt;
&lt;p&gt;The suite is at &lt;a href=&quot;https://github.com/nubo-db/dynamodb-conformance&quot;&gt;github.com/nubo-db/dynamodb-conformance&lt;/a&gt;. Clone it, run it, check the results for yourself.&lt;/p&gt;
</description>
      <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/dynoxide-conformance-suite</guid>
    </item>
    <item>
      <title>Dynoxide 0.9.8: fixing the orphan problem</title>
      <link>https://martinhicks.dev/articles/dynoxide-098-npm-process-lifecycle</link>
      <description>&lt;p&gt;&lt;a href=&quot;https://github.com/nubo-db/dynoxide&quot;&gt;Dynoxide 0.9.8&lt;/a&gt; fixes a bug where backgrounding dynoxide in an npm script left it running after the parent process exited. The port stayed bound, and the next &lt;code&gt;npm run dev&lt;/code&gt; failed with a port conflict. There were three separate causes.&lt;/p&gt;
&lt;h2&gt;The Rust server ignored SIGTERM&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;kill &amp;lt;pid&amp;gt;&lt;/code&gt; sends SIGTERM by default, but Dynoxide only handled SIGINT (Ctrl+C). The fix is a &lt;code&gt;tokio::select!&lt;/code&gt; between &lt;code&gt;ctrl_c()&lt;/code&gt; and a SIGTERM listener:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;async fn shutdown_signal() {
    #[cfg(unix)]
    {
        use tokio::signal::unix::{SignalKind, signal};
        let mut sigterm =
            signal(SignalKind::terminate()).expect(&amp;quot;failed to install SIGTERM handler&amp;quot;);
        tokio::select! {
            _ = tokio::signal::ctrl_c() =&amp;gt; {},
            _ = sigterm.recv() =&amp;gt; {},
        }
    }
    #[cfg(not(unix))]
    {
        tokio::signal::ctrl_c()
            .await
            .expect(&amp;quot;failed to install CTRL+C handler&amp;quot;);
    }
    eprintln!(&amp;quot;&#92;nShutting down...&amp;quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This applies to all installs (Homebrew, cargo, GitHub Action), not just npm.&lt;/p&gt;
&lt;h2&gt;spawnSync blocks the event loop&lt;/h2&gt;
&lt;p&gt;The npm wrapper used &lt;code&gt;spawnSync&lt;/code&gt; to launch the Rust binary. That blocks the Node.js event loop entirely - no signal handlers fire, no &lt;code&gt;process.on(&#39;exit&#39;)&lt;/code&gt;, nothing. The shim can&#39;t forward signals to the child because it&#39;s stuck in a synchronous call.&lt;/p&gt;
&lt;p&gt;Switched to async &lt;code&gt;spawn&lt;/code&gt; with explicit signal forwarding (SIGINT, SIGTERM, SIGHUP), same pattern esbuild and Biome use. The child spawns with &lt;code&gt;detached: true&lt;/code&gt; to avoid double SIGINT delivery - without it, Ctrl+C sends SIGINT to both the shim and the child via the foreground process group, &lt;em&gt;and&lt;/em&gt; the shim forwards it again. Detaching makes explicit forwarding the only signal path.&lt;/p&gt;
&lt;p&gt;There&#39;s also double-signal escalation: first Ctrl+C forwards SIGINT for graceful shutdown, second sends SIGKILL.&lt;/p&gt;
&lt;h2&gt;Backgrounded processes never get signals&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;&amp;amp;&lt;/code&gt; in a typical npm script pattern like &lt;code&gt;dynoxide &amp;amp; sleep 1 &amp;amp;&amp;amp; npm run seed &amp;amp;&amp;amp; react-router dev&lt;/code&gt; puts dynoxide outside the foreground process group. Ctrl+C goes to the dev server. SIGTERM from &lt;code&gt;kill&lt;/code&gt; targets the parent shell. No signal reaches dynoxide at all.&lt;/p&gt;
&lt;p&gt;The fix is polling &lt;code&gt;process.ppid&lt;/code&gt; on a 1-second interval. When the parent dies, the OS reparents the process to PID 1 (or launchd on macOS) and the PPID changes. When detected, the shim sends SIGTERM to the child with a SIGKILL fallback after a grace period. The interval uses &lt;code&gt;.unref()&lt;/code&gt; so short-lived commands like &lt;code&gt;dynoxide --help&lt;/code&gt; exit immediately.&lt;/p&gt;
&lt;h3&gt;detached and PPID polling are coupled&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;detached: true&lt;/code&gt; means the OS won&#39;t automatically kill the child if the wrapper crashes or gets SIGKILL&#39;d. Without PPID polling, a detached child orphans &lt;em&gt;more&lt;/em&gt; easily than the old &lt;code&gt;spawnSync&lt;/code&gt; behaviour. Removing the polling while keeping &lt;code&gt;detached: true&lt;/code&gt; would make things worse, not better.&lt;/p&gt;
&lt;h2&gt;Upgrade&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;npm install --save-dev dynoxide@latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or if you&#39;re using Homebrew or cargo, the SIGTERM fix applies regardless of how you installed it.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/nubo-db/dynoxide/blob/main/CHANGELOG.md&quot;&gt;Changelog&lt;/a&gt; and &lt;a href=&quot;https://github.com/nubo-db/dynoxide/issues/2&quot;&gt;issue&lt;/a&gt; on GitHub.&lt;/p&gt;
</description>
      <pubDate>Mon, 06 Apr 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/dynoxide-098-npm-process-lifecycle</guid>
    </item>
    <item>
      <title>Introducing Dynoxide: a fast, embeddable DynamoDB engine</title>
      <link>https://martinhicks.dev/articles/introducing-dynoxide</link>
      <description>&lt;p&gt;I&#39;ve been working with DynamoDB for the best part of a decade. It&#39;s my default database for most things I build at &lt;a href=&quot;https://sinovi.uk/&quot;&gt;Si Novi&lt;/a&gt;, and I genuinely like it. The data modelling is satisfying once it clicks, the operational overhead is basically zero, and it scales without you having to think about it.&lt;/p&gt;
&lt;p&gt;Last year, Si Novi started building &lt;a href=&quot;https://nubo.sinovi.uk/&quot;&gt;Nubo&lt;/a&gt;, a native DynamoDB client for macOS and iPadOS, with Windows and Linux on the way. One of the features we wanted from the start was a built-in sandbox. A local DynamoDB instance running on-device that you could experiment with, no AWS credentials needed. Create tables, insert data, test access patterns. All offline. All on your machine. Maybe even on an iPad.&lt;/p&gt;
&lt;p&gt;The problem was: how do you ship that?&lt;/p&gt;
&lt;p&gt;DynamoDB Local&#39;s &lt;a href=&quot;https://aws.amazon.com/dynamodb/dynamodblocallicense/&quot;&gt;licence&lt;/a&gt; explicitly prohibits redistribution. You can&#39;t bundle it inside another application or sublicence it. Even setting the licence aside, the developer experience would have been awful. Asking users to separately download and install a Java-based emulator, with Docker or a JVM as a prerequisite, just to use one feature in your app? That&#39;s not a sandbox. That&#39;s homework.&lt;/p&gt;
&lt;p&gt;And on iPadOS there&#39;s no path at all. No JVM. No Docker. Nothing.&lt;/p&gt;
&lt;p&gt;So I started building something new. A DynamoDB-compatible engine in Rust, backed by SQLite, that compiles to a single native binary. Something I could embed directly into Nubo as a library.&lt;/p&gt;
&lt;p&gt;That&#39;s how Dynoxide started.&lt;/p&gt;
&lt;h2&gt;From library to tool&lt;/h2&gt;
&lt;p&gt;The first version did exactly what I needed: a local DynamoDB that Nubo could bundle as its sandbox. Job done, in theory. But the thing that surprised me was just how small and fast it turned out to be.&lt;/p&gt;
&lt;p&gt;A ~3 MB download (~6 MB on disk). Under 5 MB of RAM at idle. Cold startup in about 15 milliseconds, compared to over two seconds for DynamoDB Local. Numbers like that change what&#39;s practical. Instead of running one shared DynamoDB instance in your CI pipeline, you could spin up a fresh one per test. Isolated state. No cleanup step. No flaky tests from leftover data. Tear it down when you&#39;re done and the overhead is essentially nothing.&lt;/p&gt;
&lt;p&gt;That realisation shifted what Dynoxide was becoming. It wasn&#39;t just infrastructure for Nubo any more. It was a tool in its own right.&lt;/p&gt;
&lt;p&gt;The same properties that made it good for CI made it a natural fit for agentic development. A coding agent that needs to create tables, write data, or test queries can do all of that through Dynoxide&#39;s built-in MCP server. 34 tools, no setup required. Run &lt;code&gt;dynoxide mcp&lt;/code&gt; and your agent has a full DynamoDB environment to work with.&lt;/p&gt;
&lt;p&gt;And then I looked at how many Node.js projects still depend on &lt;a href=&quot;https://github.com/mhart/dynalite&quot;&gt;dynalite&lt;/a&gt; for local DynamoDB. I owe a lot to dynalite. Using it through &lt;a href=&quot;https://arc.codes/&quot;&gt;arc.codes&lt;/a&gt; and Architect is what turned DynamoDB from a database I&#39;d reach for occasionally into the one I&#39;d reach for first. Having a fast local emulator that just worked changed how I thought about building with DynamoDB. It made the feedback loop tight enough that I actually wanted to experiment. But dynalite hasn&#39;t seen updates in a while and newer DynamoDB features like transactions and streams aren&#39;t covered. Dynoxide supports both, along with the rest of the API surface. With an npm package shipping platform-specific binaries (the same approach esbuild and Biome use), it works as a drop-in replacement. Same workflow, one line change in your &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Under the hood&lt;/h2&gt;
&lt;p&gt;Dynoxide implements the DynamoDB API as a translation layer in Rust, storing data in SQLite and compiling to a native binary with no runtime dependencies. DynamoDB Local takes the same general approach (SQLite as the storage engine is &lt;a href=&quot;https://dev.to/aws-heroes/dynamodb-local-in-docker-25i&quot;&gt;well documented&lt;/a&gt;) but ships as a Java application with a JVM dependency and a restrictive licence.&lt;/p&gt;
&lt;p&gt;Because SQLite is an embedded database, Dynoxide doesn&#39;t have to run as a standalone server. It can be linked directly into another application, which is exactly how Nubo uses it. Encrypt the database file with SQLCipher and you&#39;ve got a portable, encrypted, DynamoDB-compatible data store. Nubo uses this for its local cache: data you&#39;ve previously accessed from AWS is kept in an encrypted database on-device, so it loads instantly and works offline.&lt;/p&gt;
&lt;p&gt;To make sure Dynoxide actually behaves like the real thing, I built a &lt;a href=&quot;https://github.com/nubo-db/dynamodb-conformance&quot;&gt;conformance test suite&lt;/a&gt;. 526 tests, all validated against real DynamoDB on AWS. Every emulator gets the same suite. Dynoxide passes all 526. DynamoDB Local manages 92%. LocalStack gets 93%. Dynalite hits 81%.&lt;/p&gt;
&lt;p&gt;The suite is public. Anyone can run it and verify.&lt;/p&gt;
&lt;h2&gt;Get started&lt;/h2&gt;
&lt;p&gt;Homebrew:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brew install nubo-db/tap/dynoxide
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;npm (drop-in dynalite replacement):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install --save-dev dynoxide
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cargo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cargo install dynoxide-rs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then just run it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dynoxide
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Full documentation is at &lt;a href=&quot;https://dynoxide.dev/&quot;&gt;dynoxide.dev&lt;/a&gt;, the source is on &lt;a href=&quot;https://github.com/nubo-db/dynoxide&quot;&gt;GitHub&lt;/a&gt;, and there&#39;s more detail on the &lt;a href=&quot;https://martinhicks.dev/projects/dynoxide&quot;&gt;Dynoxide project page&lt;/a&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Dynoxide is the foundation. But the thing I&#39;m building on top of it is what I really want to talk about next. &lt;a href=&quot;https://nubo.sinovi.uk/&quot;&gt;Nubo&lt;/a&gt; is a native DynamoDB client for macOS and iPadOS, and I think people who work with DynamoDB day to day will love it. If that sounds interesting, there&#39;s a mailing list at &lt;a href=&quot;https://nubo.sinovi.uk/&quot;&gt;nubo.sinovi.uk&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Dynoxide stands on its own though. If you&#39;re running DynamoDB Local in CI and wondering why your pipeline is slow, or you&#39;re still using dynalite and need coverage for newer API features like transactions and streams, give it a try. I&#39;d love to know what you think.&lt;/p&gt;
&lt;p&gt;Si Novi will be at AWS Summit London on April 22nd. If you&#39;re there and want to talk DynamoDB, come and find me.&lt;/p&gt;
</description>
      <pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/introducing-dynoxide</guid>
    </item>
    <item>
      <title>Automating my son&#39;s YouTube with Python and FFmpeg</title>
      <link>https://martinhicks.dev/articles/automating-bens-youtube-channel</link>
      <description>&lt;p&gt;My son Ben is 12. Back in August he decided he wanted to start a YouTube channel. We talked about what it could be and he landed on Premier League score predictions. Every gameweek he&#39;d predict the scores for all 10 matches, record himself talking through his picks, then after the weekend we&#39;d see how he did and record a review.&lt;/p&gt;
&lt;p&gt;Simple concept. He was into it. I was into it. Let&#39;s go.&lt;/p&gt;
&lt;h2&gt;The manual era&lt;/h2&gt;
&lt;p&gt;The first video was recorded in the car on the drive home from France. I&#39;d knocked together a Keynote presentation on my iPad with the fixtures for Gameweek 1. Team names, badges, blank spaces for Ben to write his predictions with Apple Pencil while screen recording. It was rough, but it worked. He recorded his picks, we uploaded it, and &lt;a href=&quot;https://www.youtube.com/@BensPLPredictions&quot;&gt;Ben&#39;s PL Predictions&lt;/a&gt; was born.&lt;/p&gt;
&lt;p&gt;The workflow went like this: I&#39;d create the Keynote slides manually, Ben would screen record on the iPad, I&#39;d trim the video in iMovie, adjust the audio levels and brightness, then upload to YouTube Studio and fill in the title, description, and tags by hand.&lt;/p&gt;
&lt;p&gt;For the review video I&#39;d do the same thing again, but this time with actual results filled in and a little thumbnail showing what he&#39;d predicted, so viewers could see both side by side as he talked through each result.&lt;/p&gt;
&lt;p&gt;It was taking me about an hour on a Friday evening or Saturday morning. Not terrible, but I could feel the fragility of it. Ben was being consistent, recording every week without fail, and I didn&#39;t want &lt;em&gt;me&lt;/em&gt; to be the reason he couldn&#39;t get a video out. If I was busy, or forgot, or just didn&#39;t fancy spending an hour creating slides and editing video, his streak would break. That didn&#39;t sit right.&lt;/p&gt;
&lt;h2&gt;JSON and Python: the first automation&lt;/h2&gt;
&lt;p&gt;After a couple of weeks I rebuilt the system. Instead of manually creating Keynote slides, I set up a JSON data model for each gameweek (fixtures, predictions, results, points) and wrote a Python CLI using Click that generates PowerPoint presentations from the data. The slides get copied to iCloud so they appear on the iPad automatically.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;league&amp;quot;: &amp;quot;EPL&amp;quot;,
  &amp;quot;season&amp;quot;: &amp;quot;2025-26&amp;quot;,
  &amp;quot;gameweek&amp;quot;: 30,
  &amp;quot;matches&amp;quot;: [
    {
      &amp;quot;home&amp;quot;: &amp;quot;ars&amp;quot;, &amp;quot;away&amp;quot;: &amp;quot;eve&amp;quot;,
      &amp;quot;prediction&amp;quot;: {&amp;quot;home&amp;quot;: 2, &amp;quot;away&amp;quot;: 0},
      &amp;quot;result&amp;quot;: {&amp;quot;home&amp;quot;: 2, &amp;quot;away&amp;quot;: 0},
      &amp;quot;points&amp;quot;: 3
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The scoring is simple: three points for an exact score, one point for the correct result (home win, away win, or draw), zero for wrong. The CLI generates prediction slides with blank scores for Ben to write on, and review slides with actual results, points, and a thumbnail of his predictions.&lt;/p&gt;
&lt;p&gt;This got my weekly time down from an hour to about thirty minutes. Create the JSON with fixtures, run the command, copy to iCloud. After matches, add results, run the review command. Better, but I was still manually typing fixtures, manually watching his video to work out what he&#39;d predicted, manually looking up results, manually editing in iMovie, and manually uploading to YouTube. Five of those steps are things a computer should be doing.&lt;/p&gt;
&lt;h2&gt;Automating everything else&lt;/h2&gt;
&lt;p&gt;I&#39;ve just finished building the next phase, removing every remaining manual step. It&#39;s ready to go from the Gameweek 31 review and Gameweek 32 onwards. Here&#39;s what the pipeline looks like.&lt;/p&gt;
&lt;h3&gt;Fixtures from the FPL API&lt;/h3&gt;
&lt;p&gt;No more manual data entry. The Fantasy Premier League website exposes an undocumented API that &lt;a href=&quot;https://github.com/topics/fantasy-premier-league&quot;&gt;the community&lt;/a&gt; has been using for years. It provides fixture data per gameweek, free, no authentication required.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ python cli.py fetch-fixtures -w 31
Fetched 8 fixtures for GW31
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The CLI maps FPL&#39;s numeric team IDs to our registry, handles postponed matches and writes the gameweek JSON automatically. It&#39;s worth noting this is an unofficial API with no formal terms of use for developers. For a personal project making a handful of calls per week, the risk is negligible, but it&#39;s not something I&#39;d build a commercial product on.&lt;/p&gt;
&lt;h3&gt;Video transcription with Whisper&lt;/h3&gt;
&lt;p&gt;This is the one that surprised me most. After Ben records his prediction video, OpenAI&#39;s &lt;a href=&quot;https://github.com/openai/whisper&quot;&gt;Whisper&lt;/a&gt; runs locally to transcribe the audio, then a regex parser extracts his predicted scores by matching them to fixtures by team name.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ python cli.py transcribe -w 31
 1 | Bournemouth  | Man United  | 1 - 1  | matched
 2 | Brighton     | Liverpool   | 2 - 1  | matched
 ...
Matched 8/8 fixtures
Save these predictions to the gameweek JSON? [y/N]: y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It handles colloquial names like &amp;quot;Spurs,&amp;quot; &amp;quot;Palace,&amp;quot; and &amp;quot;Forest&amp;quot;, and works out which score belongs to home and away based on context. I run the Whisper &lt;code&gt;medium&lt;/code&gt; model with an initial prompt containing all 20 Premier League team names, which dramatically improves recognition accuracy. I tested it against Ben&#39;s existing recordings and it matched all predictions correctly: 10/10 for GW30 and 8/8 for GW31.&lt;/p&gt;
&lt;p&gt;It asks for confirmation before writing to the JSON, so there&#39;s always a human check.&lt;/p&gt;
&lt;h3&gt;Results from the API&lt;/h3&gt;
&lt;p&gt;Same FPL endpoint, different data. After the weekend&#39;s matches:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ python cli.py fetch-results -w 31
Updated 8 matches with results
Total points: 11
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Merges results into the existing JSON, preserving Ben&#39;s predictions, and calculates points automatically.&lt;/p&gt;
&lt;h3&gt;Encoding publish-ready Shorts&lt;/h3&gt;
&lt;p&gt;This replaces iMovie entirely. A single command takes the raw iPad screen recording and produces a YouTube-optimised Short:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ python cli.py encode-short -w 31 -t predict -i RPReplay_Final.MP4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Under the hood it runs a single Whisper pass (reused for both trim detection and SRT caption generation), detects the speech start via FFmpeg&#39;s &lt;code&gt;silencedetect&lt;/code&gt; filter, finds the closing phrase (&amp;quot;see you next time&amp;quot;) in the transcript, then encodes with normalised audio, proportional scaling and YouTube-recommended H.264 settings.&lt;/p&gt;
&lt;p&gt;The trim is tight, half a second after the closing phrase, so it cuts before Ben swipes up to stop the screen recording and you don&#39;t see the iOS Control Centre.&lt;/p&gt;
&lt;h3&gt;Upload to YouTube&lt;/h3&gt;
&lt;p&gt;The final piece. One command uploads the encoded Short as a private video with auto-generated metadata:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ python cli.py upload-short -w 31 -t predict
Title: GW31 Premier League Predictions #PremierLeague #matchweek31
Tags: Premier League, EPL, GW31, Football...
Thumbnail set
Captions uploaded
Privacy: PRIVATE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It generates the title, description (including season accuracy stats and an engagement question), tags with relevant team names, extracts and sets a thumbnail from the title frame, and uploads the SRT captions for search indexing.&lt;/p&gt;
&lt;p&gt;Everything uploads as &lt;em&gt;private&lt;/em&gt;. Ben and I review it in YouTube Studio and he publishes when he&#39;s happy. This was a deliberate decision. It&#39;s a child&#39;s channel and I wanted a human gate before anything goes public.&lt;/p&gt;
&lt;p&gt;The YouTube API OAuth credentials are pulled from 1Password at runtime via the &lt;code&gt;op&lt;/code&gt; CLI, so nothing sensitive sits on disk.&lt;/p&gt;
&lt;h2&gt;The weekly routine from Gameweek 32&lt;/h2&gt;
&lt;p&gt;What used to be nine manual steps should now be a handful of CLI commands plus Ben&#39;s two recordings:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;fetch-fixtures&lt;/code&gt; then &lt;code&gt;pptx-predict-table --icloud&lt;/code&gt; - pull fixtures, generate slides, send to iPad&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Ben records prediction video&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;transcribe&lt;/code&gt; then &lt;code&gt;encode-short&lt;/code&gt; then &lt;code&gt;upload-short&lt;/code&gt; - process and publish&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fetch-results&lt;/code&gt; then &lt;code&gt;pptx-review-table --icloud&lt;/code&gt; - pull results, generate review slides&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Ben records review video&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;encode-short&lt;/code&gt; then &lt;code&gt;upload-short&lt;/code&gt; - process and publish&lt;/li&gt;
&lt;li&gt;Review and publish manually in YouTube Studio&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I&#39;m looking forward to running it for the review once GW31&#39;s fixtures are all played, and both predictions and review for real in Gameweek 32 after the international break. If the numbers hold, my active time should go from an hour to under two minutes. And critically, none of those two minutes are things that block Ben. If I&#39;m not around, someone else could run four commands, or I could do it from my phone over SSH. The next step is getting this set up on Ben&#39;s Chromebook so he can run the pipeline himself — once I work out why the option to enable Linux is disabled on his machine, he won&#39;t need me involved at all.&lt;/p&gt;
&lt;h2&gt;What I&#39;m proud of&lt;/h2&gt;
&lt;p&gt;It&#39;s not the code. It&#39;s that Ben has stuck with it. He&#39;s at &lt;a href=&quot;https://www.youtube.com/@BensPLPredictions&quot;&gt;over a hundred subscribers&lt;/a&gt;, he&#39;s recorded every single gameweek this season, and he genuinely looks forward to it. He&#39;s learning that consistency matters more than virality, that showing up every week builds something, and that over a hundred people choosing to follow a 12-year-old&#39;s football predictions is pretty cool.&lt;/p&gt;
&lt;p&gt;The automation exists to protect that consistency. Not to make the videos for him. He still records every one himself, writes his predictions by hand with Apple Pencil, and talks through his reasoning. The personality is all him. I just make sure the boring infrastructure never gets in the way.&lt;/p&gt;
&lt;p&gt;If you&#39;re into Premier League predictions, or you want to support a kid who&#39;s been showing up every week, you can find Ben at &lt;a href=&quot;https://www.youtube.com/@BensPLPredictions&quot;&gt;youtube.com/@BensPLPredictions&lt;/a&gt;.&lt;/p&gt;
</description>
      <pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/automating-bens-youtube-channel</guid>
    </item>
    <item>
      <title>Recently Played: bringing back my Last.fm component</title>
      <link>https://martinhicks.dev/articles/recently-played-lastfm-component</link>
      <description>&lt;p&gt;Around 2010, my personal website had a Last.fm widget showing what I&#39;d been listening to. It was a small thing - just a few album covers and track names in the sidebar - but it was very &lt;em&gt;me&lt;/em&gt;. Back then your personal site was an extension of yourself, and having your music taste ticking away in the corner felt right.&lt;/p&gt;
&lt;p&gt;Fast forward fifteen years and I&#39;ve rebuilt &lt;a href=&quot;https://martinhicks.dev/&quot;&gt;this site&lt;/a&gt; from scratch several times over. The Last.fm widget never made the cut again. Not for any good reason - it just fell off the list each time. When I was recently working on the sidebar I remembered it, and thought &lt;em&gt;why not bring it back?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;There&#39;s something I like about the full circle. The web has gone through its various phases since 2010 - the single page app years, the JavaScript-for-everything years - and come out the other side valuing progressive enhancement and server-rendered HTML again. My site is built with &lt;a href=&quot;https://www.11ty.dev/&quot;&gt;Eleventy&lt;/a&gt; and deployed from GitHub Actions to S3 with CloudFront. It felt fitting to bring back a feature from an earlier era, built with the principles I care about now.&lt;/p&gt;
&lt;p&gt;So here&#39;s how it works.&lt;/p&gt;
&lt;h2&gt;The architecture&lt;/h2&gt;
&lt;p&gt;The component is a two-layer system: build-time SSR for the initial HTML, and client-side polling for live updates. It&#39;s dropped into the sidebar as a &lt;code&gt;&amp;lt;now-listening&amp;gt;&lt;/code&gt; WebC element alongside &lt;code&gt;&amp;lt;my-details&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Build time:  listening.js → Last.fm API → tracks data → SSR into HTML
Runtime:     Client JS → polls Last.fm API every 60s → diffs → updates DOM
             ├── localStorage cache (2min TTL) for instant loads
             └── pauses when tab hidden, resumes on focus
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Data layer: &lt;code&gt;src/_data/listening.js&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;This is an Eleventy global data file that runs at build time. It calls the Last.fm API - specifically &lt;code&gt;user.getrecenttracks&lt;/code&gt; - requesting the five most recent tracks for my account.&lt;/p&gt;
&lt;p&gt;It has a three-second timeout so that if Last.fm is having a bad day, builds aren&#39;t left hanging. On any failure it returns &lt;code&gt;{ tracks: [] }&lt;/code&gt;, which means builds never break regardless of what the API does.&lt;/p&gt;
&lt;p&gt;The raw API response gets mapped into clean objects - &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;artist&lt;/code&gt;, &lt;code&gt;album&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;art&lt;/code&gt;, and a &lt;code&gt;nowPlaying&lt;/code&gt; boolean. All text fields are HTML-escaped and URLs are validated &lt;em&gt;(only &lt;code&gt;http:&lt;/code&gt; and &lt;code&gt;https:&lt;/code&gt; schemes allowed)&lt;/em&gt; - this is the server-side XSS protection layer.&lt;/p&gt;
&lt;p&gt;The data is then available to templates as &lt;code&gt;listening.tracks&lt;/code&gt; via Eleventy&#39;s data cascade.&lt;/p&gt;
&lt;h2&gt;The component: &lt;code&gt;now-listening.webc&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;The WebC component has three distinct parts.&lt;/p&gt;
&lt;h3&gt;Server-rendered HTML&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;webc:type=&amp;quot;render&amp;quot;&lt;/code&gt; script runs at build time, reading &lt;code&gt;this.$data.listening.tracks&lt;/code&gt;. It generates the initial HTML so the page ships with real track data baked in. This means content is visible immediately on load - and for search engines or anyone browsing without JavaScript, this &lt;em&gt;is&lt;/em&gt; the component. It&#39;s done. No spinner, no empty state.&lt;/p&gt;
&lt;h3&gt;A &lt;code&gt;&amp;lt;template&amp;gt;&lt;/code&gt; element&lt;/h3&gt;
&lt;p&gt;An inert HTML template used by the client-side code for DOM cloning. It defines the track row structure with &lt;code&gt;data-*&lt;/code&gt; attribute hooks - &lt;code&gt;data-track&lt;/code&gt;, &lt;code&gt;data-name&lt;/code&gt;, &lt;code&gt;data-detail&lt;/code&gt;, &lt;code&gt;data-art-placeholder&lt;/code&gt;, and &lt;code&gt;data-now-playing&lt;/code&gt;. Nothing renders from this until JavaScript picks it up.&lt;/p&gt;
&lt;h3&gt;Client-side polling script&lt;/h3&gt;
&lt;p&gt;A self-executing IIFE &lt;em&gt;(kept alive with &lt;code&gt;webc:keep&lt;/code&gt; to prevent Eleventy from stripping it)&lt;/em&gt; that handles the live behaviour:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Polling&lt;/strong&gt; - hits the Last.fm API every 60 seconds to keep the track list current.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;localStorage cache&lt;/strong&gt; - on page load, if a fresh cache exists &lt;em&gt;(two-minute TTL)&lt;/em&gt;, it renders from cache immediately and defers the first API call. This avoids the flash-of-stale-content problem on repeat visits or navigating between pages.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Diffing&lt;/strong&gt; - compares &lt;code&gt;JSON.stringify(tracks)&lt;/code&gt; against the last known state. If nothing has changed, the DOM stays untouched. No unnecessary reflows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Visibility awareness&lt;/strong&gt; - listens to &lt;code&gt;visibilitychange&lt;/code&gt; to stop polling when the tab is hidden and restart when it becomes visible. If you leave the tab in the background for an hour, it&#39;s not hammering the API the whole time. Be a good citizen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AbortController&lt;/strong&gt; - cancels in-flight fetch requests when polling stops, so there&#39;s no risk of stale responses landing after the component has moved on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Client-side XSS protection&lt;/strong&gt; - uses the same &lt;code&gt;safeUrl&lt;/code&gt; and &lt;code&gt;esc&lt;/code&gt; helpers as the server layer. Content is inserted via &lt;code&gt;textContent&lt;/code&gt; &lt;em&gt;(inherently safe)&lt;/em&gt; and URLs are validated before being set as &lt;code&gt;href&lt;/code&gt; or &lt;code&gt;src&lt;/code&gt; attributes.&lt;/p&gt;
&lt;h2&gt;Design decisions&lt;/h2&gt;
&lt;p&gt;The main tradeoff worth calling out is the duplicated logic. The same API call and data mapping exists in both &lt;code&gt;listening.js&lt;/code&gt; &lt;em&gt;(server)&lt;/em&gt; and the client-side script. I could abstract it into a shared module, but the server code runs in Node during the Eleventy build and the client code runs in the browser. Keeping them self-contained means each layer is independently understandable and testable. The duplication is small and the mapping is straightforward - it&#39;s not the kind of logic that&#39;s likely to drift in dangerous ways.&lt;/p&gt;
&lt;p&gt;The other thing I&#39;m quietly pleased with is how little the component asks of the outside world. Between the visibility-aware polling, the localStorage cache, and the diff check before touching the DOM, it makes the minimum number of requests necessary. If you&#39;re pulling from someone else&#39;s API on every page load of your site, I think you owe it to them &lt;em&gt;(and your users)&lt;/em&gt; to be thoughtful about it.&lt;/p&gt;
&lt;h2&gt;Was it worth it?&lt;/h2&gt;
&lt;p&gt;It took a couple of hours to build and it makes me smile every time I see it on the page. Sometimes the best features aren&#39;t the most technically ambitious - they&#39;re the ones that make a site feel like &lt;em&gt;yours&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;I might extract this into a standalone package at some point - the component is fairly self-contained and would slot into any Eleventy site with minimal config.&lt;/p&gt;
&lt;p&gt;If you want to see it in action, it&#39;s in the sidebar at &lt;a href=&quot;https://martinhicks.dev/&quot;&gt;martinhicks.dev&lt;/a&gt;.&lt;/p&gt;
</description>
      <pubDate>Sun, 08 Mar 2026 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/recently-played-lastfm-component</guid>
    </item>
    <item>
      <title>WireGuard for safe browsing on kids&#39; devices</title>
      <link>https://martinhicks.dev/articles/wireguard-safe-browsing-kids</link>
      <description>&lt;p&gt;After setting up &lt;a href=&quot;https://martinhicks.dev/articles/adguard-unbound-dns-home&quot;&gt;AdGuard Home and Unbound&lt;/a&gt; on my &lt;a href=&quot;https://martinhicks.dev/articles/n100-home-server-build&quot;&gt;home server&lt;/a&gt;, DNS filtering was working perfectly for every device on the home network. But children&#39;s devices don&#39;t stay on the home network. They go to school, to friends&#39; houses, connect to public Wi-Fi, and use mobile data. In all of those situations, the home DNS filtering was being bypassed entirely.&lt;/p&gt;
&lt;p&gt;The solution was &lt;strong&gt;WireGuard VPN&lt;/strong&gt;, configured so that children&#39;s devices always route their DNS queries back through the home server, regardless of which network they&#39;re on.&lt;/p&gt;
&lt;h2&gt;Why WireGuard&lt;/h2&gt;
&lt;p&gt;I&#39;ve used OpenVPN in the past and it works, but WireGuard is a different league:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fast.&lt;/strong&gt; Built into the Linux kernel, minimal overhead. You don&#39;t notice it&#39;s running.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simple.&lt;/strong&gt; The entire codebase is around 4,000 lines of code. OpenVPN is over 100,000.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Battery friendly.&lt;/strong&gt; It only sends packets when there&#39;s traffic. No keepalive polling draining the battery.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reliable.&lt;/strong&gt; Handles network changes gracefully. Switch from Wi-Fi to mobile data and the tunnel reconnects silently.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The N100&#39;s hardware AES-NI support means encryption overhead is essentially zero. WireGuard throughput on this box easily saturates the home broadband connection.&lt;/p&gt;
&lt;h2&gt;DNS-only vs full tunnel&lt;/h2&gt;
&lt;p&gt;This is the key design decision. There are two approaches:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Full tunnel&lt;/strong&gt; routes &lt;em&gt;all&lt;/em&gt; traffic through the VPN. Every web request, every app connection, everything goes home first and then out to the internet. This gives you full control but means all traffic takes a round trip through your home broadband, which adds latency and uses upload bandwidth.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;DNS-only routing&lt;/strong&gt; sends &lt;em&gt;only DNS queries&lt;/em&gt; through the VPN. The actual browsing traffic goes directly to the internet via whatever network the device is on. But because DNS is resolved at home, AdGuard Home&#39;s filtering still applies everywhere.&lt;/p&gt;
&lt;p&gt;I went with &lt;strong&gt;DNS-only routing&lt;/strong&gt; for the children&#39;s devices. The filtering is the point, not inspecting traffic. This keeps browsing fast while still blocking ads, trackers and inappropriate content on every network.&lt;/p&gt;
&lt;h2&gt;Server setup&lt;/h2&gt;
&lt;h3&gt;Install WireGuard&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install wireguard
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Generate server keys&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;wg genkey | tee /etc/wireguard/server_private.key | wg pubkey &amp;gt; /etc/wireguard/server_public.key
chmod 600 /etc/wireguard/server_private.key
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Server configuration&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;/etc/wireguard/wg0.conf&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;Note: replace &lt;code&gt;enp1s0&lt;/code&gt; below with your server&#39;s actual NIC name. Run &lt;code&gt;ip -br link&lt;/code&gt; to find it - modern Debian uses predictable names like &lt;code&gt;enp1s0&lt;/code&gt; or &lt;code&gt;eno1&lt;/code&gt;, not &lt;code&gt;eth0&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = &amp;lt;server_private_key&amp;gt;

# NAT for VPN clients
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o enp1s0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o enp1s0 -j MASQUERADE

# Child&#39;s iPad
[Peer]
PublicKey = &amp;lt;client_public_key&amp;gt;
AllowedIPs = 10.10.0.2/32
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enable IP forwarding:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo &amp;quot;net.ipv4.ip_forward = 1&amp;quot; | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start and enable the service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl enable wg-quick@wg0
sudo systemctl start wg-quick@wg0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Port forwarding&lt;/h3&gt;
&lt;p&gt;You&#39;ll need to forward UDP port 51820 on your router to the server&#39;s local IP. This is the only port WireGuard needs.&lt;/p&gt;
&lt;h2&gt;Client configuration&lt;/h2&gt;
&lt;p&gt;For each child&#39;s device, generate a key pair:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;wg genkey | tee client_private.key | wg pubkey &amp;gt; client_public.key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client configuration is where the DNS-only routing magic happens:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[Interface]
PrivateKey = &amp;lt;client_private_key&amp;gt;
Address = 10.10.0.2/24
DNS = 10.10.0.1

[Peer]
PublicKey = &amp;lt;server_public_key&amp;gt;
Endpoint = your-home-ip:51820
AllowedIPs = 10.10.0.0/24
PersistentKeepalive = 25
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;PersistentKeepalive = 25&lt;/code&gt; line tells the client to send a packet every 25 seconds. Without it, mobile carrier NATs quietly drop the connection&#39;s state after a minute or two of silence and the server can no longer reach the device until the device sends something first.&lt;/p&gt;
&lt;p&gt;The critical line is &lt;code&gt;AllowedIPs = 10.10.0.0/24&lt;/code&gt;. This tells WireGuard to only route traffic destined for the VPN subnet through the tunnel. Since &lt;code&gt;DNS = 10.10.0.1&lt;/code&gt; points to the server&#39;s VPN address, DNS queries go through the tunnel and hit AdGuard Home. Everything else goes direct.&lt;/p&gt;
&lt;p&gt;If you wanted a full tunnel instead, you&#39;d set &lt;code&gt;AllowedIPs = 0.0.0.0/0&lt;/code&gt; which routes everything through the VPN.&lt;/p&gt;
&lt;h3&gt;Getting it onto the devices&lt;/h3&gt;
&lt;p&gt;The easiest way is to generate a QR code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install qrencode
qrencode -t ansiutf8 &amp;lt; client.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Open the WireGuard app on iOS or Android, tap &amp;quot;Add tunnel&amp;quot;, scan the QR code, done. The whole process takes about 30 seconds per device.&lt;/p&gt;
&lt;h2&gt;AdGuard Home integration&lt;/h2&gt;
&lt;p&gt;For this to work properly, AdGuard Home needs to listen on the WireGuard interface too. In AdGuard Home&#39;s settings, make sure it&#39;s bound to &lt;code&gt;0.0.0.0&lt;/code&gt; (all interfaces) or specifically includes &lt;code&gt;10.10.0.1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You can also configure AdGuard Home with per-client settings. I have the children&#39;s VPN IPs set up with stricter filtering rules (safe search enforced, adult content blocked) while my own devices have a lighter touch.&lt;/p&gt;
&lt;h2&gt;On-demand VPN on iOS&lt;/h2&gt;
&lt;p&gt;WireGuard&#39;s iOS app supports On-Demand activation per-network. In the app, edit the tunnel, enable On-Demand, and add your home Wi-Fi SSIDs to the &lt;em&gt;Disconnect on demand&lt;/em&gt; list. The result: the tunnel comes up automatically anywhere except home, where the LAN already has filtered DNS via the router. iOS sometimes takes a few seconds to bring the tunnel up after a network change - but it&#39;s reliable enough that I haven&#39;t had to think about it for months.&lt;/p&gt;
&lt;h2&gt;The result&lt;/h2&gt;
&lt;p&gt;Whether a device is at home, on school Wi-Fi, at a friend&#39;s house, or using mobile data, DNS queries go through the home filtering system. Ads are blocked, trackers are blocked, and inappropriate content is filtered. All without installing any software beyond the WireGuard app, and with no noticeable impact on browsing speed.&lt;/p&gt;
&lt;p&gt;The whole stack (N100 server, Debian, AdGuard Home, Unbound, WireGuard) has been running for months now without intervention. It&#39;s the kind of infrastructure that just fades into the background and works, which is exactly the point.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This is the final article in my home server series. Previously: &lt;a href=&quot;https://martinhicks.dev/articles/n100-home-server-build&quot;&gt;building the N100 server&lt;/a&gt; and &lt;a href=&quot;https://martinhicks.dev/articles/adguard-unbound-dns-home&quot;&gt;AdGuard Home with Unbound for private DNS&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
      <pubDate>Mon, 28 Jul 2025 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/wireguard-safe-browsing-kids</guid>
    </item>
    <item>
      <title>Running AdGuard Home and Unbound on a home server</title>
      <link>https://martinhicks.dev/articles/adguard-unbound-dns-home</link>
      <description>&lt;p&gt;After &lt;a href=&quot;https://martinhicks.dev/articles/n100-home-server-build&quot;&gt;building my Intel N100 home server&lt;/a&gt;, the next step was improving the network itself. The server was sitting there running 24/7, barely using any resources. Perfect for handling DNS.&lt;/p&gt;
&lt;p&gt;My goals were straightforward:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Block ads and tracking across every device on the network&lt;/li&gt;
&lt;li&gt;Block malicious and adult content domains&lt;/li&gt;
&lt;li&gt;Stop relying on third-party DNS resolvers for privacy&lt;/li&gt;
&lt;li&gt;Get better visibility into what&#39;s happening on the network&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The solution I settled on was &lt;strong&gt;AdGuard Home&lt;/strong&gt; for filtering, combined with &lt;strong&gt;Unbound&lt;/strong&gt; for recursive DNS resolution. Together they give you a private, fast, ad-free DNS setup that you fully control.&lt;/p&gt;
&lt;h2&gt;Update - May 2026&lt;/h2&gt;
&lt;p&gt;A few months in, I disabled Unbound. The privacy story is genuinely great, but cold-cache lookups for less popular domains were noticeably slower than I wanted on a household network - enough that family members were noticing. AdGuard Home now forwards directly to a fast public resolver. The walkthrough below still works exactly as written if you&#39;d like to try Unbound yourself; this is just my honest experience after living with the setup for a while.&lt;/p&gt;
&lt;h2&gt;The architecture&lt;/h2&gt;
&lt;p&gt;The DNS query flow looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Device → Router → AdGuard Home → Unbound → Root DNS servers
                   (filter)      (resolve)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every device on the network sends DNS queries to AdGuard Home (via the router&#39;s DHCP settings). AdGuard checks the query against its blocklists. If the domain is allowed, it forwards the query to Unbound, which performs recursive resolution starting from the root DNS servers.&lt;/p&gt;
&lt;p&gt;No Google DNS. No Cloudflare. No third party sees your queries.&lt;/p&gt;
&lt;h2&gt;AdGuard Home&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/AdguardTeam/AdGuardHome&quot;&gt;AdGuard Home&lt;/a&gt; is a network-wide DNS filtering gateway. Think Pi-hole, but with a more polished interface and built-in support for DNS-over-HTTPS, DNS-over-TLS and DNS-over-QUIC.&lt;/p&gt;
&lt;p&gt;Every device that uses the network DNS automatically gets:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ad blocking&lt;/strong&gt; across all apps, not just browsers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tracker blocking&lt;/strong&gt; for analytics, telemetry and fingerprinting domains&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Malware domain blocking&lt;/strong&gt; via regularly updated threat lists&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Adult content filtering&lt;/strong&gt; (configurable per-client, handy with children in the house)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query logging&lt;/strong&gt; with a clean dashboard showing top clients, blocked queries, and trends&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Installation&lt;/h3&gt;
&lt;p&gt;I installed AdGuard Home directly on the host using their official script:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -v
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After installation, the web UI is available on port 3000 for initial setup, then moves to port 80 (or wherever you configure it).&lt;/p&gt;
&lt;h3&gt;Configuration&lt;/h3&gt;
&lt;p&gt;The key settings I changed from defaults:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Upstream DNS&lt;/strong&gt;: Set to &lt;code&gt;127.0.0.1:5335&lt;/code&gt; (Unbound, see below)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bootstrap DNS&lt;/strong&gt;: &lt;code&gt;9.9.9.9&lt;/code&gt; (Quad9, only used to resolve filter list domains during startup)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blocking mode&lt;/strong&gt;: Default (0.0.0.0) which returns a null route for blocked domains&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rate limit&lt;/strong&gt;: Disabled (it&#39;s a home network, not a public resolver)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query log retention&lt;/strong&gt;: 7 days&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For blocklists, I use:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AdGuard DNS filter (default)&lt;/li&gt;
&lt;li&gt;OISD blocklist (comprehensive, well-maintained)&lt;/li&gt;
&lt;li&gt;Steven Black&#39;s unified hosts&lt;/li&gt;
&lt;li&gt;A custom list for a handful of domains I want blocked specifically&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Between these lists, around 15-20% of all DNS queries on the network get blocked. That&#39;s a lot of ads, trackers and telemetry that never reaches any device.&lt;/p&gt;
&lt;h2&gt;Unbound&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://nlnetlabs.nl/projects/unbound/about/&quot;&gt;Unbound&lt;/a&gt; is a validating, recursive, caching DNS resolver. Rather than forwarding queries to an upstream provider like 8.8.8.8 or 1.1.1.1, Unbound resolves domains itself by walking the DNS hierarchy from the root servers down.&lt;/p&gt;
&lt;h3&gt;Why bother with recursive resolution?&lt;/h3&gt;
&lt;p&gt;When you use a third-party resolver, they see every domain you look up. Even with DNS-over-HTTPS, you&#39;re trusting that provider with your full browsing history. With Unbound resolving recursively, no single upstream server sees the full picture. The root servers see you asking about &lt;code&gt;.dev&lt;/code&gt;, the &lt;code&gt;.dev&lt;/code&gt; TLD servers see you asking about &lt;code&gt;martinhicks.dev&lt;/code&gt;, and the authoritative nameserver sees the full query. Privacy through distribution.&lt;/p&gt;
&lt;h3&gt;Installation and configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install unbound
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The main configuration file at &lt;code&gt;/etc/unbound/unbound.conf.d/pi-hole.conf&lt;/code&gt; (the name is a leftover from many guides, works fine):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;server:
    verbosity: 0

    interface: 127.0.0.1
    port: 5335

    do-ip4: yes
    do-udp: yes
    do-tcp: yes
    do-ip6: no

    prefer-ip6: no

    # Root hints for recursive resolution
    root-hints: &amp;quot;/var/lib/unbound/root.hints&amp;quot;

    # Trust glue only if it is within the server&#39;s authority
    harden-glue: yes
    harden-dnssec-stripped: yes

    use-caps-for-id: no

    # Reduce EDNS reassembly buffer size
    edns-buffer-size: 1232

    prefetch: yes

    num-threads: 1

    so-rcvbuf: 1m

    private-address: 192.168.0.0/16
    private-address: 169.254.0.0/16
    private-address: 172.16.0.0/12
    private-address: 10.0.0.0/8
    private-address: fd00::/8
    private-address: fe80::/10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The root hints file needs updating periodically (I have a cron job for this):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo wget -O /var/lib/unbound/root.hints https://www.internic.net/domain/named.root
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Unbound listens on &lt;code&gt;127.0.0.1:5335&lt;/code&gt;, and AdGuard Home forwards allowed queries to it.&lt;/p&gt;
&lt;h2&gt;Performance&lt;/h2&gt;
&lt;p&gt;Running DNS locally is fast. After the initial recursive lookup, Unbound caches the result, so subsequent queries for the same domain return in under a millisecond. Even uncached lookups typically resolve in 20-50ms. In hindsight that&#39;s a touch optimistic - the popular stuff is fast, but the long tail of cold lookups is where I felt the latency tax most.&lt;/p&gt;
&lt;p&gt;The N100 handles this effortlessly. CPU usage from AdGuard and Unbound combined is negligible, hovering around 1-2%. Memory usage was light when I first measured it, well under 100MB total. AdGuard&#39;s footprint grows over time as filter lists get larger, so don&#39;t be surprised to see it considerably higher months down the line.&lt;/p&gt;
&lt;p&gt;The dashboard in AdGuard Home is genuinely useful too. Being able to see which devices are making the most queries, what&#39;s being blocked, and spotting unusual patterns has been eye-opening. You quickly learn just how chatty some devices and apps are.&lt;/p&gt;
&lt;h2&gt;Router configuration&lt;/h2&gt;
&lt;p&gt;The final step is telling your router to hand out the server&#39;s IP as the DNS server via DHCP. On most routers this is somewhere in the LAN/DHCP settings. Set the primary DNS to your server&#39;s local IP and remove any secondary DNS (otherwise devices will fall back to it and bypass filtering).&lt;/p&gt;
&lt;p&gt;If your router doesn&#39;t support custom DNS settings via DHCP, you can configure devices individually, though that&#39;s less convenient.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This is the second article in my home server series. Previously: &lt;a href=&quot;https://martinhicks.dev/articles/n100-home-server-build&quot;&gt;building the N100 server&lt;/a&gt;. Next: &lt;a href=&quot;https://martinhicks.dev/articles/wireguard-safe-browsing-kids&quot;&gt;using WireGuard so kids&#39; devices stay protected on any network&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
      <pubDate>Sun, 20 Jul 2025 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/adguard-unbound-dns-home</guid>
    </item>
    <item>
      <title>Building a tiny Intel N100 home server</title>
      <link>https://martinhicks.dev/articles/n100-home-server-build</link>
      <description>&lt;p&gt;Over the past few years my home setup has gradually grown more complicated. Between family devices, work machines, backups, development tools and general tinkering, I&#39;d slowly accumulated a mixture of cloud services, routers and older hardware doing different jobs. None of it was joined up, and none of it was particularly efficient.&lt;/p&gt;
&lt;p&gt;I wanted a &lt;strong&gt;single small home server&lt;/strong&gt; that could handle a few key tasks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Network services (DNS filtering, VPN)&lt;/li&gt;
&lt;li&gt;Time Machine backups for every Mac in the house&lt;/li&gt;
&lt;li&gt;Some lightweight file storage&lt;/li&gt;
&lt;li&gt;A place to run a few containers&lt;/li&gt;
&lt;li&gt;Something quiet enough to live in the office and cheap enough to run 24/7&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After a bit of research I settled on building a tiny &lt;strong&gt;Intel N100 based server&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;Why the N100&lt;/h2&gt;
&lt;p&gt;The Intel N100 is a 4-core, 4-thread processor from the Alder Lake-N family. It was designed for low-power, embedded use cases, but it&#39;s surprisingly capable. The key specs that sold me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;6W TDP (yes, six watts)&lt;/li&gt;
&lt;li&gt;Hardware AES-NI (important for VPN throughput)&lt;/li&gt;
&lt;li&gt;Support for DDR4 or DDR5 depending on the board&lt;/li&gt;
&lt;li&gt;Passive cooling on most boards&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For a home server that runs DNS, VPN, backups and the occasional container, it&#39;s more than enough. This isn&#39;t a Plex transcoding box or a development build server. It&#39;s a quiet, reliable workhorse.&lt;/p&gt;
&lt;h2&gt;Hardware&lt;/h2&gt;
&lt;p&gt;The core of the build is an &lt;strong&gt;ASRock N100DC-ITX&lt;/strong&gt; motherboard. This board is powered by a standard laptop-style DC barrel jack, which means no ATX PSU, no fan noise from a power supply, and a much smaller footprint.&lt;/p&gt;
&lt;p&gt;The full parts list:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Motherboard&lt;/td&gt;
&lt;td&gt;ASRock N100DC-ITX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Case&lt;/td&gt;
&lt;td&gt;SKTC MX01 mini-ITX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM&lt;/td&gt;
&lt;td&gt;16GB Crucial DDR4 SODIMM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boot drive&lt;/td&gt;
&lt;td&gt;500GB Samsung 980 NVMe SSD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;2TB WD Blue 2.5&amp;quot; HDD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power&lt;/td&gt;
&lt;td&gt;19V external laptop PSU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Total cost was around £250. The whole system is incredibly compact and almost entirely silent. The only moving part is the 2.5&amp;quot; HDD, which only spins up for backups.&lt;/p&gt;
&lt;h2&gt;Assembly&lt;/h2&gt;
&lt;p&gt;The build was straightforward, if a little tight due to the mini-ITX case:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Slot in the SODIMM RAM&lt;/li&gt;
&lt;li&gt;Install the NVMe SSD (M.2 slot on the underside of the board)&lt;/li&gt;
&lt;li&gt;Mount the motherboard on the standoffs&lt;/li&gt;
&lt;li&gt;Install the 2.5&amp;quot; HDD in the drive bay&lt;/li&gt;
&lt;li&gt;Connect SATA data and power&lt;/li&gt;
&lt;li&gt;Route the DC barrel jack through the rear panel&lt;/li&gt;
&lt;li&gt;Close up the case&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The result is a very tidy little machine that sits quietly on a shelf next to the router. No fans spinning. You&#39;d barely know it was on.&lt;/p&gt;
&lt;h2&gt;Operating system&lt;/h2&gt;
&lt;p&gt;For the OS I chose &lt;strong&gt;Debian 12 (Bookworm)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I considered a few alternatives:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Why I didn&#39;t choose it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TrueNAS&lt;/td&gt;
&lt;td&gt;Overkill for my storage needs, heavy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unraid&lt;/td&gt;
&lt;td&gt;Licence cost, more NAS-focused than I need&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxmox&lt;/td&gt;
&lt;td&gt;Great, but adds a virtualisation layer I don&#39;t need yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ubuntu Server&lt;/td&gt;
&lt;td&gt;Would have been fine, but I prefer Debian&#39;s stability&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Debian felt like the right balance of stability, simplicity and long-term maintainability. The N100 is well supported in the 6.1 kernel. Everything worked out of the box, including the Realtek NIC which can sometimes be problematic.&lt;/p&gt;
&lt;p&gt;I&#39;m running everything directly on the host for now, with Docker available for anything that benefits from containerisation. No VMs, no hypervisor. Keep it simple.&lt;/p&gt;
&lt;h2&gt;What it runs&lt;/h2&gt;
&lt;p&gt;The server now handles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://martinhicks.dev/articles/adguard-unbound-dns-home&quot;&gt;AdGuard Home&lt;/a&gt;&lt;/strong&gt; for network-wide DNS filtering&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://martinhicks.dev/articles/wireguard-safe-browsing-kids&quot;&gt;WireGuard VPN&lt;/a&gt;&lt;/strong&gt; so family devices stay protected outside the house&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time Machine&lt;/strong&gt; backups over SMB for three Macs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Samba&lt;/strong&gt; file shares for general household storage&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailscale&lt;/strong&gt; for low-effort remote access to the server from anywhere&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AirConnect&lt;/strong&gt; for bridging AirPlay to Chromecast and UPnP speakers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Netdata&lt;/strong&gt; for live system monitoring on a local web dashboard&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fail2ban&lt;/strong&gt; guarding SSH&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker&lt;/strong&gt; is installed for the occasional container, though I&#39;m not running anything in it long-term right now&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The DNS and VPN setup in particular has been a real quality-of-life improvement for the whole family. Some of these services get their own dedicated articles in this series; others are smaller pieces I&#39;ll possibly cover in shorter follow-ups.&lt;/p&gt;
&lt;h2&gt;Power usage&lt;/h2&gt;
&lt;p&gt;One of the key goals was efficiency. This thing runs 24/7, so every watt matters.&lt;/p&gt;
&lt;p&gt;Typical measurements with a plug-in power meter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;6-8W idle&lt;/strong&gt; (SSD only, HDD spun down)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;10-12W&lt;/strong&gt; when the HDD is active (backups running)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;8-9W&lt;/strong&gt; under light CPU load (DNS, VPN traffic)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At roughly 8W average, that&#39;s around 70 kWh per year, which costs about £20 in electricity. Compare that to an old desktop repurposed as a server pulling 60-80W idle, and the difference is stark.&lt;/p&gt;
&lt;h2&gt;What I&#39;d change&lt;/h2&gt;
&lt;p&gt;If I were doing this build again:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I considered SSD-only, but in hindsight the spinning HDD has been the right call for Time Machine. Backups are network-bound, not disk-bound, and putting that volume of writes onto an SSD would chew through its endurance for no real gain. The original concern about HDD noise turned out to be overblown too - a one-line &lt;code&gt;hdparm -S 120 /dev/sda&lt;/code&gt; (run as a tiny systemd unit at boot) puts the drive into standby after ten minutes of idle, and since Time Machine is the only thing using it, I genuinely can&#39;t hear it from the same room. I&#39;d keep this same split.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A case with better airflow.&lt;/strong&gt; The SKTC MX01 is compact and looks decent, but cable management is tight. Something like the Jonsbo N1 would give more room, though it&#39;s larger.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Overall though, I&#39;m really happy with this build. It&#39;s been running for months now without a single issue, and it&#39;s become one of those invisible pieces of infrastructure that just works.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This is the first in a series of articles about my home server setup. Next up: &lt;a href=&quot;https://martinhicks.dev/articles/adguard-unbound-dns-home&quot;&gt;running AdGuard Home and Unbound for private DNS filtering&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
      <pubDate>Thu, 03 Jul 2025 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/n100-home-server-build</guid>
    </item>
    <item>
      <title>Remove trailing slashes with CloudFront and 11ty</title>
      <link>https://martinhicks.dev/articles/remove-trailing-slash-cloudfront-s3-11ty</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;This article will walk through the steps required to create a Cloudfront function to handle redirecting trailing slash URIs to non-trailing slash equivalents on your S3 hosted 11ty website.&lt;/p&gt;
&lt;h2&gt;Background&lt;/h2&gt;
&lt;p&gt;Several years ago we published our website at Si Novi using a hand-balled static site generator we built for ourselves, and deployed it to S3 with Cloudfront used for caching and routing our A record from Route 53.&lt;/p&gt;
&lt;p&gt;For this old website we wanted to strip trailing slashes on URLs, so &lt;code&gt;https://example.com/articles/some-article&lt;/code&gt; instead of &lt;code&gt;https://example.com/articles/some-article/&lt;/code&gt;, personal preference I guess.&lt;/p&gt;
&lt;p&gt;Anyway, to achieve this &lt;a href=&quot;https://sinovi.uk/articles/static-website-url-optimisation-with-aws-serverless&quot;&gt;we used a Lamda@Edge function to handle the redirects&lt;/a&gt; for us - 301 redirecting from the trailing slash URI to the non-trailing slash URI - something we&#39;d long achieved using &lt;code&gt;.htaccess&lt;/code&gt; on an Apache server.&lt;/p&gt;
&lt;p&gt;We published this function to the &lt;a href=&quot;https://serverlessrepo.aws.amazon.com/applications/us-east-1/951661612909/LambdaEdgeRemoveTrailingSlash&quot;&gt;AWS Serverless Application Repository&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Fast forward a few years, and we now publish our website using &lt;a href=&quot;https://11ty.dev/&quot;&gt;Eleventy&lt;/a&gt; - still hosted on S3, still fronted with the Cloudfront CDN, but our long-standing redirect function no longer worked.&lt;/p&gt;
&lt;p&gt;With 11ty we were hitting a redirect loop which we believe was due to it&#39;s use of subfolder index.html pages - our old hand-balled system created S3 objects like &lt;code&gt;articles/some-article.html&lt;/code&gt; whereas Eleventy creates S3 objects like &lt;code&gt;articles/some-article/index.html&lt;/code&gt;. The the old system resolved to the object correctly, whereas when using sub-directory within an &lt;code&gt;index.html&lt;/code&gt; as 11ty and others do, this caused an infinite redirect loop.&lt;/p&gt;
&lt;h2&gt;Solution&lt;/h2&gt;
&lt;h3&gt;1. Create a new CloudFront function&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;function handler(event) {
    var request = event.request;
    var uri = request.uri;
    
    var params = &#39;&#39;;
    if((&#39;querystring&#39; in request) &amp;amp;&amp;amp; (request.querystring.length &amp;gt; 0)) {
        params = &#39;?&#39;+request.querystring;
    }
    
    if(uri.endsWith(&#39;/&#39;)) {
        if(uri !== &#39;/&#39;) {
            var response = {
                statusCode: 301,
                statusDescription: &#39;Permanently moved&#39;,
                headers:
                { &amp;quot;location&amp;quot;: { &amp;quot;value&amp;quot;: `${uri.slice(0, -1) + params}` } } // remove trailing slash
            }
    
            return response;    
        }
        
        
    }
    //Check whether the URI is missing a file extension.
    else if (!uri.includes(&#39;.&#39;)) {
        request.uri += &#39;/index.html&#39;;
    }
    
    

    return request;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The above code achieves the same trailing slash removal as we had in our old Lambda@Edge function, but also includes an additional check to ensure that &lt;code&gt;index.html&lt;/code&gt; is appended to any requests on their way to S3 (only if the request doesn&#39;t already include &#39;.&#39;, so &lt;code&gt;image/some-image.png&lt;/code&gt; will pass-through just fine ).&lt;/p&gt;
&lt;h3&gt;2. Publish the function&lt;/h3&gt;
&lt;p&gt;Save and publish your newly created function, in this example I&#39;ve named it &lt;code&gt;subfolder-index&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;3. Configure your Cloudfront distribution to route requests through the function&lt;/h3&gt;
&lt;p&gt;Modify your Cloudfront distribution&#39;s behaviour and set the published function to run on Viewer request.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://martinhicks.dev/images/articles/cloudfront-viewer-request.png&quot; alt=&quot;A screenshot demonstrating how to select the Cloudfront function as a Viewer request&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Wrap up&lt;/h2&gt;
&lt;p&gt;That&#39;s it, you should now be seeing URLs redirected to your non-trailing slash URI preference, while still successfully serving the subfolder index.html file (which wont appear in the URL)&lt;/p&gt;
&lt;p&gt;One benefit of using a Cloudfront function is it&#39;s cheaper to invoke than a Lambda@Edge function.&lt;/p&gt;
&lt;p&gt;You can see our old &lt;a href=&quot;https://github.com/sinovi/lambda-edge-remove-trailing-slash&quot;&gt;Lambda@Edge function here&lt;/a&gt;, which still might be useful.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;ps:  If you&#39;re not hosting subfolder &lt;code&gt;index.html&lt;/code&gt; files you can remove the else if from the Cloudfront function.&lt;/em&gt;&lt;/p&gt;
&lt;hr /&gt;
</description>
      <pubDate>Tue, 16 May 2023 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/remove-trailing-slash-cloudfront-s3-11ty</guid>
    </item>
    <item>
      <title>Enhance CSRF package - now supports multipart form data</title>
      <link>https://martinhicks.dev/articles/enhance-csrf-package-update</link>
      <description>&lt;p&gt;I&#39;ve just published a minor update to my &lt;a href=&quot;https://www.npmjs.com/package/@hicksy/enhance-csrf&quot;&gt;CSRF plugin&lt;/a&gt; for &lt;a href=&quot;https://enhance.dev/&quot;&gt;Enhance projects&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;v0.9.0 now supports &lt;code&gt;multipart/form&lt;/code&gt; data, meaning you can easily use the &lt;code&gt;&amp;lt;csrf-form&amp;gt;&amp;lt;/csrf-form&amp;gt;&lt;/code&gt; component for forms that contain file uploads.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;usage:&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;csrf-form method=&amp;quot;post&amp;quot; action=&amp;quot;/upload&amp;quot; enctype=&amp;quot;multipart/form-data&amp;quot;&amp;gt;
  &amp;lt;input type=&amp;quot;file&amp;quot; name=&amp;quot;file&amp;quot; /&amp;gt;
&amp;lt;/csrf-form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;outputs:&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;form action=&amp;quot;/si-novi/christopher-dee/media/upload&amp;quot; method=&amp;quot;post&amp;quot; enctype=&amp;quot;multipart/form-data&amp;quot;&amp;gt;
  &amp;lt;input type=&amp;quot;hidden&amp;quot; name=&amp;quot;csrf&amp;quot; value=&amp;quot;540c460e-946e-4c78-8c8d-63c4cd091ee9&amp;quot;&amp;gt; &amp;lt;!-- auto-generated hidden input with the unique csrf token for this request (use with verifyCsrfToken on your post handler) --&amp;gt;
  &amp;lt;input type=&amp;quot;file&amp;quot; name=&amp;quot;file&amp;quot;&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Additionally, I&#39;ve also improved the html generation for the component so you can now include all the following optional attributes and they&#39;ll pass-through into your HTML &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; element.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;enctype
target
acceptCharset
autocomplete
id
novalidate
rel
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Of course you can still use the standaone &lt;code&gt;&amp;lt;csrf-input&amp;gt;&amp;lt;/csrf-input&amp;gt;&lt;/code&gt; if you&#39;d prefer to use a standard form tag.&lt;/p&gt;
&lt;h2&gt;Get in touch&lt;/h2&gt;
&lt;p&gt;Any feedback on your usage of this plugin, bugs, or suggestions for improvements please get in touch on &lt;a href=&quot;https://github.com/hicksy/enhance-csrf&quot;&gt;GitHub&lt;/a&gt;. I&#39;d love to hear from you.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href=&quot;https://github.com/hicksy/enhance-csrf&quot;&gt;https://github.com/hicksy/enhance-csrf&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NPM: &lt;a href=&quot;https://www.npmjs.com/package/@hicksy/enhance-csrf&quot;&gt;https://www.npmjs.com/package/@hicksy/enhance-csrf&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
</description>
      <pubDate>Mon, 15 May 2023 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/enhance-csrf-package-update</guid>
    </item>
    <item>
      <title>DynamoDB Streams locally with arc.codes &amp; Enhance</title>
      <link>https://martinhicks.dev/articles/arc-sandbox-table-streams</link>
      <description>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;When working on a recent web app project using &lt;a href=&quot;https://enhance.dev/&quot;&gt;Enhance&lt;/a&gt;, I encountered a requirement for using both transactions and DynamoDB streams, two DynamoDB features that aren&#39;t supported by Dynalite - the fast in-memory DynamoDB engine that &lt;a href=&quot;https://arc.codes/&quot;&gt;Architect&lt;/a&gt; uses to support local development.&lt;/p&gt;
&lt;p&gt;In this article, I&#39;ll introduce my new plugin (&lt;a href=&quot;https://www.npmjs.com/package/@hicksy/arc-plugin-sandbox-stream&quot;&gt;arc-plugin-sandbox-stream&lt;/a&gt;) for working with DynamoDB streams locally within Arc and Enhance projects. This plugin enables developers to use DynamoDB streams locally in their arc or enhance sandbox environment.&lt;/p&gt;
&lt;h2&gt;Background&lt;/h2&gt;
&lt;p&gt;Architect got me hooked back in 2018 with its amazing local developer experience, it&#39;s a huge DX boon to be able to try ideas out on your own machine without being concerned about provisioning real infrastructure, or being tied to a location where network connectivity is guaranteed.&lt;/p&gt;
&lt;p&gt;One of the services Architect spins up locally is &lt;a href=&quot;https://github.com/mhart/dynalite&quot;&gt;Dynalite&lt;/a&gt;. It provides a fast in-memory DynamoDB engine. For lots of projects this engine is absolutely ideal, fast to launch, lightweight and not a process hog. But for this particular project I really needed streams and I wasn&#39;t prepared to sacrifice the local developer experience. So I set about researching how this could be achieved, if at all. Helpfully it&#39;s a question that has cropped up on &lt;a href=&quot;https://discord.com/channels/880272256100601927/1078256032087805972/1079142054279524552&quot;&gt;Arc&#39;s discord&lt;/a&gt; and the thread of replies helped point me in the right direction.&lt;/p&gt;
&lt;p&gt;I&#39;d need to do a few things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Switch to using DynamoDB Local&lt;/li&gt;
&lt;li&gt;Create a middleware to poll the stream for new data&lt;/li&gt;
&lt;li&gt;Invoke the corresponding function handler when new data is returned from the stream.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Using the plugin&lt;/h2&gt;
&lt;p&gt;The plugin kicks in when a &lt;code&gt;@tables-streams&lt;/code&gt; pragma is discovered within your arc config, and queries the table meta data / stream meta data to retrieve iterators for each shard of the table&#39;s stream.&lt;/p&gt;
&lt;p&gt;If data is found the plugin invokes the corresponding lambda function using arc&#39;s inbuilt invoke function.&lt;/p&gt;
&lt;p&gt;Your function code will receive one or more results off the stream like so (as you&#39;d expect if you&#39;ve used streams on AWS):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[
  {
    &amp;quot;eventID&amp;quot;: &amp;quot;7c1ac231-2c9d-4ba0-b3a2-1de2eb6602c0&amp;quot;,
    &amp;quot;eventName&amp;quot;: &amp;quot;MODIFY&amp;quot;,
    &amp;quot;eventVersion&amp;quot;: &amp;quot;1.1&amp;quot;,
    &amp;quot;eventSource&amp;quot;: &amp;quot;aws:dynamodb&amp;quot;,
    &amp;quot;awsRegion&amp;quot;: &amp;quot;ddblocal&amp;quot;,
    &amp;quot;dynamodb&amp;quot;: {
      &amp;quot;ApproximateCreationDateTime&amp;quot;: &amp;quot;2023-04-24T12:19:00.000Z&amp;quot;,
      &amp;quot;Keys&amp;quot;: {
        &amp;quot;sk&amp;quot;: {
          &amp;quot;S&amp;quot;: &amp;quot;account:si-novi&amp;quot;
        },
        &amp;quot;pk&amp;quot;: {
          &amp;quot;S&amp;quot;: &amp;quot;account:si-novi&amp;quot;
        }
      },
      &amp;quot;NewImage&amp;quot;: {
        &amp;quot;_type&amp;quot;: {
          &amp;quot;S&amp;quot;: &amp;quot;Organisation&amp;quot;
        },
        &amp;quot;name&amp;quot;: {
          &amp;quot;S&amp;quot;: &amp;quot;Si Novi&amp;quot;
        },
        &amp;quot;sk&amp;quot;: {
          &amp;quot;S&amp;quot;: &amp;quot;account:si-novi&amp;quot;
        },
        &amp;quot;created_at&amp;quot;: {
          &amp;quot;S&amp;quot;: &amp;quot;2023-04-24T12:19:07.409Z&amp;quot;
        },
        &amp;quot;id&amp;quot;: {
          &amp;quot;S&amp;quot;: &amp;quot;01GYSK850HWHGE36A2VTDG1CPK&amp;quot;
        },
        &amp;quot;pk&amp;quot;: {
          &amp;quot;S&amp;quot;: &amp;quot;account:si-novi&amp;quot;
        },
        &amp;quot;modified_at&amp;quot;: {
          &amp;quot;S&amp;quot;: &amp;quot;2023-04-24T12:19:07.409Z&amp;quot;
        },
        &amp;quot;slug&amp;quot;: {
          &amp;quot;S&amp;quot;: &amp;quot;si-novi&amp;quot;
        }
      },
      &amp;quot;SequenceNumber&amp;quot;: &amp;quot;000000000000000000935&amp;quot;,
      &amp;quot;SizeBytes&amp;quot;: 310,
      &amp;quot;StreamViewType&amp;quot;: &amp;quot;NEW_IMAGE&amp;quot;
    }
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Setup&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. Add the dependency to your project&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;npm install @hicksy/arc-plugin-sandbox-stream&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Configure your project to use @tables-streams in &lt;code&gt;.arc&lt;/code&gt; file&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@tables
example
  pk *String
  sk **String

@tables-streams
example
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;3. Add the plugin to arc config&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@plugins
hicksy/arc-plugin-sandbox-stream
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;4. Setup DynamoDB Local&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There are various ways to use DyanmoDB Local. I opted for the bundled version that comes with &lt;a href=&quot;https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/workbench.settingup.html&quot;&gt;AWS&#39;s NoSQL Workbench tool&lt;/a&gt;. But you can use which ever version you&#39;re comfortable with.&lt;/p&gt;
&lt;p&gt;Just note that if you use the NoSQL Workbench version there&#39;s the following caveats I came accross:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Data storage is persistent in the NoSQL Workbench version - I can&#39;t find a way to set the &lt;code&gt;inMemory&lt;/code&gt; flag available to the standalone / docker version (you&#39;ll need to modify any seed scripts to conditionally insert or drop and re-create the table)&lt;/li&gt;
&lt;li&gt;You need to be running NoSQL Workbench while you develop and remember to toggle on DynamoDB Local each time (not a biggie really, but it&#39;d be great to have the means to set it to autostart or run as a start-up background task)&lt;/li&gt;
&lt;li&gt;The NoSQL Workbench version runs on port 5500, I can&#39;t see a way to change this, but it&#39;s not a problem for me.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;5. Configure Arc to use DynamoDB Local&lt;/strong&gt;&lt;/p&gt;
&lt;h4&gt;Arc environment params&lt;/h4&gt;
&lt;p&gt;Arc makes it really easy to switch out Dynalite for DynamoDB Local.&lt;/p&gt;
&lt;p&gt;There&#39;s a couple of env vars you need to set to get you going.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Tell arc that you&#39;re using an external db&lt;/li&gt;
&lt;li&gt;Change the port used for tables within arc&lt;/li&gt;
&lt;li&gt;Set your region to &lt;code&gt;ddblocal&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You can do this one of several ways.&lt;/p&gt;
&lt;p&gt;Either setting an environment variable ARC_DB_EXTERNAL=true / ARC_TABLES_PORT=5500 in an &lt;code&gt;.env&lt;/code&gt; file, or using the &lt;code&gt;prefs.arc&lt;/code&gt; file and setting the properties within the @sandbox section &lt;a href=&quot;https://arc.codes/docs/en/reference/configuration/local-preferences#local-preferences&quot;&gt;see arc&#39;s docs for more info&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;e.g.&lt;/p&gt;
&lt;p&gt;.env file&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;APP_URL=http://localhost:3333
ARC_TABLES_PORT=5500
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;prefs.arc file&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@sandbox
external-db true
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Arc data seeds&lt;/h4&gt;
&lt;p&gt;As DynamoDB Local data is persistent when using the bundled NoSQL Workbench version, you&#39;ll need to modify any startup data seeds you have and update them to either conditionally insert data, or what I prefer, to delete the table and re-create it each time - this makes it more in keeping with all my other arc projects.&lt;/p&gt;
&lt;h4&gt;Arc table create or update&lt;/h4&gt;
&lt;p&gt;Sadly, as Dynalite doesn&#39;t support streams, arc&#39;s sandbox doesn&#39;t create the table with the required &lt;code&gt;StreamSpecification&lt;/code&gt; param, so you&#39;ll need to supply that either when re-creating the table using &lt;code&gt;createTable&lt;/code&gt; or supplying it via an &lt;code&gt;updateTable&lt;/code&gt; call.&lt;/p&gt;
&lt;p&gt;An example &lt;code&gt;StreamSpecifcation&lt;/code&gt; looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;StreamSpecification: {
	StreamEnabled: true,
	StreamViewType: &#39;NEW_IMAGE&#39; 
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The project I&#39;m working on currently uses Sensedeep&#39;s &lt;a href=&quot;https://github.com/sensedeep/dynamodb-onetable&quot;&gt;OneTable&lt;/a&gt;. So I can achieve the above like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const client = new Dynamo({client: new DynamoDBClient(params)})
const table = new Table({
    name: DDB_NAME,
    client: client,
    logger: true,
    schema: AppSchema,
    partial: false
})

if(await table.exists()) {
	await table.deleteTable(&#39;DeleteTableForever&#39;);
}

await table.createTable({
    StreamSpecification: {
        StreamEnabled: true,
        StreamViewType: &#39;NEW_IMAGE&#39; 
    },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can also do this using the standard JS dynamodb client for any projects that aren&#39;t using an additional tool like onetable.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;tip: use &lt;a href=&quot;https://www.npmjs.com/package/@architect/functions&quot;&gt;@architect/functions&lt;/a&gt; to infer the full name for your DynamoDB table&#39;s, which will be a combination of the name you&#39;ve given and it&#39;s deployement environment etc&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Modify the polling interval [optional]&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;By default the plugin will poll for new stream data every 10 seconds. You can override this by providing an alternate millisecond interval by updating your &lt;code&gt;.arc&lt;/code&gt; file&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@sandbox-table-streams
polling_interval 1000
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Get in touch&lt;/h3&gt;
&lt;p&gt;Any feedback on your usage of this plugin, bugs, or suggestions for improvements please get in touch on &lt;a href=&quot;https://github.com/hicksy/arc-plugin-sandbox-stream&quot;&gt;GitHub&lt;/a&gt;. I&#39;d love to hear from you.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href=&quot;https://github.com/hicksy/arc-plugin-sandbox-stream&quot;&gt;https://github.com/hicksy/arc-plugin-sandbox-stream&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NPM: &lt;a href=&quot;https://www.npmjs.com/package/@hicksy/arc-plugin-sandbox-stream&quot;&gt;https://www.npmjs.com/package/@hicksy/arc-plugin-sandbox-stream&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
</description>
      <pubDate>Mon, 24 Apr 2023 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/arc-sandbox-table-streams</guid>
    </item>
    <item>
      <title>A mistake the internet won&#39;t forget easily</title>
      <link>https://martinhicks.dev/articles/how-a-misconfigured-header-caused-an-unforgettable-problem</link>
      <description>&lt;p&gt;&lt;em&gt;&lt;strong&gt;tldr&lt;/strong&gt;: If you visited my site between 2022-12-01 and 2022-12-18 you might not be seeing updated content on previously accessed pages. I&#39;ve added a &lt;code&gt;Clear-Site-Data&lt;/code&gt; header to this page to possibly rectify. If you don&#39;t see multiple blogs posts on the homepage after viewing this article, please manually refresh any page you previously visited - thank you&lt;/em&gt; 🙏&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;edit: 2022-12-20&lt;/strong&gt; - Following a &lt;a href=&quot;https://indieweb.social/@martinhicks/109542056075959267&quot;&gt;brief discussion with Jake Archibold&lt;/a&gt; he offered the suggestion to use a fetch request with the &lt;code&gt;cache: &#39;reload&#39;&lt;/code&gt; property.&lt;/em&gt;
&lt;em&gt;The nice thing about this is that it&#39;s cross-browser, so even Safari on both Desktop and iOS should re-validate their local cache using this technique.&lt;/em&gt;
&lt;em&gt;It&#39;s still not something you&#39;d want to put on every request - &lt;a href=&quot;https://martinhicks.dev/articles/how-a-misconfigured-header-caused-an-unforgettable-problem#update-20th-dec-2022&quot;&gt;I&#39;ve added an update below that describes this&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;A couple of weeks or so ago, enthused by my move to &lt;a href=&quot;https://indieweb.social/@martinhicks&quot;&gt;Mastodon&lt;/a&gt;, the growing desire of owning your own content post-Musk, and an upcoming New Year&#39;s resolution I&#39;d made with myself to be &amp;quot;less mute&amp;quot; on the internet in 2023 - I decided to re-publish my website and start sharing my thoughts in longer form writing.&lt;/p&gt;
&lt;p&gt;Over the weekend &lt;em&gt;(hungover, after some pre-Christmas drinks the night before)&lt;/em&gt; I realised I&#39;d made a BIG mistake with the website deployment mechanism - accidentally setting a long-term &lt;code&gt;cache-control&lt;/code&gt; header for all resources, not just the immutable ones 🤦🏻‍♂️.&lt;/p&gt;
&lt;p&gt;Read on for what what caused the problem, how I&#39;ve resolved it, the steps I&#39;ve taken to mitigate it, and what I wish was possible to limit damage following a situation like this in the future.&lt;/p&gt;
&lt;h2&gt;Automatic deployments&lt;/h2&gt;
&lt;p&gt;I update this site using a &lt;a href=&quot;https://github.com/hicksy/martinhicks.net/blob/main/.github/workflows/deploy.yml&quot; target=&quot;_blank&quot; class=&quot;external&quot;&gt;GitHub action&lt;/a&gt;; triggering a build of my static site and transferring the web assets over to AWS S3 where I statically host my website.&lt;/p&gt;
&lt;p&gt;It&#39;s within this S3 sync command that I made the galling mistake...&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;aws s3 sync ./_site s3://martinhicks.net --cache-control max-age=31536000 --delete --acl=public-read --follow-symlinks
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you&#39;re not familiar with the AWS S3 cli - this command transfers the directory &lt;code&gt;/_site&lt;/code&gt; to the S3 bucket hosting this website.&lt;/p&gt;
&lt;p&gt;As you can see I use a number of CLI params:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;--delete&lt;/code&gt;: This tells the &lt;code&gt;s3 sync&lt;/code&gt; command to delete any items that exist in the destination bucket, but aren&#39;t in the local source directory &lt;em&gt;(perfect for clearing up old, no-longer needed objects)&lt;/em&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;--acl=public-read&lt;/code&gt;: Every object uploaded to the bucket has public read enabled - this being a public website, you need every asset to be available. Omitting this would prevent you from accessing the page, despite the bucket itself being public&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;--follow-symlink&lt;/code&gt;: I &lt;em&gt;think&lt;/em&gt; this is a default actually, but added it in just to make sure any content symlinked into that source directory was actually transferred&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;--cache-control&lt;/code&gt;: The biggie, the doozy.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here I inform S3 to set the Cache-Control header - a header S3 will include when returning a request for each individual web asset - a html page, a css file, an image, a sitemap.xml... i.e. everything I&#39;ve just synced.&lt;/p&gt;
&lt;p&gt;In haste I inadvertently set the &lt;code&gt;max-age&lt;/code&gt; value to &lt;code&gt;31536000&lt;/code&gt; - that&#39;s ONE YEAR - and I told S3 to do so for all my assets. Therefore for every request, S3 will return the header &lt;code&gt;cache-control: max-age=31536000&lt;/code&gt;, which tells your web browser that it&#39;s allowed to cache the contents of the page you requested &lt;em&gt;&lt;em&gt;for up to one year.&lt;/em&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;Why is this a problem - caching is good right?&lt;/h2&gt;
&lt;p&gt;So firstly, caching is great.&lt;/p&gt;
&lt;p&gt;It&#39;s definitely the right thing to do. You&#39;ll improve the speed in which return visitors access your page(s), and you&#39;ll receive a better Page Speed Rank from Google, which is a &lt;a href=&quot;https://developers.google.com/search/blog/2010/04/using-site-speed-in-web-search-ranking&quot;&gt;ranking factor on Google&#39;s search results&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Using a CDN is even better - I do that here too with &lt;a href=&quot;https://aws.amazon.com/cloudfront/&quot;&gt;AWS CloudFront&lt;/a&gt; storing a cache on the edge, closer to where you&#39;re accessing the internet from.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;eg: you live in London, someone else from London has already accessed this page, you then visit the same page, and the CDN will serve you from the edge cache - win for you as the page will load much faster, bonus for a webmaster as the origin server won&#39;t have to handle the request&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Anyway that&#39;s an aside.&lt;/p&gt;
&lt;p&gt;Here I&#39;m taking about the &lt;code&gt;cache-control: max-age=31536000&lt;/code&gt; header I was inadvertently serving.&lt;/p&gt;
&lt;p&gt;The issue with that header is the liberal use of it for all my assets. It&#39;s absolutely the correct header to return for anything immutable - images, css etc.&lt;/p&gt;
&lt;p&gt;If you visit &lt;a href=&quot;https://martinhicks.net/&quot; target=&quot;_blank&quot; class=&quot;external&quot;&gt;my home page&lt;/a&gt;, you&#39;ll see that it has some changeable content - a list of latest blog posts that are updated when I publish a new article. So by setting this long-term cache on all web assets, I&#39;d created a situation where the next time you visit my homepage you&#39;d think I hadn&#39;t written any new content - you&#39;d likely move on elsewhere - you most certainly wouldn&#39;t think;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I know - I&#39;ll hit refresh, just in case Martin has messed up his cache headers.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I had an inkling something wasn&#39;t right during development, but given I&#39;m an impatient soul, while my site was being synced with S3, in another tab I would be furiously hitting refresh to see the changes&lt;/p&gt;
&lt;p&gt;&lt;em&gt;hitting refresh clears the cache, and hid the problem&lt;/em&gt;. It was only when I revisited a page on mobile (iOS Safari), that I realised older content was being served.&lt;/p&gt;
&lt;h2&gt;The correct way to cache content for a website&lt;/h2&gt;
&lt;p&gt;If you&#39;ve read this far, you might be interested in seeing a more sensible mechanism for caching content using the S3 sync command.&lt;/p&gt;
&lt;p&gt;I updated my GitHub action to make two separate calls to s3 sync.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//long-lived cache for images and css
aws s3 sync ./_site s3://martinhicks.net --cache-control &#39;max-age=31536000&#39; --delete --acl=public-read --follow-symlinks --exclude &#39;*&#39; --include &#39;images/*&#39; --include &#39;css/*&#39;

//no-cache on everything else
aws s3 sync ./_site s3://martinhicks.net --cache-control &#39;no-cache&#39; --delete --acl=public-read --follow-symlinks --exclude &#39;images/*&#39; --exclude &#39;css/*&#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first command ensures that my immutable content (images and css) are served with a year long &lt;code&gt;max-age&lt;/code&gt;. Telling the browser that once you&#39;ve received an image or a css file for the first time to cache it, and keep it in cache for up-to a year. &lt;em&gt;(i.e. your browser won&#39;t keep requesting it on subsequent pages, or reloads, over the internet. It&#39;ll serve that image from your local browser cache instead.)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The key props to that are as follows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;--exclude &#39;*&#39;&lt;/code&gt;: tell s3 sync to exclude every item in the source &lt;code&gt;_/site&lt;/code&gt; directory &lt;em&gt;(nothing that isn&#39;t explicitly included will be transferred to S3)&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--include &#39;images/*&#39;&lt;/code&gt; &amp;amp; &lt;code&gt;--include &#39;css/*&lt;/code&gt;: tell s3 sync to include anything within images and the css directory&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With the second pass of S3 sync, we invert that - excluding the &lt;code&gt;images&lt;/code&gt; and &lt;code&gt;css&lt;/code&gt; directories. This run will upload every web asset that isn&#39;t in the two excluded directories while setting a cache-control header of &lt;code&gt;no-cache&lt;/code&gt; &lt;em&gt;(i.e. don&#39;t locally cache the HTML page in browser, re-request it from the CDN or origin as appropriate for every request)&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;Fixing the problem for returning visitors&lt;/h2&gt;
&lt;p&gt;Sadly, this isn&#39;t a problem that&#39;s easily fixed.&lt;/p&gt;
&lt;p&gt;First off, you need to hope anyone who accessed page content with a long cache header, will revisit your site to read something new you&#39;ve written - this is the only way to set a new header after all, as the old content, say the index page, will have already been stored long-term in the returning-visitor&#39;s browsers cache.&lt;/p&gt;
&lt;p&gt;After studying MDN I found the &lt;code&gt;Clear-Site-Data&lt;/code&gt; header, which is exactly what I need.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data&quot;&gt;As MDN states&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The Clear-Site-Data header clears browsing data (cookies, storage, cache) associated with the requesting website. It allows web developers to have more control over the data stored by a client browser for their origins.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Great. So this article is being served with the following header.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Clear-Site-Data: &amp;quot;cache&amp;quot;&lt;/code&gt;. Which I&#39;ve acheived by creating a response headers policy on CloudFront that includes this header in the response for this specific path.&lt;/p&gt;
&lt;p&gt;Which I sincerely hope means your browser has now been &lt;a href=&quot;https://meninblack.fandom.com/wiki/Neuralyzer&quot;&gt;zapped by a Neuralyzer&lt;/a&gt; and forgotten any cached data it stored. It&#39;s good for all browsers except for Safari (desktop and iOS), and Firefox (although that&#39;s behind a feature flag, so hopefully wider roll-out will be in an iminent version).&lt;/p&gt;
&lt;p&gt;~&lt;em&gt;(If you&#39;re on iOS, maybe you could humour me by giving the &lt;a href=&quot;https://martinhicks.net/&quot; target=&quot;_blank&quot; class=&quot;external&quot;&gt;homepage&lt;/a&gt; and &lt;a href=&quot;https://martinhicks.net/articles&quot; target=&quot;_blank&quot; class=&quot;external&quot;&gt;journal&lt;/a&gt; page a refresh?)&lt;/em&gt;~&lt;/p&gt;
&lt;h2&gt;Is there a better solution?&lt;/h2&gt;
&lt;p&gt;I&#39;m currently only applying the &lt;code&gt;Clear-Site-Data&lt;/code&gt; header to this page and really hoping / relying on some of the people that read my earlier articles might stumble upon this one? Which is probably unrealistic...&lt;/p&gt;
&lt;p&gt;It feels like I shouldn&#39;t apply this header on every new page I publish from this point forward - wouldn&#39;t that mean the browser will never cache any data? I guess I could include the header for a limited period on all html pages, but it still doesn&#39;t feel quite right.&lt;/p&gt;
&lt;p&gt;What I really wish is that there was a more deterministic header I could apply, say something like:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Clear-Site-Data:  &#39;cache&#39; &#39;2022-12-01:2022-12-18&#39;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Wouldn&#39;t it be handy to be able to say &amp;quot;clear this site cache if the item was stored between the following dates&amp;quot;?&lt;/p&gt;
&lt;p&gt;That way I&#39;d be able to drop this header onto all future assets, and eventually anyone returning to my site will have the rogue cached pages cleared - without affecting the behaviour of anything cached before or after the incident date.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: I have no idea how the internals of a browser work. Maybe this is nonsense as the item in cache might just store its expires-at date, rather than the date it was accessed and originally stored?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;To be honest, I&#39;m probably over analysing the problem. There&#39;s been very little inter-page navigation here - most people have followed a link to a specific article, read it and then moved on. But it&#39;s still bugging me...&lt;/p&gt;
&lt;p&gt;I&#39;d be keen to hear from anyone who has any better ideas. Is there a more appropriate solution I could apply?&lt;/p&gt;
&lt;p&gt;You can contact me at &lt;a href=&quot;https://indieweb.social/@martinhicks&quot;&gt;https://indieweb.social/@martinhicks&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;update-20th-dec-2022&quot;&gt;Update: 20th Dec 2022:&lt;/h2&gt;
&lt;p&gt;I outlined my issue on the &lt;a href=&quot;https://indieweb.social/@martinhicks/109542056075959267&quot;&gt;Fediverse&lt;/a&gt; - and tagged in &lt;a href=&quot;https://indieweb.social/@jaffathecake@mastodon.social&quot;&gt;Jake Archibald&lt;/a&gt; and &lt;a href=&quot;https://indieweb.social/@slightlyoff@toot.cafe&quot;&gt;Alex Russell&lt;/a&gt; to see if they had any ideas - I wasn&#39;t expecting a response tbh, so was blown away that they took time out to offer their thoughts.&lt;/p&gt;
&lt;p&gt;Alex rightly pointed out that Apple lagging behind on this spec &lt;em&gt;(and many more, let&#39;s be clear)&lt;/em&gt;, and the general issues holding up improving the web, are largely down to the lack of browser competition on iOS - something the &lt;a href=&quot;https://open-web-advocacy.org/&quot;&gt;Open Web Advocacy (OWA)&lt;/a&gt; group are looking to change. I completely agree and support this cause, and I&#39;m going to reach out on &lt;a href=&quot;https://discord.gg/x53hkqrRKx&quot;&gt;OWA&#39;s Discord&lt;/a&gt; to see how I go about helping. If you care about the web, then why don&#39;t you too?&lt;/p&gt;
&lt;p&gt;Jake offered the following suggestion - &lt;code&gt;fetch(brokenUrl, { cache: &#39;reload&#39; })&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;I tested this out on a random test URL and set the long &lt;code&gt;max-time&lt;/code&gt; on its &lt;code&gt;cache-control&lt;/code&gt; response header. I then hit a second page (after changing the initial test page&#39;s &lt;code&gt;cache-control&lt;/code&gt; to &lt;code&gt;no-cache&lt;/code&gt;), which included the suggested fetch call to the now stuck test page.&lt;/p&gt;
&lt;p&gt;After hitting the page with this fetch command, the rogue stuck resource was re-requested by Safari, and when I returned to the original long-cached page it had it&#39;s new &lt;code&gt;cache-control: no-cache&lt;/code&gt; applied.&lt;/p&gt;
&lt;p&gt;It&#39;s perfect for what I need - a cross-browser solution, albeit with an additional couple of network requests as you need to specify individual stuck resources - there&#39;s no site-wide mechanism like with &lt;code&gt;clear-site-data&lt;/code&gt;. But these quick tests proved that you can clear the cache of a previously visited page, from a second independent page (on the same domain).&lt;/p&gt;
&lt;p&gt;This technique uses the &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Request/cache&quot;&gt;Request.cache api&lt;/a&gt; to tell the browser to re-request the URL specified without using any existing local cache. And to re-populate its cache based on the rules of the new &lt;code&gt;cache-contol&lt;/code&gt; header.&lt;/p&gt;
&lt;p&gt;Like with &lt;code&gt;clear-site-data&lt;/code&gt; you wouldn&#39;t want to apply this to all your pages, but if you can get your returning-vistors to view some new content* it&#39;s a great solution.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;* perhaps a special article such as this, or if you&#39;ve built a web-app you could email users who you know had accessed your service between a specific date period, and send them to a brand new landing page to peform the cleanse.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;There&#39;s also an experimental spec proposal &lt;code&gt;only-if-cached&lt;/code&gt; on this API too. Meaning in the future, we could get even more specific and only perform the cache reload if the problematic stuck resource is a) in cache for this specific visitor, and b) has a date earlier than the incident.&lt;/p&gt;
&lt;p&gt;Anyway, for now.. I&#39;ve added the following script to this page, and this page only:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script type=&amp;quot;module&amp;quot;&amp;gt;
  
  async function clearRogueCache(url) {
      return await fetch(url, {
          &amp;quot;cache&amp;quot;: &amp;quot;reload&amp;quot;
      });
  }

  await clearRogueCache(&amp;quot;https://martinhicks.net&amp;quot;);
  await clearRogueCache(&amp;quot;https://martinhicks.net/articles&amp;quot;);

&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There we go. Still hoping this article can catch any of the early readers of my site. But also hopeful that if you&#39;re reading this way in the future, and you&#39;ve made a similar mistake, you might have more options at your disposal - lucky you.&lt;/p&gt;
&lt;p&gt;Thanks again Jake and Alex - really appreciated.&lt;/p&gt;
&lt;hr /&gt;
</description>
      <pubDate>Mon, 19 Dec 2022 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/how-a-misconfigured-header-caused-an-unforgettable-problem</guid>
    </item>
    <item>
      <title>Eleventy 2.0 &amp; WebC</title>
      <link>https://martinhicks.dev/articles/eleventy-and-webc</link>
      <description>&lt;p&gt;I recently rebuilt this website and my company website - &lt;a href=&quot;https://sinovi.uk/&quot;&gt;sinovi.uk&lt;/a&gt; - using &lt;a href=&quot;https://www.11ty.dev/&quot;&gt;Eleventy&lt;/a&gt; 2.0 and their new &lt;a href=&quot;https://www.11ty.dev/docs/languages/webc/&quot;&gt;WebC&lt;/a&gt; language for templating.&lt;/p&gt;
&lt;p&gt;It&#39;s really good. And well worth checking out.&lt;/p&gt;
&lt;p&gt;In this post I look at my experiences trying out Eleventy 2.0 and its new Web Component language, WebC.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing 11ty 2.0 is pre-release.&lt;/p&gt;
&lt;p&gt;It’s just hit version &lt;a href=&quot;https://www.npmjs.com/package/@11ty/eleventy/v/2.0.0-canary.20&quot;&gt;canary-20&lt;/a&gt; and judging by 11ty&#39;s &lt;a href=&quot;https://fosstodon.org/@eleventy/109524124580037564&quot;&gt;recent toot&lt;/a&gt;, it’s official release is very close.&lt;/p&gt;
&lt;h2&gt;Background&lt;/h2&gt;
&lt;p&gt;I’d heard of the Eleventy project in passing via newsletters and Twitter posts for several years, but up until a few months ago I hadn’t tried to build anything with it.&lt;/p&gt;
&lt;p&gt;I think I’d mentally stored it away as similar in vain to Gatsby  or some similar react tool - &lt;em&gt;...there&#39;s a lot of those, right?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;How wrong was I?&lt;/p&gt;
&lt;h2&gt;Static site generation&lt;/h2&gt;
&lt;p&gt;Back in 2018, at Si Novi, we rolled our own internal tool for static site generation, that I’ve now learnt felt pretty similar to 11ty in some respects, but lacked its finesse and feature set.&lt;/p&gt;
&lt;p&gt;Our templates were HTML and we used a companion JSON file &lt;em&gt;(ie index.html &amp;amp; index.json)&lt;/em&gt; to hook data into each page view using HTML attributes.&lt;/p&gt;
&lt;p&gt;We had collections in an array within a standalone json file &lt;em&gt;(eg articles.json)&lt;/em&gt;. We even hooked it up to PHP blade templates for one test project.&lt;/p&gt;
&lt;p&gt;There was a key thing wrong with it though &lt;strong&gt;- it was a bloody pain to use.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;At the time we didn’t know about the &lt;a href=&quot;https://www.11ty.dev/docs/data-frontmatter/&quot;&gt;front matter&lt;/a&gt; syntax, which in hindsight might’ve helped.&lt;/p&gt;
&lt;p&gt;But the key issue was our node CLI processing engine was written using a jquery compatible dom lib - &lt;a href=&quot;https://www.npmjs.com/package/cheerio&quot;&gt;Cheerio&lt;/a&gt;, and a load of &lt;a href=&quot;https://gruntjs.com/&quot;&gt;Grunt&lt;/a&gt; hooks.&lt;/p&gt;
&lt;p&gt;It was fiddly; hard to maintain and lacked features, and we never quite found the time to make improvements alongside our client work.&lt;/p&gt;
&lt;p&gt;Furthermore, we were having to stuff lots of properties into the JSON files to handle conditional templating logic particularly for reusing partial views / re-usable template snippets.&lt;/p&gt;
&lt;p&gt;Maybe if we’d known about frontmatter, had chosen a templating engine like &lt;a href=&quot;https://mozilla.github.io/nunjucks/&quot;&gt;nunjucks&lt;/a&gt;, and had known about &lt;a href=&quot;https://github.com/inikulin/parse5&quot;&gt;parse5&lt;/a&gt;, I’d still be maintaining it now - who knows?&lt;/p&gt;
&lt;p&gt;What I do know &lt;em&gt;(now)&lt;/em&gt; is that 11ty absolutely nails static site generation.&lt;/p&gt;
&lt;p&gt;They have multiple &lt;a href=&quot;https://www.11ty.dev/docs/languages/&quot;&gt;templating languages&lt;/a&gt;, &lt;a href=&quot;https://www.11ty.dev/docs/layout-chaining/&quot;&gt;nested layouts&lt;/a&gt;, a &lt;a href=&quot;https://www.11ty.dev/docs/config/&quot;&gt;sensible config&lt;/a&gt; and &lt;a href=&quot;https://www.11ty.dev/docs/plugins/&quot;&gt;plugin system&lt;/a&gt;, and a really cool &lt;a href=&quot;https://www.11ty.dev/docs/data-cascade/&quot;&gt;data cascade&lt;/a&gt; which provides lots of options for populating a page’s templating data or mutating a particular value prior to generating the page.&lt;/p&gt;
&lt;p&gt;The docs site is &lt;em&gt;veeeeerrrry&lt;/em&gt; comprehensive. To be honest… so big I found it overwhelming to begin with &lt;em&gt;(it really melted my head for a bit)&lt;/em&gt;, but it&#39;s a fantastic resource and a credit to the community of contributors.&lt;/p&gt;
&lt;h2&gt;My first look&lt;/h2&gt;
&lt;p&gt;I took my first spin of Eleventy when I heard about &lt;a href=&quot;https://enhance.dev/&quot;&gt;Enhance&lt;/a&gt; in September and noticed one of their &lt;a href=&quot;https://enhance.dev/docs/learn/deployment/11ty&quot;&gt;deployment targets was 11ty&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Building out a few demo pages, I really liked what I saw in both projects but struggled with a few bits as I tried to understand how these worked together.&lt;/p&gt;
&lt;p&gt;Attempting to learn two new things at once meant I was doing a disservice to both tools; hampering my understanding of each project, and limiting my discovery of any overlap / boundaries of concern when using them together.&lt;/p&gt;
&lt;p&gt;I gave up.&lt;/p&gt;
&lt;p&gt;But promised myself I’d go deeper into each tool individually when time allowed.&lt;/p&gt;
&lt;h2&gt;Learning Eleventy&lt;/h2&gt;
&lt;p&gt;A month or so later I started a fresh starter Eleventy project.&lt;/p&gt;
&lt;p&gt;Making some really simple pages and layouts, I learnt about 11ty’s special dirs (like &lt;code&gt;_includes&lt;/code&gt; &amp;amp; &lt;code&gt;_data&lt;/code&gt;), and generally started to feel more comfortable with how Eleventy worked and it’s limitations.&lt;/p&gt;
&lt;p&gt;Those limitations, for me, were the fact that sharing blocks of re-usable components felt difficult.&lt;/p&gt;
&lt;p&gt;You could use 11ty’s &lt;a href=&quot;https://www.11ty.dev/docs/shortcodes/&quot;&gt;shortcodes&lt;/a&gt; to create snippets, or &lt;a href=&quot;https://www.trysmudford.com/blog/encapsulated-11ty-components/&quot;&gt;nunjuck macros&lt;/a&gt;, but these felt to me like reusing strings of templates was just about ok, but making them configurable using properties or attributes wasn’t a great experience for me.&lt;/p&gt;
&lt;p&gt;Having first used 11ty with Enhance, I’d been drawn to Enhance’s use of Web Components as &lt;a href=&quot;https://enhance.dev/docs/learn/starter-project/elements&quot;&gt;reusable elements&lt;/a&gt; - it fit my more recent mental model of using components in &lt;a href=&quot;https://github.com/developit/htm&quot;&gt;htm&lt;/a&gt; or react.&lt;/p&gt;
&lt;p&gt;Maybe I gave up too soon in my earlier experiments?&lt;/p&gt;
&lt;p&gt;But then I heard about Webc - I decided to persevere with my original plan, and set about re-building the &lt;a href=&quot;https://sinovi.uk/&quot;&gt;Si Novi&lt;/a&gt; and &lt;a href=&quot;https://martinhicks.dev/&quot;&gt;martinhicks.dev&lt;/a&gt; sites .&lt;/p&gt;
&lt;h2&gt;Webc - now we’re rocking&lt;/h2&gt;
&lt;p&gt;Webc is a brand new 11ty templating language.&lt;/p&gt;
&lt;p&gt;It uses Web Components and requires Eleventy 2.0 &lt;em&gt;(at time of writing this is pre-release and requires installing &lt;a href=&quot;https://www.npmjs.com/package/@11ty/eleventy/v/2.0.0-canary.20&quot;&gt;their canary package&lt;/a&gt;)&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Using Webc with 11ty is seamless; You can use WebC to build an individual component, for re-use and you can use it for layouts. Basically anywhere you’d expect an 11ty template to work, .webc files work.&lt;/p&gt;
&lt;p&gt;Meaning it works on its own, or can be used within their other templating languages too. And being 11ty you can mix and match templating languages throughout a project.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I like this 11ty feature a lot&lt;/strong&gt; - there’s loads of useful 11ty code snippets on gist, on their website, and, well all over the internet written in &lt;a href=&quot;https://www.11ty.dev/docs/languages/nunjucks/&quot;&gt;Nunjucks&lt;/a&gt; or &lt;a href=&quot;https://www.11ty.dev/docs/languages/liquid/&quot;&gt;Liquid&lt;/a&gt; and choosing to use WebC doesn’t prohibit you from tapping into this rich seam.&lt;/p&gt;
&lt;p&gt;For example I’m using an njk page to create my &lt;code&gt;sitemap.xml&lt;/code&gt; and another to create my RSS &lt;code&gt;feed.xml&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
//sitemap.njk

---
permalink: /sitemap.xml
eleventyExcludeFromCollections: true
---
&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;
&amp;lt;urlset xmlns=&amp;quot;http://www.sitemaps.org/schemas/sitemap/0.9&amp;quot;&amp;gt;
    {% for page in collections.all %}
        &amp;lt;url&amp;gt;
            &amp;lt;loc&amp;gt;{{ site.url }}{{ page.url | url | replace(r/&#92;/$/, &amp;quot;&amp;quot;) }}&amp;lt;/loc&amp;gt;
            &amp;lt;lastmod&amp;gt;{{ page.date.toISOString() }}&amp;lt;/lastmod&amp;gt;
        &amp;lt;/url&amp;gt;
    {% endfor %}
&amp;lt;/urlset&amp;gt;

&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;
//rss.njk
---json
{
  &amp;quot;permalink&amp;quot;: &amp;quot;feed.xml&amp;quot;,
  &amp;quot;eleventyExcludeFromCollections&amp;quot;: true,
  &amp;quot;metadata&amp;quot;: {
    &amp;quot;title&amp;quot;: &amp;quot;Martin Hicks - Journal&amp;quot;,
    &amp;quot;subtitle&amp;quot;: &amp;quot;Martin Hicks is a software developer from Manchester, UK&amp;quot;,
    &amp;quot;language&amp;quot;: &amp;quot;en&amp;quot;,
    &amp;quot;url&amp;quot;: &amp;quot;https://martinhicks.dev/&amp;quot;,
    &amp;quot;author&amp;quot;: {
      &amp;quot;name&amp;quot;: &amp;quot;Martin Hikcs&amp;quot;,
      &amp;quot;email&amp;quot;: &amp;quot;hello@martinhicks.net&amp;quot;
    }
  }
}
---
&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;utf-8&amp;quot;?&amp;gt;
&amp;lt;rss version=&amp;quot;2.0&amp;quot; xmlns:dc=&amp;quot;http://purl.org/dc/elements/1.1/&amp;quot; xml:base=&amp;quot;{{ metadata.url }}&amp;quot; xmlns:atom=&amp;quot;http://www.w3.org/2005/Atom&amp;quot;&amp;gt;
  &amp;lt;channel&amp;gt;
    &amp;lt;title&amp;gt;{{ metadata.title }}&amp;lt;/title&amp;gt;
    &amp;lt;link&amp;gt;{{ metadata.url }}&amp;lt;/link&amp;gt;
    &amp;lt;atom:link href=&amp;quot;{{ permalink | absoluteUrl(metadata.url) }}&amp;quot; rel=&amp;quot;self&amp;quot; type=&amp;quot;application/rss+xml&amp;quot; /&amp;gt;
    &amp;lt;description&amp;gt;{{ metadata.subtitle }}&amp;lt;/description&amp;gt;
    &amp;lt;language&amp;gt;{{ metadata.language }}&amp;lt;/language&amp;gt;
    {%- for post in collections.articles | reverse %}
    {%- set absolutePostUrl = post.url | absoluteUrl(metadata.url) %}
    &amp;lt;item&amp;gt;
      &amp;lt;title&amp;gt;{{ post.data.title }}&amp;lt;/title&amp;gt;
      &amp;lt;link&amp;gt;{{ absolutePostUrl }}&amp;lt;/link&amp;gt;
      &amp;lt;description&amp;gt;{{ post.data.description | htmlToAbsoluteUrls(absolutePostUrl) }}&amp;lt;/description&amp;gt;
      &amp;lt;pubDate&amp;gt;{{ post.date | dateToRfc822 }}&amp;lt;/pubDate&amp;gt;
      &amp;lt;dc:creator&amp;gt;{{ metadata.author.name }}&amp;lt;/dc:creator&amp;gt;
      &amp;lt;guid&amp;gt;{{ absolutePostUrl }}&amp;lt;/guid&amp;gt;
    &amp;lt;/item&amp;gt;
    {%- endfor %}
  &amp;lt;/channel&amp;gt;
&amp;lt;/rss&amp;gt;

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I also really like the fact I can &lt;em&gt;just&lt;/em&gt; use WebC as a &lt;a href=&quot;https://www.11ty.dev/docs/languages/webc/#html-only-components&quot;&gt;templating language for HTML generation&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This website currently has zero need for any client-side progressive enhancement, so I don’t need any of the web component features in the HTML served to the browser.&lt;/p&gt;
&lt;p&gt;11ty allows you to build a component that just returns HTML, and if so, the generator treats that component output as just the inner HTML omitting the enclosing custom element tag.&lt;/p&gt;
&lt;p&gt;If you did want to keep the component wrapper for some reason you pass an attribute of &lt;code&gt;webc:keep&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//my-avatar.webc
//example html only web component
&amp;lt;picture&amp;gt;
    &amp;lt;source srcset=&amp;quot;/images/5F8AB69C-FA08-4C25-B932-74D76EBB7721.webp&amp;quot; type=&amp;quot;image/webp&amp;quot;&amp;gt;
    &amp;lt;img src=&amp;quot;/images/5F8AB69C-FA08-4C25-B932-74D76EBB7721.jpg&amp;quot; alt=&amp;quot;Me and my wife, Helen, lying on the grass in summer. &amp;quot; :width=&amp;quot;this.width&amp;quot; :height=&amp;quot;this.height&amp;quot; class=&amp;quot;max-w-[100px] md:max-w-[200px] mx-auto aspect-square ring-2 ring-zinc-500/40 rotate-45 rounded-full bg-zinc-100 object-cover&amp;quot;/&amp;gt;
&amp;lt;/picture&amp;gt; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Webc with 2.0 means I could now build proper re-usable components, configurable with attribute props if required. No more snippets or Nunjucks macros.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;my-avatar width=&amp;quot;200&amp;quot; height=&amp;quot;200&amp;quot;&amp;gt;&amp;lt;/my-avatar&amp;gt;
&amp;lt;my-avatar width=&amp;quot;100&amp;quot; height=&amp;quot;100&amp;quot;&amp;gt;&amp;lt;/my-avatar&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Perfect.&lt;/p&gt;
&lt;p&gt;Other wins are;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Using web components within the &lt;a href=&quot;https://www.11ty.dev/docs/languages/webc/#components&quot;&gt;head element&lt;/a&gt;&lt;/strong&gt; - this isn’t allowed by the Web Component spec I don&#39;t think, but given you may have a Webc component that just provides HTML you can use the &lt;code&gt;webc:is&lt;/code&gt; attribute to upgrade a standard element to a WebC component just for templating purposes &lt;em&gt;(but only if it just returns html)&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Eg:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script webc:is=&amp;quot;json-ld&amp;quot; &amp;gt;&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The above &lt;code&gt;script&lt;/code&gt; tag in my head, will be ran using my component &lt;code&gt;json-ld&lt;/code&gt;, which basically adds some dynamic json-ld for articles on this site, and excludes if it&#39;s not an article page.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;WebC components can include render only Js functions to iterate collections&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;
---
meta:
title: &amp;quot;Articles&amp;quot;
description: &amp;quot;Occasional thoughts&amp;quot;
pagination:
  data: collections.articles
  size: 10
  alias: articles
  reverse: true
layout: layouts/main.webc
---


&amp;lt;container&amp;gt;
  &amp;lt;div class=&amp;quot;flex flex-col mx-auto justify-center &amp;quot;&amp;gt;
    &amp;lt;h1 class=&amp;quot;text-4xl font-bold tracking-tight text-zinc-800  sm:text-5xl mt-4&amp;quot;&amp;gt;
      Journal
    &amp;lt;/h1&amp;gt;

    &amp;lt;div class=&amp;quot;grid grid-cols-1 gap-8 md:grid-cols-2 auto-cols-auto md:auto-rows-[1fr] mb-8&amp;quot;&amp;gt;
      &amp;lt;script webc:type=&amp;quot;render&amp;quot; webc:is=&amp;quot;template&amp;quot;&amp;gt;
        function () {
          //console.log(this.pagination)
          let articles = this.pagination.items;

          return articles.map((article, idx) =&amp;gt; /*html*/`
                    
            &amp;lt;div class=&amp;quot;prose relative pb-8&amp;quot;&amp;gt;
                    &amp;lt;a href=&amp;quot;${article.data.url}&amp;quot;&amp;gt;
                        &amp;lt;picture class=&amp;quot;flex w-full &amp;quot; &amp;gt;
                            &amp;lt;source srcset=&amp;quot;${article.data.image.webp}&amp;quot; type=&amp;quot;image/webp&amp;quot;&amp;gt;
                            &amp;lt;img class=&amp;quot; full-width mb-2&amp;quot; src=&amp;quot;${article.data.image.path}&amp;quot; width=&amp;quot;345&amp;quot; height=&amp;quot;236&amp;quot; alt=&amp;quot;${article.data.image.alt}&amp;quot;&amp;gt;
                        &amp;lt;/picture&amp;gt; 
                    &amp;lt;/a&amp;gt;
                    &amp;lt;span class=&amp;quot;text-sm!&amp;quot;&amp;gt;
                    ${article.data.date}
                    &amp;lt;/span&amp;gt;
                    &amp;lt;h1 class=&amp;quot;text-xl! mb-2 font-semibold&amp;quot;&amp;gt;&amp;lt;a class=&amp;quot; no-underline&amp;quot; href=&amp;quot;${article.data.url}&amp;quot;&amp;gt;${article.data.title}&amp;lt;/a&amp;gt;&amp;lt;/h1&amp;gt;
                    &amp;lt;p&amp;gt;${article.data.description}&amp;lt;/p&amp;gt;
                    &amp;lt;a class=&amp;quot;absolute bottom-2 &amp;quot; href=&amp;quot;${article.data.url}&amp;quot;&amp;gt;
                        Read the article
                    &amp;lt;/a&amp;gt;
                &amp;lt;/div&amp;gt;
                
                    `)
          .join(&amp;quot;&amp;quot;);
        }
      &amp;lt;/script&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;hr&amp;gt;

  &amp;lt;/div&amp;gt;

  &amp;lt;my-details mode=&amp;quot;full&amp;quot;&amp;gt;&amp;lt;/my-details&amp;gt;
&amp;lt;/container&amp;gt;


&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Slots&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you’ve used any Web Component tool or manually created your own, you’ll know that web components use ‘slots’ to control where nested elements or strings are displayed within the component template.&lt;/p&gt;
&lt;p&gt;They’re super useful and help direct content to the correct placeholder without using attributes or similar.&lt;/p&gt;
&lt;p&gt;eg:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//social-link.webc

&amp;lt;a class=&amp;quot;group -m-1 p-1&amp;quot; :href=&amp;quot;href&amp;quot; target=&amp;quot;_blank&amp;quot; :aria-label=&amp;quot;this.arialabel&amp;quot; :role=&amp;quot;this.role&amp;quot; :rel=&amp;quot;this.rel&amp;quot;&amp;gt;
    &amp;lt;div class=&amp;quot;flex items-center space-x-2&amp;quot;&amp;gt;
        &amp;lt;slot name=&amp;quot;icon&amp;quot;&amp;gt;&amp;lt;/slot&amp;gt;
        &amp;lt;slot name=&amp;quot;content&amp;quot;&amp;gt;&amp;lt;/slot&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/a&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which is usable like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;social-link rel=&amp;quot;me&amp;quot; role=&amp;quot;listitem&amp;quot; href=&amp;quot;https://indieweb.social/@martinhicks&amp;quot;&amp;gt;
    &amp;lt;icon-mastodon slot=&amp;quot;icon&amp;quot; class=&amp;quot;w-8 h-8&amp;quot;&amp;gt;&amp;lt;/icon-mastodon&amp;gt;
    &amp;lt;span slot=&amp;quot;content&amp;quot;&amp;gt;Follow on Mastodon&amp;lt;/span&amp;gt;
&amp;lt;/social-link&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;nb icon-mastodon is another webc component - completely nest-able as you&#39;d expect&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;Things to look out for in 2.0 / webc&lt;/h2&gt;
&lt;p&gt;Having not been a long time user of 11ty, I’ll leave any deep comparison between the two versions to seasoned experts.&lt;/p&gt;
&lt;p&gt;There’s loads of new features in 11ty 2.0, some of which are breaking changes.&lt;/p&gt;
&lt;p&gt;Their docs site does a good job of signposting these changes, and I’m sure when it’s released there will be loads written to guide users in migration.&lt;/p&gt;
&lt;p&gt;What I’ve found:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Webc: Script and link tags&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Since, I think, &lt;code&gt;&amp;quot;@11ty/eleventy&amp;quot;: &amp;quot;2.0.0-canary.18&amp;quot;&lt;/code&gt; or &lt;code&gt;&amp;quot;@11ty/eleventy-plugin-webc&amp;quot;: &amp;quot;0.8.0&amp;quot;&lt;/code&gt;, you’ve been required to add &lt;code&gt;webc:keep&lt;/code&gt; to any script or link tag that has an external src.&lt;/p&gt;
&lt;p&gt;Thankfully the CLI warns you of these during build, throwing an error.&lt;/p&gt;
&lt;p&gt;However, I found that several non external script tags in my head (two json-ld and the local google analytics gtag script) weren’t included in my published site for a week.&lt;/p&gt;
&lt;p&gt;Adding &lt;code&gt;webc:keep&lt;/code&gt; brought them back.&lt;/p&gt;
&lt;p&gt;Maybe I misread the CLI warning, but I don’t think I did. And certainly the lack of them on an external src caused a build fail, whereas omitting them for locally src’d elements didn’t.&lt;/p&gt;
&lt;p&gt;Luckily I don’t care much about either of those on my personal site, so no biggie.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;**Given that I’m using a canary build and I haven’t been studiously keeping up with the change notes between pre-release build versions I’ll take the blame on this one.&lt;/strong&gt;**&lt;/em&gt;&lt;/p&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;&lt;strong&gt;11ty 2.0 - The copy command doesn’t actually copy files locally during dev&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is new and intentional, for performance reasons, it sort of magically symlinks them internally during the local serve process.&lt;/p&gt;
&lt;p&gt;So as far as the browser is concerned the images folder you’ve set to copy to the output dir, for example, and therefore is served from &lt;code&gt;/images/myimage.jpg&lt;/code&gt;, hasn’t physically been copied to that location on your machine. That only happens during a production build.&lt;/p&gt;
&lt;p&gt;Fine when you know but it’s a little confusing at first.&lt;/p&gt;
&lt;p&gt;I’ve had to build a few production builds locally at times just to make sure my copy configs are set correctly.&lt;/p&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;&lt;strong&gt;Plug-ins have a whole bunch of new hooks&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is a big upgrade and I think will make integrating other tools way easier.&lt;/p&gt;
&lt;p&gt;I think it&#39;s backwards compatible. I’m looking forward to playing around with this more.&lt;/p&gt;
&lt;ol start=&quot;4&quot;&gt;
&lt;li&gt;&lt;strong&gt;WebC components aren’t automatically discoverable within a project by default.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I found this confusing.&lt;/p&gt;
&lt;p&gt;Especially as most of my &lt;code&gt;.webc&lt;/code&gt; components were simply returning HTML, I didn’t want to have to add a load of &lt;code&gt;webc:import&lt;/code&gt; attributes per component.&lt;/p&gt;
&lt;p&gt;Thankfully 11ty’s config system has you covered.&lt;/p&gt;
&lt;p&gt;Adding the following to your &lt;code&gt;.eleventy.js&lt;/code&gt; file, and placing all your components within &lt;code&gt;/_includes/components&lt;/code&gt; makes them usable throughout the entire project without individually importing them.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;eleventyConfig.addPlugin(pluginWebc, {
    // Glob to find no-import global components
		components: &amp;quot;src/_includes/components/**/*.webc&amp;quot;,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;5&quot;&gt;
&lt;li&gt;&lt;strong&gt;Everything in project root by default&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I think this is right, and not just a mistake I made. But the default 11ty starter had everything configured to run from the project root.&lt;/p&gt;
&lt;p&gt;Eg, &lt;code&gt;index.webc&lt;/code&gt; was in the same root folder as &lt;code&gt;package.json&lt;/code&gt; and other non web assets.&lt;/p&gt;
&lt;p&gt;I didn’t like this.&lt;/p&gt;
&lt;p&gt;Again, 11ty config to the rescue, it’s super simple to tell 11ty where your input and output dirs should be. So it was quick get things how I wanted them.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;return {
  dir: {
      input: &amp;quot;src&amp;quot;,
      output: &amp;quot;_site&amp;quot;
    }
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It’d be great if the starter cli command could ask you where you’d like your source and output directories to be, and auto configure this for newbies like me.&lt;/p&gt;
&lt;ol start=&quot;6&quot;&gt;
&lt;li&gt;&lt;strong&gt;Tailwind&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Tailwind works really well with a component based system, so 11ty and webc is no different, outputting only the css for classes that you’ve actually used in your pages.&lt;/p&gt;
&lt;p&gt;To make that work better, I pointed tailwind config to the output folder &lt;em&gt;(&lt;code&gt;_site&lt;/code&gt; in my case)&lt;/em&gt; to look for content, rather than the src directory, and made it run after 11ty prod build.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//tailwind.config.js
module.exports = {
  content: [&#39;./_site/**/*.html&#39;],
  plugins: [require(&#39;@tailwindcss/typography&#39;)]
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This way any draft components I’ve built but not yet used, won’t have any of its unique classes included in the final production css output. Until they’re actually included in one of the html pages.&lt;/p&gt;
&lt;p&gt;I also needed a way to allow component modification throughout the site (more so on Si Novi). To do that I used the Tailwind Merge &lt;a href=&quot;https://www.npmjs.com/package/tailwind-merge&quot;&gt;package&lt;/a&gt; and created a helper function within &lt;code&gt;.eleventy.js&lt;/code&gt; that can be used on each component.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;eleventyConfig.addFilter(&amp;quot;tailwindMerge&amp;quot;, function(defaultClasses, overrideClasses) { 
  return twMerge(defaultClasses, overrideClasses)
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means I can pass in overriding css attributes at the point of using the component and the &lt;code&gt;twMerge&lt;/code&gt; function (as it’s tailwind aware), replaces the defaults with the overrides.&lt;/p&gt;
&lt;p&gt;Within a WebC component:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//link-primary.webc
&amp;lt;a :href=&amp;quot;href&amp;quot; :class=&amp;quot;tailwindMerge(&#39;underline hover:text-blue-500&#39;, this.class)&amp;quot;&amp;gt;&amp;lt;slot&amp;gt;&amp;lt;/slot&amp;gt;&amp;lt;/a&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If I used this component, like so...&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;link-primary class=&amp;quot;hover:text-red-500&amp;quot;&amp;gt;example link hovers red&amp;lt;/link-primary&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;... the hover on that instance of the component would be red.&lt;/p&gt;
&lt;h2&gt;Wrap up&lt;/h2&gt;
&lt;p&gt;I’m so glad I’ve found 11ty, it’s a great tool. Version 2.0 and WebC has made it sticky for me.&lt;/p&gt;
&lt;p&gt;I haven’t used any of the more advanced webc features such as scoped css or bundling, but I’m sure I’ll test them out in due course.&lt;/p&gt;
&lt;p&gt;Absolutely great job.&lt;/p&gt;
&lt;p&gt;Keep an eye out for the next post in this series, which explains how to set up 11ty, AWS and GitHub actions to create a CI/CD build pipeline to deploy to S3.&lt;/p&gt;
&lt;p&gt;You can view the &lt;a href=&quot;https://github.com/hicksy/martinhicks.dev&quot;&gt;source code of my site here&lt;/a&gt;.&lt;/p&gt;
&lt;hr /&gt;
</description>
      <pubDate>Sat, 17 Dec 2022 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/eleventy-and-webc</guid>
    </item>
    <item>
      <title>Sharing Enhance elements between projects</title>
      <link>https://martinhicks.dev/articles/sharing-enhance-elements</link>
      <description>&lt;p&gt;This post introduces a new arc plugin for Enhance projects that I&#39;ve recently published, with the catchy name &lt;a href=&quot;https://www.npmjs.com/package/@hicksy/shared-enhance-components-plugin&quot;&gt;shared-enhance-components-plugin&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Read on for details about how to use the plugin and why I came to write it.&lt;/p&gt;
&lt;h2&gt;Background&lt;/h2&gt;
&lt;p&gt;While I &lt;a href=&quot;https://martinhicks.dev/articles/back-to-the-future&quot;&gt;continued my exploration&lt;/a&gt; of the &lt;a href=&quot;https://enhance.dev/&quot;&gt;Enhance&lt;/a&gt; framework, I started to wonder how you&#39;d go about importing elements from a shared library or UI toolkit.&lt;/p&gt;
&lt;p&gt;In a React project, you&#39;d import the library into the given component and then begin using its JSX tag within the render.&lt;/p&gt;
&lt;p&gt;However, Enhance elements are dependency free by design, for good reasons.&lt;/p&gt;
&lt;p&gt;So instead, any element you create within &lt;code&gt;/app/elements&lt;/code&gt; or define within &lt;code&gt;/app/elements.mjs&lt;/code&gt; &lt;em&gt;(* more on this later)&lt;/em&gt;, becomes automatically available in any Enhance page.&lt;/p&gt;
&lt;p&gt;That doesn&#39;t mean you can&#39;t use dependencies where appropriate. Let&#39;s say you have a couple of elements, one of which can be used standalone, the other you always want a particular element included, you could do the following:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//app/elements/csrf-input

export default function CsrfInput({ html, state }) {
    const { attrs={}, store={} } = state
    const { name = &#39;csrf&#39; } = attrs
    return html`
        &amp;lt;input type=&amp;quot;hidden&amp;quot; name=&amp;quot;${name}&amp;quot; value=&amp;quot;${store.csrf_token}&amp;quot; /&amp;gt;
    `
}

import CsrfInput from &amp;quot;./csrf-input.mjs&amp;quot;;

export default function CsrfForm({ html, state }) {
    const { attrs={} } = state
    const { action = &#39;&#39;, method = &#39;&#39; } = attrs

    return html`
    &amp;lt;form action=&amp;quot;${action}&amp;quot; method=&amp;quot;${method}&amp;quot;&amp;gt;
        ${CsrfInput({html, state})}
        &amp;lt;slot&amp;gt;&amp;lt;/slot&amp;gt;
    &amp;lt;/form&amp;gt;
`
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can simply call the pure function to return its &amp;quot;render&amp;quot;, e.g. &lt;code&gt;${CsrfInput({html, state})}&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;And I think looking at my browser&#39;s source, it means the dependent element is just the HTML entity, rather than being expanded into a web component. Which in this case is what I wanted, all be it by accident.&lt;/p&gt;
&lt;p&gt;Pretty cool.&lt;/p&gt;
&lt;p&gt;But what about if you want to bring in a shared UI library maintained by a different team or 3rd party?&lt;/p&gt;
&lt;p&gt;After a search on the &lt;a href=&quot;https://enhance.dev/discord&quot;&gt;Enhance Discord&lt;/a&gt; &lt;em&gt;(which is great by the way - definitely head there for help / guidance / cool things people are building)&lt;/em&gt;, I couldn&#39;t find anything baked in.&lt;/p&gt;
&lt;p&gt;I did see a comment from Macdonst (one of the begin/enhance team), who was explaining how you could do all of your imports in an &lt;code&gt;/app/elements.mjs&lt;/code&gt; file, and then they would be available throughout your app just like a first-party element you&#39;ve created in &lt;code&gt;/app/elements&lt;/code&gt; - nice.&lt;/p&gt;
&lt;p&gt;He went on...&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The above is begging to be a plug-in.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So I thought why not.&lt;/p&gt;
&lt;h2&gt;What is this elements.mjs file anyway?&lt;/h2&gt;
&lt;p&gt;It&#39;s not mentioned much in their docs at all. In fact, at the time of writing, it&#39;s only referenced within the &lt;a href=&quot;https://enhance.dev/docs/learn/deployment/fastify&quot;&gt;Deploy to Fastify section&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;However, if you look at the code for the &lt;code&gt;arc-plugin-enhance&lt;/code&gt; &lt;em&gt;(which is the plugin that orchestrates arc to have a catch-all lambda for each /app/api/ route, among many other things)&lt;/em&gt;, you&#39;ll see that 4 locations are checked for elements to enhance:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let pathToModule = join(basePath, &#39;elements.mjs&#39;)
let pathToPages = join(basePath, &#39;pages&#39;)
let pathToElements = join(basePath, &#39;elements&#39;)
let pathToHead = join(basePath, &#39;head.mjs&#39;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;i.e.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/app/elements.mjs&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/app/pages&lt;/code&gt; (any mjs file in here)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/app/elements&lt;/code&gt; (any mjs file in here)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/app/head.mjs&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The elements.mjs file is basically returning a keyed object mapping the tag name (e.g &lt;code&gt;my-component&lt;/code&gt;) to a corresponding element function (e.g. MyComponent) (which can be located anywhere, like &lt;code&gt;node_modules/some-package/elements/MyComponent.mjs&lt;/code&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import MyComponent from &#39;some-package/elements/MyComponent.mjs&#39;

let elements = {

  &#39;my-component&#39;: MyComponent
}

export default elements
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So that&#39;s a pretty powerful in-built mechanism for linking things together right there.&lt;/p&gt;
&lt;p&gt;The need for a potential plugin is that you may &lt;code&gt;npm install&lt;/code&gt; a package with 10s, 100s of components you&#39;d like to use, so having a plugin do most of the leg work is helpful.&lt;/p&gt;
&lt;p&gt;Also, defining a map between the tag name and pure function doesn&#39;t seem to have any draw back - I&#39;m fairly certain elements are only processed if they&#39;re referenced within a page and / or element - so mapping a lot of potentially unused components seems to have no issues&lt;/p&gt;
&lt;h2&gt;Building the plugin&lt;/h2&gt;
&lt;p&gt;Enhance (the way I&#39;m using it), is wrapped with &lt;a href=&quot;https://arc.codes/&quot;&gt;Architect&lt;/a&gt;. Meaning you can easily deploy your enhance app to &lt;a href=&quot;https://aws.amazon.com/lambda/&quot;&gt;AWS&lt;/a&gt;, or to &lt;a href=&quot;https://begin.com/&quot;&gt;Begin&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Because it uses Architect&#39;s sandbox to run locally, and its hydration mechanism to hydrate your Lambda function code before deployment, you can also tap into Arc&#39;s powerful plug-in system.&lt;/p&gt;
&lt;p&gt;I&#39;d previously had a go at an Architect plugin and got a little lost. Since then &lt;em&gt;(18 months or so ago)&lt;/em&gt;, they&#39;ve massively &lt;a href=&quot;https://arc.codes/docs/en/guides/plugins/overview&quot;&gt;expanded their plug-in docs&lt;/a&gt; and improved the available lifecycle hooks you can tap into.&lt;/p&gt;
&lt;p&gt;I also had a good look at some of the &lt;a href=&quot;https://github.com/architect/plugins&quot;&gt;plugins listed in their repository&lt;/a&gt;, which helped me figure things out.&lt;/p&gt;
&lt;p&gt;After an afternoon or so&#39;s work I had an acceptable &lt;em&gt;(just about)&lt;/em&gt; version of a plugin which works as follows:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;You add a component package or UI library to the .arc file under the plugin&#39;s unique @ pragma - &lt;code&gt;@shared-enhance-components-plugin&lt;/code&gt; - this informs the plugin which external packages to import component elements from.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;On arc hydration (which happens during sandbox start, and pre arc deploy), the plugin analyses the available functions that can be imported from the given package:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;either from a specific folder, in which case each .mjs / js file will be mapped into elements.mjs&lt;/li&gt;
&lt;li&gt;or from an index.js file, in which case each named export will be mapped into elements.mjs&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Tag names are inferred by de-camelcasing the function name&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Any existing elements you&#39;ve manually added to elements.mjs will be preserved &lt;em&gt;(auto generated lines include an eol comment)&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Example usage&lt;/h2&gt;
&lt;p&gt;I think it&#39;s pretty easy to use.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;First install the plugin and tie it into your project&#39;s arc file.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;npm install @hicksy/shared-enhance-components-plugin
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;//.arc

@app
myproj

@plugins
enhance/arc-plugin-enhance
hicksy/shared-enhance-components-plugin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;nb: you have to drop the preceding @ on scoped package names in the .arc file so they don&#39;t collide with the pragma names&lt;/em&gt;&lt;/p&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;Install some shared elements&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For example, we could install these example Enhance form elements - &lt;a href=&quot;https://github.com/enhance-dev/form-elements.git&quot;&gt;https://github.com/enhance-dev/form-elements.git&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install git+https://github.com/enhance-dev/form-elements.git
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;Tell the plugin to use this package&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;//.arc

@app
myproj

@plugins
enhance/arc-plugin-enhance
hicksy/shared-enhance-components-plugin

@shared-enhance-components-plugin
hicksy/enhance-csrf &#39;elements&#39;
enhance/form-elements
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note in the above example the package we&#39;ve just installed is referenced just by it&#39;s name &lt;em&gt;(dropping the @ again to avoid collision with arc pragmas)&lt;/em&gt; - we know by looking at this package all of it&#39;s components are named exports &lt;a href=&quot;https://github.com/enhance-dev/form-elements/blob/main/index.js&quot;&gt;from a route index.js file&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;For &lt;a href=&quot;https://www.npmjs.com/package/@hicksy/enhance-csrf&quot;&gt;@hicksy/enhance-csrf&lt;/a&gt; package, we pass a second arg to the plugin, this tells the plugin that the components are individual files within &lt;a href=&quot;https://github.com/hicksy/enhance-csrf/tree/main/elements&quot;&gt;the specific folder&lt;/a&gt;.&lt;/p&gt;
&lt;ol start=&quot;4&quot;&gt;
&lt;li&gt;Start&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;npm start&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The plugin-hook will fire, and the &lt;code&gt;/app/elements.mjs&lt;/code&gt; file will be auto-populated with the tag-name&#39;s and import declarations as required.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// /app/elements.mjs

import CsrfInput from &#39;@hicksy/enhance-csrf/elements/csrf-input.mjs&#39; //automatically inserted by shared-enhance-components-plugin
import CsrfForm from &#39;@hicksy/enhance-csrf/elements/csrf-form.mjs&#39; //automatically inserted by shared-enhance-components-plugin
import { CheckBox } from &#39;@enhance/form-elements/index.js&#39; //automatically inserted by shared-enhance-components-plugin
import { FieldSet } from &#39;@enhance/form-elements/index.js&#39; //automatically inserted by shared-enhance-components-plugin
import { FormElement } from &#39;@enhance/form-elements/index.js&#39; //automatically inserted by shared-enhance-components-plugin
import { LinkElement } from &#39;@enhance/form-elements/index.js&#39; //automatically inserted by shared-enhance-components-plugin
import { PageContainer } from &#39;@enhance/form-elements/index.js&#39; //automatically inserted by shared-enhance-components-plugin
import { SubmitButton } from &#39;@enhance/form-elements/index.js&#39; //automatically inserted by shared-enhance-components-plugin
import { TextInput } from &#39;@enhance/form-elements/index.js&#39; //automatically inserted by shared-enhance-components-plugin

let elements = {

  &#39;csrf-input&#39;: CsrfInput, //automatically inserted by shared-enhance-components-plugin
  &#39;csrf-form&#39;: CsrfForm, //automatically inserted by shared-enhance-components-plugin
  &#39;check-box&#39;: CheckBox, //automatically inserted by shared-enhance-components-plugin
  &#39;field-set&#39;: FieldSet, //automatically inserted by shared-enhance-components-plugin
  &#39;form-element&#39;: FormElement, //automatically inserted by shared-enhance-components-plugin
  &#39;link-element&#39;: LinkElement, //automatically inserted by shared-enhance-components-plugin
  &#39;page-container&#39;: PageContainer, //automatically inserted by shared-enhance-components-plugin
  &#39;submit-button&#39;: SubmitButton, //automatically inserted by shared-enhance-components-plugin
  &#39;text-input&#39;: TextInput //automatically inserted by shared-enhance-components-plugin
}

export default elements

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Publishing to NPM&lt;/h2&gt;
&lt;p&gt;I&#39;ve never published a package to NPM before so this was all new to me.&lt;/p&gt;
&lt;p&gt;Previous node packages I&#39;ve created have all been private and we&#39;d opted to npm installing from the git repo address rather than purchasing a private packages account from npm.&lt;/p&gt;
&lt;p&gt;Publishing was really straight forward (aside from accidentally deploying it unscoped, and then deciding I&#39;d prefer to release it scoped to my username).&lt;/p&gt;
&lt;p&gt;I followed this &lt;a href=&quot;https://zellwk.com/blog/publish-to-npm/&quot;&gt;guide to npm publishing&lt;/a&gt; - thanks Zell!&lt;/p&gt;
&lt;h2&gt;And there you go...&lt;/h2&gt;
&lt;p&gt;A small plugin that hopefully eases the use of shared Enhance elements and hopefully making it more compelling for authors to share libraries of their elements for others to use?&lt;/p&gt;
&lt;p&gt;There&#39;s no doubt a few wrinkles will be discovered, and the plugin code could do with a tidy, but I&#39;m pretty happy with it.&lt;/p&gt;
&lt;p&gt;Get in &lt;a href=&quot;https://github.com/hicksy/hicksy-shared-enhance-components-plugin&quot;&gt;touch on GitHub&lt;/a&gt; if there&#39;s any issues, improvements or feature ideas.&lt;/p&gt;
&lt;hr /&gt;
</description>
      <pubDate>Thu, 15 Dec 2022 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/sharing-enhance-elements</guid>
    </item>
    <item>
      <title>Back to the Future</title>
      <link>https://martinhicks.dev/articles/back-to-the-future</link>
      <description>&lt;p&gt;As 2022 draws to a close I wanted to reflect on a welcome change in the web framework world that shifts the needle back towards web-first principles. Which I&#39;m all here for.&lt;/p&gt;
&lt;p&gt;This year I’ve worked on a large web application using &lt;a href=&quot;http://remix.run/&quot;&gt;Remix&lt;/a&gt;, and the past week I’ve finally had free time to properly explore &lt;a href=&quot;https://enhance.dev/&quot;&gt;Enhance&lt;/a&gt; - something I&#39;ve been super excited about since it was announced back in late Summer 2022.&lt;/p&gt;
&lt;p&gt;Both frameworks share core principles;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;web-platform aligned mechanism for storing state &lt;em&gt;(i.e forms, sessions and http verbs - those foundational web principles that somehow had been forgotten)&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;progressive enhancement; a web app can &lt;em&gt;(and should)&lt;/em&gt; be usable without browser JS&lt;/li&gt;
&lt;li&gt;HTML served over-the-wire&lt;/li&gt;
&lt;li&gt;file based routing&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The fact as an industry we allowed ourselves to sleep-walk into a period where these principles weren&#39;t the norm is unbelievable.&lt;/p&gt;
&lt;p&gt;Thankfully a long overdue course correction is taking place.&lt;/p&gt;
&lt;h2&gt;Remix&lt;/h2&gt;
&lt;p&gt;I really enjoyed building with Remix - it&#39;s well thought out, and makes working with React, well... more &lt;em&gt;enjoyable&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;We were able to scaffold out a broad approach to the app pretty seamlessly, and the build out was relatively pain free. We deployed the app to AWS using what Remix call their &lt;a href=&quot;https://github.com/remix-run/grunge-stack&quot;&gt;Grunge stack&lt;/a&gt; - which basically pairs Remix with &lt;a href=&quot;https://arc.codes/&quot;&gt;Architect&lt;/a&gt; &lt;em&gt;(a tool I&#39;m already very familiar with)&lt;/em&gt; and provides a catch-all Lambda to power the server-side code.&lt;/p&gt;
&lt;p&gt;I believe Remix can be deployed anywhere that supports the Node.js runtime, and I think it even runs at the edge on web-workers - which is pretty cool.&lt;/p&gt;
&lt;p&gt;It&#39;s amazing how much cruft Remix helps take away from a typical React project- no more nonsense client side Redux stores, optimistic UI is extremely easy (no loading spinners), and with the file based routing it&#39;s far simpler to move things around as you&#39;re building out an app.&lt;/p&gt;
&lt;p&gt;It&#39;s not without complexity, &lt;em&gt;it&#39;s still React, right?&lt;/em&gt; You have to be pretty disciplined to not over engineer things, particularly the functional component, as you can get drawn to using &lt;em&gt;(or overusing in my case)&lt;/em&gt; hooks etc to maintain state client-side rather than using the platform to best effect. &lt;em&gt;(Aware this is my issue not necessarily Remix&#39;s)&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;A lot to like and definitely looking forward to using it again in 2023.&lt;/p&gt;
&lt;h2&gt;Enhance&lt;/h2&gt;
&lt;p&gt;For those new to it, &lt;a href=&quot;https://enhance.dev/&quot;&gt;Enhance&lt;/a&gt; is a standards based web framework underpinned by Web Components from the folks at &lt;a href=&quot;https://begin.com/&quot;&gt;Begin&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;So far I&#39;ve only had a chance to test out some very basic examples, but really like what I&#39;ve seen, both in terms of playing around with it and the team&#39;s core mission.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Our mission is to enable anyone to build multi-page dynamic web apps while staying as close to the platform as possible.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;YES!! 🙌&lt;/p&gt;
&lt;p&gt;I&#39;ll write up another post soon I&#39;m sure, but here&#39;s a quick summary of wins:&lt;/p&gt;
&lt;h3&gt;1. No need for client-side code&lt;/h3&gt;
&lt;p&gt;Components &lt;em&gt;(or Elements as enhance refers to them)&lt;/em&gt;, can be &lt;em&gt;just&lt;/em&gt; HTML if you like, there&#39;s no need for any client-side JS at all.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export default function ProseHeader({ html, state }) {
  const { attrs } = state
  const { title = &#39;&#39; } = attrs

  return html`
    &amp;lt;h1&amp;gt;&amp;lt;slot&amp;gt;&amp;lt;/slot&amp;gt;&amp;lt;/h1&amp;gt;
  `
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This Element would be usable in any Enhance page like so:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;prose-header&amp;gt;Article title&amp;lt;/prose-header&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And to the browser, would be served up expanded ready for enhancement if required:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;prose-header&amp;gt;
&amp;lt;h1&amp;gt;Article title&amp;lt;/h1&amp;gt;
&amp;lt;/prose-header&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing I wish were possible &lt;em&gt;(and it may well be, I just haven&#39;t looked hard enough)&lt;/em&gt;, is whether you could opt-out of the web component parent element all together for elements that don&#39;t require client-side enhancement
&lt;em&gt;i.e. they only contain HTML&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;This is a feature I quite like in &lt;a href=&quot;https://www.11ty.dev/docs/languages/webc/#html-only-components&quot;&gt;11ty&#39;s webc&lt;/a&gt; - meaning if you&#39;re using webc as only a templating language, the outputted HTML is just pure HTML.&lt;/p&gt;
&lt;p&gt;Like I say, I&#39;m not sure if this is possible or not. Nor is it much of a pain point to be honest, just a nicety.&lt;/p&gt;
&lt;h3&gt;2. Progressive enhancement&lt;/h3&gt;
&lt;p&gt;Easy to progressively enhance client-side capabilities by adding a script into the string returned from the Element.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;...rest of Element
&amp;lt;script type=&amp;quot;module&amp;quot;&amp;gt;
  class ProseHeader extends HTMLElement {
    constructor() {
      super()
      this.heading = this.querySelector(&#39;h1&#39;)
    }
    
    ...whatever powers you want to give the new Web Component
  }

  customElements.define(&#39;prose-header&#39;, ProseHeader)
&amp;lt;/script&amp;gt;
...etc
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Routing&lt;/h3&gt;
&lt;p&gt;File based routing, with an API mechanism backed in.&lt;/p&gt;
&lt;p&gt;For example, let&#39;s say you have the following page &lt;code&gt;/app/pages/index.html&lt;/code&gt;, and the following api route &lt;code&gt;/app/api/index.mjs&lt;/code&gt; the returned JSON from either the &lt;code&gt;get&lt;/code&gt; handler, or &lt;code&gt;post&lt;/code&gt; handler of the API will be made available to any custom elements within index.html automatically.&lt;/p&gt;
&lt;p&gt;The server-side mechanism automatically routes to the &lt;code&gt;get&lt;/code&gt; handler if the request method is &lt;code&gt;GET&lt;/code&gt; and likewise to the &lt;code&gt;post&lt;/code&gt; handler if the request is a &lt;code&gt;POST&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is similar in nature to Remix&#39;s actions (post) and loaders (gets), but split across different files.&lt;/p&gt;
&lt;h3&gt;4. Automatic state&lt;/h3&gt;
&lt;p&gt;Server side state is populated into every custom element for a given route automatically&lt;/p&gt;
&lt;p&gt;I think this is really powerful. No need to drill props, no need to maintain some sort of client-side state, if it&#39;s returned in the &lt;code&gt;json&lt;/code&gt; prop from either a &lt;code&gt;get&lt;/code&gt; or &lt;code&gt;post&lt;/code&gt; handler it will be available in &lt;code&gt;state.store&lt;/code&gt; in every element on the current page.&lt;/p&gt;
&lt;p&gt;What a joy.&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;Overall two very interesting frameworks that I&#39;m delighted exist.&lt;/p&gt;
&lt;p&gt;They&#39;ve both, for different reasons, helped me get excited about web development again and travelling back to the future.&lt;/p&gt;
&lt;hr /&gt;
</description>
      <pubDate>Mon, 12 Dec 2022 00:00:00 GMT</pubDate>
      <dc:creator>Martin Hicks</dc:creator>
      <guid>https://martinhicks.dev/articles/back-to-the-future</guid>
    </item>
  </channel>
</rss>