@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/bun-dev-entry.ts
CHANGED
package/src/chunk-names.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* URL-safe output chunk naming. Bun names a code-split chunk after its source file's basename, so a
|
|
3
|
-
* dynamic-route file like `[slug].tsx` emits `[slug]-<hash>.js`
|
|
3
|
+
* dynamic-route file like `[slug].tsx` emits `[slug]-<hash>.js` - the `[ ]` make a URL a static server
|
|
4
4
|
* (server-bun, CF Pages) reject with 400, so the lazy import fails and the route silently never
|
|
5
5
|
* hydrates (SSR looks perfect; all interactivity is dead). `sanitizeOutputNames` renames every output
|
|
6
6
|
* whose basename isn't URL-path-safe (`[slug]` → `_slug_`) and rewrites the references to it inside the
|
package/src/client.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web/client`
|
|
2
|
+
* `@nifrajs/web/client` - the agnostic browser layer for client-side navigation. It wires the pure
|
|
3
3
|
* router store ({@link ClientRouter}) to the browser: `pushState` on navigate, `popstate` →
|
|
4
4
|
* navigate, and delegated interception of same-origin `<a>` clicks (client transition instead of
|
|
5
|
-
* a full page load). DOM-only
|
|
5
|
+
* a full page load). DOM-only - never imported on the server, so the store stays SSR-safe.
|
|
6
6
|
*/
|
|
7
|
+
import { trustedHeadAttributes } from "./internal/head-attributes.ts"
|
|
8
|
+
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
|
|
7
9
|
import type { Meta } from "./manifest.ts"
|
|
8
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type Blocker,
|
|
12
|
+
type BlockerController,
|
|
13
|
+
type BlockerFunction,
|
|
14
|
+
type BlockerLocation,
|
|
15
|
+
type BlockerState,
|
|
16
|
+
type BrowserNavigate,
|
|
17
|
+
IDLE_BLOCKER,
|
|
18
|
+
setBlockerController,
|
|
19
|
+
setBrowserNavigate,
|
|
20
|
+
} from "./navigation.ts"
|
|
9
21
|
import type { ClientRouter } from "./router.ts"
|
|
10
22
|
|
|
11
23
|
export interface InstallHistoryOptions {
|
|
@@ -31,6 +43,22 @@ export function installHistory(
|
|
|
31
43
|
// fragment target (`#id`) to scroll to once the cross-page navigation's content renders.
|
|
32
44
|
type PendingScroll = { readonly pos: readonly [number, number] } | { readonly hash: string }
|
|
33
45
|
let pendingScroll: PendingScroll | null = null
|
|
46
|
+
|
|
47
|
+
// Navigation-guard state (useBlocker). `index` is our position in the history line, stored in
|
|
48
|
+
// `history.state.nifraIndex`; it exists solely to reverse a blocked back/forward (a popstate can't be
|
|
49
|
+
// cancelled, so we read the delta from the index and `history.go(-delta)` back). `here` is the location
|
|
50
|
+
// we're currently on - after a popstate `location` is already the destination, so "where we were" has
|
|
51
|
+
// to be tracked, not read. `reversing` swallows the popstate our own restoring `history.go` fires.
|
|
52
|
+
let index = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
|
|
53
|
+
let here = location.pathname + location.search + (location.hash ?? "")
|
|
54
|
+
let reversing = false
|
|
55
|
+
type Registration = {
|
|
56
|
+
readonly shouldBlock: BlockerFunction
|
|
57
|
+
readonly emit: (blocker: Blocker) => void
|
|
58
|
+
state: BlockerState
|
|
59
|
+
}
|
|
60
|
+
// A single active guard (like react-router, one blocker at a time); the latest registration wins.
|
|
61
|
+
let registration: Registration | undefined
|
|
34
62
|
const scrollOf = (state: unknown): [number, number] => {
|
|
35
63
|
const saved = (state as { nifraScroll?: [number, number] } | null)?.nifraScroll
|
|
36
64
|
return Array.isArray(saved) ? saved : [0, 0]
|
|
@@ -50,7 +78,7 @@ export function installHistory(
|
|
|
50
78
|
return document.getElementById(id)
|
|
51
79
|
}
|
|
52
80
|
|
|
53
|
-
// Wrap a navigation's render in a View Transition when the browser supports it
|
|
81
|
+
// Wrap a navigation's render in a View Transition when the browser supports it - a graceful
|
|
54
82
|
// enhancement (no-op elsewhere). The captured "before" is held across the data fetch, so pair
|
|
55
83
|
// with link prefetch (hover/focus warms the cache) to keep most transitions instant.
|
|
56
84
|
type ViewTransition = {
|
|
@@ -69,35 +97,112 @@ export function installHistory(
|
|
|
69
97
|
}
|
|
70
98
|
const vt = doc.startViewTransition(run)
|
|
71
99
|
// Rapid in-app clicks supersede an in-flight transition; the browser skips it and rejects its
|
|
72
|
-
// promises. Reasons vary by engine
|
|
100
|
+
// promises. Reasons vary by engine - InvalidStateError "Transition was aborted because of
|
|
73
101
|
// invalid state", or AbortError "Transition was skipped". The navigation still completes, so
|
|
74
102
|
// we swallow these expected aborts rather than let them surface as unhandled rejections.
|
|
75
103
|
for (const p of [vt.ready, vt.finished, vt.updateCallbackDone]) p.catch(() => {})
|
|
76
104
|
}
|
|
77
105
|
|
|
78
|
-
|
|
106
|
+
// Parse a path into the `{ pathname, search, hash }` a `BlockerFunction` decides on.
|
|
107
|
+
const locationOf = (path: string): BlockerLocation => {
|
|
79
108
|
const url = new URL(path, location.origin)
|
|
109
|
+
return { pathname: url.pathname, search: url.search, hash: url.hash }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The single funnel every navigation asks before it commits. Returns true (navigation held) when a
|
|
113
|
+
// guard is armed and blocks; false when it may proceed. `retry` performs the navigation and is what
|
|
114
|
+
// `proceed` replays. While a prompt is already open (`blocked`) further navigations are swallowed; the
|
|
115
|
+
// replay from `proceed` (`proceeding`) is let straight through.
|
|
116
|
+
const guard = (nextPath: string, retry: () => void): boolean => {
|
|
117
|
+
const reg = registration
|
|
118
|
+
if (reg === undefined || reg.state === "proceeding") return false
|
|
119
|
+
if (reg.state === "blocked") return true
|
|
120
|
+
if (!reg.shouldBlock({ currentLocation: locationOf(here), nextLocation: locationOf(nextPath) }))
|
|
121
|
+
return false
|
|
122
|
+
reg.state = "blocked"
|
|
123
|
+
reg.emit({
|
|
124
|
+
state: "blocked",
|
|
125
|
+
proceed: () => {
|
|
126
|
+
if (reg.state !== "blocked") return
|
|
127
|
+
reg.state = "proceeding"
|
|
128
|
+
reg.emit({ state: "proceeding", proceed: undefined, reset: undefined })
|
|
129
|
+
retry()
|
|
130
|
+
},
|
|
131
|
+
reset: () => {
|
|
132
|
+
if (reg.state !== "blocked") return
|
|
133
|
+
reg.state = "unblocked"
|
|
134
|
+
reg.emit(IDLE_BLOCKER)
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Once a proceeded navigation has committed, return the guard to idle so it can block the next one.
|
|
141
|
+
const settle = (): void => {
|
|
142
|
+
const reg = registration
|
|
143
|
+
if (reg !== undefined && reg.state === "proceeding") {
|
|
144
|
+
reg.state = "unblocked"
|
|
145
|
+
reg.emit(IDLE_BLOCKER)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Commit a navigation only after its URL and origin are known-safe. An unmatched same-origin path
|
|
150
|
+
// becomes a hard load BEFORE history is mutated; otherwise the address bar could change while the
|
|
151
|
+
// router intentionally kept rendering the old route.
|
|
152
|
+
const parseNavigationUrl = (path: string): URL | null => {
|
|
153
|
+
try {
|
|
154
|
+
const url = new URL(path, location.origin)
|
|
155
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null
|
|
156
|
+
if (url.origin !== location.origin) return null
|
|
157
|
+
return url
|
|
158
|
+
} catch {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const commit = (path: string, url: URL, mode: "push" | "replace"): void => {
|
|
164
|
+
const routePath = url.pathname + url.search
|
|
165
|
+
if (router.match(routePath) === null) {
|
|
166
|
+
settle()
|
|
167
|
+
fallback(path)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
80
170
|
if (mode === "replace") {
|
|
81
|
-
// Replace the current entry with the new URL. The entry we're leaving is discarded, so
|
|
82
|
-
// isn't worth saving; the new route scrolls to its fragment target, else the top.
|
|
83
|
-
history.replaceState({}, "", path)
|
|
171
|
+
// Replace the current entry (same index) with the new URL. The entry we're leaving is discarded, so
|
|
172
|
+
// its scroll isn't worth saving; the new route scrolls to its fragment target, else the top.
|
|
173
|
+
history.replaceState({ nifraIndex: index }, "", path)
|
|
84
174
|
pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
|
|
85
175
|
} else {
|
|
86
|
-
// Save the leaving entry's scroll
|
|
87
|
-
// route: to the fragment target if
|
|
176
|
+
// Save the leaving entry's scroll (spread keeps its nifraIndex), push a fresh higher-indexed entry
|
|
177
|
+
// (URL incl. any #hash), then scroll the new route: to the fragment target if given, else the top.
|
|
88
178
|
history.replaceState({ ...(history.state ?? {}), nifraScroll: [scrollX, scrollY] }, "")
|
|
89
|
-
|
|
179
|
+
index += 1
|
|
180
|
+
history.pushState({ nifraIndex: index }, "", path)
|
|
90
181
|
pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
|
|
91
182
|
}
|
|
183
|
+
here = url.pathname + url.search + url.hash
|
|
92
184
|
// The data layer fetches by path+search; the #hash is client-only (never sent to the server).
|
|
93
|
-
transition(() => router.navigate(
|
|
185
|
+
transition(() => router.navigate(routePath).catch(() => fallback(path)))
|
|
186
|
+
settle()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const go = (path: string, mode: "push" | "replace"): void => {
|
|
190
|
+
// Validate before the blocker observes the target. A blocker receives parsed locations, so letting a
|
|
191
|
+
// malformed string reach `locationOf` would make an otherwise no-throw navigate unexpectedly throw.
|
|
192
|
+
const url = parseNavigationUrl(path)
|
|
193
|
+
if (url === null) {
|
|
194
|
+
settle()
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if (guard(path, () => commit(path, url, mode))) return
|
|
198
|
+
commit(path, url, mode)
|
|
94
199
|
}
|
|
95
200
|
|
|
96
201
|
// Programmatic navigation for adapter `useNavigate` bindings, published through the DOM-free bridge
|
|
97
202
|
// (`@nifrajs/web`'s `getBrowserNavigate`). A string path pushes (or replaces); a number is a history
|
|
98
|
-
// delta (`-1` back / `1` forward), matching `history.go`.
|
|
99
|
-
//
|
|
100
|
-
//
|
|
203
|
+
// delta (`-1` back / `1` forward), matching `history.go`. Off-route same-origin destinations hard-load
|
|
204
|
+
// without first creating a stale in-document history entry. Cross-origin and active-scheme strings are
|
|
205
|
+
// rejected; callers that intentionally leave the app use a normal `<a>`.
|
|
101
206
|
const navigate: BrowserNavigate = (to, navOptions) => {
|
|
102
207
|
if (typeof to === "number") {
|
|
103
208
|
history.go(to)
|
|
@@ -107,6 +212,20 @@ export function installHistory(
|
|
|
107
212
|
}
|
|
108
213
|
setBrowserNavigate(navigate)
|
|
109
214
|
|
|
215
|
+
// Publish the guard registry through the DOM-free bridge (`@nifrajs/web`'s `registerBlocker`, which an
|
|
216
|
+
// adapter's `useBlocker` calls). One slot, latest registration wins; the unregister clears it only if
|
|
217
|
+
// it's still the current one (so a stale unmount doesn't wipe a newer guard).
|
|
218
|
+
const controller: BlockerController = {
|
|
219
|
+
register(shouldBlock, onChange) {
|
|
220
|
+
const reg: Registration = { shouldBlock, emit: onChange, state: "unblocked" }
|
|
221
|
+
registration = reg
|
|
222
|
+
return () => {
|
|
223
|
+
if (registration === reg) registration = undefined
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
setBlockerController(controller)
|
|
228
|
+
|
|
110
229
|
// Resolve an event target to an in-app route path, or null (cross-origin, unknown route, or an
|
|
111
230
|
// anchor opting out via target/download/rel=external). Shared by click + hover/focus prefetch.
|
|
112
231
|
const inAppHref = (target: EventTarget | null): string | null => {
|
|
@@ -118,9 +237,9 @@ export function installHistory(
|
|
|
118
237
|
const url = new URL(anchor.href)
|
|
119
238
|
if (url.origin !== location.origin) return null
|
|
120
239
|
if (router.match(url.pathname) === null) return null
|
|
121
|
-
// A same-page fragment link (`#section`, `/here#section`
|
|
240
|
+
// A same-page fragment link (`#section`, `/here#section` - only the hash differs) → null: let the
|
|
122
241
|
// browser do its native in-page anchor jump. Intercepting it would drop the fragment from the URL
|
|
123
|
-
// and force a scroll-to-top (a fresh push restores [0,0])
|
|
242
|
+
// and force a scroll-to-top (a fresh push restores [0,0]) - i.e. break every in-page anchor (AUDIT
|
|
124
243
|
// H2). (A same-page link with NO hash still soft-navigates, as before.)
|
|
125
244
|
if (url.pathname === location.pathname && url.search === location.search && url.hash !== "")
|
|
126
245
|
return null
|
|
@@ -146,12 +265,49 @@ export function installHistory(
|
|
|
146
265
|
void router.prefetch(url.pathname + url.search) // warm by path+search; the #hash isn't data
|
|
147
266
|
}
|
|
148
267
|
|
|
149
|
-
// Back/forward: the entry exists (no push)
|
|
268
|
+
// Back/forward: the entry already exists (no push). The URL has ALSO already changed - a popstate can't
|
|
269
|
+
// be cancelled - so a guard here can only restore-then-prompt: read the delta from our index, and if the
|
|
270
|
+
// guard holds, `history.go(-delta)` back to where we were (ignoring the popstate that reversal fires via
|
|
271
|
+
// `reversing`) while `proceed` re-issues `history.go(delta)` to redo the move. Unguarded, it's the old
|
|
272
|
+
// path: restore the entry's saved scroll after it renders.
|
|
150
273
|
const onPopState = (): void => {
|
|
274
|
+
if (reversing) {
|
|
275
|
+
reversing = false
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
const newIndex = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
|
|
279
|
+
const dest = location.pathname + location.search + (location.hash ?? "")
|
|
280
|
+
const delta = newIndex - index
|
|
281
|
+
if (guard(dest, () => history.go(delta))) {
|
|
282
|
+
reversing = true
|
|
283
|
+
history.go(-delta)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
index = newIndex
|
|
287
|
+
here = dest
|
|
151
288
|
pendingScroll = { pos: scrollOf(history.state) }
|
|
152
289
|
transition(() =>
|
|
153
290
|
router.navigate(location.pathname + location.search).catch(() => fallback(location.pathname)),
|
|
154
291
|
)
|
|
292
|
+
settle()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Close / reload / hard navigation away: no in-app destination exists, so consult the guard with the
|
|
296
|
+
// current location as both sides. A dirtiness guard (a boolean, or one that checks its own state) then
|
|
297
|
+
// triggers the browser's native "Leave site?" prompt; a purely destination-based guard won't. Browsers
|
|
298
|
+
// ignore any custom message - the string is set only because legacy engines require `returnValue`.
|
|
299
|
+
const onBeforeUnload = (event: BeforeUnloadEvent): void => {
|
|
300
|
+
const reg = registration
|
|
301
|
+
if (reg === undefined || reg.state === "proceeding") return
|
|
302
|
+
// `blocked` already proves the guard found unsaved state. Re-check only while idle; suppressing the
|
|
303
|
+
// native prompt merely because an in-app confirmation is open creates a close/reload data-loss gap.
|
|
304
|
+
if (
|
|
305
|
+
reg.state === "unblocked" &&
|
|
306
|
+
!reg.shouldBlock({ currentLocation: locationOf(here), nextLocation: locationOf(here) })
|
|
307
|
+
)
|
|
308
|
+
return
|
|
309
|
+
event.preventDefault()
|
|
310
|
+
event.returnValue = ""
|
|
155
311
|
}
|
|
156
312
|
|
|
157
313
|
// After a navigation settles (content rendered), apply the pending scroll target on the next frame:
|
|
@@ -177,32 +333,35 @@ export function installHistory(
|
|
|
177
333
|
document.addEventListener("pointerover", onPrefetch)
|
|
178
334
|
document.addEventListener("focusin", onPrefetch)
|
|
179
335
|
window.addEventListener("popstate", onPopState)
|
|
336
|
+
window.addEventListener("beforeunload", onBeforeUnload)
|
|
180
337
|
return () => {
|
|
181
338
|
unsubscribe()
|
|
182
339
|
setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
|
|
340
|
+
setBlockerController(undefined) // stop routing `useBlocker` to a torn-down registry
|
|
183
341
|
document.removeEventListener("click", onClick)
|
|
184
342
|
document.removeEventListener("pointerover", onPrefetch)
|
|
185
343
|
document.removeEventListener("focusin", onPrefetch)
|
|
186
344
|
window.removeEventListener("popstate", onPopState)
|
|
345
|
+
window.removeEventListener("beforeunload", onBeforeUnload)
|
|
187
346
|
}
|
|
188
347
|
}
|
|
189
348
|
|
|
190
349
|
/**
|
|
191
350
|
* Intercept submissions of same-origin `<form method="post">` whose action targets an app route:
|
|
192
|
-
* submit via the router (no full reload
|
|
351
|
+
* submit via the router (no full reload - POST the action, then revalidate the active loader). On
|
|
193
352
|
* failure it falls back to a native submit, so the form still works. Returns a teardown function.
|
|
194
353
|
*/
|
|
195
354
|
/**
|
|
196
355
|
* Sync the document head to a route's resolved {@link Meta} on client navigation. Sets the title
|
|
197
|
-
* (when provided) and replaces the **managed** (`data-nifra`) `<meta>`/`<link>` tags
|
|
356
|
+
* (when provided) and replaces the **managed** (`data-nifra`) `<meta>`/`<link>` tags - static head
|
|
198
357
|
* content (charset, hand-written tags) is never touched. SSR injects the same `data-nifra` tags, so
|
|
199
358
|
* the first navigation cleanly takes over from the server-rendered head.
|
|
200
359
|
*/
|
|
201
360
|
export function applyHead(head: Meta): void {
|
|
202
361
|
if (head.title !== undefined) document.title = head.title
|
|
203
|
-
// `<html lang>`/`<html dir>`
|
|
362
|
+
// `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
|
|
204
363
|
// route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
|
|
205
|
-
// removed when unset. Anything laxer drifts on a multilingual site
|
|
364
|
+
// removed when unset. Anything laxer drifts on a multilingual site - navigating /ur → /en would leave
|
|
206
365
|
// `dir="rtl"` behind and lay the English page out right-to-left, a bug a hard reload would not show.
|
|
207
366
|
const root = document.documentElement
|
|
208
367
|
root.setAttribute("lang", head.lang ?? "en")
|
|
@@ -210,12 +369,13 @@ export function applyHead(head: Meta): void {
|
|
|
210
369
|
else root.setAttribute("dir", head.dir)
|
|
211
370
|
for (const el of document.head.querySelectorAll("[data-nifra]")) el.remove()
|
|
212
371
|
// Values follow the same HTML attribute conventions as the SSR `tagAttrs`: a string sets the value,
|
|
213
|
-
// `true` sets the bare boolean attribute, `false`/`undefined` skip it
|
|
372
|
+
// `true` sets the bare boolean attribute, `false`/`undefined` skip it - so a soft-nav head matches
|
|
214
373
|
// the server-rendered one exactly (no hydration drift on the managed tags).
|
|
215
|
-
const add = (tag: "meta" | "link", attrs:
|
|
374
|
+
const add = (tag: "meta" | "link", attrs: Readonly<object>): void => {
|
|
375
|
+
const trusted = trustedHeadAttributes(tag, attrs)
|
|
376
|
+
if (trusted === null) return
|
|
216
377
|
const el = document.createElement(tag)
|
|
217
|
-
for (const [name, value] of
|
|
218
|
-
if (value === undefined || value === false) continue
|
|
378
|
+
for (const [name, value] of trusted) {
|
|
219
379
|
el.setAttribute(name, value === true ? "" : value)
|
|
220
380
|
}
|
|
221
381
|
el.setAttribute("data-nifra", "")
|
|
@@ -223,13 +383,36 @@ export function applyHead(head: Meta): void {
|
|
|
223
383
|
}
|
|
224
384
|
for (const m of head.meta ?? []) add("meta", m)
|
|
225
385
|
for (const l of head.link ?? []) add("link", l)
|
|
226
|
-
// `<script>` slot (JSON-LD etc.)
|
|
386
|
+
// `<script>` slot (JSON-LD etc.) - soft-nav parity with the SSR `headTags` script render. Set the body
|
|
227
387
|
// via `textContent`, NOT `innerHTML`: `textContent` assigns the raw string without HTML re-parsing, so
|
|
228
|
-
// a `</script>` (or `<!--`/`]]>`) payload in the JSON-LD can't break out
|
|
388
|
+
// a `</script>` (or `<!--`/`]]>`) payload in the JSON-LD can't break out - the DOM-native equivalent of
|
|
229
389
|
// the server's `escapeScriptContent`. `type` defaults to `application/ld+json` (matching the SSR side).
|
|
230
390
|
for (const s of head.script ?? []) {
|
|
391
|
+
const type = s.type ?? "application/ld+json"
|
|
392
|
+
if (!INERT_SCRIPT_TYPES.has(type)) {
|
|
393
|
+
throw new TypeError("[nifra/web] refusing executable content in the inert head.script slot")
|
|
394
|
+
}
|
|
395
|
+
const el = document.createElement("script")
|
|
396
|
+
el.setAttribute("type", type)
|
|
397
|
+
el.textContent = s.content
|
|
398
|
+
el.setAttribute("data-nifra", "")
|
|
399
|
+
document.head.appendChild(el)
|
|
400
|
+
}
|
|
401
|
+
for (const s of head.unsafeScript ?? []) {
|
|
402
|
+
if (s.unsafe !== true || s.nonce.trim() === "") {
|
|
403
|
+
throw new TypeError("[nifra/web] executable inline scripts require a non-empty CSP nonce")
|
|
404
|
+
}
|
|
405
|
+
// Same allowlist the server applies. `setAttribute` cannot be broken out of the way the server's
|
|
406
|
+
// template literal could, so this is not about injection here - it is about the two halves
|
|
407
|
+
// accepting the same documents, so a head that renders cannot fail on the next soft-nav.
|
|
408
|
+
if (!EXECUTABLE_SCRIPT_TYPES.has(s.type)) {
|
|
409
|
+
throw new TypeError(
|
|
410
|
+
`[nifra/web] unsupported executable script type ${JSON.stringify(s.type)}`,
|
|
411
|
+
)
|
|
412
|
+
}
|
|
231
413
|
const el = document.createElement("script")
|
|
232
|
-
el.setAttribute("type", s.type
|
|
414
|
+
el.setAttribute("type", s.type)
|
|
415
|
+
el.setAttribute("nonce", s.nonce)
|
|
233
416
|
el.textContent = s.content
|
|
234
417
|
el.setAttribute("data-nifra", "")
|
|
235
418
|
document.head.appendChild(el)
|
|
@@ -250,7 +433,7 @@ export function installForms(router: ClientRouter): () => void {
|
|
|
250
433
|
// actionData drives the update); absent or any other value keeps the default revalidation.
|
|
251
434
|
const revalidate = form.dataset.nifraRevalidate !== "false"
|
|
252
435
|
router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch(() => {
|
|
253
|
-
form.submit() // data submit failed
|
|
436
|
+
form.submit() // data submit failed - fall back to a full-page POST
|
|
254
437
|
})
|
|
255
438
|
}
|
|
256
439
|
document.addEventListener("submit", onSubmit)
|
|
@@ -262,7 +445,7 @@ export function installForms(router: ClientRouter): () => void {
|
|
|
262
445
|
* and fires a one-shot `nifra:hydrated` event. The generated client entry calls this on the next frame
|
|
263
446
|
* after the adapter mounts (so every framework binding gets it), letting apps gate a custom JS-only
|
|
264
447
|
* interaction that would otherwise fire before its handler is attached. Idempotent. nifra's own
|
|
265
|
-
* progressive-enhancement forms/links don't need it
|
|
448
|
+
* progressive-enhancement forms/links don't need it - only hand-wired onClick/onSubmit handlers with no
|
|
266
449
|
* native fallback do. See the Hydration guide.
|
|
267
450
|
*/
|
|
268
451
|
export function signalHydrated(): void {
|
package/src/conformance.ts
CHANGED
|
@@ -93,8 +93,8 @@ const readHtml = async (
|
|
|
93
93
|
* and hydration-head output is a stable string. It rejects with
|
|
94
94
|
* {@link RenderAdapterConformanceError} naming the first failed check.
|
|
95
95
|
*
|
|
96
|
-
* Framework semantics outside the interface
|
|
97
|
-
* error boundaries, and framework-specific primitives
|
|
96
|
+
* Framework semantics outside the interface-Suspense scheduling, compiler plugins, DOM hydration,
|
|
97
|
+
* error boundaries, and framework-specific primitives-remain the adapter package's local tests.
|
|
98
98
|
*/
|
|
99
99
|
export async function assertRenderAdapterConformance(
|
|
100
100
|
adapter: RenderAdapter,
|
|
@@ -135,7 +135,7 @@ export async function assertRenderAdapterConformance(
|
|
|
135
135
|
if (typeof buffered !== "string") fail("buffered render", "renderToString must return a string")
|
|
136
136
|
const bufferedHtml = buffered as string
|
|
137
137
|
if (fixture.deferred) {
|
|
138
|
-
// Deferred content differs between streaming and buffered renders
|
|
138
|
+
// Deferred content differs between streaming and buffered renders - only require every marker.
|
|
139
139
|
for (const marker of [
|
|
140
140
|
markers.outer,
|
|
141
141
|
markers.inner,
|
package/src/deferred.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Deferred loader data (`defer()`). A loader may mark slow values as deferred: the critical data
|
|
3
3
|
* renders in the shell, the deferred data streams in behind a `<Suspense>` (the adapter's `<Await>`
|
|
4
4
|
* consumes it) and resolves on the client **without a re-fetch**. This module owns the agnostic
|
|
5
|
-
* protocol
|
|
5
|
+
* protocol - the marker, the resolved/deferred split, and the tiny client registry that streamed
|
|
6
6
|
* resolution scripts settle. The per-adapter `<Await>` lives in `@nifrajs/web-{solid,react}/client`.
|
|
7
7
|
*/
|
|
8
8
|
import { plainJsonCodec, type TransportCodec } from "@nifrajs/core/transport-codec"
|
|
@@ -10,7 +10,7 @@ import { plainJsonCodec, type TransportCodec } from "@nifrajs/core/transport-cod
|
|
|
10
10
|
/**
|
|
11
11
|
* A loader value marked to stream in after the shell. The component consumes it with the adapter's
|
|
12
12
|
* `<Await resolve={...}>`; until the promise settles the shell shows the `<Suspense>` fallback.
|
|
13
|
-
* `id` is assigned by the server at serialization time
|
|
13
|
+
* `id` is assigned by the server at serialization time - the streamed resolve script keys off it.
|
|
14
14
|
*/
|
|
15
15
|
export interface Deferred<T> {
|
|
16
16
|
readonly __nifra_deferred: true
|
|
@@ -19,8 +19,8 @@ export interface Deferred<T> {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Mark a loader value as deferred
|
|
23
|
-
* **anywhere** in the loader's returned data
|
|
22
|
+
* Mark a loader value as deferred - it streams in after the shell instead of blocking it. Works
|
|
23
|
+
* **anywhere** in the loader's returned data - a top-level key, or nested in objects/arrays:
|
|
24
24
|
*
|
|
25
25
|
* return { user: await api.user.get(), feed: defer(api.user.feed()),
|
|
26
26
|
* panels: [{ chart: defer(api.metrics()) }] }
|
|
@@ -48,8 +48,8 @@ function isDeferred(value: unknown): value is Deferred<unknown> {
|
|
|
48
48
|
/**
|
|
49
49
|
* Split a loader (or action) result into the **component-facing** data (deferred values carry their
|
|
50
50
|
* assigned `id` + promise, for `<Await>`) and the **client-serializable** data (deferred values
|
|
51
|
-
* replaced with a `{__nifra_deferred: id}` placeholder
|
|
52
|
-
* **recursively**
|
|
51
|
+
* replaced with a `{__nifra_deferred: id}` placeholder - promises don't serialize). Walks the tree
|
|
52
|
+
* **recursively** - `defer()` works at any depth, inside nested objects and arrays - assigning ids in
|
|
53
53
|
* walk order. `idOffset` continues the id space when a page splits two results (loader data **and**
|
|
54
54
|
* action data) into one shared client registry, so their ids don't collide. `deferred` lists the
|
|
55
55
|
* promises for callers that await them. Recurses plain objects + arrays only; the data is expected to
|
|
@@ -65,7 +65,7 @@ export function prepareDeferred(
|
|
|
65
65
|
} {
|
|
66
66
|
const deferred: Array<{ id: number; promise: Promise<unknown> }> = []
|
|
67
67
|
// Structural sharing: a subtree with no deferred value returns the ORIGINAL node for
|
|
68
|
-
// both sides (`fc`/`cl` identical to the input), so a deferred-free result
|
|
68
|
+
// both sides (`fc`/`cl` identical to the input), so a deferred-free result - the common case - is
|
|
69
69
|
// returned by reference with no persisted clone, no downstream copy, and no throwaway clone
|
|
70
70
|
// allocations. Only the path to a `defer()` marker is rebuilt; unchanged siblings are shared by
|
|
71
71
|
// reference.
|
|
@@ -140,19 +140,19 @@ export function prepareDeferred(
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
|
-
* Stable, non-leaking payload streamed to the client when a deferred value rejects
|
|
143
|
+
* Stable, non-leaking payload streamed to the client when a deferred value rejects - never the raw
|
|
144
144
|
* error text. The real reason is logged server-side; `<Await>`'s `errorFallback` receives
|
|
145
145
|
* this code. (A future typed `DeferredError` could opt into a public, intentional message.) Shared by
|
|
146
146
|
* the NDJSON soft-nav transport and the full-document SSR path (`renderPage`'s `streamDocument`).
|
|
147
147
|
*/
|
|
148
148
|
export const DEFERRED_ERROR_CODE = "deferred_error"
|
|
149
|
-
// Shared, stateless
|
|
149
|
+
// Shared, stateless - allocated once at module load, not per stream.
|
|
150
150
|
const NDJSON_ENCODER = new TextEncoder()
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
153
|
* Stream a loader result as NDJSON for a client (soft) navigation: line 1 is the critical data with
|
|
154
154
|
* `{__nifra_deferred: id}` placeholders (`forClient`), then one line per deferred as its promise
|
|
155
|
-
* settles
|
|
155
|
+
* settles - `{"i": id, "v": value}` on resolve, `{"i": id, "e": "deferred_error"}` on reject (a
|
|
156
156
|
* rejection is data, not a stream error; the opaque code never leaks the raw reason). Closes when all
|
|
157
157
|
* settle. The client (`defaultFetchData`) returns the
|
|
158
158
|
* data after line 1 and settles `<Await>`'s markers as the resolution lines arrive. `JSON.stringify`
|
|
@@ -210,7 +210,7 @@ export const DEFERRED_RUNTIME = `(() => {
|
|
|
210
210
|
return e;
|
|
211
211
|
};
|
|
212
212
|
window.__nifraDeferred = (id) => get(id).p;
|
|
213
|
-
// Tag the promise (status/value/reason)
|
|
213
|
+
// Tag the promise (status/value/reason) - React's use() reads them and returns SYNCHRONOUSLY at
|
|
214
214
|
// hydration, so it renders the resolved content directly into the <Suspense> boundary the server
|
|
215
215
|
// streamed (no re-suspend, no fallback flash). r/j also wake awaiters (Solid uses its own _$HY).
|
|
216
216
|
window.__nifraResolve = (id, v) => { const e = get(id); e.p.status = "fulfilled"; e.p.value = v; e.r(v); };
|
|
@@ -219,7 +219,7 @@ export const DEFERRED_RUNTIME = `(() => {
|
|
|
219
219
|
|
|
220
220
|
/**
|
|
221
221
|
* Source emitted into the generated client entry: maps serialized `{__nifra_deferred: id}` placeholders
|
|
222
|
-
* (at any depth
|
|
222
|
+
* (at any depth - nested objects/arrays) to the registry's promises, so the component receives real
|
|
223
223
|
* promises to `<Await>`. A no-op for data without placeholders (so non-deferred pages are unchanged).
|
|
224
224
|
*/
|
|
225
225
|
export const MAP_DEFERRED_SOURCE = `const mapDeferred = (d) => {
|
|
@@ -233,18 +233,18 @@ export const MAP_DEFERRED_SOURCE = `const mapDeferred = (d) => {
|
|
|
233
233
|
|
|
234
234
|
type Pending = Map<number, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>
|
|
235
235
|
|
|
236
|
-
/** Replace `{__nifra_deferred: <number>}` placeholders (at any depth
|
|
236
|
+
/** Replace `{__nifra_deferred: <number>}` placeholders (at any depth - nested objects/arrays) with
|
|
237
237
|
* `Deferred` markers whose promises are held open in `pending` (settled later by
|
|
238
238
|
* {@link parseNdjsonData}'s drain). Mirrors `prepareDeferred`'s recursive walk. */
|
|
239
239
|
function markersFromPlaceholders(data: unknown, pending: Pending): unknown {
|
|
240
|
-
// Markers already created in THIS parse, keyed by id
|
|
240
|
+
// Markers already created in THIS parse, keyed by id - so a repeated id aliases the first marker.
|
|
241
241
|
const byId = new Map<number, Deferred<unknown>>()
|
|
242
242
|
const walk = (value: unknown): unknown => {
|
|
243
243
|
const id = (value as { __nifra_deferred?: unknown } | null)?.__nifra_deferred
|
|
244
244
|
if (value !== null && typeof value === "object" && typeof id === "number") {
|
|
245
245
|
// The NDJSON stream is a trust boundary; the server assigns unique ids, but a corrupt/crafted
|
|
246
246
|
// line-1 could repeat one. Alias a duplicate id to the first marker (same promise) instead of
|
|
247
|
-
// overwriting its resolver in `pending`
|
|
247
|
+
// overwriting its resolver in `pending` - otherwise the first marker would never settle (a
|
|
248
248
|
// permanently-pending promise that leaks on abort). One resolve line then settles both.
|
|
249
249
|
const seen = byId.get(id)
|
|
250
250
|
if (seen !== undefined) return seen
|
|
@@ -280,7 +280,7 @@ function markersFromPlaceholders(data: unknown, pending: Pending): unknown {
|
|
|
280
280
|
* the NDJSON body into a data object whose deferred markers settle as resolution lines arrive.
|
|
281
281
|
* Returns after **line 1** so the router can apply the critical data + render immediately; the
|
|
282
282
|
* markers' promises settle/reject in the background. If the stream ends (or `signal` aborts) with
|
|
283
|
-
* markers unsettled, they reject
|
|
283
|
+
* markers unsettled, they reject - so `<Await>` never hangs.
|
|
284
284
|
*/
|
|
285
285
|
export async function parseNdjsonData(
|
|
286
286
|
stream: ReadableStream<Uint8Array>,
|
|
@@ -331,7 +331,7 @@ export async function parseNdjsonData(
|
|
|
331
331
|
else entry.resolve(msg.v)
|
|
332
332
|
}
|
|
333
333
|
} catch {
|
|
334
|
-
// stream errored
|
|
334
|
+
// stream errored - leftovers are rejected below (unless this was an abort)
|
|
335
335
|
} finally {
|
|
336
336
|
signal?.removeEventListener("abort", onAbort) // no-op if it already fired ({ once: true })
|
|
337
337
|
// A natural stream end with markers unsettled = truncation → reject (so `<Await>` shows its
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev server's diagnostics surface, shared by both dev-server adapters - Bun in `dev.ts`, Vite in
|
|
3
|
+
* `vite.ts`. It owns the one contract that must never drift between them: capture the most recent SSR
|
|
4
|
+
* failure, serve it as JSON at `LAST_ERROR_PATH` with the identity + no-store headers an agent relies on,
|
|
5
|
+
* and render that same failure as the developer overlay. Each adapter keeps only its bundler-specific
|
|
6
|
+
* transport (Bun.serve + HMR vs Vite middlewares + `ssrFixStacktrace`); the diagnostic behaviour lives
|
|
7
|
+
* here once, so a new header or a new capture rule is a single edit both servers pick up.
|
|
8
|
+
*
|
|
9
|
+
* Vite once shipped without the `/__nifra/last-error` endpoint at all - the drift this module prevents.
|
|
10
|
+
*/
|
|
11
|
+
import { renderDiagnosticOverlay } from "./dev-error.ts"
|
|
12
|
+
import { buildDiagnostic, type Diagnostic, LAST_ERROR_PATH } from "./diagnostic.ts"
|
|
13
|
+
|
|
14
|
+
export interface DevDiagnostics {
|
|
15
|
+
/** True when a request path targets the structured last-error endpoint. */
|
|
16
|
+
isLastErrorPath(pathname: string): boolean
|
|
17
|
+
/** The JSON body + headers for the last-error endpoint: the most recent failure, or a benign
|
|
18
|
+
* `NIFRA_NONE` shape when nothing has failed yet. `no-store` so a stale failure is never cached. */
|
|
19
|
+
lastError(): { readonly body: string; readonly headers: Readonly<Record<string, string>> }
|
|
20
|
+
/** Capture a thrown SSR failure: store it for the endpoint and return the overlay HTML. The overlay a
|
|
21
|
+
* person sees and the JSON an agent reads come from this one Diagnostic, so they can never disagree. */
|
|
22
|
+
capture(err: unknown, request: { readonly method: string; readonly url: string }): string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One diagnostics surface per dev server. `root` is the resolved project root; it scopes the codeframe
|
|
26
|
+
* to the project (see buildDiagnostic). Both dev servers resolve a concrete root before calling this. */
|
|
27
|
+
export function createDevDiagnostics(root: string): DevDiagnostics {
|
|
28
|
+
let last: Diagnostic | undefined
|
|
29
|
+
return {
|
|
30
|
+
isLastErrorPath: (pathname) => pathname === LAST_ERROR_PATH,
|
|
31
|
+
lastError: () => ({
|
|
32
|
+
body: JSON.stringify(
|
|
33
|
+
last ?? { code: "NIFRA_NONE", message: "No error captured since the dev server started." },
|
|
34
|
+
),
|
|
35
|
+
headers: {
|
|
36
|
+
"content-type": "application/json; charset=utf-8",
|
|
37
|
+
"cache-control": "no-store",
|
|
38
|
+
"x-content-type-options": "nosniff",
|
|
39
|
+
"x-nifra-diagnostic": "true",
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
capture: (err, request) => {
|
|
43
|
+
last = buildDiagnostic(err, { root, request })
|
|
44
|
+
return renderDiagnosticOverlay(last)
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|