Skip to content

Commit e821e8d

Browse files
authored
feat: run Metro internally (#27)
Currently, Harness runs Metro through the React Native CLI. This means the tool has no direct control over the configuration and relies on CLI parameters, as well as the user, to wrap the config with the withRnHarness function. By internalizing Metro and leveraging its JS API, Harness can dynamically augment the configuration on the fly. As a result, users no longer need to manually update the config file, and this approach also enables further optimizations.
1 parent 8c5aec3 commit e821e8d

File tree

16 files changed

+216
-140
lines changed

16 files changed

+216
-140
lines changed

apps/playground/metro.config.js

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
const { withNxMetro } = require('@nx/react-native');
22
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
33
const path = require('path');
4-
const { withRnHarness } = require('react-native-harness/metro');
54

65
const defaultConfig = getDefaultConfig(__dirname);
76

@@ -21,12 +20,10 @@ const customConfig = {
2120
},
2221
};
2322

24-
module.exports = withRnHarness(
25-
withNxMetro(mergeConfig(defaultConfig, customConfig), {
26-
watchFolders: [monorepoRoot],
27-
nodeModulesPaths: [
28-
path.resolve(projectRoot, 'node_modules'),
29-
path.resolve(monorepoRoot, 'node_modules'),
30-
],
31-
})
32-
);
23+
module.exports = withNxMetro(mergeConfig(defaultConfig, customConfig), {
24+
watchFolders: [monorepoRoot],
25+
nodeModulesPaths: [
26+
path.resolve(projectRoot, 'node_modules'),
27+
path.resolve(monorepoRoot, 'node_modules'),
28+
],
29+
});

packages/bundler-metro/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,17 @@
1616
}
1717
},
1818
"dependencies": {
19+
"@react-native-harness/metro": "workspace:*",
1920
"@react-native-harness/tools": "workspace:*",
21+
"connect": "^3.7.0",
22+
"nocache": "^4.0.0",
2023
"tslib": "^2.3.0"
2124
},
25+
"peerDependencies": {
26+
"metro": "*"
27+
},
2228
"devDependencies": {
29+
"@types/connect": "^3.4.38",
2330
"@types/node": "18.16.9"
2431
},
2532
"license": "MIT"

packages/bundler-metro/src/errors.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ export class MetroPortUnavailableError extends HarnessError {
77
}
88
}
99

10-
export class MetroBundlerNotReadyError extends HarnessError {
11-
constructor(public readonly maxRetries: number) {
12-
super(`Metro bundler is not ready after ${maxRetries} attempts`);
13-
this.name = 'MetroBundlerNotReadyError';
10+
export class MetroNotInstalledError extends HarnessError {
11+
constructor() {
12+
super(
13+
'Metro was not found in your project. This is unexpected. Please report this issue to the React Native Harness team.'
14+
);
15+
this.name = 'MetroNotInstalledError';
1416
}
1517
}
Lines changed: 66 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,91 @@
1-
import {
2-
getReactNativeCliPath,
3-
getExpoCliPath,
4-
spawn,
5-
logger,
6-
SubprocessError,
7-
} from '@react-native-harness/tools';
8-
import type { ChildProcess } from 'child_process';
9-
import { isPortAvailable } from './utils.js';
10-
import {
11-
MetroPortUnavailableError,
12-
MetroBundlerNotReadyError,
13-
} from './errors.js';
1+
import { withRnHarness } from '@react-native-harness/metro';
2+
import { logger } from '@react-native-harness/tools';
3+
import type { IncomingMessage, ServerResponse } from 'node:http';
4+
import connect from 'connect';
5+
import nocache from 'nocache';
6+
import { isPortAvailable, getMetroPackage } from './utils.js';
7+
import { MetroPortUnavailableError } from './errors.js';
148
import { METRO_PORT } from './constants.js';
15-
import type { MetroInstance } from './types.js';
16-
import assert from 'node:assert';
17-
import { createRequire } from 'node:module';
18-
19-
const INITIALIZATION_DONE_EVENT_TYPE = 'initialize_done';
20-
21-
const require = createRequire(import.meta.url);
22-
23-
const waitForReady = (
24-
metroProcess: ChildProcess,
25-
timeoutMs = 60000
9+
import type { MetroInstance, MetroOptions } from './types.js';
10+
import {
11+
type Reporter,
12+
withReporter,
13+
type ReportableEvent,
14+
} from './reporter.js';
15+
16+
const waitForBundler = async (
17+
reporter: Reporter,
18+
abortSignal: AbortSignal
2619
): Promise<void> => {
27-
return new Promise<void>((resolve, reject) => {
28-
const customPipe = metroProcess.stdio[3];
29-
assert(customPipe, 'customPipe is required');
30-
31-
// eslint-disable-next-line prefer-const
32-
let pipeListener: (data: Buffer) => void;
33-
// eslint-disable-next-line prefer-const
34-
let timer: NodeJS.Timeout;
35-
36-
const cleanup = () => {
37-
clearTimeout(timer);
38-
customPipe.off('data', pipeListener);
39-
};
40-
41-
pipeListener = (data) => {
42-
const text = data.toString().split('\n');
43-
44-
for (const line of text) {
45-
if (line.trim() === '') {
46-
continue;
47-
}
48-
49-
try {
50-
const event = JSON.parse(line);
51-
52-
if (event.type === INITIALIZATION_DONE_EVENT_TYPE) {
53-
cleanup();
54-
resolve();
55-
}
56-
} catch (error) {
57-
logger.error('Failed to parse event', error);
58-
}
20+
return new Promise((resolve, reject) => {
21+
const onEvent = (event: ReportableEvent) => {
22+
if (event.type === 'initialize_done') {
23+
reporter.removeListener(onEvent);
24+
resolve();
5925
}
6026
};
27+
reporter.addListener(onEvent);
6128

62-
customPipe.on('data', pipeListener);
63-
64-
timer = setTimeout(() => {
65-
cleanup();
66-
reject(new MetroBundlerNotReadyError(timeoutMs));
67-
}, timeoutMs);
29+
abortSignal.addEventListener('abort', () => {
30+
reporter.removeListener(onEvent);
31+
reject(new DOMException('The operation was aborted', 'AbortError'));
32+
});
6833
});
6934
};
7035

7136
export const getMetroInstance = async (
72-
isExpo = false
37+
options: MetroOptions,
38+
abortSignal: AbortSignal
7339
): Promise<MetroInstance> => {
74-
const metro = spawn(
75-
'node',
76-
[
77-
isExpo ? getExpoCliPath() : getReactNativeCliPath(),
78-
'start',
79-
'--port',
80-
METRO_PORT.toString(),
81-
'--customLogReporterPath',
82-
require.resolve('../assets/reporter.cjs'),
83-
],
84-
{
85-
stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
86-
env: {
87-
...process.env,
88-
RN_HARNESS: 'true',
89-
...(isExpo && { EXPO_NO_METRO_WORKSPACE_ROOT: 'true' }),
90-
},
91-
}
92-
);
93-
40+
const { projectRoot } = options;
9441
const isDefaultPortAvailable = await isPortAvailable(METRO_PORT);
9542

9643
if (!isDefaultPortAvailable) {
9744
throw new MetroPortUnavailableError(METRO_PORT);
9845
}
9946

100-
const childProcess = await metro.nodeChildProcess;
47+
const Metro = getMetroPackage(projectRoot);
10148

102-
// Forward metro output to logger
103-
if (childProcess.stdout) {
104-
childProcess.stdout.on('data', (data) => {
105-
logger.debug(data.toString().trim());
106-
});
107-
}
108-
if (childProcess.stderr) {
109-
childProcess.stderr.on('data', (data) => {
110-
logger.debug(data.toString().trim());
111-
});
112-
}
49+
process.env.RN_HARNESS = 'true';
11350

114-
metro.catch((error) => {
115-
// This process is going to be killed by us, so we don't need to throw an error
116-
if (error instanceof SubprocessError && error.signalName === 'SIGTERM') {
117-
return;
118-
}
51+
const projectMetroConfig = await Metro.loadConfig({
52+
port: METRO_PORT,
53+
projectRoot,
54+
});
55+
const config = await withRnHarness(projectMetroConfig)();
56+
const reporter = withReporter(config);
11957

120-
logger.error('Metro crashed unexpectedly', error);
58+
abortSignal.throwIfAborted();
59+
60+
const statusPageMiddleware = (_: IncomingMessage, res: ServerResponse) => {
61+
res.setHeader(
62+
'X-React-Native-Project-Root',
63+
new URL(`file:///${projectRoot}`).pathname.slice(1)
64+
);
65+
res.end('packager-status:running');
66+
};
67+
const middleware = connect()
68+
.use(nocache())
69+
.use('/status', statusPageMiddleware);
70+
71+
const ready = waitForBundler(reporter, abortSignal);
72+
const server = await Metro.runServer(config, {
73+
waitForBundler: true,
74+
unstable_extraMiddleware: [middleware],
12175
});
76+
server.keepAliveTimeout = 30000;
12277

123-
// Wait for Metro to be ready by monitoring stdout for "Dev server ready."
124-
await waitForReady(childProcess);
78+
abortSignal.throwIfAborted();
12579

126-
return {
127-
dispose: async () => {
128-
const isKilled = childProcess.kill('SIGTERM');
80+
await ready;
12981

130-
if (!isKilled) {
131-
childProcess.kill('SIGKILL');
132-
}
133-
},
82+
logger.debug('Metro server is running');
83+
84+
return {
85+
events: reporter,
86+
dispose: () =>
87+
new Promise<void>((resolve) => {
88+
server.close(() => resolve());
89+
}),
13490
};
13591
};
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { getMetroInstance } from './factory.js';
2-
export type { MetroInstance, MetroFactory } from './types.js';
2+
export type { MetroInstance, MetroFactory, MetroOptions } from './types.js';
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { getEmitter, type EventEmitter } from '@react-native-harness/tools';
2+
import type {
3+
MetroConfig,
4+
ReportableEvent as MetroReportableEvent,
5+
} from 'metro';
6+
7+
export type ReportableEvent =
8+
| MetroReportableEvent
9+
| {
10+
type: 'initialize_done';
11+
};
12+
13+
export type Reporter = EventEmitter<ReportableEvent>;
14+
15+
export const withReporter = (metroConfig: MetroConfig): Reporter => {
16+
const emitter = getEmitter<ReportableEvent>();
17+
18+
metroConfig.reporter = {
19+
update: (event: ReportableEvent) => {
20+
emitter.emit(event);
21+
},
22+
};
23+
24+
return emitter;
25+
};
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import type { Reporter } from './reporter.js';
2+
3+
export type MetroOptions = {
4+
projectRoot: string;
5+
};
6+
17
export type MetroInstance = {
8+
events: Reporter;
29
dispose: () => Promise<void>;
310
};
411

5-
export type MetroFactory = (isExpo: boolean) => Promise<MetroInstance>;
12+
export type MetroFactory = () => Promise<MetroInstance>;

packages/bundler-metro/src/utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import net from 'node:net';
2+
import { createRequire } from 'node:module';
3+
import { MetroNotInstalledError } from './errors.js';
4+
5+
const require = createRequire(import.meta.url);
26

37
export const isPortAvailable = (port: number): Promise<boolean> => {
48
return new Promise((resolve) => {
@@ -14,3 +18,14 @@ export const isPortAvailable = (port: number): Promise<boolean> => {
1418
server.listen(port);
1519
});
1620
};
21+
22+
export const getMetroPackage = (
23+
projectRoot: string
24+
): typeof import('metro') => {
25+
try {
26+
const metroPath = require.resolve('metro', { paths: [projectRoot] });
27+
return require(metroPath);
28+
} catch {
29+
throw new MetroNotInstalledError();
30+
}
31+
};

packages/bundler-metro/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
"files": [],
44
"include": [],
55
"references": [
6+
{
7+
"path": "../metro"
8+
},
69
{
710
"path": "../tools"
811
},

packages/bundler-metro/tsconfig.lib.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
},
1313
"include": ["src/**/*.ts"],
1414
"references": [
15+
{
16+
"path": "../metro/tsconfig.lib.json"
17+
},
1518
{
1619
"path": "../tools/tsconfig.lib.json"
1720
}

0 commit comments

Comments
 (0)