@znight/core 0.6.2 → 0.6.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/README.md +7 -2
- package/dist/index.d.mts +6 -5
- package/dist/index.mjs +78 -25
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/application.ts +73 -15
- package/src/container.ts +26 -10
- package/src/scope.ts +4 -1
package/package.json
CHANGED
package/src/application.ts
CHANGED
|
@@ -116,6 +116,13 @@ export class Application {
|
|
|
116
116
|
#startAborted = false
|
|
117
117
|
/** Releases a `stop()` waiting for the start, when a start hook asks to stop in turn. */
|
|
118
118
|
#releaseStopWait: (() => void) | undefined
|
|
119
|
+
/** Owner of the `onStart` hook in progress. */
|
|
120
|
+
#starting: StartOwner | undefined
|
|
121
|
+
/**
|
|
122
|
+
* Owner of the `onStart` hook still in flight when the wait of `stop()` timed out: the shutdown
|
|
123
|
+
* skips its `onDispose`, and it is stopped and disposed when that hook settles.
|
|
124
|
+
*/
|
|
125
|
+
#lateStart: StartOwner | undefined
|
|
119
126
|
|
|
120
127
|
private constructor(options: ApplicationOptions) {
|
|
121
128
|
const plugins = [...(options.plugins ?? [])]
|
|
@@ -292,11 +299,11 @@ export class Application {
|
|
|
292
299
|
async #runStartHooks(): Promise<boolean> {
|
|
293
300
|
const providers = this.container.instances()
|
|
294
301
|
for (const provider of providers) {
|
|
295
|
-
if (!(await this.#startHook(() => this.#callProvider(provider, 'onStart')))) return false
|
|
302
|
+
if (!(await this.#startHook(() => this.#callProvider(provider, 'onStart'), { provider }))) return false
|
|
296
303
|
this.#startedProviders.add(provider.token)
|
|
297
304
|
}
|
|
298
305
|
for (const plugin of this.#plugins) {
|
|
299
|
-
if (!(await this.#startHook(() => this.#callPlugin(plugin, 'onStart')))) return false
|
|
306
|
+
if (!(await this.#startHook(() => this.#callPlugin(plugin, 'onStart'), { plugin }))) return false
|
|
300
307
|
this.#startedPlugins.push(plugin)
|
|
301
308
|
}
|
|
302
309
|
for (const provider of providers) {
|
|
@@ -311,18 +318,51 @@ export class Application {
|
|
|
311
318
|
/**
|
|
312
319
|
* Runs one start hook in an async context carrying a fresh marker, so that a `stop()` called from
|
|
313
320
|
* it (or from work it awaits) is recognised. Returns `false` when the hook stopped the application.
|
|
321
|
+
* `owner` is given for `onStart` hooks: one that settles after `stop()` stopped waiting for it is
|
|
322
|
+
* stopped and disposed then (see `#stopLateStart`).
|
|
314
323
|
*/
|
|
315
|
-
async #startHook(hook: () => Promise<void
|
|
324
|
+
async #startHook(hook: () => Promise<void>, owner?: StartOwner): Promise<boolean> {
|
|
316
325
|
const marker = {}
|
|
317
326
|
this.#startHookMarker = marker
|
|
327
|
+
this.#starting = owner
|
|
328
|
+
let started = false
|
|
318
329
|
try {
|
|
319
330
|
await startHookContext.run(marker, hook)
|
|
331
|
+
started = true
|
|
320
332
|
} finally {
|
|
321
333
|
this.#startHookMarker = undefined
|
|
334
|
+
this.#starting = undefined
|
|
335
|
+
if (owner !== undefined && this.#lateStart === owner) {
|
|
336
|
+
await this.#stopLateStart(owner, started)
|
|
337
|
+
}
|
|
322
338
|
}
|
|
323
339
|
return !this.#startAborted
|
|
324
340
|
}
|
|
325
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Stops what an `onStart` hook settling after the shutdown started (best effort, failures logged):
|
|
344
|
+
* a plugin gets `onStop` (when its `onStart` succeeded) then the `onDispose` the shutdown skipped;
|
|
345
|
+
* a provider, already disposed by the shutdown, gets `onStop` when its `onStart` succeeded.
|
|
346
|
+
*/
|
|
347
|
+
async #stopLateStart(owner: StartOwner, started: boolean): Promise<void> {
|
|
348
|
+
const { plugin, provider } = owner
|
|
349
|
+
const label = plugin === undefined ? `${provider?.displayName}.onStart()` : `Plugin "${plugin.name}" onStart()`
|
|
350
|
+
this.#logger.warn(`${label} settled after the shutdown stopped waiting for it: stopping it now`)
|
|
351
|
+
const errors: unknown[] = []
|
|
352
|
+
const step = this.#stepRunner(errors)
|
|
353
|
+
if (plugin !== undefined) {
|
|
354
|
+
if (started && typeof plugin.onStop === 'function') {
|
|
355
|
+
await step(`Plugin "${plugin.name}" onStop()`, () => plugin.onStop?.(this.#context))
|
|
356
|
+
}
|
|
357
|
+
if (typeof plugin.onDispose === 'function') {
|
|
358
|
+
await step(`Plugin "${plugin.name}" onDispose()`, () => plugin.onDispose?.(this.#context))
|
|
359
|
+
}
|
|
360
|
+
} else if (provider !== undefined && started && hasLifecycleHook(provider.instance, 'onStop')) {
|
|
361
|
+
await step(`${provider.displayName}.onStop()`, () => callLifecycleHook(provider.instance, 'onStop'))
|
|
362
|
+
}
|
|
363
|
+
for (const error of errors) this.#logger.error('Failed to stop a late start', { err: error })
|
|
364
|
+
}
|
|
365
|
+
|
|
326
366
|
/** Whether the caller runs in the async context of the start hook in progress. */
|
|
327
367
|
#calledFromStartHook(): boolean {
|
|
328
368
|
const marker = this.#startHookMarker
|
|
@@ -339,11 +379,12 @@ export class Application {
|
|
|
339
379
|
}
|
|
340
380
|
|
|
341
381
|
/**
|
|
342
|
-
* Stops the application (idempotent; waits for an in-progress `start()
|
|
343
|
-
*
|
|
344
|
-
* plugins' `
|
|
345
|
-
*
|
|
346
|
-
*
|
|
382
|
+
* Stops the application (idempotent; waits for an in-progress `start()`, as a step bounded like
|
|
383
|
+
* the others: past it, the remaining start hooks are skipped and what completed its `onStart` is
|
|
384
|
+
* stopped): plugins' `onStop` (reverse) → providers' `onStop` + disposal (reverse resolution
|
|
385
|
+
* order) → plugins' `onDispose` (reverse). Every step is attempted, each within `hookTimeoutMs`
|
|
386
|
+
* and all within `timeoutMs`; failures are collected into a ZnightAggregateError (ZNIGHT021).
|
|
387
|
+
* Signal handlers are removed.
|
|
347
388
|
*
|
|
348
389
|
* Called from a start hook (`onStart`/`onReady` of a provider or plugin, or work it awaits), it
|
|
349
390
|
* does not wait for the start, which waits for that hook: the shutdown runs right away, stopping
|
|
@@ -359,13 +400,25 @@ export class Application {
|
|
|
359
400
|
}
|
|
360
401
|
|
|
361
402
|
async #runStop(): Promise<void> {
|
|
362
|
-
|
|
403
|
+
const errors: unknown[] = []
|
|
404
|
+
const step = this.#stepRunner(errors)
|
|
405
|
+
if (this.#state === 'starting' && !this.#startAborted) {
|
|
406
|
+
// a start hook may never settle: waiting for the start is a step of the shutdown
|
|
407
|
+
await step('Waiting for start()', () => this.#waitForStart())
|
|
408
|
+
if (this.#state === 'starting') {
|
|
409
|
+
// timed out: the remaining start hooks are skipped, what completed its onStart is stopped,
|
|
410
|
+
// and the owner of the onStart still in flight is stopped and disposed once it settles
|
|
411
|
+
this.#startAborted = true
|
|
412
|
+
this.#releaseStopWait = undefined
|
|
413
|
+
this.#lateStart = this.#starting
|
|
414
|
+
}
|
|
415
|
+
}
|
|
363
416
|
if (this.#state === 'failed' || this.#state === 'stopped') {
|
|
364
417
|
this.#removeSignals()
|
|
365
418
|
return
|
|
366
419
|
}
|
|
367
420
|
this.#state = 'stopping'
|
|
368
|
-
|
|
421
|
+
await this.#shutdown(errors, step)
|
|
369
422
|
this.#removeSignals()
|
|
370
423
|
this.#state = 'stopped'
|
|
371
424
|
if (errors.length > 0) {
|
|
@@ -395,11 +448,9 @@ export class Application {
|
|
|
395
448
|
|
|
396
449
|
/**
|
|
397
450
|
* The three shutdown phases (also used to roll back a failed create/start: only what was started
|
|
398
|
-
* is stopped). Never throws: returns the errors of the failed steps.
|
|
451
|
+
* is stopped). Never throws: returns the errors of the failed steps (appended to `errors`).
|
|
399
452
|
*/
|
|
400
|
-
async #shutdown(): Promise<unknown[]> {
|
|
401
|
-
const errors: unknown[] = []
|
|
402
|
-
const step = this.#stepRunner(errors)
|
|
453
|
+
async #shutdown(errors: unknown[] = [], step = this.#stepRunner(errors)): Promise<unknown[]> {
|
|
403
454
|
for (const plugin of [...this.#startedPlugins].reverse()) {
|
|
404
455
|
if (typeof plugin.onStop === 'function') {
|
|
405
456
|
await step(`Plugin "${plugin.name}" onStop()`, () => plugin.onStop?.(this.#context))
|
|
@@ -421,7 +472,8 @@ export class Application {
|
|
|
421
472
|
errors.push(...flatten(error))
|
|
422
473
|
}
|
|
423
474
|
for (const plugin of [...this.#registered].reverse()) {
|
|
424
|
-
|
|
475
|
+
// a plugin whose onStart is still in flight is disposed when it settles (#stopLateStart)
|
|
476
|
+
if (typeof plugin.onDispose === 'function' && plugin !== this.#lateStart?.plugin) {
|
|
425
477
|
await step(`Plugin "${plugin.name}" onDispose()`, () => plugin.onDispose?.(this.#context))
|
|
426
478
|
}
|
|
427
479
|
}
|
|
@@ -500,6 +552,12 @@ export class Application {
|
|
|
500
552
|
}
|
|
501
553
|
}
|
|
502
554
|
|
|
555
|
+
/** The plugin or provider an `onStart` hook belongs to. */
|
|
556
|
+
interface StartOwner {
|
|
557
|
+
readonly plugin?: Plugin
|
|
558
|
+
readonly provider?: ManagedInstance
|
|
559
|
+
}
|
|
560
|
+
|
|
503
561
|
function entriesOf(values: ProviderValues): Iterable<readonly [ProviderKey, unknown]> {
|
|
504
562
|
if (typeof (values as Partial<Iterable<unknown>>)[Symbol.iterator] === 'function') {
|
|
505
563
|
return values as Iterable<readonly [ProviderKey, unknown]>
|
package/src/container.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
isMissing,
|
|
32
32
|
missingProvidersError,
|
|
33
33
|
} from './graph.ts'
|
|
34
|
-
import { disposeInstance } from './lifecycle.ts'
|
|
34
|
+
import { disposeInstance, isDisposable } from './lifecycle.ts'
|
|
35
35
|
import {
|
|
36
36
|
type ExternalDefinition,
|
|
37
37
|
type Lazy,
|
|
@@ -160,6 +160,11 @@ export class Container {
|
|
|
160
160
|
readonly #storage = new AsyncLocalStorage<RequestScope>()
|
|
161
161
|
/** Instantiated singletons, in creation order. */
|
|
162
162
|
readonly #created: Slot[] = []
|
|
163
|
+
/**
|
|
164
|
+
* Request-scoped and transient slots whose factory is running. Creation is synchronous, so a slot
|
|
165
|
+
* found here again was requested by its own construction (a `Lazy<T>.get()` in a constructor).
|
|
166
|
+
*/
|
|
167
|
+
readonly #constructing = new Set<Slot>()
|
|
163
168
|
#order: readonly Slot[] = []
|
|
164
169
|
#names: Map<string, Slot[]> | undefined
|
|
165
170
|
#status: Status = 'open'
|
|
@@ -574,22 +579,31 @@ export class Container {
|
|
|
574
579
|
/** Instantiates a request-scoped or transient provider (synchronous factories only). */
|
|
575
580
|
#create(slot: Slot): unknown {
|
|
576
581
|
const definition = slot.definition as ProviderDefinition
|
|
582
|
+
const constructing = this.#constructing
|
|
583
|
+
if (constructing.has(slot)) throw reentrantCreationError(slot)
|
|
584
|
+
constructing.add(slot)
|
|
577
585
|
let deps: unknown[]
|
|
578
|
-
try {
|
|
579
|
-
deps = this.#resolveDeps(slot)
|
|
580
|
-
} catch (error) {
|
|
581
|
-
throw error instanceof InstantiationError ? error.withParents([slot.token]) : error
|
|
582
|
-
}
|
|
583
586
|
let instance: unknown
|
|
584
587
|
try {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
+
try {
|
|
589
|
+
deps = this.#resolveDeps(slot)
|
|
590
|
+
} catch (error) {
|
|
591
|
+
throw error instanceof InstantiationError ? error.withParents([slot.token]) : error
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
instance = definition.factory(deps)
|
|
595
|
+
} catch (error) {
|
|
596
|
+
throw new InstantiationError(slot.token, [slot.token], error, definition.source)
|
|
597
|
+
}
|
|
598
|
+
} finally {
|
|
599
|
+
constructing.delete(slot)
|
|
588
600
|
}
|
|
589
601
|
if (definition.interceptors !== undefined) {
|
|
590
602
|
try {
|
|
591
603
|
applyInterceptors(instance as object, definition.interceptors, deps, slot.displayName)
|
|
592
604
|
} catch (error) {
|
|
605
|
+
// the instance is never handed out nor tracked by a scope: release it now
|
|
606
|
+
disposeQuietly(instance, definition.dispose)
|
|
593
607
|
throw new InstantiationError(slot.token, [slot.token], error, definition.source)
|
|
594
608
|
}
|
|
595
609
|
}
|
|
@@ -606,7 +620,9 @@ export class Container {
|
|
|
606
620
|
this.#assertResolvable(slot)
|
|
607
621
|
const instance = this.#create(slot)
|
|
608
622
|
values.set(slot.token, instance)
|
|
609
|
-
|
|
623
|
+
// only what has something to dispose is tracked: a scope without any closes synchronously
|
|
624
|
+
const dispose = slot.definition?.dispose
|
|
625
|
+
if (isDisposable(instance, dispose)) scope.created.push({ token: slot.token, instance, dispose })
|
|
610
626
|
return instance
|
|
611
627
|
}
|
|
612
628
|
|
package/src/scope.ts
CHANGED
|
@@ -17,7 +17,10 @@ export interface ScopeContext {
|
|
|
17
17
|
readonly closed: boolean
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* A request-scoped instance created by the container that has something to dispose (a dispose
|
|
22
|
+
* method or `[Symbol.asyncDispose]`/`[Symbol.dispose]`), disposed when its scope closes.
|
|
23
|
+
*/
|
|
21
24
|
export interface ScopedInstance {
|
|
22
25
|
readonly token: Token
|
|
23
26
|
readonly instance: unknown
|