@napi-rs/cli 3.8.1 → 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
@@ -153,9 +153,45 @@ module runs inside the host process, so `wasm32` or host-OS restrictions would
153
153
  make npm reject a direct install or skip the optional dependency on otherwise
154
154
  supported hosts.
155
155
 
156
+ Because nothing gates the install, the root package does not declare the WASI
157
+ package in `optionalDependencies` when native targets are also configured. npm
158
+ evaluates every `optionalDependencies` entry independently, so a declared WASI
159
+ package is downloaded by every consumer, including the ones that already
160
+ resolved a native package and will never load the `.wasm` binary. The generated
161
+ binding loader picks WASI at require time instead, and environments without a
162
+ native package are expected to install it on demand.
163
+
164
+ When WASI is the only configured target it is the primary artifact rather than a
165
+ fallback, so it is declared by default. Set `napi.wasm.optionalDependency` to
166
+ override the default in either direction:
167
+
168
+ ```json
169
+ {
170
+ "napi": {
171
+ "wasm": {
172
+ "optionalDependency": true
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
156
178
  `napi.wasm.initialMemory` is measured in 64 KiB WebAssembly pages. The regular
157
179
  Node and browser loaders retain the historical 4,000-page (250 MiB) default.
158
180
  The deferred `./workerd` loader defaults to 1,024 pages (64 MiB), leaving
159
181
  headroom under workerd's 128 MiB isolate limit. An explicit
160
182
  `napi.wasm.initialMemory` value applies to every loader, so keep it within the
161
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.1",
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() {␊
@@ -0,0 +1,141 @@
1
+ import test from 'ava'
2
+
3
+ import {
4
+ parseNpmPackFiles,
5
+ resolveRootOptionalDependencies,
6
+ } from '../pre-publish.js'
7
+ import { parseTriple } from '../../utils/index.js'
8
+
9
+ const PACKAGE_NAME = '@scope/pkg'
10
+ const VERSION = '1.2.3'
11
+
12
+ const NATIVE_TARGETS = ['aarch64-apple-darwin', 'x86_64-unknown-linux-gnu'].map(
13
+ parseTriple,
14
+ )
15
+ const WASI_TARGET = parseTriple('wasm32-wasip1-threads')
16
+ const THREADLESS_WASI_TARGET = parseTriple('wasm32-wasip1')
17
+
18
+ const NATIVE_ENTRIES = {
19
+ [`${PACKAGE_NAME}-darwin-arm64`]: VERSION,
20
+ [`${PACKAGE_NAME}-linux-x64-gnu`]: VERSION,
21
+ }
22
+
23
+ function resolve(
24
+ targets: Parameters<typeof resolveRootOptionalDependencies>[0]['targets'],
25
+ overrides: Partial<
26
+ Parameters<typeof resolveRootOptionalDependencies>[0]
27
+ > = {},
28
+ ) {
29
+ return resolveRootOptionalDependencies({
30
+ existing: undefined,
31
+ managedPackageNames: [PACKAGE_NAME],
32
+ packageName: PACKAGE_NAME,
33
+ targets,
34
+ version: VERSION,
35
+ ...overrides,
36
+ })
37
+ }
38
+
39
+ test('omits the WASI package when native targets are configured', (t) => {
40
+ // The WASI binary is a require-time fallback, not something npm should
41
+ // install on hosts that already resolved a native package.
42
+ t.deepEqual(resolve([...NATIVE_TARGETS, WASI_TARGET]), NATIVE_ENTRIES)
43
+ })
44
+
45
+ test('omits the threadless WASI package too', (t) => {
46
+ t.deepEqual(
47
+ resolve([...NATIVE_TARGETS, THREADLESS_WASI_TARGET]),
48
+ NATIVE_ENTRIES,
49
+ )
50
+ })
51
+
52
+ test('declares the WASI package when it is the only target', (t) => {
53
+ // With no native package to fall back from, WASI is the primary artifact.
54
+ t.deepEqual(resolve([WASI_TARGET]), {
55
+ [`${PACKAGE_NAME}-wasm32-wasi`]: VERSION,
56
+ })
57
+ })
58
+
59
+ test('declares every WASI flavor when only WASI targets are configured', (t) => {
60
+ t.deepEqual(resolve([WASI_TARGET, THREADLESS_WASI_TARGET]), {
61
+ [`${PACKAGE_NAME}-wasm32-wasi`]: VERSION,
62
+ [`${PACKAGE_NAME}-wasm32-wasip1`]: VERSION,
63
+ })
64
+ })
65
+
66
+ test('wasm.optionalDependency=true opts back into declaring WASI', (t) => {
67
+ t.deepEqual(
68
+ resolve([...NATIVE_TARGETS, WASI_TARGET], {
69
+ wasm: { optionalDependency: true },
70
+ }),
71
+ {
72
+ ...NATIVE_ENTRIES,
73
+ [`${PACKAGE_NAME}-wasm32-wasi`]: VERSION,
74
+ },
75
+ )
76
+ })
77
+
78
+ test('wasm.optionalDependency=false opts out even for WASI-only builds', (t) => {
79
+ t.deepEqual(
80
+ resolve([WASI_TARGET], { wasm: { optionalDependency: false } }),
81
+ {},
82
+ )
83
+ })
84
+
85
+ test('drops a stale WASI entry left over from a previous release', (t) => {
86
+ // Consumers upgrading from a release that did declare the WASI package must
87
+ // not keep the entry, otherwise the regression survives the fix.
88
+ t.deepEqual(
89
+ resolve([...NATIVE_TARGETS, WASI_TARGET], {
90
+ existing: {
91
+ ...NATIVE_ENTRIES,
92
+ [`${PACKAGE_NAME}-wasm32-wasi`]: '1.2.2',
93
+ },
94
+ }),
95
+ NATIVE_ENTRIES,
96
+ )
97
+ })
98
+
99
+ test('preserves unmanaged optionalDependencies', (t) => {
100
+ t.deepEqual(
101
+ resolve([...NATIVE_TARGETS, WASI_TARGET], {
102
+ existing: { 'unrelated-package': '^1.0.0' },
103
+ }),
104
+ {
105
+ 'unrelated-package': '^1.0.0',
106
+ ...NATIVE_ENTRIES,
107
+ },
108
+ )
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(
@@ -60,6 +60,7 @@ import {
60
60
  type CommonPackageJsonFields,
61
61
  type FileSystemTransactionWrite,
62
62
  type Target,
63
+ type UserNapiConfig,
63
64
  } from '../utils/index.js'
64
65
 
65
66
  const debug = debugFactory('pre-publish')
@@ -227,6 +228,61 @@ async function copyOwnedTemporaryPath(source: string, destination: string) {
227
228
  }
228
229
  }
229
230
 
231
+ function wasiIsOnlyTarget(targets: Target[]) {
232
+ return (
233
+ targets.length > 0 && targets.every((target) => target.platform === 'wasi')
234
+ )
235
+ }
236
+
237
+ /**
238
+ * Build the root package's `optionalDependencies` map.
239
+ *
240
+ * A WASI package is a fallback for hosts that cannot load a `.node` binary, and
241
+ * the generated binding loader selects it at require time rather than npm
242
+ * selecting it at install time. Declaring it as an `optionalDependency`
243
+ * alongside the native packages cannot express "install this only when nothing
244
+ * else matched": npm evaluates every entry independently, so every consumer
245
+ * downloads a `.wasm` binary they will never load.
246
+ *
247
+ * It is therefore only declared when WASI is the only configured target, which
248
+ * makes it the primary artifact rather than a fallback.
249
+ * `napi.wasm.optionalDependency` overrides the default in both directions.
250
+ *
251
+ * See https://github.com/rolldown/rolldown/issues/10556
252
+ */
253
+ export function resolveRootOptionalDependencies({
254
+ existing,
255
+ managedPackageNames,
256
+ packageName,
257
+ targets,
258
+ version,
259
+ wasm,
260
+ }: {
261
+ existing: unknown
262
+ managedPackageNames: Iterable<string>
263
+ packageName: string
264
+ targets: Target[]
265
+ version: string
266
+ wasm?: UserNapiConfig['wasm']
267
+ }): Record<string, unknown> {
268
+ const optionalDependencies: Record<string, unknown> = {
269
+ ...asRecord(existing),
270
+ }
271
+ for (const managedPackageName of managedPackageNames) {
272
+ for (const suffix of MANAGED_OPTIONAL_DEPENDENCY_SUFFIXES) {
273
+ delete optionalDependencies[`${managedPackageName}-${suffix}`]
274
+ }
275
+ }
276
+ const declareWasi = wasm?.optionalDependency ?? wasiIsOnlyTarget(targets)
277
+ for (const target of targets) {
278
+ if (target.platform === 'wasi' && !declareWasi) {
279
+ continue
280
+ }
281
+ optionalDependencies[`${packageName}-${target.platformArchABI}`] = version
282
+ }
283
+ return optionalDependencies
284
+ }
285
+
230
286
  export async function prePublish(userOptions: PrePublishOptions) {
231
287
  debug('Receive pre-publish options:')
232
288
  debug(' %O', userOptions)
@@ -341,9 +397,6 @@ export async function prePublish(userOptions: PrePublishOptions) {
341
397
  rootFacade,
342
398
  )
343
399
  }
344
- const optionalDependencies = {
345
- ...asRecord(packageJson.optionalDependencies),
346
- }
347
400
  const managedPackageNames = new Set([packageName])
348
401
  for (const flavorPackage of rootFacadeReconciliation.managedFlavorPackages) {
349
402
  for (const suffix of MANAGED_OPTIONAL_DEPENDENCY_SUFFIXES) {
@@ -353,22 +406,19 @@ export async function prePublish(userOptions: PrePublishOptions) {
353
406
  }
354
407
  }
355
408
  }
356
- for (const managedPackageName of managedPackageNames) {
357
- for (const suffix of MANAGED_OPTIONAL_DEPENDENCY_SUFFIXES) {
358
- delete optionalDependencies[`${managedPackageName}-${suffix}`]
359
- }
360
- }
361
- for (const target of targets) {
362
- optionalDependencies[`${packageName}-${target.platformArchABI}`] =
363
- packageJson.version
364
- }
365
- const nodeEngine =
366
- targets.length > 0 &&
367
- targets.every((target) => target.platform === 'wasi')
368
- ? restrictWasiNodeEngine(
369
- packageJson.engines?.node ?? MINIMUM_WASI_NODE_VERSION,
370
- )
371
- : undefined
409
+ const optionalDependencies = resolveRootOptionalDependencies({
410
+ existing: packageJson.optionalDependencies,
411
+ managedPackageNames,
412
+ packageName,
413
+ targets,
414
+ version: packageJson.version,
415
+ wasm,
416
+ })
417
+ const nodeEngine = wasiIsOnlyTarget(targets)
418
+ ? restrictWasiNodeEngine(
419
+ packageJson.engines?.node ?? MINIMUM_WASI_NODE_VERSION,
420
+ )
421
+ : undefined
372
422
  const rootReleasePlan: RootReleaseMaterializationPlan = {
373
423
  packageJson: reconciledPackageJson,
374
424
  optionalDependencies,
@@ -1641,18 +1691,7 @@ function readNpmPackFiles(packageDir: string, packageDescription: string) {
1641
1691
  maxBuffer: 64 * 1024 * 1024,
1642
1692
  stdio: ['ignore', 'pipe', 'pipe'],
1643
1693
  })
1644
- const packResult = JSON.parse(output) as {
1645
- files?: { path?: unknown }[]
1646
- }[]
1647
- if (!Array.isArray(packResult) || !Array.isArray(packResult[0]?.files)) {
1648
- throw new Error('npm pack returned an unexpected JSON result')
1649
- }
1650
- return new Set(
1651
- packResult[0].files
1652
- .map(({ path }) => path)
1653
- .filter((path): path is string => typeof path === 'string')
1654
- .map((path) => path.replaceAll('\\', '/').replace(/^\.\//, '')),
1655
- )
1694
+ return parseNpmPackFiles(output)
1656
1695
  } catch (error) {
1657
1696
  throw new Error(
1658
1697
  `Failed to validate the ${packageDescription} with npm pack --dry-run. Ensure npm is available and the package can be packed.`,
@@ -1661,6 +1700,23 @@ function readNpmPackFiles(packageDir: string, packageDescription: string) {
1661
1700
  }
1662
1701
  }
1663
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
+
1664
1720
  function validateRootFacadePacklist(
1665
1721
  rootDir: string,
1666
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}\
@@ -72,10 +72,29 @@ export interface UserNapiConfig {
72
72
  */
73
73
  maximumMemory?: number
74
74
 
75
+ /**
76
+ * Whether the generated `<packageName>-wasm32-wasi` package is declared as
77
+ * an `optionalDependency` of the root package.
78
+ *
79
+ * When native targets are configured the WASI package is a fallback for
80
+ * hosts that cannot load a `.node` binary, so declaring it would make every
81
+ * consumer download the `.wasm` binary they will never load. It is
82
+ * therefore omitted by default and is expected to be installed on demand by
83
+ * the environments that need it.
84
+ *
85
+ * When WASI is the only configured target it is the primary artifact and is
86
+ * declared by default.
87
+ *
88
+ * Set this explicitly to override either default.
89
+ *
90
+ * @default true when every configured target is WASI, false otherwise
91
+ */
92
+ optionalDependency?: boolean
93
+
75
94
  /**
76
95
  * Browser wasm binding configuration
77
96
  */
78
- browser: {
97
+ browser?: {
79
98
  /**
80
99
  * Whether to use fs module in browser
81
100
  */