@dipanshuhandoo/excelio 1.0.0 → 1.1.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/dist/esm/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // src/writer.js
2
- import { Readable, PassThrough } from "node:stream";
2
+ import { PassThrough } from "node:stream";
3
3
  import { createWriteStream } from "node:fs";
4
+ import { rm } from "node:fs/promises";
5
+ import { setImmediate as tick, setTimeout as sleep } from "node:timers/promises";
4
6
  import ExcelJS from "exceljs";
5
7
 
6
8
  // src/errors.js
@@ -101,11 +103,44 @@ function formatDate(d, fmt) {
101
103
  }
102
104
  }
103
105
 
106
+ // src/rowSource.js
107
+ function rowSourceKind(data) {
108
+ if (data === void 0 || data === null) return "empty";
109
+ if (Array.isArray(data)) return "array";
110
+ if (typeof data !== "object" && typeof data !== "function") return "invalid";
111
+ if (typeof data[Symbol.asyncIterator] === "function") return "async";
112
+ if (typeof data[Symbol.iterator] === "function") return "sync";
113
+ return "invalid";
114
+ }
115
+ var isIterableSource = (data) => {
116
+ const kind = rowSourceKind(data);
117
+ return kind === "sync" || kind === "async";
118
+ };
119
+ var specHasIterable = (spec) => Array.isArray(spec) && spec.some((wb) => (Array.isArray(wb?.sheets) ? wb.sheets : []).some((s) => isIterableSource(s?.data)));
120
+ async function closeIterator(iterator) {
121
+ if (typeof iterator?.return !== "function") return;
122
+ try {
123
+ await iterator.return();
124
+ } catch {
125
+ }
126
+ }
127
+ function sourceFailure(err, details, signal) {
128
+ if (signal?.aborted) return new ExcelIoError("Aborted", { code: "ABORTED", cause: err, details });
129
+ return new ExcelIoError(`Row source failed for sheet "${details.sheetName}": ${err?.message ?? err}`, {
130
+ code: "SOURCE_ERROR",
131
+ cause: err,
132
+ details
133
+ });
134
+ }
135
+
104
136
  // src/writer.js
105
137
  var DEFAULT_BATCH = 5e3;
106
138
  var SHEET_NAME_MAX = 31;
139
+ var EXCEL_MAX_DATA_ROWS = 1048575;
140
+ var OUTPUT_HIGH_WATER_MARK = 8 * 1024 * 1024;
141
+ var BACKLOG_POLL_MS = 5;
107
142
  async function writeSpec(spec, options = {}) {
108
- validateSpec(spec);
143
+ preflightWrite(spec, options);
109
144
  const { to } = options;
110
145
  if (to !== void 0 && spec.length > 1) {
111
146
  throw new ExcelIoError(
@@ -125,41 +160,106 @@ async function writeSpec(spec, options = {}) {
125
160
  return buffers.length === 1 ? buffers[0] : buffers;
126
161
  }
127
162
  function writeSpecStream(spec, options = {}) {
128
- validateSpec(spec);
163
+ preflightWrite(spec, options);
129
164
  const passthrough = new PassThrough();
130
165
  writeOneWorkbook(spec[0], 0, passthrough, options).catch((err) => passthrough.destroy(err));
131
166
  return passthrough;
132
167
  }
168
+ function preflightWrite(spec, options = {}) {
169
+ validateSpec(spec);
170
+ validateWriteOptions(options);
171
+ }
133
172
  async function writeOneWorkbookToBuffer(workbookSpec, wbIndex, options) {
134
173
  const chunks = [];
135
174
  const collector = new PassThrough();
136
175
  collector.on("data", (chunk) => chunks.push(chunk));
137
- await writeOneWorkbook(workbookSpec, wbIndex, collector, options);
176
+ await writeOneWorkbook(workbookSpec, wbIndex, collector, options, { owned: true });
138
177
  return Buffer.concat(chunks);
139
178
  }
140
- async function writeOneWorkbook(workbookSpec, wbIndex, target, options) {
179
+ async function writeOneWorkbook(workbookSpec, wbIndex, target, options, sinkOptions = {}) {
141
180
  const { signal, onProgress, beforeWrite, batchSize = DEFAULT_BATCH, logger } = options;
142
- const sink = resolveSink(target);
143
- const wb = new ExcelJS.stream.xlsx.WorkbookWriter({
144
- stream: sink.writable,
145
- useStyles: true,
146
- useSharedStrings: true
147
- });
148
- applyWorkbookProperties(wb, workbookSpec.properties);
149
- const usedNames = /* @__PURE__ */ new Set();
181
+ const maxRowsPerSheet = options.maxRowsPerSheet ?? EXCEL_MAX_DATA_ROWS;
150
182
  const sheets = workbookSpec.sheets || [];
151
- let totalRows = sheets.reduce((n, s) => n + (s.data?.length || 0), 0);
152
- let rowsProcessed = 0;
153
- for (let sIndex = 0; sIndex < sheets.length; sIndex++) {
154
- if (signal?.aborted) throw new ExcelIoError("Aborted", { code: "ABORTED" });
155
- const sheetSpec = sheets[sIndex];
156
- const sheetName = sanitiseSheetName(sheetSpec.sheet || `Sheet ${sIndex + 1}`, usedNames, logger);
157
- const wsOptions = {};
158
- if (sheetSpec.styling?.freezeHeader) {
159
- wsOptions.views = [{ state: "frozen", ySplit: 1 }];
183
+ const sink = resolveSink(target, sinkOptions);
184
+ let wb = null;
185
+ try {
186
+ wb = new ExcelJS.stream.xlsx.WorkbookWriter({
187
+ stream: sink.writable,
188
+ useStyles: true,
189
+ useSharedStrings: resolveSharedStrings(options.sharedStrings, sheets)
190
+ });
191
+ applyWorkbookProperties(wb, workbookSpec.properties);
192
+ const used = /* @__PURE__ */ new Set();
193
+ const firstNames = sheets.map((s, i) => sanitiseSheetName(s.sheet || `Sheet ${i + 1}`, used, logger));
194
+ const totalRows = computeTotalRows(sheets);
195
+ const state = { rowsProcessed: 0, last: { sheetIndex: 0, sheetName: firstNames[0], sheetPart: 1 } };
196
+ const emit = () => onProgress?.({
197
+ phase: "write",
198
+ workbookIndex: wbIndex,
199
+ ...state.last,
200
+ rowsProcessed: state.rowsProcessed,
201
+ totalRows
202
+ });
203
+ for (let sIndex = 0; sIndex < sheets.length; sIndex++) {
204
+ throwIfAborted(signal);
205
+ const sheetSpec = sheets[sIndex];
206
+ const writer = createSheetWriter({
207
+ wb,
208
+ workbookSpec,
209
+ wbIndex,
210
+ sIndex,
211
+ sheetSpec,
212
+ firstName: firstNames[sIndex],
213
+ used,
214
+ beforeWrite,
215
+ maxRowsPerSheet,
216
+ onOpen: (sheetName, sheetPart) => {
217
+ state.last = { sheetIndex: sIndex, sheetName, sheetPart };
218
+ },
219
+ onRow: () => {
220
+ state.rowsProcessed++;
221
+ if (onProgress && state.rowsProcessed % batchSize === 0) emit();
222
+ }
223
+ });
224
+ const kind = rowSourceKind(sheetSpec.data);
225
+ if (kind === "sync" || kind === "async") {
226
+ await writeIterableSheet({ writer, wb, sink, sheetSpec, kind, signal, batchSize, wbIndex, sIndex });
227
+ } else {
228
+ const data = sheetSpec.data || [];
229
+ writer.start(resolveColumns(sheetSpec.columns, data));
230
+ for (let i = 0; i < data.length; i++) {
231
+ throwIfAborted(signal);
232
+ writer.write(data[i]);
233
+ }
234
+ }
235
+ writer.finish();
160
236
  }
161
- const ws = wb.addWorksheet(sheetName, wsOptions);
162
- const cols = resolveColumns(sheetSpec.columns, sheetSpec.data);
237
+ const committing = wb.commit();
238
+ committing.catch(() => {
239
+ });
240
+ await Promise.race([committing, sink.failed]);
241
+ if (sink.cleanup) await Promise.race([sink.cleanup(), sink.failed]);
242
+ sink.release();
243
+ emit();
244
+ } catch (err) {
245
+ await abortWorkbook(wb, sink, err);
246
+ throw err;
247
+ }
248
+ }
249
+ function createSheetWriter({ wb, workbookSpec, wbIndex, sIndex, sheetSpec, firstName, used, beforeWrite, maxRowsPerSheet, onOpen, onRow }) {
250
+ const baseName = sheetSpec.sheet || `Sheet ${sIndex + 1}`;
251
+ let cols = [];
252
+ let ws = null;
253
+ let sheetName = null;
254
+ let part = 0;
255
+ let rowsInPart = 0;
256
+ const open = () => {
257
+ part += 1;
258
+ rowsInPart = 0;
259
+ sheetName = part === 1 ? firstName : sanitiseSheetName(baseName, used, null);
260
+ const wsOptions = {};
261
+ if (sheetSpec.styling?.freezeHeader) wsOptions.views = [{ state: "frozen", ySplit: 1 }];
262
+ ws = wb.addWorksheet(sheetName, wsOptions);
163
263
  if (cols.length) {
164
264
  ws.columns = cols.map((c) => ({
165
265
  key: c.key,
@@ -169,52 +269,188 @@ async function writeOneWorkbook(workbookSpec, wbIndex, target, options) {
169
269
  }));
170
270
  }
171
271
  applyStylingPostColumns(ws, sheetSpec.styling, cols.length);
172
- const data = sheetSpec.data || [];
173
- for (let i = 0; i < data.length; i++) {
174
- if (signal?.aborted) throw new ExcelIoError("Aborted", { code: "ABORTED" });
175
- let row = data[i];
272
+ onOpen(sheetName, part);
273
+ };
274
+ return {
275
+ get sheetName() {
276
+ return sheetName ?? firstName;
277
+ },
278
+ start(resolvedColumns) {
279
+ cols = resolvedColumns;
280
+ open();
281
+ },
282
+ write(row) {
283
+ if (rowsInPart >= maxRowsPerSheet) {
284
+ ws.commit();
285
+ open();
286
+ }
287
+ let out = row;
176
288
  if (beforeWrite) {
177
- row = beforeWrite(row, {
289
+ out = beforeWrite(row, {
178
290
  workbookIndex: wbIndex,
179
291
  workbookName: workbookSpec.workbook,
180
292
  sheetIndex: sIndex,
181
293
  sheetName,
182
- rowNumber: i + 2
294
+ sheetPart: part,
295
+ rowNumber: rowsInPart + 2
183
296
  // header is row 1
184
297
  }) ?? row;
185
298
  }
186
- const rowValues = cols.length ? cols.reduce((acc, c) => {
187
- acc[c.key] = valueForWrite(row[c.key], c.type);
299
+ const values = cols.length ? cols.reduce((acc, c) => {
300
+ acc[c.key] = valueForWrite(out[c.key], c.type);
188
301
  return acc;
189
- }, {}) : row;
190
- ws.addRow(rowValues).commit();
191
- rowsProcessed++;
192
- if (onProgress && rowsProcessed % batchSize === 0) {
193
- onProgress({ phase: "write", workbookIndex: wbIndex, sheetIndex: sIndex, rowsProcessed, totalRows });
194
- }
302
+ }, {}) : out;
303
+ ws.addRow(values).commit();
304
+ rowsInPart++;
305
+ onRow();
306
+ },
307
+ finish() {
308
+ ws?.commit();
195
309
  }
196
- ws.commit();
310
+ };
311
+ }
312
+ async function writeIterableSheet({ writer, wb, sink, sheetSpec, kind, signal, batchSize, wbIndex, sIndex }) {
313
+ let rowsRead = 0;
314
+ const details = () => ({ workbookIndex: wbIndex, sheetIndex: sIndex, sheetName: writer.sheetName, rowsRead });
315
+ let iterator;
316
+ try {
317
+ iterator = kind === "async" ? sheetSpec.data[Symbol.asyncIterator]() : sheetSpec.data[Symbol.iterator]();
318
+ } catch (err) {
319
+ throw sourceFailure(err, details(), signal);
197
320
  }
198
- await wb.commit();
199
- if (sink.cleanup) await sink.cleanup();
200
- if (onProgress) {
201
- onProgress({ phase: "write", workbookIndex: wbIndex, sheetIndex: sheets.length - 1, rowsProcessed, totalRows });
321
+ const next = async () => {
322
+ let step;
323
+ try {
324
+ step = await iterator.next();
325
+ } catch (err) {
326
+ throw sourceFailure(err, details(), signal);
327
+ }
328
+ if (step === null || typeof step !== "object") {
329
+ throw sourceFailure(new TypeError("Iterator result is not an object"), details(), signal);
330
+ }
331
+ return step;
332
+ };
333
+ let finished = false;
334
+ try {
335
+ let step = await next();
336
+ writer.start(resolveColumns(sheetSpec.columns, step.done ? [] : [step.value]));
337
+ while (!step.done) {
338
+ throwIfAborted(signal);
339
+ writer.write(step.value);
340
+ rowsRead++;
341
+ if (rowsRead % batchSize === 0) await waitForCapacity({ wb, sink, signal });
342
+ step = await next();
343
+ }
344
+ finished = true;
345
+ } finally {
346
+ if (!finished) await closeIterator(iterator);
202
347
  }
203
348
  }
204
- function resolveSink(target) {
349
+ var len = (n) => Number.isFinite(n) ? n : 0;
350
+ var streamLen = (s) => len(s?.writableLength ?? s?._writableState?.length) + len(s?.readableLength ?? s?._readableState?.length);
351
+ function backlogBytes(wb, sink) {
352
+ let total = streamLen(wb?.zip) + streamLen(wb?.zip?._module) + streamLen(sink?.writable);
353
+ for (const ws of wb?._worksheets ?? []) {
354
+ const sb = ws?._stream;
355
+ if (!sb) continue;
356
+ for (const buf of sb.buffers ?? []) total += len(buf?.length);
357
+ for (const p of sb.pipes ?? []) total += streamLen(p);
358
+ }
359
+ return total;
360
+ }
361
+ var backlogStats = { peak: 0 };
362
+ async function waitForCapacity({ wb, sink, signal }) {
363
+ const check = () => {
364
+ throwIfAborted(signal);
365
+ if (sink.failure) throw sink.failure;
366
+ };
367
+ for (; ; ) {
368
+ check();
369
+ const backlog = backlogBytes(wb, sink);
370
+ if (backlog > backlogStats.peak) backlogStats.peak = backlog;
371
+ if (backlog <= OUTPUT_HIGH_WATER_MARK) break;
372
+ await sleep(BACKLOG_POLL_MS);
373
+ }
374
+ await tick();
375
+ check();
376
+ }
377
+ function resolveSink(target, { owned = false } = {}) {
378
+ const track = (writable, isOwned) => {
379
+ const sink = { writable, owned: isOwned, failure: null };
380
+ let onError;
381
+ sink.failed = new Promise((_, reject) => {
382
+ onError = (err) => {
383
+ if (sink.failure) return;
384
+ sink.failure = err;
385
+ reject(err);
386
+ };
387
+ writable.on("error", onError);
388
+ });
389
+ sink.failed.catch(() => {
390
+ });
391
+ sink.release = () => {
392
+ if (!isOwned) writable.removeListener("error", onError);
393
+ };
394
+ return sink;
395
+ };
205
396
  if (typeof target === "string") {
206
397
  const fileStream = createWriteStream(target);
207
- const closed = new Promise((resolve, reject) => {
208
- fileStream.once("finish", resolve);
209
- fileStream.once("error", reject);
210
- });
211
- return { writable: fileStream, cleanup: () => closed };
398
+ const sink = track(fileStream, true);
399
+ const finished = new Promise((resolve) => fileStream.once("finish", resolve));
400
+ const closed = new Promise((resolve) => fileStream.once("close", resolve));
401
+ sink.cleanup = () => finished;
402
+ sink.remove = async () => {
403
+ fileStream.destroy();
404
+ await closed;
405
+ await rm(target, { force: true });
406
+ };
407
+ return sink;
212
408
  }
213
409
  if (target && typeof target.write === "function") {
214
- return { writable: target, cleanup: null };
410
+ return track(target, owned);
215
411
  }
216
412
  throw new ExcelIoError(`Invalid write target \u2014 expected file path or writable stream`, { code: "INVALID_SPEC" });
217
413
  }
414
+ async function abortWorkbook(wb, sink, err) {
415
+ const quiet = (s) => s?.on?.("error", () => {
416
+ });
417
+ try {
418
+ if (wb) {
419
+ wb.zip?.unpipe?.(sink.writable);
420
+ quiet(wb.zip);
421
+ quiet(wb.zip?._module);
422
+ for (const ws of wb._worksheets ?? []) (ws?._stream?.pipes ?? []).forEach(quiet);
423
+ wb.zip?.abort?.();
424
+ }
425
+ if (sink.remove) await sink.remove();
426
+ else if (sink.owned) sink.writable.destroy?.();
427
+ sink.release();
428
+ } catch (cleanupError) {
429
+ try {
430
+ if (typeof err === "object" && err !== null || typeof err === "function") err.cleanupError = cleanupError;
431
+ } catch {
432
+ }
433
+ }
434
+ }
435
+ function resolveSharedStrings(option = "auto", sheets) {
436
+ if (typeof option === "boolean") return option;
437
+ return !sheets.some((s) => isIterableSource(s?.data));
438
+ }
439
+ function computeTotalRows(sheets) {
440
+ let total = 0;
441
+ for (const s of sheets) {
442
+ const kind = rowSourceKind(s.data);
443
+ if (kind === "array") total += s.data.length;
444
+ else if (kind === "sync" || kind === "async") {
445
+ if (s.totalRows === void 0) return void 0;
446
+ total += s.totalRows;
447
+ }
448
+ }
449
+ return total;
450
+ }
451
+ function throwIfAborted(signal) {
452
+ if (signal?.aborted) throw new ExcelIoError("Aborted", { code: "ABORTED" });
453
+ }
218
454
  function applyWorkbookProperties(wb, props) {
219
455
  if (!props) return;
220
456
  if (props.author) wb.creator = props.author;
@@ -258,13 +494,47 @@ function validateSpec(spec) {
258
494
  if (!Array.isArray(spec) || spec.length === 0) {
259
495
  throw new ExcelIoError("Spec must be a non-empty array of workbooks", { code: "INVALID_SPEC" });
260
496
  }
261
- for (const wb of spec) {
497
+ const owners = /* @__PURE__ */ new Map();
498
+ spec.forEach((wb, wbIndex) => {
262
499
  if (!wb || typeof wb !== "object") {
263
500
  throw new ExcelIoError("Workbook entries must be objects", { code: "INVALID_SPEC" });
264
501
  }
265
502
  if (!Array.isArray(wb.sheets) || wb.sheets.length === 0) {
266
503
  throw new ExcelIoError("Workbook must have at least one sheet", { code: "INVALID_SPEC" });
267
504
  }
505
+ wb.sheets.forEach((s, i) => {
506
+ if (!s || typeof s !== "object") {
507
+ throw new ExcelIoError(`Workbook ${wbIndex}: sheet entries must be objects`, { code: "INVALID_SPEC" });
508
+ }
509
+ const name = s.sheet || `Sheet ${i + 1}`;
510
+ const kind = rowSourceKind(s.data);
511
+ if (kind === "invalid") {
512
+ throw new ExcelIoError(`Sheet "${name}": data must be an array or an iterable of row objects`, { code: "INVALID_SPEC" });
513
+ }
514
+ if (kind === "sync" || kind === "async") {
515
+ if (owners.has(s.data)) {
516
+ throw new ExcelIoError(
517
+ `Sheet "${name}": its data source is already used by sheet "${owners.get(s.data)}". Pass a separate iterable (or an array) per sheet.`,
518
+ { code: "INVALID_SPEC" }
519
+ );
520
+ }
521
+ owners.set(s.data, name);
522
+ }
523
+ if (s.totalRows !== void 0 && !(Number.isInteger(s.totalRows) && s.totalRows >= 0)) {
524
+ throw new ExcelIoError(`Sheet "${name}": totalRows must be a non-negative integer`, { code: "INVALID_SPEC" });
525
+ }
526
+ });
527
+ });
528
+ }
529
+ function validateWriteOptions({ maxRowsPerSheet, sharedStrings, batchSize } = {}) {
530
+ if (maxRowsPerSheet !== void 0 && !(Number.isInteger(maxRowsPerSheet) && maxRowsPerSheet >= 1 && maxRowsPerSheet <= EXCEL_MAX_DATA_ROWS)) {
531
+ throw new ExcelIoError(`maxRowsPerSheet must be an integer between 1 and ${EXCEL_MAX_DATA_ROWS}`, { code: "INVALID_SPEC" });
532
+ }
533
+ if (sharedStrings !== void 0 && sharedStrings !== "auto" && typeof sharedStrings !== "boolean") {
534
+ throw new ExcelIoError(`sharedStrings must be 'auto', true or false`, { code: "INVALID_SPEC" });
535
+ }
536
+ if (batchSize !== void 0 && !(Number.isInteger(batchSize) && batchSize >= 1)) {
537
+ throw new ExcelIoError("batchSize must be a positive integer", { code: "INVALID_SPEC" });
268
538
  }
269
539
  }
270
540
 
@@ -418,6 +688,10 @@ function decideMode(op, payload, options) {
418
688
  if (op === "read" && payload && typeof payload.pipe === "function") {
419
689
  return { mode: "inline", reason: "stream input" };
420
690
  }
691
+ if (op === "write" && specHasIterable(payload)) {
692
+ if (useWorker === true) options.logger?.warn?.("excelio: iterable sheet data is written inline; useWorker ignored");
693
+ return { mode: "inline", reason: "iterable sheet data" };
694
+ }
421
695
  if (hasHook(options)) return { mode: "inline", reason: "hooks present" };
422
696
  if (useWorker === false) return { mode: "inline", reason: "disabled" };
423
697
  if (useWorker === true) return { mode: "worker", reason: "forced" };
@@ -445,7 +719,7 @@ function estimateRows(spec) {
445
719
  let n = 0;
446
720
  for (const wb of spec) {
447
721
  for (const sheet of wb?.sheets || []) {
448
- n += sheet.data?.length || 0;
722
+ n += Array.isArray(sheet?.data) ? sheet.data.length : 0;
449
723
  }
450
724
  }
451
725
  return n;
@@ -521,13 +795,16 @@ var excelio = {
521
795
  * is provided and returns the target.
522
796
  */
523
797
  async write(spec, options = {}) {
798
+ preflightWrite(spec, options);
524
799
  return maybeUseWorker("write", spec, options, () => writeSpec(spec, options));
525
800
  },
526
801
  /**
527
802
  * Returns a Readable stream for the first workbook. Synchronous return;
528
- * errors during the underlying write surface via the stream's 'error' event.
803
+ * spec errors throw immediately, errors during the underlying write surface
804
+ * via the stream's 'error' event.
529
805
  */
530
806
  writeStream(spec, options = {}) {
807
+ preflightWrite(spec, options);
531
808
  return writeSpecStream(spec, options);
532
809
  },
533
810
  /**