@nifrajs/web 5.0.0 → 5.1.0

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 (109) hide show
  1. package/README.md +8 -0
  2. package/dist/client.d.ts +5 -5
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +25 -15
  5. package/dist/client.js.map +1 -1
  6. package/dist/dev-port.d.ts +3 -2
  7. package/dist/dev-port.d.ts.map +1 -1
  8. package/dist/dev-port.js +2 -6
  9. package/dist/dev-port.js.map +1 -1
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/internal/package-entries.d.ts +34 -0
  15. package/dist/internal/package-entries.d.ts.map +1 -0
  16. package/dist/internal/package-entries.js +324 -0
  17. package/dist/internal/package-entries.js.map +1 -0
  18. package/dist/internal/page-execution.d.ts.map +1 -1
  19. package/dist/internal/page-execution.js +60 -6
  20. package/dist/internal/page-execution.js.map +1 -1
  21. package/dist/internal/parity.d.ts.map +1 -1
  22. package/dist/internal/parity.js +15 -2
  23. package/dist/internal/parity.js.map +1 -1
  24. package/dist/internal/private-env.d.ts +8 -0
  25. package/dist/internal/private-env.d.ts.map +1 -1
  26. package/dist/internal/private-env.js +25 -10
  27. package/dist/internal/private-env.js.map +1 -1
  28. package/dist/internal/render-document.d.ts +4 -44
  29. package/dist/internal/render-document.d.ts.map +1 -1
  30. package/dist/internal/render-document.js +23 -58
  31. package/dist/internal/render-document.js.map +1 -1
  32. package/dist/internal/route-path.d.ts +6 -0
  33. package/dist/internal/route-path.d.ts.map +1 -1
  34. package/dist/internal/route-path.js +11 -0
  35. package/dist/internal/route-path.js.map +1 -1
  36. package/dist/internal/secret-scan.d.ts.map +1 -1
  37. package/dist/internal/secret-scan.js +103 -1
  38. package/dist/internal/secret-scan.js.map +1 -1
  39. package/dist/internal/web-app.d.ts +4 -2
  40. package/dist/internal/web-app.d.ts.map +1 -1
  41. package/dist/internal/web-app.js +8 -3
  42. package/dist/internal/web-app.js.map +1 -1
  43. package/dist/internal/without-query.d.ts +4 -0
  44. package/dist/internal/without-query.d.ts.map +1 -0
  45. package/dist/internal/without-query.js +9 -0
  46. package/dist/internal/without-query.js.map +1 -0
  47. package/dist/internal/zone-graph.d.ts.map +1 -1
  48. package/dist/internal/zone-graph.js +17 -6
  49. package/dist/internal/zone-graph.js.map +1 -1
  50. package/dist/isr.d.ts +1 -1
  51. package/dist/isr.d.ts.map +1 -1
  52. package/dist/isr.js +15 -2
  53. package/dist/isr.js.map +1 -1
  54. package/dist/manifest.d.ts +9 -0
  55. package/dist/manifest.d.ts.map +1 -1
  56. package/dist/manifest.js +1 -0
  57. package/dist/manifest.js.map +1 -1
  58. package/dist/plugins/vite-leak-guard.d.ts +6 -0
  59. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  60. package/dist/plugins/vite-leak-guard.js +80 -7
  61. package/dist/plugins/vite-leak-guard.js.map +1 -1
  62. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  63. package/dist/plugins/vite-zone-guard.js +17 -21
  64. package/dist/plugins/vite-zone-guard.js.map +1 -1
  65. package/dist/plugins/zone-guard.d.ts.map +1 -1
  66. package/dist/plugins/zone-guard.js +44 -3
  67. package/dist/plugins/zone-guard.js.map +1 -1
  68. package/dist/prerender.d.ts +5 -1
  69. package/dist/prerender.d.ts.map +1 -1
  70. package/dist/prerender.js +41 -19
  71. package/dist/prerender.js.map +1 -1
  72. package/dist/render-seam.d.ts +23 -2
  73. package/dist/render-seam.d.ts.map +1 -1
  74. package/dist/render-seam.js.map +1 -1
  75. package/dist/router.d.ts.map +1 -1
  76. package/dist/router.js +9 -5
  77. package/dist/router.js.map +1 -1
  78. package/dist/seo.d.ts +49 -0
  79. package/dist/seo.d.ts.map +1 -0
  80. package/dist/seo.js +48 -0
  81. package/dist/seo.js.map +1 -0
  82. package/dist/zones.d.ts +30 -8
  83. package/dist/zones.d.ts.map +1 -1
  84. package/dist/zones.js +185 -46
  85. package/dist/zones.js.map +1 -1
  86. package/package.json +8 -3
  87. package/src/client.ts +27 -15
  88. package/src/dev-port.ts +4 -10
  89. package/src/index.ts +2 -4
  90. package/src/internal/package-entries.ts +331 -0
  91. package/src/internal/page-execution.ts +67 -6
  92. package/src/internal/parity.ts +15 -2
  93. package/src/internal/private-env.ts +27 -13
  94. package/src/internal/render-document.ts +40 -78
  95. package/src/internal/route-path.ts +16 -0
  96. package/src/internal/secret-scan.ts +101 -1
  97. package/src/internal/web-app.ts +10 -5
  98. package/src/internal/without-query.ts +8 -0
  99. package/src/internal/zone-graph.ts +19 -6
  100. package/src/isr.ts +13 -2
  101. package/src/manifest.ts +10 -0
  102. package/src/plugins/vite-leak-guard.ts +99 -7
  103. package/src/plugins/vite-zone-guard.ts +21 -19
  104. package/src/plugins/zone-guard.ts +41 -2
  105. package/src/prerender.ts +39 -18
  106. package/src/render-seam.ts +28 -2
  107. package/src/router.ts +9 -5
  108. package/src/seo.ts +68 -0
  109. package/src/zones.ts +237 -52
@@ -6,15 +6,12 @@ import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, prepareDeferred } from "../defer
6
6
  import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "../isr.ts"
7
7
  import type {
8
8
  Loader as LayoutLoader,
9
- LinkDescriptor,
10
9
  Meta,
11
- MetaDescriptor,
12
10
  MetaInput,
13
11
  RouteModule,
14
- ScriptDescriptor,
15
12
  UnsafeScriptDescriptor,
16
13
  } from "../manifest.ts"
17
- import type { MatchChain, RenderAdapter, RenderProps } from "../render-seam.ts"
14
+ import type { DocumentParts, MatchChain, RenderAdapter, RenderProps } from "../render-seam.ts"
18
15
  import {
19
16
  ACTION_GLOBAL,
20
17
  BOUNDARY_GLOBAL,
@@ -201,8 +198,10 @@ export interface RenderPageOptions {
201
198
  export interface RenderAssemblyCache {
202
199
  /** Shell up to (not including) the deferred-runtime insertion point. */
203
200
  shellPre?: string
204
- /** Shell from after the deferred-runtime insertion point through the open `#root` container. */
201
+ /** Shell from after the deferred-runtime insertion point through `<body>`. */
205
202
  shellPost?: string
203
+ /** The open `#root` container. */
204
+ rootOpen?: string
206
205
  /** Tail opener: the handover script open tag + the data member's key. */
207
206
  tailPre?: string
208
207
  /** Tail from after the per-request handover members (the route id, prerendered paths, the handover
@@ -423,7 +422,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
423
422
  ? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}`
424
423
  : ""
425
424
  slot.shellPre = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head, nonce)}${styleLinks}${entryPreloads}`
426
- slot.shellPost = `${islandPreloads}${hydrationHead}</head><body><div id="${escapeAttr(rootId)}"${rootMarker}>`
425
+ slot.shellPost = `${islandPreloads}${hydrationHead}</head><body>`
426
+ slot.rootOpen = `<div id="${escapeAttr(rootId)}"${rootMarker}>`
427
427
  // Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
428
428
  // client but can still mount no-framework islands (`@nifrajs/web/islands`).
429
429
  let islandTags = ""
@@ -441,7 +441,13 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
441
441
  // The deferred runtime rides only on a hydrating page (a static page has no client takeover), and
442
442
  // only when something actually deferred - matching the pre-cache emission exactly.
443
443
  const runtimeSeam = hydrate && allDeferred.length > 0 ? deferredRuntime : ""
444
- const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}`
444
+ const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}${slot.rootOpen}`
445
+ const parts: DocumentParts | undefined =
446
+ adapter.documentParts === true ? { head: "", body: "" } : undefined
447
+ const shellOf = (): string =>
448
+ parts === undefined || (parts.head === "" && parts.body === "")
449
+ ? shellHtml
450
+ : `${slot.shellPre}${runtimeSeam}${parts.head}${slot.shellPost}${parts.body}${slot.rootOpen}`
445
451
  // Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
446
452
  // aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
447
453
  const closeRootHtml = "</div>"
@@ -504,13 +510,22 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
504
510
  // gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
505
511
  // shell-readiness `await` does, so the `_error` boundary still maps it to a status.
506
512
  if (allDeferred.length === 0 && adapter.renderToString !== undefined) {
507
- const out = adapter.renderToString(chain, renderProps)
513
+ const out =
514
+ parts !== undefined
515
+ ? adapter.renderToString(
516
+ chain,
517
+ renderProps,
518
+ nonce === undefined ? { parts } : { nonce, parts },
519
+ )
520
+ : nonce === undefined
521
+ ? adapter.renderToString(chain, renderProps)
522
+ : adapter.renderToString(chain, renderProps, { nonce })
508
523
  return typeof out === "string"
509
- ? new BufferedRenderedPage(shellHtml + out + closeRootHtml + tailHtml, status, headers)
524
+ ? new BufferedRenderedPage(shellOf() + out + closeRootHtml + tailHtml, status, headers)
510
525
  : out.then(
511
526
  (bodyHtml) =>
512
527
  new BufferedRenderedPage(
513
- shellHtml + bodyHtml + closeRootHtml + tailHtml,
528
+ shellOf() + bodyHtml + closeRootHtml + tailHtml,
514
529
  status,
515
530
  headers,
516
531
  ),
@@ -521,7 +536,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
521
536
  adapter,
522
537
  chain,
523
538
  renderProps,
524
- shellHtml,
539
+ shellOf,
540
+ parts,
525
541
  closeRootHtml,
526
542
  allDeferred,
527
543
  tailHtml,
@@ -536,7 +552,8 @@ async function renderStreamedPage(
536
552
  adapter: RenderAdapter,
537
553
  chain: readonly unknown[],
538
554
  renderProps: RenderProps,
539
- shellHtml: string,
555
+ shellOf: () => string,
556
+ parts: DocumentParts | undefined,
540
557
  closeRootHtml: string,
541
558
  allDeferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
542
559
  tailHtml: string,
@@ -550,13 +567,20 @@ async function renderStreamedPage(
550
567
  // (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
551
568
  // sent. A *mid*-stream failure errors the body instead.
552
569
  const enc = TEXT_ENCODER
553
- const shell = enc.encode(shellHtml)
554
570
  const closeRoot = enc.encode(closeRootHtml)
555
571
  const tail = enc.encode(tailHtml)
556
572
  const appStream =
557
- nonce === undefined
558
- ? await adapter.renderToStream(chain, renderProps)
559
- : await adapter.renderToStream(chain, renderProps, { nonce })
573
+ parts !== undefined
574
+ ? await adapter.renderToStream(
575
+ chain,
576
+ renderProps,
577
+ nonce === undefined ? { parts } : { nonce, parts },
578
+ )
579
+ : nonce === undefined
580
+ ? await adapter.renderToStream(chain, renderProps)
581
+ : await adapter.renderToStream(chain, renderProps, { nonce })
582
+ // After the render: an adapter fills its document parts before handing back the stream.
583
+ const shell = enc.encode(shellOf())
560
584
  const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr)
561
585
  return new Response(body, { status, headers })
562
586
  }
@@ -1123,68 +1147,6 @@ function headTags(head: Meta | undefined, documentNonce?: string): string {
1123
1147
  return out
1124
1148
  }
1125
1149
 
1126
- /**
1127
- * A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
1128
- * engines which URL is authoritative for a page (deduping query-string / tracking variants).
1129
- *
1130
- * ```ts
1131
- * export const meta = (a) => ({ link: [canonical(`https://site.com/posts/${a.params.slug}`)] })
1132
- * ```
1133
- */
1134
- export function canonical(href: string): LinkDescriptor {
1135
- return { rel: "canonical", href }
1136
- }
1137
-
1138
- /** Inputs for {@link openGraph} - the common Open Graph properties. All optional; only the provided
1139
- * ones become tags. `type` defaults to `"website"`. */
1140
- export interface OpenGraphInput {
1141
- readonly title?: string
1142
- readonly description?: string
1143
- /** Absolute URL of the share image (`og:image`). */
1144
- readonly image?: string
1145
- /** Canonical URL of the page (`og:url`). */
1146
- readonly url?: string
1147
- /** Object type (`og:type`) - `"website"`, `"article"`, … Default `"website"`. */
1148
- readonly type?: string
1149
- }
1150
-
1151
- /**
1152
- * Build the Open Graph `<meta property="og:*">` entries for a route's `meta.meta`. Returns only the
1153
- * properties you supplied (plus `og:type`, defaulting to `"website"`), so it composes with other meta.
1154
- *
1155
- * ```ts
1156
- * export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
1157
- * ```
1158
- */
1159
- export function openGraph(input: OpenGraphInput): MetaDescriptor[] {
1160
- const tags: MetaDescriptor[] = []
1161
- const add = (property: string, content: string | undefined): void => {
1162
- if (content !== undefined) tags.push({ property, content })
1163
- }
1164
- add("og:title", input.title)
1165
- add("og:description", input.description)
1166
- add("og:image", input.image)
1167
- add("og:url", input.url)
1168
- // `og:type` always present (the spec's required property) - default "website" when unset.
1169
- tags.push({ property: "og:type", content: input.type ?? "website" })
1170
- return tags
1171
- }
1172
-
1173
- /**
1174
- * Build a JSON-LD `<script type="application/ld+json">` entry for a route's `meta.script` from a plain
1175
- * object. `JSON.stringify` produces the body; the head renderer breakout-escapes it (see
1176
- * `escapeScriptContent`), so a string field containing `</script>` is embedded safely.
1177
- *
1178
- * ```ts
1179
- * export const meta = {
1180
- * script: [jsonLd({ "@context": "https://schema.org", "@type": "Article", headline: "Hi" })],
1181
- * }
1182
- * ```
1183
- */
1184
- export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
1185
- return { type: "application/ld+json", content: JSON.stringify(data) }
1186
- }
1187
-
1188
1150
  /**
1189
1151
  * Deliberately unsafe escape hatch for executable inline code. The required nonce keeps the result
1190
1152
  * compatible with a strict CSP and makes the security-sensitive choice visible at the call site.
@@ -6,3 +6,19 @@
6
6
  */
7
7
  export const requestPattern = (pattern: string): string =>
8
8
  pattern.normalize("NFC").replace(/[^!-~]/gu, encodeURIComponent)
9
+
10
+ // This module also compiles without the DOM lib; at runtime the name is the browser's own global.
11
+ declare const location: { readonly origin: string } | undefined
12
+
13
+ /**
14
+ * An app path as a URL `fetch`, `history` and `location` resolve on this document's own origin. A path
15
+ * starting `//` or `/\` is the path of a page here (`https://shop.example//evil.example/x`), but resolved
16
+ * as written it is a protocol-relative URL to another host, so it is anchored to this origin.
17
+ */
18
+ export const appUrl = (path: string): string =>
19
+ path.charCodeAt(0) === 47 /* / */ &&
20
+ (path.charCodeAt(1) === 47 || path.charCodeAt(1) === 92) /* / or \ */ &&
21
+ typeof location !== "undefined" &&
22
+ location.origin !== "null"
23
+ ? location.origin + path
24
+ : path
@@ -293,6 +293,98 @@ function formatMatches(text: string): Match[] {
293
293
  return matches
294
294
  }
295
295
 
296
+ /** Words that mark a whole `_`/`-` segment of a value as a stand-in: `your_signing_secret`. */
297
+ const PLACEHOLDER_SEGMENT =
298
+ /^(?:your|example|placeholder|sample|dummy|changeme|replace|generated|x{3,})$/
299
+
300
+ const placeholderSegments = (value: string): boolean =>
301
+ value
302
+ .toLowerCase()
303
+ .split(/[_-]/)
304
+ .some((segment) => PLACEHOLDER_SEGMENT.test(segment))
305
+
306
+ /** A word-cased label: letters and marks, hyphen-joined words, an acronym. No digits, no runs of
307
+ * mixed case, no credential punctuation. */
308
+ const LABEL = /^(?:\p{Lu}{1,4}|\p{Lu}?[\p{Ll}\p{M}]+)(?:-(?:\p{Lu}{1,4}|\p{Lu}?[\p{Ll}\p{M}]+))*$/u
309
+
310
+ /** How many string values a catalog holds at least, and the share of them that is prose. */
311
+ const CATALOG_VALUES = 10
312
+
313
+ /** Where a `/` starts a regular expression: after anything but a value's end. */
314
+ const VALUE_END = /[\w$)\]}"'`]/
315
+
316
+ /**
317
+ * The spans of top-level object literals that read as message catalogs: at least
318
+ * {@link CATALOG_VALUES} string property values, most of them prose with whitespace. A lexer, not a
319
+ * parser, so a build scans without TypeScript; strings, comments and regular expressions are skipped.
320
+ */
321
+ function catalogSpans(text: string): Array<readonly [number, number]> {
322
+ const spans: Array<readonly [number, number]> = []
323
+ let depth = 0
324
+ let start = 0
325
+ let values = 0
326
+ let prose = 0
327
+ // Whether the open top-level brace starts an object literal, not a block such as a function body.
328
+ let literal = false
329
+ let last = ""
330
+ let i = 0
331
+ while (i < text.length) {
332
+ const c = text.charAt(i)
333
+ if (c === "/" && text[i + 1] === "/") {
334
+ const end = text.indexOf("\n", i)
335
+ i = end === -1 ? text.length : end
336
+ continue
337
+ }
338
+ if (c === "/" && text[i + 1] === "*") {
339
+ const end = text.indexOf("*/", i + 2)
340
+ i = end === -1 ? text.length : end + 2
341
+ continue
342
+ }
343
+ if (c === '"' || c === "'" || c === "`" || (c === "/" && !VALUE_END.test(last))) {
344
+ let j = i + 1
345
+ let inClass = false
346
+ for (; j < text.length; j++) {
347
+ const d = text[j]
348
+ if (d === "\\") {
349
+ j++
350
+ continue
351
+ }
352
+ if (c !== "`" && d === "\n") break
353
+ if (c !== "/") {
354
+ if (d === c) break
355
+ continue
356
+ }
357
+ if (d === "[") inClass = true
358
+ else if (d === "]") inClass = false
359
+ else if (d === "/" && !inClass) break
360
+ }
361
+ // A string after `:` is a property's value.
362
+ if (depth > 0 && c !== "/" && c !== "`" && last === ":") {
363
+ values++
364
+ if (/\s/.test(text.slice(i + 1, j))) prose++
365
+ }
366
+ last = c
367
+ i = j + 1
368
+ continue
369
+ }
370
+ if (c === "{") {
371
+ if (depth === 0) {
372
+ start = i
373
+ values = 0
374
+ prose = 0
375
+ const word = /([\w$]+)\s*$/.exec(text.slice(Math.max(0, i - 16), i))?.[1]
376
+ literal = last === "" || "=(,:[?".includes(last) || word === "default"
377
+ }
378
+ depth++
379
+ } else if (c === "}" && depth > 0 && --depth === 0) {
380
+ if (literal && values >= CATALOG_VALUES && prose * 2 > values) spans.push([start, i + 1])
381
+ }
382
+ if (!/\s/.test(c)) last = c
383
+ i++
384
+ }
385
+ return spans
386
+ }
387
+
296
388
  /** The credential a literal given to `name` carries, when the name is secret-like and the literal
297
389
  * looks random. */
298
390
  function credentialOf(name: string, value: string): string | undefined {
@@ -304,6 +396,7 @@ function credentialOf(name: string, value: string): string | undefined {
304
396
  secret === undefined ||
305
397
  PUBLIC_VALUE.test(secret) ||
306
398
  PLACEHOLDER.test(secret) ||
399
+ placeholderSegments(secret) ||
307
400
  !looksRandom(secret, 16)
308
401
  )
309
402
  return undefined
@@ -312,9 +405,16 @@ function credentialOf(name: string, value: string): string | undefined {
312
405
 
313
406
  function assignedMatches(text: string): Match[] {
314
407
  const matches: Match[] = []
408
+ let catalogs: ReadonlyArray<readonly [number, number]> | undefined
409
+ /** A label in a message catalog: `password: "Lykilorð"` names the field in another language. */
410
+ const catalogLabel = (value: string, valueAt: number): boolean => {
411
+ if (!LABEL.test(value)) return false
412
+ catalogs ??= catalogSpans(text)
413
+ return catalogs.some(([start, end]) => valueAt > start && valueAt < end)
414
+ }
315
415
  const add = (name: string, value: string, valueAt: number): void => {
316
416
  const secret = credentialOf(name, value)
317
- if (secret === undefined) return
417
+ if (secret === undefined || catalogLabel(value, valueAt)) return
318
418
  matches.push({
319
419
  rule: "assigned-secret",
320
420
  // The credential is the value's tail: a header value's scheme is not part of it.
@@ -199,8 +199,10 @@ export interface CreateWebAppOptions<Env = unknown> {
199
199
  * team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
200
200
  * routinely. Turn it on only for a repo whose guidelines you would publish as a page. */
201
201
  readonly publishLocalGuidelines?: boolean
202
- /** Serve `/llms.txt` and `/llms-full.txt` (default `true`): the page routes, and the backend routes
203
- * when the backend is mounted over HTTP at {@link apiPrefix}. `false` registers neither path. */
202
+ /** Serve `/llms.txt` and `/llms-full.txt`: the page routes, and the backend routes when the backend
203
+ * is mounted over HTTP at {@link apiPrefix}, with their request and response schemas in the full
204
+ * text. **Off by default**: both are public and unauthenticated, and they list every route, admin
205
+ * and internal ones included. Turn it on for an app whose whole surface you would publish. */
204
206
  readonly llmsTxt?: boolean
205
207
  /** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
206
208
  * the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
@@ -458,8 +460,10 @@ export function createWebApp<Env = unknown>(
458
460
  cacheChannel,
459
461
  })
460
462
 
463
+ let ownsRootCatchAll = false
461
464
  for (const route of manifest.routes) {
462
465
  const pattern = requestPattern(route.pattern)
466
+ if (pattern.startsWith("/*")) ownsRootCatchAll = true
463
467
  app.register(
464
468
  "GET",
465
469
  pattern,
@@ -473,7 +477,7 @@ export function createWebApp<Env = unknown>(
473
477
  // llms.txt & llms-full.txt describe what a visitor can reach: the pages, and the backend only when it
474
478
  // is served over HTTP - a backend the loaders call in-process has no public surface to describe. An
475
479
  // app route at either path takes precedence, and each text is built once per app.
476
- if (options.llmsTxt !== false) {
480
+ if (options.llmsTxt === true) {
477
481
  const llmsOptions = {
478
482
  includeLocalGuidelines: options.publishLocalGuidelines === true,
479
483
  apiPathPrefix: apiStrip ? mountPathPrefix(apiPrefix) : "",
@@ -518,8 +522,9 @@ export function createWebApp<Env = unknown>(
518
522
  }
519
523
 
520
524
  // Wildcard catch-all: unmatched paths render the nearest `_404` (404), or a plain text 404 if
521
- // absent.
522
- app.register("GET", "/*", undefined, pageExecutor.fallback)
525
+ // absent. A root catch-all page (`[...slug].tsx`) already answers every path, the root included,
526
+ // and the router refuses a second `/*` under another param name.
527
+ if (!ownsRootCatchAll) app.register("GET", "/*", undefined, pageExecutor.fallback)
523
528
 
524
529
  const evidenceProvider: ComposedEvidenceProvider = async (passed) => {
525
530
  const { traverseEvidence } = await import("./evidence-traversal.ts")
@@ -0,0 +1,8 @@
1
+ /** A module id or specifier up to its first `?` or `#`. An index scan, since `/[?#].*$/` retries
2
+ * from every `#` and runs in quadratic time on a long run of them before a line break. */
3
+ export function withoutQuery(value: string): string {
4
+ const query = value.indexOf("?")
5
+ const hash = value.indexOf("#")
6
+ const cut = query === -1 ? hash : hash === -1 ? query : Math.min(query, hash)
7
+ return cut === -1 ? value : value.slice(0, cut)
8
+ }
@@ -19,6 +19,7 @@ import {
19
19
  } from "../zones.ts"
20
20
  import { codeUnitOrder } from "./code-unit-order.ts"
21
21
  import { isBareNodeBuiltin } from "./node-builtins.ts"
22
+ import { withoutQuery } from "./without-query.ts"
22
23
 
23
24
  /** What a graph module id names. */
24
25
  export type ModuleSource =
@@ -30,9 +31,8 @@ export type ModuleSource =
30
31
 
31
32
  /** A namespace prefix needs two characters, so a Windows drive letter still reads as a path. */
32
33
  const NAMESPACED = /^([a-z][\w.+-]+):(.*)$/i
33
- const stripQuery = (id: string): string => id.replace(/[?#].*$/, "")
34
34
  const virtualOf = (rest: string): ModuleSource => {
35
- const file = stripQuery(rest)
35
+ const file = withoutQuery(rest)
36
36
  return isAbsolute(file) && existsSync(file) ? { kind: "virtual", file } : { kind: "virtual" }
37
37
  }
38
38
 
@@ -42,7 +42,7 @@ export function bunModuleSource(cwd: string): (id: string) => ModuleSource {
42
42
  if (id.startsWith("node:") || id.startsWith("bun:")) return { kind: "builtin" }
43
43
  const namespaced = NAMESPACED.exec(id)
44
44
  if (namespaced !== null && namespaced[1] !== "file") return virtualOf(namespaced[2] ?? "")
45
- return { kind: "file", file: resolve(cwd, stripQuery(namespaced?.[2] ?? id)) }
45
+ return { kind: "file", file: resolve(cwd, withoutQuery(namespaced?.[2] ?? id)) }
46
46
  }
47
47
  }
48
48
 
@@ -50,7 +50,7 @@ export function bunModuleSource(cwd: string): (id: string) => ModuleSource {
50
50
  export function rollupModuleSource(id: string): ModuleSource {
51
51
  if (id.startsWith("node:") || id.includes("__vite-browser-external")) return { kind: "builtin" }
52
52
  if (id.startsWith("\0")) return virtualOf(id.slice(1).replace(/^[\w-]+:/, ""))
53
- const file = stripQuery(id)
53
+ const file = withoutQuery(id)
54
54
  return isAbsolute(file) ? { kind: "file", file } : { kind: "virtual" }
55
55
  }
56
56
 
@@ -233,7 +233,9 @@ export function verifyClientGraph(
233
233
  if (file === undefined) continue
234
234
  const classification = classifier.classify(file)
235
235
  let reason = browserDenial(classification)
236
- for (const spec of specifiers.get(id) ?? []) reason ??= browserDenial(classification, spec)
236
+ // An import that names a package entry is judged by that entry's own declared side.
237
+ for (const spec of specifiers.get(id) ?? [])
238
+ reason ??= browserDenial(classifier.classify(file, spec), spec)
237
239
  if (reason === undefined && BROWSER_CODE.has(classification.zone))
238
240
  reason = options.privateEnv?.(file)
239
241
  if (reason !== undefined) denied.set(id, reason)
@@ -406,7 +408,18 @@ export function verifyServerGraph(
406
408
  const target = resolveEdge(im)
407
409
  if (target === undefined) continue
408
410
  out.push({ to: target, label: im.original ?? labelOf(target) })
409
- const to = classOf(target)
411
+ const targetFile = fileOf(target)
412
+ const to =
413
+ im.original === undefined || targetFile === undefined
414
+ ? classOf(target)
415
+ : classifier.classify(targetFile, im.original)
416
+ if (to?.zone === "error" && to.entry !== undefined)
417
+ found.push({
418
+ at: id,
419
+ module: labelOf(target),
420
+ reason: to.reason,
421
+ tail: im.original ?? labelOf(target),
422
+ })
410
423
  if (from === undefined || to === undefined || from.zone === "error" || to.zone === "error")
411
424
  continue
412
425
  const componentBridge =
package/src/isr.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * This module is the store primitive; the SWR wrapper (`withISR`) builds on it next.
8
8
  */
9
9
  import { isAuthenticatedRequest, writableResponse } from "@nifrajs/core/server"
10
+ import { readBoundedBytes, TransportCodecError } from "@nifrajs/core/transport-codec"
10
11
  import { isDraftEnabled } from "./draft.ts"
11
12
  import { assertTokenSecret, timingSafeEqual } from "./internal/timing-safe-equal.ts"
12
13
 
@@ -1139,6 +1140,8 @@ export interface CdnPurgeTarget {
1139
1140
  /** Most paths and tags one revalidation request may name, so a leaked token cannot fan out. */
1140
1141
  export const MAX_REVALIDATE_PATHS = 100
1141
1142
  export const MAX_REVALIDATE_TAGS = MAX_ISR_TAGS
1143
+ // Room for a full batch of long paths; a purge body past it is refused before it is buffered.
1144
+ const MAX_REVALIDATE_BODY_BYTES = 1024 * 1024
1142
1145
 
1143
1146
  export interface RevalidateEndpointOptions {
1144
1147
  readonly store: CacheStore
@@ -1180,7 +1183,7 @@ const stringList = (value: unknown): string[] | null | undefined => {
1180
1183
  * `?path=/blog/x`, `?tag=products`, a JSON `{ "path": "/blog/x" }` / `{ "tag": "products" }` body, or a
1181
1184
  * batch `{ "paths": [...], "tags": [...] }` of at most 100 paths and 32 tags.
1182
1185
  * The token is checked in **constant time** (wrong/missing → `401`); malformed targets → `400`;
1183
- * non-POST → `405`. A store without tag support returns `501` for tag requests. Mount it on a nifra route, e.g.
1186
+ * a JSON body over 1 MiB → `413`; non-POST → `405`. A store without tag support returns `501` for tag requests. Mount it on a nifra route, e.g.
1184
1187
  * `app.post("/__nifra/revalidate", (c) => handler(c.req))`.
1185
1188
  */
1186
1189
  export function revalidateEndpoint(
@@ -1205,7 +1208,15 @@ export function revalidateEndpoint(
1205
1208
  let tag = url.searchParams.get("tag")
1206
1209
  let batch: { paths: string[]; tags: string[] } | undefined
1207
1210
  if (path === null && tag === null) {
1208
- const body: unknown = await req.json().catch(() => null)
1211
+ let body: unknown = null
1212
+ try {
1213
+ const bytes = await readBoundedBytes(new Response(req.body, { headers: req.headers }), {
1214
+ maxBytes: MAX_REVALIDATE_BODY_BYTES,
1215
+ })
1216
+ body = JSON.parse(new TextDecoder().decode(bytes))
1217
+ } catch (error) {
1218
+ if (error instanceof TransportCodecError) return jsonError(413, "body_too_large")
1219
+ }
1209
1220
  if (typeof body === "object" && body !== null) {
1210
1221
  const candidate = body as { path?: unknown; tag?: unknown; paths?: unknown; tags?: unknown }
1211
1222
  if (candidate.paths !== undefined || candidate.tags !== undefined) {
package/src/manifest.ts CHANGED
@@ -337,6 +337,15 @@ export interface RouteModule {
337
337
  /** Layout loader that must run before actions and is not retained across the action's render. */
338
338
  readonly gate?: boolean
339
339
  readonly action?: Action
340
+ /**
341
+ * Backend half only: which browser origins may POST to {@link action}. When omitted, a request
342
+ * whose `Origin` is another site's is refused with 403 before middleware, gates or the action run;
343
+ * requests without an `Origin` (non-browser clients) pass. An array allows exactly those origins
344
+ * (and refuses a request without one); a function decides, receiving `null` when there is none.
345
+ * Use it for a callback a third party posts to, such as an identity provider's `form_post`.
346
+ * `Origin` is browser-enforced, not authentication: keep the action's own auth checks.
347
+ */
348
+ readonly allowedOrigins?: ReadonlyArray<string> | ((origin: string | null) => boolean)
340
349
  /**
341
350
  * Backend half only: the schema of what {@link loader} sends to the browser. Required whenever the
342
351
  * loader returns data. Keys it does not declare are dropped before the page renders; a declared
@@ -642,6 +651,7 @@ export const BACKEND_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
642
651
  "middleware",
643
652
  "gate",
644
653
  "shouldRevalidate",
654
+ "allowedOrigins",
645
655
  ])
646
656
 
647
657
  const routeError = (message: string): Error => new Error(`[nifra/web] ${message}`)