@telorun/http-server 0.6.1 → 0.8.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 +46 -0
- package/README.md +3 -8
- package/dist/http-api-controller.js +43 -4
- package/dist/http-server-controller.d.ts +1 -0
- package/dist/http-server-controller.js +10 -2
- package/package.json +2 -2
- package/src/http-api-controller.ts +58 -3
- package/src/http-server-controller.ts +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# @telorun/http-server
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 5331205: Add cooperative invoke cancellation via an out-of-band `InvokeContext`.
|
|
8
|
+
|
|
9
|
+
Every `invoke(inputs, ctx?)` now receives a second argument carrying a read-only
|
|
10
|
+
cancellation token (`ctx.cancellation`): poll `isCancelled`, subscribe via
|
|
11
|
+
`onCancelled`, bail with `throwIfCancelled`, or hand its `signal` to a Web API.
|
|
12
|
+
The SDK exposes the source/token split (`createCancellationSource`,
|
|
13
|
+
`CancellationSource`/`CancellationToken`), a never-cancellable sentinel, and the
|
|
14
|
+
`isCancellationError` helper. Deadlines are scheduled cancellation
|
|
15
|
+
(`source.cancelAt(epochMs)` / `cancelAfter(ms)`).
|
|
16
|
+
|
|
17
|
+
The kernel mints one cancellation scope per invocation tree (inherited by nested
|
|
18
|
+
invokes via a kernel-internal `AsyncLocalStorage`, always passed to controllers
|
|
19
|
+
as the explicit argument), refuses a not-yet-dispatched invoke whose tree was
|
|
20
|
+
cancelled with `ERR_INVOKE_CANCELLED`, and emits a scoped `InvokeCancelled`
|
|
21
|
+
event. `Kernel.invoke(ref, inputs, opts?)` accepts `{ signal, deadlineAt }`.
|
|
22
|
+
Sources are allocated lazily, so invokes that never touch cancellation pay no
|
|
23
|
+
extra allocation.
|
|
24
|
+
|
|
25
|
+
The boot `targets` run is also cancellable: `Runnable.run(ctx?)` now receives
|
|
26
|
+
the token, `Kernel.cancel(reason?)` cancels the boot scope, and the CLI's
|
|
27
|
+
SIGINT/SIGTERM handler calls it so Ctrl-C cooperatively stops honoring targets
|
|
28
|
+
and in-flight invoke trees (then unblocks graceful exit via `forceIdle`).
|
|
29
|
+
|
|
30
|
+
Honoring leaves: `Ai.Text` / `Ai.TextStream` / `Ai.Agent` forward the token's
|
|
31
|
+
signal into the model (aborting a live LLM stream on cancel); `http-client`
|
|
32
|
+
merges it with its request timeout. Triggers: `http-server` cancels on client
|
|
33
|
+
disconnect and returns 499; `lambda` arms cancellation at the AWS deadline.
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- @telorun/http-dispatch@0.4.1
|
|
38
|
+
|
|
39
|
+
## 0.7.0
|
|
40
|
+
|
|
41
|
+
### Minor Changes
|
|
42
|
+
|
|
43
|
+
- 030bfdd: Support binary request bodies. An `Http.Server` `contentTypeParsers` entry may declare `stream: true` to deliver bodies of that content type to the handler as a raw `Stream<Uint8Array>` — no buffering, no parsing. A route opts in by marking its `request.schema.body` with `x-telo-stream: true`, which skips AJV on the body and surfaces `request.body` as a stream in handler CEL (member access past it is a static error). A content type on one server is either streamed or parsed, never both.
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- @telorun/http-dispatch@0.4.1
|
|
48
|
+
|
|
3
49
|
## 0.6.1
|
|
4
50
|
|
|
5
51
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -23,16 +23,11 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
|
|
|
23
23
|
```yaml
|
|
24
24
|
kind: Telo.Application
|
|
25
25
|
metadata: { name: hello-http, version: 1.0.0 }
|
|
26
|
+
imports:
|
|
27
|
+
Http: pkg:npm/@telorun/http-server@^1.0.0
|
|
28
|
+
JS: pkg:npm/@telorun/javascript@^1.0.0
|
|
26
29
|
targets: [Server]
|
|
27
30
|
---
|
|
28
|
-
kind: Telo.Import
|
|
29
|
-
metadata: { name: Http }
|
|
30
|
-
source: pkg:npm/@telorun/http-server@^1.0.0
|
|
31
|
-
---
|
|
32
|
-
kind: Telo.Import
|
|
33
|
-
metadata: { name: JS }
|
|
34
|
-
source: pkg:npm/@telorun/javascript@^1.0.0
|
|
35
|
-
---
|
|
36
31
|
kind: Http.Server
|
|
37
32
|
metadata: { name: Server }
|
|
38
33
|
port: 8080
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
2
|
import { CatchEntry, dispatchCatches, dispatchReturns, ReturnEntry, validateNoContentTypeHeader, validateStreamWhenDoesNotReferenceResult, } from "@telorun/http-dispatch";
|
|
3
|
-
import { isInvokeError, Ref, } from "@telorun/sdk";
|
|
3
|
+
import { InvokeError, isCancellationError, isInvokeError, Ref, Stream, } from "@telorun/sdk";
|
|
4
4
|
import { fastifyReplySink } from "./fastify-reply-sink.js";
|
|
5
5
|
const HttpApiRouteManifest = Type.Object({
|
|
6
6
|
request: Type.Object({
|
|
@@ -56,11 +56,15 @@ export class HttpServerApi {
|
|
|
56
56
|
const handlerName = handlerRef?.name ?? "";
|
|
57
57
|
const translatedPath = translateOpenApiPath(route.request.path);
|
|
58
58
|
const schema = { response: {} };
|
|
59
|
+
// A stream-marked body is delivered as a raw `Stream<Uint8Array>` (see the
|
|
60
|
+
// server's `contentTypeParsers[].stream`); it is opaque to AJV, so skip
|
|
61
|
+
// body-schema registration and wrap the raw request stream in the handler.
|
|
62
|
+
const streamBody = route.request.schema?.body?.["x-telo-stream"] === true;
|
|
59
63
|
if (route.request.schema?.query)
|
|
60
64
|
schema.querystring = route.request.schema.query;
|
|
61
65
|
if (route.request.schema?.params)
|
|
62
66
|
schema.params = route.request.schema.params;
|
|
63
|
-
if (route.request.schema?.body)
|
|
67
|
+
if (route.request.schema?.body && !streamBody)
|
|
64
68
|
schema.body = route.request.schema.body;
|
|
65
69
|
if (route.request.schema?.headers)
|
|
66
70
|
schema.headers = route.request.schema.headers;
|
|
@@ -90,7 +94,7 @@ export class HttpServerApi {
|
|
|
90
94
|
params: request.params || {},
|
|
91
95
|
query: request.query || {},
|
|
92
96
|
headers: normalizeHeaders(request.headers),
|
|
93
|
-
body: request.body,
|
|
97
|
+
body: streamBody ? toByteStream(request) : request.body,
|
|
94
98
|
},
|
|
95
99
|
};
|
|
96
100
|
const acceptHeader = request.headers["accept"]?.toString();
|
|
@@ -102,13 +106,25 @@ export class HttpServerApi {
|
|
|
102
106
|
inputs: resolvedInputs,
|
|
103
107
|
};
|
|
104
108
|
const sink = fastifyReplySink(reply);
|
|
109
|
+
// Per-request cancellation: abandon downstream work when the client
|
|
110
|
+
// disconnects before the response is sent.
|
|
111
|
+
const cancellation = this.ctx.createCancellationSource();
|
|
112
|
+
request.raw.on("close", () => {
|
|
113
|
+
if (!reply.sent)
|
|
114
|
+
cancellation.cancel("client-disconnect");
|
|
115
|
+
});
|
|
105
116
|
let result;
|
|
106
117
|
try {
|
|
107
118
|
result = handler
|
|
108
|
-
? await this.ctx.invokeResolved(handlerKind, handlerName, handler, invokeInput)
|
|
119
|
+
? await this.ctx.invokeResolved(handlerKind, handlerName, handler, invokeInput, cancellation.context)
|
|
109
120
|
: undefined;
|
|
110
121
|
}
|
|
111
122
|
catch (err) {
|
|
123
|
+
if (isCancellationError(err)) {
|
|
124
|
+
if (!reply.sent)
|
|
125
|
+
reply.code(499).send();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
112
128
|
if (!isInvokeError(err))
|
|
113
129
|
throw err;
|
|
114
130
|
return dispatchCatches(route.catches, { code: err.code, message: err.message, data: err.data }, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), sink);
|
|
@@ -157,6 +173,29 @@ export async function create(resource, ctx) {
|
|
|
157
173
|
function translateOpenApiPath(openApiPath) {
|
|
158
174
|
return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
|
|
159
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Wraps an incoming request's raw body as a `Stream<Uint8Array>`. Requires a
|
|
178
|
+
* stream content-type parser (`contentTypeParsers[].stream`) for the request's
|
|
179
|
+
* Content-Type — only then is `request.body` the undrained payload stream.
|
|
180
|
+
* Without one, Fastify has already consumed the socket to build a string/object
|
|
181
|
+
* body, so `request.raw` is drained; fail fast with an actionable error rather
|
|
182
|
+
* than yield an empty stream or hang.
|
|
183
|
+
*/
|
|
184
|
+
function toByteStream(request) {
|
|
185
|
+
const body = request.body;
|
|
186
|
+
if (!body || typeof body[Symbol.asyncIterator] !== "function") {
|
|
187
|
+
const contentType = request.headers["content-type"] ?? "(none)";
|
|
188
|
+
throw new InvokeError("ERR_REQUEST_BODY_NOT_STREAMED", `Route declares an x-telo-stream request body, but the body for content-type ` +
|
|
189
|
+
`"${contentType}" arrived parsed, not streamed. Register a raw stream parser on ` +
|
|
190
|
+
`the Http.Server: contentTypeParsers: [{ contentType: "${contentType}", stream: true }].`);
|
|
191
|
+
}
|
|
192
|
+
return new Stream(toUint8Chunks(body));
|
|
193
|
+
}
|
|
194
|
+
async function* toUint8Chunks(source) {
|
|
195
|
+
for await (const chunk of source) {
|
|
196
|
+
yield chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
160
199
|
/**
|
|
161
200
|
* Normalizes all header keys to lowercase as per Telo spec
|
|
162
201
|
*/
|
|
@@ -39,8 +39,16 @@ class HttpServer {
|
|
|
39
39
|
this.setupRoutes();
|
|
40
40
|
}
|
|
41
41
|
async setupPlugins() {
|
|
42
|
-
for (const { contentType, parser } of this.resource.contentTypeParsers ?? []) {
|
|
43
|
-
if (
|
|
42
|
+
for (const { contentType, parser, stream } of this.resource.contentTypeParsers ?? []) {
|
|
43
|
+
if (stream) {
|
|
44
|
+
// Raw passthrough: omit `parseAs` so Fastify hands the handler the
|
|
45
|
+
// undrained request stream. The matching route wraps `request.body`
|
|
46
|
+
// in a `Stream<Uint8Array>`. No buffering, no AJV — see http-api-controller.
|
|
47
|
+
this.app.addContentTypeParser(contentType, (_req, payload, done) => {
|
|
48
|
+
done(null, payload);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
else if (parser) {
|
|
44
52
|
this.app.addContentTypeParser(contentType, { parseAs: "string" }, async (_req, body, done) => {
|
|
45
53
|
try {
|
|
46
54
|
done(null, await parser.invoke({ body }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/http-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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.19.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@telorun/sdk": "*"
|
|
@@ -11,11 +11,14 @@ import {
|
|
|
11
11
|
import {
|
|
12
12
|
ControllerContext,
|
|
13
13
|
Invocable,
|
|
14
|
+
InvokeError,
|
|
15
|
+
isCancellationError,
|
|
14
16
|
isInvokeError,
|
|
15
17
|
KindRef,
|
|
16
18
|
Ref,
|
|
17
19
|
ResourceContext,
|
|
18
20
|
ResourceInstance,
|
|
21
|
+
Stream,
|
|
19
22
|
} from "@telorun/sdk";
|
|
20
23
|
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
21
24
|
import { fastifyReplySink } from "./fastify-reply-sink.js";
|
|
@@ -90,9 +93,14 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
90
93
|
|
|
91
94
|
const schema: any = { response: {} };
|
|
92
95
|
|
|
96
|
+
// A stream-marked body is delivered as a raw `Stream<Uint8Array>` (see the
|
|
97
|
+
// server's `contentTypeParsers[].stream`); it is opaque to AJV, so skip
|
|
98
|
+
// body-schema registration and wrap the raw request stream in the handler.
|
|
99
|
+
const streamBody = route.request.schema?.body?.["x-telo-stream"] === true;
|
|
100
|
+
|
|
93
101
|
if (route.request.schema?.query) schema.querystring = route.request.schema.query;
|
|
94
102
|
if (route.request.schema?.params) schema.params = route.request.schema.params;
|
|
95
|
-
if (route.request.schema?.body) schema.body = route.request.schema.body;
|
|
103
|
+
if (route.request.schema?.body && !streamBody) schema.body = route.request.schema.body;
|
|
96
104
|
if (route.request.schema?.headers) schema.headers = route.request.schema.headers;
|
|
97
105
|
|
|
98
106
|
// Response schemas: register the FIRST content[mime].schema we find for
|
|
@@ -121,7 +129,7 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
121
129
|
params: request.params || {},
|
|
122
130
|
query: request.query || {},
|
|
123
131
|
headers: normalizeHeaders(request.headers),
|
|
124
|
-
body: request.body,
|
|
132
|
+
body: streamBody ? toByteStream(request) : request.body,
|
|
125
133
|
},
|
|
126
134
|
};
|
|
127
135
|
const acceptHeader = (
|
|
@@ -139,12 +147,29 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
139
147
|
|
|
140
148
|
const sink = fastifyReplySink(reply);
|
|
141
149
|
|
|
150
|
+
// Per-request cancellation: abandon downstream work when the client
|
|
151
|
+
// disconnects before the response is sent.
|
|
152
|
+
const cancellation = this.ctx.createCancellationSource();
|
|
153
|
+
request.raw.on("close", () => {
|
|
154
|
+
if (!reply.sent) cancellation.cancel("client-disconnect");
|
|
155
|
+
});
|
|
156
|
+
|
|
142
157
|
let result: unknown;
|
|
143
158
|
try {
|
|
144
159
|
result = handler
|
|
145
|
-
? await this.ctx.invokeResolved(
|
|
160
|
+
? await this.ctx.invokeResolved(
|
|
161
|
+
handlerKind,
|
|
162
|
+
handlerName,
|
|
163
|
+
handler,
|
|
164
|
+
invokeInput,
|
|
165
|
+
cancellation.context,
|
|
166
|
+
)
|
|
146
167
|
: undefined;
|
|
147
168
|
} catch (err) {
|
|
169
|
+
if (isCancellationError(err)) {
|
|
170
|
+
if (!reply.sent) reply.code(499).send();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
148
173
|
if (!isInvokeError(err)) throw err;
|
|
149
174
|
return dispatchCatches(
|
|
150
175
|
route.catches,
|
|
@@ -213,6 +238,36 @@ function translateOpenApiPath(openApiPath: string): string {
|
|
|
213
238
|
return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
|
|
214
239
|
}
|
|
215
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Wraps an incoming request's raw body as a `Stream<Uint8Array>`. Requires a
|
|
243
|
+
* stream content-type parser (`contentTypeParsers[].stream`) for the request's
|
|
244
|
+
* Content-Type — only then is `request.body` the undrained payload stream.
|
|
245
|
+
* Without one, Fastify has already consumed the socket to build a string/object
|
|
246
|
+
* body, so `request.raw` is drained; fail fast with an actionable error rather
|
|
247
|
+
* than yield an empty stream or hang.
|
|
248
|
+
*/
|
|
249
|
+
function toByteStream(request: FastifyRequest): Stream<Uint8Array> {
|
|
250
|
+
const body = request.body as unknown;
|
|
251
|
+
if (!body || typeof (body as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] !== "function") {
|
|
252
|
+
const contentType = (request.headers["content-type"] as string | undefined) ?? "(none)";
|
|
253
|
+
throw new InvokeError(
|
|
254
|
+
"ERR_REQUEST_BODY_NOT_STREAMED",
|
|
255
|
+
`Route declares an x-telo-stream request body, but the body for content-type ` +
|
|
256
|
+
`"${contentType}" arrived parsed, not streamed. Register a raw stream parser on ` +
|
|
257
|
+
`the Http.Server: contentTypeParsers: [{ contentType: "${contentType}", stream: true }].`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return new Stream(toUint8Chunks(body as AsyncIterable<Uint8Array | Buffer>));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function* toUint8Chunks(
|
|
264
|
+
source: AsyncIterable<Uint8Array | Buffer>,
|
|
265
|
+
): AsyncIterable<Uint8Array> {
|
|
266
|
+
for await (const chunk of source) {
|
|
267
|
+
yield chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
216
271
|
/**
|
|
217
272
|
* Normalizes all header keys to lowercase as per Telo spec
|
|
218
273
|
*/
|
|
@@ -41,7 +41,7 @@ type HttpServerResource = RuntimeResource & {
|
|
|
41
41
|
baseUrl?: string;
|
|
42
42
|
logger?: boolean;
|
|
43
43
|
cors?: CorsOptions;
|
|
44
|
-
contentTypeParsers?: Array<{ contentType: string; parser?: Invocable }>;
|
|
44
|
+
contentTypeParsers?: Array<{ contentType: string; parser?: Invocable; stream?: boolean }>;
|
|
45
45
|
openapi?: {
|
|
46
46
|
info: {
|
|
47
47
|
title: string;
|
|
@@ -109,8 +109,15 @@ class HttpServer implements ResourceInstance {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
private async setupPlugins() {
|
|
112
|
-
for (const { contentType, parser } of this.resource.contentTypeParsers ?? []) {
|
|
113
|
-
if (
|
|
112
|
+
for (const { contentType, parser, stream } of this.resource.contentTypeParsers ?? []) {
|
|
113
|
+
if (stream) {
|
|
114
|
+
// Raw passthrough: omit `parseAs` so Fastify hands the handler the
|
|
115
|
+
// undrained request stream. The matching route wraps `request.body`
|
|
116
|
+
// in a `Stream<Uint8Array>`. No buffering, no AJV — see http-api-controller.
|
|
117
|
+
this.app.addContentTypeParser(contentType, (_req, payload, done) => {
|
|
118
|
+
done(null, payload);
|
|
119
|
+
});
|
|
120
|
+
} else if (parser) {
|
|
114
121
|
this.app.addContentTypeParser(
|
|
115
122
|
contentType,
|
|
116
123
|
{ parseAs: "string" },
|