@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.
- package/README.md +8 -0
- package/dist/client.d.ts +5 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +25 -15
- package/dist/client.js.map +1 -1
- package/dist/dev-port.d.ts +3 -2
- package/dist/dev-port.d.ts.map +1 -1
- package/dist/dev-port.js +2 -6
- package/dist/dev-port.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/package-entries.d.ts +34 -0
- package/dist/internal/package-entries.d.ts.map +1 -0
- package/dist/internal/package-entries.js +324 -0
- package/dist/internal/package-entries.js.map +1 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +60 -6
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +15 -2
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/private-env.d.ts +8 -0
- package/dist/internal/private-env.d.ts.map +1 -1
- package/dist/internal/private-env.js +25 -10
- package/dist/internal/private-env.js.map +1 -1
- package/dist/internal/render-document.d.ts +4 -44
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +23 -58
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/route-path.d.ts +6 -0
- package/dist/internal/route-path.d.ts.map +1 -1
- package/dist/internal/route-path.js +11 -0
- package/dist/internal/route-path.js.map +1 -1
- package/dist/internal/secret-scan.d.ts.map +1 -1
- package/dist/internal/secret-scan.js +103 -1
- package/dist/internal/secret-scan.js.map +1 -1
- package/dist/internal/web-app.d.ts +4 -2
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +8 -3
- package/dist/internal/web-app.js.map +1 -1
- package/dist/internal/without-query.d.ts +4 -0
- package/dist/internal/without-query.d.ts.map +1 -0
- package/dist/internal/without-query.js +9 -0
- package/dist/internal/without-query.js.map +1 -0
- package/dist/internal/zone-graph.d.ts.map +1 -1
- package/dist/internal/zone-graph.js +17 -6
- package/dist/internal/zone-graph.js.map +1 -1
- package/dist/isr.d.ts +1 -1
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +15 -2
- package/dist/isr.js.map +1 -1
- package/dist/manifest.d.ts +9 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +1 -0
- package/dist/manifest.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +6 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
- package/dist/plugins/vite-leak-guard.js +80 -7
- package/dist/plugins/vite-leak-guard.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
- package/dist/plugins/vite-zone-guard.js +17 -21
- package/dist/plugins/vite-zone-guard.js.map +1 -1
- package/dist/plugins/zone-guard.d.ts.map +1 -1
- package/dist/plugins/zone-guard.js +44 -3
- package/dist/plugins/zone-guard.js.map +1 -1
- package/dist/prerender.d.ts +5 -1
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +41 -19
- package/dist/prerender.js.map +1 -1
- package/dist/render-seam.d.ts +23 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +9 -5
- package/dist/router.js.map +1 -1
- package/dist/seo.d.ts +49 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +48 -0
- package/dist/seo.js.map +1 -0
- package/dist/zones.d.ts +30 -8
- package/dist/zones.d.ts.map +1 -1
- package/dist/zones.js +185 -46
- package/dist/zones.js.map +1 -1
- package/package.json +8 -3
- package/src/client.ts +27 -15
- package/src/dev-port.ts +4 -10
- package/src/index.ts +2 -4
- package/src/internal/package-entries.ts +331 -0
- package/src/internal/page-execution.ts +67 -6
- package/src/internal/parity.ts +15 -2
- package/src/internal/private-env.ts +27 -13
- package/src/internal/render-document.ts +40 -78
- package/src/internal/route-path.ts +16 -0
- package/src/internal/secret-scan.ts +101 -1
- package/src/internal/web-app.ts +10 -5
- package/src/internal/without-query.ts +8 -0
- package/src/internal/zone-graph.ts +19 -6
- package/src/isr.ts +13 -2
- package/src/manifest.ts +10 -0
- package/src/plugins/vite-leak-guard.ts +99 -7
- package/src/plugins/vite-zone-guard.ts +21 -19
- package/src/plugins/zone-guard.ts +41 -2
- package/src/prerender.ts +39 -18
- package/src/render-seam.ts +28 -2
- package/src/router.ts +9 -5
- package/src/seo.ts +68 -0
- 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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 =
|
|
224
|
+
const importerFile = withoutQuery(importer)
|
|
223
225
|
if (!isAbsolute(importerFile)) return null
|
|
224
226
|
if (source.startsWith(".")) {
|
|
225
|
-
const target = resolve(dirname(importerFile),
|
|
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 :
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
}
|
package/src/render-seam.ts
CHANGED
|
@@ -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
|
-
/**
|
|
88
|
-
|
|
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, {
|
|
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
|
+
}
|