@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 +22 -0
- package/README.md +21 -1
- package/dist/http-api-controller.js +6 -2
- 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-api-controller.ts +6 -2
- package/src/http-server-controller.ts +17 -17
- package/tests/request-cancel.test.ts +98 -0
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
|
-
|
|
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
|
-
|
|
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?:
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
if (!api) {
|
|
202
|
-
throw new Error(
|
|
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
|
+
});
|