@ultimat3/core 1.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/LICENSE +21 -0
- package/README.md +171 -0
- package/package.json +33 -0
- package/src/actor.ts +81 -0
- package/src/assert.ts +44 -0
- package/src/clock.ts +51 -0
- package/src/config.ts +265 -0
- package/src/context.ts +210 -0
- package/src/cursor.ts +116 -0
- package/src/env.ts +259 -0
- package/src/error-codes.ts +131 -0
- package/src/errors.ts +159 -0
- package/src/ids.ts +132 -0
- package/src/image/color.ts +34 -0
- package/src/image/errors.ts +58 -0
- package/src/image/fixtures.ts +263 -0
- package/src/image/jpeg-decode.ts +283 -0
- package/src/image/jpeg-encode.ts +463 -0
- package/src/image/jpeg-headers.ts +267 -0
- package/src/image/jpeg-huffman.ts +202 -0
- package/src/image/jpeg-tables.ts +117 -0
- package/src/image/pipeline.ts +117 -0
- package/src/image/png-bytes.ts +91 -0
- package/src/image/png.ts +433 -0
- package/src/image/probe-svg.ts +85 -0
- package/src/image/probe.ts +302 -0
- package/src/image/raster.ts +71 -0
- package/src/image/resize.ts +320 -0
- package/src/index.ts +256 -0
- package/src/lifecycle.ts +242 -0
- package/src/listeners.ts +80 -0
- package/src/logger.ts +146 -0
- package/src/registrar.ts +94 -0
- package/src/result.ts +78 -0
- package/src/roles.ts +66 -0
- package/src/service.ts +61 -0
- package/src/telemetry.ts +290 -0
- package/src/version.ts +37 -0
package/src/roles.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Single responsibility: the runtime role table. One image, N processes; `ROLE` selects
|
|
2
|
+
// behaviour. Every role exposes /healthz + /readyz and drains on SIGTERM.
|
|
3
|
+
|
|
4
|
+
import { UltimateError } from './errors';
|
|
5
|
+
|
|
6
|
+
export const ROLES = ['web', 'sync', 'worker', 'scheduler', 'migrate', 'replicator'] as const;
|
|
7
|
+
|
|
8
|
+
export type Role = (typeof ROLES)[number];
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_ROLE: Role = 'web';
|
|
11
|
+
|
|
12
|
+
/** What each role scales on — machine-readable, so `x deploy` can emit sane defaults. */
|
|
13
|
+
export type ScalingSignal =
|
|
14
|
+
| 'rps'
|
|
15
|
+
| 'ws-connections'
|
|
16
|
+
| 'queue-depth'
|
|
17
|
+
| 'singleton'
|
|
18
|
+
| 'run-once'
|
|
19
|
+
| 'per-database';
|
|
20
|
+
|
|
21
|
+
export interface RoleInfo {
|
|
22
|
+
readonly role: Role;
|
|
23
|
+
readonly scalesOn: ScalingSignal;
|
|
24
|
+
/** Hard replica ceiling, when the role must not be scaled horizontally. */
|
|
25
|
+
readonly maxReplicas: number | null;
|
|
26
|
+
readonly stateful: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const ROLE_INFO: Readonly<Record<Role, RoleInfo>> = Object.freeze({
|
|
30
|
+
web: { role: 'web', scalesOn: 'rps', maxReplicas: null, stateful: false },
|
|
31
|
+
sync: { role: 'sync', scalesOn: 'ws-connections', maxReplicas: null, stateful: false },
|
|
32
|
+
worker: { role: 'worker', scalesOn: 'queue-depth', maxReplicas: null, stateful: false },
|
|
33
|
+
scheduler: { role: 'scheduler', scalesOn: 'singleton', maxReplicas: 1, stateful: false },
|
|
34
|
+
migrate: { role: 'migrate', scalesOn: 'run-once', maxReplicas: 1, stateful: false },
|
|
35
|
+
replicator: { role: 'replicator', scalesOn: 'per-database', maxReplicas: 1, stateful: true },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export function isRole(value: unknown): value is Role {
|
|
39
|
+
return typeof value === 'string' && (ROLES as readonly string[]).includes(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ResolveRoleOptions {
|
|
43
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
44
|
+
readonly key?: string | undefined;
|
|
45
|
+
readonly fallback?: Role | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read the role from the environment. Unset means `web` (the common case); a set-but-unknown
|
|
50
|
+
* value is always a mistake and throws `X_ROLE_INVALID`.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveRole(options?: ResolveRoleOptions): Role {
|
|
53
|
+
const key = options?.key ?? 'ROLE';
|
|
54
|
+
const source = options?.env ?? (process.env as Record<string, string | undefined>);
|
|
55
|
+
const raw = source[key];
|
|
56
|
+
if (raw === undefined || raw === '') return options?.fallback ?? DEFAULT_ROLE;
|
|
57
|
+
if (!isRole(raw)) {
|
|
58
|
+
throw new UltimateError({
|
|
59
|
+
code: 'X_ROLE_INVALID',
|
|
60
|
+
cause: `${key}="${raw}" is not one of ${ROLES.join(' | ')}`,
|
|
61
|
+
fix: `set ${key} to one of: ${ROLES.join(', ')}`,
|
|
62
|
+
meta: { key, received: raw, allowed: ROLES },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return raw;
|
|
66
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Single responsibility: named service factories that `createContext` installs automatically,
|
|
2
|
+
// bound to the exact ctx (actor, clock, tz) they were built for.
|
|
3
|
+
//
|
|
4
|
+
// A service closes over the `ctx` it was constructed with — `ctx.actor.orgId` inside it means
|
|
5
|
+
// "the actor this service was built for", not "whoever is calling right now". So a factory
|
|
6
|
+
// cannot be built once and cached: it has to run again every time `createContext` produces a
|
|
7
|
+
// ctx with a different actor, or an impersonated call would read the wrong tenant. Registering
|
|
8
|
+
// with `defineService` is what lets `createContext` do that automatically instead of every
|
|
9
|
+
// caller wiring `services: { posts: postsService(ctx) }` by hand at every call site.
|
|
10
|
+
|
|
11
|
+
import type { Ctx, ServiceBag } from './context';
|
|
12
|
+
import { UltimateError } from './errors';
|
|
13
|
+
|
|
14
|
+
export type ServiceFactory<T = unknown> = (ctx: Ctx) => T;
|
|
15
|
+
|
|
16
|
+
const factories = new Map<string, ServiceFactory>();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Register a named service factory, once per app boot — importing the module that calls this
|
|
20
|
+
* IS the registration, the same convention `registerActions` uses. Returns the factory
|
|
21
|
+
* unchanged, so `export const postsService = defineService('posts', (ctx) => ({...}))` still
|
|
22
|
+
* exports a plain callable a test can invoke directly, without going through a context at all.
|
|
23
|
+
*/
|
|
24
|
+
export function defineService<T>(name: string, factory: ServiceFactory<T>): ServiceFactory<T> {
|
|
25
|
+
if (factories.has(name)) {
|
|
26
|
+
throw new UltimateError({
|
|
27
|
+
code: 'X_SERVICE_DUPLICATE',
|
|
28
|
+
cause: `a service named "${name}" is already registered`,
|
|
29
|
+
fix: `rename one of the two defineService('${name}', ...) declarations`,
|
|
30
|
+
meta: { name },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
factories.set(name, factory as ServiceFactory);
|
|
34
|
+
return factory;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether `name` is a registered factory. `withChildContext` uses this so an impersonated
|
|
39
|
+
* child never carries forward a parent's instance built for a different actor — only ad hoc
|
|
40
|
+
* services nobody registered (a test's hand-built mock) survive the swap unrebuilt.
|
|
41
|
+
*/
|
|
42
|
+
export function isManagedService(name: string): boolean {
|
|
43
|
+
return factories.has(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every registered factory, called fresh against `ctx`. `ctx` carries no other registered
|
|
48
|
+
* service yet — a factory reads the ambient actor/clock/tz, never a sibling service, so
|
|
49
|
+
* factories cannot depend on one another's instances.
|
|
50
|
+
*/
|
|
51
|
+
export function installedServices(ctx: Ctx): ServiceBag {
|
|
52
|
+
if (factories.size === 0) return {};
|
|
53
|
+
const bag: Record<string, unknown> = {};
|
|
54
|
+
for (const [name, factory] of factories) bag[name] = factory(ctx);
|
|
55
|
+
return Object.freeze(bag);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Test-only. Production registers once at boot and never unregisters. */
|
|
59
|
+
export function resetServices(): void {
|
|
60
|
+
factories.clear();
|
|
61
|
+
}
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Single responsibility: OpenTelemetry-shaped tracing that is always on. The default exporter
|
|
2
|
+
// is a no-op so unconfigured apps pay nothing, and trace context is serialised explicitly
|
|
3
|
+
// (`traceparent`) so a trace survives HTTP -> job -> live query.
|
|
4
|
+
|
|
5
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
|
+
import { type Clock, systemClock } from './clock';
|
|
7
|
+
import { tryUseContext } from './context';
|
|
8
|
+
import { isUltimateError } from './errors';
|
|
9
|
+
import { spanId as newSpanId, traceId as newTraceId } from './ids';
|
|
10
|
+
|
|
11
|
+
export type SpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer';
|
|
12
|
+
|
|
13
|
+
export type AttributeValue = string | number | boolean | readonly string[] | readonly number[];
|
|
14
|
+
|
|
15
|
+
export interface SpanAttributes {
|
|
16
|
+
readonly [key: string]: AttributeValue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SpanContext {
|
|
20
|
+
readonly traceId: string;
|
|
21
|
+
readonly spanId: string;
|
|
22
|
+
/** Bit 0 = sampled, per W3C trace-context. */
|
|
23
|
+
readonly traceFlags: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SpanEvent {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly at: number;
|
|
29
|
+
readonly attributes: SpanAttributes;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SpanStatusCode = 'unset' | 'ok' | 'error';
|
|
33
|
+
|
|
34
|
+
export interface SpanStatus {
|
|
35
|
+
readonly code: SpanStatusCode;
|
|
36
|
+
readonly message?: string | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ReadableSpan {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly kind: SpanKind;
|
|
42
|
+
readonly context: SpanContext;
|
|
43
|
+
readonly parentSpanId: string | undefined;
|
|
44
|
+
/** Epoch milliseconds. */
|
|
45
|
+
readonly startedAt: number;
|
|
46
|
+
readonly endedAt: number;
|
|
47
|
+
readonly durationMs: number;
|
|
48
|
+
readonly attributes: SpanAttributes;
|
|
49
|
+
readonly events: readonly SpanEvent[];
|
|
50
|
+
readonly status: SpanStatus;
|
|
51
|
+
readonly links: readonly SpanContext[];
|
|
52
|
+
readonly resource: SpanResource;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SpanResource {
|
|
56
|
+
readonly serviceName: string;
|
|
57
|
+
readonly serviceVersion: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Span {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly context: SpanContext;
|
|
63
|
+
readonly ended: boolean;
|
|
64
|
+
setAttribute(key: string, value: AttributeValue): Span;
|
|
65
|
+
setAttributes(attributes: SpanAttributes): Span;
|
|
66
|
+
addEvent(name: string, attributes?: SpanAttributes): Span;
|
|
67
|
+
recordError(error: unknown): Span;
|
|
68
|
+
setStatus(code: SpanStatusCode, message?: string): Span;
|
|
69
|
+
end(): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SpanExporter {
|
|
73
|
+
export(span: ReadableSpan): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface StartSpanOptions {
|
|
77
|
+
readonly kind?: SpanKind | undefined;
|
|
78
|
+
readonly attributes?: SpanAttributes | undefined;
|
|
79
|
+
/** Explicit parent. Falls back to the active span, then to the request context's traceId. */
|
|
80
|
+
readonly parent?: SpanContext | undefined;
|
|
81
|
+
readonly links?: readonly SpanContext[] | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface TelemetryOptions {
|
|
85
|
+
readonly exporter?: SpanExporter | undefined;
|
|
86
|
+
readonly clock?: Clock | undefined;
|
|
87
|
+
readonly serviceName?: string | undefined;
|
|
88
|
+
readonly serviceVersion?: string | undefined;
|
|
89
|
+
readonly enabled?: boolean | undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const noopExporter: SpanExporter = Object.freeze({
|
|
93
|
+
export(): void {
|
|
94
|
+
// Intentionally empty: tracing is always on, and free until an exporter is configured.
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export interface MemoryExporter extends SpanExporter {
|
|
99
|
+
readonly spans: readonly ReadableSpan[];
|
|
100
|
+
reset(): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** For tests and for `x trace --json`. */
|
|
104
|
+
export function memoryExporter(): MemoryExporter {
|
|
105
|
+
const spans: ReadableSpan[] = [];
|
|
106
|
+
return {
|
|
107
|
+
spans,
|
|
108
|
+
export(span: ReadableSpan): void {
|
|
109
|
+
spans.push(span);
|
|
110
|
+
},
|
|
111
|
+
reset(): void {
|
|
112
|
+
spans.length = 0;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const activeSpan = new AsyncLocalStorage<Span>();
|
|
118
|
+
|
|
119
|
+
let exporter: SpanExporter = noopExporter;
|
|
120
|
+
let clock: Clock = systemClock;
|
|
121
|
+
let enabled = true;
|
|
122
|
+
let resource: SpanResource = Object.freeze({ serviceName: 'ultimate', serviceVersion: '0.0.1' });
|
|
123
|
+
|
|
124
|
+
export function configureTelemetry(options: TelemetryOptions): void {
|
|
125
|
+
if (options.exporter !== undefined) exporter = options.exporter;
|
|
126
|
+
if (options.clock !== undefined) clock = options.clock;
|
|
127
|
+
if (options.enabled !== undefined) enabled = options.enabled;
|
|
128
|
+
if (options.serviceName !== undefined || options.serviceVersion !== undefined) {
|
|
129
|
+
resource = Object.freeze({
|
|
130
|
+
serviceName: options.serviceName ?? resource.serviceName,
|
|
131
|
+
serviceVersion: options.serviceVersion ?? resource.serviceVersion,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function resetTelemetry(): void {
|
|
137
|
+
exporter = noopExporter;
|
|
138
|
+
clock = systemClock;
|
|
139
|
+
enabled = true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function currentSpan(): Span | undefined {
|
|
143
|
+
return activeSpan.getStore();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The trace the caller is inside: active span, else the request context, else a fresh trace. */
|
|
147
|
+
export function currentSpanContext(): SpanContext | undefined {
|
|
148
|
+
const span = activeSpan.getStore();
|
|
149
|
+
if (span !== undefined) return span.context;
|
|
150
|
+
const ctx = tryUseContext();
|
|
151
|
+
if (ctx === undefined) return undefined;
|
|
152
|
+
return { traceId: ctx.traceId, spanId: '', traceFlags: 1 };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function startSpan(name: string, options?: StartSpanOptions): Span {
|
|
156
|
+
const parent = options?.parent ?? currentSpanContext();
|
|
157
|
+
const context: SpanContext = {
|
|
158
|
+
traceId: parent?.traceId ?? newTraceId(),
|
|
159
|
+
spanId: newSpanId(),
|
|
160
|
+
traceFlags: parent?.traceFlags ?? 1,
|
|
161
|
+
};
|
|
162
|
+
const attributes: Record<string, AttributeValue> = { ...(options?.attributes ?? {}) };
|
|
163
|
+
const events: SpanEvent[] = [];
|
|
164
|
+
const startedAt = clock.now().getTime();
|
|
165
|
+
const startedMono = clock.monotonic();
|
|
166
|
+
let status: SpanStatus = { code: 'unset' };
|
|
167
|
+
let ended = false;
|
|
168
|
+
|
|
169
|
+
const span: Span = {
|
|
170
|
+
name,
|
|
171
|
+
context,
|
|
172
|
+
get ended(): boolean {
|
|
173
|
+
return ended;
|
|
174
|
+
},
|
|
175
|
+
setAttribute(key, value) {
|
|
176
|
+
attributes[key] = value;
|
|
177
|
+
return span;
|
|
178
|
+
},
|
|
179
|
+
setAttributes(next) {
|
|
180
|
+
for (const [key, value] of Object.entries(next)) attributes[key] = value;
|
|
181
|
+
return span;
|
|
182
|
+
},
|
|
183
|
+
addEvent(eventName, eventAttributes) {
|
|
184
|
+
events.push({
|
|
185
|
+
name: eventName,
|
|
186
|
+
at: clock.now().getTime(),
|
|
187
|
+
attributes: eventAttributes ?? {},
|
|
188
|
+
});
|
|
189
|
+
return span;
|
|
190
|
+
},
|
|
191
|
+
recordError(error) {
|
|
192
|
+
const code = isUltimateError(error) ? error.code : 'X_INTERNAL';
|
|
193
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
194
|
+
events.push({
|
|
195
|
+
name: 'exception',
|
|
196
|
+
at: clock.now().getTime(),
|
|
197
|
+
attributes: { 'error.code': code, 'error.message': message },
|
|
198
|
+
});
|
|
199
|
+
status = { code: 'error', message };
|
|
200
|
+
return span;
|
|
201
|
+
},
|
|
202
|
+
setStatus(code, message) {
|
|
203
|
+
status = { code, message };
|
|
204
|
+
return span;
|
|
205
|
+
},
|
|
206
|
+
end(): void {
|
|
207
|
+
if (ended) return;
|
|
208
|
+
ended = true;
|
|
209
|
+
if (!enabled) return;
|
|
210
|
+
const endedAt = clock.now().getTime();
|
|
211
|
+
const parentSpanId = parent === undefined || parent.spanId === '' ? undefined : parent.spanId;
|
|
212
|
+
exporter.export({
|
|
213
|
+
name,
|
|
214
|
+
kind: options?.kind ?? 'internal',
|
|
215
|
+
context,
|
|
216
|
+
parentSpanId,
|
|
217
|
+
startedAt,
|
|
218
|
+
endedAt,
|
|
219
|
+
durationMs: clock.monotonic() - startedMono,
|
|
220
|
+
attributes,
|
|
221
|
+
events,
|
|
222
|
+
status,
|
|
223
|
+
links: options?.links ?? [],
|
|
224
|
+
resource,
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
return span;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Runs `fn` inside a span, ending it on return, resolve or throw. Sync and async both work. */
|
|
232
|
+
export function withSpan<T>(name: string, fn: (span: Span) => T, options?: StartSpanOptions): T {
|
|
233
|
+
const span = startSpan(name, options);
|
|
234
|
+
return activeSpan.run(span, () => {
|
|
235
|
+
try {
|
|
236
|
+
const result = fn(span);
|
|
237
|
+
if (isPromiseLike(result)) {
|
|
238
|
+
return result.then(
|
|
239
|
+
(value) => {
|
|
240
|
+
span.end();
|
|
241
|
+
return value;
|
|
242
|
+
},
|
|
243
|
+
(reason: unknown) => {
|
|
244
|
+
span.recordError(reason);
|
|
245
|
+
span.end();
|
|
246
|
+
throw reason;
|
|
247
|
+
},
|
|
248
|
+
) as unknown as T;
|
|
249
|
+
}
|
|
250
|
+
span.end();
|
|
251
|
+
return result;
|
|
252
|
+
} catch (thrown) {
|
|
253
|
+
span.recordError(thrown);
|
|
254
|
+
span.end();
|
|
255
|
+
throw thrown;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Continue an inbound trace. The HTTP, job and realtime layers all call this. */
|
|
261
|
+
export function withSpanContext<T>(context: SpanContext, name: string, fn: (span: Span) => T): T {
|
|
262
|
+
return withSpan(name, fn, { parent: context });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
266
|
+
|
|
267
|
+
export function traceparent(context: SpanContext): string {
|
|
268
|
+
const flags = context.traceFlags.toString(16).padStart(2, '0');
|
|
269
|
+
return `00-${context.traceId}-${context.spanId}-${flags}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function parseTraceparent(header: string | null | undefined): SpanContext | undefined {
|
|
273
|
+
if (header === null || header === undefined) return undefined;
|
|
274
|
+
const match = TRACEPARENT_RE.exec(header.trim());
|
|
275
|
+
if (match === null) return undefined;
|
|
276
|
+
return {
|
|
277
|
+
traceId: match[1] as string,
|
|
278
|
+
spanId: match[2] as string,
|
|
279
|
+
traceFlags: Number.parseInt(match[3] as string, 16),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
284
|
+
return (
|
|
285
|
+
typeof value === 'object' &&
|
|
286
|
+
value !== null &&
|
|
287
|
+
'then' in value &&
|
|
288
|
+
typeof (value as { then: unknown }).then === 'function'
|
|
289
|
+
);
|
|
290
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The one version string the framework reports — MCP `serverInfo`, `x --version`, the deps a
|
|
2
|
+
// scaffold pins. Read from this package's OWN package.json, never the workspace root: the root
|
|
3
|
+
// manifest is `private` and carries no `version`, and after `npm install` a walk above the package
|
|
4
|
+
// lands in `node_modules/` where there is no manifest at all. Both mistakes fail silently.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
// Bun has no path-join primitive; `import.meta.dir` is the module's own directory in both the
|
|
8
|
+
// checked-out `src/` layout and the published `dist/` one, each exactly one level below the
|
|
9
|
+
// package root.
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
import { UltimateError } from './errors';
|
|
12
|
+
|
|
13
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)*$/;
|
|
14
|
+
|
|
15
|
+
/** `@ultimat3/core`'s own manifest — the only file that can answer what version shipped. */
|
|
16
|
+
export const VERSION_MANIFEST = resolve(import.meta.dir, '..', 'package.json');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A package with no readable version is a broken publish, not a runtime condition to degrade
|
|
20
|
+
* through: an `undefined` version poisons the MCP handshake and every dependency a scaffold pins,
|
|
21
|
+
* and does it quietly. Fail at import, where the fix is a release-script change.
|
|
22
|
+
*/
|
|
23
|
+
export function readPackageVersion(manifestPath: string): string {
|
|
24
|
+
const raw: unknown = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
25
|
+
const version =
|
|
26
|
+
typeof raw === 'object' && raw !== null ? (raw as { version?: unknown }).version : undefined;
|
|
27
|
+
if (typeof version !== 'string' || !SEMVER.test(version)) {
|
|
28
|
+
throw new UltimateError({
|
|
29
|
+
code: 'X_INVARIANT',
|
|
30
|
+
cause: `${manifestPath} has no valid semver "version" field (found ${JSON.stringify(version)})`,
|
|
31
|
+
fix: `set a semver "version" in ${manifestPath}, then re-run: bun run verify`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return version;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const FRAMEWORK_VERSION: string = readPackageVersion(VERSION_MANIFEST);
|