@junejs/server 0.0.7 → 0.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@junejs/core": "0.0.7",
32
+ "@junejs/core": "0.0.9",
33
33
  "marked": "^18.0.5",
34
34
  "rolldown": "^1.1.0"
35
35
  },
package/src/app.ts CHANGED
@@ -13,7 +13,7 @@ import { dirname, resolve } from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
14
  import type { ComponentType } from "react";
15
15
 
16
- import { isRouteDefinition } from "@junejs/core/route";
16
+ import { routeFromModule } from "@junejs/core/route";
17
17
  import { resolveAgent, resolveSpeculationRules, type JuneConfig } from "@junejs/core/config";
18
18
  import type { DocumentConfig } from "@junejs/core/document";
19
19
  import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
@@ -117,9 +117,10 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
117
117
  resolve: async (pathname): Promise<Resolved | null> => {
118
118
  const match = await matchRouteTree(appDir, pathname, { pageConvention: true });
119
119
  if (!match) return null;
120
- const mod = (await import(pathToFileURL(match.file).href)) as { default?: unknown };
121
- if (!isRouteDefinition(mod.default)) return null;
122
- return { def: mod.default, params: match.params, chain: await loadChain(match.segments) };
120
+ const mod = await import(pathToFileURL(match.file).href);
121
+ const def = routeFromModule(mod);
122
+ if (!def) return null;
123
+ return { def, params: match.params, chain: await loadChain(match.segments) };
123
124
  },
124
125
  });
125
126
  }
package/src/build.ts CHANGED
@@ -24,7 +24,8 @@ import { pathToFileURL } from "node:url";
24
24
 
25
25
  import { loadJuneConfig } from "./config-loader";
26
26
  import { resolveAgent, resolveSpeculationRules } from "@junejs/core/config";
27
- import { isRouteDefinition, type BrandedRoute } from "@junejs/core/route";
27
+ import { buildLinkHeader } from "@junejs/core/discovery";
28
+ import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
28
29
  import type { DocumentConfig } from "@junejs/core/document";
29
30
  import { collection } from "./content";
30
31
  import { createWorker, type WorkerManifest } from "./worker";
@@ -171,14 +172,15 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
171
172
  const layoutChains: Record<string, LayoutComponent[]> = {};
172
173
 
173
174
  for (const r of scanned) {
174
- const mod = (await import(pathToFileURL(r.file).href)) as { default?: unknown };
175
- if (!isRouteDefinition(mod.default)) continue;
175
+ const mod = await import(pathToFileURL(r.file).href);
176
+ const def = routeFromModule(mod);
177
+ if (!def) continue;
176
178
  const chain = await componentsFor(r.layouts);
177
179
  if (r.dynamic) {
178
- dynamicRoutes.push({ pattern: r.path, def: mod.default });
180
+ dynamicRoutes.push({ pattern: r.path, def });
179
181
  layoutChains[r.path] = chain;
180
182
  } else {
181
- routes[r.path] = mod.default;
183
+ routes[r.path] = def;
182
184
  layoutChains[r.path] = chain;
183
185
  }
184
186
  }
@@ -224,7 +226,13 @@ export async function juneBuild(
224
226
  const frozen = await freezeConfig(appRoot);
225
227
 
226
228
  // ---- generated entry -----------------------------------------------------
227
- const imports: string[] = [`import { createWorker } from "@junejs/server/worker";`];
229
+ // Routes are namespace-imported and adapted with routeFromModule, so the
230
+ // multi-export page shape (default view + named loader/json/md) and the legacy
231
+ // route({}) default export both work.
232
+ const imports: string[] = [
233
+ `import { createWorker } from "@junejs/server/worker";`,
234
+ `import { routeFromModule } from "@junejs/core/route";`,
235
+ ];
228
236
  const statics: string[] = [];
229
237
  const dynamics: string[] = [];
230
238
  const layoutIds = new Map<string, string>();
@@ -239,9 +247,9 @@ export async function juneBuild(
239
247
  };
240
248
  const chains: string[] = [];
241
249
  routes.forEach((r, i) => {
242
- imports.push(`import r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
243
- if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: r${i} },`);
244
- else statics.push(` ${JSON.stringify(r.path)}: r${i},`);
250
+ imports.push(`import * as r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
251
+ if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: routeFromModule(r${i}) },`);
252
+ else statics.push(` ${JSON.stringify(r.path)}: routeFromModule(r${i}),`);
245
253
  chains.push(` ${JSON.stringify(r.path)}: [${r.layouts.map(layoutId).join(", ")}],`);
246
254
  });
247
255
 
@@ -250,10 +258,17 @@ export async function juneBuild(
250
258
  imports.push(`import extra from ${JSON.stringify(importPath(genDir, builtExtraFile))};`);
251
259
  }
252
260
 
261
+ // The deployed worker runs first (wrangler assets.run_worker_first) and wraps
262
+ // the pipeline with withAssets — so prerendered pages, served from the ASSETS
263
+ // binding, still get the Link header + Accept:markdown negotiation the asset
264
+ // layer can't do. The Link header is frozen here from the same builder the
265
+ // pipeline uses, so the static and dynamic surfaces advertise identically.
266
+ const linkHeader = buildLinkHeader(frozen.agent);
253
267
  const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
268
+ import { withAssets } from "@junejs/server/worker";
254
269
  ${imports.join("\n")}
255
270
 
256
- export default createWorker({
271
+ const pipeline = createWorker({
257
272
  routes: {
258
273
  ${statics.join("\n")}
259
274
  },
@@ -267,6 +282,8 @@ ${chains.join("\n")}
267
282
  agent: ${JSON.stringify(frozen.agent)},
268
283
  earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}
269
284
  });
285
+
286
+ export default withAssets(pipeline, { link: ${JSON.stringify(linkHeader)} });
270
287
  `;
271
288
  const entryFile = join(genDir, "worker-entry.tsx");
272
289
  await writeFile(entryFile, entry);
@@ -310,11 +327,15 @@ ${chains.join("\n")}
310
327
  const def = manifest.routes[r.path];
311
328
  if (!def || !def.prerender) continue;
312
329
  const stem = r.path === "/" ? "index" : r.path.slice(1);
313
- const targets: Array<[string, string]> = [[r.path, `${stem}.html`]];
314
- if (r.path !== "/") {
315
- targets.push([`${r.path}.md`, `${stem}.md`]);
316
- if (typeof def.json === "function") targets.push([`${r.path}.json`, `${stem}.json`]);
317
- }
330
+ // The homepage's projection requests are `/.md` / `/.json` (negotiate strips
331
+ // the ext back to `/`); withAssets serves them for `Accept: text/markdown`.
332
+ const mdReq = r.path === "/" ? "/.md" : `${r.path}.md`;
333
+ const jsonReq = r.path === "/" ? "/.json" : `${r.path}.json`;
334
+ const targets: Array<[string, string]> = [
335
+ [r.path, `${stem}.html`],
336
+ [mdReq, `${stem}.md`],
337
+ ];
338
+ if (typeof def.json === "function") targets.push([jsonReq, `${stem}.json`]);
318
339
  for (const [reqPath, file] of targets) {
319
340
  const res = await worker.fetch(new Request(`https://prerender.june${reqPath}`));
320
341
  if (!res.ok) throw new Error(`prerender ${reqPath} → ${res.status}`);
@@ -341,15 +362,30 @@ ${chains.join("\n")}
341
362
  const pkg = existsSync(pkgPath)
342
363
  ? (JSON.parse(await Bun.file(pkgPath).text()) as { name?: string })
343
364
  : {};
365
+ const deployCfg = (await loadJuneConfig(appRoot)).deploy;
344
366
  await writeFile(
345
367
  join(outDir, "wrangler.jsonc"),
346
368
  JSON.stringify(
347
369
  {
348
- name: (pkg.name ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase(),
370
+ name:
371
+ deployCfg?.name ??
372
+ (pkg.name ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase(),
349
373
  main: "./worker.js",
350
374
  compatibility_date: "2025-01-01",
351
375
  compatibility_flags: ["nodejs_compat"],
352
- ...(hasAssets ? { assets: { directory: "./assets" } } : {}),
376
+ // run_worker_first: the worker wraps asset serving (withAssets) so
377
+ // prerendered pages still get Link headers + Accept:markdown
378
+ // negotiation. The ASSETS binding lets the worker serve the static
379
+ // files itself.
380
+ ...(hasAssets
381
+ ? { assets: { directory: "./assets", binding: "ASSETS", run_worker_first: true } }
382
+ : {}),
383
+ // config deploy.domain → a Workers custom domain; without it the
384
+ // generated file regenerating on every build would silently drop a
385
+ // hand-attached domain.
386
+ ...(deployCfg?.domain
387
+ ? { routes: [{ pattern: deployCfg.domain, custom_domain: true }] }
388
+ : {}),
353
389
  },
354
390
  null,
355
391
  2,
package/src/db.ts CHANGED
@@ -3,15 +3,28 @@
3
3
  // by the host, injected onto RouteContext as `ctx.db`. The framework depends on
4
4
  // the JuneDb contract; these adapters (and Juno on top) are swappable.
5
5
 
6
+ import { mkdirSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+
6
9
  import type { DbFactory, JuneDb, RunResult } from "@junejs/core/resources";
7
10
 
8
11
  import { host } from "./host";
9
12
 
10
- // Local SQLite — the zero-config dev default (embedded file or :memory:). Built
11
- // on the demoted host.openDb primitive (the sync bun:/node: driver wrapped async).
13
+ // Local SQLite — the zero-config dev default. A persistent FILE, not :memory:,
14
+ // on purpose: `june dev` restarts the process on every save (the watch
15
+ // supervisor), so an in-memory default would evaporate your dev data at each
16
+ // edit. A file also makes the dev db inspectable with the sqlite3 every
17
+ // machine already has (`sqlite3 .june/dev.sqlite`). Pass ":memory:" explicitly
18
+ // for ephemerality.
12
19
  export function sqlite(opts: { path?: string } = {}): DbFactory {
13
- const path = opts.path ?? ":memory:";
14
- return { kind: "sqlite", open: () => host.openDb(path) };
20
+ const path = opts.path ?? ".june/dev.sqlite";
21
+ return {
22
+ kind: "sqlite",
23
+ open: () => {
24
+ if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
25
+ return host.openDb(path);
26
+ },
27
+ };
15
28
  }
16
29
 
17
30
  // --- D1 (Cloudflare) — the third openDb impl (rebuild-plan Phase 5) ----------
package/src/negotiate.ts CHANGED
@@ -10,12 +10,10 @@ import type { RenderTarget } from "@junejs/core/route";
10
10
 
11
11
  const EXT_TARGET: Record<string, RenderTarget> = {
12
12
  ".json": "json",
13
- ".agent": "agent",
14
13
  ".md": "md",
15
14
  };
16
15
 
17
16
  const ACCEPT_TARGET: Array<[test: RegExp, target: RenderTarget]> = [
18
- [/application\/vnd\.june-agent\+json/, "agent"],
19
17
  [/text\/markdown/, "md"],
20
18
  [/application\/json/, "json"],
21
19
  ];
package/src/pipeline.ts CHANGED
@@ -18,14 +18,12 @@ import React from "react";
18
18
  import { renderToReadableStream } from "react-dom/server";
19
19
 
20
20
  import {
21
- resolveProjection,
22
21
  type BrandedRoute,
23
22
  type Metadata,
24
23
  type RenderTarget,
25
24
  type RouteContext,
26
25
  } from "@junejs/core/route";
27
26
  import { Document, type DocumentConfig } from "@junejs/core/document";
28
- import { isResourceManifest } from "@junejs/core/agent";
29
27
  import {
30
28
  apiCatalog,
31
29
  buildLinkHeader,
@@ -34,7 +32,7 @@ import {
34
32
  robotsTxt,
35
33
  sitemapXml,
36
34
  } from "@junejs/core/discovery";
37
- import { mcpHandler } from "@junejs/core/mcp";
35
+ import { mcpHandler, mcpTools } from "@junejs/core/mcp";
38
36
  import type { AgentConfig } from "@junejs/core/config";
39
37
  import type { Resources } from "@junejs/core/resources";
40
38
 
@@ -95,6 +93,23 @@ function text(body: string, contentType: string, init?: ResponseInit) {
95
93
  return new Response(body, { ...init, headers });
96
94
  }
97
95
 
96
+ // The default favicon: the site name's first character in a rounded square —
97
+ // a plain SVG string, so it needs no fonts, no rasterizer, and works for CJK
98
+ // names as readily as latin ones. Served at /favicon.svg AND /favicon.ico
99
+ // (browsers respect the svg content-type), so no June app 404s its icon.
100
+ function letterFavicon(siteName: string | undefined): Response {
101
+ const first = (siteName ?? "").trim().charAt(0) || "•";
102
+ const letter = first.toUpperCase().replace(/[<>&"']/g, "");
103
+ return text(
104
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` +
105
+ `<rect width="64" height="64" rx="12" fill="#1d1d1f"/>` +
106
+ `<text x="32" y="32" dy=".36em" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="34" font-weight="600" fill="#fbfbf8">${letter || "•"}</text>` +
107
+ `</svg>\n`,
108
+ "image/svg+xml",
109
+ { headers: { "cache-control": "public, max-age=86400" } },
110
+ );
111
+ }
112
+
98
113
  export function createPipeline(cfg: PipelineConfig): Pipeline {
99
114
  const { docConfig, agent } = cfg;
100
115
  const NotFound = cfg.notFoundComponent ?? DefaultNotFound;
@@ -118,8 +133,13 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
118
133
  (acc, L) => React.createElement(L, null, acc),
119
134
  node,
120
135
  );
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;
121
141
  const stream = await renderToReadableStream(
122
- React.createElement(Document, { config: docConfig, metadata, children: wrapped }),
142
+ React.createElement(Document, { config, metadata, children: wrapped }),
123
143
  );
124
144
  await stream.allReady; // fully resolved markup (no streamed Suspense fallbacks)
125
145
  const html = "<!doctype html>\n" + (await new Response(stream).text());
@@ -145,9 +165,18 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
145
165
  }
146
166
 
147
167
  async function renderMarkdown(def: BrandedRoute, data: unknown, ctx: RouteContext): Promise<Response> {
148
- if (def.md) return text(await def.md(data, ctx), "text/markdown; charset=utf-8");
149
- const payload = def.json ? await def.json(data, ctx) : data;
150
- return text("```json\n" + JSON.stringify(payload, null, 2) + "\n```\n", "text/markdown; charset=utf-8");
168
+ // md fn → custom; absent → derive from the json projection (loader data when
169
+ // json is also absent). md/json === false is handled as 404 by the caller.
170
+ const jsonData =
171
+ typeof def.json === "function" ? await def.json(data, ctx) : def.json === false ? null : data;
172
+ const body =
173
+ typeof def.md === "function"
174
+ ? await def.md(data, ctx)
175
+ : "```json\n" + JSON.stringify(jsonData, null, 2) + "\n```\n";
176
+ // x-markdown-tokens: a rough estimate (~4 chars/token) agents use to budget.
177
+ return text(body, "text/markdown; charset=utf-8", {
178
+ headers: { "x-markdown-tokens": String(Math.ceil(body.length / 4)) },
179
+ });
151
180
  }
152
181
 
153
182
  async function renderProjection(
@@ -157,21 +186,17 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
157
186
  ctx: RouteContext,
158
187
  ): Promise<Response> {
159
188
  const { def, chain } = resolved;
160
- if (target === "md") return renderMarkdown(def, data, ctx);
189
+ // A projection declared `false` is disabled → 404 (and absent from discovery).
190
+ if (def[target] === false) return notFoundResponse(target, ctx.url.pathname);
161
191
 
162
- switch (resolveProjection(def, target)) {
163
- case "json":
164
- return Response.json(await def.json!(data, ctx));
165
- case "agent": {
166
- const out = await def.agent!(data, ctx);
167
- const body = isResourceManifest(out) ? out.toManifest() : out;
168
- return text(JSON.stringify(body), "application/vnd.june-agent+json");
169
- }
170
- default: {
171
- const node = def.view ? def.view(data, ctx) : null;
172
- return renderDocument(node, resolveMeta(def, data, ctx), 200, chain);
173
- }
192
+ if (target === "md") return renderMarkdown(def, data, ctx);
193
+ if (target === "json") {
194
+ // Convention: a json() fn customizes; absent → serialize the loader data.
195
+ const payload = typeof def.json === "function" ? await def.json(data, ctx) : data;
196
+ return Response.json(payload);
174
197
  }
198
+ const node = def.view ? def.view(data, ctx) : null;
199
+ return renderDocument(node, resolveMeta(def, data, ctx), 200, chain);
175
200
  }
176
201
 
177
202
  async function discovery(url: URL): Promise<Response | null> {
@@ -219,6 +244,15 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
219
244
  if (out) return out;
220
245
  }
221
246
 
247
+ // --- default favicon (after extra so an app can override it) ----------
248
+ if (
249
+ request.method === "GET" &&
250
+ !docConfig.site.icon &&
251
+ (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico")
252
+ ) {
253
+ return letterFavicon(docConfig.site.name);
254
+ }
255
+
222
256
  // --- routes ----------------------------------------------------------
223
257
  const { target, pathname, speculative } = negotiate(url, request);
224
258
  const resolved = await cfg.resolve(pathname);
package/src/router.ts CHANGED
@@ -67,8 +67,12 @@ function isSpecialFile(file: string) {
67
67
 
68
68
  const isRouteGroup = (name: string) => /^\(.+\)$/.test(name);
69
69
  const isParamDir = (name: string) => /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
70
+ // [[slug]] / [[...slug]] — match like their required forms, and ALSO match the
71
+ // segment being absent (the param is then simply missing from ctx.params).
72
+ const isOptionalDir = (name: string) => /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
73
+ const isOptionalCatchAllDir = (name: string) => /^\[\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
70
74
  const isCatchAllDir = (name: string) => /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
71
- const paramName = (name: string) => name.replace(/^\[(\.\.\.)?|\]$/g, "");
75
+ const paramName = (name: string) => name.replace(/^\[+(\.\.\.)?|\]+$/g, "");
72
76
 
73
77
  type DirEntry = { name: string; dir: boolean };
74
78
 
@@ -164,7 +168,16 @@ export async function matchRouteTree(
164
168
  if (hit) return hit;
165
169
  }
166
170
 
167
- if (rest.length === 0) return null;
171
+ if (rest.length === 0) {
172
+ // Optional segments match ABSENCE too: descend without consuming and
173
+ // without setting the param.
174
+ for (const e of entries) {
175
+ if (!e.dir || !(isOptionalDir(e.name) || isOptionalCatchAllDir(e.name))) continue;
176
+ const hit = await descend(join(dir, e.name), [], params, segments);
177
+ if (hit) return hit;
178
+ }
179
+ return null;
180
+ }
168
181
  const [head, ...tail] = rest as [string, ...string[]];
169
182
 
170
183
  // 1) exact static dir
@@ -173,9 +186,9 @@ export async function matchRouteTree(
173
186
  const hit = await descend(join(dir, head), tail, params, segments);
174
187
  if (hit) return hit;
175
188
  }
176
- // 2) [param] dirs
189
+ // 2) [param] and [[param]] dirs consume one segment
177
190
  for (const e of entries) {
178
- if (!e.dir || !isParamDir(e.name)) continue;
191
+ if (!e.dir || !(isParamDir(e.name) || isOptionalDir(e.name))) continue;
179
192
  const hit = await descend(
180
193
  join(dir, e.name),
181
194
  tail,
@@ -184,9 +197,9 @@ export async function matchRouteTree(
184
197
  );
185
198
  if (hit) return hit;
186
199
  }
187
- // 3) [...catchAll] dirs consume everything remaining
200
+ // 3) [...catchAll] and [[...catchAll]] dirs consume everything remaining
188
201
  for (const e of entries) {
189
- if (!e.dir || !isCatchAllDir(e.name)) continue;
202
+ if (!e.dir || !(isCatchAllDir(e.name) || isOptionalCatchAllDir(e.name))) continue;
190
203
  const hit = await descend(
191
204
  join(dir, e.name),
192
205
  [],
package/src/worker.ts CHANGED
@@ -41,26 +41,45 @@ export type WorkerManifest = {
41
41
 
42
42
  type Compiled = { regex: RegExp; names: string[]; def: BrandedRoute; pattern: string };
43
43
 
44
- // "/posts/[slug]" | "/docs/[...path]" → matcher
44
+ // "/posts/[slug]" | "/docs/[...path]" | "/notes/[[tag]]" → matcher. Optional
45
+ // segments ([[x]], [[...x]]) wrap slash + capture together so absence matches;
46
+ // a pattern of ONLY optional segments must also match the bare "/".
45
47
  function compilePattern(pattern: string): { regex: RegExp; names: string[] } {
46
48
  const names: string[] = [];
47
- const source = pattern
48
- .split("/")
49
- .map((seg) => {
50
- const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
51
- if (ca) {
52
- names.push(ca[1]!);
53
- return "(.+)";
54
- }
55
- const p = seg.match(/^\[(\w+)\]$/);
56
- if (p) {
57
- names.push(p[1]!);
58
- return "([^/]+)";
59
- }
60
- return seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
61
- })
62
- .join("/");
63
- return { regex: new RegExp(`^${source}$`), names };
49
+ let source = "";
50
+ let allOptional = true;
51
+ for (const seg of pattern.split("/").filter(Boolean)) {
52
+ const oca = seg.match(/^\[\[\.\.\.(\w+)\]\]$/);
53
+ if (oca) {
54
+ names.push(oca[1]!);
55
+ source += "(?:/(.+))?";
56
+ continue;
57
+ }
58
+ const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
59
+ if (ca) {
60
+ names.push(ca[1]!);
61
+ source += "/(.+)";
62
+ allOptional = false;
63
+ continue;
64
+ }
65
+ const op = seg.match(/^\[\[(\w+)\]\]$/);
66
+ if (op) {
67
+ names.push(op[1]!);
68
+ source += "(?:/([^/]+))?";
69
+ continue;
70
+ }
71
+ const p = seg.match(/^\[(\w+)\]$/);
72
+ if (p) {
73
+ names.push(p[1]!);
74
+ source += "/([^/]+)";
75
+ allOptional = false;
76
+ continue;
77
+ }
78
+ source += "/" + seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
79
+ allOptional = false;
80
+ }
81
+ if (source === "") return { regex: /^\/$/, names };
82
+ return { regex: new RegExp(allOptional ? `^(?:${source}|/)$` : `^${source}$`), names };
64
83
  }
65
84
 
66
85
  export function createWorker(manifest: WorkerManifest): { fetch(request: Request): Promise<Response> } {
@@ -91,7 +110,14 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
91
110
  for (const d of dynamic) {
92
111
  const m = pathname.match(d.regex);
93
112
  if (m) {
94
- const params = Object.fromEntries(d.names.map((n, i) => [n, decodeURIComponent(m[i + 1]!)]));
113
+ // Optional segments leave their capture undefined — the param is
114
+ // then absent, matching the dev matcher's semantics.
115
+ const params = Object.fromEntries(
116
+ d.names.flatMap((n, i) => {
117
+ const v = m[i + 1];
118
+ return v === undefined ? [] : [[n, decodeURIComponent(v)]];
119
+ }),
120
+ );
95
121
  return { def: d.def, params, chain: chainFor(d.pattern) };
96
122
  }
97
123
  }
@@ -99,3 +125,65 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
99
125
  },
100
126
  });
101
127
  }
128
+
129
+ // --- the deployed worker's outermost layer (run_worker_first) ---------------
130
+ // Prerendered pages are served as static ASSETS, which bypass the pipeline —
131
+ // so the agent-ready signals the asset layer can't produce get added HERE:
132
+ // 1. `Accept: text/markdown` on a page → the prerendered `.md` asset.
133
+ // 2. a `Link` header on every HTML response (RFC 8288/9727 discovery).
134
+ // 3. `x-markdown-tokens` on markdown responses.
135
+ // Static assets are served by the ASSETS binding (no re-render); only genuinely
136
+ // dynamic routes reach the pipeline. With no ASSETS binding (no prerender), this
137
+ // is a transparent pass-through to the pipeline.
138
+ type AssetEnv = { ASSETS?: { fetch(request: Request): Promise<Response> } };
139
+ type FetchPipeline = { fetch(request: Request): Promise<Response> };
140
+
141
+ const estimateTokens = (s: string) => String(Math.ceil(s.length / 4));
142
+
143
+ export function withAssets(
144
+ pipeline: FetchPipeline,
145
+ opts: { link?: string | null } = {},
146
+ ): { fetch(request: Request, env?: AssetEnv): Promise<Response> } {
147
+ return {
148
+ async fetch(request, env) {
149
+ const assets = env?.ASSETS;
150
+ const url = new URL(request.url);
151
+ const isPagePath = !/\.[a-z0-9]+$/i.test(url.pathname); // no file extension
152
+ const accept = request.headers.get("accept") ?? "";
153
+
154
+ // 1. Markdown content negotiation on a page path → prerendered .md asset.
155
+ if (assets && request.method === "GET" && isPagePath && /text\/markdown/.test(accept)) {
156
+ const base = url.pathname === "/" ? "/index" : url.pathname.replace(/\/+$/, "");
157
+ const mdUrl = new URL(url);
158
+ mdUrl.pathname = `${base}.md`;
159
+ const a = await assets.fetch(new Request(mdUrl.toString(), { headers: request.headers }));
160
+ if (a.ok) {
161
+ const body = await a.text();
162
+ const headers = new Headers(a.headers);
163
+ headers.set("content-type", "text/markdown; charset=utf-8");
164
+ headers.set("x-markdown-tokens", estimateTokens(body));
165
+ if (opts.link) headers.set("link", opts.link);
166
+ return new Response(body, { status: 200, headers });
167
+ }
168
+ // no prerendered .md → fall through; a dynamic route renders it below.
169
+ }
170
+
171
+ // 2. Static assets (prerendered HTML/.md/.json, /client.js) served direct.
172
+ if (assets) {
173
+ const a = await assets.fetch(request);
174
+ if (a.status !== 404) {
175
+ const ct = a.headers.get("content-type") ?? "";
176
+ if (opts.link && ct.includes("text/html") && !a.headers.has("link")) {
177
+ const headers = new Headers(a.headers);
178
+ headers.set("link", opts.link);
179
+ return new Response(a.body, { status: a.status, headers });
180
+ }
181
+ return a;
182
+ }
183
+ }
184
+
185
+ // 3. Dynamic routes → the render pipeline (already sets Link + negotiates).
186
+ return pipeline.fetch(request);
187
+ },
188
+ };
189
+ }