@nifrajs/web 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +8 -0
  2. package/dist/client.d.ts +5 -5
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +25 -15
  5. package/dist/client.js.map +1 -1
  6. package/dist/dev-port.d.ts +3 -2
  7. package/dist/dev-port.d.ts.map +1 -1
  8. package/dist/dev-port.js +2 -6
  9. package/dist/dev-port.js.map +1 -1
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/internal/package-entries.d.ts +34 -0
  15. package/dist/internal/package-entries.d.ts.map +1 -0
  16. package/dist/internal/package-entries.js +324 -0
  17. package/dist/internal/package-entries.js.map +1 -0
  18. package/dist/internal/page-execution.d.ts.map +1 -1
  19. package/dist/internal/page-execution.js +60 -6
  20. package/dist/internal/page-execution.js.map +1 -1
  21. package/dist/internal/parity.d.ts.map +1 -1
  22. package/dist/internal/parity.js +15 -2
  23. package/dist/internal/parity.js.map +1 -1
  24. package/dist/internal/private-env.d.ts +8 -0
  25. package/dist/internal/private-env.d.ts.map +1 -1
  26. package/dist/internal/private-env.js +25 -10
  27. package/dist/internal/private-env.js.map +1 -1
  28. package/dist/internal/render-document.d.ts +4 -44
  29. package/dist/internal/render-document.d.ts.map +1 -1
  30. package/dist/internal/render-document.js +23 -58
  31. package/dist/internal/render-document.js.map +1 -1
  32. package/dist/internal/route-path.d.ts +6 -0
  33. package/dist/internal/route-path.d.ts.map +1 -1
  34. package/dist/internal/route-path.js +11 -0
  35. package/dist/internal/route-path.js.map +1 -1
  36. package/dist/internal/secret-scan.d.ts.map +1 -1
  37. package/dist/internal/secret-scan.js +103 -1
  38. package/dist/internal/secret-scan.js.map +1 -1
  39. package/dist/internal/web-app.d.ts +4 -2
  40. package/dist/internal/web-app.d.ts.map +1 -1
  41. package/dist/internal/web-app.js +8 -3
  42. package/dist/internal/web-app.js.map +1 -1
  43. package/dist/internal/without-query.d.ts +4 -0
  44. package/dist/internal/without-query.d.ts.map +1 -0
  45. package/dist/internal/without-query.js +9 -0
  46. package/dist/internal/without-query.js.map +1 -0
  47. package/dist/internal/zone-graph.d.ts.map +1 -1
  48. package/dist/internal/zone-graph.js +17 -6
  49. package/dist/internal/zone-graph.js.map +1 -1
  50. package/dist/isr.d.ts +1 -1
  51. package/dist/isr.d.ts.map +1 -1
  52. package/dist/isr.js +15 -2
  53. package/dist/isr.js.map +1 -1
  54. package/dist/manifest.d.ts +9 -0
  55. package/dist/manifest.d.ts.map +1 -1
  56. package/dist/manifest.js +1 -0
  57. package/dist/manifest.js.map +1 -1
  58. package/dist/plugins/vite-leak-guard.d.ts +6 -0
  59. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  60. package/dist/plugins/vite-leak-guard.js +80 -7
  61. package/dist/plugins/vite-leak-guard.js.map +1 -1
  62. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  63. package/dist/plugins/vite-zone-guard.js +17 -21
  64. package/dist/plugins/vite-zone-guard.js.map +1 -1
  65. package/dist/plugins/zone-guard.d.ts.map +1 -1
  66. package/dist/plugins/zone-guard.js +44 -3
  67. package/dist/plugins/zone-guard.js.map +1 -1
  68. package/dist/prerender.d.ts +5 -1
  69. package/dist/prerender.d.ts.map +1 -1
  70. package/dist/prerender.js +41 -19
  71. package/dist/prerender.js.map +1 -1
  72. package/dist/render-seam.d.ts +23 -2
  73. package/dist/render-seam.d.ts.map +1 -1
  74. package/dist/render-seam.js.map +1 -1
  75. package/dist/router.d.ts.map +1 -1
  76. package/dist/router.js +9 -5
  77. package/dist/router.js.map +1 -1
  78. package/dist/seo.d.ts +49 -0
  79. package/dist/seo.d.ts.map +1 -0
  80. package/dist/seo.js +48 -0
  81. package/dist/seo.js.map +1 -0
  82. package/dist/zones.d.ts +30 -8
  83. package/dist/zones.d.ts.map +1 -1
  84. package/dist/zones.js +185 -46
  85. package/dist/zones.js.map +1 -1
  86. package/package.json +8 -3
  87. package/src/client.ts +27 -15
  88. package/src/dev-port.ts +4 -10
  89. package/src/index.ts +2 -4
  90. package/src/internal/package-entries.ts +331 -0
  91. package/src/internal/page-execution.ts +67 -6
  92. package/src/internal/parity.ts +15 -2
  93. package/src/internal/private-env.ts +27 -13
  94. package/src/internal/render-document.ts +40 -78
  95. package/src/internal/route-path.ts +16 -0
  96. package/src/internal/secret-scan.ts +101 -1
  97. package/src/internal/web-app.ts +10 -5
  98. package/src/internal/without-query.ts +8 -0
  99. package/src/internal/zone-graph.ts +19 -6
  100. package/src/isr.ts +13 -2
  101. package/src/manifest.ts +10 -0
  102. package/src/plugins/vite-leak-guard.ts +99 -7
  103. package/src/plugins/vite-zone-guard.ts +21 -19
  104. package/src/plugins/zone-guard.ts +41 -2
  105. package/src/prerender.ts +39 -18
  106. package/src/render-seam.ts +28 -2
  107. package/src/router.ts +9 -5
  108. package/src/seo.ts +68 -0
  109. package/src/zones.ts +237 -52
@@ -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
  }
@@ -62,10 +62,24 @@ export interface UIMatch {
62
62
  readonly handle: unknown
63
63
  }
64
64
 
65
+ /**
66
+ * Markup a render produced for outside the app root, which an adapter that sets
67
+ * {@link RenderAdapter.documentParts} appends to. Each part must be complete before the render
68
+ * settles: `renderToString` resolving, or `renderToStream` returning its stream.
69
+ */
70
+ export interface DocumentParts {
71
+ /** Placed at the end of `<head>`. Svelte's `<svelte:head>` lands here. */
72
+ head: string
73
+ /** Placed first in `<body>`, before the app root. Vue's `<Teleport to="body">` lands here. */
74
+ body: string
75
+ }
76
+
65
77
  /** Per-document options for {@link RenderAdapter.renderToStream}. */
66
78
  export interface RenderStreamOptions {
67
79
  /** The document's CSP nonce, for every executable `<script>` the framework streams. */
68
80
  readonly nonce?: string
81
+ /** Present only for an adapter that sets {@link RenderAdapter.documentParts}. */
82
+ readonly parts?: DocumentParts
69
83
  }
70
84
 
71
85
  /**
@@ -84,10 +98,22 @@ export interface RenderAdapter {
84
98
  props: RenderProps,
85
99
  options?: RenderStreamOptions,
86
100
  ): 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>
101
+ /**
102
+ * Render the chain to a complete HTML string when no content is deferred. `options.nonce` is the
103
+ * document's CSP nonce, for any inline script the renderer emits (Solid's hydration data).
104
+ */
105
+ renderToString?(
106
+ chain: readonly unknown[],
107
+ props: RenderProps,
108
+ options?: RenderStreamOptions,
109
+ ): string | Promise<string>
89
110
  /** Per-document bootstrap markup injected into `<head>` for client hydration. */
90
111
  hydrationHead(nonce?: string): string
112
+ /**
113
+ * Set when a render can produce markup that belongs outside the app root. Every render is then
114
+ * passed `options.parts` to fill; an adapter without it never is, and pays nothing for the seam.
115
+ */
116
+ readonly documentParts?: boolean
91
117
  }
92
118
 
93
119
  /** Loads a module through the dev server's module graph rather than the runtime's resolver. */
package/src/router.ts CHANGED
@@ -10,7 +10,7 @@ import type { StandardSchemaV1 } from "@nifrajs/core/server"
10
10
  import type { BoundaryDescriptor, BoundaryStates } from "./boundary.ts"
11
11
  import { parseNdjsonData } from "./deferred.ts"
12
12
  import { parsePageData } from "./internal/revive-dates.ts"
13
- import { requestPattern } from "./internal/route-path.ts"
13
+ import { appUrl, requestPattern } from "./internal/route-path.ts"
14
14
  import type { ClientActionResult, ClientRequestBody, ClientRouteHooks } from "./manifest.ts"
15
15
  import type { MatchChain } from "./render-seam.ts"
16
16
  import { isClientOnlySearchChange } from "./search.ts"
@@ -429,7 +429,7 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
429
429
  const cut = query === -1 ? hash : hash === -1 ? query : Math.min(query, hash)
430
430
  const pathname = cut === -1 ? path : path.slice(0, cut)
431
431
  if (prerenderedSetOf(prerendered).has(pathname)) {
432
- const staticRes = await fetch(dataUrlFor(pathname), { signal: signal ?? null })
432
+ const staticRes = await fetch(appUrl(dataUrlFor(pathname)), { signal: signal ?? null })
433
433
  if (staticRes.ok) return readResponseData(staticRes, signal)
434
434
  }
435
435
  }
@@ -438,7 +438,7 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
438
438
  headers.set(RETAIN_HEADER, navigation.retain.join(","))
439
439
  headers.set(NAV_FROM_HEADER, navigation.from)
440
440
  }
441
- const res = await fetch(path, { headers, signal: signal ?? null })
441
+ const res = await fetch(appUrl(path), { headers, signal: signal ?? null })
442
442
  const redirectTo = res.headers.get(REDIRECT_HEADER)
443
443
  if (redirectTo !== null) throw redirected(redirectTo)
444
444
  const status = Number(res.headers.get(STATUS_HEADER))
@@ -943,7 +943,11 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
943
943
  }
944
944
  fEmit()
945
945
  try {
946
- const res = await fetch(action, { method: "POST", body, headers: { [DATA_HEADER]: "1" } })
946
+ const res = await fetch(appUrl(action), {
947
+ method: "POST",
948
+ body,
949
+ headers: { [DATA_HEADER]: "1" },
950
+ })
947
951
  if (!res.ok)
948
952
  throw new Error(`[nifra/web] fetcher action failed (${res.status}): ${action}`)
949
953
  const actionData = res.status === 204 ? undefined : await readResponseData(res, ac.signal)
@@ -1094,7 +1098,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1094
1098
  state = { ...state, actionData: prepared.optimisticData }
1095
1099
  emit()
1096
1100
  }
1097
- const res = await fetch(action, {
1101
+ const res = await fetch(appUrl(action), {
1098
1102
  method: "POST",
1099
1103
  body: prepared.body,
1100
1104
  headers: { [DATA_HEADER]: "1" },
package/src/seo.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Head helpers for a route's `meta`: `canonical()`, `openGraph()` and `jsonLd()`. `meta()` runs on the
3
+ * server and in the browser, and this entry imports nothing at runtime, so browser code can take the
4
+ * helpers from here without bundling the server modules the package root also exports.
5
+ */
6
+ import type { LinkDescriptor, MetaDescriptor, ScriptDescriptor } from "./manifest.ts"
7
+
8
+ /**
9
+ * A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
10
+ * engines which URL is authoritative for a page (deduping query-string / tracking variants).
11
+ *
12
+ * ```ts
13
+ * export const meta = (a) => ({ link: [canonical(`https://site.com/posts/${a.params.slug}`)] })
14
+ * ```
15
+ */
16
+ export function canonical(href: string): LinkDescriptor {
17
+ return { rel: "canonical", href }
18
+ }
19
+
20
+ /** Inputs for {@link openGraph} - the common Open Graph properties. All optional; only the provided
21
+ * ones become tags. `type` defaults to `"website"`. */
22
+ export interface OpenGraphInput {
23
+ readonly title?: string
24
+ readonly description?: string
25
+ /** Absolute URL of the share image (`og:image`). */
26
+ readonly image?: string
27
+ /** Canonical URL of the page (`og:url`). */
28
+ readonly url?: string
29
+ /** Object type (`og:type`) - `"website"`, `"article"`, … Default `"website"`. */
30
+ readonly type?: string
31
+ }
32
+
33
+ /**
34
+ * Build the Open Graph `<meta property="og:*">` entries for a route's `meta.meta`. Returns only the
35
+ * properties you supplied (plus `og:type`, defaulting to `"website"`), so it composes with other meta.
36
+ *
37
+ * ```ts
38
+ * export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
39
+ * ```
40
+ */
41
+ export function openGraph(input: OpenGraphInput): MetaDescriptor[] {
42
+ const tags: MetaDescriptor[] = []
43
+ const add = (property: string, content: string | undefined): void => {
44
+ if (content !== undefined) tags.push({ property, content })
45
+ }
46
+ add("og:title", input.title)
47
+ add("og:description", input.description)
48
+ add("og:image", input.image)
49
+ add("og:url", input.url)
50
+ // `og:type` always present (the spec's required property) - default "website" when unset.
51
+ tags.push({ property: "og:type", content: input.type ?? "website" })
52
+ return tags
53
+ }
54
+
55
+ /**
56
+ * Build a JSON-LD `<script type="application/ld+json">` entry for a route's `meta.script` from a plain
57
+ * object. `JSON.stringify` produces the body; the head renderer breakout-escapes it (see
58
+ * `escapeScriptContent`), so a string field containing `</script>` is embedded safely.
59
+ *
60
+ * ```ts
61
+ * export const meta = {
62
+ * script: [jsonLd({ "@context": "https://schema.org", "@type": "Article", headline: "Hi" })],
63
+ * }
64
+ * ```
65
+ */
66
+ export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
67
+ return { type: "application/ld+json", content: JSON.stringify(data) }
68
+ }