a post-component library for building user-interfaces on the web.
1import { serve } from '@hono/node-server'
2import { serveStatic } from '@hono/node-server/serve-static'
3import { transformSync } from 'amaro'
4import { Hono } from 'hono'
5import * as path from 'node:path'
6import { fileURLToPath } from 'node:url'
7import * as puppeteer from 'puppeteer'
8import type { Runtime } from './main.ts'
9
10export interface BrowserRuntimeOptions {
11 collect_coverage?: boolean
12}
13
14export async function create_browser_runtime(options: BrowserRuntimeOptions = {}): Promise<Runtime> {
15 const collect_coverage = options.collect_coverage ?? true
16
17 const browser = await puppeteer.launch({
18 // headless: false,
19 // devtools: true,
20 })
21
22 const app = new Hono()
23
24 app.get('/@runner', c => {
25 const pkg = (specifier: string) => '/' + path.relative('.', fileURLToPath(import.meta.resolve(specifier)))
26
27 return c.html(`
28 <!doctype html>
29 <link rel="icon" href="data:" />
30 <script type="importmap">${JSON.stringify({
31 imports: {
32 dhtml: '/dist/index.js',
33 'dhtml/client': '/dist/client.js',
34 'dhtml/server': '/dist/server.js',
35 birpc: pkg('birpc'),
36 devalue: pkg('devalue'),
37 mitata: pkg('mitata'),
38 },
39 })}</script>
40 <script type="module" src="/scripts/test/runtime.ts"></script>
41 `)
42 })
43
44 app.use(async (c, next) => {
45 await next()
46 if (c.res.ok && c.req.path.endsWith('.ts')) {
47 const { code } = transformSync(await c.res.text(), { mode: 'strip-only' })
48 c.res = c.body(code)
49 c.res.headers.set('content-type', 'text/javascript')
50 c.res.headers.delete('content-length')
51 }
52 })
53 app.use(serveStatic({ root: './' }))
54 app.use(async (c, next) => {
55 await next()
56 c.header('Cross-Origin-Opener-Policy', 'same-origin')
57 c.header('Cross-Origin-Embedder-Policy', 'require-corp')
58 c.header('Cross-Origin-Resource-Policy', 'same-origin')
59 })
60
61 const server = serve({
62 fetch: app.fetch,
63 port: 0,
64 })
65
66 let addr = server.address()!
67 if (typeof addr !== 'string') {
68 addr = addr.family === 'IPv6' ? `[${addr.address}]:${addr.port}` : `${addr.address}:${addr.port}`
69 }
70
71 const [page] = await browser.pages()
72 page.on('console', async msg => {
73 const args = await Promise.all(msg.args().map(arg => arg.jsonValue()))
74 const type = msg.type()
75 switch (type) {
76 case 'startGroup':
77 console.group(...args)
78 break
79 case 'startGroupCollapsed':
80 console.groupCollapsed(...args)
81 break
82 case 'endGroup':
83 console.groupEnd()
84 break
85 case 'verbose':
86 console.log(...args)
87 break
88 default:
89 const fn = console[type]
90 // @ts-expect-error
91 fn(...args)
92 }
93 })
94 const { port1, port2 } = new MessageChannel()
95 await page.exposeFunction('__postMessage', (data: any) => port1.postMessage(data))
96
97 if (collect_coverage) await page.coverage.startJSCoverage({ includeRawScriptCoverage: true })
98 await page.goto(`http://${addr}/@runner`)
99
100 const onmessage = await page.waitForFunction(() => window.__onmessage)
101 port1.onmessage = e => onmessage.evaluate((fn, data) => fn(data), e.data)
102
103 return {
104 port: port2,
105 async coverage() {
106 if (!collect_coverage) return []
107 const coverage = await page.coverage.stopJSCoverage()
108 return coverage.map(c => c.rawScriptCoverage!)
109 },
110 async [Symbol.asyncDispose]() {
111 port1.close()
112 server.close()
113 await browser.close()
114 },
115 }
116}