@junejs/server 1.0.0-dev.28 → 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.
- package/dist/agent-compile.d.ts +8 -9
- package/dist/agent-discover.d.ts +3 -4
- package/dist/agent-durable.d.ts +20 -19
- package/dist/agent-durable.js +41 -6
- package/dist/agent-native.d.ts +10 -11
- package/dist/agent-native.js +6 -2
- package/dist/app.d.ts +4 -5
- package/dist/app.js +2 -1
- package/dist/blob.d.ts +4 -5
- package/dist/build.d.ts +6 -6
- package/dist/build.js +34 -7
- package/dist/client-bundle.d.ts +8 -9
- package/dist/{config-freeze-DwI-Rmvq.d.ts → config-freeze-ZWC_Qd4h.d.ts} +1 -1
- package/dist/config-freeze.d.ts +1 -1
- package/dist/config-loader.d.ts +3 -4
- package/dist/connection-auth.d.ts +8 -9
- package/dist/content.d.ts +9 -10
- package/dist/core-version.d.ts +3 -4
- package/dist/css-modules-ambient.d.ts +2 -1
- package/dist/css-modules.d.ts +10 -11
- package/dist/css.d.ts +9 -10
- package/dist/d1-migrate.d.ts +6 -7
- package/dist/d1-migrate.js +3 -2
- package/dist/db.d.ts +2 -2
- package/dist/deploy.d.ts +3 -4
- package/dist/deploy.js +3 -2
- package/dist/dev-reload.d.ts +3 -4
- package/dist/dev.d.ts +4 -5
- package/dist/favicon.d.ts +16 -17
- package/dist/host.d.ts +5 -5
- package/dist/icon-letter.d.ts +2 -3
- package/dist/index.d.ts +4 -4
- package/dist/instrumentation.d.ts +2 -3
- package/dist/instrumentation.js +2 -1
- package/dist/island-registry.d.ts +6 -7
- package/dist/isolate-local.d.ts +4 -5
- package/dist/kv.d.ts +3 -4
- package/dist/llms-links.d.ts +9 -0
- package/dist/llms-links.js +65 -0
- package/dist/{manifest-gcNWqWoI.d.ts → manifest-DSNX4I-G.d.ts} +2 -2
- package/dist/manifest.d.ts +1 -1
- package/dist/negotiate.d.ts +4 -3
- package/dist/negotiate.js +24 -14
- package/dist/{pipeline-cCR6MGNK.d.ts → pipeline-BHUjBO0G.d.ts} +5 -1
- package/dist/pipeline.d.ts +2 -2
- package/dist/pipeline.js +263 -39
- package/dist/resources.d.ts +5 -6
- package/dist/router.d.ts +12 -13
- package/dist/rsc-build.d.ts +6 -7
- package/dist/rsc-bundle.d.ts +6 -7
- package/dist/rsc-manifest.d.ts +5 -6
- package/dist/rsc-runtime/flight-render-routes.d.ts +3 -4
- package/dist/rsc-runtime/flight-render.d.ts +2 -3
- package/dist/rsc-runtime/flight-to-html.d.ts +2 -3
- package/dist/rsc-runtime/flight-to-html.js +3 -2
- package/dist/rsc-runtime/rsdw-shims.d.ts +2 -1
- package/dist/segment.d.ts +4 -5
- package/dist/sqlite-driver.d.ts +10 -11
- package/dist/static-files.d.ts +4 -5
- package/dist/tsconfig-jsx.d.ts +4 -5
- package/dist/turn-events.d.ts +2 -3
- package/dist/web-manifest.d.ts +3 -4
- package/dist/web-manifest.js +2 -6
- package/dist/{worker-BkFHGtxp.d.ts → worker-Dv4l6Ybs.d.ts} +4 -2
- package/dist/worker.d.ts +1 -1
- package/dist/worker.js +35 -18
- package/package.json +5 -5
- package/src/agent-durable.ts +39 -10
- package/src/agent-native.ts +5 -1
- package/src/build.ts +63 -5
- package/src/llms-links.ts +96 -0
- package/src/negotiate.ts +39 -15
- package/src/pipeline.ts +437 -47
- package/src/web-manifest.ts +2 -6
- 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
|
|
63
|
+
import { siteShortName, type AgentConfig } from "@junejs/core/config";
|
|
41
64
|
import {
|
|
42
65
|
LOCALE_COOKIE,
|
|
43
66
|
localeAlternates,
|
|
@@ -50,6 +73,7 @@ import {
|
|
|
50
73
|
import type { Resources } from "@junejs/core/resources";
|
|
51
74
|
|
|
52
75
|
import { iconLetter } from "./icon-letter";
|
|
76
|
+
import { collectLlmsLinks, collectSitemapPages } from "./llms-links";
|
|
53
77
|
import { webManifest } from "./web-manifest";
|
|
54
78
|
import { negotiate, TITLE_HEADER, SEGMENT_HEADER, encodeTitle } from "./negotiate";
|
|
55
79
|
|
|
@@ -121,6 +145,11 @@ export type PipelineConfig = {
|
|
|
121
145
|
// re-scan the filesystem; the worker returns a frozen array.
|
|
122
146
|
routeList: () => Promise<string[]> | string[];
|
|
123
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;
|
|
124
153
|
// Opened data resources (db/kv/blob) injected onto ctx before load(). A
|
|
125
154
|
// provider so opening is lazy/memoized; absent → no resources on ctx.
|
|
126
155
|
resources?: () => Promise<Resources> | Resources;
|
|
@@ -170,6 +199,9 @@ const DefaultNotFound: React.ComponentType<{ pathname: string }> = ({ pathname }
|
|
|
170
199
|
React.createElement("p", null, pathname),
|
|
171
200
|
);
|
|
172
201
|
|
|
202
|
+
// Catalogs crawlers and browser-based agents fetch cross-origin (ARD requires it).
|
|
203
|
+
const CORS = { "access-control-allow-origin": "*" };
|
|
204
|
+
|
|
173
205
|
function text(body: string, contentType: string, init?: ResponseInit) {
|
|
174
206
|
const headers = new Headers(init?.headers);
|
|
175
207
|
headers.set("content-type", contentType);
|
|
@@ -217,19 +249,91 @@ function letterFavicon(siteName: string | undefined): Response {
|
|
|
217
249
|
);
|
|
218
250
|
}
|
|
219
251
|
|
|
220
|
-
|
|
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
|
+
}
|
|
221
305
|
|
|
222
306
|
// A request's matched route pathname (locale stripped, /index → /), keyed by its
|
|
223
307
|
// ctx — RouteContext is public API, so the pipeline tracks it on the side.
|
|
224
308
|
const routePathOf = new WeakMap<RouteContext, string>();
|
|
225
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
|
+
|
|
226
322
|
export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
227
323
|
const { docConfig, agent } = cfg;
|
|
228
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;
|
|
229
329
|
|
|
230
330
|
function htmlHeaders(): Headers {
|
|
231
|
-
|
|
232
|
-
|
|
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[];
|
|
233
337
|
if (links.length) headers.set("link", links.join(", "));
|
|
234
338
|
if (cfg.htmlCacheControl) headers.set("cache-control", cfg.htmlCacheControl);
|
|
235
339
|
return headers;
|
|
@@ -261,15 +365,60 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
261
365
|
// The document's page-identity props: the URL for og:url/canonical, whether the
|
|
262
366
|
// MATCHED route is home (so /de and /index count, per negotiate's normalized
|
|
263
367
|
// pathname), and whether the host is a locale's own domain (its public origin).
|
|
264
|
-
function pageProps(ctx: RouteContext): PageProps {
|
|
265
|
-
const
|
|
368
|
+
function pageProps(ctx: RouteContext, def: BrandedRoute): PageProps {
|
|
369
|
+
const isHome = routePathOf.get(ctx) === "/";
|
|
266
370
|
return {
|
|
267
371
|
pageUrl: ctx.url.href,
|
|
268
|
-
isHome
|
|
269
|
-
onLocaleDomain:
|
|
270
|
-
|
|
271
|
-
|
|
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 || "/",
|
|
272
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;
|
|
273
422
|
}
|
|
274
423
|
|
|
275
424
|
async function renderDocument(
|
|
@@ -289,7 +438,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
289
438
|
);
|
|
290
439
|
const stream = await renderToReadableStream(
|
|
291
440
|
React.createElement(Document, {
|
|
292
|
-
config: docConfigForRender(),
|
|
441
|
+
config: docConfigForRender(page?.pageUrl),
|
|
293
442
|
metadata,
|
|
294
443
|
children: wrapped,
|
|
295
444
|
shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
|
|
@@ -348,9 +497,17 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
348
497
|
// WebMCP: register the app's actions as browser tools. Computed per render
|
|
349
498
|
// from the live registry (stable after warmup), gated on agent.webmcp + mcp
|
|
350
499
|
// (execute proxies to /mcp). No actions → no script → page stays zero-JS.
|
|
351
|
-
function docConfigForRender(): DocumentConfig {
|
|
500
|
+
function docConfigForRender(pageUrl?: string): DocumentConfig {
|
|
352
501
|
const webmcpTools = agent.webmcp && agent.mcp ? mcpTools() : null;
|
|
353
|
-
|
|
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
|
+
};
|
|
354
511
|
}
|
|
355
512
|
|
|
356
513
|
// Streaming Suspense: the shell (layout chain + the loading.tsx fallback)
|
|
@@ -362,6 +519,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
362
519
|
resolved: Resolved,
|
|
363
520
|
loadPromise: Promise<unknown>,
|
|
364
521
|
ctx: RouteContext,
|
|
522
|
+
breadcrumbs: Breadcrumb[] | undefined,
|
|
365
523
|
): Promise<Response> {
|
|
366
524
|
const { def, chain, loading: Loading, boundaryKey } = resolved;
|
|
367
525
|
const leaf = React.createElement(StreamedView, { loadPromise, def, ctx });
|
|
@@ -375,14 +533,17 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
375
533
|
boundary,
|
|
376
534
|
);
|
|
377
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.
|
|
378
538
|
const stream = await renderToReadableStream(
|
|
379
539
|
React.createElement(Document, {
|
|
380
|
-
config: docConfigForRender(),
|
|
540
|
+
config: docConfigForRender(ctx.url.href),
|
|
381
541
|
metadata,
|
|
382
542
|
children: wrapped,
|
|
383
543
|
shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
|
|
384
544
|
alternates: alternatesFor(ctx),
|
|
385
|
-
...pageProps(ctx),
|
|
545
|
+
...pageProps(ctx, def),
|
|
546
|
+
breadcrumbs,
|
|
386
547
|
...langDir(ctx.locale),
|
|
387
548
|
}),
|
|
388
549
|
{ onError: (e: unknown) => console.error("[june] streaming render error:", e) },
|
|
@@ -392,21 +553,94 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
392
553
|
return new Response(stream, { status: 200, headers: htmlHeaders() });
|
|
393
554
|
}
|
|
394
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
|
+
|
|
395
569
|
function notFoundResponse(
|
|
396
570
|
target: RenderTarget,
|
|
397
571
|
pathname: string,
|
|
398
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,
|
|
399
578
|
): Promise<Response> | Response {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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");
|
|
410
644
|
}
|
|
411
645
|
|
|
412
646
|
function resolveMeta(def: BrandedRoute, data: unknown, ctx: RouteContext): Metadata | undefined {
|
|
@@ -420,16 +654,38 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
420
654
|
// json is also absent). md/json === false is handled as 404 by the caller.
|
|
421
655
|
const jsonData =
|
|
422
656
|
typeof def.json === "function" ? await def.json(data, ctx) : def.json === false ? null : data;
|
|
423
|
-
const
|
|
657
|
+
const content =
|
|
424
658
|
typeof def.md === "function"
|
|
425
659
|
? await def.md(data, ctx)
|
|
426
660
|
: "```json\n" + JSON.stringify(jsonData, null, 2) + "\n```\n";
|
|
661
|
+
const body = withFrontmatter(content, resolveMeta(def, data, ctx), ctx);
|
|
427
662
|
// x-markdown-tokens: a rough estimate (~4 chars/token) agents use to budget.
|
|
428
663
|
return text(body, "text/markdown; charset=utf-8", {
|
|
429
664
|
headers: { "x-markdown-tokens": String(Math.ceil(body.length / 4)) },
|
|
430
665
|
});
|
|
431
666
|
}
|
|
432
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
|
+
|
|
433
689
|
async function renderProjection(
|
|
434
690
|
resolved: Resolved,
|
|
435
691
|
target: RenderTarget,
|
|
@@ -475,27 +731,124 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
475
731
|
// renders the whole chain (a hard load of the URL is never segment-scoped) —
|
|
476
732
|
// but it stamps the shell key so the client knows which shell is mounted.
|
|
477
733
|
if (target === "fragment") return renderFragment(node, meta, chain, boundaryIndex, boundaryKey);
|
|
478
|
-
|
|
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;
|
|
479
793
|
}
|
|
480
794
|
|
|
481
795
|
async function discovery(url: URL): Promise<Response | null> {
|
|
796
|
+
const catalog = await agentCatalog(url);
|
|
797
|
+
if (catalog) return catalog;
|
|
482
798
|
switch (url.pathname) {
|
|
483
|
-
case "/llms.txt":
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
799
|
+
case "/llms.txt": {
|
|
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 ?? "";
|
|
805
|
+
// sections, descriptions, and Optional come from each route's `llms` export
|
|
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");
|
|
813
|
+
}
|
|
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 });
|
|
491
819
|
return text(
|
|
492
|
-
sitemapXml(
|
|
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
|
+
),
|
|
493
828
|
"application/xml; charset=utf-8",
|
|
494
829
|
);
|
|
830
|
+
}
|
|
495
831
|
case "/.well-known/api-catalog":
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
return
|
|
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;
|
|
499
852
|
default:
|
|
500
853
|
return null;
|
|
501
854
|
}
|
|
@@ -512,11 +865,42 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
512
865
|
// the same principal: requiresPrincipal actions and per-call connection
|
|
513
866
|
// auth are live on this surface. ctx carries identity only — not resources.
|
|
514
867
|
const identity = cfg.identity ? await cfg.identity(request) : undefined;
|
|
515
|
-
return mcpHandler(
|
|
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 });
|
|
516
898
|
}
|
|
517
|
-
|
|
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) {
|
|
518
902
|
const d = await discovery(url);
|
|
519
|
-
if (d) return d;
|
|
903
|
+
if (d) return request.method === "HEAD" ? new Response(null, { status: d.status, headers: d.headers }) : d;
|
|
520
904
|
}
|
|
521
905
|
|
|
522
906
|
// --- durable agent surface (chat + channels) -------------------------
|
|
@@ -581,8 +965,8 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
581
965
|
|
|
582
966
|
// --- routes ----------------------------------------------------------
|
|
583
967
|
const { target, pathname, speculative } = negotiate(url, request, routeBase);
|
|
584
|
-
const resolved = await
|
|
585
|
-
if (!resolved) return notFoundResponse(target, pathname, locale);
|
|
968
|
+
const resolved = await resolveRoute(pathname);
|
|
969
|
+
if (!resolved) return notFoundResponse(target, pathname, locale, request);
|
|
586
970
|
|
|
587
971
|
// ctx is identity/request only; db/kv/blob are ambient (read from the
|
|
588
972
|
// request scope this whole handler runs inside — see runInScope below).
|
|
@@ -603,10 +987,16 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
603
987
|
// static metadata flushes the shell + fallback before load() resolves.
|
|
604
988
|
// (data-derived metadata can't stream — the <head> needs the title.)
|
|
605
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);
|
|
606
996
|
const loadPromise = Promise.resolve(
|
|
607
997
|
resolved.def.load ? resolved.def.load(ctx) : undefined,
|
|
608
998
|
);
|
|
609
|
-
return renderStreamingDocument(resolved, loadPromise, ctx);
|
|
999
|
+
return renderStreamingDocument(resolved, loadPromise, ctx, breadcrumbs);
|
|
610
1000
|
}
|
|
611
1001
|
|
|
612
1002
|
let data: unknown;
|