@ultimat3/core 21.0.0 → 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 -527
- package/README.md +39 -1
- package/package.json +2 -2
- package/src/address-class.ts +143 -0
- package/src/canonical-json.ts +24 -1
- package/src/client-flight.ts +5 -2
- 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/core-error-codes.ts +1 -0
- package/src/dev-secrets.ts +45 -0
- package/src/exports/secrets.ts +1 -0
- 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 +26 -29
- 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/secrets-store.ts +51 -5
- package/src/source-mask.ts +30 -0
- package/src/type-pins.ts +9 -0
- package/src/result.ts +0 -78
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Single responsibility: the boot refusal of a shipped development signing secret outside a local
|
|
2
|
+
// environment. `x doctor` reported it; nothing failed — so a production pod that forgot
|
|
3
|
+
// ULTIMATE_CURSOR_SECRET signed every cursor with a key published in this package.
|
|
4
|
+
|
|
5
|
+
import { usesDevCursorSecret } from './cursor';
|
|
6
|
+
import { isLocal } from './environment';
|
|
7
|
+
import { UltimateError } from './errors';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `X_CURSOR_SECRET_DEV`, the code `x doctor` already reports for this key — one condition, one code.
|
|
11
|
+
* Doctor warns; this refuses the boot.
|
|
12
|
+
*/
|
|
13
|
+
export class CursorSecretDevError extends UltimateError {
|
|
14
|
+
static readonly code = 'X_CURSOR_SECRET_DEV';
|
|
15
|
+
override readonly name = 'CursorSecretDevError';
|
|
16
|
+
|
|
17
|
+
// One secret today, so the fix is a literal: a spliced name would be a value in a pasted command.
|
|
18
|
+
constructor() {
|
|
19
|
+
super({
|
|
20
|
+
code: CursorSecretDevError.code,
|
|
21
|
+
cause:
|
|
22
|
+
'ULTIMATE_CURSOR_SECRET is unset, so this process signs cursors with the development key the framework ships — anyone can forge a page position',
|
|
23
|
+
fix: "x secrets set ULTIMATE_CURSOR_SECRET — or export ULTIMATE_CURSOR_SECRET from the platform's secret store",
|
|
24
|
+
meta: { variable: 'ULTIMATE_CURSOR_SECRET' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DevSecretsOptions {
|
|
30
|
+
/** Which environment the boot is — defaults to `process.env`. */
|
|
31
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Throws when a shipped dev secret is in use and the environment is not `development`/`test`.
|
|
36
|
+
*
|
|
37
|
+
* FAILS CLOSED: a process with neither `ULTIMATE_ENV` nor `NODE_ENV` resolves as `production`
|
|
38
|
+
* here, the answer `templates/scaffold-auth.ts` already gives — `isLocal()`'s own fallback is
|
|
39
|
+
* `development`, which is exactly the process that forgot to say. The secret itself is read where
|
|
40
|
+
* signing reads it, so this refuses what the process WILL sign with, not what `env` claims.
|
|
41
|
+
*/
|
|
42
|
+
export function assertNoDevSecretsOutsideLocal(options: DevSecretsOptions = {}): void {
|
|
43
|
+
if (isLocal({ env: options.env, fallback: 'production' })) return;
|
|
44
|
+
if (usesDevCursorSecret()) throw new CursorSecretDevError();
|
|
45
|
+
}
|
package/src/exports/secrets.ts
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `allowHosts`, as a decision every browser driver asks before a request leaves — never a note in
|
|
2
|
+
// a README. A headless browser inside your network is the widest SSRF surface an app can own: one
|
|
3
|
+
// injected `<img src="http://169.254.169.254/…">` on a page you do not control is a credential
|
|
4
|
+
// read. In core because two tier-5 packages drive a browser (`scraping`, and `cli`'s `x shot`) and
|
|
5
|
+
// neither may import the other; two copies of this rule would be two answers to "may it leave".
|
|
6
|
+
|
|
7
|
+
export type HostRule = string;
|
|
8
|
+
|
|
9
|
+
/** The one spelling that means "every host", written out so it is visible in review. */
|
|
10
|
+
export const ANY_HOST: HostRule = '*';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Schemes with no host to match. `about:blank` is where every browser starts, `data:` and `blob:`
|
|
14
|
+
* never leave the process — refusing them would refuse the first page load of every run.
|
|
15
|
+
*/
|
|
16
|
+
const HOSTLESS_SCHEMES = new Set(['about:', 'data:', 'blob:']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hostless AND refused, which is why it is not on the line above — it sat there until 2026-09.
|
|
20
|
+
* `javascript:` has no host for the same reason `data:` has none, and that is the whole
|
|
21
|
+
* resemblance: the other three are inert content this process renders, while this one is code
|
|
22
|
+
* EXECUTED in the current document's origin, with the session's cookies and the session's
|
|
23
|
+
* `localStorage` already in scope. `allowHosts` cannot say anything about a URL with no host to
|
|
24
|
+
* name, so "no host, therefore allowed" was the allow list opting itself out of the one navigation
|
|
25
|
+
* that needs no host to exfiltrate through — `javascript:fetch('/admin').then(post_elsewhere)` is
|
|
26
|
+
* a same-origin read on an allow-listed site. Fail closed; there is no legitimate scrape verb that
|
|
27
|
+
* needs it (`page.eval` is the declared seam).
|
|
28
|
+
*/
|
|
29
|
+
const REFUSED_SCHEMES = new Set(['javascript:']);
|
|
30
|
+
|
|
31
|
+
export interface HostDecision {
|
|
32
|
+
readonly allowed: boolean;
|
|
33
|
+
/** The host the URL resolved to, `''` for a hostless scheme. */
|
|
34
|
+
readonly host: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `example.com` matches that host EXACTLY. `*.example.com` matches any subdomain and NOT the
|
|
39
|
+
* apex — the two are written separately on purpose: an allow list that silently included every
|
|
40
|
+
* subdomain would let a `cdn-user-content.example.com` (whose contents somebody else controls)
|
|
41
|
+
* through a rule an author wrote for the apex.
|
|
42
|
+
*/
|
|
43
|
+
export function hostMatches(host: string, rule: HostRule): boolean {
|
|
44
|
+
if (rule === ANY_HOST) return true;
|
|
45
|
+
const normalised = host.toLowerCase();
|
|
46
|
+
const cleaned = rule.trim().toLowerCase();
|
|
47
|
+
if (cleaned.startsWith('*.')) {
|
|
48
|
+
const suffix = cleaned.slice(1);
|
|
49
|
+
return normalised.endsWith(suffix) && normalised.length > suffix.length;
|
|
50
|
+
}
|
|
51
|
+
return normalised === cleaned;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fails CLOSED: a URL that cannot be parsed is refused. A driver handed a malformed request has
|
|
56
|
+
* no way to know where it would have gone, and "we could not tell, so we let it through" is the
|
|
57
|
+
* decision that makes the whole list advisory.
|
|
58
|
+
*/
|
|
59
|
+
export function hostDecision(url: string, allowHosts: readonly HostRule[]): HostDecision {
|
|
60
|
+
const scheme = url.slice(0, Math.max(0, url.indexOf(':') + 1)).toLowerCase();
|
|
61
|
+
if (REFUSED_SCHEMES.has(scheme)) return { allowed: false, host: '' };
|
|
62
|
+
if (HOSTLESS_SCHEMES.has(scheme)) return { allowed: true, host: '' };
|
|
63
|
+
let host: string;
|
|
64
|
+
try {
|
|
65
|
+
host = new URL(url).hostname;
|
|
66
|
+
} catch {
|
|
67
|
+
return { allowed: false, host: '' };
|
|
68
|
+
}
|
|
69
|
+
if (host === '') return { allowed: false, host };
|
|
70
|
+
return { allowed: allowHosts.some((rule) => hostMatches(host, rule)), host };
|
|
71
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Single responsibility: the EXIF orientation tag (0x0112) of one JPEG APP1 segment. The decoder
|
|
2
|
+
// applies it — `Bun.Image` reports a 40x20 sensor image tagged `6` as 20x40 — so a probe that
|
|
3
|
+
// ignored it reserved a layout box with width and height swapped.
|
|
4
|
+
|
|
5
|
+
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
|
|
6
|
+
const ORIENTATION_TAG = 0x0112;
|
|
7
|
+
const SHORT = 3;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The orientation (1–8) an APP1 payload declares, or `1` when it declares none or cannot be read.
|
|
11
|
+
* Never throws: EXIF is metadata beside the image, and a malformed block is one the decoder
|
|
12
|
+
* ignores too — refusing the whole image over it would fail a file every browser renders.
|
|
13
|
+
*
|
|
14
|
+
* `payload` is the segment body after its length word.
|
|
15
|
+
*/
|
|
16
|
+
export function exifOrientation(payload: Uint8Array): number {
|
|
17
|
+
if (payload.length < EXIF_HEADER.length + 8) return 1;
|
|
18
|
+
if (EXIF_HEADER.some((byte, index) => payload[index] !== byte)) return 1;
|
|
19
|
+
const tiff = payload.subarray(EXIF_HEADER.length);
|
|
20
|
+
const view = new DataView(tiff.buffer, tiff.byteOffset, tiff.byteLength);
|
|
21
|
+
const order = view.getUint16(0);
|
|
22
|
+
if (order !== 0x4949 && order !== 0x4d4d) return 1;
|
|
23
|
+
const little = order === 0x4949;
|
|
24
|
+
if (view.getUint16(2, little) !== 42) return 1;
|
|
25
|
+
const ifd = view.getUint32(4, little);
|
|
26
|
+
if (ifd + 2 > tiff.length) return 1;
|
|
27
|
+
const entries = view.getUint16(ifd, little);
|
|
28
|
+
for (let index = 0; index < entries; index += 1) {
|
|
29
|
+
const at = ifd + 2 + index * 12;
|
|
30
|
+
if (at + 12 > tiff.length) return 1;
|
|
31
|
+
if (view.getUint16(at, little) !== ORIENTATION_TAG) continue;
|
|
32
|
+
if (view.getUint16(at + 2, little) !== SHORT) return 1;
|
|
33
|
+
const value = view.getUint16(at + 8, little);
|
|
34
|
+
return value >= 1 && value <= 8 ? value : 1;
|
|
35
|
+
}
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Orientations 5–8 transpose the image: the stored width is the displayed height. */
|
|
40
|
+
export const swapsAxes = (orientation: number): boolean => orientation >= 5 && orientation <= 8;
|
package/src/image/probe.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// than in header bytes, so that reading lives in `probe-svg.ts`.
|
|
6
6
|
|
|
7
7
|
import { imageDecodeFailed, imageUnsupported } from './errors';
|
|
8
|
+
import { exifOrientation, swapsAxes } from './exif-orientation';
|
|
8
9
|
import { hasSvgRoot, probeSvg } from './probe-svg';
|
|
9
10
|
import { assertPixelBudget, type ImageSize } from './raster';
|
|
10
11
|
|
|
@@ -137,9 +138,12 @@ const isStandaloneMarker = (marker: number): boolean =>
|
|
|
137
138
|
/**
|
|
138
139
|
* Walks segment lengths to the first SOF. Baseline, progressive and lossless all declare their
|
|
139
140
|
* size the same way — probing is not decoding, so a format the decoder refuses still measures.
|
|
141
|
+
* An EXIF orientation met on the way (APP1, always ahead of the SOF) is applied to the answer,
|
|
142
|
+
* because the decoder applies it: the box reserved must be the box that renders.
|
|
140
143
|
*/
|
|
141
144
|
function probeJpeg(bytes: Uint8Array): ImageSize {
|
|
142
145
|
const view = viewOf(bytes);
|
|
146
|
+
let orientation = 1;
|
|
143
147
|
let at = 2;
|
|
144
148
|
while (at + 3 < bytes.length) {
|
|
145
149
|
if (byteAt(bytes, at) !== 0xff) {
|
|
@@ -164,7 +168,15 @@ function probeJpeg(bytes: Uint8Array): ImageSize {
|
|
|
164
168
|
const length = view.getUint16(at + 2);
|
|
165
169
|
if (isSofMarker(marker)) {
|
|
166
170
|
requireBytes(bytes, at + 9, 'JPEG', 'the SOF segment width and height');
|
|
167
|
-
|
|
171
|
+
const width = view.getUint16(at + 7);
|
|
172
|
+
const height = view.getUint16(at + 5);
|
|
173
|
+
return swapsAxes(orientation) ? { width: height, height: width } : { width, height };
|
|
174
|
+
}
|
|
175
|
+
// The first APP1 declaring a turn decides; XMP also rides APP1 and reads as none.
|
|
176
|
+
if (marker === 0xe1 && orientation === 1 && length >= 2) {
|
|
177
|
+
orientation = exifOrientation(
|
|
178
|
+
bytes.subarray(at + 4, Math.min(bytes.length, at + 2 + length)),
|
|
179
|
+
);
|
|
168
180
|
}
|
|
169
181
|
if (length < 2) {
|
|
170
182
|
throw imageDecodeFailed(`JPEG segment 0xFF${marker.toString(16)} declares length ${length}`, {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Where a SERVER-side typed call goes when its answer lives in this process. The build's
|
|
2
|
+
// measurement render has no server to reach, so a route `load` reading the app's own queries over
|
|
3
|
+
// `queryClient()` failed as the network; the process that owns the route table answers instead.
|
|
4
|
+
//
|
|
5
|
+
// ZERO bytes in a browser, by construction: the transport's dispatch already calls
|
|
6
|
+
// `globalThis.fetch` at call time, so this module wraps THAT, once, in the process that first opens
|
|
7
|
+
// a scope — and only a server process ever imports this module. Outside a scope the wrapper hands
|
|
8
|
+
// every call to the fetch it wrapped, untouched. The first version added a slot read to the
|
|
9
|
+
// dispatch itself, and that was 46 B in every island that calls `rpc()` or `queryClient()`.
|
|
10
|
+
import { asyncContext } from './async-context';
|
|
11
|
+
import type { FetchLike } from './client-dispatch';
|
|
12
|
+
|
|
13
|
+
const scope = asyncContext<FetchLike>('the in-process dispatch');
|
|
14
|
+
|
|
15
|
+
const INSTALLED: unique symbol = Symbol.for('ultimate.in-process-fetch');
|
|
16
|
+
|
|
17
|
+
/** Wrap `globalThis.fetch` once per process; a re-wrap after someone replaced it wraps theirs. */
|
|
18
|
+
function install(): void {
|
|
19
|
+
const current = globalThis.fetch as typeof fetch & { [INSTALLED]?: true };
|
|
20
|
+
if (current[INSTALLED] === true) return;
|
|
21
|
+
const wrapped = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
22
|
+
const inProcess = scope.get();
|
|
23
|
+
if (inProcess === undefined) return current(input, init);
|
|
24
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
25
|
+
return inProcess(url, init ?? {});
|
|
26
|
+
};
|
|
27
|
+
globalThis.fetch = Object.assign(wrapped, current, { [INSTALLED]: true as const });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run `fn` with every `fetch` inside it — so every `clientTransport` call, `queryClient()` and
|
|
32
|
+
* `rpc()` included — answered by `fetchImpl`: the app's own HTTP pipeline handed a `Request`,
|
|
33
|
+
* rather than the network. A caller's explicit `fetchImpl` never reaches `globalThis.fetch` at all,
|
|
34
|
+
* so a test's double is never overridden by an ambient scope.
|
|
35
|
+
*/
|
|
36
|
+
export function withInProcessFetch<T>(fetchImpl: FetchLike, fn: () => T): T {
|
|
37
|
+
install();
|
|
38
|
+
return scope.run(fetchImpl, fn);
|
|
39
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,8 @@ export {
|
|
|
41
41
|
userActor,
|
|
42
42
|
withFacts,
|
|
43
43
|
} from './actor';
|
|
44
|
+
export type { AddressClass } from './address-class';
|
|
45
|
+
export { classifyAddress, isPublicAddress } from './address-class';
|
|
44
46
|
export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
|
|
45
47
|
export { assert, assertNever, type InvariantOptions, invariant } from './assert';
|
|
46
48
|
export { type AsyncContext, asyncContext } from './async-context';
|
|
@@ -96,6 +98,7 @@ export type {
|
|
|
96
98
|
AuthConfig,
|
|
97
99
|
CacheConfig,
|
|
98
100
|
DatabaseConfig,
|
|
101
|
+
DrainConfig,
|
|
99
102
|
JobsConfig,
|
|
100
103
|
McpConfig,
|
|
101
104
|
NotifyConfig,
|
|
@@ -133,6 +136,8 @@ export {
|
|
|
133
136
|
usesDevCursorSecret,
|
|
134
137
|
} from './cursor';
|
|
135
138
|
export { compareDecimalText } from './decimal-order';
|
|
139
|
+
export type { DevSecretsOptions } from './dev-secrets';
|
|
140
|
+
export { assertNoDevSecretsOutsideLocal, CursorSecretDevError } from './dev-secrets';
|
|
136
141
|
export type {
|
|
137
142
|
Env,
|
|
138
143
|
EnvBooleanVar,
|
|
@@ -320,22 +325,13 @@ export {
|
|
|
320
325
|
noopErrorReporter,
|
|
321
326
|
noopExporter,
|
|
322
327
|
noopMetricExporter,
|
|
323
|
-
OTEL_SAMPLER_ARG_KEY,
|
|
324
|
-
OTEL_SAMPLER_KEY,
|
|
325
|
-
OTLP_ENDPOINT_KEY,
|
|
326
|
-
OTLP_HEADERS_KEY,
|
|
327
|
-
OTLP_PROTOCOL_KEY,
|
|
328
|
-
OTLP_SCOPE,
|
|
329
328
|
OtlpEndpointInvalidError,
|
|
330
329
|
OtlpHeadersInvalidError,
|
|
331
330
|
OtlpProtocolUnsupportedError,
|
|
332
|
-
OVERFLOW_ATTRIBUTE,
|
|
333
|
-
otlpAttributes,
|
|
334
331
|
otlpEndpoint,
|
|
335
332
|
otlpHeaders,
|
|
336
333
|
otlpMetricExporter,
|
|
337
334
|
otlpMetricsRequest,
|
|
338
|
-
otlpResource,
|
|
339
335
|
otlpSpanExporter,
|
|
340
336
|
otlpTraceRequest,
|
|
341
337
|
parentBasedRatioSampler,
|
|
@@ -353,7 +349,6 @@ export {
|
|
|
353
349
|
reportError,
|
|
354
350
|
requestDuration,
|
|
355
351
|
requests,
|
|
356
|
-
resetDefaultSampler,
|
|
357
352
|
resetErrorReporting,
|
|
358
353
|
resetMetrics,
|
|
359
354
|
resetTelemetry,
|
|
@@ -368,7 +363,6 @@ export {
|
|
|
368
363
|
startSpan,
|
|
369
364
|
traceparent,
|
|
370
365
|
tryOtlpEndpoint,
|
|
371
|
-
unixNano,
|
|
372
366
|
withSpan,
|
|
373
367
|
withSpanContext,
|
|
374
368
|
} from './exports/observability';
|
|
@@ -396,25 +390,15 @@ export {
|
|
|
396
390
|
masterKeyPath,
|
|
397
391
|
openSecrets,
|
|
398
392
|
parseMasterKey,
|
|
399
|
-
parseSecretsEnvelope,
|
|
400
393
|
readSecretsFile,
|
|
401
394
|
requireMasterKey,
|
|
402
395
|
revealOptionalSecret,
|
|
403
396
|
revealSecret,
|
|
404
|
-
SECRET_BRAND,
|
|
405
|
-
SECRET_NAME,
|
|
406
|
-
SECRETS_ALG,
|
|
407
397
|
SECRETS_ERROR_CODES,
|
|
408
398
|
SECRETS_FILE,
|
|
409
|
-
SECRETS_IV_BYTES,
|
|
410
|
-
SECRETS_KEY_BYTES,
|
|
411
399
|
SECRETS_KEY_ENV,
|
|
412
400
|
SECRETS_KEY_FILE,
|
|
413
|
-
SECRETS_KEY_HEX_LENGTH,
|
|
414
|
-
SECRETS_KEY_ID_LENGTH,
|
|
415
401
|
SECRETS_KEY_MODE,
|
|
416
|
-
SECRETS_TAG_BYTES,
|
|
417
|
-
SECRETS_VERSION,
|
|
418
402
|
SecretsFileInvalidError,
|
|
419
403
|
SecretsFileMissingError,
|
|
420
404
|
SecretsKeyInvalidError,
|
|
@@ -427,6 +411,7 @@ export {
|
|
|
427
411
|
secretsFileExists,
|
|
428
412
|
secretsPath,
|
|
429
413
|
serializeSecretValues,
|
|
414
|
+
stagedMasterKeyPath,
|
|
430
415
|
writeMasterKeyFile,
|
|
431
416
|
writeSecretsFile,
|
|
432
417
|
} from './exports/secrets';
|
|
@@ -441,6 +426,8 @@ export { createFlightGate, gateOverloaded } from './flight-gate';
|
|
|
441
426
|
export { formatBytes } from './format-bytes';
|
|
442
427
|
export type { GenerationFence } from './generation-fence';
|
|
443
428
|
export { createFence, isSuperseded } from './generation-fence';
|
|
429
|
+
export type { HostDecision, HostRule } from './host-rules';
|
|
430
|
+
export { ANY_HOST, hostDecision, hostMatches } from './host-rules';
|
|
444
431
|
export type { Brand, Id } from './ids';
|
|
445
432
|
export {
|
|
446
433
|
isSpanId,
|
|
@@ -456,7 +443,7 @@ export {
|
|
|
456
443
|
uuid,
|
|
457
444
|
uuidTimestamp,
|
|
458
445
|
} from './ids';
|
|
459
|
-
export {
|
|
446
|
+
export type { ImageFit, ResizeSpec } from './image/canvas';
|
|
460
447
|
export { parseColor } from './image/color';
|
|
461
448
|
export {
|
|
462
449
|
ImageDecodeFailedError,
|
|
@@ -488,13 +475,12 @@ export type { ImageFormat, ImageInfo } from './image/probe';
|
|
|
488
475
|
export { IMAGE_FORMATS, IMAGE_MIME_TYPES, probeImage, sniffImageFormat } from './image/probe';
|
|
489
476
|
export type { ImageSize, Raster } from './image/raster';
|
|
490
477
|
export {
|
|
491
|
-
assertPixelBudget,
|
|
492
478
|
createRaster,
|
|
493
479
|
hasAlpha,
|
|
494
480
|
MAX_IMAGE_PIXELS,
|
|
495
|
-
rasterFrom,
|
|
496
481
|
} from './image/raster';
|
|
497
482
|
export { impersonate, impersonationReason, isImpersonating } from './impersonate';
|
|
483
|
+
export { withInProcessFetch } from './in-process-fetch';
|
|
498
484
|
export {
|
|
499
485
|
assertLocale,
|
|
500
486
|
cachedFormatter,
|
|
@@ -503,6 +489,7 @@ export {
|
|
|
503
489
|
MAX_CACHED_FORMATTERS,
|
|
504
490
|
MAX_LOCALE_EXCERPT,
|
|
505
491
|
} from './intl-cache';
|
|
492
|
+
export { isIsoDateTime } from './iso-date';
|
|
506
493
|
export { isJsonObject } from './json-object';
|
|
507
494
|
export type {
|
|
508
495
|
HealthPayload,
|
|
@@ -516,7 +503,6 @@ export type {
|
|
|
516
503
|
ShutdownHook,
|
|
517
504
|
ShutdownPhase,
|
|
518
505
|
ShutdownReason,
|
|
519
|
-
SignalHandlerOptions,
|
|
520
506
|
} from './lifecycle';
|
|
521
507
|
export {
|
|
522
508
|
beginWork,
|
|
@@ -527,23 +513,37 @@ export {
|
|
|
527
513
|
healthzPayload,
|
|
528
514
|
idleWaiterCount,
|
|
529
515
|
inflightCount,
|
|
530
|
-
installSignalHandlers,
|
|
531
516
|
isDraining,
|
|
532
517
|
lifecycleState,
|
|
533
518
|
markReady,
|
|
534
519
|
onShutdown,
|
|
535
520
|
readinessCheckCount,
|
|
536
521
|
readinessChecks,
|
|
522
|
+
readinessGraceMs,
|
|
537
523
|
readyzPayload,
|
|
538
524
|
registerReadinessCheck,
|
|
539
525
|
resetLifecycle,
|
|
540
526
|
SHUTDOWN_PHASES,
|
|
541
527
|
shutdownHookCount,
|
|
542
528
|
} from './lifecycle';
|
|
529
|
+
export {
|
|
530
|
+
defaultReadinessGraceMs,
|
|
531
|
+
READINESS_GRACE_DEFAULT_MS,
|
|
532
|
+
READINESS_GRACE_MAX_MS,
|
|
533
|
+
} from './lifecycle-grace';
|
|
534
|
+
export type { SignalHandlerOptions } from './lifecycle-signals';
|
|
535
|
+
export { installSignalHandlers } from './lifecycle-signals';
|
|
543
536
|
export { isSelfOrigin, listeningOrigins, markListening, resetListeners } from './listeners';
|
|
544
537
|
export type { Direction } from './locale-direction';
|
|
545
538
|
export { directionOf, isRtl } from './locale-direction';
|
|
546
539
|
export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
|
|
540
|
+
export type { MeasurementActorFactory } from './measurement-actor';
|
|
541
|
+
export {
|
|
542
|
+
defineMeasurementActor,
|
|
543
|
+
MEASUREMENT_ACTOR_ID,
|
|
544
|
+
measurementActor,
|
|
545
|
+
resetMeasurementActor,
|
|
546
|
+
} from './measurement-actor';
|
|
547
547
|
export { nearestName } from './nearest-name';
|
|
548
548
|
/** The message pwa's `sw.js` posts and realtime's outbox listens for. */
|
|
549
549
|
export { OUTBOX_DRAIN_MESSAGE, type OutboxDrainMessage } from './outbox-drain';
|
|
@@ -582,8 +582,6 @@ export {
|
|
|
582
582
|
REQUEST_TIMEOUT_HEADER,
|
|
583
583
|
remainingBudgetMs,
|
|
584
584
|
} from './request-budget';
|
|
585
|
-
export type { Err, Ok, Result } from './result';
|
|
586
|
-
export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from './result';
|
|
587
585
|
export type { RetryDecision, RetryDeps, RetryPolicy, RetryStopReason } from './retry';
|
|
588
586
|
export { retry, retryDecision } from './retry';
|
|
589
587
|
export { isRetryableStatus, RETRYABLE_STATUSES } from './retryable-status';
|
|
@@ -608,7 +606,6 @@ export {
|
|
|
608
606
|
readPackageVersion,
|
|
609
607
|
resolveVersion,
|
|
610
608
|
VERSION_DEFINE,
|
|
611
|
-
VERSION_MANIFEST,
|
|
612
609
|
} from './version';
|
|
613
610
|
// The webhook wire format, at the tier both halves can reach — `@ultimat3/jobs` signs a delivery
|
|
614
611
|
// and `@ultimat3/http` verifies one, and neither may import the other. Same argument
|
package/src/iso-date.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Single responsibility: re-export `@ultimat3/schema`'s ISO date-time predicate, so a package that
|
|
2
|
+
// depends on core and not on schema — `@ultimat3/ui` formats dates, it validates nothing — judges a
|
|
3
|
+
// date string with the ONE rule `t.date` and `timestamp()` use. The `time-zone-name.ts` shape.
|
|
4
|
+
|
|
5
|
+
export { isIsoDateTime } from '@ultimat3/schema';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Single responsibility: the readiness grace's default and its domain. One module because the
|
|
2
|
+
// config validator and `configureLifecycle` must refuse the same values and default the same way.
|
|
3
|
+
|
|
4
|
+
import { countIssue } from './config-count';
|
|
5
|
+
import { tryResolveEnvironment } from './environment';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Outside a local environment. Long enough for the endpoints controller to observe `/readyz` at
|
|
9
|
+
* 503 and for kube-proxy/the ingress to stop routing here (`periodSeconds: 5` in the shipped chart
|
|
10
|
+
* is the dominant term); short enough to sit well inside a 30s `terminationGracePeriodSeconds`
|
|
11
|
+
* beside the 25s drain budget it is ADDED to.
|
|
12
|
+
*/
|
|
13
|
+
export const READINESS_GRACE_DEFAULT_MS = 5000;
|
|
14
|
+
|
|
15
|
+
/** A grace past a minute is a stalled rollout, not a drain. */
|
|
16
|
+
export const READINESS_GRACE_MAX_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
type EnvRecord = Readonly<Record<string, string | undefined>>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `0` in `development`/`test`, where no load balancer is routing and a Ctrl-C should be instant;
|
|
22
|
+
* the full grace everywhere else. FAILS CLOSED: a process naming no environment — or a
|
|
23
|
+
* `ULTIMATE_ENV` that is not one — is production here, because the process that forgot to say is
|
|
24
|
+
* exactly the one a 502 on every deploy would reach.
|
|
25
|
+
*/
|
|
26
|
+
export function defaultReadinessGraceMs(env?: EnvRecord): number {
|
|
27
|
+
const environment = tryResolveEnvironment({ env, fallback: 'production' });
|
|
28
|
+
return environment === 'development' || environment === 'test' ? 0 : READINESS_GRACE_DEFAULT_MS;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Why a value is not a grace, or `undefined` when it is one. A whole number of milliseconds in
|
|
33
|
+
* `0 ≤ v ≤ 60000`; a fraction is refused rather than rounded, since `setTimeout` would round it
|
|
34
|
+
* and nothing would say so.
|
|
35
|
+
*/
|
|
36
|
+
export function readinessGraceIssue(value: unknown): string | undefined {
|
|
37
|
+
const count = countIssue(GRACE_KEY, value, 0);
|
|
38
|
+
if (count !== undefined) return count;
|
|
39
|
+
return (value as number) > READINESS_GRACE_MAX_MS
|
|
40
|
+
? `${GRACE_KEY} must be at most ${READINESS_GRACE_MAX_MS} milliseconds — a longer grace is a stalled rollout, not a drain`
|
|
41
|
+
: undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const GRACE_KEY = 'drain.readinessGraceMs';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Single responsibility: wiring process signals to the one drain. Split from `lifecycle.ts`, which
|
|
2
|
+
// owns the state machine and the phases, when the readiness grace took it past its line ceiling.
|
|
3
|
+
|
|
4
|
+
import { drain, type ProcessSignal } from './lifecycle';
|
|
5
|
+
|
|
6
|
+
export interface SignalHandlerOptions {
|
|
7
|
+
readonly signals?: readonly ProcessSignal[] | undefined;
|
|
8
|
+
/** Call `process.exit()` once drained. Off in tests. */
|
|
9
|
+
readonly exit?: boolean | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Install SIGTERM/SIGINT handling. Returns an uninstall function. */
|
|
13
|
+
export function installSignalHandlers(options?: SignalHandlerOptions): () => void {
|
|
14
|
+
const signals: readonly ProcessSignal[] = options?.signals ?? ['SIGTERM', 'SIGINT'];
|
|
15
|
+
const handlers = new Map<ProcessSignal, () => void>();
|
|
16
|
+
|
|
17
|
+
for (const signal of signals) {
|
|
18
|
+
const handler = (): void => {
|
|
19
|
+
// Attached on BOTH settle paths, for the reason `settleWithin` gives: an unhandled rejection
|
|
20
|
+
// ends the process before the drain does, and the exit is what the kubelet is waiting for.
|
|
21
|
+
// `drain()` cannot reject today — that is `runDrain`'s `try/finally` in `lifecycle.ts`, not luck — and this is
|
|
22
|
+
// the one line that keeps it true when someone changes the body.
|
|
23
|
+
const done = (): void => {
|
|
24
|
+
if (options?.exit === true) process.exit(0);
|
|
25
|
+
};
|
|
26
|
+
void drain(signal).then(done, done);
|
|
27
|
+
};
|
|
28
|
+
handlers.set(signal, handler);
|
|
29
|
+
process.on(signal, handler);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return () => {
|
|
33
|
+
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
34
|
+
};
|
|
35
|
+
}
|
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';
|