@junejs/server 0.1.3 → 0.2.1

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 (131) hide show
  1. package/assets/icon-latin.LICENSE.txt +92 -0
  2. package/assets/icon-latin.ttf +0 -0
  3. package/dist/adapter-D0UnYBuN.d.ts +64 -0
  4. package/dist/adapter.d.ts +2 -56
  5. package/dist/adapter.js +107 -6
  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/assets.d.ts +23 -0
  17. package/dist/assets.js +123 -0
  18. package/dist/blob.d.ts +4 -5
  19. package/dist/build.d.ts +8 -30
  20. package/dist/build.js +143 -298
  21. package/dist/client-bundle.d.ts +8 -9
  22. package/dist/client-bundle.js +1 -1
  23. package/dist/config-freeze-r5p-vTWv.d.ts +17 -0
  24. package/dist/config-freeze.d.ts +2 -0
  25. package/dist/config-freeze.js +54 -0
  26. package/dist/config-loader.d.ts +3 -4
  27. package/dist/connection-auth.d.ts +38 -0
  28. package/dist/connection-auth.js +31 -0
  29. package/dist/content-freeze-CyiW2fRK.d.ts +4 -0
  30. package/dist/content-freeze.d.ts +2 -0
  31. package/dist/content-freeze.js +83 -0
  32. package/dist/content.d.ts +22 -9
  33. package/dist/content.js +43 -3
  34. package/dist/core-version.d.ts +4 -0
  35. package/dist/core-version.js +12 -0
  36. package/dist/css-modules-ambient.d.ts +2 -1
  37. package/dist/css-modules.d.ts +10 -11
  38. package/dist/css.d.ts +9 -10
  39. package/dist/d1-migrate.d.ts +6 -7
  40. package/dist/d1-migrate.js +3 -2
  41. package/dist/db.d.ts +2 -2
  42. package/dist/deploy.d.ts +3 -4
  43. package/dist/deploy.js +3 -2
  44. package/dist/dev-reload.d.ts +3 -4
  45. package/dist/dev.d.ts +8 -5
  46. package/dist/dev.js +40 -9
  47. package/dist/favicon.d.ts +37 -0
  48. package/dist/favicon.js +248 -0
  49. package/dist/host.d.ts +6 -5
  50. package/dist/host.js +2 -1
  51. package/dist/icon-letter.d.ts +3 -0
  52. package/dist/icon-letter.js +8 -0
  53. package/dist/index.d.ts +11 -5
  54. package/dist/index.js +9 -3
  55. package/dist/instrumentation.d.ts +2 -3
  56. package/dist/instrumentation.js +2 -1
  57. package/dist/island-registry.d.ts +6 -7
  58. package/dist/isolate-local.d.ts +5 -0
  59. package/dist/isolate-local.js +12 -0
  60. package/dist/kv.d.ts +3 -4
  61. package/dist/llms-links.d.ts +9 -0
  62. package/dist/llms-links.js +65 -0
  63. package/dist/manifest-CwTT1zVs.d.ts +11 -0
  64. package/dist/manifest.d.ts +2 -0
  65. package/dist/manifest.js +113 -0
  66. package/dist/negotiate.d.ts +4 -3
  67. package/dist/negotiate.js +24 -14
  68. package/dist/{pipeline-CCp9idQ4.d.ts → pipeline-BHUjBO0G.d.ts} +13 -1
  69. package/dist/pipeline.d.ts +2 -2
  70. package/dist/pipeline.js +286 -35
  71. package/dist/resources.d.ts +5 -6
  72. package/dist/route-rank.d.ts +6 -7
  73. package/dist/route-scan-BMbBzPIB.d.ts +20 -0
  74. package/dist/route-scan.d.ts +2 -0
  75. package/dist/route-scan.js +100 -0
  76. package/dist/router.d.ts +12 -13
  77. package/dist/router.js +51 -38
  78. package/dist/rsc-build.d.ts +6 -7
  79. package/dist/rsc-bundle.d.ts +6 -7
  80. package/dist/rsc-manifest.d.ts +5 -6
  81. package/dist/rsc-runtime/flight-render-routes.d.ts +3 -4
  82. package/dist/rsc-runtime/flight-render.d.ts +2 -3
  83. package/dist/rsc-runtime/flight-to-html.d.ts +2 -3
  84. package/dist/rsc-runtime/flight-to-html.js +3 -2
  85. package/dist/rsc-runtime/rsc-worker.js +11 -8
  86. package/dist/rsc-runtime/rsdw-shims.d.ts +2 -1
  87. package/dist/segment.d.ts +4 -5
  88. package/dist/sqlite-driver.d.ts +11 -11
  89. package/dist/sqlite-driver.js +17 -4
  90. package/dist/static-files.d.ts +4 -5
  91. package/dist/tsconfig-jsx.d.ts +4 -4
  92. package/dist/tsconfig-jsx.js +1 -1
  93. package/dist/turn-events.d.ts +4 -0
  94. package/dist/turn-events.js +46 -0
  95. package/dist/web-manifest.d.ts +5 -0
  96. package/dist/web-manifest.js +42 -0
  97. package/dist/worker-2qbQjj2c.d.ts +64 -0
  98. package/dist/worker.d.ts +2 -57
  99. package/dist/worker.js +69 -27
  100. package/package.json +28 -9
  101. package/src/adapter.ts +149 -4
  102. package/src/agent-compile.ts +271 -0
  103. package/src/agent-discover.ts +77 -63
  104. package/src/agent-durable.ts +998 -30
  105. package/src/agent-native.ts +370 -35
  106. package/src/app.ts +75 -6
  107. package/src/assets.ts +200 -0
  108. package/src/build.ts +241 -449
  109. package/src/config-freeze.ts +109 -0
  110. package/src/connection-auth.ts +101 -0
  111. package/src/content-freeze.ts +151 -0
  112. package/src/content.ts +55 -4
  113. package/src/core-version.ts +34 -0
  114. package/src/dev.ts +41 -8
  115. package/src/favicon.ts +325 -0
  116. package/src/host.ts +4 -3
  117. package/src/icon-letter.ts +14 -0
  118. package/src/index.ts +22 -0
  119. package/src/isolate-local.ts +33 -0
  120. package/src/llms-links.ts +96 -0
  121. package/src/manifest.ts +130 -0
  122. package/src/negotiate.ts +39 -15
  123. package/src/pipeline.ts +494 -44
  124. package/src/route-scan.ts +161 -0
  125. package/src/router.ts +66 -54
  126. package/src/rsc-runtime/rsc-worker.tsx +5 -4
  127. package/src/sqlite-driver.ts +36 -6
  128. package/src/tsconfig-jsx.ts +2 -2
  129. package/src/turn-events.ts +43 -0
  130. package/src/web-manifest.ts +38 -0
  131. package/src/worker.ts +131 -35
package/src/pipeline.ts CHANGED
@@ -24,19 +24,44 @@ 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 { mcpForbidden, mcpHandler, mcpServerIdentity, mcpTools, originRejection } from "@junejs/core/mcp";
52
+ import {
53
+ apiActionId,
54
+ apiForbidden,
55
+ apiHandler,
56
+ apiNamespaceResponse,
57
+ isApiNamespace,
58
+ OPENAPI_MEDIA_TYPE,
59
+ openApiDocument,
60
+ } from "@junejs/core/api";
61
+ import type { Principal, Session } from "@junejs/core/context";
37
62
 
38
63
  import { ensureScope, runInScope, setRequestLocale } from "@junejs/db";
39
- import type { AgentConfig } from "@junejs/core/config";
64
+ import { siteShortName, type AgentConfig } from "@junejs/core/config";
40
65
  import {
41
66
  LOCALE_COOKIE,
42
67
  localeAlternates,
@@ -48,6 +73,9 @@ import {
48
73
  } from "@junejs/core/i18n";
49
74
  import type { Resources } from "@junejs/core/resources";
50
75
 
76
+ import { iconLetter } from "./icon-letter";
77
+ import { collectLlmsLinks, collectSitemapPages } from "./llms-links";
78
+ import { webManifest } from "./web-manifest";
51
79
  import { negotiate, TITLE_HEADER, SEGMENT_HEADER, encodeTitle } from "./negotiate";
52
80
 
53
81
  // Minimal Cookie-header read for the locale negotiation chain (no dependency on
@@ -118,6 +146,11 @@ export type PipelineConfig = {
118
146
  // re-scan the filesystem; the worker returns a frozen array.
119
147
  routeList: () => Promise<string[]> | string[];
120
148
  resolve: RouteResolver;
149
+ // True only while the static() build prerenders: /sitemap.xml may then list a
150
+ // dynamic route's `staticPaths`, which the route contract confines to that
151
+ // build (they may do build-only or expensive work). At runtime a crawler's
152
+ // /sitemap.xml enumerates dynamic pages from their `llms` entries alone.
153
+ staticBuild?: boolean;
121
154
  // Opened data resources (db/kv/blob) injected onto ctx before load(). A
122
155
  // provider so opening is lazy/memoized; absent → no resources on ctx.
123
156
  resources?: () => Promise<Resources> | Resources;
@@ -126,6 +159,14 @@ export type PipelineConfig = {
126
159
  // `currentServices()` resolves in loaders/views/actions. Absent → currentServices()
127
160
  // is undefined. This is the Worker-side twin of a Durable Object seeding services.
128
161
  services?: () => Promise<unknown> | unknown;
162
+ // Resolve the request's authenticated identity (the auth integration's job —
163
+ // e.g. Better Auth reading the session cookie). The result rides every
164
+ // ActionContext the pipeline builds, starting with the /mcp mount, so
165
+ // requiresPrincipal actions and per-call connection auth see the SAME
166
+ // principal a UI dispatch would. Absent → anonymous (identity-gated actions
167
+ // reject); a THROWING resolver propagates as a server error — it must never
168
+ // silently degrade to anonymous.
169
+ identity?: (request: Request) => Promise<{ user?: Principal; session?: Session }> | { user?: Principal; session?: Session };
129
170
  earlyHints?: string[];
130
171
  htmlCacheControl?: string;
131
172
  notFoundComponent?: React.ComponentType<{ pathname: string }>;
@@ -159,6 +200,9 @@ const DefaultNotFound: React.ComponentType<{ pathname: string }> = ({ pathname }
159
200
  React.createElement("p", null, pathname),
160
201
  );
161
202
 
203
+ // Catalogs crawlers and browser-based agents fetch cross-origin (ARD requires it).
204
+ const CORS = { "access-control-allow-origin": "*" };
205
+
162
206
  function text(body: string, contentType: string, init?: ResponseInit) {
163
207
  const headers = new Headers(init?.headers);
164
208
  headers.set("content-type", contentType);
@@ -191,10 +235,11 @@ function StreamedView({
191
235
  // The default favicon: the site name's first character in a rounded square —
192
236
  // a plain SVG string, so it needs no fonts, no rasterizer, and works for CJK
193
237
  // names as readily as latin ones. Served at /favicon.svg AND /favicon.ico
194
- // (browsers respect the svg content-type), so no June app 404s its icon.
238
+ // (browsers respect the svg content-type), so no June app 404s its icon. A built
239
+ // app also ships real PNG/ICO files (favicon.ts), which the asset layer answers
240
+ // first, so this /favicon.ico is only the fallback.
195
241
  function letterFavicon(siteName: string | undefined): Response {
196
- const first = (siteName ?? "").trim().charAt(0) || "•";
197
- const letter = first.toUpperCase().replace(/[<>&"']/g, "");
242
+ const letter = (iconLetter(siteName) || "•").replace(/[<>&"']/g, "");
198
243
  return text(
199
244
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` +
200
245
  `<rect width="64" height="64" rx="12" fill="#1d1d1f"/>` +
@@ -205,13 +250,91 @@ function letterFavicon(siteName: string | undefined): Response {
205
250
  );
206
251
  }
207
252
 
253
+ // A page URL's markdown twin — the same paths `june build` prerenders: /index.md
254
+ // for the home page (and a locale home, /zh-cn/index.md), <path>.md otherwise.
255
+ // A trailing slash is dropped first: /users/ is the same page as /users, whose
256
+ // twin is the flat /users.md (never /users/index.md). A home reached through its
257
+ // /index alias (/index, /de/index) is the same home: /de/index → /de/index.md.
258
+ // Only a HOME folds /index — /docs/index is its own route, twin /docs/index.md.
259
+ export function markdownPath(pathname: string, isHome: boolean): string {
260
+ const path = pathname.replace(/\/+$/, "");
261
+ return isHome ? `${path.replace(/\/index$/, "")}/index.md` : `${path}.md`;
262
+ }
263
+
264
+ // The inverse: the path a page's HTML form lives at, for the markdown
265
+ // projection's canonical — /docs/x.md → /docs/x; for a home, its /index alias
266
+ // folds away (/index.md → /, /de/index.md and /de/index → /de). A non-home
267
+ // /docs/index(.md) stays /docs/index.
268
+ export function htmlPath(pathname: string, isHome: boolean): string {
269
+ const path = pathname.endsWith(".md") ? pathname.slice(0, -3) : pathname;
270
+ if (!isHome) return path;
271
+ return path.replace(/\/+$/, "").replace(/\/index$/, "") || "/";
272
+ }
273
+
274
+ // The MCP Server Card's discovery contract (experimental-ext-server-card
275
+ // docs/discovery.md): its own media type (MCP_SERVER_CARD_TYPE, shared with the
276
+ // api-catalog and Link header), and CORS so browser clients can read it.
277
+ const SERVER_CARD_PATH = "/.well-known/mcp/server-card.json";
278
+ const SERVER_CARD_CORS = {
279
+ "access-control-allow-origin": "*",
280
+ // HEAD too: the discovery gate answers it on every surface (same headers, no body).
281
+ "access-control-allow-methods": "GET, HEAD",
282
+ "access-control-allow-headers": "Content-Type, If-None-Match",
283
+ "access-control-expose-headers": "ETag",
284
+ };
285
+
286
+ type PageProps = {
287
+ pageUrl: string;
288
+ isHome: boolean;
289
+ onLocaleDomain: boolean;
290
+ markdownHref?: string;
291
+ breadcrumbs?: Breadcrumb[];
292
+ };
293
+
294
+ // A URL segment as a crumb label, for a page whose title isn't static: decoded,
295
+ // "-"/"_" as spaces, first letter capitalized (scripts without case are unchanged).
296
+ function humanizeSegment(segment: string): string {
297
+ let s = segment;
298
+ try {
299
+ s = decodeURIComponent(segment);
300
+ } catch {
301
+ // an undecodable segment never reaches here (resolveRoute refuses the path)
302
+ }
303
+ s = s.replace(/[-_]+/g, " ").trim();
304
+ return s.charAt(0).toUpperCase() + s.slice(1);
305
+ }
306
+
307
+ // A request's matched route pathname (locale stripped, /index → /), keyed by its
308
+ // ctx — RouteContext is public API, so the pipeline tracks it on the side.
309
+ const routePathOf = new WeakMap<RouteContext, string>();
310
+
311
+ // Whether every "/"-separated segment of a path percent-decodes (a malformed
312
+ // escape like "%ZZ" or a truncated UTF-8 sequence doesn't). The only thing
313
+ // decodeURIComponent can throw is URIError, so any failure means "no".
314
+ export function isDecodablePath(pathname: string): boolean {
315
+ try {
316
+ for (const segment of pathname.split("/")) decodeURIComponent(segment);
317
+ return true;
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+
208
323
  export function createPipeline(cfg: PipelineConfig): Pipeline {
209
324
  const { docConfig, agent } = cfg;
210
325
  const NotFound = cfg.notFoundComponent ?? DefaultNotFound;
326
+ // The origin-independent half of catalogOrigin() below — all a response header
327
+ // can know (a static build publishes no headers, so the prerender-origin half
328
+ // never applies to one).
329
+ const publishesCatalogs = agent.discovery && !docConfig.basePath;
211
330
 
212
331
  function htmlHeaders(): Headers {
213
- const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
214
- const links = [buildLinkHeader(agent), ...(cfg.earlyHints ?? [])].filter(Boolean) as string[];
332
+ // Every pipeline document is one variant of an Accept-negotiated URL (its .md
333
+ // and .json answer at the same path) — including streamed pages and the 404.
334
+ const headers = new Headers({ "content-type": "text/html; charset=utf-8", vary: "accept" });
335
+ const links = [buildLinkHeader(agent, { catalogs: publishesCatalogs }), ...(cfg.earlyHints ?? [])].filter(
336
+ Boolean,
337
+ ) as string[];
215
338
  if (links.length) headers.set("link", links.join(", "));
216
339
  if (cfg.htmlCacheControl) headers.set("cache-control", cfg.htmlCacheControl);
217
340
  return headers;
@@ -240,6 +363,65 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
240
363
  });
241
364
  }
242
365
 
366
+ // The document's page-identity props: the URL for og:url/canonical, whether the
367
+ // MATCHED route is home (so /de and /index count, per negotiate's normalized
368
+ // pathname), and whether the host is a locale's own domain (its public origin).
369
+ function pageProps(ctx: RouteContext, def: BrandedRoute): PageProps {
370
+ const isHome = routePathOf.get(ctx) === "/";
371
+ return {
372
+ pageUrl: ctx.url.href,
373
+ isHome,
374
+ onLocaleDomain: onLocaleDomain(ctx),
375
+ // The page's markdown twin, advertised as a discovery signal (so gated with
376
+ // the rest of discovery). A disabled md projection gets no link to a 404.
377
+ markdownHref: agent.discovery && def.md !== false ? markdownPath(ctx.url.pathname, isHome) : undefined,
378
+ };
379
+ }
380
+
381
+ // The page's breadcrumb trail (Metadata.breadcrumb): home first, this page last.
382
+ // Derived from the URL unless the route declares its own (or false). An ancestor
383
+ // path gets a crumb only if it RESOLVES to a page matched with no params — a
384
+ // static route, or an optional segment's bare match: a page that exists by
385
+ // declaration. A dynamic match (/docs/[slug] for /docs/x) exists only if its data
386
+ // does, which would take a load to know, so it is skipped, never guessed. Each
387
+ // ancestor costs one route resolve (no load); its label is the route's static
388
+ // metadata.title, else its humanized URL segment — the URL is verified, only the
389
+ // label is derived, and a gap in the trail would misstate the hierarchy more.
390
+ // Paths are request paths: a locale prefix (/de/…) carries through, so the
391
+ // home crumb is the locale's home.
392
+ async function breadcrumbsFor(ctx: RouteContext, meta: Metadata | undefined): Promise<Breadcrumb[] | undefined> {
393
+ const routePath = routePathOf.get(ctx);
394
+ if (!routePath || routePath === "/" || meta?.breadcrumb === false) return undefined;
395
+ const urlPath = ctx.url.pathname.replace(/\/+$/, "") || "/";
396
+ const route = routePath.replace(/\/+$/, "");
397
+ // The request path minus the route path = the locale prefix ("" on the default
398
+ // locale or a locale's own domain).
399
+ const prefix = urlPath.endsWith(route) ? urlPath.slice(0, urlPath.length - route.length) : "";
400
+ const home: Breadcrumb = {
401
+ name: siteShortName(docConfig.site) ?? documentTitle(undefined, docConfig.site),
402
+ path: prefix || "/",
403
+ };
404
+ if (Array.isArray(meta?.breadcrumb)) return [home, ...meta.breadcrumb];
405
+ const segments = route.split("/").filter(Boolean);
406
+ const trail: Breadcrumb[] = [home];
407
+ for (let i = 1; i < segments.length; i++) {
408
+ const ancestor = "/" + segments.slice(0, i).join("/");
409
+ const r = await resolveRoute(ancestor);
410
+ if (!r || "handler" in r || Object.keys(r.params).length > 0) continue;
411
+ const title = typeof r.def.metadata === "object" ? r.def.metadata?.title : undefined;
412
+ trail.push({ name: title || humanizeSegment(segments[i - 1]!), path: prefix + ancestor });
413
+ }
414
+ trail.push({ name: meta?.title || humanizeSegment(segments[segments.length - 1]!), path: urlPath });
415
+ return trail;
416
+ }
417
+
418
+ function onLocaleDomain(ctx: RouteContext): boolean {
419
+ const host = ctx.url.hostname.toLowerCase();
420
+ return cfg.i18n
421
+ ? Object.values(cfg.i18n.locales).some((l) => l.domain?.toLowerCase() === host)
422
+ : false;
423
+ }
424
+
243
425
  async function renderDocument(
244
426
  node: React.ReactNode,
245
427
  metadata: Metadata | undefined,
@@ -248,6 +430,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
248
430
  locale?: string,
249
431
  boundaryKey?: string | null,
250
432
  alternates?: LocaleAlternate[],
433
+ page?: PageProps,
251
434
  ): Promise<Response> {
252
435
  // Wrap root→leaf: chain[0] is outermost.
253
436
  const wrapped = chain.reduceRight<React.ReactNode>(
@@ -256,11 +439,12 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
256
439
  );
257
440
  const stream = await renderToReadableStream(
258
441
  React.createElement(Document, {
259
- config: docConfigForRender(),
442
+ config: docConfigForRender(page?.pageUrl),
260
443
  metadata,
261
444
  children: wrapped,
262
445
  shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
263
446
  alternates,
447
+ ...page,
264
448
  ...langDir(locale),
265
449
  }),
266
450
  );
@@ -314,9 +498,17 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
314
498
  // WebMCP: register the app's actions as browser tools. Computed per render
315
499
  // from the live registry (stable after warmup), gated on agent.webmcp + mcp
316
500
  // (execute proxies to /mcp). No actions → no script → page stays zero-JS.
317
- function docConfigForRender(): DocumentConfig {
501
+ function docConfigForRender(pageUrl?: string): DocumentConfig {
318
502
  const webmcpTools = agent.webmcp && agent.mcp ? mcpTools() : null;
319
- return webmcpTools?.length ? { ...docConfig, webmcpTools } : docConfig;
503
+ // <link rel="ai-catalog"> — the AI Catalog spec's in-page pointer (ARD) —
504
+ // only where this pipeline actually serves the catalog for the page's origin
505
+ // (never under a basePath, never on a static build without a public origin).
506
+ const servesCatalog = !!pageUrl && catalogOrigin(new URL(pageUrl)) !== null;
507
+ return {
508
+ ...docConfig,
509
+ ...(webmcpTools?.length ? { webmcpTools } : {}),
510
+ ...(servesCatalog ? { aiCatalog: AI_CATALOG_PATH } : {}),
511
+ };
320
512
  }
321
513
 
322
514
  // Streaming Suspense: the shell (layout chain + the loading.tsx fallback)
@@ -328,6 +520,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
328
520
  resolved: Resolved,
329
521
  loadPromise: Promise<unknown>,
330
522
  ctx: RouteContext,
523
+ breadcrumbs: Breadcrumb[] | undefined,
331
524
  ): Promise<Response> {
332
525
  const { def, chain, loading: Loading, boundaryKey } = resolved;
333
526
  const leaf = React.createElement(StreamedView, { loadPromise, def, ctx });
@@ -341,13 +534,17 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
341
534
  boundary,
342
535
  );
343
536
  const metadata = typeof def.metadata === "object" ? def.metadata : undefined;
537
+ // No await between here and React taking loadPromise (see the caller); the
538
+ // breadcrumb trail is resolved before load() starts.
344
539
  const stream = await renderToReadableStream(
345
540
  React.createElement(Document, {
346
- config: docConfigForRender(),
541
+ config: docConfigForRender(ctx.url.href),
347
542
  metadata,
348
543
  children: wrapped,
349
544
  shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
350
545
  alternates: alternatesFor(ctx),
546
+ ...pageProps(ctx, def),
547
+ breadcrumbs,
351
548
  ...langDir(ctx.locale),
352
549
  }),
353
550
  { onError: (e: unknown) => console.error("[june] streaming render error:", e) },
@@ -357,21 +554,94 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
357
554
  return new Response(stream, { status: 200, headers: htmlHeaders() });
358
555
  }
359
556
 
557
+ // Route resolution for a request path. Every resolver (the dev tree matcher,
558
+ // the built worker's dynamic + resource tables) percent-decodes URL segments,
559
+ // and would throw URIError on a malformed escape (/docs/%ZZ). Such a path can
560
+ // match no route by definition, so it is refused BEFORE resolving — a miss
561
+ // (the normal negotiated 404, or the API's JSON 404 under /api) — rather than
562
+ // catching around cfg.resolve(), which also imports route modules: a genuine
563
+ // error there, URIError included, must still surface. Checking each segment
564
+ // suffices for every resolver: their captures are whole segments or runs of
565
+ // them joined by "/", which decode iff each segment does.
566
+ async function resolveRoute(pathname: string): ReturnType<RouteResolver> {
567
+ return isDecodablePath(pathname) ? cfg.resolve(pathname) : null;
568
+ }
569
+
360
570
  function notFoundResponse(
361
571
  target: RenderTarget,
362
572
  pathname: string,
363
573
  locale?: string,
574
+ // Passed only when ROUTING found nothing (not when a matched route 404s): an
575
+ // unmatched path in June's REST namespace (/api) is an API miss, answered
576
+ // with the API's own JSON error/index whatever Accept negotiated — app
577
+ // routes under /api were already given their chance.
578
+ unmatched?: Request,
364
579
  ): Promise<Response> | Response {
365
- // Data clients get a JSON 404; humans get the rendered NotFound document.
366
- return target !== "view"
367
- ? Response.json({ error: "Not Found", path: pathname }, { status: 404 })
368
- : renderDocument(
369
- React.createElement(NotFound, { pathname }),
370
- { title: "Not found", robots: "noindex" },
371
- 404,
372
- [],
373
- locale,
374
- );
580
+ if (unmatched && agent.api) {
581
+ const url = new URL(unmatched.url);
582
+ if (isApiNamespace(url.pathname)) return apiNamespaceResponse(unmatched, url.origin);
583
+ }
584
+ // Agents get a 404 they can act on: Markdown for an Accept: text/markdown (or
585
+ // .md) request, structured JSON for other data clients — both pointing at the
586
+ // discovery surfaces. Humans get the rendered NotFound document. The body
587
+ // depends on Accept, so every variant says so to caches.
588
+ if (target === "md") {
589
+ return discoveryLinks().then((links) =>
590
+ text(notFoundMarkdown(pathname, links), "text/markdown; charset=utf-8", {
591
+ status: 404,
592
+ headers: { vary: "accept" },
593
+ }),
594
+ );
595
+ }
596
+ if (target !== "view") {
597
+ return discoveryLinks().then((links) =>
598
+ Response.json(
599
+ { error: "Not Found", code: "not_found", path: pathname, hint: notFoundHint(links) },
600
+ { status: 404, headers: { vary: "accept" } },
601
+ ),
602
+ );
603
+ }
604
+ return renderDocument(
605
+ React.createElement(NotFound, { pathname }),
606
+ { title: "Not found", robots: "noindex" },
607
+ 404,
608
+ [],
609
+ locale,
610
+ );
611
+ }
612
+
613
+ // Where an agent that hit a dead end should look next — only surfaces this app
614
+ // actually serves: /index.md only when a root page exists with its md live.
615
+ async function discoveryLinks(): Promise<Array<[label: string, href: string]>> {
616
+ const links: Array<[string, string]> = [];
617
+ if (agent.discovery) {
618
+ links.push(["llms.txt — the site index for agents", "/llms.txt"], ["sitemap.xml — every page", "/sitemap.xml"]);
619
+ }
620
+ if (agent.mcp) links.push(["MCP server — this site's tools", "/mcp"]);
621
+ if (agent.api) links.push(["OpenAPI — the same tools over HTTP", "/openapi.json"]);
622
+ const home = await cfg.resolve("/");
623
+ if (home && "def" in home && home.def.md !== false) links.push(["Home page (Markdown)", "/index.md"]);
624
+ return links;
625
+ }
626
+
627
+ function notFoundHint(links: Array<[string, string]>): string {
628
+ return links.length
629
+ ? `No page at this path. Start from ${links.map(([, h]) => h).join(", ")}.`
630
+ : "No page at this path.";
631
+ }
632
+
633
+ function notFoundMarkdown(pathname: string, links: Array<[string, string]>): string {
634
+ const site = docConfig.site.name ? ` on ${docConfig.site.name}` : "";
635
+ return [
636
+ "# 404 — Not found",
637
+ "",
638
+ links.length
639
+ ? `There is no page at \`${pathname}\`${site}. Try one of these instead:`
640
+ : `There is no page at \`${pathname}\`${site}.`,
641
+ "",
642
+ ...links.map(([label, href]) => `- [${label}](${href})`),
643
+ "",
644
+ ].join("\n");
375
645
  }
376
646
 
377
647
  function resolveMeta(def: BrandedRoute, data: unknown, ctx: RouteContext): Metadata | undefined {
@@ -385,16 +655,38 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
385
655
  // json is also absent). md/json === false is handled as 404 by the caller.
386
656
  const jsonData =
387
657
  typeof def.json === "function" ? await def.json(data, ctx) : def.json === false ? null : data;
388
- const body =
658
+ const content =
389
659
  typeof def.md === "function"
390
660
  ? await def.md(data, ctx)
391
661
  : "```json\n" + JSON.stringify(jsonData, null, 2) + "\n```\n";
662
+ const body = withFrontmatter(content, resolveMeta(def, data, ctx), ctx);
392
663
  // x-markdown-tokens: a rough estimate (~4 chars/token) agents use to budget.
393
664
  return text(body, "text/markdown; charset=utf-8", {
394
665
  headers: { "x-markdown-tokens": String(Math.ceil(body.length / 4)) },
395
666
  });
396
667
  }
397
668
 
669
+ // Open served markdown with a frontmatter block (title, description, canonical)
670
+ // so an agent gets the page's metadata without scraping the HTML. A body that
671
+ // already opens with its own frontmatter (e.g. a content file served verbatim)
672
+ // is left alone. Values are JSON strings — valid YAML double-quoted scalars.
673
+ function withFrontmatter(body: string, meta: Metadata | undefined, ctx: RouteContext): string {
674
+ if (/^---\r?\n/.test(body)) return body;
675
+ // The page's OWN title, not the templated <title> ("Users", not "Users · Site"):
676
+ // the template is browser-tab branding, and authored frontmatter (served
677
+ // verbatim) carries the bare title too — so every .md reads the same way. The
678
+ // site name is only the fallback for a page with no title — an empty title
679
+ // counts as none, exactly as documentTitle() treats it.
680
+ const title = meta?.title || documentTitle(meta, docConfig.site);
681
+ const description = meta?.description ?? docConfig.site.description;
682
+ const canonical = pageCanonical(docConfig, meta, ctx.url.href, htmlPath(ctx.url.pathname, routePathOf.get(ctx) === "/"), onLocaleDomain(ctx));
683
+ const lines = ["---", `title: ${JSON.stringify(title)}`];
684
+ if (description) lines.push(`description: ${JSON.stringify(description)}`);
685
+ if (canonical) lines.push(`canonical: ${JSON.stringify(canonical)}`);
686
+ lines.push("---", "");
687
+ return lines.join("\n") + body;
688
+ }
689
+
398
690
  async function renderProjection(
399
691
  resolved: Resolved,
400
692
  target: RenderTarget,
@@ -440,32 +732,137 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
440
732
  // renders the whole chain (a hard load of the URL is never segment-scoped) —
441
733
  // but it stamps the shell key so the client knows which shell is mounted.
442
734
  if (target === "fragment") return renderFragment(node, meta, chain, boundaryIndex, boundaryKey);
443
- return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey, alternatesFor(ctx));
735
+ const page = { ...pageProps(ctx, def), breadcrumbs: await breadcrumbsFor(ctx, meta) };
736
+ return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey, alternatesFor(ctx), page);
737
+ }
738
+
739
+ // The origin the agent catalogs name, or null when this site publishes none —
740
+ // the single rule for serving them, prerendering them, and advertising them
741
+ // (head link, Link header, robots Agentmap):
742
+ // • agent.discovery off → no discovery surface at all;
743
+ // • a basePath site doesn't own the domain root, where /.well-known lives;
744
+ // • a static build renders against the placeholder prerender host, which must
745
+ // never reach a published file: use the configured public origin (site.url,
746
+ // else deploy.domain) there, and publish nothing when neither is set.
747
+ function catalogOrigin(url: URL): string | null {
748
+ return publishesCatalogs ? publicOrigin(url) : null;
749
+ }
750
+
751
+ // The public origin that published discovery files (llms.txt, sitemap.xml,
752
+ // robots.txt, the agent catalogs) name. Live, it is the request's own origin.
753
+ // A prerender runs against the placeholder host, which must never reach a
754
+ // published file, so it uses the configured public origin: site.url's origin,
755
+ // else the deploy domain. null → unknown: publish no absolute URL at all.
756
+ function publicOrigin(url: URL): string | null {
757
+ if (url.origin !== PRERENDER_ORIGIN) return url.origin;
758
+ return docConfig.site.url ? new URL(docConfig.site.url).origin : (docConfig.deployOrigin ?? null);
759
+ }
760
+
761
+ // Where the site's pages live: the public origin plus the deploy basePath, so
762
+ // every absolute URL a discovery file lists resolves (a GitHub Pages project
763
+ // site is https://user.github.io/repo/…). null when the origin is unknown.
764
+ function publicBase(url: URL): string | null {
765
+ const origin = publicOrigin(url);
766
+ return origin === null ? null : origin + (docConfig.basePath ?? "");
767
+ }
768
+
769
+ // The agent catalogs: Agent Skills (index + the generated SKILL.md) and the
770
+ // ARD / AI Catalog. CORS-open — crawlers and browser agents fetch them cross-origin.
771
+ async function agentCatalog(url: URL): Promise<Response | null> {
772
+ const { pathname } = url;
773
+ if (!pathname.startsWith("/.well-known/")) return null;
774
+ const origin = catalogOrigin(url);
775
+ if (!origin) return null;
776
+ if (pathname === AGENT_SKILLS_INDEX_PATH) {
777
+ return Response.json(await agentSkillsIndex(origin, agent, docConfig.site), { headers: CORS });
778
+ }
779
+ if (pathname === AI_CATALOG_PATH || pathname === ARD_PATH) {
780
+ return Response.json(aiCatalog(origin, agent, docConfig.site), { headers: CORS });
781
+ }
782
+ // The skill's path carries its name; only the generated skill's own path answers.
783
+ const skill = siteSkill(origin, agent, docConfig.site);
784
+ if (pathname === new URL(skill.url).pathname) {
785
+ return text(skill.markdown, "text/markdown; charset=utf-8", { headers: CORS });
786
+ }
787
+ // The skills directory is ours while we serve it: any other path under it is
788
+ // a skill that doesn't exist, a 404 the RFC requires — never handed to app
789
+ // routing, where a catch-all would answer 200.
790
+ if (pathname.startsWith("/.well-known/agent-skills/")) {
791
+ return Response.json({ error: "Not Found", path: pathname }, { status: 404, headers: CORS });
792
+ }
793
+ return null;
444
794
  }
445
795
 
446
796
  async function discovery(url: URL): Promise<Response | null> {
797
+ const catalog = await agentCatalog(url);
798
+ if (catalog) return catalog;
447
799
  switch (url.pathname) {
448
- case "/llms.txt":
449
- return text(
450
- llmsTxt(url.origin, await cfg.routeList(), agent, docConfig.site),
451
- "text/markdown; charset=utf-8",
452
- );
453
- case "/robots.txt":
454
- return text(robotsTxt(url.origin), "text/plain; charset=utf-8");
455
- case "/sitemap.xml":
800
+ case "/llms.txt": {
801
+ const routes = await cfg.routeList();
802
+ // With no known public origin (a static build without site.url or a deploy
803
+ // domain) the links are root-relative — llmstxt.org allows it — never the
804
+ // prerender placeholder.
805
+ const base = publicBase(url) ?? docConfig.basePath ?? "";
806
+ // sections, descriptions, and Optional come from each route's `llms` export
807
+ const links = await collectLlmsLinks(base, routes, cfg.resolve);
808
+ return text(llmsTxt(base, routes, agent, docConfig.site, links), "text/markdown; charset=utf-8");
809
+ }
810
+ case "/robots.txt": {
811
+ const base = publicBase(url);
812
+ if (base === null) return text("Not Found\n", "text/plain; charset=utf-8", { status: 404 });
813
+ return text(robotsTxt(base, { catalogs: catalogOrigin(url) !== null }), "text/plain; charset=utf-8");
814
+ }
815
+ case "/sitemap.xml": {
816
+ // The sitemap protocol requires absolute <loc>s: with no known public origin
817
+ // there is no correct sitemap, so none (the static build warns).
818
+ const base = publicBase(url);
819
+ if (base === null) return text("Not Found\n", "text/plain; charset=utf-8", { status: 404 });
456
820
  return text(
457
- sitemapXml(url.origin, await cfg.routeList(), cfg.i18n),
821
+ sitemapXml(
822
+ base,
823
+ await collectSitemapPages(await cfg.routeList(), cfg.resolve, {
824
+ i18n: !!cfg.i18n,
825
+ staticPaths: cfg.staticBuild === true,
826
+ }),
827
+ cfg.i18n,
828
+ ),
458
829
  "application/xml; charset=utf-8",
459
830
  );
831
+ }
460
832
  case "/.well-known/api-catalog":
461
- return text(JSON.stringify(apiCatalog(url.origin, agent)), "application/linkset+json");
462
- case "/.well-known/mcp/server-card.json":
463
- return agent.mcp ? Response.json(mcpServerCard(url.origin)) : null;
833
+ // RFC 9727 §2: a HEAD here SHALL carry a Link with rel="api-catalog". The
834
+ // gate answers HEAD with this GET's headers, so both carry it.
835
+ return text(JSON.stringify(apiCatalog(url.origin, agent)), API_CATALOG_CONTENT_TYPE, {
836
+ headers: { link: `</.well-known/api-catalog>; rel="api-catalog"` },
837
+ });
838
+ case SERVER_CARD_PATH:
839
+ return agent.mcp
840
+ ? text(
841
+ JSON.stringify(
842
+ mcpServerCard(url.origin, {
843
+ site: docConfig.site,
844
+ agent,
845
+ icons: docConfig.icons,
846
+ basePath: docConfig.basePath,
847
+ }),
848
+ ),
849
+ MCP_SERVER_CARD_TYPE,
850
+ { headers: { ...SERVER_CARD_CORS, "cache-control": "public, max-age=3600" } },
851
+ )
852
+ : null;
464
853
  default:
465
854
  return null;
466
855
  }
467
856
  }
468
857
 
858
+ // Which browsers may call /mcp and /api (#308) — the config's allowlists.
859
+ const originPolicy = { allowedOrigins: agent.allowedOrigins, allowedHosts: agent.allowedHosts };
860
+ // Only a POST runs an action on /mcp or /api; any other method is a CORS
861
+ // preflight or a 405. A preflight carries no credentials, so a cookie-reading
862
+ // resolver could throw on it — never resolve identity for one.
863
+ const callerIdentity = async (request: Request) =>
864
+ request.method === "POST" && cfg.identity ? await cfg.identity(request) : undefined;
865
+
469
866
  async function handleRequest(request: Request): Promise<Response> {
470
867
  const url = new URL(request.url);
471
868
 
@@ -473,13 +870,53 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
473
870
  if (url.pathname === "/mcp") {
474
871
  if (!agent.mcp) return notFoundResponse("view", url.pathname);
475
872
  // The agent's tool calls run inside the same request scope, so an action's
476
- // ambient `db` is the SAME resource the UI uses (and, once auth is wired,
477
- // the same principal via ctx). ctx carries identity only — not resources.
478
- return mcpHandler(request, { request });
873
+ // ambient `db` is the SAME resource the UI uses, and — via cfg.identity —
874
+ // the same principal: requiresPrincipal actions and per-call connection
875
+ // auth are live on this surface. ctx carries identity only — not resources.
876
+ // The origin policy first: a refused browser call must not reach the
877
+ // identity resolver (its I/O, or a throw that would turn 403 into 500).
878
+ const refused = originRejection(request, originPolicy);
879
+ if (refused !== undefined) return mcpForbidden(refused);
880
+ const identity = await callerIdentity(request);
881
+ return mcpHandler(
882
+ request,
883
+ { request, ...identity },
884
+ mcpServerIdentity(url.origin, { site: docConfig.site, agent }),
885
+ originPolicy,
886
+ );
887
+ }
888
+ // The REST projection of the same actions: POST /api/<id> dispatches through
889
+ // the same invokeAction with the same principal as /mcp. Only a registered
890
+ // action's canonical path is claimed — any other /api/* falls through to the app.
891
+ // /openapi.json is the API's own description, so it is gated on agent.api (not
892
+ // agent.discovery) and answered here; the discovery gate below never sees it.
893
+ if (agent.api) {
894
+ if (url.pathname === "/openapi.json" && (request.method === "GET" || request.method === "HEAD")) {
895
+ const doc = Response.json(openApiDocument(url.origin, docConfig.site), {
896
+ // the same type the Link header + api-catalog advertise
897
+ headers: { "content-type": OPENAPI_MEDIA_TYPE, "access-control-allow-origin": "*" },
898
+ });
899
+ // HEAD: the same status + headers, no body.
900
+ return request.method === "HEAD" ? new Response(null, { status: doc.status, headers: doc.headers }) : doc;
901
+ }
902
+ const actionId = apiActionId(url.pathname);
903
+ if (actionId) {
904
+ const refused = originRejection(request, originPolicy);
905
+ if (refused !== undefined) return apiForbidden(request, refused);
906
+ const identity = await callerIdentity(request);
907
+ return apiHandler(request, actionId, { request, ...identity }, originPolicy);
908
+ }
479
909
  }
480
- if (request.method === "GET" && agent.discovery) {
910
+ // The server card is read cross-origin by browser-based MCP clients; its
911
+ // If-None-Match revalidation is a non-safelisted header → a CORS preflight.
912
+ if (request.method === "OPTIONS" && agent.discovery && agent.mcp && url.pathname === SERVER_CARD_PATH) {
913
+ return new Response(null, { status: 204, headers: SERVER_CARD_CORS });
914
+ }
915
+ // GET and HEAD (Agent Skills Discovery RFC v0.2.0 requires both): a HEAD
916
+ // gets the same status and headers, no body.
917
+ if ((request.method === "GET" || request.method === "HEAD") && agent.discovery) {
481
918
  const d = await discovery(url);
482
- if (d) return d;
919
+ if (d) return request.method === "HEAD" ? new Response(null, { status: d.status, headers: d.headers }) : d;
483
920
  }
484
921
 
485
922
  // --- durable agent surface (chat + channels) -------------------------
@@ -507,6 +944,12 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
507
944
  ) {
508
945
  return letterFavicon(docConfig.site.name);
509
946
  }
947
+ // --- generated web manifest (only beside June's generated icons) ------
948
+ if (request.method === "GET" && docConfig.icons?.generated && url.pathname === "/manifest.webmanifest") {
949
+ return text(webManifest(docConfig), "application/manifest+json", {
950
+ headers: { "cache-control": "public, max-age=86400" },
951
+ });
952
+ }
510
953
 
511
954
  // --- locale resolution (before routing) ------------------------------
512
955
  // host/path → locale, stripping the locale prefix off the front so the
@@ -538,8 +981,8 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
538
981
 
539
982
  // --- routes ----------------------------------------------------------
540
983
  const { target, pathname, speculative } = negotiate(url, request, routeBase);
541
- const resolved = await cfg.resolve(pathname);
542
- if (!resolved) return notFoundResponse(target, pathname, locale);
984
+ const resolved = await resolveRoute(pathname);
985
+ if (!resolved) return notFoundResponse(target, pathname, locale, request);
543
986
 
544
987
  // ctx is identity/request only; db/kv/blob are ambient (read from the
545
988
  // request scope this whole handler runs inside — see runInScope below).
@@ -551,6 +994,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
551
994
  locale,
552
995
  speculative,
553
996
  };
997
+ routePathOf.set(ctx, pathname);
554
998
 
555
999
  // Resource route (route.*): a raw-Response handler — no projection, no doc
556
1000
  // shell. It's matched like any route, so it can't shadow a page.
@@ -559,10 +1003,16 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
559
1003
  // static metadata flushes the shell + fallback before load() resolves.
560
1004
  // (data-derived metadata can't stream — the <head> needs the title.)
561
1005
  if (target === "view" && resolved.loading && typeof resolved.def.metadata !== "function") {
1006
+ // Everything the shell awaits happens BEFORE load() starts: once the load
1007
+ // promise exists, it must reach React (use()) with no await in between, or
1008
+ // a load that rejects meanwhile is an unhandled rejection (fatal on a Node
1009
+ // host with --unhandled-rejections=throw). Metadata is static on this path,
1010
+ // so the breadcrumb trail doesn't depend on the load.
1011
+ const breadcrumbs = await breadcrumbsFor(ctx, resolved.def.metadata);
562
1012
  const loadPromise = Promise.resolve(
563
1013
  resolved.def.load ? resolved.def.load(ctx) : undefined,
564
1014
  );
565
- return renderStreamingDocument(resolved, loadPromise, ctx);
1015
+ return renderStreamingDocument(resolved, loadPromise, ctx, breadcrumbs);
566
1016
  }
567
1017
 
568
1018
  let data: unknown;