@telorun/http-server 0.1.7 → 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/CHANGELOG.md +54 -0
- package/README.md +150 -0
- package/dist/http-api-controller.d.ts +51 -30
- package/dist/http-api-controller.js +149 -92
- package/dist/http-server-controller.d.ts +4 -3
- package/dist/http-server-controller.js +31 -10
- package/package.json +21 -2
- package/src/http-api-controller.ts +198 -112
- package/src/http-server-controller.ts +54 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# @telorun/http-server
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 353d7e5: feat: invocable errors — structured error channel end-to-end
|
|
8
|
+
|
|
9
|
+
Invocables and runnables now have a first-class structured-error channel for domain failures (`InvokeError`), distinct from operational failures (plain `Error` / `RuntimeError`). Route handlers branch on named codes via `catches:`; sequences catch with `error.code` / `error.message` / `error.data` / `error.step` context.
|
|
10
|
+
|
|
11
|
+
**SDK** (`@telorun/sdk`)
|
|
12
|
+
|
|
13
|
+
- New `InvokeError` class + `isInvokeError` guard. Symbol-based discrimination (`Symbol.for("telo.InvokeError")`) is dual-realm-safe across pnpm hoist splits, registry modules, and future sandbox isolation.
|
|
14
|
+
- `ResourceDefinition.throws`: declared-throw contract (`codes` map, `inherit: true`, `passthrough: true`).
|
|
15
|
+
- `ResourceContext` / `EvaluationContext` gain `invokeResolved(kind, name, instance, inputs)` for callers that already hold a resolved instance.
|
|
16
|
+
|
|
17
|
+
**Kernel** (`@telorun/kernel`)
|
|
18
|
+
|
|
19
|
+
- Single emission point for invoke-level events: `Invoked` / `InvokeRejected` / `InvokeFailed` / `InvokeRejected.Undeclared`. All call paths (direct invoke, sequence scope path, HTTP route handler) route through the same wrapper.
|
|
20
|
+
- `Telo.Definition.throws:` schema with per-capability restrictions (rule 8: only on Invocable / Runnable).
|
|
21
|
+
- `resolveChildren` now auto-registers bare-kind inline refs when a resource name is supplied without an explicit name on the ref — lets stateless invocables like `Run.Throw` be used inline via `invoke: {kind: Run.Throw}`.
|
|
22
|
+
|
|
23
|
+
**Analyzer** (`@telorun/analyzer`)
|
|
24
|
+
|
|
25
|
+
- New dataflow resolver (`resolve-throws-union.ts`) for `inherit: true` / `passthrough: true` declarations. Walks `x-telo-step-context` arrays generically, applies `try`/`catch` subtraction, detects cycles, memoises per manifest.
|
|
26
|
+
- New coverage validator (`validate-throws-coverage.ts`) — rules 1/2/4/7 for `catches:` lists. Coverage-proving CEL parser recognises `error.code == 'X'`, disjunctions, and `error.code in [...]`. Typed `error.data.<field>` access against per-code `data:` schemas, with intersection narrowing for disjunctive `when:` clauses.
|
|
27
|
+
- New error codes: `UNDECLARED_THROW_CODE`, `UNCOVERED_THROW_CODE`, `UNBOUNDED_UNION_NEEDS_CATCHALL`, `CATCHALL_NOT_LAST`, `INHERIT_WITHOUT_STEP_CONTEXT`.
|
|
28
|
+
|
|
29
|
+
**Run module** (`@telorun/run`)
|
|
30
|
+
|
|
31
|
+
- `Run.Sequence` declares `throws: { inherit: true }`. Its effective union is resolved from step invocables at analysis time.
|
|
32
|
+
- New `Run.Throw` invocable: takes `{code, message, data?}` and throws `InvokeError`. Declared with `throws: { passthrough: true }`; the analyzer resolves constant / `error.code`-inside-catch forms at each call site.
|
|
33
|
+
- Sequence `try`/`catch` `error` context gains `data?: unknown` and now branches on `isInvokeError`.
|
|
34
|
+
|
|
35
|
+
**HTTP server module** (`@telorun/http-server`) — **breaking**
|
|
36
|
+
|
|
37
|
+
- Route-level `response:` is replaced by two channel lists: `returns:` (how to render handler results) and `catches:` (how to render `InvokeError` throws). Applies to both `Http.Api` routes and `Http.Server.notFoundHandler`.
|
|
38
|
+
- Plain `Error` / `RuntimeError` throws skip `catches:` and fall through to Fastify's default 5xx renderer — operational vs. domain failures are now distinct on the wire.
|
|
39
|
+
- `catches:` entries reject `mode: stream` at schema validation (structured errors always render as JSON).
|
|
40
|
+
- Unmatched `returns:` dispatch now throws (surfaces via Fastify's error handler) instead of rendering a silent 500.
|
|
41
|
+
- Every `response:` occurrence across the repo (apps, benchmarks, examples, tests) migrated to `returns:` — no manifest carries the old shape.
|
|
42
|
+
|
|
43
|
+
See `sdk/nodejs/plans/invocable-errors.md` for the full design and rollout phasing.
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- Updated dependencies [353d7e5]
|
|
48
|
+
- @telorun/sdk@0.3.0
|
|
49
|
+
|
|
50
|
+
## 0.1.8
|
|
51
|
+
|
|
52
|
+
### Patch Changes
|
|
53
|
+
|
|
54
|
+
- Updated dependencies
|
|
55
|
+
- @telorun/sdk@0.2.8
|
|
56
|
+
|
|
3
57
|
## 0.1.7
|
|
4
58
|
|
|
5
59
|
### Patch Changes
|
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Telo HTTP Standard Specification (v1.0 Draft)
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The `Http.Server` and `Http.Api` manifests in Telo are designed to be strictly **language-agnostic** and **framework-agnostic**. To maintain the "Zero Lock-in" promise, the underlying HTTP engine (e.g., Fastify in Node.js, Actix in Rust) is treated purely as an implementation detail.
|
|
6
|
+
|
|
7
|
+
All HTTP modules integrated into the Telo kernel **must** adhere to this behavioral contract. This ensures that a YAML manifest written today will execute with exactly the same I/O and validation behavior regardless of the underlying language or framework.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. Routing Contract (Path Definitions)
|
|
12
|
+
|
|
13
|
+
Different web frameworks use different syntaxes for path parameters (e.g., `/users/:id` vs. `/users/{id}`).
|
|
14
|
+
|
|
15
|
+
Telo standardizes on the **OpenAPI specification format** for paths.
|
|
16
|
+
|
|
17
|
+
- **Standard:** Path parameters MUST be enclosed in curly braces: `{parameterName}`.
|
|
18
|
+
- **Module Responsibility:** The underlying HTTP module must parse the Telo path and translate it into its framework's native routing syntax at startup.
|
|
19
|
+
|
|
20
|
+
**Example Manifest Path:** `/api/v1/users/{userId}`
|
|
21
|
+
|
|
22
|
+
- _Node.js (Fastify) Adapter translates to:_ `/api/v1/users/:userId`
|
|
23
|
+
- _Rust (Actix) Adapter translates to:_ `/api/v1/users/{userId}`
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 2. The I/O Context Contract
|
|
28
|
+
|
|
29
|
+
When an incoming HTTP request is received, the underlying framework must normalize it into a standard **Telo Request Object** before passing it to the Handler/CEL engine. Conversely, it must accept a standard **Telo Response Object** to send back to the client.
|
|
30
|
+
|
|
31
|
+
### 2.1. Standardized Telo Request Object (Input)
|
|
32
|
+
|
|
33
|
+
The HTTP module must construct and pass the following exact payload to the execution environment:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"request": {
|
|
38
|
+
"method": "POST",
|
|
39
|
+
"path": "/api/v1/users/123",
|
|
40
|
+
"params": { "userId": "123" },
|
|
41
|
+
"query": { "active": "true" },
|
|
42
|
+
"headers": {
|
|
43
|
+
"content-type": "application/json",
|
|
44
|
+
"authorization": "Bearer token..."
|
|
45
|
+
},
|
|
46
|
+
"body": {
|
|
47
|
+
"name": "Alice",
|
|
48
|
+
"age": 30
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- **Constraint:** All `headers` keys MUST be normalized to lowercase.
|
|
55
|
+
- **Constraint:** If the `content-type` is `application/json`, the `body` MUST be parsed into a native object/dictionary before evaluation.
|
|
56
|
+
|
|
57
|
+
### 2.2. Standardized Telo Response Object (Output)
|
|
58
|
+
|
|
59
|
+
After the Handler executes and the `response.mapping` evaluates, the engine will return an object to the HTTP module. The module must map this directly to the native HTTP response.
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"status": 200,
|
|
64
|
+
"headers": {
|
|
65
|
+
"x-telo-runtime": "0.1.0",
|
|
66
|
+
"content-type": "application/json"
|
|
67
|
+
},
|
|
68
|
+
"body": {
|
|
69
|
+
"id": "123",
|
|
70
|
+
"status": "created"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 3. Validation & Error Handling Contract
|
|
78
|
+
|
|
79
|
+
When a request fails schema validation (defined in the `request.schema` of the manifest), the underlying engine (e.g., AJV in Fastify) will generate native errors. **These internal errors must not leak to the client.**
|
|
80
|
+
|
|
81
|
+
All Telo HTTP modules MUST intercept framework-specific validation errors and return a standardized HTTP 400 Bad Request payload.
|
|
82
|
+
|
|
83
|
+
### Standardized Validation Error Format
|
|
84
|
+
|
|
85
|
+
The response body must strictly follow this JSON structure:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"error": "ValidationError",
|
|
90
|
+
"message": "Request validation failed",
|
|
91
|
+
"status": 400,
|
|
92
|
+
"details": [
|
|
93
|
+
{
|
|
94
|
+
"location": "body",
|
|
95
|
+
"path": "user.age",
|
|
96
|
+
"message": "must be an integer"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"location": "query",
|
|
100
|
+
"path": "active",
|
|
101
|
+
"message": "is a required property"
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- **`location` enum:** `body` | `query` | `params` | `headers`
|
|
108
|
+
- **Module Responsibility:** The module author must write an error handler/mapper that transforms the native framework's validation output into the Telo `details` array.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 4. Manifest Schema Upgrades
|
|
113
|
+
|
|
114
|
+
To fully support this contract, the `Http.Api` JSON Schema definition includes the following structural definitions for the `request` block:
|
|
115
|
+
|
|
116
|
+
```yaml
|
|
117
|
+
request:
|
|
118
|
+
type: "object"
|
|
119
|
+
properties:
|
|
120
|
+
path:
|
|
121
|
+
type: "string"
|
|
122
|
+
description: "Must use OpenAPI style path parameters, e.g., /users/{id}"
|
|
123
|
+
method:
|
|
124
|
+
type: "string"
|
|
125
|
+
enum: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
|
|
126
|
+
consumes:
|
|
127
|
+
type: "array"
|
|
128
|
+
items: { type: "string" }
|
|
129
|
+
default: ["application/json"]
|
|
130
|
+
produces:
|
|
131
|
+
type: "array"
|
|
132
|
+
items: { type: "string" }
|
|
133
|
+
default: ["application/json"]
|
|
134
|
+
schema:
|
|
135
|
+
type: "object"
|
|
136
|
+
properties:
|
|
137
|
+
params:
|
|
138
|
+
type: "object"
|
|
139
|
+
description: "Validation schema for path parameters"
|
|
140
|
+
query:
|
|
141
|
+
type: "object"
|
|
142
|
+
description: "Validation schema for query string parameters"
|
|
143
|
+
headers:
|
|
144
|
+
type: "object"
|
|
145
|
+
description: "Validation schema for HTTP headers"
|
|
146
|
+
body:
|
|
147
|
+
type: "object"
|
|
148
|
+
description: "Validation schema for the request payload"
|
|
149
|
+
required: ["path", "method"]
|
|
150
|
+
```
|
|
@@ -1,33 +1,30 @@
|
|
|
1
1
|
import { Static } from "@sinclair/typebox";
|
|
2
2
|
import { ControllerContext, Invocable, KindRef, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
3
3
|
import { FastifyInstance, FastifyReply } from "fastify";
|
|
4
|
-
declare const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
query: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
11
|
-
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
12
|
-
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
13
|
-
}>>;
|
|
14
|
-
}>;
|
|
15
|
-
handler: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnsafe<KindRef<Invocable<Record<string, any>, any>>>>;
|
|
16
|
-
inputs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TAny>>;
|
|
17
|
-
response: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
18
|
-
status: import("@sinclair/typebox").TInteger;
|
|
19
|
-
when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
20
|
-
mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"buffer">, import("@sinclair/typebox").TLiteral<"stream">]>>;
|
|
21
|
-
schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
22
|
-
query: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
23
|
-
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
24
|
-
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
25
|
-
}>>;
|
|
26
|
-
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
4
|
+
declare const ReturnEntry: import("@sinclair/typebox").TObject<{
|
|
5
|
+
status: import("@sinclair/typebox").TInteger;
|
|
6
|
+
when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
7
|
+
mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"buffer">, import("@sinclair/typebox").TLiteral<"stream">]>>;
|
|
8
|
+
schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
9
|
+
query: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
27
10
|
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
11
|
+
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
28
12
|
}>>;
|
|
13
|
+
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
14
|
+
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
29
15
|
}>;
|
|
30
|
-
type
|
|
16
|
+
type ReturnEntry = Static<typeof ReturnEntry>;
|
|
17
|
+
declare const CatchEntry: import("@sinclair/typebox").TObject<{
|
|
18
|
+
status: import("@sinclair/typebox").TInteger;
|
|
19
|
+
when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
20
|
+
schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
21
|
+
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
22
|
+
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
23
|
+
}>>;
|
|
24
|
+
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
25
|
+
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
26
|
+
}>;
|
|
27
|
+
type CatchEntry = Static<typeof CatchEntry>;
|
|
31
28
|
declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
|
|
32
29
|
routes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
33
30
|
request: import("@sinclair/typebox").TObject<{
|
|
@@ -42,7 +39,7 @@ declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
|
|
|
42
39
|
}>;
|
|
43
40
|
handler: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnsafe<KindRef<Invocable<Record<string, any>, any>>>>;
|
|
44
41
|
inputs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TAny>>;
|
|
45
|
-
|
|
42
|
+
returns: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
46
43
|
status: import("@sinclair/typebox").TInteger;
|
|
47
44
|
when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
48
45
|
mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"buffer">, import("@sinclair/typebox").TLiteral<"stream">]>>;
|
|
@@ -54,22 +51,46 @@ declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
|
|
|
54
51
|
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
55
52
|
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
56
53
|
}>>;
|
|
54
|
+
catches: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
55
|
+
status: import("@sinclair/typebox").TInteger;
|
|
56
|
+
when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
57
|
+
schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
58
|
+
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
59
|
+
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
60
|
+
}>>;
|
|
61
|
+
headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
62
|
+
body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
|
|
63
|
+
}>>>;
|
|
57
64
|
}>>;
|
|
58
65
|
}>;
|
|
59
66
|
type HttpApiManifest = Static<typeof HttpApiManifest>;
|
|
60
67
|
export declare function register(_ctx: ControllerContext): Promise<void>;
|
|
61
|
-
export type
|
|
62
|
-
|
|
68
|
+
export type { ReturnEntry, CatchEntry };
|
|
69
|
+
type ModuleLikeContext = {
|
|
63
70
|
expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
|
|
64
|
-
}
|
|
71
|
+
};
|
|
72
|
+
type ValidateSchema = (value: unknown, schema: unknown) => void;
|
|
73
|
+
type HandlerRef = {
|
|
74
|
+
kind: string;
|
|
75
|
+
name: string;
|
|
76
|
+
};
|
|
77
|
+
export declare function dispatchReturns(returns: ReturnEntry[], result: unknown, requestContext: Record<string, unknown>, moduleContext: ModuleLikeContext, validateSchema: ValidateSchema, reply: FastifyReply): Promise<void>;
|
|
78
|
+
/** Render an InvokeError through a `catches:` list. Falls back to a structured
|
|
79
|
+
* 500 when no entry matches. Plain (non-InvokeError) throws never reach this
|
|
80
|
+
* function — the caller re-throws them to Fastify. */
|
|
81
|
+
export declare function dispatchCatches(catches: CatchEntry[] | undefined, error: {
|
|
82
|
+
code: string;
|
|
83
|
+
message: string;
|
|
84
|
+
data?: unknown;
|
|
85
|
+
}, requestContext: Record<string, unknown>, moduleContext: ModuleLikeContext, validateSchema: ValidateSchema, reply: FastifyReply): Promise<void>;
|
|
65
86
|
export declare class HttpServerApi implements ResourceInstance {
|
|
66
87
|
private readonly ctx;
|
|
67
88
|
readonly manifest: HttpApiManifest;
|
|
68
|
-
|
|
89
|
+
private readonly handlerRefs;
|
|
90
|
+
constructor(ctx: ResourceContext, manifest: HttpApiManifest, handlerRefs: WeakMap<object, HandlerRef>);
|
|
69
91
|
init(): Promise<void>;
|
|
70
92
|
register(app: FastifyInstance, prefix?: string): void;
|
|
71
93
|
private registerRoutes;
|
|
72
94
|
private registerRoute;
|
|
73
95
|
}
|
|
74
96
|
export declare function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi>;
|
|
75
|
-
export {};
|
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
|
-
import { Ref, } from "@telorun/sdk";
|
|
2
|
+
import { isInvokeError, Ref, } from "@telorun/sdk";
|
|
3
3
|
import { pipeline } from "stream/promises";
|
|
4
|
+
const ReturnEntry = Type.Object({
|
|
5
|
+
status: Type.Integer({ minimum: 100, maximum: 599 }),
|
|
6
|
+
when: Type.Optional(Type.String()),
|
|
7
|
+
mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
|
|
8
|
+
schema: Type.Optional(Type.Object({
|
|
9
|
+
query: Type.Optional(Type.Any()),
|
|
10
|
+
body: Type.Optional(Type.Any()),
|
|
11
|
+
headers: Type.Optional(Type.Any()),
|
|
12
|
+
})),
|
|
13
|
+
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
14
|
+
body: Type.Optional(Type.Any()),
|
|
15
|
+
});
|
|
16
|
+
const CatchEntry = Type.Object({
|
|
17
|
+
status: Type.Integer({ minimum: 100, maximum: 599 }),
|
|
18
|
+
when: Type.Optional(Type.String()),
|
|
19
|
+
schema: Type.Optional(Type.Object({
|
|
20
|
+
body: Type.Optional(Type.Any()),
|
|
21
|
+
headers: Type.Optional(Type.Any()),
|
|
22
|
+
})),
|
|
23
|
+
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
24
|
+
body: Type.Optional(Type.Any()),
|
|
25
|
+
});
|
|
4
26
|
const HttpApiRouteManifest = Type.Object({
|
|
5
27
|
request: Type.Object({
|
|
6
28
|
path: Type.String(),
|
|
@@ -12,79 +34,98 @@ const HttpApiRouteManifest = Type.Object({
|
|
|
12
34
|
headers: Type.Optional(Type.Any()),
|
|
13
35
|
})),
|
|
14
36
|
}),
|
|
15
|
-
handler: Type.Optional(Type.Unsafe(Ref("
|
|
37
|
+
handler: Type.Optional(Type.Unsafe(Ref("telo#Invocable"))),
|
|
16
38
|
inputs: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
when: Type.Optional(Type.String()),
|
|
20
|
-
mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
|
|
21
|
-
schema: Type.Optional(Type.Object({
|
|
22
|
-
query: Type.Optional(Type.Any()),
|
|
23
|
-
body: Type.Optional(Type.Any()),
|
|
24
|
-
headers: Type.Optional(Type.Any()),
|
|
25
|
-
})),
|
|
26
|
-
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
27
|
-
body: Type.Optional(Type.Any()),
|
|
28
|
-
})),
|
|
39
|
+
returns: Type.Array(ReturnEntry),
|
|
40
|
+
catches: Type.Optional(Type.Array(CatchEntry)),
|
|
29
41
|
});
|
|
30
42
|
const HttpApiManifest = Type.Object({
|
|
31
43
|
routes: Type.Array(HttpApiRouteManifest),
|
|
32
44
|
});
|
|
33
45
|
export async function register(_ctx) { }
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
/** Pick the first entry whose `when:` evaluates truthy, falling back to the
|
|
47
|
+
* first entry with no `when:` (the list's catch-all). */
|
|
48
|
+
function matchEntry(entries, celCtx, moduleContext) {
|
|
36
49
|
let fallback;
|
|
37
|
-
for (const entry of
|
|
50
|
+
for (const entry of entries) {
|
|
38
51
|
if (!entry.when) {
|
|
39
52
|
fallback ??= entry;
|
|
40
53
|
continue;
|
|
41
54
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
matched = entry;
|
|
45
|
-
break;
|
|
46
|
-
}
|
|
55
|
+
if (moduleContext.expandWith(entry.when, celCtx) === true)
|
|
56
|
+
return entry;
|
|
47
57
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
60
|
+
export async function dispatchReturns(returns, result, requestContext, moduleContext, validateSchema, reply) {
|
|
61
|
+
const celCtx = { result, ...requestContext };
|
|
62
|
+
const entry = matchEntry(returns, celCtx, moduleContext);
|
|
63
|
+
if (!entry) {
|
|
64
|
+
// Unreachable when the analyzer has run — every route's returns: list must
|
|
65
|
+
// cover its handler's return values (explicit when: or catch-all). Hitting
|
|
66
|
+
// this at runtime means something bypassed analysis; surface it loudly
|
|
67
|
+
// via Fastify's error handler rather than quietly render a 500.
|
|
68
|
+
throw new Error("No matching returns entry for handler result — the route's returns: list must cover every return value (add a catch-all entry or widen a when: clause)");
|
|
57
69
|
}
|
|
58
|
-
reply.code(
|
|
59
|
-
if (
|
|
60
|
-
const mappedHeaders = moduleContext.expandWith(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
Object.entries(mappedHeaders).forEach(([key, value]) => reply.header(key, value));
|
|
70
|
+
reply.code(entry.status);
|
|
71
|
+
if (entry.headers) {
|
|
72
|
+
const mappedHeaders = moduleContext.expandWith(entry.headers, celCtx);
|
|
73
|
+
for (const [key, value] of Object.entries(mappedHeaders)) {
|
|
74
|
+
reply.header(key, value);
|
|
75
|
+
}
|
|
65
76
|
}
|
|
66
|
-
if (
|
|
77
|
+
if (entry.mode === "stream") {
|
|
67
78
|
reply.hijack();
|
|
68
|
-
reply.raw.writeHead(
|
|
79
|
+
reply.raw.writeHead(entry.status, reply.getHeaders());
|
|
69
80
|
await pipeline(result, reply.raw);
|
|
70
81
|
return;
|
|
71
82
|
}
|
|
72
|
-
if (
|
|
73
|
-
const mappedBody = moduleContext.expandWith(
|
|
74
|
-
if (
|
|
75
|
-
validateSchema(mappedBody,
|
|
76
|
-
}
|
|
83
|
+
if (entry.body !== undefined) {
|
|
84
|
+
const mappedBody = moduleContext.expandWith(entry.body, celCtx);
|
|
85
|
+
if (entry.schema?.body)
|
|
86
|
+
validateSchema(mappedBody, entry.schema.body);
|
|
77
87
|
reply.send(mappedBody);
|
|
78
88
|
return;
|
|
79
89
|
}
|
|
80
90
|
reply.send(result);
|
|
81
91
|
}
|
|
92
|
+
/** Render an InvokeError through a `catches:` list. Falls back to a structured
|
|
93
|
+
* 500 when no entry matches. Plain (non-InvokeError) throws never reach this
|
|
94
|
+
* function — the caller re-throws them to Fastify. */
|
|
95
|
+
export async function dispatchCatches(catches, error, requestContext, moduleContext, validateSchema, reply) {
|
|
96
|
+
const celCtx = { error, ...requestContext };
|
|
97
|
+
const entry = catches ? matchEntry(catches, celCtx, moduleContext) : undefined;
|
|
98
|
+
if (!entry) {
|
|
99
|
+
reply.code(500);
|
|
100
|
+
reply.send({
|
|
101
|
+
error: { code: error.code, message: error.message, data: error.data },
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
reply.code(entry.status);
|
|
106
|
+
if (entry.headers) {
|
|
107
|
+
const mappedHeaders = moduleContext.expandWith(entry.headers, celCtx);
|
|
108
|
+
for (const [key, value] of Object.entries(mappedHeaders)) {
|
|
109
|
+
reply.header(key, value);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (entry.body !== undefined) {
|
|
113
|
+
const mappedBody = moduleContext.expandWith(entry.body, celCtx);
|
|
114
|
+
if (entry.schema?.body)
|
|
115
|
+
validateSchema(mappedBody, entry.schema.body);
|
|
116
|
+
reply.send(mappedBody);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
reply.send({ error: { code: error.code, message: error.message, data: error.data } });
|
|
120
|
+
}
|
|
82
121
|
export class HttpServerApi {
|
|
83
122
|
ctx;
|
|
84
123
|
manifest;
|
|
85
|
-
|
|
124
|
+
handlerRefs;
|
|
125
|
+
constructor(ctx, manifest, handlerRefs) {
|
|
86
126
|
this.ctx = ctx;
|
|
87
127
|
this.manifest = manifest;
|
|
128
|
+
this.handlerRefs = handlerRefs;
|
|
88
129
|
}
|
|
89
130
|
async init() { }
|
|
90
131
|
register(app, prefix = "") {
|
|
@@ -106,68 +147,84 @@ export class HttpServerApi {
|
|
|
106
147
|
registerRoute(app, route) {
|
|
107
148
|
// After Phase 5 injection, KindRef<Invocable> is replaced with the live Invocable instance.
|
|
108
149
|
const handler = route.handler;
|
|
150
|
+
const handlerRef = this.handlerRefs.get(route);
|
|
151
|
+
const handlerKind = handlerRef?.kind ?? "";
|
|
152
|
+
const handlerName = handlerRef?.name ?? "";
|
|
109
153
|
const translatedPath = translateOpenApiPath(route.request.path);
|
|
110
|
-
const schema = {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (route.request.schema?.
|
|
114
|
-
schema.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
154
|
+
const schema = { response: {} };
|
|
155
|
+
if (route.request.schema?.query)
|
|
156
|
+
schema.querystring = route.request.schema.query;
|
|
157
|
+
if (route.request.schema?.params)
|
|
158
|
+
schema.params = route.request.schema.params;
|
|
159
|
+
if (route.request.schema?.body)
|
|
160
|
+
schema.body = route.request.schema.body;
|
|
161
|
+
if (route.request.schema?.headers)
|
|
162
|
+
schema.headers = route.request.schema.headers;
|
|
163
|
+
for (const entry of route.returns) {
|
|
164
|
+
if (entry.schema?.body)
|
|
165
|
+
schema.response[entry.status] = entry.schema.body;
|
|
166
|
+
else if (entry.schema)
|
|
167
|
+
schema.response[entry.status] = {};
|
|
121
168
|
}
|
|
122
|
-
if (route.request.schema?.headers) {
|
|
123
|
-
schema.headers = route.request.schema?.headers;
|
|
124
|
-
}
|
|
125
|
-
schema.response = route.response.reduce((acc, entry) => {
|
|
126
|
-
if (entry.schema?.body) {
|
|
127
|
-
acc[entry.status] = entry.schema.body;
|
|
128
|
-
}
|
|
129
|
-
else if (entry.schema) {
|
|
130
|
-
acc[entry.status] = {};
|
|
131
|
-
}
|
|
132
|
-
return acc;
|
|
133
|
-
}, {});
|
|
134
169
|
app.route({
|
|
135
170
|
method: route.request.method,
|
|
136
171
|
url: translatedPath,
|
|
137
172
|
schema,
|
|
138
173
|
handler: async (request, reply) => {
|
|
174
|
+
const requestContext = {
|
|
175
|
+
request: {
|
|
176
|
+
method: request.method,
|
|
177
|
+
path: request.url,
|
|
178
|
+
params: request.params || {},
|
|
179
|
+
query: request.query || {},
|
|
180
|
+
headers: normalizeHeaders(request.headers),
|
|
181
|
+
body: request.body,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
const resolvedInputs = route.inputs
|
|
185
|
+
? (this.ctx.moduleContext.expandWith(route.inputs, requestContext) ?? {})
|
|
186
|
+
: requestContext;
|
|
187
|
+
const invokeInput = {
|
|
188
|
+
...resolvedInputs,
|
|
189
|
+
inputs: resolvedInputs,
|
|
190
|
+
};
|
|
191
|
+
let result;
|
|
139
192
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
path: request.url,
|
|
144
|
-
params: request.params || {},
|
|
145
|
-
query: request.query || {},
|
|
146
|
-
headers: normalizeHeaders(request.headers),
|
|
147
|
-
body: request.body,
|
|
148
|
-
},
|
|
149
|
-
};
|
|
150
|
-
const resolvedInputs = route.inputs
|
|
151
|
-
? (this.ctx.moduleContext.expandWith(route.inputs, requestContext) ?? {})
|
|
152
|
-
: requestContext;
|
|
153
|
-
const invokeInput = {
|
|
154
|
-
...resolvedInputs,
|
|
155
|
-
inputs: resolvedInputs,
|
|
156
|
-
};
|
|
157
|
-
const result = handler ? await handler.invoke(invokeInput) : undefined;
|
|
158
|
-
return dispatchResponse(route.response, result, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
|
|
193
|
+
result = handler
|
|
194
|
+
? await this.ctx.invokeResolved(handlerKind, handlerName, handler, invokeInput)
|
|
195
|
+
: undefined;
|
|
159
196
|
}
|
|
160
|
-
catch (
|
|
161
|
-
|
|
162
|
-
|
|
197
|
+
catch (err) {
|
|
198
|
+
if (!isInvokeError(err))
|
|
199
|
+
throw err;
|
|
200
|
+
return dispatchCatches(route.catches, { code: err.code, message: err.message, data: err.data }, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
|
|
163
201
|
}
|
|
202
|
+
return dispatchReturns(route.returns, result, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
|
|
164
203
|
},
|
|
165
204
|
});
|
|
166
205
|
}
|
|
167
206
|
}
|
|
168
207
|
export async function create(resource, ctx) {
|
|
169
208
|
ctx.validateSchema(resource, HttpApiManifest);
|
|
170
|
-
|
|
209
|
+
// Capture handler {kind, name} before Phase 5 injection overwrites the ref
|
|
210
|
+
// with a live Invocable instance. invokeResolved() needs the kind/name to
|
|
211
|
+
// emit properly-scoped Invoked / InvokeRejected events.
|
|
212
|
+
const handlerRefs = new WeakMap();
|
|
213
|
+
for (const route of resource.routes ?? []) {
|
|
214
|
+
const h = route.handler;
|
|
215
|
+
if (!h)
|
|
216
|
+
continue;
|
|
217
|
+
if (typeof h === "object") {
|
|
218
|
+
handlerRefs.set(route, ctx.resolveChildren(h));
|
|
219
|
+
}
|
|
220
|
+
else if (typeof h === "string") {
|
|
221
|
+
// String form (schema oneOf: string | object) — only the resource name
|
|
222
|
+
// is given, not the kind. Phase 5 injects the live instance either way;
|
|
223
|
+
// invoke events on this route just emit with an empty kind.
|
|
224
|
+
handlerRefs.set(route, { kind: "", name: h });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return new HttpServerApi(ctx, resource, handlerRefs);
|
|
171
228
|
}
|
|
172
229
|
/**
|
|
173
230
|
* Translates OpenAPI path format {paramName} to Fastify format :paramName
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import { type Invocable, type KindRef, type ResourceContext, type ResourceInstance, type RuntimeResource } from "@telorun/sdk";
|
|
2
|
+
import { CatchEntry, ReturnEntry } from "./http-api-controller.js";
|
|
3
3
|
type CorsOptions = {
|
|
4
4
|
origin?: string | boolean | string[];
|
|
5
5
|
methods?: string | string[];
|
|
@@ -36,7 +36,8 @@ type HttpServerResource = RuntimeResource & {
|
|
|
36
36
|
}>;
|
|
37
37
|
notFoundHandler?: {
|
|
38
38
|
invoke: KindRef<Invocable>;
|
|
39
|
-
|
|
39
|
+
returns?: ReturnEntry[];
|
|
40
|
+
catches?: CatchEntry[];
|
|
40
41
|
};
|
|
41
42
|
};
|
|
42
43
|
export declare function create(resource: HttpServerResource, ctx: ResourceContext): Promise<ResourceInstance | null>;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import cors from "@fastify/cors";
|
|
2
2
|
import swagger from "@fastify/swagger";
|
|
3
3
|
import apiReference from "@scalar/fastify-api-reference";
|
|
4
|
+
import { isInvokeError, } from "@telorun/sdk";
|
|
4
5
|
import addFormats from "ajv-formats";
|
|
5
6
|
import Fastify from "fastify";
|
|
6
|
-
import {
|
|
7
|
+
import { dispatchCatches, dispatchReturns, } from "./http-api-controller.js";
|
|
7
8
|
class HttpServer {
|
|
8
9
|
releaseHold = null;
|
|
9
10
|
pluginsInitialized = false;
|
|
@@ -111,7 +112,7 @@ class HttpServer {
|
|
|
111
112
|
const type = mount.type || "";
|
|
112
113
|
const { kind, name } = parseType(type);
|
|
113
114
|
const prefix = mount.path || "";
|
|
114
|
-
const api = this.ctx.moduleContext.
|
|
115
|
+
const api = this.ctx.moduleContext.getInstance(name);
|
|
115
116
|
if (!api) {
|
|
116
117
|
throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
|
|
117
118
|
}
|
|
@@ -134,9 +135,17 @@ class HttpServer {
|
|
|
134
135
|
body: request.body,
|
|
135
136
|
},
|
|
136
137
|
};
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
let result;
|
|
139
|
+
try {
|
|
140
|
+
result = await this.ctx.invoke(handler.kind, handler.name, requestContext);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (!isInvokeError(err))
|
|
144
|
+
throw err;
|
|
145
|
+
return dispatchCatches(handler.catches, { code: err.code, message: err.message, data: err.data }, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
|
|
146
|
+
}
|
|
147
|
+
if (handler.returns) {
|
|
148
|
+
return dispatchReturns(handler.returns, result, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
|
|
140
149
|
}
|
|
141
150
|
const status = result?.status ?? 200;
|
|
142
151
|
reply.code(status);
|
|
@@ -179,12 +188,24 @@ class HttpServer {
|
|
|
179
188
|
export async function create(resource, ctx) {
|
|
180
189
|
let resolvedNotFoundHandler = null;
|
|
181
190
|
if (resource.notFoundHandler) {
|
|
182
|
-
const
|
|
191
|
+
const invoke = resource.notFoundHandler.invoke;
|
|
192
|
+
let kind = "";
|
|
193
|
+
let name = "";
|
|
194
|
+
if (typeof invoke === "object" && invoke !== null) {
|
|
195
|
+
const resolved = ctx.resolveChildren(invoke);
|
|
196
|
+
kind = resolved.kind;
|
|
197
|
+
name = resolved.name;
|
|
198
|
+
}
|
|
199
|
+
else if (typeof invoke === "string") {
|
|
200
|
+
// String form (schema oneOf: string | object) — resource name only.
|
|
201
|
+
name = invoke;
|
|
202
|
+
}
|
|
183
203
|
resolvedNotFoundHandler = {
|
|
184
|
-
kind
|
|
185
|
-
name
|
|
186
|
-
inputs:
|
|
187
|
-
|
|
204
|
+
kind,
|
|
205
|
+
name,
|
|
206
|
+
inputs: invoke?.inputs ?? {},
|
|
207
|
+
returns: resource.notFoundHandler.returns,
|
|
208
|
+
catches: resource.notFoundHandler.catches,
|
|
188
209
|
};
|
|
189
210
|
}
|
|
190
211
|
return new HttpServer(resource, ctx, resolvedNotFoundHandler);
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/http-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"telo",
|
|
7
|
+
"http",
|
|
8
|
+
"server",
|
|
9
|
+
"api",
|
|
10
|
+
"fastify"
|
|
11
|
+
],
|
|
12
|
+
"author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
|
|
13
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/telorun/telo.git",
|
|
17
|
+
"directory": "modules/http-server/nodejs"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/telorun/telo#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/telorun/telo/issues"
|
|
22
|
+
},
|
|
4
23
|
"type": "module",
|
|
5
24
|
"main": "./dist/index.js",
|
|
6
25
|
"module": "./dist/index.js",
|
|
@@ -24,7 +43,7 @@
|
|
|
24
43
|
"ajv": "^8.17.1",
|
|
25
44
|
"ajv-formats": "^3.0.1",
|
|
26
45
|
"fastify": "^5.7.2",
|
|
27
|
-
"@telorun/sdk": "0.
|
|
46
|
+
"@telorun/sdk": "0.3.0"
|
|
28
47
|
},
|
|
29
48
|
"devDependencies": {
|
|
30
49
|
"@types/node": "^20.0.0",
|
|
@@ -2,6 +2,7 @@ import { Static, Type } from "@sinclair/typebox";
|
|
|
2
2
|
import {
|
|
3
3
|
ControllerContext,
|
|
4
4
|
Invocable,
|
|
5
|
+
isInvokeError,
|
|
5
6
|
KindRef,
|
|
6
7
|
Ref,
|
|
7
8
|
ResourceContext,
|
|
@@ -11,6 +12,36 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
|
11
12
|
import { type Readable } from "stream";
|
|
12
13
|
import { pipeline } from "stream/promises";
|
|
13
14
|
|
|
15
|
+
const ReturnEntry = Type.Object({
|
|
16
|
+
status: Type.Integer({ minimum: 100, maximum: 599 }),
|
|
17
|
+
when: Type.Optional(Type.String()),
|
|
18
|
+
mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
|
|
19
|
+
schema: Type.Optional(
|
|
20
|
+
Type.Object({
|
|
21
|
+
query: Type.Optional(Type.Any()),
|
|
22
|
+
body: Type.Optional(Type.Any()),
|
|
23
|
+
headers: Type.Optional(Type.Any()),
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
27
|
+
body: Type.Optional(Type.Any()),
|
|
28
|
+
});
|
|
29
|
+
type ReturnEntry = Static<typeof ReturnEntry>;
|
|
30
|
+
|
|
31
|
+
const CatchEntry = Type.Object({
|
|
32
|
+
status: Type.Integer({ minimum: 100, maximum: 599 }),
|
|
33
|
+
when: Type.Optional(Type.String()),
|
|
34
|
+
schema: Type.Optional(
|
|
35
|
+
Type.Object({
|
|
36
|
+
body: Type.Optional(Type.Any()),
|
|
37
|
+
headers: Type.Optional(Type.Any()),
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
41
|
+
body: Type.Optional(Type.Any()),
|
|
42
|
+
});
|
|
43
|
+
type CatchEntry = Static<typeof CatchEntry>;
|
|
44
|
+
|
|
14
45
|
const HttpApiRouteManifest = Type.Object({
|
|
15
46
|
request: Type.Object({
|
|
16
47
|
path: Type.String(),
|
|
@@ -24,24 +55,10 @@ const HttpApiRouteManifest = Type.Object({
|
|
|
24
55
|
}),
|
|
25
56
|
),
|
|
26
57
|
}),
|
|
27
|
-
handler: Type.Optional(Type.Unsafe<KindRef<Invocable>>(Ref("
|
|
58
|
+
handler: Type.Optional(Type.Unsafe<KindRef<Invocable>>(Ref("telo#Invocable"))),
|
|
28
59
|
inputs: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
status: Type.Integer({ minimum: 100, maximum: 599 }),
|
|
32
|
-
when: Type.Optional(Type.String()),
|
|
33
|
-
mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
|
|
34
|
-
schema: Type.Optional(
|
|
35
|
-
Type.Object({
|
|
36
|
-
query: Type.Optional(Type.Any()),
|
|
37
|
-
body: Type.Optional(Type.Any()),
|
|
38
|
-
headers: Type.Optional(Type.Any()),
|
|
39
|
-
}),
|
|
40
|
-
),
|
|
41
|
-
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
42
|
-
body: Type.Optional(Type.Any()),
|
|
43
|
-
}),
|
|
44
|
-
),
|
|
60
|
+
returns: Type.Array(ReturnEntry),
|
|
61
|
+
catches: Type.Optional(Type.Array(CatchEntry)),
|
|
45
62
|
});
|
|
46
63
|
type HttpApiRouteManifest = Static<typeof HttpApiRouteManifest>;
|
|
47
64
|
|
|
@@ -52,66 +69,77 @@ type HttpApiManifest = Static<typeof HttpApiManifest>;
|
|
|
52
69
|
|
|
53
70
|
export async function register(_ctx: ControllerContext): Promise<void> {}
|
|
54
71
|
|
|
55
|
-
export type
|
|
56
|
-
(typeof HttpApiRouteManifest)["properties"]["response"]["items"]
|
|
57
|
-
>;
|
|
72
|
+
export type { ReturnEntry, CatchEntry };
|
|
58
73
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
): Promise<void> {
|
|
67
|
-
let matched: ResponseEntry | undefined;
|
|
68
|
-
let fallback: ResponseEntry | undefined;
|
|
74
|
+
type ModuleLikeContext = {
|
|
75
|
+
expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type ValidateSchema = (value: unknown, schema: unknown) => void;
|
|
79
|
+
|
|
80
|
+
type HandlerRef = { kind: string; name: string };
|
|
69
81
|
|
|
70
|
-
|
|
82
|
+
/** Pick the first entry whose `when:` evaluates truthy, falling back to the
|
|
83
|
+
* first entry with no `when:` (the list's catch-all). */
|
|
84
|
+
function matchEntry<T extends { when?: string }>(
|
|
85
|
+
entries: T[],
|
|
86
|
+
celCtx: Record<string, unknown>,
|
|
87
|
+
moduleContext: ModuleLikeContext,
|
|
88
|
+
): T | undefined {
|
|
89
|
+
let fallback: T | undefined;
|
|
90
|
+
for (const entry of entries) {
|
|
71
91
|
if (!entry.when) {
|
|
72
92
|
fallback ??= entry;
|
|
73
93
|
continue;
|
|
74
94
|
}
|
|
75
|
-
|
|
76
|
-
if (condition === true) {
|
|
77
|
-
matched = entry;
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
95
|
+
if (moduleContext.expandWith(entry.when, celCtx) === true) return entry;
|
|
80
96
|
}
|
|
97
|
+
return fallback;
|
|
98
|
+
}
|
|
81
99
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
export async function dispatchReturns(
|
|
101
|
+
returns: ReturnEntry[],
|
|
102
|
+
result: unknown,
|
|
103
|
+
requestContext: Record<string, unknown>,
|
|
104
|
+
moduleContext: ModuleLikeContext,
|
|
105
|
+
validateSchema: ValidateSchema,
|
|
106
|
+
reply: FastifyReply,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
const celCtx = { result, ...requestContext };
|
|
109
|
+
const entry = matchEntry(returns, celCtx, moduleContext);
|
|
110
|
+
|
|
111
|
+
if (!entry) {
|
|
112
|
+
// Unreachable when the analyzer has run — every route's returns: list must
|
|
113
|
+
// cover its handler's return values (explicit when: or catch-all). Hitting
|
|
114
|
+
// this at runtime means something bypassed analysis; surface it loudly
|
|
115
|
+
// via Fastify's error handler rather than quietly render a 500.
|
|
116
|
+
throw new Error(
|
|
117
|
+
"No matching returns entry for handler result — the route's returns: list must cover every return value (add a catch-all entry or widen a when: clause)",
|
|
118
|
+
);
|
|
91
119
|
}
|
|
92
120
|
|
|
93
|
-
reply.code(
|
|
121
|
+
reply.code(entry.status);
|
|
94
122
|
|
|
95
|
-
if (
|
|
96
|
-
const mappedHeaders = moduleContext.expandWith(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
123
|
+
if (entry.headers) {
|
|
124
|
+
const mappedHeaders = moduleContext.expandWith(entry.headers, celCtx) as Record<
|
|
125
|
+
string,
|
|
126
|
+
unknown
|
|
127
|
+
>;
|
|
128
|
+
for (const [key, value] of Object.entries(mappedHeaders)) {
|
|
129
|
+
reply.header(key, value as string);
|
|
130
|
+
}
|
|
101
131
|
}
|
|
102
132
|
|
|
103
|
-
if (
|
|
133
|
+
if (entry.mode === "stream") {
|
|
104
134
|
reply.hijack();
|
|
105
|
-
reply.raw.writeHead(
|
|
135
|
+
reply.raw.writeHead(entry.status, reply.getHeaders() as Record<string, string>);
|
|
106
136
|
await pipeline(result as Readable, reply.raw);
|
|
107
137
|
return;
|
|
108
138
|
}
|
|
109
139
|
|
|
110
|
-
if (
|
|
111
|
-
const mappedBody = moduleContext.expandWith(
|
|
112
|
-
if (
|
|
113
|
-
validateSchema(mappedBody, statusEntry.schema.body);
|
|
114
|
-
}
|
|
140
|
+
if (entry.body !== undefined) {
|
|
141
|
+
const mappedBody = moduleContext.expandWith(entry.body, celCtx);
|
|
142
|
+
if (entry.schema?.body) validateSchema(mappedBody, entry.schema.body);
|
|
115
143
|
reply.send(mappedBody);
|
|
116
144
|
return;
|
|
117
145
|
}
|
|
@@ -119,10 +147,55 @@ export async function dispatchResponse(
|
|
|
119
147
|
reply.send(result);
|
|
120
148
|
}
|
|
121
149
|
|
|
150
|
+
/** Render an InvokeError through a `catches:` list. Falls back to a structured
|
|
151
|
+
* 500 when no entry matches. Plain (non-InvokeError) throws never reach this
|
|
152
|
+
* function — the caller re-throws them to Fastify. */
|
|
153
|
+
export async function dispatchCatches(
|
|
154
|
+
catches: CatchEntry[] | undefined,
|
|
155
|
+
error: { code: string; message: string; data?: unknown },
|
|
156
|
+
requestContext: Record<string, unknown>,
|
|
157
|
+
moduleContext: ModuleLikeContext,
|
|
158
|
+
validateSchema: ValidateSchema,
|
|
159
|
+
reply: FastifyReply,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
const celCtx = { error, ...requestContext };
|
|
162
|
+
const entry = catches ? matchEntry(catches, celCtx, moduleContext) : undefined;
|
|
163
|
+
|
|
164
|
+
if (!entry) {
|
|
165
|
+
reply.code(500);
|
|
166
|
+
reply.send({
|
|
167
|
+
error: { code: error.code, message: error.message, data: error.data },
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
reply.code(entry.status);
|
|
173
|
+
|
|
174
|
+
if (entry.headers) {
|
|
175
|
+
const mappedHeaders = moduleContext.expandWith(entry.headers, celCtx) as Record<
|
|
176
|
+
string,
|
|
177
|
+
unknown
|
|
178
|
+
>;
|
|
179
|
+
for (const [key, value] of Object.entries(mappedHeaders)) {
|
|
180
|
+
reply.header(key, value as string);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (entry.body !== undefined) {
|
|
185
|
+
const mappedBody = moduleContext.expandWith(entry.body, celCtx);
|
|
186
|
+
if (entry.schema?.body) validateSchema(mappedBody, entry.schema.body);
|
|
187
|
+
reply.send(mappedBody);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
reply.send({ error: { code: error.code, message: error.message, data: error.data } });
|
|
192
|
+
}
|
|
193
|
+
|
|
122
194
|
export class HttpServerApi implements ResourceInstance {
|
|
123
195
|
constructor(
|
|
124
196
|
private readonly ctx: ResourceContext,
|
|
125
197
|
readonly manifest: HttpApiManifest,
|
|
198
|
+
private readonly handlerRefs: WeakMap<object, HandlerRef>,
|
|
126
199
|
) {}
|
|
127
200
|
|
|
128
201
|
async init() {}
|
|
@@ -149,75 +222,72 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
149
222
|
|
|
150
223
|
private registerRoute(app: FastifyInstance, route: HttpApiRouteManifest) {
|
|
151
224
|
// After Phase 5 injection, KindRef<Invocable> is replaced with the live Invocable instance.
|
|
152
|
-
const handler = route.handler as unknown as
|
|
225
|
+
const handler = route.handler as unknown as ResourceInstance | undefined;
|
|
226
|
+
const handlerRef = this.handlerRefs.get(route as unknown as object);
|
|
227
|
+
const handlerKind = handlerRef?.kind ?? "";
|
|
228
|
+
const handlerName = handlerRef?.name ?? "";
|
|
153
229
|
const translatedPath = translateOpenApiPath(route.request.path);
|
|
154
230
|
|
|
155
|
-
const schema: any = {
|
|
156
|
-
response: {},
|
|
157
|
-
};
|
|
231
|
+
const schema: any = { response: {} };
|
|
158
232
|
|
|
159
|
-
if (route.request.schema?.query)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (route.request.schema?.
|
|
163
|
-
schema.params = route.request.schema?.params;
|
|
164
|
-
}
|
|
165
|
-
if (route.request.schema?.body) {
|
|
166
|
-
schema.body = route.request.schema?.body;
|
|
167
|
-
}
|
|
168
|
-
if (route.request.schema?.headers) {
|
|
169
|
-
schema.headers = route.request.schema?.headers;
|
|
170
|
-
}
|
|
233
|
+
if (route.request.schema?.query) schema.querystring = route.request.schema.query;
|
|
234
|
+
if (route.request.schema?.params) schema.params = route.request.schema.params;
|
|
235
|
+
if (route.request.schema?.body) schema.body = route.request.schema.body;
|
|
236
|
+
if (route.request.schema?.headers) schema.headers = route.request.schema.headers;
|
|
171
237
|
|
|
172
|
-
|
|
173
|
-
(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
} else if (entry.schema) {
|
|
177
|
-
acc[entry.status] = {};
|
|
178
|
-
}
|
|
179
|
-
return acc;
|
|
180
|
-
},
|
|
181
|
-
{} as Record<number, any>,
|
|
182
|
-
);
|
|
238
|
+
for (const entry of route.returns) {
|
|
239
|
+
if (entry.schema?.body) schema.response[entry.status] = entry.schema.body;
|
|
240
|
+
else if (entry.schema) schema.response[entry.status] = {};
|
|
241
|
+
}
|
|
183
242
|
|
|
184
243
|
app.route({
|
|
185
244
|
method: route.request.method as any,
|
|
186
245
|
url: translatedPath,
|
|
187
246
|
schema,
|
|
188
247
|
handler: async (request: FastifyRequest, reply: FastifyReply) => {
|
|
248
|
+
const requestContext = {
|
|
249
|
+
request: {
|
|
250
|
+
method: request.method,
|
|
251
|
+
path: request.url,
|
|
252
|
+
params: request.params || {},
|
|
253
|
+
query: request.query || {},
|
|
254
|
+
headers: normalizeHeaders(request.headers),
|
|
255
|
+
body: request.body,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
const resolvedInputs: Record<string, any> = route.inputs
|
|
259
|
+
? ((this.ctx.moduleContext.expandWith(route.inputs, requestContext) as any) ?? {})
|
|
260
|
+
: requestContext;
|
|
261
|
+
const invokeInput: Record<string, any> = {
|
|
262
|
+
...resolvedInputs,
|
|
263
|
+
inputs: resolvedInputs,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
let result: unknown;
|
|
189
267
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
},
|
|
199
|
-
};
|
|
200
|
-
const resolvedInputs: Record<string, any> = route.inputs
|
|
201
|
-
? ((this.ctx.moduleContext.expandWith(route.inputs, requestContext) as any) ?? {})
|
|
202
|
-
: requestContext;
|
|
203
|
-
const invokeInput: Record<string, any> = {
|
|
204
|
-
...resolvedInputs,
|
|
205
|
-
inputs: resolvedInputs,
|
|
206
|
-
};
|
|
207
|
-
const result = handler ? await handler.invoke(invokeInput) : undefined;
|
|
208
|
-
|
|
209
|
-
return dispatchResponse(
|
|
210
|
-
route.response,
|
|
211
|
-
result,
|
|
268
|
+
result = handler
|
|
269
|
+
? await this.ctx.invokeResolved(handlerKind, handlerName, handler, invokeInput)
|
|
270
|
+
: undefined;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (!isInvokeError(err)) throw err;
|
|
273
|
+
return dispatchCatches(
|
|
274
|
+
route.catches,
|
|
275
|
+
{ code: err.code, message: err.message, data: err.data },
|
|
212
276
|
requestContext,
|
|
213
277
|
this.ctx.moduleContext,
|
|
214
278
|
this.ctx.validateSchema.bind(this.ctx),
|
|
215
279
|
reply,
|
|
216
280
|
);
|
|
217
|
-
} catch (error) {
|
|
218
|
-
// Let the error handler deal with all errors
|
|
219
|
-
throw error;
|
|
220
281
|
}
|
|
282
|
+
|
|
283
|
+
return dispatchReturns(
|
|
284
|
+
route.returns,
|
|
285
|
+
result,
|
|
286
|
+
requestContext,
|
|
287
|
+
this.ctx.moduleContext,
|
|
288
|
+
this.ctx.validateSchema.bind(this.ctx),
|
|
289
|
+
reply,
|
|
290
|
+
);
|
|
221
291
|
},
|
|
222
292
|
});
|
|
223
293
|
}
|
|
@@ -225,7 +295,23 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
225
295
|
|
|
226
296
|
export async function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi> {
|
|
227
297
|
ctx.validateSchema(resource, HttpApiManifest);
|
|
228
|
-
|
|
298
|
+
// Capture handler {kind, name} before Phase 5 injection overwrites the ref
|
|
299
|
+
// with a live Invocable instance. invokeResolved() needs the kind/name to
|
|
300
|
+
// emit properly-scoped Invoked / InvokeRejected events.
|
|
301
|
+
const handlerRefs = new WeakMap<object, HandlerRef>();
|
|
302
|
+
for (const route of resource.routes ?? []) {
|
|
303
|
+
const h = route.handler;
|
|
304
|
+
if (!h) continue;
|
|
305
|
+
if (typeof h === "object") {
|
|
306
|
+
handlerRefs.set(route, ctx.resolveChildren(h));
|
|
307
|
+
} else if (typeof h === "string") {
|
|
308
|
+
// String form (schema oneOf: string | object) — only the resource name
|
|
309
|
+
// is given, not the kind. Phase 5 injects the live instance either way;
|
|
310
|
+
// invoke events on this route just emit with an empty kind.
|
|
311
|
+
handlerRefs.set(route, { kind: "", name: h });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return new HttpServerApi(ctx, resource, handlerRefs);
|
|
229
315
|
}
|
|
230
316
|
|
|
231
317
|
/**
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import cors from "@fastify/cors";
|
|
2
2
|
import swagger from "@fastify/swagger";
|
|
3
3
|
import apiReference from "@scalar/fastify-api-reference";
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
import {
|
|
5
|
+
isInvokeError,
|
|
6
|
+
type Invocable,
|
|
7
|
+
type KindRef,
|
|
8
|
+
type ResourceContext,
|
|
9
|
+
type ResourceInstance,
|
|
10
|
+
type RuntimeResource,
|
|
10
11
|
} from "@telorun/sdk";
|
|
11
12
|
import addFormats from "ajv-formats";
|
|
12
13
|
import Fastify, { FastifyInstance } from "fastify";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
CatchEntry,
|
|
16
|
+
dispatchCatches,
|
|
17
|
+
dispatchReturns,
|
|
18
|
+
HttpServerApi,
|
|
19
|
+
ReturnEntry,
|
|
20
|
+
} from "./http-api-controller.js";
|
|
14
21
|
|
|
15
22
|
type CorsOptions = {
|
|
16
23
|
origin?: string | boolean | string[];
|
|
@@ -46,7 +53,8 @@ type HttpServerResource = RuntimeResource & {
|
|
|
46
53
|
}>;
|
|
47
54
|
notFoundHandler?: {
|
|
48
55
|
invoke: KindRef<Invocable>;
|
|
49
|
-
|
|
56
|
+
returns?: ReturnEntry[];
|
|
57
|
+
catches?: CatchEntry[];
|
|
50
58
|
};
|
|
51
59
|
};
|
|
52
60
|
|
|
@@ -54,7 +62,8 @@ type ResolvedHandler = {
|
|
|
54
62
|
kind: string;
|
|
55
63
|
name: string;
|
|
56
64
|
inputs: Record<string, any>;
|
|
57
|
-
|
|
65
|
+
returns?: ReturnEntry[];
|
|
66
|
+
catches?: CatchEntry[];
|
|
58
67
|
};
|
|
59
68
|
|
|
60
69
|
class HttpServer implements ResourceInstance {
|
|
@@ -178,7 +187,7 @@ class HttpServer implements ResourceInstance {
|
|
|
178
187
|
const { kind, name } = parseType(type);
|
|
179
188
|
const prefix = mount.path || "";
|
|
180
189
|
|
|
181
|
-
const api = this.ctx.moduleContext.
|
|
190
|
+
const api = this.ctx.moduleContext.getInstance(name) as unknown as HttpServerApi;
|
|
182
191
|
|
|
183
192
|
if (!api) {
|
|
184
193
|
throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
|
|
@@ -203,10 +212,25 @@ class HttpServer implements ResourceInstance {
|
|
|
203
212
|
body: request.body,
|
|
204
213
|
},
|
|
205
214
|
};
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
215
|
+
|
|
216
|
+
let result: any;
|
|
217
|
+
try {
|
|
218
|
+
result = await this.ctx.invoke(handler.kind, handler.name, requestContext);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (!isInvokeError(err)) throw err;
|
|
221
|
+
return dispatchCatches(
|
|
222
|
+
handler.catches,
|
|
223
|
+
{ code: err.code, message: err.message, data: err.data },
|
|
224
|
+
requestContext,
|
|
225
|
+
this.ctx.moduleContext,
|
|
226
|
+
this.ctx.validateSchema.bind(this.ctx),
|
|
227
|
+
reply,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (handler.returns) {
|
|
232
|
+
return dispatchReturns(
|
|
233
|
+
handler.returns,
|
|
210
234
|
result,
|
|
211
235
|
requestContext,
|
|
212
236
|
this.ctx.moduleContext,
|
|
@@ -262,12 +286,23 @@ export async function create(
|
|
|
262
286
|
): Promise<ResourceInstance | null> {
|
|
263
287
|
let resolvedNotFoundHandler: ResolvedHandler | null = null;
|
|
264
288
|
if (resource.notFoundHandler) {
|
|
265
|
-
const
|
|
289
|
+
const invoke = resource.notFoundHandler.invoke as unknown;
|
|
290
|
+
let kind = "";
|
|
291
|
+
let name = "";
|
|
292
|
+
if (typeof invoke === "object" && invoke !== null) {
|
|
293
|
+
const resolved = ctx.resolveChildren(invoke);
|
|
294
|
+
kind = resolved.kind;
|
|
295
|
+
name = resolved.name;
|
|
296
|
+
} else if (typeof invoke === "string") {
|
|
297
|
+
// String form (schema oneOf: string | object) — resource name only.
|
|
298
|
+
name = invoke;
|
|
299
|
+
}
|
|
266
300
|
resolvedNotFoundHandler = {
|
|
267
|
-
kind
|
|
268
|
-
name
|
|
269
|
-
inputs: (
|
|
270
|
-
|
|
301
|
+
kind,
|
|
302
|
+
name,
|
|
303
|
+
inputs: (invoke as any)?.inputs ?? {},
|
|
304
|
+
returns: resource.notFoundHandler.returns,
|
|
305
|
+
catches: resource.notFoundHandler.catches,
|
|
271
306
|
};
|
|
272
307
|
}
|
|
273
308
|
return new HttpServer(resource, ctx, resolvedNotFoundHandler);
|