@nifrajs/web 5.1.0 → 5.3.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 (162) hide show
  1. package/CHANGELOG.md +3042 -0
  2. package/dist/build-plan.d.ts +7 -0
  3. package/dist/build-plan.d.ts.map +1 -1
  4. package/dist/build-plan.js +19 -0
  5. package/dist/build-plan.js.map +1 -1
  6. package/dist/build-vite.d.ts +1 -0
  7. package/dist/build-vite.d.ts.map +1 -1
  8. package/dist/build-vite.js +51 -3
  9. package/dist/build-vite.js.map +1 -1
  10. package/dist/build.d.ts +23 -3
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +224 -16
  13. package/dist/build.js.map +1 -1
  14. package/dist/client.d.ts +2 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +14 -7
  17. package/dist/client.js.map +1 -1
  18. package/dist/csp.d.ts +2 -0
  19. package/dist/csp.d.ts.map +1 -1
  20. package/dist/csp.js +2 -0
  21. package/dist/csp.js.map +1 -1
  22. package/dist/deferred.d.ts.map +1 -1
  23. package/dist/deferred.js +23 -3
  24. package/dist/deferred.js.map +1 -1
  25. package/dist/index.d.ts +4 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/codegen.d.ts +6 -0
  30. package/dist/internal/codegen.d.ts.map +1 -1
  31. package/dist/internal/codegen.js +23 -5
  32. package/dist/internal/codegen.js.map +1 -1
  33. package/dist/internal/copy-split.d.ts +27 -0
  34. package/dist/internal/copy-split.d.ts.map +1 -0
  35. package/dist/internal/copy-split.js +173 -0
  36. package/dist/internal/copy-split.js.map +1 -0
  37. package/dist/internal/deferred-free.d.ts +3 -0
  38. package/dist/internal/deferred-free.d.ts.map +1 -0
  39. package/dist/internal/deferred-free.js +15 -0
  40. package/dist/internal/deferred-free.js.map +1 -0
  41. package/dist/internal/module-preparation.d.ts +7 -0
  42. package/dist/internal/module-preparation.d.ts.map +1 -0
  43. package/dist/internal/module-preparation.js +24 -0
  44. package/dist/internal/module-preparation.js.map +1 -0
  45. package/dist/internal/output-guard.d.ts +2 -0
  46. package/dist/internal/output-guard.d.ts.map +1 -1
  47. package/dist/internal/output-guard.js +51 -9
  48. package/dist/internal/output-guard.js.map +1 -1
  49. package/dist/internal/page-execution.d.ts +20 -1
  50. package/dist/internal/page-execution.d.ts.map +1 -1
  51. package/dist/internal/page-execution.js +115 -50
  52. package/dist/internal/page-execution.js.map +1 -1
  53. package/dist/internal/parity.d.ts.map +1 -1
  54. package/dist/internal/parity.js +3 -1
  55. package/dist/internal/parity.js.map +1 -1
  56. package/dist/internal/prerendered-pages.d.ts +42 -0
  57. package/dist/internal/prerendered-pages.d.ts.map +1 -0
  58. package/dist/internal/prerendered-pages.js +92 -0
  59. package/dist/internal/prerendered-pages.js.map +1 -0
  60. package/dist/internal/public-url.d.ts +7 -0
  61. package/dist/internal/public-url.d.ts.map +1 -1
  62. package/dist/internal/public-url.js +13 -0
  63. package/dist/internal/public-url.js.map +1 -1
  64. package/dist/internal/render-document.d.ts +11 -2
  65. package/dist/internal/render-document.d.ts.map +1 -1
  66. package/dist/internal/render-document.js +131 -41
  67. package/dist/internal/render-document.js.map +1 -1
  68. package/dist/internal/request-url.d.ts +4 -0
  69. package/dist/internal/request-url.d.ts.map +1 -1
  70. package/dist/internal/request-url.js +24 -0
  71. package/dist/internal/request-url.js.map +1 -1
  72. package/dist/internal/secret-scan.d.ts +4 -0
  73. package/dist/internal/secret-scan.d.ts.map +1 -1
  74. package/dist/internal/secret-scan.js +8 -1
  75. package/dist/internal/secret-scan.js.map +1 -1
  76. package/dist/internal/typescript-api.d.ts +4 -0
  77. package/dist/internal/typescript-api.d.ts.map +1 -0
  78. package/dist/internal/typescript-api.js +8 -0
  79. package/dist/internal/typescript-api.js.map +1 -0
  80. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  81. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  82. package/dist/internal/vite-plugin-compat.js +82 -0
  83. package/dist/internal/vite-plugin-compat.js.map +1 -0
  84. package/dist/internal/web-app.d.ts +45 -0
  85. package/dist/internal/web-app.d.ts.map +1 -1
  86. package/dist/internal/web-app.js +32 -1
  87. package/dist/internal/web-app.js.map +1 -1
  88. package/dist/isr.d.ts +1 -0
  89. package/dist/isr.d.ts.map +1 -1
  90. package/dist/isr.js +7 -4
  91. package/dist/isr.js.map +1 -1
  92. package/dist/plugins/import-glob.d.ts +20 -0
  93. package/dist/plugins/import-glob.d.ts.map +1 -0
  94. package/dist/plugins/import-glob.js +401 -0
  95. package/dist/plugins/import-glob.js.map +1 -0
  96. package/dist/plugins/kit.d.ts +11 -0
  97. package/dist/plugins/kit.d.ts.map +1 -1
  98. package/dist/plugins/kit.js +32 -0
  99. package/dist/plugins/kit.js.map +1 -1
  100. package/dist/plugins/stylex.d.ts.map +1 -1
  101. package/dist/plugins/stylex.js +2 -2
  102. package/dist/plugins/stylex.js.map +1 -1
  103. package/dist/prerender.d.ts +4 -0
  104. package/dist/prerender.d.ts.map +1 -1
  105. package/dist/prerender.js +9 -1
  106. package/dist/prerender.js.map +1 -1
  107. package/dist/public-dir.d.ts +15 -0
  108. package/dist/public-dir.d.ts.map +1 -1
  109. package/dist/public-dir.js +91 -12
  110. package/dist/public-dir.js.map +1 -1
  111. package/dist/render-seam.d.ts +42 -2
  112. package/dist/render-seam.d.ts.map +1 -1
  113. package/dist/render-seam.js +33 -0
  114. package/dist/render-seam.js.map +1 -1
  115. package/dist/router.d.ts +3 -2
  116. package/dist/router.d.ts.map +1 -1
  117. package/dist/router.js +4 -4
  118. package/dist/router.js.map +1 -1
  119. package/dist/search.d.ts.map +1 -1
  120. package/dist/search.js +2 -0
  121. package/dist/search.js.map +1 -1
  122. package/dist/server-components/typescript.d.ts.map +1 -1
  123. package/dist/server-components/typescript.js +2 -7
  124. package/dist/server-components/typescript.js.map +1 -1
  125. package/dist/vite.d.ts +7 -28
  126. package/dist/vite.d.ts.map +1 -1
  127. package/dist/vite.js +130 -123
  128. package/dist/vite.js.map +1 -1
  129. package/package.json +5 -4
  130. package/src/build-plan.ts +21 -0
  131. package/src/build-vite.ts +60 -3
  132. package/src/build.ts +283 -39
  133. package/src/client.ts +15 -7
  134. package/src/csp.ts +4 -0
  135. package/src/deferred.ts +20 -3
  136. package/src/index.ts +9 -0
  137. package/src/internal/codegen.ts +32 -3
  138. package/src/internal/copy-split.ts +220 -0
  139. package/src/internal/deferred-free.ts +15 -0
  140. package/src/internal/module-preparation.ts +30 -0
  141. package/src/internal/output-guard.ts +51 -9
  142. package/src/internal/page-execution.ts +137 -53
  143. package/src/internal/parity.ts +2 -1
  144. package/src/internal/prerendered-pages.ts +124 -0
  145. package/src/internal/public-url.ts +13 -0
  146. package/src/internal/render-document.ts +179 -58
  147. package/src/internal/request-url.ts +24 -0
  148. package/src/internal/secret-scan.ts +9 -1
  149. package/src/internal/typescript-api.ts +11 -0
  150. package/src/internal/vite-plugin-compat.ts +81 -0
  151. package/src/internal/web-app.ts +82 -2
  152. package/src/isr.ts +7 -4
  153. package/src/plugins/import-glob.ts +392 -0
  154. package/src/plugins/kit.ts +36 -0
  155. package/src/plugins/stylex.ts +2 -1
  156. package/src/prerender.ts +13 -1
  157. package/src/public-dir.ts +107 -12
  158. package/src/render-seam.ts +65 -2
  159. package/src/router.ts +7 -6
  160. package/src/search.ts +1 -0
  161. package/src/server-components/typescript.ts +2 -10
  162. package/src/vite.ts +133 -135
package/src/isr.ts CHANGED
@@ -161,6 +161,8 @@ export class MemoryCacheStore implements CacheStore {
161
161
  private order = 0
162
162
  private readonly purgedKeys = new Map<string, number>()
163
163
  private readonly purgedTags = new Map<string, number>()
164
+ // The newest purge order whose mark was evicted. A render fenced before it may predate that purge.
165
+ private purgeFloor = 0
164
166
 
165
167
  constructor(options: MemoryCacheStoreOptions = {}) {
166
168
  const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production"
@@ -246,8 +248,8 @@ export class MemoryCacheStore implements CacheStore {
246
248
  }
247
249
 
248
250
  private purgedAfter(key: string, tags: readonly string[], fence: number): boolean {
249
- if ((this.purgedKeys.get(key) ?? 0) > fence) return true
250
- return tags.some((tag) => (this.purgedTags.get(tag) ?? 0) > fence)
251
+ if ((this.purgedKeys.get(key) ?? this.purgeFloor) > fence) return true
252
+ return tags.some((tag) => (this.purgedTags.get(tag) ?? this.purgeFloor) > fence)
251
253
  }
252
254
 
253
255
  private markPurged(marks: Map<string, number>, name: string): void {
@@ -255,9 +257,10 @@ export class MemoryCacheStore implements CacheStore {
255
257
  marks.set(name, ++this.order)
256
258
  // Bounded like the entries: a mark matters only to a render still in flight.
257
259
  while (marks.size > this.max) {
258
- const oldest = marks.keys().next().value
260
+ const oldest = marks.entries().next().value
259
261
  if (oldest === undefined) break
260
- marks.delete(oldest)
262
+ marks.delete(oldest[0])
263
+ this.purgeFloor = Math.max(this.purgeFloor, oldest[1])
261
264
  }
262
265
  }
263
266
 
@@ -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
+ }
@@ -122,6 +122,42 @@ export function hash8(input: string): string {
122
122
  return h.toString(16).padStart(8, "0")
123
123
  }
124
124
 
125
+ const SOURCE_TRANSFORMS = Symbol.for("nifra.build.source-transforms")
126
+
127
+ /** A rewrite of a source file's text before any plugin compiles it; `undefined` leaves the text as is. */
128
+ export type SourceTransform = (path: string, source: string) => string | undefined
129
+
130
+ const isTransformList = (value: unknown): value is SourceTransform[] => Array.isArray(value)
131
+
132
+ /**
133
+ * Register a source rewrite for one build. Bun runs only the first `onLoad` that returns contents, so
134
+ * a plugin that claims a file and reads it off disk would compile it without the rewrite: such a plugin
135
+ * reads through {@link readSource} instead. Stored on the build's config, which every plugin of one
136
+ * `Bun.build` shares.
137
+ */
138
+ export function addSourceTransform(build: PluginBuilder, transform: SourceTransform): void {
139
+ const config: unknown = build.config
140
+ if (typeof config !== "object" || config === null) return
141
+ const existing: unknown = Reflect.get(config, SOURCE_TRANSFORMS)
142
+ if (isTransformList(existing)) existing.push(transform)
143
+ else Reflect.set(config, SOURCE_TRANSFORMS, [transform])
144
+ }
145
+
146
+ /** A source file's text as this build compiles it: off disk, through each {@link addSourceTransform}. */
147
+ export async function readSource(build: PluginBuilder, path: string): Promise<string> {
148
+ let source = await Bun.file(path).text()
149
+ // A runtime plugin (`Bun.plugin`) has no build config.
150
+ const config: unknown = build.config
151
+ const transforms: unknown =
152
+ typeof config === "object" && config !== null
153
+ ? Reflect.get(config, SOURCE_TRANSFORMS)
154
+ : undefined
155
+ if (isTransformList(transforms)) {
156
+ for (const transform of transforms) source = transform(path, source) ?? source
157
+ }
158
+ return source
159
+ }
160
+
125
161
  /**
126
162
  * Env flag a nifra dev server sets for its own lifetime, so a framework compiler plugin can tell a dev
127
163
  * compile from a `nifra build` one. Exported for the dev server that sets it; plugins ask
@@ -11,6 +11,7 @@ import {
11
11
  devServerCompile,
12
12
  hash8,
13
13
  normalizeFilePath,
14
+ readSource,
14
15
  reproduciblePath,
15
16
  requirePeer,
16
17
  } from "./kit.ts"
@@ -236,7 +237,7 @@ export function stylexBunPlugin(
236
237
  const dependencies = dependenciesFor(options)
237
238
  build.onLoad({ filter: JS_FILTER }, async (args) => {
238
239
  const path = normalizeFilePath(args.path)
239
- const source = await Bun.file(path).text()
240
+ const source = await readSource(build, path)
240
241
  const transformed = await transformModule(source, path, options, dependencies())
241
242
  if (transformed === undefined) return
242
243
  const contents =
package/src/prerender.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { mkdirSync, writeFileSync } from "node:fs"
12
12
  import { dirname, join, resolve, sep } from "node:path"
13
+ import { prerenderedDocumentHeaders } from "./internal/prerendered-pages.ts"
13
14
  import { assertNoSecrets, buildEnvironment, type SecretExemption } from "./internal/secret-scan.ts"
14
15
  import { fillRoutePattern, type RouteEntry } from "./manifest.ts"
15
16
  import { DATA_HEADER, PRERENDERED_GLOBAL, PRERENDERED_LIST_PATH } from "./router.ts"
@@ -52,6 +53,10 @@ export interface PrerenderEntry {
52
53
  /** The loader-data file written alongside (`<path>/_data.json`) so a client soft-nav INTO this
53
54
  * route skips the worker. Absent when the data-mode response is NDJSON (a deferred loader). */
54
55
  readonly dataFile?: string
56
+ /** The document headers a self-hosted server sends with this file (its `content-language` and CSP).
57
+ * Absent when the render was specific to its request - it set a cookie, was private or no-store, or
58
+ * varied on a request header - so a server keeps rendering this path on demand. */
59
+ readonly serveHeaders?: Readonly<Record<string, string>>
55
60
  }
56
61
 
57
62
  export interface PrerenderResult {
@@ -184,7 +189,14 @@ export async function prerenderRoutes(options: PrerenderOptions): Promise<Preren
184
189
  await dataRes.body?.cancel() // not emitting (non-OK or NDJSON) - release the stream
185
190
  }
186
191
 
187
- prerendered.push({ path, file, bytes: html.length, ...(dataFile ? { dataFile } : {}) })
192
+ const serveHeaders = prerenderedDocumentHeaders(res.status, res.headers)
193
+ prerendered.push({
194
+ path,
195
+ file,
196
+ bytes: html.length,
197
+ ...(dataFile ? { dataFile } : {}),
198
+ ...(serveHeaders ? { serveHeaders } : {}),
199
+ })
188
200
  }
189
201
 
190
202
  for (const route of options.routes) {