@ultimat3/core 1.2.0 → 3.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 +272 -0
- package/README.md +248 -10
- package/package.json +2 -1
- package/src/actor.ts +118 -4
- package/src/app-version.ts +32 -0
- package/src/assert.ts +5 -1
- package/src/config.ts +47 -12
- package/src/context.ts +30 -3
- package/src/cursor.ts +25 -4
- package/src/env-example.ts +2 -1
- package/src/env.ts +14 -3
- package/src/environment.ts +39 -13
- package/src/error-codes.ts +15 -1
- package/src/error-render.ts +249 -0
- package/src/error-reporter-sentry.ts +175 -0
- package/src/error-reporter.ts +212 -0
- package/src/error-retry.ts +112 -0
- package/src/errors.ts +55 -7
- package/src/exports/error-contract.ts +62 -0
- package/src/exports/observability.ts +161 -0
- package/src/exports/secrets.ts +71 -0
- package/src/ids.ts +49 -7
- package/src/impersonate.ts +62 -0
- package/src/index.ts +279 -113
- package/src/intl-cache.ts +43 -0
- package/src/lifecycle-deadline.ts +73 -0
- package/src/lifecycle-errors.ts +33 -0
- package/src/lifecycle.ts +237 -34
- package/src/logger.ts +99 -9
- package/src/mcp-exposure.ts +32 -0
- package/src/metrics.ts +0 -0
- package/src/otlp-metric-exporter.ts +136 -0
- package/src/otlp-span-exporter.ts +170 -0
- package/src/otlp.ts +217 -0
- package/src/read-capped.ts +47 -0
- package/src/runtime-metrics.ts +15 -0
- package/src/safe-url.ts +50 -0
- package/src/sampler.ts +126 -0
- package/src/schema-error-codes.ts +28 -0
- package/src/secrets-errors.ts +143 -0
- package/src/secrets-store.ts +173 -0
- package/src/secrets.ts +292 -0
- package/src/telemetry.ts +43 -11
- package/src/timing-safe-equal.ts +18 -0
- package/src/type-pins.ts +93 -0
- package/src/version.ts +53 -4
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Single responsibility: acting as another actor, on the record. `withChildContext({ actor })` is
|
|
2
|
+
// the mechanism; this is the ONE door through it, because a swap with no reason and no origin is
|
|
3
|
+
// indistinguishable in an audit trail from the customer doing it themselves.
|
|
4
|
+
|
|
5
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
|
+
import { type Actor, actorLabel, actorOrigin } from './actor';
|
|
7
|
+
import { assert } from './assert';
|
|
8
|
+
import { useContext, withChildContext } from './context';
|
|
9
|
+
import { currentSpan } from './telemetry';
|
|
10
|
+
|
|
11
|
+
const storage = new AsyncLocalStorage<string>();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run `fn` as `actor`, recording who asked and why.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* await impersonate(customer, 'ticket 4821: reproduce the failed refund', async () => { … });
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Three properties, none optional. **The original actor is preserved** — stamped onto the child as
|
|
21
|
+
* `onBehalfOf`, so every log line, span and `actorLabel()` inside renders `support:eng-7→user:…`
|
|
22
|
+
* and a refund issued here can never read as the customer's. **The reason is required and
|
|
23
|
+
* non-blank** because it *is* the mechanism: a swap with no argument is a pragma, and the next
|
|
24
|
+
* reader cannot tell a support session from a bug. **There must be a context** — outside one there
|
|
25
|
+
* is no original actor to preserve, so there is nothing to impersonate *from*, and
|
|
26
|
+
* `withChildContext` says so with `X_NO_CONTEXT`.
|
|
27
|
+
*
|
|
28
|
+
* Same template as `@ultimat3/entity`'s `crossTenant()`, deliberately: two escapes from the
|
|
29
|
+
* framework's default posture should not be two different-looking things.
|
|
30
|
+
*/
|
|
31
|
+
export function impersonate<T>(actor: Actor, reason: string, fn: () => T): T {
|
|
32
|
+
assert(
|
|
33
|
+
reason.trim() !== '',
|
|
34
|
+
'impersonate() was given a blank reason, so the identity swap it performs carries no argument',
|
|
35
|
+
"pass why one actor is acting as another: impersonate(customer, 'ticket 4821: reproduce the failed refund', fn)",
|
|
36
|
+
);
|
|
37
|
+
const parent = useContext();
|
|
38
|
+
const impersonated: Actor = Object.freeze({ ...actor, onBehalfOf: actorOrigin(parent.actor) });
|
|
39
|
+
const label = actorLabel(impersonated);
|
|
40
|
+
// The PARENT's logger, because the parent is who is performing this — and `warn`, not `info`,
|
|
41
|
+
// because this is rare, always interesting, and an audit query that has to read info-level to
|
|
42
|
+
// find it will be run at the wrong level during the one incident where it matters.
|
|
43
|
+
parent.logger.warn('impersonation', { event: 'impersonation', actor: label, reason });
|
|
44
|
+
currentSpan()?.addEvent('impersonation', {
|
|
45
|
+
'actor.label': label,
|
|
46
|
+
'impersonation.reason': reason,
|
|
47
|
+
});
|
|
48
|
+
return withChildContext({ actor: impersonated }, () => storage.run(reason, fn));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The innermost enclosing reason, or `undefined` outside every scope — which is every request in
|
|
53
|
+
* an app where nobody is impersonating. Read by an audit sink, and by nothing else.
|
|
54
|
+
*/
|
|
55
|
+
export function impersonationReason(): string | undefined {
|
|
56
|
+
return storage.getStore();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Is the caller acting as somebody else right now? */
|
|
60
|
+
export function isImpersonating(): boolean {
|
|
61
|
+
return storage.getStore() !== undefined;
|
|
62
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
// Single responsibility: the public API of @ultimat3/core. Explicit named exports only —
|
|
2
2
|
// every other package imports from here, so this list is the tier-0 contract.
|
|
3
|
+
//
|
|
4
|
+
// Three slices arrive through a barrel in `exports/` — observability, the error contract and
|
|
5
|
+
// secrets — because each is one subject spread over a dozen modules. Every name they carry is
|
|
6
|
+
// still written out below: `export *` would make the contract something a reader has to resolve.
|
|
3
7
|
|
|
4
|
-
export type {
|
|
8
|
+
export type {
|
|
9
|
+
Actor,
|
|
10
|
+
ActorFactKey,
|
|
11
|
+
ActorFactMap,
|
|
12
|
+
ActorFacts,
|
|
13
|
+
ActorInit,
|
|
14
|
+
ActorKind,
|
|
15
|
+
ActorOrigin,
|
|
16
|
+
} from './actor';
|
|
5
17
|
export {
|
|
6
18
|
ACTOR_KINDS,
|
|
19
|
+
actorFact,
|
|
7
20
|
actorLabel,
|
|
21
|
+
actorOrigin,
|
|
8
22
|
agentActor,
|
|
9
23
|
anonymousActor,
|
|
10
24
|
hasRole,
|
|
@@ -13,7 +27,9 @@ export {
|
|
|
13
27
|
isAnonymous,
|
|
14
28
|
serviceActor,
|
|
15
29
|
userActor,
|
|
30
|
+
withFacts,
|
|
16
31
|
} from './actor';
|
|
32
|
+
export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
|
|
17
33
|
export type { InvariantOptions } from './assert';
|
|
18
34
|
export { assert, assertNever, invariant } from './assert';
|
|
19
35
|
export type { Clock, FrozenClock } from './clock';
|
|
@@ -24,6 +40,7 @@ export type {
|
|
|
24
40
|
AppConfig,
|
|
25
41
|
AppConfigInput,
|
|
26
42
|
AppConfigOverlay,
|
|
43
|
+
AuthConfig,
|
|
27
44
|
CacheConfig,
|
|
28
45
|
CacheTier,
|
|
29
46
|
DatabaseConfig,
|
|
@@ -58,6 +75,7 @@ export {
|
|
|
58
75
|
configureCursorSigning,
|
|
59
76
|
decodeCursor,
|
|
60
77
|
encodeCursor,
|
|
78
|
+
resetCursorSigning,
|
|
61
79
|
usesDevCursorSecret,
|
|
62
80
|
} from './cursor';
|
|
63
81
|
export type {
|
|
@@ -95,44 +113,266 @@ export {
|
|
|
95
113
|
isLocal,
|
|
96
114
|
isProduction,
|
|
97
115
|
resolveEnvironment,
|
|
116
|
+
tryResolveEnvironment,
|
|
98
117
|
} from './environment';
|
|
99
118
|
export type {
|
|
119
|
+
CodedErrorInit,
|
|
100
120
|
CoreErrorCode,
|
|
101
121
|
ErrorCodeDeclaration,
|
|
102
122
|
ErrorCodeDescriptor,
|
|
103
123
|
ErrorCodeEntry,
|
|
104
|
-
|
|
105
|
-
export {
|
|
106
|
-
CORE_ERROR_CODES,
|
|
107
|
-
describeErrorCode,
|
|
108
|
-
ERROR_DOCS_BASE,
|
|
109
|
-
errorCodeSnapshot,
|
|
110
|
-
errorDocsUrl,
|
|
111
|
-
hasErrorCode,
|
|
112
|
-
listErrorCodes,
|
|
113
|
-
registerErrorCodes,
|
|
114
|
-
resetErrorCodes,
|
|
115
|
-
} from './error-codes';
|
|
116
|
-
export type {
|
|
117
|
-
CodedErrorInit,
|
|
124
|
+
ErrorRetry,
|
|
118
125
|
FormatErrorOptions,
|
|
119
126
|
UltimateErrorInit,
|
|
120
127
|
UltimateErrorJSON,
|
|
121
|
-
} from './
|
|
128
|
+
} from './exports/error-contract';
|
|
122
129
|
export {
|
|
130
|
+
CORE_ERROR_CODES,
|
|
123
131
|
ConfigInvalidError,
|
|
132
|
+
DEFAULT_ERROR_RETRY,
|
|
133
|
+
declaredErrorRetry,
|
|
134
|
+
describeErrorCode,
|
|
135
|
+
describeValue,
|
|
124
136
|
EnvMissingError,
|
|
137
|
+
ERROR_DOCS_BASE,
|
|
138
|
+
ERROR_RETRY_KINDS,
|
|
139
|
+
errorCodeSnapshot,
|
|
140
|
+
errorDocsUrl,
|
|
141
|
+
errorRetry,
|
|
125
142
|
formatError,
|
|
143
|
+
hasErrorCode,
|
|
126
144
|
InternalError,
|
|
145
|
+
isErrorRetry,
|
|
146
|
+
isThrownError,
|
|
127
147
|
isUltimateError,
|
|
148
|
+
listErrorCodes,
|
|
149
|
+
MAX_RENDERED_LENGTH,
|
|
128
150
|
NotImplementedError,
|
|
129
151
|
notImplemented,
|
|
152
|
+
registerErrorCodes,
|
|
153
|
+
registerErrorRetry,
|
|
154
|
+
registeredErrorRetry,
|
|
155
|
+
renderCauseValue,
|
|
156
|
+
renderFixLiteral,
|
|
157
|
+
renderThrowable,
|
|
158
|
+
resetErrorCodes,
|
|
159
|
+
resetErrorRetry,
|
|
160
|
+
retryFor,
|
|
161
|
+
SCHEMA_ERROR_CODE_TITLES,
|
|
162
|
+
stringField,
|
|
130
163
|
toUltimateError,
|
|
131
164
|
ULTIMATE_ERROR_BRAND,
|
|
132
165
|
UltimateError,
|
|
133
|
-
} from './
|
|
166
|
+
} from './exports/error-contract';
|
|
167
|
+
export type {
|
|
168
|
+
AttributeValue,
|
|
169
|
+
Counter,
|
|
170
|
+
ErrorReport,
|
|
171
|
+
ErrorReporter,
|
|
172
|
+
ErrorReportingOptions,
|
|
173
|
+
ErrorScope,
|
|
174
|
+
ErrorSeverity,
|
|
175
|
+
ErrorSource,
|
|
176
|
+
Gauge,
|
|
177
|
+
GaugeOptions,
|
|
178
|
+
Histogram,
|
|
179
|
+
HistogramOptions,
|
|
180
|
+
HistogramPoint,
|
|
181
|
+
InstrumentOptions,
|
|
182
|
+
LogFields,
|
|
183
|
+
Logger,
|
|
184
|
+
LoggerOptions,
|
|
185
|
+
LogLevel,
|
|
186
|
+
MemoryErrorReporter,
|
|
187
|
+
MemoryExporter,
|
|
188
|
+
MemoryMetricExporter,
|
|
189
|
+
MetricAttributes,
|
|
190
|
+
MetricAttributeValue,
|
|
191
|
+
MetricCollection,
|
|
192
|
+
MetricDescriptor,
|
|
193
|
+
MetricExporter,
|
|
194
|
+
MetricKind,
|
|
195
|
+
MetricPoint,
|
|
196
|
+
MetricsOptions,
|
|
197
|
+
OtlpAnyValue,
|
|
198
|
+
OtlpKeyValue,
|
|
199
|
+
OtlpMetricExporter,
|
|
200
|
+
OtlpMetricExporterOptions,
|
|
201
|
+
OtlpSignal,
|
|
202
|
+
OtlpSpanExporter,
|
|
203
|
+
OtlpSpanExporterOptions,
|
|
204
|
+
ReadableMetric,
|
|
205
|
+
ReadableSpan,
|
|
206
|
+
ReportErrorOptions,
|
|
207
|
+
RequestSample,
|
|
208
|
+
Sampler,
|
|
209
|
+
SentryDsn,
|
|
210
|
+
SentryEnvelopeOptions,
|
|
211
|
+
SentryReporterOptions,
|
|
212
|
+
Span,
|
|
213
|
+
SpanAttributes,
|
|
214
|
+
SpanContext,
|
|
215
|
+
SpanEvent,
|
|
216
|
+
SpanExporter,
|
|
217
|
+
SpanKind,
|
|
218
|
+
SpanResource,
|
|
219
|
+
SpanStatus,
|
|
220
|
+
SpanStatusCode,
|
|
221
|
+
StartSpanOptions,
|
|
222
|
+
TelemetryOptions,
|
|
223
|
+
} from './exports/observability';
|
|
224
|
+
export {
|
|
225
|
+
alwaysOffSampler,
|
|
226
|
+
alwaysOnSampler,
|
|
227
|
+
collectMetrics,
|
|
228
|
+
configureErrorReporting,
|
|
229
|
+
configureMetrics,
|
|
230
|
+
configureTelemetry,
|
|
231
|
+
connections,
|
|
232
|
+
counter,
|
|
233
|
+
createLogger,
|
|
234
|
+
currentSampler,
|
|
235
|
+
currentSpan,
|
|
236
|
+
currentSpanContext,
|
|
237
|
+
DEFAULT_HISTOGRAM_BOUNDS,
|
|
238
|
+
DEFAULT_MAX_SERIES,
|
|
239
|
+
DEFAULT_SAMPLE_RATIO,
|
|
240
|
+
defaultSampler,
|
|
241
|
+
ERROR_SOURCES,
|
|
242
|
+
ErrorReporterDsnInvalidError,
|
|
243
|
+
errorReport,
|
|
244
|
+
exportMetrics,
|
|
245
|
+
gauge,
|
|
246
|
+
histogram,
|
|
247
|
+
isRedactedKey,
|
|
248
|
+
jobs,
|
|
249
|
+
LOG_LEVELS,
|
|
250
|
+
leasesLost,
|
|
251
|
+
logger,
|
|
252
|
+
METRICS_CONTENT_TYPE,
|
|
253
|
+
METRICS_PATH,
|
|
254
|
+
MetricCardinalityError,
|
|
255
|
+
MetricNameInvalidError,
|
|
256
|
+
MetricValueInvalidError,
|
|
257
|
+
memoryErrorReporter,
|
|
258
|
+
memoryExporter,
|
|
259
|
+
memoryMetricExporter,
|
|
260
|
+
metricsText,
|
|
261
|
+
noopErrorReporter,
|
|
262
|
+
noopExporter,
|
|
263
|
+
noopMetricExporter,
|
|
264
|
+
OTEL_SAMPLER_ARG_KEY,
|
|
265
|
+
OTEL_SAMPLER_KEY,
|
|
266
|
+
OTLP_ENDPOINT_KEY,
|
|
267
|
+
OTLP_HEADERS_KEY,
|
|
268
|
+
OTLP_PROTOCOL_KEY,
|
|
269
|
+
OTLP_SCOPE,
|
|
270
|
+
OtlpEndpointInvalidError,
|
|
271
|
+
OtlpProtocolUnsupportedError,
|
|
272
|
+
OVERFLOW_ATTRIBUTE,
|
|
273
|
+
otlpAttributes,
|
|
274
|
+
otlpEndpoint,
|
|
275
|
+
otlpHeaders,
|
|
276
|
+
otlpMetricExporter,
|
|
277
|
+
otlpMetricsRequest,
|
|
278
|
+
otlpResource,
|
|
279
|
+
otlpSpanExporter,
|
|
280
|
+
otlpTraceRequest,
|
|
281
|
+
parentBasedRatioSampler,
|
|
282
|
+
parseSentryDsn,
|
|
283
|
+
parseTraceparent,
|
|
284
|
+
queueDepth,
|
|
285
|
+
REDACTED,
|
|
286
|
+
ratioSampler,
|
|
287
|
+
recordConnection,
|
|
288
|
+
recordJob,
|
|
289
|
+
recordLeaseLost,
|
|
290
|
+
recordQueueDepth,
|
|
291
|
+
recordRequest,
|
|
292
|
+
redactKeys,
|
|
293
|
+
reportError,
|
|
294
|
+
requestDuration,
|
|
295
|
+
requests,
|
|
296
|
+
resetDefaultSampler,
|
|
297
|
+
resetErrorReporting,
|
|
298
|
+
resetMetrics,
|
|
299
|
+
resetTelemetry,
|
|
300
|
+
SCALING_METRICS,
|
|
301
|
+
samplerFromEnv,
|
|
302
|
+
sentryEnvelope,
|
|
303
|
+
sentryErrorReporter,
|
|
304
|
+
serviceResource,
|
|
305
|
+
setLoggerContextFields,
|
|
306
|
+
startMetricExport,
|
|
307
|
+
startSpan,
|
|
308
|
+
traceparent,
|
|
309
|
+
tryOtlpEndpoint,
|
|
310
|
+
unixNano,
|
|
311
|
+
withSpan,
|
|
312
|
+
withSpanContext,
|
|
313
|
+
} from './exports/observability';
|
|
314
|
+
export type {
|
|
315
|
+
MasterKeyRef,
|
|
316
|
+
MasterKeySource,
|
|
317
|
+
Secret,
|
|
318
|
+
SecretSummary,
|
|
319
|
+
SecretsEnvelope,
|
|
320
|
+
SecretsErrorCode,
|
|
321
|
+
SecretsInstallOptions,
|
|
322
|
+
SecretsInstallReport,
|
|
323
|
+
SecretsLocation,
|
|
324
|
+
SecretValues,
|
|
325
|
+
} from './exports/secrets';
|
|
326
|
+
export {
|
|
327
|
+
assertSecretValues,
|
|
328
|
+
describeSecrets,
|
|
329
|
+
findMasterKey,
|
|
330
|
+
generateMasterKey,
|
|
331
|
+
installSecrets,
|
|
332
|
+
isSecret,
|
|
333
|
+
masterKeyId,
|
|
334
|
+
masterKeyIdOf,
|
|
335
|
+
masterKeyPath,
|
|
336
|
+
openSecrets,
|
|
337
|
+
parseMasterKey,
|
|
338
|
+
parseSecretsEnvelope,
|
|
339
|
+
readSecretsFile,
|
|
340
|
+
requireMasterKey,
|
|
341
|
+
revealOptionalSecret,
|
|
342
|
+
revealSecret,
|
|
343
|
+
SECRET_BRAND,
|
|
344
|
+
SECRET_NAME,
|
|
345
|
+
SECRETS_ALG,
|
|
346
|
+
SECRETS_ERROR_CODES,
|
|
347
|
+
SECRETS_FILE,
|
|
348
|
+
SECRETS_IV_BYTES,
|
|
349
|
+
SECRETS_KEY_BYTES,
|
|
350
|
+
SECRETS_KEY_ENV,
|
|
351
|
+
SECRETS_KEY_FILE,
|
|
352
|
+
SECRETS_KEY_HEX_LENGTH,
|
|
353
|
+
SECRETS_KEY_ID_LENGTH,
|
|
354
|
+
SECRETS_KEY_MODE,
|
|
355
|
+
SECRETS_TAG_BYTES,
|
|
356
|
+
SECRETS_VERSION,
|
|
357
|
+
SecretsFileInvalidError,
|
|
358
|
+
SecretsFileMissingError,
|
|
359
|
+
SecretsKeyInvalidError,
|
|
360
|
+
SecretsKeyMismatchError,
|
|
361
|
+
SecretsKeyMissingError,
|
|
362
|
+
SecretsPlaintextInvalidError,
|
|
363
|
+
SecretsTamperedError,
|
|
364
|
+
sealSecrets,
|
|
365
|
+
secret,
|
|
366
|
+
secretsFileExists,
|
|
367
|
+
secretsPath,
|
|
368
|
+
serializeSecretValues,
|
|
369
|
+
writeMasterKeyFile,
|
|
370
|
+
writeSecretsFile,
|
|
371
|
+
} from './exports/secrets';
|
|
134
372
|
export type { Brand, Id } from './ids';
|
|
135
373
|
export {
|
|
374
|
+
isSpanId,
|
|
375
|
+
isTraceId,
|
|
136
376
|
isUuid,
|
|
137
377
|
nanoid,
|
|
138
378
|
parseId,
|
|
@@ -187,6 +427,8 @@ export {
|
|
|
187
427
|
} from './image/raster';
|
|
188
428
|
export type { ImageFit, ResizeSpec } from './image/resize';
|
|
189
429
|
export { fitBox, resizeRaster, scaledToFit } from './image/resize';
|
|
430
|
+
export { impersonate, impersonationReason, isImpersonating } from './impersonate';
|
|
431
|
+
export { cachedFormatter, canonicalLocale, MAX_CACHED_FORMATTERS } from './intl-cache';
|
|
190
432
|
export type {
|
|
191
433
|
HealthPayload,
|
|
192
434
|
HealthReport,
|
|
@@ -194,6 +436,8 @@ export type {
|
|
|
194
436
|
LifecycleOptions,
|
|
195
437
|
OnShutdownOptions,
|
|
196
438
|
ProcessSignal,
|
|
439
|
+
ReadinessCheck,
|
|
440
|
+
ReadinessStatus,
|
|
197
441
|
ShutdownHook,
|
|
198
442
|
ShutdownPhase,
|
|
199
443
|
ShutdownReason,
|
|
@@ -203,17 +447,23 @@ export {
|
|
|
203
447
|
beginWork,
|
|
204
448
|
configureLifecycle,
|
|
205
449
|
drain,
|
|
450
|
+
drainDeadlineMs,
|
|
206
451
|
healthReport,
|
|
207
452
|
healthzPayload,
|
|
453
|
+
idleWaiterCount,
|
|
208
454
|
inflightCount,
|
|
209
455
|
installSignalHandlers,
|
|
210
456
|
isDraining,
|
|
211
457
|
lifecycleState,
|
|
212
458
|
markReady,
|
|
213
459
|
onShutdown,
|
|
460
|
+
readinessCheckCount,
|
|
461
|
+
readinessChecks,
|
|
214
462
|
readyzPayload,
|
|
463
|
+
registerReadinessCheck,
|
|
215
464
|
resetLifecycle,
|
|
216
465
|
SHUTDOWN_PHASES,
|
|
466
|
+
shutdownHookCount,
|
|
217
467
|
} from './lifecycle';
|
|
218
468
|
export {
|
|
219
469
|
isSelfOrigin,
|
|
@@ -221,51 +471,10 @@ export {
|
|
|
221
471
|
markListening,
|
|
222
472
|
resetListeners,
|
|
223
473
|
} from './listeners';
|
|
224
|
-
export type {
|
|
225
|
-
export {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
LOG_LEVELS,
|
|
229
|
-
logger,
|
|
230
|
-
REDACTED,
|
|
231
|
-
redactKeys,
|
|
232
|
-
setLoggerContextFields,
|
|
233
|
-
} from './logger';
|
|
234
|
-
export type {
|
|
235
|
-
Counter,
|
|
236
|
-
Gauge,
|
|
237
|
-
GaugeOptions,
|
|
238
|
-
Histogram,
|
|
239
|
-
HistogramOptions,
|
|
240
|
-
HistogramPoint,
|
|
241
|
-
InstrumentOptions,
|
|
242
|
-
MemoryMetricExporter,
|
|
243
|
-
MetricAttributes,
|
|
244
|
-
MetricAttributeValue,
|
|
245
|
-
MetricCollection,
|
|
246
|
-
MetricDescriptor,
|
|
247
|
-
MetricExporter,
|
|
248
|
-
MetricKind,
|
|
249
|
-
MetricPoint,
|
|
250
|
-
MetricsOptions,
|
|
251
|
-
ReadableMetric,
|
|
252
|
-
} from './metrics';
|
|
253
|
-
export {
|
|
254
|
-
collectMetrics,
|
|
255
|
-
configureMetrics,
|
|
256
|
-
counter,
|
|
257
|
-
DEFAULT_HISTOGRAM_BOUNDS,
|
|
258
|
-
exportMetrics,
|
|
259
|
-
gauge,
|
|
260
|
-
histogram,
|
|
261
|
-
MetricNameInvalidError,
|
|
262
|
-
MetricValueInvalidError,
|
|
263
|
-
memoryMetricExporter,
|
|
264
|
-
noopMetricExporter,
|
|
265
|
-
resetMetrics,
|
|
266
|
-
startMetricExport,
|
|
267
|
-
} from './metrics';
|
|
268
|
-
export { METRICS_CONTENT_TYPE, METRICS_PATH, metricsText } from './metrics-text';
|
|
474
|
+
export type { McpExposureDeclaration } from './mcp-exposure';
|
|
475
|
+
export { isMcpExposed } from './mcp-exposure';
|
|
476
|
+
export type { CappedBody } from './read-capped';
|
|
477
|
+
export { readWithinLimit } from './read-capped';
|
|
269
478
|
export type { ModuleRegistrar, PrimitiveKind, RegisteredPrimitive } from './registrar';
|
|
270
479
|
export {
|
|
271
480
|
hasPrimitiveRegistrar,
|
|
@@ -278,57 +487,14 @@ export type { Err, Ok, Result } from './result';
|
|
|
278
487
|
export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from './result';
|
|
279
488
|
export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles';
|
|
280
489
|
export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
|
|
281
|
-
export
|
|
282
|
-
export {
|
|
283
|
-
connections,
|
|
284
|
-
jobs,
|
|
285
|
-
queueDepth,
|
|
286
|
-
recordConnection,
|
|
287
|
-
recordJob,
|
|
288
|
-
recordQueueDepth,
|
|
289
|
-
recordRequest,
|
|
290
|
-
requestDuration,
|
|
291
|
-
requests,
|
|
292
|
-
SCALING_METRICS,
|
|
293
|
-
} from './runtime-metrics';
|
|
294
|
-
export type { Secret } from './secret';
|
|
295
|
-
export {
|
|
296
|
-
isSecret,
|
|
297
|
-
revealOptionalSecret,
|
|
298
|
-
revealSecret,
|
|
299
|
-
SECRET_BRAND,
|
|
300
|
-
secret,
|
|
301
|
-
} from './secret';
|
|
490
|
+
export { safeUrl, URL_ATTRIBUTES } from './safe-url';
|
|
302
491
|
export type { ServiceFactory } from './service';
|
|
303
492
|
export { defineService, resetServices } from './service';
|
|
304
|
-
export
|
|
305
|
-
AttributeValue,
|
|
306
|
-
MemoryExporter,
|
|
307
|
-
ReadableSpan,
|
|
308
|
-
Span,
|
|
309
|
-
SpanAttributes,
|
|
310
|
-
SpanContext,
|
|
311
|
-
SpanEvent,
|
|
312
|
-
SpanExporter,
|
|
313
|
-
SpanKind,
|
|
314
|
-
SpanResource,
|
|
315
|
-
SpanStatus,
|
|
316
|
-
SpanStatusCode,
|
|
317
|
-
StartSpanOptions,
|
|
318
|
-
TelemetryOptions,
|
|
319
|
-
} from './telemetry';
|
|
493
|
+
export { timingSafeEqual } from './timing-safe-equal';
|
|
320
494
|
export {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
resetTelemetry,
|
|
328
|
-
serviceResource,
|
|
329
|
-
startSpan,
|
|
330
|
-
traceparent,
|
|
331
|
-
withSpan,
|
|
332
|
-
withSpanContext,
|
|
333
|
-
} from './telemetry';
|
|
334
|
-
export { FRAMEWORK_VERSION, readPackageVersion, VERSION_MANIFEST } from './version';
|
|
495
|
+
frameworkVersion,
|
|
496
|
+
readPackageVersion,
|
|
497
|
+
resolveVersion,
|
|
498
|
+
VERSION_DEFINE,
|
|
499
|
+
VERSION_MANIFEST,
|
|
500
|
+
} from './version';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// One bounded cache, on one canonical key, for every `Intl` formatter the framework builds.
|
|
2
|
+
// A locale and a zone both arrive from a request header, so an unbounded `Map` keyed on the
|
|
3
|
+
// caller's spelling is memory the client chooses — 31 MB and 55.1 MB, measured `As of 2026-08` and
|
|
4
|
+
// written up in the README. The bound and the canonical key are two halves of ONE rule.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Above the full canonical IANA set (445 zones as of tzdata 2025) so a correct app never evicts,
|
|
8
|
+
* and small enough that the worst case is a few megabytes rather than a leak. A miss costs one
|
|
9
|
+
* `Intl` construction, never a wrong answer — which is what makes a bound safe here at all.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_CACHED_FORMATTERS = 512;
|
|
12
|
+
|
|
13
|
+
/** FIFO — a `Map` iterates in insertion order, so the first key inserted is the first evicted. */
|
|
14
|
+
export function cachedFormatter<T>(cache: Map<string, T>, key: string, build: () => T): T {
|
|
15
|
+
// Membership decides, never truthiness: `T` is the caller's, so a stored `undefined` is a hit.
|
|
16
|
+
// The cast is sound because `has` just proved the key is present, which `get`'s signature cannot.
|
|
17
|
+
if (cache.has(key)) return cache.get(key) as T;
|
|
18
|
+
const formatter = build();
|
|
19
|
+
if (cache.size >= MAX_CACHED_FORMATTERS) {
|
|
20
|
+
const oldest = cache.keys().next().value;
|
|
21
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
22
|
+
}
|
|
23
|
+
cache.set(key, formatter);
|
|
24
|
+
return formatter;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The canonical BCP 47 spelling, or `undefined` when the tag is not structurally valid at all
|
|
29
|
+
* (`en_US`, `''`, `not a locale`). Well-formed but unknown to ICU (`zz`) is a locale — `Intl`
|
|
30
|
+
* falls back for it, and refusing here would be stricter than the formatters this feeds.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately **not** memoised: this is string work, and a `Map` keyed on a header value is the
|
|
33
|
+
* unbounded cache the bound above exists to prevent.
|
|
34
|
+
*/
|
|
35
|
+
export function canonicalLocale(locale: string): string | undefined {
|
|
36
|
+
try {
|
|
37
|
+
// `getCanonicalLocales` runs the same IsStructurallyValidLanguageTag check that
|
|
38
|
+
// `supportedLocalesOf` throws on, and unlike it, hands back the canonical spelling.
|
|
39
|
+
return Intl.getCanonicalLocales(locale)[0];
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Single responsibility: run one shutdown hook under the drain's remaining budget. A hook that
|
|
2
|
+
// overruns is ABANDONED — still running, no longer awaited — because a deadline that only logged
|
|
3
|
+
// leaves the kubelet to SIGKILL the process, which is the failure the budget exists to prevent.
|
|
4
|
+
// Split out of `lifecycle.ts` so the race has no access to the drain's module state.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What became of one hook. `abandoned` is the only outcome the caller must report: the hook is
|
|
8
|
+
* still running, nothing will ever read its result, and whatever it had in flight may be
|
|
9
|
+
* incomplete — which is the price of a deadline and has to be visible in the log line.
|
|
10
|
+
*/
|
|
11
|
+
export type HookOutcome =
|
|
12
|
+
| { readonly kind: 'settled' }
|
|
13
|
+
| { readonly kind: 'failed'; readonly error: unknown }
|
|
14
|
+
| { readonly kind: 'abandoned' };
|
|
15
|
+
|
|
16
|
+
const SETTLED: HookOutcome = Object.freeze({ kind: 'settled' });
|
|
17
|
+
const ABANDONED: HookOutcome = Object.freeze({ kind: 'abandoned' });
|
|
18
|
+
|
|
19
|
+
const failed = (error: unknown): HookOutcome => ({ kind: 'failed', error });
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `work()` raced against `budgetMs` of REAL elapsed time. `budgetMs` is required and is a `number`:
|
|
23
|
+
* every drain has a budget, so "no budget" is not a case this has to answer for, and the signature
|
|
24
|
+
* is what stops it from becoming one.
|
|
25
|
+
*
|
|
26
|
+
* A synchronous throw and a rejection are one outcome (`failed`); the caller logs both the same
|
|
27
|
+
* way. Nothing here throws: a drain that rejects never reaches `process.exit(0)`.
|
|
28
|
+
*/
|
|
29
|
+
export function settleWithin(
|
|
30
|
+
work: () => void | Promise<void>,
|
|
31
|
+
budgetMs: number,
|
|
32
|
+
): Promise<HookOutcome> {
|
|
33
|
+
let started: void | Promise<void>;
|
|
34
|
+
try {
|
|
35
|
+
started = work();
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return Promise.resolve(failed(error));
|
|
38
|
+
}
|
|
39
|
+
const pending = Promise.resolve(started);
|
|
40
|
+
return new Promise<HookOutcome>((resolve) => {
|
|
41
|
+
let decided = false;
|
|
42
|
+
const timer = setTimeout(
|
|
43
|
+
() => {
|
|
44
|
+
if (decided) return;
|
|
45
|
+
decided = true;
|
|
46
|
+
resolve(ABANDONED);
|
|
47
|
+
},
|
|
48
|
+
Math.max(0, budgetMs),
|
|
49
|
+
);
|
|
50
|
+
// A budget already spent still gives a synchronous hook its turn — a resolved promise settles
|
|
51
|
+
// on a microtask and this timer on a macrotask — so closing a pool costs nothing it does not
|
|
52
|
+
// already have. It must also never be the thing keeping a drained process alive.
|
|
53
|
+
timer.unref?.();
|
|
54
|
+
// Attached unconditionally, on both settle paths: an abandoned hook that rejects later has
|
|
55
|
+
// nobody left awaiting it, and an unhandled rejection would kill the process this drain is
|
|
56
|
+
// trying to end cleanly. After the decision the outcome is dropped on purpose — the overrun
|
|
57
|
+
// was already reported, and a second line about a hook nobody is waiting for is noise.
|
|
58
|
+
pending.then(
|
|
59
|
+
() => {
|
|
60
|
+
if (decided) return;
|
|
61
|
+
decided = true;
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
resolve(SETTLED);
|
|
64
|
+
},
|
|
65
|
+
(error: unknown) => {
|
|
66
|
+
if (decided) return;
|
|
67
|
+
decided = true;
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
resolve(failed(error));
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Single responsibility: the one condition a lifecycle that has already drained raises, and the
|
|
2
|
+
// error that carries it. Split from `lifecycle.ts` so the state machine reads as a state machine,
|
|
3
|
+
// and registered here rather than in `error-codes.ts` for the reason `secrets-errors.ts` gives:
|
|
4
|
+
// the code and the module that throws it ship together.
|
|
5
|
+
|
|
6
|
+
import { registerErrorCodes } from './error-codes';
|
|
7
|
+
import { UltimateError } from './errors';
|
|
8
|
+
|
|
9
|
+
registerErrorCodes({
|
|
10
|
+
X_LIFECYCLE_DRAINED: { title: 'a drained process cannot become ready again' },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A role tried to become ready in a process whose lifecycle has already drained.
|
|
15
|
+
*
|
|
16
|
+
* Loud rather than tolerant, because the silent version was measured and is worse than a failed
|
|
17
|
+
* boot: `state` never leaves `stopped` and `drain()` has memoized its promise, so a second
|
|
18
|
+
* `createServer().start()` bound a real port, answered `X_DRAINING` (503) to every request, and was
|
|
19
|
+
* still accepting connections after its own `stop()` returned — a dead listener holding a port,
|
|
20
|
+
* with no log line naming what happened.
|
|
21
|
+
*
|
|
22
|
+
* The `fix` has to answer two readers, because there are exactly two ways here: a boot that starts
|
|
23
|
+
* a role after SIGTERM has already arrived, and a test that drained in one case and built a server
|
|
24
|
+
* in the next.
|
|
25
|
+
*/
|
|
26
|
+
export function lifecycleDrained(state: 'draining' | 'stopped'): UltimateError {
|
|
27
|
+
return new UltimateError({
|
|
28
|
+
code: 'X_LIFECYCLE_DRAINED',
|
|
29
|
+
cause: `this process is ${state} — its lifecycle has already drained, and a drain is terminal: one process, one lifecycle. A role marked ready now would bind a socket that answers 503 to every request, and drain() memoizes, so nothing would ever close it`,
|
|
30
|
+
fix: 'start every role BEFORE the first drain — a drained process is on its way out and is replaced, never reused. In a test, call resetLifecycle() from @ultimat3/core between the drain and the next start()',
|
|
31
|
+
meta: { state },
|
|
32
|
+
});
|
|
33
|
+
}
|