@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.
@@ -1,51 +0,0 @@
1
- import type { DispatchGroupInterface, DispatcherEventMap, DispatcherInterface, DispatcherOptions, DispatchResult, Method, RouteInput, RouteRecord, RouterInterface } from './types.js';
2
- import type { EmitterInterface } from '@orkestrel/emitter';
3
- /**
4
- * The fetch-standard, method-dimensioned dispatch entity — layers HTTP method
5
- * dispatch and web-standard `Request`/`Response` handling over one internal
6
- * `Router<RouteRecord<TState>>`. The core machine the eventual server face
7
- * (§7) and any fetch-native runtime consumes directly.
8
- *
9
- * @typeParam TState - The consumer's opaque per-request state type
10
- *
11
- * @remarks
12
- * - **Dedup by `method + canonicalizePath`.** The underlying `Router` is
13
- * constructed with a `key` function so registering the same method+path
14
- * twice REPLACES the prior route in place (§5.1).
15
- * - **Registration boundary guard (§14).** `add` validates each input's
16
- * `handler` (`isFunction`) and `method` (must be in {@link METHODS}) —
17
- * throws `TypeError` on a malformed registration; path validation is
18
- * delegated to the underlying `Router`'s own guard. `match`/`handle` stay
19
- * guard-free.
20
- * - **Auto-`HEAD` / auto-`OPTIONS`.** A `HEAD` request with no registered
21
- * `HEAD` route runs the matching `GET` handler and strips the response
22
- * body; an `OPTIONS` request with no registered `OPTIONS` route answers
23
- * `204` with a derived `Allow` header.
24
- * - **Handler throws propagate.** `handle` never invents an error boundary —
25
- * a handler throw reaches the caller uncaught (§5.1).
26
- * - **Emitter (§13).** Owns a `#emitter` for {@link DispatcherEventMap};
27
- * `match`/`miss` fire AFTER resolution, before the handler/responder runs.
28
- *
29
- * @example
30
- * ```ts
31
- * const dispatcher = new Dispatcher<{ readonly userId: string }>()
32
- * dispatcher.add({
33
- * method: 'GET',
34
- * path: '/users/:id',
35
- * handler: (request, context) => Response.json({ id: context.params.id }),
36
- * })
37
- * const response = await dispatcher.handle(new Request('http://x/users/7'), { userId: 'me' })
38
- * ```
39
- */
40
- export declare class Dispatcher<TState = undefined> implements DispatcherInterface<TState> {
41
- #private;
42
- readonly router: RouterInterface<RouteRecord<TState>>;
43
- constructor(options?: DispatcherOptions<TState>);
44
- get emitter(): EmitterInterface<DispatcherEventMap>;
45
- add<Path extends string>(input: RouteInput<Path, TState>): void;
46
- add(inputs: readonly RouteInput<string, TState>[]): void;
47
- group(prefix: string): DispatchGroupInterface<TState>;
48
- match(method: Method, pathname: string): DispatchResult<TState>;
49
- handle(request: Request, state: TState): Promise<Response>;
50
- destroy(): void;
51
- }
@@ -1,30 +0,0 @@
1
- import type { GroupInterface, RouteEntry, RouterInterface } from './types.js';
2
- /**
3
- * A prefix-scoped registration handle over a {@link import('./Router.js').Router} —
4
- * pure string composition (AGENTS §4.2.2), no independent state or storage.
5
- *
6
- * @typeParam Meta - The entry payload type, matching the owning router
7
- *
8
- * @remarks
9
- * Every `add` composes `entry.path` as `joinPaths(prefix, entry.path)` and
10
- * forwards to the OWNING router, so grouped routes land in the SAME registry.
11
- * `group(prefix)` nests, composing prefixes via {@link joinPaths}.
12
- *
13
- * @example
14
- * ```ts
15
- * import { Router } from '@src/core'
16
- *
17
- * const router = new Router<{ readonly page: string }>()
18
- * const api = router.group('/api')
19
- * api.add({ path: '/users', meta: { page: 'list' } })
20
- * router.match('/api/users')?.path // '/api/users'
21
- * ```
22
- */
23
- export declare class Group<Meta> implements GroupInterface<Meta> {
24
- #private;
25
- readonly prefix: string;
26
- constructor(parent: RouterInterface<Meta>, prefix: string);
27
- add(entry: RouteEntry<Meta>): void;
28
- add(entries: readonly RouteEntry<Meta>[]): void;
29
- group(prefix: string): GroupInterface<Meta>;
30
- }
@@ -1,42 +0,0 @@
1
- import type { AnswerHandler, GroupInterface, RouteEntry, RouterInterface, RouterMatch, RouterOptions } from './types.js';
2
- /**
3
- * The path-matching + registry engine — registers `{ path, meta, name? }`
4
- * entries (compiling each path once) and resolves a concrete pathname to the
5
- * MOST SPECIFIC matching entry. The shared machine both the `Navigator`
6
- * (browser) and the `Dispatcher` (core, method-dimensioned) compose.
7
- *
8
- * @typeParam Meta - The opaque payload each entry carries and a match returns
9
- *
10
- * @remarks
11
- * - **Registration boundary guard (§14).** `add` validates each entry's
12
- * `path` — `isString` plus a leading `/` — and throws `TypeError` on a
13
- * malformed registration; `match` stays guard-free (the hot path).
14
- * - **Compile-once.** Each path is compiled exactly once at registration into
15
- * a parallel `#compiled` array, so `match` runs only a cached `exec` per
16
- * candidate.
17
- * - **Dedup via `key`.** When `options.key` is set, an entry whose computed
18
- * key already exists REPLACES the prior one IN PLACE (both the `#entries`
19
- * and `#compiled` arrays, at the existing index) — last write wins, no
20
- * engine rebuild. Omitted ⇒ every entry is kept, even duplicate paths.
21
- * - **Groups.** `group(prefix)` returns a {@link GroupInterface} that composes
22
- * `prefix` onto every entry it registers, nesting via {@link joinPaths}.
23
- *
24
- * @example
25
- * ```ts
26
- * const router = new Router<{ readonly page: string }>()
27
- * router.add({ path: '/users/:id', meta: { page: 'profile' } })
28
- * router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }
29
- * ```
30
- */
31
- export declare class Router<Meta> implements RouterInterface<Meta> {
32
- #private;
33
- constructor(options?: RouterOptions<Meta>);
34
- get count(): number;
35
- add(entry: RouteEntry<Meta>): void;
36
- add(entries: readonly RouteEntry<Meta>[]): void;
37
- match(pathname: string, answers?: AnswerHandler<Meta>): RouterMatch<Meta> | undefined;
38
- entries(): readonly RouteEntry<Meta>[];
39
- entries(pathname: string): readonly RouteEntry<Meta>[];
40
- group(prefix: string): GroupInterface<Meta>;
41
- clear(): void;
42
- }
@@ -1,60 +0,0 @@
1
- /**
2
- * The complete set of HTTP methods a {@link import('./types.js').Dispatcher}
3
- * registers routes under — backs the registration guard (`add` rejects any
4
- * `method` outside this set) and the auto-`OPTIONS` `Allow` derivation.
5
- *
6
- * @remarks
7
- * A `ReadonlySet` of the seven {@link import('./types.js').Method} literals:
8
- * `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`. `HEAD` is
9
- * included even though it is never required at registration (a `GET` route
10
- * auto-answers `HEAD`) — it is still a valid method to register explicitly.
11
- *
12
- * @example
13
- * ```ts
14
- * METHODS.has('GET') // true
15
- * METHODS.has('TRACE') // false
16
- * ```
17
- */
18
- export declare const METHODS: ReadonlySet<string>;
19
- /**
20
- * Specificity tier for a **literal** path segment (`/users`) — the highest
21
- * tier, always outranking a param or wildcard segment at the same position.
22
- *
23
- * @remarks
24
- * Consumed by `computeSpecificity` (U1 `helpers.ts`) when ranking candidate
25
- * matches left-to-right at the earliest differing segment (§4 precedence).
26
- *
27
- * @example
28
- * ```ts
29
- * TIER_LITERAL > TIER_PARAM // true
30
- * ```
31
- */
32
- export declare const TIER_LITERAL = 2;
33
- /**
34
- * Specificity tier for a **param** path segment (`:name`) — ranks below a
35
- * literal segment and above a wildcard segment at the same position.
36
- *
37
- * @remarks
38
- * Consumed by `computeSpecificity` (U1 `helpers.ts`) alongside {@link TIER_LITERAL}
39
- * and {@link TIER_WILDCARD}.
40
- *
41
- * @example
42
- * ```ts
43
- * TIER_PARAM > TIER_WILDCARD // true
44
- * ```
45
- */
46
- export declare const TIER_PARAM = 1;
47
- /**
48
- * Specificity tier for a **wildcard** path segment (`*name`) — the lowest
49
- * tier; a wildcard only ever wins against another wildcard shape (an
50
- * equal-specificity tie resolved by registration order).
51
- *
52
- * @remarks
53
- * Consumed by `computeSpecificity` (U1 `helpers.ts`).
54
- *
55
- * @example
56
- * ```ts
57
- * TIER_WILDCARD // 0
58
- * ```
59
- */
60
- export declare const TIER_WILDCARD = 0;
@@ -1,53 +0,0 @@
1
- import type { DispatcherInterface, DispatcherOptions, RouterInterface, RouterOptions } from './types.js';
2
- /**
3
- * Create a {@link RouterInterface} — the pure path-matching + registry engine
4
- * shared by the browser `Navigator` and the core `Dispatcher`.
5
- *
6
- * @remarks
7
- * Prefer this over `new Router(...)` at call sites that only need the
8
- * interface; an entity that OWNS a `Router` internally (like `Dispatcher`)
9
- * still constructs `new Router(...)` directly.
10
- *
11
- * @typeParam Meta - The opaque payload each entry carries and a match returns
12
- * @param options - Optional initial `entries`, the `sensitive` case toggle
13
- * (default `true`), and a `key` dedup identity function
14
- * @returns A {@link RouterInterface}
15
- *
16
- * @example
17
- * ```ts
18
- * import { createRouter } from '@src/core'
19
- *
20
- * const router = createRouter<{ readonly page: string }>()
21
- * router.add({ path: '/users/:id', meta: { page: 'profile' } })
22
- * router.match('/users/7') // { path: '/users/:id', params: { id: '7' }, meta: { page: 'profile' } }
23
- * ```
24
- */
25
- export declare function createRouter<Meta>(options?: RouterOptions<Meta>): RouterInterface<Meta>;
26
- /**
27
- * Create a {@link DispatcherInterface} — the fetch-standard, method-
28
- * dimensioned dispatch entity over one internal `Router<RouteRecord<TState>>`.
29
- *
30
- * @remarks
31
- * Prefer this over `new Dispatcher(...)` at call sites that only need the
32
- * interface.
33
- *
34
- * @typeParam TState - The consumer's opaque per-request state type (default
35
- * `undefined` for stateless use)
36
- * @param options - Optional initial `routes`, the `sensitive` case toggle,
37
- * the `unmatched`/`unmethoded` default-responder overrides, and the AGENTS
38
- * §13 emitter `on`/`error` wiring
39
- * @returns A {@link DispatcherInterface}
40
- *
41
- * @example
42
- * ```ts
43
- * import { createDispatcher } from '@src/core'
44
- *
45
- * const dispatcher = createDispatcher<{ readonly userId: string }>({
46
- * routes: [
47
- * { method: 'GET', path: '/health', handler: () => new Response('ok') },
48
- * ],
49
- * })
50
- * const response = await dispatcher.handle(new Request('http://x/health'), { userId: 'me' })
51
- * ```
52
- */
53
- export declare function createDispatcher<TState = undefined>(options?: DispatcherOptions<TState>): DispatcherInterface<TState>;
@@ -1,274 +0,0 @@
1
- import type { CompiledPath, Method } from './types.js';
2
- /**
3
- * Escape every regex metacharacter in a literal string so it can be embedded
4
- * inside a larger `RegExp` source without being interpreted as syntax.
5
- *
6
- * @remarks
7
- * {@link compilePath} escapes the literal segments of a route pattern with this
8
- * before splicing in `:name` / `*name` capture groups, so a path like
9
- * `/files/:name.json` matches the `.` literally rather than as "any character".
10
- * Pure and total — never throws.
11
- *
12
- * @param value - The literal string to escape
13
- * @returns `value` with every regex metacharacter backslash-escaped
14
- *
15
- * @example
16
- * ```ts
17
- * escapeRegExp('a.b+c') // 'a\\.b\\+c'
18
- * new RegExp(`^${escapeRegExp('a.b')}$`).test('a.b') // true
19
- * new RegExp(`^${escapeRegExp('a.b')}$`).test('axb') // false
20
- * ```
21
- */
22
- export declare function escapeRegExp(value: string): string;
23
- /**
24
- * Canonicalize a route path for REGISTRY IDENTITY — strip a single trailing
25
- * slash, except the root `/` (and the empty pattern). The trailing-slash fold
26
- * {@link compilePath} normalizes a pattern through, so identity agrees with the
27
- * matcher.
28
- *
29
- * @remarks
30
- * Mirrors {@link compilePath}'s trailing-slash folding: `/users/` canonicalizes
31
- * to `/users` (the two compile to the same regex and match the same
32
- * pathnames), while the root `/` and the empty `''` are EXEMPT (a bare `/`
33
- * already matches `/`; stripping it would break that). Pure and total — a path
34
- * without a trailing slash returns unchanged.
35
- *
36
- * @param path - The route path pattern
37
- * @returns The canonical path (one trailing slash removed, except `/` and `''`)
38
- *
39
- * @example
40
- * ```ts
41
- * canonicalizePath('/users/') // '/users'
42
- * canonicalizePath('/users') // '/users'
43
- * canonicalizePath('/') // '/'
44
- * canonicalizePath('') // ''
45
- * ```
46
- */
47
- export declare function canonicalizePath(path: string): string;
48
- /**
49
- * Compile a route path pattern into an anchored regex and its ordered param
50
- * names.
51
- *
52
- * @remarks
53
- * Splits the CANONICALIZED path into segments. Each `:name` segment becomes a
54
- * `([^/]+)` capture group; the FINAL segment may instead be `*name`, which
55
- * becomes a `(.+)` capture spanning the REST of the path including slashes — a
56
- * wildcard segment anywhere but last is a registration-time programmer error
57
- * and throws `TypeError` (§14 construction/registration boundary). Every regex
58
- * metacharacter in a literal segment is escaped first ({@link escapeRegExp}),
59
- * so a path like `/files/:name.json` matches the `.` literally apart from the
60
- * param. The regex is anchored (`^…$`), so it matches the whole pathname, not
61
- * a prefix.
62
- *
63
- * **Trailing slash is INSENSITIVE** (Express's `strict: false` default): a
64
- * single trailing slash on the request path is OPTIONAL, so `/users` matches
65
- * both `/users` and `/users/`, and `/users/:id` matches both `/users/me` and
66
- * `/users/me/`. This is NOT prefix matching — a deeper path is still a
67
- * distinct segment, so `/api` does not match `/api/users`. The ROOT `/` (and
68
- * the empty pattern `''`) are EXEMPT — they are not stripped, so `/` stays
69
- * `^/$` and `''` stays `^$`.
70
- *
71
- * `sensitive` (default `true`) controls case folding: `false` adds the `i`
72
- * regex flag, so `/Users` matches `/users`. The pattern's own casing is never
73
- * altered — only the matching behavior.
74
- *
75
- * @param path - The route path pattern (e.g. `/users/:id`, `/files/*rest`)
76
- * @param sensitive - Case-sensitive matching (default `true`)
77
- * @returns The {@link CompiledPath} — its `regex` + ordered `params`
78
- * @throws {TypeError} When a `*name` wildcard segment is not the FINAL segment
79
- *
80
- * @example
81
- * ```ts
82
- * const { regex, params } = compilePath('/users/:id/posts/:slug')
83
- * params // ['id', 'slug']
84
- * regex.exec('/users/7/posts/hello') // ['…', '7', 'hello']
85
- * regex.test('/users/7/posts/hello/') // true — the trailing slash is optional
86
- *
87
- * compilePath('/files/*rest').regex.test('/files/a/b.png') // true
88
- * compilePath('/Users', false).regex.test('/users') // true — case-insensitive
89
- * ```
90
- */
91
- export declare function compilePath(path: string, sensitive?: boolean): CompiledPath;
92
- /**
93
- * URL-decode one captured param value, tolerating a malformed percent-escape —
94
- * the decode {@link matchPath} applies to each captured group.
95
- *
96
- * @remarks
97
- * A bad `%` sequence is not a reason to reject an otherwise-matching route, so
98
- * a `decodeURIComponent` that would throw falls back to the raw value
99
- * (mirroring the cookie / token boundary readers, AGENTS §14). Total — never
100
- * throws.
101
- *
102
- * @param value - The raw captured param value
103
- * @returns The URL-decoded value, or the raw value when decoding would throw
104
- *
105
- * @example
106
- * ```ts
107
- * decodeParam('a%2Fb') // 'a/b'
108
- * decodeParam('100%25') // '100%'
109
- * decodeParam('%') // '%' — malformed escape stays literal
110
- * ```
111
- */
112
- export declare function decodeParam(value: string): string;
113
- /**
114
- * Extract the URL-decoded params a compiled path captures from a concrete
115
- * pathname, or `undefined` when the pathname does not match.
116
- *
117
- * @remarks
118
- * Runs the {@link CompiledPath} `regex` against `pathname` (a single `exec`); a
119
- * miss returns `undefined`. On a hit it walks `params` POSITIONALLY — the
120
- * `n`-th param name pairs with the `n`-th capture group — and URL-decodes each
121
- * value with {@link decodeParam}. Returns a frozen `name → value` record (empty
122
- * for a parameterless path). Total — never throws.
123
- *
124
- * @param compiled - The {@link CompiledPath} from {@link compilePath}
125
- * @param pathname - The concrete request pathname to match (e.g. `/users/7`)
126
- * @returns The decoded params on a hit, or `undefined` on a miss
127
- *
128
- * @example
129
- * ```ts
130
- * const compiled = compilePath('/users/:id')
131
- * matchPath(compiled, '/users/7') // { id: '7' }
132
- * matchPath(compiled, '/users/a%2Fb') // { id: 'a/b' } — decoded
133
- * matchPath(compiled, '/posts/7') // undefined
134
- * ```
135
- */
136
- export declare function matchPath(compiled: CompiledPath, pathname: string): Readonly<Record<string, string>> | undefined;
137
- /**
138
- * Classify one path segment into its specificity TIER — the SAME syntax
139
- * {@link compilePath} rewrites: a syntactically valid `:name` head is a PARAM
140
- * segment, a final `*name` is a WILDCARD segment, everything else (including a
141
- * literal segment that merely CONTAINS a `:` mid-string, e.g. `a:b`) is a
142
- * LITERAL segment.
143
- *
144
- * @remarks
145
- * This is the fix over the old engine's bug: the old classifier ranked any
146
- * segment `includes(':')` as a param, so a literal segment like `a:b` was
147
- * mis-tiered even though {@link compilePath} compiles it literally. Sharing one
148
- * segment parser between compilation and classification keeps the two in
149
- * agreement (§4 fixes). Pure and total.
150
- *
151
- * @param segment - One `/`-split path segment
152
- * @param isFinal - Whether `segment` is the last segment of its path (only the
153
- * final segment may be classified as a wildcard)
154
- * @returns The segment's specificity tier — {@link import('./constants.js').TIER_LITERAL},
155
- * {@link import('./constants.js').TIER_PARAM}, or
156
- * {@link import('./constants.js').TIER_WILDCARD}
157
- *
158
- * @example
159
- * ```ts
160
- * classifySegment(':id', true) // 1 — TIER_PARAM
161
- * classifySegment('*rest', true) // 0 — TIER_WILDCARD
162
- * classifySegment('a:b', true) // 2 — TIER_LITERAL — the old bug's regression case
163
- * classifySegment('users', false) // 2 — TIER_LITERAL
164
- * ```
165
- */
166
- export declare function classifySegment(segment: string, isFinal: boolean): number;
167
- /**
168
- * Compute a route path's SPECIFICITY VECTOR — the per-segment type ranking
169
- * that breaks a tie when several registered routes match the same concrete
170
- * pathname.
171
- *
172
- * @remarks
173
- * Splits the CANONICALIZED path into segments (on `/`) and maps each to its
174
- * specificity tier via {@link classifySegment} — the same segment parser
175
- * {@link compilePath} uses, so a literal segment that merely contains a `:`
176
- * (e.g. `a:b`) is correctly tiered as literal rather than param (the old
177
- * engine's bug, fixed here). The standard route-precedence rule compares two
178
- * matching routes' vectors LEFT-TO-RIGHT: at the first index where the tiers
179
- * differ, the HIGHER tier (a literal over a param over a wildcard) is MORE
180
- * SPECIFIC and wins — so `/users/me` (`[2, 2]`) beats `/users/:id` (`[2, 1]`)
181
- * beats `/users/*rest` (`[2, 0]`) regardless of registration order. Two routes
182
- * that match the SAME concrete pathname necessarily have the same segment
183
- * count in the common case; {@link compareSpecificity} handles the general
184
- * case for totality.
185
- *
186
- * @param path - The route path pattern (e.g. `/users/:id`)
187
- * @returns The per-segment specificity tiers, in order
188
- *
189
- * @example
190
- * ```ts
191
- * computeSpecificity('/users/me') // [2, 2]
192
- * computeSpecificity('/users/:id') // [2, 1]
193
- * computeSpecificity('/files/*rest') // [2, 0]
194
- * computeSpecificity('/a:b') // [2] — literal, not param — the classification fix
195
- * ```
196
- */
197
- export declare function computeSpecificity(path: string): readonly number[];
198
- /**
199
- * Compare two route paths by SPECIFICITY — the comparator that picks the
200
- * most-specific matching route (literal-over-param-over-wildcard,
201
- * registration-order-independent).
202
- *
203
- * @remarks
204
- * Compares the two paths' {@link computeSpecificity} vectors LEFT-TO-RIGHT and
205
- * returns a standard `Array.sort` ordering: a NEGATIVE number when `a` is MORE
206
- * specific than `b` (so a descending-specificity sort puts `a` first),
207
- * positive when `b` is more specific, `0` when neither out-ranks the other
208
- * across the compared segments. At the first index where the tiers differ,
209
- * the higher tier wins; if one vector is a prefix of the other (different
210
- * segment counts), the LONGER, more-segmented path is treated as more
211
- * specific (a missing segment ranks below any real one).
212
- *
213
- * @param a - The first route path
214
- * @param b - The second route path
215
- * @returns A negative number when `a` is more specific, positive when `b` is, else `0`
216
- *
217
- * @example
218
- * ```ts
219
- * compareSpecificity('/users/me', '/users/:id') // negative — literal wins
220
- * compareSpecificity('/users/:id', '/users/*rest') // negative — param beats wildcard
221
- * compareSpecificity('/users/:id', '/users/:id') // 0 — equal specificity
222
- * ```
223
- */
224
- export declare function compareSpecificity(a: string, b: string): number;
225
- /**
226
- * Narrow a raw `request.method` string into a typed {@link Method} — total,
227
- * never throws.
228
- *
229
- * @remarks
230
- * Guarded via {@link import('./constants.js').METHODS} (the seven registrable
231
- * HTTP methods); any other value (an unknown verb, non-uppercase casing)
232
- * resolves to `undefined` rather than throwing (§14 guard totality). Pure
233
- * leaf shared by the `Dispatcher`'s `handle` (§5.1 unknown-verb honesty) and
234
- * anywhere else a raw method string needs narrowing.
235
- *
236
- * @param value - The raw `request.method` string to narrow
237
- * @returns The matching {@link Method}, or `undefined` when `value` is not one
238
- * of the seven registrable methods
239
- *
240
- * @example
241
- * ```ts
242
- * parseMethod('GET') // 'GET'
243
- * parseMethod('PURGE') // undefined
244
- * parseMethod('get') // undefined — case-sensitive
245
- * ```
246
- */
247
- export declare function parseMethod(value: string): Method | undefined;
248
- /**
249
- * Join a group prefix and a route path into one `/`-prefixed path, normalizing
250
- * duplicate or missing joining slashes.
251
- *
252
- * @remarks
253
- * {@link import('./types.js').GroupInterface} / {@link import('./types.js').DispatchGroupInterface}
254
- * compose a prefix with each registered entry's path this way — pure string
255
- * composition (§4.2.2), no independent state. Both a duplicated slash
256
- * (`'/api/'` + `'/users'`) and a missing one (`'/api'` + `'users'`) normalize
257
- * to a single joining slash. An empty `prefix` returns `path` unchanged (after
258
- * ensuring a leading slash); an empty `path` returns `prefix` unchanged.
259
- * Pure and total.
260
- *
261
- * @param prefix - The group prefix (e.g. `/api`)
262
- * @param path - The route path being joined under the prefix (e.g. `/users`)
263
- * @returns The joined `/`-prefixed path
264
- *
265
- * @example
266
- * ```ts
267
- * joinPaths('/api', '/users') // '/api/users'
268
- * joinPaths('/api/', '/users') // '/api/users'
269
- * joinPaths('/api', 'users') // '/api/users'
270
- * joinPaths('', '/users') // '/users'
271
- * joinPaths('/api', '') // '/api'
272
- * ```
273
- */
274
- export declare function joinPaths(prefix: string, path: string): string;