Skip to content

Commit b09e1e9

Browse files
committed
feat: run Metro internally
1 parent 7c0b563 commit b09e1e9

16 files changed

Lines changed: 215 additions & 140 deletions

File tree

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: 65 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,90 @@
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+
reject(new DOMException('The operation was aborted', 'AbortError'))
31+
);
6832
});
6933
};
7034

7135
export const getMetroInstance = async (
72-
isExpo = false
36+
options: MetroOptions,
37+
abortSignal: AbortSignal
7338
): 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-
39+
const { projectRoot } = options;
9440
const isDefaultPortAvailable = await isPortAvailable(METRO_PORT);
9541

9642
if (!isDefaultPortAvailable) {
9743
throw new MetroPortUnavailableError(METRO_PORT);
9844
}
9945

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

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-
}
48+
process.env.RN_HARNESS = 'true';
49+
50+
const projectMetroConfig = await Metro.loadConfig({
51+
port: METRO_PORT,
52+
projectRoot,
53+
});
54+
const config = await withRnHarness(projectMetroConfig)();
55+
const reporter = withReporter(config);
11356

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-
}
57+
abortSignal.throwIfAborted();
11958

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

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

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

130-
if (!isKilled) {
131-
childProcess.kill('SIGKILL');
132-
}
133-
},
81+
logger.debug('Metro server is running');
82+
83+
return {
84+
events: reporter,
85+
dispose: () =>
86+
new Promise<void>((resolve) => {
87+
server.close(() => resolve());
88+
}),
13489
};
13590
};
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)