@ultimat3/core 24.0.0 → 25.0.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.
Files changed (76) hide show
  1. package/CLAUDE.md +24 -27
  2. package/README.md +71 -34
  3. package/package.json +4 -7
  4. package/src/actor.ts +9 -0
  5. package/src/address-class.ts +40 -4
  6. package/src/assert.ts +9 -5
  7. package/src/audit.ts +144 -0
  8. package/src/aws-sigv4.ts +275 -0
  9. package/src/backoff.ts +16 -0
  10. package/src/bunfs.ts +17 -0
  11. package/src/client-dispatch.ts +24 -3
  12. package/src/client-flight.ts +68 -13
  13. package/src/client-problem.ts +62 -6
  14. package/src/client-retry-after.ts +47 -0
  15. package/src/client-transport.ts +3 -1
  16. package/src/client-wire.ts +27 -3
  17. package/src/config-ai.ts +32 -0
  18. package/src/config-defaults.ts +18 -11
  19. package/src/config-fixes.ts +0 -10
  20. package/src/config-health.ts +9 -2
  21. package/src/config-jobs.ts +51 -0
  22. package/src/config-keys.ts +170 -0
  23. package/src/config-mail.ts +73 -0
  24. package/src/config-merge.ts +1 -1
  25. package/src/config-navigation.ts +1 -25
  26. package/src/config-pwa.ts +42 -5
  27. package/src/config-removed.ts +131 -0
  28. package/src/config-shape.ts +0 -33
  29. package/src/config.ts +71 -94
  30. package/src/context.ts +16 -12
  31. package/src/cookie.ts +267 -3
  32. package/src/core-error-codes.ts +2 -0
  33. package/src/cursor-page.ts +41 -0
  34. package/src/cursor.ts +23 -5
  35. package/src/deprecation.ts +77 -0
  36. package/src/dev-secrets.ts +18 -7
  37. package/src/drain-deadline.ts +43 -0
  38. package/src/env-example.ts +9 -29
  39. package/src/errors.ts +18 -9
  40. package/src/exports/error-contract.ts +0 -1
  41. package/src/exports/observability.ts +1 -1
  42. package/src/exports/secrets.ts +3 -0
  43. package/src/finite-option.ts +1 -1
  44. package/src/flight-gate.ts +29 -14
  45. package/src/generation-fence.ts +1 -1
  46. package/src/ids.ts +7 -7
  47. package/src/image/canvas.ts +76 -5
  48. package/src/image/pipeline.ts +17 -5
  49. package/src/image/raster.ts +24 -1
  50. package/src/index.ts +89 -35
  51. package/src/iso-date.ts +1 -1
  52. package/src/lifecycle-errors.ts +1 -1
  53. package/src/lifecycle-readiness.ts +60 -2
  54. package/src/lifecycle-signals.ts +27 -2
  55. package/src/lifecycle-types.ts +96 -0
  56. package/src/lifecycle.ts +44 -133
  57. package/src/locale-direction.ts +1 -1
  58. package/src/logger.ts +16 -7
  59. package/src/mcp-exposure.ts +70 -8
  60. package/src/measurement-actor.ts +16 -1
  61. package/src/metric-errors.ts +32 -0
  62. package/src/metric-registry.ts +151 -0
  63. package/src/metric-series.ts +94 -0
  64. package/src/metrics.ts +9 -255
  65. package/src/page.ts +4 -2
  66. package/src/registrar.ts +1 -0
  67. package/src/retry.ts +25 -5
  68. package/src/secrets-key-file.ts +139 -0
  69. package/src/secrets-store.ts +32 -16
  70. package/src/service.ts +5 -5
  71. package/src/single-flight.ts +1 -1
  72. package/src/telemetry.ts +1 -1
  73. package/src/theme-storage.ts +12 -0
  74. package/src/type-pins.ts +51 -1
  75. package/src/image/fixtures.ts +0 -263
  76. package/src/time-zone-name.ts +0 -14
package/src/audit.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The audit seam every primitive shares: the record an audited attempt produces, the sink it goes
3
+ * to, and the one installed-sink slot. Here at tier 0 so `@ultimat3/action` and `@ultimat3/query`
4
+ * — siblings that cannot import each other — write one contract into one sink. What the ROW says
5
+ * (retention, hash chain, subject index, "who" under impersonation) is the app's; none of it is here.
6
+ */
7
+
8
+ import type { Ctx } from './context';
9
+
10
+ /**
11
+ * The three things that can happen to an attempt. Deliberately the same three words
12
+ * `@ultimat3/admin`'s `AuditEntry` uses — that package is tier 5, so the vocabulary is shared by
13
+ * name and not by import. `denied` is an authz refusal, `failed` is everything else that threw,
14
+ * including an input that never parsed.
15
+ */
16
+ export type AuditOutcome = 'allowed' | 'denied' | 'failed';
17
+
18
+ /** Which primitive acted — the value of the `ultimate.primitive` span attribute, too. */
19
+ export type AuditPrimitive = 'action' | 'query';
20
+
21
+ /**
22
+ * Which projection ran the attempt: a price change over `http` and one over `mcp` are not the same
23
+ * event. The union of the action surfaces (`server`, `http`, `mcp`, `job`) and the read surfaces
24
+ * (`server`, `http`, `mcp`, `live`) — one sink receives both.
25
+ */
26
+ export type AuditSurface = 'server' | 'http' | 'mcp' | 'job' | 'live';
27
+
28
+ /** Why a non-`allowed` attempt ended. The framework classifies; it never renders. */
29
+ export interface AuditFailure {
30
+ /** The `X_*` code when an `UltimateError` ended it; `null` for anything else that threw. */
31
+ readonly code: string | null;
32
+ /** The thrown value, verbatim — its stack is the thing worth reading. */
33
+ readonly error: unknown;
34
+ }
35
+
36
+ /**
37
+ * One attempt, as the framework observed it. Every field is something the primitive's one path
38
+ * already holds; nothing on it is a guess about the business.
39
+ *
40
+ * A result is deliberately absent — an action's return value and a read's rows alike. Shipping it
41
+ * would be the framework deciding the row carries an after-image (for a read: a copy of every row
42
+ * an auditor's table then has to protect as carefully as the source). `input` is present for the
43
+ * opposite reason: on a `denied` record nothing ran, so nothing in app code can recover what was
44
+ * attempted, and that is the record an auditor actually wants.
45
+ */
46
+ export interface AuditRecord {
47
+ /**
48
+ * When the attempt began, from `ctx.now()` — never `new Date()`. An instant, not a rendering:
49
+ * serialising it is the app's decision, and it is the app that knows the zone it is shown in.
50
+ */
51
+ readonly at: Date;
52
+ /**
53
+ * The registered export name of the primitive that acted. A mutator carries its action half's.
54
+ * Required since 25.0.0, which also dropped the `action` alias this field replaced: one name,
55
+ * one spelling (plan 101, M9). `@ultimat3/action`'s durable sink still files it in the
56
+ * `x_audit.action` column — a column rename is a data migration, and the value is the same.
57
+ */
58
+ readonly name: string;
59
+ /** `'action'` (a mutator included — see `mutator`) or `'query'`. A read and a write are not one event. */
60
+ readonly primitive: AuditPrimitive;
61
+ /** True when `mutator()` built it. Always false for a query. */
62
+ readonly mutator: boolean;
63
+ readonly surface: AuditSurface;
64
+ /**
65
+ * The context the attempt ran in — actor, `requestId`, `traceId`, locale, and the service bag a
66
+ * sink needs to write a row at all. Carried whole, because choosing WHICH context facts a row
67
+ * keeps is precisely the convention four apps modelled four ways.
68
+ *
69
+ * **A sink that PERSISTS must project it** (`@ultimat3/action`'s `postgresAuditSink` is the
70
+ * shipped allow-list): `ctxOf` spreads every installed service onto this object and an
71
+ * HTTP surface's value carries the caller's `Authorization` and `Cookie`.
72
+ */
73
+ readonly ctx: Ctx;
74
+ /**
75
+ * The PARSED input, or `undefined` when the parse is what failed. Never the raw payload: an
76
+ * unvalidated body is attacker-shaped. Unredacted — a persisting sink redacts through
77
+ * `auditableInput`, the walk core's `isRedactedKey` table and `Secret` decide.
78
+ */
79
+ readonly input: unknown;
80
+ /** The namespaced key an `idempotent` action was retried under, or `null`. Always `null` for a read. */
81
+ readonly idempotencyKey: string | null;
82
+ /**
83
+ * True when the answer came from an earlier settled execution rather than this call's own: an
84
+ * idempotent action's stored response, or a read served by the request memo or a cache tier.
85
+ * A call, not a write — and, for a read, still a sighting.
86
+ */
87
+ readonly replayed: boolean;
88
+ readonly outcome: AuditOutcome;
89
+ /** Present exactly when `outcome !== 'allowed'`. */
90
+ readonly failure: AuditFailure | null;
91
+ }
92
+
93
+ /**
94
+ * Every field of `AuditRecord`, as data — the pin both primitives' tests hold their records to, so
95
+ * a field one of them forgets to write is a red test in that package and not a hole in a table.
96
+ * Spelled as a `Record` over `keyof AuditRecord` so a field missing here, or one that is not on the
97
+ * record, is a typecheck error in this file.
98
+ */
99
+ const FIELDS: Readonly<Record<keyof AuditRecord, true>> = {
100
+ at: true,
101
+ name: true,
102
+ primitive: true,
103
+ mutator: true,
104
+ surface: true,
105
+ ctx: true,
106
+ input: true,
107
+ idempotencyKey: true,
108
+ replayed: true,
109
+ outcome: true,
110
+ failure: true,
111
+ };
112
+
113
+ export const AUDIT_RECORD_FIELDS: readonly (keyof AuditRecord)[] = Object.freeze(
114
+ Object.keys(FIELDS) as (keyof AuditRecord)[],
115
+ );
116
+
117
+ /**
118
+ * Where a record goes: a table, an append-only hash chain, an OTel log, a queue — the app's.
119
+ * `@ultimat3/admin`'s `AuditSink` is the same noun one tier up over its own fixed entry type.
120
+ * A sink that throws is never swallowed; each primitive's audit gate says which failure wins.
121
+ */
122
+ export interface AuditSink {
123
+ write(record: AuditRecord): Promise<void> | void;
124
+ }
125
+
126
+ /**
127
+ * No default. A logger-backed default would satisfy `audit: true` with a line nobody stores, which
128
+ * is the silent pass this seam exists to remove: an audited primitive with no sink installed is
129
+ * refused before it reads its input (`X_AUDIT_SINK_MISSING`, `X_QUERY_AUDIT_SINK_MISSING`).
130
+ */
131
+ let installed: AuditSink | null = null;
132
+
133
+ export function setAuditSink(sink: AuditSink): void {
134
+ installed = sink;
135
+ }
136
+
137
+ export function getAuditSink(): AuditSink | null {
138
+ return installed;
139
+ }
140
+
141
+ /** Test seam: back to "nothing installed", which restoring a literal cannot express. */
142
+ export function resetAuditSink(): void {
143
+ installed = null;
144
+ }
@@ -0,0 +1,275 @@
1
+ // Single responsibility: AWS Signature Version 4 over one HTTP request — the framework's ONE SigV4
2
+ // implementation, shared by `@ultimat3/storage`'s s3 disk and `@ultimat3/mail`'s SES driver, which
3
+ // sit at tiers that cannot import each other. Web Crypto only (HMAC-SHA256, SHA-256), no SDK, and
4
+ // the instant is an injected `Clock`, so a test signs against AWS's own known answers.
5
+
6
+ import { assert } from './assert';
7
+ import { type Clock, systemClock } from './clock';
8
+ import { describeValue } from './error-render';
9
+
10
+ export interface AwsCredentials {
11
+ readonly accessKeyId: string;
12
+ readonly secretAccessKey: string;
13
+ /** STS / instance-role credentials: sent and signed as `x-amz-security-token`. */
14
+ readonly sessionToken?: string | undefined;
15
+ }
16
+
17
+ /** The literal S3 accepts in place of a body hash — a stream, or bytes too large to hash twice. */
18
+ export const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
19
+
20
+ /**
21
+ * What the signature says about the body. Absent is the EMPTY body. `{ body }` is hashed here;
22
+ * `{ sha256Hex }` is a hash the caller already took (a stream it hashed while buffering);
23
+ * `UNSIGNED_PAYLOAD` signs no body at all, which S3 accepts over TLS.
24
+ */
25
+ export type AwsPayload =
26
+ | { readonly body: Uint8Array | string }
27
+ | { readonly sha256Hex: string }
28
+ | typeof UNSIGNED_PAYLOAD;
29
+
30
+ export interface SignAwsRequestInput {
31
+ readonly method: string;
32
+ /** Absolute. The path and query are re-encoded strictly; the URL returned is the one signed. */
33
+ readonly url: string | URL;
34
+ /** Every header here is signed. `host` defaults to the URL's; `authorization`/`x-amz-date` are the signer's. */
35
+ readonly headers?: Readonly<Record<string, string>> | undefined;
36
+ readonly payload?: AwsPayload | undefined;
37
+ readonly credentials: AwsCredentials;
38
+ /** `us-east-1`, or `auto` for R2. */
39
+ readonly region: string;
40
+ /** `s3`, `ses`, `email`, … — the scope's service segment. */
41
+ readonly service: string;
42
+ /** Default `systemClock`. The signature is only valid within ~15 minutes of this instant. */
43
+ readonly clock?: Clock | undefined;
44
+ /**
45
+ * Encode each path segment twice. Default `service !== 's3'`: every AWS service but S3 signs the
46
+ * already-encoded path again; S3 signs the key encoded once.
47
+ */
48
+ readonly doubleEncodePath?: boolean | undefined;
49
+ /** Send and sign `x-amz-content-sha256`. Default `service === 's3'`, which requires it. */
50
+ readonly contentSha256Header?: boolean | undefined;
51
+ }
52
+
53
+ export interface SignedAwsRequest {
54
+ readonly method: string;
55
+ /** The URL to send: the input's, with the path and query in the encoding that was signed. */
56
+ readonly url: string;
57
+ /** Every header to send — lower-case names, `authorization` included. */
58
+ readonly headers: Readonly<Record<string, string>>;
59
+ /** The two intermediate strings, exposed so a provider's `SignatureDoesNotMatch` can be diffed. */
60
+ readonly canonicalRequest: string;
61
+ readonly stringToSign: string;
62
+ readonly signature: string;
63
+ }
64
+
65
+ const ALGORITHM = 'AWS4-HMAC-SHA256';
66
+ const SCOPE_SEGMENT = /^[a-z0-9-]{1,64}$/;
67
+ const METHOD = /^[A-Z]{1,16}$/;
68
+ /** Headers the signer writes itself; a caller's copy would sign a value the request does not carry. */
69
+ const SIGNER_OWNED: ReadonlySet<string> = new Set([
70
+ 'authorization',
71
+ 'x-amz-date',
72
+ 'x-amz-security-token',
73
+ 'x-amz-content-sha256',
74
+ ]);
75
+
76
+ const encoder = new TextEncoder();
77
+
78
+ const toHex = (bytes: ArrayBuffer | Uint8Array): string =>
79
+ Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, '0')).join('');
80
+
81
+ /** Web Crypto takes an ArrayBuffer-backed view; only a view over shared memory is copied. */
82
+ const ownedBytes = (bytes: Uint8Array): Uint8Array<ArrayBuffer> =>
83
+ bytes.buffer instanceof ArrayBuffer
84
+ ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)
85
+ : new Uint8Array(bytes);
86
+
87
+ async function sha256Hex(data: Uint8Array | string): Promise<string> {
88
+ const bytes = typeof data === 'string' ? encoder.encode(data) : ownedBytes(data);
89
+ return toHex(await crypto.subtle.digest('SHA-256', bytes));
90
+ }
91
+
92
+ async function hmac(key: Uint8Array<ArrayBuffer>, data: string): Promise<Uint8Array<ArrayBuffer>> {
93
+ const imported = await crypto.subtle.importKey(
94
+ 'raw',
95
+ key,
96
+ { name: 'HMAC', hash: 'SHA-256' },
97
+ false,
98
+ ['sign'],
99
+ );
100
+ return new Uint8Array(await crypto.subtle.sign('HMAC', imported, encoder.encode(data)));
101
+ }
102
+
103
+ /** RFC 3986 unreserved bytes stay; everything else is `%XX`, upper-case — SigV4's `UriEncode`. */
104
+ function uriEncode(value: string): string {
105
+ let encoded: string;
106
+ try {
107
+ encoded = encodeURIComponent(value);
108
+ } catch {
109
+ // A lone surrogate: there are no UTF-8 bytes to sign.
110
+ assert(
111
+ false,
112
+ 'an AWS request URL holds a lone UTF-16 surrogate, which has no UTF-8 encoding',
113
+ 'build the key from well-formed text: value.toWellFormed()',
114
+ );
115
+ }
116
+ return encoded.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
117
+ }
118
+
119
+ function uriDecode(value: string, where: string): string {
120
+ try {
121
+ return decodeURIComponent(value);
122
+ } catch {
123
+ assert(
124
+ false,
125
+ `an AWS request ${where} holds a malformed percent-escape (${describeValue(value)}), so no canonical form exists to sign`,
126
+ 'encode each segment with encodeURIComponent() before building the URL — a literal % is %25',
127
+ );
128
+ }
129
+ }
130
+
131
+ function canonicalPath(pathname: string, doubleEncode: boolean): string {
132
+ if (pathname === '') return '/';
133
+ return pathname
134
+ .split('/')
135
+ .map((segment) => {
136
+ const once = uriEncode(uriDecode(segment, 'path'));
137
+ return doubleEncode ? uriEncode(once) : once;
138
+ })
139
+ .join('/');
140
+ }
141
+
142
+ /** Pairs decoded then strictly re-encoded, sorted by key then value; a bare `key` is `key=`. */
143
+ function canonicalQuery(search: string): string {
144
+ const pairs: [string, string][] = [];
145
+ for (const part of search.replace(/^\?/, '').split('&')) {
146
+ if (part === '') continue;
147
+ const at = part.indexOf('=');
148
+ const key = at === -1 ? part : part.slice(0, at);
149
+ const value = at === -1 ? '' : part.slice(at + 1);
150
+ pairs.push([uriEncode(uriDecode(key, 'query')), uriEncode(uriDecode(value, 'query'))]);
151
+ }
152
+ pairs.sort(([ak, av], [bk, bv]) => (ak === bk ? (av < bv ? -1 : 1) : ak < bk ? -1 : 1));
153
+ return pairs.map(([key, value]) => `${key}=${value}`).join('&');
154
+ }
155
+
156
+ /** Lower-case names, trimmed values with inner whitespace runs collapsed, duplicates comma-joined. */
157
+ function canonicalHeaders(headers: Readonly<Record<string, string>>): Map<string, string> {
158
+ const merged = new Map<string, string>();
159
+ for (const [name, raw] of Object.entries(headers)) {
160
+ const key = name.trim().toLowerCase();
161
+ const value = raw.trim().replace(/\s+/g, ' ');
162
+ const prior = merged.get(key);
163
+ merged.set(key, prior === undefined ? value : `${prior},${value}`);
164
+ }
165
+ return new Map([...merged].sort(([a], [b]) => (a < b ? -1 : 1)));
166
+ }
167
+
168
+ function parseUrl(url: string | URL): URL {
169
+ if (url instanceof URL) return url;
170
+ assert(
171
+ URL.canParse(url),
172
+ `signAwsRequest was handed a url that is not absolute (${describeValue(url)})`,
173
+ "pass the whole endpoint URL: signAwsRequest({ url: 'https://<host>/<path>', … })",
174
+ );
175
+ return new URL(url);
176
+ }
177
+
178
+ function assertInput(input: SignAwsRequestInput): void {
179
+ const { accessKeyId, secretAccessKey } = input.credentials;
180
+ assert(
181
+ accessKeyId !== '' && secretAccessKey !== '',
182
+ 'signAwsRequest was handed an empty access key id or secret, so nothing can be signed',
183
+ 'set the access key id and secret (S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY, or the driver’s own env names), then re-run',
184
+ );
185
+ for (const [name, value] of [
186
+ ['region', input.region],
187
+ ['service', input.service],
188
+ ] as const) {
189
+ assert(
190
+ SCOPE_SEGMENT.test(value),
191
+ `signAwsRequest was handed a ${name} that cannot be a credential-scope segment (${describeValue(value)})`,
192
+ `pass a lower-case ${name} of letters, digits and hyphens — region: 'us-east-1', service: 's3'`,
193
+ );
194
+ }
195
+ assert(
196
+ METHOD.test(input.method),
197
+ `signAwsRequest was handed an HTTP method that is not upper-case letters (${describeValue(input.method)})`,
198
+ "pass the method as sent on the wire: method: 'PUT'",
199
+ );
200
+ }
201
+
202
+ async function payloadHash(payload: AwsPayload | undefined): Promise<string> {
203
+ if (payload === undefined) return sha256Hex('');
204
+ if (payload === UNSIGNED_PAYLOAD) return UNSIGNED_PAYLOAD;
205
+ if ('sha256Hex' in payload) return payload.sha256Hex;
206
+ return sha256Hex(payload.body);
207
+ }
208
+
209
+ /** `20150830T123600Z` — the ISO instant with its separators and fraction removed. Always UTC. */
210
+ const amzDate = (clock: Clock): string =>
211
+ clock
212
+ .now()
213
+ .toISOString()
214
+ .replace(/\.\d{3}Z$/, 'Z')
215
+ .replace(/[-:]/g, '');
216
+
217
+ /**
218
+ * Sign one request with SigV4 in the `Authorization` header. Returns everything to send — the
219
+ * re-encoded URL and every header — plus the canonical request and string-to-sign for debugging.
220
+ * A malformed input (an empty credential, a scope segment with a `/`, a broken escape) is a coded
221
+ * `X_INVARIANT` refusal before anything is hashed.
222
+ */
223
+ export async function signAwsRequest(input: SignAwsRequestInput): Promise<SignedAwsRequest> {
224
+ assertInput(input);
225
+ const url = parseUrl(input.url);
226
+ const service = input.service;
227
+ const path = canonicalPath(url.pathname, input.doubleEncodePath ?? service !== 's3');
228
+ const query = canonicalQuery(url.search);
229
+ const date = amzDate(input.clock ?? systemClock);
230
+ const hash = await payloadHash(input.payload);
231
+
232
+ const caller: Record<string, string> = {};
233
+ for (const [name, value] of Object.entries(input.headers ?? {})) {
234
+ if (!SIGNER_OWNED.has(name.trim().toLowerCase())) caller[name] = value;
235
+ }
236
+ const hasHost = Object.keys(caller).some((name) => name.trim().toLowerCase() === 'host');
237
+ const token = input.credentials.sessionToken;
238
+ const headers = canonicalHeaders({
239
+ ...caller,
240
+ ...(hasHost ? {} : { host: url.host }),
241
+ 'x-amz-date': date,
242
+ ...(token === undefined || token === '' ? {} : { 'x-amz-security-token': token }),
243
+ ...((input.contentSha256Header ?? service === 's3') ? { 'x-amz-content-sha256': hash } : {}),
244
+ });
245
+ const signedHeaders = [...headers.keys()].join(';');
246
+ const canonicalRequest = [
247
+ input.method,
248
+ path,
249
+ query,
250
+ ...[...headers].map(([name, value]) => `${name}:${value}`),
251
+ '',
252
+ signedHeaders,
253
+ hash,
254
+ ].join('\n');
255
+
256
+ const day = date.slice(0, 8);
257
+ const scope = `${day}/${input.region}/${service}/aws4_request`;
258
+ const stringToSign = [ALGORITHM, date, scope, await sha256Hex(canonicalRequest)].join('\n');
259
+ let key = encoder.encode(`AWS4${input.credentials.secretAccessKey}`);
260
+ for (const part of [day, input.region, service, 'aws4_request']) key = await hmac(key, part);
261
+ const signature = toHex(await hmac(key, stringToSign));
262
+
263
+ const sent = Object.fromEntries(headers);
264
+ sent['authorization'] =
265
+ `${ALGORITHM} Credential=${input.credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
266
+ const singlePath = canonicalPath(url.pathname, false);
267
+ return {
268
+ method: input.method,
269
+ url: `${url.origin}${singlePath}${query === '' ? '' : `?${query}`}`,
270
+ headers: sent,
271
+ canonicalRequest,
272
+ stringToSign,
273
+ signature,
274
+ };
275
+ }
package/src/backoff.ts CHANGED
@@ -70,3 +70,19 @@ export function backoffDelay(options: BackoffOptions): number {
70
70
  if (options.jitter === 'equal') return Math.round(capped / 2 + (capped / 2) * roll());
71
71
  return Math.round(capped);
72
72
  }
73
+
74
+ /**
75
+ * The wait after a responder STATED one: the stated delay as a FLOOR, plus a spread in
76
+ * `[0, min(wait / 2, cap))`. The ONE rule for a named delay — `retryDecision`'s `retry-after`
77
+ * path and `@ultimat3/jobs`' rate-limit deferral and webhook throttle all take it. A floor because
78
+ * waking before the responder said is refused again; a spread because a shedding server tells a
79
+ * whole burst `Retry-After: 1`, and a burst that waits exactly that replays in lockstep every
80
+ * second, as does every delivery handed one HTTP-date. `cap` bounds only the spread.
81
+ */
82
+ export function jitterStatedDelay(
83
+ waitMs: number,
84
+ capMs: number,
85
+ random: Random = Math.random,
86
+ ): number {
87
+ return waitMs + Math.floor(random() * Math.min(waitMs / 2, capMs));
88
+ }
package/src/bunfs.ts ADDED
@@ -0,0 +1,17 @@
1
+ // Whether a directory is inside a `bun build --compile` executable's virtual filesystem. One answer
2
+ // for every platform, because Bun spells its root two ways: `/$bunfs/` on POSIX and `B:\~BUN\`
3
+ // (public form `B:/~BUN/`) on Windows — `BASE_PATH` / `BASE_PUBLIC_PATH` in Bun's
4
+ // `StandaloneModuleGraph`, as of Bun 1.4.2. An entry that tested only the first resolved its app root
5
+ // INSIDE the bundle on Windows, where no source exists, and every registry booted empty.
6
+
7
+ /** Each prefix is followed by a separator or nothing, so `/$bunfsx` or `B:\~BUNDLE` never match. */
8
+ const COMPILED_ROOT = /^(?:\/\$bunfs|[Bb]:[\\/]~BUN)(?:[\\/]|$)/;
9
+
10
+ /**
11
+ * `true` when `dir` — normally `import.meta.dir` — is a path inside a compiled executable. Such a
12
+ * path holds the entry's bundled imports and nothing else, so an app reads its root from the
13
+ * directory it is started in instead.
14
+ */
15
+ export function isCompiledBundle(dir: string): boolean {
16
+ return COMPILED_ROOT.test(dir);
17
+ }
@@ -6,9 +6,11 @@
6
6
 
7
7
  import type { ClientFlight, ClientRetry } from './client-flight';
8
8
  import { problemError, transportFailed } from './client-problem';
9
+ import { retryAfterSecondsOf } from './client-retry-after';
9
10
  import { onRescope } from './client-scope';
10
11
  import { scopeChanged } from './client-scope-error';
11
12
  import type { UltimateError } from './errors';
13
+ import { CLIENT_BUILD_META } from './page-meta';
12
14
  import type { RecordEnvelope } from './record-envelope';
13
15
  import { RECORDS_HEADER } from './record-envelope';
14
16
  import { outboundSlot, pageClient } from './record-sink';
@@ -18,6 +20,14 @@ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
18
20
  /** The header `@ultimat3/action`'s server reads to replay rather than re-run a write. */
19
21
  export const IDEMPOTENCY_HEADER = 'idempotency-key';
20
22
 
23
+ /**
24
+ * The build a client was served, on the request, and the build that answered, on the response.
25
+ * `@ultimat3/http` reads it into `ctx.clientBuildId` and stamps it back; `@ultimat3/action`'s typed
26
+ * client and `@ultimat3/pwa`'s service worker send it; `@ultimat3/render` writes it. The document
27
+ * meta's spelling by construction, so a script reading either reads one name. Not configurable.
28
+ */
29
+ export const BUILD_ID_HEADER: typeof CLIENT_BUILD_META = CLIENT_BUILD_META;
30
+
21
31
  export interface TransportRequest {
22
32
  readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
23
33
  readonly url: string;
@@ -46,8 +56,14 @@ export interface TransportRequest {
46
56
  readonly onEnvelope?: ((envelope: RecordEnvelope) => void) | undefined;
47
57
  /** Sees every response before its body is read — a header check may throw its own refusal. */
48
58
  readonly onResponse?: ((response: Response) => void | Promise<void>) | undefined;
49
- /** A non-2xx answer as the caller's own error; `undefined` falls back to the shared decode. */
50
- readonly decodeError?: ((status: number, text: string) => UltimateError | undefined) | undefined;
59
+ /**
60
+ * A non-2xx answer as the caller's own error; `undefined` falls back to the shared decode.
61
+ * `retryAfterSeconds` is the delay the answer's `Retry-After` named (`retryAfterSecondsOf`),
62
+ * for a decoder to classify with `retryForStatus` and carry as `meta.retryAfterSeconds`.
63
+ */
64
+ readonly decodeError?:
65
+ | ((status: number, text: string, retryAfterSeconds?: number) => UltimateError | undefined)
66
+ | undefined;
51
67
  /** Default `globalThis.fetch`, read at call time — never captured at module scope. */
52
68
  readonly fetchImpl?: FetchLike | undefined;
53
69
  }
@@ -79,8 +95,13 @@ export async function dispatch(
79
95
  // Read as TEXT once: a body is a single-use stream, and the failure path wants it too.
80
96
  const text = response.status === 204 ? '' : await onTheWire(req, read, () => response.text());
81
97
  if (!response.ok) {
98
+ const stated = retryAfterSecondsOf(
99
+ response.headers.get('retry-after'),
100
+ response.headers.get('date'),
101
+ );
82
102
  throw (
83
- req.decodeError?.(response.status, text) ?? problemError(response.status, text, req.url)
103
+ req.decodeError?.(response.status, text, stated) ??
104
+ problemError(response.status, text, req.url, stated)
84
105
  );
85
106
  }
86
107
  return { text, enveloped: response.headers.get(RECORDS_HEADER) === '1' };
@@ -9,7 +9,7 @@
9
9
  * each; both re-export this one, so their public surface is unchanged and there is one file.
10
10
  *
11
11
  * Nothing here is imported by either package's `client.ts` at VALUE level. A caller that wants a
12
- * plain typed fetch never mentions `createClientFlight`, so this module and every module it
12
+ * plain typed fetch never mentions `clientFlight`, so this module and every module it
13
13
  * imports are shaken out of that caller's bundle — the 36 kB island problem is the reason it is
14
14
  * built this way, and `packages/{action,query}/src/client.ts` must keep naming `ClientFlight` as
15
15
  * an `import type`.
@@ -19,12 +19,12 @@ import type { Random } from './backoff';
19
19
  import { classifyThrown } from './error-retry';
20
20
  import { UltimateError } from './errors';
21
21
  import type { FlightGate, FlightGateLimits } from './flight-gate';
22
- import { createFlightGate } from './flight-gate';
23
- import { createFence } from './generation-fence';
22
+ import { flightGate } from './flight-gate';
23
+ import { generationFence } from './generation-fence';
24
24
  import type { RetryPolicy } from './retry';
25
25
  import { retry } from './retry';
26
26
  import type { Scheduler } from './single-flight';
27
- import { createSingleFlight } from './single-flight';
27
+ import { singleFlight } from './single-flight';
28
28
 
29
29
  /** Overrides for the shipped policy. `attempts: 1` — the default — means one dispatch, no retry. */
30
30
  export type ClientRetry = Partial<RetryPolicy>;
@@ -93,6 +93,11 @@ export interface FlightPlan<T> {
93
93
  readonly abortable: boolean;
94
94
  /** One attempt. `signal` is the flight's own — a caller's signal never reaches here. */
95
95
  run(signal: AbortSignal | undefined, attempt: number): Promise<T>;
96
+ /**
97
+ * The CALLER's signal, read for one thing: an abort during the wait between attempts settles the
98
+ * call at once, a write's included. `run` gets the flight's own signal as before.
99
+ */
100
+ readonly signal?: AbortSignal | undefined;
96
101
  /** Overrides the flight's policy for this one call. */
97
102
  readonly retry?: ClientRetry | undefined;
98
103
  /**
@@ -153,9 +158,9 @@ const defaultSchedule: Scheduler = (fn, ms) => {
153
158
  };
154
159
  };
155
160
 
156
- export function createClientFlight(options: ClientFlightOptions = {}): ClientFlight {
161
+ export function clientFlight(options: ClientFlightOptions = {}): ClientFlight {
157
162
  const subject = options.subject ?? 'a typed client call';
158
- const fence = createFence(subject);
163
+ const fence = generationFence(subject);
159
164
  const schedule = options.schedule ?? defaultSchedule;
160
165
  const sleep =
161
166
  options.sleep ??
@@ -167,11 +172,60 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
167
172
  const transientFor = (plan: FlightPlan<unknown>): ((error: unknown) => boolean) =>
168
173
  plan.classified === true && options.transient === undefined ? isDeclaredTransient : transient;
169
174
  const deadlineMs = options.deadlineMs;
170
- const flights = createSingleFlight({ deadlineMs, schedule });
175
+ const flights = singleFlight({ deadlineMs, schedule });
171
176
  const gate: FlightGate | undefined =
172
- options.limit === undefined ? undefined : createFlightGate(options.limit, { subject });
177
+ options.limit === undefined ? undefined : flightGate(options.limit, { subject });
173
178
  const live = new Set<AbortController>();
174
179
 
180
+ /**
181
+ * The wait between attempts, raced against the flight's signal (`bump()`, the deadline) and the
182
+ * caller's. Uncancellable, a bump at 50 ms settled `X_SUPERSEDED` only when a one-second wait
183
+ * ran out; now it settles at once, with the abort's own reason, and the timer is let go.
184
+ */
185
+ const waitFor = (ms: number, signals: readonly (AbortSignal | undefined)[]): Promise<void> => {
186
+ const live = signals.filter((one): one is AbortSignal => one !== undefined);
187
+ const fired = live.find((one) => one.aborted);
188
+ if (fired !== undefined) return Promise.reject(fired.reason);
189
+ if (live.length === 0) return sleep(ms);
190
+ return new Promise<void>((resolve, reject) => {
191
+ let cancel: (() => void) | undefined;
192
+ const stop = (): void => {
193
+ for (const one of live) one.removeEventListener('abort', onAbort);
194
+ };
195
+ const onAbort = (event: Event): void => {
196
+ stop();
197
+ cancel?.();
198
+ reject((event.target as AbortSignal).reason);
199
+ };
200
+ const done = (): void => {
201
+ stop();
202
+ resolve();
203
+ };
204
+ for (const one of live) one.addEventListener('abort', onAbort, { once: true });
205
+ // The flight's own scheduler is cancellable, so an abandoned wait leaves no timer behind; an
206
+ // injected `sleep` is a test's, raced and simply never awaited again.
207
+ if (options.sleep === undefined) cancel = schedule(done, ms);
208
+ else
209
+ options.sleep(ms).then(done, (error: unknown) => {
210
+ stop();
211
+ reject(error);
212
+ });
213
+ });
214
+ };
215
+
216
+ /**
217
+ * One attempt holds a gate slot; the WAIT between attempts does not. Held across the sleep, a
218
+ * call backing off from a 503 kept the ceiling's only slot and a second call was refused
219
+ * `X_FLIGHT_GATE_OVERLOADED` for work nobody was doing. Re-acquired for the next attempt.
220
+ */
221
+ const once = <T>(plan: FlightPlan<T>, signal: AbortSignal | undefined, count: number) => {
222
+ if (gate === undefined) return plan.run(signal, count);
223
+ // The queued wait for a slot ends on the same two signals the wait between attempts does.
224
+ const signals = [signal, plan.signal].filter((one): one is AbortSignal => one !== undefined);
225
+ const cancel = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
226
+ return gate.run(() => plan.run(signal, count), cancel);
227
+ };
228
+
175
229
  const attempt = async <T>(plan: FlightPlan<T>, signal: AbortSignal | undefined): Promise<T> => {
176
230
  const policy: RetryPolicy = {
177
231
  ...DEFAULT_CLIENT_RETRY,
@@ -188,7 +242,7 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
188
242
  const answer = await retry<T | typeof STOPPED>(
189
243
  async (count) => {
190
244
  try {
191
- return await plan.run(signal, count);
245
+ return await once(plan, signal, count);
192
246
  } catch (error) {
193
247
  if (sendAgain(error)) throw error;
194
248
  stopped = { error };
@@ -197,7 +251,7 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
197
251
  },
198
252
  policy,
199
253
  {
200
- sleep,
254
+ sleep: (ms) => waitFor(ms, [signal, plan.signal]),
201
255
  ...(options.random === undefined ? {} : { random: options.random }),
202
256
  ...(options.now === undefined ? {} : { now: options.now }),
203
257
  },
@@ -272,8 +326,9 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
272
326
 
273
327
  run<T>(plan: FlightPlan<T>): Promise<T> {
274
328
  const issued = fence.generation();
275
- const work = (): Promise<T> =>
276
- gate === undefined ? dispatch(plan) : gate.run(() => dispatch(plan));
329
+ // The gate is taken per ATTEMPT, inside `dispatch` (`once`), so a wait between attempts holds
330
+ // no slot.
331
+ const work = (): Promise<T> => dispatch(plan);
277
332
  // The single flight sits OUTSIDE the gate: a joiner takes no slot, so dedup relieves the
278
333
  // ceiling instead of queueing behind it. Keyed by GENERATION too: `bump()` aborts the old
279
334
  // flights, but each holds its key until its rejection settles, and a same-key read issued
@@ -304,7 +359,7 @@ function deadlineExpired(subject: string, deadlineMs: number): UltimateError {
304
359
  return new UltimateError({
305
360
  code: 'X_TIMEOUT',
306
361
  cause: `${subject} was aborted after its client deadline of ${deadlineMs}ms`,
307
- fix: 'raise deadlineMs at the createClientFlight({ deadlineMs }) call site, or find what is answering that slowly with x doctor --json',
362
+ fix: 'raise deadlineMs at the clientFlight({ deadlineMs }) call site, or find what is answering that slowly with x doctor --json',
308
363
  meta: { subject, deadlineMs },
309
364
  });
310
365
  }