@supacloud/elysia 0.21.0 → 0.23.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/NOTICE CHANGED
@@ -1,22 +1,17 @@
1
1
  SupaCloud
2
2
  Copyright 2025 SupaCloud Contributors
3
3
 
4
- SupaCloud project-owned code in this revision is licensed under the GNU
5
- Affero General Public License, version 3 only (AGPL-3.0-only). The complete
6
- license is provided in LICENSE. This is not an "or later" grant.
4
+ SupaCloud project-owned code in this revision is licensed under the Apache
5
+ License, Version 2.0 (Apache-2.0). The complete license is provided in
6
+ LICENSE.
7
7
 
8
8
  This program is distributed without any warranty; without even the implied
9
9
  warranty of merchantability or fitness for a particular purpose.
10
10
 
11
11
  Third-party components, incorporated upstream code, and their copyright,
12
- license and attribution notices retain their original terms. This notice
13
- does not replace those terms or claim ownership of third-party work.
12
+ license and attribution notices retain their original terms. This notice does
13
+ not replace those terms or claim ownership of third-party work.
14
14
 
15
- Earlier SupaCloud code was distributed under Apache-2.0 or MIT as indicated
16
- in its original files and package metadata. Previously granted permissions
17
- are not revoked by this change. The historical Apache-2.0 text is preserved
18
- in LICENSE-APACHE-2.0.txt. Retain any applicable upstream MIT notices as well.
19
-
20
- Before publishing this licensing change, maintainers must confirm authority
21
- to license the affected contributions and compatibility of incorporated
22
- third-party code. This notice is not evidence of contributor consent.
15
+ Previously released SupaCloud versions retain the permissions granted with
16
+ those versions. See the historical license files and release notices where
17
+ applicable.
package/README.md CHANGED
@@ -562,9 +562,32 @@ with those statuses. The default content type is
562
562
  is for bounded JSON payloads, not files or streams. Existing native `Response`
563
563
  passthrough is unchanged.
564
564
 
565
- ### `createApplication(options: ApplicationOptions): Elysia`
565
+ ### `createApplication(options: ApplicationOptions): Elysia & ApplicationLifecycle`
566
566
 
567
- Creates the root Elysia application from compiled modules.
567
+ Creates the root Elysia application from compiled modules. The returned app
568
+ also exposes explicit `initialize()` and `destroy()` controls for
569
+ application-scoped resources. Initialization runs compiled modules in their
570
+ topological order; destruction runs in reverse order and is idempotent. A
571
+ startup failure triggers best-effort cleanup of all modules that were created,
572
+ and shutdown continues across modules before reporting an `AggregateError`.
573
+
574
+ The controls are intentionally explicit so hosts can await startup before
575
+ accepting traffic and await shutdown from their process signal handler:
576
+
577
+ ```ts
578
+ const app = createApplication({ modules: createCompiledModules() });
579
+ await app.initialize();
580
+ const server = app.listen(3000);
581
+ process.once("SIGTERM", async () => {
582
+ server.stop();
583
+ await app.destroy();
584
+ });
585
+ ```
586
+
587
+ The compiler emits `initializeServices` and `destroyServices` for
588
+ application-scoped providers and controllers that declare `onInit()` or
589
+ `onDestroy()`/`ngOnDestroy()`. Borrowed `useExisting` providers are never
590
+ destroyed by the owning module.
568
591
 
569
592
  ### `createModulePlugin(compiled, services, ctxFactory?, options?, imported?): Elysia`
570
593
 
@@ -36,4 +36,4 @@ export declare function createSupaCloudFramework<const Http extends AnyElysia =
36
36
  schemas: {};
37
37
  response: {};
38
38
  error: [];
39
- }>;
39
+ }> & import("./index").ApplicationLifecycle;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Elysia, type AnyElysia, type Context, type StatusMap } from "elysia";
2
2
  import type { CommandRuntimeAudit, CommandRuntimeAuthorizer, CommandRuntimeGovernance, CommandRuntimeInvocation, CommandRuntimeMiddleware } from "@supacloud/app";
3
+ import { PendingWorkRegistry } from "@supacloud/app/runtime";
3
4
  import { type CommandPreview } from "@supacloud/contracts";
4
5
  import { type HttpPolicyRegistry } from "./http-policy";
5
6
  export { HttpPolicyConfigurationError } from "./http-policy";
@@ -108,6 +109,10 @@ export interface CompiledController {
108
109
  export interface CompiledModule {
109
110
  name: string;
110
111
  createServices(deps: Record<string, unknown>, imported: Record<string, Record<string, unknown>>): Record<string, unknown>;
112
+ /** Initializes application-scoped instances owned by this module. */
113
+ initializeServices?(services: Record<string, unknown>): Promise<void>;
114
+ /** Destroys application-scoped instances owned by this module. */
115
+ destroyServices?(services: Record<string, unknown>): Promise<void>;
111
116
  createRequestScope?(services: Record<string, unknown>, ctx: unknown, imported?: Record<string, Record<string, unknown>>): Record<string, unknown> | Promise<Record<string, unknown>>;
112
117
  destroyRequestScope?(scope: Record<string, unknown>): Promise<void>;
113
118
  createJobScope?(services: Record<string, unknown>, ctx: unknown, imported?: Record<string, Record<string, unknown>>): Record<string, unknown> | Promise<Record<string, unknown>>;
@@ -243,7 +248,7 @@ export type ApplicationHttpContext<Http extends AnyElysia = Elysia> = Omit<Conte
243
248
  store: Http["~Singleton"]["store"];
244
249
  derive: Http["~Singleton"]["derive"] & Http["~Ephemeral"]["derive"];
245
250
  }>, "body" | "params" | "query" | "headers" | "cookie"> & Pick<HttpContext, "body" | "params" | "query" | "headers" | "cookie">;
246
- export type HttpRequestContextFactory<Http extends AnyElysia = Elysia, Value = unknown> = (request: Request, context: ApplicationHttpContext<Http>) => Value | Promise<Value>;
251
+ export type HttpRequestContextFactory<Http extends AnyElysia = Elysia, Value = unknown> = (request: Request, context: ApplicationHttpContext<Http>, signal?: AbortSignal) => Value | Promise<Value>;
247
252
  export interface ApplicationOptions<Http extends AnyElysia = Elysia, RequestContext = unknown> {
248
253
  name?: string;
249
254
  /** false rejects extra schema properties instead of silently removing them. */
@@ -268,6 +273,23 @@ export interface ApplicationOptions<Http extends AnyElysia = Elysia, RequestCont
268
273
  onExecution?: ExecutionObserver;
269
274
  /** Optional read-only OpenAPI and GraphQL documentation endpoints. */
270
275
  documentation?: ApplicationDocumentationOptions;
276
+ /** Bounded lifecycle work accounting; registries remain owned by each host. */
277
+ pendingWork?: {
278
+ capacity?: number;
279
+ shutdownTimeoutMs?: number;
280
+ };
281
+ }
282
+ /** Lifecycle controls exposed by createApplication for explicit host startup/shutdown. */
283
+ export interface ApplicationLifecycle {
284
+ readonly initialized: boolean;
285
+ readonly destroyed: boolean;
286
+ initialize(): Promise<void>;
287
+ destroy(): Promise<void>;
288
+ readonly pendingWork: PendingWorkRegistry;
289
+ waitForIdle(options?: {
290
+ timeoutMs?: number;
291
+ includeBackground?: boolean;
292
+ }): Promise<void>;
271
293
  }
272
294
  export interface JobInvocation {
273
295
  job: CompiledJob;
@@ -338,7 +360,7 @@ interface HttpContext {
338
360
  * and passed to the controller input as `scope`. Request-scoped controllers
339
361
  * are looked up on that scope, everything else on `services`.
340
362
  */
341
- export declare function createModulePlugin<const Services extends Record<string, unknown>, const Http extends AnyElysia = Elysia>(compiled: CompiledModule, services: Services, ctxFactory?: HttpRequestContextFactory<Http>, options?: Pick<ApplicationOptions<Http>, "http" | "httpPolicies" | "commandGovernance" | "commandExecutor" | "errorMapper" | "onExecution" | "normalize">, imported?: Record<string, Record<string, unknown>>): import("elysia/types").LocalHookReturn<"", "local", {
363
+ export declare function createModulePlugin<const Services extends Record<string, unknown>, const Http extends AnyElysia = Elysia>(compiled: CompiledModule, services: Services, ctxFactory?: HttpRequestContextFactory<Http>, options?: Pick<ApplicationOptions<Http>, "http" | "httpPolicies" | "commandGovernance" | "commandExecutor" | "errorMapper" | "onExecution" | "normalize">, imported?: Record<string, Record<string, unknown>>, pendingWork?: PendingWorkRegistry): import("elysia/types").LocalHookReturn<"", "local", {
342
364
  decorator: {} & Http["~Singleton"]["decorator"];
343
365
  store: ({} & Http["~Singleton"]["store"] extends infer T_1 ? { [K_1 in keyof T_1]: T_1[K_1]; } : never) extends infer T ? { [K in keyof T]: T[K]; } : never;
344
366
  derive: {} & Http["~Singleton"]["derive"];
@@ -394,7 +416,7 @@ export declare function createApplication<const Http extends AnyElysia = Elysia>
394
416
  schemas: {};
395
417
  response: {};
396
418
  error: [];
397
- }>;
419
+ }> & ApplicationLifecycle;
398
420
  /** Semantic alias of createApplication for readable tests. */
399
421
  export declare function createTestApp<const Http extends AnyElysia = Elysia>(options: ApplicationOptions<Http>): Elysia<"", "local", {
400
422
  decorator: {} & Http["~Singleton"]["decorator"];
@@ -416,7 +438,7 @@ export declare function createTestApp<const Http extends AnyElysia = Elysia>(opt
416
438
  schemas: {};
417
439
  response: {};
418
440
  error: [];
419
- }>;
441
+ }> & ApplicationLifecycle;
420
442
  export { createMemorySandbox, } from "./memory";
421
443
  export type { HandleLike as MemoryHandleLike, MemoryDatabase, MemorySandbox, MemorySandboxOptions, MemoryStorage, MemoryStorageObject, } from "./memory";
422
444
  export { createMemoryPolicy } from "./memory_policy";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/index.ts
2
2
  import { Elysia as Elysia3, setupTypebox, Validator as Validator2 } from "elysia";
3
+ import { PendingWorkRegistry as PendingWorkRegistry2, PendingWorkTimeoutError } from "@supacloud/app/runtime";
3
4
  import { decodeCommandPreview } from "@supacloud/contracts";
4
5
 
5
6
  // src/command-errors.ts
@@ -1426,6 +1427,7 @@ function cloneValue(value) {
1426
1427
  return structuredClone(value);
1427
1428
  }
1428
1429
  // src/worker.ts
1430
+ import { PendingWorkRegistry } from "@supacloud/app/runtime";
1429
1431
  function workerFailureMessage(error) {
1430
1432
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Worker job failed";
1431
1433
  const normalized = message.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
@@ -1558,6 +1560,11 @@ class SupaCloudWorker {
1558
1560
  workerId;
1559
1561
  concurrency;
1560
1562
  pollIntervalMs;
1563
+ shutdownTimeoutMs;
1564
+ work = new PendingWorkRegistry;
1565
+ get pendingWork() {
1566
+ return this.work;
1567
+ }
1561
1568
  executor;
1562
1569
  observer;
1563
1570
  onError;
@@ -1588,6 +1595,7 @@ class SupaCloudWorker {
1588
1595
  }));
1589
1596
  this.concurrency = this.capturePositiveInteger(options.concurrency, 1, 1000, "concurrency");
1590
1597
  this.pollIntervalMs = this.captureNonNegativeInteger(options.pollIntervalMs, 1000, 300000, "pollIntervalMs");
1598
+ this.shutdownTimeoutMs = this.captureNonNegativeInteger(options.shutdownTimeoutMs, 5000, 2147483647, "shutdownTimeoutMs");
1591
1599
  this.executor = options.executor;
1592
1600
  this.observer = options.onExecution;
1593
1601
  this.onError = options.onError;
@@ -1640,11 +1648,13 @@ class SupaCloudWorker {
1640
1648
  return;
1641
1649
  if (this.stateValue === "starting" && this.startPromise)
1642
1650
  return this.startPromise;
1643
- if (this.stateValue === "stopping" && this.stopPromise)
1644
- await this.stopPromise;
1651
+ if (this.stateValue === "stopping")
1652
+ await this.stop();
1645
1653
  this.stopRequested = false;
1654
+ if (this.work.closed)
1655
+ this.work = new PendingWorkRegistry;
1646
1656
  this.stateValue = "starting";
1647
- const startPromise = Promise.resolve().then(() => this.initialize()).then(async () => {
1657
+ const startPromise = this.work.run({ name: "worker.startup", kind: "startup" }, () => Promise.resolve().then(() => this.initialize())).then(async () => {
1648
1658
  if (this.stopRequested || this.stateValue !== "starting") {
1649
1659
  await this.releaseServices();
1650
1660
  return;
@@ -1667,9 +1677,12 @@ class SupaCloudWorker {
1667
1677
  return this.stopPromise;
1668
1678
  this.stopRequested = true;
1669
1679
  this.stateValue = "stopping";
1670
- this.claimController?.abort(new DOMException("Worker stopped", "AbortError"));
1680
+ this.work.close();
1671
1681
  const startPromise = this.startPromise;
1672
- this.stopPromise = (async () => {
1682
+ this.stopPromise = Promise.resolve().then(async () => {
1683
+ this.claimController?.abort(new DOMException("Worker stopped", "AbortError"));
1684
+ this.work.dispose();
1685
+ await this.work.waitForIdle({ timeoutMs: this.shutdownTimeoutMs });
1673
1686
  await startPromise?.catch(() => {
1674
1687
  return;
1675
1688
  });
@@ -1681,7 +1694,10 @@ class SupaCloudWorker {
1681
1694
  this.loopPromise = undefined;
1682
1695
  this.startPromise = undefined;
1683
1696
  this.stopPromise = undefined;
1684
- })();
1697
+ }).catch((error) => {
1698
+ this.stopPromise = undefined;
1699
+ throw error;
1700
+ });
1685
1701
  return this.stopPromise;
1686
1702
  }
1687
1703
  async runOnce() {
@@ -1692,20 +1708,22 @@ class SupaCloudWorker {
1692
1708
  if (!controller)
1693
1709
  throw new WorkerRegistrationError("WORKER_NOT_STARTED", "Worker claim lifecycle is unavailable");
1694
1710
  controller.signal.throwIfAborted();
1695
- const claim = await this.transport.claim(controller.signal);
1696
- if (claim === null)
1697
- return null;
1698
- return this.processClaim(claim);
1711
+ return this.work.run({ name: "worker.claim", kind: "job" }, async (signal) => {
1712
+ const claim = await this.transport.claim(controller.signal);
1713
+ return claim === null ? null : this.executeClaim(claim, signal);
1714
+ });
1699
1715
  }
1700
1716
  async processClaim(rawClaim) {
1717
+ return this.work.run({ name: "worker.job", kind: "job" }, (signal) => this.executeClaim(rawClaim, signal));
1718
+ }
1719
+ async executeClaim(rawClaim, signal) {
1701
1720
  const claim = safeClaim(this.mapClaim(rawClaim));
1702
1721
  const entry = this.registry.get(claim.jobName);
1703
- const controller = new AbortController;
1704
1722
  const context = Object.freeze({
1705
1723
  jobId: claim.id,
1706
1724
  jobName: claim.jobName,
1707
1725
  attempt: claim.attempt ?? 1,
1708
- signal: controller.signal
1726
+ signal: new AbortController().signal
1709
1727
  });
1710
1728
  if (!entry) {
1711
1729
  const receipt = await this.failClaim(rawClaim, claim, new WorkerRegistrationError("WORKER_INVALID_JOB", `Job "${claim.jobName}" is not registered`), context);
@@ -1716,7 +1734,7 @@ class SupaCloudWorker {
1716
1734
  if (this.stateValue !== "running" && this.stateValue !== "stopping") {
1717
1735
  throw new WorkerRegistrationError("WORKER_NOT_STARTED", "Worker is not active");
1718
1736
  }
1719
- const requestContext = claim.requestContext ?? await this.requestContextFactory(claim, controller.signal);
1737
+ const requestContext = claim.requestContext ?? await this.requestContextFactory(claim, signal);
1720
1738
  const moduleServices = this.moduleServices.get(entry.module.name);
1721
1739
  if (!moduleServices)
1722
1740
  throw new WorkerRegistrationError("WORKER_NOT_STARTED", "Worker services are unavailable");
@@ -1776,10 +1794,12 @@ class SupaCloudWorker {
1776
1794
  await Promise.race(this.inFlight).catch((error) => this.reportError(error));
1777
1795
  continue;
1778
1796
  }
1797
+ const complete = this.work.add({ name: "worker.claim", kind: "job" });
1779
1798
  let claim;
1780
1799
  try {
1781
1800
  claim = await this.transport.claim(signal);
1782
1801
  } catch (error) {
1802
+ complete();
1783
1803
  if (signal.aborted)
1784
1804
  break;
1785
1805
  await this.reportError(error);
@@ -1789,12 +1809,13 @@ class SupaCloudWorker {
1789
1809
  continue;
1790
1810
  }
1791
1811
  if (claim === null) {
1812
+ complete();
1792
1813
  await delay(this.pollIntervalMs, signal).catch(() => {
1793
1814
  return;
1794
1815
  });
1795
1816
  continue;
1796
1817
  }
1797
- const execution = this.processClaim(claim).catch((error) => this.reportError(error));
1818
+ const execution = this.executeClaim(claim, this.work.signal).catch((error) => this.reportError(error)).finally(complete);
1798
1819
  this.inFlight.add(execution);
1799
1820
  execution.finally(() => this.inFlight.delete(execution));
1800
1821
  }
@@ -2293,7 +2314,7 @@ function assertDeclaredResponseStatus(route, value, configuredStatus) {
2293
2314
  return;
2294
2315
  throw new ApplicationError("Response validation failed", { status: 500, code: "RESPONSE_CONTRACT_UNDECLARED" });
2295
2316
  }
2296
- function createModulePlugin(compiled, services, ctxFactory = defaultRequestContext, options = {}, imported = {}) {
2317
+ function createModulePlugin(compiled, services, ctxFactory = defaultRequestContext, options = {}, imported = {}, pendingWork) {
2297
2318
  const supportedMethods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
2298
2319
  const supportedFields = new Set([
2299
2320
  "method",
@@ -2352,29 +2373,35 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
2352
2373
  }
2353
2374
  }
2354
2375
  const requestContexts = new WeakMap;
2376
+ const requestCompletions = new WeakMap;
2355
2377
  const governanceExecutor = options.commandGovernance ? createCommandExecutor(options.commandGovernance, options.onExecution) : undefined;
2356
2378
  const commandExecutor = options.commandExecutor && governanceExecutor ? composeCommandExecutors(options.commandExecutor, governanceExecutor) : options.commandExecutor ?? governanceExecutor;
2357
2379
  const plugin = mountHttp(options.http, `supacloud:${compiled.name}`, options.normalize ?? true).derive(async (context) => {
2358
- const requestContext = await ctxFactory(context.request, context);
2380
+ if (pendingWork?.closed)
2381
+ throw new ApplicationError("Application is stopping", { status: 503, code: "APPLICATION_STOPPING" });
2382
+ const complete = pendingWork?.add({ name: "http.request", kind: "request" });
2383
+ if (complete)
2384
+ requestCompletions.set(context.request, complete);
2385
+ const signal = pendingWork ? AbortSignal.any([context.request.signal, pendingWork.signal]) : context.request.signal;
2386
+ const requestContext = await ctxFactory(context.request, context, signal);
2359
2387
  requestContexts.set(context.request, requestContext);
2360
2388
  return { services, requestContext };
2361
2389
  });
2362
2390
  const createRequestScope = compiled.createRequestScope;
2363
2391
  const requestScopes = new WeakMap;
2364
- if (compiled.destroyRequestScope) {
2365
- const destroyRequestScope = compiled.destroyRequestScope;
2366
- plugin.afterResponse(async ({ request }) => {
2367
- const scope = requestScopes.get(request);
2368
- if (!scope)
2369
- return;
2370
- requestScopes.delete(request);
2371
- try {
2372
- await destroyRequestScope(scope);
2373
- } catch (error) {
2374
- console.error(`supacloud: request scope cleanup failed for "${compiled.name}"`, error);
2375
- }
2376
- });
2377
- }
2392
+ plugin.afterResponse(async ({ request }) => {
2393
+ const scope = requestScopes.get(request);
2394
+ requestScopes.delete(request);
2395
+ try {
2396
+ if (scope)
2397
+ await compiled.destroyRequestScope?.(scope);
2398
+ } catch (error) {
2399
+ console.error(`supacloud: request scope cleanup failed for "${compiled.name}"`, error);
2400
+ } finally {
2401
+ requestCompletions.get(request)?.();
2402
+ requestCompletions.delete(request);
2403
+ }
2404
+ });
2378
2405
  plugin.error(async ({ error, request }) => {
2379
2406
  const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code.toUpperCase().replaceAll("-", "_") : "UNKNOWN";
2380
2407
  const context = {
@@ -2682,25 +2709,102 @@ function isPublicApplicationError(error) {
2682
2709
  return error.expose === true && typeof error.status === "number" && Number.isInteger(error.status) && error.status >= 400 && error.status <= 599 && typeof error.code === "string" && error.code.length > 0;
2683
2710
  }
2684
2711
  function createApplication(options) {
2712
+ const pendingWork = new PendingWorkRegistry2(options.pendingWork ? { capacity: options.pendingWork.capacity } : {});
2685
2713
  const app = new Elysia3({ name: options.name ?? "supacloud:app", normalize: options.normalize ?? true }).use(contextPlugin(options.http).as("global"));
2686
2714
  if (options.documentation)
2687
2715
  app.use(createDocumentationPlugin(options.documentation));
2688
2716
  const configuredContextFactory = options.requestContext ?? defaultRequestContext;
2689
2717
  const contextCache = new WeakMap;
2690
- const ctxFactory = (request, context) => {
2718
+ const ctxFactory = (request, context, signal) => {
2691
2719
  const cached = contextCache.get(request);
2692
2720
  if (cached)
2693
2721
  return cached;
2694
- const pending = Promise.resolve(configuredContextFactory(request, context));
2722
+ const pending = Promise.resolve().then(() => configuredContextFactory(request, context, signal));
2695
2723
  contextCache.set(request, pending);
2696
2724
  return pending;
2697
2725
  };
2698
2726
  const imported = {};
2727
+ const moduleServices = [];
2699
2728
  for (const module of options.modules ?? []) {
2700
2729
  const services = module.createServices(options.deps ?? {}, imported);
2701
2730
  imported[module.name] = services;
2702
- app.use(createModulePlugin(module, services, ctxFactory, { ...options, http: undefined }, imported));
2703
- }
2731
+ moduleServices.push({ module, services });
2732
+ app.use(createModulePlugin(module, services, ctxFactory, { ...options, http: undefined }, imported, pendingWork));
2733
+ }
2734
+ let initialized = false;
2735
+ let destroyed = false;
2736
+ let initializationPromise;
2737
+ let destructionPromise;
2738
+ const lifecycle = {
2739
+ get initialized() {
2740
+ return initialized;
2741
+ },
2742
+ get destroyed() {
2743
+ return destroyed;
2744
+ },
2745
+ pendingWork,
2746
+ waitForIdle(options) {
2747
+ return pendingWork.waitForIdle(options);
2748
+ },
2749
+ initialize() {
2750
+ if (destroyed)
2751
+ return Promise.reject(new Error("Application has already been destroyed"));
2752
+ if (initializationPromise)
2753
+ return initializationPromise;
2754
+ const startup = pendingWork.run({ name: "application.startup", kind: "startup" }, () => Promise.resolve().then(async () => {
2755
+ for (const { module, services } of moduleServices) {
2756
+ await module.initializeServices?.(services);
2757
+ }
2758
+ initialized = !destroyed;
2759
+ })).catch(async (error) => {
2760
+ try {
2761
+ await lifecycle.destroy();
2762
+ } catch (cleanupError) {
2763
+ throw new AggregateError([error, cleanupError], "Application initialization failed and cleanup failed");
2764
+ }
2765
+ throw error;
2766
+ });
2767
+ initializationPromise = startup;
2768
+ return startup;
2769
+ },
2770
+ destroy() {
2771
+ if (destructionPromise)
2772
+ return destructionPromise;
2773
+ destroyed = true;
2774
+ pendingWork.close();
2775
+ destructionPromise = Promise.resolve().then(async () => {
2776
+ pendingWork.dispose();
2777
+ const errors = [];
2778
+ try {
2779
+ await pendingWork.waitForIdle({ timeoutMs: options.pendingWork?.shutdownTimeoutMs ?? 5000 });
2780
+ } catch (error) {
2781
+ if (error instanceof PendingWorkTimeoutError)
2782
+ destructionPromise = undefined;
2783
+ throw error;
2784
+ }
2785
+ pendingWork.dispose();
2786
+ for (const { module, services } of [...moduleServices].reverse()) {
2787
+ try {
2788
+ await module.destroyServices?.(services);
2789
+ } catch (error) {
2790
+ errors.push(error);
2791
+ }
2792
+ }
2793
+ if (errors.length > 0) {
2794
+ throw new AggregateError(errors, "Application destruction failed");
2795
+ }
2796
+ });
2797
+ return destructionPromise;
2798
+ }
2799
+ };
2800
+ Object.defineProperties(app, {
2801
+ initialized: { enumerable: true, configurable: false, get: () => lifecycle.initialized },
2802
+ destroyed: { enumerable: true, configurable: false, get: () => lifecycle.destroyed },
2803
+ pendingWork: { enumerable: true, configurable: false, value: pendingWork },
2804
+ waitForIdle: { enumerable: true, configurable: false, value: lifecycle.waitForIdle },
2805
+ initialize: { enumerable: true, configurable: false, value: lifecycle.initialize },
2806
+ destroy: { enumerable: true, configurable: false, value: lifecycle.destroy }
2807
+ });
2704
2808
  return app;
2705
2809
  }
2706
2810
  function createTestApp(options) {
@@ -38,6 +38,6 @@ export declare function createWebhookMigrationExample(options: {
38
38
  schemas: {};
39
39
  response: {};
40
40
  error: [];
41
- }>;
41
+ }> & import("./index").ApplicationLifecycle;
42
42
  modules: import("./fixtures/webhook-generated/application").CompiledApplicationModule[];
43
43
  };
package/dist/worker.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { PendingWorkRegistry } from "@supacloud/app/runtime";
1
2
  import { type CompiledJob, type CompiledModule, type ExecutionObserver, type JobExecutor } from "./index";
2
3
  export type WorkerState = "idle" | "starting" | "running" | "stopping" | "stopped";
3
4
  /** The transport-specific claim is mapped to this small execution contract. */
@@ -76,6 +77,8 @@ export interface WorkerOptions<TClaim = WorkerClaim, TReceipt = unknown> {
76
77
  workerId?: string;
77
78
  concurrency?: number;
78
79
  pollIntervalMs?: number;
80
+ /** Maximum drain observation; timeout keeps services owned until a later stop succeeds. */
81
+ shutdownTimeoutMs?: number;
79
82
  executor?: JobExecutor;
80
83
  onExecution?: ExecutionObserver;
81
84
  onError?: (error: unknown) => void | Promise<void>;
@@ -111,6 +114,9 @@ export declare class SupaCloudWorker<TClaim = WorkerClaim, TReceipt = unknown> {
111
114
  private readonly workerId;
112
115
  private readonly concurrency;
113
116
  private readonly pollIntervalMs;
117
+ private readonly shutdownTimeoutMs;
118
+ private work;
119
+ get pendingWork(): PendingWorkRegistry;
114
120
  private readonly executor;
115
121
  private readonly observer;
116
122
  private readonly onError;
@@ -139,6 +145,7 @@ export declare class SupaCloudWorker<TClaim = WorkerClaim, TReceipt = unknown> {
139
145
  runOnce(): Promise<WorkerRunResult<TReceipt> | null>;
140
146
  /** Process a host-provided claim without performing another claim operation. */
141
147
  processClaim(rawClaim: TClaim): Promise<WorkerRunResult<TReceipt>>;
148
+ private executeClaim;
142
149
  private failClaim;
143
150
  private initialize;
144
151
  private releaseServices;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/elysia",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Elysia runtime adapter for SupaCloud compiled modules: application/request scopes, route registration and validation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,32 +42,32 @@
42
42
  "dependency-injection",
43
43
  "module"
44
44
  ],
45
- "license": "AGPL-3.0-only",
45
+ "license": "Apache-2.0",
46
46
  "repository": {
47
47
  "type": "git",
48
48
  "url": "https://github.com/vibeunion/supacloud.git",
49
49
  "directory": "packages/elysia"
50
50
  },
51
51
  "dependencies": {
52
- "@supacloud/app": "0.19.0",
53
- "@supacloud/contracts": "0.6.0",
52
+ "@supacloud/app": "0.21.0",
53
+ "@supacloud/contracts": "0.8.0",
54
54
  "exact-mirror": "1.2.6",
55
55
  "jose": "^6.2.11",
56
56
  "typebox": "1.3.34"
57
57
  },
58
58
  "overrides": {
59
- "@supacloud/delivery": "0.3.0",
60
- "@supacloud/contracts": "0.6.0",
61
- "@supacloud/commands": "0.8.0"
59
+ "@supacloud/delivery": "0.4.0",
60
+ "@supacloud/contracts": "0.8.0",
61
+ "@supacloud/commands": "0.9.0"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "elysia": "2.0.0-beta.19"
65
65
  },
66
66
  "devDependencies": {
67
- "@supacloud/compiler": "0.28.0",
68
- "@supacloud/delivery": "0.3.0",
69
- "@supacloud/commands": "0.8.0",
70
- "@supacloud/db": "0.10.0",
67
+ "@supacloud/compiler": "0.30.0",
68
+ "@supacloud/delivery": "0.4.0",
69
+ "@supacloud/commands": "0.9.0",
70
+ "@supacloud/db": "0.11.0",
71
71
  "@types/bun": "^1.4.2",
72
72
  "elysia": "2.0.0-beta.19",
73
73
  "typescript": "^7.0.2"