Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185/** * Live connection-status registry for the active WebSocket sync adapter. * * The Repo's WebSocketClientAdapter emits 'peer-candidate' once the * remote sync-server peer is recognised and 'peer-disconnected' when * the socket closes (see * node_modules/@automerge/automerge-repo-network-websocket/dist/ * WebSocketClientAdapter.js — lines 83/135 emit 'peer-disconnected', * line 155 emits 'peer-candidate'). NO 'disconnect' event is ever * emitted by the adapter itself; we deliberately bind to * 'peer-disconnected' instead. * * This registry exposes a small reactive store so the Settings UI badge * and the e2e test diagnostics can both observe the WebSocket peer * state without coupling to the adapter's internals or its identity * across hot-swaps. */
import type { WebSocketClientAdapter } from '@automerge/automerge-repo-network-websocket'
export type SyncConnectionState = 'disconnected' | 'connecting' | 'connected' | 'blocked'
export interface SyncConnectionStatus { state: SyncConnectionState url: string | null}
type Listener = (status: SyncConnectionStatus) => void
export interface SyncStatusRegistry { getStatus(): SyncConnectionStatus subscribe(listener: Listener): () => void setAdapter(adapter: WebSocketClientAdapter | null): void /** * Timestamp (Date.now()) of the most recent inbound message on the * attached WebSocket adapter, or null if no message has been * received since the adapter was attached. * * Used by the e2e harness as a stronger settled-signal than the * local save-status indicator: receiving a message back from the * sync server confirms the server is engaged with this client's * outbound traffic. (R5) */ getLastMessageReceivedAt(): number | null /** * Surface a sync-server URL change that happened BEFORE the repo * singleton was initialised. No adapter exists yet, but UI * subscribed to status updates (the SyncSection badge) deserves to * see the URL coherent with localStorage in the gap between * setSyncServerUrl and initializeRepo. (R12) * * No-op if an adapter is currently attached (the adapter is the * source of truth in that case). */ setPreInitUrl(url: string): void setBlockedUrl(url: string): void}
export function createSyncStatusRegistry(): SyncStatusRegistry { let status: SyncConnectionStatus = { state: 'disconnected', url: null } let attached: { adapter: WebSocketClientAdapter; off: () => void } | null = null let lastMessageReceivedAt: number | null = null // Tracks whether the most recent state mutation was a pre-init URL // declaration. Used by setAdapter to suppress a redundant // 'connecting'+url emission when the pre-init URL matches the new // adapter's URL — see R3. Cleared on any subsequent setStatus // transition. let preInitUrlPending: string | null = null const listeners = new Set<Listener>()
const emit = () => { for (const l of listeners) { try { l(status) } catch (err) { console.error('sync-status listener threw:', err) } } }
const setStatus = (next: SyncConnectionStatus) => { if (next.state === status.state && next.url === status.url) return status = next // Any non-pre-init transition consumes the pending marker — the // pre-init URL has either been superseded by an adapter (handled // explicitly in setAdapter) or by a different URL via another // pre-init call, and either way subsequent emits are no longer // candidates for redundancy suppression. (Direct setPreInitUrl // calls set preInitUrlPending AFTER going through this function // so the marker survives them; that's the intentional asymmetry.) if (status.state !== 'disconnected') { preInitUrlPending = null } emit() }
return { getStatus: () => status, getLastMessageReceivedAt: () => lastMessageReceivedAt, setPreInitUrl(url: string) { // If a real adapter is attached, the adapter's URL is the source // of truth — don't let pre-init writes overwrite a live state. if (attached) return preInitUrlPending = url setStatus({ state: 'disconnected', url }) }, setBlockedUrl(url: string) { this.setAdapter(null) setStatus({ state: 'blocked', url }) }, subscribe(listener) { listeners.add(listener) return () => { listeners.delete(listener) } }, setAdapter(adapter) { // Detach the old adapter before attaching the new one — otherwise // hot-swap leaves dangling listeners that can fire stale status // updates against the previous URL. if (attached) { attached.off() attached = null } // Reset the message timestamp when an adapter changes. Stale // timestamps across hot-swaps would let an e2e think the new // adapter had received traffic when it had not. lastMessageReceivedAt = null if (!adapter) { preInitUrlPending = null setStatus({ state: 'disconnected', url: null }) return } const url = adapter.url // R3: if the previous pre-init emit already surfaced this URL // to subscribers, do not emit a separate 'connecting' event // for the same URL. A real adapter going from disconnected // (pre-init) to connecting (post-init) for an unchanged URL // is one logical state change, not two. We DO still update // the in-memory state machine — subsequent peer-candidate // events flow into 'connected' as normal — we just suppress // the redundant subscriber notification. const suppressPostInitEmit = preInitUrlPending === url preInitUrlPending = null if (suppressPostInitEmit) { status = { state: 'connecting', url } } else { setStatus({ state: 'connecting', url }) } const onPeer = () => setStatus({ state: 'connected', url }) // Post-disconnect we report 'connecting' (not 'disconnected') because // the WebSocketClientAdapter auto-reconnects per its retryInterval. // From the user's POV the system is "still trying," and reporting // 'disconnected' would imply the app has given up. const onDisconnected = () => setStatus({ state: 'connecting', url }) // 'message' fires for every inbound sync-server packet; record // the timestamp so the e2e harness can poll for a positive // confirmation that the server is engaged with this client. // (R5: stronger settled-signal than local IndexedDB save.) const onMessage = () => { lastMessageReceivedAt = Date.now() } adapter.on('peer-candidate', onPeer) adapter.on('peer-disconnected', onDisconnected) adapter.on('message', onMessage) attached = { adapter, off: () => { adapter.off('peer-candidate', onPeer) adapter.off('peer-disconnected', onDisconnected) adapter.off('message', onMessage) }, } }, }}
/** * App-wide singleton. Tests should generally use * {@link createSyncStatusRegistry} to construct fresh registries instead * of mutating this one, but the SettingsModal/SyncSection subscribes * here directly. */export const syncStatusRegistry = createSyncStatusRegistry()