@junejs/server 1.0.0-dev.29 → 1.0.0-dev.36

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 (75) hide show
  1. package/dist/agent-compile.d.ts +8 -9
  2. package/dist/agent-discover.d.ts +3 -4
  3. package/dist/agent-durable.d.ts +20 -19
  4. package/dist/agent-durable.js +41 -6
  5. package/dist/agent-native.d.ts +10 -11
  6. package/dist/agent-native.js +6 -2
  7. package/dist/app.d.ts +4 -5
  8. package/dist/app.js +2 -1
  9. package/dist/blob.d.ts +4 -5
  10. package/dist/build.d.ts +6 -6
  11. package/dist/build.js +34 -7
  12. package/dist/client-bundle.d.ts +8 -9
  13. package/dist/{config-freeze-DwI-Rmvq.d.ts → config-freeze-ZWC_Qd4h.d.ts} +1 -1
  14. package/dist/config-freeze.d.ts +1 -1
  15. package/dist/config-loader.d.ts +3 -4
  16. package/dist/connection-auth.d.ts +8 -9
  17. package/dist/content.d.ts +9 -10
  18. package/dist/core-version.d.ts +3 -4
  19. package/dist/css-modules-ambient.d.ts +2 -1
  20. package/dist/css-modules.d.ts +10 -11
  21. package/dist/css.d.ts +9 -10
  22. package/dist/d1-migrate.d.ts +6 -7
  23. package/dist/d1-migrate.js +3 -2
  24. package/dist/db.d.ts +2 -2
  25. package/dist/deploy.d.ts +3 -4
  26. package/dist/deploy.js +3 -2
  27. package/dist/dev-reload.d.ts +3 -4
  28. package/dist/dev.d.ts +4 -5
  29. package/dist/favicon.d.ts +16 -17
  30. package/dist/host.d.ts +5 -5
  31. package/dist/icon-letter.d.ts +2 -3
  32. package/dist/index.d.ts +4 -4
  33. package/dist/instrumentation.d.ts +2 -3
  34. package/dist/instrumentation.js +2 -1
  35. package/dist/island-registry.d.ts +6 -7
  36. package/dist/isolate-local.d.ts +4 -5
  37. package/dist/kv.d.ts +3 -4
  38. package/dist/llms-links.d.ts +8 -5
  39. package/dist/llms-links.js +31 -1
  40. package/dist/{manifest-gcNWqWoI.d.ts → manifest-DSNX4I-G.d.ts} +2 -2
  41. package/dist/manifest.d.ts +1 -1
  42. package/dist/negotiate.d.ts +4 -3
  43. package/dist/negotiate.js +24 -14
  44. package/dist/{pipeline-cCR6MGNK.d.ts → pipeline-BHUjBO0G.d.ts} +5 -1
  45. package/dist/pipeline.d.ts +2 -2
  46. package/dist/pipeline.js +260 -41
  47. package/dist/resources.d.ts +5 -6
  48. package/dist/router.d.ts +12 -13
  49. package/dist/rsc-build.d.ts +6 -7
  50. package/dist/rsc-bundle.d.ts +6 -7
  51. package/dist/rsc-manifest.d.ts +5 -6
  52. package/dist/rsc-runtime/flight-render-routes.d.ts +3 -4
  53. package/dist/rsc-runtime/flight-render.d.ts +2 -3
  54. package/dist/rsc-runtime/flight-to-html.d.ts +2 -3
  55. package/dist/rsc-runtime/flight-to-html.js +3 -2
  56. package/dist/rsc-runtime/rsdw-shims.d.ts +2 -1
  57. package/dist/segment.d.ts +4 -5
  58. package/dist/sqlite-driver.d.ts +10 -11
  59. package/dist/static-files.d.ts +4 -5
  60. package/dist/tsconfig-jsx.d.ts +4 -5
  61. package/dist/turn-events.d.ts +2 -3
  62. package/dist/web-manifest.d.ts +3 -4
  63. package/dist/web-manifest.js +2 -6
  64. package/dist/{worker-BkFHGtxp.d.ts → worker-Dv4l6Ybs.d.ts} +4 -2
  65. package/dist/worker.d.ts +1 -1
  66. package/dist/worker.js +35 -18
  67. package/package.json +5 -5
  68. package/src/agent-durable.ts +39 -10
  69. package/src/agent-native.ts +5 -1
  70. package/src/build.ts +63 -5
  71. package/src/llms-links.ts +46 -1
  72. package/src/negotiate.ts +39 -15
  73. package/src/pipeline.ts +433 -45
  74. package/src/web-manifest.ts +2 -6
  75. package/src/worker.ts +55 -17
package/src/pipeline.ts CHANGED
@@ -24,20 +24,43 @@ import {
24
24
  type RenderTarget,
25
25
  type RouteContext,
26
26
  } from "@junejs/core/route";
27
- import { Document, type DocumentConfig } from "@junejs/core/document";
28
27
  import {
28
+ Document,
29
+ documentTitle,
30
+ pageCanonical,
31
+ PRERENDER_ORIGIN,
32
+ type Breadcrumb,
33
+ type DocumentConfig,
34
+ } from "@junejs/core/document";
35
+ import {
36
+ AGENT_SKILLS_INDEX_PATH,
37
+ AI_CATALOG_PATH,
38
+ API_CATALOG_CONTENT_TYPE,
39
+ ARD_PATH,
40
+ agentSkillsIndex,
41
+ aiCatalog,
29
42
  apiCatalog,
30
43
  buildLinkHeader,
31
44
  llmsTxt,
45
+ MCP_SERVER_CARD_TYPE,
32
46
  mcpServerCard,
33
47
  robotsTxt,
48
+ siteSkill,
34
49
  sitemapXml,
35
50
  } from "@junejs/core/discovery";
36
- import { mcpHandler, mcpTools } from "@junejs/core/mcp";
51
+ import { mcpHandler, mcpServerIdentity, mcpTools } from "@junejs/core/mcp";
52
+ import {
53
+ apiActionId,
54
+ apiHandler,
55
+ apiNamespaceResponse,
56
+ isApiNamespace,
57
+ OPENAPI_MEDIA_TYPE,
58
+ openApiDocument,
59
+ } from "@junejs/core/api";
37
60
  import type { Principal, Session } from "@junejs/core/context";
38
61
 
39
62
  import { ensureScope, runInScope, setRequestLocale } from "@junejs/db";
40
- import type { AgentConfig } from "@junejs/core/config";
63
+ import { siteShortName, type AgentConfig } from "@junejs/core/config";
41
64
  import {
42
65
  LOCALE_COOKIE,
43
66
  localeAlternates,
@@ -50,7 +73,7 @@ import {
50
73
  import type { Resources } from "@junejs/core/resources";
51
74
 
52
75
  import { iconLetter } from "./icon-letter";
53
- import { collectLlmsLinks } from "./llms-links";
76
+ import { collectLlmsLinks, collectSitemapPages } from "./llms-links";
54
77
  import { webManifest } from "./web-manifest";
55
78
  import { negotiate, TITLE_HEADER, SEGMENT_HEADER, encodeTitle } from "./negotiate";
56
79
 
@@ -122,6 +145,11 @@ export type PipelineConfig = {
122
145
  // re-scan the filesystem; the worker returns a frozen array.
123
146
  routeList: () => Promise<string[]> | string[];
124
147
  resolve: RouteResolver;
148
+ // True only while the static() build prerenders: /sitemap.xml may then list a
149
+ // dynamic route's `staticPaths`, which the route contract confines to that
150
+ // build (they may do build-only or expensive work). At runtime a crawler's
151
+ // /sitemap.xml enumerates dynamic pages from their `llms` entries alone.
152
+ staticBuild?: boolean;
125
153
  // Opened data resources (db/kv/blob) injected onto ctx before load(). A
126
154
  // provider so opening is lazy/memoized; absent → no resources on ctx.
127
155
  resources?: () => Promise<Resources> | Resources;
@@ -171,6 +199,9 @@ const DefaultNotFound: React.ComponentType<{ pathname: string }> = ({ pathname }
171
199
  React.createElement("p", null, pathname),
172
200
  );
173
201
 
202
+ // Catalogs crawlers and browser-based agents fetch cross-origin (ARD requires it).
203
+ const CORS = { "access-control-allow-origin": "*" };
204
+
174
205
  function text(body: string, contentType: string, init?: ResponseInit) {
175
206
  const headers = new Headers(init?.headers);
176
207
  headers.set("content-type", contentType);
@@ -218,19 +249,91 @@ function letterFavicon(siteName: string | undefined): Response {
218
249
  );
219
250
  }
220
251
 
221
- type PageProps = { pageUrl: string; isHome: boolean; onLocaleDomain: boolean };
252
+ // A page URL's markdown twin — the same paths `june build` prerenders: /index.md
253
+ // for the home page (and a locale home, /zh-cn/index.md), <path>.md otherwise.
254
+ // A trailing slash is dropped first: /users/ is the same page as /users, whose
255
+ // twin is the flat /users.md (never /users/index.md). A home reached through its
256
+ // /index alias (/index, /de/index) is the same home: /de/index → /de/index.md.
257
+ // Only a HOME folds /index — /docs/index is its own route, twin /docs/index.md.
258
+ export function markdownPath(pathname: string, isHome: boolean): string {
259
+ const path = pathname.replace(/\/+$/, "");
260
+ return isHome ? `${path.replace(/\/index$/, "")}/index.md` : `${path}.md`;
261
+ }
262
+
263
+ // The inverse: the path a page's HTML form lives at, for the markdown
264
+ // projection's canonical — /docs/x.md → /docs/x; for a home, its /index alias
265
+ // folds away (/index.md → /, /de/index.md and /de/index → /de). A non-home
266
+ // /docs/index(.md) stays /docs/index.
267
+ export function htmlPath(pathname: string, isHome: boolean): string {
268
+ const path = pathname.endsWith(".md") ? pathname.slice(0, -3) : pathname;
269
+ if (!isHome) return path;
270
+ return path.replace(/\/+$/, "").replace(/\/index$/, "") || "/";
271
+ }
272
+
273
+ // The MCP Server Card's discovery contract (experimental-ext-server-card
274
+ // docs/discovery.md): its own media type (MCP_SERVER_CARD_TYPE, shared with the
275
+ // api-catalog and Link header), and CORS so browser clients can read it.
276
+ const SERVER_CARD_PATH = "/.well-known/mcp/server-card.json";
277
+ const SERVER_CARD_CORS = {
278
+ "access-control-allow-origin": "*",
279
+ // HEAD too: the discovery gate answers it on every surface (same headers, no body).
280
+ "access-control-allow-methods": "GET, HEAD",
281
+ "access-control-allow-headers": "Content-Type, If-None-Match",
282
+ "access-control-expose-headers": "ETag",
283
+ };
284
+
285
+ type PageProps = {
286
+ pageUrl: string;
287
+ isHome: boolean;
288
+ onLocaleDomain: boolean;
289
+ markdownHref?: string;
290
+ breadcrumbs?: Breadcrumb[];
291
+ };
292
+
293
+ // A URL segment as a crumb label, for a page whose title isn't static: decoded,
294
+ // "-"/"_" as spaces, first letter capitalized (scripts without case are unchanged).
295
+ function humanizeSegment(segment: string): string {
296
+ let s = segment;
297
+ try {
298
+ s = decodeURIComponent(segment);
299
+ } catch {
300
+ // an undecodable segment never reaches here (resolveRoute refuses the path)
301
+ }
302
+ s = s.replace(/[-_]+/g, " ").trim();
303
+ return s.charAt(0).toUpperCase() + s.slice(1);
304
+ }
222
305
 
223
306
  // A request's matched route pathname (locale stripped, /index → /), keyed by its
224
307
  // ctx — RouteContext is public API, so the pipeline tracks it on the side.
225
308
  const routePathOf = new WeakMap<RouteContext, string>();
226
309
 
310
+ // Whether every "/"-separated segment of a path percent-decodes (a malformed
311
+ // escape like "%ZZ" or a truncated UTF-8 sequence doesn't). The only thing
312
+ // decodeURIComponent can throw is URIError, so any failure means "no".
313
+ export function isDecodablePath(pathname: string): boolean {
314
+ try {
315
+ for (const segment of pathname.split("/")) decodeURIComponent(segment);
316
+ return true;
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+
227
322
  export function createPipeline(cfg: PipelineConfig): Pipeline {
228
323
  const { docConfig, agent } = cfg;
229
324
  const NotFound = cfg.notFoundComponent ?? DefaultNotFound;
325
+ // The origin-independent half of catalogOrigin() below — all a response header
326
+ // can know (a static build publishes no headers, so the prerender-origin half
327
+ // never applies to one).
328
+ const publishesCatalogs = agent.discovery && !docConfig.basePath;
230
329
 
231
330
  function htmlHeaders(): Headers {
232
- const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
233
- const links = [buildLinkHeader(agent), ...(cfg.earlyHints ?? [])].filter(Boolean) as string[];
331
+ // Every pipeline document is one variant of an Accept-negotiated URL (its .md
332
+ // and .json answer at the same path) — including streamed pages and the 404.
333
+ const headers = new Headers({ "content-type": "text/html; charset=utf-8", vary: "accept" });
334
+ const links = [buildLinkHeader(agent, { catalogs: publishesCatalogs }), ...(cfg.earlyHints ?? [])].filter(
335
+ Boolean,
336
+ ) as string[];
234
337
  if (links.length) headers.set("link", links.join(", "));
235
338
  if (cfg.htmlCacheControl) headers.set("cache-control", cfg.htmlCacheControl);
236
339
  return headers;
@@ -262,15 +365,60 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
262
365
  // The document's page-identity props: the URL for og:url/canonical, whether the
263
366
  // MATCHED route is home (so /de and /index count, per negotiate's normalized
264
367
  // pathname), and whether the host is a locale's own domain (its public origin).
265
- function pageProps(ctx: RouteContext): PageProps {
266
- const host = ctx.url.hostname.toLowerCase();
368
+ function pageProps(ctx: RouteContext, def: BrandedRoute): PageProps {
369
+ const isHome = routePathOf.get(ctx) === "/";
267
370
  return {
268
371
  pageUrl: ctx.url.href,
269
- isHome: routePathOf.get(ctx) === "/",
270
- onLocaleDomain: cfg.i18n
271
- ? Object.values(cfg.i18n.locales).some((l) => l.domain?.toLowerCase() === host)
272
- : false,
372
+ isHome,
373
+ onLocaleDomain: onLocaleDomain(ctx),
374
+ // The page's markdown twin, advertised as a discovery signal (so gated with
375
+ // the rest of discovery). A disabled md projection gets no link to a 404.
376
+ markdownHref: agent.discovery && def.md !== false ? markdownPath(ctx.url.pathname, isHome) : undefined,
377
+ };
378
+ }
379
+
380
+ // The page's breadcrumb trail (Metadata.breadcrumb): home first, this page last.
381
+ // Derived from the URL unless the route declares its own (or false). An ancestor
382
+ // path gets a crumb only if it RESOLVES to a page matched with no params — a
383
+ // static route, or an optional segment's bare match: a page that exists by
384
+ // declaration. A dynamic match (/docs/[slug] for /docs/x) exists only if its data
385
+ // does, which would take a load to know, so it is skipped, never guessed. Each
386
+ // ancestor costs one route resolve (no load); its label is the route's static
387
+ // metadata.title, else its humanized URL segment — the URL is verified, only the
388
+ // label is derived, and a gap in the trail would misstate the hierarchy more.
389
+ // Paths are request paths: a locale prefix (/de/…) carries through, so the
390
+ // home crumb is the locale's home.
391
+ async function breadcrumbsFor(ctx: RouteContext, meta: Metadata | undefined): Promise<Breadcrumb[] | undefined> {
392
+ const routePath = routePathOf.get(ctx);
393
+ if (!routePath || routePath === "/" || meta?.breadcrumb === false) return undefined;
394
+ const urlPath = ctx.url.pathname.replace(/\/+$/, "") || "/";
395
+ const route = routePath.replace(/\/+$/, "");
396
+ // The request path minus the route path = the locale prefix ("" on the default
397
+ // locale or a locale's own domain).
398
+ const prefix = urlPath.endsWith(route) ? urlPath.slice(0, urlPath.length - route.length) : "";
399
+ const home: Breadcrumb = {
400
+ name: siteShortName(docConfig.site) ?? documentTitle(undefined, docConfig.site),
401
+ path: prefix || "/",
273
402
  };
403
+ if (Array.isArray(meta?.breadcrumb)) return [home, ...meta.breadcrumb];
404
+ const segments = route.split("/").filter(Boolean);
405
+ const trail: Breadcrumb[] = [home];
406
+ for (let i = 1; i < segments.length; i++) {
407
+ const ancestor = "/" + segments.slice(0, i).join("/");
408
+ const r = await resolveRoute(ancestor);
409
+ if (!r || "handler" in r || Object.keys(r.params).length > 0) continue;
410
+ const title = typeof r.def.metadata === "object" ? r.def.metadata?.title : undefined;
411
+ trail.push({ name: title || humanizeSegment(segments[i - 1]!), path: prefix + ancestor });
412
+ }
413
+ trail.push({ name: meta?.title || humanizeSegment(segments[segments.length - 1]!), path: urlPath });
414
+ return trail;
415
+ }
416
+
417
+ function onLocaleDomain(ctx: RouteContext): boolean {
418
+ const host = ctx.url.hostname.toLowerCase();
419
+ return cfg.i18n
420
+ ? Object.values(cfg.i18n.locales).some((l) => l.domain?.toLowerCase() === host)
421
+ : false;
274
422
  }
275
423
 
276
424
  async function renderDocument(
@@ -290,7 +438,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
290
438
  );
291
439
  const stream = await renderToReadableStream(
292
440
  React.createElement(Document, {
293
- config: docConfigForRender(),
441
+ config: docConfigForRender(page?.pageUrl),
294
442
  metadata,
295
443
  children: wrapped,
296
444
  shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
@@ -349,9 +497,17 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
349
497
  // WebMCP: register the app's actions as browser tools. Computed per render
350
498
  // from the live registry (stable after warmup), gated on agent.webmcp + mcp
351
499
  // (execute proxies to /mcp). No actions → no script → page stays zero-JS.
352
- function docConfigForRender(): DocumentConfig {
500
+ function docConfigForRender(pageUrl?: string): DocumentConfig {
353
501
  const webmcpTools = agent.webmcp && agent.mcp ? mcpTools() : null;
354
- return webmcpTools?.length ? { ...docConfig, webmcpTools } : docConfig;
502
+ // <link rel="ai-catalog"> — the AI Catalog spec's in-page pointer (ARD) —
503
+ // only where this pipeline actually serves the catalog for the page's origin
504
+ // (never under a basePath, never on a static build without a public origin).
505
+ const servesCatalog = !!pageUrl && catalogOrigin(new URL(pageUrl)) !== null;
506
+ return {
507
+ ...docConfig,
508
+ ...(webmcpTools?.length ? { webmcpTools } : {}),
509
+ ...(servesCatalog ? { aiCatalog: AI_CATALOG_PATH } : {}),
510
+ };
355
511
  }
356
512
 
357
513
  // Streaming Suspense: the shell (layout chain + the loading.tsx fallback)
@@ -363,6 +519,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
363
519
  resolved: Resolved,
364
520
  loadPromise: Promise<unknown>,
365
521
  ctx: RouteContext,
522
+ breadcrumbs: Breadcrumb[] | undefined,
366
523
  ): Promise<Response> {
367
524
  const { def, chain, loading: Loading, boundaryKey } = resolved;
368
525
  const leaf = React.createElement(StreamedView, { loadPromise, def, ctx });
@@ -376,14 +533,17 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
376
533
  boundary,
377
534
  );
378
535
  const metadata = typeof def.metadata === "object" ? def.metadata : undefined;
536
+ // No await between here and React taking loadPromise (see the caller); the
537
+ // breadcrumb trail is resolved before load() starts.
379
538
  const stream = await renderToReadableStream(
380
539
  React.createElement(Document, {
381
- config: docConfigForRender(),
540
+ config: docConfigForRender(ctx.url.href),
382
541
  metadata,
383
542
  children: wrapped,
384
543
  shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
385
544
  alternates: alternatesFor(ctx),
386
- ...pageProps(ctx),
545
+ ...pageProps(ctx, def),
546
+ breadcrumbs,
387
547
  ...langDir(ctx.locale),
388
548
  }),
389
549
  { onError: (e: unknown) => console.error("[june] streaming render error:", e) },
@@ -393,21 +553,94 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
393
553
  return new Response(stream, { status: 200, headers: htmlHeaders() });
394
554
  }
395
555
 
556
+ // Route resolution for a request path. Every resolver (the dev tree matcher,
557
+ // the built worker's dynamic + resource tables) percent-decodes URL segments,
558
+ // and would throw URIError on a malformed escape (/docs/%ZZ). Such a path can
559
+ // match no route by definition, so it is refused BEFORE resolving — a miss
560
+ // (the normal negotiated 404, or the API's JSON 404 under /api) — rather than
561
+ // catching around cfg.resolve(), which also imports route modules: a genuine
562
+ // error there, URIError included, must still surface. Checking each segment
563
+ // suffices for every resolver: their captures are whole segments or runs of
564
+ // them joined by "/", which decode iff each segment does.
565
+ async function resolveRoute(pathname: string): ReturnType<RouteResolver> {
566
+ return isDecodablePath(pathname) ? cfg.resolve(pathname) : null;
567
+ }
568
+
396
569
  function notFoundResponse(
397
570
  target: RenderTarget,
398
571
  pathname: string,
399
572
  locale?: string,
573
+ // Passed only when ROUTING found nothing (not when a matched route 404s): an
574
+ // unmatched path in June's REST namespace (/api) is an API miss, answered
575
+ // with the API's own JSON error/index whatever Accept negotiated — app
576
+ // routes under /api were already given their chance.
577
+ unmatched?: Request,
400
578
  ): Promise<Response> | Response {
401
- // Data clients get a JSON 404; humans get the rendered NotFound document.
402
- return target !== "view"
403
- ? Response.json({ error: "Not Found", path: pathname }, { status: 404 })
404
- : renderDocument(
405
- React.createElement(NotFound, { pathname }),
406
- { title: "Not found", robots: "noindex" },
407
- 404,
408
- [],
409
- locale,
410
- );
579
+ if (unmatched && agent.api) {
580
+ const url = new URL(unmatched.url);
581
+ if (isApiNamespace(url.pathname)) return apiNamespaceResponse(unmatched, url.origin);
582
+ }
583
+ // Agents get a 404 they can act on: Markdown for an Accept: text/markdown (or
584
+ // .md) request, structured JSON for other data clients — both pointing at the
585
+ // discovery surfaces. Humans get the rendered NotFound document. The body
586
+ // depends on Accept, so every variant says so to caches.
587
+ if (target === "md") {
588
+ return discoveryLinks().then((links) =>
589
+ text(notFoundMarkdown(pathname, links), "text/markdown; charset=utf-8", {
590
+ status: 404,
591
+ headers: { vary: "accept" },
592
+ }),
593
+ );
594
+ }
595
+ if (target !== "view") {
596
+ return discoveryLinks().then((links) =>
597
+ Response.json(
598
+ { error: "Not Found", code: "not_found", path: pathname, hint: notFoundHint(links) },
599
+ { status: 404, headers: { vary: "accept" } },
600
+ ),
601
+ );
602
+ }
603
+ return renderDocument(
604
+ React.createElement(NotFound, { pathname }),
605
+ { title: "Not found", robots: "noindex" },
606
+ 404,
607
+ [],
608
+ locale,
609
+ );
610
+ }
611
+
612
+ // Where an agent that hit a dead end should look next — only surfaces this app
613
+ // actually serves: /index.md only when a root page exists with its md live.
614
+ async function discoveryLinks(): Promise<Array<[label: string, href: string]>> {
615
+ const links: Array<[string, string]> = [];
616
+ if (agent.discovery) {
617
+ links.push(["llms.txt — the site index for agents", "/llms.txt"], ["sitemap.xml — every page", "/sitemap.xml"]);
618
+ }
619
+ if (agent.mcp) links.push(["MCP server — this site's tools", "/mcp"]);
620
+ if (agent.api) links.push(["OpenAPI — the same tools over HTTP", "/openapi.json"]);
621
+ const home = await cfg.resolve("/");
622
+ if (home && "def" in home && home.def.md !== false) links.push(["Home page (Markdown)", "/index.md"]);
623
+ return links;
624
+ }
625
+
626
+ function notFoundHint(links: Array<[string, string]>): string {
627
+ return links.length
628
+ ? `No page at this path. Start from ${links.map(([, h]) => h).join(", ")}.`
629
+ : "No page at this path.";
630
+ }
631
+
632
+ function notFoundMarkdown(pathname: string, links: Array<[string, string]>): string {
633
+ const site = docConfig.site.name ? ` on ${docConfig.site.name}` : "";
634
+ return [
635
+ "# 404 — Not found",
636
+ "",
637
+ links.length
638
+ ? `There is no page at \`${pathname}\`${site}. Try one of these instead:`
639
+ : `There is no page at \`${pathname}\`${site}.`,
640
+ "",
641
+ ...links.map(([label, href]) => `- [${label}](${href})`),
642
+ "",
643
+ ].join("\n");
411
644
  }
412
645
 
413
646
  function resolveMeta(def: BrandedRoute, data: unknown, ctx: RouteContext): Metadata | undefined {
@@ -421,16 +654,38 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
421
654
  // json is also absent). md/json === false is handled as 404 by the caller.
422
655
  const jsonData =
423
656
  typeof def.json === "function" ? await def.json(data, ctx) : def.json === false ? null : data;
424
- const body =
657
+ const content =
425
658
  typeof def.md === "function"
426
659
  ? await def.md(data, ctx)
427
660
  : "```json\n" + JSON.stringify(jsonData, null, 2) + "\n```\n";
661
+ const body = withFrontmatter(content, resolveMeta(def, data, ctx), ctx);
428
662
  // x-markdown-tokens: a rough estimate (~4 chars/token) agents use to budget.
429
663
  return text(body, "text/markdown; charset=utf-8", {
430
664
  headers: { "x-markdown-tokens": String(Math.ceil(body.length / 4)) },
431
665
  });
432
666
  }
433
667
 
668
+ // Open served markdown with a frontmatter block (title, description, canonical)
669
+ // so an agent gets the page's metadata without scraping the HTML. A body that
670
+ // already opens with its own frontmatter (e.g. a content file served verbatim)
671
+ // is left alone. Values are JSON strings — valid YAML double-quoted scalars.
672
+ function withFrontmatter(body: string, meta: Metadata | undefined, ctx: RouteContext): string {
673
+ if (/^---\r?\n/.test(body)) return body;
674
+ // The page's OWN title, not the templated <title> ("Users", not "Users · Site"):
675
+ // the template is browser-tab branding, and authored frontmatter (served
676
+ // verbatim) carries the bare title too — so every .md reads the same way. The
677
+ // site name is only the fallback for a page with no title — an empty title
678
+ // counts as none, exactly as documentTitle() treats it.
679
+ const title = meta?.title || documentTitle(meta, docConfig.site);
680
+ const description = meta?.description ?? docConfig.site.description;
681
+ const canonical = pageCanonical(docConfig, meta, ctx.url.href, htmlPath(ctx.url.pathname, routePathOf.get(ctx) === "/"), onLocaleDomain(ctx));
682
+ const lines = ["---", `title: ${JSON.stringify(title)}`];
683
+ if (description) lines.push(`description: ${JSON.stringify(description)}`);
684
+ if (canonical) lines.push(`canonical: ${JSON.stringify(canonical)}`);
685
+ lines.push("---", "");
686
+ return lines.join("\n") + body;
687
+ }
688
+
434
689
  async function renderProjection(
435
690
  resolved: Resolved,
436
691
  target: RenderTarget,
@@ -476,28 +731,124 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
476
731
  // renders the whole chain (a hard load of the URL is never segment-scoped) —
477
732
  // but it stamps the shell key so the client knows which shell is mounted.
478
733
  if (target === "fragment") return renderFragment(node, meta, chain, boundaryIndex, boundaryKey);
479
- return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey, alternatesFor(ctx), pageProps(ctx));
734
+ const page = { ...pageProps(ctx, def), breadcrumbs: await breadcrumbsFor(ctx, meta) };
735
+ return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey, alternatesFor(ctx), page);
736
+ }
737
+
738
+ // The origin the agent catalogs name, or null when this site publishes none —
739
+ // the single rule for serving them, prerendering them, and advertising them
740
+ // (head link, Link header, robots Agentmap):
741
+ // • agent.discovery off → no discovery surface at all;
742
+ // • a basePath site doesn't own the domain root, where /.well-known lives;
743
+ // • a static build renders against the placeholder prerender host, which must
744
+ // never reach a published file: use the configured public origin (site.url,
745
+ // else deploy.domain) there, and publish nothing when neither is set.
746
+ function catalogOrigin(url: URL): string | null {
747
+ return publishesCatalogs ? publicOrigin(url) : null;
748
+ }
749
+
750
+ // The public origin that published discovery files (llms.txt, sitemap.xml,
751
+ // robots.txt, the agent catalogs) name. Live, it is the request's own origin.
752
+ // A prerender runs against the placeholder host, which must never reach a
753
+ // published file, so it uses the configured public origin: site.url's origin,
754
+ // else the deploy domain. null → unknown: publish no absolute URL at all.
755
+ function publicOrigin(url: URL): string | null {
756
+ if (url.origin !== PRERENDER_ORIGIN) return url.origin;
757
+ return docConfig.site.url ? new URL(docConfig.site.url).origin : (docConfig.deployOrigin ?? null);
758
+ }
759
+
760
+ // Where the site's pages live: the public origin plus the deploy basePath, so
761
+ // every absolute URL a discovery file lists resolves (a GitHub Pages project
762
+ // site is https://user.github.io/repo/…). null when the origin is unknown.
763
+ function publicBase(url: URL): string | null {
764
+ const origin = publicOrigin(url);
765
+ return origin === null ? null : origin + (docConfig.basePath ?? "");
766
+ }
767
+
768
+ // The agent catalogs: Agent Skills (index + the generated SKILL.md) and the
769
+ // ARD / AI Catalog. CORS-open — crawlers and browser agents fetch them cross-origin.
770
+ async function agentCatalog(url: URL): Promise<Response | null> {
771
+ const { pathname } = url;
772
+ if (!pathname.startsWith("/.well-known/")) return null;
773
+ const origin = catalogOrigin(url);
774
+ if (!origin) return null;
775
+ if (pathname === AGENT_SKILLS_INDEX_PATH) {
776
+ return Response.json(await agentSkillsIndex(origin, agent, docConfig.site), { headers: CORS });
777
+ }
778
+ if (pathname === AI_CATALOG_PATH || pathname === ARD_PATH) {
779
+ return Response.json(aiCatalog(origin, agent, docConfig.site), { headers: CORS });
780
+ }
781
+ // The skill's path carries its name; only the generated skill's own path answers.
782
+ const skill = siteSkill(origin, agent, docConfig.site);
783
+ if (pathname === new URL(skill.url).pathname) {
784
+ return text(skill.markdown, "text/markdown; charset=utf-8", { headers: CORS });
785
+ }
786
+ // The skills directory is ours while we serve it: any other path under it is
787
+ // a skill that doesn't exist, a 404 the RFC requires — never handed to app
788
+ // routing, where a catch-all would answer 200.
789
+ if (pathname.startsWith("/.well-known/agent-skills/")) {
790
+ return Response.json({ error: "Not Found", path: pathname }, { status: 404, headers: CORS });
791
+ }
792
+ return null;
480
793
  }
481
794
 
482
795
  async function discovery(url: URL): Promise<Response | null> {
796
+ const catalog = await agentCatalog(url);
797
+ if (catalog) return catalog;
483
798
  switch (url.pathname) {
484
799
  case "/llms.txt": {
485
800
  const routes = await cfg.routeList();
801
+ // With no known public origin (a static build without site.url or a deploy
802
+ // domain) the links are root-relative — llmstxt.org allows it — never the
803
+ // prerender placeholder.
804
+ const base = publicBase(url) ?? docConfig.basePath ?? "";
486
805
  // sections, descriptions, and Optional come from each route's `llms` export
487
- const links = await collectLlmsLinks(url.origin, routes, cfg.resolve);
488
- return text(llmsTxt(url.origin, routes, agent, docConfig.site, links), "text/markdown; charset=utf-8");
806
+ const links = await collectLlmsLinks(base, routes, cfg.resolve);
807
+ return text(llmsTxt(base, routes, agent, docConfig.site, links), "text/markdown; charset=utf-8");
808
+ }
809
+ case "/robots.txt": {
810
+ const base = publicBase(url);
811
+ if (base === null) return text("Not Found\n", "text/plain; charset=utf-8", { status: 404 });
812
+ return text(robotsTxt(base, { catalogs: catalogOrigin(url) !== null }), "text/plain; charset=utf-8");
489
813
  }
490
- case "/robots.txt":
491
- return text(robotsTxt(url.origin), "text/plain; charset=utf-8");
492
- case "/sitemap.xml":
814
+ case "/sitemap.xml": {
815
+ // The sitemap protocol requires absolute <loc>s: with no known public origin
816
+ // there is no correct sitemap, so none (the static build warns).
817
+ const base = publicBase(url);
818
+ if (base === null) return text("Not Found\n", "text/plain; charset=utf-8", { status: 404 });
493
819
  return text(
494
- sitemapXml(url.origin, await cfg.routeList(), cfg.i18n),
820
+ sitemapXml(
821
+ base,
822
+ await collectSitemapPages(await cfg.routeList(), cfg.resolve, {
823
+ i18n: !!cfg.i18n,
824
+ staticPaths: cfg.staticBuild === true,
825
+ }),
826
+ cfg.i18n,
827
+ ),
495
828
  "application/xml; charset=utf-8",
496
829
  );
830
+ }
497
831
  case "/.well-known/api-catalog":
498
- return text(JSON.stringify(apiCatalog(url.origin, agent)), "application/linkset+json");
499
- case "/.well-known/mcp/server-card.json":
500
- return agent.mcp ? Response.json(mcpServerCard(url.origin)) : null;
832
+ // RFC 9727 §2: a HEAD here SHALL carry a Link with rel="api-catalog". The
833
+ // gate answers HEAD with this GET's headers, so both carry it.
834
+ return text(JSON.stringify(apiCatalog(url.origin, agent)), API_CATALOG_CONTENT_TYPE, {
835
+ headers: { link: `</.well-known/api-catalog>; rel="api-catalog"` },
836
+ });
837
+ case SERVER_CARD_PATH:
838
+ return agent.mcp
839
+ ? text(
840
+ JSON.stringify(
841
+ mcpServerCard(url.origin, {
842
+ site: docConfig.site,
843
+ agent,
844
+ icons: docConfig.icons,
845
+ basePath: docConfig.basePath,
846
+ }),
847
+ ),
848
+ MCP_SERVER_CARD_TYPE,
849
+ { headers: { ...SERVER_CARD_CORS, "cache-control": "public, max-age=3600" } },
850
+ )
851
+ : null;
501
852
  default:
502
853
  return null;
503
854
  }
@@ -514,11 +865,42 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
514
865
  // the same principal: requiresPrincipal actions and per-call connection
515
866
  // auth are live on this surface. ctx carries identity only — not resources.
516
867
  const identity = cfg.identity ? await cfg.identity(request) : undefined;
517
- return mcpHandler(request, { request, ...identity });
868
+ return mcpHandler(
869
+ request,
870
+ { request, ...identity },
871
+ mcpServerIdentity(url.origin, { site: docConfig.site, agent }),
872
+ );
873
+ }
874
+ // The REST projection of the same actions: POST /api/<id> dispatches through
875
+ // the same invokeAction with the same principal as /mcp. Only a registered
876
+ // action's canonical path is claimed — any other /api/* falls through to the app.
877
+ // /openapi.json is the API's own description, so it is gated on agent.api (not
878
+ // agent.discovery) and answered here; the discovery gate below never sees it.
879
+ if (agent.api) {
880
+ if (url.pathname === "/openapi.json" && (request.method === "GET" || request.method === "HEAD")) {
881
+ const doc = Response.json(openApiDocument(url.origin, docConfig.site), {
882
+ // the same type the Link header + api-catalog advertise
883
+ headers: { "content-type": OPENAPI_MEDIA_TYPE, "access-control-allow-origin": "*" },
884
+ });
885
+ // HEAD: the same status + headers, no body.
886
+ return request.method === "HEAD" ? new Response(null, { status: doc.status, headers: doc.headers }) : doc;
887
+ }
888
+ const actionId = apiActionId(url.pathname);
889
+ if (actionId) {
890
+ const identity = cfg.identity ? await cfg.identity(request) : undefined;
891
+ return apiHandler(request, actionId, { request, ...identity });
892
+ }
893
+ }
894
+ // The server card is read cross-origin by browser-based MCP clients; its
895
+ // If-None-Match revalidation is a non-safelisted header → a CORS preflight.
896
+ if (request.method === "OPTIONS" && agent.discovery && agent.mcp && url.pathname === SERVER_CARD_PATH) {
897
+ return new Response(null, { status: 204, headers: SERVER_CARD_CORS });
518
898
  }
519
- if (request.method === "GET" && agent.discovery) {
899
+ // GET and HEAD (Agent Skills Discovery RFC v0.2.0 requires both): a HEAD
900
+ // gets the same status and headers, no body.
901
+ if ((request.method === "GET" || request.method === "HEAD") && agent.discovery) {
520
902
  const d = await discovery(url);
521
- if (d) return d;
903
+ if (d) return request.method === "HEAD" ? new Response(null, { status: d.status, headers: d.headers }) : d;
522
904
  }
523
905
 
524
906
  // --- durable agent surface (chat + channels) -------------------------
@@ -583,8 +965,8 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
583
965
 
584
966
  // --- routes ----------------------------------------------------------
585
967
  const { target, pathname, speculative } = negotiate(url, request, routeBase);
586
- const resolved = await cfg.resolve(pathname);
587
- if (!resolved) return notFoundResponse(target, pathname, locale);
968
+ const resolved = await resolveRoute(pathname);
969
+ if (!resolved) return notFoundResponse(target, pathname, locale, request);
588
970
 
589
971
  // ctx is identity/request only; db/kv/blob are ambient (read from the
590
972
  // request scope this whole handler runs inside — see runInScope below).
@@ -605,10 +987,16 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
605
987
  // static metadata flushes the shell + fallback before load() resolves.
606
988
  // (data-derived metadata can't stream — the <head> needs the title.)
607
989
  if (target === "view" && resolved.loading && typeof resolved.def.metadata !== "function") {
990
+ // Everything the shell awaits happens BEFORE load() starts: once the load
991
+ // promise exists, it must reach React (use()) with no await in between, or
992
+ // a load that rejects meanwhile is an unhandled rejection (fatal on a Node
993
+ // host with --unhandled-rejections=throw). Metadata is static on this path,
994
+ // so the breadcrumb trail doesn't depend on the load.
995
+ const breadcrumbs = await breadcrumbsFor(ctx, resolved.def.metadata);
608
996
  const loadPromise = Promise.resolve(
609
997
  resolved.def.load ? resolved.def.load(ctx) : undefined,
610
998
  );
611
- return renderStreamingDocument(resolved, loadPromise, ctx);
999
+ return renderStreamingDocument(resolved, loadPromise, ctx, breadcrumbs);
612
1000
  }
613
1001
 
614
1002
  let data: unknown;