@supacloud/elysia 0.1.0 → 0.2.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 +53 -81
- package/dist/index.d.ts +104 -2
- package/dist/index.js +256 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,55 +1,34 @@
|
|
|
1
1
|
# @supacloud/elysia
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
101
|
-
|
|
72
|
+
Lightweight error class carrying HTTP `status`, machine-readable `code`, and
|
|
73
|
+
optional structured `details`.
|
package/dist/index.d.ts
CHANGED
|
@@ -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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
13
|
-
if (
|
|
14
|
-
|
|
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" });
|
|
18
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
|
+
}
|
|
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
|
|
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
|
-
|
|
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:
|
|
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":
|
|
@@ -62,16 +240,66 @@ function createModulePlugin(compiled, services, ctxFactory = defaultRequestConte
|
|
|
62
240
|
}
|
|
63
241
|
}
|
|
64
242
|
}
|
|
243
|
+
plugin.onError({ as: "global" }, async ({ code, error, request }) => {
|
|
244
|
+
const context = {
|
|
245
|
+
request,
|
|
246
|
+
requestContext: requestContexts.get(request),
|
|
247
|
+
frameworkCode: code
|
|
248
|
+
};
|
|
249
|
+
const mapped = await options.errorMapper?.(error, context);
|
|
250
|
+
return mapped ?? defaultErrorResponse(error, code);
|
|
251
|
+
});
|
|
65
252
|
return plugin;
|
|
66
253
|
}
|
|
254
|
+
function defaultErrorResponse(error, frameworkCode) {
|
|
255
|
+
if (isPublicApplicationError(error)) {
|
|
256
|
+
return Response.json({
|
|
257
|
+
ok: false,
|
|
258
|
+
code: error.code,
|
|
259
|
+
message: error.message,
|
|
260
|
+
...error.details === undefined ? {} : { details: error.details }
|
|
261
|
+
}, { status: error.status });
|
|
262
|
+
}
|
|
263
|
+
if (frameworkCode === "VALIDATION") {
|
|
264
|
+
return Response.json({
|
|
265
|
+
ok: false,
|
|
266
|
+
code: "VALIDATION_ERROR",
|
|
267
|
+
message: "Request validation failed"
|
|
268
|
+
}, { status: 422 });
|
|
269
|
+
}
|
|
270
|
+
return Response.json({
|
|
271
|
+
ok: false,
|
|
272
|
+
code: "INTERNAL_ERROR",
|
|
273
|
+
message: "Internal Server Error"
|
|
274
|
+
}, { status: 500 });
|
|
275
|
+
}
|
|
276
|
+
function isPublicApplicationError(error) {
|
|
277
|
+
if (!(error instanceof Error))
|
|
278
|
+
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;
|
|
281
|
+
}
|
|
67
282
|
function createApplication(options) {
|
|
68
283
|
const app = new Elysia({ name: options.name ?? "supacloud:app" });
|
|
69
|
-
const
|
|
284
|
+
const configuredContextFactory = options.requestContext ?? defaultRequestContext;
|
|
285
|
+
const contextCache = new WeakMap;
|
|
286
|
+
const ctxFactory = (request) => {
|
|
287
|
+
const cached = contextCache.get(request);
|
|
288
|
+
if (cached)
|
|
289
|
+
return cached;
|
|
290
|
+
const pending = Promise.resolve(configuredContextFactory(request));
|
|
291
|
+
contextCache.set(request, pending);
|
|
292
|
+
return pending;
|
|
293
|
+
};
|
|
70
294
|
const imported = {};
|
|
71
295
|
for (const module of options.modules) {
|
|
72
296
|
const services = module.createServices(options.deps ?? {}, imported);
|
|
73
297
|
imported[module.name] = services;
|
|
74
|
-
app.use(createModulePlugin(module, services, ctxFactory
|
|
298
|
+
app.use(createModulePlugin(module, services, ctxFactory, {
|
|
299
|
+
commandGovernance: options.commandGovernance,
|
|
300
|
+
commandExecutor: options.commandExecutor,
|
|
301
|
+
errorMapper: options.errorMapper
|
|
302
|
+
}, imported));
|
|
75
303
|
}
|
|
76
304
|
return app;
|
|
77
305
|
}
|
|
@@ -79,7 +307,17 @@ function createTestApp(options) {
|
|
|
79
307
|
return createApplication(options);
|
|
80
308
|
}
|
|
81
309
|
export {
|
|
310
|
+
ApplicationError,
|
|
311
|
+
EXECUTION_ID_HEADER,
|
|
312
|
+
IDEMPOTENCY_KEY_HEADER,
|
|
313
|
+
VERIFIED_JWT_SUBJECT_HEADER,
|
|
314
|
+
composeCommandExecutors,
|
|
82
315
|
createApplication,
|
|
316
|
+
createCommandExecutor,
|
|
83
317
|
createModulePlugin,
|
|
84
|
-
|
|
318
|
+
createSupaCloudRequestContext,
|
|
319
|
+
createTestApp,
|
|
320
|
+
defaultErrorResponse,
|
|
321
|
+
requireIdempotencyKey,
|
|
322
|
+
requireTrustedIdentity
|
|
85
323
|
};
|
package/package.json
CHANGED