From b5587e004f48b7f38fac0808f0909261ceeaad5f Mon Sep 17 00:00:00 2001 From: Sourabh Choraria Date: Fri, 31 Jul 2026 22:39:18 +0100 Subject: [PATCH] feat(dev): let apps/www run its committed Worker on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `next dev` never loads a wrangler `main`, so the two routes www's Worker adds — the cookieless page-view write to Analytics Engine and the MTA-STS policy response — did not exist locally AT ALL. Not "slower to reach": absent, with no way to exercise them short of deploying. Keeping the fast loop as the default is right for a content site, and this does not change it. But "not the default" and "impossible" are different things, and only the first is a trade-off somebody chose. `dev:worker` builds the static export and serves it through the real Worker on the same port. Measured rather than asserted, same request both ways: next dev → GET /.well-known/mta-sts.txt (Host: mta-sts.webhook.co) 404 dev:worker → the same request 200, mode: enforce The build must come first and the test pins that ordering: the Worker serves ./out through its ASSETS binding, so `wrangler dev` against a stale or absent export would serve yesterday's site and look like it had worked. It is also why this cannot be the default — every change needs a rebuild. Derived from the same table as every other dev command, and offered ONLY where the default actually skips the Worker: null for web, whose default is already the OpenNext preview, and null for wrangler apps, which run their main by definition. A second name for something the default already does reads as a capability rather than an option. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRmGUnxeYsQoG9c8BCZcae --- apps/www/package.json | 3 +- docs/local-parity.md | 19 ++++++++++++- scripts/dev-ports-guard.test.mjs | 49 ++++++++++++++++++++++++++++++-- scripts/dev-ports.mjs | 25 ++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/apps/www/package.json b/apps/www/package.json index aa14ec91..b1199052 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -20,7 +20,8 @@ "check:export": "node scripts/check-export.mjs", "deploy": "wrangler deploy", "deploy:dry": "wrangler deploy --dry-run", - "gen:brand-mail": "node scripts/gen-brand-mail-assets.mjs" + "gen:brand-mail": "node scripts/gen-brand-mail-assets.mjs", + "dev:worker": "next build && wrangler dev --port 3002 --ip 127.0.0.1 --inspector-port 4002" }, "dependencies": { "@webhook-co/shared": "workspace:*", diff --git a/docs/local-parity.md b/docs/local-parity.md index 305906cd..0768665c 100644 --- a/docs/local-parity.md +++ b/docs/local-parity.md @@ -109,7 +109,7 @@ fast loop and buy nothing. ## Marketing site: two routes its worker adds `apps/www` runs under `next dev` for a fast content loop, but its wrangler `main` is a custom worker -(`worker/index.ts`). `next dev` does not run it, so locally you do not get: +(`worker/index.ts`). `next dev` does not run it, so **by default** you do not get: - the cookieless aggregate page-view write to Analytics Engine - the MTA-STS policy response @@ -117,6 +117,23 @@ fast loop and buy nothing. Neither blocks content work, which is why www keeps the fast loop. `apps/auth` made the opposite trade — see below. +**They are now one command away rather than unreachable:** + +``` +pnpm --filter @webhook-co/www dev:worker +``` + +which builds the static export and serves it through the real Worker on the same port. Measured, same +request both ways: + +``` +next dev → GET /.well-known/mta-sts.txt (Host: mta-sts.webhook.co) 404 +dev:worker → the same request 200, mode: enforce +``` + +"Not the default" and "impossible" are different things, and only the first is a trade-off somebody chose. +The build is why this cannot be the default: the Worker serves `./out`, so every change needs a rebuild. + --- ## What matches prod by default diff --git a/scripts/dev-ports-guard.test.mjs b/scripts/dev-ports-guard.test.mjs index 1255ab93..b5045436 100644 --- a/scripts/dev-ports-guard.test.mjs +++ b/scripts/dev-ports-guard.test.mjs @@ -5,15 +5,16 @@ import { test } from "node:test"; import { SERVICE_BINDINGS } from "./wrangler-services.mjs"; import { + CRON_APPS, DEV_APPS, LOCAL_INGEST_BASE_URL, devCommand, + devFastCommand, + devWorkerCommand, duplicateAssignments, duplicatePorts, inspectorPortFor, portAssignments, - CRON_APPS, - devFastCommand, } from "./dev-ports.mjs"; import { appsMissingDevScript, @@ -307,3 +308,47 @@ test("web's committed dev scripts match the derived commands", () => { // The opt-in name is gone: leaving `dev:bindings` behind would imply bindings are still the exception. assert.equal(pkg.scripts["dev:bindings"], undefined); }); + +// --- Exercising a Worker that the fast loop skips ------------------------------------------------ +// `apps/www` runs under `next dev`, which never loads its wrangler `main`, so the two routes that Worker +// adds — the cookieless page-view write and the MTA-STS policy response — do not exist locally AT ALL. +// The fast loop is the right default for a content site, but "not the default" and "impossible" are +// different things, and only the first is a trade-off. `dev:worker` builds the static export and serves it +// through the real Worker, so the gap is one command away instead of unreachable. +test("an app whose committed Worker its default skips can still run it on demand", () => { + const cmd = devWorkerCommand("www"); + assert.ok(cmd, "www's worker routes are unreachable locally with no way to exercise them"); + assert.match( + cmd, + /next build/, + "the Worker serves ./out — without a build it has nothing to serve", + ); + assert.match( + cmd, + /wrangler dev/, + "it must be wrangler, not next dev — next dev cannot load a main", + ); + assert.match(cmd, new RegExp(`--port ${DEV_APPS.www.port}\\b`)); + assert.ok( + cmd.indexOf("next build") < cmd.indexOf("wrangler dev"), + "building after serving would serve a stale ./out", + ); +}); + +test("it is offered ONLY where the default actually skips the Worker", () => { + // web's default is the OpenNext preview, which runs its worker already; a second name for that would + // read as a capability. A wrangler app runs its main by definition. + assert.equal(devWorkerCommand("web"), null); + assert.equal(devWorkerCommand("engine"), null); + assert.equal(devWorkerCommand("auth"), null); +}); + +test("www's committed dev:worker script matches the derived command", () => { + const pkg = JSON.parse( + readFileSync(new URL("../apps/www/package.json", import.meta.url), "utf8"), + ); + assert.equal(pkg.scripts["dev:worker"], devWorkerCommand("www")); + // …and the DEFAULT stays the fast loop. Changing that is a deliberate call, not a silent drift. + assert.equal(pkg.scripts.dev, devCommand("www")); + assert.match(pkg.scripts.dev, /next dev/); +}); diff --git a/scripts/dev-ports.mjs b/scripts/dev-ports.mjs index 1723dec9..a09250a8 100644 --- a/scripts/dev-ports.mjs +++ b/scripts/dev-ports.mjs @@ -234,6 +234,31 @@ export function duplicatePorts() { * would read as a capability rather than what it is. Use it for pure UI work, where no service binding is * in the path; anything touching login, endpoints, or delivery wants the default. */ +/** + * Run a Next app's committed Worker on demand, for the one app whose default skips it. + * + * `next dev` never loads a wrangler `main`, so for `apps/www` the two routes its Worker adds — the + * cookieless page-view write to Analytics Engine and the MTA-STS policy response — do not exist locally at + * all. Keeping the fast loop as the default is the right call for a content site; leaving those routes + * UNREACHABLE is not. "Not the default" and "impossible" are different things, and only the first is a + * trade-off somebody chose. + * + * The Worker serves the static export through its ASSETS binding, so the build has to happen first — a + * `wrangler dev` against a stale or absent `./out` would serve yesterday's site and look like it worked. + * + * Returns null where the default ALREADY runs the Worker: web's default is the OpenNext preview, and a + * wrangler app runs its main by definition. A second name for the same thing reads as a capability. + */ +export function devWorkerCommand(app) { + const spec = DEV_APPS[app]; + if (!spec) throw new Error(`dev-ports: unknown app "${app}"`); + if (spec.kind !== "next" || SERVICE_BINDINGS[app]) return null; + return ( + `next build && wrangler dev --port ${spec.port} --ip 127.0.0.1 ` + + `--inspector-port ${spec.port + INSPECTOR_OFFSET}` + ); +} + export function devFastCommand(app) { const spec = DEV_APPS[app]; if (!spec) throw new Error(`dev-ports: unknown app "${app}"`);