UNPKG

@databutton/liveblocks-client

Version:

**At [Liveblocks](https://liveblocks.io), we’re building tools to help companies create world-class collaborative products that attract, engage and retain users.** This repository is a set of open-source packages for building performant and reliable multi

1,083 lines (1,082 loc) 38 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { getTreesDiffOperations, isSameNodeOrChildOf, remove } from "./utils"; import auth, { parseToken } from "./authentication"; import { ClientMessageType, ServerMessageType, OpType, } from "./live"; import { LiveMap } from "./LiveMap"; import { LiveObject } from "./LiveObject"; import { LiveList } from "./LiveList"; import { AbstractCrdt } from "./AbstractCrdt"; import { LiveRegister } from "./LiveRegister"; const BACKOFF_RETRY_DELAYS = [250, 500, 1000, 2000, 4000, 8000, 10000]; const HEARTBEAT_INTERVAL = 30000; // const WAKE_UP_CHECK_INTERVAL = 2000; const PONG_TIMEOUT = 2000; function isValidRoomEventType(value) { return (value === "my-presence" || value === "others" || value === "event" || value === "error" || value === "connection"); } function makeIdFactory(connectionId) { let count = 0; return () => `${connectionId}:${count++}`; } function makeOthers(presenceMap) { const array = Object.values(presenceMap); return { get count() { return array.length; }, [Symbol.iterator]() { return array[Symbol.iterator](); }, map(callback) { return array.map(callback); }, toArray() { return array; }, }; } function log(...params) { return; console.log(...params, new Date().toString()); } export function makeStateMachine(state, context, mockedEffects) { context.WebSocketPolyfill = context.WebSocketPolyfill ? context.WebSocketPolyfill : window.WebSocket; const effects = mockedEffects || { authenticate() { return __awaiter(this, void 0, void 0, function* () { try { const token = yield auth(context.authEndpoint, context.room, context.publicApiKey); const parsedToken = parseToken(token); const socket = new context.WebSocketPolyfill(`${context.liveblocksServer}/?token=${token}`); socket.addEventListener("message", onMessage); socket.addEventListener("open", onOpen); socket.addEventListener("close", onClose); socket.addEventListener("error", onError); authenticationSuccess(parsedToken, socket); } catch (er) { authenticationFailure(er); } }); }, send(messageOrMessages) { if (state.socket == null) { throw new Error("Can't send message if socket is null"); } state.socket.send(JSON.stringify(messageOrMessages)); }, delayFlush(delay) { return setTimeout(tryFlushing, delay); }, startHeartbeatInterval() { return setInterval(heartbeat, HEARTBEAT_INTERVAL); }, schedulePongTimeout() { return setTimeout(pongTimeout, PONG_TIMEOUT); }, scheduleReconnect(delay) { return setTimeout(connect, delay); }, }; function genericSubscribe(callback) { state.listeners.storage.push(callback); return () => remove(state.listeners.storage, callback); } function crdtSubscribe(crdt, innerCallback, options) { const cb = (updates) => { const relatedUpdates = []; for (const update of updates) { if ((options === null || options === void 0 ? void 0 : options.isDeep) && isSameNodeOrChildOf(update.node, crdt)) { relatedUpdates.push(update); } else if (update.node._id === crdt._id) { innerCallback(update.node); } } if ((options === null || options === void 0 ? void 0 : options.isDeep) && relatedUpdates.length > 0) { innerCallback(relatedUpdates); } }; return genericSubscribe(cb); } function createOrUpdateRootFromMessage(message) { if (message.items.length === 0) { throw new Error("Internal error: cannot load storage without items"); } if (state.root) { updateRoot(message.items); } else { state.root = load(message.items); } for (const key in state.defaultStorageRoot) { if (state.root.get(key) == null) { state.root.set(key, state.defaultStorageRoot[key]); } } } function buildRootAndParentToChildren(items) { const parentToChildren = new Map(); let root = null; for (const tuple of items) { const parentId = tuple[1].parentId; if (parentId == null) { root = tuple; } else { const children = parentToChildren.get(parentId); if (children != null) { children.push(tuple); } else { parentToChildren.set(parentId, [tuple]); } } } if (root == null) { throw new Error("Root can't be null"); } return [root, parentToChildren]; } function updateRoot(items) { if (!state.root) { return; } const currentItems = new Map(); state.items.forEach((liveCrdt, id) => { currentItems.set(id, liveCrdt._toSerializedCrdt()); }); // Get operations that represent the diff between 2 states. const ops = getTreesDiffOperations(currentItems, new Map(items)); const result = apply(ops, false); notify(result.updates); } function load(items) { const [root, parentToChildren] = buildRootAndParentToChildren(items); return LiveObject._deserialize(root, parentToChildren, { addItem, deleteItem, generateId, generateOpId, dispatch: storageDispatch, }); } function addItem(id, item) { state.items.set(id, item); } function deleteItem(id) { state.items.delete(id); } function getItem(id) { return state.items.get(id); } function addToUndoStack(historyItem) { // If undo stack is too large, we remove the older item if (state.undoStack.length >= 50) { state.undoStack.shift(); } if (state.isHistoryPaused) { state.pausedHistory.unshift(...historyItem); } else { state.undoStack.push(historyItem); } } function storageDispatch(ops, reverse, modified) { if (state.isBatching) { state.batch.ops.push(...ops); for (const item of modified) { state.batch.updates.nodes.add(item); } state.batch.reverseOps.push(...reverse); } else { addToUndoStack(reverse); state.redoStack = []; dispatch(ops); notify({ nodes: new Set(modified) }); } } function notify({ nodes = new Set(), presence = false, others = [], }) { if (others.length > 0) { state.others = makeOthers(state.users); for (const event of others) { for (const listener of state.listeners["others"]) { listener(state.others, event); } } } if (presence) { for (const listener of state.listeners["my-presence"]) { listener(state.me); } } if (nodes.size > 0) { for (const subscriber of state.listeners.storage) { subscriber(Array.from(nodes).map((m) => { if (m instanceof LiveObject) { return { type: "LiveObject", node: m, }; } else if (m instanceof LiveList) { return { type: "LiveList", node: m, }; } else { return { type: "LiveMap", node: m, }; } })); } } } function getConnectionId() { if (state.connection.state === "open" || state.connection.state === "connecting") { return state.connection.id; } else if (state.lastConnectionId !== null) { return state.lastConnectionId; } throw new Error("Internal. Tried to get connection id but connection was never open"); } function generateId() { return `${getConnectionId()}:${state.clock++}`; } function generateOpId() { return `${getConnectionId()}:${state.opClock++}`; } function apply(item, isLocal) { const result = { reverse: [], updates: { nodes: new Set(), presence: false }, }; for (const op of item) { if (op.type === "presence") { const reverse = { type: "presence", data: {}, }; for (const key in op.data) { reverse.data[key] = state.me[key]; } state.me = Object.assign(Object.assign({}, state.me), op.data); if (state.buffer.presence == null) { state.buffer.presence = op.data; } else { for (const key in op.data) { state.buffer.presence[key] = op.data; } } result.reverse.unshift(reverse); result.updates.presence = true; } else { // Ops applied after undo/redo don't have an opId. if (isLocal && !op.opId) { op.opId = generateOpId(); } const applyOpResult = applyOp(op, isLocal); if (applyOpResult.modified) { result.updates.nodes.add(applyOpResult.modified); result.reverse.unshift(...applyOpResult.reverse); } } } return result; } function applyOp(op, isLocal) { if (op.opId) { state.offlineOperations.delete(op.opId); } switch (op.type) { case OpType.DeleteObjectKey: case OpType.UpdateObject: case OpType.DeleteCrdt: { const item = state.items.get(op.id); if (item == null) { return { modified: false }; } return item._apply(op, isLocal); } case OpType.SetParentKey: { const item = state.items.get(op.id); if (item == null) { return { modified: false }; } if (item._parent instanceof LiveList) { const previousKey = item._parentKey; item._parent._setChildKey(op.parentKey, item); return { reverse: [ { type: OpType.SetParentKey, id: item._id, parentKey: previousKey, }, ], modified: item._parent, }; } return { modified: false }; } case OpType.CreateObject: { const parent = state.items.get(op.parentId); if (parent == null || getItem(op.id) != null) { return { modified: false }; } return parent._attachChild(op.id, op.parentKey, new LiveObject(op.data), isLocal); } case OpType.CreateList: { const parent = state.items.get(op.parentId); if (parent == null || getItem(op.id) != null) { return { modified: false }; } return parent._attachChild(op.id, op.parentKey, new LiveList(), isLocal); } case OpType.CreateRegister: { const parent = state.items.get(op.parentId); if (parent == null || getItem(op.id) != null) { return { modified: false }; } return parent._attachChild(op.id, op.parentKey, new LiveRegister(op.data), isLocal); } case OpType.CreateMap: { const parent = state.items.get(op.parentId); if (parent == null || getItem(op.id) != null) { return { modified: false }; } return parent._attachChild(op.id, op.parentKey, new LiveMap(), isLocal); } } return { modified: false }; } function subscribe(firstParam, listener, options) { if (firstParam instanceof AbstractCrdt) { return crdtSubscribe(firstParam, listener, options); } else if (typeof firstParam === "function") { return genericSubscribe(firstParam); } else if (!isValidRoomEventType(firstParam)) { throw new Error(`"${firstParam}" is not a valid event name`); } state.listeners[firstParam].push(listener); return () => { const callbacks = state.listeners[firstParam]; remove(callbacks, listener); }; } function unsubscribe(event, callback) { console.warn(`unsubscribe is depreacted and will be removed in a future version. use the callback returned by subscribe instead. See v0.13 release notes for more information. `); if (!isValidRoomEventType(event)) { throw new Error(`"${event}" is not a valid event name`); } const callbacks = state.listeners[event]; remove(callbacks, callback); } function getConnectionState() { return state.connection.state; } function getSelf() { return state.connection.state === "open" || state.connection.state === "connecting" ? { connectionId: state.connection.id, id: state.connection.userId, info: state.connection.userInfo, presence: getPresence(), } : null; } function connect() { if (state.connection.state !== "closed" && state.connection.state !== "unavailable") { return null; } updateConnection({ state: "authenticating" }); effects.authenticate(); } function updatePresence(overrides, options) { const oldValues = {}; if (state.buffer.presence == null) { state.buffer.presence = {}; } for (const key in overrides) { state.buffer.presence[key] = overrides[key]; oldValues[key] = state.me[key]; } state.me = Object.assign(Object.assign({}, state.me), overrides); if (state.isBatching) { if (options === null || options === void 0 ? void 0 : options.addToHistory) { state.batch.reverseOps.push({ type: "presence", data: oldValues }); } state.batch.updates.presence = true; } else { tryFlushing(); if (options === null || options === void 0 ? void 0 : options.addToHistory) { addToUndoStack([{ type: "presence", data: oldValues }]); } notify({ presence: true }); } } function authenticationSuccess(token, socket) { updateConnection({ state: "connecting", id: token.actor, userInfo: token.info, userId: token.id, }); state.idFactory = makeIdFactory(token.actor); state.socket = socket; } function authenticationFailure(error) { if (process.env.NODE_ENV !== "production") { console.error("Call to authentication endpoint failed", error); } updateConnection({ state: "unavailable" }); state.numberOfRetry++; state.timeoutHandles.reconnect = effects.scheduleReconnect(getRetryDelay()); } function onVisibilityChange(visibilityState) { if (visibilityState === "visible" && state.connection.state === "open") { log("Heartbeat after visibility change"); heartbeat(); } } function onUpdatePresenceMessage(message) { const user = state.users[message.actor]; if (user == null) { state.users[message.actor] = { connectionId: message.actor, presence: message.data, }; } else { state.users[message.actor] = { id: user.id, info: user.info, connectionId: message.actor, presence: Object.assign(Object.assign({}, user.presence), message.data), }; } return { type: "update", updates: message.data, user: state.users[message.actor], }; } function onUserLeftMessage(message) { const userLeftMessage = message; const user = state.users[userLeftMessage.actor]; if (user) { delete state.users[userLeftMessage.actor]; return { type: "leave", user }; } return null; } function onRoomStateMessage(message) { const newUsers = {}; for (const key in message.users) { const connectionId = Number.parseInt(key); const user = message.users[key]; newUsers[connectionId] = { connectionId, info: user.info, id: user.id, }; } state.users = newUsers; return { type: "reset" }; } function onNavigatorOnline() { if (state.connection.state === "unavailable") { log("Try to reconnect after connectivity change"); reconnect(); } } function onEvent(message) { for (const listener of state.listeners.event) { listener({ connectionId: message.actor, event: message.event }); } } function onUserJoinedMessage(message) { state.users[message.actor] = { connectionId: message.actor, info: message.info, id: message.id, }; if (state.me) { // Send current presence to new user // TODO: Consider storing it on the backend state.buffer.messages.push({ type: ClientMessageType.UpdatePresence, data: state.me, targetActor: message.actor, }); tryFlushing(); } return { type: "enter", user: state.users[message.actor] }; } function onMessage(event) { if (event.data === "pong") { clearTimeout(state.timeoutHandles.pongTimeout); return; } const message = JSON.parse(event.data); let subMessages = []; if (Array.isArray(message)) { subMessages = message; } else { subMessages.push(message); } const updates = { nodes: new Set(), others: [], }; for (const subMessage of subMessages) { switch (subMessage.type) { case ServerMessageType.UserJoined: { updates.others.push(onUserJoinedMessage(message)); break; } case ServerMessageType.UpdatePresence: { updates.others.push(onUpdatePresenceMessage(subMessage)); break; } case ServerMessageType.Event: { onEvent(subMessage); break; } case ServerMessageType.UserLeft: { const event = onUserLeftMessage(subMessage); if (event) { updates.others.push(event); } break; } case ServerMessageType.RoomState: { updates.others.push(onRoomStateMessage(subMessage)); break; } case ServerMessageType.InitialStorageState: { createOrUpdateRootFromMessage(subMessage); applyAndSendOfflineOps(); _getInitialStateResolver === null || _getInitialStateResolver === void 0 ? void 0 : _getInitialStateResolver(); break; } case ServerMessageType.UpdateStorage: { const applyResult = apply(subMessage.ops, false); for (const node of applyResult.updates.nodes) { updates.nodes.add(node); } break; } } } notify(updates); } // function onWakeUp() { // // Sometimes, the browser can put the webpage on pause (computer is on sleep mode for example) // // The client will not know that the server has probably close the connection even if the readyState is Open // // One way to detect this kind of pause is to ensure that a setInterval is not taking more than the delay it was configured with // if (state.connection.state === "open") { // log("Try to reconnect after laptop wake up"); // reconnect(); // } // } function onClose(event) { state.socket = null; clearTimeout(state.timeoutHandles.pongTimeout); clearInterval(state.intervalHandles.heartbeat); if (state.timeoutHandles.flush) { clearTimeout(state.timeoutHandles.flush); } clearTimeout(state.timeoutHandles.reconnect); state.users = {}; notify({ others: [{ type: "reset" }] }); if (event.code >= 4000 && event.code <= 4100) { updateConnection({ state: "failed" }); const error = new LiveblocksError(event.reason, event.code); for (const listener of state.listeners.error) { if (process.env.NODE_ENV !== "production") { console.error(`Connection to Liveblocks websocket server closed. Reason: ${error.message} (code: ${error.code})`); } listener(error); } } else if (event.wasClean === false) { state.numberOfRetry++; const delay = getRetryDelay(); if (process.env.NODE_ENV !== "production") { console.warn(`Connection to Liveblocks websocket server closed (code: ${event.code}). Retrying in ${delay}ms.`); } updateConnection({ state: "unavailable" }); state.timeoutHandles.reconnect = effects.scheduleReconnect(delay); } else { updateConnection({ state: "closed" }); } } function updateConnection(connection) { state.connection = connection; for (const listener of state.listeners.connection) { listener(connection.state); } } function getRetryDelay() { return BACKOFF_RETRY_DELAYS[state.numberOfRetry < BACKOFF_RETRY_DELAYS.length ? state.numberOfRetry : BACKOFF_RETRY_DELAYS.length - 1]; } function onError() { } function onOpen() { clearInterval(state.intervalHandles.heartbeat); state.intervalHandles.heartbeat = effects.startHeartbeatInterval(); if (state.connection.state === "connecting") { updateConnection(Object.assign(Object.assign({}, state.connection), { state: "open" })); state.numberOfRetry = 0; state.lastConnectionId = state.connection.id; if (state.root) { state.buffer.messages.push({ type: ClientMessageType.FetchStorage }); } tryFlushing(); } else { // TODO } } function heartbeat() { if (state.socket == null) { // Should never happen, because we clear the pong timeout when the connection is dropped explictly return; } clearTimeout(state.timeoutHandles.pongTimeout); state.timeoutHandles.pongTimeout = effects.schedulePongTimeout(); if (state.socket.readyState === state.socket.OPEN) { state.socket.send("ping"); } } function pongTimeout() { log("Pong timeout. Trying to reconnect."); reconnect(); } function reconnect() { if (state.socket) { state.socket.removeEventListener("open", onOpen); state.socket.removeEventListener("message", onMessage); state.socket.removeEventListener("close", onClose); state.socket.removeEventListener("error", onError); state.socket.close(); state.socket = null; } updateConnection({ state: "unavailable" }); clearTimeout(state.timeoutHandles.pongTimeout); if (state.timeoutHandles.flush) { clearTimeout(state.timeoutHandles.flush); } clearTimeout(state.timeoutHandles.reconnect); clearInterval(state.intervalHandles.heartbeat); connect(); } function applyAndSendOfflineOps() { if (state.offlineOperations.size === 0) { return; } const messages = []; const ops = Array.from(state.offlineOperations.values()); const result = apply(ops, true); messages.push({ type: ClientMessageType.UpdateStorage, ops: ops, }); notify(result.updates); effects.send(messages); } function tryFlushing() { const storageOps = state.buffer.storageOperations; if (storageOps.length > 0) { storageOps.forEach((op) => { state.offlineOperations.set(op.opId, op); }); } if (state.socket == null || state.socket.readyState !== state.socket.OPEN) { state.buffer.storageOperations = []; return; } const now = Date.now(); const elapsedTime = now - state.lastFlushTime; if (elapsedTime > context.throttleDelay) { const messages = flushDataToMessages(state); if (messages.length === 0) { return; } effects.send(messages); state.buffer = { messages: [], storageOperations: [], presence: null, }; state.lastFlushTime = now; } else { if (state.timeoutHandles.flush != null) { clearTimeout(state.timeoutHandles.flush); } state.timeoutHandles.flush = effects.delayFlush(context.throttleDelay - (now - state.lastFlushTime)); } } function flushDataToMessages(state) { const messages = []; if (state.buffer.presence) { messages.push({ type: ClientMessageType.UpdatePresence, data: state.buffer.presence, }); } for (const event of state.buffer.messages) { messages.push(event); } if (state.buffer.storageOperations.length > 0) { messages.push({ type: ClientMessageType.UpdateStorage, ops: state.buffer.storageOperations, }); } return messages; } function disconnect() { if (state.socket) { state.socket.removeEventListener("open", onOpen); state.socket.removeEventListener("message", onMessage); state.socket.removeEventListener("close", onClose); state.socket.removeEventListener("error", onError); state.socket.close(); state.socket = null; } updateConnection({ state: "closed" }); if (state.timeoutHandles.flush) { clearTimeout(state.timeoutHandles.flush); } clearTimeout(state.timeoutHandles.reconnect); clearTimeout(state.timeoutHandles.pongTimeout); clearInterval(state.intervalHandles.heartbeat); state.users = {}; notify({ others: [{ type: "reset" }] }); clearListeners(); } function clearListeners() { for (const key in state.listeners) { state.listeners[key] = []; } } function getPresence() { return state.me; } function getOthers() { return state.others; } function broadcastEvent(event, options = { shouldQueueEventIfNotReady: false, }) { if (state.socket == null && options.shouldQueueEventIfNotReady == false) { return; } state.buffer.messages.push({ type: ClientMessageType.ClientEvent, event, }); tryFlushing(); } function dispatch(ops) { state.buffer.storageOperations.push(...ops); tryFlushing(); } let _getInitialStatePromise = null; let _getInitialStateResolver = null; function getStorage() { return __awaiter(this, void 0, void 0, function* () { if (state.root) { return { root: state.root, }; } if (_getInitialStatePromise == null) { state.buffer.messages.push({ type: ClientMessageType.FetchStorage }); tryFlushing(); _getInitialStatePromise = new Promise((resolve) => (_getInitialStateResolver = resolve)); } yield _getInitialStatePromise; return { root: state.root, }; }); } function undo() { if (state.isBatching) { throw new Error("undo is not allowed during a batch"); } const historyItem = state.undoStack.pop(); if (historyItem == null) { return; } state.isHistoryPaused = false; const result = apply(historyItem, true); notify(result.updates); state.redoStack.push(result.reverse); for (const op of historyItem) { if (op.type !== "presence") { state.buffer.storageOperations.push(op); } } tryFlushing(); } function redo() { if (state.isBatching) { throw new Error("redo is not allowed during a batch"); } const historyItem = state.redoStack.pop(); if (historyItem == null) { return; } state.isHistoryPaused = false; const result = apply(historyItem, true); notify(result.updates); state.undoStack.push(result.reverse); for (const op of historyItem) { if (op.type !== "presence") { state.buffer.storageOperations.push(op); } } tryFlushing(); } function batch(callback) { if (state.isBatching) { throw new Error("batch should not be called during a batch"); } state.isBatching = true; try { callback(); } finally { state.isBatching = false; if (state.batch.reverseOps.length > 0) { addToUndoStack(state.batch.reverseOps); } // Clear the redo stack because batch is always called from a local operation state.redoStack = []; if (state.batch.ops.length > 0) { dispatch(state.batch.ops); } notify(state.batch.updates); state.batch = { ops: [], reverseOps: [], updates: { others: [], nodes: new Set(), presence: false, }, }; tryFlushing(); } } function pauseHistory() { state.pausedHistory = []; state.isHistoryPaused = true; } function resumeHistory() { state.isHistoryPaused = false; if (state.pausedHistory.length > 0) { addToUndoStack(state.pausedHistory); } state.pausedHistory = []; } function simulateSocketClose() { if (state.socket) { state.socket.close(); } } function simulateSendCloseEvent(event) { if (state.socket) { onClose(event); } } return { // Internal onOpen, onClose, onMessage, authenticationSuccess, heartbeat, onNavigatorOnline, // Internal dev tools simulateSocketClose, simulateSendCloseEvent, // onWakeUp, onVisibilityChange, getUndoStack: () => state.undoStack, getItemsCount: () => state.items.size, // Core connect, disconnect, subscribe, unsubscribe, // Presence updatePresence, broadcastEvent, batch, undo, redo, pauseHistory, resumeHistory, getStorage, selectors: { // Core getConnectionState, getSelf, // Presence getPresence, getOthers, }, }; } export function defaultState(me, defaultStorageRoot) { return { connection: { state: "closed" }, lastConnectionId: null, socket: null, listeners: { event: [], others: [], "my-presence": [], error: [], connection: [], storage: [], }, numberOfRetry: 0, lastFlushTime: 0, timeoutHandles: { flush: null, reconnect: 0, pongTimeout: 0, }, buffer: { presence: me == null ? {} : me, messages: [], storageOperations: [], }, intervalHandles: { heartbeat: 0, }, me: me == null ? {} : me, users: {}, others: makeOthers({}), defaultStorageRoot, idFactory: null, // Storage clock: 0, opClock: 0, items: new Map(), root: undefined, undoStack: [], redoStack: [], isHistoryPaused: false, pausedHistory: [], isBatching: false, batch: { ops: [], updates: { nodes: new Set(), presence: false, others: [] }, reverseOps: [], }, offlineOperations: new Map(), }; } export function createRoom(name, options) { const throttleDelay = options.throttle || 100; const liveblocksServer = options.liveblocksServer || "wss://liveblocks.net/v5"; let authEndpoint; if (options.authEndpoint) { authEndpoint = options.authEndpoint; } else { const publicAuthorizeEndpoint = options.publicAuthorizeEndpoint || "https://liveblocks.io/api/public/authorize"; authEndpoint = publicAuthorizeEndpoint; } const state = defaultState(options.defaultPresence, options.defaultStorageRoot); const machine = makeStateMachine(state, { throttleDelay, liveblocksServer, authEndpoint, room: name, publicApiKey: options.publicApiKey, WebSocketPolyfill: options.WebSocketPolyfill }); const room = { ///////////// // Core // ///////////// getConnectionState: machine.selectors.getConnectionState, getSelf: machine.selectors.getSelf, subscribe: machine.subscribe, unsubscribe: machine.unsubscribe, ////////////// // Presence // ////////////// getPresence: machine.selectors.getPresence, updatePresence: machine.updatePresence, getOthers: machine.selectors.getOthers, broadcastEvent: machine.broadcastEvent, getStorage: machine.getStorage, batch: machine.batch, history: { undo: machine.undo, redo: machine.redo, pause: machine.pauseHistory, resume: machine.resumeHistory, }, // @ts-ignore internalDevTools: { closeWebsocket: machine.simulateSocketClose, sendCloseEvent: machine.simulateSendCloseEvent, }, }; return { connect: machine.connect, disconnect: machine.disconnect, onNavigatorOnline: machine.onNavigatorOnline, onVisibilityChange: machine.onVisibilityChange, room, }; } class LiveblocksError extends Error { constructor(message, code) { super(message); this.code = code; } }