@nifrajs/web 5.0.0 → 5.2.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 (171) hide show
  1. package/CHANGELOG.md +2970 -0
  2. package/README.md +8 -0
  3. package/dist/build-plan.d.ts +7 -0
  4. package/dist/build-plan.d.ts.map +1 -1
  5. package/dist/build-plan.js +19 -0
  6. package/dist/build-plan.js.map +1 -1
  7. package/dist/build-vite.d.ts +1 -0
  8. package/dist/build-vite.d.ts.map +1 -1
  9. package/dist/build-vite.js +51 -3
  10. package/dist/build-vite.js.map +1 -1
  11. package/dist/build.d.ts +12 -1
  12. package/dist/build.d.ts.map +1 -1
  13. package/dist/build.js +43 -6
  14. package/dist/build.js.map +1 -1
  15. package/dist/client.d.ts +7 -6
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +39 -22
  18. package/dist/client.js.map +1 -1
  19. package/dist/dev-port.d.ts +3 -2
  20. package/dist/dev-port.d.ts.map +1 -1
  21. package/dist/dev-port.js +2 -6
  22. package/dist/dev-port.js.map +1 -1
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/codegen.d.ts +6 -0
  28. package/dist/internal/codegen.d.ts.map +1 -1
  29. package/dist/internal/codegen.js +23 -5
  30. package/dist/internal/codegen.js.map +1 -1
  31. package/dist/internal/copy-split.d.ts +27 -0
  32. package/dist/internal/copy-split.d.ts.map +1 -0
  33. package/dist/internal/copy-split.js +173 -0
  34. package/dist/internal/copy-split.js.map +1 -0
  35. package/dist/internal/package-entries.d.ts +34 -0
  36. package/dist/internal/package-entries.d.ts.map +1 -0
  37. package/dist/internal/package-entries.js +324 -0
  38. package/dist/internal/package-entries.js.map +1 -0
  39. package/dist/internal/page-execution.d.ts +19 -1
  40. package/dist/internal/page-execution.d.ts.map +1 -1
  41. package/dist/internal/page-execution.js +123 -14
  42. package/dist/internal/page-execution.js.map +1 -1
  43. package/dist/internal/parity.d.ts.map +1 -1
  44. package/dist/internal/parity.js +15 -2
  45. package/dist/internal/parity.js.map +1 -1
  46. package/dist/internal/private-env.d.ts +8 -0
  47. package/dist/internal/private-env.d.ts.map +1 -1
  48. package/dist/internal/private-env.js +25 -10
  49. package/dist/internal/private-env.js.map +1 -1
  50. package/dist/internal/render-document.d.ts +15 -46
  51. package/dist/internal/render-document.d.ts.map +1 -1
  52. package/dist/internal/render-document.js +59 -62
  53. package/dist/internal/render-document.js.map +1 -1
  54. package/dist/internal/route-path.d.ts +6 -0
  55. package/dist/internal/route-path.d.ts.map +1 -1
  56. package/dist/internal/route-path.js +11 -0
  57. package/dist/internal/route-path.js.map +1 -1
  58. package/dist/internal/secret-scan.d.ts.map +1 -1
  59. package/dist/internal/secret-scan.js +103 -1
  60. package/dist/internal/secret-scan.js.map +1 -1
  61. package/dist/internal/typescript-api.d.ts +4 -0
  62. package/dist/internal/typescript-api.d.ts.map +1 -0
  63. package/dist/internal/typescript-api.js +8 -0
  64. package/dist/internal/typescript-api.js.map +1 -0
  65. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  66. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  67. package/dist/internal/vite-plugin-compat.js +82 -0
  68. package/dist/internal/vite-plugin-compat.js.map +1 -0
  69. package/dist/internal/web-app.d.ts +38 -2
  70. package/dist/internal/web-app.d.ts.map +1 -1
  71. package/dist/internal/web-app.js +30 -3
  72. package/dist/internal/web-app.js.map +1 -1
  73. package/dist/internal/without-query.d.ts +4 -0
  74. package/dist/internal/without-query.d.ts.map +1 -0
  75. package/dist/internal/without-query.js +9 -0
  76. package/dist/internal/without-query.js.map +1 -0
  77. package/dist/internal/zone-graph.d.ts.map +1 -1
  78. package/dist/internal/zone-graph.js +17 -6
  79. package/dist/internal/zone-graph.js.map +1 -1
  80. package/dist/isr.d.ts +1 -1
  81. package/dist/isr.d.ts.map +1 -1
  82. package/dist/isr.js +15 -2
  83. package/dist/isr.js.map +1 -1
  84. package/dist/manifest.d.ts +9 -0
  85. package/dist/manifest.d.ts.map +1 -1
  86. package/dist/manifest.js +1 -0
  87. package/dist/manifest.js.map +1 -1
  88. package/dist/plugins/import-glob.d.ts +20 -0
  89. package/dist/plugins/import-glob.d.ts.map +1 -0
  90. package/dist/plugins/import-glob.js +401 -0
  91. package/dist/plugins/import-glob.js.map +1 -0
  92. package/dist/plugins/kit.d.ts +11 -0
  93. package/dist/plugins/kit.d.ts.map +1 -1
  94. package/dist/plugins/kit.js +32 -0
  95. package/dist/plugins/kit.js.map +1 -1
  96. package/dist/plugins/stylex.d.ts.map +1 -1
  97. package/dist/plugins/stylex.js +2 -2
  98. package/dist/plugins/stylex.js.map +1 -1
  99. package/dist/plugins/vite-leak-guard.d.ts +6 -0
  100. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  101. package/dist/plugins/vite-leak-guard.js +80 -7
  102. package/dist/plugins/vite-leak-guard.js.map +1 -1
  103. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  104. package/dist/plugins/vite-zone-guard.js +17 -21
  105. package/dist/plugins/vite-zone-guard.js.map +1 -1
  106. package/dist/plugins/zone-guard.d.ts.map +1 -1
  107. package/dist/plugins/zone-guard.js +44 -3
  108. package/dist/plugins/zone-guard.js.map +1 -1
  109. package/dist/prerender.d.ts +5 -1
  110. package/dist/prerender.d.ts.map +1 -1
  111. package/dist/prerender.js +41 -19
  112. package/dist/prerender.js.map +1 -1
  113. package/dist/render-seam.d.ts +60 -2
  114. package/dist/render-seam.d.ts.map +1 -1
  115. package/dist/render-seam.js +33 -0
  116. package/dist/render-seam.js.map +1 -1
  117. package/dist/router.d.ts +3 -2
  118. package/dist/router.d.ts.map +1 -1
  119. package/dist/router.js +13 -9
  120. package/dist/router.js.map +1 -1
  121. package/dist/seo.d.ts +49 -0
  122. package/dist/seo.d.ts.map +1 -0
  123. package/dist/seo.js +48 -0
  124. package/dist/seo.js.map +1 -0
  125. package/dist/server-components/typescript.d.ts.map +1 -1
  126. package/dist/server-components/typescript.js +2 -7
  127. package/dist/server-components/typescript.js.map +1 -1
  128. package/dist/vite.d.ts +1 -28
  129. package/dist/vite.d.ts.map +1 -1
  130. package/dist/vite.js +3 -69
  131. package/dist/vite.js.map +1 -1
  132. package/dist/zones.d.ts +30 -8
  133. package/dist/zones.d.ts.map +1 -1
  134. package/dist/zones.js +185 -46
  135. package/dist/zones.js.map +1 -1
  136. package/package.json +10 -4
  137. package/src/build-plan.ts +21 -0
  138. package/src/build-vite.ts +60 -3
  139. package/src/build.ts +57 -4
  140. package/src/client.ts +42 -22
  141. package/src/dev-port.ts +4 -10
  142. package/src/index.ts +8 -4
  143. package/src/internal/codegen.ts +32 -3
  144. package/src/internal/copy-split.ts +220 -0
  145. package/src/internal/package-entries.ts +331 -0
  146. package/src/internal/page-execution.ts +150 -13
  147. package/src/internal/parity.ts +15 -2
  148. package/src/internal/private-env.ts +27 -13
  149. package/src/internal/render-document.ts +100 -81
  150. package/src/internal/route-path.ts +16 -0
  151. package/src/internal/secret-scan.ts +101 -1
  152. package/src/internal/typescript-api.ts +11 -0
  153. package/src/internal/vite-plugin-compat.ts +81 -0
  154. package/src/internal/web-app.ts +71 -6
  155. package/src/internal/without-query.ts +8 -0
  156. package/src/internal/zone-graph.ts +19 -6
  157. package/src/isr.ts +13 -2
  158. package/src/manifest.ts +10 -0
  159. package/src/plugins/import-glob.ts +392 -0
  160. package/src/plugins/kit.ts +36 -0
  161. package/src/plugins/stylex.ts +2 -1
  162. package/src/plugins/vite-leak-guard.ts +99 -7
  163. package/src/plugins/vite-zone-guard.ts +21 -19
  164. package/src/plugins/zone-guard.ts +41 -2
  165. package/src/prerender.ts +39 -18
  166. package/src/render-seam.ts +88 -2
  167. package/src/router.ts +16 -11
  168. package/src/seo.ts +68 -0
  169. package/src/server-components/typescript.ts +2 -10
  170. package/src/vite.ts +3 -81
  171. package/src/zones.ts +237 -52
@@ -21,10 +21,54 @@ import type { RenderAdapter } from "../render-seam.ts"
21
21
  import { createMatcher, PRERENDERED_LIST_PATH } from "../router.ts"
22
22
  import type { EvidenceAncestry } from "./evidence-traversal.ts"
23
23
  import { formatShadowedPages, shadowedPages } from "./mount-shadow.ts"
24
- import { createPageRequestExecutor, type NonceResolver } from "./page-execution.ts"
24
+ import { createPageRequestExecutor, type NonceResolver, type PageI18n } from "./page-execution.ts"
25
25
  import { requestPattern } from "./route-path.ts"
26
26
 
27
27
  export type { NonceResolver } from "./page-execution.ts"
28
+
29
+ /** An app's locale routing as `createWebApp` reads it: what `defineI18nRouting()` from
30
+ * `@nifrajs/i18n/routing` returns. */
31
+ export interface WebAppI18n {
32
+ readonly locales: {
33
+ readonly default: string
34
+ /** Served locale keys, in registry order. */
35
+ readonly served: readonly string[]
36
+ documentMeta(key: string): { readonly lang: string; readonly dir: "ltr" | "rtl" }
37
+ }
38
+ localeOf(path: string): string
39
+ matchSegment(
40
+ value: string | undefined,
41
+ pathname: string,
42
+ ):
43
+ | { readonly kind: "ok" | "not-found" }
44
+ | { readonly kind: "redirect"; readonly location: string }
45
+ }
46
+
47
+ function pageI18n(routing: WebAppI18n): PageI18n {
48
+ const { locales } = routing
49
+ if (
50
+ typeof routing.localeOf !== "function" ||
51
+ typeof routing.matchSegment !== "function" ||
52
+ typeof locales?.documentMeta !== "function" ||
53
+ !Array.isArray(locales.served)
54
+ ) {
55
+ throw new TypeError(
56
+ "[nifra/web] createWebApp `i18n` must be the router defineI18nRouting() from @nifrajs/i18n/routing returns",
57
+ )
58
+ }
59
+ return {
60
+ locales: {
61
+ default: locales.default,
62
+ locales: locales.served.map((key) => {
63
+ const { lang, dir } = locales.documentMeta(key)
64
+ return { key, lang, dir }
65
+ }),
66
+ },
67
+ localeOf: (path) => routing.localeOf(path),
68
+ matchSegment: (value, pathname) => routing.matchSegment(value, pathname),
69
+ }
70
+ }
71
+
28
72
  export interface CreateWebAppOptions<Env = unknown> {
29
73
  readonly serverComponents?: import("../server-components/types.ts").ServerComponentRuntime
30
74
  readonly adapter: RenderAdapter
@@ -181,6 +225,20 @@ export interface CreateWebAppOptions<Env = unknown> {
181
225
  * of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
182
226
  * page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
183
227
  readonly routeStyles?: Readonly<Record<string, readonly string[]>>
228
+ /** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`, the server manifest's
229
+ * `copyChunks`). A production client build moves copy tables (`@nifrajs/i18n/copy`) into one chunk per
230
+ * locale; each page lists them for the browser and preloads its own. Omit ⇒ tables keep their values. */
231
+ readonly copyChunks?: Readonly<Record<string, string>>
232
+ /**
233
+ * The app's locale routing, `defineI18nRouting()` from `@nifrajs/i18n/routing`, registered once:
234
+ * export it as `i18n` from `backend/framework.ts`. Every document then takes its locale from the
235
+ * URL: `<html lang dir>` from the registry unless the route's meta sets `lang`, a `Content-Language`
236
+ * header, and the locale each adapter's `useLocale()` and `<I18nProvider>` read. A route under a
237
+ * `[lang]` or `[[lang]]` directory is checked with its `matchSegment()` before any middleware: an
238
+ * unserved locale, or a page missing from it, is a 404, and a non-canonical prefix (`/en/x` when the
239
+ * default is unprefixed) redirects with a 308.
240
+ */
241
+ readonly i18n?: WebAppI18n
184
242
  /**
185
243
  * Activation policy for framework-owned stylesheet links (default `"blocking"`). `"deferred"`
186
244
  * emits `media="print"` links; the generated client waits for them and switches them to `all` before
@@ -199,8 +257,10 @@ export interface CreateWebAppOptions<Env = unknown> {
199
257
  * team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
200
258
  * routinely. Turn it on only for a repo whose guidelines you would publish as a page. */
201
259
  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. */
260
+ /** Serve `/llms.txt` and `/llms-full.txt`: the page routes, and the backend routes when the backend
261
+ * is mounted over HTTP at {@link apiPrefix}, with their request and response schemas in the full
262
+ * text. **Off by default**: both are public and unauthenticated, and they list every route, admin
263
+ * and internal ones included. Turn it on for an app whose whole surface you would publish. */
204
264
  readonly llmsTxt?: boolean
205
265
  /** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
206
266
  * the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
@@ -440,6 +500,8 @@ export function createWebApp<Env = unknown>(
440
500
  ...(options.routePreload === undefined ? {} : { routePreload: options.routePreload }),
441
501
  ...(options.styles === undefined ? {} : { styles: options.styles }),
442
502
  ...(options.routeStyles === undefined ? {} : { routeStyles: options.routeStyles }),
503
+ ...(options.copyChunks === undefined ? {} : { copyChunks: options.copyChunks }),
504
+ ...(options.i18n === undefined ? {} : { i18n: pageI18n(options.i18n) }),
443
505
  ...(options.cssLoading === undefined ? {} : { cssLoading: options.cssLoading }),
444
506
  ...(options.prerenderedPaths === undefined
445
507
  ? {}
@@ -458,8 +520,10 @@ export function createWebApp<Env = unknown>(
458
520
  cacheChannel,
459
521
  })
460
522
 
523
+ let ownsRootCatchAll = false
461
524
  for (const route of manifest.routes) {
462
525
  const pattern = requestPattern(route.pattern)
526
+ if (pattern.startsWith("/*")) ownsRootCatchAll = true
463
527
  app.register(
464
528
  "GET",
465
529
  pattern,
@@ -473,7 +537,7 @@ export function createWebApp<Env = unknown>(
473
537
  // llms.txt & llms-full.txt describe what a visitor can reach: the pages, and the backend only when it
474
538
  // is served over HTTP - a backend the loaders call in-process has no public surface to describe. An
475
539
  // app route at either path takes precedence, and each text is built once per app.
476
- if (options.llmsTxt !== false) {
540
+ if (options.llmsTxt === true) {
477
541
  const llmsOptions = {
478
542
  includeLocalGuidelines: options.publishLocalGuidelines === true,
479
543
  apiPathPrefix: apiStrip ? mountPathPrefix(apiPrefix) : "",
@@ -518,8 +582,9 @@ export function createWebApp<Env = unknown>(
518
582
  }
519
583
 
520
584
  // 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)
585
+ // absent. A root catch-all page (`[...slug].tsx`) already answers every path, the root included,
586
+ // and the router refuses a second `/*` under another param name.
587
+ if (!ownsRootCatchAll) app.register("GET", "/*", undefined, pageExecutor.fallback)
523
588
 
524
589
  const evidenceProvider: ComposedEvidenceProvider = async (passed) => {
525
590
  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}`)
@@ -0,0 +1,392 @@
1
+ /**
2
+ * `import.meta.glob` for the Bun build. Vite expands it at build time and Bun has no equivalent, so
3
+ * without this a module that loads its siblings through a glob bundles with the call left in place:
4
+ * `import.meta.glob` is `undefined` in the browser and every file it named is missing from the build.
5
+ */
6
+ import { existsSync } from "node:fs"
7
+ import { dirname, extname, relative, resolve, sep } from "node:path"
8
+ import type { BunPlugin } from "bun"
9
+ import { readSource } from "./kit.ts"
10
+
11
+ const SOURCE = /\.[cm]?[jt]sx?$/
12
+ const CALL = /\bimport\.meta\.glob\b/g
13
+ const LOADERS: Readonly<Record<string, "ts" | "tsx" | "jsx" | "js">> = {
14
+ ".ts": "ts",
15
+ ".mts": "ts",
16
+ ".cts": "ts",
17
+ ".tsx": "tsx",
18
+ ".jsx": "jsx",
19
+ // Bun's own default: a `.js` module may hold JSX, a `.mjs` or `.cjs` one may not.
20
+ ".js": "jsx",
21
+ }
22
+
23
+ interface GlobOptions {
24
+ readonly eager: boolean
25
+ /** The export each entry takes: `"default"`, a name, or the whole module when unset. */
26
+ readonly pick: string | undefined
27
+ }
28
+
29
+ /** A literal the call's arguments may hold. */
30
+ type Literal = string | boolean | readonly string[] | Readonly<Record<string, string | boolean>>
31
+
32
+ /**
33
+ * `source` with every comment, string body and template text blanked to spaces, offsets kept, so a
34
+ * glob named in prose or inside a string is never read as a call. A template's `${}` stays code.
35
+ */
36
+ function codeOnly(source: string): string {
37
+ const out = source.split("")
38
+ const blank = (from: number, to: number): void => {
39
+ for (let index = from; index < to; index++) if (out[index] !== "\n") out[index] = " "
40
+ }
41
+ // Reads code from `start`; inside a template's `${`, returns past the `}` that closes it.
42
+ const code = (start: number, nested: boolean): number => {
43
+ let index = start
44
+ let depth = 0
45
+ while (index < source.length) {
46
+ const char = source[index]
47
+ if (source.startsWith("//", index)) {
48
+ const end = source.indexOf("\n", index)
49
+ const stop = end === -1 ? source.length : end
50
+ blank(index, stop)
51
+ index = stop
52
+ } else if (source.startsWith("/*", index)) {
53
+ const end = source.indexOf("*/", index + 2)
54
+ const stop = end === -1 ? source.length : end + 2
55
+ blank(index, stop)
56
+ index = stop
57
+ } else if (char === '"' || char === "'") {
58
+ let end = index + 1
59
+ while (end < source.length && source[end] !== char) end += source[end] === "\\" ? 2 : 1
60
+ blank(index + 1, Math.min(end, source.length))
61
+ index = end + 1
62
+ } else if (char === "`") {
63
+ index = template(index + 1)
64
+ } else if (nested && char === "}" && depth === 0) {
65
+ return index + 1
66
+ } else {
67
+ if (nested && char === "{") depth += 1
68
+ if (nested && char === "}") depth -= 1
69
+ index += 1
70
+ }
71
+ }
72
+ return index
73
+ }
74
+ const template = (start: number): number => {
75
+ let index = start
76
+ while (index < source.length && source[index] !== "`") {
77
+ if (source.startsWith("${", index)) {
78
+ index = code(index + 2, true)
79
+ } else {
80
+ const step = source[index] === "\\" ? 2 : 1
81
+ blank(index, index + step)
82
+ index += step
83
+ }
84
+ }
85
+ return index + 1
86
+ }
87
+ code(0, false)
88
+ return out.join("")
89
+ }
90
+
91
+ /** A reader over the call's argument text: string, array of strings, `{ key: literal }`, booleans. */
92
+ function argumentReader(text: string, start: number) {
93
+ let at = start
94
+ const space = (): void => {
95
+ while (at < text.length && /\s/.test(text[at] ?? "")) at += 1
96
+ }
97
+ const fail = (): never => {
98
+ throw new SyntaxError("not a literal")
99
+ }
100
+ const string = (): string => {
101
+ const quote = text[at]
102
+ if (quote !== '"' && quote !== "'" && quote !== "`") return fail()
103
+ let value = ""
104
+ at += 1
105
+ while (at < text.length && text[at] !== quote) {
106
+ const char = text[at] ?? ""
107
+ if (quote === "`" && char === "$" && text[at + 1] === "{") return fail()
108
+ if (char === "\\") {
109
+ value += text[at + 1] ?? ""
110
+ at += 2
111
+ } else {
112
+ value += char
113
+ at += 1
114
+ }
115
+ }
116
+ at += 1
117
+ return value
118
+ }
119
+ const literal = (): Literal => {
120
+ space()
121
+ const char = text[at]
122
+ if (char === "[") {
123
+ at += 1
124
+ const items: string[] = []
125
+ for (;;) {
126
+ space()
127
+ if (text[at] === "]") break
128
+ items.push(string())
129
+ space()
130
+ if (text[at] === ",") at += 1
131
+ else if (text[at] !== "]") return fail()
132
+ }
133
+ at += 1
134
+ return items
135
+ }
136
+ if (char === "{") {
137
+ at += 1
138
+ const entries: Record<string, string | boolean> = {}
139
+ for (;;) {
140
+ space()
141
+ if (text[at] === "}") break
142
+ const key = /^[A-Za-z_$][\w$]*/.exec(text.slice(at))?.[0]
143
+ const name = key ?? string()
144
+ if (key !== undefined) at += key.length
145
+ space()
146
+ if (text[at] !== ":") return fail()
147
+ at += 1
148
+ const value = literal()
149
+ if (typeof value !== "string" && typeof value !== "boolean") return fail()
150
+ entries[name] = value
151
+ space()
152
+ if (text[at] === ",") at += 1
153
+ else if (text[at] !== "}") return fail()
154
+ }
155
+ at += 1
156
+ return entries
157
+ }
158
+ if (text.startsWith("true", at) || text.startsWith("false", at)) {
159
+ const value = text.startsWith("true", at)
160
+ at += value ? 4 : 5
161
+ return value
162
+ }
163
+ return string()
164
+ }
165
+ return {
166
+ literal,
167
+ space,
168
+ get at() {
169
+ return at
170
+ },
171
+ expect(char: string): void {
172
+ space()
173
+ if (text[at] !== char) fail()
174
+ at += 1
175
+ },
176
+ peek(): string | undefined {
177
+ space()
178
+ return text[at]
179
+ },
180
+ }
181
+ }
182
+
183
+ const OPTION_NAMES = new Set(["eager", "import"])
184
+
185
+ // `Array.isArray` does not narrow a readonly array out of a union.
186
+ const isList = (value: Literal): value is readonly string[] => Array.isArray(value)
187
+
188
+ function readOptions(value: Literal | undefined): GlobOptions {
189
+ if (value === undefined) return { eager: false, pick: undefined }
190
+ if (typeof value !== "object" || isList(value)) throw new SyntaxError("options")
191
+ const options: Readonly<Record<string, string | boolean>> = value
192
+ for (const name of Object.keys(options))
193
+ if (!OPTION_NAMES.has(name)) throw new SyntaxError(`option ${name}`)
194
+ const eager = options.eager ?? false
195
+ const pick = options.import
196
+ if (typeof eager !== "boolean" || (pick !== undefined && typeof pick !== "string"))
197
+ throw new SyntaxError("option types")
198
+ return { eager, pick }
199
+ }
200
+
201
+ /** The files a set of patterns matches from `importer`, by Vite's rules, sorted; `!` excludes. */
202
+ function matchGlob(importer: string, root: string, patterns: readonly string[]): string[] {
203
+ // Globs read `\` as an escape, so on Windows every path is matched with forward slashes.
204
+ const slash = (path: string): string => (sep === "\\" ? path.replaceAll("\\", "/") : path)
205
+ const absolute = (pattern: string): string => {
206
+ if (pattern.startsWith("/")) return slash(resolve(root, `.${pattern}`))
207
+ if (pattern.startsWith("./") || pattern.startsWith("../"))
208
+ return slash(resolve(dirname(importer), pattern))
209
+ throw new SyntaxError(`pattern ${pattern}`)
210
+ }
211
+ const include = patterns.filter((pattern) => !pattern.startsWith("!")).map(absolute)
212
+ const exclude = patterns
213
+ .filter((pattern) => pattern.startsWith("!"))
214
+ .map((pattern) => new Bun.Glob(absolute(pattern.slice(1))))
215
+ const found = new Set<string>()
216
+ for (const pattern of include) {
217
+ const segments = pattern.split("/")
218
+ const wild = segments.findIndex((segment) => /[*?[\]{}]/.test(segment))
219
+ if (wild === -1) {
220
+ if (existsSync(pattern)) found.add(pattern)
221
+ continue
222
+ }
223
+ const base = segments.slice(0, wild).join("/") || "/"
224
+ const rest = segments.slice(wild).join("/")
225
+ for (const file of new Bun.Glob(rest).scanSync({ cwd: base, onlyFiles: true })) {
226
+ const path = slash(resolve(base, file))
227
+ if (!rest.includes("node_modules") && path.split("/").includes("node_modules")) continue
228
+ found.add(path)
229
+ }
230
+ }
231
+ const self = slash(importer)
232
+ return [...found]
233
+ .filter((path) => path !== self && !exclude.some((glob) => glob.match(path)))
234
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
235
+ }
236
+
237
+ /** `value` as a JS string literal that stays on one line for every parser: before ES2019, U+2028 and
238
+ * U+2029 end a line even inside a string. */
239
+ const stringLiteral = (value: string): string =>
240
+ JSON.stringify(value).replace(
241
+ /[\u2028\u2029]/g,
242
+ (char) => `\\u${char.charCodeAt(0).toString(16)}`,
243
+ )
244
+
245
+ /** How a matched file is keyed, as Vite keys it: from the importer (`./x.ts`) or the root (`/x.ts`). */
246
+ function keyOf(importer: string, root: string, file: string, rooted: boolean): string {
247
+ if (rooted) return `/${relative(root, file).replaceAll("\\", "/")}`
248
+ const path = relative(dirname(importer), file).replaceAll("\\", "/")
249
+ return path.startsWith(".") ? path : `./${path}`
250
+ }
251
+
252
+ /**
253
+ * How often `import.meta.glob` appears in the module's code as Bun reads it: types stripped and JSX
254
+ * text turned into strings, neither of which {@link codeOnly} can see in the source.
255
+ */
256
+ function globsInCode(source: string, file: string): number {
257
+ const js = new Bun.Transpiler({ loader: LOADERS[extname(file)] ?? "js" }).transformSync(source)
258
+ return codeOnly(js).match(CALL)?.length ?? 0
259
+ }
260
+
261
+ const unclear = (file: string): Error =>
262
+ new Error(
263
+ `[nifra/web] ${file}: the Bun build cannot tell this module's import.meta.glob calls from text that names it (JSX text, or a quote inside a regular expression). Move the calls into a .ts module, or build with --vite.`,
264
+ )
265
+
266
+ /**
267
+ * `source` (the module at `file`) with each `import.meta.glob(...)` call expanded into an object of
268
+ * the files it matches. Throws, naming the line, on a call this cannot read.
269
+ */
270
+ export function expandImportGlobs(source: string, file: string, root: string): string {
271
+ const real = globsInCode(source, file)
272
+ // A docs page that shows the call in JSX text has no call to expand, and rewriting its text is wrong.
273
+ if (real === 0) return source
274
+ // JSX text is not a string to `codeOnly`: an apostrophe there flips what it reads as code.
275
+ const code = codeOnly(source)
276
+ if ((code.match(CALL)?.length ?? 0) !== real) throw unclear(file)
277
+ const edits: Array<{ start: number; end: number; text: string }> = []
278
+ const hoisted: string[] = []
279
+ let call = 0
280
+ for (const match of code.matchAll(CALL)) {
281
+ let start = match.index
282
+ const line = source.slice(0, start).split("\n").length
283
+ // A bundle that expands the call has the function: a `typeof` guard around it reads so.
284
+ const guard = /\btypeof\s+$/.exec(code.slice(Math.max(0, start - 32), start))
285
+ if (guard !== null) {
286
+ start -= guard[0].length
287
+ edits.push({ start, end: match.index + match[0].length, text: '"function"' })
288
+ continue
289
+ }
290
+ const refuse = (why: string): never => {
291
+ throw new Error(
292
+ `[nifra/web] ${file}:${line}: import.meta.glob ${why}. The Bun build reads a literal pattern (a string or an array of strings starting with ./, ../ or /, "!" excluding) and the options eager and import; write it that way, or build with --vite.`,
293
+ )
294
+ }
295
+ let after = start + match[0].length
296
+ if (!/^\s*[(<]/.test(code.slice(after))) refuse("is read as a value, not called")
297
+ // `import.meta.glob<T>(...)`: TypeScript's type argument, dropped with the call.
298
+ if (code[after] === "<") {
299
+ const close = code.indexOf(">(", after)
300
+ if (close === -1) refuse("has a type argument this cannot read")
301
+ after = close + 1
302
+ }
303
+ const reader = argumentReader(source, after)
304
+ let patterns: readonly string[]
305
+ let options: GlobOptions
306
+ try {
307
+ reader.expect("(")
308
+ const first = reader.literal()
309
+ patterns = typeof first === "string" ? [first] : isList(first) ? first : []
310
+ if (patterns.length === 0) throw new SyntaxError("patterns")
311
+ let second: Literal | undefined
312
+ if (reader.peek() === ",") {
313
+ reader.expect(",")
314
+ if (reader.peek() !== ")") second = reader.literal()
315
+ }
316
+ options = readOptions(second)
317
+ if (reader.peek() === ",") reader.expect(",")
318
+ reader.expect(")")
319
+ } catch (error) {
320
+ return refuse(
321
+ error instanceof SyntaxError && error.message.startsWith("option ")
322
+ ? `takes the ${error.message.slice(7)} option, which the Bun build does not support`
323
+ : "takes arguments that are not literals",
324
+ )
325
+ }
326
+ let files: string[]
327
+ try {
328
+ files = matchGlob(file, root, patterns)
329
+ } catch {
330
+ return refuse("takes a pattern that does not start with ./, ../ or /")
331
+ }
332
+ const rooted = patterns.some((pattern) => pattern.replace(/^!/, "").startsWith("/"))
333
+ const entries = files.map((target, index) => {
334
+ const key = stringLiteral(keyOf(file, root, target, rooted))
335
+ const specifier = stringLiteral(keyOf(file, root, target, false))
336
+ if (options.eager) {
337
+ const name = `__nifra_glob_${call}_${index}`
338
+ hoisted.push(`import * as ${name} from ${specifier}`)
339
+ return `${key}: ${options.pick === undefined ? name : `${name}[${stringLiteral(options.pick)}]`}`
340
+ }
341
+ const load = `import(${specifier})`
342
+ return `${key}: () => ${options.pick === undefined ? load : `${load}.then((m) => m[${stringLiteral(options.pick)}])`}`
343
+ })
344
+ edits.push({ start, end: reader.at, text: `({${entries.join(", ")}})` })
345
+ call += 1
346
+ }
347
+ let next = source
348
+ for (const edit of edits.reverse())
349
+ next = next.slice(0, edit.start) + edit.text + next.slice(edit.end)
350
+ // Equal counts can still pair a text mention with a missed call; one left in the code is that.
351
+ if (globsInCode(next, file) !== 0) throw unclear(file)
352
+ // Import declarations are hoisted wherever they sit, so appending keeps any directive prologue first.
353
+ return hoisted.length === 0 ? next : `${next}\n${hoisted.join("\n")}\n`
354
+ }
355
+
356
+ /**
357
+ * Expand `import.meta.glob` in the app's own modules, for `Bun.build`. `root` is the app root, which a
358
+ * pattern starting with `/` is read from. Installed packages are left as published. Register it after
359
+ * every plugin that loads app modules itself (a server-function stub must win over it); a module such
360
+ * a plugin loads keeps its call, which {@link assertNoImportGlob} then refuses in the output. Register
361
+ * it before a plugin that rewrites source through `addSourceTransform`: it reads through that rewrite.
362
+ */
363
+ export function importGlobPlugin(options: { readonly root: string }): BunPlugin {
364
+ return {
365
+ name: "nifra-import-glob",
366
+ setup(build) {
367
+ build.onLoad({ filter: SOURCE }, async (args) => {
368
+ if (args.namespace !== "file" || args.path.split("/").includes("node_modules"))
369
+ return undefined
370
+ if (!(await Bun.file(args.path).text()).includes("import.meta.glob")) return undefined
371
+ // Claiming the module would skip a later plugin's rewrite of it (the copy split), so read
372
+ // through it.
373
+ const source = await readSource(build, args.path)
374
+ const contents = expandImportGlobs(source, args.path, options.root)
375
+ if (contents === source) return undefined
376
+ return { contents, loader: LOADERS[extname(args.path)] ?? "js" }
377
+ })
378
+ },
379
+ }
380
+ }
381
+
382
+ /** Refuse a bundle that still calls `import.meta.glob`: the browser has no such function, so every
383
+ * file the call named would be missing at run time. `outputs` are the emitted chunks, path to text. */
384
+ export function assertNoImportGlob(outputs: ReadonlyArray<readonly [string, string]>): void {
385
+ const left = outputs
386
+ .filter(([, text]) => /\bimport\.meta\.glob\s*\(/.test(text))
387
+ .map(([path]) => path)
388
+ if (left.length === 0) return
389
+ throw new Error(
390
+ `[nifra/web] ${left.join(", ")} still call${left.length === 1 ? "s" : ""} import.meta.glob, which no browser or server runtime defines. A plugin that loads a module itself runs before the glob expansion and keeps its call; expand it in that plugin, or build with --vite.`,
391
+ )
392
+ }