@zudojs/runtime 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -1
- package/dist/lifecycle/lifecycle.core.js +2 -3
- package/dist/runtime/runtime.core.d.ts +10 -0
- package/dist/runtime/runtime.core.js +57 -10
- package/dist/runtimeError/runtimeError.base.d.ts +28 -6
- package/dist/runtimeError/runtimeError.base.js +49 -17
- package/dist/runtimeEvents/runtimeEvents.type.d.ts +6 -4
- package/dist/runtimeOptions/runtimeOptions.type.d.ts +16 -0
- package/dist/runtimeOptions/runtimeOptions.type.js +1 -0
- package/dist/runtimeState/runtimeState.core.d.ts +10 -2
- package/dist/runtimeState/runtimeState.core.js +16 -5
- package/dist/shutdown/index.d.ts +1 -0
- package/dist/shutdown/index.js +1 -0
- package/dist/shutdown/shutdown.container.d.ts +22 -0
- package/dist/shutdown/shutdown.container.js +37 -0
- package/dist/signalHandler/signalHandler.core.js +4 -1
- package/dist/startup/startup.core.d.ts +4 -1
- package/dist/startup/startup.core.js +12 -6
- package/dist/testRuntime/testRuntime.core.d.ts +3 -0
- package/dist/testRuntime/testRuntime.core.js +5 -0
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -44,6 +44,86 @@ await runtime.stop();
|
|
|
44
44
|
|
|
45
45
|
Modules start in dependency order and stop in reverse.
|
|
46
46
|
|
|
47
|
+
## Lifecycle states
|
|
48
|
+
|
|
49
|
+
`start()` walks the state machine in order:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
created -> initializing -> initialized -> starting -> running -> stopping -> stopped
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Every module's `onInitialize` runs while `runtime.state` is
|
|
56
|
+
`initializing`; once all have initialized the runtime passes through
|
|
57
|
+
`initialized` to `starting`, and every `onReady` runs under `starting`.
|
|
58
|
+
`start()` resolves in `running`. A failure in any phase ends in `failed`,
|
|
59
|
+
which is still stoppable. `created` may go straight to `stopped` (a
|
|
60
|
+
`stop()` before `start()`). Only `stopped` is terminal:
|
|
61
|
+
`isTerminalState("failed")` is `false`, because `stop()` moves a failed
|
|
62
|
+
runtime on to `stopped`.
|
|
63
|
+
|
|
64
|
+
## Startup failures
|
|
65
|
+
|
|
66
|
+
If any module throws, the runtime rolls back (modules that already started
|
|
67
|
+
are stopped and destroyed in reverse) and the state becomes `failed`.
|
|
68
|
+
`start()` then rejects with a `RuntimeStartError` that wraps the error your
|
|
69
|
+
module threw rather than re-throwing it: the original is `error.cause`,
|
|
70
|
+
`error.phase` is `"initialize"` (an `onInitialize` threw) or `"start"` (an
|
|
71
|
+
`onReady` threw), and `error.failedModuleId` names the module.
|
|
72
|
+
|
|
73
|
+
Two subclasses of `RuntimeStartError` narrow it down:
|
|
74
|
+
|
|
75
|
+
- `RuntimeInitializationError` — the failure was in the initialize phase
|
|
76
|
+
(an `onInitialize` threw, or the configuration manager failed to load).
|
|
77
|
+
- `RuntimeRollbackError` — startup failed AND the rollback that followed
|
|
78
|
+
failed, so a module may still hold resources. `phase`,
|
|
79
|
+
`failedModuleId` and `cause` still describe the startup failure;
|
|
80
|
+
`originalError` is the error `start()` would otherwise have thrown, and
|
|
81
|
+
`rollbackError` is what failed during rollback (an `AggregateError`
|
|
82
|
+
when several modules failed). Call `stop()` to retry the release.
|
|
83
|
+
|
|
84
|
+
A shutdown triggered by `SIGTERM`, `SIGINT` or a fatal error has no caller
|
|
85
|
+
to reject; if it fails, the runtime logs a `RuntimeSignalError` (with the
|
|
86
|
+
failure as `cause`) as the `error` field of its "Shutdown handler
|
|
87
|
+
failed." entry.
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import { RuntimeStartError } from "@zudojs/runtime";
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await runtime.start();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof RuntimeStartError) {
|
|
96
|
+
const original = error.cause; // what the module threw
|
|
97
|
+
logger.error(`Module ${error.failedModuleId} failed in ${error.phase}`, {
|
|
98
|
+
original,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
await runtime.stop(); // release anything rollback did not reach
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A startup that outlives `startupTimeout` rejects with `RuntimeTimeoutError`,
|
|
106
|
+
which has no cause.
|
|
107
|
+
|
|
108
|
+
## Container ownership
|
|
109
|
+
|
|
110
|
+
The runtime does not create the container; you pass it in, so by default
|
|
111
|
+
you own it and `stop()` leaves it alone. Dispose it yourself after
|
|
112
|
+
`stop()`:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
await runtime.stop();
|
|
116
|
+
await container.dispose();
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
or hand ownership to the runtime with `disposeContainerOnStop: true`, and
|
|
120
|
+
`stop()` disposes it after every module has shut down and been destroyed.
|
|
121
|
+
A disposal failure does not fail `stop()`; it is logged and recorded in
|
|
122
|
+
`runtime.status.shutdownFailures` under the id `"(container)"`
|
|
123
|
+
(exported as `CONTAINER_SHUTDOWN_ID`).
|
|
124
|
+
`createTestRuntime` (from `@zudojs/runtime/testing`) creates its own
|
|
125
|
+
container and sets `disposeContainerOnStop: true`.
|
|
126
|
+
|
|
47
127
|
## Readiness and health
|
|
48
128
|
|
|
49
129
|
Readiness checks are registered on the runtime and re-evaluated on demand.
|
|
@@ -104,7 +184,7 @@ Subscribe with `bus.on(type, handler)`; the payload types are in
|
|
|
104
184
|
|
|
105
185
|
| Event | Payload |
|
|
106
186
|
| --- | --- |
|
|
107
|
-
| `runtime.initializing`, `runtime.running`, `runtime.stopping`, `runtime.stopped` | `RuntimeEventPayload` |
|
|
187
|
+
| `runtime.initializing`, `runtime.initialized`, `runtime.starting`, `runtime.running`, `runtime.stopping`, `runtime.stopped` | `RuntimeEventPayload` |
|
|
108
188
|
| `runtime.failed` | `RuntimeFailureEventPayload` |
|
|
109
189
|
| `runtime.module.initializing` / `initialized` / `starting` / `started` / `stopping` / `stopped` / `failed` | `RuntimeModuleEventPayload` |
|
|
110
190
|
| `runtime.shutdown.drain`, `runtime.shutdown.complete` | `RuntimeEventPayload` |
|
|
@@ -142,6 +222,7 @@ createRuntime(dependencies, {
|
|
|
142
222
|
trackHealth: true,
|
|
143
223
|
readinessCheckTimeout: 5_000, // 0 removes the bound
|
|
144
224
|
parallelInitialization: false, // initialize each depth group at once
|
|
225
|
+
disposeContainerOnStop: false, // true: stop() disposes the container
|
|
145
226
|
metadata: { region: "eu-west-1" },
|
|
146
227
|
});
|
|
147
228
|
```
|
|
@@ -2,7 +2,7 @@ import { createConfigurationManager } from "@zudojs/core";
|
|
|
2
2
|
import { createModuleEventPayload } from "../runtimeEvents/runtimeEvents.core.js";
|
|
3
3
|
import { resolveDependencies } from "../dependencyGraph/index.js";
|
|
4
4
|
import { LifecycleCancellation } from "./lifecycle.cancellation.js";
|
|
5
|
-
import { RuntimeDependencyError, RuntimeStartError, RuntimeStateError, } from "../runtimeError/index.js";
|
|
5
|
+
import { RuntimeDependencyError, RuntimeInitializationError, RuntimeStartError, RuntimeStateError, } from "../runtimeError/index.js";
|
|
6
6
|
/**
|
|
7
7
|
* Manages the lifecycle of runtime modules.
|
|
8
8
|
*/
|
|
@@ -75,8 +75,7 @@ export class LifecycleManager {
|
|
|
75
75
|
await this.configuration.initialize();
|
|
76
76
|
}
|
|
77
77
|
catch (error) {
|
|
78
|
-
throw new
|
|
79
|
-
phase: "initialize",
|
|
78
|
+
throw new RuntimeInitializationError("Configuration failed to load.", {
|
|
80
79
|
...(error instanceof Error && { cause: error }),
|
|
81
80
|
});
|
|
82
81
|
}
|
|
@@ -137,6 +137,16 @@ export declare class DefaultRuntime implements Runtime {
|
|
|
137
137
|
* Performs runtime startup.
|
|
138
138
|
*/
|
|
139
139
|
private performStart;
|
|
140
|
+
/**
|
|
141
|
+
* Walks `initializing` -> `initialized` -> `starting` once every module
|
|
142
|
+
* has initialized, before the first `onReady` hook runs.
|
|
143
|
+
*/
|
|
144
|
+
private enterStartingPhase;
|
|
145
|
+
/**
|
|
146
|
+
* Disposes the container when the runtime owns it
|
|
147
|
+
* (`disposeContainerOnStop`). Returns the failure to record, if any.
|
|
148
|
+
*/
|
|
149
|
+
private releaseContainer;
|
|
140
150
|
/**
|
|
141
151
|
* Performs runtime shutdown.
|
|
142
152
|
*/
|
|
@@ -4,12 +4,13 @@ import { createRuntimeContext, withRuntimeContextState, } from "../runtimeContex
|
|
|
4
4
|
import { LifecycleManager } from "../lifecycle/index.js";
|
|
5
5
|
import { executeStartup, rollbackStartup } from "../startup/index.js";
|
|
6
6
|
import { executeShutdown } from "../shutdown/index.js";
|
|
7
|
+
import { disposeRuntimeContainer } from "../shutdown/shutdown.container.js";
|
|
7
8
|
import { SignalHandler } from "../signalHandler/index.js";
|
|
8
9
|
import { ReadinessTracker } from "../readiness/index.js";
|
|
9
10
|
import { computeRuntimeHealth } from "../health/index.js";
|
|
10
11
|
import { createRuntimeEventPayload, createFailureEventPayload, createHealthEventPayload, createReadinessEventPayload, publishRuntimeEvent, } from "../runtimeEvents/index.js";
|
|
11
12
|
import { createEvent } from "@zudojs/events";
|
|
12
|
-
import { RuntimeStateError, toRuntimeError } from "../runtimeError/index.js";
|
|
13
|
+
import { RuntimeRollbackError, RuntimeStateError, toRuntimeError, } from "../runtimeError/index.js";
|
|
13
14
|
/**
|
|
14
15
|
* Default runtime implementation.
|
|
15
16
|
*/
|
|
@@ -224,6 +225,10 @@ export class DefaultRuntime {
|
|
|
224
225
|
this._state = "stopped";
|
|
225
226
|
this._stoppedAt = new Date();
|
|
226
227
|
this.signalHandler.unregister();
|
|
228
|
+
const containerFailure = await this.releaseContainer();
|
|
229
|
+
if (containerFailure !== undefined) {
|
|
230
|
+
this._shutdownFailures = [containerFailure];
|
|
231
|
+
}
|
|
227
232
|
return;
|
|
228
233
|
}
|
|
229
234
|
if (!canStop(this._state)) {
|
|
@@ -250,7 +255,7 @@ export class DefaultRuntime {
|
|
|
250
255
|
// while modules are still coming up must be handled, not ignored.
|
|
251
256
|
this.signalHandler.register(() => this.handleShutdownSignal());
|
|
252
257
|
try {
|
|
253
|
-
await executeStartup(this.lifecycle, this.options.runtimeId, this._contextBase.eventBus, this.logger, this.options.emitEvents, this.options.startupTimeout);
|
|
258
|
+
await executeStartup(this.lifecycle, this.options.runtimeId, this._contextBase.eventBus, this.logger, this.options.emitEvents, this.options.startupTimeout, () => this.enterStartingPhase());
|
|
254
259
|
this.transitionTo("running");
|
|
255
260
|
this._startedAt = new Date();
|
|
256
261
|
// Evaluate any checks registered before startup instead of
|
|
@@ -285,27 +290,65 @@ export class DefaultRuntime {
|
|
|
285
290
|
if (this.options.emitEvents) {
|
|
286
291
|
this.emitEvent("runtime.failed", createFailureEventPayload(this.options.runtimeId, "failed", runtimeError, "startup"));
|
|
287
292
|
}
|
|
293
|
+
let rollbackError;
|
|
288
294
|
try {
|
|
289
295
|
const rollbackFailures = await rollbackStartup(this.lifecycle, this.logger);
|
|
290
296
|
if (rollbackFailures.length > 0) {
|
|
291
297
|
this.logger.error("Rollback completed with failures.", {
|
|
292
298
|
failedModules: rollbackFailures.map((failure) => failure.moduleId),
|
|
293
299
|
});
|
|
300
|
+
rollbackError =
|
|
301
|
+
rollbackFailures.length === 1
|
|
302
|
+
? rollbackFailures[0].error
|
|
303
|
+
: new AggregateError(rollbackFailures.map((failure) => failure.error), `${rollbackFailures.length} modules failed to roll back: ` +
|
|
304
|
+
rollbackFailures.map((f) => f.moduleId).join(", ") +
|
|
305
|
+
".");
|
|
294
306
|
}
|
|
295
307
|
}
|
|
296
|
-
catch (
|
|
308
|
+
catch (caught) {
|
|
309
|
+
rollbackError =
|
|
310
|
+
caught instanceof Error ? caught : new Error(String(caught));
|
|
297
311
|
this.logger.error("Rollback failed.", {
|
|
298
|
-
errorMessage: rollbackError
|
|
299
|
-
? rollbackError.message
|
|
300
|
-
: String(rollbackError),
|
|
312
|
+
errorMessage: rollbackError.message,
|
|
301
313
|
});
|
|
302
314
|
}
|
|
315
|
+
// A rollback that failed leaves resources behind; say so rather than
|
|
316
|
+
// reporting only the startup failure. The startup failure stays
|
|
317
|
+
// reachable as `originalError`, and `cause` is unchanged.
|
|
318
|
+
const thrown = rollbackError === undefined
|
|
319
|
+
? runtimeError
|
|
320
|
+
: new RuntimeRollbackError(runtimeError, rollbackError);
|
|
321
|
+
this._error = thrown;
|
|
303
322
|
this.transitionTo("failed");
|
|
304
323
|
// A rolled-back runtime owns nothing, so it must not keep owning the
|
|
305
324
|
// process's signals and fatal-error handlers either.
|
|
306
325
|
this.signalHandler.unregister();
|
|
307
|
-
throw
|
|
326
|
+
throw thrown;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Walks `initializing` -> `initialized` -> `starting` once every module
|
|
331
|
+
* has initialized, before the first `onReady` hook runs.
|
|
332
|
+
*/
|
|
333
|
+
enterStartingPhase() {
|
|
334
|
+
this.transitionTo("initialized");
|
|
335
|
+
if (this.options.emitEvents) {
|
|
336
|
+
this.emitEvent("runtime.initialized");
|
|
337
|
+
}
|
|
338
|
+
this.transitionTo("starting");
|
|
339
|
+
if (this.options.emitEvents) {
|
|
340
|
+
this.emitEvent("runtime.starting");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Disposes the container when the runtime owns it
|
|
345
|
+
* (`disposeContainerOnStop`). Returns the failure to record, if any.
|
|
346
|
+
*/
|
|
347
|
+
async releaseContainer() {
|
|
348
|
+
if (!this.options.disposeContainerOnStop) {
|
|
349
|
+
return undefined;
|
|
308
350
|
}
|
|
351
|
+
return disposeRuntimeContainer(this._contextBase.container, this.logger);
|
|
309
352
|
}
|
|
310
353
|
/**
|
|
311
354
|
* Performs runtime shutdown.
|
|
@@ -318,18 +361,22 @@ export class DefaultRuntime {
|
|
|
318
361
|
}
|
|
319
362
|
try {
|
|
320
363
|
const result = await executeShutdown(this.lifecycle, this.options.runtimeId, this._contextBase.eventBus, this.logger, this.options.shutdownTimeout, this.options.emitEvents);
|
|
321
|
-
|
|
364
|
+
const containerFailure = await this.releaseContainer();
|
|
365
|
+
this._shutdownFailures =
|
|
366
|
+
containerFailure === undefined
|
|
367
|
+
? result.failures
|
|
368
|
+
: [...result.failures, containerFailure];
|
|
322
369
|
this.transitionTo("stopped");
|
|
323
370
|
this._stoppedAt = new Date();
|
|
324
371
|
if (this.options.emitEvents) {
|
|
325
372
|
this.emitEvent("runtime.stopped");
|
|
326
373
|
}
|
|
327
|
-
if (
|
|
374
|
+
if (this._shutdownFailures.length > 0) {
|
|
328
375
|
// A teardown that dropped modules on the floor must not read as
|
|
329
376
|
// a clean stop; `status.shutdownFailures` records what failed.
|
|
330
377
|
this.logger.warn("Runtime stopped with module failures.", {
|
|
331
378
|
runtimeId: this.options.runtimeId,
|
|
332
|
-
failedModules:
|
|
379
|
+
failedModules: this._shutdownFailures.map((failure) => failure.moduleId),
|
|
333
380
|
});
|
|
334
381
|
}
|
|
335
382
|
else {
|
|
@@ -28,11 +28,18 @@ export declare class RuntimeStopError extends RuntimeError {
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
|
-
* Error thrown when
|
|
31
|
+
* Error thrown when startup fails during initialization: a module's
|
|
32
|
+
* `onInitialize` threw, or the configuration manager failed to load.
|
|
33
|
+
*
|
|
34
|
+
* A {@link RuntimeStartError} with `phase: "initialize"`, so handlers
|
|
35
|
+
* written against `RuntimeStartError` keep matching. `cause` is the
|
|
36
|
+
* original error and `failedModuleId` names the module (absent for a
|
|
37
|
+
* configuration failure).
|
|
32
38
|
*/
|
|
33
|
-
export declare class RuntimeInitializationError extends
|
|
39
|
+
export declare class RuntimeInitializationError extends RuntimeStartError {
|
|
34
40
|
constructor(message: string, options?: {
|
|
35
41
|
readonly cause?: Error;
|
|
42
|
+
readonly failedModuleId?: string;
|
|
36
43
|
});
|
|
37
44
|
}
|
|
38
45
|
/**
|
|
@@ -44,9 +51,17 @@ export declare class RuntimeTimeoutError extends RuntimeError {
|
|
|
44
51
|
constructor(operation: string, timeoutMs: number);
|
|
45
52
|
}
|
|
46
53
|
/**
|
|
47
|
-
* Error thrown when
|
|
54
|
+
* Error thrown when startup failed and the rollback that followed failed
|
|
55
|
+
* too, so some module may still hold resources.
|
|
56
|
+
*
|
|
57
|
+
* A {@link RuntimeStartError} describing the ORIGINAL failure: `phase`,
|
|
58
|
+
* `failedModuleId` and `cause` (what the module threw) match the error
|
|
59
|
+
* `start()` would otherwise have rejected with, which is kept whole as
|
|
60
|
+
* `originalError`. `rollbackError` is what failed during rollback (an
|
|
61
|
+
* `AggregateError` when several modules failed). Call `stop()` to retry
|
|
62
|
+
* releasing what rollback did not reach.
|
|
48
63
|
*/
|
|
49
|
-
export declare class RuntimeRollbackError extends
|
|
64
|
+
export declare class RuntimeRollbackError extends RuntimeStartError {
|
|
50
65
|
readonly originalError: Error;
|
|
51
66
|
readonly rollbackError: Error;
|
|
52
67
|
constructor(originalError: Error, rollbackError: Error);
|
|
@@ -67,11 +82,18 @@ export declare class RuntimeDependencyError extends RuntimeError {
|
|
|
67
82
|
constructor(moduleId: string, dependencyId: string);
|
|
68
83
|
}
|
|
69
84
|
/**
|
|
70
|
-
*
|
|
85
|
+
* Reports a shutdown triggered by a signal (`SIGTERM`, `SIGINT`, or
|
|
86
|
+
* `"fatal"` for an uncaught exception or unhandled rejection) that failed.
|
|
87
|
+
*
|
|
88
|
+
* Signal listeners have no caller to throw to, so the signal handler logs
|
|
89
|
+
* this error (as the `error` field of its "Shutdown handler failed." log
|
|
90
|
+
* entry) with the shutdown failure as `cause`.
|
|
71
91
|
*/
|
|
72
92
|
export declare class RuntimeSignalError extends RuntimeError {
|
|
73
93
|
readonly signal: string;
|
|
74
|
-
constructor(signal: string
|
|
94
|
+
constructor(signal: string, options?: {
|
|
95
|
+
readonly cause?: unknown;
|
|
96
|
+
});
|
|
75
97
|
}
|
|
76
98
|
/**
|
|
77
99
|
* Converts an unknown error to a RuntimeError.
|
|
@@ -41,15 +41,22 @@ export class RuntimeStopError extends RuntimeError {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
|
-
* Error thrown when
|
|
44
|
+
* Error thrown when startup fails during initialization: a module's
|
|
45
|
+
* `onInitialize` threw, or the configuration manager failed to load.
|
|
46
|
+
*
|
|
47
|
+
* A {@link RuntimeStartError} with `phase: "initialize"`, so handlers
|
|
48
|
+
* written against `RuntimeStartError` keep matching. `cause` is the
|
|
49
|
+
* original error and `failedModuleId` names the module (absent for a
|
|
50
|
+
* configuration failure).
|
|
45
51
|
*/
|
|
46
|
-
export class RuntimeInitializationError extends
|
|
52
|
+
export class RuntimeInitializationError extends RuntimeStartError {
|
|
47
53
|
constructor(message, options = {}) {
|
|
48
54
|
super(message, {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
phase: "initialize",
|
|
56
|
+
...(options.cause !== undefined && { cause: options.cause }),
|
|
57
|
+
...(options.failedModuleId !== undefined && {
|
|
58
|
+
failedModuleId: options.failedModuleId,
|
|
59
|
+
}),
|
|
53
60
|
});
|
|
54
61
|
}
|
|
55
62
|
}
|
|
@@ -71,18 +78,30 @@ export class RuntimeTimeoutError extends RuntimeError {
|
|
|
71
78
|
}
|
|
72
79
|
}
|
|
73
80
|
/**
|
|
74
|
-
* Error thrown when
|
|
81
|
+
* Error thrown when startup failed and the rollback that followed failed
|
|
82
|
+
* too, so some module may still hold resources.
|
|
83
|
+
*
|
|
84
|
+
* A {@link RuntimeStartError} describing the ORIGINAL failure: `phase`,
|
|
85
|
+
* `failedModuleId` and `cause` (what the module threw) match the error
|
|
86
|
+
* `start()` would otherwise have rejected with, which is kept whole as
|
|
87
|
+
* `originalError`. `rollbackError` is what failed during rollback (an
|
|
88
|
+
* `AggregateError` when several modules failed). Call `stop()` to retry
|
|
89
|
+
* releasing what rollback did not reach.
|
|
75
90
|
*/
|
|
76
|
-
export class RuntimeRollbackError extends
|
|
91
|
+
export class RuntimeRollbackError extends RuntimeStartError {
|
|
77
92
|
originalError;
|
|
78
93
|
rollbackError;
|
|
79
94
|
constructor(originalError, rollbackError) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
const start = originalError instanceof RuntimeStartError ? originalError : undefined;
|
|
96
|
+
const cause = start !== undefined && start.cause instanceof Error
|
|
97
|
+
? start.cause
|
|
98
|
+
: originalError;
|
|
99
|
+
super(`${originalError.message} Rollback also failed: ${rollbackError.message}`, {
|
|
100
|
+
phase: start?.phase ?? "startup",
|
|
101
|
+
cause,
|
|
102
|
+
...(start?.failedModuleId !== undefined && {
|
|
103
|
+
failedModuleId: start.failedModuleId,
|
|
104
|
+
}),
|
|
86
105
|
});
|
|
87
106
|
this.originalError = originalError;
|
|
88
107
|
this.rollbackError = rollbackError;
|
|
@@ -120,12 +139,25 @@ export class RuntimeDependencyError extends RuntimeError {
|
|
|
120
139
|
}
|
|
121
140
|
}
|
|
122
141
|
/**
|
|
123
|
-
*
|
|
142
|
+
* Reports a shutdown triggered by a signal (`SIGTERM`, `SIGINT`, or
|
|
143
|
+
* `"fatal"` for an uncaught exception or unhandled rejection) that failed.
|
|
144
|
+
*
|
|
145
|
+
* Signal listeners have no caller to throw to, so the signal handler logs
|
|
146
|
+
* this error (as the `error` field of its "Shutdown handler failed." log
|
|
147
|
+
* entry) with the shutdown failure as `cause`.
|
|
124
148
|
*/
|
|
125
149
|
export class RuntimeSignalError extends RuntimeError {
|
|
126
150
|
signal;
|
|
127
|
-
constructor(signal) {
|
|
128
|
-
|
|
151
|
+
constructor(signal, options = {}) {
|
|
152
|
+
const detail = options.cause === undefined
|
|
153
|
+
? undefined
|
|
154
|
+
: options.cause instanceof Error
|
|
155
|
+
? options.cause.message
|
|
156
|
+
: String(options.cause);
|
|
157
|
+
super(detail === undefined
|
|
158
|
+
? `Runtime received unexpected signal "${signal}".`
|
|
159
|
+
: `Shutdown triggered by "${signal}" failed: ${detail}`, {
|
|
160
|
+
...(options.cause instanceof Error && { cause: options.cause }),
|
|
129
161
|
metadata: {
|
|
130
162
|
signal,
|
|
131
163
|
},
|
|
@@ -64,13 +64,15 @@ export type RuntimeModuleEventType = "runtime.module.initializing" | "runtime.mo
|
|
|
64
64
|
/**
|
|
65
65
|
* Maps event types to their payload types.
|
|
66
66
|
*
|
|
67
|
-
* Every entry here is emitted by the runtime.
|
|
68
|
-
*
|
|
69
|
-
* `runtime.starting`
|
|
70
|
-
*
|
|
67
|
+
* Every entry here is emitted by the runtime. `runtime.created` is not:
|
|
68
|
+
* nothing is subscribed before construction. `runtime.initialized` and
|
|
69
|
+
* `runtime.starting` are published between module initialization and the
|
|
70
|
+
* first `onReady` hook.
|
|
71
71
|
*/
|
|
72
72
|
export interface RuntimeEventMap {
|
|
73
73
|
"runtime.initializing": RuntimeEventPayload;
|
|
74
|
+
"runtime.initialized": RuntimeEventPayload;
|
|
75
|
+
"runtime.starting": RuntimeEventPayload;
|
|
74
76
|
"runtime.running": RuntimeEventPayload;
|
|
75
77
|
"runtime.stopping": RuntimeEventPayload;
|
|
76
78
|
"runtime.stopped": RuntimeEventPayload;
|
|
@@ -98,6 +98,20 @@ export interface RuntimeOptions {
|
|
|
98
98
|
* @default false
|
|
99
99
|
*/
|
|
100
100
|
readonly parallelInitialization?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Whether `stop()` disposes the container passed in `dependencies`.
|
|
103
|
+
*
|
|
104
|
+
* The caller that created the container owns it, so by default the
|
|
105
|
+
* runtime leaves it alone: dispose it yourself after `stop()`, or set
|
|
106
|
+
* this to hand ownership to the runtime. It is disposed after every
|
|
107
|
+
* module has shut down and been destroyed (or on `stop()` of a runtime
|
|
108
|
+
* that never started). A disposal failure is recorded in
|
|
109
|
+
* `status.shutdownFailures` under `"(container)"` rather than failing
|
|
110
|
+
* the stop. `createTestRuntime` creates its own container and sets this.
|
|
111
|
+
*
|
|
112
|
+
* @default false
|
|
113
|
+
*/
|
|
114
|
+
readonly disposeContainerOnStop?: boolean;
|
|
101
115
|
/**
|
|
102
116
|
* Additional runtime metadata.
|
|
103
117
|
*/
|
|
@@ -123,6 +137,7 @@ export interface ResolvedRuntimeOptions {
|
|
|
123
137
|
readonly trackHealth: boolean;
|
|
124
138
|
readonly readinessCheckTimeout: number;
|
|
125
139
|
readonly parallelInitialization: boolean;
|
|
140
|
+
readonly disposeContainerOnStop: boolean;
|
|
126
141
|
readonly metadata: Readonly<Record<string, unknown>>;
|
|
127
142
|
}
|
|
128
143
|
/**
|
|
@@ -141,6 +156,7 @@ export declare const DEFAULT_RUNTIME_OPTIONS: Readonly<{
|
|
|
141
156
|
readonly trackHealth: true;
|
|
142
157
|
readonly readinessCheckTimeout: 5000;
|
|
143
158
|
readonly parallelInitialization: false;
|
|
159
|
+
readonly disposeContainerOnStop: false;
|
|
144
160
|
readonly applicationVersion: "0.1.0";
|
|
145
161
|
readonly metadata: Readonly<{}>;
|
|
146
162
|
}>;
|
|
@@ -14,6 +14,7 @@ export const DEFAULT_RUNTIME_OPTIONS = Object.freeze({
|
|
|
14
14
|
trackHealth: true,
|
|
15
15
|
readinessCheckTimeout: 5_000,
|
|
16
16
|
parallelInitialization: false,
|
|
17
|
+
disposeContainerOnStop: false,
|
|
17
18
|
applicationVersion: "0.1.0",
|
|
18
19
|
// `metadata` is required on ResolvedRuntimeOptions, so it needs a
|
|
19
20
|
// default; without one the resolved options claimed a value the
|
|
@@ -4,7 +4,13 @@ import type { RuntimeState, RuntimeStatus } from "./runtimeState.type.js";
|
|
|
4
4
|
*/
|
|
5
5
|
export declare const RUNTIME_STATE_TRANSITIONS: Readonly<Record<RuntimeState, readonly RuntimeState[]>>;
|
|
6
6
|
/**
|
|
7
|
-
* Terminal runtime states.
|
|
7
|
+
* Terminal runtime states: no further transition is possible.
|
|
8
|
+
*
|
|
9
|
+
* Only `stopped`. `failed` is NOT terminal: `stop()` is allowed from it
|
|
10
|
+
* (`failed -> stopping -> stopped`) so an operator can release whatever
|
|
11
|
+
* startup rollback did not reach. It used to be listed here, so
|
|
12
|
+
* `isTerminalState("failed")` claimed a state was final while `stop()`
|
|
13
|
+
* still moved out of it.
|
|
8
14
|
*/
|
|
9
15
|
export declare const TERMINAL_STATES: readonly RuntimeState[];
|
|
10
16
|
/**
|
|
@@ -28,7 +34,9 @@ export declare function canTransition(from: RuntimeState, to: RuntimeState): boo
|
|
|
28
34
|
*/
|
|
29
35
|
export declare function assertTransition(from: RuntimeState, to: RuntimeState): void;
|
|
30
36
|
/**
|
|
31
|
-
* Returns whether the runtime is in a terminal state.
|
|
37
|
+
* Returns whether the runtime is in a terminal state (`stopped`).
|
|
38
|
+
*
|
|
39
|
+
* `failed` is not terminal: call `stop()` to release it.
|
|
32
40
|
*/
|
|
33
41
|
export declare function isTerminalState(state: RuntimeState): boolean;
|
|
34
42
|
/**
|
|
@@ -4,8 +4,12 @@ import { RuntimeStateError } from "../runtimeError/runtimeError.base.js";
|
|
|
4
4
|
*/
|
|
5
5
|
export const RUNTIME_STATE_TRANSITIONS = Object.freeze({
|
|
6
6
|
created: ["initializing", "stopped", "failed"],
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// Startup walks every state in order: modules initialize under
|
|
8
|
+
// "initializing", the runtime passes through "initialized", and modules'
|
|
9
|
+
// onReady hooks run under "starting". The shortcuts straight to
|
|
10
|
+
// "running" were never taken once the runtime entered these states.
|
|
11
|
+
initializing: ["initialized", "failed"],
|
|
12
|
+
initialized: ["starting", "failed"],
|
|
9
13
|
starting: ["running", "failed"],
|
|
10
14
|
running: ["stopping", "failed"],
|
|
11
15
|
stopping: ["stopped", "failed"],
|
|
@@ -17,11 +21,16 @@ export const RUNTIME_STATE_TRANSITIONS = Object.freeze({
|
|
|
17
21
|
failed: ["stopping", "stopped"],
|
|
18
22
|
});
|
|
19
23
|
/**
|
|
20
|
-
* Terminal runtime states.
|
|
24
|
+
* Terminal runtime states: no further transition is possible.
|
|
25
|
+
*
|
|
26
|
+
* Only `stopped`. `failed` is NOT terminal: `stop()` is allowed from it
|
|
27
|
+
* (`failed -> stopping -> stopped`) so an operator can release whatever
|
|
28
|
+
* startup rollback did not reach. It used to be listed here, so
|
|
29
|
+
* `isTerminalState("failed")` claimed a state was final while `stop()`
|
|
30
|
+
* still moved out of it.
|
|
21
31
|
*/
|
|
22
32
|
export const TERMINAL_STATES = Object.freeze([
|
|
23
33
|
"stopped",
|
|
24
|
-
"failed",
|
|
25
34
|
]);
|
|
26
35
|
/**
|
|
27
36
|
* Runtime states from which startup is allowed.
|
|
@@ -59,7 +68,9 @@ export function assertTransition(from, to) {
|
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
70
|
/**
|
|
62
|
-
* Returns whether the runtime is in a terminal state.
|
|
71
|
+
* Returns whether the runtime is in a terminal state (`stopped`).
|
|
72
|
+
*
|
|
73
|
+
* `failed` is not terminal: call `stop()` to release it.
|
|
63
74
|
*/
|
|
64
75
|
export function isTerminalState(state) {
|
|
65
76
|
return TERMINAL_STATES.includes(state);
|
package/dist/shutdown/index.d.ts
CHANGED
package/dist/shutdown/index.js
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Container } from "@zudojs/container";
|
|
2
|
+
import type { Logger } from "@zudojs/logger";
|
|
3
|
+
import type { LifecycleFailure } from "../lifecycle/lifecycle.type.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `moduleId` under which a container disposal failure is reported in
|
|
6
|
+
* `status.shutdownFailures`. Parenthesised so it cannot collide with a
|
|
7
|
+
* real module id.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CONTAINER_SHUTDOWN_ID = "(container)";
|
|
10
|
+
/**
|
|
11
|
+
* Disposes the runtime's container after its modules have shut down.
|
|
12
|
+
*
|
|
13
|
+
* Runs only when the runtime owns the container
|
|
14
|
+
* (`disposeContainerOnStop: true`, which `createTestRuntime` sets). A
|
|
15
|
+
* disposal failure does not fail the stop, matching module teardown
|
|
16
|
+
* failures: it is logged and returned so it lands in
|
|
17
|
+
* `status.shutdownFailures`.
|
|
18
|
+
*
|
|
19
|
+
* @returns The failure, or `undefined` when disposal succeeded.
|
|
20
|
+
*/
|
|
21
|
+
export declare function disposeRuntimeContainer(container: Container, logger: Logger): Promise<LifecycleFailure | undefined>;
|
|
22
|
+
//# sourceMappingURL=shutdown.container.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `moduleId` under which a container disposal failure is reported in
|
|
3
|
+
* `status.shutdownFailures`. Parenthesised so it cannot collide with a
|
|
4
|
+
* real module id.
|
|
5
|
+
*/
|
|
6
|
+
export const CONTAINER_SHUTDOWN_ID = "(container)";
|
|
7
|
+
/**
|
|
8
|
+
* Disposes the runtime's container after its modules have shut down.
|
|
9
|
+
*
|
|
10
|
+
* Runs only when the runtime owns the container
|
|
11
|
+
* (`disposeContainerOnStop: true`, which `createTestRuntime` sets). A
|
|
12
|
+
* disposal failure does not fail the stop, matching module teardown
|
|
13
|
+
* failures: it is logged and returned so it lands in
|
|
14
|
+
* `status.shutdownFailures`.
|
|
15
|
+
*
|
|
16
|
+
* @returns The failure, or `undefined` when disposal succeeded.
|
|
17
|
+
*/
|
|
18
|
+
export async function disposeRuntimeContainer(container, logger) {
|
|
19
|
+
const startedAt = Date.now();
|
|
20
|
+
try {
|
|
21
|
+
await container.dispose();
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const failure = {
|
|
26
|
+
moduleId: CONTAINER_SHUTDOWN_ID,
|
|
27
|
+
phase: "destroy",
|
|
28
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
29
|
+
durationMs: Date.now() - startedAt,
|
|
30
|
+
};
|
|
31
|
+
logger.error("Runtime container failed to dispose.", {
|
|
32
|
+
errorMessage: failure.error.message,
|
|
33
|
+
});
|
|
34
|
+
return failure;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=shutdown.container.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RuntimeSignalError } from "../runtimeError/runtimeError.base.js";
|
|
1
2
|
/** Default grace period for a fatal-error shutdown. */
|
|
2
3
|
const DEFAULT_FATAL_EXIT_TIMEOUT = 10_000;
|
|
3
4
|
/**
|
|
@@ -146,8 +147,10 @@ export class SignalHandler {
|
|
|
146
147
|
this.isShuttingDown = true;
|
|
147
148
|
if (this.shutdownHandler) {
|
|
148
149
|
return Promise.resolve(this.shutdownHandler()).catch((error) => {
|
|
150
|
+
const signalError = new RuntimeSignalError(source, { cause: error });
|
|
149
151
|
this.logger.error("Shutdown handler failed.", {
|
|
150
|
-
errorMessage:
|
|
152
|
+
errorMessage: signalError.message,
|
|
153
|
+
error: signalError,
|
|
151
154
|
});
|
|
152
155
|
});
|
|
153
156
|
}
|
|
@@ -4,8 +4,11 @@ import { LifecycleManager } from "../lifecycle/index.js";
|
|
|
4
4
|
import type { LifecycleFailure } from "../lifecycle/lifecycle.type.js";
|
|
5
5
|
/**
|
|
6
6
|
* Executes the startup sequence.
|
|
7
|
+
*
|
|
8
|
+
* @param onInitialized - Called once every module has initialized and
|
|
9
|
+
* before any `onReady` hook runs.
|
|
7
10
|
*/
|
|
8
|
-
export declare function executeStartup(lifecycle: LifecycleManager, runtimeId: string, eventBus: EventBus | undefined, logger: Logger, emitEvents: boolean, startupTimeout?: number): Promise<void>;
|
|
11
|
+
export declare function executeStartup(lifecycle: LifecycleManager, runtimeId: string, eventBus: EventBus | undefined, logger: Logger, emitEvents: boolean, startupTimeout?: number, onInitialized?: () => void): Promise<void>;
|
|
9
12
|
/**
|
|
10
13
|
* Rolls back a failed startup.
|
|
11
14
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createEvent } from "@zudojs/events";
|
|
2
2
|
import { createFailureEventPayload, publishRuntimeEvent, } from "../runtimeEvents/index.js";
|
|
3
3
|
import { LifecycleManager } from "../lifecycle/index.js";
|
|
4
|
-
import { RuntimeStartError, RuntimeTimeoutError, } from "../runtimeError/index.js";
|
|
4
|
+
import { RuntimeInitializationError, RuntimeStartError, RuntimeTimeoutError, } from "../runtimeError/index.js";
|
|
5
5
|
/** Largest delay a timer can represent. */
|
|
6
6
|
const MAX_TIMER_DELAY = 2_147_483_647;
|
|
7
7
|
/**
|
|
@@ -40,14 +40,17 @@ async function withStartupTimeout(operation, timeoutMs, onTimeout) {
|
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
42
|
* Executes the startup sequence.
|
|
43
|
+
*
|
|
44
|
+
* @param onInitialized - Called once every module has initialized and
|
|
45
|
+
* before any `onReady` hook runs.
|
|
43
46
|
*/
|
|
44
|
-
export async function executeStartup(lifecycle, runtimeId, eventBus, logger, emitEvents, startupTimeout = 0) {
|
|
45
|
-
return withStartupTimeout(runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents), startupTimeout, () => lifecycle.cancel());
|
|
47
|
+
export async function executeStartup(lifecycle, runtimeId, eventBus, logger, emitEvents, startupTimeout = 0, onInitialized) {
|
|
48
|
+
return withStartupTimeout(runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents, onInitialized), startupTimeout, () => lifecycle.cancel());
|
|
46
49
|
}
|
|
47
50
|
/**
|
|
48
51
|
* Runs the startup sequence.
|
|
49
52
|
*/
|
|
50
|
-
async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents) {
|
|
53
|
+
async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents, onInitialized) {
|
|
51
54
|
// Per-module `runtime.module.*` events are emitted by the lifecycle
|
|
52
55
|
// manager, which is the only layer that knows which module is running.
|
|
53
56
|
const initResult = await lifecycle.initialize();
|
|
@@ -62,8 +65,7 @@ async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents) {
|
|
|
62
65
|
payload: createFailureEventPayload(runtimeId, "initialization_failed", failure.error, "initialize", failure.moduleId),
|
|
63
66
|
}));
|
|
64
67
|
}
|
|
65
|
-
throw new
|
|
66
|
-
phase: "initialize",
|
|
68
|
+
throw new RuntimeInitializationError(`Module "${failure.moduleId}" failed during initialization.`, {
|
|
67
69
|
failedModuleId: failure.moduleId,
|
|
68
70
|
cause: failure.error,
|
|
69
71
|
});
|
|
@@ -72,6 +74,10 @@ async function runStartup(lifecycle, runtimeId, eventBus, logger, emitEvents) {
|
|
|
72
74
|
modules: initResult.succeeded,
|
|
73
75
|
durationMs: initResult.durationMs,
|
|
74
76
|
});
|
|
77
|
+
// Lets the runtime enter "initialized" and "starting" between the two
|
|
78
|
+
// phases, so onReady hooks observe "starting" rather than
|
|
79
|
+
// "initializing".
|
|
80
|
+
onInitialized?.();
|
|
75
81
|
const startResult = await lifecycle.start();
|
|
76
82
|
if (lifecycle.cancelled) {
|
|
77
83
|
return;
|
|
@@ -10,6 +10,9 @@ import type { RuntimeOptions } from "../runtimeOptions/runtimeOptions.type.js";
|
|
|
10
10
|
/**
|
|
11
11
|
* Creates a test runtime with mock infrastructure.
|
|
12
12
|
*
|
|
13
|
+
* The runtime owns the container it creates, so `stop()` disposes it
|
|
14
|
+
* (`disposeContainerOnStop: true`; pass `false` to keep it).
|
|
15
|
+
*
|
|
13
16
|
* @param modules - Optional modules to register.
|
|
14
17
|
* @param options - Optional runtime options overrides.
|
|
15
18
|
* @returns A runtime instance ready for testing.
|
|
@@ -11,6 +11,9 @@ import { DefaultRuntime } from "../runtime/runtime.core.js";
|
|
|
11
11
|
/**
|
|
12
12
|
* Creates a test runtime with mock infrastructure.
|
|
13
13
|
*
|
|
14
|
+
* The runtime owns the container it creates, so `stop()` disposes it
|
|
15
|
+
* (`disposeContainerOnStop: true`; pass `false` to keep it).
|
|
16
|
+
*
|
|
14
17
|
* @param modules - Optional modules to register.
|
|
15
18
|
* @param options - Optional runtime options overrides.
|
|
16
19
|
* @returns A runtime instance ready for testing.
|
|
@@ -38,6 +41,8 @@ export function createTestRuntime(modules = [], options = {}) {
|
|
|
38
41
|
shutdownTimeout: 5000,
|
|
39
42
|
startupTimeout: 10000,
|
|
40
43
|
emitEvents: false,
|
|
44
|
+
// The test runtime created this container, so it owns and releases it.
|
|
45
|
+
disposeContainerOnStop: true,
|
|
41
46
|
...options,
|
|
42
47
|
};
|
|
43
48
|
return new DefaultRuntime(dependencies, runtimeOptions);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Application lifecycle orchestrator with dependency ordering, rollback, signals, and readiness checks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,17 +22,17 @@
|
|
|
22
22
|
"!dist/.tsbuildinfo"
|
|
23
23
|
],
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@types/node": "^26.
|
|
25
|
+
"@types/node": "^26.6.2",
|
|
26
26
|
"typescript": "7.0.2",
|
|
27
|
-
"vitest": "^
|
|
27
|
+
"vitest": "^5.0.1"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@zudojs/constants": "1.1.
|
|
31
|
-
"@zudojs/container": "1.
|
|
32
|
-
"@zudojs/core": "1.2.
|
|
33
|
-
"@zudojs/errors": "1.
|
|
34
|
-
"@zudojs/events": "1.
|
|
35
|
-
"@zudojs/logger": "1.
|
|
30
|
+
"@zudojs/constants": "1.1.2",
|
|
31
|
+
"@zudojs/container": "1.2.0",
|
|
32
|
+
"@zudojs/core": "1.2.2",
|
|
33
|
+
"@zudojs/errors": "1.3.0",
|
|
34
|
+
"@zudojs/events": "1.3.0",
|
|
35
|
+
"@zudojs/logger": "1.4.0"
|
|
36
36
|
},
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"author": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"orchestrator",
|
|
53
53
|
"services"
|
|
54
54
|
],
|
|
55
|
-
"homepage": "https://
|
|
55
|
+
"homepage": "https://zudojs.oyinlola.site/docs/packages-runtime",
|
|
56
56
|
"bugs": {
|
|
57
57
|
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
58
58
|
},
|