@nifrajs/web 5.3.0 → 5.5.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 (118) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/build-plan.d.ts +6 -0
  3. package/dist/build-plan.d.ts.map +1 -1
  4. package/dist/build-plan.js +25 -0
  5. package/dist/build-plan.js.map +1 -1
  6. package/dist/build-vite.d.ts +2 -0
  7. package/dist/build-vite.d.ts.map +1 -1
  8. package/dist/build-vite.js +2 -0
  9. package/dist/build-vite.js.map +1 -1
  10. package/dist/build.d.ts +17 -3
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +174 -54
  13. package/dist/build.js.map +1 -1
  14. package/dist/deferred.d.ts.map +1 -1
  15. package/dist/deferred.js +20 -6
  16. package/dist/deferred.js.map +1 -1
  17. package/dist/dev.d.ts +5 -0
  18. package/dist/dev.d.ts.map +1 -1
  19. package/dist/dev.js +7 -6
  20. package/dist/dev.js.map +1 -1
  21. package/dist/diagnostic.d.ts +6 -0
  22. package/dist/diagnostic.d.ts.map +1 -1
  23. package/dist/diagnostic.js +41 -0
  24. package/dist/diagnostic.js.map +1 -1
  25. package/dist/host-policy.d.ts +72 -0
  26. package/dist/host-policy.d.ts.map +1 -0
  27. package/dist/host-policy.js +149 -0
  28. package/dist/host-policy.js.map +1 -0
  29. package/dist/host-reach.d.ts +49 -0
  30. package/dist/host-reach.d.ts.map +1 -0
  31. package/dist/host-reach.js +144 -0
  32. package/dist/host-reach.js.map +1 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/internal/certified-components.d.ts +8 -0
  38. package/dist/internal/certified-components.d.ts.map +1 -0
  39. package/dist/internal/certified-components.js +8 -0
  40. package/dist/internal/certified-components.js.map +1 -0
  41. package/dist/internal/codegen.d.ts +8 -0
  42. package/dist/internal/codegen.d.ts.map +1 -1
  43. package/dist/internal/codegen.js +15 -4
  44. package/dist/internal/codegen.js.map +1 -1
  45. package/dist/internal/node-adapter.d.ts +19 -0
  46. package/dist/internal/node-adapter.d.ts.map +1 -0
  47. package/dist/internal/node-adapter.js +48 -0
  48. package/dist/internal/node-adapter.js.map +1 -0
  49. package/dist/internal/output-guard.d.ts +6 -0
  50. package/dist/internal/output-guard.d.ts.map +1 -1
  51. package/dist/internal/output-guard.js +118 -16
  52. package/dist/internal/output-guard.js.map +1 -1
  53. package/dist/internal/page-execution.d.ts +2 -0
  54. package/dist/internal/page-execution.d.ts.map +1 -1
  55. package/dist/internal/page-execution.js +16 -11
  56. package/dist/internal/page-execution.js.map +1 -1
  57. package/dist/internal/parity.d.ts.map +1 -1
  58. package/dist/internal/parity.js +3 -2
  59. package/dist/internal/parity.js.map +1 -1
  60. package/dist/internal/public-url.d.ts +0 -7
  61. package/dist/internal/public-url.d.ts.map +1 -1
  62. package/dist/internal/public-url.js +0 -13
  63. package/dist/internal/public-url.js.map +1 -1
  64. package/dist/internal/render-document.d.ts +3 -1
  65. package/dist/internal/render-document.d.ts.map +1 -1
  66. package/dist/internal/render-document.js +10 -4
  67. package/dist/internal/render-document.js.map +1 -1
  68. package/dist/internal/web-app.d.ts +15 -1
  69. package/dist/internal/web-app.d.ts.map +1 -1
  70. package/dist/internal/web-app.js +8 -0
  71. package/dist/internal/web-app.js.map +1 -1
  72. package/dist/public-dir.d.ts +5 -7
  73. package/dist/public-dir.d.ts.map +1 -1
  74. package/dist/public-dir.js +54 -96
  75. package/dist/public-dir.js.map +1 -1
  76. package/dist/router.d.ts +2 -1
  77. package/dist/router.d.ts.map +1 -1
  78. package/dist/router.js +9 -8
  79. package/dist/router.js.map +1 -1
  80. package/dist/server-components/vite.d.ts.map +1 -1
  81. package/dist/server-components/vite.js +8 -6
  82. package/dist/server-components/vite.js.map +1 -1
  83. package/dist/service-worker.d.ts +3 -1
  84. package/dist/service-worker.d.ts.map +1 -1
  85. package/dist/service-worker.js +29 -8
  86. package/dist/service-worker.js.map +1 -1
  87. package/dist/vite.d.ts.map +1 -1
  88. package/dist/vite.js +3 -3
  89. package/dist/vite.js.map +1 -1
  90. package/dist/zones.d.ts +5 -1
  91. package/dist/zones.d.ts.map +1 -1
  92. package/dist/zones.js +17 -4
  93. package/dist/zones.js.map +1 -1
  94. package/package.json +4 -4
  95. package/src/build-plan.ts +26 -0
  96. package/src/build-vite.ts +4 -0
  97. package/src/build.ts +228 -68
  98. package/src/deferred.ts +20 -6
  99. package/src/dev.ts +7 -6
  100. package/src/diagnostic.ts +43 -0
  101. package/src/host-policy.ts +219 -0
  102. package/src/host-reach.ts +177 -0
  103. package/src/index.ts +10 -2
  104. package/src/internal/certified-components.ts +7 -0
  105. package/src/internal/codegen.ts +24 -5
  106. package/src/internal/node-adapter.ts +57 -0
  107. package/src/internal/output-guard.ts +166 -15
  108. package/src/internal/page-execution.ts +23 -9
  109. package/src/internal/parity.ts +3 -2
  110. package/src/internal/public-url.ts +0 -13
  111. package/src/internal/render-document.ts +11 -4
  112. package/src/internal/web-app.ts +27 -1
  113. package/src/public-dir.ts +70 -97
  114. package/src/router.ts +9 -8
  115. package/src/server-components/vite.ts +11 -6
  116. package/src/service-worker.ts +32 -9
  117. package/src/vite.ts +3 -3
  118. package/src/zones.ts +27 -5
package/src/dev.ts CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  isLoopbackHost,
66
66
  } from "./dev-session.ts"
67
67
  import { createSsrGraph, type SsrGraph } from "./dev-ssr-graph.ts"
68
+ import { formatBuildLog } from "./diagnostic.ts"
68
69
  import { discoverRoutes } from "./fs.ts"
69
70
  import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
70
71
  import { DEV_ENTRY_FILE } from "./internal/dev-reserved.ts"
@@ -634,11 +635,6 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
634
635
  * Overlapping runs are collapsed - a save during a bundle queues exactly one re-run, so a burst of edits
635
636
  * cannot pile up builds behind each other.
636
637
  */
637
- /**
638
- * Format a client-build rejection for the guard's report. Bun.build rejects with an AggregateError
639
- * whose message is just "Bundle failed" - the actionable part (which file, which import) lives in
640
- * `.errors`. Surface it, or the guard reports a failure while hiding the reason.
641
- */
642
638
  /** A Host header's name: lowercased, without its port or IPv6 brackets. */
643
639
  function hostName(host: string): string {
644
640
  const name = host.trim().toLowerCase()
@@ -648,9 +644,14 @@ function hostName(host: string): string {
648
644
  return colon === -1 ? name : name.slice(0, colon)
649
645
  }
650
646
 
647
+ /**
648
+ * Format a client-build rejection for the guard's report. Bun.build rejects with an AggregateError
649
+ * whose message is just "Bundle failed" - the actionable part (which file, which import) lives in
650
+ * `.errors`. Surface it, or the guard reports a failure while hiding the reason.
651
+ */
651
652
  export function buildFailureDetail(err: unknown): string {
652
653
  return err instanceof AggregateError && err.errors.length > 0
653
- ? err.errors.map((e) => ` ${e instanceof Error ? e.message : String(e)}`).join("\n")
654
+ ? err.errors.map((e) => ` ${formatBuildLog(e).replaceAll("\n", "\n ")}`).join("\n")
654
655
  : ` ${err instanceof Error ? err.message : String(err)}`
655
656
  }
656
657
 
package/src/diagnostic.ts CHANGED
@@ -34,6 +34,49 @@ export {
34
34
  /** Shared endpoint name used by both dev pipelines and the agent-facing MCP tools. */
35
35
  export const LAST_ERROR_PATH = "/__nifra/last-error"
36
36
 
37
+ /** `file` relative to `root` when it sits inside it (the bundler reports real paths, so a symlinked
38
+ * root is compared by its real path too), else `file` as given. */
39
+ function pathUnder(root: string, file: string): string {
40
+ const inside = (base: string): string | undefined => {
41
+ const rel = relative(base, file).replaceAll("\\", "/")
42
+ return rel === ".." || rel.startsWith("../") || isAbsolute(rel) ? undefined : rel
43
+ }
44
+ let real: string | undefined
45
+ try {
46
+ real = realpathSync(root)
47
+ } catch {
48
+ real = undefined
49
+ }
50
+ return inside(root) ?? (real === undefined ? undefined : inside(real)) ?? file
51
+ }
52
+
53
+ /**
54
+ * One bundler log as `file:line:column: message`, the source line it points at underneath. Bun's
55
+ * `BuildMessage`/`ResolveMessage` carry the place in `position`, which their `String()` drops: a
56
+ * failed build said "Could not resolve: ./db" without naming the module that imported it.
57
+ */
58
+ export function formatBuildLog(log: unknown, root: string = process.cwd()): string {
59
+ const message =
60
+ typeof log === "object" && log !== null && "message" in log && typeof log.message === "string"
61
+ ? log.message
62
+ : String(log)
63
+ const position =
64
+ typeof log === "object" && log !== null && "position" in log ? log.position : undefined
65
+ if (typeof position !== "object" || position === null || !("file" in position)) return message
66
+ const file = typeof position.file === "string" ? position.file : ""
67
+ if (file === "") return message
68
+ const where = [
69
+ pathUnder(root, file),
70
+ "line" in position && typeof position.line === "number" ? position.line : undefined,
71
+ "column" in position && typeof position.column === "number" ? position.column : undefined,
72
+ ]
73
+ .filter((part) => part !== undefined)
74
+ .join(":")
75
+ const text =
76
+ "lineText" in position && typeof position.lineText === "string" ? position.lineText.trim() : ""
77
+ return text === "" ? `${where}: ${message}` : `${where}: ${message}\n ${text}`
78
+ }
79
+
37
80
  /** One parsed stack frame. `file`/`line`/`column` are present only when the frame could be located. */
38
81
  export interface DiagnosticFrame {
39
82
  readonly raw: string
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Host-scoped surfaces: which pages and files each host may serve.
3
+ *
4
+ * An app that answers on more than one host often must not show all of itself on each: a domain
5
+ * someone else points at the app should not render its sign-in page under that domain's certificate.
6
+ * A gate that knows only page paths gets the files wrong instead, because a page is not just its path:
7
+ * its document pulls the client bundle, its stylesheets and whatever `public/` files it links, and a
8
+ * gate that refuses those serves the page unstyled and never hydrates it. So a rule names its pages by
9
+ * route pattern and takes the build's own files by token: `buildAssets` is the exact set the build
10
+ * emitted (`buildAssets` from the generated server manifest), never a guess at its naming scheme.
11
+ *
12
+ * Pure and runtime-free, so the same policy can refuse a soft navigation in the browser.
13
+ */
14
+ import { Router as CoreRouter } from "@nifrajs/core/router"
15
+ import type { Middleware, NodeRequestContext } from "@nifrajs/core/server"
16
+ import { requestPattern } from "./internal/route-path.ts"
17
+
18
+ /** The files a build emitted, by request path: the generated server manifest's `buildAssets`. */
19
+ export interface BuildAssetSet {
20
+ has(pathname: string): boolean
21
+ }
22
+
23
+ /** One host's surface. A request on a host no rule names is not restricted. */
24
+ export interface HostPolicyRule {
25
+ /**
26
+ * The hosts this rule restricts: names compared without case, port or trailing dot, or a predicate
27
+ * given that lowercase name (`(host) => !ownHosts.has(host)` for "every host that is not ours").
28
+ */
29
+ readonly hosts: readonly string[] | ((hostname: string) => boolean)
30
+ /** Page routes these hosts render, as route patterns (`/`, `/p/:id`, `/share/*`). */
31
+ readonly pages: readonly string[]
32
+ /**
33
+ * Serve the build's own files on these hosts (default `true`): the client bundle and the files the
34
+ * build copied from `public/`, exactly as the build emitted them.
35
+ */
36
+ readonly buildAssets?: boolean
37
+ /** Other paths these hosts serve, as route patterns: an API the pages call, a server function. */
38
+ readonly paths?: readonly string[]
39
+ }
40
+
41
+ export interface HostPolicyOptions {
42
+ readonly rules: readonly HostPolicyRule[]
43
+ /**
44
+ * The answer to a path a rule refuses. Default: a bare `404` with `cache-control: no-store`, so a
45
+ * restricted host does not even confirm the refused page exists.
46
+ */
47
+ readonly refuse?: (refused: { readonly host: string; readonly pathname: string }) => Response
48
+ }
49
+
50
+ export interface HostPolicy {
51
+ readonly rules: readonly HostPolicyRule[]
52
+ /**
53
+ * Whether `pathname` (as requested, percent-encoded, without the query) may be served on `host` (a
54
+ * Host header value; its port and case are ignored). `buildAssets` is the build's file set; without
55
+ * it a rule's `buildAssets` matches nothing.
56
+ */
57
+ allows(host: string, pathname: string, buildAssets?: BuildAssetSet): boolean
58
+ /** The refusal for `pathname` on `host`, or `undefined` to serve it. */
59
+ refusal(host: string, pathname: string, buildAssets?: BuildAssetSet): Response | undefined
60
+ /** {@link HostPolicy.refusal} for a request: its Host header (else its URL's host) and path. */
61
+ check(request: Request, buildAssets?: BuildAssetSet): Response | undefined
62
+ }
63
+
64
+ const HOST_POLICY = Symbol.for("nifra.web.host-policy")
65
+
66
+ interface CompiledRule {
67
+ readonly covers: (hostname: string) => boolean
68
+ readonly matches: (pathname: string) => boolean
69
+ readonly buildAssets: boolean
70
+ }
71
+
72
+ /** A Host header value's name: lowercase, without its port, IPv6 brackets or trailing dots. */
73
+ export function hostnameOf(host: string): string {
74
+ const name = host.trim().toLowerCase()
75
+ if (name.startsWith("[")) {
76
+ const end = name.indexOf("]")
77
+ return end === -1 ? name.slice(1) : name.slice(1, end)
78
+ }
79
+ const colon = name.lastIndexOf(":")
80
+ const bare = colon === -1 || name.indexOf(":") !== colon ? name : name.slice(0, colon)
81
+ // `partner.example.` is the same host as `partner.example`; kept, it escapes the rule listing it.
82
+ let end = bare.length
83
+ while (end > 0 && bare.charCodeAt(end - 1) === 46 /* . */) end--
84
+ return end === bare.length ? bare : bare.slice(0, end)
85
+ }
86
+
87
+ function compilePatterns(
88
+ patterns: readonly string[],
89
+ label: string,
90
+ ): (pathname: string) => boolean {
91
+ const router = new CoreRouter<true>()
92
+ for (const pattern of patterns) {
93
+ if (typeof pattern !== "string" || !pattern.startsWith("/")) {
94
+ throw new TypeError(
95
+ `[nifra/web] hostPolicy: ${label} entries are route patterns starting with "/", got ${JSON.stringify(pattern)}`,
96
+ )
97
+ }
98
+ router.add("GET", requestPattern(pattern), true)
99
+ }
100
+ return (pathname) => router.find("GET", pathname).found
101
+ }
102
+
103
+ function compileRule(rule: HostPolicyRule, index: number): CompiledRule {
104
+ const label = `rules[${index}]`
105
+ if (typeof rule !== "object" || rule === null) {
106
+ throw new TypeError(`[nifra/web] hostPolicy: ${label} must be an object`)
107
+ }
108
+ let covers: (hostname: string) => boolean
109
+ if (typeof rule.hosts === "function") {
110
+ covers = rule.hosts
111
+ } else if (Array.isArray(rule.hosts) && rule.hosts.every((host) => typeof host === "string")) {
112
+ const names = new Set(rule.hosts.map(hostnameOf))
113
+ covers = (hostname) => names.has(hostname)
114
+ } else {
115
+ throw new TypeError(
116
+ `[nifra/web] hostPolicy: ${label}.hosts must be a list of host names or a predicate`,
117
+ )
118
+ }
119
+ if (!Array.isArray(rule.pages)) {
120
+ throw new TypeError(
121
+ `[nifra/web] hostPolicy: ${label}.pages must list the page routes the hosts serve`,
122
+ )
123
+ }
124
+ const pages = compilePatterns(rule.pages, `${label}.pages`)
125
+ const paths = rule.paths === undefined ? undefined : compilePatterns(rule.paths, `${label}.paths`)
126
+ return {
127
+ covers,
128
+ matches: paths === undefined ? pages : (pathname) => pages(pathname) || paths(pathname),
129
+ buildAssets: rule.buildAssets !== false,
130
+ }
131
+ }
132
+
133
+ const notFound = (): Response =>
134
+ new Response("Not Found", {
135
+ status: 404,
136
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
137
+ })
138
+
139
+ /**
140
+ * Declare which pages and files each host serves. Hand the result to `createWebApp({ hostPolicy })`
141
+ * (or export it as `hostPolicy` from the framework module, which `nifra build`'s server entry reads):
142
+ * it is evaluated before the build's static files, the prerendered pages, the mounts and every page.
143
+ * On Vercel the CDN answers the build's files before the function runs, so there it covers the rest.
144
+ * A hand-written entry calls `check(request, buildAssets)` first, before its own static handler.
145
+ *
146
+ * ```ts
147
+ * export const hostPolicy = createHostPolicy({
148
+ * rules: [{
149
+ * hosts: (host) => !OWN_HOSTS.has(host),
150
+ * pages: ["/", "/p/:id", "/share/*"],
151
+ * paths: ["/api/submissions/*"],
152
+ * }],
153
+ * })
154
+ * ```
155
+ */
156
+ export function createHostPolicy(options: HostPolicyOptions): HostPolicy {
157
+ if (typeof options !== "object" || options === null || !Array.isArray(options.rules)) {
158
+ throw new TypeError("[nifra/web] createHostPolicy takes { rules: [...] }")
159
+ }
160
+ const rules = options.rules.map(compileRule)
161
+ const refuse = options.refuse ?? notFound
162
+ const allows = (host: string, pathname: string, buildAssets?: BuildAssetSet): boolean => {
163
+ const hostname = hostnameOf(host)
164
+ const rule = rules.find((candidate) => candidate.covers(hostname))
165
+ if (rule === undefined) return true
166
+ if (rule.buildAssets && buildAssets?.has(pathname) === true) return true
167
+ return rule.matches(pathname)
168
+ }
169
+ const refusal = (host: string, pathname: string, buildAssets?: BuildAssetSet) =>
170
+ allows(host, pathname, buildAssets) ? undefined : refuse({ host: hostnameOf(host), pathname })
171
+ const policy: HostPolicy = {
172
+ rules: Object.freeze([...options.rules]),
173
+ allows,
174
+ refusal,
175
+ check(request, buildAssets) {
176
+ const url = new URL(request.url)
177
+ return refusal(request.headers.get("host") ?? url.host, url.pathname, buildAssets)
178
+ },
179
+ }
180
+ Object.defineProperty(policy, HOST_POLICY, { value: true })
181
+ return Object.freeze(policy)
182
+ }
183
+
184
+ /** Whether `value` came from {@link createHostPolicy}. */
185
+ export function isHostPolicy(value: unknown): value is HostPolicy {
186
+ return typeof value === "object" && value !== null && HOST_POLICY in value
187
+ }
188
+
189
+ const BUN_NATIVE_REQUEST_SAFE = Symbol.for("@nifrajs/core/bun-native-request-safe")
190
+
191
+ /** The path of a Node view's URL, `<protocol>://<host><target>`: the adapter has refused a host
192
+ * holding `/`, `?`, `#` or `@` and resolved the target's dot segments, so it starts at the first `/`
193
+ * after the scheme and ends where core's routing ends it. */
194
+ const targetPath = (url: string): string => {
195
+ const start = url.indexOf("/", url.indexOf("://") + 3)
196
+ if (start === -1) return ""
197
+ let end = url.length
198
+ for (let i = start; i < end; i++) {
199
+ const c = url.charCodeAt(i)
200
+ if (c === 63 /* ? */ || c === 35 /* # */) end = i
201
+ }
202
+ return url.slice(start, end)
203
+ }
204
+
205
+ /** A host policy as request middleware, for `createWebApp`: header-only, so its Node twin keeps the
206
+ * direct lane. */
207
+ export function hostPolicyGate(
208
+ policy: HostPolicy,
209
+ buildAssets: BuildAssetSet | undefined,
210
+ ): Middleware {
211
+ const gate: Middleware = {
212
+ name: "nifra-host-policy",
213
+ onRequest: (request) => policy.check(request, buildAssets),
214
+ onNodeRequest: (request: NodeRequestContext) =>
215
+ policy.refusal(request.header("host") ?? "", targetPath(request.url), buildAssets),
216
+ }
217
+ Object.defineProperty(gate, BUN_NATIVE_REQUEST_SAFE, { value: true })
218
+ return gate
219
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Whether a host can load what its pages reference: the check that catches a host policy (or any
3
+ * gate in front of the static files) that serves a page but refuses its stylesheet or bundle. Such a
4
+ * page still answers 200, so a status check passes while the browser renders it unstyled and never
5
+ * hydrates it.
6
+ *
7
+ * Bun-only (HTMLRewriter, Bun.Transpiler), like the rest of `@nifrajs/web/build`.
8
+ */
9
+
10
+ /** One request the check made. */
11
+ export interface HostReachResult {
12
+ /** The request path, as sent. */
13
+ readonly path: string
14
+ /** `page` for a page the check was given, `asset` for a file a page or another file references. */
15
+ readonly kind: "page" | "asset" | "refused"
16
+ readonly status: number
17
+ /** What referenced it: the page or file path. Absent for a page. */
18
+ readonly from?: string
19
+ }
20
+
21
+ export interface HostReachReport {
22
+ readonly host: string
23
+ readonly ok: boolean
24
+ readonly results: readonly HostReachResult[]
25
+ /** The results that broke the expectation: a page or asset not answered 200, a refused path answered. */
26
+ readonly failures: readonly HostReachResult[]
27
+ /** `limit` stopped the walk with declared pages or references left unfetched; `ok` is then `false`. */
28
+ readonly truncated: boolean
29
+ }
30
+
31
+ export interface HostReachOptions {
32
+ /** Sends one GET. Given the path and the Host header to send. */
33
+ readonly fetch: (path: string, host: string) => Promise<Response>
34
+ /** The host to address (the Host header every request carries). */
35
+ readonly host: string
36
+ /** Page paths that must render on `host` (concrete paths, not patterns). */
37
+ readonly pages: readonly string[]
38
+ /** Paths `host` must refuse with `refusedStatus`. */
39
+ readonly refused?: readonly string[]
40
+ /** The status a refused path answers (default `404`). */
41
+ readonly refusedStatus?: number
42
+ /** At most this many files are fetched, pages included (default 500). */
43
+ readonly limit?: number
44
+ }
45
+
46
+ // The attributes that make a browser fetch a file before or while a page renders.
47
+ const LINK_RELS = new Set([
48
+ "stylesheet",
49
+ "modulepreload",
50
+ "preload",
51
+ "icon",
52
+ "apple-touch-icon",
53
+ "manifest",
54
+ "mask-icon",
55
+ ])
56
+
57
+ /** The URLs a document asks the browser to load: scripts, stylesheets, preloads, icons, images. */
58
+ async function documentReferences(html: string): Promise<string[]> {
59
+ const found: string[] = []
60
+ const rewriter = new HTMLRewriter()
61
+ .on("script[src]", {
62
+ element: (el) => void found.push(el.getAttribute("src") ?? ""),
63
+ })
64
+ .on("link[href]", {
65
+ element: (el) => {
66
+ const rels = (el.getAttribute("rel") ?? "").toLowerCase().split(/\s+/)
67
+ if (rels.some((rel) => LINK_RELS.has(rel))) found.push(el.getAttribute("href") ?? "")
68
+ },
69
+ })
70
+ .on("img[src], source[src], video[poster]", {
71
+ element: (el) => void found.push(el.getAttribute("src") ?? el.getAttribute("poster") ?? ""),
72
+ })
73
+ await rewriter.transform(new Response(html)).text()
74
+ return found.filter((url) => url !== "")
75
+ }
76
+
77
+ /** `url(...)` references in a stylesheet, found by scanning rather than a regex over untrusted text. */
78
+ function cssReferences(css: string): string[] {
79
+ const found: string[] = []
80
+ let at = css.indexOf("url(")
81
+ while (at !== -1) {
82
+ const close = css.indexOf(")", at)
83
+ if (close === -1) break
84
+ const raw = css
85
+ .slice(at + 4, close)
86
+ .trim()
87
+ .replace(/^["']|["']$/g, "")
88
+ if (raw !== "" && !raw.startsWith("data:")) found.push(raw)
89
+ at = css.indexOf("url(", close)
90
+ }
91
+ return found
92
+ }
93
+
94
+ const scanner = new Bun.Transpiler({ loader: "js" })
95
+
96
+ /** A script's static and dynamic import specifiers that name files (not bare packages). */
97
+ function scriptReferences(code: string): string[] {
98
+ try {
99
+ return scanner
100
+ .scanImports(code)
101
+ .map((entry) => entry.path)
102
+ .filter((path) => path.startsWith("/") || path.startsWith("./") || path.startsWith("../"))
103
+ } catch {
104
+ return []
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Render each page on `host`, then fetch every same-origin file each page references, following
110
+ * scripts' imports and stylesheets' `url()`s, and expect 200 for all of them; then expect each
111
+ * `refused` path to answer `refusedStatus`. Cross-origin references are skipped: they are not this
112
+ * app's to serve.
113
+ */
114
+ export async function checkHostReach(options: HostReachOptions): Promise<HostReachReport> {
115
+ const { host, fetch } = options
116
+ const limit = options.limit ?? 500
117
+ const refusedStatus = options.refusedStatus ?? 404
118
+ const origin = `http://${host}`
119
+ const results: HostReachResult[] = []
120
+ const seen = new Set<string>()
121
+ const queue: { path: string; from: string }[] = []
122
+ const enqueue = (reference: string, base: string): void => {
123
+ let url: URL
124
+ try {
125
+ url = new URL(reference, origin + base)
126
+ } catch {
127
+ return
128
+ }
129
+ if (url.origin !== origin) return
130
+ const path = url.pathname
131
+ if (seen.has(path)) return
132
+ seen.add(path)
133
+ queue.push({ path, from: base })
134
+ }
135
+
136
+ let pagesTruncated = false
137
+ for (const page of options.pages) {
138
+ if (results.length >= limit) {
139
+ pagesTruncated = true
140
+ break
141
+ }
142
+ seen.add(page)
143
+ const response = await fetch(page, host)
144
+ const text = await response.text()
145
+ results.push({ path: page, kind: "page", status: response.status })
146
+ if (response.status !== 200) continue
147
+ for (const reference of await documentReferences(text)) enqueue(reference, page)
148
+ }
149
+ while (results.length < limit) {
150
+ const next = queue.shift()
151
+ if (next === undefined) break
152
+ const response = await fetch(next.path, host)
153
+ results.push({ path: next.path, kind: "asset", status: response.status, from: next.from })
154
+ if (response.status !== 200) {
155
+ await response.body?.cancel()
156
+ continue
157
+ }
158
+ const type = response.headers.get("content-type") ?? ""
159
+ if (!type.includes("css") && !type.includes("javascript")) {
160
+ await response.body?.cancel()
161
+ continue
162
+ }
163
+ const body = await response.text()
164
+ const references = type.includes("css") ? cssReferences(body) : scriptReferences(body)
165
+ for (const reference of references) enqueue(reference, next.path)
166
+ }
167
+ for (const path of options.refused ?? []) {
168
+ const response = await fetch(path, host)
169
+ await response.body?.cancel()
170
+ results.push({ path, kind: "refused", status: response.status })
171
+ }
172
+ const failures = results.filter((result) =>
173
+ result.kind === "refused" ? result.status !== refusedStatus : result.status !== 200,
174
+ )
175
+ const truncated = pagesTruncated || queue.length > 0
176
+ return { host, ok: failures.length === 0 && !truncated, results, failures, truncated }
177
+ }
package/src/index.ts CHANGED
@@ -76,6 +76,16 @@ export {
76
76
  fontFace,
77
77
  fontPreload,
78
78
  } from "./fonts.ts"
79
+ // Generated client entries intentionally import the browser router from the client subpath:
80
+ // `import { createClientRouter, createMatcher, mergeHeads, resolveChainMeta } from "@nifrajs/web/client"`.
81
+ export {
82
+ type BuildAssetSet,
83
+ createHostPolicy,
84
+ type HostPolicy,
85
+ type HostPolicyOptions,
86
+ type HostPolicyRule,
87
+ isHostPolicy,
88
+ } from "./host-policy.ts"
79
89
  export {
80
90
  type GenerateClientEntryOptions,
81
91
  type GenerateRouteSearchTypesOptions,
@@ -84,8 +94,6 @@ export {
84
94
  generateRouteSearchTypes,
85
95
  generateServerManifest,
86
96
  } from "./internal/codegen.ts"
87
- // Generated client entries intentionally import the browser router from the client subpath:
88
- // `import { createClientRouter, createMatcher, mergeHeads, resolveChainMeta } from "@nifrajs/web/client"`.
89
97
  export { mergeHeads, resolveChainMeta, resolveMeta } from "./internal/head-merge.ts"
90
98
  export type { PrerenderedPages } from "./internal/prerendered-pages.ts"
91
99
  export {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The one Vite and React release the server-component pipeline is certified against. The plugin's
3
+ * guard and the consumer check read these; `component-certification.test.ts` holds the exact pins
4
+ * in every manifest that runs the pipeline to them, so a range bump cannot drift past the guard.
5
+ */
6
+ export const CERTIFIED_COMPONENT_VITE = "8.2.1"
7
+ export const CERTIFIED_COMPONENT_REACT = "19.3.1"
@@ -17,6 +17,13 @@ import {
17
17
  import { PRERENDERED_GLOBAL } from "../router.ts"
18
18
  import { jsStringLiteral, jsValueLiteral } from "./js-string.ts"
19
19
 
20
+ /** The refusal for a server build given no client entry, shared by the codegen and `buildServer`. */
21
+ export function missingClientEntry(clientEntry: unknown): TypeError {
22
+ return new TypeError(
23
+ `[nifra/web] the server build needs the client build: pass client (the manifest buildClient returned) or clientEntry (its entry URL), got clientEntry ${clientEntry === null ? "null" : typeof clientEntry}; pass clientEntry "" for an app with no client script`,
24
+ )
25
+ }
26
+
20
27
  /** The globals the page-state handover may set - nothing else in it reaches `window`. */
21
28
  const HANDOVER_GLOBALS = [
22
29
  DATA_GLOBAL,
@@ -474,6 +481,12 @@ export interface GenerateServerManifestOptions {
474
481
  /** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`) - baked as
475
482
  * `export const copyChunks` when the client build split copy tables, for `createWebApp`. */
476
483
  readonly copyChunks?: Readonly<Record<string, string>> | undefined
484
+ /**
485
+ * The request path of every file the build emitted: the client build's `assets` and the `public/`
486
+ * files it copied. Baked as `export const buildAssets` (a `Set`) and `isBuildAsset(pathname)`, so a
487
+ * server entry or a host policy can allow exactly the files that exist. Omitted ⇒ neither export.
488
+ */
489
+ readonly buildAssets?: readonly string[] | undefined
477
490
  /** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
478
491
  * eager `import * as`, so a bundler with code-splitting emits one chunk per route - loaded on the
479
492
  * first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
@@ -522,15 +535,12 @@ export function generateServerManifest(
522
535
  routeStyles,
523
536
  cssLoading: requestedCssLoading,
524
537
  copyChunks,
538
+ buildAssets,
525
539
  lazy = false,
526
540
  } = options
527
541
  // A missing entry would bake `clientEntry = undefined` and the build would still succeed, leaving
528
542
  // every hydrating page to fail at request time. `""` stays valid: an app with no client script.
529
- if (typeof clientEntry !== "string") {
530
- throw new TypeError(
531
- `[nifra/web] the server build needs clientEntry (the built client entry URL, buildClient's manifest.entry), got ${clientEntry === null ? "null" : typeof clientEntry}; check the option is spelled clientEntry, or pass "" for an app with no client script`,
532
- )
533
- }
543
+ if (typeof clientEntry !== "string") throw missingClientEntry(clientEntry)
534
544
  const cssLoading = normalizeCssLoading(requestedCssLoading ?? DEFAULT_CSS_LOADING)
535
545
  // Every unique source file in the manifest, both halves of each, sorted for stable output. This is the
536
546
  // one generated module that imports frontend and backend halves together: it is the server's
@@ -568,6 +578,13 @@ export function generateServerManifest(
568
578
  copyChunks === undefined || Object.keys(copyChunks).length === 0
569
579
  ? []
570
580
  : [`export const copyChunks = ${jsValueLiteral(copyChunks)}`]
581
+ const buildAssetsLines =
582
+ buildAssets === undefined
583
+ ? []
584
+ : [
585
+ `export const buildAssets: ReadonlySet<string> = new Set(${jsValueLiteral([...new Set(buildAssets)].sort())})`,
586
+ "export const isBuildAsset = (pathname: string): boolean => buildAssets.has(pathname)",
587
+ ]
571
588
  if (lazy) {
572
589
  // Lazy: `() => import("./routes/x")` per route (static specifier → one chunk per route under a
573
590
  // code-splitting bundler, loaded on first request). The map keys are the route-relative paths
@@ -586,6 +603,7 @@ export function generateServerManifest(
586
603
  routeStylesLine,
587
604
  cssLoadingLine,
588
605
  ...copyChunksLines,
606
+ ...buildAssetsLines,
589
607
  `export const manifest = buildManifest(Object.keys(loaders), ${LAZY_IMPORTER})`,
590
608
  ].join("\n")}\n`
591
609
  }
@@ -606,6 +624,7 @@ export function generateServerManifest(
606
624
  routeStylesLine,
607
625
  cssLoadingLine,
608
626
  ...copyChunksLines,
627
+ ...buildAssetsLines,
609
628
  `export const manifest = buildManifest(Object.keys(modules), ${EAGER_IMPORTER})`,
610
629
  ].join("\n")}\n`
611
630
  }
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+
4
+ /** A package as installed: its directory and the version its package.json declares. */
5
+ export interface InstalledPackage {
6
+ readonly dir: string
7
+ readonly version: string
8
+ }
9
+
10
+ /** The `version` a package.json declares, or `undefined` when it declares none. */
11
+ function manifestVersion(path: string | URL): string | undefined {
12
+ const manifest: unknown = JSON.parse(readFileSync(path, "utf8"))
13
+ return typeof manifest === "object" &&
14
+ manifest !== null &&
15
+ "version" in manifest &&
16
+ typeof manifest.version === "string"
17
+ ? manifest.version
18
+ : undefined
19
+ }
20
+
21
+ /**
22
+ * The copy of `name` a module in `fromDir` imports: the first `node_modules/<name>` walking up, as
23
+ * both bundlers resolve a bare specifier. Walked rather than `Bun.resolveSync`, which keeps its first
24
+ * answer for the life of the process.
25
+ */
26
+ export function installedPackage(name: string, fromDir: string): InstalledPackage | undefined {
27
+ for (let dir = resolve(fromDir); ; dir = dirname(dir)) {
28
+ const manifest = join(dir, "node_modules", name, "package.json")
29
+ if (existsSync(manifest)) {
30
+ const version = manifestVersion(manifest)
31
+ return version === undefined ? undefined : { dir: dirname(manifest), version }
32
+ }
33
+ if (dirname(dir) === dir) return undefined
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Why a `--target node` build whose server entry is generated in `workDir` must stop before
39
+ * bundling, or `undefined`. The entry is written for the `@nifrajs/node` of this package's release and
40
+ * the bundle inlines whichever copy is installed: a 5.3 adapter built cleanly, then threw at startup
41
+ * on the static mounts.
42
+ */
43
+ export function nodeAdapterRefusal(
44
+ workDir: string,
45
+ // src/internal/ and dist/internal/ both sit two levels below the package root.
46
+ webVersion = manifestVersion(new URL("../../package.json", import.meta.url)),
47
+ ): string | undefined {
48
+ const adapter = installedPackage("@nifrajs/node", workDir)
49
+ if (adapter === undefined || webVersion === undefined) return undefined
50
+ if (Bun.semver.order(adapter.version, webVersion) >= 0) return undefined
51
+ return (
52
+ `[nifra/web] --target node would bundle @nifrajs/node ${adapter.version} (${adapter.dir}), older ` +
53
+ `than @nifrajs/web ${webVersion}. The server entry is generated for the adapter of the same ` +
54
+ "release, and an older one can fail when the server starts. Upgrade the nifra packages " +
55
+ `together: nifra upgrade ${webVersion}`
56
+ )
57
+ }