Support framework base paths outside Next.js#2771
Conversation
Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
🦋 Changeset detectedLatest commit: 7f3d6b9 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🧪 E2E Test Results✅ All tests passed Summary
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
|
Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Refactor to the minimal change set while keeping behavior: - builders: share createBuildOutputApiWorkflowRoutes / createBuildOutputApiRootBlockRoutes so Astro reuses them instead of duplicating route regexes; merge the two new test files into one and drop test-only exports (createBuildOutputApiRoutes, createBasePathRouteRegexPrefix no longer re-exported) - astro: make the handler guard private, reuse shared routes, only inject the base path Vite plugin when a base is configured; drop the package test script and test file (covered by builders tests) - sveltekit: revert the test-only flow-route extraction; keep the svelte.config loader + its test - nitro: only register the Vercel output patch hook, static manifest copy, and base path runtime plugin when a baseURL is configured so default deployments are untouched; consolidate the two file-fixture tests into one and revert unrelated test churn - pass undefined (not '') basePath when no base path is configured so generated route files are identical to main for default apps - workbenches: drop redundant turbo.json env overrides (root turbo.json already declares them; verified via turbo --dry), compact the E2E env boilerplate, and revert the TanStack type-fix commit (builds fine without it) Verified locally with WORKFLOW_E2E_BASE_PATH=/app builds + HTTP probes for SvelteKit, Astro, Nitro v3, Nuxt, and Vite/TanStack (prod and dev servers): base-prefixed health/manifest respond 200 with the prefixed endpoint path, root workflow routes are blocked, and default (no base path) builds are unchanged. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
…outing Every piece of the base path support is now either verified as needed via local falsification or Vercel preview deployments, or removed. Fixed (found via preview deploys — queue-triggered workflow execution was broken on Vercel for every framework with a base path; runs stayed pending forever while deliveries retried): - nitro: Vercel queue triggers invoke the flow function with the root-relative route path, which the baseURL-mounted Nitro app answers with a 302 redirect. The base path plugin now rewrites queue deliveries (identified by their CloudEvents/queue headers) to the base-prefixed path before routing. Nitro's generated flow/webhook routes point at per-route functions that Vercel's router fails to resolve — they are repointed at the catch-all server function. - sveltekit: same delivery failure (the SvelteKit server mounted below kit.paths.base 404s the root-relative trigger path). The flow function handler is wrapped to rewrite queue deliveries to the base-prefixed path; plain HTTP requests are untouched. - astro/Build Output API: workflow functions are now emitted BELOW the base path so public URLs match function paths directly, and Astro splices the routes before `handle: filesystem` (post-filesystem rewrites don't resolve; verified on previews). The root-block 404 routes are gone entirely — root-relative URLs match no function and 404/redirect naturally (verified per framework). Removed as unnecessary (verified): - Nuxt baseURL propagation (both mechanisms): Nuxt natively lowers app.baseURL into nitro.options.baseURL (verified via instrumented builds); reverted the nuxt module change and dropped the options.app fallback. - SvelteKit base-prefixed .vc-config patching: the adapter emits the flow function at the root-relative route-id path even with a base path; the extra path never exists. - Nitro static manifest copy: the manifest route resolves natively through the catch-all server function (verified 200 on preview). - createBuildOutputApiRootBlockRoutes and its tests. Kept after failing to falsify: - Astro handler guard (Astro node server serves workflow routes at both root and base without it) and dev middleware (Astro dev normalizes request.url to include the base, blinding the guard). - Vite dev root-block branch (Nitro dev serves handlers at both root and base-prefixed paths). Also gates the base path e2e health test to local deployments, matching the existing suite contract: direct HTTP access to the flow endpoint has never been guaranteed on Vercel (sveltekit on main has the same platform 404; queue-based health is the Vercel path). Verified on Vercel preview deployments with WORKFLOW_E2E_BASE_PATH=/app: full workflow runs complete on nitro-v3 (addTen=42, metadata.url ends in /app), sveltekit (run completed including webhook resume at the base-prefixed URL), and astro (run completed); root-relative workflow URLs are blocked or redirected on all of them; public manifests serve below the base path. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
- Remove the getDeploymentUrl unit tests and the local-only base path health e2e test (the behavior is covered by every existing e2e test running against a base-path deployment via getDeploymentUrl). - Document why the base path helpers live in @workflow/builders: Next.js owns its entire routing surface so @workflow/next only forwards nextConfig.basePath, while the other integrations generate the routing artifacts themselves and share this logic. - Share the queue-delivery detection between the Nitro plugin and the SvelteKit flow wrapper via QUEUE_DELIVERY_HEADERS_GUARD_CODE instead of duplicating the header contract in two generated-code strings, and use WORKFLOW_ROUTE_BASE/joinWorkflowBasePath in the SvelteKit wrapper instead of hand-rolled path strings. - Make createBasePathRouteRegexPrefix private to the Build Output API module (its only consumer). - Consolidate the builders base path tests to three focused cases and merge the two Nitro internal-routes tests into one. Re-verified the regenerated code on Vercel previews with WORKFLOW_E2E_BASE_PATH=/app: nitro-v3 full loop returns addTen=42 and sveltekit runs execute (queue deliveries rewritten by the shared guard). Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
A base path should just change where routes are mounted — routes that aren't mounted at the root shouldn't need explicit 404 code. The three remaining pieces of explicit 404 code existed because Astro (prod and dev) and Nitro dev serve routes at BOTH the root-relative and base-prefixed paths. Verified that this dual-mounting applies to the user's own routes too (e.g. /api/... responds at both paths with a base path configured), so blocking only the workflow routes made them artificially stricter than every other route in the same app. Removed: - the Astro generated-handler base path guard (generated route files are identical to main again) - the Astro dev-server middleware that 404'd root-relative workflow routes (the base path Vite plugin is banner-only now) - the root-blocking branch in the Nitro Vite dev middleware; the file is restored to main's structure, with the existing 404-empty-body workaround extended to cover the base-prefixed alias (Nitro dev serves both) Root-relative workflow URLs now behave exactly like the framework treats any other route: SvelteKit 404s them, Nitro/Nuxt production redirects to the base path, and Astro + Nitro dev serve them at both paths. Re-verified: local astro prod + vite dev probes (base works, aliases match user-route behavior, webhook 404 workaround still applies on the base-prefixed path), and an Astro preview deployment with WORKFLOW_E2E_BASE_PATH=/app where addTenWorkflow completed. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
- Changeset: drop @workflow/nuxt and workflow — neither package is modified anymore. - @workflow/builders: stop exporting getBuildOutputStaticManifestDir and getBuildOutputFunctionsPrefix from the package index — they have no consumers outside the Build Output API module and its tests. - Nitro tests: merge the two base path plugin tests into one (the generated-plugin assertions are the only automated guard for the Vercel queue-delivery fix, since CI cannot deploy-test base paths) and drop the baseURL-forwarding test (it only echoed config plumbing). - SvelteKit: rename createQueueBasePathEntryCode to createQueueDeliveryEntryCode and document the actual reason it exists: Vercel queue triggers invoke the function at its function-directory path (the root-relative route id), not the public base-prefixed URL, so the base-mounted server inside would 404 every delivery. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
…iveries The queue trigger config carries no path — Vercel invokes a triggered function at its function-directory path. So the right fix for base paths is to put the function where the server inside it expects requests: below the base path, matching the public URL. This is the same principle the Build Output API builder already uses, now applied uniformly: - sveltekit: the beforeExit hook recreates the flow function below the base path (instead of in place) — the handler wrapper that rewrote queue deliveries is deleted. - nitro: the compiled hook moves the generated flow function below the base path — the base path plugin is global-injection-only again and the queue-delivery request rewrite is deleted. - builders: QUEUE_DELIVERY_HEADERS_GUARD_CODE is deleted — nothing sniffs queue-delivery headers anymore. Verified on Vercel preview deployments with WORKFLOW_E2E_BASE_PATH=/app: nitro-v3 full loop returns addTen=42 (and base health 200 via the catch-all), sveltekit runs execute end to end. Local builds without a base path produce byte-identical output to main. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Full-file review pass: - astro: unify the Vercel route splice — routes always go before `handle: filesystem` (post-filesystem rewrites never resolved the functions; verified the no-base preview deployment serves webhook + manifest identically, and HTTP flow access on Vercel was already non-functional on main — it's queue-only, like SvelteKit). - nitro: revert the route-constant refactor churn in index.ts (paths are internal literals again, matching main) and the unrelated vi.stubEnv test rewrite; collapse the dev-middleware path check to a single substring match (both path aliases contain the workflow marker), removing the route-prefix state. - builders: inline the two single-use path helpers into the Build Output builder; drop their test block. - e2e: collapse getDeploymentUrl to a single function. nitro's Vercel output is byte-identical before/after the churn revert; astro local prod with a base path still serves base-prefixed health. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
…-path-local Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> # Conflicts: # packages/next/src/index.ts # packages/utils/src/index.ts
… nathanc/fix-framework-base-path Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> # Conflicts: # packages/astro/src/builder.ts # packages/astro/src/plugin.ts # packages/core/e2e/bench.bench.ts # packages/nitro/src/builders.ts # packages/nitro/src/index.test.ts # packages/nitro/src/index.ts # packages/nitro/src/vite.ts # packages/sveltekit/src/builder.ts # packages/sveltekit/src/plugin.ts # workbench/express/nitro.config.ts # workbench/fastify/nitro.config.ts # workbench/hono/nitro.config.ts # workbench/nitro-v3/nitro.config.ts # workbench/tanstack-start/vite.config.ts # workbench/vite/vite.config.ts
Also addresses the #2732 review nit about the base path symbol: the Symbol.for('@workflow/core/basePath') string lives in three coordinated places (canonical reader in @workflow/utils workflow-routes, the CJS copy in @workflow/next, and the generated-code emitter in @workflow/builders) — each now carries a cross-reference comment so a drift is caught in review instead of silently breaking propagation. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
📊 Workflow Benchmarkscommit Backend:
📜 Previous results (3)41d2013Fri, 10 Jul 2026 05:24:49 GMT · run logs
41d2013Thu, 09 Jul 2026 23:44:19 GMT · run logs
f5a13cdThu, 09 Jul 2026 23:22:00 GMT · run logs
Avg deltas compare against the most recent benchmark run on Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader) Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges 🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120 TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request. |
…ndings The adapter-node chunk patch skipped any chunk matching /\b(const|let|var)\s+__(file|dir)name\b/ — but $ is not a regex word character, so rollup-renamed declarations like `__filename$1` (produced when adapter-node re-bundles the intermediate server output and our banner's declaration collides) satisfied the check. Chunks that declared only a renamed binding while a bundled CJS dependency referenced the bare `__filename` were skipped, and the production server crashed at boot (observed on main with the TypeScript compiler bundled via cosmiconfig through @workflow/world-postgres). Anchor both regexes with (?![\w$]) so renamed identifiers no longer match. Verified: the sveltekit workbench production server now boots and serves health checks (with and without a base path), and queue deliveries from start() succeed. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
The plugin is only the delivery mechanism for setWorkflowBasePath into a bundle without a boot hook; a runtime env var would be a second config source that drifts from config.base, and a build-time define can't reach @workflow/utils since Astro externalizes it in SSR output. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
…ename bindings" Split into #2799 together with the underlying fix that keeps the TypeScript compiler out of the SvelteKit server bundle — that crash is a main regression independent of base path support. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
The flow function needs no route: it's an exact static path that Vercel serves via filesystem matching (and with a base path the function lives below it, so the public URL is the function path — main's implicit behavior, just relocated). Only the webhook needs a route, because [token] is a dynamic segment that arbitrary token URLs can't filesystem-match. The flow route entry was a leftover from the PR's earlier root-blocking design. Verified on an astro preview deployment with WORKFLOW_E2E_BASE_PATH=/app: webhook handler reachable at the base URL, manifest 200, root 404, and addTenWorkflow ran to completed. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Nitro v3 types baseURL on NitroOptions (with NITRO_APP_BASE_URL docs); the cast and its comment claiming otherwise were wrong. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
- Delete getNitroBasePath (alias of normalizeWorkflowBasePath over nitro.options.baseURL), joinWorkflowBasePath (normalize + concat, all call sites already normalized), createBasePathRouteRegexPrefix (one caller), and astro's prependBanner (one caller; matches sveltekit's inline style). - Nitro setup computes the base path once and passes it down; the runtime plugin registration is inlined at that point; patchNativeVercelWorkflowRoutes takes the normalized base path and fails loudly (no existsSync guard) if the flow function is missing. - SvelteKit config loader is one function: existsSync instead of try/catch, no function-config branch (svelte configs are object exports), required workingDir argument. - createBuildOutputApiWebhookRoute takes a required string | undefined. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
The test hardcoded ${process.cwd()}/workbench/express, assuming vitest
runs from the repo root — but the package test script (added in this PR;
these tests never ran in CI before) runs from packages/nitro, making the
path nonexistent. Linux accidentally passed via require.resolve ancestor
walking; Windows fell through to the bare-specifier fallback. Resolve the
workbench path relative to the test file instead.
Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
The test resolved @workflow/world-postgres from a real workbench, which requires that package's dist to exist — but the unit test job builds only each tested package's own dependency graph (build-packages: false), in arbitrary order, so the test raced world-postgres's build. Linux happened to win the race, Windows didn't. Resolve against a fixture app with the world installed instead; verified passing with world-postgres/dist deleted. Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
# Conflicts: # packages/core/e2e/bench.bench.ts
Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Signed-off-by: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com>
Description
Builds on #2732 (now merged; this PR targets
maindirectly).Adds non-Next base path support using framework-native config only (
kit.paths.base, Astrobase, NitrobaseURL, Nuxtapp.baseURL). There is noWORKFLOW_BASE_PATHenv var and no new user-facing Workflow option.Every behavior in this PR was verified as needed — via local falsification or Vercel preview deployments — or removed. Preview deploys surfaced that queue-triggered workflow execution was broken on Vercel for every framework with a base path: the queue trigger config carries no path — Vercel invokes a triggered function at its function-directory path — and the adapters emit the flow function at the root-relative route id, which the base-mounted server inside rejects (deliveries retried forever, runs stayed
pending).The fix follows one principle everywhere: the flow function lives below the base path, so its directory path (= what the queue invokes, = the public URL) matches the route the server inside actually mounts. No request rewriting, no queue-header sniffing:
src/destboth carry the base path. Root-relative URLs match no function and 404 naturally (no 404 block routes — Vercel's router re-matches rewritten paths against them, which corrupts legitimate responses).baseURL; keeps internal routes/functionRules unprefixed (Nitro appliesbaseURLwhen serving). On Vercel, thecompiledhook moves the generated flow function below the base path and repoints Nitro's generated flow/webhook HTTP routes at the catch-all server function (the per-route dests don't resolve — platform 404s). The base-path plugin only injects the runtime global.kit.paths.basefrom the svelte config; the existingbeforeExitfunction patch now recreates the flow function below the base path (the adapter emits it at the root-relative route id).config.base; splices workflow routes beforehandle: filesystem(post-filesystem rewrites don't resolve functions) and injects the runtime base path global into the SSR build.app.baseURLinto Nitro'sbaseURL(verified via instrumented builds; the previous propagation code was removed as dead).WORKFLOW_E2E_BASE_PATH=/app;getDeploymentUrl()in the e2e utils appends it toDEPLOYMENT_URL, so the entire existing e2e suite runs against the base-path deployment unchanged — no dedicated base-path tests.The shared helpers live in
@workflow/buildersbecause — unlike Next.js, which owns its whole routing surface (@workflow/nextonly forwardsnextConfig.basePath) — these integrations generate the routing artifacts themselves, and Astro, Nitro, and SvelteKit share the same normalization, Build Output API prefixing, and generated-code snippets (runtime global, queue-delivery detection).How did you test your changes?
Vercel preview deployments (
vercel deploywithWORKFLOW_E2E_BASE_PATH=/app WORKFLOW_PUBLIC_MANIFEST=1), probed through deployment protection via trusted-sources OIDC:addTen=42awaited over HTTPmetadata.urlends in/app/app/...Negative results that shaped the final code (each intermediate variant was deployed and probed): Nitro's native routes platform-404 the base-prefixed flow/webhook URLs; queue deliveries 302/404-looped on Nitro/SvelteKit while the flow function lived at the root-relative path (runs stuck
pending) and succeeded once relocated below the base path; 404 block routes stamped their status onto legitimate rewritten requests; Astro and Nitro dev dual-mount every route (root and base), so explicit root 404s made workflow routes stricter than the user's own routes and were removed.Local falsification (disable the code → observe the behavior): Nuxt propagation (works with it deleted → removed), SvelteKit base-prefixed vc-config path (never exists → removed), Astro/Nitro-dev root guards (frameworks dual-mount user routes identically → removed).
Local builds + probes with and without
WORKFLOW_E2E_BASE_PATH=/appfor sveltekit, astro, nitro-v3, nuxt (prod) and vite, astro (dev): base-prefixed health 200, root non-200, no-base builds byte-identical tomain(no base-path code injected into generated routes).Unit tests:
@workflow/builders,@workflow/nitro,@workflow/sveltekit.biome check,tsc, andchangeset statuspass.PR Checklist - Required to merge
pnpm changesetwas run to create a changelog for this PRgit commit --signoffon your commits)@vercel/workflowin a comment once the PR is ready, and the above checklist is complete