@nifrajs/node 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.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/index.d.ts +110 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +696 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/index.ts +954 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a nifra app (or any Web-`fetch` handler) on Node's `http` server.
|
|
3
|
+
*
|
|
4
|
+
* import { serve } from "@nifrajs/node"
|
|
5
|
+
* import { server } from "@nifrajs/core"
|
|
6
|
+
* const app = server().get("/", () => ({ ok: true }))
|
|
7
|
+
* serve(app, { port: 3000 })
|
|
8
|
+
*
|
|
9
|
+
* nifra's lifecycle is `app.fetch(Request): Response | Promise<Response>` — pure Web Standards —
|
|
10
|
+
* so this adapter just bridges Node's stream-based `(req, res)` to/from Web
|
|
11
|
+
* `Request`/`Response`, plus a Bun-`listen()`-style graceful `stop()`.
|
|
12
|
+
*/
|
|
13
|
+
import { open } from "node:fs/promises"
|
|
14
|
+
import {
|
|
15
|
+
createServer,
|
|
16
|
+
type IncomingHttpHeaders,
|
|
17
|
+
type IncomingMessage,
|
|
18
|
+
type ServerResponse,
|
|
19
|
+
} from "node:http"
|
|
20
|
+
import { extname, resolve, sep } from "node:path"
|
|
21
|
+
import { type Duplex, Readable } from "node:stream"
|
|
22
|
+
import { fileURLToPath } from "node:url"
|
|
23
|
+
|
|
24
|
+
/** Anything exposing a Web `fetch` handler — a nifra `app`, for instance. */
|
|
25
|
+
export interface FetchHandler {
|
|
26
|
+
fetch(request: Request): Response | Promise<Response>
|
|
27
|
+
/** Nifra apps also expose this WS-upgrade seam; present → this adapter serves `app.ws()` routes via
|
|
28
|
+
* the optional `ws` package (lazy-imported on the first upgrade). Absent (a plain `{ fetch }`
|
|
29
|
+
* handler) → HTTP only, and an upgrade request gets a 404. */
|
|
30
|
+
resolveWebSocketUpgrade?(request: Request): WsUpgradeOutcome | Promise<WsUpgradeOutcome>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- WebSocket types: structurally mirrored from @nifrajs/core (this adapter has no @nifrajs/core
|
|
34
|
+
// dependency — see NodeServeOutcome above). Kept in lockstep by the WS integration test. ---
|
|
35
|
+
|
|
36
|
+
/** A received frame, normalized: text → `string`, binary → `Uint8Array`. */
|
|
37
|
+
type NifraWsData = string | Uint8Array
|
|
38
|
+
|
|
39
|
+
/** The portable socket a nifra WS handler sees (mirror of core's `NifraWebSocket`). */
|
|
40
|
+
interface NifraWs {
|
|
41
|
+
send(data: string | ArrayBufferView | ArrayBuffer): void
|
|
42
|
+
close(code?: number, reason?: string): void
|
|
43
|
+
readonly readyState: number
|
|
44
|
+
subscribe(topic: string): void
|
|
45
|
+
unsubscribe(topic: string): void
|
|
46
|
+
data: unknown
|
|
47
|
+
readonly raw: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Mirror of core's `TopicRegistry` surface — the app's pub/sub the adapter wires `ws.subscribe` to. */
|
|
51
|
+
interface WsPubSub {
|
|
52
|
+
subscribe(topic: string, ws: NifraWs): void
|
|
53
|
+
unsubscribe(topic: string, ws: NifraWs): void
|
|
54
|
+
unsubscribeAll(ws: NifraWs): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A nifra WS route's lifecycle (mirror of core's `WebSocketHandler` — the post-upgrade callbacks). */
|
|
58
|
+
interface NifraWsHandler {
|
|
59
|
+
open?(ws: NifraWs): void | Promise<void>
|
|
60
|
+
message?(ws: NifraWs, data: NifraWsData): void | Promise<void>
|
|
61
|
+
close?(ws: NifraWs, code: number, reason: string): void | Promise<void>
|
|
62
|
+
error?(ws: NifraWs, error: unknown): void | Promise<void>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Mirror of core's `WebSocketUpgradeOutcome` — what `resolveWebSocketUpgrade` returns. */
|
|
66
|
+
type WsUpgradeOutcome =
|
|
67
|
+
| { readonly kind: "pass" }
|
|
68
|
+
| { readonly kind: "reject"; readonly response: Response }
|
|
69
|
+
| {
|
|
70
|
+
readonly kind: "upgrade"
|
|
71
|
+
readonly handler: NifraWsHandler
|
|
72
|
+
readonly data: unknown
|
|
73
|
+
readonly pubsub: WsPubSub
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Structural view of the `ws` package's `WebSocket` (no `@types/ws` dependency — see `loadWsServer`). */
|
|
77
|
+
interface WsSocket {
|
|
78
|
+
send(data: string | ArrayBufferView | ArrayBuffer): void
|
|
79
|
+
close(code?: number, reason?: string): void
|
|
80
|
+
readonly readyState: number
|
|
81
|
+
on(event: "message", listener: (data: Buffer, isBinary: boolean) => void): void
|
|
82
|
+
on(event: "close", listener: (code: number, reason: Buffer) => void): void
|
|
83
|
+
on(event: "error", listener: (error: Error) => void): void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Structural view of the `ws` package's `WebSocketServer` (noServer mode). */
|
|
87
|
+
interface WsServer {
|
|
88
|
+
handleUpgrade(
|
|
89
|
+
request: IncomingMessage,
|
|
90
|
+
socket: Duplex,
|
|
91
|
+
head: Buffer,
|
|
92
|
+
callback: (ws: WsSocket) => void,
|
|
93
|
+
): void
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type RequestProtocol = "http" | "https"
|
|
97
|
+
export type RequestProtocolOption =
|
|
98
|
+
| RequestProtocol
|
|
99
|
+
| ((request: IncomingMessage) => RequestProtocol)
|
|
100
|
+
type RequestProtocolResolver = (request: IncomingMessage) => RequestProtocol
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The node-direct render returned by a nifra app's `resolveNode` — structurally mirrored here so this
|
|
104
|
+
* adapter stays decoupled from `@nifrajs/core` (it bridges *any* handler exposing this seam, and has no
|
|
105
|
+
* runtime dependency on nifra). A `kind: "json"` outcome is a plain-data result we serialize straight to
|
|
106
|
+
* the socket; a `kind: "body"` outcome is a marked buffered response body (notably @nifrajs/web's
|
|
107
|
+
* non-deferred SSR HTML). Both skip undici/Web body draining. Everything else carries a `Response` we
|
|
108
|
+
* write the usual Web way. Kept in lockstep with `@nifrajs/core`'s `NodeServeOutcome` by the integration
|
|
109
|
+
* test (`serve.test.ts`), which runs a real nifra app end-to-end through this path.
|
|
110
|
+
*/
|
|
111
|
+
type NodeServeOutcome =
|
|
112
|
+
| { readonly kind: "response"; readonly response: Response }
|
|
113
|
+
| {
|
|
114
|
+
readonly kind: "json"
|
|
115
|
+
readonly status: number
|
|
116
|
+
readonly headers: Readonly<Record<string, string>> | undefined
|
|
117
|
+
readonly cookies: readonly string[] | undefined
|
|
118
|
+
readonly body: string | null
|
|
119
|
+
}
|
|
120
|
+
| {
|
|
121
|
+
readonly kind: "body"
|
|
122
|
+
readonly status: number
|
|
123
|
+
readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
|
|
124
|
+
readonly body: string | Uint8Array
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface NodeRequestSource {
|
|
128
|
+
readonly method: string
|
|
129
|
+
readonly url: string
|
|
130
|
+
readonly headers: Headers
|
|
131
|
+
header(name: string): string | null
|
|
132
|
+
readonly body: ReadableStream<Uint8Array> | null
|
|
133
|
+
arrayBuffer(): Promise<ArrayBuffer>
|
|
134
|
+
json(): Promise<unknown>
|
|
135
|
+
readonly request: Request
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A `FetchHandler` that *also* exposes the node-direct fast path (every nifra app does). May resolve
|
|
139
|
+
* **synchronously** (a bare route + sync handler allocates no promise) — we `await` it regardless. */
|
|
140
|
+
interface NodeFastHandler extends FetchHandler {
|
|
141
|
+
resolveNode(request: Request): NodeServeOutcome | Promise<NodeServeOutcome>
|
|
142
|
+
resolveNodeSource(source: NodeRequestSource): NodeServeOutcome | Promise<NodeServeOutcome>
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The `Content-Type` the host runtime's `Response.json` emits — Node's undici uses `application/json`,
|
|
147
|
+
* Bun uses `application/json;charset=utf-8`. Probed once at module load (zero per-request cost) so the
|
|
148
|
+
* fast path is byte-for-byte identical to the `Response`-building path on whatever runtime hosts us.
|
|
149
|
+
*/
|
|
150
|
+
const JSON_CONTENT_TYPE = Response.json(0).headers.get("content-type") ?? "application/json"
|
|
151
|
+
|
|
152
|
+
const INTERNAL_ERROR_BODY = '{"ok":false,"error":"internal_error"}'
|
|
153
|
+
const EMPTY_BUFFER = Buffer.alloc(0)
|
|
154
|
+
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Serve static files from a directory (e.g. the client build) under a URL prefix — so a self-hosted
|
|
158
|
+
* Node deploy doesn't need a CDN or a hand-rolled `/assets/*` handler. (On Cloudflare/Vercel the
|
|
159
|
+
* platform serves assets; this is for `node server.js`.)
|
|
160
|
+
*/
|
|
161
|
+
export interface ServeStaticOptions {
|
|
162
|
+
/** Directory to read files from — an absolute path or a `file://` URL (`new URL("./assets/", import.meta.url)`). */
|
|
163
|
+
readonly dir: string | URL
|
|
164
|
+
/** URL prefix these files are served under. Default `"/assets"`. Use `"/"` to serve the whole dir. */
|
|
165
|
+
readonly prefix?: string
|
|
166
|
+
/** Emit `cache-control: public, max-age=31536000, immutable` — correct for content-hashed files. Default `true`. */
|
|
167
|
+
readonly immutable?: boolean
|
|
168
|
+
/** Extra headers merged onto every served file. */
|
|
169
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface ServeOptions {
|
|
173
|
+
readonly port: number
|
|
174
|
+
readonly hostname?: string
|
|
175
|
+
/**
|
|
176
|
+
* Protocol used when the adapter constructs `Request.url`.
|
|
177
|
+
*
|
|
178
|
+
* `@nifrajs/node` creates a plain Node `http` server, so the safe default is `"http"`. Deployments behind
|
|
179
|
+
* TLS termination can set `"https"` (or a trusted infra-aware function) so app code that reads
|
|
180
|
+
* `request.url` sees the public scheme. Forwarded headers are not trusted implicitly.
|
|
181
|
+
*/
|
|
182
|
+
readonly protocol?: RequestProtocolOption
|
|
183
|
+
/**
|
|
184
|
+
* Install SIGTERM/SIGINT handlers that call `stop()` for a graceful drain on
|
|
185
|
+
* `docker stop` / Ctrl-C. Off by default — taking over process signals is opt-in,
|
|
186
|
+
* mirroring nifra's Bun `listen({ gracefulSignals })`.
|
|
187
|
+
*
|
|
188
|
+
* The app-level request timeout (`server({ requestTimeoutMs })` → 503) and body cap
|
|
189
|
+
* are *not* set here — they live inside `app.fetch`, so they already apply through
|
|
190
|
+
* this adapter. Slow-client protection is Node's built-in `requestTimeout` (300s) /
|
|
191
|
+
* `headersTimeout` (60s) defaults.
|
|
192
|
+
*/
|
|
193
|
+
readonly signals?: boolean
|
|
194
|
+
/**
|
|
195
|
+
* Serve static files from disk for matching GET/HEAD requests *before* the app runs — non-matching
|
|
196
|
+
* requests fall through to `app.fetch` with the node-direct fast path intact (no perf regression on
|
|
197
|
+
* SSR/API routes). Replaces the hand-rolled `/assets/*` reader in self-hosted entries.
|
|
198
|
+
*/
|
|
199
|
+
readonly static?: ServeStaticOptions
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface NodeServer {
|
|
203
|
+
/** The bound port (resolved when `port: 0` is requested). */
|
|
204
|
+
readonly port: number
|
|
205
|
+
/**
|
|
206
|
+
* Stop accepting connections, let in-flight requests drain (up to `drainMs`), then
|
|
207
|
+
* force-close stragglers + idle keep-alives. Mirrors nifra's Bun `stop()`.
|
|
208
|
+
*/
|
|
209
|
+
stop(options?: { drainMs?: number }): Promise<void>
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const DEFAULT_DRAIN_MS = 10_000
|
|
213
|
+
const DRAIN_POLL_MS = 10
|
|
214
|
+
|
|
215
|
+
const STATIC_CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
216
|
+
".js": "text/javascript; charset=utf-8",
|
|
217
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
218
|
+
".css": "text/css; charset=utf-8",
|
|
219
|
+
".html": "text/html; charset=utf-8",
|
|
220
|
+
".json": "application/json; charset=utf-8",
|
|
221
|
+
".map": "application/json; charset=utf-8",
|
|
222
|
+
".svg": "image/svg+xml",
|
|
223
|
+
".png": "image/png",
|
|
224
|
+
".jpg": "image/jpeg",
|
|
225
|
+
".jpeg": "image/jpeg",
|
|
226
|
+
".gif": "image/gif",
|
|
227
|
+
".webp": "image/webp",
|
|
228
|
+
".avif": "image/avif",
|
|
229
|
+
".ico": "image/x-icon",
|
|
230
|
+
".woff": "font/woff",
|
|
231
|
+
".woff2": "font/woff2",
|
|
232
|
+
".ttf": "font/ttf",
|
|
233
|
+
".otf": "font/otf",
|
|
234
|
+
".wasm": "application/wasm",
|
|
235
|
+
".txt": "text/plain; charset=utf-8",
|
|
236
|
+
".webmanifest": "application/manifest+json",
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
interface StaticState {
|
|
240
|
+
readonly root: string
|
|
241
|
+
readonly prefix: string // normalized: "/assets" (no trailing slash) or "/"
|
|
242
|
+
readonly immutable: boolean
|
|
243
|
+
readonly headers: Readonly<Record<string, string>> | undefined
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function staticStateOf(options: ServeStaticOptions): StaticState {
|
|
247
|
+
const root = resolve(typeof options.dir === "string" ? options.dir : fileURLToPath(options.dir))
|
|
248
|
+
const raw = options.prefix ?? "/assets"
|
|
249
|
+
const prefix = raw === "/" ? "/" : `/${raw.replace(/^\/+|\/+$/g, "")}`
|
|
250
|
+
return { root, prefix, immutable: options.immutable !== false, headers: options.headers }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Resolve a request URL to a file under the served root — **synchronously**, so non-matching requests
|
|
255
|
+
* stay on the app's sync fast path. Returns `"pass"` (let the app handle it), a rejection `Response`
|
|
256
|
+
* (malformed encoding / NUL / `..` traversal out of root), or a confined absolute file path to read.
|
|
257
|
+
*/
|
|
258
|
+
function staticMatch(
|
|
259
|
+
state: StaticState,
|
|
260
|
+
rawUrl: string,
|
|
261
|
+
): "pass" | { readonly reject: Response } | { readonly file: string } {
|
|
262
|
+
const query = rawUrl.indexOf("?")
|
|
263
|
+
const path = query === -1 ? rawUrl : rawUrl.slice(0, query)
|
|
264
|
+
const underPrefix =
|
|
265
|
+
state.prefix === "/" ? true : path === state.prefix || path.startsWith(`${state.prefix}/`)
|
|
266
|
+
if (!underPrefix) return "pass"
|
|
267
|
+
let rel: string
|
|
268
|
+
try {
|
|
269
|
+
rel = decodeURIComponent(state.prefix === "/" ? path : path.slice(state.prefix.length))
|
|
270
|
+
} catch {
|
|
271
|
+
return { reject: new Response("Bad Request", { status: 400 }) }
|
|
272
|
+
}
|
|
273
|
+
rel = rel.replace(/^\/+/, "")
|
|
274
|
+
if (rel === "" || rel.endsWith("/")) return "pass" // a directory request → the app decides
|
|
275
|
+
if (rel.includes("\0")) return { reject: new Response("Bad Request", { status: 400 }) }
|
|
276
|
+
const file = resolve(state.root, rel)
|
|
277
|
+
// Confine to the served directory — block `..` from escaping root.
|
|
278
|
+
if (file !== state.root && !file.startsWith(state.root + sep)) {
|
|
279
|
+
return { reject: new Response("Forbidden", { status: 403 }) }
|
|
280
|
+
}
|
|
281
|
+
return { file }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function readStatic(
|
|
285
|
+
file: string,
|
|
286
|
+
state: StaticState,
|
|
287
|
+
method: string,
|
|
288
|
+
): Promise<NodeServeOutcome> {
|
|
289
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined
|
|
290
|
+
try {
|
|
291
|
+
handle = await open(file, "r")
|
|
292
|
+
const stat = await handle.stat()
|
|
293
|
+
if (!stat.isFile()) {
|
|
294
|
+
await handle.close()
|
|
295
|
+
return { kind: "response", response: new Response("Not Found", { status: 404 }) }
|
|
296
|
+
}
|
|
297
|
+
const headers: Record<string, string> = { ...state.headers }
|
|
298
|
+
headers["content-type"] =
|
|
299
|
+
STATIC_CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream"
|
|
300
|
+
headers["content-length"] = String(stat.size)
|
|
301
|
+
if (state.immutable && headers["cache-control"] === undefined) {
|
|
302
|
+
headers["cache-control"] = "public, max-age=31536000, immutable"
|
|
303
|
+
}
|
|
304
|
+
if (method === "HEAD") {
|
|
305
|
+
await handle.close()
|
|
306
|
+
return { kind: "response", response: new Response(null, { headers }) }
|
|
307
|
+
}
|
|
308
|
+
const stream = handle.createReadStream()
|
|
309
|
+
return {
|
|
310
|
+
kind: "response",
|
|
311
|
+
response: new Response(Readable.toWeb(stream) as ReadableStream<Uint8Array>, { headers }),
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
await handle?.close().catch(() => {})
|
|
315
|
+
// Missing/unreadable under the static prefix → 404 (don't fall through to an SSR 404 page).
|
|
316
|
+
return { kind: "response", response: new Response("Not Found", { status: 404 }) }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Serve a Web-`fetch` app on a Node `http` server. Resolves once bound — Node binds
|
|
322
|
+
* the port asynchronously, so awaiting gives you the real port (matters for `port: 0`).
|
|
323
|
+
*/
|
|
324
|
+
export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeServer> {
|
|
325
|
+
let inFlight = 0
|
|
326
|
+
let closed = false
|
|
327
|
+
const protocol = protocolResolver(options.protocol)
|
|
328
|
+
const staticState = options.static !== undefined ? staticStateOf(options.static) : undefined
|
|
329
|
+
const server = createServer((nodeReq, nodeRes) => {
|
|
330
|
+
inFlight += 1
|
|
331
|
+
try {
|
|
332
|
+
const handled = handle(app, nodeReq, nodeRes, protocol, staticState)
|
|
333
|
+
if (handled instanceof Promise) {
|
|
334
|
+
void handled.finally(() => {
|
|
335
|
+
inFlight -= 1
|
|
336
|
+
})
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
inFlight -= 1
|
|
340
|
+
} catch {
|
|
341
|
+
writeInternalError(nodeRes)
|
|
342
|
+
inFlight -= 1
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
|
|
347
|
+
// the optional `ws` package — lazy-imported (and the server lazily built) on the FIRST real WS
|
|
348
|
+
// upgrade, so a non-WS Node app never loads `ws`.
|
|
349
|
+
const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
|
|
350
|
+
if (resolveWs !== undefined) {
|
|
351
|
+
let wssPromise: Promise<WsServer | undefined> | undefined
|
|
352
|
+
server.on("upgrade", (nodeReq, socket, head) => {
|
|
353
|
+
void handleUpgrade(resolveWs, protocol, nodeReq, socket, head, () => {
|
|
354
|
+
wssPromise ??= loadWsServer()
|
|
355
|
+
return wssPromise
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Opt-in: own SIGTERM/SIGINT so `docker stop` / Ctrl-C drains in-flight requests
|
|
361
|
+
// before exit. `stop` is hoisted, so `onSignal` can reference it.
|
|
362
|
+
const onSignal = (): void => {
|
|
363
|
+
void stop()
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function stop({ drainMs = DEFAULT_DRAIN_MS }: { drainMs?: number } = {}): Promise<void> {
|
|
367
|
+
if (closed) return // idempotent
|
|
368
|
+
closed = true
|
|
369
|
+
if (options.signals === true) {
|
|
370
|
+
// Remove our own handlers so repeated serve()/stop() cycles don't leak listeners.
|
|
371
|
+
process.removeListener("SIGTERM", onSignal)
|
|
372
|
+
process.removeListener("SIGINT", onSignal)
|
|
373
|
+
}
|
|
374
|
+
server.close() // stop accepting new connections; existing requests continue
|
|
375
|
+
const deadline = Date.now() + drainMs
|
|
376
|
+
while (inFlight > 0 && Date.now() < deadline) {
|
|
377
|
+
await new Promise((r) => setTimeout(r, DRAIN_POLL_MS))
|
|
378
|
+
}
|
|
379
|
+
server.closeAllConnections() // force-close stragglers + idle keep-alive sockets
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return new Promise((resolve) => {
|
|
383
|
+
server.listen(options.port, options.hostname, () => {
|
|
384
|
+
const address = server.address()
|
|
385
|
+
const port = address !== null && typeof address === "object" ? address.port : options.port
|
|
386
|
+
if (options.signals === true) {
|
|
387
|
+
process.once("SIGTERM", onSignal)
|
|
388
|
+
process.once("SIGINT", onSignal)
|
|
389
|
+
}
|
|
390
|
+
resolve({ port, stop })
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function handle(
|
|
396
|
+
app: FetchHandler,
|
|
397
|
+
nodeReq: IncomingMessage,
|
|
398
|
+
nodeRes: ServerResponse,
|
|
399
|
+
getProtocol: RequestProtocolResolver,
|
|
400
|
+
staticState: StaticState | undefined,
|
|
401
|
+
): void | Promise<void> {
|
|
402
|
+
let protocol: RequestProtocol
|
|
403
|
+
try {
|
|
404
|
+
protocol = getProtocol(nodeReq)
|
|
405
|
+
} catch {
|
|
406
|
+
writeInternalError(nodeRes)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Static files first (GET/HEAD). The match is synchronous, so a non-asset request never leaves the
|
|
411
|
+
// app's sync fast path below; only a prefix hit reads from disk (and async-writes the file).
|
|
412
|
+
if (staticState !== undefined && (nodeReq.method === "GET" || nodeReq.method === "HEAD")) {
|
|
413
|
+
const matched = staticMatch(staticState, nodeReq.url ?? "/")
|
|
414
|
+
if (matched !== "pass") {
|
|
415
|
+
if ("reject" in matched) return writeNodeResponse(matched.reject, nodeRes)
|
|
416
|
+
return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then(
|
|
417
|
+
(outcome) => writeNodeOutcome(outcome, nodeRes),
|
|
418
|
+
() => writeInternalError(nodeRes),
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Fast path: a nifra app exposes `resolveNode`, which renders a plain-data result as primitives we
|
|
424
|
+
// write straight to the socket — skipping the undici `Response` build + body drain (the bulk of the
|
|
425
|
+
// Web-bridge cost on Node). A handler-returned `Response`/redirect, 404/405, error, timeout, or any
|
|
426
|
+
// `onResponse` hook comes back as `{ kind: "response" }` and takes the same Web path as before, so
|
|
427
|
+
// behavior is identical. A plain `{ fetch }` handler (no `resolveNode`) uses the Web path too.
|
|
428
|
+
const resolveNodeSource = (app as Partial<NodeFastHandler>).resolveNodeSource
|
|
429
|
+
if (typeof resolveNodeSource === "function") {
|
|
430
|
+
try {
|
|
431
|
+
const outcome = resolveNodeSource.call(app, toNodeRequestSource(nodeReq, protocol))
|
|
432
|
+
return outcome instanceof Promise
|
|
433
|
+
? outcome.then(
|
|
434
|
+
(settled) => writeNodeOutcome(settled, nodeRes),
|
|
435
|
+
() => writeInternalError(nodeRes),
|
|
436
|
+
)
|
|
437
|
+
: writeNodeOutcome(outcome, nodeRes)
|
|
438
|
+
} catch {
|
|
439
|
+
writeInternalError(nodeRes)
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const request = toWebRequest(nodeReq, protocol)
|
|
445
|
+
const resolveNode = (app as Partial<NodeFastHandler>).resolveNode
|
|
446
|
+
if (typeof resolveNode === "function") {
|
|
447
|
+
try {
|
|
448
|
+
const outcome = resolveNode.call(app, request)
|
|
449
|
+
return outcome instanceof Promise
|
|
450
|
+
? outcome.then(
|
|
451
|
+
(settled) => writeNodeOutcome(settled, nodeRes),
|
|
452
|
+
() => writeInternalError(nodeRes),
|
|
453
|
+
)
|
|
454
|
+
: writeNodeOutcome(outcome, nodeRes)
|
|
455
|
+
} catch {
|
|
456
|
+
writeInternalError(nodeRes)
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
const response = app.fetch(request)
|
|
463
|
+
return response instanceof Promise
|
|
464
|
+
? response.then(
|
|
465
|
+
(settled) => writeNodeResponse(settled, nodeRes),
|
|
466
|
+
() => writeInternalError(nodeRes),
|
|
467
|
+
)
|
|
468
|
+
: writeNodeResponse(response, nodeRes)
|
|
469
|
+
} catch {
|
|
470
|
+
// The app should never throw (nifra returns a 500), but never leak a stack to the wire.
|
|
471
|
+
writeInternalError(nodeRes)
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function writeNodeOutcome(
|
|
477
|
+
outcome: NodeServeOutcome,
|
|
478
|
+
nodeRes: ServerResponse,
|
|
479
|
+
): void | Promise<void> {
|
|
480
|
+
if (outcome.kind === "json") {
|
|
481
|
+
writeJsonOutcome(outcome, nodeRes)
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
if (outcome.kind === "body") {
|
|
485
|
+
writeBodyOutcome(outcome, nodeRes)
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
return writeNodeResponse(outcome.response, nodeRes)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** A flat 500 with no leaked detail — the adapter's last-resort guard if a handler throws. */
|
|
492
|
+
function writeInternalError(nodeRes: ServerResponse): void {
|
|
493
|
+
nodeRes.writeHead(500, { "content-type": "application/json" })
|
|
494
|
+
nodeRes.end(INTERNAL_ERROR_BODY)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Serialize a node-direct JSON outcome straight to the socket — no undici `Response`, no stream drain.
|
|
499
|
+
* Mirrors `Response.json(data, { status, headers })` byte-for-byte: user headers are lowercased to
|
|
500
|
+
* match undici's `Headers` normalization, the JSON `Content-Type` matches the host runtime's, and each
|
|
501
|
+
* queued cookie is emitted as its own `Set-Cookie` line (never comma-joined).
|
|
502
|
+
*/
|
|
503
|
+
function writeJsonOutcome(
|
|
504
|
+
outcome: Extract<NodeServeOutcome, { kind: "json" }>,
|
|
505
|
+
nodeRes: ServerResponse,
|
|
506
|
+
): void {
|
|
507
|
+
const headers: Record<string, string | string[]> = {}
|
|
508
|
+
if (outcome.headers !== undefined) {
|
|
509
|
+
for (const [key, value] of Object.entries(outcome.headers)) headers[key.toLowerCase()] = value
|
|
510
|
+
}
|
|
511
|
+
// A `null` body is a 204/no-content render — `new Response(null)` carries no Content-Type, so we add
|
|
512
|
+
// none either; a non-null body is JSON, matching `Response.json`'s Content-Type.
|
|
513
|
+
if (outcome.body !== null) headers["content-type"] = JSON_CONTENT_TYPE
|
|
514
|
+
if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
|
|
515
|
+
headers["set-cookie"] = [...outcome.cookies]
|
|
516
|
+
}
|
|
517
|
+
nodeRes.writeHead(outcome.status, headers)
|
|
518
|
+
if (outcome.body !== null) nodeRes.end(outcome.body)
|
|
519
|
+
else nodeRes.end()
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Write a node-direct buffered body outcome straight to the socket. This is the Response-shaped
|
|
524
|
+
* sibling of `writeJsonOutcome`: headers/status were already normalized by core, and the body is the
|
|
525
|
+
* exact marked payload from the Response producer, so there is no Web body reader to drain.
|
|
526
|
+
*/
|
|
527
|
+
function writeBodyOutcome(
|
|
528
|
+
outcome: Extract<NodeServeOutcome, { kind: "body" }>,
|
|
529
|
+
nodeRes: ServerResponse,
|
|
530
|
+
): void {
|
|
531
|
+
const headers: Record<string, string | string[]> = {}
|
|
532
|
+
if (outcome.headers !== undefined) {
|
|
533
|
+
for (const [key, value] of Object.entries(outcome.headers)) {
|
|
534
|
+
headers[key] = typeof value === "string" ? value : [...value]
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
nodeRes.writeHead(outcome.status, headers)
|
|
538
|
+
nodeRes.end(outcome.body)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function protocolResolver(option: RequestProtocolOption | undefined): RequestProtocolResolver {
|
|
542
|
+
if (option === undefined) return () => "http"
|
|
543
|
+
if (typeof option === "function") return (req) => normalizeProtocol(option(req))
|
|
544
|
+
const protocol = normalizeProtocol(option)
|
|
545
|
+
return () => protocol
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function normalizeProtocol(value: string): RequestProtocol {
|
|
549
|
+
if (value === "http" || value === "https") return value
|
|
550
|
+
throw new Error(
|
|
551
|
+
`@nifrajs/node: protocol must be "http" or "https" (got ${JSON.stringify(value)})`,
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function toWebRequest(req: IncomingMessage, protocol: RequestProtocol): Request {
|
|
556
|
+
const host = req.headers.host ?? "localhost"
|
|
557
|
+
const url = `${protocol}://${host}${req.url ?? "/"}`
|
|
558
|
+
const method = req.method ?? "GET"
|
|
559
|
+
return makeWebRequest(req, method, url, headersFromNode(req.headers))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function toNodeRequestSource(req: IncomingMessage, protocol: RequestProtocol): NodeRequestSource {
|
|
563
|
+
return new LazyNodeRequestSource(req, protocol)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Lazy view over a Node `IncomingMessage`. Methods live on the prototype (not re-allocated per
|
|
568
|
+
* request), and every materialization is deferred: the `Headers` object, the Web `ReadableStream`
|
|
569
|
+
* body, and the undici `Request` are each built only when first read. A bare GET that never touches
|
|
570
|
+
* `c.req` allocates just this instance.
|
|
571
|
+
*/
|
|
572
|
+
class LazyNodeRequestSource implements NodeRequestSource {
|
|
573
|
+
readonly method: string
|
|
574
|
+
readonly url: string
|
|
575
|
+
|
|
576
|
+
private headersValue: Headers | undefined
|
|
577
|
+
private bodyValue: ReadableStream<Uint8Array> | null | undefined
|
|
578
|
+
private requestValue: Request | undefined
|
|
579
|
+
private consumedBody: Buffer | undefined
|
|
580
|
+
private readBodyPromise: Promise<Buffer> | undefined
|
|
581
|
+
private readonly nodeReq: IncomingMessage
|
|
582
|
+
|
|
583
|
+
constructor(nodeReq: IncomingMessage, protocol: RequestProtocol) {
|
|
584
|
+
this.nodeReq = nodeReq
|
|
585
|
+
this.method = nodeReq.method ?? "GET"
|
|
586
|
+
const host = nodeReq.headers.host ?? "localhost"
|
|
587
|
+
this.url = `${protocol}://${host}${nodeReq.url ?? "/"}`
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
get headers(): Headers {
|
|
591
|
+
this.headersValue ??= headersFromNode(this.nodeReq.headers)
|
|
592
|
+
return this.headersValue
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Read straight off Node's already-lowercased header bag (comma-joining multi-values to match
|
|
596
|
+
// `Headers.get`) so the body-cap path can check content-type/length without building a `Headers`.
|
|
597
|
+
header(name: string): string | null {
|
|
598
|
+
const value = this.nodeReq.headers[name.toLowerCase()]
|
|
599
|
+
if (value === undefined) return null
|
|
600
|
+
return Array.isArray(value) ? value.join(", ") : value
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
get body(): ReadableStream<Uint8Array> | null {
|
|
604
|
+
if (this.method === "GET" || this.method === "HEAD") return null
|
|
605
|
+
if (this.consumedBody !== undefined) return this.request.body
|
|
606
|
+
this.bodyValue ??= Readable.toWeb(this.nodeReq) as ReadableStream<Uint8Array>
|
|
607
|
+
return this.bodyValue
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
arrayBuffer(): Promise<ArrayBuffer> {
|
|
611
|
+
if (this.requestValue !== undefined) return this.requestValue.arrayBuffer()
|
|
612
|
+
return this.readNodeBody().then(
|
|
613
|
+
(buffer) =>
|
|
614
|
+
buffer.buffer.slice(
|
|
615
|
+
buffer.byteOffset,
|
|
616
|
+
buffer.byteOffset + buffer.byteLength,
|
|
617
|
+
) as ArrayBuffer,
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
json(): Promise<unknown> {
|
|
622
|
+
if (this.requestValue !== undefined) return this.requestValue.json()
|
|
623
|
+
return this.readNodeBody().then((buffer) => JSON.parse(buffer.toString("utf8")) as unknown)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
get request(): Request {
|
|
627
|
+
if (this.requestValue !== undefined) return this.requestValue
|
|
628
|
+
if (this.consumedBody !== undefined) {
|
|
629
|
+
this.requestValue = makeWebRequest(
|
|
630
|
+
this.nodeReq,
|
|
631
|
+
this.method,
|
|
632
|
+
this.url,
|
|
633
|
+
this.headers,
|
|
634
|
+
this.consumedBody,
|
|
635
|
+
)
|
|
636
|
+
// Preserve one-shot body semantics if user code asks for `c.req` after nifra already consumed it.
|
|
637
|
+
void this.requestValue.arrayBuffer().catch(() => {})
|
|
638
|
+
return this.requestValue
|
|
639
|
+
}
|
|
640
|
+
this.requestValue = makeWebRequest(this.nodeReq, this.method, this.url, this.headers, this.body)
|
|
641
|
+
return this.requestValue
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Buffer the request body once. A single-chunk body (the common case) skips the array + `concat`;
|
|
645
|
+
// a client abort / socket error rejects (via `error`/`aborted`), which the body-cap callers catch
|
|
646
|
+
// and map to a flat 400.
|
|
647
|
+
private readNodeBody(): Promise<Buffer> {
|
|
648
|
+
if (this.consumedBody !== undefined) return Promise.resolve(this.consumedBody)
|
|
649
|
+
this.readBodyPromise ??= new Promise<Buffer>((resolve, reject) => {
|
|
650
|
+
let first: Buffer | undefined
|
|
651
|
+
let chunks: Buffer[] | undefined
|
|
652
|
+
let total = 0
|
|
653
|
+
const finish = (): void => {
|
|
654
|
+
cleanup()
|
|
655
|
+
this.consumedBody =
|
|
656
|
+
chunks === undefined ? (first ?? EMPTY_BUFFER) : Buffer.concat(chunks, total)
|
|
657
|
+
resolve(this.consumedBody)
|
|
658
|
+
}
|
|
659
|
+
const fail = (err: Error): void => {
|
|
660
|
+
cleanup()
|
|
661
|
+
reject(err)
|
|
662
|
+
}
|
|
663
|
+
const onData = (chunk: Buffer | string): void => {
|
|
664
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk
|
|
665
|
+
if (first === undefined) {
|
|
666
|
+
first = buffer
|
|
667
|
+
} else {
|
|
668
|
+
chunks ??= [first]
|
|
669
|
+
chunks.push(buffer)
|
|
670
|
+
}
|
|
671
|
+
total += buffer.byteLength
|
|
672
|
+
}
|
|
673
|
+
const onAborted = (): void => fail(new Error("request aborted"))
|
|
674
|
+
const cleanup = (): void => {
|
|
675
|
+
this.nodeReq.removeListener("data", onData)
|
|
676
|
+
this.nodeReq.removeListener("end", finish)
|
|
677
|
+
this.nodeReq.removeListener("error", fail)
|
|
678
|
+
this.nodeReq.removeListener("aborted", onAborted)
|
|
679
|
+
this.nodeReq.removeListener("close", onClose)
|
|
680
|
+
}
|
|
681
|
+
// `aborted` is deprecated on newer Node; `close`-without-`end` is the forward-compatible signal
|
|
682
|
+
// that the connection dropped mid-body. Either way the read rejects instead of hanging forever.
|
|
683
|
+
const onClose = (): void => {
|
|
684
|
+
if (this.consumedBody === undefined) fail(new Error("request closed before body completed"))
|
|
685
|
+
}
|
|
686
|
+
this.nodeReq.on("data", onData)
|
|
687
|
+
this.nodeReq.once("end", finish)
|
|
688
|
+
this.nodeReq.once("error", fail)
|
|
689
|
+
this.nodeReq.once("aborted", onAborted)
|
|
690
|
+
this.nodeReq.once("close", onClose)
|
|
691
|
+
})
|
|
692
|
+
return this.readBodyPromise
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function headersFromNode(input: IncomingHttpHeaders): Headers {
|
|
697
|
+
const headers = new Headers()
|
|
698
|
+
for (const [key, value] of Object.entries(input)) {
|
|
699
|
+
if (value === undefined) continue
|
|
700
|
+
headers.set(key, Array.isArray(value) ? value.join(", ") : value)
|
|
701
|
+
}
|
|
702
|
+
return headers
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function makeWebRequest(
|
|
706
|
+
req: IncomingMessage,
|
|
707
|
+
method: string,
|
|
708
|
+
url: string,
|
|
709
|
+
headers: Headers,
|
|
710
|
+
body?: ReadableStream<Uint8Array> | Uint8Array | null,
|
|
711
|
+
): Request {
|
|
712
|
+
const init: RequestInit & { duplex?: "half" } = { method, headers }
|
|
713
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
714
|
+
// Stream the body in; `duplex: "half"` is required for a streamed request body.
|
|
715
|
+
init.body = body ?? (Readable.toWeb(req) as ReadableStream<Uint8Array>)
|
|
716
|
+
init.duplex = "half"
|
|
717
|
+
}
|
|
718
|
+
return new Request(url, init)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function waitForDrain(nodeRes: ServerResponse): Promise<boolean> {
|
|
722
|
+
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
723
|
+
return Promise.resolve(false)
|
|
724
|
+
}
|
|
725
|
+
return new Promise((resolve) => {
|
|
726
|
+
const cleanup = (): void => {
|
|
727
|
+
nodeRes.removeListener("drain", onDrain)
|
|
728
|
+
nodeRes.removeListener("close", onClose)
|
|
729
|
+
nodeRes.removeListener("error", onError)
|
|
730
|
+
}
|
|
731
|
+
const onDrain = (): void => {
|
|
732
|
+
cleanup()
|
|
733
|
+
resolve(true)
|
|
734
|
+
}
|
|
735
|
+
const onClose = (): void => {
|
|
736
|
+
cleanup()
|
|
737
|
+
resolve(false)
|
|
738
|
+
}
|
|
739
|
+
const onError = (): void => {
|
|
740
|
+
cleanup()
|
|
741
|
+
resolve(false)
|
|
742
|
+
}
|
|
743
|
+
nodeRes.once("drain", onDrain)
|
|
744
|
+
nodeRes.once("close", onClose)
|
|
745
|
+
nodeRes.once("error", onError)
|
|
746
|
+
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
747
|
+
cleanup()
|
|
748
|
+
resolve(false)
|
|
749
|
+
}
|
|
750
|
+
})
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function writeNodeResponse(response: Response, nodeRes: ServerResponse): void | Promise<void> {
|
|
754
|
+
const headers: Record<string, string | string[]> = {}
|
|
755
|
+
response.headers.forEach((value, key) => {
|
|
756
|
+
headers[key] = value
|
|
757
|
+
})
|
|
758
|
+
// `Headers.forEach` comma-joins repeated `Set-Cookie` into one value — wrong, since a cookie's
|
|
759
|
+
// `Expires` attribute itself contains a comma (so a client can't safely split it back). Emit one
|
|
760
|
+
// header line per cookie via the un-joined `getSetCookie()` array (a response can set several:
|
|
761
|
+
// e.g. a session cookie + a CSRF cookie).
|
|
762
|
+
const setCookies = response.headers.getSetCookie?.()
|
|
763
|
+
if (setCookies !== undefined && setCookies.length > 0) headers["set-cookie"] = setCookies
|
|
764
|
+
nodeRes.writeHead(response.status, headers)
|
|
765
|
+
const directBody = nodeResponseBody(response)
|
|
766
|
+
if (directBody !== undefined) {
|
|
767
|
+
nodeRes.end(directBody)
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
if (response.body === null) {
|
|
771
|
+
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
|
|
772
|
+
return
|
|
773
|
+
}
|
|
774
|
+
return writeNodeResponseBody(response, nodeRes)
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function writeNodeResponseBody(response: Response, nodeRes: ServerResponse): Promise<void> {
|
|
778
|
+
const reader = response.body!.getReader()
|
|
779
|
+
try {
|
|
780
|
+
for (;;) {
|
|
781
|
+
const { done, value } = await reader.read()
|
|
782
|
+
if (done) break
|
|
783
|
+
if (!nodeRes.write(value)) {
|
|
784
|
+
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
785
|
+
try {
|
|
786
|
+
await reader.cancel()
|
|
787
|
+
} catch {}
|
|
788
|
+
return
|
|
789
|
+
}
|
|
790
|
+
const drained = await waitForDrain(nodeRes)
|
|
791
|
+
if (!drained || nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
792
|
+
try {
|
|
793
|
+
await reader.cancel()
|
|
794
|
+
} catch {}
|
|
795
|
+
return
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
} catch {
|
|
800
|
+
await reader.cancel().catch(() => {})
|
|
801
|
+
if (!nodeRes.headersSent) writeInternalError(nodeRes)
|
|
802
|
+
else nodeRes.destroy()
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function nodeResponseBody(response: Response): string | Uint8Array | undefined {
|
|
809
|
+
if (response.bodyUsed) return undefined
|
|
810
|
+
const body = (response as { readonly [NODE_RESPONSE_BODY]?: unknown })[NODE_RESPONSE_BODY]
|
|
811
|
+
return typeof body === "string" || body instanceof Uint8Array ? body : undefined
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// --- WebSocket bridge: Node has no built-in WS server, so upgrades go through the OPTIONAL `ws`
|
|
815
|
+
// package, lazy-imported on the first upgrade (non-WS apps never load it). ---
|
|
816
|
+
|
|
817
|
+
type WsServerCtor = new (options: { noServer: true }) => WsServer
|
|
818
|
+
|
|
819
|
+
/** A non-literal specifier so TS treats `import(...)` as `any` — `ws` is an optional peer with no
|
|
820
|
+
* `@types/ws` dependency here (the surface is structurally typed via {@link WsServer}/{@link WsSocket}). */
|
|
821
|
+
const WS_MODULE_SPECIFIER = "ws"
|
|
822
|
+
|
|
823
|
+
/** Lazily build a noServer `ws` `WebSocketServer`, or `undefined` if `ws` isn't installed. */
|
|
824
|
+
async function loadWsServer(): Promise<WsServer | undefined> {
|
|
825
|
+
try {
|
|
826
|
+
const mod = (await import(WS_MODULE_SPECIFIER)) as {
|
|
827
|
+
WebSocketServer?: WsServerCtor
|
|
828
|
+
default?: { WebSocketServer?: WsServerCtor }
|
|
829
|
+
}
|
|
830
|
+
const Ctor = mod.WebSocketServer ?? mod.default?.WebSocketServer
|
|
831
|
+
return Ctor === undefined ? undefined : new Ctor({ noServer: true })
|
|
832
|
+
} catch {
|
|
833
|
+
return undefined // `ws` not installed — caller responds 501
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const WS_STATUS_TEXT: Readonly<Record<number, string>> = {
|
|
838
|
+
400: "Bad Request",
|
|
839
|
+
401: "Unauthorized",
|
|
840
|
+
403: "Forbidden",
|
|
841
|
+
404: "Not Found",
|
|
842
|
+
426: "Upgrade Required",
|
|
843
|
+
500: "Internal Server Error",
|
|
844
|
+
501: "Not Implemented",
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Resolve a Node `upgrade` event: run the nifra upgrade guard, then either reject (write an HTTP error
|
|
848
|
+
* to the raw socket) or perform the `ws` upgrade and wire the socket to the handler. */
|
|
849
|
+
async function handleUpgrade(
|
|
850
|
+
resolveWs: (request: Request) => WsUpgradeOutcome | Promise<WsUpgradeOutcome>,
|
|
851
|
+
getProtocol: RequestProtocolResolver,
|
|
852
|
+
nodeReq: IncomingMessage,
|
|
853
|
+
socket: Duplex,
|
|
854
|
+
head: Buffer,
|
|
855
|
+
getWss: () => Promise<WsServer | undefined>,
|
|
856
|
+
): Promise<void> {
|
|
857
|
+
let outcome: WsUpgradeOutcome
|
|
858
|
+
try {
|
|
859
|
+
outcome = await resolveWs(toWebRequest(nodeReq, getProtocol(nodeReq)))
|
|
860
|
+
} catch {
|
|
861
|
+
writeUpgradeRejection(socket, 500, "internal_error")
|
|
862
|
+
return
|
|
863
|
+
}
|
|
864
|
+
if (outcome.kind === "pass") {
|
|
865
|
+
writeUpgradeRejection(socket, 404, "not_found") // upgrade to a path with no WS route
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
if (outcome.kind === "reject") {
|
|
869
|
+
await writeRejectionResponse(socket, outcome.response)
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
const wss = await getWss()
|
|
873
|
+
if (wss === undefined) {
|
|
874
|
+
writeUpgradeRejection(socket, 501, "websocket_unavailable") // `ws` not installed
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
const { handler, data, pubsub } = outcome
|
|
878
|
+
wss.handleUpgrade(nodeReq, socket, head, (ws) => attachNodeWebSocket(ws, handler, data, pubsub))
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Wire a `ws` socket (already open in `handleUpgrade`'s callback) to a nifra WS handler. The Node copy
|
|
882
|
+
* of core's `attachWebSocket` — binary frames normalize to `Uint8Array`; a thrown/rejected callback
|
|
883
|
+
* routes to `error()` and never crashes the connection. */
|
|
884
|
+
function attachNodeWebSocket(
|
|
885
|
+
ws: WsSocket,
|
|
886
|
+
handler: NifraWsHandler,
|
|
887
|
+
data: unknown,
|
|
888
|
+
pubsub: WsPubSub,
|
|
889
|
+
): void {
|
|
890
|
+
const nifra: NifraWs = {
|
|
891
|
+
send: (payload) => ws.send(payload),
|
|
892
|
+
close: (code, reason) => ws.close(code, reason),
|
|
893
|
+
get readyState() {
|
|
894
|
+
return ws.readyState
|
|
895
|
+
},
|
|
896
|
+
subscribe: (topic) => pubsub.subscribe(topic, nifra),
|
|
897
|
+
unsubscribe: (topic) => pubsub.unsubscribe(topic, nifra),
|
|
898
|
+
data,
|
|
899
|
+
raw: ws,
|
|
900
|
+
}
|
|
901
|
+
const reportError = (error: unknown): void => {
|
|
902
|
+
if (handler.error === undefined) return
|
|
903
|
+
try {
|
|
904
|
+
const r = handler.error(nifra, error)
|
|
905
|
+
if (r instanceof Promise) r.catch(() => {})
|
|
906
|
+
} catch {
|
|
907
|
+
/* the error handler itself failed — last resort, swallow */
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const safe = (call: () => void | Promise<void>): void => {
|
|
911
|
+
try {
|
|
912
|
+
const r = call()
|
|
913
|
+
if (r instanceof Promise) r.catch(reportError)
|
|
914
|
+
} catch (error) {
|
|
915
|
+
reportError(error)
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
ws.on("message", (raw, isBinary) => {
|
|
919
|
+
const payload: NifraWsData = isBinary
|
|
920
|
+
? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
|
|
921
|
+
: raw.toString()
|
|
922
|
+
safe(() => handler.message?.(nifra, payload))
|
|
923
|
+
})
|
|
924
|
+
ws.on("close", (code, reason) => {
|
|
925
|
+
pubsub.unsubscribeAll(nifra) // drop topic subscriptions so the registry never holds a dead socket
|
|
926
|
+
safe(() => handler.close?.(nifra, code, reason.toString()))
|
|
927
|
+
})
|
|
928
|
+
ws.on("error", (error) => reportError(error))
|
|
929
|
+
safe(() => handler.open?.(nifra)) // open: the socket is already established here
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** Write a minimal JSON error response to a raw upgrade socket, then close it. */
|
|
933
|
+
function writeUpgradeRejection(socket: Duplex, status: number, error: string): void {
|
|
934
|
+
const body = JSON.stringify({ ok: false, error })
|
|
935
|
+
socket.write(
|
|
936
|
+
`HTTP/1.1 ${status} ${WS_STATUS_TEXT[status] ?? "Error"}\r\n` +
|
|
937
|
+
`Content-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\n` +
|
|
938
|
+
"Connection: close\r\n\r\n" +
|
|
939
|
+
body,
|
|
940
|
+
)
|
|
941
|
+
socket.destroy()
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** Serialize a nifra guard's rejection `Response` (e.g. a 401) to a raw upgrade socket, then close. */
|
|
945
|
+
async function writeRejectionResponse(socket: Duplex, response: Response): Promise<void> {
|
|
946
|
+
const body = await response.text()
|
|
947
|
+
let head = `HTTP/1.1 ${response.status} ${response.statusText || WS_STATUS_TEXT[response.status] || "Error"}\r\n`
|
|
948
|
+
response.headers.forEach((value, key) => {
|
|
949
|
+
if (key.toLowerCase() !== "content-length") head += `${key}: ${value}\r\n`
|
|
950
|
+
})
|
|
951
|
+
head += `Content-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n`
|
|
952
|
+
socket.write(head + body)
|
|
953
|
+
socket.destroy()
|
|
954
|
+
}
|