UNPKG

@lvce-editor/preview-process

Version:
825 lines (759 loc) 21.4 kB
import { VError } from '@lvce-editor/verror'; import { createServer } from 'node:http'; import { createHash } from 'node:crypto'; import path, { extname, dirname, join } from 'node:path'; import * as fs from 'node:fs'; import { createReadStream } from 'node:fs'; import * as nodeFs from 'node:fs/promises'; import { stat } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { pipeline } from 'node:stream/promises'; import { ElectronMessagePortRpcClient, ElectronUtilityProcessRpcClient, NodeWorkerRpcClient, NodeForkedProcessRpcClient } from '@lvce-editor/rpc'; const infos = Object.create(null); const set$1 = (webViewId, info) => { infos[webViewId] = info; }; const get$1 = webViewId => { return infos[webViewId]; }; const emptyInfo = { contentSecurityPolicy: '', iframeContent: '', webViewId: '', webViewRoot: '', remotePathPrefix: '/remote' }; const getInfoAndPath = requestUrl => { const { pathname } = new URL(requestUrl, 'http://localhost'); if (pathname.startsWith('/remote')) { return { pathName: pathname, info: emptyInfo }; } if (pathname.endsWith('/preview-injected.js')) { return { pathName: pathname, info: emptyInfo }; } const parts = pathname.split('/'); if (parts.length < 2) { return undefined; } const webViewId = parts[1]; const info = get$1(webViewId); if (!info) { return undefined; } if (pathname === `/${webViewId}` || pathname === `/${webViewId}/`) { return { info, pathName: '/index.html' }; } return { info, pathName: '/' + parts.slice(2).join('/') }; }; const findMatchingRoute = (path, routes) => { return routes.find(route => typeof route.pattern === 'string' ? path === route.pattern : route.pattern.test(path)); }; const AcceptRanges = 'Accept-Ranges'; const ContentLength = 'Content-Length'; const ContentRange = 'Content-Range'; const ContentSecurityPolicy = 'Content-Security-Policy'; const ContentType = 'Content-Type'; const CrossOriginEmbedderPolicy = 'Cross-Origin-Embedder-Policy'; const ServerTiming = 'Server-Timing'; const CrossOriginResourcePolicy = 'Cross-Origin-Resource-Policy'; const Etag = 'ETag'; const IfNotMatch = 'if-none-match'; const Get = 'GET'; const Head = 'HEAD'; class HeadResponse extends Response { constructor(status, headers) { super(null, { status, headers }); } } const MethodNotAllowed = 405; const NotFound = 404; const Ok = 200; const PartialContent = 206; const RangeNotSatisfiable = 416; const BadRequest = 400; const NotModified = 304; const ServerError = 500; class MethodNotAllowedResponse extends Response { constructor() { super('405 - Method Not Allowed', { status: MethodNotAllowed, headers: { [CrossOriginResourcePolicy]: 'same-origin' } }); } } class NotFoundResponse extends Response { constructor() { super('not found', { status: NotFound, headers: { [CrossOriginResourcePolicy]: 'same-origin' } }); } } class ServerErrorResponse extends Response { constructor() { super('Internal Server Error', { status: ServerError, headers: { [CrossOriginResourcePolicy]: 'same-origin' } }); } } const value = 'require-corp'; const CrossOrigin = 'cross-origin'; const generateEtag = content => { const hash = createHash('sha1'); hash.update(content); return `W/"${hash.digest('hex')}"`; }; const getContentSecurityPolicyDocument = contentSecurityPolicy => { return contentSecurityPolicy; }; const textMimeType = { '.html': 'text/html', '.js': 'text/javascript', '.ts': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.svg': 'image/svg+xml', '.avif': 'image/avif', '.woff': 'application/font-woff', '.ttf': 'font/ttf', '.png': 'image/png', '.jpe': 'image/jpg', '.ico': 'image/x-icon', '.jpeg': 'image/jpg', '.jpg': 'image/jpg', '.webp': 'image/webp' }; const getContentType = filePath => { return textMimeType[extname(filePath)] || 'text/plain'; }; const matchesEtag = (request, etag) => { return request.headers[IfNotMatch] === etag; }; class NotModifiedResponse extends Response { constructor(etag, extraHeaders = {}) { super(null, { status: NotModified, headers: { [CrossOriginResourcePolicy]: 'same-origin', [Etag]: etag, ...extraHeaders } }); } } const handleIndexHtml = async (request, options) => { try { if (!options.iframeContent) { throw new Error('iframe content is required'); } const contentType = getContentType('/test/index.html'); const csp = getContentSecurityPolicyDocument(options.contentSecurityPolicy); const headers = { [CrossOriginResourcePolicy]: CrossOrigin, [CrossOriginEmbedderPolicy]: value, [ContentSecurityPolicy]: csp, [ContentType]: contentType }; if (options.etag) { const etag = generateEtag(options.iframeContent); if (matchesEtag(request, etag)) { return new NotModifiedResponse(etag, headers); } // @ts-ignore headers[Etag] = etag; } return new Response(options.iframeContent, { headers }); } catch (error) { console.error(`[preview-server] ${error}`); return new ServerErrorResponse(); } }; const readFile = async url => { const buffer = await nodeFs.readFile(url); return buffer; }; const getEtag = fileStat => { return `W/"${[fileStat.ino, fileStat.size, fileStat.mtime.getTime()].join('-')}"`; }; const getPathEtag = async absolutePath => { let stats = await stat(absolutePath); if (stats.isDirectory()) { absolutePath += '/index.html'; stats = await stat(absolutePath); } const etag = getEtag(stats); return etag; }; class BadRequestResponse extends Response { constructor(message = 'Bad Request') { super(message, { status: BadRequest, headers: { [CrossOriginResourcePolicy]: 'same-origin' } }); } } class RangeNotSatisfiableResponse extends Response { constructor(totalSize) { super(null, { status: RangeNotSatisfiable, headers: { [ContentRange]: `bytes */${totalSize}`, [CrossOriginResourcePolicy]: 'same-origin' } }); } } class RangeResponse extends Response { constructor(readStream, start, end, totalSize) { super(readStream, { status: PartialContent, headers: { [ContentRange]: `bytes ${start}-${end}/${totalSize}`, [ContentLength]: `${end - start + 1}`, [AcceptRanges]: 'bytes' } }); } } const handleRangeRequest = async (filePath, range) => { const stats = await stat(filePath); const [x, y] = range.replace('bytes=', '').split('-'); const start = Number.parseInt(x, 10); if (Number.isNaN(start)) { return new BadRequestResponse('Invalid Range'); } if (start >= stats.size) { return new RangeNotSatisfiableResponse(stats.size); } const end = y ? Number.parseInt(y, 10) : stats.size - 1; const finalEnd = end >= stats.size ? stats.size - 1 : end; const readStream = createReadStream(filePath, { start, end: finalEnd }); return new RangeResponse(readStream, start, finalEnd, stats.size); }; const ENOENT = 'ENOENT'; const ERR_STREAM_PREMATURE_CLOSE = 'ERR_STREAM_PREMATURE_CLOSE'; const isEnoentError = error => { // @ts-ignore return error && error.code && error.code === ENOENT; }; const isUriError = error => { return Boolean(error && error instanceof URIError); }; const resolveFilePath = (pathName, webViewRoot, remotePathPrefix = '/remote') => { // TODO remove this, double slash should not be allowed // TODO use path.resolve and verify that file path is in allowed roots if (pathName.startsWith('/remote//')) { const filePath = pathName.slice('/remote/'.length); return fileURLToPath(`file://${filePath}`); } if (pathName.startsWith('/remote/')) { const filePath = pathName.slice('/remote'.length); return fileURLToPath(`file://${filePath}`); } if (webViewRoot.startsWith('file://')) { return fileURLToPath(`${webViewRoot}${pathName}`); } const filePath = fileURLToPath(`file://${webViewRoot}${pathName}`); return filePath; }; class ContentResponse extends Response { constructor(content, contentType, etag) { const headers = { [CrossOriginResourcePolicy]: 'same-origin', [ContentType]: contentType }; if (etag) { headers[Etag] = etag; } super(content, { status: Ok, headers }); } } const handleOther = async (requestOptions, handlerOptions) => { try { const filePath = resolveFilePath(requestOptions.path, handlerOptions.webViewRoot); if (requestOptions.headers && requestOptions.headers.range) { return await handleRangeRequest(filePath, requestOptions.headers.range); } const etag = handlerOptions.etag ? await getPathEtag(filePath) : undefined; if (etag === null) { return new NotFoundResponse(); } if (etag && matchesEtag(requestOptions, etag)) { return new NotModifiedResponse(etag); } const contentType = getContentType(filePath); if (handlerOptions.stream) { const readStream = createReadStream(filePath); return new ContentResponse(readStream, contentType, etag); } const content = await readFile(filePath); return new ContentResponse(content, contentType, etag); } catch (error) { if (isEnoentError(error)) { return new NotFoundResponse(); } if (isUriError(error)) { return new BadRequestResponse(); } console.error(`[preview-server] ${error}`); return new ServerErrorResponse(); } }; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = path.join(__dirname, '..'); const injectedCodePath = join(root, 'files', 'previewInjectedCode.js'); const injectedCode = fs.readFileSync(injectedCodePath, 'utf8'); const PreviewInjectedCode = { __proto__: null, injectedCode }; const handlePreviewInjected = async () => { const { injectedCode } = PreviewInjectedCode; const contentType = getContentType('/test/file.js'); return new ContentResponse(injectedCode, contentType); }; const routes = [{ pattern: /index\.html$/, handler: handleIndexHtml }, { pattern: /preview-injected\.js$/, handler: handlePreviewInjected }, { pattern: /.*/, handler: handleOther }]; const getResponse$1 = async (request, options) => { const start = performance.now(); try { if (request.method !== Get && request.method !== Head) { return new MethodNotAllowedResponse(); } const matchedRoute = findMatchingRoute(request.path, routes); if (!matchedRoute) { return new NotFoundResponse(); } const response = await matchedRoute.handler(request, options); if (request.method === Head) { return new HeadResponse(response.status, response.headers); } // Add Server-Timing header const duration = Math.round(performance.now() - start); response.headers.set(ServerTiming, `total;dur=${duration}`); return response; } catch (error) { console.error(`[preview-process] ${error}`); return new ServerErrorResponse(); } }; const isStreamPrematureCloseError = error => { return error && error.code === ERR_STREAM_PREMATURE_CLOSE; }; const sendResponse = async (response, result) => { try { response.statusCode = result.status; for (const [key, value] of result.headers.entries()) { response.setHeader(key, value); } if (!result.body) { response.end(); return; } await pipeline(result.body, response); } catch (error) { if (isStreamPrematureCloseError(error)) { return; } if (isEnoentError(error)) { if (!response.headersSent) { response.statusCode = NotFound; response.end('Not Found'); } return; } console.error(`[preview-process] ${error}`); if (!response.headersSent) { response.statusCode = ServerError; response.end('Internal Server Error'); } } }; const handleRequest2 = async (request, response) => { const infoAndPath = getInfoAndPath(request.url || ''); if (!infoAndPath) { const result = new NotFoundResponse(); await sendResponse(response, result); return; } const requestOptions = { method: request.method || 'GET', path: infoAndPath.pathName, headers: request.headers }; const handlerOptions = { webViewRoot: infoAndPath.info.webViewRoot, contentSecurityPolicy: infoAndPath.info.contentSecurityPolicy, iframeContent: infoAndPath.info.iframeContent, stream: false, etag: true, remotePathPrefix: '/remote' }; const result = await getResponse$1(requestOptions, handlerOptions); await sendResponse(response, result); }; const servers = Object.create(null); const set = (id, server) => { servers[id] = server; }; const get = id => { const server = servers[id]; if (!server) { throw new Error(`Server with id ${id} not found`); } return server; }; const has = id => { return id in servers; }; const createWebViewServer = (id, useNewHandler) => { try { if (has(id)) { return; } const server = createServer(); if (useNewHandler) { // eslint-disable-next-line @typescript-eslint/no-misused-promises server.on('request', handleRequest2); } const webViewServer = { handler: undefined, setHandler(handleRequest) { if (this.handler) { return; } this.handler = handleRequest; // eslint-disable-next-line @typescript-eslint/no-misused-promises server.on('request', handleRequest); }, on(event, listener) { server.on(event, listener); }, off(event, listener) { server.off(event, listener); }, listen(port, callback) { server.listen(port, callback); }, isListening() { return server.listening; } }; set(id, webViewServer); } catch (error) { throw new VError(error, 'Failed to create webview server'); } }; const RE_URL_MATCH = /^([a-z-]+):\/\/([a-z-.]+)/; const getProtocolMatch = url => { // TODO maybe use URL to parse the url const match = url.match(RE_URL_MATCH); if (!match) { throw new Error(`Failed to parse url`); } return { protocol: match[1], domain: match[2] }; }; // TODO make scheme dynamic const allowedProtocols = ['lvce-webview', 'lvce-oss-webview']; const getInfo = url => { const { protocol, domain } = getProtocolMatch(url); if (!allowedProtocols.includes(protocol)) { throw new Error(`unsupported protocol`); } const item = get$1(domain); if (!item) { throw new Error(`webview info not found`); } return item; }; const getPathName = request => { const { pathname } = new URL(request.url || '', `https://${request.headers.host}`); return pathname; }; const getPathName2 = url => { try { const p = new URL(url).pathname; return p; } catch { return ''; } }; const SPECIAL_CASES = { etag: 'ETag' }; const toTitleCase = key => { const lowerKey = key.toLowerCase(); if (SPECIAL_CASES[lowerKey]) { return SPECIAL_CASES[lowerKey]; } return key.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join('-'); }; const serializeHeaders = headers => { const result = {}; for (const [key, value] of headers.entries()) { const normalizedKey = toTitleCase(key); result[normalizedKey] = value; } return result; }; const serializeResponse = async response => { const body = await response.arrayBuffer(); return { body: Buffer.from(body), init: { status: response.status, headers: serializeHeaders(response.headers) } }; }; const getResponse = async (method, url, headers) => { const info = getInfo(url); let pathName = getPathName2(url); if (pathName === '/') { pathName += 'index.html'; } const requestOptions = { method, path: pathName, headers: headers || {} }; const handlerOptions = { contentSecurityPolicy: info.contentSecurityPolicy, iframeContent: info.iframeContent, stream: false, webViewRoot: info.webViewRoot, etag: false, remotePathPrefix: '/remote' }; const jsResponse = await getResponse$1(requestOptions, handlerOptions); const serializedResponse = await serializeResponse(jsResponse); return serializedResponse; }; const commandMap$1 = { 'WebViewProtocol.getResponse': getResponse }; const handleElectronMessagePort = async messagePort => { await ElectronMessagePortRpcClient.create({ messagePort, commandMap: commandMap$1 }); }; const setInfo2 = options => { set$1(options.webViewId, options); }; const setInfo = (id, webViewId, webViewUri, contentSecurityPolicy, iframeContent) => { // TODO set webviewroot and webviewUri let webViewRoot = webViewUri; if (webViewRoot.startsWith('file://')) { webViewRoot = fileURLToPath(webViewRoot).toString(); } set$1(webViewId, { webViewId, webViewRoot, contentSecurityPolicy, iframeContent }); }; const createHandler = (webViewRoot, contentSecurityPolicy, iframeContent) => { if (webViewRoot && webViewRoot.startsWith('file://')) { webViewRoot = fileURLToPath(webViewRoot); } // TODO configuration can be added via setInfo. then the request handler doesn't need to be a closure, // but instead can retrieve the info from infoState (matching by request protocol / request url) const handleRequest = async (request, response) => { let pathName = getPathName(request); if (pathName === '/') { pathName += 'index.html'; } const requestOptions = { method: request.method || 'GET', path: pathName, headers: request.headers }; const handlerOptions = { webViewRoot, contentSecurityPolicy, iframeContent, stream: false, etag: true, remotePathPrefix: '/remote' }; const result = await getResponse$1(requestOptions, handlerOptions); await sendResponse(response, result); }; return handleRequest; }; const setWebViewServerHandler = (id, frameAncestors, webViewRoot, contentSecurityPolicy, iframeContent) => { const server = get(id); const handler = createHandler(webViewRoot, contentSecurityPolicy, iframeContent); server.setHandler(handler); }; const addListener = (emitter, type, callback) => { emitter.on(type, callback); }; const removeListener = (emitter, type, callback) => { emitter.off(type, callback); }; const getFirstEvent = (eventEmitter, eventMap) => { const { resolve, promise } = Promise.withResolvers(); const listenerMap = Object.create(null); const cleanup = value => { for (const event of Object.keys(eventMap)) { removeListener(eventEmitter, event, listenerMap[event]); } resolve(value); }; for (const [event, type] of Object.entries(eventMap)) { const listener = event => { cleanup({ type, event }); }; addListener(eventEmitter, event, listener); listenerMap[event] = listener; } return promise; }; const waitForServerToBeReady = async (server, port) => { const responsePromise = getFirstEvent(server, { error: 1, listening: 2 }); server.listen(port, () => {}); const { type, event } = await responsePromise; if (type === 1) { throw new Error(`Server error: ${event}`); } }; const startWebViewServer = async (id, port) => { try { const server = get(id); if (server.isListening()) { return; } await waitForServerToBeReady(server, port); } catch (error) { throw new VError(error, 'Failed to start webview server'); } }; const commandMap = { 'HandleElectronMessagePort.handleElectronMessagePort': handleElectronMessagePort, 'WebViewServer.create': createWebViewServer, 'WebViewServer.setHandler': setWebViewServerHandler, 'WebViewServer.setInfo': setInfo, 'WebViewServer.setInfo2': setInfo2, 'WebViewServer.start': startWebViewServer }; const NodeWorker = 1; const NodeForkedProcess = 2; const ElectronUtilityProcess = 3; const ElectronMessagePort = 4; const Auto = () => { const { argv } = process; if (argv.includes('--ipc-type=node-worker')) { return NodeWorker; } if (argv.includes('--ipc-type=node-forked-process')) { return NodeForkedProcess; } if (argv.includes('--ipc-type=electron-utility-process')) { return ElectronUtilityProcess; } throw new Error('[preview-process] unknown ipc type'); }; const getModule = method => { switch (method) { case NodeForkedProcess: return NodeForkedProcessRpcClient; case NodeWorker: return NodeWorkerRpcClient; case ElectronUtilityProcess: return ElectronUtilityProcessRpcClient; case ElectronMessagePort: return ElectronMessagePortRpcClient; default: throw new Error('unexpected ipc type'); } }; const listen$1 = async ({ method, ...params }) => { const module = getModule(method); // @ts-ignore const rpc = await module.create(params); // @ts-ignore return rpc; }; const listen = async () => { await listen$1({ method: Auto(), commandMap: commandMap }); }; const main = async () => { await listen(); }; main();