@ultimat3/core 1.1.0 → 2.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 +252 -0
- package/README.md +217 -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 +13 -0
- 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 +100 -0
- package/src/errors.ts +55 -7
- package/src/exports/error-contract.ts +61 -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 +277 -113
- package/src/lifecycle-deadline.ts +73 -0
- package/src/lifecycle-errors.ts +33 -0
- package/src/lifecycle.ts +178 -16
- 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
package/src/telemetry.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
6
|
import { type Clock, systemClock } from './clock';
|
|
7
7
|
import { tryUseContext } from './context';
|
|
8
|
+
import { renderThrowable } from './error-render';
|
|
8
9
|
import { isUltimateError } from './errors';
|
|
9
|
-
import { spanId as newSpanId, traceId as newTraceId } from './ids';
|
|
10
|
+
import { isSpanId, isTraceId, spanId as newSpanId, traceId as newTraceId } from './ids';
|
|
11
|
+
import { defaultSampler, resetDefaultSampler, type Sampler } from './sampler';
|
|
10
12
|
|
|
11
13
|
export type SpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer';
|
|
12
14
|
|
|
@@ -87,6 +89,8 @@ export interface TelemetryOptions {
|
|
|
87
89
|
readonly serviceName?: string | undefined;
|
|
88
90
|
readonly serviceVersion?: string | undefined;
|
|
89
91
|
readonly enabled?: boolean | undefined;
|
|
92
|
+
/** Defaults to `defaultSampler()`: honour the parent, else the ratio the env asks for. */
|
|
93
|
+
readonly sampler?: Sampler | undefined;
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
export const noopExporter: SpanExporter = Object.freeze({
|
|
@@ -100,7 +104,7 @@ export interface MemoryExporter extends SpanExporter {
|
|
|
100
104
|
reset(): void;
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
/** For tests and for
|
|
107
|
+
/** For tests, and for reading back what a run traced with no collector on the box. */
|
|
104
108
|
export function memoryExporter(): MemoryExporter {
|
|
105
109
|
const spans: ReadableSpan[] = [];
|
|
106
110
|
return {
|
|
@@ -119,12 +123,14 @@ const activeSpan = new AsyncLocalStorage<Span>();
|
|
|
119
123
|
let exporter: SpanExporter = noopExporter;
|
|
120
124
|
let clock: Clock = systemClock;
|
|
121
125
|
let enabled = true;
|
|
126
|
+
let sampler: Sampler | undefined;
|
|
122
127
|
let resource: SpanResource = Object.freeze({ serviceName: 'ultimate', serviceVersion: '0.0.1' });
|
|
123
128
|
|
|
124
129
|
export function configureTelemetry(options: TelemetryOptions): void {
|
|
125
130
|
if (options.exporter !== undefined) exporter = options.exporter;
|
|
126
131
|
if (options.clock !== undefined) clock = options.clock;
|
|
127
132
|
if (options.enabled !== undefined) enabled = options.enabled;
|
|
133
|
+
if (options.sampler !== undefined) sampler = options.sampler;
|
|
128
134
|
if (options.serviceName !== undefined || options.serviceVersion !== undefined) {
|
|
129
135
|
resource = Object.freeze({
|
|
130
136
|
serviceName: options.serviceName ?? resource.serviceName,
|
|
@@ -137,6 +143,13 @@ export function resetTelemetry(): void {
|
|
|
137
143
|
exporter = noopExporter;
|
|
138
144
|
clock = systemClock;
|
|
139
145
|
enabled = true;
|
|
146
|
+
sampler = undefined;
|
|
147
|
+
resetDefaultSampler();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The sampler in force: whatever `configureTelemetry` was given, else the env's. */
|
|
151
|
+
export function currentSampler(): Sampler {
|
|
152
|
+
return sampler ?? defaultSampler();
|
|
140
153
|
}
|
|
141
154
|
|
|
142
155
|
/**
|
|
@@ -162,12 +175,16 @@ export function currentSpanContext(): SpanContext | undefined {
|
|
|
162
175
|
|
|
163
176
|
export function startSpan(name: string, options?: StartSpanOptions): Span {
|
|
164
177
|
const parent = options?.parent ?? currentSpanContext();
|
|
178
|
+
const attributes: Record<string, AttributeValue> = { ...(options?.attributes ?? {}) };
|
|
179
|
+
// The bit is decided ONCE, here, and every child of this span inherits it through `parent` —
|
|
180
|
+
// so one trace is sampled or not sampled as a whole. Before this, `traceFlags` was hardcoded to
|
|
181
|
+
// 1 for a root and `end()` exported regardless, which made the bit a value the framework
|
|
182
|
+
// forwarded and nobody obeyed.
|
|
165
183
|
const context: SpanContext = {
|
|
166
184
|
traceId: parent?.traceId ?? newTraceId(),
|
|
167
185
|
spanId: newSpanId(),
|
|
168
|
-
traceFlags: parent
|
|
186
|
+
traceFlags: currentSampler().shouldSample(name, parent, attributes) ? 1 : 0,
|
|
169
187
|
};
|
|
170
|
-
const attributes: Record<string, AttributeValue> = { ...(options?.attributes ?? {}) };
|
|
171
188
|
const events: SpanEvent[] = [];
|
|
172
189
|
const startedAt = clock.now().getTime();
|
|
173
190
|
const startedMono = clock.monotonic();
|
|
@@ -198,7 +215,12 @@ export function startSpan(name: string, options?: StartSpanOptions): Span {
|
|
|
198
215
|
},
|
|
199
216
|
recordError(error) {
|
|
200
217
|
const code = isUltimateError(error) ? error.code : 'X_INTERNAL';
|
|
201
|
-
|
|
218
|
+
// `renderThrowable`, never `error instanceof Error ? error.message : String(error)`: both
|
|
219
|
+
// halves are property reads on a value the framework did not build, and this runs inside
|
|
220
|
+
// `withSpan`'s catch — around `cache.invalidate`, `db.<verb>` and every HTTP and job span.
|
|
221
|
+
// A throw here substitutes the tracer's own TypeError for the caller's real failure and
|
|
222
|
+
// leaves the span it was annotating unended.
|
|
223
|
+
const message = renderThrowable(error);
|
|
202
224
|
events.push({
|
|
203
225
|
name: 'exception',
|
|
204
226
|
at: clock.now().getTime(),
|
|
@@ -215,6 +237,10 @@ export function startSpan(name: string, options?: StartSpanOptions): Span {
|
|
|
215
237
|
if (ended) return;
|
|
216
238
|
ended = true;
|
|
217
239
|
if (!enabled) return;
|
|
240
|
+
// The whole point of propagating a sampling bit is that somebody obeys it. A span still
|
|
241
|
+
// exists, still parents its children and still carries the decision onward in
|
|
242
|
+
// `traceparent`; it is simply not exported.
|
|
243
|
+
if ((context.traceFlags & 1) === 0) return;
|
|
218
244
|
const endedAt = clock.now().getTime();
|
|
219
245
|
const parentSpanId = parent === undefined || parent.spanId === '' ? undefined : parent.spanId;
|
|
220
246
|
exporter.export({
|
|
@@ -272,8 +298,13 @@ export function withSpanContext<T>(context: SpanContext, name: string, fn: (span
|
|
|
272
298
|
|
|
273
299
|
const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
274
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Round-trips with `traceId()` / `spanId()` from `ids.ts` and with nothing else. A context whose
|
|
303
|
+
* `traceId` came from `uuid()` renders a 36-character dashed header here that `parseTraceparent`
|
|
304
|
+
* — and every OTLP collector — rejects, so mint the pair with those two generators.
|
|
305
|
+
*/
|
|
275
306
|
export function traceparent(context: SpanContext): string {
|
|
276
|
-
const flags = context.traceFlags.toString(16).padStart(2, '0');
|
|
307
|
+
const flags = (context.traceFlags & 0xff).toString(16).padStart(2, '0');
|
|
277
308
|
return `00-${context.traceId}-${context.spanId}-${flags}`;
|
|
278
309
|
}
|
|
279
310
|
|
|
@@ -281,11 +312,12 @@ export function parseTraceparent(header: string | null | undefined): SpanContext
|
|
|
281
312
|
if (header === null || header === undefined) return undefined;
|
|
282
313
|
const match = TRACEPARENT_RE.exec(header.trim());
|
|
283
314
|
if (match === null) return undefined;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
315
|
+
const traceId = match[1] as string;
|
|
316
|
+
const spanId = match[2] as string;
|
|
317
|
+
// `ids.ts` owns what a valid id is, so the all-zero rejection the spec requires lives in one
|
|
318
|
+
// place instead of being a second regex here that drifts from the generator's.
|
|
319
|
+
if (!isTraceId(traceId) || !isSpanId(spanId)) return undefined;
|
|
320
|
+
return { traceId, spanId, traceFlags: Number.parseInt(match[3] as string, 16) };
|
|
289
321
|
}
|
|
290
322
|
|
|
291
323
|
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Single responsibility: a string comparison whose duration does not depend on where two secrets
|
|
2
|
+
// first differ. `@ultimat3/auth` and `@ultimat3/storage` both compared a signature or a hashed
|
|
3
|
+
// token this way and, being tier 1+ packages that both sit below `@ultimat3/core`, neither is the
|
|
4
|
+
// other's dependency — so the one copy lives here, at the tier both can reach.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Length is compared first and non-constant-time on purpose: every secret this compares is a
|
|
8
|
+
* fixed-width hash, token or signature, so the length carries no information, and the XOR
|
|
9
|
+
* accumulator below is what has to be branch-free.
|
|
10
|
+
*/
|
|
11
|
+
export function timingSafeEqual(a: string, b: string): boolean {
|
|
12
|
+
if (a.length !== b.length) return false;
|
|
13
|
+
let diff = 0;
|
|
14
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
15
|
+
diff |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
16
|
+
}
|
|
17
|
+
return diff === 0;
|
|
18
|
+
}
|
package/src/type-pins.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Compile-time pins for the actor-facts seam and the config surface. Source, not a `.test.ts`,
|
|
2
|
+
// on purpose:
|
|
3
|
+
// `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
|
|
4
|
+
// type-level assertion written there can never fail. This module emits nothing and exports
|
|
5
|
+
// nothing anybody imports — a regression is a build error, the only enforcement that counts.
|
|
6
|
+
|
|
7
|
+
import type { Actor, ActorFactMap, FactKeysOf, FactMapOf } from './actor';
|
|
8
|
+
import type { AppConfigInput, DatabaseConfig } from './config';
|
|
9
|
+
|
|
10
|
+
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
11
|
+
type Assert<T extends true> = T;
|
|
12
|
+
|
|
13
|
+
interface Viewer {
|
|
14
|
+
readonly friendIds: ReadonlySet<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A stand-in for what an app augments `ActorFacts` with. Declared locally rather than by
|
|
19
|
+
* augmenting the real interface: augmenting it HERE would declare `viewer` for every app that
|
|
20
|
+
* imports the framework, and a pin that changes the product it pins is not a pin.
|
|
21
|
+
*/
|
|
22
|
+
interface SampleFacts {
|
|
23
|
+
readonly viewer: Viewer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type SampleMap = FactMapOf<SampleFacts>;
|
|
27
|
+
|
|
28
|
+
/** A declared fact reads back as its own type — never `unknown`, never a bag. */
|
|
29
|
+
type _FactIsTyped = Assert<[NonNullable<SampleMap['viewer']>] extends [Viewer] ? true : false>;
|
|
30
|
+
|
|
31
|
+
type _FactIsNotUnknown = Assert<[unknown] extends [SampleMap['viewer']] ? false : true>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The denial rule, as a type. Every fact is independently absent because nothing can prove one
|
|
35
|
+
* was resolved — a job runner, a test and an MCP token exchange all mint actors too. A predicate
|
|
36
|
+
* therefore has to branch on `undefined`, so an absent fact cannot silently read as a satisfied
|
|
37
|
+
* one.
|
|
38
|
+
*/
|
|
39
|
+
type _AbsentFactIsRepresentable = Assert<undefined extends SampleMap['viewer'] ? true : false>;
|
|
40
|
+
|
|
41
|
+
/** A typo is a build error rather than a fact that is forever absent. */
|
|
42
|
+
type _UnknownFactIsNotAKey = Assert<'viewr' extends keyof SampleMap ? false : true>;
|
|
43
|
+
|
|
44
|
+
/** The phantom that keeps the empty interface from being `{}` is never itself a fact. */
|
|
45
|
+
type _PhantomIsNotAFactKey = Assert<'__ultimate' extends FactKeysOf<SampleFacts> ? false : true>;
|
|
46
|
+
|
|
47
|
+
/** An actor that resolved nothing is still an actor: every key is optional, always. */
|
|
48
|
+
type _NoFactsIsALegalFactMap = Assert<Record<string, never> extends SampleMap ? true : false>;
|
|
49
|
+
|
|
50
|
+
type _ActorFactMapAcceptsNothing = Assert<
|
|
51
|
+
Record<string, never> extends ActorFactMap ? true : false
|
|
52
|
+
>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The seam is additive: an actor literal written before it existed still is one. `facts` is
|
|
56
|
+
* optional for that reason and not only for the denial rule — a required member would have been
|
|
57
|
+
* a breaking change to a tier-0 type every package depends on.
|
|
58
|
+
*/
|
|
59
|
+
type _ActorWithoutFactsIsStillAnActor = Assert<
|
|
60
|
+
[
|
|
61
|
+
{
|
|
62
|
+
readonly kind: 'user';
|
|
63
|
+
readonly id: string;
|
|
64
|
+
readonly roles: readonly string[];
|
|
65
|
+
readonly scopes: readonly string[];
|
|
66
|
+
},
|
|
67
|
+
] extends [Actor]
|
|
68
|
+
? true
|
|
69
|
+
: false
|
|
70
|
+
>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The three `config.database` fields deleted 2026-08 must stay deleted. Each produced neither a
|
|
74
|
+
* build error nor a runtime effect, which is the worst state a config field can be in: an SRE set
|
|
75
|
+
* `poolSize: 3`, redeployed, and nothing changed. Re-adding one silently restores that, so the
|
|
76
|
+
* pin is here rather than in a `.test.ts` — a `@ts-expect-error` in an excluded file asserts
|
|
77
|
+
* nothing.
|
|
78
|
+
*
|
|
79
|
+
* `DATABASE_POOL_MAX` is the pool knob that works, `DATABASE_URL` is read as a literal by
|
|
80
|
+
* `@ultimat3/db`'s `client.ts`, and nothing emits `SET search_path`.
|
|
81
|
+
*/
|
|
82
|
+
type DeadDatabaseField = 'urlEnv' | 'poolSize' | 'schema';
|
|
83
|
+
|
|
84
|
+
type _DatabaseConfigCarriesNoDeadField = Assert<
|
|
85
|
+
Extract<keyof DatabaseConfig, DeadDatabaseField> extends never ? true : false
|
|
86
|
+
>;
|
|
87
|
+
|
|
88
|
+
/** And the input side with it — `Input<DatabaseConfig>` is what an `app.config.ts` writes. */
|
|
89
|
+
type _DatabaseInputCarriesNoDeadField = Assert<
|
|
90
|
+
Extract<keyof NonNullable<AppConfigInput['database']>, DeadDatabaseField> extends never
|
|
91
|
+
? true
|
|
92
|
+
: false
|
|
93
|
+
>;
|
package/src/version.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// manifest is `private` and carries no `version`, and after `npm install` a walk above the package
|
|
4
4
|
// lands in `node_modules/` where there is no manifest at all. Both mistakes fail silently.
|
|
5
5
|
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
7
|
// Bun has no path-join primitive; `import.meta.dir` is the module's own directory in both the
|
|
8
8
|
// checked-out `src/` layout and the published `dist/` one, each exactly one level below the
|
|
9
9
|
// package root.
|
|
10
10
|
import { resolve } from 'node:path';
|
|
11
|
+
import { renderCauseValue } from './error-render';
|
|
11
12
|
import { UltimateError } from './errors';
|
|
12
13
|
|
|
13
14
|
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)*$/;
|
|
@@ -15,10 +16,22 @@ const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)*$/;
|
|
|
15
16
|
/** `@ultimat3/core`'s own manifest — the only file that can answer what version shipped. */
|
|
16
17
|
export const VERSION_MANIFEST = resolve(import.meta.dir, '..', 'package.json');
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The bundler define that carries the version into a build with no manifest to read.
|
|
21
|
+
* `x build --target binary` passes it (`binaryArgs` in `@ultimat3/cli`); the name is declared here
|
|
22
|
+
* so the flag that writes it and the read below cannot drift.
|
|
23
|
+
*/
|
|
24
|
+
export const VERSION_DEFINE = 'ULTIMATE_FRAMEWORK_VERSION';
|
|
25
|
+
|
|
26
|
+
// Replaced with a string literal by `bun build --define ULTIMATE_FRAMEWORK_VERSION='"1.2.3"'`, and
|
|
27
|
+
// declared by nothing at runtime — which is why the read is `typeof`-guarded. An unbundled process
|
|
28
|
+
// must see `undefined` here, not a `ReferenceError`.
|
|
29
|
+
declare const ULTIMATE_FRAMEWORK_VERSION: string | undefined;
|
|
30
|
+
|
|
18
31
|
/**
|
|
19
32
|
* A package with no readable version is a broken publish, not a runtime condition to degrade
|
|
20
33
|
* through: an `undefined` version poisons the MCP handshake and every dependency a scaffold pins,
|
|
21
|
-
* and does it quietly. Fail
|
|
34
|
+
* and does it quietly. Fail where the fix is a release-script change.
|
|
22
35
|
*/
|
|
23
36
|
export function readPackageVersion(manifestPath: string): string {
|
|
24
37
|
const raw: unknown = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
@@ -27,11 +40,47 @@ export function readPackageVersion(manifestPath: string): string {
|
|
|
27
40
|
if (typeof version !== 'string' || !SEMVER.test(version)) {
|
|
28
41
|
throw new UltimateError({
|
|
29
42
|
code: 'X_INVARIANT',
|
|
30
|
-
cause: `${manifestPath} has no valid semver "version" field (found ${
|
|
43
|
+
cause: `${manifestPath} has no valid semver "version" field (found ${renderCauseValue(version)})`,
|
|
31
44
|
fix: `set a semver "version" in ${manifestPath}, then re-run: bun run verify`,
|
|
32
45
|
});
|
|
33
46
|
}
|
|
34
47
|
return version;
|
|
35
48
|
}
|
|
36
49
|
|
|
37
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Manifest first, build define second, throw last.
|
|
52
|
+
*
|
|
53
|
+
* A single-file executable carries no `package.json`, so a *missing* manifest is the one absence
|
|
54
|
+
* that is not a broken publish — it falls through to the define. A manifest that exists and
|
|
55
|
+
* declares no semver still throws, because that is the broken publish `readPackageVersion` was
|
|
56
|
+
* written for, and a define must not paper over it. Pure and exported so the compiled-binary case
|
|
57
|
+
* is a unit test rather than a `bun build --compile` nobody runs.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveVersion(manifestPath: string, defined: string | undefined): string {
|
|
60
|
+
if (existsSync(manifestPath)) return readPackageVersion(manifestPath);
|
|
61
|
+
if (defined !== undefined && SEMVER.test(defined)) return defined;
|
|
62
|
+
throw new UltimateError({
|
|
63
|
+
code: 'X_INVARIANT',
|
|
64
|
+
cause: `no manifest at ${manifestPath} and no valid ${VERSION_DEFINE} define (found ${JSON.stringify(defined)}) — only the builder that passes the define produces a bootable binary`,
|
|
65
|
+
fix: `x build --target binary`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let resolved: string | undefined;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The framework's version, resolved on first call and cached for every call after.
|
|
73
|
+
*
|
|
74
|
+
* Lazy is the whole point. As a module-scope constant the read ran before `main` in every process
|
|
75
|
+
* that imported core, so `x build --target binary` produced an artifact that threw at import
|
|
76
|
+
* before any role started — the artifact compiled and could never boot.
|
|
77
|
+
*/
|
|
78
|
+
export function frameworkVersion(): string {
|
|
79
|
+
if (resolved === undefined) {
|
|
80
|
+
resolved = resolveVersion(
|
|
81
|
+
VERSION_MANIFEST,
|
|
82
|
+
typeof ULTIMATE_FRAMEWORK_VERSION === 'string' ? ULTIMATE_FRAMEWORK_VERSION : undefined,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return resolved;
|
|
86
|
+
}
|