| // Copyright 2016 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| const fs = require('node:fs'); |
| const https = require('node:https'); |
| const path = require('node:path'); |
| const parseURL = require('node:url').parse; |
| const promisify = require('node:util').promisify; |
| const WebSocketServer = require('ws').Server; |
| |
| const remoteDebuggingPort = parseInt(process.env.REMOTE_DEBUGGING_PORT, 10) || 9222; |
| const port = parseInt(process.env.PORT, 10); |
| const requestedPort = port || port === 0 ? port : 8090; |
| const readFile = promisify(fs.readFile); |
| const exists = promisify(fs.exists); |
| |
| let pathToOutTargetDir = __dirname; |
| /** |
| * If we are in the gen directory, we need to find the out/Default folder to use |
| * as our base to find files from. We could do this with path.join(x, '..', |
| * '..') until we get the right folder, but that's brittle. It's better to |
| * search up for the directory containing args.gn to be robust to any folder structures. |
| */ |
| const fileSystemRootDirectory = path.parse(process.cwd()).root; |
| while (!fs.existsSync(path.join(pathToOutTargetDir, 'args.gn'))) { |
| pathToOutTargetDir = path.resolve(pathToOutTargetDir, '..'); |
| if (pathToOutTargetDir === fileSystemRootDirectory) { |
| console.error( |
| 'Could not find the build root directory. You must run the hosted server from within the build root directory containing the args.gn file for it to work (node gen/scripts/hosted_mode/server.js). The hosted mode server only works on the built output from DevTools, not from the source input.'); |
| process.exit(1); |
| } |
| } |
| // We care about everything in the gen/ directory, unless we are in a full checkout. |
| let devtoolsFolder = path.resolve(path.join(pathToOutTargetDir, 'gen')); |
| const fullCheckoutDevtoolsRootFolder = path.join(devtoolsFolder, 'third_party', 'devtools-frontend', 'src'); |
| if (__dirname.startsWith(fullCheckoutDevtoolsRootFolder)) { |
| devtoolsFolder = fullCheckoutDevtoolsRootFolder; |
| } |
| |
| // The certificate is taken from |
| // https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/tools/apache_config/webkit-httpd.pem |
| const options = { |
| key: fs.readFileSync(__dirname + '/key.pem'), |
| cert: fs.readFileSync(__dirname + '/cert.pem'), |
| }; |
| |
| const server = https.createServer(options, requestHandler); |
| server.once('error', error => { |
| if (process.send) { |
| process.send('ERROR'); |
| } |
| throw error; |
| }); |
| server.once('listening', () => { |
| // If port 0 was used, then requested and actual port will be different. |
| const actualPort = server.address().port; |
| if (process.send) { |
| process.send(actualPort); |
| } |
| console.log(`Started hosted mode server at http://localhost:${actualPort}\n`); |
| console.log('For info on using the hosted mode server, see our contributing docs:'); |
| console.log('https://goo.gle/devtools-contribution-guide'); |
| console.log('Tip: Look for the \'Development server options\' section\n'); |
| }); |
| const wss = new WebSocketServer({server}); |
| |
| wss.on('connection', ws => { |
| ws.on('message', (message, binary) => { |
| ws.send(message, {binary}); |
| }); |
| }); |
| |
| let delayResolve = null; |
| |
| server.listen(requestedPort); |
| |
| async function requestHandler(request, response) { |
| const url = parseURL(request.url); |
| const filePath = unescape(url.pathname); |
| |
| if (url.search === '?send_delayed' && delayResolve) { |
| delayResolve(); |
| delayResolve = null; |
| } |
| |
| if (filePath === '/') { |
| const landingURL = `http://localhost:${remoteDebuggingPort}#custom=true`; |
| sendResponse(200, `<html>Please go to <a href="${landingURL}">${landingURL}</a></html>`, 'utf8'); |
| return; |
| } |
| |
| const absoluteFilePath = path.join(devtoolsFolder, filePath); |
| if (!path.resolve(absoluteFilePath).startsWith(path.join(devtoolsFolder, '..'))) { |
| console.log(`File requested (${absoluteFilePath}) is outside of devtools folder: ${devtoolsFolder}`); |
| sendResponse(403, `403 - Access denied. File requested is outside of devtools folder: ${devtoolsFolder}`, 'utf8'); |
| return; |
| } |
| |
| const fileExists = await exists(absoluteFilePath); |
| if (!fileExists) { |
| console.log(`Cannot find file ${absoluteFilePath}. Requested URL: ${filePath}`); |
| sendResponse(404, '404 - File not found', 'utf8'); |
| return; |
| } |
| |
| let statusCode, data, headers; |
| const headersFileExists = await exists(absoluteFilePath + '.headers'); |
| if (headersFileExists) { |
| try { |
| const headersFile = await readFile(absoluteFilePath + '.headers', 'utf8'); |
| ({statusCode, headers} = parseRawResponse(headersFile)); |
| } catch (err) { |
| console.log(`Unable to read local file ${absoluteFilePath}.headers:`, err); |
| sendResponse(500, '500 - Internal Server Error', 'utf8'); |
| } |
| } |
| |
| let encoding = 'utf8'; |
| if (absoluteFilePath.endsWith('.wasm') || absoluteFilePath.endsWith('.png') || absoluteFilePath.endsWith('.jpg') || |
| absoluteFilePath.endsWith('.avif') || absoluteFilePath.endsWith('.dwp') || absoluteFilePath.endsWith('.dwo') || |
| absoluteFilePath.endsWith('.gz')) { |
| encoding = 'binary'; |
| } |
| |
| try { |
| data = await readFile(absoluteFilePath, encoding); |
| if (absoluteFilePath.endsWith('.rawresponse')) { |
| ({statusCode, data, headers} = parseRawResponse(data)); |
| } |
| sendResponse(statusCode || 200, data, encoding, headers); |
| } catch (err) { |
| console.log(`Unable to read local file ${absoluteFilePath}:`, err); |
| sendResponse(500, '500 - Internal Server Error', 'utf8'); |
| } |
| |
| function inferContentType(url) { |
| const path = parseURL(url).pathname; |
| |
| if (path.endsWith('.js') || path.endsWith('.mjs')) { |
| return 'text/javascript; charset=utf-8'; |
| } |
| if (path.endsWith('.css')) { |
| return 'text/css; charset=utf-8'; |
| } |
| if (path.endsWith('.wasm')) { |
| return 'application/wasm'; |
| } |
| if (path.endsWith('.svg')) { |
| return 'image/svg+xml; charset=utf-8'; |
| } |
| if (path.endsWith('.png')) { |
| return 'image/png'; |
| } |
| if (path.endsWith('.jpg')) { |
| return 'image/jpg'; |
| } |
| if (path.endsWith('.avif')) { |
| return 'image/avif'; |
| } |
| if (path.endsWith('.gz')) { |
| return 'application/gzip'; |
| } |
| return null; |
| } |
| |
| async function sendResponse(statusCode, data, encoding, headers) { |
| if (url.search === '?delay') { |
| delayPromise = new Promise(resolve => { |
| delayResolve = resolve; |
| }); |
| await delayPromise; |
| } |
| if (!headers) { |
| headers = new Map(); |
| } |
| if (!headers.get('Content-Type')) { |
| const inferredContentType = inferContentType(request.url); |
| if (inferredContentType) { |
| headers.set('Content-Type', inferredContentType); |
| } |
| } |
| if (!headers.get('Cache-Control')) { |
| // Lets reduce Disk I/O by allowing clients to cache resources. |
| // This is fine to do given that test invocations run against fresh Chrome profiles. |
| headers.set('Cache-Control', 'max-age=3600'); |
| } |
| if (!headers.get('Access-Control-Allow-Origin')) { |
| // The DevTools frontend in hosted-mode uses regular fetch to get source maps etc. |
| // Disable CORS only for the DevTools frontend, not for resource/target pages. |
| // Since Chrome will cache resources, we have to indicate that CORS can still vary |
| // based on the origin that made the request. E.g. the target page loads a script first |
| // but then DevTools also wants to load it. In the former, we disallow cross-origin requests by default, |
| // while for the latter we allow it. |
| const requestedByDevTools = request.headers.origin?.includes('devtools-frontend.test') || |
| request.headers.origin?.includes('devtools://devtools'); |
| if (requestedByDevTools) { |
| headers.set('Access-Control-Allow-Origin', request.headers.origin); |
| } |
| headers.set('Vary', 'Origin'); |
| } |
| |
| if (request.url.endsWith('.gz')) { |
| headers.set('Content-Encoding', 'gzip'); |
| } |
| |
| headers.forEach((value, header) => { |
| response.setHeader(header, value); |
| }); |
| |
| const waitBeforeHeaders = parseInt(url.searchParams?.get('waitBeforeHeaders'), 10); |
| if (!isNaN(waitBeforeHeaders)) { |
| await new Promise(resolve => setTimeout(resolve, waitBeforeHeaders)); |
| } |
| response.writeHead(statusCode); |
| if (data && encoding) { |
| const waitBetweenChunks = parseInt(url.searchParams?.get('waitBetweenChunks'), 10); |
| const numChunks = parseInt(url.searchParams?.get('numChunks'), 10); |
| const chunkSize = isNaN(numChunks) ? data.length : data.length / numChunks; |
| for (let i = 0; i < data.length; i += chunkSize) { |
| if (!isNaN(waitBetweenChunks)) { |
| await new Promise(resolve => setTimeout(resolve, waitBetweenChunks)); |
| } |
| const chunk = data.subarray ? data.subarray(i, i + chunkSize) : data.substring(i, i + chunkSize); |
| response.write(chunk, encoding); |
| } |
| } |
| response.end(); |
| } |
| |
| function parseRawResponse(rawResponse) { |
| const newline = '\n'; |
| const lines = rawResponse.split(newline); |
| |
| let isHeader = true; |
| let line = lines.shift(); |
| const statusCode = parseInt(line, 10); |
| |
| const headers = new Map(); |
| let data = ''; |
| |
| while ((line = lines.shift()) !== undefined) { |
| if (line.trim() === '') { |
| if (!isHeader) { |
| // The first empty line should be omitted as it indicates the transition from headers to body. |
| // All those that follow should be included in the response body. |
| data += line + newline; |
| } |
| isHeader = false; |
| if (request.headers['if-none-match'] && response.getHeader('ETag') === request.headers['if-none-match']) { |
| return {statusCode: 304}; |
| } |
| continue; |
| } |
| |
| if (isHeader) { |
| const firstColon = line.indexOf(':'); |
| let headerValue = line.substring(firstColon + 1).trim(); |
| headerValue = headerValue.replace('$host_port', `${server.address().port}`); |
| headers.set(line.substring(0, firstColon), headerValue); |
| } else { |
| data += line + newline; |
| } |
| } |
| |
| return {statusCode, data, headers}; |
| } |
| } |