fix(base): handle csv export client abort and mid-stream errors

This commit is contained in:
Philipinho
2026-04-18 18:18:34 +01:00
parent 66f9194e96
commit f119d728a8
@@ -63,21 +63,37 @@ export class BaseCsvExportService {
}); });
stringifier.pipe(out); stringifier.pipe(out);
// RFC 5987: use filename*=UTF-8''... for non-ASCII; keep a plain
// ASCII fallback in filename=. Percent-encoding a name inside the
// quoted-string filename= token is not decoded by browsers, so it
// would land as e.g. "My%20Base.csv" on disk.
const asciiFallback = fileName.replace(/[^\x20-\x7e]/g, '_');
reply.headers({ reply.headers({
'Content-Type': 'text/csv; charset=utf-8', 'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"', `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
}); });
reply.send(out); reply.send(out);
// Client aborts (tab close, cancel-download) close `out`. Without
// an abort signal the row loop keeps pulling chunks from Postgres
// long after the response is gone — on a 500k-row base that's
// hundreds of useless round-trips.
let aborted = false;
out.once('close', () => {
aborted = true;
});
try { try {
for await (const chunk of this.baseRowRepo.streamByBaseId(baseId, { for await (const chunk of this.baseRowRepo.streamByBaseId(baseId, {
workspaceId, workspaceId,
chunkSize: CHUNK_SIZE, chunkSize: CHUNK_SIZE,
})) { })) {
if (aborted) break;
const ctx = await this.buildCtx(chunk, properties); const ctx = await this.buildCtx(chunk, properties);
for (const row of chunk) { for (const row of chunk) {
if (aborted) break;
const record: Record<string, string> = {}; const record: Record<string, string> = {};
const cells = (row.cells ?? {}) as Record<string, unknown>; const cells = (row.cells ?? {}) as Record<string, unknown>;
@@ -109,9 +125,12 @@ export class BaseCsvExportService {
stringifier.end(); stringifier.end();
} catch (err) { } catch (err) {
// Headers are already flushed at this point — re-throwing would
// trigger Nest's exception filter to try to send another
// response, which Fastify rejects. Destroying the stringifier
// cascades to `out` and signals EOF to the client.
this.logger.error(`csv export failed base=${baseId}`, err); this.logger.error(`csv export failed base=${baseId}`, err);
stringifier.destroy(err as Error); stringifier.destroy(err as Error);
throw err;
} }
} }