@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
package/src/index.ts
CHANGED
|
@@ -88,12 +88,8 @@ export {
|
|
|
88
88
|
// `import { createClientRouter, createMatcher, mergeHeads, resolveChainMeta } from "@nifrajs/web/client"`.
|
|
89
89
|
export { mergeHeads, resolveChainMeta, resolveMeta } from "./internal/head-merge.ts"
|
|
90
90
|
export {
|
|
91
|
-
canonical,
|
|
92
91
|
gone,
|
|
93
|
-
jsonLd,
|
|
94
92
|
notFound,
|
|
95
|
-
type OpenGraphInput,
|
|
96
|
-
openGraph,
|
|
97
93
|
type RedirectOptions,
|
|
98
94
|
type RenderAssemblyCache,
|
|
99
95
|
type RenderedPage,
|
|
@@ -257,6 +253,7 @@ export {
|
|
|
257
253
|
ACTION_GLOBAL,
|
|
258
254
|
BOUNDARY_GLOBAL,
|
|
259
255
|
DATA_GLOBAL,
|
|
256
|
+
type DocumentParts,
|
|
260
257
|
HANDOVER_ID,
|
|
261
258
|
LAYOUT_DATA_GLOBAL,
|
|
262
259
|
type MatchChain,
|
|
@@ -298,6 +295,7 @@ export {
|
|
|
298
295
|
// ctx + `renderPage`) and a client adapter mount call this with the same URL + schema, so the two sides
|
|
299
296
|
// produce the identical value by construction. An adapter's `useSearch` binding reads its result.
|
|
300
297
|
export { type SearchOf, searchOf, searchOfChain, serializeSearch } from "./search.ts"
|
|
298
|
+
export { canonical, jsonLd, type OpenGraphInput, openGraph } from "./seo.ts"
|
|
301
299
|
export {
|
|
302
300
|
type HtmlSanitizer,
|
|
303
301
|
type SanitizedHtml,
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-entry package sides: `"nifra": { "environment": { ".": "backend", "./react": "frontend" } }`.
|
|
3
|
+
*
|
|
4
|
+
* An import names an entry, and that entry's side decides it. A path names no entry, so a file is owned
|
|
5
|
+
* by every mapped entry that reaches it inside the package: the entry's targets under any export
|
|
6
|
+
* condition, followed through relative imports. A stage that sees only paths decides by those owners;
|
|
7
|
+
* which entry an import may name is decided where the import is written, from its specifier.
|
|
8
|
+
*/
|
|
9
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
|
|
10
|
+
import { dirname, extname, join, relative } from "node:path"
|
|
11
|
+
import type { PackageEnvironment } from "../zones.ts"
|
|
12
|
+
import { type Token, tokenize } from "./private-env.ts"
|
|
13
|
+
import { withoutQuery } from "./without-query.ts"
|
|
14
|
+
|
|
15
|
+
export type EnvironmentDeclaration =
|
|
16
|
+
| { readonly kind: "none" }
|
|
17
|
+
| { readonly kind: "package"; readonly side: PackageEnvironment }
|
|
18
|
+
| { readonly kind: "entries"; readonly entries: Readonly<Record<string, PackageEnvironment>> }
|
|
19
|
+
| { readonly kind: "invalid"; readonly problem: string }
|
|
20
|
+
|
|
21
|
+
const SIDES: ReadonlySet<string> = new Set(["frontend", "backend", "shared", "library"])
|
|
22
|
+
const isSide = (value: unknown): value is PackageEnvironment =>
|
|
23
|
+
typeof value === "string" && SIDES.has(value)
|
|
24
|
+
|
|
25
|
+
/** A manifest's `nifra.environment`. A string naming no side declares nothing, as it always has. */
|
|
26
|
+
export function parseEnvironment(value: unknown): EnvironmentDeclaration {
|
|
27
|
+
if (isSide(value)) return { kind: "package", side: value }
|
|
28
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return { kind: "none" }
|
|
29
|
+
const entries: Record<string, PackageEnvironment> = {}
|
|
30
|
+
for (const [key, side] of Object.entries(value)) {
|
|
31
|
+
if (key !== "." && !key.startsWith("./"))
|
|
32
|
+
return {
|
|
33
|
+
kind: "invalid",
|
|
34
|
+
problem: `key "${key}" must be "." or a subpath starting with "./"`,
|
|
35
|
+
}
|
|
36
|
+
if (key.indexOf("*") !== key.lastIndexOf("*"))
|
|
37
|
+
return { kind: "invalid", problem: `key "${key}" may hold one "*"` }
|
|
38
|
+
if (!isSide(side))
|
|
39
|
+
return {
|
|
40
|
+
kind: "invalid",
|
|
41
|
+
problem: `"${key}" must be one of frontend, backend, shared, library`,
|
|
42
|
+
}
|
|
43
|
+
entries[key] = side
|
|
44
|
+
}
|
|
45
|
+
return Object.keys(entries).length === 0
|
|
46
|
+
? {
|
|
47
|
+
kind: "invalid",
|
|
48
|
+
problem: 'maps no entry; give each export subpath (".", "./react") a side',
|
|
49
|
+
}
|
|
50
|
+
: { kind: "entries", entries }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The mapped key that decides `subpath`: the exact key, else the `*` pattern with the longest prefix,
|
|
55
|
+
* then the longest key, where `*` matches at least one character - as Node matches `exports`.
|
|
56
|
+
*/
|
|
57
|
+
export function entryKey(
|
|
58
|
+
entries: Readonly<Record<string, PackageEnvironment>>,
|
|
59
|
+
subpath: string,
|
|
60
|
+
): string | undefined {
|
|
61
|
+
if (Object.hasOwn(entries, subpath)) return subpath
|
|
62
|
+
let best: string | undefined
|
|
63
|
+
for (const key of Object.keys(entries)) {
|
|
64
|
+
const star = key.indexOf("*")
|
|
65
|
+
if (star === -1 || subpath.length < key.length) continue
|
|
66
|
+
if (!subpath.startsWith(key.slice(0, star)) || !subpath.endsWith(key.slice(star + 1))) continue
|
|
67
|
+
const bestStar = best?.indexOf("*") ?? -1
|
|
68
|
+
if (best === undefined || star > bestStar || (star === bestStar && key.length > best.length))
|
|
69
|
+
best = key
|
|
70
|
+
}
|
|
71
|
+
return best
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface EntryOwnership {
|
|
75
|
+
/** Each file a mapped entry reaches, by real path, with the sides of every entry that reaches it. */
|
|
76
|
+
readonly owners: ReadonlyMap<string, ReadonlySet<PackageEnvironment>>
|
|
77
|
+
/** Export subpaths the map gives no side. */
|
|
78
|
+
readonly unmapped: readonly string[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isFile = (path: string): boolean =>
|
|
82
|
+
statSync(path, { throwIfNoEntry: false })?.isFile() ?? false
|
|
83
|
+
|
|
84
|
+
const real = (path: string): string => {
|
|
85
|
+
try {
|
|
86
|
+
return realpathSync.native(path).replaceAll("\\", "/")
|
|
87
|
+
} catch {
|
|
88
|
+
return path.replaceAll("\\", "/")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SCRIPT = /\.(?:[cm]?[jt]sx?|svelte|vue)$/
|
|
93
|
+
const STYLESHEET = /\.(?:css|scss|sass|less|styl)$/
|
|
94
|
+
const DECLARATION = /\.d\.[cm]?ts$/
|
|
95
|
+
const PROBE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"]
|
|
96
|
+
/** A TypeScript source imports its sibling by the extension it compiles to. */
|
|
97
|
+
const SOURCE_TWINS: Readonly<Record<string, readonly string[]>> = {
|
|
98
|
+
".js": [".ts", ".tsx"],
|
|
99
|
+
".jsx": [".tsx"],
|
|
100
|
+
".mjs": [".mts"],
|
|
101
|
+
".cjs": [".cts"],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function probe(base: string): string | undefined {
|
|
105
|
+
if (isFile(base)) return base
|
|
106
|
+
const extension = extname(base)
|
|
107
|
+
for (const twin of SOURCE_TWINS[extension] ?? []) {
|
|
108
|
+
const candidate = `${base.slice(0, -extension.length)}${twin}`
|
|
109
|
+
if (isFile(candidate)) return candidate
|
|
110
|
+
}
|
|
111
|
+
for (const candidate of PROBE_EXTENSIONS.map((ext) => `${base}${ext}`)) {
|
|
112
|
+
if (isFile(candidate)) return candidate
|
|
113
|
+
}
|
|
114
|
+
for (const candidate of PROBE_EXTENSIONS.map((ext) => join(base, `index${ext}`))) {
|
|
115
|
+
if (isFile(candidate)) return candidate
|
|
116
|
+
}
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const CLAUSE_PUNCT: ReadonlySet<string> = new Set(["{", "}", ",", "*"])
|
|
121
|
+
const isPunct = (token: Token | undefined, value: string): boolean =>
|
|
122
|
+
token?.kind === "punct" && token.value === value
|
|
123
|
+
const isId = (token: Token | undefined, value: string): boolean =>
|
|
124
|
+
token?.kind === "id" && token.value === value
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether the `import`/`export` clause ending at the `from` token `at` binds only types: `import type`,
|
|
128
|
+
* `export type`, or a `{ }` list whose every specifier is `type`. Such an edge grants no ownership - a
|
|
129
|
+
* backend file a frontend entry merely types against stays backend. `import { type A }` survives as
|
|
130
|
+
* `import {}` under Vite with `verbatimModuleSyntax`; dropping it then fails closed, since the guard
|
|
131
|
+
* judges that load by the file's remaining owners.
|
|
132
|
+
*/
|
|
133
|
+
function typeOnlyClause(tokens: readonly Token[], at: number): boolean {
|
|
134
|
+
let start = at - 1
|
|
135
|
+
for (; start >= 0; start--) {
|
|
136
|
+
const token = tokens[start]
|
|
137
|
+
if (isId(token, "import") || isId(token, "export")) break
|
|
138
|
+
if (token?.kind !== "id" && !(token?.kind === "punct" && CLAUSE_PUNCT.has(token.value)))
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
if (start < 0) return false
|
|
142
|
+
const clause = tokens.slice(start + 1, at)
|
|
143
|
+
// `import type from "./x"` and `import type, { a } from` bind a default import named `type`.
|
|
144
|
+
if (isId(clause[0], "type")) return clause.length > 1 && !isPunct(clause[1], ",")
|
|
145
|
+
if (!isPunct(clause[0], "{") || !isPunct(clause.at(-1), "}")) return false
|
|
146
|
+
const specifiers: Token[][] = [[]]
|
|
147
|
+
for (const token of clause.slice(1, -1)) {
|
|
148
|
+
if (isPunct(token, ",")) specifiers.push([])
|
|
149
|
+
else specifiers.at(-1)?.push(token)
|
|
150
|
+
}
|
|
151
|
+
const named = specifiers.filter((specifier) => specifier.length > 0)
|
|
152
|
+
// `{ type as x }` imports a value named `type`; `{ type A }` and `{ type A as B }` import types.
|
|
153
|
+
return (
|
|
154
|
+
named.length > 0 &&
|
|
155
|
+
named.every(
|
|
156
|
+
(specifier) =>
|
|
157
|
+
isId(specifier[0], "type") &&
|
|
158
|
+
(specifier.length === 2 || (specifier.length === 4 && isId(specifier[2], "as"))),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The relative specifiers a module loads: imports, re-exports, `import()`s, `require`s and
|
|
164
|
+
* `new URL()`s (a worker or an asset beside it). A clause binding only types loads nothing. */
|
|
165
|
+
function relativeImports(code: string): string[] {
|
|
166
|
+
const tokens = tokenize(code)
|
|
167
|
+
const found: string[] = []
|
|
168
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
169
|
+
const token = tokens[i]
|
|
170
|
+
if (token?.kind !== "id") continue
|
|
171
|
+
const previous = tokens[i - 1]
|
|
172
|
+
if (previous?.kind === "punct" && previous.value === ".") continue
|
|
173
|
+
let next = tokens[i + 1]
|
|
174
|
+
const call = next?.kind === "punct" && next.value === "("
|
|
175
|
+
const url = token.value === "URL" && previous?.kind === "id" && previous.value === "new"
|
|
176
|
+
if (token.value === "require" || url || (token.value === "import" && call)) {
|
|
177
|
+
if (!call) continue
|
|
178
|
+
next = tokens[i + 2]
|
|
179
|
+
} else if (token.value !== "from" && token.value !== "import") continue
|
|
180
|
+
if (next?.kind !== "string" || !/^\.\.?\//.test(next.value)) continue
|
|
181
|
+
if (token.value === "from" && typeOnlyClause(tokens, i)) continue
|
|
182
|
+
found.push(next.value)
|
|
183
|
+
}
|
|
184
|
+
return found
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** What a stylesheet pulls in beside it: `@import`s and `url()`s that name no scheme or root. */
|
|
188
|
+
function stylesheetImports(code: string): string[] {
|
|
189
|
+
const text = code.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
190
|
+
const found: string[] = []
|
|
191
|
+
for (const match of text.matchAll(
|
|
192
|
+
/@import\s+(?:url\(\s*)?["']?([^"'()\s;]+)|url\(\s*["']?([^"'()\s]+)/g,
|
|
193
|
+
)) {
|
|
194
|
+
const reference = match[1] ?? match[2] ?? ""
|
|
195
|
+
if (reference !== "" && !/^(?:[a-z][\w+.-]*:|\/|#|~)/i.test(reference)) found.push(reference)
|
|
196
|
+
}
|
|
197
|
+
return found
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Every string target of an export value, under every condition. */
|
|
201
|
+
function targetsOf(value: unknown): string[] {
|
|
202
|
+
if (typeof value === "string") return value.startsWith("./") ? [value] : []
|
|
203
|
+
if (Array.isArray(value)) return value.flatMap(targetsOf)
|
|
204
|
+
if (typeof value === "object" && value !== null) return Object.values(value).flatMap(targetsOf)
|
|
205
|
+
return []
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** `exports` as subpath entries; without it, the root is `main`/`module`/`browser` and any file a subpath. */
|
|
209
|
+
function exportEntries(
|
|
210
|
+
manifest: Readonly<Record<string, unknown>>,
|
|
211
|
+
entries: Readonly<Record<string, PackageEnvironment>>,
|
|
212
|
+
): Array<readonly [string, readonly string[]]> {
|
|
213
|
+
const exports = manifest.exports
|
|
214
|
+
if (exports === undefined || exports === null) {
|
|
215
|
+
const roots = [manifest.main, manifest.module, manifest.browser]
|
|
216
|
+
.filter((root): root is string => typeof root === "string")
|
|
217
|
+
.map((root) => (root.startsWith("./") ? root : `./${root}`))
|
|
218
|
+
return [
|
|
219
|
+
[".", roots.length > 0 ? roots : ["./index.js"]],
|
|
220
|
+
...Object.keys(entries)
|
|
221
|
+
.filter((key) => key !== ".")
|
|
222
|
+
.map((key) => [key, [key]] as const),
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
if (typeof exports !== "object" || Array.isArray(exports)) return [[".", targetsOf(exports)]]
|
|
226
|
+
const keys = Object.keys(exports)
|
|
227
|
+
if (keys.length === 0 || !keys.every((key) => key.startsWith(".")))
|
|
228
|
+
return [[".", targetsOf(exports)]]
|
|
229
|
+
return Object.entries(exports).map(([key, value]) => [key, targetsOf(value)] as const)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Every file under `dir`, outside `node_modules`. */
|
|
233
|
+
function filesUnder(dir: string): string[] {
|
|
234
|
+
let names: string[]
|
|
235
|
+
try {
|
|
236
|
+
names = readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
237
|
+
} catch {
|
|
238
|
+
return []
|
|
239
|
+
}
|
|
240
|
+
return names
|
|
241
|
+
.filter((name) => !/(?:^|[\\/])node_modules(?:[\\/]|$)/.test(name))
|
|
242
|
+
.map((name) => join(dir, name))
|
|
243
|
+
.filter(isFile)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Which mapped entries reach each file of the package at `dir` (a real path). */
|
|
247
|
+
export function entryOwnership(
|
|
248
|
+
dir: string,
|
|
249
|
+
manifest: Readonly<Record<string, unknown>>,
|
|
250
|
+
entries: Readonly<Record<string, PackageEnvironment>>,
|
|
251
|
+
): EntryOwnership {
|
|
252
|
+
const owners = new Map<string, Set<PackageEnvironment>>()
|
|
253
|
+
const unmapped = new Set<string>()
|
|
254
|
+
const queue: Array<readonly [string, PackageEnvironment]> = []
|
|
255
|
+
const reach = (path: string, side: PackageEnvironment): void => {
|
|
256
|
+
const file = real(path)
|
|
257
|
+
if (!file.startsWith(`${dir}/`) || file.slice(dir.length).includes("/node_modules/")) return
|
|
258
|
+
const sides = owners.get(file) ?? new Set<PackageEnvironment>()
|
|
259
|
+
if (sides.has(side)) return
|
|
260
|
+
sides.add(side)
|
|
261
|
+
owners.set(file, sides)
|
|
262
|
+
queue.push([file, side])
|
|
263
|
+
}
|
|
264
|
+
const sideOf = (subpath: string, exportKey = subpath): PackageEnvironment | undefined => {
|
|
265
|
+
const key = entryKey(entries, subpath)
|
|
266
|
+
if (key === undefined) unmapped.add(exportKey)
|
|
267
|
+
return key === undefined ? undefined : entries[key]
|
|
268
|
+
}
|
|
269
|
+
for (const [key, targets] of exportEntries(manifest, entries)) {
|
|
270
|
+
const star = key.indexOf("*")
|
|
271
|
+
if (star === -1) {
|
|
272
|
+
const side = sideOf(key)
|
|
273
|
+
if (side === undefined) continue
|
|
274
|
+
for (const target of targets) {
|
|
275
|
+
const file = target.includes("*") ? undefined : probe(join(dir, target))
|
|
276
|
+
if (file !== undefined) reach(file, side)
|
|
277
|
+
}
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
// A pattern entry: each file a target pattern matches is the subpath its `*` spells.
|
|
281
|
+
for (const target of targets) {
|
|
282
|
+
const at = target.indexOf("*")
|
|
283
|
+
if (at === -1) continue
|
|
284
|
+
const prefix = target.slice(0, at)
|
|
285
|
+
const suffix = target.slice(at + 1)
|
|
286
|
+
for (const file of filesUnder(join(dir, prefix.slice(0, prefix.lastIndexOf("/") + 1)))) {
|
|
287
|
+
const path = `./${relative(dir, file).replaceAll("\\", "/")}`
|
|
288
|
+
if (path.length <= prefix.length + suffix.length) continue
|
|
289
|
+
if (!path.startsWith(prefix) || !path.endsWith(suffix)) continue
|
|
290
|
+
const matched = path.slice(prefix.length, path.length - suffix.length)
|
|
291
|
+
const side = sideOf(`${key.slice(0, star)}${matched}${key.slice(star + 1)}`, key)
|
|
292
|
+
if (side !== undefined) reach(file, side)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const imports = new Map<string, readonly string[]>()
|
|
297
|
+
for (const [file, side] of queue) {
|
|
298
|
+
const script = SCRIPT.test(file) && !DECLARATION.test(file)
|
|
299
|
+
if (!script && !STYLESHEET.test(file)) continue
|
|
300
|
+
let specifiers = imports.get(file)
|
|
301
|
+
if (specifiers === undefined) {
|
|
302
|
+
try {
|
|
303
|
+
const code = readFileSync(file, "utf8")
|
|
304
|
+
specifiers = script ? relativeImports(code) : stylesheetImports(code)
|
|
305
|
+
} catch {
|
|
306
|
+
specifiers = []
|
|
307
|
+
}
|
|
308
|
+
imports.set(file, specifiers)
|
|
309
|
+
}
|
|
310
|
+
for (const specifier of specifiers) {
|
|
311
|
+
const found = probe(join(dirname(file), withoutQuery(specifier)))
|
|
312
|
+
if (found !== undefined) reach(found, side)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { owners, unmapped: [...unmapped].sort() }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The `package.json` of the installed package `name`, as Node finds it: the nearest
|
|
320
|
+
* `node_modules/<name>` at or above `fromDir`.
|
|
321
|
+
*/
|
|
322
|
+
export function installedManifest(fromDir: string, name: string): string | undefined {
|
|
323
|
+
let dir = fromDir
|
|
324
|
+
for (;;) {
|
|
325
|
+
const manifest = join(dir, "node_modules", name, "package.json")
|
|
326
|
+
if (isFile(manifest)) return manifest
|
|
327
|
+
const parent = dirname(dir)
|
|
328
|
+
if (parent === dir) return undefined
|
|
329
|
+
dir = parent
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -3,7 +3,12 @@ import {
|
|
|
3
3
|
NIFRA_BACKEND_BIND_PLATFORM,
|
|
4
4
|
NIFRA_PLATFORM_CLIENT_IP_DERIVED,
|
|
5
5
|
} from "@nifrajs/core/mount"
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
isSameOriginForm,
|
|
8
|
+
type Platform,
|
|
9
|
+
type ResponseResult,
|
|
10
|
+
status as statusResult,
|
|
11
|
+
} from "@nifrajs/core/server"
|
|
7
12
|
import {
|
|
8
13
|
type BoundaryRequestCtx,
|
|
9
14
|
type BoundaryStates,
|
|
@@ -167,9 +172,11 @@ function appTarget(location: string, requestUrl: string): string {
|
|
|
167
172
|
} catch {
|
|
168
173
|
return location // the browser fails on it the same way when it loads it
|
|
169
174
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
175
|
+
if (target.origin !== new URL(requestUrl).origin) return location
|
|
176
|
+
// A path starting `//` (from `/.//host`) is a protocol-relative URL to whoever resolves it next.
|
|
177
|
+
return target.pathname.startsWith("//")
|
|
178
|
+
? target.href
|
|
179
|
+
: target.pathname + target.search + target.hash
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
export interface PageExecutionOptions<Env = unknown> {
|
|
@@ -415,6 +422,25 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
415
422
|
return value
|
|
416
423
|
}
|
|
417
424
|
|
|
425
|
+
// Development only. A layout loader that refuses access reads as a guard, and is not one: a client
|
|
426
|
+
// navigation can skip it and an action below it runs without it.
|
|
427
|
+
const warnsUngatedRefusal =
|
|
428
|
+
typeof process !== "undefined" && process.env?.NODE_ENV === "development"
|
|
429
|
+
const warnedUngated = new Set<string>()
|
|
430
|
+
const warnUngatedRefusal = (layoutId: string, outcome: unknown): void => {
|
|
431
|
+
if (warnedUngated.has(layoutId)) return
|
|
432
|
+
// notFound() and gone() say the record is missing, which a layout may decide without guarding.
|
|
433
|
+
const refusesAccess = isStatusSignal(outcome)
|
|
434
|
+
? outcome.status === 401 || outcome.status === 403
|
|
435
|
+
: isControlFlow(outcome)
|
|
436
|
+
if (!refusesAccess) return
|
|
437
|
+
warnedUngated.add(layoutId)
|
|
438
|
+
const file = manifest.layouts?.[layoutId]?.file ?? layoutId
|
|
439
|
+
console.warn(
|
|
440
|
+
`[nifra/web] the loader in "${file}" refused a request, but that layout is not a gate. A client navigation that keeps the layout on screen can skip its loader, and an action below it runs without it, so it does not protect the pages under it. Export \`gate = true\` from that file, or move the check into middleware. (Shown in development only.)`,
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
418
444
|
/**
|
|
419
445
|
* Run layout loaders, awaiting gates before starting the page loader and retaining only safe data.
|
|
420
446
|
* `gated` carries gates that already ran for this request, so none runs twice.
|
|
@@ -469,15 +495,25 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
469
495
|
if (value !== null && (typeof value === "object" || typeof value === "function")) {
|
|
470
496
|
pending.push(
|
|
471
497
|
(async () => {
|
|
498
|
+
let settled: unknown
|
|
472
499
|
try {
|
|
473
|
-
|
|
500
|
+
settled = await value
|
|
474
501
|
} catch (err) {
|
|
502
|
+
if (warnsUngatedRefusal) warnUngatedRefusal(layoutId, err)
|
|
475
503
|
throw tagLayoutError(err, layoutId)
|
|
476
504
|
}
|
|
505
|
+
// A returned redirect or Response refuses exactly as a thrown one: kept as layout data,
|
|
506
|
+
// it would ship beside the page loader's data.
|
|
507
|
+
if (isControlFlow(settled)) {
|
|
508
|
+
if (warnsUngatedRefusal) warnUngatedRefusal(layoutId, settled)
|
|
509
|
+
throw tagLayoutError(settled, layoutId)
|
|
510
|
+
}
|
|
511
|
+
results[index] = settled
|
|
477
512
|
})(),
|
|
478
513
|
)
|
|
479
514
|
} else results[index] = value
|
|
480
515
|
} catch (err) {
|
|
516
|
+
if (warnsUngatedRefusal) warnUngatedRefusal(layoutId, err)
|
|
481
517
|
// Preserve a synchronous throw as a pending layout failure, just like the async lane.
|
|
482
518
|
pending.push(Promise.reject(tagLayoutError(err, layoutId)))
|
|
483
519
|
}
|
|
@@ -1493,6 +1529,32 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1493
1529
|
headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
|
|
1494
1530
|
})
|
|
1495
1531
|
}
|
|
1532
|
+
const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
|
|
1533
|
+
// Before anything runs: a form posts cross-site without a preflight and carries the visitor's
|
|
1534
|
+
// cookies, so an action is a CSRF target unless its route names the origins it accepts.
|
|
1535
|
+
const origin = c.req.headers.get("origin")
|
|
1536
|
+
const allowedOrigins = mod.allowedOrigins
|
|
1537
|
+
const allowed =
|
|
1538
|
+
allowedOrigins === undefined
|
|
1539
|
+
? origin === null ||
|
|
1540
|
+
isSameOriginForm(origin, c.req.headers.get("sec-fetch-site"), c.req)
|
|
1541
|
+
: typeof allowedOrigins === "function"
|
|
1542
|
+
? allowedOrigins(origin) === true
|
|
1543
|
+
: origin !== null && allowedOrigins.includes(origin)
|
|
1544
|
+
if (!allowed) {
|
|
1545
|
+
return isDataRequest
|
|
1546
|
+
? Response.json(
|
|
1547
|
+
{ ok: false, error: "forbidden_origin" },
|
|
1548
|
+
{ status: 403, headers: { "cache-control": PRIVATE_NO_STORE } },
|
|
1549
|
+
)
|
|
1550
|
+
: new Response("Forbidden", {
|
|
1551
|
+
status: 403,
|
|
1552
|
+
headers: {
|
|
1553
|
+
"content-type": "text/plain; charset=utf-8",
|
|
1554
|
+
"cache-control": PRIVATE_NO_STORE,
|
|
1555
|
+
},
|
|
1556
|
+
})
|
|
1557
|
+
}
|
|
1496
1558
|
const requestApi = apiFor(c)
|
|
1497
1559
|
const actionContext: LoaderContext = {
|
|
1498
1560
|
params: c.params,
|
|
@@ -1504,7 +1566,6 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1504
1566
|
search: loaderSearch(mod.searchSchema, c.req),
|
|
1505
1567
|
set: controls.scope(ACTION_SCOPE),
|
|
1506
1568
|
}
|
|
1507
|
-
const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
|
|
1508
1569
|
let gated: GatedLayouts
|
|
1509
1570
|
let result: unknown
|
|
1510
1571
|
try {
|
package/src/internal/parity.ts
CHANGED
|
@@ -586,6 +586,17 @@ const linkedRepoBoundary = async (packageRoot: string): Promise<string> => {
|
|
|
586
586
|
return packageRoot
|
|
587
587
|
}
|
|
588
588
|
|
|
589
|
+
/** Where a real package folder lives when only its files are links: Bun installs a `file:` folder
|
|
590
|
+
* dependency as a directory of per-file symlinks, so its imports resolve beside the linked files. */
|
|
591
|
+
const fileLinkedDir = async (dir: string): Promise<string | undefined> => {
|
|
592
|
+
const manifest = join(dir, "package.json")
|
|
593
|
+
try {
|
|
594
|
+
return (await lstat(manifest)).isSymbolicLink() ? dirname(await realpath(manifest)) : undefined
|
|
595
|
+
} catch {
|
|
596
|
+
return undefined
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
589
600
|
const linkedPackageRoots = async (
|
|
590
601
|
importerRoots: readonly string[],
|
|
591
602
|
manifests: readonly Record<string, unknown>[],
|
|
@@ -605,8 +616,10 @@ const linkedPackageRoots = async (
|
|
|
605
616
|
}
|
|
606
617
|
const candidate = join(importerRoot, "node_modules", ...name.split("/"))
|
|
607
618
|
const link = await lstat(candidate).catch(() => undefined)
|
|
608
|
-
if (link === undefined
|
|
609
|
-
const resolved =
|
|
619
|
+
if (link === undefined) continue
|
|
620
|
+
const resolved = link.isSymbolicLink()
|
|
621
|
+
? await realpath(candidate).catch(() => undefined)
|
|
622
|
+
: await fileLinkedDir(candidate)
|
|
610
623
|
if (resolved === undefined || pathInside(realScanRoot, resolved)) continue
|
|
611
624
|
found.set(resolved, true)
|
|
612
625
|
}
|
|
@@ -32,12 +32,12 @@ const BEFORE_EXPRESSION = new Set([
|
|
|
32
32
|
"instanceof",
|
|
33
33
|
])
|
|
34
34
|
|
|
35
|
-
type Token =
|
|
35
|
+
export type Token =
|
|
36
36
|
| { readonly kind: "id" | "punct" | "string"; readonly value: string }
|
|
37
37
|
| { readonly kind: "other" }
|
|
38
38
|
|
|
39
39
|
/** JavaScript tokens, with strings, template text, comments and regular expressions skipped. */
|
|
40
|
-
function tokenize(code: string): Token[] {
|
|
40
|
+
export function tokenize(code: string): Token[] {
|
|
41
41
|
const tokens: Token[] = []
|
|
42
42
|
// One entry per open `${`: how many `{` are open inside it.
|
|
43
43
|
const templates: number[] = []
|
|
@@ -152,24 +152,37 @@ function envReads(tokens: readonly Token[]): EnvRead[] {
|
|
|
152
152
|
const token = tokens[at]
|
|
153
153
|
return token?.kind === "id" ? token.value : undefined
|
|
154
154
|
}
|
|
155
|
-
|
|
155
|
+
// The tokenizer reads `?.` as `.`, so `?.[` and `?.(` arrive as `.` before the bracket.
|
|
156
|
+
const open = (at: number, bracket: "[" | "("): number =>
|
|
157
|
+
is(at, "punct", ".") && is(at + 1, "punct", bracket) ? at + 1 : at
|
|
158
|
+
/** The string key of `["X"]` (or `?.["X"]`) at `at`, and the index after it. */
|
|
159
|
+
const keyAt = (at: number): { readonly key: string; readonly next: number } | undefined => {
|
|
160
|
+
const bracket = open(at, "[")
|
|
161
|
+
const key =
|
|
162
|
+
is(bracket, "punct", "[") && is(bracket + 2, "punct", "]") ? stringAt(bracket + 1) : undefined
|
|
163
|
+
return key === undefined ? undefined : { key, next: bracket + 3 }
|
|
164
|
+
}
|
|
165
|
+
/** A property read after an env object that ends at `at`: `.X`, `["X"]` or `?.["X"]`. */
|
|
156
166
|
const member = (base: string, at: number, importMeta: boolean): EnvRead => {
|
|
157
167
|
const property = is(at, "punct", ".") ? idAt(at + 1) : undefined
|
|
158
168
|
if (property !== undefined)
|
|
159
169
|
return { expression: `${base}.${property}`, name: property, importMeta }
|
|
160
|
-
const key =
|
|
170
|
+
const key = keyAt(at)?.key
|
|
161
171
|
if (key !== undefined)
|
|
162
172
|
return { expression: `${base}[${JSON.stringify(key)}]`, name: key, importMeta }
|
|
163
173
|
return { expression: base, importMeta }
|
|
164
174
|
}
|
|
175
|
+
/** The index after `.env`, `["env"]` or `?.["env"]` at `at`, or `-1`. */
|
|
176
|
+
const envAt = (at: number): number => {
|
|
177
|
+
if (is(at, "punct", ".") && is(at + 1, "id", "env")) return at + 2
|
|
178
|
+
const bracket = keyAt(at)
|
|
179
|
+
return bracket?.key === "env" ? bracket.next : -1
|
|
180
|
+
}
|
|
165
181
|
for (let i = 0; i < tokens.length; i++) {
|
|
166
182
|
const head = idAt(i)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
is(i + 2, "id", "env")
|
|
171
|
-
) {
|
|
172
|
-
reads.push(member(`${head}.env`, i + 3, false))
|
|
183
|
+
const env = head === "process" || head === "Bun" ? envAt(i + 1) : -1
|
|
184
|
+
if (env !== -1) {
|
|
185
|
+
reads.push(member(`${head}.env`, env, false))
|
|
173
186
|
} else if (
|
|
174
187
|
head === "import" &&
|
|
175
188
|
is(i + 1, "punct", ".") &&
|
|
@@ -180,12 +193,13 @@ function envReads(tokens: readonly Token[]): EnvRead[] {
|
|
|
180
193
|
reads.push(member("import.meta.env", i + 5, true))
|
|
181
194
|
} else if (head === "Deno" && is(i + 1, "punct", ".") && is(i + 2, "id", "env")) {
|
|
182
195
|
const method = is(i + 3, "punct", ".") ? idAt(i + 4) : undefined
|
|
196
|
+
const call = open(i + 5, "(")
|
|
183
197
|
const name =
|
|
184
|
-
(method === "get" || method === "has") && is(
|
|
185
|
-
? stringAt(
|
|
198
|
+
(method === "get" || method === "has") && is(call, "punct", "(")
|
|
199
|
+
? stringAt(call + 1)
|
|
186
200
|
: undefined
|
|
187
201
|
reads.push(
|
|
188
|
-
name !== undefined && is(
|
|
202
|
+
name !== undefined && is(call + 2, "punct", ")")
|
|
189
203
|
? { expression: `Deno.env.${method}(${JSON.stringify(name)})`, name, importMeta: false }
|
|
190
204
|
: {
|
|
191
205
|
expression: method === undefined ? "Deno.env" : `Deno.env.${method}()`,
|