@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
@@ -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 =
@@ -46,6 +46,7 @@ import {
46
46
  formatServerOnlyLeak,
47
47
  } from "../build-plan.ts"
48
48
  import { isBareNodeBuiltin } from "../internal/node-builtins.ts"
49
+ import { installedManifest } from "../internal/package-entries.ts"
49
50
  import { privateEnvCheck } from "../internal/private-env.ts"
50
51
  import {
51
52
  emittedScanFiles,
@@ -58,6 +59,7 @@ import {
58
59
  scanForSecrets,
59
60
  textOf,
60
61
  } from "../internal/secret-scan.ts"
62
+ import { withoutQuery } from "../internal/without-query.ts"
61
63
  import {
62
64
  accountEmittedFiles,
63
65
  assetDenial,
@@ -75,7 +77,7 @@ import {
75
77
  type GraphModule,
76
78
  type RollupBundleLike,
77
79
  } from "../module-graph.ts"
78
- import { createZoneClassifier, type ZoneClassifier } from "../zones.ts"
80
+ import { createZoneClassifier, specifierPackage, type ZoneClassifier } from "../zones.ts"
79
81
 
80
82
  /**
81
83
  * The Rollup plugin-context slice this uses: `getModuleInfo` for a module's resolved imports, and `error`
@@ -160,9 +162,80 @@ export interface LeakGuardPlugin {
160
162
  * reached the browser.
161
163
  */
162
164
  leak?: string
165
+ buildStart(): void
166
+ /** Ordered first: the bundler's own resolver answers before a plugin listed after it is asked. */
167
+ readonly resolveId: {
168
+ readonly order: "pre"
169
+ handler(
170
+ this: ResolveContext,
171
+ source: string,
172
+ importer: string | undefined,
173
+ options?: Readonly<Record<string, unknown>>,
174
+ ): Promise<null>
175
+ }
163
176
  generateBundle(this: RollupPluginContext, options: unknown, bundle: RollupBundleLike): void
164
177
  }
165
178
 
179
+ /**
180
+ * Rollup keeps no import's written specifier, yet a package that gives each entry its own side is judged
181
+ * by the entry an import names, since two entries may share one file. This records the specifier of
182
+ * each import of such a package by the edge it resolved to, and puts it back on the finished graph.
183
+ */
184
+ function entrySpecifiers(appRoot: string, options: ServerZoneGuardOptions) {
185
+ // One edge may carry several specifiers: `pkg` and `pkg/react` can resolve to one shared file.
186
+ const written = new Map<string, Set<string>>()
187
+ let classifier: ZoneClassifier | undefined
188
+ const edge = (from: string, to: string): string =>
189
+ `${from.replaceAll("\\", "/")}\0${to.replaceAll("\\", "/")}`
190
+ return {
191
+ reset(): void {
192
+ written.clear()
193
+ classifier = undefined
194
+ },
195
+ async resolveId(
196
+ context: ResolveContext,
197
+ source: string,
198
+ importer: string | undefined,
199
+ resolveOptions: Readonly<Record<string, unknown>> | undefined,
200
+ ): Promise<null> {
201
+ const name = specifierPackage(source)
202
+ const from = importer === undefined ? undefined : withoutQuery(importer)
203
+ if (name === undefined || importer === undefined || from === undefined || !isAbsolute(from))
204
+ return null
205
+ const manifest = installedManifest(dirname(from), name)
206
+ if (manifest === undefined) return null
207
+ classifier ??= classifierFor(appRoot, options)
208
+ if (classifier.classify(manifest, source).entry === undefined) return null
209
+ const resolved = await context.resolve(source, importer, {
210
+ ...resolveOptions,
211
+ skipSelf: true,
212
+ })
213
+ if (resolved === null) return null
214
+ const key = edge(importer, resolved.id)
215
+ written.set(key, (written.get(key) ?? new Set<string>()).add(source))
216
+ return null
217
+ },
218
+ annotate(graph: ClientModuleGraph): ClientModuleGraph {
219
+ if (written.size === 0) return graph
220
+ const modules: Record<string, GraphModule> = {}
221
+ for (const [id, module] of Object.entries(graph.modules)) {
222
+ modules[id] = {
223
+ imports: module.imports.flatMap((im) => {
224
+ const originals =
225
+ im.original !== undefined || im.path === undefined
226
+ ? undefined
227
+ : written.get(edge(id, im.path))
228
+ return originals === undefined
229
+ ? [im]
230
+ : [...originals].map((original) => ({ ...im, original }))
231
+ }),
232
+ }
233
+ }
234
+ return { ...graph, modules }
235
+ },
236
+ }
237
+ }
238
+
166
239
  const CSS_FILE = /\.(?:css|scss|sass|less|styl)(?:$|\?)/
167
240
 
168
241
  /**
@@ -175,8 +248,16 @@ export function viteLeakGuard(options: LeakGuardOptions = {}): LeakGuardPlugin {
175
248
  const appRoot = resolve(options.appRoot ?? process.cwd())
176
249
  const root = resolve(options.root ?? appRoot)
177
250
  const outDir = resolve(root, options.outDir ?? "dist")
251
+ const specifiers = entrySpecifiers(appRoot, options)
178
252
  const plugin: LeakGuardPlugin = {
179
253
  name: "nifra:leak-guard",
254
+ buildStart: () => specifiers.reset(),
255
+ resolveId: {
256
+ order: "pre",
257
+ handler(source, importer, resolveOptions) {
258
+ return specifiers.resolveId(this, source, importer, resolveOptions)
259
+ },
260
+ },
180
261
  generateBundle(_options, bundle) {
181
262
  const classifier = classifierFor(appRoot, options)
182
263
  // Resolved import edges per module, straight from Rollup's graph. Dynamic imports count too: a
@@ -186,7 +267,9 @@ export function viteLeakGuard(options: LeakGuardOptions = {}): LeakGuardPlugin {
186
267
  if (info === null) return []
187
268
  return [...(info.importedIds ?? []), ...(info.dynamicallyImportedIds ?? [])]
188
269
  }
189
- const graph = completeGraph(fromRollupBundle(bundle, importsOf), this, importsOf)
270
+ const graph = specifiers.annotate(
271
+ completeGraph(fromRollupBundle(bundle, importsOf), this, importsOf),
272
+ )
190
273
  const sources = { classifier, sourceOf: rollupModuleSource }
191
274
  const privateEnv = privateEnvCheck(options.publicEnvPrefix ?? "PUBLIC_")
192
275
  const outputs = Object.values(bundle as Readonly<Record<string, RollupOutputLike>>)
@@ -271,8 +354,16 @@ export type ServerZoneGuardOptions = Pick<
271
354
  */
272
355
  export function viteServerZoneGuard(options: ServerZoneGuardOptions = {}): LeakGuardPlugin {
273
356
  const appRoot = resolve(options.appRoot ?? process.cwd())
357
+ const specifiers = entrySpecifiers(appRoot, options)
274
358
  const plugin: LeakGuardPlugin = {
275
359
  name: "nifra:server-zone-guard",
360
+ buildStart: () => specifiers.reset(),
361
+ resolveId: {
362
+ order: "pre",
363
+ handler(source, importer, resolveOptions) {
364
+ return specifiers.resolveId(this, source, importer, resolveOptions)
365
+ },
366
+ },
276
367
  generateBundle(_options, bundle) {
277
368
  const classifier = classifierFor(appRoot, options)
278
369
  const importsOf = (id: string): readonly string[] => {
@@ -280,7 +371,9 @@ export function viteServerZoneGuard(options: ServerZoneGuardOptions = {}): LeakG
280
371
  if (info === null) return []
281
372
  return [...(info.importedIds ?? []), ...(info.dynamicallyImportedIds ?? [])]
282
373
  }
283
- const graph = completeGraph(fromRollupBundle(bundle, importsOf), this, importsOf)
374
+ const graph = specifiers.annotate(
375
+ completeGraph(fromRollupBundle(bundle, importsOf), this, importsOf),
376
+ )
284
377
  const leak = formatServerGraphVerdict(
285
378
  verifyServerGraph(graph, {
286
379
  classifier,
@@ -465,7 +558,6 @@ export interface AssetUrlGuardPlugin {
465
558
  ): Promise<null>
466
559
  }
467
560
 
468
- const POSTFIX = /[?#].*$/s
469
561
  const URL_SCHEME = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i
470
562
 
471
563
  /**
@@ -507,7 +599,7 @@ export function viteAssetUrlGuard(options: ServerZoneGuardOptions = {}): AssetUr
507
599
  },
508
600
  async transform(code, id, transformOptions) {
509
601
  if (transformOptions?.ssr === true || !code.includes("import.meta.url")) return null
510
- const importer = id.replace(POSTFIX, "")
602
+ const importer = withoutQuery(id)
511
603
  if (!isAbsolute(importer) || importer.includes("/node_modules/") || CSS_FILE.test(importer))
512
604
  return null
513
605
  let program: unknown
@@ -553,11 +645,11 @@ async function assetUrlFiles(
553
645
  ): Promise<string[]> {
554
646
  const files = new Set<string>()
555
647
  const add = (path: string | undefined): void => {
556
- const file = path?.replace(POSTFIX, "")
648
+ const file = path === undefined ? undefined : withoutQuery(path)
557
649
  if (file === undefined || !isAbsolute(file) || file.includes("\0")) return
558
650
  if (statSync(file, { throwIfNoEntry: false })?.isFile() === true) files.add(file)
559
651
  }
560
- if (!url.startsWith("/")) add(resolve(dirname(importer), url.replace(POSTFIX, "")))
652
+ if (!url.startsWith("/")) add(resolve(dirname(importer), withoutQuery(url)))
561
653
  const sources = url.startsWith(".") || url.startsWith("/") ? [url] : [url, `./${url}`]
562
654
  for (const source of sources) {
563
655
  try {
@@ -10,10 +10,12 @@
10
10
  *
11
11
  * The SSR environment loads the same files legitimately and is never checked here.
12
12
  */
13
- import { existsSync, readFileSync, statSync } from "node:fs"
13
+ import { readFileSync, statSync } from "node:fs"
14
14
  import type { IncomingMessage, ServerResponse } from "node:http"
15
15
  import { dirname, isAbsolute, join, relative, resolve } from "node:path"
16
+ import { installedManifest } from "../internal/package-entries.ts"
16
17
  import { privateEnvDenial } from "../internal/private-env.ts"
18
+ import { withoutQuery } from "../internal/without-query.ts"
17
19
  import {
18
20
  browserDenial,
19
21
  type Classification,
@@ -78,7 +80,6 @@ export interface ViteZoneGuardPlugin {
78
80
  fileForUrl(url: string): string | undefined
79
81
  }
80
82
 
81
- const stripQuery = (id: string): string => id.replace(/[?#].*$/, "")
82
83
  /** Query forms that hand a module's source, or a URL to it, to the browser untransformed. */
83
84
  const RAW_QUERY = /(?:^|&)(?:raw|url|inline)(?:[&=]|$)/
84
85
 
@@ -136,7 +137,7 @@ export function viteZoneGuard(options: ViteZoneGuardOptions): ViteZoneGuardPlugi
136
137
  const fileForUrl = (url: string): string | undefined => {
137
138
  let path: string
138
139
  try {
139
- path = decodeURIComponent(stripQuery(url))
140
+ path = decodeURIComponent(withoutQuery(url))
140
141
  } catch {
141
142
  return undefined
142
143
  }
@@ -187,16 +188,17 @@ export function viteZoneGuard(options: ViteZoneGuardOptions): ViteZoneGuardPlugi
187
188
  return context.error(new Error(message))
188
189
  }
189
190
 
190
- /** A bare specifier's package, classified through its manifest so a workspace link is first-party. */
191
- const packageClassification = (name: string, importer: string): Classification => {
192
- let dir = dirname(importer)
193
- for (;;) {
194
- const manifest = join(dir, "node_modules", name, "package.json")
195
- if (existsSync(manifest)) return classifier.classify(manifest)
196
- const parent = dirname(dir)
197
- if (parent === dir) return { zone: "library", packageName: name }
198
- dir = parent
199
- }
191
+ /** A bare import's package, classified through its manifest so a workspace link is first-party,
192
+ * and by the entry it names when the package declares a side per entry. */
193
+ const packageClassification = (
194
+ name: string,
195
+ importer: string,
196
+ specifier: string,
197
+ ): Classification => {
198
+ const manifest = installedManifest(dirname(importer), name)
199
+ return manifest === undefined
200
+ ? { zone: "library", packageName: name }
201
+ : classifier.classify(manifest, specifier)
200
202
  }
201
203
 
202
204
  return {
@@ -219,10 +221,10 @@ export function viteZoneGuard(options: ViteZoneGuardOptions): ViteZoneGuardPlugi
219
221
  async resolveId(source, importer, resolveOptions) {
220
222
  if (importer === undefined || resolveOptions?.scan === true) return null
221
223
  if (!isClient(this, resolveOptions?.ssr)) return null
222
- const importerFile = stripQuery(importer)
224
+ const importerFile = withoutQuery(importer)
223
225
  if (!isAbsolute(importerFile)) return null
224
226
  if (source.startsWith(".")) {
225
- const target = resolve(dirname(importerFile), stripQuery(source))
227
+ const target = resolve(dirname(importerFile), withoutQuery(source))
226
228
  if (isFile(target)) {
227
229
  const reason = browserDenial(classifier.classify(target))
228
230
  if (reason !== undefined) refuse(this, target, reason, importerFile)
@@ -231,21 +233,21 @@ export function viteZoneGuard(options: ViteZoneGuardOptions): ViteZoneGuardPlugi
231
233
  }
232
234
  const name = specifierPackage(source)
233
235
  if (name !== undefined) {
234
- const reason = browserDenial(packageClassification(name, importerFile), source)
236
+ const reason = browserDenial(packageClassification(name, importerFile, source), source)
235
237
  if (reason !== undefined) refuse(this, source, reason, importerFile)
236
238
  }
237
239
  // An alias, a tsconfig path or a package export: what the specifier resolves to decides.
238
240
  const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true })
239
- const target = resolved === null || resolved.external ? undefined : stripQuery(resolved.id)
241
+ const target = resolved === null || resolved.external ? undefined : withoutQuery(resolved.id)
240
242
  if (target !== undefined && isAbsolute(target) && isFile(target)) {
241
- const reason = browserDenial(classifier.classify(target), source)
243
+ const reason = browserDenial(classifier.classify(target, source), source)
242
244
  if (reason !== undefined) refuse(this, target, reason, importerFile)
243
245
  }
244
246
  return null
245
247
  },
246
248
  transform(code, id, transformOptions) {
247
249
  if (!isClient(this, transformOptions?.ssr)) return null
248
- const file = stripQuery(id)
250
+ const file = withoutQuery(id)
249
251
  if (!isAbsolute(file) || !isFile(file)) return null
250
252
  // A fresh transform re-resolves every import, so an earlier refusal is stale until it recurs.
251
253
  refusals.delete(resolve(file))
@@ -10,11 +10,14 @@
10
10
  import { statSync } from "node:fs"
11
11
  import { dirname, isAbsolute, relative, resolve } from "node:path"
12
12
  import type { BunPlugin } from "bun"
13
+ import { installedManifest } from "../internal/package-entries.ts"
13
14
  import { privateEnvDenial } from "../internal/private-env.ts"
15
+ import { withoutQuery } from "../internal/without-query.ts"
14
16
  import {
15
17
  BACKEND_ONLY_MARKER,
16
18
  browserDenial,
17
19
  createZoneClassifier,
20
+ specifierPackage,
18
21
  type ZoneClassifier,
19
22
  } from "../zones.ts"
20
23
 
@@ -58,6 +61,40 @@ export interface ZoneGuardOptions {
58
61
  /** Zones whose code runs in a browser, so may read only public environment variables. */
59
62
  const BROWSER_CODE = new Set<string>(["route-frontend", "frontend", "shared"])
60
63
 
64
+ const loaderFor = (file: string): "ts" | "tsx" | "js" | "jsx" | undefined => {
65
+ const extension = /\.[cm]?([jt]sx?)$/.exec(file)?.[1]
66
+ return extension === "ts" || extension === "tsx" || extension === "js" || extension === "jsx"
67
+ ? extension
68
+ : undefined
69
+ }
70
+
71
+ /**
72
+ * Why browser code at `file` may not make one of its package imports: one naming an entry its package
73
+ * declares backend-only, or one its per-entry map leaves out. A path cannot tell two entries that share
74
+ * a file apart, so the dev server judges the specifier here, where it is written.
75
+ */
76
+ function entryDenial(classifier: ZoneClassifier, file: string, source: string): string | undefined {
77
+ const loader = loaderFor(file)
78
+ if (loader === undefined) return undefined
79
+ let imports: ReadonlyArray<{ readonly path: string }>
80
+ try {
81
+ imports = new Bun.Transpiler({ loader }).scanImports(source)
82
+ } catch {
83
+ // Source Bun cannot read fails its own load with the parse error.
84
+ return undefined
85
+ }
86
+ for (const { path } of imports) {
87
+ const name = specifierPackage(path)
88
+ const manifest = name === undefined ? undefined : installedManifest(dirname(file), name)
89
+ if (manifest === undefined) continue
90
+ const classification = classifier.classify(manifest, path)
91
+ if (classification.entry === undefined) continue
92
+ const reason = browserDenial(classification, path)
93
+ if (reason !== undefined) return `it imports "${path}": ${reason}`
94
+ }
95
+ return undefined
96
+ }
97
+
61
98
  export function zoneGuardPlugin(options: ZoneGuardOptions): BunPlugin {
62
99
  const classifier = options.classifier ?? createZoneClassifier(options)
63
100
  const display = (file: string): string => {
@@ -86,14 +123,16 @@ export function zoneGuardPlugin(options: ZoneGuardOptions): BunPlugin {
86
123
  BROWSER_CODE.has(classification.zone)
87
124
  ) {
88
125
  const source = await Bun.file(args.path).text()
89
- reason = privateEnvDenial(args.path, source, options.publicEnvPrefix ?? "PUBLIC_")
126
+ reason =
127
+ privateEnvDenial(args.path, source, options.publicEnvPrefix ?? "PUBLIC_") ??
128
+ entryDenial(classifier, args.path, source)
90
129
  }
91
130
  return reason === undefined ? undefined : refuse(args.path, reason)
92
131
  })
93
132
  build.onResolve({ filter: NON_SOURCE_PATH }, (args) => {
94
133
  // An entry point (a dev server's HTML page) is the build's own choice, not an import.
95
134
  if (args.importer === "") return undefined
96
- const file = resolve(dirname(args.importer), args.path.replace(/[?#].*$/, ""))
135
+ const file = resolve(dirname(args.importer), withoutQuery(args.path))
97
136
  // No such file means an extensionless import of a dotted source name (`./date.utils`): Bun resolves
98
137
  // it to the source file, which onLoad judges. Judging the bare path would refuse a file never loaded.
99
138
  if (!isFile(file)) return undefined
package/src/prerender.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * routes are not supported.
10
10
  */
11
11
  import { mkdirSync, writeFileSync } from "node:fs"
12
- import { dirname, resolve, sep } from "node:path"
12
+ import { dirname, join, resolve, sep } from "node:path"
13
13
  import { assertNoSecrets, buildEnvironment, type SecretExemption } from "./internal/secret-scan.ts"
14
14
  import { fillRoutePattern, type RouteEntry } from "./manifest.ts"
15
15
  import { DATA_HEADER, PRERENDERED_GLOBAL, PRERENDERED_LIST_PATH } from "./router.ts"
@@ -70,10 +70,32 @@ function trimTrailingSlashes(path: string): string {
70
70
  return path.slice(0, end)
71
71
  }
72
72
 
73
- /** Map a route path to its output file: `/` → `index.html`, `/a/b` → `a/b/index.html`. */
73
+ /**
74
+ * One request-path segment as the file name a static host looks up, which is the segment decoded, or
75
+ * `undefined` when no file name maps back to it: malformed percent-encoding, `.` or `..`, or a decoded
76
+ * `/`, `\` or NUL.
77
+ */
78
+ function fileSegment(segment: string): string | undefined {
79
+ let decoded: string
80
+ try {
81
+ decoded = decodeURIComponent(segment)
82
+ } catch {
83
+ return undefined
84
+ }
85
+ if (decoded === "." || decoded === ".." || /[/\\\0]/.test(decoded)) return undefined
86
+ return decoded
87
+ }
88
+
89
+ /**
90
+ * Map a route path to its output file: `/` → `index.html`, `/a/b` → `a/b/index.html`. Segments are
91
+ * written decoded (`/caf%C3%A9` → `café/index.html`) because a static host decodes the request path
92
+ * before it looks for a file; a segment with no such file name stays as written.
93
+ */
74
94
  export function htmlFileFor(pattern: string): string {
75
95
  const trimmed = trimTrailingSlashes(pattern.replace(/^\/+/, ""))
76
- return trimmed === "" ? "index.html" : `${trimmed}/index.html`
96
+ if (trimmed === "") return "index.html"
97
+ const segments = trimmed.split("/").map((segment) => fileSegment(segment) ?? segment)
98
+ return `${segments.join("/")}/index.html`
77
99
  }
78
100
 
79
101
  // The handover of a page whose prerendered set is served as a list rather than inlined.
@@ -119,6 +141,16 @@ export async function prerenderRoutes(options: PrerenderOptions): Promise<Preren
119
141
  // static (one path) and dynamic (one path per getStaticPaths entry) routes. A failed render is a skip;
120
142
  // a credential in the output fails the whole prerender.
121
143
  const renderPath = async (path: string): Promise<void> => {
144
+ // Refused before rendering: a segment no file name maps back to, or a file outside `outDir`.
145
+ const file = htmlFileFor(path)
146
+ const segments = trimTrailingSlashes(path.replace(/^\/+/, "")).split("/")
147
+ const abs = segments.every((s) => s === "" || fileSegment(s) !== undefined)
148
+ ? outputPath(outRoot, file)
149
+ : undefined
150
+ if (abs === undefined) {
151
+ skipped.push({ path, reason: "unsafe output path" })
152
+ return
153
+ }
122
154
  const res = await options.app.fetch(new Request(`${origin}${path}`))
123
155
  if (!res.ok) {
124
156
  // A failed render is a per-route skip, not a build failure - surfaced for the caller to log.
@@ -127,12 +159,6 @@ export async function prerenderRoutes(options: PrerenderOptions): Promise<Preren
127
159
  }
128
160
  const html = await res.text()
129
161
  listUrl ??= PRERENDERED_LIST_URL.exec(html)?.[1]
130
- const file = htmlFileFor(path)
131
- const abs = outputPath(outRoot, file)
132
- if (abs === undefined) {
133
- skipped.push({ path, reason: "unsafe output path" })
134
- return
135
- }
136
162
  assertClean(file, html)
137
163
  mkdirSync(dirname(abs), { recursive: true })
138
164
  writeFileSync(abs, html)
@@ -150,15 +176,10 @@ export async function prerenderRoutes(options: PrerenderOptions): Promise<Preren
150
176
  !(dataRes.headers.get("content-type") ?? "").includes("application/x-ndjson")
151
177
  ) {
152
178
  dataFile = dataFileFor(path)
153
- const dataAbs = outputPath(outRoot, dataFile)
154
- if (dataAbs === undefined) {
155
- await dataRes.body?.cancel()
156
- dataFile = undefined
157
- } else {
158
- const data = await dataRes.text()
159
- assertClean(dataFile, data)
160
- writeFileSync(dataAbs, data)
161
- }
179
+ const data = await dataRes.text()
180
+ assertClean(dataFile, data)
181
+ // Beside `index.html`, so inside `outDir` whenever it is.
182
+ writeFileSync(join(dirname(abs), "_data.json"), data)
162
183
  } else {
163
184
  await dataRes.body?.cancel() // not emitting (non-OK or NDJSON) - release the stream
164
185
  }
@@ -33,6 +33,61 @@ export interface RenderProps {
33
33
  readonly boundaries?: BoundaryStates
34
34
  /** The rendered chain's module ids and `handle` exports, for the adapter's `useMatches`. */
35
35
  readonly matchChain?: MatchChain
36
+ /** The page's locale, when the app registers its locale routing (`createWebApp`'s `i18n`). Set on
37
+ * SSR only; the client names it from `path` (see {@link localeOfProps}). */
38
+ readonly locale?: PageLocale
39
+ }
40
+
41
+ /** A served locale as a page renders in it: from the app's locale registry. */
42
+ export interface PageLocale {
43
+ /** The registry key, which is also the URL segment (`ur`). */
44
+ readonly key: string
45
+ /** The BCP-47 tag `<html lang>` and `Content-Language` carry (`ur-PK`). */
46
+ readonly lang: string
47
+ readonly dir: "ltr" | "rtl"
48
+ }
49
+
50
+ /** The served locales, handed to the client so it names a page's locale from its path the way the
51
+ * server does. */
52
+ export interface PageLocales {
53
+ /** The default locale's key. */
54
+ readonly default: string
55
+ /** Every served locale, in registry order. */
56
+ readonly locales: readonly PageLocale[]
57
+ }
58
+
59
+ /** The handover global carrying {@link PageLocales}. */
60
+ export const LOCALES_GLOBAL = "__NIFRA_LOCALES__"
61
+
62
+ /**
63
+ * The locale a path is in, by the rule `@nifrajs/i18n/routing`'s `localeOf` follows: a first path
64
+ * segment that names a served locale, ignoring ASCII case, or else the default.
65
+ */
66
+ export function pageLocaleIn(config: PageLocales, path: string): PageLocale | undefined {
67
+ // One linear pass: separators, then the segment up to the next separator, query or hash.
68
+ const first = /^[/\\]*([^/\\?#]*)/.exec(path.replace(/[\t\n\r]/g, ""))?.[1]?.toLowerCase()
69
+ return (
70
+ config.locales.find((locale) => first !== "" && locale.key.toLowerCase() === first) ??
71
+ config.locales.find((locale) => locale.key === config.default)
72
+ )
73
+ }
74
+
75
+ const isPageLocales = (value: unknown): value is PageLocales =>
76
+ typeof value === "object" && value !== null && "locales" in value && Array.isArray(value.locales)
77
+
78
+ /** The locale of the page at `path`, from the served locales the server handed over; `undefined`
79
+ * outside a browser page, or when the app registers no locale routing. */
80
+ export function handoverLocale(path: string): PageLocale | undefined {
81
+ const config: unknown = Reflect.get(globalThis, LOCALES_GLOBAL)
82
+ return isPageLocales(config) ? pageLocaleIn(config, path) : undefined
83
+ }
84
+
85
+ /** The locale of the page `props` render: the server's answer on SSR, else the one the client names
86
+ * from `props.path`. What every adapter's `useLocale` reads. */
87
+ export function localeOfProps(props: RenderProps | undefined): PageLocale | undefined {
88
+ if (props === undefined) return undefined
89
+ if (props.locale !== undefined) return props.locale
90
+ return props.path === undefined ? undefined : handoverLocale(props.path)
36
91
  }
37
92
 
38
93
  /**
@@ -62,10 +117,24 @@ export interface UIMatch {
62
117
  readonly handle: unknown
63
118
  }
64
119
 
120
+ /**
121
+ * Markup a render produced for outside the app root, which an adapter that sets
122
+ * {@link RenderAdapter.documentParts} appends to. Each part must be complete before the render
123
+ * settles: `renderToString` resolving, or `renderToStream` returning its stream.
124
+ */
125
+ export interface DocumentParts {
126
+ /** Placed at the end of `<head>`. Svelte's `<svelte:head>` lands here. */
127
+ head: string
128
+ /** Placed first in `<body>`, before the app root. Vue's `<Teleport to="body">` lands here. */
129
+ body: string
130
+ }
131
+
65
132
  /** Per-document options for {@link RenderAdapter.renderToStream}. */
66
133
  export interface RenderStreamOptions {
67
134
  /** The document's CSP nonce, for every executable `<script>` the framework streams. */
68
135
  readonly nonce?: string
136
+ /** Present only for an adapter that sets {@link RenderAdapter.documentParts}. */
137
+ readonly parts?: DocumentParts
69
138
  }
70
139
 
71
140
  /**
@@ -84,10 +153,22 @@ export interface RenderAdapter {
84
153
  props: RenderProps,
85
154
  options?: RenderStreamOptions,
86
155
  ): ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>
87
- /** Render the chain to a complete HTML string when no content is deferred. */
88
- renderToString?(chain: readonly unknown[], props: RenderProps): string | Promise<string>
156
+ /**
157
+ * Render the chain to a complete HTML string when no content is deferred. `options.nonce` is the
158
+ * document's CSP nonce, for any inline script the renderer emits (Solid's hydration data).
159
+ */
160
+ renderToString?(
161
+ chain: readonly unknown[],
162
+ props: RenderProps,
163
+ options?: RenderStreamOptions,
164
+ ): string | Promise<string>
89
165
  /** Per-document bootstrap markup injected into `<head>` for client hydration. */
90
166
  hydrationHead(nonce?: string): string
167
+ /**
168
+ * Set when a render can produce markup that belongs outside the app root. Every render is then
169
+ * passed `options.parts` to fill; an adapter without it never is, and pays nothing for the seam.
170
+ */
171
+ readonly documentParts?: boolean
91
172
  }
92
173
 
93
174
  /** Loads a module through the dev server's module graph rather than the runtime's resolver. */
@@ -125,5 +206,10 @@ export const ACTION_GLOBAL = "__NIFRA_ACTION__"
125
206
  /** Dynamic-boundary states for hydration; absent when a route declares no boundaries. */
126
207
  export const BOUNDARY_GLOBAL = "__NIFRA_BOUNDARIES__"
127
208
  export const COMPONENT_GLOBAL = "__NIFRA_COMPONENTS__"
209
+ /** Locale key -> copy chunk URL, in a production build that split copy tables by locale. The name
210
+ * `@nifrajs/i18n/copy` reads. */
211
+ export const COPY_CHUNKS_GLOBAL = "__NIFRA_COPY__"
212
+ /** The registry `@nifrajs/i18n/copy` keeps its loaded chunks and definitions in. */
213
+ export const COPY_REALM = "nifra.copy"
128
214
  /** Marker attribute used to find a non-default hydration container. */
129
215
  export const ROOT_ATTRIBUTE = "data-nifra-root"