Path: blob/main/replay/backend/api/ReplayResources.ts
1030 views
import { ProtocolResponse } from 'electron';1import * as Http from 'http';2import { Readable } from 'stream';3import getResolvable from '~shared/utils/promise';4import { IReplayResource } from '~shared/interfaces/IReplayResource';5import decompress from '~shared/utils/decompress';67const packageJson = require('../../package.json');89type IResolvable<T> = {10resolve: (resource: T) => void;11promise: Promise<T>;12};13const resourceWhitelist = new Set<string>([14'Ico',15'Image',16'Media',17'Font',18'Stylesheet',19'Other',20'Document',21]);2223export default class ReplayResources {24private resourcesByUrl = new Map<string, IResolvable<IReplayResource>>();25private resourcesById = new Map<number, IReplayResource>();26private resourceBodyById = new Map<number, Promise<ProtocolResponse>>();2728public onResource(resourceMeta: IReplayResource): void {29const { url } = resourceMeta;30this.initResource(url);3132this.resourcesById.set(resourceMeta.id, resourceMeta);33this.resourcesByUrl.get(url).resolve(resourceMeta);34}3536public async get(urlStr: string): Promise<IReplayResource> {37const url = urlStr.split('#').shift();38this.initResource(url);39return await this.resourcesByUrl.get(url).promise;40}4142public async getContent(43resourceId: number,44baseHost: string,45dataDir: string,46): Promise<ProtocolResponse> {47const resource = this.resourcesById.get(resourceId);48if (!resourceWhitelist.has(resource.type)) {49console.log('skipping resource', resource);5051return <ProtocolResponse>{52data: Readable.from([]),53statusCode: 404,54};55}56if (!this.resourceBodyById.has(resourceId)) {57const { resolve, reject, promise } = getResolvable<ProtocolResponse>();58this.resourceBodyById.set(resourceId, promise);59const reqHeaders = { headers: { 'x-data-location': dataDir } };60const req = Http.get(`${baseHost}/resource/${resourceId}`, reqHeaders, async res => {61res.on('error', reject);6263const contentType = res.headers['content-type'];64const encoding = res.headers['content-encoding'];65const headers: any = {66'Cache-Control': 'public, max-age=604800, immutable',67'Content-Type': contentType,68'X-Replay-Agent': `Secret Agent Replay v${packageJson.version}`,69};70if (res.headers.location) {71headers.location = res.headers.location;72}7374const buffer: Buffer[] = [];75for await (const chunk of res) {76buffer.push(chunk);77}7879let body = Buffer.concat(buffer);8081if (encoding) {82body = await decompress(body, encoding);83}8485if (resource.type === 'Document' && !isAllowedDocumentContentType(contentType)) {86const first100 = body.slice(0, 200).toString();8788let doctype = '';89const match = first100.match(/^\s*<!DOCTYPE([^>]*?)>/i);90if (match) {91doctype = `<!DOCTYPE${match[1] ?? ''}>\n`;92}9394body = Buffer.from(`${doctype}<html><head></head><body></body></html>`);95}9697resolve(<ProtocolResponse>{98data: body,99headers,100statusCode: res.statusCode,101});102});103req.on('error', reject);104req.end();105}106const cached = await this.resourceBodyById.get(resourceId);107return {108...cached,109data: Readable.from(cached.data),110};111}112113private initResource(url: string) {114if (!this.resourcesByUrl.has(url)) {115this.resourcesByUrl.set(url, getResolvable<IReplayResource>());116}117}118}119120function isAllowedDocumentContentType(contentType: string) {121if (!contentType) return false;122return (123contentType.includes('json') || contentType.includes('svg') || contentType.includes('font')124);125}126127128