@lvce-editor/preview-process
Version:
825 lines (759 loc) • 21.4 kB
JavaScript
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();