crossbind 2.0.0-beta.62 → 2.0.0-beta.63

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 (107) hide show
  1. package/README.md +2 -2
  2. package/package.json +6 -1
  3. package/src/actions/buildDependencies.js +15 -2
  4. package/src/actions/buildExternal.js +9 -1
  5. package/src/actions/buildLib.js +25 -8
  6. package/src/actions/buildNativeCommand.js +84 -0
  7. package/src/actions/buildNode.js +48 -7
  8. package/src/actions/buildTypes.js +8 -2
  9. package/src/actions/buildWasiCommand.js +0 -4
  10. package/src/actions/buildWasm.js +49 -29
  11. package/src/actions/cloudCommands.js +133 -0
  12. package/src/actions/createImportedBridges.js +64 -0
  13. package/src/actions/createInterface.js +247 -68
  14. package/src/actions/createLib.js +57 -41
  15. package/src/actions/getData.js +4 -1
  16. package/src/actions/getLinkInputs.js +12 -5
  17. package/src/actions/isSourceNewer.js +2 -0
  18. package/src/actions/licenses.js +143 -19
  19. package/src/actions/prepareConanDependencies.js +51 -0
  20. package/src/actions/publishBinary.js +32 -0
  21. package/src/actions/run.js +63 -66
  22. package/src/actions/runnerCommands.js +421 -0
  23. package/src/actions/writeRuntimeEntry.js +69 -0
  24. package/src/assets/cmake/command/CMakeLists.txt +21 -0
  25. package/src/assets/cmake/command/command.cpp +1 -0
  26. package/src/assets/cmake/dist.cmake +2 -1
  27. package/src/assets/configure/install.sh +22 -0
  28. package/src/assets/js-runtime/adapters/path-url.js +6 -3
  29. package/src/assets/js-runtime/adapters/unboundMessage.js +2 -0
  30. package/src/assets/js-runtime/adapters/worker-comlink.js +87 -60
  31. package/src/assets/js-runtime/core.js +11 -0
  32. package/src/assets/node-import-hooks.mjs +61 -0
  33. package/src/assets/toolchain-digests.json +19 -19
  34. package/src/assets/wasi-runtime/stubs.c +29 -0
  35. package/src/bin.js +257 -50
  36. package/src/index.js +3 -0
  37. package/src/integration/bindHeaderImports.js +18 -0
  38. package/src/integration/getCrossbindScript.js +46 -10
  39. package/src/integration/getDependFilePath.js +9 -0
  40. package/src/integration/resolveNativeImport.js +14 -0
  41. package/src/node/dev.mjs +19 -0
  42. package/src/runner/blobs.js +135 -0
  43. package/src/runner/files.js +193 -0
  44. package/src/runner/server.js +320 -0
  45. package/src/state/attachConanDependencies.js +56 -0
  46. package/src/state/index.js +17 -9
  47. package/src/state/loadConfig.js +33 -24
  48. package/src/state/refreshBuiltDependencies.js +20 -0
  49. package/src/state/refreshConanDependencies.js +17 -0
  50. package/src/utils/androidToolchain.js +4 -0
  51. package/src/utils/appRustCrates.js +15 -1
  52. package/src/utils/appSources.js +97 -6
  53. package/src/utils/archiveFlags.js +12 -0
  54. package/src/utils/boundHeaders.js +79 -0
  55. package/src/utils/bridgeExtras.js +486 -0
  56. package/src/utils/bridgeRuntimeFixes.js +48 -0
  57. package/src/utils/bundledLicenses.js +26 -0
  58. package/src/utils/cargoTarget.js +21 -3
  59. package/src/utils/cloudAccount.js +55 -0
  60. package/src/utils/conanDependencies.js +66 -0
  61. package/src/utils/conanImport.js +58 -0
  62. package/src/utils/conanInstall.js +154 -0
  63. package/src/utils/conanProfile.js +203 -0
  64. package/src/utils/conanStage.js +292 -0
  65. package/src/utils/copyToDistSource.js +13 -0
  66. package/src/utils/cppDts.js +58 -15
  67. package/src/utils/darwinToolchain.js +11 -0
  68. package/src/utils/dirLock.js +137 -28
  69. package/src/utils/downloadAndExtractFile.js +34 -7
  70. package/src/utils/embindArgumentGuards.js +41 -2
  71. package/src/utils/embindCallArguments.js +19 -0
  72. package/src/utils/embindJspiQueue.js +25 -0
  73. package/src/utils/execContainer.js +49 -0
  74. package/src/utils/headerImports.js +16 -5
  75. package/src/utils/hostPlatform.js +21 -0
  76. package/src/utils/iosToolchain.js +6 -0
  77. package/src/utils/isSourceCmakePackage.js +10 -0
  78. package/src/utils/licenseReport.js +11 -2
  79. package/src/utils/linuxToolchain.js +24 -0
  80. package/src/utils/makeTreeWritable.js +12 -0
  81. package/src/utils/nativeImports.js +23 -0
  82. package/src/utils/nativeSourceStamp.js +36 -0
  83. package/src/utils/nodeDevBuild.js +92 -0
  84. package/src/utils/packageLicense.js +101 -0
  85. package/src/utils/pthreadMainScript.js +14 -0
  86. package/src/utils/pullDockerImage.js +3 -1
  87. package/src/utils/relocatePrebuilt.js +119 -0
  88. package/src/utils/remoteClient.js +127 -0
  89. package/src/utils/remoteOutputs.js +39 -0
  90. package/src/utils/remoteRunner.js +97 -0
  91. package/src/utils/remoteUpload.js +134 -0
  92. package/src/utils/replaceFile.js +8 -0
  93. package/src/utils/runCargo.js +48 -51
  94. package/src/utils/runConan.js +257 -0
  95. package/src/utils/runtimeEntries.js +56 -0
  96. package/src/utils/rustBridgeGen.js +9 -4
  97. package/src/utils/rustMt.js +4 -0
  98. package/src/utils/scopedEmbind.js +22 -0
  99. package/src/utils/selectRunner.js +81 -0
  100. package/src/utils/sourceFingerprint.js +4 -3
  101. package/src/utils/swigInterface.js +69 -4
  102. package/src/utils/systemKeys.js +35 -3
  103. package/src/utils/targets.js +49 -4
  104. package/src/utils/toolchainNotices.js +5 -0
  105. package/src/utils/wasiToolchain.js +14 -1
  106. package/src/utils/windowsToolchain.js +18 -0
  107. package/vitest.config.js +11 -0
@@ -4,14 +4,23 @@ import crypto from 'node:crypto';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { Readable } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
+ import logger from './logger.js';
7
8
 
8
- export default async function downloadAndExtractFile(url, output, sha256) {
9
+ // Upstream hosts drop a request now and then (ftp.gnu.org refused eight parallel libiconv fetches at once), so a
10
+ // request that failed for a passing reason is tried again after these pauses. Any other 4xx is the server's answer.
11
+ const RETRY_DELAYS_MS = [1000, 2000, 4000];
12
+ const RETRYABLE_STATUS = new Set([408, 429]);
13
+
14
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
15
+ const retryable = (error) => Object.assign(error, { retryable: true });
16
+
17
+ export default async function downloadAndExtractFile(url, output, sha256, options = {}) {
9
18
  const archive = path.basename(url);
10
19
  if (fs.existsSync(`${output}/source`) && sourceCameFrom(output, archive)) {
11
20
  return false;
12
21
  }
13
22
  fs.rmSync(`${output}/source`, { recursive: true, force: true });
14
- const filePath = await downloadFile(url, output);
23
+ const filePath = await downloadFile(url, output, options);
15
24
  verifyIntegrity(filePath, url, sha256);
16
25
  extractArchive(filePath, url, output);
17
26
  fs.writeFileSync(`${output}/source.archive`, archive);
@@ -86,29 +95,47 @@ export function assertHttps(target, context = target) {
86
95
 
87
96
  // fetch follows redirects itself, which release downloads rely on (github and the osgeo
88
97
  // mirrors both bounce), so the download needs no redirect library of its own.
89
- export async function downloadFile(url, folder) {
98
+ export async function downloadFile(url, folder, { retryDelaysMs = RETRY_DELAYS_MS } = {}) {
90
99
  mkdirSync(folder, { recursive: true });
91
100
  const dest = `${folder}/${path.basename(url)}`;
92
101
  if (fs.existsSync(dest)) return dest;
93
102
 
94
103
  assertHttps(url);
104
+ for (let attempt = 1; ; attempt += 1) {
105
+ try {
106
+ await fetchTo(url, dest);
107
+ return dest;
108
+ } catch (err) {
109
+ if (!err.retryable) throw err;
110
+ if (attempt > retryDelaysMs.length) {
111
+ throw new Error(`${err.message} (after ${attempt} attempts)`, { cause: err });
112
+ }
113
+ const delay = retryDelaysMs[attempt - 1];
114
+ logger.info(`${err.message} Trying again in ${delay / 1000}s…`);
115
+ await sleep(delay);
116
+ }
117
+ }
118
+ }
119
+
120
+ async function fetchTo(url, dest) {
95
121
  let response;
96
122
  try {
97
123
  response = await fetch(url, { headers: { 'User-Agent': 'curl/8.7.1' }, redirect: 'follow' });
98
124
  } catch (err) {
99
- throw new Error(`crossbind: cannot reach ${url}: ${err.message}`, { cause: err });
125
+ throw retryable(new Error(`crossbind: cannot reach ${url}: ${err.message}`, { cause: err }));
100
126
  }
101
127
  // A redirect chain must not be able to downgrade the transport on its last hop.
102
128
  if (response.url) assertHttps(response.url, `${url} (redirected to ${response.url})`);
103
129
  if (!response.ok) {
104
- throw new Error(`crossbind: download failed for ${url} — HTTP ${response.status}.`);
130
+ await response.body?.cancel();
131
+ const error = new Error(`crossbind: download failed for ${url} — HTTP ${response.status}.`);
132
+ throw response.status >= 500 || RETRYABLE_STATUS.has(response.status) ? retryable(error) : error;
105
133
  }
106
134
  try {
107
135
  await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(dest));
108
136
  } catch (err) {
109
137
  // A half-written archive would fail its hash check later with a confusing message.
110
138
  fs.rmSync(dest, { force: true });
111
- throw new Error(`crossbind: download failed for ${url}: ${err.message}`, { cause: err });
139
+ throw retryable(new Error(`crossbind: download failed for ${url}: ${err.message}`, { cause: err }));
112
140
  }
113
- return dest;
114
141
  }
@@ -36,10 +36,49 @@ export function guardBigIntArguments(glue) {
36
36
  return { text, missed: text.includes('_embind_register_bigint') && !text.includes('Number.isSafeInteger(value)') };
37
37
  }
38
38
 
39
+ // The debug glue the dev servers load spells both registrations over lines, and its integer conversion already
40
+ // throws for anything but a number or a boolean, so the member and character conversions go ahead of that check.
41
+ // Each applies inside its own registration only: the debug float conversion reads the same as the integer one.
42
+ const DEBUG_GUARDS = [
43
+ {
44
+ registration: '__embind_register_integer',
45
+ end: 'readValueFromPointer: integerReadValueFromPointer(',
46
+ from: /toWireType: \(destructors, value\) => \{(\s*)(?=if \(typeof value != ["']number["'])/,
47
+ to: 'toWireType: (destructors, value) => {$1'
48
+ + 'if (typeof value == "string" && size == 1 && value.length == 1) value = value.charCodeAt(0);$1'
49
+ + 'if (value !== null && typeof value == "object" && typeof value.value == "number") value = value.value;$1',
50
+ applied: 'value = value.charCodeAt(0);',
51
+ },
52
+ {
53
+ registration: '__embind_register_enum',
54
+ end: 'readValueFromPointer: enumReadValueFromPointer(',
55
+ from: /toWireType: \(destructors, c\) => c\.value,/,
56
+ to: 'toWireType: (destructors, c) => {'
57
+ + 'if (typeof c == "number") return c;'
58
+ + 'if (c !== null && typeof c == "object" && typeof c.value == "number") return c.value;'
59
+ + 'throw new TypeError(name + " takes a member of the enum or its number, got " + (typeof c == "string" ? JSON.stringify(c) : c === null ? "null" : typeof c))'
60
+ + '},',
61
+ applied: 'takes a member of the enum or its number',
62
+ },
63
+ ];
64
+
65
+ function guardDebugRegistration(glue, { registration, end, from, to, applied }) {
66
+ const start = glue.indexOf(`var ${registration} = `);
67
+ const stop = start < 0 ? -1 : glue.indexOf(end, start);
68
+ if (stop < 0) return { text: glue, missed: false };
69
+ const code = glue.slice(start, stop);
70
+ if (code.includes(applied)) return { text: glue, missed: false };
71
+ const rewritten = code.replace(from, to);
72
+ return { text: glue.slice(0, start) + rewritten + glue.slice(stop), missed: rewritten === code };
73
+ }
74
+
39
75
  // Returns the rewritten glue and the registrations whose code no longer looks the way these rewrites expect.
40
76
  export function guardEmbindArguments(glue) {
41
77
  const missed = GUARDS.filter(({ registration, from, to }) => glue.includes(`${registration}=`) && !glue.includes(from) && !glue.includes(to))
42
78
  .map(({ registration }) => registration);
43
- const text = GUARDS.reduce((current, { from, to }) => current.split(from).join(to), glue);
44
- return { text, missed };
79
+ const released = GUARDS.reduce((current, { from, to }) => current.split(from).join(to), glue);
80
+ return DEBUG_GUARDS.reduce(({ text, missed: names }, guard) => {
81
+ const result = guardDebugRegistration(text, guard);
82
+ return { text: result.text, missed: result.missed ? [...names, guard.registration] : names };
83
+ }, { text: released, missed });
45
84
  }
@@ -0,0 +1,19 @@
1
+ // Linked with -sDYNAMIC_EXECUTION=0, embind's craftInvokerFunction keeps argsWired, invokerFuncArgs and
2
+ // destructors once per bound function, shared by all its calls. A call frees its arguments from there
3
+ // once it returns, or once its promise settles for an async (_JSPI) function, so a call started before
4
+ // then overwrites them and the earlier call frees the later call's arguments instead of its own, which
5
+ // leak: two pending calls of one _JSPI method, or a method called again from a JavaScript callback it
6
+ // runs. Emscripten 6.0.9 builds it this way, as did upstream main on 2026-09-30; this rewrite declares
7
+ // the three inside the invoker, so each call gets its own.
8
+
9
+ // The release glue spells it on one line, the debug glue one statement per line with an argument
10
+ // count check first.
11
+ const SHARED = /var argsWired\s*=\s*new Array\(expectedArgCount\);\s*var invokerFuncArgs\s*=\s*\[\];\s*var destructors\s*=\s*\[\];\s*var invokerFn\s*=\s*function\s*\(\.\.\.args\)\s*\{(\s*(?:checkArgCount\([^;]*\);\s*)?)destructors\.length\s*=\s*0;/g;
12
+ const PER_CALL = 'var invokerFn=function(...args){$1var argsWired=new Array(expectedArgCount);var invokerFuncArgs=[];var destructors=[];';
13
+ const SEPARATED = /var invokerFn\s*=\s*function\s*\(\.\.\.args\)\s*\{\s*(?:checkArgCount\([^;]*\);\s*)?var argsWired\s*=/;
14
+
15
+ // Returns the rewritten glue and whether it still holds an invoker this rewrite does not recognise.
16
+ export function separateCallArguments(glue) {
17
+ const text = glue.replace(SHARED, PER_CALL);
18
+ return { text, missed: text.includes('argsWired') && !SEPARATED.test(text) };
19
+ }
@@ -0,0 +1,25 @@
1
+ // JSPI suspends a call's wasm frames, but its C stack frames stay on the one stack every call of the module shares,
2
+ // and Emscripten 6.0.9 gives a suspended call no stack of its own: a call that resumes after a later one started
3
+ // returns over that one's frames, which the later one still uses. This rewrite runs the module's async (_JSPI)
4
+ // bindings one at a time, as Asyncify runs its async operations: a call made while another is pending enters wasm
5
+ // once that one settles. embind still converts the arguments when the call is made, so a wrong one throws there.
6
+
7
+ // The release glue spells it on one line, the debug glue across lines.
8
+ const PROMISING = /rtn\s*=\s*WebAssembly\.promising\(rtn\)/g;
9
+ const QUEUED = 'rtn=crossbindJspiQueue(WebAssembly.promising(rtn))';
10
+ const REQUIRE_FUNCTION = /var embind__requireFunction\s*=/;
11
+ // Declared in the module factory, so each instance queues on its own, as each has its own C stack.
12
+ const QUEUE = 'var crossbindJspiQueue=(()=>{var pending=null;return fn=>(...args)=>{'
13
+ + 'var call=pending?pending.then(()=>fn(...args)):fn(...args);'
14
+ + 'var settled=pending=call.then(()=>{},()=>{});'
15
+ + 'settled.then(()=>{if(pending===settled)pending=null});'
16
+ + 'return call}})();';
17
+
18
+ // Returns the rewritten glue and whether it holds a promising export this rewrite does not recognise.
19
+ export function queueJspiCalls(glue) {
20
+ const text = glue.replace(PROMISING, QUEUED);
21
+ if (text === glue || !REQUIRE_FUNCTION.test(text)) {
22
+ return { text: glue, missed: glue.includes('WebAssembly.promising(rtn') && !glue.includes(QUEUED) };
23
+ }
24
+ return { text: text.replace(REQUIRE_FUNCTION, (declaration) => `${QUEUE}${declaration}`), missed: false };
25
+ }
@@ -0,0 +1,49 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import getOsUserAndGroupId from './getOsUserAndGroupId.js';
5
+
6
+ function samePath(a, b) {
7
+ const real = (p) => {
8
+ try {
9
+ return fs.realpathSync(p);
10
+ } catch {
11
+ return path.resolve(p);
12
+ }
13
+ };
14
+ return real(a) === real(b);
15
+ }
16
+
17
+ // docker exec attaches to a container the user created, so nothing else checks what it mounts. A
18
+ // container without a tool's cache mount keeps that cache inside itself, where the host reads paths
19
+ // that were never written: fail first, with the way to fix it.
20
+ export default function assertExecContainer({ name, mounts, workdir, hint, mountNote }) {
21
+ let info;
22
+ try {
23
+ // stderr is dropped: docker's own "No such container" would land before the message below.
24
+ info = JSON.parse(execFileSync('docker', ['container', 'inspect', name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }))[0];
25
+ } catch {
26
+ throw new Error(`crossbind: RUNNER=DOCKER_EXEC needs a container named '${name}', which does not exist.\n${hint}`);
27
+ }
28
+ if (!info?.State?.Running) {
29
+ throw new Error(
30
+ `crossbind: the container '${name}' exists but is not running. Start it with \`docker start ${name}\`, or recreate it.\n${hint}`,
31
+ );
32
+ }
33
+ for (const [destination, source] of mounts) {
34
+ const mount = (info.Mounts ?? []).find((m) => m.Destination === destination);
35
+ if (!mount || !samePath(mount.Source, source)) {
36
+ const found = mount ? ` - it mounts ${mount.Source} there` : '';
37
+ throw new Error(
38
+ `crossbind: the container '${name}' does not mount ${source} at ${destination}${found}. ${mountNote}\n${hint}`,
39
+ );
40
+ }
41
+ }
42
+ // `docker run --workdir` creates the directory; `docker exec --workdir` does not, and the tool
43
+ // dies with an OCI "chdir to cwd" error that names nothing the user can act on.
44
+ try {
45
+ execFileSync('docker', ['exec', '--user', getOsUserAndGroupId(), name, 'mkdir', '-p', workdir], { stdio: 'ignore' });
46
+ } catch (e) {
47
+ throw new Error(`crossbind: could not create the working directory ${workdir} inside '${name}'.\n${hint}`, { cause: e });
48
+ }
49
+ }
@@ -7,9 +7,14 @@ export const ALL_NAMES = '*';
7
7
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
8
8
  // The clause shapes are spelled out, so a match cannot run from one statement into the next.
9
9
  const HEADER_IMPORT = /\b(import|export)\s+(type\s+)?((?:[A-Za-z_$][\w$]*\s*,\s*)?(?:\{[^}]*\}|\*(?:\s*as\s+[A-Za-z_$][\w$]*)?)|[A-Za-z_$][\w$]*)\s*from\s*(['"])([^'"\n]+)\4/g;
10
+ // `import('x.h')`, `import 'x.h'` and `require('x.h')` hand the app the whole module. A lazy import may carry a bundler
11
+ // comment (`/* webpackChunkName: "x" */`) or a template literal. A comment ends at its first `*/`, so a run of them
12
+ // matches one way only.
13
+ const WHOLE_MODULE = /\b(?:import\s*\(\s*(?:\/\*(?:[^*]|\*(?!\/))*\*\/\s*)*|import\s+|require\s*\(\s*)(['"`])([^'"`$\n]+)\1/g;
10
14
 
15
+ // A comment runs to its first `*/`, or to the end when it never closes, so each is read once.
11
16
  function listedNames(list) {
12
- return list.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '').split(',')
17
+ return list.replace(/\/\*(?:[^*]|\*(?!\/))*(?:\*\/|$)|\/\/[^\n]*/g, '').split(',')
13
18
  .map((entry) => entry.trim())
14
19
  .filter((entry) => entry && !/^type\s/.test(entry))
15
20
  .map((entry) => entry.split(/\s+as\s+/)[0])
@@ -19,13 +24,19 @@ function listedNames(list) {
19
24
  // A specifier built at run time is not found.
20
25
  export function findHeaderImports(text, headerExtensions) {
21
26
  const isHeader = new RegExp(`\\.(${headerExtensions.join('|')})$`);
22
- return [...text.matchAll(HEADER_IMPORT)].flatMap(([, , type, clause, , specifier]) => {
27
+ const listed = [...text.matchAll(HEADER_IMPORT)].flatMap(([, , type, clause, , specifier]) => {
23
28
  if (type || !isHeader.test(specifier)) return [];
24
- const list = clause.match(/\{([^}]*)\}/);
25
- if (clause.replace(list?.[0] ?? '', '').includes('*')) return [{ specifier, names: ALL_NAMES }];
26
- const names = listedNames(list?.[1] ?? '');
29
+ const open = clause.indexOf('{');
30
+ const close = open === -1 ? -1 : clause.indexOf('}', open);
31
+ const hasList = close !== -1;
32
+ if ((hasList ? clause.slice(0, open) + clause.slice(close + 1) : clause).includes('*')) return [{ specifier, names: ALL_NAMES }];
33
+ const names = listedNames(hasList ? clause.slice(open + 1, close) : '');
27
34
  return names.length ? [{ specifier, names }] : [];
28
35
  });
36
+ const whole = [...text.matchAll(WHOLE_MODULE)]
37
+ .filter(([, , specifier]) => isHeader.test(specifier))
38
+ .map(([, , specifier]) => ({ specifier, names: ALL_NAMES }));
39
+ return [...listed, ...whole];
29
40
  }
30
41
 
31
42
  const scanned = new Map();
@@ -0,0 +1,21 @@
1
+ // What `-p host` builds: the platform whose binaries this machine runs. Node reports glibc and musl
2
+ // Linux alike; as the addon loader decides, a glibc process names its glibc in the report header.
3
+ export default function hostPlatform(platform = process.platform, readReport = processReport) {
4
+ if (platform !== 'linux') return platform;
5
+ const header = readReport()?.header;
6
+ return !header || header.glibcVersionRuntime ? 'linux' : 'linuxmusl';
7
+ }
8
+
9
+ export const resolveHostPlatform = (platforms, host = hostPlatform()) => platforms.map((platform) => (platform === 'host' ? host : platform));
10
+
11
+ function processReport() {
12
+ if (!process.report) return undefined;
13
+ // Only the header is read; the network section can stall on DNS lookups.
14
+ const { excludeNetwork } = process.report;
15
+ process.report.excludeNetwork = true;
16
+ try {
17
+ return process.report.getReport();
18
+ } finally {
19
+ process.report.excludeNetwork = excludeNetwork;
20
+ }
21
+ }
@@ -0,0 +1,6 @@
1
+ // The Xcode every iOS archive is built with, and the deployment target it is built for: port archives
2
+ // and Conan packages link into the same app, so they move together.
3
+ export const IOS_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
4
+ export const XCODE_TOOLCHAIN_BIN = `${IOS_DEVELOPER_DIR}/Toolchains/XcodeDefault.xctoolchain/usr/bin`;
5
+ // Xcode 27 refuses deployment targets below 15.0; React Native's own floor is 15.1.
6
+ export const IOS_DEPLOYMENT_TARGET = '15.1';
@@ -0,0 +1,10 @@
1
+ import fs from 'node:fs';
2
+
3
+ // A cmake package that ships its sources rather than a prebuilt: its own CMakeLists (at the package root, not a
4
+ // generated dist/prebuilt one) is compiled into the consuming build via add_subdirectory.
5
+ export default function isSourceCmakePackage(config) {
6
+ return config.export?.type === 'cmake'
7
+ && config.paths.cmake !== config.paths.cliCMakeListsTxt
8
+ && !config.paths.cmakeDir.endsWith('/prebuilt')
9
+ && fs.existsSync(config.paths.cmake);
10
+ }
@@ -18,6 +18,12 @@ export function validateSpdx(expression) {
18
18
  }
19
19
  }
20
20
 
21
+ // Longer than any run of backticks in the text, which would otherwise close the block early.
22
+ function fenceFor(text) {
23
+ const longest = [...text.matchAll(/`+/g)].reduce((most, [run]) => Math.max(most, run.length), 0);
24
+ return '`'.repeat(Math.max(3, longest + 1));
25
+ }
26
+
21
27
  export function formatNoticeSections(rows) {
22
28
  return [...rows]
23
29
  .sort((a, b) => a.name.localeCompare(b.name))
@@ -29,7 +35,10 @@ export function formatNoticeSections(rows) {
29
35
  if (row.licenseSelected) lines.push(`- license election: ${row.licenseSelected} (declared: ${row.licenseDeclared})`);
30
36
  else if (/\bOR\b/.test(row.license || '')) lines.push(`- no license election recorded; the declared expression applies${row.licenseText ? ' and all texts are included' : ' (texts linked above)'}`);
31
37
  if (row.licenseNotes) lines.push(`- note: ${row.licenseNotes}`);
32
- if (row.licenseText) lines.push('', '```', row.licenseText.trim(), '```');
38
+ if (row.licenseText) {
39
+ const fence = fenceFor(row.licenseText);
40
+ lines.push('', fence, row.licenseText.trim(), fence);
41
+ }
33
42
  return lines.join('\n');
34
43
  });
35
44
  }
@@ -84,7 +93,7 @@ export function formatCycloneDxSbom(rows, target = {}) {
84
93
  type: 'library',
85
94
  name: row.name,
86
95
  ...(row.nativeVersion ? { version: row.nativeVersion } : {}),
87
- ...(row.nativeVersion ? { purl: `pkg:generic/${row.name}@${row.nativeVersion}` } : {}),
96
+ ...(row.nativeVersion ? { purl: row.purl ?? `pkg:generic/${row.name}@${row.nativeVersion}` } : {}),
88
97
  ...(row.license ? { licenses: [{ expression: row.license }] } : {}),
89
98
  ...(row.sha256 ? { hashes: [{ alg: 'SHA-256', content: row.sha256 }] } : {}),
90
99
  ...(row.sourceUrl ? { externalReferences: [{ type: 'distribution', url: row.sourceUrl }] } : {}),
@@ -0,0 +1,24 @@
1
+ // The linux image's clang wrappers and CMake toolchain files, one per target triple, and the flags every
2
+ // Linux archive is built with: port archives and Conan packages link into the same addon, so they move
3
+ // together.
4
+ const LINUX_TOOLCHAIN = '/opt/crossbind/linux';
5
+ const LINUX_TRIPLES = {
6
+ linux: { x64: 'x86_64-linux-gnu', arm64: 'aarch64-linux-gnu' },
7
+ linuxmusl: { x64: 'x86_64-alpine-linux-musl', arm64: 'aarch64-alpine-linux-musl' },
8
+ };
9
+ // Every archive ends up inside a loadable .node module, so -fPIC holds even where a project turns
10
+ // position-independent code off for static builds, as GDAL does.
11
+ export const LINUX_ARCHIVE_FLAGS = ['-fPIC'];
12
+
13
+ export const linuxTriple = (target) => LINUX_TRIPLES[target.platform][target.arch];
14
+ export const linuxToolchainFile = (target) => `${LINUX_TOOLCHAIN}/${linuxTriple(target)}.cmake`;
15
+ export const linuxTool = (target, name) => `${LINUX_TOOLCHAIN}/bin/${linuxTriple(target)}-${name}`;
16
+
17
+ // The compilers and archive tools of the target's triple, by the variables build systems read them from.
18
+ // The image's own .pc files describe its libraries, not the sysroot's, so pkg-config reads none of them.
19
+ export const linuxBuildEnv = (target) => ({
20
+ ...Object.fromEntries(Object.entries({
21
+ CC: 'clang', CXX: 'clang++', AR: 'ar', RANLIB: 'ranlib', NM: 'nm', STRIP: 'strip',
22
+ }).map(([variable, tool]) => [variable, linuxTool(target, tool)])),
23
+ PKG_CONFIG_LIBDIR: '',
24
+ });
@@ -0,0 +1,12 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // A read-only directory cannot be emptied, so `rm -rf`, a restage and the cleanup of a conan run
5
+ // would all fail on one. fs.cpSync gives each directory it makes its source's mode. Links are left
6
+ // alone: they can point anywhere.
7
+ export default function makeTreeWritable(dir) {
8
+ const stat = fs.lstatSync(dir);
9
+ if (!stat.isDirectory()) return;
10
+ fs.chmodSync(dir, stat.mode | 0o700);
11
+ fs.readdirSync(dir).forEach((name) => makeTreeWritable(path.join(dir, name)));
12
+ }
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs';
2
+ import appSourceFiles from './appSources.js';
3
+
4
+ // Static imports and re-exports, side-effect imports, and import() or require() of a literal. A specifier
5
+ // built at run time is not found.
6
+ const IMPORT_SPECIFIER = /\b(?:from|import\s*\(?|require\s*\()\s*(['"])([^'"\n]+)\1/g;
7
+
8
+ export function findImportSpecifiers(text) {
9
+ return [...new Set([...text.matchAll(IMPORT_SPECIFIER)].map((match) => match[2]))];
10
+ }
11
+
12
+ export const nativeExtensions = ({ header, module }) => [...header, ...module, 'rs'];
13
+
14
+ export const nativeSpecifierTest = (ext) => {
15
+ const extension = new RegExp(`\\.(${nativeExtensions(ext).join('|')})$`);
16
+ return (specifier) => /^(cargo|conan):/.test(specifier) || extension.test(specifier);
17
+ };
18
+
19
+ export function findNativeImportsIn(projectDir, isNative) {
20
+ return appSourceFiles(projectDir).flatMap((importer) => findImportSpecifiers(fs.readFileSync(importer, 'utf8'))
21
+ .filter(isNative)
22
+ .map((specifier) => ({ importer, specifier })));
23
+ }
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getContentHash } from './hash.js';
4
+
5
+ // Names matter as well as dates: removing or renaming a source must invalidate its archive.
6
+ export function nativeSourceStamp(directories = []) {
7
+ const entries = [];
8
+ const visit = (file) => {
9
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
10
+ if (!stat) { entries.push([file, 'missing']); return; }
11
+ if (stat.isDirectory()) {
12
+ entries.push([file, 'directory']);
13
+ for (const entry of fs.readdirSync(file, { withFileTypes: true })) {
14
+ // Match the source walk's treatment of links and avoid cycles.
15
+ if (entry.isFile() || entry.isDirectory()) visit(path.join(file, entry.name));
16
+ }
17
+ } else if (stat.isFile()) entries.push([file, stat.size, stat.mtimeMs]);
18
+ };
19
+ directories.forEach(visit);
20
+ return getContentHash(JSON.stringify(entries.sort(([a], [b]) => a.localeCompare(b))));
21
+ }
22
+
23
+ const stampFile = (artifact) => `${artifact}.sources`;
24
+
25
+ export function nativeSourceStampChanged(artifact, directories = []) {
26
+ // No local sources: prebuilt ports retain their existing upstream fingerprint contract.
27
+ if (!directories.length) return false;
28
+ const file = stampFile(artifact);
29
+ return !fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== nativeSourceStamp(directories);
30
+ }
31
+
32
+ export function writeNativeSourceStamp(artifact, directories = []) {
33
+ if (!directories.length) return;
34
+ fs.mkdirSync(path.dirname(stampFile(artifact)), { recursive: true });
35
+ fs.writeFileSync(stampFile(artifact), nativeSourceStamp(directories));
36
+ }
@@ -0,0 +1,92 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import upath from 'upath';
5
+ import loadJson from './loadJson.js';
6
+ import { computeInputStamp } from './inputStamp.js';
7
+ import { findHeaderImportsIn } from './headerImports.js';
8
+ import { findNativeImportsIn, nativeSpecifierTest } from './nativeImports.js';
9
+
10
+ // Set on the build a dev start runs: that build is a Node process too, which NODE_OPTIONS may hand the dev hooks again.
11
+ export const DEV_BUILD_ENV = 'CROSSBIND_DEV_BUILD';
12
+
13
+ const CLI = fileURLToPath(new URL('../bin.js', import.meta.url));
14
+ const CONFIG_EXTENSIONS = ['json', 'js', 'mjs', 'cjs', 'ts'];
15
+ // This machine's binary, built the way the Node.js playbook builds it.
16
+ const BUILD_ARGS = {
17
+ napi: ['-p', 'host', '-a', process.arch, '-e', 'node', '-b', 'release'],
18
+ wasm: ['-p', 'wasm', '-a', 'wasm32', '-r', 'st', '-e', 'node', '-b', 'release'],
19
+ };
20
+
21
+ const hasConfig = (dir) => CONFIG_EXTENSIONS.some((ext) => fs.existsSync(`${dir}/crossbind.config.${ext}`));
22
+ const hasNapiRuntime = (dir) => fs.existsSync(`${dir}/node_modules/@crossbind/core-embind-napi/package.json`);
23
+
24
+ function dirAbove(start, test) {
25
+ let dir = upath.resolve(start);
26
+ while (!test(dir)) {
27
+ const parent = upath.dirname(dir);
28
+ if (parent === dir) return null;
29
+ dir = parent;
30
+ }
31
+ return dir;
32
+ }
33
+
34
+ // The nearest directory with a crossbind.config above the app's main module, else above the working directory.
35
+ export function projectDirOf(main, cwd = process.cwd()) {
36
+ const project = (main && dirAbove(upath.dirname(main), hasConfig)) ?? dirAbove(cwd, hasConfig);
37
+ if (!project) throw new Error(`crossbind: no crossbind.config above ${main ?? cwd}; start Node in the app, or leave out --import crossbind/node/dev.`);
38
+ return project;
39
+ }
40
+
41
+ // A project that installs the Node-API runtime runs its addon, any other its wasm build. Looked up through the
42
+ // node_modules above the project only: pnpm's bin shims set NODE_PATH to the whole workspace.
43
+ export function devFormatOf(project) {
44
+ return dirAbove(project, hasNapiRuntime) ? 'napi' : 'wasm';
45
+ }
46
+
47
+ // What decides whether a build is still current: the project's native sources, the files its relative native imports
48
+ // name, its config and manifest, and which native files and names its JavaScript imports - not the rest of its code.
49
+ export function nativeInputsFingerprint(config) {
50
+ const { project, native, header, module: modules } = config.paths;
51
+ const relativeTo = (importer) => upath.relative(project, importer);
52
+ const imports = findNativeImportsIn(project, nativeSpecifierTest(config.ext))
53
+ .map(({ importer, specifier }) => ({ importer: relativeTo(importer), specifier }));
54
+ const names = findHeaderImportsIn(project, config.ext.header)
55
+ .map(({ importer, specifier, names: taken }) => JSON.stringify([relativeTo(importer), specifier, taken]));
56
+ const importedFiles = imports.filter(({ specifier }) => specifier.startsWith('.'))
57
+ .map(({ importer, specifier }) => upath.resolve(project, upath.dirname(importer), specifier));
58
+ const projectFiles = [...CONFIG_EXTENSIONS.flatMap((ext) => [`crossbind.config.${ext}`, `crossbind.overrides.${ext}`]), 'package.json']
59
+ .map((file) => `${project}/${file}`);
60
+ const salt = JSON.stringify({ imports: imports.map((entry) => JSON.stringify(entry)).sort(), names: names.sort() });
61
+ return computeInputStamp(
62
+ [...native, ...header, ...modules],
63
+ [...config.ext.header, ...config.ext.source, ...config.ext.module, 'rs'],
64
+ [...projectFiles, ...importedFiles],
65
+ salt,
66
+ );
67
+ }
68
+
69
+ function runBuild(project, args) {
70
+ const command = `crossbind build ${args.join(' ')}`;
71
+ console.error(`crossbind: native sources changed since the last build - ${command}`);
72
+ const { status, error } = spawnSync(process.execPath, [CLI, 'build', ...args], {
73
+ cwd: project, stdio: ['ignore', 2, 2], env: { ...process.env, [DEV_BUILD_ENV]: '1' },
74
+ });
75
+ if (error || status !== 0) throw new Error(`crossbind: ${command} failed; fix what it reports and start the app again.`, { cause: error });
76
+ }
77
+
78
+ // Builds this machine's binary when the native inputs changed since the last build a dev start ran, or when its output
79
+ // is gone, and returns the hooks that build wrote, or null for an app that imports nothing native.
80
+ export function ensureDevBuild(config, { run = runBuild } = {}) {
81
+ const { project, cache, output } = config.paths;
82
+ const format = devFormatOf(project);
83
+ const stamp = `${cache}/node-dev/${format}.json`;
84
+ const fingerprint = nativeInputsFingerprint(config);
85
+ if (!fs.existsSync(`${output}/node/${format}.mjs`) || loadJson(stamp)?.fingerprint !== fingerprint) {
86
+ run(project, BUILD_ARGS[format]);
87
+ fs.mkdirSync(upath.dirname(stamp), { recursive: true });
88
+ fs.writeFileSync(stamp, JSON.stringify({ fingerprint }));
89
+ }
90
+ const hooks = `${output}/node/${format}.register.mjs`;
91
+ return fs.existsSync(hooks) ? hooks : null;
92
+ }
@@ -0,0 +1,101 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { deriveLicenseExpression, formatCycloneDxSbom, formatNoticeSections } from './licenseReport.js';
4
+
5
+ export const SBOM_FILE = 'sbom.cdx.json';
6
+ const RULE = '-'.repeat(70);
7
+
8
+ export function lgplRows(rows) {
9
+ return rows.filter((row) => /\bLGPL\b/.test(row.license ?? ''));
10
+ }
11
+
12
+ // The LLVM exception waives the notice for runtime parts compiled into a binary; every other
13
+ // component needs its text in the package.
14
+ function assertTexts(name, rows) {
15
+ const missing = rows.filter(
16
+ (row) => (!row.licenseText || row.licenseText.includes('(missing:')) && !/\bLLVM-exception\b/.test(row.license ?? ''),
17
+ );
18
+ if (missing.length) {
19
+ throw new Error(
20
+ `${name}: no license text for ${missing.map((row) => row.name).join(', ')}; build the package so crossbind licenses can read it.`,
21
+ );
22
+ }
23
+ }
24
+
25
+ const listOf = (names) => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names[0]);
26
+
27
+ function relinkSection({ name, version, repository, projectPath, addonFile, rows }) {
28
+ const tag = `${name}@${version}`;
29
+ const repo = repository.replace(/\.git$/, '');
30
+ return [
31
+ '## Relinking the LGPL libraries',
32
+ '',
33
+ `This addon links ${listOf(rows.map((row) => row.name))} statically; their sections below give their licenses.`,
34
+ 'You may replace them with modified versions and relink the addon:',
35
+ '',
36
+ `1. Get the corresponding source. This package was built from ${repo} at the tag ${tag}:`,
37
+ ` ${repo}/tree/${tag}`,
38
+ ' The upstream sources are the tarballs the sections below list, with their SHA-256.',
39
+ `2. In ${projectPath}, point the library at your modified source with a crossbind.overrides.js file`,
40
+ ' (docs/playbooks/override-dependencies.md in that repository).',
41
+ `3. Build the addon with \`pnpm install\` and \`pnpm --dir ${projectPath} build\`. Linux and Windows`,
42
+ ' addons build in Docker, macOS addons on a Mac.',
43
+ `4. The build writes ${addonFile} in ${projectPath}; it replaces the file of that name in this package.`,
44
+ ].join('\n');
45
+ }
46
+
47
+ export function nodePackageLicense({ name, version, repository, projectPath, addonFile = null, rows }) {
48
+ assertTexts(name, rows);
49
+ const expression = deriveLicenseExpression(rows);
50
+ const relinked = addonFile ? lgplRows(rows) : [];
51
+ const lines = [
52
+ addonFile
53
+ ? `${name} ships a Node-API addon that links the components below statically.`
54
+ : `${name} ships the JavaScript bindings, types and data of the components below; the addons that link them are in its platform packages.`,
55
+ 'The full license text of every component follows.',
56
+ '',
57
+ 'Effective license expression (also package.json "license"):',
58
+ '',
59
+ ` ${expression}`,
60
+ '',
61
+ `The machine-readable component inventory is ${SBOM_FILE} next to this file.`,
62
+ '',
63
+ 'GENERATED by `crossbind licenses --package` - do not edit. This file states facts and is not legal',
64
+ 'advice; the compliance assessment is yours.',
65
+ '',
66
+ RULE,
67
+ '',
68
+ ...(relinked.length ? [relinkSection({ name, version, repository, projectPath, addonFile, rows: relinked }), '', RULE, ''] : []),
69
+ formatNoticeSections(rows).join('\n\n'),
70
+ '',
71
+ ];
72
+ return { expression, text: lines.join('\n') };
73
+ }
74
+
75
+ export function setManifestLicense(dir, expression) {
76
+ const file = path.join(dir, 'package.json');
77
+ const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
78
+ if (manifest.license === expression) return;
79
+ fs.writeFileSync(file, `${JSON.stringify({ ...manifest, license: expression }, null, 4)}\n`);
80
+ }
81
+
82
+ export function writeNodePackageLicense(dir, meta) {
83
+ const { expression, text } = nodePackageLicense(meta);
84
+ fs.writeFileSync(path.join(dir, 'LICENSE'), text);
85
+ fs.writeFileSync(path.join(dir, SBOM_FILE), formatCycloneDxSbom(meta.rows, { name: meta.name, version: meta.version }));
86
+ setManifestLicense(dir, expression);
87
+ return expression;
88
+ }
89
+
90
+ // What a Node-API package's license names, from its manifest and its place in the repository it builds
91
+ // from: an addon package's is the addon its own build writes.
92
+ export function nodePackageMeta({ package: manifest = {}, paths }) {
93
+ const repository = typeof manifest.repository === 'string' ? manifest.repository : (manifest.repository?.url ?? '');
94
+ return {
95
+ name: manifest.name,
96
+ version: manifest.version,
97
+ repository,
98
+ projectPath: path.relative(paths.base, paths.project).split(path.sep).join('/') || '.',
99
+ addonFile: manifest.os && /\.node$/.test(manifest.main ?? '') ? manifest.main : null,
100
+ };
101
+ }