@orkestrel/router 0.0.1 → 0.0.3

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.
@@ -0,0 +1,207 @@
1
+ import { createAbort } from "@orkestrel/abort";
2
+ import { isRecord } from "@orkestrel/contract";
3
+ //#region src/server/helpers.ts
4
+ /**
5
+ * Determine whether a `node:http` connection socket is TLS-encrypted — the
6
+ * total, never-throwing narrow (AGENTS §14) `buildRequest` uses to pick the
7
+ * derived scheme (`https` vs `http`).
8
+ *
9
+ * @param socket - The connection value to test (typically `message.socket`)
10
+ * @returns `true` when `socket` carries a truthy `encrypted` property (a
11
+ * `tls.TLSSocket`), `false` for anything else (including `undefined`)
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { isEncryptedSocket } from '@src/server'
16
+ *
17
+ * isEncryptedSocket({ encrypted: true }) // true
18
+ * isEncryptedSocket({}) // false
19
+ * ```
20
+ */
21
+ function isEncryptedSocket(socket) {
22
+ return isRecord(socket) && socket.encrypted === true;
23
+ }
24
+ /**
25
+ * Build a fetch-standard `Request` from a `node:http` `IncomingMessage` — the
26
+ * server-adapter half of the §5.3 conversion seam.
27
+ *
28
+ * @remarks
29
+ * - `method` is carried over verbatim (defaulting to `GET` when absent).
30
+ * - The URL is built against `options.origin` when given, otherwise a scheme
31
+ * derived from the connection (`https` when {@link isEncryptedSocket}, else
32
+ * `http`) plus the `Host` header (absent `Host` ⇒ `localhost`).
33
+ * - Every request header is copied; multi-value headers are joined per fetch
34
+ * semantics (`', '`-joined), except `set-cookie`, whose values are each
35
+ * appended individually (fetch `Headers` preserves multiple `set-cookie`
36
+ * entries distinctly).
37
+ * - For a method that carries a body (anything but `GET`/`HEAD`), the message
38
+ * is pumped chunk by chunk into a DOM-compatible `ReadableStream<Uint8Array>`
39
+ * (reconciling the DOM + node type worlds under the root config), with
40
+ * `duplex: 'half'` set as Node's fetch implementation requires for a
41
+ * streamed request body.
42
+ * - A fresh `@orkestrel/abort` handle backs `request.signal`: if the
43
+ * connection closes before the message finished (`!message.complete`), the
44
+ * handle aborts — so a handler awaiting `request.signal` observes a client
45
+ * disconnect the fetch-standard way, with zero router-specific API.
46
+ *
47
+ * @param message - The raw `node:http` request
48
+ * @param options - Optional `origin` override (§5.3 {@link RequestOptions})
49
+ * @returns A fetch `Request` whose `signal` fires on client disconnect
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import { buildRequest } from '@src/server'
54
+ * import http from 'node:http'
55
+ *
56
+ * const server = http.createServer((incoming) => {
57
+ * const request = buildRequest(incoming)
58
+ * console.log(request.method, request.url)
59
+ * })
60
+ * ```
61
+ */
62
+ function buildRequest(message, options) {
63
+ const method = message.method ?? "GET";
64
+ const host = message.headers.host ?? "localhost";
65
+ const scheme = isEncryptedSocket(message.socket) ? "https" : "http";
66
+ const origin = options?.origin ?? `${scheme}://${host}`;
67
+ const url = new URL(message.url ?? "/", origin);
68
+ const headers = new Headers();
69
+ for (const [name, value] of Object.entries(message.headers)) {
70
+ if (value === void 0) continue;
71
+ if (name === "set-cookie") {
72
+ for (const cookie of Array.isArray(value) ? value : [value]) headers.append(name, cookie);
73
+ continue;
74
+ }
75
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
76
+ }
77
+ const abort = createAbort();
78
+ message.once("close", () => {
79
+ if (!message.complete) abort.abort(/* @__PURE__ */ new Error(`request to ${url.pathname} disconnected before completion`));
80
+ });
81
+ const carriesBody = method !== "GET" && method !== "HEAD";
82
+ const init = {
83
+ method,
84
+ headers,
85
+ signal: abort.signal
86
+ };
87
+ if (!carriesBody) return new Request(url, init);
88
+ const body = new ReadableStream({ async start(controller) {
89
+ try {
90
+ for await (const chunk of message) controller.enqueue(chunk);
91
+ controller.close();
92
+ } catch (error) {
93
+ controller.error(error);
94
+ }
95
+ } });
96
+ const streamed = {
97
+ ...init,
98
+ body,
99
+ duplex: "half"
100
+ };
101
+ return new Request(url, streamed);
102
+ }
103
+ /**
104
+ * Write a fetch-standard `Response` back to a `node:http` `ServerResponse` —
105
+ * the reverse half of the §5.3 conversion seam.
106
+ *
107
+ * @remarks
108
+ * Writes `status`/`statusText`, then every response header (`set-cookie`
109
+ * written via {@link Headers.getSetCookie} so multiple cookies stay distinct
110
+ * instead of collapsing into one comma-joined header), then streams the web
111
+ * body to `target` chunk by chunk (`for await` over `response.body`), ending
112
+ * `target` when the stream completes. A `null` body ends `target` immediately
113
+ * with no further writes. Total error posture: if `target` is destroyed
114
+ * mid-stream (the client disconnected), the write loop stops and `target` is
115
+ * left as-is rather than throwing an unhandled rejection — a destroyed
116
+ * target is not this function's error to surface.
117
+ *
118
+ * @param response - The fetch `Response` to write
119
+ * @param target - The `node:http` response to write it to
120
+ * @returns A promise that resolves once `target` has been ended (or the
121
+ * stream stopped because `target` was destroyed)
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * import { sendResponse } from '@src/server'
126
+ * import http from 'node:http'
127
+ *
128
+ * const server = http.createServer(async (_incoming, target) => {
129
+ * await sendResponse(new Response('ok'), target)
130
+ * })
131
+ * ```
132
+ */
133
+ async function sendResponse(response, target) {
134
+ target.statusCode = response.status;
135
+ target.statusMessage = response.statusText;
136
+ for (const [name, value] of response.headers) {
137
+ if (name === "set-cookie") continue;
138
+ target.setHeader(name, value);
139
+ }
140
+ const cookies = response.headers.getSetCookie();
141
+ if (cookies.length > 0) target.setHeader("set-cookie", cookies);
142
+ if (response.body === null) {
143
+ if (!target.destroyed) target.end();
144
+ return;
145
+ }
146
+ try {
147
+ for await (const chunk of response.body) {
148
+ if (target.destroyed) return;
149
+ target.write(chunk);
150
+ }
151
+ if (!target.destroyed) target.end();
152
+ } catch {
153
+ if (!target.destroyed) target.end();
154
+ }
155
+ }
156
+ /**
157
+ * Create a `node:http` request listener over a core {@link DispatcherInterface} —
158
+ * the whole server face's entry point (§5.3): convert the incoming message to
159
+ * a fetch `Request`, hand it to the dispatcher with the consumer's per-request
160
+ * `state`, and write the resulting `Response` back.
161
+ *
162
+ * @remarks
163
+ * A rejected `dispatcher.handle` (a route handler throw — the dispatcher
164
+ * never invents an error boundary, §5.1) is this listener's transport-level
165
+ * LAST RESORT, distinct from an application error boundary: when nothing has
166
+ * been sent yet, it destroys the connection with a bare `500` head (never
167
+ * leaking a hanging socket); once headers are already sent, it destroys the
168
+ * connection outright. The router still owns no error POLICY — a consumer
169
+ * that wants mapped error responses installs its own boundary around
170
+ * `dispatcher.handle` (the future `@orkestrel/server` seam, §7).
171
+ *
172
+ * @typeParam TState - The consumer's opaque per-request state type
173
+ * @param dispatcher - The core dispatcher to run each converted request through
174
+ * @param state - Derives the consumer's per-request `state` from the raw message
175
+ * @returns A `(request, response) => void` listener, passable directly to
176
+ * `http.createServer`
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * import { createListener } from '@src/server'
181
+ * import { createDispatcher } from '@src/core'
182
+ * import http from 'node:http'
183
+ *
184
+ * const dispatcher = createDispatcher()
185
+ * dispatcher.add({ method: 'GET', path: '/health', handler: () => new Response('ok') })
186
+ * http.createServer(createListener(dispatcher, () => undefined)).listen(0)
187
+ * ```
188
+ */
189
+ function createListener(dispatcher, state) {
190
+ 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
+ })();
202
+ };
203
+ }
204
+ //#endregion
205
+ export { buildRequest, createListener, isEncryptedSocket, sendResponse };
206
+
207
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orkestrel/router",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
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",
@@ -23,23 +23,34 @@
23
23
  ],
24
24
  "type": "module",
25
25
  "sideEffects": false,
26
- "main": "./dist/src/core/index.js",
27
- "types": "./dist/src/core/index.d.ts",
26
+ "main": "./dist/src/core/index.cjs",
27
+ "module": "./dist/src/core/index.js",
28
28
  "exports": {
29
29
  ".": {
30
- "types": "./dist/src/core/index.d.ts",
31
- "import": "./dist/src/core/index.js",
32
- "default": "./dist/src/core/index.js"
30
+ "import": {
31
+ "types": "./dist/src/core/index.d.ts",
32
+ "default": "./dist/src/core/index.js"
33
+ },
34
+ "require": {
35
+ "types": "./dist/src/core/index.d.cts",
36
+ "default": "./dist/src/core/index.cjs"
37
+ }
33
38
  },
34
39
  "./browser": {
35
- "types": "./dist/src/browser/index.d.ts",
36
- "import": "./dist/src/browser/index.js",
37
- "default": "./dist/src/browser/index.js"
40
+ "import": {
41
+ "types": "./dist/src/browser/index.d.ts",
42
+ "default": "./dist/src/browser/index.js"
43
+ }
38
44
  },
39
45
  "./server": {
40
- "types": "./dist/src/server/index.d.ts",
41
- "require": "./dist/src/server/index.cjs",
42
- "default": "./dist/src/server/index.cjs"
46
+ "import": {
47
+ "types": "./dist/src/server/index.d.ts",
48
+ "default": "./dist/src/server/index.js"
49
+ },
50
+ "require": {
51
+ "types": "./dist/src/server/index.d.cts",
52
+ "default": "./dist/src/server/index.cjs"
53
+ }
43
54
  },
44
55
  "./package.json": "./package.json"
45
56
  },
@@ -51,7 +62,7 @@
51
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)\"",
52
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}\"",
53
64
  "lint": "oxlint --config .oxlintrc.json --fix .",
54
- "check": "tsc --noEmit --project tsconfig.json",
65
+ "check": "tsc --noEmit --project tsconfig.json && npm run check:src",
55
66
  "check:src": "npm run check:src:core && npm run check:src:browser && npm run check:src:server",
56
67
  "check:src:core": "tsc --noEmit -p configs/src/tsconfig.core.json",
57
68
  "check:src:browser": "tsc --noEmit -p configs/src/tsconfig.browser.json",
@@ -67,27 +78,29 @@
67
78
  "test:guides": "vitest run --config vite.config.ts --reporter=dot --project guides",
68
79
  "build": "npm run clean && npm run build:src",
69
80
  "build:src": "npm run build:src:core && npm run build:src:browser && npm run build:src:server",
70
- "build:src:core": "vite build --config configs/src/vite.core.config.ts && tsc -p configs/src/tsconfig.core.json",
71
- "build:src:browser": "vite build --config configs/src/vite.browser.config.ts && tsc -p configs/src/tsconfig.browser.json",
72
- "build:src:server": "vite build --config configs/src/vite.server.config.ts && tsc -p configs/src/tsconfig.server.json",
73
- "prepublishOnly": "npm run format:check && npm run lint:check && npm run check && npm run check && npm run build && npm test"
81
+ "build:src:core": "vite build --config configs/src/vite.core.config.ts && npm run copy dist/src/core/index.d.ts dist/src/core/index.d.cts",
82
+ "build:src:browser": "vite build --config configs/src/vite.browser.config.ts",
83
+ "build:src:server": "vite build --config configs/src/vite.server.config.ts && npm run copy dist/src/server/index.d.ts dist/src/server/index.d.cts",
84
+ "prepublishOnly": "npm run format:check && npm run lint:check && npm run check && npm run build && npm test"
74
85
  },
75
86
  "dependencies": {
76
- "@orkestrel/abort": "^0.0.1",
77
- "@orkestrel/contract": "^0.0.1",
78
- "@orkestrel/emitter": "^0.0.1"
87
+ "@orkestrel/abort": "^0.0.2",
88
+ "@orkestrel/contract": "^0.0.2",
89
+ "@orkestrel/emitter": "^0.0.2"
79
90
  },
80
91
  "devDependencies": {
81
- "@orkestrel/guide": "^0.0.1",
92
+ "@microsoft/api-extractor": "^7.58.11",
93
+ "@orkestrel/guide": "^0.0.2",
82
94
  "@types/node": "^26.1.1",
83
95
  "@vitest/browser-playwright": "^4.1.10",
84
- "oxfmt": "^0.58.0",
85
- "oxlint": "^1.73.0",
96
+ "oxfmt": "^0.59.0",
97
+ "oxlint": "^1.74.0",
86
98
  "typescript": "^6.0.3",
87
- "vite": "^8.1.4",
99
+ "vite": "^8.1.5",
100
+ "vite-plugin-dts": "^5.0.3",
88
101
  "vitest": "^4.1.10"
89
102
  },
90
103
  "engines": {
91
- "node": ">=24"
104
+ "node": ">=22"
92
105
  }
93
106
  }
@@ -1,62 +0,0 @@
1
- import type { NavigatorEventMap, NavigatorInterface, NavigatorOptions } from './types.js';
2
- import type { EmitterInterface } from '@orkestrel/emitter';
3
- import type { RouteEntry, RouterInterface, RouterMatch } from '@src/core';
4
- /**
5
- * The headless History/hash navigation entity — composes one core
6
- * `Router<RouteEntry<Meta>>`, resolving the current location on `start()` and
7
- * every subsequent navigation event, tracking `active`, and emitting
8
- * `navigate` through the core {@link Emitter} (AGENTS §13). No `render` /
9
- * `outlet` — the consumer owns rendering.
10
- *
11
- * @typeParam Meta - The opaque per-route payload a match carries back
12
- *
13
- * @remarks
14
- * - **One shared engine.** Each `route.path` is registered on the SAME
15
- * `Router` machine the core `Dispatcher` composes, keyed for dedup by its
16
- * {@link canonicalizePath} (last write wins, replace-in-place) — literal-
17
- * over-param precedence, trailing-slash insensitivity, and
18
- * `:param`/`*wildcard` extraction all come from that one engine (AGENTS
19
- * §21).
20
- * - **Resolve pipeline.** Compute the `/`-prefixed pathname to match
21
- * ({@link resolveLocationPath}) → {@link match} it → on a miss, match the
22
- * `fallback` through the SAME engine → a fallback that ALSO matches nothing
23
- * aborts any pending guarded navigation (a miss SUPERSEDES it, same as a
24
- * newer navigation) and leaves `active` `undefined`, emitting nothing
25
- * (§21-honest: no phantom match is fabricated) → the optional `guard` may
26
- * veto → on a verdict, `active` is set and `navigate` emitted.
27
- * - **Supersede-safe guard.** Every navigation mints an `@orkestrel/abort`
28
- * handle, aborting the PREVIOUS navigation's handle first; a guard verdict
29
- * that resolves after its navigation was superseded (`signal.aborted`) is
30
- * discarded, same as a `false`/rejected verdict. A guard throw routes to
31
- * the `error` handler and vetoes. `stop()`/`destroy()` also abort the
32
- * pending handle.
33
- * - **Hash vs history mode.** Hash mode (`history: false`, the default) binds
34
- * `hashchange`; history mode (`history: true`) binds `popstate` and, when
35
- * `intercept` is set, same-origin `<a>` click interception (a plain
36
- * left-click with no modifier keys, `target`, or `download` attribute).
37
- *
38
- * @example
39
- * ```ts
40
- * const navigator = new Navigator<{ readonly title: string }>({
41
- * routes: [
42
- * { path: '/users/:id', meta: { title: 'User' } },
43
- * { path: '/tokens', meta: { title: 'Tokens' } },
44
- * ],
45
- * })
46
- * navigator.emitter.on('navigate', (match) => (document.title = match.meta.title))
47
- * navigator.start() // resolves the current hash now, and on every hashchange
48
- * navigator.navigate('/tokens')
49
- * ```
50
- */
51
- export declare class Navigator<Meta> implements NavigatorInterface<Meta> {
52
- #private;
53
- constructor(options: NavigatorOptions<Meta>);
54
- get router(): RouterInterface<RouteEntry<Meta>>;
55
- get emitter(): EmitterInterface<NavigatorEventMap<Meta>>;
56
- get active(): RouterMatch<Meta> | undefined;
57
- start(): void;
58
- stop(): void;
59
- navigate(path: string): void;
60
- match(path: string): RouterMatch<Meta> | undefined;
61
- destroy(): void;
62
- }
@@ -1,33 +0,0 @@
1
- import type { NavigatorInterface, NavigatorOptions } from './types.js';
2
- /**
3
- * Create a {@link NavigatorInterface} — the headless History/hash navigation
4
- * entity composing one core `Router<RouteEntry<Meta>>`.
5
- *
6
- * @remarks
7
- * Prefer this over `new Navigator(...)` at call sites that only need the
8
- * interface.
9
- *
10
- * @typeParam Meta - The opaque per-route payload a match carries back
11
- * @param options - The `routes` to register, the `history` toggle (default
12
- * `false`, hash mode), an optional `base` (history mode), an optional
13
- * `fallback` path, an optional `guard` hook, opt-in link `intercept`
14
- * (history mode), the `sensitive` case toggle, and the AGENTS §13 emitter
15
- * `on`/`error` wiring
16
- * @returns A live {@link NavigatorInterface} handle — call `start()` to begin
17
- * dispatching
18
- *
19
- * @example
20
- * ```ts
21
- * import { createNavigator } from '@src/browser'
22
- *
23
- * const navigator = createNavigator({
24
- * routes: [
25
- * { path: '/users/:id', meta: { title: 'User' } },
26
- * { path: '/tokens', meta: { title: 'Tokens' } },
27
- * ],
28
- * on: { navigate: (match) => (document.title = match.meta.title) },
29
- * })
30
- * navigator.start()
31
- * ```
32
- */
33
- export declare function createNavigator<Meta>(options: NavigatorOptions<Meta>): NavigatorInterface<Meta>;
@@ -1,76 +0,0 @@
1
- /**
2
- * Extract the `/`-prefixed pathname from a `location.hash` value — strip the
3
- * leading `#` (keeping the route's own leading `/`) and any `?query` suffix.
4
- *
5
- * @remarks
6
- * The grammar this package matches everywhere is `/`-prefixed (§4 path
7
- * grammar), so a hash-mode location's `'#/users/7?x'` becomes `'/users/7'`
8
- * — a hash pattern is expected to start `'#/'`; anything else (an empty hash,
9
- * or one that does not begin `'#/'`) yields `''` (the `Navigator` then falls
10
- * back). Total — never throws.
11
- *
12
- * @param hash - The raw `window.location.hash` value (e.g. `'#/users/7?x'`)
13
- * @returns The `/`-prefixed pathname to match, or `''` for an empty / non-`#/` hash
14
- *
15
- * @example
16
- * ```ts
17
- * extractHashPath('#/users/7?x') // '/users/7'
18
- * extractHashPath('#/tokens') // '/tokens'
19
- * extractHashPath('') // '' — the Navigator falls back
20
- * extractHashPath('#other') // '' — not a `#/` route hash
21
- * ```
22
- */
23
- export declare function extractHashPath(hash: string): string;
24
- /**
25
- * Resolve the `/`-prefixed pathname to match for the CURRENT location, in
26
- * either navigation mode — the one seam `extractHashPath` (hash mode) and
27
- * history-mode base-stripping share.
28
- *
29
- * @remarks
30
- * Hash mode (`history: false`) reads `location.hash` through
31
- * {@link extractHashPath}. History mode (`history: true`) reads
32
- * `location.pathname` and strips a leading `base` prefix when one is
33
- * configured: `base` itself maps to the root `'/'`; a pathname that is not
34
- * under `base` is returned unchanged (a base mismatch is not this helper's
35
- * concern — the `Navigator`'s match then simply misses). Total — never throws.
36
- *
37
- * @param location - The `hash` + `pathname` pair to resolve from (accepts a
38
- * real `Location` or any object shaped the same, for pure unit testing)
39
- * @param history - The navigation substrate: `false` for hash mode, `true`
40
- * for history mode
41
- * @param base - The history-mode path prefix to strip (ignored in hash mode;
42
- * omit for no prefix)
43
- * @returns The `/`-prefixed pathname to match
44
- *
45
- * @example
46
- * ```ts
47
- * resolveLocationPath({ hash: '#/users/7', pathname: '/' }, false) // '/users/7'
48
- * resolveLocationPath({ hash: '', pathname: '/app/users/7' }, true, '/app') // '/users/7'
49
- * resolveLocationPath({ hash: '', pathname: '/app' }, true, '/app') // '/'
50
- * resolveLocationPath({ hash: '', pathname: '/other/users' }, true, '/app') // '/other/users'
51
- * ```
52
- */
53
- export declare function resolveLocationPath(location: Pick<Location, 'hash' | 'pathname'>, history: boolean, base?: string): string;
54
- /**
55
- * Find the nearest enclosing `<a>` element a DOM event originated from, by
56
- * walking its composed path — the pure lookup behind history-mode link
57
- * interception.
58
- *
59
- * @remarks
60
- * Uses `event.composedPath()` (not `event.target`) so a click on a styled
61
- * child INSIDE an anchor (an icon, a span) still resolves to the anchor.
62
- * Total — never throws; returns `undefined` when no anchor is found on the
63
- * path.
64
- *
65
- * @param event - The DOM event to search (typically a `click`)
66
- * @returns The nearest enclosing `HTMLAnchorElement`, or `undefined`
67
- *
68
- * @example
69
- * ```ts
70
- * document.addEventListener('click', (event) => {
71
- * const anchor = findAnchor(event)
72
- * if (anchor !== undefined) console.log(anchor.href)
73
- * })
74
- * ```
75
- */
76
- export declare function findAnchor(event: Event): HTMLAnchorElement | undefined;
@@ -1,101 +0,0 @@
1
- import type { EmitterErrorHandler, EmitterHooks, EmitterInterface } from '@orkestrel/emitter';
2
- import type { RouteEntry, RouterInterface, RouterMatch } from '@src/core';
3
- /**
4
- * The `Navigator`'s event map (AGENTS §13) — the single `navigate` signal a
5
- * consumer observes.
6
- *
7
- * @typeParam Meta - The opaque per-route payload the resolved match carries
8
- *
9
- * @remarks
10
- * `navigate` fires once per successful resolution (start, hashchange/popstate,
11
- * `navigate()`, link interception) — never for a vetoed or superseded navigation
12
- * ({@link NavigatorOptions.guard}), and never when a miss's fallback also
13
- * misses (§21-honest: `active` is left `undefined`, nothing emitted).
14
- */
15
- export type NavigatorEventMap<Meta> = {
16
- readonly navigate: readonly [match: RouterMatch<Meta>];
17
- };
18
- /**
19
- * Options for `createNavigator` — the `routes` to dispatch between, the
20
- * navigation substrate, the optional guard hook, and the AGENTS §13 emitter
21
- * wiring.
22
- *
23
- * @typeParam Meta - The opaque payload each route may carry
24
- *
25
- * @remarks
26
- * - `routes` — the route entries to register once with the shared core
27
- * `Router` (each `path` compiled once); registration order does NOT decide
28
- * precedence — specificity does (literal-over-param-over-wildcard).
29
- * - `history` — `false` (default, hash mode: `#/…` + `hashchange`, zero
30
- * server configuration) or `true` (history mode: `pushState`/`popstate`).
31
- * - `base` — a history-mode path prefix stripped from `location.pathname`
32
- * before matching, and prepended when navigating (`navigate`, link
33
- * interception). Ignored unless `history` is set.
34
- * - `fallback` — the route PATTERN to resolve when the current location
35
- * matches NOTHING. Omitted ⇒ the first route's path. A `fallback` that
36
- * itself matches no registered route leaves `active` `undefined` and emits
37
- * nothing (§21-honest: no phantom match is fabricated).
38
- * - `guard` — `(to, from, signal) => boolean | Promise<boolean>`, called
39
- * before a navigation commits; a `false`/rejected verdict, or one arriving
40
- * after the navigation was SUPERSEDED (`signal.aborted`), is discarded —
41
- * `active` stays unchanged and nothing is emitted. `signal` fires when a
42
- * NEWER navigation starts (or on `stop`/`destroy`), so a slow async guard
43
- * can cancel its own work off it. A throw routes to the `error` handler
44
- * below and vetoes the navigation.
45
- * - `intercept` — opt-in same-origin `<a>` click interception (history mode
46
- * only): a plain left-click on a same-origin link with no modifier keys,
47
- * no `target`, and no `download` attribute is intercepted into `navigate`.
48
- * - `sensitive` — forwarded to the underlying `Router` (default `true`).
49
- * - `on` — initial `NavigatorEventMap` listeners (AGENTS §8/§13).
50
- * - `error` — the emitter's listener-error handler (AGENTS §13); ALSO the
51
- * handler a thrown {@link guard} routes to (the Navigator's own pipeline,
52
- * not a listener throw, so it is surfaced through the same channel).
53
- */
54
- export interface NavigatorOptions<Meta> {
55
- readonly routes: readonly RouteEntry<Meta>[];
56
- readonly history?: boolean;
57
- readonly base?: string;
58
- readonly fallback?: string;
59
- readonly guard?: (to: RouterMatch<Meta>, from: RouterMatch<Meta> | undefined, signal: AbortSignal) => boolean | Promise<boolean>;
60
- readonly intercept?: boolean;
61
- readonly sensitive?: boolean;
62
- readonly on?: EmitterHooks<NavigatorEventMap<Meta>>;
63
- readonly error?: EmitterErrorHandler;
64
- }
65
- /**
66
- * The headless History/hash navigation entity contract (the §4.5 behavioral-
67
- * interface role for the one-class-per-file `Navigator`). Composes a core
68
- * `Router<RouteEntry<Meta>>`, resolves the current location on `start()` and
69
- * on every subsequent navigation event, tracks `active`, and emits
70
- * `navigate` through the AGENTS §13 {@link EmitterInterface}.
71
- *
72
- * @typeParam Meta - The opaque per-route payload a match carries back
73
- *
74
- * @remarks
75
- * - `router` — the underlying registry, exposed READONLY for introspection
76
- * (the same object routes were registered on).
77
- * - `emitter` — the AGENTS §13 observable surface for {@link NavigatorEventMap}.
78
- * - `active` — the currently-resolved {@link RouterMatch}, or `undefined`
79
- * before the first resolve (or when a miss's fallback also misses).
80
- * - `start()` — begin listening (`hashchange` in hash mode; `popstate` +
81
- * optional link interception in history mode) and resolve the current
82
- * location now. Idempotent — a second call is a no-op.
83
- * - `stop()` — stop listening. Idempotent.
84
- * - `navigate(path)` — navigate programmatically: sets `location.hash` (hash
85
- * mode) or calls `history.pushState` (history mode), then resolves. A
86
- * no-op hash navigation (already the active hash) resolves directly, since
87
- * no `hashchange` would otherwise fire.
88
- * - `match(path)` — a PURE lookup through the underlying `Router`: no
89
- * location read, no fallback, no guard, no emit.
90
- * - `destroy()` — `stop()` plus tear down the `#emitter` (AGENTS §13).
91
- */
92
- export interface NavigatorInterface<Meta> {
93
- readonly router: RouterInterface<RouteEntry<Meta>>;
94
- readonly emitter: EmitterInterface<NavigatorEventMap<Meta>>;
95
- readonly active: RouterMatch<Meta> | undefined;
96
- start(): void;
97
- stop(): void;
98
- navigate(path: string): void;
99
- match(path: string): RouterMatch<Meta> | undefined;
100
- destroy(): void;
101
- }
@@ -1,32 +0,0 @@
1
- import type { DispatchGroupInterface, DispatcherInterface, RouteInput } from './types.js';
2
- /**
3
- * A prefix-scoped registration handle over a
4
- * {@link import('./Dispatcher.js').Dispatcher} — the method-dimensioned
5
- * counterpart of `Group` (`Group.ts`).
6
- *
7
- * @typeParam TState - The consumer's opaque per-request state type, matching
8
- * the owning dispatcher
9
- *
10
- * @remarks
11
- * Every `add` composes `input.path` via {@link joinPaths} against
12
- * `this.prefix` and forwards to the OWNING dispatcher's `add` (its own §14
13
- * boundary guard still applies). Pure string composition (§4.2.2) — no
14
- * independent state or storage.
15
- *
16
- * @example
17
- * ```ts
18
- * import { Dispatcher } from '@src/core'
19
- *
20
- * const dispatcher = new Dispatcher()
21
- * const api = dispatcher.group('/api')
22
- * api.add({ method: 'GET', path: '/users', handler: () => new Response('ok') })
23
- * ```
24
- */
25
- export declare class DispatchGroup<TState> implements DispatchGroupInterface<TState> {
26
- #private;
27
- readonly prefix: string;
28
- constructor(parent: DispatcherInterface<TState>, prefix: string);
29
- add<Path extends string>(input: RouteInput<Path, TState>): void;
30
- add(inputs: readonly RouteInput<string, TState>[]): void;
31
- group(prefix: string): DispatchGroupInterface<TState>;
32
- }