crossbind 0.0.1 → 2.0.0-beta.51

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 (95) hide show
  1. package/AGENTS.md +92 -0
  2. package/CHANGELOG.md +156 -0
  3. package/LICENSE +21 -0
  4. package/README.md +133 -0
  5. package/package.json +52 -4
  6. package/src/actions/buildBinTools.js +0 -0
  7. package/src/actions/buildCargo.js +88 -0
  8. package/src/actions/buildDependencies.js +157 -0
  9. package/src/actions/buildExternal.js +57 -0
  10. package/src/actions/buildJs.js +93 -0
  11. package/src/actions/buildLib.js +115 -0
  12. package/src/actions/buildTypes.js +33 -0
  13. package/src/actions/buildWasiCommand.js +101 -0
  14. package/src/actions/buildWasm.js +322 -0
  15. package/src/actions/createInterface.js +172 -0
  16. package/src/actions/createLib.js +249 -0
  17. package/src/actions/createXCFramework.js +82 -0
  18. package/src/actions/extensions.js +7 -0
  19. package/src/actions/getAllBridges.js +20 -0
  20. package/src/actions/getCmakeParameters.js +66 -0
  21. package/src/actions/getData.js +46 -0
  22. package/src/actions/getDependLibs.js +25 -0
  23. package/src/actions/isSourceNewer.js +35 -0
  24. package/src/actions/licenses.js +156 -0
  25. package/src/actions/run.js +307 -0
  26. package/src/actions/target.js +54 -0
  27. package/src/assets/cmake/CMakeLists.txt +61 -0
  28. package/src/assets/cmake/dist.cmake +57 -0
  29. package/src/assets/cmake/ios.toolchain.cmake +1176 -0
  30. package/src/assets/cpp-runtime/browser.cpp +12 -0
  31. package/src/assets/cpp-runtime/commonBridges.cpp +17 -0
  32. package/src/assets/cpp-runtime/crossbindEmptySource.cpp +9 -0
  33. package/src/assets/cpp-runtime/node.cpp +19 -0
  34. package/src/assets/js-runtime/adapters/exception-decode.js +79 -0
  35. package/src/assets/js-runtime/adapters/fs-browser.js +173 -0
  36. package/src/assets/js-runtime/adapters/fs-node.js +28 -0
  37. package/src/assets/js-runtime/adapters/path-fs.js +6 -0
  38. package/src/assets/js-runtime/adapters/path-url.js +8 -0
  39. package/src/assets/js-runtime/adapters/vector-coercion.js +209 -0
  40. package/src/assets/js-runtime/adapters/worker-comlink.js +309 -0
  41. package/src/assets/js-runtime/browser.js +16 -0
  42. package/src/assets/js-runtime/core.js +193 -0
  43. package/src/assets/js-runtime/edge.js +18 -0
  44. package/src/assets/js-runtime/node.js +20 -0
  45. package/src/assets/packaging/crossbind-package.podspec +17 -0
  46. package/src/assets/wasi-runtime/stubs.c +84 -0
  47. package/src/bin.js +416 -0
  48. package/src/index.js +19 -0
  49. package/src/integration/getCrossbindScript.js +180 -0
  50. package/src/integration/getDependFilePath.js +132 -0
  51. package/src/runtime/wasiRun.mjs +118 -0
  52. package/src/state/calculateDependencyParameters.js +97 -0
  53. package/src/state/index.js +128 -0
  54. package/src/state/loadConfig.js +215 -0
  55. package/src/utils/appRustCrates.js +106 -0
  56. package/src/utils/binTools.js +76 -0
  57. package/src/utils/bridgeAsyncGuard.js +37 -0
  58. package/src/utils/cargoTarget.js +15 -0
  59. package/src/utils/cppDts.js +201 -0
  60. package/src/utils/cppFieldBindings.js +23 -0
  61. package/src/utils/dependencyRebuild.js +99 -0
  62. package/src/utils/dirLock.js +71 -0
  63. package/src/utils/downloadAndExtractFile.js +102 -0
  64. package/src/utils/familyManifest.js +17 -0
  65. package/src/utils/findFiles.js +5 -0
  66. package/src/utils/fixPackageName.js +7 -0
  67. package/src/utils/flattenConfigForTable.js +75 -0
  68. package/src/utils/getAbsolutePath.js +14 -0
  69. package/src/utils/getCMakeListsFilePath.js +17 -0
  70. package/src/utils/getOsUserAndGroupId.js +11 -0
  71. package/src/utils/getParentPath.js +12 -0
  72. package/src/utils/hash.js +33 -0
  73. package/src/utils/inputStamp.js +28 -0
  74. package/src/utils/licenseReport.js +94 -0
  75. package/src/utils/linkLayout.js +34 -0
  76. package/src/utils/loadJs.js +31 -0
  77. package/src/utils/loadJson.js +16 -0
  78. package/src/utils/logger.js +110 -0
  79. package/src/utils/ociArtifact.js +124 -0
  80. package/src/utils/overrideDependency.js +99 -0
  81. package/src/utils/provenance.js +54 -0
  82. package/src/utils/pullDockerImage.js +105 -0
  83. package/src/utils/replaceBasePathForDocker.js +18 -0
  84. package/src/utils/resolveEmbindRust.js +66 -0
  85. package/src/utils/runCargo.js +228 -0
  86. package/src/utils/rustBridgeGen.js +1013 -0
  87. package/src/utils/rustMt.js +76 -0
  88. package/src/utils/rustSysroot.js +171 -0
  89. package/src/utils/rustSysrootPin.js +11 -0
  90. package/src/utils/systemKeys.js +29 -0
  91. package/src/utils/targets.js +109 -0
  92. package/src/utils/wasiToolchain.js +47 -0
  93. package/src/utils/writeIfChanged.js +8 -0
  94. package/src/utils/writeJson.js +9 -0
  95. package/vitest.config.js +46 -0
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ export default async function loadJs(path, fileName, fileExt = ['json', 'js', 'mjs', 'cjs', 'ts']) {
5
+ let filePath;
6
+ fileExt.some((e) => {
7
+ filePath = `${path}/${fileName}.${e}`;
8
+ if (!fs.existsSync(filePath)) {
9
+ filePath = null;
10
+ return false;
11
+ }
12
+ return true;
13
+ });
14
+
15
+ if (filePath) {
16
+ let file = filePath.endsWith('.json')
17
+ ? JSON.parse(fs.readFileSync(filePath, 'utf8'))
18
+ : await import(pathToFileURL(filePath).href);
19
+
20
+ if (file && file.default) file = file.default;
21
+
22
+ if (typeof file === 'function') {
23
+ return file();
24
+ }
25
+ if (typeof file === 'object') {
26
+ return file;
27
+ }
28
+ }
29
+
30
+ return null;
31
+ }
@@ -0,0 +1,16 @@
1
+ import fs from 'node:fs';
2
+
3
+ export default function loadJson(filePath) {
4
+ if (!fs.existsSync(filePath)) {
5
+ return null;
6
+ }
7
+
8
+ try {
9
+ const stateContent = fs.readFileSync(filePath, { encoding: 'utf8', flag: 'r' });
10
+ const state = JSON.parse(stateContent);
11
+ return state;
12
+ } catch (e) {
13
+ console.error(e);
14
+ return null;
15
+ }
16
+ }
@@ -0,0 +1,110 @@
1
+ import logUpdate from 'log-update';
2
+ import pc from 'picocolors';
3
+
4
+ const isTTY = !!process.stdout.isTTY;
5
+
6
+ let active = null;
7
+
8
+ function ms(n) {
9
+ if (n == null) return '';
10
+ return n < 1000 ? `${Math.round(n)}ms` : `${(n / 1000).toFixed(1)}s`;
11
+ }
12
+
13
+ function colorTime(rawMs) {
14
+ const text = ms(rawMs);
15
+ if (rawMs < 1000) return pc.green(text);
16
+ if (rawMs < 5000) return pc.yellow(text);
17
+ return pc.red(text);
18
+ }
19
+
20
+ function fmtLabel(target, fileType) {
21
+ const path = pc.dim(target?.path || '');
22
+ const env = target?.runtimeEnv ? pc.dim('/') + pc.cyan(target.runtimeEnv) : '';
23
+ const ft = fileType ? ` ${pc.magenta(fileType)}` : '';
24
+ return `${path}${env}${ft}`;
25
+ }
26
+
27
+ function commit(text) {
28
+ if (isTTY) {
29
+ logUpdate(text);
30
+ logUpdate.done();
31
+ } else {
32
+ process.stdout.write(`${text}\n`);
33
+ }
34
+ active = null;
35
+ }
36
+
37
+ function show(text) {
38
+ if (isTTY) {
39
+ logUpdate(text);
40
+ } else {
41
+ process.stdout.write(`${text}\n`);
42
+ }
43
+ }
44
+
45
+ export function startStep(target, fileType) {
46
+ const label = fmtLabel(target, fileType);
47
+ active = { label, startedAt: Date.now() };
48
+ show(`${label} ${pc.yellow('compiling...')}`);
49
+ }
50
+
51
+ export function doneStep(target, fileType, detail) {
52
+ const label = fmtLabel(target, fileType);
53
+ if (active && active.label === label) {
54
+ const total = Date.now() - active.startedAt;
55
+ const det = detail ? pc.dim(` (${detail})`) : '';
56
+ commit(`${label} ${colorTime(total)}${det}`);
57
+ } else {
58
+ commit(`${label} ${pc.green(detail || 'done')}`);
59
+ }
60
+ }
61
+
62
+ export function cachedStep(target, fileType) {
63
+ commit(`${fmtLabel(target, fileType)} ${pc.gray('cached')}`);
64
+ }
65
+
66
+ export function startTask(label) {
67
+ const styled = pc.magenta(label);
68
+ active = { label: styled, startedAt: Date.now() };
69
+ show(`${styled} ${pc.yellow('working...')}`);
70
+ }
71
+
72
+ export function doneTask(label, detail) {
73
+ const styled = pc.magenta(label);
74
+ if (active && active.label === styled) {
75
+ const total = Date.now() - active.startedAt;
76
+ const det = detail ? pc.dim(` (${detail})`) : '';
77
+ commit(`${styled} ${colorTime(total)}${det}`);
78
+ } else {
79
+ commit(`${styled} ${pc.green(detail || 'done')}`);
80
+ }
81
+ }
82
+
83
+ export function cachedTask(label) {
84
+ commit(`${pc.magenta(label)} ${pc.gray('cached')}`);
85
+ }
86
+
87
+ export function info(text) {
88
+ if (active && isTTY) {
89
+ logUpdate.done();
90
+ active = null;
91
+ }
92
+ process.stdout.write(`${text}\n`);
93
+ }
94
+
95
+ export function error(text) {
96
+ if (active && isTTY) {
97
+ logUpdate.done();
98
+ active = null;
99
+ }
100
+ process.stderr.write(`${pc.red(text)}\n`);
101
+ }
102
+
103
+ process.on('SIGINT', () => {
104
+ if (active && isTTY) logUpdate.done();
105
+ process.exit(130);
106
+ });
107
+
108
+ export default {
109
+ startStep, doneStep, cachedStep, startTask, doneTask, cachedTask, info, error,
110
+ };
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs';
2
+ import crypto from 'node:crypto';
3
+ import { Readable } from 'node:stream';
4
+ import { pipeline } from 'node:stream/promises';
5
+
6
+ // Fetches one layer out of a published OCI image over plain HTTPS - no docker, no oras, no second
7
+ // copy of the bytes hosted anywhere. The sysroots already ship as an image; this is how a host
8
+ // build (RUNNER=LOCAL) reads that same object instead of a repackaged tarball.
9
+ //
10
+ // Everything is verified against the ONE pinned digest the caller supplies. The index body must
11
+ // hash to it; each descriptor inside a verified body then authenticates the next fetch, so the
12
+ // chain index -> platform manifest -> layer needs no additional hashes to trust. Content
13
+ // addressing does the work a separate sha256 would otherwise be doing by hand.
14
+
15
+ const MANIFEST_TYPES = [
16
+ 'application/vnd.oci.image.index.v1+json',
17
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
18
+ 'application/vnd.oci.image.manifest.v1+json',
19
+ 'application/vnd.docker.distribution.manifest.v2+json',
20
+ ].join(', ');
21
+
22
+ const digestOf = (buf) => `sha256:${crypto.createHash('sha256').update(buf).digest('hex')}`;
23
+
24
+ function parseRef(image) {
25
+ const slash = image.indexOf('/');
26
+ if (slash === -1) throw new Error(`crossbind: '${image}' is not a <registry>/<repository> reference.`);
27
+ return { registry: image.slice(0, slash), repository: image.slice(slash + 1) };
28
+ }
29
+
30
+ // The standard token dance: an anonymous pull gets a 401 naming the realm to ask. Following it from
31
+ // the challenge rather than hardcoding a registry's endpoint keeps this portable.
32
+ async function anonymousToken(registry, repository) {
33
+ const probe = await fetch(`https://${registry}/v2/`, { redirect: 'follow' });
34
+ if (probe.status !== 401) return null;
35
+ const challenge = probe.headers.get('www-authenticate') ?? '';
36
+ const field = (name) => challenge.match(new RegExp(`${name}="([^"]+)"`))?.[1];
37
+ const realm = field('realm');
38
+ if (!realm) return null;
39
+ const url = new URL(realm);
40
+ const service = field('service');
41
+ if (service) url.searchParams.set('service', service);
42
+ url.searchParams.set('scope', `repository:${repository}:pull`);
43
+ const res = await fetch(url, { redirect: 'follow' });
44
+ if (!res.ok) throw new Error(`crossbind: ${registry} refused an anonymous pull token (HTTP ${res.status}).`);
45
+ const body = await res.json();
46
+ return body.token ?? body.access_token ?? null;
47
+ }
48
+
49
+ // Redirects are followed by hand for one reason: a blob download is redirected to a CDN on another
50
+ // host, and the registry credential must not travel there. fetch's automatic redirect would be
51
+ // spec-compliant about this, but "the runtime probably strips it" is not something to leave to
52
+ // chance when the alternative is ten lines.
53
+ async function get(url, { token, accept }) {
54
+ let current = url;
55
+ for (let hop = 0; hop < 5; hop += 1) {
56
+ const sameHost = new URL(current).host === new URL(url).host;
57
+ const headers = {};
58
+ if (accept) headers.Accept = accept;
59
+ if (token && sameHost) headers.Authorization = `Bearer ${token}`;
60
+ const res = await fetch(current, { headers, redirect: 'manual' });
61
+ if (res.status >= 300 && res.status < 400) {
62
+ const next = res.headers.get('location');
63
+ if (!next) throw new Error(`crossbind: ${current} redirected without a location header.`);
64
+ current = new URL(next, current).toString();
65
+ continue;
66
+ }
67
+ if (!res.ok) throw new Error(`crossbind: ${current} returned HTTP ${res.status}.`);
68
+ return res;
69
+ }
70
+ throw new Error(`crossbind: too many redirects fetching ${url}.`);
71
+ }
72
+
73
+ async function fetchVerified(base, token, digest) {
74
+ const res = await get(`${base}/manifests/${digest}`, { token, accept: MANIFEST_TYPES });
75
+ const body = Buffer.from(await res.arrayBuffer());
76
+ const got = digestOf(body);
77
+ if (got !== digest) {
78
+ throw new Error(`crossbind: ${base} served ${got} for ${digest} - the registry contradicted its own digest.`);
79
+ }
80
+ return JSON.parse(body.toString());
81
+ }
82
+
83
+ // Downloads the single layer of the platform manifest matching `arch`, streaming to `dest` while
84
+ // hashing, and refuses anything whose bytes do not match the digest the manifest named.
85
+ export default async function fetchOciLayer({
86
+ image, index, arch, dest,
87
+ }) {
88
+ const { registry, repository } = parseRef(image);
89
+ const base = `https://${registry}/v2/${repository}`;
90
+ const token = await anonymousToken(registry, repository);
91
+
92
+ const root = await fetchVerified(base, token, index);
93
+ const leaves = root.manifests ?? [];
94
+ if (leaves.length === 0) throw new Error(`crossbind: ${image}@${index} is not a multi-platform index.`);
95
+ const leaf = leaves.find((m) => m.platform?.os === 'linux' && m.platform?.architecture === arch);
96
+ if (!leaf) {
97
+ const have = leaves.map((m) => `${m.platform?.os}/${m.platform?.architecture}`).join(', ');
98
+ throw new Error(`crossbind: ${image}@${index} has no linux/${arch} - it carries ${have}.`);
99
+ }
100
+
101
+ const manifest = await fetchVerified(base, token, leaf.digest);
102
+ const layers = manifest.layers ?? [];
103
+ if (layers.length !== 1) {
104
+ throw new Error(`crossbind: expected the sysroot image to be one layer, found ${layers.length}.`);
105
+ }
106
+ const [layer] = layers;
107
+ if (!/tar\+gzip$/.test(layer.mediaType ?? '')) {
108
+ throw new Error(`crossbind: the sysroot layer is ${layer.mediaType}, expected a gzipped tar.`);
109
+ }
110
+
111
+ const res = await get(`${base}/blobs/${layer.digest}`, { token });
112
+ const hash = crypto.createHash('sha256');
113
+ await pipeline(
114
+ Readable.fromWeb(res.body),
115
+ async function* (source) { for await (const chunk of source) { hash.update(chunk); yield chunk; } },
116
+ fs.createWriteStream(dest),
117
+ );
118
+ const got = `sha256:${hash.digest('hex')}`;
119
+ if (got !== layer.digest) {
120
+ fs.rmSync(dest, { force: true });
121
+ throw new Error(`crossbind: the sysroot layer hashed to ${got}, expected ${layer.digest}.`);
122
+ }
123
+ return { layer: layer.digest, platform: `linux/${arch}`, size: layer.size };
124
+ }
@@ -0,0 +1,99 @@
1
+ import { getContentHash } from './hash.js';
2
+
3
+ const REPLACE_RECIPE_KEYS = [
4
+ 'buildType', 'getURL', 'env', 'copyToSource', 'copyToDist',
5
+ 'beforeRun', 'useIOSCMake', 'sourceReplaceList', 'getExtraLibs',
6
+ ];
7
+
8
+ function identityNames(dep) {
9
+ return [dep.general?.name, dep.package?.name, dep.general?.alias?.package].filter(Boolean);
10
+ }
11
+
12
+ function matchEntry(map, dep) {
13
+ if (!map || typeof map !== 'object') return null;
14
+ for (const name of identityNames(dep)) {
15
+ if (Object.prototype.hasOwnProperty.call(map, name)) return { key: name, value: map[name] };
16
+ }
17
+ return null;
18
+ }
19
+
20
+ function normalizeOverride(value) {
21
+ if (!value || typeof value !== 'object') return null;
22
+ const { replace, rebuild, exclude, ...recipe } = value;
23
+ if (rebuild === true) return recipe;
24
+ if (Object.keys(recipe).length > 0) return recipe;
25
+ return null;
26
+ }
27
+
28
+ export function resolveDependencyOverride(overrides, dep) {
29
+ const match = matchEntry(overrides, dep);
30
+ if (!match) return null;
31
+ const override = normalizeOverride(match.value);
32
+ return override ? { key: match.key, override } : null;
33
+ }
34
+
35
+ export function resolveDependencyReplace(replaces, dep) {
36
+ const match = matchEntry(replaces, dep);
37
+ const replace = match?.value && typeof match.value === 'object' ? match.value.replace : null;
38
+ return replace || null;
39
+ }
40
+
41
+ export function resolveExcludedNames(overrides) {
42
+ if (!overrides || typeof overrides !== 'object') return [];
43
+ return Object.keys(overrides).filter((k) => overrides[k]?.exclude === true);
44
+ }
45
+
46
+ // Keep the old package's identity (name/alias/package.name/libName); take the new package's
47
+ // implementation (paths, dependencies, build, targetSpecs, version) — so every consumer of the
48
+ // old identity keeps resolving while artifacts come from the replacement.
49
+ export function restampIdentity(newNode, oldNode) {
50
+ return {
51
+ ...newNode,
52
+ general: oldNode.general,
53
+ package: { ...newNode.package, name: oldNode.package?.name },
54
+ export: { ...newNode.export, libName: oldNode.export.libName },
55
+ };
56
+ }
57
+
58
+ export function mergeBuildOverride(recipe = {}, override = {}) {
59
+ if (!override) return recipe;
60
+ const merged = { ...recipe };
61
+
62
+ REPLACE_RECIPE_KEYS.forEach((key) => {
63
+ if (key in override) merged[key] = override[key];
64
+ });
65
+
66
+ if (override.replaceList) {
67
+ if (Array.isArray(override.replaceList)) {
68
+ merged.replaceList = [...(recipe.replaceList || []), ...override.replaceList];
69
+ } else if (override.replaceList.set) {
70
+ merged.replaceList = [...override.replaceList.set];
71
+ } else if (override.replaceList.append) {
72
+ merged.replaceList = [...(recipe.replaceList || []), ...override.replaceList.append];
73
+ }
74
+ }
75
+
76
+ if (typeof override.getBuildParams === 'function') {
77
+ const base = typeof recipe.getBuildParams === 'function' ? recipe.getBuildParams : () => [];
78
+ merged.getBuildParams = (...args) => override.getBuildParams(base, ...args);
79
+ }
80
+
81
+ return merged;
82
+ }
83
+
84
+ // Functions are keyed by their source text: editing the literal changes the key (rebuild),
85
+ // but values captured from outer scope do NOT — an override function must stay self-contained
86
+ // or a changed captured value silently keeps the stale cache.
87
+ export function getOverrideKey(dep) {
88
+ const override = dep.overrideBuild || {};
89
+ const serial = JSON.stringify(
90
+ override,
91
+ (key, value) => (typeof value === 'function' ? `fn:${value.toString()}` : value),
92
+ );
93
+ const parts = [
94
+ dep.general?.name || '',
95
+ override.nativeVersion || dep.package?.nativeVersion || '',
96
+ serial,
97
+ ];
98
+ return getContentHash(parts.join(' '));
99
+ }
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import crypto from 'node:crypto';
4
+ import state from '../state/index.js';
5
+ import familyManifestOf from './familyManifest.js';
6
+ import { getDockerImage, imageRoleFor } from './pullDockerImage.js';
7
+ import { resolveWasiSdkPath } from './wasiToolchain.js';
8
+
9
+ // K4 (contract): -bin packages carry a machine-readable provenance block documenting
10
+ // the reproducible recipe of the shipped binaries - producing recipe package, upstream
11
+ // source hash and build environment. Everything is derived; nothing is hand-maintained.
12
+
13
+ // wasi-sdk's VERSION file pins the sdk release plus its wasi-libc/llvm inputs.
14
+ // Also consumed by the license machinery to pin toolchain-runtime notice links.
15
+ export function wasiToolchainIdentity(sdkPath) {
16
+ const toolchain = { name: 'wasi-sdk' };
17
+ const versionFile = `${sdkPath}/VERSION`;
18
+ if (fs.existsSync(versionFile)) {
19
+ const [version, ...pins] = fs.readFileSync(versionFile, 'utf8').trim().split('\n');
20
+ toolchain.version = version.trim();
21
+ for (const line of pins) {
22
+ const match = line.match(/^([\w-]+):\s*(.+)$/);
23
+ if (match) toolchain[match[1]] = match[2].trim();
24
+ }
25
+ }
26
+ const clang = fs.realpathSync(`${sdkPath}/bin/clang`);
27
+ toolchain.clangSha256 = crypto.createHash('sha256').update(fs.readFileSync(clang)).digest('hex');
28
+ return toolchain;
29
+ }
30
+
31
+ export default function buildProvenance(target) {
32
+ const manifest = state.config.package || {};
33
+ const family = familyManifestOf(state.config);
34
+ if (!family) {
35
+ throw new Error(`crossbind: cannot resolve the family recipe package of "${manifest.name}" - required for provenance (contract K4).`);
36
+ }
37
+ const { getURL, sha256 } = state.config.build;
38
+ if (!sha256) {
39
+ throw new Error(`crossbind: the recipe of "${manifest.name}" declares no source sha256 - required for provenance (contract K4).`);
40
+ }
41
+ const wasiSdk = target.platform === 'wasi' ? resolveWasiSdkPath(state.config.system) : null;
42
+ return {
43
+ recipe: { name: family.manifest.name, version: family.manifest.version, nativeVersion: manifest.nativeVersion },
44
+ source: { url: getURL(manifest.nativeVersion), sha256 },
45
+ environment: {
46
+ builder: wasiSdk ? 'host' : 'docker',
47
+ // The digest-pinned image is the canonical reproduction environment either way.
48
+ dockerImage: getDockerImage(imageRoleFor(target)),
49
+ ...(wasiSdk ? { hostPlatform: `${os.platform()}-${os.arch()}`, toolchain: wasiToolchainIdentity(wasiSdk) } : {}),
50
+ },
51
+ // Full statically-linked component inventory with per-source hashes (contract E).
52
+ sbom: `dist/prebuilt/${target.path}/sbom.cdx.json`,
53
+ };
54
+ }
@@ -0,0 +1,105 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { getContentHash } from './hash.js';
3
+
4
+ // Digest-pinned so a re-pushed tag can't swap the compiler; IMAGE_VERSION only names containers
5
+ // and records which release these digests came from - bump every pin via scripts/pin-docker-image.js.
6
+ const REGISTRY = 'ghcr.io/crossbind';
7
+ const IMAGE_VERSION = '1.0.2';
8
+
9
+ // One entry per role in the image family. `ref` is the multi-arch index, which a native pull
10
+ // resolves per platform; `amd64` is that index's linux/amd64 leaf, needed wherever the CLI forces
11
+ // a platform, because the classic image store holds one platform per digest reference. The android
12
+ // image is amd64-only - Google ships the linux NDK host tools for x86_64 alone - so its index
13
+ // carries a single leaf and both of its refs resolve to the same platform.
14
+ // Generated by scripts/pin-docker-image.js from a release digest table; do not edit by hand.
15
+ const IMAGES = {
16
+ web: {
17
+ ref: `${REGISTRY}/web@sha256:c1d1d81a4775871cd90c8ac980e0a5f87cb7890822117f77be1c8345f51b1f47`,
18
+ amd64: `${REGISTRY}/web@sha256:9010948430c95120b8c19f73377f8684bc6b54e03537790f465237225e48dae2`,
19
+ },
20
+ android: {
21
+ ref: `${REGISTRY}/android@sha256:a3facbf2181f568f12a9fe89054188895665a63e2c147b790fd7e25f3e128ac8`,
22
+ amd64: `${REGISTRY}/android@sha256:4694c6210379a87d8cf0e7f8b56a76a9c74a8724a1aff037c943dcdb521f4de1`,
23
+ },
24
+ };
25
+
26
+ const OVERRIDE_KEYS = { web: 'CROSSBIND_IMAGE_WEB', android: 'CROSSBIND_IMAGE_ANDROID' };
27
+
28
+ // Which image a target compiles in: wasm and wasi share the web image, ios never reaches docker.
29
+ export function imageRoleFor(target) {
30
+ return target?.platform === 'android' ? 'android' : 'web';
31
+ }
32
+
33
+ const warned = new Set();
34
+ function warnOnce(message) {
35
+ if (warned.has(message)) return;
36
+ warned.add(message);
37
+ console.warn(message);
38
+ }
39
+
40
+ // An override is one of three distinct situations, and only the first keeps the reproducibility
41
+ // guarantee: same digest from another registry, a digest the release does not ship, or a bare tag.
42
+ function resolveRef(role, expected) {
43
+ const key = OVERRIDE_KEYS[role];
44
+ const explicit = process.env[key];
45
+ if (explicit) {
46
+ if (explicit !== expected) {
47
+ warnOnce(explicit.includes('@sha256:')
48
+ ? `crossbind: unsupported toolchain override - ${key} pins a digest the release does not ship (${explicit}).`
49
+ : `crossbind: mutable override - ${key} names the tag ${explicit}; the reproducibility guarantee is disabled.`);
50
+ }
51
+ return explicit;
52
+ }
53
+
54
+ const mirror = process.env.CROSSBIND_REGISTRY_MIRROR;
55
+ const at = expected.indexOf('@');
56
+ if (mirror && at !== -1) {
57
+ warnOnce(`crossbind: pulling from the registry mirror ${mirror} (same digest as the release).`);
58
+ return `${mirror.replace(/\/+$/, '')}/${expected.slice(0, at).split('/').pop()}${expected.slice(at)}`;
59
+ }
60
+ return expected;
61
+ }
62
+
63
+ export function getDockerImage(role = 'web', platform) {
64
+ const image = IMAGES[role];
65
+ if (!image) throw new Error(`crossbind: unknown docker image role "${role}".`);
66
+ return resolveRef(role, platform === 'linux/amd64' ? image.amd64 : image.ref);
67
+ }
68
+
69
+ // One container per image: a web container carries no NDK, and android runs a forced platform.
70
+ // The version is part of the name so an image bump cannot silently reuse a container built from
71
+ // the previous toolchain.
72
+ export function getDockerContainerName(base, role = 'web') {
73
+ return `crossbind-${IMAGE_VERSION}-${role}-${getContentHash(base)}`.replaceAll('/', '-').replaceAll(':', '-');
74
+ }
75
+
76
+ const pulledRefs = new Set();
77
+
78
+ // `docker images -q` can't resolve digest refs; `inspect` handles both.
79
+ function isImagePresent(ref) {
80
+ try {
81
+ execFileSync('docker', ['image', 'inspect', ref], { stdio: 'ignore' });
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ export default function pullDockerImage(role = 'web', platform) {
89
+ const ref = getDockerImage(role, platform);
90
+ if (pulledRefs.has(ref)) return;
91
+
92
+ if (!isImagePresent(ref)) {
93
+ console.log('');
94
+ console.log('===========================================================');
95
+ console.log('============= Downloading the docker image... =============');
96
+ console.log('===========================================================');
97
+ console.log('');
98
+ execFileSync('docker', ['pull', ref], { stdio: 'inherit' });
99
+ console.log('');
100
+ console.log('===========================================================');
101
+ console.log('');
102
+ }
103
+
104
+ pulledRefs.add(ref);
105
+ }
@@ -0,0 +1,18 @@
1
+ export const DOCKER_BASE = '/tmp/crossbind/live';
2
+
3
+ export default function replaceBasePathForDocker(data, basePath) {
4
+ if (typeof data === 'string' || data instanceof String) {
5
+ return data.replaceAll(basePath, DOCKER_BASE);
6
+ }
7
+ if (Array.isArray(data)) {
8
+ return data.map((d) => replaceBasePathForDocker(d, basePath));
9
+ }
10
+ if (typeof data === 'object' && data !== null) {
11
+ const newData = {};
12
+ Object.entries(data).forEach(([key, value]) => {
13
+ newData[key] = replaceBasePathForDocker(value, basePath);
14
+ });
15
+ return newData;
16
+ }
17
+ return data;
18
+ }
@@ -0,0 +1,66 @@
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 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.
11
+ 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
+
20
+ let cached = null;
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
+ }
58
+
59
+ // The package version, stamped into generated bridge manifests for debuggability.
60
+ export function embindRustVersion() {
61
+ try {
62
+ return JSON.parse(fs.readFileSync(`${resolveEmbindRustRoot()}/package.json`, 'utf8')).version ?? 'unknown';
63
+ } catch (e) {
64
+ return 'unknown';
65
+ }
66
+ }