chrome-devtools-frontend
Version:
Chrome DevTools UI
597 lines (547 loc) • 19.8 kB
JavaScript
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
const fs = require('fs');
const http = require('http');
const path = require('path');
const parseURL = require('url').parse;
const yargs = require('yargs');
const {hideBin} = require('yargs/helpers');
const argv = yargs(hideBin(process.argv)).argv;
const tracesMode = argv.traces || false;
const serverPort = parseInt(process.env.PORT, 10) || (tracesMode ? 11010 : 8090);
function getTestRunnerConfig() {
try {
return JSON.parse(process.env.TEST_RUNNER_JSON_CONFIG);
} catch {
// Return an empty object so any lookups return undefined
return {};
}
}
function getTestRunnerConfigSetting(settingKey, fallbackValue) {
const config = getTestRunnerConfig();
return config[settingKey] === undefined ? fallbackValue : config[settingKey];
}
/**
* When you run npm run components-server we run the script as is from scripts/,
* but when this server is run as part of a test suite it's run from
* out/Default/gen/scripts, so we have to do a bit of path mangling to figure
* out where we are.
*/
const [target, isRunningInGen] = (() => {
const regex = new RegExp(`out\\${path.sep}(.*)\\${path.sep}gen`);
const match = regex.exec(__dirname);
if (match) {
return [match[1], true];
}
return [argv.target || process.env.TARGET || 'Default', false];
})();
/**
* This configures the base of the URLs that are injected into each component
* doc example to load. By default it's /, so that we load /front_end/..., but
* this can be configured if you have a different file structure.
*/
const sharedResourcesBase =
argv.sharedResourcesBase || getTestRunnerConfigSetting('component-server-shared-resources-path', '/');
/**
* The server assumes that examples live in
* devtoolsRoot/out/Target/gen/front_end/ui/components/docs, but if you need to add a
* prefix you can pass this argument. Passing `foo` will redirect the server to
* look in devtoolsRoot/out/Target/gen/foo/front_end/ui/components/docs.
*/
const componentDocsBaseArg = argv.componentDocsBase || process.env.COMPONENT_DOCS_BASE ||
getTestRunnerConfigSetting('component-server-base-path', '');
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 out/Default to be robust to any folder structures.
*/
while (isRunningInGen && !pathToOutTargetDir.endsWith(`out${path.sep}${target}`)) {
pathToOutTargetDir = path.resolve(pathToOutTargetDir, '..');
}
/* If we are not running in out/Default, we'll assume the script is running from the repo root, and navigate to {CWD}/out/Target */
const pathToBuiltOutTargetDirectory =
isRunningInGen ? pathToOutTargetDir : path.resolve(path.join(process.cwd(), 'out', target));
let devtoolsRootFolder = path.resolve(
path.join(pathToBuiltOutTargetDirectory, 'gen'),
);
const fullCheckoutDevtoolsRootFolder = path.join(
devtoolsRootFolder,
'third_party',
'devtools-frontend',
'src',
);
if (__dirname.startsWith(fullCheckoutDevtoolsRootFolder)) {
devtoolsRootFolder = fullCheckoutDevtoolsRootFolder;
}
const componentDocsBaseFolder = path.join(
devtoolsRootFolder,
componentDocsBaseArg,
);
if (!fs.existsSync(devtoolsRootFolder)) {
console.error(
`ERROR: Generated front_end folder (${devtoolsRootFolder}) does not exist.`,
);
console.log(
'The components server works from the built Ninja output; you may need to run Ninja to update your built DevTools.',
);
console.log(
'If you build to a target other than default, you need to pass --target=X as an argument',
);
process.exit(1);
}
process.on('uncaughtException', error => {
console.error('uncaughtException', error);
});
process.on('unhandledRejection', error => {
console.error('unhandledRejection', error);
});
const server = http.createServer(
(req, res) => requestHandler(req, res).catch(err => send500(res, err)),
);
server.listen(serverPort, 'localhost');
server.once('listening', () => {
if (process.send) {
process.send(serverPort);
}
console.log(`Started components server at http://localhost:${serverPort}\n`);
console.log(
`ui/components/docs location: ${
path.relative(
process.cwd(),
path.join(
componentDocsBaseFolder,
'front_end',
'ui',
'components',
'docs',
),
)}`,
);
});
server.once('error', error => {
if (process.send) {
process.send('ERROR');
}
throw error;
});
// All paths that are injected globally into real DevTools, so we do the same
// to avoid styles being broken in the component server.
const styleSheetPaths = [
'front_end/design_system_tokens.css',
'front_end/application_tokens.css',
'front_end/ui/components/buttons/textButton.css',
'front_end/ui/legacy/inspectorCommon.css',
'front_end/ui/components/docs/component_docs_styles.css',
];
function createComponentIndexFile(componentPath, componentExamples) {
const componentName = componentPath.replace('/front_end/ui/components/docs/', '').replace(/_/g, ' ').replace('/', '');
// clang-format off
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>DevTools component: ${componentName}</title>
<style>
h1 { text-transform: capitalize; }
.example {
padding: 5px;
margin: 10px;
}
a:link,
a:visited {
color: blue;
text-decoration: underline;
}
a:hover {
text-decoration: none;
}
.example summary {
font-size: 20px;
}
.back-link {
padding-left: 5px;
font-size: 16px;
font-style: italic;
}
iframe { display: block; width: 100%; height: 400px; }
</style>
</head>
<body>
<h1>
${componentName}
<a class="back-link" href="/">Back to index</a>
</h1>
${componentExamples
.map(example => {
const fullPath = path.join(componentPath, example);
return `<details class="example">
<summary><a href="${fullPath}">${example
.replace('.html', '')
.replace(/-|_/g, ' ')}</a></summary>
<iframe src="${fullPath}"></iframe>
</details>`;
})
.join('\n')}
</body>
</html>`;
// clang-format on
}
function createServerIndexFile(componentNames) {
const linksToStyleSheets = styleSheetPaths
.map(
link => `<link rel="stylesheet" href="${sharedResourcesBase}${
path.join(
...link.split('/'),
)}" />`,
)
.join('\n');
// clang-format off
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>DevTools components</title>
${linksToStyleSheets}
</head>
<body id="index-page">
<h1>DevTools components</h1>
<ul class="components-list">
${componentNames
.map(name => {
const niceName = name.replace(/_/g, ' ');
return `<li><a href='/front_end/ui/components/docs/${name}'>${niceName}</a></li>`;
})
.join('\n')}
</ul>
</body>
</html>`;
// clang-format on
}
async function getExamplesForPath(filePath) {
const componentDirectory = path.join(componentDocsBaseFolder, filePath);
if (!(await checkFileExists(componentDirectory))) {
return null;
}
const allFiles = await fs.promises.readdir(componentDirectory);
const htmlExampleFiles = allFiles.filter(file => {
return path.extname(file) === '.html';
});
return createComponentIndexFile(filePath, htmlExampleFiles);
}
function respondWithHtml(response, html) {
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.writeHead(200);
response.write(html, 'utf8');
response.end();
}
function send404(response, message) {
response.writeHead(404);
response.write(message, 'utf8');
response.end();
}
function send500(response, error) {
response.writeHead(500);
response.write(error.toString(), 'utf8');
response.end();
}
async function checkFileExists(filePath) {
try {
const errorsAccessingFile = await fs.promises.access(
filePath,
fs.constants.R_OK,
);
return !errorsAccessingFile;
} catch {
return false;
}
}
/**
* @param {http.IncomingMessage} request
* @param {http.ServerResponse} response
*/
async function requestHandler(request, response) {
const filePath = parseURL(request.url).pathname;
if (filePath === '/favicon.ico') {
send404(response, '404, no favicon');
return;
}
if (['/', '/index.html'].includes(filePath) && tracesMode === false) {
const components = await fs.promises.readdir(
path.join(
componentDocsBaseFolder,
'front_end',
'ui',
'components',
'docs',
),
);
const html = createServerIndexFile(
components.filter(filePath => {
const stats = fs.lstatSync(
path.join(
componentDocsBaseFolder,
'front_end',
'ui',
'components',
'docs',
filePath,
),
);
// Filter out some build config files (tsconfig, d.ts, etc), and just list the directories.
return stats.isDirectory();
}),
);
respondWithHtml(response, html);
} else if (filePath.startsWith('/front_end/ui/components/docs') && path.extname(filePath) === '') {
// This means it's a component path like /breadcrumbs.
const componentHtml = await getExamplesForPath(filePath);
if (componentHtml !== null) {
respondWithHtml(response, componentHtml);
} else {
send404(response, '404, not a valid component');
}
return;
} else if (tracesMode) {
return handleTracesModeRequest(request, response, filePath);
} else if (/ui\/components\/docs\/(.+)\/(.+)\.html/.test(filePath)) {
/** This conditional checks if we are viewing an individual example's HTML
* file. e.g. localhost:8090/front_end/ui/components/docs/data_grid/basic.html For each
* example we inject themeColors.css into the page so all CSS variables
* that components use are available.
*/
/**
* We also let the user provide a different base path for any shared
* resources that we load. But if this is provided along with the
* componentDocsBaseArg, and the two are the same, we don't want to use the
* shared resources base, as it's part of the componentDocsBaseArg and
* therefore the URL is already correct.
*
* If we didn't get a componentDocsBaseArg or we did and it's different to
* the sharedResourcesBase, we use sharedResourcesBase.
*/
const baseUrlForSharedResource =
componentDocsBaseArg && componentDocsBaseArg.endsWith(sharedResourcesBase) ? '/' : `/${sharedResourcesBase}`;
const fullPath = path.join(componentDocsBaseFolder, filePath);
if (!(await checkFileExists(fullPath))) {
send404(response, '404, File not found');
return;
}
const fileContents = await fs.promises.readFile(fullPath, {
encoding: 'utf8',
});
const linksToStyleSheets = styleSheetPaths
.map(
link => `<link rel="stylesheet" href="${
path.join(
baseUrlForSharedResource,
...link.split('/'),
)}" type="text/css" />`,
)
.join('\n');
const toggleDarkModeScript = `<script type="module" src="${
path.join(
baseUrlForSharedResource,
'front_end',
'ui',
'components',
'docs',
'component_docs.js',
)}"></script>`;
const newFileContents = fileContents.replace('</head>', `${linksToStyleSheets}</head>`)
.replace('</body>', toggleDarkModeScript + '\n</body>');
respondWithHtml(response, newFileContents);
} else {
// This means it's an asset like a JS file or an image.
let fullPath = path.join(componentDocsBaseFolder, filePath);
if (fullPath.endsWith(path.join('locales', 'en-US.json')) &&
!componentDocsBaseFolder.includes(sharedResourcesBase)) {
/**
* If the path is for locales/en-US.json we special case the loading of that to fix the path so it works properly in the server.
* We also make sure that we take into account the shared resources base;
* but if the base folder already contains the shared resources base, we don't
* add it to the path, because otherwise that would cause the shared resources
* base to be duplicated in the fullPath.
*/
// Rewrite this path so we can load up the locale in the component-docs
let prefix = componentDocsBaseFolder;
if (sharedResourcesBase && !componentDocsBaseFolder.includes(sharedResourcesBase)) {
prefix = path.join(componentDocsBaseFolder, sharedResourcesBase);
}
fullPath = path.join(
prefix,
'front_end',
'core',
'i18n',
'locales',
'en-US.json',
);
}
if (!fullPath.startsWith(devtoolsRootFolder) && !fileIsInTestFolder) {
console.error(
`Path ${fullPath} is outside the DevTools Frontend root dir.`,
);
process.exit(1);
}
const fileExists = await checkFileExists(fullPath);
if (!fileExists) {
send404(response, '404, File not found');
return;
}
let encoding = 'utf8';
if (fullPath.endsWith('.js') || fullPath.endsWith('.mjs')) {
response.setHeader('Content-Type', 'text/javascript; charset=utf-8');
} else if (fullPath.endsWith('.css')) {
response.setHeader('Content-Type', 'text/css; charset=utf-8');
} else if (fullPath.endsWith('.wasm')) {
response.setHeader('Content-Type', 'application/wasm');
encoding = 'binary';
} else if (fullPath.endsWith('.svg')) {
response.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
} else if (fullPath.endsWith('.png')) {
response.setHeader('Content-Type', 'image/png');
encoding = 'binary';
} else if (fullPath.endsWith('.jpg')) {
response.setHeader('Content-Type', 'image/jpg');
encoding = 'binary';
} else if (fullPath.endsWith('.avif')) {
response.setHeader('Content-Type', 'image/avif');
encoding = 'binary';
} else if (fullPath.endsWith('.gz')) {
response.setHeader('Content-Type', 'application/gzip');
encoding = 'binary';
}
const fileContents = await fs.promises.readFile(fullPath, encoding);
response.writeHead(200);
response.write(fileContents, encoding);
response.end();
}
}
function createTracesIndexFile(traceFilenames) {
function pageFunction() {
const origin = new URL(location.href).origin;
document.body.addEventListener('click', async e => {
if (!e.target.matches('button')) {
return;
}
const filename = e.target.textContent;
const traceUrl = `${origin}/t/${filename}`;
const devtoolsLoadingTraceUrl = `devtools://devtools/bundled/devtools_app.html?loadTimelineFromURL=${traceUrl}`;
try {
await navigator.clipboard.writeText(devtoolsLoadingTraceUrl);
e.target.classList.add('clicked');
setTimeout(() => e.target.classList.remove('clicked'), 1000);
} catch (e) {
console.error(e);
}
});
}
// clang-format off
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Traces</title>
<style>
button {
appearance: none;
border: 0;
background: transparent;
font-size: 18px;
padding: 5px;
text-align: left;
}
button:hover {
background: aliceblue;
cursor: pointer;
}
button:active {cursor: copy;}
button.clicked {animation: 600ms cubic-bezier(0.65, 0.05, 0.36, 1) bam;}
button.clicked::after {
content: "Copied URL";
background-color: #e0e0e0;
margin-left: 6px;
font-size: 70%;
padding: 1px 3px;
animation: 500ms fadeOut ease 700ms forwards;
}
form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
}
@keyframes bam {
from {background-color: #66bb6a;}
to {background-color: aliceblue;}
}
@keyframes fadeOut {
from {opacity: 1;}
to {opacity: 0;}
}
</style>
</head>
<body id="traces-page">
<h1>First</h1>
<p><textarea cols=100>devtools://devtools/bundled/devtools_app.html</textarea>
<h1>Load OPP with fixture traces:</h1>
<form>
${traceFilenames
.map(filename => {
return `<button type=button>${filename}</button>`;
})
.join('\n')}
</form>
<script>
(${pageFunction.toString()})();
</script>
</body>
</html>`;
// clang-format on
}
/**
* @param {http.IncomingMessage} request
* @param {http.ServerResponse} response
* @param {string|null} filePath
*/
async function handleTracesModeRequest(request, response, filePath) {
const traceFolder = path.resolve(
path.join(process.cwd(), 'front_end/panels/timeline/fixtures/traces/'),
);
if (filePath === '/') {
const traceFilenames = fs.readdirSync(traceFolder).filter(f => f.includes('json'));
const html = createTracesIndexFile(traceFilenames);
respondWithHtml(response, html);
} else if (filePath.startsWith('/t/')) {
const fileName = filePath.replace('/t/', '');
const fullPath = path.resolve(path.join(traceFolder, fileName));
if (!fullPath.startsWith(traceFolder)) {
console.error(`Path ${fullPath} is outside trace fixtures folder.`);
process.exit(1);
}
const fileExists = await checkFileExists(fullPath);
if (!fileExists) {
return send404(response, '404, File not found');
}
let encoding = 'utf8';
if (fullPath.endsWith('.json')) {
response.setHeader('Content-Type', 'application/json');
} else if (fullPath.endsWith('.gz')) {
response.setHeader('Content-Type', 'application/gzip');
encoding = 'binary';
}
// Traces need CORS to be loaded by devtools:// origin
response.setHeader('Access-Control-Allow-Origin', '*');
const fileContents = await fs.promises.readFile(fullPath, encoding);
response.writeHead(200);
response.write(fileContents, encoding);
response.end();
} else {
console.error(`Unhandled traces mode request: ${filePath}`);
process.exit(1);
}
}