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