@supacloud/elysia 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,55 +1,34 @@
1
1
  # @supacloud/elysia
2
2
 
3
- Elysia runtime adapter for SupaCloud applications: it adapts the plain-data
4
- `CompiledModule` produced by `@supacloud/compiler` into Elysia plugins and
5
- manages the `application` / `request` scopes at runtime.
6
-
7
- This package does **not** depend on the compiler. The contract below is
8
- declared and re-exported locally; any object matching it works.
9
-
10
- ## Compiled module contract
11
-
12
- ```ts
13
- interface CompiledRoute {
14
- method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
15
- path: string;
16
- handler: string; // method name on the controller instance
17
- body?: unknown; // TypeBox schema; validation enabled only when present
18
- params?: unknown;
19
- query?: unknown;
20
- response?: unknown;
21
- }
22
-
23
- interface CompiledController {
24
- path: string; // controller prefix, e.g. "/cases"
25
- serviceKey: string; // key on services or the request scope
26
- scope: "application" | "request" | "job";
27
- routes: CompiledRoute[];
28
- }
29
-
30
- interface CompiledModule {
31
- name: string;
32
- createServices(
33
- deps: Record<string, unknown>,
34
- imported: Record<string, Record<string, unknown>>,
35
- ): Record<string, unknown>;
36
- createRequestScope?(
37
- services: Record<string, unknown>,
38
- ctx: unknown,
39
- ): Record<string, unknown>;
40
- controllers: CompiledController[];
41
- }
3
+ Runtime adapter that turns `@supacloud/compiler` output into a production-ready
4
+ [Elysia](https://elysiajs.com/) application.
5
+
6
+ ## Features
7
+
8
+ - **Decoupled compilation**: takes the output of `@supacloud/compiler` directly.
9
+ - **Topological initialization**: modules are registered in dependency order,
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.
13
+ - **TypeBox schema binding**: attaches compiled parameter, query, body, and
14
+ response TypeBox schemas directly to Elysia route definitions.
15
+ - **Unified Command Pipeline**: runs `@Command`-decorated handlers through a
16
+ structured `commandGovernance` adapter chain or a custom `composeCommandExecutors`
17
+ pipeline (fail-closed if command routes lack an executor).
18
+ - **Public error mapping**: transforms framework / application errors via
19
+ `errorMapper` with standard `ApplicationError` envelope support, preserving
20
+ Elysia's default behavior (422) for schema validation errors.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ bun add @supacloud/elysia elysia
42
26
  ```
43
27
 
44
- Controller methods are invoked as
45
- `controller[handler]({ body, params, query, request, scope })`; non-`Response`
46
- return values are serialized to JSON by Elysia. Validation failures use
47
- Elysia's default behavior (422).
48
-
49
28
  ## Usage
50
29
 
51
30
  ```ts
52
- import { createApplication } from "@supacloud/elysia";
31
+ import { composeCommandExecutors, createApplication, requireIdempotencyKey } from "@supacloud/elysia";
53
32
  import AuditModule from "./.generated/audit.module";
54
33
  import CaseModule from "./.generated/case.module";
55
34
 
@@ -57,45 +36,38 @@ const app = createApplication({
57
36
  name: "case-service",
58
37
  modules: [AuditModule, CaseModule], // topological import order
59
38
  deps: { db: createDbClient() }, // platform deps, passed to createServices
60
- requestContext: (request) => ({
61
- requestId: request.headers.get("x-request-id") ?? crypto.randomUUID(),
62
- request,
63
- }),
39
+ commandGovernance: {
40
+ authorize: (invocation) => authorize(invocation.requestContext, invocation.command.permission),
41
+ idempotency: (invocation, next) => idempotencyStore.run(requireIdempotencyKey(invocation), next),
42
+ transaction: (invocation, next) => transactionManager.run(invocation, next),
43
+ audit: {
44
+ succeeded: (invocation, result) => auditLog.record(invocation, result),
45
+ failed: (invocation, error) => auditLog.recordFailure(invocation, error),
46
+ },
47
+ },
48
+ // Or custom onion-style command pipeline:
49
+ // commandExecutor: composeCommandExecutors(outerMiddleware, innerMiddleware),
64
50
  });
65
51
 
66
52
  export default app;
67
53
  ```
68
54
 
69
- - Each module is instantiated in order: `createServices(deps, imported)`
70
- receives platform `deps` plus the services of all previously created
71
- modules, keyed by module name.
72
- - Each module becomes a named Elysia plugin (`supacloud:<module.name>`) that
73
- decorates the context with `services`.
74
- - When a module defines `createRequestScope`, a **fresh** request scope is
75
- resolved per request and exposed on the `scope` context key — never reused
76
- across requests. Request-scoped controller instances are looked up on
77
- `scope`; application-scoped instances on `services`.
78
- - Full route paths are `controller.path + route.path` (slashes normalized);
79
- schema options (`body` / `params` / `query` / `response`) are passed to
80
- Elysia only when the corresponding field exists.
81
-
82
- Lower-level building blocks:
83
-
84
- - `createModulePlugin(compiled, services, ctxFactory?)` — adapt a single
85
- module into an Elysia plugin (useful for tests and embedding).
86
- - `createTestApp(options)` — semantic alias of `createApplication`.
87
- - `testRequest(app, path, init?)` (from `@supacloud/elysia` source
88
- `src/testing.ts`) — in-process `app.handle(new Request(...))` helper.
89
-
90
- ## Edge runtime integration
91
-
92
- SupaCloud's edge runtime (`@supacloud/edge-runtime`) supports Elysia as a
93
- first-class function framework. Default-export the Elysia instance from your
94
- function entrypoint and set the activation manifest to:
95
-
96
- ```json
97
- { "framework": "elysia" }
98
- ```
55
+ ## API
56
+
57
+ ### `createApplication(options: ApplicationOptions): Elysia`
58
+
59
+ Creates the root Elysia application from compiled modules.
60
+
61
+ ### `createModulePlugin(compiled, services, ctxFactory?, options?, imported?): Elysia`
62
+
63
+ Creates an Elysia plugin from a single compiled module. Can be mounted
64
+ directly onto an existing Elysia app.
65
+
66
+ ### `composeCommandExecutors(...executors): CommandExecutor`
67
+
68
+ Composes multiple `CommandExecutor` middleware functions into an onion-style pipeline.
69
+
70
+ ### `ApplicationError`
99
71
 
100
- The runtime then routes requests through `app.handle(request)` instead of a
101
- plain `fetch` handler.
72
+ Lightweight error class carrying HTTP `status`, machine-readable `code`, and
73
+ optional structured `details`.
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;
@@ -9,6 +9,16 @@ export interface CompiledRoute {
9
9
  params?: unknown;
10
10
  query?: unknown;
11
11
  response?: unknown;
12
+ /** Class name of the @Command explicitly bound to this route. */
13
+ command?: string;
14
+ }
15
+ export interface CompiledCommand {
16
+ className: string;
17
+ name: string;
18
+ permission?: string;
19
+ transaction?: "required" | "none" | string;
20
+ audit?: string;
21
+ idempotency?: "required" | "none" | string;
12
22
  }
13
23
  export interface CompiledController {
14
24
  /** Controller path prefix, e.g. "/cases". */
@@ -21,10 +31,81 @@ export interface CompiledController {
21
31
  export interface CompiledModule {
22
32
  name: string;
23
33
  createServices(deps: Record<string, unknown>, imported: Record<string, Record<string, unknown>>): Record<string, unknown>;
24
- createRequestScope?(services: Record<string, unknown>, ctx: unknown): Record<string, unknown>;
34
+ createRequestScope?(services: Record<string, unknown>, ctx: unknown, imported?: Record<string, Record<string, unknown>>): Record<string, unknown>;
25
35
  controllers: CompiledController[];
36
+ commands?: CompiledCommand[];
26
37
  }
27
38
  export type RequestContextFactory = (request: Request) => unknown | Promise<unknown>;
39
+ export declare const VERIFIED_JWT_SUBJECT_HEADER = "x-supacloud-jwt-sub";
40
+ export declare const EXECUTION_ID_HEADER = "x-sb-execution-id";
41
+ export declare const IDEMPOTENCY_KEY_HEADER = "idempotency-key";
42
+ export interface TrustedRequestIdentity {
43
+ authenticated: boolean;
44
+ /** Subject verified and forwarded by the SupaCloud Edge Runtime. */
45
+ subject?: string;
46
+ /** Bearer token associated with the verified subject. Never log this value. */
47
+ accessToken?: string;
48
+ }
49
+ export interface SupaCloudRequestContext {
50
+ requestId: string;
51
+ request: Request;
52
+ identity: TrustedRequestIdentity;
53
+ idempotencyKey?: string;
54
+ }
55
+ export interface CommandInvocation {
56
+ command: CompiledCommand;
57
+ input: {
58
+ body: unknown;
59
+ params: Record<string, string>;
60
+ query: Record<string, unknown>;
61
+ };
62
+ request: Request;
63
+ requestContext: unknown;
64
+ scope?: Record<string, unknown>;
65
+ services: Record<string, unknown>;
66
+ }
67
+ export type CommandExecutor = (invocation: CommandInvocation, next: () => unknown | Promise<unknown>) => unknown | Promise<unknown>;
68
+ export type CommandAuthorizer = (invocation: CommandInvocation) => void | Promise<void>;
69
+ export type CommandMiddleware = (invocation: CommandInvocation, next: () => unknown | Promise<unknown>) => unknown | Promise<unknown>;
70
+ export interface CommandAudit {
71
+ succeeded(invocation: CommandInvocation, result: unknown): void | Promise<void>;
72
+ failed(invocation: CommandInvocation, error: unknown): void | Promise<void>;
73
+ }
74
+ export interface CommandGovernance {
75
+ authorize: CommandAuthorizer;
76
+ idempotency?: CommandMiddleware;
77
+ transaction?: CommandMiddleware;
78
+ audit?: CommandAudit;
79
+ }
80
+ /**
81
+ * Compose multiple CommandExecutors into a single onion-style pipeline.
82
+ * Outer executors run first before calling `next()`, and complete last.
83
+ */
84
+ export declare function composeCommandExecutors(...executors: (CommandExecutor | undefined | null)[]): CommandExecutor;
85
+ export interface ApplicationErrorOptions {
86
+ status?: number;
87
+ code?: string;
88
+ details?: unknown;
89
+ }
90
+ export interface PublicApplicationError extends Error {
91
+ readonly expose: true;
92
+ readonly status: number;
93
+ readonly code: string;
94
+ readonly details?: unknown;
95
+ }
96
+ export declare class ApplicationError extends Error implements PublicApplicationError {
97
+ readonly expose: true;
98
+ readonly status: number;
99
+ readonly code: string;
100
+ readonly details?: unknown;
101
+ constructor(message: string, options?: ApplicationErrorOptions);
102
+ }
103
+ export interface ErrorContext {
104
+ request: Request;
105
+ requestContext: unknown;
106
+ frameworkCode: string | number | undefined;
107
+ }
108
+ export type ErrorMapper = (error: unknown, context: ErrorContext) => Response | undefined | Promise<Response | undefined>;
28
109
  export interface ApplicationOptions {
29
110
  name?: string;
30
111
  /** Modules in topological import order. */
@@ -33,7 +114,27 @@ export interface ApplicationOptions {
33
114
  deps?: Record<string, unknown>;
34
115
  /** Builds the per-request context object. Defaults to { requestId, request }. */
35
116
  requestContext?: RequestContextFactory;
117
+ /** Enforces permission/audit/idempotency policy for command-bound routes. */
118
+ commandGovernance?: CommandGovernance;
119
+ /** Optional custom or composed executor. When provided alongside commandGovernance, it wraps or composes with governance. */
120
+ commandExecutor?: CommandExecutor;
121
+ /** Maps framework or application failures to the public HTTP contract. */
122
+ errorMapper?: ErrorMapper;
36
123
  }
124
+ /**
125
+ * Build the standard request context for applications behind SupaCloud Edge
126
+ * Runtime. The runtime strips incoming x-supacloud-jwt-sub values and writes
127
+ * the header only after JWT verification.
128
+ */
129
+ export declare const createSupaCloudRequestContext: RequestContextFactory;
130
+ /**
131
+ * Compose the standard command governance order:
132
+ * authorization -> idempotency -> transaction -> audit -> handler.
133
+ * Declared governance metadata fails closed when its adapter is absent.
134
+ */
135
+ export declare function createCommandExecutor(governance: CommandGovernance): CommandExecutor;
136
+ export declare function requireTrustedIdentity(requestContext: unknown): Required<Pick<TrustedRequestIdentity, "subject" | "accessToken">> & TrustedRequestIdentity;
137
+ export declare function requireIdempotencyKey(invocation: CommandInvocation): string;
37
138
  /**
38
139
  * Adapt a single compiled module into an Elysia plugin.
39
140
  *
@@ -42,7 +143,8 @@ export interface ApplicationOptions {
42
143
  * exposed on the `scope` context key. Request-scoped controller instances are
43
144
  * looked up on `scope`, everything else on `services`.
44
145
  */
45
- export declare function createModulePlugin(compiled: CompiledModule, services: Record<string, unknown>, ctxFactory?: RequestContextFactory): Elysia;
146
+ 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;
147
+ export declare function defaultErrorResponse(error: unknown, frameworkCode?: string | number): Response;
46
148
  /**
47
149
  * Create the root Elysia application from compiled modules.
48
150
  *
package/dist/index.js CHANGED
@@ -1,21 +1,178 @@
1
1
  // src/index.ts
2
2
  import { Elysia } from "elysia";
3
- var defaultRequestContext = (request) => ({
4
- requestId: crypto.randomUUID(),
5
- request
6
- });
3
+ var VERIFIED_JWT_SUBJECT_HEADER = "x-supacloud-jwt-sub";
4
+ var EXECUTION_ID_HEADER = "x-sb-execution-id";
5
+ var IDEMPOTENCY_KEY_HEADER = "idempotency-key";
6
+ function composeCommandExecutors(...executors) {
7
+ const active = executors.filter((e) => typeof e === "function");
8
+ if (active.length === 0)
9
+ return (_inv, next) => next();
10
+ return (invocation, next) => {
11
+ let index = -1;
12
+ const dispatch = (i) => {
13
+ if (i <= index) {
14
+ return Promise.reject(new Error("next() called multiple times"));
15
+ }
16
+ index = i;
17
+ if (i === active.length) {
18
+ return Promise.resolve(next());
19
+ }
20
+ const fn = active[i];
21
+ if (!fn) {
22
+ return Promise.resolve(next());
23
+ }
24
+ return Promise.resolve(fn(invocation, () => dispatch(i + 1)));
25
+ };
26
+ return dispatch(0);
27
+ };
28
+ }
29
+
30
+ class ApplicationError extends Error {
31
+ expose = true;
32
+ status;
33
+ code;
34
+ details;
35
+ constructor(message, options = {}) {
36
+ super(message);
37
+ this.name = "ApplicationError";
38
+ this.status = options.status ?? 500;
39
+ this.code = options.code ?? "INTERNAL_ERROR";
40
+ this.details = options.details;
41
+ }
42
+ }
43
+ function safeHeaderValue(value, maxLength) {
44
+ if (!value || value.length > maxLength || /[\u0000-\u001f\u007f]/.test(value)) {
45
+ return;
46
+ }
47
+ return value;
48
+ }
49
+ function bearerToken(request) {
50
+ const authorization = request.headers.get("authorization");
51
+ const match = authorization?.match(/^Bearer\s+(.+)$/i);
52
+ return match ? safeHeaderValue(match[1], 16384) : undefined;
53
+ }
54
+ var createSupaCloudRequestContext = (request) => {
55
+ const subject = safeHeaderValue(request.headers.get(VERIFIED_JWT_SUBJECT_HEADER), 1024);
56
+ const accessToken = subject ? bearerToken(request) : undefined;
57
+ const requestId = safeHeaderValue(request.headers.get(EXECUTION_ID_HEADER), 256) ?? safeHeaderValue(request.headers.get("x-request-id"), 256) ?? crypto.randomUUID();
58
+ const idempotencyKey = safeHeaderValue(request.headers.get(IDEMPOTENCY_KEY_HEADER), 512);
59
+ const identity = {
60
+ authenticated: subject !== undefined,
61
+ ...subject === undefined ? {} : { subject }
62
+ };
63
+ if (accessToken !== undefined) {
64
+ Object.defineProperty(identity, "accessToken", {
65
+ value: accessToken,
66
+ enumerable: false,
67
+ configurable: false
68
+ });
69
+ }
70
+ return {
71
+ requestId,
72
+ request,
73
+ identity,
74
+ ...idempotencyKey === undefined ? {} : { idempotencyKey }
75
+ };
76
+ };
77
+ var defaultRequestContext = createSupaCloudRequestContext;
78
+ function missingGovernanceAdapter(command, adapter) {
79
+ const codes = {
80
+ idempotency: "COMMAND_IDEMPOTENCY_UNAVAILABLE",
81
+ transaction: "COMMAND_TRANSACTION_UNAVAILABLE",
82
+ audit: "COMMAND_AUDIT_UNAVAILABLE"
83
+ };
84
+ return new ApplicationError(`Command "${command.name}" requires a ${adapter} adapter`, {
85
+ status: 501,
86
+ code: codes[adapter]
87
+ });
88
+ }
89
+ function createCommandExecutor(governance) {
90
+ return async (invocation, next) => {
91
+ const { command } = invocation;
92
+ if (command.audit && !governance.audit) {
93
+ throw missingGovernanceAdapter(command, "audit");
94
+ }
95
+ if (command.transaction === "required" && !governance.transaction) {
96
+ throw missingGovernanceAdapter(command, "transaction");
97
+ }
98
+ if (command.idempotency === "required" && !governance.idempotency) {
99
+ throw missingGovernanceAdapter(command, "idempotency");
100
+ }
101
+ try {
102
+ await governance.authorize(invocation);
103
+ let execute = async () => {
104
+ const result = await next();
105
+ if (command.audit)
106
+ await governance.audit.succeeded(invocation, result);
107
+ return result;
108
+ };
109
+ if (command.transaction === "required") {
110
+ const inner = execute;
111
+ execute = () => Promise.resolve(governance.transaction(invocation, inner));
112
+ }
113
+ if (command.idempotency === "required") {
114
+ const inner = execute;
115
+ execute = () => Promise.resolve(governance.idempotency(invocation, inner));
116
+ }
117
+ return await execute();
118
+ } catch (error) {
119
+ if (command.audit)
120
+ await governance.audit.failed(invocation, error);
121
+ throw error;
122
+ }
123
+ };
124
+ }
125
+ function requireTrustedIdentity(requestContext) {
126
+ const context = requestContext;
127
+ const identity = context?.identity;
128
+ if (!identity?.authenticated || !identity.subject || !identity.accessToken) {
129
+ throw new ApplicationError("Authenticated user context is required", {
130
+ status: 401,
131
+ code: "AUTHENTICATION_REQUIRED"
132
+ });
133
+ }
134
+ return identity;
135
+ }
136
+ function requireIdempotencyKey(invocation) {
137
+ const context = invocation.requestContext;
138
+ const key = context?.idempotencyKey ?? safeHeaderValue(invocation.request.headers.get(IDEMPOTENCY_KEY_HEADER), 512);
139
+ if (!key) {
140
+ throw new ApplicationError("Idempotency-Key header is required", {
141
+ status: 400,
142
+ code: "IDEMPOTENCY_KEY_REQUIRED"
143
+ });
144
+ }
145
+ return key;
146
+ }
7
147
  function joinPaths(prefix, path) {
8
148
  const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
9
149
  return joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
10
150
  }
11
- function createModulePlugin(compiled, services, ctxFactory = defaultRequestContext) {
12
- const plugin = new Elysia({ name: `supacloud:${compiled.name}` }).decorate("services", services);
13
- if (compiled.createRequestScope) {
14
- const createRequestScope = compiled.createRequestScope;
15
- plugin.resolve(async ({ request }) => ({
16
- scope: createRequestScope(services, await ctxFactory(request))
17
- }));
151
+ function createModulePlugin(compiled, services, ctxFactory = defaultRequestContext, options = {}, imported = {}) {
152
+ const hasCommandRoutes = compiled.controllers.some((controller) => controller.routes.some((route) => route.command !== undefined));
153
+ if (hasCommandRoutes && !options.commandGovernance && !options.commandExecutor) {
154
+ throw new ApplicationError(`Module "${compiled.name}" has command routes but no commandGovernance`, { code: "COMMAND_GOVERNANCE_UNCONFIGURED" });
155
+ }
156
+ const commandsByClassName = new Map((compiled.commands ?? []).map((command) => [command.className, command]));
157
+ for (const controller of compiled.controllers) {
158
+ for (const route of controller.routes) {
159
+ if (route.command && !commandsByClassName.has(route.command)) {
160
+ throw new ApplicationError(`Command "${route.command}" is not registered`, {
161
+ code: "COMMAND_NOT_REGISTERED"
162
+ });
163
+ }
164
+ }
18
165
  }
166
+ const requestContexts = new WeakMap;
167
+ const governanceExecutor = options.commandGovernance ? createCommandExecutor(options.commandGovernance) : undefined;
168
+ const commandExecutor = options.commandExecutor && governanceExecutor ? composeCommandExecutors(options.commandExecutor, governanceExecutor) : options.commandExecutor ?? governanceExecutor;
169
+ const plugin = new Elysia({ name: `supacloud:${compiled.name}` }).decorate("services", services);
170
+ const createRequestScope = compiled.createRequestScope;
171
+ plugin.resolve(async ({ request }) => {
172
+ const requestContext = await ctxFactory(request);
173
+ requestContexts.set(request, requestContext);
174
+ return { requestContext };
175
+ });
19
176
  for (const controller of compiled.controllers) {
20
177
  for (const route of controller.routes) {
21
178
  const path = joinPaths(controller.path, route.path);
@@ -29,19 +186,40 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
29
186
  if (route.response !== undefined)
30
187
  schema.response = route.response;
31
188
  const handler = async (ctx) => {
32
- const source = controller.scope === "request" ? ctx.scope : services;
189
+ const requestContext = ctx.requestContext ?? await ctxFactory(ctx.request);
190
+ const requestScope = createRequestScope ? createRequestScope(services, requestContext, imported) : undefined;
191
+ const source = controller.scope === "request" ? requestScope : services;
33
192
  const instance = source?.[controller.serviceKey];
34
193
  const method = instance?.[route.handler];
35
194
  if (typeof method !== "function") {
36
195
  throw new Error(`supacloud: controller "${controller.serviceKey}" has no handler "${route.handler}" in scope "${controller.scope}"`);
37
196
  }
38
- return method.call(instance, {
197
+ const input = {
39
198
  body: ctx.body,
40
199
  params: ctx.params,
41
200
  query: ctx.query,
42
201
  request: ctx.request,
43
- scope: ctx.scope
44
- });
202
+ scope: requestScope,
203
+ requestContext
204
+ };
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,
217
+ input,
218
+ request: ctx.request,
219
+ requestContext,
220
+ scope: requestScope,
221
+ services
222
+ }, invoke);
45
223
  };
46
224
  switch (route.method) {
47
225
  case "GET":
@@ -59,19 +237,75 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
59
237
  case "DELETE":
60
238
  plugin.delete(path, handler, schema);
61
239
  break;
240
+ case "HEAD":
241
+ plugin.head(path, handler, schema);
242
+ break;
243
+ case "OPTIONS":
244
+ plugin.options(path, handler, schema);
245
+ break;
62
246
  }
63
247
  }
64
248
  }
249
+ plugin.onError({ as: "global" }, async ({ code, error, request }) => {
250
+ const context = {
251
+ request,
252
+ requestContext: requestContexts.get(request),
253
+ frameworkCode: code
254
+ };
255
+ const mapped = await options.errorMapper?.(error, context);
256
+ return mapped ?? defaultErrorResponse(error, code);
257
+ });
65
258
  return plugin;
66
259
  }
260
+ function defaultErrorResponse(error, frameworkCode) {
261
+ if (isPublicApplicationError(error)) {
262
+ return Response.json({
263
+ ok: false,
264
+ code: error.code,
265
+ message: error.message,
266
+ ...error.details === undefined ? {} : { details: error.details }
267
+ }, { status: error.status });
268
+ }
269
+ if (frameworkCode === "VALIDATION") {
270
+ return Response.json({
271
+ ok: false,
272
+ code: "VALIDATION_ERROR",
273
+ message: "Request validation failed"
274
+ }, { status: 422 });
275
+ }
276
+ return Response.json({
277
+ ok: false,
278
+ code: "INTERNAL_ERROR",
279
+ message: "Internal Server Error"
280
+ }, { status: 500 });
281
+ }
282
+ function isPublicApplicationError(error) {
283
+ if (!(error instanceof Error))
284
+ return false;
285
+ const candidate = error;
286
+ 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;
287
+ }
67
288
  function createApplication(options) {
68
289
  const app = new Elysia({ name: options.name ?? "supacloud:app" });
69
- const ctxFactory = options.requestContext ?? defaultRequestContext;
290
+ const configuredContextFactory = options.requestContext ?? defaultRequestContext;
291
+ const contextCache = new WeakMap;
292
+ const ctxFactory = (request) => {
293
+ const cached = contextCache.get(request);
294
+ if (cached)
295
+ return cached;
296
+ const pending = Promise.resolve(configuredContextFactory(request));
297
+ contextCache.set(request, pending);
298
+ return pending;
299
+ };
70
300
  const imported = {};
71
301
  for (const module of options.modules) {
72
302
  const services = module.createServices(options.deps ?? {}, imported);
73
303
  imported[module.name] = services;
74
- app.use(createModulePlugin(module, services, ctxFactory));
304
+ app.use(createModulePlugin(module, services, ctxFactory, {
305
+ commandGovernance: options.commandGovernance,
306
+ commandExecutor: options.commandExecutor,
307
+ errorMapper: options.errorMapper
308
+ }, imported));
75
309
  }
76
310
  return app;
77
311
  }
@@ -79,7 +313,17 @@ function createTestApp(options) {
79
313
  return createApplication(options);
80
314
  }
81
315
  export {
316
+ ApplicationError,
317
+ EXECUTION_ID_HEADER,
318
+ IDEMPOTENCY_KEY_HEADER,
319
+ VERIFIED_JWT_SUBJECT_HEADER,
320
+ composeCommandExecutors,
82
321
  createApplication,
322
+ createCommandExecutor,
83
323
  createModulePlugin,
84
- createTestApp
324
+ createSupaCloudRequestContext,
325
+ createTestApp,
326
+ defaultErrorResponse,
327
+ requireIdempotencyKey,
328
+ requireTrustedIdentity
85
329
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/elysia",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Elysia runtime adapter for SupaCloud compiled modules: application/request scopes, route registration and validation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",