From bec2993d1675d162fb4c3275b8563e46c481d24a Mon Sep 17 00:00:00 2001 From: Le Vivilet <72156503+levivilet@users.noreply.github.com> Date: Tue, 27 Aug 2024 19:41:21 +0200 Subject: [PATCH] feature: add webview server (#2) --- src/parts/AddSemiColon/AddSemiColon.js | 5 + src/parts/Character/Character.js | 12 ++ src/parts/CommandMap/CommandMap.js | 6 +- .../CreateWebViewServer.js | 24 ++++ .../CreateWebViewServerHandler.js | 110 ++++++++++++++++++ .../GetContentSecurityPolicy.js | 6 + .../PreviewInjectedCode.js | 74 ++++++++++++ src/parts/Promises/Promises.js | 19 +++ src/parts/SetHeaders/SetHeaders.js | 5 + src/parts/WebViewServer/WebViewServer.ipc.js | 8 ++ src/parts/WebViewServer/WebViewServer.js | 25 ++++ 11 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/parts/AddSemiColon/AddSemiColon.js create mode 100644 src/parts/Character/Character.js create mode 100644 src/parts/CreateWebViewServer/CreateWebViewServer.js create mode 100644 src/parts/CreateWebViewServerHandler/CreateWebViewServerHandler.js create mode 100644 src/parts/GetContentSecurityPolicy/GetContentSecurityPolicy.js create mode 100644 src/parts/PreviewInjectedCode/PreviewInjectedCode.js create mode 100644 src/parts/Promises/Promises.js create mode 100644 src/parts/SetHeaders/SetHeaders.js create mode 100644 src/parts/WebViewServer/WebViewServer.ipc.js create mode 100644 src/parts/WebViewServer/WebViewServer.js diff --git a/src/parts/AddSemiColon/AddSemiColon.js b/src/parts/AddSemiColon/AddSemiColon.js new file mode 100644 index 00000000..8e2fa8c9 --- /dev/null +++ b/src/parts/AddSemiColon/AddSemiColon.js @@ -0,0 +1,5 @@ +import * as Character from '../Character/Character.js' + +export const addSemicolon = (line) => { + return line + Character.SemiColon +} diff --git a/src/parts/Character/Character.js b/src/parts/Character/Character.js new file mode 100644 index 00000000..54a44307 --- /dev/null +++ b/src/parts/Character/Character.js @@ -0,0 +1,12 @@ +export const Backslash = '\\' +export const Dash = '-' +export const Dot = '.' +export const EmptyString = '' +export const NewLine = '\n' +export const OpenAngleBracket = '<' +export const Slash = '/' +export const Space = ' ' +export const Tab = '\t' +export const Underline = '_' +export const T = 't' +export const SemiColon = ';' diff --git a/src/parts/CommandMap/CommandMap.js b/src/parts/CommandMap/CommandMap.js index bff62c3e..51cad519 100644 --- a/src/parts/CommandMap/CommandMap.js +++ b/src/parts/CommandMap/CommandMap.js @@ -1 +1,5 @@ -export const commandMap = {} +import * as WebViewServer from '../WebViewServer/WebViewServer.js' + +export const commandMap = { + 'WebViewServer.start': WebViewServer.start, +} diff --git a/src/parts/CreateWebViewServer/CreateWebViewServer.js b/src/parts/CreateWebViewServer/CreateWebViewServer.js new file mode 100644 index 00000000..4a13e9fb --- /dev/null +++ b/src/parts/CreateWebViewServer/CreateWebViewServer.js @@ -0,0 +1,24 @@ +import { VError } from '@lvce-editor/verror' +import { createServer } from 'node:http' +import * as Promises from '../Promises/Promises.js' + +export const createWebViewServer = async (port) => { + try { + const server = createServer() + const { resolve, promise } = Promises.withResolvers() + server.listen(port, resolve) + await promise + return { + handler: undefined, + setHandler(handleRequest) { + if (this.handler) { + return + } + this.handler = this.handler + server.on('request', handleRequest) + }, + } + } catch (error) { + throw new VError(error, `Failed to start webview server`) + } +} diff --git a/src/parts/CreateWebViewServerHandler/CreateWebViewServerHandler.js b/src/parts/CreateWebViewServerHandler/CreateWebViewServerHandler.js new file mode 100644 index 00000000..df7d4fcb --- /dev/null +++ b/src/parts/CreateWebViewServerHandler/CreateWebViewServerHandler.js @@ -0,0 +1,110 @@ +import { createReadStream } from 'node:fs' +import { extname } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { fileURLToPath } from 'node:url' +import * as GetContentSecurityPolicy from '../GetContentSecurityPolicy/GetContentSecurityPolicy.js' +import * as SetHeaders from '../SetHeaders/SetHeaders.js' +import * as PreviewInjectedCode from '../PreviewInjectedCode/PreviewInjectedCode.js' +import { readFile } from 'node:fs/promises' + +const getPathName = (request) => { + const { pathname } = new URL(request.url || '', `https://${request.headers.host}`) + return pathname +} + +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 injectPreviewScript = (html) => { + const injectedCode = `\n` + const titleEndIndex = html.indexOf('') + const newHtml = html.slice(0, titleEndIndex + ''.length) + '\n' + injectedCode + html.slice(titleEndIndex) + return newHtml +} + +const handleIndexHtml = async (response, filePath, frameAncestors) => { + try { + const csp = GetContentSecurityPolicy.getContentSecurityPolicy([`default-src 'none'`, `script-src 'self'`, `frame-ancestors ${frameAncestors}`]) + const contentType = getContentType(filePath) + const content = await readFile(filePath, 'utf8') + SetHeaders.setHeaders(response, { + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Content-Security-Policy': csp, + 'Content-Type': contentType, + }) + const newContent = injectPreviewScript(content) + response.end(newContent) + } catch (error) { + console.error(`[preview-server] ${error}`) + } +} + +const handleOther = async (response, filePath) => { + try { + const contentType = getContentType(filePath) + // TODO figure out which of these headers are actually needed + SetHeaders.setHeaders(response, { + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Access-Control-Allow-Origin': '*', + 'Content-Type': contentType, + }) + await pipeline(createReadStream(filePath), response) + } catch (error) { + console.error(error) + response.end(`[preview-server] ${error}`) + } +} + +const handlePreviewInjected = (response) => { + try { + const injectedCode = PreviewInjectedCode.injectedCode + const contentType = getContentType('/test/file.js') + SetHeaders.setHeaders(response, { + 'Content-Type': contentType, + }) + response.end(injectedCode) + } catch (error) { + console.error(`[preview-server] ${error}`) + } +} + +export const createHandler = (frameAncestors, webViewRoot) => { + const handleRequest = async (request, response) => { + let pathName = getPathName(request) + if (pathName === '/') { + pathName += 'index.html' + } + const filePath = fileURLToPath(`file://${webViewRoot}${pathName}`) + const isHtml = filePath.endsWith('index.html') + if (isHtml) { + return handleIndexHtml(response, filePath, frameAncestors) + } + if (filePath.endsWith('preview-injected.js')) { + return handlePreviewInjected(response) + } + return handleOther(response, filePath) + } + return handleRequest +} diff --git a/src/parts/GetContentSecurityPolicy/GetContentSecurityPolicy.js b/src/parts/GetContentSecurityPolicy/GetContentSecurityPolicy.js new file mode 100644 index 00000000..2583ea6b --- /dev/null +++ b/src/parts/GetContentSecurityPolicy/GetContentSecurityPolicy.js @@ -0,0 +1,6 @@ +import * as Character from '../Character/Character.js' +import * as AddSemiColon from '../AddSemiColon/AddSemiColon.js' + +export const getContentSecurityPolicy = (items) => { + return items.map(AddSemiColon.addSemicolon).join(Character.Space) +} diff --git a/src/parts/PreviewInjectedCode/PreviewInjectedCode.js b/src/parts/PreviewInjectedCode/PreviewInjectedCode.js new file mode 100644 index 00000000..a85a838c --- /dev/null +++ b/src/parts/PreviewInjectedCode/PreviewInjectedCode.js @@ -0,0 +1,74 @@ +export const injectedCode = ` +let commandMap = {} +let port +const callbacks = Object.create(null) + + +const isJsonRpcResponse = message => { + return 'result' in message || 'error' in message +} + +const handleMessage = async (event) => { + const message = event.data + if(isJsonRpcResponse(message)){ + const fn = callbacks[message.id] + fn(message.result) + return + } + const { method, params } = message + const fn = commandMap[method] + if(!fn){ + throw new Error("command not found \${method}") + } + const result = await fn(...params) +} + +const handleFirstMessage = (event) => { + const message = event.data + port = message.params[0] + port.onmessage = handleMessage + port.postMessage('ready') +} + +window.addEventListener('message', handleFirstMessage, { + once: true, +}) + +const withResolvers = () => { + let _resolve + const promise = new Promise(resolve => { + _resolve = resolve + }) + return { + resolve: _resolve, + promise + } +} + +const registerPromise = () => { + const id = 1 + const {resolve, promise} = withResolvers() + callbacks[id] = { resolve } + return { + id, promise + } +} + +globalThis.lvceRpc = (value) => { + commandMap = value + return { + async invoke(method, ...params){ + const {id, promise } = registerPromise() + port.postMessage({ + jsonrpc: '2.0', + id, + method, + params + }) + const response = await promise + // TODO unwrap jsonrpc result + return response + } + } +} +` diff --git a/src/parts/Promises/Promises.js b/src/parts/Promises/Promises.js new file mode 100644 index 00000000..e6ecd75e --- /dev/null +++ b/src/parts/Promises/Promises.js @@ -0,0 +1,19 @@ +export const withResolvers = () => { + /** + * @type {any} + */ + let _resolve + /** + * @type {any} + */ + let _reject + const promise = new Promise((resolve, reject) => { + _resolve = resolve + _reject = reject + }) + return { + resolve: _resolve, + reject: _reject, + promise, + } +} diff --git a/src/parts/SetHeaders/SetHeaders.js b/src/parts/SetHeaders/SetHeaders.js new file mode 100644 index 00000000..0f12e1a6 --- /dev/null +++ b/src/parts/SetHeaders/SetHeaders.js @@ -0,0 +1,5 @@ +export const setHeaders = (response, headers) => { + for (const [key, value] of Object.entries(headers)) { + response.setHeader(key, value) + } +} diff --git a/src/parts/WebViewServer/WebViewServer.ipc.js b/src/parts/WebViewServer/WebViewServer.ipc.js new file mode 100644 index 00000000..793b1bc5 --- /dev/null +++ b/src/parts/WebViewServer/WebViewServer.ipc.js @@ -0,0 +1,8 @@ +import * as WebViewServer from '../WebViewServer/WebViewServer.js' + +export const name = 'WebViewServer' + +export const Commands = { + start: WebViewServer.start, + setHandler: WebViewServer.setHandler, +} diff --git a/src/parts/WebViewServer/WebViewServer.js b/src/parts/WebViewServer/WebViewServer.js new file mode 100644 index 00000000..40a4ef42 --- /dev/null +++ b/src/parts/WebViewServer/WebViewServer.js @@ -0,0 +1,25 @@ +import * as CreateWebViewServer from '../CreateWebViewServer/CreateWebViewServer.js' +import * as CreateWebViewServerHandler from '../CreateWebViewServerHandler/CreateWebViewServerHandler.js' + +const state = { + /** + * @type {any } + */ + promise: undefined, +} + +// TODO move webview preview +// server into separate process + +export const start = async (port) => { + if (!state.promise) { + state.promise = CreateWebViewServer.createWebViewServer(port) + } + return state.promise +} + +export const setHandler = async (frameAncestors, webViewRoot) => { + const server = await state.promise + const handler = CreateWebViewServerHandler.createHandler(frameAncestors, webViewRoot) + server.setHandler(handler) +}