@tldraw/sync-core
Version:
tldraw infinite canvas SDK (multiplayer sync).
1,185 lines (1,184 loc) • 43.9 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var TLSyncRoom_exports = {};
__export(TLSyncRoom_exports, {
DATA_MESSAGE_DEBOUNCE_INTERVAL: () => DATA_MESSAGE_DEBOUNCE_INTERVAL,
DocumentState: () => DocumentState,
MAX_TOMBSTONES: () => MAX_TOMBSTONES,
TLSyncRoom: () => TLSyncRoom,
TOMBSTONE_PRUNE_BUFFER_SIZE: () => TOMBSTONE_PRUNE_BUFFER_SIZE
});
module.exports = __toCommonJS(TLSyncRoom_exports);
var import_state = require("@tldraw/state");
var import_store = require("@tldraw/store");
var import_tlschema = require("@tldraw/tlschema");
var import_utils = require("@tldraw/utils");
var import_nanoevents = require("nanoevents");
var import_RoomSession = require("./RoomSession");
var import_TLSyncClient = require("./TLSyncClient");
var import_diff = require("./diff");
var import_findMin = require("./findMin");
var import_interval = require("./interval");
var import_protocol = require("./protocol");
const MAX_TOMBSTONES = 3e3;
const TOMBSTONE_PRUNE_BUFFER_SIZE = 300;
const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1e3 / 60;
const timeSince = (time) => Date.now() - time;
class DocumentState {
constructor(state, lastChangedClock, recordType) {
this.state = state;
this.lastChangedClock = lastChangedClock;
this.recordType = recordType;
}
/**
* Create a DocumentState instance without validating the record data.
* Used for performance when validation has already been performed.
*
* @param state - The record data
* @param lastChangedClock - Clock value when this record was last modified
* @param recordType - The record type definition for validation
* @returns A new DocumentState instance
*/
static createWithoutValidating(state, lastChangedClock, recordType) {
return new DocumentState(state, lastChangedClock, recordType);
}
/**
* Create a DocumentState instance with validation of the record data.
*
* @param state - The record data to validate
* @param lastChangedClock - Clock value when this record was last modified
* @param recordType - The record type definition for validation
* @returns Result containing the DocumentState or validation error
*/
static createAndValidate(state, lastChangedClock, recordType) {
try {
recordType.validate(state);
} catch (error) {
return import_utils.Result.err(error);
}
return import_utils.Result.ok(new DocumentState(state, lastChangedClock, recordType));
}
/**
* Replace the current state with new state and calculate the diff.
*
* @param state - The new record state
* @param clock - The new clock value
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
* @returns Result containing the diff and new DocumentState, or null if no changes, or validation error
*/
replaceState(state, clock, legacyAppendMode = false) {
const diff = (0, import_diff.diffRecord)(this.state, state, legacyAppendMode);
if (!diff) return import_utils.Result.ok(null);
try {
this.recordType.validate(state);
} catch (error) {
return import_utils.Result.err(error);
}
return import_utils.Result.ok([diff, new DocumentState(state, clock, this.recordType)]);
}
/**
* Apply a diff to the current state and return the resulting changes.
*
* @param diff - The object diff to apply
* @param clock - The new clock value
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
* @returns Result containing the final diff and new DocumentState, or null if no changes, or validation error
*/
mergeDiff(diff, clock, legacyAppendMode = false) {
const newState = (0, import_diff.applyObjectDiff)(this.state, diff);
return this.replaceState(newState, clock, legacyAppendMode);
}
}
function getDocumentClock(snapshot) {
if (typeof snapshot.documentClock === "number") {
return snapshot.documentClock;
}
let max = 0;
for (const doc of snapshot.documents) {
max = Math.max(max, doc.lastChangedClock);
}
for (const tombstone of Object.values(snapshot.tombstones ?? {})) {
max = Math.max(max, tombstone);
}
return max;
}
class TLSyncRoom {
// A table of connected clients
sessions = /* @__PURE__ */ new Map();
// eslint-disable-next-line local/prefer-class-methods
pruneSessions = () => {
for (const client of this.sessions.values()) {
switch (client.state) {
case import_RoomSession.RoomSessionState.Connected: {
const hasTimedOut = timeSince(client.lastInteractionTime) > import_RoomSession.SESSION_IDLE_TIMEOUT;
if (hasTimedOut || !client.socket.isOpen) {
this.cancelSession(client.sessionId);
}
break;
}
case import_RoomSession.RoomSessionState.AwaitingConnectMessage: {
const hasTimedOut = timeSince(client.sessionStartTime) > import_RoomSession.SESSION_START_WAIT_TIME;
if (hasTimedOut || !client.socket.isOpen) {
this.removeSession(client.sessionId);
}
break;
}
case import_RoomSession.RoomSessionState.AwaitingRemoval: {
const hasTimedOut = timeSince(client.cancellationTime) > import_RoomSession.SESSION_REMOVAL_WAIT_TIME;
if (hasTimedOut) {
this.removeSession(client.sessionId);
}
break;
}
default: {
(0, import_utils.exhaustiveSwitchError)(client);
}
}
}
};
disposables = [(0, import_interval.interval)(this.pruneSessions, 2e3)];
_isClosed = false;
/**
* Close the room and clean up all resources. Disconnects all sessions
* and stops background processes.
*/
close() {
this.disposables.forEach((d) => d());
this.sessions.forEach((session) => {
session.socket.close();
});
this._isClosed = true;
}
/**
* Check if the room has been closed and is no longer accepting connections.
*
* @returns True if the room is closed
*/
isClosed() {
return this._isClosed;
}
events = (0, import_nanoevents.createNanoEvents)();
// Values associated with each uid (must be serializable).
/** @internal */
documents;
tombstones;
// this clock should start higher than the client, to make sure that clients who sync with their
// initial lastServerClock value get the full state
// in this case clients will start with 0, and the server will start with 1
clock;
documentClock;
tombstoneHistoryStartsAtClock;
// map from record id to clock upon deletion
serializedSchema;
documentTypes;
presenceType;
log;
schema;
constructor(opts) {
this.schema = opts.schema;
let snapshot = opts.snapshot;
this.log = opts.log;
this.onDataChange = opts.onDataChange;
this.onPresenceChange = opts.onPresenceChange;
(0, import_utils.assert)(
import_utils.isNativeStructuredClone,
"TLSyncRoom is supposed to run either on Cloudflare Workersor on a 18+ version of Node.js, which both support the native structuredClone API"
);
this.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize()));
this.documentTypes = new Set(
Object.values(this.schema.types).filter((t) => t.scope === "document").map((t) => t.typeName)
);
const presenceTypes = new Set(
Object.values(this.schema.types).filter((t) => t.scope === "presence")
);
if (presenceTypes.size > 1) {
throw new Error(
`TLSyncRoom: exactly zero or one presence type is expected, but found ${presenceTypes.size}`
);
}
this.presenceType = presenceTypes.values().next()?.value ?? null;
if (!snapshot) {
snapshot = {
clock: 0,
documentClock: 0,
documents: [
{
state: import_tlschema.DocumentRecordType.create({ id: import_tlschema.TLDOCUMENT_ID }),
lastChangedClock: 0
},
{
state: import_tlschema.PageRecordType.create({ name: "Page 1", index: "a1" }),
lastChangedClock: 0
}
]
};
}
this.clock = snapshot.clock;
let didIncrementClock = false;
const ensureClockDidIncrement = (_reason) => {
if (!didIncrementClock) {
didIncrementClock = true;
this.clock++;
}
};
this.tombstones = new import_store.AtomMap(
"room tombstones",
(0, import_utils.objectMapEntriesIterable)(snapshot.tombstones ?? {})
);
this.documents = new import_store.AtomMap(
"room documents",
function* () {
for (const doc of snapshot.documents) {
if (this.documentTypes.has(doc.state.typeName)) {
yield [
doc.state.id,
DocumentState.createWithoutValidating(
doc.state,
doc.lastChangedClock,
(0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, doc.state.typeName))
)
];
} else {
ensureClockDidIncrement("doc type was not doc type");
this.tombstones.set(doc.state.id, this.clock);
}
}
}.call(this)
);
this.tombstoneHistoryStartsAtClock = snapshot.tombstoneHistoryStartsAtClock ?? (0, import_findMin.findMin)(this.tombstones.values()) ?? this.clock;
if (this.tombstoneHistoryStartsAtClock === 0) {
this.tombstoneHistoryStartsAtClock++;
}
(0, import_state.transact)(() => {
const schema = snapshot.schema ?? this.schema.serializeEarliestVersion();
const migrationsToApply = this.schema.getMigrationsSince(schema);
(0, import_utils.assert)(migrationsToApply.ok, "Failed to get migrations");
if (migrationsToApply.value.length > 0) {
const store = {};
for (const [k, v] of this.documents.entries()) {
store[k] = v.state;
}
const migrationResult = this.schema.migrateStoreSnapshot(
{ store, schema },
{ mutateInputStore: true }
);
if (migrationResult.type === "error") {
throw new Error("Failed to migrate: " + migrationResult.reason);
}
for (const id in migrationResult.value) {
if (!Object.prototype.hasOwnProperty.call(migrationResult.value, id)) {
continue;
}
const r = migrationResult.value[id];
const existing = this.documents.get(id);
if (!existing || !(0, import_utils.isEqual)(existing.state, r)) {
ensureClockDidIncrement("record was added or updated during migration");
this.documents.set(
r.id,
DocumentState.createWithoutValidating(
r,
this.clock,
(0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, r.typeName))
)
);
}
}
for (const id of this.documents.keys()) {
if (!migrationResult.value[id]) {
ensureClockDidIncrement("record was removed during migration");
this.tombstones.set(id, this.clock);
this.documents.delete(id);
}
}
}
this.pruneTombstones();
});
if (didIncrementClock) {
this.documentClock = this.clock;
opts.onDataChange?.();
} else {
this.documentClock = getDocumentClock(snapshot);
}
}
didSchedulePrune = true;
// eslint-disable-next-line local/prefer-class-methods
pruneTombstones = () => {
this.didSchedulePrune = false;
if (this.tombstones.size > MAX_TOMBSTONES) {
const entries = Array.from(this.tombstones.entries());
entries.sort((a, b) => a[1] - b[1]);
let idx = entries.length - 1 - MAX_TOMBSTONES + TOMBSTONE_PRUNE_BUFFER_SIZE;
const cullClock = entries[idx++][1];
while (idx < entries.length && entries[idx][1] === cullClock) {
idx++;
}
const keysToDelete = entries.slice(0, idx).map(([key]) => key);
this.tombstoneHistoryStartsAtClock = cullClock + 1;
this.tombstones.deleteMany(keysToDelete);
}
};
getDocument(id) {
return this.documents.get(id);
}
addDocument(id, state, clock) {
if (this.tombstones.has(id)) {
this.tombstones.delete(id);
}
const createResult = DocumentState.createAndValidate(
state,
clock,
(0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, state.typeName))
);
if (!createResult.ok) return createResult;
this.documents.set(id, createResult.value);
return import_utils.Result.ok(void 0);
}
removeDocument(id, clock) {
this.documents.delete(id);
this.tombstones.set(id, clock);
if (!this.didSchedulePrune) {
this.didSchedulePrune = true;
setTimeout(this.pruneTombstones, 0);
}
}
/**
* Get a complete snapshot of the current room state that can be persisted
* and later used to restore the room.
*
* @returns Room snapshot containing all documents, tombstones, and metadata
* @example
* ```ts
* const snapshot = room.getSnapshot()
* await database.saveRoomSnapshot(roomId, snapshot)
*
* // Later, restore from snapshot
* const restoredRoom = new TLSyncRoom({
* schema: mySchema,
* snapshot: snapshot
* })
* ```
*/
getSnapshot() {
const tombstones = Object.fromEntries(this.tombstones.entries());
const documents = [];
for (const doc of this.documents.values()) {
if (this.documentTypes.has(doc.state.typeName)) {
documents.push({
state: doc.state,
lastChangedClock: doc.lastChangedClock
});
}
}
return {
clock: this.clock,
documentClock: this.documentClock,
tombstones,
tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock,
schema: this.serializedSchema,
documents
};
}
/**
* Send a message to a particular client. Debounces data events
*
* @param sessionId - The id of the session to send the message to.
* @param message - The message to send.
*/
sendMessage(sessionId, message) {
const session = this.sessions.get(sessionId);
if (!session) {
this.log?.warn?.("Tried to send message to unknown session", message.type);
return;
}
if (session.state !== import_RoomSession.RoomSessionState.Connected) {
this.log?.warn?.("Tried to send message to disconnected client", message.type);
return;
}
if (session.socket.isOpen) {
if (message.type !== "patch" && message.type !== "push_result") {
if (message.type !== "pong") {
this._flushDataMessages(sessionId);
}
session.socket.sendMessage(message);
} else {
if (session.debounceTimer === null) {
session.socket.sendMessage({ type: "data", data: [message] });
session.debounceTimer = setTimeout(
() => this._flushDataMessages(sessionId),
DATA_MESSAGE_DEBOUNCE_INTERVAL
);
} else {
session.outstandingDataMessages.push(message);
}
}
} else {
this.cancelSession(session.sessionId);
}
}
// needs to accept sessionId and not a session because the session might be dead by the time
// the timer fires
_flushDataMessages(sessionId) {
const session = this.sessions.get(sessionId);
if (!session || session.state !== import_RoomSession.RoomSessionState.Connected) {
return;
}
session.debounceTimer = null;
if (session.outstandingDataMessages.length > 0) {
session.socket.sendMessage({ type: "data", data: session.outstandingDataMessages });
session.outstandingDataMessages.length = 0;
}
}
/** @internal */
removeSession(sessionId, fatalReason) {
const session = this.sessions.get(sessionId);
if (!session) {
this.log?.warn?.("Tried to remove unknown session");
return;
}
this.sessions.delete(sessionId);
const presence = this.getDocument(session.presenceId ?? "");
try {
if (fatalReason) {
session.socket.close(import_TLSyncClient.TLSyncErrorCloseEventCode, fatalReason);
} else {
session.socket.close();
}
} catch {
}
if (presence) {
this.documents.delete(session.presenceId);
this.broadcastPatch({
diff: { [session.presenceId]: [import_diff.RecordOpType.Remove] },
sourceSessionId: sessionId
});
}
this.events.emit("session_removed", { sessionId, meta: session.meta });
if (this.sessions.size === 0) {
this.events.emit("room_became_empty");
}
}
cancelSession(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
if (session.state === import_RoomSession.RoomSessionState.AwaitingRemoval) {
this.log?.warn?.("Tried to cancel session that is already awaiting removal");
return;
}
this.sessions.set(sessionId, {
state: import_RoomSession.RoomSessionState.AwaitingRemoval,
sessionId,
presenceId: session.presenceId,
socket: session.socket,
cancellationTime: Date.now(),
meta: session.meta,
isReadonly: session.isReadonly,
requiresLegacyRejection: session.requiresLegacyRejection,
supportsStringAppend: session.supportsStringAppend
});
try {
session.socket.close();
} catch {
}
}
/**
* Broadcast a patch to all connected clients except the one with the sessionId provided.
* Automatically handles schema migration for clients on different versions.
*
* @param message - The broadcast message
* - diff - The network diff to broadcast to all clients
* - sourceSessionId - Optional ID of the session that originated this change (excluded from broadcast)
* @returns This room instance for method chaining
* @example
* ```ts
* room.broadcastPatch({
* diff: { 'shape:123': [RecordOpType.Put, newShapeData] },
* sourceSessionId: 'user-456' // This user won't receive the broadcast
* })
* ```
*/
broadcastPatch(message) {
const { diff, sourceSessionId } = message;
this.sessions.forEach((session) => {
if (session.state !== import_RoomSession.RoomSessionState.Connected) return;
if (sourceSessionId === session.sessionId) return;
if (!session.socket.isOpen) {
this.cancelSession(session.sessionId);
return;
}
const res = this.migrateDiffForSession(session.serializedSchema, diff);
if (!res.ok) {
this.rejectSession(
session.sessionId,
res.error === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
);
return;
}
this.sendMessage(session.sessionId, {
type: "patch",
diff: res.value,
serverClock: this.clock
});
});
return this;
}
/**
* Send a custom message to a connected client. Useful for application-specific
* communication that doesn't involve document synchronization.
*
* @param sessionId - The ID of the session to send the message to
* @param data - The custom payload to send (will be JSON serialized)
* @example
* ```ts
* // Send a custom notification
* room.sendCustomMessage('user-123', {
* type: 'notification',
* message: 'Document saved successfully'
* })
*
* // Send user-specific data
* room.sendCustomMessage('user-456', {
* type: 'user_permissions',
* canEdit: true,
* canDelete: false
* })
* ```
*/
sendCustomMessage(sessionId, data) {
this.sendMessage(sessionId, { type: "custom", data });
}
/**
* Register a new client session with the room. The session will be in an awaiting
* state until it sends a connect message with protocol handshake.
*
* @param opts - Session configuration
* - sessionId - Unique identifier for this session
* - socket - WebSocket adapter for communication
* - meta - Application-specific metadata for this session
* - isReadonly - Whether this session can modify documents
* @returns This room instance for method chaining
* @example
* ```ts
* room.handleNewSession({
* sessionId: crypto.randomUUID(),
* socket: new WebSocketAdapter(ws),
* meta: { userId: '123', name: 'Alice', avatar: 'url' },
* isReadonly: !hasEditPermission
* })
* ```
*
* @internal
*/
handleNewSession(opts) {
const { sessionId, socket, meta, isReadonly } = opts;
const existing = this.sessions.get(sessionId);
this.sessions.set(sessionId, {
state: import_RoomSession.RoomSessionState.AwaitingConnectMessage,
sessionId,
socket,
presenceId: existing?.presenceId ?? this.presenceType?.createId() ?? null,
sessionStartTime: Date.now(),
meta,
isReadonly: isReadonly ?? false,
// this gets set later during handleConnectMessage
requiresLegacyRejection: false,
supportsStringAppend: true
});
return this;
}
/**
* Checks if all connected sessions support string append operations (protocol version 8+).
* If any client is on an older version, returns false to enable legacy append mode.
*
* @returns True if all connected sessions are on protocol version 8 or higher
*/
getCanEmitStringAppend() {
for (const session of this.sessions.values()) {
if (session.state === import_RoomSession.RoomSessionState.Connected) {
if (!session.supportsStringAppend) {
return false;
}
}
}
return true;
}
/**
* When we send a diff to a client, if that client is on a lower version than us, we need to make
* the diff compatible with their version. At the moment this means migrating each affected record
* to the client's version and sending the whole record again. We can optimize this later by
* keeping the previous versions of records around long enough to recalculate these diffs for
* older client versions.
*/
migrateDiffForSession(serializedSchema, diff) {
if (serializedSchema === this.serializedSchema) {
return import_utils.Result.ok(diff);
}
const result = {};
for (const [id, op] of (0, import_utils.objectMapEntriesIterable)(diff)) {
if (op[0] === import_diff.RecordOpType.Remove) {
result[id] = op;
continue;
}
const doc = this.getDocument(id);
if (!doc) {
return import_utils.Result.err(import_store.MigrationFailureReason.TargetVersionTooNew);
}
const migrationResult = this.schema.migratePersistedRecord(
doc.state,
serializedSchema,
"down"
);
if (migrationResult.type === "error") {
return import_utils.Result.err(migrationResult.reason);
}
result[id] = [import_diff.RecordOpType.Put, migrationResult.value];
}
return import_utils.Result.ok(result);
}
/**
* Process an incoming message from a client session. Handles connection requests,
* data synchronization pushes, and ping/pong for connection health.
*
* @param sessionId - The ID of the session that sent the message
* @param message - The client message to process
* @example
* ```ts
* // Typically called by WebSocket message handlers
* websocket.onMessage((data) => {
* const message = JSON.parse(data)
* room.handleMessage(sessionId, message)
* })
* ```
*/
async handleMessage(sessionId, message) {
const session = this.sessions.get(sessionId);
if (!session) {
this.log?.warn?.("Received message from unknown session");
return;
}
switch (message.type) {
case "connect": {
return this.handleConnectRequest(session, message);
}
case "push": {
return this.handlePushRequest(session, message);
}
case "ping": {
if (session.state === import_RoomSession.RoomSessionState.Connected) {
session.lastInteractionTime = Date.now();
}
return this.sendMessage(session.sessionId, { type: "pong" });
}
default: {
(0, import_utils.exhaustiveSwitchError)(message);
}
}
}
/**
* Reject and disconnect a session due to incompatibility or other fatal errors.
* Sends appropriate error messages before closing the connection.
*
* @param sessionId - The session to reject
* @param fatalReason - The reason for rejection (optional)
* @example
* ```ts
* // Reject due to version mismatch
* room.rejectSession('user-123', TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
*
* // Reject due to permission issue
* room.rejectSession('user-456', 'Insufficient permissions')
* ```
*/
rejectSession(sessionId, fatalReason) {
const session = this.sessions.get(sessionId);
if (!session) return;
if (!fatalReason) {
this.removeSession(sessionId);
return;
}
if (session.requiresLegacyRejection) {
try {
if (session.socket.isOpen) {
let legacyReason;
switch (fatalReason) {
case import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD:
legacyReason = import_protocol.TLIncompatibilityReason.ClientTooOld;
break;
case import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD:
legacyReason = import_protocol.TLIncompatibilityReason.ServerTooOld;
break;
case import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD:
legacyReason = import_protocol.TLIncompatibilityReason.InvalidRecord;
break;
default:
legacyReason = import_protocol.TLIncompatibilityReason.InvalidOperation;
break;
}
session.socket.sendMessage({
type: "incompatibility_error",
reason: legacyReason
});
}
} catch {
} finally {
this.removeSession(sessionId);
}
} else {
this.removeSession(sessionId, fatalReason);
}
}
handleConnectRequest(session, message) {
let theirProtocolVersion = message.protocolVersion;
if (theirProtocolVersion === 5) {
theirProtocolVersion = 6;
}
session.requiresLegacyRejection = theirProtocolVersion === 6;
if (theirProtocolVersion === 6) {
theirProtocolVersion++;
}
if (theirProtocolVersion === 7) {
theirProtocolVersion++;
session.supportsStringAppend = false;
}
if (theirProtocolVersion == null || theirProtocolVersion < (0, import_protocol.getTlsyncProtocolVersion)()) {
this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
return;
} else if (theirProtocolVersion > (0, import_protocol.getTlsyncProtocolVersion)()) {
this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD);
return;
}
if (message.schema == null) {
this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
return;
}
const migrations = this.schema.getMigrationsSince(message.schema);
if (!migrations.ok || migrations.value.some((m) => m.scope === "store" || !m.down)) {
this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
return;
}
const sessionSchema = (0, import_utils.isEqual)(message.schema, this.serializedSchema) ? this.serializedSchema : message.schema;
const connect = async (msg) => {
this.sessions.set(session.sessionId, {
state: import_RoomSession.RoomSessionState.Connected,
sessionId: session.sessionId,
presenceId: session.presenceId,
socket: session.socket,
serializedSchema: sessionSchema,
lastInteractionTime: Date.now(),
debounceTimer: null,
outstandingDataMessages: [],
supportsStringAppend: session.supportsStringAppend,
meta: session.meta,
isReadonly: session.isReadonly,
requiresLegacyRejection: session.requiresLegacyRejection
});
this.sendMessage(session.sessionId, msg);
};
(0, import_state.transaction)((rollback) => {
if (
// if the client requests changes since a time before we have tombstone history, send them the full state
message.lastServerClock < this.tombstoneHistoryStartsAtClock || // similarly, if they ask for a time we haven't reached yet, send them the full state
// this will only happen if the DB is reset (or there is no db) and the server restarts
// or if the server exits/crashes with unpersisted changes
message.lastServerClock > this.clock
) {
const diff = {};
for (const [id, doc] of this.documents.entries()) {
if (id !== session.presenceId) {
diff[id] = [import_diff.RecordOpType.Put, doc.state];
}
}
const migrated = this.migrateDiffForSession(sessionSchema, diff);
if (!migrated.ok) {
rollback();
this.rejectSession(
session.sessionId,
migrated.error === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
);
return;
}
connect({
type: "connect",
connectRequestId: message.connectRequestId,
hydrationType: "wipe_all",
protocolVersion: (0, import_protocol.getTlsyncProtocolVersion)(),
schema: this.schema.serialize(),
serverClock: this.clock,
diff: migrated.value,
isReadonly: session.isReadonly
});
} else {
const diff = {};
for (const doc of this.documents.values()) {
if (doc.lastChangedClock > message.lastServerClock) {
diff[doc.state.id] = [import_diff.RecordOpType.Put, doc.state];
} else if (this.presenceType?.isId(doc.state.id) && doc.state.id !== session.presenceId) {
diff[doc.state.id] = [import_diff.RecordOpType.Put, doc.state];
}
}
for (const [id, deletedAtClock] of this.tombstones.entries()) {
if (deletedAtClock > message.lastServerClock) {
diff[id] = [import_diff.RecordOpType.Remove];
}
}
const migrated = this.migrateDiffForSession(sessionSchema, diff);
if (!migrated.ok) {
rollback();
this.rejectSession(
session.sessionId,
migrated.error === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
);
return;
}
connect({
type: "connect",
connectRequestId: message.connectRequestId,
hydrationType: "wipe_presence",
schema: this.schema.serialize(),
protocolVersion: (0, import_protocol.getTlsyncProtocolVersion)(),
serverClock: this.clock,
diff: migrated.value,
isReadonly: session.isReadonly
});
}
});
}
handlePushRequest(session, message) {
if (session && session.state !== import_RoomSession.RoomSessionState.Connected) {
return;
}
if (session) {
session.lastInteractionTime = Date.now();
}
this.clock++;
const initialDocumentClock = this.documentClock;
let didPresenceChange = false;
(0, import_state.transaction)((rollback) => {
const legacyAppendMode = !this.getCanEmitStringAppend();
const docChanges = { diff: null };
const presenceChanges = { diff: null };
const propagateOp = (changes, id, op) => {
if (!changes.diff) changes.diff = {};
changes.diff[id] = op;
};
const fail = (reason, underlyingError) => {
rollback();
if (session) {
this.rejectSession(session.sessionId, reason);
} else {
throw new Error("failed to apply changes: " + reason, underlyingError);
}
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
this.log?.error?.("failed to apply push", reason, message, underlyingError);
}
return import_utils.Result.err(void 0);
};
const addDocument = (changes, id, _state) => {
const res = session ? this.schema.migratePersistedRecord(_state, session.serializedSchema, "up") : { type: "success", value: _state };
if (res.type === "error") {
return fail(
res.reason === import_store.MigrationFailureReason.TargetVersionTooOld ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
);
}
const { value: state } = res;
const doc = this.getDocument(id);
if (doc) {
const diff = doc.replaceState(state, this.clock, legacyAppendMode);
if (!diff.ok) {
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
}
if (diff.value) {
this.documents.set(id, diff.value[1]);
propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]);
}
} else {
const result = this.addDocument(id, state, this.clock);
if (!result.ok) {
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
}
propagateOp(changes, id, [import_diff.RecordOpType.Put, state]);
}
return import_utils.Result.ok(void 0);
};
const patchDocument = (changes, id, patch) => {
const doc = this.getDocument(id);
if (!doc) return import_utils.Result.ok(void 0);
const downgraded = session ? this.schema.migratePersistedRecord(doc.state, session.serializedSchema, "down") : { type: "success", value: doc.state };
if (downgraded.type === "error") {
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
}
if (downgraded.value === doc.state) {
const diff = doc.mergeDiff(patch, this.clock, legacyAppendMode);
if (!diff.ok) {
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
}
if (diff.value) {
this.documents.set(id, diff.value[1]);
propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]);
}
} else {
const patched = (0, import_diff.applyObjectDiff)(downgraded.value, patch);
const upgraded = session ? this.schema.migratePersistedRecord(patched, session.serializedSchema, "up") : { type: "success", value: patched };
if (upgraded.type === "error") {
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD);
}
const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode);
if (!diff.ok) {
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
}
if (diff.value) {
this.documents.set(id, diff.value[1]);
propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]);
}
}
return import_utils.Result.ok(void 0);
};
const { clientClock } = message;
if (this.presenceType && session?.presenceId && "presence" in message && message.presence) {
if (!session) throw new Error("session is required for presence pushes");
const id = session.presenceId;
const [type, val] = message.presence;
const { typeName } = this.presenceType;
switch (type) {
case import_diff.RecordOpType.Put: {
const res = addDocument(presenceChanges, id, { ...val, id, typeName });
if (!res.ok) return;
break;
}
case import_diff.RecordOpType.Patch: {
const res = patchDocument(presenceChanges, id, {
...val,
id: [import_diff.ValueOpType.Put, id],
typeName: [import_diff.ValueOpType.Put, typeName]
});
if (!res.ok) return;
break;
}
}
}
if (message.diff && !session?.isReadonly) {
for (const [id, op] of (0, import_utils.objectMapEntriesIterable)(message.diff)) {
switch (op[0]) {
case import_diff.RecordOpType.Put: {
if (!this.documentTypes.has(op[1].typeName)) {
return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD);
}
const res = addDocument(docChanges, id, op[1]);
if (!res.ok) return;
break;
}
case import_diff.RecordOpType.Patch: {
const res = patchDocument(docChanges, id, op[1]);
if (!res.ok) return;
break;
}
case import_diff.RecordOpType.Remove: {
const doc = this.getDocument(id);
if (!doc) {
continue;
}
this.removeDocument(id, this.clock);
propagateOp(docChanges, id, op);
break;
}
}
}
}
if (
// if there was only a presence push, the client doesn't need to do anything aside from
// shift the push request.
!message.diff || (0, import_utils.isEqual)(docChanges.diff, message.diff)
) {
if (session) {
this.sendMessage(session.sessionId, {
type: "push_result",
serverClock: this.clock,
clientClock,
action: "commit"
});
}
} else if (!docChanges.diff) {
if (session) {
this.sendMessage(session.sessionId, {
type: "push_result",
serverClock: this.clock,
clientClock,
action: "discard"
});
}
} else {
if (session) {
const migrateResult = this.migrateDiffForSession(
session.serializedSchema,
docChanges.diff
);
if (!migrateResult.ok) {
return fail(
migrateResult.error === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD
);
}
this.sendMessage(session.sessionId, {
type: "push_result",
serverClock: this.clock,
clientClock,
action: { rebaseWithDiff: migrateResult.value }
});
}
}
if (docChanges.diff || presenceChanges.diff) {
this.broadcastPatch({
sourceSessionId: session?.sessionId,
diff: {
...docChanges.diff,
...presenceChanges.diff
}
});
}
if (docChanges.diff) {
this.documentClock = this.clock;
}
if (presenceChanges.diff) {
didPresenceChange = true;
}
return;
});
if (this.documentClock !== initialDocumentClock) {
this.onDataChange?.();
}
if (didPresenceChange) {
this.onPresenceChange?.();
}
}
/**
* Handle the event when a client disconnects. Cleans up the session and
* removes any presence information.
*
* @param sessionId - The session that disconnected
* @example
* ```ts
* websocket.onClose(() => {
* room.handleClose(sessionId)
* })
* ```
*/
handleClose(sessionId) {
this.cancelSession(sessionId);
}
/**
* Apply changes to the room's store in a transactional way. Changes are
* automatically synchronized to all connected clients.
*
* @param updater - Function that receives store methods to make changes
* @returns Promise that resolves when the transaction is complete
* @example
* ```ts
* // Add multiple shapes atomically
* await room.updateStore((store) => {
* store.put(createShape({ type: 'geo', x: 100, y: 100 }))
* store.put(createShape({ type: 'text', x: 200, y: 200 }))
* })
*
* // Async operations are supported
* await room.updateStore(async (store) => {
* const template = await loadTemplate()
* template.shapes.forEach(shape => store.put(shape))
* })
* ```
*/
async updateStore(updater) {
if (this._isClosed) {
throw new Error("Cannot update store on a closed room");
}
const context = new StoreUpdateContext(
Object.fromEntries(this.getSnapshot().documents.map((d) => [d.state.id, d.state]))
);
try {
await updater(context);
} finally {
context.close();
}
const diff = context.toDiff();
if (Object.keys(diff).length === 0) {
return;
}
this.handlePushRequest(null, { type: "push", diff, clientClock: 0 });
}
}
class StoreUpdateContext {
constructor(snapshot) {
this.snapshot = snapshot;
}
updates = {
puts: {},
deletes: /* @__PURE__ */ new Set()
};
put(record) {
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
if (record.id in this.snapshot && (0, import_utils.isEqual)(this.snapshot[record.id], record)) {
delete this.updates.puts[record.id];
} else {
this.updates.puts[record.id] = (0, import_utils.structuredClone)(record);
}
this.updates.deletes.delete(record.id);
}
delete(recordOrId) {
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
const id = typeof recordOrId === "string" ? recordOrId : recordOrId.id;
delete this.updates.puts[id];
if (this.snapshot[id]) {
this.updates.deletes.add(id);
}
}
get(id) {
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
if ((0, import_utils.hasOwnProperty)(this.updates.puts, id)) {
return (0, import_utils.structuredClone)(this.updates.puts[id]);
}
if (this.updates.deletes.has(id)) {
return null;
}
return (0, import_utils.structuredClone)(this.snapshot[id] ?? null);
}
getAll() {
if (this._isClosed) throw new Error("StoreUpdateContext is closed");
const result = Object.values(this.updates.puts);
for (const [id, record] of Object.entries(this.snapshot)) {
if (!this.updates.deletes.has(id) && !(0, import_utils.hasOwnProperty)(this.updates.puts, id)) {
result.push(record);
}
}
return (0, import_utils.structuredClone)(result);
}
toDiff() {
const diff = {};
for (const [id, record] of Object.entries(this.updates.puts)) {
diff[id] = [import_diff.RecordOpType.Put, record];
}
for (const id of this.updates.deletes) {
diff[id] = [import_diff.RecordOpType.Remove];
}
return diff;
}
_isClosed = false;
close() {
this._isClosed = true;
}
}
//# sourceMappingURL=TLSyncRoom.js.map