Skip to content

Kody is live

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

← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

starter-fetch/src/app.ts

220 lines · 5.8 KB · TypeScript
import { packageContext } from 'kody:runtime'
import { buildWebManifest, renderServiceWorker } from 'kody:@kentcdodds/package-app-kit/sw'
import { readRecordedVersion } from './record-version.ts'
import { defaultManifestIcons, iconPngResponse } from 'kody:@kentcdodds/package-app-kit/icons'
import { serverCachified } from 'kody:@kentcdodds/package-app-kit/server-cache'
import {
	clientBootResponse,
	clientModuleResponse,
} from 'kody:@kentcdodds/package-app-kit/client'
import { renderAboutPage, renderAppPage } from './app/page.ts'
import { addNote, deleteNote, listNotes } from './app/notes.ts'

const CACHE_NAME = '__CACHE_NAME__'

type AppPackageContext = {
	hostedUrl?: string
	appBasePath?: string
	assetBasePath?: string | null
	clientModuleUrl?: string | null
}

function json(data: unknown, status = 200) {
	return new Response(JSON.stringify(data), {
		status,
		headers: {
			'content-type': 'application/json; charset=utf-8',
			'cache-control': 'no-store',
		},
	})
}

function html(body: string) {
	return new Response(body, {
		headers: {
			'content-type': 'text/html; charset=utf-8',
			'cache-control': 'no-cache',
		},
	})
}

function appPaths() {
	const ctx = packageContext as AppPackageContext | null
	if (!ctx?.hostedUrl) {
		throw new Error('This module must run as a package app.')
	}
	return {
		hostedUrl: ctx.hostedUrl,
		appBasePath: ctx.appBasePath ?? '',
		assetBasePath: ctx.assetBasePath ?? null,
		clientModuleUrl: ctx.clientModuleUrl ?? null,
	}
}

function pathnameOf(request: Request) {
	const url = new URL(request.url)
	const path = url.pathname === '' ? '/' : url.pathname
	return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path
}

/**
 * Dual-path client:
 * - Prefer packageContext.clientModuleUrl (this app's kody.app.client bundle).
 * - Interim fallback on older platforms: kit committed/bundled ESM via ./client.
 * Never import ./client/index.ts from the Worker (separate graphs).
 */
function interimClientResponse(path: string): Response | null {
	const ctx = packageContext as AppPackageContext | null
	if (ctx?.clientModuleUrl) return null
	if (path === '/app.js' || path === '/client/boot.js') return clientBootResponse()
	if (path.startsWith('/client/')) {
		return clientModuleResponse(path.slice('/client/'.length))
	}
	return null
}

async function appHealth() {
	let fresh = false
	const value = await serverCachified({
		key: 'starter-health',
		ttl: 15_000,
		getFreshValue: async () => {
			fresh = true
			return {
				ok: true,
				generatedAt: new Date().toISOString(),
				cache: 'cachified+packageStorage',
			}
		},
	})
	return { ...value, fresh }
}

async function handleRequest(request: Request): Promise<Response> {
	const path = pathnameOf(request)
	const method = request.method.toUpperCase()
	const paths = appPaths()

	if (method === 'GET' && (path === '/' || path === '')) {
		const [version, health] = await Promise.all([readRecordedVersion(), appHealth()])
		return html(
			renderAppPage({
				...paths,
				runningSha: version.sha,
				version,
				codeUrl: null,
				health,
			}),
		)
	}

	if (method === 'GET' && path === '/about') {
		const version = await readRecordedVersion()
		return html(
			renderAboutPage({
				...paths,
				runningSha: version.sha,
				version,
				codeUrl: null,
			}),
		)
	}

	if (method === 'GET') {
		const clientRes = interimClientResponse(path)
		if (clientRes) return clientRes
	}

	if (method === 'GET' && path === '/manifest.webmanifest') {
		return new Response(
			JSON.stringify(
				buildWebManifest({
					appBasePath: paths.appBasePath,
					name: '__APP_TITLE__',
					shortName: '__APP_TITLE__',
					description: 'Scaffolded from @kentcdodds/package-app-kit starter/.',
					icons: defaultManifestIcons(paths.appBasePath),
				}),
			),
			{
				headers: {
					'content-type': 'application/manifest+json; charset=utf-8',
					'cache-control': 'no-store',
				},
			},
		)
	}

	if (method === 'GET' && path === '/icons/icon-192.png') {
		return iconPngResponse(192)
	}

	if (method === 'GET' && path === '/icons/icon-512.png') {
		return iconPngResponse(512)
	}

	// Interim Worker SW; prefer public/sw.js under assetBasePath when platform assets are live.
	if (method === 'GET' && path === '/sw.js') {
		const allow = `${paths.appBasePath.replace(/\/$/, '')}/`
		const precache = [
			'/',
			'/about',
			'/manifest.webmanifest',
			'/icons/icon-192.png',
			'/icons/icon-512.png',
		]
		if (!paths.clientModuleUrl) precache.push('/app.js', '/client/boot.js')
		return new Response(
			renderServiceWorker({
				cacheName: CACHE_NAME,
				offlineTitle: '__APP_TITLE__ offline',
				precachePaths: precache,
				htmlPaths: ['/', '/about'],
			}),
			{
				headers: {
					'content-type': 'text/javascript; charset=utf-8',
					'cache-control': 'no-store',
					'service-worker-allowed': allow,
				},
			},
		)
	}

	if (method === 'GET' && path === '/api/version') {
		return json(await readRecordedVersion())
	}

	if (method === 'GET' && path === '/api/health') {
		return json(await appHealth())
	}

	if (method === 'GET' && path === '/api/notes') {
		return json({ notes: await listNotes() })
	}

	if (method === 'POST' && path === '/api/notes') {
		const body = (await request.json().catch(() => null)) as { title?: string } | null
		const note = await addNote(String(body?.title || ''))
		return json({ note }, 201)
	}

	if (method === 'DELETE' && path.startsWith('/api/notes/')) {
		const id = decodeURIComponent(path.slice('/api/notes/'.length))
		const result = await deleteNote(id)
		return json(result, result.ok ? 200 : 404)
	}

	return json({ ok: false, error: 'Not found' }, 404)
}

export default {
	async fetch(request: Request): Promise<Response> {
		try {
			return await handleRequest(request)
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error)
			return json({ ok: false, error: message }, 500)
		}
	},
}