@askrjs/node 0.0.4 → 0.0.6

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 CHANGED
@@ -23,7 +23,7 @@ const app = createServerApp({
23
23
  routes: [{ path: "/health", handler: () => json({ status: "ok" }) }],
24
24
  });
25
25
 
26
- createServer(createNodeHandler(app)).listen(3000);
26
+ createServer(createNodeHandler(app, { baseUrl: "http://localhost:3000" })).listen(3000);
27
27
  ```
28
28
 
29
29
  `createNodeHandler` also works as Connect middleware because it accepts an optional `next`
@@ -35,12 +35,33 @@ status text, and HEAD responses.
35
35
  ```ts
36
36
  import { listen } from "@askrjs/node";
37
37
 
38
- const server = await listen(app, { port: 3000 });
38
+ const server = await listen(app, {
39
+ port: 3000,
40
+ requestTimeout: 120_000,
41
+ headersTimeout: 60_000,
42
+ keepAliveTimeout: 5_000,
43
+ });
39
44
  server.close();
40
45
  ```
41
46
 
42
47
  Pass an `AbortSignal` to integrate shutdown with your process lifecycle.
43
48
 
49
+ Enable the built-in `ws` transport with `websocket: true`. It defaults to a
50
+ 1 MiB maximum message payload with compression disabled; pass
51
+ `websocket: { maxPayload, perMessageDeflate }` to override those settings.
52
+
53
+ ```ts
54
+ router.ws("/echo", (socket) => {
55
+ socket.onMessage((message) => socket.send(message));
56
+ });
57
+
58
+ const server = await listen(createServerApp({ router }), { websocket: true });
59
+ ```
60
+
61
+ Route matching, authentication, and middleware complete before the handshake.
62
+ Rejected upgrades preserve the application response, and shutdown closes active
63
+ sockets.
64
+
44
65
  ## Serve a production application
45
66
 
46
67
  ```ts
package/dist/index.d.ts CHANGED
@@ -1,15 +1,26 @@
1
1
  import { IncomingMessage, Server, ServerResponse } from "node:http";
2
+ import { PerMessageDeflateOptions } from "ws";
2
3
  import { ServerApp } from "@askrjs/server";
3
4
  import { AddressInfo } from "node:net";
4
5
  //#region src/contracts.d.ts
6
+ interface NodeWebSocketOptions {
7
+ readonly maxPayload?: number;
8
+ readonly perMessageDeflate?: boolean | PerMessageDeflateOptions;
9
+ readonly allowedOrigins?: readonly string[];
10
+ }
5
11
  interface NodeHandlerOptions {
6
12
  baseUrl?: string;
13
+ allowedHosts?: readonly string[];
7
14
  }
8
15
  interface ListenOptions {
9
16
  port?: number;
10
17
  host?: string;
11
18
  backlog?: number;
12
19
  signal?: AbortSignal;
20
+ requestTimeout?: number;
21
+ headersTimeout?: number;
22
+ keepAliveTimeout?: number;
23
+ websocket?: boolean | NodeWebSocketOptions;
13
24
  }
14
25
  interface ServeOptions extends ListenOptions {
15
26
  readonly assets?: {
@@ -39,4 +50,4 @@ declare function serve(app: ServerApp & {
39
50
  close?: () => void | Promise<void>;
40
51
  }, options?: ServeOptions): Promise<ServedApplication>;
41
52
  //#endregion
42
- export { ConnectNext, ListenOptions, ListeningServer, NodeHandler, NodeHandlerOptions, ServeOptions, ServedApplication, createNodeHandler, listen, serve };
53
+ export { ConnectNext, ListenOptions, ListeningServer, NodeHandler, NodeHandlerOptions, NodeWebSocketOptions, ServeOptions, ServedApplication, createNodeHandler, listen, serve };
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { Readable } from "node:stream";
2
2
  import { createServer } from "node:http";
3
+ import { WebSocketServer } from "ws";
3
4
  import { createReadStream } from "node:fs";
4
5
  import { stat } from "node:fs/promises";
5
6
  import { extname, resolve, sep } from "node:path";
6
7
  //#region src/request.ts
8
+ var NodeRequestError = class extends TypeError {};
7
9
  function requestHeaders(request) {
8
10
  const headers = new Headers();
9
11
  for (const [key, value] of Object.entries(request.headers)) {
@@ -13,10 +15,17 @@ function requestHeaders(request) {
13
15
  return headers;
14
16
  }
15
17
  function requestFromNode(request, options, signal) {
16
- const base = options.baseUrl ?? `http://${request.headers.host ?? "localhost"}`;
18
+ const host = request.headers.host;
19
+ if (!options.baseUrl && !options.allowedHosts?.length) throw new NodeRequestError("Node handling requires baseUrl or an allowedHosts allowlist.");
20
+ if (!host) throw new NodeRequestError("Request Host header is required.");
21
+ const hostName = host.startsWith("[") ? host.slice(1, host.indexOf("]")) : host.split(":", 1)[0];
22
+ if (options.allowedHosts?.length && !options.allowedHosts.some((allowed) => allowed === host || allowed === hostName)) throw new NodeRequestError("Request Host header is not allowed.");
23
+ const base = options.baseUrl ?? `http://${host}`;
24
+ const target = new URL(request.url ?? "/", base);
25
+ if (/^https?:\/\//i.test(request.url ?? "") && target.origin !== new URL(base).origin) throw new NodeRequestError("Absolute-form request target origin is not allowed.");
17
26
  const method = request.method ?? "GET";
18
27
  const body = method === "GET" || method === "HEAD" ? void 0 : Readable.toWeb(request);
19
- return new Request(new URL(request.url ?? "/", base), {
28
+ return new Request(target, {
20
29
  method,
21
30
  headers: requestHeaders(request),
22
31
  body,
@@ -100,10 +109,11 @@ function minimalError(response, error) {
100
109
  response.destroy(error instanceof Error ? error : void 0);
101
110
  return;
102
111
  }
103
- response.statusCode = 500;
104
- response.statusMessage = "Internal Server Error";
112
+ const clientError = error instanceof NodeRequestError;
113
+ response.statusCode = clientError ? 400 : 500;
114
+ response.statusMessage = clientError ? "Bad Request" : "Internal Server Error";
105
115
  response.setHeader("content-type", "text/plain; charset=utf-8");
106
- response.end("Internal Server Error");
116
+ response.end(clientError ? "Bad Request" : "Internal Server Error");
107
117
  }
108
118
  function attachAbort(request, response, controller) {
109
119
  const abort = () => controller.abort();
@@ -129,9 +139,102 @@ function createNodeHandler(app, options = {}) {
129
139
  };
130
140
  }
131
141
  //#endregion
142
+ //#region src/websocket.ts
143
+ function subscribe(socket, event, listener) {
144
+ socket.on(event, listener);
145
+ return () => socket.off(event, listener);
146
+ }
147
+ function socketLike(socket) {
148
+ return {
149
+ send: (data) => socket.send(data),
150
+ close: (code, reason) => socket.close(code, reason),
151
+ onMessage: (listener) => subscribe(socket, "message", (data, binary) => {
152
+ if (!binary) listener(data.toString());
153
+ else {
154
+ const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
155
+ listener(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
156
+ }
157
+ }),
158
+ onClose: (listener) => subscribe(socket, "close", (code, reason) => listener({
159
+ code,
160
+ reason: reason.toString(),
161
+ wasClean: code === 1e3
162
+ })),
163
+ onError: (listener) => subscribe(socket, "error", listener)
164
+ };
165
+ }
166
+ async function rejectUpgrade(socket, response) {
167
+ const body = Buffer.from(await response.arrayBuffer());
168
+ const lines = [`HTTP/1.1 ${response.status} ${response.statusText || "Rejected"}`];
169
+ response.headers.forEach((value, name) => lines.push(`${name}: ${value}`));
170
+ if (!response.headers.has("content-length")) lines.push(`content-length: ${body.byteLength}`);
171
+ lines.push("connection: close", "", "");
172
+ socket.end(Buffer.concat([Buffer.from(lines.join("\r\n")), body]));
173
+ }
174
+ function installWebSockets(server, app, options = {}, handlerOptions = {}) {
175
+ const webSockets = new WebSocketServer({
176
+ noServer: true,
177
+ maxPayload: options.maxPayload ?? 1048576,
178
+ perMessageDeflate: options.perMessageDeflate ?? false
179
+ });
180
+ server.on("upgrade", (request, socket, head) => {
181
+ const controller = new AbortController();
182
+ let accepted;
183
+ Promise.resolve().then(() => requestFromNode(request, handlerOptions, controller.signal)).then(async (webRequest) => {
184
+ const originHeader = request.headers.origin;
185
+ const origin = Array.isArray(originHeader) ? void 0 : originHeader;
186
+ const allowedOrigins = options.allowedOrigins ?? [new URL(webRequest.url).origin];
187
+ let normalizedOrigin;
188
+ try {
189
+ normalizedOrigin = origin ? new URL(origin).origin : void 0;
190
+ } catch {
191
+ normalizedOrigin = void 0;
192
+ }
193
+ if (!normalizedOrigin || !allowedOrigins.includes(normalizedOrigin)) {
194
+ await rejectUpgrade(socket, new Response("Forbidden", { status: 403 }));
195
+ return;
196
+ }
197
+ let marker;
198
+ const response = await app.fetch(webRequest, { websocket: { upgrade: (_request, handler, context) => {
199
+ marker = new Response(null, { status: 200 });
200
+ accepted = {
201
+ handler,
202
+ context,
203
+ response: marker
204
+ };
205
+ return marker;
206
+ } } });
207
+ if (!accepted || response !== accepted.response) {
208
+ await rejectUpgrade(socket, response);
209
+ return;
210
+ }
211
+ webSockets.handleUpgrade(request, socket, head, (webSocket) => {
212
+ webSocket.once("close", () => controller.abort());
213
+ Promise.resolve(accepted.handler(socketLike(webSocket), accepted.context)).catch(() => webSocket.close(1011, "WebSocket handler failed"));
214
+ });
215
+ }).catch(() => socket.destroy());
216
+ });
217
+ return { close() {
218
+ for (const socket of webSockets.clients) socket.close(1001, "Server shutting down");
219
+ webSockets.close();
220
+ } };
221
+ }
222
+ //#endregion
132
223
  //#region src/listen.ts
133
224
  function listen(app, options = {}) {
134
- const server = createServer(createNodeHandler(app));
225
+ const handlerOptions = { allowedHosts: [options.host ?? "127.0.0.1", "localhost"] };
226
+ const server = createServer(createNodeHandler(app, handlerOptions));
227
+ if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
228
+ if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
229
+ if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
230
+ const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
231
+ if (webSockets) {
232
+ const nativeClose = server.close.bind(server);
233
+ server.close = ((callback) => {
234
+ webSockets.close();
235
+ return nativeClose(callback);
236
+ });
237
+ }
135
238
  const close = () => server.close();
136
239
  options.signal?.addEventListener("abort", close, { once: true });
137
240
  server.once("close", () => options.signal?.removeEventListener("abort", close));
@@ -169,8 +272,9 @@ function isAssetPath(pathname) {
169
272
  }
170
273
  async function serve(app, options = {}) {
171
274
  const root = options.assets ? resolve(options.assets.root) : void 0;
172
- const applicationHandler = createNodeHandler({ async fetch(request) {
173
- const result = await app.fetch(request);
275
+ const handlerOptions = { allowedHosts: [options.host ?? "127.0.0.1", "localhost"] };
276
+ const applicationHandler = createNodeHandler({ async fetch(request, dispatchOptions) {
277
+ const result = await app.fetch(request, dispatchOptions);
174
278
  if (!result.headers.has("cache-control") && result.headers.get("content-type")?.includes("text/html")) {
175
279
  const headers = new Headers(result.headers);
176
280
  headers.set("cache-control", "no-cache");
@@ -181,7 +285,7 @@ async function serve(app, options = {}) {
181
285
  });
182
286
  }
183
287
  return result;
184
- } });
288
+ } }, handlerOptions);
185
289
  const server = createServer(async (request, response) => {
186
290
  let pathname;
187
291
  try {
@@ -220,6 +324,10 @@ async function serve(app, options = {}) {
220
324
  }
221
325
  applicationHandler(request, response);
222
326
  });
327
+ if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
328
+ if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
329
+ if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
330
+ const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
223
331
  let closing;
224
332
  const signals = options.signals === false ? [] : options.signals ?? ["SIGINT", "SIGTERM"];
225
333
  const removeListeners = () => {
@@ -228,6 +336,7 @@ async function serve(app, options = {}) {
228
336
  };
229
337
  const close = () => closing ??= new Promise((resolveClose, rejectClose) => {
230
338
  removeListeners();
339
+ webSockets?.close();
231
340
  const finish = async (serverError) => {
232
341
  try {
233
342
  await app.close?.();
package/dist/mcp.d.ts CHANGED
@@ -10,6 +10,8 @@ interface McpStdioOptions<Dependencies = undefined> {
10
10
  signal?: AbortSignal;
11
11
  auth?: AuthContext | ((environment: NodeJS.ProcessEnv) => AuthContext | Promise<AuthContext>);
12
12
  environment?: NodeJS.ProcessEnv;
13
+ maxLineBytes?: number;
14
+ maxConcurrency?: number;
13
15
  }
14
16
  interface McpStdioConnection {
15
17
  readonly closed: Promise<void>;
package/dist/mcp.js CHANGED
@@ -22,6 +22,11 @@ function connectMcpStdio(mcp, options) {
22
22
  finish = resolve;
23
23
  });
24
24
  let ended = false;
25
+ const maxLineBytes = options.maxLineBytes ?? 1048576;
26
+ const maxConcurrency = options.maxConcurrency ?? 16;
27
+ if (!Number.isInteger(maxLineBytes) || maxLineBytes <= 0) throw new TypeError("MCP maxLineBytes must be a positive integer.");
28
+ if (!Number.isInteger(maxConcurrency) || maxConcurrency <= 0) throw new TypeError("MCP maxConcurrency must be a positive integer.");
29
+ let active = 0;
25
30
  const write = (message) => new Promise((resolve, reject) => {
26
31
  output.write(`${JSON.stringify(message)}\n`, (error) => error ? reject(error) : resolve());
27
32
  });
@@ -37,6 +42,29 @@ function connectMcpStdio(mcp, options) {
37
42
  };
38
43
  options.signal?.addEventListener("abort", () => void close(), { once: true });
39
44
  lines.on("line", (line) => {
45
+ if (Buffer.byteLength(line) > maxLineBytes) {
46
+ write({
47
+ jsonrpc: "2.0",
48
+ id: null,
49
+ error: {
50
+ code: -32600,
51
+ message: "Request line exceeds the configured limit"
52
+ }
53
+ }).catch(() => void close());
54
+ return;
55
+ }
56
+ if (active >= maxConcurrency) {
57
+ write({
58
+ jsonrpc: "2.0",
59
+ id: null,
60
+ error: {
61
+ code: -32600,
62
+ message: "Too many concurrent requests"
63
+ }
64
+ }).catch(() => void close());
65
+ return;
66
+ }
67
+ active += 1;
40
68
  (async () => {
41
69
  let message;
42
70
  try {
@@ -78,7 +106,9 @@ function connectMcpStdio(mcp, options) {
78
106
  if (controller && id !== void 0) controllers.delete(id);
79
107
  }
80
108
  }
81
- })().catch((error) => diagnostics.write(`MCP stdio error: ${error instanceof Error ? error.message : String(error)}\n`));
109
+ })().catch((error) => diagnostics.write(`MCP stdio error: ${error instanceof Error ? error.message : String(error)}\n`)).finally(() => {
110
+ active -= 1;
111
+ });
82
112
  });
83
113
  lines.once("close", () => {
84
114
  if (!ended) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/node",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Node http adapter for @askrjs/server",
5
5
  "keywords": [
6
6
  "askr",
@@ -56,10 +56,13 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@askrjs/auth": ">=0.0.1 <0.1.0",
59
- "@askrjs/server": ">=0.0.1 <0.1.0"
59
+ "@askrjs/server": ">=0.0.5 <0.1.0",
60
+ "ws": "^8.21.1"
60
61
  },
61
62
  "devDependencies": {
63
+ "@askrjs/schema": ">=0.0.2 <0.1.0",
62
64
  "@types/node": "^26.0.0",
65
+ "@types/ws": "^8.18.1",
63
66
  "publint": "^0.3.21",
64
67
  "typescript": "^6.0.0",
65
68
  "vite-plus": "^0.2.4",