crossbind 2.0.0-beta.63 → 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 (50) hide show
  1. package/AGENTS.md +2 -1
  2. package/package.json +2 -2
  3. package/src/actions/buildBinTools.js +0 -0
  4. package/src/actions/buildJs.js +2 -9
  5. package/src/actions/buildNode.js +4 -6
  6. package/src/actions/buildWasiCommand.js +10 -5
  7. package/src/actions/buildWasm.js +24 -3
  8. package/src/actions/createImportedBridges.js +7 -0
  9. package/src/actions/createInterface.js +3 -3
  10. package/src/actions/createLib.js +14 -2
  11. package/src/actions/getData.js +2 -1
  12. package/src/actions/getEnv.js +7 -0
  13. package/src/actions/runnerCommands.js +16 -4
  14. package/src/actions/writeRuntimeEntry.js +18 -7
  15. package/src/assets/cpp-runtime/node.cpp +18 -1
  16. package/src/assets/js-runtime/adapters/worker-comlink.js +51 -0
  17. package/src/assets/js-runtime/core.js +5 -4
  18. package/src/assets/toolchain-digests.json +19 -19
  19. package/src/assets/wasi-runtime/http/README.md +17 -0
  20. package/src/assets/wasi-runtime/http/bindings/crossbind_http.c +2537 -0
  21. package/src/assets/wasi-runtime/http/bindings/crossbind_http.h +826 -0
  22. package/src/assets/wasi-runtime/http/bindings/crossbind_http_component_type.o +0 -0
  23. package/src/assets/wasi-runtime/http/handler.cpp +298 -0
  24. package/src/assets/wasi-runtime/http/include/crossbind/http.h +31 -0
  25. package/src/assets/wasi-runtime/http/wit/deps/LICENSE.md +8 -0
  26. package/src/assets/wasi-runtime/http/wit/deps/cli.wit +28 -0
  27. package/src/assets/wasi-runtime/http/wit/deps/clocks.wit +43 -0
  28. package/src/assets/wasi-runtime/http/wit/deps/http.wit +509 -0
  29. package/src/assets/wasi-runtime/http/wit/deps/random.wit +18 -0
  30. package/src/assets/wasi-runtime/http/wit/world.wit +6 -0
  31. package/src/bin.js +29 -179
  32. package/src/cli.js +14 -0
  33. package/src/integration/getCrossbindScript.js +4 -3
  34. package/src/program.js +169 -0
  35. package/src/state/index.js +4 -3
  36. package/src/state/loadConfig.js +4 -11
  37. package/src/utils/appRustCrates.js +4 -0
  38. package/src/utils/bridgeExtras.js +74 -6
  39. package/src/utils/cargoLock.js +19 -0
  40. package/src/utils/cppDts.js +2 -0
  41. package/src/utils/embindObjectLifetime.js +35 -0
  42. package/src/utils/packageLicense.js +4 -2
  43. package/src/utils/resetCMakeCacheForSource.js +26 -0
  44. package/src/utils/rustBridgeGen.js +13 -4
  45. package/src/utils/rustMt.js +5 -0
  46. package/src/utils/rustSysroot.js +7 -5
  47. package/src/utils/swigInterface.js +5 -4
  48. package/src/utils/systemConfig.js +13 -0
  49. package/src/utils/targets.js +11 -0
  50. package/src/utils/wasiHttp.js +50 -0
package/AGENTS.md CHANGED
@@ -4,7 +4,8 @@
4
4
 
5
5
  ## What lives here
6
6
 
7
- - `src/bin.js` — `crossbind` CLI entry; commander commands, target filtering and top-level orchestration.
7
+ - `src/cli.js` — `crossbind` CLI entry: `login`, `logout`, `usage`, `account`, `config` and `--version` run from `src/program.js` without loading a project (only `config list -t all|project` loads it); every other command goes to `src/bin.js`.
8
+ - `src/bin.js` — the commands that need a project; commander commands, target filtering and top-level orchestration.
8
9
  - `src/index.js` — public API barrel re-exported as the `crossbind` npm package (`createLib`, `buildWasm`, `state`, `getData`, `isSourceNewer`, …).
9
10
  - `src/actions/` — the actual build steps. **Most edits land here.**
10
11
  - `src/state/` — config loading + target matrix.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crossbind",
3
- "version": "2.0.0-beta.63",
3
+ "version": "2.0.0-beta.64",
4
4
  "license": "MIT",
5
5
  "homepage": "https://crossbind.dev",
6
6
  "repository": "https://github.com/crossbind/crossbind.git",
@@ -10,7 +10,7 @@
10
10
  "node": ">=24"
11
11
  },
12
12
  "bin": {
13
- "crossbind": "./src/bin.js"
13
+ "crossbind": "./src/cli.js"
14
14
  },
15
15
  "main": "src/index.js",
16
16
  "exports": {
Binary file
@@ -5,7 +5,7 @@ import { nodeResolve } from '@rollup/plugin-node-resolve';
5
5
  import commonjs from '@rollup/plugin-commonjs';
6
6
  import virtual from '@rollup/plugin-virtual';
7
7
  import state from '../state/index.js';
8
- import getData from './getData.js';
8
+ import getEnv from './getEnv.js';
9
9
 
10
10
  const SUPPRESSED_WARN_CODES = new Set([
11
11
  'MISSING_GLOBAL_NAME',
@@ -53,14 +53,7 @@ options.edge = options.browser;
53
53
 
54
54
  export default async function buildJS(target) {
55
55
  const entryJS = `${state.config.paths.cli}/assets/js-runtime/${target.runtimeEnv}.js`;
56
- const rawEnv = getData('env', target);
57
- const resolvedEnv = Object.fromEntries(
58
- Object.entries(rawEnv).map(([key, value]) => [
59
- key,
60
- typeof value === 'function' ? value(state, target) : value,
61
- ]),
62
- );
63
- const env = JSON.stringify(resolvedEnv);
56
+ const env = JSON.stringify(getEnv(target));
64
57
  const systemConfig = `export default {
65
58
  env: ${env},
66
59
  useWorker: !!globalThis.Worker,
@@ -8,6 +8,8 @@ import virtual from '@rollup/plugin-virtual';
8
8
  import run from './run.js';
9
9
  import getLinkInputs from './getLinkInputs.js';
10
10
  import getData from './getData.js';
11
+ import getEnv from './getEnv.js';
12
+ import resetCMakeCacheForSource from '../utils/resetCMakeCacheForSource.js';
11
13
  import { suppressNodeBuiltinWarnings } from './buildJs.js';
12
14
  import state from '../state/index.js';
13
15
  import logger from '../utils/logger.js';
@@ -52,11 +54,6 @@ function linkInputs(target) {
52
54
  return { libs, linkArgs, extraSources };
53
55
  }
54
56
 
55
- function envOf(target) {
56
- return Object.fromEntries(Object.entries(getData('env', target))
57
- .map(([key, value]) => [key, typeof value === 'function' ? value(state, target) : value]));
58
- }
59
-
60
57
  // A package publishes its addons in <package>-<platform>-<arch> by listing them as optional
61
58
  // dependencies, which is also how npm learns to install only the one for its machine.
62
59
  function listsAddonPackage(target) {
@@ -82,7 +79,7 @@ export function nodeLoaderConfig(target) {
82
79
  const addonPackage = addonPackagePattern(served);
83
80
  const loaded = addonPackage ? served.filter(listsAddonPackage) : served;
84
81
  return {
85
- env: Object.fromEntries(loaded.map((t) => [`${t.platform}-${t.arch}`, envOf(t)])),
82
+ env: Object.fromEntries(loaded.map((t) => [`${t.platform}-${t.arch}`, getEnv(t)])),
86
83
  general: { name: state.config.general.name },
87
84
  paths: { addon: target.addonPattern, ...(addonPackage ? { addonPackage } : {}) },
88
85
  };
@@ -162,6 +159,7 @@ export default async function buildNode(target, options = {}) {
162
159
 
163
160
  const platformPrefix = `Node-${buildType}`;
164
161
  logger.startStep(target, 'addon');
162
+ resetCMakeCacheForSource(`${build}/${platformPrefix}/${target.path}`, `${napiRoot}/cpp`, target);
165
163
  run(null, [
166
164
  'cmake', `${napiRoot}/cpp`,
167
165
  `-DCMAKE_BUILD_TYPE=${buildType}`,
@@ -7,14 +7,18 @@ import logger from '../utils/logger.js';
7
7
  import { getContentHash, getFilesFingerprint } from '../utils/hash.js';
8
8
  import { buildLinkLibArgs } from '../utils/linkLayout.js';
9
9
  import { wasiCxxFlags, WASI_LINK_LIBS } from '../utils/wasiToolchain.js';
10
+ import { stageWasiHttpLink, wasiHttpFiles } from '../utils/wasiHttp.js';
10
11
 
11
12
  // Links a platform:'wasi' target into one command module: main() is the entry, deps are DCE'd, data lands as a real dir for --dir preopens.
13
+ // An http target links into a wasi:http/service component instead, whose entry is crossbind's handler export.
12
14
  export default async function buildWasiCommand(target, options = {}) {
13
15
  const isProd = target.buildType === 'release';
14
16
  const buildType = isProd ? 'Release' : 'Debug';
17
+ const isHttp = target.runtimeEnv === 'http';
18
+ const step = isHttp ? 'wasi http' : 'wasi command';
15
19
 
16
20
  if (state.config.export?.type === 'cargo') {
17
- logger.info(`[${target.path}] wasi command skipped (cargo packages have no cargo wasi triple)`);
21
+ logger.info(`[${target.path}] ${step} skipped (cargo packages have no cargo wasi triple)`);
18
22
  return false;
19
23
  }
20
24
 
@@ -52,17 +56,17 @@ export default async function buildWasiCommand(target, options = {}) {
52
56
  return { lib, size: stat ? stat.size : null, mtimeMs: stat ? stat.mtimeMs : null };
53
57
  }),
54
58
  data: getData('data', target),
55
- runtime: getFilesFingerprint([stubs]),
59
+ runtime: getFilesFingerprint([stubs, ...(isHttp ? wasiHttpFiles(state.config.paths.cli) : [])]),
56
60
  }));
57
61
  const linkChanged = !fs.existsSync(linkFingerprintFile)
58
62
  || fs.readFileSync(linkFingerprintFile, { encoding: 'utf8' }) !== linkFingerprint;
59
63
 
60
64
  if (!options.force && !linkChanged && fs.existsSync(`${state.config.paths.build}/${target.wasmName}`)) {
61
- logger.cachedStep(target, 'wasi command');
65
+ logger.cachedStep(target, step);
62
66
  return false;
63
67
  }
64
68
 
65
- logger.startStep(target, 'wasi command');
69
+ logger.startStep(target, step);
66
70
  const t0 = performance.now();
67
71
  // stubs.c lives outside the docker mount (paths.cli); stage it next to the artifact.
68
72
  const stagedStubs = `${state.config.paths.build}/crossbind-wasi-stubs.c`;
@@ -71,6 +75,7 @@ export default async function buildWasiCommand(target, options = {}) {
71
75
  run(null, [
72
76
  'wasi-clang++',
73
77
  stagedStubs,
78
+ ...(isHttp ? stageWasiHttpLink(state.config.paths.cli, `${state.config.paths.build}/crossbind-wasi-http`) : []),
74
79
  ...wasiCxxFlags(),
75
80
  ...(isProd ? ['-O3'] : ['-O0']),
76
81
  ...wasiFlags,
@@ -79,7 +84,7 @@ export default async function buildWasiCommand(target, options = {}) {
79
84
  '-o', `${state.config.paths.build}/${target.wasmName}`,
80
85
  ], null, target);
81
86
  const ms = Math.round(performance.now() - t0);
82
- logger.doneStep(target, 'wasi command', ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`);
87
+ logger.doneStep(target, step, ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`);
83
88
 
84
89
  // Dep data lands as a real folder next to the artifact for --dir preopens.
85
90
  Object.entries(getData('data', target)).forEach(([key, value]) => {
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import run from './run.js';
5
5
  import getLinkInputs from './getLinkInputs.js';
6
6
  import getData from './getData.js';
7
+ import getEnv from './getEnv.js';
7
8
  import buildJs from './buildJs.js';
8
9
  import triggerExtensions from './extensions.js';
9
10
  import state from '../state/index.js';
@@ -14,9 +15,10 @@ import { buildLinkLibArgs } from '../utils/linkLayout.js';
14
15
  import { guardBigIntArguments, guardEmbindArguments } from '../utils/embindArgumentGuards.js';
15
16
  import { separateCallArguments } from '../utils/embindCallArguments.js';
16
17
  import { queueJspiCalls } from '../utils/embindJspiQueue.js';
18
+ import { releaseOwnedHandles } from '../utils/embindObjectLifetime.js';
17
19
  import { spawnPthreadsFromMainScript } from '../utils/pthreadMainScript.js';
18
20
 
19
- const GLUE_REWRITES = ['../utils/embindArgumentGuards.js', '../utils/embindCallArguments.js', '../utils/embindJspiQueue.js', '../utils/pthreadMainScript.js']
21
+ const GLUE_REWRITES = ['../utils/embindArgumentGuards.js', '../utils/embindCallArguments.js', '../utils/embindJspiQueue.js', '../utils/embindObjectLifetime.js', '../utils/pthreadMainScript.js']
20
22
  .map((file) => fileURLToPath(new URL(file, import.meta.url)));
21
23
 
22
24
  // embind's bigint converter turns any Number into a BigInt, so 2^53+1 silently becomes 2^53.
@@ -64,6 +66,17 @@ function queueJspiEmbindCalls(target) {
64
66
  }
65
67
  }
66
68
 
69
+ // Objects JavaScript owns outside a smart pointer were freed only by .delete() (utils/embindObjectLifetime.js);
70
+ // each runtimeEnv links its own glue, so each one calls this after its link.
71
+ function releaseOwnedObjects(target) {
72
+ const gluePath = `${state.config.paths.build}/${target.rawJsName}`;
73
+ const { text, missed } = releaseOwnedHandles(fs.readFileSync(gluePath, 'utf8'));
74
+ fs.writeFileSync(gluePath, text);
75
+ if (missed) {
76
+ logger.error('embind object lifetime rewrite missed (emscripten glue format changed?): returned values and constructed vectors are freed only by .delete()');
77
+ }
78
+ }
79
+
67
80
  // A direct-mode mt init in a browser spawns its pthreads from "/undefined" (utils/pthreadMainScript.js).
68
81
  function pointPthreadSpawn(target) {
69
82
  const gluePath = `${state.config.paths.build}/${target.rawJsName}`;
@@ -173,6 +186,8 @@ export default async function buildWasm(target, options = {}) {
173
186
  return { lib, size: stat ? stat.size : null, mtimeMs: stat ? stat.mtimeMs : null };
174
187
  }),
175
188
  data: getData('data', target),
189
+ // The JS the link step writes carries the env.
190
+ env: getEnv(target),
176
191
  runtime: getFilesFingerprint(runtimeAssets),
177
192
  rustSources: getFilesFingerprint(rustSources),
178
193
  }));
@@ -233,6 +248,7 @@ export default async function buildWasm(target, options = {}) {
233
248
  guardArgumentConversions(target);
234
249
  separateEmbindCalls(target);
235
250
  queueJspiEmbindCalls(target);
251
+ releaseOwnedObjects(target);
236
252
  await buildJs(target);
237
253
  // fs.rmSync(`${state.config.paths.build}/${state.config.general.name}.js`);
238
254
  // fs.copyFileSync(`${state.config.paths.build}/${state.config.general.name}.browser.js`, `${state.config.paths.build}/${state.config.general.name}.js`);
@@ -257,12 +273,13 @@ export default async function buildWasm(target, options = {}) {
257
273
  // it any linked Rust package archive fails with undefined crossbind_* symbols.
258
274
  ...linkLibs, ...rustSources,
259
275
  '-s', 'WASM=1', '-s', 'MODULARIZE=1', '-s', 'DYNAMIC_EXECUTION=0',
260
- '-s', 'RESERVED_FUNCTION_POINTERS=200', // '-s', 'FORCE_FILESYSTEM=1',
276
+ '-s', 'RESERVED_FUNCTION_POINTERS=200',
261
277
  '-s', 'ALLOW_MEMORY_GROWTH=1',
262
278
  // See the GROWABLE_ARRAYBUFFERS note in the browser block (Firefox/WebKit TextDecoder).
263
279
  '-s', 'GROWABLE_ARRAYBUFFERS=0',
280
+ '-s', 'FORCE_FILESYSTEM=1',
264
281
  '-s', 'ENVIRONMENT=web',
265
- '-s', 'EXPORTED_RUNTIME_METHODS=["ENV", "getExceptionMessage"]',
282
+ '-s', 'EXPORTED_RUNTIME_METHODS=["FS", "ENV", "getExceptionMessage"]',
266
283
  '-fwasm-exceptions',
267
284
  '-sEXPORT_EXCEPTION_HANDLING_HELPERS',
268
285
  '-o', `${state.config.paths.build}/${target.rawJsName}`,
@@ -275,6 +292,7 @@ export default async function buildWasm(target, options = {}) {
275
292
  guardArgumentConversions(target);
276
293
  separateEmbindCalls(target);
277
294
  queueJspiEmbindCalls(target);
295
+ releaseOwnedObjects(target);
278
296
  await buildJs(target);
279
297
  logger.doneStep(target, 'js');
280
298
  }
@@ -292,6 +310,8 @@ export default async function buildWasm(target, options = {}) {
292
310
  // '-s', 'FETCH', '-sJSPI', '-pthread', '-sPTHREAD_POOL_SIZE=5',
293
311
  '-s', 'FORCE_FILESYSTEM=1',
294
312
  ...linkLibs, ...rustSources, `${state.config.paths.cli}/assets/cpp-runtime/node.cpp`,
313
+ // node.cpp answers fsync and fdatasync for host files.
314
+ '-Wl,--wrap=__wasi_fd_sync', '-Wl,--wrap=__syscall_fdatasync',
295
315
  '-s', 'WASM=1', '-s', 'MODULARIZE=1', '-s', 'DYNAMIC_EXECUTION=0',
296
316
  '-s', 'RESERVED_FUNCTION_POINTERS=200', // '-s', 'DISABLE_EXCEPTION_CATCHING=0', '-s', 'FORCE_FILESYSTEM=1',
297
317
  '-s', 'ALLOW_MEMORY_GROWTH=1',
@@ -311,6 +331,7 @@ export default async function buildWasm(target, options = {}) {
311
331
  guardArgumentConversions(target);
312
332
  separateEmbindCalls(target);
313
333
  queueJspiEmbindCalls(target);
334
+ releaseOwnedObjects(target);
314
335
  await buildJs(target);
315
336
  if (emccFlags.includes('FETCH')) {
316
337
  fs.appendFileSync(`${state.config.paths.build}/${target.jsName}`, 'var XMLHttpRequest = require(\'xhr2\');\n');
@@ -34,6 +34,13 @@ function resolveImport(specifier, importer, targets) {
34
34
  return null;
35
35
  }
36
36
 
37
+ // Whether a binary binds what the app's own sources import. A Node binary does: its module hooks resolve those
38
+ // imports. A browser binary only with --bind-imports: without a bundler a page cannot resolve a header import
39
+ // unless it maps the specifiers itself, as the playground's sandbox does. Nothing on an edge runtime resolves one.
40
+ export function bindsAppImports(target, { bindImports = false } = {}) {
41
+ return target.runtimeEnv === 'node' || (bindImports && target.runtimeEnv === 'browser');
42
+ }
43
+
37
44
  // The native files the app's own sources import, by real path, each under the first target whose packages ship it
38
45
  // and with the bare specifiers that name it, which Node cannot resolve on its own.
39
46
  export function findAppNativeImports(targets) {
@@ -27,10 +27,10 @@ import run, { cxxPreprocessorFor } from './run.js';
27
27
  import isSourceCmakePackage from '../utils/isSourceCmakePackage.js';
28
28
 
29
29
  // Part of every interface hash, so interfaces cached by an older generator are rebuilt.
30
- const INTERFACE_FORMAT = 'swig-macros-3';
30
+ const INTERFACE_FORMAT = 'swig-macros-4';
31
31
  // Part of every bridge hash: each bridge carries the SWIG fork's runtime and all bridges of a module must share it, so
32
32
  // bridges from a fork before field bindings and constants are rebuilt.
33
- const BRIDGE_FORMAT = 'swig-extras-1';
33
+ const BRIDGE_FORMAT = 'swig-extras-2';
34
34
  const predefinedMacros = new Map();
35
35
  const typeDefinitions = new Map();
36
36
 
@@ -188,7 +188,7 @@ function createInterfaceFile(headerOrModuleFilePath, target, sourceDir, { consta
188
188
  const fileName = interfaceName(headerOrModuleFilePath, filePathWithoutExt.split('/').at(-1));
189
189
 
190
190
  const macros = collectSwigMacros(headerOrModuleFilePath, interfaceIncludes(headerPath, prelude), fileName, target, sourceDir, constants);
191
- const extras = interfaceExtras(named, macros.table);
191
+ const extras = interfaceExtras(named, macros.table, fs.readFileSync(headerOrModuleFilePath, 'utf8'));
192
192
  const content = buildInterfaceContent({
193
193
  moduleName: fileName.toUpperCase(),
194
194
  headerPath,
@@ -10,11 +10,13 @@ import triggerExtensions from './extensions.js';
10
10
  import state from '../state/index.js';
11
11
  import logger from '../utils/logger.js';
12
12
  import { WASI_EMULATION_LIBS, WASI_LINK_LIBS, wasiCFlags, wasiCxxFlags } from '../utils/wasiToolchain.js';
13
+ import { stageWasiHttpInclude, wasiHttpHeader } from '../utils/wasiHttp.js';
13
14
  import { getFilesFingerprint, getContentHash } from '../utils/hash.js';
14
15
  import { withDependencyBridges } from '../utils/dependencyBridges.js';
15
16
  import { targetArchiveFlags } from '../utils/archiveFlags.js';
16
17
  import { conanInputsOf } from '../utils/conanDependencies.js';
17
18
  import { nativeSourceStampChanged, writeNativeSourceStamp } from '../utils/nativeSourceStamp.js';
19
+ import resetCMakeCacheForSource from '../utils/resetCMakeCacheForSource.js';
18
20
 
19
21
  const cpuCount = Math.max(1, os.cpus().length - 1);
20
22
  const sharedPlatforms = ['android'];
@@ -56,7 +58,12 @@ export default function createLib(target, fileType, options = {}) {
56
58
  const conanInputs = conanInputsOf(state.config);
57
59
  const compileInputs = compileOptions.length ? { emccFlags: configEmccFlags, compileOptions } : configEmccFlags;
58
60
  const flagsInputs = sourceReplacements.length ? { compileInputs, sourceReplacements } : compileInputs;
59
- const flagsFingerprint = getContentHash(JSON.stringify(conanInputs ? [flagsInputs, conanInputs] : flagsInputs));
61
+ const keyInputs = conanInputs ? [flagsInputs, conanInputs] : flagsInputs;
62
+ // An app's wasi source compiles against <crossbind/http.h>, so a new one misses this cache too.
63
+ const httpHeader = target.platform === 'wasi' && !state.config.build.withBuildConfig
64
+ ? getFilesFingerprint([wasiHttpHeader(state.config.paths.cli)])
65
+ : null;
66
+ const flagsFingerprint = getContentHash(JSON.stringify(httpHeader ? [keyInputs, httpHeader] : keyInputs));
60
67
  const flagsChanged = !fs.existsSync(flagsFingerprintFile)
61
68
  || fs.readFileSync(flagsFingerprintFile, { encoding: 'utf8' }) !== flagsFingerprint;
62
69
 
@@ -142,7 +149,11 @@ export default function createLib(target, fileType, options = {}) {
142
149
  buildEnv.params.push('-e', e);
143
150
  });
144
151
  } else {
145
- buildParams = getCmakeParameters(target, { ...options, nativeGlob });
152
+ // The source of an http build includes <crossbind/http.h>.
153
+ const headerDirs = target.platform === 'wasi'
154
+ ? [...(options.headerDirs ?? []), stageWasiHttpInclude(state.config.paths.cli, `${buildPath}/crossbind-wasi-http`)]
155
+ : options.headerDirs;
156
+ buildParams = getCmakeParameters(target, { ...options, nativeGlob, headerDirs });
146
157
 
147
158
  triggerExtensions('createLib', 'setFlagWithoutBuildConfig', [buildEnv]);
148
159
 
@@ -229,6 +240,7 @@ export default function createLib(target, fileType, options = {}) {
229
240
  stageWasiStubs();
230
241
  buildParams.push(`-DCMAKE_EXE_LINKER_FLAGS=${[...wasiCxxFlags(), wasiStubObj, ...WASI_LINK_LIBS].join(' ')}`);
231
242
  }
243
+ resetCMakeCacheForSource(buildPath, cmakeDir, target);
232
244
  run(null, [
233
245
  target.platform === 'ios' && state.config.build?.useIOSCMake ? 'ios-cmake' : 'cmake', cmakeDir,
234
246
  `-DCMAKE_BUILD_TYPE=${buildType}`,
@@ -30,7 +30,8 @@ function getRecursiveData(obj, config, dependency, field, target) {
30
30
  obj[dKey] = [...(obj[dKey] || []), ...value];
31
31
  } else if (typeof value === 'object') {
32
32
  obj[dKey] = { ...(obj[dKey] || {}), ...value };
33
- } else {
33
+ } else if (!Object.hasOwn(obj, dKey)) {
34
+ // The app comes before its dependencies and a package before its own, so the nearest value stays.
34
35
  obj[dKey] = value;
35
36
  }
36
37
  }
@@ -0,0 +1,7 @@
1
+ import state from '../state/index.js';
2
+ import getData from './getData.js';
3
+
4
+ export default function getEnv(target) {
5
+ return Object.fromEntries(Object.entries(getData('env', target))
6
+ .map(([key, value]) => [key, typeof value === 'function' ? value(state, target) : value]));
7
+ }
@@ -40,6 +40,7 @@ const BOOT_SCRIPT = [
40
40
  const VERSION = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
41
41
 
42
42
  const runnerName = (role) => `crossbind-runner-${role}`;
43
+ const localRunnerName = (role) => `crossbind-local-runner-${role}`;
43
44
  const runnerFiles = () => fs.readdirSync(RUNNER_SOURCE).filter((file) => file.endsWith('.js'));
44
45
  // Google ships the linux NDK for x86_64 only, so an android runner is always amd64.
45
46
  const platformOf = (role) => (role === 'android' ? 'linux/amd64' : undefined);
@@ -62,7 +63,7 @@ function assertRole(role) {
62
63
  export function runnerStartArgs({ role, port, host }) {
63
64
  const platform = platformOf(role);
64
65
  return [
65
- 'run', '-d', '--name', runnerName(role), '--restart', 'unless-stopped',
66
+ 'run', '-d', '--name', localRunnerName(role), '--restart', 'unless-stopped',
66
67
  ...(platform ? ['--platform', platform] : []),
67
68
  ...DOCKER_RUN_SECURITY_ARGS,
68
69
  '-p', `${host}:${port}:${RUNNER_PORT}`,
@@ -89,13 +90,24 @@ export function startRunner({ role = 'web', port = runnerHostPort(role), host =
89
90
  });
90
91
  // A token taken from the environment is shown by its variable, so the line printed for it puts no secret in a log.
91
92
  return {
92
- name: runnerName(role), url: `http://${host}:${port}`, token: secret, displayToken: fromEnvironment ? '$CROSSBIND_RUNNER_TOKEN' : secret,
93
+ name: localRunnerName(role), url: `http://${host}:${port}`, token: secret, displayToken: fromEnvironment ? '$CROSSBIND_RUNNER_TOKEN' : secret,
93
94
  };
94
95
  }
95
96
 
96
- export function stopRunner({ role = 'web' } = {}) {
97
+ // A container that is not there is already deleted: docker reports one on stderr even with -f, and some engines exit 1.
98
+ function removeContainer(name) {
99
+ try {
100
+ execFileSync('docker', ['rm', '-f', name], { stdio: ['ignore', 'inherit', 'pipe'] });
101
+ } catch (error) {
102
+ if (!/no such container/i.test(String(error.stderr))) throw error;
103
+ }
104
+ }
105
+
106
+ export function deleteRunner({ role = 'web' } = {}) {
97
107
  assertRole(role);
98
- execFileSync('docker', ['rm', '-f', runnerName(role)], { stdio: 'inherit' });
108
+ removeContainer(localRunnerName(role));
109
+ // An older crossbind started its local runner under the name the deployed runners take.
110
+ removeContainer(runnerName(role));
99
111
  }
100
112
 
101
113
  function dockerfile(role) {
@@ -36,13 +36,10 @@ export default function writeRuntimeEntry(entry, headers, runtime) {
36
36
 
37
37
  const IMPORT_HOOKS = new URL('../assets/node-import-hooks.mjs', import.meta.url);
38
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) => {
39
+ // Each bound header and Rust import by its real path relative to dir, with the bare specifiers that name it (imports,
40
+ // from findAppNativeImports) and its names, each with the module's name for it where the two differ.
41
+ function nativesOf(headers, imports, dir) {
42
+ return headers.map((header) => {
46
43
  const file = upath.normalize(fs.realpathSync(header.file));
47
44
  return {
48
45
  file: upath.relative(dir, file),
@@ -50,6 +47,13 @@ export function writeImportHooks(entry, headers, imports, extensions) {
50
47
  names: uniqueBindings(namesOf(header)).map(({ local, wire }) => (local === wire ? local : { local, wire })),
51
48
  };
52
49
  });
50
+ }
51
+
52
+ // The module `node --import` loads ahead of the app, so the app imports the bound headers themselves.
53
+ export function writeImportHooks(entry, headers, imports, extensions) {
54
+ // Node runs the register file from its real path, so the files it names are relative to that.
55
+ const dir = upath.normalize(fs.realpathSync(upath.dirname(entry)));
56
+ const natives = nativesOf(headers, imports, dir);
53
57
  writeIfChanged(`${dir}/hooks.mjs`, fs.readFileSync(IMPORT_HOOKS, 'utf8'));
54
58
  writeIfChanged(entry.replace(/\.mjs$/, '.register.mjs'), [
55
59
  '// Generated by crossbind - do not edit.',
@@ -60,6 +64,13 @@ export function writeImportHooks(entry, headers, imports, extensions) {
60
64
  ].join('\n'));
61
65
  }
62
66
 
67
+ // Beside a browser binary built with --bind-imports: what a page that resolves the app's imports itself (the
68
+ // playground) maps them with, each file relative to the project.
69
+ export function writeImportManifest(file, headers, imports, projectDir) {
70
+ const natives = nativesOf(headers, imports, upath.normalize(fs.realpathSync(projectDir)));
71
+ writeIfChanged(file, `${JSON.stringify({ natives }, null, 4)}\n`);
72
+ }
73
+
63
74
  // Once the sources import nothing native, no start loads hooks an earlier build left; hooks.mjs stays while the other
64
75
  // binary's hooks still import it.
65
76
  export function removeImportHooks(entry) {
@@ -2,9 +2,26 @@
2
2
  #include <emscripten/wasmfs.h>
3
3
  #include <limits.h>
4
4
  #include <unistd.h>
5
+ #include <wasi/api.h>
6
+
7
+ static backend_t hostBackend;
5
8
 
6
9
  backend_t wasmfs_create_root_dir(void) {
7
- return wasmfs_create_node_backend("/");
10
+ hostBackend = wasmfs_create_node_backend("/");
11
+ return hostBackend;
12
+ }
13
+
14
+ // Emscripten's Node backend traps on flush, which fsync and fdatasync call. A host file's writes have already
15
+ // reached the operating system, so both succeed there, as with NODERAWFS. buildWasm links these with --wrap.
16
+ extern "C" __wasi_errno_t __real___wasi_fd_sync(__wasi_fd_t fd);
17
+ extern "C" int __real___syscall_fdatasync(int fd);
18
+
19
+ extern "C" __wasi_errno_t __wrap___wasi_fd_sync(__wasi_fd_t fd) {
20
+ return wasmfs_get_backend_by_fd(fd) == hostBackend ? __WASI_ERRNO_SUCCESS : __real___wasi_fd_sync(fd);
21
+ }
22
+
23
+ extern "C" int __wrap___syscall_fdatasync(int fd) {
24
+ return wasmfs_get_backend_by_fd(fd) == hostBackend ? 0 : __real___syscall_fdatasync(fd);
8
25
  }
9
26
 
10
27
  void wasmfs_before_preload(void) {
@@ -24,12 +24,44 @@ let nextEmbindId = 1;
24
24
  const HANDLES = '__crossbindHandles';
25
25
  let remoteModule = null;
26
26
 
27
+ // How many ids name each object, as a raw pointer handed out twice is one object.
28
+ const holders = new WeakMap();
29
+
27
30
  function registerEmbindObject(obj) {
28
31
  const id = nextEmbindId++;
29
32
  embindRegistry.set(id, obj);
33
+ holders.set(obj, (holders.get(obj) ?? 0) + 1);
30
34
  return id;
31
35
  }
32
36
 
37
+ // What JavaScript owns: an object behind a smart pointer, or a copy embind made of a returned value or an object
38
+ // JavaScript constructed, which utils/embindObjectLifetime.js marks. A raw pointer stays its library's.
39
+ const ownsObject = (obj) => {
40
+ const $$ = obj?.$$;
41
+ return Boolean($$ && ($$.smartPtr || $$.count?.owned));
42
+ };
43
+
44
+ const freeIfOwned = (obj) => {
45
+ if (ownsObject(obj) && !obj.isDeleted()) obj.delete();
46
+ };
47
+
48
+ // The worker side of a handle the main thread no longer reaches: its id goes, and the object with the last id.
49
+ const RELEASE = '__crossbindRelease';
50
+
51
+ function releaseEmbindObjects(ids) {
52
+ for (const id of ids) {
53
+ const obj = embindRegistry.get(id);
54
+ if (!embindRegistry.delete(id)) continue;
55
+ const left = holders.get(obj) - 1;
56
+ if (left > 0) {
57
+ holders.set(obj, left);
58
+ } else {
59
+ holders.delete(obj);
60
+ freeIfOwned(obj);
61
+ }
62
+ }
63
+ }
64
+
33
65
  // Comlink answers every member with a remote one: JSON.stringify and String() called toJSON and Symbol.toPrimitive
34
66
  // on the worker's object, which has neither, and a member below the module is thenable. A handle answers them as a
35
67
  // direct-mode object does.
@@ -39,11 +71,27 @@ const LOCAL_MEMBERS = {
39
71
  [Symbol.toPrimitive]: (hint) => (hint === 'number' ? NaN : '[object Object]'),
40
72
  };
41
73
 
74
+ // The main thread side: once a handle is collected, the worker lets go of its object, the ids collected in one task
75
+ // in one message.
76
+ const collected = [];
77
+
78
+ function releaseLater(id) {
79
+ collected.push(id);
80
+ if (collected.length > 1) return;
81
+ setTimeout(() => {
82
+ const ids = collected.splice(0);
83
+ Promise.resolve(remoteModule?.[RELEASE](ids)).catch(() => {});
84
+ }, 0);
85
+ }
86
+
87
+ const handleWatcher = typeof FinalizationRegistry === 'function' ? new FinalizationRegistry(releaseLater) : null;
88
+
42
89
  function handleFor(id) {
43
90
  const handle = new Proxy(remoteModule[HANDLES][id], {
44
91
  get: (target, prop) => (Object.hasOwn(LOCAL_MEMBERS, prop) ? LOCAL_MEMBERS[prop] : target[prop]),
45
92
  });
46
93
  embindProxyIds.set(handle, id);
94
+ handleWatcher?.register(handle, id);
47
95
  return handle;
48
96
  }
49
97
 
@@ -126,6 +174,8 @@ Comlink.transferHandlers.set('embindVector', {
126
174
  ? { __comlinkProxy: true, __embindId: registerEmbindObject(elem) }
127
175
  : elem;
128
176
  }
177
+ // The main thread gets the array alone, so nothing can reach a vector the call handed over any more.
178
+ freeIfOwned(unwrapCoercionProxy(obj));
129
179
  return [elements, []];
130
180
  },
131
181
  deserialize(elements) {
@@ -246,6 +296,7 @@ export function moduleRoot(m) {
246
296
  value: new Proxy({}, { get: (_, id) => embindRegistry.get(Number(id)) }),
247
297
  configurable: true,
248
298
  });
299
+ Object.defineProperty(m, RELEASE, { value: releaseEmbindObjects, configurable: true });
249
300
  return wrapWithVectorCoercion(m);
250
301
  }
251
302
 
@@ -74,12 +74,13 @@ function buildLocateFile(config, adapter) {
74
74
  }
75
75
 
76
76
  // WASMFS's FS.writeFile writes at the end of a file that exists (emsdk 6.0.9 js_api.cpp), where the classic FS
77
- // replaces it; opening the file for writing empties it first.
77
+ // replaces it; opening the file for writing empties it first, unless the flags ask to append.
78
78
  function replaceOnWriteFile(FS) {
79
79
  const { writeFile } = FS;
80
- FS.writeFile = (path, data) => {
81
- FS.close(FS.open(path, 'w'));
82
- return writeFile(path, data);
80
+ FS.writeFile = (path, data, opts) => {
81
+ const appends = typeof opts?.flags === 'string' && opts.flags.startsWith('a');
82
+ if (!appends) FS.close(FS.open(path, 'w', opts?.mode));
83
+ return writeFile(path, data, opts);
83
84
  };
84
85
  }
85
86