@ultimat3/core 29.3.2 → 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
package/src/single-flight.ts
CHANGED
|
@@ -26,6 +26,19 @@ export interface SingleFlightOptions {
|
|
|
26
26
|
readonly deadlineMs?: number | undefined;
|
|
27
27
|
/** Injected so a deadline is provable without waiting for one. */
|
|
28
28
|
readonly schedule?: Scheduler | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The lifetime of whoever is calling, read at each `run`. Set, a joiner whose leader was aborted
|
|
31
|
+
* and whose own signal is live re-leads with its own `work`: the leader's closed tab is not a
|
|
32
|
+
* failure of the read it joined. Any other rejection is shared, ordinary failures included.
|
|
33
|
+
* Absent means every joiner shares every rejection. Injected, so this file reads no context.
|
|
34
|
+
*/
|
|
35
|
+
readonly signal?: (() => AbortSignal | undefined) | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Told each time `key` stops being held: settle, deadline or `evict`, and not when a load already
|
|
38
|
+
* evicted settles. So a caller keeping state per held key mirrors the map exactly, with no gap
|
|
39
|
+
* before the settle and no entry left behind by a load that never does.
|
|
40
|
+
*/
|
|
41
|
+
readonly released?: ((key: string) => void) | undefined;
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
export interface SingleFlight {
|
|
@@ -51,9 +64,10 @@ export interface SingleFlight {
|
|
|
51
64
|
evict(key: string): boolean;
|
|
52
65
|
}
|
|
53
66
|
|
|
54
|
-
/** The leader's promise, the box its merged context lives in,
|
|
67
|
+
/** The leader's promise, its signal, the box its merged context lives in, its deadline's canceller. */
|
|
55
68
|
interface Flight {
|
|
56
69
|
readonly running: Promise<unknown>;
|
|
70
|
+
readonly signal: AbortSignal | undefined;
|
|
57
71
|
readonly shared: { context: unknown };
|
|
58
72
|
cancelDeadline: () => void;
|
|
59
73
|
}
|
|
@@ -69,12 +83,68 @@ export function singleFlight(options?: SingleFlightOptions): SingleFlight {
|
|
|
69
83
|
const inflight = new Map<string, Flight>();
|
|
70
84
|
const schedule = options?.schedule ?? defaultScheduler;
|
|
71
85
|
const deadlineMs = options?.deadlineMs;
|
|
86
|
+
const readSignal = options?.signal;
|
|
87
|
+
const released = options?.released;
|
|
72
88
|
|
|
73
89
|
// Identity, never key presence. `inflight.delete(key)` from a settling load drops whatever holds
|
|
74
90
|
// the key NOW — which, once a deadline can evict, is a different load that has not settled: its
|
|
75
91
|
// joiners would then be sharing a promise nothing in the map answers for.
|
|
76
92
|
const evict = (key: string, entry: Flight): void => {
|
|
77
|
-
if (inflight.get(key)
|
|
93
|
+
if (inflight.get(key) !== entry) return;
|
|
94
|
+
inflight.delete(key);
|
|
95
|
+
released?.(key);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const run = <T, C = undefined>(
|
|
99
|
+
key: string,
|
|
100
|
+
work: (shared: () => C | undefined) => Promise<T>,
|
|
101
|
+
join?: FlightJoin<C>,
|
|
102
|
+
): Promise<T> => {
|
|
103
|
+
const joined = inflight.get(key);
|
|
104
|
+
// Two readers of one key asking for two different `T` is a caller bug this cannot see; the
|
|
105
|
+
// value they share is the same object either way, so the cast is the honest one.
|
|
106
|
+
if (joined !== undefined) {
|
|
107
|
+
if (join !== undefined) {
|
|
108
|
+
joined.shared.context = join.merge(joined.shared.context as C, join.context);
|
|
109
|
+
}
|
|
110
|
+
if (readSignal === undefined) return joined.running as Promise<T>;
|
|
111
|
+
const mine = readSignal();
|
|
112
|
+
// Asked at the rejection, not now: either side may yet abort. `settled` was attached at lead,
|
|
113
|
+
// ahead of this, so the key is already free; the first re-lead leads and the rest join it. No
|
|
114
|
+
// cap is needed, as each further round spends one more aborted leader. `catch` runs under the
|
|
115
|
+
// context it was attached in, so the re-lead's `work` is this joiner's, as this joiner.
|
|
116
|
+
return (joined.running as Promise<T>).catch((error: unknown) => {
|
|
117
|
+
if (joined.signal?.aborted !== true || mine?.aborted === true) throw error;
|
|
118
|
+
return run(key, work, join);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const shared: { context: unknown } = { context: join?.context };
|
|
123
|
+
// Wrapped so a `work()` that throws SYNCHRONOUSLY still rejects the joiners rather than
|
|
124
|
+
// escaping past the map and leaving no entry to clear.
|
|
125
|
+
const running: Promise<T> = (async () => await work(() => shared.context as C | undefined))();
|
|
126
|
+
const entry: Flight = {
|
|
127
|
+
running,
|
|
128
|
+
signal: readSignal?.(),
|
|
129
|
+
shared,
|
|
130
|
+
cancelDeadline: (): void => undefined,
|
|
131
|
+
};
|
|
132
|
+
inflight.set(key, entry);
|
|
133
|
+
|
|
134
|
+
if (deadlineMs !== undefined) {
|
|
135
|
+
entry.cancelDeadline = schedule(() => {
|
|
136
|
+
evict(key, entry);
|
|
137
|
+
}, deadlineMs);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const settled = (): void => {
|
|
141
|
+
entry.cancelDeadline();
|
|
142
|
+
evict(key, entry);
|
|
143
|
+
};
|
|
144
|
+
// A rejected load MUST clear too, or one failure is cached as a permanent rejection.
|
|
145
|
+
void running.then(settled, settled);
|
|
146
|
+
|
|
147
|
+
return running;
|
|
78
148
|
};
|
|
79
149
|
|
|
80
150
|
return {
|
|
@@ -88,45 +158,10 @@ export function singleFlight(options?: SingleFlightOptions): SingleFlight {
|
|
|
88
158
|
const held = inflight.get(key);
|
|
89
159
|
if (held === undefined) return false;
|
|
90
160
|
held.cancelDeadline();
|
|
91
|
-
|
|
161
|
+
evict(key, held);
|
|
162
|
+
return true;
|
|
92
163
|
},
|
|
93
164
|
|
|
94
|
-
run
|
|
95
|
-
key: string,
|
|
96
|
-
work: (shared: () => C | undefined) => Promise<T>,
|
|
97
|
-
join?: FlightJoin<C>,
|
|
98
|
-
): Promise<T> {
|
|
99
|
-
const joined = inflight.get(key);
|
|
100
|
-
// Two readers of one key asking for two different `T` is a caller bug this cannot see; the
|
|
101
|
-
// value they share is the same object either way, so the cast is the honest one.
|
|
102
|
-
if (joined !== undefined) {
|
|
103
|
-
if (join !== undefined) {
|
|
104
|
-
joined.shared.context = join.merge(joined.shared.context as C, join.context);
|
|
105
|
-
}
|
|
106
|
-
return joined.running as Promise<T>;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const shared: { context: unknown } = { context: join?.context };
|
|
110
|
-
// Wrapped so a `work()` that throws SYNCHRONOUSLY still rejects the joiners rather than
|
|
111
|
-
// escaping past the map and leaving no entry to clear.
|
|
112
|
-
const running: Promise<T> = (async () => await work(() => shared.context as C | undefined))();
|
|
113
|
-
const entry: Flight = { running, shared, cancelDeadline: (): void => undefined };
|
|
114
|
-
inflight.set(key, entry);
|
|
115
|
-
|
|
116
|
-
if (deadlineMs !== undefined) {
|
|
117
|
-
entry.cancelDeadline = schedule(() => {
|
|
118
|
-
evict(key, entry);
|
|
119
|
-
}, deadlineMs);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const settled = (): void => {
|
|
123
|
-
entry.cancelDeadline();
|
|
124
|
-
evict(key, entry);
|
|
125
|
-
};
|
|
126
|
-
// A rejected load MUST clear too, or one failure is cached as a permanent rejection.
|
|
127
|
-
void running.then(settled, settled);
|
|
128
|
-
|
|
129
|
-
return running;
|
|
130
|
-
},
|
|
165
|
+
run,
|
|
131
166
|
};
|
|
132
167
|
}
|
package/src/source-mask.ts
CHANGED
|
@@ -147,8 +147,42 @@ function blankRegions(text: string, strings: boolean): string {
|
|
|
147
147
|
return out.join('');
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Held units per memo — key plus answer, twice the input — past which it is dropped whole: about
|
|
152
|
+
* 190 MB of RSS. Measured 2026-11-03 on the `errors` step, user CPU / RSS: no memo 32.2 s /
|
|
153
|
+
* 0.7 GB; 32M 21.9 s, too small for the step's working set; 64M 16.5-16.9 s / 0.89-1.15 GB;
|
|
154
|
+
* 128M 15.8 s / 1.14 GB.
|
|
155
|
+
*/
|
|
156
|
+
export const MASK_MEMO_CAP = 64 * 1024 * 1024;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `mask` memoised on its input text. Every guard of the `errors` step re-masked every file: 74,031
|
|
160
|
+
* masks for 11,298 distinct inputs, 42% of its CPU. Cleared whole and not LRU, because a guard
|
|
161
|
+
* walks every file in order and an LRU smaller than the corpus misses on every call.
|
|
162
|
+
*/
|
|
163
|
+
export function memoText(
|
|
164
|
+
mask: (text: string) => string,
|
|
165
|
+
cap = MASK_MEMO_CAP,
|
|
166
|
+
): (text: string) => string {
|
|
167
|
+
const memo = new Map<string, string>();
|
|
168
|
+
let held = 0;
|
|
169
|
+
return (text) => {
|
|
170
|
+
const known = memo.get(text);
|
|
171
|
+
if (known !== undefined) return known;
|
|
172
|
+
const out = mask(text);
|
|
173
|
+
// Key and answer are both held: twice the input, about 2.9 B of RSS per unit measured.
|
|
174
|
+
if (held + 2 * text.length > cap) {
|
|
175
|
+
memo.clear();
|
|
176
|
+
held = 0;
|
|
177
|
+
}
|
|
178
|
+
memo.set(text, out);
|
|
179
|
+
held += 2 * text.length;
|
|
180
|
+
return out;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
150
184
|
/** Comments gone, string literals intact — what a scan for declared codes or `t()` keys reads. */
|
|
151
|
-
export const stripComments = (text
|
|
185
|
+
export const stripComments = memoText((text) => blankRegions(text, false));
|
|
152
186
|
|
|
153
187
|
/** Comments and string contents gone, delimiters kept — what a scan for code structure reads. */
|
|
154
|
-
export const maskLiterals = (text
|
|
188
|
+
export const maskLiterals = memoText((text) => blankRegions(text, true));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The ONE description a projected tool carries: `@ultimat3/mcp`'s, `@ultimat3/ai`'s and
|
|
2
|
+
// `@ultimat3/admin`'s, which the tier table keeps apart, so the rule lives here, never copied.
|
|
3
|
+
|
|
4
|
+
import { describeDeclaredErrors } from './declared-errors';
|
|
5
|
+
|
|
6
|
+
/** What a tool's description is read from — an action's, a query's, or a hand-built primitive's. */
|
|
7
|
+
export interface ToolDescriptionSource {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly description?: string;
|
|
10
|
+
readonly mcp?: { readonly description?: string };
|
|
11
|
+
/** `false` for a read (a query). Absent counts as a write, as `toolFrom` meters it. */
|
|
12
|
+
readonly mutates?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `mcp.description`, else the primitive's own `description`, else a default naming it — a read
|
|
17
|
+
* says it reads, so an agent never plans a write around one — then the declared `errors:`
|
|
18
|
+
* appended (`describeDeclaredErrors`), so an agent plans around a refusal.
|
|
19
|
+
*/
|
|
20
|
+
export function toolDescription(source: ToolDescriptionSource, errors?: readonly string[]): string {
|
|
21
|
+
const lead = source.mcp?.description ?? source.description ?? defaultLead(source);
|
|
22
|
+
return describeDeclaredErrors(lead, errors);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function defaultLead(source: ToolDescriptionSource): string {
|
|
26
|
+
return source.mutates === false
|
|
27
|
+
? `Read with the "${source.name}" query.`
|
|
28
|
+
: `Run the "${source.name}" action.`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A poll that a hidden tab and a lost session both stop: the ONE timer for what has no changefeed,
|
|
3
|
+
* and for the safety re-read behind an at-most-once channel. Here, not in `@ultimat3/realtime`,
|
|
4
|
+
* because reaching realtime ships the whole page runtime to an island that only wanted a timer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { backoffDelay, type Random } from './backoff';
|
|
8
|
+
import { finiteOption, MAX_TIMER_MS } from './finite-option';
|
|
9
|
+
import { whileSessionHeld } from './session-lost';
|
|
10
|
+
|
|
11
|
+
export interface VisibleIntervalOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Back off on failure: each failed run (a throw or a rejection) doubles the wait from `ms` up to
|
|
14
|
+
* `maxMs`, with equal jitter so a fleet of tabs does not ask in lockstep; a success resets it to
|
|
15
|
+
* `ms`. Without it every wait is `ms`, failed or not.
|
|
16
|
+
*/
|
|
17
|
+
readonly maxMs?: number | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The tab, injected: timers and visibility. Internal; `visibleInterval` reads the real one. */
|
|
21
|
+
export interface IntervalHost {
|
|
22
|
+
setTimer(fn: () => void, ms: number): unknown;
|
|
23
|
+
clearTimer(id: unknown): void;
|
|
24
|
+
isHidden(): boolean;
|
|
25
|
+
/** Subscribe to visibility changes; returns the unsubscribe. */
|
|
26
|
+
onVisibility(fn: () => void): () => void;
|
|
27
|
+
readonly random: Random;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SUBJECT = 'visibleInterval';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Call `run` every `ms` while the tab is shown. Hidden: no call; one make-up call when shown. An
|
|
34
|
+
* async `run` is never overlapped — the next wait starts when it settles; a throw or rejection is
|
|
35
|
+
* re-raised for the page's error hook. With `maxMs`, failures back off and a success resets.
|
|
36
|
+
* Stops on a session loss; starts afresh when a principal next signs in. Returns an idempotent
|
|
37
|
+
* disposer.
|
|
38
|
+
*/
|
|
39
|
+
export function visibleInterval(
|
|
40
|
+
run: () => unknown,
|
|
41
|
+
ms: number,
|
|
42
|
+
options: VisibleIntervalOptions = {},
|
|
43
|
+
): () => void {
|
|
44
|
+
return visibleIntervalOn(browserHost(), run, ms, options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `visibleInterval` over an injected tab. Internal: tests and `visibleInterval` call it. */
|
|
48
|
+
export function visibleIntervalOn(
|
|
49
|
+
host: IntervalHost,
|
|
50
|
+
run: () => unknown,
|
|
51
|
+
ms: number,
|
|
52
|
+
options: VisibleIntervalOptions = {},
|
|
53
|
+
): () => void {
|
|
54
|
+
const base = clampTimer(finiteOption(SUBJECT, 'ms', ms));
|
|
55
|
+
const max =
|
|
56
|
+
options.maxMs === undefined ? base : clampTimer(finiteOption(SUBJECT, 'maxMs', options.maxMs));
|
|
57
|
+
// Stopped by a session loss, started afresh — attempt 1 — by the next signed-in principal.
|
|
58
|
+
return whileSessionHeld(() => runLoop(host, run, base, max));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One session's loop: armed now, stopped by the returned function. */
|
|
62
|
+
function runLoop(host: IntervalHost, run: () => unknown, base: number, max: number): () => void {
|
|
63
|
+
/** 1 + the failures since the last success: what `backoffDelay` grows the wait by. */
|
|
64
|
+
let attempt = 1;
|
|
65
|
+
let timer: unknown;
|
|
66
|
+
let owed = false;
|
|
67
|
+
let running = false;
|
|
68
|
+
let stopped = false;
|
|
69
|
+
|
|
70
|
+
const arm = (): void => {
|
|
71
|
+
if (stopped || running) return;
|
|
72
|
+
// Jitter spreads a fleet's RETRIES; a healthy poll's period is exactly what was asked for.
|
|
73
|
+
const wait =
|
|
74
|
+
attempt > 1 && max > base
|
|
75
|
+
? backoffDelay({ attempt, base, max, jitter: 'equal', random: host.random })
|
|
76
|
+
: base;
|
|
77
|
+
timer = host.setTimer(tick, wait);
|
|
78
|
+
};
|
|
79
|
+
const call = (): void => {
|
|
80
|
+
running = true;
|
|
81
|
+
const settled = (failed: boolean): void => {
|
|
82
|
+
running = false;
|
|
83
|
+
attempt = failed ? attempt + 1 : 1;
|
|
84
|
+
arm();
|
|
85
|
+
};
|
|
86
|
+
// A failure is timed (it backs off) and then re-raised ONCE, off the loop's stack, so the loop
|
|
87
|
+
// survives and the page's error hook still sees what `run` did not catch. Never swallowed.
|
|
88
|
+
const failed = (error: unknown): void => {
|
|
89
|
+
settled(true);
|
|
90
|
+
queueMicrotask(() => {
|
|
91
|
+
throw error;
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
let result: unknown;
|
|
95
|
+
try {
|
|
96
|
+
result = run();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
failed(error);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (result instanceof Promise) result.then(() => settled(false), failed);
|
|
102
|
+
else settled(false);
|
|
103
|
+
};
|
|
104
|
+
const tick = (): void => {
|
|
105
|
+
timer = undefined;
|
|
106
|
+
if (host.isHidden()) {
|
|
107
|
+
owed = true;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
call();
|
|
111
|
+
};
|
|
112
|
+
const visible = (): void => {
|
|
113
|
+
if (host.isHidden() || !owed) return;
|
|
114
|
+
owed = false;
|
|
115
|
+
call();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const offVisibility = host.onVisibility(visible);
|
|
119
|
+
const stop = (): void => {
|
|
120
|
+
if (stopped) return;
|
|
121
|
+
stopped = true;
|
|
122
|
+
if (timer !== undefined) host.clearTimer(timer);
|
|
123
|
+
timer = undefined;
|
|
124
|
+
offVisibility();
|
|
125
|
+
};
|
|
126
|
+
arm();
|
|
127
|
+
return stop;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A timer past 2^31-1 ms fires at once, and one under 1 ms is a spin. */
|
|
131
|
+
function clampTimer(ms: number): number {
|
|
132
|
+
return Math.min(Math.max(1, ms), MAX_TIMER_MS);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
type VisibilityDoc = {
|
|
136
|
+
readonly visibilityState?: string;
|
|
137
|
+
addEventListener?(type: string, fn: () => void): void;
|
|
138
|
+
removeEventListener?(type: string, fn: () => void): void;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
function browserHost(): IntervalHost {
|
|
142
|
+
const doc: VisibilityDoc | undefined = Reflect.get(globalThis, 'document');
|
|
143
|
+
return {
|
|
144
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
145
|
+
clearTimer: (id) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
|
146
|
+
isHidden: () => doc?.visibilityState === 'hidden',
|
|
147
|
+
onVisibility(fn) {
|
|
148
|
+
doc?.addEventListener?.('visibilitychange', fn);
|
|
149
|
+
return (): void => doc?.removeEventListener?.('visibilitychange', fn);
|
|
150
|
+
},
|
|
151
|
+
random: Math.random,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/webhook-signature.ts
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
// three headers a delivery carries, and the parse of the one header a receiver reads back.
|
|
3
3
|
//
|
|
4
4
|
// It lives HERE for the reason `timing-safe-equal.ts` does, in that file's own words: two packages
|
|
5
|
-
// need the identical guarantee
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// asserted in two test files —
|
|
5
|
+
// need the identical guarantee. `@ultimat3/jobs` (tier 3) signs a delivery; `@ultimat3/http`
|
|
6
|
+
// (tier 2) verifies one and may not reach tier 3. `jobs` may import `http` (it does, for
|
|
7
|
+
// `webhookReceiver()`'s route), but a sender's format is not the receiver's policy, so the format
|
|
8
|
+
// sits below both and neither re-declares it. Before this module the spelling was stated twice and
|
|
9
|
+
// held together by a hex literal asserted in two test files — not a single source of truth.
|
|
10
10
|
//
|
|
11
11
|
// FORMAT and never POLICY. What counts as fresh, how large a body may be, and which status a
|
|
12
12
|
// refusal answers with are the receiver's questions and stay in `@ultimat3/http`.
|
|
13
13
|
|
|
14
|
+
import { hmac } from './hmac';
|
|
15
|
+
|
|
14
16
|
/** The format's version: the first field of the canonical string, and the signature's key. */
|
|
15
17
|
export const WEBHOOK_SIGNATURE_VERSION = 'v1';
|
|
16
18
|
|
|
@@ -86,12 +88,8 @@ export interface WebhookMacInput {
|
|
|
86
88
|
|
|
87
89
|
/** The hex hmac-sha256 over the canonical string. The one place either side computes one. */
|
|
88
90
|
export function webhookMac(input: WebhookMacInput): string {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
`${WEBHOOK_SIGNATURE_VERSION}:${input.timestampText}:${input.eventId}:${input.topic}:`,
|
|
92
|
-
);
|
|
93
|
-
hasher.update(input.body);
|
|
94
|
-
return hasher.digest('hex');
|
|
91
|
+
const prefix = `${WEBHOOK_SIGNATURE_VERSION}:${input.timestampText}:${input.eventId}:${input.topic}:`;
|
|
92
|
+
return hmac('sha256', input.secret, [prefix, input.body], 'hex');
|
|
95
93
|
}
|
|
96
94
|
|
|
97
95
|
/**
|