ts-typed-api 0.2.22 → 0.2.24
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/.kilo/plans/1779977869703-shiny-squid.md +49 -0
- package/.kilo/plans/1784112824796-stream-bytes-response.md +198 -0
- package/dist/definition.d.ts +1 -0
- package/dist/handler.js +34 -1
- package/dist/hono-cloudflare-workers.js +24 -0
- package/dist/router.d.ts +5 -0
- package/examples/simple/definitions.ts +25 -0
- package/package.json +1 -1
- package/src/definition.ts +1 -0
- package/src/handler.ts +38 -1
- package/src/hono-cloudflare-workers.ts +27 -0
- package/src/router.ts +6 -0
- package/tests/setup.ts +19 -1
- package/tests/simple-api.test.ts +79 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Fix PATCH/DELETE Body Parsing + Add Tests
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
In `src/handler.ts:400`, the Express handler only validates body for `POST` and `PUT`. `PATCH` and `DELETE` bypass Zod validation entirely, receiving raw `expressReq.body`. This is inconsistent with:
|
|
5
|
+
- The Hono handler (`src/hono-cloudflare-workers.ts:377`) which validates all four methods
|
|
6
|
+
- The type system (`MethodsWithBody = 'POST' | 'PUT' | 'DELETE' | 'PATCH'` in `definition.ts`)
|
|
7
|
+
|
|
8
|
+
## Plan
|
|
9
|
+
|
|
10
|
+
### 1. Fix `src/handler.ts` — line 400
|
|
11
|
+
Change:
|
|
12
|
+
```typescript
|
|
13
|
+
const parsedBody = (method === 'POST' || method === 'PUT') && ('body' in routeDefinition && routeDefinition.body)
|
|
14
|
+
```
|
|
15
|
+
To:
|
|
16
|
+
```typescript
|
|
17
|
+
const parsedBody = (method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH') && ('body' in routeDefinition && routeDefinition.body)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### 2. Add PATCH endpoint to test setup (`tests/setup.ts`)
|
|
21
|
+
Add to `SimplePublicApiDefinition` (in `examples/simple/definitions.ts`) a new `patch` route with a body schema that validates fields:
|
|
22
|
+
```typescript
|
|
23
|
+
patch: {
|
|
24
|
+
method: 'PATCH',
|
|
25
|
+
path: '/patch',
|
|
26
|
+
body: z.object({
|
|
27
|
+
name: z.string().min(1),
|
|
28
|
+
value: z.number().min(0)
|
|
29
|
+
}),
|
|
30
|
+
responses: CreateResponses({
|
|
31
|
+
200: z.object({ message: z.string(), name: z.string(), value: z.number() })
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Add a corresponding handler in `simplePublicHandlers` in `tests/setup.ts` that returns the parsed body data.
|
|
37
|
+
|
|
38
|
+
### 3. Add tests (`tests/simple-api.test.ts`)
|
|
39
|
+
Add a `describe('PATCH body validation')` block inside the existing `describe('Public API')`:
|
|
40
|
+
- **Success case**: Send valid body, expect 200 with echoed data
|
|
41
|
+
- **Validation failure case**: Send body with missing fields or wrong types, expect 422 with error details
|
|
42
|
+
- **Extra property rejection**: Send body with an unknown property, expect 422 (strict mode)
|
|
43
|
+
|
|
44
|
+
These tests will run against both Express and Hono via the existing `describe.each(['Express', SIMPLE_PORT], ['Hono', HONO_PORT])`.
|
|
45
|
+
|
|
46
|
+
### 4. Verify
|
|
47
|
+
Run `npm test` to confirm:
|
|
48
|
+
- Existing tests still pass
|
|
49
|
+
- New PATCH validation tests pass for both Express and Hono
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Plan: Add byte-stream piping to `TypedResponse`
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
Add a `res.pipeStream(readable, options)` method to `TypedResponse` that pipes a `Readable` (Node.js) or `ReadableStream` (Web) to the client without buffering in memory. Add a `streaming?: boolean` declaration option to route definitions.
|
|
5
|
+
|
|
6
|
+
## Affected files
|
|
7
|
+
- `src/definition.ts` — add `streaming?: boolean` to route types
|
|
8
|
+
- `src/router.ts` — add `pipeStream` signature to `TypedResponse`
|
|
9
|
+
- `src/handler.ts` — implement `pipeStream` for Express
|
|
10
|
+
- `src/hono-cloudflare-workers.ts` — implement `pipeStream` for Hono
|
|
11
|
+
- `tests/setup.ts` — add a test route using `pipeStream`
|
|
12
|
+
- `tests/simple-api.test.ts` — add streaming tests
|
|
13
|
+
- `examples/simple/definitions.ts` — optionally add a streaming route
|
|
14
|
+
|
|
15
|
+
## Task list
|
|
16
|
+
|
|
17
|
+
### 1. Add `streaming?: boolean` to route definition types (`src/definition.ts`)
|
|
18
|
+
|
|
19
|
+
Add `streaming?: boolean` to both `RouteWithoutBody` (~line 182) and `RouteWithBody` (~line 193). Since `RouteSchema` is a union intersection, add it in the intersection:
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
export type RouteSchema = (RouteWithoutBody | RouteWithBody) & {
|
|
23
|
+
description?: string;
|
|
24
|
+
streaming?: boolean; // <-- add this
|
|
25
|
+
};
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 2. Add `pipeStream` to `TypedResponse` interface (`src/router.ts`)
|
|
29
|
+
|
|
30
|
+
Add the method signature to `TypedResponse`:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
pipeStream: (
|
|
34
|
+
readable: NodeJS.ReadableStream | ReadableStream<Uint8Array>,
|
|
35
|
+
options?: { status?: number; contentType?: string; filename?: string }
|
|
36
|
+
) => void;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Since `TypedResponse` extends `express.Response`, this signature is shared by both Express and Hono implementations.
|
|
40
|
+
|
|
41
|
+
Also add a `PipeStreamFunction` type alias for reuse across backends.
|
|
42
|
+
|
|
43
|
+
### 3. Implement `pipeStream` for Express (`src/handler.ts`)
|
|
44
|
+
|
|
45
|
+
In `registerRouteHandlers`, after the `respondContentType` and `setHeader` assignments (~after line 508), add:
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
typedExpressRes.pipeStream = (readable, options) => {
|
|
49
|
+
const status = options?.status ?? 200;
|
|
50
|
+
const contentType = options?.contentType ?? 'application/octet-stream';
|
|
51
|
+
|
|
52
|
+
typedExpressRes.status(status);
|
|
53
|
+
Object.getPrototypeOf(expressRes).setHeader.call(expressRes, 'Content-Type', contentType);
|
|
54
|
+
if (options?.filename) {
|
|
55
|
+
Object.getPrototypeOf(expressRes).setHeader.call(
|
|
56
|
+
expressRes, 'Content-Disposition',
|
|
57
|
+
`attachment; filename="${encodeURIComponent(options.filename)}"`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (Symbol.asyncIterator in Object(readable)) {
|
|
62
|
+
// Node.js Readable — pipe directly
|
|
63
|
+
(readable as NodeJS.ReadableStream).pipe(expressRes);
|
|
64
|
+
} else {
|
|
65
|
+
// Web ReadableStream — convert via reader
|
|
66
|
+
const reader = (readable as ReadableStream<Uint8Array>).getReader();
|
|
67
|
+
const nodeReadable = new (require('stream').Readable)({
|
|
68
|
+
async read() {
|
|
69
|
+
try {
|
|
70
|
+
const { done, value } = await reader.read();
|
|
71
|
+
if (done) {
|
|
72
|
+
this.push(null);
|
|
73
|
+
} else {
|
|
74
|
+
this.push(Buffer.from(value));
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
this.destroy(err as Error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
nodeReadable.pipe(expressRes);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For Hono in `src/hono-cloudflare-workers.ts`, on the fakeRes object (~after line 502):
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
pipeStream: (readable, options) => {
|
|
90
|
+
const status = options?.status ?? 200;
|
|
91
|
+
const contentType = options?.contentType ?? 'application/octet-stream';
|
|
92
|
+
const filename = options?.filename;
|
|
93
|
+
|
|
94
|
+
const headers = new Headers({ 'Content-Type': contentType });
|
|
95
|
+
if (filename) {
|
|
96
|
+
headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let webStream: ReadableStream<Uint8Array>;
|
|
100
|
+
if (Symbol.asyncIterator in Object(readable)) {
|
|
101
|
+
// Node.js Readable -> convert to ReadableStream
|
|
102
|
+
// Pipe through a simple bridge
|
|
103
|
+
const { pipeline } = require('stream/promises');
|
|
104
|
+
const { PassThrough } = require('stream');
|
|
105
|
+
const pass = new PassThrough();
|
|
106
|
+
(readable as NodeJS.ReadableStream).pipe(pass);
|
|
107
|
+
webStream = new ReadableStream({
|
|
108
|
+
async pull(controller) {
|
|
109
|
+
const chunk = pass.read();
|
|
110
|
+
if (chunk === null) {
|
|
111
|
+
await new Promise(r => pass.once('readable', r));
|
|
112
|
+
const next = pass.read();
|
|
113
|
+
if (next) controller.enqueue(Buffer.from(next));
|
|
114
|
+
} else {
|
|
115
|
+
controller.enqueue(Buffer.from(chunk));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
webStream = readable as ReadableStream<Uint8Array>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
(c as any).__response = new Response(webStream, { status, headers });
|
|
124
|
+
};
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Simplify the Hono Web ReadableStream conversion — just use an approach that wraps Readable into ReadableStream:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// Simpler: Use Readable.toWeb() if available (Node 18+ / 16 with flag)
|
|
131
|
+
let webStream: ReadableStream<Uint8Array>;
|
|
132
|
+
if ('toWeb' in (require('stream').Readable) && Symbol.asyncIterator in Object(readable)) {
|
|
133
|
+
webStream = (require('stream').Readable as any).toWeb(readable) as ReadableStream<Uint8Array>;
|
|
134
|
+
} else if (Symbol.asyncIterator in Object(readable)) {
|
|
135
|
+
// Fallback conversion
|
|
136
|
+
const nodeReadable = readable as NodeJS.ReadableStream;
|
|
137
|
+
webStream = new ReadableStream({
|
|
138
|
+
start(controller) {
|
|
139
|
+
nodeReadable.on('data', chunk => controller.enqueue(typeof chunk === 'string' ? Buffer.from(chunk) : chunk));
|
|
140
|
+
nodeReadable.on('end', () => controller.close());
|
|
141
|
+
nodeReadable.on('error', err => controller.error(err));
|
|
142
|
+
},
|
|
143
|
+
cancel() { nodeReadable.destroy(); }
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
webStream = readable as ReadableStream<Uint8Array>;
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
For the middleware-wrapped `respond` in `createRespondFunction` (~line 142), we should also add `pipeStream` to the `MiddlewareResponse` interface so middleware can use it, but that's lower priority.
|
|
151
|
+
|
|
152
|
+
### 4. Add test coverage (`tests/setup.ts` + `tests/simple-api.test.ts`)
|
|
153
|
+
|
|
154
|
+
Add a `streamBytes` route to the simple API definition (in `examples/simple/definitions.ts`):
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
streamBytes: {
|
|
158
|
+
method: 'GET',
|
|
159
|
+
path: '/stream-bytes',
|
|
160
|
+
description: 'Binary streaming endpoint',
|
|
161
|
+
streaming: true,
|
|
162
|
+
responses: CreateResponses({
|
|
163
|
+
200: z.string() // raw status marker; validation skipped for streaming
|
|
164
|
+
})
|
|
165
|
+
},
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Add a handler that creates a readable stream and pipes it:
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
streamBytes: async (req: any, res: any) => {
|
|
172
|
+
const { Readable } = require('stream');
|
|
173
|
+
const readable = Readable.from([
|
|
174
|
+
Buffer.from('chunk1'),
|
|
175
|
+
Buffer.from('chunk2'),
|
|
176
|
+
Buffer.from('chunk3')
|
|
177
|
+
]);
|
|
178
|
+
res.pipeStream(readable, { contentType: 'application/octet-stream', filename: 'test.bin' });
|
|
179
|
+
},
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Add tests that verify:
|
|
183
|
+
- Response status is correct
|
|
184
|
+
- Content-Type / Content-Disposition headers are set
|
|
185
|
+
- Full body matches the expected byte content
|
|
186
|
+
- If a `ReadableStream` (Web) is passed, it's correctly converted
|
|
187
|
+
|
|
188
|
+
### 5. Update exports if needed
|
|
189
|
+
|
|
190
|
+
Verify `pipeStream` is accessible through `TypedResponse` — no new exports needed since it's on the existing `TypedResponse` interface already exported from `router.ts`.
|
|
191
|
+
|
|
192
|
+
## Type considerations
|
|
193
|
+
- `pipeStream` uses a permissive `readable` parameter that accepts both `NodeJS.ReadableStream` and `ReadableStream<Uint8Array>`. We can detect at runtime which one was passed via `Symbol.asyncIterator`.
|
|
194
|
+
- `streaming?: boolean` on `RouteSchema` is purely declarative — the type system allows it on any route, and at runtime it's just metadata (used by OpenAPI generation, client detection, etc.).
|
|
195
|
+
|
|
196
|
+
## Risks
|
|
197
|
+
- Node 16 compatibility: `Readable.toWeb()` is experimental in Node 16 but available with flag. The fallback manual conversion avoids this dependency. At runtime we detect and use the fallback.
|
|
198
|
+
- Error handling: If the readable stream errors mid-pipe, Express closes the connection. We should `destroy()` the stream on Hono cancel.
|
package/dist/definition.d.ts
CHANGED
|
@@ -95,6 +95,7 @@ type RouteWithBody = {
|
|
|
95
95
|
};
|
|
96
96
|
export type RouteSchema = (RouteWithoutBody | RouteWithBody) & {
|
|
97
97
|
description?: string;
|
|
98
|
+
streaming?: boolean;
|
|
98
99
|
};
|
|
99
100
|
export type ApiDefinitionSchema<TEndpoints extends Record<string, Record<string, RouteSchema>> = Record<string, Record<string, RouteSchema>>> = {
|
|
100
101
|
prefix?: string;
|
package/dist/handler.js
CHANGED
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.registerRouteHandlers = registerRouteHandlers;
|
|
7
7
|
const zod_1 = require("zod");
|
|
8
|
+
const stream_1 = require("stream");
|
|
8
9
|
const multer_1 = __importDefault(require("multer"));
|
|
9
10
|
// Helper function to preprocess parameters for type coercion
|
|
10
11
|
function preprocessParams(params, paramsSchema) {
|
|
@@ -339,7 +340,7 @@ middlewares, errorHandler) {
|
|
|
339
340
|
const parsedQuery = ('query' in routeDefinition && routeDefinition.query)
|
|
340
341
|
? routeDefinition.query.parse(preprocessedQuery)
|
|
341
342
|
: preprocessedQuery;
|
|
342
|
-
const parsedBody = (method === 'POST' || method === 'PUT') && ('body' in routeDefinition && routeDefinition.body)
|
|
343
|
+
const parsedBody = (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE') && ('body' in routeDefinition && routeDefinition.body)
|
|
343
344
|
? routeDefinition.body.parse(expressReq.body)
|
|
344
345
|
: expressReq.body;
|
|
345
346
|
// Construct TypedRequest using TDef, currentDomain, currentRouteKey
|
|
@@ -441,6 +442,38 @@ middlewares, errorHandler) {
|
|
|
441
442
|
Object.getPrototypeOf(expressRes).setHeader.call(expressRes, name, value);
|
|
442
443
|
return typedExpressRes;
|
|
443
444
|
};
|
|
445
|
+
typedExpressRes.pipeStream = (readable, options) => {
|
|
446
|
+
const status = options?.status ?? 200;
|
|
447
|
+
const contentType = options?.contentType ?? 'application/octet-stream';
|
|
448
|
+
typedExpressRes.status(status);
|
|
449
|
+
Object.getPrototypeOf(expressRes).setHeader.call(expressRes, 'Content-Type', contentType);
|
|
450
|
+
if (options?.filename) {
|
|
451
|
+
Object.getPrototypeOf(expressRes).setHeader.call(expressRes, 'Content-Disposition', `attachment; filename="${encodeURIComponent(options.filename)}"`);
|
|
452
|
+
}
|
|
453
|
+
if (Symbol.asyncIterator in Object(readable)) {
|
|
454
|
+
readable.pipe(expressRes);
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
const reader = readable.getReader();
|
|
458
|
+
const nodeReadable = new stream_1.Readable({
|
|
459
|
+
async read() {
|
|
460
|
+
try {
|
|
461
|
+
const { done, value } = await reader.read();
|
|
462
|
+
if (done) {
|
|
463
|
+
this.push(null);
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
this.push(Buffer.from(value));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
this.destroy(err);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
nodeReadable.pipe(expressRes);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
444
477
|
// SSE streaming methods
|
|
445
478
|
typedExpressRes.startSSE = () => {
|
|
446
479
|
typedExpressRes.setHeader('Content-Type', 'text/event-stream');
|
|
@@ -406,6 +406,30 @@ function registerHonoRouteHandlers(app, apiDefinition, routeHandlers, middleware
|
|
|
406
406
|
c.header(name, value);
|
|
407
407
|
return fakeRes;
|
|
408
408
|
},
|
|
409
|
+
pipeStream: (readable, options) => {
|
|
410
|
+
const status = options?.status ?? 200;
|
|
411
|
+
const contentType = options?.contentType ?? 'application/octet-stream';
|
|
412
|
+
const headers = new Headers({ 'Content-Type': contentType });
|
|
413
|
+
if (options?.filename) {
|
|
414
|
+
headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(options.filename)}"`);
|
|
415
|
+
}
|
|
416
|
+
let webStream;
|
|
417
|
+
if (Symbol.asyncIterator in Object(readable)) {
|
|
418
|
+
const nodeReadable = readable;
|
|
419
|
+
webStream = new ReadableStream({
|
|
420
|
+
start(controller) {
|
|
421
|
+
nodeReadable.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)));
|
|
422
|
+
nodeReadable.on('end', () => controller.close());
|
|
423
|
+
nodeReadable.on('error', (err) => controller.error(err));
|
|
424
|
+
},
|
|
425
|
+
cancel() { nodeReadable.destroy(); }
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
webStream = readable;
|
|
430
|
+
}
|
|
431
|
+
c.__response = new Response(webStream, { status, headers });
|
|
432
|
+
},
|
|
409
433
|
// SSE streaming methods for Hono
|
|
410
434
|
startSSE: () => {
|
|
411
435
|
c.header('Content-Type', 'text/event-stream');
|
package/dist/router.d.ts
CHANGED
|
@@ -21,6 +21,11 @@ export interface TypedResponse<TDef extends ApiDefinitionSchema, TDomain extends
|
|
|
21
21
|
startSSE: () => void;
|
|
22
22
|
streamSSE: (eventName?: string, data?: any, id?: string) => void;
|
|
23
23
|
endStream: () => void;
|
|
24
|
+
pipeStream: (readable: NodeJS.ReadableStream | ReadableStream<Uint8Array>, options?: {
|
|
25
|
+
status?: number;
|
|
26
|
+
contentType?: string;
|
|
27
|
+
filename?: string;
|
|
28
|
+
}) => void;
|
|
24
29
|
}
|
|
25
30
|
export declare function createRouteHandler<TDef extends ApiDefinitionSchema, TDomain extends keyof TDef['endpoints'], TRouteKey extends keyof TDef['endpoints'][TDomain], // Using direct keyof for simplicity
|
|
26
31
|
Ctx extends Record<string, any> = Record<string, any>>(domain: TDomain, routeKey: TRouteKey, handler: (req: TypedRequest<TDef, TDomain, TRouteKey, ApiParams<TDef, TDomain, TRouteKey>, ApiBody<TDef, TDomain, TRouteKey>, ApiQuery<TDef, TDomain, TRouteKey>, Record<string, any>, Ctx>, res: TypedResponse<TDef, TDomain, TRouteKey>) => Promise<void> | void): {
|
|
@@ -77,6 +77,31 @@ export const PublicApiDefinition = CreateApiDefinition({
|
|
|
77
77
|
200: z.string() // Raw SSE data
|
|
78
78
|
})
|
|
79
79
|
},
|
|
80
|
+
streamBytes: {
|
|
81
|
+
method: 'GET',
|
|
82
|
+
path: '/stream-bytes',
|
|
83
|
+
description: 'Binary byte streaming endpoint',
|
|
84
|
+
streaming: true,
|
|
85
|
+
responses: CreateResponses({
|
|
86
|
+
200: z.any()
|
|
87
|
+
})
|
|
88
|
+
},
|
|
89
|
+
patchResource: {
|
|
90
|
+
method: 'PATCH',
|
|
91
|
+
path: '/patch-resource',
|
|
92
|
+
description: 'PATCH endpoint to test body validation for PATCH requests',
|
|
93
|
+
body: z.object({
|
|
94
|
+
name: z.string().min(1, 'Name is required'),
|
|
95
|
+
value: z.number().int().min(0, 'Value must be non-negative')
|
|
96
|
+
}),
|
|
97
|
+
responses: CreateResponses({
|
|
98
|
+
200: z.object({
|
|
99
|
+
message: z.string(),
|
|
100
|
+
name: z.string(),
|
|
101
|
+
value: z.number()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
},
|
|
80
105
|
disconnectTest: {
|
|
81
106
|
method: 'GET',
|
|
82
107
|
path: '/disconnect-test',
|
package/package.json
CHANGED
package/src/definition.ts
CHANGED
|
@@ -196,6 +196,7 @@ type RouteWithBody = {
|
|
|
196
196
|
// Union type for all route schemas
|
|
197
197
|
export type RouteSchema = (RouteWithoutBody | RouteWithBody) & {
|
|
198
198
|
description?: string;
|
|
199
|
+
streaming?: boolean;
|
|
199
200
|
};
|
|
200
201
|
|
|
201
202
|
// Define the structure for the entire API definition object
|
package/src/handler.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { Readable } from "stream";
|
|
2
3
|
import { ApiDefinitionSchema, RouteSchema, UnifiedError, FileUploadConfig, ErrorHandler } from "./definition";
|
|
3
4
|
import { createRouteHandler, TypedRequest, TypedResponse } from "./router";
|
|
4
5
|
import { MiddlewareResponse } from "./object-handlers";
|
|
@@ -397,7 +398,7 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
|
|
|
397
398
|
? (routeDefinition.query as z.ZodTypeAny).parse(preprocessedQuery)
|
|
398
399
|
: preprocessedQuery;
|
|
399
400
|
|
|
400
|
-
const parsedBody = (method === 'POST' || method === 'PUT') && ('body' in routeDefinition && routeDefinition.body)
|
|
401
|
+
const parsedBody = (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE') && ('body' in routeDefinition && routeDefinition.body)
|
|
401
402
|
? (routeDefinition.body as z.ZodTypeAny).parse(expressReq.body)
|
|
402
403
|
: expressReq.body;
|
|
403
404
|
|
|
@@ -513,6 +514,42 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
|
|
|
513
514
|
return typedExpressRes;
|
|
514
515
|
};
|
|
515
516
|
|
|
517
|
+
typedExpressRes.pipeStream = (readable, options) => {
|
|
518
|
+
const status = options?.status ?? 200;
|
|
519
|
+
const contentType = options?.contentType ?? 'application/octet-stream';
|
|
520
|
+
|
|
521
|
+
typedExpressRes.status(status);
|
|
522
|
+
Object.getPrototypeOf(expressRes).setHeader.call(expressRes, 'Content-Type', contentType);
|
|
523
|
+
if (options?.filename) {
|
|
524
|
+
Object.getPrototypeOf(expressRes).setHeader.call(
|
|
525
|
+
expressRes,
|
|
526
|
+
'Content-Disposition',
|
|
527
|
+
`attachment; filename="${encodeURIComponent(options.filename)}"`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (Symbol.asyncIterator in Object(readable)) {
|
|
532
|
+
(readable as NodeJS.ReadableStream).pipe(expressRes);
|
|
533
|
+
} else {
|
|
534
|
+
const reader = (readable as ReadableStream<Uint8Array>).getReader();
|
|
535
|
+
const nodeReadable = new Readable({
|
|
536
|
+
async read() {
|
|
537
|
+
try {
|
|
538
|
+
const { done, value } = await reader.read();
|
|
539
|
+
if (done) {
|
|
540
|
+
this.push(null);
|
|
541
|
+
} else {
|
|
542
|
+
this.push(Buffer.from(value));
|
|
543
|
+
}
|
|
544
|
+
} catch (err) {
|
|
545
|
+
this.destroy(err as Error);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
nodeReadable.pipe(expressRes);
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
516
553
|
// SSE streaming methods
|
|
517
554
|
typedExpressRes.startSSE = () => {
|
|
518
555
|
typedExpressRes.setHeader('Content-Type', 'text/event-stream');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Hono, Context, MiddlewareHandler, Env } from 'hono';
|
|
2
|
+
import { Readable } from 'stream';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { ApiDefinitionSchema, RouteSchema, UnifiedError, FileUploadConfig } from './definition';
|
|
4
5
|
import { TypedRequest, TypedResponse } from './router';
|
|
@@ -489,6 +490,32 @@ export function registerHonoRouteHandlers<
|
|
|
489
490
|
c.header(name, value);
|
|
490
491
|
return fakeRes;
|
|
491
492
|
},
|
|
493
|
+
pipeStream: (readable, options) => {
|
|
494
|
+
const status = options?.status ?? 200;
|
|
495
|
+
const contentType = options?.contentType ?? 'application/octet-stream';
|
|
496
|
+
|
|
497
|
+
const headers = new Headers({ 'Content-Type': contentType });
|
|
498
|
+
if (options?.filename) {
|
|
499
|
+
headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(options.filename)}"`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
let webStream: ReadableStream<Uint8Array>;
|
|
503
|
+
if (Symbol.asyncIterator in Object(readable)) {
|
|
504
|
+
const nodeReadable = readable as Readable;
|
|
505
|
+
webStream = new ReadableStream<Uint8Array>({
|
|
506
|
+
start(controller) {
|
|
507
|
+
nodeReadable.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
|
|
508
|
+
nodeReadable.on('end', () => controller.close());
|
|
509
|
+
nodeReadable.on('error', (err) => controller.error(err));
|
|
510
|
+
},
|
|
511
|
+
cancel() { nodeReadable.destroy(); }
|
|
512
|
+
});
|
|
513
|
+
} else {
|
|
514
|
+
webStream = readable as ReadableStream<Uint8Array>;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
(c as any).__response = new Response(webStream, { status, headers });
|
|
518
|
+
},
|
|
492
519
|
// SSE streaming methods for Hono
|
|
493
520
|
startSSE: () => {
|
|
494
521
|
c.header('Content-Type', 'text/event-stream');
|
package/src/router.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Readable } from 'stream';
|
|
1
2
|
import express from "express";
|
|
2
3
|
import {
|
|
3
4
|
ApiDefinitionSchema, // Changed from ApiDefinition
|
|
@@ -68,6 +69,11 @@ export interface TypedResponse<
|
|
|
68
69
|
startSSE: () => void;
|
|
69
70
|
streamSSE: (eventName?: string, data?: any, id?: string) => void;
|
|
70
71
|
endStream: () => void;
|
|
72
|
+
// Binary/byte stream piping
|
|
73
|
+
pipeStream: (
|
|
74
|
+
readable: NodeJS.ReadableStream | ReadableStream<Uint8Array>,
|
|
75
|
+
options?: { status?: number; contentType?: string; filename?: string }
|
|
76
|
+
) => void;
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
// Type-safe route handler creation function, now generic over TDef and Ctx
|
package/tests/setup.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { beforeAll, afterAll } from '@jest/globals';
|
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { Server } from 'http';
|
|
4
4
|
import http from 'http';
|
|
5
|
+
import { Readable } from 'stream';
|
|
5
6
|
import { Hono } from 'hono';
|
|
6
7
|
import { PublicApiDefinition as SimplePublicApiDefinition, PrivateApiDefinition as SimplePrivateApiDefinition } from '../examples/simple/definitions';
|
|
7
8
|
import { PublicApiDefinition as AdvancedPublicApiDefinition, PrivateApiDefinition as AdvancedPrivateApiDefinition } from '../examples/advanced/definitions';
|
|
@@ -51,6 +52,19 @@ const simplePublicHandlers = {
|
|
|
51
52
|
// Close the stream
|
|
52
53
|
res.endStream();
|
|
53
54
|
},
|
|
55
|
+
streamBytes: async (req: any, res: any) => {
|
|
56
|
+
const chunks = [
|
|
57
|
+
Buffer.from('chunk1'),
|
|
58
|
+
Buffer.from('chunk2'),
|
|
59
|
+
Buffer.from('chunk3')
|
|
60
|
+
];
|
|
61
|
+
const readable = Readable.from(chunks);
|
|
62
|
+
res.pipeStream(readable, {
|
|
63
|
+
contentType: 'application/octet-stream',
|
|
64
|
+
filename: 'test.bin',
|
|
65
|
+
status: 200
|
|
66
|
+
});
|
|
67
|
+
},
|
|
54
68
|
disconnectTest: async (req: any, res: any) => {
|
|
55
69
|
const delay = req.query?.delay || 1000;
|
|
56
70
|
let disconnected = false;
|
|
@@ -69,7 +83,11 @@ const simplePublicHandlers = {
|
|
|
69
83
|
message: disconnected ? 'Client disconnected' : 'Operation completed',
|
|
70
84
|
disconnected
|
|
71
85
|
});
|
|
72
|
-
}
|
|
86
|
+
},
|
|
87
|
+
patchResource: async (req: any, res: any) => {
|
|
88
|
+
const { name, value } = req.body;
|
|
89
|
+
res.respond(200, { message: 'Patched', name, value });
|
|
90
|
+
},
|
|
73
91
|
},
|
|
74
92
|
status: {
|
|
75
93
|
probe1: async (req: any, res: any) => {
|
package/tests/simple-api.test.ts
CHANGED
|
@@ -251,6 +251,16 @@ describe.each([
|
|
|
251
251
|
return data ? JSON.parse(data) : null;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
test('should handle binary streaming with pipeStream', async () => {
|
|
255
|
+
const response = await fetch(`${baseUrl}/api/v1/public/stream-bytes`);
|
|
256
|
+
expect(response.status).toBe(200);
|
|
257
|
+
expect(response.headers.get('content-type')).toBe('application/octet-stream');
|
|
258
|
+
expect(response.headers.get('content-disposition')).toBe('attachment; filename="test.bin"');
|
|
259
|
+
|
|
260
|
+
const body = await response.text();
|
|
261
|
+
expect(body).toBe('chunk1chunk2chunk3');
|
|
262
|
+
});
|
|
263
|
+
|
|
254
264
|
test('should handle client disconnection with req.onClose', async () => {
|
|
255
265
|
// Test that the endpoint works normally when client doesn't disconnect
|
|
256
266
|
const result = await client.callApi('common', 'disconnectTest', {
|
|
@@ -328,6 +338,75 @@ describe.each([
|
|
|
328
338
|
});
|
|
329
339
|
});
|
|
330
340
|
|
|
341
|
+
describe('PATCH Body Validation', () => {
|
|
342
|
+
const client = new ApiClient(baseUrl, PublicApiDefinition);
|
|
343
|
+
|
|
344
|
+
test('should validate and parse PATCH body on success', async () => {
|
|
345
|
+
const result = await client.callApi('common', 'patchResource', {
|
|
346
|
+
body: { name: 'test-item', value: 42 }
|
|
347
|
+
}, {
|
|
348
|
+
200: ({ data }) => {
|
|
349
|
+
expect(data.message).toBe('Patched');
|
|
350
|
+
expect(data.name).toBe('test-item');
|
|
351
|
+
expect(data.value).toBe(42);
|
|
352
|
+
return data;
|
|
353
|
+
},
|
|
354
|
+
422: ({ error }) => {
|
|
355
|
+
throw new Error(`Validation error: ${JSON.stringify(error)}`);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
expect(result.message).toBe('Patched');
|
|
360
|
+
expect(result.name).toBe('test-item');
|
|
361
|
+
expect(result.value).toBe(42);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('should reject PATCH body with missing required fields', async () => {
|
|
365
|
+
await expect(
|
|
366
|
+
client.callApi('common', 'patchResource', {
|
|
367
|
+
body: { name: 'test' } as any
|
|
368
|
+
}, {
|
|
369
|
+
200: ({ data }) => data,
|
|
370
|
+
422: ({ error }) => {
|
|
371
|
+
expect(error).toBeDefined();
|
|
372
|
+
expect(Array.isArray(error)).toBe(true);
|
|
373
|
+
throw new Error('Validation failed as expected');
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
).rejects.toThrow('Validation failed as expected');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('should reject PATCH body with invalid field types', async () => {
|
|
380
|
+
await expect(
|
|
381
|
+
client.callApi('common', 'patchResource', {
|
|
382
|
+
body: { name: 'test', value: 'not-a-number' } as any
|
|
383
|
+
}, {
|
|
384
|
+
200: ({ data }) => data,
|
|
385
|
+
422: ({ error }) => {
|
|
386
|
+
expect(error).toBeDefined();
|
|
387
|
+
expect(Array.isArray(error)).toBe(true);
|
|
388
|
+
throw new Error('Validation failed as expected');
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
).rejects.toThrow('Validation failed as expected');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('should reject PATCH body with extra unknown properties (strict)', async () => {
|
|
395
|
+
await expect(
|
|
396
|
+
client.callApi('common', 'patchResource', {
|
|
397
|
+
body: { name: 'test', value: 1, extraField: 'should not be allowed' } as any
|
|
398
|
+
}, {
|
|
399
|
+
200: ({ data }) => data,
|
|
400
|
+
422: ({ error }) => {
|
|
401
|
+
expect(error).toBeDefined();
|
|
402
|
+
expect(Array.isArray(error)).toBe(true);
|
|
403
|
+
throw new Error('Validation failed as expected');
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
).rejects.toThrow('Validation failed as expected');
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
331
410
|
describe('Private API', () => {
|
|
332
411
|
const client = new ApiClient(baseUrl, PrivateApiDefinition);
|
|
333
412
|
|