Skip to content

app/server.ts

247 lines · 8.9 KB · TypeScript
Raw
import { packageContext } from 'kody:runtime'
import { JWKS } from '../src/lib/jwks.ts'
import { getCaller, humanPlayer } from '../src/lib/identity.ts'
import { signPowerSyncToken } from '../src/lib/token.ts'
import { POWERSYNC_URL } from '../src/lib/config.ts'
import {
	WorldError,
	applyBlockOps,
	parseBlockOp,
	requireWorldId,
	updateCursor,
	createRoom,
	getRoom,
	parseRoomRef,
	renameRoom,
	upsertPlayer,
	type BlockOp,
} from '../src/lib/world-ops.ts'
import { DEFAULT_WORLD_ID, LIMITS, blockId } from '../src/shared/world-rules.ts'
import { renderShell } from './shell.ts'

const json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>
	Response.json(body, {
		status,
		headers: { 'cache-control': 'no-store', ...headers },
	})

function relPath(pathname: string) {
	const base = String(packageContext?.appBasePath ?? '')
		const assets = String(packageContext?.assetBasePath ?? `${base}/_assets`).replace(/\/+$/, '')
	if (base && pathname.startsWith(base)) pathname = pathname.slice(base.length)
	return pathname.replace(/\/+$/, '') || '/'
}

async function requireHuman() {
	const caller = await getCaller()
	if (!caller) throw new WorldError('Sign in with Kody to build.', 401)
	return humanPlayer(caller)
}

async function session(url: URL) {
	const caller = await getCaller()
	if (!caller) return json({ signedIn: false, worldId: DEFAULT_WORLD_ID, powersyncUrl: POWERSYNC_URL })
	const player = humanPlayer(caller)
	const worldId = requireWorldId(url.searchParams.get('world'))
	let ready = true
	try {
		await upsertPlayer(player, worldId)
	} catch (error) {
		ready = false
		console.warn('player upsert failed', error instanceof Error ? error.message : error)
	}
	return json({
		signedIn: true,
		ready,
		worldId,
		powersyncUrl: POWERSYNC_URL,
		player: { id: player.id, displayName: player.displayName, color: player.color },
	})
}

function roomJson(room: { slug: string; name: string; id: string; ownerId: string }, username: string | null) {
	return { slug: room.slug, name: room.name, worldId: room.id, isOwner: !!username && room.ownerId === username }
}

async function rooms(request: Request, path: string, method: string) {
	const caller = await getCaller()
	if (path === '/api/rooms' && method === 'POST') {
		if (!caller) throw new WorldError('Sign in with Kody to create a room.', 401)
		return json({ room: roomJson(await createRoom(caller.username), caller.username) }, 201)
	}
	const slug = parseRoomRef(path.slice('/api/rooms/'.length))
	if (!slug) throw new WorldError('That room link is not valid.', 404)
	if (method === 'GET') {
		try {
			return json({ room: roomJson(await getRoom(slug), caller?.username ?? null) })
		} catch (error) {
			// Kody resolves credentials only for a signed-in person today, so a
			// signed-out visitor can't read the world database yet.
			if (!caller && !(error instanceof WorldError)) {
				throw new WorldError('Watching without an account is not available yet. Sign in to watch and build.', 503)
			}
			throw error
		}
	}
	if (method === 'PATCH') {
		if (!caller) throw new WorldError('Sign in with Kody to rename the room.', 401)
		const body = (await request.json().catch(() => null)) as { name?: unknown } | null
		return json({ room: roomJson(await renameRoom(slug, caller.username, body?.name), caller.username) })
	}
	return json({ error: 'Method not allowed' }, 405)
}

/**
 * PowerSync sync token. Signed-in people get `sub = u:<username>`; signed-out
 * visitors get a random read-only spectator subject. A PowerSync token can only
 * download (sync rules are read-only buckets); every write goes through
 * `POST /api/upload`, which requires a Kody session, so a spectator token never
 * grants write access.
 */
async function token() {
	const caller = await getCaller()
	const subject = caller ? humanPlayer(caller).id : `anon:${crypto.randomUUID()}`
	try {
		return json({ ...(await signPowerSyncToken(subject)), readOnly: !caller })
	} catch (error) {
		const message = error instanceof Error ? error.message : String(error)
		console.error('world-builder token signing failed', message)
		// Signing errors describe configuration (approval, host), never key material.
		throw new WorldError(`Could not sign a sync token: ${message.slice(0, 300)}`, 503)
	}
}

type CrudOp = { op?: string; type?: string; id?: string; data?: Record<string, unknown> }

/** PowerSync uploadData target. Invalid ops are rejected (and revert on the client); a 429 asks the client to retry. */
async function upload(request: Request) {
	const player = await requireHuman()
	const len = Number(request.headers.get('content-length') ?? 0)
	if (len > 256_000) throw new WorldError('Upload too large.', 413)
	const body = (await request.json().catch(() => null)) as { ops?: Array<CrudOp> } | null
	const ops = Array.isArray(body?.ops) ? body!.ops : []
	if (ops.length > LIMITS.maxUploadOps) throw new WorldError('Too many changes in one upload.', 413)

	const byWorld = new Map<string, Array<BlockOp>>()
	let rejected = 0
	let cursor: { worldId: string; pos: { x: number; y: number; z: number } | null } | null = null
	for (const op of ops) {
		if (op.type === 'blocks' && (op.op === 'PUT' || op.op === 'PATCH')) {
			const d = op.data ?? {}
			let worldId: string
			try {
				worldId = requireWorldId(d.world_id)
			} catch {
				rejected++
				continue
			}
			const parsed = parseBlockOp(d, 'put')
			if (!parsed || op.id !== blockId(worldId, parsed.x, parsed.y, parsed.z)) {
				rejected++
				continue
			}
			byWorld.set(worldId, [...(byWorld.get(worldId) ?? []), parsed])
		} else if (op.type === 'blocks' && op.op === 'DELETE') {
			const m = /^([0-9a-f-]{36}):(-?\d+):(-?\d+):(-?\d+)$/i.exec(String(op.id ?? ''))
			const parsed = m ? parseBlockOp({ x: m[2], y: m[3], z: m[4] }, 'del') : null
			if (!m || !parsed) {
				rejected++
				continue
			}
			const worldId = m[1]!.toLowerCase()
			byWorld.set(worldId, [...(byWorld.get(worldId) ?? []), parsed])
		} else if (op.type === 'players' && op.op === 'PATCH' && op.id === player.id) {
			const d = op.data ?? {}
			const has = d.cursor_x != null && d.cursor_y != null && d.cursor_z != null
			let worldId = DEFAULT_WORLD_ID
			try {
				worldId = requireWorldId(d.world_id)
			} catch {}
			cursor = {
				worldId,
				pos: has ? { x: Number(d.cursor_x), y: Number(d.cursor_y), z: Number(d.cursor_z) } : null,
			}
		} else {
			rejected++
		}
	}
	let placed = 0
	let removed = 0
	for (const [worldId, worldOps] of byWorld) {
		try {
			const out = await applyBlockOps({ player, worldId, ops: worldOps })
			placed += out.placed
			removed += out.removed
		} catch (error) {
			// 429 should be retried by the client; other world errors drop the ops.
			if (error instanceof WorldError && error.status !== 429) {
				rejected += worldOps.length
				continue
			}
			throw error
		}
	}
	if (cursor) {
		try {
			await updateCursor(player, cursor.worldId, cursor.pos)
		} catch (error) {
			if (!(error instanceof WorldError && error.status === 429)) throw error
		}
	}
	return json({ ok: true, placed, removed, rejected })
}

async function dispatch(request: Request) {
	const url = new URL(request.url)
	const path = relPath(url.pathname)
	const method = request.method.toUpperCase()

	if (path === '/health') return json({ ok: true, app: 'world-builder' })
	if (path === '/.well-known/jwks.json' || path === '/jwks.json') {
		return json(JWKS, 200, { 'cache-control': 'public, max-age=300' })
	}
	if (path === '/api/session' && method === 'GET') return session(url)
	if (path === '/api/me' && method === 'GET') {
		const caller = await getCaller()
		return json(caller ? { signedIn: true, displayName: caller.displayName } : { signedIn: false })
	}
	if (path === '/api/token' && method === 'GET') return token()
	if (path === '/api/upload') {
		if (method !== 'POST') return json({ error: 'Method not allowed' }, 405)
		return upload(request)
	}
	if (path === '/api/rooms' || path.startsWith('/api/rooms/')) return rooms(request, path, method)
	if (path === '/manifest.webmanifest') {
		const base = String(packageContext?.appBasePath ?? '')
		const assets = String(packageContext?.assetBasePath ?? `${base}/_assets`)
		return json(
			{
				name: 'Kody World Builder',
				short_name: 'World Builder',
				start_url: `${base}/`,
				scope: `${base}/`,
				display: 'standalone',
				background_color: '#0f1420',
				theme_color: '#0f1420',
				icons: [{ src: `${assets}/icon.png`, sizes: '512x512', type: 'image/png' }],
			},
			200,
			{ 'content-type': 'application/manifest+json' },
		)
	}
	if (path.startsWith('/api/')) return json({ error: 'Not found' }, 404)
	if (method === 'GET' || method === 'HEAD') return renderShell()
	return new Response('Method Not Allowed', { status: 405 })
}

export default {
	async fetch(request: Request) {
		try {
			return await dispatch(request)
		} catch (error) {
			if (error instanceof WorldError) return json({ error: error.message }, error.status)
			console.error('world-builder request failed', error instanceof Error ? error.message : error)
			return json({ error: 'Something went wrong. Try again.' }, 500)
		}
	},
}