oj_server 0.2.45

Dev server: on-demand compile over HTTP, WebSocket HMR channel, file watcher
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
# TanStack Start adapter

This directory is oj's adapter for [TanStack Start](https://tanstack.com/start)
apps. It lets `oj dev` and `oj build` run a TanStack Start app without Vite,
by reproducing the build-time glue the TanStack Vite plugin provides and by
running enough of the app's Vite plugins to keep its conventions working.

The Rust side does the bundling: `crates/oj/src/start_bundle/` builds the
client, server and Cloudflare Worker bundles and the dev client bundle with
its hot updates on the rolldown crates oj links, and
`crates/oj/src/start_dev.rs` orchestrates dev and `oj build`. The app's Vite
plugins run in plugin hosts behind a rolldown plugin. The JavaScript files in
this directory are the framework entries oj synthesizes, the route generator
driver, and the dev and prod runtime shims, embedded into the binary with
`include_str!` and written into `<app>/.oj-cache/start/` at run time
(`write_start_assets` and the `START_ASSETS` list in
`crates/oj_server/src/lib.rs`).

## Detection

`is_tanstack_start_app(root)` returns true when the app has a `src/routes`
directory and `@tanstack/react-start` in its `package.json`. `oj dev` and
`oj build` route such apps to `start_dev` / `start_build` instead of the normal
pipeline.

## The framework seam

TanStack Start is designed to be wired into an app by a bundler. The framework
runtime imports four bare specifiers that the bundler is expected to fill in,
plus one manifest specifier:

- `#tanstack-router-entry` becomes the app's `src/router` (its `getRouter`).
- `#tanstack-start-entry` becomes a start instance (we supply an empty one).
- `#tanstack-start-plugin-adapters` becomes the plugin adapter list.
- `#tanstack-start-server-fn-resolver` becomes the server-function dispatch map.
- `tanstack-start-manifest:v` becomes the client asset manifest.

In dev these are resolved by the in-process Start module host
(`crates/oj/src/start_host.rs`, the embedded JS engine's `ModuleHost`) for
SSR and by the client bundle's aliases for the browser. In prod they are
rolldown aliases of the client and server bundles. This seam is the reason the prod
server is fully bundled: `createStartHandler` (inside `@tanstack/start-server-core`)
runs `import("#tanstack-router-entry")`, and that import only resolves if the
framework package is bundled so rolldown can apply the alias. See "Known limits".

## Where things live

The bundles (`crates/oj/src/start_bundle/`):

- `mod.rs`: what a Start bundle is configured from (`StartConfig`), the
  framework aliases, the client and server defines, conditions and resolver
  options, and `write_server_fn_resolver`, which scans `src/` for
  `createServerFn` and writes the server-fn resolver (`getServerFnById`) with
  static imports.
- `host.rs` / `hosts.rs`: the app's Vite plugins inside a bundle. A plugin
  host started in bundle mode (the TanStack plugins oj reimplements left
  out), driven from rolldown's hooks with Vite's order around its core
  resolver; a plugin's `this.resolve` ends in the bundle's own resolution
  through a reverse RPC. A build shares one host across its environments; a
  worker bundle gets its own.
- `alias.rs`: the app's `resolve.alias`, ahead of every plugin.
- `assets.rs`: Vite's asset, stylesheet and worker conventions (`?raw`, `?url`,
  `?inline`, `?worker`, bare asset imports, CSS and CSS modules) and the
  content-hash emitter the client, server and Worker builds share.
- `code.rs`: `new URL(..., import.meta.url)`, `import.meta.glob` and the
  `createServerFn` split (the transforms themselves live in
  `oj_compiler::meta_glob` and `oj_compiler::server_fns`).
- `shims.rs`: Node builtins in a browser bundle, as Vite stubs them.
- `chunks.rs`: the app's `codeSplitting` / `chunkOptimization` policy, with
  function-valued groups called back in the plugin host in batches.
- `errors.rs`: Vite's unresolved-import error and its ErrorPayload fields for
  the overlay.
- `dev.rs` / `hot.rs` / `refresh.rs`: the dev client bundle, its hot updates
  on `rolldown_dev`'s `DevEngine`, and React Fast Refresh.
- `prod.rs` / `cloudflare.rs`: `oj build`, and the Worker environment's
  bundle when the app uses `@cloudflare/vite-plugin`.

The files in this directory:

- `generate.mjs` runs `@tanstack/router-generator` to write `routeTree.gen.ts`,
  importing it through `resolve-pkg.mjs` (which reaches a transitive dep under
  a pnpm-strict layout through the app's direct deps as anchors). It runs in a
  one-shot `oj start-script` child per regeneration.
- `prerender.mjs` renders the `build.prerender` routes against the built
  server bundle.
- `cf-server.mjs` is the dev/prod shim for `@cloudflare/vite-plugin/server`,
  `cf-workers.mjs` the dev stub for `cloudflare:workers`, and
  `cf-server-worker.mjs` the Worker bundle's `getCloudflareContext()`.
- `hmr-runtime.js` is the dev client's hot-update runtime; `live-reload.js` is
  the dev client that snapshots and restores form state across a warm reload.
- `vite-globs.mjs` loads Vite's own glob libraries (picomatch, tinyglobby)
  from the app's vite, or the copies oj ships in `glob-libs.mjs`, for the
  plugin host's hook filters and glob matchers.

Dev SSR:

- The SSR runner is IN-PROCESS: an embedded JS engine (`oj_js`, deno_core with
  Node compat over the app's node_modules) whose module loading is
  `crates/oj/src/start_host.rs`. The host resolves the framework aliases, the
  app's `#`-imports and tsconfig `paths` (via the Vite-style Rust resolver),
  asset conventions, `virtual:` ids, `.mdx` and `.svg`, and serves modules
  through the dev server's SSR transform pipeline (plugin transforms + oxc
  compile with `define` / `import.meta.env` / `import.meta.glob`); CommonJS
  interop and node_modules loading are the engine's byonm semantics. The JS
  half is `crates/oj/src/assets/start-bootstrap.mjs` (the `fetch(Request)`
  protocol only). The user's Vite plugins run in the ssr-environment plugin
  host, reached over its ordinary stdio RPC.

Framework entries (synthesized, what the TanStack Vite plugin would inject):

- `server-entry.tsx` wraps `createStartHandler(defaultStreamHandler)` into a
  `{ fetch(request) }` default export.
- `client-entry.tsx` hydrates the document with `<StartClient/>`.
- `start-entry.ts`, `plugin-adapters.ts`, `manifest.ts` fill the remaining
  seam specifiers.
- `fn-stubs.mjs` is a runtime `createIsomorphicFn` that branches on
  `typeof document` (the compiler stub defaults to the server impl otherwise).

## Dev flow (`start_dev`)

1. Write the assets into `.oj-cache/start/`.
2. Generate the route tree (a one-shot child) and the server-fn resolver.
3. Bundle the browser client entry in process; the session keeps one plugin
   host for it (one buildStart, plugin state across rebundles) and, once a
   page connects, one `DevEngine` that turns each edit into a hot update.
4. Build on top of the normal oj dev server for module and asset serving.
5. Boot the in-process SSR engine (`StartEngine`) on the built app's
   `SsrBridge`, plus a loopback shim for the plugin middleware's
   `x-oj-forward-to` pipe.
6. Watch `src/`. On a change: regenerate the route tree only when the route
   file set changes, regenerate the resolver only when a server-fn file
   changes, then rebuild the client and warm-reload the engine concurrently,
   and signal the page to reload.

Document requests are engine calls into the bootstrap's `handle`, which awaits
the entry's `fetch(Request)`. The request URL is built from the Host header so
the server-function CSRF same-origin check passes. Module and asset requests fall through to the dev
pipeline. `/@oj-start/` owns the live-reload WebSocket, the client entry, and
`/@oj-start/fs/<abspath>` (asset files served from the workspace root, so
relative `url()` refs inside CSS resolve).

### The request loop

The adapter is a single axum `from_fn` middleware (`start_route`) layered over
the normal oj dev server's router. Every request passes through it; SSR is an
interception and anything the middleware does not claim falls through to the
base dev pipeline (module compilation, asset serving) via `next.run`.

The middleware handles, in order:

1. `/@oj-start/hmr`: upgrade to the live-reload WebSocket. It subscribes to a
   broadcast channel and sends `reload` on every rebuild.
2. `/@oj-start/client-entry.js` and `/@oj-start/live-reload.js`: serve the two
   cached dev scripts (`no-cache`, so a rebuild always wins).
3. `/@oj-start/fs<abspath>`: `serve_fs_asset`. The path after the prefix is the
   file's real absolute path; it is percent-decoded, canonicalized, and rejected
   with 403 if it escapes the workspace root. A Tailwind stylesheet is compiled
   by the CSS host before serving; everything else is streamed as-is with a
   `Content-Type` from its extension.
4. `/_serverFn/...`: a server-function RPC. The whole request (method, headers,
   body) reaches the engine, which dispatches it through the framework's
   `handleServerAction`; with a plugin middleware present it goes through the
   middleware first (`x-oj-forward-to` names the engine's loopback shim).
5. Otherwise `classify` decides: a GET for an extensionless path or `index.html`
   (and not a proxied prefix) is a `Document`, handed to the engine as a GET
   with `document_url` applied (`.../index.html` becomes `.../`); everything else
   is `Pass`, handed to the base dev server.

`forward` is the one path to the engine: an `engine.handle(request)` call into
the bootstrap module. Requests run concurrently on the one isolate (the
engine's call scheduler interleaves pending promises with the event loop). The
transport is buffered — headers, body bytes and set-cookie lists cross the
JSON boundary whole; the bootstrap reads a streamed `Response` to completion,
so deferred TanStack content is present — and the live-reload client is
injected after the final chunk for HTML documents.

### The watcher

A dedicated OS thread runs a `notify` recursive watcher on `src/` and bridges
its events into the tokio runtime. The thread keeps `prev_routes`, the route
file set from the last pass, so it can tell a structural change from a content
edit.

Each pass:

1. Debounce. On the first event it collects the changed paths, then keeps
   draining events on a 50ms timeout, coalescing every path into one batch. This
   collapses an editor's save-storm (or a multi-file change) into a single
   rebuild.
2. Ignore its own output. A batch that only touches the generated
   `routeTree.gen.ts` is skipped, so regenerating the tree cannot trigger
   another pass.
3. Regenerate conditionally, because both generators cost more as the app grows:
   - the route tree only when the route file SET changed (add / remove / rename),
     compared against `prev_routes`; a content edit leaves the tree alone.
   - the server-fn resolver only when a `.ts`/`.tsx` file that contains
     `createServerFn` was added, removed, or edited (or when the routes changed).
4. Rebuild and reload: an edit the client's `DevEngine` accepts reaches the
   page as a hot update; anything else re-bundles the client. The engine
   reload afterwards drops changed modules (and their transitive importers)
   from the version graph and force-bumps the entry.
5. Signal the page. A broadcast on `reload_tx` reaches every `/@oj-start/hmr`
   WebSocket, which tells the live-reload client to snapshot state and reload.

### Warm reload

Every host-served module id carries a version (`?v=<n>`), tracked with its
mtime and importers in a graph (the same scheme as the plain SSR runner's
host). A reload bumps the changed ids and everything that transitively imports
them, plus the entry, so the next request re-links exactly the invalidated
chain; React and other node_modules stay external, unversioned and warm.
Orphaned instances leak in the isolate, so after enough of them the engine is
respawned behind a write lock (a cheap snapshot boot).

## Prod flow (`start_build` and `start_bundle::prod`)

1. Generate the route tree and the server-fn resolver.
2. Client bundle: minified, hashed, code-split, the app's chunk policy, with
   the client plugin host, `import.meta.glob`, `import.meta.env`, the asset
   conventions and the node-builtin shims, entered at `client-entry`.
3. Manifest pointing at the hashed client entry and its stylesheets.
4. Server bundle: `platform: node`, node builtins external, fully bundled so
   the framework's `#`-import aliases resolve at build time, code-split
   because the framework's server module uses top-level await that a
   single-file bundle cannot always carry. With `@cloudflare/vite-plugin` the
   Worker environment's bundle replaces it.
5. Each bundle runs its plugin host's `renderChunk`, `generateBundle` and
   `writeBundle` over the real output, so plugins that publish files (for
   example content-assets emitting `/__content/<collection>/<file>`) or write
   a deploy config (the Cloudflare plugin's `wrangler.json`) work, then its
   `closeBundle`, before the next environment builds.
6. Emit `server.mjs` (Node http), `worker.mjs` (edge fetch handler),
   `cf-server.mjs` and `cf-loader.mjs`, copy `public/`, and prerender any
   `build.prerender` routes.

The bundles emit assets through one content-hash emitter, so they produce
identical `/assets/<hash>` URLs for identical bytes. CSS is emitted with its
`url()` references rewritten and its referenced fonts and images emitted
alongside. The emitter records the stylesheet URLs, which the manifest lists
under the root route's `css` so `HeadContent` links them in the SSR `<head>`
(no flash of unstyled content).

### The prod server entrypoints

The build writes two runtime entrypoints wrapping the same `server-bundle.mjs`.

`server.mjs` is a Node `http` server, for `node dist/server.mjs`. It first
registers `cf-loader.mjs`, a resolve hook that maps the framework's runtime,
variable `import("@cloudflare/vite-plugin/server")` (which escapes the
build-time alias) to the bundled `cf-server.mjs` shim. On a GET it tries the
exact static file and then the prerendered `<path>/index.html` under
`dist/client` before falling back to the handler's `fetch`; a request for
`.../index.html` is folded to `.../` so the router matches the directory route.

`worker.mjs` is the edge Web `fetch` handler for a Cloudflare Worker. It needs
neither the loader nor the shim, because a real deployment gets its bindings
from `cloudflare:workers`. (See Known limits for why a large app's worker can
exceed the size cap.)

## The plugin hosts

A Start bundle reaches the app's Vite plugins through
`start_bundle::host::StartHostPlugin`, one per bundle, over a plugin host
started for a Vite environment (`client`, `ssr`, or the Worker's). The host
resolves the app's config with the app's own Vite, minus the TanStack plugins
oj reimplements, and applies Vite's gating: `apply` against the command,
`applyToEnvironment`, object-form hooks `{ handler, filter, order }`, and
`enforce` order. Around rolldown's own resolver, resolveId runs in Vite's two
phases (the `pre` hooks first, the rest on what rolldown left), each answer
kept whole, `external` included.

The live config stays in the host, so function-valued options (a chunk
group's `name` / `test`, an `external` function) are handles the bundle calls
back, never copies.

`oj build` follows Vite's builder:

- One host serves every environment, as TanStack Start's
  `builder.sharedPlugins` asks: once the client is written the host switches
  to the server environment (`switchEnvironment`) on the same plugin
  instances, with its own plugin set (`applyToEnvironment`), buildStart,
  module graph, emitted files and closeBundle.
- `config`, `configEnvironment`, `configResolved` and `buildApp` run on
  Vite's minimal plugin context (`this.meta` with the Vite and rolldown
  versions, no `this.environment`), `configEnvironment` plugin by plugin.
- The `buildApp` hooks run in hook order, with oj's environment builds (the
  `buildEnvironments` reverse RPC) standing in for TanStack Start's config
  builder just before the first post-order hook. A throwing hook fails the
  build; an environment a plugin builds itself (Nitro's) stops it with an
  error, since oj does not run it.
- moduleParsed comes from rolldown (every module, node_modules included,
  with the resolved imports), its code lowered to JS as vite:oxc would.
- Off the client `build.emitAssets` is false by default, and at vite:asset's
  place in the sorted `generateBundle` hooks (after the `order: "pre"` hooks
  and the enforce:"pre" plugins' unordered ones) the bundle's assets and the
  files emitted so far are dropped.
- A `?worker` entry is bundled with `worker.plugins` alone, as Vite's
  `bundleWorkerEntry` does: a fresh host per worker bundle (the option is a
  function), `configResolved` with `isWorker`, the whole bundle lifecycle,
  and what its plugins emit shipped with the client. The bundle takes
  `worker.rolldownOptions` (not the app's `build.rolldownOptions`) and
  `worker.format`, iife by default: a classic script whose `import.meta.url`
  is `self.location.href`, started without `type: "module"`; "es" splits
  its chunks and starts as a module. The app's plugins never see a worker's
  modules.

### svgr and the `?react` query

svgr keys its `load` filter on the import id, so both svg import styles reach
it through the host as the id it recognizes, `logo.svg` and `logo.svg?react`.
A `.svg` no plugin componentized falls back to its asset URL.

## Resolution chain

For app-owned specifiers the dev SSR loader resolves in this order:

1. framework aliases and the CF shim,
2. `virtual:` ids, `.mdx`, and `.svg` (bare or `?react`) through the plugin
   container,
3. Vite asset conventions (`?url`, `?raw`, `?inline`, bare fonts and images),
4. package.json `imports` subpaths (`#shared`, `#modules`) with extension
   probing,
5. tsconfig `paths` (a string-aware JSONC parse that follows `extends`),
6. relative and bare resolution, with `.js` to `.ts` remapping, directory to
   `index`, and file probing,
7. for the dev loader only, CJS-to-ESM interop for node_modules whose named
   exports Node's `cjs-module-lexer` cannot detect (require the module and
   re-export its actual runtime keys; ESM files, including syntax-detected
   dual-package `.js`, are left to Node).

The bundles get the same conventions from rolldown's resolver (CJS interop,
`#`-imports, tsconfig paths, `.js`-to-`.ts`, directory resolution) plus the
plugins above.

## Server functions

`createServerFn(...).handler(fn)` compiles to different code on each side, tied
together by one id: `base64url(<app-relative path>#<name>)`, computed identically
everywhere so a browser call can find its handler.

Three transform sites share that id:

- Server (SSR). The dev module host (`start_host.rs`) and the prod server
  bundle rewrite (both through `oj_compiler::server_fns::rewrite_server`) `const NAME = createServerFn(...).handler(FN)` to pass
  `createServerRpc(meta, (opts) => NAME.__executeServer(opts))` as the first
  `.handler` argument and to export `NAME_createServerFn_handler`. The handler
  runs in process during SSR, and the export is importable by the resolver.
- Client. The dev client bundle and the prod client bundle replace that
  first argument with `createClientRpc(id)` (`rewrite_client`). The runtime treats argument one as
  the extracted function, so the browser makes an HTTP RPC to `/_serverFn/<id>`
  instead of running the handler.
- The resolver. `write_server_fn_resolver` scans `src/` for the same declarations and
  writes a manifest mapping each id to its module's exported handler, using
  static relative imports so it resolves in both the dev loader hook and the
  bundled prod server (a runtime `import` of a `.ts` would fail in the bundle).
  It exports `getServerFnById(id)` behind the `#tanstack-start-server-fn-resolver`
  alias.

An RPC hits `/_serverFn/<id>`; the framework's `handleServerAction` looks the id
up through the resolver, runs the handler, and returns the result. In dev the
request loop hands `/_serverFn/` requests whole (method, headers, body) to the
engine; in prod the server bundle handles them directly. Both build the request
origin from the Host header so the framework's same-origin CSRF check passes.

## Testing

The adapter is covered at three levels.

Rust unit tests live beside the code they exercise:

- `oj_server/src/lib.rs` (`adapter_tests`): `is_tanstack_start_app` detection,
  `locate` preferring the app root then the publicDir, and the SPA-navigation
  rule.
- `oj/src/start_dev.rs`: `document_url`, `percent_decode`, `asset_mime`,
  `needs_css_compile`, `workspace_root`, `app_uses_tailwind`, and `classify`.
- `oj/src/start_host.rs`: asset/style classification, intent-query
  splitting, noExternal matching, CJS detection and the framework alias table.
- `oj/src/start_bundle/`: each plugin's rules beside it, and `tests.rs`, which
  drives the asset, alias, shim, glob and server-fn plugins through a real
  rolldown bundle over temp files.
- `oj_compiler`: `server_fns` (the split and the base64url ids) and
  `meta_glob` (`import.meta.glob` and `new URL(..., import.meta.url)`).

JS unit tests are `node --test e2e/unit/*.test.mjs`: `resolve-pkg` covers the
pnpm-anchor resolver, `cf-vars` the Cloudflare shim's JSONC / toml / dotenv
var parsers.

The integration test is `node e2e/start.mjs`. It runs oj against a self-contained
Start app (`e2e/fixtures/start-app`) in dev and prod and asserts a server-rendered
`/` wires together routing, server functions, `import.meta.glob`, `?raw` / `?url`,
svgr (bare and `?react`), MDX, Tailwind, a plugin virtual module, tsconfig paths
and package `imports` aliases, a CommonJS dep, the Cloudflare context, and a
non-default publicDir. The fixture's dependencies are not vendored; the test
skips when they are not installed (`cd e2e/fixtures/start-app && npm install`).

## Known limits

- The prod server is fully bundled and code-split because of the framework
  `#`-import seam and its top-level await. On a large app this produces many
  chunks. Externalizing node_modules to reduce this fails at runtime, because
  the framework's runtime `import("#tanstack-router-entry")` then resolves
  package-locally against the framework package and is not defined there.
- `worker.mjs` re-exports the bundled server, which for a large app exceeds
  Cloudflare Workers' size limit. A real edge deploy needs the Cloudflare
  plugin's route-split worker build.
- Live Cloudflare KV / D1 / R2 / service bindings are not emulated; the
  `cf-server.mjs` shim provides env vars (wrangler `vars` plus `.dev.vars`) and
  an ASSETS binding backed by `dist/client`.