|
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'; |
14 | 8 | 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 |
26 | 19 | ): 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(); |
59 | 25 | } |
60 | 26 | }; |
| 27 | + reporter.addListener(onEvent); |
61 | 28 |
|
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 | + }); |
68 | 33 | }); |
69 | 34 | }; |
70 | 35 |
|
71 | 36 | export const getMetroInstance = async ( |
72 | | - isExpo = false |
| 37 | + options: MetroOptions, |
| 38 | + abortSignal: AbortSignal |
73 | 39 | ): 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; |
94 | 41 | const isDefaultPortAvailable = await isPortAvailable(METRO_PORT); |
95 | 42 |
|
96 | 43 | if (!isDefaultPortAvailable) { |
97 | 44 | throw new MetroPortUnavailableError(METRO_PORT); |
98 | 45 | } |
99 | 46 |
|
100 | | - const childProcess = await metro.nodeChildProcess; |
| 47 | + const Metro = getMetroPackage(projectRoot); |
101 | 48 |
|
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'; |
113 | 50 |
|
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); |
119 | 57 |
|
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], |
121 | 75 | }); |
| 76 | + server.keepAliveTimeout = 30000; |
122 | 77 |
|
123 | | - // Wait for Metro to be ready by monitoring stdout for "Dev server ready." |
124 | | - await waitForReady(childProcess); |
| 78 | + abortSignal.throwIfAborted(); |
125 | 79 |
|
126 | | - return { |
127 | | - dispose: async () => { |
128 | | - const isKilled = childProcess.kill('SIGTERM'); |
| 80 | + await ready; |
129 | 81 |
|
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 | + }), |
134 | 90 | }; |
135 | 91 | }; |
0 commit comments