@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 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**: `router.ts`'s `decodeSegment` answers `path-invalid` →
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 matcher, precedence 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 (`/precios-espa%C3%B1a` is `/precios-españa`). A wildcard takes an EMPTY rest after the node's own routes (`/docs` → `/docs/*path` with `path: ''`, the path `@ultimat3/render`'s static build writes) — `router-spelling.test.ts` |
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.8.0",
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.8.0",
39
- "@ultimat3/i18n": "27.8.0",
40
- "@ultimat3/schema": "27.8.0",
41
- "@ultimat3/time": "27.8.0"
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,
@@ -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>>;
@@ -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,
@@ -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 './router';
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 = segmentsOf(route.path);
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 = segmentsOf(pathname);
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
- segmentsOf(path)
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
  },