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.

src/realtime.ts

209 lines · 4.9 KB · TypeScript
/**
 * Worker/SSR-safe package realtime helpers for Kody package apps.
 * Wrap the public `KodyRuntime.realtime` surface only (no DO/facet internals).
 */

export const NOTES_REALTIME_TOPIC = 'notes'

export const NOTES_CHANGED_TYPE = 'notes.changed'
export const REALTIME_READY_TYPE = 'realtime.ready'
export const REALTIME_PING_TYPE = 'realtime.ping'
export const REALTIME_PONG_TYPE = 'realtime.pong'

/** Minimal public realtime binding used by kit helpers. */
export type PackageRealtimeBroadcast = {
	broadcast(input: {
		data: unknown
		topic?: string | null
		facet?: string | null
	}): unknown | Promise<unknown>
}

export type PackageRealtimeAction =
	| { type: 'send'; data: unknown }
	| { type: 'subscribe'; topic: string }
	| { type: 'unsubscribe'; topic: string }
	| { type: 'emit'; sessionId: string; data: unknown }
	| {
			type: 'broadcast'
			data: unknown
			topic?: string | null
			facet?: string | null
	  }
	| { type: 'close'; code?: number | null; reason?: string | null }

export type PackageRealtimeHookResult =
	| { actions?: Array<PackageRealtimeAction> | null }
	| Array<PackageRealtimeAction>
	| null
	| undefined

export type PackageRealtimeSession = {
	id: string
	facet: string
	topics: Array<string>
	connectedAt: string
	lastSeenAt: string
}

export type PackageRealtimeHookInput = {
	event: 'connect' | 'message' | 'disconnect'
	facet: string
	session: PackageRealtimeSession
	request?: {
		url: string
		method: string
		headers: Record<string, string>
	} | null
	message?: {
		kind: 'text' | 'binary'
		text: string | null
		json: unknown | null
	} | null
	close?: {
		code: number
		reason: string
		wasClean: boolean
	} | null
}

export type NotesChangedEvent = {
	type: typeof NOTES_CHANGED_TYPE
	at: string
	[key: string]: unknown
}

/**
 * Build a `notes.changed` event payload (storage remains source of truth).
 */
export function notesChangedEvent(
	payload?: Record<string, unknown>,
): NotesChangedEvent {
	return {
		type: NOTES_CHANGED_TYPE,
		at: new Date().toISOString(),
		...(payload ?? {}),
	}
}

/**
 * Notify subscribed clients that notes storage changed.
 */
export function broadcastNotesChanged(
	realtime: PackageRealtimeBroadcast,
	extra?: Record<string, unknown>,
	options?: { topic?: string; facet?: string | null },
) {
	const topic = options?.topic ?? NOTES_REALTIME_TOPIC
	return realtime.broadcast({
		topic,
		facet: options?.facet,
		data: notesChangedEvent(extra),
	})
}

function messageType(value: unknown): string | null {
	if (typeof value === 'string') {
		const trimmed = value.trim()
		return trimmed || null
	}
	if (value && typeof value === 'object' && 'type' in value) {
		const t = (value as { type?: unknown }).type
		return typeof t === 'string' ? t : null
	}
	return null
}

export type CreateNotesRealtimeHandlerOptions = {
	/** Topic to subscribe on connect (default: `notes`). */
	topic?: string
	/** When true (default), send `{ type: 'realtime.ready' }` on connect. */
	sendReady?: boolean
}

/**
 * Factory for `handleRealtimeEvent`: subscribe to the notes topic on connect,
 * answer ping / `realtime.ping` with pong, ignore unknown messages.
 */
export function createNotesRealtimeHandler(
	options?: CreateNotesRealtimeHandlerOptions,
) {
	const topic = options?.topic ?? NOTES_REALTIME_TOPIC
	const sendReady = options?.sendReady !== false

	return function handleRealtimeEvent(
		payload: PackageRealtimeHookInput,
	): PackageRealtimeHookResult {
		if (payload.event === 'connect') {
			const actions: Array<PackageRealtimeAction> = [
				{ type: 'subscribe', topic },
			]
			if (sendReady) {
				actions.push({
					type: 'send',
					data: { type: REALTIME_READY_TYPE, topic },
				})
			}
			return { actions }
		}

		if (payload.event === 'message') {
			const json = payload.message?.json
			const text = payload.message?.text
			const type =
				messageType(json) ??
				(typeof text === 'string' ? messageType(text) : null)

			if (
				type === 'ping' ||
				type === REALTIME_PING_TYPE ||
				text?.trim() === 'ping'
			) {
				return {
					actions: [{ type: 'send', data: { type: REALTIME_PONG_TYPE } }],
				}
			}

			if (
				json &&
				typeof json === 'object' &&
				(json as { type?: unknown }).type === 'subscribe' &&
				typeof (json as { topic?: unknown }).topic === 'string'
			) {
				return {
					actions: [
						{
							type: 'subscribe',
							topic: String((json as { topic: string }).topic),
						},
					],
				}
			}

			if (
				json &&
				typeof json === 'object' &&
				(json as { type?: unknown }).type === 'unsubscribe' &&
				typeof (json as { topic?: unknown }).topic === 'string'
			) {
				return {
					actions: [
						{
							type: 'unsubscribe',
							topic: String((json as { topic: string }).topic),
						},
					],
				}
			}

			return { actions: [] }
		}

		return { actions: [] }
	}
}

/** Default notes live-sync handler for the kit demo / copy-paste playbook. */
export const handleRealtimeEvent = createNotesRealtimeHandler()

export default handleRealtimeEvent