crossbind 2.0.0-beta.62 → 2.0.0-beta.64

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 (132) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +2 -2
  3. package/package.json +7 -2
  4. package/src/actions/buildBinTools.js +0 -0
  5. package/src/actions/buildDependencies.js +15 -2
  6. package/src/actions/buildExternal.js +9 -1
  7. package/src/actions/buildJs.js +2 -9
  8. package/src/actions/buildLib.js +25 -8
  9. package/src/actions/buildNativeCommand.js +84 -0
  10. package/src/actions/buildNode.js +49 -10
  11. package/src/actions/buildTypes.js +8 -2
  12. package/src/actions/buildWasiCommand.js +10 -9
  13. package/src/actions/buildWasm.js +72 -31
  14. package/src/actions/cloudCommands.js +133 -0
  15. package/src/actions/createImportedBridges.js +71 -0
  16. package/src/actions/createInterface.js +247 -68
  17. package/src/actions/createLib.js +70 -42
  18. package/src/actions/getData.js +6 -2
  19. package/src/actions/getEnv.js +7 -0
  20. package/src/actions/getLinkInputs.js +12 -5
  21. package/src/actions/isSourceNewer.js +2 -0
  22. package/src/actions/licenses.js +143 -19
  23. package/src/actions/prepareConanDependencies.js +51 -0
  24. package/src/actions/publishBinary.js +32 -0
  25. package/src/actions/run.js +63 -66
  26. package/src/actions/runnerCommands.js +433 -0
  27. package/src/actions/writeRuntimeEntry.js +80 -0
  28. package/src/assets/cmake/command/CMakeLists.txt +21 -0
  29. package/src/assets/cmake/command/command.cpp +1 -0
  30. package/src/assets/cmake/dist.cmake +2 -1
  31. package/src/assets/configure/install.sh +22 -0
  32. package/src/assets/cpp-runtime/node.cpp +18 -1
  33. package/src/assets/js-runtime/adapters/path-url.js +6 -3
  34. package/src/assets/js-runtime/adapters/unboundMessage.js +2 -0
  35. package/src/assets/js-runtime/adapters/worker-comlink.js +138 -60
  36. package/src/assets/js-runtime/core.js +12 -0
  37. package/src/assets/node-import-hooks.mjs +61 -0
  38. package/src/assets/toolchain-digests.json +19 -19
  39. package/src/assets/wasi-runtime/http/README.md +17 -0
  40. package/src/assets/wasi-runtime/http/bindings/crossbind_http.c +2537 -0
  41. package/src/assets/wasi-runtime/http/bindings/crossbind_http.h +826 -0
  42. package/src/assets/wasi-runtime/http/bindings/crossbind_http_component_type.o +0 -0
  43. package/src/assets/wasi-runtime/http/handler.cpp +298 -0
  44. package/src/assets/wasi-runtime/http/include/crossbind/http.h +31 -0
  45. package/src/assets/wasi-runtime/http/wit/deps/LICENSE.md +8 -0
  46. package/src/assets/wasi-runtime/http/wit/deps/cli.wit +28 -0
  47. package/src/assets/wasi-runtime/http/wit/deps/clocks.wit +43 -0
  48. package/src/assets/wasi-runtime/http/wit/deps/http.wit +509 -0
  49. package/src/assets/wasi-runtime/http/wit/deps/random.wit +18 -0
  50. package/src/assets/wasi-runtime/http/wit/world.wit +6 -0
  51. package/src/assets/wasi-runtime/stubs.c +29 -0
  52. package/src/bin.js +218 -161
  53. package/src/cli.js +14 -0
  54. package/src/index.js +3 -0
  55. package/src/integration/bindHeaderImports.js +18 -0
  56. package/src/integration/getCrossbindScript.js +50 -13
  57. package/src/integration/getDependFilePath.js +9 -0
  58. package/src/integration/resolveNativeImport.js +14 -0
  59. package/src/node/dev.mjs +19 -0
  60. package/src/program.js +169 -0
  61. package/src/runner/blobs.js +135 -0
  62. package/src/runner/files.js +193 -0
  63. package/src/runner/server.js +320 -0
  64. package/src/state/attachConanDependencies.js +56 -0
  65. package/src/state/index.js +20 -11
  66. package/src/state/loadConfig.js +37 -35
  67. package/src/state/refreshBuiltDependencies.js +20 -0
  68. package/src/state/refreshConanDependencies.js +17 -0
  69. package/src/utils/androidToolchain.js +4 -0
  70. package/src/utils/appRustCrates.js +19 -1
  71. package/src/utils/appSources.js +97 -6
  72. package/src/utils/archiveFlags.js +12 -0
  73. package/src/utils/boundHeaders.js +79 -0
  74. package/src/utils/bridgeExtras.js +554 -0
  75. package/src/utils/bridgeRuntimeFixes.js +48 -0
  76. package/src/utils/bundledLicenses.js +26 -0
  77. package/src/utils/cargoLock.js +19 -0
  78. package/src/utils/cargoTarget.js +21 -3
  79. package/src/utils/cloudAccount.js +55 -0
  80. package/src/utils/conanDependencies.js +66 -0
  81. package/src/utils/conanImport.js +58 -0
  82. package/src/utils/conanInstall.js +154 -0
  83. package/src/utils/conanProfile.js +203 -0
  84. package/src/utils/conanStage.js +292 -0
  85. package/src/utils/copyToDistSource.js +13 -0
  86. package/src/utils/cppDts.js +60 -15
  87. package/src/utils/darwinToolchain.js +11 -0
  88. package/src/utils/dirLock.js +137 -28
  89. package/src/utils/downloadAndExtractFile.js +34 -7
  90. package/src/utils/embindArgumentGuards.js +41 -2
  91. package/src/utils/embindCallArguments.js +19 -0
  92. package/src/utils/embindJspiQueue.js +25 -0
  93. package/src/utils/embindObjectLifetime.js +35 -0
  94. package/src/utils/execContainer.js +49 -0
  95. package/src/utils/headerImports.js +16 -5
  96. package/src/utils/hostPlatform.js +21 -0
  97. package/src/utils/iosToolchain.js +6 -0
  98. package/src/utils/isSourceCmakePackage.js +10 -0
  99. package/src/utils/licenseReport.js +11 -2
  100. package/src/utils/linuxToolchain.js +24 -0
  101. package/src/utils/makeTreeWritable.js +12 -0
  102. package/src/utils/nativeImports.js +23 -0
  103. package/src/utils/nativeSourceStamp.js +36 -0
  104. package/src/utils/nodeDevBuild.js +92 -0
  105. package/src/utils/packageLicense.js +103 -0
  106. package/src/utils/pthreadMainScript.js +14 -0
  107. package/src/utils/pullDockerImage.js +3 -1
  108. package/src/utils/relocatePrebuilt.js +119 -0
  109. package/src/utils/remoteClient.js +127 -0
  110. package/src/utils/remoteOutputs.js +39 -0
  111. package/src/utils/remoteRunner.js +97 -0
  112. package/src/utils/remoteUpload.js +134 -0
  113. package/src/utils/replaceFile.js +8 -0
  114. package/src/utils/resetCMakeCacheForSource.js +26 -0
  115. package/src/utils/runCargo.js +48 -51
  116. package/src/utils/runConan.js +257 -0
  117. package/src/utils/runtimeEntries.js +56 -0
  118. package/src/utils/rustBridgeGen.js +22 -8
  119. package/src/utils/rustMt.js +9 -0
  120. package/src/utils/rustSysroot.js +7 -5
  121. package/src/utils/scopedEmbind.js +22 -0
  122. package/src/utils/selectRunner.js +81 -0
  123. package/src/utils/sourceFingerprint.js +4 -3
  124. package/src/utils/swigInterface.js +70 -4
  125. package/src/utils/systemConfig.js +13 -0
  126. package/src/utils/systemKeys.js +35 -3
  127. package/src/utils/targets.js +60 -4
  128. package/src/utils/toolchainNotices.js +5 -0
  129. package/src/utils/wasiHttp.js +50 -0
  130. package/src/utils/wasiToolchain.js +14 -1
  131. package/src/utils/windowsToolchain.js +18 -0
  132. package/vitest.config.js +11 -0
@@ -1,22 +1,57 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import getData from '../actions/getData.js';
3
+ import getEnv from '../actions/getEnv.js';
4
4
  import loadJson from '../utils/loadJson.js';
5
5
  import state from '../state/index.js';
6
6
  import { parseSurface, createRustBridgeCrate, createCrateImportBridge } from '../utils/rustBridgeGen.js';
7
7
  import { parseCargoMarkerName } from '../utils/cargoImport.js';
8
+ import { UNBOUND_MESSAGE } from '../assets/js-runtime/adapters/unboundMessage.js';
8
9
 
9
- export default function getCrossbindScript(target, bridgePath) {
10
+ // `base` is where the bundler serves the app from, as Vite's `base` names it; webpack's public path wins at run time.
11
+ // Metro asks for `liveExports`: it keeps a header's module as it first transformed it while the app's imports change.
12
+ export default function getCrossbindScript(target, bridgePath, { base = '/', liveExports: isLive = false } = {}) {
10
13
  if (!bridgePath) {
11
14
  throw new Error('getCrossbindScript needs the bridge file of the imported header');
12
15
  }
13
- return buildScript(target, loadJson(`${bridgePath}.exports.json`));
16
+ const script = buildScript(target, loadJson(`${bridgePath}.exports.json`), base);
17
+ return isLive ? `${script}${liveExportsScript()}` : script;
18
+ }
19
+
20
+ // A name the module does not export comes off the booted module: undefined before initNative. A name the module the page
21
+ // or app loaded lacks, exported or not, is a function that says why. Serialized into the generated module, so it
22
+ // references nothing outside itself.
23
+ export function liveExports(exportsObject, unbound) {
24
+ return new Proxy(exportsObject, {
25
+ get(target, name, receiver) {
26
+ const value = Reflect.get(target, name, receiver);
27
+ const bound = globalThis.__crossbindModule;
28
+ if (value != null || !bound || typeof name !== 'string' || ['then', 'default', 'toJSON'].includes(name)) return value;
29
+ if (bound[name] !== undefined) return bound[name];
30
+ if (!/^[A-Za-z_]\w*$/.test(name)) return value;
31
+ return function notBound() {
32
+ throw new Error(`crossbind: ${name} ${unbound}`);
33
+ };
34
+ },
35
+ });
36
+ }
37
+
38
+ function liveExportsScript() {
39
+ return `
40
+ const __crossbindLiveExports = ${liveExports.toString()};
41
+ if (typeof module === 'object' && module) module.exports = __crossbindLiveExports(module.exports, ${JSON.stringify(UNBOUND_MESSAGE)});
42
+ `;
14
43
  }
15
44
 
16
45
  // The Rust analog of a .h import: parse the crate surface and emit the same proxy module
17
46
  // (per-symbol lets assigned inside this module's initNative). Vectors come from the owning
18
47
  // cargo package's config; classes/enums come from the parsed source.
19
- export function getRustJsScript(target, rsFile) {
48
+ export function getRustJsScript(target, rsFile, { base = '/' } = {}) {
49
+ return buildScript(target, getRustSymbols(rsFile), base);
50
+ }
51
+
52
+ // The names a Rust import exports. An app-local file or a cargo: crate gets its bridge crate here, the way a header
53
+ // gets its bridge from createBridgeFile.
54
+ export function getRustSymbols(rsFile) {
20
55
  // Compare real paths: dependency paths go through node_modules symlinks (pnpm workspaces)
21
56
  // while bundlers hand the transformer the resolved real file.
22
57
  const realCrateDir = (d) => {
@@ -35,12 +70,13 @@ export function getRustJsScript(target, rsFile) {
35
70
  modulePath,
36
71
  spec: cargoDeps[crateName],
37
72
  cacheDir: state.config.paths.cache,
73
+ projectDir: state.config.paths.project,
38
74
  dtsMode: state.config.dts,
39
75
  log: () => {},
40
76
  });
41
77
  // A crate registers its public names under per-crate names (two crates may export the
42
78
  // same name), so the proxy exports the clean name and reads the registered one off the module.
43
- return buildScript(target, exports);
79
+ return exports;
44
80
  }
45
81
 
46
82
  const pkg = state.config.allDependencies.find((d) => d.export?.type === 'cargo'
@@ -60,7 +96,7 @@ export function getRustJsScript(target, rsFile) {
60
96
  log: () => {},
61
97
  });
62
98
  }
63
- const symbols = [
99
+ return [
64
100
  ...model.classes.map((c) => c.name),
65
101
  ...(model.streams ?? []).map((s) => s.name),
66
102
  ...model.enums.map((e) => e.name),
@@ -69,14 +105,13 @@ export function getRustJsScript(target, rsFile) {
69
105
  ...(model.consts ?? []).map((c) => c.name),
70
106
  ...vectors.map((v) => v.name),
71
107
  ];
72
- return buildScript(target, symbols);
73
108
  }
74
109
 
75
- function buildScript(target, symbols) {
110
+ function buildScript(target, symbols, base) {
76
111
  if (!target) {
77
112
  throw new Error('The target is not available!');
78
113
  }
79
- const env = JSON.stringify(getData('env', target));
114
+ const env = JSON.stringify(getEnv(target));
80
115
  const getPlatformScript = target.platform === 'wasm' ? getWebScript : getReactNativeScript;
81
116
 
82
117
  let symbolExportDefineString = '';
@@ -90,7 +125,7 @@ function buildScript(target, symbols) {
90
125
  }
91
126
 
92
127
  return `
93
- ${getPlatformScript(env)}
128
+ ${getPlatformScript(env, base)}
94
129
 
95
130
  export let AllSymbols = {};
96
131
  ${symbolExportDefineString}
@@ -144,7 +179,7 @@ function getReactNativeScript(env) {
144
179
  const RNJsiLib = TurboModuleRegistry.get('RNJsiLib');
145
180
 
146
181
  function setEnv() {
147
- const env = JSON.parse('${env}');
182
+ const env = ${env};
148
183
  const CROSSBIND_DATA_PATH = Module.Crossbind.getEnv('CROSSBIND_DATA_PATH');
149
184
 
150
185
  Object.entries(env).forEach(([key, value]) => {
@@ -165,8 +200,9 @@ function getReactNativeScript(env) {
165
200
  `;
166
201
  }
167
202
 
168
- function getWebScript(env) {
203
+ function getWebScript(env, base) {
169
204
  const params = `{
205
+ path: base,
170
206
  ...config,
171
207
  env: {...${env}, ...config.env},
172
208
  paths: {
@@ -179,7 +215,8 @@ function getWebScript(env) {
179
215
 
180
216
  return `
181
217
  function __crossbindBoot(config) {
182
- return import(/* webpackIgnore: true */ '/crossbind.js')
218
+ const base = new URL((typeof __webpack_public_path__ === 'string' ? __webpack_public_path__ : ${JSON.stringify(base)}) || './', document.baseURI).href;
219
+ return import(/* webpackIgnore: true */ /* @vite-ignore */ base + 'crossbind.js')
183
220
  .then(n => window.Crossbind.initNative(${params}));
184
221
  }
185
222
  `;
@@ -2,6 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import state from '../state/index.js';
4
4
  import { parseCargoImport, writeCargoMarker } from '../utils/cargoImport.js';
5
+ import { CONAN_PREFIX, resolveConanHeader } from '../utils/conanImport.js';
6
+ import refreshConanDependencies from '../state/refreshConanDependencies.js';
5
7
 
6
8
  const LAYOUT = { header: 'include', module: 'swig', source: '' };
7
9
 
@@ -92,6 +94,13 @@ export default function getDependFilePath(source, target) {
92
94
  return marker;
93
95
  }
94
96
 
97
+ // `conan:<package>/<header>` names a header of a declared Conan package, staged per target like a
98
+ // port's prebuilt; from there the header takes the same path as any other.
99
+ if (source.startsWith(CONAN_PREFIX)) {
100
+ refreshConanDependencies(state.config);
101
+ return resolveConanHeader(state.config, source, target ?? state.targets.find((t) => t.platform === 'wasm'));
102
+ }
103
+
95
104
  const matches = getMatchingPackages(source);
96
105
  if (matches.length === 0) return null;
97
106
 
@@ -0,0 +1,14 @@
1
+ import { createRequire } from 'node:module';
2
+ import upath from 'upath';
3
+ import getDependFilePath from './getDependFilePath.js';
4
+
5
+ // A package that is not a crossbind dependency (the conformance kit) resolves the way the bundler finds it.
6
+ export default function resolveNativeImport(specifier, importer, target) {
7
+ if (specifier.startsWith('.')) return upath.resolve(upath.dirname(importer), specifier);
8
+ if (upath.isAbsolute(specifier)) return specifier;
9
+ try {
10
+ return getDependFilePath(specifier, target) ?? createRequire(importer).resolve(specifier);
11
+ } catch (e) {
12
+ return null;
13
+ }
14
+ }
@@ -0,0 +1,19 @@
1
+ // `node --import crossbind/node/dev app.mjs`: builds this machine's binary when the app's native sources or imports
2
+ // changed since the last build it ran, then serves the app's native imports through the hooks that build wrote.
3
+ import { pathToFileURL } from 'node:url';
4
+ import loadConfig from '../state/loadConfig.js';
5
+ import { DEV_BUILD_ENV, ensureDevBuild, projectDirOf } from '../utils/nodeDevBuild.js';
6
+
7
+ if (!process.env[DEV_BUILD_ENV]) {
8
+ // node --watch restarts when a build rewrites the outputs the last start loaded. Its SIGTERM waits for the build
9
+ // and its record, then ends this start: the next one finds the build done instead of starting it over.
10
+ let restarting = false;
11
+ const restart = () => { restarting = true; };
12
+ process.on('SIGTERM', restart);
13
+ const hooks = ensureDevBuild(await loadConfig(projectDirOf(process.argv[1])));
14
+ // A signal that came during the build is handled on the next turn of the event loop.
15
+ await new Promise((resolve) => { setImmediate(resolve); });
16
+ process.off('SIGTERM', restart);
17
+ if (restarting) process.exit(143);
18
+ if (hooks) await import(pathToFileURL(hooks).href);
19
+ }
package/src/program.js ADDED
@@ -0,0 +1,169 @@
1
+ import fs from 'node:fs';
2
+ import { Command, Option } from 'commander';
3
+
4
+ import {
5
+ deleteAccount as deleteCloudAccount, fetchUsage, login as loginToCloud, logout as logoutOfCloud, usageLines,
6
+ } from './actions/cloudCommands.js';
7
+ import { cloudUrl } from './utils/cloudAccount.js';
8
+ import flattenConfigForTable from './utils/flattenConfigForTable.js';
9
+ import loadSystemConfig, { systemConfigPath } from './utils/systemConfig.js';
10
+ import systemKeys from './utils/systemKeys.js';
11
+ import writeJson from './utils/writeJson.js';
12
+
13
+ const packageJSON = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url)));
14
+
15
+ // Build failures bubble up as exceptions with a `cause` chain (see actions/run.js);
16
+ // show the operator a single line — DEBUG=1 for the full stack — and exit non-zero.
17
+ export const handleFatal = (e) => {
18
+ if (process.env.DEBUG) {
19
+ console.error(e);
20
+ } else {
21
+ let message = e?.message || String(e);
22
+ if (e?.cause?.message) message += `\n caused by: ${e.cause.message.split('\n')[0]}`;
23
+ console.error(message);
24
+ }
25
+ process.exit(1);
26
+ };
27
+
28
+ export const createProgram = () => new Command()
29
+ .name('crossbind')
30
+ .description('Compile C++ files to WebAssembly and native platforms.')
31
+ .version(packageJSON.version)
32
+ .showHelpAfterError();
33
+
34
+ export function addSystemCommands(program) {
35
+ program.command('login')
36
+ .description('sign in to crossbind cloud with your GitHub account')
37
+ .action(async () => {
38
+ const url = cloudUrl(loadSystemConfig());
39
+ const { login } = await loginToCloud({ url });
40
+ console.log(`crossbind: signed in to ${url} as ${login}.`);
41
+ });
42
+
43
+ program.command('logout')
44
+ .description('sign out of crossbind cloud on this machine')
45
+ .option('--all', 'sign out every machine signed in to this account')
46
+ .action(async (options) => {
47
+ const url = cloudUrl(loadSystemConfig());
48
+ const result = await logoutOfCloud({ url, all: Boolean(options.all) });
49
+ if (!result) {
50
+ console.log(`crossbind: not signed in to ${url}.`);
51
+ } else if (result.revoked) {
52
+ console.log(`crossbind: signed ${result.login} out${options.all ? ' on every machine' : ''}.`);
53
+ } else {
54
+ console.warn(`crossbind: signed out here, but crossbind cloud did not revoke the token (${result.reason.replace(/^crossbind: /, '')}). Sign in again and run crossbind logout --all to revoke it.`);
55
+ }
56
+ });
57
+
58
+ program.command('usage')
59
+ .description('show what you used of crossbind cloud this month and what is left')
60
+ .action(async () => {
61
+ usageLines(await fetchUsage({ url: cloudUrl(loadSystemConfig()) })).forEach((line) => console.log(line));
62
+ });
63
+
64
+ program.command('account')
65
+ .description('manage your crossbind cloud account')
66
+ .command('delete')
67
+ .description('delete your crossbind cloud account, with the sign-in of every machine (https://crossbind.dev/privacy/)')
68
+ .option('--yes', 'delete it; without this, nothing is deleted')
69
+ .action(async (options) => {
70
+ const url = cloudUrl(loadSystemConfig());
71
+ if (!options.yes) {
72
+ throw new Error(`crossbind: this deletes your crossbind cloud account at ${url}, with the sign-in of every machine. Run crossbind account delete --yes to go ahead.`);
73
+ }
74
+ const { login } = await deleteCloudAccount({ url });
75
+ console.log(`crossbind: deleted the crossbind cloud account of ${login}.`);
76
+ });
77
+
78
+ const commandConfig = program.command('config')
79
+ .description('manage the crossbind configuration files');
80
+
81
+ commandConfig.command('get')
82
+ .description('get the crossbind system configuration')
83
+ .argument('<key>', 'configuration key to read')
84
+ .action((key) => getSystemConfig(key));
85
+
86
+ commandConfig.command('set')
87
+ .description('set the crossbind system configuration')
88
+ .argument('<key>', 'configuration key to set')
89
+ .argument('<value>', 'value to assign')
90
+ .action((key, value) => setSystemConfig(key, value));
91
+
92
+ commandConfig.command('delete')
93
+ .description('delete the crossbind system configuration')
94
+ .argument('<key>', 'configuration key to remove')
95
+ .action((key) => deleteSystemConfig(key));
96
+
97
+ commandConfig.command('list')
98
+ .description('list the crossbind configurations')
99
+ .addOption(new Option('-t, --type <type>', 'config type').default('system').choices(['all', 'system', 'project']))
100
+ .action((options) => listSystemConfig(options.type));
101
+
102
+ commandConfig.command('keys')
103
+ .description('list all available system configuration keys for crossbind')
104
+ .action(() => listSystemKeys());
105
+ }
106
+
107
+ function listSystemKeys() {
108
+ console.info('Available configurations:');
109
+ console.table(systemKeys);
110
+ }
111
+
112
+ async function getSystemConfig(systemKey) {
113
+ if (!systemKey) {
114
+ await listSystemConfig('system');
115
+ return;
116
+ }
117
+ const system = loadSystemConfig();
118
+ const key = systemKey.toUpperCase();
119
+ const output = {};
120
+ if (key in system) {
121
+ output[key] = { value: system[key] || 'undefined', default: systemKeys[key].default };
122
+ }
123
+ Object.keys(systemKeys).filter((k) => k.includes(key)).forEach((k) => {
124
+ if (k in system) {
125
+ output[k] = { value: system[k] || 'undefined', default: systemKeys[k].default };
126
+ }
127
+ });
128
+
129
+ console.table(output);
130
+ }
131
+
132
+ function setSystemConfig(key, value) {
133
+ if (!systemKeys[key]) {
134
+ throw new Error(`Configuration ${key} is not available. Please choose from the following available configurations: ${Object.keys(systemKeys).join(', ')}`);
135
+ }
136
+ if (systemKeys[key].options && !systemKeys[key].options.includes(value)) {
137
+ throw new Error(`Value ${value} is not available. Please choose from the following available values: ${systemKeys[key].options.join(', ')}`);
138
+ }
139
+
140
+ writeJson(systemConfigPath(), { ...loadSystemConfig(), [key]: value });
141
+ }
142
+
143
+ function deleteSystemConfig(key) {
144
+ if (!systemKeys[key]) {
145
+ throw new Error(`Configuration ${key} is not available. Please choose from the following available configurations: ${Object.keys(systemKeys).join(', ')}`);
146
+ }
147
+ const { [key]: deleted, ...system } = loadSystemConfig();
148
+ writeJson(systemConfigPath(), system);
149
+ }
150
+
151
+ async function listSystemConfig(type) {
152
+ // Importing the state loads the project, which fails outside one, so only a listing of the project does.
153
+ const { system: systemConfig, ...projectConfig } = type === 'system'
154
+ ? { system: loadSystemConfig() }
155
+ : (await import('./state/index.js')).default.config;
156
+ if (type === 'all' || type === 'system') {
157
+ console.log('System Configuration');
158
+ console.table(systemConfig);
159
+ }
160
+
161
+ if (type === 'all') {
162
+ console.log('');
163
+ }
164
+
165
+ if (type === 'all' || type === 'project') {
166
+ console.log(`Project Configuration (paths relative to ${projectConfig.paths.base})`);
167
+ console.table(flattenConfigForTable(projectConfig, { base: projectConfig.paths.base }));
168
+ }
169
+ }
@@ -0,0 +1,135 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { sha256Of } from './files.js';
6
+
7
+ const SHA256 = /^[0-9a-f]{64}$/;
8
+ const UPLOAD_ID = /^[\w-]{8,64}$/;
9
+ // A part nobody added to for an hour belongs to an upload that stopped; the disk may be memory (Cloud Run).
10
+ const STALE_PART_MS = 60 * 60 * 1000;
11
+
12
+ export const isHash = (value) => typeof value === 'string' && SHA256.test(value);
13
+ export const isUploadId = (value) => typeof value === 'string' && UPLOAD_ID.test(value);
14
+
15
+ // Content-addressed: a blob is stored under its sha256, and an upload whose bytes hash to something else is dropped.
16
+ export function createBlobStore(dir) {
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ const root = `${path.resolve(dir)}${path.sep}`;
19
+
20
+ const pathOf = (hash) => {
21
+ if (!isHash(hash)) throw new Error(`crossbind runner: "${hash}" is not a sha256 digest`);
22
+ return path.join(dir, hash);
23
+ };
24
+
25
+ const has = (hash) => isHash(hash) && fs.existsSync(path.join(dir, hash));
26
+
27
+ async function putStream(hash, stream) {
28
+ const temp = path.join(dir, `.${hash}.${process.pid}.${crypto.randomUUID()}`);
29
+ const digest = crypto.createHash('sha256');
30
+ const output = fs.createWriteStream(temp);
31
+ try {
32
+ await pipeline(stream, async function* hashing(source) {
33
+ for await (const chunk of source) {
34
+ digest.update(chunk);
35
+ yield chunk;
36
+ }
37
+ }, output);
38
+ } catch (error) {
39
+ // pipeline settles before a file that is still opening closes; removed earlier, it would come back.
40
+ if (!output.closed) await new Promise((resolve) => { output.once('close', resolve); });
41
+ fs.rmSync(temp, { force: true });
42
+ throw error;
43
+ }
44
+ if (digest.digest('hex') !== hash) {
45
+ fs.rmSync(temp, { force: true });
46
+ return false;
47
+ }
48
+ fs.renameSync(temp, pathOf(hash));
49
+ return true;
50
+ }
51
+
52
+ function dropStaleParts() {
53
+ const now = Date.now();
54
+ for (const name of fs.readdirSync(dir).filter((file) => file.endsWith('.part'))) {
55
+ const stat = fs.statSync(path.join(dir, name), { throwIfNoEntry: false });
56
+ if (stat && now - stat.mtimeMs > STALE_PART_MS) fs.rmSync(path.join(dir, name), { force: true });
57
+ }
58
+ }
59
+
60
+ // A request names the part file, so its path is checked to stay in the store.
61
+ const partOf = (hash, upload) => {
62
+ const file = path.resolve(root, `.${hash}.${upload}.part`);
63
+ if (!file.startsWith(root)) throw new Error(`crossbind runner: "${hash}" and "${upload}" name no part in the store`);
64
+ return file;
65
+ };
66
+
67
+ // A blob too large for one request arrives in parts, in order and under the id of its upload; the last part
68
+ // checks the joined file against the hash. A part that breaks off leaves the ones before it, so the client sends
69
+ // that part again, and a part of a blob already joined answers as stored, since the last answer may have been lost.
70
+ async function writePart(hash, upload, { start, end, total }, stream) {
71
+ if (!isUploadId(upload)) throw new Error(`crossbind runner: "${upload}" is not an upload id`);
72
+ if (has(hash)) {
73
+ for await (const chunk of stream) void chunk;
74
+ return { status: 'stored' };
75
+ }
76
+ if (start === 0) dropStaleParts();
77
+ const partial = partOf(hash, upload);
78
+ const received = fs.existsSync(partial) ? fs.statSync(partial).size : 0;
79
+ if (start !== received) return { status: 'gap', received };
80
+ const restore = () => {
81
+ if (start === 0) fs.rmSync(partial, { force: true });
82
+ else if (fs.existsSync(partial)) fs.truncateSync(partial, start);
83
+ };
84
+ let length = 0;
85
+ const output = fs.createWriteStream(partial, { flags: 'a' });
86
+ try {
87
+ await pipeline(stream, async function* counting(source) {
88
+ for await (const chunk of source) {
89
+ length += chunk.length;
90
+ yield chunk;
91
+ }
92
+ }, output);
93
+ } catch (error) {
94
+ if (!output.closed) await new Promise((resolve) => { output.once('close', resolve); });
95
+ restore();
96
+ throw error;
97
+ }
98
+ if (length !== end - start + 1) {
99
+ restore();
100
+ return { status: 'short' };
101
+ }
102
+ if (end + 1 < total) return { status: 'partial', received: end + 1 };
103
+ const digest = crypto.createHash('sha256');
104
+ for await (const chunk of fs.createReadStream(partial)) digest.update(chunk);
105
+ const joined = digest.digest('hex');
106
+ if (joined !== hash) {
107
+ fs.rmSync(partial, { force: true });
108
+ return { status: 'mismatch' };
109
+ }
110
+ fs.renameSync(partial, pathOf(joined));
111
+ return { status: 'stored' };
112
+ }
113
+
114
+ // The parts of one upload go one at a time: a part whose connection broke off can still be writing when the client
115
+ // sends it again.
116
+ const writing = new Map();
117
+ function putPart(hash, upload, range, stream) {
118
+ const key = `${hash}.${upload}`;
119
+ const current = (writing.get(key) ?? Promise.resolve()).catch(() => {}).then(() => writePart(hash, upload, range, stream));
120
+ writing.set(key, current);
121
+ const forget = () => {
122
+ if (writing.get(key) === current) writing.delete(key);
123
+ };
124
+ current.then(forget, forget);
125
+ return current;
126
+ }
127
+
128
+ function putBuffer(data) {
129
+ const hash = sha256Of(data);
130
+ if (!has(hash)) fs.writeFileSync(pathOf(hash), data);
131
+ return hash;
132
+ }
133
+
134
+ return { pathOf, has, putStream, putPart, putBuffer };
135
+ }