@junejs/server 0.1.2 → 0.2.0

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.
Files changed (130) hide show
  1. package/assets/icon-latin.LICENSE.txt +92 -0
  2. package/assets/icon-latin.ttf +0 -0
  3. package/dist/adapter-DcZ-W1-7.d.ts +62 -0
  4. package/dist/adapter.d.ts +2 -56
  5. package/dist/adapter.js +62 -5
  6. package/dist/agent-compile.d.ts +38 -0
  7. package/dist/agent-compile.js +145 -0
  8. package/dist/agent-discover.d.ts +4 -4
  9. package/dist/agent-discover.js +30 -44
  10. package/dist/agent-durable.d.ts +94 -19
  11. package/dist/agent-durable.js +647 -32
  12. package/dist/agent-native.d.ts +44 -13
  13. package/dist/agent-native.js +264 -27
  14. package/dist/app.d.ts +5 -5
  15. package/dist/app.js +42 -10
  16. package/dist/blob.d.ts +4 -5
  17. package/dist/build.d.ts +8 -29
  18. package/dist/build.js +138 -289
  19. package/dist/client-bundle.d.ts +8 -9
  20. package/dist/client-bundle.js +1 -1
  21. package/dist/config-freeze-CyXvNqi2.d.ts +17 -0
  22. package/dist/config-freeze.d.ts +2 -0
  23. package/dist/config-freeze.js +54 -0
  24. package/dist/config-loader.d.ts +3 -4
  25. package/dist/connection-auth.d.ts +38 -0
  26. package/dist/connection-auth.js +31 -0
  27. package/dist/content-freeze-CyiW2fRK.d.ts +4 -0
  28. package/dist/content-freeze.d.ts +2 -0
  29. package/dist/content-freeze.js +83 -0
  30. package/dist/content.d.ts +22 -9
  31. package/dist/content.js +43 -3
  32. package/dist/core-version.d.ts +4 -0
  33. package/dist/core-version.js +12 -0
  34. package/dist/css-modules-ambient.d.ts +2 -1
  35. package/dist/css-modules.d.ts +10 -11
  36. package/dist/css.d.ts +9 -10
  37. package/dist/d1-migrate.d.ts +6 -7
  38. package/dist/d1-migrate.js +3 -2
  39. package/dist/db.d.ts +2 -2
  40. package/dist/deploy.d.ts +3 -4
  41. package/dist/deploy.js +3 -2
  42. package/dist/dev-reload.d.ts +3 -4
  43. package/dist/dev.d.ts +8 -5
  44. package/dist/dev.js +40 -9
  45. package/dist/favicon.d.ts +37 -0
  46. package/dist/favicon.js +248 -0
  47. package/dist/host.d.ts +6 -5
  48. package/dist/host.js +2 -1
  49. package/dist/icon-letter.d.ts +3 -0
  50. package/dist/icon-letter.js +8 -0
  51. package/dist/index.d.ts +11 -5
  52. package/dist/index.js +9 -3
  53. package/dist/instrumentation.d.ts +2 -3
  54. package/dist/instrumentation.js +2 -1
  55. package/dist/island-registry.d.ts +6 -7
  56. package/dist/isolate-local.d.ts +5 -0
  57. package/dist/isolate-local.js +12 -0
  58. package/dist/kv.d.ts +3 -4
  59. package/dist/llms-links.d.ts +9 -0
  60. package/dist/llms-links.js +65 -0
  61. package/dist/manifest-CwTT1zVs.d.ts +11 -0
  62. package/dist/manifest.d.ts +2 -0
  63. package/dist/manifest.js +113 -0
  64. package/dist/negotiate.d.ts +4 -3
  65. package/dist/negotiate.js +24 -14
  66. package/dist/{pipeline-CCp9idQ4.d.ts → pipeline-BHUjBO0G.d.ts} +13 -1
  67. package/dist/pipeline.d.ts +2 -2
  68. package/dist/pipeline.js +286 -35
  69. package/dist/resources.d.ts +5 -6
  70. package/dist/route-rank.d.ts +10 -0
  71. package/dist/route-rank.js +38 -0
  72. package/dist/route-scan-BMbBzPIB.d.ts +20 -0
  73. package/dist/route-scan.d.ts +2 -0
  74. package/dist/route-scan.js +100 -0
  75. package/dist/router.d.ts +12 -13
  76. package/dist/router.js +58 -44
  77. package/dist/rsc-build.d.ts +6 -7
  78. package/dist/rsc-bundle.d.ts +6 -7
  79. package/dist/rsc-manifest.d.ts +5 -6
  80. package/dist/rsc-runtime/flight-render-routes.d.ts +3 -4
  81. package/dist/rsc-runtime/flight-render.d.ts +2 -3
  82. package/dist/rsc-runtime/flight-to-html.d.ts +2 -3
  83. package/dist/rsc-runtime/flight-to-html.js +3 -2
  84. package/dist/rsc-runtime/rsc-worker.js +11 -8
  85. package/dist/rsc-runtime/rsdw-shims.d.ts +2 -1
  86. package/dist/segment.d.ts +4 -5
  87. package/dist/sqlite-driver.d.ts +11 -11
  88. package/dist/sqlite-driver.js +17 -4
  89. package/dist/static-files.d.ts +4 -5
  90. package/dist/tsconfig-jsx.d.ts +4 -4
  91. package/dist/tsconfig-jsx.js +1 -1
  92. package/dist/turn-events.d.ts +4 -0
  93. package/dist/turn-events.js +46 -0
  94. package/dist/web-manifest.d.ts +5 -0
  95. package/dist/web-manifest.js +42 -0
  96. package/dist/worker-2qbQjj2c.d.ts +64 -0
  97. package/dist/worker.d.ts +2 -56
  98. package/dist/worker.js +121 -103
  99. package/package.json +22 -9
  100. package/src/adapter.ts +84 -3
  101. package/src/agent-compile.ts +271 -0
  102. package/src/agent-discover.ts +77 -63
  103. package/src/agent-durable.ts +998 -30
  104. package/src/agent-native.ts +370 -35
  105. package/src/app.ts +75 -6
  106. package/src/build.ts +237 -444
  107. package/src/config-freeze.ts +109 -0
  108. package/src/connection-auth.ts +101 -0
  109. package/src/content-freeze.ts +151 -0
  110. package/src/content.ts +55 -4
  111. package/src/core-version.ts +34 -0
  112. package/src/dev.ts +41 -8
  113. package/src/favicon.ts +325 -0
  114. package/src/host.ts +4 -3
  115. package/src/icon-letter.ts +14 -0
  116. package/src/index.ts +22 -0
  117. package/src/isolate-local.ts +33 -0
  118. package/src/llms-links.ts +96 -0
  119. package/src/manifest.ts +130 -0
  120. package/src/negotiate.ts +39 -15
  121. package/src/pipeline.ts +494 -44
  122. package/src/route-rank.ts +60 -0
  123. package/src/route-scan.ts +161 -0
  124. package/src/router.ts +75 -58
  125. package/src/rsc-runtime/rsc-worker.tsx +5 -4
  126. package/src/sqlite-driver.ts +36 -6
  127. package/src/tsconfig-jsx.ts +2 -2
  128. package/src/turn-events.ts +43 -0
  129. package/src/web-manifest.ts +38 -0
  130. package/src/worker.ts +197 -117
package/src/worker.ts CHANGED
@@ -17,18 +17,35 @@ import type { DocumentConfig } from "@junejs/core/document";
17
17
  import type { I18nConfig } from "@junejs/core/i18n";
18
18
  import type { Resources } from "@junejs/core/resources";
19
19
 
20
+ import type { Channel, ChannelFactory } from "@junejs/core/agent-config";
21
+ import { blobFileStore } from "@junejs/core/agent-files";
22
+
20
23
  import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved, type ResolvedResource, type ResourceHandler } from "./pipeline";
21
- import { durableAgentSurface, type DurableObjectNamespace } from "./agent-durable";
24
+ import { durableAgentSurface, durableChannelSurface, type DurableObjectNamespace } from "./agent-durable";
25
+ import { acceptTarget } from "./negotiate";
26
+ import { compareRoutePatterns, parseSegment } from "./route-rank";
22
27
  import { contentTypeFor, RESERVED_PREFIX, safeRelativePath } from "./static-files";
23
28
 
29
+ // The slice of workerd's ExecutionContext the worker threads through: webhook
30
+ // channels ACK fast and finish their work (post the reply out-of-band) on
31
+ // waitUntil — without it a floating promise can be killed when the isolate is
32
+ // reclaimed. Optional end to end; a host without it (tests, other targets)
33
+ // simply doesn't extend the invocation.
34
+ export type WorkerExecutionContext = { waitUntil?(p: Promise<unknown>): void };
35
+
24
36
  export type WorkerManifest = {
25
37
  // Static paths → route definitions ("/", "/users", ...).
26
38
  routes: Record<string, BrandedRoute>;
27
39
  // Dynamic patterns in file-route syntax ("/posts/[slug]", "/docs/[...path]").
28
40
  dynamicRoutes?: Array<{ pattern: string; def: BrandedRoute }>;
29
41
  // Resource routes (app/**/route.*): a raw-Response handler keyed by its pattern
30
- // (static or dynamic). Matched after pages, so a page never gets shadowed.
42
+ // (static or dynamic). Ranked with the pages by segment shape (route-rank.ts),
43
+ // exactly as `june dev` resolves; a page wins only at the SAME pattern.
31
44
  resourceRoutes?: Array<{ pattern: string; handler: ResourceHandler }>;
45
+ // Patterns scanned from .june/routes/ (framework-generated, e.g. Kura). Dev
46
+ // consults app/ first and falls back to .june/routes/, so every one of these
47
+ // ranks after every app/ route. Absent → all routes are app/ routes.
48
+ generatedRoutes?: string[];
32
49
  // Layout chains (root→leaf) keyed by route path / dynamic pattern. The build
33
50
  // freezes the same chain the dev server loads from app/layout.* files.
34
51
  layoutChains?: Record<string, LayoutComponent[]>;
@@ -45,6 +62,13 @@ export type WorkerManifest = {
45
62
  // by the build when an agent/ directory exists; absent → no durable agent on
46
63
  // the edge. The DO namespace binding is `env.AGENT`.
47
64
  agentName?: string;
65
+ // The agent's channels (Channel or `(env) => Channel` factories, from the
66
+ // compiled agent module) — mounted as webhook routes on the worker via
67
+ // durableChannelSurface when agent.runtime.channels is on. The SAME factories
68
+ // are also wired into the DO (DoAgentDef.channels) by the generated entry, so
69
+ // capability tools and host-side delivery work; this field is the WORKER half
70
+ // (verify the webhook, run the turn on the DO, reply on waitUntil).
71
+ agentChannels?: (Channel | ChannelFactory)[];
48
72
  // Locale routing config, frozen from june.config.ts `i18n`. The locales table
49
73
  // is plain data; a `resolveLocale` hook survives the in-process freeze (parity
50
74
  // test) but not JSON codegen — worker hook support lands with the codegen pass.
@@ -66,7 +90,13 @@ export type WorkerManifest = {
66
90
  services?: (env?: unknown) => unknown;
67
91
  };
68
92
 
69
- type Compiled = { regex: RegExp; names: string[]; def: BrandedRoute; pattern: string };
93
+ // One row of the worker's route table: a page (def) or a resource route (handler).
94
+ type Compiled = {
95
+ regex: RegExp;
96
+ names: string[];
97
+ pattern: string;
98
+ generated: boolean;
99
+ } & ({ def: BrandedRoute; handler?: undefined } | { def?: undefined; handler: ResourceHandler });
70
100
 
71
101
  // "/posts/[slug]" | "/docs/[...path]" | "/notes/[[tag]]" → matcher. Optional
72
102
  // segments ([[x]], [[...x]]) wrap slash + capture together so absence matches;
@@ -76,52 +106,47 @@ function compilePattern(pattern: string): { regex: RegExp; names: string[] } {
76
106
  let source = "";
77
107
  let allOptional = true;
78
108
  for (const seg of pattern.split("/").filter(Boolean)) {
79
- const oca = seg.match(/^\[\[\.\.\.(\w+)\]\]$/);
80
- if (oca) {
81
- names.push(oca[1]!);
82
- source += "(?:/(.+))?";
83
- continue;
84
- }
85
- const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
86
- if (ca) {
87
- names.push(ca[1]!);
88
- source += "/(.+)";
109
+ // Same grammar as the dev matcher (route-rank.ts parseSegment).
110
+ const { kind, name } = parseSegment(seg);
111
+ if (name) names.push(name);
112
+ if (kind === "optionalCatchAll") source += "(?:/(.+))?";
113
+ else if (kind === "optional") source += "(?:/([^/]+))?";
114
+ else {
115
+ if (kind === "catchAll") source += "/(.+)";
116
+ else if (kind === "param") source += "/([^/]+)";
117
+ // A static segment matches literally: escape every RegExp metacharacter,
118
+ // brackets included (`docs[v2`, `[slug].png` are static names).
119
+ else source += "/" + seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
89
120
  allOptional = false;
90
- continue;
91
121
  }
92
- const op = seg.match(/^\[\[(\w+)\]\]$/);
93
- if (op) {
94
- names.push(op[1]!);
95
- source += "(?:/([^/]+))?";
96
- continue;
97
- }
98
- const p = seg.match(/^\[(\w+)\]$/);
99
- if (p) {
100
- names.push(p[1]!);
101
- source += "/([^/]+)";
102
- allOptional = false;
103
- continue;
104
- }
105
- source += "/" + seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
106
- allOptional = false;
107
122
  }
108
123
  if (source === "") return { regex: /^\/$/, names };
109
124
  return { regex: new RegExp(allOptional ? `^(?:${source}|/)$` : `^${source}$`), names };
110
125
  }
111
126
 
127
+ // opts.staticBuild: set only by the static() build's in-process prerender worker
128
+ // (see PipelineConfig.staticBuild) — never by a deployed entry.
112
129
  export function createWorker(
113
130
  manifest: WorkerManifest,
114
- ): { fetch(request: Request, env?: unknown): Promise<Response> } {
115
- const dynamic: Compiled[] = (manifest.dynamicRoutes ?? []).map((d) => ({
116
- ...compilePattern(d.pattern),
117
- def: d.def,
118
- pattern: d.pattern,
119
- }));
120
- const resources = (manifest.resourceRoutes ?? []).map((r) => ({
121
- ...compilePattern(r.pattern),
122
- handler: r.handler,
123
- pattern: r.pattern,
124
- }));
131
+ opts: { staticBuild?: boolean } = {},
132
+ ): { fetch(request: Request, env?: unknown, ctx?: WorkerExecutionContext): Promise<Response> } {
133
+ // Pages and resource routes in ONE table, ordered the way `june dev` resolves
134
+ // (#312): app/ before .june/routes/, then segment shape (static > [param] >
135
+ // [...catchAll], required before optional), then a page before a resource
136
+ // route at the same pattern. The kind of route never outranks the shape.
137
+ const generated = new Set(manifest.generatedRoutes ?? []);
138
+ const row = (pattern: string) => ({ ...compilePattern(pattern), pattern, generated: generated.has(pattern) });
139
+ const table: Compiled[] = [
140
+ ...Object.entries(manifest.routes).map(([pattern, def]) => ({ ...row(pattern), def })),
141
+ ...(manifest.dynamicRoutes ?? []).map((d) => ({ ...row(d.pattern), def: d.def })),
142
+ ...(manifest.resourceRoutes ?? []).map((r) => ({ ...row(r.pattern), handler: r.handler })),
143
+ ];
144
+ table.sort(
145
+ (a, b) =>
146
+ Number(a.generated) - Number(b.generated) ||
147
+ compareRoutePatterns(a.pattern, b.pattern) ||
148
+ Number(!!a.handler) - Number(!!b.handler),
149
+ );
125
150
  const routeList = [
126
151
  ...Object.keys(manifest.routes),
127
152
  ...(manifest.dynamicRoutes ?? []).map((d) => d.pattern),
@@ -131,11 +156,28 @@ export function createWorker(
131
156
  const loadingFor = (key: string): LoadingComponent | undefined => manifest.loadings?.[key];
132
157
  const boundaryFor = (key: string): { index: number; key: string } | null =>
133
158
  manifest.layoutBoundaries?.[key] ?? null;
159
+ const resolvedPage = (def: BrandedRoute, key: string, params: Record<string, string>): Resolved => {
160
+ const b = boundaryFor(key);
161
+ return {
162
+ def,
163
+ params,
164
+ chain: chainFor(key),
165
+ boundaryIndex: b?.index ?? null,
166
+ boundaryKey: b?.key ?? null,
167
+ loading: loadingFor(key),
168
+ };
169
+ };
134
170
 
135
171
  // The worker's env (D1/KV/R2 bindings) arrives per fetch and is stable across
136
172
  // requests in an isolate; we capture the latest and hand it to the env-aware
137
- // provider, which memoizes the opened handles on first call.
173
+ // provider, which memoizes the opened handles on first call. That "latest"
174
+ // pattern is safe ONLY for per-isolate-stable values — concurrent fetches
175
+ // interleave at await points, so anything per-INVOCATION (the execution
176
+ // context, and env used at a specific moment) must come from request-scoped
177
+ // storage instead: `requestLocals`, keyed by the Request identity that flows
178
+ // unchanged from fetch() through the pipeline to the agent surface.
138
179
  let currentEnv: unknown;
180
+ const requestLocals = new WeakMap<Request, { env: unknown; ctx?: WorkerExecutionContext }>();
139
181
  const provider = manifest.resources;
140
182
 
141
183
  // App services: the build sets a raw `(env) => services` factory here; memoize it per
@@ -144,18 +186,52 @@ export function createWorker(
144
186
  const servicesProvider = manifest.services;
145
187
  let servicesCache: { v: unknown } | undefined;
146
188
 
147
- // Durable agent (edge): route the chat endpoint to the per-session DO bound at
148
- // env.AGENT. Built only when the app has an agent (manifest.agentName) and the
149
- // runtime is enabled; the surface is inert (null → fall through) until env.AGENT
150
- // is present, so a worker without the DO binding is unaffected.
189
+ // Durable agent (edge): route the chat endpoint — and the agent's channel
190
+ // webhooks — to the per-session DO bound at env.AGENT. Built only when the app
191
+ // has an agent (manifest.agentName) and the runtime is enabled; the surface is
192
+ // inert (null → fall through) until env.AGENT is present, so a worker without
193
+ // the DO binding is unaffected.
151
194
  const agentSurface =
152
195
  manifest.agent.runtime.enabled && manifest.agentName
153
196
  ? (() => {
154
- const route = durableAgentSurface(
155
- () => (currentEnv as { AGENT?: DurableObjectNamespace } | undefined)?.AGENT,
156
- { agentName: manifest.agentName, chatPath: manifest.agent.runtime.chat.path },
157
- );
158
- return (req: Request) => route(req);
197
+ const agentName = manifest.agentName;
198
+ const wantChannels = manifest.agent.runtime.channels !== false && (manifest.agentChannels?.length ?? 0) > 0;
199
+ return async (req: Request): Promise<Response | null> => {
200
+ // REQUEST-scoped, not isolate-scoped: the pipeline awaits between
201
+ // fetch() and this call, so a concurrent fetch may have run since —
202
+ // a module-level "current request" variable here would hand this
203
+ // webhook ANOTHER request's env (wrong AGENT namespace, factories
204
+ // resolved against foreign secrets) and register its background
205
+ // work on another invocation's waitUntil. Everything below reads
206
+ // THIS request's locals.
207
+ const locals = requestLocals.get(req);
208
+ const ns = () => (locals?.env as { AGENT?: DurableObjectNamespace } | undefined)?.AGENT;
209
+ const chat = durableAgentSurface(ns, { agentName, chatPath: manifest.agent.runtime.chat.path });
210
+ const chatRes = await chat(req);
211
+ if (chatRes) return chatRes;
212
+ if (!wantChannels) return null;
213
+ // Constructed per request — cheap by design: resolveChannel is a
214
+ // call, and resolveServices memoizes the app services bag per
215
+ // (env, agentName), exactly for this construction pattern (see
216
+ // durableChannelSurface's services doc).
217
+ const channels = durableChannelSurface(ns, {
218
+ agentName,
219
+ channels: manifest.agentChannels!,
220
+ env: locals?.env,
221
+ services: servicesProvider,
222
+ waitUntil: locals?.ctx?.waitUntil?.bind(locals.ctx),
223
+ // The blob resource the agent's Durable Object stores files in, opened from
224
+ // this isolate's env — the channels read attached files back from it.
225
+ files: provider
226
+ ? blobFileStore(async () => {
227
+ const blob = (await provider(locals?.env)).blob;
228
+ if (!blob) throw new Error(`agent "${agentName}": no blob resource bound in this isolate — attached files can't be read`);
229
+ return blob;
230
+ })
231
+ : undefined,
232
+ });
233
+ return channels(req);
234
+ };
159
235
  })()
160
236
  : undefined;
161
237
 
@@ -165,6 +241,7 @@ export function createWorker(
165
241
  agentSurface,
166
242
  i18n: manifest.i18n,
167
243
  routeList: () => routeList,
244
+ staticBuild: opts.staticBuild,
168
245
  earlyHints: manifest.earlyHints,
169
246
  htmlCacheControl: manifest.htmlCacheControl,
170
247
  notFoundComponent: manifest.notFound,
@@ -172,60 +249,30 @@ export function createWorker(
172
249
  resources: provider ? () => provider(currentEnv) : undefined,
173
250
  services: servicesProvider ? () => (servicesCache ??= { v: servicesProvider(currentEnv) }).v : undefined,
174
251
  resolve: async (pathname): Promise<Resolved | ResolvedResource | null> => {
252
+ // Fast path: an app/ static page outranks every other route for its path.
175
253
  const staticDef = manifest.routes[pathname];
176
- if (staticDef) {
177
- const b = boundaryFor(pathname);
178
- return {
179
- def: staticDef,
180
- params: {},
181
- chain: chainFor(pathname),
182
- boundaryIndex: b?.index ?? null,
183
- boundaryKey: b?.key ?? null,
184
- loading: loadingFor(pathname),
185
- };
186
- }
187
- for (const d of dynamic) {
188
- const m = pathname.match(d.regex);
189
- if (m) {
190
- // Optional segments leave their capture undefined — the param is
191
- // then absent, matching the dev matcher's semantics.
192
- const params = Object.fromEntries(
193
- d.names.flatMap((n, i) => {
194
- const v = m[i + 1];
195
- return v === undefined ? [] : [[n, decodeURIComponent(v)]];
196
- }),
197
- );
198
- const b = boundaryFor(d.pattern);
199
- return {
200
- def: d.def,
201
- params,
202
- chain: chainFor(d.pattern),
203
- boundaryIndex: b?.index ?? null,
204
- boundaryKey: b?.key ?? null,
205
- loading: loadingFor(d.pattern),
206
- };
207
- }
208
- }
209
- // Resource routes after pages (so a page is never shadowed by one).
210
- for (const r of resources) {
254
+ if (staticDef && !generated.has(pathname)) return resolvedPage(staticDef, pathname, {});
255
+ for (const r of table) {
211
256
  const m = pathname.match(r.regex);
212
- if (m) {
213
- const params = Object.fromEntries(
214
- r.names.flatMap((n, i) => {
215
- const v = m[i + 1];
216
- return v === undefined ? [] : [[n, decodeURIComponent(v)]];
217
- }),
218
- );
219
- return { handler: r.handler, params };
220
- }
257
+ if (!m) continue;
258
+ // Optional segments leave their capture undefined — the param is
259
+ // then absent, matching the dev matcher's semantics.
260
+ const params = Object.fromEntries(
261
+ r.names.flatMap((n, i) => {
262
+ const v = m[i + 1];
263
+ return v === undefined ? [] : [[n, decodeURIComponent(v)]];
264
+ }),
265
+ );
266
+ return r.handler ? { handler: r.handler, params } : resolvedPage(r.def, r.pattern, params);
221
267
  }
222
268
  return null;
223
269
  },
224
270
  });
225
271
 
226
272
  return {
227
- fetch(request, env) {
228
- currentEnv = env;
273
+ fetch(request, env, ctx) {
274
+ currentEnv = env; // per-isolate-stable consumers only (resources/services)
275
+ requestLocals.set(request, { env, ctx }); // per-invocation consumers (agent surface)
229
276
  return pipeline.fetch(request);
230
277
  },
231
278
  };
@@ -234,43 +281,71 @@ export function createWorker(
234
281
  // --- the deployed worker's outermost layer (run_worker_first) ---------------
235
282
  // Prerendered pages are served as static ASSETS, which bypass the pipeline —
236
283
  // so the agent-ready signals the asset layer can't produce get added HERE:
237
- // 1. `Accept: text/markdown` on a page → the prerendered `.md` asset.
284
+ // 1. a negotiated projection on a page (Accept: text/markdown / application/
285
+ // json → the prerendered .md / .json asset; else, and for the client
286
+ // router's fragment, the pipeline — never the page's HTML asset).
238
287
  // 2. a `Link` header on every HTML response (RFC 8288/9727 discovery).
239
288
  // 3. `x-markdown-tokens` on markdown responses.
240
289
  // Static assets are served by the ASSETS binding (no re-render); only genuinely
241
290
  // dynamic routes reach the pipeline. With no ASSETS binding (no prerender), this
242
291
  // is a transparent pass-through to the pipeline.
243
292
  type AssetEnv = { ASSETS?: { fetch(request: Request): Promise<Response> } };
244
- type FetchPipeline = { fetch(request: Request, env?: unknown): Promise<Response> };
293
+ type FetchPipeline = { fetch(request: Request, env?: unknown, ctx?: WorkerExecutionContext): Promise<Response> };
245
294
 
246
295
  const estimateTokens = (s: string) => String(Math.ceil(s.length / 4));
247
296
 
297
+ // Add Accept to a response's Vary, keeping whatever the asset layer already set.
298
+ function varyAccept(headers: Headers): void {
299
+ const vary = headers.get("vary");
300
+ if (!vary) headers.set("vary", "accept");
301
+ else if (vary.trim() !== "*" && !vary.split(",").some((v) => v.trim().toLowerCase() === "accept")) {
302
+ headers.set("vary", `${vary}, accept`);
303
+ }
304
+ }
305
+
248
306
  export function withAssets(
249
307
  pipeline: FetchPipeline,
250
308
  opts: { link?: string | null } = {},
251
- ): { fetch(request: Request, env?: AssetEnv): Promise<Response> } {
309
+ ): { fetch(request: Request, env?: AssetEnv, ctx?: WorkerExecutionContext): Promise<Response> } {
252
310
  return {
253
- async fetch(request, env) {
311
+ async fetch(request, env, ctx) {
254
312
  const assets = env?.ASSETS;
255
313
  const url = new URL(request.url);
256
314
  const isPagePath = !/\.[a-z0-9]+$/i.test(url.pathname); // no file extension
257
- const accept = request.headers.get("accept") ?? "";
315
+ // The pipeline's own Accept decision (q-values included). A page path that
316
+ // negotiates a projection other than the HTML view must get THAT projection
317
+ // or the pipeline's answer for it — never the prerendered HTML asset.
318
+ const target = isPagePath ? acceptTarget(request.headers.get("accept") ?? "") : null;
258
319
 
259
- // 1. Markdown content negotiation on a page path → prerendered .md asset.
260
- if (assets && request.method === "GET" && isPagePath && /text\/markdown/.test(accept)) {
261
- const base = url.pathname === "/" ? "/index" : url.pathname.replace(/\/+$/, "");
262
- const mdUrl = new URL(url);
263
- mdUrl.pathname = `${base}.md`;
264
- const a = await assets.fetch(new Request(mdUrl.toString(), { headers: request.headers }));
265
- if (a.ok) {
266
- const body = await a.text();
267
- const headers = new Headers(a.headers);
268
- headers.set("content-type", "text/markdown; charset=utf-8");
269
- headers.set("x-markdown-tokens", estimateTokens(body));
270
- if (opts.link) headers.set("link", opts.link);
271
- return new Response(body, { status: 200, headers });
320
+ // 1. Negotiated projections on a page path.
321
+ if (target && (request.method === "GET" || request.method === "HEAD")) {
322
+ // md / json → the prerendered <path>.md / <path>.json asset when the build
323
+ // emitted one (.md unless md = false; .json only for a json() route). Only
324
+ // the flat file — never <path>/index.*, a distinct /path/index route's twin
325
+ // (locale homes' <locale>/index.* are a static()-target artifact; this
326
+ // workers()-only layer never has them).
327
+ if (assets && request.method === "GET" && (target === "md" || target === "json")) {
328
+ const base = url.pathname === "/" ? "/index" : url.pathname.replace(/\/+$/, "");
329
+ const assetUrl = new URL(url);
330
+ assetUrl.pathname = `${base}.${target}`;
331
+ const a = await assets.fetch(new Request(assetUrl.toString(), { headers: request.headers }));
332
+ if (a.ok) {
333
+ const headers = new Headers(a.headers);
334
+ if (opts.link) headers.set("link", opts.link);
335
+ varyAccept(headers);
336
+ if (target === "json") return new Response(a.body, { status: 200, headers });
337
+ const body = await a.text();
338
+ headers.set("content-type", "text/markdown; charset=utf-8");
339
+ headers.set("x-markdown-tokens", estimateTokens(body));
340
+ return new Response(body, { status: 200, headers });
341
+ }
272
342
  }
273
- // no prerendered .md → fall through; a dynamic route renders it below.
343
+ // No such asset (or a fragment, or HEAD) → the pipeline (step 3). It renders
344
+ // a dynamic route's projection, derives .json from the loader data, 404s a
345
+ // disabled projection, and renders the client router's soft-nav fragment —
346
+ // the HTML asset is a full DOCUMENT, which the router would morph into its
347
+ // [data-june-root] as if it were the root's inner HTML.
348
+ return pipeline.fetch(request, env, ctx);
274
349
  }
275
350
 
276
351
  // 2. Static assets (prerendered HTML/.md/.json, /client.js, hashed CSS)
@@ -287,10 +362,15 @@ export function withAssets(
287
362
  // URL changes when the bytes do, so the browser may cache forever and
288
363
  // never revalidate (no 304 round-trip, no stale window).
289
364
  const immutable = /\.[a-f0-9]{8,}\.(css|js)$/.test(url.pathname);
290
- if (addLink || immutable) {
365
+ // A page path answers HTML here but Markdown under Accept: text/markdown
366
+ // (step 1) — the same URL, so caches must key on Accept or they hand an
367
+ // agent the HTML (or a browser the Markdown).
368
+ const negotiated = isPagePath && ct.includes("text/html");
369
+ if (addLink || immutable || negotiated) {
291
370
  const headers = new Headers(a.headers);
292
371
  if (addLink) headers.set("link", opts.link!);
293
372
  if (immutable) headers.set("cache-control", "public, max-age=31536000, immutable");
373
+ if (negotiated) varyAccept(headers);
294
374
  return new Response(a.body, { status: a.status, headers });
295
375
  }
296
376
  return a;
@@ -299,7 +379,7 @@ export function withAssets(
299
379
 
300
380
  // 3. Dynamic routes → the render pipeline (already sets Link + negotiates).
301
381
  // Env flows through so the pipeline's resources get the D1/KV/R2 bindings.
302
- return pipeline.fetch(request, env);
382
+ return pipeline.fetch(request, env, ctx);
303
383
  },
304
384
  };
305
385
  }