pear-bridge
Version:
Local HTTP Bridge for Pear Desktop Applications
213 lines (186 loc) • 7.46 kB
JavaScript
/* global Pear */
'use strict'
const http = require('bare-http1')
const ScriptLinker = require('script-linker')
const ReadyResource = require('ready-resource')
const streamx = require('streamx')
const listen = require('listen-async')
const gunk = require('pear-api/gunk')
const transform = require('pear-api/transform')
const Mime = require('./mime')
const { ERR_HTTP_BAD_REQUEST, ERR_HTTP_NOT_FOUND } = require('./errors')
const mime = new Mime()
class PearDrive {
constructor (ipc) {
this.ipc = ipc
}
get (key) {
return this.ipc.get({ key })
}
entry (key) {
return this.ipc.entry({ key })
}
compare (keyA, keyB) {
return this.ipc.compare({ keyA, keyB })
}
}
module.exports = class Http extends ReadyResource {
constructor (opts = {}) {
super()
this.opts = opts
this.mount = this.opts.mount ?? ''
this.waypoint = this.opts.waypoint ?? null
if (this.mount && this.mount[0] !== '/') this.mount = '/' + this.mount
this.ipc = Pear[Pear.constructor.IPC]
this.drive = new PearDrive(this.ipc)
this.linker = new ScriptLinker(this.drive, {
builtins: gunk.builtins,
map: gunk.app.map,
mapImport: gunk.app.mapImport,
symbol: gunk.app.symbol,
protocol: gunk.app.protocol,
runtimes: gunk.app.runtimes
})
this.connections = new Set()
this.server = http.createServer(async (req, res) => {
try {
const xPear = req.headers['x-pear']
const isDevtools = req.url.includes('+app+map')
if ((!xPear || !xPear.startsWith('Pear')) && !isDevtools) throw ERR_HTTP_BAD_REQUEST()
const [url, protocol = 'app', type = 'app'] = req.url.split('+')
req.url = (url === '/') ? '/index.html' : url
if (protocol !== 'app' && protocol !== 'resolve') {
throw ERR_HTTP_BAD_REQUEST('Unknown protocol')
}
const id = isDevtools ? Pear.config.id : xPear.slice(5)
await this.lookup(id, protocol, type, req, res)
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') {
err.status = err.status || 404
} else if (err.code === 'ERR_HTTP_NOT_FOUND') {
err.status = err.status || 404
} else if (err.code === 'SESSION_CLOSED') {
err.status = err.status || 503
} else {
console.error('Unknown HTTP Server Error', err)
err.status = 500
}
res.setHeader('Content-Type', 'text/plain')
res.statusCode = err.status
res.end(err.message)
}
})
this.server.on('connection', (c) => {
this.connections.add(c)
c.on('close', () => this.connections.delete(c))
})
this.port = null
this.unref()
}
unref () {
this.server.unref()
}
ref () {
this.server.ref()
}
async #notFound (req, res) {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.statusCode = 404
const name = Pear.config.name
const { app } = await Pear.versions()
const locals = { url: req.url, name, version: `v.${app.fork}.${app.length}.${app.key}` }
const stream = transform.stream(await this.ipc.get({ key: 'node_modules/pear-bridge/not-found.html' }), locals)
return await streamx.pipelinePromise(stream, res)
}
async lookup (id, protocol, type, req, res) {
try {
const [, startId] = id.split('@')
const reported = await this.ipc.reported({ startId })
if (reported?.err) throw ERR_HTTP_NOT_FOUND('Not Found - ' + (reported.err.code || 'ERR_UNKNOWN') + ' - ' + reported.err.message)
return await this.#lookup(protocol, type, req, res)
} catch (err) {
if (err.code === 'ERR_HTTP_NOT_FOUND') return await this.#notFound(req, res)
throw err
}
}
async #lookup (protocol, type, req, res) {
const url = `${protocol}://${type}${req.url}`
const rootUrl = `${protocol}://${type}${this.waypoint ? this.waypoint.slice(0, this.waypoint.lastIndexOf('/')) : ''}${req.url}`
let link = ScriptLinker.link.parse(rootUrl)
try { link = link.transform === 'app' ? link : ScriptLinker.link.parse(url) } catch { throw ERR_HTTP_BAD_REQUEST(`Bad Request (Malformed URL: ${url})`) }
if (link.filename !== null) link.filename = this.mount + link.filename
const isImport = link.transform === 'esm' || link.transform === 'app'
let builtin = false
if (link.filename === null) {
link.filename = await this.linker.resolve(link.resolve, link.dirname, { isImport })
builtin = link.filename === link.resolve && this.linker.builtins.has(link.resolve)
}
let isJS = false
if (protocol !== 'resolve') {
const ct = mime.type(link.filename)
// esm import of wasm returns the wasm file url
if (ct === 'application/wasm' && link.transform === 'esm') {
res.setHeader('Content-Type', 'application/javascript; charset=utf-8')
link.transform = 'wasm'
const out = await this.linker.transform(link)
res.end(out)
return
}
res.setHeader('Content-Type', ct)
if (link.transform === 'app') link.transform = 'esm'
isJS = ct.slice(0, 22) === 'application/javascript'
if (builtin) {
res.setHeader('Content-Type', 'application/javascript; charset=utf-8')
const out = await this.linker.transform(link)
res.end(out)
return
}
}
if (await this.ipc.exists({ key: link.filename }) === false) {
if (!link.filename.endsWith('.html')) {
const file = this.#lookup(protocol, type, { __proto__: req, url: req.url + '.html' }, res)
const index = this.#lookup(protocol, type, { __proto__: req, url: req.url + '/index.html' }, res)
const matches = await Promise.allSettled([file, index])
if (matches[0].status === 'fulfilled' && this.waypoint !== matches[0].value) return matches[0]
if (matches[1].status === 'fulfilled' && this.waypoint !== matches[1].value) return matches[1]
}
throw ERR_HTTP_NOT_FOUND(`Not Found: "${link.filename}"`)
}
if (protocol === 'resolve') {
res.setHeader('Content-Type', 'text/plain; charset=UTF-8')
if (!link.resolve && !link.dirname && !link.filename) throw ERR_HTTP_NOT_FOUND(`Not Found: "${req.url}"`)
res.end(link.filename)
return
}
const isSourceMap = link.transform === 'map'
if (isJS || isSourceMap) {
const out = await this.linker.transform(link)
if (isSourceMap) res.setHeader('Content-Type', 'application/json')
res.end(out)
} else {
if (protocol === 'app' && (link.filename.endsWith('.html') || link.filename.endsWith('.htm'))) {
const mods = await this.linker.warmup(link.filename)
const batch = []
for (const [filename, mod] of mods) {
if (mod.type === 'module') continue
const source = mod.toCJS()
batch.push({ filename, source })
}
await this.ipc.warmup({ protocol, batch })
}
const buffer = await this.ipc.get({ key: link.filename })
if (buffer === null) throw new ERR_HTTP_NOT_FOUND(`Not Found: "${link.filename}"`)
res.end(buffer)
}
}
async _open () {
await listen(this.server, 0, '127.0.0.1')
this.port = this.server.address().port
this.addr = 'http://localhost:' + this.port
}
async _close () {
const serverClosing = new Promise((resolve) => this.server.close(resolve))
for (const c of this.connections) c.destroy()
await serverClosing
}
}