/** * 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 fs from 'fs'; import path from 'path'; import querystring from 'querystring'; import { TraceServer } from './traceServer'; import type { FrameSnapshot, SerializedFrameSnapshot } from './frameSnapshot'; import type { NetworkResourceTraceEvent } from '../common/traceEvents'; export interface SnapshotStorage { resourceById(resourceId: string): NetworkResourceTraceEvent; snapshotByName(snapshotName: string): FrameSnapshot | undefined; } export class SnapshotServer { private _resourcesDir: string | undefined; private _urlPrefix: string; private _snapshotStorage: SnapshotStorage; constructor(server: TraceServer, snapshotStorage: SnapshotStorage, resourcesDir: string | undefined) { this._resourcesDir = resourcesDir; this._urlPrefix = server.urlPrefix(); this._snapshotStorage = snapshotStorage; server.routePath('/snapshot/', this._serveSnapshotRoot.bind(this), true); server.routePath('/snapshot/service-worker.js', this._serveServiceWorker.bind(this)); server.routePath('/snapshot-data', this._serveSnapshot.bind(this)); server.routePrefix('/resources/', this._serveResource.bind(this)); } snapshotRootUrl() { return this._urlPrefix + '/snapshot/'; } snapshotUrl(pageId: string, snapshotId?: string, timestamp?: number) { // Prefer snapshotId over timestamp. if (snapshotId) return this._urlPrefix + `/snapshot/pageId/${pageId}/snapshotId/${snapshotId}/main`; if (timestamp) return this._urlPrefix + `/snapshot/pageId/${pageId}/timestamp/${timestamp}/main`; return 'data:text/html,Snapshot is not available'; } private _serveSnapshotRoot(request: http.IncomingMessage, response: http.ServerResponse): boolean { response.statusCode = 200; response.setHeader('Cache-Control', 'public, max-age=31536000'); response.setHeader('Content-Type', 'text/html'); response.end(` `); return true; } private _serveServiceWorker(request: http.IncomingMessage, response: http.ServerResponse): boolean { function serviceWorkerMain(self: any /* ServiceWorkerGlobalScope */) { const snapshotResources = new Map(); self.addEventListener('install', function(event: any) { }); self.addEventListener('activate', function(event: any) { event.waitUntil(self.clients.claim()); }); function respond404(): Response { return new Response(null, { status: 404 }); } function respondNotAvailable(): Response { return new Response('Snapshot is not available', { status: 200, headers: { 'Content-Type': 'text/html' } }); } function removeHash(url: string) { try { const u = new URL(url); u.hash = ''; return u.toString(); } catch (e) { return url; } } async function doFetch(event: any /* FetchEvent */): Promise { const request = event.request; const pathname = new URL(request.url).pathname; if (pathname === '/snapshot/service-worker.js' || pathname === '/snapshot/') return fetch(event.request); let snapshotId: string; if (request.mode === 'navigate') { snapshotId = pathname; } else { const client = (await self.clients.get(event.clientId))!; snapshotId = new URL(client.url).pathname; } if (request.mode === 'navigate') { const htmlResponse = await fetch(`/snapshot-data?snapshotName=${snapshotId}`); const { html, resources }: SerializedFrameSnapshot = await htmlResponse.json(); if (!html) return respondNotAvailable(); snapshotResources.set(snapshotId, resources); const response = new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } }); return response; } const resources = snapshotResources.get(snapshotId)!; const urlWithoutHash = removeHash(request.url); const resource = resources[urlWithoutHash]; if (!resource) return respond404(); const fetchUrl = resource.sha1 ? `/resources/${resource.resourceId}/override/${resource.sha1}` : `/resources/${resource.resourceId}`; const fetchedResponse = await fetch(fetchUrl); const headers = new Headers(fetchedResponse.headers); // 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. if (resource.sha1) { // No cache, so that we refetch overridden resources. headers.set('Cache-Control', 'no-cache'); } const response = new Response(fetchedResponse.body, { status: fetchedResponse.status, statusText: fetchedResponse.statusText, headers, }); return response; } self.addEventListener('fetch', function(event: any) { event.respondWith(doFetch(event)); }); } response.statusCode = 200; response.setHeader('Cache-Control', 'public, max-age=31536000'); response.setHeader('Content-Type', 'application/javascript'); response.end(`(${serviceWorkerMain.toString()})(self)`); return true; } private _serveSnapshot(request: http.IncomingMessage, response: http.ServerResponse): boolean { response.statusCode = 200; response.setHeader('Cache-Control', 'public, max-age=31536000'); response.setHeader('Content-Type', 'application/json'); const parsed: any = querystring.parse(request.url!.substring(request.url!.indexOf('?') + 1)); const snapshot = this._snapshotStorage.snapshotByName(parsed.snapshotName); const snapshotData: any = snapshot ? snapshot.serialize() : { html: '' }; response.end(JSON.stringify(snapshotData)); return true; } private _serveResource(request: http.IncomingMessage, response: http.ServerResponse): boolean { if (!this._resourcesDir) return false; // - /resources/ // - /resources//override/ const parts = request.url!.split('/'); if (!parts[0]) parts.shift(); if (!parts[parts.length - 1]) parts.pop(); if (parts[0] !== 'resources') return false; let resourceId; let overrideSha1; if (parts.length === 2) { resourceId = parts[1]; } else if (parts.length === 4 && parts[2] === 'override') { resourceId = parts[1]; overrideSha1 = parts[3]; } else { return false; } const resource = this._snapshotStorage.resourceById(resourceId); const sha1 = overrideSha1 || resource.responseSha1; try { const content = fs.readFileSync(path.join(this._resourcesDir, sha1)); response.statusCode = 200; let contentType = resource.contentType; const isTextEncoding = /^text\/|^application\/(javascript|json)/.test(contentType); if (isTextEncoding && !contentType.includes('charset')) contentType = `${contentType}; charset=utf-8`; response.setHeader('Content-Type', contentType); for (const { name, value } of resource.responseHeaders) response.setHeader(name, value); response.removeHeader('Content-Encoding'); response.removeHeader('Access-Control-Allow-Origin'); response.setHeader('Access-Control-Allow-Origin', '*'); response.removeHeader('Content-Length'); response.setHeader('Content-Length', content.byteLength); response.setHeader('Cache-Control', 'public, max-age=31536000'); response.end(content); return true; } catch (e) { return false; } } }