@nifrajs/node 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/index.d.ts +11 -11
- package/dist/index.js +15 -15
- package/package.json +2 -2
- package/src/index.ts +30 -30
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @nifrajs/node
|
|
2
2
|
|
|
3
|
-
Run a [nifra](../../README.md) app on Node's `http` server
|
|
3
|
+
Run a [nifra](../../README.md) app on Node's `http` server - nifra's ergonomics, deployed
|
|
4
4
|
on Node instead of Bun.
|
|
5
5
|
|
|
6
6
|
```sh
|
|
@@ -19,7 +19,7 @@ const node = await serve(app, { port: 3000 })
|
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
`serve(app, { port })` bridges Node's stream-based `(req, res)` to/from the Web
|
|
22
|
-
`Request`/`Response` that `app.fetch` speaks
|
|
22
|
+
`Request`/`Response` that `app.fetch` speaks - that's the only Bun-specific seam, since
|
|
23
23
|
nifra's lifecycle is already Web-standard. It also gives you a graceful `stop()`, the
|
|
24
24
|
Node equivalent of Bun's `app.listen()`.
|
|
25
25
|
|
|
@@ -45,7 +45,7 @@ await serve(app, {
|
|
|
45
45
|
## Graceful shutdown on signals
|
|
46
46
|
|
|
47
47
|
Opt in to SIGTERM/SIGINT handling so a `docker stop` / Ctrl-C drains in-flight requests
|
|
48
|
-
before exit (mirrors Bun's `listen({ gracefulSignals })`
|
|
48
|
+
before exit (mirrors Bun's `listen({ gracefulSignals })` - off by default, since owning
|
|
49
49
|
process signals should be a deliberate choice):
|
|
50
50
|
|
|
51
51
|
```ts
|
|
@@ -55,7 +55,7 @@ await serve(app, { port: 3000, signals: true })
|
|
|
55
55
|
## What you get for free
|
|
56
56
|
|
|
57
57
|
The request timeout (`server({ requestTimeoutMs })` → `503 request_timeout`) and body-size
|
|
58
|
-
cap live **inside `app.fetch`**, not Bun's `listen()`
|
|
58
|
+
cap live **inside `app.fetch`**, not Bun's `listen()` - so they apply through this adapter
|
|
59
59
|
with no extra wiring. Slow-client / slow-loris protection is Node's built-in
|
|
60
60
|
`requestTimeout` (300s) and `headersTimeout` (60s) defaults.
|
|
61
61
|
|
|
@@ -63,7 +63,7 @@ Works with any `{ fetch(req): Promise<Response> }` handler, not just nifra. ESM-
|
|
|
63
63
|
|
|
64
64
|
## For AI agents
|
|
65
65
|
|
|
66
|
-
Start with [`LLM.md`](./LLM.md)
|
|
66
|
+
Start with [`LLM.md`](./LLM.md) - this package's contract card (the exports you call + its footguns),
|
|
67
67
|
one cheap read instead of the whole corpus. For the wider framework: the repo's
|
|
68
68
|
[`AGENTS.md`](../../AGENTS.md) is the copy-paste quick reference, and
|
|
69
69
|
[`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus. Run `nifra check` as the
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { type IncomingMessage } from "node:http";
|
|
2
|
-
/** The runtime platform a nifra app accepts as `fetch`'s 2nd arg
|
|
2
|
+
/** The runtime platform a nifra app accepts as `fetch`'s 2nd arg - here, the observed socket peer. */
|
|
3
3
|
interface NodePlatform {
|
|
4
4
|
readonly clientIp?: string;
|
|
5
5
|
}
|
|
6
|
-
/** Anything exposing a Web `fetch` handler
|
|
6
|
+
/** Anything exposing a Web `fetch` handler - a nifra `app`, for instance. */
|
|
7
7
|
export interface FetchHandler {
|
|
8
8
|
fetch(request: Request, platform?: NodePlatform): Response | Promise<Response>;
|
|
9
9
|
/** Nifra apps also expose this WS-upgrade seam; present → this adapter serves `app.ws()` routes via
|
|
@@ -23,20 +23,20 @@ interface NifraWs {
|
|
|
23
23
|
data: unknown;
|
|
24
24
|
readonly raw: unknown;
|
|
25
25
|
}
|
|
26
|
-
/** Mirror of core's `TopicRegistry` surface
|
|
26
|
+
/** Mirror of core's `TopicRegistry` surface - the app's pub/sub the adapter wires `ws.subscribe` to. */
|
|
27
27
|
interface WsPubSub {
|
|
28
28
|
subscribe(topic: string, ws: NifraWs): void;
|
|
29
29
|
unsubscribe(topic: string, ws: NifraWs): void;
|
|
30
30
|
unsubscribeAll(ws: NifraWs): void;
|
|
31
31
|
}
|
|
32
|
-
/** A nifra WS route's lifecycle (mirror of core's `WebSocketHandler`
|
|
32
|
+
/** A nifra WS route's lifecycle (mirror of core's `WebSocketHandler` - the post-upgrade callbacks). */
|
|
33
33
|
interface NifraWsHandler {
|
|
34
34
|
open?(ws: NifraWs): void | Promise<void>;
|
|
35
35
|
message?(ws: NifraWs, data: NifraWsData): void | Promise<void>;
|
|
36
36
|
close?(ws: NifraWs, code: number, reason: string): void | Promise<void>;
|
|
37
37
|
error?(ws: NifraWs, error: unknown): void | Promise<void>;
|
|
38
38
|
}
|
|
39
|
-
/** Mirror of core's `WebSocketUpgradeOutcome`
|
|
39
|
+
/** Mirror of core's `WebSocketUpgradeOutcome` - what `resolveWebSocketUpgrade` returns. */
|
|
40
40
|
type WsUpgradeOutcome = {
|
|
41
41
|
readonly kind: "pass";
|
|
42
42
|
} | {
|
|
@@ -51,16 +51,16 @@ type WsUpgradeOutcome = {
|
|
|
51
51
|
export type RequestProtocol = "http" | "https";
|
|
52
52
|
export type RequestProtocolOption = RequestProtocol | ((request: IncomingMessage) => RequestProtocol);
|
|
53
53
|
/**
|
|
54
|
-
* Serve static files from a directory (e.g. the client build) under a URL prefix
|
|
54
|
+
* Serve static files from a directory (e.g. the client build) under a URL prefix - so a self-hosted
|
|
55
55
|
* Node deploy doesn't need a CDN or a hand-rolled `/assets/*` handler. (On Cloudflare/Vercel the
|
|
56
56
|
* platform serves assets; this is for `node server.js`.)
|
|
57
57
|
*/
|
|
58
58
|
export interface ServeStaticOptions {
|
|
59
|
-
/** Directory to read files from
|
|
59
|
+
/** Directory to read files from - an absolute path or a `file://` URL (`new URL("./assets/", import.meta.url)`). */
|
|
60
60
|
readonly dir: string | URL;
|
|
61
61
|
/** URL prefix these files are served under. Default `"/assets"`. Use `"/"` to serve the whole dir. */
|
|
62
62
|
readonly prefix?: string;
|
|
63
|
-
/** Emit `cache-control: public, max-age=31536000, immutable`
|
|
63
|
+
/** Emit `cache-control: public, max-age=31536000, immutable` - correct for content-hashed files. Default `true`. */
|
|
64
64
|
readonly immutable?: boolean;
|
|
65
65
|
/** Extra headers merged onto every served file. */
|
|
66
66
|
readonly headers?: Readonly<Record<string, string>>;
|
|
@@ -78,17 +78,17 @@ export interface ServeOptions {
|
|
|
78
78
|
readonly protocol?: RequestProtocolOption;
|
|
79
79
|
/**
|
|
80
80
|
* Install SIGTERM/SIGINT handlers that call `stop()` for a graceful drain on
|
|
81
|
-
* `docker stop` / Ctrl-C. Off by default
|
|
81
|
+
* `docker stop` / Ctrl-C. Off by default - taking over process signals is opt-in,
|
|
82
82
|
* mirroring nifra's Bun `listen({ gracefulSignals })`.
|
|
83
83
|
*
|
|
84
84
|
* The app-level request timeout (`server({ requestTimeoutMs })` → 503) and body cap
|
|
85
|
-
* are *not* set here
|
|
85
|
+
* are *not* set here - they live inside `app.fetch`, so they already apply through
|
|
86
86
|
* this adapter. Slow-client protection is Node's built-in `requestTimeout` (300s) /
|
|
87
87
|
* `headersTimeout` (60s) defaults.
|
|
88
88
|
*/
|
|
89
89
|
readonly signals?: boolean;
|
|
90
90
|
/**
|
|
91
|
-
* Serve static files from disk for matching GET/HEAD requests *before* the app runs
|
|
91
|
+
* Serve static files from disk for matching GET/HEAD requests *before* the app runs - non-matching
|
|
92
92
|
* requests fall through to `app.fetch` with the node-direct fast path intact (no perf regression on
|
|
93
93
|
* SSR/API routes). Replaces the hand-rolled `/assets/*` reader in self-hosted entries.
|
|
94
94
|
*/
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
14
14
|
* const app = server().get("/", () => ({ ok: true }))
|
|
15
15
|
* serve(app, { port: 3000 })
|
|
16
16
|
*
|
|
17
|
-
* nifra's lifecycle is `app.fetch(Request): Response | Promise<Response>`
|
|
17
|
+
* nifra's lifecycle is `app.fetch(Request): Response | Promise<Response>` - pure Web Standards -
|
|
18
18
|
* so this adapter just bridges Node's stream-based `(req, res)` to/from Web
|
|
19
19
|
* `Request`/`Response`, plus a Bun-`listen()`-style graceful `stop()`.
|
|
20
20
|
*/
|
|
@@ -24,7 +24,7 @@ import { extname, resolve, sep } from "node:path";
|
|
|
24
24
|
import { Readable } from "node:stream";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
26
|
/**
|
|
27
|
-
* The `Content-Type` the host runtime's `Response.json` emits
|
|
27
|
+
* The `Content-Type` the host runtime's `Response.json` emits - Node's undici uses `application/json`,
|
|
28
28
|
* Bun uses `application/json;charset=utf-8`. Probed once at module load (zero per-request cost) so the
|
|
29
29
|
* fast path is byte-for-byte identical to the `Response`-building path on whatever runtime hosts us.
|
|
30
30
|
*/
|
|
@@ -140,7 +140,7 @@ function staticStateOf(options) {
|
|
|
140
140
|
return { root, prefix, immutable: options.immutable !== false, headers: options.headers };
|
|
141
141
|
}
|
|
142
142
|
/**
|
|
143
|
-
* Resolve a request URL to a file under the served root
|
|
143
|
+
* Resolve a request URL to a file under the served root - **synchronously**, so non-matching requests
|
|
144
144
|
* stay on the app's sync fast path. Returns `"pass"` (let the app handle it), a rejection `Response`
|
|
145
145
|
* (malformed encoding / NUL / `..` traversal out of root), or a confined absolute file path to read.
|
|
146
146
|
*/
|
|
@@ -163,7 +163,7 @@ function staticMatch(state, rawUrl) {
|
|
|
163
163
|
if (rel.includes("\0"))
|
|
164
164
|
return { reject: new Response("Bad Request", { status: 400 }) };
|
|
165
165
|
const file = resolve(state.root, rel);
|
|
166
|
-
// Confine to the served directory
|
|
166
|
+
// Confine to the served directory - block `..` from escaping root.
|
|
167
167
|
if (file !== state.root && !file.startsWith(state.root + sep)) {
|
|
168
168
|
return { reject: new Response("Forbidden", { status: 403 }) };
|
|
169
169
|
}
|
|
@@ -211,7 +211,7 @@ async function readStatic(file, state, method) {
|
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
/**
|
|
214
|
-
* Serve a Web-`fetch` app on a Node `http` server. Resolves once bound
|
|
214
|
+
* Serve a Web-`fetch` app on a Node `http` server. Resolves once bound - Node binds
|
|
215
215
|
* the port asynchronously, so awaiting gives you the real port (matters for `port: 0`).
|
|
216
216
|
*/
|
|
217
217
|
// The node-direct fast path renders plain data instead of building + draining a `Response`. Serving on
|
|
@@ -256,7 +256,7 @@ export function serve(app, options) {
|
|
|
256
256
|
}
|
|
257
257
|
});
|
|
258
258
|
// WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
|
|
259
|
-
// the optional `ws` package
|
|
259
|
+
// the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
|
|
260
260
|
// upgrade, so a non-WS Node app never loads `ws`.
|
|
261
261
|
const resolveWs = app.resolveWebSocketUpgrade?.bind(app);
|
|
262
262
|
if (resolveWs !== undefined) {
|
|
@@ -326,7 +326,7 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState) {
|
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
328
|
// Fast path: a nifra app exposes `resolveNode`, which renders a plain-data result as primitives we
|
|
329
|
-
// write straight to the socket
|
|
329
|
+
// write straight to the socket - skipping the undici `Response` build + body drain (the bulk of the
|
|
330
330
|
// Web-bridge cost on Node). A handler-returned `Response`/redirect, 404/405, error, timeout, or any
|
|
331
331
|
// `onResponse` hook comes back as `{ kind: "response" }` and takes the same Web path as before, so
|
|
332
332
|
// behavior is identical. A plain `{ fetch }` handler (no `resolveNode`) uses the Web path too.
|
|
@@ -380,13 +380,13 @@ function writeNodeOutcome(outcome, nodeRes) {
|
|
|
380
380
|
}
|
|
381
381
|
return writeNodeResponse(outcome.response, nodeRes);
|
|
382
382
|
}
|
|
383
|
-
/** A flat 500 with no leaked detail
|
|
383
|
+
/** A flat 500 with no leaked detail - the adapter's last-resort guard if a handler throws. */
|
|
384
384
|
function writeInternalError(nodeRes) {
|
|
385
385
|
nodeRes.writeHead(500, { "content-type": "application/json" });
|
|
386
386
|
nodeRes.end(INTERNAL_ERROR_BODY);
|
|
387
387
|
}
|
|
388
388
|
/**
|
|
389
|
-
* Serialize a node-direct JSON outcome straight to the socket
|
|
389
|
+
* Serialize a node-direct JSON outcome straight to the socket - no undici `Response`, no stream drain.
|
|
390
390
|
* Mirrors `Response.json(data, { status, headers })` byte-for-byte: user headers are lowercased to
|
|
391
391
|
* match undici's `Headers` normalization, the JSON `Content-Type` matches the host runtime's, and each
|
|
392
392
|
* queued cookie is emitted as its own `Set-Cookie` line (never comma-joined).
|
|
@@ -397,7 +397,7 @@ function writeJsonOutcome(outcome, nodeRes) {
|
|
|
397
397
|
for (const [key, value] of Object.entries(outcome.headers))
|
|
398
398
|
headers[key.toLowerCase()] = value;
|
|
399
399
|
}
|
|
400
|
-
// A `null` body is a 204/no-content render
|
|
400
|
+
// A `null` body is a 204/no-content render - `new Response(null)` carries no Content-Type, so we add
|
|
401
401
|
// none either; a non-null body is JSON, matching `Response.json`'s Content-Type.
|
|
402
402
|
if (outcome.body !== null)
|
|
403
403
|
headers["content-type"] = JSON_CONTENT_TYPE;
|
|
@@ -661,7 +661,7 @@ function writeNodeResponse(response, nodeRes) {
|
|
|
661
661
|
response.headers.forEach((value, key) => {
|
|
662
662
|
headers[key] = value;
|
|
663
663
|
});
|
|
664
|
-
// `Headers.forEach` comma-joins repeated `Set-Cookie` into one value
|
|
664
|
+
// `Headers.forEach` comma-joins repeated `Set-Cookie` into one value - wrong, since a cookie's
|
|
665
665
|
// `Expires` attribute itself contains a comma (so a client can't safely split it back). Emit one
|
|
666
666
|
// header line per cookie via the un-joined `getSetCookie()` array (a response can set several:
|
|
667
667
|
// e.g. a session cookie + a CSRF cookie).
|
|
@@ -724,7 +724,7 @@ function nodeResponseBody(response) {
|
|
|
724
724
|
const body = response[NODE_RESPONSE_BODY];
|
|
725
725
|
return typeof body === "string" || body instanceof Uint8Array ? body : undefined;
|
|
726
726
|
}
|
|
727
|
-
/** A non-literal specifier so TS treats `import(...)` as `any`
|
|
727
|
+
/** A non-literal specifier so TS treats `import(...)` as `any` - `ws` is an optional peer with no
|
|
728
728
|
* `@types/ws` dependency here (the surface is structurally typed via {@link WsServer}/{@link WsSocket}). */
|
|
729
729
|
const WS_MODULE_SPECIFIER = "ws";
|
|
730
730
|
/** Lazily build a noServer `ws` `WebSocketServer`, or `undefined` if `ws` isn't installed. */
|
|
@@ -735,7 +735,7 @@ async function loadWsServer() {
|
|
|
735
735
|
return Ctor === undefined ? undefined : new Ctor({ noServer: true });
|
|
736
736
|
}
|
|
737
737
|
catch {
|
|
738
|
-
return undefined; // `ws` not installed
|
|
738
|
+
return undefined; // `ws` not installed - caller responds 501
|
|
739
739
|
}
|
|
740
740
|
}
|
|
741
741
|
const WS_STATUS_TEXT = {
|
|
@@ -775,7 +775,7 @@ async function handleUpgrade(resolveWs, getProtocol, nodeReq, socket, head, getW
|
|
|
775
775
|
wss.handleUpgrade(nodeReq, socket, head, (ws) => attachNodeWebSocket(ws, handler, data, pubsub));
|
|
776
776
|
}
|
|
777
777
|
/** Wire a `ws` socket (already open in `handleUpgrade`'s callback) to a nifra WS handler. The Node copy
|
|
778
|
-
* of core's `attachWebSocket`
|
|
778
|
+
* of core's `attachWebSocket` - binary frames normalize to `Uint8Array`; a thrown/rejected callback
|
|
779
779
|
* routes to `error()` and never crashes the connection. */
|
|
780
780
|
function attachNodeWebSocket(ws, handler, data, pubsub) {
|
|
781
781
|
const nifra = {
|
|
@@ -798,7 +798,7 @@ function attachNodeWebSocket(ws, handler, data, pubsub) {
|
|
|
798
798
|
r.catch(() => { });
|
|
799
799
|
}
|
|
800
800
|
catch {
|
|
801
|
-
/* the error handler itself failed
|
|
801
|
+
/* the error handler itself failed - last resort, swallow */
|
|
802
802
|
}
|
|
803
803
|
};
|
|
804
804
|
const safe = (call) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/node",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Run a nifra app on Node's http server
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "Run a nifra app on Node's http server - a Request<->stream adapter with a graceful stop().",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
7
7
|
"typescript",
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* const app = server().get("/", () => ({ ok: true }))
|
|
7
7
|
* serve(app, { port: 3000 })
|
|
8
8
|
*
|
|
9
|
-
* nifra's lifecycle is `app.fetch(Request): Response | Promise<Response>`
|
|
9
|
+
* nifra's lifecycle is `app.fetch(Request): Response | Promise<Response>` - pure Web Standards -
|
|
10
10
|
* so this adapter just bridges Node's stream-based `(req, res)` to/from Web
|
|
11
11
|
* `Request`/`Response`, plus a Bun-`listen()`-style graceful `stop()`.
|
|
12
12
|
*/
|
|
@@ -21,12 +21,12 @@ import { extname, resolve, sep } from "node:path"
|
|
|
21
21
|
import { type Duplex, Readable } from "node:stream"
|
|
22
22
|
import { fileURLToPath } from "node:url"
|
|
23
23
|
|
|
24
|
-
/** The runtime platform a nifra app accepts as `fetch`'s 2nd arg
|
|
24
|
+
/** The runtime platform a nifra app accepts as `fetch`'s 2nd arg - here, the observed socket peer. */
|
|
25
25
|
interface NodePlatform {
|
|
26
26
|
readonly clientIp?: string
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** Anything exposing a Web `fetch` handler
|
|
29
|
+
/** Anything exposing a Web `fetch` handler - a nifra `app`, for instance. */
|
|
30
30
|
export interface FetchHandler {
|
|
31
31
|
fetch(request: Request, platform?: NodePlatform): Response | Promise<Response>
|
|
32
32
|
/** Nifra apps also expose this WS-upgrade seam; present → this adapter serves `app.ws()` routes via
|
|
@@ -36,7 +36,7 @@ export interface FetchHandler {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// --- WebSocket types: structurally mirrored from @nifrajs/core (this adapter has no @nifrajs/core
|
|
39
|
-
// dependency
|
|
39
|
+
// dependency - see NodeServeOutcome above). Kept in lockstep by the WS integration test. ---
|
|
40
40
|
|
|
41
41
|
/** A received frame, normalized: text → `string`, binary → `Uint8Array`. */
|
|
42
42
|
type NifraWsData = string | Uint8Array
|
|
@@ -52,14 +52,14 @@ interface NifraWs {
|
|
|
52
52
|
readonly raw: unknown
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/** Mirror of core's `TopicRegistry` surface
|
|
55
|
+
/** Mirror of core's `TopicRegistry` surface - the app's pub/sub the adapter wires `ws.subscribe` to. */
|
|
56
56
|
interface WsPubSub {
|
|
57
57
|
subscribe(topic: string, ws: NifraWs): void
|
|
58
58
|
unsubscribe(topic: string, ws: NifraWs): void
|
|
59
59
|
unsubscribeAll(ws: NifraWs): void
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** A nifra WS route's lifecycle (mirror of core's `WebSocketHandler`
|
|
62
|
+
/** A nifra WS route's lifecycle (mirror of core's `WebSocketHandler` - the post-upgrade callbacks). */
|
|
63
63
|
interface NifraWsHandler {
|
|
64
64
|
open?(ws: NifraWs): void | Promise<void>
|
|
65
65
|
message?(ws: NifraWs, data: NifraWsData): void | Promise<void>
|
|
@@ -67,7 +67,7 @@ interface NifraWsHandler {
|
|
|
67
67
|
error?(ws: NifraWs, error: unknown): void | Promise<void>
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/** Mirror of core's `WebSocketUpgradeOutcome`
|
|
70
|
+
/** Mirror of core's `WebSocketUpgradeOutcome` - what `resolveWebSocketUpgrade` returns. */
|
|
71
71
|
type WsUpgradeOutcome =
|
|
72
72
|
| { readonly kind: "pass" }
|
|
73
73
|
| { readonly kind: "reject"; readonly response: Response }
|
|
@@ -78,7 +78,7 @@ type WsUpgradeOutcome =
|
|
|
78
78
|
readonly pubsub: WsPubSub
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/** Structural view of the `ws` package's `WebSocket` (no `@types/ws` dependency
|
|
81
|
+
/** Structural view of the `ws` package's `WebSocket` (no `@types/ws` dependency - see `loadWsServer`). */
|
|
82
82
|
interface WsSocket {
|
|
83
83
|
send(data: string | ArrayBufferView | ArrayBuffer): void
|
|
84
84
|
close(code?: number, reason?: string): void
|
|
@@ -105,7 +105,7 @@ export type RequestProtocolOption =
|
|
|
105
105
|
type RequestProtocolResolver = (request: IncomingMessage) => RequestProtocol
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* The node-direct render returned by a nifra app's `resolveNode`
|
|
108
|
+
* The node-direct render returned by a nifra app's `resolveNode` - structurally mirrored here so this
|
|
109
109
|
* adapter stays decoupled from `@nifrajs/core` (it bridges *any* handler exposing this seam, and has no
|
|
110
110
|
* runtime dependency on nifra). A `kind: "json"` outcome is a plain-data result we serialize straight to
|
|
111
111
|
* the socket; a `kind: "body"` outcome is a marked buffered response body (notably @nifrajs/web's
|
|
@@ -153,7 +153,7 @@ interface NodeOutcomeRuntime {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
/** A `FetchHandler` that *also* exposes the node-direct fast path (every nifra app does). May resolve
|
|
156
|
-
* **synchronously** (a bare route + sync handler allocates no promise)
|
|
156
|
+
* **synchronously** (a bare route + sync handler allocates no promise) - we `await` it regardless. */
|
|
157
157
|
interface NodeFastHandler extends FetchHandler {
|
|
158
158
|
resolveNode(
|
|
159
159
|
request: Request,
|
|
@@ -167,7 +167,7 @@ interface NodeFastHandler extends FetchHandler {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
|
-
* The `Content-Type` the host runtime's `Response.json` emits
|
|
170
|
+
* The `Content-Type` the host runtime's `Response.json` emits - Node's undici uses `application/json`,
|
|
171
171
|
* Bun uses `application/json;charset=utf-8`. Probed once at module load (zero per-request cost) so the
|
|
172
172
|
* fast path is byte-for-byte identical to the `Response`-building path on whatever runtime hosts us.
|
|
173
173
|
*/
|
|
@@ -280,16 +280,16 @@ function appendCookiesToNodeHeaders(
|
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
/**
|
|
283
|
-
* Serve static files from a directory (e.g. the client build) under a URL prefix
|
|
283
|
+
* Serve static files from a directory (e.g. the client build) under a URL prefix - so a self-hosted
|
|
284
284
|
* Node deploy doesn't need a CDN or a hand-rolled `/assets/*` handler. (On Cloudflare/Vercel the
|
|
285
285
|
* platform serves assets; this is for `node server.js`.)
|
|
286
286
|
*/
|
|
287
287
|
export interface ServeStaticOptions {
|
|
288
|
-
/** Directory to read files from
|
|
288
|
+
/** Directory to read files from - an absolute path or a `file://` URL (`new URL("./assets/", import.meta.url)`). */
|
|
289
289
|
readonly dir: string | URL
|
|
290
290
|
/** URL prefix these files are served under. Default `"/assets"`. Use `"/"` to serve the whole dir. */
|
|
291
291
|
readonly prefix?: string
|
|
292
|
-
/** Emit `cache-control: public, max-age=31536000, immutable`
|
|
292
|
+
/** Emit `cache-control: public, max-age=31536000, immutable` - correct for content-hashed files. Default `true`. */
|
|
293
293
|
readonly immutable?: boolean
|
|
294
294
|
/** Extra headers merged onto every served file. */
|
|
295
295
|
readonly headers?: Readonly<Record<string, string>>
|
|
@@ -308,17 +308,17 @@ export interface ServeOptions {
|
|
|
308
308
|
readonly protocol?: RequestProtocolOption
|
|
309
309
|
/**
|
|
310
310
|
* Install SIGTERM/SIGINT handlers that call `stop()` for a graceful drain on
|
|
311
|
-
* `docker stop` / Ctrl-C. Off by default
|
|
311
|
+
* `docker stop` / Ctrl-C. Off by default - taking over process signals is opt-in,
|
|
312
312
|
* mirroring nifra's Bun `listen({ gracefulSignals })`.
|
|
313
313
|
*
|
|
314
314
|
* The app-level request timeout (`server({ requestTimeoutMs })` → 503) and body cap
|
|
315
|
-
* are *not* set here
|
|
315
|
+
* are *not* set here - they live inside `app.fetch`, so they already apply through
|
|
316
316
|
* this adapter. Slow-client protection is Node's built-in `requestTimeout` (300s) /
|
|
317
317
|
* `headersTimeout` (60s) defaults.
|
|
318
318
|
*/
|
|
319
319
|
readonly signals?: boolean
|
|
320
320
|
/**
|
|
321
|
-
* Serve static files from disk for matching GET/HEAD requests *before* the app runs
|
|
321
|
+
* Serve static files from disk for matching GET/HEAD requests *before* the app runs - non-matching
|
|
322
322
|
* requests fall through to `app.fetch` with the node-direct fast path intact (no perf regression on
|
|
323
323
|
* SSR/API routes). Replaces the hand-rolled `/assets/*` reader in self-hosted entries.
|
|
324
324
|
*/
|
|
@@ -377,7 +377,7 @@ function staticStateOf(options: ServeStaticOptions): StaticState {
|
|
|
377
377
|
}
|
|
378
378
|
|
|
379
379
|
/**
|
|
380
|
-
* Resolve a request URL to a file under the served root
|
|
380
|
+
* Resolve a request URL to a file under the served root - **synchronously**, so non-matching requests
|
|
381
381
|
* stay on the app's sync fast path. Returns `"pass"` (let the app handle it), a rejection `Response`
|
|
382
382
|
* (malformed encoding / NUL / `..` traversal out of root), or a confined absolute file path to read.
|
|
383
383
|
*/
|
|
@@ -400,7 +400,7 @@ function staticMatch(
|
|
|
400
400
|
if (rel === "" || rel.endsWith("/")) return "pass" // a directory request → the app decides
|
|
401
401
|
if (rel.includes("\0")) return { reject: new Response("Bad Request", { status: 400 }) }
|
|
402
402
|
const file = resolve(state.root, rel)
|
|
403
|
-
// Confine to the served directory
|
|
403
|
+
// Confine to the served directory - block `..` from escaping root.
|
|
404
404
|
if (file !== state.root && !file.startsWith(state.root + sep)) {
|
|
405
405
|
return { reject: new Response("Forbidden", { status: 403 }) }
|
|
406
406
|
}
|
|
@@ -453,7 +453,7 @@ async function readStatic(
|
|
|
453
453
|
}
|
|
454
454
|
|
|
455
455
|
/**
|
|
456
|
-
* Serve a Web-`fetch` app on a Node `http` server. Resolves once bound
|
|
456
|
+
* Serve a Web-`fetch` app on a Node `http` server. Resolves once bound - Node binds
|
|
457
457
|
* the port asynchronously, so awaiting gives you the real port (matters for `port: 0`).
|
|
458
458
|
*/
|
|
459
459
|
// The node-direct fast path renders plain data instead of building + draining a `Response`. Serving on
|
|
@@ -496,7 +496,7 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
|
|
|
496
496
|
})
|
|
497
497
|
|
|
498
498
|
// WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
|
|
499
|
-
// the optional `ws` package
|
|
499
|
+
// the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
|
|
500
500
|
// upgrade, so a non-WS Node app never loads `ws`.
|
|
501
501
|
const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
|
|
502
502
|
if (resolveWs !== undefined) {
|
|
@@ -580,7 +580,7 @@ function handle(
|
|
|
580
580
|
}
|
|
581
581
|
|
|
582
582
|
// Fast path: a nifra app exposes `resolveNode`, which renders a plain-data result as primitives we
|
|
583
|
-
// write straight to the socket
|
|
583
|
+
// write straight to the socket - skipping the undici `Response` build + body drain (the bulk of the
|
|
584
584
|
// Web-bridge cost on Node). A handler-returned `Response`/redirect, 404/405, error, timeout, or any
|
|
585
585
|
// `onResponse` hook comes back as `{ kind: "response" }` and takes the same Web path as before, so
|
|
586
586
|
// behavior is identical. A plain `{ fetch }` handler (no `resolveNode`) uses the Web path too.
|
|
@@ -652,14 +652,14 @@ function writeNodeOutcome(
|
|
|
652
652
|
return writeNodeResponse(outcome.response, nodeRes)
|
|
653
653
|
}
|
|
654
654
|
|
|
655
|
-
/** A flat 500 with no leaked detail
|
|
655
|
+
/** A flat 500 with no leaked detail - the adapter's last-resort guard if a handler throws. */
|
|
656
656
|
function writeInternalError(nodeRes: ServerResponse): void {
|
|
657
657
|
nodeRes.writeHead(500, { "content-type": "application/json" })
|
|
658
658
|
nodeRes.end(INTERNAL_ERROR_BODY)
|
|
659
659
|
}
|
|
660
660
|
|
|
661
661
|
/**
|
|
662
|
-
* Serialize a node-direct JSON outcome straight to the socket
|
|
662
|
+
* Serialize a node-direct JSON outcome straight to the socket - no undici `Response`, no stream drain.
|
|
663
663
|
* Mirrors `Response.json(data, { status, headers })` byte-for-byte: user headers are lowercased to
|
|
664
664
|
* match undici's `Headers` normalization, the JSON `Content-Type` matches the host runtime's, and each
|
|
665
665
|
* queued cookie is emitted as its own `Set-Cookie` line (never comma-joined).
|
|
@@ -672,7 +672,7 @@ function writeJsonOutcome(
|
|
|
672
672
|
if (outcome.headers !== undefined) {
|
|
673
673
|
for (const [key, value] of Object.entries(outcome.headers)) headers[key.toLowerCase()] = value
|
|
674
674
|
}
|
|
675
|
-
// A `null` body is a 204/no-content render
|
|
675
|
+
// A `null` body is a 204/no-content render - `new Response(null)` carries no Content-Type, so we add
|
|
676
676
|
// none either; a non-null body is JSON, matching `Response.json`'s Content-Type.
|
|
677
677
|
if (outcome.body !== null) headers["content-type"] = JSON_CONTENT_TYPE
|
|
678
678
|
if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
|
|
@@ -970,7 +970,7 @@ function writeNodeResponse(response: Response, nodeRes: ServerResponse): void |
|
|
|
970
970
|
response.headers.forEach((value, key) => {
|
|
971
971
|
headers[key] = value
|
|
972
972
|
})
|
|
973
|
-
// `Headers.forEach` comma-joins repeated `Set-Cookie` into one value
|
|
973
|
+
// `Headers.forEach` comma-joins repeated `Set-Cookie` into one value - wrong, since a cookie's
|
|
974
974
|
// `Expires` attribute itself contains a comma (so a client can't safely split it back). Emit one
|
|
975
975
|
// header line per cookie via the un-joined `getSetCookie()` array (a response can set several:
|
|
976
976
|
// e.g. a session cookie + a CSRF cookie).
|
|
@@ -1031,7 +1031,7 @@ function nodeResponseBody(response: Response): string | Uint8Array | undefined {
|
|
|
1031
1031
|
|
|
1032
1032
|
type WsServerCtor = new (options: { noServer: true }) => WsServer
|
|
1033
1033
|
|
|
1034
|
-
/** A non-literal specifier so TS treats `import(...)` as `any`
|
|
1034
|
+
/** A non-literal specifier so TS treats `import(...)` as `any` - `ws` is an optional peer with no
|
|
1035
1035
|
* `@types/ws` dependency here (the surface is structurally typed via {@link WsServer}/{@link WsSocket}). */
|
|
1036
1036
|
const WS_MODULE_SPECIFIER = "ws"
|
|
1037
1037
|
|
|
@@ -1045,7 +1045,7 @@ async function loadWsServer(): Promise<WsServer | undefined> {
|
|
|
1045
1045
|
const Ctor = mod.WebSocketServer ?? mod.default?.WebSocketServer
|
|
1046
1046
|
return Ctor === undefined ? undefined : new Ctor({ noServer: true })
|
|
1047
1047
|
} catch {
|
|
1048
|
-
return undefined // `ws` not installed
|
|
1048
|
+
return undefined // `ws` not installed - caller responds 501
|
|
1049
1049
|
}
|
|
1050
1050
|
}
|
|
1051
1051
|
|
|
@@ -1094,7 +1094,7 @@ async function handleUpgrade(
|
|
|
1094
1094
|
}
|
|
1095
1095
|
|
|
1096
1096
|
/** Wire a `ws` socket (already open in `handleUpgrade`'s callback) to a nifra WS handler. The Node copy
|
|
1097
|
-
* of core's `attachWebSocket`
|
|
1097
|
+
* of core's `attachWebSocket` - binary frames normalize to `Uint8Array`; a thrown/rejected callback
|
|
1098
1098
|
* routes to `error()` and never crashes the connection. */
|
|
1099
1099
|
function attachNodeWebSocket(
|
|
1100
1100
|
ws: WsSocket,
|
|
@@ -1119,7 +1119,7 @@ function attachNodeWebSocket(
|
|
|
1119
1119
|
const r = handler.error(nifra, error)
|
|
1120
1120
|
if (r instanceof Promise) r.catch(() => {})
|
|
1121
1121
|
} catch {
|
|
1122
|
-
/* the error handler itself failed
|
|
1122
|
+
/* the error handler itself failed - last resort, swallow */
|
|
1123
1123
|
}
|
|
1124
1124
|
}
|
|
1125
1125
|
const safe = (call: () => void | Promise<void>): void => {
|