.split() for lazy-loaded Cloudflare Dynamic Workers#50
Conversation
Lazy sub-apps load on demand when a request matches a prefix pattern.
On Node/Bun they load via dynamic import. On Cloudflare Workers they
run as isolated Dynamic Workers via the LOADER binding.
Usage:
app.lazy("/admin/*", () => import("./admin?split"))
The Vite plugin strips ?split in resolveId so Rollup creates a normal
dynamic import chunk. In generateBundle it:
- collects the entry + transitive deps (including dynamicImports)
- computes a content hash for deployment-safe LOADER.get() caching
- generates a fetch wrapper module as the Dynamic Worker entrypoint
- removes split-only chunks from the main worker bundle
- replaces import() calls with inline __workerManifest data
- copies worker chunks to dist/client/__workers/ for ASSETS serving
Runtime dispatch in executeRequest:
- runs after resolveRoutes returns not-found
- strict prefix boundary (/admin does not match /administrator)
- basePath-aware via BFS over the app tree
- runs inside the parent middleware chain (auth, logging, onError apply)
- merges ctx.response headers/status like regular routes
- caches the promise (not just the result) to handle concurrent cold starts
The #lazy-dispatch conditional import (package.json imports field):
- workerd: reads __workerManifest, fetches chunks from ASSETS, creates
Dynamic Worker via LOADER.get() with content-hashed id. Forwards all
parent env bindings (KV, D1, R2, etc.) except LOADER itself.
- default: calls the dynamic import directly (Node/Bun/dev)
Includes example-split-worker with admin + billing sub-apps,
shared-helpers module, and KV binding access from inside a Dynamic
Worker. Deployed and verified at:
https://spiceflow-split-worker-example.remorses.workers.dev
Session: ses_1f2a72478ffeUAasSD0BQ4FqXc
…e transform
- Rename `.lazy()` method to `.split()` across the entire codebase
- Remove the `?split` suffix from user code: users now write
`import("./admin")` instead of `import("./admin?split")`
- Replace the `resolveId` hook (which stripped `?split`) with a
`transform` hook that auto-detects `.split()` calls containing
inline `import()` via regex, resolves the specifiers, and adds
them to `splitEntries` for Cloudflare Dynamic Worker isolation
- Rename files: `lazy-dispatch-{cloudflare,default}.ts` to
`split-dispatch-{cloudflare,default}.ts`
- Rename all internal types/functions: `LazyChild` → `SplitChild`,
`LazyHandler` → `SplitHandler`, `resolveLazyHandler` →
`resolveSplitHandler`, `lazyChildren` → `splitChildren`
- Update package.json imports: `#lazy-dispatch` → `#split-dispatch`
- Remove `declare module "*?split"` from globals.d.ts and env.d.ts
- Remove `cleanFacadeId.replace(/\?split$/, "")` from generateBundle
since split entries are now tracked by resolved ID directly
- Update all 15 test cases in the `.split()` describe block
Session: ses_1e96ba0d3ffePmGkFSb8nrPPLo
…ests
**Cloudflare LOADER.get() cross-request I/O fix:**
The worker stub returned by LOADER.get() is an I/O object bound to the
request context that created it. Caching it across requests caused
"Cannot perform I/O on behalf of a different request" errors on every
split sub-app route. Fixed by calling LOADER.get() per-request while
keeping the loader factory function reusable. Cloudflare internally
caches the compiled worker code by content hash so this is not expensive.
**Vite transform improvements:**
- Expanded regex to handle backtick template literals and block-body
arrow functions: `() => { return import("./admin") }`
- Removed the `new Spiceflow` guard that prevented detection when using
aliases like `import { Spiceflow as S }` or factory functions
- Now triggers on any file containing `.split(` which is cheap enough
**Cloudflare Dynamic Worker wrapper:**
- Changed from `import app from` (default-only) to `import * as mod`
with `mod.default ?? mod.app ?? mod` fallback, matching the runtime
dispatch behavior so named exports work in Dynamic Workers too
**Tests against deployed worker:**
- 12 vitest tests hitting https://spiceflow-split-worker-example.remorses.workers.dev
- Covers root routes, admin split sub-app, billing split sub-app
- Verifies parent middleware x-app header propagates to split sub-apps
- Separate vitest.config.ts to avoid Cloudflare vite plugin conflicts
Session: ses_1e96ba0d3ffePmGkFSb8nrPPLo
Add a comprehensive section to docs/cloudflare.md covering: - Why split is useful: bundle size limits (1 MB free, 10 MB paid) and cold start latency from loading unused code - Architecture diagram: parent Worker → LOADER.get() → Dynamic Worker flow showing how each split sub-app runs in its own V8 isolate - Setup: wrangler.jsonc config (worker_loaders + assets bindings) and the .split() API with inline import() - Build-time mechanics: how the Vite plugin detects .split() calls, chunks dependencies, computes content hashes, generates wrapper modules, and rewrites imports to manifest data - Middleware: parent middleware runs before split dispatch and can short-circuit to skip sub-app cold start entirely - Cross-runtime: same .split() code works on Node.js, Bun, and Cloudflare without conditional logic - Limitations: no parent bindings (KV/D1/R2 need WorkerEntrypoint RPC wrappers via ctx.exports), no shared module state, network access inheritance Also updates dispatch comments to accurately document that bindings are not structured-clonable (confirmed by DataCloneError when attempting to pass KvNamespace via the env field). Session: ses_1e96ba0d3ffePmGkFSb8nrPPLo
…kers Extract the service bindings guide into its own docs/service-bindings.md file. Covers when to split, wrangler.jsonc services config, proxy route pattern, dedicated worker setup with dynamic import for heavy deps, and deployment order. Referenced from docs/cloudflare.md. Session: ses_1e931c8eaffe2TDtG0qe6NOJyQ
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: edb28d54fe
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for (const child of app.splitChildren) { | ||
| const routePrefix = | ||
| child.pattern.replace(/\/?\*$/, '').replace(/\/$/, '') || '/' | ||
| const fullPrefix = appPrefix ? appPrefix + routePrefix : routePrefix |
There was a problem hiding this comment.
Join root split prefixes without the extra slash
When a split is registered on a mounted sub-app with a root pattern, e.g. new Spiceflow({ basePath: '/api' }).split('/*', ...), routePrefix becomes '/' and this concatenation produces fullPrefix === '/api/'. The boundary check then rejects normal requests like /api/users because they neither equal /api/ nor start with /api//, so the lazy app is unreachable except through the trailing-slash base path. Normalize this join the same way base paths are joined so /* under /api matches /api and /api/....
Useful? React with 👍 / 👎.
| const indexPath = path.join(rscOutDir, 'index.js') | ||
| try { | ||
| let indexCode = await fs.readFile(indexPath, 'utf-8') |
There was a problem hiding this comment.
Patch manifests in the chunk that contains them
For Cloudflare builds where the .split() call is emitted into a code-split RSC asset chunk rather than rsc/index.js, this only edits rsc/index.js, so the actual inline __workerManifest still omits copied extra modules such as __vite_rsc_assets_manifest.js. In that case the Dynamic Worker receives a modules object that lacks files its worker chunks import, and the lazy worker fails at load time even though the files were copied into __workers/. Update the manifest in every built chunk that may contain the rewritten Promise.resolve({ __workerManifest: ... }), not only index.js.
Useful? React with 👍 / 👎.
Adds
.split()method to break a single app into dynamically-loaded sub-apps. On Cloudflare Workers each split sub-app runs as an isolated Dynamic Worker via theLOADERbinding. On Node/Bun they load via regular dynamicimport().What's included:
.split()method on Spiceflow with prefix matching, middleware integration, and handler cachingsplit-dispatch-cloudflare.tsfor Dynamic Worker dispatch viaLOADER.get()split-dispatch-default.tsfor Node/Bun dynamic import fallback.split()calls, chunks dependencies, and rewrites imports for Cloudflareexample-split-worker/with admin + billing sub-apps and deployed worker testsdocs/cloudflare.md(Split Sub-Apps section)fetchalias on Spiceflow class for Cloudflare Worker compatibilityPreviously merged directly to main by mistake, then reverted. This PR re-introduces the feature properly.