@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 +26 -4
- package/package.json +5 -2
- package/src/cli.cjs +52 -0
- package/src/metro-plugin.cjs +69 -8
- package/src/metro-transformer.cjs +25 -7
- package/src/web.cjs +48 -0
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.
|
|
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.
|
|
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
|
+
}
|
package/src/metro-plugin.cjs
CHANGED
|
@@ -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
|
|
4
|
-
|
|
5
|
-
|
|
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 (
|
|
36
|
-
const target =
|
|
37
|
-
|
|
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(({
|
|
19
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
52
|
+
const bridgeFile = createBridgeFile(file, target);
|
|
42
53
|
|
|
43
|
-
|
|
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
|
+
};
|