@supacloud/elysia 0.2.0 → 0.5.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 CHANGED
@@ -8,13 +8,19 @@ Runtime adapter that turns `@supacloud/compiler` output into a production-ready
8
8
  - **Decoupled compilation**: takes the output of `@supacloud/compiler` directly.
9
9
  - **Topological initialization**: modules are registered in dependency order,
10
10
  passing exported services downstream via Elysia plugins.
11
- - **Request-scoped providers**: creates a fresh scope per HTTP request via
12
- `createRequestScope`, mapping request-scoped controllers and services.
11
+ - **Request-scoped providers**: creates a fresh scope per HTTP request via the
12
+ asynchronous compiler-generated `createRequestScope`, mapping request-scoped
13
+ controllers and services.
14
+ - **Request-scope teardown**: invokes the compiler-generated
15
+ `destroyRequestScope` after the response, including when the handler fails.
13
16
  - **TypeBox schema binding**: attaches compiled parameter, query, body, and
14
17
  response TypeBox schemas directly to Elysia route definitions.
15
18
  - **Unified Command Pipeline**: runs `@Command`-decorated handlers through a
16
19
  structured `commandGovernance` adapter chain or a custom `composeCommandExecutors`
17
20
  pipeline (fail-closed if command routes lack an executor).
21
+ - **Static AOP pipeline**: executes compiler-emitted module, route, command, and
22
+ job aspects with `composeAspects`; no runtime discovery or registration is
23
+ performed.
18
24
  - **Public error mapping**: transforms framework / application errors via
19
25
  `errorMapper` with standard `ApplicationError` envelope support, preserving
20
26
  Elysia's default behavior (422) for schema validation errors.
@@ -52,6 +58,11 @@ const app = createApplication({
52
58
  export default app;
53
59
  ```
54
60
 
61
+ Jobs are executed explicitly with `executeJob(compiledModule, services, job,
62
+ input, requestContext)`. The asynchronous compiler-generated job scope is
63
+ destroyed after execution, including when the job throws or scope construction
64
+ fails partway through.
65
+
55
66
  ## API
56
67
 
57
68
  ### `createApplication(options: ApplicationOptions): Elysia`
@@ -67,6 +78,16 @@ directly onto an existing Elysia app.
67
78
 
68
79
  Composes multiple `CommandExecutor` middleware functions into an onion-style pipeline.
69
80
 
81
+ ### `composeAspects(...aspects): ApplicationAspect`
82
+
83
+ Composes static `around(context, next)` functions. Calling `next()` more than
84
+ once is rejected.
85
+
86
+ ### `executeJob(...)`
87
+
88
+ Executes a compiler-emitted Job descriptor with its static aspect list and
89
+ compiler-generated job scope.
90
+
70
91
  ### `ApplicationError`
71
92
 
72
93
  Lightweight error class carrying HTTP `status`, machine-readable `code`, and
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Elysia } from "elysia";
2
2
  export interface CompiledRoute {
3
- method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
3
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
4
4
  path: string;
5
5
  /** Method name on the controller instance. */
6
6
  handler: string;
@@ -11,6 +11,8 @@ export interface CompiledRoute {
11
11
  response?: unknown;
12
12
  /** Class name of the @Command explicitly bound to this route. */
13
13
  command?: string;
14
+ /** Statically generated route aspects. */
15
+ aspects?: ApplicationAspect[];
14
16
  }
15
17
  export interface CompiledCommand {
16
18
  className: string;
@@ -19,6 +21,15 @@ export interface CompiledCommand {
19
21
  transaction?: "required" | "none" | string;
20
22
  audit?: string;
21
23
  idempotency?: "required" | "none" | string;
24
+ /** Statically generated command aspects. */
25
+ aspects?: ApplicationAspect[];
26
+ }
27
+ export interface CompiledJob {
28
+ className: string;
29
+ name: string;
30
+ serviceKey: string;
31
+ scope: "application" | "request" | "job";
32
+ aspects?: ApplicationAspect[];
22
33
  }
23
34
  export interface CompiledController {
24
35
  /** Controller path prefix, e.g. "/cases". */
@@ -31,9 +42,15 @@ export interface CompiledController {
31
42
  export interface CompiledModule {
32
43
  name: string;
33
44
  createServices(deps: Record<string, unknown>, imported: Record<string, Record<string, unknown>>): Record<string, unknown>;
34
- createRequestScope?(services: Record<string, unknown>, ctx: unknown, imported?: Record<string, Record<string, unknown>>): Record<string, unknown>;
45
+ createRequestScope?(services: Record<string, unknown>, ctx: unknown, imported?: Record<string, Record<string, unknown>>): Record<string, unknown> | Promise<Record<string, unknown>>;
46
+ destroyRequestScope?(scope: Record<string, unknown>): Promise<void>;
47
+ createJobScope?(services: Record<string, unknown>, ctx: unknown, imported?: Record<string, Record<string, unknown>>): Record<string, unknown> | Promise<Record<string, unknown>>;
48
+ destroyJobScope?(scope: Record<string, unknown>): Promise<void>;
35
49
  controllers: CompiledController[];
36
50
  commands?: CompiledCommand[];
51
+ jobs?: CompiledJob[];
52
+ /** Statically generated module aspects. */
53
+ aspects?: ApplicationAspect[];
37
54
  }
38
55
  export type RequestContextFactory = (request: Request) => unknown | Promise<unknown>;
39
56
  export declare const VERIFIED_JWT_SUBJECT_HEADER = "x-supacloud-jwt-sub";
@@ -65,6 +82,23 @@ export interface CommandInvocation {
65
82
  services: Record<string, unknown>;
66
83
  }
67
84
  export type CommandExecutor = (invocation: CommandInvocation, next: () => unknown | Promise<unknown>) => unknown | Promise<unknown>;
85
+ export interface ApplicationAspectContext {
86
+ kind: "route" | "command" | "job";
87
+ name: string;
88
+ input: unknown;
89
+ request?: Request;
90
+ requestContext?: unknown;
91
+ scope?: Record<string, unknown>;
92
+ services?: Record<string, unknown>;
93
+ metadata?: unknown;
94
+ }
95
+ export type ApplicationAspect = (context: ApplicationAspectContext, next: () => unknown | Promise<unknown>) => unknown | Promise<unknown>;
96
+ /**
97
+ * Compose the compiler-emitted aspect list into a deterministic onion chain.
98
+ * The runtime only executes the functions it receives; it never discovers or
99
+ * registers aspects.
100
+ */
101
+ export declare function composeAspects(...aspects: (ApplicationAspect | undefined | null)[]): ApplicationAspect;
68
102
  export type CommandAuthorizer = (invocation: CommandInvocation) => void | Promise<void>;
69
103
  export type CommandMiddleware = (invocation: CommandInvocation, next: () => unknown | Promise<unknown>) => unknown | Promise<unknown>;
70
104
  export interface CommandAudit {
@@ -121,6 +155,14 @@ export interface ApplicationOptions {
121
155
  /** Maps framework or application failures to the public HTTP contract. */
122
156
  errorMapper?: ErrorMapper;
123
157
  }
158
+ export interface JobInvocation {
159
+ job: CompiledJob;
160
+ input: unknown;
161
+ requestContext: unknown;
162
+ scope?: Record<string, unknown>;
163
+ services: Record<string, unknown>;
164
+ }
165
+ export type JobExecutor = (invocation: JobInvocation, next: () => unknown | Promise<unknown>) => unknown | Promise<unknown>;
124
166
  /**
125
167
  * Build the standard request context for applications behind SupaCloud Edge
126
168
  * Runtime. The runtime strips incoming x-supacloud-jwt-sub values and writes
@@ -144,6 +186,14 @@ export declare function requireIdempotencyKey(invocation: CommandInvocation): st
144
186
  * looked up on `scope`, everything else on `services`.
145
187
  */
146
188
  export declare function createModulePlugin(compiled: CompiledModule, services: Record<string, unknown>, ctxFactory?: RequestContextFactory, options?: Pick<ApplicationOptions, "commandGovernance" | "commandExecutor" | "errorMapper">, imported?: Record<string, Record<string, unknown>>): Elysia;
189
+ /**
190
+ * Execute one compiler-emitted Job descriptor.
191
+ *
192
+ * Job classes use `run(input)` as their entry method; `execute(input)` is also
193
+ * accepted for command-like job implementations. The scope factory and
194
+ * destruction hooks are generated statically by the compiler.
195
+ */
196
+ export declare function executeJob(compiled: CompiledModule, services: Record<string, unknown>, job: CompiledJob, input: unknown, requestContext: unknown, imported?: Record<string, Record<string, unknown>>, executor?: JobExecutor): Promise<unknown>;
147
197
  export declare function defaultErrorResponse(error: unknown, frameworkCode?: string | number): Response;
148
198
  /**
149
199
  * Create the root Elysia application from compiled modules.
package/dist/index.js CHANGED
@@ -3,6 +3,22 @@ import { Elysia } from "elysia";
3
3
  var VERIFIED_JWT_SUBJECT_HEADER = "x-supacloud-jwt-sub";
4
4
  var EXECUTION_ID_HEADER = "x-sb-execution-id";
5
5
  var IDEMPOTENCY_KEY_HEADER = "idempotency-key";
6
+ function composeAspects(...aspects) {
7
+ const active = aspects.filter((aspect) => typeof aspect === "function");
8
+ return (context, next) => {
9
+ let index = -1;
10
+ const dispatch = (current) => {
11
+ if (current <= index) {
12
+ return Promise.reject(new Error("next() called multiple times"));
13
+ }
14
+ index = current;
15
+ if (current === active.length)
16
+ return Promise.resolve(next());
17
+ return Promise.resolve(active[current](context, () => dispatch(current + 1)));
18
+ };
19
+ return dispatch(0);
20
+ };
21
+ }
6
22
  function composeCommandExecutors(...executors) {
7
23
  const active = executors.filter((e) => typeof e === "function");
8
24
  if (active.length === 0)
@@ -46,6 +62,15 @@ function safeHeaderValue(value, maxLength) {
46
62
  }
47
63
  return value;
48
64
  }
65
+ function isRecord(value) {
66
+ return value !== null && typeof value === "object" && !Array.isArray(value);
67
+ }
68
+ function isTrustedIdentity(value) {
69
+ return isRecord(value) && typeof value.authenticated === "boolean" && (value.subject === undefined || typeof value.subject === "string") && (value.accessToken === undefined || typeof value.accessToken === "string");
70
+ }
71
+ function isAuthenticatedTrustedIdentity(value) {
72
+ return value?.authenticated === true && typeof value.subject === "string" && value.subject.length > 0 && typeof value.accessToken === "string" && value.accessToken.length > 0;
73
+ }
49
74
  function bearerToken(request) {
50
75
  const authorization = request.headers.get("authorization");
51
76
  const match = authorization?.match(/^Bearer\s+(.+)$/i);
@@ -89,43 +114,50 @@ function missingGovernanceAdapter(command, adapter) {
89
114
  function createCommandExecutor(governance) {
90
115
  return async (invocation, next) => {
91
116
  const { command } = invocation;
92
- if (command.audit && !governance.audit) {
117
+ const audit = command.audit ? governance.audit : undefined;
118
+ const transaction = command.transaction === "required" ? governance.transaction : undefined;
119
+ const idempotency = command.idempotency === "required" ? governance.idempotency : undefined;
120
+ if (command.audit && !audit) {
93
121
  throw missingGovernanceAdapter(command, "audit");
94
122
  }
95
- if (command.transaction === "required" && !governance.transaction) {
123
+ if (command.transaction === "required" && !transaction) {
96
124
  throw missingGovernanceAdapter(command, "transaction");
97
125
  }
98
- if (command.idempotency === "required" && !governance.idempotency) {
126
+ if (command.idempotency === "required" && !idempotency) {
99
127
  throw missingGovernanceAdapter(command, "idempotency");
100
128
  }
101
129
  try {
102
130
  await governance.authorize(invocation);
103
131
  let execute = async () => {
104
132
  const result = await next();
105
- if (command.audit)
106
- await governance.audit.succeeded(invocation, result);
133
+ if (audit)
134
+ await audit.succeeded.call(audit, invocation, result);
107
135
  return result;
108
136
  };
109
137
  if (command.transaction === "required") {
138
+ if (!transaction)
139
+ throw missingGovernanceAdapter(command, "transaction");
110
140
  const inner = execute;
111
- execute = () => Promise.resolve(governance.transaction(invocation, inner));
141
+ execute = () => Promise.resolve(transaction.call(governance, invocation, inner));
112
142
  }
113
143
  if (command.idempotency === "required") {
144
+ if (!idempotency)
145
+ throw missingGovernanceAdapter(command, "idempotency");
114
146
  const inner = execute;
115
- execute = () => Promise.resolve(governance.idempotency(invocation, inner));
147
+ execute = () => Promise.resolve(idempotency.call(governance, invocation, inner));
116
148
  }
117
149
  return await execute();
118
150
  } catch (error) {
119
- if (command.audit)
120
- await governance.audit.failed(invocation, error);
151
+ if (audit)
152
+ await audit.failed.call(audit, invocation, error);
121
153
  throw error;
122
154
  }
123
155
  };
124
156
  }
125
157
  function requireTrustedIdentity(requestContext) {
126
- const context = requestContext;
127
- const identity = context?.identity;
128
- if (!identity?.authenticated || !identity.subject || !identity.accessToken) {
158
+ const context = isRecord(requestContext) ? requestContext : {};
159
+ const identity = isTrustedIdentity(context.identity) ? context.identity : undefined;
160
+ if (!isAuthenticatedTrustedIdentity(identity)) {
129
161
  throw new ApplicationError("Authenticated user context is required", {
130
162
  status: 401,
131
163
  code: "AUTHENTICATION_REQUIRED"
@@ -134,8 +166,8 @@ function requireTrustedIdentity(requestContext) {
134
166
  return identity;
135
167
  }
136
168
  function requireIdempotencyKey(invocation) {
137
- const context = invocation.requestContext;
138
- const key = context?.idempotencyKey ?? safeHeaderValue(invocation.request.headers.get(IDEMPOTENCY_KEY_HEADER), 512);
169
+ const context = isRecord(invocation.requestContext) ? invocation.requestContext : {};
170
+ const key = (typeof context.idempotencyKey === "string" ? context.idempotencyKey : undefined) ?? safeHeaderValue(invocation.request.headers.get(IDEMPOTENCY_KEY_HEADER), 512);
139
171
  if (!key) {
140
172
  throw new ApplicationError("Idempotency-Key header is required", {
141
173
  status: 400,
@@ -148,6 +180,9 @@ function joinPaths(prefix, path) {
148
180
  const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
149
181
  return joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
150
182
  }
183
+ function controllerInstance(value) {
184
+ return isRecord(value) ? value : undefined;
185
+ }
151
186
  function createModulePlugin(compiled, services, ctxFactory = defaultRequestContext, options = {}, imported = {}) {
152
187
  const hasCommandRoutes = compiled.controllers.some((controller) => controller.routes.some((route) => route.command !== undefined));
153
188
  if (hasCommandRoutes && !options.commandGovernance && !options.commandExecutor) {
@@ -168,6 +203,21 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
168
203
  const commandExecutor = options.commandExecutor && governanceExecutor ? composeCommandExecutors(options.commandExecutor, governanceExecutor) : options.commandExecutor ?? governanceExecutor;
169
204
  const plugin = new Elysia({ name: `supacloud:${compiled.name}` }).decorate("services", services);
170
205
  const createRequestScope = compiled.createRequestScope;
206
+ const requestScopes = new WeakMap;
207
+ if (compiled.destroyRequestScope) {
208
+ const destroyRequestScope = compiled.destroyRequestScope;
209
+ plugin.onAfterResponse(async ({ request }) => {
210
+ const scope = requestScopes.get(request);
211
+ if (!scope)
212
+ return;
213
+ requestScopes.delete(request);
214
+ try {
215
+ await destroyRequestScope(scope);
216
+ } catch (error) {
217
+ console.error(`supacloud: request scope cleanup failed for "${compiled.name}"`, error);
218
+ }
219
+ });
220
+ }
171
221
  plugin.resolve(async ({ request }) => {
172
222
  const requestContext = await ctxFactory(request);
173
223
  requestContexts.set(request, requestContext);
@@ -187,9 +237,11 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
187
237
  schema.response = route.response;
188
238
  const handler = async (ctx) => {
189
239
  const requestContext = ctx.requestContext ?? await ctxFactory(ctx.request);
190
- const requestScope = createRequestScope ? createRequestScope(services, requestContext, imported) : undefined;
240
+ const requestScope = createRequestScope ? await createRequestScope(services, requestContext, imported) : undefined;
241
+ if (requestScope && compiled.destroyRequestScope)
242
+ requestScopes.set(ctx.request, requestScope);
191
243
  const source = controller.scope === "request" ? requestScope : services;
192
- const instance = source?.[controller.serviceKey];
244
+ const instance = controllerInstance(source?.[controller.serviceKey]);
193
245
  const method = instance?.[route.handler];
194
246
  if (typeof method !== "function") {
195
247
  throw new Error(`supacloud: controller "${controller.serviceKey}" has no handler "${route.handler}" in scope "${controller.scope}"`);
@@ -202,24 +254,58 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
202
254
  scope: requestScope,
203
255
  requestContext
204
256
  };
205
- const invoke = () => method.call(instance, input);
206
- if (!route.command)
207
- return invoke();
208
- const command = commandsByClassName.get(route.command);
209
- if (!commandExecutor) {
210
- throw new ApplicationError(`Command "${command.name}" has no executor`, {
211
- status: 501,
212
- code: "COMMAND_EXECUTOR_UNAVAILABLE"
213
- });
214
- }
215
- return commandExecutor({
216
- command,
257
+ const invoke = () => Reflect.apply(method, instance, [input]);
258
+ const routeContext = {
259
+ kind: "route",
260
+ name: `${route.method} ${path}`,
217
261
  input,
218
262
  request: ctx.request,
219
263
  requestContext,
220
264
  scope: requestScope,
221
- services
222
- }, invoke);
265
+ services,
266
+ metadata: route
267
+ };
268
+ const command = route.command ? commandsByClassName.get(route.command) : undefined;
269
+ const commandContext = {
270
+ kind: "command",
271
+ name: command?.name ?? route.command ?? `${route.method} ${path}`,
272
+ input,
273
+ request: ctx.request,
274
+ requestContext,
275
+ scope: requestScope,
276
+ services,
277
+ metadata: command ?? route
278
+ };
279
+ const commandAspects = route.command ? commandsByClassName.get(route.command)?.aspects ?? [] : [];
280
+ const routePipeline = composeAspects(...route.aspects ?? []);
281
+ const commandPipeline = composeAspects(...commandAspects);
282
+ const modulePipeline = composeAspects(...compiled.aspects ?? []);
283
+ const invokeRoute = () => routePipeline(routeContext, () => route.command ? commandPipeline(commandContext, () => invokeCommand()) : invoke());
284
+ const invokeCommand = () => {
285
+ if (!route.command)
286
+ return invoke();
287
+ if (!command) {
288
+ throw new ApplicationError(`Command "${route.command}" is not registered`, {
289
+ code: "COMMAND_NOT_REGISTERED"
290
+ });
291
+ }
292
+ if (!commandExecutor) {
293
+ throw new ApplicationError(`Command "${command.name}" has no executor`, {
294
+ status: 501,
295
+ code: "COMMAND_EXECUTOR_UNAVAILABLE"
296
+ });
297
+ }
298
+ const invocation = {
299
+ command,
300
+ input,
301
+ request: ctx.request,
302
+ requestContext,
303
+ scope: requestScope,
304
+ services
305
+ };
306
+ return commandExecutor(invocation, invoke);
307
+ };
308
+ return modulePipeline(route.command ? commandContext : routeContext, invokeRoute);
223
309
  };
224
310
  switch (route.method) {
225
311
  case "GET":
@@ -237,6 +323,12 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
237
323
  case "DELETE":
238
324
  plugin.delete(path, handler, schema);
239
325
  break;
326
+ case "HEAD":
327
+ plugin.head(path, handler, schema);
328
+ break;
329
+ case "OPTIONS":
330
+ plugin.options(path, handler, schema);
331
+ break;
240
332
  }
241
333
  }
242
334
  }
@@ -251,6 +343,40 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
251
343
  });
252
344
  return plugin;
253
345
  }
346
+ async function executeJob(compiled, services, job, input, requestContext, imported = {}, executor) {
347
+ const jobScope = job.scope === "job" && compiled.createJobScope ? await compiled.createJobScope(services, requestContext, imported) : undefined;
348
+ try {
349
+ const source = job.scope === "job" ? jobScope : services;
350
+ const instance = controllerInstance(source?.[job.serviceKey]);
351
+ const method = instance?.run ?? instance?.execute;
352
+ if (typeof method !== "function") {
353
+ throw new ApplicationError(`Job "${job.name}" has no run(input) or execute(input) handler`, { code: "JOB_HANDLER_UNAVAILABLE" });
354
+ }
355
+ const invocation = {
356
+ job,
357
+ input,
358
+ requestContext,
359
+ scope: jobScope,
360
+ services
361
+ };
362
+ const context = {
363
+ kind: "job",
364
+ name: job.name,
365
+ input,
366
+ requestContext,
367
+ scope: jobScope,
368
+ services,
369
+ metadata: job
370
+ };
371
+ const invoke = () => Reflect.apply(method, instance, [input]);
372
+ const pipeline = composeAspects(...compiled.aspects ?? [], ...job.aspects ?? []);
373
+ return await pipeline(context, executor ? () => executor(invocation, invoke) : invoke);
374
+ } finally {
375
+ if (jobScope && compiled.destroyJobScope) {
376
+ await compiled.destroyJobScope(jobScope);
377
+ }
378
+ }
379
+ }
254
380
  function defaultErrorResponse(error, frameworkCode) {
255
381
  if (isPublicApplicationError(error)) {
256
382
  return Response.json({
@@ -274,10 +400,9 @@ function defaultErrorResponse(error, frameworkCode) {
274
400
  }, { status: 500 });
275
401
  }
276
402
  function isPublicApplicationError(error) {
277
- if (!(error instanceof Error))
403
+ if (!(error instanceof Error) || !isRecord(error))
278
404
  return false;
279
- const candidate = error;
280
- return candidate.expose === true && typeof candidate.status === "number" && Number.isInteger(candidate.status) && candidate.status >= 400 && candidate.status <= 599 && typeof candidate.code === "string" && candidate.code.length > 0;
405
+ 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;
281
406
  }
282
407
  function createApplication(options) {
283
408
  const app = new Elysia({ name: options.name ?? "supacloud:app" });
@@ -311,6 +436,7 @@ export {
311
436
  EXECUTION_ID_HEADER,
312
437
  IDEMPOTENCY_KEY_HEADER,
313
438
  VERIFIED_JWT_SUBJECT_HEADER,
439
+ composeAspects,
314
440
  composeCommandExecutors,
315
441
  createApplication,
316
442
  createCommandExecutor,
@@ -318,6 +444,7 @@ export {
318
444
  createSupaCloudRequestContext,
319
445
  createTestApp,
320
446
  defaultErrorResponse,
447
+ executeJob,
321
448
  requireIdempotencyKey,
322
449
  requireTrustedIdentity
323
450
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/elysia",
3
- "version": "0.2.0",
3
+ "version": "0.5.1",
4
4
  "description": "Elysia runtime adapter for SupaCloud compiled modules: application/request scopes, route registration and validation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",