@junejs/server 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pipeline.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // Bun.* — the dev-only and worker-only concerns (fs route discovery vs frozen
10
10
  // manifest) are injected as a RouteResolver, not branched on here.
11
11
 
12
- import React from "react";
12
+ import React, { Suspense, use } from "react";
13
13
  // renderToReadableStream (NOT renderToStaticMarkup): it is the ONE render
14
14
  // function present in every react-dom/server build — node, browser, AND edge.
15
15
  // workerd resolves react-dom/server to server.edge (server.browser needs
@@ -18,6 +18,7 @@ import React from "react";
18
18
  import { renderToReadableStream } from "react-dom/server";
19
19
 
20
20
  import {
21
+ LoaderDataContext,
21
22
  type BrandedRoute,
22
23
  type Metadata,
23
24
  type RenderTarget,
@@ -33,12 +34,15 @@ import {
33
34
  sitemapXml,
34
35
  } from "@junejs/core/discovery";
35
36
  import { mcpHandler, mcpTools } from "@junejs/core/mcp";
37
+
38
+ import { ensureScope, runInScope } from "@junejs/db";
36
39
  import type { AgentConfig } from "@junejs/core/config";
37
40
  import type { Resources } from "@junejs/core/resources";
38
41
 
39
42
  import { negotiate } from "./negotiate";
40
43
 
41
44
  export type LayoutComponent = React.ComponentType<{ children: React.ReactNode }>;
45
+ export type LoadingComponent = React.ComponentType;
42
46
 
43
47
  // What a resolver returns for a matched pathname: the route definition, its
44
48
  // params, and the layout chain (root→leaf) that wraps it.
@@ -46,6 +50,10 @@ export type Resolved = {
46
50
  def: BrandedRoute;
47
51
  params: Record<string, string>;
48
52
  chain: LayoutComponent[];
53
+ // The nearest loading.tsx up the segment chain. Its presence opts a route
54
+ // into streaming Suspense: the shell + this fallback flush before load()
55
+ // resolves, then the view streams in.
56
+ loading?: React.ComponentType;
49
57
  };
50
58
 
51
59
  // The one thing dev and worker do differently: turn a clean pathname into a
@@ -93,6 +101,48 @@ function text(body: string, contentType: string, init?: ResponseInit) {
93
101
  return new Response(body, { ...init, headers });
94
102
  }
95
103
 
104
+ // Make loader data available to the view's descendants via useLoaderData(). The
105
+ // view itself receives data as PROPS (canonical); this provider is the escape
106
+ // hatch for deep children and the Remix-style `const data = useLoaderData()`.
107
+ function provideLoaderData(data: unknown, node: React.ReactNode): React.ReactNode {
108
+ return React.createElement(LoaderDataContext.Provider, { value: data }, node);
109
+ }
110
+
111
+ // The suspending leaf of a streaming route: use() the load promise, then render
112
+ // the view. While the promise is pending the component suspends, so React emits
113
+ // the surrounding Suspense fallback (loading.tsx) in the shell.
114
+ function StreamedView({
115
+ loadPromise,
116
+ def,
117
+ ctx,
118
+ }: {
119
+ loadPromise: Promise<unknown>;
120
+ def: BrandedRoute;
121
+ ctx: RouteContext;
122
+ }): React.ReactNode {
123
+ const data = use(loadPromise);
124
+ return provideLoaderData(data, def.view ? def.view(data, ctx) : null);
125
+ }
126
+
127
+ // renderToReadableStream does not emit the doctype; prepend it without buffering
128
+ // the React stream (streaming stays incremental).
129
+ function withDoctype(source: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
130
+ const reader = source.getReader();
131
+ return new ReadableStream<Uint8Array>({
132
+ start(controller) {
133
+ controller.enqueue(new TextEncoder().encode("<!doctype html>\n"));
134
+ },
135
+ async pull(controller) {
136
+ const { done, value } = await reader.read();
137
+ if (done) controller.close();
138
+ else controller.enqueue(value);
139
+ },
140
+ cancel(reason) {
141
+ void reader.cancel(reason);
142
+ },
143
+ });
144
+ }
145
+
96
146
  // The default favicon: the site name's first character in a rounded square —
97
147
  // a plain SVG string, so it needs no fonts, no rasterizer, and works for CJK
98
148
  // names as readily as latin ones. Served at /favicon.svg AND /favicon.ico
@@ -133,19 +183,52 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
133
183
  (acc, L) => React.createElement(L, null, acc),
134
184
  node,
135
185
  );
136
- // WebMCP: register the app's actions as browser tools. Computed per render
137
- // from the live registry (stable after warmup), gated on agent.webmcp + mcp
138
- // (execute proxies to /mcp). No actions → no script → page stays zero-JS.
139
- const webmcpTools = agent.webmcp && agent.mcp ? mcpTools() : null;
140
- const config: DocumentConfig = webmcpTools?.length ? { ...docConfig, webmcpTools } : docConfig;
141
186
  const stream = await renderToReadableStream(
142
- React.createElement(Document, { config, metadata, children: wrapped }),
187
+ React.createElement(Document, { config: docConfigForRender(), metadata, children: wrapped }),
143
188
  );
144
189
  await stream.allReady; // fully resolved markup (no streamed Suspense fallbacks)
145
190
  const html = "<!doctype html>\n" + (await new Response(stream).text());
146
191
  return new Response(html, { status, headers: htmlHeaders() });
147
192
  }
148
193
 
194
+ // WebMCP: register the app's actions as browser tools. Computed per render
195
+ // from the live registry (stable after warmup), gated on agent.webmcp + mcp
196
+ // (execute proxies to /mcp). No actions → no script → page stays zero-JS.
197
+ function docConfigForRender(): DocumentConfig {
198
+ const webmcpTools = agent.webmcp && agent.mcp ? mcpTools() : null;
199
+ return webmcpTools?.length ? { ...docConfig, webmcpTools } : docConfig;
200
+ }
201
+
202
+ // Streaming Suspense: the shell (layout chain + the loading.tsx fallback)
203
+ // flushes immediately; <StreamedView> use()s the load promise, so React
204
+ // streams the resolved view in once load() settles. Gated by the caller on
205
+ // a present loading.tsx AND static metadata (a data-derived <title> can't
206
+ // stream — the <head> is outside the boundary).
207
+ async function renderStreamingDocument(
208
+ resolved: Resolved,
209
+ loadPromise: Promise<unknown>,
210
+ ctx: RouteContext,
211
+ ): Promise<Response> {
212
+ const { def, chain, loading: Loading } = resolved;
213
+ const leaf = React.createElement(StreamedView, { loadPromise, def, ctx });
214
+ const boundary = React.createElement(
215
+ Suspense,
216
+ { fallback: Loading ? React.createElement(Loading) : null },
217
+ leaf,
218
+ );
219
+ const wrapped = chain.reduceRight<React.ReactNode>(
220
+ (acc, L) => React.createElement(L, null, acc),
221
+ boundary,
222
+ );
223
+ const metadata = typeof def.metadata === "object" ? def.metadata : undefined;
224
+ const stream = await renderToReadableStream(
225
+ React.createElement(Document, { config: docConfigForRender(), metadata, children: wrapped }),
226
+ { onError: (e: unknown) => console.error("[june] streaming render error:", e) },
227
+ );
228
+ // NO allReady — return the live stream (shell first), doctype prepended.
229
+ return new Response(withDoctype(stream), { status: 200, headers: htmlHeaders() });
230
+ }
231
+
149
232
  function notFoundResponse(target: RenderTarget, pathname: string): Promise<Response> | Response {
150
233
  // Data clients get a JSON 404; humans get the rendered NotFound document.
151
234
  return target !== "view"
@@ -195,7 +278,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
195
278
  const payload = typeof def.json === "function" ? await def.json(data, ctx) : data;
196
279
  return Response.json(payload);
197
280
  }
198
- const node = def.view ? def.view(data, ctx) : null;
281
+ const node = provideLoaderData(data, def.view ? def.view(data, ctx) : null);
199
282
  return renderDocument(node, resolveMeta(def, data, ctx), 200, chain);
200
283
  }
201
284
 
@@ -219,17 +302,16 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
219
302
  }
220
303
  }
221
304
 
222
- return {
223
- async fetch(request: Request): Promise<Response> {
305
+ async function handleRequest(request: Request): Promise<Response> {
224
306
  const url = new URL(request.url);
225
307
 
226
308
  // --- agent surface ---------------------------------------------------
227
309
  if (url.pathname === "/mcp") {
228
310
  if (!agent.mcp) return notFoundResponse("view", url.pathname);
229
- // The agent's tool calls run through the same resources (and, once auth
230
- // is wired, the same principal) the UI uses.
231
- const res = cfg.resources ? await cfg.resources() : undefined;
232
- return mcpHandler(request, { request, db: res?.db, kv: res?.kv, blob: res?.blob });
311
+ // The agent's tool calls run inside the same request scope, so an action's
312
+ // ambient `db` is the SAME resource the UI uses (and, once auth is wired,
313
+ // the same principal via ctx). ctx carries identity only — not resources.
314
+ return mcpHandler(request, { request });
233
315
  }
234
316
  if (request.method === "GET" && agent.discovery) {
235
317
  const d = await discovery(url);
@@ -258,17 +340,25 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
258
340
  const resolved = await cfg.resolve(pathname);
259
341
  if (!resolved) return notFoundResponse(target, pathname);
260
342
 
261
- const res = cfg.resources ? await cfg.resources() : undefined;
343
+ // ctx is identity/request only; db/kv/blob are ambient (read from the
344
+ // request scope this whole handler runs inside — see runInScope below).
262
345
  const ctx: RouteContext = {
263
346
  request,
264
347
  url,
265
348
  params: resolved.params,
266
349
  target,
267
350
  speculative,
268
- db: res?.db,
269
- kv: res?.kv,
270
- blob: res?.blob,
271
351
  };
352
+ // Streaming Suspense: a view request on a route with loading.tsx AND
353
+ // static metadata flushes the shell + fallback before load() resolves.
354
+ // (data-derived metadata can't stream — the <head> needs the title.)
355
+ if (target === "view" && resolved.loading && typeof resolved.def.metadata !== "function") {
356
+ const loadPromise = Promise.resolve(
357
+ resolved.def.load ? resolved.def.load(ctx) : undefined,
358
+ );
359
+ return renderStreamingDocument(resolved, loadPromise, ctx);
360
+ }
361
+
272
362
  let data: unknown;
273
363
  try {
274
364
  data = resolved.def.load ? await resolved.def.load(ctx) : undefined;
@@ -277,6 +367,17 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
277
367
  return notFoundResponse(target, pathname);
278
368
  }
279
369
  return renderProjection(resolved, target, data, ctx);
370
+ }
371
+
372
+ return {
373
+ async fetch(request: Request): Promise<Response> {
374
+ // Open the request's resources (memoized; env-bound on workerd) and run the
375
+ // ENTIRE request inside the scope, so ambient db/kv/blob resolve to them in
376
+ // loaders, views, and /mcp actions alike. ensureScope() lazily wires the
377
+ // async-context provider on first request (no static node:* import).
378
+ await ensureScope();
379
+ const resources = cfg.resources ? await cfg.resources() : {};
380
+ return runInScope({ resources }, () => handleRequest(request));
280
381
  },
281
382
  };
282
383
  }
package/src/resources.ts CHANGED
@@ -5,8 +5,13 @@
5
5
 
6
6
  import type { ResourceConfig, Resources } from "@junejs/core/resources";
7
7
 
8
+ // Import the EDGE-safe d1 adapter directly (not via ./db, which pulls host/
9
+ // node:* — that would defeat keeping the worker graph host-free).
10
+ import { d1, type D1Database } from "./d1";
11
+
8
12
  // Open every declared resource once. Returns a memoized provider so the same
9
13
  // long-lived handles are reused across requests (one SQLite connection, etc.).
14
+ // Env-unaware — the dev/Node path, where resources are local processes.
10
15
  export function memoizeResources(
11
16
  config?: ResourceConfig,
12
17
  ): () => Promise<Resources> {
@@ -26,3 +31,45 @@ export function memoizeResources(
26
31
  return opened;
27
32
  };
28
33
  }
34
+
35
+ // The deployed worker's env: the platform bindings the adapter declared in
36
+ // wrangler. `DB` is the D1 binding name the workers() adapter emits for a
37
+ // declared `db` resource (see adapter.ts ResourcePlan).
38
+ export type WorkerEnv = { DB?: D1Database } & Record<string, unknown>;
39
+
40
+ // Which resources the app declared — a pure descriptor the build bakes into the
41
+ // generated worker entry. The worker resolves them from `env` bindings, NOT from
42
+ // the user's config: importing the config would drag the host-only sqlite()/dev
43
+ // server (node:child_process/http) into the workerd bundle. Local dev opens the
44
+ // real factories instead, through memoizeResources() (see app.ts).
45
+ export type ResourceFlags = { db?: boolean; kv?: boolean; blob?: boolean };
46
+
47
+ // The PROD/worker provider. It binds declared resources to their EDGE handles
48
+ // from env (env.DB → D1) — the prod half of "sqlite dev → D1 prod, one
49
+ // declaration": the same `resources: { db: sqlite() }` runs on D1 at the edge,
50
+ // because D1 *is* SQLite (same SQL, same Juno tables). No env binding → the
51
+ // handle is absent and the route degrades (ctx.db undefined), never a crash.
52
+ // Memoized per isolate (env is stable across requests in a workerd isolate).
53
+ //
54
+ // env is typed `unknown` (it arrives untyped from the runtime) and narrowed to
55
+ // WorkerEnv — keeping the provider assignable to WorkerManifest.resources.
56
+ export function bindWorkerResources(
57
+ flags: ResourceFlags,
58
+ ): (env?: unknown) => Promise<Resources> {
59
+ if (!flags.db && !flags.kv && !flags.blob) {
60
+ const empty: Resources = {};
61
+ return () => Promise.resolve(empty);
62
+ }
63
+ let opened: Promise<Resources> | null = null;
64
+ return (rawEnv) => {
65
+ const env = rawEnv as WorkerEnv | undefined;
66
+ if (!opened) {
67
+ opened = (async () => ({
68
+ db: flags.db && env?.DB ? await d1(env.DB).open() : undefined,
69
+ // kv/blob edge bindings (KV/R2) land with their adapters; a declared-but-
70
+ // unbound resource degrades to undefined, the same as db.
71
+ }))();
72
+ }
73
+ return opened;
74
+ };
75
+ }
@@ -0,0 +1,160 @@
1
+ // Local SQLite driver selection — the ONE place that judges which built-in
2
+ // sqlite the current runtime provides, decoupled from the host's server/spawn
3
+ // concerns (host.ts). Both are zero-install: `bun:sqlite` under Bun, `node:sqlite`
4
+ // under Node. This is dev/host-only (the deployed worker binds D1 from env via
5
+ // src/d1.ts and never touches this module).
6
+ //
7
+ // Why a separate layer: the bun-vs-node *driver* choice and the bun-vs-node
8
+ // *host* choice happen to share a signal (`typeof Bun`), but they answer
9
+ // different questions — and the Node path has a real version cliff that deserves
10
+ // one clear error, not a raw ERR_UNKNOWN_BUILTIN_MODULE leaking from a dynamic
11
+ // import. Keeping the judgment here means host.ts just delegates.
12
+
13
+ import type { JuneDb } from "@junejs/core/resources";
14
+
15
+ // The shape bun:sqlite exposes directly and node:sqlite is adapted to: a
16
+ // prepared statement with positional binding.
17
+ type SyncStatement = {
18
+ all(...params: unknown[]): unknown[];
19
+ get(...params: unknown[]): unknown;
20
+ run(...params: unknown[]): { changes: number | bigint; lastInsertRowid?: number | bigint };
21
+ };
22
+ type SyncSqlite = {
23
+ query(sql: string): SyncStatement;
24
+ exec(sql: string): void;
25
+ close(): void;
26
+ };
27
+
28
+ // node:sqlite landed in v22.5.0 behind --experimental-sqlite, and lost the flag
29
+ // in v22.13.0 (the 22 LTS line) and v23.4.0. We need the flag-free builtin, so
30
+ // 22.13.0 is the practical floor on LTS; 23.4.0 on the odd line.
31
+ export const NODE_SQLITE_MIN_LTS = "22.13.0";
32
+ export const NODE_SQLITE_MIN_ODD = "23.4.0";
33
+
34
+ // Wrap a synchronous SQLite handle as the async JuneDb. The driver work is
35
+ // synchronous, but the SURFACE is async, so swapping in D1 later is invisible to
36
+ // every caller. (Moved here from host.ts with the driver it serves.)
37
+ function asyncSqlite(db: SyncSqlite): JuneDb {
38
+ const self: JuneDb = {
39
+ async query<T>(sql: string, params: unknown[] = []) {
40
+ return db.query(sql).all(...params) as T[];
41
+ },
42
+ async get<T>(sql: string, params: unknown[] = []) {
43
+ // Normalize "no row" to undefined — bun:sqlite returns null, node:sqlite
44
+ // returns undefined; the seam hides the difference.
45
+ return (db.query(sql).get(...params) ?? undefined) as T | undefined;
46
+ },
47
+ async run(sql: string, params: unknown[] = []) {
48
+ const r = db.query(sql).run(...params);
49
+ return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid ?? 0 };
50
+ },
51
+ async exec(sql: string) {
52
+ db.exec(sql);
53
+ },
54
+ async transaction<T>(fn: (tx: JuneDb) => Promise<T>) {
55
+ db.exec("BEGIN");
56
+ try {
57
+ const out = await fn(self); // same connection — sqlite is single-writer
58
+ db.exec("COMMIT");
59
+ return out;
60
+ } catch (e) {
61
+ db.exec("ROLLBACK");
62
+ throw e;
63
+ }
64
+ },
65
+ async close() {
66
+ db.close();
67
+ },
68
+ };
69
+ return self;
70
+ }
71
+
72
+ // The actionable message when Node can't provide node:sqlite — the bad-first-run
73
+ // path this layer exists to fix. Pure + exported so it is unit-testable without
74
+ // an old Node. `nodeVersion` is process.versions.node (e.g. "20.11.0").
75
+ export function nodeSqliteHelp(nodeVersion: string): string {
76
+ return [
77
+ `June's local database needs Node's built-in sqlite (node:sqlite), which this`,
78
+ `runtime can't load. You're on Node v${nodeVersion}.`,
79
+ ``,
80
+ `node:sqlite is available without a flag in Node >= ${NODE_SQLITE_MIN_LTS} (LTS) or`,
81
+ `>= ${NODE_SQLITE_MIN_ODD}. Node 22.5–22.12 / 23.0–23.3 have it behind`,
82
+ `--experimental-sqlite, and older Node doesn't ship it at all.`,
83
+ ``,
84
+ `Fix it one of two ways:`,
85
+ ` • Upgrade Node to ${NODE_SQLITE_MIN_LTS}+ (recommended), or`,
86
+ ` • Run June with Bun — bun:sqlite is built in, no flag, no version floor.`,
87
+ ].join("\n");
88
+ }
89
+
90
+ // --- silencing node:sqlite's one-time ExperimentalWarning -------------------
91
+ // Node prints `ExperimentalWarning: SQLite is an experimental feature` the first
92
+ // time node:sqlite loads. We guard the version, so we KNOW it works here — the
93
+ // warning is just first-run noise. Silence ONLY that one line; every other
94
+ // warning (deprecations, other experimentals) must still pass through.
95
+
96
+ // True for exactly Node's node:sqlite ExperimentalWarning. Pure + exported so
97
+ // the filter is unit-testable without triggering a real warning.
98
+ export function isNodeSqliteExperimentalWarning(type: unknown, message: string): boolean {
99
+ return type === "ExperimentalWarning" && /sqlite/i.test(message);
100
+ }
101
+
102
+ type EmitWarning = (warning: string | Error, ...rest: unknown[]) => void;
103
+
104
+ // Wrap an emitWarning impl so the node:sqlite experimental warning is dropped
105
+ // and everything else is forwarded verbatim.
106
+ export function makeWarningFilter(original: EmitWarning): EmitWarning {
107
+ return (warning, ...rest) => {
108
+ const message = typeof warning === "string" ? warning : (warning?.message ?? "");
109
+ // emitWarning(warning, type?) or emitWarning(warning, { type }) — read both.
110
+ const first = rest[0];
111
+ const type = typeof first === "string" ? first : (first as { type?: string } | undefined)?.type;
112
+ if (isNodeSqliteExperimentalWarning(type, message)) return;
113
+ original(warning, ...rest);
114
+ };
115
+ }
116
+
117
+ // bun-types declares the global `Bun`; on Node the binding doesn't exist at
118
+ // runtime, which is exactly what the typeof guard checks.
119
+ declare const Bun: unknown;
120
+
121
+ // Open a LOCAL sqlite file (or ":memory:") on whichever runtime we're under.
122
+ // The single seam host.openDb() delegates to.
123
+ export async function openLocalSqlite(path: string): Promise<JuneDb> {
124
+ if (typeof Bun !== "undefined") {
125
+ // Non-literal specifier so a bundler can't FOLLOW bun:sqlite into a Node
126
+ // build (it only exists under Bun, and is only reached under Bun).
127
+ const specifier = "bun:sqlite";
128
+ const { Database } = (await import(specifier)) as {
129
+ Database: new (p: string, o?: { create?: boolean }) => SyncSqlite;
130
+ };
131
+ return asyncSqlite(new Database(path, { create: true }));
132
+ }
133
+
134
+ // Node: node:sqlite is a builtin, but only flag-free on a recent-enough
135
+ // version. A failed import here is the version cliff — translate it to guidance
136
+ // instead of letting ERR_UNKNOWN_BUILTIN_MODULE / "needs --experimental-sqlite"
137
+ // surface raw.
138
+ const specifier = "node:sqlite";
139
+ let mod: { DatabaseSync: new (p: string) => { prepare(sql: string): SyncStatement; exec(sql: string): void; close(): void } };
140
+ // Drop the SQLite ExperimentalWarning that fires during this import, restoring
141
+ // the original handler immediately after so no other warning is affected.
142
+ const originalEmitWarning = process.emitWarning;
143
+ process.emitWarning = makeWarningFilter(
144
+ originalEmitWarning.bind(process) as EmitWarning,
145
+ ) as typeof process.emitWarning;
146
+ try {
147
+ mod = (await import(specifier)) as typeof mod;
148
+ } catch (cause) {
149
+ throw new Error(nodeSqliteHelp(process.versions.node), { cause });
150
+ } finally {
151
+ process.emitWarning = originalEmitWarning;
152
+ }
153
+ const db = new mod.DatabaseSync(path);
154
+ // Adapt node:sqlite (prepare()) to the query()-shaped SyncSqlite surface.
155
+ return asyncSqlite({
156
+ query: (sql) => db.prepare(sql),
157
+ exec: (sql) => db.exec(sql),
158
+ close: () => db.close(),
159
+ });
160
+ }
package/src/worker.ts CHANGED
@@ -16,7 +16,7 @@ import type { AgentConfig } from "@junejs/core/config";
16
16
  import type { DocumentConfig } from "@junejs/core/document";
17
17
  import type { Resources } from "@junejs/core/resources";
18
18
 
19
- import { createPipeline, type ExtraHandler, type LayoutComponent, type Resolved } from "./pipeline";
19
+ import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved } from "./pipeline";
20
20
 
21
21
  export type WorkerManifest = {
22
22
  // Static paths → route definitions ("/", "/users", ...).
@@ -26,6 +26,8 @@ export type WorkerManifest = {
26
26
  // Layout chains (root→leaf) keyed by route path / dynamic pattern. The build
27
27
  // freezes the same chain the dev server loads from app/layout.* files.
28
28
  layoutChains?: Record<string, LayoutComponent[]>;
29
+ // Nearest loading.tsx per route path → streaming Suspense fallback.
30
+ loadings?: Record<string, LoadingComponent>;
29
31
  document: DocumentConfig;
30
32
  agent: AgentConfig;
31
33
  // Preload Link values (config earlyHints + auto font hints), frozen at build.
@@ -35,8 +37,9 @@ export type WorkerManifest = {
35
37
  // The app/_extra.* pre-route handler, imported by the generated entry.
36
38
  extra?: ExtraHandler;
37
39
  // Opened data resources (db/kv/blob) injected onto ctx. On workerd the D1/KV/R2
38
- // bindings come from env per request, so the generated entry passes a provider.
39
- resources?: () => Promise<Resources> | Resources;
40
+ // bindings come from env, so the generated entry passes an ENV-AWARE provider
41
+ // (bindWorkerResources) — the worker threads its env through on each fetch.
42
+ resources?: (env?: unknown) => Promise<Resources> | Resources;
40
43
  };
41
44
 
42
45
  type Compiled = { regex: RegExp; names: string[]; def: BrandedRoute; pattern: string };
@@ -82,7 +85,9 @@ function compilePattern(pattern: string): { regex: RegExp; names: string[] } {
82
85
  return { regex: new RegExp(allOptional ? `^(?:${source}|/)$` : `^${source}$`), names };
83
86
  }
84
87
 
85
- export function createWorker(manifest: WorkerManifest): { fetch(request: Request): Promise<Response> } {
88
+ export function createWorker(
89
+ manifest: WorkerManifest,
90
+ ): { fetch(request: Request, env?: unknown): Promise<Response> } {
86
91
  const dynamic: Compiled[] = (manifest.dynamicRoutes ?? []).map((d) => ({
87
92
  ...compilePattern(d.pattern),
88
93
  def: d.def,
@@ -94,8 +99,15 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
94
99
  ].sort();
95
100
 
96
101
  const chainFor = (key: string): LayoutComponent[] => manifest.layoutChains?.[key] ?? [];
102
+ const loadingFor = (key: string): LoadingComponent | undefined => manifest.loadings?.[key];
97
103
 
98
- return createPipeline({
104
+ // The worker's env (D1/KV/R2 bindings) arrives per fetch and is stable across
105
+ // requests in an isolate; we capture the latest and hand it to the env-aware
106
+ // provider, which memoizes the opened handles on first call.
107
+ let currentEnv: unknown;
108
+ const provider = manifest.resources;
109
+
110
+ const pipeline = createPipeline({
99
111
  docConfig: manifest.document,
100
112
  agent: manifest.agent,
101
113
  routeList: () => routeList,
@@ -103,10 +115,11 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
103
115
  htmlCacheControl: manifest.htmlCacheControl,
104
116
  notFoundComponent: manifest.notFound,
105
117
  extra: manifest.extra,
106
- resources: manifest.resources,
118
+ resources: provider ? () => provider(currentEnv) : undefined,
107
119
  resolve: async (pathname): Promise<Resolved | null> => {
108
120
  const staticDef = manifest.routes[pathname];
109
- if (staticDef) return { def: staticDef, params: {}, chain: chainFor(pathname) };
121
+ if (staticDef)
122
+ return { def: staticDef, params: {}, chain: chainFor(pathname), loading: loadingFor(pathname) };
110
123
  for (const d of dynamic) {
111
124
  const m = pathname.match(d.regex);
112
125
  if (m) {
@@ -118,12 +131,19 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
118
131
  return v === undefined ? [] : [[n, decodeURIComponent(v)]];
119
132
  }),
120
133
  );
121
- return { def: d.def, params, chain: chainFor(d.pattern) };
134
+ return { def: d.def, params, chain: chainFor(d.pattern), loading: loadingFor(d.pattern) };
122
135
  }
123
136
  }
124
137
  return null;
125
138
  },
126
139
  });
140
+
141
+ return {
142
+ fetch(request, env) {
143
+ currentEnv = env;
144
+ return pipeline.fetch(request);
145
+ },
146
+ };
127
147
  }
128
148
 
129
149
  // --- the deployed worker's outermost layer (run_worker_first) ---------------
@@ -136,7 +156,7 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
136
156
  // dynamic routes reach the pipeline. With no ASSETS binding (no prerender), this
137
157
  // is a transparent pass-through to the pipeline.
138
158
  type AssetEnv = { ASSETS?: { fetch(request: Request): Promise<Response> } };
139
- type FetchPipeline = { fetch(request: Request): Promise<Response> };
159
+ type FetchPipeline = { fetch(request: Request, env?: unknown): Promise<Response> };
140
160
 
141
161
  const estimateTokens = (s: string) => String(Math.ceil(s.length / 4));
142
162
 
@@ -168,14 +188,21 @@ export function withAssets(
168
188
  // no prerendered .md → fall through; a dynamic route renders it below.
169
189
  }
170
190
 
171
- // 2. Static assets (prerendered HTML/.md/.json, /client.js) served direct.
191
+ // 2. Static assets (prerendered HTML/.md/.json, /client.js, hashed CSS)
192
+ // served direct.
172
193
  if (assets) {
173
194
  const a = await assets.fetch(request);
174
195
  if (a.status !== 404) {
175
196
  const ct = a.headers.get("content-type") ?? "";
176
- if (opts.link && ct.includes("text/html") && !a.headers.has("link")) {
197
+ const addLink = !!opts.link && ct.includes("text/html") && !a.headers.has("link");
198
+ // Content-hashed assets (e.g. /global.<hash>.css) are immutable — the
199
+ // URL changes when the bytes do, so the browser may cache forever and
200
+ // never revalidate (no 304 round-trip, no stale window).
201
+ const immutable = /\.[a-f0-9]{8,}\.(css|js)$/.test(url.pathname);
202
+ if (addLink || immutable) {
177
203
  const headers = new Headers(a.headers);
178
- headers.set("link", opts.link);
204
+ if (addLink) headers.set("link", opts.link!);
205
+ if (immutable) headers.set("cache-control", "public, max-age=31536000, immutable");
179
206
  return new Response(a.body, { status: a.status, headers });
180
207
  }
181
208
  return a;
@@ -183,7 +210,8 @@ export function withAssets(
183
210
  }
184
211
 
185
212
  // 3. Dynamic routes → the render pipeline (already sets Link + negotiates).
186
- return pipeline.fetch(request);
213
+ // Env flows through so the pipeline's resources get the D1/KV/R2 bindings.
214
+ return pipeline.fetch(request, env);
187
215
  },
188
216
  };
189
217
  }