@loutrejs/loutre 0.8.1 → 0.9.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.
Files changed (41) hide show
  1. package/README.md +3 -3
  2. package/dist/adapters/bun.d.ts +1 -0
  3. package/dist/adapters/bun.js +41 -75
  4. package/dist/application/definition.d.ts +4 -4
  5. package/dist/application/kernel.js +1 -1
  6. package/dist/core/extension.d.ts +7 -3
  7. package/dist/core/generic-layer.d.ts +2 -2
  8. package/dist/core/generic-layer.js +1 -1
  9. package/dist/core/index.d.ts +4 -4
  10. package/dist/core/index.js +1 -1
  11. package/dist/core/injection.d.ts +11 -1
  12. package/dist/core/injection.js +7 -3
  13. package/dist/core/model/context.d.ts +2 -1
  14. package/dist/core/model/context.js +23 -12
  15. package/dist/core/model/lifecycle.js +1 -1
  16. package/dist/core/model/modules.js +6 -6
  17. package/dist/core/model/providers.js +2 -2
  18. package/dist/core/model/visibility.d.ts +3 -2
  19. package/dist/core/model/visibility.js +3 -5
  20. package/dist/core/model.js +1 -1
  21. package/dist/core/module.d.ts +6 -4
  22. package/dist/core/module.js +6 -1
  23. package/dist/generated/version.d.ts +1 -1
  24. package/dist/generated/version.js +1 -1
  25. package/dist/http/extension.js +7 -6
  26. package/dist/message-port/extension.js +3 -3
  27. package/dist/runtime/di.d.ts +4 -2
  28. package/dist/runtime/di.js +33 -14
  29. package/dist/runtime/engine.d.ts +1 -0
  30. package/dist/runtime/engine.js +15 -1
  31. package/dist/runtime/index.d.ts +5 -1
  32. package/dist/runtime/index.js +3 -1
  33. package/dist/runtime/kernel.d.ts +2 -2
  34. package/dist/runtime/kernel.js +47 -9
  35. package/dist/runtime/shutdown-hooks.d.ts +16 -0
  36. package/dist/runtime/shutdown-hooks.js +69 -0
  37. package/dist/runtime/shutdown.d.ts +20 -0
  38. package/dist/runtime/shutdown.js +71 -0
  39. package/dist/tasks/extension.js +11 -10
  40. package/dist/websocket/extension.js +5 -5
  41. package/package.json +1 -1
@@ -403,24 +403,25 @@ function assertValidHttpRouteDefinition(route, segments) {
403
403
  }
404
404
  }
405
405
  }
406
- function createHttpExtensionRuntime(executions, applicationRuntime) {
406
+ function createHttpExtensionRuntime(bindings, applicationRuntime) {
407
407
  const ingress = new IngressGate();
408
408
  const activeResponseStreams = new Set();
409
409
  const handlers = new Map();
410
- for (const execution of executions) {
410
+ for (const { execution, resolve } of bindings) {
411
411
  handlers.set(execution.id, runInInjectionContext({
412
412
  consumer: {
413
413
  kind: 'execution',
414
414
  id: `http:${execution.id}`,
415
415
  name: execution.id,
416
416
  },
417
- resolve: (token) => applicationRuntime.resolve(token, execution.id),
417
+ resolve: (token, consumer) => resolve(token, { consumer }),
418
418
  }, () => execution.compiled.factory()));
419
419
  }
420
- const routes = executions
421
- .flatMap((execution) => execution.compiled.routes.map((route) => ({
420
+ const routes = bindings
421
+ .flatMap(({ execution, resolve }) => execution.compiled.routes.map((route) => ({
422
422
  executionId: execution.id,
423
423
  route,
424
+ resolve,
424
425
  })))
425
426
  .toSorted((left, right) => compareHttpPathSpecificity(left.route.segments, right.route.segments));
426
427
  return {
@@ -600,7 +601,7 @@ function createHttpExtensionRuntime(executions, applicationRuntime) {
600
601
  const dispatch = () => composeLayers({
601
602
  context,
602
603
  layers: match.route.middlewares,
603
- resolve: (token, source) => applicationRuntime.resolve(token, source),
604
+ resolve: match.resolve,
604
605
  terminal: async (middlewareContext) => {
605
606
  const handlerName = `${match.executionId}.${match.route.name}`;
606
607
  const handlerOperation = applicationRuntime.beginOperation?.({
@@ -82,18 +82,18 @@ function snapshotMessagePortRoutes(routes) {
82
82
  }),
83
83
  ])));
84
84
  }
85
- function createMessagePortRuntime(executions, applicationRuntime) {
85
+ function createMessagePortRuntime(bindings, applicationRuntime) {
86
86
  const routes = new Map();
87
87
  const ingress = new IngressGate();
88
88
  const activeStreams = new Set();
89
- for (const execution of executions) {
89
+ for (const { execution, resolve } of bindings) {
90
90
  const handlers = runInInjectionContext({
91
91
  consumer: {
92
92
  kind: 'execution',
93
93
  id: `message-port:${execution.id}`,
94
94
  name: execution.id,
95
95
  },
96
- resolve: (token) => applicationRuntime.resolve(token, execution.id),
96
+ resolve: (token, consumer) => resolve(token, { consumer }),
97
97
  }, () => execution.compiled.factory());
98
98
  for (const [method, definition] of Object.entries(execution.compiled.routes)) {
99
99
  const handler = handlers[method];
@@ -1,4 +1,4 @@
1
- import { type ArgsClass, type EnvClass, type ProviderDescriptor, type TokenLike } from '../core/index.js';
1
+ import { type ArgsClass, type EnvClass, type DependencyResolutionContext, type ProviderDescriptor, type TokenLike } from '../core/index.js';
2
2
  import { Logger } from './logger.js';
3
3
  export declare class DependencyResolutionError extends Error {
4
4
  constructor(message: string);
@@ -8,6 +8,7 @@ export interface ContainerHooks {
8
8
  }
9
9
  export interface ContainerOptions {
10
10
  readonly logger?: Logger;
11
+ readonly dependencies?: ReadonlyMap<TokenLike, readonly TokenLike[]>;
11
12
  readonly hooks?: ContainerHooks;
12
13
  readonly environment?: ReadonlyMap<EnvClass, object>;
13
14
  readonly arguments?: ReadonlyMap<ArgsClass, object>;
@@ -17,6 +18,7 @@ export declare class Container {
17
18
  constructor(providers: readonly ProviderDescriptor[], options?: Logger | ContainerOptions);
18
19
  bindEnvironment(environment: EnvClass, value: object): void;
19
20
  bindArguments(argumentsContract: ArgsClass, value: object): void;
20
- resolve<T>(token: TokenLike<T>, source?: string): T;
21
+ resolve<T>(token: TokenLike<T>, context?: string | DependencyResolutionContext): T;
21
22
  get<T>(token: TokenLike<T>): T;
22
23
  }
24
+ export declare function assertDeclaredDependency(consumer: string, token: TokenLike, dependencies: readonly TokenLike[]): void;
@@ -11,12 +11,15 @@ export class Container {
11
11
  #providers = new Map();
12
12
  #applicationCache = new Map();
13
13
  #logger;
14
+ #dependencies;
14
15
  #hooks;
15
16
  #environment = new Map();
16
17
  #arguments = new Map();
17
18
  constructor(providers, options = {}) {
18
19
  this.#logger =
19
20
  options instanceof Logger ? options : (options.logger ?? new Logger());
21
+ this.#dependencies =
22
+ options instanceof Logger ? undefined : options.dependencies;
20
23
  this.#hooks = options instanceof Logger ? {} : (options.hooks ?? {});
21
24
  if (!(options instanceof Logger)) {
22
25
  for (const [environment, value] of options.environment ?? []) {
@@ -66,8 +69,9 @@ export class Container {
66
69
  }
67
70
  this.#arguments.set(argumentsContract, value);
68
71
  }
69
- resolve(token, source) {
70
- return this.#resolve(token, source);
72
+ resolve(token, context) {
73
+ const resolution = typeof context === 'string' ? { source: context } : context;
74
+ return this.#resolve(token, resolution?.source, [], resolution?.consumer);
71
75
  }
72
76
  get(token) {
73
77
  const provider = this.#providers.get(token);
@@ -100,7 +104,7 @@ export class Container {
100
104
  }
101
105
  throw new DependencyResolutionError(`LUTRE_DI_NOT_INITIALIZED: ${tokenName(token)} has not been constructed by Application initialization.`);
102
106
  }
103
- #resolve(token, source, lineage = []) {
107
+ #resolve(token, source, lineage = [], inquirer) {
104
108
  const cycleStart = lineage.indexOf(token);
105
109
  if (cycleStart >= 0) {
106
110
  const cycle = [...lineage.slice(cycleStart), token]
@@ -123,16 +127,16 @@ export class Container {
123
127
  }
124
128
  const nextLineage = [...lineage, token];
125
129
  if (provider.scope !== 'application') {
126
- return this.#create(provider, nextLineage);
130
+ return this.#create(provider, nextLineage, inquirer);
127
131
  }
128
132
  if (this.#applicationCache.has(token)) {
129
133
  return this.#applicationCache.get(token);
130
134
  }
131
- const instance = this.#create(provider, nextLineage);
135
+ const instance = this.#create(provider, nextLineage, inquirer);
132
136
  this.#applicationCache.set(token, instance);
133
137
  return instance;
134
138
  }
135
- #create(provider, lineage) {
139
+ #create(provider, lineage, inquirer) {
136
140
  try {
137
141
  return match(provider)
138
142
  .with({ kind: 'value' }, (candidate) => {
@@ -152,13 +156,16 @@ export class Container {
152
156
  throw new DependencyResolutionError(`LUTRE_ARGS_005: Arguments ${candidate.provide.name} requires runtime Arguments before Application initialization.`);
153
157
  })
154
158
  .with({ kind: 'class' }, (candidate) => {
155
- const value = this.#instantiate(candidate.useClass, lineage);
159
+ const value = this.#instantiate(candidate.useClass, lineage, candidate, inquirer);
156
160
  this.#hooks.created?.(candidate, value);
157
161
  return value;
158
162
  })
159
163
  .with({ kind: 'factory' }, (candidate) => {
160
- const dependencies = candidate.inject.map((token) => this.#resolve(token, tokenName(candidate.provide), lineage));
161
- const value = candidate.useFactory(...dependencies);
164
+ const dependencies = candidate.inject.map((token) => this.#resolveDependency(token, candidate, lineage));
165
+ const value = runInInjectionContext({
166
+ consumer: candidate.provide,
167
+ ...(inquirer === undefined ? {} : { inquirer }),
168
+ }, () => candidate.useFactory(...dependencies));
162
169
  if (isThenable(value)) {
163
170
  throw new DependencyResolutionError('LUTRE_DI_ASYNC_FACTORY: Async factory providers are not supported. Move asynchronous resource initialization to application lifecycle.');
164
171
  }
@@ -166,13 +173,13 @@ export class Container {
166
173
  return value;
167
174
  })
168
175
  .with({ kind: 'conditional' }, (candidate) => {
169
- const input = this.#resolve(candidate.select.contract, tokenName(candidate.provide), lineage);
176
+ const input = this.#resolveDependency(candidate.select.contract, candidate, lineage);
170
177
  const selected = input[candidate.select.key];
171
178
  const implementation = candidate.mapping[selected];
172
179
  if (!implementation) {
173
180
  throw new DependencyResolutionError(`No conditional Provider matches ${candidate.select.key}=${String(selected)}`);
174
181
  }
175
- const value = this.#instantiate(implementation, lineage);
182
+ const value = this.#instantiate(implementation, lineage, candidate, inquirer);
176
183
  this.#hooks.created?.(candidate, value);
177
184
  return value;
178
185
  })
@@ -185,13 +192,20 @@ export class Container {
185
192
  throw error;
186
193
  }
187
194
  }
188
- #instantiate(target, lineage) {
195
+ #resolveDependency(token, provider, lineage, source = tokenName(provider.provide)) {
196
+ if (this.#dependencies) {
197
+ assertDeclaredDependency(tokenName(provider.provide), token, this.#dependencies.get(provider.provide) ?? []);
198
+ }
199
+ return this.#resolve(token, source, lineage, provider.provide);
200
+ }
201
+ #instantiate(target, lineage, provider, inquirer) {
189
202
  if (target.length > 0) {
190
203
  throw new DependencyResolutionError(`LUTRE_DI_CONSTRUCTOR: ${target.name} has required constructor parameters. Declare framework dependencies with constructor default parameters using inject().`);
191
204
  }
192
205
  return runInInjectionContext({
193
- consumer: target,
194
- resolve: (token) => this.#resolve(token, target.name, lineage),
206
+ consumer: provider.provide,
207
+ ...(inquirer === undefined ? {} : { inquirer }),
208
+ resolve: (token) => this.#resolveDependency(token, provider, lineage, target.name),
195
209
  }, () => new target());
196
210
  }
197
211
  }
@@ -204,3 +218,8 @@ function isThenable(value) {
204
218
  'then' in value &&
205
219
  typeof value.then === 'function');
206
220
  }
221
+ export function assertDeclaredDependency(consumer, token, dependencies) {
222
+ if (!dependencies.includes(token)) {
223
+ throw new DependencyResolutionError(`LUTRE_DI_UNDECLARED_DEPENDENCY: ${consumer} requested ${tokenName(token)}, but it is not declared in the canonical Application Model. Declare dependencies unconditionally; construction metadata and runtime values must not add dependencies.`);
224
+ }
225
+ }
@@ -1,3 +1,4 @@
1
1
  export type RuntimeEngine = 'node' | 'bun' | 'cloudflare-workers' | 'aws-lambda' | 'electron' | 'unknown';
2
2
  export declare function detectRuntimeEngine(): RuntimeEngine;
3
3
  export declare function assertRuntimeEngine(expected: Exclude<RuntimeEngine, 'unknown'>): void;
4
+ export declare function assertSupportedBunVersion(): void;
@@ -31,7 +31,21 @@ export function detectRuntimeEngine() {
31
31
  }
32
32
  export function assertRuntimeEngine(expected) {
33
33
  const actual = detectRuntimeEngine();
34
- if (actual === expected)
34
+ if (actual === expected) {
35
+ assertSupportedBunVersion();
35
36
  return;
37
+ }
36
38
  throw new Error(`LUTRE_RUNTIME_MISMATCH: Expected ${runtimeLabels[expected]}, detected ${runtimeLabels[actual]}.`);
37
39
  }
40
+ export function assertSupportedBunVersion() {
41
+ if (detectRuntimeEngine() !== 'bun')
42
+ return;
43
+ const globals = globalThis;
44
+ const version = globals.Bun?.version ?? globals.process?.versions?.bun;
45
+ const release = version?.match(/^(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/);
46
+ if (release &&
47
+ (Number(release[1]) > 1 ||
48
+ (Number(release[1]) === 1 && Number(release[2]) >= 4)))
49
+ return;
50
+ throw new Error(`LUTRE_RUNTIME_VERSION: Bun ${version ?? 'unknown'} is not supported. Bun >=1.4.0 (stable) is required.`);
51
+ }
@@ -4,7 +4,7 @@ export { Container, DependencyResolutionError } from './di.js';
4
4
  export type { ContainerOptions } from './di.js';
5
5
  export { normalizeUnknownError } from './error.js';
6
6
  export type { NormalizedApplicationError } from './error.js';
7
- export { assertRuntimeEngine, detectRuntimeEngine } from './engine.js';
7
+ export { assertRuntimeEngine, assertSupportedBunVersion, detectRuntimeEngine, } from './engine.js';
8
8
  export type { RuntimeEngine } from './engine.js';
9
9
  export { ConsoleLoggerBackend, JsonConsoleLoggerBackend, Logger, SilentLogger, } from './logger.js';
10
10
  export type { ConsoleLoggerBackendOptions, LoggerBackend, LogLevel, LogRecord, } from './logger.js';
@@ -13,3 +13,7 @@ export type { ApplicationKernelRuntimeOptions } from './kernel.js';
13
13
  export { nodeRuntimeSupport } from './node-capabilities.js';
14
14
  export { canRetryOnNextPort, initialServerPort } from './server-port.js';
15
15
  export { serverUrl } from './server-url.js';
16
+ export { ProcessShutdownHooks } from './shutdown-hooks.js';
17
+ export type { ProcessShutdownHooksOptions, ProcessSignalSource, ShutdownSignal, } from './shutdown-hooks.js';
18
+ export { ApplicationShutdownError, RuntimeShutdown } from './shutdown.js';
19
+ export type { RuntimeShutdownOptions } from './shutdown.js';
@@ -1,9 +1,11 @@
1
1
  export { checkRuntimeSupport, MissingRuntimeSupportError, } from './capability.js';
2
2
  export { Container, DependencyResolutionError } from './di.js';
3
3
  export { normalizeUnknownError } from './error.js';
4
- export { assertRuntimeEngine, detectRuntimeEngine } from './engine.js';
4
+ export { assertRuntimeEngine, assertSupportedBunVersion, detectRuntimeEngine, } from './engine.js';
5
5
  export { ConsoleLoggerBackend, JsonConsoleLoggerBackend, Logger, SilentLogger, } from './logger.js';
6
6
  export { ApplicationKernelRuntime } from './kernel.js';
7
7
  export { nodeRuntimeSupport } from './node-capabilities.js';
8
8
  export { canRetryOnNextPort, initialServerPort } from './server-port.js';
9
9
  export { serverUrl } from './server-url.js';
10
+ export { ProcessShutdownHooks } from './shutdown-hooks.js';
11
+ export { ApplicationShutdownError, RuntimeShutdown } from './shutdown.js';
@@ -1,4 +1,4 @@
1
- import { RuntimeCapabilityRegistry, type ApplicationModel, type ExecutionExtension, type ExecutionExtensionRuntime, type ExecutionKernelRuntime, type ExecutionLease, type ExecutionOperationLease, type RuntimeCapabilityBinding, type RuntimeExecutionMetadata, type RuntimeInstrumentation, type RuntimeInvocationRegistration, type RuntimeOperationMetadata, type TokenLike } from '../core/index.js';
1
+ import { RuntimeCapabilityRegistry, type ApplicationModel, type ExecutionExtension, type ExecutionExtensionRuntime, type ExecutionKernelRuntime, type ExecutionRuntimeBinding, type ExecutionLease, type ExecutionOperationLease, type RuntimeCapabilityBinding, type RuntimeExecutionMetadata, type RuntimeInstrumentation, type RuntimeInvocationRegistration, type RuntimeOperationMetadata, type TokenLike } from '../core/index.js';
2
2
  import { Container } from './di.js';
3
3
  import { Logger } from './logger.js';
4
4
  export interface ApplicationKernelRuntimeOptions {
@@ -16,7 +16,7 @@ export declare class ApplicationKernelRuntime implements ExecutionKernelRuntime
16
16
  readonly capabilities: RuntimeCapabilityRegistry;
17
17
  constructor(model: ApplicationModel, options?: ApplicationKernelRuntimeOptions);
18
18
  initialize(): Promise<void>;
19
- resolve<TValue>(token: TokenLike<TValue>, source?: string): TValue;
19
+ executionBindings<TCompiled>(extension: ExecutionExtension<any, TCompiled, any, any, any>): readonly ExecutionRuntimeBinding<TCompiled>[];
20
20
  get<TValue>(token: TokenLike<TValue>): TValue;
21
21
  extensionRuntime<TRuntime extends ExecutionExtensionRuntime>(extension: ExecutionExtension<any, any, any, any, TRuntime>): TRuntime;
22
22
  beginExecution(metadata?: RuntimeExecutionMetadata, invocation?: RuntimeInvocationRegistration): ExecutionLease;
@@ -1,5 +1,6 @@
1
+ import { ApplicationShutdownError } from './shutdown.js';
1
2
  import { assertValidApplicationModel, loadArgs, loadEnv, RuntimeCapabilityRegistry, tokenName, } from '../core/index.js';
2
- import { Container } from './di.js';
3
+ import { Container, assertDeclaredDependency } from './di.js';
3
4
  import { ExecutionTracker } from './execution-tracker.js';
4
5
  import { Logger } from './logger.js';
5
6
  const defaultForceShutdownTimeoutMs = 5_000;
@@ -10,6 +11,7 @@ export class ApplicationKernelRuntime {
10
11
  #extensionRuntimes = new Map();
11
12
  #providerInstances = new Map();
12
13
  #providerNodes = new Map();
14
+ #executionBindings = new Map();
13
15
  #initializedModuleIds = new Set();
14
16
  #executions = new ExecutionTracker();
15
17
  #extensionDrainOperations = new Map();
@@ -39,10 +41,31 @@ export class ApplicationKernelRuntime {
39
41
  }
40
42
  this.container = new Container(model.providers, {
41
43
  logger: options.logger ?? new Logger(),
44
+ dependencies: new Map([...this.#providerNodes.values()].map((node) => [
45
+ node.token,
46
+ node.dependencies,
47
+ ])),
42
48
  hooks: {
43
49
  created: (provider, value) => this.#notifyProviderCreated(provider, value),
44
50
  },
45
51
  });
52
+ for (const { extension, executions } of this.model.extensions) {
53
+ this.#executionBindings.set(extension.identity, Object.freeze(executions.map((execution) => {
54
+ const resolve = (token, context) => {
55
+ if (this.#state === 'created' || this.#state === 'stopped') {
56
+ throw applicationStateError(this.#state);
57
+ }
58
+ assertDeclaredDependency(execution.id, token, execution.dependencies);
59
+ return this.container.resolve(token, {
60
+ source: execution.id,
61
+ ...(typeof context === 'string'
62
+ ? { source: context }
63
+ : context),
64
+ });
65
+ };
66
+ return Object.freeze({ execution, resolve });
67
+ })));
68
+ }
46
69
  }
47
70
  initialize() {
48
71
  if (this.#state === 'running')
@@ -68,7 +91,7 @@ export class ApplicationKernelRuntime {
68
91
  await this.#initializeProviders();
69
92
  for (const modelExtension of this.model.extensions) {
70
93
  const runtime = await modelExtension.extension.createRuntime({
71
- executions: modelExtension.executions,
94
+ executions: this.executionBindings(modelExtension.extension),
72
95
  capabilities: this.capabilities,
73
96
  applicationRuntime: this,
74
97
  });
@@ -141,11 +164,12 @@ export class ApplicationKernelRuntime {
141
164
  await this.#runHook(lifecycleHookOf(this.model, module.id, 'onApplicationBootstrap'));
142
165
  }
143
166
  }
144
- resolve(token, source) {
145
- if (this.#state === 'created' || this.#state === 'stopped') {
146
- throw applicationStateError(this.#state);
167
+ executionBindings(extension) {
168
+ const bindings = this.#executionBindings.get(extension.identity);
169
+ if (!bindings) {
170
+ throw new Error(`LUTRE_EXTENSION_RUNTIME_MISSING: ${extension.name} is not declared in the Application Model.`);
147
171
  }
148
- return this.container.resolve(token, source);
172
+ return bindings;
149
173
  }
150
174
  get(token) {
151
175
  if (this.#state !== 'running')
@@ -259,12 +283,20 @@ export class ApplicationKernelRuntime {
259
283
  }
260
284
  catch (error) {
261
285
  errors.push(error);
262
- throw new AggregateError(errors, 'Application shutdown did not reach a safe cleanup boundary.', { cause: error });
286
+ throw new ApplicationShutdownError(errors, 'Application shutdown did not reach a safe cleanup boundary.', {
287
+ cause: error,
288
+ safeCleanupBoundaryReached: false,
289
+ retryAfter: this.#waitForShutdownProgress(),
290
+ });
263
291
  }
264
292
  }
265
293
  if (timedOutDrains.some(({ isPending }) => isPending())) {
266
294
  const cause = errors.find((error) => error instanceof ExtensionDrainTimeoutError);
267
- throw new AggregateError(errors, 'Application shutdown did not reach a safe cleanup boundary.', { cause });
295
+ throw new ApplicationShutdownError(errors, 'Application shutdown did not reach a safe cleanup boundary.', {
296
+ cause,
297
+ safeCleanupBoundaryReached: false,
298
+ retryAfter: this.#waitForShutdownProgress(),
299
+ });
268
300
  }
269
301
  for (const { extension } of [...this.model.extensions].toReversed()) {
270
302
  const runtime = this.#extensionRuntimes.get(extension.identity);
@@ -276,9 +308,15 @@ export class ApplicationKernelRuntime {
276
308
  this.#state = 'stopped';
277
309
  await closeInstrumentation(this.#instrumentation);
278
310
  if (errors.length > 0) {
279
- throw new AggregateError(errors, 'Application shutdown failed.');
311
+ throw new ApplicationShutdownError(errors, 'Application shutdown failed.', { safeCleanupBoundaryReached: true });
280
312
  }
281
313
  }
314
+ async #waitForShutdownProgress() {
315
+ await Promise.allSettled([
316
+ this.#executions.waitForIdle(),
317
+ ...[...this.#extensionDrainOperations.values()].map(({ result }) => result),
318
+ ]);
319
+ }
282
320
  #startExtensionDrain(extension, runtime) {
283
321
  const existing = this.#extensionDrainOperations.get(extension.identity);
284
322
  if (existing)
@@ -0,0 +1,16 @@
1
+ export type ShutdownSignal = 'SIGINT' | 'SIGTERM';
2
+ export interface ProcessSignalSource {
3
+ once(signal: ShutdownSignal, handler: () => void): unknown;
4
+ off(signal: ShutdownSignal, handler: () => void): unknown;
5
+ }
6
+ export interface ProcessShutdownHooksOptions {
7
+ readonly source: ProcessSignalSource;
8
+ readonly close: (signal: ShutdownSignal) => Promise<void>;
9
+ readonly onError: (error: unknown, signal: ShutdownSignal) => void;
10
+ }
11
+ export declare class ProcessShutdownHooks {
12
+ #private;
13
+ constructor(options: ProcessShutdownHooksOptions);
14
+ setEnabled(enabled: boolean): void;
15
+ dispose(): void;
16
+ }
@@ -0,0 +1,69 @@
1
+ import { ApplicationShutdownError } from './shutdown.js';
2
+ export class ProcessShutdownHooks {
3
+ #handlers = new Map();
4
+ #options;
5
+ #disposed = false;
6
+ constructor(options) {
7
+ this.#options = options;
8
+ }
9
+ setEnabled(enabled) {
10
+ if (this.#disposed)
11
+ return;
12
+ if (!enabled) {
13
+ this.#removeHandlers();
14
+ return;
15
+ }
16
+ if (this.#handlers.size > 0)
17
+ return;
18
+ for (const signal of ['SIGINT', 'SIGTERM']) {
19
+ const handler = () => {
20
+ if (this.#disposed)
21
+ return;
22
+ this.dispose();
23
+ void this.#close(signal);
24
+ };
25
+ this.#handlers.set(signal, handler);
26
+ this.#options.source.once(signal, handler);
27
+ }
28
+ }
29
+ dispose() {
30
+ this.#disposed = true;
31
+ this.#removeHandlers();
32
+ }
33
+ #removeHandlers() {
34
+ for (const [signal, handler] of this.#handlers) {
35
+ this.#options.source.off(signal, handler);
36
+ }
37
+ this.#handlers.clear();
38
+ }
39
+ async #close(signal) {
40
+ for (;;) {
41
+ try {
42
+ await this.#options.close(signal);
43
+ return;
44
+ }
45
+ catch (error) {
46
+ this.#reportError(error, signal);
47
+ if (!(error instanceof ApplicationShutdownError) ||
48
+ error.safeCleanupBoundaryReached ||
49
+ !error.retryAfter)
50
+ return;
51
+ try {
52
+ await error.retryAfter;
53
+ }
54
+ catch (retryError) {
55
+ this.#reportError(retryError, signal);
56
+ return;
57
+ }
58
+ }
59
+ }
60
+ }
61
+ #reportError(error, signal) {
62
+ try {
63
+ this.#options.onError(error, signal);
64
+ }
65
+ catch (reportingError) {
66
+ console.error('Runtime shutdown error reporter failed', error, reportingError);
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,20 @@
1
+ export declare class ApplicationShutdownError extends AggregateError {
2
+ readonly safeCleanupBoundaryReached: boolean;
3
+ readonly retryAfter: Promise<void> | undefined;
4
+ constructor(errors: readonly unknown[], message: string, options: ErrorOptions & {
5
+ readonly safeCleanupBoundaryReached: boolean;
6
+ readonly retryAfter?: Promise<void>;
7
+ });
8
+ }
9
+ export interface RuntimeShutdownOptions {
10
+ readonly message: string;
11
+ readonly beginClose: () => Promise<void>;
12
+ readonly closeApplication: (signal?: string) => Promise<void>;
13
+ readonly closeResources: (safeCleanupBoundaryReached: boolean, resourceClosing: Promise<void>) => Promise<void>;
14
+ }
15
+ export declare class RuntimeShutdown {
16
+ #private;
17
+ constructor(options: RuntimeShutdownOptions);
18
+ get started(): boolean;
19
+ close(signal?: string): Promise<void>;
20
+ }
@@ -0,0 +1,71 @@
1
+ export class ApplicationShutdownError extends AggregateError {
2
+ safeCleanupBoundaryReached;
3
+ retryAfter;
4
+ constructor(errors, message, options) {
5
+ super(errors, message, options);
6
+ this.name = 'ApplicationShutdownError';
7
+ this.safeCleanupBoundaryReached = options.safeCleanupBoundaryReached;
8
+ this.retryAfter = options.retryAfter;
9
+ }
10
+ }
11
+ export class RuntimeShutdown {
12
+ #options;
13
+ #started = false;
14
+ #closed = false;
15
+ #closing;
16
+ constructor(options) {
17
+ this.#options = options;
18
+ }
19
+ get started() {
20
+ return this.#started;
21
+ }
22
+ close(signal) {
23
+ if (this.#closed)
24
+ return Promise.resolve();
25
+ if (this.#closing)
26
+ return this.#closing;
27
+ this.#started = true;
28
+ this.#closing = this.#close(signal).catch((error) => {
29
+ this.#closing = undefined;
30
+ throw error;
31
+ });
32
+ return this.#closing;
33
+ }
34
+ async #close(signal) {
35
+ const errors = [];
36
+ let resourceClosing = Promise.resolve();
37
+ try {
38
+ resourceClosing = this.#options.beginClose();
39
+ void resourceClosing.catch(() => undefined);
40
+ }
41
+ catch (error) {
42
+ errors.push(error);
43
+ }
44
+ let safeCleanupBoundaryReached = false;
45
+ let retryAfter;
46
+ try {
47
+ await this.#options.closeApplication(signal);
48
+ safeCleanupBoundaryReached = true;
49
+ }
50
+ catch (error) {
51
+ errors.push(error);
52
+ safeCleanupBoundaryReached =
53
+ error instanceof ApplicationShutdownError &&
54
+ error.safeCleanupBoundaryReached;
55
+ if (error instanceof ApplicationShutdownError)
56
+ retryAfter = error.retryAfter;
57
+ }
58
+ try {
59
+ await this.#options.closeResources(safeCleanupBoundaryReached, resourceClosing);
60
+ }
61
+ catch (error) {
62
+ errors.push(error);
63
+ }
64
+ if (errors.length > 0)
65
+ throw new ApplicationShutdownError(errors, this.#options.message, {
66
+ safeCleanupBoundaryReached,
67
+ ...(retryAfter === undefined ? {} : { retryAfter }),
68
+ });
69
+ this.#closed = true;
70
+ }
71
+ }
@@ -228,14 +228,14 @@ export function consume(declaration) {
228
228
  task: declaration.task,
229
229
  });
230
230
  }
231
- function createTasksRuntime(executions, applicationRuntime) {
231
+ function createTasksRuntime(bindings, applicationRuntime) {
232
232
  const runtimes = new Map();
233
233
  const runtimeExecutionIds = new Map();
234
234
  const triggers = [];
235
- for (const execution of executions) {
235
+ for (const { execution, resolve } of bindings) {
236
236
  const compiled = execution.compiled;
237
237
  if (compiled.type !== 'task') {
238
- triggers.push(compiled);
238
+ triggers.push({ execution, resolve });
239
239
  continue;
240
240
  }
241
241
  const runtime = runInInjectionContext({
@@ -244,7 +244,7 @@ function createTasksRuntime(executions, applicationRuntime) {
244
244
  id: `task:${compiled.name}`,
245
245
  name: compiled.name,
246
246
  },
247
- resolve: (dependency) => applicationRuntime.resolve(dependency, execution.id),
247
+ resolve: (dependency, consumer) => resolve(dependency, { consumer }),
248
248
  }, () => compiled.factory());
249
249
  runtimes.set(compiled.taskExecutionId, runtime);
250
250
  runtimeExecutionIds.set(compiled.taskExecutionId, execution.id);
@@ -344,10 +344,11 @@ function createTasksRuntime(executions, applicationRuntime) {
344
344
  const started = [];
345
345
  const startup = (async () => {
346
346
  try {
347
- for (const trigger of triggers) {
347
+ for (const { execution, resolve } of triggers) {
348
+ const trigger = execution.compiled;
348
349
  if (trigger.type === 'task')
349
350
  continue;
350
- const handle = await startTrigger(trigger, run, applicationRuntime);
351
+ const handle = await startTrigger(trigger, run, resolve);
351
352
  started.push(handle);
352
353
  if (state !== 'running') {
353
354
  throw tasksStateError(state);
@@ -406,11 +407,11 @@ function createTasksRuntime(executions, applicationRuntime) {
406
407
  },
407
408
  };
408
409
  }
409
- async function startTrigger(trigger, run, applicationRuntime) {
410
+ async function startTrigger(trigger, run, resolve) {
410
411
  return match(trigger)
411
412
  .with({ type: 'cron' }, (candidate) => startCronTrigger(candidate, run))
412
413
  .with({ type: 'fixed-delay' }, (candidate) => startFixedDelayTrigger(candidate, run))
413
- .with({ type: 'queue-consumer' }, (candidate) => startQueueTrigger(candidate, run, applicationRuntime))
414
+ .with({ type: 'queue-consumer' }, (candidate) => startQueueTrigger(candidate, run, resolve))
414
415
  .exhaustive();
415
416
  }
416
417
  function startCronTrigger(trigger, run) {
@@ -486,8 +487,8 @@ function startFixedDelayTrigger(trigger, run) {
486
487
  },
487
488
  };
488
489
  }
489
- async function startQueueTrigger(trigger, run, applicationRuntime) {
490
- const driver = applicationRuntime.resolve(trigger.queueDriver);
490
+ async function startQueueTrigger(trigger, run, resolve) {
491
+ const driver = resolve(trigger.queueDriver);
491
492
  if (!driver || typeof driver.start !== 'function') {
492
493
  throw new Error(`LUTRE_QUEUE_DRIVER_INVALID: Queue ${trigger.queueName} driver is invalid.`);
493
494
  }