@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
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 { type Platform, type ResponseResult, status as statusResult } from "@nifrajs/core/server"
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
- return target.origin === new URL(requestUrl).origin
171
- ? target.pathname + target.search + target.hash
172
- : location
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
- results[index] = await value
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 {
@@ -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 || !link.isSymbolicLink()) continue
609
- const resolved = await realpath(candidate).catch(() => undefined)
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
- /** A property read after an env object that ends at `at`: `.X` or `["X"]`. */
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 = is(at, "punct", "[") && is(at + 2, "punct", "]") ? stringAt(at + 1) : undefined
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
- if (
168
- (head === "process" || head === "Bun") &&
169
- is(i + 1, "punct", ".") &&
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(i + 5, "punct", "(")
185
- ? stringAt(i + 6)
198
+ (method === "get" || method === "has") && is(call, "punct", "(")
199
+ ? stringAt(call + 1)
186
200
  : undefined
187
201
  reads.push(
188
- name !== undefined && is(i + 7, "punct", ")")
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}()`,