@nifrajs/web 0.1.0-alpha.1

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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/css-modules.d.ts +34 -0
  4. package/dist/build.d.ts +110 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +238 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/client.d.ts +32 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +204 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/deferred.d.ts +90 -0
  13. package/dist/deferred.d.ts.map +1 -0
  14. package/dist/deferred.js +310 -0
  15. package/dist/deferred.js.map +1 -0
  16. package/dist/dev-error.d.ts +17 -0
  17. package/dist/dev-error.d.ts.map +1 -0
  18. package/dist/dev-error.js +59 -0
  19. package/dist/dev-error.js.map +1 -0
  20. package/dist/dev.d.ts +25 -0
  21. package/dist/dev.d.ts.map +1 -0
  22. package/dist/dev.js +83 -0
  23. package/dist/dev.js.map +1 -0
  24. package/dist/draft.d.ts +58 -0
  25. package/dist/draft.d.ts.map +1 -0
  26. package/dist/draft.js +55 -0
  27. package/dist/draft.js.map +1 -0
  28. package/dist/fonts-google.d.ts +109 -0
  29. package/dist/fonts-google.d.ts.map +1 -0
  30. package/dist/fonts-google.js +291 -0
  31. package/dist/fonts-google.js.map +1 -0
  32. package/dist/fonts.d.ts +67 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +103 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/fs.d.ts +10 -0
  37. package/dist/fs.d.ts.map +1 -0
  38. package/dist/fs.js +24 -0
  39. package/dist/fs.js.map +1 -0
  40. package/dist/index.d.ts +280 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +784 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/islands.d.ts +37 -0
  45. package/dist/islands.d.ts.map +1 -0
  46. package/dist/islands.js +107 -0
  47. package/dist/islands.js.map +1 -0
  48. package/dist/isr.d.ts +154 -0
  49. package/dist/isr.d.ts.map +1 -0
  50. package/dist/isr.js +331 -0
  51. package/dist/isr.js.map +1 -0
  52. package/dist/manifest.d.ts +178 -0
  53. package/dist/manifest.d.ts.map +1 -0
  54. package/dist/manifest.js +213 -0
  55. package/dist/manifest.js.map +1 -0
  56. package/dist/plugins/css-modules.d.ts +40 -0
  57. package/dist/plugins/css-modules.d.ts.map +1 -0
  58. package/dist/plugins/css-modules.js +0 -0
  59. package/dist/plugins/css-modules.js.map +1 -0
  60. package/dist/plugins/kit.d.ts +44 -0
  61. package/dist/plugins/kit.d.ts.map +1 -0
  62. package/dist/plugins/kit.js +105 -0
  63. package/dist/plugins/kit.js.map +1 -0
  64. package/dist/plugins/scss.d.ts +27 -0
  65. package/dist/plugins/scss.d.ts.map +1 -0
  66. package/dist/plugins/scss.js +78 -0
  67. package/dist/plugins/scss.js.map +1 -0
  68. package/dist/prerender.d.ts +77 -0
  69. package/dist/prerender.d.ts.map +1 -0
  70. package/dist/prerender.js +139 -0
  71. package/dist/prerender.js.map +1 -0
  72. package/dist/query.d.ts +62 -0
  73. package/dist/query.d.ts.map +1 -0
  74. package/dist/query.js +184 -0
  75. package/dist/query.js.map +1 -0
  76. package/dist/router.d.ts +169 -0
  77. package/dist/router.d.ts.map +1 -0
  78. package/dist/router.js +531 -0
  79. package/dist/router.js.map +1 -0
  80. package/dist/vite.d.ts +49 -0
  81. package/dist/vite.d.ts.map +1 -0
  82. package/dist/vite.js +144 -0
  83. package/dist/vite.js.map +1 -0
  84. package/package.json +127 -0
  85. package/src/build.ts +375 -0
  86. package/src/client.ts +218 -0
  87. package/src/deferred.ts +339 -0
  88. package/src/dev-error.ts +62 -0
  89. package/src/dev.ts +108 -0
  90. package/src/draft.ts +82 -0
  91. package/src/fonts-google.ts +397 -0
  92. package/src/fonts.ts +147 -0
  93. package/src/fs.ts +26 -0
  94. package/src/index.ts +1202 -0
  95. package/src/islands.ts +121 -0
  96. package/src/isr.ts +461 -0
  97. package/src/manifest.ts +366 -0
  98. package/src/plugins/css-modules.ts +0 -0
  99. package/src/plugins/kit.ts +123 -0
  100. package/src/plugins/scss.ts +108 -0
  101. package/src/prerender.ts +209 -0
  102. package/src/query.ts +250 -0
  103. package/src/router.ts +676 -0
  104. package/src/vite.ts +212 -0
package/src/client.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `@nifrajs/web/client` — the agnostic browser layer for client-side navigation. It wires the pure
3
+ * router store ({@link ClientRouter}) to the browser: `pushState` on navigate, `popstate` →
4
+ * navigate, and delegated interception of same-origin `<a>` clicks (client transition instead of
5
+ * a full page load). DOM-only — never imported on the server, so the store stays SSR-safe.
6
+ */
7
+ import type { Meta } from "./manifest.ts"
8
+ import type { ClientRouter } from "./router.ts"
9
+
10
+ export interface InstallHistoryOptions {
11
+ /** Full-page fallback when a client navigation can't proceed (default: `location.assign`). */
12
+ readonly fallback?: (path: string) => void
13
+ }
14
+
15
+ /**
16
+ * Attach history + link interception to a router. Returns a teardown function that removes the
17
+ * listeners. A data-fetch failure during a client navigation falls back to a full-page load, so
18
+ * navigation degrades gracefully rather than leaving the user stuck.
19
+ */
20
+ export function installHistory(
21
+ router: ClientRouter,
22
+ options: InstallHistoryOptions = {},
23
+ ): () => void {
24
+ const fallback = options.fallback ?? ((path: string) => location.assign(path))
25
+
26
+ // We manage scroll ourselves (save per history entry, restore after the navigated content
27
+ // renders), so disable the browser's native restoration which would otherwise fight us.
28
+ history.scrollRestoration = "manual"
29
+ // A pending scroll is either a saved [x,y] (fresh push → top; back/forward → the stored position) or a
30
+ // fragment target (`#id`) to scroll to once the cross-page navigation's content renders.
31
+ type PendingScroll = { readonly pos: readonly [number, number] } | { readonly hash: string }
32
+ let pendingScroll: PendingScroll | null = null
33
+ const scrollOf = (state: unknown): [number, number] => {
34
+ const saved = (state as { nifraScroll?: [number, number] } | null)?.nifraScroll
35
+ return Array.isArray(saved) ? saved : [0, 0]
36
+ }
37
+ // The fragment id from a `#hash` (percent-decoded; a malformed escape falls back to the raw value).
38
+ const hashId = (hash: string): string => {
39
+ const raw = hash.slice(1)
40
+ try {
41
+ return decodeURIComponent(raw)
42
+ } catch {
43
+ return raw
44
+ }
45
+ }
46
+ // Resolve a fragment id to its element — by `id`, then a legacy `<a name>` — or null if absent.
47
+ const findAnchor = (id: string): Element | null => {
48
+ if (id === "") return null
49
+ const byId = document.getElementById(id)
50
+ if (byId !== null) return byId
51
+ try {
52
+ return document.querySelector(`a[name="${CSS.escape(id)}"]`)
53
+ } catch {
54
+ return null // CSS/CSS.escape unavailable or an exotic id → no named-anchor match
55
+ }
56
+ }
57
+
58
+ // Wrap a navigation's render in a View Transition when the browser supports it — a graceful
59
+ // enhancement (no-op elsewhere). The captured "before" is held across the data fetch, so pair
60
+ // with link prefetch (hover/focus warms the cache) to keep most transitions instant.
61
+ type ViewTransition = {
62
+ readonly ready: Promise<unknown>
63
+ readonly finished: Promise<unknown>
64
+ readonly updateCallbackDone: Promise<unknown>
65
+ }
66
+ type ViewTransitionDocument = Document & {
67
+ startViewTransition?: (cb: () => unknown) => ViewTransition
68
+ }
69
+ const transition = (run: () => Promise<void>): void => {
70
+ const doc = document as ViewTransitionDocument
71
+ if (typeof doc.startViewTransition !== "function") {
72
+ void run()
73
+ return
74
+ }
75
+ const vt = doc.startViewTransition(run)
76
+ // Rapid in-app clicks supersede an in-flight transition; the browser skips it and rejects its
77
+ // promises. Reasons vary by engine — InvalidStateError "Transition was aborted because of
78
+ // invalid state", or AbortError "Transition was skipped". The navigation still completes, so
79
+ // we swallow these expected aborts rather than let them surface as unhandled rejections.
80
+ for (const p of [vt.ready, vt.finished, vt.updateCallbackDone]) p.catch(() => {})
81
+ }
82
+
83
+ const go = (path: string, push: boolean): void => {
84
+ const url = new URL(path, location.origin)
85
+ if (push) {
86
+ // Save the leaving entry's scroll, push a fresh entry (URL incl. any #hash), then scroll the new
87
+ // route: to the fragment target if one was given, else to the top.
88
+ history.replaceState({ ...(history.state ?? {}), nifraScroll: [scrollX, scrollY] }, "")
89
+ history.pushState({}, "", path)
90
+ pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
91
+ }
92
+ // The data layer fetches by path+search; the #hash is client-only (never sent to the server).
93
+ transition(() => router.navigate(url.pathname + url.search).catch(() => fallback(path)))
94
+ }
95
+
96
+ // Resolve an event target to an in-app route path, or null (cross-origin, unknown route, or an
97
+ // anchor opting out via target/download/rel=external). Shared by click + hover/focus prefetch.
98
+ const inAppHref = (target: EventTarget | null): string | null => {
99
+ const anchor = target instanceof Element ? target.closest("a") : null
100
+ if (anchor === null) return null
101
+ if (anchor.target !== "" && anchor.target !== "_self") return null
102
+ if (anchor.hasAttribute("download")) return null
103
+ if (anchor.getAttribute("rel")?.split(/\s+/).includes("external")) return null
104
+ const url = new URL(anchor.href)
105
+ if (url.origin !== location.origin) return null
106
+ if (router.match(url.pathname) === null) return null
107
+ // A same-page fragment link (`#section`, `/here#section` — only the hash differs) → null: let the
108
+ // browser do its native in-page anchor jump. Intercepting it would drop the fragment from the URL
109
+ // and force a scroll-to-top (a fresh push restores [0,0]) — i.e. break every in-page anchor (AUDIT
110
+ // H2). (A same-page link with NO hash still soft-navigates, as before.)
111
+ if (url.pathname === location.pathname && url.search === location.search && url.hash !== "")
112
+ return null
113
+ // Keep the hash: a cross-page link to `/docs#install` lands on that anchor once the page renders.
114
+ return url.pathname + url.search + url.hash
115
+ }
116
+
117
+ const onClick = (event: MouseEvent): void => {
118
+ // Let the browser handle anything that isn't a plain left-click (modifiers = new tab/window).
119
+ if (event.defaultPrevented || event.button !== 0) return
120
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
121
+ const href = inAppHref(event.target)
122
+ if (href === null) return
123
+ event.preventDefault()
124
+ go(href, true)
125
+ }
126
+
127
+ // Hover/focus an in-app link → warm its chunk + data (the store dedupes the spam).
128
+ const onPrefetch = (event: Event): void => {
129
+ const href = inAppHref(event.target)
130
+ if (href === null) return
131
+ const url = new URL(href, location.origin)
132
+ void router.prefetch(url.pathname + url.search) // warm by path+search; the #hash isn't data
133
+ }
134
+
135
+ // Back/forward: the entry exists (no push); restore its saved scroll after it renders.
136
+ const onPopState = (): void => {
137
+ pendingScroll = { pos: scrollOf(history.state) }
138
+ transition(() =>
139
+ router.navigate(location.pathname + location.search).catch(() => fallback(location.pathname)),
140
+ )
141
+ }
142
+
143
+ // After a navigation settles (content rendered), apply the pending scroll target on the next frame:
144
+ // a fragment's element for a cross-page `#hash`, the saved position for back/forward, else the top.
145
+ // Skipped for submits (pending).
146
+ const restoreScroll = (): void => {
147
+ if (router.snapshot().pending || pendingScroll === null) return
148
+ const target = pendingScroll
149
+ pendingScroll = null
150
+ requestAnimationFrame(() => {
151
+ if ("hash" in target) {
152
+ const el = findAnchor(target.hash)
153
+ if (el !== null) el.scrollIntoView()
154
+ else window.scrollTo(0, 0) // fragment not found → top, like a fresh page load
155
+ } else {
156
+ window.scrollTo(target.pos[0], target.pos[1])
157
+ }
158
+ })
159
+ }
160
+ const unsubscribe = router.subscribe(restoreScroll)
161
+
162
+ document.addEventListener("click", onClick)
163
+ document.addEventListener("pointerover", onPrefetch)
164
+ document.addEventListener("focusin", onPrefetch)
165
+ window.addEventListener("popstate", onPopState)
166
+ return () => {
167
+ unsubscribe()
168
+ document.removeEventListener("click", onClick)
169
+ document.removeEventListener("pointerover", onPrefetch)
170
+ document.removeEventListener("focusin", onPrefetch)
171
+ window.removeEventListener("popstate", onPopState)
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Intercept submissions of same-origin `<form method="post">` whose action targets an app route:
177
+ * submit via the router (no full reload — POST the action, then revalidate the active loader). On
178
+ * failure it falls back to a native submit, so the form still works. Returns a teardown function.
179
+ */
180
+ /**
181
+ * Sync the document head to a route's resolved {@link Meta} on client navigation. Sets the title
182
+ * (when provided) and replaces the **managed** (`data-nifra`) `<meta>`/`<link>` tags — static head
183
+ * content (charset, hand-written tags) is never touched. SSR injects the same `data-nifra` tags, so
184
+ * the first navigation cleanly takes over from the server-rendered head.
185
+ */
186
+ export function applyHead(head: Meta): void {
187
+ if (head.title !== undefined) document.title = head.title
188
+ for (const el of document.head.querySelectorAll("[data-nifra]")) el.remove()
189
+ const add = (tag: "meta" | "link", attrs: Record<string, string>): void => {
190
+ const el = document.createElement(tag)
191
+ for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value)
192
+ el.setAttribute("data-nifra", "")
193
+ document.head.appendChild(el)
194
+ }
195
+ for (const m of head.meta ?? []) add("meta", m)
196
+ for (const l of head.link ?? []) add("link", l)
197
+ }
198
+
199
+ export function installForms(router: ClientRouter): () => void {
200
+ const onSubmit = (event: SubmitEvent): void => {
201
+ if (event.defaultPrevented) return
202
+ const form = event.target
203
+ if (!(form instanceof HTMLFormElement)) return
204
+ if (form.method.toLowerCase() !== "post") return // GET forms submit natively
205
+ const url = new URL(form.action) // resolved (or the current document URL if unset)
206
+ if (url.origin !== location.origin) return
207
+ if (router.match(url.pathname) === null) return // not an app route → native submit
208
+ event.preventDefault()
209
+ // `data-nifra-revalidate="false"` opts out of the post-action loader revalidation (the action's
210
+ // actionData drives the update); absent or any other value keeps the default revalidation.
211
+ const revalidate = form.dataset.nifraRevalidate !== "false"
212
+ router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch(() => {
213
+ form.submit() // data submit failed — fall back to a full-page POST
214
+ })
215
+ }
216
+ document.addEventListener("submit", onSubmit)
217
+ return () => document.removeEventListener("submit", onSubmit)
218
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Deferred loader data (`defer()`). A loader may mark slow values as deferred: the critical data
3
+ * renders in the shell, the deferred data streams in behind a `<Suspense>` (the adapter's `<Await>`
4
+ * consumes it) and resolves on the client **without a re-fetch**. This module owns the agnostic
5
+ * protocol — the marker, the resolved/deferred split, and the tiny client registry that streamed
6
+ * resolution scripts settle. The per-adapter `<Await>` lives in `@nifrajs/web-{solid,react}/client`.
7
+ */
8
+
9
+ /**
10
+ * A loader value marked to stream in after the shell. The component consumes it with the adapter's
11
+ * `<Await resolve={...}>`; until the promise settles the shell shows the `<Suspense>` fallback.
12
+ * `id` is assigned by the server at serialization time — the streamed resolve script keys off it.
13
+ */
14
+ export interface Deferred<T> {
15
+ readonly __nifra_deferred: true
16
+ readonly id: number
17
+ readonly promise: Promise<T>
18
+ }
19
+
20
+ /**
21
+ * Mark a loader value as deferred — it streams in after the shell instead of blocking it. Works
22
+ * **anywhere** in the loader's returned data — a top-level key, or nested in objects/arrays:
23
+ *
24
+ * return { user: await api.user.get(), feed: defer(api.user.feed()),
25
+ * panels: [{ chart: defer(api.metrics()) }] }
26
+ *
27
+ * `LoaderData<typeof loader>` surfaces each deferred value as `Deferred<…>` (not awaited), so the
28
+ * component knows to `<Await>` it. The `id` is a placeholder until the server assigns the real one.
29
+ */
30
+ export function defer<T>(promise: Promise<T>): Deferred<T> {
31
+ return { __nifra_deferred: true, id: -1, promise }
32
+ }
33
+
34
+ /** A loader value is a deferred marker (vs. a serialized `{__nifra_deferred: <number>}` placeholder,
35
+ * whose field is a number, not `true`). */
36
+ function isDeferred(value: unknown): value is Deferred<unknown> {
37
+ return (
38
+ typeof value === "object" &&
39
+ value !== null &&
40
+ (value as { __nifra_deferred?: unknown }).__nifra_deferred === true
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Split a loader (or action) result into the **component-facing** data (deferred values carry their
46
+ * assigned `id` + promise, for `<Await>`) and the **client-serializable** data (deferred values
47
+ * replaced with a `{__nifra_deferred: id}` placeholder — promises don't serialize). Walks the tree
48
+ * **recursively** — `defer()` works at any depth, inside nested objects and arrays — assigning ids in
49
+ * walk order. `idOffset` continues the id space when a page splits two results (loader data **and**
50
+ * action data) into one shared client registry, so their ids don't collide. `deferred` lists the
51
+ * promises for callers that await them. Recurses plain objects + arrays only; the data is expected to
52
+ * be plain JSON-serializable values.
53
+ */
54
+ export function prepareDeferred(
55
+ data: unknown,
56
+ idOffset = 0,
57
+ ): {
58
+ readonly forComponent: unknown
59
+ readonly forClient: unknown
60
+ readonly deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>
61
+ } {
62
+ const deferred: Array<{ id: number; promise: Promise<unknown> }> = []
63
+ // Structural sharing: a subtree with no deferred value returns the ORIGINAL node for
64
+ // both sides (`fc`/`cl` identical to the input), so a deferred-free result — the common case — is
65
+ // returned by reference with no persisted clone, no downstream copy, and no throwaway clone
66
+ // allocations. Only the path to a `defer()` marker is rebuilt; unchanged siblings are shared by
67
+ // reference.
68
+ const split = (value: unknown): { fc: unknown; cl: unknown } | undefined => {
69
+ if (isDeferred(value)) {
70
+ const id = idOffset + deferred.length
71
+ deferred.push({ id, promise: value.promise })
72
+ return {
73
+ fc: { __nifra_deferred: true, id, promise: value.promise },
74
+ cl: { __nifra_deferred: id },
75
+ }
76
+ }
77
+ if (Array.isArray(value)) {
78
+ let fc: unknown[] | undefined
79
+ let cl: unknown[] | undefined
80
+ for (let i = 0; i < value.length; i++) {
81
+ const v = value[i]
82
+ const r = split(v)
83
+ if (r === undefined) {
84
+ if (fc !== undefined && cl !== undefined) {
85
+ fc[i] = v
86
+ cl[i] = v
87
+ }
88
+ continue
89
+ }
90
+ if (fc === undefined || cl === undefined) {
91
+ fc = value.slice(0, i)
92
+ cl = value.slice(0, i)
93
+ }
94
+ fc[i] = r.fc
95
+ cl[i] = r.cl
96
+ }
97
+ return fc === undefined || cl === undefined ? undefined : { fc, cl }
98
+ }
99
+ if (value !== null && typeof value === "object") {
100
+ const input = value as Record<string, unknown>
101
+ let fc: Record<string, unknown> | undefined
102
+ let cl: Record<string, unknown> | undefined
103
+ for (const k in input) {
104
+ if (!Object.hasOwn(input, k)) continue
105
+ const v = input[k]
106
+ const r = split(v)
107
+ if (r === undefined) {
108
+ if (fc !== undefined && cl !== undefined) {
109
+ fc[k] = v
110
+ cl[k] = v
111
+ }
112
+ continue
113
+ }
114
+ if (fc === undefined || cl === undefined) {
115
+ fc = {}
116
+ cl = {}
117
+ for (const prev in input) {
118
+ if (!Object.hasOwn(input, prev)) continue
119
+ if (prev === k) break
120
+ const prevValue = input[prev]
121
+ fc[prev] = prevValue
122
+ cl[prev] = prevValue
123
+ }
124
+ }
125
+ fc[k] = r.fc
126
+ cl[k] = r.cl
127
+ }
128
+ return fc === undefined || cl === undefined ? undefined : { fc, cl }
129
+ }
130
+ return undefined
131
+ }
132
+ const result = split(data)
133
+ return result === undefined
134
+ ? { forComponent: data, forClient: data, deferred }
135
+ : { forComponent: result.fc, forClient: result.cl, deferred }
136
+ }
137
+
138
+ /**
139
+ * Stable, non-leaking payload streamed to the client when a deferred value rejects — never the raw
140
+ * error text. The real reason is logged server-side; `<Await>`'s `errorFallback` receives
141
+ * this code. (A future typed `DeferredError` could opt into a public, intentional message.) Shared by
142
+ * the NDJSON soft-nav transport and the full-document SSR path (`renderPage`'s `streamDocument`).
143
+ */
144
+ export const DEFERRED_ERROR_CODE = "deferred_error"
145
+ // Shared, stateless — allocated once at module load, not per stream.
146
+ const NDJSON_ENCODER = new TextEncoder()
147
+
148
+ /**
149
+ * Stream a loader result as NDJSON for a client (soft) navigation: line 1 is the critical data with
150
+ * `{__nifra_deferred: id}` placeholders (`forClient`), then one line per deferred as its promise
151
+ * settles — `{"i": id, "v": value}` on resolve, `{"i": id, "e": "deferred_error"}` on reject (a
152
+ * rejection is data, not a stream error; the opaque code never leaks the raw reason). Closes when all
153
+ * settle. The client (`defaultFetchData`) returns the
154
+ * data after line 1 and settles `<Await>`'s markers as the resolution lines arrive. `JSON.stringify`
155
+ * escapes newlines in values, so each line is NDJSON-safe; no HTML escaping (this is a fetch body,
156
+ * parsed with `JSON.parse`, never injected into markup).
157
+ */
158
+ export function ndjsonStream(
159
+ forClient: unknown,
160
+ deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
161
+ ): ReadableStream<Uint8Array> {
162
+ return new ReadableStream({
163
+ async start(controller) {
164
+ controller.enqueue(NDJSON_ENCODER.encode(`${JSON.stringify(forClient)}\n`))
165
+ await Promise.all(
166
+ deferred.map(async (d) => {
167
+ try {
168
+ const value = await d.promise
169
+ controller.enqueue(NDJSON_ENCODER.encode(`${JSON.stringify({ i: d.id, v: value })}\n`))
170
+ } catch (err) {
171
+ // Redact: stream a stable opaque code, never the raw error text; log the real reason
172
+ // server-side. The client's <Await> errorFallback receives this code.
173
+ console.error("[nifra/web] deferred value rejected:", err)
174
+ controller.enqueue(
175
+ NDJSON_ENCODER.encode(`${JSON.stringify({ i: d.id, e: DEFERRED_ERROR_CODE })}\n`),
176
+ )
177
+ }
178
+ }),
179
+ )
180
+ controller.close()
181
+ },
182
+ })
183
+ }
184
+
185
+ /**
186
+ * Inline client runtime injected into the shell `<head>` when a page has deferred data: a per-id
187
+ * promise registry that streamed `__nifraResolve(id, value)` / `__nifraReject(id, err)` scripts settle
188
+ * (created lazily, so a resolve script that arrives before the client maps the placeholder still
189
+ * works), plus `__nifraDeferred(id)` to read the promise. A plain inline script (not the deferred
190
+ * module entry), so it runs before any streamed resolve script.
191
+ */
192
+ export const DEFERRED_RUNTIME = `(() => {
193
+ const reg = new Map();
194
+ const get = (id) => {
195
+ let e = reg.get(id);
196
+ if (!e) {
197
+ let r, j;
198
+ const p = new Promise((a, b) => { r = a; j = b; });
199
+ // <Await> reads the tagged status/reason synchronously (it never .then()s a rejected promise),
200
+ // so attach a no-op catch to keep a rejection from surfacing as an unhandled rejection.
201
+ p.catch(() => {});
202
+ e = { p, r, j };
203
+ reg.set(id, e);
204
+ }
205
+ return e;
206
+ };
207
+ window.__nifraDeferred = (id) => get(id).p;
208
+ // Tag the promise (status/value/reason) — React's use() reads them and returns SYNCHRONOUSLY at
209
+ // hydration, so it renders the resolved content directly into the <Suspense> boundary the server
210
+ // streamed (no re-suspend, no fallback flash). r/j also wake awaiters (Solid uses its own _$HY).
211
+ window.__nifraResolve = (id, v) => { const e = get(id); e.p.status = "fulfilled"; e.p.value = v; e.r(v); };
212
+ window.__nifraReject = (id, x) => { const e = get(id); e.p.status = "rejected"; e.p.reason = x; e.j(x); };
213
+ })();`
214
+
215
+ /**
216
+ * Source emitted into the generated client entry: maps serialized `{__nifra_deferred: id}` placeholders
217
+ * (at any depth — nested objects/arrays) to the registry's promises, so the component receives real
218
+ * promises to `<Await>`. A no-op for data without placeholders (so non-deferred pages are unchanged).
219
+ */
220
+ export const MAP_DEFERRED_SOURCE = `const mapDeferred = (d) => {
221
+ if (!d || typeof d !== "object") return d
222
+ if (typeof d.__nifra_deferred === "number") return { __nifra_deferred: true, id: d.__nifra_deferred, promise: window.__nifraDeferred(d.__nifra_deferred) }
223
+ if (Array.isArray(d)) return d.map(mapDeferred)
224
+ const out = {}
225
+ for (const k in d) out[k] = mapDeferred(d[k])
226
+ return out
227
+ }`
228
+
229
+ type Pending = Map<number, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>
230
+
231
+ /** Replace `{__nifra_deferred: <number>}` placeholders (at any depth — nested objects/arrays) with
232
+ * `Deferred` markers whose promises are held open in `pending` (settled later by
233
+ * {@link parseNdjsonData}'s drain). Mirrors `prepareDeferred`'s recursive walk. */
234
+ function markersFromPlaceholders(data: unknown, pending: Pending): unknown {
235
+ // Markers already created in THIS parse, keyed by id — so a repeated id aliases the first marker.
236
+ const byId = new Map<number, Deferred<unknown>>()
237
+ const walk = (value: unknown): unknown => {
238
+ const id = (value as { __nifra_deferred?: unknown } | null)?.__nifra_deferred
239
+ if (value !== null && typeof value === "object" && typeof id === "number") {
240
+ // The NDJSON stream is a trust boundary; the server assigns unique ids, but a corrupt/crafted
241
+ // line-1 could repeat one. Alias a duplicate id to the first marker (same promise) instead of
242
+ // overwriting its resolver in `pending` — otherwise the first marker would never settle (a
243
+ // permanently-pending promise that leaks on abort). One resolve line then settles both.
244
+ const seen = byId.get(id)
245
+ if (seen !== undefined) return seen
246
+ let resolve!: (v: unknown) => void
247
+ let reject!: (e: unknown) => void
248
+ const promise = new Promise<unknown>((res, rej) => {
249
+ resolve = res
250
+ reject = rej
251
+ })
252
+ promise.catch(() => {}) // no unhandled rejection (<Await> drives the actual handling)
253
+ pending.set(id, { resolve, reject })
254
+ const marker = { __nifra_deferred: true, id, promise } satisfies Deferred<unknown>
255
+ byId.set(id, marker)
256
+ return marker
257
+ }
258
+ if (Array.isArray(value)) return value.map(walk)
259
+ if (value !== null && typeof value === "object") {
260
+ const out: Record<string, unknown> = {}
261
+ for (const [key, v] of Object.entries(value)) out[key] = walk(v)
262
+ return out
263
+ }
264
+ return value
265
+ }
266
+ return walk(data)
267
+ }
268
+
269
+ /**
270
+ * Client side of {@link ndjsonStream} (used by the router's `defaultFetchData` on a soft nav): read
271
+ * the NDJSON body into a data object whose deferred markers settle as resolution lines arrive.
272
+ * Returns after **line 1** so the router can apply the critical data + render immediately; the
273
+ * markers' promises settle/reject in the background. If the stream ends (or `signal` aborts) with
274
+ * markers unsettled, they reject — so `<Await>` never hangs.
275
+ */
276
+ export async function parseNdjsonData(
277
+ stream: ReadableStream<Uint8Array>,
278
+ signal?: AbortSignal,
279
+ ): Promise<unknown> {
280
+ const reader = stream.getReader()
281
+ const dec = new TextDecoder()
282
+ let buffer = ""
283
+ // `{ once: true }` + explicit removal in the drain's `finally` so a reused long-lived signal doesn't
284
+ // accumulate listeners across navigations (a per-nav signal would be GC'd, but don't rely on it).
285
+ const onAbort = (): void => void reader.cancel()
286
+ if (signal) signal.addEventListener("abort", onAbort, { once: true })
287
+
288
+ const readLine = async (): Promise<string | null> => {
289
+ for (;;) {
290
+ const nl = buffer.indexOf("\n")
291
+ if (nl !== -1) {
292
+ const line = buffer.slice(0, nl)
293
+ buffer = buffer.slice(nl + 1)
294
+ return line
295
+ }
296
+ const { done, value } = await reader.read()
297
+ if (done) {
298
+ const rest = buffer
299
+ buffer = ""
300
+ return rest === "" ? null : rest
301
+ }
302
+ buffer += dec.decode(value, { stream: true })
303
+ }
304
+ }
305
+
306
+ const pending: Pending = new Map()
307
+ const first = await readLine()
308
+ const data = markersFromPlaceholders(first === null ? null : JSON.parse(first), pending)
309
+
310
+ void (async () => {
311
+ try {
312
+ for (;;) {
313
+ const line = await readLine()
314
+ if (line === null) break
315
+ if (line === "") continue
316
+ const msg = JSON.parse(line) as { i: number; v?: unknown; e?: string }
317
+ const entry = pending.get(msg.i)
318
+ if (entry === undefined) continue
319
+ pending.delete(msg.i)
320
+ if ("e" in msg) entry.reject(new Error(msg.e))
321
+ else entry.resolve(msg.v)
322
+ }
323
+ } catch {
324
+ // stream errored — leftovers are rejected below (unless this was an abort)
325
+ } finally {
326
+ signal?.removeEventListener("abort", onAbort) // no-op if it already fired ({ once: true })
327
+ // A natural stream end with markers unsettled = truncation → reject (so `<Await>` shows its
328
+ // error). An `abort` = a superseded navigation → leave them pending: the markers are detached
329
+ // (their data was never applied), so rejecting would only risk an error flash mid-transition.
330
+ if (!signal?.aborted) {
331
+ for (const [, entry] of pending) {
332
+ entry.reject(new Error("[nifra/web] deferred stream ended before this value resolved"))
333
+ }
334
+ }
335
+ }
336
+ })()
337
+
338
+ return data
339
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Dev-only error overlay. When a loader/action/render throws during `@nifrajs/web/vite` dev, this
3
+ * renders a readable full-page overlay — the message, the source-mapped stack (Vite's
4
+ * `ssrFixStacktrace` runs first), and the request line — instead of the bare `err.stack` text dump.
5
+ * Dev-only by construction: it's called solely from the Vite middleware's catch, never in
6
+ * production (production maps errors to the `_error` route boundary).
7
+ */
8
+
9
+ const esc = (s: string): string =>
10
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
11
+
12
+ /** Split a stack into the message (first line) + the frame lines, for separate styling. */
13
+ function parseStack(err: Error): { message: string; frames: string[] } {
14
+ const stack = err.stack ?? `${err.name}: ${err.message}`
15
+ const lines = stack.split("\n")
16
+ // The message can span multiple leading lines (some errors do); frames start at the first " at ".
17
+ const firstFrame = lines.findIndex((l) => /^\s*at\s/.test(l))
18
+ if (firstFrame === -1) return { message: stack, frames: [] }
19
+ return {
20
+ message: lines.slice(0, firstFrame).join("\n").trim(),
21
+ frames: lines.slice(firstFrame).map((l) => l.trim()),
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Render the dev error overlay HTML. `req` (method + url) is shown for context. The page is
27
+ * self-contained (inline CSS), dark, and labels itself a dev overlay so it's never mistaken for a
28
+ * production error page.
29
+ */
30
+ export function renderDevErrorOverlay(err: unknown, req: { method: string; url: string }): string {
31
+ const error = err instanceof Error ? err : new Error(String(err))
32
+ const { message, frames } = parseStack(error)
33
+ const name = esc(error.name || "Error")
34
+ const framesHtml =
35
+ frames.length > 0
36
+ ? `<ol class="frames">${frames.map((f) => `<li>${esc(f)}</li>`).join("")}</ol>`
37
+ : `<p class="no-frames">No stack frames.</p>`
38
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${name} — nifra dev</title>
39
+ <style>
40
+ :root { color-scheme: dark }
41
+ body { margin: 0; background: #0b0d10; color: #e6e6e6; font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
42
+ .bar { background: #2a0e0e; border-bottom: 1px solid #5a1d1d; padding: 10px 20px; font-weight: 700; color: #ff8a8a; display: flex; gap: 12px; align-items: baseline; }
43
+ .bar .tag { font-size: 11px; font-weight: 600; color: #ffb4b4; background: #401616; padding: 2px 8px; border-radius: 99px; letter-spacing: .04em; text-transform: uppercase; }
44
+ .bar .req { margin-left: auto; color: #c98; font-weight: 500; font-size: 12px; }
45
+ main { padding: 24px 28px; max-width: 1000px; }
46
+ h1 { font-size: 13px; color: #9aa; font-weight: 600; margin: 0 0 6px; text-transform: uppercase; letter-spacing: .05em; }
47
+ .message { font-size: 18px; color: #ff9b9b; white-space: pre-wrap; margin: 0 0 28px; font-weight: 600; }
48
+ .frames { list-style: none; margin: 0; padding: 0; border-left: 2px solid #333; }
49
+ .frames li { padding: 3px 0 3px 16px; color: #9fb0c0; white-space: pre-wrap; word-break: break-all; }
50
+ .frames li:first-child { color: #d6e2ee; }
51
+ .no-frames { color: #778; }
52
+ footer { margin-top: 28px; color: #667; font-size: 12px; }
53
+ </style></head><body>
54
+ <div class="bar"><span class="tag">nifra dev</span><span>${name}</span><span class="req">${esc(req.method)} ${esc(req.url)}</span></div>
55
+ <main>
56
+ <h1>Unhandled error during SSR</h1>
57
+ <p class="message">${esc(message)}</p>
58
+ <h1>Stack</h1>
59
+ ${framesHtml}
60
+ <footer>This overlay is shown only by the dev server. In production this error maps to your <code>_error</code> route boundary.</footer>
61
+ </main></body></html>`
62
+ }