@napi-rs/cli 3.9.0 → 3.10.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 (39) hide show
  1. package/README.md +17 -4
  2. package/dist/cli.js +10359 -8729
  3. package/dist/index.cjs +10381 -8751
  4. package/dist/index.d.cts +83 -16
  5. package/dist/index.d.ts +83 -16
  6. package/dist/index.js +10359 -8729
  7. package/docs/wasi.md +318 -4
  8. package/package.json +5 -6
  9. package/src/api/__tests__/__snapshots__/templates.spec.ts.md +4003 -53
  10. package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
  11. package/src/api/__tests__/build-regressions.spec.ts +210 -3
  12. package/src/api/__tests__/build.spec.ts +2333 -3
  13. package/src/api/__tests__/create-npm-dirs.spec.ts +105 -3
  14. package/src/api/__tests__/pre-publish.spec.ts +153 -1
  15. package/src/api/__tests__/templates.spec.ts +1309 -1
  16. package/src/api/build.ts +756 -30
  17. package/src/api/create-npm-dirs.ts +23 -10
  18. package/src/api/new.ts +13 -18
  19. package/src/api/pre-publish.ts +34 -11
  20. package/src/api/rename.ts +10 -21
  21. package/src/api/templates/binding-target.ts +176 -0
  22. package/src/api/templates/index.ts +1 -0
  23. package/src/api/templates/js-binding.ts +54 -10
  24. package/src/api/templates/load-wasi-template.ts +661 -58
  25. package/src/api/templates/wasi-worker-template.ts +36 -31
  26. package/src/commands/build.ts +1 -1
  27. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.md +18 -28
  28. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.snap +0 -0
  29. package/src/utils/__tests__/misc.spec.ts +4 -0
  30. package/src/utils/__tests__/reconciliation.spec.ts +676 -0
  31. package/src/utils/__tests__/serialize.spec.ts +55 -0
  32. package/src/utils/__tests__/target.spec.ts +221 -0
  33. package/src/utils/__tests__/typegen.spec.ts +115 -0
  34. package/src/utils/config.ts +50 -0
  35. package/src/utils/index.ts +1 -0
  36. package/src/utils/misc.ts +351 -79
  37. package/src/utils/serialize.ts +47 -0
  38. package/src/utils/target.ts +150 -1
  39. package/src/utils/typegen.ts +608 -42
@@ -0,0 +1,55 @@
1
+ import test from 'ava'
2
+
3
+ import { serializeJson, serializeToml, serializeYaml } from '../serialize.js'
4
+
5
+ test('serializeJson uses 2-space indent and a trailing newline', (t) => {
6
+ t.is(
7
+ serializeJson({ name: 'pkg', files: ['a.node'] }),
8
+ `{
9
+ "name": "pkg",
10
+ "files": [
11
+ "a.node"
12
+ ]
13
+ }
14
+ `,
15
+ )
16
+ })
17
+
18
+ test('serializeJson always ends with exactly one newline', (t) => {
19
+ const serialized = serializeJson({ ok: true })
20
+ t.true(serialized.endsWith('\n'))
21
+ t.false(serialized.endsWith('\n\n'))
22
+ })
23
+
24
+ test('serializeToml strips the leading blank line @std/toml inserts', (t) => {
25
+ const serialized = serializeToml({
26
+ package: { name: 'demo', version: '0.1.0' },
27
+ dependencies: {
28
+ napi: { version: '3.0.0', features: ['napi4'] },
29
+ },
30
+ })
31
+ t.false(serialized.startsWith('\n'))
32
+ t.true(serialized.startsWith('[package]\n'))
33
+ t.true(serialized.endsWith('\n'))
34
+ t.false(serialized.endsWith('\n\n'))
35
+ })
36
+
37
+ test('serializeYaml always ends with a trailing newline', (t) => {
38
+ const serialized = serializeYaml({ on: { push: null } })
39
+ t.true(serialized.endsWith('\n'))
40
+ t.false(serialized.endsWith('\n\n'))
41
+ })
42
+
43
+ test('serializeToml does not rewrite commas inside quoted strings', (t) => {
44
+ const serialized = serializeToml({
45
+ lib: { include: ['src/a,b.rs'] },
46
+ package: {
47
+ name: 'demo',
48
+ description: 'uses = [a,b] in a scalar',
49
+ },
50
+ })
51
+ t.true(serialized.includes('include = ["src/a,b.rs"]'))
52
+ t.true(serialized.includes('description = "uses = [a,b] in a scalar"'))
53
+ t.false(serialized.includes('src/a, b.rs'))
54
+ t.false(serialized.includes('= [a, b]'))
55
+ })
@@ -1,13 +1,49 @@
1
+ import { realpathSync } from 'node:fs'
2
+ import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
1
3
  import os from 'os'
4
+ import { join } from 'node:path'
2
5
 
3
6
  import test from 'ava'
4
7
 
5
8
  import {
9
+ wasiLibcHasNewFutexAbi,
6
10
  parseTriple,
7
11
  getSystemDefaultTarget,
12
+ rustBundledWasiLibc,
13
+ wasiSdkMajorVersion,
8
14
  AVAILABLE_TARGETS,
9
15
  } from '../target.js'
10
16
 
17
+ async function withWasiSdkDir(
18
+ setup: (wasiSdkPath: string) => Promise<void>,
19
+ assertion: (wasiSdkPath: string) => void,
20
+ ) {
21
+ const wasiSdkPath = await mkdtemp(join(os.tmpdir(), 'napi-rs-wasi-sdk-'))
22
+ try {
23
+ await setup(wasiSdkPath)
24
+ assertion(wasiSdkPath)
25
+ } finally {
26
+ await rm(wasiSdkPath, { recursive: true, force: true })
27
+ }
28
+ }
29
+
30
+ async function writeVersionFile(wasiSdkPath: string, content: string) {
31
+ await writeFile(join(wasiSdkPath, 'VERSION'), content)
32
+ }
33
+
34
+ async function writeVersionHeader(wasiSdkPath: string, content: string) {
35
+ const headerDir = join(
36
+ wasiSdkPath,
37
+ 'share',
38
+ 'wasi-sysroot',
39
+ 'include',
40
+ 'wasm32-wasip1-threads',
41
+ 'wasi',
42
+ )
43
+ await mkdir(headerDir, { recursive: true })
44
+ await writeFile(join(headerDir, 'version.h'), content)
45
+ }
46
+
11
47
  test('should parse triple correctly', (t) => {
12
48
  t.snapshot(AVAILABLE_TARGETS.map(parseTriple))
13
49
  })
@@ -17,3 +53,188 @@ test('should get system default target correctly', (t) => {
17
53
 
18
54
  t.is(target.platform, os.platform())
19
55
  })
56
+
57
+ test('should read the wasi-sdk major version from VERSION', async (t) => {
58
+ await withWasiSdkDir(
59
+ (wasiSdkPath) => writeVersionFile(wasiSdkPath, '34.0\n'),
60
+ (wasiSdkPath) => t.is(wasiSdkMajorVersion(wasiSdkPath), 34),
61
+ )
62
+ })
63
+
64
+ test('should ignore the build suffix in VERSION', async (t) => {
65
+ await withWasiSdkDir(
66
+ (wasiSdkPath) => writeVersionFile(wasiSdkPath, '33.0+m\n'),
67
+ (wasiSdkPath) => t.is(wasiSdkMajorVersion(wasiSdkPath), 33),
68
+ )
69
+ })
70
+
71
+ test('should read a pre wasi-sdk 30 VERSION', async (t) => {
72
+ await withWasiSdkDir(
73
+ (wasiSdkPath) => writeVersionFile(wasiSdkPath, '27.0\n'),
74
+ (wasiSdkPath) => t.is(wasiSdkMajorVersion(wasiSdkPath), 27),
75
+ )
76
+ })
77
+
78
+ test('should fall back to the wasi/version.h header', async (t) => {
79
+ await withWasiSdkDir(
80
+ (wasiSdkPath) =>
81
+ writeVersionHeader(
82
+ wasiSdkPath,
83
+ '#define __wasi_sdk_major__ 30\n#define __wasi_sdk_minor__ 0\n',
84
+ ),
85
+ (wasiSdkPath) => t.is(wasiSdkMajorVersion(wasiSdkPath), 30),
86
+ )
87
+ })
88
+
89
+ test('should prefer VERSION over the wasi/version.h header', async (t) => {
90
+ await withWasiSdkDir(
91
+ async (wasiSdkPath) => {
92
+ await writeVersionFile(wasiSdkPath, '34.0\n')
93
+ await writeVersionHeader(wasiSdkPath, '#define __wasi_sdk_major__ 33\n')
94
+ },
95
+ (wasiSdkPath) => t.is(wasiSdkMajorVersion(wasiSdkPath), 34),
96
+ )
97
+ })
98
+
99
+ test('should return null when no version signal exists', async (t) => {
100
+ await withWasiSdkDir(
101
+ () => Promise.resolve(),
102
+ (wasiSdkPath) => t.is(wasiSdkMajorVersion(wasiSdkPath), null),
103
+ )
104
+ })
105
+
106
+ test('should return null instead of throwing on a malformed VERSION', async (t) => {
107
+ await withWasiSdkDir(
108
+ (wasiSdkPath) => writeVersionFile(wasiSdkPath, 'not a version at all\n'),
109
+ (wasiSdkPath) => {
110
+ t.notThrows(() => wasiSdkMajorVersion(wasiSdkPath))
111
+ t.is(wasiSdkMajorVersion(wasiSdkPath), null)
112
+ },
113
+ )
114
+ })
115
+
116
+ test('should return null when the wasi-sdk path does not exist', (t) => {
117
+ t.is(wasiSdkMajorVersion(join(os.tmpdir(), 'napi-rs-missing-wasi-sdk')), null)
118
+ })
119
+
120
+ test('should detect the new futex ABI from a wasi-libc archive', async (t) => {
121
+ const dir = await mkdtemp(join(os.tmpdir(), 'napi-rs-wasi-libc-'))
122
+ try {
123
+ // wasi-libc moved the futex helpers into `futex.c` when it dropped the
124
+ // unused `int op` parameter. `__wait.c` survives either way, so only the
125
+ // presence of `futex.c` separates the two ABIs.
126
+ const newAbi = join(dir, 'libc-new.a')
127
+ const legacyAbi = join(dir, 'libc-legacy.a')
128
+ await writeFile(newAbi, '!<arch>\n__wait.c.obj\nfutex.c.obj\n')
129
+ await writeFile(
130
+ legacyAbi,
131
+ '!<arch>\n__wait.c.obj\n__wasilibc_busywait.c.obj\n',
132
+ )
133
+
134
+ t.true(wasiLibcHasNewFutexAbi(newAbi))
135
+ t.false(wasiLibcHasNewFutexAbi(legacyAbi))
136
+ } finally {
137
+ await rm(dir, { recursive: true, force: true })
138
+ }
139
+ })
140
+
141
+ test('should return null when the wasi-libc archive is unavailable', (t) => {
142
+ t.is(wasiLibcHasNewFutexAbi(null), null)
143
+ t.is(
144
+ wasiLibcHasNewFutexAbi(join(os.tmpdir(), 'napi-rs-missing-libc.a')),
145
+ null,
146
+ )
147
+ })
148
+
149
+ // A fake `rustc` must be an executable file, and `execFileSync` cannot run a
150
+ // Windows `.cmd` shim without a shell. The behaviour under test does not
151
+ // depend on the platform, so cover it on POSIX only.
152
+ const posixOnly = process.platform === 'win32' ? test.serial.skip : test.serial
153
+
154
+ /**
155
+ * Installs a `rustc` that reports its own working directory as the sysroot,
156
+ * so a test can tell which directory the probe ran in.
157
+ */
158
+ async function withFakeRustc(
159
+ envVar: 'RUSTC' | 'CARGO_BUILD_RUSTC',
160
+ body: (dir: string) => Promise<void> | void,
161
+ ) {
162
+ const dir = await mkdtemp(join(os.tmpdir(), 'napi-rs-fake-rustc-'))
163
+ const rustc = join(dir, 'rustc')
164
+ // `$PWD` comes from the inherited environment, so ask the kernel for the
165
+ // real working directory. `-P` also resolves the `/var` symlink on macOS.
166
+ await writeFile(rustc, '#!/bin/sh\necho "$(pwd -P)/sysroot"\n')
167
+ await chmod(rustc, 0o755)
168
+ const previous = process.env[envVar]
169
+ process.env[envVar] = rustc
170
+ try {
171
+ await body(dir)
172
+ } finally {
173
+ if (previous === undefined) {
174
+ delete process.env[envVar]
175
+ } else {
176
+ process.env[envVar] = previous
177
+ }
178
+ }
179
+ }
180
+
181
+ posixOnly('probes the Rust sysroot from the build cwd', async (t) => {
182
+ await withFakeRustc('RUSTC', async (dir) => {
183
+ const caller = join(dir, 'caller')
184
+ const project = join(dir, 'project')
185
+ await mkdir(caller)
186
+ await mkdir(project)
187
+
188
+ // `rustc` is a rustup shim, so the directory it runs in picks the
189
+ // toolchain. Cargo is spawned with the build cwd; the probe must match it.
190
+ t.is(
191
+ rustBundledWasiLibc('wasm32-wasip1-threads', project),
192
+ join(
193
+ realpathSync(project),
194
+ 'sysroot',
195
+ 'lib',
196
+ 'rustlib',
197
+ 'wasm32-wasip1-threads',
198
+ 'lib',
199
+ 'self-contained',
200
+ 'libc.a',
201
+ ),
202
+ )
203
+ t.not(
204
+ rustBundledWasiLibc('wasm32-wasip1-threads', caller),
205
+ rustBundledWasiLibc('wasm32-wasip1-threads', project),
206
+ )
207
+ })
208
+ })
209
+
210
+ posixOnly('falls back to CARGO_BUILD_RUSTC when RUSTC is unset', async (t) => {
211
+ const previousRustc = process.env.RUSTC
212
+ delete process.env.RUSTC
213
+ try {
214
+ await withFakeRustc('CARGO_BUILD_RUSTC', (dir) => {
215
+ t.true(
216
+ rustBundledWasiLibc('wasm32-wasip1-threads', dir)?.startsWith(
217
+ realpathSync(dir),
218
+ ),
219
+ )
220
+ })
221
+ } finally {
222
+ if (previousRustc !== undefined) {
223
+ process.env.RUSTC = previousRustc
224
+ }
225
+ }
226
+ })
227
+
228
+ posixOnly('returns null when the compiler cannot be run', (t) => {
229
+ const previous = process.env.RUSTC
230
+ process.env.RUSTC = join(os.tmpdir(), 'napi-rs-no-such-rustc')
231
+ try {
232
+ t.is(rustBundledWasiLibc('wasm32-wasip1-threads'), null)
233
+ } finally {
234
+ if (previous === undefined) {
235
+ delete process.env.RUSTC
236
+ } else {
237
+ process.env.RUSTC = previous
238
+ }
239
+ }
240
+ })
@@ -1,3 +1,5 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
1
3
  import { join } from 'path'
2
4
  import { fileURLToPath } from 'url'
3
5
 
@@ -5,6 +7,24 @@ import test from 'ava'
5
7
 
6
8
  import { correctStringIdent, processTypeDef } from '../typegen.js'
7
9
 
10
+ async function processInlineTypeDef(
11
+ lines: Array<Record<string, unknown>>,
12
+ constEnum = true,
13
+ runtimeStringEnum = false,
14
+ ) {
15
+ const dir = await mkdtemp(join(tmpdir(), 'napi-typegen-'))
16
+ const file = join(dir, 'defs')
17
+ await writeFile(
18
+ file,
19
+ lines.map((line) => JSON.stringify(line)).join('\n') + '\n',
20
+ )
21
+ try {
22
+ return await processTypeDef(file, constEnum, runtimeStringEnum)
23
+ } finally {
24
+ await rm(dir, { recursive: true, force: true })
25
+ }
26
+ }
27
+
8
28
  test('should ident string correctly', (t) => {
9
29
  const input = `
10
30
  /**
@@ -83,3 +103,98 @@ test('runtimeStringEnum is a no-op when constEnum is set', async (t) => {
83
103
 
84
104
  t.snapshot(dts)
85
105
  })
106
+
107
+ test('simple type aliases stay on one line', async (t) => {
108
+ const { dts } = await processInlineTypeDef([
109
+ { kind: 'type', name: 'CustomU32', def: 'number' },
110
+ ])
111
+ t.true(dts.includes('export type CustomU32 = number'))
112
+ t.false(/export type CustomU32 =\s*\n/.test(dts))
113
+ })
114
+
115
+ test('empty classes do not contain a blank body', async (t) => {
116
+ const { dts } = await processInlineTypeDef([
117
+ { kind: 'struct', name: 'Blake2BKey', def: '' },
118
+ ])
119
+ t.true(dts.includes('export declare class Blake2BKey {}'))
120
+ t.false(dts.includes('class Blake2BKey {\n\n}'))
121
+ })
122
+
123
+ test('no-const-enum string enums emit a spaced union', async (t) => {
124
+ const { dts } = await processTypeDef(flagFixture, false, false)
125
+ t.true(dts.includes("export type Status = 'Active' | 'Inactive'"))
126
+ t.false(dts.includes("'Active'|"))
127
+ })
128
+
129
+ test('no-const-enum string enums keep commas inside variant docs', async (t) => {
130
+ const { dts } = await processInlineTypeDef(
131
+ [
132
+ {
133
+ kind: 'string_enum',
134
+ name: 'Status',
135
+ def: "/** First, documented variant */\n Active = 'Active',\n Inactive = 'Inactive'",
136
+ },
137
+ ],
138
+ false,
139
+ false,
140
+ )
141
+ t.true(dts.includes("export type Status = 'Active' | 'Inactive'"))
142
+ t.false(dts.includes('/** First |'))
143
+ })
144
+
145
+ test('no-const-enum string enums keep Unicode variant names', async (t) => {
146
+ const { dts } = await processInlineTypeDef(
147
+ [
148
+ {
149
+ kind: 'string_enum',
150
+ name: 'Status',
151
+ def: "成功 = '成功',\n Failed = 'Failed'",
152
+ },
153
+ ],
154
+ false,
155
+ false,
156
+ )
157
+ t.true(dts.includes("export type Status = '成功' | 'Failed'"))
158
+ })
159
+
160
+ test('no-const-enum string enums keep quoted JS property names', async (t) => {
161
+ const { dts } = await processInlineTypeDef(
162
+ [
163
+ {
164
+ kind: 'string_enum',
165
+ name: 'Header',
166
+ def: "'content-type' = 'content-type',\n Plain = 'plain'",
167
+ },
168
+ ],
169
+ false,
170
+ false,
171
+ )
172
+ t.true(dts.includes("export type Header = 'content-type' | 'plain'"))
173
+ })
174
+
175
+ test('no-const-enum string enums keep commas and escapes inside values', async (t) => {
176
+ const { dts } = await processInlineTypeDef(
177
+ [
178
+ {
179
+ kind: 'string_enum',
180
+ name: 'Label',
181
+ def: "Csv = 'a,b',\n Quoted = 'say \\'hi\\'',\n Count = 2",
182
+ },
183
+ ],
184
+ false,
185
+ false,
186
+ )
187
+ t.true(dts.includes("export type Label = 'a,b' | 'say \\'hi\\'' | 2"))
188
+ })
189
+
190
+ test('correctStringIdent keeps nested object-type fields indented', (t) => {
191
+ const input = `export declare function bufferComplexOverride(value: {
192
+ Buffer(): "line"
193
+ mapped: { [Buffer in keyof T]: T[Buffer] }
194
+ external: Buffer
195
+ }): void`
196
+ const formatted = correctStringIdent(input, 0)
197
+ t.true(formatted.includes(' mapped: { [Buffer in keyof T]: T[Buffer] }'))
198
+ t.true(formatted.includes(' external: Buffer'))
199
+ t.false(formatted.includes('\nmapped:'))
200
+ })
@@ -95,6 +95,27 @@ export interface UserNapiConfig {
95
95
  * @default 4000 pages (250 MiB), or 1024 pages (64 MiB) for the deferred workerd loader
96
96
  */
97
97
  initialMemory?: number
98
+ /**
99
+ * Initial linear memory, in 64 KiB pages, for the **threadless**
100
+ * (`wasm32-wasip1`) loaders only: the Node CJS loader, the browser loader
101
+ * and the deferred `./workerd` loader.
102
+ *
103
+ * The threaded loaders allocate one `shared: true` memory that every
104
+ * wasi-threads worker maps, so every worker stack and every thread's
105
+ * allocations come out of it and growing it is a cross-thread event; it is
106
+ * sized for the whole pool up front. The threadless loaders drive a single
107
+ * thread through a plain growable `ArrayBuffer`: they only have to clear
108
+ * the module's own link-time floor (`-zstack-size` plus static data) and
109
+ * grow on demand, which is what lets the same build fit a host with a hard
110
+ * isolate cap such as workerd's 128 MiB.
111
+ *
112
+ * Falls back to `initialMemory` when unset, so existing projects are
113
+ * unaffected.
114
+ *
115
+ * @default `initialMemory` (4000 pages), or 1024 pages for the deferred
116
+ * `./workerd` loader
117
+ */
118
+ threadlessInitialMemory?: number
98
119
  /**
99
120
  * @default 65536 pages (4GiB)
100
121
  */
@@ -119,6 +140,35 @@ export interface UserNapiConfig {
119
140
  */
120
141
  optionalDependency?: boolean
121
142
 
143
+ /**
144
+ * Whether the generated WASI loaders bootstrap the `napi-async-runtime`
145
+ * CurrentThread JavaScript hosts.
146
+ *
147
+ * A binding built with the `napi-async-runtime` crate runs the
148
+ * `CurrentThread` flavor on every WebAssembly target (it is the only
149
+ * flavor there). Its futures make no progress until a task host is
150
+ * registered, and its timers never fire until a timer host is, so the
151
+ * loader must install both right after instantiation and unregister them
152
+ * before the environment is destroyed.
153
+ *
154
+ * Enabling this emits that bootstrap in the node, browser and deferred
155
+ * (workerd) loaders, and declares `@napi-rs/async-runtime` in the
156
+ * generated `<packageName>-wasm32-*` packages. The loader delegates the
157
+ * whole host contract — the seven exports, contract version 4, the
158
+ * reservation identity, the liveness probe and the rollback — to that
159
+ * package, so a binding that does not actually expose the contract fails
160
+ * loudly at load with `ERR_NAPI_ASYNC_RUNTIME_BINDING_MISMATCH`.
161
+ *
162
+ * The bootstrap runs after instantiation, so after `#[module_init]`: the
163
+ * host registration functions are exports of the binding itself. Code that
164
+ * runs during module registration must not create timers (`sleep_until`
165
+ * fails loud at creation without a timer host) and cannot expect task
166
+ * progress until the loader has returned.
167
+ *
168
+ * @default false
169
+ */
170
+ asyncRuntime?: boolean
171
+
122
172
  /**
123
173
  * Browser wasm binding configuration
124
174
  */
@@ -1,5 +1,6 @@
1
1
  export * from './log.js'
2
2
  export * from './misc.js'
3
+ export * from './serialize.js'
3
4
  export * from './target.js'
4
5
  export * from './version.js'
5
6
  export * from './metadata.js'