@nifrajs/web 2.3.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/src/dev-error.ts CHANGED
@@ -1,39 +1,55 @@
1
1
  /**
2
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).
3
+ * renders a readable full-page overlay from a structured [Diagnostic] - the code, the message, a source
4
+ * codeframe around the offending line, the recognised cause/fix when nifra has one, and the stack -
5
+ * instead of a bare `err.stack` text dump. Dev-only by construction: it's called solely from the dev
6
+ * server's catch, never in production (production maps errors to the `_error` route boundary).
7
+ *
8
+ * The overlay and the agent surfaces (`/__nifra/last-error`, `nifra_explain`) render from the SAME
9
+ * `Diagnostic`, so a person and an agent see the identical failure, one as HTML and one as JSON.
7
10
  */
11
+ import type { Diagnostic } from "./diagnostic.ts"
8
12
 
9
13
  const esc = (s: string): string =>
10
14
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
11
15
 
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
- }
16
+ /** The source codeframe block: numbered lines with the offending one highlighted. */
17
+ function codeframeHtml(diagnostic: Diagnostic): string {
18
+ const cf = diagnostic.codeframe
19
+ if (cf === undefined) return ""
20
+ const loc = `${esc(cf.file)}:${cf.line}${cf.column !== undefined ? `:${cf.column}` : ""}`
21
+ const rows = cf.lines
22
+ .map(
23
+ (l) =>
24
+ `<div class="cf-row${l.caret ? " caret" : ""}"><span class="cf-ln">${l.number}</span><span class="cf-src">${esc(l.text) || " "}</span></div>`,
25
+ )
26
+ .join("")
27
+ return `<h1>Source</h1><div class="cf-loc">${loc}</div><div class="codeframe">${rows}</div>`
23
28
  }
24
29
 
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")
30
+ /** The recognised-failure callout: cause + fix + docs anchor, shown only when nifra classified the error. */
31
+ function fixHtml(diagnostic: Diagnostic): string {
32
+ if (diagnostic.fix === undefined) return ""
33
+ const anchor =
34
+ diagnostic.docsAnchor !== undefined
35
+ ? `<div class="fix-docs">docs: <code>${esc(diagnostic.docsAnchor)}</code></div>`
36
+ : ""
37
+ const cause =
38
+ diagnostic.cause !== undefined ? `<p class="fix-cause">${esc(diagnostic.cause)}</p>` : ""
39
+ return `<div class="fix"><div class="fix-tag">likely fix</div>${cause}<p class="fix-do">${esc(diagnostic.fix)}</p>${anchor}</div>`
40
+ }
41
+
42
+ /** Render the overlay HTML from a prebuilt Diagnostic (the same object the agent surfaces serve). */
43
+ export function renderDiagnosticOverlay(diagnostic: Diagnostic): string {
44
+ const name = esc(diagnostic.name || "Error")
45
+ const code = esc(diagnostic.code)
46
+ const reqLine =
47
+ diagnostic.request !== undefined
48
+ ? `<span class="req">${esc(diagnostic.request.method)} ${esc(diagnostic.request.url)}</span>`
49
+ : ""
34
50
  const framesHtml =
35
- frames.length > 0
36
- ? `<ol class="frames">${frames.map((f) => `<li>${esc(f)}</li>`).join("")}</ol>`
51
+ diagnostic.frames.length > 0
52
+ ? `<ol class="frames">${diagnostic.frames.map((f) => `<li>${esc(f.raw)}</li>`).join("")}</ol>`
37
53
  : `<p class="no-frames">No stack frames.</p>`
38
54
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${name} - nifra dev</title>
39
55
  <style>
@@ -41,22 +57,37 @@ export function renderDevErrorOverlay(err: unknown, req: { method: string; url:
41
57
  body { margin: 0; background: #0b0d10; color: #e6e6e6; font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
42
58
  .bar { background: #2a0e0e; border-bottom: 1px solid #5a1d1d; padding: 10px 20px; font-weight: 700; color: #ff8a8a; display: flex; gap: 12px; align-items: baseline; }
43
59
  .bar .tag { font-size: 11px; font-weight: 600; color: #ffb4b4; background: #401616; padding: 2px 8px; border-radius: 99px; letter-spacing: .04em; text-transform: uppercase; }
60
+ .bar .code { font-size: 11px; font-weight: 600; color: #ffd9a0; background: #3a2a12; padding: 2px 8px; border-radius: 99px; letter-spacing: .04em; }
44
61
  .bar .req { margin-left: auto; color: #c98; font-weight: 500; font-size: 12px; }
45
62
  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; }
63
+ h1 { font-size: 13px; color: #9aa; font-weight: 600; margin: 24px 0 6px; text-transform: uppercase; letter-spacing: .05em; }
64
+ h1:first-of-type { margin-top: 0; }
65
+ .message { font-size: 18px; color: #ff9b9b; white-space: pre-wrap; margin: 0 0 4px; font-weight: 600; }
66
+ .fix { background: #10241a; border: 1px solid #1f4d38; border-radius: 8px; padding: 12px 16px; margin: 20px 0; }
67
+ .fix-tag { font-size: 11px; font-weight: 700; color: #7fe0af; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; }
68
+ .fix-cause { margin: 0 0 8px; color: #b9c9c1; }
69
+ .fix-do { margin: 0; color: #d6f2e4; }
70
+ .fix-docs { margin-top: 8px; font-size: 12px; color: #6a8; }
71
+ .cf-loc { color: #c98; font-size: 12px; margin-bottom: 8px; }
72
+ .codeframe { background: #0f1319; border: 1px solid #222; border-radius: 8px; overflow: auto; }
73
+ .cf-row { display: flex; white-space: pre; }
74
+ .cf-row.caret { background: #2a1414; }
75
+ .cf-ln { color: #556; text-align: right; width: 48px; padding: 1px 12px 1px 0; user-select: none; flex: none; border-right: 1px solid #222; }
76
+ .cf-row.caret .cf-ln { color: #ff8a8a; }
77
+ .cf-src { padding: 1px 0 1px 12px; color: #cdd6e0; }
48
78
  .frames { list-style: none; margin: 0; padding: 0; border-left: 2px solid #333; }
49
79
  .frames li { padding: 3px 0 3px 16px; color: #9fb0c0; white-space: pre-wrap; word-break: break-all; }
50
80
  .frames li:first-child { color: #d6e2ee; }
51
81
  .no-frames { color: #778; }
52
82
  footer { margin-top: 28px; color: #667; font-size: 12px; }
53
83
  </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>
84
+ <div class="bar"><span class="tag">nifra dev</span><span class="code">${code}</span><span>${name}</span>${reqLine}</div>
55
85
  <main>
56
- <h1>Unhandled error during SSR</h1>
57
- <p class="message">${esc(message)}</p>
86
+ <p class="message">${esc(diagnostic.message)}</p>
87
+ ${fixHtml(diagnostic)}
88
+ ${codeframeHtml(diagnostic)}
58
89
  <h1>Stack</h1>
59
90
  ${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>
91
+ <footer>This overlay is shown only by the dev server. Its structured form is at <code>/__nifra/last-error</code> and via <code>nifra_explain</code>. In production this error maps to your <code>_error</code> route boundary.</footer>
61
92
  </main></body></html>`
62
93
  }
package/src/dev-port.ts CHANGED
@@ -49,15 +49,27 @@ export interface ListenTarget {
49
49
  * Without an `error` listener attached, Node throws from deep inside `node:events`, the new process dies
50
50
  * in the background, and nothing connects that death to the stale page in front of you.
51
51
  */
52
- export function listenOrExplain(server: ListenTarget, port: number): Promise<void> {
52
+ export function listenOrExplain(
53
+ server: ListenTarget,
54
+ port: number,
55
+ hostname?: string,
56
+ ): Promise<void> {
53
57
  return new Promise<void>((resolve, reject) => {
54
58
  const onError = (err: unknown): void => reject(explainBindFailure(err, port))
55
59
  server.once("error", onError)
56
- server.listen(port, () => {
60
+ const onListening = (): void => {
57
61
  // Drop the guard once we're listening: leaving it attached would funnel a LATER server error into
58
62
  // an already-settled promise, silently swallowing it instead of surfacing it.
59
63
  server.removeListener("error", onError)
60
64
  resolve()
61
- })
65
+ }
66
+ if (hostname === undefined) server.listen(port, onListening)
67
+ else {
68
+ ;(
69
+ server as ListenTarget & {
70
+ listen(port: number, hostname: string, cb: () => void): void
71
+ }
72
+ ).listen(port, hostname, onListening)
73
+ }
62
74
  })
63
75
  }
package/src/dev.ts CHANGED
@@ -54,12 +54,14 @@ import {
54
54
  import { dirname, relative, resolve } from "node:path"
55
55
  import { type BuildClientOptions, buildClient } from "./build.ts"
56
56
  import { type DevEntryMatch, resolveDevEntry } from "./bun-dev-entry.ts"
57
- import { renderDevErrorOverlay } from "./dev-error.ts"
57
+ import { createDevDiagnostics } from "./dev-diagnostics.ts"
58
58
  import { explainBindFailure } from "./dev-port.ts"
59
59
  import { discoverRoutes } from "./fs.ts"
60
60
  import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
61
61
  import { servePublicDir } from "./public-dir.ts"
62
62
 
63
+ export { LAST_ERROR_PATH } from "./diagnostic.ts"
64
+
63
65
  /** Minimal app surface the dev server needs - `createWebApp(...)` satisfies it. */
64
66
  interface FetchApp {
65
67
  fetch(request: Request): Response | Promise<Response>
@@ -128,6 +130,8 @@ type HtmlBundle = unknown
128
130
  /** The `Bun.serve` surface this uses, typed structurally so the file builds without Bun's ambient types. */
129
131
  interface BunServeOptions {
130
132
  readonly port: number
133
+ /** Diagnostics contain source paths; keep the dev server private by default. */
134
+ readonly hostname: "127.0.0.1"
131
135
  readonly development: { readonly hmr: boolean }
132
136
  readonly routes: Record<string, HtmlBundle>
133
137
  fetch(request: Request): Promise<Response>
@@ -243,6 +247,10 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
243
247
  // The app root. `routesDir` is `<app>/routes` by convention, so its parent is the project - the one
244
248
  // place a generated entry can sit and still resolve the app's imports.
245
249
  const root = resolve(routesDir, "..")
250
+ // The most recent SSR failure as a structured Diagnostic, scoped to THIS server. Served at
251
+ // LAST_ERROR_PATH so an agent driving the dev server reads the exact failure (code, codeframe, fix) as
252
+ // JSON instead of scraping the overlay. Shared with the Vite adapter so the endpoint can't drift.
253
+ const devDiagnostics = createDevDiagnostics(root)
246
254
  const devDir = resolve(root, DEV_DIR)
247
255
  const entryPath = resolve(devDir, "entry.tsx")
248
256
  const htmlPath = resolve(devDir, "entry.html")
@@ -337,6 +345,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
337
345
  try {
338
346
  server = serve({
339
347
  port,
348
+ hostname: "127.0.0.1",
340
349
  development: { hmr: true },
341
350
  // The probe route is the ONLY path Bun owns; everything else falls through to nifra's SSR.
342
351
  routes: { [PROBE_PATH]: htmlModule.default },
@@ -352,6 +361,11 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
352
361
  const entry = await currentEntry()
353
362
  return new Response(null, { status: 307, headers: { location: entry.src } })
354
363
  }
364
+ if (devDiagnostics.isLastErrorPath(url.pathname)) {
365
+ // The structured form of the overlay, for an agent driving the dev server (shared surface).
366
+ const { body, headers } = devDiagnostics.lastError()
367
+ return new Response(body, { headers })
368
+ }
355
369
  // Static probe before routing; a miss returns undefined and falls through, so no route is
356
370
  // shadowed and a page render never pays a filesystem stat (extension-less paths skip it).
357
371
  const staticFile = await servePublic(req)
@@ -370,13 +384,15 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
370
384
  headers,
371
385
  })
372
386
  } catch (err) {
373
- return new Response(
374
- renderDevErrorOverlay(err, {
375
- method: req.method,
376
- url: `${url.pathname}${url.search}`,
377
- }),
378
- { status: 500, headers: { "content-type": "text/html; charset=utf-8" } },
379
- )
387
+ // One Diagnostic drives both surfaces: the overlay returned here and the JSON at LAST_ERROR_PATH.
388
+ const html = devDiagnostics.capture(err, {
389
+ method: req.method,
390
+ url: `${url.pathname}${url.search}`,
391
+ })
392
+ return new Response(html, {
393
+ status: 500,
394
+ headers: { "content-type": "text/html; charset=utf-8" },
395
+ })
380
396
  }
381
397
  },
382
398
  })
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The structured diagnostic that every nifra failure resolves to - the single source both the human
3
+ * dev overlay ([dev-error.ts]) and the agent-facing surfaces (`/__nifra/last-error`, `nifra_explain`)
4
+ * render from. A stack trace is a wall of text; a `Diagnostic` is queryable: a stable `code`, the top
5
+ * USER frame (file/line/column), a source codeframe around it, and - when the failure is one nifra
6
+ * recognises - a plain-language `cause` + `fix` + docs anchor.
7
+ *
8
+ * This is the "AI-native" half of error reporting: the same object a person reads in the overlay is the
9
+ * JSON an agent reads to apply the fix, so neither has to scrape the other's format.
10
+ *
11
+ * Pure + DOM-free by construction (it only parses strings and reads source text through an injected
12
+ * reader), so it lives in the root typecheck program and is unit-testable without a dev server or disk.
13
+ */
14
+ import { readFileSync, realpathSync } from "node:fs"
15
+ import { isAbsolute, relative, resolve } from "node:path"
16
+
17
+ /** Shared endpoint name used by both dev pipelines and the agent-facing MCP tools. */
18
+ export const LAST_ERROR_PATH = "/__nifra/last-error"
19
+
20
+ /** One parsed stack frame. `file`/`line`/`column` are present only when the frame could be located. */
21
+ export interface DiagnosticFrame {
22
+ readonly raw: string
23
+ readonly file?: string
24
+ readonly line?: number
25
+ readonly column?: number
26
+ }
27
+
28
+ /** A source window around the offending line. `caret` marks the exact line the top frame points at. */
29
+ export interface Codeframe {
30
+ readonly file: string
31
+ readonly line: number
32
+ readonly column?: number | undefined
33
+ readonly lines: ReadonlyArray<{
34
+ readonly number: number
35
+ readonly text: string
36
+ readonly caret: boolean
37
+ }>
38
+ }
39
+
40
+ /** The structured failure. Serialisable as-is to JSON for the agent surfaces. */
41
+ export interface Diagnostic {
42
+ /** Stable, greppable identifier, e.g. `NIFRA_SERVER_ONLY_IN_CLIENT`. `NIFRA_UNHANDLED` when unrecognised. */
43
+ readonly code: string
44
+ readonly name: string
45
+ readonly message: string
46
+ readonly request?: { readonly method: string; readonly url: string } | undefined
47
+ readonly frames: readonly DiagnosticFrame[]
48
+ readonly codeframe?: Codeframe | undefined
49
+ /** Plain-language "why this happened", when the failure is recognised. */
50
+ readonly cause?: string | undefined
51
+ /** Plain-language "do this", when the failure is recognised. */
52
+ readonly fix?: string | undefined
53
+ /** Docs section anchor for the code, e.g. `errors#server-only-in-client`. */
54
+ readonly docsAnchor?: string | undefined
55
+ }
56
+
57
+ /** Reads a source file's text, or returns undefined if it cannot (missing, binary, permission). */
58
+ export type SourceReader = (file: string) => string | undefined
59
+
60
+ const defaultReader: SourceReader = (file) => {
61
+ try {
62
+ return readFileSync(file, "utf8")
63
+ } catch {
64
+ return undefined
65
+ }
66
+ }
67
+
68
+ // Turns a raw frame path into an absolute filesystem path, or undefined if it isn't one. Handles the
69
+ // `file://` URL form Vite/Node emit and strips the `?v=hash` / `#` suffixes a bundler appends.
70
+ function cleanFramePath(path: string): string | undefined {
71
+ let p = path.trim()
72
+ if (p.startsWith("file://")) {
73
+ try {
74
+ p = decodeURIComponent(new URL(p).pathname)
75
+ } catch {
76
+ return undefined
77
+ }
78
+ }
79
+ // Strip the `?v=hash` / `#` suffix without a `.*` regex (ReDoS-safe): cut at the first `?` or `#`.
80
+ const cut = p.search(/[?#]/)
81
+ if (cut !== -1) p = p.slice(0, cut)
82
+ const isAbsolute = p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)
83
+ return isAbsolute ? p : undefined
84
+ }
85
+
86
+ /**
87
+ * Parse a V8/Node stack into structured frames. Handles the `at fn (path:line:col)`, bare
88
+ * `at path:line:col`, and `at async fn (...)` shapes; a frame that doesn't match keeps its raw text
89
+ * with no location (so nothing is silently dropped).
90
+ */
91
+ export function parseFrames(stack: string): DiagnosticFrame[] {
92
+ const frames: DiagnosticFrame[] = []
93
+ for (const line of stack.split("\n")) {
94
+ const raw = line.trim()
95
+ if (!/^at\s/.test(raw)) continue
96
+ // Prefer the parenthesised location; fall back to a trailing `path:line:col`.
97
+ const paren = raw.match(/\(([^()]+):(\d+):(\d+)\)\s*$/)
98
+ const bare = raw.match(/^at\s+(?:async\s+)?(\S.*):(\d+):(\d+)\s*$/)
99
+ const m = paren ?? bare
100
+ if (!m) {
101
+ frames.push({ raw })
102
+ continue
103
+ }
104
+ const file = cleanFramePath(m[1] as string)
105
+ frames.push(
106
+ file === undefined ? { raw } : { raw, file, line: Number(m[2]), column: Number(m[3]) },
107
+ )
108
+ }
109
+ return frames
110
+ }
111
+
112
+ /** Frames inside dependencies or the runtime are noise for a first look; the user's own code is the signal. */
113
+ function isUserFrame(file: string, root: string | undefined): boolean {
114
+ if (file.includes("/node_modules/") || file.includes("\\node_modules\\")) return false
115
+ if (file.startsWith("node:")) return false
116
+ if (root !== undefined) {
117
+ // A string prefix is not a path boundary (`/app-evil` starts with `/app`) and leaves `..` and
118
+ // symlink escapes unresolved. Resolve lexically first, then realpath existing paths so a codeframe
119
+ // can never read a source file outside the project root merely because a stack frame was crafted.
120
+ const rootPath = canonicalPath(root)
121
+ const framePath = canonicalPath(file)
122
+ const rel = relative(rootPath, framePath)
123
+ if (rel === ".." || rel.startsWith(`..${sepForPath(rel)}`) || isAbsolute(rel)) return false
124
+ }
125
+ return true
126
+ }
127
+
128
+ function sepForPath(path: string): string {
129
+ return path.includes("\\") ? "\\" : "/"
130
+ }
131
+
132
+ function canonicalPath(path: string): string {
133
+ const lexical = resolve(path)
134
+ try {
135
+ return realpathSync.native(lexical)
136
+ } catch {
137
+ return lexical
138
+ }
139
+ }
140
+
141
+ /** The first frame that points at the user's own source - what the codeframe should show. */
142
+ export function topUserFrame(
143
+ frames: readonly DiagnosticFrame[],
144
+ root: string | undefined,
145
+ ): DiagnosticFrame | undefined {
146
+ return frames.find((f) => f.file !== undefined && isUserFrame(f.file, root))
147
+ }
148
+
149
+ /**
150
+ * Build a source codeframe: `radius` lines either side of `line`, each tagged with its 1-based number
151
+ * and whether it is the offending line. Returns undefined if the source can't be read or the line is
152
+ * out of range - a diagnostic without a codeframe is still useful, so this never throws.
153
+ */
154
+ export function buildCodeframe(
155
+ file: string,
156
+ line: number,
157
+ column: number | undefined,
158
+ read: SourceReader = defaultReader,
159
+ radius = 3,
160
+ ): Codeframe | undefined {
161
+ const source = read(file)
162
+ if (source === undefined) return undefined
163
+ const all = source.split("\n")
164
+ if (line < 1 || line > all.length) return undefined
165
+ const start = Math.max(1, line - radius)
166
+ const end = Math.min(all.length, line + radius)
167
+ const lines: Array<{ number: number; text: string; caret: boolean }> = []
168
+ for (let n = start; n <= end; n++) {
169
+ lines.push({ number: n, text: all[n - 1] ?? "", caret: n === line })
170
+ }
171
+ return { file, line, column, lines }
172
+ }
173
+
174
+ /** A recognised failure shape: a stable code plus the plain-language cause/fix/anchor to attach. */
175
+ interface CatalogEntry {
176
+ readonly code: string
177
+ readonly match: (name: string, message: string) => boolean
178
+ readonly cause: string
179
+ readonly fix: string
180
+ readonly docsAnchor: string
181
+ }
182
+
183
+ /**
184
+ * The recognised-failure catalog. Seeded with the highest-signal nifra failures; extend it as new
185
+ * classes of error earn a stable code. Order matters only in that the first match wins.
186
+ */
187
+ export const DIAGNOSTIC_CATALOG: readonly CatalogEntry[] = [
188
+ {
189
+ code: "NIFRA_SERVER_ONLY_IN_CLIENT",
190
+ match: (_n, m) => m.includes("server-only module(s) in the client bundle"),
191
+ cause:
192
+ "A module marked server-only was reachable from a client entry, so it would ship to the browser.",
193
+ fix: "Follow the import chain in the message and move the server-only use behind a loader/action or a `*.server.ts` boundary, so it never enters a client component.",
194
+ docsAnchor: "errors#server-only-in-client",
195
+ },
196
+ {
197
+ code: "NIFRA_NODE_BUILTIN_IN_CLIENT",
198
+ match: (_n, m) => m.includes("Node built-in(s) in the client bundle"),
199
+ cause: "A `node:` built-in was reached from a client entry; it has no browser implementation.",
200
+ fix: "Move the code using the built-in to the server (loader/action or `*.server.ts`); the message lists the import chain that pulled it in.",
201
+ docsAnchor: "errors#node-builtin-in-client",
202
+ },
203
+ {
204
+ code: "NIFRA_SCHEMA_PARSE",
205
+ match: (n, m) =>
206
+ n === "SchemaError" ||
207
+ /failed to (parse|validate)|invalid_type|expected .* received/i.test(m),
208
+ cause: "Data crossing a boundary did not match its declared schema.",
209
+ fix: "Check the value against the schema at the failing boundary (loader input, search params, or request body); parse-don't-cast means the shape must match exactly.",
210
+ docsAnchor: "errors#schema-parse",
211
+ },
212
+ ]
213
+
214
+ /** Classify an error name+message against the catalog; falls back to the generic unhandled code. */
215
+ export function classify(
216
+ name: string,
217
+ message: string,
218
+ ): {
219
+ code: string
220
+ cause?: string
221
+ fix?: string
222
+ docsAnchor?: string
223
+ } {
224
+ const hit = DIAGNOSTIC_CATALOG.find((e) => e.match(name, message))
225
+ if (hit === undefined) return { code: "NIFRA_UNHANDLED" }
226
+ return { code: hit.code, cause: hit.cause, fix: hit.fix, docsAnchor: hit.docsAnchor }
227
+ }
228
+
229
+ /** Split an Error's `stack` into its leading message block and its frame lines. */
230
+ function messageAndStack(err: Error): { message: string; stack: string } {
231
+ const stack = err.stack ?? `${err.name}: ${err.message}`
232
+ const lines = stack.split("\n")
233
+ const firstFrame = lines.findIndex((l) => /^\s*at\s/.test(l))
234
+ if (firstFrame === -1) return { message: err.message || stack.trim(), stack: "" }
235
+ return {
236
+ message: lines.slice(0, firstFrame).join("\n").trim(),
237
+ stack: lines.slice(firstFrame).join("\n"),
238
+ }
239
+ }
240
+
241
+ export interface BuildDiagnosticOptions {
242
+ readonly request?: { readonly method: string; readonly url: string }
243
+ /** Project root; frames outside it are treated as non-user. Defaults to `process.cwd()`. */
244
+ readonly root?: string
245
+ /** Injectable source reader (tests pass a fake; production reads the filesystem). */
246
+ readonly read?: SourceReader
247
+ }
248
+
249
+ /**
250
+ * Resolve any thrown value into a `Diagnostic`: parse the (already source-mapped) stack, locate the top
251
+ * user frame, attach a codeframe, and classify the failure for a cause/fix. The caller is responsible
252
+ * for running Vite's `ssrFixStacktrace` first so the frames point at real source.
253
+ */
254
+ export function buildDiagnostic(err: unknown, options: BuildDiagnosticOptions = {}): Diagnostic {
255
+ const error = err instanceof Error ? err : new Error(String(err))
256
+ const root = options.root ?? safeCwd()
257
+ const { message } = messageAndStack(error)
258
+ const frames = parseFrames(error.stack ?? "")
259
+ const top = topUserFrame(frames, root)
260
+ const codeframe =
261
+ top?.file !== undefined && top.line !== undefined
262
+ ? buildCodeframe(canonicalPath(top.file), top.line, top.column, options.read)
263
+ : undefined
264
+ const { code, cause, fix, docsAnchor } = classify(error.name || "Error", message)
265
+ return {
266
+ code,
267
+ name: error.name || "Error",
268
+ message,
269
+ request: options.request,
270
+ frames,
271
+ codeframe,
272
+ cause,
273
+ fix,
274
+ docsAnchor,
275
+ }
276
+ }
277
+
278
+ function safeCwd(): string | undefined {
279
+ try {
280
+ return process.cwd()
281
+ } catch {
282
+ return undefined
283
+ }
284
+ }
package/src/router.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  * unit-tests without a browser and is safe to import from the SSR core's main entry.
6
6
  */
7
7
  import {
8
- compareRoutePatternSpecificity,
9
8
  compileRoutePattern,
10
9
  matchRoutePattern,
10
+ sortRoutesBySpecificity,
11
11
  } from "@nifrajs/core/pattern"
12
12
  import type { StandardSchemaV1 } from "@nifrajs/core/server"
13
13
  import { parseNdjsonData } from "./deferred.ts"
@@ -156,9 +156,12 @@ export interface RoutePattern {
156
156
  export function createMatcher(
157
157
  patterns: readonly RoutePattern[],
158
158
  ): (path: string) => RouteMatch | null {
159
- const compiled = patterns
160
- .map((pattern) => ({ routeId: pattern.routeId, pattern: compileRoutePattern(pattern.pattern) }))
161
- .sort((left, right) => compareRoutePatternSpecificity(left.pattern, right.pattern))
159
+ const compiled = sortRoutesBySpecificity(
160
+ patterns.map((pattern) => ({
161
+ routeId: pattern.routeId,
162
+ pattern: compileRoutePattern(pattern.pattern),
163
+ })),
164
+ )
162
165
  return (path) => {
163
166
  // Strip the query without allocating a `split("?")` array - matcher runs per match.
164
167
  const q = path.indexOf("?")
package/src/vite.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  type ServerResponse,
18
18
  } from "node:http"
19
19
  import { relative, resolve as resolvePath } from "node:path"
20
- import { renderDevErrorOverlay } from "./dev-error.ts"
20
+ import { createDevDiagnostics } from "./dev-diagnostics.ts"
21
21
  import { listenOrExplain } from "./dev-port.ts"
22
22
  import { discoverRoutes } from "./fs.ts"
23
23
  import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
@@ -103,6 +103,8 @@ export interface ViteDevServer {
103
103
  stop(): Promise<void>
104
104
  }
105
105
 
106
+ export { LAST_ERROR_PATH } from "./diagnostic.ts"
107
+
106
108
  // The codegen'd client entry is written here (at the Vite root) so Vite serves + HMRs it.
107
109
  const DEV_ENTRY = ".nifra-vite-entry.tsx"
108
110
 
@@ -297,8 +299,19 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
297
299
  // `vite`, which is assigned just below before the server starts listening.
298
300
  let vite!: ViteLike
299
301
  let app: FetchApp
302
+ // The most recent SSR failure, served as JSON at LAST_ERROR_PATH. Shared with the Bun adapter so the
303
+ // endpoint and its headers can't drift between the two dev servers.
304
+ const devDiagnostics = createDevDiagnostics(root)
300
305
 
301
306
  const server: NodeHttpServer = createHttpServer((req, res) => {
307
+ // Keep this before Vite's middleware: it is an agent endpoint owned by nifra, not a file or app route.
308
+ if (devDiagnostics.isLastErrorPath((req.url ?? "/").split("?", 1)[0] ?? "/")) {
309
+ const { body, headers } = devDiagnostics.lastError()
310
+ res.statusCode = 200
311
+ for (const [key, value] of Object.entries(headers)) res.setHeader(key, value)
312
+ res.end(body)
313
+ return
314
+ }
302
315
  vite.middlewares(req, res, () => {
303
316
  // Not a Vite asset → nifra SSR. (`next` runs after Vite declines, so the body is still readable.)
304
317
  void (async () => {
@@ -322,9 +335,13 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
322
335
  // Source-map the stack first (Vite maps the bundled frames back to your `.ts`), then render
323
336
  // the readable dev overlay instead of a bare text dump. Dev-only - production maps to `_error`.
324
337
  if (err instanceof Error) vite.ssrFixStacktrace(err)
338
+ const html = devDiagnostics.capture(err, {
339
+ method: req.method ?? "GET",
340
+ url: req.url ?? "/",
341
+ })
325
342
  res.statusCode = 500
326
343
  res.setHeader("content-type", "text/html; charset=utf-8")
327
- res.end(renderDevErrorOverlay(err, { method: req.method ?? "GET", url: req.url ?? "/" }))
344
+ res.end(html)
328
345
  }
329
346
  })()
330
347
  })
@@ -395,7 +412,7 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
395
412
  }
396
413
 
397
414
  try {
398
- await listenOrExplain(server, port)
415
+ await listenOrExplain(server, port, "127.0.0.1")
399
416
  } catch (err) {
400
417
  // Nothing is listening, but Vite is fully up by now - watchers, the dep optimizer, its own sockets -
401
418
  // and every one of those keeps the event loop alive. Left open, the process prints the diagnosis and