@telorun/http-server 0.8.1 → 0.10.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,50 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ee8926f: Unify resource references on the `!ref` YAML tag. The object form `{ kind, name }`
8
+ and bare-string references are removed: the analyzer rejects them up front
9
+ (`INVALID_REFERENCE_FORM`) and `!ref <name>` / `!ref <Alias>.<name>` is the only
10
+ authored shape. `resolveRefSentinels` now resolves `!ref` sentinels across the
11
+ whole manifest tree (including step `invoke`s and refs nested in inline
12
+ definitions), so every consumer sees the uniform resolved shape. The
13
+ http-server mount slot is renamed `mounts[].type` → `mounts[].mount`, and the
14
+ mcp transports / clients read their Phase-5-injected ref instances directly.
15
+
16
+ Schema validation (analyzer and kernel) now drops the stale scalar `type` a ref
17
+ slot may still pin (older published modules encode references as `type: string`)
18
+ before running AJV, so a resolved reference object validates against a legacy
19
+ `x-telo-ref` slot. This keeps an app that consumes a not-yet-republished
20
+ dependency analyzable and bootable during the migration. Object-typed ref slots
21
+ that also accept an inline value (e.g. `inputType` / `outputType`) are left
22
+ untouched.
23
+
24
+ `Run.Sequence` reference slots are brought onto the same enforcement path: a
25
+ step `invoke` and a scope `targets` entry now require a `!ref` (the `targets`
26
+ slot gains an `x-telo-ref` constraint and the `with` scope's visibility extends
27
+ to `/targets`), so a bare-string ref at either is rejected with
28
+ `INVALID_REFERENCE_FORM` at `telo check` — uniform with `Telo.Application`
29
+ targets — instead of failing as an obscure runtime error. The controller reads
30
+ the resolved reference rather than a bare name.
31
+
32
+ ## 0.9.0
33
+
34
+ ### Minor Changes
35
+
36
+ - 8586b39: Resolve resource references uniformly across import boundaries and execution scopes.
37
+
38
+ - **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.
39
+ - **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.
40
+ - **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.
41
+ - **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`).
42
+ - **sdk**: `CreatedResource` gains an optional `resource`, letting a factory return the expanded manifest the controller was created with.
43
+
44
+ ### Patch Changes
45
+
46
+ - @telorun/http-dispatch@0.4.1
47
+
3
48
  ## 0.8.1
4
49
 
5
50
  ### Patch Changes
package/README.md CHANGED
@@ -24,28 +24,48 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
24
24
  kind: Telo.Application
25
25
  metadata: { name: hello-http, version: 1.0.0 }
26
26
  imports:
27
- Http: pkg:npm/@telorun/http-server@^1.0.0
28
- JS: pkg:npm/@telorun/javascript@^1.0.0
29
- targets: [Server]
27
+ Http: std/http-server@0.9.0
28
+ JS: std/javascript@0.4.1
29
+ targets: [ !ref Server ]
30
30
  ---
31
31
  kind: Http.Server
32
32
  metadata: { name: Server }
33
33
  port: 8080
34
34
  mounts:
35
35
  - path: /api
36
- type: Api
36
+ mount: !ref Api
37
37
  ---
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
- handler: { kind: JS.Script, name: Greet }
57
+ handler: !ref Greet
45
58
  returns:
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
@@ -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 `mount` 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
+ mount?: 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.mount` 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.mount;
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.1",
3
+ "version": "0.10.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": "*"
@@ -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 `mount` 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
+ mount?: 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.mount` 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.mount;
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
+ });