{ "$schema": "https://shadcn-vue.com/schema/registry-item.json", "name": "event-calendar-realtime", "title": "Event Calendar — Realtime collaboration (Nitro crossws)", "description": "Optional real-time sync: a Nitro crossws WebSocket that broadcasts event changes to connected clients (owner-scoped), plus a client composable to apply remote changes with reconnect. Requires the persistence layer and nitro.experimental.websocket.", "dependencies": [], "registryDependencies": [ "https://event-calendar.anorebel.net/r/event-calendar-persistence.json" ], "files": [ { "path": "app/registry/event-calendar-realtime/_ws.ts", "content": "import type { Peer } from \"crossws\"\nimport { registerBroadcaster, type EventChange } from \"../utils/broadcast\"\n\n// Single broadcast channel: every connected peer subscribes to \"events\" and\n// receives all changes. This is the single-user template — there is no\n// owner-scoping.\nconst CHANNEL = \"events\"\n\n// Track connected peers so the broadcaster can publish to them. A broadcast\n// originating from an HTTP handler (no peer context) reaches peers via this\n// registry.\nconst peers = new Set()\n\n// Register how broadcastEventChange (called by the API after a successful write)\n// reaches connected clients. Publishes every change to all peers.\nregisterBroadcaster((change: EventChange) => {\n const payload = JSON.stringify(change)\n for (const peer of peers) {\n try {\n peer.send(payload)\n } catch {\n // Drop unreachable peers silently.\n }\n }\n})\n\nexport default defineWebSocketHandler({\n async open(peer) {\n // TODO(auth): authenticate the peer in open() and subscribe it to a per-user topic; scope broadcasts to the owner.\n peers.add(peer)\n peer.subscribe(CHANNEL)\n },\n\n message(peer, message) {\n // Client → server messages are only used for a lightweight ping/keepalive.\n if (message.text() === \"ping\") peer.send(\"pong\")\n },\n\n close(peer) {\n peers.delete(peer)\n },\n\n error(peer) {\n peers.delete(peer)\n },\n})\n", "type": "registry:file", "target": "~/server/routes/_ws.ts" }, { "path": "app/registry/event-calendar-realtime/broadcast.ts", "content": "import type { EventDTO } from \"./eventMapper\"\n\nexport type EventChange =\n | { type: \"created\"; event: EventDTO }\n | { type: \"updated\"; event: EventDTO }\n | { type: \"deleted\"; id: string; ownerId: string | null }\n\n// Publishes an event change to connected WebSocket peers. The actual crossws\n// publish is wired in the WebSocket handler (section 8); this indirection keeps\n// the API handlers decoupled from the transport and lets them broadcast only\n// AFTER a successful write. No-op until a publisher registers.\nlet publisher: ((change: EventChange) => void) | null = null\n\nexport function registerBroadcaster(fn: (change: EventChange) => void): void {\n publisher = fn\n}\n\nexport async function broadcastEventChange(change: EventChange): Promise {\n try {\n publisher?.(change)\n } catch {\n // Broadcasting must never break the API response.\n }\n}\n", "type": "registry:file", "target": "~/server/utils/broadcast.ts" }, { "path": "app/registry/event-calendar-realtime/useCalendarRealtime.ts", "content": "import { ref, onScopeDispose } from \"vue\"\n\n// Wire-format event: dates are ISO strings over the socket.\ninterface EventDTO {\n id: string\n ownerId?: string | null\n title: string\n startDate: string\n endDate: string\n [key: string]: unknown\n}\n\n// A change broadcast over the WebSocket. Matches the server's EventChange.\nexport type RemoteEventChange =\n | { type: \"created\"; event: EventDTO }\n | { type: \"updated\"; event: EventDTO }\n | { type: \"deleted\"; id: string; ownerId?: string | null }\n\n// Connects to the Nitro WebSocket and applies remote event changes. Real-time is\n// an enhancement: if the socket is unavailable, CRUD over HTTP still works. The\n// caller supplies `apply` (useCalendarData.applyRemote) and a set of ids the\n// client itself just wrote, so echoes of its own changes aren't re-applied.\nexport function useCalendarRealtime(\n apply: (change: RemoteEventChange) => void,\n isOwnEcho: (change: RemoteEventChange) => boolean,\n onReconnect?: () => void,\n) {\n const connected = ref(false)\n let ws: WebSocket | null = null\n let reconnectTimer: ReturnType | null = null\n let stopped = false\n let hadConnected = false\n\n const url = () => {\n const proto = location.protocol === \"https:\" ? \"wss:\" : \"ws:\"\n return `${proto}//${location.host}/_ws`\n }\n\n const connect = () => {\n if (stopped || !import.meta.client) return\n try {\n ws = new WebSocket(url())\n } catch {\n scheduleReconnect()\n return\n }\n\n ws.onopen = () => {\n connected.value = true\n // On a RE-connect (not the first connect), resync so changes missed during\n // the outage are picked up.\n if (hadConnected) onReconnect?.()\n hadConnected = true\n }\n\n ws.onmessage = (ev) => {\n try {\n if (ev.data === \"pong\") return\n const change = JSON.parse(ev.data) as RemoteEventChange\n if (isOwnEcho(change)) return\n apply(change)\n } catch {\n // Ignore malformed frames.\n }\n }\n\n ws.onclose = () => {\n connected.value = false\n scheduleReconnect()\n }\n\n ws.onerror = () => {\n ws?.close()\n }\n }\n\n const scheduleReconnect = () => {\n if (stopped || reconnectTimer) return\n reconnectTimer = setTimeout(() => {\n reconnectTimer = null\n connect()\n }, 3000)\n }\n\n const start = () => {\n stopped = false\n connect()\n }\n\n const stop = () => {\n stopped = true\n if (reconnectTimer) {\n clearTimeout(reconnectTimer)\n reconnectTimer = null\n }\n ws?.close()\n ws = null\n connected.value = false\n }\n\n onScopeDispose(stop)\n\n return { connected, start, stop }\n}\n", "type": "registry:hook", "target": "~/composables/useCalendarRealtime.ts" } ], "type": "registry:block" }