@nifrajs/web 5.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/CHANGELOG.md +2970 -0
  2. package/README.md +8 -0
  3. package/dist/build-plan.d.ts +7 -0
  4. package/dist/build-plan.d.ts.map +1 -1
  5. package/dist/build-plan.js +19 -0
  6. package/dist/build-plan.js.map +1 -1
  7. package/dist/build-vite.d.ts +1 -0
  8. package/dist/build-vite.d.ts.map +1 -1
  9. package/dist/build-vite.js +51 -3
  10. package/dist/build-vite.js.map +1 -1
  11. package/dist/build.d.ts +12 -1
  12. package/dist/build.d.ts.map +1 -1
  13. package/dist/build.js +43 -6
  14. package/dist/build.js.map +1 -1
  15. package/dist/client.d.ts +7 -6
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +39 -22
  18. package/dist/client.js.map +1 -1
  19. package/dist/dev-port.d.ts +3 -2
  20. package/dist/dev-port.d.ts.map +1 -1
  21. package/dist/dev-port.js +2 -6
  22. package/dist/dev-port.js.map +1 -1
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/codegen.d.ts +6 -0
  28. package/dist/internal/codegen.d.ts.map +1 -1
  29. package/dist/internal/codegen.js +23 -5
  30. package/dist/internal/codegen.js.map +1 -1
  31. package/dist/internal/copy-split.d.ts +27 -0
  32. package/dist/internal/copy-split.d.ts.map +1 -0
  33. package/dist/internal/copy-split.js +173 -0
  34. package/dist/internal/copy-split.js.map +1 -0
  35. package/dist/internal/package-entries.d.ts +34 -0
  36. package/dist/internal/package-entries.d.ts.map +1 -0
  37. package/dist/internal/package-entries.js +324 -0
  38. package/dist/internal/package-entries.js.map +1 -0
  39. package/dist/internal/page-execution.d.ts +19 -1
  40. package/dist/internal/page-execution.d.ts.map +1 -1
  41. package/dist/internal/page-execution.js +123 -14
  42. package/dist/internal/page-execution.js.map +1 -1
  43. package/dist/internal/parity.d.ts.map +1 -1
  44. package/dist/internal/parity.js +15 -2
  45. package/dist/internal/parity.js.map +1 -1
  46. package/dist/internal/private-env.d.ts +8 -0
  47. package/dist/internal/private-env.d.ts.map +1 -1
  48. package/dist/internal/private-env.js +25 -10
  49. package/dist/internal/private-env.js.map +1 -1
  50. package/dist/internal/render-document.d.ts +15 -46
  51. package/dist/internal/render-document.d.ts.map +1 -1
  52. package/dist/internal/render-document.js +59 -62
  53. package/dist/internal/render-document.js.map +1 -1
  54. package/dist/internal/route-path.d.ts +6 -0
  55. package/dist/internal/route-path.d.ts.map +1 -1
  56. package/dist/internal/route-path.js +11 -0
  57. package/dist/internal/route-path.js.map +1 -1
  58. package/dist/internal/secret-scan.d.ts.map +1 -1
  59. package/dist/internal/secret-scan.js +103 -1
  60. package/dist/internal/secret-scan.js.map +1 -1
  61. package/dist/internal/typescript-api.d.ts +4 -0
  62. package/dist/internal/typescript-api.d.ts.map +1 -0
  63. package/dist/internal/typescript-api.js +8 -0
  64. package/dist/internal/typescript-api.js.map +1 -0
  65. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  66. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  67. package/dist/internal/vite-plugin-compat.js +82 -0
  68. package/dist/internal/vite-plugin-compat.js.map +1 -0
  69. package/dist/internal/web-app.d.ts +38 -2
  70. package/dist/internal/web-app.d.ts.map +1 -1
  71. package/dist/internal/web-app.js +30 -3
  72. package/dist/internal/web-app.js.map +1 -1
  73. package/dist/internal/without-query.d.ts +4 -0
  74. package/dist/internal/without-query.d.ts.map +1 -0
  75. package/dist/internal/without-query.js +9 -0
  76. package/dist/internal/without-query.js.map +1 -0
  77. package/dist/internal/zone-graph.d.ts.map +1 -1
  78. package/dist/internal/zone-graph.js +17 -6
  79. package/dist/internal/zone-graph.js.map +1 -1
  80. package/dist/isr.d.ts +1 -1
  81. package/dist/isr.d.ts.map +1 -1
  82. package/dist/isr.js +15 -2
  83. package/dist/isr.js.map +1 -1
  84. package/dist/manifest.d.ts +9 -0
  85. package/dist/manifest.d.ts.map +1 -1
  86. package/dist/manifest.js +1 -0
  87. package/dist/manifest.js.map +1 -1
  88. package/dist/plugins/import-glob.d.ts +20 -0
  89. package/dist/plugins/import-glob.d.ts.map +1 -0
  90. package/dist/plugins/import-glob.js +401 -0
  91. package/dist/plugins/import-glob.js.map +1 -0
  92. package/dist/plugins/kit.d.ts +11 -0
  93. package/dist/plugins/kit.d.ts.map +1 -1
  94. package/dist/plugins/kit.js +32 -0
  95. package/dist/plugins/kit.js.map +1 -1
  96. package/dist/plugins/stylex.d.ts.map +1 -1
  97. package/dist/plugins/stylex.js +2 -2
  98. package/dist/plugins/stylex.js.map +1 -1
  99. package/dist/plugins/vite-leak-guard.d.ts +6 -0
  100. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  101. package/dist/plugins/vite-leak-guard.js +80 -7
  102. package/dist/plugins/vite-leak-guard.js.map +1 -1
  103. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  104. package/dist/plugins/vite-zone-guard.js +17 -21
  105. package/dist/plugins/vite-zone-guard.js.map +1 -1
  106. package/dist/plugins/zone-guard.d.ts.map +1 -1
  107. package/dist/plugins/zone-guard.js +44 -3
  108. package/dist/plugins/zone-guard.js.map +1 -1
  109. package/dist/prerender.d.ts +5 -1
  110. package/dist/prerender.d.ts.map +1 -1
  111. package/dist/prerender.js +41 -19
  112. package/dist/prerender.js.map +1 -1
  113. package/dist/render-seam.d.ts +60 -2
  114. package/dist/render-seam.d.ts.map +1 -1
  115. package/dist/render-seam.js +33 -0
  116. package/dist/render-seam.js.map +1 -1
  117. package/dist/router.d.ts +3 -2
  118. package/dist/router.d.ts.map +1 -1
  119. package/dist/router.js +13 -9
  120. package/dist/router.js.map +1 -1
  121. package/dist/seo.d.ts +49 -0
  122. package/dist/seo.d.ts.map +1 -0
  123. package/dist/seo.js +48 -0
  124. package/dist/seo.js.map +1 -0
  125. package/dist/server-components/typescript.d.ts.map +1 -1
  126. package/dist/server-components/typescript.js +2 -7
  127. package/dist/server-components/typescript.js.map +1 -1
  128. package/dist/vite.d.ts +1 -28
  129. package/dist/vite.d.ts.map +1 -1
  130. package/dist/vite.js +3 -69
  131. package/dist/vite.js.map +1 -1
  132. package/dist/zones.d.ts +30 -8
  133. package/dist/zones.d.ts.map +1 -1
  134. package/dist/zones.js +185 -46
  135. package/dist/zones.js.map +1 -1
  136. package/package.json +10 -4
  137. package/src/build-plan.ts +21 -0
  138. package/src/build-vite.ts +60 -3
  139. package/src/build.ts +57 -4
  140. package/src/client.ts +42 -22
  141. package/src/dev-port.ts +4 -10
  142. package/src/index.ts +8 -4
  143. package/src/internal/codegen.ts +32 -3
  144. package/src/internal/copy-split.ts +220 -0
  145. package/src/internal/package-entries.ts +331 -0
  146. package/src/internal/page-execution.ts +150 -13
  147. package/src/internal/parity.ts +15 -2
  148. package/src/internal/private-env.ts +27 -13
  149. package/src/internal/render-document.ts +100 -81
  150. package/src/internal/route-path.ts +16 -0
  151. package/src/internal/secret-scan.ts +101 -1
  152. package/src/internal/typescript-api.ts +11 -0
  153. package/src/internal/vite-plugin-compat.ts +81 -0
  154. package/src/internal/web-app.ts +71 -6
  155. package/src/internal/without-query.ts +8 -0
  156. package/src/internal/zone-graph.ts +19 -6
  157. package/src/isr.ts +13 -2
  158. package/src/manifest.ts +10 -0
  159. package/src/plugins/import-glob.ts +392 -0
  160. package/src/plugins/kit.ts +36 -0
  161. package/src/plugins/stylex.ts +2 -1
  162. package/src/plugins/vite-leak-guard.ts +99 -7
  163. package/src/plugins/vite-zone-guard.ts +21 -19
  164. package/src/plugins/zone-guard.ts +41 -2
  165. package/src/prerender.ts +39 -18
  166. package/src/render-seam.ts +88 -2
  167. package/src/router.ts +16 -11
  168. package/src/seo.ts +68 -0
  169. package/src/server-components/typescript.ts +2 -10
  170. package/src/vite.ts +3 -81
  171. package/src/zones.ts +237 -52
@@ -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,
@@ -29,7 +34,7 @@ import type {
29
34
  ShouldRevalidate,
30
35
  } from "../manifest.ts"
31
36
  import type { NonceResolver } from "../nonce.ts"
32
- import type { MatchChain, RenderAdapter } from "../render-seam.ts"
37
+ import type { MatchChain, PageLocale, PageLocales, RenderAdapter } from "../render-seam.ts"
33
38
  import {
34
39
  createMatcher,
35
40
  DATA_HEADER,
@@ -49,9 +54,11 @@ import {
49
54
  isStatusSignal,
50
55
  type LoadedLayoutModules,
51
56
  layoutErrorId,
57
+ notFound,
52
58
  type RenderAssemblyCache,
53
59
  type RenderedPage,
54
60
  type RevalidateResult,
61
+ redirect,
55
62
  renderPageResult,
56
63
  STATUS_SIGNAL,
57
64
  STATUS_TEXT,
@@ -167,11 +174,31 @@ function appTarget(location: string, requestUrl: string): string {
167
174
  } catch {
168
175
  return location // the browser fails on it the same way when it loads it
169
176
  }
170
- return target.origin === new URL(requestUrl).origin
171
- ? target.pathname + target.search + target.hash
172
- : location
177
+ if (target.origin !== new URL(requestUrl).origin) return location
178
+ // A path starting `//` (from `/.//host`) is a protocol-relative URL to whoever resolves it next.
179
+ return target.pathname.startsWith("//")
180
+ ? target.href
181
+ : target.pathname + target.search + target.hash
182
+ }
183
+
184
+ /** The app's locale routing, as page execution reads it. */
185
+ export interface PageI18n {
186
+ readonly locales: PageLocales
187
+ /** The served locale key a `pathname + search` is in. */
188
+ localeOf(path: string): string
189
+ /** The answer for a `[lang]` / `[[lang]]` segment's value (`undefined` when an optional one is
190
+ * absent), as `@nifrajs/i18n/routing`'s `matchSegment` gives it. */
191
+ matchSegment(
192
+ value: string | undefined,
193
+ pathname: string,
194
+ ):
195
+ | { readonly kind: "ok" | "not-found" }
196
+ | { readonly kind: "redirect"; readonly location: string }
173
197
  }
174
198
 
199
+ // The route directories whose first segment names the locale.
200
+ const LOCALE_SEGMENTS: ReadonlySet<string> = new Set(["[lang]", "[[lang]]"])
201
+
175
202
  export interface PageExecutionOptions<Env = unknown> {
176
203
  readonly serverComponents?: import("../server-components/types.ts").ServerComponentRuntime
177
204
  readonly rerenderOwnerGET?: (
@@ -191,6 +218,10 @@ export interface PageExecutionOptions<Env = unknown> {
191
218
  readonly prerenderedPaths?: readonly string[]
192
219
  /** Where pages fetch {@link prerenderedPaths} from when the list is too large to inline. */
193
220
  readonly prerenderedListUrl?: string
221
+ /** Locale key -> copy chunk URL, from a build that split copy tables by locale. */
222
+ readonly copyChunks?: Readonly<Record<string, string>>
223
+ /** The app's locale routing: each document's locale, and the check on a `[lang]` segment. */
224
+ readonly i18n?: PageI18n
194
225
  readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
195
226
  readonly staticBoundaryCache?: StaticBoundaryCache
196
227
  /** Per-request CSP nonce for framework-owned executable document scripts. */
@@ -294,6 +325,23 @@ export function createPageRequestExecutor<Env = unknown>(
294
325
  options.prerenderedListUrl === undefined
295
326
  ? { prerenderedPaths: options.prerenderedPaths ?? [] }
296
327
  : { prerenderedListUrl: options.prerenderedListUrl }
328
+ const copyOption = options.copyChunks === undefined ? {} : { copyChunks: options.copyChunks }
329
+ const { i18n } = options
330
+ const pageLocales = new Map(i18n?.locales.locales.map((locale) => [locale.key, locale]))
331
+ /** The request's locale for a document, with the served locales the client names locales from. */
332
+ const localeOption = (req: Request): { locale?: PageLocale; locales?: PageLocales } => {
333
+ if (i18n === undefined) return {}
334
+ const locale = pageLocales.get(i18n.localeOf(pathOf(req)))
335
+ return locale === undefined ? { locales: i18n.locales } : { locale, locales: i18n.locales }
336
+ }
337
+ /** Whether a route sits under a `[lang]` or `[[lang]]` directory, which the locale routing checks. */
338
+ const localeGuarded = (route: RouteEntry): boolean => {
339
+ if (i18n === undefined) return false
340
+ for (const segment of route.id.split("/")) {
341
+ if (!(segment.startsWith("(") && segment.endsWith(")"))) return LOCALE_SEGMENTS.has(segment)
342
+ }
343
+ return false
344
+ }
297
345
  const matchManifestRoute = createMatcher(
298
346
  manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })),
299
347
  )
@@ -415,6 +463,25 @@ export function createPageRequestExecutor<Env = unknown>(
415
463
  return value
416
464
  }
417
465
 
466
+ // Development only. A layout loader that refuses access reads as a guard, and is not one: a client
467
+ // navigation can skip it and an action below it runs without it.
468
+ const warnsUngatedRefusal =
469
+ typeof process !== "undefined" && process.env?.NODE_ENV === "development"
470
+ const warnedUngated = new Set<string>()
471
+ const warnUngatedRefusal = (layoutId: string, outcome: unknown): void => {
472
+ if (warnedUngated.has(layoutId)) return
473
+ // notFound() and gone() say the record is missing, which a layout may decide without guarding.
474
+ const refusesAccess = isStatusSignal(outcome)
475
+ ? outcome.status === 401 || outcome.status === 403
476
+ : isControlFlow(outcome)
477
+ if (!refusesAccess) return
478
+ warnedUngated.add(layoutId)
479
+ const file = manifest.layouts?.[layoutId]?.file ?? layoutId
480
+ console.warn(
481
+ `[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.)`,
482
+ )
483
+ }
484
+
418
485
  /**
419
486
  * Run layout loaders, awaiting gates before starting the page loader and retaining only safe data.
420
487
  * `gated` carries gates that already ran for this request, so none runs twice.
@@ -469,15 +536,25 @@ export function createPageRequestExecutor<Env = unknown>(
469
536
  if (value !== null && (typeof value === "object" || typeof value === "function")) {
470
537
  pending.push(
471
538
  (async () => {
539
+ let settled: unknown
472
540
  try {
473
- results[index] = await value
541
+ settled = await value
474
542
  } catch (err) {
543
+ if (warnsUngatedRefusal) warnUngatedRefusal(layoutId, err)
475
544
  throw tagLayoutError(err, layoutId)
476
545
  }
546
+ // A returned redirect or Response refuses exactly as a thrown one: kept as layout data,
547
+ // it would ship beside the page loader's data.
548
+ if (isControlFlow(settled)) {
549
+ if (warnsUngatedRefusal) warnUngatedRefusal(layoutId, settled)
550
+ throw tagLayoutError(settled, layoutId)
551
+ }
552
+ results[index] = settled
477
553
  })(),
478
554
  )
479
555
  } else results[index] = value
480
556
  } catch (err) {
557
+ if (warnsUngatedRefusal) warnUngatedRefusal(layoutId, err)
481
558
  // Preserve a synchronous throw as a pending layout failure, just like the async lane.
482
559
  pending.push(Promise.reject(tagLayoutError(err, layoutId)))
483
560
  }
@@ -517,6 +594,21 @@ export function createPageRequestExecutor<Env = unknown>(
517
594
  ctx: LoaderContext,
518
595
  controls: PageResponseControls,
519
596
  ): Promise<void> => {
597
+ if (i18n !== undefined && localeGuarded(route)) {
598
+ // Before any app middleware, as a root `_middleware` would run: an unserved locale, or a page
599
+ // missing from it, never reaches the app's code.
600
+ const match = i18n.matchSegment(ctx.params.lang, pathOf(ctx.request))
601
+ if (match.kind === "redirect") {
602
+ throw tagLayoutError(redirect(match.location, { status: 308 }), "_layout")
603
+ }
604
+ if (match.kind === "not-found") {
605
+ try {
606
+ notFound()
607
+ } catch (signal) {
608
+ throw tagLayoutError(signal, "_layout")
609
+ }
610
+ }
611
+ }
520
612
  const ids = route.middlewareIds ?? []
521
613
  for (let i = 0; i < ids.length; i++) {
522
614
  const id = ids[i] as string
@@ -740,6 +832,7 @@ export function createPageRequestExecutor<Env = unknown>(
740
832
  chain: components,
741
833
  data,
742
834
  ...(wrapped === undefined ? {} : { head: wrapped.head }),
835
+ ...localeOption(req),
743
836
  clientEntry,
744
837
  routeId: errorId,
745
838
  status: 500,
@@ -903,6 +996,8 @@ export function createPageRequestExecutor<Env = unknown>(
903
996
  // The build keys stylesheets by route, so a page inside the root layout links the whole-app one.
904
997
  ...(chain === undefined ? stylesOf(routeId) : stylesFrom(options.styles)),
905
998
  ...prerenderedHandover,
999
+ ...copyOption,
1000
+ ...localeOption(req),
906
1001
  ...titleOption,
907
1002
  }
908
1003
  if (chain === undefined) return renderPageResult(statusPage)
@@ -1051,6 +1146,8 @@ export function createPageRequestExecutor<Env = unknown>(
1051
1146
  ...preloadOf(route.id),
1052
1147
  ...stylesOf(route.id),
1053
1148
  ...prerenderedHandover,
1149
+ ...copyOption,
1150
+ ...localeOption(req),
1054
1151
  ...(responseHeaders === undefined ? {} : { headers: responseHeaders }),
1055
1152
  // A response that sets a cookie is one visitor's: it never advertises ISR freshness.
1056
1153
  ...(mod.revalidate !== undefined && !personalized && options.cacheChannel?.enabled === true
@@ -1177,6 +1274,7 @@ export function createPageRequestExecutor<Env = unknown>(
1177
1274
  matchChain: matchChainOf(page.layoutIds, layoutModules, id, mod),
1178
1275
  status: 404,
1179
1276
  hydrate: false,
1277
+ ...localeOption(req),
1180
1278
  ...(layoutData !== undefined ? { layoutData } : {}),
1181
1279
  ...stylesOf(id),
1182
1280
  ...(headers === undefined ? {} : { headers }),
@@ -1268,12 +1366,14 @@ export function createPageRequestExecutor<Env = unknown>(
1268
1366
  return {
1269
1367
  get: (route) => {
1270
1368
  const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
1271
- let assemblySlot: RenderAssemblyCache | undefined
1369
+ // One shell per locale: `<html lang>` is in it, and one route serves every locale.
1370
+ let assemblySlots = new Map<string, RenderAssemblyCache>()
1272
1371
  let assemblySlotMod: unknown
1273
1372
  let assemblySlotLayouts: LoadedLayoutModules | undefined
1274
1373
  const assemblyCacheFor = (
1275
1374
  mod: RouteModule,
1276
1375
  layouts: LoadedLayoutModules,
1376
+ req: Request,
1277
1377
  ): RenderAssemblyCache | undefined => {
1278
1378
  if (typeof mod.meta === "function") return undefined
1279
1379
  for (const layout of layouts) if (typeof layout.meta === "function") return undefined
@@ -1282,11 +1382,17 @@ export function createPageRequestExecutor<Env = unknown>(
1282
1382
  assemblySlotLayouts.length === layouts.length &&
1283
1383
  assemblySlotLayouts.every((layout, index) => layout === layouts[index])
1284
1384
  if (assemblySlotMod !== mod || !layoutsUnchanged) {
1285
- assemblySlot = {}
1385
+ assemblySlots = new Map()
1286
1386
  assemblySlotMod = mod
1287
1387
  assemblySlotLayouts = layouts
1288
1388
  }
1289
- return assemblySlot
1389
+ const key = i18n === undefined ? "" : i18n.localeOf(pathOf(req))
1390
+ let slot = assemblySlots.get(key)
1391
+ if (slot === undefined) {
1392
+ slot = {}
1393
+ assemblySlots.set(key, slot)
1394
+ }
1395
+ return slot
1290
1396
  }
1291
1397
 
1292
1398
  return withResponseControls(async (c, controls) => {
@@ -1328,7 +1434,9 @@ export function createPageRequestExecutor<Env = unknown>(
1328
1434
  search: loaderSearch(mod.searchSchema, c.req),
1329
1435
  set: controls.scope(PAGE_SCOPE),
1330
1436
  }
1331
- if (route.middlewareIds !== undefined) await runMiddleware(route, ctx, controls)
1437
+ if (route.middlewareIds !== undefined || localeGuarded(route)) {
1438
+ await runMiddleware(route, ctx, controls)
1439
+ }
1332
1440
  run = await runLayoutChain(
1333
1441
  route,
1334
1442
  ctx,
@@ -1474,7 +1582,7 @@ export function createPageRequestExecutor<Env = unknown>(
1474
1582
  layoutModules ?? (await loadLayoutModules(route)),
1475
1583
  layoutRetained,
1476
1584
  boundaryStates,
1477
- layoutModules === undefined ? undefined : assemblyCacheFor(mod, layoutModules),
1585
+ layoutModules === undefined ? undefined : assemblyCacheFor(mod, layoutModules, c.req),
1478
1586
  responseHeaders,
1479
1587
  controls.personalized,
1480
1588
  componentState,
@@ -1493,6 +1601,32 @@ export function createPageRequestExecutor<Env = unknown>(
1493
1601
  headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
1494
1602
  })
1495
1603
  }
1604
+ const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
1605
+ // Before anything runs: a form posts cross-site without a preflight and carries the visitor's
1606
+ // cookies, so an action is a CSRF target unless its route names the origins it accepts.
1607
+ const origin = c.req.headers.get("origin")
1608
+ const allowedOrigins = mod.allowedOrigins
1609
+ const allowed =
1610
+ allowedOrigins === undefined
1611
+ ? origin === null ||
1612
+ isSameOriginForm(origin, c.req.headers.get("sec-fetch-site"), c.req)
1613
+ : typeof allowedOrigins === "function"
1614
+ ? allowedOrigins(origin) === true
1615
+ : origin !== null && allowedOrigins.includes(origin)
1616
+ if (!allowed) {
1617
+ return isDataRequest
1618
+ ? Response.json(
1619
+ { ok: false, error: "forbidden_origin" },
1620
+ { status: 403, headers: { "cache-control": PRIVATE_NO_STORE } },
1621
+ )
1622
+ : new Response("Forbidden", {
1623
+ status: 403,
1624
+ headers: {
1625
+ "content-type": "text/plain; charset=utf-8",
1626
+ "cache-control": PRIVATE_NO_STORE,
1627
+ },
1628
+ })
1629
+ }
1496
1630
  const requestApi = apiFor(c)
1497
1631
  const actionContext: LoaderContext = {
1498
1632
  params: c.params,
@@ -1504,11 +1638,12 @@ export function createPageRequestExecutor<Env = unknown>(
1504
1638
  search: loaderSearch(mod.searchSchema, c.req),
1505
1639
  set: controls.scope(ACTION_SCOPE),
1506
1640
  }
1507
- const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
1508
1641
  let gated: GatedLayouts
1509
1642
  let result: unknown
1510
1643
  try {
1511
- if (route.middlewareIds !== undefined) await runMiddleware(route, actionContext, controls)
1644
+ if (route.middlewareIds !== undefined || localeGuarded(route)) {
1645
+ await runMiddleware(route, actionContext, controls)
1646
+ }
1512
1647
  gated = await runLayoutGates(route, actionContext, controls)
1513
1648
  result = await mod.action(actionContext)
1514
1649
  } catch (err) {
@@ -1697,6 +1832,8 @@ export function createPageRequestExecutor<Env = unknown>(
1697
1832
  ...preloadOf(route.id),
1698
1833
  ...stylesOf(route.id),
1699
1834
  ...prerenderedHandover,
1835
+ ...copyOption,
1836
+ ...localeOption(c.req),
1700
1837
  ...(responseHeaders === undefined ? {} : { headers: responseHeaders }),
1701
1838
  ...(nonce === undefined ? {} : { nonce }),
1702
1839
  ...cspOption,
@@ -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}()`,