UNPKG

trellis

Version:

Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications

1,191 lines (1,179 loc) 32.5 kB
// src/client/reactive.ts var Signal = class { _value; _subs = /* @__PURE__ */ new Set(); constructor(initial) { this._value = initial; } get value() { return this._value; } set value(v) { if (Object.is(this._value, v)) return; this._value = v; for (const fn of this._subs) { try { fn(v); } catch { } } } /** Subscribe to changes. Immediately called with current value. */ subscribe(fn) { this._subs.add(fn); try { fn(this._value); } catch { } return () => { this._subs.delete(fn); }; } /** Read without tracking. */ peek() { return this._value; } /** Whether any subscriber is registered. */ hasSubscribers() { return this._subs.size > 0; } }; // src/realtime/room.ts var RealtimeRoom = class _RealtimeRoom { id; transport; myState; peers = /* @__PURE__ */ new Map(); /** Sender timestamps from presence messages (not local lastSeen). */ presenceTsByPeer = /* @__PURE__ */ new Map(); channelHandlers = /* @__PURE__ */ new Map(); _presence = new Signal([]); now; heartbeatMs; timeoutMs; heartbeatTimer; unsubscribe; closed = false; pendingReplay = null; /** Ids of broadcasts already delivered — keeps replay idempotent (G-Set). */ seenMsgIds = /* @__PURE__ */ new Set(); constructor(opts) { this.transport = opts.transport; this.id = opts.transport.id; this.myState = opts.initialPresence ?? {}; this.now = opts.now ?? (() => Date.now()); this.heartbeatMs = opts.heartbeatMs ?? 2e3; this.timeoutMs = opts.timeoutMs ?? 6e3; this.unsubscribe = this.transport.onMessage((m) => this.handle(m)); this.recomputePresence(); } /** Join a room and announce presence. */ static join(opts) { const room = new _RealtimeRoom(opts); room.announceHello(); room.announcePresence(); room.startHeartbeat(); return room; } // ------------------------------------------------------------------------- // Presence // ------------------------------------------------------------------------- /** The local peer id. */ get selfId() { return this.id; } /** Current local presence state. */ getSelfState() { return this.myState; } /** Merge a partial update into local presence and broadcast it. */ setPresence(partial) { this.myState = { ...this.myState, ...partial }; this.recomputePresence(); this.announcePresence(); } /** Replace local presence wholesale and broadcast it. */ replacePresence(state) { this.myState = state; this.recomputePresence(); this.announcePresence(); } /** All peers including self (self first). */ getPresence() { return this._presence.value; } /** Peers excluding self. */ getOthers() { return this._presence.value.filter((p) => !p.self); } /** Subscribe to presence changes. Called immediately with current peers. */ onPresence(cb) { const unsub = this._presence.subscribe(cb); this.flushPendingReplay(); return unsub; } /** Reactive presence signal (for framework adapters). */ get presenceSignal() { return this._presence; } // ------------------------------------------------------------------------- // Broadcast pub/sub // ------------------------------------------------------------------------- /** * Fire-and-forget broadcast to all other peers on a channel. Returns the * stable message id assigned to this broadcast — persist it alongside an * optimistic local render so an echoed copy (relay replay, reconnect) is * deduplicated by id rather than re-rendered. */ broadcast(channel, event, payload) { const id = this.newMsgId(); if (this.closed) return id; this.seenMsgIds.add(id); this.transport.send({ v: 1, t: "msg", from: this.id, channel, event, payload, ts: this.now(), id }); return id; } newMsgId() { const rand = typeof globalThis.crypto?.randomUUID === "function" ? globalThis.crypto.randomUUID() : Math.random().toString(36).slice(2); return `${this.id}:${rand}`; } /** Subscribe to broadcasts on a channel. Returns an unsubscribe fn. */ on(channel, handler) { let set = this.channelHandlers.get(channel); if (!set) { set = /* @__PURE__ */ new Set(); this.channelHandlers.set(channel, set); } set.add(handler); this.flushPendingReplay(); return () => { set.delete(handler); }; } // ------------------------------------------------------------------------- // Lifecycle // ------------------------------------------------------------------------- /** * Apply a relay replay batch (chat history, text snapshot, presence). * Used when reconnecting to a hub with {@link RelayPersistence}. */ replay(messages) { for (const message of messages) { this.integrateRemote(message); } } /** Announce departure and tear down. */ leave() { if (this.closed) return; this.closed = true; try { this.transport.send({ v: 1, t: "bye", from: this.id, ts: this.now() }); } catch { } if (this.heartbeatTimer !== void 0) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = void 0; } this.unsubscribe(); this.transport.close(); } // ------------------------------------------------------------------------- // Internal // ------------------------------------------------------------------------- handle(message) { if (this.closed) return; if (message.t === "replay") { this.pendingReplay = message.messages; this.flushPendingReplay(); return; } if (message.from === this.id) return; this.integrateRemote(message); } flushPendingReplay() { if (!this.pendingReplay || this.pendingReplay.length === 0) return; if (!this.hasActiveSubscribers()) return; const batch = this.pendingReplay; this.pendingReplay = null; this.replay(batch); } hasActiveSubscribers() { if (this._presence.hasSubscribers()) return true; for (const handlers of this.channelHandlers.values()) { if (handlers.size > 0) return true; } return false; } integrateRemote(message) { if (message.from === this.id) return; switch (message.t) { case "hello": this.announcePresence(); break; case "presence": this.upsertPeer(message.from, message.state, message.ts); break; case "bye": if (this.peers.delete(message.from)) { this.presenceTsByPeer.delete(message.from); this.recomputePresence(); } break; case "msg": { if (message.id !== void 0 && this.seenMsgIds.has(message.id)) return; const handlers = this.channelHandlers.get(message.channel); if (!handlers || handlers.size === 0) return; if (message.id !== void 0) this.seenMsgIds.add(message.id); const event = { from: message.from, channel: message.channel, event: message.event, payload: message.payload, ts: message.ts, id: message.id }; for (const handler of handlers) { try { handler(event); } catch { } } break; } case "replay": break; } } upsertPeer(id, state, ts) { const prevTs = this.presenceTsByPeer.get(id) ?? 0; if (ts < prevTs) { const existing = this.peers.get(id); if (existing) existing.lastSeen = this.now(); return; } this.presenceTsByPeer.set(id, ts); this.peers.set(id, { id, state, lastSeen: this.now(), self: false }); this.recomputePresence(); } announceHello() { this.transport.send({ v: 1, t: "hello", from: this.id }); } announcePresence() { this.transport.send({ v: 1, t: "presence", from: this.id, state: this.myState, ts: this.now() }); } startHeartbeat() { if (this.heartbeatMs <= 0 || typeof setInterval !== "function") { return; } this.heartbeatTimer = setInterval(() => { this.announcePresence(); this.pruneExpired(); }, this.heartbeatMs); } pruneExpired() { const cutoff = this.now() - this.timeoutMs; let changed = false; for (const [id, peer] of this.peers) { if (peer.lastSeen < cutoff) { this.peers.delete(id); this.presenceTsByPeer.delete(id); changed = true; } } if (changed) this.recomputePresence(); } recomputePresence() { this.peers.delete(this.id); const self = { id: this.id, state: this.myState, lastSeen: this.now(), self: true }; const others = [...this.peers.values()].sort( (a, b) => a.id.localeCompare(b.id) ); this._presence.value = [self, ...others]; } }; // src/realtime/memory-hub.ts var MemoryHub = class { transports = /* @__PURE__ */ new Set(); /** Create a transport bound to this hub for the given peer id. */ connect(id) { const transport = new MemoryRealtimeTransport(this, id); this.transports.add(transport); return transport; } /** Number of currently connected transports. */ size() { return this.transports.size; } /** @internal */ _broadcast(from, message) { for (const transport of this.transports) { if (transport === from) continue; transport._deliver(message); } } /** @internal */ _remove(transport) { this.transports.delete(transport); } }; var MemoryRealtimeTransport = class { id; hub; handlers = /* @__PURE__ */ new Set(); closed = false; constructor(hub, id) { this.hub = hub; this.id = id; } send(message) { if (this.closed) return; this.hub._broadcast(this, message); } onMessage(handler) { this.handlers.add(handler); return () => { this.handlers.delete(handler); }; } close() { if (this.closed) return; this.closed = true; this.hub._remove(this); this.handlers.clear(); } /** @internal */ _deliver(message) { if (this.closed) return; for (const handler of this.handlers) { try { handler(message); } catch { } } } }; // src/realtime/broadcast-channel-transport.ts var BroadcastChannelTransport = class { id; bc; handlers = /* @__PURE__ */ new Set(); closed = false; constructor(opts) { this.id = opts.id; const Impl = opts.BroadcastChannelImpl ?? globalThis.BroadcastChannel; if (!Impl) { throw new Error( "BroadcastChannelTransport requires BroadcastChannel or opts.BroadcastChannelImpl." ); } this.bc = new Impl(opts.channel); const onIncoming = (event) => { const message = event.data; if (!message || typeof message !== "object" || message.v !== 1) return; for (const handler of this.handlers) { try { handler(message); } catch { } } }; if (this.bc.addEventListener) { this.bc.addEventListener("message", onIncoming); } else { this.bc.onmessage = onIncoming; } } send(message) { if (this.closed) return; this.bc.postMessage(message); } onMessage(handler) { this.handlers.add(handler); return () => { this.handlers.delete(handler); }; } close() { if (this.closed) return; this.closed = true; this.handlers.clear(); try { this.bc.close(); } catch { } } }; // src/realtime/websocket-relay-transport.ts var WebSocketRelayTransport = class { id; ws; handlers = /* @__PURE__ */ new Set(); closed = false; pending = []; constructor(opts) { this.id = opts.id; const WS = opts.WebSocketImpl ?? globalThis.WebSocket; if (!WS) { throw new Error( "WebSocketRelayTransport requires WebSocket or opts.WebSocketImpl." ); } this.ws = new WS(opts.url); this.ws.addEventListener("open", () => this.flushPending()); this.ws.addEventListener("message", (event) => { let message; try { message = JSON.parse(String(event.data)); } catch { return; } if (!message || typeof message !== "object" || message.v !== 1) return; for (const handler of this.handlers) { try { handler(message); } catch { } } }); this.ws.addEventListener("close", () => { if (!this.closed) this.pending = []; }); } send(message) { if (this.closed) return; if (this.ws.readyState === 1) { this.ws.send(JSON.stringify(message)); return; } this.pending.push(message); } onMessage(handler) { this.handlers.add(handler); return () => { this.handlers.delete(handler); }; } close() { if (this.closed) return; this.closed = true; this.pending = []; this.handlers.clear(); try { this.ws.close(); } catch { } } flushPending() { if (this.ws.readyState !== 1) return; const batch = this.pending.splice(0); for (const message of batch) { this.ws.send(JSON.stringify(message)); } } }; // src/realtime/durable-object-relay-transport.ts var DEFAULT_RECONNECT = { maxAttempts: 0, baseDelayMs: 500, maxDelayMs: 1e4 }; function defaultBuildUrl(opts) { let url = opts.url; if (opts.room) { url = `${url.replace(/\/$/, "")}/${encodeURIComponent(opts.room)}`; } if (opts.auth) { url += `${url.includes("?") ? "&" : "?"}token=${encodeURIComponent(opts.auth)}`; } return url; } var DurableObjectRelayTransport = class { id; url; WS; reconnect; maxPending; ws = null; handlers = /* @__PURE__ */ new Set(); pending = []; closed = false; attempts = 0; reconnectTimer = null; constructor(opts) { this.id = opts.id; const WS = opts.WebSocketImpl ?? globalThis.WebSocket; if (!WS) { throw new Error( "DurableObjectRelayTransport requires WebSocket or opts.WebSocketImpl." ); } this.WS = WS; this.url = (opts.buildUrl ?? defaultBuildUrl)({ url: opts.url, room: opts.room, auth: opts.auth }); this.reconnect = opts.reconnect === false ? null : { ...DEFAULT_RECONNECT, ...opts.reconnect ?? {} }; this.maxPending = opts.maxPending ?? 128; this.open(); } send(message) { if (this.closed) return; if (this.ws && this.ws.readyState === 1) { this.ws.send(JSON.stringify(message)); return; } this.pending.push(message); if (this.pending.length > this.maxPending) { this.pending.splice(0, this.pending.length - this.maxPending); } } onMessage(handler) { this.handlers.add(handler); return () => { this.handlers.delete(handler); }; } close() { if (this.closed) return; this.closed = true; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = null; this.pending = []; this.handlers.clear(); try { this.ws?.close(); } catch { } this.ws = null; } open() { if (this.closed) return; const ws = new this.WS(this.url); this.ws = ws; ws.addEventListener("open", () => { if (this.closed) return; this.attempts = 0; try { ws.send(JSON.stringify({ v: 1, t: "hello", from: this.id })); } catch { } this.flushPending(); }); ws.addEventListener("message", (event) => { const data = event.data; let message; try { message = JSON.parse(String(data)); } catch { return; } if (!message || typeof message !== "object" || message.v !== 1) return; for (const handler of this.handlers) { try { handler(message); } catch { } } }); ws.addEventListener("close", () => { if (this.ws === ws) this.ws = null; this.scheduleReconnect(); }); ws.addEventListener("error", () => { }); } scheduleReconnect() { if (this.closed || !this.reconnect || this.reconnectTimer) return; if (this.reconnect.maxAttempts > 0 && this.attempts >= this.reconnect.maxAttempts) { return; } const delay = Math.min( this.reconnect.baseDelayMs * 2 ** this.attempts, this.reconnect.maxDelayMs ); this.attempts += 1; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.open(); }, delay); this.reconnectTimer?.unref?.(); } flushPending() { if (!this.ws || this.ws.readyState !== 1) return; const batch = this.pending.splice(0); for (const message of batch) { this.ws.send(JSON.stringify(message)); } } }; // src/realtime/presence.ts function createPresenceTransport(opts) { if (opts.transport) return opts.transport; if (opts.relayUrl) { return new DurableObjectRelayTransport({ id: opts.peerId, url: opts.relayUrl, room: opts.room, auth: opts.auth, WebSocketImpl: opts.WebSocketImpl }); } return new BroadcastChannelTransport({ id: opts.peerId, channel: `presence:${opts.room}`, BroadcastChannelImpl: opts.BroadcastChannelImpl }); } function joinPresence(opts) { return RealtimeRoom.join({ transport: createPresenceTransport(opts), initialPresence: opts.initialPresence, heartbeatMs: opts.heartbeatMs, timeoutMs: opts.timeoutMs, now: opts.now }); } // src/realtime/relay-persistence.ts var DEFAULT_MAX_CHAT = 200; var DEFAULT_MAX_TEXT_OPS = 2e3; function chatKey(msg) { return msg.id ?? `${msg.from}:${msg.ts}`; } function tieKey(msg) { if (msg.t === "msg") return chatKey(msg); if (msg.t === "presence" || msg.t === "bye") return msg.from; return ""; } function messageTs(msg) { if (msg.t === "presence" || msg.t === "bye" || msg.t === "msg") { return msg.ts; } return 0; } var RelayPersistence = class { maxChat; maxTextOps; presence = /* @__PURE__ */ new Map(); /** Chat is a grow-only set keyed by {@link chatKey} — dedup on record. */ chatLog = []; chatKeys = /* @__PURE__ */ new Set(); textSnapshot = null; textOps = []; constructor(opts = {}) { this.maxChat = opts.maxChat ?? DEFAULT_MAX_CHAT; this.maxTextOps = opts.maxTextOps ?? DEFAULT_MAX_TEXT_OPS; } /** Record an inbound client message (skip `hello` / `replay`). */ record(message) { if (message.v !== 1) return; switch (message.t) { case "hello": return; case "replay": return; case "presence": this.presence.set(message.from, message); return; case "bye": this.presence.delete(message.from); return; case "msg": this.recordBroadcast(message); return; } } /** Ordered messages to replay to a peer that just connected. */ buildReplay() { const out = [ ...this.presence.values(), ...this.chatLog ]; if (this.textSnapshot) { out.push(this.textSnapshot); } else { out.push(...this.textOps); } out.sort((a, b) => { const dt = messageTs(a) - messageTs(b); if (dt !== 0) return dt; return tieKey(a).localeCompare(tieKey(b)); }); return out; } getPresenceCount() { return this.presence.size; } getChatCount() { return this.chatLog.length; } hasTextSnapshot() { return this.textSnapshot !== null; } recordBroadcast(message) { if (message.channel === "chat" && message.event === "message") { const key = chatKey(message); if (this.chatKeys.has(key)) return; this.chatKeys.add(key); this.chatLog.push(message); if (this.chatLog.length > this.maxChat) { const evicted = this.chatLog.splice(0, this.chatLog.length - this.maxChat); for (const m of evicted) this.chatKeys.delete(chatKey(m)); } return; } if (message.channel === "text" && message.event === "state") { this.textSnapshot = message; this.textOps = []; return; } if (message.channel === "text" && message.event === "op") { if (this.textSnapshot) return; this.textOps.push(message); if (this.textOps.length > this.maxTextOps) { this.textOps.splice(0, this.textOps.length - this.maxTextOps); } } } }; // src/realtime/persistent-channel.ts var DEFAULT_MAX_RECORDS = 200; var PersistentChannel = class _PersistentChannel { /** Reactive, deduped, `(ts, id)`-sorted message list. */ messages = new Signal([]); room; channel; event; store; max; resolveMeta; now; records = []; byId = /* @__PURE__ */ new Map(); unsubscribe; disposed = false; constructor(room, channel, opts) { this.room = room; this.channel = channel; this.event = opts.event ?? "message"; this.store = opts.store; this.max = opts.max ?? DEFAULT_MAX_RECORDS; this.resolveMeta = opts.resolveMeta; this.now = opts.now ?? (() => Date.now()); this.unsubscribe = this.room.on(this.channel, this.handleEvent); void this.hydrate(); } /** Create a persistent view over `room`'s `channel`. */ static create(room, channel, opts = {}) { return new _PersistentChannel(room, channel, opts); } /** * Optimistically record locally, broadcast to peers, and persist. Returns the * stable message id. The room dedups its own id, so the broadcast never * echoes back into this channel. */ send(payload, meta) { const id = this.room.broadcast(this.channel, this.event, payload); this.commitOne({ id, from: this.room.selfId, ts: this.now(), payload, meta }); return id; } /** Current records (deduped + sorted). Read without subscribing. */ snapshot() { return this.messages.peek(); } /** Stop listening. Does not clear the durable store. */ dispose() { if (this.disposed) return; this.disposed = true; this.unsubscribe(); } // --- internal -------------------------------------------------------------- handleEvent = (event) => { if (event.event !== this.event) return; this.commitOne({ id: event.id ?? `${event.from}:${event.ts}`, from: event.from, ts: event.ts, payload: event.payload, meta: this.resolveMeta?.(event) }); }; async hydrate() { if (!this.store) return; let loaded; try { loaded = await this.store.load(); } catch { return; } if (this.disposed) return; let changed = false; for (const rec of loaded) { if (this.add(rec)) changed = true; } if (changed) this.commit(); } /** Insert one record and publish if it was new. */ commitOne(rec) { if (this.add(rec)) this.commit(); } /** Pure insert with dedup + cap. Returns true when a new record was added. */ add(rec) { if (!rec || !rec.id || this.byId.has(rec.id)) return false; this.byId.set(rec.id, rec); this.records.push(rec); this.records.sort((a, b) => a.ts - b.ts || a.id.localeCompare(b.id)); if (this.records.length > this.max) { const evicted = this.records.splice(0, this.records.length - this.max); for (const m of evicted) this.byId.delete(m.id); } return true; } commit() { this.messages.value = [...this.records]; void this.store?.save(this.records); } }; function localStorageChannelStore(key, storage = globalThis.localStorage) { return { load() { try { const raw = storage?.getItem(key); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch { return []; } }, save(records) { try { storage?.setItem(key, JSON.stringify(records)); } catch { } } }; } // src/realtime/text.ts function parseId(id) { const at = id.indexOf("@"); return { counter: Number(id.slice(0, at)), peer: id.slice(at + 1) }; } function compareSiblings(a, b) { const pa = parseId(a); const pb = parseId(b); if (pa.counter !== pb.counter) return pb.counter - pa.counter; return pb.peer.localeCompare(pa.peer); } var RealtimeText = class { nodes = /* @__PURE__ */ new Map(); counter = 0; peerId; listeners = /* @__PURE__ */ new Set(); room; channel; unsubscribe; constructor(opts) { this.peerId = opts.peerId; this.room = opts.room; this.channel = opts.channel ?? "text"; if (this.room) { this.unsubscribe = this.room.on(this.channel, (e) => { if (e.from === this.peerId) return; this.onRemote(e.event, e.payload); }); this.room.broadcast(this.channel, "state-req", {}); } } /** Materialized visible text. */ toString() { return this.visibleNodes().map((n) => n.ch).join(""); } /** Current length of visible text. */ get length() { return this.visibleNodes().length; } /** Insert a string at a visible index. Broadcasts ops when room-bound. */ insert(index, str) { if (str.length === 0) return []; const visible = this.visibleNodes(); let after = index <= 0 ? null : visible[index - 1]?.id ?? null; const ops = []; for (const ch of str) { const id = this.nextId(); const node = { id, ch, after, deleted: false }; this.nodes.set(id, node); ops.push({ op: "ins", id, ch, after }); after = id; } this.emit(); this.publish(ops); return ops; } /** Delete `count` visible characters starting at `index`. */ delete(index, count = 1) { if (count <= 0) return []; const visible = this.visibleNodes(); const ops = []; for (let k = 0; k < count; k++) { const target = visible[index + k]; if (!target) break; const node = this.nodes.get(target.id); if (node && !node.deleted) { node.deleted = true; ops.push({ op: "del", id: node.id }); } } if (ops.length > 0) { this.emit(); this.publish(ops); } return ops; } /** Apply a single remote op. Returns true if the document changed. */ applyOp(op) { if (op.op === "ins") { if (this.nodes.has(op.id)) return false; this.bumpCounter(op.id); this.nodes.set(op.id, { id: op.id, ch: op.ch, after: op.after, deleted: false }); return true; } const node = this.nodes.get(op.id); if (!node || node.deleted) return false; node.deleted = true; return true; } /** Apply a batch of remote ops, emitting once. */ applyOps(ops) { let changed = false; for (const op of ops) { if (this.applyOp(op)) changed = true; } if (changed) this.emit(); } /** Snapshot of all nodes (including tombstones) for state sync. */ getNodes() { return [...this.nodes.values()]; } /** Merge a remote snapshot of nodes. */ mergeNodes(nodes) { let changed = false; for (const incoming of nodes) { this.bumpCounter(incoming.id); const existing = this.nodes.get(incoming.id); if (!existing) { this.nodes.set(incoming.id, { ...incoming }); changed = true; } else if (incoming.deleted && !existing.deleted) { existing.deleted = true; changed = true; } } if (changed) this.emit(); } /** Subscribe to text changes. Returns an unsubscribe fn. */ onChange(cb) { this.listeners.add(cb); return () => { this.listeners.delete(cb); }; } /** Detach room bindings. */ dispose() { this.unsubscribe?.(); this.listeners.clear(); } // ------------------------------------------------------------------------- // Internal // ------------------------------------------------------------------------- onRemote(event, payload) { switch (event) { case "op": this.applyOps(payload); break; case "state-req": this.room?.broadcast(this.channel, "state", this.getNodes()); break; case "state": this.mergeNodes(payload); break; } } publish(ops) { this.room?.broadcast(this.channel, "op", ops); } visibleNodes() { const children = /* @__PURE__ */ new Map(); for (const node of this.nodes.values()) { const list = children.get(node.after); if (list) list.push(node); else children.set(node.after, [node]); } for (const list of children.values()) { list.sort((a, b) => compareSiblings(a.id, b.id)); } const result = []; const stack = [...children.get(null) ?? []].reverse(); while (stack.length > 0) { const node = stack.pop(); if (!node.deleted) result.push(node); const kids = children.get(node.id); if (kids) { for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]); } } return result; } nextId() { this.counter += 1; return `${this.counter}@${this.peerId}`; } bumpCounter(id) { const { counter } = parseId(id); if (Number.isFinite(counter) && counter > this.counter) { this.counter = counter; } } emit() { const text = this.toString(); for (const cb of this.listeners) { try { cb(text); } catch { } } } }; // src/realtime/types.ts var REALTIME_PROTOCOL = 1; // src/realtime/blob-client.ts var HASH_RE = /^[a-f0-9]{64}$/; function createBlobClient(opts) { const baseUrl = opts.baseUrl.replace(/\/$/, ""); const doFetch = opts.fetch ?? fetch; const verify = opts.verify ?? false; return { async get(hash) { assertHash(hash); const res = await doFetch(`${baseUrl}/blob/${hash}`); if (res.status === 404) return null; if (!res.ok) { throw new Error(`Blob fetch failed: ${res.status} ${res.statusText}`); } const buf = await res.arrayBuffer(); if (verify) { const actual = await sha256Hex(buf); if (actual !== hash) { throw new Error( `Blob integrity check failed: expected ${hash}, got ${actual}` ); } } return buf; }, async has(hash) { assertHash(hash); const res = await doFetch(`${baseUrl}/blob/${hash}`, { method: "HEAD" }); if (res.status === 404) return false; if (!res.ok) { throw new Error(`Blob HEAD failed: ${res.status} ${res.statusText}`); } return true; }, async put(bytes, meta) { const body = bytes instanceof Uint8Array ? bytes : bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes; const headers = { "Content-Type": meta?.contentType?.trim() || "application/octet-stream" }; if (meta?.name?.trim()) { headers["X-Trellis-Filename"] = meta.name.trim().slice(0, 255); } const res = await doFetch(`${baseUrl}/blob`, { method: "PUT", headers, body }); if (!res.ok) { throw new Error(`Blob upload failed: ${res.status} ${res.statusText}`); } const json = await res.json(); if (!json.hash || !HASH_RE.test(json.hash)) { throw new Error("Blob upload response missing valid hash"); } return json.hash; }, async list() { const res = await doFetch(`${baseUrl}/blob`); if (!res.ok) { throw new Error(`Blob list failed: ${res.status} ${res.statusText}`); } const json = await res.json(); if (!Array.isArray(json.blobs)) { throw new Error("Blob list response missing blobs[]"); } return json.blobs.filter( (b) => typeof b.hash === "string" && HASH_RE.test(b.hash) && typeof b.size === "number" ).map((b) => ({ hash: b.hash, size: b.size, ...typeof b.name === "string" ? { name: b.name } : {}, ...typeof b.contentType === "string" ? { contentType: b.contentType } : {}, ...typeof b.uploadedAt === "number" ? { uploadedAt: b.uploadedAt } : {} })); } }; } function assertHash(hash) { if (!HASH_RE.test(hash)) { throw new Error(`Invalid blob hash: ${hash}`); } } async function sha256Hex(buf) { const digest = await crypto.subtle.digest("SHA-256", buf); return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join(""); } export { BroadcastChannelTransport, DEFAULT_MAX_CHAT, DEFAULT_MAX_RECORDS, DEFAULT_MAX_TEXT_OPS, DurableObjectRelayTransport, MemoryHub, MemoryRealtimeTransport, PersistentChannel, REALTIME_PROTOCOL, RealtimeRoom, RealtimeText, RelayPersistence, WebSocketRelayTransport, createBlobClient, createPresenceTransport, joinPresence, localStorageChannelStore };