import crypto from 'crypto' const async_build_js_script = async function (path_js_entry_script, build_options) { const result = await Bun.build({ entrypoints: [ path_js_entry_script ], env: 'disable', minify: false, ...build_options, }) const str_out = await result.outputs[0].text() return str_out } const get_siteroot_html = function ({ page_title }) {return ` ${page_title || 'Page Title'} `.trim()} const wrap_transport_sendbytes = function (ws, msg_bytes) { if (ws.readyState === WebSocket.OPEN) { ws.send(msg_bytes) } else { console.warn('ws readyState !== OPEN : dropping send') } return } const start_server = function ({ context_meta, config, jsbuild_app_frontend, handle_transport_bytes }) { const make_session_object = (ws) => { const map_obj = { ws, closed_by_server: false, id_interval_ping: null, id_timeout_checkpong: null, } const func_check_pong = () => { //console.debug('CHECK PONG') // if this check/timeout was not cancelled yet, indicates it's been "too long" (so, terminate ws) const terminate_session = () => { const code = 1008 const reason = 'Policy Violation' map_obj.closed_by_server = true map_obj.ws.close(code, reason) // cleanup here context_meta.ws_map.delete(ws.data.uid) return } terminate_session() // reset this for future ref map_obj.id_timeout_checkpong = null return } const func_periodic_ping = () => { //console.debug('PING') ws.ping() if (config.pong_timeout !== null) { if (map_obj.id_timeout_checkpong === null) { map_obj.id_timeout_checkpong = setTimeout(func_check_pong, config.pong_timeout) } else { // avoid double-schedule of this check console.warn('Unexpected: setTimeout for func_check_pong already scheduled') } } return } if (config.ping_interval !== null) { map_obj.id_interval_ping = setInterval(func_periodic_ping, config.ping_interval) } return map_obj } const server = Bun.serve({ hostname: config.hostname, port : config.port, async fetch (req, server) { const url = new URL(req.url) let resp if (url.pathname === '/') { resp = new Response(get_siteroot_html({ page_title: config.page_title }), { status: 200, headers: { 'Content-Type': 'text/html' } }) } else if (url.pathname === '/index.js') { let str_js // wrap provided function in case it throws error try { str_js = await jsbuild_app_frontend() } catch (err) { console.error(['error during JS build', err]) } if (str_js !== undefined) { resp = new Response( await async_build_js_script(import.meta.dir + '/../frontend/index.js', { define: { JS_APP_FRONTEND: JSON.stringify(str_js), } }), { status: 200, headers: { 'Content-Type': 'text/javascript' } } ) } else { // respond with generic HTTP error if JS build unsuccessful resp = new Response(null, { status: 500 }) } } else if (url.pathname === '/ws') { const uid = crypto.randomUUID() if (server.upgrade(req, { data: { uid, }, })) { // do not return a Response if upgrade succeeds resp = undefined } resp = new Response('Upgrade failed', { status: 500 }) } else { resp = new Response(null, { status: 404 }) } return resp }, websocket: { open: function (ws) { console.info(['ws open', ws.data.uid]) const map_obj = make_session_object(ws) context_meta.ws_map.set(ws.data.uid, map_obj) return }, close: function (ws, code, message) { const map_obj = context_meta.ws_map.get(ws.data.uid) if (map_obj === undefined) { console.warn(['Unexpected: ws_map missing uid', ws.data.uid]) } else { // stop ping-ing if (map_obj.id_interval_ping !== null) { clearInterval(map_obj.id_interval_ping) } // stop pong-check-ing if (map_obj.id_timeout_checkpong !== null) { clearTimeout(map_obj.id_timeout_checkpong) } if (map_obj.closed_by_server === true) { console.info(['ws closed by server', ws.data.uid]) // cleanup will happen in outer context } else { console.info(['ws closed by client', ws.data.uid, { code, message }]) // cleanup from this context context_meta.ws_map.delete(ws.data.uid) } } return }, message: function (ws, message) { const m_bytes = Buffer.byteLength(message) console.info(['ws message', ws.data.uid, { size: m_bytes }]) if (handle_transport_bytes !== undefined) { const transport_send_bytes = wrap_transport_sendbytes.bind(null, ws) try { handle_transport_bytes({ transport_send_bytes }, message) } catch (err) { console.error(['error during handle_transport_bytes', err]) } } else { console.warn('Unexpected: "handle_transport_bytes" not defined') } return }, pong: function (ws, data) { //console.debug(['ws pong', ws.data.uid]) const map_obj = context_meta.ws_map.get(ws.data.uid) if (map_obj === undefined) { console.warn(['Unexpected: ws_map missing uid', ws.data.uid]) } else { // cancel pending check if (map_obj.id_timeout_checkpong !== null) { clearTimeout(map_obj.id_timeout_checkpong) // reset this for future ref map_obj.id_timeout_checkpong = null } } return }, }, }) return server } const close_active_ws_sessions = function ({ context_meta }) { const keys_to_delete = [] for (const [ uid, map_obj ] of context_meta.ws_map) { const code = 1001 const reason = 'Going Away' map_obj.closed_by_server = true map_obj.ws.close(code, reason) keys_to_delete.push(uid) } for (let key of keys_to_delete) { context_meta.ws_map.delete(key) } return } const async_run = async function ({ config, jsbuild_app_frontend, handle_transport_bytes, }) { const active_config = { // defaults hostname: '127.0.0.1', port: 8800, ping_interval: 1000 * 15, pong_timeout : 1000 * 5, // overrides ...config, } const context_meta = { ws_map: new Map(), } const server = start_server({ context_meta, config: active_config, jsbuild_app_frontend, handle_transport_bytes }) console.info(`framerock app now running at ${active_config.hostname}:${active_config.port}`) process.on('SIGINT', async () => { console.log('SIGINT intercepted') close_active_ws_sessions({ context_meta }) // NOTE: wait for a second for teardown to fully complete await Bun.sleep(2000) process.exit() }) return } export { async_run }