@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
package/src/retry.ts
CHANGED
|
@@ -1,165 +1,67 @@
|
|
|
1
|
-
// Single responsibility: run work again, on the framework's own terms
|
|
2
|
-
// `error-retry.ts` never had — that module declares `terminal | retryable |
|
|
3
|
-
// nothing in the tree consulted it before deciding to try again, so four packages
|
|
4
|
-
// their own loop and only `@ultimat3/jobs`' asked the classification at all.
|
|
1
|
+
// Single responsibility: run work again, on the framework's own terms AND within its caller's
|
|
2
|
+
// lifetime. The executor `error-retry.ts` never had — that module declares `terminal | retryable |
|
|
3
|
+
// retry-after` and nothing in the tree consulted it before deciding to try again, so four packages
|
|
4
|
+
// each shipped their own loop and only `@ultimat3/jobs`' asked the classification at all.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
type Random,
|
|
12
|
-
} from './backoff';
|
|
13
|
-
import { systemClock } from './clock';
|
|
14
|
-
import { classifyThrown, type ErrorRetry, statedDelayMs } from './error-retry';
|
|
15
|
-
import { finiteCount, finiteOption } from './finite-option';
|
|
16
|
-
|
|
17
|
-
export interface RetryPolicy {
|
|
18
|
-
/** Total attempts INCLUDING the first. `attempts: 1` means no retry. */
|
|
19
|
-
readonly attempts: number;
|
|
20
|
-
/** The first delay, in ms. */
|
|
21
|
-
readonly base: number;
|
|
22
|
-
/**
|
|
23
|
-
* Ceiling for any single delay, in ms. A `retry-after` the responder named is clamped to it
|
|
24
|
-
* too, and then gets its spread on top — at most half again, so a burst told one delay does
|
|
25
|
-
* not wake together (`jitterStatedDelay`).
|
|
26
|
-
*/
|
|
27
|
-
readonly max: number;
|
|
28
|
-
/**
|
|
29
|
-
* Required, unlike `backoffDelay`'s, and that is the point: a retry loop with no jitter is the
|
|
30
|
-
* thundering herd itself, so the mode is a decision each caller makes rather than one it can
|
|
31
|
-
* inherit without noticing.
|
|
32
|
-
*/
|
|
33
|
-
readonly jitter: JitterMode;
|
|
34
|
-
readonly curve?: BackoffCurve | undefined;
|
|
35
|
-
readonly factor?: number | undefined;
|
|
36
|
-
/**
|
|
37
|
-
* Wall-clock budget for the WHOLE loop, waits included. A wait that would end past it is never
|
|
38
|
-
* started — the caller's own deadline outranks the attempts it was allowed.
|
|
39
|
-
*/
|
|
40
|
-
readonly timeBudgetMs?: number | undefined;
|
|
41
|
-
}
|
|
6
|
+
import type { Random } from './backoff';
|
|
7
|
+
import type { Ctx } from './context';
|
|
8
|
+
import { tryUseContext } from './context';
|
|
9
|
+
import type { RetryPolicy } from './retry-loop';
|
|
10
|
+
import { retryLoop } from './retry-loop';
|
|
42
11
|
|
|
43
12
|
export interface RetryDeps {
|
|
44
|
-
/** Injected so a retry schedule is provable without waiting for one. */
|
|
45
|
-
sleep(ms: number)
|
|
46
|
-
/** Monotonic ms, read only when
|
|
13
|
+
/** Injected so a retry schedule is provable without waiting for one. Defaults to a timer. */
|
|
14
|
+
sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
15
|
+
/** Monotonic ms, read only when a time budget applies. Defaults to the system clock. */
|
|
47
16
|
now?: (() => number) | undefined;
|
|
48
17
|
random?: Random | undefined;
|
|
49
18
|
}
|
|
50
19
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
20
|
+
/** A timer an abort lets go of, so a retry abandoned mid-wait holds nothing for the full delay. */
|
|
21
|
+
const abortableTimer = (ms: number, signal: AbortSignal | undefined): Promise<void> =>
|
|
22
|
+
new Promise<void>((done) => {
|
|
23
|
+
const finish = (): void => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
signal?.removeEventListener('abort', finish);
|
|
26
|
+
done();
|
|
27
|
+
};
|
|
28
|
+
const timer = setTimeout(finish, ms);
|
|
29
|
+
signal?.addEventListener('abort', finish, { once: true });
|
|
30
|
+
});
|
|
63
31
|
|
|
64
32
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* that locks the account after three. Everything else keeps the attempt count in charge, and
|
|
69
|
-
* `retry-after` replaces only the DELAY, never the ceiling.
|
|
33
|
+
* The request's deadline as the loop's budget, when it is the tighter of the two. Not
|
|
34
|
+
* `remainingBudgetMs`: that answers `undefined` for a spent budget as well as for no deadline,
|
|
35
|
+
* which is right for a header and here would lift the bound exactly when it has run out.
|
|
70
36
|
*/
|
|
71
|
-
|
|
72
|
-
policy
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// Screened HERE and not only in `retry()`: this function is exported so a caller can write its
|
|
78
|
-
// own loop, and `attempt >= NaN` is false for every attempt — the loop that asks it never ends.
|
|
79
|
-
const attempts = finiteCount('a retry policy', 'attempts', policy.attempts);
|
|
80
|
-
const classification = classifyThrown(error);
|
|
81
|
-
const stop = (stoppedBy: RetryStopReason): RetryDecision => ({
|
|
82
|
-
retry: false,
|
|
83
|
-
delayMs: 0,
|
|
84
|
-
attempt,
|
|
85
|
-
nextAttempt: attempt,
|
|
86
|
-
classification,
|
|
87
|
-
stoppedBy,
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
if (classification === 'terminal') return stop('terminal');
|
|
91
|
-
if (attempt >= attempts) return stop('attempts-exhausted');
|
|
92
|
-
|
|
93
|
-
const computed = backoffDelay({
|
|
94
|
-
attempt,
|
|
95
|
-
base: policy.base,
|
|
96
|
-
max: policy.max,
|
|
97
|
-
factor: policy.factor,
|
|
98
|
-
curve: policy.curve,
|
|
99
|
-
jitter: policy.jitter,
|
|
100
|
-
random,
|
|
101
|
-
});
|
|
102
|
-
const stated = classification === 'retry-after' ? statedDelayMs(error) : undefined;
|
|
103
|
-
return {
|
|
104
|
-
retry: true,
|
|
105
|
-
// The stated delay is a FLOOR plus a spread (`jitterStatedDelay`), never the bare number: a
|
|
106
|
-
// shedding server tells a whole burst `Retry-After: 1`, and waiting exactly that replays the
|
|
107
|
-
// burst in lockstep every second. The floor is clamped by the policy's own ceiling, which is
|
|
108
|
-
// what `max` is for — a responder naming a day is still a responder this deployment has not
|
|
109
|
-
// agreed to wait a day for — and the spread on top is at most half of it.
|
|
110
|
-
// `jitter: 'none'` is honoured here too — the caller's one decision, for a test or a printable
|
|
111
|
-
// schedule — and only that mode gets the bare floor.
|
|
112
|
-
delayMs:
|
|
113
|
-
stated === undefined
|
|
114
|
-
? computed
|
|
115
|
-
: policy.jitter === 'none'
|
|
116
|
-
? Math.min(stated, policy.max)
|
|
117
|
-
: jitterStatedDelay(Math.min(stated, policy.max), policy.max, random),
|
|
118
|
-
attempt,
|
|
119
|
-
nextAttempt: attempt + 1,
|
|
120
|
-
classification,
|
|
121
|
-
stoppedBy: undefined,
|
|
122
|
-
};
|
|
37
|
+
function withinDeadline(policy: RetryPolicy, ctx: Ctx | undefined): RetryPolicy {
|
|
38
|
+
if (ctx === undefined || ctx.deadlineAt === null) return policy;
|
|
39
|
+
const left = ctx.deadlineAt - ctx.now().getTime();
|
|
40
|
+
const timeBudgetMs =
|
|
41
|
+
policy.timeBudgetMs === undefined ? left : Math.min(policy.timeBudgetMs, left);
|
|
42
|
+
return { ...policy, timeBudgetMs };
|
|
123
43
|
}
|
|
124
44
|
|
|
125
45
|
/**
|
|
126
|
-
* Run `work` until it succeeds, the policy runs out, the classification says stop,
|
|
127
|
-
*
|
|
46
|
+
* Run `work` until it succeeds, the policy runs out, the classification says stop, the time budget
|
|
47
|
+
* does, or the ambient caller is gone. Ambient and not an option, because a required `signal` is
|
|
48
|
+
* the argument an agent forgets, and after the budget a forgotten one keeps sending to a vendor.
|
|
128
49
|
*
|
|
129
|
-
* The
|
|
130
|
-
*
|
|
131
|
-
*
|
|
50
|
+
* The one place a retry loop meets its caller's lifetime. Work that must outlive its caller runs
|
|
51
|
+
* inside `runToCompletion(fn)`, where the signal is live and the deadline null, so nothing clamps;
|
|
52
|
+
* `retry()` has no opt-out of its own. The last error, or the signal's reason when no attempt ran,
|
|
53
|
+
* reaches the caller unchanged.
|
|
132
54
|
*/
|
|
133
55
|
export async function retry<T>(
|
|
134
56
|
work: (attempt: number) => Promise<T>,
|
|
135
57
|
policy: RetryPolicy,
|
|
136
|
-
deps: RetryDeps,
|
|
58
|
+
deps: RetryDeps = {},
|
|
137
59
|
): Promise<T> {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
policy.timeBudgetMs === undefined
|
|
146
|
-
? undefined
|
|
147
|
-
: finiteOption('a retry policy', 'timeBudgetMs', policy.timeBudgetMs);
|
|
148
|
-
const now = deps.now ?? ((): number => systemClock.monotonic());
|
|
149
|
-
// Read once even when no budget is set: a clock call per attempt would be a cost the common case
|
|
150
|
-
// does not owe. `startedAt` is only compared against when `timeBudgetMs` is present.
|
|
151
|
-
const startedAt = now();
|
|
152
|
-
|
|
153
|
-
for (let attempt = 1; ; attempt += 1) {
|
|
154
|
-
try {
|
|
155
|
-
return await work(attempt);
|
|
156
|
-
} catch (error) {
|
|
157
|
-
const decision = retryDecision(policy, attempt, error, deps.random);
|
|
158
|
-
if (!decision.retry) throw error;
|
|
159
|
-
// Decided BEFORE the wait, never after: a loop that sleeps and then discovers it is out of
|
|
160
|
-
// budget has already spent the caller's deadline on a wait nobody could use.
|
|
161
|
-
if (budget !== undefined && now() - startedAt + decision.delayMs > budget) throw error;
|
|
162
|
-
await deps.sleep(decision.delayMs);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
60
|
+
const ctx = tryUseContext();
|
|
61
|
+
return await retryLoop(work, withinDeadline(policy, ctx), {
|
|
62
|
+
sleep: deps.sleep ?? abortableTimer,
|
|
63
|
+
now: deps.now,
|
|
64
|
+
random: deps.random,
|
|
65
|
+
signal: ctx?.signal,
|
|
66
|
+
});
|
|
165
67
|
}
|
package/src/roles.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const DEFAULT_ROLE: Role = 'web';
|
|
|
13
13
|
export type ScalingSignal =
|
|
14
14
|
| 'rps'
|
|
15
15
|
| 'ws-connections'
|
|
16
|
-
| 'queue-
|
|
16
|
+
| 'queue-demand'
|
|
17
17
|
| 'singleton'
|
|
18
18
|
| 'run-once'
|
|
19
19
|
| 'per-database';
|
|
@@ -29,7 +29,7 @@ export interface RoleInfo {
|
|
|
29
29
|
export const ROLE_INFO = Object.freeze<Record<Role, RoleInfo>>({
|
|
30
30
|
web: { role: 'web', scalesOn: 'rps', maxReplicas: null, stateful: false },
|
|
31
31
|
sync: { role: 'sync', scalesOn: 'ws-connections', maxReplicas: null, stateful: false },
|
|
32
|
-
worker: { role: 'worker', scalesOn: 'queue-
|
|
32
|
+
worker: { role: 'worker', scalesOn: 'queue-demand', maxReplicas: null, stateful: false },
|
|
33
33
|
scheduler: { role: 'scheduler', scalesOn: 'singleton', maxReplicas: 1, stateful: false },
|
|
34
34
|
migrate: { role: 'migrate', scalesOn: 'run-once', maxReplicas: 1, stateful: false },
|
|
35
35
|
replicator: { role: 'replicator', scalesOn: 'per-database', maxReplicas: 1, stateful: true },
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Single responsibility: the URL's `:param` values merged into a request's input, and the one
|
|
2
|
+
// refusal when the two disagree (`X_HTTP_PATH_INPUT_CONFLICT`). Server-side only: the template
|
|
3
|
+
// helpers it reads are `route-path-template.ts`, which a browser client loads without this.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from './errors';
|
|
6
|
+
import { templateParams } from './route-path-template';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The URL disagreed with the body (or search string) about one key. Never resolved by letting
|
|
10
|
+
* either win: a policy reading one id while the handler reads another is the bug this refuses.
|
|
11
|
+
*/
|
|
12
|
+
export class HttpPathInputConflictError extends UltimateError {
|
|
13
|
+
constructor(input: { readonly owner: string; readonly template: string; readonly key: string }) {
|
|
14
|
+
super({
|
|
15
|
+
code: 'X_HTTP_PATH_INPUT_CONFLICT',
|
|
16
|
+
cause: `${input.owner} is served at ${input.template}, and the request sent ${input.key} both as the URL's :${input.key} and, with a different value, in its body or query string`,
|
|
17
|
+
fix: `{} — the body (or query string) without ${input.key}: the URL already carries it; or send the identical value in both`,
|
|
18
|
+
meta: { key: input.key, path: input.template },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The input `invoke`/`runQuery` parses: the template's params, taken from the router's DECODED
|
|
25
|
+
* `params`, merged into `raw`. A key both carry must agree exactly, else
|
|
26
|
+
* `X_HTTP_PATH_INPUT_CONFLICT`. A `raw` that is not a keyed object is handed on untouched — the
|
|
27
|
+
* input's own schema refuses it with the code every surface answers.
|
|
28
|
+
*/
|
|
29
|
+
export function mergePathParams(input: {
|
|
30
|
+
readonly owner: string;
|
|
31
|
+
readonly template: string;
|
|
32
|
+
readonly params: Readonly<Record<string, string>>;
|
|
33
|
+
readonly raw: unknown;
|
|
34
|
+
}): unknown {
|
|
35
|
+
const names = templateParams(input.template);
|
|
36
|
+
if (names.length === 0) return input.raw;
|
|
37
|
+
const { raw } = input;
|
|
38
|
+
if (raw !== undefined && raw !== null && (typeof raw !== 'object' || Array.isArray(raw))) {
|
|
39
|
+
return raw;
|
|
40
|
+
}
|
|
41
|
+
const fields: Record<string, unknown> = { ...(raw ?? {}) };
|
|
42
|
+
for (const name of names) {
|
|
43
|
+
const fromUrl = input.params[name];
|
|
44
|
+
if (fromUrl === undefined) continue;
|
|
45
|
+
if (Object.hasOwn(fields, name) && fields[name] !== fromUrl) {
|
|
46
|
+
throw new HttpPathInputConflictError({
|
|
47
|
+
owner: input.owner,
|
|
48
|
+
template: input.template,
|
|
49
|
+
key: name,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
fields[name] = fromUrl;
|
|
53
|
+
}
|
|
54
|
+
return fields;
|
|
55
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Single responsibility: an `http.path` TEMPLATE (`/orders/:id/refund`) — shape, params, the input
|
|
2
|
+
// keys they must name, filling, OpenAPI spelling. Here because `@ultimat3/action` and `@ultimat3/query`
|
|
3
|
+
// share a tier. Imports nothing, so a browser client fills a URL without an error class; the merge
|
|
4
|
+
// and its refusal are `route-path-merge.ts`.
|
|
5
|
+
|
|
6
|
+
/** Lowercase static segments or `:camelName` params; no wildcard, no trailing slash. */
|
|
7
|
+
const PATH_TEMPLATE = /^(\/([a-z0-9][a-z0-9._~-]*|:[a-z][A-Za-z0-9]*))+$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Why `path` cannot be a pin, or `undefined` when it can. A reason, not a throw: each package
|
|
11
|
+
* refuses with its own code (`X_ACTION_HTTP_PATH_INVALID`, `X_QUERY_HTTP_PATH_INVALID`).
|
|
12
|
+
*/
|
|
13
|
+
export function pathTemplateProblem(path: unknown): string | undefined {
|
|
14
|
+
if (typeof path !== 'string') return 'is not a string';
|
|
15
|
+
// Before the pattern, which refuses `_` too: the namespace is the reason a reader can act on.
|
|
16
|
+
if (path === '/_x' || path.startsWith('/_x/')) return 'claims /_x, the framework namespace';
|
|
17
|
+
if (!PATH_TEMPLATE.test(path)) {
|
|
18
|
+
return 'is not a lowercase path of static segments and :camelCase params (an uppercase letter, a *wildcard or a trailing slash)';
|
|
19
|
+
}
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
for (const param of templateParams(path)) {
|
|
22
|
+
if (seen.has(param)) return `repeats :${param}, and one URL cannot carry two values for it`;
|
|
23
|
+
seen.add(param);
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `/orgs/:orgId/posts/:postId` → `['orgId', 'postId']`, in order. */
|
|
29
|
+
export function templateParams(path: string): readonly string[] {
|
|
30
|
+
return path
|
|
31
|
+
.split('/')
|
|
32
|
+
.filter((segment) => segment.startsWith(':'))
|
|
33
|
+
.map((segment) => segment.slice(1));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `:id` → `{id}`: OpenAPI's spelling of the same template. A static path is unchanged. */
|
|
37
|
+
export function toOpenApiPath(path: string): string {
|
|
38
|
+
return path.replace(/:([a-z][A-Za-z0-9]*)/g, '{$1}');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The slice of an introspected input member the key rule reads — `@ultimat3/schema`'s node. */
|
|
42
|
+
export interface TemplateKeyNode {
|
|
43
|
+
readonly kind?: string;
|
|
44
|
+
readonly optional?: boolean | undefined;
|
|
45
|
+
readonly hasDefault?: boolean | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Why `params` cannot be filled from an input with these top-level `properties`, or `undefined`.
|
|
50
|
+
* Every param must be a REQUIRED string key: the value is merged into the input before it is
|
|
51
|
+
* parsed, so the input's own schema validates it — an optional key would be a URL with a hole.
|
|
52
|
+
*/
|
|
53
|
+
export function templateKeyProblem(
|
|
54
|
+
params: readonly string[],
|
|
55
|
+
properties: Readonly<Record<string, TemplateKeyNode>> | undefined,
|
|
56
|
+
): string | undefined {
|
|
57
|
+
if (params.length === 0) return undefined;
|
|
58
|
+
if (properties === undefined) {
|
|
59
|
+
return 'names a :param, and input: cannot be introspected to prove it carries that key';
|
|
60
|
+
}
|
|
61
|
+
for (const param of params) {
|
|
62
|
+
const node = Object.hasOwn(properties, param) ? properties[param] : undefined;
|
|
63
|
+
if (node === undefined) return `names :${param}, which is not a key of input:`;
|
|
64
|
+
if (node.kind !== 'string') return `names :${param}, whose input: member is not a string`;
|
|
65
|
+
if (node.optional === true || node.hasDefault === true) {
|
|
66
|
+
return `names :${param}, whose input: member is optional — a URL cannot omit a segment`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type FilledTemplate =
|
|
73
|
+
| { readonly ok: true; readonly path: string }
|
|
74
|
+
| {
|
|
75
|
+
readonly ok: false;
|
|
76
|
+
/** The `:param` that could not be filled. */
|
|
77
|
+
readonly missing: string;
|
|
78
|
+
/** Why, as a phrase that follows the param's name: `:id <reason>`. */
|
|
79
|
+
readonly reason: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `.` and `..` survive `encodeURIComponent` untouched, and a URL resolver (`fetch`, `new URL`)
|
|
84
|
+
* walks them — `/posts/../edit` is `/edit` — as it does their `%2E` spellings, so no encoding
|
|
85
|
+
* keeps one inside its segment. Refused, never sent.
|
|
86
|
+
*/
|
|
87
|
+
const DOT_SEGMENTS: ReadonlySet<string> = new Set(['.', '..']);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The template with every `:param` replaced by `input[param]`, `encodeURIComponent`-ed so a `/` in
|
|
91
|
+
* a value stays inside its one segment — the router decodes it back into the same one param.
|
|
92
|
+
* A missing, empty or non-string value, or a dot-segment, is named rather than spelled into a
|
|
93
|
+
* different URL.
|
|
94
|
+
*/
|
|
95
|
+
export function fillTemplate(path: string, input: unknown): FilledTemplate {
|
|
96
|
+
const values = typeof input === 'object' && input !== null ? input : {};
|
|
97
|
+
const out: string[] = [];
|
|
98
|
+
for (const segment of path.split('/')) {
|
|
99
|
+
if (!segment.startsWith(':')) {
|
|
100
|
+
out.push(segment);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const name = segment.slice(1);
|
|
104
|
+
const value = Object.hasOwn(values, name)
|
|
105
|
+
? (values as Record<string, unknown>)[name]
|
|
106
|
+
: undefined;
|
|
107
|
+
if (typeof value !== 'string' || value === '') {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
missing: name,
|
|
111
|
+
reason: 'needs a non-empty string, and the input has none',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (DOT_SEGMENTS.has(value)) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
missing: name,
|
|
118
|
+
reason: `is '${value}', a dot-segment the URL resolver would walk out of its one segment`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
out.push(encodeURIComponent(value));
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, path: out.join('/') };
|
|
124
|
+
}
|
package/src/runtime-metrics.ts
CHANGED
|
@@ -17,7 +17,9 @@ import type { ScalingSignal } from './roles';
|
|
|
17
17
|
export const SCALING_METRICS = Object.freeze<Record<ScalingSignal, string | null>>({
|
|
18
18
|
rps: 'http_requests_total',
|
|
19
19
|
'ws-connections': 'connections',
|
|
20
|
-
|
|
20
|
+
// Not `queue_depth`, the backlog: ready-only, it reads 0 while a fleet holds every job it has, and
|
|
21
|
+
// the HPA shrinks the fleet mid-job. `queue_demand` is held plus due (`@ultimat3/jobs`'s gauge).
|
|
22
|
+
'queue-demand': 'queue_demand',
|
|
21
23
|
singleton: null,
|
|
22
24
|
'run-once': null,
|
|
23
25
|
'per-database': null,
|
|
@@ -99,3 +101,16 @@ export function recordJob(queue: string, outcome: 'ok' | 'failed' | 'dead'): voi
|
|
|
99
101
|
export function recordLeaseLost(queue: string): void {
|
|
100
102
|
leasesLost.add(1, { queue });
|
|
101
103
|
}
|
|
104
|
+
|
|
105
|
+
export const breakerTransitions: Counter = counter('breaker_transitions_total', {
|
|
106
|
+
unit: '{transition}',
|
|
107
|
+
description: 'Circuit breaker state changes, by breaker name and the state entered',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One per state change of a `breaker()`, called only by `breaker.ts`. `name` is a label the app
|
|
112
|
+
* chose in code (bounded), never a tenant or a URL, so the series count is the breaker count × 3.
|
|
113
|
+
*/
|
|
114
|
+
export function recordBreakerTransition(name: string, to: 'closed' | 'open' | 'half-open'): void {
|
|
115
|
+
breakerTransitions.add(1, { name, to });
|
|
116
|
+
}
|
package/src/same-origin.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
/** The complete `Sec-Fetch-Site` vocabulary. Anything else was written by a non-browser. */
|
|
7
7
|
const KNOWN_SITES = new Set(['same-origin', 'same-site', 'cross-site', 'none']);
|
|
8
8
|
|
|
9
|
+
/** What `Origin` carries, and `URL.origin` answers, when there is no origin to name. */
|
|
10
|
+
const OPAQUE_ORIGIN = 'null';
|
|
11
|
+
|
|
9
12
|
export interface OriginEvidence {
|
|
10
13
|
/** The origins this app is reached on. More than one when the scheme is not knowable here. */
|
|
11
14
|
readonly selfOrigins: readonly string[];
|
|
@@ -27,13 +30,20 @@ export type OriginVerdict =
|
|
|
27
30
|
* `Origin` second, so an app that lists a sibling origin keeps working. `same-site` is NOT proof:
|
|
28
31
|
* a sibling subdomain is same-site, and a `SameSite=Lax` cookie rides its requests. A request with
|
|
29
32
|
* neither header is refused — "we could not tell" is the case this exists for.
|
|
33
|
+
*
|
|
34
|
+
* `Origin: null` matches nothing, whatever a list holds: a sandboxed iframe, a `data:` page and a
|
|
35
|
+
* cross-origin redirect all send it, so it names everybody. It reaches a list by accident too —
|
|
36
|
+
* `URL.parse('localhost:3000').origin` is that same string.
|
|
30
37
|
*/
|
|
31
38
|
export function proveSameOrigin(evidence: OriginEvidence): OriginVerdict {
|
|
32
39
|
const site = evidence.secFetchSite;
|
|
33
40
|
if (site === 'same-origin' || site === 'none') return { ok: true };
|
|
34
41
|
const origin = evidence.origin;
|
|
35
|
-
|
|
36
|
-
if (origin !== null &&
|
|
42
|
+
const opaque = origin === OPAQUE_ORIGIN;
|
|
43
|
+
if (origin !== null && !opaque) {
|
|
44
|
+
if (evidence.selfOrigins.includes(origin)) return { ok: true };
|
|
45
|
+
if (evidence.listed(origin)) return { ok: true };
|
|
46
|
+
}
|
|
37
47
|
// Only the four values a browser can send are quoted back. Anything else is a client that
|
|
38
48
|
// wrote the header itself, and echoing what it wrote is how a rejected value reaches the log
|
|
39
49
|
// store and the response body.
|
|
@@ -41,6 +51,12 @@ export function proveSameOrigin(evidence: OriginEvidence): OriginVerdict {
|
|
|
41
51
|
const known = KNOWN_SITES.has(site) ? site : 'a value no browser sends';
|
|
42
52
|
return { ok: false, reason: `the request reported sec-fetch-site: ${known}` };
|
|
43
53
|
}
|
|
54
|
+
if (opaque) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
reason: 'the request declared the opaque origin null, which no list can vouch for',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
44
60
|
return {
|
|
45
61
|
ok: false,
|
|
46
62
|
reason:
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "This page's session is gone", said once. A tab past its session otherwise asks for a 401
|
|
3
|
+
* forever; every poll and every scheduled refresh reads this one flag and stops. The flag lives on
|
|
4
|
+
* the page handle (`record-sink.ts`) because every island chunk carries its own copy of core.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { onRescope } from './client-scope';
|
|
8
|
+
import { sessionLostCell } from './record-sink';
|
|
9
|
+
|
|
10
|
+
/** The two codes that mean the credential this page was rendered with no longer resolves. */
|
|
11
|
+
const LOSS_CODES: ReadonlySet<string> = new Set(['X_UNAUTHENTICATED', 'X_SESSION_EXPIRED']);
|
|
12
|
+
|
|
13
|
+
/** Whether a refusal has said this page's session is gone. `rescope()` clears it. */
|
|
14
|
+
export function sessionIsLost(): boolean {
|
|
15
|
+
return sessionLostCell().lost;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Called once per page when the session is lost; at once for a subscriber that arrives after.
|
|
20
|
+
* Returns the unsubscribe. The sign-in UI is the app's: the framework says "lost", nothing more.
|
|
21
|
+
*/
|
|
22
|
+
export function onSessionLost(fn: () => void): () => void {
|
|
23
|
+
const cell = sessionLostCell();
|
|
24
|
+
if (cell.lost) {
|
|
25
|
+
fn();
|
|
26
|
+
return (): void => {};
|
|
27
|
+
}
|
|
28
|
+
// Wrapped, so one function subscribed twice is two subscriptions with two unsubscribes.
|
|
29
|
+
const entry = (): void => fn();
|
|
30
|
+
cell.listeners.add(entry);
|
|
31
|
+
return (): void => {
|
|
32
|
+
cell.listeners.delete(entry);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Internal, the pollers': `start` runs while the page holds a session — now, unless it is already
|
|
38
|
+
* lost — and its returned stop is called on a loss. The next rescope to a signed-in principal
|
|
39
|
+
* starts it again, fresh: a poll stopped by a 401 is not one stopped for good once the visitor
|
|
40
|
+
* signs back in. A rescope to `null` keeps waiting, since an anonymous page's 401 never stops it.
|
|
41
|
+
* Returns the idempotent disposer, which stops a running `start` too.
|
|
42
|
+
*/
|
|
43
|
+
export function whileSessionHeld(start: () => () => void): () => void {
|
|
44
|
+
let stopRun: (() => void) | undefined;
|
|
45
|
+
let offLost = (): void => {};
|
|
46
|
+
let disposed = false;
|
|
47
|
+
const pause = (): void => {
|
|
48
|
+
const stop = stopRun;
|
|
49
|
+
stopRun = undefined;
|
|
50
|
+
stop?.();
|
|
51
|
+
};
|
|
52
|
+
const begin = (): void => {
|
|
53
|
+
stopRun = start();
|
|
54
|
+
offLost = onSessionLost(pause);
|
|
55
|
+
};
|
|
56
|
+
const offScope = onRescope((next) => {
|
|
57
|
+
if (stopRun === undefined && typeof next.principal === 'string') begin();
|
|
58
|
+
});
|
|
59
|
+
if (!sessionIsLost()) begin();
|
|
60
|
+
return (): void => {
|
|
61
|
+
if (disposed) return;
|
|
62
|
+
disposed = true;
|
|
63
|
+
offScope();
|
|
64
|
+
offLost();
|
|
65
|
+
pause();
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The refusal as `client-dispatch.ts` saw it: the answer's status, its URL and its code. */
|
|
70
|
+
export interface Refusal {
|
|
71
|
+
readonly status: number;
|
|
72
|
+
readonly url: string;
|
|
73
|
+
readonly code: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Internal, `client-dispatch.ts`'s: a refusal answered to a page acting for `principal`. Only a
|
|
78
|
+
* page that HAD a principal can lose one — an anonymous page's 401 is a wrong password — and only
|
|
79
|
+
* a 401 from this page's own origin speaks for the cookie it was rendered with: another origin's
|
|
80
|
+
* 401 is about that origin's credential.
|
|
81
|
+
*/
|
|
82
|
+
export function noteRefusal(refusal: Refusal, principal: string | null | undefined): void {
|
|
83
|
+
if (typeof principal !== 'string' || refusal.status !== 401) return;
|
|
84
|
+
if (!LOSS_CODES.has(refusal.code) || !isPageOrigin(refusal.url)) return;
|
|
85
|
+
const cell = sessionLostCell();
|
|
86
|
+
if (cell.lost) return;
|
|
87
|
+
cell.lost = true;
|
|
88
|
+
const listeners = [...cell.listeners];
|
|
89
|
+
cell.listeners.clear();
|
|
90
|
+
for (const listener of listeners) {
|
|
91
|
+
try {
|
|
92
|
+
listener();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// Re-raised off this stack: the caller is mid-refusal, and a listener's bug must neither
|
|
95
|
+
// replace that refusal nor silence the listeners after it. The page's error hook sees it.
|
|
96
|
+
queueMicrotask(() => {
|
|
97
|
+
throw error;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Stands in for the page's origin where there is none (a server, a test): only a relative URL is. */
|
|
104
|
+
const NO_ORIGIN = 'http://page-origin.invalid';
|
|
105
|
+
|
|
106
|
+
/** Whether `url` resolves to the page's origin; relative URLs always do. */
|
|
107
|
+
function isPageOrigin(url: string): boolean {
|
|
108
|
+
const location: unknown = Reflect.get(globalThis, 'location');
|
|
109
|
+
const stated =
|
|
110
|
+
typeof location === 'object' && location !== null ? Reflect.get(location, 'origin') : undefined;
|
|
111
|
+
// `'null'` is an opaque origin (a sandboxed frame): no absolute URL can match it.
|
|
112
|
+
const base = typeof stated === 'string' && stated !== 'null' ? stated : NO_ORIGIN;
|
|
113
|
+
if (!URL.canParse(url, base)) return false;
|
|
114
|
+
return new URL(url, base).origin === new URL(base).origin;
|
|
115
|
+
}
|