@telorun/http-server 0.6.0 → 0.7.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,26 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ ### Patch Changes
10
+
11
+ - @telorun/http-dispatch@0.4.1
12
+
13
+ ## 0.6.1
14
+
15
+ ### Patch Changes
16
+
17
+ - adc248b: Loosen the `@telorun/sdk` peer dependency range from an exact pin to `*`.
18
+
19
+ The sdk is a host-provided peer (the kernel supplies the single shared instance, so `Stream` and other sdk class identities stay intact for CEL's runtime type-checker). Pinning it via `workspace:*` published as an exact version, which made every sdk release fall out of range and forced a spurious major bump of all peer-dependents. Declaring the peer range as `*` (with a `workspace:*` devDependency to preserve local linking) keeps the single-instance guarantee while preventing the false major-bump cascade.
20
+
21
+ - Updated dependencies [adc248b]
22
+ - @telorun/http-dispatch@0.4.1
23
+
3
24
  ## 0.6.0
4
25
 
5
26
  ### 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, 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();
@@ -157,6 +161,29 @@ export async function create(resource, ctx) {
157
161
  function translateOpenApiPath(openApiPath) {
158
162
  return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
159
163
  }
164
+ /**
165
+ * Wraps an incoming request's raw body as a `Stream<Uint8Array>`. Requires a
166
+ * stream content-type parser (`contentTypeParsers[].stream`) for the request's
167
+ * Content-Type — only then is `request.body` the undrained payload stream.
168
+ * Without one, Fastify has already consumed the socket to build a string/object
169
+ * body, so `request.raw` is drained; fail fast with an actionable error rather
170
+ * than yield an empty stream or hang.
171
+ */
172
+ function toByteStream(request) {
173
+ const body = request.body;
174
+ if (!body || typeof body[Symbol.asyncIterator] !== "function") {
175
+ const contentType = request.headers["content-type"] ?? "(none)";
176
+ throw new InvokeError("ERR_REQUEST_BODY_NOT_STREAMED", `Route declares an x-telo-stream request body, but the body for content-type ` +
177
+ `"${contentType}" arrived parsed, not streamed. Register a raw stream parser on ` +
178
+ `the Http.Server: contentTypeParsers: [{ contentType: "${contentType}", stream: true }].`);
179
+ }
180
+ return new Stream(toUint8Chunks(body));
181
+ }
182
+ async function* toUint8Chunks(source) {
183
+ for await (const chunk of source) {
184
+ yield chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
185
+ }
186
+ }
160
187
  /**
161
188
  * Normalizes all header keys to lowercase as per Telo spec
162
189
  */
@@ -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.0",
3
+ "version": "0.7.0",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -43,15 +43,16 @@
43
43
  "ajv": "^8.17.1",
44
44
  "ajv-formats": "^3.0.1",
45
45
  "fastify": "^5.7.2",
46
- "@telorun/http-dispatch": "0.4.0"
46
+ "@telorun/http-dispatch": "0.4.1"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/node": "^20.0.0",
50
50
  "typescript": "^5.0.0",
51
- "vitest": "^2.1.8"
51
+ "vitest": "^2.1.8",
52
+ "@telorun/sdk": "0.18.0"
52
53
  },
53
54
  "peerDependencies": {
54
- "@telorun/sdk": "0.13.0"
55
+ "@telorun/sdk": "*"
55
56
  },
56
57
  "scripts": {
57
58
  "build": "tsc -p tsconfig.lib.json",
@@ -11,11 +11,13 @@ import {
11
11
  import {
12
12
  ControllerContext,
13
13
  Invocable,
14
+ InvokeError,
14
15
  isInvokeError,
15
16
  KindRef,
16
17
  Ref,
17
18
  ResourceContext,
18
19
  ResourceInstance,
20
+ Stream,
19
21
  } from "@telorun/sdk";
20
22
  import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
21
23
  import { fastifyReplySink } from "./fastify-reply-sink.js";
@@ -90,9 +92,14 @@ export class HttpServerApi implements ResourceInstance {
90
92
 
91
93
  const schema: any = { response: {} };
92
94
 
95
+ // A stream-marked body is delivered as a raw `Stream<Uint8Array>` (see the
96
+ // server's `contentTypeParsers[].stream`); it is opaque to AJV, so skip
97
+ // body-schema registration and wrap the raw request stream in the handler.
98
+ const streamBody = route.request.schema?.body?.["x-telo-stream"] === true;
99
+
93
100
  if (route.request.schema?.query) schema.querystring = route.request.schema.query;
94
101
  if (route.request.schema?.params) schema.params = route.request.schema.params;
95
- if (route.request.schema?.body) schema.body = route.request.schema.body;
102
+ if (route.request.schema?.body && !streamBody) schema.body = route.request.schema.body;
96
103
  if (route.request.schema?.headers) schema.headers = route.request.schema.headers;
97
104
 
98
105
  // Response schemas: register the FIRST content[mime].schema we find for
@@ -121,7 +128,7 @@ export class HttpServerApi implements ResourceInstance {
121
128
  params: request.params || {},
122
129
  query: request.query || {},
123
130
  headers: normalizeHeaders(request.headers),
124
- body: request.body,
131
+ body: streamBody ? toByteStream(request) : request.body,
125
132
  },
126
133
  };
127
134
  const acceptHeader = (
@@ -213,6 +220,36 @@ function translateOpenApiPath(openApiPath: string): string {
213
220
  return openApiPath.replace(/{([a-zA-Z_][a-zA-Z0-9_]*)}/g, ":$1");
214
221
  }
215
222
 
223
+ /**
224
+ * Wraps an incoming request's raw body as a `Stream<Uint8Array>`. Requires a
225
+ * stream content-type parser (`contentTypeParsers[].stream`) for the request's
226
+ * Content-Type — only then is `request.body` the undrained payload stream.
227
+ * Without one, Fastify has already consumed the socket to build a string/object
228
+ * body, so `request.raw` is drained; fail fast with an actionable error rather
229
+ * than yield an empty stream or hang.
230
+ */
231
+ function toByteStream(request: FastifyRequest): Stream<Uint8Array> {
232
+ const body = request.body as unknown;
233
+ if (!body || typeof (body as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] !== "function") {
234
+ const contentType = (request.headers["content-type"] as string | undefined) ?? "(none)";
235
+ throw new InvokeError(
236
+ "ERR_REQUEST_BODY_NOT_STREAMED",
237
+ `Route declares an x-telo-stream request body, but the body for content-type ` +
238
+ `"${contentType}" arrived parsed, not streamed. Register a raw stream parser on ` +
239
+ `the Http.Server: contentTypeParsers: [{ contentType: "${contentType}", stream: true }].`,
240
+ );
241
+ }
242
+ return new Stream(toUint8Chunks(body as AsyncIterable<Uint8Array | Buffer>));
243
+ }
244
+
245
+ async function* toUint8Chunks(
246
+ source: AsyncIterable<Uint8Array | Buffer>,
247
+ ): AsyncIterable<Uint8Array> {
248
+ for await (const chunk of source) {
249
+ yield chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
250
+ }
251
+ }
252
+
216
253
  /**
217
254
  * Normalizes all header keys to lowercase as per Telo spec
218
255
  */
@@ -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" },