chore(trace viewer): split SnapshotServer (#5210)
- Move service worker under /snapshot/ instead of /. - Fix stylesheet base uri bug, where we inherited the wrong base url. - Introduce TraceServer and routes there, split the actual routes between snapshot, ui and action previews.
This commit is contained in:
parent
79e00e4911
commit
5e934d0fbd
|
|
@ -18,31 +18,31 @@ import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as playwright from '../../..';
|
import * as playwright from '../../..';
|
||||||
import * as util from 'util';
|
import * as util from 'util';
|
||||||
import { actionById, ActionEntry, ContextEntry, PageEntry, TraceModel } from './traceModel';
|
import { actionById, ActionEntry, ContextEntry, TraceModel } from './traceModel';
|
||||||
import { SnapshotServer } from './snapshotServer';
|
import { SnapshotServer } from './snapshotServer';
|
||||||
|
|
||||||
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
|
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
|
||||||
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
|
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
|
||||||
|
|
||||||
export class ScreenshotGenerator {
|
export class ScreenshotGenerator {
|
||||||
private _traceStorageDir: string;
|
private _resourcesDir: string;
|
||||||
private _browserPromise: Promise<playwright.Browser>;
|
private _browserPromise: Promise<playwright.Browser>;
|
||||||
private _serverPromise: Promise<SnapshotServer>;
|
private _snapshotServer: SnapshotServer;
|
||||||
private _traceModel: TraceModel;
|
private _traceModel: TraceModel;
|
||||||
private _rendering = new Map<ActionEntry, Promise<Buffer | undefined>>();
|
private _rendering = new Map<ActionEntry, Promise<Buffer | undefined>>();
|
||||||
private _lock = new Lock(3);
|
private _lock = new Lock(3);
|
||||||
|
|
||||||
constructor(resourcesDir: string, traceModel: TraceModel) {
|
constructor(snapshotServer: SnapshotServer, resourcesDir: string, traceModel: TraceModel) {
|
||||||
this._traceStorageDir = resourcesDir;
|
this._snapshotServer = snapshotServer;
|
||||||
|
this._resourcesDir = resourcesDir;
|
||||||
this._traceModel = traceModel;
|
this._traceModel = traceModel;
|
||||||
this._browserPromise = playwright.chromium.launch();
|
this._browserPromise = playwright.chromium.launch();
|
||||||
this._serverPromise = SnapshotServer.create(undefined, resourcesDir, traceModel, undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
generateScreenshot(actionId: string): Promise<Buffer | undefined> {
|
generateScreenshot(actionId: string): Promise<Buffer | undefined> {
|
||||||
const { context, action, page } = actionById(this._traceModel, actionId);
|
const { context, action } = actionById(this._traceModel, actionId);
|
||||||
if (!this._rendering.has(action)) {
|
if (!this._rendering.has(action)) {
|
||||||
this._rendering.set(action, this._render(context, page, action).then(body => {
|
this._rendering.set(action, this._render(context, action).then(body => {
|
||||||
this._rendering.delete(action);
|
this._rendering.delete(action);
|
||||||
return body;
|
return body;
|
||||||
}));
|
}));
|
||||||
|
|
@ -50,8 +50,8 @@ export class ScreenshotGenerator {
|
||||||
return this._rendering.get(action)!;
|
return this._rendering.get(action)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _render(contextEntry: ContextEntry, pageEntry: PageEntry, actionEntry: ActionEntry): Promise<Buffer | undefined> {
|
private async _render(contextEntry: ContextEntry, actionEntry: ActionEntry): Promise<Buffer | undefined> {
|
||||||
const imageFileName = path.join(this._traceStorageDir, actionEntry.action.timestamp + '-screenshot.png');
|
const imageFileName = path.join(this._resourcesDir, actionEntry.action.timestamp + '-screenshot.png');
|
||||||
try {
|
try {
|
||||||
return await fsReadFileAsync(imageFileName);
|
return await fsReadFileAsync(imageFileName);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -60,7 +60,6 @@ export class ScreenshotGenerator {
|
||||||
|
|
||||||
const { action } = actionEntry;
|
const { action } = actionEntry;
|
||||||
const browser = await this._browserPromise;
|
const browser = await this._browserPromise;
|
||||||
const server = await this._serverPromise;
|
|
||||||
|
|
||||||
await this._lock.obtain();
|
await this._lock.obtain();
|
||||||
|
|
||||||
|
|
@ -70,15 +69,11 @@ export class ScreenshotGenerator {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(server.snapshotRootUrl());
|
await page.goto(this._snapshotServer.snapshotRootUrl());
|
||||||
await page.evaluate(async () => {
|
|
||||||
navigator.serviceWorker.register('/service-worker.js');
|
|
||||||
await new Promise(resolve => navigator.serviceWorker.oncontrollerchange = resolve);
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshots = action.snapshots || [];
|
const snapshots = action.snapshots || [];
|
||||||
const snapshotId = snapshots.length ? snapshots[0].snapshotId : undefined;
|
const snapshotId = snapshots.length ? snapshots[0].snapshotId : undefined;
|
||||||
const snapshotUrl = server.snapshotUrl(action.pageId!, snapshotId, action.endTime);
|
const snapshotUrl = this._snapshotServer.snapshotUrl(action.pageId!, snapshotId, action.endTime);
|
||||||
console.log('Generating screenshot for ' + action.action); // eslint-disable-line no-console
|
console.log('Generating screenshot for ' + action.action); // eslint-disable-line no-console
|
||||||
await page.evaluate(snapshotUrl => (window as any).showSnapshot(snapshotUrl), snapshotUrl);
|
await page.evaluate(snapshotUrl => (window as any).showSnapshot(snapshotUrl), snapshotUrl);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,29 +18,16 @@ import * as http from 'http';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { TraceModel, trace } from './traceModel';
|
import type { TraceModel, trace } from './traceModel';
|
||||||
import type { ScreenshotGenerator } from './screenshotGenerator';
|
import { TraceServer } from './traceServer';
|
||||||
|
|
||||||
export class SnapshotServer {
|
export class SnapshotServer {
|
||||||
static async create(traceViewerDir: string | undefined, resourcesDir: string | undefined, traceModel: TraceModel, screenshotGenerator: ScreenshotGenerator | undefined): Promise<SnapshotServer> {
|
|
||||||
const server = new SnapshotServer(traceViewerDir, resourcesDir, traceModel, screenshotGenerator);
|
|
||||||
await new Promise(cb => server._server.once('listening', cb));
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _traceViewerDir: string | undefined;
|
|
||||||
private _resourcesDir: string | undefined;
|
private _resourcesDir: string | undefined;
|
||||||
private _traceModel: TraceModel;
|
private _server: TraceServer;
|
||||||
private _server: http.Server;
|
|
||||||
private _resourceById: Map<string, trace.NetworkResourceTraceEvent>;
|
private _resourceById: Map<string, trace.NetworkResourceTraceEvent>;
|
||||||
private _screenshotGenerator: ScreenshotGenerator | undefined;
|
|
||||||
|
|
||||||
constructor(traceViewerDir: string | undefined, resourcesDir: string | undefined, traceModel: TraceModel, screenshotGenerator: ScreenshotGenerator | undefined) {
|
constructor(server: TraceServer, traceModel: TraceModel, resourcesDir: string | undefined) {
|
||||||
this._traceViewerDir = traceViewerDir;
|
|
||||||
this._resourcesDir = resourcesDir;
|
this._resourcesDir = resourcesDir;
|
||||||
this._traceModel = traceModel;
|
this._server = server;
|
||||||
this._screenshotGenerator = screenshotGenerator;
|
|
||||||
this._server = http.createServer(this._onRequest.bind(this));
|
|
||||||
this._server.listen();
|
|
||||||
|
|
||||||
this._resourceById = new Map();
|
this._resourceById = new Map();
|
||||||
for (const contextEntry of traceModel.contexts) {
|
for (const contextEntry of traceModel.contexts) {
|
||||||
|
|
@ -50,74 +37,25 @@ export class SnapshotServer {
|
||||||
pageEntry.resources.forEach(r => this._resourceById.set(r.resourceId, r));
|
pageEntry.resources.forEach(r => this._resourceById.set(r.resourceId, r));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private _urlPrefix() {
|
server.routePath('/snapshot/', this._serveSnapshotRoot.bind(this), true);
|
||||||
const address = this._server.address();
|
server.routePath('/snapshot/service-worker.js', this._serveServiceWorker.bind(this));
|
||||||
return typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`;
|
server.routePrefix('/resources/', this._serveResource.bind(this));
|
||||||
}
|
|
||||||
|
|
||||||
traceViewerUrl(relative: string) {
|
|
||||||
return this._urlPrefix() + '/traceviewer/' + relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotRootUrl() {
|
snapshotRootUrl() {
|
||||||
return this._urlPrefix() + '/snapshot/';
|
return this._server.urlPrefix() + '/snapshot/';
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotUrl(pageId: string, snapshotId?: string, timestamp?: number) {
|
snapshotUrl(pageId: string, snapshotId?: string, timestamp?: number) {
|
||||||
|
// Prefer snapshotId over timestamp.
|
||||||
if (snapshotId)
|
if (snapshotId)
|
||||||
return this._urlPrefix() + `/snapshot/pageId/${pageId}/snapshotId/${snapshotId}/main`;
|
return this._server.urlPrefix() + `/snapshot/pageId/${pageId}/snapshotId/${snapshotId}/main`;
|
||||||
if (timestamp)
|
if (timestamp)
|
||||||
return this._urlPrefix() + `/snapshot/pageId/${pageId}/timestamp/${timestamp}/main`;
|
return this._server.urlPrefix() + `/snapshot/pageId/${pageId}/timestamp/${timestamp}/main`;
|
||||||
return 'data:text/html,Snapshot is not available';
|
return 'data:text/html,Snapshot is not available';
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
|
||||||
// This server serves:
|
|
||||||
// - "/traceviewer/..." - our frontend;
|
|
||||||
// - "/sha1/<sha1>" - trace resources;
|
|
||||||
// - "/tracemodel" - json with trace model;
|
|
||||||
// - "/resources/<resourceId>" - network resources from the trace;
|
|
||||||
// - "/file?filePath" - local files for sources tab;
|
|
||||||
// - "/action-preview/..." - lazily generated action previews;
|
|
||||||
// - "/snapshot/" - root for snapshot frame;
|
|
||||||
// - "/snapshot/pageId/..." - actual snapshot html;
|
|
||||||
// - "/service-worker.js" - service worker that intercepts snapshot resources
|
|
||||||
// and translates them into "/resources/<resourceId>".
|
|
||||||
|
|
||||||
request.on('error', () => response.end());
|
|
||||||
if (!request.url)
|
|
||||||
return response.end();
|
|
||||||
|
|
||||||
const url = new URL('http://localhost' + request.url);
|
|
||||||
// These two entry points do not require referrer check.
|
|
||||||
if (url.pathname.startsWith('/traceviewer/') && this._serveTraceViewer(request, response, url.pathname))
|
|
||||||
return;
|
|
||||||
if (url.pathname === '/snapshot/' && this._serveSnapshotRoot(request, response))
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Only serve the rest when referrer is present to avoid exposure.
|
|
||||||
const hasReferrer = request.headers['referer'] && request.headers['referer'].startsWith(this._urlPrefix());
|
|
||||||
if (!hasReferrer)
|
|
||||||
return response.end();
|
|
||||||
if (url.pathname.startsWith('/resources/') && this._serveResource(request, response, url.pathname))
|
|
||||||
return;
|
|
||||||
if (url.pathname.startsWith('/sha1/') && this._serveSha1(request, response, url.pathname))
|
|
||||||
return;
|
|
||||||
if (url.pathname.startsWith('/action-preview/') && this._serveActionPreview(request, response, url.pathname))
|
|
||||||
return;
|
|
||||||
if (url.pathname === '/file' && this._serveFile(request, response, url.search))
|
|
||||||
return;
|
|
||||||
if (url.pathname === '/service-worker.js' && this._serveServiceWorker(request, response))
|
|
||||||
return;
|
|
||||||
if (url.pathname === '/tracemodel' && this._serveTraceModel(request, response))
|
|
||||||
return;
|
|
||||||
|
|
||||||
response.statusCode = 404;
|
|
||||||
response.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _serveSnapshotRoot(request: http.IncomingMessage, response: http.ServerResponse): boolean {
|
private _serveSnapshotRoot(request: http.IncomingMessage, response: http.ServerResponse): boolean {
|
||||||
response.statusCode = 200;
|
response.statusCode = 200;
|
||||||
response.setHeader('Cache-Control', 'public, max-age=31536000');
|
response.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||||
|
|
@ -139,13 +77,18 @@ export class SnapshotServer {
|
||||||
</style>
|
</style>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
|
navigator.serviceWorker.register('./service-worker.js');
|
||||||
|
|
||||||
|
let showPromise = Promise.resolve();
|
||||||
|
if (!navigator.serviceWorker.controller)
|
||||||
|
showPromise = new Promise(resolve => navigator.serviceWorker.oncontrollerchange = resolve);
|
||||||
|
|
||||||
let current = document.createElement('iframe');
|
let current = document.createElement('iframe');
|
||||||
document.body.appendChild(current);
|
document.body.appendChild(current);
|
||||||
let next = document.createElement('iframe');
|
let next = document.createElement('iframe');
|
||||||
document.body.appendChild(next);
|
document.body.appendChild(next);
|
||||||
next.style.visibility = 'hidden';
|
next.style.visibility = 'hidden';
|
||||||
|
|
||||||
let showPromise = Promise.resolve();
|
|
||||||
let nextUrl;
|
let nextUrl;
|
||||||
window.showSnapshot = url => {
|
window.showSnapshot = url => {
|
||||||
if (!nextUrl) {
|
if (!nextUrl) {
|
||||||
|
|
@ -172,7 +115,7 @@ export class SnapshotServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _serveServiceWorker(request: http.IncomingMessage, response: http.ServerResponse): boolean {
|
private _serveServiceWorker(request: http.IncomingMessage, response: http.ServerResponse): boolean {
|
||||||
function serviceWorkerMain(self: any /* ServiceWorkerGlobalScope */, urlPrefix: string) {
|
function serviceWorkerMain(self: any /* ServiceWorkerGlobalScope */) {
|
||||||
let traceModel: TraceModel;
|
let traceModel: TraceModel;
|
||||||
|
|
||||||
function preprocessModel() {
|
function preprocessModel() {
|
||||||
|
|
@ -195,7 +138,7 @@ export class SnapshotServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.addEventListener('install', function(event: any) {
|
self.addEventListener('install', function(event: any) {
|
||||||
event.waitUntil(fetch('./tracemodel').then(async response => {
|
event.waitUntil(fetch('/tracemodel').then(async response => {
|
||||||
traceModel = await response.json();
|
traceModel = await response.json();
|
||||||
preprocessModel();
|
preprocessModel();
|
||||||
}));
|
}));
|
||||||
|
|
@ -212,8 +155,8 @@ export class SnapshotServer {
|
||||||
parts.shift();
|
parts.shift();
|
||||||
if (!parts[parts.length - 1])
|
if (!parts[parts.length - 1])
|
||||||
parts.pop();
|
parts.pop();
|
||||||
// snapshot/pageId/<pageId>/snapshotId/<snapshotId>/<frameId>
|
// - /snapshot/pageId/<pageId>/snapshotId/<snapshotId>/<frameId>
|
||||||
// snapshot/pageId/<pageId>/timestamp/<timestamp>/<frameId>
|
// - /snapshot/pageId/<pageId>/timestamp/<timestamp>/<frameId>
|
||||||
if (parts.length !== 6 || parts[0] !== 'snapshot' || parts[1] !== 'pageId' || (parts[3] !== 'snapshotId' && parts[3] !== 'timestamp'))
|
if (parts.length !== 6 || parts[0] !== 'snapshot' || parts[1] !== 'pageId' || (parts[3] !== 'snapshotId' && parts[3] !== 'timestamp'))
|
||||||
throw new Error(`Unexpected url "${urlString}"`);
|
throw new Error(`Unexpected url "${urlString}"`);
|
||||||
return {
|
return {
|
||||||
|
|
@ -243,13 +186,11 @@ export class SnapshotServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doFetch(event: any /* FetchEvent */): Promise<Response> {
|
async function doFetch(event: any /* FetchEvent */): Promise<Response> {
|
||||||
for (const prefix of ['/traceviewer/', '/sha1/', '/resources/', '/file?', '/action-preview/']) {
|
try {
|
||||||
if (event.request.url.startsWith(urlPrefix + prefix))
|
const pathname = new URL(event.request.url).pathname;
|
||||||
return fetch(event.request);
|
if (pathname === '/snapshot/service-worker.js' || pathname === '/snapshot/')
|
||||||
}
|
|
||||||
for (const exact of ['/tracemodel', '/service-worker.js', '/snapshot/']) {
|
|
||||||
if (event.request.url === urlPrefix + exact)
|
|
||||||
return fetch(event.request);
|
return fetch(event.request);
|
||||||
|
} catch (e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = event.request;
|
const request = event.request;
|
||||||
|
|
@ -308,9 +249,20 @@ export class SnapshotServer {
|
||||||
return respond404();
|
return respond404();
|
||||||
const resourceOverride = snapshotEvent.snapshot.resourceOverrides.find(o => o.url === request.url);
|
const resourceOverride = snapshotEvent.snapshot.resourceOverrides.find(o => o.url === request.url);
|
||||||
const overrideSha1 = resourceOverride ? resourceOverride.sha1 : undefined;
|
const overrideSha1 = resourceOverride ? resourceOverride.sha1 : undefined;
|
||||||
if (overrideSha1)
|
|
||||||
return fetch(`/resources/${resource.resourceId}/override/${overrideSha1}`);
|
const response = overrideSha1 ?
|
||||||
return fetch(`/resources/${resource.resourceId}`);
|
await fetch(`/resources/${resource.resourceId}/override/${overrideSha1}`) :
|
||||||
|
await fetch(`/resources/${resource.resourceId}`);
|
||||||
|
// We make a copy of the response, instead of just forwarding,
|
||||||
|
// so that response url is not inherited as "/resources/...", but instead
|
||||||
|
// as the original request url.
|
||||||
|
// Response url turns into resource base uri that is used to resolve
|
||||||
|
// relative links, e.g. url(/foo/bar) in style sheets.
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: response.headers,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
self.addEventListener('fetch', function(event: any) {
|
self.addEventListener('fetch', function(event: any) {
|
||||||
|
|
@ -321,22 +273,17 @@ export class SnapshotServer {
|
||||||
response.statusCode = 200;
|
response.statusCode = 200;
|
||||||
response.setHeader('Cache-Control', 'public, max-age=31536000');
|
response.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||||
response.setHeader('Content-Type', 'application/javascript');
|
response.setHeader('Content-Type', 'application/javascript');
|
||||||
response.end(`(${serviceWorkerMain.toString()})(self, '${this._urlPrefix()}')`);
|
response.end(`(${serviceWorkerMain.toString()})(self)`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _serveTraceModel(request: http.IncomingMessage, response: http.ServerResponse): boolean {
|
private _serveResource(request: http.IncomingMessage, response: http.ServerResponse): boolean {
|
||||||
response.statusCode = 200;
|
|
||||||
response.setHeader('Content-Type', 'application/json');
|
|
||||||
response.end(JSON.stringify(this._traceModel));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _serveResource(request: http.IncomingMessage, response: http.ServerResponse, pathname: string): boolean {
|
|
||||||
if (!this._resourcesDir)
|
if (!this._resourcesDir)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const parts = pathname.split('/');
|
// - /resources/<resourceId>
|
||||||
|
// - /resources/<resourceId>/override/<overrideSha1>
|
||||||
|
const parts = request.url!.split('/');
|
||||||
if (!parts[0])
|
if (!parts[0])
|
||||||
parts.shift();
|
parts.shift();
|
||||||
if (!parts[parts.length - 1])
|
if (!parts[parts.length - 1])
|
||||||
|
|
@ -360,7 +307,6 @@ export class SnapshotServer {
|
||||||
return false;
|
return false;
|
||||||
const sha1 = overrideSha1 || resource.responseSha1;
|
const sha1 = overrideSha1 || resource.responseSha1;
|
||||||
try {
|
try {
|
||||||
// console.log(`reading ${sha1} as ${resource.contentType}...`);
|
|
||||||
const content = fs.readFileSync(path.join(this._resourcesDir, sha1));
|
const content = fs.readFileSync(path.join(this._resourcesDir, sha1));
|
||||||
response.statusCode = 200;
|
response.statusCode = 200;
|
||||||
let contentType = resource.contentType;
|
let contentType = resource.contentType;
|
||||||
|
|
@ -377,87 +323,9 @@ export class SnapshotServer {
|
||||||
response.removeHeader('Content-Length');
|
response.removeHeader('Content-Length');
|
||||||
response.setHeader('Content-Length', content.byteLength);
|
response.setHeader('Content-Length', content.byteLength);
|
||||||
response.end(content);
|
response.end(content);
|
||||||
// console.log(`done`);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _serveActionPreview(request: http.IncomingMessage, response: http.ServerResponse, pathname: string): boolean {
|
|
||||||
if (!this._screenshotGenerator)
|
|
||||||
return false;
|
|
||||||
const fullPath = pathname.substring('/action-preview/'.length);
|
|
||||||
const actionId = fullPath.substring(0, fullPath.indexOf('.png'));
|
|
||||||
this._screenshotGenerator.generateScreenshot(actionId).then(body => {
|
|
||||||
if (!body) {
|
|
||||||
response.statusCode = 404;
|
|
||||||
response.end();
|
|
||||||
} else {
|
|
||||||
response.statusCode = 200;
|
|
||||||
response.setHeader('Content-Type', 'image/png');
|
|
||||||
response.setHeader('Content-Length', body.byteLength);
|
|
||||||
response.end(body);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _serveSha1(request: http.IncomingMessage, response: http.ServerResponse, pathname: string): boolean {
|
|
||||||
if (!this._resourcesDir)
|
|
||||||
return false;
|
|
||||||
const parts = pathname.split('/');
|
|
||||||
if (!parts[0])
|
|
||||||
parts.shift();
|
|
||||||
if (!parts[parts.length - 1])
|
|
||||||
parts.pop();
|
|
||||||
if (parts.length !== 2 || parts[0] !== 'sha1')
|
|
||||||
return false;
|
|
||||||
const sha1 = parts[1];
|
|
||||||
return this._serveStaticFile(response, path.join(this._resourcesDir, sha1));
|
|
||||||
}
|
|
||||||
|
|
||||||
private _serveFile(request: http.IncomingMessage, response: http.ServerResponse, search: string): boolean {
|
|
||||||
if (search[0] !== '?')
|
|
||||||
return false;
|
|
||||||
return this._serveStaticFile(response, search.substring(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
private _serveTraceViewer(request: http.IncomingMessage, response: http.ServerResponse, pathname: string): boolean {
|
|
||||||
if (!this._traceViewerDir)
|
|
||||||
return false;
|
|
||||||
const relativePath = pathname.substring('/traceviewer/'.length);
|
|
||||||
const absolutePath = path.join(this._traceViewerDir, ...relativePath.split('/'));
|
|
||||||
return this._serveStaticFile(response, absolutePath, { 'Service-Worker-Allowed': '/' });
|
|
||||||
}
|
|
||||||
|
|
||||||
private _serveStaticFile(response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(absoluteFilePath);
|
|
||||||
response.statusCode = 200;
|
|
||||||
const contentType = extensionToMime[path.extname(absoluteFilePath).substring(1)] || 'application/octet-stream';
|
|
||||||
response.setHeader('Content-Type', contentType);
|
|
||||||
response.setHeader('Content-Length', content.byteLength);
|
|
||||||
for (const [name, value] of Object.entries(headers || {}))
|
|
||||||
response.setHeader(name, value);
|
|
||||||
response.end(content);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extensionToMime: { [key: string]: string } = {
|
|
||||||
'css': 'text/css',
|
|
||||||
'html': 'text/html',
|
|
||||||
'jpeg': 'image/jpeg',
|
|
||||||
'jpg': 'image/jpeg',
|
|
||||||
'js': 'application/javascript',
|
|
||||||
'png': 'image/png',
|
|
||||||
'ttf': 'font/ttf',
|
|
||||||
'svg': 'image/svg+xml',
|
|
||||||
'webp': 'image/webp',
|
|
||||||
'woff': 'font/woff',
|
|
||||||
'woff2': 'font/woff2',
|
|
||||||
};
|
|
||||||
|
|
|
||||||
121
src/cli/traceViewer/traceServer.ts
Normal file
121
src/cli/traceViewer/traceServer.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type { TraceModel } from './traceModel';
|
||||||
|
|
||||||
|
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => boolean;
|
||||||
|
|
||||||
|
export class TraceServer {
|
||||||
|
private _traceModel: TraceModel;
|
||||||
|
private _server: http.Server | undefined;
|
||||||
|
private _urlPrefix: string;
|
||||||
|
private _routes: { prefix?: string, exact?: string, needsReferrer: boolean, handler: ServerRouteHandler }[] = [];
|
||||||
|
|
||||||
|
constructor(traceModel: TraceModel) {
|
||||||
|
this._traceModel = traceModel;
|
||||||
|
this._urlPrefix = '';
|
||||||
|
|
||||||
|
const traceModelHandler: ServerRouteHandler = (request, response) => {
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(this._traceModel));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
this.routePath('/tracemodel', traceModelHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
routePrefix(prefix: string, handler: ServerRouteHandler, skipReferrerCheck?: boolean) {
|
||||||
|
this._routes.push({ prefix, handler, needsReferrer: !skipReferrerCheck });
|
||||||
|
}
|
||||||
|
|
||||||
|
routePath(path: string, handler: ServerRouteHandler, skipReferrerCheck?: boolean) {
|
||||||
|
this._routes.push({ exact: path, handler, needsReferrer: !skipReferrerCheck });
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<string> {
|
||||||
|
this._server = http.createServer(this._onRequest.bind(this));
|
||||||
|
this._server.listen();
|
||||||
|
await new Promise(cb => this._server!.once('listening', cb));
|
||||||
|
const address = this._server.address();
|
||||||
|
this._urlPrefix = typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`;
|
||||||
|
return this._urlPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await new Promise(cb => this._server!.close(cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
urlPrefix() {
|
||||||
|
return this._urlPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
serveFile(response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(absoluteFilePath);
|
||||||
|
response.statusCode = 200;
|
||||||
|
const contentType = extensionToMime[path.extname(absoluteFilePath).substring(1)] || 'application/octet-stream';
|
||||||
|
response.setHeader('Content-Type', contentType);
|
||||||
|
response.setHeader('Content-Length', content.byteLength);
|
||||||
|
for (const [name, value] of Object.entries(headers || {}))
|
||||||
|
response.setHeader(name, value);
|
||||||
|
response.end(content);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||||
|
request.on('error', () => response.end());
|
||||||
|
try {
|
||||||
|
if (!request.url) {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL('http://localhost' + request.url);
|
||||||
|
const hasReferrer = request.headers['referer'] && request.headers['referer'].startsWith(this._urlPrefix);
|
||||||
|
for (const route of this._routes) {
|
||||||
|
if (route.needsReferrer && !hasReferrer)
|
||||||
|
continue;
|
||||||
|
if (route.exact && url.pathname === route.exact && route.handler(request, response))
|
||||||
|
return;
|
||||||
|
if (route.prefix && url.pathname.startsWith(route.prefix) && route.handler(request, response))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.statusCode = 404;
|
||||||
|
response.end();
|
||||||
|
} catch (e) {
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionToMime: { [key: string]: string } = {
|
||||||
|
'css': 'text/css',
|
||||||
|
'html': 'text/html',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'js': 'application/javascript',
|
||||||
|
'png': 'image/png',
|
||||||
|
'ttf': 'font/ttf',
|
||||||
|
'svg': 'image/svg+xml',
|
||||||
|
'webp': 'image/webp',
|
||||||
|
'woff': 'font/woff',
|
||||||
|
'woff2': 'font/woff2',
|
||||||
|
};
|
||||||
|
|
@ -22,6 +22,7 @@ import { ScreenshotGenerator } from './screenshotGenerator';
|
||||||
import { readTraceFile, TraceModel } from './traceModel';
|
import { readTraceFile, TraceModel } from './traceModel';
|
||||||
import type { TraceEvent } from '../../trace/traceTypes';
|
import type { TraceEvent } from '../../trace/traceTypes';
|
||||||
import { SnapshotServer } from './snapshotServer';
|
import { SnapshotServer } from './snapshotServer';
|
||||||
|
import { ServerRouteHandler, TraceServer } from './traceServer';
|
||||||
|
|
||||||
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
|
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
|
||||||
|
|
||||||
|
|
@ -80,14 +81,81 @@ class TraceViewer {
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
const browser = await playwright.chromium.launch({ headless: false });
|
const browser = await playwright.chromium.launch({ headless: false });
|
||||||
const server = await SnapshotServer.create(
|
|
||||||
path.join(__dirname, '..', '..', 'web'),
|
// Served by TraceServer
|
||||||
this._document ? this._document.resourcesDir : undefined,
|
// - "/tracemodel" - json with trace model.
|
||||||
this._document ? this._document.model : emptyModel,
|
//
|
||||||
this._document ? new ScreenshotGenerator(this._document.resourcesDir, this._document.model) : undefined);
|
// Served by TraceViewer
|
||||||
|
// - "/traceviewer/..." - our frontend.
|
||||||
|
// - "/file?filePath" - local files, used by sources tab.
|
||||||
|
// - "/action-preview/..." - lazily generated action previews.
|
||||||
|
// - "/sha1/<sha1>" - trace resource bodies, used by network previews.
|
||||||
|
//
|
||||||
|
// Served by SnapshotServer
|
||||||
|
// - "/resources/<resourceId>" - network resources from the trace.
|
||||||
|
// - "/snapshot/" - root for snapshot frame.
|
||||||
|
// - "/snapshot/pageId/..." - actual snapshot html.
|
||||||
|
// - "/snapshot/service-worker.js" - service worker that intercepts snapshot resources
|
||||||
|
// and translates them into "/resources/<resourceId>".
|
||||||
|
|
||||||
|
const server = new TraceServer(this._document ? this._document.model : emptyModel);
|
||||||
|
const snapshotServer = new SnapshotServer(server, this._document ? this._document.model : emptyModel, this._document ? this._document.resourcesDir : undefined);
|
||||||
|
const screenshotGenerator = this._document ? new ScreenshotGenerator(snapshotServer, this._document.resourcesDir, this._document.model) : undefined;
|
||||||
|
|
||||||
|
const traceViewerHandler: ServerRouteHandler = (request, response) => {
|
||||||
|
const relativePath = request.url!.substring('/traceviewer/'.length);
|
||||||
|
const absolutePath = path.join(__dirname, '..', '..', 'web', ...relativePath.split('/'));
|
||||||
|
return server.serveFile(response, absolutePath);
|
||||||
|
};
|
||||||
|
server.routePrefix('/traceviewer/', traceViewerHandler, true);
|
||||||
|
|
||||||
|
const actionPreviewHandler: ServerRouteHandler = (request, response) => {
|
||||||
|
if (!screenshotGenerator)
|
||||||
|
return false;
|
||||||
|
const fullPath = request.url!.substring('/action-preview/'.length);
|
||||||
|
const actionId = fullPath.substring(0, fullPath.indexOf('.png'));
|
||||||
|
screenshotGenerator.generateScreenshot(actionId).then(body => {
|
||||||
|
if (!body) {
|
||||||
|
response.statusCode = 404;
|
||||||
|
response.end();
|
||||||
|
} else {
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.setHeader('Content-Type', 'image/png');
|
||||||
|
response.setHeader('Content-Length', body.byteLength);
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
server.routePrefix('/action-preview/', actionPreviewHandler);
|
||||||
|
|
||||||
|
const fileHandler: ServerRouteHandler = (request, response) => {
|
||||||
|
try {
|
||||||
|
const url = new URL('http://localhost' + request.url!);
|
||||||
|
const search = url.search;
|
||||||
|
if (search[0] !== '?')
|
||||||
|
return false;
|
||||||
|
return server.serveFile(response, search.substring(1));
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
server.routePath('/file', fileHandler);
|
||||||
|
|
||||||
|
const sha1Handler: ServerRouteHandler = (request, response) => {
|
||||||
|
if (!this._document)
|
||||||
|
return false;
|
||||||
|
const sha1 = request.url!.substring('/sha1/'.length);
|
||||||
|
if (sha1.includes('/'))
|
||||||
|
return false;
|
||||||
|
return server.serveFile(response, path.join(this._document.resourcesDir, sha1));
|
||||||
|
};
|
||||||
|
server.routePrefix('/sha1/', sha1Handler);
|
||||||
|
|
||||||
|
const urlPrefix = await server.start();
|
||||||
const uiPage = await browser.newPage({ viewport: null });
|
const uiPage = await browser.newPage({ viewport: null });
|
||||||
uiPage.on('close', () => process.exit(0));
|
uiPage.on('close', () => process.exit(0));
|
||||||
await uiPage.goto(server.traceViewerUrl('traceViewer/index.html'));
|
await uiPage.goto(urlPrefix + '/traceviewer/traceViewer/index.html');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,6 @@ import { applyTheme } from '../theme';
|
||||||
import '../common.css';
|
import '../common.css';
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
navigator.serviceWorker.register('/service-worker.js');
|
|
||||||
if (!navigator.serviceWorker.controller)
|
|
||||||
await new Promise(resolve => navigator.serviceWorker.oncontrollerchange = resolve);
|
|
||||||
applyTheme();
|
applyTheme();
|
||||||
const traceModel = await fetch('/tracemodel').then(response => response.json());
|
const traceModel = await fetch('/tracemodel').then(response => response.json());
|
||||||
ReactDOM.render(<Workbench traceModel={traceModel} />, document.querySelector('#root'));
|
ReactDOM.render(<Workbench traceModel={traceModel} />, document.querySelector('#root'));
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,6 @@ const SnapshotTab: React.FunctionComponent<{
|
||||||
}> = ({ actionEntry, snapshotSize, selectedTime, boundaries }) => {
|
}> = ({ actionEntry, snapshotSize, selectedTime, boundaries }) => {
|
||||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||||
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
|
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
|
||||||
const origin = location.href.substring(0, location.href.indexOf(location.pathname));
|
|
||||||
const snapshotIframeUrl = origin + '/snapshot/';
|
|
||||||
|
|
||||||
let snapshots: { name: string, snapshotId?: string, snapshotTime?: number }[] = [];
|
let snapshots: { name: string, snapshotId?: string, snapshotTime?: number }[] = [];
|
||||||
snapshots = (actionEntry ? (actionEntry.action.snapshots || []) : []).slice();
|
snapshots = (actionEntry ? (actionEntry.action.snapshots || []) : []).slice();
|
||||||
|
|
@ -91,15 +89,16 @@ const SnapshotTab: React.FunctionComponent<{
|
||||||
if (!actionEntry || !iframeRef.current)
|
if (!actionEntry || !iframeRef.current)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// TODO: this logic is copied from SnapshotServer. Find a way to share.
|
||||||
let snapshotUrl = 'data:text/html,Snapshot is not available';
|
let snapshotUrl = 'data:text/html,Snapshot is not available';
|
||||||
if (selectedTime) {
|
if (selectedTime) {
|
||||||
snapshotUrl = origin + `/snapshot/pageId/${actionEntry.action.pageId!}/timestamp/${selectedTime}/main`;
|
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/timestamp/${selectedTime}/main`;
|
||||||
} else {
|
} else {
|
||||||
const snapshot = snapshots[snapshotIndex];
|
const snapshot = snapshots[snapshotIndex];
|
||||||
if (snapshot && snapshot.snapshotTime)
|
if (snapshot && snapshot.snapshotTime)
|
||||||
snapshotUrl = origin + `/snapshot/pageId/${actionEntry.action.pageId!}/timestamp/${snapshot.snapshotTime}/main`;
|
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/timestamp/${snapshot.snapshotTime}/main`;
|
||||||
else if (snapshot && snapshot.snapshotId)
|
else if (snapshot && snapshot.snapshotId)
|
||||||
snapshotUrl = origin + `/snapshot/pageId/${actionEntry.action.pageId!}/snapshotId/${snapshot.snapshotId}/main`;
|
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/snapshotId/${snapshot.snapshotId}/main`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -129,7 +128,7 @@ const SnapshotTab: React.FunctionComponent<{
|
||||||
height: snapshotSize.height + 'px',
|
height: snapshotSize.height + 'px',
|
||||||
transform: `translate(${-snapshotSize.width * (1 - scale) / 2}px, ${-snapshotSize.height * (1 - scale) / 2}px) scale(${scale})`,
|
transform: `translate(${-snapshotSize.width * (1 - scale) / 2}px, ${-snapshotSize.height * (1 - scale) / 2}px) scale(${scale})`,
|
||||||
}}>
|
}}>
|
||||||
<iframe ref={iframeRef} id='snapshot' name='snapshot' src={snapshotIframeUrl}></iframe>
|
<iframe ref={iframeRef} id='snapshot' name='snapshot' src='/snapshot/'></iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue