Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kody/doomPublic

src/app.ts

184 lines · 6.3 KB · TypeScript
import { 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 })
}