ts-typed-api 0.2.18 → 0.2.20

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.
@@ -91,10 +91,13 @@ type RouteWithBody = {
91
91
  fileUpload?: FileUploadConfig;
92
92
  responses: Record<number, ZodTypeAny>;
93
93
  };
94
- export type RouteSchema = RouteWithoutBody | RouteWithBody;
95
- export type ApiDefinitionSchema = {
94
+ export type RouteSchema = (RouteWithoutBody | RouteWithBody) & {
95
+ description?: string;
96
+ };
97
+ export type ApiDefinitionSchema<TEndpoints extends Record<string, Record<string, RouteSchema>> = Record<string, Record<string, RouteSchema>>> = {
96
98
  prefix?: string;
97
- endpoints: Record<string, Record<string, RouteSchema>>;
99
+ sectionDescriptions?: Partial<Record<keyof TEndpoints, string>>;
100
+ endpoints: TEndpoints;
98
101
  };
99
102
  export declare function CreateApiDefinition<T extends ApiDefinitionSchema>(definition: T): T;
100
103
  export type ApiRouteKey<TDef extends ApiDefinitionSchema, TDomain extends keyof TDef['endpoints']> = keyof TDef['endpoints'][TDomain];
package/dist/handler.js CHANGED
@@ -6,6 +6,55 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.registerRouteHandlers = registerRouteHandlers;
7
7
  const zod_1 = require("zod");
8
8
  const multer_1 = __importDefault(require("multer"));
9
+ // Helper function to preprocess parameters for type coercion
10
+ function preprocessParams(params, paramsSchema) {
11
+ if (!paramsSchema || !params)
12
+ return params;
13
+ // Create a copy to avoid mutating the original
14
+ const processedParams = { ...params };
15
+ // Get the shape of the schema if it's a ZodObject
16
+ if (paramsSchema instanceof zod_1.z.ZodObject) {
17
+ const shape = paramsSchema.shape;
18
+ for (const [key, value] of Object.entries(processedParams)) {
19
+ if (typeof value === 'string' && shape[key]) {
20
+ const fieldSchema = shape[key];
21
+ // Handle ZodOptional and ZodDefault wrappers
22
+ let innerSchema = fieldSchema;
23
+ if (fieldSchema instanceof zod_1.z.ZodOptional) {
24
+ innerSchema = fieldSchema._def.innerType;
25
+ }
26
+ if (fieldSchema instanceof zod_1.z.ZodDefault) {
27
+ innerSchema = fieldSchema._def.innerType;
28
+ }
29
+ // Handle nested ZodOptional/ZodDefault combinations
30
+ while (innerSchema instanceof zod_1.z.ZodOptional || innerSchema instanceof zod_1.z.ZodDefault) {
31
+ if (innerSchema instanceof zod_1.z.ZodOptional) {
32
+ innerSchema = innerSchema._def.innerType;
33
+ }
34
+ else if (innerSchema instanceof zod_1.z.ZodDefault) {
35
+ innerSchema = innerSchema._def.innerType;
36
+ }
37
+ }
38
+ // Convert based on the inner schema type
39
+ if (innerSchema instanceof zod_1.z.ZodNumber) {
40
+ const numValue = Number(value);
41
+ if (!isNaN(numValue)) {
42
+ processedParams[key] = numValue;
43
+ }
44
+ }
45
+ else if (innerSchema instanceof zod_1.z.ZodBoolean) {
46
+ if (value === 'true') {
47
+ processedParams[key] = true;
48
+ }
49
+ else if (value === 'false') {
50
+ processedParams[key] = false;
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ return processedParams;
57
+ }
9
58
  // Helper function to preprocess query parameters for type coercion
10
59
  function preprocessQueryParams(query, querySchema) {
11
60
  if (!querySchema || !query)
@@ -277,9 +326,12 @@ middlewares) {
277
326
  try {
278
327
  // Ensure TDef is correctly used for type inference if this section needs it.
279
328
  // Currently, parsedParams,Query,Body are based on runtime routeDefinition.
280
- const parsedParams = ('params' in routeDefinition && routeDefinition.params)
281
- ? routeDefinition.params.parse(expressReq.params)
329
+ const preprocessedParams = ('params' in routeDefinition && routeDefinition.params)
330
+ ? preprocessParams(expressReq.params, routeDefinition.params)
282
331
  : expressReq.params;
332
+ const parsedParams = ('params' in routeDefinition && routeDefinition.params)
333
+ ? routeDefinition.params.parse(preprocessedParams)
334
+ : preprocessedParams;
283
335
  // Preprocess query parameters to handle type coercion from strings
284
336
  const preprocessedQuery = ('query' in routeDefinition && routeDefinition.query)
285
337
  ? preprocessQueryParams(expressReq.query, routeDefinition.query)
@@ -380,6 +432,26 @@ middlewares) {
380
432
  Object.getPrototypeOf(expressRes).setHeader.call(expressRes, name, value);
381
433
  return typedExpressRes;
382
434
  };
435
+ // SSE streaming methods
436
+ typedExpressRes.startSSE = () => {
437
+ typedExpressRes.setHeader('Content-Type', 'text/event-stream');
438
+ typedExpressRes.setHeader('Cache-Control', 'no-cache');
439
+ typedExpressRes.setHeader('Connection', 'keep-alive');
440
+ typedExpressRes.setHeader('Access-Control-Allow-Origin', '*');
441
+ typedExpressRes.setHeader('Access-Control-Allow-Headers', 'Cache-Control');
442
+ };
443
+ typedExpressRes.streamSSE = (eventName, data, id) => {
444
+ let event = '';
445
+ if (eventName)
446
+ event += `event: ${eventName}\n`;
447
+ if (id)
448
+ event += `id: ${id}\n`;
449
+ event += `data: ${JSON.stringify(data)}\n\n`;
450
+ expressRes.write(event);
451
+ };
452
+ typedExpressRes.endStream = () => {
453
+ expressRes.end();
454
+ };
383
455
  const specificHandlerFn = handler;
384
456
  await specificHandlerFn(finalTypedReq, typedExpressRes);
385
457
  }
@@ -19,6 +19,55 @@ exports.honoFileSchema = zod_1.z.object({
19
19
  path: zod_1.z.string().optional(),
20
20
  stream: zod_1.z.any().optional(),
21
21
  });
22
+ // Helper function to preprocess parameters for type coercion
23
+ function preprocessParams(params, paramsSchema) {
24
+ if (!paramsSchema || !params)
25
+ return params;
26
+ // Create a copy to avoid mutating the original
27
+ const processedParams = { ...params };
28
+ // Get the shape of the schema if it's a ZodObject
29
+ if (paramsSchema instanceof zod_1.z.ZodObject) {
30
+ const shape = paramsSchema.shape;
31
+ for (const [key, value] of Object.entries(processedParams)) {
32
+ if (typeof value === 'string' && shape[key]) {
33
+ const fieldSchema = shape[key];
34
+ // Handle ZodOptional and ZodDefault wrappers
35
+ let innerSchema = fieldSchema;
36
+ if (fieldSchema instanceof zod_1.z.ZodOptional) {
37
+ innerSchema = fieldSchema._def.innerType;
38
+ }
39
+ if (fieldSchema instanceof zod_1.z.ZodDefault) {
40
+ innerSchema = fieldSchema._def.innerType;
41
+ }
42
+ // Handle nested ZodOptional/ZodDefault combinations
43
+ while (innerSchema instanceof zod_1.z.ZodOptional || innerSchema instanceof zod_1.z.ZodDefault) {
44
+ if (innerSchema instanceof zod_1.z.ZodOptional) {
45
+ innerSchema = innerSchema._def.innerType;
46
+ }
47
+ else if (innerSchema instanceof zod_1.z.ZodDefault) {
48
+ innerSchema = innerSchema._def.innerType;
49
+ }
50
+ }
51
+ // Convert based on the inner schema type
52
+ if (innerSchema instanceof zod_1.z.ZodNumber) {
53
+ const numValue = Number(value);
54
+ if (!isNaN(numValue)) {
55
+ processedParams[key] = numValue;
56
+ }
57
+ }
58
+ else if (innerSchema instanceof zod_1.z.ZodBoolean) {
59
+ if (value === 'true') {
60
+ processedParams[key] = true;
61
+ }
62
+ else if (value === 'false') {
63
+ processedParams[key] = false;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ return processedParams;
70
+ }
22
71
  // Helper function to preprocess query parameters for type coercion
23
72
  function preprocessQueryParams(query, querySchema) {
24
73
  if (!querySchema || !query)
@@ -240,9 +289,12 @@ function registerHonoRouteHandlers(app, apiDefinition, routeHandlers, middleware
240
289
  const honoMiddleware = async (c) => {
241
290
  try {
242
291
  // Parse and validate request
243
- const parsedParams = ('params' in routeDefinition && routeDefinition.params)
244
- ? routeDefinition.params.parse(c.req.param())
292
+ const preprocessedParams = ('params' in routeDefinition && routeDefinition.params)
293
+ ? preprocessParams(c.req.param(), routeDefinition.params)
245
294
  : c.req.param();
295
+ const parsedParams = ('params' in routeDefinition && routeDefinition.params)
296
+ ? routeDefinition.params.parse(preprocessedParams)
297
+ : preprocessedParams;
246
298
  const preprocessedQuery = ('query' in routeDefinition && routeDefinition.query)
247
299
  ? preprocessQueryParams(c.req.query(), routeDefinition.query)
248
300
  : c.req.query();
@@ -351,6 +403,38 @@ function registerHonoRouteHandlers(app, apiDefinition, routeHandlers, middleware
351
403
  setHeader: (name, value) => {
352
404
  c.header(name, value);
353
405
  return fakeRes;
406
+ },
407
+ // SSE streaming methods for Hono
408
+ startSSE: () => {
409
+ c.header('Content-Type', 'text/event-stream');
410
+ c.header('Cache-Control', 'no-cache');
411
+ c.header('Connection', 'keep-alive');
412
+ c.header('Access-Control-Allow-Origin', '*');
413
+ c.header('Access-Control-Allow-Headers', 'Cache-Control');
414
+ },
415
+ streamSSE: (eventName, data, id) => {
416
+ let event = '';
417
+ if (eventName)
418
+ event += `event: ${eventName}\n`;
419
+ if (id)
420
+ event += `id: ${id}\n`;
421
+ event += `data: ${JSON.stringify(data)}\n\n`;
422
+ // For Hono, we need to accumulate the response
423
+ if (!c.__sseBuffer) {
424
+ c.__sseBuffer = '';
425
+ }
426
+ c.__sseBuffer += event;
427
+ },
428
+ endStream: () => {
429
+ // Send the accumulated SSE data
430
+ const sseData = c.__sseBuffer || '';
431
+ c.__response = new Response(sseData, {
432
+ headers: {
433
+ 'Content-Type': 'text/event-stream',
434
+ 'Cache-Control': 'no-cache',
435
+ 'Connection': 'keep-alive'
436
+ }
437
+ });
354
438
  }
355
439
  };
356
440
  const specificHandlerFn = handler;
@@ -14,6 +14,10 @@ export interface OpenAPISpec {
14
14
  components?: {
15
15
  schemas?: Record<string, SchemaObject>;
16
16
  };
17
+ tags?: Array<{
18
+ name: string;
19
+ description?: string;
20
+ }>;
17
21
  }
18
22
  export interface PathItem {
19
23
  get?: Operation;
@@ -417,10 +417,12 @@ function processRoute(route, fullPath, registry, domain, anonymousTypes = false)
417
417
  const responses = createResponses(route.responses, registry, anonymousTypes);
418
418
  const operation = {
419
419
  summary: `${route.method} ${fullPath}`,
420
- description: `${route.method} operation for ${fullPath}`,
421
420
  responses,
422
421
  tags: [domain]
423
422
  };
423
+ if (route.description) {
424
+ operation.description = route.description;
425
+ }
424
426
  if (parameters.length > 0) {
425
427
  operation.parameters = parameters;
426
428
  }
@@ -450,6 +452,7 @@ function generateOpenApiSpec(definitions, options = {}) {
450
452
  const definitionsArray = Array.isArray(definitions) ? definitions : [definitions];
451
453
  const anonymousTypes = options.anonymousTypes || false;
452
454
  const allPaths = {};
455
+ const allTags = [];
453
456
  // Process each definition
454
457
  for (const definition of definitionsArray) {
455
458
  const paths = processApiDefinition(definition, registry, anonymousTypes);
@@ -463,6 +466,18 @@ function generateOpenApiSpec(definitions, options = {}) {
463
466
  allPaths[path] = pathItem;
464
467
  }
465
468
  }
469
+ // Collect tags from sectionDescriptions
470
+ if (definition.sectionDescriptions) {
471
+ for (const [sectionName, description] of Object.entries(definition.sectionDescriptions)) {
472
+ // Avoid duplicates
473
+ if (!allTags.find(tag => tag.name === sectionName)) {
474
+ allTags.push({
475
+ name: sectionName,
476
+ description: description
477
+ });
478
+ }
479
+ }
480
+ }
466
481
  }
467
482
  const spec = {
468
483
  openapi: '3.0.0',
@@ -477,6 +492,10 @@ function generateOpenApiSpec(definitions, options = {}) {
477
492
  if (options.servers && options.servers.length > 0) {
478
493
  spec.servers = options.servers;
479
494
  }
495
+ // Add tags if any were found
496
+ if (allTags.length > 0) {
497
+ spec.tags = allTags;
498
+ }
480
499
  // Add components with schemas if any were registered and not using anonymous types
481
500
  if (!anonymousTypes) {
482
501
  const schemas = registry.getSchemas();
package/dist/openapi.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { ApiDefinitionSchema } from './definition';
2
- export declare function generateOpenApiSpec(definitions: ApiDefinitionSchema | ApiDefinitionSchema[], options?: {
1
+ import { RouteSchema } from './definition';
2
+ export declare function generateOpenApiSpec<TEndpoints extends Record<string, Record<string, RouteSchema>>>(definitions: import('./definition').ApiDefinitionSchema<TEndpoints> | import('./definition').ApiDefinitionSchema<TEndpoints>[], options?: {
3
3
  info?: {
4
4
  title?: string;
5
5
  version?: string;
package/dist/openapi.js CHANGED
@@ -47,11 +47,20 @@ function generateOpenApiSpec(definitions, options = {}) {
47
47
  },
48
48
  };
49
49
  }
50
+ // Collect all tags with descriptions for the OpenAPI spec
51
+ const allTags = [];
50
52
  // Iterate over multiple API definitions to register routes
51
53
  definitionArray.forEach((definition) => {
52
54
  Object.keys(definition.endpoints).forEach(domainNameKey => {
53
55
  // domainNameKey is a string, representing the domain like 'users', 'products'
54
56
  const domain = definition.endpoints[domainNameKey];
57
+ // Add tag if not already present
58
+ if (!allTags.find(tag => tag.name === domainNameKey)) {
59
+ allTags.push({
60
+ name: domainNameKey,
61
+ description: definition.sectionDescriptions?.[domainNameKey]
62
+ });
63
+ }
55
64
  Object.keys(domain).forEach(routeNameKey => {
56
65
  // routeNameKey is a string, representing the route name like 'getUser', 'createProduct'
57
66
  const route = domain[routeNameKey];
@@ -91,6 +100,7 @@ function generateOpenApiSpec(definitions, options = {}) {
91
100
  }
92
101
  const operation = {
93
102
  summary: `${domainNameKey} - ${routeNameKey}`, // Use keys directly for summary
103
+ description: route.description, // Use route description if provided
94
104
  tags: [domainNameKey], // Use domainNameKey for tags
95
105
  parameters: parameters.length > 0 ? parameters : undefined,
96
106
  requestBody: requestBody,
@@ -103,7 +113,6 @@ function generateOpenApiSpec(definitions, options = {}) {
103
113
  method: route.method.toLowerCase(), // Ensure method is lowercase
104
114
  path: openApiPath,
105
115
  ...operation,
106
- // Add description or other OpenAPI fields if available in RouteSchema
107
116
  });
108
117
  });
109
118
  });
@@ -118,6 +127,7 @@ function generateOpenApiSpec(definitions, options = {}) {
118
127
  description: options.info?.description ?? 'Automatically generated OpenAPI specification',
119
128
  },
120
129
  servers: options.servers ?? [{ url: '/api' }], // Adjust as needed
130
+ tags: allTags.filter(tag => tag.description), // Only include tags that have descriptions
121
131
  });
122
132
  return openApiDocument;
123
133
  }
package/dist/router.d.ts CHANGED
@@ -17,6 +17,9 @@ export interface TypedResponse<TDef extends ApiDefinitionSchema, TDomain extends
17
17
  respondContentType: (status: number, data: any, contentType: string) => void;
18
18
  setHeader: (name: string, value: string) => this;
19
19
  json: <B = any>(body: B) => this;
20
+ startSSE: () => void;
21
+ streamSSE: (eventName?: string, data?: any, id?: string) => void;
22
+ endStream: () => void;
20
23
  }
21
24
  export declare function createRouteHandler<TDef extends ApiDefinitionSchema, TDomain extends keyof TDef['endpoints'], TRouteKey extends keyof TDef['endpoints'][TDomain], // Using direct keyof for simplicity
22
25
  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): {
@@ -20,11 +20,16 @@ const ProductSchema = z.object({
20
20
 
21
21
  export const PublicApiDefinition = CreateApiDefinition({
22
22
  prefix: '/api/v1/public',
23
+ sectionDescriptions: {
24
+ auth: 'Authentication endpoints for user login and logout',
25
+ products: 'Product management and listing endpoints'
26
+ },
23
27
  endpoints: {
24
28
  auth: {
25
29
  login: {
26
30
  method: 'POST',
27
31
  path: '/login',
32
+ description: 'Authenticate a user with username and password, returns JWT token',
28
33
  body: z.object({
29
34
  username: z.string(),
30
35
  password: z.string()
@@ -42,6 +47,7 @@ export const PublicApiDefinition = CreateApiDefinition({
42
47
  logout: {
43
48
  method: 'POST',
44
49
  path: '/logout',
50
+ description: 'Log out the current user and invalidate their session',
45
51
  responses: CreateResponses({
46
52
  200: z.object({
47
53
  message: z.string()
@@ -53,6 +59,7 @@ export const PublicApiDefinition = CreateApiDefinition({
53
59
  list: {
54
60
  method: 'GET',
55
61
  path: '/products',
62
+ description: 'Retrieve a paginated list of products with optional filtering',
56
63
  query: z.object({
57
64
  page: z.number().int().min(1).optional().default(1),
58
65
  limit: z.number().int().min(1).max(100).optional().default(10),
@@ -2,11 +2,16 @@ import { ZodSchema as z, CreateApiDefinition, CreateResponses } from '../../src'
2
2
 
3
3
  export const PublicApiDefinition = CreateApiDefinition({
4
4
  prefix: '/api/v1/public',
5
+ sectionDescriptions: {
6
+ status: 'Health check and status endpoints',
7
+ common: 'Common utility endpoints'
8
+ },
5
9
  endpoints: {
6
10
  status: {
7
11
  probe1: {
8
12
  method: 'GET',
9
13
  path: '/status/probe1',
14
+ description: 'Advanced health check with query parameters',
10
15
  query: z.object({
11
16
  match: z.boolean()
12
17
  }),
@@ -21,6 +26,7 @@ export const PublicApiDefinition = CreateApiDefinition({
21
26
  probe2: {
22
27
  method: 'GET',
23
28
  path: '/status/probe2',
29
+ description: 'Simple health check endpoint',
24
30
  responses: CreateResponses({
25
31
  200: z.enum(["pong"]),
26
32
  })
@@ -30,6 +36,7 @@ export const PublicApiDefinition = CreateApiDefinition({
30
36
  ping: {
31
37
  method: 'GET',
32
38
  path: '/ping',
39
+ description: 'Basic ping endpoint to check if the service is alive',
33
40
  query: z.object({
34
41
  format: z.enum(["json", "html"]).optional()
35
42
  }),
@@ -40,12 +47,36 @@ export const PublicApiDefinition = CreateApiDefinition({
40
47
  customHeaders: {
41
48
  method: 'GET',
42
49
  path: '/custom-headers',
50
+ description: 'Returns information about custom headers',
43
51
  responses: CreateResponses({
44
52
  200: z.object({
45
53
  message: z.string()
46
54
  }),
47
55
  })
48
56
  },
57
+ longpoll: {
58
+ method: 'GET',
59
+ path: '/longpoll/:sequence',
60
+ description: 'Long polling endpoint that simulates delayed response',
61
+ params: z.object({
62
+ sequence: z.number().int().min(1)
63
+ }),
64
+ responses: CreateResponses({
65
+ 200: z.object({
66
+ sequence: z.number(),
67
+ data: z.string(),
68
+ timestamp: z.number()
69
+ }),
70
+ })
71
+ },
72
+ stream: {
73
+ method: 'GET',
74
+ path: '/stream',
75
+ description: 'Server-Sent Events streaming endpoint',
76
+ responses: CreateResponses({
77
+ 200: z.string() // Raw SSE data
78
+ })
79
+ },
49
80
  }
50
81
  }
51
82
  })
@@ -49,6 +49,33 @@ RegisterHandlers(app, PublicApiDefinition, {
49
49
  res.setHeader('x-custom-test', 'test-value');
50
50
  res.setHeader('x-another-header', 'another-value');
51
51
  res.respond(200, { message: "headers set" });
52
+ },
53
+ longpoll: async (req, res) => {
54
+ const sequence = req.params.sequence;
55
+ // Simulate long polling delay based on sequence
56
+ const delay = sequence * 100; // 100ms per sequence number
57
+ await new Promise(resolve => setTimeout(resolve, delay));
58
+ res.respond(200, {
59
+ sequence,
60
+ data: `object ${sequence}`,
61
+ timestamp: Date.now()
62
+ });
63
+ },
64
+ stream: async (req, res) => {
65
+ // Initialize SSE with proper headers
66
+ res.startSSE();
67
+
68
+ // Send SSE events with JSON data at intervals
69
+ await res.streamSSE('update', { sequence: 1, data: 'object 1' });
70
+ await new Promise(resolve => setTimeout(resolve, 100));
71
+
72
+ await res.streamSSE('update', { sequence: 2, data: 'object 2' });
73
+ await new Promise(resolve => setTimeout(resolve, 100));
74
+
75
+ await res.streamSSE('update', { sequence: 3, data: 'object 3' });
76
+
77
+ // Close the stream
78
+ res.endStream();
52
79
  }
53
80
  },
54
81
  status: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-typed-api",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
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
@@ -184,13 +184,16 @@ type RouteWithBody = {
184
184
  };
185
185
 
186
186
  // Union type for all route schemas
187
- export type RouteSchema = RouteWithoutBody | RouteWithBody;
187
+ export type RouteSchema = (RouteWithoutBody | RouteWithBody) & {
188
+ description?: string;
189
+ };
188
190
 
189
191
  // Define the structure for the entire API definition object
190
192
  // Now includes an optional prefix and endpoints record
191
- export type ApiDefinitionSchema = {
193
+ export type ApiDefinitionSchema<TEndpoints extends Record<string, Record<string, RouteSchema>> = Record<string, Record<string, RouteSchema>>> = {
192
194
  prefix?: string;
193
- endpoints: Record<string, Record<string, RouteSchema>>;
195
+ sectionDescriptions?: Partial<Record<keyof TEndpoints, string>>;
196
+ endpoints: TEndpoints;
194
197
  };
195
198
 
196
199
  // Helper function to ensure the definition conforms to ApiDefinitionSchema
package/src/handler.ts CHANGED
@@ -27,6 +27,59 @@ export type EndpointMiddleware<TDef extends ApiDefinitionSchema = ApiDefinitionS
27
27
  }[keyof TDef['endpoints']]
28
28
  ) => void | Promise<void>;
29
29
 
30
+ // Helper function to preprocess parameters for type coercion
31
+ function preprocessParams(params: any, paramsSchema?: z.ZodTypeAny): any {
32
+ if (!paramsSchema || !params) return params;
33
+
34
+ // Create a copy to avoid mutating the original
35
+ const processedParams = { ...params };
36
+
37
+ // Get the shape of the schema if it's a ZodObject
38
+ if (paramsSchema instanceof z.ZodObject) {
39
+ const shape = paramsSchema.shape;
40
+
41
+ for (const [key, value] of Object.entries(processedParams)) {
42
+ if (typeof value === 'string' && shape[key]) {
43
+ const fieldSchema = shape[key];
44
+
45
+ // Handle ZodOptional and ZodDefault wrappers
46
+ let innerSchema = fieldSchema;
47
+ if (fieldSchema instanceof z.ZodOptional) {
48
+ innerSchema = fieldSchema._def.innerType;
49
+ }
50
+ if (fieldSchema instanceof z.ZodDefault) {
51
+ innerSchema = fieldSchema._def.innerType;
52
+ }
53
+
54
+ // Handle nested ZodOptional/ZodDefault combinations
55
+ while (innerSchema instanceof z.ZodOptional || innerSchema instanceof z.ZodDefault) {
56
+ if (innerSchema instanceof z.ZodOptional) {
57
+ innerSchema = innerSchema._def.innerType;
58
+ } else if (innerSchema instanceof z.ZodDefault) {
59
+ innerSchema = innerSchema._def.innerType;
60
+ }
61
+ }
62
+
63
+ // Convert based on the inner schema type
64
+ if (innerSchema instanceof z.ZodNumber) {
65
+ const numValue = Number(value);
66
+ if (!isNaN(numValue)) {
67
+ processedParams[key] = numValue;
68
+ }
69
+ } else if (innerSchema instanceof z.ZodBoolean) {
70
+ if (value === 'true') {
71
+ processedParams[key] = true;
72
+ } else if (value === 'false') {
73
+ processedParams[key] = false;
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ return processedParams;
81
+ }
82
+
30
83
  // Helper function to preprocess query parameters for type coercion
31
84
  function preprocessQueryParams(query: any, querySchema?: z.ZodTypeAny): any {
32
85
  if (!querySchema || !query) return query;
@@ -326,10 +379,14 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
326
379
  try {
327
380
  // Ensure TDef is correctly used for type inference if this section needs it.
328
381
  // Currently, parsedParams,Query,Body are based on runtime routeDefinition.
329
- const parsedParams = ('params' in routeDefinition && routeDefinition.params)
330
- ? (routeDefinition.params as z.ZodTypeAny).parse(expressReq.params)
382
+ const preprocessedParams = ('params' in routeDefinition && routeDefinition.params)
383
+ ? preprocessParams(expressReq.params, routeDefinition.params as z.ZodTypeAny)
331
384
  : expressReq.params;
332
385
 
386
+ const parsedParams = ('params' in routeDefinition && routeDefinition.params)
387
+ ? (routeDefinition.params as z.ZodTypeAny).parse(preprocessedParams)
388
+ : preprocessedParams;
389
+
333
390
  // Preprocess query parameters to handle type coercion from strings
334
391
  const preprocessedQuery = ('query' in routeDefinition && routeDefinition.query)
335
392
  ? preprocessQueryParams(expressReq.query, routeDefinition.query as z.ZodTypeAny)
@@ -444,6 +501,27 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
444
501
  return typedExpressRes;
445
502
  };
446
503
 
504
+ // SSE streaming methods
505
+ typedExpressRes.startSSE = () => {
506
+ typedExpressRes.setHeader('Content-Type', 'text/event-stream');
507
+ typedExpressRes.setHeader('Cache-Control', 'no-cache');
508
+ typedExpressRes.setHeader('Connection', 'keep-alive');
509
+ typedExpressRes.setHeader('Access-Control-Allow-Origin', '*');
510
+ typedExpressRes.setHeader('Access-Control-Allow-Headers', 'Cache-Control');
511
+ };
512
+
513
+ typedExpressRes.streamSSE = (eventName?: string, data?: any, id?: string) => {
514
+ let event = '';
515
+ if (eventName) event += `event: ${eventName}\n`;
516
+ if (id) event += `id: ${id}\n`;
517
+ event += `data: ${JSON.stringify(data)}\n\n`;
518
+ expressRes.write(event);
519
+ };
520
+
521
+ typedExpressRes.endStream = () => {
522
+ expressRes.end();
523
+ };
524
+
447
525
  const specificHandlerFn = handler as (
448
526
  req: TypedRequest<TDef, typeof currentDomain, typeof currentRouteKey>,
449
527
  res: TypedResponse<TDef, typeof currentDomain, typeof currentRouteKey>
@@ -45,6 +45,59 @@ export type HonoTypedContext<
45
45
  respond: TypedResponse<TDef, TDomain, TRouteKey>['respond'];
46
46
  };
47
47
 
48
+ // Helper function to preprocess parameters for type coercion
49
+ function preprocessParams(params: any, paramsSchema?: z.ZodTypeAny): any {
50
+ if (!paramsSchema || !params) return params;
51
+
52
+ // Create a copy to avoid mutating the original
53
+ const processedParams = { ...params };
54
+
55
+ // Get the shape of the schema if it's a ZodObject
56
+ if (paramsSchema instanceof z.ZodObject) {
57
+ const shape = paramsSchema.shape;
58
+
59
+ for (const [key, value] of Object.entries(processedParams)) {
60
+ if (typeof value === 'string' && shape[key]) {
61
+ const fieldSchema = shape[key];
62
+
63
+ // Handle ZodOptional and ZodDefault wrappers
64
+ let innerSchema = fieldSchema;
65
+ if (fieldSchema instanceof z.ZodOptional) {
66
+ innerSchema = fieldSchema._def.innerType;
67
+ }
68
+ if (fieldSchema instanceof z.ZodDefault) {
69
+ innerSchema = fieldSchema._def.innerType;
70
+ }
71
+
72
+ // Handle nested ZodOptional/ZodDefault combinations
73
+ while (innerSchema instanceof z.ZodOptional || innerSchema instanceof z.ZodDefault) {
74
+ if (innerSchema instanceof z.ZodOptional) {
75
+ innerSchema = innerSchema._def.innerType;
76
+ } else if (innerSchema instanceof z.ZodDefault) {
77
+ innerSchema = innerSchema._def.innerType;
78
+ }
79
+ }
80
+
81
+ // Convert based on the inner schema type
82
+ if (innerSchema instanceof z.ZodNumber) {
83
+ const numValue = Number(value);
84
+ if (!isNaN(numValue)) {
85
+ processedParams[key] = numValue;
86
+ }
87
+ } else if (innerSchema instanceof z.ZodBoolean) {
88
+ if (value === 'true') {
89
+ processedParams[key] = true;
90
+ } else if (value === 'false') {
91
+ processedParams[key] = false;
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ return processedParams;
99
+ }
100
+
48
101
  // Helper function to preprocess query parameters for type coercion
49
102
  function preprocessQueryParams(query: any, querySchema?: z.ZodTypeAny): any {
50
103
  if (!querySchema || !query) return query;
@@ -304,10 +357,14 @@ export function registerHonoRouteHandlers<
304
357
  ) => {
305
358
  try {
306
359
  // Parse and validate request
307
- const parsedParams = ('params' in routeDefinition && routeDefinition.params)
308
- ? (routeDefinition.params as z.ZodTypeAny).parse(c.req.param())
360
+ const preprocessedParams = ('params' in routeDefinition && routeDefinition.params)
361
+ ? preprocessParams(c.req.param(), routeDefinition.params as z.ZodTypeAny)
309
362
  : c.req.param();
310
363
 
364
+ const parsedParams = ('params' in routeDefinition && routeDefinition.params)
365
+ ? (routeDefinition.params as z.ZodTypeAny).parse(preprocessedParams)
366
+ : preprocessedParams;
367
+
311
368
  const preprocessedQuery = ('query' in routeDefinition && routeDefinition.query)
312
369
  ? preprocessQueryParams(c.req.query(), routeDefinition.query as z.ZodTypeAny)
313
370
  : c.req.query();
@@ -429,6 +486,37 @@ export function registerHonoRouteHandlers<
429
486
  setHeader: (name: string, value: string) => {
430
487
  c.header(name, value);
431
488
  return fakeRes;
489
+ },
490
+ // SSE streaming methods for Hono
491
+ startSSE: () => {
492
+ c.header('Content-Type', 'text/event-stream');
493
+ c.header('Cache-Control', 'no-cache');
494
+ c.header('Connection', 'keep-alive');
495
+ c.header('Access-Control-Allow-Origin', '*');
496
+ c.header('Access-Control-Allow-Headers', 'Cache-Control');
497
+ },
498
+ streamSSE: (eventName?: string, data?: any, id?: string) => {
499
+ let event = '';
500
+ if (eventName) event += `event: ${eventName}\n`;
501
+ if (id) event += `id: ${id}\n`;
502
+ event += `data: ${JSON.stringify(data)}\n\n`;
503
+
504
+ // For Hono, we need to accumulate the response
505
+ if (!(c as any).__sseBuffer) {
506
+ (c as any).__sseBuffer = '';
507
+ }
508
+ (c as any).__sseBuffer += event;
509
+ },
510
+ endStream: () => {
511
+ // Send the accumulated SSE data
512
+ const sseData = (c as any).__sseBuffer || '';
513
+ (c as any).__response = new Response(sseData, {
514
+ headers: {
515
+ 'Content-Type': 'text/event-stream',
516
+ 'Cache-Control': 'no-cache',
517
+ 'Connection': 'keep-alive'
518
+ }
519
+ });
432
520
  }
433
521
  } as TypedResponse<TDef, typeof currentDomain, typeof currentRouteKey>;
434
522
 
@@ -17,6 +17,10 @@ export interface OpenAPISpec {
17
17
  components?: {
18
18
  schemas?: Record<string, SchemaObject>;
19
19
  };
20
+ tags?: Array<{
21
+ name: string;
22
+ description?: string;
23
+ }>;
20
24
  }
21
25
 
22
26
  export interface PathItem {
@@ -558,11 +562,14 @@ function processRoute(
558
562
 
559
563
  const operation: Operation = {
560
564
  summary: `${route.method} ${fullPath}`,
561
- description: `${route.method} operation for ${fullPath}`,
562
565
  responses,
563
566
  tags: [domain]
564
567
  };
565
568
 
569
+ if (route.description) {
570
+ operation.description = route.description;
571
+ }
572
+
566
573
  if (parameters.length > 0) {
567
574
  operation.parameters = parameters;
568
575
  }
@@ -609,6 +616,7 @@ export function generateOpenApiSpec(
609
616
  const anonymousTypes = options.anonymousTypes || false;
610
617
 
611
618
  const allPaths: Record<string, PathItem> = {};
619
+ const allTags: Array<{ name: string; description?: string }> = [];
612
620
 
613
621
  // Process each definition
614
622
  for (const definition of definitionsArray) {
@@ -623,6 +631,19 @@ export function generateOpenApiSpec(
623
631
  allPaths[path] = pathItem;
624
632
  }
625
633
  }
634
+
635
+ // Collect tags from sectionDescriptions
636
+ if (definition.sectionDescriptions) {
637
+ for (const [sectionName, description] of Object.entries(definition.sectionDescriptions)) {
638
+ // Avoid duplicates
639
+ if (!allTags.find(tag => tag.name === sectionName)) {
640
+ allTags.push({
641
+ name: sectionName,
642
+ description: description
643
+ });
644
+ }
645
+ }
646
+ }
626
647
  }
627
648
 
628
649
  const spec: OpenAPISpec = {
@@ -640,6 +661,11 @@ export function generateOpenApiSpec(
640
661
  spec.servers = options.servers;
641
662
  }
642
663
 
664
+ // Add tags if any were found
665
+ if (allTags.length > 0) {
666
+ spec.tags = allTags;
667
+ }
668
+
643
669
  // Add components with schemas if any were registered and not using anonymous types
644
670
  if (!anonymousTypes) {
645
671
  const schemas = registry.getSchemas();
package/src/openapi.ts CHANGED
@@ -1,12 +1,12 @@
1
- import { ApiDefinitionSchema, RouteSchema } from './definition';
1
+ import { RouteSchema } from './definition';
2
2
  import { OpenAPIRegistry, OpenApiGeneratorV31, extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
3
3
  import { z, ZodTypeAny } from 'zod';
4
4
 
5
5
  // Extend Zod with OpenAPI capabilities
6
6
  extendZodWithOpenApi(z);
7
7
 
8
- export function generateOpenApiSpec(
9
- definitions: ApiDefinitionSchema | ApiDefinitionSchema[],
8
+ export function generateOpenApiSpec<TEndpoints extends Record<string, Record<string, RouteSchema>>>(
9
+ definitions: import('./definition').ApiDefinitionSchema<TEndpoints> | import('./definition').ApiDefinitionSchema<TEndpoints>[],
10
10
  options: {
11
11
  info?: {
12
12
  title?: string;
@@ -59,11 +59,23 @@ export function generateOpenApiSpec(
59
59
  };
60
60
  }
61
61
 
62
+ // Collect all tags with descriptions for the OpenAPI spec
63
+ const allTags: Array<{ name: string; description?: string }> = [];
64
+
62
65
  // Iterate over multiple API definitions to register routes
63
66
  definitionArray.forEach((definition) => {
64
67
  Object.keys(definition.endpoints).forEach(domainNameKey => {
65
68
  // domainNameKey is a string, representing the domain like 'users', 'products'
66
69
  const domain = definition.endpoints[domainNameKey];
70
+
71
+ // Add tag if not already present
72
+ if (!allTags.find(tag => tag.name === domainNameKey)) {
73
+ allTags.push({
74
+ name: domainNameKey,
75
+ description: definition.sectionDescriptions?.[domainNameKey]
76
+ });
77
+ }
78
+
67
79
  Object.keys(domain).forEach(routeNameKey => {
68
80
  // routeNameKey is a string, representing the route name like 'getUser', 'createProduct'
69
81
  const route: RouteSchema = domain[routeNameKey];
@@ -108,6 +120,7 @@ export function generateOpenApiSpec(
108
120
 
109
121
  const operation = {
110
122
  summary: `${domainNameKey} - ${routeNameKey}`, // Use keys directly for summary
123
+ description: route.description, // Use route description if provided
111
124
  tags: [domainNameKey], // Use domainNameKey for tags
112
125
  parameters: parameters.length > 0 ? parameters : undefined,
113
126
  requestBody: requestBody,
@@ -122,7 +135,6 @@ export function generateOpenApiSpec(
122
135
  method: route.method.toLowerCase() as any, // Ensure method is lowercase
123
136
  path: openApiPath,
124
137
  ...operation,
125
- // Add description or other OpenAPI fields if available in RouteSchema
126
138
  });
127
139
  });
128
140
  });
@@ -138,6 +150,7 @@ export function generateOpenApiSpec(
138
150
  description: options.info?.description ?? 'Automatically generated OpenAPI specification',
139
151
  },
140
152
  servers: options.servers ?? [{ url: '/api' }], // Adjust as needed
153
+ tags: allTags.filter(tag => tag.description), // Only include tags that have descriptions
141
154
  });
142
155
 
143
156
  return openApiDocument;
package/src/router.ts CHANGED
@@ -62,6 +62,10 @@ export interface TypedResponse<
62
62
  respondContentType: (status: number, data: any, contentType: string) => void;
63
63
  setHeader: (name: string, value: string) => this;
64
64
  json: <B = any>(body: B) => this; // Keep original json
65
+ // SSE streaming methods
66
+ startSSE: () => void;
67
+ streamSSE: (eventName?: string, data?: any, id?: string) => void;
68
+ endStream: () => void;
65
69
  }
66
70
 
67
71
  // Type-safe route handler creation function, now generic over TDef and Ctx
@@ -316,4 +316,105 @@ describe('OpenAPI Specification Generation', () => {
316
316
  }
317
317
  }
318
318
  });
319
+
320
+ test('should include section descriptions in OpenAPI tags', () => {
321
+ const spec = generateOpenApiSpec(PublicApiDefinition);
322
+
323
+ expect(spec).toBeDefined();
324
+ expect(spec.tags).toBeDefined();
325
+ expect(Array.isArray(spec.tags)).toBe(true);
326
+
327
+ // Check that tags with descriptions are included
328
+ const statusTag = spec.tags?.find((tag: any) => tag.name === 'status');
329
+ const commonTag = spec.tags?.find((tag: any) => tag.name === 'common');
330
+
331
+ expect(statusTag).toBeDefined();
332
+ expect(statusTag?.description).toBe('Health check and status endpoints');
333
+
334
+ expect(commonTag).toBeDefined();
335
+ expect(commonTag?.description).toBe('Common utility endpoints');
336
+ });
337
+
338
+ test('should include route descriptions in operation descriptions', () => {
339
+ const spec = generateOpenApiSpec(PublicApiDefinition);
340
+
341
+ expect(spec).toBeDefined();
342
+ expect(spec.paths).toBeDefined();
343
+
344
+ // Check specific endpoints for descriptions
345
+ const probe1Path = spec.paths?.['/api/v1/public/status/probe1'];
346
+ const pingPath = spec.paths?.['/api/v1/public/ping'];
347
+
348
+ expect(probe1Path?.get?.description).toBe('Advanced health check with query parameters');
349
+ expect(pingPath?.get?.description).toBe('Basic ping endpoint to check if the service is alive');
350
+ });
351
+
352
+ test('should handle API definitions without descriptions', () => {
353
+ // Create a definition without descriptions
354
+ const NoDescriptionDefinition = CreateApiDefinition({
355
+ endpoints: {
356
+ test: {
357
+ simpleEndpoint: {
358
+ method: 'GET' as const,
359
+ path: '/test',
360
+ responses: {
361
+ 200: z.object({ message: z.string() })
362
+ }
363
+ }
364
+ }
365
+ }
366
+ });
367
+
368
+ const spec = generateOpenApiSpec(NoDescriptionDefinition);
369
+
370
+ expect(spec).toBeDefined();
371
+ expect(spec.tags).toBeUndefined(); // No tags should be generated when no descriptions
372
+
373
+ const testPath = spec.paths?.['/test'];
374
+ expect(testPath?.get?.description).toBeUndefined(); // No description on operation
375
+ });
376
+
377
+ test('should handle partial section descriptions', () => {
378
+ // Create a definition with only some sections having descriptions
379
+ const PartialDescriptionDefinition = CreateApiDefinition({
380
+ sectionDescriptions: {
381
+ section1: 'Description for section 1'
382
+ // section2 intentionally omitted
383
+ },
384
+ endpoints: {
385
+ section1: {
386
+ endpoint1: {
387
+ method: 'GET' as const,
388
+ path: '/section1/endpoint1',
389
+ responses: {
390
+ 200: z.object({ data: z.string() })
391
+ }
392
+ }
393
+ },
394
+ section2: {
395
+ endpoint2: {
396
+ method: 'GET' as const,
397
+ path: '/section2/endpoint2',
398
+ responses: {
399
+ 200: z.object({ data: z.string() })
400
+ }
401
+ }
402
+ }
403
+ }
404
+ });
405
+
406
+ const spec = generateOpenApiSpec(PartialDescriptionDefinition);
407
+
408
+ expect(spec).toBeDefined();
409
+ expect(spec.tags).toBeDefined();
410
+ expect(spec.tags?.length).toBe(1); // Only one tag should be generated
411
+
412
+ const section1Tag = spec.tags?.find((tag: any) => tag.name === 'section1');
413
+ expect(section1Tag).toBeDefined();
414
+ expect(section1Tag?.description).toBe('Description for section 1');
415
+
416
+ // section2 should not have a tag since it has no description
417
+ const section2Tag = spec.tags?.find((tag: any) => tag.name === 'section2');
418
+ expect(section2Tag).toBeUndefined();
419
+ });
319
420
  });
package/tests/setup.ts CHANGED
@@ -23,6 +23,33 @@ const simplePublicHandlers = {
23
23
  res.setHeader('X-Custom-Test', 'test-value');
24
24
  res.setHeader('X-Another-Header', 'another-value');
25
25
  res.respond(200, { message: "headers set" });
26
+ },
27
+ longpoll: async (req: any, res: any) => {
28
+ const sequence = req.params.sequence;
29
+ // Simulate long polling delay based on sequence
30
+ const delay = sequence * 100; // 100ms per sequence number
31
+ await new Promise(resolve => setTimeout(resolve, delay));
32
+ res.respond(200, {
33
+ sequence,
34
+ data: `object ${sequence}`,
35
+ timestamp: Date.now()
36
+ });
37
+ },
38
+ stream: async (req: any, res: any) => {
39
+ // Initialize SSE with proper headers
40
+ res.startSSE();
41
+
42
+ // Send SSE events with JSON data at intervals
43
+ await res.streamSSE('update', { sequence: 1, data: 'object 1' });
44
+ await new Promise(resolve => setTimeout(resolve, 100));
45
+
46
+ await res.streamSSE('update', { sequence: 2, data: 'object 2' });
47
+ await new Promise(resolve => setTimeout(resolve, 100));
48
+
49
+ await res.streamSSE('update', { sequence: 3, data: 'object 3' });
50
+
51
+ // Close the stream
52
+ res.endStream();
26
53
  }
27
54
  },
28
55
  status: {
@@ -121,6 +121,136 @@ describe.each([
121
121
  expect(anotherHeader).toBe('another-value');
122
122
  });
123
123
 
124
+ test('should handle long polling with delayed responses', async () => {
125
+ const startTime = Date.now();
126
+
127
+ // Test multiple sequences to simulate intervals
128
+ for (let seq = 1; seq <= 3; seq++) {
129
+ const result = await client.callApi('common', 'longpoll', {
130
+ params: { sequence: seq }
131
+ }, {
132
+ 200: ({ data }) => {
133
+ expect(data.sequence).toBe(seq);
134
+ expect(data.data).toBe(`object ${seq}`);
135
+ expect(typeof data.timestamp).toBe('number');
136
+ expect(data.timestamp).toBeGreaterThan(startTime);
137
+ return data;
138
+ },
139
+ 422: ({ error }) => {
140
+ throw new Error(`Validation error: ${JSON.stringify(error)}`);
141
+ }
142
+ });
143
+
144
+ expect(result.sequence).toBe(seq);
145
+ expect(result.data).toBe(`object ${seq}`);
146
+ }
147
+
148
+ // Verify that total time is at least the sum of delays (100ms * 1 + 100ms * 2 + 100ms * 3 = 600ms)
149
+ const elapsed = Date.now() - startTime;
150
+ expect(elapsed).toBeGreaterThanOrEqual(600); // Allow some tolerance for test execution
151
+ });
152
+
153
+ test('should handle SSE streaming with multiple JSON objects', async () => {
154
+ // Test SSE streaming by making a fetch request and parsing the response
155
+ const response = await fetch(`${baseUrl}/api/v1/public/stream`);
156
+ expect(response.status).toBe(200);
157
+ expect(response.headers.get('content-type')).toBe('text/event-stream');
158
+
159
+ const responseText = await response.text();
160
+
161
+ // Parse SSE events from the response
162
+ const events = responseText.trim().split('\n\n');
163
+ expect(events).toHaveLength(3);
164
+
165
+ // Verify each event
166
+ for (let i = 0; i < events.length; i++) {
167
+ const event = events[i];
168
+ const lines = event.split('\n');
169
+ expect(lines[0]).toBe('event: update');
170
+
171
+ const dataLine = lines.find(line => line.startsWith('data: '));
172
+ expect(dataLine).toBeDefined();
173
+
174
+ const data = JSON.parse(dataLine!.substring(6)); // Remove 'data: ' prefix
175
+ expect(data.sequence).toBe(i + 1);
176
+ expect(data.data).toBe(`object ${i + 1}`);
177
+ }
178
+ });
179
+
180
+ test('should handle SSE streaming incrementally', async () => {
181
+ const response = await fetch(`${baseUrl}/api/v1/public/stream`);
182
+ expect(response.status).toBe(200);
183
+ expect(response.headers.get('content-type')).toBe('text/event-stream');
184
+
185
+ return new Promise<void>((resolve, reject) => {
186
+ // node-fetch returns a Node.js stream, cast properly
187
+ const stream = response.body as any; // Node.js Readable stream
188
+ let buffer = '';
189
+ const events: any[] = [];
190
+
191
+ stream.on('data', (chunk: Buffer) => {
192
+ buffer += chunk.toString();
193
+
194
+ // Parse complete SSE events from buffer
195
+ const lines = buffer.split('\n');
196
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
197
+
198
+ let currentEvent = '';
199
+ for (const line of lines) {
200
+ if (line === '') {
201
+ // Empty line = end of event
202
+ if (currentEvent.trim()) {
203
+ const eventData = parseSSEEvent(currentEvent);
204
+ if (eventData) events.push(eventData);
205
+ }
206
+ currentEvent = '';
207
+ } else {
208
+ currentEvent += line + '\n';
209
+ }
210
+ }
211
+
212
+ // Check if we have all expected events
213
+ if (events.length >= 3) {
214
+ // For Node.js streams, just remove listeners to "close"
215
+ stream.removeAllListeners('data');
216
+ stream.removeAllListeners('error');
217
+ stream.removeAllListeners('end');
218
+
219
+ try {
220
+ expect(events).toHaveLength(3);
221
+ expect(events[0]).toEqual({ sequence: 1, data: 'object 1' });
222
+ expect(events[1]).toEqual({ sequence: 2, data: 'object 2' });
223
+ expect(events[2]).toEqual({ sequence: 3, data: 'object 3' });
224
+ resolve();
225
+ } catch (error) {
226
+ reject(error);
227
+ }
228
+ }
229
+ });
230
+
231
+ stream.on('error', reject);
232
+ stream.on('end', () => {
233
+ // If we get here without 3 events, test failed
234
+ if (events.length < 3) {
235
+ reject(new Error(`Expected 3 events, got ${events.length}`));
236
+ }
237
+ });
238
+ });
239
+ });
240
+
241
+ function parseSSEEvent(eventText: string): any | null {
242
+ const lines = eventText.split('\n');
243
+ let data = '';
244
+
245
+ for (const line of lines) {
246
+ if (line.startsWith('data: ')) {
247
+ data = line.substring(6);
248
+ }
249
+ }
250
+
251
+ return data ? JSON.parse(data) : null;
252
+ }
253
+
124
254
  test('generateUrl should return correct URL for ping', () => {
125
255
  const url = client.generateUrl('common', 'ping');
126
256
  expect(url).toBe(`${baseUrl}/api/v1/public/ping`);