@nazarkalytiuk/rusc-xlsx 2.0.0 → 2.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.
Files changed (6) hide show
  1. package/README.md +68 -5
  2. package/index.d.ts +72 -14
  3. package/index.js +167 -32
  4. package/package.json +44 -17
  5. package/wrapper.d.ts +127 -521
  6. package/wrapper.js +1228 -1424
package/README.md CHANGED
@@ -68,7 +68,7 @@ npm install @nazarkalytiuk/rusc-xlsx
68
68
  ### Requirements
69
69
 
70
70
  - Node.js >= 22.13
71
- - Rust >= 1.88 (only when building from source)
71
+ - Rust >= 1.95 (only when building from source)
72
72
 
73
73
  ## Quick Start
74
74
 
@@ -171,6 +171,9 @@ workbook.save('output.xlsx');
171
171
  - `setTempdir(path)` - Set temp directory for memory modes
172
172
  - `setDefaultFormat(format, rowHeight, columnWidth)` - Set workbook-wide default formatting before adding worksheets
173
173
  - `useExcel2023Theme()` / `useCustomTheme(path)` - Select the workbook theme before adding worksheets
174
+ - `setCalculationMode(mode)` - Formula recalculation: `'automatic'`, `'manual'` or `'partial'`
175
+ - `setZipCompressionLevel(level)` - Trade file size for speed (1-9, default 6)
176
+ - `setTabRatio(ratio)` - Width of the sheet tab bar vs. the horizontal scroll bar (0-100)
174
177
  - `save(filename)` - Save to file
175
178
  - `saveToBuffer()` - Save to Buffer
176
179
  - `saveToStream(writable)` - Save through a temp file and await stream completion
@@ -187,7 +190,7 @@ workbook.save('output.xlsx');
187
190
  - **Features**: `insertNote()`, `addDataValidation()`, `insertChart()`, `insertImage()`, `insertImageFitToCellCentered()`, `addTable()`, `insertButton()`, `insertShape()`
188
191
  - **Conditional Formatting**: 14 methods for all rule types
189
192
  - **Page Setup**: 26 methods for printing, margins, headers, etc.
190
- - **View**: `autofilter()`, `setFreezePanes()`, `setZoom()`, `setTabColor()`, `hideSheet()`
193
+ - **View**: `autofilter()`, `setFreezePanes()`, `setZoom()`, `setTabColor()`, `setHiddenZeroes()`, `setHidden()`
191
194
  - **Hyperlinks**: `writeUrl()`
192
195
  - **Protection**: `protect()`, `protectWithPassword()`
193
196
  - **Rich Text**: `writeRichString()`
@@ -247,14 +250,31 @@ See the `examples/` directory for complete examples:
247
250
  git clone https://github.com/NazarKalytiuk/napi_rust_xlsxwrighter.git
248
251
  cd napi_rust_xlsxwrighter
249
252
 
250
- # Install dependencies
253
+ # Install dependencies (also builds wrapper.js + wrapper.d.ts from wrapper.ts via tsdown)
251
254
  npm install
252
255
 
253
256
  # Build native module
254
257
  npm run build
255
258
 
256
- # Run tests (577 tests)
259
+ # Rebuild the JavaScript wrapper after editing wrapper.ts
260
+ npm run build:wrapper
261
+
262
+ # Run tests (Vitest)
257
263
  npm test
264
+ npm run test:watch # re-run on change
265
+ npm run test:coverage # V8 coverage report for wrapper.ts
266
+ npm run test:coverage:rust # line coverage of src/*.rs via an instrumented addon;
267
+ # needs cargo-llvm-cov and `rustup component add llvm-tools`
268
+
269
+ # Format, lint (oxfmt, oxlint, rustfmt, clippy) and typecheck — same as CI
270
+ npm run check
271
+ npm run fmt # apply oxfmt + rustfmt
272
+
273
+ # rust_xlsxwriter coverage checklist (rust-xlsxwriter-coverage-checklist.md)
274
+ npm run coverage:check # verify test call sites, counts and the pinned version
275
+ npm run coverage:sync # refresh them after editing tests or row statuses; after a
276
+ # rust_xlsxwriter upgrade it documents the new release with
277
+ # rustdoc JSON (`rustup toolchain install nightly-2026-11-13`)
258
278
 
259
279
  # Run examples
260
280
  node examples/basic.js
@@ -267,7 +287,7 @@ node examples/basic.js
267
287
  Built with Rust and NAPI-RS for native-level performance:
268
288
  - **Fast execution**: Native Rust performance via NAPI bindings
269
289
  - **Low memory usage**: Optimized memory footprint across all modes
270
- - **Test performance**: All 577 tests complete in < 1 second
290
+ - **Test performance**: All 601 tests complete in < 1 second
271
291
 
272
292
  ### Memory Modes
273
293
 
@@ -337,6 +357,49 @@ for await (const item of cursor) {
337
357
  This keeps memory bounded to the current cursor item while retaining
338
358
  constant-memory worksheet generation.
339
359
 
360
+ ### Node.js vs Bun Benchmarks
361
+
362
+ The runtime benchmark executes the same package workload through the same
363
+ release N-API binary on Node.js and Bun:
364
+
365
+ ```bash
366
+ npm run benchmark:runtimes
367
+ ```
368
+
369
+ Bun must be installed and available in `PATH`. The default workload generates
370
+ 200,000 rows × 20 columns through `writeRow()` in constant-memory mode. The
371
+ runner alternates runtime order across three runs, reports both in-process
372
+ generation time and end-to-end process wall time, and verifies identical
373
+ worksheet XML size and CRC for every Node.js/Bun pair.
374
+
375
+ Override the workload or package API, or reuse the existing release build:
376
+
377
+ ```bash
378
+ npm run benchmark:runtimes -- --rows 100000 --columns 20 --runs 5
379
+ npm run benchmark:runtimes -- --write-mode cell
380
+ npm run benchmark:runtimes -- --write-mode batch --batch-rows 1000
381
+ npm run benchmark:runtimes -- --skip-build
382
+ ```
383
+
384
+ #### ExcelJS runtime comparison
385
+
386
+ To isolate the runtime difference for a JavaScript XLSX implementation, run the
387
+ same dataset through the ExcelJS streaming writer:
388
+
389
+ ```bash
390
+ npm run benchmark:runtimes:exceljs
391
+ ```
392
+
393
+ This profile uses ExcelJS `stream.xlsx.WorkbookWriter`, disables styles and
394
+ shared strings, fixes ZIP DEFLATE compression at level 6, and commits every
395
+ row. It uses the same alternating order, timing and memory metrics, and
396
+ worksheet-equivalence checks as the native-package profile. Override the
397
+ default workload when needed:
398
+
399
+ ```bash
400
+ npm run benchmark:runtimes:exceljs -- --rows 100000 --columns 20 --runs 5
401
+ ```
402
+
340
403
  ### Node.js vs Go, C#, and Python Benchmark
341
404
 
342
405
  The cross-language benchmark compares concrete streaming XLSX libraries, not
package/index.d.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  /* auto-generated by NAPI-RS */
2
2
  /* eslint-disable */
3
+
4
+ /**
5
+ * Which binding artifact the generated loader actually loaded: `'native'` for
6
+ * a native addon, otherwise the `platformArchABI` of the WASI flavor. Every
7
+ * flavor napi-rs can build is listed, because `NAPI_RS_NATIVE_LIBRARY_PATH`
8
+ * can point the loader at a WASI artifact this package does not build itself.
9
+ */
10
+ export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1'
11
+
3
12
  export declare class RuscButton {
4
13
  /** Create a new Button */
5
14
  static new(): RuscButton
@@ -277,10 +286,10 @@ export declare class RuscRichText {
277
286
  /** Create a new empty rich text string */
278
287
  constructor()
279
288
  /**
280
- * Add a text segment with formatting
281
- * The format is cloned internally for storage
289
+ * Add a text segment, optionally formatted; without a format the segment uses
290
+ * the default cell font. The format is cloned internally for storage.
282
291
  */
283
- addSegment(text: string, format: RuscFormat): this
292
+ addSegment(text: string, format?: RuscFormat | undefined | null): this
284
293
  }
285
294
 
286
295
  export declare class RuscShape {
@@ -301,7 +310,10 @@ export declare class RuscShape {
301
310
  export declare class RuscSparkline {
302
311
  /** Create a new Sparkline */
303
312
  static new(): RuscSparkline
304
- /** Set the data range for the sparkline (e.g., "Sheet1!A1:E1") */
313
+ /**
314
+ * Set the data range for the sparkline, e.g. "Sheet1!A1:E1", or "A1:E1"
315
+ * for a range on the worksheet the sparkline is added to
316
+ */
305
317
  setRange(range: string): this
306
318
  /** Set the type of sparkline: 'line', 'column', or 'winLose' */
307
319
  setType(sparklineType: string): this
@@ -374,7 +386,10 @@ export declare class RuscTable {
374
386
  setAltText(altText: string): this
375
387
  /** Set the alternative text title for screen readers. */
376
388
  setAltTextTitle(title: string): this
377
- /** Set the style of the table (1-21 for light styles, 22-49 for medium styles, 50-61 for dark styles) */
389
+ /**
390
+ * Set the style of the table: 0 for no style, 1-21 for light styles,
391
+ * 22-49 for medium styles and 50-60 for dark styles
392
+ */
378
393
  setStyle(styleId: number): this
379
394
  /** Add columns to the table */
380
395
  setColumns(columns: Array<RuscTableColumn>): this
@@ -410,6 +425,21 @@ export declare class RuscWorkbook {
410
425
  * Must be called before adding worksheets.
411
426
  */
412
427
  setDefaultFormat(format: RuscFormat, rowHeight: number, columnWidth: number): void
428
+ /**
429
+ * Set the zip compression level of the xlsx container (1-9, default 6).
430
+ * Lower levels are faster, higher levels produce smaller files.
431
+ */
432
+ setZipCompressionLevel(level: number): void
433
+ /**
434
+ * Set when Excel recalculates formulas: "automatic" (default), "manual",
435
+ * or "partial" (automatic except for data tables).
436
+ */
437
+ setCalculationMode(mode: string): void
438
+ /**
439
+ * Set the width of the worksheet tab bar as a percentage (0-100, default
440
+ * 60) of the space it shares with the horizontal scroll bar.
441
+ */
442
+ setTabRatio(ratio: number): void
413
443
  /**
414
444
  * Use the Excel 2023 Office theme with the Aptos default font.
415
445
  * Must be called before adding worksheets.
@@ -452,20 +482,25 @@ export declare class RuscWorkbook {
452
482
 
453
483
  /** Worksheet wrapper for Excel sheet manipulation */
454
484
  export declare class RuscWorksheet {
455
- /** Write a value to a cell (auto-detects type) */
456
- write(row: number, col: number, value: string | number | boolean, format?: RuscFormat | undefined | null): void
485
+ /**
486
+ * Write a value to a cell (auto-detects type). `null` or `undefined`
487
+ * writes nothing, or a blank cell with the format when one is given.
488
+ */
489
+ write(row: number, col: number, value: string | number | boolean | null | undefined, format?: RuscFormat | undefined | null): void
457
490
  /**
458
491
  * Write an array of values as a row starting at the given column.
459
492
  * Uses lazy Array iteration — reads one cell at a time from the JS array
460
493
  * instead of deserializing the entire row into Rust memory.
461
- * Null/undefined values are skipped (preserving column positions).
494
+ * Values are written as `write` writes them: `null`, `undefined` and
495
+ * holes leave the cell empty, or write a blank cell with the format.
462
496
  */
463
497
  writeRow(row: number, startCol: number, values: unknown[], format?: RuscFormat | undefined | null): void
464
498
  /**
465
499
  * Write a 2D array of values starting at the given position.
466
500
  * Uses lazy Array iteration — reads one row/cell at a time from JS,
467
501
  * never materializing the entire grid in Rust memory.
468
- * Null/undefined values are skipped (preserving column positions).
502
+ * Values are written as `write` writes them: `null`, `undefined` and
503
+ * holes leave the cell empty, or write a blank cell with the format.
469
504
  */
470
505
  writeRows(startRow: number, startCol: number, rows: unknown[], format?: RuscFormat | undefined | null): void
471
506
  /** Write a string to a cell */
@@ -488,8 +523,11 @@ export declare class RuscWorksheet {
488
523
  writeDatetimeFromYmdHms(row: number, col: number, year: number, month: number, day: number, hour: number, min: number, sec: number, format?: RuscFormat | undefined | null): void
489
524
  /** Write a datetime from an ISO 8601 string (e.g., "2023-01-25T12:30:00") */
490
525
  writeDatetimeFromString(row: number, col: number, datetimeStr: string, format?: RuscFormat | undefined | null): void
491
- /** Write a value with formatting to a cell */
492
- writeWithFormat(row: number, col: number, value: string | number | boolean, format: RuscFormat): void
526
+ /**
527
+ * Write a value with formatting to a cell; `null` or `undefined` writes
528
+ * a blank cell with the format
529
+ */
530
+ writeWithFormat(row: number, col: number, value: string | number | boolean | null | undefined, format: RuscFormat): void
493
531
  /** Set the width of a column */
494
532
  setColumnWidth(col: number, width: number): void
495
533
  /** Set the height of a row */
@@ -600,6 +638,11 @@ export declare class RuscWorksheet {
600
638
  setHidden(enable: boolean): void
601
639
  /** Set the worksheet zoom level (10-400) */
602
640
  setZoom(zoom: number): void
641
+ /**
642
+ * Hide zero values in worksheet cells, the equivalent of turning off the
643
+ * Excel option "Show a zero in cells that have zero value"
644
+ */
645
+ setHiddenZeroes(enable: boolean): void
603
646
  /** Set the default cell selection when opening the worksheet */
604
647
  setSelection(firstRow: number, firstCol: number, lastRow: number, lastCol: number): void
605
648
  /** Set which cell appears in the top-left corner when opening the worksheet */
@@ -640,8 +683,23 @@ export declare class RuscWorksheet {
640
683
  insertShapeWithOffset(row: number, col: number, shape: RuscShape, xOffset: number, yOffset: number): void
641
684
  }
642
685
 
643
- /** Resets peak memory tracking to current usage */
686
+ /**
687
+ * Resets peak memory tracking to the current allocation level.
688
+ * Call before a section you want to measure.
689
+ *
690
+ * @example
691
+ * rustMemoryResetPeak();
692
+ * // ... do work ...
693
+ * const [current, peak] = rustMemoryStats();
694
+ */
644
695
  export declare function rustMemoryResetPeak(): void
645
696
 
646
- /** Returns [currentBytes, peakBytes] of Rust allocator usage */
647
- export declare function rustMemoryStats(): Array<number>
697
+ /**
698
+ * Returns [currentBytes, peakBytes] of Rust allocator usage.
699
+ * Useful for profiling memory consumption of the native layer.
700
+ *
701
+ * @example
702
+ * const [current, peak] = rustMemoryStats();
703
+ * console.log(`Rust heap: ${(current / 1024 / 1024).toFixed(2)} MB (peak: ${(peak / 1024 / 1024).toFixed(2)} MB)`);
704
+ */
705
+ export declare function rustMemoryStats(): [number, number]
package/index.js CHANGED
@@ -1,10 +1,18 @@
1
- // prettier-ignore
2
1
  /* eslint-disable */
3
2
  // @ts-nocheck
4
3
  /* auto-generated by NAPI-RS */
5
4
 
6
5
  const { readFileSync } = require('fs')
7
6
  let nativeBinding = null
7
+ // Which artifact actually loaded. The WASI fallback chain overwrites it with
8
+ // the flavor it resolved; the late native retry below leaves it alone because
9
+ // it only runs while no WASI candidate has been loaded. The take-over check
10
+ // after the load-failure block can also set it.
11
+ let __napiLoadedBindingTarget = 'native'
12
+ // What this loader's own `requireNative()` calls returned. The check after
13
+ // the load-failure block compares it with `nativeBinding` to tell a binding
14
+ // this loader loaded from one that code outside it put in its place.
15
+ let __napiRequiredNativeBinding = null
8
16
  const loadErrors = []
9
17
 
10
18
  const isMusl = () => {
@@ -63,7 +71,16 @@ const isMuslFromChildProcess = () => {
63
71
  function requireNative() {
64
72
  if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) {
65
73
  try {
66
- return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH);
74
+ const overrideBinding = require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH)
75
+ // The override may be a generated WASI loader, which already reports its
76
+ // own flavor. Adopt it: `module.exports` aliases this object, so claiming
77
+ // 'native' would both misreport the artifact and overwrite the loader's
78
+ // marker through the alias.
79
+ __napiLoadedBindingTarget =
80
+ overrideBinding && typeof overrideBinding.__napiBindingTarget === 'string'
81
+ ? overrideBinding.__napiBindingTarget
82
+ : 'native'
83
+ return overrideBinding
67
84
  } catch (err) {
68
85
  loadErrors.push(err)
69
86
  }
@@ -107,36 +124,36 @@ function requireNative() {
107
124
  if (process.arch === 'x64') {
108
125
  if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) {
109
126
  try {
110
- return require('./rusc_xlsx.win32-x64-gnu.node')
111
- } catch (e) {
112
- loadErrors.push(e)
113
- }
114
- try {
115
- const binding = require('@nazarkalytiuk/rusc-xlsx-win32-x64-gnu')
116
- const bindingPackageVersion = require('@nazarkalytiuk/rusc-xlsx-win32-x64-gnu/package.json').version
117
- if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
118
- throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
127
+ return require('./rusc_xlsx.win32-x64-gnu.node')
128
+ } catch (e) {
129
+ loadErrors.push(e)
130
+ }
131
+ try {
132
+ const binding = require('@nazarkalytiuk/rusc-xlsx-win32-x64-gnu')
133
+ const bindingPackageVersion = require('@nazarkalytiuk/rusc-xlsx-win32-x64-gnu/package.json').version
134
+ if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
135
+ throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
136
+ }
137
+ return binding
138
+ } catch (e) {
139
+ loadErrors.push(e)
119
140
  }
120
- return binding
121
- } catch (e) {
122
- loadErrors.push(e)
123
- }
124
141
  } else {
125
142
  try {
126
- return require('./rusc_xlsx.win32-x64-msvc.node')
127
- } catch (e) {
128
- loadErrors.push(e)
129
- }
130
- try {
131
- const binding = require('@nazarkalytiuk/rusc-xlsx-win32-x64-msvc')
132
- const bindingPackageVersion = require('@nazarkalytiuk/rusc-xlsx-win32-x64-msvc/package.json').version
133
- if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
134
- throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
143
+ return require('./rusc_xlsx.win32-x64-msvc.node')
144
+ } catch (e) {
145
+ loadErrors.push(e)
146
+ }
147
+ try {
148
+ const binding = require('@nazarkalytiuk/rusc-xlsx-win32-x64-msvc')
149
+ const bindingPackageVersion = require('@nazarkalytiuk/rusc-xlsx-win32-x64-msvc/package.json').version
150
+ if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
151
+ throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
152
+ }
153
+ return binding
154
+ } catch (e) {
155
+ loadErrors.push(e)
135
156
  }
136
- return binding
137
- } catch (e) {
138
- loadErrors.push(e)
139
- }
140
157
  }
141
158
  } else if (process.arch === 'ia32') {
142
159
  try {
@@ -257,6 +274,22 @@ function requireNative() {
257
274
  } catch (e) {
258
275
  loadErrors.push(e)
259
276
  }
277
+ } else if (process.arch === 'ppc64') {
278
+ try {
279
+ return require('./rusc_xlsx.freebsd-ppc64.node')
280
+ } catch (e) {
281
+ loadErrors.push(e)
282
+ }
283
+ try {
284
+ const binding = require('@nazarkalytiuk/rusc-xlsx-freebsd-ppc64')
285
+ const bindingPackageVersion = require('@nazarkalytiuk/rusc-xlsx-freebsd-ppc64/package.json').version
286
+ if (bindingPackageVersion !== '2.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
287
+ throw new Error(`Native binding package version mismatch, expected 2.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
288
+ }
289
+ return binding
290
+ } catch (e) {
291
+ loadErrors.push(e)
292
+ }
260
293
  } else {
261
294
  loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`))
262
295
  }
@@ -550,7 +583,7 @@ function createLoadErrorChain(errors) {
550
583
  //
551
584
  // NAPI_RS_WASI_FLAVOR selects one exact generated flavor and implies strict
552
585
  // WASI loading. It never crosses into another flavor or falls back to native.
553
- const __napiWasiFlavors = ["wasm32-wasi"]
586
+ const __napiWasiFlavors = ['wasm32-wasi']
554
587
  const __napiWasiFlavor = process.env.NAPI_RS_WASI_FLAVOR
555
588
  const __napiWasiFlavorRequested =
556
589
  typeof __napiWasiFlavor === 'string' && __napiWasiFlavor.length > 0
@@ -573,6 +606,7 @@ const forceWasi =
573
606
 
574
607
  if (!forceWasi) {
575
608
  nativeBinding = requireNative()
609
+ __napiRequiredNativeBinding = nativeBinding
576
610
  }
577
611
 
578
612
  if (!nativeBinding || forceWasi) {
@@ -619,15 +653,16 @@ if (!nativeBinding || forceWasi) {
619
653
  }
620
654
  return null
621
655
  }
622
- if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) {
656
+ if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === 'wasm32-wasi')) {
623
657
  let candidateError = null
624
658
  let candidateFailed = false
625
659
  try {
626
- candidateError = __napiWasiResolveCandidate('./rusc_xlsx.wasi.cjs', false, ["./rusc_xlsx.wasm32-wasi.debug.wasm","./rusc_xlsx.wasm32-wasi.wasm"])
660
+ candidateError = __napiWasiResolveCandidate('./rusc_xlsx.wasi.cjs', false, ['./rusc_xlsx.wasm32-wasi.debug.wasm', './rusc_xlsx.wasm32-wasi.wasm'])
627
661
  candidateFailed = candidateError !== null
628
662
  if (!candidateFailed) {
629
663
  wasiBinding = require('./rusc_xlsx.wasi.cjs')
630
664
  nativeBinding = wasiBinding
665
+ __napiLoadedBindingTarget = 'wasm32-wasi'
631
666
  wasiBindingLoaded = true
632
667
  }
633
668
  } catch (err) {
@@ -639,7 +674,7 @@ if (!nativeBinding || forceWasi) {
639
674
  loadErrors.push(candidateError)
640
675
  }
641
676
  }
642
- if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) {
677
+ if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === 'wasm32-wasi')) {
643
678
  let candidateError = null
644
679
  let candidateFailed = false
645
680
  try {
@@ -654,6 +689,7 @@ if (!nativeBinding || forceWasi) {
654
689
  }
655
690
  wasiBinding = require('@nazarkalytiuk/rusc-xlsx-wasm32-wasi')
656
691
  nativeBinding = wasiBinding
692
+ __napiLoadedBindingTarget = 'wasm32-wasi'
657
693
  wasiBindingLoaded = true
658
694
  }
659
695
  } catch (err) {
@@ -672,6 +708,7 @@ if (!nativeBinding || forceWasi) {
672
708
  !__napiWasiFlavorRequested
673
709
  ) {
674
710
  nativeBinding = requireNative()
711
+ __napiRequiredNativeBinding = nativeBinding
675
712
  }
676
713
  if ((forceWasiError || __napiWasiFlavorRequested) && !wasiBindingLoaded) {
677
714
  const error = new Error(
@@ -699,6 +736,104 @@ if (!nativeBinding) {
699
736
  throw new Error(`Failed to load native binding`)
700
737
  }
701
738
 
739
+ // A package can patch its own fallback in front of the load-failure check
740
+ // above, e.g. a WebContainer download through `napi-postinstall/fallback`.
741
+ // That fallback swaps `nativeBinding` for a generated WASI loader, which has
742
+ // already stamped its own flavor, while the target above still says 'native'.
743
+ // Take that flavor over, or the CommonJS stamp throws a conflict and the ESM
744
+ // export reports the wrong artifact. Three limits keep the conflict check:
745
+ // - only a binding this loader did not load itself, so a native addon whose
746
+ // `#[napi(module_exports)]` hook claims the name is still rejected;
747
+ // - only an own data property, read through its descriptor, so no addon
748
+ // getter runs here, and it must read back the same, as the stamp reads it;
749
+ // - only a flavor in `__napiWasiFlavors`, the ones this loader's own WASI
750
+ // chain can report ('wasm32-wasi' for a package without a WASI target).
751
+ // This is metadata and never fails a load: a throwing Proxy trap keeps 'native'.
752
+ if (
753
+ __napiLoadedBindingTarget === 'native' &&
754
+ nativeBinding !== __napiRequiredNativeBinding
755
+ ) {
756
+ try {
757
+ const __napiBindingTargetDescriptor = Object.getOwnPropertyDescriptor(
758
+ nativeBinding,
759
+ '__napiBindingTarget',
760
+ )
761
+ if (
762
+ __napiBindingTargetDescriptor &&
763
+ __napiWasiFlavors.indexOf(__napiBindingTargetDescriptor.value) !== -1 &&
764
+ nativeBinding.__napiBindingTarget === __napiBindingTargetDescriptor.value
765
+ ) {
766
+ __napiLoadedBindingTarget = __napiBindingTargetDescriptor.value
767
+ }
768
+ } catch {
769
+ // keep 'native'
770
+ }
771
+ }
772
+
773
+ function __napiStampBindingTarget(exportsObject, target) {
774
+ if (
775
+ Object.prototype.hasOwnProperty.call(exportsObject, '__napiBindingTarget')
776
+ ) {
777
+ if (exportsObject.__napiBindingTarget === target) {
778
+ // Already ours: the root entry aliases the object it loaded, so a WASI
779
+ // fallback candidate — or a `NAPI_RS_NATIVE_LIBRARY_PATH` override that
780
+ // is a generated loader — arrives already stamped with this same value.
781
+ return target
782
+ }
783
+ const error = new Error(
784
+ '`__napiBindingTarget` is reserved by the generated binding loader, but the loaded binding already exports it. Rename the export, e.g. #[napi(js_name = "...")].',
785
+ )
786
+ error.code = 'ERR_NAPI_BINDING_TARGET_CONFLICT'
787
+ throw error
788
+ }
789
+ if (!Object.isExtensible(exportsObject)) {
790
+ // A `#[napi(module_exports)]` hook may seal or freeze this object
791
+ // (`Object::seal` / `Object::freeze`). Reporting the artifact is metadata,
792
+ // never a reason to fail an otherwise successful load, so the stamp is
793
+ // skipped. What a consumer still sees then follows the entry point: the
794
+ // browser and deferred loaders declare `__napiBindingTarget` at module
795
+ // level and go on reporting it, while the CommonJS entries hand back this
796
+ // very object as `module.exports`, so there the value is absent.
797
+ return target
798
+ }
799
+ try {
800
+ // [[Define]], not [[Set]]: an ordinary assignment walks the prototype
801
+ // chain, so an inherited accessor could swallow the value or throw and
802
+ // fail an otherwise successful load. The descriptor is what a successful
803
+ // assignment would have produced.
804
+ Object.defineProperty(exportsObject, '__napiBindingTarget', {
805
+ configurable: true,
806
+ enumerable: true,
807
+ value: target,
808
+ writable: true,
809
+ })
810
+ } catch {
811
+ // Same rule as the non-extensible skip above: reporting the artifact is
812
+ // metadata, never a reason to fail an otherwise successful load. An exotic
813
+ // object (a Proxy whose defineProperty trap refuses) is skipped, not
814
+ // thrown over.
815
+ }
816
+ // The CommonJS loaders assign this return value so `cjs-module-lexer` — and
817
+ // therefore Node's CJS -> ESM named export detection — can see
818
+ // `__napiBindingTarget` statically.
819
+ return target
820
+ }
821
+ // Stamp before the alias, not after. The guard only reads `nativeBinding`
822
+ // (`hasOwnProperty` plus a comparison), which is safe against any addon
823
+ // accessor; an assignment is not, because a `#[napi(module_exports)]` hook can
824
+ // expose a getter reporting this very value and a setter that throws. So the
825
+ // assignment lands on the loader's own `module.exports`, still the original
826
+ // object here, and the alias below replaces it.
827
+ //
828
+ // The assignment is what keeps the marker a statically visible CommonJS export:
829
+ // `cjs-module-lexer` is Node's CJS -> ESM named export detection, it cannot see
830
+ // a bare call, and the later `module.exports = nativeBinding` does not undo the
831
+ // detection. The assignment itself always succeeds — its target is this
832
+ // loader's own, still extensible `module.exports` — and the alias below then
833
+ // discards the value it wrote. What a consumer reads is whatever the guard put
834
+ // on `nativeBinding`, so on a frozen binding, where the guard skips, the
835
+ // linked import resolves to `undefined`.
836
+ module.exports.__napiBindingTarget = __napiStampBindingTarget(nativeBinding, __napiLoadedBindingTarget)
702
837
  module.exports = nativeBinding
703
838
  module.exports.RuscButton = nativeBinding.RuscButton
704
839
  module.exports.RuscChart = nativeBinding.RuscChart
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@nazarkalytiuk/rusc-xlsx",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Fast Excel XLSX library for Node.js powered by rust_xlsxwriter",
5
+ "type": "commonjs",
5
6
  "main": "wrapper.js",
6
7
  "types": "wrapper.d.ts",
7
8
  "repository": {
@@ -32,14 +33,29 @@
32
33
  "artifacts": "napi artifacts",
33
34
  "build": "napi build --platform --release",
34
35
  "build:debug": "napi build --platform",
36
+ "build:wrapper": "tsdown",
37
+ "prepare": "tsdown",
35
38
  "benchmark": "node benchmarks/compare-generation.js",
36
39
  "benchmark:languages": "node benchmarks/compare-languages.js",
37
- "prepublishOnly": "napi prepublish -t npm",
38
- "test": "jest",
40
+ "benchmark:runtimes": "node benchmarks/compare-runtimes.js",
41
+ "benchmark:runtimes:exceljs": "node benchmarks/compare-runtimes.js --library exceljs",
42
+ "prepublishOnly": "napi prepublish -t npm --no-gh-release",
43
+ "test": "vitest run",
44
+ "test:watch": "vitest",
45
+ "test:coverage": "vitest run --coverage",
46
+ "test:coverage:rust": "bash scripts/rust-coverage.sh",
47
+ "lint": "oxlint --deny-warnings",
48
+ "lint:rust": "cargo clippy --locked --all-targets -- -D warnings",
49
+ "fmt": "oxfmt && cargo fmt --all",
50
+ "fmt:check": "oxfmt --check && cargo fmt --all -- --check",
51
+ "lint:package": "publint --strict && attw --pack .",
39
52
  "typecheck": "tsc --project tsconfig.json",
53
+ "check": "npm run fmt:check && npm run lint && npm run lint:rust && npm run lint:package && npm run typecheck && npm run coverage:check",
40
54
  "universal": "napi universalize",
41
55
  "version": "napi version",
42
- "clean": "cargo clean && rm -rf *.node"
56
+ "clean": "cargo clean && rm -rf *.node",
57
+ "coverage:sync": "node scripts/coverage-checklist/cli.mts",
58
+ "coverage:check": "node scripts/coverage-checklist/cli.mts --check"
43
59
  },
44
60
  "keywords": [
45
61
  "xlsx",
@@ -61,22 +77,33 @@
61
77
  "LICENSE"
62
78
  ],
63
79
  "devDependencies": {
64
- "@napi-rs/cli": "^3.8.2",
65
- "@types/node": "^24.13.3",
66
- "adm-zip": "^0.6.0",
67
- "jest": "^30.4.2",
68
- "typescript": "^7.0.2"
80
+ "@arethetypeswrong/cli": "^0.18.5",
81
+ "@napi-rs/cli": "^3.10.12",
82
+ "@types/node": "^26.6.6",
83
+ "@vitest/coverage-v8": "^5.1.0",
84
+ "adm-zip": "^0.6.1",
85
+ "exceljs": "^4.4.0",
86
+ "oxc-parser": "^0.158.0",
87
+ "oxfmt": "^0.78.0",
88
+ "oxlint": "^1.93.0",
89
+ "publint": "^0.3.25",
90
+ "tsdown": "^0.23.7",
91
+ "typescript": "^7.0.2",
92
+ "vitest": "^5.1.0"
69
93
  },
70
94
  "engines": {
71
95
  "node": ">=22.13.0"
72
96
  },
97
+ "publishConfig": {
98
+ "access": "public"
99
+ },
73
100
  "optionalDependencies": {
74
- "@nazarkalytiuk/rusc-xlsx-win32-x64-msvc": "2.0.0",
75
- "@nazarkalytiuk/rusc-xlsx-win32-ia32-msvc": "2.0.0",
76
- "@nazarkalytiuk/rusc-xlsx-win32-arm64-msvc": "2.0.0",
77
- "@nazarkalytiuk/rusc-xlsx-darwin-universal": "2.0.0",
78
- "@nazarkalytiuk/rusc-xlsx-linux-x64-gnu": "2.0.0",
79
- "@nazarkalytiuk/rusc-xlsx-linux-arm64-gnu": "2.0.0",
80
- "@nazarkalytiuk/rusc-xlsx-linux-x64-musl": "2.0.0"
101
+ "@nazarkalytiuk/rusc-xlsx-win32-x64-msvc": "2.1.0",
102
+ "@nazarkalytiuk/rusc-xlsx-win32-ia32-msvc": "2.1.0",
103
+ "@nazarkalytiuk/rusc-xlsx-win32-arm64-msvc": "2.1.0",
104
+ "@nazarkalytiuk/rusc-xlsx-darwin-universal": "2.1.0",
105
+ "@nazarkalytiuk/rusc-xlsx-linux-x64-gnu": "2.1.0",
106
+ "@nazarkalytiuk/rusc-xlsx-linux-arm64-gnu": "2.1.0",
107
+ "@nazarkalytiuk/rusc-xlsx-linux-x64-musl": "2.1.0"
81
108
  }
82
- }
109
+ }