@orkestrel/router 0.0.1 → 0.0.2
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/dist/src/browser/index.d.ts +283 -4
- package/dist/src/browser/index.js +4 -300
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +893 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +1067 -0
- package/dist/src/core/index.d.ts +1067 -8
- package/dist/src/core/index.js +33 -175
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +4 -210
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/{helpers.d.ts → index.d.cts} +166 -127
- package/dist/src/server/index.d.ts +166 -2
- package/dist/src/server/index.js +207 -0
- package/dist/src/server/index.js.map +1 -0
- package/package.json +32 -19
- package/dist/src/browser/Navigator.d.ts +0 -62
- package/dist/src/browser/factories.d.ts +0 -33
- package/dist/src/browser/helpers.d.ts +0 -76
- package/dist/src/browser/types.d.ts +0 -101
- package/dist/src/core/DispatchGroup.d.ts +0 -32
- package/dist/src/core/Dispatcher.d.ts +0 -51
- package/dist/src/core/Group.d.ts +0 -30
- package/dist/src/core/Router.d.ts +0 -42
- package/dist/src/core/constants.d.ts +0 -60
- package/dist/src/core/factories.d.ts +0 -53
- package/dist/src/core/helpers.d.ts +0 -274
- package/dist/src/core/types.d.ts +0 -445
- package/dist/src/server/types.d.ts +0 -31
|
@@ -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.
|
|
3
|
+
"version": "0.0.2",
|
|
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.
|
|
27
|
-
"
|
|
26
|
+
"main": "./dist/src/core/index.cjs",
|
|
27
|
+
"module": "./dist/src/core/index.js",
|
|
28
28
|
"exports": {
|
|
29
29
|
".": {
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
"
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
"import": {
|
|
41
|
+
"types": "./dist/src/browser/index.d.ts",
|
|
42
|
+
"default": "./dist/src/browser/index.js"
|
|
43
|
+
}
|
|
38
44
|
},
|
|
39
45
|
"./server": {
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
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
|
},
|
|
@@ -67,10 +78,10 @@
|
|
|
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 &&
|
|
71
|
-
"build:src:browser": "vite build --config configs/src/vite.browser.config.ts
|
|
72
|
-
"build:src:server": "vite build --config configs/src/vite.server.config.ts &&
|
|
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 check:src && npm run build && npm test"
|
|
74
85
|
},
|
|
75
86
|
"dependencies": {
|
|
76
87
|
"@orkestrel/abort": "^0.0.1",
|
|
@@ -78,13 +89,15 @@
|
|
|
78
89
|
"@orkestrel/emitter": "^0.0.1"
|
|
79
90
|
},
|
|
80
91
|
"devDependencies": {
|
|
92
|
+
"@microsoft/api-extractor": "^7.58.10",
|
|
81
93
|
"@orkestrel/guide": "^0.0.1",
|
|
82
94
|
"@types/node": "^26.1.1",
|
|
83
95
|
"@vitest/browser-playwright": "^4.1.10",
|
|
84
|
-
"oxfmt": "^0.
|
|
85
|
-
"oxlint": "^1.
|
|
96
|
+
"oxfmt": "^0.59.0",
|
|
97
|
+
"oxlint": "^1.74.0",
|
|
86
98
|
"typescript": "^6.0.3",
|
|
87
|
-
"vite": "^8.1.
|
|
99
|
+
"vite": "^8.1.5",
|
|
100
|
+
"vite-plugin-dts": "^5.0.3",
|
|
88
101
|
"vitest": "^4.1.10"
|
|
89
102
|
},
|
|
90
103
|
"engines": {
|
|
@@ -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
|
-
}
|