@telorun/http-server 0.8.0 → 0.9.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,27 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8586b39: Resolve resource references uniformly across import boundaries and execution scopes.
8
+
9
+ - **http-server**: `mounts[].type` is now an injected `Telo.Mount` reference (`!ref <name>`, or `!ref <Alias>.<name>` for a mount exported by an imported library) instead of a dotted kind-string. The server consumes the live injected instance, so an `Http.Api` / `Mcp.HttpEndpoint` defined in another library can be mounted across the boundary. The bare `Kind.Name` string form is removed.
10
+ - **s3**: `bucketRef` is now an `x-telo-ref: "std/s3#Bucket"` slot (`!ref <bucket>` / `!ref <Alias>.<bucket>`); controllers consume the injected `S3.Bucket` instance, so S3 operations can reference a bucket exported by another library. The `{ name }` form is removed.
11
+ - **analyzer**: `resolveRefSentinels` recurses into `x-telo-scope` resources, so a `!ref` inside a scoped resource (e.g. a `Run.Sequence` `with:` server's mount) is canonicalized to `{kind, name}` like any top-level slot.
12
+ - **kernel**: Phase-5 dependency injection targets the (compile-CEL-expanded) resource the controller actually receives, so injected instances reach reference fields that also carry `x-telo-eval: compile` (e.g. `Http.Server.mounts`).
13
+ - **sdk**: `CreatedResource` gains an optional `resource`, letting a factory return the expanded manifest the controller was created with.
14
+
15
+ ### Patch Changes
16
+
17
+ - @telorun/http-dispatch@0.4.1
18
+
19
+ ## 0.8.1
20
+
21
+ ### Patch Changes
22
+
23
+ - e3146f3: Fix spurious request cancellation (HTTP 499) for body-bearing requests whose handler awaits before replying. Per-request cancellation was wired to the request stream's `close` event, which fires as normal cleanup once a request body has been fully received — so any `PUT`/`POST` whose handler did async work (e.g. a DB query) before sending a response was cancelled mid-flight and answered with 499. Cancellation now listens on the response socket, which only closes early on a genuine client disconnect; normal completions and synchronous rejects are unaffected.
24
+
3
25
  ## 0.8.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -38,7 +38,20 @@ mounts:
38
38
  kind: Http.Api
39
39
  metadata: { name: Api }
40
40
  routes:
41
- - request: { method: GET, path: /hello/{name} }
41
+ # Declare request.schema and the response content.schema so the route is
42
+ # type-checked AND fully described in the generated OpenAPI document. Put
43
+ # `examples` on each field so the spec shows sample payloads.
44
+ - request:
45
+ method: GET
46
+ path: /hello/{name}
47
+ schema:
48
+ params:
49
+ type: object
50
+ properties:
51
+ name:
52
+ type: string
53
+ description: Name to greet.
54
+ examples: [ "Ada" ]
42
55
  inputs:
43
56
  name: "${{ request.params.name }}"
44
57
  handler: { kind: JS.Script, name: Greet }
@@ -46,6 +59,13 @@ routes:
46
59
  - status: 200
47
60
  content:
48
61
  application/json:
62
+ schema:
63
+ type: object
64
+ properties:
65
+ message:
66
+ type: string
67
+ description: The greeting.
68
+ examples: [ "Hello, Ada!" ]
49
69
  body: { message: "${{ result.message }}" }
50
70
  ---
51
71
  kind: JS.Script
@@ -107,9 +107,13 @@ export class HttpServerApi {
107
107
  };
108
108
  const sink = fastifyReplySink(reply);
109
109
  // Per-request cancellation: abandon downstream work when the client
110
- // disconnects before the response is sent.
110
+ // disconnects before the response is sent. Listen on the response
111
+ // socket, not the request stream — the latter's `close` fires as normal
112
+ // cleanup once a request body has been fully received, which would
113
+ // cancel any body-bearing request that awaits (e.g. a DB call) before
114
+ // replying. The response socket only closes early on a real disconnect.
111
115
  const cancellation = this.ctx.createCancellationSource();
112
- request.raw.on("close", () => {
116
+ reply.raw.on("close", () => {
113
117
  if (!reply.sent)
114
118
  cancellation.cancel("client-disconnect");
115
119
  });
@@ -1,5 +1,12 @@
1
1
  import { CatchEntry, ReturnEntry } from "@telorun/http-dispatch";
2
2
  import { type Invocable, type KindRef, type ResourceContext, type ResourceInstance, type RuntimeResource } from "@telorun/sdk";
3
+ import { FastifyInstance } from "fastify";
4
+ /** A mounted Telo.Mount instance (Http.Api, Mcp.HttpEndpoint, …). The kernel injects the
5
+ * live instance into a mount's `type` slot (x-telo-ref "telo#Mount") — cross-module refs
6
+ * resolve to an imported library's exported mount — and every mountable exposes register(). */
7
+ interface Mountable {
8
+ register(app: FastifyInstance, prefix: string): void | Promise<void>;
9
+ }
3
10
  type CorsOptions = {
4
11
  origin?: string | boolean | string[];
5
12
  methods?: string | string[];
@@ -33,7 +40,7 @@ type HttpServerResource = RuntimeResource & {
33
40
  };
34
41
  mounts?: Array<{
35
42
  path?: string;
36
- type?: string;
43
+ type?: Mountable;
37
44
  }>;
38
45
  notFoundHandler?: {
39
46
  invoke: KindRef<Invocable>;
@@ -118,12 +118,12 @@ class HttpServer {
118
118
  const mounts = this.resource.mounts || [];
119
119
  // const resolveSchema = createSchemaResolver(this.ctx);
120
120
  for (const mount of mounts) {
121
- const type = mount.type || "";
122
- const { kind, name } = parseType(type);
123
121
  const prefix = mount.path || "";
124
- const api = this.ctx.moduleContext.getInstance(name);
125
- if (!api) {
126
- throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
122
+ // `mount.type` is the live Telo.Mount instance injected by the kernel at Phase 5
123
+ // (x-telo-ref "telo#Mount") — a same-module or imported-library mount, uniformly.
124
+ const api = mount.type;
125
+ if (!api || typeof api.register !== "function") {
126
+ throw new Error(`Failed to mount at "${prefix}": mount target did not resolve to a Telo.Mount instance`);
127
127
  }
128
128
  api.register(this.app, prefix);
129
129
  }
@@ -234,13 +234,6 @@ export async function create(resource, ctx) {
234
234
  }
235
235
  return new HttpServer(resource, ctx, resolvedNotFoundHandler);
236
236
  }
237
- function parseType(type) {
238
- const separator = type.lastIndexOf(".");
239
- if (separator <= 0 || separator === type.length - 1) {
240
- return { kind: "", name: "" };
241
- }
242
- return { kind: type.slice(0, separator), name: type.slice(separator + 1) };
243
- }
244
237
  /**
245
238
  * Converts Fastify validation errors to standardized Telo format
246
239
  * Returns null if the error is not a validation error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -49,7 +49,7 @@
49
49
  "@types/node": "^20.0.0",
50
50
  "typescript": "^5.0.0",
51
51
  "vitest": "^2.1.8",
52
- "@telorun/sdk": "0.19.0"
52
+ "@telorun/sdk": "0.23.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@telorun/sdk": "*"
@@ -148,9 +148,13 @@ export class HttpServerApi implements ResourceInstance {
148
148
  const sink = fastifyReplySink(reply);
149
149
 
150
150
  // Per-request cancellation: abandon downstream work when the client
151
- // disconnects before the response is sent.
151
+ // disconnects before the response is sent. Listen on the response
152
+ // socket, not the request stream — the latter's `close` fires as normal
153
+ // cleanup once a request body has been fully received, which would
154
+ // cancel any body-bearing request that awaits (e.g. a DB call) before
155
+ // replying. The response socket only closes early on a real disconnect.
152
156
  const cancellation = this.ctx.createCancellationSource();
153
- request.raw.on("close", () => {
157
+ reply.raw.on("close", () => {
154
158
  if (!reply.sent) cancellation.cancel("client-disconnect");
155
159
  });
156
160
 
@@ -18,7 +18,13 @@ import {
18
18
  import addFormats from "ajv-formats";
19
19
  import Fastify, { FastifyInstance } from "fastify";
20
20
  import { fastifyReplySink } from "./fastify-reply-sink.js";
21
- import { HttpServerApi } from "./http-api-controller.js";
21
+
22
+ /** A mounted Telo.Mount instance (Http.Api, Mcp.HttpEndpoint, …). The kernel injects the
23
+ * live instance into a mount's `type` slot (x-telo-ref "telo#Mount") — cross-module refs
24
+ * resolve to an imported library's exported mount — and every mountable exposes register(). */
25
+ interface Mountable {
26
+ register(app: FastifyInstance, prefix: string): void | Promise<void>;
27
+ }
22
28
 
23
29
  type CorsOptions = {
24
30
  origin?: string | boolean | string[];
@@ -50,7 +56,9 @@ type HttpServerResource = RuntimeResource & {
50
56
  };
51
57
  mounts?: Array<{
52
58
  path?: string;
53
- type?: string;
59
+ // x-telo-ref "telo#Mount": Phase 5 replaces this slot with the live mounted
60
+ // instance (Http.Api, Mcp.HttpEndpoint, …), local or imported.
61
+ type?: Mountable;
54
62
  }>;
55
63
  notFoundHandler?: {
56
64
  invoke: KindRef<Invocable>;
@@ -192,14 +200,14 @@ class HttpServer implements ResourceInstance {
192
200
  const mounts = this.resource.mounts || [];
193
201
  // const resolveSchema = createSchemaResolver(this.ctx);
194
202
  for (const mount of mounts) {
195
- const type = mount.type || "";
196
- const { kind, name } = parseType(type);
197
203
  const prefix = mount.path || "";
198
-
199
- const api = this.ctx.moduleContext.getInstance(name) as unknown as HttpServerApi;
200
-
201
- if (!api) {
202
- throw new Error(`Failed to mount Http.Api at "${prefix}": ${type} not found`);
204
+ // `mount.type` is the live Telo.Mount instance injected by the kernel at Phase 5
205
+ // (x-telo-ref "telo#Mount") a same-module or imported-library mount, uniformly.
206
+ const api = mount.type;
207
+ if (!api || typeof api.register !== "function") {
208
+ throw new Error(
209
+ `Failed to mount at "${prefix}": mount target did not resolve to a Telo.Mount instance`,
210
+ );
203
211
  }
204
212
  api.register(this.app, prefix);
205
213
  }
@@ -341,14 +349,6 @@ export async function create(
341
349
  return new HttpServer(resource, ctx, resolvedNotFoundHandler);
342
350
  }
343
351
 
344
- function parseType(type: string): { kind: string; name: string } {
345
- const separator = type.lastIndexOf(".");
346
- if (separator <= 0 || separator === type.length - 1) {
347
- return { kind: "", name: "" };
348
- }
349
- return { kind: type.slice(0, separator), name: type.slice(separator + 1) };
350
- }
351
-
352
352
  /**
353
353
  * Converts Fastify validation errors to standardized Telo format
354
354
  * Returns null if the error is not a validation error
@@ -0,0 +1,98 @@
1
+ import { ERR_INVOKE_CANCELLED, InvokeError, createCancellationSource } from "@telorun/sdk";
2
+ import Fastify from "fastify";
3
+ import net from "node:net";
4
+ import type { AddressInfo } from "node:net";
5
+ import { describe, expect, it } from "vitest";
6
+ import { create } from "../src/http-api-controller.js";
7
+
8
+ /**
9
+ * The route handler must be cancelled when the client disconnects before the
10
+ * response is sent. The controller wires per-request cancellation to the
11
+ * response socket's `close`; this drives the real controller end-to-end (real
12
+ * Fastify, real socket) and asserts the cancellation token reached the handler.
13
+ *
14
+ * Node-only by nature: Bun's `node:http` does not fire the response-socket
15
+ * `close` before the response on a disconnect, so this can't be observed there.
16
+ * That's why it lives here (vitest, run under Node) rather than as a YAML test
17
+ * in the Bun-run suite. The bug fix it guards — cancelling on the *response*
18
+ * socket so a fully-read-but-still-connected request is NOT spuriously
19
+ * cancelled — is exercised by the registry e2e on both runtimes.
20
+ */
21
+ describe("http-server request cancellation", () => {
22
+ it("cancels the handler when the client disconnects mid-request", async () => {
23
+ let handlerEntered = false;
24
+ let handlerCancelled = false;
25
+
26
+ const handler = {
27
+ async invoke(_input: unknown, invokeCtx?: { cancellation?: any }) {
28
+ handlerEntered = true;
29
+ await new Promise<void>((resolve, reject) => {
30
+ const timer = setTimeout(resolve, 3000);
31
+ invokeCtx?.cancellation?.onCancelled(() => {
32
+ clearTimeout(timer);
33
+ handlerCancelled = true;
34
+ reject(new InvokeError(ERR_INVOKE_CANCELLED, "cancelled while waiting"));
35
+ });
36
+ });
37
+ return { ok: true };
38
+ },
39
+ snapshot: () => ({}),
40
+ };
41
+
42
+ // Minimal ResourceContext: just the surface the controller touches.
43
+ const ctx = {
44
+ validateSchema: () => {},
45
+ resolveChildren: () => ({ kind: "Test.Handler", name: "SlowWork" }),
46
+ moduleContext: { expandWith: (value: unknown) => value },
47
+ createCancellationSource: () => createCancellationSource(),
48
+ invokeResolved: (_kind: string, _name: string, h: typeof handler, input: unknown, c: unknown) =>
49
+ h.invoke(input, c as { cancellation?: any }),
50
+ emitEvent: () => {},
51
+ } as unknown as Parameters<typeof create>[1];
52
+
53
+ const resource = {
54
+ metadata: { name: "SlowApi", module: "test" },
55
+ routes: [
56
+ {
57
+ request: { path: "/slow", method: "PUT", schema: { body: { type: "string" } } },
58
+ handler,
59
+ inputs: {},
60
+ returns: [{ status: 200, content: { "application/json": { body: { ok: true } } } }],
61
+ },
62
+ ],
63
+ };
64
+
65
+ const api = await create(resource, ctx);
66
+ const app = Fastify({ logger: false });
67
+ api.register(app);
68
+ await app.listen({ host: "127.0.0.1", port: 0 });
69
+ const { port } = app.server.address() as AddressInfo;
70
+
71
+ try {
72
+ await new Promise<void>((resolve, reject) => {
73
+ const socket = net.connect({ host: "127.0.0.1", port });
74
+ socket.on("error", reject);
75
+ socket.on("connect", () => {
76
+ const body = "x";
77
+ socket.write(
78
+ `PUT /slow HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\n` +
79
+ `Content-Type: text/plain\r\nContent-Length: ${body.length}\r\n\r\n${body}`,
80
+ );
81
+ // Let the handler enter its wait, then hard-close (real disconnect).
82
+ setTimeout(() => {
83
+ socket.destroy();
84
+ resolve();
85
+ }, 200);
86
+ });
87
+ });
88
+
89
+ // Allow the response-socket close → cancellation → handler rejection to settle.
90
+ await new Promise((r) => setTimeout(r, 400));
91
+
92
+ expect(handlerEntered).toBe(true);
93
+ expect(handlerCancelled).toBe(true);
94
+ } finally {
95
+ await app.close().catch(() => {});
96
+ }
97
+ });
98
+ });