ts-typed-api 0.2.23 → 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.
@@ -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.
@@ -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) {
@@ -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,15 @@ 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
+ },
80
89
  patchResource: {
81
90
  method: 'PATCH',
82
91
  path: '/patch-resource',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-typed-api",
3
- "version": "0.2.23",
3
+ "version": "0.2.24",
4
4
  "description": "A lightweight, type-safe RPC library for TypeScript with Zod validation",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
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";
@@ -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;
@@ -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', {