@napi-rs/cli 3.10.3 → 3.10.5

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.
@@ -831,6 +831,263 @@ test('a fresh WASI declaration narrows the inherited root union', async (t) => {
831
831
  const ROOT_BINDING_TARGET_DECLARATION =
832
832
  "export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1'"
833
833
 
834
+ test('an ESM root declaration derives the WASI declaration unless it cannot cross', async (t) => {
835
+ const { tmpDir, projectDir, typeDefDir } = t.context
836
+ await writeFile(
837
+ join(typeDefDir, 'sum.type'),
838
+ '{"kind":"fn","name":"sum","def":"function sum(a: number, b: number): number"}\n',
839
+ )
840
+ const { dts } = await generateTypeDef({
841
+ typeDefDir,
842
+ cwd: projectDir,
843
+ declareBindingTarget: true,
844
+ })
845
+
846
+ // A `"type": "module"` package's `index.d.ts` is an ESM declaration, and so
847
+ // is an explicit `.d.mts` — but the generated typedef carries nothing a
848
+ // `.d.cts` cannot re-declare, so the WASI declaration derives verbatim.
849
+ // (napi-rs#3531: an unconditional refusal failed every WASI build in a
850
+ // module package, including `--esm` ones.)
851
+ const sourcePath = join(projectDir, 'index.d.ts')
852
+ const destinationPath = join(projectDir, 'pkg.wasi.d.cts')
853
+ for (const esmSourcePath of [sourcePath, join(projectDir, 'index.d.mts')]) {
854
+ t.is(
855
+ prepareWasiBindingTypeDef(
856
+ dts,
857
+ esmSourcePath,
858
+ destinationPath,
859
+ true,
860
+ 'module',
861
+ ),
862
+ dts,
863
+ )
864
+ }
865
+
866
+ // The threadless flavor still gets its node-global rewrites on the way, and
867
+ // what it emits is valid TypeScript beside the ESM source it came from.
868
+ const threadless = prepareWasiBindingTypeDef(
869
+ dts,
870
+ sourcePath,
871
+ destinationPath,
872
+ false,
873
+ 'module',
874
+ )
875
+ const codes = await semanticDiagnosticCodes(join(tmpDir, 'esm-derived'), {
876
+ 'index.d.ts': dts,
877
+ 'pkg.wasi.d.cts': threadless,
878
+ })
879
+ t.deepEqual(codes['pkg.wasi.d.cts'], [])
880
+
881
+ // Only the constructs a CommonJS declaration cannot carry still refuse —
882
+ // each one named in the error, from `.d.ts` and `.d.mts` sources alike.
883
+ const hazards: Array<[what: string, source: string, barrier: string]> = [
884
+ [
885
+ 'an `export default` declaration',
886
+ 'export declare function sum(a: number): number\nexport default sum\n',
887
+ 'an `export default` declaration',
888
+ ],
889
+ [
890
+ 'a default class declaration',
891
+ 'export default class Sum {}\n',
892
+ 'an `export default` declaration',
893
+ ],
894
+ [
895
+ 'a default function declaration',
896
+ 'export default function sum(a: number): number\n',
897
+ 'an `export default` declaration',
898
+ ],
899
+ [
900
+ 'a default interface declaration',
901
+ 'export default interface Sum { a: number }\n',
902
+ 'an `export default` declaration',
903
+ ],
904
+ [
905
+ 'a default namespace declaration',
906
+ 'export default namespace Sum { const a: number }\n',
907
+ 'an `export default` declaration',
908
+ ],
909
+ [
910
+ 'a `default` re-export',
911
+ 'declare const sum: number\nexport { sum as default }\n',
912
+ 'a `default` re-export',
913
+ ],
914
+ [
915
+ 'a `default` re-export from a package',
916
+ "export { sum as default } from 'sums'\n",
917
+ 'a `default` re-export',
918
+ ],
919
+ [
920
+ 'a type-only `default` re-export',
921
+ 'type Sum = number\nexport type { Sum as default }\n',
922
+ 'a `default` re-export',
923
+ ],
924
+ [
925
+ 'a `default` namespace re-export',
926
+ "export * as default from 'sums'\n",
927
+ 'a `default` re-export',
928
+ ],
929
+ [
930
+ 'a relative import specifier',
931
+ "import { sum } from './sum.js'\nexport declare function f(): typeof sum\n",
932
+ "a relative './sum.js' specifier",
933
+ ],
934
+ [
935
+ 'an extensionless relative specifier',
936
+ "import { sum } from './sum'\nexport declare function f(): typeof sum\n",
937
+ "a relative './sum' specifier",
938
+ ],
939
+ [
940
+ 'a relative export specifier',
941
+ "export { sum } from './sum.js'\n",
942
+ "a relative './sum.js' specifier",
943
+ ],
944
+ [
945
+ 'a relative export-star specifier',
946
+ "export * from './sums.js'\n",
947
+ "a relative './sums.js' specifier",
948
+ ],
949
+ [
950
+ 'a relative import type specifier',
951
+ "import type { Sum } from './sum.js'\nexport declare function f(s: Sum): void\n",
952
+ "a relative './sum.js' specifier",
953
+ ],
954
+ [
955
+ 'a relative import() type',
956
+ "export declare function f(): import('./sum.js').Sum\n",
957
+ "a relative './sum.js' specifier",
958
+ ],
959
+ [
960
+ 'a relative import-equals specifier',
961
+ "import sum = require('./sum.js')\nexport declare function f(): typeof sum\n",
962
+ "a relative './sum.js' specifier",
963
+ ],
964
+ [
965
+ 'a relative ambient module specifier',
966
+ "declare module './sum.js' { export const sum: number }\n",
967
+ "a relative './sum.js' specifier",
968
+ ],
969
+ [
970
+ 'a relative types reference',
971
+ "/// <reference types='./sum' />\nexport declare const sum: number\n",
972
+ 'a relative \'/// <reference types="./sum" />\' directive',
973
+ ],
974
+ [
975
+ 'attributes on a runtime statement',
976
+ "import sum from './sum.json' with { type: 'json' }\nexport declare const s: typeof sum\n",
977
+ '`with` attributes on a runtime module statement',
978
+ ],
979
+ ]
980
+ const esmSources: Array<
981
+ [path: string, packageType: 'module' | 'commonjs' | undefined]
982
+ > = [
983
+ [sourcePath, 'module'],
984
+ [join(projectDir, 'index.d.mts'), undefined],
985
+ ]
986
+ for (const [what, source, barrier] of hazards) {
987
+ for (const [esmSourcePath, packageType] of esmSources) {
988
+ const error = t.throws(
989
+ () =>
990
+ prepareWasiBindingTypeDef(
991
+ source,
992
+ esmSourcePath,
993
+ destinationPath,
994
+ true,
995
+ packageType,
996
+ ),
997
+ { instanceOf: Error },
998
+ )
999
+ t.true(
1000
+ error?.message.includes(barrier),
1001
+ `${what} names the offending construct, got: ${error?.message}`,
1002
+ )
1003
+ }
1004
+ }
1005
+
1006
+ // Everything else a module package can write crosses honestly.
1007
+ const honest: Record<string, string> = {
1008
+ 'bare and node: imports':
1009
+ "import { Buffer } from 'buffer'\nimport type { ReadableStream } from 'node:stream/web'\nexport declare function f(b: Buffer, s: ReadableStream): void\n",
1010
+ '.mjs specifiers':
1011
+ "import { sum } from './sum.mjs'\nexport declare function f(): typeof sum\n",
1012
+ '.cjs specifiers':
1013
+ "import { sum } from './sum.cjs'\nexport declare function f(): typeof sum\n",
1014
+ 'an export assignment':
1015
+ 'declare const binding: { sum(a: number): number }\nexport = binding\n',
1016
+ 'a declare global block':
1017
+ 'declare global { namespace Legacy { const v: number } }\nexport {}\n',
1018
+ 'a bare ambient module':
1019
+ "declare module 'legacy' { export const sum: number }\n",
1020
+ 'a .mjs ambient module':
1021
+ "declare module './sum.mjs' { export const sum: number }\n",
1022
+ 'a default inside an ambient module':
1023
+ "declare module 'legacy' { const sum: number\nexport default sum }\n",
1024
+ 'a type-only import with resolution-mode':
1025
+ "import type { Sum } from './sum.js' with { 'resolution-mode': 'import' }\nexport declare function f(s: Sum): void\n",
1026
+ 'a type-only import pinned to require mode':
1027
+ "import type { Sum } from './sum.js' with { 'resolution-mode': 'require' }\nexport declare function f(s: Sum): void\n",
1028
+ 'a type-only export with resolution-mode':
1029
+ "export type { Sum } from './sum.js' with { 'resolution-mode': 'import' }\n",
1030
+ 'a json specifier':
1031
+ "import sum from './sum.json'\nexport declare const s: typeof sum\n",
1032
+ 'a path reference':
1033
+ "/// <reference path='./sum.d.ts' />\nexport declare const sum: number\n",
1034
+ 'a bare import() type':
1035
+ "export declare function f(): import('buffer').Buffer\n",
1036
+ 'an import() type pinned to import mode':
1037
+ "export declare function f(): import('./sum.js', { with: { 'resolution-mode': 'import' } }).Sum\n",
1038
+ 'a namespace export':
1039
+ 'export declare namespace Legacy { const sum: number }\n',
1040
+ }
1041
+ for (const [what, source] of Object.entries(honest)) {
1042
+ t.notThrows(
1043
+ () =>
1044
+ prepareWasiBindingTypeDef(
1045
+ source,
1046
+ sourcePath,
1047
+ destinationPath,
1048
+ true,
1049
+ 'module',
1050
+ ),
1051
+ what,
1052
+ )
1053
+ }
1054
+
1055
+ // Nothing is scanned when the source is already CommonJS — a `.d.cts` (or a
1056
+ // `.d.ts` in a `commonjs` package) reads the same beside its destination, so
1057
+ // every hazard above carries over verbatim.
1058
+ for (const [, source] of hazards) {
1059
+ for (const [cjsSourcePath, packageType] of [
1060
+ [join(projectDir, 'index.d.cts'), 'module'],
1061
+ [sourcePath, 'commonjs'],
1062
+ [sourcePath, undefined],
1063
+ ] as const) {
1064
+ t.notThrows(() =>
1065
+ prepareWasiBindingTypeDef(
1066
+ source,
1067
+ cjsSourcePath,
1068
+ destinationPath,
1069
+ true,
1070
+ packageType,
1071
+ ),
1072
+ )
1073
+ }
1074
+ }
1075
+
1076
+ // A specifier that can cross still rebases onto the destination directory
1077
+ // — including a `declare module` augmentation name, which targets the file
1078
+ // it spells out.
1079
+ const relocated = prepareWasiBindingTypeDef(
1080
+ "/// <reference path='./sum.d.ts' />\ndeclare module './sum.mjs' { export const sum: number }\nexport declare function f(): import('./sum.mjs').Sum\nexport { sum } from './sum.mjs'\n",
1081
+ join(projectDir, 'types', 'index.d.mts'),
1082
+ destinationPath,
1083
+ true,
1084
+ )
1085
+ t.true(relocated.includes("declare module './types/sum.mjs'"))
1086
+ t.true(relocated.includes("import('./types/sum.mjs')"))
1087
+ t.true(relocated.includes("from './types/sum.mjs'"))
1088
+ t.true(relocated.includes("reference path='./types/sum.d.ts'"))
1089
+ })
1090
+
834
1091
  test('a crate without type defs still declares the binding target', async (t) => {
835
1092
  const { projectDir, typeDefDir } = t.context
836
1093
 
@@ -1123,6 +1123,241 @@ for (const { name, code } of eagerWasiLoaderCases) {
1123
1123
  })
1124
1124
  }
1125
1125
 
1126
+ // `napi_prepare_wasm_env_cleanup` waits: it returns only once the addon's async
1127
+ // runtime has quiesced, and on a threaded artifact the work it waits for can be
1128
+ // waiting for a JavaScript turn from the very thread the export runs on — which
1129
+ // never comes, because that thread is inside the export. The addon exposes the
1130
+ // same teardown as `…_begin` / `napi_wasm_runtime_work_pending` / `…_finish` so
1131
+ // a loader can put real turns in the middle. Every shape that can yield has to
1132
+ // use it, and every shape has to keep working against an addon that has no such
1133
+ // exports.
1134
+ const TWO_PHASE_BARRIER_BY_FLAVOR = {
1135
+ eager: {
1136
+ detect:
1137
+ /if \(typeof begin !== 'function' \|\|\s*typeof finish !== 'function'\) \{/,
1138
+ poll: '}, __WASM_RUNTIME_WORK_POLL_INTERVAL_MS)',
1139
+ finish: '.then(finishCleanup, finishCleanup)',
1140
+ fallback: '__prepareWasmEnvCleanup()',
1141
+ report: '__reportUnreachedWasmEnvSettlements()',
1142
+ // The closer a caller that cannot yield uses to end a parked handshake,
1143
+ // and the point at which the poll publishes it.
1144
+ parked: '__finishParkedWasmEnvCleanup',
1145
+ publishParked: '__finishParkedWasmEnvCleanup = finishCleanup',
1146
+ clearParked: '__finishParkedWasmEnvCleanup = undefined',
1147
+ },
1148
+ deferred: {
1149
+ detect:
1150
+ /if \(typeof __begin !== 'function' \|\|\s*typeof __finish !== 'function'\) \{/,
1151
+ poll: '}, __WASM_RUNTIME_WORK_POLL_INTERVAL_MS)',
1152
+ finish: `return __pollWasmRuntimeWork(__workPending).then(
1153
+ __finishEnvCleanup,
1154
+ __finishEnvCleanup,
1155
+ )`,
1156
+ fallback: '__prepareEnvCleanup()',
1157
+ report: '__reportUnreachedSettlements()',
1158
+ parked: '__finishParkedEnvCleanup',
1159
+ publishParked: '__finishParkedEnvCleanup = __finishEnvCleanup',
1160
+ clearParked: '__finishParkedEnvCleanup = undefined',
1161
+ },
1162
+ } as const
1163
+
1164
+ for (const { name, code } of wasiLoaderCases) {
1165
+ const barrier =
1166
+ TWO_PHASE_BARRIER_BY_FLAVOR[
1167
+ code.includes(EAGER_ROLLBACK_SIGNATURE) ? 'eager' : 'deferred'
1168
+ ]
1169
+ test(`WASI loader polls the two-phase wasm env cleanup: ${name}`, (t) => {
1170
+ t.true(
1171
+ code.includes('napi_prepare_wasm_env_cleanup_begin'),
1172
+ 'loader must start the teardown without joining',
1173
+ )
1174
+ t.true(
1175
+ code.includes('napi_wasm_runtime_work_pending'),
1176
+ 'loader must poll the runtime between the two halves; without it there is nothing to wait on',
1177
+ )
1178
+ t.true(
1179
+ code.includes('napi_prepare_wasm_env_cleanup_finish'),
1180
+ 'loader must still join: finish is the call that owes quiescence',
1181
+ )
1182
+ // Optional, exactly like every other export in this teardown: an addon
1183
+ // built against a napi crate that predates the split keeps the single
1184
+ // blocking call.
1185
+ t.regex(code, barrier.detect, 'the split must be feature-detected')
1186
+ t.true(
1187
+ code.includes(barrier.fallback),
1188
+ 'a loader that finds no split must fall back to the single call',
1189
+ )
1190
+ // Unbounded, and deliberately so. The only way to end the poll early is to
1191
+ // call `…_finish`, which joins on the JavaScript thread — the very thread
1192
+ // the work it joins may be waiting for a turn from — so a budget would not
1193
+ // end that wait, it would only move it somewhere the thread can no longer
1194
+ // be reached. Same reason the async-work drain has no deadline. A host that
1195
+ // breaks the "no blocking closure may wait on a JavaScript turn" rule keeps
1196
+ // its disposal promise pending instead of wedging the thread.
1197
+ t.false(
1198
+ code.includes('__WASM_RUNTIME_DRAIN_TURNS'),
1199
+ 'the poll must not carry a turn budget',
1200
+ )
1201
+ t.is(
1202
+ code.split('for (;;) {').length - 1,
1203
+ 1,
1204
+ 'the poll must be an unbounded loop',
1205
+ )
1206
+ t.true(
1207
+ code.includes(barrier.poll),
1208
+ 'the poll must yield with a real referenced timer, not a macrotask spin',
1209
+ )
1210
+ // `__scheduleTimer` falls back to the macrotask scheduler when `setTimeout`
1211
+ // is missing or throws — but not when it is present, returns a handle and
1212
+ // never fires (fake timers; a host whose timers belong to an IO context
1213
+ // that is gone). With no budget left to bail the poll out, that host would
1214
+ // park it forever. Arm both until timers have actually arrived, then pace
1215
+ // on the timer alone rather than spinning the zero-delay queue.
1216
+ //
1217
+ // Whether they arrive is a property of the poll, never of the module: a
1218
+ // host can lose its timers between two disposals, and in the deferred
1219
+ // shape every instance shares this module — one healthy instance must not
1220
+ // disarm the fallback for the next one.
1221
+ t.false(
1222
+ code.includes('let __wasmRuntimePollTimerArrived'),
1223
+ 'the pacing state must not outlive the poll that learned it',
1224
+ )
1225
+ t.is(
1226
+ code.split('function __createWasmRuntimePollPace()').length - 1,
1227
+ 1,
1228
+ 'one definition of the pacing state',
1229
+ )
1230
+ t.is(
1231
+ code.split('__createWasmRuntimePollPace()').length - 1,
1232
+ 2,
1233
+ 'and exactly one caller: the poll loop, which owns it for its own run',
1234
+ )
1235
+ const yieldStart = code.indexOf('function __yieldWasmRuntimePollTurn(')
1236
+ t.true(yieldStart > 0, 'the poll must yield through one shared turn helper')
1237
+ const yieldTurn = code.slice(yieldStart, code.indexOf('\n}\n', yieldStart))
1238
+ t.true(
1239
+ yieldTurn.includes('__scheduleTimer(') &&
1240
+ yieldTurn.includes('__scheduleMacrotask('),
1241
+ 'an undecided turn must arm both primitives, so an inert setTimeout cannot park the poll',
1242
+ )
1243
+ t.true(
1244
+ yieldTurn.includes('pace.arrivals++'),
1245
+ 'the timer callback must count that timers work on this host',
1246
+ )
1247
+ t.true(
1248
+ yieldTurn.includes(
1249
+ 'pace.arrivals < __WASM_RUNTIME_WORK_POLL_TRUSTED_ARRIVALS',
1250
+ ),
1251
+ 'and one arrival must not be enough: it can be a timer armed before the host stopped running them',
1252
+ )
1253
+ // A poll that has settled onto the timer alone has nothing left to fall
1254
+ // back on if the timers stop mid-poll — the turn that armed the dead timer
1255
+ // is the turn that parks, and a parked poll schedules nothing that could
1256
+ // notice. Every turn keeps a longer timer outstanding for that.
1257
+ t.true(
1258
+ yieldTurn.includes('__armWasmRuntimePollStallBackup('),
1259
+ 'every turn must keep a stall backup outstanding, armed while the timers still work',
1260
+ )
1261
+ const backupStart = code.indexOf(
1262
+ 'function __armWasmRuntimePollStallBackup(',
1263
+ )
1264
+ t.true(backupStart > 0, 'the backup must be one shared helper')
1265
+ const stallBackup = code.slice(
1266
+ backupStart,
1267
+ code.indexOf('\n}\n', backupStart),
1268
+ )
1269
+ t.true(
1270
+ stallBackup.includes('pace.arrivals = 0'),
1271
+ 'a turn the backup has to end proves the timers stopped: the poll goes back to arming both',
1272
+ )
1273
+ t.true(
1274
+ stallBackup.includes('pace.settleTurn'),
1275
+ 'and the backup must end whichever turn is parked, not the one that armed it',
1276
+ )
1277
+ // By due time, never by how long the parked turn has been waiting. A host
1278
+ // runs its timers in due order, so a backup that runs while a turn due a
1279
+ // window earlier is still parked proves that turn's timer was dropped —
1280
+ // and a healthy turn, whose timer runs first and clears `settleTurn`, is
1281
+ // never touched. Measuring the wait instead has a phase hole: arms spaced
1282
+ // further apart than the window leave the turn that parks between them
1283
+ // with no backup young enough to rescue it.
1284
+ t.regex(
1285
+ stallBackup,
1286
+ /pace\.turnTimerDueAt > (?:__)?dueAt - __WASM_RUNTIME_WORK_POLL_STALL_MS/,
1287
+ 'the backup must judge by due time, so there is no phase to fall through',
1288
+ )
1289
+ t.false(
1290
+ stallBackup.includes('Date.now() -'),
1291
+ 'and it must not measure how long the parked turn has waited',
1292
+ )
1293
+ t.true(
1294
+ yieldTurn.includes('pace.settleTurn = undefined'),
1295
+ 'a turn that ends must stop being the parked one, or a later backup reads a due time already answered',
1296
+ )
1297
+ t.regex(
1298
+ yieldTurn,
1299
+ /if \(__settled\??\) \{\s*return\s*\}|if \(settled\) \{\s*return\s*\}/,
1300
+ 'whichever primitive loses the race must resolve nothing: one poll per turn',
1301
+ )
1302
+ t.true(
1303
+ code.includes(barrier.finish),
1304
+ 'finish must run whether the poll ended, timed out or could not run at all',
1305
+ )
1306
+ // The one caller that cannot yield is the raw `Context.destroy()` the
1307
+ // wrapper intercepts, and the queue it leaves behind is discarded by the
1308
+ // destroy that follows. Say so, once, without throwing.
1309
+ t.true(
1310
+ code.includes(barrier.report),
1311
+ 'the single-call barrier must report settlements nothing can reach any more',
1312
+ )
1313
+ // The window between the halves spans real event-loop turns, so a caller
1314
+ // that cannot yield — the CJS 'exit' teardown, the managed beforeExit one —
1315
+ // can land in the middle of one. It cannot wait for the poll; it has to
1316
+ // close the handshake itself, because `…_finish` is the call that joins and
1317
+ // lowers the barrier. Without this the context is destroyed with the
1318
+ // barrier still raised, the runtime never joined and the destroy recorded
1319
+ // as done.
1320
+ t.is(
1321
+ code.split(barrier.publishParked).length - 1,
1322
+ 1,
1323
+ 'the poll must publish a closer before it yields',
1324
+ )
1325
+ t.is(
1326
+ code.split(barrier.clearParked).length - 1,
1327
+ 1,
1328
+ 'finishing the handshake must retract that closer exactly once',
1329
+ )
1330
+ const parkedIndex = code.indexOf(`const ${barrier.parked}`)
1331
+ t.true(
1332
+ parkedIndex > 0 || code.includes(`let ${barrier.parked}`),
1333
+ 'the closer must live outside the barrier, where a non-yielding caller can reach it',
1334
+ )
1335
+ // And the single call is where it is reached: every teardown path runs the
1336
+ // barrier through it before destroying, so closing a parked handshake there
1337
+ // covers all of them at once.
1338
+ const isEager = barrier.parked === '__finishParkedWasmEnvCleanup'
1339
+ const prepareStart = code.indexOf(
1340
+ isEager
1341
+ ? `function ${barrier.fallback.replace('()', '')}() {`
1342
+ : `const ${barrier.fallback.replace('()', '')} = () => {`,
1343
+ )
1344
+ t.true(prepareStart > 0, 'the single-call barrier must be one function')
1345
+ const prepareBody = code.slice(
1346
+ prepareStart,
1347
+ code.indexOf(isEager ? '\n}\n' : '\n }\n', prepareStart),
1348
+ )
1349
+ const closerIndex = prepareBody.indexOf(barrier.parked)
1350
+ t.true(
1351
+ closerIndex > 0,
1352
+ 'the single call must close a parked handshake instead of skipping the barrier',
1353
+ )
1354
+ t.true(
1355
+ closerIndex < prepareBody.indexOf('napi_prepare_wasm_env_cleanup'),
1356
+ 'and it must close it before looking the single-call export up: a parked handshake is already begun',
1357
+ )
1358
+ })
1359
+ }
1360
+
1126
1361
  // The loaders order their own teardown barrier-then-destroy, but the emnapi
1127
1362
  // context is a live object: an embedder or test harness holding it, or emnapi's
1128
1363
  // own `beforeExit` auto-destroy on a host where `suppressDestroy()` is absent,
@@ -1140,14 +1375,22 @@ const CONTEXT_DESTROY_WRAP_SIGNATURE =
1140
1375
  const preparingBarrierGuards = {
1141
1376
  shared: {
1142
1377
  probe: '__isPreparingWasmEnvCleanup',
1378
+ // Both barrier entry points read it: the single call and the two-phase
1379
+ // form, which keeps it raised across the turns it yields. The single call
1380
+ // splits the check in two, because a handshake parked between the halves is
1381
+ // one it closes rather than refuses — see the two-phase test above — so the
1382
+ // shape below is the reentrancy half alone.
1383
+ entryGuard: ` if (__emnapiWasmEnvCleanupPreparing) {
1384
+ return
1385
+ }`,
1386
+ twoPhaseEntryGuard: ` if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) {
1387
+ return
1388
+ }`,
1143
1389
  snippets: [
1144
1390
  'let __emnapiWasmEnvCleanupPreparing = false',
1145
1391
  `function __isPreparingWasmEnvCleanup() {
1146
1392
  return __emnapiWasmEnvCleanupPreparing
1147
1393
  }`,
1148
- ` if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) {
1149
- return
1150
- }`,
1151
1394
  ` __emnapiWasmEnvCleanupPreparing = true
1152
1395
  try {
1153
1396
  prepare()
@@ -1158,12 +1401,15 @@ const preparingBarrierGuards = {
1158
1401
  },
1159
1402
  deferred: {
1160
1403
  probe: '__isPreparingEnvCleanup',
1404
+ entryGuard: ` if (__wasmEnvCleanupPreparing) {
1405
+ return
1406
+ }`,
1407
+ twoPhaseEntryGuard: ` if (__wasmEnvCleanupPrepared || __wasmEnvCleanupPreparing) {
1408
+ return
1409
+ }`,
1161
1410
  snippets: [
1162
1411
  'let __wasmEnvCleanupPreparing = false',
1163
1412
  'const __isPreparingEnvCleanup = () => __wasmEnvCleanupPreparing',
1164
- ` if (__wasmEnvCleanupPrepared || __wasmEnvCleanupPreparing) {
1165
- return
1166
- }`,
1167
1413
  ` __wasmEnvCleanupPreparing = true
1168
1414
  try {
1169
1415
  __prepareWasmEnvCleanup()
@@ -1267,6 +1513,20 @@ for (const { name, code, prepare, guard } of wrappedContextCreationCases) {
1267
1513
  `barrier must carry its in-flight guard exactly once: ${snippet}`,
1268
1514
  )
1269
1515
  }
1516
+ // Both barrier entry points — the single call and the two-phase form —
1517
+ // refuse to re-enter a barrier already in flight. The two-phase form still
1518
+ // tests the pair in one condition; the single call tests the reentrancy
1519
+ // half on its own, after it has dealt with a parked handshake.
1520
+ t.is(
1521
+ code.split(guard.entryGuard).length - 1,
1522
+ 1,
1523
+ 'the single call must refuse to re-enter a barrier already in flight',
1524
+ )
1525
+ t.is(
1526
+ code.split(guard.twoPhaseEntryGuard).length - 1,
1527
+ 1,
1528
+ 'the two-phase barrier must refuse to re-enter a barrier already in flight',
1529
+ )
1270
1530
  t.is(
1271
1531
  code.split(NESTED_DESTROY_NO_OP).length - 1,
1272
1532
  1,
package/src/api/build.ts CHANGED
@@ -21,6 +21,7 @@ import type { BuildOptions as RawBuildOptions } from '../def/build.js'
21
21
  import {
22
22
  CLI_VERSION,
23
23
  commitFileSystemTransaction,
24
+ commonJsDeclarationBarrier,
24
25
  copyFileAtomic,
25
26
  type Crate,
26
27
  debugFactory,
@@ -41,7 +42,9 @@ import {
41
42
  removeNodeStreamWebTypeImports,
42
43
  rewriteUnboundNodeGlobalTypeQueries,
43
44
  rewriteTypeImportReferences,
45
+ checkSelfsign,
44
46
  scanExportedName,
47
+ signFileAtomic,
45
48
  type Target,
46
49
  targetToEnvVar,
47
50
  tryInstallCargoBinary,
@@ -253,7 +256,7 @@ export function checkAsyncRuntimeHostContract({
253
256
  }
254
257
  if (!asyncRuntime && missingHostExports.length === 0) {
255
258
  return {
256
- warning: `${packageName} exports the napi-async-runtime host contract but napi.wasm.asyncRuntime is not enabled. The generated WASI loaders will not install the CurrentThread task and timer hosts, so async exports will never make progress unless the host is installed by hand.`,
259
+ warning: `${packageName} exports the napi-async-runtime host contract but napi.wasm.asyncRuntime is not enabled. The generated WASI loaders will not install the CurrentThread task and timer hosts, so a binding running the CurrentThread flavor (the default on every wasm target) will make no progress unless the hosts are installed by hand. A wasm32-wasip1-threads binding that configures MultiThread does not need them.`,
257
260
  }
258
261
  }
259
262
  return {}
@@ -795,6 +798,16 @@ export function bindingTargetDeclarationPredicate(input: {
795
798
  (input.rootLoaderCandidate && (input.hasWasiFallback || exports.length > 0))
796
799
  }
797
800
 
801
+ /**
802
+ * The `.d.cts` content for a WASI flavor loader, derived from the root
803
+ * declaration this build wrote.
804
+ *
805
+ * An ESM source (`*.d.mts`, or `*.d.ts` in a `"type": "module"` package) is
806
+ * re-declared verbatim when nothing in it changes meaning under CommonJS
807
+ * interpretation — which is everything a generated typedef and most
808
+ * `--dts-header`s contain. {@link commonJsDeclarationBarrier} names the
809
+ * constructs that still refuse, and why.
810
+ */
798
811
  export function prepareWasiBindingTypeDef(
799
812
  source: string,
800
813
  sourcePath: string,
@@ -803,12 +816,15 @@ export function prepareWasiBindingTypeDef(
803
816
  packageType?: 'module' | 'commonjs',
804
817
  ) {
805
818
  if (
806
- sourcePath.endsWith('.d.mts') ||
807
- (sourcePath.endsWith('.d.ts') && packageType === 'module')
819
+ sourcePath.endsWith('.mts') ||
820
+ (sourcePath.endsWith('.ts') && packageType === 'module')
808
821
  ) {
809
- throw new Error(
810
- `Cannot emit the CommonJS WASI declaration ${destinationPath} from the ESM declaration ${sourcePath}. Use a .d.cts --dts path for WASI builds in module packages.`,
811
- )
822
+ const barrier = commonJsDeclarationBarrier(source)
823
+ if (barrier !== undefined) {
824
+ throw new Error(
825
+ `Cannot emit the CommonJS WASI declaration ${destinationPath} from the ESM declaration ${sourcePath}: ${barrier} cannot be re-declared in a CommonJS declaration. Use a .d.cts --dts path for WASI builds from ESM sources.`,
826
+ )
827
+ }
812
828
  }
813
829
  const targetSource = hasThreads
814
830
  ? source
@@ -1124,6 +1140,7 @@ export async function buildProject(rawOptions: BuildOptions) {
1124
1140
 
1125
1141
  const options: ParsedBuildOptions = {
1126
1142
  dtsCache: true,
1143
+ ohosSign: true,
1127
1144
  ...rawOptions,
1128
1145
  format: resolveBuildFormat(rawOptions),
1129
1146
  cwd: rawOptions.cwd ?? process.cwd(),
@@ -2452,6 +2469,40 @@ class Builder {
2452
2469
  } else {
2453
2470
  await copyFileAtomic(src, dest)
2454
2471
  artifactReplaced = true
2472
+ // OpenHarmony devices refuse to load unsigned `.so` files, so inject
2473
+ // the fs-verity `.codesign` self-signature unless `--no-ohos-sign`.
2474
+ if (
2475
+ this.target.platform === 'openharmony' &&
2476
+ this.options.ohosSign !== false
2477
+ ) {
2478
+ debug('Self-signing OpenHarmony artifact:')
2479
+ debug(' %i', dest)
2480
+ try {
2481
+ // Force mode: the OHOS SDK linker can already self-sign artifacts
2482
+ // when its opt-in code-signing is enabled — strip that signature
2483
+ // and re-sign rather than failing on the existing `.codesign`
2484
+ // section.
2485
+ signFileAtomic(dest, true)
2486
+ // Re-verify the signature we just wrote so a corrupt or
2487
+ // mis-injected `.codesign` section is surfaced immediately.
2488
+ const { ok, reason } = checkSelfsign(readFileSync(dest))
2489
+ if (!ok) {
2490
+ debug.warn(
2491
+ `OpenHarmony self-sign verification failed: ${dest}: ${reason}`,
2492
+ )
2493
+ }
2494
+ } catch (e) {
2495
+ // Self-signing is best-effort: HarmonyOS only ships on aarch64,
2496
+ // and the algorithm (like the official binary-sign-tool) is only
2497
+ // exercised against aarch64 artifacts — warn instead of failing
2498
+ // the whole build when e.g. an x86_64 artifact cannot be signed.
2499
+ // signElf rejects before writing anything, so `dest` still holds
2500
+ // the copied artifact.
2501
+ debug.warn(
2502
+ `Skip OpenHarmony self-signing: ${dest}: ${(e as Error).message}`,
2503
+ )
2504
+ }
2505
+ }
2455
2506
  }
2456
2507
  this.outputs.push({
2457
2508
  kind: dest.endsWith('.node') ? 'node' : isWasm ? 'wasm' : 'exe',