/** * Browser-friendly streaming CSV parser. * * Adapted from mafintosh/csv-parser's parsing approach, * but implemented with Web Streams for modern runtimes. * * @module */ export interface CsvParseStreamOptions { separator?: string; quote?: string; escape?: string; headers?: Array | false | null; skipFirstRow?: boolean; skipComments?: boolean | string; skipLines?: number; strict?: boolean; mapHeaders?: (args: { header: string; index: number }) => string | null; mapValues?: (args: { header: string | undefined; index: number; value: string }) => string; } const defaults: Required< Pick< CsvParseStreamOptions, "separator" | "quote" | "escape" | "skipFirstRow" | "skipComments" | "strict" > > = { separator: ",", quote: '"', escape: '"', skipFirstRow: false, skipComments: false, strict: false, }; /** * TransformStream-like wrapper for CSV parsing. * * Exposes `writable` + `readable` so it can be used directly in `pipeThrough`. */ export class CsvParseStream { readonly readable: ReadableStream>; readonly writable: WritableStream; constructor(opts: CsvParseStreamOptions = {}) { const options: Required = { ...defaults, ...opts, headers: opts.headers ?? null, skipLines: opts.skipLines ?? 0, mapHeaders: opts.mapHeaders ?? (({ header }) => header), mapValues: opts.mapValues ?? (({ value }) => value), }; let headers = options.headers; let lineNumber = 0; let row: string[] = []; let field = ""; let inQuotes = false; const stream = new TransformStream>({ transform(chunk, controller) { const emitRow = (cells: string[]) => { const skip = options.skipLines > lineNumber; lineNumber++; if (skip) return; if (options.skipComments) { const commentPrefix = typeof options.skipComments === "string" ? options.skipComments : "#"; const firstCell = cells[0] ?? ""; if (firstCell.startsWith(commentPrefix)) return; } if (headers === null || (lineNumber === 1 && options.skipFirstRow)) { headers = cells.map((header, index) => options.mapHeaders({ header, index })); return; } if (headers === false) { const out = Object.fromEntries(cells.map((value, index) => [index, value])); controller.enqueue(out as unknown as Record); return; } if (options.strict && cells.length !== headers.length) { throw new RangeError( `Row length ${cells.length} does not match header length ${headers.length}`, ); } const out: Record = {}; for (let index = 0; index < cells.length; index++) { const header = headers[index] ?? `_${index}`; if (header === null) continue; out[header] = options.mapValues({ header, index, value: cells[index] ?? "", }); } controller.enqueue(out); }; for (let i = 0; i < chunk.length; i++) { const ch = chunk[i]; const next = chunk[i + 1]; if (ch === options.escape && inQuotes && next === options.quote) { field += options.quote; i++; continue; } if (ch === options.quote) { inQuotes = !inQuotes; continue; } if (!inQuotes && ch === options.separator) { row.push(field); field = ""; continue; } if (!inQuotes && (ch === "\n" || ch === "\r")) { if (ch === "\r" && next === "\n") i++; row.push(field); field = ""; emitRow(row); row = []; continue; } field += ch; } }, flush(controller) { const emitRow = (cells: string[]) => { const skip = options.skipLines > lineNumber; lineNumber++; if (skip) return; if (options.skipComments) { const commentPrefix = typeof options.skipComments === "string" ? options.skipComments : "#"; const firstCell = cells[0] ?? ""; if (firstCell.startsWith(commentPrefix)) return; } if (headers === null || (lineNumber === 1 && options.skipFirstRow)) { headers = cells.map((header, index) => options.mapHeaders({ header, index })); return; } if (headers === false) { const out = Object.fromEntries(cells.map((value, index) => [index, value])); controller.enqueue(out as unknown as Record); return; } if (options.strict && cells.length !== headers.length) { throw new RangeError( `Row length ${cells.length} does not match header length ${headers.length}`, ); } const out: Record = {}; for (let index = 0; index < cells.length; index++) { const header = headers[index] ?? `_${index}`; if (header === null) continue; out[header] = options.mapValues({ header, index, value: cells[index] ?? "", }); } controller.enqueue(out); }; if (field.length > 0 || row.length > 0) { row.push(field); emitRow(row); } }, }); this.readable = stream.readable; this.writable = stream.writable; } }