@telorun/http-server 0.1.8 → 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 CHANGED
@@ -1,5 +1,52 @@
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
+
3
50
  ## 0.1.8
4
51
 
5
52
  ### Patch Changes
@@ -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 HttpApiRouteManifest: import("@sinclair/typebox").TObject<{
5
- request: import("@sinclair/typebox").TObject<{
6
- path: import("@sinclair/typebox").TString;
7
- method: import("@sinclair/typebox").TString;
8
- schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
9
- params: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
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 HttpApiRouteManifest = Static<typeof HttpApiRouteManifest>;
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
- response: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
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 ResponseEntry = Static<(typeof HttpApiRouteManifest)["properties"]["response"]["items"]>;
62
- export declare function dispatchResponse(response: ResponseEntry[], result: unknown, requestContext: Record<string, unknown>, moduleContext: {
68
+ export type { ReturnEntry, CatchEntry };
69
+ type ModuleLikeContext = {
63
70
  expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
64
- }, validateSchema: (value: unknown, schema: unknown) => void, reply: FastifyReply): Promise<void>;
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
- constructor(ctx: ResourceContext, manifest: HttpApiManifest);
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("kernel#Invocable"))),
37
+ handler: Type.Optional(Type.Unsafe(Ref("telo#Invocable"))),
16
38
  inputs: Type.Optional(Type.Record(Type.String(), Type.Any())),
17
- response: Type.Array(Type.Object({
18
- status: Type.Integer({ minimum: 100, maximum: 599 }),
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
- export async function dispatchResponse(response, result, requestContext, moduleContext, validateSchema, reply) {
35
- let matched;
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 response) {
50
+ for (const entry of entries) {
38
51
  if (!entry.when) {
39
52
  fallback ??= entry;
40
53
  continue;
41
54
  }
42
- const condition = moduleContext.expandWith(entry.when, { result, ...requestContext });
43
- if (condition === true) {
44
- matched = entry;
45
- break;
46
- }
55
+ if (moduleContext.expandWith(entry.when, celCtx) === true)
56
+ return entry;
47
57
  }
48
- const statusEntry = matched ?? fallback;
49
- if (!statusEntry) {
50
- reply.code(500);
51
- reply.send({
52
- error: "InternalServerError",
53
- message: "No matching response status entry",
54
- status: 500,
55
- });
56
- return;
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(statusEntry.status);
59
- if (statusEntry.headers) {
60
- const mappedHeaders = moduleContext.expandWith(statusEntry.headers, {
61
- result,
62
- ...requestContext,
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 (statusEntry.mode === "stream") {
77
+ if (entry.mode === "stream") {
67
78
  reply.hijack();
68
- reply.raw.writeHead(statusEntry.status, reply.getHeaders());
79
+ reply.raw.writeHead(entry.status, reply.getHeaders());
69
80
  await pipeline(result, reply.raw);
70
81
  return;
71
82
  }
72
- if (statusEntry.body !== undefined) {
73
- const mappedBody = moduleContext.expandWith(statusEntry.body, { result, ...requestContext });
74
- if (statusEntry.schema?.body) {
75
- validateSchema(mappedBody, statusEntry.schema.body);
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
- constructor(ctx, manifest) {
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
- response: {},
112
- };
113
- if (route.request.schema?.query) {
114
- schema.querystring = route.request.schema?.query;
115
- }
116
- if (route.request.schema?.params) {
117
- schema.params = route.request.schema?.params;
118
- }
119
- if (route.request.schema?.body) {
120
- schema.body = route.request.schema?.body;
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
- const requestContext = {
141
- request: {
142
- method: request.method,
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 (error) {
161
- // Let the error handler deal with all errors
162
- throw error;
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
- return new HttpServerApi(ctx, resource);
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 { Invocable, KindRef, ResourceContext, ResourceInstance, RuntimeResource } from "@telorun/sdk";
2
- import { ResponseEntry } from "./http-api-controller.js";
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
- response?: ResponseEntry[];
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 { dispatchResponse } from "./http-api-controller.js";
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.getInvocable(name);
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
- const result = await this.ctx.invoke(handler.kind, handler.name, requestContext);
138
- if (handler.response) {
139
- return dispatchResponse(handler.response, result, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
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 resolved = ctx.resolveChildren(resource.notFoundHandler.invoke);
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: resolved.kind,
185
- name: resolved.name,
186
- inputs: resource.notFoundHandler.invoke.inputs ?? {},
187
- response: resource.notFoundHandler.response,
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,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -43,7 +43,7 @@
43
43
  "ajv": "^8.17.1",
44
44
  "ajv-formats": "^3.0.1",
45
45
  "fastify": "^5.7.2",
46
- "@telorun/sdk": "0.2.8"
46
+ "@telorun/sdk": "0.3.0"
47
47
  },
48
48
  "devDependencies": {
49
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("kernel#Invocable"))),
58
+ handler: Type.Optional(Type.Unsafe<KindRef<Invocable>>(Ref("telo#Invocable"))),
28
59
  inputs: Type.Optional(Type.Record(Type.String(), Type.Any())),
29
- response: Type.Array(
30
- Type.Object({
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 ResponseEntry = Static<
56
- (typeof HttpApiRouteManifest)["properties"]["response"]["items"]
57
- >;
72
+ export type { ReturnEntry, CatchEntry };
58
73
 
59
- export async function dispatchResponse(
60
- response: ResponseEntry[],
61
- result: unknown,
62
- requestContext: Record<string, unknown>,
63
- moduleContext: { expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown },
64
- validateSchema: (value: unknown, schema: unknown) => void,
65
- reply: FastifyReply,
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
- for (const entry of response) {
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
- const condition = moduleContext.expandWith(entry.when, { result, ...requestContext });
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
- const statusEntry = matched ?? fallback;
83
- if (!statusEntry) {
84
- reply.code(500);
85
- reply.send({
86
- error: "InternalServerError",
87
- message: "No matching response status entry",
88
- status: 500,
89
- });
90
- return;
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(statusEntry.status);
121
+ reply.code(entry.status);
94
122
 
95
- if (statusEntry.headers) {
96
- const mappedHeaders = moduleContext.expandWith(statusEntry.headers, {
97
- result,
98
- ...requestContext,
99
- }) as Record<string, unknown>;
100
- Object.entries(mappedHeaders).forEach(([key, value]) => reply.header(key, value as string));
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 (statusEntry.mode === "stream") {
133
+ if (entry.mode === "stream") {
104
134
  reply.hijack();
105
- reply.raw.writeHead(statusEntry.status, reply.getHeaders() as Record<string, string>);
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 (statusEntry.body !== undefined) {
111
- const mappedBody = moduleContext.expandWith(statusEntry.body, { result, ...requestContext });
112
- if (statusEntry.schema?.body) {
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 Invocable | undefined;
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
- schema.querystring = route.request.schema?.query;
161
- }
162
- if (route.request.schema?.params) {
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
- schema.response = route.response.reduce(
173
- (acc, entry) => {
174
- if (entry.schema?.body) {
175
- acc[entry.status] = entry.schema.body;
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
- const requestContext = {
191
- request: {
192
- method: request.method,
193
- path: request.url,
194
- params: request.params || {},
195
- query: request.query || {},
196
- headers: normalizeHeaders(request.headers),
197
- body: request.body,
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
- return new HttpServerApi(ctx, resource);
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 type {
5
- Invocable,
6
- KindRef,
7
- ResourceContext,
8
- ResourceInstance,
9
- RuntimeResource,
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 { dispatchResponse, HttpServerApi, ResponseEntry } from "./http-api-controller.js";
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
- response?: ResponseEntry[];
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
- response?: ResponseEntry[];
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.getInvocable(name) as unknown as HttpServerApi;
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
- const result = await this.ctx.invoke(handler.kind, handler.name, requestContext);
207
- if (handler.response) {
208
- return dispatchResponse(
209
- handler.response,
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 resolved = ctx.resolveChildren(resource.notFoundHandler.invoke);
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: resolved.kind,
268
- name: resolved.name,
269
- inputs: (resource.notFoundHandler.invoke as any).inputs ?? {},
270
- response: resource.notFoundHandler.response,
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);