@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 +45 -0
- package/README.md +26 -6
- package/dist/http-server-controller.d.ts +8 -1
- package/dist/http-server-controller.js +5 -12
- package/package.json +2 -2
- package/src/http-server-controller.ts +17 -17
- package/tests/request-cancel.test.ts +98 -0
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:
|
|
28
|
-
JS:
|
|
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
|
-
|
|
36
|
+
mount: !ref Api
|
|
37
37
|
---
|
|
38
38
|
kind: Http.Api
|
|
39
39
|
metadata: { name: Api }
|
|
40
40
|
routes:
|
|
41
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
if (!api) {
|
|
202
|
-
throw new Error(
|
|
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
|
+
});
|