@nifrajs/web 3.3.0 → 4.0.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/dist/backend-only.d.ts +15 -0
- package/dist/backend-only.d.ts.map +1 -0
- package/dist/backend-only.js +16 -0
- package/dist/backend-only.js.map +1 -0
- package/dist/boundary.d.ts +5 -0
- package/dist/boundary.d.ts.map +1 -1
- package/dist/boundary.js +11 -2
- package/dist/boundary.js.map +1 -1
- package/dist/build-plan.d.ts +35 -6
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +89 -35
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +18 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +97 -17
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +72 -23
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +411 -98
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- package/dist/cdn.d.ts +74 -0
- package/dist/cdn.d.ts.map +1 -0
- package/dist/cdn.js +132 -0
- package/dist/cdn.js.map +1 -0
- package/dist/client.d.ts +21 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +226 -15
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +4 -3
- package/dist/conformance.d.ts.map +1 -1
- package/dist/conformance.js +12 -3
- package/dist/conformance.js.map +1 -1
- package/dist/csp.d.ts +93 -0
- package/dist/csp.d.ts.map +1 -0
- package/dist/csp.js +122 -0
- package/dist/csp.js.map +1 -0
- package/dist/css-contract.d.ts +21 -0
- package/dist/css-contract.d.ts.map +1 -0
- package/dist/css-contract.js +28 -0
- package/dist/css-contract.js.map +1 -0
- package/dist/dev-client.d.ts +76 -0
- package/dist/dev-client.d.ts.map +1 -0
- package/dist/dev-client.js +280 -0
- package/dist/dev-client.js.map +1 -0
- package/dist/dev-diagnostics.d.ts +26 -2
- package/dist/dev-diagnostics.d.ts.map +1 -1
- package/dist/dev-diagnostics.js +38 -7
- package/dist/dev-diagnostics.js.map +1 -1
- package/dist/dev-error.d.ts +4 -13
- package/dist/dev-error.d.ts.map +1 -1
- package/dist/dev-error.js +46 -4
- package/dist/dev-error.js.map +1 -1
- package/dist/dev-feed.d.ts +298 -0
- package/dist/dev-feed.d.ts.map +1 -0
- package/dist/dev-feed.js +797 -0
- package/dist/dev-feed.js.map +1 -0
- package/dist/dev-indicator.d.ts +38 -0
- package/dist/dev-indicator.d.ts.map +1 -0
- package/dist/dev-indicator.js +93 -0
- package/dist/dev-indicator.js.map +1 -0
- package/dist/dev-session.d.ts +77 -0
- package/dist/dev-session.d.ts.map +1 -0
- package/dist/dev-session.js +580 -0
- package/dist/dev-session.js.map +1 -0
- package/dist/dev-sourcemap.d.ts +27 -0
- package/dist/dev-sourcemap.d.ts.map +1 -0
- package/dist/dev-sourcemap.js +235 -0
- package/dist/dev-sourcemap.js.map +1 -0
- package/dist/dev-ssr-graph.d.ts.map +1 -1
- package/dist/dev-ssr-graph.js +13 -5
- package/dist/dev-ssr-graph.js.map +1 -1
- package/dist/dev.d.ts +15 -2
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +69 -44
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic-catalog.d.ts +35 -0
- package/dist/diagnostic-catalog.d.ts.map +1 -0
- package/dist/diagnostic-catalog.js +203 -0
- package/dist/diagnostic-catalog.js.map +1 -0
- package/dist/diagnostic-prompt.d.ts +49 -0
- package/dist/diagnostic-prompt.d.ts.map +1 -0
- package/dist/diagnostic-prompt.js +303 -0
- package/dist/diagnostic-prompt.js.map +1 -0
- package/dist/diagnostic.d.ts +17 -23
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +31 -39
- package/dist/diagnostic.js.map +1 -1
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +4 -1
- package/dist/draft.js.map +1 -1
- package/dist/fn.d.ts +5 -0
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +13 -3
- package/dist/fn.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +4 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +15 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -12
- package/dist/index.js.map +1 -1
- package/dist/internal/cdn/cloudflare.d.ts +48 -0
- package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
- package/dist/internal/cdn/cloudflare.js +94 -0
- package/dist/internal/cdn/cloudflare.js.map +1 -0
- package/dist/internal/cdn/core.d.ts +91 -0
- package/dist/internal/cdn/core.d.ts.map +1 -0
- package/dist/internal/cdn/core.js +220 -0
- package/dist/internal/cdn/core.js.map +1 -0
- package/dist/internal/cdn/fastly.d.ts +17 -0
- package/dist/internal/cdn/fastly.d.ts.map +1 -0
- package/dist/internal/cdn/fastly.js +43 -0
- package/dist/internal/cdn/fastly.js.map +1 -0
- package/dist/internal/cdn/vercel.d.ts +31 -0
- package/dist/internal/cdn/vercel.d.ts.map +1 -0
- package/dist/internal/cdn/vercel.js +56 -0
- package/dist/internal/cdn/vercel.js.map +1 -0
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/codegen.d.ts +3 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +151 -30
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/dev-csp.d.ts +24 -0
- package/dist/internal/dev-csp.d.ts.map +1 -0
- package/dist/internal/dev-csp.js +160 -0
- package/dist/internal/dev-csp.js.map +1 -0
- package/dist/internal/dev-reserved.d.ts +25 -0
- package/dist/internal/dev-reserved.d.ts.map +1 -0
- package/dist/internal/dev-reserved.js +39 -0
- package/dist/internal/dev-reserved.js.map +1 -0
- package/dist/internal/html-spans.d.ts +10 -0
- package/dist/internal/html-spans.d.ts.map +1 -0
- package/dist/internal/html-spans.js +46 -0
- package/dist/internal/html-spans.js.map +1 -0
- package/dist/internal/loading-runtime.d.ts +44 -0
- package/dist/internal/loading-runtime.d.ts.map +1 -0
- package/dist/internal/loading-runtime.js +206 -0
- package/dist/internal/loading-runtime.js.map +1 -0
- package/dist/internal/matches-runtime.d.ts +8 -0
- package/dist/internal/matches-runtime.d.ts.map +1 -0
- package/dist/internal/matches-runtime.js +61 -0
- package/dist/internal/matches-runtime.js.map +1 -0
- package/dist/internal/mount-shadow.d.ts +36 -0
- package/dist/internal/mount-shadow.d.ts.map +1 -0
- package/dist/internal/mount-shadow.js +65 -0
- package/dist/internal/mount-shadow.js.map +1 -0
- package/dist/internal/node-builtins.d.ts +3 -0
- package/dist/internal/node-builtins.d.ts.map +1 -0
- package/dist/internal/node-builtins.js +79 -0
- package/dist/internal/node-builtins.js.map +1 -0
- package/dist/internal/output-guard.d.ts +65 -0
- package/dist/internal/output-guard.d.ts.map +1 -0
- package/dist/internal/output-guard.js +630 -0
- package/dist/internal/output-guard.js.map +1 -0
- package/dist/internal/page-execution.d.ts +69 -0
- package/dist/internal/page-execution.d.ts.map +1 -0
- package/dist/internal/page-execution.js +1021 -0
- package/dist/internal/page-execution.js.map +1 -0
- package/dist/internal/parity.d.ts +21 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +104 -22
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/private-env.d.ts +12 -0
- package/dist/internal/private-env.d.ts.map +1 -0
- package/dist/internal/private-env.js +335 -0
- package/dist/internal/private-env.js.map +1 -0
- package/dist/internal/public-url.d.ts +3 -0
- package/dist/internal/public-url.d.ts.map +1 -0
- package/dist/internal/public-url.js +18 -0
- package/dist/internal/public-url.js.map +1 -0
- package/dist/internal/render-document.d.ts +37 -21
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +104 -65
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-url.d.ts +3 -0
- package/dist/internal/request-url.d.ts.map +1 -0
- package/dist/internal/request-url.js +14 -0
- package/dist/internal/request-url.js.map +1 -0
- package/dist/internal/response-controls.d.ts +75 -0
- package/dist/internal/response-controls.d.ts.map +1 -0
- package/dist/internal/response-controls.js +256 -0
- package/dist/internal/response-controls.js.map +1 -0
- package/dist/internal/runtime-contract.d.ts +11 -16
- package/dist/internal/runtime-contract.d.ts.map +1 -1
- package/dist/internal/runtime-contract.js.map +1 -1
- package/dist/internal/secret-scan.d.ts +104 -0
- package/dist/internal/secret-scan.d.ts.map +1 -0
- package/dist/internal/secret-scan.js +485 -0
- package/dist/internal/secret-scan.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +6 -29
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +6 -66
- package/dist/internal/server-boundary.js.map +1 -1
- package/dist/internal/source-map.d.ts +47 -0
- package/dist/internal/source-map.d.ts.map +1 -0
- package/dist/internal/source-map.js +225 -0
- package/dist/internal/source-map.js.map +1 -0
- package/dist/internal/target-compat.d.ts +27 -0
- package/dist/internal/target-compat.d.ts.map +1 -0
- package/dist/internal/target-compat.js +77 -0
- package/dist/internal/target-compat.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +6 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -1
- package/dist/internal/timing-safe-equal.js +10 -0
- package/dist/internal/timing-safe-equal.js.map +1 -1
- package/dist/internal/vitals-runtime.d.ts +18 -0
- package/dist/internal/vitals-runtime.d.ts.map +1 -0
- package/dist/internal/vitals-runtime.js +38 -0
- package/dist/internal/vitals-runtime.js.map +1 -0
- package/dist/internal/web-app.d.ts +56 -8
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +215 -772
- package/dist/internal/web-app.js.map +1 -1
- package/dist/internal/zone-graph.d.ts +89 -0
- package/dist/internal/zone-graph.d.ts.map +1 -0
- package/dist/internal/zone-graph.js +492 -0
- package/dist/internal/zone-graph.js.map +1 -0
- package/dist/isr.d.ts +83 -6
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +301 -39
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts +3 -0
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +37 -9
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +221 -14
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +386 -9
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +26 -5
- package/dist/module-graph.d.ts.map +1 -1
- package/dist/module-graph.js +17 -7
- package/dist/module-graph.js.map +1 -1
- package/dist/navigation.d.ts +17 -3
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +11 -5
- package/dist/navigation.js.map +1 -1
- package/dist/nonce.d.ts +47 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +63 -0
- package/dist/nonce.js.map +1 -0
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +4 -3
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +9 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +18 -0
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/svg.d.ts +22 -3
- package/dist/plugins/svg.d.ts.map +1 -1
- package/dist/plugins/svg.js +112 -1
- package/dist/plugins/svg.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +107 -3
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
- package/dist/plugins/vite-leak-guard.js +378 -17
- package/dist/plugins/vite-leak-guard.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts +61 -0
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
- package/dist/plugins/vite-zone-guard.js +208 -0
- package/dist/plugins/vite-zone-guard.js.map +1 -0
- package/dist/plugins/zone-guard.d.ts +27 -0
- package/dist/plugins/zone-guard.d.ts.map +1 -0
- package/dist/plugins/zone-guard.js +72 -0
- package/dist/plugins/zone-guard.js.map +1 -0
- package/dist/prerender.d.ts +10 -0
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +35 -3
- package/dist/prerender.js.map +1 -1
- package/dist/public-dir.d.ts +9 -0
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +131 -48
- package/dist/public-dir.js.map +1 -1
- package/dist/pwa-manifest.d.ts +68 -0
- package/dist/pwa-manifest.d.ts.map +1 -0
- package/dist/pwa-manifest.js +178 -0
- package/dist/pwa-manifest.js.map +1 -0
- package/dist/query.js +4 -4
- package/dist/query.js.map +1 -1
- package/dist/render-seam.d.ts +42 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +6 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/route-manifest.d.ts +3 -0
- package/dist/route-manifest.d.ts.map +1 -1
- package/dist/route-manifest.js +26 -3
- package/dist/route-manifest.js.map +1 -1
- package/dist/route-types.d.ts +24 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +195 -0
- package/dist/route-types.js.map +1 -0
- package/dist/router.d.ts +28 -6
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +129 -40
- package/dist/router.js.map +1 -1
- package/dist/trusted-html.d.ts +34 -0
- package/dist/trusted-html.d.ts.map +1 -0
- package/dist/trusted-html.js +35 -0
- package/dist/trusted-html.js.map +1 -0
- package/dist/vitals.d.ts +33 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +16 -0
- package/dist/vitals.js.map +1 -0
- package/dist/vite.d.ts +16 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +187 -36
- package/dist/vite.js.map +1 -1
- package/dist/zones.d.ts +70 -0
- package/dist/zones.d.ts.map +1 -0
- package/dist/zones.js +365 -0
- package/dist/zones.js.map +1 -0
- package/package.json +67 -14
- package/src/backend-only.ts +16 -0
- package/src/boundary.ts +18 -5
- package/src/build-plan.ts +113 -36
- package/src/build-vite.ts +124 -15
- package/src/build.ts +535 -102
- package/src/bun-dev-entry.ts +1 -1
- package/src/cdn.ts +216 -0
- package/src/client.ts +251 -18
- package/src/conformance.ts +16 -3
- package/src/csp.ts +194 -0
- package/src/css-contract.ts +44 -0
- package/src/dev-client.ts +349 -0
- package/src/dev-diagnostics.ts +60 -7
- package/src/dev-error.ts +49 -5
- package/src/dev-feed.ts +1140 -0
- package/src/dev-indicator.ts +113 -0
- package/src/dev-session.ts +729 -0
- package/src/dev-sourcemap.ts +281 -0
- package/src/dev-ssr-graph.ts +16 -5
- package/src/dev.ts +86 -43
- package/src/diagnostic-catalog.ts +243 -0
- package/src/diagnostic-prompt.ts +355 -0
- package/src/diagnostic.ts +63 -66
- package/src/draft.ts +4 -1
- package/src/fn.ts +19 -3
- package/src/fs.ts +13 -2
- package/src/index.ts +57 -10
- package/src/internal/cdn/cloudflare.ts +146 -0
- package/src/internal/cdn/core.ts +316 -0
- package/src/internal/cdn/fastly.ts +62 -0
- package/src/internal/cdn/vercel.ts +95 -0
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/codegen.ts +180 -29
- package/src/internal/dev-csp.ts +176 -0
- package/src/internal/dev-reserved.ts +52 -0
- package/src/internal/html-spans.ts +50 -0
- package/src/internal/loading-runtime.ts +244 -0
- package/src/internal/matches-runtime.ts +62 -0
- package/src/internal/mount-shadow.ts +88 -0
- package/src/internal/node-builtins.ts +79 -0
- package/src/internal/output-guard.ts +701 -0
- package/src/internal/page-execution.ts +1371 -0
- package/src/internal/parity.ts +150 -36
- package/src/internal/private-env.ts +334 -0
- package/src/internal/public-url.ts +22 -0
- package/src/internal/render-document.ts +153 -73
- package/src/internal/request-url.ts +14 -0
- package/src/internal/response-controls.ts +284 -0
- package/src/internal/runtime-contract.ts +10 -15
- package/src/internal/secret-scan.ts +623 -0
- package/src/internal/server-boundary.ts +6 -69
- package/src/internal/source-map.ts +256 -0
- package/src/internal/target-compat.ts +92 -0
- package/src/internal/timing-safe-equal.ts +13 -0
- package/src/internal/vitals-runtime.ts +53 -0
- package/src/internal/web-app.ts +290 -879
- package/src/internal/zone-graph.ts +593 -0
- package/src/isr.ts +366 -39
- package/src/llms-txt.ts +44 -8
- package/src/manifest.ts +671 -17
- package/src/module-graph.ts +53 -9
- package/src/navigation.ts +29 -6
- package/src/nonce.ts +104 -0
- package/src/plugins/css-modules.ts +5 -3
- package/src/plugins/kit.ts +24 -0
- package/src/plugins/svg.ts +135 -4
- package/src/plugins/vite-leak-guard.ts +529 -16
- package/src/plugins/vite-zone-guard.ts +263 -0
- package/src/plugins/zone-guard.ts +110 -0
- package/src/prerender.ts +49 -3
- package/src/public-dir.ts +135 -49
- package/src/pwa-manifest.ts +226 -0
- package/src/query.ts +4 -4
- package/src/render-seam.ts +46 -1
- package/src/route-manifest.ts +15 -3
- package/src/route-types.ts +264 -0
- package/src/router.ts +163 -63
- package/src/trusted-html.ts +44 -0
- package/src/vitals.ts +44 -0
- package/src/vite.ts +207 -36
- package/src/zones.ts +430 -0
- package/dist/plugins/vite-server-only.d.ts +0 -49
- package/dist/plugins/vite-server-only.d.ts.map +0 -1
- package/dist/plugins/vite-server-only.js +0 -57
- package/dist/plugins/vite-server-only.js.map +0 -1
- package/dist/server-only.d.ts +0 -21
- package/dist/server-only.d.ts.map +0 -1
- package/dist/server-only.js +0 -25
- package/dist/server-only.js.map +0 -1
- package/src/plugins/vite-server-only.ts +0 -75
- package/src/server-only.ts +0 -25
package/src/manifest.ts
CHANGED
|
@@ -4,8 +4,37 @@
|
|
|
4
4
|
* the filesystem and feeds `buildManifest`; everything here is pure logic, so it stays
|
|
5
5
|
* portable (no fs, no DOM) and fully unit-testable. Edge deploys pre-build the manifest.
|
|
6
6
|
*/
|
|
7
|
-
import
|
|
7
|
+
import { routePatternOverlap } from "@nifrajs/core"
|
|
8
|
+
import { paramConstraint } from "@nifrajs/core/pattern"
|
|
9
|
+
import type { CookieOptions, ResponseResult, StandardSchemaV1 } from "@nifrajs/core/server"
|
|
8
10
|
import type { BoundaryDescriptor, BoundaryRegistration } from "./boundary.ts"
|
|
11
|
+
import { guardChannel, outputGuard } from "./internal/output-guard.ts"
|
|
12
|
+
import type { RevalidateTags } from "./isr.ts"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Response controls a loader or action reaches as `ctx.set` - the page counterpart of a route
|
|
16
|
+
* handler's `c.set`. Write before the loader or action returns; a write from a deferred promise that
|
|
17
|
+
* settles later throws.
|
|
18
|
+
*/
|
|
19
|
+
export interface LoaderResponseControls {
|
|
20
|
+
/**
|
|
21
|
+
* Headers for the rendered document (`cache-control`, `x-robots-tag`, `link`, ...). A layout's
|
|
22
|
+
* headers are applied first, then the page's, then the action's, so the most specific writer wins a
|
|
23
|
+
* name. They are not applied to a redirect, a status page, an error page, or a navigation data
|
|
24
|
+
* response. `content-type`, `set-cookie`, `location`, transport headers, and the `x-nifra-` prefix
|
|
25
|
+
* are refused.
|
|
26
|
+
*/
|
|
27
|
+
readonly headers: Record<string, string>
|
|
28
|
+
/**
|
|
29
|
+
* Queue a `Set-Cookie`, with the same secure defaults as `c.set.cookie`
|
|
30
|
+
* (`HttpOnly; Secure; SameSite=Lax; Path=/`). The cookie rides every outcome - the document, a
|
|
31
|
+
* navigation data response, a redirect, a status or error page - and makes the response
|
|
32
|
+
* `cache-control: private, no-store`.
|
|
33
|
+
*/
|
|
34
|
+
cookie(name: string, value: string, options?: CookieOptions): void
|
|
35
|
+
/** Queue a cookie deletion. Match the `path`/`domain` the cookie was set with. */
|
|
36
|
+
deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void
|
|
37
|
+
}
|
|
9
38
|
|
|
10
39
|
/** Context passed to a route `loader`. The `api` + `env` are injected by `createWebApp` and typed
|
|
11
40
|
* per-route via `@nifrajs/client`'s `LoaderArgs<Api, Env>` (here they are opaque to the agnostic core). */
|
|
@@ -24,11 +53,66 @@ export interface LoaderContext {
|
|
|
24
53
|
* declares one - failing closed to the schema's defaults - else the raw parsed query. Typed per-route
|
|
25
54
|
* via `@nifrajs/client`'s `LoaderArgs<Api, Env, Search>`. */
|
|
26
55
|
readonly search: Record<string, unknown>
|
|
56
|
+
/** Response headers and cookies for this page request. */
|
|
57
|
+
readonly set: LoaderResponseControls
|
|
27
58
|
}
|
|
28
59
|
|
|
29
60
|
/** A route's optional data loader: params/request in, data out. */
|
|
30
61
|
export type Loader = (ctx: LoaderContext) => unknown | Promise<unknown>
|
|
31
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The `middleware` export of a `_layout.backend.ts` file. It runs on the server before the layouts, loaders and
|
|
65
|
+
* action of every route in its directory and below - on a document request, a client navigation and a
|
|
66
|
+
* form post alike - and before a nested `_404` there. Middleware higher in the tree runs first.
|
|
67
|
+
*
|
|
68
|
+
* - Return nothing to let the request through.
|
|
69
|
+
* - Return or throw a `redirect()`, a status such as `notFound()`, or a `Response` to answer with it:
|
|
70
|
+
* nothing below the middleware runs.
|
|
71
|
+
* - `ctx.set` adds headers and cookies as a loader's does. A layout's or the page's header of the same
|
|
72
|
+
* name wins.
|
|
73
|
+
* - `ctx.params` holds the params of the directory's own URL prefix, as a layout's loader sees them.
|
|
74
|
+
*
|
|
75
|
+
* It does not run for a prerendered page, an ISR cache hit, a mounted API or a static file. For
|
|
76
|
+
* middleware on every request, export `use` from `backend/framework.ts`. Pass a typed context to type
|
|
77
|
+
* `ctx.api` and `ctx.env`: `RouteMiddleware<LoaderArgs<typeof app, Env>>`.
|
|
78
|
+
*/
|
|
79
|
+
export type RouteMiddleware<Ctx = LoaderContext> = (
|
|
80
|
+
ctx: Ctx,
|
|
81
|
+
) => MiddlewareOutcome | Promise<MiddlewareOutcome>
|
|
82
|
+
|
|
83
|
+
/** What a {@link RouteMiddleware} answers with: nothing, to let the request through, or a response. */
|
|
84
|
+
export type MiddlewareOutcome = undefined | Response | ResponseResult
|
|
85
|
+
|
|
86
|
+
/** One client navigation, as a layout's {@link ShouldRevalidate} sees it. */
|
|
87
|
+
export interface ShouldRevalidateArgs {
|
|
88
|
+
/** The URL the client is leaving. */
|
|
89
|
+
readonly currentUrl: URL
|
|
90
|
+
/** The URL being loaded. */
|
|
91
|
+
readonly nextUrl: URL
|
|
92
|
+
/** Every route param of the page being left - not only the ones the layout owns. */
|
|
93
|
+
readonly currentParams: Readonly<Record<string, string>>
|
|
94
|
+
/** Every route param of the page being loaded. */
|
|
95
|
+
readonly nextParams: Readonly<Record<string, string>>
|
|
96
|
+
/** The answer without the hook: `true` when a param the layout owns or the query changed. */
|
|
97
|
+
readonly defaultShouldRevalidate: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A layout's say over whether its loader runs again on a client navigation. Export it from a
|
|
102
|
+
* `_layout.backend.ts` next to the loader: `export const shouldRevalidate: ShouldRevalidate = (args) => ...`.
|
|
103
|
+
*
|
|
104
|
+
* - Asked on the server, on a client navigation that keeps this layout on screen, when the browser
|
|
105
|
+
* already holds its data. A document request, the revalidation after an action, and
|
|
106
|
+
* `router.invalidate()` always run the loader.
|
|
107
|
+
* - Return `false` to keep the data the browser has; any other value runs the loader. Return
|
|
108
|
+
* `args.defaultShouldRevalidate` to keep the default for the cases you do not handle.
|
|
109
|
+
* - Must be synchronous: a returned promise is not `false`, so the loader runs.
|
|
110
|
+
* - Never asked for a `gate` layout, which runs on every request.
|
|
111
|
+
* - A page's loader runs on every navigation and a page's `shouldRevalidate` is not read. Query keys
|
|
112
|
+
* a page's loader never uses belong in its `searchClientKeys`.
|
|
113
|
+
*/
|
|
114
|
+
export type ShouldRevalidate = (args: ShouldRevalidateArgs) => boolean
|
|
115
|
+
|
|
32
116
|
/**
|
|
33
117
|
* A route's optional mutation, run on POST. Shares the loader context (params/request/api);
|
|
34
118
|
* read the form/JSON body off `request`. Returns either a control-flow value (a `redirect()`, a
|
|
@@ -66,14 +150,15 @@ export interface ClientActionResult {
|
|
|
66
150
|
/** A client-only action wrapper; it never replaces the server action. */
|
|
67
151
|
export type ClientAction = (
|
|
68
152
|
args: ClientActionArgs,
|
|
69
|
-
) => ClientActionResult |
|
|
153
|
+
) => ClientActionResult | undefined | Promise<ClientActionResult | undefined>
|
|
70
154
|
|
|
71
155
|
/** Client hooks populated by the generated route entry after a route chunk loads. */
|
|
72
156
|
export interface ClientRouteHooks {
|
|
73
157
|
readonly clientLoader?: ClientLoader
|
|
74
158
|
readonly clientAction?: ClientAction
|
|
75
|
-
/** Neutral boundary descriptors needed for soft-navigation interception
|
|
76
|
-
|
|
159
|
+
/** Neutral boundary descriptors needed for soft-navigation interception. A boundary's loader lives in
|
|
160
|
+
* the route's backend half, so the browser never learns whether one exists. */
|
|
161
|
+
readonly boundaries?: ReadonlyArray<Omit<BoundaryDescriptor, "hasLoad">>
|
|
77
162
|
}
|
|
78
163
|
|
|
79
164
|
/**
|
|
@@ -189,6 +274,8 @@ export interface Meta {
|
|
|
189
274
|
export interface MetaArgs<Data = unknown> {
|
|
190
275
|
readonly data: Data
|
|
191
276
|
readonly params: Record<string, string>
|
|
277
|
+
/** The resolved document CSP nonce, when this render uses per-request nonce protection. */
|
|
278
|
+
readonly nonce?: string
|
|
192
279
|
/**
|
|
193
280
|
* The site origin - scheme + host (+ port), e.g. `"https://news.example.com"`, **with no trailing
|
|
194
281
|
* slash**. The single piece of server/env knowledge `meta()` otherwise can't see: it runs in BOTH
|
|
@@ -232,12 +319,24 @@ export interface RouteModule {
|
|
|
232
319
|
readonly default: unknown
|
|
233
320
|
readonly loader?: Loader
|
|
234
321
|
readonly action?: Action
|
|
322
|
+
/**
|
|
323
|
+
* Backend half only: the schema of what {@link loader} sends to the browser. Required whenever the
|
|
324
|
+
* loader returns data. Keys it does not declare are dropped before the page renders; a declared
|
|
325
|
+
* field of the wrong shape fails the request with a 500 naming the field's path.
|
|
326
|
+
*/
|
|
327
|
+
readonly loaderOutput?: StandardSchemaV1
|
|
328
|
+
/** Backend half only: the schema of what {@link action} sends back, held to the same rule. */
|
|
329
|
+
readonly actionOutput?: StandardSchemaV1
|
|
235
330
|
/** Optional client-only loader. It runs after hydration and on client navigations. */
|
|
236
331
|
readonly clientLoader?: ClientLoader
|
|
237
332
|
/** Optional client-only submit wrapper. The server action remains mandatory for mutations. */
|
|
238
333
|
readonly clientAction?: ClientAction
|
|
239
|
-
/** Named async boundaries owned by this route. The adapter renders their neutral state seam.
|
|
334
|
+
/** Named async boundaries owned by this route. The adapter renders their neutral state seam. A
|
|
335
|
+
* boundary's `load` comes from {@link boundaryLoaders} in the route's backend half; the frontend
|
|
336
|
+
* file declares only how it renders. */
|
|
240
337
|
readonly boundaries?: readonly BoundaryRegistration[]
|
|
338
|
+
/** Backend half only: each boundary's server loader, keyed by boundary name. */
|
|
339
|
+
readonly boundaryLoaders?: Readonly<Record<string, BoundaryLoaderEntry>>
|
|
241
340
|
/** A Standard Schema validating this route's URL search params. When present, `ctx.search` is parsed +
|
|
242
341
|
* validated against it (failing closed to the schema's defaults on invalid input); type it into the
|
|
243
342
|
* loader with `LoaderArgs<Api, Env, typeof searchSchema>`. */
|
|
@@ -256,6 +355,30 @@ export interface RouteModule {
|
|
|
256
355
|
* island-hydrated pages where interactivity is mounted by smaller, explicit client entries.
|
|
257
356
|
*/
|
|
258
357
|
readonly hydrate?: boolean
|
|
358
|
+
/**
|
|
359
|
+
* `false` renders this route's component in the browser only. The server still matches the route,
|
|
360
|
+
* runs its gates and loaders, renders the layouts around it and embeds the data - it skips the
|
|
361
|
+
* component alone, putting {@link HydrateFallback} (or nothing) in the page slot. The browser
|
|
362
|
+
* hydrates against that, then renders the component. For a component that cannot render on a
|
|
363
|
+
* server: one that reads `window` or a browser-only API while it renders.
|
|
364
|
+
*
|
|
365
|
+
* The module itself is still imported on the server, for its loader and options - so an import
|
|
366
|
+
* that needs a browser at load time belongs inside the component (a dynamic `import()`), not at the
|
|
367
|
+
* top of the file. Cannot be combined with `hydrate = false`: nothing would ever render the page.
|
|
368
|
+
*/
|
|
369
|
+
readonly ssr?: boolean
|
|
370
|
+
/**
|
|
371
|
+
* What the server renders in the page slot of an `ssr = false` route, and what the browser hydrates
|
|
372
|
+
* before the component takes over - a skeleton, a spinner. Receives the same props as the
|
|
373
|
+
* component, loader `data` included. Omitted ⇒ the slot is empty until the component renders.
|
|
374
|
+
*/
|
|
375
|
+
readonly HydrateFallback?: unknown
|
|
376
|
+
/**
|
|
377
|
+
* Any value to report through `useMatches` - a breadcrumb label, a render function, a flag a layout
|
|
378
|
+
* reads about the page inside it. Layouts, pages, and status pages may export one. It is read from
|
|
379
|
+
* the module on the server and in the browser, so it must be the same value on both.
|
|
380
|
+
*/
|
|
381
|
+
readonly handle?: unknown
|
|
259
382
|
/**
|
|
260
383
|
* Opt a **static** route (no `:param`/`*`) into build-time prerendering (SSG): `prerenderRoutes`
|
|
261
384
|
* (from `@nifrajs/web/build`) renders it to a static `index.html` at build. The loader runs at build
|
|
@@ -276,8 +399,12 @@ export interface RouteModule {
|
|
|
276
399
|
* list the client parses to refetch - so the two channels never alias.)
|
|
277
400
|
*/
|
|
278
401
|
readonly revalidate?: number
|
|
279
|
-
/**
|
|
280
|
-
|
|
402
|
+
/**
|
|
403
|
+
* Bounded tags ISR on-demand invalidation (`?tag=...`) and a CDN purge reach this page by. A list, or
|
|
404
|
+
* a function of the route's params and URL (`({ params }) => [\`product:${params.id}\`]`) for tags
|
|
405
|
+
* per page. Tags travel to the CDN and reach anyone reading the origin: never put personal data in one.
|
|
406
|
+
*/
|
|
407
|
+
readonly revalidateTags?: RevalidateTags
|
|
281
408
|
/**
|
|
282
409
|
* No-framework island bundles (`@nifrajs/web/islands`) to load on this route, as `<script
|
|
283
410
|
* type="module">` in the document tail. Loaded **regardless of `hydrate`** - pair with
|
|
@@ -287,6 +414,13 @@ export interface RouteModule {
|
|
|
287
414
|
readonly islandScripts?: readonly string[]
|
|
288
415
|
}
|
|
289
416
|
|
|
417
|
+
/** A boundary's server loader, declared in the route's backend half under `boundaryLoaders`. */
|
|
418
|
+
export interface BoundaryLoaderEntry {
|
|
419
|
+
readonly load: NonNullable<BoundaryRegistration["load"]>
|
|
420
|
+
/** The boundary data's output schema. Required whenever `load` returns data. */
|
|
421
|
+
readonly output?: StandardSchemaV1
|
|
422
|
+
}
|
|
423
|
+
|
|
290
424
|
/** A layout (or `_404`/`_error`) entry: its source file (for client codegen) + a lazy loader.
|
|
291
425
|
*
|
|
292
426
|
* A `_layout.tsx` may export `meta` (static {@link Meta} or a function of the loader data + params,
|
|
@@ -295,7 +429,11 @@ export interface RouteModule {
|
|
|
295
429
|
* (`meta`/`link`) concatenate outermost→innermost→page; scalars (`title`) are nearest-wins (the page
|
|
296
430
|
* overrides an inner layout, which overrides an outer one). See `mergeHeads` in `@nifrajs/web`. */
|
|
297
431
|
export interface LayoutEntry {
|
|
432
|
+
/** The frontend half: the file the browser bundle imports. */
|
|
298
433
|
readonly file: string
|
|
434
|
+
/** The backend half (`x.backend.ts`), when the file has one. Server-only: never in a client bundle. */
|
|
435
|
+
readonly backend?: string
|
|
436
|
+
/** Both halves, merged and checked. */
|
|
299
437
|
readonly load: () => Promise<RouteModule>
|
|
300
438
|
}
|
|
301
439
|
|
|
@@ -314,14 +452,65 @@ export interface RouteEntry {
|
|
|
314
452
|
* Optional only so hand-built test manifests may omit it.
|
|
315
453
|
*/
|
|
316
454
|
readonly layoutParams?: ReadonlyArray<readonly string[]>
|
|
455
|
+
/** `_middleware` ids in this route's ancestor chain (outermost → innermost), run before its
|
|
456
|
+
* layouts. Absent ⇒ none. */
|
|
457
|
+
readonly middlewareIds?: readonly string[]
|
|
458
|
+
/** Param names each middleware in {@link middlewareIds} sees, aligned by index: the params of its
|
|
459
|
+
* directory's URL prefix, as {@link layoutParams} holds them for a layout. */
|
|
460
|
+
readonly middlewareParams?: ReadonlyArray<readonly string[]>
|
|
317
461
|
/** `_error` boundary ids in this route's ancestor chain (outermost → innermost). The last is the
|
|
318
462
|
* **nearest** boundary, rendered when the route's loader throws. Always set by `buildManifest`
|
|
319
463
|
* (optional only so hand-built test manifests may omit it); absent/empty ⇒ no boundary (error 500s). */
|
|
320
464
|
readonly errorIds?: readonly string[]
|
|
465
|
+
/** Nested `_404` page ids in this route's ancestor chain (outermost → innermost); the root `_404`
|
|
466
|
+
* is not listed. The last is the **nearest**, rendered when the route's loader answers
|
|
467
|
+
* `notFound()`. Absent ⇒ the root `_404`. */
|
|
468
|
+
readonly notFoundIds?: readonly string[]
|
|
469
|
+
/** `_loading` page ids in this route's ancestor chain (outermost → innermost). A client navigation
|
|
470
|
+
* to the route shows the innermost one whose layouts are already on screen. Absent ⇒ none. */
|
|
471
|
+
readonly loadingIds?: readonly string[]
|
|
472
|
+
/** The frontend half: the file the browser bundle imports. */
|
|
321
473
|
readonly file: string
|
|
474
|
+
/** The backend half (`x.backend.ts`), when the route has one. Server-only. */
|
|
475
|
+
readonly backend?: string
|
|
476
|
+
/** Both halves, merged and checked. */
|
|
322
477
|
readonly load: () => Promise<RouteModule>
|
|
323
478
|
}
|
|
324
479
|
|
|
480
|
+
/** One URL pattern a nested `_404` answers when no route matches it. */
|
|
481
|
+
export interface NotFoundScope {
|
|
482
|
+
/** The directory's URL prefix (`/admin`) or everything beneath it (`/admin/*`). */
|
|
483
|
+
readonly pattern: string
|
|
484
|
+
/** Param names each layout in {@link NotFoundEntry.layoutIds} owns on this pattern, by index. */
|
|
485
|
+
readonly layoutParams: ReadonlyArray<readonly string[]>
|
|
486
|
+
/** Param names each middleware in {@link NotFoundEntry.middlewareIds} sees on this pattern, by
|
|
487
|
+
* index. Absent when the page has no middleware. */
|
|
488
|
+
readonly middlewareParams?: ReadonlyArray<readonly string[]>
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** A `_404` page below the routes root. */
|
|
492
|
+
export interface NotFoundEntry extends LayoutEntry {
|
|
493
|
+
/** Layouts at or above the page's directory (outermost → innermost) - the ones it renders inside. */
|
|
494
|
+
readonly layoutIds: readonly string[]
|
|
495
|
+
/** `_error` boundaries at or above the page's directory (outermost → innermost). */
|
|
496
|
+
readonly errorIds: readonly string[]
|
|
497
|
+
/** `_middleware` ids at or above the page's directory (outermost → innermost). Absent ⇒ none. */
|
|
498
|
+
readonly middlewareIds?: readonly string[]
|
|
499
|
+
/**
|
|
500
|
+
* The URL patterns this page answers when no route matches. Empty when the directory is a
|
|
501
|
+
* catch-all, or when a `_404` in a directory above it answers the same URLs - the page is then
|
|
502
|
+
* reached only by `notFound()` from a route beneath it.
|
|
503
|
+
*/
|
|
504
|
+
readonly scopes: readonly NotFoundScope[]
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** A `_loading` page: what the page slot shows while a client navigation loads. */
|
|
508
|
+
export interface LoadingEntry extends LayoutEntry {
|
|
509
|
+
/** Layouts at or above the page's directory (outermost → innermost). The page shows only while
|
|
510
|
+
* every one of them is on screen, so it never renders outside a layout above it. */
|
|
511
|
+
readonly layoutIds: readonly string[]
|
|
512
|
+
}
|
|
513
|
+
|
|
325
514
|
/** The full route manifest. */
|
|
326
515
|
export interface Manifest {
|
|
327
516
|
readonly routes: readonly RouteEntry[]
|
|
@@ -329,12 +518,33 @@ export interface Manifest {
|
|
|
329
518
|
/** Per-segment `_error` boundary components, keyed by id (`_error`, `a/_error`, …). Always set by
|
|
330
519
|
* `buildManifest` (optional only so hand-built test manifests may omit it). */
|
|
331
520
|
readonly errors?: Readonly<Record<string, LayoutEntry>>
|
|
521
|
+
/** The `_404` at the routes root: unmatched URLs and `notFound()` wherever no nested one applies. */
|
|
332
522
|
readonly notFound?: LayoutEntry
|
|
523
|
+
/**
|
|
524
|
+
* `_404` pages below the routes root, keyed by id (`admin/_404`, …). Each answers `notFound()` from
|
|
525
|
+
* the routes beneath its directory and the unmatched URLs under that directory's URL prefix, and
|
|
526
|
+
* renders inside the layouts at or above it. Absent when the app has none.
|
|
527
|
+
*/
|
|
528
|
+
readonly notFounds?: Readonly<Record<string, NotFoundEntry>>
|
|
333
529
|
/** Per-status terminal pages from `_<status>.tsx` at the routes root (`_410`, `_451`, …), keyed by
|
|
334
530
|
* the status as a string. Rendered by a loader's `gone()` / `statusPage(n)`; a status with no page
|
|
335
531
|
* falls back to `_404`, and then to plain text. `_404` itself stays on {@link notFound} - it is
|
|
336
532
|
* reached by unmatched paths too, which these are not. */
|
|
337
533
|
readonly statusPages?: Readonly<Record<string, LayoutEntry>>
|
|
534
|
+
/**
|
|
535
|
+
* `_loading` pages, keyed by id (`_loading`, `admin/_loading`, …). A client navigation that is
|
|
536
|
+
* still loading after a short delay shows the target route's nearest one in the page slot, inside
|
|
537
|
+
* the layouts the two pages share. Browser-only: the server never renders one. Absent when the app
|
|
538
|
+
* has none.
|
|
539
|
+
*/
|
|
540
|
+
readonly loadings?: Readonly<Record<string, LoadingEntry>>
|
|
541
|
+
/**
|
|
542
|
+
* Middleware candidates, keyed by id (`_middleware`, `admin/_middleware`, …): one per directory
|
|
543
|
+
* with a `_layout.backend.ts`, whose `middleware` export each loads as `default` (`undefined` when
|
|
544
|
+
* that file exports none). Server-only: the client build never imports one. Absent when no
|
|
545
|
+
* directory has a layout backend half.
|
|
546
|
+
*/
|
|
547
|
+
readonly middlewares?: Readonly<Record<string, LayoutEntry>>
|
|
338
548
|
}
|
|
339
549
|
|
|
340
550
|
// `.svelte` and `.vue` routes are supported too: their `default` export is the component and
|
|
@@ -354,13 +564,211 @@ const GROUP = /^\(.+\)$/
|
|
|
354
564
|
// thing. Restricted to 3 digits so `_401k` or `_4` is treated as an ordinary underscore-prefixed
|
|
355
565
|
// file (ignored) rather than silently becoming a status page.
|
|
356
566
|
const STATUS_PAGE = /^_[1-5][0-9][0-9]$/
|
|
567
|
+
// The retired `_middleware.ts`. Discovery still collects it, so that it fails loudly rather than
|
|
568
|
+
// silently stopping guarding anything.
|
|
569
|
+
const MIDDLEWARE_FILE = /^_middleware\.(?:ts|js)$/
|
|
570
|
+
// A route's backend half: `x.backend.ts` beside `x.tsx`. Script extensions only - JSX renders, and
|
|
571
|
+
// rendering belongs to the frontend half.
|
|
572
|
+
const ROUTE_BACKEND = /\.backend\.(?:[cm]?[jt]s)$/
|
|
573
|
+
// A zone or retired suffix on a route file (`x.backend.tsx`) would otherwise become a URL segment.
|
|
574
|
+
const SUFFIXED_ROUTE = /\.(?:backend|frontend|shared|fn|server)$/
|
|
357
575
|
|
|
358
576
|
const stripExt = (file: string): string => file.replace(ROUTE_EXT, "")
|
|
359
577
|
const baseName = (file: string): string => file.slice(file.lastIndexOf("/") + 1)
|
|
578
|
+
|
|
579
|
+
/** Whether a routes-relative path is a retired `_middleware` module. Discovery collects it only so
|
|
580
|
+
* that {@link buildManifest} can refuse it with the migration. */
|
|
581
|
+
export const isMiddlewareFile = (file: string): boolean => MIDDLEWARE_FILE.test(baseName(file))
|
|
582
|
+
|
|
583
|
+
/** Whether a routes-relative path is a route's backend half (`x.backend.ts`). */
|
|
584
|
+
export const isRouteBackendFile = (file: string): boolean => ROUTE_BACKEND.test(baseName(file))
|
|
585
|
+
|
|
586
|
+
/** The backend half a frontend route file pairs with: `blog/[slug].tsx` -> `blog/[slug].backend.ts`. */
|
|
587
|
+
export const backendFileFor = (file: string): string => `${stripExt(file)}.backend.ts`
|
|
588
|
+
|
|
589
|
+
/** Exports the browser runs or reads. They live in a route's frontend file, which ships whole. */
|
|
590
|
+
export const FRONTEND_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
|
|
591
|
+
"default",
|
|
592
|
+
"meta",
|
|
593
|
+
"handle",
|
|
594
|
+
"HydrateFallback",
|
|
595
|
+
"searchSchema",
|
|
596
|
+
"searchClientKeys",
|
|
597
|
+
"clientLoader",
|
|
598
|
+
"clientAction",
|
|
599
|
+
"boundaries",
|
|
600
|
+
"ssr",
|
|
601
|
+
])
|
|
602
|
+
|
|
603
|
+
/** Exports only the server or the build reads. They live in the route's `x.backend.ts`. */
|
|
604
|
+
export const BACKEND_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
|
|
605
|
+
"loader",
|
|
606
|
+
"action",
|
|
607
|
+
"loaderOutput",
|
|
608
|
+
"actionOutput",
|
|
609
|
+
"boundaryLoaders",
|
|
610
|
+
"getStaticPaths",
|
|
611
|
+
"prerender",
|
|
612
|
+
"revalidate",
|
|
613
|
+
"revalidateTags",
|
|
614
|
+
"hydrate",
|
|
615
|
+
"islandScripts",
|
|
616
|
+
"middleware",
|
|
617
|
+
"gate",
|
|
618
|
+
"shouldRevalidate",
|
|
619
|
+
])
|
|
620
|
+
|
|
621
|
+
const routeError = (message: string): Error => new Error(`[nifra/web] ${message}`)
|
|
622
|
+
|
|
623
|
+
/** The frontend export names a backend-only name was found under, for the move-it message. */
|
|
624
|
+
function assertFrontendHalf(file: string, front: Readonly<Record<string, unknown>>): void {
|
|
625
|
+
for (const name of Object.keys(front)) {
|
|
626
|
+
if (BACKEND_ROUTE_EXPORTS.has(name)) {
|
|
627
|
+
throw routeError(
|
|
628
|
+
`"${file}" exports "${name}", which runs on the server only. Move it to "${backendFileFor(file)}": a route's frontend file ships to the browser whole (\`nifra migrate layout\` splits every route of an app)`,
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const boundaries = front.boundaries
|
|
633
|
+
if (Array.isArray(boundaries)) {
|
|
634
|
+
for (const boundary of boundaries as ReadonlyArray<{ name?: unknown; load?: unknown }>) {
|
|
635
|
+
if (boundary?.load !== undefined) {
|
|
636
|
+
throw routeError(
|
|
637
|
+
`boundary "${String(boundary.name)}" in "${file}" declares its load function in the frontend file. Move it to boundaryLoaders["${String(boundary.name)}"].load in "${backendFileFor(file)}"`,
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function assertBackendHalf(
|
|
645
|
+
file: string,
|
|
646
|
+
backend: string,
|
|
647
|
+
back: Readonly<Record<string, unknown>>,
|
|
648
|
+
allowMiddleware: boolean,
|
|
649
|
+
): void {
|
|
650
|
+
for (const name of Object.keys(back)) {
|
|
651
|
+
if (name === "middleware" && !allowMiddleware) {
|
|
652
|
+
throw routeError(
|
|
653
|
+
`"${backend}" exports "middleware", which only a directory's _layout.backend.ts may export`,
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
if (BACKEND_ROUTE_EXPORTS.has(name)) continue
|
|
657
|
+
throw routeError(
|
|
658
|
+
FRONTEND_ROUTE_EXPORTS.has(name)
|
|
659
|
+
? `"${backend}" exports "${name}", which the browser needs. Move it to "${file}"`
|
|
660
|
+
: `"${backend}" exports "${name}", which nifra does not read from a backend half. It may export only: ${[...BACKEND_ROUTE_EXPORTS].join(", ")}`,
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// One merged module per (frontend namespace, backend namespace) pair. A runtime caches each module,
|
|
666
|
+
// so a page served many times merges once; a dev re-import yields new namespaces and a fresh merge.
|
|
667
|
+
const mergedHalves = new WeakMap<
|
|
668
|
+
object,
|
|
669
|
+
{ readonly back: object | undefined; readonly module: RouteModule }
|
|
670
|
+
>()
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Merge a route's two halves into the one {@link RouteModule} the server runs, refusing any export on
|
|
674
|
+
* the wrong side. The checks run on every server, dev and production alike, so a misplaced `loader`
|
|
675
|
+
* fails the first request to its route even before a client build has looked at it.
|
|
676
|
+
*/
|
|
677
|
+
export function mergeRouteHalves(
|
|
678
|
+
file: string,
|
|
679
|
+
front: RouteModule,
|
|
680
|
+
backend: string | undefined,
|
|
681
|
+
back: Readonly<Record<string, unknown>> | undefined,
|
|
682
|
+
): RouteModule {
|
|
683
|
+
const cached = mergedHalves.get(front)
|
|
684
|
+
if (cached !== undefined && cached.back === back) return cached.module
|
|
685
|
+
assertFrontendHalf(file, front as unknown as Readonly<Record<string, unknown>>)
|
|
686
|
+
if (back === undefined || backend === undefined) {
|
|
687
|
+
mergedHalves.set(front, { back, module: front })
|
|
688
|
+
return front
|
|
689
|
+
}
|
|
690
|
+
assertBackendHalf(file, backend, back, stripExt(baseName(file)) === "_layout")
|
|
691
|
+
const {
|
|
692
|
+
middleware: _middleware,
|
|
693
|
+
boundaryLoaders,
|
|
694
|
+
loader,
|
|
695
|
+
action,
|
|
696
|
+
loaderOutput,
|
|
697
|
+
actionOutput,
|
|
698
|
+
...server
|
|
699
|
+
} = back
|
|
700
|
+
const guarded: { loader?: unknown; action?: unknown } = {}
|
|
701
|
+
for (const [name, fn, schema, schemaName] of [
|
|
702
|
+
["loader", loader, loaderOutput, "loaderOutput"],
|
|
703
|
+
["action", action, actionOutput, "actionOutput"],
|
|
704
|
+
] as const) {
|
|
705
|
+
if (fn === undefined) {
|
|
706
|
+
if (schema !== undefined) {
|
|
707
|
+
throw routeError(`"${backend}" exports ${schemaName} but no ${name} for it to describe`)
|
|
708
|
+
}
|
|
709
|
+
continue
|
|
710
|
+
}
|
|
711
|
+
if (typeof fn !== "function")
|
|
712
|
+
throw routeError(`"${backend}" exports a ${name} that is not a function`)
|
|
713
|
+
guarded[name] = guardChannel(fn as (...args: unknown[]) => unknown, {
|
|
714
|
+
label: `the ${name} of "${file}"`,
|
|
715
|
+
guard: schema === undefined ? undefined : outputGuard(schema, `"${backend}"`, schemaName),
|
|
716
|
+
missing: `export const ${schemaName} = t.object({ ... }) from "${backend}"`,
|
|
717
|
+
revalidate: name === "action",
|
|
718
|
+
})
|
|
719
|
+
}
|
|
720
|
+
let boundaries = front.boundaries
|
|
721
|
+
if (boundaryLoaders !== undefined) {
|
|
722
|
+
if (typeof boundaryLoaders !== "object" || boundaryLoaders === null) {
|
|
723
|
+
throw routeError(`"${backend}" exports boundaryLoaders that is not an object of loaders`)
|
|
724
|
+
}
|
|
725
|
+
const loaders = boundaryLoaders as Readonly<
|
|
726
|
+
Record<string, { readonly load?: unknown; readonly output?: unknown }>
|
|
727
|
+
>
|
|
728
|
+
const declared = new Set((front.boundaries ?? []).map((boundary) => boundary.name))
|
|
729
|
+
for (const [name, entry] of Object.entries(loaders)) {
|
|
730
|
+
if (!declared.has(name)) {
|
|
731
|
+
throw routeError(
|
|
732
|
+
`"${backend}" has boundaryLoaders["${name}"], but "${file}" declares no boundary named "${name}"`,
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
if (typeof entry?.load !== "function") {
|
|
736
|
+
throw routeError(`boundaryLoaders["${name}"].load in "${backend}" must be a function`)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
boundaries = front.boundaries?.map((boundary) => {
|
|
740
|
+
const entry = loaders[boundary.name]
|
|
741
|
+
if (entry === undefined) return boundary
|
|
742
|
+
const key = `boundaryLoaders["${boundary.name}"]`
|
|
743
|
+
const load = guardChannel(entry.load as (...args: unknown[]) => unknown, {
|
|
744
|
+
label: `${key} of "${backend}"`,
|
|
745
|
+
guard:
|
|
746
|
+
entry.output === undefined
|
|
747
|
+
? undefined
|
|
748
|
+
: outputGuard(entry.output, `"${backend}"`, `${key}.output`),
|
|
749
|
+
missing: `${key}.output in "${backend}"`,
|
|
750
|
+
})
|
|
751
|
+
return { ...boundary, load } as BoundaryRegistration
|
|
752
|
+
})
|
|
753
|
+
}
|
|
754
|
+
const module = {
|
|
755
|
+
...front,
|
|
756
|
+
...server,
|
|
757
|
+
...guarded,
|
|
758
|
+
...(boundaries === undefined ? {} : { boundaries }),
|
|
759
|
+
} as RouteModule
|
|
760
|
+
mergedHalves.set(front, { back, module })
|
|
761
|
+
return module
|
|
762
|
+
}
|
|
360
763
|
const dirOf = (file: string): string =>
|
|
361
764
|
file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : ""
|
|
362
765
|
const layoutIdFor = (dir: string): string => (dir === "" ? "_layout" : `${dir}/_layout`)
|
|
363
766
|
const errorIdFor = (dir: string): string => (dir === "" ? "_error" : `${dir}/_error`)
|
|
767
|
+
const notFoundIdFor = (dir: string): string => `${dir}/_404`
|
|
768
|
+
const loadingIdFor = (dir: string): string => (dir === "" ? "_loading" : `${dir}/_loading`)
|
|
769
|
+
const middlewareIdFor = (dir: string): string => (dir === "" ? "_middleware" : `${dir}/_middleware`)
|
|
770
|
+
const isDirAtOrAbove = (dir: string, other: string): boolean =>
|
|
771
|
+
dir === "" || dir === other || other.startsWith(`${dir}/`)
|
|
364
772
|
|
|
365
773
|
/**
|
|
366
774
|
* Derive **every** nifra router pattern a route file maps to (relative to the routes dir):
|
|
@@ -386,6 +794,9 @@ const FILE_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
|
386
794
|
* `stripExt` already reduces `[inKey].txt.tsx` to `[inKey].txt` - `robots.txt.tsx` proves literal
|
|
387
795
|
* dots survive into a segment, so this was only ever a parser gap.
|
|
388
796
|
*
|
|
797
|
+
* A `{...}` group straight after a marker that the router would read as a param constraint
|
|
798
|
+
* (`[id]{a|b}`) is rejected too: see the check below.
|
|
799
|
+
*
|
|
389
800
|
* `[[optional]]` and `[...catchAll]` are **rejected** inside a mixed segment rather than given a
|
|
390
801
|
* meaning. There is no sensible absent form for `/[[locale]]-feed.xml` - `/-feed.xml` and dropping
|
|
391
802
|
* the segment are both surprising - and a catch-all captures the remaining path verbatim, which
|
|
@@ -421,6 +832,13 @@ function mixedFileSegment(seg: string, file: string): string | undefined {
|
|
|
421
832
|
}
|
|
422
833
|
pattern += `${seg.slice(cursor, marker.index)}:${inner}`
|
|
423
834
|
cursor = (marker.index ?? 0) + marker[0].length
|
|
835
|
+
// The router reads `:name{...}` as a constraint on the param. A file name is not the place for
|
|
836
|
+
// one: links, prerendered paths and hydration all rebuild a URL from the pattern by name.
|
|
837
|
+
if (paramConstraint(seg.slice(cursor)) !== undefined) {
|
|
838
|
+
throw new Error(
|
|
839
|
+
`[nifra/web] a param constraint cannot be written in a route file name: "${file}" (segment "${seg}"). Check the value in the route's loader instead, or rename the file so "[${inner}]" is not followed by a "{...}" group.`,
|
|
840
|
+
)
|
|
841
|
+
}
|
|
424
842
|
}
|
|
425
843
|
return pattern + seg.slice(cursor)
|
|
426
844
|
}
|
|
@@ -550,6 +968,14 @@ function paramsInPrefix(pattern: string, count: number): string[] {
|
|
|
550
968
|
return names
|
|
551
969
|
}
|
|
552
970
|
|
|
971
|
+
/** The params each directory in `dirs` owns on one expanded pattern - see {@link paramsInPrefix}. */
|
|
972
|
+
const paramsOwnedBy = (
|
|
973
|
+
dirs: readonly string[],
|
|
974
|
+
pattern: string,
|
|
975
|
+
depths: readonly number[],
|
|
976
|
+
): string[][] =>
|
|
977
|
+
dirs.map((dir) => paramsInPrefix(pattern, depths[dir === "" ? 0 : dir.split("/").length] ?? 0))
|
|
978
|
+
|
|
553
979
|
const ancestorDirs = (file: string): string[] => {
|
|
554
980
|
const dirs = [""]
|
|
555
981
|
const dir = dirOf(file)
|
|
@@ -565,8 +991,8 @@ const ancestorDirs = (file: string): string[] => {
|
|
|
565
991
|
/**
|
|
566
992
|
* Build a manifest from route file paths (relative to the routes dir) + an `importer` that
|
|
567
993
|
* turns a path into a lazy module loader. Pure - no fs. Throws at boot (the loud-and-early
|
|
568
|
-
* RouteConfigError ethos) on duplicate patterns. `_layout`/`_404`/`_error`
|
|
569
|
-
* `_`-prefixed files are ignored (private/colocated, never routed).
|
|
994
|
+
* RouteConfigError ethos) on duplicate patterns. `_layout`/`_404`/`_error`/`_loading`/`_middleware`
|
|
995
|
+
* files are special; other `_`-prefixed files are ignored (private/colocated, never routed).
|
|
570
996
|
*/
|
|
571
997
|
export function buildManifest(
|
|
572
998
|
files: readonly string[],
|
|
@@ -577,26 +1003,105 @@ export function buildManifest(
|
|
|
577
1003
|
const errorDirs = new Set<string>()
|
|
578
1004
|
const errors: Record<string, LayoutEntry> = {}
|
|
579
1005
|
let notFound: LayoutEntry | undefined
|
|
1006
|
+
const notFoundFiles = new Map<string, string>()
|
|
580
1007
|
const statusPages: Record<string, LayoutEntry> = {}
|
|
1008
|
+
const loadingFiles = new Map<string, string>()
|
|
1009
|
+
const middlewareFiles = new Map<string, string>()
|
|
581
1010
|
const routeFiles: string[] = []
|
|
582
1011
|
|
|
1012
|
+
// Pair each frontend file with its `x.backend.ts`. A backend half is never a route of its own.
|
|
1013
|
+
const backendByStem = new Map<string, string>()
|
|
1014
|
+
const frontendFiles: string[] = []
|
|
583
1015
|
for (const file of files) {
|
|
1016
|
+
if (isMiddlewareFile(file)) {
|
|
1017
|
+
throw routeError(
|
|
1018
|
+
`"${file}" is retired: export \`middleware\` from "${dirOf(file) === "" ? "" : `${dirOf(file)}/`}_layout.backend.ts" instead (\`nifra migrate layout\` moves it)`,
|
|
1019
|
+
)
|
|
1020
|
+
}
|
|
1021
|
+
if (!isRouteBackendFile(file)) {
|
|
1022
|
+
frontendFiles.push(file)
|
|
1023
|
+
continue
|
|
1024
|
+
}
|
|
1025
|
+
const stem = file.replace(ROUTE_BACKEND, "")
|
|
1026
|
+
const other = backendByStem.get(stem)
|
|
1027
|
+
if (other !== undefined) {
|
|
1028
|
+
throw routeError(`two backend halves for one route: "${other}" and "${file}"; keep one`)
|
|
1029
|
+
}
|
|
1030
|
+
backendByStem.set(stem, file)
|
|
1031
|
+
}
|
|
1032
|
+
const pairedBackends = new Set<string>()
|
|
1033
|
+
const frontendStems = new Map<string, string>()
|
|
1034
|
+
const halves = (
|
|
1035
|
+
file: string,
|
|
1036
|
+
): { readonly backend?: string; readonly load: () => Promise<RouteModule> } => {
|
|
1037
|
+
const backend = backendByStem.get(stripExt(file))
|
|
1038
|
+
const loadFront = importer(file)
|
|
1039
|
+
if (backend === undefined) {
|
|
1040
|
+
return {
|
|
1041
|
+
load: () =>
|
|
1042
|
+
loadFront().then((front) => mergeRouteHalves(file, front, undefined, undefined)),
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
pairedBackends.add(backend)
|
|
1046
|
+
const loadBack = importer(backend)
|
|
1047
|
+
return {
|
|
1048
|
+
backend,
|
|
1049
|
+
load: () =>
|
|
1050
|
+
Promise.all([loadFront(), loadBack()]).then(([front, back]) =>
|
|
1051
|
+
mergeRouteHalves(
|
|
1052
|
+
file,
|
|
1053
|
+
front,
|
|
1054
|
+
backend,
|
|
1055
|
+
back as unknown as Readonly<Record<string, unknown>>,
|
|
1056
|
+
),
|
|
1057
|
+
),
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Every `_layout.backend.ts` is a middleware candidate for its directory, with or without a
|
|
1062
|
+
// frontend layout beside it; one exporting no `middleware` resolves to `undefined` and is skipped.
|
|
1063
|
+
for (const [stem, backend] of backendByStem) {
|
|
1064
|
+
if (baseName(stem) !== "_layout") continue
|
|
1065
|
+
middlewareFiles.set(dirOf(backend), backend)
|
|
1066
|
+
pairedBackends.add(backend)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
for (const file of frontendFiles) {
|
|
584
1070
|
const stem = stripExt(baseName(file))
|
|
1071
|
+
const twin = frontendStems.get(stripExt(file))
|
|
1072
|
+
if (twin !== undefined) {
|
|
1073
|
+
throw routeError(`two frontend files for one route: "${twin}" and "${file}"; keep one`)
|
|
1074
|
+
}
|
|
1075
|
+
frontendStems.set(stripExt(file), file)
|
|
1076
|
+
if (SUFFIXED_ROUTE.test(stem)) {
|
|
1077
|
+
throw routeError(
|
|
1078
|
+
`"${file}" is a route file with a zone suffix. A route's backend half is a script file ("${stem}.ts"), and other zoned code belongs outside routes/`,
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
if (stem === "_middleware") {
|
|
1082
|
+
throw routeError(
|
|
1083
|
+
`"${file}" is retired: export \`middleware\` from "${dirOf(file) === "" ? "" : `${dirOf(file)}/`}_layout.backend.ts" instead (\`nifra migrate layout\` moves it)`,
|
|
1084
|
+
)
|
|
1085
|
+
}
|
|
585
1086
|
if (stem === "_layout") {
|
|
586
1087
|
const dir = dirOf(file)
|
|
587
1088
|
layoutDirs.add(dir)
|
|
588
|
-
layouts[layoutIdFor(dir)] = { file,
|
|
1089
|
+
layouts[layoutIdFor(dir)] = { file, ...halves(file) }
|
|
589
1090
|
} else if (stem === "_error") {
|
|
590
1091
|
const dir = dirOf(file)
|
|
591
1092
|
errorDirs.add(dir)
|
|
592
|
-
errors[errorIdFor(dir)] = { file,
|
|
1093
|
+
errors[errorIdFor(dir)] = { file, ...halves(file) }
|
|
593
1094
|
} else if (stem === "_404") {
|
|
594
|
-
|
|
1095
|
+
const dir = dirOf(file)
|
|
1096
|
+
notFoundFiles.set(dir, file)
|
|
1097
|
+
if (dir === "") notFound = { file, ...halves(file) }
|
|
1098
|
+
} else if (stem === "_loading") {
|
|
1099
|
+
loadingFiles.set(dirOf(file), file)
|
|
595
1100
|
} else if (STATUS_PAGE.test(stem) && dirOf(file) === "") {
|
|
596
1101
|
// `_410.tsx`, `_451.tsx`, … at the routes root. Root-only: unlike `_error`, these are not
|
|
597
1102
|
// resolved per segment - a terminal status is a property of the outcome, not of where in the
|
|
598
1103
|
// tree the route lives, and a per-segment variant would need a precedence rule nobody asked for.
|
|
599
|
-
statusPages[stem.slice(1)] = { file,
|
|
1104
|
+
statusPages[stem.slice(1)] = { file, ...halves(file) }
|
|
600
1105
|
} else if (!stem.startsWith("_")) {
|
|
601
1106
|
routeFiles.push(file)
|
|
602
1107
|
}
|
|
@@ -609,8 +1114,15 @@ export function buildManifest(
|
|
|
609
1114
|
const layoutDirsForFile = dirs.filter((dir) => layoutDirs.has(dir))
|
|
610
1115
|
const layoutIds = layoutDirsForFile.map(layoutIdFor)
|
|
611
1116
|
const errorIds = dirs.filter((dir) => errorDirs.has(dir)).map(errorIdFor)
|
|
1117
|
+
const notFoundIds = dirs
|
|
1118
|
+
.filter((dir) => dir !== "" && notFoundFiles.has(dir))
|
|
1119
|
+
.map(notFoundIdFor)
|
|
1120
|
+
const loadingIds = dirs.filter((dir) => loadingFiles.has(dir)).map(loadingIdFor)
|
|
1121
|
+
const middlewareDirsForFile = dirs.filter((dir) => middlewareFiles.has(dir))
|
|
1122
|
+
const middlewareIds = middlewareDirsForFile.map(middlewareIdFor)
|
|
612
1123
|
const id = stripExt(file)
|
|
613
|
-
|
|
1124
|
+
// One lazy loader per file, shared by its (possibly expanded) patterns.
|
|
1125
|
+
const { backend, load } = halves(file)
|
|
614
1126
|
// An optional `[[x]]` segment expands a file into multiple patterns, all pointing at the same
|
|
615
1127
|
// module (same id/load/layout chain). Distinct patterns ⇒ no match ambiguity (different lengths).
|
|
616
1128
|
for (const { pattern, depths } of filePathToRoutes(file)) {
|
|
@@ -620,19 +1132,161 @@ export function buildManifest(
|
|
|
620
1132
|
`[nifra/web] duplicate route: "${file}" and "${existing}" both map to "${pattern}"`,
|
|
621
1133
|
)
|
|
622
1134
|
}
|
|
1135
|
+
for (const previous of routes) {
|
|
1136
|
+
const witness = routePatternOverlap(previous.pattern, pattern)
|
|
1137
|
+
if (witness !== undefined) {
|
|
1138
|
+
throw new Error(
|
|
1139
|
+
`[nifra/web] overlapping routes: "${previous.file}" (${previous.pattern}) and "${file}" (${pattern}) both match "${witness}"; make the patterns disjoint or remove one route`,
|
|
1140
|
+
)
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
623
1143
|
byPattern.set(pattern, file)
|
|
624
|
-
|
|
625
|
-
|
|
1144
|
+
routes.push({
|
|
1145
|
+
id,
|
|
1146
|
+
pattern,
|
|
1147
|
+
layoutIds,
|
|
1148
|
+
layoutParams: paramsOwnedBy(layoutDirsForFile, pattern, depths),
|
|
1149
|
+
...(middlewareIds.length > 0
|
|
1150
|
+
? {
|
|
1151
|
+
middlewareIds,
|
|
1152
|
+
middlewareParams: paramsOwnedBy(middlewareDirsForFile, pattern, depths),
|
|
1153
|
+
}
|
|
1154
|
+
: {}),
|
|
1155
|
+
errorIds,
|
|
1156
|
+
...(notFoundIds.length > 0 ? { notFoundIds } : {}),
|
|
1157
|
+
...(loadingIds.length > 0 ? { loadingIds } : {}),
|
|
1158
|
+
file,
|
|
1159
|
+
...(backend === undefined ? {} : { backend }),
|
|
1160
|
+
load,
|
|
1161
|
+
})
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Nested `_404` pages. Each answers the unmatched URLs under its directory's URL prefix; when two
|
|
1166
|
+
// directories share a prefix (a route group adds no segment), the one that contains the others
|
|
1167
|
+
// answers, and with no such directory the choice would be arbitrary - so it is a boot error.
|
|
1168
|
+
const scopeOwners = new Map<string, string[]>()
|
|
1169
|
+
const scopesByDir = new Map<string, Array<NotFoundScope & { readonly shape: string }>>()
|
|
1170
|
+
for (const dir of notFoundFiles.keys()) {
|
|
1171
|
+
const layoutDirsForDir = ancestorDirs(`${dir}/_404`).filter((d) => layoutDirs.has(d))
|
|
1172
|
+
const middlewareDirsForDir = ancestorDirs(`${dir}/_404`).filter((d) => middlewareFiles.has(d))
|
|
1173
|
+
const scopes: Array<NotFoundScope & { readonly shape: string }> = []
|
|
1174
|
+
const forms =
|
|
1175
|
+
dir === "" ? [{ pattern: "/", depths: [0] }] : filePathToRoutes(`${dir}/index.tsx`)
|
|
1176
|
+
for (const { pattern: prefix, depths } of forms) {
|
|
1177
|
+
// A catch-all directory already matches everything beneath it: nothing there is unmatched.
|
|
1178
|
+
if (/(?:^|\/)\*[^/]*$/.test(prefix)) continue
|
|
1179
|
+
const layoutParams = paramsOwnedBy(layoutDirsForDir, prefix, depths)
|
|
1180
|
+
const middlewareParams =
|
|
1181
|
+
middlewareDirsForDir.length === 0
|
|
1182
|
+
? undefined
|
|
1183
|
+
: paramsOwnedBy(middlewareDirsForDir, prefix, depths)
|
|
1184
|
+
for (const pattern of prefix === "/" ? ["/*"] : [prefix, `${prefix}/*`]) {
|
|
1185
|
+
// Two directories answer the same URLs when their patterns differ only in param names.
|
|
1186
|
+
const shape = pattern.replace(/([:*])[A-Za-z_][A-Za-z0-9_]*/g, "$1")
|
|
1187
|
+
if (scopes.some((scope) => scope.shape === shape)) continue
|
|
1188
|
+
scopes.push({
|
|
1189
|
+
pattern,
|
|
1190
|
+
layoutParams,
|
|
1191
|
+
...(middlewareParams === undefined ? {} : { middlewareParams }),
|
|
1192
|
+
shape,
|
|
1193
|
+
})
|
|
1194
|
+
const owners = scopeOwners.get(shape)
|
|
1195
|
+
if (owners === undefined) scopeOwners.set(shape, [dir])
|
|
1196
|
+
else owners.push(dir)
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
scopesByDir.set(dir, scopes)
|
|
1200
|
+
}
|
|
1201
|
+
const ownerOf = new Map<string, string>()
|
|
1202
|
+
for (const [shape, dirs] of scopeOwners) {
|
|
1203
|
+
const owner = dirs.find((dir) => dirs.every((other) => isDirAtOrAbove(dir, other)))
|
|
1204
|
+
if (owner === undefined) {
|
|
1205
|
+
const [first, second] = dirs as [string, string]
|
|
1206
|
+
throw new Error(
|
|
1207
|
+
`[nifra/web] ambiguous _404: "${notFoundFiles.get(first)}" and "${notFoundFiles.get(second)}" both answer unmatched URLs under "${shape.replace(/\/?\*$/, "") || "/"}"; keep one, or add a _404 in a directory that contains both`,
|
|
626
1208
|
)
|
|
627
|
-
routes.push({ id, pattern, layoutIds, layoutParams, errorIds, file, load })
|
|
628
1209
|
}
|
|
1210
|
+
ownerOf.set(shape, owner)
|
|
1211
|
+
}
|
|
1212
|
+
const notFounds: Record<string, NotFoundEntry> = {}
|
|
1213
|
+
for (const [dir, file] of notFoundFiles) {
|
|
1214
|
+
if (dir === "") continue
|
|
1215
|
+
const dirs = ancestorDirs(file)
|
|
1216
|
+
const middlewareIds = dirs.filter((d) => middlewareFiles.has(d)).map(middlewareIdFor)
|
|
1217
|
+
notFounds[notFoundIdFor(dir)] = {
|
|
1218
|
+
file,
|
|
1219
|
+
...halves(file),
|
|
1220
|
+
layoutIds: dirs.filter((d) => layoutDirs.has(d)).map(layoutIdFor),
|
|
1221
|
+
errorIds: dirs.filter((d) => errorDirs.has(d)).map(errorIdFor),
|
|
1222
|
+
...(middlewareIds.length > 0 ? { middlewareIds } : {}),
|
|
1223
|
+
scopes: (scopesByDir.get(dir) ?? [])
|
|
1224
|
+
.filter((scope) => ownerOf.get(scope.shape) === dir)
|
|
1225
|
+
.map(({ pattern, layoutParams, middlewareParams }) => ({
|
|
1226
|
+
pattern,
|
|
1227
|
+
layoutParams,
|
|
1228
|
+
...(middlewareParams === undefined ? {} : { middlewareParams }),
|
|
1229
|
+
})),
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const loadings: Record<string, LoadingEntry> = {}
|
|
1234
|
+
for (const [dir, file] of loadingFiles) {
|
|
1235
|
+
const backend = backendByStem.get(stripExt(file))
|
|
1236
|
+
if (backend !== undefined) {
|
|
1237
|
+
throw routeError(
|
|
1238
|
+
`"${backend}" pairs with a _loading page, which renders in the browser only and has no backend half`,
|
|
1239
|
+
)
|
|
1240
|
+
}
|
|
1241
|
+
loadings[loadingIdFor(dir)] = {
|
|
1242
|
+
file,
|
|
1243
|
+
load: () =>
|
|
1244
|
+
importer(file)().then((front) => mergeRouteHalves(file, front, undefined, undefined)),
|
|
1245
|
+
layoutIds: ancestorDirs(file)
|
|
1246
|
+
.filter((d) => layoutDirs.has(d))
|
|
1247
|
+
.map(layoutIdFor),
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const middlewares: Record<string, LayoutEntry> = {}
|
|
1252
|
+
for (const [dir, backend] of middlewareFiles) {
|
|
1253
|
+
const loadBack = importer(backend)
|
|
1254
|
+
const layout = layoutDirs.has(dir) ? layouts[layoutIdFor(dir)]?.file : undefined
|
|
1255
|
+
middlewares[middlewareIdFor(dir)] = {
|
|
1256
|
+
file: backend,
|
|
1257
|
+
load: () =>
|
|
1258
|
+
loadBack().then((module) => {
|
|
1259
|
+
const back = module as unknown as Readonly<Record<string, unknown>>
|
|
1260
|
+
// Without a frontend layout there is no layout data to load: middleware is all it may hold.
|
|
1261
|
+
if (layout === undefined) {
|
|
1262
|
+
for (const name of Object.keys(back)) {
|
|
1263
|
+
if (name !== "middleware") {
|
|
1264
|
+
throw routeError(
|
|
1265
|
+
`"${backend}" exports "${name}", but its directory has no _layout frontend file, so it may export only middleware`,
|
|
1266
|
+
)
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return { default: back.middleware } as RouteModule
|
|
1271
|
+
}),
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
for (const backend of backendByStem.values()) {
|
|
1276
|
+
if (pairedBackends.has(backend)) continue
|
|
1277
|
+
throw routeError(
|
|
1278
|
+
`"${backend}" has no frontend half. A backend half pairs with the file that renders its page ("${backend.replace(ROUTE_BACKEND, "")}.tsx" or another route extension); an endpoint with no page is a route in backend/app.ts`,
|
|
1279
|
+
)
|
|
629
1280
|
}
|
|
630
1281
|
|
|
631
1282
|
const base: Manifest = {
|
|
632
1283
|
routes,
|
|
633
1284
|
layouts,
|
|
634
1285
|
errors,
|
|
1286
|
+
...(Object.keys(notFounds).length > 0 ? { notFounds } : {}),
|
|
635
1287
|
...(Object.keys(statusPages).length > 0 ? { statusPages } : {}),
|
|
1288
|
+
...(Object.keys(loadings).length > 0 ? { loadings } : {}),
|
|
1289
|
+
...(Object.keys(middlewares).length > 0 ? { middlewares } : {}),
|
|
636
1290
|
}
|
|
637
1291
|
return notFound === undefined ? base : { ...base, notFound }
|
|
638
1292
|
}
|