@ultimat3/core 29.4.0 → 30.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.
- package/CLAUDE.md +45 -81
- package/README.md +137 -10
- package/package.json +19 -3
- package/src/address-class.ts +53 -12
- package/src/aws-sigv4.ts +104 -8
- package/src/breaker.ts +177 -0
- package/src/client-dispatch.ts +76 -17
- package/src/client-flight.ts +4 -4
- package/src/client-paths-pins.ts +92 -0
- package/src/client-problem.ts +25 -1
- package/src/client-scope.ts +3 -1
- package/src/client-stream.ts +106 -0
- package/src/client-transport.ts +16 -2
- package/src/config-assets.ts +64 -0
- package/src/config-client-errors.ts +132 -0
- package/src/config-defaults.ts +18 -4
- package/src/config-failure-capture.ts +70 -0
- package/src/config-health.ts +8 -0
- package/src/config-islands.ts +1 -1
- package/src/config-keys.ts +2 -0
- package/src/config-site.ts +16 -0
- package/src/config-time.ts +40 -0
- package/src/config.ts +37 -26
- package/src/context.ts +92 -10
- package/src/core-error-codes.ts +19 -0
- package/src/cursor.ts +7 -3
- package/src/deadline-signal.ts +57 -0
- package/src/declared-errors-openapi.ts +70 -0
- package/src/declared-errors-scan.ts +79 -0
- package/src/declared-errors.ts +185 -0
- package/src/define-error.ts +200 -0
- package/src/deprecation.ts +14 -3
- package/src/drain-deadline.ts +22 -0
- package/src/env.ts +8 -3
- package/src/error-fingerprint.ts +95 -0
- package/src/error-reporter-chain.ts +45 -0
- package/src/error-reporter-sentry.ts +4 -0
- package/src/error-reporter.ts +14 -1
- package/src/error-status-declared.ts +82 -0
- package/src/exercise-errors.ts +41 -0
- package/src/exercise-run.ts +176 -0
- package/src/exports/error-contract.ts +7 -0
- package/src/exports/observability.ts +26 -0
- package/src/failure-capture-flush.ts +93 -0
- package/src/failure-capture-mask.ts +54 -0
- package/src/failure-capture.ts +205 -0
- package/src/fill-wire.ts +30 -0
- package/src/finite-option.ts +7 -0
- package/src/health-disclosure.ts +2 -2
- package/src/hmac.ts +47 -0
- package/src/index.ts +172 -9
- package/src/json-storable.ts +21 -0
- package/src/keyed-fingerprint.ts +3 -7
- package/src/lifecycle.ts +5 -1
- package/src/logger.ts +1 -1
- package/src/otlp-metric-exporter.ts +7 -1
- package/src/otlp-post.ts +147 -0
- package/src/otlp-span-exporter.ts +7 -1
- package/src/otlp.ts +4 -37
- package/src/outbound-attempt.ts +235 -0
- package/src/outbound-dial.ts +103 -0
- package/src/outbound-errors.ts +193 -0
- package/src/outbound-hops.ts +135 -0
- package/src/outbound-read.ts +103 -0
- package/src/outbound-subpath.ts +26 -0
- package/src/outbound-types.ts +108 -0
- package/src/outbound.ts +223 -0
- package/src/page-meta.ts +8 -0
- package/src/page.ts +9 -0
- package/src/record-envelope-openapi.ts +6 -0
- package/src/record-envelope.ts +33 -9
- package/src/record-sink.ts +13 -0
- package/src/redis-client.ts +78 -0
- package/src/registrar.ts +11 -0
- package/src/render-node.ts +13 -0
- package/src/render-nodes.ts +137 -0
- package/src/retry-loop.ts +197 -0
- package/src/retry.ts +47 -145
- package/src/roles.ts +2 -2
- package/src/route-path-merge.ts +55 -0
- package/src/route-path-template.ts +124 -0
- package/src/runtime-metrics.ts +16 -1
- package/src/same-origin.ts +18 -2
- package/src/session-lost.ts +115 -0
- package/src/single-flight.ts +75 -40
- package/src/source-mask.ts +36 -2
- package/src/tool-description.ts +29 -0
- package/src/visible-interval.ts +153 -0
- package/src/webhook-signature.ts +9 -11
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Single responsibility: one attempt's hops — dial the URL as `dial` says (screened and pinned, or
|
|
2
|
+
// as named), and under `redirect: 'follow'` take each 3xx by hand, re-screening the next address
|
|
3
|
+
// and dropping credentials at an origin change. The platform's own `follow` dials a hop itself,
|
|
4
|
+
// unscreened: measured, a default redirect from a screened name reached loopback.
|
|
5
|
+
|
|
6
|
+
import type { DialResolve } from './outbound-dial';
|
|
7
|
+
import { publicUrlRefusal, screenDial } from './outbound-dial';
|
|
8
|
+
import type { OutboundSite } from './outbound-errors';
|
|
9
|
+
import { outboundHostBlocked, outboundRedirected, outboundUnreachable } from './outbound-errors';
|
|
10
|
+
import type { OutboundBody, OutboundFetch, OutboundInit } from './outbound-types';
|
|
11
|
+
import { OUTBOUND_MAX_REDIRECTS } from './outbound-types';
|
|
12
|
+
|
|
13
|
+
export interface HopCall {
|
|
14
|
+
readonly site: OutboundSite;
|
|
15
|
+
readonly url: URL;
|
|
16
|
+
readonly method: string;
|
|
17
|
+
/** As the caller spelled them; every rule below compares names lower-cased. */
|
|
18
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
19
|
+
readonly body: OutboundBody | undefined;
|
|
20
|
+
readonly dial: 'named' | 'public';
|
|
21
|
+
readonly redirect: 'error' | 'follow';
|
|
22
|
+
readonly local: boolean;
|
|
23
|
+
readonly fetch: OutboundFetch;
|
|
24
|
+
readonly resolve: DialResolve | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a hop to ANOTHER origin may carry: an allow-list, never a strip-list. The fetch standard
|
|
29
|
+
* strips `authorization` and `cookie`, but a vendor's own credential header (`x-api-key`) went
|
|
30
|
+
* along to `http://169.254.169.254` in a probe — so everything else is dropped.
|
|
31
|
+
*/
|
|
32
|
+
const CROSS_ORIGIN_KEPT: ReadonlySet<string> = new Set(['accept', 'content-type', 'user-agent']);
|
|
33
|
+
|
|
34
|
+
const isRedirect = (status: number): boolean =>
|
|
35
|
+
status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A path as a cause, `meta` or a log line may show it: the first segment, then only how long the
|
|
39
|
+
* rest is. A webhook URL keeps its credential IN the path (`/services/T000/B000/XXXX`).
|
|
40
|
+
*/
|
|
41
|
+
export function redactedPath(pathname: string): string {
|
|
42
|
+
const [first = '', ...rest] = pathname.split('/').filter((segment) => segment !== '');
|
|
43
|
+
if (rest.length === 0) return `/${first}`;
|
|
44
|
+
return `/${first}/…(${String(rest.join('/').length)} chars)`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Where a URL points, for a cause or `meta`: origin and the redacted path, never the query. */
|
|
48
|
+
export const redactedUrl = (url: URL): string => `${url.origin}${redactedPath(url.pathname)}`;
|
|
49
|
+
|
|
50
|
+
interface Target {
|
|
51
|
+
readonly url: string;
|
|
52
|
+
readonly host?: string | undefined;
|
|
53
|
+
readonly serverName?: string | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The first hop as `dial` says; EVERY later hop screened as a public one, `'named'` included: the
|
|
58
|
+
* app named the first host, never the one a redirect points at.
|
|
59
|
+
*/
|
|
60
|
+
async function targetFor(call: HopCall, url: URL, hop: number): Promise<Target> {
|
|
61
|
+
if (call.dial === 'named' && hop === 0) return { url: url.href };
|
|
62
|
+
const refusal = publicUrlRefusal(url, call.local);
|
|
63
|
+
if (refusal !== undefined)
|
|
64
|
+
throw outboundHostBlocked(call.site, `${redactedUrl(url)}: ${refusal}`);
|
|
65
|
+
const verdict = await screenDial(url, { resolve: call.resolve });
|
|
66
|
+
if ('refused' in verdict) {
|
|
67
|
+
throw outboundHostBlocked(
|
|
68
|
+
call.site,
|
|
69
|
+
`${url.host} resolves to a ${verdict.refused} address, and a public dial opens only public ones`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if ('unresolved' in verdict)
|
|
73
|
+
throw outboundUnreachable(call.site, verdict.error ?? verdict.unresolved);
|
|
74
|
+
return verdict.pin;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const discard = async (response: Response): Promise<void> => {
|
|
78
|
+
try {
|
|
79
|
+
await response.body?.cancel();
|
|
80
|
+
} catch {
|
|
81
|
+
// A redirect's body is nothing the caller waits for; its cancel failing is no failure.
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** The response the last hop answered. A 3xx comes back as itself under `redirect: 'error'`. */
|
|
86
|
+
export async function sendHops(call: HopCall, signal: AbortSignal): Promise<Response> {
|
|
87
|
+
let url = call.url;
|
|
88
|
+
let method = call.method;
|
|
89
|
+
let body = call.body;
|
|
90
|
+
let headers: Record<string, string> = { ...call.headers };
|
|
91
|
+
for (let hop = 0; ; hop += 1) {
|
|
92
|
+
const target = await targetFor(call, url, hop);
|
|
93
|
+
const init: OutboundInit = {
|
|
94
|
+
method,
|
|
95
|
+
// Last: a pinned request is reached AS the name that was screened.
|
|
96
|
+
headers:
|
|
97
|
+
target.host === undefined
|
|
98
|
+
? headers
|
|
99
|
+
: {
|
|
100
|
+
...Object.fromEntries(
|
|
101
|
+
Object.entries(headers).filter(([name]) => name.toLowerCase() !== 'host'),
|
|
102
|
+
),
|
|
103
|
+
host: target.host,
|
|
104
|
+
},
|
|
105
|
+
...(body === undefined ? {} : { body }),
|
|
106
|
+
...(target.serverName === undefined ? {} : { tls: { serverName: target.serverName } }),
|
|
107
|
+
signal,
|
|
108
|
+
redirect: 'manual',
|
|
109
|
+
};
|
|
110
|
+
const response = await call.fetch(target.url, init);
|
|
111
|
+
const location = response.headers.get('location');
|
|
112
|
+
if (!isRedirect(response.status) || location === null || call.redirect === 'error') {
|
|
113
|
+
return response;
|
|
114
|
+
}
|
|
115
|
+
await discard(response);
|
|
116
|
+
const next = new URL(location, url);
|
|
117
|
+
if (hop + 1 > OUTBOUND_MAX_REDIRECTS) {
|
|
118
|
+
throw outboundRedirected(call.site, response.status, redactedUrl(next));
|
|
119
|
+
}
|
|
120
|
+
if (next.origin !== url.origin) {
|
|
121
|
+
headers = Object.fromEntries(
|
|
122
|
+
Object.entries(headers).filter(([name]) => CROSS_ORIGIN_KEPT.has(name.toLowerCase())),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
// 303 always, and 301/302 on a POST: the fetch standard re-sends as a GET with no body.
|
|
126
|
+
if (response.status === 303 || (method === 'POST' && response.status <= 302)) {
|
|
127
|
+
method = 'GET';
|
|
128
|
+
body = undefined;
|
|
129
|
+
headers = Object.fromEntries(
|
|
130
|
+
Object.entries(headers).filter(([name]) => name.toLowerCase() !== 'content-type'),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
url = next;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Single responsibility: an outbound response body read under two bounds a header-only deadline
|
|
2
|
+
// never gave — an IDLE watchdog (a body that stops sending is abandoned, measured: a stalled body
|
|
3
|
+
// under a header deadline was `still-reading-after-300ms`) and a byte cap, counted as it arrives
|
|
4
|
+
// so nothing past it is held. A bytes read is capped by `read-capped.ts`, the one counting reader.
|
|
5
|
+
|
|
6
|
+
import type { DeadlineTimer } from './deadline-signal';
|
|
7
|
+
|
|
8
|
+
export interface WatchedBodyOptions {
|
|
9
|
+
readonly idleMs: number;
|
|
10
|
+
/**
|
|
11
|
+
* The cap a STREAMED body is held to. Absent for a bytes read, which `read-capped.ts`'s
|
|
12
|
+
* `readWithinLimit` caps itself: the one counting reader, and it never holds an over-cap body.
|
|
13
|
+
*/
|
|
14
|
+
readonly maxBytes?: number | undefined;
|
|
15
|
+
readonly timer: DeadlineTimer;
|
|
16
|
+
/** Ends a pending read at once, as a `source` failure carrying the signal's reason. */
|
|
17
|
+
readonly signal?: AbortSignal | undefined;
|
|
18
|
+
/** The gap passed: abort the attempt, so the fetch lets its socket go. */
|
|
19
|
+
readonly onIdle: () => void;
|
|
20
|
+
/** The coded error a source failure, an idle gap or the cap becomes for whoever reads. */
|
|
21
|
+
readonly failure: (cause: WatchedFailure) => unknown;
|
|
22
|
+
/** Settled once, however the stream ends: read out, cancelled or failed. */
|
|
23
|
+
readonly onSettled: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type WatchedFailure =
|
|
27
|
+
| { readonly kind: 'idle' }
|
|
28
|
+
| { readonly kind: 'too-large' }
|
|
29
|
+
| { readonly kind: 'source'; readonly error: unknown };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `body` behind the watchdog and the cap. Pull-driven, so the timer runs only while a reader is
|
|
33
|
+
* WAITING on the vendor: a consumer slow to pull is its own business, never the vendor's idle gap.
|
|
34
|
+
* The gap and `signal` end a pending read THEMSELVES, rather than trusting the source to reject
|
|
35
|
+
* when its fetch is aborted: a body that ignores the abort must not hold the reader past its bound.
|
|
36
|
+
*/
|
|
37
|
+
export function watchedBody(
|
|
38
|
+
body: ReadableStream<Uint8Array>,
|
|
39
|
+
options: WatchedBodyOptions,
|
|
40
|
+
): ReadableStream<Uint8Array> {
|
|
41
|
+
const reader = body.getReader();
|
|
42
|
+
const { signal } = options;
|
|
43
|
+
let total = 0;
|
|
44
|
+
let settled = false;
|
|
45
|
+
const settle = (): void => {
|
|
46
|
+
if (settled) return;
|
|
47
|
+
settled = true;
|
|
48
|
+
options.onSettled();
|
|
49
|
+
};
|
|
50
|
+
const fail = (
|
|
51
|
+
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
52
|
+
cause: WatchedFailure,
|
|
53
|
+
): void => {
|
|
54
|
+
controller.error(options.failure(cause));
|
|
55
|
+
settle();
|
|
56
|
+
reader.cancel().catch(() => undefined);
|
|
57
|
+
};
|
|
58
|
+
return new ReadableStream<Uint8Array>({
|
|
59
|
+
pull: (controller) =>
|
|
60
|
+
new Promise<void>((done) => {
|
|
61
|
+
let finished = false;
|
|
62
|
+
const finish = (): boolean => {
|
|
63
|
+
if (finished) return false;
|
|
64
|
+
finished = true;
|
|
65
|
+
options.timer.clear(handle);
|
|
66
|
+
signal?.removeEventListener('abort', aborted);
|
|
67
|
+
return true;
|
|
68
|
+
};
|
|
69
|
+
const stop = (cause: WatchedFailure): void => {
|
|
70
|
+
if (!finish()) return;
|
|
71
|
+
if (cause.kind === 'idle') options.onIdle();
|
|
72
|
+
fail(controller, cause);
|
|
73
|
+
done();
|
|
74
|
+
};
|
|
75
|
+
const aborted = (): void => stop({ kind: 'source', error: signal?.reason });
|
|
76
|
+
const handle = options.timer.set(() => stop({ kind: 'idle' }), options.idleMs);
|
|
77
|
+
if (signal?.aborted === true) return aborted();
|
|
78
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
79
|
+
reader.read().then(
|
|
80
|
+
(next) => {
|
|
81
|
+
if (!finish()) return;
|
|
82
|
+
if (next.done) {
|
|
83
|
+
controller.close();
|
|
84
|
+
settle();
|
|
85
|
+
return done();
|
|
86
|
+
}
|
|
87
|
+
total += next.value.byteLength;
|
|
88
|
+
if (options.maxBytes !== undefined && total > options.maxBytes) {
|
|
89
|
+
fail(controller, { kind: 'too-large' });
|
|
90
|
+
} else {
|
|
91
|
+
controller.enqueue(next.value);
|
|
92
|
+
}
|
|
93
|
+
done();
|
|
94
|
+
},
|
|
95
|
+
(error: unknown) => stop({ kind: 'source', error }),
|
|
96
|
+
);
|
|
97
|
+
}),
|
|
98
|
+
async cancel(reason): Promise<void> {
|
|
99
|
+
settle();
|
|
100
|
+
await reader.cancel(reason).catch(() => undefined);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Single responsibility: the `@ultimat3/core/outbound` entry — the outbound call, the dial screen
|
|
2
|
+
// and the undoable deadline signal, and nothing else. A subpath, never the barrel: core is in every
|
|
3
|
+
// bundle graph and this reads `Bun.dns` (axiom 6). `outbound-subpath.test.ts` pins the split.
|
|
4
|
+
|
|
5
|
+
export type { DeadlineSignal, DeadlineTimer } from './deadline-signal';
|
|
6
|
+
export { deadlineSignal, platformDeadlineTimer } from './deadline-signal';
|
|
7
|
+
export { outbound, outboundResponse } from './outbound';
|
|
8
|
+
export type { DialPin, DialResolve, DialVerdict, ScreenDialOptions } from './outbound-dial';
|
|
9
|
+
export { publicUrlRefusal, resolveDialHost, screenDial } from './outbound-dial';
|
|
10
|
+
export type { OutboundSite, OutboundTimeoutPhase } from './outbound-errors';
|
|
11
|
+
export type {
|
|
12
|
+
OutboundAnswer,
|
|
13
|
+
OutboundBody,
|
|
14
|
+
OutboundDeps,
|
|
15
|
+
OutboundFetch,
|
|
16
|
+
OutboundInit,
|
|
17
|
+
OutboundMethod,
|
|
18
|
+
OutboundRequest,
|
|
19
|
+
OutboundStreamAnswer,
|
|
20
|
+
} from './outbound-types';
|
|
21
|
+
export {
|
|
22
|
+
OUTBOUND_IDLE_MS,
|
|
23
|
+
OUTBOUND_MAX_BYTES,
|
|
24
|
+
OUTBOUND_MAX_REDIRECTS,
|
|
25
|
+
OUTBOUND_RETRY,
|
|
26
|
+
} from './outbound-types';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Single responsibility: the shapes of one `outbound()` call — the request, the injected seams and
|
|
2
|
+
// the answer — plus the defaults they fall back to. Types and constants only, so the attempt, the
|
|
3
|
+
// call and a caller's wrapper can share them without importing each other.
|
|
4
|
+
|
|
5
|
+
import type { Schema } from '@ultimat3/schema';
|
|
6
|
+
import type { Random } from './backoff';
|
|
7
|
+
import type { Breaker } from './breaker';
|
|
8
|
+
import type { DeadlineTimer } from './deadline-signal';
|
|
9
|
+
import type { DialResolve } from './outbound-dial';
|
|
10
|
+
import type { RetryPolicy } from './retry-loop';
|
|
11
|
+
|
|
12
|
+
export type OutboundMethod = 'GET' | 'HEAD' | 'PUT' | 'DELETE' | 'POST' | 'PATCH' | 'OPTIONS';
|
|
13
|
+
|
|
14
|
+
/** Re-sendable bodies only: a retry or a 307 hop sends the body again, and a stream cannot. */
|
|
15
|
+
export type OutboundBody =
|
|
16
|
+
| string
|
|
17
|
+
| Uint8Array<ArrayBuffer>
|
|
18
|
+
| ArrayBuffer
|
|
19
|
+
| Blob
|
|
20
|
+
| URLSearchParams
|
|
21
|
+
| FormData;
|
|
22
|
+
|
|
23
|
+
/** What one hop is sent with. `tls.serverName` is the name a PINNED connection proves. */
|
|
24
|
+
export type OutboundInit = RequestInit & {
|
|
25
|
+
readonly tls?: { readonly serverName?: string } | undefined;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** "Just the call", the seam `MailFetch` already is. Its `init` always carries the signal. */
|
|
29
|
+
export type OutboundFetch = (url: string, init: OutboundInit) => Promise<Response>;
|
|
30
|
+
|
|
31
|
+
export interface OutboundRequest {
|
|
32
|
+
readonly url: string | URL;
|
|
33
|
+
/** A bounded label chosen in code (`'resend'`), in every log line and cause. */
|
|
34
|
+
readonly vendor: string;
|
|
35
|
+
/**
|
|
36
|
+
* Which network the URL may reach — a decision per call, so it is required. `'public'` screens
|
|
37
|
+
* every address the name answers and pins the connection; `'named'` dials a host the app itself
|
|
38
|
+
* names in code (a configured endpoint, a sidecar) as written.
|
|
39
|
+
*/
|
|
40
|
+
readonly dial: 'named' | 'public';
|
|
41
|
+
/** The WHOLE call, retries and waits included. Clamped by the ambient request's own deadline. */
|
|
42
|
+
readonly deadlineMs: number;
|
|
43
|
+
readonly method?: OutboundMethod | undefined;
|
|
44
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
45
|
+
readonly body?: OutboundBody | undefined;
|
|
46
|
+
/** The longest gap between two body chunks, in ms. Default `OUTBOUND_IDLE_MS`. */
|
|
47
|
+
readonly idleMs?: number | undefined;
|
|
48
|
+
/** Default `OUTBOUND_MAX_BYTES`. */
|
|
49
|
+
readonly maxBytes?: number | undefined;
|
|
50
|
+
/** `'error'` by default: a 3xx is `X_OUTBOUND_REDIRECTED`, never a silent hop. */
|
|
51
|
+
readonly redirect?: 'error' | 'follow' | undefined;
|
|
52
|
+
/** Default `OUTBOUND_RETRY` for GET, HEAD, PUT, DELETE, OPTIONS; one attempt for POST, PATCH. */
|
|
53
|
+
readonly retry?: RetryPolicy | undefined;
|
|
54
|
+
/** A POST or PATCH carrying the vendor's idempotency key: retried as a safe method is. */
|
|
55
|
+
readonly idempotent?: true | undefined;
|
|
56
|
+
readonly read?: 'bytes' | 'stream' | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* A signal of the caller's own, heard BESIDE the ambient `ctx.signal` — a fan-out's cancel. Its
|
|
59
|
+
* reason reaches the caller unchanged, as the context's does.
|
|
60
|
+
*/
|
|
61
|
+
readonly signal?: AbortSignal | undefined;
|
|
62
|
+
/** Recorded once per call, after the retries: a 503 retried away is not a failure. */
|
|
63
|
+
readonly breaker?: Breaker | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface OutboundDeps {
|
|
67
|
+
readonly fetch?: OutboundFetch | undefined;
|
|
68
|
+
readonly resolve?: DialResolve | undefined;
|
|
69
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
70
|
+
/** Monotonic ms. */
|
|
71
|
+
readonly now?: (() => number) | undefined;
|
|
72
|
+
readonly random?: Random | undefined;
|
|
73
|
+
/** The deadline and idle timers. A test fires it instead of waiting. */
|
|
74
|
+
readonly timer?: DeadlineTimer | undefined;
|
|
75
|
+
/** Which environment decides whether a public dial may use `http:`. Defaults to `process.env`. */
|
|
76
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface OutboundHead {
|
|
80
|
+
readonly status: number;
|
|
81
|
+
readonly headers: Headers;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A 2xx, read whole. Anything else was thrown as a coded error. */
|
|
85
|
+
export interface OutboundAnswer extends OutboundHead {
|
|
86
|
+
readonly bytes: Uint8Array;
|
|
87
|
+
text(): string;
|
|
88
|
+
/** The body through `schema`: `X_OUTBOUND_BODY_INVALID` when it does not fit. */
|
|
89
|
+
json<T>(schema: Schema<unknown, T>): T;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A 2xx whose body is read by the caller, under the same idle watchdog, cap and deadline. */
|
|
93
|
+
export interface OutboundStreamAnswer extends OutboundHead {
|
|
94
|
+
readonly stream: ReadableStream<Uint8Array>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Three tries, 250 ms then up to 5 s apart, full jitter. */
|
|
98
|
+
export const OUTBOUND_RETRY: RetryPolicy = Object.freeze({
|
|
99
|
+
attempts: 3,
|
|
100
|
+
base: 250,
|
|
101
|
+
max: 5_000,
|
|
102
|
+
jitter: 'full',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const OUTBOUND_IDLE_MS = 30_000;
|
|
106
|
+
export const OUTBOUND_MAX_BYTES = 16 * 1024 * 1024;
|
|
107
|
+
/** Hops a `redirect: 'follow'` call takes before it is `X_OUTBOUND_REDIRECTED`. */
|
|
108
|
+
export const OUTBOUND_MAX_REDIRECTS = 5;
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Single responsibility: `outbound()`, the one outbound HTTP call — screen the request, run its
|
|
2
|
+
// attempts through core's `retry()` (which owns the clamp to the ambient caller), record the
|
|
3
|
+
// breaker once, and hand back a 2xx. Three adopters built this client and agreed on every property;
|
|
4
|
+
// the parts were in core already, unassembled. Server-only: `@ultimat3/core/outbound`, never the barrel.
|
|
5
|
+
|
|
6
|
+
import type { Schema } from '@ultimat3/schema';
|
|
7
|
+
import type { Breaker } from './breaker';
|
|
8
|
+
import { settleWith } from './breaker';
|
|
9
|
+
import { systemClock } from './clock';
|
|
10
|
+
import { tryUseContext } from './context';
|
|
11
|
+
import { platformDeadlineTimer } from './deadline-signal';
|
|
12
|
+
import { isLocal } from './environment';
|
|
13
|
+
import { isUltimateError } from './errors';
|
|
14
|
+
import { finiteCount } from './finite-option';
|
|
15
|
+
import type { AttemptAnswer, AttemptCall } from './outbound-attempt';
|
|
16
|
+
import { runAttempt } from './outbound-attempt';
|
|
17
|
+
import type { OutboundSite } from './outbound-errors';
|
|
18
|
+
import { isCallerBound, outboundBodyInvalid, outboundRetryUnsafe } from './outbound-errors';
|
|
19
|
+
import { redactedPath } from './outbound-hops';
|
|
20
|
+
import type {
|
|
21
|
+
OutboundAnswer,
|
|
22
|
+
OutboundDeps,
|
|
23
|
+
OutboundFetch,
|
|
24
|
+
OutboundRequest,
|
|
25
|
+
OutboundStreamAnswer,
|
|
26
|
+
} from './outbound-types';
|
|
27
|
+
import { OUTBOUND_IDLE_MS, OUTBOUND_MAX_BYTES, OUTBOUND_RETRY } from './outbound-types';
|
|
28
|
+
import { retry } from './retry';
|
|
29
|
+
import type { RetryPolicy } from './retry-loop';
|
|
30
|
+
|
|
31
|
+
/** The platform's `fetch`, closed over (a detached `fetch` is "Illegal invocation" on some hosts). */
|
|
32
|
+
const platformFetch: OutboundFetch = (target, init) =>
|
|
33
|
+
fetch(target, { ...init, signal: init.signal ?? null });
|
|
34
|
+
|
|
35
|
+
/** A backoff wait the call's OWN signal also ends; `retry()` already hears the ambient one. */
|
|
36
|
+
const signalledSleep =
|
|
37
|
+
(signal: AbortSignal) =>
|
|
38
|
+
(ms: number): Promise<void> =>
|
|
39
|
+
new Promise<void>((done) => {
|
|
40
|
+
const finish = (): void => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
signal.removeEventListener('abort', finish);
|
|
43
|
+
done();
|
|
44
|
+
};
|
|
45
|
+
const timer = setTimeout(finish, ms);
|
|
46
|
+
if (signal.aborted) finish();
|
|
47
|
+
else signal.addEventListener('abort', finish, { once: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** A write the vendor cannot tell from a second one: never replayed without its key. */
|
|
51
|
+
const UNSAFE = new Set(['POST', 'PATCH']);
|
|
52
|
+
|
|
53
|
+
const ONE_ATTEMPT: RetryPolicy = Object.freeze({ attempts: 1, base: 0, max: 0, jitter: 'none' });
|
|
54
|
+
|
|
55
|
+
function policyFor(request: OutboundRequest, site: OutboundSite): RetryPolicy {
|
|
56
|
+
const unsafe = UNSAFE.has(site.method) && request.idempotent !== true;
|
|
57
|
+
// Refused at the call, before a byte: a replayed charge is not a failure a log line repairs.
|
|
58
|
+
if (unsafe && request.retry !== undefined) throw outboundRetryUnsafe(site);
|
|
59
|
+
const policy = request.retry ?? (unsafe ? ONE_ATTEMPT : OUTBOUND_RETRY);
|
|
60
|
+
const budget = policy.timeBudgetMs ?? Number.POSITIVE_INFINITY;
|
|
61
|
+
return { ...policy, timeBudgetMs: Math.min(budget, request.deadlineMs) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function prepare(request: OutboundRequest, deps: OutboundDeps): AttemptCall {
|
|
65
|
+
const url = new URL(request.url);
|
|
66
|
+
const method = request.method ?? 'GET';
|
|
67
|
+
const site: OutboundSite = {
|
|
68
|
+
vendor: request.vendor,
|
|
69
|
+
method,
|
|
70
|
+
origin: url.origin,
|
|
71
|
+
path: redactedPath(url.pathname),
|
|
72
|
+
};
|
|
73
|
+
const deadlineMs = finiteCount('outbound()', 'deadlineMs', request.deadlineMs, 1);
|
|
74
|
+
const now = deps.now ?? ((): number => systemClock.monotonic());
|
|
75
|
+
const send = deps.fetch ?? platformFetch;
|
|
76
|
+
return {
|
|
77
|
+
site,
|
|
78
|
+
url,
|
|
79
|
+
method,
|
|
80
|
+
headers: { ...request.headers },
|
|
81
|
+
body: request.body,
|
|
82
|
+
dial: request.dial,
|
|
83
|
+
redirect: request.redirect ?? 'error',
|
|
84
|
+
local: isLocal({ env: deps.env, fallback: 'production' }),
|
|
85
|
+
fetch: send,
|
|
86
|
+
resolve: deps.resolve,
|
|
87
|
+
endsAt: now() + deadlineMs,
|
|
88
|
+
deadlineMs,
|
|
89
|
+
idleMs: finiteCount('outbound()', 'idleMs', request.idleMs ?? OUTBOUND_IDLE_MS, 1),
|
|
90
|
+
maxBytes: finiteCount('outbound()', 'maxBytes', request.maxBytes ?? OUTBOUND_MAX_BYTES),
|
|
91
|
+
read: request.read ?? 'bytes',
|
|
92
|
+
signal: request.signal,
|
|
93
|
+
now,
|
|
94
|
+
timer: deps.timer ?? platformDeadlineTimer,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Once per CALL, after the retries; a caller gone gives the probe back and counts nothing. */
|
|
99
|
+
async function guarded(
|
|
100
|
+
gate: Breaker,
|
|
101
|
+
signal: AbortSignal | undefined,
|
|
102
|
+
work: () => Promise<AttemptAnswer>,
|
|
103
|
+
): Promise<AttemptAnswer> {
|
|
104
|
+
const ticket = gate.admit();
|
|
105
|
+
if (ticket === undefined) throw gate.refusal();
|
|
106
|
+
try {
|
|
107
|
+
const answer = await work();
|
|
108
|
+
gate.succeeded(ticket);
|
|
109
|
+
return answer;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// A caller gone, or the caller's own deadline spent, says nothing about the vendor.
|
|
112
|
+
const gone =
|
|
113
|
+
tryUseContext()?.signal.aborted === true || signal?.aborted === true || isCallerBound(error);
|
|
114
|
+
settleWith(gate, gone ? 'ignore' : gate.judge(error), ticket);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function bytesAnswer(site: OutboundSite, answer: AttemptAnswer & { bytes: Uint8Array }) {
|
|
120
|
+
const text = (): string => new TextDecoder().decode(answer.bytes);
|
|
121
|
+
return {
|
|
122
|
+
status: answer.status,
|
|
123
|
+
headers: answer.headers,
|
|
124
|
+
bytes: answer.bytes,
|
|
125
|
+
text,
|
|
126
|
+
json<T>(schema: Schema<unknown, T>): T {
|
|
127
|
+
let value: unknown;
|
|
128
|
+
try {
|
|
129
|
+
value = JSON.parse(text());
|
|
130
|
+
} catch {
|
|
131
|
+
throw outboundBodyInvalid(site, ['the body is not JSON']);
|
|
132
|
+
}
|
|
133
|
+
const result = schema.safeParse(value);
|
|
134
|
+
if ('value' in result && result.issues === undefined) return result.value;
|
|
135
|
+
const issues = (result.issues ?? []).map(
|
|
136
|
+
(issue) =>
|
|
137
|
+
(issue.path ?? [])
|
|
138
|
+
.map((part) => String(typeof part === 'object' ? part.key : part))
|
|
139
|
+
.join('.') || '(root)',
|
|
140
|
+
);
|
|
141
|
+
throw outboundBodyInvalid(site, issues);
|
|
142
|
+
},
|
|
143
|
+
} satisfies OutboundAnswer;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* One outbound HTTP call, every property decided: a deadline over the WHOLE call, the ambient
|
|
148
|
+
* request's signal and deadline honoured (`runToCompletion` lifts both, its `bound` sets them
|
|
149
|
+
* again), safe-only retry with `Retry-After`, an idle watchdog and a byte cap on the body, a 3xx
|
|
150
|
+
* never followed unless asked, and every failure a code whose cause names no query or header.
|
|
151
|
+
*/
|
|
152
|
+
export async function outbound(
|
|
153
|
+
request: OutboundRequest & { readonly read: 'stream' },
|
|
154
|
+
deps?: OutboundDeps,
|
|
155
|
+
): Promise<OutboundStreamAnswer>;
|
|
156
|
+
export async function outbound(
|
|
157
|
+
request: OutboundRequest & { readonly read?: 'bytes' | undefined },
|
|
158
|
+
deps?: OutboundDeps,
|
|
159
|
+
): Promise<OutboundAnswer>;
|
|
160
|
+
export async function outbound(
|
|
161
|
+
request: OutboundRequest,
|
|
162
|
+
deps: OutboundDeps = {},
|
|
163
|
+
): Promise<OutboundAnswer | OutboundStreamAnswer> {
|
|
164
|
+
const call = prepare(request, deps);
|
|
165
|
+
const policy = policyFor(request, call.site);
|
|
166
|
+
const work = (): Promise<AttemptAnswer> =>
|
|
167
|
+
retry((attempt) => runAttempt(call, attempt), policy, {
|
|
168
|
+
sleep:
|
|
169
|
+
deps.sleep ?? (request.signal === undefined ? undefined : signalledSleep(request.signal)),
|
|
170
|
+
now: call.now,
|
|
171
|
+
random: deps.random,
|
|
172
|
+
});
|
|
173
|
+
const answer =
|
|
174
|
+
request.breaker === undefined
|
|
175
|
+
? await work()
|
|
176
|
+
: await guarded(request.breaker, request.signal, work);
|
|
177
|
+
if ('stream' in answer) return answer;
|
|
178
|
+
return bytesAnswer(call.site, answer);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* `outbound()` for a caller whose existing code READS a refusal: the final answer as a `Response`
|
|
183
|
+
* whatever its status. Every property above still holds — deadline, signal, retry, caps, redaction
|
|
184
|
+
* (a refusal's body is its redacted 200-character `meta.detail`) — and a call that got no answer
|
|
185
|
+
* still throws its code. For a driver mapping statuses onto codes it already shipped.
|
|
186
|
+
*/
|
|
187
|
+
export async function outboundResponse(
|
|
188
|
+
request: OutboundRequest & { readonly read?: 'bytes' | undefined },
|
|
189
|
+
deps: OutboundDeps = {},
|
|
190
|
+
): Promise<Response> {
|
|
191
|
+
// The last answer's HEADERS, kept for the refusal rebuilt below: a vendor names its error type
|
|
192
|
+
// in one (SES's `x-amzn-ErrorType`), and a cause never carries a header.
|
|
193
|
+
let lastHeaders: Headers | undefined;
|
|
194
|
+
const send = deps.fetch ?? platformFetch;
|
|
195
|
+
const recording: OutboundFetch = async (target, init) => {
|
|
196
|
+
const response = await send(target, init);
|
|
197
|
+
lastHeaders = response.headers;
|
|
198
|
+
return response;
|
|
199
|
+
};
|
|
200
|
+
try {
|
|
201
|
+
const answer = await outbound(request, { ...deps, fetch: recording });
|
|
202
|
+
return new Response(answer.bytes.slice(), { status: answer.status, headers: answer.headers });
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const refused = refusalResponse(error, lastHeaders);
|
|
205
|
+
if (refused === undefined) throw error;
|
|
206
|
+
return refused;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The answer a coded refusal carried, rebuilt: its status, its stated delay, its detail. */
|
|
211
|
+
function refusalResponse(error: unknown, received: Headers | undefined): Response | undefined {
|
|
212
|
+
if (!isUltimateError(error) || !error.code.startsWith('X_OUTBOUND_')) return undefined;
|
|
213
|
+
const status = error.meta?.['status'];
|
|
214
|
+
if (typeof status !== 'number') return undefined;
|
|
215
|
+
const detail = error.meta?.['detail'];
|
|
216
|
+
const after = error.meta?.['retryAfterSeconds'];
|
|
217
|
+
const headers = new Headers(received);
|
|
218
|
+
// The body is the detail now, never the vendor's bytes: their length and encoding no longer hold.
|
|
219
|
+
headers.delete('content-length');
|
|
220
|
+
headers.delete('content-encoding');
|
|
221
|
+
if (typeof after === 'number') headers.set('retry-after', String(after));
|
|
222
|
+
return new Response(typeof detail === 'string' ? detail : '', { status, headers });
|
|
223
|
+
}
|
package/src/page-meta.ts
CHANGED
|
@@ -34,6 +34,14 @@ export const APP_UPDATE_MESSAGE = 'AppUpdateAvailable';
|
|
|
34
34
|
*/
|
|
35
35
|
export const CLIENT_PATH_STYLE_META = 'ultimate-path-style';
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Every PINNED action and query (`http: { path, method }`) the server serves, as one JSON object
|
|
39
|
+
* (`client-paths-pins.ts` owns the format). A browser caller reaching one BY NAME — `rpc`,
|
|
40
|
+
* `queryClient`, `useQuery`, `useMutation`, the outbox — goes where the declaration put it, and
|
|
41
|
+
* no browser ref restates a pin it could drift from. Absent when nothing is pinned.
|
|
42
|
+
*/
|
|
43
|
+
export const CLIENT_PINS_META = 'ultimate-pins';
|
|
44
|
+
|
|
37
45
|
/** Where the page's one socket dials: `/_x/sync`, or the deployment's absolute `SYNC_URL`. */
|
|
38
46
|
export const CLIENT_SYNC_META = 'ultimate-sync';
|
|
39
47
|
|
package/src/page.ts
CHANGED
|
@@ -17,6 +17,8 @@ export { backoffDelay } from './backoff';
|
|
|
17
17
|
export type { FetchLike, TransportRequest } from './client-dispatch';
|
|
18
18
|
export { IDEMPOTENCY_HEADER } from './client-dispatch';
|
|
19
19
|
export { actionPath, queryPath, splitWords } from './client-paths';
|
|
20
|
+
export type { ActionPin, PinnedActionMethod } from './client-paths-pins';
|
|
21
|
+
export { renderedActionPin, renderedQueryPin } from './client-paths-pins';
|
|
20
22
|
export type { ClientScope } from './client-scope';
|
|
21
23
|
export { onRescope, rescope } from './client-scope';
|
|
22
24
|
export { clientTransport } from './client-transport';
|
|
@@ -34,6 +36,7 @@ export { renderFixShellArg, renderThrowable, stringField } from './error-render'
|
|
|
34
36
|
export { classifyThrown } from './error-retry';
|
|
35
37
|
export type { UltimateErrorInit } from './errors';
|
|
36
38
|
export { isUltimateError, UltimateError } from './errors';
|
|
39
|
+
export { fillWire } from './fill-wire';
|
|
37
40
|
export { finiteCount, finiteOption } from './finite-option';
|
|
38
41
|
export { isSuperseded } from './generation-fence';
|
|
39
42
|
export { uuidV7 } from './ids';
|
|
@@ -49,6 +52,7 @@ export {
|
|
|
49
52
|
CLIENT_NAVIGATION_SURFACE_HEADER,
|
|
50
53
|
CLIENT_PATH_STYLE_META,
|
|
51
54
|
CLIENT_PERSIST_META,
|
|
55
|
+
CLIENT_PINS_META,
|
|
52
56
|
CLIENT_SCOPE_HEADER,
|
|
53
57
|
CLIENT_SCOPE_META,
|
|
54
58
|
CLIENT_SYNC_META,
|
|
@@ -58,8 +62,13 @@ export type { RecordEnvelope, RecordRows } from './record-envelope';
|
|
|
58
62
|
export { decodeRecordEnvelope, RECORDS_HEADER } from './record-envelope';
|
|
59
63
|
export type { PageClient, RecordSink } from './record-sink';
|
|
60
64
|
export { pageClient } from './record-sink';
|
|
65
|
+
export { fillTemplate, templateParams } from './route-path-template';
|
|
66
|
+
export { onSessionLost, sessionIsLost } from './session-lost';
|
|
61
67
|
// The key the theme boot script reads and the toggle island writes.
|
|
62
68
|
export { THEME_STORAGE_KEY } from './theme-storage';
|
|
69
|
+
// A poll a hidden tab and a lost session stop: here, so an island polling reaches no realtime.
|
|
70
|
+
export type { VisibleIntervalOptions } from './visible-interval';
|
|
71
|
+
export { visibleInterval } from './visible-interval';
|
|
63
72
|
// What `clientTransport` revives a read's instants by: the header the server names them in.
|
|
64
73
|
export { DATES_HEADER, reviveWireDates } from './wire-dates';
|
|
65
74
|
// A write's public name, so the page's store can recognise the `records` frame its own write made.
|
|
@@ -27,6 +27,12 @@ export function recordEnvelopeSchema(
|
|
|
27
27
|
description: 'Record keys the client store must drop, keyed by record type.',
|
|
28
28
|
additionalProperties: { type: 'array', items: { type: 'string' } },
|
|
29
29
|
},
|
|
30
|
+
order: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
description:
|
|
33
|
+
"A record type's keys in answer order, sent only where `records` cannot keep it (integer-like keys enumerate ascending).",
|
|
34
|
+
additionalProperties: { type: 'array', items: { type: 'string' } },
|
|
35
|
+
},
|
|
30
36
|
},
|
|
31
37
|
};
|
|
32
38
|
}
|