@orkestrel/router 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.
@@ -77,6 +77,32 @@ export declare function buildRequest(message: IncomingMessage, options?: Request
77
77
  */
78
78
  export declare function createListener<TState>(dispatcher: DispatcherInterface<TState>, state: StateFunction<TState>): ListenerFunction;
79
79
 
80
+ /**
81
+ * Handle one `node:http` request through a core dispatcher and write its
82
+ * fetch-standard response.
83
+ *
84
+ * @remarks
85
+ * This is the named asynchronous orchestration behind {@link createListener}.
86
+ * A rejected dispatch is treated only as a transport-level last resort: write
87
+ * a bare `500` before headers, or destroy a response whose headers have
88
+ * already started.
89
+ *
90
+ * @typeParam TState - The consumer's opaque per-request state type
91
+ * @param dispatcher - The core dispatcher to run
92
+ * @param state - Derives the consumer state from the incoming message
93
+ * @param request - The raw `node:http` request
94
+ * @param response - The raw `node:http` response
95
+ * @returns A promise that settles after the response is written or closed
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * const server = http.createServer((request, response) => {
100
+ * void handleListenerRequest(dispatcher, () => undefined, request, response)
101
+ * })
102
+ * ```
103
+ */
104
+ export declare function handleListenerRequest<TState>(dispatcher: DispatcherInterface<TState>, state: StateFunction<TState>, request: IncomingMessage, response: ServerResponse): Promise<void>;
105
+
80
106
  /**
81
107
  * Determine whether a `node:http` connection socket is TLS-encrypted — the
82
108
  * total, never-throwing narrow (AGENTS §14) `buildRequest` uses to pick the
@@ -154,6 +154,41 @@ async function sendResponse(response, target) {
154
154
  }
155
155
  }
156
156
  /**
157
+ * Handle one `node:http` request through a core dispatcher and write its
158
+ * fetch-standard response.
159
+ *
160
+ * @remarks
161
+ * This is the named asynchronous orchestration behind {@link createListener}.
162
+ * A rejected dispatch is treated only as a transport-level last resort: write
163
+ * a bare `500` before headers, or destroy a response whose headers have
164
+ * already started.
165
+ *
166
+ * @typeParam TState - The consumer's opaque per-request state type
167
+ * @param dispatcher - The core dispatcher to run
168
+ * @param state - Derives the consumer state from the incoming message
169
+ * @param request - The raw `node:http` request
170
+ * @param response - The raw `node:http` response
171
+ * @returns A promise that settles after the response is written or closed
172
+ *
173
+ * @example
174
+ * ```ts
175
+ * const server = http.createServer((request, response) => {
176
+ * void handleListenerRequest(dispatcher, () => undefined, request, response)
177
+ * })
178
+ * ```
179
+ */
180
+ async function handleListenerRequest(dispatcher, state, request, response) {
181
+ try {
182
+ const converted = buildRequest(request);
183
+ await sendResponse(await dispatcher.handle(converted, state(request)), response);
184
+ } catch (error) {
185
+ if (!response.headersSent && !response.destroyed) {
186
+ response.writeHead(500);
187
+ response.end();
188
+ } else if (!response.destroyed) response.destroy(error instanceof Error ? error : new Error(String(error)));
189
+ }
190
+ }
191
+ /**
157
192
  * Create a `node:http` request listener over a core {@link DispatcherInterface} —
158
193
  * the whole server face's entry point (§5.3): convert the incoming message to
159
194
  * a fetch `Request`, hand it to the dispatcher with the consumer's per-request
@@ -188,20 +223,10 @@ async function sendResponse(response, target) {
188
223
  */
189
224
  function createListener(dispatcher, state) {
190
225
  return (request, response) => {
191
- (async () => {
192
- try {
193
- const converted = buildRequest(request);
194
- await sendResponse(await dispatcher.handle(converted, state(request)), response);
195
- } catch (error) {
196
- if (!response.headersSent && !response.destroyed) {
197
- response.writeHead(500);
198
- response.end();
199
- } else if (!response.destroyed) response.destroy(error instanceof Error ? error : new Error(String(error)));
200
- }
201
- })();
226
+ handleListenerRequest(dispatcher, state, request, response);
202
227
  };
203
228
  }
204
229
  //#endregion
205
- export { buildRequest, createListener, isEncryptedSocket, sendResponse };
230
+ export { buildRequest, createListener, handleListenerRequest, isEncryptedSocket, sendResponse };
206
231
 
207
232
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/server/helpers.ts"],"sourcesContent":["// ============================================================================\n// Pure conversion + glue between `node:http` and the fetch vocabulary the\n// core `Dispatcher` speaks — no lifecycle, no listener ownership beyond the\n// handler function `createListener` returns (§5.3). Every function is\n// exported per AGENTS §5.\n// ============================================================================\n\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { DispatcherInterface } from '@src/core'\nimport type { ListenerFunction, RequestOptions, StateFunction } from './types.js'\nimport { createAbort } from '@orkestrel/abort'\nimport { isRecord } from '@orkestrel/contract'\n\n/**\n * Determine whether a `node:http` connection socket is TLS-encrypted — the\n * total, never-throwing narrow (AGENTS §14) `buildRequest` uses to pick the\n * derived scheme (`https` vs `http`).\n *\n * @param socket - The connection value to test (typically `message.socket`)\n * @returns `true` when `socket` carries a truthy `encrypted` property (a\n * `tls.TLSSocket`), `false` for anything else (including `undefined`)\n *\n * @example\n * ```ts\n * import { isEncryptedSocket } from '@src/server'\n *\n * isEncryptedSocket({ encrypted: true }) // true\n * isEncryptedSocket({}) // false\n * ```\n */\nexport function isEncryptedSocket(socket: unknown): socket is { readonly encrypted: true } {\n\treturn isRecord(socket) && socket.encrypted === true\n}\n\n/**\n * Build a fetch-standard `Request` from a `node:http` `IncomingMessage` — the\n * server-adapter half of the §5.3 conversion seam.\n *\n * @remarks\n * - `method` is carried over verbatim (defaulting to `GET` when absent).\n * - The URL is built against `options.origin` when given, otherwise a scheme\n * derived from the connection (`https` when {@link isEncryptedSocket}, else\n * `http`) plus the `Host` header (absent `Host` ⇒ `localhost`).\n * - Every request header is copied; multi-value headers are joined per fetch\n * semantics (`', '`-joined), except `set-cookie`, whose values are each\n * appended individually (fetch `Headers` preserves multiple `set-cookie`\n * entries distinctly).\n * - For a method that carries a body (anything but `GET`/`HEAD`), the message\n * is pumped chunk by chunk into a DOM-compatible `ReadableStream<Uint8Array>`\n * (reconciling the DOM + node type worlds under the root config), with\n * `duplex: 'half'` set as Node's fetch implementation requires for a\n * streamed request body.\n * - A fresh `@orkestrel/abort` handle backs `request.signal`: if the\n * connection closes before the message finished (`!message.complete`), the\n * handle aborts — so a handler awaiting `request.signal` observes a client\n * disconnect the fetch-standard way, with zero router-specific API.\n *\n * @param message - The raw `node:http` request\n * @param options - Optional `origin` override (§5.3 {@link RequestOptions})\n * @returns A fetch `Request` whose `signal` fires on client disconnect\n *\n * @example\n * ```ts\n * import { buildRequest } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer((incoming) => {\n * \tconst request = buildRequest(incoming)\n * \tconsole.log(request.method, request.url)\n * })\n * ```\n */\nexport function buildRequest(message: IncomingMessage, options?: RequestOptions): Request {\n\tconst method = message.method ?? 'GET'\n\tconst host = message.headers.host ?? 'localhost'\n\tconst scheme = isEncryptedSocket(message.socket) ? 'https' : 'http'\n\tconst origin = options?.origin ?? `${scheme}://${host}`\n\tconst url = new URL(message.url ?? '/', origin)\n\n\tconst headers = new Headers()\n\tfor (const [name, value] of Object.entries(message.headers)) {\n\t\tif (value === undefined) continue\n\t\tif (name === 'set-cookie') {\n\t\t\tfor (const cookie of Array.isArray(value) ? value : [value]) headers.append(name, cookie)\n\t\t\tcontinue\n\t\t}\n\t\theaders.set(name, Array.isArray(value) ? value.join(', ') : value)\n\t}\n\n\tconst abort = createAbort()\n\tmessage.once('close', () => {\n\t\tif (!message.complete)\n\t\t\tabort.abort(new Error(`request to ${url.pathname} disconnected before completion`))\n\t})\n\n\tconst carriesBody = method !== 'GET' && method !== 'HEAD'\n\tconst init: RequestInit = { method, headers, signal: abort.signal }\n\tif (!carriesBody) return new Request(url, init)\n\n\tconst body = new ReadableStream<Uint8Array>({\n\t\tasync start(controller) {\n\t\t\ttry {\n\t\t\t\tfor await (const chunk of message) controller.enqueue(chunk)\n\t\t\t\tcontroller.close()\n\t\t\t} catch (error) {\n\t\t\t\tcontroller.error(error)\n\t\t\t}\n\t\t},\n\t})\n\tconst streamed: RequestInit & { readonly duplex: 'half' } = { ...init, body, duplex: 'half' }\n\treturn new Request(url, streamed)\n}\n\n/**\n * Write a fetch-standard `Response` back to a `node:http` `ServerResponse` —\n * the reverse half of the §5.3 conversion seam.\n *\n * @remarks\n * Writes `status`/`statusText`, then every response header (`set-cookie`\n * written via {@link Headers.getSetCookie} so multiple cookies stay distinct\n * instead of collapsing into one comma-joined header), then streams the web\n * body to `target` chunk by chunk (`for await` over `response.body`), ending\n * `target` when the stream completes. A `null` body ends `target` immediately\n * with no further writes. Total error posture: if `target` is destroyed\n * mid-stream (the client disconnected), the write loop stops and `target` is\n * left as-is rather than throwing an unhandled rejection — a destroyed\n * target is not this function's error to surface.\n *\n * @param response - The fetch `Response` to write\n * @param target - The `node:http` response to write it to\n * @returns A promise that resolves once `target` has been ended (or the\n * stream stopped because `target` was destroyed)\n *\n * @example\n * ```ts\n * import { sendResponse } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer(async (_incoming, target) => {\n * \tawait sendResponse(new Response('ok'), target)\n * })\n * ```\n */\nexport async function sendResponse(response: Response, target: ServerResponse): Promise<void> {\n\ttarget.statusCode = response.status\n\ttarget.statusMessage = response.statusText\n\tfor (const [name, value] of response.headers) {\n\t\tif (name === 'set-cookie') continue\n\t\ttarget.setHeader(name, value)\n\t}\n\tconst cookies = response.headers.getSetCookie()\n\tif (cookies.length > 0) target.setHeader('set-cookie', cookies)\n\n\tif (response.body === null) {\n\t\tif (!target.destroyed) target.end()\n\t\treturn\n\t}\n\ttry {\n\t\tfor await (const chunk of response.body) {\n\t\t\tif (target.destroyed) return\n\t\t\ttarget.write(chunk)\n\t\t}\n\t\tif (!target.destroyed) target.end()\n\t} catch {\n\t\tif (!target.destroyed) target.end()\n\t}\n}\n\n/**\n * Create a `node:http` request listener over a core {@link DispatcherInterface} —\n * the whole server face's entry point (§5.3): convert the incoming message to\n * a fetch `Request`, hand it to the dispatcher with the consumer's per-request\n * `state`, and write the resulting `Response` back.\n *\n * @remarks\n * A rejected `dispatcher.handle` (a route handler throw — the dispatcher\n * never invents an error boundary, §5.1) is this listener's transport-level\n * LAST RESORT, distinct from an application error boundary: when nothing has\n * been sent yet, it destroys the connection with a bare `500` head (never\n * leaking a hanging socket); once headers are already sent, it destroys the\n * connection outright. The router still owns no error POLICY — a consumer\n * that wants mapped error responses installs its own boundary around\n * `dispatcher.handle` (the future `@orkestrel/server` seam, §7).\n *\n * @typeParam TState - The consumer's opaque per-request state type\n * @param dispatcher - The core dispatcher to run each converted request through\n * @param state - Derives the consumer's per-request `state` from the raw message\n * @returns A `(request, response) => void` listener, passable directly to\n * `http.createServer`\n *\n * @example\n * ```ts\n * import { createListener } from '@src/server'\n * import { createDispatcher } from '@src/core'\n * import http from 'node:http'\n *\n * const dispatcher = createDispatcher()\n * dispatcher.add({ method: 'GET', path: '/health', handler: () => new Response('ok') })\n * http.createServer(createListener(dispatcher, () => undefined)).listen(0)\n * ```\n */\nexport function createListener<TState>(\n\tdispatcher: DispatcherInterface<TState>,\n\tstate: StateFunction<TState>,\n): ListenerFunction {\n\treturn (request, response) => {\n\t\tvoid (async () => {\n\t\t\ttry {\n\t\t\t\tconst converted = buildRequest(request)\n\t\t\t\tconst result = await dispatcher.handle(converted, state(request))\n\t\t\t\tawait sendResponse(result, response)\n\t\t\t} catch (error) {\n\t\t\t\tif (!response.headersSent && !response.destroyed) {\n\t\t\t\t\tresponse.writeHead(500)\n\t\t\t\t\tresponse.end()\n\t\t\t\t} else if (!response.destroyed) {\n\t\t\t\t\tresponse.destroy(error instanceof Error ? error : new Error(String(error)))\n\t\t\t\t}\n\t\t\t}\n\t\t})()\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/server/helpers.ts"],"sourcesContent":["// ============================================================================\n// Pure conversion + glue between `node:http` and the fetch vocabulary the\n// core `Dispatcher` speaks — no lifecycle, no listener ownership beyond the\n// handler function `createListener` returns (§5.3). Every function is\n// exported per AGENTS §5.\n// ============================================================================\n\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { DispatcherInterface } from '@src/core'\nimport type { ListenerFunction, RequestOptions, StateFunction } from './types.js'\nimport { createAbort } from '@orkestrel/abort'\nimport { isRecord } from '@orkestrel/contract'\n\n/**\n * Determine whether a `node:http` connection socket is TLS-encrypted — the\n * total, never-throwing narrow (AGENTS §14) `buildRequest` uses to pick the\n * derived scheme (`https` vs `http`).\n *\n * @param socket - The connection value to test (typically `message.socket`)\n * @returns `true` when `socket` carries a truthy `encrypted` property (a\n * `tls.TLSSocket`), `false` for anything else (including `undefined`)\n *\n * @example\n * ```ts\n * import { isEncryptedSocket } from '@src/server'\n *\n * isEncryptedSocket({ encrypted: true }) // true\n * isEncryptedSocket({}) // false\n * ```\n */\nexport function isEncryptedSocket(socket: unknown): socket is { readonly encrypted: true } {\n\treturn isRecord(socket) && socket.encrypted === true\n}\n\n/**\n * Build a fetch-standard `Request` from a `node:http` `IncomingMessage` — the\n * server-adapter half of the §5.3 conversion seam.\n *\n * @remarks\n * - `method` is carried over verbatim (defaulting to `GET` when absent).\n * - The URL is built against `options.origin` when given, otherwise a scheme\n * derived from the connection (`https` when {@link isEncryptedSocket}, else\n * `http`) plus the `Host` header (absent `Host` ⇒ `localhost`).\n * - Every request header is copied; multi-value headers are joined per fetch\n * semantics (`', '`-joined), except `set-cookie`, whose values are each\n * appended individually (fetch `Headers` preserves multiple `set-cookie`\n * entries distinctly).\n * - For a method that carries a body (anything but `GET`/`HEAD`), the message\n * is pumped chunk by chunk into a DOM-compatible `ReadableStream<Uint8Array>`\n * (reconciling the DOM + node type worlds under the root config), with\n * `duplex: 'half'` set as Node's fetch implementation requires for a\n * streamed request body.\n * - A fresh `@orkestrel/abort` handle backs `request.signal`: if the\n * connection closes before the message finished (`!message.complete`), the\n * handle aborts — so a handler awaiting `request.signal` observes a client\n * disconnect the fetch-standard way, with zero router-specific API.\n *\n * @param message - The raw `node:http` request\n * @param options - Optional `origin` override (§5.3 {@link RequestOptions})\n * @returns A fetch `Request` whose `signal` fires on client disconnect\n *\n * @example\n * ```ts\n * import { buildRequest } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer((incoming) => {\n * \tconst request = buildRequest(incoming)\n * \tconsole.log(request.method, request.url)\n * })\n * ```\n */\nexport function buildRequest(message: IncomingMessage, options?: RequestOptions): Request {\n\tconst method = message.method ?? 'GET'\n\tconst host = message.headers.host ?? 'localhost'\n\tconst scheme = isEncryptedSocket(message.socket) ? 'https' : 'http'\n\tconst origin = options?.origin ?? `${scheme}://${host}`\n\tconst url = new URL(message.url ?? '/', origin)\n\n\tconst headers = new Headers()\n\tfor (const [name, value] of Object.entries(message.headers)) {\n\t\tif (value === undefined) continue\n\t\tif (name === 'set-cookie') {\n\t\t\tfor (const cookie of Array.isArray(value) ? value : [value]) headers.append(name, cookie)\n\t\t\tcontinue\n\t\t}\n\t\theaders.set(name, Array.isArray(value) ? value.join(', ') : value)\n\t}\n\n\tconst abort = createAbort()\n\tmessage.once('close', () => {\n\t\tif (!message.complete)\n\t\t\tabort.abort(new Error(`request to ${url.pathname} disconnected before completion`))\n\t})\n\n\tconst carriesBody = method !== 'GET' && method !== 'HEAD'\n\tconst init: RequestInit = { method, headers, signal: abort.signal }\n\tif (!carriesBody) return new Request(url, init)\n\n\tconst body = new ReadableStream<Uint8Array>({\n\t\tasync start(controller) {\n\t\t\ttry {\n\t\t\t\tfor await (const chunk of message) controller.enqueue(chunk)\n\t\t\t\tcontroller.close()\n\t\t\t} catch (error) {\n\t\t\t\tcontroller.error(error)\n\t\t\t}\n\t\t},\n\t})\n\tconst streamed: RequestInit & { readonly duplex: 'half' } = { ...init, body, duplex: 'half' }\n\treturn new Request(url, streamed)\n}\n\n/**\n * Write a fetch-standard `Response` back to a `node:http` `ServerResponse` —\n * the reverse half of the §5.3 conversion seam.\n *\n * @remarks\n * Writes `status`/`statusText`, then every response header (`set-cookie`\n * written via {@link Headers.getSetCookie} so multiple cookies stay distinct\n * instead of collapsing into one comma-joined header), then streams the web\n * body to `target` chunk by chunk (`for await` over `response.body`), ending\n * `target` when the stream completes. A `null` body ends `target` immediately\n * with no further writes. Total error posture: if `target` is destroyed\n * mid-stream (the client disconnected), the write loop stops and `target` is\n * left as-is rather than throwing an unhandled rejection — a destroyed\n * target is not this function's error to surface.\n *\n * @param response - The fetch `Response` to write\n * @param target - The `node:http` response to write it to\n * @returns A promise that resolves once `target` has been ended (or the\n * stream stopped because `target` was destroyed)\n *\n * @example\n * ```ts\n * import { sendResponse } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer(async (_incoming, target) => {\n * \tawait sendResponse(new Response('ok'), target)\n * })\n * ```\n */\nexport async function sendResponse(response: Response, target: ServerResponse): Promise<void> {\n\ttarget.statusCode = response.status\n\ttarget.statusMessage = response.statusText\n\tfor (const [name, value] of response.headers) {\n\t\tif (name === 'set-cookie') continue\n\t\ttarget.setHeader(name, value)\n\t}\n\tconst cookies = response.headers.getSetCookie()\n\tif (cookies.length > 0) target.setHeader('set-cookie', cookies)\n\n\tif (response.body === null) {\n\t\tif (!target.destroyed) target.end()\n\t\treturn\n\t}\n\ttry {\n\t\tfor await (const chunk of response.body) {\n\t\t\tif (target.destroyed) return\n\t\t\ttarget.write(chunk)\n\t\t}\n\t\tif (!target.destroyed) target.end()\n\t} catch {\n\t\tif (!target.destroyed) target.end()\n\t}\n}\n\n/**\n * Handle one `node:http` request through a core dispatcher and write its\n * fetch-standard response.\n *\n * @remarks\n * This is the named asynchronous orchestration behind {@link createListener}.\n * A rejected dispatch is treated only as a transport-level last resort: write\n * a bare `500` before headers, or destroy a response whose headers have\n * already started.\n *\n * @typeParam TState - The consumer's opaque per-request state type\n * @param dispatcher - The core dispatcher to run\n * @param state - Derives the consumer state from the incoming message\n * @param request - The raw `node:http` request\n * @param response - The raw `node:http` response\n * @returns A promise that settles after the response is written or closed\n *\n * @example\n * ```ts\n * const server = http.createServer((request, response) => {\n * \tvoid handleListenerRequest(dispatcher, () => undefined, request, response)\n * })\n * ```\n */\nexport async function handleListenerRequest<TState>(\n\tdispatcher: DispatcherInterface<TState>,\n\tstate: StateFunction<TState>,\n\trequest: IncomingMessage,\n\tresponse: ServerResponse,\n): Promise<void> {\n\ttry {\n\t\tconst converted = buildRequest(request)\n\t\tconst result = await dispatcher.handle(converted, state(request))\n\t\tawait sendResponse(result, response)\n\t} catch (error) {\n\t\tif (!response.headersSent && !response.destroyed) {\n\t\t\tresponse.writeHead(500)\n\t\t\tresponse.end()\n\t\t} else if (!response.destroyed) {\n\t\t\tresponse.destroy(error instanceof Error ? error : new Error(String(error)))\n\t\t}\n\t}\n}\n\n/**\n * Create a `node:http` request listener over a core {@link DispatcherInterface} —\n * the whole server face's entry point (§5.3): convert the incoming message to\n * a fetch `Request`, hand it to the dispatcher with the consumer's per-request\n * `state`, and write the resulting `Response` back.\n *\n * @remarks\n * A rejected `dispatcher.handle` (a route handler throw — the dispatcher\n * never invents an error boundary, §5.1) is this listener's transport-level\n * LAST RESORT, distinct from an application error boundary: when nothing has\n * been sent yet, it destroys the connection with a bare `500` head (never\n * leaking a hanging socket); once headers are already sent, it destroys the\n * connection outright. The router still owns no error POLICY — a consumer\n * that wants mapped error responses installs its own boundary around\n * `dispatcher.handle` (the future `@orkestrel/server` seam, §7).\n *\n * @typeParam TState - The consumer's opaque per-request state type\n * @param dispatcher - The core dispatcher to run each converted request through\n * @param state - Derives the consumer's per-request `state` from the raw message\n * @returns A `(request, response) => void` listener, passable directly to\n * `http.createServer`\n *\n * @example\n * ```ts\n * import { createListener } from '@src/server'\n * import { createDispatcher } from '@src/core'\n * import http from 'node:http'\n *\n * const dispatcher = createDispatcher()\n * dispatcher.add({ method: 'GET', path: '/health', handler: () => new Response('ok') })\n * http.createServer(createListener(dispatcher, () => undefined)).listen(0)\n * ```\n */\nexport function createListener<TState>(\n\tdispatcher: DispatcherInterface<TState>,\n\tstate: StateFunction<TState>,\n): ListenerFunction {\n\treturn (request, response) => {\n\t\tvoid handleListenerRequest(dispatcher, state, request, response)\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,kBAAkB,QAAyD;CAC1F,OAAO,SAAS,MAAM,KAAK,OAAO,cAAc;AACjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,SAAgB,aAAa,SAA0B,SAAmC;CACzF,MAAM,SAAS,QAAQ,UAAU;CACjC,MAAM,OAAO,QAAQ,QAAQ,QAAQ;CACrC,MAAM,SAAS,kBAAkB,QAAQ,MAAM,IAAI,UAAU;CAC7D,MAAM,SAAS,SAAS,UAAU,GAAG,OAAO,KAAK;CACjD,MAAM,MAAM,IAAI,IAAI,QAAQ,OAAO,KAAK,MAAM;CAE9C,MAAM,UAAU,IAAI,QAAQ;CAC5B,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,QAAQ,OAAO,GAAG;EAC5D,IAAI,UAAU,KAAA,GAAW;EACzB,IAAI,SAAS,cAAc;GAC1B,KAAK,MAAM,UAAU,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,KAAK,GAAG,QAAQ,OAAO,MAAM,MAAM;GACxF;EACD;EACA,QAAQ,IAAI,MAAM,MAAM,QAAQ,KAAK,IAAI,MAAM,KAAK,IAAI,IAAI,KAAK;CAClE;CAEA,MAAM,QAAQ,YAAY;CAC1B,QAAQ,KAAK,eAAe;EAC3B,IAAI,CAAC,QAAQ,UACZ,MAAM,sBAAM,IAAI,MAAM,cAAc,IAAI,SAAS,gCAAgC,CAAC;CACpF,CAAC;CAED,MAAM,cAAc,WAAW,SAAS,WAAW;CACnD,MAAM,OAAoB;EAAE;EAAQ;EAAS,QAAQ,MAAM;CAAO;CAClE,IAAI,CAAC,aAAa,OAAO,IAAI,QAAQ,KAAK,IAAI;CAE9C,MAAM,OAAO,IAAI,eAA2B,EAC3C,MAAM,MAAM,YAAY;EACvB,IAAI;GACH,WAAW,MAAM,SAAS,SAAS,WAAW,QAAQ,KAAK;GAC3D,WAAW,MAAM;EAClB,SAAS,OAAO;GACf,WAAW,MAAM,KAAK;EACvB;CACD,EACD,CAAC;CACD,MAAM,WAAsD;EAAE,GAAG;EAAM;EAAM,QAAQ;CAAO;CAC5F,OAAO,IAAI,QAAQ,KAAK,QAAQ;AACjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,eAAsB,aAAa,UAAoB,QAAuC;CAC7F,OAAO,aAAa,SAAS;CAC7B,OAAO,gBAAgB,SAAS;CAChC,KAAK,MAAM,CAAC,MAAM,UAAU,SAAS,SAAS;EAC7C,IAAI,SAAS,cAAc;EAC3B,OAAO,UAAU,MAAM,KAAK;CAC7B;CACA,MAAM,UAAU,SAAS,QAAQ,aAAa;CAC9C,IAAI,QAAQ,SAAS,GAAG,OAAO,UAAU,cAAc,OAAO;CAE9D,IAAI,SAAS,SAAS,MAAM;EAC3B,IAAI,CAAC,OAAO,WAAW,OAAO,IAAI;EAClC;CACD;CACA,IAAI;EACH,WAAW,MAAM,SAAS,SAAS,MAAM;GACxC,IAAI,OAAO,WAAW;GACtB,OAAO,MAAM,KAAK;EACnB;EACA,IAAI,CAAC,OAAO,WAAW,OAAO,IAAI;CACnC,QAAQ;EACP,IAAI,CAAC,OAAO,WAAW,OAAO,IAAI;CACnC;AACD;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,eAAsB,sBACrB,YACA,OACA,SACA,UACgB;CAChB,IAAI;EACH,MAAM,YAAY,aAAa,OAAO;EAEtC,MAAM,aAAa,MADE,WAAW,OAAO,WAAW,MAAM,OAAO,CAAC,GACrC,QAAQ;CACpC,SAAS,OAAO;EACf,IAAI,CAAC,SAAS,eAAe,CAAC,SAAS,WAAW;GACjD,SAAS,UAAU,GAAG;GACtB,SAAS,IAAI;EACd,OAAO,IAAI,CAAC,SAAS,WACpB,SAAS,QAAQ,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC,CAAC;CAE5E;AACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,SAAgB,eACf,YACA,OACmB;CACnB,QAAQ,SAAS,aAAa;EAC7B,sBAA2B,YAAY,OAAO,SAAS,QAAQ;CAChE;AACD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orkestrel/router",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "A typed request router for the @orkestrel line — server and browser environments. Part of the @orkestrel line.",
5
5
  "keywords": [
6
6
  "browser",
@@ -18,7 +18,7 @@
18
18
  "url": "git+https://github.com/orkestrel/router.git"
19
19
  },
20
20
  "files": [
21
- "dist",
21
+ "dist/src",
22
22
  "README.md"
23
23
  ],
24
24
  "type": "module",
@@ -58,10 +58,10 @@
58
58
  "access": "public"
59
59
  },
60
60
  "scripts": {
61
- "clean": "node -e \"try{require('node:fs').rmSync('dist',{recursive:true,force:true})}catch{}\"",
61
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
62
62
  "copy": "node -e \"const fs=require('node:fs'),p=require('node:path'),a=process.argv[1],b=process.argv[2];fs.mkdirSync(p.dirname(b),{recursive:true});fs.cpSync(a,b,{force:true});console.log('Copied: '+a+' to '+b)\"",
63
- "tmp:txt": "node -e \"const fs=require('node:fs'),p=require('node:path');function walk(d){for(const e of fs.readdirSync(d,{withFileTypes:true})){const f=p.join(d,e.name);if(e.isDirectory()){walk(f)}else if(!e.name.endsWith('.md')&&!e.name.endsWith('.txt')){const t=f+'.txt';if(!fs.existsSync(t)){fs.renameSync(f,t)}else{console.warn('Skipping '+f+' — target exists: '+t)}}}}try{walk('tmp')}catch(e){if(e.code!=='ENOENT')throw e}\"",
64
- "lint": "oxlint --config .oxlintrc.json --fix .",
63
+ "scaffold": "scaffold",
64
+ "lint": "oxlint --config .oxlintrc.json --fix --deny-warnings .",
65
65
  "check": "tsc --noEmit --project tsconfig.json && npm run check:src",
66
66
  "check:src": "npm run check:src:core && npm run check:src:browser && npm run check:src:server",
67
67
  "check:src:core": "tsc --noEmit -p configs/src/tsconfig.core.json",
@@ -69,12 +69,13 @@
69
69
  "check:src:server": "tsc --noEmit -p configs/src/tsconfig.server.json",
70
70
  "format": "oxfmt --config .oxfmtrc.json --write .",
71
71
  "format:check": "oxfmt --config .oxfmtrc.json --check .",
72
- "lint:check": "oxlint --config .oxlintrc.json .",
73
- "test": "npm run test:src && npm run test:guides",
72
+ "lint:check": "oxlint --config .oxlintrc.json --deny-warnings .",
73
+ "test": "npm run test:src && npm run test:policy && npm run test:guides",
74
74
  "test:src": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:core --project src:browser --project src:server",
75
75
  "test:src:core": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:core",
76
76
  "test:src:browser": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:browser",
77
77
  "test:src:server": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:server",
78
+ "test:policy": "vitest run --config vite.config.ts --no-cache --reporter=dot --project policy",
78
79
  "test:guides": "vitest run --config vite.config.ts --reporter=dot --project guides",
79
80
  "build": "npm run clean && npm run build:src",
80
81
  "build:src": "npm run build:src:core && npm run build:src:browser && npm run build:src:server",
@@ -84,23 +85,25 @@
84
85
  "prepublishOnly": "npm run format:check && npm run lint:check && npm run check && npm run build && npm test"
85
86
  },
86
87
  "dependencies": {
87
- "@orkestrel/abort": "^0.0.3",
88
- "@orkestrel/contract": "^0.0.5",
89
- "@orkestrel/emitter": "^0.0.3"
88
+ "@orkestrel/abort": "^0.0.4",
89
+ "@orkestrel/contract": "^0.0.8",
90
+ "@orkestrel/emitter": "^0.0.4"
90
91
  },
91
92
  "devDependencies": {
92
- "@microsoft/api-extractor": "^7.58.11",
93
- "@orkestrel/guide": "^0.0.5",
94
- "@types/node": "^26.1.1",
93
+ "@microsoft/api-extractor": "^7.58.12",
94
+ "@orkestrel/guide": "^0.0.7",
95
+ "@orkestrel/scaffold": "^0.0.6",
96
+ "@types/node": "^26.1.2",
95
97
  "@vitest/browser-playwright": "^4.1.10",
96
- "oxfmt": "^0.59.0",
97
- "oxlint": "^1.74.0",
98
+ "oxfmt": "^0.61.0",
99
+ "oxlint": "^1.76.0",
100
+ "playwright": "^1.62.0",
98
101
  "typescript": "^6.0.3",
99
102
  "vite": "^8.1.5",
100
103
  "vite-plugin-dts": "^5.0.3",
101
104
  "vitest": "^4.1.10"
102
105
  },
103
106
  "engines": {
104
- "node": ">=22"
107
+ "node": ">=22.12.0"
105
108
  }
106
109
  }