@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/dist/index.js
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Run a nifra app (or any Web-`fetch` handler) on Node's `http` server.
|
|
11
|
+
*
|
|
12
|
+
* import { serve } from "@nifrajs/node"
|
|
13
|
+
* import { server } from "@nifrajs/core"
|
|
14
|
+
* const app = server().get("/", () => ({ ok: true }))
|
|
15
|
+
* serve(app, { port: 3000 })
|
|
16
|
+
*
|
|
17
|
+
* nifra's lifecycle is `app.fetch(Request): Response | Promise<Response>` — pure Web Standards —
|
|
18
|
+
* so this adapter just bridges Node's stream-based `(req, res)` to/from Web
|
|
19
|
+
* `Request`/`Response`, plus a Bun-`listen()`-style graceful `stop()`.
|
|
20
|
+
*/
|
|
21
|
+
import { open } from "node:fs/promises";
|
|
22
|
+
import { createServer, } from "node:http";
|
|
23
|
+
import { extname, resolve, sep } from "node:path";
|
|
24
|
+
import { Readable } from "node:stream";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
/**
|
|
27
|
+
* The `Content-Type` the host runtime's `Response.json` emits — Node's undici uses `application/json`,
|
|
28
|
+
* Bun uses `application/json;charset=utf-8`. Probed once at module load (zero per-request cost) so the
|
|
29
|
+
* fast path is byte-for-byte identical to the `Response`-building path on whatever runtime hosts us.
|
|
30
|
+
*/
|
|
31
|
+
const JSON_CONTENT_TYPE = Response.json(0).headers.get("content-type") ?? "application/json";
|
|
32
|
+
const INTERNAL_ERROR_BODY = '{"ok":false,"error":"internal_error"}';
|
|
33
|
+
const EMPTY_BUFFER = Buffer.alloc(0);
|
|
34
|
+
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body");
|
|
35
|
+
const DEFAULT_DRAIN_MS = 10_000;
|
|
36
|
+
const DRAIN_POLL_MS = 10;
|
|
37
|
+
const STATIC_CONTENT_TYPES = {
|
|
38
|
+
".js": "text/javascript; charset=utf-8",
|
|
39
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
40
|
+
".css": "text/css; charset=utf-8",
|
|
41
|
+
".html": "text/html; charset=utf-8",
|
|
42
|
+
".json": "application/json; charset=utf-8",
|
|
43
|
+
".map": "application/json; charset=utf-8",
|
|
44
|
+
".svg": "image/svg+xml",
|
|
45
|
+
".png": "image/png",
|
|
46
|
+
".jpg": "image/jpeg",
|
|
47
|
+
".jpeg": "image/jpeg",
|
|
48
|
+
".gif": "image/gif",
|
|
49
|
+
".webp": "image/webp",
|
|
50
|
+
".avif": "image/avif",
|
|
51
|
+
".ico": "image/x-icon",
|
|
52
|
+
".woff": "font/woff",
|
|
53
|
+
".woff2": "font/woff2",
|
|
54
|
+
".ttf": "font/ttf",
|
|
55
|
+
".otf": "font/otf",
|
|
56
|
+
".wasm": "application/wasm",
|
|
57
|
+
".txt": "text/plain; charset=utf-8",
|
|
58
|
+
".webmanifest": "application/manifest+json",
|
|
59
|
+
};
|
|
60
|
+
function staticStateOf(options) {
|
|
61
|
+
const root = resolve(typeof options.dir === "string" ? options.dir : fileURLToPath(options.dir));
|
|
62
|
+
const raw = options.prefix ?? "/assets";
|
|
63
|
+
const prefix = raw === "/" ? "/" : `/${raw.replace(/^\/+|\/+$/g, "")}`;
|
|
64
|
+
return { root, prefix, immutable: options.immutable !== false, headers: options.headers };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a request URL to a file under the served root — **synchronously**, so non-matching requests
|
|
68
|
+
* stay on the app's sync fast path. Returns `"pass"` (let the app handle it), a rejection `Response`
|
|
69
|
+
* (malformed encoding / NUL / `..` traversal out of root), or a confined absolute file path to read.
|
|
70
|
+
*/
|
|
71
|
+
function staticMatch(state, rawUrl) {
|
|
72
|
+
const query = rawUrl.indexOf("?");
|
|
73
|
+
const path = query === -1 ? rawUrl : rawUrl.slice(0, query);
|
|
74
|
+
const underPrefix = state.prefix === "/" ? true : path === state.prefix || path.startsWith(`${state.prefix}/`);
|
|
75
|
+
if (!underPrefix)
|
|
76
|
+
return "pass";
|
|
77
|
+
let rel;
|
|
78
|
+
try {
|
|
79
|
+
rel = decodeURIComponent(state.prefix === "/" ? path : path.slice(state.prefix.length));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return { reject: new Response("Bad Request", { status: 400 }) };
|
|
83
|
+
}
|
|
84
|
+
rel = rel.replace(/^\/+/, "");
|
|
85
|
+
if (rel === "" || rel.endsWith("/"))
|
|
86
|
+
return "pass"; // a directory request → the app decides
|
|
87
|
+
if (rel.includes("\0"))
|
|
88
|
+
return { reject: new Response("Bad Request", { status: 400 }) };
|
|
89
|
+
const file = resolve(state.root, rel);
|
|
90
|
+
// Confine to the served directory — block `..` from escaping root.
|
|
91
|
+
if (file !== state.root && !file.startsWith(state.root + sep)) {
|
|
92
|
+
return { reject: new Response("Forbidden", { status: 403 }) };
|
|
93
|
+
}
|
|
94
|
+
return { file };
|
|
95
|
+
}
|
|
96
|
+
async function readStatic(file, state, method) {
|
|
97
|
+
let handle;
|
|
98
|
+
try {
|
|
99
|
+
handle = await open(file, "r");
|
|
100
|
+
const stat = await handle.stat();
|
|
101
|
+
if (!stat.isFile()) {
|
|
102
|
+
await handle.close();
|
|
103
|
+
return { kind: "response", response: new Response("Not Found", { status: 404 }) };
|
|
104
|
+
}
|
|
105
|
+
const headers = { ...state.headers };
|
|
106
|
+
headers["content-type"] =
|
|
107
|
+
STATIC_CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
108
|
+
headers["content-length"] = String(stat.size);
|
|
109
|
+
if (state.immutable && headers["cache-control"] === undefined) {
|
|
110
|
+
headers["cache-control"] = "public, max-age=31536000, immutable";
|
|
111
|
+
}
|
|
112
|
+
if (method === "HEAD") {
|
|
113
|
+
await handle.close();
|
|
114
|
+
return { kind: "response", response: new Response(null, { headers }) };
|
|
115
|
+
}
|
|
116
|
+
const stream = handle.createReadStream();
|
|
117
|
+
return {
|
|
118
|
+
kind: "response",
|
|
119
|
+
response: new Response(Readable.toWeb(stream), { headers }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
await handle?.close().catch(() => { });
|
|
124
|
+
// Missing/unreadable under the static prefix → 404 (don't fall through to an SSR 404 page).
|
|
125
|
+
return { kind: "response", response: new Response("Not Found", { status: 404 }) };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Serve a Web-`fetch` app on a Node `http` server. Resolves once bound — Node binds
|
|
130
|
+
* the port asynchronously, so awaiting gives you the real port (matters for `port: 0`).
|
|
131
|
+
*/
|
|
132
|
+
export function serve(app, options) {
|
|
133
|
+
let inFlight = 0;
|
|
134
|
+
let closed = false;
|
|
135
|
+
const protocol = protocolResolver(options.protocol);
|
|
136
|
+
const staticState = options.static !== undefined ? staticStateOf(options.static) : undefined;
|
|
137
|
+
const server = createServer((nodeReq, nodeRes) => {
|
|
138
|
+
inFlight += 1;
|
|
139
|
+
try {
|
|
140
|
+
const handled = handle(app, nodeReq, nodeRes, protocol, staticState);
|
|
141
|
+
if (handled instanceof Promise) {
|
|
142
|
+
void handled.finally(() => {
|
|
143
|
+
inFlight -= 1;
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
inFlight -= 1;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
writeInternalError(nodeRes);
|
|
151
|
+
inFlight -= 1;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
// WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
|
|
155
|
+
// the optional `ws` package — lazy-imported (and the server lazily built) on the FIRST real WS
|
|
156
|
+
// upgrade, so a non-WS Node app never loads `ws`.
|
|
157
|
+
const resolveWs = app.resolveWebSocketUpgrade?.bind(app);
|
|
158
|
+
if (resolveWs !== undefined) {
|
|
159
|
+
let wssPromise;
|
|
160
|
+
server.on("upgrade", (nodeReq, socket, head) => {
|
|
161
|
+
void handleUpgrade(resolveWs, protocol, nodeReq, socket, head, () => {
|
|
162
|
+
wssPromise ??= loadWsServer();
|
|
163
|
+
return wssPromise;
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// Opt-in: own SIGTERM/SIGINT so `docker stop` / Ctrl-C drains in-flight requests
|
|
168
|
+
// before exit. `stop` is hoisted, so `onSignal` can reference it.
|
|
169
|
+
const onSignal = () => {
|
|
170
|
+
void stop();
|
|
171
|
+
};
|
|
172
|
+
async function stop({ drainMs = DEFAULT_DRAIN_MS } = {}) {
|
|
173
|
+
if (closed)
|
|
174
|
+
return; // idempotent
|
|
175
|
+
closed = true;
|
|
176
|
+
if (options.signals === true) {
|
|
177
|
+
// Remove our own handlers so repeated serve()/stop() cycles don't leak listeners.
|
|
178
|
+
process.removeListener("SIGTERM", onSignal);
|
|
179
|
+
process.removeListener("SIGINT", onSignal);
|
|
180
|
+
}
|
|
181
|
+
server.close(); // stop accepting new connections; existing requests continue
|
|
182
|
+
const deadline = Date.now() + drainMs;
|
|
183
|
+
while (inFlight > 0 && Date.now() < deadline) {
|
|
184
|
+
await new Promise((r) => setTimeout(r, DRAIN_POLL_MS));
|
|
185
|
+
}
|
|
186
|
+
server.closeAllConnections(); // force-close stragglers + idle keep-alive sockets
|
|
187
|
+
}
|
|
188
|
+
return new Promise((resolve) => {
|
|
189
|
+
server.listen(options.port, options.hostname, () => {
|
|
190
|
+
const address = server.address();
|
|
191
|
+
const port = address !== null && typeof address === "object" ? address.port : options.port;
|
|
192
|
+
if (options.signals === true) {
|
|
193
|
+
process.once("SIGTERM", onSignal);
|
|
194
|
+
process.once("SIGINT", onSignal);
|
|
195
|
+
}
|
|
196
|
+
resolve({ port, stop });
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
function handle(app, nodeReq, nodeRes, getProtocol, staticState) {
|
|
201
|
+
let protocol;
|
|
202
|
+
try {
|
|
203
|
+
protocol = getProtocol(nodeReq);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
writeInternalError(nodeRes);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Static files first (GET/HEAD). The match is synchronous, so a non-asset request never leaves the
|
|
210
|
+
// app's sync fast path below; only a prefix hit reads from disk (and async-writes the file).
|
|
211
|
+
if (staticState !== undefined && (nodeReq.method === "GET" || nodeReq.method === "HEAD")) {
|
|
212
|
+
const matched = staticMatch(staticState, nodeReq.url ?? "/");
|
|
213
|
+
if (matched !== "pass") {
|
|
214
|
+
if ("reject" in matched)
|
|
215
|
+
return writeNodeResponse(matched.reject, nodeRes);
|
|
216
|
+
return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then((outcome) => writeNodeOutcome(outcome, nodeRes), () => writeInternalError(nodeRes));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Fast path: a nifra app exposes `resolveNode`, which renders a plain-data result as primitives we
|
|
220
|
+
// write straight to the socket — skipping the undici `Response` build + body drain (the bulk of the
|
|
221
|
+
// Web-bridge cost on Node). A handler-returned `Response`/redirect, 404/405, error, timeout, or any
|
|
222
|
+
// `onResponse` hook comes back as `{ kind: "response" }` and takes the same Web path as before, so
|
|
223
|
+
// behavior is identical. A plain `{ fetch }` handler (no `resolveNode`) uses the Web path too.
|
|
224
|
+
const resolveNodeSource = app.resolveNodeSource;
|
|
225
|
+
if (typeof resolveNodeSource === "function") {
|
|
226
|
+
try {
|
|
227
|
+
const outcome = resolveNodeSource.call(app, toNodeRequestSource(nodeReq, protocol));
|
|
228
|
+
return outcome instanceof Promise
|
|
229
|
+
? outcome.then((settled) => writeNodeOutcome(settled, nodeRes), () => writeInternalError(nodeRes))
|
|
230
|
+
: writeNodeOutcome(outcome, nodeRes);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
writeInternalError(nodeRes);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const request = toWebRequest(nodeReq, protocol);
|
|
238
|
+
const resolveNode = app.resolveNode;
|
|
239
|
+
if (typeof resolveNode === "function") {
|
|
240
|
+
try {
|
|
241
|
+
const outcome = resolveNode.call(app, request);
|
|
242
|
+
return outcome instanceof Promise
|
|
243
|
+
? outcome.then((settled) => writeNodeOutcome(settled, nodeRes), () => writeInternalError(nodeRes))
|
|
244
|
+
: writeNodeOutcome(outcome, nodeRes);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
writeInternalError(nodeRes);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const response = app.fetch(request);
|
|
253
|
+
return response instanceof Promise
|
|
254
|
+
? response.then((settled) => writeNodeResponse(settled, nodeRes), () => writeInternalError(nodeRes))
|
|
255
|
+
: writeNodeResponse(response, nodeRes);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// The app should never throw (nifra returns a 500), but never leak a stack to the wire.
|
|
259
|
+
writeInternalError(nodeRes);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function writeNodeOutcome(outcome, nodeRes) {
|
|
264
|
+
if (outcome.kind === "json") {
|
|
265
|
+
writeJsonOutcome(outcome, nodeRes);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (outcome.kind === "body") {
|
|
269
|
+
writeBodyOutcome(outcome, nodeRes);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
return writeNodeResponse(outcome.response, nodeRes);
|
|
273
|
+
}
|
|
274
|
+
/** A flat 500 with no leaked detail — the adapter's last-resort guard if a handler throws. */
|
|
275
|
+
function writeInternalError(nodeRes) {
|
|
276
|
+
nodeRes.writeHead(500, { "content-type": "application/json" });
|
|
277
|
+
nodeRes.end(INTERNAL_ERROR_BODY);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Serialize a node-direct JSON outcome straight to the socket — no undici `Response`, no stream drain.
|
|
281
|
+
* Mirrors `Response.json(data, { status, headers })` byte-for-byte: user headers are lowercased to
|
|
282
|
+
* match undici's `Headers` normalization, the JSON `Content-Type` matches the host runtime's, and each
|
|
283
|
+
* queued cookie is emitted as its own `Set-Cookie` line (never comma-joined).
|
|
284
|
+
*/
|
|
285
|
+
function writeJsonOutcome(outcome, nodeRes) {
|
|
286
|
+
const headers = {};
|
|
287
|
+
if (outcome.headers !== undefined) {
|
|
288
|
+
for (const [key, value] of Object.entries(outcome.headers))
|
|
289
|
+
headers[key.toLowerCase()] = value;
|
|
290
|
+
}
|
|
291
|
+
// A `null` body is a 204/no-content render — `new Response(null)` carries no Content-Type, so we add
|
|
292
|
+
// none either; a non-null body is JSON, matching `Response.json`'s Content-Type.
|
|
293
|
+
if (outcome.body !== null)
|
|
294
|
+
headers["content-type"] = JSON_CONTENT_TYPE;
|
|
295
|
+
if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
|
|
296
|
+
headers["set-cookie"] = [...outcome.cookies];
|
|
297
|
+
}
|
|
298
|
+
nodeRes.writeHead(outcome.status, headers);
|
|
299
|
+
if (outcome.body !== null)
|
|
300
|
+
nodeRes.end(outcome.body);
|
|
301
|
+
else
|
|
302
|
+
nodeRes.end();
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Write a node-direct buffered body outcome straight to the socket. This is the Response-shaped
|
|
306
|
+
* sibling of `writeJsonOutcome`: headers/status were already normalized by core, and the body is the
|
|
307
|
+
* exact marked payload from the Response producer, so there is no Web body reader to drain.
|
|
308
|
+
*/
|
|
309
|
+
function writeBodyOutcome(outcome, nodeRes) {
|
|
310
|
+
const headers = {};
|
|
311
|
+
if (outcome.headers !== undefined) {
|
|
312
|
+
for (const [key, value] of Object.entries(outcome.headers)) {
|
|
313
|
+
headers[key] = typeof value === "string" ? value : [...value];
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
nodeRes.writeHead(outcome.status, headers);
|
|
317
|
+
nodeRes.end(outcome.body);
|
|
318
|
+
}
|
|
319
|
+
function protocolResolver(option) {
|
|
320
|
+
if (option === undefined)
|
|
321
|
+
return () => "http";
|
|
322
|
+
if (typeof option === "function")
|
|
323
|
+
return (req) => normalizeProtocol(option(req));
|
|
324
|
+
const protocol = normalizeProtocol(option);
|
|
325
|
+
return () => protocol;
|
|
326
|
+
}
|
|
327
|
+
function normalizeProtocol(value) {
|
|
328
|
+
if (value === "http" || value === "https")
|
|
329
|
+
return value;
|
|
330
|
+
throw new Error(`@nifrajs/node: protocol must be "http" or "https" (got ${JSON.stringify(value)})`);
|
|
331
|
+
}
|
|
332
|
+
function toWebRequest(req, protocol) {
|
|
333
|
+
const host = req.headers.host ?? "localhost";
|
|
334
|
+
const url = `${protocol}://${host}${req.url ?? "/"}`;
|
|
335
|
+
const method = req.method ?? "GET";
|
|
336
|
+
return makeWebRequest(req, method, url, headersFromNode(req.headers));
|
|
337
|
+
}
|
|
338
|
+
function toNodeRequestSource(req, protocol) {
|
|
339
|
+
return new LazyNodeRequestSource(req, protocol);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Lazy view over a Node `IncomingMessage`. Methods live on the prototype (not re-allocated per
|
|
343
|
+
* request), and every materialization is deferred: the `Headers` object, the Web `ReadableStream`
|
|
344
|
+
* body, and the undici `Request` are each built only when first read. A bare GET that never touches
|
|
345
|
+
* `c.req` allocates just this instance.
|
|
346
|
+
*/
|
|
347
|
+
class LazyNodeRequestSource {
|
|
348
|
+
method;
|
|
349
|
+
url;
|
|
350
|
+
headersValue;
|
|
351
|
+
bodyValue;
|
|
352
|
+
requestValue;
|
|
353
|
+
consumedBody;
|
|
354
|
+
readBodyPromise;
|
|
355
|
+
nodeReq;
|
|
356
|
+
constructor(nodeReq, protocol) {
|
|
357
|
+
this.nodeReq = nodeReq;
|
|
358
|
+
this.method = nodeReq.method ?? "GET";
|
|
359
|
+
const host = nodeReq.headers.host ?? "localhost";
|
|
360
|
+
this.url = `${protocol}://${host}${nodeReq.url ?? "/"}`;
|
|
361
|
+
}
|
|
362
|
+
get headers() {
|
|
363
|
+
this.headersValue ??= headersFromNode(this.nodeReq.headers);
|
|
364
|
+
return this.headersValue;
|
|
365
|
+
}
|
|
366
|
+
// Read straight off Node's already-lowercased header bag (comma-joining multi-values to match
|
|
367
|
+
// `Headers.get`) so the body-cap path can check content-type/length without building a `Headers`.
|
|
368
|
+
header(name) {
|
|
369
|
+
const value = this.nodeReq.headers[name.toLowerCase()];
|
|
370
|
+
if (value === undefined)
|
|
371
|
+
return null;
|
|
372
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
373
|
+
}
|
|
374
|
+
get body() {
|
|
375
|
+
if (this.method === "GET" || this.method === "HEAD")
|
|
376
|
+
return null;
|
|
377
|
+
if (this.consumedBody !== undefined)
|
|
378
|
+
return this.request.body;
|
|
379
|
+
this.bodyValue ??= Readable.toWeb(this.nodeReq);
|
|
380
|
+
return this.bodyValue;
|
|
381
|
+
}
|
|
382
|
+
arrayBuffer() {
|
|
383
|
+
if (this.requestValue !== undefined)
|
|
384
|
+
return this.requestValue.arrayBuffer();
|
|
385
|
+
return this.readNodeBody().then((buffer) => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
|
|
386
|
+
}
|
|
387
|
+
json() {
|
|
388
|
+
if (this.requestValue !== undefined)
|
|
389
|
+
return this.requestValue.json();
|
|
390
|
+
return this.readNodeBody().then((buffer) => JSON.parse(buffer.toString("utf8")));
|
|
391
|
+
}
|
|
392
|
+
get request() {
|
|
393
|
+
if (this.requestValue !== undefined)
|
|
394
|
+
return this.requestValue;
|
|
395
|
+
if (this.consumedBody !== undefined) {
|
|
396
|
+
this.requestValue = makeWebRequest(this.nodeReq, this.method, this.url, this.headers, this.consumedBody);
|
|
397
|
+
// Preserve one-shot body semantics if user code asks for `c.req` after nifra already consumed it.
|
|
398
|
+
void this.requestValue.arrayBuffer().catch(() => { });
|
|
399
|
+
return this.requestValue;
|
|
400
|
+
}
|
|
401
|
+
this.requestValue = makeWebRequest(this.nodeReq, this.method, this.url, this.headers, this.body);
|
|
402
|
+
return this.requestValue;
|
|
403
|
+
}
|
|
404
|
+
// Buffer the request body once. A single-chunk body (the common case) skips the array + `concat`;
|
|
405
|
+
// a client abort / socket error rejects (via `error`/`aborted`), which the body-cap callers catch
|
|
406
|
+
// and map to a flat 400.
|
|
407
|
+
readNodeBody() {
|
|
408
|
+
if (this.consumedBody !== undefined)
|
|
409
|
+
return Promise.resolve(this.consumedBody);
|
|
410
|
+
this.readBodyPromise ??= new Promise((resolve, reject) => {
|
|
411
|
+
let first;
|
|
412
|
+
let chunks;
|
|
413
|
+
let total = 0;
|
|
414
|
+
const finish = () => {
|
|
415
|
+
cleanup();
|
|
416
|
+
this.consumedBody =
|
|
417
|
+
chunks === undefined ? (first ?? EMPTY_BUFFER) : Buffer.concat(chunks, total);
|
|
418
|
+
resolve(this.consumedBody);
|
|
419
|
+
};
|
|
420
|
+
const fail = (err) => {
|
|
421
|
+
cleanup();
|
|
422
|
+
reject(err);
|
|
423
|
+
};
|
|
424
|
+
const onData = (chunk) => {
|
|
425
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
426
|
+
if (first === undefined) {
|
|
427
|
+
first = buffer;
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
chunks ??= [first];
|
|
431
|
+
chunks.push(buffer);
|
|
432
|
+
}
|
|
433
|
+
total += buffer.byteLength;
|
|
434
|
+
};
|
|
435
|
+
const onAborted = () => fail(new Error("request aborted"));
|
|
436
|
+
const cleanup = () => {
|
|
437
|
+
this.nodeReq.removeListener("data", onData);
|
|
438
|
+
this.nodeReq.removeListener("end", finish);
|
|
439
|
+
this.nodeReq.removeListener("error", fail);
|
|
440
|
+
this.nodeReq.removeListener("aborted", onAborted);
|
|
441
|
+
this.nodeReq.removeListener("close", onClose);
|
|
442
|
+
};
|
|
443
|
+
// `aborted` is deprecated on newer Node; `close`-without-`end` is the forward-compatible signal
|
|
444
|
+
// that the connection dropped mid-body. Either way the read rejects instead of hanging forever.
|
|
445
|
+
const onClose = () => {
|
|
446
|
+
if (this.consumedBody === undefined)
|
|
447
|
+
fail(new Error("request closed before body completed"));
|
|
448
|
+
};
|
|
449
|
+
this.nodeReq.on("data", onData);
|
|
450
|
+
this.nodeReq.once("end", finish);
|
|
451
|
+
this.nodeReq.once("error", fail);
|
|
452
|
+
this.nodeReq.once("aborted", onAborted);
|
|
453
|
+
this.nodeReq.once("close", onClose);
|
|
454
|
+
});
|
|
455
|
+
return this.readBodyPromise;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function headersFromNode(input) {
|
|
459
|
+
const headers = new Headers();
|
|
460
|
+
for (const [key, value] of Object.entries(input)) {
|
|
461
|
+
if (value === undefined)
|
|
462
|
+
continue;
|
|
463
|
+
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
|
464
|
+
}
|
|
465
|
+
return headers;
|
|
466
|
+
}
|
|
467
|
+
function makeWebRequest(req, method, url, headers, body) {
|
|
468
|
+
const init = { method, headers };
|
|
469
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
470
|
+
// Stream the body in; `duplex: "half"` is required for a streamed request body.
|
|
471
|
+
init.body = body ?? Readable.toWeb(req);
|
|
472
|
+
init.duplex = "half";
|
|
473
|
+
}
|
|
474
|
+
return new Request(url, init);
|
|
475
|
+
}
|
|
476
|
+
function waitForDrain(nodeRes) {
|
|
477
|
+
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
478
|
+
return Promise.resolve(false);
|
|
479
|
+
}
|
|
480
|
+
return new Promise((resolve) => {
|
|
481
|
+
const cleanup = () => {
|
|
482
|
+
nodeRes.removeListener("drain", onDrain);
|
|
483
|
+
nodeRes.removeListener("close", onClose);
|
|
484
|
+
nodeRes.removeListener("error", onError);
|
|
485
|
+
};
|
|
486
|
+
const onDrain = () => {
|
|
487
|
+
cleanup();
|
|
488
|
+
resolve(true);
|
|
489
|
+
};
|
|
490
|
+
const onClose = () => {
|
|
491
|
+
cleanup();
|
|
492
|
+
resolve(false);
|
|
493
|
+
};
|
|
494
|
+
const onError = () => {
|
|
495
|
+
cleanup();
|
|
496
|
+
resolve(false);
|
|
497
|
+
};
|
|
498
|
+
nodeRes.once("drain", onDrain);
|
|
499
|
+
nodeRes.once("close", onClose);
|
|
500
|
+
nodeRes.once("error", onError);
|
|
501
|
+
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
502
|
+
cleanup();
|
|
503
|
+
resolve(false);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
function writeNodeResponse(response, nodeRes) {
|
|
508
|
+
const headers = {};
|
|
509
|
+
response.headers.forEach((value, key) => {
|
|
510
|
+
headers[key] = value;
|
|
511
|
+
});
|
|
512
|
+
// `Headers.forEach` comma-joins repeated `Set-Cookie` into one value — wrong, since a cookie's
|
|
513
|
+
// `Expires` attribute itself contains a comma (so a client can't safely split it back). Emit one
|
|
514
|
+
// header line per cookie via the un-joined `getSetCookie()` array (a response can set several:
|
|
515
|
+
// e.g. a session cookie + a CSRF cookie).
|
|
516
|
+
const setCookies = response.headers.getSetCookie?.();
|
|
517
|
+
if (setCookies !== undefined && setCookies.length > 0)
|
|
518
|
+
headers["set-cookie"] = setCookies;
|
|
519
|
+
nodeRes.writeHead(response.status, headers);
|
|
520
|
+
const directBody = nodeResponseBody(response);
|
|
521
|
+
if (directBody !== undefined) {
|
|
522
|
+
nodeRes.end(directBody);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (response.body === null) {
|
|
526
|
+
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable)
|
|
527
|
+
nodeRes.end();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
return writeNodeResponseBody(response, nodeRes);
|
|
531
|
+
}
|
|
532
|
+
async function writeNodeResponseBody(response, nodeRes) {
|
|
533
|
+
const reader = response.body.getReader();
|
|
534
|
+
try {
|
|
535
|
+
for (;;) {
|
|
536
|
+
const { done, value } = await reader.read();
|
|
537
|
+
if (done)
|
|
538
|
+
break;
|
|
539
|
+
if (!nodeRes.write(value)) {
|
|
540
|
+
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
541
|
+
try {
|
|
542
|
+
await reader.cancel();
|
|
543
|
+
}
|
|
544
|
+
catch { }
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const drained = await waitForDrain(nodeRes);
|
|
548
|
+
if (!drained || nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
549
|
+
try {
|
|
550
|
+
await reader.cancel();
|
|
551
|
+
}
|
|
552
|
+
catch { }
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
await reader.cancel().catch(() => { });
|
|
560
|
+
if (!nodeRes.headersSent)
|
|
561
|
+
writeInternalError(nodeRes);
|
|
562
|
+
else
|
|
563
|
+
nodeRes.destroy();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable)
|
|
567
|
+
nodeRes.end();
|
|
568
|
+
}
|
|
569
|
+
function nodeResponseBody(response) {
|
|
570
|
+
if (response.bodyUsed)
|
|
571
|
+
return undefined;
|
|
572
|
+
const body = response[NODE_RESPONSE_BODY];
|
|
573
|
+
return typeof body === "string" || body instanceof Uint8Array ? body : undefined;
|
|
574
|
+
}
|
|
575
|
+
/** A non-literal specifier so TS treats `import(...)` as `any` — `ws` is an optional peer with no
|
|
576
|
+
* `@types/ws` dependency here (the surface is structurally typed via {@link WsServer}/{@link WsSocket}). */
|
|
577
|
+
const WS_MODULE_SPECIFIER = "ws";
|
|
578
|
+
/** Lazily build a noServer `ws` `WebSocketServer`, or `undefined` if `ws` isn't installed. */
|
|
579
|
+
async function loadWsServer() {
|
|
580
|
+
try {
|
|
581
|
+
const mod = (await import(__rewriteRelativeImportExtension(WS_MODULE_SPECIFIER)));
|
|
582
|
+
const Ctor = mod.WebSocketServer ?? mod.default?.WebSocketServer;
|
|
583
|
+
return Ctor === undefined ? undefined : new Ctor({ noServer: true });
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
return undefined; // `ws` not installed — caller responds 501
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const WS_STATUS_TEXT = {
|
|
590
|
+
400: "Bad Request",
|
|
591
|
+
401: "Unauthorized",
|
|
592
|
+
403: "Forbidden",
|
|
593
|
+
404: "Not Found",
|
|
594
|
+
426: "Upgrade Required",
|
|
595
|
+
500: "Internal Server Error",
|
|
596
|
+
501: "Not Implemented",
|
|
597
|
+
};
|
|
598
|
+
/** Resolve a Node `upgrade` event: run the nifra upgrade guard, then either reject (write an HTTP error
|
|
599
|
+
* to the raw socket) or perform the `ws` upgrade and wire the socket to the handler. */
|
|
600
|
+
async function handleUpgrade(resolveWs, getProtocol, nodeReq, socket, head, getWss) {
|
|
601
|
+
let outcome;
|
|
602
|
+
try {
|
|
603
|
+
outcome = await resolveWs(toWebRequest(nodeReq, getProtocol(nodeReq)));
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
writeUpgradeRejection(socket, 500, "internal_error");
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (outcome.kind === "pass") {
|
|
610
|
+
writeUpgradeRejection(socket, 404, "not_found"); // upgrade to a path with no WS route
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (outcome.kind === "reject") {
|
|
614
|
+
await writeRejectionResponse(socket, outcome.response);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const wss = await getWss();
|
|
618
|
+
if (wss === undefined) {
|
|
619
|
+
writeUpgradeRejection(socket, 501, "websocket_unavailable"); // `ws` not installed
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const { handler, data, pubsub } = outcome;
|
|
623
|
+
wss.handleUpgrade(nodeReq, socket, head, (ws) => attachNodeWebSocket(ws, handler, data, pubsub));
|
|
624
|
+
}
|
|
625
|
+
/** Wire a `ws` socket (already open in `handleUpgrade`'s callback) to a nifra WS handler. The Node copy
|
|
626
|
+
* of core's `attachWebSocket` — binary frames normalize to `Uint8Array`; a thrown/rejected callback
|
|
627
|
+
* routes to `error()` and never crashes the connection. */
|
|
628
|
+
function attachNodeWebSocket(ws, handler, data, pubsub) {
|
|
629
|
+
const nifra = {
|
|
630
|
+
send: (payload) => ws.send(payload),
|
|
631
|
+
close: (code, reason) => ws.close(code, reason),
|
|
632
|
+
get readyState() {
|
|
633
|
+
return ws.readyState;
|
|
634
|
+
},
|
|
635
|
+
subscribe: (topic) => pubsub.subscribe(topic, nifra),
|
|
636
|
+
unsubscribe: (topic) => pubsub.unsubscribe(topic, nifra),
|
|
637
|
+
data,
|
|
638
|
+
raw: ws,
|
|
639
|
+
};
|
|
640
|
+
const reportError = (error) => {
|
|
641
|
+
if (handler.error === undefined)
|
|
642
|
+
return;
|
|
643
|
+
try {
|
|
644
|
+
const r = handler.error(nifra, error);
|
|
645
|
+
if (r instanceof Promise)
|
|
646
|
+
r.catch(() => { });
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
/* the error handler itself failed — last resort, swallow */
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
const safe = (call) => {
|
|
653
|
+
try {
|
|
654
|
+
const r = call();
|
|
655
|
+
if (r instanceof Promise)
|
|
656
|
+
r.catch(reportError);
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
reportError(error);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
ws.on("message", (raw, isBinary) => {
|
|
663
|
+
const payload = isBinary
|
|
664
|
+
? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
|
|
665
|
+
: raw.toString();
|
|
666
|
+
safe(() => handler.message?.(nifra, payload));
|
|
667
|
+
});
|
|
668
|
+
ws.on("close", (code, reason) => {
|
|
669
|
+
pubsub.unsubscribeAll(nifra); // drop topic subscriptions so the registry never holds a dead socket
|
|
670
|
+
safe(() => handler.close?.(nifra, code, reason.toString()));
|
|
671
|
+
});
|
|
672
|
+
ws.on("error", (error) => reportError(error));
|
|
673
|
+
safe(() => handler.open?.(nifra)); // open: the socket is already established here
|
|
674
|
+
}
|
|
675
|
+
/** Write a minimal JSON error response to a raw upgrade socket, then close it. */
|
|
676
|
+
function writeUpgradeRejection(socket, status, error) {
|
|
677
|
+
const body = JSON.stringify({ ok: false, error });
|
|
678
|
+
socket.write(`HTTP/1.1 ${status} ${WS_STATUS_TEXT[status] ?? "Error"}\r\n` +
|
|
679
|
+
`Content-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\n` +
|
|
680
|
+
"Connection: close\r\n\r\n" +
|
|
681
|
+
body);
|
|
682
|
+
socket.destroy();
|
|
683
|
+
}
|
|
684
|
+
/** Serialize a nifra guard's rejection `Response` (e.g. a 401) to a raw upgrade socket, then close. */
|
|
685
|
+
async function writeRejectionResponse(socket, response) {
|
|
686
|
+
const body = await response.text();
|
|
687
|
+
let head = `HTTP/1.1 ${response.status} ${response.statusText || WS_STATUS_TEXT[response.status] || "Error"}\r\n`;
|
|
688
|
+
response.headers.forEach((value, key) => {
|
|
689
|
+
if (key.toLowerCase() !== "content-length")
|
|
690
|
+
head += `${key}: ${value}\r\n`;
|
|
691
|
+
});
|
|
692
|
+
head += `Content-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n`;
|
|
693
|
+
socket.write(head + body);
|
|
694
|
+
socket.destroy();
|
|
695
|
+
}
|
|
696
|
+
//# sourceMappingURL=index.js.map
|