@napi-rs/cli 3.7.3 → 3.8.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 (51) hide show
  1. package/README.md +33 -0
  2. package/cli.mjs +9 -12
  3. package/dist/cli.js +9277 -1313
  4. package/dist/index.cjs +9266 -1302
  5. package/dist/index.d.cts +176 -158
  6. package/dist/index.d.ts +176 -158
  7. package/dist/index.js +9277 -1313
  8. package/docs/artifacts.md +33 -0
  9. package/docs/build.md +58 -0
  10. package/docs/create-npm-dirs.md +32 -0
  11. package/docs/new.md +39 -0
  12. package/docs/pre-publish.md +37 -0
  13. package/docs/rename.md +37 -0
  14. package/docs/universalize.md +31 -0
  15. package/docs/version.md +31 -0
  16. package/docs/wasi.md +161 -0
  17. package/package.json +11 -10
  18. package/src/api/__tests__/__snapshots__/templates.spec.ts.md +1019 -114
  19. package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
  20. package/src/api/__tests__/artifacts.spec.ts +17 -0
  21. package/src/api/__tests__/create-npm-dirs.spec.ts +14 -15
  22. package/src/api/__tests__/new.spec.ts +41 -5
  23. package/src/api/artifacts.ts +934 -108
  24. package/src/api/build.ts +1325 -194
  25. package/src/api/create-npm-dirs.ts +472 -101
  26. package/src/api/new.ts +354 -35
  27. package/src/api/pre-publish.ts +2845 -99
  28. package/src/api/rename.ts +1638 -124
  29. package/src/api/templates/js-binding.ts +216 -38
  30. package/src/api/templates/load-wasi-template.ts +1641 -111
  31. package/src/api/templates/wasi-worker-template.ts +32 -3
  32. package/src/commands/rename.ts +8 -3
  33. package/src/def/artifacts.ts +2 -2
  34. package/src/utils/__tests__/__fixtures__/optional-napi-derive/Cargo.toml +3 -0
  35. package/src/utils/__tests__/__fixtures__/optional-napi-derive/main-crate/Cargo.toml +8 -0
  36. package/src/utils/__tests__/__fixtures__/optional-napi-derive/main-crate/src/lib.rs +1 -0
  37. package/src/utils/__tests__/__fixtures__/optional-napi-derive/napi-derive/Cargo.toml +5 -0
  38. package/src/utils/__tests__/__fixtures__/optional-napi-derive/napi-derive/src/lib.rs +1 -0
  39. package/src/utils/__tests__/__fixtures__/optional-napi-derive/with-optional-derive/Cargo.toml +11 -0
  40. package/src/utils/__tests__/__fixtures__/optional-napi-derive/with-optional-derive/src/lib.rs +1 -0
  41. package/src/utils/__tests__/__snapshots__/target.spec.ts.md +2 -2
  42. package/src/utils/__tests__/__snapshots__/target.spec.ts.snap +0 -0
  43. package/src/utils/__tests__/metadata.spec.ts +43 -2
  44. package/src/utils/__tests__/misc.spec.ts +124 -3
  45. package/src/utils/__tests__/version.spec.ts +47 -1
  46. package/src/utils/config.ts +15 -2
  47. package/src/utils/metadata.ts +206 -7
  48. package/src/utils/misc.ts +6079 -0
  49. package/src/utils/target.ts +66 -8
  50. package/src/utils/typegen.ts +1223 -63
  51. package/src/utils/version.ts +96 -0
@@ -6,7 +6,13 @@ import { parentPort, Worker } from "node:worker_threads";
6
6
 
7
7
  const require = createRequire(import.meta.url);
8
8
 
9
- const { instantiateNapiModuleSync, MessageHandler, getDefaultContext } = require("@napi-rs/wasm-runtime");
9
+ const {
10
+ instantiateNapiModuleSync,
11
+ MessageHandler,
12
+ getDefaultContext,
13
+ emnapiAsyncWorkPlugin,
14
+ emnapiTSFNPlugin,
15
+ } = require("@napi-rs/wasm-runtime");
10
16
 
11
17
  if (parentPort) {
12
18
  parentPort.on("message", (data) => {
@@ -46,6 +52,11 @@ const handler = new MessageHandler({
46
52
  childThread: true,
47
53
  wasi,
48
54
  context: emnapiContext,
55
+ // The wasm links a "basic" emnapi archive (no C async-work /
56
+ // threadsafe-function implementations), so every thread that
57
+ // instantiates it must provide the JavaScript implementations
58
+ // through the emnapi plugins.
59
+ plugins: [emnapiAsyncWorkPlugin, emnapiTSFNPlugin],
49
60
  overwriteImports(importObject) {
50
61
  importObject.env = {
51
62
  ...importObject.env,
@@ -68,11 +79,24 @@ export const createWasiBrowserWorkerBinding = (
68
79
  errorEvent: boolean,
69
80
  ) => {
70
81
  const fsImport = fs
71
- ? `import { instantiateNapiModuleSync, MessageHandler, WASI, createFsProxy } from '@napi-rs/wasm-runtime'
82
+ ? `import {
83
+ instantiateNapiModuleSync,
84
+ MessageHandler,
85
+ WASI,
86
+ createFsProxy,
87
+ emnapiAsyncWorkPlugin,
88
+ emnapiTSFNPlugin,
89
+ } from '@napi-rs/wasm-runtime'
72
90
  import { memfsExported as __memfsExported } from '@napi-rs/wasm-runtime/fs'
73
91
 
74
92
  const fs = createFsProxy(__memfsExported)`
75
- : `import { instantiateNapiModuleSync, MessageHandler, WASI } from '@napi-rs/wasm-runtime'`
93
+ : `import {
94
+ instantiateNapiModuleSync,
95
+ MessageHandler,
96
+ WASI,
97
+ emnapiAsyncWorkPlugin,
98
+ emnapiTSFNPlugin,
99
+ } from '@napi-rs/wasm-runtime'`
76
100
  const errorOutputsAppend = errorEvent
77
101
  ? `\n errorOutputs.push([...arguments])`
78
102
  : ''
@@ -118,6 +142,11 @@ const handler = new MessageHandler({
118
142
  return instantiateNapiModuleSync(wasmModule, {
119
143
  childThread: true,
120
144
  wasi,
145
+ // The wasm links a "basic" emnapi archive (no C async-work /
146
+ // threadsafe-function implementations), so every thread that
147
+ // instantiates it must provide the JavaScript implementations
148
+ // through the emnapi plugins.
149
+ plugins: [emnapiAsyncWorkPlugin, emnapiTSFNPlugin],
121
150
  overwriteImports(importObject) {
122
151
  importObject.env = {
123
152
  ...importObject.env,
@@ -6,14 +6,19 @@ import { BaseRenameCommand } from '../def/rename.js'
6
6
  export class RenameCommand extends BaseRenameCommand {
7
7
  async execute() {
8
8
  const options = this.getOptions()
9
- if (!options.name) {
9
+ const hasExplicitRename = [
10
+ options.name,
11
+ options.binaryName,
12
+ options.packageName,
13
+ options.repository,
14
+ options.description,
15
+ ].some((value) => value !== undefined)
16
+ if (!hasExplicitRename) {
10
17
  const name = await input({
11
18
  message: `Enter the new package name in the package.json`,
12
19
  required: true,
13
20
  })
14
21
  options.name = name
15
- }
16
- if (!options.binaryName) {
17
22
  const binaryName = await input({
18
23
  message: `Enter the new binary name`,
19
24
  required: true,
@@ -34,7 +34,7 @@ export abstract class BaseArtifactsCommand extends Command {
34
34
 
35
35
  buildOutputDir?: string = Option.String('--build-output-dir', {
36
36
  description:
37
- 'Path to the build output dir, only needed when targets contains `wasm32-wasi-*`',
37
+ 'Path to the build output dir, only needed when targets contain a WASI target',
38
38
  })
39
39
 
40
40
  getOptions() {
@@ -82,7 +82,7 @@ export interface ArtifactsOptions {
82
82
  */
83
83
  npmDir?: string
84
84
  /**
85
- * Path to the build output dir, only needed when targets contains `wasm32-wasi-*`
85
+ * Path to the build output dir, only needed when targets contain a WASI target
86
86
  */
87
87
  buildOutputDir?: string
88
88
  }
@@ -0,0 +1,3 @@
1
+ [workspace]
2
+ resolver = "2"
3
+ members = ["napi-derive", "with-optional-derive", "main-crate"]
@@ -0,0 +1,8 @@
1
+ [package]
2
+ name = "main-crate"
3
+ version = "0.0.0"
4
+ edition = "2021"
5
+
6
+ [dependencies]
7
+ napi-derive = { path = "../napi-derive" }
8
+ with-optional-derive = { path = "../with-optional-derive" }
@@ -0,0 +1,5 @@
1
+ # Stand-in for the real `napi-derive` so `cargo metadata` resolves offline.
2
+ [package]
3
+ name = "napi-derive"
4
+ version = "0.0.0"
5
+ edition = "2021"
@@ -0,0 +1,11 @@
1
+ [package]
2
+ name = "with-optional-derive"
3
+ version = "0.0.0"
4
+ edition = "2021"
5
+
6
+ [dependencies]
7
+ napi-derive = { path = "../napi-derive", optional = true }
8
+
9
+ [features]
10
+ default = []
11
+ node = ["dep:napi-derive"]
@@ -167,8 +167,8 @@ Generated by [AVA](https://avajs.dev).
167
167
  abi: 'wasi',
168
168
  arch: 'wasm32',
169
169
  platform: 'wasi',
170
- platformArchABI: 'wasm32-wasi',
171
- triple: 'wasm32-wasi-preview1-threads',
170
+ platformArchABI: 'wasm32-wasip1',
171
+ triple: 'wasm32-wasip1',
172
172
  },
173
173
  {
174
174
  abi: 'wasi',
@@ -1,10 +1,17 @@
1
1
  import { unlink, writeFile } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
- import { join } from 'node:path'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
4
5
 
5
6
  import test from 'ava'
6
7
 
7
- import { parseMetadata } from '../metadata.js'
8
+ import { getNapiDeriveDependentCrates, parseMetadata } from '../metadata.js'
9
+
10
+ const FIXTURE_WORKSPACE = join(
11
+ dirname(fileURLToPath(import.meta.url)),
12
+ '__fixtures__',
13
+ 'optional-napi-derive',
14
+ )
8
15
 
9
16
  test('should surface child process startup errors', async (t) => {
10
17
  const manifestPath = join(
@@ -26,3 +33,37 @@ test('should surface child process startup errors', async (t) => {
26
33
  await unlink(manifestPath)
27
34
  }
28
35
  })
36
+
37
+ test('should exclude crates whose optional napi-derive dependency is disabled', async (t) => {
38
+ const metadata = await parseMetadata(
39
+ join(FIXTURE_WORKSPACE, 'main-crate', 'Cargo.toml'),
40
+ )
41
+ const dependentCrates = getNapiDeriveDependentCrates(metadata).map(
42
+ (crate) => crate.name,
43
+ )
44
+ t.deepEqual(dependentCrates, ['main-crate'])
45
+ })
46
+
47
+ test('should include crates whose optional napi-derive dependency is enabled via build features', async (t) => {
48
+ const metadata = await parseMetadata(
49
+ join(FIXTURE_WORKSPACE, 'with-optional-derive', 'Cargo.toml'),
50
+ {
51
+ features: ['node'],
52
+ },
53
+ )
54
+ const dependentCrates = getNapiDeriveDependentCrates(metadata).map(
55
+ (crate) => crate.name,
56
+ )
57
+ t.true(dependentCrates.includes('with-optional-derive'))
58
+ })
59
+
60
+ test('should fall back to declared dependencies when the resolve graph is unavailable', async (t) => {
61
+ const metadata = await parseMetadata(
62
+ join(FIXTURE_WORKSPACE, 'main-crate', 'Cargo.toml'),
63
+ )
64
+ metadata.resolve = null
65
+ const dependentCrates = getNapiDeriveDependentCrates(metadata)
66
+ .map((crate) => crate.name)
67
+ .sort()
68
+ t.deepEqual(dependentCrates, ['main-crate', 'with-optional-derive'])
69
+ })
@@ -1,11 +1,29 @@
1
- import { existsSync } from 'node:fs'
2
- import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
1
+ import { existsSync, type BigIntStats } from 'node:fs'
2
+ import {
3
+ lstat,
4
+ mkdtemp,
5
+ readdir,
6
+ readFile,
7
+ rename,
8
+ rm,
9
+ writeFile,
10
+ } from 'node:fs/promises'
3
11
  import { tmpdir } from 'node:os'
4
12
  import { join } from 'node:path'
5
13
 
6
14
  import ava, { type TestFn } from 'ava'
7
15
 
8
- import { updatePackageJson } from '../misc.js'
16
+ import {
17
+ retireFailedSnapshotLeftover,
18
+ snapshotLeftoverIsTransactionOwned,
19
+ statIdentitiesMatch,
20
+ updatePackageJson,
21
+ } from '../misc.js'
22
+
23
+ async function fileIdentityStrings(path: string) {
24
+ const stats = await lstat(path, { bigint: true })
25
+ return { dev: String(stats.dev), ino: String(stats.ino) }
26
+ }
9
27
 
10
28
  const test = ava as TestFn<{
11
29
  tmpDir: string
@@ -54,3 +72,106 @@ test('updatePackageJson merges nested objects instead of overwriting them', asyn
54
72
  '@napi-rs/fixture-darwin-arm64': '1.0.1',
55
73
  })
56
74
  })
75
+
76
+ test('statIdentitiesMatch distinguishes inodes that collide as lossy Numbers', (t) => {
77
+ const dev = 1n
78
+ const ino = 2n ** 53n
79
+ const owned = { dev, ino }
80
+ const successor = { dev, ino: ino + 1n }
81
+
82
+ // The defect being guarded against: past Number.MAX_SAFE_INTEGER two
83
+ // distinct 64-bit identifiers collapse onto the same JS double, so a
84
+ // numeric Stats.dev/ino comparison cannot tell these files apart.
85
+ t.is(Number(owned.ino), Number(successor.ino))
86
+
87
+ t.false(statIdentitiesMatch(owned, successor))
88
+ t.false(statIdentitiesMatch({ dev: dev + 1n, ino }, owned))
89
+ t.true(statIdentitiesMatch(owned, { dev, ino }))
90
+ t.false(statIdentitiesMatch(owned, undefined))
91
+ })
92
+
93
+ test('snapshotLeftoverIsTransactionOwned rejects Number-colliding successors', (t) => {
94
+ const identity = { dev: '1', ino: String(2n ** 53n) }
95
+ const ownedStats = {
96
+ isFile: () => true,
97
+ dev: 1n,
98
+ ino: 2n ** 53n,
99
+ } as unknown as BigIntStats
100
+ const successorStats = {
101
+ isFile: () => true,
102
+ dev: 1n,
103
+ ino: 2n ** 53n + 1n,
104
+ } as unknown as BigIntStats
105
+
106
+ t.true(snapshotLeftoverIsTransactionOwned(ownedStats, identity))
107
+ t.false(snapshotLeftoverIsTransactionOwned(successorStats, identity))
108
+ t.false(snapshotLeftoverIsTransactionOwned(undefined, identity))
109
+ })
110
+
111
+ test('retireFailedSnapshotLeftover removes the transaction-owned inode', async (t) => {
112
+ const destination = join(t.context.tmpDir, 'leftover.tmp')
113
+ await writeFile(destination, 'partial snapshot')
114
+ const identity = await fileIdentityStrings(destination)
115
+
116
+ const result = await retireFailedSnapshotLeftover(destination, identity)
117
+
118
+ t.deepEqual(result, { outcome: 'removed' })
119
+ t.false(existsSync(destination))
120
+ t.deepEqual(await readdir(t.context.tmpDir), [])
121
+ })
122
+
123
+ test('retireFailedSnapshotLeftover reports a missing leftover', async (t) => {
124
+ const destination = join(t.context.tmpDir, 'leftover.tmp')
125
+ await writeFile(destination, 'partial snapshot')
126
+ const identity = await fileIdentityStrings(destination)
127
+ await rm(destination)
128
+
129
+ const result = await retireFailedSnapshotLeftover(destination, identity)
130
+
131
+ t.deepEqual(result, { outcome: 'missing' })
132
+ t.deepEqual(await readdir(t.context.tmpDir), [])
133
+ })
134
+
135
+ test('retireFailedSnapshotLeftover keeps a pre-existing non-owned successor', async (t) => {
136
+ const destination = join(t.context.tmpDir, 'leftover.tmp')
137
+ await writeFile(destination, 'partial snapshot')
138
+ const identity = await fileIdentityStrings(destination)
139
+
140
+ // Replace the owned inode with a distinct one before cleanup runs. The
141
+ // successor is created as a sibling first so it deterministically has a
142
+ // different inode, then atomically renamed over the destination.
143
+ const successor = join(t.context.tmpDir, 'successor.tmp')
144
+ await writeFile(successor, 'successor content')
145
+ await rename(successor, destination)
146
+
147
+ const result = await retireFailedSnapshotLeftover(destination, identity)
148
+
149
+ t.deepEqual(result, { outcome: 'kept' })
150
+ t.is(await readFile(destination, 'utf8'), 'successor content')
151
+ t.deepEqual(await readdir(t.context.tmpDir), ['leftover.tmp'])
152
+ })
153
+
154
+ test('retireFailedSnapshotLeftover restores a successor swapped in during the race window', async (t) => {
155
+ const destination = join(t.context.tmpDir, 'leftover.tmp')
156
+ await writeFile(destination, 'partial snapshot')
157
+ const identity = await fileIdentityStrings(destination)
158
+
159
+ const successor = join(t.context.tmpDir, 'successor.tmp')
160
+ await writeFile(successor, 'successor content')
161
+
162
+ // Swap the successor onto the pathname after the ownership pre-check and
163
+ // before the retirement rename — the exact interval in which the previous
164
+ // lstat-then-unlink cleanup would have deleted a file the transaction never
165
+ // owned.
166
+ const result = await retireFailedSnapshotLeftover(
167
+ destination,
168
+ identity,
169
+ async () => {
170
+ await rename(successor, destination)
171
+ },
172
+ )
173
+
174
+ t.deepEqual(result, { outcome: 'kept' })
175
+ t.is(await readFile(destination, 'utf8'), 'successor content')
176
+ t.deepEqual(await readdir(t.context.tmpDir), ['leftover.tmp'])
177
+ })
@@ -1,7 +1,53 @@
1
1
  import test from 'ava'
2
2
 
3
- import { napiEngineRequirement, SUPPORTED_NAPI_VERSIONS } from '../version.js'
3
+ import {
4
+ MINIMUM_WASI_NODE_VERSION,
5
+ napiEngineRequirement,
6
+ restrictWasiNodeEngine,
7
+ SUPPORTED_NAPI_VERSIONS,
8
+ } from '../version.js'
4
9
 
5
10
  test('should generate correct napi engine requirement', (t) => {
6
11
  t.snapshot(SUPPORTED_NAPI_VERSIONS.map(napiEngineRequirement))
7
12
  })
13
+
14
+ test('should keep node engine ranges inside the WASI floor', (t) => {
15
+ t.is(restrictWasiNodeEngine('^22.14.0'), '^22.14.0')
16
+ t.is(restrictWasiNodeEngine('>=24'), '>=24')
17
+ })
18
+
19
+ test('should return the WASI floor for superset node engine ranges', (t) => {
20
+ t.is(restrictWasiNodeEngine('>=18'), MINIMUM_WASI_NODE_VERSION)
21
+ t.is(restrictWasiNodeEngine('>=12 <14 || >=18'), MINIMUM_WASI_NODE_VERSION)
22
+ })
23
+
24
+ test('should preserve partial intersections with the WASI floor', (t) => {
25
+ t.is(restrictWasiNodeEngine('>=22'), '>=22.13.0 <23.0.0-0 || >=23.5.0')
26
+ t.is(restrictWasiNodeEngine('<21'), '>=20.19.0 <21.0.0-0')
27
+ })
28
+
29
+ test('should reject node engine ranges disjoint from the WASI floor', (t) => {
30
+ for (const nodeRange of ['>=21 <22', '<20', '13.0.0', '21.0.0']) {
31
+ const error = t.throws(() => restrictWasiNodeEngine(nodeRange))
32
+ t.true(
33
+ error.message.includes(`"${nodeRange}"`),
34
+ `error should name the rejected range ${nodeRange}`,
35
+ )
36
+ t.true(
37
+ error.message.includes(`"${MINIMUM_WASI_NODE_VERSION}"`),
38
+ 'error should name the supported WASI versions',
39
+ )
40
+ }
41
+ })
42
+
43
+ test('should fall back to the WASI floor for malformed node engine ranges', (t) => {
44
+ t.is(restrictWasiNodeEngine('not-a-range'), MINIMUM_WASI_NODE_VERSION)
45
+ })
46
+
47
+ test('napi engine requirements always intersect the WASI floor', (t) => {
48
+ for (const napiVersion of SUPPORTED_NAPI_VERSIONS) {
49
+ t.notThrows(() =>
50
+ restrictWasiNodeEngine(napiEngineRequirement(napiVersion)),
51
+ )
52
+ }
53
+ })
@@ -64,7 +64,7 @@ export interface UserNapiConfig {
64
64
  wasm?: {
65
65
  /**
66
66
  * https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/Memory
67
- * @default 4000 pages (256MiB)
67
+ * @default 4000 pages (250 MiB), or 1024 pages (64 MiB) for the deferred workerd loader
68
68
  */
69
69
  initialMemory?: number
70
70
  /**
@@ -151,6 +151,7 @@ export interface CommonPackageJsonFields {
151
151
 
152
152
  dependencies?: Record<string, string>
153
153
  devDependencies?: Record<string, string>
154
+ optionalDependencies?: Record<string, string>
154
155
 
155
156
  ava?: {
156
157
  timeout?: string
@@ -265,7 +266,19 @@ export async function readNapiConfig(
265
266
  throw new Error(`Duplicate targets are not allowed: ${duplicateTarget}`)
266
267
  }
267
268
 
268
- napiConfig.targets = targets.map(parseTriple)
269
+ const parsedTargets = targets.map(parseTriple)
270
+ const outputTargets = new Map<string, string>()
271
+ for (const [index, target] of parsedTargets.entries()) {
272
+ const previous = outputTargets.get(target.platformArchABI)
273
+ if (previous) {
274
+ throw new Error(
275
+ `Targets ${previous} and ${targets[index]} produce the same ${target.platformArchABI} artifact set. Choose one target spelling.`,
276
+ )
277
+ }
278
+ outputTargets.set(target.platformArchABI, targets[index])
279
+ }
280
+
281
+ napiConfig.targets = parsedTargets
269
282
 
270
283
  return napiConfig
271
284
  }