crossbind 2.0.0-beta.60 → 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 (118) hide show
  1. package/README.md +8 -6
  2. package/package.json +6 -1
  3. package/src/actions/buildDependencies.js +37 -9
  4. package/src/actions/buildExternal.js +9 -1
  5. package/src/actions/buildJs.js +1 -1
  6. package/src/actions/buildLib.js +27 -8
  7. package/src/actions/buildNativeCommand.js +84 -0
  8. package/src/actions/buildNode.js +190 -0
  9. package/src/actions/buildTypes.js +8 -2
  10. package/src/actions/buildWasiCommand.js +0 -4
  11. package/src/actions/buildWasm.js +92 -95
  12. package/src/actions/cloudCommands.js +133 -0
  13. package/src/actions/createImportedBridges.js +64 -0
  14. package/src/actions/createInterface.js +318 -65
  15. package/src/actions/createLib.js +60 -43
  16. package/src/actions/getAllBridges.js +1 -1
  17. package/src/actions/getData.js +4 -1
  18. package/src/actions/getLinkInputs.js +70 -0
  19. package/src/actions/isSourceNewer.js +2 -0
  20. package/src/actions/licenses.js +175 -17
  21. package/src/actions/prepareConanDependencies.js +51 -0
  22. package/src/actions/publishBinary.js +32 -0
  23. package/src/actions/run.js +124 -32
  24. package/src/actions/runnerCommands.js +421 -0
  25. package/src/actions/writeRuntimeEntry.js +69 -0
  26. package/src/assets/cmake/CMakeLists.txt +1 -1
  27. package/src/assets/cmake/command/CMakeLists.txt +21 -0
  28. package/src/assets/cmake/command/command.cpp +1 -0
  29. package/src/assets/cmake/dist.cmake +2 -1
  30. package/src/assets/configure/install.sh +22 -0
  31. package/src/assets/js-runtime/adapters/path-url.js +6 -3
  32. package/src/assets/js-runtime/adapters/unboundMessage.js +2 -0
  33. package/src/assets/js-runtime/adapters/worker-comlink.js +87 -56
  34. package/src/assets/js-runtime/core.js +11 -0
  35. package/src/assets/node-import-hooks.mjs +61 -0
  36. package/src/assets/toolchain-digests.json +27 -13
  37. package/src/assets/wasi-runtime/stubs.c +29 -0
  38. package/src/bin.js +290 -37
  39. package/src/index.js +6 -1
  40. package/src/integration/bindHeaderImports.js +18 -0
  41. package/src/integration/getCrossbindScript.js +53 -19
  42. package/src/integration/getDependFilePath.js +18 -12
  43. package/src/integration/resolveNativeImport.js +14 -0
  44. package/src/node/dev.mjs +19 -0
  45. package/src/runner/blobs.js +135 -0
  46. package/src/runner/files.js +193 -0
  47. package/src/runner/server.js +320 -0
  48. package/src/state/attachConanDependencies.js +56 -0
  49. package/src/state/index.js +31 -20
  50. package/src/state/loadConfig.js +36 -24
  51. package/src/state/refreshBuiltDependencies.js +20 -0
  52. package/src/state/refreshConanDependencies.js +17 -0
  53. package/src/utils/androidToolchain.js +4 -0
  54. package/src/utils/appRustCrates.js +17 -1
  55. package/src/utils/appSources.js +112 -0
  56. package/src/utils/archiveFlags.js +12 -0
  57. package/src/utils/boundHeaders.js +79 -0
  58. package/src/utils/bridgeExtras.js +486 -0
  59. package/src/utils/bridgeRuntimeFixes.js +48 -0
  60. package/src/utils/bundledLicenses.js +26 -0
  61. package/src/utils/cargoImport.js +44 -0
  62. package/src/utils/cargoTarget.js +23 -3
  63. package/src/utils/cloudAccount.js +55 -0
  64. package/src/utils/conanDependencies.js +66 -0
  65. package/src/utils/conanImport.js +58 -0
  66. package/src/utils/conanInstall.js +154 -0
  67. package/src/utils/conanProfile.js +203 -0
  68. package/src/utils/conanStage.js +292 -0
  69. package/src/utils/copyToDistSource.js +13 -0
  70. package/src/utils/cppDts.js +134 -55
  71. package/src/utils/darwinToolchain.js +11 -0
  72. package/src/utils/dirLock.js +137 -28
  73. package/src/utils/downloadAndExtractFile.js +34 -7
  74. package/src/utils/embindArgumentGuards.js +84 -0
  75. package/src/utils/embindCallArguments.js +19 -0
  76. package/src/utils/embindJspiQueue.js +25 -0
  77. package/src/utils/embindRsFingerprint.js +26 -0
  78. package/src/utils/execContainer.js +49 -0
  79. package/src/utils/headerImports.js +56 -0
  80. package/src/utils/hostPlatform.js +21 -0
  81. package/src/utils/inputStamp.js +23 -0
  82. package/src/utils/iosToolchain.js +6 -0
  83. package/src/utils/isSourceCmakePackage.js +10 -0
  84. package/src/utils/licenseReport.js +11 -2
  85. package/src/utils/linkLayout.js +8 -1
  86. package/src/utils/linuxToolchain.js +24 -0
  87. package/src/utils/makeTreeWritable.js +12 -0
  88. package/src/utils/nativeImports.js +23 -0
  89. package/src/utils/nativeSourceStamp.js +36 -0
  90. package/src/utils/nodeDevBuild.js +92 -0
  91. package/src/utils/packageLicense.js +101 -0
  92. package/src/utils/pthreadMainScript.js +14 -0
  93. package/src/utils/pullDockerImage.js +10 -5
  94. package/src/utils/relocatePrebuilt.js +119 -0
  95. package/src/utils/remoteClient.js +127 -0
  96. package/src/utils/remoteOutputs.js +39 -0
  97. package/src/utils/remoteRunner.js +97 -0
  98. package/src/utils/remoteUpload.js +134 -0
  99. package/src/utils/replaceFile.js +8 -0
  100. package/src/utils/resolveCompanionPackage.js +51 -0
  101. package/src/utils/resolveEmbindNapi.js +22 -0
  102. package/src/utils/resolveEmbindRust.js +17 -52
  103. package/src/utils/runCargo.js +51 -53
  104. package/src/utils/runConan.js +257 -0
  105. package/src/utils/runtimeEntries.js +56 -0
  106. package/src/utils/rustBridgeGen.js +903 -253
  107. package/src/utils/rustMt.js +4 -0
  108. package/src/utils/scopedEmbind.js +22 -0
  109. package/src/utils/selectRunner.js +81 -0
  110. package/src/utils/sourceFingerprint.js +4 -3
  111. package/src/utils/swigInterface.js +86 -6
  112. package/src/utils/systemKeys.js +43 -3
  113. package/src/utils/targets.js +104 -3
  114. package/src/utils/toolchainNotices.js +5 -0
  115. package/src/utils/wasiToolchain.js +14 -1
  116. package/src/utils/windowsToolchain.js +18 -0
  117. package/vitest.config.js +11 -0
  118. package/src/utils/cppFieldBindings.js +0 -23
@@ -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 }] } : {}),
@@ -13,7 +13,9 @@ export function libNameOf(libPath) {
13
13
  return match ? match[1] : base;
14
14
  }
15
15
 
16
- export function buildLinkLibArgs(libs, { wholeArchiveAll = false, wholeArchiveNames = new Set(), wrapLast = true } = {}) {
16
+ export function buildLinkLibArgs(libs, {
17
+ wholeArchiveAll = false, wholeArchiveNames = new Set(), wrapLast = true, forceLoad = false,
18
+ } = {}) {
17
19
  const args = [];
18
20
  let wrapped = false;
19
21
  libs.forEach((lib, index) => {
@@ -21,6 +23,11 @@ export function buildLinkLibArgs(libs, { wholeArchiveAll = false, wholeArchiveNa
21
23
  // have no bridge (main() is the GC root), so they pass false.
22
24
  const isBridge = wrapLast && index === libs.length - 1;
23
25
  const wantWrap = wholeArchiveAll || isBridge || wholeArchiveNames.has(libNameOf(lib));
26
+ // Apple's ld64 has no --whole-archive groups; it keeps archives one at a time.
27
+ if (forceLoad) {
28
+ args.push(wantWrap ? `-Wl,-force_load,${lib}` : lib);
29
+ return;
30
+ }
24
31
  if (wantWrap !== wrapped) {
25
32
  args.push(wantWrap ? '-Wl,--whole-archive' : '-Wl,--no-whole-archive');
26
33
  wrapped = wantWrap;
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ // The pthread bootstrap spawns its workers from _scriptName, which the glue captures from document.currentScript
2
+ // when it loads: undefined for a bridge loaded as a module script, so the workers fetch "/undefined" and a
3
+ // direct-mode mt init hangs. The spawn reads Module.crossbindMainScript first, which the browser adapter sets from
4
+ // paths.worker/paths.js (emscripten's own mainScriptUrlOrBlob trips emsdk 6 debug assertions on st targets).
5
+
6
+ // The release glue spells it `pthreadMainJs=_scriptName`, the debug glue `pthreadMainJs = _scriptName`.
7
+ const SPAWN = /pthreadMainJs\s*=\s*_scriptName/g;
8
+ const MAIN_SCRIPT = 'pthreadMainJs=Module["crossbindMainScript"]||_scriptName';
9
+
10
+ // Returns the rewritten glue and whether it spawns pthreads from a script this rewrite does not recognise.
11
+ export function spawnPthreadsFromMainScript(glue) {
12
+ const text = glue.replace(SPAWN, MAIN_SCRIPT);
13
+ return { text, missed: text.includes('pthreadMainJs') && !text.includes(MAIN_SCRIPT) };
14
+ }
@@ -7,8 +7,11 @@ import { getContentHash } from './hash.js';
7
7
  const table = createRequire(import.meta.url)('../assets/toolchain-digests.json');
8
8
  const REGISTRY = table.registry;
9
9
  const IMAGE_VERSION = table.version;
10
+ const OVERRIDE_KEYS = {
11
+ web: 'CROSSBIND_IMAGE_WEB', android: 'CROSSBIND_IMAGE_ANDROID', linux: 'CROSSBIND_IMAGE_LINUX', windows: 'CROSSBIND_IMAGE_WINDOWS',
12
+ };
10
13
  const IMAGES = Object.fromEntries(
11
- ['web', 'android'].map((role) => [
14
+ Object.keys(OVERRIDE_KEYS).map((role) => [
12
15
  role,
13
16
  {
14
17
  ref: `${REGISTRY}/${role}@${table.images[role].index}`,
@@ -17,11 +20,13 @@ const IMAGES = Object.fromEntries(
17
20
  ]),
18
21
  );
19
22
 
20
- const OVERRIDE_KEYS = { web: 'CROSSBIND_IMAGE_WEB', android: 'CROSSBIND_IMAGE_ANDROID' };
21
-
22
- // Which image a target compiles in: wasm and wasi share the web image, ios never reaches docker.
23
+ // Which image a target compiles in: wasm and wasi share the web image, linux and linuxmusl the linux
24
+ // image, ios and darwin never reach docker.
23
25
  export function imageRoleFor(target) {
24
- return target?.platform === 'android' ? 'android' : 'web';
26
+ if (target?.platform === 'android' || target?.platform === 'linux') return target.platform;
27
+ if (target?.platform === 'linuxmusl') return 'linux';
28
+ if (target?.platform === 'win32') return 'windows';
29
+ return 'web';
25
30
  }
26
31
 
27
32
  const warned = new Set();
@@ -0,0 +1,119 @@
1
+ import fs from 'node:fs';
2
+ import upath from 'upath';
3
+
4
+ // pkg-config files, libtool archives, CMake configs and -config scripts record absolute paths of
5
+ // the machine that built them, so a published prebuilt only resolves there. These rewrites make
6
+ // every path relative to where the tree lands; whatever they cannot rewrite is left for the
7
+ // publish gate to report. Some upstream templates also name libstdc++, which no crossbind
8
+ // toolchain links: every one of them builds against libc++.
9
+
10
+ const PREFIX_VARIABLE = 'crossbind_prefix';
11
+ const ASSIGNMENT = /^(\s*[A-Za-z_][A-Za-z0-9_]*=)(.*)$/;
12
+ const SINGLE_QUOTED = /^'([^']*)'$/;
13
+ const DOUBLE_QUOTED = /^"((?:[^"\\]|\\.)*)"$/;
14
+ const BARE_WORD = /^[^\s'"\\]*$/;
15
+ const EXPANDS_IN_DOUBLE_QUOTES = /[$`"\\]/;
16
+ const PERMISSION_BITS = 0o7777;
17
+ const OWNER_WRITE = 0o200;
18
+ const LIBSTDCXX_FLAG = /(^|[\s"'=])-lstdc\+\+(?=[\s"']|$)/gm;
19
+
20
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+
22
+ const nameLibcxx = (text) => text.replace(LIBSTDCXX_FLAG, '$1-lc++');
23
+
24
+ const replaceEach = (text, needles, replacement) => needles
25
+ .reduce((result, needle) => result.replaceAll(needle, () => replacement), text);
26
+
27
+ // CMake installs programs without the owner write bit, so the rewrite borrows it and gives it back.
28
+ function rewrite(file, before, after) {
29
+ if (before === after) return;
30
+ const mode = fs.statSync(file).mode & PERMISSION_BITS;
31
+ fs.chmodSync(file, mode | OWNER_WRITE);
32
+ fs.writeFileSync(file, after);
33
+ fs.chmodSync(file, mode);
34
+ }
35
+
36
+ function filesUnder(dir) {
37
+ return fs.readdirSync(dir, { recursive: true, withFileTypes: true })
38
+ .filter((entry) => entry.isFile())
39
+ .map((entry) => upath.join(entry.parentPath, entry.name));
40
+ }
41
+
42
+ const isUnder = (path, prefixes) => prefixes.some((prefix) => path.startsWith(prefix));
43
+
44
+ // Another package's files are only known by name once the tree leaves the build machine. Its
45
+ // archive may sit in a lib directory or, from an xcframework, in a slice directory.
46
+ function nameForeignArchives(text, { installPrefixes, buildBases }, spell) {
47
+ return buildBases.reduce((result, base) => result.replace(
48
+ new RegExp(`${escapeRegExp(base)}/[^\\s'";]*?/lib([\\w+.-]+)\\.a`, 'g'),
49
+ (archive, name) => (isUnder(archive, installPrefixes) ? archive : spell(name)),
50
+ ), text);
51
+ }
52
+
53
+ // Search paths into other packages, and object files of the build tree such as the WASI runtime
54
+ // stubs a configure build links through LIBS.
55
+ function dropForeignPaths(text, { installPrefixes, buildBases }) {
56
+ return buildBases.reduce((result, base) => result
57
+ .replace(
58
+ new RegExp(`\\s-[LI]${escapeRegExp(base)}/[^\\s'";]*`, 'g'),
59
+ (flag) => (isUnder(flag.trim().slice(2), installPrefixes) ? flag : ''),
60
+ )
61
+ .replace(
62
+ new RegExp(`\\s${escapeRegExp(base)}/[^\\s'";]*\\.o(?=[\\s'";]|$)`, 'gm'),
63
+ (object) => (isUnder(object.trim(), installPrefixes) ? object : ''),
64
+ ), text);
65
+ }
66
+
67
+ function relocatePkgConfig(file, prefixDir, paths) {
68
+ const toPrefix = upath.relative(upath.dirname(file), prefixDir);
69
+ const before = fs.readFileSync(file, 'utf8');
70
+ const own = replaceEach(before, paths.installPrefixes, `\${pcfiledir}/${toPrefix}`);
71
+ const foreign = nameForeignArchives(dropForeignPaths(own, paths), paths, (name) => `-l${name}`);
72
+ rewrite(file, before, nameLibcxx(foreign));
73
+ }
74
+
75
+ function relocateCMake(file, paths) {
76
+ const before = fs.readFileSync(file, 'utf8');
77
+ rewrite(file, before, nameForeignArchives(before, paths, (name) => name));
78
+ }
79
+
80
+ // The text of an assignment value as it reads between double quotes, or null when its quoting
81
+ // is anything but one plain word.
82
+ function doubleQuotedContent(value) {
83
+ const single = SINGLE_QUOTED.exec(value);
84
+ if (single) return EXPANDS_IN_DOUBLE_QUOTES.test(single[1]) ? null : single[1];
85
+ const double = DOUBLE_QUOTED.exec(value);
86
+ if (double) return double[1];
87
+ return BARE_WORD.test(value) ? value : null;
88
+ }
89
+
90
+ function relocateAssignment(line, installPrefixes) {
91
+ const match = ASSIGNMENT.exec(line);
92
+ if (!match || !installPrefixes.some((prefix) => line.includes(prefix))) return line;
93
+ const content = doubleQuotedContent(match[2]);
94
+ if (content === null) return line;
95
+ return `${match[1]}"${replaceEach(content, installPrefixes, `\${${PREFIX_VARIABLE}}`)}"`;
96
+ }
97
+
98
+ function relocateConfigScript(file, prefixDir, installPrefixes) {
99
+ const before = fs.readFileSync(file, 'utf8');
100
+ const lines = before.split('\n');
101
+ if (!/^#!.*sh/.test(lines[0])) return;
102
+ const relocated = lines.map((line) => relocateAssignment(line, installPrefixes));
103
+ const isPrefixRelocated = relocated.some((line, index) => line !== lines[index]);
104
+ const toPrefix = upath.relative(upath.dirname(file), prefixDir);
105
+ const definition = `${PREFIX_VARIABLE}=$(cd "$(dirname "$0")/${toPrefix}" && pwd)`;
106
+ const after = isPrefixRelocated ? [relocated[0], definition, ...relocated.slice(1)] : relocated;
107
+ rewrite(file, before, nameLibcxx(after.join('\n')));
108
+ }
109
+
110
+ const isConfigScript = (file) => file.endsWith('-config') && upath.basename(upath.dirname(file)) === 'bin';
111
+
112
+ export default function relocatePrebuilt(prefixDir, paths) {
113
+ filesUnder(prefixDir).forEach((file) => {
114
+ if (file.endsWith('.la')) fs.rmSync(file);
115
+ else if (file.endsWith('.pc')) relocatePkgConfig(file, prefixDir, paths);
116
+ else if (file.endsWith('.cmake')) relocateCMake(file, paths);
117
+ else if (isConfigScript(file)) relocateConfigScript(file, prefixDir, paths.installPrefixes);
118
+ });
119
+ }
@@ -0,0 +1,127 @@
1
+ // One toolchain step on the remote runner: send what changed below each mount's input roots, stream the
2
+ // step's output, write the files it produced back through the mounts, exit with its exit code.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { walk, hashIndex, presentUnits } from '../runner/files.js';
7
+ import { isHash } from '../runner/blobs.js';
8
+ import { getContentHash } from './hash.js';
9
+ import { outputTarget, writeOutputFile, removeOutputFile } from './remoteOutputs.js';
10
+ import { request, expectOk, uploadMissing } from './remoteUpload.js';
11
+
12
+ const INDEX_DIR = path.join(os.homedir(), '.crossbind', 'remote-index');
13
+
14
+ const indexFileOf = (mount) => path.join(INDEX_DIR, `${getContentHash(`${mount.host}\n${mount.container}`).slice(0, 16)}.json`);
15
+
16
+ function loadIndex(mount) {
17
+ try {
18
+ return new Map(Object.entries(JSON.parse(fs.readFileSync(indexFileOf(mount), 'utf8'))));
19
+ } catch {
20
+ return new Map();
21
+ }
22
+ }
23
+
24
+ function saveIndex(mount, index) {
25
+ fs.mkdirSync(INDEX_DIR, { recursive: true });
26
+ fs.writeFileSync(indexFileOf(mount), JSON.stringify(Object.fromEntries(index)));
27
+ }
28
+
29
+ function describeMount(mount, rules, index) {
30
+ const { files, dirs } = walk(mount.host, mount.inputRoots, rules);
31
+ const hashes = hashIndex(mount.host, files, index);
32
+ const manifest = Object.fromEntries([...hashes].map(([rel, sha256]) => [rel, { sha256, mode: files.get(rel).mode }]));
33
+ const sources = [...hashes].map(([rel, sha256]) => [sha256, path.join(mount.host, rel)]);
34
+ return { manifest, dirs, sources };
35
+ }
36
+
37
+ function writeOutput(target, entry, data, indexes) {
38
+ if (getContentHash(data) !== entry.sha256) throw new Error(`crossbind: ${target.file} arrived from the remote runner with the wrong content.`);
39
+ writeOutputFile(target, data, entry.mode);
40
+ const { size, mtimeMs } = fs.statSync(target.file);
41
+ indexes.get(target.mount).set(target.rel, { size, mtimeMs, sha256: entry.sha256 });
42
+ }
43
+
44
+ async function runStep(payload, described, present, indexes, received) {
45
+ const body = {
46
+ role: payload.role,
47
+ image: payload.image,
48
+ rules: payload.rules,
49
+ cwd: payload.cwd,
50
+ argv: payload.argv,
51
+ env: payload.env,
52
+ mounts: payload.mounts.map((mount, i) => ({
53
+ path: mount.container,
54
+ roots: mount.inputRoots,
55
+ outputRoots: mount.outputRoots,
56
+ manifest: described[i].manifest,
57
+ dirs: described[i].dirs,
58
+ present: present[i],
59
+ })),
60
+ };
61
+ const response = await request(payload.url, '/v1/exec', { method: 'POST', body: JSON.stringify(body) });
62
+ if (response.status !== 200) await expectOk(response, 'to run the step');
63
+
64
+ const decoder = new TextDecoder();
65
+ let pending = '';
66
+ let result = null;
67
+ const handle = (record) => {
68
+ if ('exit' in record) result = record;
69
+ if (record.stdout) process.stdout.write(record.stdout);
70
+ if (record.stderr) process.stderr.write(record.stderr);
71
+ if (record.file) {
72
+ writeOutput(outputTarget(payload.mounts, present, record.file), record, Buffer.from(record.data, 'base64'), indexes);
73
+ received.add(record.file);
74
+ }
75
+ };
76
+ for await (const chunk of response.body) {
77
+ pending += decoder.decode(chunk, { stream: true });
78
+ let newline = pending.indexOf('\n');
79
+ while (newline !== -1) {
80
+ if (newline > 0) handle(JSON.parse(pending.slice(0, newline)));
81
+ pending = pending.slice(newline + 1);
82
+ newline = pending.indexOf('\n');
83
+ }
84
+ }
85
+ if (!result) throw new Error('crossbind: the remote runner closed the connection before the step finished.');
86
+ if (result.error) throw new Error(`crossbind: the remote runner failed during the step: ${result.error}.`);
87
+ return result;
88
+ }
89
+
90
+ // Outputs too large for the stream are fetched by hash.
91
+ async function writeOutputs(payload, result, present, indexes, received) {
92
+ for (const [file, entry] of Object.entries(result.outputs).filter(([name]) => !received.has(name))) {
93
+ const target = outputTarget(payload.mounts, present, file);
94
+ if (!isHash(entry.sha256)) throw new Error(`crossbind: the remote runner named ${file} without a sha256 digest.`);
95
+ const response = await request(payload.url, `/v1/blobs/${entry.sha256}`);
96
+ if (response.status !== 200) throw new Error(`crossbind: could not download ${file} from the remote runner (${response.status}).`);
97
+ writeOutput(target, entry, Buffer.from(await response.arrayBuffer()), indexes);
98
+ }
99
+ result.removed.forEach((file) => {
100
+ const target = outputTarget(payload.mounts, present, file);
101
+ removeOutputFile(target);
102
+ indexes.get(target.mount).delete(target.rel);
103
+ });
104
+ }
105
+
106
+ async function main() {
107
+ const payload = JSON.parse(process.argv[2]);
108
+ const indexes = new Map(payload.mounts.map((mount) => [mount, loadIndex(mount)]));
109
+ const described = payload.mounts.map((mount) => describeMount(mount, payload.rules, indexes.get(mount)));
110
+ await uploadMissing(payload.url, new Map(described.flatMap((d) => d.sources)));
111
+
112
+ // The store units this machine holds before the step: the runner sends only the others, and may write only those.
113
+ const present = payload.mounts.map((mount) => presentUnits(mount.host, mount.outputRoots));
114
+ const received = new Set();
115
+ const result = await runStep(payload, described, present, indexes, received);
116
+ await writeOutputs(payload, result, present, indexes, received);
117
+ indexes.forEach((index, mount) => saveIndex(mount, index));
118
+ return result.exit;
119
+ }
120
+
121
+ main().then(
122
+ (code) => process.exit(code),
123
+ (error) => {
124
+ process.stderr.write(`${error.message}\n`);
125
+ process.exit(1);
126
+ },
127
+ );