@web-ts-toolkit/express-response-handler 0.42.2 → 0.44.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/responses/csv.mjs CHANGED
@@ -1,7 +1,6 @@
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
4
  var CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/u;
6
5
  var isIterable = (value) => typeof value === "object" && value !== null && Symbol.iterator in value;
7
6
  var isAsyncIterable = (value) => typeof value === "object" && value !== null && Symbol.asyncIterator in value;
@@ -65,28 +64,66 @@ var CSVResponse = class {
65
64
  this.headers = isPlainObject(this.dataset);
66
65
  }
67
66
  }
67
+ /**
68
+ * Streams the dataset as CSV into `res`.
69
+ *
70
+ * Contract choice (B-ERH-06): minimal runtime-safe fallback, no breaking
71
+ * signature change. `onBeforeOutputError` stays optional.
72
+ *
73
+ * - Pre-output failure (invalid filename, failed first read, first-row
74
+ * processor throw, before headers are sent) with an error owner:
75
+ * the owner is invoked exactly once with the normalized failure and owns
76
+ * destination termination (the handler owner renders a single redacted
77
+ * JSON error; direct callers must end/destroy `res` themselves). A
78
+ * throwing owner is contained and the destination is then destroyed with
79
+ * the original failure so it never stays open.
80
+ * - Pre-output failure without an error owner: deterministic fallback —
81
+ * the destination is destroyed with the normalized failure (or ended
82
+ * when `destroy` is unavailable), observable via `error` + `close`.
83
+ * - Post-output failure (after piping starts): the destination is always
84
+ * destroyed with the failure, with or without an owner.
85
+ *
86
+ * Non-`Error` failures are wrapped in an `Error` with the original kept as
87
+ * `cause`. A throwing owner never displaces the original failure and never
88
+ * escapes as a process-level error.
89
+ */
68
90
  streamCsv(res, onBeforeOutputError) {
69
91
  const stream = format({ headers: this.headers });
70
92
  let outputStarted = false;
71
93
  let failed = false;
72
94
  let finished = false;
95
+ let cleaned = false;
73
96
  let activeIterator = null;
74
- let abortStreaming = () => void 0;
75
- const abortSignal = new Promise((_, reject) => {
76
- abortStreaming = reject;
77
- });
78
- abortSignal.catch(() => void 0);
97
+ const abortWaiters = /* @__PURE__ */ new Set();
98
+ const notifyAborted = () => {
99
+ for (const notify of Array.from(abortWaiters)) {
100
+ abortWaiters.delete(notify);
101
+ try {
102
+ notify();
103
+ } catch {
104
+ }
105
+ }
106
+ };
79
107
  const cleanup = () => {
108
+ if (cleaned) {
109
+ return;
110
+ }
111
+ cleaned = true;
80
112
  stream.off("error", fail);
81
113
  res.off?.("error", fail);
82
- res.off?.("close", abort);
83
- res.off?.("finish", markFinished);
114
+ res.off?.("close", handleClose);
115
+ res.off?.("finish", handleFinish);
84
116
  };
85
117
  const closeActiveIterator = () => {
86
118
  const iterator = activeIterator;
87
119
  activeIterator = null;
88
- if (iterator?.return) {
120
+ if (!iterator?.return) {
121
+ return;
122
+ }
123
+ try {
89
124
  void Promise.resolve(iterator.return()).catch(() => void 0);
125
+ } catch {
126
+ return;
90
127
  }
91
128
  };
92
129
  const closeResponse = (error) => {
@@ -101,58 +138,129 @@ var CSVResponse = class {
101
138
  return;
102
139
  }
103
140
  failed = true;
104
- cleanup();
105
- const normalizedError = error instanceof Error ? error : new Error("CSV response streaming failed");
106
- abortStreaming(normalizedError);
141
+ notifyAborted();
142
+ const normalizedError = error instanceof Error ? error : (() => {
143
+ const wrapped = new Error("CSV response streaming failed");
144
+ try {
145
+ wrapped.cause = error;
146
+ } catch {
147
+ }
148
+ return wrapped;
149
+ })();
107
150
  closeActiveIterator();
108
151
  if (!outputStarted && !res.headersSent) {
109
- onBeforeOutputError?.(normalizedError);
152
+ stream.destroy();
153
+ if (onBeforeOutputError) {
154
+ cleanup();
155
+ try {
156
+ onBeforeOutputError(normalizedError);
157
+ } catch {
158
+ try {
159
+ res.on?.("error", () => void 0);
160
+ } catch {
161
+ }
162
+ try {
163
+ closeResponse(normalizedError);
164
+ } catch {
165
+ }
166
+ }
167
+ return;
168
+ }
169
+ try {
170
+ closeResponse(normalizedError);
171
+ } catch {
172
+ cleanup();
173
+ }
110
174
  return;
111
175
  }
112
176
  stream.destroy();
113
177
  closeResponse(normalizedError);
114
178
  };
115
- const abort = () => {
116
- if (!finished) {
179
+ const handleClose = () => {
180
+ if (!finished && !failed) {
117
181
  fail(new Error("CSV response streaming aborted"));
182
+ cleanup();
183
+ return;
118
184
  }
185
+ cleanup();
119
186
  };
120
- const markFinished = () => {
187
+ const handleFinish = () => {
121
188
  finished = true;
189
+ cleanup();
122
190
  };
123
191
  const writeRow = async (row) => {
192
+ if (failed) {
193
+ return;
194
+ }
124
195
  outputStarted = true;
125
196
  if (!stream.write(row)) {
126
- await Promise.race([once(stream, "drain"), abortSignal]);
197
+ await new Promise((resolve) => {
198
+ if (failed) {
199
+ resolve();
200
+ return;
201
+ }
202
+ const onDrain = () => {
203
+ stream.off("drain", onDrain);
204
+ abortWaiters.delete(onAbort);
205
+ resolve();
206
+ };
207
+ const onAbort = () => {
208
+ stream.off("drain", onDrain);
209
+ resolve();
210
+ };
211
+ abortWaiters.add(onAbort);
212
+ stream.once("drain", onDrain);
213
+ });
127
214
  }
128
215
  };
129
216
  stream.on("error", fail);
130
- stream.on("end", cleanup);
131
217
  res.on?.("error", fail);
132
- res.on?.("close", abort);
133
- res.on?.("finish", markFinished);
218
+ res.on?.("close", handleClose);
219
+ res.on?.("finish", handleFinish);
134
220
  const pump = async () => {
135
221
  try {
136
222
  const iterator = createAsyncIterator(this.dataset);
137
223
  activeIterator = iterator;
138
224
  const first = await iterator.next();
139
- const firstRow = first.done ? void 0 : this.processor(first.value);
225
+ if (failed) {
226
+ return;
227
+ }
228
+ let firstRow;
229
+ if (!first.done) {
230
+ firstRow = this.processor(first.value);
231
+ if (failed) {
232
+ return;
233
+ }
234
+ }
140
235
  const contentDisposition = createAttachmentContentDisposition(this.filename);
236
+ if (failed) {
237
+ return;
238
+ }
141
239
  res.set("Content-Type", "text/csv");
142
240
  res.set("Content-Disposition", contentDisposition);
143
241
  stream.pipe(res);
144
- if (!first.done) {
145
- await writeRow(firstRow);
242
+ if (first.done) {
243
+ if (!failed) {
244
+ stream.end();
245
+ }
246
+ return;
146
247
  }
248
+ await writeRow(firstRow);
147
249
  while (!failed) {
148
- const row = await Promise.race([iterator.next(), abortSignal]);
149
- if (row.done) {
250
+ const result = await iterator.next();
251
+ if (failed) {
252
+ return;
253
+ }
254
+ if (result.done) {
150
255
  break;
151
256
  }
152
- await writeRow(this.processor(row.value));
257
+ const processed = this.processor(result.value);
258
+ if (failed) {
259
+ return;
260
+ }
261
+ await writeRow(processed);
153
262
  }
154
263
  if (!failed) {
155
- finished = true;
156
264
  stream.end();
157
265
  }
158
266
  } catch (error) {
@@ -161,7 +269,7 @@ var CSVResponse = class {
161
269
  activeIterator = null;
162
270
  }
163
271
  };
164
- void pump();
272
+ void pump().then(void 0, () => void 0);
165
273
  }
166
274
  };
167
275
  export {