@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/LICENSE +202 -661
- package/NOTICE +8 -13
- package/README.md +25 -2
- package/dist/framework.d.ts +1 -1
- package/dist/index.d.ts +26 -4
- package/dist/index.js +138 -34
- package/dist/webhook-migration-example.d.ts +1 -1
- package/dist/worker.d.ts +7 -0
- package/package.json +11 -11
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
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
package/dist/framework.d.ts
CHANGED
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
|
|
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
|
|
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"
|
|
1644
|
-
await this.
|
|
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.
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
return null;
|
|
1698
|
-
|
|
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:
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2365
|
-
const
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
if (
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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.
|
|
53
|
-
"@supacloud/contracts": "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.
|
|
60
|
-
"@supacloud/contracts": "0.
|
|
61
|
-
"@supacloud/commands": "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.
|
|
68
|
-
"@supacloud/delivery": "0.
|
|
69
|
-
"@supacloud/commands": "0.
|
|
70
|
-
"@supacloud/db": "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"
|