@web-ts-toolkit/express-response-handler 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms.txt CHANGED
@@ -44,6 +44,12 @@ import { Created, NoContent } from '@web-ts-toolkit/express-response-handler/res
44
44
  - default export is the handler instance; named exports (`handleResponse`, `HttpResponse`, `createHandler`, `ErrorFormats`) are also available
45
45
  - return plain values for `200 OK`; use `HttpResponse.*` wrappers for other status codes
46
46
  - `ErrorFormats` selects between `simple`, `aip193`, and `rfc9457` error payload shapes
47
+ - unexpected non-HTTP errors return `500` with `Internal Server Error`; raw thrown messages are kept for hooks/logging and are not sent to clients by default
48
+ - only finite integer `4xx` and `5xx` status codes are serialized as HTTP errors; invalid status values are rejected before headers are written
49
+ - hooks (`preJson`, `postJson`, `preError`, `postError`) are observational `void | Promise<void>` side effects; returned values never transform payloads
50
+ - `postJson` and `postError` run after successful HTTP `finish`; they do not run on client `close`, failed serialization, or `undefined` handler returns
51
+ - pre-hook failures go through the normal error response path; post-hook failures are passed to Express with `next(err)` after the response has finished
52
+ - the default export is a mutable process-wide singleton; use `createHandler()` for isolated hook and error-provider state
47
53
  - response wrappers (`Created`, `NoContent`, `CSVResponse`, etc.) are also exported from the root entrypoint, but the subpaths give tree-shakeable access
48
54
 
49
55
  ## Pointers
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/express-response-handler",
3
3
  "description": "FastAPI-style return-value response handling for Express",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-response-handler",
5
- "version": "0.32.0",
5
+ "version": "0.33.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -47,12 +47,16 @@
47
47
  }
48
48
  },
49
49
  "engines": {
50
- "node": ">=20"
50
+ "node": ">=22"
51
51
  },
52
52
  "dependencies": {
53
53
  "@fast-csv/format": "^5.0.7",
54
- "@web-ts-toolkit/http-errors": "0.32.0",
55
- "@web-ts-toolkit/utils": "0.32.0"
54
+ "@types/express-serve-static-core": "^5.1.1",
55
+ "@web-ts-toolkit/http-errors": "0.33.0",
56
+ "@web-ts-toolkit/utils": "0.33.0"
57
+ },
58
+ "peerDependencies": {
59
+ "express": "^5.0.0"
56
60
  },
57
61
  "files": [
58
62
  "**/*",
@@ -1,17 +1,18 @@
1
+ import { ParamsDictionary, Query, Request, Response, NextFunction as NextFunction$1, RequestHandler } from 'express-serve-static-core';
1
2
  import { HttpResponseHelpers } from './http-response.mjs';
2
3
  import { ErrorFormats } from './error-formats.mjs';
4
+ import './responses/csv.mjs';
3
5
  import '@web-ts-toolkit/http-errors';
4
6
  import './responses/success.mjs';
5
7
  import './responses/index.mjs';
6
- import './responses/csv.mjs';
7
8
 
8
9
  type ErrorMessageResult = string | Record<string, unknown>;
9
10
  type ErrorMessageProvider = (error: unknown) => ErrorMessageResult;
10
11
  type ErrorFormat = (typeof ErrorFormats)[keyof typeof ErrorFormats];
11
- type MaybePromise<T> = T | Promise<T>;
12
- type Hook = (value: unknown) => unknown;
13
- type AsyncHook = (value: unknown) => Promise<unknown>;
14
- type NextFunction = (error?: unknown) => void;
12
+ type MaybePromise<T> = T | PromiseLike<T>;
13
+ type Hook = (value: unknown) => void | PromiseLike<void>;
14
+ type AsyncHook = (value: unknown) => Promise<void>;
15
+ type NextFunction = NextFunction$1;
15
16
  type ExpressResponseHandlerOptions = {
16
17
  errorFormat?: ErrorFormat;
17
18
  errorDomain?: string;
@@ -23,18 +24,15 @@ type ResponseLike = {
23
24
  json(data: unknown): unknown;
24
25
  send(data: unknown): unknown;
25
26
  set(name: string, value: string): unknown;
27
+ once?(event: 'finish', listener: () => void): unknown;
26
28
  end(): void;
27
29
  };
28
- type EventState = {
29
- canceled: boolean;
30
- nextError: unknown;
31
- };
32
- type MiddlewareFunction<Request = unknown, Response extends ResponseLike = ResponseLike, Return = unknown> = (req: Request, res: Response, next: NextFunction) => MaybePromise<Return>;
33
- type RouterFunction<Request = unknown, Response extends ResponseLike = ResponseLike> = (req: Request, res: Response, next: NextFunction) => void;
30
+ type MiddlewareFunction<Params = ParamsDictionary, ResBody = unknown, ReqBody = unknown, ReqQuery = Query, Locals extends Record<string, unknown> = Record<string, unknown>, Return = unknown> = (req: Request<Params, ResBody, ReqBody, ReqQuery, Locals>, res: Response<ResBody, Locals>, next: NextFunction) => MaybePromise<Return>;
31
+ type RouterFunction<Params = ParamsDictionary, ResBody = unknown, ReqBody = unknown, ReqQuery = Query, Locals extends Record<string, unknown> = Record<string, unknown>> = RequestHandler<Params, ResBody, ReqBody, ReqQuery, Locals>;
34
32
  type HandleResponse = {
35
- <Request = unknown, Response extends ResponseLike = ResponseLike, Return = unknown>(fn: MiddlewareFunction<Request, Response, Return>): RouterFunction<Request, Response>;
36
- <Request = unknown, Response extends ResponseLike = ResponseLike, Return = unknown>(...fns: Array<MiddlewareFunction<Request, Response, Return>>): Array<RouterFunction<Request, Response>>;
37
- <Request = unknown, Response extends ResponseLike = ResponseLike, Return = unknown>(fns: Array<MiddlewareFunction<Request, Response, Return>>): Array<RouterFunction<Request, Response>>;
33
+ <Params = ParamsDictionary, ResBody = unknown, ReqBody = unknown, ReqQuery = Query, Locals extends Record<string, unknown> = Record<string, unknown>, Return = unknown>(fn: MiddlewareFunction<Params, ResBody, ReqBody, ReqQuery, Locals, Return>): RouterFunction<Params, ResBody, ReqBody, ReqQuery, Locals>;
34
+ <Params = ParamsDictionary, ResBody = unknown, ReqBody = unknown, ReqQuery = Query, Locals extends Record<string, unknown> = Record<string, unknown>, Return = unknown>(...fns: Array<MiddlewareFunction<Params, ResBody, ReqBody, ReqQuery, Locals, Return>>): Array<RouterFunction<Params, ResBody, ReqBody, ReqQuery, Locals>>;
35
+ <Params = ParamsDictionary, ResBody = unknown, ReqBody = unknown, ReqQuery = Query, Locals extends Record<string, unknown> = Record<string, unknown>, Return = unknown>(fns: Array<MiddlewareFunction<Params, ResBody, ReqBody, ReqQuery, Locals, Return>>): Array<RouterFunction<Params, ResBody, ReqBody, ReqQuery, Locals>>;
38
36
  };
39
37
  type ErrorWithPayload = {
40
38
  statusCode?: number;
@@ -53,8 +51,6 @@ type ErrorWithPayload = {
53
51
  type CreateHandler = (options?: ExpressResponseHandlerOptions) => ExpressResponseHandler;
54
52
  type ExpressResponseHandler = {
55
53
  handleResponse: HandleResponse;
56
- handleResult: (res: ResponseLike, result: unknown, event: EventState) => void;
57
- handlePromise: (res: ResponseLike, promise: Promise<unknown>, event: EventState) => void;
58
54
  HttpResponse: HttpResponseHelpers;
59
55
  createHandler: CreateHandler;
60
56
  errorMessageProvider: ErrorMessageProvider;
@@ -64,4 +60,4 @@ type ExpressResponseHandler = {
64
60
  postError: Hook | null;
65
61
  };
66
62
 
67
- export { type AsyncHook, type CreateHandler, type ErrorFormat, ErrorFormats, type ErrorMessageProvider, type ErrorMessageResult, type ErrorWithPayload, type EventState, type ExpressResponseHandler, type ExpressResponseHandlerOptions, type HandleResponse, type Hook, HttpResponseHelpers, type MaybePromise, type MiddlewareFunction, type NextFunction, type ResponseLike, type RouterFunction };
63
+ export { type AsyncHook, type CreateHandler, type ErrorFormat, ErrorFormats, type ErrorMessageProvider, type ErrorMessageResult, type ErrorWithPayload, type ExpressResponseHandler, type ExpressResponseHandlerOptions, type HandleResponse, type Hook, HttpResponseHelpers, type MaybePromise, type MiddlewareFunction, type NextFunction, type ResponseLike, type RouterFunction };
package/public-types.d.ts CHANGED
@@ -1,17 +1,18 @@
1
+ import { ParamsDictionary, Query, Request, Response, NextFunction as NextFunction$1, RequestHandler } from 'express-serve-static-core';
1
2
  import { HttpResponseHelpers } from './http-response.js';
2
3
  import { ErrorFormats } from './error-formats.js';
4
+ import './responses/csv.js';
3
5
  import '@web-ts-toolkit/http-errors';
4
6
  import './responses/success.js';
5
7
  import './responses/index.js';
6
- import './responses/csv.js';
7
8
 
8
9
  type ErrorMessageResult = string | Record<string, unknown>;
9
10
  type ErrorMessageProvider = (error: unknown) => ErrorMessageResult;
10
11
  type ErrorFormat = (typeof ErrorFormats)[keyof typeof ErrorFormats];
11
- type MaybePromise<T> = T | Promise<T>;
12
- type Hook = (value: unknown) => unknown;
13
- type AsyncHook = (value: unknown) => Promise<unknown>;
14
- type NextFunction = (error?: unknown) => void;
12
+ type MaybePromise<T> = T | PromiseLike<T>;
13
+ type Hook = (value: unknown) => void | PromiseLike<void>;
14
+ type AsyncHook = (value: unknown) => Promise<void>;
15
+ type NextFunction = NextFunction$1;
15
16
  type ExpressResponseHandlerOptions = {
16
17
  errorFormat?: ErrorFormat;
17
18
  errorDomain?: string;
@@ -23,18 +24,15 @@ type ResponseLike = {
23
24
  json(data: unknown): unknown;
24
25
  send(data: unknown): unknown;
25
26
  set(name: string, value: string): unknown;
27
+ once?(event: 'finish', listener: () => void): unknown;
26
28
  end(): void;
27
29
  };
28
- type EventState = {
29
- canceled: boolean;
30
- nextError: unknown;
31
- };
32
- type MiddlewareFunction<Request = unknown, Response extends ResponseLike = ResponseLike, Return = unknown> = (req: Request, res: Response, next: NextFunction) => MaybePromise<Return>;
33
- type RouterFunction<Request = unknown, Response extends ResponseLike = ResponseLike> = (req: Request, res: Response, next: NextFunction) => void;
30
+ type MiddlewareFunction<Params = ParamsDictionary, ResBody = unknown, ReqBody = unknown, ReqQuery = Query, Locals extends Record<string, unknown> = Record<string, unknown>, Return = unknown> = (req: Request<Params, ResBody, ReqBody, ReqQuery, Locals>, res: Response<ResBody, Locals>, next: NextFunction) => MaybePromise<Return>;
31
+ type RouterFunction<Params = ParamsDictionary, ResBody = unknown, ReqBody = unknown, ReqQuery = Query, Locals extends Record<string, unknown> = Record<string, unknown>> = RequestHandler<Params, ResBody, ReqBody, ReqQuery, Locals>;
34
32
  type HandleResponse = {
35
- <Request = unknown, Response extends ResponseLike = ResponseLike, Return = unknown>(fn: MiddlewareFunction<Request, Response, Return>): RouterFunction<Request, Response>;
36
- <Request = unknown, Response extends ResponseLike = ResponseLike, Return = unknown>(...fns: Array<MiddlewareFunction<Request, Response, Return>>): Array<RouterFunction<Request, Response>>;
37
- <Request = unknown, Response extends ResponseLike = ResponseLike, Return = unknown>(fns: Array<MiddlewareFunction<Request, Response, Return>>): Array<RouterFunction<Request, Response>>;
33
+ <Params = ParamsDictionary, ResBody = unknown, ReqBody = unknown, ReqQuery = Query, Locals extends Record<string, unknown> = Record<string, unknown>, Return = unknown>(fn: MiddlewareFunction<Params, ResBody, ReqBody, ReqQuery, Locals, Return>): RouterFunction<Params, ResBody, ReqBody, ReqQuery, Locals>;
34
+ <Params = ParamsDictionary, ResBody = unknown, ReqBody = unknown, ReqQuery = Query, Locals extends Record<string, unknown> = Record<string, unknown>, Return = unknown>(...fns: Array<MiddlewareFunction<Params, ResBody, ReqBody, ReqQuery, Locals, Return>>): Array<RouterFunction<Params, ResBody, ReqBody, ReqQuery, Locals>>;
35
+ <Params = ParamsDictionary, ResBody = unknown, ReqBody = unknown, ReqQuery = Query, Locals extends Record<string, unknown> = Record<string, unknown>, Return = unknown>(fns: Array<MiddlewareFunction<Params, ResBody, ReqBody, ReqQuery, Locals, Return>>): Array<RouterFunction<Params, ResBody, ReqBody, ReqQuery, Locals>>;
38
36
  };
39
37
  type ErrorWithPayload = {
40
38
  statusCode?: number;
@@ -53,8 +51,6 @@ type ErrorWithPayload = {
53
51
  type CreateHandler = (options?: ExpressResponseHandlerOptions) => ExpressResponseHandler;
54
52
  type ExpressResponseHandler = {
55
53
  handleResponse: HandleResponse;
56
- handleResult: (res: ResponseLike, result: unknown, event: EventState) => void;
57
- handlePromise: (res: ResponseLike, promise: Promise<unknown>, event: EventState) => void;
58
54
  HttpResponse: HttpResponseHelpers;
59
55
  createHandler: CreateHandler;
60
56
  errorMessageProvider: ErrorMessageProvider;
@@ -64,4 +60,4 @@ type ExpressResponseHandler = {
64
60
  postError: Hook | null;
65
61
  };
66
62
 
67
- export { type AsyncHook, type CreateHandler, type ErrorFormat, ErrorFormats, type ErrorMessageProvider, type ErrorMessageResult, type ErrorWithPayload, type EventState, type ExpressResponseHandler, type ExpressResponseHandlerOptions, type HandleResponse, type Hook, HttpResponseHelpers, type MaybePromise, type MiddlewareFunction, type NextFunction, type ResponseLike, type RouterFunction };
63
+ export { type AsyncHook, type CreateHandler, type ErrorFormat, ErrorFormats, type ErrorMessageProvider, type ErrorMessageResult, type ErrorWithPayload, type ExpressResponseHandler, type ExpressResponseHandlerOptions, type HandleResponse, type Hook, HttpResponseHelpers, type MaybePromise, type MiddlewareFunction, type NextFunction, type ResponseLike, type RouterFunction };
@@ -1,24 +1,30 @@
1
1
  type CsvProcessor = (value: unknown) => unknown;
2
+ type CsvErrorHandler = (error: unknown) => void;
3
+ type CsvHeaders = boolean | string[];
4
+ type CsvSource = unknown[] | Iterable<unknown> | AsyncIterable<unknown> | unknown;
2
5
  type CsvResponseOptions = {
3
6
  filename?: string;
4
- headers?: boolean;
7
+ headers?: CsvHeaders;
5
8
  processor?: CsvProcessor;
6
9
  };
7
10
  type CsvStreamResponse = {
11
+ headersSent?: boolean;
8
12
  set(name: string, value: string): unknown;
9
13
  destroy?(error?: Error): void;
10
14
  end(): void;
15
+ on?(event: 'error' | 'close' | 'finish', listener: (...args: unknown[]) => void): unknown;
16
+ off?(event: 'error' | 'close' | 'finish', listener: (...args: unknown[]) => void): unknown;
11
17
  };
12
18
  declare const csvResponseBrand: unique symbol;
13
19
  declare const isCSVResponse: (value: unknown) => value is CSVResponse;
14
20
  declare class CSVResponse {
15
21
  readonly [csvResponseBrand] = true;
16
- readonly dataset: unknown[];
22
+ readonly dataset: CsvSource;
17
23
  readonly filename: string;
18
24
  readonly processor: CsvProcessor;
19
- readonly headers?: boolean;
20
- constructor(dataset?: unknown, options?: CsvResponseOptions);
21
- streamCsv(res: CsvStreamResponse): void;
25
+ readonly headers?: CsvHeaders;
26
+ constructor(dataset?: CsvSource, options?: CsvResponseOptions);
27
+ streamCsv(res: CsvStreamResponse, onBeforeOutputError?: CsvErrorHandler): void;
22
28
  }
23
29
 
24
- export { CSVResponse, csvResponseBrand, isCSVResponse };
30
+ export { CSVResponse, type CsvResponseOptions, type CsvSource, csvResponseBrand, isCSVResponse };
@@ -1,24 +1,30 @@
1
1
  type CsvProcessor = (value: unknown) => unknown;
2
+ type CsvErrorHandler = (error: unknown) => void;
3
+ type CsvHeaders = boolean | string[];
4
+ type CsvSource = unknown[] | Iterable<unknown> | AsyncIterable<unknown> | unknown;
2
5
  type CsvResponseOptions = {
3
6
  filename?: string;
4
- headers?: boolean;
7
+ headers?: CsvHeaders;
5
8
  processor?: CsvProcessor;
6
9
  };
7
10
  type CsvStreamResponse = {
11
+ headersSent?: boolean;
8
12
  set(name: string, value: string): unknown;
9
13
  destroy?(error?: Error): void;
10
14
  end(): void;
15
+ on?(event: 'error' | 'close' | 'finish', listener: (...args: unknown[]) => void): unknown;
16
+ off?(event: 'error' | 'close' | 'finish', listener: (...args: unknown[]) => void): unknown;
11
17
  };
12
18
  declare const csvResponseBrand: unique symbol;
13
19
  declare const isCSVResponse: (value: unknown) => value is CSVResponse;
14
20
  declare class CSVResponse {
15
21
  readonly [csvResponseBrand] = true;
16
- readonly dataset: unknown[];
22
+ readonly dataset: CsvSource;
17
23
  readonly filename: string;
18
24
  readonly processor: CsvProcessor;
19
- readonly headers?: boolean;
20
- constructor(dataset?: unknown, options?: CsvResponseOptions);
21
- streamCsv(res: CsvStreamResponse): void;
25
+ readonly headers?: CsvHeaders;
26
+ constructor(dataset?: CsvSource, options?: CsvResponseOptions);
27
+ streamCsv(res: CsvStreamResponse, onBeforeOutputError?: CsvErrorHandler): void;
22
28
  }
23
29
 
24
- export { CSVResponse, csvResponseBrand, isCSVResponse };
30
+ export { CSVResponse, type CsvResponseOptions, type CsvSource, csvResponseBrand, isCSVResponse };
package/responses/csv.js CHANGED
@@ -27,6 +27,41 @@ __export(csv_exports, {
27
27
  module.exports = __toCommonJS(csv_exports);
28
28
  var import_format = require("@fast-csv/format");
29
29
  var import_utils = require("@web-ts-toolkit/utils");
30
+ var import_events = require("events");
31
+ var CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/u;
32
+ var isIterable = (value) => typeof value === "object" && value !== null && Symbol.iterator in value;
33
+ var isAsyncIterable = (value) => typeof value === "object" && value !== null && Symbol.asyncIterator in value;
34
+ var isLazyCsvSource = (value) => !Array.isArray(value) && (isIterable(value) || isAsyncIterable(value));
35
+ var createAsyncIterator = (source) => {
36
+ if (isAsyncIterable(source)) {
37
+ return source[Symbol.asyncIterator]();
38
+ }
39
+ const iterator = (isIterable(source) ? source : (0, import_utils.castArray)(source))[Symbol.iterator]();
40
+ return {
41
+ next: () => Promise.resolve(iterator.next()),
42
+ return: (value) => {
43
+ if (iterator.return) {
44
+ return Promise.resolve(iterator.return(value));
45
+ }
46
+ return Promise.resolve({ done: true, value });
47
+ }
48
+ };
49
+ };
50
+ var encodeRFC5987Value = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
51
+ var createQuotedFilenameFallback = (filename) => {
52
+ const fallback = Array.from(filename.replace(/[\\/]/g, "_"), (char) => {
53
+ const codePoint = char.codePointAt(0) ?? 0;
54
+ return codePoint >= 32 && codePoint <= 126 ? char : "_";
55
+ }).join("");
56
+ return fallback.replace(/(["\\])/g, "\\$1");
57
+ };
58
+ var createAttachmentContentDisposition = (filename) => {
59
+ if (CONTROL_CHARACTER_PATTERN.test(filename)) {
60
+ throw new TypeError("CSV filename cannot contain control characters");
61
+ }
62
+ const fallback = createQuotedFilenameFallback(filename);
63
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeRFC5987Value(filename)}`;
64
+ };
30
65
  var csvResponseBrand = /* @__PURE__ */ Symbol.for("@web-ts-toolkit/express-response-handler.csv-response");
31
66
  var isCSVResponse = (value) => {
32
67
  if (value instanceof CSVResponse) {
@@ -43,30 +78,116 @@ _a = csvResponseBrand;
43
78
  var CSVResponse = class {
44
79
  constructor(dataset = [], options = {}) {
45
80
  this[_a] = true;
46
- this.dataset = (0, import_utils.castArray)(dataset);
81
+ this.dataset = dataset;
47
82
  this.filename = options.filename || "download.csv";
48
83
  this.processor = options.processor || ((value) => value);
49
- if ((0, import_utils.isBoolean)(options.headers)) {
84
+ if ((0, import_utils.isBoolean)(options.headers) || Array.isArray(options.headers)) {
50
85
  this.headers = options.headers;
51
- } else if (this.dataset.length > 0) {
86
+ } else if (Array.isArray(this.dataset) && this.dataset.length > 0) {
52
87
  this.headers = (0, import_utils.isPlainObject)(this.dataset[0]);
88
+ } else if (isLazyCsvSource(this.dataset)) {
89
+ throw new TypeError("CSV lazy sources require an explicit headers option");
90
+ } else {
91
+ this.headers = (0, import_utils.isPlainObject)(this.dataset);
53
92
  }
54
93
  }
55
- streamCsv(res) {
94
+ streamCsv(res, onBeforeOutputError) {
56
95
  const stream = (0, import_format.format)({ headers: this.headers });
57
- res.set("Content-Type", "text/csv");
58
- res.set("Content-Disposition", `attachment;filename=${this.filename}`);
59
- stream.pipe(res);
60
- stream.on("error", (error) => {
96
+ let outputStarted = false;
97
+ let failed = false;
98
+ let finished = false;
99
+ let activeIterator = null;
100
+ let abortStreaming = () => void 0;
101
+ const abortSignal = new Promise((_, reject) => {
102
+ abortStreaming = reject;
103
+ });
104
+ abortSignal.catch(() => void 0);
105
+ const cleanup = () => {
106
+ stream.off("error", fail);
107
+ res.off?.("error", fail);
108
+ res.off?.("close", abort);
109
+ res.off?.("finish", markFinished);
110
+ };
111
+ const closeActiveIterator = () => {
112
+ const iterator = activeIterator;
113
+ activeIterator = null;
114
+ if (iterator?.return) {
115
+ void Promise.resolve(iterator.return()).catch(() => void 0);
116
+ }
117
+ };
118
+ const closeResponse = (error) => {
61
119
  if (res.destroy) {
62
120
  res.destroy(error);
63
121
  return;
64
122
  }
65
123
  res.end();
66
- });
67
- stream.on("end", () => res.end());
68
- this.dataset.forEach((value) => stream.write(this.processor(value)));
69
- stream.end();
124
+ };
125
+ const fail = (error) => {
126
+ if (failed) {
127
+ return;
128
+ }
129
+ failed = true;
130
+ cleanup();
131
+ const normalizedError = error instanceof Error ? error : new Error("CSV response streaming failed");
132
+ abortStreaming(normalizedError);
133
+ closeActiveIterator();
134
+ if (!outputStarted && !res.headersSent) {
135
+ onBeforeOutputError?.(normalizedError);
136
+ return;
137
+ }
138
+ stream.destroy();
139
+ closeResponse(normalizedError);
140
+ };
141
+ const abort = () => {
142
+ if (!finished) {
143
+ fail(new Error("CSV response streaming aborted"));
144
+ }
145
+ };
146
+ const markFinished = () => {
147
+ finished = true;
148
+ };
149
+ const writeRow = async (row) => {
150
+ outputStarted = true;
151
+ if (!stream.write(row)) {
152
+ await Promise.race([(0, import_events.once)(stream, "drain"), abortSignal]);
153
+ }
154
+ };
155
+ stream.on("error", fail);
156
+ stream.on("end", cleanup);
157
+ res.on?.("error", fail);
158
+ res.on?.("close", abort);
159
+ res.on?.("finish", markFinished);
160
+ const pump = async () => {
161
+ try {
162
+ const iterator = createAsyncIterator(this.dataset);
163
+ activeIterator = iterator;
164
+ const first = await iterator.next();
165
+ const firstRow = first.done ? void 0 : this.processor(first.value);
166
+ const contentDisposition = createAttachmentContentDisposition(this.filename);
167
+ res.set("Content-Type", "text/csv");
168
+ res.set("Content-Disposition", contentDisposition);
169
+ stream.pipe(res);
170
+ if (!first.done) {
171
+ await writeRow(firstRow);
172
+ }
173
+ while (!failed) {
174
+ const row = await Promise.race([iterator.next(), abortSignal]);
175
+ if (row.done) {
176
+ break;
177
+ }
178
+ await writeRow(this.processor(row.value));
179
+ }
180
+ if (!failed) {
181
+ finished = true;
182
+ stream.end();
183
+ }
184
+ } catch (error) {
185
+ fail(error);
186
+ } finally {
187
+ activeIterator = null;
188
+ }
189
+ };
190
+ void pump();
70
191
  }
71
192
  };
72
193
  // Annotate the CommonJS export names for ESM import in node:
package/responses/csv.mjs CHANGED
@@ -1,6 +1,41 @@
1
1
  // src/responses/csv.ts
2
2
  import { format } from "@fast-csv/format";
3
3
  import { castArray, isBoolean, isPlainObject } from "@web-ts-toolkit/utils";
4
+ import { once } from "events";
5
+ var CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/u;
6
+ var isIterable = (value) => typeof value === "object" && value !== null && Symbol.iterator in value;
7
+ var isAsyncIterable = (value) => typeof value === "object" && value !== null && Symbol.asyncIterator in value;
8
+ var isLazyCsvSource = (value) => !Array.isArray(value) && (isIterable(value) || isAsyncIterable(value));
9
+ var createAsyncIterator = (source) => {
10
+ if (isAsyncIterable(source)) {
11
+ return source[Symbol.asyncIterator]();
12
+ }
13
+ const iterator = (isIterable(source) ? source : castArray(source))[Symbol.iterator]();
14
+ return {
15
+ next: () => Promise.resolve(iterator.next()),
16
+ return: (value) => {
17
+ if (iterator.return) {
18
+ return Promise.resolve(iterator.return(value));
19
+ }
20
+ return Promise.resolve({ done: true, value });
21
+ }
22
+ };
23
+ };
24
+ var encodeRFC5987Value = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
25
+ var createQuotedFilenameFallback = (filename) => {
26
+ const fallback = Array.from(filename.replace(/[\\/]/g, "_"), (char) => {
27
+ const codePoint = char.codePointAt(0) ?? 0;
28
+ return codePoint >= 32 && codePoint <= 126 ? char : "_";
29
+ }).join("");
30
+ return fallback.replace(/(["\\])/g, "\\$1");
31
+ };
32
+ var createAttachmentContentDisposition = (filename) => {
33
+ if (CONTROL_CHARACTER_PATTERN.test(filename)) {
34
+ throw new TypeError("CSV filename cannot contain control characters");
35
+ }
36
+ const fallback = createQuotedFilenameFallback(filename);
37
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeRFC5987Value(filename)}`;
38
+ };
4
39
  var csvResponseBrand = /* @__PURE__ */ Symbol.for("@web-ts-toolkit/express-response-handler.csv-response");
5
40
  var isCSVResponse = (value) => {
6
41
  if (value instanceof CSVResponse) {
@@ -17,30 +52,116 @@ _a = csvResponseBrand;
17
52
  var CSVResponse = class {
18
53
  constructor(dataset = [], options = {}) {
19
54
  this[_a] = true;
20
- this.dataset = castArray(dataset);
55
+ this.dataset = dataset;
21
56
  this.filename = options.filename || "download.csv";
22
57
  this.processor = options.processor || ((value) => value);
23
- if (isBoolean(options.headers)) {
58
+ if (isBoolean(options.headers) || Array.isArray(options.headers)) {
24
59
  this.headers = options.headers;
25
- } else if (this.dataset.length > 0) {
60
+ } else if (Array.isArray(this.dataset) && this.dataset.length > 0) {
26
61
  this.headers = isPlainObject(this.dataset[0]);
62
+ } else if (isLazyCsvSource(this.dataset)) {
63
+ throw new TypeError("CSV lazy sources require an explicit headers option");
64
+ } else {
65
+ this.headers = isPlainObject(this.dataset);
27
66
  }
28
67
  }
29
- streamCsv(res) {
68
+ streamCsv(res, onBeforeOutputError) {
30
69
  const stream = format({ headers: this.headers });
31
- res.set("Content-Type", "text/csv");
32
- res.set("Content-Disposition", `attachment;filename=${this.filename}`);
33
- stream.pipe(res);
34
- stream.on("error", (error) => {
70
+ let outputStarted = false;
71
+ let failed = false;
72
+ let finished = false;
73
+ let activeIterator = null;
74
+ let abortStreaming = () => void 0;
75
+ const abortSignal = new Promise((_, reject) => {
76
+ abortStreaming = reject;
77
+ });
78
+ abortSignal.catch(() => void 0);
79
+ const cleanup = () => {
80
+ stream.off("error", fail);
81
+ res.off?.("error", fail);
82
+ res.off?.("close", abort);
83
+ res.off?.("finish", markFinished);
84
+ };
85
+ const closeActiveIterator = () => {
86
+ const iterator = activeIterator;
87
+ activeIterator = null;
88
+ if (iterator?.return) {
89
+ void Promise.resolve(iterator.return()).catch(() => void 0);
90
+ }
91
+ };
92
+ const closeResponse = (error) => {
35
93
  if (res.destroy) {
36
94
  res.destroy(error);
37
95
  return;
38
96
  }
39
97
  res.end();
40
- });
41
- stream.on("end", () => res.end());
42
- this.dataset.forEach((value) => stream.write(this.processor(value)));
43
- stream.end();
98
+ };
99
+ const fail = (error) => {
100
+ if (failed) {
101
+ return;
102
+ }
103
+ failed = true;
104
+ cleanup();
105
+ const normalizedError = error instanceof Error ? error : new Error("CSV response streaming failed");
106
+ abortStreaming(normalizedError);
107
+ closeActiveIterator();
108
+ if (!outputStarted && !res.headersSent) {
109
+ onBeforeOutputError?.(normalizedError);
110
+ return;
111
+ }
112
+ stream.destroy();
113
+ closeResponse(normalizedError);
114
+ };
115
+ const abort = () => {
116
+ if (!finished) {
117
+ fail(new Error("CSV response streaming aborted"));
118
+ }
119
+ };
120
+ const markFinished = () => {
121
+ finished = true;
122
+ };
123
+ const writeRow = async (row) => {
124
+ outputStarted = true;
125
+ if (!stream.write(row)) {
126
+ await Promise.race([once(stream, "drain"), abortSignal]);
127
+ }
128
+ };
129
+ stream.on("error", fail);
130
+ stream.on("end", cleanup);
131
+ res.on?.("error", fail);
132
+ res.on?.("close", abort);
133
+ res.on?.("finish", markFinished);
134
+ const pump = async () => {
135
+ try {
136
+ const iterator = createAsyncIterator(this.dataset);
137
+ activeIterator = iterator;
138
+ const first = await iterator.next();
139
+ const firstRow = first.done ? void 0 : this.processor(first.value);
140
+ const contentDisposition = createAttachmentContentDisposition(this.filename);
141
+ res.set("Content-Type", "text/csv");
142
+ res.set("Content-Disposition", contentDisposition);
143
+ stream.pipe(res);
144
+ if (!first.done) {
145
+ await writeRow(firstRow);
146
+ }
147
+ while (!failed) {
148
+ const row = await Promise.race([iterator.next(), abortSignal]);
149
+ if (row.done) {
150
+ break;
151
+ }
152
+ await writeRow(this.processor(row.value));
153
+ }
154
+ if (!failed) {
155
+ finished = true;
156
+ stream.end();
157
+ }
158
+ } catch (error) {
159
+ fail(error);
160
+ } finally {
161
+ activeIterator = null;
162
+ }
163
+ };
164
+ void pump();
44
165
  }
45
166
  };
46
167
  export {
@@ -1,4 +1,5 @@
1
1
  declare const responseBrand: unique symbol;
2
+ declare const validateHttpStatusCode: (statusCode: unknown) => number;
2
3
  declare const isResponse: (value: unknown) => value is Response<unknown>;
3
4
  declare class Response<T = unknown> {
4
5
  readonly [responseBrand] = true;
@@ -7,4 +8,4 @@ declare class Response<T = unknown> {
7
8
  constructor(statusCode: number | undefined, data: T);
8
9
  }
9
10
 
10
- export { Response, isResponse, responseBrand };
11
+ export { Response, isResponse, responseBrand, validateHttpStatusCode };
@@ -1,4 +1,5 @@
1
1
  declare const responseBrand: unique symbol;
2
+ declare const validateHttpStatusCode: (statusCode: unknown) => number;
2
3
  declare const isResponse: (value: unknown) => value is Response<unknown>;
3
4
  declare class Response<T = unknown> {
4
5
  readonly [responseBrand] = true;
@@ -7,4 +8,4 @@ declare class Response<T = unknown> {
7
8
  constructor(statusCode: number | undefined, data: T);
8
9
  }
9
10
 
10
- export { Response, isResponse, responseBrand };
11
+ export { Response, isResponse, responseBrand, validateHttpStatusCode };
@@ -22,10 +22,17 @@ var responses_exports = {};
22
22
  __export(responses_exports, {
23
23
  Response: () => Response,
24
24
  isResponse: () => isResponse,
25
- responseBrand: () => responseBrand
25
+ responseBrand: () => responseBrand,
26
+ validateHttpStatusCode: () => validateHttpStatusCode
26
27
  });
27
28
  module.exports = __toCommonJS(responses_exports);
28
29
  var responseBrand = /* @__PURE__ */ Symbol.for("@web-ts-toolkit/express-response-handler.response");
30
+ var validateHttpStatusCode = (statusCode) => {
31
+ if (typeof statusCode !== "number" || !Number.isFinite(statusCode) || !Number.isInteger(statusCode) || statusCode < 100 || statusCode > 599) {
32
+ throw new TypeError("statusCode must be an integer HTTP status code between 100 and 599");
33
+ }
34
+ return statusCode;
35
+ };
29
36
  var isResponse = (value) => {
30
37
  if (value instanceof Response) {
31
38
  return true;
@@ -41,7 +48,7 @@ _a = responseBrand;
41
48
  var Response = class {
42
49
  constructor(statusCode = 200, data) {
43
50
  this[_a] = true;
44
- this.statusCode = statusCode;
51
+ this.statusCode = validateHttpStatusCode(statusCode);
45
52
  this.data = data;
46
53
  }
47
54
  };
@@ -49,5 +56,6 @@ var Response = class {
49
56
  0 && (module.exports = {
50
57
  Response,
51
58
  isResponse,
52
- responseBrand
59
+ responseBrand,
60
+ validateHttpStatusCode
53
61
  });