← Public packages
@kody/doomPublic
src/app.ts
184 lines · 6.3 KB · TypeScriptimport { packageContext } from 'kody:runtime'
import { appCss } from './app/styles'
import { appClientJs } from './app/client'
import { serviceWorkerJs } from './app/sw'
import { renderHtml } from './app/html'
const ENGINE_BASE = 'https://cdn.jsdelivr.net/gh/justinh-rahb/webui-doom@v6.6.6r9/src'
const FREEDOOM_ZIP = 'https://github.com/freedoom/freedoom/releases/download/v0.13.0/freedoom-0.13.0.zip'
const FFLATE_URL = 'https://cdn.jsdelivr.net/npm/fflate@0.8.2/umd/index.js'
function requireContext() {
if (!packageContext?.hostedUrl || !packageContext?.appBasePath) {
throw new Error('This module must run as a package app.')
}
return {
hostedUrl: String(packageContext.hostedUrl).replace(/\/$/, ''),
appBasePath: String(packageContext.appBasePath).replace(/\/$/, '') || '/',
}
}
/** Compact brand mark for app icon routes. Listing uses committed raw PNGs (no base64 TS). */
const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="KODOOM"><defs><radialGradient id="g" cx="50%" cy="60%" r="60%"><stop offset="0%" stop-color="#ff5014"/><stop offset="55%" stop-color="#1a0a08"/><stop offset="100%" stop-color="#070405"/></radialGradient></defs><rect width="128" height="128" rx="24" fill="url(#g)"/><circle cx="64" cy="64" r="46" fill="none" stroke="#ffb020" stroke-width="4"/><path d="M40 78 V42 H52 L64 62 76 42 H88 V78 H76 V56 L64 74 52 56 V78 Z" fill="#ffc040"/></svg>`
function pathOf(request: Request): string {
const url = new URL(request.url)
let path = url.pathname || '/'
try {
const { appBasePath } = requireContext()
if (appBasePath && appBasePath !== '/' && path.startsWith(appBasePath)) {
path = path.slice(appBasePath.length) || '/'
}
} catch {
// ignore
}
if (!path.startsWith('/')) path = '/' + path
return path
}
async function streamProxy(upstreamUrl: string, contentType: string, cacheControl = 'public, max-age=86400'): Promise<Response> {
let upstream: Response
try {
upstream = await fetch(upstreamUrl)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return new Response(`Upstream fetch failed for ${upstreamUrl}: ${msg}`, { status: 502 })
}
if (!upstream.ok) {
const detail = `Upstream HTTP ${upstream.status} for ${upstreamUrl}`
return new Response(detail, { status: upstream.status === 404 ? 404 : 502 })
}
if (!upstream.body) {
return new Response(`Upstream returned empty body for ${upstreamUrl}`, { status: 502 })
}
const headers = new Headers()
headers.set('content-type', contentType)
headers.set('cache-control', cacheControl)
// Stream body as-is; do not forward content-length/encoding (Worker fetch may decompress).
return new Response(upstream.body, { status: upstream.status, headers })
}
/**
* Hosted KODOOM PWA: Freedoom on Chocolate Doom WASM with installability and WebMCP.
* The package app runtime calls this default export as `fetch`.
*
* @param request - Incoming package-app request. Paths are mount-stripped.
* @returns HTML, manifest, service worker, icons, CSS, or JS
*
* @example
* import handleApp from 'kody:@kody/doom/app'
* const response = await handleApp(new Request('https://example.com/'))
*/
export default async function handleApp(request: Request): Promise<Response> {
const { hostedUrl, appBasePath } = requireContext()
const path = pathOf(request)
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response('Method Not Allowed', { status: 405 })
}
if (path === '/' || path === '') {
const assetBase = appBasePath.replace(/\/$/, '')
const html = renderHtml({
appBasePath,
hostedUrl,
engineBase: ENGINE_BASE,
freedoomZipUrl: `${assetBase}/iwad/freedoom.zip`,
sharewareWadUrl: `${assetBase}/iwad/doom1.wad`,
cfgUrl: `${assetBase}/engine/default.cfg`,
fflateUrl: FFLATE_URL,
})
return new Response(html, {
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-cache',
},
})
}
if (path === '/app.css') {
return new Response(appCss, {
headers: {
'content-type': 'text/css; charset=utf-8',
'cache-control': 'public, max-age=3600',
},
})
}
if (path === '/app.js') {
return new Response(appClientJs, {
headers: {
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'public, max-age=3600',
},
})
}
if (path === '/sw.js') {
return new Response(serviceWorkerJs, {
headers: {
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
'Service-Worker-Allowed': appBasePath || '/',
},
})
}
if (path === '/manifest.webmanifest') {
const manifest = {
name: 'KODOOM',
short_name: 'KODOOM',
description: 'Play Freedoom in the browser on Chocolate Doom WASM — installable PWA with WebMCP.',
start_url: `${appBasePath}/`,
scope: `${appBasePath}/`,
display: 'standalone',
background_color: '#070405',
theme_color: '#070405',
icons: [
{ src: `${appBasePath}/icons/icon-192.png`, sizes: '192x192', type: 'image/svg+xml', purpose: 'any' },
{ src: `${appBasePath}/icons/icon-512.png`, sizes: '512x512', type: 'image/svg+xml', purpose: 'any maskable' },
{ src: `${appBasePath}/icon.png`, sizes: '512x512', type: 'image/svg+xml', purpose: 'any' },
],
categories: ['games', 'entertainment'],
}
return new Response(JSON.stringify(manifest, null, 2), {
headers: {
'content-type': 'application/manifest+json; charset=utf-8',
'cache-control': 'public, max-age=3600',
},
})
}
// Compact SVG for app routes. Community listing / README use raw icon.png + static/icons.
if (
path === '/icon.png' ||
path === '/icons/icon-192.png' ||
path === '/icons/icon-512.png' ||
path === '/icons/apple-touch-icon.png'
) {
return new Response(ICON_SVG, {
headers: {
'content-type': 'image/svg+xml; charset=utf-8',
'cache-control': 'public, max-age=86400',
},
})
}
if (path === '/websockets-doom.wasm') {
return streamProxy(`${ENGINE_BASE}/websockets-doom.wasm`, 'application/wasm')
}
if (path === '/iwad/freedoom.zip') {
return streamProxy(FREEDOOM_ZIP, 'application/zip')
}
if (path === '/iwad/doom1.wad') {
return streamProxy(`${ENGINE_BASE}/doom1.wad`, 'application/octet-stream')
}
if (path === '/engine/default.cfg') {
return streamProxy(`${ENGINE_BASE}/default.cfg`, 'text/plain; charset=utf-8')
}
return new Response('Not Found', { status: 404 })
}