Skip to content

.split() for lazy-loaded Cloudflare Dynamic Workers#50

Open
remorses wants to merge 5 commits into
mainfrom
lazy-split-workers
Open

.split() for lazy-loaded Cloudflare Dynamic Workers#50
remorses wants to merge 5 commits into
mainfrom
lazy-split-workers

Conversation

@remorses

Copy link
Copy Markdown
Owner

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 the LOADER binding. On Node/Bun they load via regular dynamic import().

const app = new Spiceflow()
  .get('/', () => 'home')
  .split('/admin/*', () => import('./admin'))
  .split('/billing/*', () => import('./billing'))

What's included:

  • .split() method on Spiceflow with prefix matching, middleware integration, and handler caching
  • split-dispatch-cloudflare.ts for Dynamic Worker dispatch via LOADER.get()
  • split-dispatch-default.ts for Node/Bun dynamic import fallback
  • Vite plugin transform that auto-detects .split() calls, chunks dependencies, and rewrites imports for Cloudflare
  • example-split-worker/ with admin + billing sub-apps and deployed worker tests
  • Full documentation in docs/cloudflare.md (Split Sub-Apps section)
  • fetch alias on Spiceflow class for Cloudflare Worker compatibility

Previously merged directly to main by mistake, then reverted. This PR re-introduces the feature properly.

remorses added 5 commits May 12, 2026 15:45
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
@vercel

vercel Bot commented May 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
integration-tests Ready Ready Preview, Comment May 12, 2026 1:47pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread spiceflow/src/vite.tsx
Comment on lines +1409 to +1411
const indexPath = path.join(rscOutDir, 'index.js')
try {
let indexCode = await fs.readFile(indexPath, 'utf-8')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant