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.
- package/README.md +2 -2
- package/package.json +6 -1
- package/src/actions/buildDependencies.js +15 -2
- package/src/actions/buildExternal.js +9 -1
- package/src/actions/buildLib.js +25 -8
- package/src/actions/buildNativeCommand.js +84 -0
- package/src/actions/buildNode.js +48 -7
- package/src/actions/buildTypes.js +8 -2
- package/src/actions/buildWasiCommand.js +0 -4
- package/src/actions/buildWasm.js +49 -29
- package/src/actions/cloudCommands.js +133 -0
- package/src/actions/createImportedBridges.js +64 -0
- package/src/actions/createInterface.js +247 -68
- package/src/actions/createLib.js +57 -41
- package/src/actions/getData.js +4 -1
- package/src/actions/getLinkInputs.js +12 -5
- package/src/actions/isSourceNewer.js +2 -0
- package/src/actions/licenses.js +143 -19
- package/src/actions/prepareConanDependencies.js +51 -0
- package/src/actions/publishBinary.js +32 -0
- package/src/actions/run.js +63 -66
- package/src/actions/runnerCommands.js +421 -0
- package/src/actions/writeRuntimeEntry.js +69 -0
- package/src/assets/cmake/command/CMakeLists.txt +21 -0
- package/src/assets/cmake/command/command.cpp +1 -0
- package/src/assets/cmake/dist.cmake +2 -1
- package/src/assets/configure/install.sh +22 -0
- package/src/assets/js-runtime/adapters/path-url.js +6 -3
- package/src/assets/js-runtime/adapters/unboundMessage.js +2 -0
- package/src/assets/js-runtime/adapters/worker-comlink.js +87 -60
- package/src/assets/js-runtime/core.js +11 -0
- package/src/assets/node-import-hooks.mjs +61 -0
- package/src/assets/toolchain-digests.json +19 -19
- package/src/assets/wasi-runtime/stubs.c +29 -0
- package/src/bin.js +257 -50
- package/src/index.js +3 -0
- package/src/integration/bindHeaderImports.js +18 -0
- package/src/integration/getCrossbindScript.js +46 -10
- package/src/integration/getDependFilePath.js +9 -0
- package/src/integration/resolveNativeImport.js +14 -0
- package/src/node/dev.mjs +19 -0
- package/src/runner/blobs.js +135 -0
- package/src/runner/files.js +193 -0
- package/src/runner/server.js +320 -0
- package/src/state/attachConanDependencies.js +56 -0
- package/src/state/index.js +17 -9
- package/src/state/loadConfig.js +33 -24
- package/src/state/refreshBuiltDependencies.js +20 -0
- package/src/state/refreshConanDependencies.js +17 -0
- package/src/utils/androidToolchain.js +4 -0
- package/src/utils/appRustCrates.js +15 -1
- package/src/utils/appSources.js +97 -6
- package/src/utils/archiveFlags.js +12 -0
- package/src/utils/boundHeaders.js +79 -0
- package/src/utils/bridgeExtras.js +486 -0
- package/src/utils/bridgeRuntimeFixes.js +48 -0
- package/src/utils/bundledLicenses.js +26 -0
- package/src/utils/cargoTarget.js +21 -3
- package/src/utils/cloudAccount.js +55 -0
- package/src/utils/conanDependencies.js +66 -0
- package/src/utils/conanImport.js +58 -0
- package/src/utils/conanInstall.js +154 -0
- package/src/utils/conanProfile.js +203 -0
- package/src/utils/conanStage.js +292 -0
- package/src/utils/copyToDistSource.js +13 -0
- package/src/utils/cppDts.js +58 -15
- package/src/utils/darwinToolchain.js +11 -0
- package/src/utils/dirLock.js +137 -28
- package/src/utils/downloadAndExtractFile.js +34 -7
- package/src/utils/embindArgumentGuards.js +41 -2
- package/src/utils/embindCallArguments.js +19 -0
- package/src/utils/embindJspiQueue.js +25 -0
- package/src/utils/execContainer.js +49 -0
- package/src/utils/headerImports.js +16 -5
- package/src/utils/hostPlatform.js +21 -0
- package/src/utils/iosToolchain.js +6 -0
- package/src/utils/isSourceCmakePackage.js +10 -0
- package/src/utils/licenseReport.js +11 -2
- package/src/utils/linuxToolchain.js +24 -0
- package/src/utils/makeTreeWritable.js +12 -0
- package/src/utils/nativeImports.js +23 -0
- package/src/utils/nativeSourceStamp.js +36 -0
- package/src/utils/nodeDevBuild.js +92 -0
- package/src/utils/packageLicense.js +101 -0
- package/src/utils/pthreadMainScript.js +14 -0
- package/src/utils/pullDockerImage.js +3 -1
- package/src/utils/relocatePrebuilt.js +119 -0
- package/src/utils/remoteClient.js +127 -0
- package/src/utils/remoteOutputs.js +39 -0
- package/src/utils/remoteRunner.js +97 -0
- package/src/utils/remoteUpload.js +134 -0
- package/src/utils/replaceFile.js +8 -0
- package/src/utils/runCargo.js +48 -51
- package/src/utils/runConan.js +257 -0
- package/src/utils/runtimeEntries.js +56 -0
- package/src/utils/rustBridgeGen.js +9 -4
- package/src/utils/rustMt.js +4 -0
- package/src/utils/scopedEmbind.js +22 -0
- package/src/utils/selectRunner.js +81 -0
- package/src/utils/sourceFingerprint.js +4 -3
- package/src/utils/swigInterface.js +69 -4
- package/src/utils/systemKeys.js +35 -3
- package/src/utils/targets.js +49 -4
- package/src/utils/toolchainNotices.js +5 -0
- package/src/utils/wasiToolchain.js +14 -1
- package/src/utils/windowsToolchain.js +18 -0
- package/vitest.config.js +11 -0
|
@@ -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
|
+
}
|
|
@@ -20,9 +20,11 @@ const IMAGES = Object.fromEntries(
|
|
|
20
20
|
]),
|
|
21
21
|
);
|
|
22
22
|
|
|
23
|
-
// Which image a target compiles in: wasm and wasi share the web image,
|
|
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.
|
|
24
25
|
export function imageRoleFor(target) {
|
|
25
26
|
if (target?.platform === 'android' || target?.platform === 'linux') return target.platform;
|
|
27
|
+
if (target?.platform === 'linuxmusl') return 'linux';
|
|
26
28
|
if (target?.platform === 'win32') return 'windows';
|
|
27
29
|
return 'web';
|
|
28
30
|
}
|
|
@@ -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
|
+
);
|
|
@@ -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
|
+
}
|