@crossbind/plugin-metro 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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  A tool for seamless C++ integration with the Metro bundler.
4
4
 
5
5
  <a href="https://www.npmjs.com/package/@crossbind/plugin-metro">
6
- <img alt="NPM version" src="https://img.shields.io/npm/v/@crossbind/plugin-metro?style=for-the-badge" />
6
+ <img alt="NPM version" src="https://img.shields.io/npm/v/@crossbind/plugin-metro/beta?style=for-the-badge" />
7
7
  </a>
8
8
  <a href="https://github.com/crossbind/crossbind/blob/main/LICENSE">
9
9
  <img alt="License" src="https://img.shields.io/github/license/crossbind/crossbind?style=for-the-badge" />
@@ -20,15 +20,15 @@ To integrate crossbind into your project using Metro as a bundler, you can utili
20
20
 
21
21
  NPM
22
22
  ```sh
23
- npm install @crossbind/plugin-metro --save-dev
23
+ npm install @crossbind/plugin-metro@beta --save-dev
24
24
  ```
25
25
  or YARN
26
26
  ```sh
27
- yarn add @crossbind/plugin-metro --dev
27
+ yarn add @crossbind/plugin-metro@beta --dev
28
28
  ```
29
29
  or PNPM
30
30
  ```sh
31
- pnpm add @crossbind/plugin-metro --save-dev
31
+ pnpm add @crossbind/plugin-metro@beta --save-dev
32
32
  ```
33
33
  or BUN
34
34
  ```sh
@@ -73,3 +73,25 @@ const config = getDefaultConfig(__dirname);
73
73
  -module.exports = config;
74
74
  +module.exports = mergeConfig(config, newConfig);
75
75
  ```
76
+
77
+ ## Web
78
+ On Expo's web platform the plugin compiles the C++ to WebAssembly, the build the Vite plugin makes: single-threaded unless `crossbind.config.js` sets `target.runtime: 'mt'`.
79
+
80
+ Metro resolves imports against the files it found when it started, so the Conan packages and cargo builds the web build needs are staged first:
81
+ ```sh
82
+ npx crossbind-metro prepare-web
83
+ npx expo start --web
84
+ ```
85
+
86
+ `expo start --web` builds the wasm when the page first loads and serves it from the dev server. A multithreaded build also gets the `Cross-Origin-Opener-Policy` and `Cross-Origin-Embedder-Policy` headers it needs there. Metro does not watch the C++ sources; reload the page after an edit to rebuild.
87
+
88
+ `expo export` runs nothing after the bundle, so a web export takes a last command, which links the release wasm into the export:
89
+ ```sh
90
+ npx crossbind-metro prepare-web
91
+ npx expo export -p web --clear
92
+ npx crossbind-metro export-web dist
93
+ ```
94
+
95
+ Host `dist` as any static site (with `web.output: 'server'`, pass `dist/client`). A multithreaded build also needs `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` from the host.
96
+
97
+ If your Metro config sets `server.enhanceMiddleware`, set it before passing the config to `CrossbindMetroPlugin`, which chains it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossbind/plugin-metro",
3
- "version": "2.0.0-beta.62",
3
+ "version": "2.0.0-beta.64",
4
4
  "description": "crossbind Metro plugin: A tool for seamless C++ integration with the Metro bundler.",
5
5
  "homepage": "https://github.com/crossbind/crossbind/tree/main/plugins/metro#readme",
6
6
  "repository": {
@@ -9,12 +9,15 @@
9
9
  },
10
10
  "license": "MIT",
11
11
  "main": "src/metro-plugin.cjs",
12
+ "bin": {
13
+ "crossbind-metro": "src/cli.cjs"
14
+ },
12
15
  "peerDependencies": {
13
16
  "metro": "*",
14
17
  "@babel/core": "^7.0.0"
15
18
  },
16
19
  "dependencies": {
17
- "crossbind": "^2.0.0-beta.62",
20
+ "crossbind": "^2.0.0-beta.64",
18
21
  "@babel/runtime": "^7.29.7",
19
22
  "@react-native/metro-babel-transformer": "^0.87.1",
20
23
  "upath": "^3.0.8"
package/src/cli.cjs ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { WEB_FILES, requireWebTargets, buildWebGlue } = require('./web.cjs');
5
+
6
+ const USAGE = 'usage: crossbind-metro prepare-web\n crossbind-metro export-web [output-dir]';
7
+
8
+ // Metro resolves imports against the files it found when it started, so the Conan packages and cargo
9
+ // builds a web bundle needs are staged before `expo start --web` or `expo export`, as build_js.js
10
+ // stages them before the native bundles.
11
+ async function prepareWeb() {
12
+ const crossbind = await import('crossbind');
13
+ const { targetParams, release: target } = requireWebTargets(crossbind);
14
+ await crossbind.buildDependencies({ targetParams: { ...targetParams, buildType: [target.buildType] } });
15
+ }
16
+
17
+ // Expo's export copies public/ before Metro bundles and runs nothing after it, so the wasm the web
18
+ // bundle boots is linked here, from the bridges that bundling wrote, and put next to the bundle.
19
+ async function exportWeb(outputDir) {
20
+ const crossbind = await import('crossbind');
21
+ const { targetParams, release: target } = requireWebTargets(crossbind);
22
+ await buildWebGlue(crossbind, targetParams, target);
23
+ fs.mkdirSync(outputDir, { recursive: true });
24
+ WEB_FILES.forEach(({ name, output, isOptional }) => {
25
+ const filePath = `${crossbind.state.config.paths.build}/${target[output]}`;
26
+ if (isOptional && !fs.existsSync(filePath)) return;
27
+ fs.copyFileSync(filePath, path.join(outputDir, name));
28
+ });
29
+ console.log(`crossbind: linked ${target.path} into ${outputDir}`);
30
+ if (target.runtime === 'mt') {
31
+ console.log(`crossbind: ${outputDir} holds a multithreaded build; serve it with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp.`);
32
+ }
33
+ }
34
+
35
+ const [command, outputDir = 'dist'] = process.argv.slice(2);
36
+ const COMMANDS = {
37
+ 'prepare-web': () => prepareWeb(),
38
+ 'export-web': () => exportWeb(outputDir),
39
+ };
40
+ if (command === '--help' || command === '-h') {
41
+ console.log(USAGE);
42
+ } else if (!Object.hasOwn(COMMANDS, command ?? '')) {
43
+ console.error(USAGE);
44
+ process.exitCode = 1;
45
+ } else {
46
+ // Reported as the crossbind CLI reports a failure: one line, the whole stack with DEBUG=1.
47
+ COMMANDS[command]().catch((error) => {
48
+ const cause = error?.cause?.message ? `\n caused by: ${error.cause.message.split('\n')[0]}` : '';
49
+ console.error(process.env.DEBUG ? error : `${error?.message ?? error}${cause}`);
50
+ process.exitCode = 1;
51
+ });
52
+ }
@@ -1,12 +1,63 @@
1
+ const fs = require('node:fs');
1
2
  const path = require('node:path');
3
+ const { pipeline } = require('node:stream/promises');
4
+ const { WEB_FILES, getWebTargets, buildWebGlue } = require('./web.cjs');
2
5
 
3
- let getDependFilePathFunc;
4
- let crossbindState;
5
- import('crossbind').then(({ getDependFilePath, state }) => {
6
- getDependFilePathFunc = getDependFilePath;
7
- crossbindState = state;
6
+ let crossbind;
7
+ const crossbindPromise = import('crossbind').then((module) => {
8
+ crossbind = module;
8
9
  });
9
10
 
11
+ // A web page load asks for the glue first, so serving it is where a changed source or a newly
12
+ // imported header gets linked in. Requests that arrive meanwhile (a pthread pool's) share the build,
13
+ // and the wasm and data wait for it instead of reading what it is still writing.
14
+ let inFlightBuild = null;
15
+
16
+ async function serveWebFile({ targetParams, debug: target }, file, res) {
17
+ if (file.name === 'crossbind.js') {
18
+ inFlightBuild = inFlightBuild || buildWebGlue(crossbind, targetParams, target).finally(() => { inFlightBuild = null; });
19
+ }
20
+ await inFlightBuild;
21
+ // A page reloaded during the build has gone; piping a file to its response would leak the handle.
22
+ if (res.destroyed) return;
23
+ const filePath = `${crossbind.state.config.paths.build}/${target[file.output]}`;
24
+ if (!fs.existsSync(filePath)) {
25
+ res.statusCode = 404;
26
+ res.end();
27
+ return;
28
+ }
29
+ res.setHeader('Content-Type', file.contentType);
30
+ await pipeline(fs.createReadStream(filePath), res);
31
+ }
32
+
33
+ function createWebMiddleware(middleware) {
34
+ return (req, res, next) => {
35
+ crossbindPromise.then(() => {
36
+ const webTargets = getWebTargets(crossbind);
37
+ if (webTargets?.debug.runtime === 'mt') {
38
+ // A multithreaded wasm needs SharedArrayBuffer, which only a cross-origin isolated page has.
39
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
40
+ res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
41
+ }
42
+ // The boot code asks under Expo's experiments.baseUrl, which need not be the root.
43
+ const pathname = req.url.split('?')[0];
44
+ const file = webTargets && req.method === 'GET' && WEB_FILES.find(({ name }) => pathname.endsWith(`/${name}`));
45
+ if (!file) {
46
+ return middleware(req, res, next);
47
+ }
48
+ return serveWebFile(webTargets, file, res).catch((error) => {
49
+ // A page that reloads mid-download closes the response; nothing failed.
50
+ if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
51
+ console.error(error);
52
+ if (res.headersSent || res.destroyed) return;
53
+ res.statusCode = 500;
54
+ res.setHeader('Content-Type', 'text/plain');
55
+ res.end(error.message);
56
+ });
57
+ }).catch(next);
58
+ };
59
+ }
60
+
10
61
  module.exports = function CrossbindMetroPlugin(defaultConfig) {
11
62
  // Generated proxy modules (e.g. a cargo package's lib.rs) originate in package workspaces
12
63
  // outside the app's node_modules chain, so their bare imports (react-native, @babel/runtime,
@@ -32,9 +83,11 @@ module.exports = function CrossbindMetroPlugin(defaultConfig) {
32
83
  // The `crossbind` import above resolves asynchronously; until it lands (and for
33
84
  // platforms with no matching target) fall back to Metro's default resolution
34
85
  // instead of dereferencing undefined state.
35
- if (crossbindState && getDependFilePathFunc) {
36
- const target = crossbindState.targets.find((t) => t.platform === platform);
37
- const dependFilePath = target && getDependFilePathFunc(moduleName, target);
86
+ if (crossbind) {
87
+ const target = platform === 'web'
88
+ ? getWebTargets(crossbind)?.release
89
+ : crossbind.state.targets.find((t) => t.platform === platform);
90
+ const dependFilePath = target && crossbind.getDependFilePath(moduleName, target);
38
91
  if (dependFilePath) {
39
92
  return context.resolveRequest(context, dependFilePath, platform);
40
93
  }
@@ -47,5 +100,13 @@ module.exports = function CrossbindMetroPlugin(defaultConfig) {
47
100
  ...defaultConfig.transformer,
48
101
  babelTransformerPath: require.resolve('./metro-transformer.cjs'),
49
102
  },
103
+ server: {
104
+ ...defaultConfig.server,
105
+ // Expo's dev server serves the web app, and the wasm its bundle boots is served from here.
106
+ // Metro deprecates enhanceMiddleware, but Expo still builds its middleware stack from it.
107
+ enhanceMiddleware: (metroMiddleware, metroServer) => createWebMiddleware(
108
+ defaultConfig.server?.enhanceMiddleware?.(metroMiddleware, metroServer) ?? metroMiddleware,
109
+ ),
110
+ },
50
111
  };
51
112
  };
@@ -1,4 +1,7 @@
1
1
 
2
+ const path = require('node:path');
3
+ const { getWebTargets, requireWebTargets } = require('./web.cjs');
4
+
2
5
  const upstreamTransformer = (() => {
3
6
  try {
4
7
  return require('@expo/metro-config/babel-transformer');
@@ -15,33 +18,48 @@ let compilerClassPromise;
15
18
  const getCompilerClass = () => {
16
19
  if (compilerClassPromise) return compilerClassPromise;
17
20
  compilerClassPromise = new Promise((resolve, reject) => {
18
- import('crossbind').then(({ state, getCrossbindScript, getRustJsScript, createBridgeFile }) => {
19
- resolve({ state, getCrossbindScript, getRustJsScript, createBridgeFile });
21
+ import('crossbind').then(({
22
+ state, getCrossbindScript, getRustJsScript, createBridgeFile, getTargetParams, getFilteredBuildTargets, bindHeaderImports,
23
+ }) => {
24
+ resolve({
25
+ state, getCrossbindScript, getRustJsScript, createBridgeFile, getTargetParams, getFilteredBuildTargets, bindHeaderImports,
26
+ });
20
27
  }).catch((e) => reject(e));
21
28
  });
22
29
  return compilerClassPromise;
23
30
  };
24
31
 
25
32
  module.exports.transform = async ({ src, filename, ...rest }) => {
26
- const { state, getCrossbindScript, getRustJsScript, createBridgeFile } = await getCompilerClass();
33
+ const crossbind = await getCompilerClass();
34
+ const { state, getCrossbindScript, getRustJsScript, createBridgeFile } = crossbind;
35
+ const file = path.resolve(rest.options.projectRoot ?? '', filename);
27
36
  const headerRegex = new RegExp(`\\.(${state.config.ext.header.join('|')})$`);
28
37
  const moduleRegex = new RegExp(`\\.(${state.config.ext.module.join('|')})$`);
29
38
  if (headerRegex.test(filename) || moduleRegex.test(filename) || filename.endsWith('.rs')) {
30
39
  let target;
31
40
  if (rest.options.platform === 'ios') target = state.targets.find((t) => t.platform === 'ios');
32
41
  else if (rest.options.platform === 'android') target = state.targets.find((t) => t.platform === 'android');
33
- else target = state.targets.find((t) => t.platform === 'wasm');
42
+ else target = requireWebTargets(crossbind).release;
43
+ // Expo serves a web app under experiments.baseUrl, and the boot code asks for its wasm there.
44
+ const base = `${rest.options.customTransformOptions?.baseUrl ?? ''}/`;
34
45
 
35
46
  // Rust surfaces need no bridge file here: the native bridge is the package's generated
36
47
  // companion crate; this only emits the JS proxy module (same shape as the .h flow).
37
48
  if (filename.endsWith('.rs')) {
38
- return upstreamTransformer.transform({ src: getRustJsScript(target, filename), filename, ...rest });
49
+ return upstreamTransformer.transform({ src: getRustJsScript(target, filename, { base }), filename, ...rest });
39
50
  }
40
51
 
41
- const bridgeFile = createBridgeFile(filename, target);
52
+ const bridgeFile = createBridgeFile(file, target);
42
53
 
43
- return upstreamTransformer.transform({ src: getCrossbindScript(target, bridgeFile), filename, ...rest });
54
+ // Only a dev server keeps a header's module while the app's imports change: Xcode and Gradle bundle a release
55
+ // from a cleared cache.
56
+ return upstreamTransformer.transform({ src: getCrossbindScript(target, bridgeFile, { base, liveExports: rest.options.dev }), filename, ...rest });
44
57
  }
45
58
 
59
+ const appTarget = ['ios', 'android'].includes(rest.options.platform)
60
+ ? state.targets.find((t) => t.platform === rest.options.platform)
61
+ : getWebTargets(crossbind)?.release;
62
+ if (appTarget) crossbind.bindHeaderImports(src, file, appTarget);
63
+
46
64
  return upstreamTransformer.transform({ src, filename, ...rest });
47
65
  };
package/src/web.cjs ADDED
@@ -0,0 +1,48 @@
1
+ // The files the web bundle's boot code asks for, each one a build output of the web target.
2
+ const WEB_FILES = [
3
+ { name: 'crossbind.js', output: 'jsName', contentType: 'text/javascript' },
4
+ { name: 'crossbind.wasm', output: 'wasmName', contentType: 'application/wasm' },
5
+ { name: 'crossbind.data.txt', output: 'dataTxtName', contentType: 'text/plain', isOptional: true },
6
+ ];
7
+
8
+ // The build the Vite, webpack and Rollup plugins make for a browser: single-threaded unless
9
+ // crossbind.config.js sets target.runtime to 'mt'. Null when the config leaves no such target.
10
+ function getWebTargets({ getTargetParams, getFilteredBuildTargets }) {
11
+ const targetParams = getTargetParams({
12
+ platform: ['wasm'], arch: ['wasm32'], runtime: ['st'], runtimeEnv: ['browser'],
13
+ }, true);
14
+ const release = getFilteredBuildTargets(targetParams, { buildType: 'release' })[0];
15
+ const debug = getFilteredBuildTargets(targetParams, { buildType: 'debug' })[0];
16
+ if (!release && !debug) return null;
17
+ return { targetParams, release: release ?? debug, debug: debug ?? release };
18
+ }
19
+
20
+ function requireWebTargets(crossbind) {
21
+ const webTargets = getWebTargets(crossbind);
22
+ if (!webTargets) {
23
+ throw new Error('crossbind: crossbind.config.js leaves no wasm32 browser target to build the web app with.');
24
+ }
25
+ return webTargets;
26
+ }
27
+
28
+ // Metro transforms the .h imports in worker processes, so their bridges are read from the build
29
+ // directory, as the native builds read them.
30
+ async function buildWebGlue(crossbind, targetParams, target) {
31
+ const {
32
+ state, buildDependencies, isSourceNewer, createLib, getAllBridges, buildWasm,
33
+ } = crossbind;
34
+ await buildDependencies({ targetParams: { ...targetParams, buildType: [target.buildType] } });
35
+ const force = isSourceNewer(target);
36
+ const sourceBuilt = createLib(target, 'Source', { force, buildSource: true });
37
+ const bridgeBuilt = createLib(target, 'Bridge', {
38
+ force,
39
+ buildSource: false,
40
+ nativeGlob: [`${state.config.paths.cli}/assets/cpp-runtime/commonBridges.cpp`, ...getAllBridges()],
41
+ });
42
+ // A rebuilt lib must force the final link, or the wasm keeps the stale registrations.
43
+ await buildWasm(target, { force: force || Boolean(sourceBuilt) || Boolean(bridgeBuilt) });
44
+ }
45
+
46
+ module.exports = {
47
+ WEB_FILES, getWebTargets, requireWebTargets, buildWebGlue,
48
+ };