@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/README.md +246 -2
- package/dist/command-errors.d.ts +2 -0
- package/dist/documentation.d.ts +29 -0
- package/dist/execution.d.ts +13 -0
- package/dist/fixtures/webhook/contracts.d.ts +42 -0
- package/dist/fixtures/webhook/environment.d.ts +11 -0
- package/dist/fixtures/webhook/update.command.d.ts +13 -0
- package/dist/fixtures/webhook/webhook.controller.d.ts +15 -0
- package/dist/fixtures/webhook/webhook.module.d.ts +2 -0
- package/dist/fixtures/webhook-generated/application.d.ts +100 -0
- package/dist/generate-webhook-example.d.ts +19 -0
- package/dist/identity.d.ts +37 -0
- package/dist/index.d.ts +86 -4
- package/dist/index.js +1183 -100
- package/dist/persistent-command.d.ts +20 -0
- package/dist/schema_contract.d.ts +160 -0
- package/dist/webhook-migration-example.d.ts +41 -0
- package/dist/worker.d.ts +150 -0
- package/package.json +16 -3
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
|
+
"&": "&",
|
|
321
|
+
"<": "<",
|
|
322
|
+
">": ">",
|
|
323
|
+
'"': """,
|
|
324
|
+
"'": "'"
|
|
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
|
-
|
|
1256
|
+
const aspect = active[current];
|
|
1257
|
+
if (!aspect)
|
|
365
1258
|
return Promise.resolve(next());
|
|
366
|
-
return Promise.resolve(
|
|
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
|
-
|
|
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
|
|
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 = () =>
|
|
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 = () =>
|
|
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
|
|
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
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
745
|
-
|
|
746
|
-
|
|
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
|
|
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
|
};
|