@ultimat3/core 21.0.0 → 22.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.
@@ -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
+ }
@@ -66,6 +66,7 @@ export {
66
66
  SECRETS_KEY_MODE,
67
67
  secretsFileExists,
68
68
  secretsPath,
69
+ stagedMasterKeyPath,
69
70
  writeMasterKeyFile,
70
71
  writeSecretsFile,
71
72
  } from '../secrets-store';
@@ -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;
@@ -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
- return { width: view.getUint16(at + 7), height: view.getUint16(at + 5) };
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 { fitBox, type ImageFit, type ResizeSpec, scaledToFit } from './image/canvas';
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';
@@ -592,6 +590,11 @@ export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
592
590
  export type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary';
593
591
  export { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from './route-vocabulary';
594
592
  export { safeUrl, URL_ATTRIBUTES } from './safe-url';
593
+ export {
594
+ type OriginEvidence,
595
+ type OriginVerdict,
596
+ proveSameOrigin,
597
+ } from './same-origin';
595
598
  export {
596
599
  defineService,
597
600
  installedServices,
@@ -608,7 +611,6 @@ export {
608
611
  readPackageVersion,
609
612
  resolveVersion,
610
613
  VERSION_DEFINE,
611
- VERSION_MANIFEST,
612
614
  } from './version';
613
615
  // The webhook wire format, at the tier both halves can reach — `@ultimat3/jobs` signs a delivery
614
616
  // and `@ultimat3/http` verifies one, and neither may import the other. Same argument
@@ -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
+ }