framerock/backend/index.js

234 lines
6.3 KiB
JavaScript

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 `
<!DOCTYPE html>
<html lang=en>
<head>
<meta charset=utf-8>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${page_title || 'Page Title'}</title>
</head>
<body>
<script type="text/javascript" src="/index.js">
</script>
</body>
</html>
`.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') {
const str_js = await jsbuild_app_frontend()
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 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)
handle_transport_bytes({ transport_send_bytes }, message)
}
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(server)
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 }