@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 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
  */
@@ -23,6 +23,7 @@ type HttpServerResource = RuntimeResource & {
23
23
  contentTypeParsers?: Array<{
24
24
  contentType: string;
25
25
  parser?: Invocable;
26
+ stream?: boolean;
26
27
  }>;
27
28
  openapi?: {
28
29
  info: {
@@ -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 (parser) {
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.6.1",
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.16.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(handlerKind, handlerName, handler, invokeInput)
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 (parser) {
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" },