@telorun/http-server 0.1.3 → 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,21 @@
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
+
11
+ ## 0.1.4
12
+
13
+ ### Patch Changes
14
+
15
+ - Automated release.
16
+ - Updated dependencies
17
+ - @telorun/sdk@0.2.5
18
+
3
19
  ## 0.1.3
4
20
 
5
21
  ### 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;
@@ -37,16 +87,6 @@ export class HttpServerApi {
37
87
  }
38
88
  async init() { }
39
89
  register(app, prefix = "") {
40
- // Register custom error handler for validation errors
41
- app.setErrorHandler((error, request, reply) => {
42
- const mappedError = convertFastifyValidationError(error);
43
- if (mappedError) {
44
- reply.code(400);
45
- return reply.send(mappedError);
46
- }
47
- // Let Fastify handle other errors normally
48
- throw error;
49
- });
50
90
  if (prefix) {
51
91
  app.register(async (scoped) => {
52
92
  this.registerRoutes(scoped);
@@ -63,7 +103,8 @@ export class HttpServerApi {
63
103
  }
64
104
  }
65
105
  registerRoute(app, route) {
66
- 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;
67
108
  const translatedPath = translateOpenApiPath(route.request.path);
68
109
  const schema = {
69
110
  response: {},
@@ -80,19 +121,12 @@ export class HttpServerApi {
80
121
  if (route.request.schema?.headers) {
81
122
  schema.headers = route.request.schema?.headers;
82
123
  }
83
- schema.response = Object.keys(route.response.statuses).reduce((acc, status) => {
84
- const statusConfig = route.response.statuses[status];
85
- if (statusConfig.schema) {
86
- acc[status] = {};
87
- if (statusConfig.schema.query) {
88
- acc[status].querystring = statusConfig.schema.query;
89
- }
90
- if (statusConfig.schema.body) {
91
- acc[status].body = statusConfig.schema.body;
92
- }
93
- if (statusConfig.schema.headers) {
94
- acc[status].headers = statusConfig.schema.headers;
95
- }
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] = {};
96
130
  }
97
131
  return acc;
98
132
  }, {});
@@ -102,59 +136,18 @@ export class HttpServerApi {
102
136
  schema,
103
137
  handler: async (request, reply) => {
104
138
  try {
105
- // Normalize headers to lowercase
106
- const normalizedHeaders = normalizeHeaders(request.headers);
107
- // Construct standardized Telo request object
108
- const requestPayload = {
109
- method: request.method,
110
- path: request.url,
111
- params: request.params || {},
112
- query: request.query || {},
113
- headers: normalizedHeaders,
114
- 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
+ },
115
148
  };
116
- // Wrap in "request" object as per spec
117
- const teloRequestContext = { request: requestPayload };
118
- const result = handler
119
- ? await this.ctx.invoke(handler.kind, handler.name, resolveHandlerInputs(route.handler, teloRequestContext))
120
- : undefined;
121
- const response = route.response;
122
- // Determine final status code
123
- let statusCode = response.status;
124
- if (typeof statusCode === "string") {
125
- statusCode = this.ctx.expandValue(statusCode, { result });
126
- }
127
- // Convert status to string for lookup
128
- const statusKey = String(statusCode);
129
- const statusConfig = response.statuses[statusKey];
130
- if (!statusConfig) {
131
- reply.code(500);
132
- return reply.send({
133
- error: "InternalServerError",
134
- message: "Response status configuration not found",
135
- status: 500,
136
- });
137
- }
138
- // Set HTTP status code
139
- reply.code(statusCode);
140
- // Map and set response headers if specified
141
- if (statusConfig.headers) {
142
- const mappedHeaders = this.ctx.expandValue(statusConfig.headers, { result });
143
- Object.entries(mappedHeaders).forEach(([key, value]) => {
144
- reply.header(key, value);
145
- });
146
- }
147
- // Map and send response body if specified
148
- if (statusConfig.body !== undefined) {
149
- const mappedBody = this.ctx.expandValue(statusConfig.body, { result });
150
- // Validate response body if schema is specified
151
- if (statusConfig.schema && statusConfig.schema.body) {
152
- this.ctx.validateSchema(mappedBody, statusConfig.schema.body);
153
- }
154
- return reply.send(mappedBody);
155
- }
156
- // No body mapping, send result as-is
157
- 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);
158
151
  }
159
152
  catch (error) {
160
153
  // Let the error handler deal with all errors
@@ -165,92 +158,8 @@ export class HttpServerApi {
165
158
  }
166
159
  }
167
160
  export async function create(resource, ctx) {
168
- // First validate with a permissive schema (handler can be any shape)
169
161
  ctx.validateSchema(resource, HttpApiManifest);
170
- // Process routes and register unnamed handlers as child resources
171
- let handlerCounter = 0;
172
- const processedRoutes = (resource.routes || []).map((route) => {
173
- if (!route.handler) {
174
- return route;
175
- }
176
- // Check if handler is unnamed (inline handler)
177
- if (typeof route.handler === "object" && !route.handler.name) {
178
- // Use resolveChildren to register the unnamed handler and get its normalized reference
179
- const resolvedHandler = ctx.resolveChildren(route.handler, `__handler_${handlerCounter++}`);
180
- // Return route with the resolved handler reference
181
- return {
182
- ...route,
183
- handler: {
184
- kind: resolvedHandler.kind,
185
- name: resolvedHandler.name,
186
- inputs: route.handler.inputs,
187
- },
188
- };
189
- }
190
- return route;
191
- });
192
- // Create the API instance with processed routes
193
- const processedResource = {
194
- ...resource,
195
- routes: processedRoutes,
196
- };
197
- return new HttpServerApi(ctx, processedResource);
198
- }
199
- function resolveHandlerName(handler) {
200
- if (typeof handler === "string") {
201
- const [kind, name] = handler.split("/");
202
- return { kind, name };
203
- }
204
- if (handler && typeof handler === "object" && typeof handler.kind === "string") {
205
- // name should always be present after create() processes the routes
206
- // but fallback gracefully if it's not
207
- const name = handler.name || `__unnamed_${Math.random().toString(36).slice(2, 9)}`;
208
- return { name, kind: handler.kind };
209
- }
210
- throw new Error("Unable to resolve handler - handler must have a 'kind' property");
211
- }
212
- function resolveHandlerInputs(handler, requestContext) {
213
- if (typeof handler === "string") {
214
- return requestContext;
215
- }
216
- if (!handler || typeof handler !== "object") {
217
- return requestContext;
218
- }
219
- if (!handler.inputs) {
220
- return requestContext;
221
- }
222
- return resolveTemplateInputs(handler.inputs, requestContext);
223
- }
224
- function resolveTemplateInputs(value, context) {
225
- if (typeof value === "string") {
226
- const match = value.match(/^\s*\$\{\{\s*([^}]+)\s*\}\}\s*$/);
227
- if (match) {
228
- return resolveTemplatePath(match[1], context);
229
- }
230
- return value;
231
- }
232
- if (Array.isArray(value)) {
233
- return value.map((item) => resolveTemplateInputs(item, context));
234
- }
235
- if (value && typeof value === "object") {
236
- const resolved = {};
237
- for (const [key, entry] of Object.entries(value)) {
238
- resolved[key] = resolveTemplateInputs(entry, context);
239
- }
240
- return resolved;
241
- }
242
- return value;
243
- }
244
- function resolveTemplatePath(pathExpression, context) {
245
- const parts = pathExpression.trim().split(".").filter(Boolean);
246
- let current = context;
247
- for (const part of parts) {
248
- if (!current || (typeof current !== "object" && typeof current !== "function")) {
249
- return undefined;
250
- }
251
- current = current[part];
252
- }
253
- return current;
162
+ return new HttpServerApi(ctx, resource);
254
163
  }
255
164
  /**
256
165
  * Translates OpenAPI path format {paramName} to Fastify format :paramName
@@ -269,115 +178,3 @@ function normalizeHeaders(headers) {
269
178
  }
270
179
  return normalized;
271
180
  }
272
- /**
273
- * Converts Fastify validation errors to standardized Telo format
274
- * Returns null if the error is not a validation error
275
- */
276
- function convertFastifyValidationError(error) {
277
- // Check if this is a Fastify validation error
278
- if (!error || typeof error !== "object" || error.code !== "FST_ERR_VALIDATION") {
279
- return null;
280
- }
281
- const message = error.message || "";
282
- const details = [];
283
- // Parse Fastify validation error message to extract location and field
284
- // Format examples:
285
- // "querystring must have required property 'name'"
286
- // "body must be object"
287
- // "params.userId must be string"
288
- let location = "body"; // default
289
- let fieldPath = "";
290
- let validationMessage = "Validation failed";
291
- // Try to extract location from message
292
- if (message.includes("querystring")) {
293
- location = "query";
294
- }
295
- else if (message.includes("params")) {
296
- location = "params";
297
- }
298
- else if (message.includes("headers")) {
299
- location = "headers";
300
- }
301
- else if (message.includes("body")) {
302
- location = "body";
303
- }
304
- // Extract field name from "must have required property 'fieldName'" pattern
305
- const requiredMatch = message.match(/must have required property '([^']+)'/);
306
- if (requiredMatch) {
307
- fieldPath = requiredMatch[1];
308
- validationMessage = `is a required property`;
309
- }
310
- else {
311
- // Extract field from "fieldName must be" pattern
312
- const fieldMatch = message.match(/^(?:querystring|body|params|headers)\.?(\w+)\s/);
313
- if (fieldMatch) {
314
- fieldPath = fieldMatch[1];
315
- }
316
- validationMessage = message
317
- .replace(/^(?:querystring|body|params|headers)\.?\w*\s/, "")
318
- .replace(" must ", " ");
319
- }
320
- if (fieldPath || message) {
321
- details.push({
322
- location,
323
- path: fieldPath,
324
- message: validationMessage,
325
- });
326
- }
327
- return {
328
- error: "ValidationError",
329
- message: "Request validation failed",
330
- status: 400,
331
- details,
332
- };
333
- }
334
- /**
335
- * Legacy function - kept for compatibility but not used
336
- * Converts framework-specific validation errors to standardized Telo format
337
- * Returns null if the error is not a validation error
338
- */
339
- function convertValidationError(error) {
340
- // Check if this is a Fastify/AJV validation error
341
- if (!error || typeof error !== "object") {
342
- return null;
343
- }
344
- // Fastify validation errors have a statusCode of 400 and validation array
345
- if (error.statusCode === 400 && Array.isArray(error.validation)) {
346
- const details = error.validation.map((err) => {
347
- const path = err.instancePath ? err.instancePath.replace(/^\//, "").replace(/\//g, ".") : "";
348
- // Determine location from keyword/message context
349
- let location = "body"; // default
350
- if (err.keyword === "required" && err.params?.missingProperty) {
351
- location = determinLocationFromContext(err);
352
- }
353
- else {
354
- location = determinLocationFromContext(err);
355
- }
356
- return {
357
- location,
358
- path: path || err.params?.missingProperty || "",
359
- message: err.message || "Validation failed",
360
- };
361
- });
362
- return {
363
- error: "ValidationError",
364
- message: "Request validation failed",
365
- status: 400,
366
- details,
367
- };
368
- }
369
- return null;
370
- }
371
- /**
372
- * Helper to determine the location (body, query, params, headers) from validation error context
373
- */
374
- function determinLocationFromContext(err) {
375
- // AJV validation errors in Fastify include parent keyword context
376
- if (err.parentSchema && err.instancePath) {
377
- const path = err.instancePath;
378
- // This is a simplified check; in practice, Fastify provides better context
379
- // For now, default to "body" for general validation errors
380
- return "body";
381
- }
382
- return "body";
383
- }
@@ -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 {};