@orkestrel/scaffold 0.0.66 → 0.0.68
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/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +8 -8
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1509 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +311 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +437 -6
- package/dist/src/core/index.cjs +44 -22
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +43 -23
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +9 -9
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
# Router
|
|
2
|
+
|
|
3
|
+
> The typed request router: a path-matching engine (`Router`) that compiles route patterns,
|
|
4
|
+
> extracts URL-decoded params, and resolves the most specific match, with a fetch-standard,
|
|
5
|
+
> method-dimensioned dispatcher (`Dispatcher`), a headless History or hash `Navigator`, and a
|
|
6
|
+
> `node:http` adapter all composing that same engine.
|
|
7
|
+
|
|
8
|
+
This guide covers every face the package publishes. The core is pure and
|
|
9
|
+
environment-agnostic: it speaks `string`, `RegExp`, `URL`, `Request`, and `Response`, and
|
|
10
|
+
neither DOM nor `node:*`. Precedence, trailing-slash folding, tolerant percent-decoding, and
|
|
11
|
+
the `answers` override seam all live in the engine rather than in a face, which is what keeps
|
|
12
|
+
the browser and server faces thin; a native override earns its place only on a genuinely
|
|
13
|
+
faster path. Source: [`src/core`](../src/core), [`src/browser`](../src/browser), and
|
|
14
|
+
[`src/server`](../src/server), surfaced through the `@orkestrel/router` barrel (aliased
|
|
15
|
+
`@src/core` / `@src/browser` / `@src/server` inside this repo).
|
|
16
|
+
|
|
17
|
+
## Surface
|
|
18
|
+
|
|
19
|
+
### Register and match
|
|
20
|
+
|
|
21
|
+
Register routes on a `Router`, resolve the most-specific match, and dispatch
|
|
22
|
+
fetch-standard requests through a `Dispatcher`:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { createDispatcher, createRouter } from '@orkestrel/router'
|
|
26
|
+
|
|
27
|
+
const router = createRouter<{ readonly page: string }>()
|
|
28
|
+
router.add({ path: '/users/:id', meta: { page: 'profile' } })
|
|
29
|
+
router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }
|
|
30
|
+
|
|
31
|
+
const dispatcher = createDispatcher<{ readonly userId: string }>({
|
|
32
|
+
routes: [
|
|
33
|
+
{
|
|
34
|
+
method: 'GET',
|
|
35
|
+
path: '/users/:id',
|
|
36
|
+
handler: (_request, context) => Response.json(context.params),
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
})
|
|
40
|
+
const response = await dispatcher.handle(new Request('http://x/users/7'), { userId: 'me' })
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Path patterns are `/`-prefixed: a literal segment (`/users`), a `:name` param
|
|
44
|
+
(one segment), or a final `*name` wildcard (captures the rest of the path).
|
|
45
|
+
Matching is case-sensitive by default (`sensitive: false` opts out); a single
|
|
46
|
+
trailing slash is always optional except on the root `/` and the empty
|
|
47
|
+
pattern.
|
|
48
|
+
|
|
49
|
+
Browser and server usage appear under [Patterns](#patterns).
|
|
50
|
+
|
|
51
|
+
### Factories
|
|
52
|
+
|
|
53
|
+
| API | Kind | Summary |
|
|
54
|
+
| ------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| `createRouter` | function | Creates a `RouterInterface` — the pure path-matching + registry engine shared by the browser `Navigator` and the core `Dispatcher`. |
|
|
56
|
+
| `createDispatcher` | function | Creates a `DispatcherInterface` — the fetch-standard, method-dimensioned dispatch entity over one internal `Router<RouteRecord<TState>>`. |
|
|
57
|
+
| `createNavigator` | function | Creates a `NavigatorInterface` — the headless History/hash navigation entity composing one core `Router<Meta>`. |
|
|
58
|
+
|
|
59
|
+
### Constants
|
|
60
|
+
|
|
61
|
+
A `Shape` cell holds the constant's declared type.
|
|
62
|
+
|
|
63
|
+
| API | Kind | Shape | Summary |
|
|
64
|
+
| --------------- | ----- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| `METHOD_LIST` | const | `readonly ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']` | Lists the HTTP methods a `DispatcherInterface` registers routes under, in canonical order — a frozen literal tuple, and the single source the `Method` type, `METHODS`, and `parseMethod` are all derived from. |
|
|
66
|
+
| `METHODS` | const | `ReadonlySet<string>` | Holds every HTTP method a `DispatcherInterface` registers routes under as a `ReadonlySet` — backs the registration guard (`add` rejects any `method` outside this set) and the auto-`OPTIONS` `Allow` derivation. |
|
|
67
|
+
| `TIER_LITERAL` | const | `number` | Names the specificity tier for a \*\*literal\*\* path segment (`/users`) — the highest tier, always outranking a param or wildcard segment at the same position. |
|
|
68
|
+
| `TIER_PARAM` | const | `number` | Names the specificity tier for a \*\*param\*\* path segment (`:name`) — ranks below a literal segment and above a wildcard segment at the same position. |
|
|
69
|
+
| `TIER_WILDCARD` | const | `number` | Names the specificity tier for a \*\*wildcard\*\* path segment (`*name`) — the lowest tier; a wildcard only ever wins against another wildcard shape (an equal-specificity tie resolved by registration order). |
|
|
70
|
+
|
|
71
|
+
### Helpers
|
|
72
|
+
|
|
73
|
+
| API | Kind | Summary |
|
|
74
|
+
| ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
75
|
+
| `escapeRegExp` | function | Escapes every regex metacharacter in a literal string so it can be embedded inside a larger `RegExp` source without being interpreted as syntax. |
|
|
76
|
+
| `canonicalizePath` | function | Canonicalizes a route path for registry identity — strips a single trailing slash, except the root `/` (and the empty pattern). The trailing-slash fold `compilePath` normalizes a pattern through, so identity agrees with the matcher. |
|
|
77
|
+
| `computeDispatchKey` | function | Computes the canonical `METHOD /path` registry key for a method-dimensioned dispatcher route. |
|
|
78
|
+
| `compilePath` | function | Compiles a route path pattern into an anchored regex and its ordered param names. |
|
|
79
|
+
| `decodeParam` | function | Decodes one captured param value from a URL, tolerating a malformed percent-escape — the decode `matchPath` applies to each captured group. |
|
|
80
|
+
| `matchPath` | function | Extracts the URL-decoded params a compiled path captures from a concrete pathname, or `undefined` when the pathname does not match. |
|
|
81
|
+
| `classifySegment` | function | Classifies one path segment into its specificity tier — the same syntax `compilePath` rewrites: a syntactically valid `:name` head is a param segment, a final `*name` is a wildcard segment, and everything else (including a literal segment that merely contains a `:` mid-string, for example `a:b`) is a literal segment. |
|
|
82
|
+
| `computeSpecificity` | function | Computes a route path's specificity vector — the per-segment type ranking that breaks a tie when several registered routes match the same concrete pathname. |
|
|
83
|
+
| `compareSpecificity` | function | Compares two route paths by specificity — the comparator that picks the most-specific matching route (literal-over-param-over-wildcard, registration-order-independent). |
|
|
84
|
+
| `joinPaths` | function | Joins a group prefix and a route path into one `/`-prefixed path, normalizing duplicate or missing joining slashes. |
|
|
85
|
+
| `defineRoute` | function | Provides an identity pass-through for a `RouteInput` that pins its `Path` generic to the literal registration-site string, so `context.params` types correctly through `PathParams` without an explicit type argument. |
|
|
86
|
+
| `computeNavigationKey` | function | Computes the canonical path key a `Navigator` registers a browser navigation route under. |
|
|
87
|
+
| `extractHashPath` | function | Extracts the `/`-prefixed pathname from a `location.hash` value — strips the leading `#` (keeping the route's own leading `/`) and any `?query` suffix. |
|
|
88
|
+
| `resolveLocationPath` | function | Resolves the `/`-prefixed pathname to match for the current location, in either navigation mode — the one seam `extractHashPath` (hash mode) and history-mode base-stripping share. |
|
|
89
|
+
| `findAnchor` | function | Finds the nearest enclosing `<a>` element a DOM event originated from, by walking its composed path — the pure lookup behind history-mode link interception. |
|
|
90
|
+
| `buildRequest` | function | Builds a fetch-standard `Request` from a `node:http` `IncomingMessage` — the server-adapter half of the fetch/node conversion seam. |
|
|
91
|
+
| `sendResponse` | function | Writes a fetch-standard `Response` back to a `node:http` `ServerResponse` — the reverse half of the fetch/node conversion seam. |
|
|
92
|
+
|
|
93
|
+
### Parsers
|
|
94
|
+
|
|
95
|
+
| API | Kind | Summary |
|
|
96
|
+
| ------------- | -------- | ---------------------------------------------------------------------------------- |
|
|
97
|
+
| `parseMethod` | function | Narrows a raw `request.method` string into a typed `Method` — total, never throws. |
|
|
98
|
+
|
|
99
|
+
### Guards
|
|
100
|
+
|
|
101
|
+
In a guard table a `Shape` cell holds the type the guard narrows to.
|
|
102
|
+
|
|
103
|
+
| API | Kind | Shape | Summary |
|
|
104
|
+
| ------------------- | -------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
105
|
+
| `isEncryptedSocket` | function | `{ encrypted }` | Determines whether a `node:http` connection socket is TLS-encrypted — the total, never-throwing narrow `buildRequest` uses to pick the derived scheme (`https` vs `http`). |
|
|
106
|
+
|
|
107
|
+
### Handlers
|
|
108
|
+
|
|
109
|
+
| API | Kind | Summary |
|
|
110
|
+
| ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
111
|
+
| `handleListenerRequest` | function | Handles one `node:http` request through a core dispatcher and writes its fetch-standard response. |
|
|
112
|
+
| `createListener` | function | Creates a `node:http` request listener over a core `DispatcherInterface` — the whole server face's entry point: converts the incoming message to a fetch `Request`, hands it to the dispatcher with the consumer's per-request `state`, and writes the resulting `Response` back. |
|
|
113
|
+
|
|
114
|
+
### Classes
|
|
115
|
+
|
|
116
|
+
| API | Kind | Summary |
|
|
117
|
+
| --------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
118
|
+
| `Router` | class | Represents the path-matching + registry engine — registers `{ path, meta, name? }` entries (compiling each path once) and resolves a concrete pathname to the most specific matching entry. The shared machine both the `Navigator` (browser) and the `Dispatcher` (core, method-dimensioned) compose. |
|
|
119
|
+
| `Group` | class | Represents a prefix-scoped registration handle over a `Router` — pure string composition, no independent state or storage. |
|
|
120
|
+
| `Dispatcher` | class | Represents the fetch-standard, method-dimensioned dispatch entity — layers HTTP method dispatch and web-standard `Request`/`Response` handling over one internal `Router<RouteRecord<TState>>`. The core machine the server face and any fetch-native runtime consume directly. |
|
|
121
|
+
| `DispatchGroup` | class | Represents a prefix-scoped registration handle over a `Dispatcher` — the method-dimensioned counterpart of `Group`. |
|
|
122
|
+
| `Navigator` | class | Represents the headless History/hash navigation entity — composes one core `Router<Meta>`, resolving the current location on `start()` and every subsequent navigation event, tracking `active`, and emitting `navigate` through the core `Emitter`. No `render` / `outlet` — the consumer owns rendering. |
|
|
123
|
+
|
|
124
|
+
### Types
|
|
125
|
+
|
|
126
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`.
|
|
127
|
+
|
|
128
|
+
| Type | Kind | Shape | Summary |
|
|
129
|
+
| ------------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
130
|
+
| `PathParams` | type | `{ readonly [K in keyof PathParamsRaw<Path>]: PathParamsRaw<Path>[K] }` | Extracts `{ name: string }` param records from a path pattern at the type level — the typed half of the path grammar. |
|
|
131
|
+
| `PathParamsRaw` | type | ``string extends Path ? Readonly<Record<string, string>> : Path extends `${infer Segment}/${infer Rest}` ? SegmentParam<Segment> & PathParamsRaw<Rest> : SegmentParam<Path>`` | Performs recursive, unflattened param extraction for `PathParams` — walks a path pattern segment by segment (split on `/`), extracting each segment's `SegmentParam` contribution and intersecting the rest. |
|
|
132
|
+
| `IdentifierStartChar` | type | `'a' \| 'b' \| 'c' \| 'd' \| 'e' \| 'f' \| 'g' \| 'h' \| 'i' \| 'j' \| 'k' \| 'l' \| 'm' \| 'n' \| 'o' \| 'p' \| 'q' \| 'r' \| 's' \| 't' \| 'u' \| 'v' \| 'w' \| 'x' \| 'y' \| 'z' \| 'A' \| 'B' \| 'C' \| 'D' \| 'E' \| 'F' \| 'G' \| 'H' \| 'I' \| 'J' \| 'K' \| 'L' \| 'M' \| 'N' \| 'O' \| 'P' \| 'Q' \| 'R' \| 'S' \| 'T' \| 'U' \| 'V' \| 'W' \| 'X' \| 'Y' \| 'Z' \| '_'` | Names the identifier start characters an identifier-grammar param name may begin with — mirrors the runtime classifier's `[A-Za-z_]` head class, the one `classifySegment` and `compilePath` share. |
|
|
133
|
+
| `IdentifierChar` | type | `IdentifierStartChar \| '0' \| '1' \| '2' \| '3' \| '4' \| '5' \| '6' \| '7' \| '8' \| '9'` | Names the identifier continuation characters after the first — mirrors the runtime classifier's `[A-Za-z0-9_]*` tail class. |
|
|
134
|
+
| `TakeIdentifierTail` | type | ``S extends `${infer Head}${infer Tail}` ? Head extends IdentifierChar ? TakeIdentifierTail<Tail, `${Acc}${Head}`> : Acc : Acc`` | Consumes the identifier-continuation run at the front of a string literal, char by char, appending each onto the accumulator. |
|
|
135
|
+
| `IdentifierHead` | type | ``S extends `${infer Head}${infer Tail}` ? Head extends IdentifierStartChar ? TakeIdentifierTail<Tail, Head> : '' : ''`` | Captures the identifier at the front of a string literal, or an empty string when the literal does not begin with an identifier-start char. |
|
|
136
|
+
| `SegmentParam` | type | ``Segment extends `:${infer Rest}` ? IdentifierHead<Rest> extends infer Name extends string ? Name extends '' ? unknown : { readonly [K in Name]: string } : unknown : Segment extends `*${infer Rest}` ? IdentifierHead<Rest> extends infer Name extends string ? Name extends '' ? unknown : { readonly [K in Name]: string } : unknown : unknown`` | Contributes one path segment's type-level param record — the type-level mirror of the runtime `classifySegment` and `compilePath` segment parser. |
|
|
137
|
+
| `CompiledPath` | interface | `{ regex, params }` | Represents a compiled route path — the anchored regex plus its ordered param names. |
|
|
138
|
+
| `RouteEntry` | interface | `{ path, meta, name? }` | Represents one registered route in a `RouterInterface` — the `path` pattern plus the opaque `meta` payload to return on a match, with an optional `name`. |
|
|
139
|
+
| `RouterMatch` | interface | `{ path, params, meta, name? }` | Represents one matched route — the winning entry's registered pattern, its decoded params, its `meta` payload, and its optional `name`. |
|
|
140
|
+
| `AnswerHandler` | type | `(meta: Meta) => boolean` | Represents the native-override seam — a predicate deciding whether an entry's `meta` answers a given `match` call, beyond path matching. |
|
|
141
|
+
| `RouterOptions` | interface | `{ entries?, sensitive?, key? }` | Represents the options for `createRouter` — an optional initial entry set, the case-sensitivity toggle, and the dedup identity function. |
|
|
142
|
+
| `RouterInterface` | interface | `{ count } plus add, match, entries, group, clear` | Represents the path-matching + registry engine contract (the behavioral-interface role for the one-class-per-file `Router`). Registers `{ path, meta, name? }` entries (compiling each path once) and resolves a concrete pathname to the most specific matching entry — a literal segment beats a param beats a wildcard at the earliest differing segment, registration-order-independent. The shared engine both the `Navigator` (browser) and the `Dispatcher` (core, method-dimensioned) compose. |
|
|
143
|
+
| `GroupInterface` | interface | `{ prefix } plus add, group` | Represents a prefix-scoped registration handle over a `RouterInterface` — pure string composition, no independent state or storage. |
|
|
144
|
+
| `Method` | type | `'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE' \| 'HEAD' \| 'OPTIONS'` | Names the HTTP methods a `DispatcherInterface` dimensions dispatch over — derived from `METHOD_LIST`, whose membership counterpart is `METHODS`. |
|
|
145
|
+
| `RouteContext` | interface | `{ params, pattern, url, state }` | Represents the ambient context a `RouteHandler` receives alongside the raw `Request` — decoded params, the winning pattern, the parsed URL, and the consumer's opaque per-request state. |
|
|
146
|
+
| `RouteHandler` | type | `(request: Request, context: RouteContext<Path, TState>) => Response \| Promise<Response>` | Receives the raw fetch `Request` plus its typed `RouteContext` and returns (or resolves) a fetch `Response`. |
|
|
147
|
+
| `RouteInput` | interface | `{ method, path, handler, name? }` | Represents one route registration input for `DispatcherInterface.add` — the method-dimensioned counterpart of `RouteEntry`. |
|
|
148
|
+
| `RouteRecord` | interface | `{ method, handler, name? }` | Represents the `meta` payload a `DispatcherInterface` stores in its underlying `Router` — what `RouterInterface.match` returns as `RouterMatch.meta` on a dispatch hit. |
|
|
149
|
+
| `DispatchResult` | type | `{ status: 'matched', match } \| { status: 'unmethoded', allow } \| { status: 'unmatched' }` | Represents the outcome of `DispatcherInterface.match` — a discriminated union over the dispatch tiers: a full hit, a path that matches with no route for the method (405 territory), or nothing matched at all (404 territory). |
|
|
150
|
+
| `DispatcherEventMap` | type | `{ match, miss }` | Represents the `Dispatcher`'s event map — the dispatch-outcome signals a consumer can observe alongside the return value of `handle`. |
|
|
151
|
+
| `DispatcherOptions` | interface | `{ routes?, sensitive?, unmatched?, unmethoded?, on?, error? }` | Represents the options for `createDispatcher` — initial routes, case sensitivity, the default-responder overrides, and the Emitter pattern's wiring. |
|
|
152
|
+
| `DispatcherInterface` | interface | `{ router, emitter } plus add, group, match, handle, destroy` | Represents the fetch-standard, method-dimensioned dispatch entity contract (the behavioral-interface role for the one-class-per-file `Dispatcher`). Layers HTTP method dispatch and web-standard `Request`/`Response` handling over a single internal `Router<RouteRecord<TState>>`. |
|
|
153
|
+
| `DispatchGroupInterface` | interface | `{ prefix } plus add, group` | Represents a prefix-scoped registration handle over a `DispatcherInterface` — the method-dimensioned counterpart of `GroupInterface`. |
|
|
154
|
+
| `NavigatorEventMap` | type | `{ navigate }` | Represents the `Navigator`'s event map — the single `navigate` signal a consumer observes. |
|
|
155
|
+
| `NavigatorOptions` | interface | `{ routes, history?, base?, fallback?, guard?, intercept?, sensitive?, on?, error? }` | Represents the options for `createNavigator` — the `routes` to dispatch between, the navigation substrate, the optional guard hook, and the Emitter pattern's wiring. |
|
|
156
|
+
| `NavigatorInterface` | interface | `{ router, emitter, active } plus start, stop, navigate, match, destroy` | Represents the headless History/hash navigation entity contract (the behavioral-interface role for the one-class-per-file `Navigator`). Composes a core `Router<Meta>`, resolves the current location on `start()` and on every subsequent navigation event, tracks `active`, and emits `navigate` through the `EmitterInterface`. |
|
|
157
|
+
| `RequestOptions` | interface | `{ origin?, response? }` | Represents the options for `buildRequest` — URL origin and response-side disconnect tracking. |
|
|
158
|
+
| `ListenerFunction` | type | `(request: IncomingMessage, response: ServerResponse) => void` | Represents a `node:http` request handler — the function `createListener` returns, matching `http.createServer`'s handler signature. |
|
|
159
|
+
| `StateFunction` | type | `(message: IncomingMessage) => TState` | Derives a consumer's opaque per-request `TState` from the raw `IncomingMessage` — the `state` argument `createListener` threads into `dispatcher.handle`. |
|
|
160
|
+
|
|
161
|
+
The `count` member of `RouterInterface`, the `prefix` member of `GroupInterface` and
|
|
162
|
+
`DispatchGroupInterface`, the `router` and `emitter` members of `DispatcherInterface`, and the
|
|
163
|
+
`router`, `emitter`, and `active` members of `NavigatorInterface` are all `readonly` data members
|
|
164
|
+
(the preceding Surface rows) — the call-signature members each `Shape` cell names after `plus` are
|
|
165
|
+
documented under [Methods](#methods).
|
|
166
|
+
|
|
167
|
+
## Methods
|
|
168
|
+
|
|
169
|
+
The public methods of `RouterInterface`, `GroupInterface`,
|
|
170
|
+
`DispatcherInterface`, `DispatchGroupInterface`, and `NavigatorInterface` —
|
|
171
|
+
every call-signature member listed (their `readonly` data members stay
|
|
172
|
+
Surface rows). `Router`, `Group`, `Dispatcher`, `DispatchGroup`, and
|
|
173
|
+
`Navigator` implement their interfaces exactly, so this doubles as each
|
|
174
|
+
class's instance-method surface.
|
|
175
|
+
|
|
176
|
+
#### `RouterInterface`
|
|
177
|
+
|
|
178
|
+
The registry engine's call-signature members, each documented on the interface
|
|
179
|
+
declaration it belongs to:
|
|
180
|
+
|
|
181
|
+
| Method | Returns | Summary |
|
|
182
|
+
| --------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
183
|
+
| `add` | `void` | Registers one entry, or many in one call (batch registration), compiling each path once; throws a `ContractError` on a malformed path. |
|
|
184
|
+
| `match` | `RouterMatch \| undefined` | Resolves the most-specific matching entry for a pathname, or `undefined` when nothing matches. |
|
|
185
|
+
| `entries` | `readonly RouteEntry[]` | Lists every registered entry in registration order, or only those whose path matches a given pathname. |
|
|
186
|
+
| `group` | `GroupInterface` | Returns a prefix-scoped registration handle over this router. |
|
|
187
|
+
| `clear` | `void` | Drops every entry, leaving the router reusable. |
|
|
188
|
+
|
|
189
|
+
#### `DispatcherInterface`
|
|
190
|
+
|
|
191
|
+
The dispatch entity's call-signature members, each documented on the interface
|
|
192
|
+
declaration it belongs to:
|
|
193
|
+
|
|
194
|
+
| Method | Returns | Summary |
|
|
195
|
+
| --------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
196
|
+
| `add` | `void` | Registers one route input, or many in one call (batch registration); throws a `ContractError` on a malformed registration. |
|
|
197
|
+
| `group` | `DispatchGroupInterface` | Returns a prefix-scoped registration handle over this dispatcher. |
|
|
198
|
+
| `match` | `DispatchResult` | Decides the raw `DispatchResult` for a method and pathname pair, with no `Request` or `Response` involvement — the pure decision `handle` builds its response from. |
|
|
199
|
+
| `handle` | `Promise<Response>` | Runs the full dispatch: parses the request URL, matches, and invokes either the winning handler or the `unmatched`/`unmethoded` responder. |
|
|
200
|
+
| `destroy` | `void` | Tears down the emitter; the underlying router is left registered rather than cleared, so introspection stays valid afterwards. |
|
|
201
|
+
|
|
202
|
+
#### `NavigatorInterface`
|
|
203
|
+
|
|
204
|
+
The navigation entity's call-signature members, each documented on the interface
|
|
205
|
+
declaration it belongs to:
|
|
206
|
+
|
|
207
|
+
| Method | Returns | Summary |
|
|
208
|
+
| ---------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
209
|
+
| `start` | `void` | Begins listening and resolves the current location — idempotent, so a second call is a no-op. |
|
|
210
|
+
| `stop` | `void` | Stops listening and aborts any pending guard — idempotent. |
|
|
211
|
+
| `navigate` | `void` | Navigates programmatically — sets `location.hash` in hash mode or calls `history.pushState` in history mode, then resolves. |
|
|
212
|
+
| `match` | `RouterMatch \| undefined` | Looks one path up through the underlying `Router` — a pure lookup with no location read, no fallback, no guard, and no emit. |
|
|
213
|
+
| `destroy` | `void` | Stops listening and tears down the emitter. |
|
|
214
|
+
|
|
215
|
+
#### `GroupInterface`
|
|
216
|
+
|
|
217
|
+
The group handle's call-signature members — a group holds no registry of its own,
|
|
218
|
+
and every registration lands on the owning router:
|
|
219
|
+
|
|
220
|
+
| Method | Returns | Summary |
|
|
221
|
+
| ------- | ---------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
222
|
+
| `add` | `void` | Registers one entry, or many in one call, on the owning router with this group's prefix composed onto each path. |
|
|
223
|
+
| `group` | `GroupInterface` | Returns a nested group whose prefix is this prefix followed by the given one. |
|
|
224
|
+
|
|
225
|
+
#### `DispatchGroupInterface`
|
|
226
|
+
|
|
227
|
+
The dispatch group handle's call-signature members — the owning dispatcher's
|
|
228
|
+
registration guard still applies to every route a group registers:
|
|
229
|
+
|
|
230
|
+
| Method | Returns | Summary |
|
|
231
|
+
| ------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
|
232
|
+
| `add` | `void` | Registers one route input, or many in one call, on the owning dispatcher with this group's prefix composed onto each path. |
|
|
233
|
+
| `group` | `DispatchGroupInterface` | Returns a nested group whose prefix is this prefix followed by the given one. |
|
|
234
|
+
|
|
235
|
+
## Contract
|
|
236
|
+
|
|
237
|
+
These invariants hold across `src/core` / `src/browser` / `src/server` ↔
|
|
238
|
+
`router.md`.
|
|
239
|
+
|
|
240
|
+
1. **Doc-to-source bijection.** Every `function` / `class` / `interface` /
|
|
241
|
+
`type` / `const` row in the `## Surface` tables is a real export of its
|
|
242
|
+
source directory, and every export appears as a Surface row — exhaustive,
|
|
243
|
+
both directions.
|
|
244
|
+
2. **Doc-to-source method bijection.** The `## Methods` tables list exactly
|
|
245
|
+
`RouterInterface`'s, `GroupInterface`'s, `DispatcherInterface`'s,
|
|
246
|
+
`DispatchGroupInterface`'s, and `NavigatorInterface`'s public methods —
|
|
247
|
+
exhaustive, both directions — and `Router` / `Group` / `Dispatcher` /
|
|
248
|
+
`DispatchGroup` / `Navigator` expose the same public methods, no more.
|
|
249
|
+
3. **Path grammar.** Segment kinds: literal (`/users`), param
|
|
250
|
+
(`:name`, one segment), wildcard (`*name`, final segment only, captures
|
|
251
|
+
the rest of the path including slashes). A wildcard anywhere but the
|
|
252
|
+
final segment throws a `ContractError` at compile time, guarded at the boundary.
|
|
253
|
+
4. **Precedence tiers.** A literal segment (`TIER_LITERAL`, 2) outranks a
|
|
254
|
+
param (`TIER_PARAM`, 1), which outranks a wildcard (`TIER_WILDCARD`, 0).
|
|
255
|
+
Two matching routes compare left to right, and the higher tier at the
|
|
256
|
+
earliest differing segment wins, independent of registration order. A
|
|
257
|
+
shorter pattern that is a prefix of a longer one ranks below it. An
|
|
258
|
+
equal-specificity tie, possible only between distinct wildcard shapes,
|
|
259
|
+
resolves to the earliest registered.
|
|
260
|
+
5. **Trailing slash is insensitive.** A single trailing slash on the request
|
|
261
|
+
path is always optional, folded both at registration (`canonicalizePath`)
|
|
262
|
+
and at match time — except the root `/` and the empty pattern, which are
|
|
263
|
+
exempt and anchor exactly.
|
|
264
|
+
6. **Case-sensitive by default.** `sensitive: true` (`Router`/`Dispatcher`
|
|
265
|
+
construction) is the default; `sensitive: false` folds case during
|
|
266
|
+
matching without altering the pattern's own stored casing.
|
|
267
|
+
7. **Dedup with `key`.** When `RouterOptions.key` is set, an entry whose
|
|
268
|
+
computed key already exists replaces the prior entry in place (last write
|
|
269
|
+
wins, no engine rebuild); `Dispatcher` always constructs its internal
|
|
270
|
+
`Router` with `key: computeDispatchKey`, which pairs the route's method
|
|
271
|
+
with `canonicalizePath(entry.path)`, so two registrations differing only
|
|
272
|
+
by a trailing slash replace each other while different methods stay
|
|
273
|
+
distinct.
|
|
274
|
+
8. **The `answers` seam.** `RouterInterface.match`'s optional
|
|
275
|
+
`AnswerHandler<Meta>` predicate is the single native-override point both
|
|
276
|
+
faces compose differently: the `Dispatcher` passes a method-check, the
|
|
277
|
+
browser `Navigator` omits it entirely — every path match answers.
|
|
278
|
+
9. **Dispatch semantics.** A `HEAD` request with no explicit `HEAD` route
|
|
279
|
+
runs the matching `GET` handler and strips the response body. An
|
|
280
|
+
`OPTIONS` request with no explicit `OPTIONS` route answers `204` with a
|
|
281
|
+
derived `Allow` header (from `router.entries(pathname)`, `GET` implying
|
|
282
|
+
`HEAD`). A path-matches-but-method-doesn't dispatch invokes the
|
|
283
|
+
`unmethoded` responder (default `405` + `Allow`); nothing matching
|
|
284
|
+
invokes the `unmatched` responder (default `404`). **A handler throw
|
|
285
|
+
propagates uncaught** — the dispatcher never invents an error boundary
|
|
286
|
+
(that is the consuming server's policy).
|
|
287
|
+
10. **Wildcard trailing-slash capture is asymmetric with param folding
|
|
288
|
+
(intended).** A final `*name` wildcard captures any trailing slash on the
|
|
289
|
+
request path into its own captured value (`/files/a/b/` → `rest: 'a/b/'`)
|
|
290
|
+
— unlike a `:name` param segment, whose own trailing slash is folded away
|
|
291
|
+
by the shared trailing-slash-insensitivity rule stated earlier. This is
|
|
292
|
+
deliberate: the wildcard's capture is "the rest of the path, verbatim,"
|
|
293
|
+
including whatever trailing slash the caller sent.
|
|
294
|
+
11. **Event map.** `DispatcherEventMap` carries `match` (emitted on every
|
|
295
|
+
dispatch the dispatcher answers, the derived `HEAD` and `OPTIONS` cases
|
|
296
|
+
included; for a derived `OPTIONS` answer the `pattern` is the
|
|
297
|
+
most-specific pattern the pathname resolved to) and `miss` (emitted on
|
|
298
|
+
every non-matching dispatch, tagged
|
|
299
|
+
`'unmatched'`/`'unmethoded'` through its `status` field) — no
|
|
300
|
+
`error`/`observerError` domain event (listener errors route through the
|
|
301
|
+
emitter's own `error` option).
|
|
302
|
+
12. **Headless by design.** No `render`/`outlet`. The `Navigator` resolves,
|
|
303
|
+
tracks `active`, and emits `navigate`; rendering is entirely the
|
|
304
|
+
consumer's responsibility.
|
|
305
|
+
13. **One shared engine.** Each route's `path` is registered once on the same
|
|
306
|
+
`Router` machine the core `Dispatcher` composes, keyed for dedup by its
|
|
307
|
+
`canonicalizePath` (last write wins, replace-in-place). `Navigator` never
|
|
308
|
+
rebuilds matching logic of its own.
|
|
309
|
+
14. **The history toggle.** `history: false` (default) reads/writes
|
|
310
|
+
`location.hash` and binds `hashchange`; `history: true` reads/writes through
|
|
311
|
+
`pushState`/`popstate`, with an optional `base` path prefix stripped
|
|
312
|
+
before matching and prepended when navigating. `intercept: true`
|
|
313
|
+
(history mode only) adds same-origin `<a>` click interception — a plain
|
|
314
|
+
left-click with no modifier keys, no `target`, and no `download`
|
|
315
|
+
attribute.
|
|
316
|
+
15. **Fallback semantics.** A location that matches nothing resolves the
|
|
317
|
+
configured `fallback` pattern (default: the first route's path) through
|
|
318
|
+
the same engine. A `fallback` that itself matches no registered route
|
|
319
|
+
leaves `active` `undefined` and emits nothing — no phantom match is ever
|
|
320
|
+
fabricated.
|
|
321
|
+
16. **Guard + supersede semantics.** An optional `guard(to, from, signal)` may
|
|
322
|
+
veto (or asynchronously veto) a navigation. The `Navigator` mints an
|
|
323
|
+
`@orkestrel/abort` handle per navigation and aborts the previous handle
|
|
324
|
+
when a newer navigation starts (or on `stop`/`destroy`) — a guard verdict
|
|
325
|
+
that resolves after its navigation was superseded (`signal.aborted`) is
|
|
326
|
+
discarded, same as a synchronous `false`/rejected verdict: `active` stays
|
|
327
|
+
unchanged and nothing is emitted. A guard throw routes to the `error`
|
|
328
|
+
handler (not through the emitter's own `emit`) and vetoes.
|
|
329
|
+
17. **Case-sensitive by default.** `sensitive: true` (forwarded to the
|
|
330
|
+
underlying `Router`) is the default; `sensitive: false` folds case during
|
|
331
|
+
matching.
|
|
332
|
+
18. **Intercepted links carry pathname only (known limitation).** Click
|
|
333
|
+
interception passes only the intercepted link's `/`-prefixed pathname
|
|
334
|
+
through to `navigate` — a query string on the link's `href` is not
|
|
335
|
+
preserved (the pathname-only grammar has no query concept). A consumer
|
|
336
|
+
needing query data reads it from `window.location.search` after
|
|
337
|
+
navigating, or skips interception for that link.
|
|
338
|
+
19. **Only HTML `<a>` elements are intercepted (known limitation).** Click
|
|
339
|
+
interception ({@link findAnchor}) walks up the event's composed path for
|
|
340
|
+
an `HTMLAnchorElement` — an SVG `<a>` (`SVGAElement`) is not intercepted,
|
|
341
|
+
even inside a same-origin document, and falls through to the browser's
|
|
342
|
+
native navigation.
|
|
343
|
+
20. **Signal fires on client disconnect.** `buildRequest` mints an
|
|
344
|
+
`@orkestrel/abort` handle and builds the `Request` over its `signal`. The
|
|
345
|
+
handle aborts if the request connection closes before the message finished
|
|
346
|
+
(`!message.complete`), preserving that incomplete-request error, or if the
|
|
347
|
+
paired `RequestOptions.response` closes before the response finished
|
|
348
|
+
(`!response.writableEnded`). `handleListenerRequest` always supplies that
|
|
349
|
+
response, so a handler observes both an incomplete request body and the
|
|
350
|
+
ordinary post-request client disconnect through `request.signal`, with zero
|
|
351
|
+
router-specific cancellation API. A normally completed response does not
|
|
352
|
+
abort the signal, and each close observer is one-shot.
|
|
353
|
+
21. **Transport-level 500 is a last resort, not an error policy.**
|
|
354
|
+
`createListener`'s handler wraps `dispatcher.handle` in a try/catch purely
|
|
355
|
+
for the connection: when nothing has been sent yet, it writes a bare `500`
|
|
356
|
+
head and ends the response (never leaking a hanging socket); after headers
|
|
357
|
+
are already sent, it destroys the connection outright. The router still
|
|
358
|
+
owns no error policy — a consumer wanting mapped error responses installs
|
|
359
|
+
its own boundary around `dispatcher.handle` directly; and the core
|
|
360
|
+
`Dispatcher` never swallows a handler throw into a generic response, as
|
|
361
|
+
stated earlier.
|
|
362
|
+
22. **Streaming both ways.** `buildRequest` streams a body-carrying method's
|
|
363
|
+
message into the `Request` through a manual `ReadableStream` pump — a `for
|
|
364
|
+
await` loop over the `IncomingMessage` enqueueing each chunk, with
|
|
365
|
+
`duplex: 'half'` set as Node's fetch implementation requires for a
|
|
366
|
+
streamed request body; `sendResponse` streams a non-`null` `Response`
|
|
367
|
+
body back to the `ServerResponse` chunk by chunk, ending the target when
|
|
368
|
+
the stream completes. When a write reports backpressure, it waits for
|
|
369
|
+
`drain` before pulling the next body chunk, raced against target
|
|
370
|
+
close/error/destruction so a client disconnect stops the pump promptly;
|
|
371
|
+
every race listener is removed when that wait settles. A target destroyed
|
|
372
|
+
mid-stream stops cleanly without throwing.
|
|
373
|
+
23. **Header fidelity.** `buildRequest` copies every incoming header
|
|
374
|
+
(multi-value headers comma-joined, except `set-cookie`, appended
|
|
375
|
+
individually); `sendResponse` writes every outgoing header and re-derives
|
|
376
|
+
`set-cookie` through `Headers.getSetCookie()` so multiple response cookies
|
|
377
|
+
stay distinct instead of collapsing into one comma-joined header.
|
|
378
|
+
|
|
379
|
+
## Patterns
|
|
380
|
+
|
|
381
|
+
### Groups and dedup
|
|
382
|
+
|
|
383
|
+
`group(prefix)` scopes a registration handle that composes its prefix onto
|
|
384
|
+
every entry it registers on the same underlying router; a `key` function
|
|
385
|
+
lets a later registration replace an earlier one in place instead of adding
|
|
386
|
+
a duplicate candidate.
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
import { createRouter } from '@orkestrel/router'
|
|
390
|
+
|
|
391
|
+
const router = createRouter<{ readonly page: string }>({
|
|
392
|
+
key: (entry) => entry.path,
|
|
393
|
+
})
|
|
394
|
+
const api = router.group('/api')
|
|
395
|
+
api.add({ path: '/users', meta: { page: 'list' } })
|
|
396
|
+
router.match('/api/users')?.path // '/api/users'
|
|
397
|
+
|
|
398
|
+
router.add({ path: '/api/users', meta: { page: 'list-v2' } }) // replaces the prior entry
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Wildcard capture and precedence
|
|
402
|
+
|
|
403
|
+
A literal segment always outranks a param, which always outranks a wildcard,
|
|
404
|
+
compared left-to-right at the earliest differing segment:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
import { createRouter } from '@orkestrel/router'
|
|
408
|
+
|
|
409
|
+
const router = createRouter<{ readonly handler: string }>()
|
|
410
|
+
router.add([
|
|
411
|
+
{ path: '/files/*rest', meta: { handler: 'catchAll' } },
|
|
412
|
+
{ path: '/files/:name', meta: { handler: 'named' } },
|
|
413
|
+
{ path: '/files/readme', meta: { handler: 'literal' } },
|
|
414
|
+
])
|
|
415
|
+
router.match('/files/readme')?.meta.handler // 'literal'
|
|
416
|
+
router.match('/files/other')?.meta.handler // 'named'
|
|
417
|
+
router.match('/files/a/b.png')?.meta.handler // 'catchAll'
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### Method-dimensioned dispatch (auto-HEAD, auto-OPTIONS, 405)
|
|
421
|
+
|
|
422
|
+
Registering a single `GET` route yields an auto-derived `HEAD`, an auto-derived `OPTIONS`,
|
|
423
|
+
and a `405` for every other method on that path:
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
427
|
+
|
|
428
|
+
const dispatcher = createDispatcher()
|
|
429
|
+
dispatcher.add({ method: 'GET', path: '/health', handler: () => new Response('ok') })
|
|
430
|
+
|
|
431
|
+
const head = await dispatcher.handle(new Request('http://x/health', { method: 'HEAD' }), undefined)
|
|
432
|
+
head.body // null — auto-HEAD strips the GET handler's body
|
|
433
|
+
|
|
434
|
+
const options = await dispatcher.handle(
|
|
435
|
+
new Request('http://x/health', { method: 'OPTIONS' }),
|
|
436
|
+
undefined,
|
|
437
|
+
)
|
|
438
|
+
options.headers.get('Allow') // 'GET, HEAD, OPTIONS'
|
|
439
|
+
|
|
440
|
+
const notAllowed = await dispatcher.handle(
|
|
441
|
+
new Request('http://x/health', { method: 'DELETE' }),
|
|
442
|
+
undefined,
|
|
443
|
+
)
|
|
444
|
+
notAllowed.status // 405
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### Observing dispatch outcomes
|
|
448
|
+
|
|
449
|
+
The `on` hooks report every dispatch outcome, matched or missed, alongside the return
|
|
450
|
+
value of `handle`:
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
454
|
+
|
|
455
|
+
const dispatcher = createDispatcher({
|
|
456
|
+
on: {
|
|
457
|
+
match: (method, pattern) => console.log('matched', method, pattern),
|
|
458
|
+
miss: (method, pathname, status) => console.log('missed', method, pathname, status),
|
|
459
|
+
},
|
|
460
|
+
})
|
|
461
|
+
dispatcher.add({ method: 'GET', path: '/health', handler: () => new Response('ok') })
|
|
462
|
+
await dispatcher.handle(new Request('http://x/missing'), undefined) // logs a 'miss'
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Typing a route input at the registration site
|
|
466
|
+
|
|
467
|
+
`defineRoute(...)` is a pure identity pass-through with a `const Path extends
|
|
468
|
+
string` generic — wrapping a route literal in it pins `Path` to the literal
|
|
469
|
+
string at the call site (instead of the widened `string` a bare intermediate
|
|
470
|
+
binding would get), so `context.params` types correctly through
|
|
471
|
+
`PathParams` even when the input is built before the `add` call:
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
import { createDispatcher, defineRoute } from '@orkestrel/router'
|
|
475
|
+
|
|
476
|
+
const input = defineRoute({
|
|
477
|
+
method: 'GET',
|
|
478
|
+
path: '/users/:id',
|
|
479
|
+
handler: (_request, context) => new Response(context.params.id), // typed string
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
const dispatcher = createDispatcher()
|
|
483
|
+
dispatcher.add(input)
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
A heterogeneous `RouteInput[]` built by collecting several `defineRoute(...)`
|
|
487
|
+
results still widens each element's `Path` to `string` the moment the array
|
|
488
|
+
type is inferred — TypeScript has no per-element literal-preserving array
|
|
489
|
+
type. The realistic ceiling `defineRoute` raises is per-call typing at the
|
|
490
|
+
registration site (a single `defineRoute({...})` or a direct `add({...})`
|
|
491
|
+
call), not a stored, still-literal-typed array of route records.
|
|
492
|
+
|
|
493
|
+
### Introspection and reset
|
|
494
|
+
|
|
495
|
+
`entries()` lists every registration (or only those matching a pathname —
|
|
496
|
+
the same set a 405 response's `Allow` header derives from); `clear()` drops
|
|
497
|
+
every entry while leaving the router usable; `Dispatcher.destroy()` tears
|
|
498
|
+
down its emitter.
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
import { createDispatcher, createRouter } from '@orkestrel/router'
|
|
502
|
+
|
|
503
|
+
const router = createRouter<{ readonly page: string }>()
|
|
504
|
+
router.add([
|
|
505
|
+
{ path: '/users/:id', meta: { page: 'profile' } },
|
|
506
|
+
{ path: '/tokens', meta: { page: 'tokens' } },
|
|
507
|
+
])
|
|
508
|
+
router.entries().length // 2
|
|
509
|
+
router.entries('/users/7').length // 1 — only the matching entry
|
|
510
|
+
router.clear()
|
|
511
|
+
router.entries().length // 0 — the router stays usable
|
|
512
|
+
|
|
513
|
+
const dispatcher = createDispatcher()
|
|
514
|
+
dispatcher.add({ method: 'GET', path: '/health', handler: () => new Response('ok') })
|
|
515
|
+
dispatcher.destroy() // tears down the #emitter; router.entries() is still valid afterward
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### Hash-mode navigation
|
|
519
|
+
|
|
520
|
+
A `Navigator` in hash mode dispatches on `location.hash` and updates `active` after
|
|
521
|
+
each `hashchange`:
|
|
522
|
+
|
|
523
|
+
```ts
|
|
524
|
+
import { createNavigator } from '@orkestrel/router/browser'
|
|
525
|
+
|
|
526
|
+
const navigator = createNavigator({
|
|
527
|
+
routes: [
|
|
528
|
+
{ path: '/', meta: { title: 'Home' } },
|
|
529
|
+
{ path: '/about', meta: { title: 'About' } },
|
|
530
|
+
],
|
|
531
|
+
})
|
|
532
|
+
navigator.emitter.on('navigate', (match) => (document.title = match.meta.title))
|
|
533
|
+
navigator.start()
|
|
534
|
+
navigator.match('/about')?.meta.title // 'About' — a pure lookup, no location read
|
|
535
|
+
navigator.navigate('/about') // sets location.hash; `active` updates after the hashchange fires
|
|
536
|
+
navigator.stop()
|
|
537
|
+
navigator.destroy() // stop() plus tear down the #emitter
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
### History mode with link interception
|
|
541
|
+
|
|
542
|
+
History mode binds `popstate` and, with `intercept` set, same-origin `<a>` clicks:
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
import { createNavigator } from '@orkestrel/router/browser'
|
|
546
|
+
|
|
547
|
+
const navigator = createNavigator({
|
|
548
|
+
routes: [{ path: '/users/:id', meta: { title: 'User' } }],
|
|
549
|
+
history: true,
|
|
550
|
+
base: '/app',
|
|
551
|
+
intercept: true,
|
|
552
|
+
})
|
|
553
|
+
navigator.start() // binds popstate + same-origin <a> click interception
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### Guarding navigation (auth walls)
|
|
557
|
+
|
|
558
|
+
A guard may veto synchronously or asynchronously; a superseded guard's
|
|
559
|
+
verdict is discarded through its own `signal`.
|
|
560
|
+
|
|
561
|
+
```ts
|
|
562
|
+
import { createNavigator } from '@orkestrel/router/browser'
|
|
563
|
+
|
|
564
|
+
const navigator = createNavigator({
|
|
565
|
+
routes: [
|
|
566
|
+
{ path: '/private', meta: { title: 'Private' } },
|
|
567
|
+
{ path: '/', meta: { title: 'Home' } },
|
|
568
|
+
],
|
|
569
|
+
guard: async (to, _from, signal) => {
|
|
570
|
+
const allowed = await checkAuth({ signal }) // cancels its own work if superseded
|
|
571
|
+
return signal.aborted ? false : allowed
|
|
572
|
+
},
|
|
573
|
+
})
|
|
574
|
+
navigator.start()
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Basic server
|
|
578
|
+
|
|
579
|
+
`createListener` adapts a core `Dispatcher` into a `node:http` request listener:
|
|
580
|
+
|
|
581
|
+
```ts
|
|
582
|
+
import { createListener } from '@orkestrel/router/server'
|
|
583
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
584
|
+
import http from 'node:http'
|
|
585
|
+
|
|
586
|
+
const dispatcher = createDispatcher<{ readonly requestId: string }>()
|
|
587
|
+
dispatcher.add({
|
|
588
|
+
method: 'GET',
|
|
589
|
+
path: '/users/:id',
|
|
590
|
+
handler: (_request, context) =>
|
|
591
|
+
Response.json({ id: context.params.id, requestId: context.state.requestId }),
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
const server = http.createServer(
|
|
595
|
+
createListener(dispatcher, () => ({ requestId: crypto.randomUUID() })),
|
|
596
|
+
)
|
|
597
|
+
server.listen(0)
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
### Converting requests and responses directly
|
|
601
|
+
|
|
602
|
+
For a runtime seam that needs finer control than `createListener` (custom
|
|
603
|
+
error handling around `dispatcher.handle`, for instance), compose
|
|
604
|
+
`buildRequest`/`sendResponse` directly:
|
|
605
|
+
|
|
606
|
+
```ts
|
|
607
|
+
import { buildRequest, sendResponse } from '@orkestrel/router/server'
|
|
608
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
609
|
+
import http from 'node:http'
|
|
610
|
+
|
|
611
|
+
const dispatcher = createDispatcher()
|
|
612
|
+
dispatcher.add({ method: 'GET', path: '/health', handler: () => new Response('ok') })
|
|
613
|
+
|
|
614
|
+
const server = http.createServer(async (incoming, target) => {
|
|
615
|
+
const request = buildRequest(incoming, {
|
|
616
|
+
origin: 'https://api.example.com',
|
|
617
|
+
response: target,
|
|
618
|
+
})
|
|
619
|
+
try {
|
|
620
|
+
const response = await dispatcher.handle(request, undefined)
|
|
621
|
+
await sendResponse(response, target)
|
|
622
|
+
} catch (error) {
|
|
623
|
+
target.writeHead(500).end(String(error)) // this consumer's own error policy
|
|
624
|
+
}
|
|
625
|
+
})
|
|
626
|
+
server.listen(0)
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
### Observing client disconnect
|
|
630
|
+
|
|
631
|
+
The `Request` returned by `buildRequest` carries a `signal` that aborts when the connection
|
|
632
|
+
closes before the response completes:
|
|
633
|
+
|
|
634
|
+
```ts
|
|
635
|
+
import { buildRequest } from '@orkestrel/router/server'
|
|
636
|
+
import http from 'node:http'
|
|
637
|
+
|
|
638
|
+
const server = http.createServer((incoming, response) => {
|
|
639
|
+
const request = buildRequest(incoming, { response })
|
|
640
|
+
request.signal.addEventListener('abort', () => console.log('client disconnected'))
|
|
641
|
+
})
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
### Practices
|
|
645
|
+
|
|
646
|
+
- **One engine, one seam per face** — compose `Router` directly for a
|
|
647
|
+
method-less consumer (a `Navigator`), or through `Dispatcher` for
|
|
648
|
+
method-dimensioned fetch dispatch; never rebuild the matching logic per
|
|
649
|
+
face.
|
|
650
|
+
- **Guard the registration boundary, not the hot path** — `add` throws a
|
|
651
|
+
`ContractError` on a malformed entry; `match`/`handle` carry zero guards.
|
|
652
|
+
- **Let handler throws propagate** — the dispatcher is not an error
|
|
653
|
+
boundary; a consuming server installs its own around `handle`.
|
|
654
|
+
- **Dedup with `key`, not manual lookups** — pass a `key` function instead of
|
|
655
|
+
checking `router.entries()` before every `add`.
|
|
656
|
+
- **Never build a second registry** — compose the same core `Router` other
|
|
657
|
+
faces use; a `Navigator` never hand-rolls its own path matching.
|
|
658
|
+
- **Thread `signal` into async guard work** — a slow guard can cancel its own
|
|
659
|
+
work when it observes `signal.aborted`, closing the stale-guard race.
|
|
660
|
+
- **Keep rendering outside the Navigator** — subscribe to `navigate` and
|
|
661
|
+
render in the consumer, never inside this headless entity.
|
|
662
|
+
- **`stop()`/`destroy()` before disposal** — releases listeners and aborts
|
|
663
|
+
any pending guard; `destroy()` also tears down the `#emitter`.
|
|
664
|
+
- **Prefer `createListener` for the common case** — it wires conversion,
|
|
665
|
+
dispatch, and the transport-level last-resort `500` together correctly.
|
|
666
|
+
- **Install your own error boundary for mapped error responses** — the
|
|
667
|
+
router (core and this adapter) never invents one; a handler throw
|
|
668
|
+
propagates.
|
|
669
|
+
- **Thread `request.signal` into downstream work** — a handler can cancel
|
|
670
|
+
its own I/O when the client disconnects, the fetch-standard idiom.
|
|
671
|
+
- **Skip this face entirely on fetch-native runtimes** — Bun, Deno, and
|
|
672
|
+
workers hand `Request`s to `dispatcher.handle` directly.
|
|
673
|
+
|
|
674
|
+
## Tests
|
|
675
|
+
|
|
676
|
+
- [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔
|
|
677
|
+
`src/core` / `src/browser` / `src/server` bijection (value + type exports), the
|
|
678
|
+
interface-to-class method bijection for `RouterInterface`, `GroupInterface`,
|
|
679
|
+
`DispatcherInterface`, `DispatchGroupInterface`, and `NavigatorInterface`, and the
|
|
680
|
+
equality gate: every `Summary` cell against its declaration's description paragraph,
|
|
681
|
+
the titled `Basic server` fence against the `@example` block of that title (pinned so
|
|
682
|
+
the titled pair cannot be retired silently), and the README pitch against this guide's
|
|
683
|
+
tagline. It also runs the flagship fences this project can execute and asserts the
|
|
684
|
+
values their comments claim.
|
|
685
|
+
- [`tests/src/core/Router.test.ts`](../tests/src/core/Router.test.ts) —
|
|
686
|
+
registration boundary guard, method-less matching, order-independent
|
|
687
|
+
literal-over-param-over-wildcard precedence, wildcard capture, the
|
|
688
|
+
`answers` seam, `entries()` (all + filtered), dedup through `key`, case
|
|
689
|
+
sensitivity, and `RouterInterface` conformance.
|
|
690
|
+
- [`tests/src/core/Group.test.ts`](../tests/src/core/Group.test.ts) —
|
|
691
|
+
`Group` direct construction, prefix composition and nesting, batch
|
|
692
|
+
registration, dedup-key collision across differently-nested group chains,
|
|
693
|
+
and `GroupInterface` conformance.
|
|
694
|
+
- [`tests/src/core/Dispatcher.test.ts`](../tests/src/core/Dispatcher.test.ts) —
|
|
695
|
+
type-level surfaces (`RouteHandler` context typing, `TState` generic flow,
|
|
696
|
+
`DispatcherInterface` member shape, factory return type), emitter event
|
|
697
|
+
payload shapes, destroy idempotence, the cross-face grammar parity
|
|
698
|
+
fixture, the full functional dispatch matrix (auto-HEAD, auto-OPTIONS,
|
|
699
|
+
404/405 responders, handler-throw propagation), and per-method dedup.
|
|
700
|
+
- [`tests/src/core/DispatchGroup.test.ts`](../tests/src/core/DispatchGroup.test.ts) —
|
|
701
|
+
`DispatchGroup` direct construction and group + nested group registration
|
|
702
|
+
with prefixes composed.
|
|
703
|
+
- [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) —
|
|
704
|
+
`escapeRegExp`, `canonicalizePath`, `computeDispatchKey`, `compilePath` (literal/param/wildcard,
|
|
705
|
+
trailing-slash folding, case sensitivity, the wildcard-not-final throw),
|
|
706
|
+
`decodeParam` (including a malformed `%` escape), `matchPath`,
|
|
707
|
+
`classifySegment` (the literal-vs-param classification fix regression
|
|
708
|
+
case), `computeSpecificity`, `compareSpecificity`, `joinPaths`, and
|
|
709
|
+
`defineRoute` (identity pass-through, literal `Path` preservation at the
|
|
710
|
+
call site).
|
|
711
|
+
- [`tests/src/core/parsers.test.ts`](../tests/src/core/parsers.test.ts) —
|
|
712
|
+
`parseMethod` narrowing every registrable verb, rejecting an unknown verb
|
|
713
|
+
and the wrong casing, and accepting exactly the verbs `METHOD_LIST`
|
|
714
|
+
declares.
|
|
715
|
+
- [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) —
|
|
716
|
+
`createRouter`/`createDispatcher` round-trips and factory return-type
|
|
717
|
+
assertions.
|
|
718
|
+
- [`tests/src/browser/Navigator.test.ts`](../tests/src/browser/Navigator.test.ts) —
|
|
719
|
+
hash and history modes, `navigate()`/`active`/`navigate` event, fallback
|
|
720
|
+
semantics, guard veto (sync + async, including supersede-discard), link
|
|
721
|
+
interception on/off, `start`/`stop`/`destroy` idempotence,
|
|
722
|
+
`NavigatorInterface` conformance, and the transcriptions of this guide's
|
|
723
|
+
`@orkestrel/router/browser` fences.
|
|
724
|
+
- [`tests/src/browser/factories.test.ts`](../tests/src/browser/factories.test.ts) —
|
|
725
|
+
`createNavigator` returns a working `NavigatorInterface`.
|
|
726
|
+
- [`tests/src/browser/helpers.test.ts`](../tests/src/browser/helpers.test.ts) —
|
|
727
|
+
`computeNavigationKey`, `extractHashPath`, `resolveLocationPath` (hash + history, with/without
|
|
728
|
+
`base`), and `findAnchor` (including a click on a styled child inside an
|
|
729
|
+
anchor).
|
|
730
|
+
- [`tests/src/server/validators.test.ts`](../tests/src/server/validators.test.ts) —
|
|
731
|
+
`isEncryptedSocket` on an encrypted socket, a plain record, and every
|
|
732
|
+
off-shape value.
|
|
733
|
+
- [`tests/src/server/helpers.test.ts`](../tests/src/server/helpers.test.ts) —
|
|
734
|
+
`buildRequest` fidelity (method, URL from `Host`, headers including
|
|
735
|
+
multi-value and `set-cookie`, body streaming, the incomplete-request and
|
|
736
|
+
complete-request response-side disconnect aborts, plus normal-response
|
|
737
|
+
signal/listener cleanup) and `sendResponse` (status, headers including
|
|
738
|
+
`set-cookie`, streamed and empty bodies, a destroyed target mid-stream)
|
|
739
|
+
over real `node:http` sockets.
|
|
740
|
+
- [`tests/src/server/handlers.test.ts`](../tests/src/server/handlers.test.ts) —
|
|
741
|
+
`handleListenerRequest` at the transport boundary and `createListener`
|
|
742
|
+
end-to-end round-trips (matched, 404, 405, auto-HEAD, auto-OPTIONS, a
|
|
743
|
+
handler throw, and per-request state) over real `node:http` sockets.
|
|
744
|
+
|
|
745
|
+
## See also
|
|
746
|
+
|
|
747
|
+
- [`AGENTS.md`](../AGENTS.md) — the rules: the Emitter pattern, the
|
|
748
|
+
contract & validation architecture, one engine with native overrides,
|
|
749
|
+
and documentation as contracts.
|
|
750
|
+
- [`abort.md`](abort.md) — `@orkestrel/abort`, the supersede-safe guard
|
|
751
|
+
cancellation primitive the Navigator composes, and the client-disconnect
|
|
752
|
+
cancellation primitive the Listener composes.
|
|
753
|
+
- [`README.md`](README.md) — the guides index.
|