← 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