@nifrajs/web 2.2.0 → 2.4.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 +4 -4
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +20 -74
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +64 -42
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +100 -78
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +201 -29
- 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-diagnostics.d.ts +20 -0
- package/dist/dev-diagnostics.d.ts.map +1 -0
- package/dist/dev-diagnostics.js +34 -0
- package/dist/dev-diagnostics.js.map +1 -0
- package/dist/dev-error.d.ts +10 -13
- package/dist/dev-error.d.ts.map +1 -1
- package/dist/dev-error.js +51 -37
- package/dist/dev-error.js.map +1 -1
- package/dist/dev-port.d.ts +3 -3
- package/dist/dev-port.d.ts.map +1 -1
- package/dist/dev-port.js +10 -4
- package/dist/dev-port.js.map +1 -1
- package/dist/dev.d.ts +4 -4
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +28 -12
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic.d.ts +93 -0
- package/dist/diagnostic.d.ts.map +1 -0
- package/dist/diagnostic.js +205 -0
- package/dist/diagnostic.js.map +1 -0
- package/dist/draft.d.ts +12 -12
- package/dist/draft.js +11 -11
- 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 +1 -1
- package/dist/fs.js +1 -1
- package/dist/index.d.ts +151 -70
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +317 -139
- 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 +1 -1
- package/dist/internal/timing-safe-equal.js +1 -1
- 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.js +7 -7
- package/dist/manifest.d.ts +73 -35
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -16
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +5 -5
- package/dist/module-graph.js +4 -4
- 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.js +0 -0
- 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 +1 -1
- package/dist/plugins/vite-leak-guard.js +1 -1
- 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/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +1 -1
- package/dist/route-manifest.js +2 -2
- package/dist/router.d.ts +30 -21
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +62 -32
- 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 +16 -14
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +51 -24
- package/dist/vite.js.map +1 -1
- package/package.json +38 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +24 -77
- package/src/build.ts +137 -105
- package/src/bun-dev-entry.ts +1 -1
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +215 -32
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-diagnostics.ts +47 -0
- package/src/dev-error.ts +64 -33
- package/src/dev-port.ts +17 -5
- package/src/dev.ts +36 -20
- package/src/diagnostic.ts +284 -0
- package/src/draft.ts +15 -15
- 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 +2 -2
- package/src/index.ts +460 -181
- 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 +1 -1
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +17 -17
- package/src/manifest.ts +83 -42
- package/src/module-graph.ts +6 -6
- 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 +2 -2
- 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/query.ts +22 -22
- package/src/route-manifest.ts +3 -3
- package/src/router.ts +98 -45
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +61 -32
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,6 +24,8 @@ 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 {
|
|
@@ -34,10 +36,12 @@ import type {
|
|
|
34
36
|
Manifest,
|
|
35
37
|
Meta,
|
|
36
38
|
MetaArgs,
|
|
39
|
+
MetaDescriptor,
|
|
37
40
|
MetaInput,
|
|
38
41
|
RouteEntry,
|
|
39
42
|
RouteModule,
|
|
40
43
|
ScriptDescriptor,
|
|
44
|
+
UnsafeScriptDescriptor,
|
|
41
45
|
} from "./manifest.ts"
|
|
42
46
|
import {
|
|
43
47
|
createMatcher,
|
|
@@ -50,8 +54,9 @@ import {
|
|
|
50
54
|
STATUS_HEADER,
|
|
51
55
|
type Submission,
|
|
52
56
|
} from "./router.ts"
|
|
57
|
+
import { searchOf, searchOfChain } from "./search.ts"
|
|
53
58
|
|
|
54
|
-
// Draft / preview mode
|
|
59
|
+
// Draft / preview mode - a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
|
|
55
60
|
export {
|
|
56
61
|
DRAFT_COOKIE,
|
|
57
62
|
type DraftCookieControls,
|
|
@@ -62,7 +67,7 @@ export {
|
|
|
62
67
|
type PreviewEndpointOptions,
|
|
63
68
|
previewEndpoint,
|
|
64
69
|
} from "./draft.ts"
|
|
65
|
-
// Font optimization
|
|
70
|
+
// Font optimization - a CLS-safe `@font-face` generator + a preload `<link>` for self-hosted fonts.
|
|
66
71
|
export {
|
|
67
72
|
type FontDisplay,
|
|
68
73
|
type FontFace,
|
|
@@ -90,7 +95,7 @@ export {
|
|
|
90
95
|
revalidateEndpoint,
|
|
91
96
|
withISR,
|
|
92
97
|
} from "./isr.ts"
|
|
93
|
-
// File-based routing manifest
|
|
98
|
+
// File-based routing manifest - pure + fs-free. `discoverRoutes` (fs) lives in `@nifrajs/web/fs`.
|
|
94
99
|
export {
|
|
95
100
|
type Action,
|
|
96
101
|
buildManifest,
|
|
@@ -98,6 +103,7 @@ export {
|
|
|
98
103
|
filePathToPattern,
|
|
99
104
|
filePathToPatterns,
|
|
100
105
|
type GetStaticPaths,
|
|
106
|
+
type InertScriptType,
|
|
101
107
|
type LayoutEntry,
|
|
102
108
|
type LinkDescriptor,
|
|
103
109
|
type Loader,
|
|
@@ -112,23 +118,37 @@ export {
|
|
|
112
118
|
type StaticPath,
|
|
113
119
|
type StaticPaths,
|
|
114
120
|
type StaticRoutes,
|
|
121
|
+
type UnsafeScriptDescriptor,
|
|
115
122
|
} from "./manifest.ts"
|
|
116
|
-
// Navigation bridge
|
|
123
|
+
// Navigation bridge - a DOM-free seam the browser layer (`installHistory`) populates so an adapter's
|
|
117
124
|
// `useNavigate` (a route component, importing only this agnostic entry) reaches history-aware nav.
|
|
118
125
|
export {
|
|
126
|
+
type Blocker,
|
|
127
|
+
type BlockerController,
|
|
128
|
+
type BlockerFunction,
|
|
129
|
+
type BlockerLocation,
|
|
130
|
+
type BlockerState,
|
|
119
131
|
type BrowserNavigate,
|
|
120
132
|
getBrowserNavigate,
|
|
133
|
+
IDLE_BLOCKER,
|
|
134
|
+
type NavigateFunction,
|
|
121
135
|
type NavigateOptions,
|
|
136
|
+
type NavigateSearchOf,
|
|
137
|
+
type NavigateTargetInput,
|
|
138
|
+
type RouteSearch,
|
|
139
|
+
registerBlocker,
|
|
140
|
+
resolveNavigate,
|
|
141
|
+
setBlockerController,
|
|
122
142
|
setBrowserNavigate,
|
|
123
143
|
} from "./navigation.ts"
|
|
124
|
-
// public/
|
|
144
|
+
// public/ - user-authored static files, served identically in dev and production by one handler.
|
|
125
145
|
export {
|
|
126
146
|
type PublicDirCache,
|
|
127
147
|
resolvePublicPath,
|
|
128
148
|
type ServePublicDirOptions,
|
|
129
149
|
servePublicDir,
|
|
130
150
|
} from "./public-dir.ts"
|
|
131
|
-
// Keyed query-cache (agnostic)
|
|
151
|
+
// Keyed query-cache (agnostic) - a `query(key, fn)` primitive (dedup + staleness + invalidation + GC)
|
|
132
152
|
// consumed by the per-adapter `useQuery`/`createQuery` bindings.
|
|
133
153
|
export {
|
|
134
154
|
createMutation,
|
|
@@ -149,7 +169,7 @@ export {
|
|
|
149
169
|
type QueryState,
|
|
150
170
|
type QueryStatus,
|
|
151
171
|
} from "./query.ts"
|
|
152
|
-
// Agnostic client-side router core (pure + DOM-free)
|
|
172
|
+
// Agnostic client-side router core (pure + DOM-free) - consumed by per-adapter Router bindings.
|
|
153
173
|
// `DATA_HEADER` marks a navigation's data-only GET; `createWebApp` answers it with loader JSON.
|
|
154
174
|
export {
|
|
155
175
|
type ClientRouter,
|
|
@@ -172,27 +192,27 @@ export {
|
|
|
172
192
|
type Submission,
|
|
173
193
|
type SubmitOptions,
|
|
174
194
|
} from "./router.ts"
|
|
175
|
-
// Deferred loader data (`defer()` + the `Deferred<T>` type)
|
|
195
|
+
// Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
|
|
176
196
|
export { type Deferred, defer }
|
|
177
197
|
|
|
178
198
|
/** Phantom brand key for {@link ServerOnly}. A unique symbol so the brand can't be forged by a plain
|
|
179
199
|
* object literal; `unique symbol` keys never appear in the emitted JS, so the brand is purely
|
|
180
200
|
* type-level (zero runtime cost). The property is optional + `never`-typed, so a `ServerOnly<T>`
|
|
181
|
-
* stays assignment-compatible with `T`
|
|
201
|
+
* stays assignment-compatible with `T` - it documents intent without obstructing real use. */
|
|
182
202
|
declare const SERVER_ONLY_BRAND: unique symbol
|
|
183
203
|
|
|
184
204
|
/**
|
|
185
|
-
* 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
|
|
186
206
|
* server-only client. `ServerOnly<T>` is structurally `T` (the brand is an optional phantom field, so
|
|
187
207
|
* existing code keeps type-checking), but it advertises to readers + the compiler that the value is
|
|
188
|
-
* 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,
|
|
189
209
|
* by itself, keep the value out of the client bundle.
|
|
190
210
|
*
|
|
191
211
|
* The enforcement is two runtime conventions:
|
|
192
|
-
* - 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
|
|
193
213
|
* client build ({@link buildClient}) fails loud, with the import chain, if it reaches a browser
|
|
194
214
|
* chunk (the poison-import marker);
|
|
195
|
-
* - or name the file `*.server.ts`
|
|
215
|
+
* - or name the file `*.server.ts` - the `.server` convention empties it in the client build.
|
|
196
216
|
*
|
|
197
217
|
* Use this brand to express the intent in the types; pair it with one of those runtime markers so a
|
|
198
218
|
* leak is caught at build time rather than shipping a secret to the client.
|
|
@@ -210,9 +230,15 @@ export {
|
|
|
210
230
|
type RenderAdapterConformanceFixture,
|
|
211
231
|
} from "./conformance.ts"
|
|
212
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
|
+
|
|
213
239
|
/** The data handed to a route component. Opaque to the core. `actionData` is the return of a
|
|
214
240
|
* route `action` after a POST (absent on plain GETs). `pending` + `submission` are client-only
|
|
215
|
-
* (absent on SSR): they drive **optimistic UI**
|
|
241
|
+
* (absent on SSR): they drive **optimistic UI** - render from `submission.formData` while `pending`. */
|
|
216
242
|
export interface RenderProps {
|
|
217
243
|
readonly data: unknown
|
|
218
244
|
/**
|
|
@@ -235,10 +261,15 @@ export interface RenderProps {
|
|
|
235
261
|
* SSR (from the request match) and on client navigation (from router state) so an adapter's
|
|
236
262
|
* `useParams` reads the same value on both, with no hydration mismatch. Absent ⇒ no params (`{}`). */
|
|
237
263
|
readonly params?: Readonly<Record<string, string>>
|
|
238
|
-
/** 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
|
|
239
265
|
* SSR and client alike so an adapter's `useLocation`/`useSearchParams` hydrate consistently. Absent
|
|
240
266
|
* on adapters/callers that don't supply it (treated as `""`). */
|
|
241
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>
|
|
242
273
|
}
|
|
243
274
|
|
|
244
275
|
/**
|
|
@@ -252,7 +283,7 @@ export interface RenderAdapter {
|
|
|
252
283
|
* (the loader data); each layout wraps the child via its `children`. Returns (or resolves to)
|
|
253
284
|
* a Web `ReadableStream<Uint8Array>`: a streaming renderer flushes the shell first and streams
|
|
254
285
|
* Suspense boundaries as they resolve; a non-streaming renderer may return a one-chunk stream.
|
|
255
|
-
* May be async
|
|
286
|
+
* May be async - e.g. React resolves once the shell is renderable, so a shell-render throw can
|
|
256
287
|
* still map to an error status before any byte is sent.
|
|
257
288
|
*/
|
|
258
289
|
renderToStream(
|
|
@@ -262,10 +293,10 @@ export interface RenderAdapter {
|
|
|
262
293
|
/**
|
|
263
294
|
* Server (optional): render the chain to a complete HTML **string** in one pass, with the same
|
|
264
295
|
* hydration markers `renderToStream` produces. When provided, `renderPage` uses it for any page
|
|
265
|
-
* that does **not** `defer()`
|
|
296
|
+
* that does **not** `defer()` - buffering the document is faster than the streaming pipeline there,
|
|
266
297
|
* because the framework's synchronous renderer (React `renderToString`, Solid `renderToString`,
|
|
267
298
|
* Preact `render`, Vue `renderToString`, Svelte `render`) is markedly cheaper than its streaming
|
|
268
|
-
* renderer
|
|
299
|
+
* renderer - most visibly on Bun, where React Fizz / Solid `renderToStream` are the heaviest. Pages
|
|
269
300
|
* that `defer()` keep the streaming path (progressive `<Await>` needs it). Markup MUST be
|
|
270
301
|
* hydration-equivalent to `renderToStream`; a throw surfaces like the streaming shell-readiness
|
|
271
302
|
* await (the `_error` boundary maps it to a status). Omit it to always stream.
|
|
@@ -275,7 +306,7 @@ export interface RenderAdapter {
|
|
|
275
306
|
* Server: per-document bootstrap markup injected into `<head>` that the client
|
|
276
307
|
* `hydrate` requires (Solid: `generateHydrationScript()`). Empty string if none.
|
|
277
308
|
*/
|
|
278
|
-
hydrationHead(): string
|
|
309
|
+
hydrationHead(nonce?: string): string
|
|
279
310
|
}
|
|
280
311
|
|
|
281
312
|
/** Global the server serializes loader data into; the client reads it to hydrate. */
|
|
@@ -292,12 +323,25 @@ export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
|
|
|
292
323
|
export const ACTION_GLOBAL = "__NIFRA_ACTION__"
|
|
293
324
|
|
|
294
325
|
/**
|
|
295
|
-
*
|
|
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
|
|
296
340
|
* first paint and the island bundle taking over). It neutralizes the one real hydration footgun: a
|
|
297
341
|
* JS-only form (a hand-wired `onSubmit` with no native fallback) submitting *natively* before its
|
|
298
342
|
* handler is attached, which navigates the browser to a broken `?field=…` GET of the current page.
|
|
299
343
|
*
|
|
300
|
-
* It blocks ONLY that shape
|
|
344
|
+
* It blocks ONLY that shape - an effective-GET form whose action targets the current document - and
|
|
301
345
|
* only until hydration commits (`data-nifra-hydrated`, set by the client entry). Method-`post` forms
|
|
302
346
|
* (progressive enhancement: the native POST hits the route `action`) and GET forms with a real action
|
|
303
347
|
* (a search box → `/search`) pass through untouched. Opt out per-form with `data-native`. Static text
|
|
@@ -315,12 +359,12 @@ export const PRE_HYDRATION_GUARD =
|
|
|
315
359
|
/**
|
|
316
360
|
* The single default port for the dev server (`@nifrajs/web/dev`, `@nifrajs/web/vite`) **and**
|
|
317
361
|
* `nifra start`. Deliberately uncommon: `3000`/`5173`/`8080` collide with whatever else is running
|
|
318
|
-
* (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`
|
|
319
363
|
* and `nifra start` means a project's URL doesn't change between commands. Override per-run with
|
|
320
364
|
* `--port <n>` or the `PORT` env var. */
|
|
321
365
|
export const DEFAULT_DEV_PORT = 4321
|
|
322
366
|
|
|
323
|
-
// Shared, stateless
|
|
367
|
+
// Shared, stateless - allocated once at module load, not per render/stream.
|
|
324
368
|
const TEXT_ENCODER = new TextEncoder()
|
|
325
369
|
|
|
326
370
|
type MaybePromise<T> = T | Promise<T>
|
|
@@ -353,21 +397,27 @@ export interface RenderedPage {
|
|
|
353
397
|
|
|
354
398
|
export interface RenderPageOptions {
|
|
355
399
|
readonly adapter: RenderAdapter
|
|
356
|
-
/** The layout chain to render
|
|
400
|
+
/** The layout chain to render - outermost layout → page (opaque; the adapter renders it). */
|
|
357
401
|
readonly chain: readonly unknown[]
|
|
358
402
|
/** The loader output for this request. */
|
|
359
403
|
readonly data: unknown
|
|
360
|
-
/** An action's data return (POST only)
|
|
404
|
+
/** An action's data return (POST only) - surfaced to the page as `actionData` + serialized
|
|
361
405
|
* for the client so post-POST hydration matches. Omit on GETs. */
|
|
362
406
|
readonly actionData?: unknown
|
|
363
|
-
/**
|
|
364
|
-
|
|
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
|
|
365
415
|
/** Chunk URLs for the **matched** route (its layout chain + own chunk) to `modulepreload` in the
|
|
366
|
-
* shell
|
|
416
|
+
* shell - so the route code downloads in parallel with the entry instead of after it
|
|
367
417
|
* (`buildClient`'s per-route map). Empty/omitted ⇒ only the entry is preloaded (unchanged). */
|
|
368
418
|
readonly preload?: readonly string[]
|
|
369
419
|
/** Stylesheet URLs for the **matched** route (its layout chain + own CSS, from `buildClient`'s
|
|
370
|
-
* `BuildManifest.css`)
|
|
420
|
+
* `BuildManifest.css`) - injected as `<link rel="stylesheet">` in `<head>` so styles arrive with the
|
|
371
421
|
* first paint (no FOUC). Rendered even on non-hydrated pages. Empty/omitted ⇒ none (unchanged). */
|
|
372
422
|
readonly styles?: readonly string[]
|
|
373
423
|
/** SSG: the prerendered-path set, serialized to `window.__NIFRA_PRERENDERED__` so the client fetches
|
|
@@ -378,14 +428,18 @@ export interface RenderPageOptions {
|
|
|
378
428
|
readonly revalidate?: number
|
|
379
429
|
/** Matched route id; written to `window.__NIFRA_ROUTE__` so the client hydrates this chain. */
|
|
380
430
|
readonly routeId?: string
|
|
381
|
-
/** The matched route's decoded path params
|
|
431
|
+
/** The matched route's decoded path params - surfaced to the page as `params` (via {@link RenderProps})
|
|
382
432
|
* so an adapter's `useParams` is SSR-correct. Omit ⇒ `{}` (a route with no dynamic segments, or an
|
|
383
433
|
* error/404 render). */
|
|
384
434
|
readonly params?: Readonly<Record<string, string>>
|
|
385
|
-
/** The request's `pathname + search`
|
|
435
|
+
/** The request's `pathname + search` - surfaced as `path` (via {@link RenderProps}) so an adapter's
|
|
386
436
|
* `useLocation`/`useSearchParams` render the right URL server-side and hydrate without drift. Omit ⇒
|
|
387
437
|
* `""`. */
|
|
388
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>
|
|
389
443
|
/** Per-layout loader data, forwarded to the adapter as `RenderProps.layoutData`. Aligned with the
|
|
390
444
|
* chain's layout prefix; omitted when no layout in the chain has a loader. */
|
|
391
445
|
readonly layoutData?: readonly unknown[]
|
|
@@ -397,45 +451,57 @@ export interface RenderPageOptions {
|
|
|
397
451
|
readonly headers?: HeadersLike
|
|
398
452
|
/** Document title (fallback when `head.title` is unset). */
|
|
399
453
|
readonly title?: string
|
|
400
|
-
/** Resolved route head
|
|
454
|
+
/** Resolved route head - `title` overrides `title` above; `meta`/`link` render as managed
|
|
401
455
|
* (`data-nifra`) tags the client updates on navigation.
|
|
402
456
|
*
|
|
403
457
|
* **Head contract (the layout chain contributes).** `createWebApp` resolves this via
|
|
404
458
|
* {@link mergeHeads}: a route's head is its **layout chain's** `meta`/`head` exports merged with the
|
|
405
|
-
* 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
|
|
406
460
|
* on every page below it (the home for `hreflang`, `preconnect`, a section-default `<title>`). The
|
|
407
461
|
* merge is **nearest-wins for scalars** (the page's `title` overrides an inner layout's, which
|
|
408
462
|
* overrides an outer one; an undefined page title keeps the layout's) and **concatenated for the
|
|
409
|
-
* `meta`/`link` arrays** (outermost layout first, page last). `<link>` attributes
|
|
410
|
-
* (
|
|
411
|
-
* `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. */
|
|
412
465
|
readonly head?: Meta
|
|
413
|
-
/** 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. */
|
|
414
469
|
readonly rootId?: string
|
|
415
|
-
/** When `false`, emit a complete but **non-hydrated** document
|
|
470
|
+
/** When `false`, emit a complete but **non-hydrated** document - no client entry script, data
|
|
416
471
|
* globals, or modulepreloads. Used for server-rendered `_error` pages: a terminal state that needs no
|
|
417
472
|
* client takeover, and it sidesteps an SSR/hydrate mismatch (the server rendered the boundary, not
|
|
418
473
|
* the page the client manifest maps this route id to). Default `true`. */
|
|
419
474
|
readonly hydrate?: boolean
|
|
420
475
|
/** Island client bundles (`@nifrajs/web/islands`) to load as `<script type="module">` in the document
|
|
421
|
-
* tail
|
|
476
|
+
* tail - emitted **regardless of `hydrate`**, so a static (`hydrate: false`) page can still mount
|
|
422
477
|
* no-framework islands. URLs are attribute-escaped. Empty/omitted ⇒ none (unchanged output). */
|
|
423
478
|
readonly islandScripts?: readonly string[]
|
|
479
|
+
/** CSP nonce applied to every framework-owned executable script in this document. */
|
|
480
|
+
readonly nonce?: string
|
|
424
481
|
}
|
|
425
482
|
|
|
426
483
|
/**
|
|
427
|
-
*
|
|
428
|
-
*
|
|
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`.
|
|
429
495
|
* The shell (`<head>` + the open container) flushes first, the adapter's app stream follows,
|
|
430
496
|
* then the tail (data globals + client entry). Pure Web Standards, so it returns straight from
|
|
431
497
|
* a nifra route handler and streams on any fetch runtime (Bun/Node/Deno/Workers).
|
|
432
498
|
*/
|
|
433
|
-
export function renderPage(options:
|
|
499
|
+
export function renderPage(options: RenderPageInput): MaybePromise<Response> {
|
|
434
500
|
const page = renderPageResult(options)
|
|
435
501
|
return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse()
|
|
436
502
|
}
|
|
437
503
|
|
|
438
|
-
export function renderPageResult(options:
|
|
504
|
+
export function renderPageResult(options: RenderPageInput): MaybePromise<RenderedPage> {
|
|
439
505
|
const {
|
|
440
506
|
adapter,
|
|
441
507
|
chain,
|
|
@@ -453,10 +519,15 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
453
519
|
rootId = "root",
|
|
454
520
|
hydrate = true,
|
|
455
521
|
islandScripts = [],
|
|
522
|
+
nonce,
|
|
456
523
|
headers: extraHeaders,
|
|
457
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)}"`
|
|
458
529
|
const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`
|
|
459
|
-
// 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
|
|
460
531
|
// `_data.json` on soft-nav into a prerendered route instead of hitting the worker. Empty ⇒ omitted.
|
|
461
532
|
const prerendered =
|
|
462
533
|
prerenderedPaths.length === 0
|
|
@@ -464,7 +535,7 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
464
535
|
: `window.${PRERENDERED_GLOBAL}=${serializeData(prerenderedPaths)};`
|
|
465
536
|
// Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
|
|
466
537
|
// data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
|
|
467
|
-
// also `defer()`
|
|
538
|
+
// also `defer()` - split it too, continuing the id space so a single registry settles both. The
|
|
468
539
|
// inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
|
|
469
540
|
const { forComponent, forClient, deferred } = prepareDeferred(data)
|
|
470
541
|
// Each layout's data is split in turn, each continuing the id space, so a `defer()` in a layout
|
|
@@ -484,7 +555,7 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
484
555
|
? undefined
|
|
485
556
|
: prepareDeferred(actionData, deferred.length + layoutDeferred.length)
|
|
486
557
|
const allDeferred = [...deferred, ...layoutDeferred, ...(actionSplit ? actionSplit.deferred : [])]
|
|
487
|
-
// Omitted entirely when no layout has a loader
|
|
558
|
+
// Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
|
|
488
559
|
const layoutTail =
|
|
489
560
|
options.layoutData === undefined
|
|
490
561
|
? ""
|
|
@@ -495,9 +566,10 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
495
566
|
actionSplit === undefined
|
|
496
567
|
? ""
|
|
497
568
|
: `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`
|
|
498
|
-
const deferredRuntime =
|
|
569
|
+
const deferredRuntime =
|
|
570
|
+
allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : ""
|
|
499
571
|
// Matched-route chunk preloads, concatenated directly (skip the `filter().map()`
|
|
500
|
-
// intermediate arrays
|
|
572
|
+
// intermediate arrays - and the whole loop - on the common no-extra-preload render). De-duped
|
|
501
573
|
// against the entry, which is preloaded separately below.
|
|
502
574
|
let preloadLinks = ""
|
|
503
575
|
if (preload.length > 0) {
|
|
@@ -506,7 +578,7 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
506
578
|
preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`
|
|
507
579
|
}
|
|
508
580
|
}
|
|
509
|
-
// The matched route's stylesheets
|
|
581
|
+
// The matched route's stylesheets - `<link rel="stylesheet">` in `<head>` so CSS arrives with the
|
|
510
582
|
// first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static or
|
|
511
583
|
// `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead, so
|
|
512
584
|
// `styles` is empty there.
|
|
@@ -514,44 +586,51 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
514
586
|
for (const url of styles) styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`
|
|
515
587
|
// Island bundles are referenced only by a `<script type="module">` at the END of `<body>` (below), so
|
|
516
588
|
// the browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
|
|
517
|
-
// 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
|
|
518
590
|
// is typically hydrate:false). Without this, a heavy island bundle on a cold first load leaves its
|
|
519
591
|
// server-rendered placeholder visible until that late, un-prefetched fetch lands ("stuck loading").
|
|
520
592
|
let islandPreloads = ""
|
|
521
593
|
for (const src of islandScripts)
|
|
522
594
|
islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`
|
|
523
|
-
// Shell
|
|
524
|
-
// open container. `modulepreload` of the client entry
|
|
525
|
-
// (`preloadLinks`)
|
|
526
|
-
// 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.
|
|
527
599
|
// A non-hydrated page (e.g. an `_error` boundary) omits the client-entry preload, route-chunk
|
|
528
|
-
// preloads, and deferred runtime
|
|
600
|
+
// preloads, and deferred runtime - there's no client takeover to feed.
|
|
529
601
|
const hydrationLinks = hydrate
|
|
530
|
-
? `<link rel="modulepreload" href="${escapeAttr(clientEntry)}">${preloadLinks}${deferredRuntime}`
|
|
602
|
+
? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}${deferredRuntime}`
|
|
531
603
|
: ""
|
|
532
604
|
// Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only on a
|
|
533
605
|
// hydrating page (a static/_error page has no client handlers, so no footgun).
|
|
534
|
-
const hydrationGuard = hydrate ? `<script>${PRE_HYDRATION_GUARD}</script>` : ""
|
|
606
|
+
const hydrationGuard = hydrate ? `<script${nonceAttr}>${PRE_HYDRATION_GUARD}</script>` : ""
|
|
535
607
|
// `<html>` attributes. `lang` defaults to `"en"` (so an app that never sets it is byte-identical to
|
|
536
|
-
// before); `dir` is omitted entirely when unset, which IS HTML's `ltr` default
|
|
608
|
+
// before); `dir` is omitted entirely when unset, which IS HTML's `ltr` default - emitting it
|
|
537
609
|
// unconditionally would change every existing app's output for no gain. Both attribute-escaped.
|
|
538
610
|
// `client.ts`'s `applyHead` mirrors this exact defaulting on soft-nav, so a hard load and a client
|
|
539
611
|
// navigation to the same URL produce the same `<html>` (no drift on a multilingual site).
|
|
540
612
|
const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`
|
|
541
|
-
|
|
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}>`
|
|
542
621
|
// Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
|
|
543
622
|
// aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
|
|
544
623
|
const closeRootHtml = "</div>"
|
|
545
|
-
// Tail
|
|
624
|
+
// Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
|
|
546
625
|
// the data global + every streamed deferred resolution are set before the entry hydrates.
|
|
547
|
-
// Island bundles load regardless of `hydrate`
|
|
626
|
+
// Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
|
|
548
627
|
// client but can still mount no-framework islands (`@nifrajs/web/islands`).
|
|
549
628
|
let islandTags = ""
|
|
550
629
|
for (const src of islandScripts)
|
|
551
|
-
islandTags += `<script type="module" src="${escapeAttr(src)}"></script>`
|
|
630
|
+
islandTags += `<script type="module" src="${escapeAttr(src)}"${nonceAttr}></script>`
|
|
552
631
|
const tailHtml = `${
|
|
553
632
|
hydrate
|
|
554
|
-
? `<script>${route}${action}${prerendered}${layoutTail}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>`
|
|
555
634
|
: ""
|
|
556
635
|
}${islandTags}</body></html>`
|
|
557
636
|
const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
|
|
@@ -571,10 +650,13 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
571
650
|
data: forComponent,
|
|
572
651
|
actionData: actionSplit?.forComponent,
|
|
573
652
|
// `params`/`path` thread the matched route + URL to the adapter's `useParams`/`useLocation` so they
|
|
574
|
-
// render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes)
|
|
653
|
+
// render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes) - an adapter with no
|
|
575
654
|
// router bindings simply never reads them.
|
|
576
655
|
...(options.params !== undefined ? { params: options.params } : {}),
|
|
577
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 } : {}),
|
|
578
660
|
// Spread only when present, so a page-only render produces exactly the props it did before.
|
|
579
661
|
...(options.layoutData !== undefined
|
|
580
662
|
? { layoutData: layoutSplits.map((split) => split.forComponent) }
|
|
@@ -611,6 +693,7 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
611
693
|
tailHtml,
|
|
612
694
|
status,
|
|
613
695
|
headers,
|
|
696
|
+
nonceAttr,
|
|
614
697
|
).then((response) => new ResponseRenderedPage(response))
|
|
615
698
|
}
|
|
616
699
|
|
|
@@ -624,8 +707,9 @@ async function renderStreamedPage(
|
|
|
624
707
|
tailHtml: string,
|
|
625
708
|
status: number,
|
|
626
709
|
headers: Record<string, string>,
|
|
710
|
+
nonceAttr: string,
|
|
627
711
|
): Promise<Response> {
|
|
628
|
-
// Streaming path
|
|
712
|
+
// Streaming path - required for `defer()` (progressive `<Await>` resolution) and used by any adapter
|
|
629
713
|
// that doesn't implement `renderToString`. Awaiting `renderToStream` resolves on shell-readiness
|
|
630
714
|
// (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
|
|
631
715
|
// sent. A *mid*-stream failure errors the body instead.
|
|
@@ -634,7 +718,7 @@ async function renderStreamedPage(
|
|
|
634
718
|
const closeRoot = enc.encode(closeRootHtml)
|
|
635
719
|
const tail = enc.encode(tailHtml)
|
|
636
720
|
const appStream = await adapter.renderToStream(chain, renderProps)
|
|
637
|
-
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc)
|
|
721
|
+
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr)
|
|
638
722
|
return new Response(body, { status, headers })
|
|
639
723
|
}
|
|
640
724
|
|
|
@@ -688,8 +772,8 @@ function htmlResponse(body: string, init: ResponseInit): Response {
|
|
|
688
772
|
/**
|
|
689
773
|
* Assemble the document stream: `shell` → the app `stream` (forwarded chunk-by-chunk, so a streaming
|
|
690
774
|
* renderer's progressive flushing is preserved) → `closeRoot` → one `__nifraResolve`/`__nifraReject`
|
|
691
|
-
* script per deferred value (emitted once its promise settles
|
|
692
|
-
* 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
|
|
693
777
|
* the result (the body breaks) rather than silently truncating a 200.
|
|
694
778
|
*/
|
|
695
779
|
function streamDocument(
|
|
@@ -699,6 +783,7 @@ function streamDocument(
|
|
|
699
783
|
deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
|
|
700
784
|
tail: Uint8Array,
|
|
701
785
|
enc: TextEncoder,
|
|
786
|
+
nonceAttr: string,
|
|
702
787
|
): ReadableStream<Uint8Array> {
|
|
703
788
|
return new ReadableStream({
|
|
704
789
|
async start(controller) {
|
|
@@ -711,23 +796,23 @@ function streamDocument(
|
|
|
711
796
|
controller.enqueue(value)
|
|
712
797
|
}
|
|
713
798
|
controller.enqueue(closeRoot)
|
|
714
|
-
// Stream each resolution as ITS OWN promise settles
|
|
799
|
+
// Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
|
|
715
800
|
// defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
|
|
716
801
|
await Promise.all(
|
|
717
802
|
deferred.map(async (d) => {
|
|
718
803
|
try {
|
|
719
804
|
const value = serializeData(await d.promise)
|
|
720
805
|
controller.enqueue(
|
|
721
|
-
enc.encode(`<script>window.__nifraResolve(${d.id},${value})</script>`),
|
|
806
|
+
enc.encode(`<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>`),
|
|
722
807
|
)
|
|
723
808
|
} catch (err) {
|
|
724
|
-
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it)
|
|
809
|
+
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
|
|
725
810
|
// not break the whole body. Redact: stream a stable opaque code, never the raw error
|
|
726
811
|
// text; log the real reason server-side.
|
|
727
812
|
console.error("[nifra/web] deferred value rejected:", err)
|
|
728
813
|
controller.enqueue(
|
|
729
814
|
enc.encode(
|
|
730
|
-
`<script>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`,
|
|
815
|
+
`<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`,
|
|
731
816
|
),
|
|
732
817
|
)
|
|
733
818
|
}
|
|
@@ -762,12 +847,12 @@ function isSameOriginPath(location: string): boolean {
|
|
|
762
847
|
}
|
|
763
848
|
|
|
764
849
|
/**
|
|
765
|
-
* Build a redirect `Response`
|
|
850
|
+
* Build a redirect `Response` - return it from a route `action` for the Post/Redirect/Get
|
|
766
851
|
* pattern (POST mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit).
|
|
767
852
|
* Defaults to 303 (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
|
|
768
853
|
*
|
|
769
854
|
* **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
|
|
770
|
-
* off-origin/absolute destination throws unless you pass `{ external: true }`
|
|
855
|
+
* off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
|
|
771
856
|
* open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
|
|
772
857
|
* which returns the action's `Response` verbatim.
|
|
773
858
|
*
|
|
@@ -779,13 +864,13 @@ export function redirect(location: string, options: RedirectOptions = {}): Respo
|
|
|
779
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.`,
|
|
780
865
|
)
|
|
781
866
|
}
|
|
782
|
-
// Reject CR/LF in the Location explicitly
|
|
867
|
+
// Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
|
|
783
868
|
// injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
|
|
784
869
|
// unvalidated input reach this sink, so we don't rely on the runtime. Same posture as
|
|
785
870
|
// serializeCookie / the SSE frame formatter, which strip CRLF at their sinks.
|
|
786
871
|
if (/[\r\n]/.test(location)) {
|
|
787
872
|
throw new Error(
|
|
788
|
-
`[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.`,
|
|
789
874
|
)
|
|
790
875
|
}
|
|
791
876
|
return new Response(null, { status: options.status ?? 303, headers: { location } })
|
|
@@ -854,6 +939,9 @@ type LoadedLayoutModules = ReadonlyArray<{
|
|
|
854
939
|
loader?: LayoutLoader
|
|
855
940
|
action?: unknown
|
|
856
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"]
|
|
857
945
|
}>
|
|
858
946
|
|
|
859
947
|
/**
|
|
@@ -992,7 +1080,7 @@ export function statusPage(status: number, options: StatusPageOptions = {}): nev
|
|
|
992
1080
|
|
|
993
1081
|
/** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged
|
|
994
1082
|
* shape (not a class) so `@nifrajs/client`'s `ActionData` can unwrap it structurally without importing
|
|
995
|
-
* from `@nifrajs/web`. `createWebApp` strips the wrapper
|
|
1083
|
+
* from `@nifrajs/web`. `createWebApp` strips the wrapper - the client receives `data` as the body and
|
|
996
1084
|
* the paths via the `X-Nifra-Revalidate` header. */
|
|
997
1085
|
export interface RevalidateResult<T> {
|
|
998
1086
|
readonly __nifraRevalidate: readonly string[]
|
|
@@ -1002,7 +1090,7 @@ export interface RevalidateResult<T> {
|
|
|
1002
1090
|
/**
|
|
1003
1091
|
* Return this from an action to declare which routes the mutation changed (alongside the action's
|
|
1004
1092
|
* `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client
|
|
1005
|
-
* marks those cached routes stale
|
|
1093
|
+
* marks those cached routes stale - refetching the active one and any mounted fetcher showing them -
|
|
1006
1094
|
* so a mutation can refresh views beyond the one that was submitted. `data` is still surfaced to the
|
|
1007
1095
|
* component as `actionData` (the wrapper is transparent to `ActionData<typeof action>`).
|
|
1008
1096
|
*/
|
|
@@ -1027,10 +1115,10 @@ function escapeHtml(value: string): string {
|
|
|
1027
1115
|
/**
|
|
1028
1116
|
* Escape a `<script>` element's text content so it cannot break out of the element. Per the HTML spec,
|
|
1029
1117
|
* a script's text may contain any character EXCEPT the sequences that the parser treats as element
|
|
1030
|
-
* boundaries: `</` (which would begin the end tag
|
|
1118
|
+
* boundaries: `</` (which would begin the end tag - `</script>` is the obvious XSS vector, but ANY `</`
|
|
1031
1119
|
* starts script-data-end-tag parsing), and the comment/CDATA edges `<!--` / `]]>`. We escape the `<`
|
|
1032
1120
|
* (and the `>` of `]]>`) to its JS unicode escape: inside a JS/JSON string literal `<` is the
|
|
1033
|
-
* 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
|
|
1034
1122
|
* HTML tokenizer scans for is gone. This is the JSON-LD-in-HTML rule (content is JSON, never raw HTML),
|
|
1035
1123
|
* mirroring {@link serializeData}'s posture for the data-global script. Idempotent on already-safe text.
|
|
1036
1124
|
*/
|
|
@@ -1049,27 +1137,12 @@ function escapeAttr(value: string): string {
|
|
|
1049
1137
|
.replaceAll(">", ">")
|
|
1050
1138
|
}
|
|
1051
1139
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
// `disabled`, `color`, `fetchpriority` — alongside `<meta>`'s `name`/`property`/`content` and custom
|
|
1057
|
-
// `data-*`. A hardcoded list would silently drop valid attrs (the bug this guards against: `hreflang`
|
|
1058
|
-
// /`crossorigin` getting filtered out). What it rejects is name-shape abuse only — a name with a space,
|
|
1059
|
-
// `=`, `>`, quote, or a leading digit can't break out of the tag. Attribute VALUES are escaped
|
|
1060
|
-
// separately (they may carry loader data → XSS), so a widened name set never widens the injection
|
|
1061
|
-
// surface. Names that fail the shape check are dropped.
|
|
1062
|
-
const SAFE_ATTR_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/
|
|
1063
|
-
|
|
1064
|
-
/** Serialize a meta/link tag's attributes: validate the name shape (see {@link SAFE_ATTR_NAME}) +
|
|
1065
|
-
* escape the value against XSS. Invalid attribute names are dropped. Values follow the HTML attribute
|
|
1066
|
-
* conventions {@link LinkDescriptor} types: a string renders `name="escaped"`; `true` renders the bare
|
|
1067
|
-
* boolean attribute (`disabled`); `false` and `undefined` are skipped (a conditionally-absent attr). */
|
|
1068
|
-
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
|
|
1069
1144
|
let out = ""
|
|
1070
|
-
for (const [name, value] of
|
|
1071
|
-
if (value === undefined || value === false) continue // omitted / absent boolean attribute
|
|
1072
|
-
if (!SAFE_ATTR_NAME.test(name)) continue // name-shape abuse — dropped (see SAFE_ATTR_NAME)
|
|
1145
|
+
for (const [name, value] of trusted) {
|
|
1073
1146
|
if (out !== "") out += " "
|
|
1074
1147
|
out += value === true ? name : `${name}="${escapeAttr(value)}"`
|
|
1075
1148
|
}
|
|
@@ -1083,11 +1156,19 @@ function tagAttrs(attrs: Record<string, string | boolean | undefined>): string {
|
|
|
1083
1156
|
// content can vary). WeakMap so a route module that's GC'd takes its entry with it.
|
|
1084
1157
|
const headTagsCache = new WeakMap<Meta, string>()
|
|
1085
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
|
+
|
|
1086
1167
|
/** Render a route's `meta`/`link`/`script` as managed (`data-nifra`) head tags. Title is set
|
|
1087
|
-
* separately. XSS-safe by construction: every attribute flows through
|
|
1088
|
-
*
|
|
1089
|
-
* (breakout-escaped)
|
|
1090
|
-
* 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
|
|
1091
1172
|
* the already concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output. Result
|
|
1092
1173
|
* is memoized per resolved-`Meta` identity (static meta → serialized once per route). */
|
|
1093
1174
|
function headTags(head: Meta | undefined): string {
|
|
@@ -1095,14 +1176,43 @@ function headTags(head: Meta | undefined): string {
|
|
|
1095
1176
|
const cached = headTagsCache.get(head)
|
|
1096
1177
|
if (cached !== undefined) return cached
|
|
1097
1178
|
let out = ""
|
|
1098
|
-
if (head.meta !== undefined)
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
|
1101
1190
|
// breakout-escaped (`escapeScriptContent`) so a `</script>` (or `<!--`/`]]>`) payload can't close the
|
|
1102
1191
|
// element early. Managed (`data-nifra`) so a soft-nav's `applyHead` cleanly replaces it.
|
|
1103
1192
|
if (head.script !== undefined)
|
|
1104
|
-
for (const s of head.script)
|
|
1105
|
-
|
|
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
|
+
}
|
|
1106
1216
|
headTagsCache.set(head, out)
|
|
1107
1217
|
return out
|
|
1108
1218
|
}
|
|
@@ -1125,7 +1235,7 @@ export function canonical(href: string): LinkDescriptor {
|
|
|
1125
1235
|
return { rel: "canonical", href }
|
|
1126
1236
|
}
|
|
1127
1237
|
|
|
1128
|
-
/** Inputs for {@link openGraph}
|
|
1238
|
+
/** Inputs for {@link openGraph} - the common Open Graph properties. All optional; only the provided
|
|
1129
1239
|
* ones become tags. `type` defaults to `"website"`. */
|
|
1130
1240
|
export interface OpenGraphInput {
|
|
1131
1241
|
readonly title?: string
|
|
@@ -1134,7 +1244,7 @@ export interface OpenGraphInput {
|
|
|
1134
1244
|
readonly image?: string
|
|
1135
1245
|
/** Canonical URL of the page (`og:url`). */
|
|
1136
1246
|
readonly url?: string
|
|
1137
|
-
/** Object type (`og:type`)
|
|
1247
|
+
/** Object type (`og:type`) - `"website"`, `"article"`, … Default `"website"`. */
|
|
1138
1248
|
readonly type?: string
|
|
1139
1249
|
}
|
|
1140
1250
|
|
|
@@ -1146,8 +1256,8 @@ export interface OpenGraphInput {
|
|
|
1146
1256
|
* export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
|
|
1147
1257
|
* ```
|
|
1148
1258
|
*/
|
|
1149
|
-
export function openGraph(input: OpenGraphInput):
|
|
1150
|
-
const tags:
|
|
1259
|
+
export function openGraph(input: OpenGraphInput): MetaDescriptor[] {
|
|
1260
|
+
const tags: MetaDescriptor[] = []
|
|
1151
1261
|
const add = (property: string, content: string | undefined): void => {
|
|
1152
1262
|
if (content !== undefined) tags.push({ property, content })
|
|
1153
1263
|
}
|
|
@@ -1155,7 +1265,7 @@ export function openGraph(input: OpenGraphInput): Array<Record<string, string>>
|
|
|
1155
1265
|
add("og:description", input.description)
|
|
1156
1266
|
add("og:image", input.image)
|
|
1157
1267
|
add("og:url", input.url)
|
|
1158
|
-
// `og:type` always present (the spec's required property)
|
|
1268
|
+
// `og:type` always present (the spec's required property) - default "website" when unset.
|
|
1159
1269
|
tags.push({ property: "og:type", content: input.type ?? "website" })
|
|
1160
1270
|
return tags
|
|
1161
1271
|
}
|
|
@@ -1175,6 +1285,30 @@ export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
|
|
|
1175
1285
|
return { type: "application/ld+json", content: JSON.stringify(data) }
|
|
1176
1286
|
}
|
|
1177
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
|
+
|
|
1178
1312
|
/**
|
|
1179
1313
|
* Merge a route's `<head>` contributions from its layout chain + the page into one {@link Meta}.
|
|
1180
1314
|
*
|
|
@@ -1182,28 +1316,29 @@ export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
|
|
|
1182
1316
|
* `meta` to put sitewide tags (`hreflang`, `preconnect`, a default `<title>`, …) on every page below
|
|
1183
1317
|
* it. `heads` is passed **outermost layout → … → innermost layout → page** (the same order as the
|
|
1184
1318
|
* render chain), and merges:
|
|
1185
|
-
* - **`title`**
|
|
1319
|
+
* - **`title`** - *nearest-wins*: the last defined value across the list, so the page overrides an
|
|
1186
1320
|
* inner layout, which overrides an outer one. A layout `title` is the section default; an undefined
|
|
1187
1321
|
* page `title` keeps it.
|
|
1188
|
-
* - **`meta` / `link`**
|
|
1322
|
+
* - **`meta` / `link`** - *concatenated* in list order (outermost first, page last). Duplicate-tag
|
|
1189
1323
|
* de-duplication is the caller's concern; the framework emits exactly what's declared so a layout
|
|
1190
1324
|
* can ship N `<link rel="alternate" hreflang>` tags and a page can add its own canonical.
|
|
1191
1325
|
*
|
|
1192
1326
|
* Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
|
|
1193
|
-
* 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
|
|
1194
1328
|
* fine: {@link headTags}'s memo is keyed on identity, so a per-request merge simply recomputes (its
|
|
1195
1329
|
* content can vary with loader data anyway).
|
|
1196
1330
|
*/
|
|
1197
1331
|
export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
1198
|
-
// 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
|
|
1199
1333
|
// resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
|
|
1200
1334
|
if (heads.length === 1) return heads[0] as Meta
|
|
1201
1335
|
let title: string | undefined
|
|
1202
1336
|
let lang: string | undefined
|
|
1203
1337
|
let dir: Meta["dir"]
|
|
1204
|
-
const meta:
|
|
1338
|
+
const meta: MetaDescriptor[] = []
|
|
1205
1339
|
const link: LinkDescriptor[] = []
|
|
1206
1340
|
const script: ScriptDescriptor[] = []
|
|
1341
|
+
const unsafeScript: UnsafeScriptDescriptor[] = []
|
|
1207
1342
|
for (const h of heads) {
|
|
1208
1343
|
if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
|
|
1209
1344
|
if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
|
|
@@ -1211,8 +1346,9 @@ export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
|
1211
1346
|
if (h.meta !== undefined) meta.push(...h.meta)
|
|
1212
1347
|
if (h.link !== undefined) link.push(...h.link)
|
|
1213
1348
|
if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
|
|
1349
|
+
if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
|
|
1214
1350
|
}
|
|
1215
|
-
// 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`/
|
|
1216
1352
|
// `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
|
|
1217
1353
|
// is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
|
|
1218
1354
|
// `undefined`).
|
|
@@ -1221,6 +1357,7 @@ export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
|
1221
1357
|
meta?: Meta["meta"]
|
|
1222
1358
|
link?: Meta["link"]
|
|
1223
1359
|
script?: Meta["script"]
|
|
1360
|
+
unsafeScript?: Meta["unsafeScript"]
|
|
1224
1361
|
lang?: string
|
|
1225
1362
|
dir?: Meta["dir"]
|
|
1226
1363
|
} = {}
|
|
@@ -1228,6 +1365,7 @@ export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
|
1228
1365
|
if (meta.length > 0) merged.meta = meta
|
|
1229
1366
|
if (link.length > 0) merged.link = link
|
|
1230
1367
|
if (script.length > 0) merged.script = script
|
|
1368
|
+
if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
|
|
1231
1369
|
if (lang !== undefined) merged.lang = lang
|
|
1232
1370
|
if (dir !== undefined) merged.dir = dir
|
|
1233
1371
|
return merged as Meta
|
|
@@ -1240,7 +1378,7 @@ export interface CreateWebAppOptions {
|
|
|
1240
1378
|
readonly clientEntry: string
|
|
1241
1379
|
/** Default document title for all pages. */
|
|
1242
1380
|
readonly title?: string
|
|
1243
|
-
/** Injected into each loader's `ctx.api`
|
|
1381
|
+
/** Injected into each loader's `ctx.api` - typically an `inProcessClient(app)` (typed
|
|
1244
1382
|
* per-route via `@nifrajs/client`'s `createRoutes`). Opaque to the core.
|
|
1245
1383
|
*
|
|
1246
1384
|
* **Auto-mount.** Every `inProcessClient(backend)` exposes the symbol-keyed platform-aware backend
|
|
@@ -1287,15 +1425,15 @@ export interface CreateWebAppOptions {
|
|
|
1287
1425
|
* signed `__nifra_draft` cookie gets `ctx.draft === true` in loaders/actions (else always `false`).
|
|
1288
1426
|
* Pair with `withISR({ draftSecret })` so editors bypass the cache. Omit to disable draft mode. */
|
|
1289
1427
|
readonly draftSecret?: string
|
|
1290
|
-
/** Per-route chunk URLs (`buildClient`'s `BuildManifest.routes`)
|
|
1428
|
+
/** Per-route chunk URLs (`buildClient`'s `BuildManifest.routes`) - `routeId → [layout chunks…, own
|
|
1291
1429
|
* chunk]`. When present, each page `modulepreload`s its matched route's chunks alongside the entry,
|
|
1292
1430
|
* so the route code downloads in parallel (no entry→route-chunk waterfall). Omit ⇒ entry-only. */
|
|
1293
1431
|
readonly routePreload?: Readonly<Record<string, readonly string[]>>
|
|
1294
|
-
/** The app's bundled stylesheet URLs (`buildClient`'s `BuildManifest.css`)
|
|
1432
|
+
/** The app's bundled stylesheet URLs (`buildClient`'s `BuildManifest.css`) - the aggregate, injected
|
|
1295
1433
|
* as `<link rel="stylesheet">` in a page's `<head>`. Used as the fallback for any route absent from
|
|
1296
1434
|
* {@link routeStyles}. Omit ⇒ no links (dev, where Vite injects CSS, or a CSS-free app). */
|
|
1297
1435
|
readonly styles?: readonly string[]
|
|
1298
|
-
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`)
|
|
1436
|
+
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - `routeId → [chain CSS]`.
|
|
1299
1437
|
* When a matched route has an entry here, only those (its layout chain + own CSS) are linked instead
|
|
1300
1438
|
* of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
|
|
1301
1439
|
* page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
|
|
@@ -1306,14 +1444,14 @@ export interface CreateWebAppOptions {
|
|
|
1306
1444
|
readonly prerenderedPaths?: readonly string[]
|
|
1307
1445
|
/** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
|
|
1308
1446
|
* the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
|
|
1309
|
-
* `prerenderedPaths` with the 404 page
|
|
1447
|
+
* `prerenderedPaths` with the 404 page - the unlisted path simply doesn't exist. `"ssr"` (the
|
|
1310
1448
|
* default for unmapped routes) renders unlisted paths on-demand. */
|
|
1311
1449
|
readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
|
|
1312
|
-
/** Observe every loader/action failure
|
|
1450
|
+
/** Observe every loader/action failure - for error-reporting plugins (Sentry-style). Called for
|
|
1313
1451
|
* real throws (not control-flow `Response`s like `redirect`), **before** the nearest `_error`
|
|
1314
|
-
* 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
|
|
1315
1453
|
* otherwise hide. Observation only; its own throws are swallowed so a faulty reporter can't break
|
|
1316
|
-
* rendering. (`beforeLoader` is intentionally omitted
|
|
1454
|
+
* rendering. (`beforeLoader` is intentionally omitted - the core HTTP hooks already cover
|
|
1317
1455
|
* pre-request work.) */
|
|
1318
1456
|
readonly onLoaderError?: (
|
|
1319
1457
|
error: unknown,
|
|
@@ -1325,7 +1463,7 @@ export interface CreateWebAppOptions {
|
|
|
1325
1463
|
) => void
|
|
1326
1464
|
}
|
|
1327
1465
|
|
|
1328
|
-
/** The handler context fields createWebApp uses
|
|
1466
|
+
/** The handler context fields createWebApp uses - a structural subset of nifra's `Context`. */
|
|
1329
1467
|
interface RouteContext {
|
|
1330
1468
|
readonly params: Record<string, string>
|
|
1331
1469
|
readonly req: Request
|
|
@@ -1368,15 +1506,15 @@ function backendMountOf(api: unknown): BackendMountHandler | undefined {
|
|
|
1368
1506
|
/**
|
|
1369
1507
|
* Build a nifra app from a route manifest: every route SSRs its layout chain via `renderPage`,
|
|
1370
1508
|
* and a wildcard catch-all renders `_404` (or a plain 404). Reuses @nifrajs/core's router +
|
|
1371
|
-
* 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
|
|
1372
1510
|
* manifest from `discoverRoutes` (`@nifrajs/web/fs`) at startup, so the served app stays portable.
|
|
1373
1511
|
*
|
|
1374
|
-
* **Typed platform bindings.** Pass `Env`
|
|
1512
|
+
* **Typed platform bindings.** Pass `Env` - `createWebApp<Env>({ … })` - to declare the app's Workers
|
|
1375
1513
|
* bindings ONCE. It seeds the returned `Server`'s context with `{ env: Env }` (exactly as the backend's
|
|
1376
1514
|
* `server<Env>()` does), so `app.fetch(req, { env })` / `toFetchHandler(app)` type-check the `env`
|
|
1377
|
-
* argument against the declared shape
|
|
1515
|
+
* argument against the declared shape - no per-binding cast at the edge entry. Per-route loaders/actions
|
|
1378
1516
|
* stay typed independently of this call: annotate them with `@nifrajs/client`'s `LoaderArgs<Api, Env>`
|
|
1379
|
-
* (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
|
|
1380
1518
|
* secure default; validate at the trust boundary before use.
|
|
1381
1519
|
*/
|
|
1382
1520
|
export function createWebApp<Env = unknown>(
|
|
@@ -1396,7 +1534,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1396
1534
|
return chunks ? { preload: chunks } : {}
|
|
1397
1535
|
}
|
|
1398
1536
|
// The matched route's stylesheet links (spread into renderPage). Per-route when the build mapped it
|
|
1399
|
-
// (`routeStyles[id]`
|
|
1537
|
+
// (`routeStyles[id]` - only the chain's CSS; an empty array ⇒ no `<link>`), else the aggregate
|
|
1400
1538
|
// `styles`. Omitted entirely when CSS-free (for exactOptionalPropertyTypes).
|
|
1401
1539
|
const stylesOf = (id: string): { styles?: readonly string[] } => {
|
|
1402
1540
|
const perRoute = options.routeStyles?.[id]
|
|
@@ -1409,7 +1547,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1409
1547
|
const app = server<Env>()
|
|
1410
1548
|
// Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
|
|
1411
1549
|
// `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
|
|
1412
|
-
// 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`)
|
|
1413
1551
|
// surfaces as the backend's own response, never the page 404. We dispatch the SAME `Request` object
|
|
1414
1552
|
// (its body unread) so streamed/large bodies pass through untouched; the backend defines its routes
|
|
1415
1553
|
// at the full prefixed path (`server().post("/api/sync", …)`), matching the `inProcessClient` call
|
|
@@ -1433,7 +1571,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1433
1571
|
if (!underPrefix(pathname, mount.path)) continue
|
|
1434
1572
|
return mount.app.fetch(mount.stripPrefix === true ? stripMountPrefix(req, mount.path) : req)
|
|
1435
1573
|
}
|
|
1436
|
-
// Exactly the prefix (`/api`) or a sub-path (`/api/…`)
|
|
1574
|
+
// Exactly the prefix (`/api`) or a sub-path (`/api/…`) - NOT a sibling like `/apixyz` that merely
|
|
1437
1575
|
// shares the prefix as a string head. Dispatch the original `req` (body intact) to the backend.
|
|
1438
1576
|
if (mountedApi !== undefined && apiPrefix !== "" && underPrefix(pathname, apiPrefix)) {
|
|
1439
1577
|
return mountedApi(apiStrip ? stripMountPrefix(req, apiPrefix) : req, platform)
|
|
@@ -1452,7 +1590,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1452
1590
|
|
|
1453
1591
|
// Load a route's layout modules (outermost layout → innermost), keeping each module whole so the
|
|
1454
1592
|
// render path can take both its `default` (the component chain) AND its `meta` (the sitewide head it
|
|
1455
|
-
// contributes). Loaded lazily
|
|
1593
|
+
// contributes). Loaded lazily - the data-only and 405 branches return before any layout is needed.
|
|
1456
1594
|
const loadLayoutModules = async (route: RouteEntry): Promise<LoadedLayoutModules> => {
|
|
1457
1595
|
const modules = await Promise.all(
|
|
1458
1596
|
// layoutIds only reference layouts present in the manifest (buildManifest invariant).
|
|
@@ -1464,7 +1602,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1464
1602
|
if ((modules[i] as { action?: unknown }).action === undefined) continue
|
|
1465
1603
|
const id = route.layoutIds[i] as string
|
|
1466
1604
|
throw new Error(
|
|
1467
|
-
`[nifra/web] "${(manifest.layouts[id] as LayoutEntry).file}" exports an \`action\`, but layouts do not run one
|
|
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.`,
|
|
1468
1606
|
)
|
|
1469
1607
|
}
|
|
1470
1608
|
return modules
|
|
@@ -1553,6 +1691,24 @@ export function createWebApp<Env = unknown>(
|
|
|
1553
1691
|
return valid
|
|
1554
1692
|
}
|
|
1555
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
|
+
|
|
1556
1712
|
const runLayoutChain = async (
|
|
1557
1713
|
route: RouteEntry,
|
|
1558
1714
|
ctx: LoaderContext,
|
|
@@ -1675,11 +1831,11 @@ export function createWebApp<Env = unknown>(
|
|
|
1675
1831
|
return undefined
|
|
1676
1832
|
}
|
|
1677
1833
|
|
|
1678
|
-
// The request's origin (scheme + host + port, NO trailing slash)
|
|
1834
|
+
// The request's origin (scheme + host + port, NO trailing slash) - the one server fact `meta()` needs
|
|
1679
1835
|
// for absolute `canonical`/`og:url`/`og:image` but can't otherwise see (it also runs on the client).
|
|
1680
1836
|
// `URL.origin` is exactly that shape; it matches the browser's `location.origin` for the same URL, so
|
|
1681
1837
|
// a soft-nav's `applyHead` resolves an identical `<head>` (no hydration drift). A malformed `req.url`
|
|
1682
|
-
// (shouldn't happen on a real request) degrades to `""`
|
|
1838
|
+
// (shouldn't happen on a real request) degrades to `""` - the documented unknown-origin default -
|
|
1683
1839
|
// rather than throwing during render.
|
|
1684
1840
|
const originOf = (req: Request): string => {
|
|
1685
1841
|
try {
|
|
@@ -1689,7 +1845,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1689
1845
|
}
|
|
1690
1846
|
}
|
|
1691
1847
|
|
|
1692
|
-
// The request's `pathname + search`
|
|
1848
|
+
// The request's `pathname + search` - threaded into `renderPage` so an adapter's `useLocation`/
|
|
1693
1849
|
// `useSearchParams` render the current URL server-side and hydrate against the client's initial state
|
|
1694
1850
|
// (which the client entry seeds from `location.pathname + location.search`). No hash: it never reaches
|
|
1695
1851
|
// the server. A malformed `req.url` degrades to "/" rather than throwing during render.
|
|
@@ -1704,11 +1860,11 @@ export function createWebApp<Env = unknown>(
|
|
|
1704
1860
|
|
|
1705
1861
|
/**
|
|
1706
1862
|
* Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
|
|
1707
|
-
* half of error UI
|
|
1863
|
+
* half of error UI - works on every adapter, no client takeover). The boundary renders in place of
|
|
1708
1864
|
* the page, wrapped by the layouts **at or above** its segment (deeper layouts are dropped). It's
|
|
1709
1865
|
* served **non-hydrated** at status 500: a terminal page, and rendering a boundary (not the page the
|
|
1710
1866
|
* client maps this route to) would otherwise hydrate-mismatch. The component receives `{ name,
|
|
1711
|
-
* message }`
|
|
1867
|
+
* message }` - never the stack (no internals leak into HTML).
|
|
1712
1868
|
*/
|
|
1713
1869
|
const renderError = async (
|
|
1714
1870
|
route: RouteEntry,
|
|
@@ -1739,7 +1895,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1739
1895
|
})
|
|
1740
1896
|
}
|
|
1741
1897
|
|
|
1742
|
-
// The 404 response
|
|
1898
|
+
// The 404 response - the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
|
|
1743
1899
|
// catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
|
|
1744
1900
|
// that opted out of on-demand SSR).
|
|
1745
1901
|
const renderNotFound = (path?: string): Promise<Response | RenderedPage> =>
|
|
@@ -1769,13 +1925,28 @@ export function createWebApp<Env = unknown>(
|
|
|
1769
1925
|
// The routeId decides which preload/style bundle and which client component this maps to, so it
|
|
1770
1926
|
// must name the page actually rendered - `_410` when `_410.tsx` exists, `_404` when it fell back.
|
|
1771
1927
|
const routeId = manifest.statusPages?.[String(status)] !== undefined ? `_${status}` : "_404"
|
|
1772
|
-
const
|
|
1928
|
+
const loaded = (await page.load()) as {
|
|
1929
|
+
readonly default: unknown
|
|
1930
|
+
readonly searchSchema?: RouteModule["searchSchema"]
|
|
1931
|
+
}
|
|
1932
|
+
const component = loaded.default
|
|
1773
1933
|
return renderPageResult({
|
|
1774
1934
|
adapter,
|
|
1775
1935
|
chain: [component],
|
|
1776
1936
|
data: null,
|
|
1777
1937
|
clientEntry,
|
|
1778
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
|
+
: {}),
|
|
1779
1950
|
// The page hydrates, so its SSR `path` must equal the client's initial
|
|
1780
1951
|
// `location.pathname+search` (an unmatched path yields no params → `{}`), or a `useLocation`
|
|
1781
1952
|
// on it would drift.
|
|
@@ -1813,7 +1984,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1813
1984
|
}
|
|
1814
1985
|
|
|
1815
1986
|
for (const route of manifest.routes) {
|
|
1816
|
-
// A dynamic route whose `getStaticPaths` declared `fallback: "404"`
|
|
1987
|
+
// A dynamic route whose `getStaticPaths` declared `fallback: "404"` - only its prerendered paths
|
|
1817
1988
|
// exist; anything else 404s (computed once per route, not per request).
|
|
1818
1989
|
const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
|
|
1819
1990
|
app.register("GET", route.pattern, undefined, async (c: RouteContext) => {
|
|
@@ -1837,6 +2008,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1837
2008
|
api,
|
|
1838
2009
|
env: c.env,
|
|
1839
2010
|
draft,
|
|
2011
|
+
search: loaderSearch(mod.searchSchema, c.req),
|
|
1840
2012
|
}
|
|
1841
2013
|
// Layout loaders run for BOTH the document and the data-only request. A gate that only ran on
|
|
1842
2014
|
// the document path would be bypassed by adding the data header, which is exactly the request
|
|
@@ -1852,8 +2024,12 @@ export function createWebApp<Env = unknown>(
|
|
|
1852
2024
|
)
|
|
1853
2025
|
layoutModules = run.modules
|
|
1854
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`.
|
|
1855
2029
|
const [pageData] = await Promise.all([
|
|
1856
|
-
mod.loader
|
|
2030
|
+
mod.loader
|
|
2031
|
+
? mod.loader({ ...ctx, search: searchChainOf(run.modules, mod, c.req) })
|
|
2032
|
+
: null,
|
|
1857
2033
|
run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
|
|
1858
2034
|
])
|
|
1859
2035
|
data = pageData
|
|
@@ -1864,7 +2040,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1864
2040
|
// is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
|
|
1865
2041
|
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
1866
2042
|
// A thrown `Response` is a control-flow signal (a guard's `redirect(...)`, an explicit error
|
|
1867
|
-
// response)
|
|
2043
|
+
// response) - let it propagate to core, which returns it as-is. Real errors render the nearest
|
|
1868
2044
|
// `_error` boundary, if any; with none, rethrow (unchanged 500 behavior).
|
|
1869
2045
|
if (err instanceof Response) throw err
|
|
1870
2046
|
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
@@ -1877,7 +2053,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1877
2053
|
}
|
|
1878
2054
|
const errorId = boundaryFor(route, err)
|
|
1879
2055
|
if (errorId === undefined) throw err
|
|
1880
|
-
// 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
|
|
1881
2057
|
// navigation, which lands here as a document and renders the `_error` page.
|
|
1882
2058
|
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
1883
2059
|
return new Response("Internal Server Error", {
|
|
@@ -1887,10 +2063,10 @@ export function createWebApp<Env = unknown>(
|
|
|
1887
2063
|
}
|
|
1888
2064
|
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
1889
2065
|
}
|
|
1890
|
-
// 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
|
|
1891
2067
|
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
1892
2068
|
// then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
|
|
1893
|
-
// same auth
|
|
2069
|
+
// same auth - only the transport differs.
|
|
1894
2070
|
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
1895
2071
|
const pageSplit = prepareDeferred(data)
|
|
1896
2072
|
const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
|
|
@@ -1910,7 +2086,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1910
2086
|
layoutData: layoutSplits.map((split) => split.forClient),
|
|
1911
2087
|
retained: layoutRetained,
|
|
1912
2088
|
}
|
|
1913
|
-
// Bare value when there is no layout data
|
|
2089
|
+
// Bare value when there is no layout data - the pre-envelope shape, so a client running older
|
|
1914
2090
|
// code against a newer server keeps working.
|
|
1915
2091
|
if (deferred.length === 0) {
|
|
1916
2092
|
return Response.json(payload ?? null)
|
|
@@ -1931,7 +2107,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1931
2107
|
const hydrateRoute = mod.hydrate !== false
|
|
1932
2108
|
try {
|
|
1933
2109
|
// `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
|
|
1934
|
-
// can render the `_error` page
|
|
2110
|
+
// can render the `_error` page - not just a loader throw. Mid-stream (post-shell) throws can't
|
|
1935
2111
|
// be recovered to a full page; the per-adapter client boundary catches client render errors.
|
|
1936
2112
|
return await renderPageResult({
|
|
1937
2113
|
adapter,
|
|
@@ -1942,6 +2118,10 @@ export function createWebApp<Env = unknown>(
|
|
|
1942
2118
|
routeId: route.id,
|
|
1943
2119
|
params: c.params,
|
|
1944
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),
|
|
1945
2125
|
hydrate: hydrateRoute,
|
|
1946
2126
|
...(layoutData !== undefined ? { layoutData } : {}),
|
|
1947
2127
|
...preloadOf(route.id),
|
|
@@ -1952,7 +2132,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1952
2132
|
...titleOption,
|
|
1953
2133
|
})
|
|
1954
2134
|
} catch (err) {
|
|
1955
|
-
// Same precedence as the loader catch above
|
|
2135
|
+
// Same precedence as the loader catch above - a `meta()` may signal too.
|
|
1956
2136
|
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
1957
2137
|
if (err instanceof Response) throw err
|
|
1958
2138
|
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
@@ -1971,7 +2151,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1971
2151
|
|
|
1972
2152
|
// POST runs the route's `action` (mutation). A `Response` return (e.g. a `redirect(...)`)
|
|
1973
2153
|
// passes straight through; a data return re-renders the page (the loader re-runs for fresh
|
|
1974
|
-
// 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.
|
|
1975
2155
|
app.register("POST", route.pattern, undefined, async (c: RouteContext) => {
|
|
1976
2156
|
const mod = await route.load()
|
|
1977
2157
|
const draft = await draftFlag(c.req)
|
|
@@ -1988,6 +2168,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1988
2168
|
api,
|
|
1989
2169
|
env: c.env,
|
|
1990
2170
|
draft,
|
|
2171
|
+
search: loaderSearch(mod.searchSchema, c.req),
|
|
1991
2172
|
}
|
|
1992
2173
|
// A gate is an authorization boundary for everything beneath its layout, mutations included.
|
|
1993
2174
|
// Run only gates before the action; ordinary layout data loaders run after a native mutation.
|
|
@@ -1996,7 +2177,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1996
2177
|
const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
|
|
1997
2178
|
// An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
|
|
1998
2179
|
// Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
|
|
1999
|
-
// responses (the client acts on them
|
|
2180
|
+
// responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
|
|
2000
2181
|
const isRevalidate =
|
|
2001
2182
|
result !== null && typeof result === "object" && "__nifraRevalidate" in result
|
|
2002
2183
|
const actionResult = isRevalidate ? (result as RevalidateResult<unknown>).data : result
|
|
@@ -2015,7 +2196,7 @@ export function createWebApp<Env = unknown>(
|
|
|
2015
2196
|
// Client submit wants just the action's data (it revalidates the loader itself); a native
|
|
2016
2197
|
// form POST re-renders the full page (loader re-runs) with the action data.
|
|
2017
2198
|
if (isDataRequest) {
|
|
2018
|
-
// 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
|
|
2019
2200
|
// each deferred as it settles) exactly like a loader; a non-deferred action returns one JSON.
|
|
2020
2201
|
const { forClient, deferred } = prepareDeferred(actionResult)
|
|
2021
2202
|
if (deferred.length === 0)
|
|
@@ -2024,8 +2205,20 @@ export function createWebApp<Env = unknown>(
|
|
|
2024
2205
|
headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
|
|
2025
2206
|
})
|
|
2026
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)
|
|
2027
2212
|
const data = mod.loader
|
|
2028
|
-
? 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
|
+
})
|
|
2029
2222
|
: null
|
|
2030
2223
|
const { chain, head } = resolveChainAndHead(layoutModules, mod, {
|
|
2031
2224
|
data,
|
|
@@ -2033,7 +2226,7 @@ export function createWebApp<Env = unknown>(
|
|
|
2033
2226
|
origin: originOf(c.req),
|
|
2034
2227
|
})
|
|
2035
2228
|
const hydrateRoute = mod.hydrate !== false
|
|
2036
|
-
// 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 -
|
|
2037
2230
|
// `renderPage` splits `actionData` like loader data (works with JS off; hydrates after).
|
|
2038
2231
|
return renderPageResult({
|
|
2039
2232
|
adapter,
|
|
@@ -2045,6 +2238,7 @@ export function createWebApp<Env = unknown>(
|
|
|
2045
2238
|
routeId: route.id,
|
|
2046
2239
|
params: c.params,
|
|
2047
2240
|
path: pathOf(c.req),
|
|
2241
|
+
search,
|
|
2048
2242
|
hydrate: hydrateRoute,
|
|
2049
2243
|
...preloadOf(route.id),
|
|
2050
2244
|
...stylesOf(route.id),
|
|
@@ -2080,7 +2274,7 @@ export interface GenerateClientEntryOptions {
|
|
|
2080
2274
|
* Module specifier for the adapter's client runtime, e.g. `"@nifrajs/web-solid/client"`.
|
|
2081
2275
|
*
|
|
2082
2276
|
* **Contract:** the module MUST export `mountRouter({ router, routes, container })`. The generated
|
|
2083
|
-
* bootstrap imports the specifier and calls it
|
|
2277
|
+
* bootstrap imports the specifier and calls it - so a self-executing entry that mounts on import
|
|
2084
2278
|
* builds cleanly and then does nothing, which is the trap this contract exists to name. The type is
|
|
2085
2279
|
* a bare `string` because a specifier is resolved by the bundler, not the type system, so the
|
|
2086
2280
|
* requirement is enforced by the bootstrap instead: it throws immediately, naming this module and
|
|
@@ -2110,7 +2304,7 @@ export function generateClientEntry(
|
|
|
2110
2304
|
const loaderRows: string[] = []
|
|
2111
2305
|
const patternRows: string[] = []
|
|
2112
2306
|
const statusRoutes: Record<number, string> = {}
|
|
2113
|
-
// Routes whose loader appends a nearest `_error` module (LAST)
|
|
2307
|
+
// Routes whose loader appends a nearest `_error` module (LAST) - the client wraps the page in the
|
|
2114
2308
|
// adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
|
|
2115
2309
|
const errorRouteIds: string[] = []
|
|
2116
2310
|
// Lazy loader returns the raw modules (for both the component chain + the page's `meta` export).
|
|
@@ -2151,7 +2345,7 @@ export function generateClientEntry(
|
|
|
2151
2345
|
'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web"',
|
|
2152
2346
|
'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
|
|
2153
2347
|
// Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
|
|
2154
|
-
// access yields `undefined` if absent
|
|
2348
|
+
// access yields `undefined` if absent - unlike a named import, which would be a link error.
|
|
2155
2349
|
`import * as __adapter from ${JSON.stringify(clientModule)}`,
|
|
2156
2350
|
"const { mountRouter } = __adapter",
|
|
2157
2351
|
// The `clientModule` contract is a string specifier, so nothing type-checks that the module it
|
|
@@ -2160,7 +2354,7 @@ export function generateClientEntry(
|
|
|
2160
2354
|
// module nor the requirement - a self-executing entry passes every build and fails only here.
|
|
2161
2355
|
`if (typeof mountRouter !== "function") throw new Error(${JSON.stringify(
|
|
2162
2356
|
`[nifra/web] clientModule ${JSON.stringify(clientModule)} does not export \`mountRouter\`. ` +
|
|
2163
|
-
"A client module must export `mountRouter({ router, routes, container })`
|
|
2357
|
+
"A client module must export `mountRouter({ router, routes, container })` - it is called by " +
|
|
2164
2358
|
"the generated bootstrap, so a self-executing entry will not work. Use your adapter's " +
|
|
2165
2359
|
'`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
|
|
2166
2360
|
)})`,
|
|
@@ -2174,6 +2368,12 @@ export function generateClientEntry(
|
|
|
2174
2368
|
"}",
|
|
2175
2369
|
"const chains = {}",
|
|
2176
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 = {}",
|
|
2177
2377
|
"const loadModule = async (id) => {",
|
|
2178
2378
|
" if (chains[id]) return",
|
|
2179
2379
|
" const mods = await loaders[id]()",
|
|
@@ -2182,7 +2382,7 @@ export function generateClientEntry(
|
|
|
2182
2382
|
// hydrated tree matches the SSR markup (which has no boundary). Falls back to the plain chain when
|
|
2183
2383
|
// the adapter has no `errorBoundary`.
|
|
2184
2384
|
// `metas[id]` is the chain's `meta` exports in head order (outermost layout → … → page), so a
|
|
2185
|
-
// 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
|
|
2186
2386
|
// layout tags persist across client navigation, no flash of page-only head). `_error` carries no
|
|
2187
2387
|
// head (a terminal boundary), so it's excluded from the meta list for error routes.
|
|
2188
2388
|
" if (errorBoundary && errorRouteIds.has(id)) {",
|
|
@@ -2191,9 +2391,16 @@ export function generateClientEntry(
|
|
|
2191
2391
|
" const layouts = mods.slice(0, mods.length - 2).map((m) => m.default)",
|
|
2192
2392
|
" chains[id] = [...layouts, errorBoundary(fallback), page]",
|
|
2193
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 ?? []",
|
|
2194
2398
|
" } else {",
|
|
2195
2399
|
" chains[id] = mods.map((m) => m.default)",
|
|
2196
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 ?? []",
|
|
2197
2404
|
" }",
|
|
2198
2405
|
"}",
|
|
2199
2406
|
"const patterns = [",
|
|
@@ -2222,23 +2429,36 @@ export function generateClientEntry(
|
|
|
2222
2429
|
" pending: false,",
|
|
2223
2430
|
"}",
|
|
2224
2431
|
`const statusRoutes = ${JSON.stringify(statusRoutes)}`,
|
|
2225
|
-
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes })",
|
|
2432
|
+
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys })",
|
|
2226
2433
|
"installHistory(router)",
|
|
2227
2434
|
"installForms(router)",
|
|
2228
|
-
|
|
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
|
+
)})`,
|
|
2229
2449
|
// Load the initial route's chunk, then hydrate the Router (chain is cached). The initial head
|
|
2230
2450
|
// is server-rendered; subsequent navigations update it from the matched route's meta + data.
|
|
2231
|
-
"
|
|
2232
|
-
" mountRouter({ router, routes: chains, container: root })",
|
|
2451
|
+
"loadModule(initial.routeId).then(() => {",
|
|
2452
|
+
" mountRouter({ router, routes: chains, searchSchemas, container: root })",
|
|
2233
2453
|
// Next frame: the adapter has committed its initial hydration, so handlers are attached. Flip the
|
|
2234
2454
|
// document to interactive (`data-nifra-hydrated` + `nifra:hydrated`) for apps to gate pre-hydration UI.
|
|
2235
2455
|
" requestAnimationFrame(signalHydrated)",
|
|
2236
2456
|
" router.subscribe(() => {",
|
|
2237
2457
|
" const s = router.snapshot()",
|
|
2238
|
-
// 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.
|
|
2239
2459
|
" if (!s.pending) {",
|
|
2240
2460
|
// `origin: location.origin` matches the SSR `originOf(req)` (both are `URL.origin` for the same
|
|
2241
|
-
// 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.
|
|
2242
2462
|
" const args = { data: s.data, params: s.params, origin: location.origin }",
|
|
2243
2463
|
" applyHead(mergeHeads((metas[s.routeId] ?? [undefined]).map((m) => resolveMeta(m, args))))",
|
|
2244
2464
|
" }",
|
|
@@ -2247,22 +2467,69 @@ export function generateClientEntry(
|
|
|
2247
2467
|
].join("\n")}\n`
|
|
2248
2468
|
}
|
|
2249
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
|
+
|
|
2250
2517
|
export interface GenerateServerManifestOptions {
|
|
2251
|
-
/** 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 -
|
|
2252
2519
|
* same contract as `generateClientEntry`'s `resolve`. */
|
|
2253
2520
|
readonly resolve: (file: string) => string
|
|
2254
2521
|
/** The content-hashed client entry URL (from `buildClient`'s manifest), **baked** into the emitted
|
|
2255
|
-
* module
|
|
2522
|
+
* module - a disk-less worker can't read `manifest.json` at runtime. */
|
|
2256
2523
|
readonly clientEntry: string
|
|
2257
|
-
/** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`)
|
|
2524
|
+
/** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked as
|
|
2258
2525
|
* `export const styles` so the server entry can hand them to `createWebApp` (→ `<link rel="stylesheet">`
|
|
2259
2526
|
* in `<head>`). Without this the built SSR page ships no CSS link and renders unstyled. */
|
|
2260
2527
|
readonly styles?: readonly string[] | undefined
|
|
2261
|
-
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`)
|
|
2528
|
+
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked as
|
|
2262
2529
|
* `export const routeStyles` so a page links only the CSS its route chain uses. */
|
|
2263
2530
|
readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
|
|
2264
2531
|
/** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
|
|
2265
|
-
* 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
|
|
2266
2533
|
* first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
|
|
2267
2534
|
* modes are fs-free with statically-analyzable specifiers; only the *when* differs. */
|
|
2268
2535
|
readonly lazy?: boolean
|
|
@@ -2270,10 +2537,10 @@ export interface GenerateServerManifestOptions {
|
|
|
2270
2537
|
|
|
2271
2538
|
/**
|
|
2272
2539
|
* Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
|
|
2273
|
-
* Workers, …)
|
|
2274
|
-
* and dynamic-imports each route by a *runtime* path
|
|
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
|
|
2275
2542
|
* **statically-analyzable** imports of every route/layout/`_error`/terminal status page (so the bundler includes them) and
|
|
2276
|
-
* rebuilds the manifest with `buildManifest`
|
|
2543
|
+
* rebuilds the manifest with `buildManifest` - the SAME pure logic `discoverRoutes` feeds, so patterns
|
|
2277
2544
|
* + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
|
|
2278
2545
|
* a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
|
|
2279
2546
|
* `createWebApp`, unchanged) + `clientEntry` (baked). Write it to a file and bundle it into the worker
|
|
@@ -2295,7 +2562,7 @@ export function generateServerManifest(
|
|
|
2295
2562
|
]),
|
|
2296
2563
|
].sort()
|
|
2297
2564
|
const header = [
|
|
2298
|
-
"// GENERATED by @nifrajs/web generateServerManifest
|
|
2565
|
+
"// GENERATED by @nifrajs/web generateServerManifest - route manifest for the disk-less edge",
|
|
2299
2566
|
"// (no filesystem; route imports are static specifiers the bundler resolves). buildManifest is",
|
|
2300
2567
|
"// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
|
|
2301
2568
|
'import { buildManifest } from "@nifrajs/web"',
|
|
@@ -2339,3 +2606,15 @@ export function generateServerManifest(
|
|
|
2339
2606
|
"export const manifest = buildManifest(Object.keys(modules), (file) => () => Promise.resolve(modules[file]))",
|
|
2340
2607
|
].join("\n")}\n`
|
|
2341
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"
|