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,320 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import readline from 'node:readline';
7
+ import { spawn } from 'node:child_process';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { createBlobStore, isHash, isUploadId } from './blobs.js';
10
+ import {
11
+ snapshot, hashIndex, planSync, applySync, diffSnapshots, expandRoots, sha256Of,
12
+ } from './files.js';
13
+
14
+ // The runner side of `RUNNER=REMOTE`: it runs inside a crossbind toolchain image and executes the
15
+ // toolchain steps a client would otherwise `docker run`, against folders it keeps in sync with the client.
16
+
17
+ // 2: a blob too large for one request arrives in parts.
18
+ export const PROTOCOL_VERSION = 2;
19
+ export const MIN_TOKEN_LENGTH = 16;
20
+ const MAX_JSON_BYTES = 16 * 1024 * 1024;
21
+ const INLINE_OUTPUT_BYTES = 8 * 1024 * 1024;
22
+ const MAX_MOUNTS = 8;
23
+ const DEFAULT_PORT = 8787;
24
+ const CONTENT_RANGE = /^bytes (\d+)-(\d+)\/(\d+)$/;
25
+ // A proxy in front of a hosted runner (fly, cloudflare) closes a response that stays quiet, and a compile
26
+ // step can print nothing for minutes.
27
+ const HEARTBEAT_MS = 15_000;
28
+ // Where crossbind mounts things into its images: the project base, and the cargo and conan caches.
29
+ const DEFAULT_MOUNT_PREFIXES = ['/tmp/crossbind/live', '/var/cache/crossbind'];
30
+ // cargo runs from a directory outside every mount so its upward config search finds nothing.
31
+ const DEFAULT_SCRATCH_DIRS = ['/tmp/crossbind-cargo'];
32
+
33
+ const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
34
+ const isSafeRel = (rel) => typeof rel === 'string' && rel.length > 0 && !path.posix.isAbsolute(rel)
35
+ && path.posix.normalize(rel) === rel && !rel.split('/').includes('..');
36
+ const isCleanAbsolute = (value) => typeof value === 'string' && path.posix.isAbsolute(value)
37
+ && path.posix.normalize(value) === value && (value === '/' || !value.endsWith('/'));
38
+ const isWithin = (child, parent) => child === parent || child.startsWith(`${parent}/`);
39
+ const isUnderRoot = (rel, root) => root === '.' || isWithin(rel, root);
40
+ const isPlainName = (value) => typeof value === 'string' && /^[\w.-]+$/.test(value) && value !== '.' && value !== '..';
41
+ const isNamePattern = (value) => isPlainName(typeof value === 'string' && value.endsWith('*') ? value.slice(0, -1) : value);
42
+ // A build and a runner name one toolchain by digest even when they pull it from different registries (a mirror).
43
+ const digestOf = (ref) => (typeof ref === 'string' && ref.includes('@') ? ref.slice(ref.indexOf('@') + 1) : ref);
44
+
45
+ class RequestError extends Error {
46
+ constructor(status, message, extra = {}) {
47
+ super(message);
48
+ this.status = status;
49
+ this.extra = extra;
50
+ }
51
+ }
52
+
53
+ function send(res, status, body) {
54
+ res.writeHead(status, { 'content-type': 'application/json' });
55
+ res.end(JSON.stringify(body));
56
+ }
57
+
58
+ async function readJson(req) {
59
+ let size = 0;
60
+ const chunks = [];
61
+ for await (const chunk of req) {
62
+ size += chunk.length;
63
+ if (size > MAX_JSON_BYTES) throw new RequestError(413, 'request body too large');
64
+ chunks.push(chunk);
65
+ }
66
+ try {
67
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
68
+ } catch {
69
+ throw new RequestError(400, 'request body is not JSON');
70
+ }
71
+ }
72
+
73
+ function tokenMatches(header, token) {
74
+ const expected = Buffer.from(`Bearer ${token}`);
75
+ const actual = Buffer.from(header ?? '');
76
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
77
+ }
78
+
79
+ function validateRules(rules) {
80
+ if (!rules || !isStringArray(rules.dirs) || !isStringArray(rules.extensions)) throw new RequestError(400, 'rules must list dirs and extensions');
81
+ if (rules.files !== undefined && !(isStringArray(rules.files) && rules.files.every(isNamePattern))) {
82
+ throw new RequestError(400, 'rules.files must list file names, each optionally ending in *');
83
+ }
84
+ const { serverOnly } = rules;
85
+ if (serverOnly === undefined) return;
86
+ if (!isStringArray(serverOnly.names) || !serverOnly.names.every(isPlainName) || !isPlainName(serverOnly.marker) || !isStringArray(serverOnly.keep)) {
87
+ throw new RequestError(400, 'rules.serverOnly must name folders, a marker file and keep patterns');
88
+ }
89
+ }
90
+
91
+ function validateMount(mount, mountPrefixes) {
92
+ if (!mount || !isCleanAbsolute(mount.path) || !mountPrefixes.some((prefix) => isWithin(mount.path, prefix))) {
93
+ throw new RequestError(400, `mount ${mount?.path} is not one this runner allows`);
94
+ }
95
+ const { roots, outputRoots, manifest, dirs } = mount;
96
+ if (!isStringArray(roots) || !roots.every(isSafeRel)) throw new RequestError(400, 'roots must be relative paths inside the mount');
97
+ if (!isStringArray(outputRoots) || !outputRoots.every(isSafeRel)) throw new RequestError(400, 'outputRoots must be relative paths inside the mount');
98
+ if (!manifest || typeof manifest !== 'object') throw new RequestError(400, 'manifest must be an object');
99
+ for (const [rel, entry] of Object.entries(manifest)) {
100
+ if (!isSafeRel(rel) || !roots.some((root) => isUnderRoot(rel, root))) throw new RequestError(400, `manifest path ${rel} is outside the roots`);
101
+ if (!isHash(entry?.sha256)) throw new RequestError(400, `manifest entry ${rel} has no sha256`);
102
+ }
103
+ if (!isStringArray(dirs) || !dirs.every((rel) => isSafeRel(rel) && roots.some((root) => isUnderRoot(rel, root)))) {
104
+ throw new RequestError(400, 'dirs must be folders inside the roots');
105
+ }
106
+ if (mount.present !== undefined && !(isStringArray(mount.present) && mount.present.every(isSafeRel))) {
107
+ throw new RequestError(400, 'present must list relative folders');
108
+ }
109
+ }
110
+
111
+ function validateExec(body, { mountPrefixes, scratchDirs, image, role }) {
112
+ if (body.role !== role) throw new RequestError(409, `this runner serves the ${role} toolchain, not ${body.role}`);
113
+ if (image && digestOf(body.image) !== digestOf(image)) throw new RequestError(409, `toolchain mismatch: runner has ${image}, build pins ${body.image}`);
114
+ validateRules(body.rules);
115
+ const { mounts, cwd, argv, env } = body;
116
+ if (!Array.isArray(mounts) || mounts.length === 0 || mounts.length > MAX_MOUNTS) throw new RequestError(400, `mounts must list 1 to ${MAX_MOUNTS} folders`);
117
+ mounts.forEach((mount) => validateMount(mount, mountPrefixes));
118
+ if (mounts.some((a, i) => mounts.some((b, j) => i !== j && isWithin(a.path, b.path)))) throw new RequestError(400, 'mounts must not overlap');
119
+ const allowedCwd = isCleanAbsolute(cwd) && [...mounts.map((mount) => mount.path), ...scratchDirs].some((dir) => isWithin(cwd, dir));
120
+ if (!allowedCwd) throw new RequestError(400, 'cwd must be inside a mount or a scratch folder');
121
+ if (!isStringArray(argv) || argv.length === 0) throw new RequestError(400, 'argv must be a non-empty string array');
122
+ if (!env || typeof env !== 'object' || !Object.values(env).every((value) => typeof value === 'string')) throw new RequestError(400, 'env must map names to strings');
123
+ }
124
+
125
+ // A command never sees the runner's own settings, its token above all.
126
+ const commandEnv = (env) => ({
127
+ ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('CROSSBIND_RUNNER_'))),
128
+ ...env,
129
+ });
130
+
131
+ // The job count a client wrote for its own machine means nothing here: a step runs as many jobs as this
132
+ // runner has cores.
133
+ const withJobs = (argv, jobs) => argv.map((arg, i) => {
134
+ if (/^-j\d+$/.test(arg)) return `-j${jobs}`;
135
+ return /^\d+$/.test(arg) && argv[i - 1] === '-j' ? String(jobs) : arg;
136
+ });
137
+
138
+ function runCommand({ argv: given, cwd, env }, line, jobs) {
139
+ const argv = withJobs(given, jobs);
140
+ return new Promise((resolve) => {
141
+ const child = spawn(argv[0], argv.slice(1), { cwd, env: commandEnv(env) });
142
+ child.stdout.on('data', (data) => line({ stdout: data.toString() }));
143
+ child.stderr.on('data', (data) => line({ stderr: data.toString() }));
144
+ child.on('error', (error) => {
145
+ line({ stderr: `crossbind runner: ${error.message}\n` });
146
+ resolve(127);
147
+ });
148
+ child.on('close', (code) => resolve(code ?? 1));
149
+ });
150
+ }
151
+
152
+ export function createRunnerServer({
153
+ mountPrefixes = DEFAULT_MOUNT_PREFIXES, scratchDirs = DEFAULT_SCRATCH_DIRS, blobDir, token, image, role, heartbeatMs = HEARTBEAT_MS,
154
+ jobs = os.availableParallelism(),
155
+ }) {
156
+ if (!token) throw new Error('crossbind runner: a token is required - the runner runs build commands for whoever holds it.');
157
+ if (token.length < MIN_TOKEN_LENGTH) throw new Error(`crossbind runner: the token needs at least ${MIN_TOKEN_LENGTH} characters.`);
158
+ const blobs = createBlobStore(blobDir);
159
+ const indexes = new Map();
160
+ const indexFor = (mountPath) => {
161
+ if (!indexes.has(mountPath)) indexes.set(mountPath, new Map());
162
+ return indexes.get(mountPath);
163
+ };
164
+ let queue = Promise.resolve();
165
+
166
+ function syncMount(mount, rules) {
167
+ fs.mkdirSync(mount.path, { recursive: true });
168
+ const current = hashIndex(mount.path, snapshot(mount.path, mount.roots, rules), indexFor(mount.path));
169
+ applySync(mount.path, planSync(current, mount.manifest), mount.manifest, blobs.pathOf);
170
+ // The host creates folders before a step writes into them (`-o build/bridge/x.cpp`), so even empty ones travel.
171
+ mount.dirs.forEach((rel) => fs.mkdirSync(path.join(mount.path, rel), { recursive: true }));
172
+ }
173
+
174
+ // One command at a time: each mount is a single tree, as with a local docker run.
175
+ async function execute(body, line) {
176
+ body.mounts.forEach((mount) => syncMount(mount, body.rules));
177
+ // Outputs come back whatever their type: the glue JavaScript a link writes is the build's product.
178
+ const outputRules = { ...body.rules, extensions: [] };
179
+ const isStore = (root) => root.includes('*');
180
+ const outputsOf = (mount) => snapshot(mount.path, mount.outputRoots.filter((root) => !isStore(root)), outputRules, { outputs: true });
181
+ // A store unit (a crate version, a conan package folder) never changes once written, so every unit the
182
+ // client does not report travels whole, touched by this command or not, and the rest are not walked.
183
+ const storeFilesOf = (mount) => {
184
+ const present = new Set(mount.present ?? []);
185
+ const missing = expandRoots(mount.path, mount.outputRoots.filter(isStore)).filter((unit) => !present.has(unit));
186
+ return snapshot(mount.path, missing, outputRules, { outputs: true });
187
+ };
188
+ const before = body.mounts.map(outputsOf);
189
+ fs.mkdirSync(body.cwd, { recursive: true });
190
+ const exit = await runCommand(body, line, jobs);
191
+
192
+ const outputs = {};
193
+ const removed = [];
194
+ body.mounts.forEach((mount, i) => {
195
+ const diffed = outputsOf(mount);
196
+ const store = storeFilesOf(mount);
197
+ const after = new Map([...diffed, ...store]);
198
+ const diff = diffSnapshots(before[i], diffed);
199
+ new Set([...diff.changed, ...store.keys()]).forEach((rel) => {
200
+ const file = `${mount.path}/${rel}`;
201
+ const data = fs.readFileSync(file);
202
+ const entry = { sha256: blobs.putBuffer(data), mode: after.get(rel).mode, size: data.length };
203
+ outputs[file] = entry;
204
+ // Small outputs ride along in the stream; a larger one is fetched by its hash afterwards.
205
+ if (data.length <= INLINE_OUTPUT_BYTES) line({ file, ...entry, data: data.toString('base64') });
206
+ });
207
+ removed.push(...diff.removed.map((rel) => `${mount.path}/${rel}`));
208
+ });
209
+ return { exit, outputs, removed };
210
+ }
211
+
212
+ async function handleExec(req, res) {
213
+ const body = await readJson(req);
214
+ validateExec(body, {
215
+ mountPrefixes, scratchDirs, image, role,
216
+ });
217
+ const hashes = body.mounts.flatMap((mount) => Object.values(mount.manifest).map((entry) => entry.sha256));
218
+ const missing = [...new Set(hashes)].filter((hash) => !blobs.has(hash));
219
+ if (missing.length > 0) throw new RequestError(409, 'missing blobs', { missing });
220
+ // The answer starts at once and heartbeats until the end: a step can wait behind another one longer than
221
+ // a proxy, or the client's fetch, waits for a response.
222
+ res.writeHead(200, { 'content-type': 'application/x-ndjson' });
223
+ res.flushHeaders();
224
+ const line = (record) => res.write(`${JSON.stringify(record)}\n`);
225
+ const heartbeat = setInterval(() => line({ heartbeat: true }), heartbeatMs);
226
+ try {
227
+ const run = queue.then(() => execute(body, line));
228
+ queue = run.catch(() => {});
229
+ res.end(`${JSON.stringify(await run)}\n`);
230
+ } finally {
231
+ clearInterval(heartbeat);
232
+ }
233
+ }
234
+
235
+ // One JSON line per blob ({sha256, data: base64}), so a cold start uploads its inputs in a few requests.
236
+ async function handleBatchUpload(req, res) {
237
+ let stored = 0;
238
+ for await (const text of readline.createInterface({ input: req, crlfDelay: Infinity })) {
239
+ if (!text) continue;
240
+ const { sha256, data } = JSON.parse(text);
241
+ const bytes = Buffer.from(data ?? '', 'base64');
242
+ if (!isHash(sha256) || sha256Of(bytes) !== sha256) throw new RequestError(400, `content does not match ${sha256}`);
243
+ blobs.putBuffer(bytes);
244
+ stored += 1;
245
+ }
246
+ send(res, 201, { stored });
247
+ }
248
+
249
+ async function route(req, res) {
250
+ const url = new URL(req.url, 'http://runner');
251
+ if (req.method === 'GET' && url.pathname === '/v1/health') return send(res, 200, { ok: true, protocol: PROTOCOL_VERSION, role, image });
252
+ if (!tokenMatches(req.headers.authorization, token)) return send(res, 401, { error: 'invalid token' });
253
+
254
+ if (req.method === 'POST' && url.pathname === '/v1/blobs') return handleBatchUpload(req, res);
255
+ const blobMatch = url.pathname.match(/^\/v1\/blobs\/([^/]+)$/);
256
+ if (blobMatch) {
257
+ const hash = decodeURIComponent(blobMatch[1]);
258
+ if (!isHash(hash)) return send(res, 400, { error: 'blob names are sha256 digests' });
259
+ if (req.method === 'PUT' && req.headers['content-range'] !== undefined) {
260
+ const range = CONTENT_RANGE.exec(req.headers['content-range']);
261
+ const [start, end, total] = range ? range.slice(1).map(Number) : [];
262
+ const upload = req.headers['x-crossbind-upload'];
263
+ if (!range || start > end || end >= total || !isUploadId(upload)) {
264
+ return send(res, 400, { error: 'a part needs a content-range of bytes start-end/total and an x-crossbind-upload id' });
265
+ }
266
+ const result = await blobs.putPart(hash, upload, { start, end, total }, req);
267
+ if (result.status === 'stored') return send(res, 201, { stored: hash });
268
+ if (result.status === 'partial') return send(res, 202, { received: result.received });
269
+ if (result.status === 'gap') return send(res, 409, { error: `the upload holds ${result.received} bytes, so its next part starts there`, received: result.received });
270
+ return send(res, 400, { error: result.status === 'short' ? 'the part is not as long as its content-range says' : 'content does not match the hash' });
271
+ }
272
+ if (req.method === 'PUT') {
273
+ return (await blobs.putStream(hash, req)) ? send(res, 201, { stored: hash }) : send(res, 400, { error: 'content does not match the hash' });
274
+ }
275
+ if (req.method === 'GET') {
276
+ if (!blobs.has(hash)) return send(res, 404, { error: 'unknown blob' });
277
+ res.writeHead(200, { 'content-type': 'application/octet-stream' });
278
+ return fs.createReadStream(blobs.pathOf(hash)).pipe(res);
279
+ }
280
+ }
281
+ if (req.method === 'POST' && url.pathname === '/v1/missing') {
282
+ const { hashes } = await readJson(req);
283
+ if (!isStringArray(hashes) || !hashes.every(isHash)) return send(res, 400, { error: 'hashes must be sha256 digests' });
284
+ return send(res, 200, { missing: hashes.filter((hash) => !blobs.has(hash)) });
285
+ }
286
+ if (req.method === 'POST' && url.pathname === '/v1/exec') return handleExec(req, res);
287
+ return send(res, 404, { error: 'not found' });
288
+ }
289
+
290
+ return http.createServer((req, res) => {
291
+ route(req, res).catch((error) => {
292
+ if (res.headersSent) {
293
+ res.end(`${JSON.stringify({ exit: 1, error: error.message })}\n`);
294
+ return;
295
+ }
296
+ send(res, error.status ?? 500, { error: error.message, ...error.extra });
297
+ });
298
+ });
299
+ }
300
+
301
+ const listFromEnv = (value, fallback) => (value ? value.split(',').map((item) => item.trim()).filter(Boolean) : fallback);
302
+
303
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
304
+ const token = process.env.CROSSBIND_RUNNER_TOKEN;
305
+ delete process.env.CROSSBIND_RUNNER_TOKEN;
306
+ if (!token || token.length < MIN_TOKEN_LENGTH) {
307
+ process.stderr.write(`crossbind runner: set CROSSBIND_RUNNER_TOKEN to at least ${MIN_TOKEN_LENGTH} characters - the runner runs build commands for whoever holds it.\n`);
308
+ process.exit(1);
309
+ }
310
+ const server = createRunnerServer({
311
+ mountPrefixes: listFromEnv(process.env.CROSSBIND_RUNNER_MOUNT_PREFIXES, DEFAULT_MOUNT_PREFIXES),
312
+ scratchDirs: listFromEnv(process.env.CROSSBIND_RUNNER_SCRATCH_DIRS, DEFAULT_SCRATCH_DIRS),
313
+ blobDir: process.env.CROSSBIND_RUNNER_BLOBS || '/tmp/crossbind-runner/blobs',
314
+ token,
315
+ image: process.env.CROSSBIND_RUNNER_IMAGE || '',
316
+ role: process.env.CROSSBIND_RUNNER_ROLE || 'web',
317
+ });
318
+ const port = Number(process.env.CROSSBIND_RUNNER_PORT || DEFAULT_PORT);
319
+ server.listen(port, '0.0.0.0', () => process.stdout.write(`crossbind runner (${process.env.CROSSBIND_RUNNER_ROLE || 'web'}) listening on ${port}\n`));
320
+ }
@@ -0,0 +1,56 @@
1
+ import { getFilledConfig, flattenDependencies } from './loadConfig.js';
2
+ import calculateDependencyParameters from './calculateDependencyParameters.js';
3
+ import { conanStageDir, conanPackageDir } from '../utils/conanImport.js';
4
+ import { conanDependenciesKey } from '../utils/conanDependencies.js';
5
+ import { readConanManifest, conanTargetName } from '../utils/conanStage.js';
6
+ import { TARGETS, targetPathOf } from '../utils/targets.js';
7
+
8
+ // What a recipe declares for its consumers, per staged target since it declares other things on another
9
+ // platform: the defines your code compiles with, and the system libraries and frameworks a Node.js addon
10
+ // or a native executable links. A framework goes as one linker argument: CMake collapses a repeated
11
+ // -framework. A debug build takes the release packages, so the build type is left out, and a target's
12
+ // specs stay one object, as getData takes them.
13
+ function targetSpecsOf(name, targets) {
14
+ return Object.entries(targets).flatMap(([targetPath, packages]) => {
15
+ const target = TARGETS.find((t) => targetPathOf(t) === targetPath);
16
+ const pkg = packages.find((entry) => entry.name === name);
17
+ if (!target || !pkg) return [];
18
+ const compileOptions = pkg.defines.map((define) => `-D${define}`);
19
+ const addonFlags = [...pkg.frameworks.map((framework) => `-Wl,-framework,${framework}`), ...pkg.systemLibs.map((lib) => `-l${lib}`)];
20
+ const specs = {
21
+ ...(compileOptions.length > 0 ? { cmake: { compileOptions } } : {}),
22
+ ...(addonFlags.length > 0 ? { binary: { addonFlags } } : {}),
23
+ };
24
+ if (Object.keys(specs).length === 0) return [];
25
+ return [{
26
+ platform: target.platform, arch: target.arch, runtime: target.runtime, specs,
27
+ }];
28
+ });
29
+ }
30
+
31
+ // Every library a recipe names on some target (libpng is png16 on Windows): a target links the archives
32
+ // staged for it, so a name it lacks drops out there.
33
+ const libsOf = (name, targets) => [...new Set(Object.values(targets).flat().filter((entry) => entry.name === name).flatMap((entry) => entry.libs))];
34
+
35
+ export function conanDependencyConfigs(manifest, stageDir) {
36
+ return manifest.packages.map((pkg) => ({
37
+ general: { name: conanTargetName(pkg.name), conan: pkg },
38
+ export: { type: 'cmake', libName: libsOf(pkg.name, manifest.targets) },
39
+ paths: { project: conanPackageDir(stageDir, pkg.name), output: `${conanPackageDir(stageDir, pkg.name)}/dist` },
40
+ targetSpecs: targetSpecsOf(pkg.name, manifest.targets),
41
+ }));
42
+ }
43
+
44
+ // The staged Conan packages join the config as dependencies, so header resolution, the link, the
45
+ // cmake graph and the license rows see them like any port. Only packages staged for the current
46
+ // conanDependencies join; reading the manifest runs nothing, so state can do it while it loads.
47
+ export default function attachConanDependencies(config) {
48
+ const isDeclared = Object.keys(config.conanDependencies ?? {}).length > 0;
49
+ if (!isDeclared && !config.dependencies?.some((d) => d.general.conan)) return;
50
+ const stageDir = conanStageDir(config.paths.cache);
51
+ const manifest = isDeclared ? readConanManifest(stageDir, conanDependenciesKey(config.conanDependencies)) : null;
52
+ const conan = manifest ? conanDependencyConfigs(manifest, stageDir).map((d) => getFilledConfig(d, { isDepend: true })) : [];
53
+ config.dependencies = [...config.dependencies.filter((d) => !d.general.conan), ...conan];
54
+ config.allDependencies = flattenDependencies(config.dependencies);
55
+ config.dependencyParameters = calculateDependencyParameters(config);
56
+ }
@@ -1,9 +1,11 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
  import loadJson from '../utils/loadJson.js';
4
3
  import writeJson from '../utils/writeJson.js';
5
- import { TARGETS, targetPathOf } from '../utils/targets.js';
6
- import loadConfig from './loadConfig.js';
4
+ import { TARGETS, targetPathOf, nodeAddonNamesOf, nativeCommandNamesOf } from '../utils/targets.js';
5
+ import { findCargoModuleImportsIn, writeCargoMarker } from '../utils/cargoImport.js';
6
+ import { exportWasiSdkPath } from '../utils/wasiToolchain.js';
7
+ import loadConfig, { assertBinarySelection } from './loadConfig.js';
8
+ import refreshConanDependencies from './refreshConanDependencies.js';
7
9
 
8
10
  const cacheDir = `${process.cwd()}/.crossbind`;
9
11
 
@@ -22,18 +24,17 @@ await initProcessState();
22
24
  async function initProcessState() {
23
25
  state.cache = loadCacheState();
24
26
  state.config = await loadConfig();
25
- // Recipes read the wasi-sdk location from the environment (lazily, in
26
- // getBuildParams); mirror the system-config value there so both sources
27
- // behave the same.
28
- if (state.config.system.WASI_SDK_PATH && !process.env.CROSSBIND_WASI_SDK_PATH) {
29
- process.env.CROSSBIND_WASI_SDK_PATH = state.config.system.WASI_SDK_PATH;
30
- }
27
+ assertBinarySelection(state.config);
28
+ refreshConanDependencies(state.config);
29
+ exportWasiSdkPath(state.config.system);
31
30
  // Same idea for the docker image overrides: pullDockerImage.js resolves them from the
32
31
  // environment alone, so it stays free of a state import (and of the cycle that would create).
33
32
  Object.entries({
34
33
  DOCKER_REGISTRY_MIRROR: 'CROSSBIND_REGISTRY_MIRROR',
35
34
  DOCKER_IMAGE_WEB: 'CROSSBIND_IMAGE_WEB',
36
35
  DOCKER_IMAGE_ANDROID: 'CROSSBIND_IMAGE_ANDROID',
36
+ DOCKER_IMAGE_LINUX: 'CROSSBIND_IMAGE_LINUX',
37
+ DOCKER_IMAGE_WINDOWS: 'CROSSBIND_IMAGE_WINDOWS',
37
38
  }).forEach(([key, envKey]) => {
38
39
  if (state.config.system[key] && !process.env[envKey]) {
39
40
  process.env[envKey] = state.config.system[key];
@@ -54,21 +55,26 @@ async function initProcessState() {
54
55
  // A wasi build is a single command module - no JS glue, no preload.
55
56
  target.wasmName = `${state.config.general.name}-${target.path}.wasm`;
56
57
  }
58
+ if (target.runtimeEnv === 'node' && target.platform !== 'wasm') {
59
+ Object.assign(target, nodeAddonNamesOf(target, state.config.general.name));
60
+ }
61
+ if (target.runtimeEnv === 'native') {
62
+ Object.assign(target, nativeCommandNamesOf(target, state.config.general.name));
63
+ }
57
64
  });
58
65
 
59
66
  setAllDependecyPaths();
60
67
 
61
- // Bare cargo-crate import markers must exist BEFORE any bundler starts: metro resolves
62
- // against its startup file map, so a marker first created mid-resolution is invisible
63
- // to that very build.
68
+ // Cargo-crate import markers must exist BEFORE any bundler starts: metro resolves against
69
+ // its startup file map, so a marker first created mid-resolution is invisible to that very
70
+ // build. Bare crates come from the config, module imports from the app's own sources.
64
71
  const cargoDeps = state.config.cargoDependencies ?? {};
65
- for (const name of Object.keys(cargoDeps)) {
66
- const marker = `${state.config.paths.cache}/rust-crates/${name}.rs`;
67
- if (!fs.existsSync(marker)) {
68
- fs.mkdirSync(path.dirname(marker), { recursive: true });
69
- fs.writeFileSync(marker, `// crossbind cargo crate import marker: ${name}\n`);
70
- }
71
- }
72
+ const crateNames = Object.keys(cargoDeps);
73
+ const moduleImports = crateNames.length && state.config.paths.project
74
+ ? findCargoModuleImportsIn(state.config.paths.project).filter((i) => Object.hasOwn(cargoDeps, i.crateName))
75
+ : [];
76
+ [...crateNames.map((crateName) => ({ crateName, modulePath: [] })), ...moduleImports]
77
+ .forEach((cargoImport) => writeCargoMarker(state.config.paths.cache, cargoImport));
72
78
 
73
79
  if (state.config.build?.setState) {
74
80
  state.config.build.setState(state);
@@ -86,7 +92,12 @@ export function setAllDependecyPaths() {
86
92
  state.config.allDependencyPaths[target.path] = { cmake: {} };
87
93
  state.config.allDependencies.forEach((d) => {
88
94
  state.config.allDependencyPaths[target.path].cmake[d.general.name] = `${d.paths.output}/prebuilt`;
89
- d.export.libName.forEach((name) => {
95
+ // A Conan recipe can name a library on another platform only (libpng is png16 on Windows).
96
+ const libNames = d.general.conan
97
+ ? d.export.libName.filter((name) => [target.path, target.releasePath]
98
+ .some((targetPath) => fs.existsSync(`${d.paths.output}/prebuilt/${targetPath}/lib/lib${name}.a`)))
99
+ : d.export.libName;
100
+ libNames.forEach((name) => {
90
101
  state.config.allDependencyPaths[target.path][name] = {
91
102
  root: `${d.paths.output}/prebuilt/${target.path}`,
92
103
  };
@@ -12,8 +12,23 @@ import fixPackageName from '../utils/fixPackageName.js';
12
12
  import getCMakeListsFilePath from '../utils/getCMakeListsFilePath.js';
13
13
  import calculateDependencyParameters from './calculateDependencyParameters.js';
14
14
  import logger from '../utils/logger.js';
15
+ import { RUNTIME_ENVS } from '../utils/targets.js';
16
+ import { normalizeConanDependencies } from '../utils/conanDependencies.js';
17
+ import isSourceCmakePackage from '../utils/isSourceCmakePackage.js';
15
18
  // import getCmakeParameters from './getCmakeParameters.js';
16
19
 
20
+ // For the project being built only: dependency builds load published configs that may still set
21
+ // export.bundle.
22
+ export function assertBinarySelection(config) {
23
+ if ('bundle' in config.export) {
24
+ throw new Error('crossbind: export.bundle was removed. A build makes binaries only for the runtime environments it is asked for: -e on the command line, target.runtimeEnv in crossbind.config.js, or a bundler plugin. Remove export.bundle.');
25
+ }
26
+ const { runtimeEnv } = config.target;
27
+ if (runtimeEnv !== undefined && runtimeEnv !== null && !RUNTIME_ENVS.includes(runtimeEnv)) {
28
+ throw new Error(`crossbind: target.runtimeEnv must be one of ${RUNTIME_ENVS.join(', ')} (got ${JSON.stringify(runtimeEnv)}).`);
29
+ }
30
+ }
31
+
17
32
  export default async function loadConfig(configDir = process.cwd(), configName = 'crossbind.config') {
18
33
  const config = await loadJs(configDir, configName) || {};
19
34
  if (config.excludedDependencies?.length) {
@@ -80,6 +95,15 @@ export default async function loadConfig(configDir = process.cwd(), configName =
80
95
  return output;
81
96
  }
82
97
 
98
+ // Direct and transitive dependencies, one entry per package directory.
99
+ export function flattenDependencies(dependencies) {
100
+ const output = {};
101
+ [...dependencies, ...dependencies.map((d) => d.allDependencies).flat()].forEach((d) => {
102
+ output[d.paths.project] = d;
103
+ });
104
+ return Object.values(output);
105
+ }
106
+
83
107
  export function getFilledConfig(config, options = { isDepend: false }) {
84
108
  const exclude = options.exclude || [];
85
109
  const { seen, replaces } = options;
@@ -101,6 +125,7 @@ export function getFilledConfig(config, options = { isDepend: false }) {
101
125
  general: config.general || {},
102
126
  dependencies,
103
127
  cargoDependencies: config.cargoDependencies || {},
128
+ conanDependencies: normalizeConanDependencies(config.conanDependencies),
104
129
  // 'sync' types the direct surface; 'promise' wraps every generated method return
105
130
  // for useWorker-style runtimes (constructors stay sync-typed: await new X()).
106
131
  dts: config.dts || 'sync',
@@ -126,6 +151,9 @@ export function getFilledConfig(config, options = { isDepend: false }) {
126
151
  if (typeof newConfig.types !== 'boolean') {
127
152
  throw new Error(`crossbind: "types" must be true or false (got '${newConfig.types}').`);
128
153
  }
154
+ if (newConfig.targetSpecs.some((t) => Array.isArray(t?.specs?.cmake))) {
155
+ throw new Error('crossbind: "targetSpecs[].specs.cmake" must be an object such as { compileOptions: [\'-O3\'] } (got an array). CMake configure flags (-D...) belong in a recipe\'s getBuildParams.');
156
+ }
129
157
 
130
158
  if (newConfig.paths.config && !newConfig.paths.project) {
131
159
  newConfig.paths.project = getParentPath(newConfig.paths.config);
@@ -173,15 +201,8 @@ export function getFilledConfig(config, options = { isDepend: false }) {
173
201
  newConfig.export.libPath = getPath(newConfig.export.libPath || 'lib');
174
202
  newConfig.export.libName = newConfig.export.libName || [newConfig.general.name];
175
203
  newConfig.export.binHeaders = newConfig.export.binHeaders || [];
176
- newConfig.export.bundle = newConfig.export.bundle ?? true;
177
204
 
178
- newConfig.allDependencies = (() => {
179
- const output = {};
180
- [...newConfig.dependencies, ...newConfig.dependencies.map((d) => d.allDependencies).flat()].forEach((d) => {
181
- output[d.paths.project] = d;
182
- });
183
- return Object.values(output);
184
- })();
205
+ newConfig.allDependencies = flattenDependencies(newConfig.dependencies);
185
206
 
186
207
  newConfig.extensions?.forEach(e => {
187
208
  e?.loadConfig?.after(newConfig);
@@ -191,22 +212,13 @@ export function getFilledConfig(config, options = { isDepend: false }) {
191
212
  newConfig.target.runtime = 'mt';
192
213
  }
193
214
 
194
- newConfig.functions.isEnabled = newConfig.functions.isEnabled || ((target) => {
195
- // Source-cmake packages ship no prebuilt at all: their own CMakeLists (at the
196
- // package root, not a generated dist/prebuilt one) is compiled into the consuming
197
- // build via add_subdirectory, so they serve every target.
198
- const isSourceCmakePackage = newConfig.export?.type === 'cmake'
199
- && newConfig.paths.cmake !== newConfig.paths.cliCMakeListsTxt
200
- && !newConfig.paths.cmakeDir.endsWith('/prebuilt')
201
- && fs.existsSync(newConfig.paths.cmake);
202
-
203
- return (
204
- isSourceCmakePackage
205
- || fs.existsSync(`${newConfig.paths.cmakeDir}/${target.path}`)
206
- || fs.existsSync(`${newConfig.paths.cmakeDir}/${target.releasePath}`)
207
- || (target.platform === 'ios' && fs.existsSync(`${newConfig.paths.cmakeDir}/../../${newConfig.general.name}-${target.runtime}.xcframework`))
208
- );
209
- });
215
+ // A package that ships its sources serves every target.
216
+ newConfig.functions.isEnabled = newConfig.functions.isEnabled || ((target) => (
217
+ isSourceCmakePackage(newConfig)
218
+ || fs.existsSync(`${newConfig.paths.cmakeDir}/${target.path}`)
219
+ || fs.existsSync(`${newConfig.paths.cmakeDir}/${target.releasePath}`)
220
+ || (target.platform === 'ios' && fs.existsSync(`${newConfig.paths.cmakeDir}/../../${newConfig.general.name}-${target.runtime}.xcframework`))
221
+ ));
210
222
 
211
223
  newConfig.dependencyParameters = calculateDependencyParameters(newConfig);
212
224
  // newConfig.cmakeParameters = getCmakeParameters(newConfig);
@@ -0,0 +1,20 @@
1
+ import calculateDependencyParameters from './calculateDependencyParameters.js';
2
+ import getAbsolutePath from '../utils/getAbsolutePath.js';
3
+ import getCMakeListsFilePath, { getCliCMakeListsFile } from '../utils/getCMakeListsFilePath.js';
4
+ import getParentPath from '../utils/getParentPath.js';
5
+
6
+ // A dependency with no build when the config loaded resolved to the CLI's own CMakeLists, which keeps it out of the link
7
+ // and its headers out of the dependency include roots: a process that outlives the build, such as a Metro server, takes it
8
+ // in here. Says whether it took one in.
9
+ export default function refreshBuiltDependencies(config) {
10
+ const built = (config.allDependencies ?? [])
11
+ .filter((dep) => dep !== config && ['cmake', 'cargo'].includes(dep.export?.type) && dep.paths.cmake === config.paths.cliCMakeListsTxt)
12
+ .map((dep) => ({ dep, cmake: getCMakeListsFilePath(dep.paths.output) }))
13
+ .filter(({ cmake }) => cmake !== getCliCMakeListsFile());
14
+ built.forEach(({ dep, cmake }) => {
15
+ dep.paths.cmake = getAbsolutePath(dep.paths.project, cmake);
16
+ dep.paths.cmakeDir = getParentPath(dep.paths.cmake);
17
+ });
18
+ if (built.length > 0) config.dependencyParameters = calculateDependencyParameters(config);
19
+ return built.length > 0;
20
+ }
@@ -0,0 +1,17 @@
1
+ import attachConanDependencies from './attachConanDependencies.js';
2
+ import { conanStageDir } from '../utils/conanImport.js';
3
+ import { conanManifestsStamp } from '../utils/conanStage.js';
4
+
5
+ const attachedFrom = new WeakMap();
6
+
7
+ // Attaches what a build staged since this config last attached: a process that loaded state before
8
+ // the build, such as a Metro server, would otherwise bind headers without the packages. Says whether
9
+ // it attached again.
10
+ export default function refreshConanDependencies(config) {
11
+ if (Object.keys(config.conanDependencies ?? {}).length === 0) return false;
12
+ const stamp = conanManifestsStamp(conanStageDir(config.paths.cache));
13
+ if (attachedFrom.get(config) === stamp) return false;
14
+ attachedFrom.set(config, stamp);
15
+ attachConanDependencies(config);
16
+ return true;
17
+ }
@@ -0,0 +1,4 @@
1
+ // The android image's NDK, and the API level every Android archive is built against: port archives
2
+ // and Conan packages link into the same library, so they move together.
3
+ export const ANDROID_NDK = '/opt/android-sdk/ndk/current';
4
+ export const ANDROID_API_LEVEL = 33;