Skip to content

Commit

Permalink
feature: add webview server (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
levivilet authored Aug 27, 2024
1 parent 3cad5a4 commit bec2993
Show file tree
Hide file tree
Showing 11 changed files with 293 additions and 1 deletion.
5 changes: 5 additions & 0 deletions src/parts/AddSemiColon/AddSemiColon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as Character from '../Character/Character.js'

export const addSemicolon = (line) => {
return line + Character.SemiColon
}
12 changes: 12 additions & 0 deletions src/parts/Character/Character.js
Original file line number Diff line number Diff line change
@@ -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 = ';'
6 changes: 5 additions & 1 deletion src/parts/CommandMap/CommandMap.js
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export const commandMap = {}
import * as WebViewServer from '../WebViewServer/WebViewServer.js'

export const commandMap = {
'WebViewServer.start': WebViewServer.start,
}
24 changes: 24 additions & 0 deletions src/parts/CreateWebViewServer/CreateWebViewServer.js
Original file line number Diff line number Diff line change
@@ -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`)
}
}
110 changes: 110 additions & 0 deletions src/parts/CreateWebViewServerHandler/CreateWebViewServerHandler.js
Original file line number Diff line number Diff line change
@@ -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 = `<script type="module" src="/preview-injected.js"></script>\n`
const titleEndIndex = html.indexOf('</title>')
const newHtml = html.slice(0, titleEndIndex + '</title>'.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
}
Original file line number Diff line number Diff line change
@@ -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)
}
74 changes: 74 additions & 0 deletions src/parts/PreviewInjectedCode/PreviewInjectedCode.js
Original file line number Diff line number Diff line change
@@ -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
}
}
}
`
19 changes: 19 additions & 0 deletions src/parts/Promises/Promises.js
Original file line number Diff line number Diff line change
@@ -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,
}
}
5 changes: 5 additions & 0 deletions src/parts/SetHeaders/SetHeaders.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const setHeaders = (response, headers) => {
for (const [key, value] of Object.entries(headers)) {
response.setHeader(key, value)
}
}
8 changes: 8 additions & 0 deletions src/parts/WebViewServer/WebViewServer.ipc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as WebViewServer from '../WebViewServer/WebViewServer.js'

export const name = 'WebViewServer'

export const Commands = {
start: WebViewServer.start,
setHandler: WebViewServer.setHandler,
}
25 changes: 25 additions & 0 deletions src/parts/WebViewServer/WebViewServer.js
Original file line number Diff line number Diff line change
@@ -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)
}

0 comments on commit bec2993

Please sign in to comment.