@nifrajs/web 2.0.0 → 2.2.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/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +487 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +112 -25
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +283 -64
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -0
- package/dist/client.js.map +1 -1
- package/dist/deferred.d.ts +3 -2
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +11 -8
- package/dist/deferred.js.map +1 -1
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +61 -4
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +37 -0
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +87 -4
- package/dist/draft.js.map +1 -1
- package/dist/fs.d.ts +14 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +7 -1
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +116 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +565 -53
- package/dist/index.js.map +1 -1
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +1 -14
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +51 -8
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +130 -14
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -0
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +64 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +114 -31
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts +10 -4
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +44 -12
- package/dist/vite.js.map +1 -1
- package/package.json +17 -2
- package/src/build-vite.ts +606 -0
- package/src/build.ts +432 -73
- package/src/bun-dev-entry.ts +184 -0
- package/src/client.ts +8 -0
- package/src/deferred.ts +13 -6
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +467 -65
- package/src/draft.ts +116 -4
- package/src/fs.ts +22 -4
- package/src/index.ts +727 -57
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/isr.ts +1 -13
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +188 -13
- package/src/module-graph.ts +146 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/public-dir.ts +108 -0
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +198 -34
- package/src/vite.ts +62 -20
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/bun-dev-entry` — discovering the client-entry URL Bun's dev server assigns.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this file exists at all
|
|
5
|
+
*
|
|
6
|
+
* Bun's dev server bundles an HTML route and rewrites its `<script src="./entry.tsx">` into a
|
|
7
|
+
* content-hashed URL under `/_bun/client/`. That rewrite is the whole point: the hash changes per build,
|
|
8
|
+
* so the URL cannot be predicted or hard-coded.
|
|
9
|
+
*
|
|
10
|
+
* nifra needs that URL, because nifra renders the document. Bun never sees the page it would inject the
|
|
11
|
+
* script into - `createWebApp` builds the HTML from the route's own output and needs `clientEntry` to
|
|
12
|
+
* emit `<script type="module" src=…>` and the matching `modulepreload`. So the two halves have to meet:
|
|
13
|
+
* Bun knows the URL, nifra needs it, and there is no supported API connecting them. Verified against Bun
|
|
14
|
+
* 1.3.14: the HTML import evaluates to `{}` with no keys, the `Bun.serve` instance exposes nothing
|
|
15
|
+
* asset-related, and `/_bun/manifest`, `/_bun/client/manifest.json` and `/_bun/assets` all 404.
|
|
16
|
+
*
|
|
17
|
+
* The only route is to ask the dev server for a page it *did* bundle and read the URL back out of it.
|
|
18
|
+
*
|
|
19
|
+
* ## Why it is a module instead of four lines inline
|
|
20
|
+
*
|
|
21
|
+
* A regex over another tool's output is a dependency on an unspecified format, and that is true whether
|
|
22
|
+
* it lives here or inline in the dev server. What changes is the blast radius. Isolated here it is one
|
|
23
|
+
* function with one caller, covered by tests that pin the exact tag shape Bun emits today - so a Bun
|
|
24
|
+
* upgrade that changes the format fails a test with a diff of the markup instead of producing a dev
|
|
25
|
+
* server that boots fine and serves pages whose scripts 404. And if Bun ever exposes a real accessor,
|
|
26
|
+
* this is the only body that changes.
|
|
27
|
+
*
|
|
28
|
+
* Detection is layered so a cosmetic change degrades instead of breaking: the `data-bun-dev-server-script`
|
|
29
|
+
* attribute is the precise signal, an unambiguous single module script under `/_bun/` is the fallback,
|
|
30
|
+
* and anything else is a named error that says what was searched for and what was found.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** One `<script>` tag lifted out of a served page: its raw attribute text and parsed `src`. */
|
|
34
|
+
interface ScriptTag {
|
|
35
|
+
readonly attrs: string
|
|
36
|
+
readonly src: string | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `<script …>` open tags. Attribute text is captured raw; `src` is parsed out of it separately so the
|
|
40
|
+
// two are independent of attribute ORDER (Bun emits `crossorigin src=… data-bun-dev-server-script`, but
|
|
41
|
+
// nothing in HTML makes that order stable).
|
|
42
|
+
const SCRIPT_OPEN = /<script\b([^>]*)>/gi
|
|
43
|
+
const SRC_ATTR = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i
|
|
44
|
+
// The attribute Bun marks its own bundled entry with. Valueless today; matched tolerantly so a future
|
|
45
|
+
// `data-bun-dev-server-script="1"` still hits.
|
|
46
|
+
const MARKER_ATTR = /\bdata-bun-dev-server-script\b/i
|
|
47
|
+
// Bun serves every bundled client asset under this prefix.
|
|
48
|
+
const BUN_CLIENT_PREFIX = "/_bun/"
|
|
49
|
+
|
|
50
|
+
/** Pull every `<script>` open tag out of an HTML document, in source order. */
|
|
51
|
+
function scriptTags(html: string): ScriptTag[] {
|
|
52
|
+
const tags: ScriptTag[] = []
|
|
53
|
+
SCRIPT_OPEN.lastIndex = 0
|
|
54
|
+
for (let m = SCRIPT_OPEN.exec(html); m !== null; m = SCRIPT_OPEN.exec(html)) {
|
|
55
|
+
const attrs = m[1] ?? ""
|
|
56
|
+
const src = SRC_ATTR.exec(attrs)
|
|
57
|
+
tags.push({ attrs, src: src?.[1] ?? src?.[2] ?? src?.[3] })
|
|
58
|
+
}
|
|
59
|
+
return tags
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How the entry URL was found - reported so a fallback hit is visible rather than silent. */
|
|
63
|
+
export type DevEntryVia = "marker" | "single-bun-script"
|
|
64
|
+
|
|
65
|
+
/** A located client entry: the URL to hand nifra, its stylesheets, and which signal found it. */
|
|
66
|
+
export interface DevEntryMatch {
|
|
67
|
+
readonly src: string
|
|
68
|
+
/**
|
|
69
|
+
* Stylesheet URLs Bun extracted from the entry's CSS imports.
|
|
70
|
+
*
|
|
71
|
+
* These are not a nicety. Bun's bundler pulls `import "./app.css"` out of the JS graph and links it
|
|
72
|
+
* from the HTML *it* bundled - the throwaway probe page, which nobody ever sees. nifra renders the real
|
|
73
|
+
* pages, so unless these are carried across, a Bun-pipeline dev session serves every page unstyled
|
|
74
|
+
* while the production build (which reads CSS from the build manifest) is fine. That is the worst
|
|
75
|
+
* possible shape for a bug: dev-only, silent, and looks like the app's own CSS is broken.
|
|
76
|
+
*/
|
|
77
|
+
readonly styles: readonly string[]
|
|
78
|
+
readonly via: DevEntryVia
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// `<link rel="stylesheet" href="…">`. `rel` and `href` are matched independently of order, same reason
|
|
82
|
+
// as the script tags: attribute order in generated markup is not a contract.
|
|
83
|
+
const LINK_OPEN = /<link\b([^>]*)>/gi
|
|
84
|
+
const HREF_ATTR = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i
|
|
85
|
+
const REL_STYLESHEET =
|
|
86
|
+
/\brel\s*=\s*(?:"[^"]*\bstylesheet\b[^"]*"|'[^']*\bstylesheet\b[^']*'|stylesheet)/i
|
|
87
|
+
|
|
88
|
+
/** Every stylesheet URL linked from a document, in source order. */
|
|
89
|
+
export function parseDevStyles(html: string): string[] {
|
|
90
|
+
const hrefs: string[] = []
|
|
91
|
+
LINK_OPEN.lastIndex = 0
|
|
92
|
+
for (let m = LINK_OPEN.exec(html); m !== null; m = LINK_OPEN.exec(html)) {
|
|
93
|
+
const attrs = m[1] ?? ""
|
|
94
|
+
if (!REL_STYLESHEET.test(attrs)) continue
|
|
95
|
+
const href = HREF_ATTR.exec(attrs)
|
|
96
|
+
const url = href?.[1] ?? href?.[2] ?? href?.[3]
|
|
97
|
+
if (url !== undefined && url !== "") hrefs.push(url)
|
|
98
|
+
}
|
|
99
|
+
return hrefs
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find the bundled client-entry URL in a page Bun's dev server rendered. Pure - the whole format
|
|
104
|
+
* dependency lives in this function, which is what makes it testable against captured real output.
|
|
105
|
+
*
|
|
106
|
+
* Returns `undefined` rather than throwing so the caller can attach the context a useful error needs
|
|
107
|
+
* (which URL was probed, what the server returned).
|
|
108
|
+
*/
|
|
109
|
+
export function parseDevEntry(html: string): DevEntryMatch | undefined {
|
|
110
|
+
const tags = scriptTags(html)
|
|
111
|
+
const styles = parseDevStyles(html)
|
|
112
|
+
// 1. The precise signal: Bun's own marker attribute.
|
|
113
|
+
for (const tag of tags) {
|
|
114
|
+
if (MARKER_ATTR.test(tag.attrs) && tag.src !== undefined && tag.src !== "") {
|
|
115
|
+
return { src: tag.src, styles, via: "marker" }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// 2. Fallback: exactly one script pointing into Bun's client-asset prefix. Requiring uniqueness is what
|
|
119
|
+
// keeps this a fallback and not a guess - with two candidates there is no basis for picking one, so
|
|
120
|
+
// it declines and lets the caller raise a real error.
|
|
121
|
+
const bunScripts = tags.filter((t) => t.src?.startsWith(BUN_CLIENT_PREFIX))
|
|
122
|
+
const only = bunScripts.length === 1 ? bunScripts[0] : undefined
|
|
123
|
+
if (only?.src !== undefined) return { src: only.src, styles, via: "single-bun-script" }
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The error thrown when Bun's dev server serves a page nothing in it can be recognised as the entry. */
|
|
128
|
+
export function devEntryNotFoundMessage(probeUrl: string, html: string): string {
|
|
129
|
+
const scripts = scriptTags(html)
|
|
130
|
+
const found =
|
|
131
|
+
scripts.length === 0
|
|
132
|
+
? "the page contains no <script> tags at all"
|
|
133
|
+
: `the page's script tags are: ${scripts.map((s) => `<script${s.attrs}>`).join(", ")}`
|
|
134
|
+
return (
|
|
135
|
+
`[nifra] could not find Bun's bundled client entry in the page served at ${probeUrl}.\n` +
|
|
136
|
+
` Looked for a <script> carrying \`data-bun-dev-server-script\`, then for a single module script ` +
|
|
137
|
+
`under \`${BUN_CLIENT_PREFIX}\`; ${found}.\n` +
|
|
138
|
+
` This is how Bun's dev server labelled its bundled entry when this was written, so a Bun upgrade ` +
|
|
139
|
+
`that changes the markup lands here first. Run the Bun pipeline dev server's tests to see the ` +
|
|
140
|
+
`current shape, or use the Vite pipeline (\`nifra dev\`) meanwhile.`
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Structural slice of a running server this needs: just the port it bound. */
|
|
145
|
+
export interface ServedOn {
|
|
146
|
+
readonly port: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ResolveDevEntryOptions {
|
|
150
|
+
/** Path of the throwaway route serving Bun's bundled HTML (e.g. `/__nifra/entry`). */
|
|
151
|
+
readonly probePath: string
|
|
152
|
+
/** Injected for tests; defaults to global `fetch`. */
|
|
153
|
+
readonly fetchImpl?: typeof fetch
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Ask Bun's dev server for the current bundled entry URL. One loopback request against a route the app
|
|
158
|
+
* never exposes.
|
|
159
|
+
*
|
|
160
|
+
* **The answer expires.** The URL is a content hash over the whole client graph, so editing any file it
|
|
161
|
+
* reaches - a route, a component, a stylesheet - re-hashes it. Caching the startup value and injecting it
|
|
162
|
+
* forever produces a spectacular failure: Bun answers a superseded chunk URL with a 104-byte
|
|
163
|
+
* `location.reload()` stub under a 405, so the page reloads, gets the same dead URL from SSR, and reloads
|
|
164
|
+
* again, forever. No console output survives the loop to explain it. Callers must re-resolve rather than
|
|
165
|
+
* remember - see how `@nifrajs/web/dev` serves a stable URL that redirects here per request.
|
|
166
|
+
*/
|
|
167
|
+
export async function resolveDevEntry(
|
|
168
|
+
server: ServedOn,
|
|
169
|
+
options: ResolveDevEntryOptions,
|
|
170
|
+
): Promise<DevEntryMatch> {
|
|
171
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
172
|
+
const probeUrl = `http://127.0.0.1:${server.port}${options.probePath}`
|
|
173
|
+
const response = await doFetch(probeUrl)
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`[nifra] Bun's dev server returned ${response.status} for the client-entry probe at ${probeUrl}. ` +
|
|
177
|
+
"The generated HTML route did not build - the error above this line is Bun's.",
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
const html = await response.text()
|
|
181
|
+
const match = parseDevEntry(html)
|
|
182
|
+
if (match === undefined) throw new Error(devEntryNotFoundMessage(probeUrl, html))
|
|
183
|
+
return match
|
|
184
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -200,6 +200,14 @@ export function installHistory(
|
|
|
200
200
|
*/
|
|
201
201
|
export function applyHead(head: Meta): void {
|
|
202
202
|
if (head.title !== undefined) document.title = head.title
|
|
203
|
+
// `<html lang>`/`<html dir>` — applied AUTHORITATIVELY (unlike `title`, which is left alone when the
|
|
204
|
+
// 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 — navigating /ur → /en would leave
|
|
206
|
+
// `dir="rtl"` behind and lay the English page out right-to-left, a bug a hard reload would not show.
|
|
207
|
+
const root = document.documentElement
|
|
208
|
+
root.setAttribute("lang", head.lang ?? "en")
|
|
209
|
+
if (head.dir === undefined) root.removeAttribute("dir")
|
|
210
|
+
else root.setAttribute("dir", head.dir)
|
|
203
211
|
for (const el of document.head.querySelectorAll("[data-nifra]")) el.remove()
|
|
204
212
|
// Values follow the same HTML attribute conventions as the SSR `tagAttrs`: a string sets the value,
|
|
205
213
|
// `true` sets the bare boolean attribute, `false`/`undefined` skip it — so a soft-nav head matches
|
package/src/deferred.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
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
|
+
import { plainJsonCodec, type TransportCodec } from "@nifrajs/core/transport-codec"
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* A loader value marked to stream in after the shell. The component consumes it with the adapter's
|
|
@@ -161,21 +162,22 @@ const NDJSON_ENCODER = new TextEncoder()
|
|
|
161
162
|
export function ndjsonStream(
|
|
162
163
|
forClient: unknown,
|
|
163
164
|
deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
|
|
165
|
+
codec: TransportCodec = plainJsonCodec,
|
|
164
166
|
): ReadableStream<Uint8Array> {
|
|
165
167
|
return new ReadableStream({
|
|
166
168
|
async start(controller) {
|
|
167
|
-
controller.enqueue(NDJSON_ENCODER.encode(`${
|
|
169
|
+
controller.enqueue(NDJSON_ENCODER.encode(`${codec.encode(forClient)}\n`))
|
|
168
170
|
await Promise.all(
|
|
169
171
|
deferred.map(async (d) => {
|
|
170
172
|
try {
|
|
171
173
|
const value = await d.promise
|
|
172
|
-
controller.enqueue(NDJSON_ENCODER.encode(`${
|
|
174
|
+
controller.enqueue(NDJSON_ENCODER.encode(`${codec.encode({ i: d.id, v: value })}\n`))
|
|
173
175
|
} catch (err) {
|
|
174
176
|
// Redact: stream a stable opaque code, never the raw error text; log the real reason
|
|
175
177
|
// server-side. The client's <Await> errorFallback receives this code.
|
|
176
178
|
console.error("[nifra/web] deferred value rejected:", err)
|
|
177
179
|
controller.enqueue(
|
|
178
|
-
NDJSON_ENCODER.encode(`${
|
|
180
|
+
NDJSON_ENCODER.encode(`${codec.encode({ i: d.id, e: DEFERRED_ERROR_CODE })}\n`),
|
|
179
181
|
)
|
|
180
182
|
}
|
|
181
183
|
}),
|
|
@@ -259,7 +261,11 @@ function markersFromPlaceholders(data: unknown, pending: Pending): unknown {
|
|
|
259
261
|
return marker
|
|
260
262
|
}
|
|
261
263
|
if (Array.isArray(value)) return value.map(walk)
|
|
262
|
-
if (
|
|
264
|
+
if (
|
|
265
|
+
value !== null &&
|
|
266
|
+
typeof value === "object" &&
|
|
267
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
|
|
268
|
+
) {
|
|
263
269
|
const out: Record<string, unknown> = {}
|
|
264
270
|
for (const [key, v] of Object.entries(value)) out[key] = walk(v)
|
|
265
271
|
return out
|
|
@@ -279,6 +285,7 @@ function markersFromPlaceholders(data: unknown, pending: Pending): unknown {
|
|
|
279
285
|
export async function parseNdjsonData(
|
|
280
286
|
stream: ReadableStream<Uint8Array>,
|
|
281
287
|
signal?: AbortSignal,
|
|
288
|
+
codec: TransportCodec = plainJsonCodec,
|
|
282
289
|
): Promise<unknown> {
|
|
283
290
|
const reader = stream.getReader()
|
|
284
291
|
const dec = new TextDecoder()
|
|
@@ -308,7 +315,7 @@ export async function parseNdjsonData(
|
|
|
308
315
|
|
|
309
316
|
const pending: Pending = new Map()
|
|
310
317
|
const first = await readLine()
|
|
311
|
-
const data = markersFromPlaceholders(first === null ? null :
|
|
318
|
+
const data = markersFromPlaceholders(first === null ? null : codec.decode(first), pending)
|
|
312
319
|
|
|
313
320
|
void (async () => {
|
|
314
321
|
try {
|
|
@@ -316,7 +323,7 @@ export async function parseNdjsonData(
|
|
|
316
323
|
const line = await readLine()
|
|
317
324
|
if (line === null) break
|
|
318
325
|
if (line === "") continue
|
|
319
|
-
const msg =
|
|
326
|
+
const msg = codec.decode(line) as { i: number; v?: unknown; e?: string }
|
|
320
327
|
const entry = pending.get(msg.i)
|
|
321
328
|
if (entry === undefined) continue
|
|
322
329
|
pending.delete(msg.i)
|
package/src/dev-port.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/dev-port` — what a dev server says when it cannot bind.
|
|
3
|
+
*
|
|
4
|
+
* Both pipelines need this and neither should own it: the Vite server binds through Node's `http`
|
|
5
|
+
* (asynchronous `error` event), the Bun server binds through `Bun.serve` (synchronous throw). Two very
|
|
6
|
+
* different failure mechanics, one thing the developer has to be told.
|
|
7
|
+
*
|
|
8
|
+
* That message is worth this much care because the visible symptom is not the real one. When the port is
|
|
9
|
+
* taken, the EARLIER dev server is still answering on it, still serving the build it started with. Every
|
|
10
|
+
* subsequent edit appears to do nothing. What reaches the developer is "my changes stopped reaching SSR",
|
|
11
|
+
* which reads as broken HMR or a stale module graph and sends them into the module graph - not to the one
|
|
12
|
+
* process that never started. Naming the cause here is the entire fix.
|
|
13
|
+
*
|
|
14
|
+
* This module deliberately imports nothing: it is shared by a Node-http path and a Bun path, and the
|
|
15
|
+
* structural `ListenTarget` keeps it free of either runtime's types.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The `EADDRINUSE` explanation. Exported so tests pin the exact text a user will read. */
|
|
19
|
+
export function portInUseMessage(port: number): string {
|
|
20
|
+
return (
|
|
21
|
+
`[nifra] dev server can't start: port ${port} is already in use.\n` +
|
|
22
|
+
` Most likely an earlier \`nifra dev\` is still running. It keeps serving the PREVIOUS build, so ` +
|
|
23
|
+
`every edit will look like it stops reaching SSR while the browser shows stale output.\n` +
|
|
24
|
+
` Free the port: lsof -ti:${port} | xargs kill\n` +
|
|
25
|
+
` Or use another: nifra dev --port ${port + 1}`
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True when an unknown thrown value carries the "address already in use" errno. */
|
|
30
|
+
export const isAddressInUse = (err: unknown): boolean =>
|
|
31
|
+
(err as { code?: unknown } | null)?.code === "EADDRINUSE"
|
|
32
|
+
|
|
33
|
+
const asError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err)))
|
|
34
|
+
|
|
35
|
+
/** Map a bind failure to the port-collision explanation, leaving every other failure as itself. */
|
|
36
|
+
export const explainBindFailure = (err: unknown, port: number): Error =>
|
|
37
|
+
isAddressInUse(err) ? new Error(portInUseMessage(port)) : asError(err)
|
|
38
|
+
|
|
39
|
+
/** The bits of a Node server {@link listenOrExplain} touches — structural, so a test can fake it. */
|
|
40
|
+
export interface ListenTarget {
|
|
41
|
+
listen(port: number, cb: () => void): void
|
|
42
|
+
once(event: "error", cb: (err: unknown) => void): void
|
|
43
|
+
removeListener(event: "error", cb: (err: unknown) => void): void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `listen`, but a bind failure becomes a readable nifra error instead of Node's raw internal throw.
|
|
48
|
+
*
|
|
49
|
+
* Without an `error` listener attached, Node throws from deep inside `node:events`, the new process dies
|
|
50
|
+
* in the background, and nothing connects that death to the stale page in front of you.
|
|
51
|
+
*/
|
|
52
|
+
export function listenOrExplain(server: ListenTarget, port: number): Promise<void> {
|
|
53
|
+
return new Promise<void>((resolve, reject) => {
|
|
54
|
+
const onError = (err: unknown): void => reject(explainBindFailure(err, port))
|
|
55
|
+
server.once("error", onError)
|
|
56
|
+
server.listen(port, () => {
|
|
57
|
+
// Drop the guard once we're listening: leaving it attached would funnel a LATER server error into
|
|
58
|
+
// an already-settled promise, silently swallowing it instead of surfacing it.
|
|
59
|
+
server.removeListener("error", onError)
|
|
60
|
+
resolve()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
}
|