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.
Files changed (107) hide show
  1. package/README.md +2 -2
  2. package/package.json +6 -1
  3. package/src/actions/buildDependencies.js +15 -2
  4. package/src/actions/buildExternal.js +9 -1
  5. package/src/actions/buildLib.js +25 -8
  6. package/src/actions/buildNativeCommand.js +84 -0
  7. package/src/actions/buildNode.js +48 -7
  8. package/src/actions/buildTypes.js +8 -2
  9. package/src/actions/buildWasiCommand.js +0 -4
  10. package/src/actions/buildWasm.js +49 -29
  11. package/src/actions/cloudCommands.js +133 -0
  12. package/src/actions/createImportedBridges.js +64 -0
  13. package/src/actions/createInterface.js +247 -68
  14. package/src/actions/createLib.js +57 -41
  15. package/src/actions/getData.js +4 -1
  16. package/src/actions/getLinkInputs.js +12 -5
  17. package/src/actions/isSourceNewer.js +2 -0
  18. package/src/actions/licenses.js +143 -19
  19. package/src/actions/prepareConanDependencies.js +51 -0
  20. package/src/actions/publishBinary.js +32 -0
  21. package/src/actions/run.js +63 -66
  22. package/src/actions/runnerCommands.js +421 -0
  23. package/src/actions/writeRuntimeEntry.js +69 -0
  24. package/src/assets/cmake/command/CMakeLists.txt +21 -0
  25. package/src/assets/cmake/command/command.cpp +1 -0
  26. package/src/assets/cmake/dist.cmake +2 -1
  27. package/src/assets/configure/install.sh +22 -0
  28. package/src/assets/js-runtime/adapters/path-url.js +6 -3
  29. package/src/assets/js-runtime/adapters/unboundMessage.js +2 -0
  30. package/src/assets/js-runtime/adapters/worker-comlink.js +87 -60
  31. package/src/assets/js-runtime/core.js +11 -0
  32. package/src/assets/node-import-hooks.mjs +61 -0
  33. package/src/assets/toolchain-digests.json +19 -19
  34. package/src/assets/wasi-runtime/stubs.c +29 -0
  35. package/src/bin.js +257 -50
  36. package/src/index.js +3 -0
  37. package/src/integration/bindHeaderImports.js +18 -0
  38. package/src/integration/getCrossbindScript.js +46 -10
  39. package/src/integration/getDependFilePath.js +9 -0
  40. package/src/integration/resolveNativeImport.js +14 -0
  41. package/src/node/dev.mjs +19 -0
  42. package/src/runner/blobs.js +135 -0
  43. package/src/runner/files.js +193 -0
  44. package/src/runner/server.js +320 -0
  45. package/src/state/attachConanDependencies.js +56 -0
  46. package/src/state/index.js +17 -9
  47. package/src/state/loadConfig.js +33 -24
  48. package/src/state/refreshBuiltDependencies.js +20 -0
  49. package/src/state/refreshConanDependencies.js +17 -0
  50. package/src/utils/androidToolchain.js +4 -0
  51. package/src/utils/appRustCrates.js +15 -1
  52. package/src/utils/appSources.js +97 -6
  53. package/src/utils/archiveFlags.js +12 -0
  54. package/src/utils/boundHeaders.js +79 -0
  55. package/src/utils/bridgeExtras.js +486 -0
  56. package/src/utils/bridgeRuntimeFixes.js +48 -0
  57. package/src/utils/bundledLicenses.js +26 -0
  58. package/src/utils/cargoTarget.js +21 -3
  59. package/src/utils/cloudAccount.js +55 -0
  60. package/src/utils/conanDependencies.js +66 -0
  61. package/src/utils/conanImport.js +58 -0
  62. package/src/utils/conanInstall.js +154 -0
  63. package/src/utils/conanProfile.js +203 -0
  64. package/src/utils/conanStage.js +292 -0
  65. package/src/utils/copyToDistSource.js +13 -0
  66. package/src/utils/cppDts.js +58 -15
  67. package/src/utils/darwinToolchain.js +11 -0
  68. package/src/utils/dirLock.js +137 -28
  69. package/src/utils/downloadAndExtractFile.js +34 -7
  70. package/src/utils/embindArgumentGuards.js +41 -2
  71. package/src/utils/embindCallArguments.js +19 -0
  72. package/src/utils/embindJspiQueue.js +25 -0
  73. package/src/utils/execContainer.js +49 -0
  74. package/src/utils/headerImports.js +16 -5
  75. package/src/utils/hostPlatform.js +21 -0
  76. package/src/utils/iosToolchain.js +6 -0
  77. package/src/utils/isSourceCmakePackage.js +10 -0
  78. package/src/utils/licenseReport.js +11 -2
  79. package/src/utils/linuxToolchain.js +24 -0
  80. package/src/utils/makeTreeWritable.js +12 -0
  81. package/src/utils/nativeImports.js +23 -0
  82. package/src/utils/nativeSourceStamp.js +36 -0
  83. package/src/utils/nodeDevBuild.js +92 -0
  84. package/src/utils/packageLicense.js +101 -0
  85. package/src/utils/pthreadMainScript.js +14 -0
  86. package/src/utils/pullDockerImage.js +3 -1
  87. package/src/utils/relocatePrebuilt.js +119 -0
  88. package/src/utils/remoteClient.js +127 -0
  89. package/src/utils/remoteOutputs.js +39 -0
  90. package/src/utils/remoteRunner.js +97 -0
  91. package/src/utils/remoteUpload.js +134 -0
  92. package/src/utils/replaceFile.js +8 -0
  93. package/src/utils/runCargo.js +48 -51
  94. package/src/utils/runConan.js +257 -0
  95. package/src/utils/runtimeEntries.js +56 -0
  96. package/src/utils/rustBridgeGen.js +9 -4
  97. package/src/utils/rustMt.js +4 -0
  98. package/src/utils/scopedEmbind.js +22 -0
  99. package/src/utils/selectRunner.js +81 -0
  100. package/src/utils/sourceFingerprint.js +4 -3
  101. package/src/utils/swigInterface.js +69 -4
  102. package/src/utils/systemKeys.js +35 -3
  103. package/src/utils/targets.js +49 -4
  104. package/src/utils/toolchainNotices.js +5 -0
  105. package/src/utils/wasiToolchain.js +14 -1
  106. package/src/utils/windowsToolchain.js +18 -0
  107. package/vitest.config.js +11 -0
@@ -0,0 +1,421 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import zlib from 'node:zlib';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import pullDockerImage, { getDockerImage } from '../utils/pullDockerImage.js';
8
+ import { DOCKER_RUN_SECURITY_ARGS } from '../utils/dockerSecurity.js';
9
+ import { remoteVariables } from '../utils/selectRunner.js';
10
+ import { MIN_TOKEN_LENGTH } from '../runner/server.js';
11
+
12
+ // `crossbind runner`: a runner is the toolchain image this CLI pins plus the server in src/runner, which
13
+ // reaches the image mounted (start), copied by a generated Dockerfile or packed into Cloud Run or Azure variables
14
+ // (init); no runner image is published.
15
+
16
+ export const RUNNER_ROLES = ['web', 'android', 'linux', 'windows'];
17
+ const RUNNER_PORT = 8787;
18
+ const RUNNER_SOURCE = fileURLToPath(new URL('../runner', import.meta.url));
19
+ const CONTAINER_RUNNER = '/opt/crossbind/runner';
20
+ const TOKEN_BYTES = 24;
21
+ const FLY_SUFFIX_BYTES = 3;
22
+ // Cloudflare sizes a custom container by its vCPUs: at least 3 GiB of memory each, at most 2 GB of disk per GiB,
23
+ // up to 20 GB. Memory is most of the price while a container runs, so a runner takes the least.
24
+ const CLOUDFLARE_MAX_VCPU = 4;
25
+ const CLOUDFLARE_MIB_PER_VCPU = 3072;
26
+ const CLOUDFLARE_DISK_MB_PER_VCPU = 6000;
27
+ const CLOUDFLARE_MAX_DISK_MB = 20000;
28
+ // Cloud Run and Azure deploy the pinned image straight from its registry. One variable carries the runner gzipped
29
+ // (Cloud Run allows 32 KB each) and another a boot script that unpacks it and starts the server.
30
+ const BOOT_SOURCE = '/tmp/crossbind-runner-src';
31
+ const BOOT_UNPACK = 'const fs = require("node:fs"); const path = require("node:path"); '
32
+ + 'for (const [name, text] of Object.entries(JSON.parse(fs.readFileSync(0, "utf8")))) fs.writeFileSync(path.join(process.argv[1], name), text);';
33
+ const BOOT_SCRIPT = [
34
+ 'set -e',
35
+ `dir=${BOOT_SOURCE}`,
36
+ 'mkdir -p "$dir"',
37
+ `printf %s "$CROSSBIND_RUNNER_BUNDLE" | base64 -d | gunzip | node -e '${BOOT_UNPACK}' "$dir"`,
38
+ 'exec node "$dir/server.js"',
39
+ ].join('\n');
40
+ const VERSION = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
41
+
42
+ const runnerName = (role) => `crossbind-runner-${role}`;
43
+ const runnerFiles = () => fs.readdirSync(RUNNER_SOURCE).filter((file) => file.endsWith('.js'));
44
+ // Google ships the linux NDK for x86_64 only, so an android runner is always amd64.
45
+ const platformOf = (role) => (role === 'android' ? 'linux/amd64' : undefined);
46
+ const runnerImage = (role) => getDockerImage(role, platformOf(role));
47
+
48
+ export const newRunnerToken = () => crypto.randomBytes(TOKEN_BYTES).toString('base64url');
49
+
50
+ // Each image has its own host port, so the runners one build needs can share a machine.
51
+ export const runnerHostPort = (role) => RUNNER_PORT + RUNNER_ROLES.indexOf(role);
52
+
53
+ export function connectCommands(role, url, token) {
54
+ const variables = remoteVariables(role);
55
+ return ['crossbind config set RUNNER REMOTE', `crossbind config set ${variables.key} ${url}`, `export ${variables.token}=${token}`];
56
+ }
57
+
58
+ function assertRole(role) {
59
+ if (!RUNNER_ROLES.includes(role)) throw new Error(`crossbind: unknown image "${role}" - expected one of ${RUNNER_ROLES.join(', ')}.`);
60
+ }
61
+
62
+ export function runnerStartArgs({ role, port, host }) {
63
+ const platform = platformOf(role);
64
+ return [
65
+ 'run', '-d', '--name', runnerName(role), '--restart', 'unless-stopped',
66
+ ...(platform ? ['--platform', platform] : []),
67
+ ...DOCKER_RUN_SECURITY_ARGS,
68
+ '-p', `${host}:${port}:${RUNNER_PORT}`,
69
+ '-v', `${RUNNER_SOURCE}:${CONTAINER_RUNNER}:ro`,
70
+ // By name only: docker reads the value from its own environment, so the token stays off the command line.
71
+ '-e', 'CROSSBIND_RUNNER_TOKEN',
72
+ '-e', `CROSSBIND_RUNNER_IMAGE=${runnerImage(role)}`,
73
+ '-e', `CROSSBIND_RUNNER_ROLE=${role}`,
74
+ '--entrypoint', 'node',
75
+ runnerImage(role),
76
+ `${CONTAINER_RUNNER}/server.js`,
77
+ ];
78
+ }
79
+
80
+ export function startRunner({ role = 'web', port = runnerHostPort(role), host = '127.0.0.1', token } = {}) {
81
+ assertRole(role);
82
+ const fromEnvironment = !token && process.env.CROSSBIND_RUNNER_TOKEN;
83
+ const secret = token || fromEnvironment || newRunnerToken();
84
+ if (secret.length < MIN_TOKEN_LENGTH) throw new Error(`crossbind: a runner token needs at least ${MIN_TOKEN_LENGTH} characters.`);
85
+ pullDockerImage(role, platformOf(role));
86
+ execFileSync('docker', runnerStartArgs({ role, port, host }), {
87
+ stdio: ['ignore', 'ignore', 'inherit'],
88
+ env: { ...process.env, CROSSBIND_RUNNER_TOKEN: secret },
89
+ });
90
+ // A token taken from the environment is shown by its variable, so the line printed for it puts no secret in a log.
91
+ return {
92
+ name: runnerName(role), url: `http://${host}:${port}`, token: secret, displayToken: fromEnvironment ? '$CROSSBIND_RUNNER_TOKEN' : secret,
93
+ };
94
+ }
95
+
96
+ export function stopRunner({ role = 'web' } = {}) {
97
+ assertRole(role);
98
+ execFileSync('docker', ['rm', '-f', runnerName(role)], { stdio: 'inherit' });
99
+ }
100
+
101
+ function dockerfile(role) {
102
+ const platform = platformOf(role);
103
+ return [
104
+ `# Generated by crossbind ${VERSION}: the ${role} toolchain image this CLI pins, plus its runner.`,
105
+ `FROM ${platform ? `--platform=${platform} ` : ''}${runnerImage(role)}`,
106
+ `COPY runner ${CONTAINER_RUNNER}`,
107
+ `ENV CROSSBIND_RUNNER_IMAGE=${runnerImage(role)} CROSSBIND_RUNNER_ROLE=${role}`,
108
+ `EXPOSE ${RUNNER_PORT}`,
109
+ `ENTRYPOINT ["node", "${CONTAINER_RUNNER}/server.js"]`,
110
+ '',
111
+ ].join('\n');
112
+ }
113
+
114
+ // One machine: a runner keeps its build tree on its own disk, so a second machine would hold another tree.
115
+ function flyToml(app) {
116
+ return [
117
+ `# Generated by crossbind ${VERSION}. Create the app with \`fly launch --copy-config --no-deploy --ha=false\`,`,
118
+ '# set its token with `fly secrets set CROSSBIND_RUNNER_TOKEN=<token>`, then run `fly deploy --ha=false`.',
119
+ `app = "${app}"`,
120
+ '',
121
+ '[build]',
122
+ ' dockerfile = "Dockerfile"',
123
+ '',
124
+ '[http_service]',
125
+ ` internal_port = ${RUNNER_PORT}`,
126
+ ' force_https = true',
127
+ ' auto_stop_machines = "stop"',
128
+ ' auto_start_machines = true',
129
+ ' min_machines_running = 0',
130
+ '',
131
+ '[[vm]]',
132
+ ' size = "performance-4x"',
133
+ ' memory = "16gb"',
134
+ '',
135
+ ].join('\n');
136
+ }
137
+
138
+ // Cloudflare's GA scheduling (`default` policy), driven through the Durable Object container API, so the
139
+ // folder needs no npm dependency. One container: a runner keeps one build tree.
140
+ function cloudflareInstance(vcpu) {
141
+ if (vcpu === undefined) return '"standard-4"';
142
+ const disk = Math.min(CLOUDFLARE_MAX_DISK_MB, vcpu * CLOUDFLARE_DISK_MB_PER_VCPU);
143
+ return `{ "vcpu": ${vcpu}, "memory_mib": ${vcpu * CLOUDFLARE_MIB_PER_VCPU}, "disk_mb": ${disk} }`;
144
+ }
145
+
146
+ function wranglerConfig(app, vcpu) {
147
+ return [
148
+ `// Generated by crossbind ${VERSION}. \`npx wrangler deploy\` builds the image with the local Docker, then`,
149
+ '// `npx wrangler secret put CROSSBIND_RUNNER_TOKEN` sets the token builds must send.',
150
+ '{',
151
+ ` "name": "${app}",`,
152
+ ' "main": "src/worker.js",',
153
+ ' "compatibility_date": "2026-09-01",',
154
+ ' "containers": [',
155
+ ' {',
156
+ ' "class_name": "CrossbindRunner",',
157
+ ' "image": "./Dockerfile",',
158
+ ` "instance_type": ${cloudflareInstance(vcpu)},`,
159
+ ' "max_instances": 1',
160
+ ' }',
161
+ ' ],',
162
+ ' "durable_objects": {',
163
+ ' "bindings": [{ "name": "RUNNER", "class_name": "CrossbindRunner" }]',
164
+ ' },',
165
+ ' "exports": {',
166
+ ' "CrossbindRunner": { "type": "durable-object", "storage": "sqlite" }',
167
+ ' }',
168
+ '}',
169
+ '',
170
+ ].join('\n');
171
+ }
172
+
173
+ function cloudflareWorker(role) {
174
+ return [
175
+ `// Generated by crossbind ${VERSION}: a Worker in front of one ${role} runner container.`,
176
+ "import { DurableObject } from 'cloudflare:workers';",
177
+ '',
178
+ `const RUNNER_PORT = ${RUNNER_PORT};`,
179
+ '// A cold start pulls the toolchain image first; the wait stays below the 300 s a client waits for an answer.',
180
+ 'const READY_ATTEMPTS = 480;',
181
+ 'const READY_WAIT_MS = 500;',
182
+ '// A step streams a line at least every 15 s, its heartbeat; a minute without one means no step is running.',
183
+ 'const KEEPALIVE_MS = 10000;',
184
+ 'const IDLE_AFTER_MS = 60000;',
185
+ '',
186
+ 'const wait = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });',
187
+ 'const encoder = new TextEncoder();',
188
+ '',
189
+ '// Constant time, so the answer does not tell how much of a guess was right.',
190
+ 'function tokenMatches(header, token) {',
191
+ " const actual = encoder.encode(header ?? '');",
192
+ " const expected = encoder.encode('Bearer ' + token);",
193
+ ' return actual.byteLength === expected.byteLength && crypto.subtle.timingSafeEqual(actual, expected);',
194
+ '}',
195
+ '',
196
+ 'export class CrossbindRunner extends DurableObject {',
197
+ ' async fetch(request) {',
198
+ ' if (!this.ctx.container.running) this.ready = null;',
199
+ ' this.ready ??= this.start().catch((error) => {',
200
+ ' this.ready = null;',
201
+ ' throw error;',
202
+ ' });',
203
+ ' await this.ready;',
204
+ ' // The container port takes plain http; the Worker itself is reached over https.',
205
+ ' const url = new URL(request.url);',
206
+ " url.protocol = 'http:';",
207
+ ' const response = await this.ctx.container.getTcpPort(RUNNER_PORT).fetch(new Request(url, request));',
208
+ ' if (!response.body) return response;',
209
+ ' this.streamed();',
210
+ ' const body = response.body.pipeThrough(new TransformStream({',
211
+ ' transform: (chunk, controller) => {',
212
+ ' this.streamed();',
213
+ ' controller.enqueue(chunk);',
214
+ ' },',
215
+ ' }));',
216
+ ' return new Response(body, response);',
217
+ ' }',
218
+ '',
219
+ ' // Work inside the container is no activity for this object, and an idle object takes its container down',
220
+ ' // with it: while a step streams, an alarm keeps both up.',
221
+ ' streamed() {',
222
+ ' this.lastStreamed = Date.now();',
223
+ ' if (this.alarmSet) return;',
224
+ ' this.alarmSet = true;',
225
+ ' this.ctx.storage.setAlarm(Date.now() + KEEPALIVE_MS).catch(() => {',
226
+ ' this.alarmSet = false;',
227
+ ' });',
228
+ ' }',
229
+ '',
230
+ ' async alarm() {',
231
+ ' this.alarmSet = false;',
232
+ ' if (Date.now() - this.lastStreamed < IDLE_AFTER_MS) {',
233
+ ' this.alarmSet = true;',
234
+ ' await this.ctx.storage.setAlarm(Date.now() + KEEPALIVE_MS);',
235
+ ' }',
236
+ ' }',
237
+ '',
238
+ ' async start() {',
239
+ ' // Builds fetch crates, conan packages and library sources, so the container reaches the internet.',
240
+ ' if (!this.ctx.container.running) {',
241
+ ' this.ctx.container.start({ enableInternet: true, env: { CROSSBIND_RUNNER_TOKEN: this.env.CROSSBIND_RUNNER_TOKEN } });',
242
+ ' }',
243
+ ' const port = this.ctx.container.getTcpPort(RUNNER_PORT);',
244
+ ' for (let attempt = 0; attempt < READY_ATTEMPTS; attempt += 1) {',
245
+ ' try {',
246
+ " if ((await port.fetch('http://runner/v1/health')).ok) return;",
247
+ ' } catch {',
248
+ ' // The runner inside is still starting.',
249
+ ' }',
250
+ ' await wait(READY_WAIT_MS);',
251
+ ' }',
252
+ " throw new Error('crossbind runner: the container did not start');",
253
+ ' }',
254
+ '}',
255
+ '',
256
+ 'export default {',
257
+ ' async fetch(request, env) {',
258
+ ' // The runner checks the token too; checking it here keeps a stranger from waking the container.',
259
+ " if (!env.CROSSBIND_RUNNER_TOKEN || !tokenMatches(request.headers.get('authorization'), env.CROSSBIND_RUNNER_TOKEN)) {",
260
+ " return Response.json({ error: 'invalid token' }, { status: 401 });",
261
+ ' }',
262
+ " return env.RUNNER.getByName('runner').fetch(request);",
263
+ ' },',
264
+ '};',
265
+ '',
266
+ ].join('\n');
267
+ }
268
+
269
+ function bootVariables(role) {
270
+ const files = Object.fromEntries(runnerFiles().map((file) => [file, fs.readFileSync(path.join(RUNNER_SOURCE, file), 'utf8')]));
271
+ return {
272
+ CROSSBIND_RUNNER_IMAGE: runnerImage(role),
273
+ CROSSBIND_RUNNER_ROLE: role,
274
+ CROSSBIND_RUNNER_BOOT: BOOT_SCRIPT,
275
+ CROSSBIND_RUNNER_BUNDLE: zlib.gzipSync(JSON.stringify(files)).toString('base64'),
276
+ };
277
+ }
278
+
279
+ function cloudRunEnv(role) {
280
+ return [
281
+ `# Generated by crossbind ${VERSION}: the variables of a Cloud Run ${role} runner. The container runs`,
282
+ '# CROSSBIND_RUNNER_BOOT, which unpacks the runner from CROSSBIND_RUNNER_BUNDLE and starts it.',
283
+ ...Object.entries(bootVariables(role)).map(([name, value]) => `${name}: ${JSON.stringify(value)}`),
284
+ '',
285
+ ].join('\n');
286
+ }
287
+
288
+ // One replica at most, scaled to zero when idle. The token reaches the app's secrets only as the app is created.
289
+ function azureApp(role) {
290
+ const variables = bootVariables(role);
291
+ return [
292
+ `# Generated by crossbind ${VERSION}: an Azure Container Apps ${role} runner. The deploy step fills in <token>.`,
293
+ 'properties:',
294
+ ' workloadProfileName: Consumption',
295
+ ' configuration:',
296
+ ' activeRevisionsMode: Single',
297
+ ' ingress:',
298
+ ' external: true',
299
+ ` targetPort: ${RUNNER_PORT}`,
300
+ ' transport: auto',
301
+ ' # Left unset, it makes Azure refuse the app with an error that names no field.',
302
+ ' allowInsecure: false',
303
+ ' secrets:',
304
+ ' - name: runner-token',
305
+ ' value: "<token>"',
306
+ ' template:',
307
+ ' containers:',
308
+ ' - name: runner',
309
+ ` image: ${JSON.stringify(variables.CROSSBIND_RUNNER_IMAGE)}`,
310
+ ` command: ["sh", "-c", ${JSON.stringify('eval "$CROSSBIND_RUNNER_BOOT"')}]`,
311
+ ' resources:',
312
+ ' cpu: 4',
313
+ ' memory: 8Gi',
314
+ ' env:',
315
+ ' - name: CROSSBIND_RUNNER_TOKEN',
316
+ ' secretRef: runner-token',
317
+ ...Object.entries(variables).flatMap(([name, value]) => [` - name: ${name}`, ` value: ${JSON.stringify(value)}`]),
318
+ ' scale:',
319
+ ' minReplicas: 0',
320
+ ' maxReplicas: 1',
321
+ '',
322
+ ].join('\n');
323
+ }
324
+
325
+ function azureSteps(dir, token, role) {
326
+ return [
327
+ `cd ${dir}`,
328
+ 'RG=crossbind-runner LOCATION=germanywestcentral (any Container Apps region that takes new apps)',
329
+ 'az provider register -n Microsoft.App --wait',
330
+ 'az group create -n $RG -l $LOCATION',
331
+ 'az containerapp env create -n crossbind-runner -g $RG -l $LOCATION --logs-destination none (the first one can take 20 minutes)',
332
+ `sed 's/<token>/${token}/' containerapp.yaml | az containerapp create -n ${runnerName(role)} -g $RG --environment crossbind-runner --yaml /dev/stdin`,
333
+ ];
334
+ }
335
+
336
+ // One instance (--max is the service's limit, which the regional CPU quota is checked against; --max-instances the
337
+ // revision's), an hour per step, and the token in Secret Manager, read by an account that may read nothing else:
338
+ // the default compute account can hold broad roles, and a build step could take its token from the metadata server.
339
+ function cloudRunSteps(dir, token, role) {
340
+ const app = runnerName(role);
341
+ const account = `${app}@$PROJECT.iam.gserviceaccount.com`;
342
+ return [
343
+ `cd ${dir}`,
344
+ 'PROJECT=$(gcloud config get-value project) REGION=europe-west1 (any Cloud Run region)',
345
+ 'gcloud services enable run.googleapis.com secretmanager.googleapis.com iam.googleapis.com',
346
+ `gcloud iam service-accounts create ${app}`,
347
+ `printf %s ${token} | gcloud secrets create ${app}-token --data-file=-`,
348
+ `gcloud secrets add-iam-policy-binding ${app}-token --member=serviceAccount:${account} --role=roles/secretmanager.secretAccessor`,
349
+ `gcloud run deploy ${app} --image ${runnerImage(role)} --region $REGION --execution-environment gen2 --cpu 4 --memory 8Gi`
350
+ + ` --port ${RUNNER_PORT} --max 1 --max-instances 1 --timeout 3600 --allow-unauthenticated --service-account ${account}`
351
+ + ` --set-secrets CROSSBIND_RUNNER_TOKEN=${app}-token:latest --env-vars-file env.yaml --command sh --args='-c,eval "$CROSSBIND_RUNNER_BOOT"'`,
352
+ ];
353
+ }
354
+
355
+ const PLATFORMS = {
356
+ azure: {
357
+ appName: runnerName,
358
+ url: (app) => `https://${app}.<environment-domain>`,
359
+ files: (role) => ({ 'containerapp.yaml': azureApp(role) }),
360
+ steps: azureSteps,
361
+ },
362
+ cloudflare: {
363
+ appName: (role) => `crossbind-runner-${role}`,
364
+ url: (app) => `https://${app}.<account>.workers.dev`,
365
+ files: (role, app, vcpu) => ({ Dockerfile: dockerfile(role), 'wrangler.jsonc': wranglerConfig(app, vcpu), 'src/worker.js': cloudflareWorker(role) }),
366
+ steps: (dir, token) => [
367
+ `cd ${dir}`,
368
+ 'npx wrangler deploy (Docker must be running: wrangler builds the image locally)',
369
+ `npx wrangler secret put CROSSBIND_RUNNER_TOKEN (paste ${token})`,
370
+ ],
371
+ },
372
+ cloudrun: {
373
+ appName: runnerName,
374
+ url: (app) => `https://${app}-<project-number>.<region>.run.app`,
375
+ files: (role) => ({ 'env.yaml': cloudRunEnv(role) }),
376
+ steps: cloudRunSteps,
377
+ },
378
+ fly: {
379
+ // Fly app names are global, and fly starts the machine for any request, one without the token too: the
380
+ // random part keeps the name free and the address hard to guess.
381
+ appName: (role) => `crossbind-runner-${role}-${crypto.randomBytes(FLY_SUFFIX_BYTES).toString('hex')}`,
382
+ url: (app) => `https://${app}.fly.dev`,
383
+ files: (role, app) => ({ Dockerfile: dockerfile(role), 'fly.toml': flyToml(app) }),
384
+ steps: (dir, token) => [
385
+ `cd ${dir}`,
386
+ 'fly launch --copy-config --no-deploy --ha=false',
387
+ `fly secrets set CROSSBIND_RUNNER_TOKEN=${token}`,
388
+ 'fly deploy --ha=false',
389
+ ],
390
+ },
391
+ };
392
+
393
+ export const RUNNER_PLATFORMS = Object.keys(PLATFORMS);
394
+
395
+ export const deploySteps = (platform, dir, token, role, url) => [
396
+ ...PLATFORMS[platform].steps(dir, token, role),
397
+ 'then build against it:',
398
+ ...connectCommands(role, url, token),
399
+ ];
400
+
401
+ export function initRunner({
402
+ platform, role = 'web', dir, vcpu,
403
+ }) {
404
+ assertRole(role);
405
+ if (!PLATFORMS[platform]) throw new Error(`crossbind: runner init knows ${RUNNER_PLATFORMS.join(', ')}, not ${platform}.`);
406
+ if (vcpu !== undefined && platform !== 'cloudflare') {
407
+ throw new Error('crossbind: --vcpu sizes a cloudflare container; fly.toml, containerapp.yaml and the Cloud Run deploy command set the size of the others.');
408
+ }
409
+ if (vcpu !== undefined && !(Number.isInteger(vcpu) && vcpu >= 1 && vcpu <= CLOUDFLARE_MAX_VCPU)) {
410
+ throw new Error(`crossbind: a cloudflare runner takes 1 to ${CLOUDFLARE_MAX_VCPU} vCPUs, not ${vcpu}.`);
411
+ }
412
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0) throw new Error(`crossbind: ${dir} is not empty; runner init writes a fresh folder.`);
413
+ const app = PLATFORMS[platform].appName(role);
414
+ fs.mkdirSync(path.join(dir, 'runner'), { recursive: true });
415
+ runnerFiles().forEach((file) => fs.copyFileSync(path.join(RUNNER_SOURCE, file), path.join(dir, 'runner', file)));
416
+ Object.entries(PLATFORMS[platform].files(role, app, vcpu)).forEach(([name, text]) => {
417
+ fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
418
+ fs.writeFileSync(path.join(dir, name), text);
419
+ });
420
+ return { dir, url: PLATFORMS[platform].url(app) };
421
+ }
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+ import upath from 'upath';
3
+ import loadJson from '../utils/loadJson.js';
4
+ import writeIfChanged from '../utils/writeIfChanged.js';
5
+ import { parseCppSurface, emitCppDts } from '../utils/cppDts.js';
6
+ import { headerAliases } from '../utils/boundHeaders.js';
7
+ import { runtimeEntryModule, uniqueBindings } from '../utils/runtimeEntries.js';
8
+
9
+ function namesOf({ file, bridge, names: rustNames }) {
10
+ if (rustNames) return rustNames;
11
+ const names = loadJson(`${bridge}.exports.json`);
12
+ if (!names) throw new Error(`crossbind: ${file} left no export list beside its bridge (${bridge}.exports.json).`);
13
+ return [...names, ...headerAliases(fs.readFileSync(file, 'utf8'), names)];
14
+ }
15
+
16
+ function surfaceOf(headers) {
17
+ const classes = new Map();
18
+ headers.filter(({ file, names }) => !names && !file.endsWith('.i')).forEach(({ file }) => {
19
+ parseCppSurface(fs.readFileSync(file, 'utf8'), () => {}).classes
20
+ .forEach((cls) => { if (!classes.has(cls.name)) classes.set(cls.name, cls); });
21
+ });
22
+ return { classes: [...classes.values()] };
23
+ }
24
+
25
+ // A Rust import's names go out under the names they are registered by: two crates may both export `Version`, and
26
+ // the import hooks give each crate's import its own.
27
+ const registeredNamesOf = (header) => (header.names ? header.names.map((name) => (typeof name === 'string' ? name : name.wire)) : namesOf(header));
28
+
29
+ // Writes the entry module of one runtime binary and its declarations, from the headers bound into it,
30
+ // each { file, bridge }, and the Rust imports linked into it, each { file, names }.
31
+ export default function writeRuntimeEntry(entry, headers, runtime) {
32
+ const names = headers.flatMap(registeredNamesOf);
33
+ writeIfChanged(entry, runtimeEntryModule(names, runtime));
34
+ writeIfChanged(entry.replace(/\.mjs$/, '.d.mts'), emitCppDts(surfaceOf(headers), uniqueBindings(names).map(({ local }) => local), 'sync'));
35
+ }
36
+
37
+ const IMPORT_HOOKS = new URL('../assets/node-import-hooks.mjs', import.meta.url);
38
+
39
+ // The module `node --import` loads ahead of the app, so the app imports the bound headers themselves: each
40
+ // header by its real path, with the bare specifiers that name it (imports, from findAppNativeImports) and its
41
+ // names, each with the entry's name for it where the two differ.
42
+ export function writeImportHooks(entry, headers, imports, extensions) {
43
+ // Node runs the register file from its real path, so the files it names are relative to that.
44
+ const dir = upath.normalize(fs.realpathSync(upath.dirname(entry)));
45
+ const natives = headers.map((header) => {
46
+ const file = upath.normalize(fs.realpathSync(header.file));
47
+ return {
48
+ file: upath.relative(dir, file),
49
+ specifiers: imports.get(file)?.specifiers ?? [],
50
+ names: uniqueBindings(namesOf(header)).map(({ local, wire }) => (local === wire ? local : { local, wire })),
51
+ };
52
+ });
53
+ writeIfChanged(`${dir}/hooks.mjs`, fs.readFileSync(IMPORT_HOOKS, 'utf8'));
54
+ writeIfChanged(entry.replace(/\.mjs$/, '.register.mjs'), [
55
+ '// Generated by crossbind - do not edit.',
56
+ "import registerNativeImports from './hooks.mjs';",
57
+ '',
58
+ `registerNativeImports(new URL('./${upath.basename(entry)}', import.meta.url), ${JSON.stringify({ extensions, natives }, null, 4)});`,
59
+ '',
60
+ ].join('\n'));
61
+ }
62
+
63
+ // Once the sources import nothing native, no start loads hooks an earlier build left; hooks.mjs stays while the other
64
+ // binary's hooks still import it.
65
+ export function removeImportHooks(entry) {
66
+ const dir = upath.dirname(entry);
67
+ fs.rmSync(entry.replace(/\.mjs$/, '.register.mjs'), { force: true });
68
+ if (fs.existsSync(dir) && !fs.readdirSync(dir).some((file) => file.endsWith('.register.mjs'))) fs.rmSync(`${dir}/hooks.mjs`, { force: true });
69
+ }
@@ -0,0 +1,21 @@
1
+ cmake_minimum_required(VERSION 3.20)
2
+ project(crossbind_command CXX)
3
+
4
+ # main() comes from the project's archive, which CROSSBIND_LINK_ARGS links whole; this source only
5
+ # gives CMake a target to link.
6
+ add_executable(command command.cpp)
7
+ set_target_properties(command PROPERTIES OUTPUT_NAME "${CROSSBIND_COMMAND_FILE}" SUFFIX "")
8
+ target_link_libraries(command PRIVATE ${CROSSBIND_LINK_ARGS})
9
+ # A whole-archive input reaches the link as a linker flag, which CMake does not track.
10
+ set_property(TARGET command APPEND PROPERTY LINK_DEPENDS ${CROSSBIND_LINK_DEPENDS})
11
+
12
+ if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
13
+ find_package(Threads REQUIRED)
14
+ target_link_libraries(command PRIVATE Threads::Threads ${CMAKE_DL_LIBS})
15
+ endif()
16
+
17
+ # A musl executable links fully static, so one binary runs on every Linux distribution; MinGW keeps
18
+ # its C++ runtime and winpthreads in the executable, as the addon does.
19
+ if(CROSSBIND_STATIC OR MINGW)
20
+ target_link_options(command PRIVATE -static)
21
+ endif()
@@ -0,0 +1 @@
1
+ // main() comes from the project's own archive.
@@ -51,7 +51,8 @@ if(NOT PACKAGE_HOST_INDEX EQUAL -1)
51
51
  target_link_libraries("${PROJECT_NAME}" INTERFACE "${PROJECT_LIBS_DIR}")
52
52
  endif()
53
53
 
54
- if(NOT (APPLE AND CMAKE_SYSTEM_NAME STREQUAL "iOS"))
54
+ # On iOS each library's xcframework carries the headers, so only a header-only package needs them here.
55
+ if(NOT (APPLE AND CMAKE_SYSTEM_NAME STREQUAL "iOS") OR PROJECT_LIBS STREQUAL "")
55
56
  target_include_directories("${PROJECT_NAME}" INTERFACE "${PROJECT_SOURCE_DIR}/${PROJECT_TARGET_HOST}/include")
56
57
  endif()
57
58
  endif()
@@ -0,0 +1,22 @@
1
+ #!/bin/sh
2
+ # Docker Desktop's file sharing now and then refuses the chmod that install makes on a file it has just
3
+ # written (EPERM), at times on every attempt. The file is in place by then and a chmod by path is not
4
+ # refused, so a failure that is nothing but refused chmods ends with those.
5
+ mode=0755
6
+ prev=
7
+ for arg in "$@"; do
8
+ case $prev in -m) mode=$arg ;; esac
9
+ case $arg in --mode=*) mode=${arg#--mode=} ;; -m?*) mode=${arg#-m} ;; esac
10
+ prev=$arg
11
+ done
12
+
13
+ { errors=$(install "$@" 2>&1 1>&3); } 3>&1 && exit 0
14
+ status=$?
15
+ refused=$(printf '%s\n' "$errors" | sed -n \
16
+ -e "s/^install: setting permissions for '\(.*\)': Operation not permitted$/\1/p" \
17
+ -e "s/^install: setting permissions for ‘\(.*\)’: Operation not permitted$/\1/p")
18
+ if [ -n "$refused" ] && [ "$(printf '%s\n' "$refused" | wc -l)" -eq "$(printf '%s\n' "$errors" | wc -l)" ]; then
19
+ printf '%s\n' "$refused" | while IFS= read -r file; do chmod "$mode" "$file" || exit 1; done && exit 0
20
+ fi
21
+ printf '%s\n' "$errors" >&2
22
+ exit $status
@@ -1,8 +1,11 @@
1
+ // A path with no scheme and no leading slash is relative to the page, as `path: './dist'` is. A worker has no
2
+ // page, so the main thread hands it the path resolved.
1
3
  export default {
4
+ getDefaultPathPrefix: () => '/',
2
5
  finalizePath(output) {
3
- if (output.substring(0, 4) !== 'http' && output[0] !== '/') {
4
- return `/${output}`;
6
+ if (/^[a-z][a-z\d+.-]*:/i.test(output) || output[0] === '/') {
7
+ return output;
5
8
  }
6
- return output;
9
+ return new URL(output, document.baseURI).href;
7
10
  },
8
11
  };
@@ -0,0 +1,2 @@
1
+ // Follows "crossbind: <name>" when the module a page or app loaded has no such function.
2
+ export const UNBOUND_MESSAGE = "is not bound in the native module. A dependency's header binds the functions the app imports by name; after a new import, reload the page or rebuild the app.";