@napi-rs/cli 3.9.1 → 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.
@@ -1,11 +1,20 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { mkdir, rm, writeFile } from 'node:fs/promises'
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
- import { join } from 'node:path'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { join as posixJoin, sep as posixSep } from 'node:path/posix'
6
+ import { sep as win32Sep } from 'node:path/win32'
7
+ import { fileURLToPath } from 'node:url'
5
8
 
6
9
  import ava, { type TestFn } from 'ava'
7
10
 
8
- import { buildProject, generateTypeDef } from '../build.js'
11
+ import {
12
+ buildProject,
13
+ generateTypeDef,
14
+ WASI_ARTIFACT_METADATA_PREFIX,
15
+ } from '../build.js'
16
+
17
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..')
9
18
 
10
19
  const test = ava as TestFn<{
11
20
  tmpDir: string
@@ -97,6 +106,204 @@ test.serial(
97
106
  },
98
107
  )
99
108
 
109
+ test.serial(
110
+ 'a native build refreshes WASI declarations that predate __napiBindingTarget',
111
+ async (t) => {
112
+ const { projectDir } = t.context
113
+ await writeMixedTargetProject(projectDir)
114
+ // the same crate, declaring a WASI target this native build has to keep in
115
+ // sync without rebuilding it
116
+ await writeFile(
117
+ join(projectDir, 'package.json'),
118
+ JSON.stringify({
119
+ name: 'build-regression',
120
+ version: '0.1.0',
121
+ napi: { binaryName: 'build-regression', targets: ['wasm32-wasip1'] },
122
+ }),
123
+ )
124
+
125
+ // what an earlier CLI left behind: a loader carrying artifact metadata,
126
+ // and a declaration file written before the loaders reported their target
127
+ const outputDir = join(projectDir, 'dist')
128
+ await mkdir(outputDir, { recursive: true })
129
+ const loaderPath = join(outputDir, 'build-regression.wasip1.cjs')
130
+ const declarationPath = join(outputDir, 'build-regression.wasip1.d.cts')
131
+ await Promise.all([
132
+ writeFile(
133
+ loaderPath,
134
+ `${WASI_ARTIFACT_METADATA_PREFIX}${JSON.stringify({
135
+ version: 2,
136
+ rootEntry: 'index.js',
137
+ exports: ['sum'],
138
+ managedRootEntries: ['browser.js', 'index.js'],
139
+ })}
140
+ module.exports = __napiModule.exports
141
+ module.exports.sum = __napiModule.exports.sum
142
+ `,
143
+ ),
144
+ writeFile(
145
+ declarationPath,
146
+ `/* auto-generated by NAPI-RS */
147
+
148
+ export declare function sum(a: number, b: number): number
149
+ `,
150
+ ),
151
+ ])
152
+
153
+ const originalCargo = process.env.CARGO
154
+ delete process.env.CARGO
155
+ try {
156
+ await (
157
+ await buildProject({ cwd: projectDir, outputDir: 'dist' })
158
+ ).task
159
+ } finally {
160
+ if (originalCargo === undefined) {
161
+ delete process.env.CARGO
162
+ } else {
163
+ process.env.CARGO = originalCargo
164
+ }
165
+ }
166
+
167
+ const [loader, declaration] = await Promise.all([
168
+ readFile(loaderPath, 'utf8'),
169
+ readFile(declarationPath, 'utf8'),
170
+ ])
171
+
172
+ // the loader is regenerated from the preserved metadata, so it reports its
173
+ // flavor; the declaration kept beside it has to say so too
174
+ // `cjs-module-lexer` — Node's CJS -> ESM named export detection — only sees
175
+ // a named export it can read off a `module.exports.<name> =` assignment, so
176
+ // the stamp assigns the guard's return value rather than calling it
177
+ const stampLine =
178
+ 'module.exports.__napiBindingTarget = __napiStampBindingTarget(__napiModule.exports, __napiBindingTarget)'
179
+ t.true(loader.includes(stampLine))
180
+ // exactly one, because every guard call can throw (a claimed name) and the
181
+ // assignment itself can throw (an addon accessor with a refusing setter);
182
+ // both have to happen where the initialization catch can roll them back
183
+ t.is(loader.split('module.exports.__napiBindingTarget =').length - 1, 1)
184
+ const initializationCatch = '\n} catch (error) {'
185
+ t.is(loader.split(initializationCatch).length - 1, 1)
186
+ t.true(loader.indexOf(stampLine) < loader.indexOf(initializationCatch))
187
+ // and the appended CommonJS tail carries no executable stamp at all
188
+ const tail = loader.slice(
189
+ loader.indexOf('\nmodule.exports = __napiModule.exports'),
190
+ )
191
+ t.false(tail.includes('__napiStampBindingTarget('))
192
+ t.true(declaration.startsWith('/* auto-generated by NAPI-RS */'))
193
+ // this file types one fixed artifact, whose loader can only ever report
194
+ // its own flavor — the root entry's union is what the override needs
195
+ t.true(
196
+ declaration.includes(
197
+ "export declare const __napiBindingTarget: 'wasm32-wasip1'",
198
+ ),
199
+ )
200
+ },
201
+ )
202
+
203
+ async function writeDeclarationOnlyProject(projectDir: string) {
204
+ // `napi-derive` in the dependency graph is the whole of what turns
205
+ // `enableTypeDef` on, and the crate deliberately uses no `#[napi]` macro, so
206
+ // the build writes zero `.type` files. That is the shape of a crate that
207
+ // registers everything from a `#[napi(module_exports)]` hook: a loader is
208
+ // written, but typegen has no runtime export to describe.
209
+ const napiDerivePath = posixJoin(repoRoot, 'crates', 'macro').replaceAll(
210
+ win32Sep,
211
+ posixSep,
212
+ )
213
+ await mkdir(join(projectDir, 'src'), { recursive: true })
214
+ await writeFile(
215
+ join(projectDir, 'Cargo.toml'),
216
+ `[package]
217
+ name = "declaration_only"
218
+ version = "0.1.0"
219
+ edition = "2021"
220
+
221
+ [lib]
222
+ crate-type = ["cdylib"]
223
+
224
+ [dependencies]
225
+ napi-derive = { path = "${napiDerivePath}" }
226
+ `,
227
+ )
228
+ await writeFile(join(projectDir, 'src', 'lib.rs'), 'pub fn library() {}\n')
229
+ }
230
+
231
+ function declarationOnlyPackageJson(napi: Record<string, unknown>) {
232
+ return JSON.stringify({
233
+ name: 'declaration-only',
234
+ version: '0.1.0',
235
+ napi,
236
+ })
237
+ }
238
+
239
+ test.serial(
240
+ 'a declaration-only type def is a tracked build output',
241
+ async (t) => {
242
+ const { projectDir } = t.context
243
+ await writeDeclarationOnlyProject(projectDir)
244
+ // a declared WASI fallback is reason enough to write the root loader, so the
245
+ // declaration file describes it with `__napiBindingTarget` even though the
246
+ // export list is empty
247
+ await writeFile(
248
+ join(projectDir, 'package.json'),
249
+ declarationOnlyPackageJson({
250
+ binaryName: 'declaration-only',
251
+ targets: ['wasm32-wasip1'],
252
+ }),
253
+ )
254
+
255
+ const [outputs, emptyOutputs] = await (async () => {
256
+ const originalCargo = process.env.CARGO
257
+ delete process.env.CARGO
258
+ try {
259
+ const declarationOutputs = await (
260
+ await buildProject({
261
+ cwd: projectDir,
262
+ outputDir: 'dist',
263
+ platform: true,
264
+ })
265
+ ).task
266
+ // the same crate with nothing to declare: no root loader (`--platform`
267
+ // writes it) and no WASI fallback, so typegen writes an empty file, and
268
+ // an empty file is not an output anyone should be asked to post-process
269
+ await writeFile(
270
+ join(projectDir, 'package.json'),
271
+ declarationOnlyPackageJson({ binaryName: 'declaration-only' }),
272
+ )
273
+ const emptyDeclarationOutputs = await (
274
+ await buildProject({ cwd: projectDir, outputDir: 'empty' })
275
+ ).task
276
+ return [declarationOutputs, emptyDeclarationOutputs] as const
277
+ } finally {
278
+ if (originalCargo === undefined) {
279
+ delete process.env.CARGO
280
+ } else {
281
+ process.env.CARGO = originalCargo
282
+ }
283
+ }
284
+ })()
285
+
286
+ const declarationPath = join(projectDir, 'dist', 'index.d.ts')
287
+ // `--pipe` and the `NapiCli.build` return value only ever see registered
288
+ // outputs, so a declaration file this build filled has to be registered
289
+ t.true(
290
+ outputs.some(
291
+ (output) => output.kind === 'dts' && output.path === declarationPath,
292
+ ),
293
+ )
294
+ // and the red above is about tracking, not about content: the file itself
295
+ // has always been written
296
+ t.true(
297
+ (await readFile(declarationPath, 'utf8')).includes(
298
+ 'export declare const __napiBindingTarget:',
299
+ ),
300
+ )
301
+
302
+ t.is(await readFile(join(projectDir, 'empty', 'index.d.ts'), 'utf8'), '')
303
+ t.false(emptyOutputs.some((output) => output.kind === 'dts'))
304
+ },
305
+ )
306
+
100
307
  test.serial(
101
308
  'an explicit dtsHeaderFile takes precedence over config and inline headers',
102
309
  async (t) => {