Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162/** * Sync-server URL configuration. * * Reams talks to a single Automerge WebSocket sync server, and the URL * for that server is configured per-device via localStorage. This module * owns the localStorage contract and the resolution-order helper: * * 1. localStorage['reams:sync-server-url'] (user-set; survives reloads) * 2. import.meta.env.VITE_SYNC_SERVER_URL (build-time default for envs * like staging/preview) * 3. DEFAULT_SYNC_SERVER_URL (local dev fallback) * * This module has no React, Automerge, or browser-API-beyond-localStorage * dependencies, so it's exhaustively unit-testable in jsdom and stays * trivial to call from anywhere in the app. */
// Default sync server is the dev box on the LAN at 192.168.1.240:7030// so multi-device dogfooding works out-of-the-box in development. A// production build blocks this cleartext remote default until the user// explicitly opts in; configure wss:// for a production deployment.// Port 7030 is in the 7000-9000 range to avoid colliding// with common dev defaults (3000/3030/4000/5000). Users on a different// LAN should override via localStorage['reams:sync-server-url'] or// VITE_SYNC_SERVER_URL.export const DEFAULT_SYNC_SERVER_URL = 'ws://192.168.1.240:7030'export const SYNC_SERVER_URL_KEY = 'reams:sync-server-url'export const INSECURE_SYNC_OVERRIDE_KEY = 'reams:allow-insecure-remote-sync'
/** A persisted opt-in, never inferred from a stored URL or a build-time default. */export function hasInsecureSyncOverride(): boolean { return localStorage.getItem(INSECURE_SYNC_OVERRIDE_KEY) === 'true'}
export function persistInsecureSyncOverride(enabled: boolean): void { if (enabled) localStorage.setItem(INSECURE_SYNC_OVERRIDE_KEY, 'true') else localStorage.removeItem(INSECURE_SYNC_OVERRIDE_KEY)}
export type SyncTransportPolicy = 'allowed' | 'blocked' | 'insecure-override'
/** Check the actual endpoint before constructing an adapter, including stored and env URLs. */export function getSyncTransportPolicy(url: string): SyncTransportPolicy { if (!import.meta.env.PROD) return 'allowed' let endpoint: URL try { endpoint = new URL(url) } catch { return 'blocked' } if (endpoint.protocol === 'wss:') return 'allowed' if (endpoint.protocol !== 'ws:') return 'blocked' // URL.hostname is canonicalized by URL (including IPv6 brackets). Do not // accept private LAN addresses, localhost subdomains, or hostname suffixes. if (['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname)) return 'allowed' return hasInsecureSyncOverride() ? 'insecure-override' : 'blocked'}
type Listener = (url: string) => void
const listeners = new Set<Listener>()
function envFallback(): string | undefined { // Vite replaces `import.meta.env.*` at build time; in tests Vitest's // vi.stubEnv shims the same surface. Treat empty string as "not set" // so a misconfigured CI that exports `VITE_SYNC_SERVER_URL=` doesn't // brick connectivity. const v = import.meta.env?.VITE_SYNC_SERVER_URL return typeof v === 'string' && v.length > 0 ? v : undefined}
/** * Resolve the sync-server URL from localStorage → env → default. * * Pure: same inputs (localStorage state + env) → same output. Never * mutates state, never throws. */export function getSyncServerUrl(): string { const stored = localStorage.getItem(SYNC_SERVER_URL_KEY) if (stored && stored.length > 0) return stored return envFallback() ?? DEFAULT_SYNC_SERVER_URL}
/** * Persist a new sync-server URL and notify subscribers. * * **Internal**: this is the persist-only half of the sync-server URL * contract. It does NOT swap the live WebSocketClientAdapter on the * Repo singleton. Most callers want {@link repo.setSyncServerUrl}, * which calls this AND swaps the adapter. Direct callers of this * helper must understand they are leaving the Repo connected to the * previous URL until something else triggers a swap (a reload, an * explicit repo.setSyncServerUrl call, etc.). * * Validation: the value must be non-empty after trimming and must use * `ws:` or `wss:`. We validate here — synchronously — because the actual * Automerge WebSocketClientAdapter throws asynchronously inside `connect` * for malformed URLs; surfacing the error at the call site means the * Settings UI can show a clear inline message instead of an opaque later * console error. * * Subscriber notification is wrapped in try/catch per listener so one * misbehaving subscriber cannot interfere with the rest. */export function persistSyncServerUrl(url: string): void { const trimmed = url.trim() if (trimmed.length === 0) { throw new Error('Sync server URL must not be empty') } // Scheme prefix check first so the error message is precise for the // most common typo (http:// instead of ws://). new URL() would parse // "http://example.com" without complaint, so the scheme guard here // doubles as a clearer error for that case. if (!/^wss?:\/\//.test(trimmed)) { throw new Error('Sync server URL must start with ws: or wss:') } // Reject the "triple-slash" host-less form ("ws:///path") explicitly: // for special schemes (ws/wss/http/https), Node's URL parser slides // the path segment into the host (e.g. "ws:///path" parses with // hostname="path"), so a plain hostname check would not catch it. // The user almost certainly meant "ws://path"; refuse to guess. if (/^wss?:\/\/\//.test(trimmed)) { throw new Error(`Sync server URL must include a host: ${trimmed}`) } // Full URL parse catches host-less inputs ("ws://"), whitespace in // the host, and other obvious malformations that the regex alone // would pass through. The WebSocketClientAdapter would otherwise // throw asynchronously inside connect(), surfacing an opaque error // to the user. // // No separate hostname-length check is needed: for special schemes // (ws/wss) the URL constructor always throws when the hostname is // empty, so the catch block above already covers that case. The // triple-slash regex above handles the one edge case ("ws:///path") // where the parser would slide the path into the hostname. try { new URL(trimmed) } catch { throw new Error(`Sync server URL is not a valid URL: ${trimmed}`) } localStorage.setItem(SYNC_SERVER_URL_KEY, trimmed) for (const l of listeners) { try { l(trimmed) } catch (err) { console.error('sync-server-url listener threw:', err) } }}
/** * Subscribe to URL changes. Returns a function that removes the * subscription. Listener does not fire on subscribe — only on subsequent * {@link setSyncServerUrl} calls. */export function subscribeSyncServerUrl(listener: Listener): () => void { listeners.add(listener) return () => { listeners.delete(listener) }}