@ultimat3/core 20.2.1 → 22.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 +189 -523
- package/README.md +62 -1
- package/package.json +6 -3
- package/src/actor.ts +14 -0
- package/src/address-class.ts +143 -0
- package/src/async-state.ts +19 -0
- package/src/canonical-json.ts +24 -1
- package/src/client-dispatch.ts +163 -0
- package/src/client-flight.ts +22 -3
- package/src/client-paths.ts +79 -0
- package/src/client-problem.ts +94 -0
- package/src/client-scope-error.ts +18 -0
- package/src/client-scope.ts +59 -0
- package/src/client-transport.ts +86 -0
- package/src/config-count.ts +19 -0
- package/src/config-fixes.ts +23 -0
- package/src/config-merge.ts +36 -0
- package/src/config.ts +122 -65
- package/src/conflict-policy.ts +48 -0
- package/src/context.ts +12 -1
- package/src/core-error-codes.ts +78 -0
- package/src/dev-secrets.ts +45 -0
- package/src/error-codes.ts +17 -66
- package/src/error-retry.ts +3 -0
- package/src/exports/error-contract.ts +2 -2
- package/src/exports/secrets.ts +1 -0
- package/src/generation-fence.ts +9 -2
- package/src/host-rules.ts +71 -0
- package/src/image/exif-orientation.ts +40 -0
- package/src/image/probe.ts +13 -1
- package/src/in-process-fetch.ts +39 -0
- package/src/index.ts +80 -30
- package/src/iso-date.ts +5 -0
- package/src/lifecycle-grace.ts +44 -0
- package/src/lifecycle-signals.ts +35 -0
- package/src/lifecycle.ts +44 -36
- package/src/logger.ts +22 -3
- package/src/measurement-actor.ts +52 -0
- package/src/metrics-text.ts +10 -2
- package/src/otlp-metric-exporter.ts +39 -12
- package/src/otlp-span-exporter.ts +38 -15
- package/src/outbound-headers.ts +16 -0
- package/src/outbox-drain.ts +14 -0
- package/src/page-meta.ts +41 -0
- package/src/page.ts +52 -0
- package/src/pending-records.ts +58 -0
- package/src/record-envelope-openapi.ts +41 -0
- package/src/record-envelope.ts +98 -0
- package/src/record-sink.ts +129 -0
- package/src/schema-error-codes.ts +1 -1
- package/src/secrets-errors.ts +1 -1
- package/src/secrets-store.ts +51 -5
- package/src/service.ts +9 -0
- package/src/source-mask.ts +30 -0
- package/src/telemetry.ts +3 -0
- package/src/type-pins.ts +9 -0
- package/src/write-digest.ts +29 -0
- package/src/write-origin.ts +31 -0
- package/src/result.ts +0 -78
package/src/lifecycle.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { UltimateError } from './errors';
|
|
|
7
7
|
import { finiteCount } from './finite-option';
|
|
8
8
|
import { settleWithin } from './lifecycle-deadline';
|
|
9
9
|
import { lifecycleDrained } from './lifecycle-errors';
|
|
10
|
+
import { defaultReadinessGraceMs, readinessGraceIssue } from './lifecycle-grace';
|
|
10
11
|
import { type LogFields, type Logger, logger as rootLogger } from './logger';
|
|
11
12
|
|
|
12
13
|
export type HealthState = 'starting' | 'ready' | 'draining' | 'stopped';
|
|
@@ -54,6 +55,15 @@ export interface LifecycleOptions {
|
|
|
54
55
|
* Screened where it is assigned: a whole number of milliseconds, 0 or more. `0` is "drain now".
|
|
55
56
|
*/
|
|
56
57
|
readonly deadlineMs?: number | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* How long `/readyz` answers 503 BEFORE the `accept` phase closes the listener — the time the
|
|
60
|
+
* endpoints controller and the ingress need to stop routing here. Closing on the flip itself left
|
|
61
|
+
* endpoints pointing at a closed socket, and a POST in that window got a 502. Added to
|
|
62
|
+
* `deadlineMs`, never taken from it. Unset: `defaultReadinessGraceMs()` of the process env at
|
|
63
|
+
* drain time — 0 in development/test, 5000 everywhere else, including a process naming no env.
|
|
64
|
+
* A whole number from 0 to 60000; 0 is no grace.
|
|
65
|
+
*/
|
|
66
|
+
readonly readinessGraceMs?: number | undefined;
|
|
57
67
|
readonly clock?: Clock | undefined;
|
|
58
68
|
readonly logger?: Logger | undefined;
|
|
59
69
|
}
|
|
@@ -111,6 +121,8 @@ interface Registration {
|
|
|
111
121
|
const DEFAULT_DEADLINE_MS = 25_000;
|
|
112
122
|
|
|
113
123
|
let deadlineMs = DEFAULT_DEADLINE_MS;
|
|
124
|
+
/** `undefined` means "the environment's default", read when a drain starts, not at import. */
|
|
125
|
+
let graceMs: number | undefined;
|
|
114
126
|
let clock: Clock = systemClock;
|
|
115
127
|
let log: Logger = rootLogger;
|
|
116
128
|
let state: HealthState = 'starting';
|
|
@@ -131,6 +143,18 @@ export function configureLifecycle(options: LifecycleOptions): void {
|
|
|
131
143
|
if (options.deadlineMs !== undefined) {
|
|
132
144
|
deadlineMs = finiteCount('configureLifecycle', 'deadlineMs', options.deadlineMs, 0);
|
|
133
145
|
}
|
|
146
|
+
if (options.readinessGraceMs !== undefined) {
|
|
147
|
+
const issue = readinessGraceIssue(options.readinessGraceMs);
|
|
148
|
+
if (issue !== undefined) {
|
|
149
|
+
throw new UltimateError({
|
|
150
|
+
code: 'X_CONFIG_INVALID',
|
|
151
|
+
cause: issue,
|
|
152
|
+
fix: 'pass configureLifecycle({ readinessGraceMs: 5_000 }) — or set drain: { readinessGraceMs: 5_000 } in app.config.ts',
|
|
153
|
+
meta: { key: 'drain.readinessGraceMs' },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
graceMs = options.readinessGraceMs;
|
|
157
|
+
}
|
|
134
158
|
if (options.clock !== undefined) {
|
|
135
159
|
clock = options.clock;
|
|
136
160
|
startedAtMono = clock.monotonic();
|
|
@@ -316,6 +340,11 @@ export function drainDeadlineMs(): number {
|
|
|
316
340
|
return deadlineMs;
|
|
317
341
|
}
|
|
318
342
|
|
|
343
|
+
/** The grace the next drain will wait out — configured, else the environment's default. */
|
|
344
|
+
export function readinessGraceMs(): number {
|
|
345
|
+
return graceMs ?? defaultReadinessGraceMs();
|
|
346
|
+
}
|
|
347
|
+
|
|
319
348
|
/**
|
|
320
349
|
* What is left of that budget. Read per hook, not per phase: the deadline bounds the WHOLE drain,
|
|
321
350
|
* so a hook that spent it leaves nothing for the ones behind it — which is what
|
|
@@ -354,10 +383,20 @@ async function runPhase(phase: ShutdownPhase, reason: ShutdownReason): Promise<v
|
|
|
354
383
|
}
|
|
355
384
|
}
|
|
356
385
|
|
|
357
|
-
/**
|
|
358
|
-
|
|
386
|
+
/**
|
|
387
|
+
* The three phases, in order, under one budget. Never rejects — `drain()` depends on that.
|
|
388
|
+
*
|
|
389
|
+
* The readiness grace runs FIRST and OUTSIDE the budget: `state` is already `draining`, so
|
|
390
|
+
* `/readyz` answers 503 while the listener still accepts what was routed here before the flip.
|
|
391
|
+
* The deadline starts after it, so a chart's `terminationGracePeriodSeconds` must exceed
|
|
392
|
+
* `readinessGraceMs + deadlineMs`.
|
|
393
|
+
*/
|
|
394
|
+
async function runDrain(signal: string): Promise<void> {
|
|
359
395
|
try {
|
|
360
|
-
|
|
396
|
+
const grace = readinessGraceMs();
|
|
397
|
+
report('info', 'draining', { signal, deadlineMs, readinessGraceMs: grace, inflight });
|
|
398
|
+
if (grace > 0) await Bun.sleep(grace);
|
|
399
|
+
const reason: ShutdownReason = { signal, deadlineAt: systemClock.monotonic() + deadlineMs };
|
|
361
400
|
await runPhase('accept', reason);
|
|
362
401
|
|
|
363
402
|
// Real monotonic, like `deadlineAt` itself: `waitForIdle` sleeps on a real `setTimeout`, and
|
|
@@ -401,7 +440,6 @@ async function runDrain(signal: string, reason: ShutdownReason): Promise<void> {
|
|
|
401
440
|
export function drain(signal = 'manual'): Promise<void> {
|
|
402
441
|
if (drainPromise !== undefined) return drainPromise;
|
|
403
442
|
state = 'draining';
|
|
404
|
-
const reason: ShutdownReason = { signal, deadlineAt: systemClock.monotonic() + deadlineMs };
|
|
405
443
|
let published!: () => void;
|
|
406
444
|
drainPromise = new Promise<void>((resolve) => {
|
|
407
445
|
published = resolve;
|
|
@@ -409,41 +447,10 @@ export function drain(signal = 'manual'): Promise<void> {
|
|
|
409
447
|
// Both settle paths, for the reason `installSignalHandlers` gives below: `runDrain` cannot
|
|
410
448
|
// reject today — that is its `try/finally`, not luck — and a rejected memo would re-reject for
|
|
411
449
|
// every later caller and end the process the drain was trying to end cleanly.
|
|
412
|
-
void runDrain(signal
|
|
450
|
+
void runDrain(signal).then(published, published);
|
|
413
451
|
return drainPromise;
|
|
414
452
|
}
|
|
415
453
|
|
|
416
|
-
export interface SignalHandlerOptions {
|
|
417
|
-
readonly signals?: readonly ProcessSignal[] | undefined;
|
|
418
|
-
/** Call `process.exit()` once drained. Off in tests. */
|
|
419
|
-
readonly exit?: boolean | undefined;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/** Install SIGTERM/SIGINT handling. Returns an uninstall function. */
|
|
423
|
-
export function installSignalHandlers(options?: SignalHandlerOptions): () => void {
|
|
424
|
-
const signals: readonly ProcessSignal[] = options?.signals ?? ['SIGTERM', 'SIGINT'];
|
|
425
|
-
const handlers = new Map<ProcessSignal, () => void>();
|
|
426
|
-
|
|
427
|
-
for (const signal of signals) {
|
|
428
|
-
const handler = (): void => {
|
|
429
|
-
// Attached on BOTH settle paths, for the reason `settleWithin` gives: an unhandled rejection
|
|
430
|
-
// ends the process before the drain does, and the exit is what the kubelet is waiting for.
|
|
431
|
-
// `drain()` cannot reject today — that is the `try/finally` above, not luck — and this is
|
|
432
|
-
// the one line that keeps it true when someone changes the body.
|
|
433
|
-
const done = (): void => {
|
|
434
|
-
if (options?.exit === true) process.exit(0);
|
|
435
|
-
};
|
|
436
|
-
void drain(signal).then(done, done);
|
|
437
|
-
};
|
|
438
|
-
handlers.set(signal, handler);
|
|
439
|
-
process.on(signal, handler);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
return () => {
|
|
443
|
-
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
444
|
-
};
|
|
445
|
-
}
|
|
446
|
-
|
|
447
454
|
export function healthReport(): HealthReport {
|
|
448
455
|
const checks = readinessChecks();
|
|
449
456
|
return {
|
|
@@ -479,6 +486,7 @@ export function readyzPayload(): HealthPayload {
|
|
|
479
486
|
/** Test-only: forget all hooks and return to `starting`. */
|
|
480
487
|
export function resetLifecycle(): void {
|
|
481
488
|
deadlineMs = DEFAULT_DEADLINE_MS;
|
|
489
|
+
graceMs = undefined;
|
|
482
490
|
clock = systemClock;
|
|
483
491
|
log = rootLogger;
|
|
484
492
|
state = 'starting';
|
package/src/logger.ts
CHANGED
|
@@ -304,6 +304,22 @@ function resolveLevel(declared: LogLevel): LogLevel {
|
|
|
304
304
|
return declared;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/** The keys a line owns. A caller field spelled like one is renamed, never allowed to replace it. */
|
|
308
|
+
const RESERVED_KEYS: ReadonlySet<string> = new Set(['ts', 'level', 'msg']);
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The caller's fields with any reserved key moved to `field.<key>`. Spread after `level`, a field
|
|
312
|
+
* `{ level: 'debug' }` turned an `error` line into a `debug` one, so a level-filtered alert never
|
|
313
|
+
* saw it. Renamed rather than dropped: the value is still evidence.
|
|
314
|
+
*/
|
|
315
|
+
function unreserved(fields: Record<string, unknown>): Record<string, unknown> {
|
|
316
|
+
const out: Record<string, unknown> = {};
|
|
317
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
318
|
+
out[RESERVED_KEYS.has(key) ? `field.${key}` : key] = value;
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
307
323
|
export function createLogger(options?: LoggerOptions): Logger {
|
|
308
324
|
const level = options?.level === undefined ? envLevel() : resolveLevel(options.level);
|
|
309
325
|
const bound = options?.fields ?? {};
|
|
@@ -313,13 +329,16 @@ export function createLogger(options?: LoggerOptions): Logger {
|
|
|
313
329
|
|
|
314
330
|
function emit(lineLevel: LogLevel, message: string, fields?: LogFields): void {
|
|
315
331
|
if (LEVEL_WEIGHT[lineLevel] < threshold) return;
|
|
332
|
+
const caller = {
|
|
333
|
+
...redactFields(bound),
|
|
334
|
+
...redactFields(contextFields() ?? {}),
|
|
335
|
+
...redactFields(fields ?? {}),
|
|
336
|
+
};
|
|
316
337
|
const line = {
|
|
317
338
|
ts: timestamp(clock),
|
|
318
339
|
level: lineLevel,
|
|
319
340
|
msg: message,
|
|
320
|
-
...
|
|
321
|
-
...redactFields(contextFields() ?? {}),
|
|
322
|
-
...redactFields(fields ?? {}),
|
|
341
|
+
...unreserved(caller),
|
|
323
342
|
};
|
|
324
343
|
writer(renderLine(line, lineLevel, message, line.ts), lineLevel);
|
|
325
344
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The actor a route is rendered AS when the render exists only to be weighed — `x build`'s budget
|
|
2
|
+
// measurement, whose document is discarded. The default holds every permission and no app facts;
|
|
3
|
+
// a page that reads the app's own facts (`useActor()` over `actorFact`) failed `X_ACTOR_UNRESOLVED`
|
|
4
|
+
// under it, so the app declares its own here, from `app.config.ts`, with no import of the CLI.
|
|
5
|
+
|
|
6
|
+
import type { Actor } from './actor';
|
|
7
|
+
import { serviceActor } from './actor';
|
|
8
|
+
import { assert } from './assert';
|
|
9
|
+
|
|
10
|
+
/** The id every trace and log line under a default measurement render carries. */
|
|
11
|
+
export const MEASUREMENT_ACTOR_ID = 'x-build-measure';
|
|
12
|
+
|
|
13
|
+
/** What an app declares: the actor its authed pages render as while they are weighed. */
|
|
14
|
+
export type MeasurementActorFactory = () => Actor | Promise<Actor>;
|
|
15
|
+
|
|
16
|
+
let declared: MeasurementActorFactory | undefined;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Declare the measurement actor. Called at module scope in `app.config.ts`, the one file every
|
|
20
|
+
* build imports. The LAST declaration wins, because `x dev` re-imports that file on a save and a
|
|
21
|
+
* refusal there would be a reload that fails over a line nobody changed.
|
|
22
|
+
*
|
|
23
|
+
* Only a render that is WEIGHED AND DISCARDED uses it — never a published `site/` artifact, whose
|
|
24
|
+
* `load` must keep failing the build rather than render this actor's rows into a file for everyone.
|
|
25
|
+
*/
|
|
26
|
+
export function defineMeasurementActor(factory: MeasurementActorFactory): void {
|
|
27
|
+
declared = factory;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `kind: 'service'` and `'*'`: weighing bytes needs no data authority, and an app's own
|
|
32
|
+
* `requireMember()` has nothing to resolve for a service actor, which is the honest answer.
|
|
33
|
+
*/
|
|
34
|
+
const defaultMeasurementActor = (): Actor =>
|
|
35
|
+
serviceActor({ id: MEASUREMENT_ACTOR_ID, permissions: ['*'] });
|
|
36
|
+
|
|
37
|
+
/** The declared actor, or the framework's default. The build's ONE reader. */
|
|
38
|
+
export async function measurementActor(): Promise<Actor> {
|
|
39
|
+
if (declared === undefined) return defaultMeasurementActor();
|
|
40
|
+
const actor: Actor | undefined = await declared();
|
|
41
|
+
assert(
|
|
42
|
+
typeof actor === 'object' && actor !== null && typeof actor.id === 'string',
|
|
43
|
+
'the factory handed to defineMeasurementActor() answered no actor',
|
|
44
|
+
'return one from it in app.config.ts: defineMeasurementActor(() => userActor({ id: "measure", roles: ["member"] }))',
|
|
45
|
+
);
|
|
46
|
+
return actor;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Back to the default. For a test, and for nothing else. */
|
|
50
|
+
export function resetMeasurementActor(): void {
|
|
51
|
+
declared = undefined;
|
|
52
|
+
}
|
package/src/metrics-text.ts
CHANGED
|
@@ -9,7 +9,15 @@ export const METRICS_PATH = '/metrics';
|
|
|
9
9
|
|
|
10
10
|
export const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* HELP's two escapes, and only those: the exposition format defines `\\` and `\n` for a docstring.
|
|
14
|
+
* `\"` is a label-value escape — in HELP a parser keeps it as a literal backslash before the quote.
|
|
15
|
+
*/
|
|
16
|
+
function escapeHelp(value: string): string {
|
|
17
|
+
return value.replaceAll('\\', '\\\\').replaceAll('\n', '\\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A label VALUE escapes exactly these three, and nothing else. */
|
|
13
21
|
function escapeLabel(value: string): string {
|
|
14
22
|
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n');
|
|
15
23
|
}
|
|
@@ -60,7 +68,7 @@ function histogramLines(name: string, point: HistogramPoint): readonly string[]
|
|
|
60
68
|
function metricLines(metric: ReadableMetric): readonly string[] {
|
|
61
69
|
const { name, kind, description, unit } = metric.descriptor;
|
|
62
70
|
const help = unit === '1' || unit === '' ? description : `${description} (${unit})`;
|
|
63
|
-
const lines = [`# HELP ${name} ${
|
|
71
|
+
const lines = [`# HELP ${name} ${escapeHelp(help)}`, `# TYPE ${name} ${kind}`];
|
|
64
72
|
for (const point of metric.points) {
|
|
65
73
|
if (kind === 'histogram' && isHistogramPoint(point)) {
|
|
66
74
|
lines.push(...histogramLines(name, point));
|
|
@@ -122,7 +122,33 @@ export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): Otl
|
|
|
122
122
|
const timeoutMs = assertFiniteOtlpBound('timeoutMs', options.timeoutMs ?? 10_000);
|
|
123
123
|
const send = options.fetch ?? globalThis.fetch;
|
|
124
124
|
let startedAtMs = options.startedAtMs;
|
|
125
|
-
|
|
125
|
+
/** The ONE POST in flight, and the one body waiting behind it — never a chain. */
|
|
126
|
+
let sending: Promise<void> | undefined;
|
|
127
|
+
let waiting: string | undefined;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One snapshot in flight and at most one waiting. Each export used to chain a POST behind the
|
|
131
|
+
* last, so a stalled collector held every snapshot taken while it stalled. Snapshots are
|
|
132
|
+
* CUMULATIVE, so the newest supersedes every older one still waiting — dropping those loses no
|
|
133
|
+
* count — and sending strictly one at a time keeps a counter from arriving out of order and
|
|
134
|
+
* reading as a reset. `postOtlp` never rejects; the `then` pair keeps that true for this loop.
|
|
135
|
+
*/
|
|
136
|
+
const pump = (): Promise<void> => {
|
|
137
|
+
if (sending !== undefined) return sending;
|
|
138
|
+
const body = waiting;
|
|
139
|
+
waiting = undefined;
|
|
140
|
+
if (body === undefined) return Promise.resolve();
|
|
141
|
+
sending = postOtlp({ url, headers, body, timeoutMs, fetch: send })
|
|
142
|
+
.then(
|
|
143
|
+
() => undefined,
|
|
144
|
+
() => undefined,
|
|
145
|
+
)
|
|
146
|
+
.then(() => {
|
|
147
|
+
sending = undefined;
|
|
148
|
+
if (waiting !== undefined) void pump();
|
|
149
|
+
});
|
|
150
|
+
return sending;
|
|
151
|
+
};
|
|
126
152
|
|
|
127
153
|
return {
|
|
128
154
|
export(collection: MetricCollection): void {
|
|
@@ -143,18 +169,19 @@ export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): Otl
|
|
|
143
169
|
});
|
|
144
170
|
return;
|
|
145
171
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// `otlp-span-exporter.ts` spells out: a chain that carries a rejection forward stops calling
|
|
149
|
-
// `postOtlp` for the life of the process, in silence.
|
|
150
|
-
const settled = inflight.then(
|
|
151
|
-
() => undefined,
|
|
152
|
-
() => undefined,
|
|
153
|
-
);
|
|
154
|
-
inflight = settled.then(() => postOtlp({ url, headers, body, timeoutMs, fetch: send }));
|
|
172
|
+
waiting = body;
|
|
173
|
+
void pump();
|
|
155
174
|
},
|
|
156
|
-
flush(): Promise<void> {
|
|
157
|
-
|
|
175
|
+
async flush(): Promise<void> {
|
|
176
|
+
// The one in flight, then the one that was waiting behind it — which that settle started.
|
|
177
|
+
// Bounded, so a timer exporting while this waits cannot keep a shutdown here forever.
|
|
178
|
+
for (
|
|
179
|
+
let round = 0;
|
|
180
|
+
round < 3 && (sending !== undefined || waiting !== undefined);
|
|
181
|
+
round += 1
|
|
182
|
+
) {
|
|
183
|
+
await pump();
|
|
184
|
+
}
|
|
158
185
|
},
|
|
159
186
|
};
|
|
160
187
|
}
|
|
@@ -136,14 +136,15 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
|
|
|
136
136
|
);
|
|
137
137
|
const send = options.fetch ?? globalThis.fetch;
|
|
138
138
|
const queue: ReadableSpan[] = [];
|
|
139
|
-
|
|
139
|
+
/** The ONE batch in flight, or `undefined`. Never a chain: see `pump`. */
|
|
140
|
+
let sending: Promise<void> | undefined;
|
|
140
141
|
|
|
141
142
|
const post = (batch: readonly ReadableSpan[]): Promise<void> => {
|
|
142
143
|
const first = batch[0];
|
|
143
144
|
if (first === undefined) return Promise.resolve();
|
|
144
145
|
let body: string;
|
|
145
146
|
try {
|
|
146
|
-
// The one synchronous throw on this path, and the only way
|
|
147
|
+
// The one synchronous throw on this path, and the only way a send could reject at all:
|
|
147
148
|
// `AttributeValue` is a compile-time claim, so an attribute the app spelled as an object, a
|
|
148
149
|
// bigint or a cycle reaches `anyValue`'s `value.map(...)` as a TypeError. Dropped with a
|
|
149
150
|
// line, the same degradation `postOtlp` already applies to a collector that is down —
|
|
@@ -160,25 +161,47 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
|
|
|
160
161
|
return postOtlp({ url, headers, body, timeoutMs, fetch: send });
|
|
161
162
|
};
|
|
162
163
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
164
|
+
/**
|
|
165
|
+
* At most ONE batch in flight, and at most `maxBatchSize` spans in it. The queue used to be
|
|
166
|
+
* moved wholesale into a promise chain behind the previous POST, so a stalled collector held
|
|
167
|
+
* every span ever exported — 100k with `maxQueueSize: 2048` — and the bound was a bound on the
|
|
168
|
+
* queue, not on memory. Now spans WAIT IN the queue while a POST is unsettled, where drop-oldest
|
|
169
|
+
* applies, and what is held is `maxQueueSize` plus one batch. One at a time also keeps batches
|
|
170
|
+
* in order, so a parent span never arrives after its child. A POST cannot reject (`postOtlp`
|
|
171
|
+
* degrades to a log line) and `post` catches its own serialisation throw, so nothing here can
|
|
172
|
+
* poison the next send.
|
|
173
|
+
*/
|
|
174
|
+
const pump = (): Promise<void> => {
|
|
175
|
+
if (sending !== undefined) return sending;
|
|
176
|
+
const batch = queue.splice(0, maxBatchSize);
|
|
177
|
+
if (batch.length === 0) return Promise.resolve();
|
|
178
|
+
const current = post(batch).then(
|
|
172
179
|
() => undefined,
|
|
173
180
|
() => undefined,
|
|
174
181
|
);
|
|
175
|
-
|
|
176
|
-
|
|
182
|
+
sending = current.then(() => {
|
|
183
|
+
sending = undefined;
|
|
184
|
+
// A full batch waiting behind a slow POST goes next without waiting for the timer.
|
|
185
|
+
if (queue.length >= maxBatchSize) void pump();
|
|
186
|
+
});
|
|
187
|
+
return sending;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Everything queued NOW, batch by batch. Bounded, so a busy exporter cannot keep it looping. */
|
|
191
|
+
const drainQueue = async (): Promise<void> => {
|
|
192
|
+
let rounds = Math.ceil(queue.length / maxBatchSize) + 1;
|
|
193
|
+
while (rounds > 0) {
|
|
194
|
+
rounds -= 1;
|
|
195
|
+
await pump();
|
|
196
|
+
if (queue.length === 0 && sending === undefined) return;
|
|
197
|
+
}
|
|
177
198
|
};
|
|
178
199
|
|
|
179
200
|
// Unref'd, so a pending flush never holds a draining process open — `shutdown()` is what
|
|
180
201
|
// decides the last batch leaves, exactly as `startMetricExport` defers to the drain hook.
|
|
181
|
-
|
|
202
|
+
// `pump`, never `drainQueue`: while a POST is stalled `pump` answers the send already in flight
|
|
203
|
+
// and allocates nothing, where a drain per tick would park one more waiter per interval.
|
|
204
|
+
const timer = setInterval(() => void pump(), flushIntervalMs);
|
|
182
205
|
timer.unref();
|
|
183
206
|
|
|
184
207
|
return {
|
|
@@ -187,7 +210,7 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
|
|
|
187
210
|
// the spans an operator wants during an incident are the ones happening now.
|
|
188
211
|
if (queue.length >= maxQueueSize) queue.shift();
|
|
189
212
|
queue.push(span);
|
|
190
|
-
if (queue.length >= maxBatchSize) void
|
|
213
|
+
if (queue.length >= maxBatchSize) void pump();
|
|
191
214
|
},
|
|
192
215
|
flush(): Promise<void> {
|
|
193
216
|
return drainQueue();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trace and request-budget headers a SERVER-side typed call carries onward, installed into the
|
|
3
|
+
* transport's slot by the code that makes them meaningful — `runWithContext` and `startSpan` — and
|
|
4
|
+
* never at import. A browser runs neither, so its slot stays empty and `clientTransport` carries
|
|
5
|
+
* zero bytes of telemetry, context or logger: that graph was 12.9 kB of every island that called
|
|
6
|
+
* `rpc()` or `queryClient()`, for a header a browser never has a value for.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { traceHeaders } from './client-wire';
|
|
10
|
+
import { outboundSlot } from './record-sink';
|
|
11
|
+
|
|
12
|
+
/** Idempotent: the first call fills the slot, every later one is a property read. */
|
|
13
|
+
export function installTraceHeaders(): void {
|
|
14
|
+
const slot = outboundSlot();
|
|
15
|
+
if (slot.outboundHeaders === undefined) slot.outboundHeaders = traceHeaders;
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one message a service worker posts to its window clients when the platform says the
|
|
3
|
+
* connection is back: "drain your outbox now". Tier 0 because the SENDER is `@ultimat3/pwa`'s
|
|
4
|
+
* emitted `sw.js` (tier 4) and the LISTENER is `@ultimat3/realtime`'s outbox (tier 3) — one literal,
|
|
5
|
+
* one home, never a string each side retypes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** `event.data.type` of the drain message. */
|
|
9
|
+
export const OUTBOX_DRAIN_MESSAGE = 'x-outbox-drain';
|
|
10
|
+
|
|
11
|
+
/** The whole message. It carries nothing else: the outbox lives in the page, not in the worker. */
|
|
12
|
+
export interface OutboxDrainMessage {
|
|
13
|
+
readonly type: typeof OUTBOX_DRAIN_MESSAGE;
|
|
14
|
+
}
|
package/src/page-meta.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<meta name>`s a rendered document hands its page client. Tier 0 because the WRITER is
|
|
3
|
+
* `@ultimat3/render` (tier 4) and the READERS are core's `pageClient()`, `@ultimat3/realtime`'s
|
|
4
|
+
* socket host (tier 3) and `@ultimat3/pwa`'s skew check — one literal per fact, never retyped.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The page's principal: content = that principal, empty = anonymous, absent = unscoped. */
|
|
8
|
+
export const CLIENT_SCOPE_META = 'ultimate-scope';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The same scope on the RESPONSE of a private document, so a service worker — which never parses
|
|
12
|
+
* HTML — can partition its offline pages by principal. Present only where the meta is; its value
|
|
13
|
+
* is the meta's (`''` for the anonymous visitor).
|
|
14
|
+
*/
|
|
15
|
+
export const CLIENT_SCOPE_HEADER = 'x-ultimate-scope';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The build id the document was rendered by. `x-ultimate-build` — the same spelling as the header,
|
|
19
|
+
* and the one `@ultimat3/pwa` shipped as `BUILD_ID_META`, so no shipped reader changes.
|
|
20
|
+
*/
|
|
21
|
+
export const CLIENT_BUILD_META = 'x-ultimate-build';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `event.data.type` of the message a service worker posts to its windows when a newer build is
|
|
25
|
+
* waiting (`{ type, to: <build id> }`). Sent by `@ultimat3/pwa`'s emitted `sw.js`, read by an app's
|
|
26
|
+
* update banner — one literal both import, beside the build id it is about.
|
|
27
|
+
*/
|
|
28
|
+
export const APP_UPDATE_MESSAGE = 'AppUpdateAvailable';
|
|
29
|
+
|
|
30
|
+
/** Where the page's one socket dials: `/_x/sync`, or the deployment's absolute `SYNC_URL`. */
|
|
31
|
+
export const CLIENT_SYNC_META = 'ultimate-sync';
|
|
32
|
+
|
|
33
|
+
/** The worker script that hosts the socket; absent when the app has no realtime. */
|
|
34
|
+
export const CLIENT_SYNC_WORKER_META = 'ultimate-sync-worker';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The record types the client keeps on disk — the entities registered with `persist: true`,
|
|
38
|
+
* comma-separated (`post,comment`). The browser holds no entity declarations, so this is the one
|
|
39
|
+
* place it learns which types `@ultimat3/realtime`'s persister may write. Absent = none.
|
|
40
|
+
*/
|
|
41
|
+
export const CLIENT_PERSIST_META = 'ultimate-persist';
|
package/src/page.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ultimat3/core/page` — the ONE light entry for browser code: the page handle, the principal
|
|
3
|
+
* fence, the names a document, a service worker and an island agree on, and `UltimateError` with
|
|
4
|
+
* the render helper a refusal's `fix:` needs. What it never carries is a titles TABLE: any barrel
|
|
5
|
+
* import retains the anchored `core-error-codes.ts` and `schema-error-codes.ts`, and a browser
|
|
6
|
+
* that loaded no table titles a code from its name. The barrel re-exports every name here;
|
|
7
|
+
* `page-bundle.test.ts` fails if this entry ever grows a table back.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// The client seam's own functions, for the hooks a browser island calls: the one transport, the
|
|
11
|
+
// URL rule, the principal-supersession reader, and the small helpers realtime's browser half uses.
|
|
12
|
+
// Each reaches no titles table — `page-bundle.test.ts` builds all of them together to prove it.
|
|
13
|
+
export { invariant } from './assert';
|
|
14
|
+
export type { AsyncState } from './async-state';
|
|
15
|
+
export type { JitterMode, Random } from './backoff';
|
|
16
|
+
export { backoffDelay } from './backoff';
|
|
17
|
+
export type { FetchLike, TransportRequest } from './client-dispatch';
|
|
18
|
+
export { IDEMPOTENCY_HEADER } from './client-dispatch';
|
|
19
|
+
export { actionPath, queryPath, splitWords } from './client-paths';
|
|
20
|
+
export type { ClientScope } from './client-scope';
|
|
21
|
+
export { onRescope, rescope } from './client-scope';
|
|
22
|
+
export { clientTransport } from './client-transport';
|
|
23
|
+
export { type Clock, systemClock } from './clock';
|
|
24
|
+
export type { ConflictPolicy, Row } from './conflict-policy';
|
|
25
|
+
// Registry-free: it merges two rows and throws nothing, so the page's record store settles a
|
|
26
|
+
// write under a conflict policy without loading the error table.
|
|
27
|
+
export { resolveConflict } from './conflict-policy';
|
|
28
|
+
export { renderFixShellArg, renderThrowable, stringField } from './error-render';
|
|
29
|
+
export { classifyThrown } from './error-retry';
|
|
30
|
+
export type { UltimateErrorInit } from './errors';
|
|
31
|
+
export { isUltimateError, UltimateError } from './errors';
|
|
32
|
+
export { finiteCount, finiteOption } from './finite-option';
|
|
33
|
+
export { isSuperseded } from './generation-fence';
|
|
34
|
+
export { uuid } from './ids';
|
|
35
|
+
export { isJsonObject } from './json-object';
|
|
36
|
+
export type { OutboxDrainMessage } from './outbox-drain';
|
|
37
|
+
export { OUTBOX_DRAIN_MESSAGE } from './outbox-drain';
|
|
38
|
+
export {
|
|
39
|
+
APP_UPDATE_MESSAGE,
|
|
40
|
+
CLIENT_BUILD_META,
|
|
41
|
+
CLIENT_PERSIST_META,
|
|
42
|
+
CLIENT_SCOPE_HEADER,
|
|
43
|
+
CLIENT_SCOPE_META,
|
|
44
|
+
CLIENT_SYNC_META,
|
|
45
|
+
CLIENT_SYNC_WORKER_META,
|
|
46
|
+
} from './page-meta';
|
|
47
|
+
export type { RecordEnvelope, RecordRows } from './record-envelope';
|
|
48
|
+
export { decodeRecordEnvelope, RECORDS_HEADER } from './record-envelope';
|
|
49
|
+
export type { PageClient, RecordSink } from './record-sink';
|
|
50
|
+
export { pageClient } from './record-sink';
|
|
51
|
+
// A write's public name, so the page's store can recognise the `records` frame its own write made.
|
|
52
|
+
export { isWriteDigest, WRITE_DIGEST_LENGTH, writeDigest } from './write-digest';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records answered before the page's store exists. The store is installed by the first realtime
|
|
3
|
+
* hook, and an action or query can answer earlier than that — so in a BROWSER page the handle
|
|
4
|
+
* holds the latest row per `type:key` (and the keys removed since) here, and hands them to the
|
|
5
|
+
* store once, when it is installed. Bounded by the keys themselves: a row seen twice is one entry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Row } from './conflict-policy';
|
|
9
|
+
import type { RecordRows } from './record-envelope';
|
|
10
|
+
import type { RecordSink } from './record-sink';
|
|
11
|
+
|
|
12
|
+
export interface PendingRecords extends RecordSink {
|
|
13
|
+
/** Adopts everything held into `sink`, then forgets it — a second drain adopts nothing. */
|
|
14
|
+
drainInto(sink: RecordSink): void;
|
|
15
|
+
/** Forgets everything held: the principal changed, and these rows were the previous one's. */
|
|
16
|
+
clear(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function pendingRecords(): PendingRecords {
|
|
20
|
+
const rows = new Map<string, Map<string, Row>>();
|
|
21
|
+
const removed = new Map<string, Set<string>>();
|
|
22
|
+
const group = <T>(table: Map<string, T>, type: string, make: () => T): T => {
|
|
23
|
+
const found = table.get(type);
|
|
24
|
+
if (found !== undefined) return found;
|
|
25
|
+
const created = make();
|
|
26
|
+
table.set(type, created);
|
|
27
|
+
return created;
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
adopt(type: string, incoming: RecordRows): void {
|
|
31
|
+
const held = group(rows, type, () => new Map<string, Row>());
|
|
32
|
+
for (const [key, row] of Object.entries(incoming)) {
|
|
33
|
+
held.set(key, row);
|
|
34
|
+
removed.get(type)?.delete(key);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
remove(type: string, keys: readonly string[]): void {
|
|
38
|
+
const gone = group(removed, type, () => new Set<string>());
|
|
39
|
+
for (const key of keys) {
|
|
40
|
+
rows.get(type)?.delete(key);
|
|
41
|
+
gone.add(key);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
drainInto(sink: RecordSink): void {
|
|
45
|
+
for (const [type, held] of rows) {
|
|
46
|
+
if (held.size > 0) sink.adopt(type, Object.fromEntries(held));
|
|
47
|
+
}
|
|
48
|
+
for (const [type, gone] of removed) {
|
|
49
|
+
if (gone.size > 0) sink.remove(type, [...gone]);
|
|
50
|
+
}
|
|
51
|
+
this.clear();
|
|
52
|
+
},
|
|
53
|
+
clear(): void {
|
|
54
|
+
rows.clear();
|
|
55
|
+
removed.clear();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE OpenAPI description of the records envelope, for every operation that answers one —
|
|
3
|
+
* `@ultimat3/action`'s and `@ultimat3/query`'s projections both build their 200 from it. The rule
|
|
4
|
+
* it serves: an operation decides ONCE, from its schema, whether it answers `{ data, records }`,
|
|
5
|
+
* so each operation has exactly one body shape and one header, never a `oneOf` over "sometimes".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { RECORDS_HEADER } from './record-envelope';
|
|
9
|
+
|
|
10
|
+
/** The 200 body: the operation's own answer under `data`, the rows it carried beside it. */
|
|
11
|
+
export function recordEnvelopeSchema(
|
|
12
|
+
data: Readonly<Record<string, unknown>>,
|
|
13
|
+
): Record<string, unknown> {
|
|
14
|
+
return {
|
|
15
|
+
type: 'object',
|
|
16
|
+
required: ['data', 'records'],
|
|
17
|
+
properties: {
|
|
18
|
+
data,
|
|
19
|
+
records: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
description:
|
|
22
|
+
'Entity rows in `data`, keyed by record type and then by record key, for the client record store. May be empty.',
|
|
23
|
+
additionalProperties: { type: 'object', additionalProperties: { type: 'object' } },
|
|
24
|
+
},
|
|
25
|
+
removed: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
description: 'Record keys the client store must drop, keyed by record type.',
|
|
28
|
+
additionalProperties: { type: 'array', items: { type: 'string' } },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The header beside that body. Always `1` on an operation that answers the envelope at all. */
|
|
35
|
+
export const RECORDS_OPENAPI_HEADER: Readonly<Record<string, unknown>> = Object.freeze({
|
|
36
|
+
[RECORDS_HEADER]: {
|
|
37
|
+
description: 'Always `1`: the body is a record envelope and the operation answer is `data`.',
|
|
38
|
+
required: true,
|
|
39
|
+
schema: { type: 'string', enum: ['1'] },
|
|
40
|
+
},
|
|
41
|
+
});
|