Streaming CSV Parser for Chunked Input
Incrementally parse CSV chunks with quoted fields, escaped quotes, embedded newlines, and CRLF endings using a dependency-free state machine.
Code
export class CsvStreamParser {
constructor({ delimiter = ",", onRow } = {}) {
if (typeof delimiter !== "string" || delimiter.length !== 1) {
throw new TypeError("delimiter must be a single character");
}
if (typeof onRow !== "function") {
throw new TypeError("onRow must be a function");
}
this.delimiter = delimiter;
this.onRow = onRow;
this.field = "";
this.row = [];
this.rowStarted = false;
this.inQuotes = false;
this.pendingQuote = false;
this.pendingCarriageReturn = false;
}
write(chunk) {
const text = String(chunk);
let startIndex = 0;
if (this.pendingCarriageReturn) {
this.pendingCarriageReturn = false;
if (text.startsWith("\n")) startIndex = 1;
}
for (let index = startIndex; index < text.length; index += 1) {
const char = text[index];
if (this.inQuotes) {
if (this.pendingQuote) {
if (char === '"') {
this.field += '"';
this.pendingQuote = false;
continue;
}
this.inQuotes = false;
this.pendingQuote = false;
} else if (char === '"') {
this.pendingQuote = true;
continue;
} else {
this.field += char;
continue;
}
}
if (char === '"') {
if (this.field.length > 0) {
throw new Error("quote must start a field");
}
this.inQuotes = true;
this.rowStarted = true;
} else if (char === this.delimiter) {
this.row.push(this.field);
this.field = "";
this.rowStarted = true;
} else if (char === "\n" || char === "\r") {
if (char === "\r") {
if (text[index + 1] === "\n") index += 1;
else this.pendingCarriageReturn = true;
}
this.row.push(this.field);
this.field = "";
const row = this.row;
this.row = [];
this.rowStarted = false;
this.onRow(row);
} else {
this.field += char;
this.rowStarted = true;
}
}
return this;
}
end() {
if (this.inQuotes && !this.pendingQuote) {
throw new Error("unterminated quoted field");
}
this.inQuotes = false;
this.pendingQuote = false;
this.pendingCarriageReturn = false;
if (this.rowStarted || this.field.length > 0 || this.row.length > 0) {
this.row.push(this.field);
this.field = "";
const row = this.row;
this.row = [];
this.rowStarted = false;
this.onRow(row);
}
}
}
const rows = [];
const parser = new CsvStreamParser({ onRow: (row) => rows.push(row) });
parser.write('name,note\r\n"Ada","hello, ');
parser.write('world"\r\n"Grace","said ""hi"""');
parser.end();
console.log(JSON.stringify(rows));
const empty = [];
const quotedEmpty = new CsvStreamParser({ onRow: (row) => empty.push(row) });
quotedEmpty.write('""');
quotedEmpty.end();
console.log(JSON.stringify(empty));
const splitRows = [];
const splitCrlf = new CsvStreamParser({ onRow: (row) => splitRows.push(row) });
splitCrlf.write("a,b\r");
splitCrlf.write("\nc,d");
splitCrlf.end();
console.log(JSON.stringify(splitRows));
try {
const broken = new CsvStreamParser({ onRow: () => {} });
broken.write('"open');
broken.end();
} catch (error) {
console.log(error.message);
}Report a problem with this exampleOpens a pre-filled issue on GitHub.