@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 +2 -2
- package/src/app.ts +5 -4
- package/src/build.ts +53 -17
- package/src/db.ts +17 -4
- package/src/negotiate.ts +0 -2
- package/src/pipeline.ts +54 -20
- package/src/router.ts +19 -6
- package/src/worker.ts +107 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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 {
|
|
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 =
|
|
121
|
-
|
|
122
|
-
|
|
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 {
|
|
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 =
|
|
175
|
-
|
|
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
|
|
180
|
+
dynamicRoutes.push({ pattern: r.path, def });
|
|
179
181
|
layoutChains[r.path] = chain;
|
|
180
182
|
} else {
|
|
181
|
-
routes[r.path] =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
11
|
-
// on
|
|
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 ?? "
|
|
14
|
-
return {
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(/^\[(\.\.\.)?|\]
|
|
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)
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
+
}
|