@telorun/http-server 0.6.1 → 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 +10 -0
- package/README.md +3 -8
- package/dist/http-api-controller.js +30 -3
- 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 +39 -2
- package/src/http-server-controller.ts +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 0.6.1
|
|
4
14
|
|
|
5
15
|
### 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
|
*/
|
|
@@ -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.7.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.18.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@telorun/sdk": "*"
|
|
@@ -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 (
|
|
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" },
|