@telorun/http-server 0.1.4 → 0.1.5

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,13 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - Automated release.
8
+ - Updated dependencies
9
+ - @telorun/sdk@0.2.6
10
+
3
11
  ## 0.1.4
4
12
 
5
13
  ### Patch Changes
@@ -1,6 +1,32 @@
1
1
  import { Static } from "@sinclair/typebox";
2
- import { ControllerContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
3
- import { FastifyInstance } from "fastify";
2
+ import { ControllerContext, Invocable, KindRef, ResourceContext, ResourceInstance } from "@telorun/sdk";
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
+ response: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
17
+ status: import("@sinclair/typebox").TInteger;
18
+ when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
19
+ mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"buffer">, import("@sinclair/typebox").TLiteral<"stream">]>>;
20
+ schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
21
+ query: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
22
+ body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
23
+ headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
24
+ }>>;
25
+ headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
26
+ body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
27
+ }>>;
28
+ }>;
29
+ type HttpApiRouteManifest = Static<typeof HttpApiRouteManifest>;
4
30
  declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
5
31
  routes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
6
32
  request: import("@sinclair/typebox").TObject<{
@@ -13,23 +39,27 @@ declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
13
39
  headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
14
40
  }>>;
15
41
  }>;
16
- handler: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
17
- response: import("@sinclair/typebox").TObject<{
18
- status: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TNumber, import("@sinclair/typebox").TString]>;
19
- statuses: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
20
- schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
21
- query: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
22
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
23
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
24
- }>>;
25
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
42
+ handler: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnsafe<KindRef<Invocable<Record<string, any>, any>>>>;
43
+ response: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
44
+ status: import("@sinclair/typebox").TInteger;
45
+ when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
46
+ mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"buffer">, import("@sinclair/typebox").TLiteral<"stream">]>>;
47
+ schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
48
+ query: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
26
49
  body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
50
+ headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
27
51
  }>>;
28
- }>;
52
+ headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
53
+ body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
54
+ }>>;
29
55
  }>>;
30
56
  }>;
31
57
  type HttpApiManifest = Static<typeof HttpApiManifest>;
32
- export declare function register(ctx: ControllerContext): Promise<void>;
58
+ export declare function register(_ctx: ControllerContext): Promise<void>;
59
+ export type ResponseEntry = Static<(typeof HttpApiRouteManifest)["properties"]["response"]["items"]>;
60
+ export declare function dispatchResponse(response: ResponseEntry[], result: unknown, requestContext: Record<string, unknown>, moduleContext: {
61
+ expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
62
+ }, validateSchema: (value: unknown, schema: unknown) => void, reply: FastifyReply): Promise<void>;
33
63
  export declare class HttpServerApi implements ResourceInstance {
34
64
  private readonly ctx;
35
65
  readonly manifest: HttpApiManifest;
@@ -1,4 +1,6 @@
1
1
  import { Type } from "@sinclair/typebox";
2
+ import { Ref } from "@telorun/sdk";
3
+ import { pipeline } from "stream/promises";
2
4
  const HttpApiRouteManifest = Type.Object({
3
5
  request: Type.Object({
4
6
  path: Type.String(),
@@ -10,24 +12,72 @@ const HttpApiRouteManifest = Type.Object({
10
12
  headers: Type.Optional(Type.Any()),
11
13
  })),
12
14
  }),
13
- handler: Type.Optional(Type.Any()), // Any handler shape is allowed - will be processed in create()
14
- response: Type.Object({
15
- status: Type.Union([Type.Number({ minimum: 100, maximum: 599 }), Type.String()]),
16
- statuses: Type.Record(Type.String(), Type.Object({
17
- schema: Type.Optional(Type.Object({
18
- query: Type.Optional(Type.Any()),
19
- body: Type.Optional(Type.Any()),
20
- headers: Type.Optional(Type.Any()),
21
- })),
22
- headers: Type.Optional(Type.Record(Type.String(), Type.String())),
15
+ handler: Type.Optional(Type.Unsafe(Ref("kernel#Invocable"))),
16
+ response: Type.Array(Type.Object({
17
+ status: Type.Integer({ minimum: 100, maximum: 599 }),
18
+ when: Type.Optional(Type.String()),
19
+ mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
20
+ schema: Type.Optional(Type.Object({
21
+ query: Type.Optional(Type.Any()),
23
22
  body: Type.Optional(Type.Any()),
23
+ headers: Type.Optional(Type.Any()),
24
24
  })),
25
- }),
25
+ headers: Type.Optional(Type.Record(Type.String(), Type.String())),
26
+ body: Type.Optional(Type.Any()),
27
+ })),
26
28
  });
27
29
  const HttpApiManifest = Type.Object({
28
30
  routes: Type.Array(HttpApiRouteManifest),
29
31
  });
30
- export async function register(ctx) { }
32
+ export async function register(_ctx) { }
33
+ export async function dispatchResponse(response, result, requestContext, moduleContext, validateSchema, reply) {
34
+ let matched;
35
+ let fallback;
36
+ for (const entry of response) {
37
+ if (!entry.when) {
38
+ fallback ??= entry;
39
+ continue;
40
+ }
41
+ const condition = moduleContext.expandWith(entry.when, { result, ...requestContext });
42
+ if (condition === true) {
43
+ matched = entry;
44
+ break;
45
+ }
46
+ }
47
+ const statusEntry = matched ?? fallback;
48
+ if (!statusEntry) {
49
+ reply.code(500);
50
+ reply.send({
51
+ error: "InternalServerError",
52
+ message: "No matching response status entry",
53
+ status: 500,
54
+ });
55
+ return;
56
+ }
57
+ reply.code(statusEntry.status);
58
+ if (statusEntry.headers) {
59
+ const mappedHeaders = moduleContext.expandWith(statusEntry.headers, {
60
+ result,
61
+ ...requestContext,
62
+ });
63
+ Object.entries(mappedHeaders).forEach(([key, value]) => reply.header(key, value));
64
+ }
65
+ if (statusEntry.mode === "stream") {
66
+ reply.hijack();
67
+ reply.raw.writeHead(statusEntry.status, reply.getHeaders());
68
+ await pipeline(result, reply.raw);
69
+ return;
70
+ }
71
+ if (statusEntry.body !== undefined) {
72
+ const mappedBody = moduleContext.expandWith(statusEntry.body, { result, ...requestContext });
73
+ if (statusEntry.schema?.body) {
74
+ validateSchema(mappedBody, statusEntry.schema.body);
75
+ }
76
+ reply.send(mappedBody);
77
+ return;
78
+ }
79
+ reply.send(result);
80
+ }
31
81
  export class HttpServerApi {
32
82
  ctx;
33
83
  manifest;
@@ -53,7 +103,8 @@ export class HttpServerApi {
53
103
  }
54
104
  }
55
105
  registerRoute(app, route) {
56
- const handler = route.handler ? resolveHandlerName(route.handler) : null;
106
+ // After Phase 5 injection, KindRef<Invocable> is replaced with the live Invocable instance.
107
+ const handler = route.handler;
57
108
  const translatedPath = translateOpenApiPath(route.request.path);
58
109
  const schema = {
59
110
  response: {},
@@ -70,15 +121,12 @@ export class HttpServerApi {
70
121
  if (route.request.schema?.headers) {
71
122
  schema.headers = route.request.schema?.headers;
72
123
  }
73
- schema.response = Object.keys(route.response.statuses).reduce((acc, status) => {
74
- const statusConfig = route.response.statuses[status];
75
- if (statusConfig.schema) {
76
- if (statusConfig.schema.body) {
77
- acc[status] = statusConfig.schema.body;
78
- }
79
- else {
80
- acc[status] = {};
81
- }
124
+ schema.response = route.response.reduce((acc, entry) => {
125
+ if (entry.schema?.body) {
126
+ acc[entry.status] = entry.schema.body;
127
+ }
128
+ else if (entry.schema) {
129
+ acc[entry.status] = {};
82
130
  }
83
131
  return acc;
84
132
  }, {});
@@ -88,59 +136,18 @@ export class HttpServerApi {
88
136
  schema,
89
137
  handler: async (request, reply) => {
90
138
  try {
91
- // Normalize headers to lowercase
92
- const normalizedHeaders = normalizeHeaders(request.headers);
93
- // Construct standardized Telo request object
94
- const requestPayload = {
95
- method: request.method,
96
- path: request.url,
97
- params: request.params || {},
98
- query: request.query || {},
99
- headers: normalizedHeaders,
100
- body: request.body,
139
+ const requestContext = {
140
+ request: {
141
+ method: request.method,
142
+ path: request.url,
143
+ params: request.params || {},
144
+ query: request.query || {},
145
+ headers: normalizeHeaders(request.headers),
146
+ body: request.body,
147
+ },
101
148
  };
102
- // Wrap in "request" object as per spec
103
- const teloRequestContext = { request: requestPayload };
104
- const result = handler
105
- ? await this.ctx.invoke(handler.kind, handler.name, resolveHandlerInputs(route.handler, teloRequestContext))
106
- : undefined;
107
- const response = route.response;
108
- // Determine final status code
109
- let statusCode = response.status;
110
- if (typeof statusCode === "string") {
111
- statusCode = this.ctx.expandValue(statusCode, { result });
112
- }
113
- // Convert status to string for lookup
114
- const statusKey = String(statusCode);
115
- const statusConfig = response.statuses[statusKey];
116
- if (!statusConfig) {
117
- reply.code(500);
118
- return reply.send({
119
- error: "InternalServerError",
120
- message: "Response status configuration not found",
121
- status: 500,
122
- });
123
- }
124
- // Set HTTP status code
125
- reply.code(statusCode);
126
- // Map and set response headers if specified
127
- if (statusConfig.headers) {
128
- const mappedHeaders = this.ctx.expandValue(statusConfig.headers, { result });
129
- Object.entries(mappedHeaders).forEach(([key, value]) => {
130
- reply.header(key, value);
131
- });
132
- }
133
- // Map and send response body if specified
134
- if (statusConfig.body !== undefined) {
135
- const mappedBody = this.ctx.expandValue(statusConfig.body, { result });
136
- // Validate response body if schema is specified
137
- if (statusConfig.schema && statusConfig.schema.body) {
138
- this.ctx.validateSchema(mappedBody, statusConfig.schema.body);
139
- }
140
- return reply.send(mappedBody);
141
- }
142
- // No body mapping, send result as-is
143
- return reply.send(result);
149
+ const result = handler ? await handler.invoke(requestContext) : undefined;
150
+ return dispatchResponse(route.response, result, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
144
151
  }
145
152
  catch (error) {
146
153
  // Let the error handler deal with all errors
@@ -151,92 +158,8 @@ export class HttpServerApi {
151
158
  }
152
159
  }
153
160
  export async function create(resource, ctx) {
154
- // First validate with a permissive schema (handler can be any shape)
155
161
  ctx.validateSchema(resource, HttpApiManifest);
156
- // Process routes and register unnamed handlers as child resources
157
- let handlerCounter = 0;
158
- const processedRoutes = (resource.routes || []).map((route) => {
159
- if (!route.handler) {
160
- return route;
161
- }
162
- // Check if handler is unnamed (inline handler)
163
- if (typeof route.handler === "object" && !route.handler.name) {
164
- // Use resolveChildren to register the unnamed handler and get its normalized reference
165
- const resolvedHandler = ctx.resolveChildren(route.handler, `__handler_${handlerCounter++}`);
166
- // Return route with the resolved handler reference
167
- return {
168
- ...route,
169
- handler: {
170
- kind: resolvedHandler.kind,
171
- name: resolvedHandler.name,
172
- inputs: route.handler.inputs,
173
- },
174
- };
175
- }
176
- return route;
177
- });
178
- // Create the API instance with processed routes
179
- const processedResource = {
180
- ...resource,
181
- routes: processedRoutes,
182
- };
183
- return new HttpServerApi(ctx, processedResource);
184
- }
185
- function resolveHandlerName(handler) {
186
- if (typeof handler === "string") {
187
- const [kind, name] = handler.split("/");
188
- return { kind, name };
189
- }
190
- if (handler && typeof handler === "object" && typeof handler.kind === "string") {
191
- // name should always be present after create() processes the routes
192
- // but fallback gracefully if it's not
193
- const name = handler.name || `__unnamed_${Math.random().toString(36).slice(2, 9)}`;
194
- return { name, kind: handler.kind };
195
- }
196
- throw new Error("Unable to resolve handler - handler must have a 'kind' property");
197
- }
198
- function resolveHandlerInputs(handler, requestContext) {
199
- if (typeof handler === "string") {
200
- return requestContext;
201
- }
202
- if (!handler || typeof handler !== "object") {
203
- return requestContext;
204
- }
205
- if (!handler.inputs) {
206
- return requestContext;
207
- }
208
- return resolveTemplateInputs(handler.inputs, requestContext);
209
- }
210
- function resolveTemplateInputs(value, context) {
211
- if (typeof value === "string") {
212
- const match = value.match(/^\s*\$\{\{\s*([^}]+)\s*\}\}\s*$/);
213
- if (match) {
214
- return resolveTemplatePath(match[1], context);
215
- }
216
- return value;
217
- }
218
- if (Array.isArray(value)) {
219
- return value.map((item) => resolveTemplateInputs(item, context));
220
- }
221
- if (value && typeof value === "object") {
222
- const resolved = {};
223
- for (const [key, entry] of Object.entries(value)) {
224
- resolved[key] = resolveTemplateInputs(entry, context);
225
- }
226
- return resolved;
227
- }
228
- return value;
229
- }
230
- function resolveTemplatePath(pathExpression, context) {
231
- const parts = pathExpression.trim().split(".").filter(Boolean);
232
- let current = context;
233
- for (const part of parts) {
234
- if (!current || (typeof current !== "object" && typeof current !== "function")) {
235
- return undefined;
236
- }
237
- current = current[part];
238
- }
239
- return current;
162
+ return new HttpServerApi(ctx, resource);
240
163
  }
241
164
  /**
242
165
  * Translates OpenAPI path format {paramName} to Fastify format :paramName
@@ -255,53 +178,3 @@ function normalizeHeaders(headers) {
255
178
  }
256
179
  return normalized;
257
180
  }
258
- /**
259
- * Legacy function - kept for compatibility but not used
260
- * Converts framework-specific validation errors to standardized Telo format
261
- * Returns null if the error is not a validation error
262
- */
263
- function convertValidationError(error) {
264
- // Check if this is a Fastify/AJV validation error
265
- if (!error || typeof error !== "object") {
266
- return null;
267
- }
268
- // Fastify validation errors have a statusCode of 400 and validation array
269
- if (error.statusCode === 400 && Array.isArray(error.validation)) {
270
- const details = error.validation.map((err) => {
271
- const path = err.instancePath ? err.instancePath.replace(/^\//, "").replace(/\//g, ".") : "";
272
- // Determine location from keyword/message context
273
- let location = "body"; // default
274
- if (err.keyword === "required" && err.params?.missingProperty) {
275
- location = determinLocationFromContext(err);
276
- }
277
- else {
278
- location = determinLocationFromContext(err);
279
- }
280
- return {
281
- location,
282
- path: path || err.params?.missingProperty || "",
283
- message: err.message || "Validation failed",
284
- };
285
- });
286
- return {
287
- error: "ValidationError",
288
- message: "Request validation failed",
289
- status: 400,
290
- details,
291
- };
292
- }
293
- return null;
294
- }
295
- /**
296
- * Helper to determine the location (body, query, params, headers) from validation error context
297
- */
298
- function determinLocationFromContext(err) {
299
- // AJV validation errors in Fastify include parent keyword context
300
- if (err.parentSchema && err.instancePath) {
301
- const path = err.instancePath;
302
- // This is a simplified check; in practice, Fastify provides better context
303
- // For now, default to "body" for general validation errors
304
- return "body";
305
- }
306
- return "body";
307
- }
@@ -1,8 +1,14 @@
1
- import type { ResourceContext, ResourceInstance, RuntimeResource } from "@telorun/sdk";
1
+ import type { Invocable, KindRef, ResourceContext, ResourceInstance, RuntimeResource } from "@telorun/sdk";
2
+ import { ResponseEntry } from "./http-api-controller.js";
2
3
  type HttpServerResource = RuntimeResource & {
3
4
  host?: string;
4
5
  port?: number;
5
6
  baseUrl?: string;
7
+ logger?: boolean;
8
+ contentTypeParsers?: Array<{
9
+ contentType: string;
10
+ parser?: Invocable;
11
+ }>;
6
12
  openapi?: {
7
13
  info: {
8
14
  title: string;
@@ -13,6 +19,10 @@ type HttpServerResource = RuntimeResource & {
13
19
  path?: string;
14
20
  type?: string;
15
21
  }>;
22
+ notFoundHandler?: {
23
+ invoke: KindRef<Invocable>;
24
+ response?: ResponseEntry[];
25
+ };
16
26
  };
17
- export declare function create(resource: HttpServerResource, ctx: ResourceContext): ResourceInstance | null;
27
+ export declare function create(resource: HttpServerResource, ctx: ResourceContext): Promise<ResourceInstance | null>;
18
28
  export {};
@@ -2,30 +2,54 @@ import swagger from "@fastify/swagger";
2
2
  import apiReference from "@scalar/fastify-api-reference";
3
3
  import addFormats from "ajv-formats";
4
4
  import Fastify from "fastify";
5
+ import { dispatchResponse } from "./http-api-controller.js";
5
6
  class HttpServer {
6
7
  releaseHold = null;
8
+ pluginsInitialized = false;
7
9
  app;
8
10
  host;
9
11
  port;
10
12
  baseUrl;
11
13
  resource;
12
14
  ctx;
13
- constructor(resource, ctx) {
15
+ resolvedNotFoundHandler;
16
+ constructor(resource, ctx, resolvedNotFoundHandler = null) {
14
17
  this.resource = resource;
15
18
  this.ctx = ctx;
16
19
  this.host = resource.host || "0.0.0.0";
17
20
  this.port = Number(resource.port || 0);
18
21
  this.baseUrl = resource.baseUrl ?? `http://${this.host}:${this.port}`;
22
+ this.resolvedNotFoundHandler = resolvedNotFoundHandler;
19
23
  if (!this.port) {
20
24
  throw new Error("Http.Server port is required");
21
25
  }
22
- this.app = Fastify({ logger: true, ajv: { plugins: [addFormats.default] } });
26
+ this.app = Fastify({ logger: resource.logger, ajv: { plugins: [addFormats.default] } });
23
27
  }
24
28
  async init() {
25
- await this.setupPlugins();
29
+ if (!this.pluginsInitialized) {
30
+ await this.setupPlugins();
31
+ this.pluginsInitialized = true;
32
+ }
26
33
  this.setupRoutes();
27
34
  }
28
35
  async setupPlugins() {
36
+ for (const { contentType, parser } of this.resource.contentTypeParsers ?? []) {
37
+ if (parser) {
38
+ this.app.addContentTypeParser(contentType, { parseAs: "string" }, async (_req, body, done) => {
39
+ try {
40
+ done(null, await parser.invoke({ body }));
41
+ }
42
+ catch (err) {
43
+ done(err, undefined);
44
+ }
45
+ });
46
+ }
47
+ else {
48
+ this.app.addContentTypeParser(contentType, { parseAs: "string" }, (_req, body, done) => {
49
+ done(null, body);
50
+ });
51
+ }
52
+ }
29
53
  // Register custom error handler for validation errors
30
54
  this.app.setErrorHandler((error, request, reply) => {
31
55
  const mappedError = convertFastifyValidationError(error);
@@ -67,12 +91,41 @@ class HttpServer {
67
91
  const type = mount.type || "";
68
92
  const { kind, name } = parseType(type);
69
93
  const prefix = mount.path || "";
70
- const api = this.ctx.moduleContext.getInvokable(name);
94
+ const api = this.ctx.moduleContext.getInvocable(name);
71
95
  if (!api) {
72
96
  throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
73
97
  }
74
98
  api.register(this.app, prefix);
75
99
  }
100
+ if (this.resolvedNotFoundHandler) {
101
+ const handler = this.resolvedNotFoundHandler;
102
+ this.app.setNotFoundHandler(async (request, reply) => {
103
+ const normalizedHeaders = {};
104
+ for (const [key, value] of Object.entries(request.headers)) {
105
+ normalizedHeaders[key.toLowerCase()] = value;
106
+ }
107
+ const requestContext = {
108
+ request: {
109
+ method: request.method,
110
+ path: request.url,
111
+ params: request.params || {},
112
+ query: request.query || {},
113
+ headers: normalizedHeaders,
114
+ body: request.body,
115
+ },
116
+ };
117
+ const result = await this.ctx.invoke(handler.kind, handler.name, requestContext);
118
+ if (handler.response) {
119
+ return dispatchResponse(handler.response, result, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
120
+ }
121
+ const status = result?.status ?? 200;
122
+ reply.code(status);
123
+ if (result?.headers) {
124
+ Object.entries(result.headers).forEach(([key, value]) => reply.header(key, value));
125
+ }
126
+ return reply.send(result?.body ?? result);
127
+ });
128
+ }
76
129
  }
77
130
  async run() {
78
131
  this.releaseHold = this.ctx.acquireHold();
@@ -103,8 +156,18 @@ class HttpServer {
103
156
  await this.app.close();
104
157
  }
105
158
  }
106
- export function create(resource, ctx) {
107
- return new HttpServer(resource, ctx);
159
+ export async function create(resource, ctx) {
160
+ let resolvedNotFoundHandler = null;
161
+ if (resource.notFoundHandler) {
162
+ const resolved = ctx.resolveChildren(resource.notFoundHandler.invoke);
163
+ resolvedNotFoundHandler = {
164
+ kind: resolved.kind,
165
+ name: resolved.name,
166
+ inputs: resource.notFoundHandler.invoke.inputs ?? {},
167
+ response: resource.notFoundHandler.response,
168
+ };
169
+ }
170
+ return new HttpServer(resource, ctx, resolvedNotFoundHandler);
108
171
  }
109
172
  function parseType(type) {
110
173
  const separator = type.lastIndexOf(".");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -23,7 +23,7 @@
23
23
  "ajv": "^8.17.1",
24
24
  "ajv-formats": "^3.0.1",
25
25
  "fastify": "^5.7.2",
26
- "@telorun/sdk": "0.2.5"
26
+ "@telorun/sdk": "0.2.6"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^20.0.0",
@@ -1,6 +1,8 @@
1
1
  import { Static, Type } from "@sinclair/typebox";
2
- import { ControllerContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import { ControllerContext, Invocable, KindRef, Ref, ResourceContext, ResourceInstance } from "@telorun/sdk";
3
3
  import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
4
+ import { type Readable } from "stream";
5
+ import { pipeline } from "stream/promises";
4
6
 
5
7
  const HttpApiRouteManifest = Type.Object({
6
8
  request: Type.Object({
@@ -15,24 +17,23 @@ const HttpApiRouteManifest = Type.Object({
15
17
  }),
16
18
  ),
17
19
  }),
18
- handler: Type.Optional(Type.Any()), // Any handler shape is allowed - will be processed in create()
19
- response: Type.Object({
20
- status: Type.Union([Type.Number({ minimum: 100, maximum: 599 }), Type.String()]),
21
- statuses: Type.Record(
22
- Type.String(),
23
- Type.Object({
24
- schema: Type.Optional(
25
- Type.Object({
26
- query: Type.Optional(Type.Any()),
27
- body: Type.Optional(Type.Any()),
28
- headers: Type.Optional(Type.Any()),
29
- }),
30
- ),
31
- headers: Type.Optional(Type.Record(Type.String(), Type.String())),
32
- body: Type.Optional(Type.Any()),
33
- }),
34
- ),
35
- }),
20
+ handler: Type.Optional(Type.Unsafe<KindRef<Invocable>>(Ref("kernel#Invocable"))),
21
+ response: Type.Array(
22
+ Type.Object({
23
+ status: Type.Integer({ minimum: 100, maximum: 599 }),
24
+ when: Type.Optional(Type.String()),
25
+ mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
26
+ schema: Type.Optional(
27
+ Type.Object({
28
+ query: Type.Optional(Type.Any()),
29
+ body: Type.Optional(Type.Any()),
30
+ headers: Type.Optional(Type.Any()),
31
+ }),
32
+ ),
33
+ headers: Type.Optional(Type.Record(Type.String(), Type.String())),
34
+ body: Type.Optional(Type.Any()),
35
+ }),
36
+ ),
36
37
  });
37
38
  type HttpApiRouteManifest = Static<typeof HttpApiRouteManifest>;
38
39
 
@@ -41,7 +42,74 @@ const HttpApiManifest = Type.Object({
41
42
  });
42
43
  type HttpApiManifest = Static<typeof HttpApiManifest>;
43
44
 
44
- export async function register(ctx: ControllerContext): Promise<void> {}
45
+ export async function register(_ctx: ControllerContext): Promise<void> {}
46
+
47
+ export type ResponseEntry = Static<
48
+ (typeof HttpApiRouteManifest)["properties"]["response"]["items"]
49
+ >;
50
+
51
+ export async function dispatchResponse(
52
+ response: ResponseEntry[],
53
+ result: unknown,
54
+ requestContext: Record<string, unknown>,
55
+ moduleContext: { expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown },
56
+ validateSchema: (value: unknown, schema: unknown) => void,
57
+ reply: FastifyReply,
58
+ ): Promise<void> {
59
+ let matched: ResponseEntry | undefined;
60
+ let fallback: ResponseEntry | undefined;
61
+
62
+ for (const entry of response) {
63
+ if (!entry.when) {
64
+ fallback ??= entry;
65
+ continue;
66
+ }
67
+ const condition = moduleContext.expandWith(entry.when, { result, ...requestContext });
68
+ if (condition === true) {
69
+ matched = entry;
70
+ break;
71
+ }
72
+ }
73
+
74
+ const statusEntry = matched ?? fallback;
75
+ if (!statusEntry) {
76
+ reply.code(500);
77
+ reply.send({
78
+ error: "InternalServerError",
79
+ message: "No matching response status entry",
80
+ status: 500,
81
+ });
82
+ return;
83
+ }
84
+
85
+ reply.code(statusEntry.status);
86
+
87
+ if (statusEntry.headers) {
88
+ const mappedHeaders = moduleContext.expandWith(statusEntry.headers, {
89
+ result,
90
+ ...requestContext,
91
+ }) as Record<string, unknown>;
92
+ Object.entries(mappedHeaders).forEach(([key, value]) => reply.header(key, value as string));
93
+ }
94
+
95
+ if (statusEntry.mode === "stream") {
96
+ reply.hijack();
97
+ reply.raw.writeHead(statusEntry.status, reply.getHeaders() as Record<string, string>);
98
+ await pipeline(result as Readable, reply.raw);
99
+ return;
100
+ }
101
+
102
+ if (statusEntry.body !== undefined) {
103
+ const mappedBody = moduleContext.expandWith(statusEntry.body, { result, ...requestContext });
104
+ if (statusEntry.schema?.body) {
105
+ validateSchema(mappedBody, statusEntry.schema.body);
106
+ }
107
+ reply.send(mappedBody);
108
+ return;
109
+ }
110
+
111
+ reply.send(result);
112
+ }
45
113
 
46
114
  export class HttpServerApi implements ResourceInstance {
47
115
  constructor(
@@ -72,7 +140,8 @@ export class HttpServerApi implements ResourceInstance {
72
140
  }
73
141
 
74
142
  private registerRoute(app: FastifyInstance, route: HttpApiRouteManifest) {
75
- const handler = route.handler ? resolveHandlerName(route.handler) : null;
143
+ // After Phase 5 injection, KindRef<Invocable> is replaced with the live Invocable instance.
144
+ const handler = route.handler as unknown as Invocable | undefined;
76
145
  const translatedPath = translateOpenApiPath(route.request.path);
77
146
 
78
147
  const schema: any = {
@@ -92,19 +161,16 @@ export class HttpServerApi implements ResourceInstance {
92
161
  schema.headers = route.request.schema?.headers;
93
162
  }
94
163
 
95
- schema.response = Object.keys(route.response.statuses).reduce(
96
- (acc, status) => {
97
- const statusConfig = route.response.statuses[status];
98
- if (statusConfig.schema) {
99
- if (statusConfig.schema.body) {
100
- acc[status] = statusConfig.schema.body;
101
- } else {
102
- acc[status] = {};
103
- }
164
+ schema.response = route.response.reduce(
165
+ (acc, entry) => {
166
+ if (entry.schema?.body) {
167
+ acc[entry.status] = entry.schema.body;
168
+ } else if (entry.schema) {
169
+ acc[entry.status] = {};
104
170
  }
105
171
  return acc;
106
172
  },
107
- {} as Record<string, any>,
173
+ {} as Record<number, any>,
108
174
  );
109
175
 
110
176
  app.route({
@@ -113,76 +179,26 @@ export class HttpServerApi implements ResourceInstance {
113
179
  schema,
114
180
  handler: async (request: FastifyRequest, reply: FastifyReply) => {
115
181
  try {
116
- // Normalize headers to lowercase
117
- const normalizedHeaders = normalizeHeaders(request.headers);
118
-
119
- // Construct standardized Telo request object
120
- const requestPayload = {
121
- method: request.method,
122
- path: request.url,
123
- params: request.params || {},
124
- query: request.query || {},
125
- headers: normalizedHeaders,
126
- body: request.body,
182
+ const requestContext = {
183
+ request: {
184
+ method: request.method,
185
+ path: request.url,
186
+ params: request.params || {},
187
+ query: request.query || {},
188
+ headers: normalizeHeaders(request.headers),
189
+ body: request.body,
190
+ },
127
191
  };
128
-
129
- // Wrap in "request" object as per spec
130
- const teloRequestContext = { request: requestPayload };
131
-
132
- const result = handler
133
- ? await this.ctx.invoke(
134
- handler.kind,
135
- handler.name,
136
- resolveHandlerInputs(route.handler, teloRequestContext),
137
- )
138
- : undefined;
139
-
140
- const response = route.response;
141
-
142
- // Determine final status code
143
- let statusCode = response.status;
144
- if (typeof statusCode === "string") {
145
- statusCode = this.ctx.expandValue(statusCode, { result }) as number;
146
- }
147
-
148
- // Convert status to string for lookup
149
- const statusKey = String(statusCode);
150
- const statusConfig = response.statuses[statusKey];
151
-
152
- if (!statusConfig) {
153
- reply.code(500);
154
- return reply.send({
155
- error: "InternalServerError",
156
- message: "Response status configuration not found",
157
- status: 500,
158
- });
159
- }
160
-
161
- // Set HTTP status code
162
- reply.code(statusCode as number);
163
-
164
- // Map and set response headers if specified
165
- if (statusConfig.headers) {
166
- const mappedHeaders = this.ctx.expandValue(statusConfig.headers, { result });
167
- Object.entries(mappedHeaders).forEach(([key, value]) => {
168
- reply.header(key, value as string);
169
- });
170
- }
171
-
172
- // Map and send response body if specified
173
- if (statusConfig.body !== undefined) {
174
- const mappedBody = this.ctx.expandValue(statusConfig.body, { result });
175
-
176
- // Validate response body if schema is specified
177
- if (statusConfig.schema && statusConfig.schema.body) {
178
- this.ctx.validateSchema(mappedBody, statusConfig.schema.body);
179
- }
180
-
181
- return reply.send(mappedBody);
182
- }
183
-
184
- // No body mapping, send result as-is
185
- return reply.send(result);
192
+ const result = handler ? await handler.invoke(requestContext) : undefined;
193
+
194
+ return dispatchResponse(
195
+ route.response,
196
+ result,
197
+ requestContext,
198
+ this.ctx.moduleContext,
199
+ this.ctx.validateSchema.bind(this.ctx),
200
+ reply,
201
+ );
186
202
  } catch (error) {
187
203
  // Let the error handler deal with all errors
188
204
  throw error;
@@ -193,101 +209,8 @@ export class HttpServerApi implements ResourceInstance {
193
209
  }
194
210
 
195
211
  export async function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi> {
196
- // First validate with a permissive schema (handler can be any shape)
197
212
  ctx.validateSchema(resource, HttpApiManifest);
198
- // Process routes and register unnamed handlers as child resources
199
- let handlerCounter = 0;
200
- const processedRoutes = (resource.routes || []).map((route: any) => {
201
- if (!route.handler) {
202
- return route;
203
- }
204
-
205
- // Check if handler is unnamed (inline handler)
206
- if (typeof route.handler === "object" && !route.handler.name) {
207
- // Use resolveChildren to register the unnamed handler and get its normalized reference
208
- const resolvedHandler = ctx.resolveChildren(route.handler, `__handler_${handlerCounter++}`);
209
-
210
- // Return route with the resolved handler reference
211
- return {
212
- ...route,
213
- handler: {
214
- kind: resolvedHandler.kind,
215
- name: resolvedHandler.name,
216
- inputs: route.handler.inputs,
217
- },
218
- };
219
- }
220
-
221
- return route;
222
- });
223
-
224
- // Create the API instance with processed routes
225
- const processedResource: HttpApiManifest = {
226
- ...resource,
227
- routes: processedRoutes,
228
- };
229
-
230
- return new HttpServerApi(ctx, processedResource);
231
- }
232
-
233
- function resolveHandlerName(handler: any): { kind: string; name: string } {
234
- if (typeof handler === "string") {
235
- const [kind, name] = handler.split("/");
236
- return { kind, name };
237
- }
238
- if (handler && typeof handler === "object" && typeof handler.kind === "string") {
239
- // name should always be present after create() processes the routes
240
- // but fallback gracefully if it's not
241
- const name = handler.name || `__unnamed_${Math.random().toString(36).slice(2, 9)}`;
242
- return { name, kind: handler.kind };
243
- }
244
- throw new Error("Unable to resolve handler - handler must have a 'kind' property");
245
- }
246
-
247
- function resolveHandlerInputs(handler: any, requestContext: Record<string, any>): any {
248
- if (typeof handler === "string") {
249
- return requestContext;
250
- }
251
- if (!handler || typeof handler !== "object") {
252
- return requestContext;
253
- }
254
- if (!handler.inputs) {
255
- return requestContext;
256
- }
257
- return resolveTemplateInputs(handler.inputs, requestContext);
258
- }
259
-
260
- function resolveTemplateInputs(value: any, context: Record<string, any>): any {
261
- if (typeof value === "string") {
262
- const match = value.match(/^\s*\$\{\{\s*([^}]+)\s*\}\}\s*$/);
263
- if (match) {
264
- return resolveTemplatePath(match[1], context);
265
- }
266
- return value;
267
- }
268
- if (Array.isArray(value)) {
269
- return value.map((item) => resolveTemplateInputs(item, context));
270
- }
271
- if (value && typeof value === "object") {
272
- const resolved: Record<string, any> = {};
273
- for (const [key, entry] of Object.entries(value)) {
274
- resolved[key] = resolveTemplateInputs(entry, context);
275
- }
276
- return resolved;
277
- }
278
- return value;
279
- }
280
-
281
- function resolveTemplatePath(pathExpression: string, context: Record<string, any>): any {
282
- const parts = pathExpression.trim().split(".").filter(Boolean);
283
- let current: any = context;
284
- for (const part of parts) {
285
- if (!current || (typeof current !== "object" && typeof current !== "function")) {
286
- return undefined;
287
- }
288
- current = current[part];
289
- }
290
- return current;
213
+ return new HttpServerApi(ctx, resource);
291
214
  }
292
215
 
293
216
  /**
@@ -308,59 +231,3 @@ function normalizeHeaders(headers: Record<string, any>): Record<string, any> {
308
231
  }
309
232
  return normalized;
310
233
  }
311
-
312
- /**
313
- * Legacy function - kept for compatibility but not used
314
- * Converts framework-specific validation errors to standardized Telo format
315
- * Returns null if the error is not a validation error
316
- */
317
- function convertValidationError(error: any): Record<string, any> | null {
318
- // Check if this is a Fastify/AJV validation error
319
- if (!error || typeof error !== "object") {
320
- return null;
321
- }
322
-
323
- // Fastify validation errors have a statusCode of 400 and validation array
324
- if (error.statusCode === 400 && Array.isArray(error.validation)) {
325
- const details = error.validation.map((err: any) => {
326
- const path = err.instancePath ? err.instancePath.replace(/^\//, "").replace(/\//g, ".") : "";
327
-
328
- // Determine location from keyword/message context
329
- let location = "body"; // default
330
- if (err.keyword === "required" && err.params?.missingProperty) {
331
- location = determinLocationFromContext(err);
332
- } else {
333
- location = determinLocationFromContext(err);
334
- }
335
-
336
- return {
337
- location,
338
- path: path || err.params?.missingProperty || "",
339
- message: err.message || "Validation failed",
340
- };
341
- });
342
-
343
- return {
344
- error: "ValidationError",
345
- message: "Request validation failed",
346
- status: 400,
347
- details,
348
- };
349
- }
350
-
351
- return null;
352
- }
353
-
354
- /**
355
- * Helper to determine the location (body, query, params, headers) from validation error context
356
- */
357
- function determinLocationFromContext(err: any): string {
358
- // AJV validation errors in Fastify include parent keyword context
359
- if (err.parentSchema && err.instancePath) {
360
- const path = err.instancePath;
361
- // This is a simplified check; in practice, Fastify provides better context
362
- // For now, default to "body" for general validation errors
363
- return "body";
364
- }
365
- return "body";
366
- }
@@ -1,27 +1,22 @@
1
1
  import swagger from "@fastify/swagger";
2
2
  import apiReference from "@scalar/fastify-api-reference";
3
- import type { ResourceContext, ResourceInstance, RuntimeResource } from "@telorun/sdk";
3
+ import type {
4
+ Invocable,
5
+ KindRef,
6
+ ResourceContext,
7
+ ResourceInstance,
8
+ RuntimeResource,
9
+ } from "@telorun/sdk";
4
10
  import addFormats from "ajv-formats";
5
11
  import Fastify, { FastifyInstance } from "fastify";
6
- import { HttpServerApi } from "./http-api-controller.js";
7
-
8
- type HttpRouteResource = RuntimeResource & {
9
- metadata?: { path?: string; method?: string };
10
- path?: string;
11
- method?: string;
12
- handler?: HttpHandlerSpec;
13
- request?: HttpRequestSchema;
14
- response?: {
15
- status?: number;
16
- headers?: Record<string, string>;
17
- body?: any;
18
- };
19
- };
12
+ import { dispatchResponse, HttpServerApi, ResponseEntry } from "./http-api-controller.js";
20
13
 
21
14
  type HttpServerResource = RuntimeResource & {
22
15
  host?: string;
23
16
  port?: number;
24
17
  baseUrl?: string;
18
+ logger?: boolean;
19
+ contentTypeParsers?: Array<{ contentType: string; parser?: Invocable }>;
25
20
  openapi?: {
26
21
  info: {
27
22
  title: string;
@@ -32,70 +27,73 @@ type HttpServerResource = RuntimeResource & {
32
27
  path?: string;
33
28
  type?: string;
34
29
  }>;
30
+ notFoundHandler?: {
31
+ invoke: KindRef<Invocable>;
32
+ response?: ResponseEntry[];
33
+ };
35
34
  };
36
35
 
37
- type HttpApiResource = RuntimeResource & {
38
- routes?: Array<
39
- | string
40
- | {
41
- request?: {
42
- path?: string;
43
- method?: string;
44
- query?: Record<string, any>;
45
- body?: Record<string, any>;
46
- headers?: Record<string, any>;
47
- };
48
- handler?: HttpHandlerSpec;
49
- response?: {
50
- status?: number;
51
- headers?: Record<string, string>;
52
- body?: any;
53
- };
54
- }
55
- >;
56
- };
57
-
58
- type HttpHandlerSpec =
59
- | string
60
- | {
61
- name?: string;
62
- inputs?: Record<string, any>;
63
- };
64
-
65
- type HttpRequestSchema = {
66
- query?: Record<string, any>;
67
- body?: Record<string, any>;
68
- headers?: Record<string, any>;
36
+ type ResolvedHandler = {
37
+ kind: string;
38
+ name: string;
39
+ inputs: Record<string, any>;
40
+ response?: ResponseEntry[];
69
41
  };
70
42
 
71
43
  class HttpServer implements ResourceInstance {
72
44
  private releaseHold: (() => void) | null = null;
45
+ private pluginsInitialized = false;
73
46
  private readonly app: FastifyInstance;
74
47
  private readonly host: string;
75
48
  private readonly port: number;
76
49
  private readonly baseUrl: string;
77
50
  private readonly resource: HttpServerResource;
78
51
  private readonly ctx: ResourceContext;
52
+ private readonly resolvedNotFoundHandler: ResolvedHandler | null;
79
53
 
80
- constructor(resource: HttpServerResource, ctx: ResourceContext) {
54
+ constructor(
55
+ resource: HttpServerResource,
56
+ ctx: ResourceContext,
57
+ resolvedNotFoundHandler: ResolvedHandler | null = null,
58
+ ) {
81
59
  this.resource = resource;
82
60
  this.ctx = ctx;
83
61
  this.host = resource.host || "0.0.0.0";
84
62
  this.port = Number(resource.port || 0);
85
63
  this.baseUrl = resource.baseUrl ?? `http://${this.host}:${this.port}`;
64
+ this.resolvedNotFoundHandler = resolvedNotFoundHandler;
86
65
 
87
66
  if (!this.port) {
88
67
  throw new Error("Http.Server port is required");
89
68
  }
90
- this.app = Fastify({ logger: true, ajv: { plugins: [addFormats.default as any] } });
69
+ this.app = Fastify({ logger: resource.logger, ajv: { plugins: [addFormats.default as any] } });
91
70
  }
92
71
 
93
72
  async init() {
94
- await this.setupPlugins();
73
+ if (!this.pluginsInitialized) {
74
+ await this.setupPlugins();
75
+ this.pluginsInitialized = true;
76
+ }
95
77
  this.setupRoutes();
96
78
  }
97
79
 
98
80
  private async setupPlugins() {
81
+ for (const { contentType, parser } of this.resource.contentTypeParsers ?? []) {
82
+ if (parser) {
83
+ this.app.addContentTypeParser(contentType, { parseAs: "string" }, async (_req, body, done) => {
84
+ try {
85
+ done(null, await parser.invoke({ body }));
86
+ } catch (err) {
87
+ done(err as Error, undefined);
88
+ }
89
+ });
90
+ } else {
91
+ this.app.addContentTypeParser(contentType, { parseAs: "string" }, (_req, body, done) => {
92
+ done(null, body);
93
+ });
94
+ }
95
+ }
96
+
99
97
  // Register custom error handler for validation errors
100
98
  this.app.setErrorHandler((error, request, reply) => {
101
99
  const mappedError = convertFastifyValidationError(error);
@@ -139,13 +137,52 @@ class HttpServer implements ResourceInstance {
139
137
  const { kind, name } = parseType(type);
140
138
  const prefix = mount.path || "";
141
139
 
142
- const api = this.ctx.moduleContext.getInvokable(name) as unknown as HttpServerApi;
140
+ const api = this.ctx.moduleContext.getInvocable(name) as unknown as HttpServerApi;
143
141
 
144
142
  if (!api) {
145
143
  throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
146
144
  }
147
145
  api.register(this.app, prefix);
148
146
  }
147
+
148
+ if (this.resolvedNotFoundHandler) {
149
+ const handler = this.resolvedNotFoundHandler;
150
+ this.app.setNotFoundHandler(async (request, reply) => {
151
+ const normalizedHeaders: Record<string, any> = {};
152
+ for (const [key, value] of Object.entries(request.headers)) {
153
+ normalizedHeaders[key.toLowerCase()] = value;
154
+ }
155
+ const requestContext = {
156
+ request: {
157
+ method: request.method,
158
+ path: request.url,
159
+ params: request.params || {},
160
+ query: request.query || {},
161
+ headers: normalizedHeaders,
162
+ body: request.body,
163
+ },
164
+ };
165
+ const result = await this.ctx.invoke(handler.kind, handler.name, requestContext);
166
+ if (handler.response) {
167
+ return dispatchResponse(
168
+ handler.response,
169
+ result,
170
+ requestContext,
171
+ this.ctx.moduleContext,
172
+ this.ctx.validateSchema.bind(this.ctx),
173
+ reply,
174
+ );
175
+ }
176
+ const status = result?.status ?? 200;
177
+ reply.code(status);
178
+ if (result?.headers) {
179
+ Object.entries(result.headers).forEach(([key, value]) =>
180
+ reply.header(key, value as string),
181
+ );
182
+ }
183
+ return reply.send(result?.body ?? result);
184
+ });
185
+ }
149
186
  }
150
187
 
151
188
  async run(): Promise<void> {
@@ -178,11 +215,21 @@ class HttpServer implements ResourceInstance {
178
215
  }
179
216
  }
180
217
 
181
- export function create(
218
+ export async function create(
182
219
  resource: HttpServerResource,
183
220
  ctx: ResourceContext,
184
- ): ResourceInstance | null {
185
- return new HttpServer(resource, ctx);
221
+ ): Promise<ResourceInstance | null> {
222
+ let resolvedNotFoundHandler: ResolvedHandler | null = null;
223
+ if (resource.notFoundHandler) {
224
+ const resolved = ctx.resolveChildren(resource.notFoundHandler.invoke);
225
+ resolvedNotFoundHandler = {
226
+ kind: resolved.kind,
227
+ name: resolved.name,
228
+ inputs: (resource.notFoundHandler.invoke as any).inputs ?? {},
229
+ response: resource.notFoundHandler.response,
230
+ };
231
+ }
232
+ return new HttpServer(resource, ctx, resolvedNotFoundHandler);
186
233
  }
187
234
 
188
235
  function parseType(type: string): { kind: string; name: string } {