@ultimat3/core 29.4.0 → 30.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CLAUDE.md +45 -81
  2. package/README.md +137 -10
  3. package/package.json +19 -3
  4. package/src/address-class.ts +53 -12
  5. package/src/aws-sigv4.ts +104 -8
  6. package/src/breaker.ts +177 -0
  7. package/src/client-dispatch.ts +76 -17
  8. package/src/client-flight.ts +4 -4
  9. package/src/client-paths-pins.ts +92 -0
  10. package/src/client-problem.ts +25 -1
  11. package/src/client-scope.ts +3 -1
  12. package/src/client-stream.ts +106 -0
  13. package/src/client-transport.ts +16 -2
  14. package/src/config-assets.ts +64 -0
  15. package/src/config-client-errors.ts +132 -0
  16. package/src/config-defaults.ts +18 -4
  17. package/src/config-failure-capture.ts +70 -0
  18. package/src/config-health.ts +8 -0
  19. package/src/config-islands.ts +1 -1
  20. package/src/config-keys.ts +2 -0
  21. package/src/config-site.ts +16 -0
  22. package/src/config-time.ts +40 -0
  23. package/src/config.ts +37 -26
  24. package/src/context.ts +92 -10
  25. package/src/core-error-codes.ts +19 -0
  26. package/src/cursor.ts +7 -3
  27. package/src/deadline-signal.ts +57 -0
  28. package/src/declared-errors-openapi.ts +70 -0
  29. package/src/declared-errors-scan.ts +79 -0
  30. package/src/declared-errors.ts +185 -0
  31. package/src/define-error.ts +200 -0
  32. package/src/deprecation.ts +14 -3
  33. package/src/drain-deadline.ts +22 -0
  34. package/src/env.ts +8 -3
  35. package/src/error-fingerprint.ts +95 -0
  36. package/src/error-reporter-chain.ts +45 -0
  37. package/src/error-reporter-sentry.ts +4 -0
  38. package/src/error-reporter.ts +14 -1
  39. package/src/error-status-declared.ts +82 -0
  40. package/src/exercise-errors.ts +41 -0
  41. package/src/exercise-run.ts +176 -0
  42. package/src/exports/error-contract.ts +7 -0
  43. package/src/exports/observability.ts +26 -0
  44. package/src/failure-capture-flush.ts +93 -0
  45. package/src/failure-capture-mask.ts +54 -0
  46. package/src/failure-capture.ts +205 -0
  47. package/src/fill-wire.ts +30 -0
  48. package/src/finite-option.ts +7 -0
  49. package/src/health-disclosure.ts +2 -2
  50. package/src/hmac.ts +47 -0
  51. package/src/index.ts +172 -9
  52. package/src/json-storable.ts +21 -0
  53. package/src/keyed-fingerprint.ts +3 -7
  54. package/src/lifecycle.ts +5 -1
  55. package/src/logger.ts +1 -1
  56. package/src/otlp-metric-exporter.ts +7 -1
  57. package/src/otlp-post.ts +147 -0
  58. package/src/otlp-span-exporter.ts +7 -1
  59. package/src/otlp.ts +4 -37
  60. package/src/outbound-attempt.ts +235 -0
  61. package/src/outbound-dial.ts +103 -0
  62. package/src/outbound-errors.ts +193 -0
  63. package/src/outbound-hops.ts +135 -0
  64. package/src/outbound-read.ts +103 -0
  65. package/src/outbound-subpath.ts +26 -0
  66. package/src/outbound-types.ts +108 -0
  67. package/src/outbound.ts +223 -0
  68. package/src/page-meta.ts +8 -0
  69. package/src/page.ts +9 -0
  70. package/src/record-envelope-openapi.ts +6 -0
  71. package/src/record-envelope.ts +33 -9
  72. package/src/record-sink.ts +13 -0
  73. package/src/redis-client.ts +78 -0
  74. package/src/registrar.ts +11 -0
  75. package/src/render-node.ts +13 -0
  76. package/src/render-nodes.ts +137 -0
  77. package/src/retry-loop.ts +197 -0
  78. package/src/retry.ts +47 -145
  79. package/src/roles.ts +2 -2
  80. package/src/route-path-merge.ts +55 -0
  81. package/src/route-path-template.ts +124 -0
  82. package/src/runtime-metrics.ts +16 -1
  83. package/src/same-origin.ts +18 -2
  84. package/src/session-lost.ts +115 -0
  85. package/src/single-flight.ts +75 -40
  86. package/src/source-mask.ts +36 -2
  87. package/src/tool-description.ts +29 -0
  88. package/src/visible-interval.ts +153 -0
  89. 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. The executor
2
- // `error-retry.ts` never had — that module declares `terminal | retryable | retry-after` and
3
- // nothing in the tree consulted it before deciding to try again, so four packages each shipped
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
- type BackoffCurve,
8
- backoffDelay,
9
- type JitterMode,
10
- jitterStatedDelay,
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): Promise<void>;
46
- /** Monotonic ms, read only when `timeBudgetMs` is set. Defaults to the system clock. */
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
- /** Why the loop stopped. Absent while it is still retrying. */
52
- export type RetryStopReason = 'terminal' | 'attempts-exhausted' | 'budget-exhausted';
53
-
54
- export interface RetryDecision {
55
- readonly retry: boolean;
56
- readonly delayMs: number;
57
- readonly attempt: number;
58
- readonly nextAttempt: number;
59
- /** The classification consulted, or `undefined` when nobody classified the thrown code. */
60
- readonly classification: ErrorRetry | undefined;
61
- readonly stoppedBy: RetryStopReason | undefined;
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
- * Retry, stop, and when — pure, so a caller can print the schedule and a test can pin it without a
66
- * loop. `terminal` stops on the attempt that failed: the same code run again is the same answer,
67
- * and the attempts left are a queue slot, a provider bill, or three more wrong passwords at a site
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
- export function retryDecision(
72
- policy: RetryPolicy,
73
- attempt: number,
74
- error: unknown,
75
- random?: Random,
76
- ): RetryDecision {
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, or the time
127
- * budget does. `work` is handed the 1-based attempt number.
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 LAST error reaches the caller unchanged — never wrapped. An `UltimateError` carries a code, a
130
- * cause and a runnable `fix:`, and a wrapper would replace all three with the fact that something
131
- * was retried, which no reader can act on.
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
- // Both bounds are refused BEFORE the first try: a policy that cannot stop the loop is a defect in
139
- // the call, and running the work once first would report it as the work's own failure.
140
- // `finiteOption` for the budget, not `finiteCount`: it is a duration a caller computes from a
141
- // monotonic clock, so a fraction is real and a spent (negative) one means "do not wait at all".
142
- // Zero stays legal and means what it always did — one try, no retry (`retry.test.ts` pins it).
143
- finiteCount('a retry policy', 'attempts', policy.attempts);
144
- const budget =
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-depth'
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-depth', maxReplicas: null, stateful: false },
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
+ }
@@ -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
- 'queue-depth': 'queue_depth',
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
+ }
@@ -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
- if (origin !== null && evidence.selfOrigins.includes(origin)) return { ok: true };
36
- if (origin !== null && evidence.listed(origin)) return { ok: true };
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
+ }