@askrjs/node 0.0.3 → 0.0.5

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 ADDED
@@ -0,0 +1,90 @@
1
+ # @askrjs/node
2
+
3
+ [![CI](https://github.com/askrjs/askr-node/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/askrjs/askr-node/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/%40askrjs%2Fnode.svg)](https://www.npmjs.com/package/@askrjs/node)
5
+
6
+ Run an `@askrjs/server` application on Node.js. The adapter translates Node HTTP messages at the
7
+ boundary while the application continues to use Web `Request` and `Response` objects.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install @askrjs/server @askrjs/node
13
+ ```
14
+
15
+ ## Create a Node handler
16
+
17
+ ```ts
18
+ import { createServer } from "node:http";
19
+ import { createServerApp, json } from "@askrjs/server";
20
+ import { createNodeHandler } from "@askrjs/node";
21
+
22
+ const app = createServerApp({
23
+ routes: [{ path: "/health", handler: () => json({ status: "ok" }) }],
24
+ });
25
+
26
+ createServer(createNodeHandler(app)).listen(3000);
27
+ ```
28
+
29
+ `createNodeHandler` also works as Connect middleware because it accepts an optional `next`
30
+ callback. It preserves streaming bodies, repeated `Set-Cookie` headers, aborts, backpressure,
31
+ status text, and HEAD responses.
32
+
33
+ ## Listen directly
34
+
35
+ ```ts
36
+ import { listen } from "@askrjs/node";
37
+
38
+ const server = await listen(app, {
39
+ port: 3000,
40
+ requestTimeout: 120_000,
41
+ headersTimeout: 60_000,
42
+ keepAliveTimeout: 5_000,
43
+ });
44
+ server.close();
45
+ ```
46
+
47
+ Pass an `AbortSignal` to integrate shutdown with your process lifecycle.
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
+
65
+ ## Serve a production application
66
+
67
+ ```ts
68
+ import { serve } from "@askrjs/node";
69
+
70
+ const running = await serve(app, {
71
+ port: 3000,
72
+ assets: { root: "./dist/client" },
73
+ });
74
+
75
+ await running.close();
76
+ ```
77
+
78
+ `serve` handles static assets and closes both the HTTP server and the application during shutdown.
79
+
80
+ ## MCP over stdio
81
+
82
+ ```ts
83
+ import { connectMcpStdio } from "@askrjs/node/mcp";
84
+
85
+ const connection = connectMcpStdio(mcp, { dependencies });
86
+ await connection.closed;
87
+ ```
88
+
89
+ Protocol messages use stdin/stdout; diagnostics remain isolated on stderr. Authentication may be
90
+ provided directly or resolved from the process environment for each message.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,12 @@
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
+ }
5
10
  interface NodeHandlerOptions {
6
11
  baseUrl?: string;
7
12
  }
@@ -10,6 +15,10 @@ interface ListenOptions {
10
15
  host?: string;
11
16
  backlog?: number;
12
17
  signal?: AbortSignal;
18
+ requestTimeout?: number;
19
+ headersTimeout?: number;
20
+ keepAliveTimeout?: number;
21
+ websocket?: boolean | NodeWebSocketOptions;
13
22
  }
14
23
  interface ServeOptions extends ListenOptions {
15
24
  readonly assets?: {
@@ -39,4 +48,4 @@ declare function serve(app: ServerApp & {
39
48
  close?: () => void | Promise<void>;
40
49
  }, options?: ServeOptions): Promise<ServedApplication>;
41
50
  //#endregion
42
- export { ConnectNext, ListenOptions, ListeningServer, NodeHandler, NodeHandlerOptions, ServeOptions, ServedApplication, createNodeHandler, listen, serve };
51
+ export { ConnectNext, ListenOptions, ListeningServer, NodeHandler, NodeHandlerOptions, NodeWebSocketOptions, ServeOptions, ServedApplication, createNodeHandler, listen, serve };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
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";
@@ -129,9 +130,88 @@ function createNodeHandler(app, options = {}) {
129
130
  };
130
131
  }
131
132
  //#endregion
133
+ //#region src/websocket.ts
134
+ function subscribe(socket, event, listener) {
135
+ socket.on(event, listener);
136
+ return () => socket.off(event, listener);
137
+ }
138
+ function socketLike(socket) {
139
+ return {
140
+ send: (data) => socket.send(data),
141
+ close: (code, reason) => socket.close(code, reason),
142
+ onMessage: (listener) => subscribe(socket, "message", (data, binary) => {
143
+ if (!binary) listener(data.toString());
144
+ else {
145
+ const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
146
+ listener(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
147
+ }
148
+ }),
149
+ onClose: (listener) => subscribe(socket, "close", (code, reason) => listener({
150
+ code,
151
+ reason: reason.toString(),
152
+ wasClean: code === 1e3
153
+ })),
154
+ onError: (listener) => subscribe(socket, "error", listener)
155
+ };
156
+ }
157
+ async function rejectUpgrade(socket, response) {
158
+ const body = Buffer.from(await response.arrayBuffer());
159
+ const lines = [`HTTP/1.1 ${response.status} ${response.statusText || "Rejected"}`];
160
+ response.headers.forEach((value, name) => lines.push(`${name}: ${value}`));
161
+ if (!response.headers.has("content-length")) lines.push(`content-length: ${body.byteLength}`);
162
+ lines.push("connection: close", "", "");
163
+ socket.end(Buffer.concat([Buffer.from(lines.join("\r\n")), body]));
164
+ }
165
+ function installWebSockets(server, app, options = {}, handlerOptions = {}) {
166
+ const webSockets = new WebSocketServer({
167
+ noServer: true,
168
+ maxPayload: options.maxPayload ?? 1048576,
169
+ perMessageDeflate: options.perMessageDeflate ?? false
170
+ });
171
+ server.on("upgrade", (request, socket, head) => {
172
+ const controller = new AbortController();
173
+ let accepted;
174
+ Promise.resolve().then(() => requestFromNode(request, handlerOptions, controller.signal)).then(async (webRequest) => {
175
+ let marker;
176
+ const response = await app.fetch(webRequest, { websocket: { upgrade: (_request, handler, context) => {
177
+ marker = new Response(null, { status: 200 });
178
+ accepted = {
179
+ handler,
180
+ context,
181
+ response: marker
182
+ };
183
+ return marker;
184
+ } } });
185
+ if (!accepted || response !== accepted.response) {
186
+ await rejectUpgrade(socket, response);
187
+ return;
188
+ }
189
+ webSockets.handleUpgrade(request, socket, head, (webSocket) => {
190
+ webSocket.once("close", () => controller.abort());
191
+ Promise.resolve(accepted.handler(socketLike(webSocket), accepted.context)).catch(() => webSocket.close(1011, "WebSocket handler failed"));
192
+ });
193
+ }).catch(() => socket.destroy());
194
+ });
195
+ return { close() {
196
+ for (const socket of webSockets.clients) socket.close(1001, "Server shutting down");
197
+ webSockets.close();
198
+ } };
199
+ }
200
+ //#endregion
132
201
  //#region src/listen.ts
133
202
  function listen(app, options = {}) {
134
203
  const server = createServer(createNodeHandler(app));
204
+ if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
205
+ if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
206
+ if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
207
+ const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket) : void 0;
208
+ if (webSockets) {
209
+ const nativeClose = server.close.bind(server);
210
+ server.close = ((callback) => {
211
+ webSockets.close();
212
+ return nativeClose(callback);
213
+ });
214
+ }
135
215
  const close = () => server.close();
136
216
  options.signal?.addEventListener("abort", close, { once: true });
137
217
  server.once("close", () => options.signal?.removeEventListener("abort", close));
@@ -169,8 +249,8 @@ function isAssetPath(pathname) {
169
249
  }
170
250
  async function serve(app, options = {}) {
171
251
  const root = options.assets ? resolve(options.assets.root) : void 0;
172
- const applicationHandler = createNodeHandler({ async fetch(request) {
173
- const result = await app.fetch(request);
252
+ const applicationHandler = createNodeHandler({ async fetch(request, dispatchOptions) {
253
+ const result = await app.fetch(request, dispatchOptions);
174
254
  if (!result.headers.has("cache-control") && result.headers.get("content-type")?.includes("text/html")) {
175
255
  const headers = new Headers(result.headers);
176
256
  headers.set("cache-control", "no-cache");
@@ -220,6 +300,10 @@ async function serve(app, options = {}) {
220
300
  }
221
301
  applicationHandler(request, response);
222
302
  });
303
+ if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
304
+ if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
305
+ if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
306
+ const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket) : void 0;
223
307
  let closing;
224
308
  const signals = options.signals === false ? [] : options.signals ?? ["SIGINT", "SIGTERM"];
225
309
  const removeListeners = () => {
@@ -228,6 +312,7 @@ async function serve(app, options = {}) {
228
312
  };
229
313
  const close = () => closing ??= new Promise((resolveClose, rejectClose) => {
230
314
  removeListeners();
315
+ webSockets?.close();
231
316
  const finish = async (serverError) => {
232
317
  try {
233
318
  await app.close?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/node",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Node http adapter for @askrjs/server",
5
5
  "keywords": [
6
6
  "askr",
@@ -56,10 +56,12 @@
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": {
62
63
  "@types/node": "^26.0.0",
64
+ "@types/ws": "^8.18.1",
63
65
  "publint": "^0.3.21",
64
66
  "typescript": "^6.0.0",
65
67
  "vite-plus": "^0.2.4",