@napi-rs/cli 3.8.2 → 3.8.3

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/docs/wasi.md CHANGED
@@ -181,3 +181,17 @@ The deferred `./workerd` loader defaults to 1,024 pages (64 MiB), leaving
181
181
  headroom under workerd's 128 MiB isolate limit. An explicit
182
182
  `napi.wasm.initialMemory` value applies to every loader, so keep it within the
183
183
  target isolate's limit after measuring the addon's actual requirements.
184
+
185
+ Threaded browser loaders always pre-create a pool of wasi-threads workers at
186
+ module initialization, sized as `asyncWorkPoolSize + hardwareConcurrency`
187
+ (logical cores, floored at 2, with a fallback for privacy-fuzzed values),
188
+ and therefore always initialize asynchronously. The `asyncWorkPoolSize`
189
+ reservation is included because emnapi's async-work pool draws its workers
190
+ from the same reuse pool; without it, async work could starve the pool
191
+ before addon thread spawns. This is what allows addon Rust code to spawn
192
+ threads from inside a blocking call: a browser cannot start a worker until
193
+ the blocking thread returns to its event loop, so a thread spawned mid-call
194
+ would never boot and the caller would deadlock waiting for it. With a
195
+ pre-created pool, spawning is only a message to an already-running worker,
196
+ and if the pool is exhausted the fallback allocates a fresh worker that
197
+ boots once the spawning parent returns to its event loop.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@napi-rs/cli",
3
- "version": "3.8.2",
3
+ "version": "3.8.3",
4
4
  "description": "Cli tools for napi-rs",
5
5
  "author": "LongYinan <lynweklm@gmail.com>",
6
6
  "homepage": "https://napi.rs/",
@@ -86,7 +86,7 @@
86
86
  "ava": "^8.0.1",
87
87
  "empathic": "^2.0.1",
88
88
  "env-paths": "^4.0.0",
89
- "oxc-parser": "^0.142.0",
89
+ "oxc-parser": "^0.143.0",
90
90
  "prettier": "^3.8.3",
91
91
  "tsdown": "^0.22.2",
92
92
  "tslib": "^2.8.1"
@@ -12,7 +12,7 @@ Generated by [AVA](https://avajs.dev).
12
12
  emnapiAsyncWorkPlugin as __emnapiAsyncWorkPlugin,␊
13
13
  emnapiTSFNPlugin as __emnapiTSFNPlugin,␊
14
14
  createOnMessage as __wasmCreateOnMessageForFsProxy,␊
15
- instantiateNapiModuleSync as __emnapiInstantiateNapiModuleSync,␊
15
+ instantiateNapiModule as __emnapiInstantiateNapiModule,␊
16
16
  WASI as __WASI,␊
17
17
  } from '@napi-rs/wasm-runtime'␊
18
18
  import { createContext as __emnapiCreateContext } from '@emnapi/runtime'␊
@@ -42,6 +42,11 @@ Generated by [AVA](https://avajs.dev).
42
42
  maximum: 65536,␊
43
43
  shared: true,␊
44
44
  })␊
45
+ const __asyncWorkPoolSize = 4␊
46
+ const __workerPoolSize = Math.max(␊
47
+ 2,␊
48
+ globalThis.navigator?.hardwareConcurrency ?? 4,␊
49
+ )␊
45
50
  ␊
46
51
  let __emnapiContext␊
47
52
  ␊
@@ -314,9 +319,10 @@ Generated by [AVA](https://avajs.dev).
314
319
  instance: __napiInstance,␊
315
320
  module: __wasiModule,␊
316
321
  napiModule: __napiModule,␊
317
- } = __emnapiInstantiateNapiModuleSync(__wasmFile, {␊
322
+ } = await __emnapiInstantiateNapiModule(__wasmFile, {␊
318
323
  context: __emnapiContext,␊
319
- asyncWorkPoolSize: 4,␊
324
+ asyncWorkPoolSize: __asyncWorkPoolSize,␊
325
+ reuseWorker: { size: __asyncWorkPoolSize + __workerPoolSize },␊
320
326
  plugins: [__emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],␊
321
327
  wasi: __wasi,␊
322
328
  onCreateWorker() {␊
@@ -361,7 +367,7 @@ Generated by [AVA](https://avajs.dev).
361
367
  emnapiAsyncWorkPlugin as __emnapiAsyncWorkPlugin,␊
362
368
  emnapiTSFNPlugin as __emnapiTSFNPlugin,␊
363
369
  createOnMessage as __wasmCreateOnMessageForFsProxy,␊
364
- instantiateNapiModuleSync as __emnapiInstantiateNapiModuleSync,␊
370
+ instantiateNapiModule as __emnapiInstantiateNapiModule,␊
365
371
  WASI as __WASI,␊
366
372
  } from '@napi-rs/wasm-runtime'␊
367
373
  import { createContext as __emnapiCreateContext } from '@emnapi/runtime'␊
@@ -391,6 +397,11 @@ Generated by [AVA](https://avajs.dev).
391
397
  maximum: 65536,␊
392
398
  shared: true,␊
393
399
  })␊
400
+ const __asyncWorkPoolSize = 4␊
401
+ const __workerPoolSize = Math.max(␊
402
+ 2,␊
403
+ globalThis.navigator?.hardwareConcurrency ?? 4,␊
404
+ )␊
394
405
  ␊
395
406
  let __emnapiContext␊
396
407
  ␊
@@ -663,9 +674,10 @@ Generated by [AVA](https://avajs.dev).
663
674
  instance: __napiInstance,␊
664
675
  module: __wasiModule,␊
665
676
  napiModule: __napiModule,␊
666
- } = __emnapiInstantiateNapiModuleSync(__wasmFile, {␊
677
+ } = await __emnapiInstantiateNapiModule(__wasmFile, {␊
667
678
  context: __emnapiContext,␊
668
- asyncWorkPoolSize: 4,␊
679
+ asyncWorkPoolSize: __asyncWorkPoolSize,␊
680
+ reuseWorker: { size: __asyncWorkPoolSize + __workerPoolSize },␊
669
681
  plugins: [__emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],␊
670
682
  wasi: __wasi,␊
671
683
  onCreateWorker() {␊
@@ -723,7 +735,7 @@ Generated by [AVA](https://avajs.dev).
723
735
  emnapiAsyncWorkPlugin as __emnapiAsyncWorkPlugin,␊
724
736
  emnapiTSFNPlugin as __emnapiTSFNPlugin,␊
725
737
  createOnMessage as __wasmCreateOnMessageForFsProxy,␊
726
- instantiateNapiModuleSync as __emnapiInstantiateNapiModuleSync,␊
738
+ instantiateNapiModule as __emnapiInstantiateNapiModule,␊
727
739
  WASI as __WASI,␊
728
740
  } from '@napi-rs/wasm-runtime'␊
729
741
  import { createContext as __emnapiCreateContext } from '@emnapi/runtime'␊
@@ -759,6 +771,11 @@ Generated by [AVA](https://avajs.dev).
759
771
  maximum: 65536,␊
760
772
  shared: true,␊
761
773
  })␊
774
+ const __asyncWorkPoolSize = 4␊
775
+ const __workerPoolSize = Math.max(␊
776
+ 2,␊
777
+ globalThis.navigator?.hardwareConcurrency ?? 4,␊
778
+ )␊
762
779
  ␊
763
780
  let __emnapiContext␊
764
781
  ␊
@@ -1031,9 +1048,10 @@ Generated by [AVA](https://avajs.dev).
1031
1048
  instance: __napiInstance,␊
1032
1049
  module: __wasiModule,␊
1033
1050
  napiModule: __napiModule,␊
1034
- } = __emnapiInstantiateNapiModuleSync(__wasmFile, {␊
1051
+ } = await __emnapiInstantiateNapiModule(__wasmFile, {␊
1035
1052
  context: __emnapiContext,␊
1036
- asyncWorkPoolSize: 4,␊
1053
+ asyncWorkPoolSize: __asyncWorkPoolSize,␊
1054
+ reuseWorker: { size: __asyncWorkPoolSize + __workerPoolSize },␊
1037
1055
  plugins: [__emnapiAsyncWorkPlugin, __emnapiTSFNPlugin],␊
1038
1056
  wasi: __wasi,␊
1039
1057
  onCreateWorker() {␊
@@ -1,6 +1,9 @@
1
1
  import test from 'ava'
2
2
 
3
- import { resolveRootOptionalDependencies } from '../pre-publish.js'
3
+ import {
4
+ parseNpmPackFiles,
5
+ resolveRootOptionalDependencies,
6
+ } from '../pre-publish.js'
4
7
  import { parseTriple } from '../../utils/index.js'
5
8
 
6
9
  const PACKAGE_NAME = '@scope/pkg'
@@ -104,3 +107,35 @@ test('preserves unmanaged optionalDependencies', (t) => {
104
107
  },
105
108
  )
106
109
  })
110
+
111
+ const NPM_PACK_FILES = [
112
+ { path: './package.json' },
113
+ { path: 'dist\\index.js' },
114
+ { path: 42 },
115
+ ]
116
+
117
+ test('parses npm 11 pack JSON output', (t) => {
118
+ t.deepEqual(
119
+ [...parseNpmPackFiles(JSON.stringify([{ files: NPM_PACK_FILES }]))],
120
+ ['package.json', 'dist/index.js'],
121
+ )
122
+ })
123
+
124
+ test('parses npm 12 pack JSON output', (t) => {
125
+ t.deepEqual(
126
+ [
127
+ ...parseNpmPackFiles(
128
+ JSON.stringify({
129
+ [PACKAGE_NAME]: { files: NPM_PACK_FILES },
130
+ }),
131
+ ),
132
+ ],
133
+ ['package.json', 'dist/index.js'],
134
+ )
135
+ })
136
+
137
+ test('rejects an unexpected npm pack JSON result', (t) => {
138
+ t.throws(() => parseNpmPackFiles(JSON.stringify({ files: [] })), {
139
+ message: 'npm pack returned an unexpected JSON result',
140
+ })
141
+ })
@@ -13,6 +13,54 @@ test('createWasiBrowserBinding default', (t) => {
13
13
  t.snapshot(createWasiBrowserBinding('test-wasi'))
14
14
  })
15
15
 
16
+ test('createWasiBrowserBinding threaded builds size worker pools from hardwareConcurrency', (t) => {
17
+ const binding = createWasiBrowserBinding(
18
+ 'test-wasi',
19
+ 4000,
20
+ 65536,
21
+ false,
22
+ false, // asyncInit: false — threaded builds still init asynchronously
23
+ false,
24
+ false,
25
+ true,
26
+ )
27
+ t.true(binding.includes('const __asyncWorkPoolSize = 4'))
28
+ t.true(binding.includes('const __workerPoolSize = Math.max('))
29
+ t.true(binding.includes('globalThis.navigator?.hardwareConcurrency ?? 4'))
30
+ // The reuse pool includes the async-work reservation: the async pool
31
+ // draws from the same reuse pool, so without it exhaustion would starve
32
+ // addon thread spawns. No `strict`: at exhaustion the fallback worker
33
+ // boots once the parent returns to its event loop, which is the correct
34
+ // behavior for spawn-and-return workloads.
35
+ t.true(
36
+ binding.includes(
37
+ 'reuseWorker: { size: __asyncWorkPoolSize + __workerPoolSize },',
38
+ ),
39
+ )
40
+ t.false(binding.includes('strict'))
41
+ t.true(binding.includes('asyncWorkPoolSize: __asyncWorkPoolSize,'))
42
+ t.true(binding.includes('await __emnapiInstantiateNapiModule('))
43
+ t.false(binding.includes('__emnapiInstantiateNapiModuleSync(__wasmFile'))
44
+ })
45
+
46
+ test('createWasiBrowserBinding threadless keeps sync init and no pool', (t) => {
47
+ const binding = createWasiBrowserBinding(
48
+ 'test-wasi',
49
+ 4000,
50
+ 65536,
51
+ false,
52
+ false,
53
+ false,
54
+ false,
55
+ false,
56
+ )
57
+ t.false(binding.includes('__workerPoolSize'))
58
+ t.false(binding.includes('hardwareConcurrency'))
59
+ t.false(binding.includes('reuseWorker'))
60
+ t.true(binding.includes('asyncWorkPoolSize: 0,'))
61
+ t.true(binding.includes('__emnapiInstantiateNapiModuleSync(__wasmFile'))
62
+ })
63
+
16
64
  test('createWasiBrowserBinding with errorEvent', (t) => {
17
65
  t.snapshot(
18
66
  createWasiBrowserBinding(
@@ -1691,18 +1691,7 @@ function readNpmPackFiles(packageDir: string, packageDescription: string) {
1691
1691
  maxBuffer: 64 * 1024 * 1024,
1692
1692
  stdio: ['ignore', 'pipe', 'pipe'],
1693
1693
  })
1694
- const packResult = JSON.parse(output) as {
1695
- files?: { path?: unknown }[]
1696
- }[]
1697
- if (!Array.isArray(packResult) || !Array.isArray(packResult[0]?.files)) {
1698
- throw new Error('npm pack returned an unexpected JSON result')
1699
- }
1700
- return new Set(
1701
- packResult[0].files
1702
- .map(({ path }) => path)
1703
- .filter((path): path is string => typeof path === 'string')
1704
- .map((path) => path.replaceAll('\\', '/').replace(/^\.\//, '')),
1705
- )
1694
+ return parseNpmPackFiles(output)
1706
1695
  } catch (error) {
1707
1696
  throw new Error(
1708
1697
  `Failed to validate the ${packageDescription} with npm pack --dry-run. Ensure npm is available and the package can be packed.`,
@@ -1711,6 +1700,23 @@ function readNpmPackFiles(packageDir: string, packageDescription: string) {
1711
1700
  }
1712
1701
  }
1713
1702
 
1703
+ export function parseNpmPackFiles(output: string) {
1704
+ const parsedResult: unknown = JSON.parse(output)
1705
+ const packResults = Array.isArray(parsedResult)
1706
+ ? parsedResult
1707
+ : Object.values(asRecord(parsedResult) ?? {})
1708
+ const files = asRecord(packResults[0])?.files
1709
+ if (!Array.isArray(files)) {
1710
+ throw new Error('npm pack returned an unexpected JSON result')
1711
+ }
1712
+ return new Set(
1713
+ files
1714
+ .map((file) => asRecord(file)?.path)
1715
+ .filter((path): path is string => typeof path === 'string')
1716
+ .map((path) => path.replaceAll('\\', '/').replace(/^\.\//, '')),
1717
+ )
1718
+ }
1719
+
1714
1720
  function validateRootFacadePacklist(
1715
1721
  rootDir: string,
1716
1722
  rootPackageFiles: string[],
@@ -271,6 +271,10 @@ export const createWasiBrowserBinding = (
271
271
  errorEvent = false,
272
272
  threads = true,
273
273
  ) => {
274
+ // Threaded builds always get a pre-created worker pool (see
275
+ // `reuseWorkerOption` below), and pool pre-creation is asynchronous, so
276
+ // they always initialize asynchronously.
277
+ const effectiveAsyncInit = asyncInit || threads
274
278
  const fsImport = fs
275
279
  ? buffer
276
280
  ? `import { memfs, Buffer } from '@napi-rs/wasm-runtime/fs'`
@@ -317,17 +321,55 @@ const __wasi = new __WASI({
317
321
  const emnapiInjectBuffer = buffer
318
322
  ? ' __emnapiContext.features.Buffer = Buffer\n'
319
323
  : ''
320
- const emnapiInstantiateImport = asyncInit
324
+ const emnapiInstantiateImport = effectiveAsyncInit
321
325
  ? `instantiateNapiModule as __emnapiInstantiateNapiModule`
322
326
  : `instantiateNapiModuleSync as __emnapiInstantiateNapiModuleSync`
323
- const emnapiInstantiateCall = asyncInit
327
+ const emnapiInstantiateCall = effectiveAsyncInit
324
328
  ? `await __emnapiInstantiateNapiModule`
325
329
  : `__emnapiInstantiateNapiModuleSync`
330
+ // The `reuseWorker` pool is what lets addon Rust code spawn threads while
331
+ // the calling thread is blocked inside the wasm call: a browser cannot
332
+ // start a worker until the blocking thread returns to its event loop, so
333
+ // a thread spawned mid-call can never boot and the caller deadlocks
334
+ // waiting for it. With a pre-created pool, spawning is only a message to
335
+ // an already-running worker.
336
+ //
337
+ // Its size comes from `navigator.hardwareConcurrency` at runtime (logical
338
+ // cores, floored at 2, with a fallback for privacy-fuzzed or missing
339
+ // values): a constant undersizes both ends of the range — big desktops
340
+ // leave parallelism on the table, and a fuzzed "2 cores" would
341
+ // oversubscribe.
342
+ //
343
+ // The reuse pool is sized as `__asyncWorkPoolSize + __workerPoolSize`
344
+ // because emnapi's async-work pool draws its workers from the SAME reuse
345
+ // pool: the async reservation must be included or async-work
346
+ // initialization can starve the reuse pool before addon threads spawn.
347
+ //
348
+ // `strict` is deliberately NOT set. Review suggested it so exhaustion
349
+ // errors instead of allocating a fresh worker, but it breaks
350
+ // spawn-and-return workloads (e.g. `testWorkers` in examples/napi, which
351
+ // spawns workers and joins them on a helper thread): at exhaustion their
352
+ // `std::thread::spawn` panics on EAGAIN. Without `strict` the fallback
353
+ // allocates a fresh worker, which boots normally once the spawning
354
+ // parent returns to its event loop — and for joins inside a blocked
355
+ // call, the pre-created pool is what those calls draw from anyway.
356
+ const workerPoolSizeBinding = threads
357
+ ? `const __asyncWorkPoolSize = 4
358
+ const __workerPoolSize = Math.max(
359
+ 2,
360
+ globalThis.navigator?.hardwareConcurrency ?? 4,
361
+ )
362
+
363
+ `
364
+ : ''
365
+ const reuseWorkerOption = threads
366
+ ? ` reuseWorker: { size: __asyncWorkPoolSize + __workerPoolSize },\n`
367
+ : ''
326
368
  const workerRuntimeImport = threads
327
369
  ? ` createOnMessage as __wasmCreateOnMessageForFsProxy,\n`
328
370
  : ''
329
371
  const memoryName = threads ? '__sharedMemory' : '__wasmMemory'
330
- const asyncWorkPoolOption = ` asyncWorkPoolSize: ${threads ? 4 : 0},
372
+ const asyncWorkPoolOption = ` asyncWorkPoolSize: ${threads ? '__asyncWorkPoolSize' : 0},
331
373
  `
332
374
  // Every build links a "basic" emnapi archive without the C async-work and
333
375
  // threadsafe-function implementations (the `emnapi-napi-rs(-mt)` archives shipped by the emnapi package), so the
@@ -380,7 +422,7 @@ const ${memoryName} = new WebAssembly.Memory({
380
422
  maximum: ${maximumMemory},
381
423
  ${threads ? ' shared: true,\n' : ''}\
382
424
  })
383
-
425
+ ${workerPoolSizeBinding}\
384
426
  let __emnapiContext
385
427
  ${emnapiContextLifecycle}
386
428
  let __wasiModule
@@ -397,6 +439,7 @@ try {
397
439
  } = ${emnapiInstantiateCall}(__wasmFile, {
398
440
  context: __emnapiContext,
399
441
  ${asyncWorkPoolOption}\
442
+ ${reuseWorkerOption}\
400
443
  ${emnapiPluginOption}\
401
444
  wasi: __wasi,
402
445
  ${workerOption}\