@ultimat3/http 0.0.1
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/README.md +70 -0
- package/package.json +36 -0
- package/src/config.ts +89 -0
- package/src/context.ts +105 -0
- package/src/cors.ts +61 -0
- package/src/error-map.ts +124 -0
- package/src/errors.ts +122 -0
- package/src/hooks.ts +32 -0
- package/src/index.ts +119 -0
- package/src/locale.ts +109 -0
- package/src/middleware.ts +24 -0
- package/src/overlay.ts +106 -0
- package/src/pipeline.ts +392 -0
- package/src/rate-limit.ts +146 -0
- package/src/request.ts +170 -0
- package/src/response.ts +129 -0
- package/src/router.ts +251 -0
- package/src/security-headers.ts +90 -0
- package/src/server.ts +157 -0
- package/src/validate.ts +55 -0
package/src/response.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Response constructors. Every response in the framework is built here so that
|
|
2
|
+
// content types, charsets and cache semantics are decided once instead of per route.
|
|
3
|
+
import { toProblem } from './error-map';
|
|
4
|
+
|
|
5
|
+
type HeaderSource = { readonly headers?: HeadersInit | undefined } | undefined;
|
|
6
|
+
|
|
7
|
+
const withDefaults = (init: HeaderSource, defaults: Record<string, string>): Headers => {
|
|
8
|
+
const headers = new Headers(init?.headers);
|
|
9
|
+
for (const [name, value] of Object.entries(defaults)) {
|
|
10
|
+
if (!headers.has(name)) headers.set(name, value);
|
|
11
|
+
}
|
|
12
|
+
return headers;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const json = <T>(body: T, init?: ResponseInit): Response =>
|
|
16
|
+
new Response(JSON.stringify(body), {
|
|
17
|
+
...init,
|
|
18
|
+
headers: withDefaults(init, { 'content-type': 'application/json; charset=utf-8' }),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const text = (body: string, init?: ResponseInit): Response =>
|
|
22
|
+
new Response(body, {
|
|
23
|
+
...init,
|
|
24
|
+
headers: withDefaults(init, { 'content-type': 'text/plain; charset=utf-8' }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const html = (markup: string, init?: ResponseInit): Response =>
|
|
28
|
+
new Response(markup, {
|
|
29
|
+
...init,
|
|
30
|
+
headers: withDefaults(init, { 'content-type': 'text/html; charset=utf-8' }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Streamed responses must never be buffered by an intermediary, otherwise the
|
|
35
|
+
* shell-then-holes render mode degrades to plain SSR without anyone noticing.
|
|
36
|
+
*/
|
|
37
|
+
export const stream = (
|
|
38
|
+
body: ReadableStream<Uint8Array>,
|
|
39
|
+
init?: ResponseInit & { readonly contentType?: string },
|
|
40
|
+
): Response =>
|
|
41
|
+
new Response(body, {
|
|
42
|
+
...init,
|
|
43
|
+
headers: withDefaults(init, {
|
|
44
|
+
'content-type': init?.contentType ?? 'text/html; charset=utf-8',
|
|
45
|
+
'transfer-encoding': 'chunked',
|
|
46
|
+
'x-accel-buffering': 'no',
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const noContent = (init?: ResponseInit): Response =>
|
|
51
|
+
new Response(null, { ...init, status: 204 });
|
|
52
|
+
|
|
53
|
+
/** 303 after a mutation, 302 otherwise — never 301 from application code. */
|
|
54
|
+
export const redirect = (location: string, status: 302 | 303 | 307 | 308 = 302): Response =>
|
|
55
|
+
new Response(null, { status, headers: { location } });
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* RFC-9457. The body carries the framework's error contract verbatim: `code`,
|
|
59
|
+
* `cause`, `fix`, `docs`. An agent reading a failed response gets the same three
|
|
60
|
+
* strings a human reads in the terminal.
|
|
61
|
+
*/
|
|
62
|
+
export const problem = (
|
|
63
|
+
error: unknown,
|
|
64
|
+
meta: { instance?: string; requestId?: string; headers?: Record<string, string> } = {},
|
|
65
|
+
): Response => {
|
|
66
|
+
const document = toProblem(error, {
|
|
67
|
+
...(meta.instance === undefined ? {} : { instance: meta.instance }),
|
|
68
|
+
...(meta.requestId === undefined ? {} : { requestId: meta.requestId }),
|
|
69
|
+
});
|
|
70
|
+
return new Response(JSON.stringify(document), {
|
|
71
|
+
status: document.status,
|
|
72
|
+
headers: withDefaults(
|
|
73
|
+
{ headers: meta.headers },
|
|
74
|
+
{
|
|
75
|
+
'content-type': 'application/problem+json; charset=utf-8',
|
|
76
|
+
// A problem is never cacheable: the next request may well succeed.
|
|
77
|
+
'cache-control': 'no-store',
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export interface CacheHint {
|
|
84
|
+
readonly mode: 'no-store' | 'private' | 'public' | 'immutable';
|
|
85
|
+
readonly maxAgeSeconds?: number;
|
|
86
|
+
/** Shared/CDN age. `isr` routes set this and rely on tag purges to revalidate. */
|
|
87
|
+
readonly sMaxAgeSeconds?: number;
|
|
88
|
+
readonly staleWhileRevalidateSeconds?: number;
|
|
89
|
+
/** Cache tags a purge can target; mirrored into `x-cache-tags`. */
|
|
90
|
+
readonly tags?: readonly string[];
|
|
91
|
+
readonly vary?: readonly string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const NO_STORE: CacheHint = { mode: 'no-store' };
|
|
95
|
+
|
|
96
|
+
export const cacheControl = (hint: CacheHint): string => {
|
|
97
|
+
if (hint.mode === 'no-store') return 'no-store';
|
|
98
|
+
if (hint.mode === 'immutable') {
|
|
99
|
+
return `public, max-age=${hint.maxAgeSeconds ?? 31_536_000}, immutable`;
|
|
100
|
+
}
|
|
101
|
+
const parts = [hint.mode, `max-age=${hint.maxAgeSeconds ?? 0}`];
|
|
102
|
+
if (hint.mode === 'public' && hint.sMaxAgeSeconds !== undefined) {
|
|
103
|
+
parts.push(`s-maxage=${hint.sMaxAgeSeconds}`);
|
|
104
|
+
}
|
|
105
|
+
if (hint.staleWhileRevalidateSeconds !== undefined) {
|
|
106
|
+
parts.push(`stale-while-revalidate=${hint.staleWhileRevalidateSeconds}`);
|
|
107
|
+
}
|
|
108
|
+
return parts.join(', ');
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Mutates the response headers in place — responses are per-request, never shared. */
|
|
112
|
+
export const applyCacheHeaders = (response: Response, hint: CacheHint): Response => {
|
|
113
|
+
response.headers.set('cache-control', cacheControl(hint));
|
|
114
|
+
if (hint.tags !== undefined && hint.tags.length > 0) {
|
|
115
|
+
response.headers.set('x-cache-tags', hint.tags.join(','));
|
|
116
|
+
}
|
|
117
|
+
const vary = hint.vary ?? (hint.mode === 'public' ? ['accept-language'] : []);
|
|
118
|
+
if (vary.length > 0) {
|
|
119
|
+
const existing = response.headers.get('vary');
|
|
120
|
+
const merged = new Set([...(existing === null ? [] : existing.split(/,\s*/)), ...vary]);
|
|
121
|
+
response.headers.set('vary', [...merged].join(', '));
|
|
122
|
+
}
|
|
123
|
+
return response;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const withHeaders = (response: Response, headers: Record<string, string>): Response => {
|
|
127
|
+
for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
|
|
128
|
+
return response;
|
|
129
|
+
};
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// The route table. A segment trie, not a regex list, so matching cost is bounded by
|
|
2
|
+
// path depth and precedence is a property of the structure rather than of
|
|
3
|
+
// declaration order.
|
|
4
|
+
//
|
|
5
|
+
// PRECEDENCE (deterministic, tested in router.test.ts):
|
|
6
|
+
// 1. static segment `/posts/new`
|
|
7
|
+
// 2. param segment `/posts/:id`
|
|
8
|
+
// 3. wildcard segment `/posts/*rest` (matches ONE OR MORE remaining segments)
|
|
9
|
+
// Depth-first with backtracking: `/posts/new` beats `/posts/:id` even though the
|
|
10
|
+
// param branch would also match, and a dead end in the static branch still falls
|
|
11
|
+
// back to the param branch. Two routes that would tie are a build error
|
|
12
|
+
// (`X_ROUTE_CONFLICT`) rather than a coin flip.
|
|
13
|
+
import type { RequestContext } from './context';
|
|
14
|
+
import { routeConflict } from './errors';
|
|
15
|
+
import type { UltimateRequest } from './request';
|
|
16
|
+
import type { CacheHint } from './response';
|
|
17
|
+
import type { Schema } from './validate';
|
|
18
|
+
|
|
19
|
+
export type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
|
|
20
|
+
|
|
21
|
+
export const HTTP_METHODS: readonly HttpMethod[] = [
|
|
22
|
+
'GET',
|
|
23
|
+
'HEAD',
|
|
24
|
+
'POST',
|
|
25
|
+
'PUT',
|
|
26
|
+
'PATCH',
|
|
27
|
+
'DELETE',
|
|
28
|
+
'OPTIONS',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream' | 'spa';
|
|
32
|
+
|
|
33
|
+
export type RouteParams = Readonly<Record<string, string>>;
|
|
34
|
+
|
|
35
|
+
export interface RouteMeta {
|
|
36
|
+
/** Stable id used by rate-limit keys, traces and the manifest. */
|
|
37
|
+
readonly name: string;
|
|
38
|
+
/**
|
|
39
|
+
* Required, never inferred: a route that forgets to declare its auth posture is a
|
|
40
|
+
* type error instead of an accidentally public endpoint.
|
|
41
|
+
*/
|
|
42
|
+
readonly auth: 'public' | 'required';
|
|
43
|
+
/** Name of the policy the authz stage must satisfy. Resolved by tier 3. */
|
|
44
|
+
readonly policy?: string;
|
|
45
|
+
/** Validated in the body stage; also what the OpenAPI/MCP emitters read. */
|
|
46
|
+
readonly input?: Schema;
|
|
47
|
+
readonly render?: RenderMode;
|
|
48
|
+
readonly cache?: CacheHint;
|
|
49
|
+
/** Named bucket from `rateLimit.buckets`. */
|
|
50
|
+
readonly rateLimit?: string;
|
|
51
|
+
readonly tags?: readonly string[];
|
|
52
|
+
readonly description?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type RouteHandler = (
|
|
56
|
+
request: UltimateRequest,
|
|
57
|
+
ctx: RequestContext,
|
|
58
|
+
) => Response | Promise<Response>;
|
|
59
|
+
|
|
60
|
+
export interface Route {
|
|
61
|
+
readonly method: HttpMethod;
|
|
62
|
+
/** Leading slash, no trailing slash, segments may be `:param` or `*wildcard`. */
|
|
63
|
+
readonly path: string;
|
|
64
|
+
readonly handler: RouteHandler;
|
|
65
|
+
readonly meta: RouteMeta;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type MatchResult =
|
|
69
|
+
| { readonly ok: true; readonly route: Route; readonly params: RouteParams }
|
|
70
|
+
| { readonly ok: false; readonly reason: 'not-found' }
|
|
71
|
+
| {
|
|
72
|
+
readonly ok: false;
|
|
73
|
+
readonly reason: 'method-not-allowed';
|
|
74
|
+
readonly allow: readonly HttpMethod[];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
interface TrieNode {
|
|
78
|
+
readonly statics: Map<string, TrieNode>;
|
|
79
|
+
param: { readonly name: string; readonly node: TrieNode } | undefined;
|
|
80
|
+
wildcard: { readonly name: string; readonly node: TrieNode } | undefined;
|
|
81
|
+
readonly routes: Map<HttpMethod, Route>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface RouteTable {
|
|
85
|
+
readonly routes: readonly Route[];
|
|
86
|
+
readonly root: TrieNode;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const node = (): TrieNode => ({
|
|
90
|
+
statics: new Map(),
|
|
91
|
+
param: undefined,
|
|
92
|
+
wildcard: undefined,
|
|
93
|
+
routes: new Map(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** `/a/b/` and `//a//b` both normalise to `/a/b`; `/` stays `/`. */
|
|
97
|
+
export const normalizePath = (path: string): string => {
|
|
98
|
+
const trimmed = path.split('/').filter((segment) => segment.length > 0);
|
|
99
|
+
return trimmed.length === 0 ? '/' : `/${trimmed.join('/')}`;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const segmentsOf = (path: string): readonly string[] =>
|
|
103
|
+
normalizePath(path)
|
|
104
|
+
.split('/')
|
|
105
|
+
.filter((segment) => segment.length > 0);
|
|
106
|
+
|
|
107
|
+
export const createRouter = (routes: readonly Route[]): RouteTable => {
|
|
108
|
+
const root = node();
|
|
109
|
+
for (const route of routes) {
|
|
110
|
+
let current = root;
|
|
111
|
+
const segments = segmentsOf(route.path);
|
|
112
|
+
for (const [index, segment] of segments.entries()) {
|
|
113
|
+
if (segment.startsWith(':')) {
|
|
114
|
+
const name = segment.slice(1);
|
|
115
|
+
if (current.param !== undefined && current.param.name !== name) {
|
|
116
|
+
throw routeConflict(
|
|
117
|
+
route.path,
|
|
118
|
+
`segment ${index} is :${current.param.name} in an existing route and :${name} here`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
current.param = current.param ?? { name, node: node() };
|
|
122
|
+
current = current.param.node;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (segment.startsWith('*')) {
|
|
126
|
+
const name = segment.slice(1) || 'wildcard';
|
|
127
|
+
if (index !== segments.length - 1) {
|
|
128
|
+
throw routeConflict(route.path, 'a wildcard must be the last segment');
|
|
129
|
+
}
|
|
130
|
+
if (current.wildcard !== undefined && current.wildcard.name !== name) {
|
|
131
|
+
throw routeConflict(route.path, `wildcard is *${current.wildcard.name} elsewhere`);
|
|
132
|
+
}
|
|
133
|
+
current.wildcard = current.wildcard ?? { name, node: node() };
|
|
134
|
+
current = current.wildcard.node;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const existing = current.statics.get(segment);
|
|
138
|
+
const next = existing ?? node();
|
|
139
|
+
if (existing === undefined) current.statics.set(segment, next);
|
|
140
|
+
current = next;
|
|
141
|
+
}
|
|
142
|
+
if (current.routes.has(route.method)) {
|
|
143
|
+
throw routeConflict(route.path, `${route.method} is already handled by another route`);
|
|
144
|
+
}
|
|
145
|
+
current.routes.set(route.method, route);
|
|
146
|
+
}
|
|
147
|
+
return { routes: [...routes], root };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
interface Candidate {
|
|
151
|
+
readonly node: TrieNode;
|
|
152
|
+
readonly params: RouteParams;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Terminal nodes reachable for `segments`, in precedence order. */
|
|
156
|
+
const candidates = (
|
|
157
|
+
current: TrieNode,
|
|
158
|
+
segments: readonly string[],
|
|
159
|
+
index: number,
|
|
160
|
+
params: RouteParams,
|
|
161
|
+
out: Candidate[],
|
|
162
|
+
): void => {
|
|
163
|
+
if (index === segments.length) {
|
|
164
|
+
if (current.routes.size > 0) out.push({ node: current, params });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const segment = segments[index];
|
|
168
|
+
if (segment === undefined) return;
|
|
169
|
+
|
|
170
|
+
const staticChild = current.statics.get(segment);
|
|
171
|
+
if (staticChild !== undefined) candidates(staticChild, segments, index + 1, params, out);
|
|
172
|
+
|
|
173
|
+
if (current.param !== undefined) {
|
|
174
|
+
const next = { ...params, [current.param.name]: decodeURIComponent(segment) };
|
|
175
|
+
candidates(current.param.node, segments, index + 1, next, out);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (current.wildcard !== undefined) {
|
|
179
|
+
const rest = segments.slice(index).map(decodeURIComponent).join('/');
|
|
180
|
+
const next = { ...params, [current.wildcard.name]: rest };
|
|
181
|
+
if (current.wildcard.node.routes.size > 0) {
|
|
182
|
+
out.push({ node: current.wildcard.node, params: next });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* HEAD falls back to the GET route: the runtime strips the body, so declaring both
|
|
189
|
+
* would be two ways to do one thing.
|
|
190
|
+
*/
|
|
191
|
+
const routeFor = (candidate: Candidate, method: HttpMethod): Route | undefined =>
|
|
192
|
+
candidate.node.routes.get(method) ??
|
|
193
|
+
(method === 'HEAD' ? candidate.node.routes.get('GET') : undefined);
|
|
194
|
+
|
|
195
|
+
export const matchRoute = (table: RouteTable, method: string, pathname: string): MatchResult => {
|
|
196
|
+
const segments = segmentsOf(pathname);
|
|
197
|
+
const found: Candidate[] = [];
|
|
198
|
+
candidates(table.root, segments, 0, {}, found);
|
|
199
|
+
if (found.length === 0) return { ok: false, reason: 'not-found' };
|
|
200
|
+
|
|
201
|
+
const wanted = method.toUpperCase() as HttpMethod;
|
|
202
|
+
for (const candidate of found) {
|
|
203
|
+
const route = routeFor(candidate, wanted);
|
|
204
|
+
if (route !== undefined) return { ok: true, route, params: candidate.params };
|
|
205
|
+
}
|
|
206
|
+
const allow = new Set<HttpMethod>();
|
|
207
|
+
for (const candidate of found) for (const key of candidate.node.routes.keys()) allow.add(key);
|
|
208
|
+
if (allow.has('GET')) allow.add('HEAD');
|
|
209
|
+
return { ok: false, reason: 'method-not-allowed', allow: [...allow] };
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export interface RouteDescription {
|
|
213
|
+
readonly method: HttpMethod;
|
|
214
|
+
readonly path: string;
|
|
215
|
+
readonly name: string;
|
|
216
|
+
readonly params: readonly string[];
|
|
217
|
+
readonly auth: 'public' | 'required';
|
|
218
|
+
readonly policy: string | null;
|
|
219
|
+
readonly render: RenderMode | null;
|
|
220
|
+
readonly rateLimit: string | null;
|
|
221
|
+
readonly tags: readonly string[];
|
|
222
|
+
readonly description: string | null;
|
|
223
|
+
readonly hasInputSchema: boolean;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const compare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
227
|
+
|
|
228
|
+
const paramsOf = (path: string): readonly string[] =>
|
|
229
|
+
segmentsOf(path)
|
|
230
|
+
.filter((segment) => segment.startsWith(':') || segment.startsWith('*'))
|
|
231
|
+
.map((segment) => segment.slice(1));
|
|
232
|
+
|
|
233
|
+
/** Feeds the `/_x` dashboard, the manifest emitter and `x routes list --json`. */
|
|
234
|
+
export const describeRoutes = (table: RouteTable): readonly RouteDescription[] =>
|
|
235
|
+
table.routes
|
|
236
|
+
.map((route) => ({
|
|
237
|
+
method: route.method,
|
|
238
|
+
path: normalizePath(route.path),
|
|
239
|
+
name: route.meta.name,
|
|
240
|
+
params: paramsOf(route.path),
|
|
241
|
+
auth: route.meta.auth,
|
|
242
|
+
policy: route.meta.policy ?? null,
|
|
243
|
+
render: route.meta.render ?? null,
|
|
244
|
+
rateLimit: route.meta.rateLimit ?? null,
|
|
245
|
+
tags: route.meta.tags ?? [],
|
|
246
|
+
description: route.meta.description ?? null,
|
|
247
|
+
hasInputSchema: route.meta.input !== undefined,
|
|
248
|
+
}))
|
|
249
|
+
// Code-unit compare, not localeCompare: the manifest is a build artefact and must
|
|
250
|
+
// be byte-identical across machines and ICU versions.
|
|
251
|
+
.sort((a, b) => compare(a.path, b.path) || compare(a.method, b.method));
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Locked security headers. The CSP is written to work with the three things the
|
|
2
|
+
// framework actually ships — a service worker, streamed HTML with a hydration
|
|
3
|
+
// nonce, and wasm — and nothing else, so widening it is a visible config change.
|
|
4
|
+
|
|
5
|
+
export interface SecurityConfig {
|
|
6
|
+
readonly csp: {
|
|
7
|
+
/** Extra sources per directive, merged into the locked baseline. */
|
|
8
|
+
readonly extend: Readonly<Record<string, readonly string[]>>;
|
|
9
|
+
readonly reportUri: string | null;
|
|
10
|
+
/** `true` sends Content-Security-Policy-Report-Only instead. Dev default. */
|
|
11
|
+
readonly reportOnly: boolean;
|
|
12
|
+
};
|
|
13
|
+
readonly hsts: {
|
|
14
|
+
readonly maxAgeSeconds: number;
|
|
15
|
+
readonly includeSubdomains: boolean;
|
|
16
|
+
readonly preload: boolean;
|
|
17
|
+
} | null;
|
|
18
|
+
readonly frameAncestors: readonly string[];
|
|
19
|
+
readonly referrerPolicy: string;
|
|
20
|
+
readonly permissionsPolicy: string;
|
|
21
|
+
readonly coop: string;
|
|
22
|
+
readonly corp: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_SECURITY: SecurityConfig = {
|
|
26
|
+
csp: { extend: {}, reportUri: null, reportOnly: false },
|
|
27
|
+
frameAncestors: ["'none'"],
|
|
28
|
+
referrerPolicy: 'strict-origin-when-cross-origin',
|
|
29
|
+
permissionsPolicy: 'camera=(), microphone=(), geolocation=(), payment=()',
|
|
30
|
+
coop: 'same-origin',
|
|
31
|
+
corp: 'same-origin',
|
|
32
|
+
hsts: { maxAgeSeconds: 63_072_000, includeSubdomains: true, preload: false },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Directive -> sources. `'nonce-*'` is injected per response, never stored here. */
|
|
36
|
+
const baseline = (config: SecurityConfig): Record<string, readonly string[]> => ({
|
|
37
|
+
'default-src': ["'self'"],
|
|
38
|
+
// 'wasm-unsafe-eval' only: no 'unsafe-inline', no 'unsafe-eval'.
|
|
39
|
+
'script-src': ["'self'", "'wasm-unsafe-eval'"],
|
|
40
|
+
'style-src': ["'self'"],
|
|
41
|
+
'img-src': ["'self'", 'data:', 'blob:'],
|
|
42
|
+
'font-src': ["'self'"],
|
|
43
|
+
// ws:/wss: are required by the realtime tiers; blob: by streamed responses.
|
|
44
|
+
'connect-src': ["'self'", 'ws:', 'wss:', 'blob:'],
|
|
45
|
+
'worker-src': ["'self'", 'blob:'],
|
|
46
|
+
'manifest-src': ["'self'"],
|
|
47
|
+
'media-src': ["'self'", 'blob:'],
|
|
48
|
+
'frame-ancestors': config.frameAncestors,
|
|
49
|
+
'form-action': ["'self'"],
|
|
50
|
+
'base-uri': ["'none'"],
|
|
51
|
+
'object-src': ["'none'"],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const buildCsp = (config: SecurityConfig, nonce?: string): string => {
|
|
55
|
+
const directives = baseline(config);
|
|
56
|
+
for (const [name, sources] of Object.entries(config.csp.extend)) {
|
|
57
|
+
directives[name] = [...(directives[name] ?? []), ...sources];
|
|
58
|
+
}
|
|
59
|
+
if (nonce !== undefined) {
|
|
60
|
+
directives['script-src'] = [...(directives['script-src'] ?? []), `'nonce-${nonce}'`];
|
|
61
|
+
}
|
|
62
|
+
const parts = Object.entries(directives).map(([name, sources]) => `${name} ${sources.join(' ')}`);
|
|
63
|
+
if (config.csp.reportUri !== null) parts.push(`report-uri ${config.csp.reportUri}`);
|
|
64
|
+
return parts.join('; ');
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const securityHeaders = (
|
|
68
|
+
config: SecurityConfig,
|
|
69
|
+
options: { nonce?: string; https?: boolean } = {},
|
|
70
|
+
): Record<string, string> => {
|
|
71
|
+
const cspHeader = config.csp.reportOnly
|
|
72
|
+
? 'content-security-policy-report-only'
|
|
73
|
+
: 'content-security-policy';
|
|
74
|
+
const headers: Record<string, string> = {
|
|
75
|
+
[cspHeader]: buildCsp(config, options.nonce),
|
|
76
|
+
'x-content-type-options': 'nosniff',
|
|
77
|
+
'referrer-policy': config.referrerPolicy,
|
|
78
|
+
'permissions-policy': config.permissionsPolicy,
|
|
79
|
+
'cross-origin-opener-policy': config.coop,
|
|
80
|
+
'cross-origin-resource-policy': config.corp,
|
|
81
|
+
};
|
|
82
|
+
// HSTS over plaintext is ignored by browsers and confuses local dev, so skip it.
|
|
83
|
+
if (config.hsts !== null && options.https !== false) {
|
|
84
|
+
const parts = [`max-age=${config.hsts.maxAgeSeconds}`];
|
|
85
|
+
if (config.hsts.includeSubdomains) parts.push('includeSubDomains');
|
|
86
|
+
if (config.hsts.preload) parts.push('preload');
|
|
87
|
+
headers['strict-transport-security'] = parts.join('; ');
|
|
88
|
+
}
|
|
89
|
+
return headers;
|
|
90
|
+
};
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// We own the server lifecycle instead of exposing `Bun.serve` directly, because ALS
|
|
2
|
+
// context, tracing and authz must be impossible to skip — a route is a data
|
|
3
|
+
// declaration, never a chance to hand-roll a request handler.
|
|
4
|
+
|
|
5
|
+
import type { Role } from '@ultimat3/core';
|
|
6
|
+
import { logger, onShutdown } from '@ultimat3/core';
|
|
7
|
+
import type { Server } from 'bun';
|
|
8
|
+
import { defineHttpConfig, type HttpConfig } from './config';
|
|
9
|
+
import { serverNotStarted } from './errors';
|
|
10
|
+
import type { ServerHooks } from './hooks';
|
|
11
|
+
import type { Middleware } from './middleware';
|
|
12
|
+
import { createPipeline, type Pipeline } from './pipeline';
|
|
13
|
+
import { json } from './response';
|
|
14
|
+
import { createRouter, describeRoutes, type Route, type RouteDescription } from './router';
|
|
15
|
+
|
|
16
|
+
export type LifecycleState = 'idle' | 'starting' | 'ready' | 'draining' | 'stopped';
|
|
17
|
+
|
|
18
|
+
/** `Server` is generic over its websocket payload; the `web` role does not use one. */
|
|
19
|
+
type BunServer = Server<unknown>;
|
|
20
|
+
|
|
21
|
+
type NativeHandler = (request: Request, socket: BunServer) => Promise<Response>;
|
|
22
|
+
|
|
23
|
+
export interface ServerOptions {
|
|
24
|
+
readonly routes: readonly Route[];
|
|
25
|
+
readonly config?: HttpConfig;
|
|
26
|
+
/** `ROLE` env selects behaviour; one image, N processes. */
|
|
27
|
+
readonly role?: Role;
|
|
28
|
+
readonly hooks?: ServerHooks;
|
|
29
|
+
readonly middleware?: readonly Middleware[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ServerHandle {
|
|
33
|
+
readonly role: Role;
|
|
34
|
+
readonly config: HttpConfig;
|
|
35
|
+
readonly pipeline: Pipeline;
|
|
36
|
+
state(): LifecycleState;
|
|
37
|
+
/** `http://host:port` once started; throws before `start()`. */
|
|
38
|
+
url(): string;
|
|
39
|
+
describe(): readonly RouteDescription[];
|
|
40
|
+
start(): ServerHandle;
|
|
41
|
+
stop(options?: { readonly timeoutMs?: number }): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Runs one request through the entire lifecycle with no socket. This is the
|
|
44
|
+
* supported way to test routes: there is no second, "lighter" code path.
|
|
45
|
+
*/
|
|
46
|
+
fetch(request: Request): Promise<Response>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const roleFromEnv = (): Role => (Bun.env['ROLE'] ?? 'web') as Role;
|
|
50
|
+
|
|
51
|
+
export const createServer = (options: ServerOptions): ServerHandle => {
|
|
52
|
+
const config = options.config ?? defineHttpConfig();
|
|
53
|
+
const role = options.role ?? roleFromEnv();
|
|
54
|
+
const table = createRouter(options.routes);
|
|
55
|
+
const pipeline = createPipeline({
|
|
56
|
+
table,
|
|
57
|
+
config,
|
|
58
|
+
...(options.hooks === undefined ? {} : { hooks: options.hooks }),
|
|
59
|
+
...(options.middleware === undefined ? {} : { middleware: options.middleware }),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let state: LifecycleState = 'idle';
|
|
63
|
+
let server: BunServer | undefined;
|
|
64
|
+
let inflight = 0;
|
|
65
|
+
|
|
66
|
+
const health = (): Response =>
|
|
67
|
+
json(
|
|
68
|
+
{ status: 'ok', role, state, buildId: config.buildId },
|
|
69
|
+
{
|
|
70
|
+
headers: { 'cache-control': 'no-store' },
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Readiness is the only thing a load balancer reads, so it must flip to 503 the
|
|
75
|
+
// instant we start draining — before the socket closes, so in-flight work finishes.
|
|
76
|
+
const ready = (): Response =>
|
|
77
|
+
json(
|
|
78
|
+
{ status: state === 'ready' ? 'ready' : state, role, buildId: config.buildId },
|
|
79
|
+
{ status: state === 'ready' ? 200 : 503, headers: { 'cache-control': 'no-store' } },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const dispatch = async (request: Request, socket?: BunServer): Promise<Response> => {
|
|
83
|
+
inflight += 1;
|
|
84
|
+
try {
|
|
85
|
+
return await pipeline.handle(request, {
|
|
86
|
+
role,
|
|
87
|
+
ip: socket?.requestIP(request)?.address ?? null,
|
|
88
|
+
});
|
|
89
|
+
} finally {
|
|
90
|
+
inflight -= 1;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Static paths go into Bun's native route table so path dispatch happens in
|
|
96
|
+
* native code. Method resolution stays ours: Bun's automatic 405 would not carry
|
|
97
|
+
* our problem+json body. Param/wildcard paths fall through to `fetch`.
|
|
98
|
+
*/
|
|
99
|
+
const nativeRoutes = (): Record<string, NativeHandler> => {
|
|
100
|
+
const out: Record<string, NativeHandler> = {};
|
|
101
|
+
const prefix = config.basePath === '/' ? '' : config.basePath.replace(/\/$/, '');
|
|
102
|
+
for (const description of describeRoutes(table)) {
|
|
103
|
+
if (description.params.length > 0) continue;
|
|
104
|
+
out[`${prefix}${description.path}`] = dispatch;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handle: ServerHandle = {
|
|
110
|
+
role,
|
|
111
|
+
config,
|
|
112
|
+
pipeline,
|
|
113
|
+
state: () => state,
|
|
114
|
+
url: () => {
|
|
115
|
+
if (server === undefined) throw serverNotStarted('url()');
|
|
116
|
+
return server.url.origin;
|
|
117
|
+
},
|
|
118
|
+
describe: () => describeRoutes(table),
|
|
119
|
+
fetch: (request) => dispatch(request),
|
|
120
|
+
start() {
|
|
121
|
+
state = 'starting';
|
|
122
|
+
server = Bun.serve({
|
|
123
|
+
port: config.port,
|
|
124
|
+
hostname: config.hostname,
|
|
125
|
+
development: config.dev,
|
|
126
|
+
routes: {
|
|
127
|
+
...nativeRoutes(),
|
|
128
|
+
// Health endpoints answer outside the pipeline on purpose: a draining or
|
|
129
|
+
// rate-limited process must still be able to say what it is doing.
|
|
130
|
+
'/healthz': () => health(),
|
|
131
|
+
'/readyz': () => ready(),
|
|
132
|
+
},
|
|
133
|
+
fetch: (request, socket) => dispatch(request, socket),
|
|
134
|
+
});
|
|
135
|
+
state = 'ready';
|
|
136
|
+
logger.info(`ultimate ${role} listening on ${server.url.origin}`);
|
|
137
|
+
// SIGTERM handling belongs to core so every role drains identically.
|
|
138
|
+
onShutdown(() => handle.stop());
|
|
139
|
+
return handle;
|
|
140
|
+
},
|
|
141
|
+
async stop(stopOptions) {
|
|
142
|
+
if (state === 'stopped') return;
|
|
143
|
+
state = 'draining';
|
|
144
|
+
const deadline = Date.now() + (stopOptions?.timeoutMs ?? config.drainTimeoutMs);
|
|
145
|
+
while (inflight > 0 && Date.now() < deadline) await Bun.sleep(25);
|
|
146
|
+
if (inflight > 0) {
|
|
147
|
+
logger.warn(`ultimate ${role} draining timed out with ${inflight} in-flight requests`);
|
|
148
|
+
}
|
|
149
|
+
await server?.stop(true);
|
|
150
|
+
server = undefined;
|
|
151
|
+
state = 'stopped';
|
|
152
|
+
logger.info(`ultimate ${role} stopped`);
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return handle;
|
|
157
|
+
};
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Validation goes through the Standard Schema interface, never through a vendor
|
|
2
|
+
// API, so ArkType (`t`) is a default rather than a dependency of the HTTP layer.
|
|
3
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
4
|
+
|
|
5
|
+
export type Schema<Out = unknown> = StandardSchemaV1<unknown, Out>;
|
|
6
|
+
|
|
7
|
+
export type InferOutput<S> = S extends Schema<infer Out> ? Out : never;
|
|
8
|
+
|
|
9
|
+
export type ValidationOutcome<Out> =
|
|
10
|
+
| { readonly ok: true; readonly value: Out }
|
|
11
|
+
| { readonly ok: false; readonly issues: readonly string[] };
|
|
12
|
+
|
|
13
|
+
type Issue = { readonly message: string; readonly path?: readonly unknown[] | undefined };
|
|
14
|
+
|
|
15
|
+
const segment = (value: unknown): string => {
|
|
16
|
+
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
|
17
|
+
if (typeof value === 'object' && value !== null && 'key' in value) {
|
|
18
|
+
return String((value as { key: unknown }).key);
|
|
19
|
+
}
|
|
20
|
+
return '?';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** `posts.0.title: must be a string` — a path an agent can act on directly. */
|
|
24
|
+
export const formatIssue = (issue: Issue): string => {
|
|
25
|
+
const path = (issue.path ?? []).map(segment).join('.');
|
|
26
|
+
return path.length > 0 ? `${path}: ${issue.message}` : issue.message;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const outcome = <Out>(result: {
|
|
30
|
+
readonly value?: Out;
|
|
31
|
+
readonly issues?: readonly Issue[] | undefined;
|
|
32
|
+
}): ValidationOutcome<Out> => {
|
|
33
|
+
if (result.issues !== undefined && result.issues.length > 0) {
|
|
34
|
+
return { ok: false, issues: result.issues.map(formatIssue) };
|
|
35
|
+
}
|
|
36
|
+
return { ok: true, value: result.value as Out };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Query strings and route params must validate without awaiting: they are read
|
|
41
|
+
* inside synchronous stages. A schema that returns a promise here is a bug in the
|
|
42
|
+
* schema, not in the request.
|
|
43
|
+
*/
|
|
44
|
+
export const validateSync = <Out>(schema: Schema<Out>, value: unknown): ValidationOutcome<Out> => {
|
|
45
|
+
const result = schema['~standard'].validate(value);
|
|
46
|
+
if (result instanceof Promise) {
|
|
47
|
+
return { ok: false, issues: ['schema is async; use validate() for request bodies'] };
|
|
48
|
+
}
|
|
49
|
+
return outcome<Out>(result);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const validate = async <Out>(
|
|
53
|
+
schema: Schema<Out>,
|
|
54
|
+
value: unknown,
|
|
55
|
+
): Promise<ValidationOutcome<Out>> => outcome<Out>(await schema['~standard'].validate(value));
|