← 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