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
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { hostPathOf, isDeclaredOutput, modeOf } from '../runner/files.js';
4
+
5
+ // Where a file the runner hands back may land: below an output root the step declared, and in a store
6
+ // unit only when this machine does not hold it yet. `present` lists those units per mount.
7
+ export function outputTarget(mounts, present, containerPath) {
8
+ const target = hostPathOf(mounts, containerPath);
9
+ if (!isDeclaredOutput(target.rel, target.mount.outputRoots, present[mounts.indexOf(target.mount)])) {
10
+ throw new Error(`crossbind: the remote runner returned ${containerPath}, which is not an output of this step.`);
11
+ }
12
+ return target;
13
+ }
14
+
15
+ // A link inside the project must not carry a write or a delete out of it.
16
+ function assertStaysInside(target) {
17
+ fs.mkdirSync(target.mount.host, { recursive: true });
18
+ const root = fs.realpathSync(target.mount.host);
19
+ let dir = path.dirname(target.file);
20
+ while (!fs.existsSync(dir)) dir = path.dirname(dir);
21
+ const real = fs.realpathSync(dir);
22
+ if (real !== root && !real.startsWith(`${root}${path.sep}`)) {
23
+ throw new Error(`crossbind: ${target.file} leads out of ${target.mount.host} through a link, so the remote runner's output stays unwritten.`);
24
+ }
25
+ }
26
+
27
+ export function writeOutputFile(target, data, mode) {
28
+ assertStaysInside(target);
29
+ fs.mkdirSync(path.dirname(target.file), { recursive: true });
30
+ // A link at the output's own path is replaced, never written through.
31
+ if (fs.lstatSync(target.file, { throwIfNoEntry: false })?.isSymbolicLink()) fs.rmSync(target.file);
32
+ fs.writeFileSync(target.file, data);
33
+ fs.chmodSync(target.file, modeOf(mode));
34
+ }
35
+
36
+ export function removeOutputFile(target) {
37
+ assertStaysInside(target);
38
+ fs.rmSync(target.file, { force: true });
39
+ }
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { DOCKER_BASE } from './replaceBasePathForDocker.js';
5
+ import { remoteVariables } from './selectRunner.js';
6
+
7
+ const CLIENT = fileURLToPath(new URL('./remoteClient.js', import.meta.url));
8
+
9
+ // Only native inputs travel to the runner; the application's JavaScript and credential files stay on the
10
+ // machine. A cargo target folder stays on the runner, and only the static libraries crossbind links come back from it.
11
+ export const REMOTE_EXCLUDE_RULES = Object.freeze({
12
+ dirs: Object.freeze(['node_modules', '.git']),
13
+ files: Object.freeze(['.env', '.env.*', '.npmrc', '.yarnrc', '.yarnrc.yml', '.netrc']),
14
+ extensions: Object.freeze(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx']),
15
+ serverOnly: Object.freeze({ names: Object.freeze(['target', 'target-mt']), marker: 'Cargo.toml', keep: Object.freeze(['*/release/*.a']) }),
16
+ });
17
+
18
+ const TOKEN_VARIABLE = 'CROSSBIND_TOKEN';
19
+
20
+ const LOOPBACK = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/;
21
+ const warnedUrls = new Set();
22
+
23
+ // Plain http to another machine carries the token and the sources unencrypted.
24
+ function warnIfPlainHttp(url) {
25
+ const { protocol, hostname } = new URL(url);
26
+ if (protocol !== 'http:' || LOOPBACK.test(hostname) || warnedUrls.has(url)) return;
27
+ warnedUrls.add(url);
28
+ console.warn(`crossbind: ${url} is plain http to another machine - the token and your sources travel unencrypted; put TLS in front of the runner.`);
29
+ }
30
+
31
+ const isInside = (rel, root) => root === '.' || rel === root || rel.startsWith(`${root}/`);
32
+
33
+ // Null for a folder outside the base: a local docker run mounts only the base, so it never sees one either.
34
+ function relativeToBase(base, folder) {
35
+ const rel = path.relative(base, folder);
36
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
37
+ return rel.split(path.sep).join('/') || '.';
38
+ }
39
+
40
+ function foldNested(roots) {
41
+ const unique = [...new Set(roots)].sort((a, b) => a.length - b.length);
42
+ return unique.filter((root, i) => !unique.slice(0, i).some((outer) => isInside(root, outer)));
43
+ }
44
+
45
+ // A file the command line names may include its neighbours (`#include "../include/x.h"`), so unless a
46
+ // configured folder holds it, the package that holds it travels: the nearest folder below the base with a
47
+ // package.json. Its JavaScript stays behind, as everywhere else.
48
+ function packageOf(base, configured, file) {
49
+ if (configured.some((root) => isInside(relativeToBase(base, file), root))) return file;
50
+ for (let dir = path.dirname(file); ![null, '.'].includes(relativeToBase(base, dir)); dir = path.dirname(dir)) {
51
+ if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
52
+ }
53
+ return file;
54
+ }
55
+
56
+ // What a toolchain step may read (inputRoots) and where it writes (outputRoots), relative to paths.base,
57
+ // the folder a local docker run mounts. `extraInputs` are only read (a file the link names);
58
+ // `extraOutputs` are read and written, like a crate's folder and its Cargo.lock.
59
+ export function remoteRoots(config, { extraInputs = [], extraOutputs = [] } = {}) {
60
+ const { paths, allDependencies = [] } = config;
61
+ const relOf = (folder) => relativeToBase(paths.base, folder);
62
+ const isBelowBase = (folder) => Boolean(folder) && relOf(folder) !== null;
63
+ const dependencyFolders = allDependencies.flatMap((d) => [d.paths.output, ...(d.paths.native ?? []), ...(d.paths.header ?? [])]);
64
+ const configured = [...paths.native, ...paths.header, paths.cache, paths.output, `${paths.cli}/assets`, ...dependencyFolders].filter(isBelowBase).map(relOf);
65
+ const outputs = [paths.cache, paths.output, ...extraOutputs].filter(isBelowBase).map(relOf);
66
+ return {
67
+ inputRoots: foldNested([...configured, ...extraInputs.filter(isBelowBase).map((file) => relOf(packageOf(paths.base, configured, file))), ...outputs]),
68
+ outputRoots: foldNested(outputs),
69
+ };
70
+ }
71
+
72
+ export function baseMount(config, extra = {}) {
73
+ return { host: config.paths.base, container: DOCKER_BASE, ...remoteRoots(config, extra) };
74
+ }
75
+
76
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
77
+
78
+ // Paths a command line names below the base (a source the link adds, an include folder, a preload file)
79
+ // travel even when no configured folder holds them. The base itself is never one: it would send everything.
80
+ export function referencedPaths(base, values) {
81
+ const pattern = new RegExp(`${escapeRegExp(base)}(?:/[^@=;:,'"\\s]*)?`, 'g');
82
+ const found = values.flatMap((value) => String(value).match(pattern) ?? []);
83
+ return [...new Set(found)].filter((candidate) => candidate !== base && fs.existsSync(candidate));
84
+ }
85
+
86
+ // Arguments for execFileSync/spawnSync: the client runs as its own process, so the caller stays
87
+ // synchronous as it is with docker. The token reaches it through its environment, never its command line.
88
+ export function remoteExecParams({ remote, role, image, mounts, cwd, argv, env }, options) {
89
+ if (!remote) {
90
+ throw new Error(`crossbind: RUNNER=REMOTE, but the ${role} image has no runner address - set ${remoteVariables(role).key} (or REMOTE_URL), or build with CROSSBIND_RUNNER=DOCKER_RUN. A machine that names no runner address builds on crossbind cloud after crossbind login.`);
91
+ }
92
+ const { url, token, tokenVariable } = remote;
93
+ if (!token) throw new Error(`crossbind: the ${role} runner at ${url} needs a token - set ${tokenVariable}.`);
94
+ warnIfPlainHttp(url);
95
+ const payload = { url, role, image, mounts, cwd, argv, env, rules: REMOTE_EXCLUDE_RULES };
96
+ return [process.execPath, [CLIENT, JSON.stringify(payload)], { ...options, env: { ...process.env, [TOKEN_VARIABLE]: token } }];
97
+ }
@@ -0,0 +1,134 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Raw bytes per upload request, a batch of small files or a part of a large one. With base64 a batch stays below the
5
+ // 32 MiB a Cloud Run request may carry, and a part below Cloudflare's 100 MB as well.
6
+ const UPLOAD_BATCH_BYTES = 16 * 1024 * 1024;
7
+ // The first runner protocol that joins a file sent in parts.
8
+ const PARTS_PROTOCOL = 2;
9
+ const MB = 1024 * 1024;
10
+ // A long upload to a hosted runner now and then loses its connection: a request goes again this many times, waiting a
11
+ // second longer each time.
12
+ const UPLOAD_RETRIES = 4;
13
+ const RETRY_DELAY_MS = 1000;
14
+ // Failures before a connection opened, such as a name lookup that failed for a moment: the request never reached the
15
+ // runner, so it goes again this many times. After a connection opened it never does, since a step may have started.
16
+ const UNSENT_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ENETUNREACH', 'EHOSTUNREACH', 'UND_ERR_CONNECT_TIMEOUT']);
17
+ const UNSENT_RETRIES = 2;
18
+
19
+ const wait = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
20
+
21
+ export async function request(url, route, init = {}) {
22
+ const token = process.env.CROSSBIND_TOKEN;
23
+ const headers = { ...(token ? { authorization: `Bearer ${token}` } : {}), ...init.headers };
24
+ for (let attempt = 1; ; attempt += 1) {
25
+ try {
26
+ // A redirect would send the request, sources included, to an address nobody configured.
27
+ return await fetch(`${url}${route}`, { ...init, headers, redirect: 'error' });
28
+ } catch (error) {
29
+ if (!UNSENT_CODES.has(error.cause?.code) || attempt > UNSENT_RETRIES) {
30
+ throw new Error(`crossbind: the remote runner at ${url} is unreachable (${error.cause?.code ?? error.message}).`, { cause: error });
31
+ }
32
+ await wait(RETRY_DELAY_MS * attempt);
33
+ }
34
+ }
35
+ }
36
+
37
+ export async function expectOk(response, what) {
38
+ if (response.ok) return response.json();
39
+ const body = await response.json().catch(() => ({}));
40
+ throw new Error(`crossbind: the remote runner failed ${what}: ${body.error ?? response.status}.`);
41
+ }
42
+
43
+ async function requestWithRetries(url, route, init) {
44
+ for (let attempt = 1; ; attempt += 1) {
45
+ const response = await request(url, route, init).catch((error) => error);
46
+ const failed = response instanceof Error || response.status >= 500;
47
+ if (!failed || attempt > UPLOAD_RETRIES) {
48
+ if (response instanceof Error) throw response;
49
+ return response;
50
+ }
51
+ await wait(RETRY_DELAY_MS * attempt);
52
+ }
53
+ }
54
+
55
+ async function runnerProtocol(url) {
56
+ const health = await expectOk(await request(url, '/v1/health'), 'to answer its health check');
57
+ return health.protocol ?? 1;
58
+ }
59
+
60
+ // One request per part, in order; the runner checks the joined file against its hash. After a part whose connection
61
+ // dropped, the upload goes on from where the runner says it stands. The upload is named after the file, so a later
62
+ // attempt, or another client sending the same file, goes on with what the runner holds.
63
+ async function uploadInParts(url, hash, file, total, partBytes) {
64
+ const upload = hash.slice(0, 32);
65
+ const fd = fs.openSync(file, 'r');
66
+ let resumes = 0;
67
+ try {
68
+ for (let start = 0; start < total;) {
69
+ const part = Buffer.alloc(Math.min(partBytes, total - start));
70
+ fs.readSync(fd, part, 0, part.length, start);
71
+ const response = await requestWithRetries(url, `/v1/blobs/${hash}`, {
72
+ method: 'PUT',
73
+ body: part,
74
+ headers: { 'content-range': `bytes ${start}-${start + part.length - 1}/${total}`, 'x-crossbind-upload': upload },
75
+ });
76
+ if (response.status === 413) {
77
+ throw new Error(`crossbind: the remote runner at ${url} took no part of ${path.basename(file)}: it refused a part of ${part.length} bytes, so the proxy in front of it takes less in one request.`);
78
+ }
79
+ if (response.status === 409 && resumes < UPLOAD_RETRIES) {
80
+ const { received } = await response.json();
81
+ if (!Number.isSafeInteger(received) || received < 0 || received >= total) throw new Error(`crossbind: the remote runner holds ${received} bytes of ${path.basename(file)}, which has ${total}.`);
82
+ resumes += 1;
83
+ start = received;
84
+ continue;
85
+ }
86
+ const answer = await expectOk(response, `to receive ${path.basename(file)}`);
87
+ if (response.status === 201) return;
88
+ start = answer.received;
89
+ }
90
+ } finally {
91
+ fs.closeSync(fd);
92
+ }
93
+ }
94
+
95
+ // A runner deployed before uploads in parts takes a large file in one request, which a body limit in front of it refuses.
96
+ async function uploadWhole(url, hash, file, total) {
97
+ const response = await request(url, `/v1/blobs/${hash}`, { method: 'PUT', body: fs.readFileSync(file) });
98
+ if (response.status === 413) {
99
+ throw new Error(`crossbind: the remote runner at ${url} refused ${path.basename(file)} (${Math.ceil(total / MB)} MB) as too large for one request; deploy the runner again with this crossbind version, which sends large files in parts.`);
100
+ }
101
+ await expectOk(response, `to receive ${path.basename(file)}`);
102
+ }
103
+
104
+ // `sources` maps each hash to a file holding it; only what the runner lacks travels, small files in a few batches and a
105
+ // large one on its own.
106
+ export async function uploadMissing(url, sources, partBytes = UPLOAD_BATCH_BYTES) {
107
+ const listing = await request(url, '/v1/missing', { method: 'POST', body: JSON.stringify({ hashes: [...sources.keys()] }) });
108
+ const { missing } = await expectOk(listing, 'to list the files it lacks');
109
+ let protocol;
110
+ let batch = [];
111
+ let size = 0;
112
+ const flush = async () => {
113
+ if (batch.length === 0) return;
114
+ const body = batch.map(({ hash, data }) => JSON.stringify({ sha256: hash, data: data.toString('base64') })).join('\n');
115
+ await expectOk(await requestWithRetries(url, '/v1/blobs', { method: 'POST', body }), `to receive ${batch.length} files`);
116
+ batch = [];
117
+ size = 0;
118
+ };
119
+ for (const hash of missing) {
120
+ const file = sources.get(hash);
121
+ const fileSize = fs.statSync(file).size;
122
+ if (fileSize > partBytes) {
123
+ protocol ??= await runnerProtocol(url);
124
+ if (protocol >= PARTS_PROTOCOL) await uploadInParts(url, hash, file, fileSize, partBytes);
125
+ else await uploadWhole(url, hash, file, fileSize);
126
+ continue;
127
+ }
128
+ const data = fs.readFileSync(file);
129
+ if (size > 0 && size + data.length > partBytes) await flush();
130
+ batch.push({ hash, data });
131
+ size += data.length;
132
+ }
133
+ await flush();
134
+ }
@@ -0,0 +1,8 @@
1
+ import fs from 'node:fs';
2
+
3
+ // macOS keeps the code signature of a loaded binary per inode: a rebuilt addon or command copied over
4
+ // one that already ran gets the next process that loads it killed. A new file gets a new inode.
5
+ export default function replaceFile(source, destination) {
6
+ fs.rmSync(destination, { force: true });
7
+ fs.copyFileSync(source, destination);
8
+ }
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { fileURLToPath } from 'node:url';
5
+ import state from '../state/index.js';
6
+
7
+ // The binding runtimes ship as their own packages and the ENGINE never depends on them: the
8
+ // consumer declares one - directly, or transitively through a platform plugin - and the engine
9
+ // only resolves it. pnpm keeps transitive deps unhoisted, so resolution may need to hop through
10
+ // the declaring plugin; inside this monorepo the package sits next to the engine under core/.
11
+ export default function createCompanionResolver({
12
+ pkg, anchors = [], siblingDir, siblingMarker, missingMessage,
13
+ }) {
14
+ let cached = null;
15
+
16
+ const resolveFrom = (baseDir) => {
17
+ try {
18
+ const req = createRequire(path.join(baseDir, 'package.json'));
19
+ return path.dirname(fs.realpathSync(req.resolve(`${pkg}/package.json`)));
20
+ } catch (e) {
21
+ return null;
22
+ }
23
+ };
24
+
25
+ return function resolveCompanionRoot() {
26
+ if (cached) return cached;
27
+
28
+ const project = state.config?.paths?.project;
29
+ let root = project ? resolveFrom(project) : null;
30
+ if (!root && project) {
31
+ for (const anchor of anchors) {
32
+ try {
33
+ const req = createRequire(path.join(project, 'package.json'));
34
+ const anchorDir = path.dirname(fs.realpathSync(req.resolve(`${anchor}/package.json`)));
35
+ root = resolveFrom(anchorDir);
36
+ if (root) break;
37
+ } catch (e) { /* this plugin is not installed - try the next anchor */ }
38
+ }
39
+ }
40
+ if (!root) {
41
+ const here = path.dirname(fileURLToPath(import.meta.url));
42
+ const sibling = path.resolve(here, '../../..', siblingDir);
43
+ if (fs.existsSync(`${sibling}/${siblingMarker}`)) root = sibling;
44
+ }
45
+ if (!root) {
46
+ throw new Error(missingMessage);
47
+ }
48
+ cached = root;
49
+ return root;
50
+ };
51
+ }
@@ -0,0 +1,22 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import createCompanionResolver from './resolveCompanionPackage.js';
5
+
6
+ const PKG = '@crossbind/core-embind-napi';
7
+
8
+ const resolveEmbindNapiRoot = createCompanionResolver({
9
+ pkg: PKG,
10
+ siblingDir: 'embind-napi',
11
+ siblingMarker: 'cpp/src/node_api_module.cpp',
12
+ missingMessage: `crossbind: native Node.js builds need ${PKG} - add it to your devDependencies.`,
13
+ });
14
+
15
+ export default resolveEmbindNapiRoot;
16
+
17
+ // The embind runtime itself ships in @crossbind/core-embind-jsi, a dependency of the Node-API
18
+ // package, so it resolves from there.
19
+ export function resolveEmbindJsiRoot() {
20
+ const req = createRequire(path.join(resolveEmbindNapiRoot(), 'package.json'));
21
+ return path.dirname(fs.realpathSync(req.resolve('@crossbind/core-embind-jsi/package.json')));
22
+ }
@@ -1,60 +1,25 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
- import { fileURLToPath } from 'node:url';
5
- import state from '../state/index.js';
2
+ import createCompanionResolver from './resolveCompanionPackage.js';
6
3
 
7
- // The Rust producer crate + adapters ship in @crossbind/core-embind-rust. The ENGINE never
8
- // depends on it (same direction rule as core-embind-jsi): the consumer declares it - directly,
9
- // or transitively through a platform plugin - and the engine only resolves it. pnpm keeps
10
- // transitive deps unhoisted, so resolution may need to hop through the declaring plugin.
4
+ // The Rust producer crate + adapters ship in @crossbind/core-embind-rust (same direction rule as
5
+ // core-embind-jsi: the consumer declares it, the engine only resolves it).
11
6
  const PKG = '@crossbind/core-embind-rust';
12
- const PLUGIN_ANCHORS = [
13
- '@crossbind/plugin-react-native',
14
- '@crossbind/plugin-vite',
15
- '@crossbind/plugin-rollup',
16
- '@crossbind/plugin-metro',
17
- '@crossbind/plugin-webpack',
18
- ];
19
7
 
20
- let cached = null;
8
+ const resolveEmbindRustRoot = createCompanionResolver({
9
+ pkg: PKG,
10
+ anchors: [
11
+ '@crossbind/plugin-react-native',
12
+ '@crossbind/plugin-vite',
13
+ '@crossbind/plugin-rollup',
14
+ '@crossbind/plugin-metro',
15
+ '@crossbind/plugin-webpack',
16
+ ],
17
+ siblingDir: 'embind-rust',
18
+ siblingMarker: 'crate/Cargo.toml',
19
+ missingMessage: `crossbind: Rust bindings need ${PKG} - add it to your (dev)dependencies.`,
20
+ });
21
21
 
22
- export default function resolveEmbindRustRoot() {
23
- if (cached) return cached;
24
-
25
- const resolveFrom = (baseDir) => {
26
- try {
27
- const req = createRequire(path.join(baseDir, 'package.json'));
28
- return path.dirname(fs.realpathSync(req.resolve(`${PKG}/package.json`)));
29
- } catch (e) {
30
- return null;
31
- }
32
- };
33
-
34
- const project = state.config?.paths?.project;
35
- let root = project ? resolveFrom(project) : null;
36
- if (!root && project) {
37
- for (const anchor of PLUGIN_ANCHORS) {
38
- try {
39
- const req = createRequire(path.join(project, 'package.json'));
40
- const anchorDir = path.dirname(fs.realpathSync(req.resolve(`${anchor}/package.json`)));
41
- root = resolveFrom(anchorDir);
42
- if (root) break;
43
- } catch (e) { /* this plugin is not installed - try the next anchor */ }
44
- }
45
- }
46
- if (!root) {
47
- // Monorepo layout: the package ships next to the engine.
48
- const here = path.dirname(fileURLToPath(import.meta.url));
49
- const sibling = path.resolve(here, '../../../embind-rust');
50
- if (fs.existsSync(`${sibling}/crate/Cargo.toml`)) root = sibling;
51
- }
52
- if (!root) {
53
- throw new Error(`crossbind: Rust bindings need ${PKG} - add it to your (dev)dependencies.`);
54
- }
55
- cached = root;
56
- return root;
57
- }
22
+ export default resolveEmbindRustRoot;
58
23
 
59
24
  // The package version, stamped into generated bridge manifests for debuggability.
60
25
  export function embindRustVersion() {
@@ -1,4 +1,4 @@
1
- import { spawnSync, execFileSync } from 'node:child_process';
1
+ import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
@@ -7,6 +7,10 @@ import getOsUserAndGroupId from './getOsUserAndGroupId.js';
7
7
  import replaceBasePathForDockerUtil, { DOCKER_BASE } from './replaceBasePathForDocker.js';
8
8
  import pullDockerImage, { getDockerImage, getDockerContainerName, imageRoleFor } from './pullDockerImage.js';
9
9
  import { DOCKER_RUN_SECURITY_ARGS } from './dockerSecurity.js';
10
+ import { HOST_BUILT_PLATFORMS } from './targets.js';
11
+ import assertExecContainer from './execContainer.js';
12
+ import { remoteExecParams, baseMount } from './remoteRunner.js';
13
+ import { runnerFor } from './selectRunner.js';
10
14
 
11
15
  // Every cargo invocation crossbind makes goes through here.
12
16
  //
@@ -59,6 +63,26 @@ const CONFIG_NAMES = ['config.toml', 'config'];
59
63
  // cargo's upward config search finds nothing.
60
64
  const CONTAINER_CARGO_HOME = '/var/cache/crossbind/cargo';
61
65
  const CONTAINER_CWD = '/tmp/crossbind-cargo';
66
+ // What the host reads out of the registry a remote runner keeps: crate sources, for bridge generation, one
67
+ // folder per crate version or git revision.
68
+ const REMOTE_CARGO_HOME_OUTPUTS = ['registry/src/*/*', 'git/checkouts/*/*'];
69
+
70
+ // The crate a cargo call names travels whole, with every crate its `path =` dependencies reach (embind-rs,
71
+ // the generated bridges); their target folders stay on a remote runner. A `path` naming a file ([lib]
72
+ // path) holds no Cargo.toml and is skipped.
73
+ function crateFolders(args) {
74
+ const at = args.indexOf('--manifest-path');
75
+ if (at === -1) return [];
76
+ const folders = new Set();
77
+ const visit = (dir) => {
78
+ const manifest = path.join(dir, 'Cargo.toml');
79
+ if (folders.has(dir) || !fs.existsSync(manifest)) return;
80
+ folders.add(dir);
81
+ for (const [, rel] of fs.readFileSync(manifest, 'utf8').matchAll(/\bpath\s*=\s*"([^"]+)"/g)) visit(path.resolve(dir, rel));
82
+ };
83
+ visit(path.dirname(path.resolve(args[at + 1])));
84
+ return [...folders];
85
+ }
62
86
 
63
87
  // Crossbind-owned and machine-wide: $CARGO_HOME/config.toml is one of the discovery legs, so the
64
88
  // registry cache lives somewhere we can assert is clean instead of in the user's ~/.cargo. It is
@@ -107,12 +131,11 @@ export function assertCleanConfigChain(home, cwd) {
107
131
  }
108
132
  }
109
133
 
110
- // iOS links with Xcode, which is in no image, so its Rust stays on the host - as does every build
111
- // under RUNNER=LOCAL.
134
+ // Apple targets link with Xcode, which is in no image, so their Rust stays on the host - as does
135
+ // every build under RUNNER=LOCAL.
112
136
  export function cargoRunner(target) {
113
- if (target?.platform === 'ios') return 'LOCAL';
114
- const runner = state.config?.system?.RUNNER;
115
- return runner === 'DOCKER_RUN' || runner === 'DOCKER_EXEC' ? runner : 'LOCAL';
137
+ const { runner } = runnerFor(imageRoleFor(target), state.config?.system);
138
+ return HOST_BUILT_PLATFORMS.includes(target?.platform) ? 'LOCAL' : runner;
116
139
  }
117
140
 
118
141
  function allowedEnv() {
@@ -153,52 +176,6 @@ function createHint(name, base, home, image) {
153
176
  ].join('\n');
154
177
  }
155
178
 
156
- function samePath(a, b) {
157
- const real = (p) => {
158
- try {
159
- return fs.realpathSync(p);
160
- } catch {
161
- return path.resolve(p);
162
- }
163
- };
164
- return real(a) === real(b);
165
- }
166
-
167
- function assertExecContainer(name, base, home, image) {
168
- const hint = createHint(name, base, home, image);
169
- let info;
170
- try {
171
- // stderr is dropped: docker's own "No such container" would land before the message below.
172
- info = JSON.parse(execFileSync('docker', ['container', 'inspect', name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }))[0];
173
- } catch {
174
- throw new Error(`crossbind: RUNNER=DOCKER_EXEC needs a container named '${name}', which does not exist.\n${hint}`);
175
- }
176
- if (!info?.State?.Running) {
177
- throw new Error(
178
- `crossbind: the container '${name}' exists but is not running. Start it with \`docker start ${name}\`, or recreate it.\n${hint}`,
179
- );
180
- }
181
- for (const [destination, source] of [
182
- [DOCKER_BASE, base],
183
- [CONTAINER_CARGO_HOME, home],
184
- ]) {
185
- const mount = (info.Mounts ?? []).find((m) => m.Destination === destination);
186
- if (!mount || !samePath(mount.Source, source)) {
187
- const found = mount ? ` - it mounts ${mount.Source} there` : '';
188
- throw new Error(
189
- `crossbind: the container '${name}' does not mount ${source} at ${destination}${found}. It predates the cargo mount, so recreate it.\n${hint}`,
190
- );
191
- }
192
- }
193
- // `docker run --workdir` creates the directory; `docker exec --workdir` does not, and cargo
194
- // dies with an OCI "chdir to cwd" error that names nothing the user can act on.
195
- try {
196
- execFileSync('docker', ['exec', '--user', getOsUserAndGroupId(), name, 'mkdir', '-p', CONTAINER_CWD], { stdio: 'ignore' });
197
- } catch (e) {
198
- throw new Error(`crossbind: could not create the working directory ${CONTAINER_CWD} inside '${name}'.\n${hint}`, { cause: e });
199
- }
200
- }
201
-
202
179
  export default function runCargo(args, { cwd, rustflags = [], panic, capture = false, maxBuffer, allowUnstable = false, target } = {}) {
203
180
  const home = cargoHome();
204
181
  const workdir = cwd ?? neutralCwd();
@@ -232,12 +209,33 @@ export default function runCargo(args, { cwd, rustflags = [], panic, capture = f
232
209
  panic,
233
210
  allowUnstable,
234
211
  });
212
+ if (runner === 'REMOTE') {
213
+ return spawnSync(...remoteExecParams({
214
+ remote: runnerFor(role, state.config.system).remote,
215
+ role,
216
+ image: getDockerImage(role, platform),
217
+ mounts: [
218
+ baseMount(state.config, { extraOutputs: crateFolders(args) }),
219
+ { host: home, container: CONTAINER_CARGO_HOME, inputRoots: [], outputRoots: REMOTE_CARGO_HOME_OUTPUTS },
220
+ ],
221
+ cwd: CONTAINER_CWD,
222
+ argv: ['cargo', ...replaceBasePathForDockerUtil(args, base)],
223
+ env,
224
+ }, options));
225
+ }
226
+
235
227
  const envArgs = Object.entries(env).flatMap(([key, value]) => ['-e', `${key}=${value}`]);
236
228
 
237
229
  let runnerArgs;
238
230
  if (runner === 'DOCKER_EXEC') {
239
231
  const name = getDockerContainerName(base, role);
240
- assertExecContainer(name, base, home, getDockerImage(role, platform));
232
+ assertExecContainer({
233
+ name,
234
+ mounts: [[DOCKER_BASE, base], [CONTAINER_CARGO_HOME, home]],
235
+ workdir: CONTAINER_CWD,
236
+ hint: createHint(name, base, home, getDockerImage(role, platform)),
237
+ mountNote: 'It predates the cargo mount, so recreate it.',
238
+ });
241
239
  runnerArgs = ['exec', ...envArgs, '--user', getOsUserAndGroupId(), '--workdir', CONTAINER_CWD, name];
242
240
  } else {
243
241
  pullDockerImage(role, platform);