@supacloud/elysia 0.9.0 → 0.14.1

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/dist/index.js CHANGED
@@ -1,6 +1,503 @@
1
1
  // src/index.ts
2
- import { Elysia } from "elysia";
2
+ import { Elysia as Elysia2 } from "elysia";
3
+ import { CommandError as CommandError2, decodeCommandPreview } from "@supacloud/contracts";
4
+ import {
5
+ provideToken,
6
+ runInRequestContext
7
+ } from "@supacloud/app";
8
+ import { REQUEST_CONTEXT } from "@supacloud/app";
9
+
10
+ // src/command-errors.ts
11
+ function commandErrorStatus(code) {
12
+ switch (code) {
13
+ case "COMMAND_INPUT_INVALID":
14
+ return 400;
15
+ case "COMMAND_REJECTED":
16
+ return 403;
17
+ case "COMMAND_IDEMPOTENCY_CONFLICT":
18
+ return 409;
19
+ case "COMMAND_INPUT_EXPIRED":
20
+ return 410;
21
+ case "COMMAND_UNAVAILABLE":
22
+ case "COMMAND_RECEIPT_INVALID":
23
+ case "COMMAND_OUTCOME_UNKNOWN":
24
+ return 503;
25
+ }
26
+ }
27
+
28
+ // src/execution.ts
29
+ async function observeExecution(observer, event, next) {
30
+ const emit = (phase, durationMs) => {
31
+ if (!observer)
32
+ return;
33
+ try {
34
+ Promise.resolve(observer(Object.freeze({
35
+ ...event,
36
+ phase,
37
+ ...durationMs === undefined ? {} : { durationMs }
38
+ }))).catch(() => {});
39
+ } catch {}
40
+ };
41
+ const started = performance.now();
42
+ emit("started");
43
+ try {
44
+ const result = await next();
45
+ emit("succeeded", performance.now() - started);
46
+ return result;
47
+ } catch (error) {
48
+ emit("failed", performance.now() - started);
49
+ throw error;
50
+ }
51
+ }
52
+ function executionRequestId(context) {
53
+ if (!context || typeof context !== "object" || !("requestId" in context))
54
+ return;
55
+ const value = context.requestId;
56
+ return typeof value === "string" && /^[A-Za-z0-9._:-]{1,256}$/.test(value) ? value : undefined;
57
+ }
58
+ function executionTrace(context) {
59
+ const requestId = executionRequestId(context);
60
+ return requestId === undefined ? {} : { requestId };
61
+ }
3
62
 
63
+ // src/schema_contract.ts
64
+ import {
65
+ getSchemaValidator,
66
+ StatusMap,
67
+ ElysiaCustomStatusResponse
68
+ } from "elysia";
69
+ function defineRouteContract(schemas) {
70
+ return Object.freeze({ ...schemas });
71
+ }
72
+ var TYPEBOX_KIND = Symbol.for("TypeBox.Kind");
73
+ var RESPONSE_STATUS_MIN = 100;
74
+ var RESPONSE_STATUS_MAX = 599;
75
+ var RESPONSE_FAMILY_PATTERN = /^([1-5])(?:xx|XX)$/;
76
+ var RESPONSE_STATUS_PATTERN = /^[1-5]\d{2}$/;
77
+ function isSchema(value) {
78
+ return value !== null && typeof value === "object" && ((TYPEBOX_KIND in value) || ("~standard" in value));
79
+ }
80
+ function isJsonSchemaObject(value) {
81
+ if (value === null || typeof value !== "object" || Array.isArray(value))
82
+ return false;
83
+ return [
84
+ "$schema",
85
+ "$id",
86
+ "$ref",
87
+ "$defs",
88
+ "definitions",
89
+ "type",
90
+ "properties",
91
+ "patternProperties",
92
+ "required",
93
+ "items",
94
+ "prefixItems",
95
+ "contains",
96
+ "allOf",
97
+ "anyOf",
98
+ "oneOf",
99
+ "not",
100
+ "enum",
101
+ "const",
102
+ "additionalProperties",
103
+ "minLength",
104
+ "maxLength",
105
+ "minimum",
106
+ "maximum",
107
+ "format",
108
+ "pattern"
109
+ ].some((key) => (key in value));
110
+ }
111
+ function invalidResponseSelector(selector) {
112
+ throw new TypeError(`Unsupported response selector "${selector}". Use an HTTP status (100-599), ` + "a status family such as 4XX/5XX, or default.");
113
+ }
114
+ function conflictingResponseSchemas() {
115
+ throw new TypeError("Route contracts cannot declare both response and responses. " + "Remove response and use responses: { 200: Schema }.");
116
+ }
117
+ function duplicateResponseSelector(selector, existing) {
118
+ throw new TypeError(`Duplicate response selectors "${existing}" and "${selector}" differ only by case. ` + `Keep one ${selector.toUpperCase()} selector.`);
119
+ }
120
+ function responseSchemaForStatus(responses, status) {
121
+ const exact = Object.hasOwn(responses, status) ? responses[status] : Object.hasOwn(responses, String(status)) ? responses[String(status)] : undefined;
122
+ if (exact !== undefined || Object.hasOwn(responses, status) || Object.hasOwn(responses, String(status))) {
123
+ return exact;
124
+ }
125
+ const family = `${Math.floor(status / 100)}XX`;
126
+ if (Object.hasOwn(responses, family))
127
+ return responses[family];
128
+ const lowerFamily = family.toLowerCase();
129
+ if (Object.hasOwn(responses, lowerFamily))
130
+ return responses[lowerFamily];
131
+ return Object.hasOwn(responses, "default") ? responses.default : undefined;
132
+ }
133
+ function responseStatusDeclared(responses, status) {
134
+ return responseSchemaForStatus(responses, status) !== undefined || Object.hasOwn(responses, status) || Object.hasOwn(responses, String(status));
135
+ }
136
+ function responseStatusOf(value, configuredStatus) {
137
+ if (value instanceof Response)
138
+ return value.status;
139
+ if (value instanceof ElysiaCustomStatusResponse) {
140
+ const code = value.code;
141
+ if (typeof code === "number" && Number.isInteger(code) && code >= RESPONSE_STATUS_MIN && code <= RESPONSE_STATUS_MAX) {
142
+ return code;
143
+ }
144
+ }
145
+ if (typeof configuredStatus === "number")
146
+ return configuredStatus;
147
+ if (typeof configuredStatus === "string") {
148
+ const mapped = StatusMap[configuredStatus];
149
+ if (typeof mapped === "number")
150
+ return mapped;
151
+ }
152
+ return 200;
153
+ }
154
+ function assertResponseStatusDeclared(responses, status) {
155
+ if (responseStatusDeclared(responses, status))
156
+ return;
157
+ throw new TypeError("Response validation failed");
158
+ }
159
+ function toElysiaResponseMap(responses) {
160
+ const exact = new Map;
161
+ const families = new Map;
162
+ const familySelectors = new Map;
163
+ let defaultSchema;
164
+ let hasDefault = false;
165
+ let requiresExpansion = false;
166
+ for (const [selector, schema] of Object.entries(responses)) {
167
+ if (RESPONSE_STATUS_PATTERN.test(selector)) {
168
+ exact.set(Number(selector), schema);
169
+ continue;
170
+ }
171
+ if (selector === "default") {
172
+ defaultSchema = schema;
173
+ hasDefault = true;
174
+ requiresExpansion = true;
175
+ continue;
176
+ }
177
+ const family = RESPONSE_FAMILY_PATTERN.exec(selector);
178
+ if (family) {
179
+ const canonicalSelector = selector.toLowerCase();
180
+ const existingSelector = familySelectors.get(canonicalSelector);
181
+ if (existingSelector !== undefined)
182
+ duplicateResponseSelector(selector, existingSelector);
183
+ familySelectors.set(canonicalSelector, selector);
184
+ families.set(Number(family[1]), schema);
185
+ requiresExpansion = true;
186
+ continue;
187
+ }
188
+ invalidResponseSelector(selector);
189
+ }
190
+ if (!requiresExpansion)
191
+ return responses;
192
+ const expanded = {};
193
+ if (hasDefault) {
194
+ for (let status = RESPONSE_STATUS_MIN;status <= RESPONSE_STATUS_MAX; status++) {
195
+ expanded[status] = defaultSchema;
196
+ }
197
+ }
198
+ for (const [family, schema] of families) {
199
+ const first = family * 100;
200
+ for (let status = first;status < first + 100; status++)
201
+ expanded[status] = schema;
202
+ }
203
+ for (const [status, schema] of exact)
204
+ expanded[status] = schema;
205
+ return expanded;
206
+ }
207
+ function toElysiaResponseSchema(response) {
208
+ if (typeof response === "string" || isSchema(response) || isJsonSchemaObject(response))
209
+ return response;
210
+ if (response === null || typeof response !== "object" || Array.isArray(response))
211
+ return response;
212
+ return toElysiaResponseMap(response);
213
+ }
214
+ function toElysiaRouteSchema(contract) {
215
+ if (contract.response !== undefined && contract.responses !== undefined) {
216
+ conflictingResponseSchemas();
217
+ }
218
+ const schema = {};
219
+ if (contract.body !== undefined)
220
+ schema.body = contract.body;
221
+ if (contract.params !== undefined)
222
+ schema.params = contract.params;
223
+ if (contract.query !== undefined)
224
+ schema.query = contract.query;
225
+ if (contract.headers !== undefined)
226
+ schema.headers = contract.headers;
227
+ if (contract.cookie !== undefined)
228
+ schema.cookie = contract.cookie;
229
+ if (contract.responses !== undefined)
230
+ schema.response = toElysiaResponseMap(contract.responses);
231
+ else if (contract.response !== undefined)
232
+ schema.response = toElysiaResponseSchema(contract.response);
233
+ return schema;
234
+ }
235
+ function defineElysiaRoute(method, path, contract, handler) {
236
+ toElysiaRouteSchema(contract);
237
+ const stableContract = Object.isFrozen(contract) ? contract : Object.freeze(contract);
238
+ return Object.freeze({
239
+ method,
240
+ path,
241
+ contract: stableContract,
242
+ handler
243
+ });
244
+ }
245
+ function registerElysiaRoute(app, route) {
246
+ const handler = async (context) => {
247
+ const value = await route.handler(context);
248
+ if (route.contract.responses !== undefined) {
249
+ const status = responseStatusOf(value, context.set.status);
250
+ assertResponseStatusDeclared(route.contract.responses, status);
251
+ }
252
+ return value;
253
+ };
254
+ app.route(route.method, route.path, handler, toElysiaRouteSchema(route.contract));
255
+ return app;
256
+ }
257
+
258
+ class SchemaContractError extends Error {
259
+ code = "SCHEMA_CONTRACT_INVALID";
260
+ constructor() {
261
+ super("Schema contract validation failed");
262
+ this.name = "SchemaContractError";
263
+ }
264
+ }
265
+ function createSchemaDecoder(schema, options = {}) {
266
+ const validator = getSchemaValidator(schema, {
267
+ coerce: options.coerce ?? false,
268
+ normalize: options.normalize ?? true
269
+ });
270
+ return (value) => {
271
+ try {
272
+ return validator.parse(value);
273
+ } catch {
274
+ throw new SchemaContractError;
275
+ }
276
+ };
277
+ }
278
+ function defineJsonContract(schemas, request, options = {}) {
279
+ return Object.freeze({
280
+ ...schemas,
281
+ input: createSchemaDecoder(schemas.body, options),
282
+ result: createSchemaDecoder(schemas.response, options),
283
+ request
284
+ });
285
+ }
286
+
287
+ // src/documentation.ts
288
+ import { Elysia } from "elysia";
289
+ function normalizePath(value, fallback) {
290
+ const path = value ?? fallback;
291
+ if (!path.trim() || !path.startsWith("/") || path.includes("?") || path.includes("#")) {
292
+ throw new Error(`Documentation path must be an absolute path without query or fragment: ${path}`);
293
+ }
294
+ return path.length > 1 ? path.replace(/\/+$/, "") : path;
295
+ }
296
+ function resolveSource(source) {
297
+ if (typeof source === "function") {
298
+ return Promise.resolve(source());
299
+ }
300
+ return Promise.resolve(source);
301
+ }
302
+ function jsonResponse(document) {
303
+ if (document === null || typeof document !== "object" || Array.isArray(document)) {
304
+ throw new Error("OpenAPI documentation must be a JSON object");
305
+ }
306
+ const serialized = JSON.stringify(document);
307
+ if (serialized === undefined)
308
+ throw new Error("OpenAPI documentation must be JSON-serializable");
309
+ return new Response(serialized, {
310
+ headers: { "content-type": "application/json; charset=utf-8" }
311
+ });
312
+ }
313
+ function textResponse(schema) {
314
+ return new Response(schema, {
315
+ headers: { "content-type": "text/plain; charset=utf-8" }
316
+ });
317
+ }
318
+ function escapeHtml(value) {
319
+ return value.replace(/[&<>"']/g, (character) => ({
320
+ "&": "&amp;",
321
+ "<": "&lt;",
322
+ ">": "&gt;",
323
+ '"': "&quot;",
324
+ "'": "&#39;"
325
+ })[character] ?? character);
326
+ }
327
+ function scriptString(value) {
328
+ return JSON.stringify(value).replace(/</g, "\\u003c");
329
+ }
330
+ function renderViewerPage(title, resourcePath, mode) {
331
+ const parse = mode === "json" ? "const value = await response.json(); output.textContent = JSON.stringify(value, null, 2);" : "output.textContent = await response.text();";
332
+ const safeTitle = escapeHtml(title);
333
+ const safeResourcePath = escapeHtml(resourcePath);
334
+ return `<!doctype html>
335
+ <html lang="en">
336
+ <head>
337
+ <meta charset="utf-8">
338
+ <meta name="viewport" content="width=device-width, initial-scale=1">
339
+ <title>${safeTitle}</title>
340
+ <style>
341
+ :root { color-scheme: light dark; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
342
+ body { margin: 0; padding: 24px; background: Canvas; color: CanvasText; }
343
+ header { display: flex; gap: 16px; align-items: baseline; flex-wrap: wrap; }
344
+ h1 { font: 600 20px system-ui, sans-serif; margin: 0; }
345
+ a { color: LinkText; }
346
+ pre { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; }
347
+ </style>
348
+ </head>
349
+ <body>
350
+ <header><h1>${safeTitle}</h1><a href="${safeResourcePath}">${safeResourcePath}</a></header>
351
+ <pre id="output">Loading...</pre>
352
+ <script>
353
+ const output = document.getElementById("output");
354
+ fetch(${scriptString(resourcePath)}, { headers: { "accept": "${mode === "json" ? "application/json" : "text/plain"}" } })
355
+ .then(async (response) => {
356
+ if (!response.ok) throw new Error("HTTP " + response.status);
357
+ ${parse}
358
+ })
359
+ .catch((error) => { output.textContent = "Unable to load documentation: " + error.message; });
360
+ </script>
361
+ </body>
362
+ </html>`;
363
+ }
364
+ function createDocumentationPlugin(options = {}) {
365
+ const app = new Elysia({ name: "supacloud:documentation" });
366
+ const paths = new Set;
367
+ const reserve = (path) => {
368
+ if (paths.has(path))
369
+ throw new Error(`Duplicate documentation path: ${path}`);
370
+ paths.add(path);
371
+ };
372
+ if (options.openApi) {
373
+ const specPath = normalizePath(options.openApi.specPath, "/openapi.json");
374
+ const uiPath = normalizePath(options.openApi.uiPath, "/docs");
375
+ reserve(specPath);
376
+ reserve(uiPath);
377
+ const title = options.openApi.title ?? "OpenAPI Documentation";
378
+ app.get(specPath, async () => jsonResponse(await resolveSource(options.openApi.document)), {
379
+ detail: { hide: true }
380
+ });
381
+ app.get(uiPath, () => new Response(renderViewerPage(title, specPath, "json"), {
382
+ headers: { "content-type": "text/html; charset=utf-8" }
383
+ }), { detail: { hide: true } });
384
+ }
385
+ if (options.graphql) {
386
+ const schemaPath = normalizePath(options.graphql.schemaPath, "/graphql/schema.graphql");
387
+ const uiPath = normalizePath(options.graphql.uiPath, "/graphql/docs");
388
+ reserve(schemaPath);
389
+ reserve(uiPath);
390
+ const title = options.graphql.title ?? "GraphQL Schema";
391
+ app.get(schemaPath, async () => textResponse(await resolveSource(options.graphql.schema)), {
392
+ detail: { hide: true }
393
+ });
394
+ app.get(uiPath, () => new Response(renderViewerPage(title, schemaPath, "text"), {
395
+ headers: { "content-type": "text/html; charset=utf-8" }
396
+ }), { detail: { hide: true } });
397
+ }
398
+ return app;
399
+ }
400
+ // src/persistent-command.ts
401
+ import {
402
+ CommandError,
403
+ decodeCommandIdentity
404
+ } from "@supacloud/contracts";
405
+ function createPersistentCommandAdapter(command, options) {
406
+ return {
407
+ capabilities: {
408
+ audit: true,
409
+ idempotency: true,
410
+ transaction: command.kind === "transactional",
411
+ boundary: command.kind === "transactional" ? "database" : "external"
412
+ },
413
+ async execute(invocation) {
414
+ try {
415
+ const identity = decodeCommandIdentity(await options.identity(invocation));
416
+ return await command.execute(identity, requireIdempotencyKey(invocation), options.input(invocation));
417
+ } catch (error) {
418
+ if (!(error instanceof CommandError))
419
+ throw error;
420
+ const status = commandErrorStatus(error.code);
421
+ throw new ApplicationError(error.code, { code: error.code, status });
422
+ }
423
+ }
424
+ };
425
+ }
426
+ // src/identity.ts
427
+ import { createRemoteJWKSet, jwtVerify, errors } from "jose";
428
+ function httpsUrl(value) {
429
+ const url = new URL(value);
430
+ if (url.protocol !== "https:" || url.username || url.password || url.hash) {
431
+ throw new TypeError("Identity endpoints must be HTTPS URLs without credentials or fragments");
432
+ }
433
+ return url;
434
+ }
435
+ function createSupAuthRequestContext(options) {
436
+ httpsUrl(options.issuer);
437
+ const jwksUrl = httpsUrl(options.jwksUrl);
438
+ if (!options.audience.trim() || !options.projectId.trim() || !options.clientId?.trim()) {
439
+ throw new TypeError("SupAuth audience, clientId and projectId are required");
440
+ }
441
+ const algorithms = [...options.algorithms ?? ["ES256", "RS256"]];
442
+ if (!algorithms.length || algorithms.some((value) => value !== "ES256" && value !== "RS256")) {
443
+ throw new TypeError("SupAuth requires ES256 or RS256");
444
+ }
445
+ const { issuer, audience, clientId, projectId, resolveAccess } = options;
446
+ const keyResolver = options.keyResolver ?? createRemoteJWKSet(jwksUrl, { timeoutDuration: 5000 });
447
+ return async (request) => {
448
+ const match = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i);
449
+ const token = match?.[1];
450
+ if (!token || token.length > 16384)
451
+ throw unauthenticated();
452
+ let subject;
453
+ try {
454
+ const { payload } = await jwtVerify(token, keyResolver, {
455
+ issuer,
456
+ audience,
457
+ algorithms,
458
+ requiredClaims: ["sub", "exp", "iat"]
459
+ });
460
+ if (typeof payload.sub !== "string" || !payload.sub.trim() || payload.sub.length > 1024 || /[\u0000-\u001f\u007f]/.test(payload.sub)) {
461
+ throw unauthenticated();
462
+ }
463
+ const application = payload.client_id ?? payload.azp;
464
+ if (application !== clientId || payload.role !== "authenticated" || payload.client_id !== undefined && payload.client_id !== clientId || payload.azp !== undefined && payload.azp !== clientId) {
465
+ throw unauthenticated();
466
+ }
467
+ subject = payload.sub;
468
+ } catch (error) {
469
+ if (error instanceof ApplicationError || error instanceof errors.JWTClaimValidationFailed || error instanceof errors.JWTExpired || error instanceof errors.JWTInvalid || error instanceof errors.JWSInvalid || error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JOSEAlgNotAllowed || error instanceof errors.JOSENotSupported || error instanceof errors.JWKSNoMatchingKey)
470
+ throw unauthenticated();
471
+ throw new ApplicationError("Identity verification service unavailable", {
472
+ status: 503,
473
+ code: "AUTHENTICATION_UNAVAILABLE"
474
+ });
475
+ }
476
+ const identity = { authenticated: true, subject, issuer, clientId };
477
+ Object.defineProperty(identity, "accessToken", { value: token, enumerable: false });
478
+ Object.freeze(identity);
479
+ const access = await resolveAccess(identity, request);
480
+ if (!access || access.projectId !== projectId || typeof access.tenantId !== "string" || !access.tenantId.trim() || !Array.isArray(access.permissions) || access.permissions.some((permission) => typeof permission !== "string" || !permission.trim())) {
481
+ throw new ApplicationError("Application access denied", { status: 403, code: "APPLICATION_ACCESS_DENIED" });
482
+ }
483
+ const context = createSupaCloudRequestContext(request);
484
+ return {
485
+ ...context,
486
+ identity,
487
+ access: Object.freeze({
488
+ projectId,
489
+ tenantId: access.tenantId,
490
+ permissions: Object.freeze([...new Set(access.permissions)])
491
+ })
492
+ };
493
+ };
494
+ }
495
+ function unauthenticated() {
496
+ return new ApplicationError("Authenticated user context is required", {
497
+ status: 401,
498
+ code: "AUTHENTICATION_REQUIRED"
499
+ });
500
+ }
4
501
  // src/feature.ts
5
502
  function assertFeatureTransition(spec, state, event) {
6
503
  const transition = Object.hasOwn(spec.transitions, event) ? spec.transitions[event] : undefined;
@@ -347,6 +844,401 @@ function cloneTables(tables) {
347
844
  function cloneValue(value) {
348
845
  return structuredClone(value);
349
846
  }
847
+ // src/worker.ts
848
+ function workerFailureMessage(error) {
849
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Worker job failed";
850
+ const normalized = message.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
851
+ return (normalized || "Worker job failed").slice(0, 4096);
852
+ }
853
+ function createQueueWorkerTransport(options) {
854
+ if (!options.queue || typeof options.queue.receive !== "function" || typeof options.queue.ack !== "function" || typeof options.queue.fail !== "function" || typeof options.decodeClaim !== "function" || typeof options.messageId !== "function") {
855
+ throw new TypeError("Queue worker transport requires queue, decodeClaim and messageId");
856
+ }
857
+ const receiveOptions = options.receive === undefined ? undefined : { ...options.receive };
858
+ return {
859
+ claim: async (signal) => {
860
+ signal.throwIfAborted();
861
+ const message = await options.queue.receive(receiveOptions);
862
+ signal.throwIfAborted();
863
+ return message === null ? null : options.decodeClaim(message);
864
+ },
865
+ ack: (claim) => options.queue.ack(options.messageId(claim)),
866
+ fail: (claim, error) => options.queue.fail(options.messageId(claim), {
867
+ error: workerFailureMessage(error)
868
+ })
869
+ };
870
+ }
871
+
872
+ class WorkerRegistrationError extends Error {
873
+ code;
874
+ constructor(code, message) {
875
+ super(message);
876
+ this.name = "WorkerRegistrationError";
877
+ this.code = code;
878
+ }
879
+ }
880
+
881
+ class WorkerReceiptUnconfirmedError extends Error {
882
+ operation;
883
+ claimId;
884
+ code = "WORKER_RECEIPT_UNCONFIRMED";
885
+ mutationMayHaveApplied = true;
886
+ constructor(operation, claimId) {
887
+ super(`Worker ${operation} receipt could not be confirmed for claim "${claimId}"`);
888
+ this.operation = operation;
889
+ this.claimId = claimId;
890
+ this.name = "WorkerReceiptUnconfirmedError";
891
+ }
892
+ }
893
+ function safeWorkerId(value) {
894
+ const workerId = value ?? `worker-${crypto.randomUUID()}`;
895
+ if (workerId.length === 0 || workerId.length > 256 || /[\u0000-\u001f\u007f]/.test(workerId)) {
896
+ throw new TypeError("Worker workerId must be a safe non-empty string");
897
+ }
898
+ return workerId;
899
+ }
900
+ function safeDescriptorText(value, maxLength) {
901
+ return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength && !/[\u0000-\u001f\u007f]/.test(value);
902
+ }
903
+ function validJobScope(value) {
904
+ return value === "application" || value === "request" || value === "job";
905
+ }
906
+ function validJobMode(value) {
907
+ return value === "task" || value === "workflow";
908
+ }
909
+ function validJobIdempotency(value) {
910
+ return value === "required" || value === "none";
911
+ }
912
+ function validPositiveInteger(value) {
913
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
914
+ }
915
+ function safeClaim(claim) {
916
+ if (!claim || typeof claim !== "object") {
917
+ throw new WorkerRegistrationError("WORKER_INVALID_JOB", "Worker claim is invalid");
918
+ }
919
+ if (typeof claim.id !== "string" || claim.id.length === 0 || claim.id.length > 512 || /[\u0000-\u001f\u007f]/.test(claim.id) || typeof claim.jobName !== "string" || claim.jobName.length === 0 || claim.jobName.length > 256 || /[\u0000-\u001f\u007f]/.test(claim.jobName)) {
920
+ throw new WorkerRegistrationError("WORKER_INVALID_JOB", "Worker claim identity is invalid");
921
+ }
922
+ const attempt = claim.attempt ?? 1;
923
+ if (!Number.isSafeInteger(attempt) || attempt < 1) {
924
+ throw new WorkerRegistrationError("WORKER_INVALID_JOB", "Worker claim attempt is invalid");
925
+ }
926
+ return { ...claim, attempt };
927
+ }
928
+ function delay(milliseconds, signal) {
929
+ if (milliseconds <= 0)
930
+ return Promise.resolve();
931
+ return new Promise((resolve, reject) => {
932
+ let settled = false;
933
+ const finish = (callback) => {
934
+ if (settled)
935
+ return;
936
+ settled = true;
937
+ clearTimeout(timer);
938
+ signal.removeEventListener("abort", onAbort);
939
+ callback();
940
+ };
941
+ const onAbort = () => finish(() => reject(signal.reason ?? new DOMException("Aborted", "AbortError")));
942
+ const timer = setTimeout(() => finish(resolve), milliseconds);
943
+ signal.addEventListener("abort", onAbort, { once: true });
944
+ if (signal.aborted)
945
+ onAbort();
946
+ });
947
+ }
948
+ async function destroyServiceValues(services) {
949
+ const errors = [];
950
+ const seen = new Set;
951
+ for (const value of Object.values(services).reverse()) {
952
+ if (seen.has(value) || !value || typeof value !== "object")
953
+ continue;
954
+ seen.add(value);
955
+ const candidate = value;
956
+ const hook = typeof candidate.onDestroy === "function" ? candidate.onDestroy : typeof candidate.ngOnDestroy === "function" ? candidate.ngOnDestroy : undefined;
957
+ if (!hook)
958
+ continue;
959
+ try {
960
+ await Reflect.apply(hook, value, []);
961
+ } catch (error) {
962
+ errors.push(error);
963
+ }
964
+ }
965
+ if (errors.length > 0)
966
+ throw new AggregateError(errors, "Worker service destruction failed");
967
+ }
968
+
969
+ class SupaCloudWorker {
970
+ registry = new Map;
971
+ modules = [];
972
+ moduleServices = new Map;
973
+ deps;
974
+ transport;
975
+ mapClaim;
976
+ requestContextFactory;
977
+ workerId;
978
+ concurrency;
979
+ pollIntervalMs;
980
+ executor;
981
+ observer;
982
+ onError;
983
+ destroyServices;
984
+ stateValue = "idle";
985
+ claimController;
986
+ loopPromise;
987
+ startPromise;
988
+ stopPromise;
989
+ stopRequested = false;
990
+ inFlight = new Set;
991
+ services = {};
992
+ imported = {};
993
+ constructor(options) {
994
+ if (!options.transport || typeof options.transport.claim !== "function" || typeof options.transport.fail !== "function" || typeof options.transport.ack !== "function" && typeof options.transport.acknowledge !== "function") {
995
+ throw new WorkerRegistrationError("WORKER_TRANSPORT_INVALID", "Worker transport must provide claim, ack and fail");
996
+ }
997
+ this.transport = options.transport;
998
+ this.deps = options.deps ?? {};
999
+ this.workerId = safeWorkerId(options.workerId);
1000
+ this.mapClaim = options.mapClaim ?? ((claim) => claim);
1001
+ this.requestContextFactory = options.requestContext ?? ((claim, signal) => ({
1002
+ workerId: this.workerId,
1003
+ jobId: claim.id,
1004
+ jobName: claim.jobName,
1005
+ attempt: claim.attempt,
1006
+ signal
1007
+ }));
1008
+ this.concurrency = this.capturePositiveInteger(options.concurrency, 1, 1000, "concurrency");
1009
+ this.pollIntervalMs = this.captureNonNegativeInteger(options.pollIntervalMs, 1000, 300000, "pollIntervalMs");
1010
+ this.executor = options.executor;
1011
+ this.observer = options.onExecution;
1012
+ this.onError = options.onError;
1013
+ this.destroyServices = options.destroyServices;
1014
+ for (const module of options.modules ?? [])
1015
+ this.registerModule(module);
1016
+ }
1017
+ get state() {
1018
+ return this.stateValue;
1019
+ }
1020
+ get id() {
1021
+ return this.workerId;
1022
+ }
1023
+ get jobNames() {
1024
+ return Object.freeze([...this.registry.keys()]);
1025
+ }
1026
+ get jobs() {
1027
+ return Object.freeze([...this.registry.values()].map((entry) => entry.job));
1028
+ }
1029
+ registerModule(module) {
1030
+ if (this.stateValue !== "idle" && this.stateValue !== "stopped") {
1031
+ throw new WorkerRegistrationError("WORKER_INVALID_JOB", "Jobs cannot be registered while the worker is active");
1032
+ }
1033
+ if (!module || typeof module !== "object" || typeof module.name !== "string" || module.name.length === 0 || module.name.length > 256 || /[\u0000-\u001f\u007f]/.test(module.name) || typeof module.createServices !== "function" || module.jobs !== undefined && !Array.isArray(module.jobs)) {
1034
+ throw new WorkerRegistrationError("WORKER_INVALID_JOB", "A registered module is invalid");
1035
+ }
1036
+ if (this.modules.some((candidate) => candidate.name === module.name)) {
1037
+ throw new WorkerRegistrationError("WORKER_DUPLICATE_MODULE", `Module "${module.name}" is registered more than once`);
1038
+ }
1039
+ const jobs = module.jobs ?? [];
1040
+ const names = new Set;
1041
+ for (const job of jobs) {
1042
+ if (!job || typeof job !== "object" || !safeDescriptorText(job.className, 256) || !safeDescriptorText(job.name, 256) || !safeDescriptorText(job.serviceKey, 256) || !validJobScope(job.scope) || job.mode !== undefined && !validJobMode(job.mode) || job.idempotency !== undefined && !validJobIdempotency(job.idempotency) || job.timeoutSec !== undefined && !validPositiveInteger(job.timeoutSec) || job.maxAttempts !== undefined && !validPositiveInteger(job.maxAttempts)) {
1043
+ throw new WorkerRegistrationError("WORKER_INVALID_JOB", "A registered Job has invalid identity or execution metadata");
1044
+ }
1045
+ if (this.registry.has(job.name) || names.has(job.name)) {
1046
+ throw new WorkerRegistrationError("WORKER_DUPLICATE_JOB", `Job "${job.name}" is registered more than once`);
1047
+ }
1048
+ names.add(job.name);
1049
+ }
1050
+ this.modules.push(module);
1051
+ for (const job of jobs)
1052
+ this.registry.set(job.name, { module, job });
1053
+ }
1054
+ register(module) {
1055
+ this.registerModule(module);
1056
+ }
1057
+ async start() {
1058
+ if (this.stateValue === "running")
1059
+ return;
1060
+ if (this.stateValue === "starting" && this.startPromise)
1061
+ return this.startPromise;
1062
+ if (this.stateValue === "stopping" && this.stopPromise)
1063
+ await this.stopPromise;
1064
+ this.stopRequested = false;
1065
+ this.stateValue = "starting";
1066
+ const startPromise = Promise.resolve().then(() => this.initialize()).then(async () => {
1067
+ if (this.stopRequested || this.stateValue !== "starting") {
1068
+ await this.releaseServices();
1069
+ return;
1070
+ }
1071
+ this.claimController = new AbortController;
1072
+ this.stateValue = "running";
1073
+ this.loopPromise = this.pollLoop(this.claimController.signal);
1074
+ }).catch(async (error) => {
1075
+ this.stateValue = "stopped";
1076
+ await this.releaseServices();
1077
+ throw error;
1078
+ });
1079
+ this.startPromise = startPromise;
1080
+ return startPromise;
1081
+ }
1082
+ async stop() {
1083
+ if (this.stateValue === "idle" || this.stateValue === "stopped")
1084
+ return;
1085
+ if (this.stopPromise)
1086
+ return this.stopPromise;
1087
+ this.stopRequested = true;
1088
+ this.stateValue = "stopping";
1089
+ this.claimController?.abort(new DOMException("Worker stopped", "AbortError"));
1090
+ const startPromise = this.startPromise;
1091
+ this.stopPromise = (async () => {
1092
+ await startPromise?.catch(() => {
1093
+ return;
1094
+ });
1095
+ await this.loopPromise?.catch((error) => this.reportError(error));
1096
+ await Promise.allSettled([...this.inFlight]);
1097
+ await this.releaseServices();
1098
+ this.stateValue = "stopped";
1099
+ this.claimController = undefined;
1100
+ this.loopPromise = undefined;
1101
+ this.startPromise = undefined;
1102
+ this.stopPromise = undefined;
1103
+ })();
1104
+ return this.stopPromise;
1105
+ }
1106
+ async runOnce() {
1107
+ if (this.stateValue !== "running") {
1108
+ throw new WorkerRegistrationError("WORKER_NOT_STARTED", "Worker must be started before runOnce");
1109
+ }
1110
+ const controller = this.claimController;
1111
+ if (!controller)
1112
+ throw new WorkerRegistrationError("WORKER_NOT_STARTED", "Worker claim lifecycle is unavailable");
1113
+ controller.signal.throwIfAborted();
1114
+ const claim = await this.transport.claim(controller.signal);
1115
+ if (claim === null)
1116
+ return null;
1117
+ return this.processClaim(claim);
1118
+ }
1119
+ async processClaim(rawClaim) {
1120
+ const claim = safeClaim(this.mapClaim(rawClaim));
1121
+ const entry = this.registry.get(claim.jobName);
1122
+ const controller = new AbortController;
1123
+ const context = Object.freeze({
1124
+ jobId: claim.id,
1125
+ jobName: claim.jobName,
1126
+ attempt: claim.attempt ?? 1,
1127
+ signal: controller.signal
1128
+ });
1129
+ if (!entry) {
1130
+ const receipt = await this.failClaim(rawClaim, claim, new WorkerRegistrationError("WORKER_INVALID_JOB", `Job "${claim.jobName}" is not registered`), context);
1131
+ return { claimId: claim.id, jobName: claim.jobName, status: "failed", receipt };
1132
+ }
1133
+ let output;
1134
+ try {
1135
+ if (this.stateValue !== "running" && this.stateValue !== "stopping") {
1136
+ throw new WorkerRegistrationError("WORKER_NOT_STARTED", "Worker is not active");
1137
+ }
1138
+ const requestContext = claim.requestContext ?? await this.requestContextFactory(claim, controller.signal);
1139
+ const moduleServices = this.moduleServices.get(entry.module.name);
1140
+ if (!moduleServices)
1141
+ throw new WorkerRegistrationError("WORKER_NOT_STARTED", "Worker services are unavailable");
1142
+ output = await executeJob(entry.module, moduleServices, entry.job, claim.input, requestContext, this.imported, this.executor, this.observer);
1143
+ } catch (error) {
1144
+ const receipt = await this.failClaim(rawClaim, claim, error, context);
1145
+ return { claimId: claim.id, jobName: claim.jobName, status: "failed", receipt };
1146
+ }
1147
+ const acknowledge = this.transport.ack ?? this.transport.acknowledge;
1148
+ if (!acknowledge)
1149
+ throw new WorkerRegistrationError("WORKER_TRANSPORT_INVALID", "Worker acknowledgement is unavailable");
1150
+ try {
1151
+ const receipt = await acknowledge(rawClaim, output, context);
1152
+ return { claimId: claim.id, jobName: claim.jobName, status: "acknowledged", receipt };
1153
+ } catch {
1154
+ throw new WorkerReceiptUnconfirmedError("ack", claim.id);
1155
+ }
1156
+ }
1157
+ async failClaim(rawClaim, claim, error, context) {
1158
+ try {
1159
+ return await this.transport.fail(rawClaim, error, context);
1160
+ } catch {
1161
+ throw new WorkerReceiptUnconfirmedError("fail", claim.id);
1162
+ }
1163
+ }
1164
+ async initialize() {
1165
+ this.services = {};
1166
+ this.imported = {};
1167
+ this.moduleServices.clear();
1168
+ for (const module of this.modules) {
1169
+ const services = module.createServices(this.deps, this.imported);
1170
+ this.moduleServices.set(module.name, services);
1171
+ this.services = { ...this.services, ...services };
1172
+ this.imported[module.name] = services;
1173
+ }
1174
+ }
1175
+ async releaseServices() {
1176
+ const services = this.services;
1177
+ const imported = this.imported;
1178
+ this.services = {};
1179
+ this.imported = {};
1180
+ this.moduleServices.clear();
1181
+ if (Object.keys(services).length === 0)
1182
+ return;
1183
+ try {
1184
+ if (this.destroyServices)
1185
+ await this.destroyServices(services, imported);
1186
+ else
1187
+ await destroyServiceValues(services);
1188
+ } catch (error) {
1189
+ await this.reportError(error);
1190
+ }
1191
+ }
1192
+ async pollLoop(signal) {
1193
+ while (!signal.aborted && this.stateValue === "running") {
1194
+ if (this.inFlight.size >= this.concurrency) {
1195
+ await Promise.race(this.inFlight).catch((error) => this.reportError(error));
1196
+ continue;
1197
+ }
1198
+ let claim;
1199
+ try {
1200
+ claim = await this.transport.claim(signal);
1201
+ } catch (error) {
1202
+ if (signal.aborted)
1203
+ break;
1204
+ await this.reportError(error);
1205
+ await delay(this.pollIntervalMs, signal).catch(() => {
1206
+ return;
1207
+ });
1208
+ continue;
1209
+ }
1210
+ if (claim === null) {
1211
+ await delay(this.pollIntervalMs, signal).catch(() => {
1212
+ return;
1213
+ });
1214
+ continue;
1215
+ }
1216
+ const execution = this.processClaim(claim).catch((error) => this.reportError(error));
1217
+ this.inFlight.add(execution);
1218
+ execution.finally(() => this.inFlight.delete(execution));
1219
+ }
1220
+ }
1221
+ async reportError(error) {
1222
+ try {
1223
+ await this.onError?.(error);
1224
+ } catch {}
1225
+ }
1226
+ capturePositiveInteger(value, fallback, max, name) {
1227
+ const result = value ?? fallback;
1228
+ if (!Number.isSafeInteger(result) || result < 1 || result > max)
1229
+ throw new TypeError(`Worker ${name} is invalid`);
1230
+ return result;
1231
+ }
1232
+ captureNonNegativeInteger(value, fallback, max, name) {
1233
+ const result = value ?? fallback;
1234
+ if (!Number.isSafeInteger(result) || result < 0 || result > max)
1235
+ throw new TypeError(`Worker ${name} is invalid`);
1236
+ return result;
1237
+ }
1238
+ }
1239
+ function createWorker(options) {
1240
+ return new SupaCloudWorker(options);
1241
+ }
350
1242
 
351
1243
  // src/index.ts
352
1244
  var VERIFIED_JWT_SUBJECT_HEADER = "x-supacloud-jwt-sub";
@@ -361,13 +1253,22 @@ function composeAspects(...aspects) {
361
1253
  return Promise.reject(new Error("next() called multiple times"));
362
1254
  }
363
1255
  index = current;
364
- if (current === active.length)
1256
+ const aspect = active[current];
1257
+ if (!aspect)
365
1258
  return Promise.resolve(next());
366
- return Promise.resolve(active[current](context, () => dispatch(current + 1)));
1259
+ return Promise.resolve(aspect(context, () => dispatch(current + 1)));
367
1260
  };
368
1261
  return dispatch(0);
369
1262
  };
370
1263
  }
1264
+ function observedAspects(aspects, boundary, observer) {
1265
+ return composeAspects(...aspects.map((aspect, index) => (context, next) => observeExecution(observer, {
1266
+ kind: context.kind,
1267
+ operation: context.name,
1268
+ stage: `${boundary}.aspect[${index}]:${aspect.name || "anonymous"}`,
1269
+ ...executionTrace(context.requestContext)
1270
+ }, () => aspect(context, next))));
1271
+ }
371
1272
  function composeCommandExecutors(...executors) {
372
1273
  const active = executors.filter((e) => typeof e === "function");
373
1274
  if (active.length === 0)
@@ -427,6 +1328,19 @@ function validatedJsonResponse(validate, value, init) {
427
1328
  headers.set("content-type", "application/json");
428
1329
  return new Response(body, { ...init, headers });
429
1330
  }
1331
+ function decodeJobContract(schema, value, boundary) {
1332
+ if (schema === undefined)
1333
+ return value;
1334
+ try {
1335
+ return createSchemaDecoder(schema)(value);
1336
+ } catch {
1337
+ const input = boundary === "input";
1338
+ throw new ApplicationError(input ? "Job input contract validation failed" : "Job output contract validation failed", {
1339
+ status: input ? 422 : 500,
1340
+ code: input ? "JOB_INPUT_VALIDATION_ERROR" : "JOB_OUTPUT_VALIDATION_ERROR"
1341
+ });
1342
+ }
1343
+ }
430
1344
  function safeHeaderValue(value, maxLength) {
431
1345
  if (!value || value.length > maxLength || /[\u0000-\u001f\u007f]/.test(value)) {
432
1346
  return;
@@ -445,7 +1359,7 @@ function isAuthenticatedTrustedIdentity(value) {
445
1359
  function bearerToken(request) {
446
1360
  const authorization = request.headers.get("authorization");
447
1361
  const match = authorization?.match(/^Bearer\s+(.+)$/i);
448
- return match ? safeHeaderValue(match[1], 16384) : undefined;
1362
+ return match?.[1] ? safeHeaderValue(match[1], 16384) : undefined;
449
1363
  }
450
1364
  var createSupaCloudRequestContext = (request) => {
451
1365
  const subject = safeHeaderValue(request.headers.get(VERIFIED_JWT_SUBJECT_HEADER), 1024);
@@ -482,9 +1396,31 @@ function missingGovernanceAdapter(command, adapter) {
482
1396
  code: codes[adapter]
483
1397
  });
484
1398
  }
485
- function createCommandExecutor(governance) {
1399
+ function createCommandExecutor(governance, observer) {
486
1400
  return async (invocation, next) => {
487
1401
  const { command } = invocation;
1402
+ for (const mode of [command.transaction, command.idempotency]) {
1403
+ if (mode !== undefined && mode !== "required" && mode !== "none") {
1404
+ throw new ApplicationError("Invalid command governance mode", { code: "COMMAND_MODE_INVALID" });
1405
+ }
1406
+ }
1407
+ if (command.rpc !== undefined) {
1408
+ const adapter = Object.hasOwn(governance.rpc ?? {}, command.rpc) ? governance.rpc?.[command.rpc] : undefined;
1409
+ if (!adapter)
1410
+ throw new ApplicationError("RPC governance adapter unavailable", { code: "COMMAND_RPC_UNAVAILABLE", status: 501 });
1411
+ for (const capability of ["audit", "transaction", "idempotency"]) {
1412
+ const required = capability === "audit" ? !!command.audit : command[capability] === "required";
1413
+ if (required && adapter.capabilities[capability] !== true)
1414
+ throw missingGovernanceAdapter(command, capability);
1415
+ }
1416
+ const event = {
1417
+ kind: "command",
1418
+ operation: command.name,
1419
+ ...executionTrace(invocation.requestContext)
1420
+ };
1421
+ await observeExecution(observer, { ...event, stage: "authorize" }, () => governance.authorize(invocation));
1422
+ return observeExecution(observer, { ...event, stage: `rpc:${command.rpc}` }, () => adapter.execute(invocation, once(next)));
1423
+ }
488
1424
  const audit = command.audit ? governance.audit : undefined;
489
1425
  const transaction = command.transaction === "required" ? governance.transaction : undefined;
490
1426
  const idempotency = command.idempotency === "required" ? governance.idempotency : undefined;
@@ -498,24 +1434,31 @@ function createCommandExecutor(governance) {
498
1434
  throw missingGovernanceAdapter(command, "idempotency");
499
1435
  }
500
1436
  try {
501
- await governance.authorize(invocation);
1437
+ const observe = (stage, next) => observeExecution(observer, {
1438
+ kind: "command",
1439
+ operation: command.name,
1440
+ stage,
1441
+ ...executionTrace(invocation.requestContext)
1442
+ }, next);
1443
+ await observe("authorize", () => governance.authorize(invocation));
502
1444
  let execute = async () => {
503
- const result = await next();
1445
+ const result = await observe("handler", invokeOnce);
504
1446
  if (audit)
505
- await audit.succeeded.call(audit, invocation, result);
1447
+ await observe("audit", () => audit.succeeded.call(audit, invocation, result));
506
1448
  return result;
507
1449
  };
1450
+ const invokeOnce = once(next);
508
1451
  if (command.transaction === "required") {
509
1452
  if (!transaction)
510
1453
  throw missingGovernanceAdapter(command, "transaction");
511
1454
  const inner = execute;
512
- execute = () => Promise.resolve(transaction.call(governance, invocation, inner));
1455
+ execute = () => observe("transaction", () => transaction.call(governance, invocation, once(inner)));
513
1456
  }
514
1457
  if (command.idempotency === "required") {
515
1458
  if (!idempotency)
516
1459
  throw missingGovernanceAdapter(command, "idempotency");
517
1460
  const inner = execute;
518
- execute = () => Promise.resolve(idempotency.call(governance, invocation, inner));
1461
+ execute = () => observe("idempotency", () => idempotency.call(governance, invocation, once(inner)));
519
1462
  }
520
1463
  return await execute();
521
1464
  } catch (error) {
@@ -525,6 +1468,79 @@ function createCommandExecutor(governance) {
525
1468
  }
526
1469
  };
527
1470
  }
1471
+ async function executeCompiledCommand(options) {
1472
+ const matches = options.module.commands?.filter((item) => item.className === options.command) ?? [];
1473
+ if (matches.length !== 1)
1474
+ throw new ApplicationError("Command descriptor missing or ambiguous", { code: "COMMAND_NOT_REGISTERED" });
1475
+ const command = matches[0];
1476
+ if (!command)
1477
+ throw new Error(`Command not found: ${options.command}`);
1478
+ const invocation = {
1479
+ command,
1480
+ input: { body: options.input, params: {}, query: {} },
1481
+ request: options.request,
1482
+ requestContext: options.requestContext,
1483
+ services: options.services ?? {},
1484
+ ...options.scope ? { scope: options.scope } : {}
1485
+ };
1486
+ const aspects = composeAspects(observedAspects(options.module.aspects ?? [], `module:${options.module.name}`, options.observer), observedAspects(command.aspects ?? [], "command", options.observer));
1487
+ const value = await createCommandExecutor(options.governance, options.observer)(invocation, once(() => aspects({
1488
+ kind: "command",
1489
+ name: command.name,
1490
+ input: options.input,
1491
+ request: options.request,
1492
+ requestContext: options.requestContext,
1493
+ services: invocation.services,
1494
+ ...invocation.scope ? { scope: invocation.scope } : {},
1495
+ metadata: command
1496
+ }, once(() => options.handler(options.input)))));
1497
+ return options.decode(value);
1498
+ }
1499
+ async function previewCompiledCommand(options) {
1500
+ const matches = options.module.commands?.filter((item) => item.className === options.command) ?? [];
1501
+ if (matches.length !== 1)
1502
+ throw new ApplicationError("Command descriptor missing or ambiguous", { code: "COMMAND_NOT_REGISTERED" });
1503
+ const command = matches[0];
1504
+ if (!command)
1505
+ throw new Error(`Command not found: ${options.command}`);
1506
+ const invocation = {
1507
+ command,
1508
+ input: { body: options.input, params: {}, query: {} },
1509
+ request: options.request,
1510
+ requestContext: options.requestContext,
1511
+ services: options.services ?? {},
1512
+ ...options.scope ? { scope: options.scope } : {}
1513
+ };
1514
+ try {
1515
+ await options.governance.authorize(invocation);
1516
+ } catch (error) {
1517
+ if (error instanceof ApplicationError && (error.status === 401 || error.status === 403)) {
1518
+ return decodeCommandPreview({
1519
+ command: command.name,
1520
+ allowed: false,
1521
+ blockers: [{ code: error.code, message: error.message }]
1522
+ });
1523
+ }
1524
+ throw error;
1525
+ }
1526
+ const preview = decodeCommandPreview(await options.preview(options.input));
1527
+ if (preview.command !== command.name && preview.command !== command.className) {
1528
+ throw new TypeError("Mismatched command identity");
1529
+ }
1530
+ return preview;
1531
+ }
1532
+ function once(next) {
1533
+ let called = false;
1534
+ return async () => {
1535
+ if (called) {
1536
+ throw new ApplicationError("Command continuation called multiple times", {
1537
+ code: "COMMAND_CONTINUATION_REUSED"
1538
+ });
1539
+ }
1540
+ called = true;
1541
+ return await next();
1542
+ };
1543
+ }
528
1544
  function requireTrustedIdentity(requestContext) {
529
1545
  const context = isRecord(requestContext) ? requestContext : {};
530
1546
  const identity = isTrustedIdentity(context.identity) ? context.identity : undefined;
@@ -554,6 +1570,26 @@ function joinPaths(prefix, path) {
554
1570
  function controllerInstance(value) {
555
1571
  return isRecord(value) ? value : undefined;
556
1572
  }
1573
+ function cookieValues(value) {
1574
+ if (!isRecord(value))
1575
+ return;
1576
+ const result = {};
1577
+ for (const [name, entry] of Object.entries(value)) {
1578
+ result[name] = isRecord(entry) && "value" in entry ? entry.value : entry;
1579
+ }
1580
+ return result;
1581
+ }
1582
+ function assertDeclaredResponseStatus(route, value, configuredStatus) {
1583
+ if (route.responses === undefined)
1584
+ return;
1585
+ const status = responseStatusOf(value, configuredStatus);
1586
+ if (responseStatusDeclared(route.responses, status))
1587
+ return;
1588
+ const transport = route.contract?.response;
1589
+ if ((transport === "binary" || transport === "stream") && status >= 200 && status < 300)
1590
+ return;
1591
+ throw new ApplicationError("Response validation failed", { status: 500, code: "RESPONSE_CONTRACT_UNDECLARED" });
1592
+ }
557
1593
  function createModulePlugin(compiled, services, ctxFactory = defaultRequestContext, options = {}, imported = {}) {
558
1594
  const hasCommandRoutes = compiled.controllers.some((controller) => controller.routes.some((route) => route.command !== undefined));
559
1595
  if (hasCommandRoutes && !options.commandGovernance && !options.commandExecutor) {
@@ -570,9 +1606,9 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
570
1606
  }
571
1607
  }
572
1608
  const requestContexts = new WeakMap;
573
- const governanceExecutor = options.commandGovernance ? createCommandExecutor(options.commandGovernance) : undefined;
1609
+ const governanceExecutor = options.commandGovernance ? createCommandExecutor(options.commandGovernance, options.onExecution) : undefined;
574
1610
  const commandExecutor = options.commandExecutor && governanceExecutor ? composeCommandExecutors(options.commandExecutor, governanceExecutor) : options.commandExecutor ?? governanceExecutor;
575
- const plugin = new Elysia({ name: `supacloud:${compiled.name}` }).decorate("services", services);
1611
+ const plugin = new Elysia2({ name: `supacloud:${compiled.name}`, normalize: options.normalize ?? true }).decorate("services", services);
576
1612
  const createRequestScope = compiled.createRequestScope;
577
1613
  const requestScopes = new WeakMap;
578
1614
  if (compiled.destroyRequestScope) {
@@ -606,88 +1642,98 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
606
1642
  for (const controller of compiled.controllers) {
607
1643
  for (const route of controller.routes) {
608
1644
  const path = joinPaths(controller.path, route.path);
609
- const schema = {};
610
- if (route.body !== undefined)
611
- schema.body = route.body;
612
- if (route.params !== undefined)
613
- schema.params = route.params;
614
- if (route.query !== undefined)
615
- schema.query = route.query;
616
- if (route.response !== undefined)
617
- schema.response = route.response;
1645
+ const schema = toElysiaRouteSchema(route);
618
1646
  const handler = async (ctx) => {
619
1647
  const requestContext = ctx.requestContext ?? await ctxFactory(ctx.request);
620
- const requestScope = createRequestScope ? await createRequestScope(services, requestContext, imported) : undefined;
621
- if (requestScope && compiled.destroyRequestScope)
622
- requestScopes.set(ctx.request, requestScope);
623
- const source = controller.scope === "request" ? requestScope : services;
624
- const instance = controllerInstance(source?.[controller.serviceKey]);
625
- const method = instance?.[route.handler];
626
- if (typeof method !== "function") {
627
- throw new Error(`supacloud: controller "${controller.serviceKey}" has no handler "${route.handler}" in scope "${controller.scope}"`);
628
- }
629
- const input = {
630
- body: ctx.body,
631
- params: ctx.params,
632
- query: ctx.query,
633
- headers: Object.fromEntries(ctx.request.headers.entries()),
634
- context: requestContext,
635
- request: ctx.request,
636
- scope: requestScope,
637
- requestContext
638
- };
639
- const invoke = () => route.invoker ? route.invoker(instance, input) : Reflect.apply(method, instance, [input]);
640
- const routeContext = {
641
- kind: "route",
642
- name: `${route.method} ${path}`,
643
- input,
644
- request: ctx.request,
645
- requestContext,
646
- scope: requestScope,
647
- services,
648
- metadata: route
649
- };
650
- const command = route.command ? commandsByClassName.get(route.command) : undefined;
651
- const commandContext = {
652
- kind: "command",
653
- name: command?.name ?? route.command ?? `${route.method} ${path}`,
654
- input,
655
- request: ctx.request,
656
- requestContext,
657
- scope: requestScope,
658
- services,
659
- metadata: command ?? route
660
- };
661
- const commandAspects = route.command ? commandsByClassName.get(route.command)?.aspects ?? [] : [];
662
- const routePipeline = composeAspects(...route.aspects ?? []);
663
- const commandPipeline = composeAspects(...commandAspects);
664
- const modulePipeline = composeAspects(...compiled.aspects ?? []);
665
- const invokeRoute = () => routePipeline(routeContext, () => route.command ? commandPipeline(commandContext, () => invokeCommand()) : invoke());
666
- const invokeCommand = () => {
667
- if (!route.command)
668
- return invoke();
669
- if (!command) {
670
- throw new ApplicationError(`Command "${route.command}" is not registered`, {
671
- code: "COMMAND_NOT_REGISTERED"
672
- });
1648
+ const execute = async () => {
1649
+ const requestScope = createRequestScope ? await createRequestScope(services, requestContext, imported) : undefined;
1650
+ if (requestScope && compiled.destroyRequestScope)
1651
+ requestScopes.set(ctx.request, requestScope);
1652
+ const source = controller.scope === "request" ? requestScope : services;
1653
+ const instance = controllerInstance(source?.[controller.serviceKey]);
1654
+ const method = instance?.[route.handler];
1655
+ if (typeof method !== "function") {
1656
+ throw new Error(`supacloud: controller "${controller.serviceKey}" has no handler "${route.handler}" in scope "${controller.scope}"`);
673
1657
  }
674
- if (!commandExecutor) {
675
- throw new ApplicationError(`Command "${command.name}" has no executor`, {
676
- status: 501,
677
- code: "COMMAND_EXECUTOR_UNAVAILABLE"
678
- });
679
- }
680
- const invocation = {
681
- command,
1658
+ const normalizedCookie = ctx.cookie === undefined ? undefined : cookieValues(ctx.cookie);
1659
+ const input = {
1660
+ body: ctx.body,
1661
+ params: ctx.params,
1662
+ query: ctx.query,
1663
+ headers: ctx.headers ?? Object.fromEntries(ctx.request.headers.entries()),
1664
+ ...normalizedCookie === undefined ? {} : { cookie: normalizedCookie },
1665
+ context: requestContext,
1666
+ request: ctx.request,
1667
+ ...requestScope ? { scope: requestScope } : {},
1668
+ requestContext
1669
+ };
1670
+ const handlerCall = () => route.invoker ? route.invoker(instance, input) : Reflect.apply(method, instance, [input]);
1671
+ const invoke = once(() => route.command && options.commandGovernance ? handlerCall() : observeExecution(options.onExecution, {
1672
+ kind: route.command ? "command" : "route",
1673
+ operation: route.command ?? `${route.method} ${path}`,
1674
+ stage: "handler",
1675
+ ...executionTrace(requestContext)
1676
+ }, handlerCall));
1677
+ const routeContext = {
1678
+ kind: "route",
1679
+ name: `${route.method} ${path}`,
682
1680
  input,
683
1681
  request: ctx.request,
684
1682
  requestContext,
685
- scope: requestScope,
686
- services
1683
+ ...requestScope ? { scope: requestScope } : {},
1684
+ services,
1685
+ metadata: route
687
1686
  };
688
- return commandExecutor(invocation, invoke);
1687
+ const command = route.command ? commandsByClassName.get(route.command) : undefined;
1688
+ const commandContext = {
1689
+ kind: "command",
1690
+ name: command?.name ?? route.command ?? `${route.method} ${path}`,
1691
+ input,
1692
+ request: ctx.request,
1693
+ requestContext,
1694
+ ...requestScope ? { scope: requestScope } : {},
1695
+ services,
1696
+ metadata: command ?? route
1697
+ };
1698
+ const commandAspects = route.command ? commandsByClassName.get(route.command)?.aspects ?? [] : [];
1699
+ const routePipeline = observedAspects(route.aspects ?? [], "route", options.onExecution);
1700
+ const commandPipeline = observedAspects(commandAspects, "command", options.onExecution);
1701
+ const modulePipeline = observedAspects(compiled.aspects ?? [], `module:${compiled.name}`, options.onExecution);
1702
+ const invokeRoute = () => routePipeline(routeContext, () => route.command ? commandPipeline(commandContext, () => invokeCommand()) : invoke());
1703
+ const invokeCommand = () => {
1704
+ if (!route.command)
1705
+ return invoke();
1706
+ if (!command) {
1707
+ throw new ApplicationError(`Command "${route.command}" is not registered`, {
1708
+ code: "COMMAND_NOT_REGISTERED"
1709
+ });
1710
+ }
1711
+ if (!commandExecutor) {
1712
+ throw new ApplicationError(`Command "${command.name}" has no executor`, {
1713
+ status: 501,
1714
+ code: "COMMAND_EXECUTOR_UNAVAILABLE"
1715
+ });
1716
+ }
1717
+ const invocation = {
1718
+ command,
1719
+ input,
1720
+ request: ctx.request,
1721
+ requestContext,
1722
+ ...requestScope ? { scope: requestScope } : {},
1723
+ services
1724
+ };
1725
+ return observeExecution(options.onExecution, {
1726
+ kind: "command",
1727
+ operation: command.name,
1728
+ stage: "commandExecutor",
1729
+ ...executionTrace(requestContext)
1730
+ }, () => commandExecutor(invocation, invoke));
1731
+ };
1732
+ return modulePipeline(route.command ? commandContext : routeContext, invokeRoute);
689
1733
  };
690
- return modulePipeline(route.command ? commandContext : routeContext, invokeRoute);
1734
+ const result = options.injector ? await runInRequestContext(options.injector, [provideToken(REQUEST_CONTEXT, requestContext)], execute) : await execute();
1735
+ assertDeclaredResponseStatus(route, result, ctx.set.status);
1736
+ return result;
691
1737
  };
692
1738
  switch (route.method) {
693
1739
  case "GET":
@@ -716,7 +1762,8 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
716
1762
  }
717
1763
  return plugin;
718
1764
  }
719
- async function executeJob(compiled, services, job, input, requestContext, imported = {}, executor) {
1765
+ async function executeJob(compiled, services, job, input, requestContext, imported = {}, executor, observer) {
1766
+ const decodedInput = decodeJobContract(job.input, input, "input");
720
1767
  const jobScope = job.scope === "job" && compiled.createJobScope ? await compiled.createJobScope(services, requestContext, imported) : undefined;
721
1768
  try {
722
1769
  const source = job.scope === "job" ? jobScope : services;
@@ -727,23 +1774,35 @@ async function executeJob(compiled, services, job, input, requestContext, import
727
1774
  }
728
1775
  const invocation = {
729
1776
  job,
730
- input,
1777
+ input: decodedInput,
731
1778
  requestContext,
732
- scope: jobScope,
1779
+ ...jobScope ? { scope: jobScope } : {},
733
1780
  services
734
1781
  };
735
1782
  const context = {
736
1783
  kind: "job",
737
1784
  name: job.name,
738
- input,
1785
+ input: decodedInput,
739
1786
  requestContext,
740
- scope: jobScope,
1787
+ ...jobScope ? { scope: jobScope } : {},
741
1788
  services,
742
1789
  metadata: job
743
1790
  };
744
- const invoke = () => Reflect.apply(method, instance, [input]);
745
- const pipeline = composeAspects(...compiled.aspects ?? [], ...job.aspects ?? []);
746
- return await pipeline(context, executor ? () => executor(invocation, invoke) : invoke);
1791
+ const invoke = once(() => observeExecution(observer, {
1792
+ kind: "job",
1793
+ operation: job.name,
1794
+ stage: "handler",
1795
+ ...executionTrace(requestContext)
1796
+ }, () => Reflect.apply(method, instance, [decodedInput])));
1797
+ const pipeline = observedAspects(compiled.aspects ?? [], `module:${compiled.name}`, observer);
1798
+ const jobPipeline = observedAspects(job.aspects ?? [], "job", observer);
1799
+ const result = await pipeline(context, () => jobPipeline(context, () => observeExecution(observer, {
1800
+ kind: "job",
1801
+ operation: job.name,
1802
+ stage: "jobExecutor",
1803
+ ...executionTrace(requestContext)
1804
+ }, executor ? () => executor(invocation, invoke) : invoke)));
1805
+ return decodeJobContract(job.output, result, "output");
747
1806
  } finally {
748
1807
  if (jobScope && compiled.destroyJobScope) {
749
1808
  await compiled.destroyJobScope(jobScope);
@@ -751,6 +1810,11 @@ async function executeJob(compiled, services, job, input, requestContext, import
751
1810
  }
752
1811
  }
753
1812
  function defaultErrorResponse(error, frameworkCode) {
1813
+ if (error instanceof CommandError2) {
1814
+ return Response.json({ ok: false, code: error.code, message: error.code }, {
1815
+ status: commandErrorStatus(error.code)
1816
+ });
1817
+ }
754
1818
  if (isPublicApplicationError(error)) {
755
1819
  return Response.json({
756
1820
  ok: false,
@@ -785,7 +1849,9 @@ function isPublicApplicationError(error) {
785
1849
  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;
786
1850
  }
787
1851
  function createApplication(options) {
788
- const app = new Elysia({ name: options.name ?? "supacloud:app" });
1852
+ const app = new Elysia2({ name: options.name ?? "supacloud:app", normalize: options.normalize ?? true });
1853
+ if (options.documentation)
1854
+ app.use(createDocumentationPlugin(options.documentation));
789
1855
  const configuredContextFactory = options.requestContext ?? defaultRequestContext;
790
1856
  const contextCache = new WeakMap;
791
1857
  const ctxFactory = (request) => {
@@ -800,11 +1866,7 @@ function createApplication(options) {
800
1866
  for (const module of options.modules ?? []) {
801
1867
  const services = module.createServices(options.deps ?? {}, imported);
802
1868
  imported[module.name] = services;
803
- app.use(createModulePlugin(module, services, ctxFactory, {
804
- commandGovernance: options.commandGovernance,
805
- commandExecutor: options.commandExecutor,
806
- errorMapper: options.errorMapper
807
- }, imported));
1869
+ app.use(createModulePlugin(module, services, ctxFactory, options, imported));
808
1870
  }
809
1871
  return app;
810
1872
  }
@@ -815,20 +1877,41 @@ export {
815
1877
  ApplicationError,
816
1878
  EXECUTION_ID_HEADER,
817
1879
  IDEMPOTENCY_KEY_HEADER,
1880
+ SchemaContractError,
1881
+ SupaCloudWorker,
818
1882
  VERIFIED_JWT_SUBJECT_HEADER,
1883
+ WorkerReceiptUnconfirmedError,
1884
+ WorkerRegistrationError,
819
1885
  assertFeatureTransition,
1886
+ assertResponseStatusDeclared,
820
1887
  composeAspects,
821
1888
  composeCommandExecutors,
822
1889
  createApplication,
823
1890
  createCommandExecutor,
1891
+ createDocumentationPlugin,
824
1892
  createMemoryPolicy,
825
1893
  createMemorySandbox,
826
1894
  createModulePlugin,
1895
+ createPersistentCommandAdapter,
1896
+ createQueueWorkerTransport,
1897
+ createSchemaDecoder,
1898
+ createSupAuthRequestContext,
827
1899
  createSupaCloudRequestContext,
828
1900
  createTestApp,
1901
+ createWorker,
829
1902
  defaultErrorResponse,
1903
+ defineElysiaRoute,
1904
+ defineJsonContract,
1905
+ defineRouteContract,
1906
+ executeCompiledCommand,
830
1907
  executeJob,
1908
+ previewCompiledCommand,
1909
+ registerElysiaRoute,
831
1910
  requireIdempotencyKey,
832
1911
  requireTrustedIdentity,
1912
+ responseSchemaForStatus,
1913
+ responseStatusDeclared,
1914
+ responseStatusOf,
1915
+ toElysiaRouteSchema,
833
1916
  validatedJsonResponse
834
1917
  };