@nifrajs/web 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +433 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +174 -65
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +376 -135
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +209 -27
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-error.d.ts +2 -2
- package/dist/dev-error.js +3 -3
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +62 -5
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +44 -7
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +92 -9
- package/dist/draft.js.map +1 -1
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +15 -2
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +8 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +266 -74
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +869 -179
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +8 -21
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +121 -40
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +136 -26
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -0
- package/dist/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- package/dist/plugins/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +94 -22
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +170 -58
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +25 -17
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -33
- package/dist/vite.js.map +1 -1
- package/package.json +48 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +553 -0
- package/src/build.ts +563 -172
- package/src/bun-dev-entry.ts +184 -0
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +221 -30
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-error.ts +3 -3
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +468 -66
- package/src/draft.ts +124 -12
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +24 -6
- package/src/index.ts +1174 -225
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +18 -30
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +264 -48
- package/src/module-graph.ts +146 -0
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/public-dir.ts +108 -0
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +288 -74
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +103 -49
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @nifrajs/web
|
|
2
|
+
* @nifrajs/web - the framework-agnostic SSR core. It owns the render *seam* and the HTML
|
|
3
3
|
* document orchestration, and knows nothing about any specific UI framework: a render
|
|
4
4
|
* adapter (@nifrajs/web-solid, @nifrajs/web-react, …) supplies the actual render + hydrate.
|
|
5
5
|
*
|
|
6
|
-
* The core treats a "component" and a hydration "container" as opaque `unknown`
|
|
6
|
+
* The core treats a "component" and a hydration "container" as opaque `unknown` - only
|
|
7
7
|
* the adapter interprets them. That keeps this package both framework-agnostic and free
|
|
8
8
|
* of DOM types: it is pure server orchestration + string building.
|
|
9
9
|
*/
|
|
@@ -24,28 +24,39 @@ import {
|
|
|
24
24
|
prepareDeferred,
|
|
25
25
|
} from "./deferred.ts"
|
|
26
26
|
import { isDraftEnabled } from "./draft.ts"
|
|
27
|
+
import { trustedHeadAttributes } from "./internal/head-attributes.ts"
|
|
28
|
+
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
|
|
27
29
|
import { ISR_REVALIDATE_HEADER } from "./isr.ts"
|
|
28
30
|
import { generateLlmsTxt } from "./llms-txt.ts"
|
|
29
31
|
import type {
|
|
30
32
|
LayoutEntry,
|
|
33
|
+
Loader as LayoutLoader,
|
|
31
34
|
LinkDescriptor,
|
|
35
|
+
LoaderContext,
|
|
32
36
|
Manifest,
|
|
33
37
|
Meta,
|
|
34
38
|
MetaArgs,
|
|
39
|
+
MetaDescriptor,
|
|
35
40
|
MetaInput,
|
|
36
41
|
RouteEntry,
|
|
37
42
|
RouteModule,
|
|
38
43
|
ScriptDescriptor,
|
|
44
|
+
UnsafeScriptDescriptor,
|
|
39
45
|
} from "./manifest.ts"
|
|
40
46
|
import {
|
|
47
|
+
createMatcher,
|
|
41
48
|
DATA_HEADER,
|
|
49
|
+
NAV_FROM_HEADER,
|
|
42
50
|
PRERENDERED_GLOBAL,
|
|
43
51
|
REDIRECT_HEADER,
|
|
52
|
+
RETAIN_HEADER,
|
|
44
53
|
REVALIDATE_HEADER,
|
|
54
|
+
STATUS_HEADER,
|
|
45
55
|
type Submission,
|
|
46
56
|
} from "./router.ts"
|
|
57
|
+
import { searchOf, searchOfChain } from "./search.ts"
|
|
47
58
|
|
|
48
|
-
// Draft / preview mode
|
|
59
|
+
// Draft / preview mode - a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
|
|
49
60
|
export {
|
|
50
61
|
DRAFT_COOKIE,
|
|
51
62
|
type DraftCookieControls,
|
|
@@ -53,8 +64,10 @@ export {
|
|
|
53
64
|
type EnableDraftOptions,
|
|
54
65
|
enableDraft,
|
|
55
66
|
isDraftEnabled,
|
|
67
|
+
type PreviewEndpointOptions,
|
|
68
|
+
previewEndpoint,
|
|
56
69
|
} from "./draft.ts"
|
|
57
|
-
// Font optimization
|
|
70
|
+
// Font optimization - a CLS-safe `@font-face` generator + a preload `<link>` for self-hosted fonts.
|
|
58
71
|
export {
|
|
59
72
|
type FontDisplay,
|
|
60
73
|
type FontFace,
|
|
@@ -82,8 +95,7 @@ export {
|
|
|
82
95
|
revalidateEndpoint,
|
|
83
96
|
withISR,
|
|
84
97
|
} from "./isr.ts"
|
|
85
|
-
|
|
86
|
-
// File-based routing manifest — pure + fs-free. `discoverRoutes` (fs) lives in `@nifrajs/web/fs`.
|
|
98
|
+
// File-based routing manifest - pure + fs-free. `discoverRoutes` (fs) lives in `@nifrajs/web/fs`.
|
|
87
99
|
export {
|
|
88
100
|
type Action,
|
|
89
101
|
buildManifest,
|
|
@@ -91,6 +103,7 @@ export {
|
|
|
91
103
|
filePathToPattern,
|
|
92
104
|
filePathToPatterns,
|
|
93
105
|
type GetStaticPaths,
|
|
106
|
+
type InertScriptType,
|
|
94
107
|
type LayoutEntry,
|
|
95
108
|
type LinkDescriptor,
|
|
96
109
|
type Loader,
|
|
@@ -105,16 +118,37 @@ export {
|
|
|
105
118
|
type StaticPath,
|
|
106
119
|
type StaticPaths,
|
|
107
120
|
type StaticRoutes,
|
|
121
|
+
type UnsafeScriptDescriptor,
|
|
108
122
|
} from "./manifest.ts"
|
|
109
|
-
// Navigation bridge
|
|
123
|
+
// Navigation bridge - a DOM-free seam the browser layer (`installHistory`) populates so an adapter's
|
|
110
124
|
// `useNavigate` (a route component, importing only this agnostic entry) reaches history-aware nav.
|
|
111
125
|
export {
|
|
126
|
+
type Blocker,
|
|
127
|
+
type BlockerController,
|
|
128
|
+
type BlockerFunction,
|
|
129
|
+
type BlockerLocation,
|
|
130
|
+
type BlockerState,
|
|
112
131
|
type BrowserNavigate,
|
|
113
132
|
getBrowserNavigate,
|
|
133
|
+
IDLE_BLOCKER,
|
|
134
|
+
type NavigateFunction,
|
|
114
135
|
type NavigateOptions,
|
|
136
|
+
type NavigateSearchOf,
|
|
137
|
+
type NavigateTargetInput,
|
|
138
|
+
type RouteSearch,
|
|
139
|
+
registerBlocker,
|
|
140
|
+
resolveNavigate,
|
|
141
|
+
setBlockerController,
|
|
115
142
|
setBrowserNavigate,
|
|
116
143
|
} from "./navigation.ts"
|
|
117
|
-
//
|
|
144
|
+
// public/ - user-authored static files, served identically in dev and production by one handler.
|
|
145
|
+
export {
|
|
146
|
+
type PublicDirCache,
|
|
147
|
+
resolvePublicPath,
|
|
148
|
+
type ServePublicDirOptions,
|
|
149
|
+
servePublicDir,
|
|
150
|
+
} from "./public-dir.ts"
|
|
151
|
+
// Keyed query-cache (agnostic) - a `query(key, fn)` primitive (dedup + staleness + invalidation + GC)
|
|
118
152
|
// consumed by the per-adapter `useQuery`/`createQuery` bindings.
|
|
119
153
|
export {
|
|
120
154
|
createMutation,
|
|
@@ -135,7 +169,7 @@ export {
|
|
|
135
169
|
type QueryState,
|
|
136
170
|
type QueryStatus,
|
|
137
171
|
} from "./query.ts"
|
|
138
|
-
// Agnostic client-side router core (pure + DOM-free)
|
|
172
|
+
// Agnostic client-side router core (pure + DOM-free) - consumed by per-adapter Router bindings.
|
|
139
173
|
// `DATA_HEADER` marks a navigation's data-only GET; `createWebApp` answers it with loader JSON.
|
|
140
174
|
export {
|
|
141
175
|
type ClientRouter,
|
|
@@ -147,35 +181,38 @@ export {
|
|
|
147
181
|
type FetcherState,
|
|
148
182
|
type FetchRouteData,
|
|
149
183
|
type MountRouterOptions,
|
|
184
|
+
NAV_FROM_HEADER,
|
|
150
185
|
REDIRECT_HEADER,
|
|
186
|
+
RETAIN_HEADER,
|
|
151
187
|
REVALIDATE_HEADER,
|
|
152
188
|
type RouteMatch,
|
|
153
189
|
type RoutePattern,
|
|
154
190
|
type RouterState,
|
|
191
|
+
STATUS_HEADER,
|
|
155
192
|
type Submission,
|
|
156
193
|
type SubmitOptions,
|
|
157
194
|
} from "./router.ts"
|
|
158
|
-
// Deferred loader data (`defer()` + the `Deferred<T>` type)
|
|
195
|
+
// Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
|
|
159
196
|
export { type Deferred, defer }
|
|
160
197
|
|
|
161
198
|
/** Phantom brand key for {@link ServerOnly}. A unique symbol so the brand can't be forged by a plain
|
|
162
199
|
* object literal; `unique symbol` keys never appear in the emitted JS, so the brand is purely
|
|
163
200
|
* type-level (zero runtime cost). The property is optional + `never`-typed, so a `ServerOnly<T>`
|
|
164
|
-
* stays assignment-compatible with `T`
|
|
201
|
+
* stays assignment-compatible with `T` - it documents intent without obstructing real use. */
|
|
165
202
|
declare const SERVER_ONLY_BRAND: unique symbol
|
|
166
203
|
|
|
167
204
|
/**
|
|
168
|
-
* Type-level intent marker for a value that must only exist on the server
|
|
205
|
+
* Type-level intent marker for a value that must only exist on the server - a secret, a DB handle, a
|
|
169
206
|
* server-only client. `ServerOnly<T>` is structurally `T` (the brand is an optional phantom field, so
|
|
170
207
|
* existing code keeps type-checking), but it advertises to readers + the compiler that the value is
|
|
171
|
-
* not meant to cross to the browser. It is **purely type-level** and erases at build
|
|
208
|
+
* not meant to cross to the browser. It is **purely type-level** and erases at build - it does NOT,
|
|
172
209
|
* by itself, keep the value out of the client bundle.
|
|
173
210
|
*
|
|
174
211
|
* The enforcement is two runtime conventions:
|
|
175
|
-
* - add the side-effect import `import "@nifrajs/web/server-only"` at the top of the module
|
|
212
|
+
* - add the side-effect import `import "@nifrajs/web/server-only"` at the top of the module - the
|
|
176
213
|
* client build ({@link buildClient}) fails loud, with the import chain, if it reaches a browser
|
|
177
214
|
* chunk (the poison-import marker);
|
|
178
|
-
* - or name the file `*.server.ts`
|
|
215
|
+
* - or name the file `*.server.ts` - the `.server` convention empties it in the client build.
|
|
179
216
|
*
|
|
180
217
|
* Use this brand to express the intent in the types; pair it with one of those runtime markers so a
|
|
181
218
|
* leak is caught at build time rather than shipping a secret to the client.
|
|
@@ -193,11 +230,25 @@ export {
|
|
|
193
230
|
type RenderAdapterConformanceFixture,
|
|
194
231
|
} from "./conformance.ts"
|
|
195
232
|
|
|
233
|
+
// The search-derivation primitive: parse a URL query, validate it against a route's `searchSchema`
|
|
234
|
+
// (fail-closed to defaults), or return the raw parsed query when there is none. Both the server (loader
|
|
235
|
+
// ctx + `renderPage`) and a client adapter mount call this with the same URL + schema, so the two sides
|
|
236
|
+
// produce the identical value by construction. An adapter's `useSearch` binding reads its result.
|
|
237
|
+
export { type SearchOf, searchOf, searchOfChain, serializeSearch } from "./search.ts"
|
|
238
|
+
|
|
196
239
|
/** The data handed to a route component. Opaque to the core. `actionData` is the return of a
|
|
197
240
|
* route `action` after a POST (absent on plain GETs). `pending` + `submission` are client-only
|
|
198
|
-
* (absent on SSR): they drive **optimistic UI**
|
|
241
|
+
* (absent on SSR): they drive **optimistic UI** - render from `submission.formData` while `pending`. */
|
|
199
242
|
export interface RenderProps {
|
|
200
243
|
readonly data: unknown
|
|
244
|
+
/**
|
|
245
|
+
* Per-layout loader data, aligned index-for-index with the layout prefix of `chain`.
|
|
246
|
+
*
|
|
247
|
+
* Absent when no layout in the chain exports a `loader`, so a page-only app is byte-identical to
|
|
248
|
+
* before. An adapter that does not read it renders exactly as it did; one that does passes
|
|
249
|
+
* `layoutData[i]` to `chain[i]`.
|
|
250
|
+
*/
|
|
251
|
+
readonly layoutData?: readonly unknown[]
|
|
201
252
|
readonly actionData?: unknown
|
|
202
253
|
/** True while a client navigation or submit is in flight (client-only; absent/false on SSR). */
|
|
203
254
|
readonly pending?: boolean
|
|
@@ -210,10 +261,15 @@ export interface RenderProps {
|
|
|
210
261
|
* SSR (from the request match) and on client navigation (from router state) so an adapter's
|
|
211
262
|
* `useParams` reads the same value on both, with no hydration mismatch. Absent ⇒ no params (`{}`). */
|
|
212
263
|
readonly params?: Readonly<Record<string, string>>
|
|
213
|
-
/** The current URL's `pathname + search` (no hash
|
|
264
|
+
/** The current URL's `pathname + search` (no hash - the hash never reaches the server). Threaded on
|
|
214
265
|
* SSR and client alike so an adapter's `useLocation`/`useSearchParams` hydrate consistently. Absent
|
|
215
266
|
* on adapters/callers that don't supply it (treated as `""`). */
|
|
216
267
|
readonly path?: string
|
|
268
|
+
/** The route's typed, validated search params (the value the loader also gets as `ctx.search`),
|
|
269
|
+
* threaded to the adapter's `useSearch`. Derived identically on SSR (from the request) and on client
|
|
270
|
+
* navigation (adapter mount) via the shared `searchOf`, so a component reading it hydrates with no
|
|
271
|
+
* drift. Absent ⇒ no search context (`{}`). */
|
|
272
|
+
readonly search?: Record<string, unknown>
|
|
217
273
|
}
|
|
218
274
|
|
|
219
275
|
/**
|
|
@@ -227,7 +283,7 @@ export interface RenderAdapter {
|
|
|
227
283
|
* (the loader data); each layout wraps the child via its `children`. Returns (or resolves to)
|
|
228
284
|
* a Web `ReadableStream<Uint8Array>`: a streaming renderer flushes the shell first and streams
|
|
229
285
|
* Suspense boundaries as they resolve; a non-streaming renderer may return a one-chunk stream.
|
|
230
|
-
* May be async
|
|
286
|
+
* May be async - e.g. React resolves once the shell is renderable, so a shell-render throw can
|
|
231
287
|
* still map to an error status before any byte is sent.
|
|
232
288
|
*/
|
|
233
289
|
renderToStream(
|
|
@@ -237,10 +293,10 @@ export interface RenderAdapter {
|
|
|
237
293
|
/**
|
|
238
294
|
* Server (optional): render the chain to a complete HTML **string** in one pass, with the same
|
|
239
295
|
* hydration markers `renderToStream` produces. When provided, `renderPage` uses it for any page
|
|
240
|
-
* that does **not** `defer()`
|
|
296
|
+
* that does **not** `defer()` - buffering the document is faster than the streaming pipeline there,
|
|
241
297
|
* because the framework's synchronous renderer (React `renderToString`, Solid `renderToString`,
|
|
242
298
|
* Preact `render`, Vue `renderToString`, Svelte `render`) is markedly cheaper than its streaming
|
|
243
|
-
* renderer
|
|
299
|
+
* renderer - most visibly on Bun, where React Fizz / Solid `renderToStream` are the heaviest. Pages
|
|
244
300
|
* that `defer()` keep the streaming path (progressive `<Await>` needs it). Markup MUST be
|
|
245
301
|
* hydration-equivalent to `renderToStream`; a throw surfaces like the streaming shell-readiness
|
|
246
302
|
* await (the `_error` boundary maps it to a status). Omit it to always stream.
|
|
@@ -250,11 +306,14 @@ export interface RenderAdapter {
|
|
|
250
306
|
* Server: per-document bootstrap markup injected into `<head>` that the client
|
|
251
307
|
* `hydrate` requires (Solid: `generateHydrationScript()`). Empty string if none.
|
|
252
308
|
*/
|
|
253
|
-
hydrationHead(): string
|
|
309
|
+
hydrationHead(nonce?: string): string
|
|
254
310
|
}
|
|
255
311
|
|
|
256
312
|
/** Global the server serializes loader data into; the client reads it to hydrate. */
|
|
257
313
|
export const DATA_GLOBAL = "__NIFRA_DATA__"
|
|
314
|
+
/** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
|
|
315
|
+
* a page-only app's HTML is byte-identical to before layout loaders existed. */
|
|
316
|
+
export const LAYOUT_DATA_GLOBAL = "__NIFRA_LAYOUT_DATA__"
|
|
258
317
|
|
|
259
318
|
/** Global the server writes the matched route id into; the client uses it to pick the chain. */
|
|
260
319
|
export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
|
|
@@ -264,12 +323,25 @@ export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
|
|
|
264
323
|
export const ACTION_GLOBAL = "__NIFRA_ACTION__"
|
|
265
324
|
|
|
266
325
|
/**
|
|
267
|
-
*
|
|
326
|
+
* Marker attribute on the app container, emitted ONLY when `rootId` is not the default.
|
|
327
|
+
*
|
|
328
|
+
* `rootId` is a per-RENDER option; the client entry is a per-BUILD artifact, so it cannot be
|
|
329
|
+
* generated knowing which id a given render will use. The container therefore announces itself, and
|
|
330
|
+
* the entry finds it by this attribute before falling back to `#root`. It has to be the DOM rather
|
|
331
|
+
* than another `window.__NIFRA_*` global: a second copy of the id can drift from the markup, while
|
|
332
|
+
* this is written by the same expression that writes the id and cannot.
|
|
333
|
+
*
|
|
334
|
+
* Absent on a default render, so an app that never sets `rootId` emits the same bytes it always has.
|
|
335
|
+
*/
|
|
336
|
+
export const ROOT_ATTRIBUTE = "data-nifra-root"
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Pre-hydration form guard - a tiny inline script flushed in `<head>` (it runs in the window between
|
|
268
340
|
* first paint and the island bundle taking over). It neutralizes the one real hydration footgun: a
|
|
269
341
|
* JS-only form (a hand-wired `onSubmit` with no native fallback) submitting *natively* before its
|
|
270
342
|
* handler is attached, which navigates the browser to a broken `?field=…` GET of the current page.
|
|
271
343
|
*
|
|
272
|
-
* It blocks ONLY that shape
|
|
344
|
+
* It blocks ONLY that shape - an effective-GET form whose action targets the current document - and
|
|
273
345
|
* only until hydration commits (`data-nifra-hydrated`, set by the client entry). Method-`post` forms
|
|
274
346
|
* (progressive enhancement: the native POST hits the route `action`) and GET forms with a real action
|
|
275
347
|
* (a search box → `/search`) pass through untouched. Opt out per-form with `data-native`. Static text
|
|
@@ -287,12 +359,12 @@ export const PRE_HYDRATION_GUARD =
|
|
|
287
359
|
/**
|
|
288
360
|
* The single default port for the dev server (`@nifrajs/web/dev`, `@nifrajs/web/vite`) **and**
|
|
289
361
|
* `nifra start`. Deliberately uncommon: `3000`/`5173`/`8080` collide with whatever else is running
|
|
290
|
-
* (Next, Vite, a stray Node API). `4321` rarely is
|
|
362
|
+
* (Next, Vite, a stray Node API). `4321` rarely is - and being the *same* constant across `nifra dev`
|
|
291
363
|
* and `nifra start` means a project's URL doesn't change between commands. Override per-run with
|
|
292
364
|
* `--port <n>` or the `PORT` env var. */
|
|
293
365
|
export const DEFAULT_DEV_PORT = 4321
|
|
294
366
|
|
|
295
|
-
// Shared, stateless
|
|
367
|
+
// Shared, stateless - allocated once at module load, not per render/stream.
|
|
296
368
|
const TEXT_ENCODER = new TextEncoder()
|
|
297
369
|
|
|
298
370
|
type MaybePromise<T> = T | Promise<T>
|
|
@@ -325,21 +397,27 @@ export interface RenderedPage {
|
|
|
325
397
|
|
|
326
398
|
export interface RenderPageOptions {
|
|
327
399
|
readonly adapter: RenderAdapter
|
|
328
|
-
/** The layout chain to render
|
|
400
|
+
/** The layout chain to render - outermost layout → page (opaque; the adapter renders it). */
|
|
329
401
|
readonly chain: readonly unknown[]
|
|
330
402
|
/** The loader output for this request. */
|
|
331
403
|
readonly data: unknown
|
|
332
|
-
/** An action's data return (POST only)
|
|
404
|
+
/** An action's data return (POST only) - surfaced to the page as `actionData` + serialized
|
|
333
405
|
* for the client so post-POST hydration matches. Omit on GETs. */
|
|
334
406
|
readonly actionData?: unknown
|
|
335
|
-
/**
|
|
336
|
-
|
|
407
|
+
/**
|
|
408
|
+
* URL of the built client entry (loaded as a module script).
|
|
409
|
+
*
|
|
410
|
+
* Optional ONLY on a `hydrate: false` page, where there is no client takeover to feed and this value
|
|
411
|
+
* reaches no output at all. The union below is what enforces that: omitting it on a hydrating page
|
|
412
|
+
* stays a compile error rather than rendering `src=""`.
|
|
413
|
+
*/
|
|
414
|
+
readonly clientEntry?: string
|
|
337
415
|
/** Chunk URLs for the **matched** route (its layout chain + own chunk) to `modulepreload` in the
|
|
338
|
-
* shell
|
|
416
|
+
* shell - so the route code downloads in parallel with the entry instead of after it
|
|
339
417
|
* (`buildClient`'s per-route map). Empty/omitted ⇒ only the entry is preloaded (unchanged). */
|
|
340
418
|
readonly preload?: readonly string[]
|
|
341
419
|
/** Stylesheet URLs for the **matched** route (its layout chain + own CSS, from `buildClient`'s
|
|
342
|
-
* `BuildManifest.css`)
|
|
420
|
+
* `BuildManifest.css`) - injected as `<link rel="stylesheet">` in `<head>` so styles arrive with the
|
|
343
421
|
* first paint (no FOUC). Rendered even on non-hydrated pages. Empty/omitted ⇒ none (unchanged). */
|
|
344
422
|
readonly styles?: readonly string[]
|
|
345
423
|
/** SSG: the prerendered-path set, serialized to `window.__NIFRA_PRERENDERED__` so the client fetches
|
|
@@ -350,57 +428,80 @@ export interface RenderPageOptions {
|
|
|
350
428
|
readonly revalidate?: number
|
|
351
429
|
/** Matched route id; written to `window.__NIFRA_ROUTE__` so the client hydrates this chain. */
|
|
352
430
|
readonly routeId?: string
|
|
353
|
-
/** The matched route's decoded path params
|
|
431
|
+
/** The matched route's decoded path params - surfaced to the page as `params` (via {@link RenderProps})
|
|
354
432
|
* so an adapter's `useParams` is SSR-correct. Omit ⇒ `{}` (a route with no dynamic segments, or an
|
|
355
433
|
* error/404 render). */
|
|
356
434
|
readonly params?: Readonly<Record<string, string>>
|
|
357
|
-
/** The request's `pathname + search`
|
|
435
|
+
/** The request's `pathname + search` - surfaced as `path` (via {@link RenderProps}) so an adapter's
|
|
358
436
|
* `useLocation`/`useSearchParams` render the right URL server-side and hydrate without drift. Omit ⇒
|
|
359
437
|
* `""`. */
|
|
360
438
|
readonly path?: string
|
|
439
|
+
/** The route's validated search (`searchOf(mod.searchSchema, url.search)`, the same value handed to
|
|
440
|
+
* the loader as `ctx.search`), surfaced as `search` (via {@link RenderProps}) so an adapter's
|
|
441
|
+
* `useSearch` is SSR-correct. Omit ⇒ `{}` (a render with no search context). */
|
|
442
|
+
readonly search?: Record<string, unknown>
|
|
443
|
+
/** Per-layout loader data, forwarded to the adapter as `RenderProps.layoutData`. Aligned with the
|
|
444
|
+
* chain's layout prefix; omitted when no layout in the chain has a loader. */
|
|
445
|
+
readonly layoutData?: readonly unknown[]
|
|
361
446
|
/** HTTP status for the response (default 200; e.g. 404 for a not-found page). */
|
|
362
447
|
readonly status?: number
|
|
448
|
+
/** Extra response headers - e.g. the `cache-control` a terminal status page wants. `content-type`
|
|
449
|
+
* is ignored (the document is always HTML) and the ISR freshness header is applied after these,
|
|
450
|
+
* so neither can be overridden from here. */
|
|
451
|
+
readonly headers?: HeadersLike
|
|
363
452
|
/** Document title (fallback when `head.title` is unset). */
|
|
364
453
|
readonly title?: string
|
|
365
|
-
/** Resolved route head
|
|
454
|
+
/** Resolved route head - `title` overrides `title` above; `meta`/`link` render as managed
|
|
366
455
|
* (`data-nifra`) tags the client updates on navigation.
|
|
367
456
|
*
|
|
368
457
|
* **Head contract (the layout chain contributes).** `createWebApp` resolves this via
|
|
369
458
|
* {@link mergeHeads}: a route's head is its **layout chain's** `meta`/`head` exports merged with the
|
|
370
|
-
* page's. A `_layout.tsx` may `export const meta` (or `export function meta(args)`)
|
|
459
|
+
* page's. A `_layout.tsx` may `export const meta` (or `export function meta(args)`) - its tags land
|
|
371
460
|
* on every page below it (the home for `hreflang`, `preconnect`, a section-default `<title>`). The
|
|
372
461
|
* merge is **nearest-wins for scalars** (the page's `title` overrides an inner layout's, which
|
|
373
462
|
* overrides an outer one; an undefined page title keeps the layout's) and **concatenated for the
|
|
374
|
-
* `meta`/`link` arrays** (outermost layout first, page last). `<link>` attributes
|
|
375
|
-
* (
|
|
376
|
-
* `integrity`/`fetchpriority`/…) and value-escaped against XSS. */
|
|
463
|
+
* `meta`/`link` arrays** (outermost layout first, page last). `<meta>`/`<link>` attributes pass a
|
|
464
|
+
* shared tag-specific allowlist (including inert `data-*` metadata) and values are escaped. */
|
|
377
465
|
readonly head?: Meta
|
|
378
|
-
/** Id of the container wrapping the app markup (default `"root"`).
|
|
466
|
+
/** Id of the container wrapping the app markup (default `"root"`). A non-default id also gets
|
|
467
|
+
* {@link ROOT_ATTRIBUTE}, which is how the generated client entry finds the container to hydrate -
|
|
468
|
+
* the entry is built once and cannot know what a given render chose. */
|
|
379
469
|
readonly rootId?: string
|
|
380
|
-
/** When `false`, emit a complete but **non-hydrated** document
|
|
470
|
+
/** When `false`, emit a complete but **non-hydrated** document - no client entry script, data
|
|
381
471
|
* globals, or modulepreloads. Used for server-rendered `_error` pages: a terminal state that needs no
|
|
382
472
|
* client takeover, and it sidesteps an SSR/hydrate mismatch (the server rendered the boundary, not
|
|
383
473
|
* the page the client manifest maps this route id to). Default `true`. */
|
|
384
474
|
readonly hydrate?: boolean
|
|
385
475
|
/** Island client bundles (`@nifrajs/web/islands`) to load as `<script type="module">` in the document
|
|
386
|
-
* tail
|
|
476
|
+
* tail - emitted **regardless of `hydrate`**, so a static (`hydrate: false`) page can still mount
|
|
387
477
|
* no-framework islands. URLs are attribute-escaped. Empty/omitted ⇒ none (unchanged output). */
|
|
388
478
|
readonly islandScripts?: readonly string[]
|
|
479
|
+
/** CSP nonce applied to every framework-owned executable script in this document. */
|
|
480
|
+
readonly nonce?: string
|
|
389
481
|
}
|
|
390
482
|
|
|
391
483
|
/**
|
|
392
|
-
*
|
|
393
|
-
*
|
|
484
|
+
* `renderPage` input. A hydrating page (the default) must supply `clientEntry`, because the document
|
|
485
|
+
* loads it as a module script; a `hydrate: false` page may omit it, because nothing in the emitted
|
|
486
|
+
* document references it. Expressed as a union so the compiler enforces the pairing rather than the
|
|
487
|
+
* renderer discovering an empty `src` at runtime.
|
|
488
|
+
*/
|
|
489
|
+
export type RenderPageInput = RenderPageOptions &
|
|
490
|
+
({ readonly hydrate?: true; readonly clientEntry: string } | { readonly hydrate: false })
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Server: render a full HTML document for a page - the adapter's hydration head + the SSR
|
|
494
|
+
* markup (**streamed**) + the serialized loader data + the client module - as a `Response`.
|
|
394
495
|
* The shell (`<head>` + the open container) flushes first, the adapter's app stream follows,
|
|
395
496
|
* then the tail (data globals + client entry). Pure Web Standards, so it returns straight from
|
|
396
497
|
* a nifra route handler and streams on any fetch runtime (Bun/Node/Deno/Workers).
|
|
397
498
|
*/
|
|
398
|
-
export function renderPage(options:
|
|
499
|
+
export function renderPage(options: RenderPageInput): MaybePromise<Response> {
|
|
399
500
|
const page = renderPageResult(options)
|
|
400
501
|
return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse()
|
|
401
502
|
}
|
|
402
503
|
|
|
403
|
-
export function renderPageResult(options:
|
|
504
|
+
export function renderPageResult(options: RenderPageInput): MaybePromise<RenderedPage> {
|
|
404
505
|
const {
|
|
405
506
|
adapter,
|
|
406
507
|
chain,
|
|
@@ -418,9 +519,15 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
418
519
|
rootId = "root",
|
|
419
520
|
hydrate = true,
|
|
420
521
|
islandScripts = [],
|
|
522
|
+
nonce,
|
|
523
|
+
headers: extraHeaders,
|
|
421
524
|
} = options
|
|
525
|
+
if (nonce !== undefined && nonce.trim() === "") {
|
|
526
|
+
throw new TypeError("[nifra/web] renderPage nonce must be non-empty when provided")
|
|
527
|
+
}
|
|
528
|
+
const nonceAttr = nonce === undefined ? "" : ` nonce="${escapeAttr(nonce)}"`
|
|
422
529
|
const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`
|
|
423
|
-
// The SSG prerendered-path set (when an app declares it)
|
|
530
|
+
// The SSG prerendered-path set (when an app declares it) - the client reads it to fetch a static
|
|
424
531
|
// `_data.json` on soft-nav into a prerendered route instead of hitting the worker. Empty ⇒ omitted.
|
|
425
532
|
const prerendered =
|
|
426
533
|
prerenderedPaths.length === 0
|
|
@@ -428,20 +535,41 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
428
535
|
: `window.${PRERENDERED_GLOBAL}=${serializeData(prerenderedPaths)};`
|
|
429
536
|
// Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
|
|
430
537
|
// data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
|
|
431
|
-
// also `defer()`
|
|
538
|
+
// also `defer()` - split it too, continuing the id space so a single registry settles both. The
|
|
432
539
|
// inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
|
|
433
540
|
const { forComponent, forClient, deferred } = prepareDeferred(data)
|
|
541
|
+
// Each layout's data is split in turn, each continuing the id space, so a `defer()` in a layout
|
|
542
|
+
// settles through the SAME registry as the page's rather than colliding on ids.
|
|
543
|
+
const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
|
|
544
|
+
if (options.layoutData !== undefined) {
|
|
545
|
+
let offset = deferred.length
|
|
546
|
+
for (const entry of options.layoutData) {
|
|
547
|
+
const split = prepareDeferred(entry, offset)
|
|
548
|
+
offset += split.deferred.length
|
|
549
|
+
layoutSplits.push(split)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const layoutDeferred = layoutSplits.flatMap((split) => split.deferred)
|
|
434
553
|
const actionSplit =
|
|
435
|
-
actionData === undefined
|
|
436
|
-
|
|
554
|
+
actionData === undefined
|
|
555
|
+
? undefined
|
|
556
|
+
: prepareDeferred(actionData, deferred.length + layoutDeferred.length)
|
|
557
|
+
const allDeferred = [...deferred, ...layoutDeferred, ...(actionSplit ? actionSplit.deferred : [])]
|
|
558
|
+
// Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
|
|
559
|
+
const layoutTail =
|
|
560
|
+
options.layoutData === undefined
|
|
561
|
+
? ""
|
|
562
|
+
: `window.${LAYOUT_DATA_GLOBAL}=${serializeData(layoutSplits.map((split) => split.forClient))};`
|
|
563
|
+
|
|
437
564
|
// Only emit the action global when an action actually ran, so plain GET output is unchanged.
|
|
438
565
|
const action =
|
|
439
566
|
actionSplit === undefined
|
|
440
567
|
? ""
|
|
441
568
|
: `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`
|
|
442
|
-
const deferredRuntime =
|
|
569
|
+
const deferredRuntime =
|
|
570
|
+
allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : ""
|
|
443
571
|
// Matched-route chunk preloads, concatenated directly (skip the `filter().map()`
|
|
444
|
-
// intermediate arrays
|
|
572
|
+
// intermediate arrays - and the whole loop - on the common no-extra-preload render). De-duped
|
|
445
573
|
// against the entry, which is preloaded separately below.
|
|
446
574
|
let preloadLinks = ""
|
|
447
575
|
if (preload.length > 0) {
|
|
@@ -450,7 +578,7 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
450
578
|
preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`
|
|
451
579
|
}
|
|
452
580
|
}
|
|
453
|
-
// The matched route's stylesheets
|
|
581
|
+
// The matched route's stylesheets - `<link rel="stylesheet">` in `<head>` so CSS arrives with the
|
|
454
582
|
// first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static or
|
|
455
583
|
// `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead, so
|
|
456
584
|
// `styles` is empty there.
|
|
@@ -458,41 +586,62 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
458
586
|
for (const url of styles) styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`
|
|
459
587
|
// Island bundles are referenced only by a `<script type="module">` at the END of `<body>` (below), so
|
|
460
588
|
// the browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
|
|
461
|
-
// so the fetch starts immediately, in parallel with parsing
|
|
589
|
+
// so the fetch starts immediately, in parallel with parsing - regardless of `hydrate` (an island page
|
|
462
590
|
// is typically hydrate:false). Without this, a heavy island bundle on a cold first load leaves its
|
|
463
591
|
// server-rendered placeholder visible until that late, un-prefetched fetch lands ("stuck loading").
|
|
464
592
|
let islandPreloads = ""
|
|
465
593
|
for (const src of islandScripts)
|
|
466
594
|
islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`
|
|
467
|
-
// Shell
|
|
468
|
-
// open container. `modulepreload` of the client entry
|
|
469
|
-
// (`preloadLinks`)
|
|
470
|
-
// intermediate array + `join`)
|
|
595
|
+
// Shell - flushed before the app finishes rendering: `<head>` (title, meta, hydration head) + the
|
|
596
|
+
// open container. `modulepreload` of the client entry - plus the matched route's own chunks
|
|
597
|
+
// (`preloadLinks`) - lets the JS download while the body streams. One template literal (no
|
|
598
|
+
// intermediate array + `join`) - byte-identical to the prior output.
|
|
471
599
|
// A non-hydrated page (e.g. an `_error` boundary) omits the client-entry preload, route-chunk
|
|
472
|
-
// preloads, and deferred runtime
|
|
600
|
+
// preloads, and deferred runtime - there's no client takeover to feed.
|
|
473
601
|
const hydrationLinks = hydrate
|
|
474
|
-
? `<link rel="modulepreload" href="${escapeAttr(clientEntry)}">${preloadLinks}${deferredRuntime}`
|
|
602
|
+
? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}${deferredRuntime}`
|
|
475
603
|
: ""
|
|
476
604
|
// Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only on a
|
|
477
605
|
// hydrating page (a static/_error page has no client handlers, so no footgun).
|
|
478
|
-
const hydrationGuard = hydrate ? `<script>${PRE_HYDRATION_GUARD}</script>` : ""
|
|
479
|
-
|
|
606
|
+
const hydrationGuard = hydrate ? `<script${nonceAttr}>${PRE_HYDRATION_GUARD}</script>` : ""
|
|
607
|
+
// `<html>` attributes. `lang` defaults to `"en"` (so an app that never sets it is byte-identical to
|
|
608
|
+
// before); `dir` is omitted entirely when unset, which IS HTML's `ltr` default - emitting it
|
|
609
|
+
// unconditionally would change every existing app's output for no gain. Both attribute-escaped.
|
|
610
|
+
// `client.ts`'s `applyHead` mirrors this exact defaulting on soft-nav, so a hard load and a client
|
|
611
|
+
// navigation to the same URL produce the same `<html>` (no drift on a multilingual site).
|
|
612
|
+
const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`
|
|
613
|
+
// Marks the container for the client entry when the id is not the one the entry falls back to. A
|
|
614
|
+
// static attribute name with no value, so `rootId` never reaches the markup twice and there is
|
|
615
|
+
// nothing here to escape. Omitted on the default so existing output is unchanged.
|
|
616
|
+
const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`
|
|
617
|
+
const hydrationHead = adapter
|
|
618
|
+
.hydrationHead(nonce)
|
|
619
|
+
.replace(/<script(?![^>]*\bnonce=)(?=[\s>])/g, `<script${nonceAttr}`)
|
|
620
|
+
const shellHtml = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${hydrationLinks}${islandPreloads}${hydrationHead}</head><body><div id="${escapeAttr(rootId)}"${rootMarker}>`
|
|
480
621
|
// Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
|
|
481
622
|
// aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
|
|
482
623
|
const closeRootHtml = "</div>"
|
|
483
|
-
// Tail
|
|
624
|
+
// Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
|
|
484
625
|
// the data global + every streamed deferred resolution are set before the entry hydrates.
|
|
485
|
-
// Island bundles load regardless of `hydrate`
|
|
626
|
+
// Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
|
|
486
627
|
// client but can still mount no-framework islands (`@nifrajs/web/islands`).
|
|
487
628
|
let islandTags = ""
|
|
488
629
|
for (const src of islandScripts)
|
|
489
|
-
islandTags += `<script type="module" src="${escapeAttr(src)}"></script>`
|
|
630
|
+
islandTags += `<script type="module" src="${escapeAttr(src)}"${nonceAttr}></script>`
|
|
490
631
|
const tailHtml = `${
|
|
491
632
|
hydrate
|
|
492
|
-
? `<script>${route}${action}${prerendered}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry)}"></script>`
|
|
633
|
+
? `<script${nonceAttr}>${route}${action}${prerendered}${layoutTail}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>`
|
|
493
634
|
: ""
|
|
494
635
|
}${islandTags}</body></html>`
|
|
495
636
|
const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
|
|
637
|
+
// Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
|
|
638
|
+
// framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
|
|
639
|
+
// not be able to mislabel the document or forge the freshness header by passing them here.
|
|
640
|
+
if (extraHeaders !== undefined) {
|
|
641
|
+
for (const [name, value] of new Headers(extraHeaders)) {
|
|
642
|
+
if (name !== "content-type") headers[name] = value
|
|
643
|
+
}
|
|
644
|
+
}
|
|
496
645
|
// ISR: advertise the route's freshness so a `withISR` wrapper can set this page's cache TTL. A
|
|
497
646
|
// dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
|
|
498
647
|
// the client's path-list channel.
|
|
@@ -501,10 +650,17 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
501
650
|
data: forComponent,
|
|
502
651
|
actionData: actionSplit?.forComponent,
|
|
503
652
|
// `params`/`path` thread the matched route + URL to the adapter's `useParams`/`useLocation` so they
|
|
504
|
-
// render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes)
|
|
653
|
+
// render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes) - an adapter with no
|
|
505
654
|
// router bindings simply never reads them.
|
|
506
655
|
...(options.params !== undefined ? { params: options.params } : {}),
|
|
507
656
|
...(options.path !== undefined ? { path: options.path } : {}),
|
|
657
|
+
// Same rule for `search`: the validated query the adapter's `useSearch` reads, spread only when the
|
|
658
|
+
// caller supplied it (a non-router render never has it).
|
|
659
|
+
...(options.search !== undefined ? { search: options.search } : {}),
|
|
660
|
+
// Spread only when present, so a page-only render produces exactly the props it did before.
|
|
661
|
+
...(options.layoutData !== undefined
|
|
662
|
+
? { layoutData: layoutSplits.map((split) => split.forComponent) }
|
|
663
|
+
: {}),
|
|
508
664
|
}
|
|
509
665
|
|
|
510
666
|
// Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
|
|
@@ -537,6 +693,7 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
537
693
|
tailHtml,
|
|
538
694
|
status,
|
|
539
695
|
headers,
|
|
696
|
+
nonceAttr,
|
|
540
697
|
).then((response) => new ResponseRenderedPage(response))
|
|
541
698
|
}
|
|
542
699
|
|
|
@@ -550,8 +707,9 @@ async function renderStreamedPage(
|
|
|
550
707
|
tailHtml: string,
|
|
551
708
|
status: number,
|
|
552
709
|
headers: Record<string, string>,
|
|
710
|
+
nonceAttr: string,
|
|
553
711
|
): Promise<Response> {
|
|
554
|
-
// Streaming path
|
|
712
|
+
// Streaming path - required for `defer()` (progressive `<Await>` resolution) and used by any adapter
|
|
555
713
|
// that doesn't implement `renderToString`. Awaiting `renderToStream` resolves on shell-readiness
|
|
556
714
|
// (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
|
|
557
715
|
// sent. A *mid*-stream failure errors the body instead.
|
|
@@ -560,7 +718,7 @@ async function renderStreamedPage(
|
|
|
560
718
|
const closeRoot = enc.encode(closeRootHtml)
|
|
561
719
|
const tail = enc.encode(tailHtml)
|
|
562
720
|
const appStream = await adapter.renderToStream(chain, renderProps)
|
|
563
|
-
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc)
|
|
721
|
+
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr)
|
|
564
722
|
return new Response(body, { status, headers })
|
|
565
723
|
}
|
|
566
724
|
|
|
@@ -614,8 +772,8 @@ function htmlResponse(body: string, init: ResponseInit): Response {
|
|
|
614
772
|
/**
|
|
615
773
|
* Assemble the document stream: `shell` → the app `stream` (forwarded chunk-by-chunk, so a streaming
|
|
616
774
|
* renderer's progressive flushing is preserved) → `closeRoot` → one `__nifraResolve`/`__nifraReject`
|
|
617
|
-
* script per deferred value (emitted once its promise settles
|
|
618
|
-
* the same Suspense boundaries
|
|
775
|
+
* script per deferred value (emitted once its promise settles - by now the app stream has awaited
|
|
776
|
+
* the same Suspense boundaries - and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
|
|
619
777
|
* the result (the body breaks) rather than silently truncating a 200.
|
|
620
778
|
*/
|
|
621
779
|
function streamDocument(
|
|
@@ -625,6 +783,7 @@ function streamDocument(
|
|
|
625
783
|
deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
|
|
626
784
|
tail: Uint8Array,
|
|
627
785
|
enc: TextEncoder,
|
|
786
|
+
nonceAttr: string,
|
|
628
787
|
): ReadableStream<Uint8Array> {
|
|
629
788
|
return new ReadableStream({
|
|
630
789
|
async start(controller) {
|
|
@@ -637,23 +796,23 @@ function streamDocument(
|
|
|
637
796
|
controller.enqueue(value)
|
|
638
797
|
}
|
|
639
798
|
controller.enqueue(closeRoot)
|
|
640
|
-
// Stream each resolution as ITS OWN promise settles
|
|
799
|
+
// Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
|
|
641
800
|
// defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
|
|
642
801
|
await Promise.all(
|
|
643
802
|
deferred.map(async (d) => {
|
|
644
803
|
try {
|
|
645
804
|
const value = serializeData(await d.promise)
|
|
646
805
|
controller.enqueue(
|
|
647
|
-
enc.encode(`<script>window.__nifraResolve(${d.id},${value})</script>`),
|
|
806
|
+
enc.encode(`<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>`),
|
|
648
807
|
)
|
|
649
808
|
} catch (err) {
|
|
650
|
-
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it)
|
|
809
|
+
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
|
|
651
810
|
// not break the whole body. Redact: stream a stable opaque code, never the raw error
|
|
652
811
|
// text; log the real reason server-side.
|
|
653
812
|
console.error("[nifra/web] deferred value rejected:", err)
|
|
654
813
|
controller.enqueue(
|
|
655
814
|
enc.encode(
|
|
656
|
-
`<script>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`,
|
|
815
|
+
`<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`,
|
|
657
816
|
),
|
|
658
817
|
)
|
|
659
818
|
}
|
|
@@ -688,12 +847,12 @@ function isSameOriginPath(location: string): boolean {
|
|
|
688
847
|
}
|
|
689
848
|
|
|
690
849
|
/**
|
|
691
|
-
* Build a redirect `Response`
|
|
850
|
+
* Build a redirect `Response` - return it from a route `action` for the Post/Redirect/Get
|
|
692
851
|
* pattern (POST mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit).
|
|
693
852
|
* Defaults to 303 (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
|
|
694
853
|
*
|
|
695
854
|
* **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
|
|
696
|
-
* off-origin/absolute destination throws unless you pass `{ external: true }`
|
|
855
|
+
* off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
|
|
697
856
|
* open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
|
|
698
857
|
* which returns the action's `Response` verbatim.
|
|
699
858
|
*
|
|
@@ -705,21 +864,223 @@ export function redirect(location: string, options: RedirectOptions = {}): Respo
|
|
|
705
864
|
`[nifra/web] redirect(${JSON.stringify(location)}) is not a same-origin path. Use a path beginning with "/" (not "//"), or redirect(location, { external: true }) for a deliberate off-origin redirect. This guards against open redirects from unvalidated input.`,
|
|
706
865
|
)
|
|
707
866
|
}
|
|
708
|
-
// Reject CR/LF in the Location explicitly
|
|
867
|
+
// Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
|
|
709
868
|
// injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
|
|
710
869
|
// unvalidated input reach this sink, so we don't rely on the runtime. Same posture as
|
|
711
870
|
// serializeCookie / the SSE frame formatter, which strip CRLF at their sinks.
|
|
712
871
|
if (/[\r\n]/.test(location)) {
|
|
713
872
|
throw new Error(
|
|
714
|
-
`[nifra/web] redirect location contains a CR/LF character
|
|
873
|
+
`[nifra/web] redirect location contains a CR/LF character - refusing to emit a header-injecting redirect.`,
|
|
715
874
|
)
|
|
716
875
|
}
|
|
717
876
|
return new Response(null, { status: options.status ?? 303, headers: { location } })
|
|
718
877
|
}
|
|
719
878
|
|
|
879
|
+
/**
|
|
880
|
+
* Brand marking a `Response` as a **terminal-status signal** rather than a response to serve verbatim.
|
|
881
|
+
*
|
|
882
|
+
* A loader that throws a bare `Response` has always been passed straight through - that is how
|
|
883
|
+
* `throw redirect(...)` works, and apps rely on it. So "render the `_404` boundary at 404" cannot be
|
|
884
|
+
* expressed by throwing a plain `Response`: the framework would have no way to tell it apart from a
|
|
885
|
+
* hand-rolled body the app wants served exactly as written. The brand is that distinction, and
|
|
886
|
+
* checking it *before* the pass-through is what keeps every existing throw working unchanged.
|
|
887
|
+
*
|
|
888
|
+
* `Symbol.for` matches `RESPONSE_RESULT` above: a registry symbol survives two copies of this module,
|
|
889
|
+
* which a `unique symbol` would not - and a duplicate-install turning a 404 into a raw 404 body would
|
|
890
|
+
* be a maddening bug to trace.
|
|
891
|
+
*/
|
|
892
|
+
/** Derived from the `Headers` constructor rather than the DOM lib's `HeadersInit`: `@nifrajs/web`
|
|
893
|
+
* is consumed from DOM-free programs (the root tsconfig runs `types: ["bun"]`), and naming the DOM
|
|
894
|
+
* type here would break their build. Same workaround as `SSEInit.headers` in core. */
|
|
895
|
+
type HeadersLike = ConstructorParameters<typeof Headers>[0]
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* The signatures a **duplicate module instance** produces during SSR, across engines.
|
|
899
|
+
*
|
|
900
|
+
* Two copies of React (or of `@nifrajs/core`) at the SAME version still fail, because module identity
|
|
901
|
+
* is path-based: hooks read a dispatcher off the copy that rendered, and the component imported the
|
|
902
|
+
* other one. The error that surfaces names a React internal, so it reads as a React bug and the actual
|
|
903
|
+
* cause - two directories - is a long inference away. Naming the cause at the point of failure is the
|
|
904
|
+
* difference between that and a five-second read.
|
|
905
|
+
*/
|
|
906
|
+
const DUPLICATE_INSTANCE_SIGNATURES: readonly RegExp[] = [
|
|
907
|
+
/resolveDispatcher\(\)/,
|
|
908
|
+
/Invalid hook call/,
|
|
909
|
+
/Cannot read propert(?:y|ies) of null \(reading '(?:use[A-Z]\w*)'\)/,
|
|
910
|
+
/null is not an object \(evaluating '.*\.use[A-Z]\w*'\)/,
|
|
911
|
+
]
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Append the likely cause to an SSR error that carries a duplicate-instance signature.
|
|
915
|
+
*
|
|
916
|
+
* Deliberately does NOT claim the resolved paths: `@nifrajs/web` is framework-agnostic and does not
|
|
917
|
+
* depend on React, so it cannot resolve the copies without guessing. It names the condition and the
|
|
918
|
+
* command that reports the paths, which is the actionable part. Returns the error untouched when the
|
|
919
|
+
* signature does not match, so an ordinary render error reads exactly as before.
|
|
920
|
+
*/
|
|
921
|
+
function withDuplicateInstanceHint(err: unknown): unknown {
|
|
922
|
+
if (!(err instanceof Error)) return err
|
|
923
|
+
if (!DUPLICATE_INSTANCE_SIGNATURES.some((rx) => rx.test(err.message))) return err
|
|
924
|
+
const augmented = new Error(
|
|
925
|
+
`${err.message}\n\n[nifra/web] This signature usually means TWO COPIES of an identity-sensitive package (react, react-dom, or @nifrajs/core) are installed at different paths. Module identity is path-based, so matching versions do NOT fix it - the copies must resolve to the same directory. Run \`nifra check\` to list the paths.`,
|
|
926
|
+
{ cause: err },
|
|
927
|
+
)
|
|
928
|
+
augmented.name = err.name
|
|
929
|
+
// `stack` is optional under exactOptionalPropertyTypes; assigning `undefined` would replace a real
|
|
930
|
+
// stack with nothing on engines that always populate it.
|
|
931
|
+
if (err.stack !== undefined) augmented.stack = err.stack
|
|
932
|
+
return augmented
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** A loaded layout module. `loader`/`gate` are the layout-loader surface; `meta` predates it. */
|
|
936
|
+
type LoadedLayoutModules = ReadonlyArray<{
|
|
937
|
+
default: unknown
|
|
938
|
+
meta?: MetaInput
|
|
939
|
+
loader?: LayoutLoader
|
|
940
|
+
action?: unknown
|
|
941
|
+
gate?: boolean
|
|
942
|
+
// A layout may declare its own `searchSchema`; the route's effective search merges the layout chain's
|
|
943
|
+
// schemas with the page's (page-wins). Present on the raw module already - typed here so it is readable.
|
|
944
|
+
searchSchema?: RouteModule["searchSchema"]
|
|
945
|
+
}>
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Narrow the route's params to the ones a layout owns.
|
|
949
|
+
*
|
|
950
|
+
* A layout wraps a URL prefix, so anything deeper belongs to a route beneath it. Handing it the full
|
|
951
|
+
* set would let a layout read a param it does not own - which reads fine until the layout is reused
|
|
952
|
+
* under a route that has no such param and the value silently becomes `undefined`.
|
|
953
|
+
*/
|
|
954
|
+
function scopeParams(
|
|
955
|
+
params: Record<string, string>,
|
|
956
|
+
owned: readonly string[] | undefined,
|
|
957
|
+
): Record<string, string> {
|
|
958
|
+
if (owned === undefined) return params // hand-built manifest with no scope info: unchanged behaviour
|
|
959
|
+
if (owned.length === 0) return EMPTY_LAYOUT_PARAMS
|
|
960
|
+
const scoped: Record<string, string> = {}
|
|
961
|
+
for (const name of owned) {
|
|
962
|
+
const value = params[name]
|
|
963
|
+
if (value !== undefined) scoped[name] = value
|
|
964
|
+
}
|
|
965
|
+
return scoped
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const EMPTY_LAYOUT_PARAMS: Record<string, string> = Object.freeze({})
|
|
969
|
+
|
|
970
|
+
const EMPTY_RETAIN: ReadonlySet<number> = new Set<number>()
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Marks an error as coming from a LAYOUT loader, carrying that layout's id.
|
|
974
|
+
*
|
|
975
|
+
* The boundary that catches it must be at or above the failing layout's own segment. The route's
|
|
976
|
+
* innermost `_error` may live BELOW the layout that failed, and rendering there would wrap the
|
|
977
|
+
* boundary in a layout whose data never arrived - so the page would render inside a component that
|
|
978
|
+
* threw. A registry symbol, matching the other cross-module brands here.
|
|
979
|
+
*/
|
|
980
|
+
const LAYOUT_ERROR_ID = Symbol.for("nifra.web.layout-error-id")
|
|
981
|
+
|
|
982
|
+
const tagLayoutError = (err: unknown, layoutId: string): unknown => {
|
|
983
|
+
if (typeof err === "object" && err !== null && !(LAYOUT_ERROR_ID in err)) {
|
|
984
|
+
Object.defineProperty(err, LAYOUT_ERROR_ID, { value: layoutId, enumerable: false })
|
|
985
|
+
}
|
|
986
|
+
return err
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const layoutErrorId = (err: unknown): string | undefined =>
|
|
990
|
+
typeof err === "object" && err !== null
|
|
991
|
+
? ((err as Record<symbol, unknown>)[LAYOUT_ERROR_ID] as string | undefined)
|
|
992
|
+
: undefined
|
|
993
|
+
|
|
994
|
+
const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal")
|
|
995
|
+
|
|
996
|
+
/** Reason phrases for the plain-text fallback, used only when the app authored no `_<status>` and no
|
|
997
|
+
* `_404` page. Deliberately not exhaustive - anything unlisted falls back to "Error", which is more
|
|
998
|
+
* useful than shipping a table of every RFC status for a body almost nobody will see. */
|
|
999
|
+
const STATUS_TEXT: Readonly<Record<number, string>> = {
|
|
1000
|
+
400: "Bad Request",
|
|
1001
|
+
401: "Unauthorized",
|
|
1002
|
+
402: "Payment Required",
|
|
1003
|
+
403: "Forbidden",
|
|
1004
|
+
404: "Not Found",
|
|
1005
|
+
410: "Gone",
|
|
1006
|
+
451: "Unavailable For Legal Reasons",
|
|
1007
|
+
500: "Internal Server Error",
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
interface StatusSignal extends Response {
|
|
1011
|
+
readonly [STATUS_SIGNAL]: { readonly status: number; readonly headers?: HeadersLike }
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function isStatusSignal(value: unknown): value is StatusSignal {
|
|
1015
|
+
return value instanceof Response && STATUS_SIGNAL in value
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function statusSignal(status: number, options: StatusPageOptions): never {
|
|
1019
|
+
if (!Number.isInteger(status) || status < 400 || status > 599) {
|
|
1020
|
+
throw new Error(
|
|
1021
|
+
`[nifra/web] statusPage(${JSON.stringify(status)}) must be an integer 4xx or 5xx status. Use redirect() for 3xx, and return data for a successful render.`,
|
|
1022
|
+
)
|
|
1023
|
+
}
|
|
1024
|
+
const response = new Response(null, { status })
|
|
1025
|
+
// Non-enumerable so the brand never lands in a structured clone or a JSON log of the response.
|
|
1026
|
+
Object.defineProperty(response, STATUS_SIGNAL, {
|
|
1027
|
+
value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) },
|
|
1028
|
+
enumerable: false,
|
|
1029
|
+
})
|
|
1030
|
+
throw response
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Options shared by {@link notFound}, {@link gone}, and {@link statusPage}. */
|
|
1034
|
+
export interface StatusPageOptions {
|
|
1035
|
+
/**
|
|
1036
|
+
* Extra headers for the rendered response - `cache-control` above all.
|
|
1037
|
+
*
|
|
1038
|
+
* The defaults differ by status for a reason: a 404 may be a race with publication, so it wants a
|
|
1039
|
+
* short TTL, while a 410 is a promise that the URL is permanently gone and can be cached hard.
|
|
1040
|
+
* Getting this wrong is the difference between a crawler dropping a dead URL and re-fetching it
|
|
1041
|
+
* for weeks.
|
|
1042
|
+
*/
|
|
1043
|
+
readonly headers?: HeadersLike
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not
|
|
1048
|
+
* exist.
|
|
1049
|
+
*
|
|
1050
|
+
* This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to
|
|
1051
|
+
* answer 404, so the path of least resistance is to return empty data and render "not found" inside a
|
|
1052
|
+
* **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays
|
|
1053
|
+
* shipped - while search engines penalise it and keep the dead URL indexed.
|
|
1054
|
+
*
|
|
1055
|
+
* Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states
|
|
1056
|
+
* the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on.
|
|
1057
|
+
*/
|
|
1058
|
+
export function notFound(options: StatusPageOptions = {}): never {
|
|
1059
|
+
return statusSignal(404, options)
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and
|
|
1064
|
+
* was deliberately removed - a withdrawn listing, a deleted post.
|
|
1065
|
+
*
|
|
1066
|
+
* 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks
|
|
1067
|
+
* on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`.
|
|
1068
|
+
*/
|
|
1069
|
+
export function gone(options: StatusPageOptions = {}): never {
|
|
1070
|
+
return statusSignal(410, options)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and
|
|
1075
|
+
* {@link gone} (402, 451, …). Uses `_<status>.tsx` if present, otherwise `_404`.
|
|
1076
|
+
*/
|
|
1077
|
+
export function statusPage(status: number, options: StatusPageOptions = {}): never {
|
|
1078
|
+
return statusSignal(status, options)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
720
1081
|
/** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged
|
|
721
1082
|
* shape (not a class) so `@nifrajs/client`'s `ActionData` can unwrap it structurally without importing
|
|
722
|
-
* from `@nifrajs/web`. `createWebApp` strips the wrapper
|
|
1083
|
+
* from `@nifrajs/web`. `createWebApp` strips the wrapper - the client receives `data` as the body and
|
|
723
1084
|
* the paths via the `X-Nifra-Revalidate` header. */
|
|
724
1085
|
export interface RevalidateResult<T> {
|
|
725
1086
|
readonly __nifraRevalidate: readonly string[]
|
|
@@ -729,7 +1090,7 @@ export interface RevalidateResult<T> {
|
|
|
729
1090
|
/**
|
|
730
1091
|
* Return this from an action to declare which routes the mutation changed (alongside the action's
|
|
731
1092
|
* `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client
|
|
732
|
-
* marks those cached routes stale
|
|
1093
|
+
* marks those cached routes stale - refetching the active one and any mounted fetcher showing them -
|
|
733
1094
|
* so a mutation can refresh views beyond the one that was submitted. `data` is still surfaced to the
|
|
734
1095
|
* component as `actionData` (the wrapper is transparent to `ActionData<typeof action>`).
|
|
735
1096
|
*/
|
|
@@ -754,10 +1115,10 @@ function escapeHtml(value: string): string {
|
|
|
754
1115
|
/**
|
|
755
1116
|
* Escape a `<script>` element's text content so it cannot break out of the element. Per the HTML spec,
|
|
756
1117
|
* a script's text may contain any character EXCEPT the sequences that the parser treats as element
|
|
757
|
-
* boundaries: `</` (which would begin the end tag
|
|
1118
|
+
* boundaries: `</` (which would begin the end tag - `</script>` is the obvious XSS vector, but ANY `</`
|
|
758
1119
|
* starts script-data-end-tag parsing), and the comment/CDATA edges `<!--` / `]]>`. We escape the `<`
|
|
759
1120
|
* (and the `>` of `]]>`) to its JS unicode escape: inside a JS/JSON string literal `<` is the
|
|
760
|
-
* identical character, so a JSON-LD payload is byte-equivalent after parsing
|
|
1121
|
+
* identical character, so a JSON-LD payload is byte-equivalent after parsing - but the raw `<`/`>` the
|
|
761
1122
|
* HTML tokenizer scans for is gone. This is the JSON-LD-in-HTML rule (content is JSON, never raw HTML),
|
|
762
1123
|
* mirroring {@link serializeData}'s posture for the data-global script. Idempotent on already-safe text.
|
|
763
1124
|
*/
|
|
@@ -776,27 +1137,12 @@ function escapeAttr(value: string): string {
|
|
|
776
1137
|
.replaceAll(">", ">")
|
|
777
1138
|
}
|
|
778
1139
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
// `disabled`, `color`, `fetchpriority` — alongside `<meta>`'s `name`/`property`/`content` and custom
|
|
784
|
-
// `data-*`. A hardcoded list would silently drop valid attrs (the bug this guards against: `hreflang`
|
|
785
|
-
// /`crossorigin` getting filtered out). What it rejects is name-shape abuse only — a name with a space,
|
|
786
|
-
// `=`, `>`, quote, or a leading digit can't break out of the tag. Attribute VALUES are escaped
|
|
787
|
-
// separately (they may carry loader data → XSS), so a widened name set never widens the injection
|
|
788
|
-
// surface. Names that fail the shape check are dropped.
|
|
789
|
-
const SAFE_ATTR_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/
|
|
790
|
-
|
|
791
|
-
/** Serialize a meta/link tag's attributes: validate the name shape (see {@link SAFE_ATTR_NAME}) +
|
|
792
|
-
* escape the value against XSS. Invalid attribute names are dropped. Values follow the HTML attribute
|
|
793
|
-
* conventions {@link LinkDescriptor} types: a string renders `name="escaped"`; `true` renders the bare
|
|
794
|
-
* boolean attribute (`disabled`); `false` and `undefined` are skipped (a conditionally-absent attr). */
|
|
795
|
-
function tagAttrs(attrs: Record<string, string | boolean | undefined>): string {
|
|
1140
|
+
/** Serialize attributes that passed the shared SSR/client head trust policy. */
|
|
1141
|
+
function tagAttrs(tag: "meta" | "link", attrs: Readonly<object>): string | null {
|
|
1142
|
+
const trusted = trustedHeadAttributes(tag, attrs)
|
|
1143
|
+
if (trusted === null) return null
|
|
796
1144
|
let out = ""
|
|
797
|
-
for (const [name, value] of
|
|
798
|
-
if (value === undefined || value === false) continue // omitted / absent boolean attribute
|
|
799
|
-
if (!SAFE_ATTR_NAME.test(name)) continue // name-shape abuse — dropped (see SAFE_ATTR_NAME)
|
|
1145
|
+
for (const [name, value] of trusted) {
|
|
800
1146
|
if (out !== "") out += " "
|
|
801
1147
|
out += value === true ? name : `${name}="${escapeAttr(value)}"`
|
|
802
1148
|
}
|
|
@@ -810,11 +1156,19 @@ function tagAttrs(attrs: Record<string, string | boolean | undefined>): string {
|
|
|
810
1156
|
// content can vary). WeakMap so a route module that's GC'd takes its entry with it.
|
|
811
1157
|
const headTagsCache = new WeakMap<Meta, string>()
|
|
812
1158
|
|
|
1159
|
+
function assertExecutableScriptType(type: string): void {
|
|
1160
|
+
if (!EXECUTABLE_SCRIPT_TYPES.has(type)) {
|
|
1161
|
+
throw new TypeError(
|
|
1162
|
+
`[nifra/web] executable inline scripts accept ${[...EXECUTABLE_SCRIPT_TYPES].map((t) => JSON.stringify(t)).join(" or ")}; received ${JSON.stringify(type)}`,
|
|
1163
|
+
)
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
813
1167
|
/** Render a route's `meta`/`link`/`script` as managed (`data-nifra`) head tags. Title is set
|
|
814
|
-
* separately. XSS-safe by construction: every attribute flows through
|
|
815
|
-
*
|
|
816
|
-
* (breakout-escaped)
|
|
817
|
-
* or close the tag early. String concatenation (no intermediate `.map()` arrays + spread)
|
|
1168
|
+
* separately. XSS-safe by construction: every attribute flows through the shared tag-specific trust
|
|
1169
|
+
* policy then value escaping, and each `script[].content` through `escapeScriptContent`
|
|
1170
|
+
* (breakout-escaped) - so loader-derived strings (LLM-authored `og:*`, user content) can't inject markup
|
|
1171
|
+
* or close the tag early. String concatenation (no intermediate `.map()` arrays + spread) - parity with
|
|
818
1172
|
* the already concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output. Result
|
|
819
1173
|
* is memoized per resolved-`Meta` identity (static meta → serialized once per route). */
|
|
820
1174
|
function headTags(head: Meta | undefined): string {
|
|
@@ -822,14 +1176,43 @@ function headTags(head: Meta | undefined): string {
|
|
|
822
1176
|
const cached = headTagsCache.get(head)
|
|
823
1177
|
if (cached !== undefined) return cached
|
|
824
1178
|
let out = ""
|
|
825
|
-
if (head.meta !== undefined)
|
|
826
|
-
|
|
827
|
-
|
|
1179
|
+
if (head.meta !== undefined)
|
|
1180
|
+
for (const m of head.meta) {
|
|
1181
|
+
const attrs = tagAttrs("meta", m)
|
|
1182
|
+
if (attrs !== null) out += `<meta${attrs === "" ? "" : ` ${attrs}`} data-nifra>`
|
|
1183
|
+
}
|
|
1184
|
+
if (head.link !== undefined)
|
|
1185
|
+
for (const l of head.link) {
|
|
1186
|
+
const attrs = tagAttrs("link", l)
|
|
1187
|
+
if (attrs !== null) out += `<link${attrs === "" ? "" : ` ${attrs}`} data-nifra>`
|
|
1188
|
+
}
|
|
1189
|
+
// `<script>` slot - JSON-LD + other inert head scripts. `type` is attribute-escaped; `content` is
|
|
828
1190
|
// breakout-escaped (`escapeScriptContent`) so a `</script>` (or `<!--`/`]]>`) payload can't close the
|
|
829
1191
|
// element early. Managed (`data-nifra`) so a soft-nav's `applyHead` cleanly replaces it.
|
|
830
1192
|
if (head.script !== undefined)
|
|
831
|
-
for (const s of head.script)
|
|
832
|
-
|
|
1193
|
+
for (const s of head.script) {
|
|
1194
|
+
const type = s.type ?? "application/ld+json"
|
|
1195
|
+
if (!INERT_SCRIPT_TYPES.has(type)) {
|
|
1196
|
+
throw new TypeError(
|
|
1197
|
+
`[nifra/web] head.script only accepts inert JSON script types; received ${JSON.stringify(type)}. Use unsafeInlineScript() with a CSP nonce for executable code.`,
|
|
1198
|
+
)
|
|
1199
|
+
}
|
|
1200
|
+
out += `<script type="${type}" data-nifra>${escapeScriptContent(s.content)}</script>`
|
|
1201
|
+
}
|
|
1202
|
+
if (head.unsafeScript !== undefined)
|
|
1203
|
+
for (const s of head.unsafeScript) {
|
|
1204
|
+
if (s.unsafe !== true || s.nonce.trim() === "") {
|
|
1205
|
+
throw new TypeError("[nifra/web] executable inline scripts require a non-empty CSP nonce")
|
|
1206
|
+
}
|
|
1207
|
+
// `type` is validated, not escaped, for the same reason as the inert slot above: an unknown
|
|
1208
|
+
// value here is a bug, and escaping one would emit a nonsense script type rather than say so.
|
|
1209
|
+
// It was interpolated raw while the `nonce` beside it was escaped, so a descriptor built by hand
|
|
1210
|
+
// - `unsafeScript` is a public field, the helper is only a convenience - could close the
|
|
1211
|
+
// attribute and inject markup. Measured: `type: '"><img src=x onerror=alert(1)>'` reached the
|
|
1212
|
+
// document. An injection in the slot whose whole purpose is to make script emission trustworthy.
|
|
1213
|
+
assertExecutableScriptType(s.type)
|
|
1214
|
+
out += `<script type="${s.type}" nonce="${escapeAttr(s.nonce)}" data-nifra>${escapeScriptContent(s.content)}</script>`
|
|
1215
|
+
}
|
|
833
1216
|
headTagsCache.set(head, out)
|
|
834
1217
|
return out
|
|
835
1218
|
}
|
|
@@ -852,7 +1235,7 @@ export function canonical(href: string): LinkDescriptor {
|
|
|
852
1235
|
return { rel: "canonical", href }
|
|
853
1236
|
}
|
|
854
1237
|
|
|
855
|
-
/** Inputs for {@link openGraph}
|
|
1238
|
+
/** Inputs for {@link openGraph} - the common Open Graph properties. All optional; only the provided
|
|
856
1239
|
* ones become tags. `type` defaults to `"website"`. */
|
|
857
1240
|
export interface OpenGraphInput {
|
|
858
1241
|
readonly title?: string
|
|
@@ -861,7 +1244,7 @@ export interface OpenGraphInput {
|
|
|
861
1244
|
readonly image?: string
|
|
862
1245
|
/** Canonical URL of the page (`og:url`). */
|
|
863
1246
|
readonly url?: string
|
|
864
|
-
/** Object type (`og:type`)
|
|
1247
|
+
/** Object type (`og:type`) - `"website"`, `"article"`, … Default `"website"`. */
|
|
865
1248
|
readonly type?: string
|
|
866
1249
|
}
|
|
867
1250
|
|
|
@@ -873,8 +1256,8 @@ export interface OpenGraphInput {
|
|
|
873
1256
|
* export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
|
|
874
1257
|
* ```
|
|
875
1258
|
*/
|
|
876
|
-
export function openGraph(input: OpenGraphInput):
|
|
877
|
-
const tags:
|
|
1259
|
+
export function openGraph(input: OpenGraphInput): MetaDescriptor[] {
|
|
1260
|
+
const tags: MetaDescriptor[] = []
|
|
878
1261
|
const add = (property: string, content: string | undefined): void => {
|
|
879
1262
|
if (content !== undefined) tags.push({ property, content })
|
|
880
1263
|
}
|
|
@@ -882,7 +1265,7 @@ export function openGraph(input: OpenGraphInput): Array<Record<string, string>>
|
|
|
882
1265
|
add("og:description", input.description)
|
|
883
1266
|
add("og:image", input.image)
|
|
884
1267
|
add("og:url", input.url)
|
|
885
|
-
// `og:type` always present (the spec's required property)
|
|
1268
|
+
// `og:type` always present (the spec's required property) - default "website" when unset.
|
|
886
1269
|
tags.push({ property: "og:type", content: input.type ?? "website" })
|
|
887
1270
|
return tags
|
|
888
1271
|
}
|
|
@@ -902,6 +1285,30 @@ export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
|
|
|
902
1285
|
return { type: "application/ld+json", content: JSON.stringify(data) }
|
|
903
1286
|
}
|
|
904
1287
|
|
|
1288
|
+
/**
|
|
1289
|
+
* Deliberately unsafe escape hatch for executable inline code. The required nonce keeps the result
|
|
1290
|
+
* compatible with a strict CSP and makes the security-sensitive choice visible at the call site.
|
|
1291
|
+
*/
|
|
1292
|
+
export function unsafeInlineScript(
|
|
1293
|
+
content: string,
|
|
1294
|
+
options: { readonly nonce: string; readonly type?: "module" | "text/javascript" },
|
|
1295
|
+
): UnsafeScriptDescriptor {
|
|
1296
|
+
if (options.nonce.trim() === "") {
|
|
1297
|
+
throw new TypeError("[nifra/web] unsafeInlineScript requires a non-empty CSP nonce")
|
|
1298
|
+
}
|
|
1299
|
+
const type = options.type ?? "module"
|
|
1300
|
+
// Checked here as well as at emit: the type union is compile-time only, and this helper is the one
|
|
1301
|
+
// place a caller is told what it may pass. Failing at the call site names the argument; failing at
|
|
1302
|
+
// render names a document.
|
|
1303
|
+
assertExecutableScriptType(type)
|
|
1304
|
+
return {
|
|
1305
|
+
unsafe: true,
|
|
1306
|
+
type,
|
|
1307
|
+
nonce: options.nonce,
|
|
1308
|
+
content,
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
905
1312
|
/**
|
|
906
1313
|
* Merge a route's `<head>` contributions from its layout chain + the page into one {@link Meta}.
|
|
907
1314
|
*
|
|
@@ -909,33 +1316,39 @@ export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
|
|
|
909
1316
|
* `meta` to put sitewide tags (`hreflang`, `preconnect`, a default `<title>`, …) on every page below
|
|
910
1317
|
* it. `heads` is passed **outermost layout → … → innermost layout → page** (the same order as the
|
|
911
1318
|
* render chain), and merges:
|
|
912
|
-
* - **`title`**
|
|
1319
|
+
* - **`title`** - *nearest-wins*: the last defined value across the list, so the page overrides an
|
|
913
1320
|
* inner layout, which overrides an outer one. A layout `title` is the section default; an undefined
|
|
914
1321
|
* page `title` keeps it.
|
|
915
|
-
* - **`meta` / `link`**
|
|
1322
|
+
* - **`meta` / `link`** - *concatenated* in list order (outermost first, page last). Duplicate-tag
|
|
916
1323
|
* de-duplication is the caller's concern; the framework emits exactly what's declared so a layout
|
|
917
1324
|
* can ship N `<link rel="alternate" hreflang>` tags and a page can add its own canonical.
|
|
918
1325
|
*
|
|
919
1326
|
* Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
|
|
920
|
-
* static (by-reference) `Meta` and there is exactly one
|
|
1327
|
+
* static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
|
|
921
1328
|
* fine: {@link headTags}'s memo is keyed on identity, so a per-request merge simply recomputes (its
|
|
922
1329
|
* content can vary with loader data anyway).
|
|
923
1330
|
*/
|
|
924
1331
|
export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
925
|
-
// Single-head fast path (a route with no layout `meta`, by far the common case)
|
|
1332
|
+
// Single-head fast path (a route with no layout `meta`, by far the common case) - return the
|
|
926
1333
|
// resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
|
|
927
1334
|
if (heads.length === 1) return heads[0] as Meta
|
|
928
1335
|
let title: string | undefined
|
|
929
|
-
|
|
1336
|
+
let lang: string | undefined
|
|
1337
|
+
let dir: Meta["dir"]
|
|
1338
|
+
const meta: MetaDescriptor[] = []
|
|
930
1339
|
const link: LinkDescriptor[] = []
|
|
931
1340
|
const script: ScriptDescriptor[] = []
|
|
1341
|
+
const unsafeScript: UnsafeScriptDescriptor[] = []
|
|
932
1342
|
for (const h of heads) {
|
|
933
1343
|
if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
|
|
1344
|
+
if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
|
|
1345
|
+
if (h.dir !== undefined) dir = h.dir
|
|
934
1346
|
if (h.meta !== undefined) meta.push(...h.meta)
|
|
935
1347
|
if (h.link !== undefined) link.push(...h.link)
|
|
936
1348
|
if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
|
|
1349
|
+
if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
|
|
937
1350
|
}
|
|
938
|
-
// Build the result with only the fields that were actually contributed
|
|
1351
|
+
// Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
|
|
939
1352
|
// `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
|
|
940
1353
|
// is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
|
|
941
1354
|
// `undefined`).
|
|
@@ -944,11 +1357,17 @@ export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
|
944
1357
|
meta?: Meta["meta"]
|
|
945
1358
|
link?: Meta["link"]
|
|
946
1359
|
script?: Meta["script"]
|
|
1360
|
+
unsafeScript?: Meta["unsafeScript"]
|
|
1361
|
+
lang?: string
|
|
1362
|
+
dir?: Meta["dir"]
|
|
947
1363
|
} = {}
|
|
948
1364
|
if (title !== undefined) merged.title = title
|
|
949
1365
|
if (meta.length > 0) merged.meta = meta
|
|
950
1366
|
if (link.length > 0) merged.link = link
|
|
951
1367
|
if (script.length > 0) merged.script = script
|
|
1368
|
+
if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
|
|
1369
|
+
if (lang !== undefined) merged.lang = lang
|
|
1370
|
+
if (dir !== undefined) merged.dir = dir
|
|
952
1371
|
return merged as Meta
|
|
953
1372
|
}
|
|
954
1373
|
|
|
@@ -959,7 +1378,7 @@ export interface CreateWebAppOptions {
|
|
|
959
1378
|
readonly clientEntry: string
|
|
960
1379
|
/** Default document title for all pages. */
|
|
961
1380
|
readonly title?: string
|
|
962
|
-
/** Injected into each loader's `ctx.api`
|
|
1381
|
+
/** Injected into each loader's `ctx.api` - typically an `inProcessClient(app)` (typed
|
|
963
1382
|
* per-route via `@nifrajs/client`'s `createRoutes`). Opaque to the core.
|
|
964
1383
|
*
|
|
965
1384
|
* **Auto-mount.** Every `inProcessClient(backend)` exposes the symbol-keyed platform-aware backend
|
|
@@ -975,19 +1394,46 @@ export interface CreateWebAppOptions {
|
|
|
975
1394
|
* auto-mount entirely (the app serves pages only and `api` stays a loader-only `ctx.api`). Mounting
|
|
976
1395
|
* is also a no-op when `api` does not expose the symbol mount. */
|
|
977
1396
|
readonly apiPrefix?: string
|
|
1397
|
+
/**
|
|
1398
|
+
* Strip {@link apiPrefix} from the pathname before dispatching to `api` (default `false`).
|
|
1399
|
+
*
|
|
1400
|
+
* The default suits a backend that declares FULL paths (`server().post("/api/sync", …)`), which is
|
|
1401
|
+
* right when it is only ever mounted here. Set this when the backend declares its routes WITHOUT the
|
|
1402
|
+
* prefix because it also runs standalone behind its own shell, so its paths cannot carry a prefix that
|
|
1403
|
+
* only exists when it is mounted. Without it every request 404s inside the backend, and the workaround
|
|
1404
|
+
* is a `Proxy` that rewrites each URL.
|
|
1405
|
+
*/
|
|
1406
|
+
readonly apiStrip?: boolean
|
|
1407
|
+
/**
|
|
1408
|
+
* Sub-apps mounted ahead of page routing - an auth handler, a webhook receiver, a stack's routes.
|
|
1409
|
+
*
|
|
1410
|
+
* Structural on purpose: anything with `{ path, app: { fetch } }` fits, so a library that exposes its
|
|
1411
|
+
* routes as such a list mounts as `mounts: theirRoutes` without `@nifrajs/web` taking a dependency on
|
|
1412
|
+
* it. `better-auth` is the motivating case - it is not a `backend` route, so `/api/auth/*` used to 404
|
|
1413
|
+
* silently.
|
|
1414
|
+
*
|
|
1415
|
+
* Tried longest-path-first and BEFORE the `api` mount, so a mount at `/api/auth` wins over a backend
|
|
1416
|
+
* at `/api` no matter which was declared first. `stripPrefix` is the per-mount form of {@link apiStrip}:
|
|
1417
|
+
* leave it off to pass the full path through.
|
|
1418
|
+
*/
|
|
1419
|
+
readonly mounts?: ReadonlyArray<{
|
|
1420
|
+
readonly path: string
|
|
1421
|
+
readonly app: { fetch(request: Request): Response | Promise<Response> }
|
|
1422
|
+
readonly stripPrefix?: boolean
|
|
1423
|
+
}>
|
|
978
1424
|
/** Secret for **draft / preview mode** (see `enableDraft`). When set, a request carrying a valid
|
|
979
1425
|
* signed `__nifra_draft` cookie gets `ctx.draft === true` in loaders/actions (else always `false`).
|
|
980
1426
|
* Pair with `withISR({ draftSecret })` so editors bypass the cache. Omit to disable draft mode. */
|
|
981
1427
|
readonly draftSecret?: string
|
|
982
|
-
/** Per-route chunk URLs (`buildClient`'s `BuildManifest.routes`)
|
|
1428
|
+
/** Per-route chunk URLs (`buildClient`'s `BuildManifest.routes`) - `routeId → [layout chunks…, own
|
|
983
1429
|
* chunk]`. When present, each page `modulepreload`s its matched route's chunks alongside the entry,
|
|
984
1430
|
* so the route code downloads in parallel (no entry→route-chunk waterfall). Omit ⇒ entry-only. */
|
|
985
1431
|
readonly routePreload?: Readonly<Record<string, readonly string[]>>
|
|
986
|
-
/** The app's bundled stylesheet URLs (`buildClient`'s `BuildManifest.css`)
|
|
1432
|
+
/** The app's bundled stylesheet URLs (`buildClient`'s `BuildManifest.css`) - the aggregate, injected
|
|
987
1433
|
* as `<link rel="stylesheet">` in a page's `<head>`. Used as the fallback for any route absent from
|
|
988
1434
|
* {@link routeStyles}. Omit ⇒ no links (dev, where Vite injects CSS, or a CSS-free app). */
|
|
989
1435
|
readonly styles?: readonly string[]
|
|
990
|
-
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`)
|
|
1436
|
+
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - `routeId → [chain CSS]`.
|
|
991
1437
|
* When a matched route has an entry here, only those (its layout chain + own CSS) are linked instead
|
|
992
1438
|
* of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
|
|
993
1439
|
* page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
|
|
@@ -998,14 +1444,14 @@ export interface CreateWebAppOptions {
|
|
|
998
1444
|
readonly prerenderedPaths?: readonly string[]
|
|
999
1445
|
/** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
|
|
1000
1446
|
* the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
|
|
1001
|
-
* `prerenderedPaths` with the 404 page
|
|
1447
|
+
* `prerenderedPaths` with the 404 page - the unlisted path simply doesn't exist. `"ssr"` (the
|
|
1002
1448
|
* default for unmapped routes) renders unlisted paths on-demand. */
|
|
1003
1449
|
readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
|
|
1004
|
-
/** Observe every loader/action failure
|
|
1450
|
+
/** Observe every loader/action failure - for error-reporting plugins (Sentry-style). Called for
|
|
1005
1451
|
* real throws (not control-flow `Response`s like `redirect`), **before** the nearest `_error`
|
|
1006
|
-
* boundary renders / a soft-nav 500 / a rethrow
|
|
1452
|
+
* boundary renders / a soft-nav 500 / a rethrow - so it sees errors that the boundary would
|
|
1007
1453
|
* otherwise hide. Observation only; its own throws are swallowed so a faulty reporter can't break
|
|
1008
|
-
* rendering. (`beforeLoader` is intentionally omitted
|
|
1454
|
+
* rendering. (`beforeLoader` is intentionally omitted - the core HTTP hooks already cover
|
|
1009
1455
|
* pre-request work.) */
|
|
1010
1456
|
readonly onLoaderError?: (
|
|
1011
1457
|
error: unknown,
|
|
@@ -1017,7 +1463,7 @@ export interface CreateWebAppOptions {
|
|
|
1017
1463
|
) => void
|
|
1018
1464
|
}
|
|
1019
1465
|
|
|
1020
|
-
/** The handler context fields createWebApp uses
|
|
1466
|
+
/** The handler context fields createWebApp uses - a structural subset of nifra's `Context`. */
|
|
1021
1467
|
interface RouteContext {
|
|
1022
1468
|
readonly params: Record<string, string>
|
|
1023
1469
|
readonly req: Request
|
|
@@ -1025,6 +1471,27 @@ interface RouteContext {
|
|
|
1025
1471
|
readonly env: unknown
|
|
1026
1472
|
}
|
|
1027
1473
|
|
|
1474
|
+
/**
|
|
1475
|
+
* Re-issue `request` with `prefix` removed from its pathname.
|
|
1476
|
+
*
|
|
1477
|
+
* Needed because two conventions exist and both are reasonable. `createWebApp`'s auto-mount assumes a
|
|
1478
|
+
* backend that declares FULL paths (`server().post("/api/sync")`), which is right when the backend is
|
|
1479
|
+
* only ever mounted here. A backend that also runs standalone declares paths WITHOUT the prefix and
|
|
1480
|
+
* lets its own shell supply it - and mounting one of those here used to require the caller to wrap it
|
|
1481
|
+
* in a `Proxy` that rewrote every URL. Two apps wrote that same wrapper independently, which is the
|
|
1482
|
+
* signal that it belonged in the framework.
|
|
1483
|
+
*
|
|
1484
|
+
* The `Request` is rebuilt rather than mutated (`url` is read-only), preserving method, headers, body,
|
|
1485
|
+
* and duplex streaming - the body is passed through unread, so a large or streamed upload is untouched.
|
|
1486
|
+
*/
|
|
1487
|
+
function stripMountPrefix(request: Request, prefix: string): Request {
|
|
1488
|
+
const url = new URL(request.url)
|
|
1489
|
+
const rest = url.pathname.slice(prefix.length)
|
|
1490
|
+
url.pathname = rest === "" ? "/" : rest
|
|
1491
|
+
// `url.href`, not the `URL`: this type set only declares the string and Request overloads.
|
|
1492
|
+
return new Request(url.href, request)
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1028
1495
|
/**
|
|
1029
1496
|
* Resolve the explicit symbol-keyed backend mount interface. The symbol seam forwards platform
|
|
1030
1497
|
* context without making web depend on client.
|
|
@@ -1039,15 +1506,15 @@ function backendMountOf(api: unknown): BackendMountHandler | undefined {
|
|
|
1039
1506
|
/**
|
|
1040
1507
|
* Build a nifra app from a route manifest: every route SSRs its layout chain via `renderPage`,
|
|
1041
1508
|
* and a wildcard catch-all renders `_404` (or a plain 404). Reuses @nifrajs/core's router +
|
|
1042
|
-
* lifecycle, so matching, params, and precedence are battle-tested. fs-free
|
|
1509
|
+
* lifecycle, so matching, params, and precedence are battle-tested. fs-free - feed it a
|
|
1043
1510
|
* manifest from `discoverRoutes` (`@nifrajs/web/fs`) at startup, so the served app stays portable.
|
|
1044
1511
|
*
|
|
1045
|
-
* **Typed platform bindings.** Pass `Env`
|
|
1512
|
+
* **Typed platform bindings.** Pass `Env` - `createWebApp<Env>({ … })` - to declare the app's Workers
|
|
1046
1513
|
* bindings ONCE. It seeds the returned `Server`'s context with `{ env: Env }` (exactly as the backend's
|
|
1047
1514
|
* `server<Env>()` does), so `app.fetch(req, { env })` / `toFetchHandler(app)` type-check the `env`
|
|
1048
|
-
* argument against the declared shape
|
|
1515
|
+
* argument against the declared shape - no per-binding cast at the edge entry. Per-route loaders/actions
|
|
1049
1516
|
* stay typed independently of this call: annotate them with `@nifrajs/client`'s `LoaderArgs<Api, Env>`
|
|
1050
|
-
* (same `Env`) so `ctx.env.MY_KV` is typed there too. Omit the parameter and `Env` is `unknown`
|
|
1517
|
+
* (same `Env`) so `ctx.env.MY_KV` is typed there too. Omit the parameter and `Env` is `unknown` - the
|
|
1051
1518
|
* secure default; validate at the trust boundary before use.
|
|
1052
1519
|
*/
|
|
1053
1520
|
export function createWebApp<Env = unknown>(
|
|
@@ -1067,7 +1534,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1067
1534
|
return chunks ? { preload: chunks } : {}
|
|
1068
1535
|
}
|
|
1069
1536
|
// The matched route's stylesheet links (spread into renderPage). Per-route when the build mapped it
|
|
1070
|
-
// (`routeStyles[id]`
|
|
1537
|
+
// (`routeStyles[id]` - only the chain's CSS; an empty array ⇒ no `<link>`), else the aggregate
|
|
1071
1538
|
// `styles`. Omitted entirely when CSS-free (for exactOptionalPropertyTypes).
|
|
1072
1539
|
const stylesOf = (id: string): { styles?: readonly string[] } => {
|
|
1073
1540
|
const perRoute = options.routeStyles?.[id]
|
|
@@ -1080,21 +1547,34 @@ export function createWebApp<Env = unknown>(
|
|
|
1080
1547
|
const app = server<Env>()
|
|
1081
1548
|
// Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
|
|
1082
1549
|
// `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
|
|
1083
|
-
// for every method (POST/GET/PUT/…)
|
|
1550
|
+
// for every method (POST/GET/PUT/…) - not just the GET page routes - and a backend 404 (`/api/none`)
|
|
1084
1551
|
// surfaces as the backend's own response, never the page 404. We dispatch the SAME `Request` object
|
|
1085
1552
|
// (its body unread) so streamed/large bodies pass through untouched; the backend defines its routes
|
|
1086
1553
|
// at the full prefixed path (`server().post("/api/sync", …)`), matching the `inProcessClient` call
|
|
1087
1554
|
// sites. `apiPrefix: ""` opts out; a non-mountable `api` (no symbol mount) is left pages-only. The hook
|
|
1088
1555
|
// is registered ONLY when both hold, so a pages-only app keeps core's synchronous no-hook fast path.
|
|
1089
1556
|
const apiPrefix = options.apiPrefix ?? "/api"
|
|
1557
|
+
const apiStrip = options.apiStrip === true
|
|
1090
1558
|
const mountedApi = backendMountOf(api)
|
|
1091
|
-
|
|
1559
|
+
// Longest path first, so a more specific mount (`/api/auth`) is tried before a broader one (`/api`)
|
|
1560
|
+
// regardless of the order they were declared in.
|
|
1561
|
+
const mounts = [...(options.mounts ?? [])].sort((a, b) => b.path.length - a.path.length)
|
|
1562
|
+
const underPrefix = (pathname: string, prefix: string): boolean =>
|
|
1563
|
+
pathname === prefix || pathname.startsWith(`${prefix}/`)
|
|
1564
|
+
|
|
1565
|
+
if (mounts.length > 0 || (apiPrefix !== "" && mountedApi !== undefined)) {
|
|
1092
1566
|
app.onRequest((req, platform) => {
|
|
1093
1567
|
const { pathname } = new URL(req.url)
|
|
1094
|
-
//
|
|
1568
|
+
// Sub-app mounts win over the backend prefix: they are the more specific declaration, and an
|
|
1569
|
+
// auth handler mounted at `/api/auth` must not be swallowed by the backend mounted at `/api`.
|
|
1570
|
+
for (const mount of mounts) {
|
|
1571
|
+
if (!underPrefix(pathname, mount.path)) continue
|
|
1572
|
+
return mount.app.fetch(mount.stripPrefix === true ? stripMountPrefix(req, mount.path) : req)
|
|
1573
|
+
}
|
|
1574
|
+
// Exactly the prefix (`/api`) or a sub-path (`/api/…`) - NOT a sibling like `/apixyz` that merely
|
|
1095
1575
|
// shares the prefix as a string head. Dispatch the original `req` (body intact) to the backend.
|
|
1096
|
-
if (
|
|
1097
|
-
return mountedApi(req, platform)
|
|
1576
|
+
if (mountedApi !== undefined && apiPrefix !== "" && underPrefix(pathname, apiPrefix)) {
|
|
1577
|
+
return mountedApi(apiStrip ? stripMountPrefix(req, apiPrefix) : req, platform)
|
|
1098
1578
|
}
|
|
1099
1579
|
return undefined // not an API path → continue to page routing
|
|
1100
1580
|
})
|
|
@@ -1103,24 +1583,219 @@ export function createWebApp<Env = unknown>(
|
|
|
1103
1583
|
// path under a `"404"` route is rejected (it isn't a static file, and the route declared it shouldn't
|
|
1104
1584
|
// SSR on demand). O(1) membership via a Set.
|
|
1105
1585
|
const prerenderedSet = new Set(options.prerenderedPaths ?? [])
|
|
1586
|
+
const matchManifestRoute = createMatcher(
|
|
1587
|
+
manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })),
|
|
1588
|
+
)
|
|
1589
|
+
const routeById = new Map(manifest.routes.map((route) => [route.id, route]))
|
|
1106
1590
|
|
|
1107
1591
|
// Load a route's layout modules (outermost layout → innermost), keeping each module whole so the
|
|
1108
1592
|
// render path can take both its `default` (the component chain) AND its `meta` (the sitewide head it
|
|
1109
|
-
// contributes). Loaded lazily
|
|
1110
|
-
const loadLayoutModules = (
|
|
1111
|
-
|
|
1112
|
-
): Promise<ReadonlyArray<{ default: unknown; meta?: MetaInput }>> =>
|
|
1113
|
-
Promise.all(
|
|
1593
|
+
// contributes). Loaded lazily - the data-only and 405 branches return before any layout is needed.
|
|
1594
|
+
const loadLayoutModules = async (route: RouteEntry): Promise<LoadedLayoutModules> => {
|
|
1595
|
+
const modules = await Promise.all(
|
|
1114
1596
|
// layoutIds only reference layouts present in the manifest (buildManifest invariant).
|
|
1115
1597
|
route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry).load()),
|
|
1116
1598
|
)
|
|
1599
|
+
// A layout is not a form target, so an `action` there is a mistake with no meaning to give it.
|
|
1600
|
+
// A `loader`, by contrast, now runs (see `runLayoutChain`).
|
|
1601
|
+
for (let i = 0; i < modules.length; i++) {
|
|
1602
|
+
if ((modules[i] as { action?: unknown }).action === undefined) continue
|
|
1603
|
+
const id = route.layoutIds[i] as string
|
|
1604
|
+
throw new Error(
|
|
1605
|
+
`[nifra/web] "${(manifest.layouts[id] as LayoutEntry).file}" exports an \`action\`, but layouts do not run one - only route files do. Move the mutation into the route that submits to it.`,
|
|
1606
|
+
)
|
|
1607
|
+
}
|
|
1608
|
+
return modules
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* Run a route's layout-chain loaders, honouring declared gates.
|
|
1613
|
+
*
|
|
1614
|
+
* Returns once every **gate** has resolved, with the non-gate loaders still in flight, so the caller
|
|
1615
|
+
* can start the page loader at exactly the right moment: after anything that might reject the
|
|
1616
|
+
* request, alongside anything that merely fetches.
|
|
1617
|
+
*
|
|
1618
|
+
* The ordering is the security-relevant part. Parallel loaders are NOT an authorization boundary -
|
|
1619
|
+
* a page loader running concurrently with a guard has already queried by the time the guard says no.
|
|
1620
|
+
* Remix documents that as a footgun; here it is a declaration instead. `export const gate = true`
|
|
1621
|
+
* makes a layout blocking for everything beneath it, and nothing beneath a rejected gate runs.
|
|
1622
|
+
*
|
|
1623
|
+
* Each layout receives only the params it OWNS ({@link RouteEntry.layoutParams}), not the route's
|
|
1624
|
+
* full set, so a layout at `orgs/[org]/` cannot read a param belonging to a route below it.
|
|
1625
|
+
*/
|
|
1626
|
+
/**
|
|
1627
|
+
* Layout indices a client navigation asked to keep, from {@link RETAIN_HEADER}.
|
|
1628
|
+
*
|
|
1629
|
+
* The CLIENT decides retainability: it knows each layout's scoped params (shipped in the bootstrap)
|
|
1630
|
+
* and both the old and new match, so it can tell whether a layout's own prefix changed - without the
|
|
1631
|
+
* server re-matching a path the client already matched.
|
|
1632
|
+
*
|
|
1633
|
+
* It is a HINT, not an instruction. The client is asking to keep data it already holds, which is
|
|
1634
|
+
* harmless for data and unacceptable for a guard, so `runLayoutChain` refuses to skip a gate no
|
|
1635
|
+
* matter what arrives here. Anything unparseable is ignored rather than rejected: the worst case of
|
|
1636
|
+
* a bad hint is a loader that runs when it need not.
|
|
1637
|
+
*/
|
|
1638
|
+
const retainedIndices = (header: string | null): ReadonlySet<number> => {
|
|
1639
|
+
if (header === null || header === "") return EMPTY_RETAIN
|
|
1640
|
+
const out = new Set<number>()
|
|
1641
|
+
for (const part of header.split(",")) {
|
|
1642
|
+
// Digits only. `Number("")` is 0 and `Number(" ")` is 0, so a stray comma would otherwise read
|
|
1643
|
+
// as "retain index 0" and silently skip the outermost layout.
|
|
1644
|
+
if (!/^\d+$/.test(part)) continue
|
|
1645
|
+
out.add(Number(part))
|
|
1646
|
+
}
|
|
1647
|
+
return out
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* Validate a data-navigation's retain request against the manifest and its declared source path.
|
|
1652
|
+
* A client may ask to retain every slot it currently has; the server only honours indices whose
|
|
1653
|
+
* layout identity and owned params are unchanged. Hard document GETs never retain anything.
|
|
1654
|
+
*/
|
|
1655
|
+
const validatedRetainedIndices = (
|
|
1656
|
+
req: Request,
|
|
1657
|
+
route: RouteEntry,
|
|
1658
|
+
params: Readonly<Record<string, string>>,
|
|
1659
|
+
): ReadonlySet<number> => {
|
|
1660
|
+
if (req.headers.get(DATA_HEADER) === null) return EMPTY_RETAIN
|
|
1661
|
+
const requested = retainedIndices(req.headers.get(RETAIN_HEADER))
|
|
1662
|
+
const from = req.headers.get(NAV_FROM_HEADER)
|
|
1663
|
+
if (requested.size === 0 || from === null) return EMPTY_RETAIN
|
|
1664
|
+
let fromPath: string
|
|
1665
|
+
try {
|
|
1666
|
+
const current = new URL(req.url)
|
|
1667
|
+
const source = new URL(from, current)
|
|
1668
|
+
if (source.origin !== current.origin) return EMPTY_RETAIN
|
|
1669
|
+
fromPath = source.pathname + source.search
|
|
1670
|
+
} catch {
|
|
1671
|
+
return EMPTY_RETAIN
|
|
1672
|
+
}
|
|
1673
|
+
const previousMatch = matchManifestRoute(fromPath)
|
|
1674
|
+
if (previousMatch === null) return EMPTY_RETAIN
|
|
1675
|
+
const previous = routeById.get(previousMatch.routeId)
|
|
1676
|
+
if (previous === undefined) return EMPTY_RETAIN
|
|
1677
|
+
const valid = new Set<number>()
|
|
1678
|
+
for (const index of requested) {
|
|
1679
|
+
if (route.layoutIds[index] !== previous.layoutIds[index]) continue
|
|
1680
|
+
const owned = route.layoutParams?.[index] ?? []
|
|
1681
|
+
const previouslyOwned = previous.layoutParams?.[index] ?? []
|
|
1682
|
+
if (
|
|
1683
|
+
owned.length !== previouslyOwned.length ||
|
|
1684
|
+
owned.some((name, i) => name !== previouslyOwned[i]) ||
|
|
1685
|
+
owned.some((name) => params[name] !== previousMatch.params[name])
|
|
1686
|
+
) {
|
|
1687
|
+
continue
|
|
1688
|
+
}
|
|
1689
|
+
valid.add(index)
|
|
1690
|
+
}
|
|
1691
|
+
return valid
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// The validated (or raw-parsed) search a route's loader context receives; `searchOf` fails closed to
|
|
1695
|
+
// the schema's defaults on hostile input, so this never throws on an attacker-picked query.
|
|
1696
|
+
const loaderSearch = (searchSchema: RouteModule["searchSchema"], request: Request) =>
|
|
1697
|
+
searchOf(searchSchema, new URL(request.url).search)
|
|
1698
|
+
|
|
1699
|
+
// The route's EFFECTIVE search once its layout chain is loaded: the layout schemas merged with the
|
|
1700
|
+
// page's (page-wins), the same chain the client mount builds for `useSearch`. Used for the page loader's
|
|
1701
|
+
// `ctx.search` and the render's `RenderProps.search`, so both agree with the client and with each other.
|
|
1702
|
+
const searchChainOf = (
|
|
1703
|
+
layoutMods: LoadedLayoutModules,
|
|
1704
|
+
mod: RouteModule,
|
|
1705
|
+
request: Request,
|
|
1706
|
+
): Record<string, unknown> =>
|
|
1707
|
+
searchOfChain(
|
|
1708
|
+
[...layoutMods.map((m) => m.searchSchema), mod.searchSchema],
|
|
1709
|
+
new URL(request.url).search,
|
|
1710
|
+
)
|
|
1711
|
+
|
|
1712
|
+
const runLayoutChain = async (
|
|
1713
|
+
route: RouteEntry,
|
|
1714
|
+
ctx: LoaderContext,
|
|
1715
|
+
retain: ReadonlySet<number> = new Set(),
|
|
1716
|
+
): Promise<{
|
|
1717
|
+
readonly modules: LoadedLayoutModules
|
|
1718
|
+
readonly layoutData: readonly unknown[] | undefined
|
|
1719
|
+
readonly retained: readonly number[]
|
|
1720
|
+
readonly pending: Promise<unknown>
|
|
1721
|
+
}> => {
|
|
1722
|
+
const modules = await loadLayoutModules(route)
|
|
1723
|
+
if (!modules.some((m) => m.loader !== undefined)) {
|
|
1724
|
+
// Nothing to run: no array is produced, so a page-only app serializes nothing extra and its
|
|
1725
|
+
// HTML is byte-identical to before this feature.
|
|
1726
|
+
return { modules, layoutData: undefined, retained: [], pending: Promise.resolve() }
|
|
1727
|
+
}
|
|
1728
|
+
const results: unknown[] = new Array(modules.length).fill(null)
|
|
1729
|
+
const retained: number[] = []
|
|
1730
|
+
const pending: Array<Promise<void>> = []
|
|
1731
|
+
for (let i = 0; i < modules.length; i++) {
|
|
1732
|
+
const loader = modules[i]?.loader
|
|
1733
|
+
if (loader === undefined) continue
|
|
1734
|
+
// Unchanged scope: the client keeps what it has. A GATE is never skipped - it is a guard, and a
|
|
1735
|
+
// guard that only runs when the URL prefix changed is not a guard.
|
|
1736
|
+
if (retain.has(i) && modules[i]?.gate !== true) {
|
|
1737
|
+
retained.push(i)
|
|
1738
|
+
continue
|
|
1739
|
+
}
|
|
1740
|
+
const scoped: LoaderContext = {
|
|
1741
|
+
...ctx,
|
|
1742
|
+
params: scopeParams(ctx.params, route.layoutParams?.[i]),
|
|
1743
|
+
}
|
|
1744
|
+
if (modules[i]?.gate === true) {
|
|
1745
|
+
// Blocking: awaited here, so a throw propagates before anything deeper is started.
|
|
1746
|
+
try {
|
|
1747
|
+
results[i] = await loader(scoped)
|
|
1748
|
+
} catch (err) {
|
|
1749
|
+
throw tagLayoutError(err, route.layoutIds[i] as string)
|
|
1750
|
+
}
|
|
1751
|
+
continue
|
|
1752
|
+
}
|
|
1753
|
+
const layoutId = route.layoutIds[i] as string
|
|
1754
|
+
const index = i
|
|
1755
|
+
// The loader is invoked INSIDE the async function, not before it. `Promise.resolve(loader(x))`
|
|
1756
|
+
// would let a synchronously-throwing loader escape past the rejection handler entirely - the
|
|
1757
|
+
// exception happens while evaluating the argument, before any promise exists to reject.
|
|
1758
|
+
pending.push(
|
|
1759
|
+
(async () => {
|
|
1760
|
+
try {
|
|
1761
|
+
results[index] = await loader(scoped)
|
|
1762
|
+
} catch (err) {
|
|
1763
|
+
throw tagLayoutError(err, layoutId)
|
|
1764
|
+
}
|
|
1765
|
+
})(),
|
|
1766
|
+
)
|
|
1767
|
+
}
|
|
1768
|
+
return { modules, layoutData: results, retained, pending: Promise.all(pending) }
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/** Run only blocking layout gates before a route action. Non-gate loaders fetch render data and
|
|
1772
|
+
* therefore run after the mutation when a native POST needs a fresh document. */
|
|
1773
|
+
const runLayoutGates = async (
|
|
1774
|
+
route: RouteEntry,
|
|
1775
|
+
ctx: LoaderContext,
|
|
1776
|
+
): Promise<LoadedLayoutModules> => {
|
|
1777
|
+
const modules = await loadLayoutModules(route)
|
|
1778
|
+
for (let i = 0; i < modules.length; i++) {
|
|
1779
|
+
const mod = modules[i]
|
|
1780
|
+
if (mod?.gate !== true || mod.loader === undefined) continue
|
|
1781
|
+
try {
|
|
1782
|
+
await mod.loader({
|
|
1783
|
+
...ctx,
|
|
1784
|
+
params: scopeParams(ctx.params, route.layoutParams?.[i]),
|
|
1785
|
+
})
|
|
1786
|
+
} catch (err) {
|
|
1787
|
+
throw tagLayoutError(err, route.layoutIds[i] as string)
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
return modules
|
|
1791
|
+
}
|
|
1117
1792
|
|
|
1118
1793
|
// Build a route's render chain + its merged `<head>` from the already-loaded layout modules + the
|
|
1119
1794
|
// page module. The chain is `[…layout components, page]`; the head merges each layout's `meta`
|
|
1120
1795
|
// export (outermost→innermost) with the page's (last), so a `head`/`meta` on `_layout.tsx`
|
|
1121
1796
|
// contributes sitewide tags. `metaArgs` (loader data + params) feed function-form `meta`s.
|
|
1122
1797
|
const resolveChainAndHead = (
|
|
1123
|
-
layoutModules:
|
|
1798
|
+
layoutModules: LoadedLayoutModules,
|
|
1124
1799
|
page: RouteModule,
|
|
1125
1800
|
metaArgs: MetaArgs,
|
|
1126
1801
|
): { chain: unknown[]; head: Meta } => {
|
|
@@ -1136,11 +1811,31 @@ export function createWebApp<Env = unknown>(
|
|
|
1136
1811
|
const dirOfId = (id: string, suffix: string): string =>
|
|
1137
1812
|
id === suffix ? "" : id.slice(0, id.length - suffix.length - 1)
|
|
1138
1813
|
|
|
1139
|
-
|
|
1814
|
+
/**
|
|
1815
|
+
* Choose the `_error` boundary for a failure, respecting where a LAYOUT loader failed.
|
|
1816
|
+
*
|
|
1817
|
+
* A route error uses the nearest boundary, as it always has. A layout error must not: the nearest
|
|
1818
|
+
* boundary can sit BELOW the layout that threw, and rendering there would wrap the boundary in that
|
|
1819
|
+
* very layout - the one whose loader just failed and whose data therefore never arrived.
|
|
1820
|
+
*/
|
|
1821
|
+
const boundaryFor = (route: RouteEntry, err: unknown): string | undefined => {
|
|
1822
|
+
const ids = route.errorIds ?? []
|
|
1823
|
+
const failing = layoutErrorId(err)
|
|
1824
|
+
if (failing === undefined) return ids.at(-1)
|
|
1825
|
+
const layoutDir = dirOfId(failing, "_layout")
|
|
1826
|
+
// Deepest boundary at or ABOVE the failing layout's directory.
|
|
1827
|
+
for (let i = ids.length - 1; i >= 0; i--) {
|
|
1828
|
+
const errDir = dirOfId(ids[i] as string, "_error")
|
|
1829
|
+
if (errDir === "" || errDir === layoutDir || layoutDir.startsWith(`${errDir}/`)) return ids[i]
|
|
1830
|
+
}
|
|
1831
|
+
return undefined
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// The request's origin (scheme + host + port, NO trailing slash) - the one server fact `meta()` needs
|
|
1140
1835
|
// for absolute `canonical`/`og:url`/`og:image` but can't otherwise see (it also runs on the client).
|
|
1141
1836
|
// `URL.origin` is exactly that shape; it matches the browser's `location.origin` for the same URL, so
|
|
1142
1837
|
// a soft-nav's `applyHead` resolves an identical `<head>` (no hydration drift). A malformed `req.url`
|
|
1143
|
-
// (shouldn't happen on a real request) degrades to `""`
|
|
1838
|
+
// (shouldn't happen on a real request) degrades to `""` - the documented unknown-origin default -
|
|
1144
1839
|
// rather than throwing during render.
|
|
1145
1840
|
const originOf = (req: Request): string => {
|
|
1146
1841
|
try {
|
|
@@ -1150,7 +1845,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1150
1845
|
}
|
|
1151
1846
|
}
|
|
1152
1847
|
|
|
1153
|
-
// The request's `pathname + search`
|
|
1848
|
+
// The request's `pathname + search` - threaded into `renderPage` so an adapter's `useLocation`/
|
|
1154
1849
|
// `useSearchParams` render the current URL server-side and hydrate against the client's initial state
|
|
1155
1850
|
// (which the client entry seeds from `location.pathname + location.search`). No hash: it never reaches
|
|
1156
1851
|
// the server. A malformed `req.url` degrades to "/" rather than throwing during render.
|
|
@@ -1165,11 +1860,11 @@ export function createWebApp<Env = unknown>(
|
|
|
1165
1860
|
|
|
1166
1861
|
/**
|
|
1167
1862
|
* Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
|
|
1168
|
-
* half of error UI
|
|
1863
|
+
* half of error UI - works on every adapter, no client takeover). The boundary renders in place of
|
|
1169
1864
|
* the page, wrapped by the layouts **at or above** its segment (deeper layouts are dropped). It's
|
|
1170
1865
|
* served **non-hydrated** at status 500: a terminal page, and rendering a boundary (not the page the
|
|
1171
1866
|
* client maps this route to) would otherwise hydrate-mismatch. The component receives `{ name,
|
|
1172
|
-
* message }`
|
|
1867
|
+
* message }` - never the stack (no internals leak into HTML).
|
|
1173
1868
|
*/
|
|
1174
1869
|
const renderError = async (
|
|
1175
1870
|
route: RouteEntry,
|
|
@@ -1200,36 +1895,96 @@ export function createWebApp<Env = unknown>(
|
|
|
1200
1895
|
})
|
|
1201
1896
|
}
|
|
1202
1897
|
|
|
1203
|
-
// The 404 response
|
|
1898
|
+
// The 404 response - the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
|
|
1204
1899
|
// catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
|
|
1205
1900
|
// that opted out of on-demand SSR).
|
|
1206
|
-
const renderNotFound =
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1901
|
+
const renderNotFound = (path?: string): Promise<Response | RenderedPage> =>
|
|
1902
|
+
renderTerminalStatus(404, path)
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Render a terminal-status page: the `_<status>` page if the app authored one, else `_404`, else
|
|
1906
|
+
* plain text. Shared by the wildcard catch-all, `fallback: "404"` enforcement, and a loader's
|
|
1907
|
+
* `notFound()` / `gone()` / `statusPage()`, so all of them resolve the page and set the status
|
|
1908
|
+
* through one path rather than forking.
|
|
1909
|
+
*
|
|
1910
|
+
* Hydrates, following the `_404` precedent rather than `_error`'s non-hydrated 500: these are
|
|
1911
|
+
* ordinary pages the client router can own, and a 404 arrived at by clicking a link should behave
|
|
1912
|
+
* like any other route.
|
|
1913
|
+
*/
|
|
1914
|
+
const renderTerminalStatus = async (
|
|
1915
|
+
status: number,
|
|
1916
|
+
path?: string,
|
|
1917
|
+
extraHeaders?: HeadersLike,
|
|
1918
|
+
): Promise<Response | RenderedPage> => {
|
|
1919
|
+
const page = manifest.statusPages?.[String(status)] ?? manifest.notFound
|
|
1920
|
+
if (page === undefined) {
|
|
1921
|
+
const headers = new Headers(extraHeaders)
|
|
1922
|
+
headers.set("content-type", "text/plain; charset=utf-8")
|
|
1923
|
+
return new Response(STATUS_TEXT[status] ?? "Error", { status, headers })
|
|
1212
1924
|
}
|
|
1213
|
-
|
|
1925
|
+
// The routeId decides which preload/style bundle and which client component this maps to, so it
|
|
1926
|
+
// must name the page actually rendered - `_410` when `_410.tsx` exists, `_404` when it fell back.
|
|
1927
|
+
const routeId = manifest.statusPages?.[String(status)] !== undefined ? `_${status}` : "_404"
|
|
1928
|
+
const loaded = (await page.load()) as {
|
|
1929
|
+
readonly default: unknown
|
|
1930
|
+
readonly searchSchema?: RouteModule["searchSchema"]
|
|
1931
|
+
}
|
|
1932
|
+
const component = loaded.default
|
|
1214
1933
|
return renderPageResult({
|
|
1215
1934
|
adapter,
|
|
1216
|
-
chain: [
|
|
1935
|
+
chain: [component],
|
|
1217
1936
|
data: null,
|
|
1218
1937
|
clientEntry,
|
|
1219
|
-
routeId
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1938
|
+
routeId,
|
|
1939
|
+
// The page hydrates, so besides `path` its SSR `search` must equal the client mount's derivation for
|
|
1940
|
+
// this routeId (`searchOf(searchSchema, url.search)`), or a status page reading `useSearch` drifts.
|
|
1941
|
+
// Only when `path` is present (a real URL); a schema-less status page then gets the raw parsed query.
|
|
1942
|
+
...(path !== undefined
|
|
1943
|
+
? {
|
|
1944
|
+
search: searchOf(
|
|
1945
|
+
loaded.searchSchema,
|
|
1946
|
+
path.includes("?") ? path.slice(path.indexOf("?")) : "",
|
|
1947
|
+
),
|
|
1948
|
+
}
|
|
1949
|
+
: {}),
|
|
1950
|
+
// The page hydrates, so its SSR `path` must equal the client's initial
|
|
1951
|
+
// `location.pathname+search` (an unmatched path yields no params → `{}`), or a `useLocation`
|
|
1952
|
+
// on it would drift.
|
|
1222
1953
|
...(path !== undefined ? { path } : {}),
|
|
1223
|
-
status
|
|
1224
|
-
...
|
|
1225
|
-
...
|
|
1954
|
+
status,
|
|
1955
|
+
...(extraHeaders !== undefined ? { headers: extraHeaders } : {}),
|
|
1956
|
+
...preloadOf(routeId),
|
|
1957
|
+
...stylesOf(routeId),
|
|
1226
1958
|
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
1227
1959
|
...titleOption,
|
|
1228
1960
|
})
|
|
1229
1961
|
}
|
|
1230
1962
|
|
|
1963
|
+
/**
|
|
1964
|
+
* Answer a loader's terminal-status signal, in whichever form the caller asked for.
|
|
1965
|
+
*
|
|
1966
|
+
* A hard navigation wants the document, so it gets the rendered boundary. A client-side navigation
|
|
1967
|
+
* fetched DATA, not a document, so it gets an empty body plus the status on `X-Nifra-Status`, and
|
|
1968
|
+
* the client router renders the same boundary from its own manifest. Sending the HTML back to a
|
|
1969
|
+
* soft-nav would be answering a different question than the one asked - and skipping the header
|
|
1970
|
+
* entirely is what leaves a crawler seeing a correct 404 while a user who clicked a link sees a
|
|
1971
|
+
* blank page.
|
|
1972
|
+
*/
|
|
1973
|
+
const renderStatusSignal = (
|
|
1974
|
+
req: Request,
|
|
1975
|
+
signal: StatusSignal,
|
|
1976
|
+
): Promise<Response | RenderedPage> | Response => {
|
|
1977
|
+
const { status, headers } = signal[STATUS_SIGNAL]
|
|
1978
|
+
if (req.headers.get(DATA_HEADER) !== null) {
|
|
1979
|
+
const responseHeaders = new Headers(headers)
|
|
1980
|
+
responseHeaders.set(STATUS_HEADER, String(status))
|
|
1981
|
+
return new Response(null, { status, headers: responseHeaders })
|
|
1982
|
+
}
|
|
1983
|
+
return renderTerminalStatus(status, pathOf(req), headers)
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1231
1986
|
for (const route of manifest.routes) {
|
|
1232
|
-
// A dynamic route whose `getStaticPaths` declared `fallback: "404"`
|
|
1987
|
+
// A dynamic route whose `getStaticPaths` declared `fallback: "404"` - only its prerendered paths
|
|
1233
1988
|
// exist; anything else 404s (computed once per route, not per request).
|
|
1234
1989
|
const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
|
|
1235
1990
|
app.register("GET", route.pattern, undefined, async (c: RouteContext) => {
|
|
@@ -1242,20 +1997,50 @@ export function createWebApp<Env = unknown>(
|
|
|
1242
1997
|
const mod = await route.load()
|
|
1243
1998
|
const draft = await draftFlag(c.req)
|
|
1244
1999
|
let data: unknown
|
|
2000
|
+
let layoutData: readonly unknown[] | undefined
|
|
2001
|
+
let layoutModules: LoadedLayoutModules | undefined
|
|
2002
|
+
let layoutRetained: readonly number[] = []
|
|
1245
2003
|
try {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
2004
|
+
const ctx = {
|
|
2005
|
+
params: c.params,
|
|
2006
|
+
request: c.req,
|
|
2007
|
+
req: c.req,
|
|
2008
|
+
api,
|
|
2009
|
+
env: c.env,
|
|
2010
|
+
draft,
|
|
2011
|
+
search: loaderSearch(mod.searchSchema, c.req),
|
|
2012
|
+
}
|
|
2013
|
+
// Layout loaders run for BOTH the document and the data-only request. A gate that only ran on
|
|
2014
|
+
// the document path would be bypassed by adding the data header, which is exactly the request
|
|
2015
|
+
// a client navigation makes - a guard has to hold on the path an attacker can pick.
|
|
2016
|
+
//
|
|
2017
|
+
// `runLayoutChain` returns once every GATE has resolved, with the non-gate loaders still in
|
|
2018
|
+
// flight. So the page loader starts as late as correctness requires and as early as speed
|
|
2019
|
+
// allows, and both are awaited together below.
|
|
2020
|
+
const run = await runLayoutChain(
|
|
2021
|
+
route,
|
|
2022
|
+
ctx,
|
|
2023
|
+
validatedRetainedIndices(c.req, route, c.params),
|
|
2024
|
+
)
|
|
2025
|
+
layoutModules = run.modules
|
|
2026
|
+
layoutRetained = run.retained
|
|
2027
|
+
// The page loader gets the EFFECTIVE search (layout chain + page, page-wins), now that the layout
|
|
2028
|
+
// modules are loaded - so `ctx.search` includes a layout's shared keys (`?org`), matching `useSearch`.
|
|
2029
|
+
const [pageData] = await Promise.all([
|
|
2030
|
+
mod.loader
|
|
2031
|
+
? mod.loader({ ...ctx, search: searchChainOf(run.modules, mod, c.req) })
|
|
2032
|
+
: null,
|
|
2033
|
+
run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
|
|
2034
|
+
])
|
|
2035
|
+
data = pageData
|
|
2036
|
+
layoutData = run.layoutData
|
|
1256
2037
|
} catch (err) {
|
|
2038
|
+
// A terminal-status signal (`notFound()` / `gone()` / `statusPage(n)`) renders a boundary at
|
|
2039
|
+
// that status. Checked BEFORE the pass-through below: a signal IS a `Response`, so the order
|
|
2040
|
+
// is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
|
|
2041
|
+
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
1257
2042
|
// A thrown `Response` is a control-flow signal (a guard's `redirect(...)`, an explicit error
|
|
1258
|
-
// response)
|
|
2043
|
+
// response) - let it propagate to core, which returns it as-is. Real errors render the nearest
|
|
1259
2044
|
// `_error` boundary, if any; with none, rethrow (unchanged 500 behavior).
|
|
1260
2045
|
if (err instanceof Response) throw err
|
|
1261
2046
|
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
@@ -1266,9 +2051,9 @@ export function createWebApp<Env = unknown>(
|
|
|
1266
2051
|
// A faulty reporter must never break error rendering.
|
|
1267
2052
|
}
|
|
1268
2053
|
}
|
|
1269
|
-
const errorId = route
|
|
2054
|
+
const errorId = boundaryFor(route, err)
|
|
1270
2055
|
if (errorId === undefined) throw err
|
|
1271
|
-
// A soft-nav data fetch can't render a boundary
|
|
2056
|
+
// A soft-nav data fetch can't render a boundary - 500 so the client falls back to a full-page
|
|
1272
2057
|
// navigation, which lands here as a document and renders the `_error` page.
|
|
1273
2058
|
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
1274
2059
|
return new Response("Internal Server Error", {
|
|
@@ -1276,28 +2061,53 @@ export function createWebApp<Env = unknown>(
|
|
|
1276
2061
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
1277
2062
|
})
|
|
1278
2063
|
}
|
|
1279
|
-
return renderError(route, errorId, err)
|
|
2064
|
+
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
1280
2065
|
}
|
|
1281
|
-
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data
|
|
2066
|
+
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
|
|
1282
2067
|
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
1283
2068
|
// then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
|
|
1284
|
-
// same auth
|
|
2069
|
+
// same auth - only the transport differs.
|
|
1285
2070
|
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
1286
|
-
const
|
|
1287
|
-
|
|
1288
|
-
|
|
2071
|
+
const pageSplit = prepareDeferred(data)
|
|
2072
|
+
const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
|
|
2073
|
+
let offset = pageSplit.deferred.length
|
|
2074
|
+
for (const entry of layoutData ?? []) {
|
|
2075
|
+
const split = prepareDeferred(entry, offset)
|
|
2076
|
+
layoutSplits.push(split)
|
|
2077
|
+
offset += split.deferred.length
|
|
2078
|
+
}
|
|
2079
|
+
const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)]
|
|
2080
|
+
const payload =
|
|
2081
|
+
layoutData === undefined
|
|
2082
|
+
? pageSplit.forClient
|
|
2083
|
+
: {
|
|
2084
|
+
v: 1 as const,
|
|
2085
|
+
data: pageSplit.forClient,
|
|
2086
|
+
layoutData: layoutSplits.map((split) => split.forClient),
|
|
2087
|
+
retained: layoutRetained,
|
|
2088
|
+
}
|
|
2089
|
+
// Bare value when there is no layout data - the pre-envelope shape, so a client running older
|
|
2090
|
+
// code against a newer server keeps working.
|
|
2091
|
+
if (deferred.length === 0) {
|
|
2092
|
+
return Response.json(payload ?? null)
|
|
2093
|
+
}
|
|
2094
|
+
return new Response(ndjsonStream(payload, deferred), {
|
|
1289
2095
|
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
|
|
1290
2096
|
})
|
|
1291
2097
|
}
|
|
1292
|
-
const { chain, head } = resolveChainAndHead(
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
2098
|
+
const { chain, head } = resolveChainAndHead(
|
|
2099
|
+
layoutModules ?? (await loadLayoutModules(route)),
|
|
2100
|
+
mod,
|
|
2101
|
+
{
|
|
2102
|
+
data,
|
|
2103
|
+
params: c.params,
|
|
2104
|
+
origin: originOf(c.req),
|
|
2105
|
+
},
|
|
2106
|
+
)
|
|
1297
2107
|
const hydrateRoute = mod.hydrate !== false
|
|
1298
2108
|
try {
|
|
1299
2109
|
// `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
|
|
1300
|
-
// can render the `_error` page
|
|
2110
|
+
// can render the `_error` page - not just a loader throw. Mid-stream (post-shell) throws can't
|
|
1301
2111
|
// be recovered to a full page; the per-adapter client boundary catches client render errors.
|
|
1302
2112
|
return await renderPageResult({
|
|
1303
2113
|
adapter,
|
|
@@ -1308,7 +2118,12 @@ export function createWebApp<Env = unknown>(
|
|
|
1308
2118
|
routeId: route.id,
|
|
1309
2119
|
params: c.params,
|
|
1310
2120
|
path: pathOf(c.req),
|
|
2121
|
+
// The effective (layout chain + page) search, matching the page loader's `ctx.search` above and
|
|
2122
|
+
// the client mount - so `useSearch` renders SSR-correct and hydrates with no drift. `layoutModules`
|
|
2123
|
+
// is loaded by now (the loader try succeeded to reach here).
|
|
2124
|
+
search: searchChainOf(layoutModules ?? [], mod, c.req),
|
|
1311
2125
|
hydrate: hydrateRoute,
|
|
2126
|
+
...(layoutData !== undefined ? { layoutData } : {}),
|
|
1312
2127
|
...preloadOf(route.id),
|
|
1313
2128
|
...stylesOf(route.id),
|
|
1314
2129
|
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
@@ -1317,6 +2132,8 @@ export function createWebApp<Env = unknown>(
|
|
|
1317
2132
|
...titleOption,
|
|
1318
2133
|
})
|
|
1319
2134
|
} catch (err) {
|
|
2135
|
+
// Same precedence as the loader catch above - a `meta()` may signal too.
|
|
2136
|
+
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
1320
2137
|
if (err instanceof Response) throw err
|
|
1321
2138
|
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
1322
2139
|
if (options.onLoaderError !== undefined) {
|
|
@@ -1326,15 +2143,15 @@ export function createWebApp<Env = unknown>(
|
|
|
1326
2143
|
// A faulty reporter must never break error rendering.
|
|
1327
2144
|
}
|
|
1328
2145
|
}
|
|
1329
|
-
const errorId = route
|
|
2146
|
+
const errorId = boundaryFor(route, err)
|
|
1330
2147
|
if (errorId === undefined) throw err
|
|
1331
|
-
return renderError(route, errorId, err)
|
|
2148
|
+
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
1332
2149
|
}
|
|
1333
2150
|
})
|
|
1334
2151
|
|
|
1335
2152
|
// POST runs the route's `action` (mutation). A `Response` return (e.g. a `redirect(...)`)
|
|
1336
2153
|
// passes straight through; a data return re-renders the page (the loader re-runs for fresh
|
|
1337
|
-
// data) with `actionData`. Routes without an action reject POST with 405
|
|
2154
|
+
// data) with `actionData`. Routes without an action reject POST with 405 - not a stray 404.
|
|
1338
2155
|
app.register("POST", route.pattern, undefined, async (c: RouteContext) => {
|
|
1339
2156
|
const mod = await route.load()
|
|
1340
2157
|
const draft = await draftFlag(c.req)
|
|
@@ -1344,18 +2161,23 @@ export function createWebApp<Env = unknown>(
|
|
|
1344
2161
|
headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
|
|
1345
2162
|
})
|
|
1346
2163
|
}
|
|
1347
|
-
const
|
|
2164
|
+
const actionContext = {
|
|
1348
2165
|
params: c.params,
|
|
1349
2166
|
request: c.req,
|
|
1350
2167
|
req: c.req,
|
|
1351
2168
|
api,
|
|
1352
2169
|
env: c.env,
|
|
1353
2170
|
draft,
|
|
1354
|
-
|
|
2171
|
+
search: loaderSearch(mod.searchSchema, c.req),
|
|
2172
|
+
}
|
|
2173
|
+
// A gate is an authorization boundary for everything beneath its layout, mutations included.
|
|
2174
|
+
// Run only gates before the action; ordinary layout data loaders run after a native mutation.
|
|
2175
|
+
const layoutModules = await runLayoutGates(route, actionContext)
|
|
2176
|
+
const result = await mod.action(actionContext)
|
|
1355
2177
|
const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
|
|
1356
2178
|
// An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
|
|
1357
2179
|
// Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
|
|
1358
|
-
// responses (the client acts on them
|
|
2180
|
+
// responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
|
|
1359
2181
|
const isRevalidate =
|
|
1360
2182
|
result !== null && typeof result === "object" && "__nifraRevalidate" in result
|
|
1361
2183
|
const actionResult = isRevalidate ? (result as RevalidateResult<unknown>).data : result
|
|
@@ -1374,7 +2196,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1374
2196
|
// Client submit wants just the action's data (it revalidates the loader itself); a native
|
|
1375
2197
|
// form POST re-renders the full page (loader re-runs) with the action data.
|
|
1376
2198
|
if (isDataRequest) {
|
|
1377
|
-
// An action may `defer()` slow parts of its result
|
|
2199
|
+
// An action may `defer()` slow parts of its result - stream them (critical data first, then
|
|
1378
2200
|
// each deferred as it settles) exactly like a loader; a non-deferred action returns one JSON.
|
|
1379
2201
|
const { forClient, deferred } = prepareDeferred(actionResult)
|
|
1380
2202
|
if (deferred.length === 0)
|
|
@@ -1383,16 +2205,28 @@ export function createWebApp<Env = unknown>(
|
|
|
1383
2205
|
headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
|
|
1384
2206
|
})
|
|
1385
2207
|
}
|
|
2208
|
+
// Derived once and shared: the loader's `ctx.search` and the render's `RenderProps.search` are the
|
|
2209
|
+
// SAME effective (layout chain + page) value, and the client mount rebuilds it identically. The
|
|
2210
|
+
// layout modules are already loaded (the action ran through their gates above).
|
|
2211
|
+
const search = searchChainOf(layoutModules, mod, c.req)
|
|
1386
2212
|
const data = mod.loader
|
|
1387
|
-
? await mod.loader({
|
|
2213
|
+
? await mod.loader({
|
|
2214
|
+
params: c.params,
|
|
2215
|
+
request: c.req,
|
|
2216
|
+
req: c.req,
|
|
2217
|
+
api,
|
|
2218
|
+
env: c.env,
|
|
2219
|
+
draft,
|
|
2220
|
+
search,
|
|
2221
|
+
})
|
|
1388
2222
|
: null
|
|
1389
|
-
const { chain, head } = resolveChainAndHead(
|
|
2223
|
+
const { chain, head } = resolveChainAndHead(layoutModules, mod, {
|
|
1390
2224
|
data,
|
|
1391
2225
|
params: c.params,
|
|
1392
2226
|
origin: originOf(c.req),
|
|
1393
2227
|
})
|
|
1394
2228
|
const hydrateRoute = mod.hydrate !== false
|
|
1395
|
-
// A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too
|
|
2229
|
+
// A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too -
|
|
1396
2230
|
// `renderPage` splits `actionData` like loader data (works with JS off; hydrates after).
|
|
1397
2231
|
return renderPageResult({
|
|
1398
2232
|
adapter,
|
|
@@ -1404,6 +2238,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1404
2238
|
routeId: route.id,
|
|
1405
2239
|
params: c.params,
|
|
1406
2240
|
path: pathOf(c.req),
|
|
2241
|
+
search,
|
|
1407
2242
|
hydrate: hydrateRoute,
|
|
1408
2243
|
...preloadOf(route.id),
|
|
1409
2244
|
...stylesOf(route.id),
|
|
@@ -1435,8 +2270,16 @@ export function createWebApp<Env = unknown>(
|
|
|
1435
2270
|
}
|
|
1436
2271
|
|
|
1437
2272
|
export interface GenerateClientEntryOptions {
|
|
1438
|
-
/**
|
|
1439
|
-
* `"@nifrajs/web-solid/client"`.
|
|
2273
|
+
/**
|
|
2274
|
+
* Module specifier for the adapter's client runtime, e.g. `"@nifrajs/web-solid/client"`.
|
|
2275
|
+
*
|
|
2276
|
+
* **Contract:** the module MUST export `mountRouter({ router, routes, container })`. The generated
|
|
2277
|
+
* bootstrap imports the specifier and calls it - so a self-executing entry that mounts on import
|
|
2278
|
+
* builds cleanly and then does nothing, which is the trap this contract exists to name. The type is
|
|
2279
|
+
* a bare `string` because a specifier is resolved by the bundler, not the type system, so the
|
|
2280
|
+
* requirement is enforced by the bootstrap instead: it throws immediately, naming this module and
|
|
2281
|
+
* the missing export, rather than failing later as `mountRouter is not a function`.
|
|
2282
|
+
*/
|
|
1440
2283
|
readonly clientModule: string
|
|
1441
2284
|
/** Turn a route/layout source file (relative to the routes dir) into an import specifier. */
|
|
1442
2285
|
readonly resolve: (file: string) => string
|
|
@@ -1460,7 +2303,8 @@ export function generateClientEntry(
|
|
|
1460
2303
|
|
|
1461
2304
|
const loaderRows: string[] = []
|
|
1462
2305
|
const patternRows: string[] = []
|
|
1463
|
-
|
|
2306
|
+
const statusRoutes: Record<number, string> = {}
|
|
2307
|
+
// Routes whose loader appends a nearest `_error` module (LAST) - the client wraps the page in the
|
|
1464
2308
|
// adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
|
|
1465
2309
|
const errorRouteIds: string[] = []
|
|
1466
2310
|
// Lazy loader returns the raw modules (for both the component chain + the page's `meta` export).
|
|
@@ -1489,15 +2333,31 @@ export function generateClientEntry(
|
|
|
1489
2333
|
}
|
|
1490
2334
|
if (manifest.notFound !== undefined) {
|
|
1491
2335
|
loaderRows.push(` "_404": ${lazyLoader([manifest.notFound.file])},`)
|
|
2336
|
+
statusRoutes[404] = "_404"
|
|
2337
|
+
}
|
|
2338
|
+
for (const [status, entry] of Object.entries(manifest.statusPages ?? {})) {
|
|
2339
|
+
const routeId = `_${status}`
|
|
2340
|
+
loaderRows.push(` ${JSON.stringify(routeId)}: ${lazyLoader([entry.file])},`)
|
|
2341
|
+
statusRoutes[Number(status)] = routeId
|
|
1492
2342
|
}
|
|
1493
2343
|
|
|
1494
2344
|
return `${[
|
|
1495
2345
|
'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web"',
|
|
1496
2346
|
'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
|
|
1497
2347
|
// Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
|
|
1498
|
-
// access yields `undefined` if absent
|
|
2348
|
+
// access yields `undefined` if absent - unlike a named import, which would be a link error.
|
|
1499
2349
|
`import * as __adapter from ${JSON.stringify(clientModule)}`,
|
|
1500
2350
|
"const { mountRouter } = __adapter",
|
|
2351
|
+
// The `clientModule` contract is a string specifier, so nothing type-checks that the module it
|
|
2352
|
+
// names actually exports `mountRouter`. Without this the miss surfaces as
|
|
2353
|
+
// "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
|
|
2354
|
+
// module nor the requirement - a self-executing entry passes every build and fails only here.
|
|
2355
|
+
`if (typeof mountRouter !== "function") throw new Error(${JSON.stringify(
|
|
2356
|
+
`[nifra/web] clientModule ${JSON.stringify(clientModule)} does not export \`mountRouter\`. ` +
|
|
2357
|
+
"A client module must export `mountRouter({ router, routes, container })` - it is called by " +
|
|
2358
|
+
"the generated bootstrap, so a self-executing entry will not work. Use your adapter's " +
|
|
2359
|
+
'`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
|
|
2360
|
+
)})`,
|
|
1501
2361
|
"const errorBoundary = __adapter.errorBoundary",
|
|
1502
2362
|
`const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
|
|
1503
2363
|
// Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
|
|
@@ -1508,6 +2368,12 @@ export function generateClientEntry(
|
|
|
1508
2368
|
"}",
|
|
1509
2369
|
"const chains = {}",
|
|
1510
2370
|
"const metas = {}",
|
|
2371
|
+
// routeId → the page module's `searchSchema` export (or undefined). Populated lazily with
|
|
2372
|
+
// chains/metas so the mount can derive this route's typed `search` from the URL, matching the SSR.
|
|
2373
|
+
"const searchSchemas = {}",
|
|
2374
|
+
// routeId → the page's client-only search keys (`searchClientKeys`, or `[]`). The router reads this
|
|
2375
|
+
// by reference to decide when a same-route search change can skip the loader fetch (re-render only).
|
|
2376
|
+
"const searchClientKeys = {}",
|
|
1511
2377
|
"const loadModule = async (id) => {",
|
|
1512
2378
|
" if (chains[id]) return",
|
|
1513
2379
|
" const mods = await loaders[id]()",
|
|
@@ -1516,7 +2382,7 @@ export function generateClientEntry(
|
|
|
1516
2382
|
// hydrated tree matches the SSR markup (which has no boundary). Falls back to the plain chain when
|
|
1517
2383
|
// the adapter has no `errorBoundary`.
|
|
1518
2384
|
// `metas[id]` is the chain's `meta` exports in head order (outermost layout → … → page), so a
|
|
1519
|
-
// soft-nav merges the layout chain's head with the page's
|
|
2385
|
+
// soft-nav merges the layout chain's head with the page's - matching the SSR `<head>` (sitewide
|
|
1520
2386
|
// layout tags persist across client navigation, no flash of page-only head). `_error` carries no
|
|
1521
2387
|
// head (a terminal boundary), so it's excluded from the meta list for error routes.
|
|
1522
2388
|
" if (errorBoundary && errorRouteIds.has(id)) {",
|
|
@@ -1525,9 +2391,16 @@ export function generateClientEntry(
|
|
|
1525
2391
|
" const layouts = mods.slice(0, mods.length - 2).map((m) => m.default)",
|
|
1526
2392
|
" chains[id] = [...layouts, errorBoundary(fallback), page]",
|
|
1527
2393
|
" metas[id] = mods.slice(0, mods.length - 1).map((m) => m.meta)",
|
|
2394
|
+
// The search-schema CHAIN is the layout modules + page (all but the appended `_error`), so a layout's
|
|
2395
|
+
// `searchSchema` merges with the page's. Client keys come from the page (second-to-last).
|
|
2396
|
+
" searchSchemas[id] = mods.slice(0, mods.length - 1).map((m) => m.searchSchema)",
|
|
2397
|
+
" searchClientKeys[id] = mods[mods.length - 2].searchClientKeys ?? []",
|
|
1528
2398
|
" } else {",
|
|
1529
2399
|
" chains[id] = mods.map((m) => m.default)",
|
|
1530
2400
|
" metas[id] = mods.map((m) => m.meta)",
|
|
2401
|
+
// The chain is every module (layouts + page); client keys come from the page (the last module).
|
|
2402
|
+
" searchSchemas[id] = mods.map((m) => m.searchSchema)",
|
|
2403
|
+
" searchClientKeys[id] = mods[mods.length - 1].searchClientKeys ?? []",
|
|
1531
2404
|
" }",
|
|
1532
2405
|
"}",
|
|
1533
2406
|
"const patterns = [",
|
|
@@ -1547,29 +2420,45 @@ export function generateClientEntry(
|
|
|
1547
2420
|
// page reading the search string would hydrate-mismatch. The #hash is client-only (never SSR'd).
|
|
1548
2421
|
" path: location.pathname + location.search,",
|
|
1549
2422
|
` data: mapDeferred(window.${DATA_GLOBAL}),`,
|
|
2423
|
+
// Undefined on a page-only app, which is exactly what the router treats as "no layout data".
|
|
2424
|
+
` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
|
|
1550
2425
|
// actionData (only set after a form POST) is in the initial state so the binding hydrates
|
|
1551
2426
|
// consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
|
|
1552
2427
|
// action's placeholders become registry markers (a no-op when the action didn't defer).
|
|
1553
2428
|
` actionData: mapDeferred(window.${ACTION_GLOBAL}),`,
|
|
1554
2429
|
" pending: false,",
|
|
1555
2430
|
"}",
|
|
1556
|
-
|
|
2431
|
+
`const statusRoutes = ${JSON.stringify(statusRoutes)}`,
|
|
2432
|
+
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys })",
|
|
1557
2433
|
"installHistory(router)",
|
|
1558
2434
|
"installForms(router)",
|
|
1559
|
-
|
|
2435
|
+
// The container is found in the DOM, not baked in. `rootId` is a per-render option and this entry
|
|
2436
|
+
// is a per-build artifact, so a hardcoded id is a guess: `renderPage({ rootId: "app" })` produced a
|
|
2437
|
+
// perfect document that hydrated NOTHING, with no diagnostic, because the missing `#root` was
|
|
2438
|
+
// swallowed by an `if (root)`. `renderPage` marks a custom container with `ROOT_ATTRIBUTE`; the
|
|
2439
|
+
// default `#root` it leaves unmarked, which is what the fallback is for.
|
|
2440
|
+
`const root = document.querySelector("body > div[${ROOT_ATTRIBUTE}]") ?? document.getElementById("root")`,
|
|
2441
|
+
// Loud, because there is no recoverable case: this entry ships only in a document `renderPage`
|
|
2442
|
+
// opened a container in, so reaching here means the document is not the one it was built for.
|
|
2443
|
+
`if (!root) throw new Error(${JSON.stringify(
|
|
2444
|
+
"[nifra/web] no hydration container found. The generated bootstrap mounts into the element " +
|
|
2445
|
+
`renderPage opened the app in - the one marked \`${ROOT_ATTRIBUTE}\` when a custom \`rootId\` ` +
|
|
2446
|
+
"is set, `#root` otherwise - and this document has neither. If you emit the document yourself, " +
|
|
2447
|
+
'keep `<div id="root">` around the SSR markup.',
|
|
2448
|
+
)})`,
|
|
1560
2449
|
// Load the initial route's chunk, then hydrate the Router (chain is cached). The initial head
|
|
1561
2450
|
// is server-rendered; subsequent navigations update it from the matched route's meta + data.
|
|
1562
|
-
"
|
|
1563
|
-
" mountRouter({ router, routes: chains, container: root })",
|
|
2451
|
+
"loadModule(initial.routeId).then(() => {",
|
|
2452
|
+
" mountRouter({ router, routes: chains, searchSchemas, container: root })",
|
|
1564
2453
|
// Next frame: the adapter has committed its initial hydration, so handlers are attached. Flip the
|
|
1565
2454
|
// document to interactive (`data-nifra-hydrated` + `nifra:hydrated`) for apps to gate pre-hydration UI.
|
|
1566
2455
|
" requestAnimationFrame(signalHydrated)",
|
|
1567
2456
|
" router.subscribe(() => {",
|
|
1568
2457
|
" const s = router.snapshot()",
|
|
1569
|
-
// Merge the matched route's chain meta (layouts→page) into one head
|
|
2458
|
+
// Merge the matched route's chain meta (layouts→page) into one head - same contract as SSR.
|
|
1570
2459
|
" if (!s.pending) {",
|
|
1571
2460
|
// `origin: location.origin` matches the SSR `originOf(req)` (both are `URL.origin` for the same
|
|
1572
|
-
// page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url
|
|
2461
|
+
// page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url - no head drift.
|
|
1573
2462
|
" const args = { data: s.data, params: s.params, origin: location.origin }",
|
|
1574
2463
|
" applyHead(mergeHeads((metas[s.routeId] ?? [undefined]).map((m) => resolveMeta(m, args))))",
|
|
1575
2464
|
" }",
|
|
@@ -1578,22 +2467,69 @@ export function generateClientEntry(
|
|
|
1578
2467
|
].join("\n")}\n`
|
|
1579
2468
|
}
|
|
1580
2469
|
|
|
2470
|
+
export interface GenerateRouteSearchTypesOptions {
|
|
2471
|
+
/** Turn a route source file into a module specifier for a `typeof import(...)` type - the same contract
|
|
2472
|
+
* as `generateClientEntry`'s `resolve`, but WITHOUT a file extension (a `.d.ts` imports a module, not a
|
|
2473
|
+
* path). Relative to wherever the emitted `.d.ts` is written. */
|
|
2474
|
+
readonly resolve: (file: string) => string
|
|
2475
|
+
/** The module whose `RouteSearch` interface is augmented (default `"@nifrajs/web"`). Every adapter's
|
|
2476
|
+
* `NavigateTarget` reads `@nifrajs/web`'s `RouteSearch`, so the default types `navigate` on all of them. */
|
|
2477
|
+
readonly module?: string
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
/**
|
|
2481
|
+
* Codegen: emit a `.d.ts` that types cross-route `navigate({ to, search })` against each route's
|
|
2482
|
+
* `searchSchema`. It augments the {@link RouteSearch} map with `"<pattern>": SearchOf<typeof import(...)>`
|
|
2483
|
+
* for every **static** route (one with no `:param`/`*` segment - a concrete `to` a caller can pass; a
|
|
2484
|
+
* dynamic route's concrete path can't match its pattern key, so those keep the loose object form). Written
|
|
2485
|
+
* by `nifra sync-routes` next to the route tree and included in the app's `tsconfig`; because it is
|
|
2486
|
+
* regenerated from the route files, a `navigate` whose `search` no longer matches the route's schema
|
|
2487
|
+
* becomes a `tsc` error - the same drift guarantee as nifra's typed client, no hand-maintained map.
|
|
2488
|
+
*/
|
|
2489
|
+
export function generateRouteSearchTypes(
|
|
2490
|
+
manifest: Manifest,
|
|
2491
|
+
options: GenerateRouteSearchTypesOptions,
|
|
2492
|
+
): string {
|
|
2493
|
+
const { resolve, module = "@nifrajs/web" } = options
|
|
2494
|
+
// Static routes only (no `:param` / `*` catch-all): their pattern IS a concrete `to`. Sorted for stable
|
|
2495
|
+
// output; a dynamic route falls through to the loose `{ to: string; search?: Record<...> }` form.
|
|
2496
|
+
const entries = manifest.routes
|
|
2497
|
+
.filter((route) => !/[:*]/.test(route.pattern))
|
|
2498
|
+
.map((route) => [route.pattern, resolve(route.file)] as const)
|
|
2499
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
2500
|
+
.map(
|
|
2501
|
+
([pattern, spec]) =>
|
|
2502
|
+
` ${JSON.stringify(pattern)}: SearchOf<typeof import(${JSON.stringify(spec)})>`,
|
|
2503
|
+
)
|
|
2504
|
+
return `${[
|
|
2505
|
+
"// GENERATED by @nifrajs/web generateRouteSearchTypes - typed cross-route navigate({ to, search }).",
|
|
2506
|
+
"// Regenerated from the route files; a stale navigate search shape becomes a tsc error. Do not edit.",
|
|
2507
|
+
'import type { SearchOf } from "@nifrajs/web"',
|
|
2508
|
+
`declare module ${JSON.stringify(module)} {`,
|
|
2509
|
+
" interface RouteSearch {",
|
|
2510
|
+
...entries,
|
|
2511
|
+
" }",
|
|
2512
|
+
"}",
|
|
2513
|
+
"export {}",
|
|
2514
|
+
].join("\n")}\n`
|
|
2515
|
+
}
|
|
2516
|
+
|
|
1581
2517
|
export interface GenerateServerManifestOptions {
|
|
1582
|
-
/** Turn a route/layout source file (relative to the routes dir) into an import specifier
|
|
2518
|
+
/** Turn a route/layout source file (relative to the routes dir) into an import specifier -
|
|
1583
2519
|
* same contract as `generateClientEntry`'s `resolve`. */
|
|
1584
2520
|
readonly resolve: (file: string) => string
|
|
1585
2521
|
/** The content-hashed client entry URL (from `buildClient`'s manifest), **baked** into the emitted
|
|
1586
|
-
* module
|
|
2522
|
+
* module - a disk-less worker can't read `manifest.json` at runtime. */
|
|
1587
2523
|
readonly clientEntry: string
|
|
1588
|
-
/** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`)
|
|
2524
|
+
/** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked as
|
|
1589
2525
|
* `export const styles` so the server entry can hand them to `createWebApp` (→ `<link rel="stylesheet">`
|
|
1590
2526
|
* in `<head>`). Without this the built SSR page ships no CSS link and renders unstyled. */
|
|
1591
2527
|
readonly styles?: readonly string[] | undefined
|
|
1592
|
-
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`)
|
|
2528
|
+
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked as
|
|
1593
2529
|
* `export const routeStyles` so a page links only the CSS its route chain uses. */
|
|
1594
2530
|
readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
|
|
1595
2531
|
/** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
|
|
1596
|
-
* eager `import * as`, so a bundler with code-splitting emits one chunk per route
|
|
2532
|
+
* eager `import * as`, so a bundler with code-splitting emits one chunk per route - loaded on the
|
|
1597
2533
|
* first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
|
|
1598
2534
|
* modes are fs-free with statically-analyzable specifiers; only the *when* differs. */
|
|
1599
2535
|
readonly lazy?: boolean
|
|
@@ -1601,10 +2537,10 @@ export interface GenerateServerManifestOptions {
|
|
|
1601
2537
|
|
|
1602
2538
|
/**
|
|
1603
2539
|
* Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
|
|
1604
|
-
* Workers, …)
|
|
1605
|
-
* and dynamic-imports each route by a *runtime* path
|
|
1606
|
-
* **statically-analyzable** imports of every route/layout/`_error
|
|
1607
|
-
* rebuilds the manifest with `buildManifest`
|
|
2540
|
+
* Workers, …) - and, with a `target`, any portable server bundle. `discoverRoutes` scans `node:fs`
|
|
2541
|
+
* and dynamic-imports each route by a *runtime* path - neither exists on workerd. This instead emits
|
|
2542
|
+
* **statically-analyzable** imports of every route/layout/`_error`/terminal status page (so the bundler includes them) and
|
|
2543
|
+
* rebuilds the manifest with `buildManifest` - the SAME pure logic `discoverRoutes` feeds, so patterns
|
|
1608
2544
|
* + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
|
|
1609
2545
|
* a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
|
|
1610
2546
|
* `createWebApp`, unchanged) + `clientEntry` (baked). Write it to a file and bundle it into the worker
|
|
@@ -1615,17 +2551,18 @@ export function generateServerManifest(
|
|
|
1615
2551
|
options: GenerateServerManifestOptions,
|
|
1616
2552
|
): string {
|
|
1617
2553
|
const { resolve, clientEntry, styles, routeStyles, lazy = false } = options
|
|
1618
|
-
// Every unique source file in the manifest (routes + layouts +
|
|
2554
|
+
// Every unique source file in the manifest (routes + layouts + error/status pages), sorted for stable output.
|
|
1619
2555
|
const files = [
|
|
1620
2556
|
...new Set([
|
|
1621
2557
|
...manifest.routes.map((r) => r.file),
|
|
1622
2558
|
...Object.values(manifest.layouts).map((l) => l.file),
|
|
1623
2559
|
...Object.values(manifest.errors ?? {}).map((e) => e.file),
|
|
1624
2560
|
...(manifest.notFound ? [manifest.notFound.file] : []),
|
|
2561
|
+
...Object.values(manifest.statusPages ?? {}).map((page) => page.file),
|
|
1625
2562
|
]),
|
|
1626
2563
|
].sort()
|
|
1627
2564
|
const header = [
|
|
1628
|
-
"// GENERATED by @nifrajs/web generateServerManifest
|
|
2565
|
+
"// GENERATED by @nifrajs/web generateServerManifest - route manifest for the disk-less edge",
|
|
1629
2566
|
"// (no filesystem; route imports are static specifiers the bundler resolves). buildManifest is",
|
|
1630
2567
|
"// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
|
|
1631
2568
|
'import { buildManifest } from "@nifrajs/web"',
|
|
@@ -1669,3 +2606,15 @@ export function generateServerManifest(
|
|
|
1669
2606
|
"export const manifest = buildManifest(Object.keys(modules), (file) => () => Promise.resolve(modules[file]))",
|
|
1670
2607
|
].join("\n")}\n`
|
|
1671
2608
|
}
|
|
2609
|
+
|
|
2610
|
+
/**
|
|
2611
|
+
* The two file-name conventions that decide what never reaches a browser: a `*.server` module is
|
|
2612
|
+
* EMPTIED in the client build, a `*.fn` module is REPLACED with client stubs.
|
|
2613
|
+
*
|
|
2614
|
+
* Exported because more than the bundlers need them. `nifra dev --bun` cannot transform (Bun's dev
|
|
2615
|
+
* bundler takes no plugins) so it must REFUSE instead, and a refusal driven by its own hand-written
|
|
2616
|
+
* glob drifts from the transform - which is how `.fn.mts` came to be stubbed by both build pipelines
|
|
2617
|
+
* and waved through by the guard that exists to stop it leaking. Anything deciding "is this module
|
|
2618
|
+
* server-only" should import the matcher rather than re-encode it.
|
|
2619
|
+
*/
|
|
2620
|
+
export { SERVER_FN_MODULE, SERVER_ONLY_MODULE } from "./internal/server-boundary.ts"
|