Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: add webview server #2

Merged
merged 1 commit into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}