@ultimat3/http 27.8.0 → 27.9.0
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/CLAUDE.md +2 -2
- package/README.md +1 -0
- package/package.json +5 -5
- package/src/context.ts +8 -0
- package/src/error-map-tier-1.ts +2 -0
- package/src/error-map-tier-4.ts +4 -0
- package/src/index.ts +3 -0
- package/src/locale-prefix.ts +33 -1
- package/src/route-path.ts +61 -0
- package/src/router.ts +5 -27
- package/src/stages.ts +3 -0
package/CLAUDE.md
CHANGED
|
@@ -124,7 +124,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
124
124
|
past `bodyLimitBytes`); multipart goes through the same capped bytes. **Bytes with no
|
|
125
125
|
`content-type` are `X_BODY_INVALID`** from `bodyRaw()`, never "no body"; `bodyBytes()` reads them.
|
|
126
126
|
- **A repeated field is a LIST in all three parsers** (`collectFields`).
|
|
127
|
-
- **`matchRoute` never throws**: `
|
|
127
|
+
- **`matchRoute` never throws**: `route-path.ts`'s `decodeSegment` answers `path-invalid` →
|
|
128
128
|
`X_PATH_INVALID` (400).
|
|
129
129
|
- **`handle()` resolves to a Response or nothing**: request phases are guarded by `execute`, the tail
|
|
130
130
|
by `finalize.ts` — a refusing finalize stage degrades to `X_PIPELINE_FINALIZE_FAILED` with a second
|
|
@@ -237,7 +237,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
237
237
|
| `pipeline.ts` | the ORDER the stages run in — the framework's guarantee — and the one loop that drives a request through them |
|
|
238
238
|
| `stages.ts` | what each stage DOES, one entry per `StageName`, plus the stage vocabulary the other two import |
|
|
239
239
|
| `finalize.ts` | the tail of that lifecycle, guarded: a throw after the handler degrades, never rejects |
|
|
240
|
-
| `router.ts` | trie
|
|
240
|
+
| `router.ts` | trie, static > param > wildcard, `path-invalid` for a segment that will not decode. A request segment is decoded ONCE; a static child is looked up raw, then decoded. A wildcard takes an EMPTY rest after the node's own routes (`/docs` → `/docs/*path`, `path: ''`). Segments, decoding and `canonicalPath` are `route-path.ts`'s: `ctx.routePath` is the match's one spelling, `requestAddress(ctx)` what render keys `isr` on |
|
|
241
241
|
| `navigation.ts` | the client router's server half: the gate `context` runs after the match (a prefetch to a route whose `meta.navigation` did not opt in → `204`; a soft GET to anything but a page of the router's `<app>:<surface>` → `204` + `x-ultimate-location`, before auth or app code) and the `response` stage's rewrite of a 3xx to a router request into the same hand-over, cookies kept |
|
|
242
242
|
| `error-map.ts` | the code → status table, closed: COMPOSES the slices and holds no row of its own |
|
|
243
243
|
| `error-map-http.ts`, `error-map-tier-<0-4>.ts` | the rows, one slice per owning tier (this package's own apart). `bun run new-error-code` appends to the slice its `--package` maps to; a code in two slices is refused there and by `error-map.test.ts` |
|
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ to skip.
|
|
|
10
10
|
|---|---|
|
|
11
11
|
| server lifecycle, drain, `/healthz` + `/readyz` | `server.ts`; `health-disclosure.ts` owns the `healthDetailPeers` key, its screen and the proxy half — the body rule is `@ultimat3/core`'s |
|
|
12
12
|
| route table, matcher, `describeRoutes()` | `router.ts` |
|
|
13
|
+
| a pathname's segments, their decoding, and its ONE spelling (`canonicalPath`) — shared by the matcher and by every store keyed on an address; `ctx.routePath` and `requestAddress(ctx)` say where a request was matched | `route-path.ts`, `locale-prefix.ts` |
|
|
13
14
|
| the ordered request lifecycle | `pipeline.ts` |
|
|
14
15
|
| typed request (params, query, body) | `request.ts` |
|
|
15
16
|
| response constructors + `problem()` | `response.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.9.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/core": "27.
|
|
39
|
-
"@ultimat3/i18n": "27.
|
|
40
|
-
"@ultimat3/schema": "27.
|
|
41
|
-
"@ultimat3/time": "27.
|
|
38
|
+
"@ultimat3/core": "27.9.0",
|
|
39
|
+
"@ultimat3/i18n": "27.9.0",
|
|
40
|
+
"@ultimat3/schema": "27.9.0",
|
|
41
|
+
"@ultimat3/time": "27.9.0"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/context.ts
CHANGED
|
@@ -117,6 +117,13 @@ export interface RequestContext extends Ctx {
|
|
|
117
117
|
* is matched. Authoritative: the `locale` stage answers it over every other source.
|
|
118
118
|
*/
|
|
119
119
|
pathLocale: string | undefined;
|
|
120
|
+
/**
|
|
121
|
+
* The path the route table MATCHED, in its one spelling (`route-path.ts`'s `canonicalPath`): base
|
|
122
|
+
* path and routed locale prefix removed, no empty segment, each segment decoded and re-encoded.
|
|
123
|
+
* Set with `route` by the `context` stage. What a store keyed by address keys on — never
|
|
124
|
+
* `url.pathname`, which is one of the many spellings the router answers with the same page.
|
|
125
|
+
*/
|
|
126
|
+
routePath: string | undefined;
|
|
120
127
|
tz: string;
|
|
121
128
|
/** What the CLIENT says it is running, from core's `BUILD_ID_HEADER`. `assertBuild()` reads it. */
|
|
122
129
|
clientBuildId: string | null;
|
|
@@ -234,6 +241,7 @@ export const requestContext = (init: RequestContextInit): RequestContext => {
|
|
|
234
241
|
actor: anonymousActor(),
|
|
235
242
|
locale: localeConfig().fallback,
|
|
236
243
|
pathLocale: undefined,
|
|
244
|
+
routePath: undefined,
|
|
237
245
|
tz: timeConfig().defaultZone,
|
|
238
246
|
clientBuildId: null,
|
|
239
247
|
input: undefined,
|
package/src/error-map-tier-1.ts
CHANGED
|
@@ -80,4 +80,6 @@ export const TIER_1_ERROR_STATUS = {
|
|
|
80
80
|
// 409 with the rest of promotion's refusals: an earlier call moved these bytes (a doubled or
|
|
81
81
|
// retried confirm), a state the caller answers by reading the row that holds `meta.attachedKey`.
|
|
82
82
|
X_STORAGE_ALREADY_PROMOTED: 409,
|
|
83
|
+
// @ultimat3/seo — a sitemap entry carries a lastmod or a locale the sitemap cannot write
|
|
84
|
+
X_SITEMAP_ENTRY_INVALID: 500,
|
|
83
85
|
} satisfies Readonly<Record<string, number>>;
|
package/src/error-map-tier-4.ts
CHANGED
|
@@ -129,4 +129,8 @@ export const TIER_4_ERROR_STATUS = {
|
|
|
129
129
|
X_PWA_PUSH_PAYLOAD_TOO_LARGE: 500,
|
|
130
130
|
// @ultimat3/pwa — the push endpoint is not on a push service this app sends to
|
|
131
131
|
X_PWA_PUSH_HOST_UNLISTED: 422,
|
|
132
|
+
// @ultimat3/ai — an agent repeated one tool call with one result and was stopped
|
|
133
|
+
X_AGENT_TOOL_LOOP: 500,
|
|
134
|
+
// @ultimat3/ai — an agent ran past its declared deadline and was stopped
|
|
135
|
+
X_AGENT_DEADLINE: 504,
|
|
132
136
|
} satisfies Readonly<Record<string, number>>;
|
package/src/index.ts
CHANGED
|
@@ -106,6 +106,8 @@ export type { Authenticator, AuthzDecision, ServerHooks } from './hooks';
|
|
|
106
106
|
export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks';
|
|
107
107
|
export type { LocaleConfig, TimeZoneConfig } from './locale';
|
|
108
108
|
export { DEFAULT_LOCALE_CONFIG } from './locale';
|
|
109
|
+
export type { RequestAddress } from './locale-prefix';
|
|
110
|
+
export { requestAddress } from './locale-prefix';
|
|
109
111
|
export type { Middleware } from './middleware';
|
|
110
112
|
export { compose } from './middleware';
|
|
111
113
|
export type { NavigationPurpose } from './navigation';
|
|
@@ -194,6 +196,7 @@ export {
|
|
|
194
196
|
textResponse,
|
|
195
197
|
withHeaders,
|
|
196
198
|
} from './response';
|
|
199
|
+
export { canonicalPath } from './route-path';
|
|
197
200
|
export type {
|
|
198
201
|
HttpMethod,
|
|
199
202
|
MatchResult,
|
package/src/locale-prefix.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// it is authoritative over query, cookie, user and header. Which locales route, and how a path is
|
|
4
4
|
// spelled in one, are `@ultimat3/i18n`'s; this file only applies them to a request.
|
|
5
5
|
|
|
6
|
+
import { localeSegment } from '@ultimat3/core';
|
|
6
7
|
import { type LocaleSources, localeConfig, resolveLocale, splitLocalePrefix } from '@ultimat3/i18n';
|
|
7
8
|
import type { RequestContext } from './context';
|
|
8
|
-
import { normalizePath } from './
|
|
9
|
+
import { normalizePath } from './route-path';
|
|
9
10
|
|
|
10
11
|
/** Methods whose redirect may change nothing but the URL. Anything else is a 308, never a 301. */
|
|
11
12
|
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
@@ -44,6 +45,37 @@ export function routeLocalePrefix(
|
|
|
44
45
|
return prefix.path;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/** A matched request's address, in the one spelling every pathname the router treats alike shares. */
|
|
49
|
+
export interface RequestAddress {
|
|
50
|
+
/**
|
|
51
|
+
* The locale prefix the URL named, then the path the table matched — NO base path. What a store
|
|
52
|
+
* keys on: `@ultimat3/i18n`'s `unlocalizedPath` reads the route's own path straight back out of
|
|
53
|
+
* it, which it cannot do through a mount.
|
|
54
|
+
*/
|
|
55
|
+
readonly path: string;
|
|
56
|
+
/** `path` under the mount: the pathname the request's URL carries, respelled. */
|
|
57
|
+
readonly pathname: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where a matched request IS, from what the `context` stage recorded when it matched
|
|
62
|
+
* (`ctx.routePath`, `ctx.pathLocale`) — `undefined` before that. `/blog//a/`, `/blog/%61` and
|
|
63
|
+
* `/blog/a` are one address: it is what a shared document is keyed by and rendered AT, so a page
|
|
64
|
+
* that builds its `canonical` from the URL it was handed is never given whichever spelling
|
|
65
|
+
* happened to arrive first. Nothing is redirected; the request is answered where it was asked.
|
|
66
|
+
*/
|
|
67
|
+
export function requestAddress(ctx: RequestContext): RequestAddress | undefined {
|
|
68
|
+
if (ctx.routePath === undefined) return undefined;
|
|
69
|
+
const { basePath } = ctx.config;
|
|
70
|
+
const mount = basePath === '/' || basePath === '' ? '' : basePath.replace(/\/$/, '');
|
|
71
|
+
// The prefix PUT BACK, exactly as it was split off — never `localizedPath`, which re-spells a
|
|
72
|
+
// path already carrying a locale segment: `/en/en/x` (the `/:lang/x` route, in English) is not
|
|
73
|
+
// `/en/x`. A locale's root is the directory, `/en/`, as `localizedPath` and the export spell it.
|
|
74
|
+
const prefix = ctx.pathLocale === undefined ? '' : `/${localeSegment(ctx.pathLocale)}`;
|
|
75
|
+
const path = prefix !== '' && ctx.routePath === '/' ? `${prefix}/` : `${prefix}${ctx.routePath}`;
|
|
76
|
+
return { path, pathname: path === '/' && mount !== '' ? mount : `${mount}${path}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
47
79
|
/**
|
|
48
80
|
* The request's locale. The path first, always; then a route that takes its locale from the path
|
|
49
81
|
* alone gets the DEFAULT for an unprefixed URL — no negotiation, because the document a CDN serves
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A request pathname as the route table reads it: its segments, each one's decoded text, and the
|
|
2
|
+
// ONE spelling every pathname the router treats as the same address shares. The router matches on
|
|
3
|
+
// these functions and a store keyed by path (`@ultimat3/render`'s `isr`) keys on the same ones, so
|
|
4
|
+
// "routes to the same page" and "is the same entry" cannot be decided by two normalisers.
|
|
5
|
+
|
|
6
|
+
/** The non-empty segments: `/a/b/` and `//a//b` are both `['a', 'b']`. */
|
|
7
|
+
export const pathSegments = (path: string): readonly string[] =>
|
|
8
|
+
path.split('/').filter((segment) => segment.length > 0);
|
|
9
|
+
|
|
10
|
+
/** `/a/b/` and `//a//b` both normalise to `/a/b`; `/` stays `/`. Segments are kept as written. */
|
|
11
|
+
export const normalizePath = (path: string): string => {
|
|
12
|
+
const segments = pathSegments(path);
|
|
13
|
+
return segments.length === 0 ? '/' : `/${segments.join('/')}`;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `undefined` instead of the bare `URIError` `decodeURIComponent('%ZZ')` throws. A pathname is
|
|
18
|
+
* whatever the client typed, and an exception from the match would leave the pipeline with
|
|
19
|
+
* `X_INTERNAL` — a 500, and a page for the on-call, for a request only the caller can fix.
|
|
20
|
+
*/
|
|
21
|
+
export const decodeSegment = (segment: string): string | undefined => {
|
|
22
|
+
try {
|
|
23
|
+
return decodeURIComponent(segment);
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** What `encodeURIComponent` escapes and a path segment may carry as written (RFC 3986 `pchar`). */
|
|
30
|
+
const SEGMENT_SAFE = /%(?:24|26|2B|2C|3A|3B|3D|40)/g;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One segment in its one spelling: decoded as the router decodes it, then every character a path
|
|
34
|
+
* segment may not carry literally percent-encoded, upper-case hex. So `%61bc` is `abc`, `caf%c3%a9`
|
|
35
|
+
* is `caf%C3%A9`, and `a%2fb` is `a%2Fb` — a `/` INSIDE a segment stays encoded, because the router
|
|
36
|
+
* hands it to one param (`a/b`) and never splits on it. `@ : + , ; = $ &` stay as written, which is
|
|
37
|
+
* how `URL.pathname` spells them: an address that needs no escape is its own canonical form.
|
|
38
|
+
*
|
|
39
|
+
* A segment that will not decode (`%ZZ`) is kept as written. The router refuses it for a param
|
|
40
|
+
* (`path-invalid`), so it reaches a page only as a static segment spelled exactly that way.
|
|
41
|
+
*/
|
|
42
|
+
export const canonicalSegment = (segment: string): string => {
|
|
43
|
+
const decoded = decodeSegment(segment);
|
|
44
|
+
if (decoded === undefined) return segment;
|
|
45
|
+
try {
|
|
46
|
+
return encodeURIComponent(decoded).replace(SEGMENT_SAFE, decodeURIComponent);
|
|
47
|
+
} catch {
|
|
48
|
+
// A lone surrogate: not text a URL can carry, and never what `URL.pathname` hands over.
|
|
49
|
+
return segment;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A pathname in the one spelling shared by every pathname the router matches identically: no empty
|
|
55
|
+
* segment, no trailing slash, each segment `canonicalSegment`'s. Idempotent. Dot segments are not
|
|
56
|
+
* this function's — `URL` resolved them (`%2e%2e` included) before a pathname existed.
|
|
57
|
+
*/
|
|
58
|
+
export const canonicalPath = (path: string): string => {
|
|
59
|
+
const segments = pathSegments(path);
|
|
60
|
+
return segments.length === 0 ? '/' : `/${segments.map(canonicalSegment).join('/')}`;
|
|
61
|
+
};
|
package/src/router.ts
CHANGED
|
@@ -17,6 +17,8 @@ import type { Bucket } from './rate-limit';
|
|
|
17
17
|
import type { UltimateRequest } from './request';
|
|
18
18
|
import type { CacheHint } from './response';
|
|
19
19
|
import { assertRouteCache } from './route-cache';
|
|
20
|
+
// Segments and their decoding are `route-path.ts`'s, shared with every store keyed by a path.
|
|
21
|
+
import { decodeSegment, normalizePath, pathSegments } from './route-path';
|
|
20
22
|
import type { Schema } from './validate';
|
|
21
23
|
|
|
22
24
|
export type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
|
|
@@ -163,17 +165,6 @@ const node = (): TrieNode => ({
|
|
|
163
165
|
routes: new Map(),
|
|
164
166
|
});
|
|
165
167
|
|
|
166
|
-
/** `/a/b/` and `//a//b` both normalise to `/a/b`; `/` stays `/`. */
|
|
167
|
-
export const normalizePath = (path: string): string => {
|
|
168
|
-
const trimmed = path.split('/').filter((segment) => segment.length > 0);
|
|
169
|
-
return trimmed.length === 0 ? '/' : `/${trimmed.join('/')}`;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const segmentsOf = (path: string): readonly string[] =>
|
|
173
|
-
normalizePath(path)
|
|
174
|
-
.split('/')
|
|
175
|
-
.filter((segment) => segment.length > 0);
|
|
176
|
-
|
|
177
168
|
export const httpRouter = (routes: readonly Route[]): RouteTable => {
|
|
178
169
|
const root = node();
|
|
179
170
|
for (const route of routes) {
|
|
@@ -182,7 +173,7 @@ export const httpRouter = (routes: readonly Route[]): RouteTable => {
|
|
|
182
173
|
// log line once per request and a response that quietly falls back to heuristic caching.
|
|
183
174
|
assertRouteCache(route.meta.cache, route.meta.name, route.path);
|
|
184
175
|
let current = root;
|
|
185
|
-
const segments =
|
|
176
|
+
const segments = pathSegments(route.path);
|
|
186
177
|
for (const [index, segment] of segments.entries()) {
|
|
187
178
|
if (segment.startsWith(':')) {
|
|
188
179
|
const name = segment.slice(1);
|
|
@@ -233,19 +224,6 @@ interface Search {
|
|
|
233
224
|
undecodable: string | undefined;
|
|
234
225
|
}
|
|
235
226
|
|
|
236
|
-
/**
|
|
237
|
-
* `undefined` instead of the bare `URIError` `decodeURIComponent('%ZZ')` throws. A pathname is
|
|
238
|
-
* whatever the client typed, and an exception from the match would leave the pipeline with
|
|
239
|
-
* `X_INTERNAL` — a 500, and a page for the on-call, for a request only the caller can fix.
|
|
240
|
-
*/
|
|
241
|
-
const decodeSegment = (segment: string): string | undefined => {
|
|
242
|
-
try {
|
|
243
|
-
return decodeURIComponent(segment);
|
|
244
|
-
} catch {
|
|
245
|
-
return undefined;
|
|
246
|
-
}
|
|
247
|
-
};
|
|
248
|
-
|
|
249
227
|
/** Terminal nodes reachable for `segments`, in precedence order. */
|
|
250
228
|
const candidates = (
|
|
251
229
|
current: TrieNode,
|
|
@@ -311,7 +289,7 @@ const routeFor = (candidate: Candidate, method: HttpMethod): Route | undefined =
|
|
|
311
289
|
(method === 'HEAD' ? candidate.node.routes.get('GET') : undefined);
|
|
312
290
|
|
|
313
291
|
export const matchRoute = (table: RouteTable, method: string, pathname: string): MatchResult => {
|
|
314
|
-
const segments =
|
|
292
|
+
const segments = pathSegments(pathname);
|
|
315
293
|
const search: Search = { out: [], undecodable: undefined };
|
|
316
294
|
candidates(table.root, segments, 0, {}, search);
|
|
317
295
|
const found = search.out;
|
|
@@ -354,7 +332,7 @@ export interface RouteDescription {
|
|
|
354
332
|
const compare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
355
333
|
|
|
356
334
|
const paramsOf = (path: string): readonly string[] =>
|
|
357
|
-
|
|
335
|
+
pathSegments(path)
|
|
358
336
|
.filter((segment) => segment.startsWith(':') || segment.startsWith('*'))
|
|
359
337
|
.map((segment) => segment.slice(1));
|
|
360
338
|
|
package/src/stages.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
} from './rate-limit-stage';
|
|
48
48
|
import type { UltimateRequest } from './request';
|
|
49
49
|
import { addVary, problem, redirect } from './response';
|
|
50
|
+
import { canonicalPath } from './route-path';
|
|
50
51
|
import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
|
|
51
52
|
import { responseSecurityHeaders } from './security-headers';
|
|
52
53
|
import { validateBody } from './validate';
|
|
@@ -199,6 +200,8 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
199
200
|
}
|
|
200
201
|
ctx.route = match.route;
|
|
201
202
|
ctx.params = match.params;
|
|
203
|
+
// The address in its one spelling, from the very string the table was just asked about.
|
|
204
|
+
ctx.routePath = canonicalPath(routed);
|
|
202
205
|
// Before auth and every app hook: a router request a route may not answer runs nothing.
|
|
203
206
|
return navigationGate(request.raw, ctx.method, ctx.url, match.route.meta.navigation);
|
|
204
207
|
},
|