<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: knot crochet</title>
    <description>The latest articles on DEV Community by knot crochet (@knot_crochet_dbb4379fde5d).</description>
    <link>https://dev.to/knot_crochet_dbb4379fde5d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4085869%2F39594114-21bc-4dfe-9c71-af492ae57066.png</url>
      <title>DEV Community: knot crochet</title>
      <link>https://dev.to/knot_crochet_dbb4379fde5d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/knot_crochet_dbb4379fde5d"/>
    <language>en</language>
    <item>
      <title>Stop Paying Per-Funnel. Here's the Math on Self-Hosting.</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Thu, 01 Oct 2026 20:01:41 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/stop-paying-per-funnel-heres-the-math-on-self-hosting-335</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/stop-paying-per-funnel-heres-the-math-on-self-hosting-335</guid>
      <description>&lt;p&gt;I priced the hosted version of my open source funnel builder as a small base fee plus a percentage of what flows through it: &lt;strong&gt;$29 a month plus 1% of GMV.&lt;/strong&gt; The competitor everyone compares it to charges a flat subscription with tiers.&lt;/p&gt;

&lt;p&gt;Then I did the arithmetic on where those two curves cross, and the result is an argument against my own pricing above a certain size. I'd rather write that down than have a customer discover it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crossover
&lt;/h2&gt;

&lt;p&gt;Competitor pricing, taken from their public pricing page and re-verified on 2026-08-01 (check it yourself before relying on it, pricing pages move):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;th&gt;Annual, per month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Launch&lt;/td&gt;
&lt;td&gt;$97&lt;/td&gt;
&lt;td&gt;$81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale&lt;/td&gt;
&lt;td&gt;$197&lt;/td&gt;
&lt;td&gt;$164&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optimize&lt;/td&gt;
&lt;td&gt;$297&lt;/td&gt;
&lt;td&gt;$248&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Setting &lt;code&gt;29 + 0.01 × GMV&lt;/code&gt; equal to each of those gives the monthly GMV where my hosted plan stops being cheaper:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Compared against&lt;/th&gt;
&lt;th&gt;Monthly plan&lt;/th&gt;
&lt;th&gt;Annual plan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Launch&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$6,800/mo GMV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$5,800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$16,800/mo GMV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$14,100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optimize&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$26,800/mo GMV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$22,500&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the full curve against the middle tier, which is the one most people actually buy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Monthly GMV&lt;/th&gt;
&lt;th&gt;My hosted plan&lt;/th&gt;
&lt;th&gt;Annualized&lt;/th&gt;
&lt;th&gt;vs Scale ($2,364/yr)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;$2,000&lt;/td&gt;
&lt;td&gt;$49&lt;/td&gt;
&lt;td&gt;$588&lt;/td&gt;
&lt;td&gt;0.25x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$5,000&lt;/td&gt;
&lt;td&gt;$79&lt;/td&gt;
&lt;td&gt;$948&lt;/td&gt;
&lt;td&gt;0.40x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$10,000&lt;/td&gt;
&lt;td&gt;$129&lt;/td&gt;
&lt;td&gt;$1,548&lt;/td&gt;
&lt;td&gt;0.65x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$16,800&lt;/td&gt;
&lt;td&gt;$197&lt;/td&gt;
&lt;td&gt;$2,364&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.00x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$30,000&lt;/td&gt;
&lt;td&gt;$329&lt;/td&gt;
&lt;td&gt;$3,948&lt;/td&gt;
&lt;td&gt;1.67x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$50,000&lt;/td&gt;
&lt;td&gt;$529&lt;/td&gt;
&lt;td&gt;$6,348&lt;/td&gt;
&lt;td&gt;2.69x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$100,000&lt;/td&gt;
&lt;td&gt;$1,029&lt;/td&gt;
&lt;td&gt;$12,348&lt;/td&gt;
&lt;td&gt;5.22x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At $100k a month I'd be charging over five times what the flat competitor charges. That's not a rounding error, that's a different product category.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why anyone chooses a revenue share anyway
&lt;/h2&gt;

&lt;p&gt;Because the left half of that table is the half most people live in.&lt;/p&gt;

&lt;p&gt;A merchant doing $2,000 a month pays $49 instead of $197. That's not a 20% saving, it's a quarter of the price, and more importantly it's a price that doesn't have to be justified before the thing has made any money. A $197 monthly bill on $2,000 of revenue is 10% of the business's entire top line going to one tool.&lt;/p&gt;

&lt;p&gt;That's the actual pitch for percentage pricing, and it isn't cleverness: &lt;strong&gt;it moves the cost to after the value.&lt;/strong&gt; A flat subscription asks the customer to pay for capacity they might not use. A percentage asks them to pay in proportion to what they got. Below the crossover, the customer is strictly better off, and so am I, because I'm getting revenue from a segment that would otherwise not have bought anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  And why it turns hostile above the line
&lt;/h2&gt;

&lt;p&gt;The problem with percentage pricing is that it scales with a number that has nothing to do with what I spend.&lt;/p&gt;

&lt;p&gt;Serving a checkout page for a $500 order costs me exactly what serving a $20 order costs. My infrastructure cost tracks requests, not dollars. So above the crossover the customer is paying for growth that costs me nothing to support, and the more successful they are the more visibly wrong the bill looks. That's a price that gets renegotiated or churned, not one that holds.&lt;/p&gt;

&lt;p&gt;Percentage pricing is a bet that your customers stay small. If they don't, you either become the thing they're trying to leave, or you cap it. I'd rather say out loud where the line is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third option, which is the point of this being open source
&lt;/h2&gt;

&lt;p&gt;There's a column missing from that table: &lt;strong&gt;self-hosted, which is $0 in licence fees.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The software is open source under a permissive licence. Above roughly $16,800 a month in GMV, the honest recommendation from the person who wrote it is: stop paying me, run it yourself. What you pay instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A small server and a Postgres instance. Call it $20 to $60 a month for a shop at this scale.&lt;/li&gt;
&lt;li&gt;The upgrade command, occasionally.&lt;/li&gt;
&lt;li&gt;Your own backups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a real cost and it isn't zero, but it's flat, and flat is what you want once your revenue is the number that's growing.&lt;/p&gt;

&lt;p&gt;This is the argument for open source that I find convincing, and it's not about ideology. &lt;strong&gt;The exit is priced in from day one.&lt;/strong&gt; A customer who outgrows my hosted pricing takes their data and runs the same code somewhere cheaper. That constrains what I can charge, which is exactly the property that makes the hosted plan trustworthy on the way up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this comparison deliberately doesn't cover
&lt;/h2&gt;

&lt;p&gt;Being precise about the limits, since a price table invites over-reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This is price only.&lt;/strong&gt; The tiers being compared differ in limits, seats and included features. I'm comparing what you pay, not what you get, and anyone choosing between them should compare the features they actually need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment processing fees are excluded on both sides.&lt;/strong&gt; Stripe and PayPal take their cut regardless of who hosts the funnel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The competitor's prices are a snapshot&lt;/strong&gt; from 2026-08-01. The crossover moves when they move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted infrastructure cost is an estimate&lt;/strong&gt;, not a measurement, and it depends entirely on your traffic and your provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The arithmetic is arithmetic. The conclusion is a judgment, and it's this: percentage pricing is the right answer for people starting out and the wrong answer for people who have arrived, and any vendor selling it should be able to tell you which one you are.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>software</category>
      <category>startup</category>
    </item>
    <item>
      <title>Why I Split a Funnel Builder Into 16 Bounded Contexts</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Tue, 29 Sep 2026 20:01:33 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/why-i-split-a-funnel-builder-into-16-bounded-contexts-23n5</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/why-i-split-a-funnel-builder-into-16-bounded-contexts-23n5</guid>
      <description>&lt;p&gt;A funnel builder is a checkout page, a couple of upsell pages, and a thank-you page. That's what it looks like from outside. From inside it's a page editor, a payment layer, an ecommerce integration, an ad-conversion pipeline, an email system, a coupon engine, an analytics rollup, an abandoned-cart recovery job, a permissions model, and an AI media generator.&lt;/p&gt;

&lt;p&gt;Ten things that share nothing except a database. I split them into 16 bounded contexts, and I want to lay out what that bought and what it cost, because "we did DDD" is not information.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, and whether it actually holds
&lt;/h2&gt;

&lt;p&gt;One rule: &lt;strong&gt;contexts do not import each other.&lt;/strong&gt; They talk through ports defined in a contracts layer, and a composition root wires the implementations together.&lt;/p&gt;

&lt;p&gt;Rules like that are worth exactly as much as their enforcement, so before writing this I measured. Across all 16 contexts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;14 have zero references to another context.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;messaging&lt;/code&gt; has one: a type-only import of an identity port interface, erased at compile time.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;order-fulfillment&lt;/code&gt; has one: a reference in a test file, not in shipped code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zero runtime cross-context imports. I'll take it, and I'll note that I got there by measuring rather than by trusting, and that the measurement is one shell pipeline that anyone can rerun. A rule you can check in five seconds is a rule that survives; a rule in a README is a preference.&lt;/p&gt;

&lt;p&gt;The shape inside each context is boring on purpose: &lt;code&gt;domain/&lt;/code&gt; for entities and value objects, &lt;code&gt;application/&lt;/code&gt; for use cases and ports, &lt;code&gt;infra/&lt;/code&gt; for the adapters that implement them. 395 non-test files across the contexts, 52 in the composition root.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually bought
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Swapping an ecommerce backend is a file, not a project.&lt;/strong&gt; Shopify, WooCommerce and a self-hosted alternative all live behind one &lt;code&gt;commerce-gateway&lt;/code&gt; context. The rest of the system asks for a catalog and hands over a completed sale; it has never heard of GraphQL or of &lt;code&gt;X-Shopify-Access-Token&lt;/code&gt;. When I added the third backend, nothing outside that context changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A payment provider's weirdness stays local.&lt;/strong&gt; PayPal's authorize-then-capture flow and Stripe's charge-again flow are genuinely different shapes. Inside &lt;code&gt;payments&lt;/code&gt;, that's two adapters. Outside it, it's one port. The alternative, which I've seen and written, is &lt;code&gt;if (provider === 'paypal')&lt;/code&gt; distributed across order code, email code and analytics code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests stopped needing a database.&lt;/strong&gt; A use case that takes ports as constructor arguments is tested with plain objects. That was not the goal and it's the benefit I'd actually cite first now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost, in specifics
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The composition root is a real place with real weight.&lt;/strong&gt; 52 files whose entire job is constructing other things. &lt;code&gt;make-payments.ts&lt;/code&gt;, &lt;code&gt;make-commerce-gateway.ts&lt;/code&gt;, &lt;code&gt;make-messaging.ts&lt;/code&gt;. When a use case needs a new dependency, you edit the factory too. That's a permanent tax on every change, and anyone who says the indirection is free is selling something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-context workflows need a home, and it isn't obvious.&lt;/strong&gt; "Buyer accepts an upsell" touches checkout, payments, orders and the ecommerce gateway. It can't live in any one context without breaking the rule. So it lives in the composition layer as a coordinator, and those coordinators are the least principled files in the codebase, because they're where the rule stops helping. If someone reviewed this architecture I'd point them there first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Naming is a recurring argument.&lt;/strong&gt; Does a discount code belong to &lt;code&gt;coupons&lt;/code&gt; or to &lt;code&gt;storefront-checkout&lt;/code&gt;? Does a shipped-order email belong to &lt;code&gt;order-fulfillment&lt;/code&gt; or &lt;code&gt;messaging&lt;/code&gt;? Every one of those has a defensible answer either way, and the discussion recurs every time a feature straddles the line. That's the real ongoing cost, and it's paid in attention, not in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary that mattered most
&lt;/h2&gt;

&lt;p&gt;Not one of the 16. The decision I'd defend hardest is what the system &lt;strong&gt;refuses to own&lt;/strong&gt;: product catalog and inventory.&lt;/p&gt;

&lt;p&gt;A funnel builder needs products. The tempting move is to store them, because then you control the schema. The problem is that the merchant's real inventory lives in their store, and the moment you keep a copy, you own a sync problem forever, with a resolution policy for every conflict, and the failure mode is overselling a product you no longer have.&lt;/p&gt;

&lt;p&gt;So the catalog is read through the gateway at request time, and completed sales are written back. The system owns the sale record, the funnel, and the customer's path through it. It does not own the merchandise. That single "no" removed an entire category of bug that would otherwise have needed its own context and its own reconciliation job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The most valuable architectural decisions I've made in this project have been about what not to store.&lt;/strong&gt; A boundary that keeps state out is worth more than one that organizes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When 16 contexts is the wrong answer
&lt;/h2&gt;

&lt;p&gt;If your app is one workflow with one integration, this is overhead with a vocabulary. The structure pays off under two conditions, and I'd want both before recommending it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Multiple interchangeable external providers in the same slot.&lt;/strong&gt; Three ecommerce backends, two payment providers, four ad platforms, three email senders. Ports and adapters are the correct tool for that shape and there isn't a better one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Genuinely unrelated subsystems in one deployment.&lt;/strong&gt; The AI media generator and the coupon engine have nothing to say to each other. Enforced separation is what keeps that true a year later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you have one provider per slot and one coherent subsystem, a well-organized &lt;code&gt;services/&lt;/code&gt; directory will beat this, and it'll beat it on the metric that matters, which is how long it takes someone new to find the code that runs.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Never Leave a Payment Authorized and Uncaptured</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Thu, 24 Sep 2026 20:01:10 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/never-leave-a-payment-authorized-and-uncaptured-20jo</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/never-leave-a-payment-authorized-and-uncaptured-20jo</guid>
      <description>&lt;p&gt;PayPal lets you leave an approved order uncaptured. The buyer has agreed, no money has moved, and you capture it whenever you decide to. My checkout uses that deliberately so a post-purchase upsell can be added to the order before anything is charged.&lt;/p&gt;

&lt;p&gt;The consequence is a state I have to defend against: real orders where the customer believes they bought something and I have not taken their money. Nothing in the request path will ever capture those, because the request that would have done it is the one the buyer never made. They closed the tab.&lt;/p&gt;

&lt;p&gt;That is not an edge case. Abandoning mid-funnel is the single most common thing a buyer does, and every one of those abandonments is a sale I have already promised to deliver and not yet been paid for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safety net
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Safety net for PayPal merged upsells: a buyer who abandons mid-upsell leaves the order APPROVED&lt;/span&gt;
&lt;span class="c1"&gt;// but uncaptured. Capture anything untouched past the cutoff (base + upsells accepted so far) so the&lt;/span&gt;
&lt;span class="c1"&gt;// main sale is never lost. `updatedAt &amp;lt; cutoff` naturally excludes buyers still actively upselling,&lt;/span&gt;
&lt;span class="c1"&gt;// since each accepted upsell patch refreshes the intent.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runDeferredCaptureSweep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{...})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;intentRepo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findDeferredOlderThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;captured&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureNow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`safetynet:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;succeeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;captured&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;failed&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;abandoned deferred capture failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;captured&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scanned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Called with a 20-minute cutoff. Three details in there are load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;updatedAt &amp;lt; cutoff&lt;/code&gt; is an activity signal, not a timestamp filter.&lt;/strong&gt; Every accepted upsell patches the intent, which touches &lt;code&gt;updatedAt&lt;/code&gt;. So "not updated in 20 minutes" means "this buyer is not currently in the upsell flow", and I get the liveness check for free from a column I was already writing. I don't need a heartbeat or a session-expiry job. If you find yourself designing a "is the user still active" mechanism, check whether your write path already answers it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The idempotency key is prefixed.&lt;/strong&gt; &lt;code&gt;safetynet:${intent.id}&lt;/code&gt;, distinct from the keys the interactive path uses. If the buyer's own final capture and this sweep race, the provider sees two different keys for the same order and the second one fails cleanly on the provider's own state machine rather than silently succeeding twice. Namespacing keys by &lt;em&gt;who initiated the operation&lt;/em&gt; has caught more races for me than trying to make the keys identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The loop swallows per-item failures.&lt;/strong&gt; One order whose card expired between authorization and capture must not stop the other forty-nine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that made this actually run
&lt;/h2&gt;

&lt;p&gt;I wrote that sweep and it worked in tests and did nothing useful in production, because the scheduler underneath it was lying to me about frequency.&lt;/p&gt;

&lt;p&gt;On Cloudflare Workers, cron triggers are declared in config and the runtime calls one &lt;code&gt;scheduled&lt;/code&gt; handler. My handler runs &lt;em&gt;every&lt;/em&gt; sweep on &lt;em&gt;every&lt;/em&gt; invocation, and the trigger list is the union of all the jobs' cron expressions. So a job declaring "every 30 minutes" ran every 5 minutes, because some other job's &lt;code&gt;*/5&lt;/code&gt; trigger fired the shared handler and the shared handler runs everything.&lt;/p&gt;

&lt;p&gt;The declared schedule was decoration. Real frequency needs a gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runSweep&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lockTtlByName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;intervalByName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intervalMs&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;tooSoon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// cheap pre-gate&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;locked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;locked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;acquireLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lockKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;locked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intervalMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;tooSoon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// authoritative re-check&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lastRunKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intervalMs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cron sweep failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sweep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;releaseLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lockKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four decisions in there I'd defend:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pre-gate is outside the lock, and it's racy on purpose.&lt;/strong&gt; Acquiring the lock is a KV write and releasing it is a KV delete. Doing that before the interval check made every job pay both on every tick, including the overwhelming majority of ticks where the interval check then skipped it immediately. With &lt;code&gt;*/5&lt;/code&gt; in the trigger list, a job declaring a 30-minute interval burned roughly 288 writes and 288 deletes a day to do nothing. Across the whole catalog that was the dominant share of this namespace's write volume. The racy pre-gate filters the common case; the authoritative check inside the lock is what's correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The re-check inside the lock exists because two ticks can both clear the pre-gate&lt;/strong&gt; before either takes the lock. Without it the loser runs a second time the moment the winner releases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The timestamp is stamped before the run, not after.&lt;/strong&gt; A sweep that is slow or throwing must not re-fire on every subsequent tick and hammer an upstream API. The next attempt comes at the next interval boundary. This trades "a failed run retries promptly" for "a failing run can't turn into a hot loop against someone else's rate limit", and for anything that calls a payment provider that's the right side of the trade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's a 10% tolerance on the interval:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A tick can arrive a few ms before its nominal boundary. Comparing against the exact interval&lt;/span&gt;
&lt;span class="c1"&gt;// would then skip that tick and silently halve the job's real frequency.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;INTERVAL_TOLERANCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An exact comparison against a clock that isn't exact turns "every 30 minutes" into "every 60 minutes" for the ticks that land a few milliseconds early. Halving your job frequency is the kind of bug that produces a support ticket weeks later and no error anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building the same thing
&lt;/h2&gt;

&lt;p&gt;Two rules, both learned the expensive way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Any state where the customer thinks they paid and you haven't collected needs a timer that isn't the customer's browser.&lt;/strong&gt; Not a webhook, not an &lt;code&gt;onbeforeunload&lt;/code&gt;, not a session timeout in a store you also lose. A sweep that queries for the state and resolves it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check what actually invokes your scheduler before trusting a cron expression.&lt;/strong&gt; In a shared-handler runtime, per-job cron strings are documentation. The interval gate is the implementation. I had both for weeks and only the gate was doing anything.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why the Next Page in a Funnel Should Be a Redirect You Control</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Tue, 22 Sep 2026 20:00:56 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/why-the-next-page-in-a-funnel-should-be-a-redirect-you-control-nj</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/why-the-next-page-in-a-funnel-should-be-a-redirect-you-control-nj</guid>
      <description>&lt;p&gt;Here's a design mistake that's easy to make and expensive to unwind: a landing page's call-to-action button links directly to the next page's URL.&lt;/p&gt;

&lt;p&gt;It works immediately. It also means the funnel's structure is now encoded in the &lt;em&gt;content&lt;/em&gt; of every page in it. Want to insert an upsell step between the landing page and checkout? Edit the landing page. Want to A/B test a different order? Edit the pages, or clone them. Want to know how many people moved from step 2 to step 3? You can't, because nothing on your server observed the transition.&lt;/p&gt;

&lt;p&gt;So in my funnel builder, a CTA never points at a destination. It points at a position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/n/{funnelId}/{stepSlug}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the server decides what that means, at request time, with a 302.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the route actually does
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;funnelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Astro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;funnelId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;redirectData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getRedirectFromCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;redirectData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;funnel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;funnelId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;funnel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;stepSlug&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentIndex&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Redirect not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;nextStep&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;pageId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No next step in funnel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pageId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// ...cache the resolution&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that carefully: the URL names &lt;strong&gt;the step you are leaving&lt;/strong&gt;, and the handler resolves &lt;strong&gt;the step you are going to&lt;/strong&gt;. The button on the landing page says "I am the exit from step &lt;code&gt;lp&lt;/code&gt;", not "go to &lt;code&gt;/checkout-b&lt;/code&gt;". Reordering the funnel is a data change in one place, and every page in it follows automatically.&lt;/p&gt;

&lt;p&gt;That inversion is the whole idea. Everything below is the consequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consequence 1: the query string has to be carried, deliberately
&lt;/h2&gt;

&lt;p&gt;Traffic arrives with attribution parameters on it: &lt;code&gt;fbclid&lt;/code&gt;, &lt;code&gt;gclid&lt;/code&gt;, &lt;code&gt;ttclid&lt;/code&gt;, &lt;code&gt;utm_*&lt;/code&gt;. If a redirect drops them, ad conversion reporting silently stops working, and you find out at the end of the month when the numbers don't reconcile.&lt;/p&gt;

&lt;p&gt;So the handler rebuilds the query string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;incomingUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Identity travels via the domain-scoped anid cookie; stop re-stamping it into URLs.&lt;/span&gt;
&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;goid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;funnelId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;funnelId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forward everything, with two deliberate deletions. The visitor identifier used to be re-stamped into every URL, which meant it ended up in referrer headers, in shared links, in customers' pasted URLs, and in analytics tools that log full URLs. It travels in a domain-scoped cookie now, and stripping it here is what stops it leaking back in through a redirect.&lt;/p&gt;

&lt;p&gt;If you build this, decide explicitly which parameters are &lt;em&gt;identity&lt;/em&gt; and which are &lt;em&gt;attribution&lt;/em&gt;. Identity should not be in URLs. Attribution has to be, because that's how the ad platforms hand it to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consequence 2: cache the resolution, never the redirect
&lt;/h2&gt;

&lt;p&gt;The step-to-page resolution is two database queries. Doing them on every click is silly, so the result is cached by &lt;code&gt;funnelId:stepSlug&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But the &lt;em&gt;response&lt;/em&gt; must never be cached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Location&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-store, no-cache, must-revalidate, proxy-revalidate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Referrer-Policy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-referrer-when-downgrade&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;302 rather than 301 for the same reason, said in the status code. A 301 is permanent, browsers cache it aggressively, and some of them keep it after you've changed the funnel. You would be shipping a redirect that certain visitors can never escape, and you'd have no way to fix it for them. &lt;strong&gt;Any redirect whose target is data should be a 302 with no-store.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The final URL differs per visitor anyway, because it carries their query string. There is nothing cacheable about the response even in principle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consequence 3: the failure modes become visible
&lt;/h2&gt;

&lt;p&gt;Because the resolution runs server-side, the failures are HTTP statuses and log lines, not a silent 404 on a hand-typed URL:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Step slug isn't in the funnel: &lt;code&gt;404 Redirect not found&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Step is the last one: &lt;code&gt;404 No next step in funnel&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Step points at a deleted page: &lt;code&gt;404 Next page not found&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those is a distinct, diagnosable state. In the link-directly-to-the-URL design, all three are the same thing: a broken link discovered by a customer.&lt;/p&gt;

&lt;p&gt;There's a corresponding cost, and it's the honest downside of this design: &lt;strong&gt;the funnel can be misconfigured in ways the page editor can't see.&lt;/strong&gt; A user drags a button, sets it to "next step", and whether that works depends on funnel data they're not currently looking at. I handle it with a validator that checks a landing page contains a correctly-formed step link and shows an error in the funnel settings, but it's a second surface to maintain, and it exists purely because of this indirection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consequence 4: the redirect is the only place transitions are observable
&lt;/h2&gt;

&lt;p&gt;Every step transition passes through one handler. That's where the funnel-level analytics event belongs, and it's server-side, which means it isn't subject to ad blockers, JavaScript errors, or someone closing the tab before an &lt;code&gt;onclick&lt;/code&gt; fires.&lt;/p&gt;

&lt;p&gt;Client-side tracking of a CTA click is fundamentally a best-effort signal. A 302 handler that ran is a fact. If you care about your funnel numbers being defensible, that difference is the whole ballgame.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a plain link is correct
&lt;/h2&gt;

&lt;p&gt;If your flow is fixed and you wrote it yourself in code, link directly and skip all of this. Indirection you can't reconfigure is just an extra hop.&lt;/p&gt;

&lt;p&gt;The break-even is when someone who isn't you edits the flow. That's the moment "the page knows where the next page is" becomes a data-integrity problem instead of a URL, and one 94-line route handler is a cheap way to never have it.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>softwaredevelopment</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Don't Put Self-Hosted Config in Environment Variables</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Thu, 17 Sep 2026 20:01:27 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/dont-put-self-hosted-config-in-environment-variables-3lnd</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/dont-put-self-hosted-config-in-environment-variables-3lnd</guid>
      <description>&lt;p&gt;Look at the &lt;code&gt;.env.example&lt;/code&gt; of any self-hosted app you've installed. Mine used to look like that too: S3 keys, SMTP credentials, payment provider secrets, an ecommerce API token, all of it required, all of it read once at boot.&lt;/p&gt;

&lt;p&gt;That design has a specific victim, and it isn't you. It's the person who installs your software but doesn't have a shell on the box it runs on. Every configuration change becomes: edit a file over SSH, restart the container, hope nothing else was depending on the old value. For software that ships an admin UI, that's an incoherent story. Half the settings are in a web form and the other half require a terminal.&lt;/p&gt;

&lt;p&gt;I moved essentially all of it into the database. Environment variables are now a fallback, not the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getConfig&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;envFallback&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;memoKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_configMemo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;envFallback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getEffectiveConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makePlatform&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;getEffectiveConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;_configMemo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;CONFIG_MEMO_TTL_MS&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;envFallback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the stored value; fall back to the environment variable if it's unset. Keys are declared once in a registry with their env fallback beside them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;DEFAULT_CDN_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cdn.default_url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DEFAULT_CDN_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nx"&gt;GOOGLE_MAPS_API_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;google_maps.api_key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GOOGLE_MAPS_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nx"&gt;PERMISSIONS_ADMIN_USER_IDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;permissions.admin_user_ids&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PERMISSIONS_ADMIN_USER_IDS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The registry is the part I'd copy into any project. One file that lists every configurable thing, its storage key, its env fallback and its type. It doubles as the documentation, and unlike documentation it can't drift, because the accessors are generated from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things this got right, and one it didn't
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Nothing is required to boot.&lt;/strong&gt; Every integration is inert until configured. No LLM key means the AI features hide themselves; no S3 config means uploads throw a specific, catchable error and the admin UI shows a banner. The app starts with an empty environment, which is what makes a one-command install possible at all. That property is downstream of this decision, not of the Dockerfile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changing a credential doesn't need a restart.&lt;/strong&gt; For a single-box deployment, that's the difference between a settings form and an SSH session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A 5-second memo, and it's deliberately that short.&lt;/strong&gt; Every typed accessor calls &lt;code&gt;getConfig&lt;/code&gt;, so a single request can hit it dozens of times, each doing two serial reads. The memo collapses that to one. The TTL is far shorter than any request, so it's effectively per-request, and cross-process staleness is bounded at five seconds. Writes invalidate the exact key in-process, so same-process reads are read-your-writes. I'd rather bound staleness with a small number than build an invalidation protocol across processes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nested updates are read-modify-write in application code:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Read-modify-write of a nested path inside a JSON config document. Done in JS (not&lt;/span&gt;
&lt;span class="c1"&gt;// jsonb_set) because the underlying store does not create missing intermediate containers.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setConfigPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;getConfig&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the one it got wrong, or at least the sharp edge I'd warn you about. &lt;code&gt;getConfig&lt;/code&gt; returns the memoized object &lt;strong&gt;by reference&lt;/strong&gt;. &lt;code&gt;setConfigPath&lt;/code&gt; mutates that object in place before writing it back. So any decision you make based on a config object read &lt;em&gt;before&lt;/em&gt; a path-mutating call may be reading post-mutation state. It has bitten me. The rule that keeps it safe is: snapshot what you need before calling anything that mutates a path. A defensive clone in &lt;code&gt;getConfig&lt;/code&gt; would remove the whole class, at the cost of an allocation per call, and if I were writing it today I'd pay that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleting a key writes null rather than deleting the row:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The platform config store is write-through with no row delete; clearing a key to null&lt;/span&gt;
&lt;span class="c1"&gt;// reads back as "unset" (getConfig falls through to envFallback), which is the behavior&lt;/span&gt;
&lt;span class="c1"&gt;// every caller of deleteConfig relies on.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth stating because it's the kind of implementation detail that becomes a bug when someone later "optimizes" it into a real delete and changes what unset means.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule I'd write in capitals
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Once published, KV keys MUST NOT be renamed - installations may have written&lt;/span&gt;
&lt;span class="c1"&gt;// rows against them. Add new keys, never repurpose old ones.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Config keys are a public interface the moment you ship a version. Rename &lt;code&gt;email.from_address&lt;/code&gt; to &lt;code&gt;email.sender&lt;/code&gt; and every existing installation silently reverts to defaults on upgrade. Nothing errors. Emails go out from the wrong address until someone notices.&lt;/p&gt;

&lt;p&gt;Environment variables have the same property, but you get away with more, because a missing env var usually fails loudly at boot. A missing database row falls back to a default and keeps running. &lt;strong&gt;Moving config to a database makes renames strictly more dangerous, and it's the one thing I'd tell someone before they copy this design.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What genuinely belongs in the environment
&lt;/h2&gt;

&lt;p&gt;Not everything moved, and the split isn't arbitrary. Three things stayed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DATABASE_URL&lt;/code&gt;, because you can't read the config table without it.&lt;/li&gt;
&lt;li&gt;The session secret and the credentials encryption key, because they're needed to decrypt what's in the config table.&lt;/li&gt;
&lt;li&gt;The port and the admin hostname, needed before request handling exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else is data. The test I use: &lt;strong&gt;if the app can start without it, it isn't an environment variable.&lt;/strong&gt; That leaves you with three or four, which is a &lt;code&gt;.env.example&lt;/code&gt; someone will actually read.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Built a Page Importer That Clones Any URL Into an Editor</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Tue, 15 Sep 2026 20:01:02 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/i-built-a-page-importer-that-clones-any-url-into-an-editor-go1</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/i-built-a-page-importer-that-clones-any-url-into-an-editor-go1</guid>
      <description>&lt;p&gt;"Import from URL" is a button in my page editor. You paste any public page's address, and a few seconds later an editable copy of it is open in the canvas, with every image, stylesheet and script already served from your own CDN.&lt;/p&gt;

&lt;p&gt;The feature description is one sentence. The implementation is a fetch, a bot-wall detector, an HTML parser, a bounded upload pool, and a network egress policy. Here's what each of those is defending against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tiers, and only one of them fails loudly
&lt;/h2&gt;

&lt;p&gt;Plain &lt;code&gt;fetch&lt;/code&gt; gets you the HTML the server sent. It does not get you anything a framework rendered client-side, which on modern marketing pages is often the hero, the tabs and the countdown. So there's a second tier using a headless browser API that returns the DOM after load.&lt;/p&gt;

&lt;p&gt;The interesting part is the fallback policy, because "fall back on error" is wrong here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;captureHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ImportTier&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchRenderedHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;browser-rendering&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;BrowserRenderingNotConfiguredError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Browser Rendering not configured, falling back to fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;plainFetchHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;BrowserRenderingHttpError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;UnsafeUrlError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Browser Rendering network failure, falling back to fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;plainFetchHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three different treatments for three different causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not configured&lt;/strong&gt; is a normal state, not an error. Most self-hosted installs never set up the browser API. Degrade silently, log at info.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network failure&lt;/strong&gt; is transient. Degrade, log at warn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An HTTP error from the browser service&lt;/strong&gt; propagates as a 502. This is the one that matters: if the upstream said 500 or 429, silently falling back means the user gets a visibly worse import and no explanation of why. They'd file a bug about missing images, and the real cause would be a quota.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result carries the tier that produced it, so the UI can say which path ran. &lt;strong&gt;Any automatic degradation the user can't see is a support ticket with the wrong title.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting a bot wall before you save it
&lt;/h2&gt;

&lt;p&gt;Fetch a page behind a challenge and you get 200 OK with a body containing a spinner and a JavaScript puzzle. Store that and you've created a "page" whose content is someone else's captcha, which will confuse the user far more than an error would.&lt;/p&gt;

&lt;p&gt;So there's a detector, and it's deliberately a pile of string checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TITLE_MARKERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;title&amp;gt;Just a moment...&amp;lt;/title&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;title&amp;gt;Just a moment…&amp;lt;/title&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CLASS_MARKERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;class="cf-browser-verification"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;class="cf-injected-html"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TEXT_MARKERS&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Checking your browser before accessing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;META_REFRESH_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;meta&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+http-equiv=&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;refresh&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;][^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*__cf_chl_tk/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CF_WRAPPER_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/id=&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;cf-wrapper&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CF_WRAPPER_MAX_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I'd point out. The title list contains both an ASCII ellipsis and a Unicode one, because the real page has shipped both and matching one of them is a detector that works until it doesn't.&lt;/p&gt;

&lt;p&gt;And the last rule is size-gated: an element id alone is too weak a signal, since a real page could legitimately use it, so it only counts as a challenge if the whole document is under 50 KB. A challenge page is tiny; a real page that happens to contain that id is not. &lt;strong&gt;When a heuristic is individually too weak, pairing it with a size or structure constraint is usually cheaper than finding a stronger one.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rewriting the assets without a serial upload loop
&lt;/h2&gt;

&lt;p&gt;A captured page references 50 to 150 assets. Each needs downloading and re-uploading to the user's own storage, then the HTML needs rewriting to point at the new URLs.&lt;/p&gt;

&lt;p&gt;The naive version does that one at a time inside the traversal, and blows the request timeout. The structure that works is three phases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Collect every absolute source URL first, then upload uniques with a bounded pool, then rewrite&lt;/span&gt;
&lt;span class="c1"&gt;// synchronously off the resolved map.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Collect (a &lt;code&gt;Set&lt;/code&gt;, so a logo referenced twelve times uploads once), upload with a concurrency limit of 5, then rewrite synchronously from the resolved map. The rewrite phase touching no network at all is what makes it comprehensible: after the pool drains, it's pure string substitution over a parsed tree.&lt;/p&gt;

&lt;p&gt;The attribute list is longer than you'd guess, and each entry is a bug someone would otherwise report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;img&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;srcset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;link[rel="apple-touch-icon"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;href&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;poster&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;srcset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;embed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;plus a regex pass for &lt;code&gt;url(...)&lt;/code&gt; inside inline styles. &lt;code&gt;srcset&lt;/code&gt; needs its own parsing branch, since it's a comma-separated list of URL-plus-descriptor pairs rather than a single URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The security problem this feature is, by definition
&lt;/h2&gt;

&lt;p&gt;A form that takes a URL and makes the server fetch it is server-side request forgery with a nice UI. Left unguarded, someone types &lt;code&gt;http://169.254.169.254/...&lt;/code&gt; or an internal hostname and my server dutifully retrieves it.&lt;/p&gt;

&lt;p&gt;So every outbound fetch in this path goes through a wrapper that validates the destination and then, critically, &lt;strong&gt;pins the connection to the address it validated.&lt;/strong&gt; Checking DNS and then calling &lt;code&gt;fetch&lt;/code&gt; is not sufficient: the name can resolve differently on the second lookup, which is the DNS rebinding attack. Every redirect hop is re-validated for the same reason.&lt;/p&gt;

&lt;p&gt;The runtime split is worth stating plainly, because it's the honest version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On Node, the connection is pinned to the validated IP while preserving SNI and the Host header, so certificate validation is unaffected.&lt;/li&gt;
&lt;li&gt;On workerd there is no &lt;code&gt;node:http&lt;/code&gt; and its &lt;code&gt;fetch&lt;/code&gt; can't be told which address to use. The request proceeds with a debug log, relying on the platform's egress not reaching a private network.&lt;/li&gt;
&lt;li&gt;Any other runtime, with neither DNS nor pinning available, is refused outright.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the part I'd argue for hardest. The tempting default is to allow when you can't check. &lt;strong&gt;A guard that silently disables itself on unknown infrastructure is worse than no guard, because it's the one you stop thinking about.&lt;/strong&gt; Fail closed and let the deployment tell you it needs support.&lt;/p&gt;

&lt;p&gt;There are also byte and time ceilings on the fetch itself (20 MB, 30 seconds), enforced by capping the response body stream rather than trusting the &lt;code&gt;Content-Length&lt;/code&gt; header, since a hostile server will happily lie about that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I tried that made it worse
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scrolling the page before capture.&lt;/strong&gt; It loads lazy images. It also looks exactly like automation and reliably triggered challenges on sites that had let a plain load through. Net negative.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inlining computed styles.&lt;/strong&gt; This gives you a pixel-accurate snapshot and destroys the page, because computed styles are resolved at one viewport width. Every media query stops mattering and the import is unusable on mobile. Snapshot fidelity and editability are in direct conflict, and for something that opens in an editor, editability has to win.&lt;/p&gt;

</description>
      <category>api</category>
      <category>html</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stop Hardcoding Payment Providers Into Your Checkout Analytics</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Thu, 10 Sep 2026 20:01:14 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/stop-hardcoding-payment-providers-into-your-checkout-analytics-3bib</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/stop-hardcoding-payment-providers-into-your-checkout-analytics-3bib</guid>
      <description>&lt;p&gt;Here is a code smell that took me two payment providers to see clearly. My funnel analytics page had a column called &lt;code&gt;stripePayment&lt;/code&gt;. It also had &lt;code&gt;paypalPayment&lt;/code&gt;, &lt;code&gt;paypalCcPayment&lt;/code&gt;, and &lt;code&gt;stripeDeclined&lt;/code&gt;. The SQL that filled those columns named the event strings inline. The order detail page had a &lt;code&gt;switch&lt;/code&gt; that turned a payment method into a badge color. The settings form that collects API keys had a hand-written &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt; per provider per field.&lt;/p&gt;

&lt;p&gt;Adding a third provider meant touching five files that have nothing to do with payments. Worse, it meant a new contributor had to &lt;em&gt;know&lt;/em&gt; those five files existed. That's not a refactor you postpone. That's a design error you fix before it compounds.&lt;/p&gt;

&lt;p&gt;The fix is not interesting in the abstract - it's a registry. What's interesting is what belongs in the registration, because my first attempt put too little in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a provider actually owns
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PaymentProviderRegistration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;paymentMethod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;dbProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;eventTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PaymentEventTypes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PaymentDisplayInfo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;formFields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PaymentFormFieldSpec&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PaymentDetailInfo&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;statsColumns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StatsColumnDef&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;getPublicConfig&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first version of this had &lt;code&gt;eventTypes&lt;/code&gt; and nothing else, and it didn't help much, because the analytics page still had to know which columns to draw. The version that actually removed provider names from unrelated modules is the one where the provider also declares &lt;strong&gt;its own analytics columns&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;statsColumns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;statColumns&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAYPAL_BUTTON_CLICK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paypalButtonClick&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PayPal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PayPal Button Click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAYPAL_EXPRESS_CLICK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paypalExpressClick&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PP Express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PayPal Express Button Click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAYPAL_CC_CLICK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paypalCcClick&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PP CC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PayPal Credit Card Click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAYPAL_PAYMENT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paypalPayment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PayPal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PayPal Payment Success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAYPAL_CC_PAYMENT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paypalCcPayment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PP CC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PayPal Credit Card Payment Success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAYPAL_ERROR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paypalError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PP Error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PayPal Payment Error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the analytics page asks the registry rather than a hardcoded list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAllStatsColumns&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;StatsColumnDef&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;flatMapEntries&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statsColumns&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now "add a provider" is one object in one file. The stats table grows a column. The settings screen grows a form. The order badge renders. Nothing in &lt;code&gt;src/lib/stats&lt;/code&gt; contains the string "Stripe".&lt;/p&gt;

&lt;h2&gt;
  
  
  The detail that makes this non-obvious: a provider is not a payment method
&lt;/h2&gt;

&lt;p&gt;This is the part I'd have gotten wrong if I'd designed it up front instead of after shipping.&lt;/p&gt;

&lt;p&gt;PayPal is not one thing. A buyer can pay through the PayPal button, through PayPal Express, or by typing a card number into PayPal's embedded card fields. Commercially those are three different things: if your embedded card fields are broken, your PayPal revenue looks fine in aggregate and you lose money for a week without noticing.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;PAYPAL_PAYMENT&lt;/code&gt; and &lt;code&gt;PAYPAL_CC_PAYMENT&lt;/code&gt; are separate success events, chosen at write time from metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getSuccessEventType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paymentMethod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getProviderByMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paymentMethod&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_SUCCESS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;successEvents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventTypes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wantsCardEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;paymentMethod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paypal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;isCardPayment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wantsCardEvent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;successEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;successEvents&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;successEvents&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_SUCCESS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'm not going to pretend that's clean. &lt;code&gt;paymentMethod === 'paypal'&lt;/code&gt; is a provider name inside the registry's generic resolver, which is exactly what the registry was supposed to eliminate. It's the honest state of the code: one provider has a sub-method distinction that the others don't, and I chose a three-line special case over a general "sub-method selector" abstraction that would have one implementation.&lt;/p&gt;

&lt;p&gt;If a second provider ever needs it, the special case becomes a &lt;code&gt;selectSuccessEvent(metadata)&lt;/code&gt; hook on the registration and this function loses the &lt;code&gt;if&lt;/code&gt;. Until then, generalizing would be inventing a requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fall back, don't throw
&lt;/h2&gt;

&lt;p&gt;Every one of these lookups takes a string that came from a client request or a database row written months ago by an older version of the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_SUCCESS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAYMENT_SUCCESS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK_ERROR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAYMENT_ERROR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPaymentDisplay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paymentMethod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;PaymentDisplayInfo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getProviderByMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paymentMethod&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;FALLBACK_DISPLAY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paymentMethod&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unknown method renders its own raw name in a grey badge. It does not throw. This matters more than it looks: the alternative is that deleting a provider from the registry makes every historical order that used it un-viewable, and you find that out in production, from a customer, during a refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registration is a side effect, and that has a cost
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;registration&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;paypal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;registerPaymentProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A module-level loop mutating a module-level &lt;code&gt;Map&lt;/code&gt;. It's the simplest thing that works, and it has a real failure mode: if some module reads &lt;code&gt;getAllStatsColumns()&lt;/code&gt; before this file has been imported, it gets an empty array and the stats page silently renders zero columns. Import order becomes load-bearing.&lt;/p&gt;

&lt;p&gt;I'd flag this as the weakest part of the design. The fix, if it ever bites, is an explicit &lt;code&gt;initPaymentProviders()&lt;/code&gt; called from the composition root instead of an import side effect. I haven't done it because there's exactly one entry point today, and I'd rather have the known-shaped problem than a speculative fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is over-engineering
&lt;/h2&gt;

&lt;p&gt;If you will only ever integrate one payment provider, skip all of it. The break-even isn't the second provider - it's the second provider &lt;strong&gt;whose event shape differs from the first&lt;/strong&gt;. Two providers that both emit one click and one success event can live behind a plain enum forever.&lt;/p&gt;

&lt;p&gt;I hit the break-even the moment PayPal needed three click events and Stripe needed one, because that's the point where every consumer of the data has to stop assuming a one-to-one mapping. That assumption was baked into four files. Registering it in one was cheaper than deleting it from four again later.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>refactoring</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>I Built an Astro Integration That Injects Its Own Pages Directory</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Tue, 08 Sep 2026 20:00:42 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/i-built-an-astro-integration-that-injects-its-own-pages-directory-p0i</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/i-built-an-astro-integration-that-injects-its-own-pages-directory-p0i</guid>
      <description>&lt;p&gt;Most Astro integrations add a renderer, inject one endpoint, or wire a Vite plugin. Mine injects an entire application: every admin page, every storefront route, every API endpoint, all shipped inside an npm package so a consumer project can mount them with one line and then override any of them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;autonnel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;excludeRoutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;oauth2&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="p"&gt;})]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole integration is 261 lines across eight files. Here's how it works and, more usefully, where the approach leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enumerating your own pages at config time
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;injectRoute&lt;/code&gt; takes a pattern and an entrypoint, one call at a time. I have 203 routable files and I'm not writing that list by hand, so the integration walks its own &lt;code&gt;src/pages&lt;/code&gt; directory during &lt;code&gt;astro:config:setup&lt;/code&gt; and derives the patterns the same way Astro's file router would:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;derivePattern&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;relPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;relPath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;astro|ts|js&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;index$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;overview.astro&lt;/code&gt; becomes &lt;code&gt;/overview&lt;/code&gt;. &lt;code&gt;orders/index.astro&lt;/code&gt; becomes &lt;code&gt;/orders&lt;/code&gt;. &lt;code&gt;api/permissions/roles/[id].ts&lt;/code&gt; becomes &lt;code&gt;/api/permissions/roles/[id]&lt;/code&gt;, dynamic segment intact, because the bracket syntax is already the pattern syntax. That's the part that makes this viable: file-router conventions and route-pattern syntax are the same language, so the translation is three string operations.&lt;/p&gt;

&lt;p&gt;The walk has to reproduce the router's exclusions too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// Astro ignores these&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;astro|ts|js&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;test&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;ts|js&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;d&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;ts$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;404.astro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                            &lt;span class="c1"&gt;// consumer owns its own 404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is a product decision hiding in a loop. Injecting a 404 page means the consuming app can never have its own, and a 404 is exactly the sort of page a consumer wants to brand. Excluding one file is cheaper than an option nobody would find.&lt;/p&gt;

&lt;p&gt;The dual-path detail: the same code has to work from source during development and from &lt;code&gt;dist/&lt;/code&gt; after publish, so the pages directory is resolved from where the integration file itself lives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pagesRel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;integrationUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/dist/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../src/pages/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../pages/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A string check against your own module URL is not elegant. It is, as far as I can tell, the shortest thing that actually works for a package that must behave identically before and after a build step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exclusion is the whole point
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;filterRoutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;InjectableRoute&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;InjectableRoute&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;exclude&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight lines, and they're the reason this design works at all. A consumer that wants a different login page passes &lt;code&gt;excludeRoutes: ['/login']&lt;/code&gt; and puts their own &lt;code&gt;src/pages/login.astro&lt;/code&gt; in their project. The core route is never injected, so there's no conflict to resolve and no precedence rule to remember.&lt;/p&gt;

&lt;p&gt;Plugin routes deliberately bypass the filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// These bypass excludeRoutes: a consumer typically excludes a core route (e.g. /login) so a&lt;/span&gt;
&lt;span class="c1"&gt;// plugin can serve its own page at that pattern.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plugin&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;routes&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="nf"&gt;injectRoute&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entrypoint&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The common case is exclude-then-replace, so making the two lists interact would break the only workflow anyone uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'd flag in review
&lt;/h2&gt;

&lt;p&gt;The comment I'm least happy with is in &lt;code&gt;index.ts&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The injected pages are core SOURCE files importing via &lt;code&gt;@/...&lt;/code&gt;; the consumer must resolve that alias to the package src and mark the package src &lt;code&gt;ssr.noExternal&lt;/code&gt;. Astro's &lt;code&gt;updateConfig&lt;/code&gt; does not reliably apply a resolver plugin / alias from here, so this stays consumer-side.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is an integration with a documentation dependency. Install it, and it doesn't work until you also add an alias and an &lt;code&gt;ssr.noExternal&lt;/code&gt; entry to your own config. I tried to push both into &lt;code&gt;updateConfig&lt;/code&gt; and couldn't make it apply reliably from the setup hook.&lt;/p&gt;

&lt;p&gt;If you're designing something similar, know that this is the sharp edge: &lt;code&gt;injectRoute&lt;/code&gt; will happily take an entrypoint that resolves inside your package, but the &lt;em&gt;imports inside that entrypoint&lt;/em&gt; resolve in the consumer's module graph, not yours. Any non-relative import in an injected page is a constraint you're imposing on the consumer, and there's no mechanism to declare it.&lt;/p&gt;

&lt;p&gt;The alternative is compiling injected pages to use relative or bare specifiers only. That's a real fix and it's a large refactor of a hundred files, so I've written down the constraint instead of paying for it. That's a trade, not a solution, and I'd want a reviewer to call it that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap I found writing this post
&lt;/h2&gt;

&lt;p&gt;The integration is also where plugins get registered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;registerAuthoringComponents&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;registerPlugins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which means plugins only register when the app is consumed &lt;em&gt;through the integration&lt;/em&gt;. My two documented install paths (a Docker image and a git clone) both run the repo's own &lt;code&gt;astro.config.mjs&lt;/code&gt; and use the native file router, not the integration. So on both of them, &lt;code&gt;registerPlugins&lt;/code&gt; never runs and a marketplace plugin can't activate.&lt;/p&gt;

&lt;p&gt;That's a real gap, not a hypothetical one, and I found it by writing out the flow for this post rather than by reading the code. There's a lesson in that which I'd generalize: &lt;strong&gt;any initialization that lives in an optional wrapper is unreachable on every path that doesn't use the wrapper.&lt;/strong&gt; Registration belongs in the composition root that all paths share. Mine doesn't, yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I recommend this shape
&lt;/h2&gt;

&lt;p&gt;For "reusable pieces", no. Ship components and let people compose them.&lt;/p&gt;

&lt;p&gt;For "an entire application that a consumer extends", the injected-routes approach is genuinely good, and the exclusion mechanism is what makes it feel like a framework rather than a fork. The honest caveats: your injected pages' imports become a contract you can't enforce, and any setup work that happens in the integration hook silently doesn't happen for anyone running your source directly.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>programming</category>
      <category>softwaredevelopment</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Made a Self-Hosted Checkout Stack That Boots With One Command and Zero Config</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Thu, 03 Sep 2026 20:00:41 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/i-made-a-self-hosted-checkout-stack-that-boots-with-one-command-and-zero-config-53gb</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/i-made-a-self-hosted-checkout-stack-that-boots-with-one-command-and-zero-config-53gb</guid>
      <description>&lt;p&gt;Most self-hosted software fails its user in the first ten minutes, and it fails in a specific way: &lt;code&gt;.env.example&lt;/code&gt; has forty variables, eight of them are required, and three of those require you to go create an account somewhere else before the app will boot.&lt;/p&gt;

&lt;p&gt;I set a hard constraint for mine: &lt;code&gt;docker compose up&lt;/code&gt;, open a browser, done. No Node install, no external Postgres, no S3 bucket, no SMTP credentials, no generated secrets. Not "fast to configure" - &lt;strong&gt;zero configuration to boot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That constraint turned out to be an architecture decision, not a packaging decision, and it changed how config works in the app itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compose file is three services
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17-alpine&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;autonnel&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;autonnel"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;

  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/example/app:${TAG:-latest}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node_modules/.bin/prisma&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;push&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--schema=./prisma/schema.prisma&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--url&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;*db-url&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no"&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/example/app:${TAG:-latest}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_completed_successfully&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${PORT:-4321}:4321"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middle one is the piece people skip. &lt;code&gt;schema&lt;/code&gt; is a one-shot container that creates or updates tables and exits. &lt;code&gt;app&lt;/code&gt; waits on &lt;code&gt;service_completed_successfully&lt;/code&gt;, not on &lt;code&gt;service_started&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two properties fall out of that, and both matter more than the ten lines cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The app never races the schema. There is no retry loop, no "waiting for database" spinner, no partially-migrated first request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The upgrade path is the same command as the install path.&lt;/strong&gt; &lt;code&gt;docker compose up&lt;/code&gt; after a version bump runs the migration container again before starting the new app. There is no separate &lt;code&gt;migrate&lt;/code&gt; step to document, and therefore no step for a user to skip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;restart: "no"&lt;/code&gt; is not decoration. Without it, a one-shot container that exits 0 gets restarted by a default restart policy inherited from anywhere, and you get a migration loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha that only appears in a stripped image
&lt;/h2&gt;

&lt;p&gt;Prisma 7 reads the datasource URL from &lt;code&gt;prisma.config.ts&lt;/code&gt;. That file is a build-time artifact and it is not in my runtime image, because the runtime image only contains &lt;code&gt;dist&lt;/code&gt;, &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;package.json&lt;/code&gt; and the schema file.&lt;/p&gt;

&lt;p&gt;So the migration command has to pass the URL explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;...&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;--url&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://user:pass@db:5432/app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That took me longer to diagnose than it should have, because the error is about a missing config file and the cause is an image layer decision made in a different file. It's worth stating the general shape: &lt;strong&gt;the smaller you make your runtime image, the more implicit build-time context you have to make explicit at run time.&lt;/strong&gt; Anything your tooling "just finds" in the repo needs to be passed as a flag once the repo isn't there.&lt;/p&gt;

&lt;p&gt;For the same reason, &lt;code&gt;prisma generate&lt;/code&gt; runs in the build stages with a dummy URL fallback in the config. Generation never connects to a database, but it will refuse to run without a syntactically valid URL, and there is no database at image-build time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero config and real secrets are not actually in conflict
&lt;/h2&gt;

&lt;p&gt;The uncomfortable part of a zero-config boot is session secrets and credential encryption keys. You can't ask for them and still boot in one command. You can't generate them per-boot or every restart logs everyone out and makes stored provider credentials unreadable.&lt;/p&gt;

&lt;p&gt;So: baked development defaults, overridable, with the warning at the point of use rather than in a doc.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;AUTH_SESSION_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${AUTH_SESSION_SECRET:-dev-only-insecure-session-secret-change-me}&lt;/span&gt;
&lt;span class="na"&gt;CREDENTIALS_ENCRYPTION_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${CREDENTIALS_ENCRYPTION_KEY:-ZGV2LW9ubHktaW5zZWN1cmUta2V5LWNoYW5nZS1tZQ==}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compose reads a neighbouring &lt;code&gt;.env&lt;/code&gt; automatically, and values there win, so "make this production-ready" is: create &lt;code&gt;.env&lt;/code&gt;, put two &lt;code&gt;openssl rand&lt;/code&gt; outputs in it. That instruction lives in a comment at the top of the compose file, next to the sentence that actually gets read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Rotating either value invalidates existing sessions and makes stored provider credentials unreadable, so generate once and keep them stable.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The default value is literally the string &lt;code&gt;dev-only-insecure-...&lt;/code&gt;. If it ever shows up in a support thread or a screenshot, it identifies itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing is required to boot
&lt;/h2&gt;

&lt;p&gt;The zero-config property isn't really a packaging trick. It's downstream of one decision made inside the app: S3 credentials, the email provider, payment keys and the ecommerce backend are not environment variables. They're rows in a config table, edited in the admin UI, with env as a fallback. Every integration is therefore inert until configured, and the features that need one degrade instead of crashing.&lt;/p&gt;

&lt;p&gt;That decision has its own trade-offs, enough of them that they don't fit here. For this post the only relevant consequence is the one above: an app whose integrations are all optional at boot is an app that can boot with an empty environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The image itself
&lt;/h2&gt;

&lt;p&gt;Three stages, standard shape, one detail worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;deps&lt;/code&gt;: production dependencies only, plus &lt;code&gt;prisma generate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;builder&lt;/code&gt;: full dependencies, &lt;code&gt;prisma generate&lt;/code&gt; again, then the app build.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;runner&lt;/code&gt;: copies &lt;code&gt;node_modules&lt;/code&gt; from &lt;strong&gt;deps&lt;/strong&gt; and &lt;code&gt;dist&lt;/code&gt; from &lt;strong&gt;builder&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Copying the production module tree from a stage that never installed dev dependencies is what keeps the final image from carrying a build toolchain. It costs one extra install during build and it's worth it.&lt;/p&gt;

&lt;p&gt;Plus a healthcheck that hits a real endpoint, &lt;code&gt;USER node&lt;/code&gt;, and &lt;code&gt;HOST=0.0.0.0&lt;/code&gt; (without which the server binds to localhost inside the container and the port mapping appears to do nothing - a five-minute mystery everyone gets to solve once).&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm not claiming
&lt;/h2&gt;

&lt;p&gt;This is a single Postgres container with a named volume. It is a genuinely good way to run a small deployment on one box, and it is not a highly-available production database. If you're at the point of needing that, point &lt;code&gt;DATABASE_URL&lt;/code&gt; at your managed instance and delete the &lt;code&gt;db&lt;/code&gt; service - the compose file is designed so that's a two-line edit rather than a fork.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>devops</category>
      <category>docker</category>
      <category>software</category>
    </item>
    <item>
      <title>How I Got One Astro Codebase to Run on Node and Cloudflare Workers</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Tue, 01 Sep 2026 20:02:16 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/how-i-got-one-astro-codebase-to-run-on-node-and-cloudflare-workers-5524</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/how-i-got-one-astro-codebase-to-run-on-node-and-cloudflare-workers-5524</guid>
      <description>&lt;p&gt;I ship the same application to two runtimes: Node, for people who self-host with Docker, and Cloudflare Workers, for people who want it on the edge. Same repo, same source files, one &lt;code&gt;astro.config.mjs&lt;/code&gt; and one &lt;code&gt;astro.config.cloudflare.mjs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That worked. It cost me about six distinct debugging sessions to get there, and every one of them was a case where the second config had to say something the first one didn't. Here they are, because none of them are in a getting-started guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two configs are 90% identical, and that's the problem
&lt;/h2&gt;

&lt;p&gt;The obvious approach is one config with an &lt;code&gt;if&lt;/code&gt;. I started there and moved away, because the divergences aren't a single flag - they're a different adapter, a different externals policy, a different alias set, and a different set of build memory knobs. An &lt;code&gt;if&lt;/code&gt; ladder covering all of that is harder to read than two files.&lt;/p&gt;

&lt;p&gt;The cost is real and I want to name it: &lt;strong&gt;two files that must stay in sync in the parts where they agree.&lt;/strong&gt; The place this bites is React resolution, and it bites hard enough that the Cloudflare config carries a comment saying so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Must mirror astro.config.mjs's React handling. Without dedupe the&lt;/span&gt;
&lt;span class="c1"&gt;// production Rollup client build resolves react-dom's internal react to a&lt;/span&gt;
&lt;span class="c1"&gt;// different chunk than the islands' react, yielding two React instances -&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// "Cannot read properties of null (reading 'useEffect')" when IslandHydrator&lt;/span&gt;
&lt;span class="c1"&gt;// calls createRoot().render() on a hooked component.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you take one thing from this post: &lt;code&gt;resolve.dedupe&lt;/code&gt; for React is not an optimization. Two React copies in one bundle fail at the first hook call, with an error message that points at your component and not at your bundler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prisma's generated client doesn't resolve for workerd
&lt;/h2&gt;

&lt;p&gt;Prisma 7 generates a client that uses Node.js subpath imports (&lt;code&gt;#main-entry-point&lt;/code&gt;). Rollup, targeting workerd, can't resolve that. The fix is aliasing straight to the edge entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRISMA_CLIENT_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;require&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@prisma/client/package.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRISMA_EDGE_ENTRY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PRISMA_CLIENT_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../.prisma/client/edge.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nl"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.prisma/client/default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PRISMA_EDGE_ENTRY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what that path resolution is doing. In Prisma 7.8+ the generated &lt;code&gt;.prisma/client/&lt;/code&gt; lives inside the &lt;code&gt;@prisma/client&lt;/code&gt; package directory, and with pnpm that's &lt;code&gt;.pnpm/@prisma+client@&amp;lt;hash&amp;gt;/node_modules/.prisma/client/&lt;/code&gt;, not the project's top-level &lt;code&gt;node_modules&lt;/code&gt;. Hardcoding a path works on your machine and breaks on any machine with a different hoisting layout or store hash. Resolving relative to &lt;code&gt;@prisma/client/package.json&lt;/code&gt; is the version that survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The externals list, and one entry that is deliberately absent
&lt;/h2&gt;

&lt;p&gt;Node-only packages must not go into the workerd bundle. Mine is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NODE_ONLY_EXTERNALS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ioredis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ioredis&lt;/code&gt; is loaded via dynamic &lt;code&gt;import()&lt;/code&gt; behind an &lt;code&gt;isCloudflareRuntime()&lt;/code&gt; guard, so its chunk is never fetched on Cloudflare and externalizing it is safe.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pg&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; in that list, and the comment explaining why is the single most useful comment in the file:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;@prisma/adapter-pg&lt;/code&gt; imports &lt;code&gt;pg&lt;/code&gt; &lt;em&gt;statically&lt;/em&gt; and &lt;code&gt;PrismaPg&lt;/code&gt; runs on the CF Hyperdrive path, so &lt;code&gt;pg&lt;/code&gt; must be bundled. With &lt;code&gt;nodejs_compat&lt;/code&gt; enabled, pg's TCP connection works through Cloudflare's Node.js compatibility layer. Externalizing it produced &lt;code&gt;Uncaught Error: No such module "chunks/pg"&lt;/code&gt; at worker load time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The general rule I extracted: &lt;strong&gt;a package is externalizable only if every path that imports it is dynamic and guarded.&lt;/strong&gt; One static import anywhere in the graph and externalizing turns a build-time question into a load-time crash, which is much worse because it happens after deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The adapter overwrites your externals, so re-append them
&lt;/h2&gt;

&lt;p&gt;This one took the longest to find. &lt;code&gt;@astrojs/cloudflare&lt;/code&gt; sets &lt;code&gt;vite.ssr.noExternal = true&lt;/code&gt; and &lt;code&gt;vite.build.rollupOptions.external = ['sharp']&lt;/code&gt; inside its own &lt;code&gt;astro:build:setup&lt;/code&gt; hook. Anything you put in &lt;code&gt;ssr.external&lt;/code&gt; is simply gone by the time Rollup runs.&lt;/p&gt;

&lt;p&gt;The workaround is a post-enforced plugin that re-appends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;autonnel:cf-extra-externals&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;enforce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;rollupOptions&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;external&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rollupOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;external&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;NODE_ONLY_EXTERNALS&lt;/span&gt;&lt;span class="p"&gt;])];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existingFn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rollupOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;external&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isResolved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nx"&gt;NODE_ONLY_EXTERNALS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;existingFn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isResolved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...string / RegExp / undefined branches&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four branches, because &lt;code&gt;external&lt;/code&gt; can legitimately be an array, a string, a RegExp, a function, or absent, and I don't control what the adapter will set next release. The comment above it says "remove once @astrojs/cloudflare stops clobbering ssr.external", which is the honest status: this is a workaround against a specific version's behavior, and it's written to be deletable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build ran out of memory in CI and not locally
&lt;/h2&gt;

&lt;p&gt;Bundling every SSR entrypoint into one workerd bundle peaked above Node's 2 GB default heap. It OOM'd at about 1.99 GB on Cloudflare's build CI while succeeding on my machine, which is the least fun failure mode there is.&lt;/p&gt;

&lt;p&gt;Two lines fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;sourcemap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;reportCompressedSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sourcemaps were the biggest single consumer, and workerd doesn't consume them anyway. &lt;code&gt;reportCompressedSize&lt;/code&gt; allocates a gzipped copy of every chunk purely to print a nicer table at the end of the build. Both are pure cost in this target.&lt;/p&gt;

&lt;h2&gt;
  
  
  The worker entry does two things Node's doesn't have to
&lt;/h2&gt;

&lt;p&gt;The Node build gets a request lifecycle for free. On Workers I own it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setRuntimeEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;runWithRequestDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ssrHandler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;disposeRequestDb&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;scheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setRuntimeEnv(env)&lt;/code&gt; exists because on Workers there is no &lt;code&gt;process.env&lt;/code&gt;. Bindings arrive as an argument to the handler, which means anything that reads configuration has to go through an indirection that is populated per-invocation. If you're porting a Node app, this is the change that touches the most files, and doing it early is much cheaper than doing it after you've written a hundred &lt;code&gt;process.env.FOO&lt;/code&gt;s.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ctx.waitUntil(disposeRequestDb())&lt;/code&gt; is the other half: the connection has to be released &lt;em&gt;after&lt;/em&gt; the response is returned, not before, or you'll dispose a client that streaming code still needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I do it again
&lt;/h2&gt;

&lt;p&gt;Yes, but I'd be clearer about what the second target is for. Running on Workers is not a free "also deploy anywhere" checkbox. It's a second build with its own failure modes, and roughly all of them surface at deploy time rather than in tests.&lt;/p&gt;

&lt;p&gt;What makes it worth it is that the divergence is confined: two config files and one entry file. No &lt;code&gt;if (isWorkers)&lt;/code&gt; scattered through business logic, because everything runtime-specific is behind an adapter (cache, storage, database) that already existed for other reasons. If your app doesn't have those seams yet, build them first and add the second runtime after. Doing it in the other order is how you end up with runtime checks in a checkout service.&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>javascript</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Rebuilt ClickFunnels' One-Click Upsell. The Hard Part Wasn't the UI.</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Thu, 27 Aug 2026 20:01:37 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/i-rebuilt-clickfunnels-one-click-upsell-the-hard-part-wasnt-the-ui-2lf0</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/i-rebuilt-clickfunnels-one-click-upsell-the-hard-part-wasnt-the-ui-2lf0</guid>
      <description>&lt;p&gt;One-click upsell is the page that appears right after someone buys: "add this for $29, no need to re-enter your card." ClickFunnels built a business on it. I rebuilt it, and I want to be precise about where the work actually was, because the visible part is a button and a price.&lt;/p&gt;

&lt;p&gt;The button took an afternoon. The four failure orderings behind it took weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem one: the two providers have opposite shapes
&lt;/h2&gt;

&lt;p&gt;I support Stripe and PayPal. "Charge the customer again without asking for the card" means two genuinely different things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe.&lt;/strong&gt; Vault the payment method during the base checkout, then create a second, independent charge later. The vaulting happens at intent creation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Vault the card for later one-click upsell charges: a Customer + setup_future_usage=off_session&lt;/span&gt;
&lt;span class="c1"&gt;// makes the confirmed payment method reusable off-session.&lt;/span&gt;
&lt;span class="nx"&gt;setup_future_usage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;off_session&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with one rule that is not negotiable: &lt;strong&gt;vaulting is best-effort and must never block the base sale.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;customerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// vaulting is best-effort; never block the base sale&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If creating the Customer object fails, the buyer still buys. They just don't get offered upsells. Losing the $79 base order to protect a hypothetical $29 upsell is the wrong trade, and it's an easy one to get backwards when you're writing the upsell feature and the upsell feels like the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PayPal.&lt;/strong&gt; There is no second charge. The order sits approved-but-not-captured, and an upsell is a &lt;code&gt;PATCH&lt;/code&gt; that raises the amount, followed by exactly one capture when the buyer leaves the upsell chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// PayPal: the order is approved-but-not-captured. PATCH it to add the upsell amount + record the
// line in the snapshot (no charge). When the buyer leaves the upsell chain, capture once - the order
// then materializes with base + all accepted upsells.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So for one provider "accept upsell" means money moved. For the other it means a number in a pending authorization changed and money hasn't moved at all, including for the base order.&lt;/p&gt;

&lt;p&gt;I tried to hide this behind one &lt;code&gt;chargeUpsell()&lt;/code&gt; port. Don't. The abstraction has to sit further out, at the HTTP boundary, where both paths converge on the same response shape (&lt;code&gt;success&lt;/code&gt;, &lt;code&gt;action&lt;/code&gt;, &lt;code&gt;order&lt;/code&gt;, &lt;code&gt;nextStepUrl&lt;/code&gt;). Everything below that is two flows, and pretending otherwise produces a port whose contract is a lie for one of its implementations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem two: idempotency, twice, differently
&lt;/h2&gt;

&lt;p&gt;A buyer double-clicks. A phone loses signal mid-request and the browser retries. This is not an edge case on a page whose entire design encourages one impulsive click.&lt;/p&gt;

&lt;p&gt;Stripe gets an idempotency key scoped to the specific upsell step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:upsell:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;upsellIndex&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the local ledger row is written with the same key, with the unique-violation swallowed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;P2002&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PayPal can't use an idempotency key for this, because the operation isn't "create a charge", it's "set the total to a new value". A retried PATCH with the same intent would be indistinguishable from a legitimate second upsell at the same price. So idempotency is a state check against the snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upsellIndex&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;upsellIndex&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// append the line, recompute the total, patch the order&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same guarantee, completely different mechanism, because the underlying operation is &lt;code&gt;set&lt;/code&gt; rather than &lt;code&gt;add&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem three: getting it into the merchant's store
&lt;/h2&gt;

&lt;p&gt;This is the part nobody writes about and the part that generates support tickets.&lt;/p&gt;

&lt;p&gt;The merchant's actual fulfillment happens in Shopify or WooCommerce. So a sale with three accepted upsells has to arrive there. As what, exactly?&lt;/p&gt;

&lt;p&gt;Two legitimate answers, and I made it a setting rather than picking one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;merged&lt;/strong&gt;: base plus all accepted upsells as one external order. One shipment, one packing slip. This is what a single-warehouse merchant wants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;split&lt;/strong&gt;: the base as one order, plus one standalone order per upsell, each tagged back to the parent. This is what a merchant with per-supplier dropshipping needs, because those items ship from different places.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dispatch is one function, and the interesting lines are the two short-circuits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;executeHandoffForSale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HandoffForSaleDeps&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFulfillmentMode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;split&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMergedHandoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadSplitContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upsellLines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMergedHandoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runBaseOnlyHandoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upsellLines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushIndependentUpsell&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;upsellIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upsellIndex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;parentOrderNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;split upsell push failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;upsellIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upsellIndex&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sale with zero upsells is byte-identical in both modes, so it takes the merged path regardless of the setting. That's not an optimization, it's a correctness statement: the mode should not be able to produce two different results for the same input.&lt;/p&gt;

&lt;p&gt;And the loop is deliberately best-effort. One failing sub-order must not prevent the other sub-orders or the main order from existing. A retry job picks up the failure; a thrown exception would strand the whole sale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem four: the one that actually loses money
&lt;/h2&gt;

&lt;p&gt;Here's the ordering that took the longest to see.&lt;/p&gt;

&lt;p&gt;In merged mode the push to the merchant's store is &lt;em&gt;held&lt;/em&gt; until the buyer finishes the upsell chain, so the order can be assembled complete. Fine. But the buyer isn't obligated to follow that script. They can close the tab, come back from an email an hour later, and accept an upsell after the main order has already been pushed.&lt;/p&gt;

&lt;p&gt;Now the upsell cannot be folded into an order that already exists downstream. If you do nothing, the charge succeeds and the item is never fulfilled. The customer paid and gets nothing. That is the worst failure mode in the entire system, and it is completely silent - your payment numbers look great.&lt;/p&gt;

&lt;p&gt;The handling is a degrade, not an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// The main order was already pushed to the backend (handoff not deferred), so this late upsell&lt;/span&gt;
  &lt;span class="c1"&gt;// cannot be folded into it - push it as an independent external order tagged to the parent.&lt;/span&gt;
  &lt;span class="c1"&gt;// Covers merged late-degrade and split late-accept; prevents the upsell from being lost.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pushUpsellIndependently&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;upsellIndex&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Independent upsell push failed (cron will retry)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;upsellIndex&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A merchant configured for merged fulfillment gets a second order they didn't ask for, tagged &lt;code&gt;parent:&amp;lt;mainOrderNumber&amp;gt;&lt;/code&gt; so it's traceable. That's strictly better than the alternative, and the tag is what makes it defensible when they ask why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ordering rule I'd keep
&lt;/h2&gt;

&lt;p&gt;Charge, then persist, then push, and treat everything after the charge as retryable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;appendLineToOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Upsell charged but appending to order failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;saleRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;upsellIndex&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The buyer sees a success page even if the internal bookkeeping failed, because from their side the transaction is complete - it is. The log line is written so that the reconciliation job and the human reading it both know exactly which sale is inconsistent.&lt;/p&gt;

&lt;p&gt;The general rule: &lt;strong&gt;the irreversible step goes last among things that can fail independently, and everything after it must be retryable and must not be able to fail the response.&lt;/strong&gt; Every bug I hit in this feature was a violation of that sentence in one direction or another.&lt;/p&gt;

&lt;p&gt;The UI is a button. Everything worth writing down is in what happens after someone taps it twice on a train.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>softwareengineering</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I Built Runtime Islands for Astro Because My Pages Live in a Database</title>
      <dc:creator>knot crochet</dc:creator>
      <pubDate>Tue, 25 Aug 2026 20:01:27 +0000</pubDate>
      <link>https://dev.to/knot_crochet_dbb4379fde5d/i-built-runtime-islands-for-astro-because-my-pages-live-in-a-database-5gfi</link>
      <guid>https://dev.to/knot_crochet_dbb4379fde5d/i-built-runtime-islands-for-astro-because-my-pages-live-in-a-database-5gfi</guid>
      <description>&lt;p&gt;Astro's island model has one assumption baked into it: you know at build time which components need JavaScript. You write &lt;code&gt;client:load&lt;/code&gt; on a component in a &lt;code&gt;.astro&lt;/code&gt; file, the compiler sees it, and it emits exactly the right script tag.&lt;/p&gt;

&lt;p&gt;I don't know that at build time. The pages my app renders are drag-and-drop pages. A user arranges blocks in a visual builder, and the result is a JSON tree in Postgres. At build time, the page doesn't exist yet. At request time, I have a tree of maybe forty component instances, of which perhaps three actually need to run in the browser.&lt;/p&gt;

&lt;p&gt;So I built runtime islands: server-render the whole tree to static HTML, mark only the interactive nodes, and let a single client script figure out at runtime what to mount and what to download.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two options I rejected first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Render the whole page as one React root.&lt;/strong&gt; This is the obvious move and it's what I did first. It works, and it means a landing page with a hero image, an FAQ accordion and a footer ships the entire component library to the browser so that React can re-render markup that will never change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mark every component as interactive.&lt;/strong&gt; Same outcome with more ceremony.&lt;/p&gt;

&lt;p&gt;Both fail for the same reason: the ratio. On a typical landing page in this system, the interactive components are the countdown timer and nothing else. On a checkout page, it's the address form, the card form and the coupon field. The static-to-interactive ratio is somewhere around 10:1, and paying for the 10 to get the 1 is the whole thing island architecture exists to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server half
&lt;/h2&gt;

&lt;p&gt;Puck (the builder library) takes a config of components and a data tree, and renders it. I wrap the config before rendering: any component whose name is in the interactive set gets its &lt;code&gt;render&lt;/code&gt; replaced by one that emits a marker div plus its own props, serialized.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;islandRenderer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;componentName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;renderComponent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RenderIsland&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-island&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;componentName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-island-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;renderComponent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nf"&gt;serializedPropsScript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;wrapInteractiveComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;INTERACTIVE_COMPONENTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;islandRenderer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the page is just &lt;code&gt;renderToStaticMarkup&lt;/code&gt;. Note that it's &lt;code&gt;renderToStaticMarkup&lt;/code&gt;, not &lt;code&gt;renderToString&lt;/code&gt;: there are no hydration markers in the output, because nothing is going to hydrate in React's sense. More on that below.&lt;/p&gt;

&lt;p&gt;The props script is a JSON script tag, which means the one escaping rule you cannot skip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;u003c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any user-authored string in those props can contain &lt;code&gt;&amp;lt;/script&amp;gt;&lt;/code&gt;. If you forget this line, your builder becomes an XSS delivery mechanism the first time someone types the wrong thing into a headline field.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client half
&lt;/h2&gt;

&lt;p&gt;The entire client-side runtime is one component, mounted once per page with &lt;code&gt;client:only="react"&lt;/code&gt;. It queries the DOM, collects the distinct island types actually present, and dynamically imports only those:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hydrateIslands&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;islands&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;querySelectorAll&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-island]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;islands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;components&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadComponents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;collectIslandTypes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;islands&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;island&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;islands&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;island&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-island&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Component&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;components&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readIslandProps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;island&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;mountIsland&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;island&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;loadComponents&lt;/code&gt; walks a plain map of dynamic imports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;coreLoaders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ComponentLoader&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;ShippingAddressForm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./blocks/ShippingAddressForm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ShippingAddressForm&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;PaymentEntryForm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./blocks/PaymentEntryForm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PaymentEntryForm&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;CountdownTimer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./blocks/CountdownTimer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CountdownTimer&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bundler splits every one of those into its own chunk. A page with a countdown timer downloads the countdown chunk. It does not download the Stripe form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that bit me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. There are two lists, and they have to agree.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The server decides what to wrap (&lt;code&gt;INTERACTIVE_COMPONENTS&lt;/code&gt;). The client decides what can mount (&lt;code&gt;islandLoaders&lt;/code&gt;). They live in separate files because they're consumed by separate bundles, and nothing in the type system connects them.&lt;/p&gt;

&lt;p&gt;Add a component to the server list only, and you ship a &lt;code&gt;data-island&lt;/code&gt; div that never comes alive. It renders correctly, it looks fine, and the button does nothing. That is a genuinely nasty bug class because the failure is silent and visual inspection passes.&lt;/p&gt;

&lt;p&gt;I fixed it the only way that actually holds: a test that renders a page through the server path and asserts the island wrapper plus the serialized props survive the round trip.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;emits an island wrapper carrying default-merged scalar props&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;renderPuckToHtmlWithIslands&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pageWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CountdownTimer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-island="CountdownTimer"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractIslandProps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CountdownTimer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. It's &lt;code&gt;createRoot&lt;/code&gt;, not &lt;code&gt;hydrateRoot&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the part I'd argue about with someone, so let me state the trade honestly. React hydration wants the client's first render to match the server's markup exactly. My props don't come from a React render on the server; they come from a JSON blob that a user edited in a builder. Insisting on hydration means fighting mismatch warnings forever for a class of component that is about to re-render anyway (a countdown timer's server output is wrong by definition the moment it's serialized).&lt;/p&gt;

&lt;p&gt;So the server markup is a paint placeholder, and &lt;code&gt;createRoot&lt;/code&gt; replaces it on mount. What I get: no mismatch warnings, and one simple contract (props in a script tag) rather than two coupled ones. What I pay: the interactive subtree is rendered twice, and for anything time-dependent there's a visible correction on mount. For a countdown that's fine and arguably correct. If your interactive components are large and stable, real hydration is the better trade and you should take it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Defaults must be merged before serialization.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The builder stores only the props a user explicitly changed. Everything else lives in the component's &lt;code&gt;defaultProps&lt;/code&gt;. Server-side, Puck merges those for you during render, so the HTML looks right. But if you serialize the raw stored props, the client mounts the component with half its props &lt;code&gt;undefined&lt;/code&gt; and the island renders differently from the markup it just replaced.&lt;/p&gt;

&lt;p&gt;The fix is one call (&lt;code&gt;applyPuckDefaults&lt;/code&gt;) before rendering, and the assertion &lt;code&gt;props.hours === 2&lt;/code&gt; in that test above exists specifically to catch its removal.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;p&gt;If your pages are known at build time, use &lt;code&gt;client:load&lt;/code&gt; and stop reading. This entire mechanism exists to buy back a capability the compiler gives you for free when the page structure is static.&lt;/p&gt;

&lt;p&gt;It pays off exactly when the content tree is authored at runtime by someone who is not you: a CMS, a page builder, a template marketplace. In that case the compiler can't help, and the choice is between shipping everything and building this.&lt;/p&gt;

&lt;p&gt;The whole runtime is 246 lines across four files, and two of those files are just lists. That is the actual reason I'd recommend it over a framework: it's small enough to read in one sitting, which matters a lot for something sitting between your server output and your users' browsers.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
