@ultimat3/http 22.7.1 → 22.8.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 +1 -0
- package/package.json +5 -5
- package/src/error-map.ts +4 -2
- package/src/index.ts +3 -0
- package/src/navigation.ts +79 -0
- package/src/pipeline.ts +2 -2
- package/src/router.ts +14 -0
- package/src/stages.ts +9 -1
package/CLAUDE.md
CHANGED
|
@@ -200,6 +200,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
200
200
|
| `stages.ts` | what each stage DOES, one entry per `StageName`, plus the stage vocabulary the other two import |
|
|
201
201
|
| `finalize.ts` | the tail of that lifecycle, guarded: a throw after the handler degrades, never rejects |
|
|
202
202
|
| `router.ts` | trie matcher, precedence static > param > wildcard, `path-invalid` for a segment that will not decode |
|
|
203
|
+
| `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 |
|
|
203
204
|
| `error-map.ts` | the code → status table, closed, plus the app's half (`registerErrorStatus`) |
|
|
204
205
|
| `error-facts.ts` | every RENDERING of a throwable: `factsOf()`, the problem document (including the issue list and the opacity rule over it), the three terminal lines |
|
|
205
206
|
| `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.8.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "22.
|
|
35
|
-
"@ultimat3/i18n": "22.
|
|
36
|
-
"@ultimat3/schema": "22.
|
|
37
|
-
"@ultimat3/time": "22.
|
|
34
|
+
"@ultimat3/core": "22.8.0",
|
|
35
|
+
"@ultimat3/i18n": "22.8.0",
|
|
36
|
+
"@ultimat3/schema": "22.8.0",
|
|
37
|
+
"@ultimat3/time": "22.8.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/error-map.ts
CHANGED
|
@@ -85,8 +85,9 @@ export const ERROR_STATUS = {
|
|
|
85
85
|
X_WEBHOOK_SIGNATURE_STALE: 401,
|
|
86
86
|
// Boot refusals of the app's HTTP declaration (a bearer mount, an action's pinned path or the
|
|
87
87
|
// app's path style or a path derived before it, the openapi block, the MCP oauth block or an
|
|
88
|
-
// idempotent tool shadowing its key argument, a page's `post`) — never a request's
|
|
89
|
-
// answer; 500 if one ever escaped into a response
|
|
88
|
+
// idempotent tool shadowing its key argument, a page's `post` or `navigation`) — never a request's
|
|
89
|
+
// answer; 500 if one ever escaped into a response (`x dev` re-evaluating an edited route module):
|
|
90
|
+
// the deployment is wrong, the caller is not.
|
|
90
91
|
X_BEARER_MOUNT_INVALID: 500,
|
|
91
92
|
X_ACTION_HTTP_PATH_INVALID: 500,
|
|
92
93
|
X_ACTION_PATH_STYLE_INVALID: 500,
|
|
@@ -95,6 +96,7 @@ export const ERROR_STATUS = {
|
|
|
95
96
|
X_MCP_OAUTH_INVALID: 500,
|
|
96
97
|
X_MCP_IDEMPOTENCY_KEY_SHADOWED: 500,
|
|
97
98
|
X_ROUTE_POST_INVALID: 500,
|
|
99
|
+
X_ROUTE_NAVIGATION_INVALID: 500,
|
|
98
100
|
// @ultimat3/action — the code every primitive throws when the CALLER's input fails the schema
|
|
99
101
|
// the primitive declared. 400 because that is what the published OpenAPI operation promises for
|
|
100
102
|
// it, and because a missing row made a typo'd uuid a 500: the caller was told the server broke,
|
package/src/index.ts
CHANGED
|
@@ -110,6 +110,8 @@ export type { LocaleConfig, TimeZoneConfig } from './locale';
|
|
|
110
110
|
export { DEFAULT_LOCALE_CONFIG, readCookie } from './locale';
|
|
111
111
|
export type { Middleware } from './middleware';
|
|
112
112
|
export { compose } from './middleware';
|
|
113
|
+
export type { NavigationPurpose } from './navigation';
|
|
114
|
+
export { navigationGate, navigationPurpose, redirectForRouter, relocate } from './navigation';
|
|
113
115
|
export type { OverlayMeta, OverlayNotice } from './overlay';
|
|
114
116
|
export { overlayResponse, wantsOverlay } from './overlay';
|
|
115
117
|
export { OVERLAY_STYLE } from './overlay-style';
|
|
@@ -186,6 +188,7 @@ export type {
|
|
|
186
188
|
RouteDescription,
|
|
187
189
|
RouteHandler,
|
|
188
190
|
RouteMeta,
|
|
191
|
+
RouteNavigation,
|
|
189
192
|
RouteParams,
|
|
190
193
|
RouteTable,
|
|
191
194
|
} from './router';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The server half of client navigation: which router requests a route may answer at all, and a
|
|
2
|
+
// redirect answered to a router request turned into something it can act on without a second
|
|
3
|
+
// execution. Read by two stages — `context` (the gate, before auth and before any app code) and
|
|
4
|
+
// `response` (the rewrite, after everything) — so every route is covered, pages or not.
|
|
5
|
+
//
|
|
6
|
+
// Why here and not in the router: a request the browser was never asked to make (a prefetch)
|
|
7
|
+
// must run NOTHING on a route that did not ask for it, and a GET some app records evidence on (a
|
|
8
|
+
// download, a click, a token open) must run exactly once. Only the server can promise either.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
CLIENT_NAVIGATION_HEADER,
|
|
12
|
+
CLIENT_NAVIGATION_LOCATION_HEADER,
|
|
13
|
+
CLIENT_NAVIGATION_SURFACE_HEADER,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
|
+
import type { RouteNavigation } from './router';
|
|
16
|
+
|
|
17
|
+
export type NavigationPurpose = 'soft' | 'prefetch';
|
|
18
|
+
|
|
19
|
+
/** `soft`, `prefetch`, or `null` for every request the router did not make. */
|
|
20
|
+
export function navigationPurpose(request: Request): NavigationPurpose | null {
|
|
21
|
+
const said = request.headers.get(CLIENT_NAVIGATION_HEADER);
|
|
22
|
+
return said === 'soft' || said === 'prefetch' ? said : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SAFE = new Set(['GET', 'HEAD']);
|
|
26
|
+
|
|
27
|
+
/** "Load this with a real navigation" — never stored anywhere, it answers one request. */
|
|
28
|
+
export function relocate(location: string, headers?: Headers): Response {
|
|
29
|
+
const out = new Headers(headers);
|
|
30
|
+
out.delete('location');
|
|
31
|
+
out.delete('content-type');
|
|
32
|
+
out.delete('content-length');
|
|
33
|
+
out.set(CLIENT_NAVIGATION_LOCATION_HEADER, location);
|
|
34
|
+
out.set('cache-control', 'no-store');
|
|
35
|
+
return new Response(null, { status: 204, headers: out });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Before the route runs. A GET the router made to anything but a page of ITS surface is answered
|
|
40
|
+
* here: a prefetch with an empty `204` (it may not run the route, and it has nothing to swap), a
|
|
41
|
+
* soft visit with `204` + `x-ultimate-location` naming the same URL, which the router loads as a
|
|
42
|
+
* document — so the route runs once, for the real navigation. A POST is never gated: it is the
|
|
43
|
+
* form's submission, and the rewrite below keeps its redirect to one execution.
|
|
44
|
+
*/
|
|
45
|
+
export function navigationGate(
|
|
46
|
+
request: Request,
|
|
47
|
+
method: string,
|
|
48
|
+
url: URL,
|
|
49
|
+
navigation: RouteNavigation | undefined,
|
|
50
|
+
): Response | undefined {
|
|
51
|
+
const purpose = navigationPurpose(request);
|
|
52
|
+
if (purpose === null || !SAFE.has(method)) return undefined;
|
|
53
|
+
const surface = request.headers.get(CLIENT_NAVIGATION_SURFACE_HEADER);
|
|
54
|
+
const ours = navigation !== undefined && navigation.surface === surface;
|
|
55
|
+
if (ours && (purpose === 'soft' || navigation.prefetch)) return undefined;
|
|
56
|
+
if (purpose === 'prefetch') return new Response(null, { status: 204, headers: NO_STORE });
|
|
57
|
+
return relocate(url.href);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const NO_STORE = { 'cache-control': 'no-store' } as const;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* After everything. A redirect answered to a router request — a 303 after a form, a sign-in wall,
|
|
64
|
+
* a load's `setRedirect`, an OAuth hop to another origin — becomes `204` + `x-ultimate-location`,
|
|
65
|
+
* cookies kept. Followed by `fetch`, the target would run inside a request the router may then
|
|
66
|
+
* throw away (another surface, another origin that CORS refuses) and be asked for again; handed
|
|
67
|
+
* over, the router either follows it as its own next request or lets the browser load it, once.
|
|
68
|
+
*/
|
|
69
|
+
export function redirectForRouter(
|
|
70
|
+
request: Request,
|
|
71
|
+
response: Response,
|
|
72
|
+
base: URL,
|
|
73
|
+
): Response | undefined {
|
|
74
|
+
if (navigationPurpose(request) === null) return undefined;
|
|
75
|
+
if (response.status < 300 || response.status > 399) return undefined;
|
|
76
|
+
const location = response.headers.get('location');
|
|
77
|
+
if (location === null) return undefined;
|
|
78
|
+
return relocate(new URL(location, base).href, response.headers);
|
|
79
|
+
}
|
package/src/pipeline.ts
CHANGED
|
@@ -46,7 +46,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
46
46
|
{
|
|
47
47
|
name: 'context',
|
|
48
48
|
phase: 'request',
|
|
49
|
-
why:
|
|
49
|
+
why: "creates the ambient context and binds the matched route; build skew is checked here because a stale client must be told to reload before it gets a 404 for a route it no longer knows — and a client-router request the route may not answer (a prefetch it did not opt into, a soft visit to anything but a page of that router's surface) is answered here, before auth or any app code runs",
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
name: 'locale',
|
|
@@ -92,7 +92,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
92
92
|
{
|
|
93
93
|
name: 'response',
|
|
94
94
|
phase: 'finalize',
|
|
95
|
-
why: 'last: CORS, security headers, server-timing and the accumulated context headers are merged onto whatever the stages produced',
|
|
95
|
+
why: 'last: a redirect answered to a client-router request becomes 204 + x-ultimate-location (the router follows it once, never fetch), then CORS, security headers, server-timing and the accumulated context headers are merged onto whatever the stages produced',
|
|
96
96
|
},
|
|
97
97
|
];
|
|
98
98
|
|
package/src/router.ts
CHANGED
|
@@ -33,6 +33,13 @@ export const HTTP_METHODS: readonly HttpMethod[] = [
|
|
|
33
33
|
|
|
34
34
|
export type RouteParams = Readonly<Record<string, string>>;
|
|
35
35
|
|
|
36
|
+
/** Which router may swap this page in, and whether it may be fetched before a click. */
|
|
37
|
+
export interface RouteNavigation {
|
|
38
|
+
/** `<app>:<surface>` — the router's own document must carry the same. */
|
|
39
|
+
readonly surface: string;
|
|
40
|
+
readonly prefetch: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
export interface RouteMeta {
|
|
37
44
|
/** Stable id used by rate-limit keys, traces and the manifest. */
|
|
38
45
|
readonly name: string;
|
|
@@ -79,6 +86,13 @@ export interface RouteMeta {
|
|
|
79
86
|
* serves for `/` cannot negotiate, so the served process must not either.
|
|
80
87
|
*/
|
|
81
88
|
readonly localeSource?: 'path' | 'request';
|
|
89
|
+
/**
|
|
90
|
+
* Present ONLY on a page the client router may swap in: its `<app>:<surface>` and whether it
|
|
91
|
+
* may be prefetched. Absent — every action, query, asset, storage and app route, and a page
|
|
92
|
+
* declared `navigation: 'document'` — a router request is answered before the route runs
|
|
93
|
+
* (`navigation.ts`'s gate): `204` to a prefetch, `204` + `x-ultimate-location` to a soft visit.
|
|
94
|
+
*/
|
|
95
|
+
readonly navigation?: RouteNavigation;
|
|
82
96
|
/**
|
|
83
97
|
* THIS route's authenticator, in place of the app's `configureAuthenticator()` — never beside
|
|
84
98
|
* it. A route that states one is reached only through the credential it names: a bearer mount
|
package/src/stages.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type { ServerHooks } from './hooks';
|
|
|
35
35
|
import { acceptsHtml } from './html-render';
|
|
36
36
|
import { dropVary, localeFromPath, routeLocalePrefix } from './locale-prefix';
|
|
37
37
|
import { compose, type Middleware } from './middleware';
|
|
38
|
+
import { navigationGate, redirectForRouter } from './navigation';
|
|
38
39
|
import { overlayResponse } from './overlay';
|
|
39
40
|
import { resolvePreferences } from './preferences';
|
|
40
41
|
import { type RateLimitDecision, type RateLimiter, rateLimitSpends } from './rate-limit';
|
|
@@ -187,7 +188,8 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
187
188
|
}
|
|
188
189
|
ctx.route = match.route;
|
|
189
190
|
ctx.params = match.params;
|
|
190
|
-
|
|
191
|
+
// Before auth and every app hook: a router request a route may not answer runs nothing.
|
|
192
|
+
return navigationGate(request.raw, ctx.method, ctx.url, match.route.meta.navigation);
|
|
191
193
|
},
|
|
192
194
|
|
|
193
195
|
/**
|
|
@@ -434,6 +436,12 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
434
436
|
},
|
|
435
437
|
|
|
436
438
|
response: (request, ctx) => {
|
|
439
|
+
// First, so everything below lands on the answer the router actually gets.
|
|
440
|
+
const handedOver =
|
|
441
|
+
ctx.response === undefined
|
|
442
|
+
? undefined
|
|
443
|
+
: redirectForRouter(request.raw, ctx.response, ctx.url);
|
|
444
|
+
if (handedOver !== undefined) ctx.response = handedOver;
|
|
437
445
|
const response = ctx.response;
|
|
438
446
|
if (response === undefined) return undefined;
|
|
439
447
|
for (const [name, value] of ctx.headers) response.headers.set(name, value);
|