@objectstack/core 17.0.0-rc.3 → 17.0.0-rc.4
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/CHANGELOG.md +498 -3
- package/README.md +0 -1
- package/dist/index.cjs +316 -598
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +169 -315
- package/dist/index.d.ts +169 -315
- package/dist/index.js +316 -596
- package/dist/index.js.map +1 -1
- package/dist/logger.cjs +117 -10
- package/dist/logger.cjs.map +1 -1
- package/dist/logger.d.cts +40 -0
- package/dist/logger.d.ts +40 -0
- package/dist/logger.js +117 -10
- package/dist/logger.js.map +1 -1
- package/package.json +4 -3
package/dist/index.cjs
CHANGED
|
@@ -35,7 +35,6 @@ __export(index_exports, {
|
|
|
35
35
|
ANONYMOUS_DENY_MESSAGE: () => ANONYMOUS_DENY_MESSAGE,
|
|
36
36
|
ANONYMOUS_DENY_STATUS: () => ANONYMOUS_DENY_STATUS,
|
|
37
37
|
API_KEY_PREFIX: () => API_KEY_PREFIX,
|
|
38
|
-
ApiRegistry: () => ApiRegistry,
|
|
39
38
|
CORE_FALLBACK_FACTORIES: () => CORE_FALLBACK_FACTORIES,
|
|
40
39
|
DependencyResolver: () => DependencyResolver,
|
|
41
40
|
HotReloadManager: () => HotReloadManager,
|
|
@@ -71,7 +70,6 @@ __export(index_exports, {
|
|
|
71
70
|
calendarPartsInTz: () => calendarPartsInTz,
|
|
72
71
|
calendarPartsInTzOrUtc: () => calendarPartsInTzOrUtc,
|
|
73
72
|
counterSignPayload: () => counterSignPayload,
|
|
74
|
-
createApiRegistryPlugin: () => createApiRegistryPlugin,
|
|
75
73
|
createLogger: () => createLogger,
|
|
76
74
|
createMemoryCache: () => createMemoryCache,
|
|
77
75
|
createMemoryI18n: () => createMemoryI18n,
|
|
@@ -402,7 +400,21 @@ var ObjectKernelBase = class {
|
|
|
402
400
|
}
|
|
403
401
|
}
|
|
404
402
|
/**
|
|
405
|
-
* Trigger a hook with all registered handlers
|
|
403
|
+
* Trigger a hook with all registered handlers, ISOLATING failures: a
|
|
404
|
+
* handler that throws is logged and the remaining handlers still run.
|
|
405
|
+
*
|
|
406
|
+
* Use this for hooks where one subscriber's failure must not deny the
|
|
407
|
+
* others their turn — notification-style hooks, and `kernel:shutdown`,
|
|
408
|
+
* where the handlers still queued behind the failing one are the cleanup
|
|
409
|
+
* that flushes buffers and releases resources (#5257).
|
|
410
|
+
*
|
|
411
|
+
* It is the WRONG dispatcher for anything on the BOOT path. Every hook
|
|
412
|
+
* dispatched before "✅ Bootstrap complete" is a precondition of that
|
|
413
|
+
* claim, so swallowing a throw there does not rescue the boot — it only
|
|
414
|
+
* hides the failure behind a process that reports success. Those hooks
|
|
415
|
+
* (`kernel:ready`, `kernel:bootstrapped`, `kernel:listening`) use
|
|
416
|
+
* {@link triggerHookOrThrow} (#5170, #5257).
|
|
417
|
+
*
|
|
406
418
|
* @param name - Hook name
|
|
407
419
|
* @param args - Arguments to pass to handlers
|
|
408
420
|
*/
|
|
@@ -420,6 +432,54 @@ var ObjectKernelBase = class {
|
|
|
420
432
|
}
|
|
421
433
|
}
|
|
422
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Trigger a hook with all registered handlers, PROPAGATING the first
|
|
437
|
+
* failure: the remaining handlers do not run and the original error
|
|
438
|
+
* reaches the caller unwrapped.
|
|
439
|
+
*
|
|
440
|
+
* This is the dispatch semantics `ObjectKernel` has always had for every
|
|
441
|
+
* lifecycle hook (its `context.trigger` is a bare awaited loop that never
|
|
442
|
+
* catches). `LiteKernel` used the isolating {@link triggerHook} for all of
|
|
443
|
+
* them, so one hook name meant two opposite things depending on which
|
|
444
|
+
* kernel booted the same plugin code (#5170).
|
|
445
|
+
*
|
|
446
|
+
* `LiteKernel` now uses this dispatcher for all three BOOT-path hooks:
|
|
447
|
+
*
|
|
448
|
+
* - `kernel:ready` (#5170) — the only correct moment for a plugin to
|
|
449
|
+
* assert that the preconditions it declared were actually met (the
|
|
450
|
+
* registries are still filling during `init()`), so "declared but not
|
|
451
|
+
* deliverable ⇒ refuse to boot" gates live there. On LiteKernel, which
|
|
452
|
+
* is what vitest/serverless/edge run, they were downgraded to an error
|
|
453
|
+
* log while the process carried on serving traffic without the
|
|
454
|
+
* guarantee it claimed.
|
|
455
|
+
* - `kernel:bootstrapped` and `kernel:listening` (#5257) — the same
|
|
456
|
+
* argument one hook later. `kernel:listening` is where HTTP server
|
|
457
|
+
* plugins open their socket, so a swallowed failure there produced the
|
|
458
|
+
* worst shape available: a live process printing "✅ Bootstrap complete"
|
|
459
|
+
* with nothing listening. `kernel:bootstrapped` carries reconcile and
|
|
460
|
+
* audit passes whose silent failure is a quieter version of the same
|
|
461
|
+
* lie.
|
|
462
|
+
*
|
|
463
|
+
* Deliberately NOT applied to `kernel:shutdown`, which keeps
|
|
464
|
+
* {@link triggerHook}: on the teardown path a failing handler must not
|
|
465
|
+
* block the cleanup queued behind it. That is a per-hook judgement
|
|
466
|
+
* recorded at the dispatch site in `lite-kernel.ts`, not an inherited
|
|
467
|
+
* default — and it is the reason this dispatcher is chosen per hook rather
|
|
468
|
+
* than swapped in wholesale.
|
|
469
|
+
*
|
|
470
|
+
* @param name - Hook name
|
|
471
|
+
* @param args - Arguments to pass to handlers
|
|
472
|
+
*/
|
|
473
|
+
async triggerHookOrThrow(name, ...args) {
|
|
474
|
+
const handlers = this.hooks.get(name) || [];
|
|
475
|
+
this.logger.debug(`Triggering hook: ${name}`, {
|
|
476
|
+
hook: name,
|
|
477
|
+
handlerCount: handlers.length
|
|
478
|
+
});
|
|
479
|
+
for (const handler of handlers) {
|
|
480
|
+
await handler(...args);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
423
483
|
/**
|
|
424
484
|
* Get current kernel state
|
|
425
485
|
*/
|
|
@@ -452,6 +512,73 @@ var LEVEL_COLORS = {
|
|
|
452
512
|
silent: ""
|
|
453
513
|
};
|
|
454
514
|
var RESET = "\x1B[0m";
|
|
515
|
+
function tokenizeFieldName(name) {
|
|
516
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/([a-zA-Z])([0-9])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((word) => word.toLowerCase());
|
|
517
|
+
}
|
|
518
|
+
function singularizeWord(word) {
|
|
519
|
+
if (/(?:ss|us|is)$/.test(word)) return word;
|
|
520
|
+
if (/(?:ch|sh|s|x|z)es$/.test(word)) return word.slice(0, -2);
|
|
521
|
+
if (/[a-z0-9]s$/.test(word)) return word.slice(0, -1);
|
|
522
|
+
return word;
|
|
523
|
+
}
|
|
524
|
+
var CONCATENATED_SECRET_QUALIFIERS = /* @__PURE__ */ new Set([
|
|
525
|
+
"access",
|
|
526
|
+
"account",
|
|
527
|
+
"admin",
|
|
528
|
+
"api",
|
|
529
|
+
"app",
|
|
530
|
+
"auth",
|
|
531
|
+
"bearer",
|
|
532
|
+
"client",
|
|
533
|
+
"csrf",
|
|
534
|
+
"db",
|
|
535
|
+
"database",
|
|
536
|
+
"encryption",
|
|
537
|
+
"id",
|
|
538
|
+
"jwt",
|
|
539
|
+
"master",
|
|
540
|
+
"oauth",
|
|
541
|
+
"private",
|
|
542
|
+
"public",
|
|
543
|
+
"refresh",
|
|
544
|
+
"root",
|
|
545
|
+
"secret",
|
|
546
|
+
"service",
|
|
547
|
+
"session",
|
|
548
|
+
"shared",
|
|
549
|
+
"sign",
|
|
550
|
+
"signing",
|
|
551
|
+
"ssh",
|
|
552
|
+
"token",
|
|
553
|
+
"user",
|
|
554
|
+
"webhook",
|
|
555
|
+
"xsrf"
|
|
556
|
+
]);
|
|
557
|
+
function isQualifiedConcatenation(word, redactWord) {
|
|
558
|
+
for (const base of [word, singularizeWord(word)]) {
|
|
559
|
+
if (base.length <= redactWord.length || !base.endsWith(redactWord)) continue;
|
|
560
|
+
if (CONCATENATED_SECRET_QUALIFIERS.has(base.slice(0, base.length - redactWord.length))) return true;
|
|
561
|
+
}
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
function containsWordRun(words, run) {
|
|
565
|
+
for (let i = 0; i + run.length <= words.length; i++) {
|
|
566
|
+
if (run.every((word, offset) => words[i + offset] === word)) return true;
|
|
567
|
+
}
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
function fieldWordsMatchPattern(nameWords, patternWords) {
|
|
571
|
+
if (patternWords.length === 0 || nameWords.length === 0) return false;
|
|
572
|
+
if (patternWords.length > 1) {
|
|
573
|
+
const glued = patternWords.join("");
|
|
574
|
+
return containsWordRun(nameWords, patternWords) || nameWords.some((word) => word === glued || singularizeWord(word) === glued);
|
|
575
|
+
}
|
|
576
|
+
const redactWord = patternWords[0];
|
|
577
|
+
const isCompound = nameWords.filter((word) => /[a-z]/.test(word)).length > 1;
|
|
578
|
+
return nameWords.some(
|
|
579
|
+
(word) => word === redactWord || isCompound && singularizeWord(word) === redactWord || isQualifiedConcatenation(word, redactWord)
|
|
580
|
+
);
|
|
581
|
+
}
|
|
455
582
|
function colorEnabled(stream) {
|
|
456
583
|
if (typeof process !== "undefined") {
|
|
457
584
|
const noColor = process.env?.NO_COLOR;
|
|
@@ -490,6 +617,7 @@ var ObjectLogger = class _ObjectLogger {
|
|
|
490
617
|
rotation: config.rotation ?? { maxSize: "10m", maxFiles: 5 }
|
|
491
618
|
};
|
|
492
619
|
this.bindings = bindings;
|
|
620
|
+
this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);
|
|
493
621
|
if (this.config.file && typeof process !== "undefined") {
|
|
494
622
|
this.openFileStream(this.config.file);
|
|
495
623
|
}
|
|
@@ -535,12 +663,25 @@ var ObjectLogger = class _ObjectLogger {
|
|
|
535
663
|
isEnabled(level) {
|
|
536
664
|
return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];
|
|
537
665
|
}
|
|
666
|
+
/**
|
|
667
|
+
* Whether a meta field name names one of the configured secrets.
|
|
668
|
+
*
|
|
669
|
+
* Until #5573 this was `lower.includes(pattern)`, which redacted every
|
|
670
|
+
* field whose name merely *contained* a redact word — `keys`, `keyword`,
|
|
671
|
+
* `tokens`, `monkey`, `secretary` — and replaced its value with
|
|
672
|
+
* `***REDACTED***`, so the reader lost the fact AND was told a secret had
|
|
673
|
+
* been withheld. Matching is now on word boundaries: `key` matches
|
|
674
|
+
* `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.
|
|
675
|
+
*/
|
|
676
|
+
isRedactedFieldName(key) {
|
|
677
|
+
const nameWords = tokenizeFieldName(key);
|
|
678
|
+
return this.redactPatterns.some((pattern) => fieldWordsMatchPattern(nameWords, pattern));
|
|
679
|
+
}
|
|
538
680
|
redactSensitive(obj) {
|
|
539
681
|
if (!obj || typeof obj !== "object") return obj;
|
|
540
682
|
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
541
683
|
for (const key in redacted) {
|
|
542
|
-
|
|
543
|
-
if (this.config.redact.some((p) => lower.includes(p.toLowerCase()))) {
|
|
684
|
+
if (this.isRedactedFieldName(key)) {
|
|
544
685
|
redacted[key] = "***REDACTED***";
|
|
545
686
|
} else if (typeof redacted[key] === "object" && redacted[key] !== null) {
|
|
546
687
|
redacted[key] = this.redactSensitive(redacted[key]);
|
|
@@ -603,18 +744,44 @@ var ObjectLogger = class _ObjectLogger {
|
|
|
603
744
|
this.write("warn", message, meta);
|
|
604
745
|
}
|
|
605
746
|
error(message, errorOrMeta, meta) {
|
|
606
|
-
|
|
607
|
-
this.write("error", message, meta, errorOrMeta);
|
|
608
|
-
} else {
|
|
609
|
-
this.write("error", message, errorOrMeta);
|
|
610
|
-
}
|
|
747
|
+
this.writeErrorLike("error", message, errorOrMeta, meta);
|
|
611
748
|
}
|
|
612
749
|
fatal(message, errorOrMeta, meta) {
|
|
750
|
+
this.writeErrorLike("fatal", message, errorOrMeta, meta);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* `error`/`fatal` dispatch — the two levels whose contract has an `Error`
|
|
754
|
+
* slot in front of `meta`.
|
|
755
|
+
*
|
|
756
|
+
* The `Logger` contract declares `error(message, error?: Error, meta?)`, and
|
|
757
|
+
* `ObjectLogger` additionally tolerates a **meta object** in the `error`
|
|
758
|
+
* slot because many in-repo call sites write `logger.error(msg, { … })`.
|
|
759
|
+
* That tolerance is fine; dropping a parameter the contract *declares* is
|
|
760
|
+
* not, and that is what the previous dispatch did:
|
|
761
|
+
*
|
|
762
|
+
* if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);
|
|
763
|
+
* else this.write(level, message, errorOrMeta);
|
|
764
|
+
*
|
|
765
|
+
* With `error === undefined` the `else` branch passed `undefined` as the
|
|
766
|
+
* meta and **never read the third argument**, so every contract-shaped
|
|
767
|
+
* `logger.error(msg, undefined, { … })` call rendered a bare message with
|
|
768
|
+
* its diagnostics silently gone — ~15 such call sites across `metadata`,
|
|
769
|
+
* `metadata-protocol`, `client` and `core/security`, plus the connector
|
|
770
|
+
* reconcile seam that found this (#5575). The contract's two sibling
|
|
771
|
+
* implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)
|
|
772
|
+
* both honour the slot, so the contract was right and this class was the
|
|
773
|
+
* outlier — declared ≠ enforced, Prime Directive #10.
|
|
774
|
+
*
|
|
775
|
+
* All three shapes are now honoured. When both slots carry meta, `meta`
|
|
776
|
+
* (the later, more specific argument) wins on a key collision.
|
|
777
|
+
*/
|
|
778
|
+
writeErrorLike(level, message, errorOrMeta, meta) {
|
|
613
779
|
if (errorOrMeta instanceof Error) {
|
|
614
|
-
this.write(
|
|
615
|
-
|
|
616
|
-
this.write("fatal", message, errorOrMeta);
|
|
780
|
+
this.write(level, message, meta, errorOrMeta);
|
|
781
|
+
return;
|
|
617
782
|
}
|
|
783
|
+
const merged = errorOrMeta && meta ? { ...errorOrMeta, ...meta } : errorOrMeta ?? meta;
|
|
784
|
+
this.write(level, message, merged);
|
|
618
785
|
}
|
|
619
786
|
log(message, ...args) {
|
|
620
787
|
this.info(message, args.length > 0 ? { args } : void 0);
|
|
@@ -1873,11 +2040,12 @@ var ObjectKernel = class {
|
|
|
1873
2040
|
}
|
|
1874
2041
|
this.state = "stopping";
|
|
1875
2042
|
this.logger.info("Graceful shutdown started");
|
|
2043
|
+
const shutdownTimeoutError = new Error("Shutdown timeout exceeded");
|
|
1876
2044
|
try {
|
|
1877
2045
|
const shutdownPromise = this.performShutdown();
|
|
1878
2046
|
const timeoutPromise = new Promise((_, reject) => {
|
|
1879
2047
|
const t = setTimeout(() => {
|
|
1880
|
-
reject(
|
|
2048
|
+
reject(shutdownTimeoutError);
|
|
1881
2049
|
}, this.config.shutdownTimeout);
|
|
1882
2050
|
if (t.unref) t.unref();
|
|
1883
2051
|
});
|
|
@@ -1885,10 +2053,17 @@ var ObjectKernel = class {
|
|
|
1885
2053
|
this.state = "stopped";
|
|
1886
2054
|
this.logger.info("\u2705 Graceful shutdown complete");
|
|
1887
2055
|
} catch (error) {
|
|
1888
|
-
this.logger.error("Shutdown timed out \u2014 forcing exit", error);
|
|
1889
2056
|
this.state = "stopped";
|
|
1890
|
-
|
|
1891
|
-
|
|
2057
|
+
if (error === shutdownTimeoutError) {
|
|
2058
|
+
this.logger.error("Shutdown timed out \u2014 forcing exit", error);
|
|
2059
|
+
await this.logger.destroy();
|
|
2060
|
+
process.exit(1);
|
|
2061
|
+
} else {
|
|
2062
|
+
this.logger.error(
|
|
2063
|
+
"Shutdown finished with an unexpected teardown error \u2014 the kernel is stopped and the process is NOT being exited; some cleanup may not have run",
|
|
2064
|
+
error
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
1892
2067
|
} finally {
|
|
1893
2068
|
await this.logger.destroy();
|
|
1894
2069
|
}
|
|
@@ -2078,8 +2253,48 @@ var ObjectKernel = class {
|
|
|
2078
2253
|
}
|
|
2079
2254
|
this.startedPlugins.clear();
|
|
2080
2255
|
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Dispatch `kernel:shutdown`, ISOLATING failures: a handler that throws is
|
|
2258
|
+
* logged and the remaining handlers still run (#5274).
|
|
2259
|
+
*
|
|
2260
|
+
* This is a per-hook judgement, deliberately NOT the bare awaited loop
|
|
2261
|
+
* `context.trigger` runs for every other hook — the boot-path hooks
|
|
2262
|
+
* (`kernel:ready`, `kernel:bootstrapped`, `kernel:listening`) keep
|
|
2263
|
+
* propagating, because everything dispatched before "✅ Bootstrap complete"
|
|
2264
|
+
* is a precondition of that claim and swallowing a throw there only hides
|
|
2265
|
+
* the failure behind a process reporting success (#5170, #5257).
|
|
2266
|
+
*
|
|
2267
|
+
* On the teardown path there is no "refuse to proceed" left to buy. What is
|
|
2268
|
+
* queued behind a failing shutdown handler is the rest of the cleanup —
|
|
2269
|
+
* every other subscriber, then each plugin's `destroy()` in reverse order —
|
|
2270
|
+
* which is what flushes buffers, closes connections and releases locks. So
|
|
2271
|
+
* one bad handler must not amplify into leaked resources and unflushed
|
|
2272
|
+
* writes. Same reasoning, same wording, same `Hook handler failed:
|
|
2273
|
+
* kernel:shutdown` log line as `LiteKernel`'s dispatch site, which reaches
|
|
2274
|
+
* the shared isolating dispatcher `ObjectKernelBase.triggerHook` (#5257).
|
|
2275
|
+
*
|
|
2276
|
+
* `ObjectKernel` cannot call that dispatcher: it does not extend
|
|
2277
|
+
* `ObjectKernelBase` (only `LiteKernel` does) and owns its own `hooks` map,
|
|
2278
|
+
* so the semantics are mirrored here rather than shared. One hook name
|
|
2279
|
+
* meaning two opposite things across the two kernels is exactly the bug
|
|
2280
|
+
* #5170/#5257 closed, so the pin for this one lives on both sides too.
|
|
2281
|
+
*/
|
|
2282
|
+
async triggerShutdownHookIsolating() {
|
|
2283
|
+
const handlers = this.hooks.get("kernel:shutdown") || [];
|
|
2284
|
+
this.logger.debug("Triggering hook: kernel:shutdown", {
|
|
2285
|
+
hook: "kernel:shutdown",
|
|
2286
|
+
handlerCount: handlers.length
|
|
2287
|
+
});
|
|
2288
|
+
for (const handler of handlers) {
|
|
2289
|
+
try {
|
|
2290
|
+
await handler();
|
|
2291
|
+
} catch (error) {
|
|
2292
|
+
this.logger.error("Hook handler failed: kernel:shutdown", error);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2081
2296
|
async performShutdown() {
|
|
2082
|
-
await this.
|
|
2297
|
+
await this.triggerShutdownHookIsolating();
|
|
2083
2298
|
const orderedPlugins = Array.from(this.plugins.values()).reverse();
|
|
2084
2299
|
for (const plugin of orderedPlugins) {
|
|
2085
2300
|
if (plugin.destroy) {
|
|
@@ -2181,9 +2396,14 @@ var LiteKernel = class extends ObjectKernelBase {
|
|
|
2181
2396
|
for (const plugin of orderedPlugins) {
|
|
2182
2397
|
await this.runPluginStart(plugin);
|
|
2183
2398
|
}
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2399
|
+
try {
|
|
2400
|
+
await this.triggerHookOrThrow("kernel:ready");
|
|
2401
|
+
await this.triggerHookOrThrow("kernel:bootstrapped");
|
|
2402
|
+
await this.triggerHookOrThrow("kernel:listening");
|
|
2403
|
+
} catch (error) {
|
|
2404
|
+
this.state = "stopped";
|
|
2405
|
+
throw error;
|
|
2406
|
+
}
|
|
2187
2407
|
this.logger.info("\u2705 Bootstrap complete", {
|
|
2188
2408
|
pluginCount: this.plugins.size
|
|
2189
2409
|
});
|
|
@@ -2231,569 +2451,6 @@ var LiteKernel = class extends ObjectKernelBase {
|
|
|
2231
2451
|
}
|
|
2232
2452
|
};
|
|
2233
2453
|
|
|
2234
|
-
// src/api-registry.ts
|
|
2235
|
-
var import_api = require("@objectstack/spec/api");
|
|
2236
|
-
var ApiRegistry = class {
|
|
2237
|
-
constructor(logger, conflictResolution = "error", version = "1.0.0") {
|
|
2238
|
-
this.apis = /* @__PURE__ */ new Map();
|
|
2239
|
-
this.endpoints = /* @__PURE__ */ new Map();
|
|
2240
|
-
this.routes = /* @__PURE__ */ new Map();
|
|
2241
|
-
// Performance optimization: Auxiliary indices for O(1) lookups
|
|
2242
|
-
this.apisByType = /* @__PURE__ */ new Map();
|
|
2243
|
-
this.apisByTag = /* @__PURE__ */ new Map();
|
|
2244
|
-
this.apisByStatus = /* @__PURE__ */ new Map();
|
|
2245
|
-
this.logger = logger;
|
|
2246
|
-
this.conflictResolution = conflictResolution;
|
|
2247
|
-
this.version = version;
|
|
2248
|
-
this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2249
|
-
}
|
|
2250
|
-
/**
|
|
2251
|
-
* Register an API with its endpoints
|
|
2252
|
-
*
|
|
2253
|
-
* @param api - API registry entry
|
|
2254
|
-
* @throws Error if API already registered or route conflicts detected
|
|
2255
|
-
*/
|
|
2256
|
-
registerApi(api) {
|
|
2257
|
-
if (this.apis.has(api.id)) {
|
|
2258
|
-
throw new Error(`[ApiRegistry] API '${api.id}' already registered`);
|
|
2259
|
-
}
|
|
2260
|
-
const fullApi = import_api.ApiRegistryEntrySchema.parse(api);
|
|
2261
|
-
for (const endpoint of fullApi.endpoints) {
|
|
2262
|
-
this.validateEndpoint(endpoint, fullApi.id);
|
|
2263
|
-
}
|
|
2264
|
-
this.apis.set(fullApi.id, fullApi);
|
|
2265
|
-
for (const endpoint of fullApi.endpoints) {
|
|
2266
|
-
this.registerEndpoint(fullApi.id, endpoint);
|
|
2267
|
-
}
|
|
2268
|
-
this.updateIndices(fullApi);
|
|
2269
|
-
this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2270
|
-
this.logger.info(`API registered: ${fullApi.id}`, {
|
|
2271
|
-
api: fullApi.id,
|
|
2272
|
-
type: fullApi.type,
|
|
2273
|
-
endpointCount: fullApi.endpoints.length
|
|
2274
|
-
});
|
|
2275
|
-
}
|
|
2276
|
-
/**
|
|
2277
|
-
* Unregister an API and all its endpoints
|
|
2278
|
-
*
|
|
2279
|
-
* @param apiId - API identifier
|
|
2280
|
-
*/
|
|
2281
|
-
unregisterApi(apiId) {
|
|
2282
|
-
const api = this.apis.get(apiId);
|
|
2283
|
-
if (!api) {
|
|
2284
|
-
throw new Error(`[ApiRegistry] API '${apiId}' not found`);
|
|
2285
|
-
}
|
|
2286
|
-
for (const endpoint of api.endpoints) {
|
|
2287
|
-
this.unregisterEndpoint(apiId, endpoint.id);
|
|
2288
|
-
}
|
|
2289
|
-
this.removeFromIndices(api);
|
|
2290
|
-
this.apis.delete(apiId);
|
|
2291
|
-
this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2292
|
-
this.logger.info(`API unregistered: ${apiId}`);
|
|
2293
|
-
}
|
|
2294
|
-
/**
|
|
2295
|
-
* Register a single endpoint
|
|
2296
|
-
*
|
|
2297
|
-
* @param apiId - API identifier
|
|
2298
|
-
* @param endpoint - Endpoint registration
|
|
2299
|
-
* @throws Error if route conflict detected
|
|
2300
|
-
*/
|
|
2301
|
-
registerEndpoint(apiId, endpoint) {
|
|
2302
|
-
const endpointKey = `${apiId}:${endpoint.id}`;
|
|
2303
|
-
if (this.endpoints.has(endpointKey)) {
|
|
2304
|
-
throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' already registered for API '${apiId}'`);
|
|
2305
|
-
}
|
|
2306
|
-
this.endpoints.set(endpointKey, { api: apiId, endpoint });
|
|
2307
|
-
if (endpoint.path) {
|
|
2308
|
-
this.registerRoute(apiId, endpoint);
|
|
2309
|
-
}
|
|
2310
|
-
}
|
|
2311
|
-
/**
|
|
2312
|
-
* Unregister a single endpoint
|
|
2313
|
-
*
|
|
2314
|
-
* @param apiId - API identifier
|
|
2315
|
-
* @param endpointId - Endpoint identifier
|
|
2316
|
-
*/
|
|
2317
|
-
unregisterEndpoint(apiId, endpointId) {
|
|
2318
|
-
const endpointKey = `${apiId}:${endpointId}`;
|
|
2319
|
-
const entry = this.endpoints.get(endpointKey);
|
|
2320
|
-
if (!entry) {
|
|
2321
|
-
return;
|
|
2322
|
-
}
|
|
2323
|
-
if (entry.endpoint.path) {
|
|
2324
|
-
const routeKey = this.getRouteKey(entry.endpoint);
|
|
2325
|
-
this.routes.delete(routeKey);
|
|
2326
|
-
}
|
|
2327
|
-
this.endpoints.delete(endpointKey);
|
|
2328
|
-
}
|
|
2329
|
-
/**
|
|
2330
|
-
* Register a route with conflict detection
|
|
2331
|
-
*
|
|
2332
|
-
* @param apiId - API identifier
|
|
2333
|
-
* @param endpoint - Endpoint registration
|
|
2334
|
-
* @throws Error if route conflict detected (based on strategy)
|
|
2335
|
-
*/
|
|
2336
|
-
registerRoute(apiId, endpoint) {
|
|
2337
|
-
const routeKey = this.getRouteKey(endpoint);
|
|
2338
|
-
const priority = endpoint.priority ?? 100;
|
|
2339
|
-
const existingRoute = this.routes.get(routeKey);
|
|
2340
|
-
if (existingRoute) {
|
|
2341
|
-
this.handleRouteConflict(routeKey, apiId, endpoint, existingRoute, priority);
|
|
2342
|
-
return;
|
|
2343
|
-
}
|
|
2344
|
-
this.routes.set(routeKey, {
|
|
2345
|
-
api: apiId,
|
|
2346
|
-
endpointId: endpoint.id,
|
|
2347
|
-
priority
|
|
2348
|
-
});
|
|
2349
|
-
}
|
|
2350
|
-
/**
|
|
2351
|
-
* Handle route conflict based on resolution strategy
|
|
2352
|
-
*
|
|
2353
|
-
* @param routeKey - Route key
|
|
2354
|
-
* @param apiId - New API identifier
|
|
2355
|
-
* @param endpoint - New endpoint
|
|
2356
|
-
* @param existingRoute - Existing route registration
|
|
2357
|
-
* @param newPriority - New endpoint priority
|
|
2358
|
-
* @throws Error if strategy is 'error'
|
|
2359
|
-
*/
|
|
2360
|
-
handleRouteConflict(routeKey, apiId, endpoint, existingRoute, newPriority) {
|
|
2361
|
-
const strategy = this.conflictResolution;
|
|
2362
|
-
switch (strategy) {
|
|
2363
|
-
case "error":
|
|
2364
|
-
throw new Error(
|
|
2365
|
-
`[ApiRegistry] Route conflict detected: '${routeKey}' is already registered by API '${existingRoute.api}' endpoint '${existingRoute.endpointId}'`
|
|
2366
|
-
);
|
|
2367
|
-
case "priority":
|
|
2368
|
-
if (newPriority > existingRoute.priority) {
|
|
2369
|
-
this.logger.warn(
|
|
2370
|
-
`Route conflict: replacing '${routeKey}' (priority ${existingRoute.priority} -> ${newPriority})`,
|
|
2371
|
-
{
|
|
2372
|
-
oldApi: existingRoute.api,
|
|
2373
|
-
oldEndpoint: existingRoute.endpointId,
|
|
2374
|
-
newApi: apiId,
|
|
2375
|
-
newEndpoint: endpoint.id
|
|
2376
|
-
}
|
|
2377
|
-
);
|
|
2378
|
-
this.routes.set(routeKey, {
|
|
2379
|
-
api: apiId,
|
|
2380
|
-
endpointId: endpoint.id,
|
|
2381
|
-
priority: newPriority
|
|
2382
|
-
});
|
|
2383
|
-
} else {
|
|
2384
|
-
this.logger.warn(
|
|
2385
|
-
`Route conflict: keeping existing '${routeKey}' (priority ${existingRoute.priority} >= ${newPriority})`,
|
|
2386
|
-
{
|
|
2387
|
-
existingApi: existingRoute.api,
|
|
2388
|
-
existingEndpoint: existingRoute.endpointId,
|
|
2389
|
-
newApi: apiId,
|
|
2390
|
-
newEndpoint: endpoint.id
|
|
2391
|
-
}
|
|
2392
|
-
);
|
|
2393
|
-
}
|
|
2394
|
-
break;
|
|
2395
|
-
case "first-wins":
|
|
2396
|
-
this.logger.warn(
|
|
2397
|
-
`Route conflict: keeping first registered '${routeKey}'`,
|
|
2398
|
-
{
|
|
2399
|
-
existingApi: existingRoute.api,
|
|
2400
|
-
newApi: apiId
|
|
2401
|
-
}
|
|
2402
|
-
);
|
|
2403
|
-
break;
|
|
2404
|
-
case "last-wins":
|
|
2405
|
-
this.logger.warn(
|
|
2406
|
-
`Route conflict: replacing with last registered '${routeKey}'`,
|
|
2407
|
-
{
|
|
2408
|
-
oldApi: existingRoute.api,
|
|
2409
|
-
newApi: apiId
|
|
2410
|
-
}
|
|
2411
|
-
);
|
|
2412
|
-
this.routes.set(routeKey, {
|
|
2413
|
-
api: apiId,
|
|
2414
|
-
endpointId: endpoint.id,
|
|
2415
|
-
priority: newPriority
|
|
2416
|
-
});
|
|
2417
|
-
break;
|
|
2418
|
-
default:
|
|
2419
|
-
throw new Error(`[ApiRegistry] Unknown conflict resolution strategy: ${strategy}`);
|
|
2420
|
-
}
|
|
2421
|
-
}
|
|
2422
|
-
/**
|
|
2423
|
-
* Generate a unique route key for conflict detection
|
|
2424
|
-
*
|
|
2425
|
-
* NOTE: This implementation uses exact string matching for route conflict detection.
|
|
2426
|
-
* It works well for static paths but has limitations with parameterized routes.
|
|
2427
|
-
* For example, `/api/users/:id` and `/api/users/:userId` will NOT be detected as conflicts
|
|
2428
|
-
* even though they are semantically identical parameterized patterns. Similarly,
|
|
2429
|
-
* `/api/:resource/list` and `/api/:entity/list` would also not be detected as conflicting.
|
|
2430
|
-
*
|
|
2431
|
-
* For more advanced conflict detection (e.g., path-to-regexp pattern matching),
|
|
2432
|
-
* consider integrating with your routing library's conflict detection mechanism.
|
|
2433
|
-
*
|
|
2434
|
-
* @param endpoint - Endpoint registration
|
|
2435
|
-
* @returns Route key (e.g., "GET:/api/v1/customers/:id")
|
|
2436
|
-
*/
|
|
2437
|
-
getRouteKey(endpoint) {
|
|
2438
|
-
const method = endpoint.method || "ANY";
|
|
2439
|
-
return `${method}:${endpoint.path}`;
|
|
2440
|
-
}
|
|
2441
|
-
/**
|
|
2442
|
-
* Validate endpoint registration
|
|
2443
|
-
*
|
|
2444
|
-
* @param endpoint - Endpoint to validate
|
|
2445
|
-
* @param apiId - API identifier (for error messages)
|
|
2446
|
-
* @throws Error if endpoint is invalid
|
|
2447
|
-
*/
|
|
2448
|
-
validateEndpoint(endpoint, apiId) {
|
|
2449
|
-
if (!endpoint.id) {
|
|
2450
|
-
throw new Error(`[ApiRegistry] Endpoint in API '${apiId}' missing 'id' field`);
|
|
2451
|
-
}
|
|
2452
|
-
if (!endpoint.path) {
|
|
2453
|
-
throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' in API '${apiId}' missing 'path' field`);
|
|
2454
|
-
}
|
|
2455
|
-
}
|
|
2456
|
-
/**
|
|
2457
|
-
* Get an API by ID
|
|
2458
|
-
*
|
|
2459
|
-
* @param apiId - API identifier
|
|
2460
|
-
* @returns API registry entry or undefined
|
|
2461
|
-
*/
|
|
2462
|
-
getApi(apiId) {
|
|
2463
|
-
return this.apis.get(apiId);
|
|
2464
|
-
}
|
|
2465
|
-
/**
|
|
2466
|
-
* Get all registered APIs
|
|
2467
|
-
*
|
|
2468
|
-
* @returns Array of all APIs
|
|
2469
|
-
*/
|
|
2470
|
-
getAllApis() {
|
|
2471
|
-
return Array.from(this.apis.values());
|
|
2472
|
-
}
|
|
2473
|
-
/**
|
|
2474
|
-
* Find APIs matching query criteria
|
|
2475
|
-
*
|
|
2476
|
-
* Performance optimized with auxiliary indices for O(1) lookups on type, tags, and status.
|
|
2477
|
-
*
|
|
2478
|
-
* @param query - Discovery query parameters
|
|
2479
|
-
* @returns Matching APIs
|
|
2480
|
-
*/
|
|
2481
|
-
findApis(query) {
|
|
2482
|
-
let resultIds;
|
|
2483
|
-
if (query.type) {
|
|
2484
|
-
const typeIds = this.apisByType.get(query.type);
|
|
2485
|
-
if (!typeIds || typeIds.size === 0) {
|
|
2486
|
-
return { apis: [], total: 0, filters: query };
|
|
2487
|
-
}
|
|
2488
|
-
resultIds = new Set(typeIds);
|
|
2489
|
-
}
|
|
2490
|
-
if (query.status) {
|
|
2491
|
-
const statusIds = this.apisByStatus.get(query.status);
|
|
2492
|
-
if (!statusIds || statusIds.size === 0) {
|
|
2493
|
-
return { apis: [], total: 0, filters: query };
|
|
2494
|
-
}
|
|
2495
|
-
if (resultIds) {
|
|
2496
|
-
resultIds = new Set([...resultIds].filter((id) => statusIds.has(id)));
|
|
2497
|
-
} else {
|
|
2498
|
-
resultIds = new Set(statusIds);
|
|
2499
|
-
}
|
|
2500
|
-
if (resultIds.size === 0) {
|
|
2501
|
-
return { apis: [], total: 0, filters: query };
|
|
2502
|
-
}
|
|
2503
|
-
}
|
|
2504
|
-
if (query.tags && query.tags.length > 0) {
|
|
2505
|
-
const tagMatches = /* @__PURE__ */ new Set();
|
|
2506
|
-
for (const tag of query.tags) {
|
|
2507
|
-
const tagIds = this.apisByTag.get(tag);
|
|
2508
|
-
if (tagIds) {
|
|
2509
|
-
tagIds.forEach((id) => tagMatches.add(id));
|
|
2510
|
-
}
|
|
2511
|
-
}
|
|
2512
|
-
if (tagMatches.size === 0) {
|
|
2513
|
-
return { apis: [], total: 0, filters: query };
|
|
2514
|
-
}
|
|
2515
|
-
if (resultIds) {
|
|
2516
|
-
resultIds = new Set([...resultIds].filter((id) => tagMatches.has(id)));
|
|
2517
|
-
} else {
|
|
2518
|
-
resultIds = tagMatches;
|
|
2519
|
-
}
|
|
2520
|
-
if (resultIds.size === 0) {
|
|
2521
|
-
return { apis: [], total: 0, filters: query };
|
|
2522
|
-
}
|
|
2523
|
-
}
|
|
2524
|
-
let results;
|
|
2525
|
-
if (resultIds) {
|
|
2526
|
-
results = Array.from(resultIds).map((id) => this.apis.get(id)).filter((api) => api !== void 0);
|
|
2527
|
-
} else {
|
|
2528
|
-
results = Array.from(this.apis.values());
|
|
2529
|
-
}
|
|
2530
|
-
if (query.pluginSource) {
|
|
2531
|
-
results = results.filter(
|
|
2532
|
-
(api) => api.metadata?.pluginSource === query.pluginSource
|
|
2533
|
-
);
|
|
2534
|
-
}
|
|
2535
|
-
if (query.version) {
|
|
2536
|
-
results = results.filter((api) => api.version === query.version);
|
|
2537
|
-
}
|
|
2538
|
-
if (query.search) {
|
|
2539
|
-
const searchLower = query.search.toLowerCase();
|
|
2540
|
-
results = results.filter(
|
|
2541
|
-
(api) => api.name.toLowerCase().includes(searchLower) || api.description && api.description.toLowerCase().includes(searchLower)
|
|
2542
|
-
);
|
|
2543
|
-
}
|
|
2544
|
-
return {
|
|
2545
|
-
apis: results,
|
|
2546
|
-
total: results.length,
|
|
2547
|
-
filters: query
|
|
2548
|
-
};
|
|
2549
|
-
}
|
|
2550
|
-
/**
|
|
2551
|
-
* Get endpoint by API ID and endpoint ID
|
|
2552
|
-
*
|
|
2553
|
-
* @param apiId - API identifier
|
|
2554
|
-
* @param endpointId - Endpoint identifier
|
|
2555
|
-
* @returns Endpoint registration or undefined
|
|
2556
|
-
*/
|
|
2557
|
-
getEndpoint(apiId, endpointId) {
|
|
2558
|
-
const key = `${apiId}:${endpointId}`;
|
|
2559
|
-
return this.endpoints.get(key)?.endpoint;
|
|
2560
|
-
}
|
|
2561
|
-
/**
|
|
2562
|
-
* Find endpoint by route (method + path)
|
|
2563
|
-
*
|
|
2564
|
-
* @param method - HTTP method
|
|
2565
|
-
* @param path - URL path
|
|
2566
|
-
* @returns Endpoint registration or undefined
|
|
2567
|
-
*/
|
|
2568
|
-
findEndpointByRoute(method, path) {
|
|
2569
|
-
const routeKey = `${method}:${path}`;
|
|
2570
|
-
const route = this.routes.get(routeKey);
|
|
2571
|
-
if (!route) {
|
|
2572
|
-
return void 0;
|
|
2573
|
-
}
|
|
2574
|
-
const api = this.apis.get(route.api);
|
|
2575
|
-
const endpoint = this.getEndpoint(route.api, route.endpointId);
|
|
2576
|
-
if (!api || !endpoint) {
|
|
2577
|
-
return void 0;
|
|
2578
|
-
}
|
|
2579
|
-
return { api, endpoint };
|
|
2580
|
-
}
|
|
2581
|
-
/**
|
|
2582
|
-
* Get complete registry snapshot
|
|
2583
|
-
*
|
|
2584
|
-
* @returns Current registry state
|
|
2585
|
-
*/
|
|
2586
|
-
getRegistry() {
|
|
2587
|
-
const apis = Array.from(this.apis.values());
|
|
2588
|
-
const byType = {};
|
|
2589
|
-
for (const api of apis) {
|
|
2590
|
-
if (!byType[api.type]) {
|
|
2591
|
-
byType[api.type] = [];
|
|
2592
|
-
}
|
|
2593
|
-
byType[api.type].push(api);
|
|
2594
|
-
}
|
|
2595
|
-
const byStatus = {};
|
|
2596
|
-
for (const api of apis) {
|
|
2597
|
-
const status = api.metadata?.status || "active";
|
|
2598
|
-
if (!byStatus[status]) {
|
|
2599
|
-
byStatus[status] = [];
|
|
2600
|
-
}
|
|
2601
|
-
byStatus[status].push(api);
|
|
2602
|
-
}
|
|
2603
|
-
const totalEndpoints = apis.reduce(
|
|
2604
|
-
(sum, api) => sum + api.endpoints.length,
|
|
2605
|
-
0
|
|
2606
|
-
);
|
|
2607
|
-
return {
|
|
2608
|
-
version: this.version,
|
|
2609
|
-
conflictResolution: this.conflictResolution,
|
|
2610
|
-
apis,
|
|
2611
|
-
totalApis: apis.length,
|
|
2612
|
-
totalEndpoints,
|
|
2613
|
-
byType,
|
|
2614
|
-
byStatus,
|
|
2615
|
-
updatedAt: this.updatedAt
|
|
2616
|
-
};
|
|
2617
|
-
}
|
|
2618
|
-
/**
|
|
2619
|
-
* Clear all registered APIs
|
|
2620
|
-
*
|
|
2621
|
-
* **⚠️ SAFETY WARNING:**
|
|
2622
|
-
* This method clears all registered APIs and should be used with caution.
|
|
2623
|
-
*
|
|
2624
|
-
* **Usage Restrictions:**
|
|
2625
|
-
* - In production environments (NODE_ENV=production), a `force: true` parameter is required
|
|
2626
|
-
* - Primarily intended for testing and development hot-reload scenarios
|
|
2627
|
-
*
|
|
2628
|
-
* @param options - Clear options
|
|
2629
|
-
* @param options.force - Force clear in production environment (default: false)
|
|
2630
|
-
* @throws Error if called in production without force flag
|
|
2631
|
-
*
|
|
2632
|
-
* @example Safe usage in tests
|
|
2633
|
-
* ```typescript
|
|
2634
|
-
* beforeEach(() => {
|
|
2635
|
-
* registry.clear(); // OK in test environment
|
|
2636
|
-
* });
|
|
2637
|
-
* ```
|
|
2638
|
-
*
|
|
2639
|
-
* @example Usage in production (requires explicit force)
|
|
2640
|
-
* ```typescript
|
|
2641
|
-
* // In production, explicit force is required
|
|
2642
|
-
* registry.clear({ force: true });
|
|
2643
|
-
* ```
|
|
2644
|
-
*/
|
|
2645
|
-
clear(options = {}) {
|
|
2646
|
-
const isProduction = this.isProductionEnvironment();
|
|
2647
|
-
if (isProduction && !options.force) {
|
|
2648
|
-
throw new Error(
|
|
2649
|
-
"[ApiRegistry] Cannot clear registry in production environment without force flag. Use clear({ force: true }) if you really want to clear the registry."
|
|
2650
|
-
);
|
|
2651
|
-
}
|
|
2652
|
-
this.apis.clear();
|
|
2653
|
-
this.endpoints.clear();
|
|
2654
|
-
this.routes.clear();
|
|
2655
|
-
this.apisByType.clear();
|
|
2656
|
-
this.apisByTag.clear();
|
|
2657
|
-
this.apisByStatus.clear();
|
|
2658
|
-
this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2659
|
-
if (isProduction) {
|
|
2660
|
-
this.logger.warn("API registry forcefully cleared in production", { force: options.force });
|
|
2661
|
-
} else {
|
|
2662
|
-
this.logger.info("API registry cleared");
|
|
2663
|
-
}
|
|
2664
|
-
}
|
|
2665
|
-
/**
|
|
2666
|
-
* Get registry statistics
|
|
2667
|
-
*
|
|
2668
|
-
* @returns Registry statistics
|
|
2669
|
-
*/
|
|
2670
|
-
getStats() {
|
|
2671
|
-
const apis = Array.from(this.apis.values());
|
|
2672
|
-
const apisByType = {};
|
|
2673
|
-
for (const api of apis) {
|
|
2674
|
-
apisByType[api.type] = (apisByType[api.type] || 0) + 1;
|
|
2675
|
-
}
|
|
2676
|
-
const endpointsByApi = {};
|
|
2677
|
-
for (const api of apis) {
|
|
2678
|
-
endpointsByApi[api.id] = api.endpoints.length;
|
|
2679
|
-
}
|
|
2680
|
-
return {
|
|
2681
|
-
totalApis: this.apis.size,
|
|
2682
|
-
totalEndpoints: this.endpoints.size,
|
|
2683
|
-
totalRoutes: this.routes.size,
|
|
2684
|
-
apisByType,
|
|
2685
|
-
endpointsByApi
|
|
2686
|
-
};
|
|
2687
|
-
}
|
|
2688
|
-
/**
|
|
2689
|
-
* Update auxiliary indices when an API is registered
|
|
2690
|
-
*
|
|
2691
|
-
* @param api - API entry to index
|
|
2692
|
-
* @private
|
|
2693
|
-
* @internal
|
|
2694
|
-
*/
|
|
2695
|
-
updateIndices(api) {
|
|
2696
|
-
this.ensureIndexSet(this.apisByType, api.type).add(api.id);
|
|
2697
|
-
const status = api.metadata?.status || "active";
|
|
2698
|
-
this.ensureIndexSet(this.apisByStatus, status).add(api.id);
|
|
2699
|
-
const tags = api.metadata?.tags || [];
|
|
2700
|
-
for (const tag of tags) {
|
|
2701
|
-
this.ensureIndexSet(this.apisByTag, tag).add(api.id);
|
|
2702
|
-
}
|
|
2703
|
-
}
|
|
2704
|
-
/**
|
|
2705
|
-
* Remove API from auxiliary indices when unregistered
|
|
2706
|
-
*
|
|
2707
|
-
* @param api - API entry to remove from indices
|
|
2708
|
-
* @private
|
|
2709
|
-
* @internal
|
|
2710
|
-
*/
|
|
2711
|
-
removeFromIndices(api) {
|
|
2712
|
-
this.removeFromIndexSet(this.apisByType, api.type, api.id);
|
|
2713
|
-
const status = api.metadata?.status || "active";
|
|
2714
|
-
this.removeFromIndexSet(this.apisByStatus, status, api.id);
|
|
2715
|
-
const tags = api.metadata?.tags || [];
|
|
2716
|
-
for (const tag of tags) {
|
|
2717
|
-
this.removeFromIndexSet(this.apisByTag, tag, api.id);
|
|
2718
|
-
}
|
|
2719
|
-
}
|
|
2720
|
-
/**
|
|
2721
|
-
* Helper to ensure an index set exists and return it
|
|
2722
|
-
*
|
|
2723
|
-
* @param map - Index map
|
|
2724
|
-
* @param key - Index key
|
|
2725
|
-
* @returns The Set for this key (created if needed)
|
|
2726
|
-
* @private
|
|
2727
|
-
* @internal
|
|
2728
|
-
*/
|
|
2729
|
-
ensureIndexSet(map, key) {
|
|
2730
|
-
let set = map.get(key);
|
|
2731
|
-
if (!set) {
|
|
2732
|
-
set = /* @__PURE__ */ new Set();
|
|
2733
|
-
map.set(key, set);
|
|
2734
|
-
}
|
|
2735
|
-
return set;
|
|
2736
|
-
}
|
|
2737
|
-
/**
|
|
2738
|
-
* Helper to remove an ID from an index set and clean up empty sets
|
|
2739
|
-
*
|
|
2740
|
-
* @param map - Index map
|
|
2741
|
-
* @param key - Index key
|
|
2742
|
-
* @param id - API ID to remove
|
|
2743
|
-
* @private
|
|
2744
|
-
* @internal
|
|
2745
|
-
*/
|
|
2746
|
-
removeFromIndexSet(map, key, id) {
|
|
2747
|
-
const set = map.get(key);
|
|
2748
|
-
if (set) {
|
|
2749
|
-
set.delete(id);
|
|
2750
|
-
if (set.size === 0) {
|
|
2751
|
-
map.delete(key);
|
|
2752
|
-
}
|
|
2753
|
-
}
|
|
2754
|
-
}
|
|
2755
|
-
/**
|
|
2756
|
-
* Check if running in production environment
|
|
2757
|
-
*
|
|
2758
|
-
* @returns true if NODE_ENV is 'production'
|
|
2759
|
-
* @private
|
|
2760
|
-
* @internal
|
|
2761
|
-
*/
|
|
2762
|
-
isProductionEnvironment() {
|
|
2763
|
-
return getEnv("NODE_ENV") === "production";
|
|
2764
|
-
}
|
|
2765
|
-
};
|
|
2766
|
-
|
|
2767
|
-
// src/api-registry-plugin.ts
|
|
2768
|
-
function createApiRegistryPlugin(config = {}) {
|
|
2769
|
-
const {
|
|
2770
|
-
conflictResolution = "error",
|
|
2771
|
-
version = "1.0.0"
|
|
2772
|
-
} = config;
|
|
2773
|
-
return {
|
|
2774
|
-
name: "com.objectstack.core.api-registry",
|
|
2775
|
-
/**
|
|
2776
|
-
* Services init() registers on every path (ADR-0116, #4131) — lets the
|
|
2777
|
-
* kernel name this plugin when a consumer requires one before it inits.
|
|
2778
|
-
*/
|
|
2779
|
-
providesServices: ["api-registry"],
|
|
2780
|
-
type: "standard",
|
|
2781
|
-
version: "1.0.0",
|
|
2782
|
-
init: async (ctx) => {
|
|
2783
|
-
const registry = new ApiRegistry(
|
|
2784
|
-
ctx.logger,
|
|
2785
|
-
conflictResolution,
|
|
2786
|
-
version
|
|
2787
|
-
);
|
|
2788
|
-
ctx.registerService("api-registry", registry);
|
|
2789
|
-
ctx.logger.info("API Registry plugin initialized", {
|
|
2790
|
-
conflictResolution,
|
|
2791
|
-
version
|
|
2792
|
-
});
|
|
2793
|
-
}
|
|
2794
|
-
};
|
|
2795
|
-
}
|
|
2796
|
-
|
|
2797
2454
|
// src/qa/index.ts
|
|
2798
2455
|
var qa_exports = {};
|
|
2799
2456
|
__export(qa_exports, {
|
|
@@ -5762,10 +5419,11 @@ var PluginHealthMonitor = class {
|
|
|
5762
5419
|
const checks = [];
|
|
5763
5420
|
try {
|
|
5764
5421
|
if (config.checkMethod && typeof plugin[config.checkMethod] === "function") {
|
|
5765
|
-
const checkResult = await
|
|
5422
|
+
const checkResult = await this.raceCheckTimeout(
|
|
5766
5423
|
plugin[config.checkMethod](),
|
|
5767
|
-
|
|
5768
|
-
|
|
5424
|
+
config.timeout,
|
|
5425
|
+
`Health check timeout after ${config.timeout}ms`
|
|
5426
|
+
);
|
|
5769
5427
|
if (checkResult === false || checkResult && checkResult.status === "unhealthy") {
|
|
5770
5428
|
status = "unhealthy";
|
|
5771
5429
|
message = checkResult?.message || "Custom health check failed";
|
|
@@ -5921,12 +5579,39 @@ var PluginHealthMonitor = class {
|
|
|
5921
5579
|
this.logger.info("Health monitor shutdown complete");
|
|
5922
5580
|
}
|
|
5923
5581
|
/**
|
|
5924
|
-
*
|
|
5582
|
+
* Race a plugin's custom health check against its timeout guard, and
|
|
5583
|
+
* reclaim the guard the moment the race settles (#4875).
|
|
5584
|
+
*
|
|
5585
|
+
* Same shape, same reasoning as `ObjectKernel.raceStartupTimeout()` (#4813,
|
|
5586
|
+
* PR #4874): the guard used to be armed and then abandoned — when the check
|
|
5587
|
+
* won the race, its `setTimeout` stayed ref'd in the event loop for the full
|
|
5588
|
+
* `config.timeout`. Health checks are *periodic*, so unlike the kernel's
|
|
5589
|
+
* one-shot startup guards the orphans here accumulate: one per plugin per
|
|
5590
|
+
* round, each pinning the loop for `config.timeout`.
|
|
5591
|
+
*
|
|
5592
|
+
* Clearing on settle rather than `unref()`-ing at arm time is deliberate.
|
|
5593
|
+
* An unref'd guard also stops pinning the loop, but it stops being a guard
|
|
5594
|
+
* as well: if the check never settles and nothing else keeps the loop alive,
|
|
5595
|
+
* Node exits before the timer can fire and the timeout is never reported.
|
|
5596
|
+
* The guard has to stay ref'd exactly as long as the race is undecided,
|
|
5597
|
+
* which is what `clearTimeout` in a `finally` expresses.
|
|
5598
|
+
*
|
|
5599
|
+
* `check` is widened to `T | PromiseLike<T>` because `checkMethod` is called
|
|
5600
|
+
* dynamically off the plugin and may be synchronous; such a check wins the
|
|
5601
|
+
* race immediately and the guard is reclaimed on the same turn.
|
|
5925
5602
|
*/
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
5603
|
+
async raceCheckTimeout(check, ms, message) {
|
|
5604
|
+
let guard;
|
|
5605
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
5606
|
+
guard = setTimeout(() => {
|
|
5607
|
+
reject(new Error(message));
|
|
5608
|
+
}, ms);
|
|
5929
5609
|
});
|
|
5610
|
+
try {
|
|
5611
|
+
return await Promise.race([check, timeoutPromise]);
|
|
5612
|
+
} finally {
|
|
5613
|
+
clearTimeout(guard);
|
|
5614
|
+
}
|
|
5930
5615
|
}
|
|
5931
5616
|
};
|
|
5932
5617
|
|
|
@@ -6118,11 +5803,11 @@ var HotReloadManager = class {
|
|
|
6118
5803
|
}
|
|
6119
5804
|
if (plugin.destroy) {
|
|
6120
5805
|
this.logger.debug("Destroying plugin", { plugin: pluginName });
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
5806
|
+
await this.raceShutdownTimeout(
|
|
5807
|
+
plugin.destroy(),
|
|
5808
|
+
config.shutdownTimeout,
|
|
5809
|
+
"Shutdown timeout"
|
|
5810
|
+
);
|
|
6126
5811
|
this.logger.debug("Plugin destroyed successfully", { plugin: pluginName });
|
|
6127
5812
|
}
|
|
6128
5813
|
this.logger.debug("Plugin module would be reloaded here", { plugin: pluginName });
|
|
@@ -6149,6 +5834,41 @@ var HotReloadManager = class {
|
|
|
6149
5834
|
return false;
|
|
6150
5835
|
}
|
|
6151
5836
|
}
|
|
5837
|
+
/**
|
|
5838
|
+
* Race a plugin's `destroy()` against its shutdown-timeout guard, and
|
|
5839
|
+
* reclaim the guard the moment the race settles (#4952).
|
|
5840
|
+
*
|
|
5841
|
+
* The guard used to be armed and then abandoned — byte-for-byte the leak
|
|
5842
|
+
* #4813 fixed in the kernel's startup guards (PR #4874) and #4875 fixed in
|
|
5843
|
+
* the periodic health checks (PR #4950): when `destroy()` won the race, its
|
|
5844
|
+
* `setTimeout` stayed ref'd in the event loop for the full
|
|
5845
|
+
* `shutdownTimeout`, so a hot reload that finished in milliseconds still
|
|
5846
|
+
* pinned the loop for the whole budget — once per reload, per plugin.
|
|
5847
|
+
*
|
|
5848
|
+
* Clearing on settle rather than `unref()`-ing at arm time is deliberate.
|
|
5849
|
+
* An unref'd guard also stops pinning the loop, but it stops being a guard
|
|
5850
|
+
* as well: if `destroy()` never settles and nothing else keeps the loop
|
|
5851
|
+
* alive, Node exits before the timer can fire and the timeout is never
|
|
5852
|
+
* reported. The guard has to stay ref'd exactly as long as the race is
|
|
5853
|
+
* undecided, which is what `clearTimeout` in a `finally` expresses.
|
|
5854
|
+
*
|
|
5855
|
+
* `shutdown` is widened to `T | PromiseLike<T>` because the Plugin contract
|
|
5856
|
+
* permits a synchronous `destroy()` (`Promise<void> | void`); such a hook
|
|
5857
|
+
* wins the race immediately and the guard is reclaimed on the same turn.
|
|
5858
|
+
*/
|
|
5859
|
+
async raceShutdownTimeout(shutdown, timeout, message) {
|
|
5860
|
+
let guard;
|
|
5861
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
5862
|
+
guard = setTimeout(() => {
|
|
5863
|
+
reject(new Error(message));
|
|
5864
|
+
}, timeout);
|
|
5865
|
+
});
|
|
5866
|
+
try {
|
|
5867
|
+
return await Promise.race([shutdown, timeoutPromise]);
|
|
5868
|
+
} finally {
|
|
5869
|
+
clearTimeout(guard);
|
|
5870
|
+
}
|
|
5871
|
+
}
|
|
6152
5872
|
/**
|
|
6153
5873
|
* Schedule a reload with debouncing
|
|
6154
5874
|
*/
|
|
@@ -6608,7 +6328,6 @@ var NamespaceResolver = class {
|
|
|
6608
6328
|
ANONYMOUS_DENY_MESSAGE,
|
|
6609
6329
|
ANONYMOUS_DENY_STATUS,
|
|
6610
6330
|
API_KEY_PREFIX,
|
|
6611
|
-
ApiRegistry,
|
|
6612
6331
|
CORE_FALLBACK_FACTORIES,
|
|
6613
6332
|
DependencyResolver,
|
|
6614
6333
|
HotReloadManager,
|
|
@@ -6644,7 +6363,6 @@ var NamespaceResolver = class {
|
|
|
6644
6363
|
calendarPartsInTz,
|
|
6645
6364
|
calendarPartsInTzOrUtc,
|
|
6646
6365
|
counterSignPayload,
|
|
6647
|
-
createApiRegistryPlugin,
|
|
6648
6366
|
createLogger,
|
|
6649
6367
|
createMemoryCache,
|
|
6650
6368
|
createMemoryI18n,
|