@objectstack/core 17.0.0-rc.3 → 17.0.0-rc.5
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 +548 -2
- package/README.md +0 -1
- package/dist/index.cjs +316 -598
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +181 -318
- package/dist/index.d.ts +181 -318
- 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.js
CHANGED
|
@@ -280,7 +280,21 @@ var ObjectKernelBase = class {
|
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
282
|
/**
|
|
283
|
-
* Trigger a hook with all registered handlers
|
|
283
|
+
* Trigger a hook with all registered handlers, ISOLATING failures: a
|
|
284
|
+
* handler that throws is logged and the remaining handlers still run.
|
|
285
|
+
*
|
|
286
|
+
* Use this for hooks where one subscriber's failure must not deny the
|
|
287
|
+
* others their turn — notification-style hooks, and `kernel:shutdown`,
|
|
288
|
+
* where the handlers still queued behind the failing one are the cleanup
|
|
289
|
+
* that flushes buffers and releases resources (#5257).
|
|
290
|
+
*
|
|
291
|
+
* It is the WRONG dispatcher for anything on the BOOT path. Every hook
|
|
292
|
+
* dispatched before "✅ Bootstrap complete" is a precondition of that
|
|
293
|
+
* claim, so swallowing a throw there does not rescue the boot — it only
|
|
294
|
+
* hides the failure behind a process that reports success. Those hooks
|
|
295
|
+
* (`kernel:ready`, `kernel:bootstrapped`, `kernel:listening`) use
|
|
296
|
+
* {@link triggerHookOrThrow} (#5170, #5257).
|
|
297
|
+
*
|
|
284
298
|
* @param name - Hook name
|
|
285
299
|
* @param args - Arguments to pass to handlers
|
|
286
300
|
*/
|
|
@@ -298,6 +312,54 @@ var ObjectKernelBase = class {
|
|
|
298
312
|
}
|
|
299
313
|
}
|
|
300
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Trigger a hook with all registered handlers, PROPAGATING the first
|
|
317
|
+
* failure: the remaining handlers do not run and the original error
|
|
318
|
+
* reaches the caller unwrapped.
|
|
319
|
+
*
|
|
320
|
+
* This is the dispatch semantics `ObjectKernel` has always had for every
|
|
321
|
+
* lifecycle hook (its `context.trigger` is a bare awaited loop that never
|
|
322
|
+
* catches). `LiteKernel` used the isolating {@link triggerHook} for all of
|
|
323
|
+
* them, so one hook name meant two opposite things depending on which
|
|
324
|
+
* kernel booted the same plugin code (#5170).
|
|
325
|
+
*
|
|
326
|
+
* `LiteKernel` now uses this dispatcher for all three BOOT-path hooks:
|
|
327
|
+
*
|
|
328
|
+
* - `kernel:ready` (#5170) — the only correct moment for a plugin to
|
|
329
|
+
* assert that the preconditions it declared were actually met (the
|
|
330
|
+
* registries are still filling during `init()`), so "declared but not
|
|
331
|
+
* deliverable ⇒ refuse to boot" gates live there. On LiteKernel, which
|
|
332
|
+
* is what vitest/serverless/edge run, they were downgraded to an error
|
|
333
|
+
* log while the process carried on serving traffic without the
|
|
334
|
+
* guarantee it claimed.
|
|
335
|
+
* - `kernel:bootstrapped` and `kernel:listening` (#5257) — the same
|
|
336
|
+
* argument one hook later. `kernel:listening` is where HTTP server
|
|
337
|
+
* plugins open their socket, so a swallowed failure there produced the
|
|
338
|
+
* worst shape available: a live process printing "✅ Bootstrap complete"
|
|
339
|
+
* with nothing listening. `kernel:bootstrapped` carries reconcile and
|
|
340
|
+
* audit passes whose silent failure is a quieter version of the same
|
|
341
|
+
* lie.
|
|
342
|
+
*
|
|
343
|
+
* Deliberately NOT applied to `kernel:shutdown`, which keeps
|
|
344
|
+
* {@link triggerHook}: on the teardown path a failing handler must not
|
|
345
|
+
* block the cleanup queued behind it. That is a per-hook judgement
|
|
346
|
+
* recorded at the dispatch site in `lite-kernel.ts`, not an inherited
|
|
347
|
+
* default — and it is the reason this dispatcher is chosen per hook rather
|
|
348
|
+
* than swapped in wholesale.
|
|
349
|
+
*
|
|
350
|
+
* @param name - Hook name
|
|
351
|
+
* @param args - Arguments to pass to handlers
|
|
352
|
+
*/
|
|
353
|
+
async triggerHookOrThrow(name, ...args) {
|
|
354
|
+
const handlers = this.hooks.get(name) || [];
|
|
355
|
+
this.logger.debug(`Triggering hook: ${name}`, {
|
|
356
|
+
hook: name,
|
|
357
|
+
handlerCount: handlers.length
|
|
358
|
+
});
|
|
359
|
+
for (const handler of handlers) {
|
|
360
|
+
await handler(...args);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
301
363
|
/**
|
|
302
364
|
* Get current kernel state
|
|
303
365
|
*/
|
|
@@ -330,6 +392,73 @@ var LEVEL_COLORS = {
|
|
|
330
392
|
silent: ""
|
|
331
393
|
};
|
|
332
394
|
var RESET = "\x1B[0m";
|
|
395
|
+
function tokenizeFieldName(name) {
|
|
396
|
+
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());
|
|
397
|
+
}
|
|
398
|
+
function singularizeWord(word) {
|
|
399
|
+
if (/(?:ss|us|is)$/.test(word)) return word;
|
|
400
|
+
if (/(?:ch|sh|s|x|z)es$/.test(word)) return word.slice(0, -2);
|
|
401
|
+
if (/[a-z0-9]s$/.test(word)) return word.slice(0, -1);
|
|
402
|
+
return word;
|
|
403
|
+
}
|
|
404
|
+
var CONCATENATED_SECRET_QUALIFIERS = /* @__PURE__ */ new Set([
|
|
405
|
+
"access",
|
|
406
|
+
"account",
|
|
407
|
+
"admin",
|
|
408
|
+
"api",
|
|
409
|
+
"app",
|
|
410
|
+
"auth",
|
|
411
|
+
"bearer",
|
|
412
|
+
"client",
|
|
413
|
+
"csrf",
|
|
414
|
+
"db",
|
|
415
|
+
"database",
|
|
416
|
+
"encryption",
|
|
417
|
+
"id",
|
|
418
|
+
"jwt",
|
|
419
|
+
"master",
|
|
420
|
+
"oauth",
|
|
421
|
+
"private",
|
|
422
|
+
"public",
|
|
423
|
+
"refresh",
|
|
424
|
+
"root",
|
|
425
|
+
"secret",
|
|
426
|
+
"service",
|
|
427
|
+
"session",
|
|
428
|
+
"shared",
|
|
429
|
+
"sign",
|
|
430
|
+
"signing",
|
|
431
|
+
"ssh",
|
|
432
|
+
"token",
|
|
433
|
+
"user",
|
|
434
|
+
"webhook",
|
|
435
|
+
"xsrf"
|
|
436
|
+
]);
|
|
437
|
+
function isQualifiedConcatenation(word, redactWord) {
|
|
438
|
+
for (const base of [word, singularizeWord(word)]) {
|
|
439
|
+
if (base.length <= redactWord.length || !base.endsWith(redactWord)) continue;
|
|
440
|
+
if (CONCATENATED_SECRET_QUALIFIERS.has(base.slice(0, base.length - redactWord.length))) return true;
|
|
441
|
+
}
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
function containsWordRun(words, run) {
|
|
445
|
+
for (let i = 0; i + run.length <= words.length; i++) {
|
|
446
|
+
if (run.every((word, offset) => words[i + offset] === word)) return true;
|
|
447
|
+
}
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
function fieldWordsMatchPattern(nameWords, patternWords) {
|
|
451
|
+
if (patternWords.length === 0 || nameWords.length === 0) return false;
|
|
452
|
+
if (patternWords.length > 1) {
|
|
453
|
+
const glued = patternWords.join("");
|
|
454
|
+
return containsWordRun(nameWords, patternWords) || nameWords.some((word) => word === glued || singularizeWord(word) === glued);
|
|
455
|
+
}
|
|
456
|
+
const redactWord = patternWords[0];
|
|
457
|
+
const isCompound = nameWords.filter((word) => /[a-z]/.test(word)).length > 1;
|
|
458
|
+
return nameWords.some(
|
|
459
|
+
(word) => word === redactWord || isCompound && singularizeWord(word) === redactWord || isQualifiedConcatenation(word, redactWord)
|
|
460
|
+
);
|
|
461
|
+
}
|
|
333
462
|
function colorEnabled(stream) {
|
|
334
463
|
if (typeof process !== "undefined") {
|
|
335
464
|
const noColor = process.env?.NO_COLOR;
|
|
@@ -368,6 +497,7 @@ var ObjectLogger = class _ObjectLogger {
|
|
|
368
497
|
rotation: config.rotation ?? { maxSize: "10m", maxFiles: 5 }
|
|
369
498
|
};
|
|
370
499
|
this.bindings = bindings;
|
|
500
|
+
this.redactPatterns = this.config.redact.map(tokenizeFieldName).filter((words) => words.length > 0);
|
|
371
501
|
if (this.config.file && typeof process !== "undefined") {
|
|
372
502
|
this.openFileStream(this.config.file);
|
|
373
503
|
}
|
|
@@ -413,12 +543,25 @@ var ObjectLogger = class _ObjectLogger {
|
|
|
413
543
|
isEnabled(level) {
|
|
414
544
|
return LEVEL_ORDER[level] >= LEVEL_ORDER[this.config.level];
|
|
415
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* Whether a meta field name names one of the configured secrets.
|
|
548
|
+
*
|
|
549
|
+
* Until #5573 this was `lower.includes(pattern)`, which redacted every
|
|
550
|
+
* field whose name merely *contained* a redact word — `keys`, `keyword`,
|
|
551
|
+
* `tokens`, `monkey`, `secretary` — and replaced its value with
|
|
552
|
+
* `***REDACTED***`, so the reader lost the fact AND was told a secret had
|
|
553
|
+
* been withheld. Matching is now on word boundaries: `key` matches
|
|
554
|
+
* `apiKey` / `api_key`, not `keys` / `monkey` / `keyword`.
|
|
555
|
+
*/
|
|
556
|
+
isRedactedFieldName(key) {
|
|
557
|
+
const nameWords = tokenizeFieldName(key);
|
|
558
|
+
return this.redactPatterns.some((pattern) => fieldWordsMatchPattern(nameWords, pattern));
|
|
559
|
+
}
|
|
416
560
|
redactSensitive(obj) {
|
|
417
561
|
if (!obj || typeof obj !== "object") return obj;
|
|
418
562
|
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
419
563
|
for (const key in redacted) {
|
|
420
|
-
|
|
421
|
-
if (this.config.redact.some((p) => lower.includes(p.toLowerCase()))) {
|
|
564
|
+
if (this.isRedactedFieldName(key)) {
|
|
422
565
|
redacted[key] = "***REDACTED***";
|
|
423
566
|
} else if (typeof redacted[key] === "object" && redacted[key] !== null) {
|
|
424
567
|
redacted[key] = this.redactSensitive(redacted[key]);
|
|
@@ -481,18 +624,44 @@ var ObjectLogger = class _ObjectLogger {
|
|
|
481
624
|
this.write("warn", message, meta);
|
|
482
625
|
}
|
|
483
626
|
error(message, errorOrMeta, meta) {
|
|
484
|
-
|
|
485
|
-
this.write("error", message, meta, errorOrMeta);
|
|
486
|
-
} else {
|
|
487
|
-
this.write("error", message, errorOrMeta);
|
|
488
|
-
}
|
|
627
|
+
this.writeErrorLike("error", message, errorOrMeta, meta);
|
|
489
628
|
}
|
|
490
629
|
fatal(message, errorOrMeta, meta) {
|
|
630
|
+
this.writeErrorLike("fatal", message, errorOrMeta, meta);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* `error`/`fatal` dispatch — the two levels whose contract has an `Error`
|
|
634
|
+
* slot in front of `meta`.
|
|
635
|
+
*
|
|
636
|
+
* The `Logger` contract declares `error(message, error?: Error, meta?)`, and
|
|
637
|
+
* `ObjectLogger` additionally tolerates a **meta object** in the `error`
|
|
638
|
+
* slot because many in-repo call sites write `logger.error(msg, { … })`.
|
|
639
|
+
* That tolerance is fine; dropping a parameter the contract *declares* is
|
|
640
|
+
* not, and that is what the previous dispatch did:
|
|
641
|
+
*
|
|
642
|
+
* if (errorOrMeta instanceof Error) this.write(level, message, meta, errorOrMeta);
|
|
643
|
+
* else this.write(level, message, errorOrMeta);
|
|
644
|
+
*
|
|
645
|
+
* With `error === undefined` the `else` branch passed `undefined` as the
|
|
646
|
+
* meta and **never read the third argument**, so every contract-shaped
|
|
647
|
+
* `logger.error(msg, undefined, { … })` call rendered a bare message with
|
|
648
|
+
* its diagnostics silently gone — ~15 such call sites across `metadata`,
|
|
649
|
+
* `metadata-protocol`, `client` and `core/security`, plus the connector
|
|
650
|
+
* reconcile seam that found this (#5575). The contract's two sibling
|
|
651
|
+
* implementations (`ConsoleLogger`/`JsonLogger` in `@objectstack/observability`)
|
|
652
|
+
* both honour the slot, so the contract was right and this class was the
|
|
653
|
+
* outlier — declared ≠ enforced, Prime Directive #10.
|
|
654
|
+
*
|
|
655
|
+
* All three shapes are now honoured. When both slots carry meta, `meta`
|
|
656
|
+
* (the later, more specific argument) wins on a key collision.
|
|
657
|
+
*/
|
|
658
|
+
writeErrorLike(level, message, errorOrMeta, meta) {
|
|
491
659
|
if (errorOrMeta instanceof Error) {
|
|
492
|
-
this.write(
|
|
493
|
-
|
|
494
|
-
this.write("fatal", message, errorOrMeta);
|
|
660
|
+
this.write(level, message, meta, errorOrMeta);
|
|
661
|
+
return;
|
|
495
662
|
}
|
|
663
|
+
const merged = errorOrMeta && meta ? { ...errorOrMeta, ...meta } : errorOrMeta ?? meta;
|
|
664
|
+
this.write(level, message, merged);
|
|
496
665
|
}
|
|
497
666
|
log(message, ...args) {
|
|
498
667
|
this.info(message, args.length > 0 ? { args } : void 0);
|
|
@@ -1757,11 +1926,12 @@ var ObjectKernel = class {
|
|
|
1757
1926
|
}
|
|
1758
1927
|
this.state = "stopping";
|
|
1759
1928
|
this.logger.info("Graceful shutdown started");
|
|
1929
|
+
const shutdownTimeoutError = new Error("Shutdown timeout exceeded");
|
|
1760
1930
|
try {
|
|
1761
1931
|
const shutdownPromise = this.performShutdown();
|
|
1762
1932
|
const timeoutPromise = new Promise((_, reject) => {
|
|
1763
1933
|
const t = setTimeout(() => {
|
|
1764
|
-
reject(
|
|
1934
|
+
reject(shutdownTimeoutError);
|
|
1765
1935
|
}, this.config.shutdownTimeout);
|
|
1766
1936
|
if (t.unref) t.unref();
|
|
1767
1937
|
});
|
|
@@ -1769,10 +1939,17 @@ var ObjectKernel = class {
|
|
|
1769
1939
|
this.state = "stopped";
|
|
1770
1940
|
this.logger.info("\u2705 Graceful shutdown complete");
|
|
1771
1941
|
} catch (error) {
|
|
1772
|
-
this.logger.error("Shutdown timed out \u2014 forcing exit", error);
|
|
1773
1942
|
this.state = "stopped";
|
|
1774
|
-
|
|
1775
|
-
|
|
1943
|
+
if (error === shutdownTimeoutError) {
|
|
1944
|
+
this.logger.error("Shutdown timed out \u2014 forcing exit", error);
|
|
1945
|
+
await this.logger.destroy();
|
|
1946
|
+
process.exit(1);
|
|
1947
|
+
} else {
|
|
1948
|
+
this.logger.error(
|
|
1949
|
+
"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",
|
|
1950
|
+
error
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1776
1953
|
} finally {
|
|
1777
1954
|
await this.logger.destroy();
|
|
1778
1955
|
}
|
|
@@ -1962,8 +2139,48 @@ var ObjectKernel = class {
|
|
|
1962
2139
|
}
|
|
1963
2140
|
this.startedPlugins.clear();
|
|
1964
2141
|
}
|
|
2142
|
+
/**
|
|
2143
|
+
* Dispatch `kernel:shutdown`, ISOLATING failures: a handler that throws is
|
|
2144
|
+
* logged and the remaining handlers still run (#5274).
|
|
2145
|
+
*
|
|
2146
|
+
* This is a per-hook judgement, deliberately NOT the bare awaited loop
|
|
2147
|
+
* `context.trigger` runs for every other hook — the boot-path hooks
|
|
2148
|
+
* (`kernel:ready`, `kernel:bootstrapped`, `kernel:listening`) keep
|
|
2149
|
+
* propagating, because everything dispatched before "✅ Bootstrap complete"
|
|
2150
|
+
* is a precondition of that claim and swallowing a throw there only hides
|
|
2151
|
+
* the failure behind a process reporting success (#5170, #5257).
|
|
2152
|
+
*
|
|
2153
|
+
* On the teardown path there is no "refuse to proceed" left to buy. What is
|
|
2154
|
+
* queued behind a failing shutdown handler is the rest of the cleanup —
|
|
2155
|
+
* every other subscriber, then each plugin's `destroy()` in reverse order —
|
|
2156
|
+
* which is what flushes buffers, closes connections and releases locks. So
|
|
2157
|
+
* one bad handler must not amplify into leaked resources and unflushed
|
|
2158
|
+
* writes. Same reasoning, same wording, same `Hook handler failed:
|
|
2159
|
+
* kernel:shutdown` log line as `LiteKernel`'s dispatch site, which reaches
|
|
2160
|
+
* the shared isolating dispatcher `ObjectKernelBase.triggerHook` (#5257).
|
|
2161
|
+
*
|
|
2162
|
+
* `ObjectKernel` cannot call that dispatcher: it does not extend
|
|
2163
|
+
* `ObjectKernelBase` (only `LiteKernel` does) and owns its own `hooks` map,
|
|
2164
|
+
* so the semantics are mirrored here rather than shared. One hook name
|
|
2165
|
+
* meaning two opposite things across the two kernels is exactly the bug
|
|
2166
|
+
* #5170/#5257 closed, so the pin for this one lives on both sides too.
|
|
2167
|
+
*/
|
|
2168
|
+
async triggerShutdownHookIsolating() {
|
|
2169
|
+
const handlers = this.hooks.get("kernel:shutdown") || [];
|
|
2170
|
+
this.logger.debug("Triggering hook: kernel:shutdown", {
|
|
2171
|
+
hook: "kernel:shutdown",
|
|
2172
|
+
handlerCount: handlers.length
|
|
2173
|
+
});
|
|
2174
|
+
for (const handler of handlers) {
|
|
2175
|
+
try {
|
|
2176
|
+
await handler();
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
this.logger.error("Hook handler failed: kernel:shutdown", error);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
1965
2182
|
async performShutdown() {
|
|
1966
|
-
await this.
|
|
2183
|
+
await this.triggerShutdownHookIsolating();
|
|
1967
2184
|
const orderedPlugins = Array.from(this.plugins.values()).reverse();
|
|
1968
2185
|
for (const plugin of orderedPlugins) {
|
|
1969
2186
|
if (plugin.destroy) {
|
|
@@ -2065,9 +2282,14 @@ var LiteKernel = class extends ObjectKernelBase {
|
|
|
2065
2282
|
for (const plugin of orderedPlugins) {
|
|
2066
2283
|
await this.runPluginStart(plugin);
|
|
2067
2284
|
}
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2285
|
+
try {
|
|
2286
|
+
await this.triggerHookOrThrow("kernel:ready");
|
|
2287
|
+
await this.triggerHookOrThrow("kernel:bootstrapped");
|
|
2288
|
+
await this.triggerHookOrThrow("kernel:listening");
|
|
2289
|
+
} catch (error) {
|
|
2290
|
+
this.state = "stopped";
|
|
2291
|
+
throw error;
|
|
2292
|
+
}
|
|
2071
2293
|
this.logger.info("\u2705 Bootstrap complete", {
|
|
2072
2294
|
pluginCount: this.plugins.size
|
|
2073
2295
|
});
|
|
@@ -2115,569 +2337,6 @@ var LiteKernel = class extends ObjectKernelBase {
|
|
|
2115
2337
|
}
|
|
2116
2338
|
};
|
|
2117
2339
|
|
|
2118
|
-
// src/api-registry.ts
|
|
2119
|
-
import { ApiRegistryEntrySchema } from "@objectstack/spec/api";
|
|
2120
|
-
var ApiRegistry = class {
|
|
2121
|
-
constructor(logger, conflictResolution = "error", version = "1.0.0") {
|
|
2122
|
-
this.apis = /* @__PURE__ */ new Map();
|
|
2123
|
-
this.endpoints = /* @__PURE__ */ new Map();
|
|
2124
|
-
this.routes = /* @__PURE__ */ new Map();
|
|
2125
|
-
// Performance optimization: Auxiliary indices for O(1) lookups
|
|
2126
|
-
this.apisByType = /* @__PURE__ */ new Map();
|
|
2127
|
-
this.apisByTag = /* @__PURE__ */ new Map();
|
|
2128
|
-
this.apisByStatus = /* @__PURE__ */ new Map();
|
|
2129
|
-
this.logger = logger;
|
|
2130
|
-
this.conflictResolution = conflictResolution;
|
|
2131
|
-
this.version = version;
|
|
2132
|
-
this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2133
|
-
}
|
|
2134
|
-
/**
|
|
2135
|
-
* Register an API with its endpoints
|
|
2136
|
-
*
|
|
2137
|
-
* @param api - API registry entry
|
|
2138
|
-
* @throws Error if API already registered or route conflicts detected
|
|
2139
|
-
*/
|
|
2140
|
-
registerApi(api) {
|
|
2141
|
-
if (this.apis.has(api.id)) {
|
|
2142
|
-
throw new Error(`[ApiRegistry] API '${api.id}' already registered`);
|
|
2143
|
-
}
|
|
2144
|
-
const fullApi = ApiRegistryEntrySchema.parse(api);
|
|
2145
|
-
for (const endpoint of fullApi.endpoints) {
|
|
2146
|
-
this.validateEndpoint(endpoint, fullApi.id);
|
|
2147
|
-
}
|
|
2148
|
-
this.apis.set(fullApi.id, fullApi);
|
|
2149
|
-
for (const endpoint of fullApi.endpoints) {
|
|
2150
|
-
this.registerEndpoint(fullApi.id, endpoint);
|
|
2151
|
-
}
|
|
2152
|
-
this.updateIndices(fullApi);
|
|
2153
|
-
this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2154
|
-
this.logger.info(`API registered: ${fullApi.id}`, {
|
|
2155
|
-
api: fullApi.id,
|
|
2156
|
-
type: fullApi.type,
|
|
2157
|
-
endpointCount: fullApi.endpoints.length
|
|
2158
|
-
});
|
|
2159
|
-
}
|
|
2160
|
-
/**
|
|
2161
|
-
* Unregister an API and all its endpoints
|
|
2162
|
-
*
|
|
2163
|
-
* @param apiId - API identifier
|
|
2164
|
-
*/
|
|
2165
|
-
unregisterApi(apiId) {
|
|
2166
|
-
const api = this.apis.get(apiId);
|
|
2167
|
-
if (!api) {
|
|
2168
|
-
throw new Error(`[ApiRegistry] API '${apiId}' not found`);
|
|
2169
|
-
}
|
|
2170
|
-
for (const endpoint of api.endpoints) {
|
|
2171
|
-
this.unregisterEndpoint(apiId, endpoint.id);
|
|
2172
|
-
}
|
|
2173
|
-
this.removeFromIndices(api);
|
|
2174
|
-
this.apis.delete(apiId);
|
|
2175
|
-
this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2176
|
-
this.logger.info(`API unregistered: ${apiId}`);
|
|
2177
|
-
}
|
|
2178
|
-
/**
|
|
2179
|
-
* Register a single endpoint
|
|
2180
|
-
*
|
|
2181
|
-
* @param apiId - API identifier
|
|
2182
|
-
* @param endpoint - Endpoint registration
|
|
2183
|
-
* @throws Error if route conflict detected
|
|
2184
|
-
*/
|
|
2185
|
-
registerEndpoint(apiId, endpoint) {
|
|
2186
|
-
const endpointKey = `${apiId}:${endpoint.id}`;
|
|
2187
|
-
if (this.endpoints.has(endpointKey)) {
|
|
2188
|
-
throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' already registered for API '${apiId}'`);
|
|
2189
|
-
}
|
|
2190
|
-
this.endpoints.set(endpointKey, { api: apiId, endpoint });
|
|
2191
|
-
if (endpoint.path) {
|
|
2192
|
-
this.registerRoute(apiId, endpoint);
|
|
2193
|
-
}
|
|
2194
|
-
}
|
|
2195
|
-
/**
|
|
2196
|
-
* Unregister a single endpoint
|
|
2197
|
-
*
|
|
2198
|
-
* @param apiId - API identifier
|
|
2199
|
-
* @param endpointId - Endpoint identifier
|
|
2200
|
-
*/
|
|
2201
|
-
unregisterEndpoint(apiId, endpointId) {
|
|
2202
|
-
const endpointKey = `${apiId}:${endpointId}`;
|
|
2203
|
-
const entry = this.endpoints.get(endpointKey);
|
|
2204
|
-
if (!entry) {
|
|
2205
|
-
return;
|
|
2206
|
-
}
|
|
2207
|
-
if (entry.endpoint.path) {
|
|
2208
|
-
const routeKey = this.getRouteKey(entry.endpoint);
|
|
2209
|
-
this.routes.delete(routeKey);
|
|
2210
|
-
}
|
|
2211
|
-
this.endpoints.delete(endpointKey);
|
|
2212
|
-
}
|
|
2213
|
-
/**
|
|
2214
|
-
* Register a route with conflict detection
|
|
2215
|
-
*
|
|
2216
|
-
* @param apiId - API identifier
|
|
2217
|
-
* @param endpoint - Endpoint registration
|
|
2218
|
-
* @throws Error if route conflict detected (based on strategy)
|
|
2219
|
-
*/
|
|
2220
|
-
registerRoute(apiId, endpoint) {
|
|
2221
|
-
const routeKey = this.getRouteKey(endpoint);
|
|
2222
|
-
const priority = endpoint.priority ?? 100;
|
|
2223
|
-
const existingRoute = this.routes.get(routeKey);
|
|
2224
|
-
if (existingRoute) {
|
|
2225
|
-
this.handleRouteConflict(routeKey, apiId, endpoint, existingRoute, priority);
|
|
2226
|
-
return;
|
|
2227
|
-
}
|
|
2228
|
-
this.routes.set(routeKey, {
|
|
2229
|
-
api: apiId,
|
|
2230
|
-
endpointId: endpoint.id,
|
|
2231
|
-
priority
|
|
2232
|
-
});
|
|
2233
|
-
}
|
|
2234
|
-
/**
|
|
2235
|
-
* Handle route conflict based on resolution strategy
|
|
2236
|
-
*
|
|
2237
|
-
* @param routeKey - Route key
|
|
2238
|
-
* @param apiId - New API identifier
|
|
2239
|
-
* @param endpoint - New endpoint
|
|
2240
|
-
* @param existingRoute - Existing route registration
|
|
2241
|
-
* @param newPriority - New endpoint priority
|
|
2242
|
-
* @throws Error if strategy is 'error'
|
|
2243
|
-
*/
|
|
2244
|
-
handleRouteConflict(routeKey, apiId, endpoint, existingRoute, newPriority) {
|
|
2245
|
-
const strategy = this.conflictResolution;
|
|
2246
|
-
switch (strategy) {
|
|
2247
|
-
case "error":
|
|
2248
|
-
throw new Error(
|
|
2249
|
-
`[ApiRegistry] Route conflict detected: '${routeKey}' is already registered by API '${existingRoute.api}' endpoint '${existingRoute.endpointId}'`
|
|
2250
|
-
);
|
|
2251
|
-
case "priority":
|
|
2252
|
-
if (newPriority > existingRoute.priority) {
|
|
2253
|
-
this.logger.warn(
|
|
2254
|
-
`Route conflict: replacing '${routeKey}' (priority ${existingRoute.priority} -> ${newPriority})`,
|
|
2255
|
-
{
|
|
2256
|
-
oldApi: existingRoute.api,
|
|
2257
|
-
oldEndpoint: existingRoute.endpointId,
|
|
2258
|
-
newApi: apiId,
|
|
2259
|
-
newEndpoint: endpoint.id
|
|
2260
|
-
}
|
|
2261
|
-
);
|
|
2262
|
-
this.routes.set(routeKey, {
|
|
2263
|
-
api: apiId,
|
|
2264
|
-
endpointId: endpoint.id,
|
|
2265
|
-
priority: newPriority
|
|
2266
|
-
});
|
|
2267
|
-
} else {
|
|
2268
|
-
this.logger.warn(
|
|
2269
|
-
`Route conflict: keeping existing '${routeKey}' (priority ${existingRoute.priority} >= ${newPriority})`,
|
|
2270
|
-
{
|
|
2271
|
-
existingApi: existingRoute.api,
|
|
2272
|
-
existingEndpoint: existingRoute.endpointId,
|
|
2273
|
-
newApi: apiId,
|
|
2274
|
-
newEndpoint: endpoint.id
|
|
2275
|
-
}
|
|
2276
|
-
);
|
|
2277
|
-
}
|
|
2278
|
-
break;
|
|
2279
|
-
case "first-wins":
|
|
2280
|
-
this.logger.warn(
|
|
2281
|
-
`Route conflict: keeping first registered '${routeKey}'`,
|
|
2282
|
-
{
|
|
2283
|
-
existingApi: existingRoute.api,
|
|
2284
|
-
newApi: apiId
|
|
2285
|
-
}
|
|
2286
|
-
);
|
|
2287
|
-
break;
|
|
2288
|
-
case "last-wins":
|
|
2289
|
-
this.logger.warn(
|
|
2290
|
-
`Route conflict: replacing with last registered '${routeKey}'`,
|
|
2291
|
-
{
|
|
2292
|
-
oldApi: existingRoute.api,
|
|
2293
|
-
newApi: apiId
|
|
2294
|
-
}
|
|
2295
|
-
);
|
|
2296
|
-
this.routes.set(routeKey, {
|
|
2297
|
-
api: apiId,
|
|
2298
|
-
endpointId: endpoint.id,
|
|
2299
|
-
priority: newPriority
|
|
2300
|
-
});
|
|
2301
|
-
break;
|
|
2302
|
-
default:
|
|
2303
|
-
throw new Error(`[ApiRegistry] Unknown conflict resolution strategy: ${strategy}`);
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
/**
|
|
2307
|
-
* Generate a unique route key for conflict detection
|
|
2308
|
-
*
|
|
2309
|
-
* NOTE: This implementation uses exact string matching for route conflict detection.
|
|
2310
|
-
* It works well for static paths but has limitations with parameterized routes.
|
|
2311
|
-
* For example, `/api/users/:id` and `/api/users/:userId` will NOT be detected as conflicts
|
|
2312
|
-
* even though they are semantically identical parameterized patterns. Similarly,
|
|
2313
|
-
* `/api/:resource/list` and `/api/:entity/list` would also not be detected as conflicting.
|
|
2314
|
-
*
|
|
2315
|
-
* For more advanced conflict detection (e.g., path-to-regexp pattern matching),
|
|
2316
|
-
* consider integrating with your routing library's conflict detection mechanism.
|
|
2317
|
-
*
|
|
2318
|
-
* @param endpoint - Endpoint registration
|
|
2319
|
-
* @returns Route key (e.g., "GET:/api/v1/customers/:id")
|
|
2320
|
-
*/
|
|
2321
|
-
getRouteKey(endpoint) {
|
|
2322
|
-
const method = endpoint.method || "ANY";
|
|
2323
|
-
return `${method}:${endpoint.path}`;
|
|
2324
|
-
}
|
|
2325
|
-
/**
|
|
2326
|
-
* Validate endpoint registration
|
|
2327
|
-
*
|
|
2328
|
-
* @param endpoint - Endpoint to validate
|
|
2329
|
-
* @param apiId - API identifier (for error messages)
|
|
2330
|
-
* @throws Error if endpoint is invalid
|
|
2331
|
-
*/
|
|
2332
|
-
validateEndpoint(endpoint, apiId) {
|
|
2333
|
-
if (!endpoint.id) {
|
|
2334
|
-
throw new Error(`[ApiRegistry] Endpoint in API '${apiId}' missing 'id' field`);
|
|
2335
|
-
}
|
|
2336
|
-
if (!endpoint.path) {
|
|
2337
|
-
throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' in API '${apiId}' missing 'path' field`);
|
|
2338
|
-
}
|
|
2339
|
-
}
|
|
2340
|
-
/**
|
|
2341
|
-
* Get an API by ID
|
|
2342
|
-
*
|
|
2343
|
-
* @param apiId - API identifier
|
|
2344
|
-
* @returns API registry entry or undefined
|
|
2345
|
-
*/
|
|
2346
|
-
getApi(apiId) {
|
|
2347
|
-
return this.apis.get(apiId);
|
|
2348
|
-
}
|
|
2349
|
-
/**
|
|
2350
|
-
* Get all registered APIs
|
|
2351
|
-
*
|
|
2352
|
-
* @returns Array of all APIs
|
|
2353
|
-
*/
|
|
2354
|
-
getAllApis() {
|
|
2355
|
-
return Array.from(this.apis.values());
|
|
2356
|
-
}
|
|
2357
|
-
/**
|
|
2358
|
-
* Find APIs matching query criteria
|
|
2359
|
-
*
|
|
2360
|
-
* Performance optimized with auxiliary indices for O(1) lookups on type, tags, and status.
|
|
2361
|
-
*
|
|
2362
|
-
* @param query - Discovery query parameters
|
|
2363
|
-
* @returns Matching APIs
|
|
2364
|
-
*/
|
|
2365
|
-
findApis(query) {
|
|
2366
|
-
let resultIds;
|
|
2367
|
-
if (query.type) {
|
|
2368
|
-
const typeIds = this.apisByType.get(query.type);
|
|
2369
|
-
if (!typeIds || typeIds.size === 0) {
|
|
2370
|
-
return { apis: [], total: 0, filters: query };
|
|
2371
|
-
}
|
|
2372
|
-
resultIds = new Set(typeIds);
|
|
2373
|
-
}
|
|
2374
|
-
if (query.status) {
|
|
2375
|
-
const statusIds = this.apisByStatus.get(query.status);
|
|
2376
|
-
if (!statusIds || statusIds.size === 0) {
|
|
2377
|
-
return { apis: [], total: 0, filters: query };
|
|
2378
|
-
}
|
|
2379
|
-
if (resultIds) {
|
|
2380
|
-
resultIds = new Set([...resultIds].filter((id) => statusIds.has(id)));
|
|
2381
|
-
} else {
|
|
2382
|
-
resultIds = new Set(statusIds);
|
|
2383
|
-
}
|
|
2384
|
-
if (resultIds.size === 0) {
|
|
2385
|
-
return { apis: [], total: 0, filters: query };
|
|
2386
|
-
}
|
|
2387
|
-
}
|
|
2388
|
-
if (query.tags && query.tags.length > 0) {
|
|
2389
|
-
const tagMatches = /* @__PURE__ */ new Set();
|
|
2390
|
-
for (const tag of query.tags) {
|
|
2391
|
-
const tagIds = this.apisByTag.get(tag);
|
|
2392
|
-
if (tagIds) {
|
|
2393
|
-
tagIds.forEach((id) => tagMatches.add(id));
|
|
2394
|
-
}
|
|
2395
|
-
}
|
|
2396
|
-
if (tagMatches.size === 0) {
|
|
2397
|
-
return { apis: [], total: 0, filters: query };
|
|
2398
|
-
}
|
|
2399
|
-
if (resultIds) {
|
|
2400
|
-
resultIds = new Set([...resultIds].filter((id) => tagMatches.has(id)));
|
|
2401
|
-
} else {
|
|
2402
|
-
resultIds = tagMatches;
|
|
2403
|
-
}
|
|
2404
|
-
if (resultIds.size === 0) {
|
|
2405
|
-
return { apis: [], total: 0, filters: query };
|
|
2406
|
-
}
|
|
2407
|
-
}
|
|
2408
|
-
let results;
|
|
2409
|
-
if (resultIds) {
|
|
2410
|
-
results = Array.from(resultIds).map((id) => this.apis.get(id)).filter((api) => api !== void 0);
|
|
2411
|
-
} else {
|
|
2412
|
-
results = Array.from(this.apis.values());
|
|
2413
|
-
}
|
|
2414
|
-
if (query.pluginSource) {
|
|
2415
|
-
results = results.filter(
|
|
2416
|
-
(api) => api.metadata?.pluginSource === query.pluginSource
|
|
2417
|
-
);
|
|
2418
|
-
}
|
|
2419
|
-
if (query.version) {
|
|
2420
|
-
results = results.filter((api) => api.version === query.version);
|
|
2421
|
-
}
|
|
2422
|
-
if (query.search) {
|
|
2423
|
-
const searchLower = query.search.toLowerCase();
|
|
2424
|
-
results = results.filter(
|
|
2425
|
-
(api) => api.name.toLowerCase().includes(searchLower) || api.description && api.description.toLowerCase().includes(searchLower)
|
|
2426
|
-
);
|
|
2427
|
-
}
|
|
2428
|
-
return {
|
|
2429
|
-
apis: results,
|
|
2430
|
-
total: results.length,
|
|
2431
|
-
filters: query
|
|
2432
|
-
};
|
|
2433
|
-
}
|
|
2434
|
-
/**
|
|
2435
|
-
* Get endpoint by API ID and endpoint ID
|
|
2436
|
-
*
|
|
2437
|
-
* @param apiId - API identifier
|
|
2438
|
-
* @param endpointId - Endpoint identifier
|
|
2439
|
-
* @returns Endpoint registration or undefined
|
|
2440
|
-
*/
|
|
2441
|
-
getEndpoint(apiId, endpointId) {
|
|
2442
|
-
const key = `${apiId}:${endpointId}`;
|
|
2443
|
-
return this.endpoints.get(key)?.endpoint;
|
|
2444
|
-
}
|
|
2445
|
-
/**
|
|
2446
|
-
* Find endpoint by route (method + path)
|
|
2447
|
-
*
|
|
2448
|
-
* @param method - HTTP method
|
|
2449
|
-
* @param path - URL path
|
|
2450
|
-
* @returns Endpoint registration or undefined
|
|
2451
|
-
*/
|
|
2452
|
-
findEndpointByRoute(method, path) {
|
|
2453
|
-
const routeKey = `${method}:${path}`;
|
|
2454
|
-
const route = this.routes.get(routeKey);
|
|
2455
|
-
if (!route) {
|
|
2456
|
-
return void 0;
|
|
2457
|
-
}
|
|
2458
|
-
const api = this.apis.get(route.api);
|
|
2459
|
-
const endpoint = this.getEndpoint(route.api, route.endpointId);
|
|
2460
|
-
if (!api || !endpoint) {
|
|
2461
|
-
return void 0;
|
|
2462
|
-
}
|
|
2463
|
-
return { api, endpoint };
|
|
2464
|
-
}
|
|
2465
|
-
/**
|
|
2466
|
-
* Get complete registry snapshot
|
|
2467
|
-
*
|
|
2468
|
-
* @returns Current registry state
|
|
2469
|
-
*/
|
|
2470
|
-
getRegistry() {
|
|
2471
|
-
const apis = Array.from(this.apis.values());
|
|
2472
|
-
const byType = {};
|
|
2473
|
-
for (const api of apis) {
|
|
2474
|
-
if (!byType[api.type]) {
|
|
2475
|
-
byType[api.type] = [];
|
|
2476
|
-
}
|
|
2477
|
-
byType[api.type].push(api);
|
|
2478
|
-
}
|
|
2479
|
-
const byStatus = {};
|
|
2480
|
-
for (const api of apis) {
|
|
2481
|
-
const status = api.metadata?.status || "active";
|
|
2482
|
-
if (!byStatus[status]) {
|
|
2483
|
-
byStatus[status] = [];
|
|
2484
|
-
}
|
|
2485
|
-
byStatus[status].push(api);
|
|
2486
|
-
}
|
|
2487
|
-
const totalEndpoints = apis.reduce(
|
|
2488
|
-
(sum, api) => sum + api.endpoints.length,
|
|
2489
|
-
0
|
|
2490
|
-
);
|
|
2491
|
-
return {
|
|
2492
|
-
version: this.version,
|
|
2493
|
-
conflictResolution: this.conflictResolution,
|
|
2494
|
-
apis,
|
|
2495
|
-
totalApis: apis.length,
|
|
2496
|
-
totalEndpoints,
|
|
2497
|
-
byType,
|
|
2498
|
-
byStatus,
|
|
2499
|
-
updatedAt: this.updatedAt
|
|
2500
|
-
};
|
|
2501
|
-
}
|
|
2502
|
-
/**
|
|
2503
|
-
* Clear all registered APIs
|
|
2504
|
-
*
|
|
2505
|
-
* **⚠️ SAFETY WARNING:**
|
|
2506
|
-
* This method clears all registered APIs and should be used with caution.
|
|
2507
|
-
*
|
|
2508
|
-
* **Usage Restrictions:**
|
|
2509
|
-
* - In production environments (NODE_ENV=production), a `force: true` parameter is required
|
|
2510
|
-
* - Primarily intended for testing and development hot-reload scenarios
|
|
2511
|
-
*
|
|
2512
|
-
* @param options - Clear options
|
|
2513
|
-
* @param options.force - Force clear in production environment (default: false)
|
|
2514
|
-
* @throws Error if called in production without force flag
|
|
2515
|
-
*
|
|
2516
|
-
* @example Safe usage in tests
|
|
2517
|
-
* ```typescript
|
|
2518
|
-
* beforeEach(() => {
|
|
2519
|
-
* registry.clear(); // OK in test environment
|
|
2520
|
-
* });
|
|
2521
|
-
* ```
|
|
2522
|
-
*
|
|
2523
|
-
* @example Usage in production (requires explicit force)
|
|
2524
|
-
* ```typescript
|
|
2525
|
-
* // In production, explicit force is required
|
|
2526
|
-
* registry.clear({ force: true });
|
|
2527
|
-
* ```
|
|
2528
|
-
*/
|
|
2529
|
-
clear(options = {}) {
|
|
2530
|
-
const isProduction = this.isProductionEnvironment();
|
|
2531
|
-
if (isProduction && !options.force) {
|
|
2532
|
-
throw new Error(
|
|
2533
|
-
"[ApiRegistry] Cannot clear registry in production environment without force flag. Use clear({ force: true }) if you really want to clear the registry."
|
|
2534
|
-
);
|
|
2535
|
-
}
|
|
2536
|
-
this.apis.clear();
|
|
2537
|
-
this.endpoints.clear();
|
|
2538
|
-
this.routes.clear();
|
|
2539
|
-
this.apisByType.clear();
|
|
2540
|
-
this.apisByTag.clear();
|
|
2541
|
-
this.apisByStatus.clear();
|
|
2542
|
-
this.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2543
|
-
if (isProduction) {
|
|
2544
|
-
this.logger.warn("API registry forcefully cleared in production", { force: options.force });
|
|
2545
|
-
} else {
|
|
2546
|
-
this.logger.info("API registry cleared");
|
|
2547
|
-
}
|
|
2548
|
-
}
|
|
2549
|
-
/**
|
|
2550
|
-
* Get registry statistics
|
|
2551
|
-
*
|
|
2552
|
-
* @returns Registry statistics
|
|
2553
|
-
*/
|
|
2554
|
-
getStats() {
|
|
2555
|
-
const apis = Array.from(this.apis.values());
|
|
2556
|
-
const apisByType = {};
|
|
2557
|
-
for (const api of apis) {
|
|
2558
|
-
apisByType[api.type] = (apisByType[api.type] || 0) + 1;
|
|
2559
|
-
}
|
|
2560
|
-
const endpointsByApi = {};
|
|
2561
|
-
for (const api of apis) {
|
|
2562
|
-
endpointsByApi[api.id] = api.endpoints.length;
|
|
2563
|
-
}
|
|
2564
|
-
return {
|
|
2565
|
-
totalApis: this.apis.size,
|
|
2566
|
-
totalEndpoints: this.endpoints.size,
|
|
2567
|
-
totalRoutes: this.routes.size,
|
|
2568
|
-
apisByType,
|
|
2569
|
-
endpointsByApi
|
|
2570
|
-
};
|
|
2571
|
-
}
|
|
2572
|
-
/**
|
|
2573
|
-
* Update auxiliary indices when an API is registered
|
|
2574
|
-
*
|
|
2575
|
-
* @param api - API entry to index
|
|
2576
|
-
* @private
|
|
2577
|
-
* @internal
|
|
2578
|
-
*/
|
|
2579
|
-
updateIndices(api) {
|
|
2580
|
-
this.ensureIndexSet(this.apisByType, api.type).add(api.id);
|
|
2581
|
-
const status = api.metadata?.status || "active";
|
|
2582
|
-
this.ensureIndexSet(this.apisByStatus, status).add(api.id);
|
|
2583
|
-
const tags = api.metadata?.tags || [];
|
|
2584
|
-
for (const tag of tags) {
|
|
2585
|
-
this.ensureIndexSet(this.apisByTag, tag).add(api.id);
|
|
2586
|
-
}
|
|
2587
|
-
}
|
|
2588
|
-
/**
|
|
2589
|
-
* Remove API from auxiliary indices when unregistered
|
|
2590
|
-
*
|
|
2591
|
-
* @param api - API entry to remove from indices
|
|
2592
|
-
* @private
|
|
2593
|
-
* @internal
|
|
2594
|
-
*/
|
|
2595
|
-
removeFromIndices(api) {
|
|
2596
|
-
this.removeFromIndexSet(this.apisByType, api.type, api.id);
|
|
2597
|
-
const status = api.metadata?.status || "active";
|
|
2598
|
-
this.removeFromIndexSet(this.apisByStatus, status, api.id);
|
|
2599
|
-
const tags = api.metadata?.tags || [];
|
|
2600
|
-
for (const tag of tags) {
|
|
2601
|
-
this.removeFromIndexSet(this.apisByTag, tag, api.id);
|
|
2602
|
-
}
|
|
2603
|
-
}
|
|
2604
|
-
/**
|
|
2605
|
-
* Helper to ensure an index set exists and return it
|
|
2606
|
-
*
|
|
2607
|
-
* @param map - Index map
|
|
2608
|
-
* @param key - Index key
|
|
2609
|
-
* @returns The Set for this key (created if needed)
|
|
2610
|
-
* @private
|
|
2611
|
-
* @internal
|
|
2612
|
-
*/
|
|
2613
|
-
ensureIndexSet(map, key) {
|
|
2614
|
-
let set = map.get(key);
|
|
2615
|
-
if (!set) {
|
|
2616
|
-
set = /* @__PURE__ */ new Set();
|
|
2617
|
-
map.set(key, set);
|
|
2618
|
-
}
|
|
2619
|
-
return set;
|
|
2620
|
-
}
|
|
2621
|
-
/**
|
|
2622
|
-
* Helper to remove an ID from an index set and clean up empty sets
|
|
2623
|
-
*
|
|
2624
|
-
* @param map - Index map
|
|
2625
|
-
* @param key - Index key
|
|
2626
|
-
* @param id - API ID to remove
|
|
2627
|
-
* @private
|
|
2628
|
-
* @internal
|
|
2629
|
-
*/
|
|
2630
|
-
removeFromIndexSet(map, key, id) {
|
|
2631
|
-
const set = map.get(key);
|
|
2632
|
-
if (set) {
|
|
2633
|
-
set.delete(id);
|
|
2634
|
-
if (set.size === 0) {
|
|
2635
|
-
map.delete(key);
|
|
2636
|
-
}
|
|
2637
|
-
}
|
|
2638
|
-
}
|
|
2639
|
-
/**
|
|
2640
|
-
* Check if running in production environment
|
|
2641
|
-
*
|
|
2642
|
-
* @returns true if NODE_ENV is 'production'
|
|
2643
|
-
* @private
|
|
2644
|
-
* @internal
|
|
2645
|
-
*/
|
|
2646
|
-
isProductionEnvironment() {
|
|
2647
|
-
return getEnv("NODE_ENV") === "production";
|
|
2648
|
-
}
|
|
2649
|
-
};
|
|
2650
|
-
|
|
2651
|
-
// src/api-registry-plugin.ts
|
|
2652
|
-
function createApiRegistryPlugin(config = {}) {
|
|
2653
|
-
const {
|
|
2654
|
-
conflictResolution = "error",
|
|
2655
|
-
version = "1.0.0"
|
|
2656
|
-
} = config;
|
|
2657
|
-
return {
|
|
2658
|
-
name: "com.objectstack.core.api-registry",
|
|
2659
|
-
/**
|
|
2660
|
-
* Services init() registers on every path (ADR-0116, #4131) — lets the
|
|
2661
|
-
* kernel name this plugin when a consumer requires one before it inits.
|
|
2662
|
-
*/
|
|
2663
|
-
providesServices: ["api-registry"],
|
|
2664
|
-
type: "standard",
|
|
2665
|
-
version: "1.0.0",
|
|
2666
|
-
init: async (ctx) => {
|
|
2667
|
-
const registry = new ApiRegistry(
|
|
2668
|
-
ctx.logger,
|
|
2669
|
-
conflictResolution,
|
|
2670
|
-
version
|
|
2671
|
-
);
|
|
2672
|
-
ctx.registerService("api-registry", registry);
|
|
2673
|
-
ctx.logger.info("API Registry plugin initialized", {
|
|
2674
|
-
conflictResolution,
|
|
2675
|
-
version
|
|
2676
|
-
});
|
|
2677
|
-
}
|
|
2678
|
-
};
|
|
2679
|
-
}
|
|
2680
|
-
|
|
2681
2340
|
// src/qa/index.ts
|
|
2682
2341
|
var qa_exports = {};
|
|
2683
2342
|
__export(qa_exports, {
|
|
@@ -5656,10 +5315,11 @@ var PluginHealthMonitor = class {
|
|
|
5656
5315
|
const checks = [];
|
|
5657
5316
|
try {
|
|
5658
5317
|
if (config.checkMethod && typeof plugin[config.checkMethod] === "function") {
|
|
5659
|
-
const checkResult = await
|
|
5318
|
+
const checkResult = await this.raceCheckTimeout(
|
|
5660
5319
|
plugin[config.checkMethod](),
|
|
5661
|
-
|
|
5662
|
-
|
|
5320
|
+
config.timeout,
|
|
5321
|
+
`Health check timeout after ${config.timeout}ms`
|
|
5322
|
+
);
|
|
5663
5323
|
if (checkResult === false || checkResult && checkResult.status === "unhealthy") {
|
|
5664
5324
|
status = "unhealthy";
|
|
5665
5325
|
message = checkResult?.message || "Custom health check failed";
|
|
@@ -5815,12 +5475,39 @@ var PluginHealthMonitor = class {
|
|
|
5815
5475
|
this.logger.info("Health monitor shutdown complete");
|
|
5816
5476
|
}
|
|
5817
5477
|
/**
|
|
5818
|
-
*
|
|
5478
|
+
* Race a plugin's custom health check against its timeout guard, and
|
|
5479
|
+
* reclaim the guard the moment the race settles (#4875).
|
|
5480
|
+
*
|
|
5481
|
+
* Same shape, same reasoning as `ObjectKernel.raceStartupTimeout()` (#4813,
|
|
5482
|
+
* PR #4874): the guard used to be armed and then abandoned — when the check
|
|
5483
|
+
* won the race, its `setTimeout` stayed ref'd in the event loop for the full
|
|
5484
|
+
* `config.timeout`. Health checks are *periodic*, so unlike the kernel's
|
|
5485
|
+
* one-shot startup guards the orphans here accumulate: one per plugin per
|
|
5486
|
+
* round, each pinning the loop for `config.timeout`.
|
|
5487
|
+
*
|
|
5488
|
+
* Clearing on settle rather than `unref()`-ing at arm time is deliberate.
|
|
5489
|
+
* An unref'd guard also stops pinning the loop, but it stops being a guard
|
|
5490
|
+
* as well: if the check never settles and nothing else keeps the loop alive,
|
|
5491
|
+
* Node exits before the timer can fire and the timeout is never reported.
|
|
5492
|
+
* The guard has to stay ref'd exactly as long as the race is undecided,
|
|
5493
|
+
* which is what `clearTimeout` in a `finally` expresses.
|
|
5494
|
+
*
|
|
5495
|
+
* `check` is widened to `T | PromiseLike<T>` because `checkMethod` is called
|
|
5496
|
+
* dynamically off the plugin and may be synchronous; such a check wins the
|
|
5497
|
+
* race immediately and the guard is reclaimed on the same turn.
|
|
5819
5498
|
*/
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5499
|
+
async raceCheckTimeout(check, ms, message) {
|
|
5500
|
+
let guard;
|
|
5501
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
5502
|
+
guard = setTimeout(() => {
|
|
5503
|
+
reject(new Error(message));
|
|
5504
|
+
}, ms);
|
|
5823
5505
|
});
|
|
5506
|
+
try {
|
|
5507
|
+
return await Promise.race([check, timeoutPromise]);
|
|
5508
|
+
} finally {
|
|
5509
|
+
clearTimeout(guard);
|
|
5510
|
+
}
|
|
5824
5511
|
}
|
|
5825
5512
|
};
|
|
5826
5513
|
|
|
@@ -6012,11 +5699,11 @@ var HotReloadManager = class {
|
|
|
6012
5699
|
}
|
|
6013
5700
|
if (plugin.destroy) {
|
|
6014
5701
|
this.logger.debug("Destroying plugin", { plugin: pluginName });
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
5702
|
+
await this.raceShutdownTimeout(
|
|
5703
|
+
plugin.destroy(),
|
|
5704
|
+
config.shutdownTimeout,
|
|
5705
|
+
"Shutdown timeout"
|
|
5706
|
+
);
|
|
6020
5707
|
this.logger.debug("Plugin destroyed successfully", { plugin: pluginName });
|
|
6021
5708
|
}
|
|
6022
5709
|
this.logger.debug("Plugin module would be reloaded here", { plugin: pluginName });
|
|
@@ -6043,6 +5730,41 @@ var HotReloadManager = class {
|
|
|
6043
5730
|
return false;
|
|
6044
5731
|
}
|
|
6045
5732
|
}
|
|
5733
|
+
/**
|
|
5734
|
+
* Race a plugin's `destroy()` against its shutdown-timeout guard, and
|
|
5735
|
+
* reclaim the guard the moment the race settles (#4952).
|
|
5736
|
+
*
|
|
5737
|
+
* The guard used to be armed and then abandoned — byte-for-byte the leak
|
|
5738
|
+
* #4813 fixed in the kernel's startup guards (PR #4874) and #4875 fixed in
|
|
5739
|
+
* the periodic health checks (PR #4950): when `destroy()` won the race, its
|
|
5740
|
+
* `setTimeout` stayed ref'd in the event loop for the full
|
|
5741
|
+
* `shutdownTimeout`, so a hot reload that finished in milliseconds still
|
|
5742
|
+
* pinned the loop for the whole budget — once per reload, per plugin.
|
|
5743
|
+
*
|
|
5744
|
+
* Clearing on settle rather than `unref()`-ing at arm time is deliberate.
|
|
5745
|
+
* An unref'd guard also stops pinning the loop, but it stops being a guard
|
|
5746
|
+
* as well: if `destroy()` never settles and nothing else keeps the loop
|
|
5747
|
+
* alive, Node exits before the timer can fire and the timeout is never
|
|
5748
|
+
* reported. The guard has to stay ref'd exactly as long as the race is
|
|
5749
|
+
* undecided, which is what `clearTimeout` in a `finally` expresses.
|
|
5750
|
+
*
|
|
5751
|
+
* `shutdown` is widened to `T | PromiseLike<T>` because the Plugin contract
|
|
5752
|
+
* permits a synchronous `destroy()` (`Promise<void> | void`); such a hook
|
|
5753
|
+
* wins the race immediately and the guard is reclaimed on the same turn.
|
|
5754
|
+
*/
|
|
5755
|
+
async raceShutdownTimeout(shutdown, timeout, message) {
|
|
5756
|
+
let guard;
|
|
5757
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
5758
|
+
guard = setTimeout(() => {
|
|
5759
|
+
reject(new Error(message));
|
|
5760
|
+
}, timeout);
|
|
5761
|
+
});
|
|
5762
|
+
try {
|
|
5763
|
+
return await Promise.race([shutdown, timeoutPromise]);
|
|
5764
|
+
} finally {
|
|
5765
|
+
clearTimeout(guard);
|
|
5766
|
+
}
|
|
5767
|
+
}
|
|
6046
5768
|
/**
|
|
6047
5769
|
* Schedule a reload with debouncing
|
|
6048
5770
|
*/
|
|
@@ -6501,7 +6223,6 @@ export {
|
|
|
6501
6223
|
ANONYMOUS_DENY_MESSAGE,
|
|
6502
6224
|
ANONYMOUS_DENY_STATUS,
|
|
6503
6225
|
API_KEY_PREFIX,
|
|
6504
|
-
ApiRegistry,
|
|
6505
6226
|
CORE_FALLBACK_FACTORIES,
|
|
6506
6227
|
DependencyResolver,
|
|
6507
6228
|
HotReloadManager,
|
|
@@ -6537,7 +6258,6 @@ export {
|
|
|
6537
6258
|
calendarPartsInTz,
|
|
6538
6259
|
calendarPartsInTzOrUtc,
|
|
6539
6260
|
counterSignPayload,
|
|
6540
|
-
createApiRegistryPlugin,
|
|
6541
6261
|
createLogger,
|
|
6542
6262
|
createMemoryCache,
|
|
6543
6263
|
createMemoryI18n,
|