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-client.ts

193 lines · 4.8 KB · TypeScript
/**
 * Browser-safe package realtime WebSocket client.
 * Do not import `kody:` from this module — safe for islands / client bundles.
 */

export type RealtimeClientStatus =
	| 'idle'
	| 'connecting'
	| 'open'
	| 'reconnecting'
	| 'closed'

export type RealtimeWsUrlInput = {
	appBasePath: string
	facet?: string | null
	/** Defaults to `globalThis.location` in the browser. */
	location?: Pick<Location, 'protocol' | 'host'>
}

/**
 * Build `ws:` / `wss:` URL for the package app realtime path.
 * Path shape: `{appBasePath}/ws` or `{appBasePath}/ws/{facet}`.
 */
export function realtimeWsUrl(input: RealtimeWsUrlInput): string {
	const loc =
		input.location ??
		(typeof globalThis !== 'undefined'
			? (globalThis as { location?: Location }).location
			: undefined)
	if (!loc?.protocol || !loc.host) {
		throw new Error('realtimeWsUrl requires a browser location (or pass location)')
	}
	const protocol = loc.protocol === 'https:' ? 'wss:' : 'ws:'
	const base = String(input.appBasePath || '').replace(/\/+$/, '')
	const facet = String(input.facet || '').trim()
	const path = facet
		? `${base}/ws/${encodeURIComponent(facet)}`
		: `${base}/ws`
	return `${protocol}//${loc.host}${path}`
}

export type ConnectPackageRealtimeInput = {
	appBasePath: string
	facet?: string | null
	/** After open, send `{ type: 'subscribe', topic }` for each (optional). */
	topics?: Array<string>
	onEvent: (data: unknown) => void
	onStatus?: (status: RealtimeClientStatus) => void
	protocols?: string | Array<string>
	/** Initial reconnect delay ms (default 500). */
	reconnectDelayMs?: number
	/** Max reconnect delay ms (default 15_000). */
	maxReconnectDelayMs?: number
}

export type PackageRealtimeConnection = {
	close: () => void
	getStatus: () => RealtimeClientStatus
	send: (data: unknown) => void
}

/**
 * Connect to package app realtime with reconnect/backoff.
 * Parses JSON text frames and calls `onEvent(data)`.
 */
export function connectPackageRealtime(
	input: ConnectPackageRealtimeInput,
): PackageRealtimeConnection {
	if (typeof WebSocket === 'undefined') {
		throw new Error('connectPackageRealtime requires WebSocket')
	}

	let status: RealtimeClientStatus = 'idle'
	let socket: WebSocket | null = null
	let closedByUser = false
	let reconnectAttempt = 0
	let reconnectTimer: ReturnType<typeof setTimeout> | null = null
	const baseDelay = input.reconnectDelayMs ?? 500
	const maxDelay = input.maxReconnectDelayMs ?? 15_000

	function setStatus(next: RealtimeClientStatus) {
		if (status === next) return
		status = next
		input.onStatus?.(next)
	}

	function clearReconnect() {
		if (reconnectTimer != null) {
			clearTimeout(reconnectTimer)
			reconnectTimer = null
		}
	}

	function scheduleReconnect() {
		if (closedByUser) return
		clearReconnect()
		const delay = Math.min(
			maxDelay,
			baseDelay * Math.pow(2, Math.min(reconnectAttempt, 6)),
		)
		reconnectAttempt += 1
		setStatus('reconnecting')
		reconnectTimer = setTimeout(() => {
			reconnectTimer = null
			open()
		}, delay)
	}

	function sendSubscribeTopics() {
		const topics = input.topics
		if (!topics?.length || !socket || socket.readyState !== WebSocket.OPEN) {
			return
		}
		for (const topic of topics) {
			const t = String(topic || '').trim()
			if (!t) continue
			socket.send(JSON.stringify({ type: 'subscribe', topic: t }))
		}
	}

	function open() {
		if (closedByUser) return
		clearReconnect()
		setStatus(reconnectAttempt > 0 ? 'reconnecting' : 'connecting')
		const url = realtimeWsUrl({
			appBasePath: input.appBasePath,
			facet: input.facet,
		})
		const ws = input.protocols
			? new WebSocket(url, input.protocols)
			: new WebSocket(url)
		socket = ws

		ws.addEventListener('open', () => {
			if (socket !== ws || closedByUser) return
			reconnectAttempt = 0
			setStatus('open')
			sendSubscribeTopics()
		})

		ws.addEventListener('message', (event) => {
			if (socket !== ws) return
			const raw = event.data
			if (typeof raw !== 'string') return
			try {
				input.onEvent(JSON.parse(raw))
			} catch {
				// Non-JSON text frames are ignored.
			}
		})

		ws.addEventListener('close', () => {
			if (socket !== ws) return
			socket = null
			if (closedByUser) {
				setStatus('closed')
				return
			}
			scheduleReconnect()
		})

		ws.addEventListener('error', () => {
			// close handler drives reconnect
		})
	}

	open()

	return {
		close() {
			closedByUser = true
			clearReconnect()
			const ws = socket
			socket = null
			setStatus('closed')
			try {
				ws?.close()
			} catch {
				// ignore
			}
		},
		getStatus() {
			return status
		},
		send(data: unknown) {
			if (!socket || socket.readyState !== WebSocket.OPEN) return
			socket.send(typeof data === 'string' ? data : JSON.stringify(data))
		},
	}
}

/** Default export for package execute/module artifact (named imports still preferred). */
export default connectPackageRealtime