@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/src/index.ts ADDED
@@ -0,0 +1,119 @@
1
+ // The public surface of @ultimat3/http. Explicit, never `export *`: what is not
2
+ // listed here is an implementation detail and may change without a major bump.
3
+
4
+ export type { HttpConfig, HttpConfigInput } from './config';
5
+ export { defineHttpConfig, stripBasePath } from './config';
6
+ export type { ActorView, RequestContext, RequestContextInit } from './context';
7
+ export {
8
+ actorView,
9
+ asCtx,
10
+ createRequestContext,
11
+ elapsedMs,
12
+ useRequestContext,
13
+ } from './context';
14
+ export type { CorsConfig } from './cors';
15
+ export { corsHeaders, DEFAULT_CORS, preflight } from './cors';
16
+ export type { ErrorFacts, ProblemDocument } from './error-map';
17
+ export {
18
+ DEFAULT_STATUS,
19
+ ERROR_STATUS,
20
+ factsOf,
21
+ renderErrorLines,
22
+ statusFor,
23
+ toProblem,
24
+ } from './error-map';
25
+ export type { HttpErrorCode } from './errors';
26
+ export {
27
+ bodyInvalid,
28
+ buildSkew,
29
+ forbidden,
30
+ HTTP_ERROR_CODES,
31
+ HTTP_ERROR_TITLES,
32
+ HttpError,
33
+ methodNotAllowed,
34
+ pipelineNoResponse,
35
+ rateLimited,
36
+ routeConflict,
37
+ routeNotFound,
38
+ serverNotStarted,
39
+ unauthenticated,
40
+ } from './errors';
41
+ export type { AuthzDecision, ServerHooks } from './hooks';
42
+ export type { LocaleConfig, TimeZoneConfig } from './locale';
43
+ export {
44
+ DEFAULT_LOCALE_CONFIG,
45
+ DEFAULT_TZ_CONFIG,
46
+ isValidTimeZone,
47
+ negotiateLocale,
48
+ readCookie,
49
+ resolveTimeZone,
50
+ } from './locale';
51
+ export type { Middleware } from './middleware';
52
+ export { compose } from './middleware';
53
+ export type { OverlayMeta } from './overlay';
54
+ export { overlayResponse, renderOverlay, wantsOverlay } from './overlay';
55
+ export type {
56
+ HandleInit,
57
+ Pipeline,
58
+ PipelineDeps,
59
+ Stage,
60
+ StageDoc,
61
+ StageName,
62
+ StagePhase,
63
+ StageRun,
64
+ } from './pipeline';
65
+ export { createPipeline, PIPELINE_STAGES } from './pipeline';
66
+ export type {
67
+ Bucket,
68
+ RateLimitConfig,
69
+ RateLimitDecision,
70
+ RateLimiter,
71
+ RateLimitKeyParts,
72
+ RateLimitStore,
73
+ } from './rate-limit';
74
+ export {
75
+ createRateLimiter,
76
+ DEFAULT_RATE_LIMIT,
77
+ memoryRateLimitStore,
78
+ rateLimitKey,
79
+ } from './rate-limit';
80
+ export type { QueryValues } from './request';
81
+ export { UltimateRequest } from './request';
82
+ export type { CacheHint } from './response';
83
+ export {
84
+ applyCacheHeaders,
85
+ cacheControl,
86
+ html,
87
+ json,
88
+ NO_STORE,
89
+ noContent,
90
+ problem,
91
+ redirect,
92
+ stream,
93
+ text,
94
+ withHeaders,
95
+ } from './response';
96
+ export type {
97
+ HttpMethod,
98
+ MatchResult,
99
+ RenderMode,
100
+ Route,
101
+ RouteDescription,
102
+ RouteHandler,
103
+ RouteMeta,
104
+ RouteParams,
105
+ RouteTable,
106
+ } from './router';
107
+ export {
108
+ createRouter,
109
+ describeRoutes,
110
+ HTTP_METHODS,
111
+ matchRoute,
112
+ normalizePath,
113
+ } from './router';
114
+ export type { SecurityConfig } from './security-headers';
115
+ export { buildCsp, DEFAULT_SECURITY, securityHeaders } from './security-headers';
116
+ export type { LifecycleState, ServerHandle, ServerOptions } from './server';
117
+ export { createServer } from './server';
118
+ export type { InferOutput, Schema, ValidationOutcome } from './validate';
119
+ export { formatIssue, validate, validateSync } from './validate';
package/src/locale.ts ADDED
@@ -0,0 +1,109 @@
1
+ // Locale and time zone are resolved once per request, before any handler runs, so
2
+ // no code path can format a date or a number without them. `@ultimat3/i18n` owns
3
+ // catalogs; this file only owns the negotiation of the two request-scoped values.
4
+
5
+ export interface LocaleConfig {
6
+ readonly supported: readonly string[];
7
+ readonly default: string;
8
+ /** Cookie the client sets when the user picks a locale explicitly. */
9
+ readonly cookie: string;
10
+ }
11
+
12
+ export interface TimeZoneConfig {
13
+ readonly default: string;
14
+ /** Header the client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
15
+ readonly header: string;
16
+ readonly cookie: string;
17
+ }
18
+
19
+ export const DEFAULT_LOCALE_CONFIG: LocaleConfig = {
20
+ supported: ['en'],
21
+ default: 'en',
22
+ cookie: 'x-locale',
23
+ };
24
+
25
+ export const DEFAULT_TZ_CONFIG: TimeZoneConfig = {
26
+ default: 'UTC',
27
+ header: 'x-timezone',
28
+ cookie: 'x-timezone',
29
+ };
30
+
31
+ export const readCookie = (header: string | null, name: string): string | null => {
32
+ if (header === null) return null;
33
+ for (const part of header.split(';')) {
34
+ const index = part.indexOf('=');
35
+ if (index === -1) continue;
36
+ if (part.slice(0, index).trim() !== name) continue;
37
+ return decodeURIComponent(part.slice(index + 1).trim());
38
+ }
39
+ return null;
40
+ };
41
+
42
+ interface Weighted {
43
+ readonly tag: string;
44
+ readonly q: number;
45
+ }
46
+
47
+ const parseAcceptLanguage = (header: string): readonly Weighted[] =>
48
+ header
49
+ .split(',')
50
+ .map((entry): Weighted => {
51
+ const [tag = '', ...params] = entry.trim().split(';');
52
+ const qParam = params.find((p) => p.trim().startsWith('q='));
53
+ const q = qParam === undefined ? 1 : Number.parseFloat(qParam.trim().slice(2));
54
+ return { tag: tag.trim().toLowerCase(), q: Number.isFinite(q) ? q : 0 };
55
+ })
56
+ .filter((entry) => entry.tag.length > 0 && entry.q > 0)
57
+ .sort((a, b) => b.q - a.q);
58
+
59
+ /**
60
+ * Exact match wins, then primary-subtag match (`de-CH` -> `de`), then the config
61
+ * default. `*` is treated as "no preference" rather than "any locale" so the
62
+ * default stays predictable for SEO and cache keys.
63
+ */
64
+ export const negotiateLocale = (
65
+ acceptLanguage: string | null,
66
+ config: LocaleConfig = DEFAULT_LOCALE_CONFIG,
67
+ explicit?: string | null,
68
+ ): string => {
69
+ const supported = config.supported.map((locale) => locale.toLowerCase());
70
+ const pick = (candidate: string): string | undefined => {
71
+ const wanted = candidate.toLowerCase();
72
+ const exact = supported.indexOf(wanted);
73
+ if (exact !== -1) return config.supported[exact];
74
+ const primary = wanted.split('-')[0] ?? wanted;
75
+ const loose = supported.findIndex((locale) => (locale.split('-')[0] ?? locale) === primary);
76
+ return loose === -1 ? undefined : config.supported[loose];
77
+ };
78
+
79
+ if (explicit !== undefined && explicit !== null) {
80
+ const chosen = pick(explicit);
81
+ if (chosen !== undefined) return chosen;
82
+ }
83
+ if (acceptLanguage !== null) {
84
+ for (const entry of parseAcceptLanguage(acceptLanguage)) {
85
+ if (entry.tag === '*') break;
86
+ const chosen = pick(entry.tag);
87
+ if (chosen !== undefined) return chosen;
88
+ }
89
+ }
90
+ return config.default;
91
+ };
92
+
93
+ /** A time zone is only accepted if `Intl` can actually format with it. */
94
+ export const isValidTimeZone = (candidate: string): boolean => {
95
+ try {
96
+ new Intl.DateTimeFormat('en', { timeZone: candidate }).format(0);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ };
102
+
103
+ export const resolveTimeZone = (
104
+ candidate: string | null,
105
+ config: TimeZoneConfig = DEFAULT_TZ_CONFIG,
106
+ ): string => {
107
+ if (candidate !== null && candidate.length > 0 && isValidTimeZone(candidate)) return candidate;
108
+ return config.default;
109
+ };
@@ -0,0 +1,24 @@
1
+ // Middleware exists for the one thing the pipeline cannot express: wrapping a
2
+ // handler (timing, transactions, retries). It is deliberately tiny — the ordered
3
+ // pipeline is the blessed extension point, and there is no plugin API before v1.
4
+ import type { RequestContext } from './context';
5
+ import type { UltimateRequest } from './request';
6
+ import type { RouteHandler } from './router';
7
+
8
+ export type Middleware = (
9
+ request: UltimateRequest,
10
+ ctx: RequestContext,
11
+ next: RouteHandler,
12
+ ) => Response | Promise<Response>;
13
+
14
+ /**
15
+ * Left-to-right: `compose([a, b])(handler)` runs `a` outermost. Composition is done
16
+ * once at server start, not per request, so the closure chain is built exactly once.
17
+ */
18
+ export const compose =
19
+ (middleware: readonly Middleware[]) =>
20
+ (handler: RouteHandler): RouteHandler =>
21
+ middleware.reduceRight<RouteHandler>(
22
+ (next, current) => (request, ctx) => current(request, ctx, next),
23
+ handler,
24
+ );
package/src/overlay.ts ADDED
@@ -0,0 +1,106 @@
1
+ // The dev error overlay. It renders the SAME facts object the terminal prints and
2
+ // `--json` emits, so a code/cause/fix string can never differ between the three
3
+ // surfaces. Labels here ("cause", "fix") are protocol strings from the error
4
+ // contract, not UI copy, so they are not routed through the i18n catalog.
5
+ import { factsOf, renderErrorLines, toProblem } from './error-map';
6
+ import { html } from './response';
7
+
8
+ const escapeHtml = (value: string): string =>
9
+ value
10
+ .replaceAll('&', '&amp;')
11
+ .replaceAll('<', '&lt;')
12
+ .replaceAll('>', '&gt;')
13
+ .replaceAll('"', '&quot;');
14
+
15
+ export interface OverlayMeta {
16
+ readonly requestId?: string;
17
+ readonly method?: string;
18
+ readonly path?: string;
19
+ readonly buildId?: string | null;
20
+ }
21
+
22
+ // Token definitions live here and nowhere else; every rule below uses var().
23
+ const STYLE = `
24
+ :root {
25
+ --x-bg: #fbfbfd; --x-surface: #ffffff; --x-border: #e3e3ea;
26
+ --x-text: #1b1b1f; --x-muted: #6b6b76; --x-danger: #b3261e; --x-accent: #2f4fd8;
27
+ --x-code-bg: #f3f3f7;
28
+ }
29
+ @media (prefers-color-scheme: dark) {
30
+ :root {
31
+ --x-bg: #111115; --x-surface: #1a1a20; --x-border: #2e2e38;
32
+ --x-text: #ececf1; --x-muted: #9b9baa; --x-danger: #ff8a80; --x-accent: #9db2ff;
33
+ --x-code-bg: #22222b;
34
+ }
35
+ }
36
+ * { box-sizing: border-box; }
37
+ body {
38
+ margin: 0; padding: 2rem; background: var(--x-bg); color: var(--x-text);
39
+ font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
40
+ }
41
+ main { max-width: 60rem; margin: 0 auto; }
42
+ .card {
43
+ background: var(--x-surface); border: 1px solid var(--x-border);
44
+ border-radius: 8px; padding: 1.25rem 1.5rem; margin-bottom: 1rem;
45
+ }
46
+ h1 { font-size: 1.1rem; margin: 0 0 .25rem; color: var(--x-danger); }
47
+ h2 { font-size: .8rem; margin: 0 0 .5rem; color: var(--x-muted);
48
+ text-transform: uppercase; letter-spacing: .08em; }
49
+ dl { display: grid; grid-template-columns: 5rem 1fr; gap: .35rem 1rem; margin: 0; }
50
+ dt { color: var(--x-muted); }
51
+ dd { margin: 0; overflow-wrap: anywhere; }
52
+ pre { background: var(--x-code-bg); border-radius: 6px; padding: .75rem;
53
+ margin: 0; overflow-x: auto; }
54
+ a { color: var(--x-accent); }
55
+ .title { color: var(--x-muted); font-weight: normal; }
56
+ `;
57
+
58
+ export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string => {
59
+ const facts = factsOf(error);
60
+ const problem = toProblem(error, {
61
+ ...(meta.requestId === undefined ? {} : { requestId: meta.requestId }),
62
+ ...(meta.path === undefined ? {} : { instance: meta.path }),
63
+ });
64
+ const where = `${meta.method ?? ''} ${meta.path ?? ''}`.trim();
65
+ return `<style>${STYLE}</style>
66
+ <main>
67
+ <section class="card">
68
+ <h1>${escapeHtml(facts.code)} <span class="title">${escapeHtml(facts.title)}</span></h1>
69
+ <dl>
70
+ <dt>cause</dt><dd>${escapeHtml(facts.cause)}</dd>
71
+ <dt>fix</dt><dd><code>${escapeHtml(facts.fix)}</code></dd>
72
+ <dt>docs</dt><dd><a href="${escapeHtml(facts.docs)}">${escapeHtml(facts.docs)}</a></dd>
73
+ ${where === '' ? '' : `<dt>route</dt><dd>${escapeHtml(where)}</dd>`}
74
+ ${meta.requestId === undefined ? '' : `<dt>request</dt><dd>${escapeHtml(meta.requestId)}</dd>`}
75
+ ${
76
+ meta.buildId === undefined || meta.buildId === null
77
+ ? ''
78
+ : `<dt>build</dt><dd>${escapeHtml(meta.buildId)}</dd>`
79
+ }
80
+ </dl>
81
+ </section>
82
+ <section class="card">
83
+ <h2>terminal</h2>
84
+ <pre>${escapeHtml(renderErrorLines(error))}</pre>
85
+ </section>
86
+ ${
87
+ facts.stack === undefined
88
+ ? ''
89
+ : `<section class="card"><h2>stack</h2><pre>${escapeHtml(facts.stack)}</pre></section>`
90
+ }
91
+ <section class="card">
92
+ <h2>json</h2>
93
+ <pre>${escapeHtml(JSON.stringify(problem, null, 2))}</pre>
94
+ </section>
95
+ </main>`;
96
+ };
97
+
98
+ export const overlayResponse = (error: unknown, meta: OverlayMeta = {}): Response =>
99
+ html(renderOverlay(error, meta), {
100
+ status: factsOf(error).status,
101
+ headers: { 'cache-control': 'no-store' },
102
+ });
103
+
104
+ /** Dev only, and only when the caller is a browser: agents and RPC want problem+json. */
105
+ export const wantsOverlay = (request: Request): boolean =>
106
+ (request.headers.get('accept') ?? '').includes('text/html');