ts-typed-api 0.2.20 → 0.2.22

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.
@@ -1,4 +1,5 @@
1
1
  import { z, ZodTypeAny } from 'zod';
2
+ import express from 'express';
2
3
  export declare class TsTypeMarker<T> {
3
4
  readonly _isTsTypeMarker = true;
4
5
  readonly _type: T;
@@ -17,6 +18,7 @@ declare const unifiedErrorSchema: z.ZodNullable<z.ZodArray<z.ZodObject<{
17
18
  message: z.ZodString;
18
19
  }, z.core.$strip>>>;
19
20
  export type UnifiedError = z.infer<typeof unifiedErrorSchema>;
21
+ export type ErrorHandler = (error: unknown, routeDefinition: RouteSchema, method: string, path: string, expressRes: express.Response) => boolean | void;
20
22
  declare const errorUnifiedResponseSchema: z.ZodObject<{
21
23
  error: z.ZodNullable<z.ZodArray<z.ZodObject<{
22
24
  field: z.ZodString;
package/dist/handler.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ApiDefinitionSchema } from "./definition";
1
+ import { ApiDefinitionSchema, ErrorHandler } from "./definition";
2
2
  import { createRouteHandler } from "./router";
3
3
  import { MiddlewareResponse } from "./object-handlers";
4
4
  import express from "express";
@@ -15,4 +15,4 @@ export type EndpointMiddleware<TDef extends ApiDefinitionSchema = ApiDefinitionS
15
15
  }[keyof TDef['endpoints']]) => void | Promise<void>;
16
16
  export declare function registerRouteHandlers<TDef extends ApiDefinitionSchema>(app: express.Express, apiDefinition: TDef, // Pass the actual API definition object
17
17
  routeHandlers: Array<SpecificRouteHandler<TDef>>, // Use the generic handler type
18
- middlewares?: EndpointMiddleware<TDef>[]): void;
18
+ middlewares?: EndpointMiddleware<TDef>[], errorHandler?: ErrorHandler): void;
package/dist/handler.js CHANGED
@@ -306,7 +306,7 @@ function createFileUploadMiddleware(config) {
306
306
  // Register route handlers with Express, now generic over TDef
307
307
  function registerRouteHandlers(app, apiDefinition, // Pass the actual API definition object
308
308
  routeHandlers, // Use the generic handler type
309
- middlewares) {
309
+ middlewares, errorHandler) {
310
310
  routeHandlers.forEach((specificHandlerIterationItem) => {
311
311
  const { domain, routeKey, handler } = specificHandlerIterationItem; // Use 'as any' for simplicity in destructuring union
312
312
  const currentDomain = domain;
@@ -343,28 +343,37 @@ middlewares) {
343
343
  ? routeDefinition.body.parse(expressReq.body)
344
344
  : expressReq.body;
345
345
  // Construct TypedRequest using TDef, currentDomain, currentRouteKey
346
- const finalTypedReq = {
347
- ...expressReq,
348
- params: parsedParams,
349
- query: parsedQuery,
350
- body: parsedBody,
351
- ctx: expressReq.ctx,
352
- headers: expressReq.headers,
353
- cookies: expressReq.cookies,
354
- ip: expressReq.ip,
355
- ips: expressReq.ips,
356
- hostname: expressReq.hostname,
357
- protocol: expressReq.protocol,
358
- secure: expressReq.secure,
359
- xhr: expressReq.xhr,
360
- fresh: expressReq.fresh,
361
- stale: expressReq.stale,
362
- subdomains: expressReq.subdomains,
363
- path: expressReq.path,
364
- originalUrl: expressReq.originalUrl,
365
- baseUrl: expressReq.baseUrl,
366
- url: expressReq.url,
367
- };
346
+ // Create a new object that inherits from expressReq to preserve prototype methods like .on()
347
+ const finalTypedReq = Object.create(expressReq, {
348
+ // Core parsed/validated properties
349
+ params: { value: parsedParams, writable: true, enumerable: true, configurable: true },
350
+ query: { value: parsedQuery, writable: true, enumerable: true, configurable: true },
351
+ body: { value: parsedBody, writable: true, enumerable: true, configurable: true },
352
+ ctx: { value: expressReq.ctx, writable: true, enumerable: true, configurable: true },
353
+ // Unified API for client disconnection
354
+ onClose: {
355
+ value: (callback) => expressReq.on('close', callback),
356
+ writable: false,
357
+ enumerable: true,
358
+ configurable: false
359
+ },
360
+ // Restore original Express request properties for full compatibility
361
+ headers: { value: expressReq.headers, writable: false, enumerable: true, configurable: false },
362
+ cookies: { value: expressReq.cookies, writable: false, enumerable: true, configurable: false },
363
+ ip: { value: expressReq.ip, writable: false, enumerable: true, configurable: false },
364
+ ips: { value: expressReq.ips, writable: false, enumerable: true, configurable: false },
365
+ hostname: { value: expressReq.hostname, writable: false, enumerable: true, configurable: false },
366
+ protocol: { value: expressReq.protocol, writable: false, enumerable: true, configurable: false },
367
+ secure: { value: expressReq.secure, writable: false, enumerable: true, configurable: false },
368
+ xhr: { value: expressReq.xhr, writable: false, enumerable: true, configurable: false },
369
+ fresh: { value: expressReq.fresh, writable: false, enumerable: true, configurable: false },
370
+ stale: { value: expressReq.stale, writable: false, enumerable: true, configurable: false },
371
+ subdomains: { value: expressReq.subdomains, writable: false, enumerable: true, configurable: false },
372
+ path: { value: expressReq.path, writable: false, enumerable: true, configurable: false },
373
+ originalUrl: { value: expressReq.originalUrl, writable: false, enumerable: true, configurable: false },
374
+ baseUrl: { value: expressReq.baseUrl, writable: false, enumerable: true, configurable: false },
375
+ url: { value: expressReq.url, writable: false, enumerable: true, configurable: false },
376
+ });
368
377
  // Augment expressRes with the .respond and .setHeader methods, using TDef
369
378
  const typedExpressRes = expressRes;
370
379
  typedExpressRes.respond = (status, dataForResponse) => {
@@ -456,6 +465,14 @@ middlewares) {
456
465
  await specificHandlerFn(finalTypedReq, typedExpressRes);
457
466
  }
458
467
  catch (error) {
468
+ // Check if custom error handler is provided
469
+ if (errorHandler) {
470
+ const handled = errorHandler(error, routeDefinition, method, path, expressRes);
471
+ if (handled) {
472
+ return; // Error was handled by custom handler
473
+ }
474
+ }
475
+ // Default error handling
459
476
  if (error instanceof zod_1.z.ZodError) {
460
477
  const mappedErrors = error.issues.map(err => {
461
478
  let errorType = 'general';
@@ -389,7 +389,9 @@ function registerHonoRouteHandlers(app, apiDefinition, routeHandlers, middleware
389
389
  ip: c.req.header('CF-Connecting-IP') || '127.0.0.1',
390
390
  method: c.req.method,
391
391
  path: c.req.path,
392
- originalUrl: c.req.url
392
+ originalUrl: c.req.url,
393
+ // Hono doesn't support long-lived connections, so onClose is undefined
394
+ onClose: undefined
393
395
  };
394
396
  const fakeRes = {
395
397
  respond: c.respond,
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { ApiClient, FetchHttpClientAdapter } from './client';
2
2
  export { generateOpenApiSpec } from './openapi';
3
3
  export { generateOpenApiSpec as generateOpenApiSpec2 } from './openapi-self';
4
- export { CreateApiDefinition, CreateResponses, ApiDefinitionSchema } from './definition';
4
+ export { CreateApiDefinition, CreateResponses, ApiDefinitionSchema, ErrorHandler } from './definition';
5
5
  export { RegisterHandlers, EndpointMiddleware, UniversalEndpointMiddleware, SimpleMiddleware, EndpointInfo, MiddlewareResponse } from './object-handlers';
6
6
  export { File as UploadedFile } from './router';
7
7
  export { z as ZodSchema } from 'zod';
@@ -31,6 +31,6 @@ export type ObjectHandlers<TDef extends ApiDefinitionSchema, Ctx extends Record<
31
31
  [TRouteKey in keyof TDef['endpoints'][TDomain]]: HandlerFunction<TDef, TDomain, TRouteKey, Ctx>;
32
32
  };
33
33
  };
34
- export declare function RegisterHandlers<TDef extends ApiDefinitionSchema, Ctx extends Record<string, any> = Record<string, any>>(app: express.Express, apiDefinition: TDef, objectHandlers: ObjectHandlers<TDef, Ctx>, middlewares?: AnyMiddleware<TDef>[]): void;
34
+ export declare function RegisterHandlers<TDef extends ApiDefinitionSchema, Ctx extends Record<string, any> = Record<string, any>>(app: express.Express, apiDefinition: TDef, objectHandlers: ObjectHandlers<TDef, Ctx>, middlewares?: AnyMiddleware<TDef>[], errorHandler?: import('./definition').ErrorHandler): void;
35
35
  export declare function makeObjectHandlerRegistrar<TDef extends ApiDefinitionSchema>(apiDefinition: TDef): (app: express.Express, objectHandlers: ObjectHandlers<TDef>, middlewares?: EndpointMiddleware<TDef>[]) => void;
36
36
  export {};
@@ -27,7 +27,7 @@ function transformObjectHandlersToArray(objectHandlers) {
27
27
  return handlerArray;
28
28
  }
29
29
  // Main utility function that registers object-based handlers
30
- function RegisterHandlers(app, apiDefinition, objectHandlers, middlewares) {
30
+ function RegisterHandlers(app, apiDefinition, objectHandlers, middlewares, errorHandler) {
31
31
  const handlerArray = transformObjectHandlersToArray(objectHandlers);
32
32
  // Convert AnyMiddleware to EndpointMiddleware by checking function arity
33
33
  const endpointMiddlewares = middlewares?.map(middleware => {
@@ -43,7 +43,7 @@ function RegisterHandlers(app, apiDefinition, objectHandlers, middlewares) {
43
43
  });
44
44
  }
45
45
  }) || [];
46
- (0, handler_1.registerRouteHandlers)(app, apiDefinition, handlerArray, endpointMiddlewares);
46
+ (0, handler_1.registerRouteHandlers)(app, apiDefinition, handlerArray, endpointMiddlewares, errorHandler);
47
47
  }
48
48
  // Factory function to create a typed handler registrar for a specific API definition
49
49
  function makeObjectHandlerRegistrar(apiDefinition) {
package/dist/router.d.ts CHANGED
@@ -9,6 +9,7 @@ P extends ApiParams<TDef, TDomain, TRouteKey> = ApiParams<TDef, TDomain, TRouteK
9
9
  [fieldname: string]: File[];
10
10
  };
11
11
  ctx?: Ctx;
12
+ onClose?: (callback: () => void) => void;
12
13
  };
13
14
  type ResponseDataForStatus<TDef extends ApiDefinitionSchema, TDomain extends keyof TDef['endpoints'], TRouteName extends keyof TDef['endpoints'][TDomain], TStatus extends keyof TDef['endpoints'][TDomain][TRouteName]['responses'] & number> = InferDataFromUnifiedResponse<TDef['endpoints'][TDomain][TRouteName]['responses'][TStatus]>;
14
15
  type RespondFunction<TDef extends ApiDefinitionSchema, TDomain extends keyof TDef['endpoints'], TRouteName extends keyof TDef['endpoints'][TDomain]> = <TStatusLocal extends keyof TDef['endpoints'][TDomain][TRouteName]['responses'] & number>(status: TStatusLocal, data: ResponseDataForStatus<TDef, TDomain, TRouteName, TStatusLocal>) => void;
@@ -77,6 +77,20 @@ export const PublicApiDefinition = CreateApiDefinition({
77
77
  200: z.string() // Raw SSE data
78
78
  })
79
79
  },
80
+ disconnectTest: {
81
+ method: 'GET',
82
+ path: '/disconnect-test',
83
+ description: 'Endpoint for testing client disconnection handling',
84
+ query: z.object({
85
+ delay: z.number().int().min(100).max(5000).optional().default(1000)
86
+ }),
87
+ responses: CreateResponses({
88
+ 200: z.object({
89
+ message: z.string(),
90
+ disconnected: z.boolean()
91
+ }),
92
+ })
93
+ },
80
94
  }
81
95
  }
82
96
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-typed-api",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
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
@@ -1,4 +1,5 @@
1
1
  import { z, ZodTypeAny, ZodType } from 'zod';
2
+ import express from 'express';
2
3
 
3
4
  // Marker class for raw TypeScript types
4
5
  export class TsTypeMarker<T> {
@@ -30,6 +31,15 @@ const errorDetailSchema = z.object({
30
31
  const unifiedErrorSchema = z.array(errorDetailSchema).nullable(); // Nullable if no errors
31
32
  export type UnifiedError = z.infer<typeof unifiedErrorSchema>;
32
33
 
34
+ // Type for custom error handler function
35
+ export type ErrorHandler = (
36
+ error: unknown,
37
+ routeDefinition: RouteSchema,
38
+ method: string,
39
+ path: string,
40
+ expressRes: express.Response
41
+ ) => boolean | void; // Return true if handled, void/null to use default
42
+
33
43
  // Helper function to create the success-specific unified response schema
34
44
  // This wraps the original data schema with a 'data' field and sets 'error' to null.
35
45
  function createSuccessUnifiedResponseSchema<TData extends ZodTypeAny>(dataSchema: TData) {
package/src/handler.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { ApiDefinitionSchema, RouteSchema, UnifiedError, FileUploadConfig } from "./definition";
2
+ import { ApiDefinitionSchema, RouteSchema, UnifiedError, FileUploadConfig, ErrorHandler } from "./definition";
3
3
  import { createRouteHandler, TypedRequest, TypedResponse } from "./router";
4
4
  import { MiddlewareResponse } from "./object-handlers";
5
5
  import express from "express";
@@ -350,7 +350,8 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
350
350
  app: express.Express,
351
351
  apiDefinition: TDef, // Pass the actual API definition object
352
352
  routeHandlers: Array<SpecificRouteHandler<TDef>>, // Use the generic handler type
353
- middlewares?: EndpointMiddleware<TDef>[]
353
+ middlewares?: EndpointMiddleware<TDef>[],
354
+ errorHandler?: ErrorHandler
354
355
  ) {
355
356
  routeHandlers.forEach((specificHandlerIterationItem) => {
356
357
  const { domain, routeKey, handler } = specificHandlerIterationItem as any; // Use 'as any' for simplicity in destructuring union
@@ -401,28 +402,39 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
401
402
  : expressReq.body;
402
403
 
403
404
  // Construct TypedRequest using TDef, currentDomain, currentRouteKey
404
- const finalTypedReq = {
405
- ...expressReq,
406
- params: parsedParams,
407
- query: parsedQuery,
408
- body: parsedBody,
409
- ctx: (expressReq as any).ctx,
410
- headers: expressReq.headers,
411
- cookies: expressReq.cookies,
412
- ip: expressReq.ip,
413
- ips: expressReq.ips,
414
- hostname: expressReq.hostname,
415
- protocol: expressReq.protocol,
416
- secure: expressReq.secure,
417
- xhr: expressReq.xhr,
418
- fresh: expressReq.fresh,
419
- stale: expressReq.stale,
420
- subdomains: expressReq.subdomains,
421
- path: expressReq.path,
422
- originalUrl: expressReq.originalUrl,
423
- baseUrl: expressReq.baseUrl,
424
- url: expressReq.url,
425
- } as TypedRequest<TDef, typeof currentDomain, typeof currentRouteKey>;
405
+ // Create a new object that inherits from expressReq to preserve prototype methods like .on()
406
+ const finalTypedReq = Object.create(expressReq, {
407
+ // Core parsed/validated properties
408
+ params: { value: parsedParams, writable: true, enumerable: true, configurable: true },
409
+ query: { value: parsedQuery, writable: true, enumerable: true, configurable: true },
410
+ body: { value: parsedBody, writable: true, enumerable: true, configurable: true },
411
+ ctx: { value: (expressReq as any).ctx, writable: true, enumerable: true, configurable: true },
412
+
413
+ // Unified API for client disconnection
414
+ onClose: {
415
+ value: (callback: () => void) => expressReq.on('close', callback),
416
+ writable: false,
417
+ enumerable: true,
418
+ configurable: false
419
+ },
420
+
421
+ // Restore original Express request properties for full compatibility
422
+ headers: { value: expressReq.headers, writable: false, enumerable: true, configurable: false },
423
+ cookies: { value: expressReq.cookies, writable: false, enumerable: true, configurable: false },
424
+ ip: { value: expressReq.ip, writable: false, enumerable: true, configurable: false },
425
+ ips: { value: expressReq.ips, writable: false, enumerable: true, configurable: false },
426
+ hostname: { value: expressReq.hostname, writable: false, enumerable: true, configurable: false },
427
+ protocol: { value: expressReq.protocol, writable: false, enumerable: true, configurable: false },
428
+ secure: { value: expressReq.secure, writable: false, enumerable: true, configurable: false },
429
+ xhr: { value: expressReq.xhr, writable: false, enumerable: true, configurable: false },
430
+ fresh: { value: expressReq.fresh, writable: false, enumerable: true, configurable: false },
431
+ stale: { value: expressReq.stale, writable: false, enumerable: true, configurable: false },
432
+ subdomains: { value: expressReq.subdomains, writable: false, enumerable: true, configurable: false },
433
+ path: { value: expressReq.path, writable: false, enumerable: true, configurable: false },
434
+ originalUrl: { value: expressReq.originalUrl, writable: false, enumerable: true, configurable: false },
435
+ baseUrl: { value: expressReq.baseUrl, writable: false, enumerable: true, configurable: false },
436
+ url: { value: expressReq.url, writable: false, enumerable: true, configurable: false },
437
+ }) as TypedRequest<TDef, typeof currentDomain, typeof currentRouteKey>;
426
438
 
427
439
  // Augment expressRes with the .respond and .setHeader methods, using TDef
428
440
  const typedExpressRes = expressRes as TypedResponse<TDef, typeof currentDomain, typeof currentRouteKey>;
@@ -530,6 +542,15 @@ export function registerRouteHandlers<TDef extends ApiDefinitionSchema>(
530
542
  await specificHandlerFn(finalTypedReq, typedExpressRes);
531
543
 
532
544
  } catch (error) {
545
+ // Check if custom error handler is provided
546
+ if (errorHandler) {
547
+ const handled = errorHandler(error, routeDefinition, method, path, expressRes);
548
+ if (handled) {
549
+ return; // Error was handled by custom handler
550
+ }
551
+ }
552
+
553
+ // Default error handling
533
554
  if (error instanceof z.ZodError) {
534
555
  const mappedErrors: UnifiedError = error.issues.map(err => {
535
556
  let errorType: 'param' | 'query' | 'body' | 'general' = 'general';
@@ -471,7 +471,9 @@ export function registerHonoRouteHandlers<
471
471
  ip: c.req.header('CF-Connecting-IP') || '127.0.0.1',
472
472
  method: c.req.method,
473
473
  path: c.req.path,
474
- originalUrl: c.req.url
474
+ originalUrl: c.req.url,
475
+ // Hono doesn't support long-lived connections, so onClose is undefined
476
+ onClose: undefined
475
477
  } as TypedRequest<TDef, typeof currentDomain, typeof currentRouteKey>;
476
478
 
477
479
  const fakeRes = {
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { ApiClient, FetchHttpClientAdapter } from './client';
2
2
  export { generateOpenApiSpec } from './openapi'
3
3
  export { generateOpenApiSpec as generateOpenApiSpec2 } from './openapi-self'
4
- export { CreateApiDefinition, CreateResponses, ApiDefinitionSchema } from './definition';
4
+ export { CreateApiDefinition, CreateResponses, ApiDefinitionSchema, ErrorHandler } from './definition';
5
5
  export { RegisterHandlers, EndpointMiddleware, UniversalEndpointMiddleware, SimpleMiddleware, EndpointInfo, MiddlewareResponse } from './object-handlers';
6
6
  export { File as UploadedFile } from './router';
7
7
  export { z as ZodSchema } from 'zod';
@@ -125,7 +125,8 @@ export function RegisterHandlers<
125
125
  app: express.Express,
126
126
  apiDefinition: TDef,
127
127
  objectHandlers: ObjectHandlers<TDef, Ctx>,
128
- middlewares?: AnyMiddleware<TDef>[]
128
+ middlewares?: AnyMiddleware<TDef>[],
129
+ errorHandler?: import('./definition').ErrorHandler
129
130
  ): void {
130
131
  const handlerArray = transformObjectHandlersToArray(objectHandlers);
131
132
 
@@ -143,7 +144,7 @@ export function RegisterHandlers<
143
144
  }
144
145
  }) || [];
145
146
 
146
- registerRouteHandlers(app, apiDefinition, handlerArray, endpointMiddlewares);
147
+ registerRouteHandlers(app, apiDefinition, handlerArray, endpointMiddlewares, errorHandler);
147
148
  }
148
149
 
149
150
  // Factory function to create a typed handler registrar for a specific API definition
package/src/router.ts CHANGED
@@ -27,6 +27,8 @@ export type TypedRequest<
27
27
  files?: File[] | { [fieldname: string]: File[] };
28
28
  // Add typed context object for carrying data between middlewares and handlers
29
29
  ctx?: Ctx;
30
+ // Unified client disconnection handler (Express only, undefined for Hono)
31
+ onClose?: (callback: () => void) => void;
30
32
  }
31
33
 
32
34
  // --- Enhanced TypedResponse with res.respond, now generic over TDef ---
@@ -0,0 +1,146 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
2
+ import express from 'express';
3
+ import { Server } from 'http';
4
+ import { RegisterHandlers, CreateApiDefinition, CreateResponses, ErrorHandler } from '../src';
5
+ import { z } from 'zod';
6
+
7
+ const ErrorHandlerTestApiDefinition = CreateApiDefinition({
8
+ prefix: '/api',
9
+ endpoints: {
10
+ test: {
11
+ success: {
12
+ method: 'GET',
13
+ path: '/success',
14
+ responses: CreateResponses({
15
+ 200: z.object({ message: z.string() })
16
+ })
17
+ },
18
+ validationError: {
19
+ method: 'POST',
20
+ path: '/validation-error',
21
+ body: z.object({
22
+ name: z.string().min(3, 'Name must be at least 3 characters'),
23
+ email: z.string().email('Invalid email format')
24
+ }),
25
+ responses: CreateResponses({
26
+ 200: z.object({ message: z.string() })
27
+ })
28
+ },
29
+ customError: {
30
+ method: 'GET',
31
+ path: '/custom-error',
32
+ responses: CreateResponses({
33
+ 200: z.object({ message: z.string() })
34
+ })
35
+ }
36
+ }
37
+ }
38
+ });
39
+
40
+ const testHandlers = {
41
+ test: {
42
+ success: async (req: any, res: any) => {
43
+ res.respond(200, { message: 'success' });
44
+ },
45
+ validationError: async () => {
46
+ // This will cause a Zod validation error - validation happens before handler
47
+ throw new Error('Should not reach handler');
48
+ },
49
+ customError: async (req: any, res: any) => {
50
+ // Throw a custom error to test error handler
51
+ throw new Error('Custom application error');
52
+ }
53
+ }
54
+ };
55
+
56
+ describe('Error Handler Tests', () => {
57
+ let server: Server;
58
+ let port: number;
59
+
60
+ beforeAll(async () => {
61
+ port = 3009; // Use a different port
62
+ const app = express();
63
+ app.use(express.json());
64
+
65
+ // Custom error handler
66
+ const customErrorHandler: ErrorHandler = (error, routeDefinition, method, path, expressRes) => {
67
+ if (error instanceof z.ZodError) {
68
+ // Custom Zod error handling
69
+ const customErrors = error.issues.map(issue => ({
70
+ field: issue.path.join('.'),
71
+ message: `Custom: ${issue.message}`,
72
+ type: issue.path[0] === 'body' ? 'body' : issue.path[0] === 'query' ? 'query' : 'param'
73
+ }));
74
+
75
+ expressRes.status(422).json({
76
+ data: null,
77
+ error: customErrors,
78
+ customHandled: true
79
+ });
80
+ return true; // Handled
81
+ } else if (error instanceof Error && error.message === 'Custom application error') {
82
+ // Custom application error handling
83
+ expressRes.status(400).json({
84
+ data: null,
85
+ error: [{ field: 'general', message: 'Custom error message', type: 'general' }],
86
+ customHandled: true
87
+ });
88
+ return true; // Handled
89
+ }
90
+
91
+ return false; // Not handled, use default
92
+ };
93
+
94
+ RegisterHandlers(app, ErrorHandlerTestApiDefinition, testHandlers, undefined, customErrorHandler);
95
+
96
+ server = app.listen(port);
97
+ });
98
+
99
+ afterAll(async () => {
100
+ if (server) {
101
+ server.close();
102
+ }
103
+ });
104
+
105
+ test('should handle success response normally', async () => {
106
+ const response = await fetch(`http://localhost:${port}/api/success`);
107
+ expect(response.status).toBe(200);
108
+ const data = await response.json();
109
+ expect(data).toEqual({
110
+ data: { message: 'success' }
111
+ });
112
+ });
113
+
114
+ test('should use custom error handler for Zod validation errors', async () => {
115
+ const response = await fetch(`http://localhost:${port}/api/validation-error`, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({ name: 'A', email: 'invalid-email' })
119
+ });
120
+ expect(response.status).toBe(422);
121
+ const data = await response.json() as any;
122
+ expect(data.customHandled).toBe(true);
123
+ expect(data.error).toBeDefined();
124
+ expect(data.error.length).toBeGreaterThan(0);
125
+ expect(data.error[0].message).toContain('Custom:');
126
+ });
127
+
128
+ test('should use custom error handler for application errors', async () => {
129
+ const response = await fetch(`http://localhost:${port}/api/custom-error`);
130
+ expect(response.status).toBe(400);
131
+ const data = await response.json() as any;
132
+ expect(data.customHandled).toBe(true);
133
+ expect(data.error).toEqual([{
134
+ field: 'general',
135
+ message: 'Custom error message',
136
+ type: 'general'
137
+ }]);
138
+ });
139
+
140
+ test('should fall back to default error handler for unhandled errors', async () => {
141
+ // Test with a route that doesn't exist to trigger a different error
142
+ const response = await fetch(`http://localhost:${port}/api/nonexistent`);
143
+ expect(response.status).toBe(404);
144
+ // This would be handled by Express default 404 handler, not our error handler
145
+ });
146
+ });
package/tests/setup.ts CHANGED
@@ -50,6 +50,25 @@ const simplePublicHandlers = {
50
50
 
51
51
  // Close the stream
52
52
  res.endStream();
53
+ },
54
+ disconnectTest: async (req: any, res: any) => {
55
+ const delay = req.query?.delay || 1000;
56
+ let disconnected = false;
57
+
58
+ // Set up disconnection handler using unified API
59
+ req.onClose?.(() => {
60
+ console.log('Client disconnected during disconnect-test');
61
+ disconnected = true;
62
+ });
63
+
64
+ // Simulate long-running operation
65
+ await new Promise(resolve => setTimeout(resolve, delay));
66
+
67
+ // Check if client disconnected during the delay
68
+ res.respond(200, {
69
+ message: disconnected ? 'Client disconnected' : 'Operation completed',
70
+ disconnected
71
+ });
53
72
  }
54
73
  },
55
74
  status: {
@@ -251,6 +251,62 @@ describe.each([
251
251
  return data ? JSON.parse(data) : null;
252
252
  }
253
253
 
254
+ test('should handle client disconnection with req.onClose', async () => {
255
+ // Test that the endpoint works normally when client doesn't disconnect
256
+ const result = await client.callApi('common', 'disconnectTest', {
257
+ query: { delay: 100 }
258
+ }, {
259
+ 200: ({ data }) => {
260
+ expect(data.message).toBe('Operation completed');
261
+ expect(data.disconnected).toBe(false);
262
+ return data;
263
+ },
264
+ 422: ({ error }) => {
265
+ throw new Error(`Validation error: ${JSON.stringify(error)}`);
266
+ }
267
+ });
268
+
269
+ expect(result.disconnected).toBe(false);
270
+ });
271
+
272
+ test('should detect client disconnection during request processing', async () => {
273
+ if (serverName === 'Hono') {
274
+ // Hono doesn't support disconnection detection, so onClose is undefined
275
+ // The endpoint completes successfully but doesn't detect disconnections
276
+ const response = await fetch(`${baseUrl}/api/v1/public/disconnect-test?delay=100`);
277
+ expect(response.status).toBe(200);
278
+ const data = await response.json();
279
+ expect(data.data.disconnected).toBe(false); // No disconnection detected
280
+ return;
281
+ }
282
+
283
+ // Use fetch directly with AbortController to simulate client disconnection
284
+ const controller = new AbortController();
285
+ const signal = controller.signal;
286
+
287
+ // Start the request
288
+ const fetchPromise = fetch(`${baseUrl}/api/v1/public/disconnect-test?delay=500`, {
289
+ signal,
290
+ headers: { 'Accept': 'application/json' }
291
+ });
292
+
293
+ // Abort the request after a short delay (before the server finishes)
294
+ setTimeout(() => {
295
+ controller.abort();
296
+ }, 200);
297
+
298
+ // The request should be aborted (node-fetch uses different error messages)
299
+ await expect(fetchPromise).rejects.toThrow(/aborted/i);
300
+
301
+ // Give the server a moment to process the disconnection
302
+ await new Promise(resolve => setTimeout(resolve, 100));
303
+
304
+ // Note: In a real test environment, we would need a way to verify
305
+ // that the close handler was called on the server side.
306
+ // For this test, we're mainly verifying that the endpoint exists
307
+ // and that client disconnection doesn't crash the server.
308
+ });
309
+
254
310
  test('generateUrl should return correct URL for ping', () => {
255
311
  const url = client.generateUrl('common', 'ping');
256
312
  expect(url).toBe(`${baseUrl}/api/v1/public/ping`);