Skip to content

Commit 273481c

Browse files
motiz88facebook-github-bot
authored andcommitted
Add dedicated HTTP handler for fetching source contents
Summary: Changelog: * **[Feature]:** Serve source files at `/[metro-project]/` and `/[metro-watchFolders]/{index}/`. Creates a new set of HTTP GET endpoints for serving source and asset contents from Metro. The endpoints are designed to provide access to all of Metro's input files that are under [`projectRoot`](https://metrobundler.dev/docs/configuration/#projectroot) and/or [`watchFolders`](https://metrobundler.dev/docs/configuration/#watchfolders). As a security measure, access is restricted to files with valid source/asset extensions, and is subject to watcher config options like [`blockList`](https://metrobundler.dev/docs/configuration/#blocklist). In subsequent diffs, we'll change Metro's source map serializer to allow clients to opt into fetching source contents lazily through these endpoints. Reviewed By: robhogan Differential Revision: D56952064 fbshipit-source-id: 99821c358bf6d341e6fe9de362d4e3c1702e8523
1 parent 539a9e1 commit 273481c

File tree

5 files changed

+172
-1
lines changed

5 files changed

+172
-1
lines changed

packages/metro/src/Server.js

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ class Server {
143143
_platforms: Set<string>;
144144
_reporter: Reporter;
145145
_serverOptions: ServerOptions | void;
146+
_allowedSuffixesForSourceRequests: $ReadOnlyArray<string>;
147+
_sourceRequestRoutingMap: $ReadOnlyArray<
148+
[pathnamePrefix: string, normalizedRootDir: string],
149+
>;
146150

147151
constructor(config: ConfigT, options?: ServerOptions) {
148152
this._config = config;
@@ -158,6 +162,22 @@ class Server {
158162
this._reporter = config.reporter;
159163
this._logger = Logger;
160164
this._platforms = new Set(this._config.resolver.platforms);
165+
this._allowedSuffixesForSourceRequests = [
166+
...new Set(
167+
[
168+
...this._config.resolver.sourceExts,
169+
...this._config.watcher.additionalExts,
170+
...this._config.resolver.assetExts,
171+
].map(ext => '.' + ext),
172+
),
173+
];
174+
this._sourceRequestRoutingMap = [
175+
['/[metro-project]/', path.resolve(this._config.projectRoot)],
176+
...this._config.watchFolders.map((watchFolder, index) => [
177+
`/[metro-watchFolders]/${index}/`,
178+
path.resolve(watchFolder),
179+
]),
180+
];
161181
this._isEnded = false;
162182

163183
// TODO(T34760917): These two properties should eventually be instantiated
@@ -563,10 +583,69 @@ class Server {
563583
} else if (pathname === '/symbolicate') {
564584
await this._symbolicate(req, res);
565585
} else {
566-
next();
586+
let handled = false;
587+
for (const [pathnamePrefix, normalizedRootDir] of this
588+
._sourceRequestRoutingMap) {
589+
if (pathname.startsWith(pathnamePrefix)) {
590+
await this._processSourceRequest(
591+
req,
592+
res,
593+
pathnamePrefix,
594+
normalizedRootDir,
595+
);
596+
handled = true;
597+
break;
598+
}
599+
}
600+
if (!handled) {
601+
next();
602+
}
567603
}
568604
}
569605

606+
async _processSourceRequest(
607+
req: IncomingMessage,
608+
res: ServerResponse,
609+
pathnamePrefix: string,
610+
rootDir: string,
611+
): Promise<void> {
612+
const urlObj = url.parse(req.url, true);
613+
const relativePathname = nullthrows(urlObj.pathname).substr(
614+
pathnamePrefix.length,
615+
);
616+
if (
617+
!this._allowedSuffixesForSourceRequests.some(suffix =>
618+
relativePathname.endsWith(suffix),
619+
)
620+
) {
621+
res.writeHead(404);
622+
res.end();
623+
return;
624+
}
625+
const depGraph = await this._bundler.getBundler().getDependencyGraph();
626+
const filePath = path.join(rootDir, relativePathname);
627+
try {
628+
depGraph.getSha1(filePath);
629+
} catch {
630+
res.writeHead(404);
631+
res.end();
632+
return;
633+
}
634+
const mimeType = mime.lookup(path.basename(relativePathname));
635+
res.setHeader('Content-Type', mimeType);
636+
const stream = fs.createReadStream(filePath);
637+
stream.pipe(res);
638+
stream.on('error', error => {
639+
if (error.code === 'ENOENT') {
640+
res.writeHead(404);
641+
res.end();
642+
} else {
643+
res.writeHead(500);
644+
res.end();
645+
}
646+
});
647+
}
648+
570649
_createRequestProcessor<T>({
571650
createStartEntry,
572651
createEndEntry,

packages/metro/src/integration_tests/__tests__/server-test.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212

1313
const Metro = require('../../..');
1414
const execBundle = require('../execBundle');
15+
const fs = require('fs');
1516
const fetch = require('node-fetch');
17+
const path = require('path');
1618

1719
jest.unmock('cosmiconfig');
1820

@@ -117,4 +119,81 @@ describe('Metro development server serves bundles via HTTP', () => {
117119
);
118120
expect(response.status).toBe(500);
119121
});
122+
123+
describe('dedicated endpoints for serving source files', () => {
124+
test('under /[metro-project]/', async () => {
125+
const response = await fetch(
126+
'http://localhost:' +
127+
config.server.port +
128+
'/[metro-project]/TestBundle.js',
129+
);
130+
expect(response.status).toBe(200);
131+
expect(await response.text()).toEqual(
132+
await fs.promises.readFile(
133+
path.join(__dirname, '../basic_bundle/TestBundle.js'),
134+
'utf8',
135+
),
136+
);
137+
});
138+
139+
test('under /[metro-watchFolders]/', async () => {
140+
const response = await fetch(
141+
'http://localhost:' +
142+
config.server.port +
143+
'/[metro-watchFolders]/1/metro/src/integration_tests/basic_bundle/TestBundle.js',
144+
);
145+
expect(response.status).toBe(200);
146+
expect(await response.text()).toEqual(
147+
await fs.promises.readFile(
148+
path.join(__dirname, '../basic_bundle/TestBundle.js'),
149+
'utf8',
150+
),
151+
);
152+
});
153+
154+
test('under /[metro-project]/', async () => {
155+
const response = await fetch(
156+
'http://localhost:' +
157+
config.server.port +
158+
'/[metro-project]/TestBundle.js',
159+
);
160+
expect(response.status).toBe(200);
161+
expect(await response.text()).toEqual(
162+
await fs.promises.readFile(
163+
path.join(__dirname, '../basic_bundle/TestBundle.js'),
164+
'utf8',
165+
),
166+
);
167+
});
168+
169+
test('no access to files without source extensions', async () => {
170+
const response = await fetch(
171+
'http://localhost:' +
172+
config.server.port +
173+
'/[metro-project]/not_a_source_file.xyz',
174+
);
175+
expect(response.status).toBe(404);
176+
expect(await response.text()).not.toContain(
177+
await fs.promises.readFile(
178+
path.join(__dirname, '../basic_bundle/not_a_source_file.xyz'),
179+
'utf8',
180+
),
181+
);
182+
});
183+
184+
test('no access to source files excluded from the file map', async () => {
185+
const response = await fetch(
186+
'http://localhost:' +
187+
config.server.port +
188+
'/[metro-project]/excluded_from_file_map.js',
189+
);
190+
expect(response.status).toBe(404);
191+
expect(await response.text()).not.toContain(
192+
await fs.promises.readFile(
193+
path.join(__dirname, '../basic_bundle/excluded_from_file_map.js'),
194+
'utf8',
195+
),
196+
);
197+
});
198+
});
120199
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
export default '/* secret */';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/* secret */

packages/metro/src/integration_tests/metro.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ module.exports = {
2222
watchFolders: [path.resolve(__dirname, '../../../')],
2323
server: {port: 10028},
2424
resolver: {
25+
blockList: [/excluded_from_file_map\.js$/],
2526
useWatchman: false,
2627
},
2728
transformer: {

0 commit comments

Comments
 (0)