@smoothglue/sync-whiteboard 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/assets.js CHANGED
@@ -15,7 +15,6 @@ if (!ASSET_STORAGE_URL) {
15
15
  process.exit(1);
16
16
  }
17
17
  logger_1.default.info({ assetStorageUrl: ASSET_STORAGE_URL }, `[ASSETS] Using Asset Storage URL`);
18
- // --- End Configuration ---
19
18
  /**
20
19
  * Stores an asset by proxying a PUT request to the configured asset storage backend.
21
20
  * @param id - The unique identifier for the asset (generated by the client).
@@ -25,7 +24,7 @@ logger_1.default.info({ assetStorageUrl: ASSET_STORAGE_URL }, `[ASSETS] Using As
25
24
  * @returns The asset ID upon successful storage.
26
25
  * @throws Throws an error if the request to the backend fails.
27
26
  */
28
- async function storeAsset(id, fileStream, contentType = "application/octet-stream", originalFilename, authHeader) {
27
+ async function storeAsset(id, fileStream, contentType = "application/octet-stream", originalFilename, credentials) {
29
28
  const url = `${ASSET_STORAGE_URL}/${id}`;
30
29
  logger_1.default.debug({ assetId: id, filename: originalFilename, targetUrl: url }, `[ASSETS] Storing asset`);
31
30
  // Ensure we have a readable stream
@@ -46,8 +45,12 @@ async function storeAsset(id, fileStream, contentType = "application/octet-strea
46
45
  "Content-Disposition": `attachment; filename="${sanitizedFilename}"`, // Use the sanitized filename in the Content-Disposition header to avoid errors
47
46
  };
48
47
  // forward auth headers if included from client
49
- if (authHeader) {
50
- headers.Authorization = authHeader;
48
+ // Prioritize Authorization header, but fall back to Cookie
49
+ if (credentials?.authorization) {
50
+ headers["Authorization"] = credentials.authorization;
51
+ }
52
+ if (credentials?.cookie) {
53
+ headers["Cookie"] = credentials.cookie;
51
54
  }
52
55
  // Make the PUT request to the asset storage backend
53
56
  const response = await fetch(url, {
@@ -104,12 +107,17 @@ async function storeAsset(id, fileStream, contentType = "application/octet-strea
104
107
  * @returns An object containing the asset's data as a Readable stream and its Content-Type.
105
108
  * @throws Throws an error if the request to the backend fails or the asset is not found.
106
109
  */
107
- async function loadAsset(id, authHeader) {
110
+ async function loadAsset(id, credentials) {
108
111
  const url = `${ASSET_STORAGE_URL}/${id}`;
109
112
  logger_1.default.debug({ assetId: id, targetUrl: url }, `[ASSETS] Loading asset`);
110
113
  const headers = {};
111
- if (authHeader) {
112
- headers.Authorization = authHeader;
114
+ // Pass auth headers from client to backend call
115
+ // Prioritize Authorization header, but fall back to Cookie
116
+ if (credentials?.authorization) {
117
+ headers["Authorization"] = credentials.authorization;
118
+ }
119
+ else if (credentials?.cookie) {
120
+ headers["Cookie"] = credentials.cookie;
113
121
  }
114
122
  try {
115
123
  // Make the GET request to the actual asset storage backend
package/dist/rooms.js CHANGED
@@ -4,6 +4,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getOrCreateRoom = getOrCreateRoom;
7
+ /**
8
+ * @file Manages tldraw room instances, including creation, persistence, and lifecycle.
9
+ * This module handles loading snapshots from a backend, saving them periodically,
10
+ * and cleaning up inactive rooms from memory.
11
+ */
7
12
  const sync_core_1 = require("@tldraw/sync-core");
8
13
  const schema_1 = require("./schema");
9
14
  const logger_1 = __importDefault(require("./logger"));
@@ -27,16 +32,17 @@ let createRoomMutex = Promise.resolve(undefined);
27
32
  * @returns The snapshot data, or undefined if the backend returns 404.
28
33
  * @throws Throws an error for non-404 HTTP errors or network issues.
29
34
  */
30
- async function readSnapshotFromBackend(roomId, authHeader) {
35
+ async function readSnapshotFromBackend(roomId, credentials) {
31
36
  const url = `${SNAPSHOT_STORAGE_URL}/${roomId}`;
32
37
  logger_1.default.debug({ roomId, url }, `[ROOMS] Loading snapshot for room ${roomId} from ${url}`);
33
- // Start with the required 'Accept' header
34
- const headers = {
35
- Accept: "application/json",
36
- };
37
- // Conditionally add the Authorization header if it exists
38
- if (authHeader) {
39
- headers["Authorization"] = authHeader;
38
+ const headers = new Headers();
39
+ headers.append("Accept", "application/json");
40
+ // Prioritize Authorization header, but fall back to Cookie
41
+ if (credentials?.authorization) {
42
+ headers.append("Authorization", credentials.authorization);
43
+ }
44
+ else if (credentials?.cookie) {
45
+ headers.append("Cookie", credentials.cookie);
40
46
  }
41
47
  try {
42
48
  const response = await fetch(url, {
@@ -45,6 +51,13 @@ async function readSnapshotFromBackend(roomId, authHeader) {
45
51
  });
46
52
  if (response.ok) {
47
53
  const snapshot = await response.json();
54
+ // Validate the snapshot structure before returning
55
+ if (snapshot &&
56
+ typeof snapshot === "object" &&
57
+ !("documents" in snapshot)) {
58
+ logger_1.default.warn({ roomId, snapshotReceived: snapshot }, `[ROOMS] Invalid snapshot received from backend for room ${roomId}. Missing 'documents' property.`);
59
+ return undefined;
60
+ }
48
61
  logger_1.default.debug({ roomId, snapshotSize: JSON.stringify(snapshot).length }, `[ROOMS] Snapshot loaded successfully for room ${roomId}`);
49
62
  return snapshot;
50
63
  }
@@ -55,15 +68,13 @@ async function readSnapshotFromBackend(roomId, authHeader) {
55
68
  else {
56
69
  // Handle unexpected errors from the backend
57
70
  const errorBody = await response.text();
58
- const err = new Error(// better logging context
59
- `Backend failed to load snapshot for ${roomId}. Status: ${response.status}. Body: ${errorBody}. Headers: ${headers}`);
71
+ const err = new Error(`Backend failed to load snapshot for ${roomId}. Status: ${response.status}. Body: ${errorBody}.`);
60
72
  logger_1.default.error({
61
73
  err,
62
74
  roomId,
63
75
  url,
64
76
  responseStatus: response.status,
65
77
  responseBody: errorBody,
66
- responseHeader: headers,
67
78
  }, `[ROOMS] Error loading snapshot for room ${roomId}`);
68
79
  throw err;
69
80
  }
@@ -78,16 +89,17 @@ async function readSnapshotFromBackend(roomId, authHeader) {
78
89
  * @param roomId - The ID of the room.
79
90
  * @param room - The TLSocketRoom instance containing the state to save.
80
91
  */
81
- async function saveSnapshotToBackend(roomId, room, authHeader) {
92
+ async function saveSnapshotToBackend(roomId, room, credentials) {
82
93
  const url = `${SNAPSHOT_STORAGE_URL}/${roomId}`;
83
94
  const snapshot = room.getCurrentSnapshot();
84
- // Start with the required 'Accept' header
85
- const headers = {
86
- Accept: "application/json",
87
- };
88
- // Conditionally add the Authorization header if it exists
89
- if (authHeader) {
90
- headers["Authorization"] = authHeader;
95
+ const headers = new Headers();
96
+ headers.append("Content-Type", "application/json");
97
+ // Prioritize Authorization header, but fall back to Cookie
98
+ if (credentials?.authorization) {
99
+ headers.append("Authorization", credentials.authorization);
100
+ }
101
+ else if (credentials?.cookie) {
102
+ headers.append("Cookie", credentials.cookie);
91
103
  }
92
104
  logger_1.default.debug({ roomId, url, snapshotSize: JSON.stringify(snapshot).length }, `[ROOMS] Saving snapshot for room ${roomId} to ${url}`);
93
105
  try {
@@ -103,10 +115,7 @@ async function saveSnapshotToBackend(roomId, room, authHeader) {
103
115
  url,
104
116
  responseStatus: response.status,
105
117
  responseBody: errorBody,
106
- responseHeaders: headers,
107
- }, // No err: new Error() here, just context
108
- `[ROOMS] Error saving snapshot for room ${roomId}: ${response.status} ${response.statusText}`);
109
- // Log error but don't throw, to avoid breaking the save interval
118
+ }, `[ROOMS] Error saving snapshot for room ${roomId}: ${response.status} ${response.statusText}`);
110
119
  }
111
120
  else {
112
121
  logger_1.default.debug({ roomId }, `[ROOMS] Snapshot saved successfully for room ${roomId}`);
@@ -114,7 +123,6 @@ async function saveSnapshotToBackend(roomId, room, authHeader) {
114
123
  }
115
124
  catch (error) {
116
125
  logger_1.default.error({ err: error, roomId, url }, `[ROOMS] Network or fetch error saving snapshot for room ${roomId}`);
117
- // Log error but don't throw
118
126
  }
119
127
  }
120
128
  /**
@@ -125,25 +133,21 @@ async function saveSnapshotToBackend(roomId, room, authHeader) {
125
133
  * @returns A promise resolving to the TLSocketRoom instance.
126
134
  * @throws Throws an error if backend interaction fails during creation.
127
135
  */
128
- async function getOrCreateRoom(roomId, authHeader) {
129
- // Chain onto the mutex promise to ensure sequential access to the `rooms` map
136
+ async function getOrCreateRoom(roomId, credentials) {
130
137
  createRoomMutex = createRoomMutex.then(async () => {
131
- // Check if an active room instance already exists
132
138
  if (rooms.has(roomId)) {
133
139
  const existingRoomState = rooms.get(roomId);
134
140
  if (!existingRoomState.room.isClosed()) {
135
141
  logger_1.default.debug({ roomId }, "[ROOMS] Active room instance found in memory.");
136
- return; // Room exists and is active
142
+ return;
137
143
  }
138
144
  else {
139
145
  logger_1.default.info({ roomId }, `[ROOMS] Found closed room ${roomId}, removing before creating new one.`);
140
- rooms.delete(roomId); // Clean up closed room reference
146
+ rooms.delete(roomId);
141
147
  }
142
148
  }
143
149
  logger_1.default.info({ roomId }, `[ROOMS] Creating or recreating room: ${roomId}`);
144
- // Fetch initial state from the backend API (can throw error)
145
- const initialSnapshot = await readSnapshotFromBackend(roomId);
146
- // Define child logger for the tldraw room instance
150
+ const initialSnapshot = await readSnapshotFromBackend(roomId, credentials);
147
151
  const tldrawInstanceLogger = logger_1.default.child({
148
152
  tldrawRoomId: roomId,
149
153
  component: "tldraw-sync-core",
@@ -173,86 +177,88 @@ async function getOrCreateRoom(roomId, authHeader) {
173
177
  }
174
178
  },
175
179
  };
176
- // Create the new room state object
177
180
  const newRoomState = {
178
181
  id: roomId,
179
182
  needsPersist: false,
180
183
  persistPromise: null,
181
- authHeader: authHeader,
182
- room: new sync_core_1.TLSocketRoom({
183
- schema: schema_1.whiteboardSchema, // Our defined tldraw schema
184
- initialSnapshot, // Initial state from backend (or undefined)
185
- log: tldrawLogAdapter, // Logger for internal tldraw messages
186
- /** Callback when a user session is removed (e.g., disconnects/times out) */
187
- onSessionRemoved(roomInstance, args) {
188
- logger_1.default.debug({ roomId, remainingSessions: args.numSessionsRemaining }, `[ROOMS] Session removed from room ${roomId}. Remaining: ${args.numSessionsRemaining}`);
189
- // If last user leaves, trigger a final save and close the room
190
- if (args.numSessionsRemaining === 0) {
191
- logger_1.default.info({ roomId }, `[ROOMS] Last user left room ${roomId}. Triggering final save.`);
192
- // Ensure any pending periodic save completes first
193
- const savePromise = newRoomState.persistPromise ?? Promise.resolve();
194
- savePromise.finally(() => {
195
- logger_1.default.info({ roomId }, `[ROOMS] Performing final save for room ${roomId}...`);
196
- saveSnapshotToBackend(roomId, roomInstance).finally(() => {
197
- logger_1.default.info({ roomId }, `[ROOMS] Closing room ${roomId} after final save.`);
198
- roomInstance.close(); // Mark the tldraw room as closed
199
- });
200
- });
201
- }
202
- },
203
- /** Callback when data within the room changes */
204
- onDataChange() {
205
- // Flag that the room needs to be saved on the next interval
206
- newRoomState.needsPersist = true;
207
- },
208
- }),
184
+ credentials,
185
+ room: null,
209
186
  };
210
- // Store the new room state in our map
187
+ const roomInstance = new sync_core_1.TLSocketRoom({
188
+ schema: schema_1.whiteboardSchema,
189
+ initialSnapshot,
190
+ log: tldrawLogAdapter,
191
+ onSessionRemoved(roomInstance, args) {
192
+ logger_1.default.debug({ roomId, remainingSessions: args.numSessionsRemaining }, `[ROOMS] Session removed. Remaining: ${args.numSessionsRemaining}`);
193
+ if (args.numSessionsRemaining === 0) {
194
+ logger_1.default.info({ roomId }, `[ROOMS] Last user left. Triggering final save.`);
195
+ const savePromise = newRoomState.persistPromise ?? Promise.resolve();
196
+ savePromise.finally(() => {
197
+ logger_1.default.info({ roomId }, `[ROOMS] Performing final save...`);
198
+ saveSnapshotToBackend(roomId, roomInstance, newRoomState.credentials).finally(() => {
199
+ logger_1.default.info({ roomId }, `[ROOMS] Closing room after final save.`);
200
+ roomInstance.close();
201
+ });
202
+ });
203
+ }
204
+ },
205
+ onDataChange() {
206
+ newRoomState.needsPersist = true;
207
+ },
208
+ });
209
+ newRoomState.room = roomInstance;
211
210
  rooms.set(roomId, newRoomState);
212
- logger_1.default.info({ roomId }, `[ROOMS] Room ${roomId} created successfully.`);
211
+ logger_1.default.info({ roomId }, `[ROOMS] Room created successfully.`);
212
+ if (rooms.size > 0) {
213
+ startPersistenceInterval();
214
+ }
213
215
  });
214
- // Wait for the mutex-protected operation (lookup/creation) to complete
215
216
  await createRoomMutex;
216
- // Retrieve the room state (should always exist after the mutex)
217
217
  const roomState = rooms.get(roomId);
218
218
  if (!roomState || roomState.room.isClosed()) {
219
- // Defensive check in case something went wrong
220
- logger_1.default.error({ roomId }, `[ROOMS] Failed to get or create a valid room instance for ${roomId} after mutex.`);
219
+ logger_1.default.error({ roomId }, `[ROOMS] Failed to get or create a valid room instance after mutex.`);
221
220
  throw new Error(`Failed to retrieve valid room instance for ${roomId}`);
222
221
  }
223
- // Return the tldraw room object
224
222
  return roomState.room;
225
223
  }
226
- // --- Periodic Persistence ---
227
- // Saves snapshots for rooms marked as `needsPersist` at regular intervals.
228
- setInterval(() => {
229
- logger_1.default.debug("[ROOMS] Periodic persistence check initiated.");
230
- let updatedRoomCount = 0;
231
- for (const roomState of rooms.values()) {
232
- // Clean up closed rooms from memory
233
- if (roomState.room.isClosed()) {
234
- logger_1.default.info({ roomId: roomState.id }, `[ROOMS] Removing closed room ${roomState.id} during periodic check.`);
235
- rooms.delete(roomState.id);
236
- continue;
224
+ // --- Smart Periodic Persistence ---
225
+ let persistenceInterval = null;
226
+ function stopPersistenceInterval() {
227
+ if (persistenceInterval) {
228
+ logger_1.default.info("[ROOMS] No active rooms. Stopping periodic persistence.");
229
+ clearInterval(persistenceInterval);
230
+ persistenceInterval = null;
231
+ }
232
+ }
233
+ function startPersistenceInterval() {
234
+ if (persistenceInterval)
235
+ return;
236
+ logger_1.default.info("[ROOMS] First active room created. Starting periodic persistence.");
237
+ persistenceInterval = setInterval(() => {
238
+ logger_1.default.debug("[ROOMS] Periodic persistence check initiated.");
239
+ let updatedRoomCount = 0;
240
+ for (const roomState of rooms.values()) {
241
+ if (roomState.room.isClosed()) {
242
+ logger_1.default.info({ roomId: roomState.id }, `[ROOMS] Removing closed room during periodic check.`);
243
+ rooms.delete(roomState.id);
244
+ continue;
245
+ }
246
+ if (roomState.needsPersist && !roomState.persistPromise) {
247
+ roomState.needsPersist = false;
248
+ updatedRoomCount++;
249
+ roomState.persistPromise = saveSnapshotToBackend(roomState.id, roomState.room, roomState.credentials)
250
+ .catch((error) => {
251
+ logger_1.default.error({ err: error, roomId: roomState.id }, `[ROOMS] Periodic save failed.`);
252
+ })
253
+ .finally(() => {
254
+ roomState.persistPromise = null;
255
+ logger_1.default.debug({ roomId: roomState.id }, "[ROOMS] Persistence promise cleared.");
256
+ });
257
+ }
237
258
  }
238
- // If room has changes and isn't already saving, start a save operation
239
- if (roomState.needsPersist && !roomState.persistPromise) {
240
- roomState.needsPersist = false; // Reset flag
241
- updatedRoomCount++;
242
- // Track the save operation promise
243
- roomState.persistPromise = saveSnapshotToBackend(roomState.id, roomState.room, roomState.authHeader)
244
- .catch((error) => {
245
- // Log errors from periodic save but don't stop the interval
246
- logger_1.default.error({ err: error, roomId: roomState.id }, // Pass error object
247
- `[ROOMS] Periodic save failed for room ${roomState.id}`);
248
- })
249
- .finally(() => {
250
- // Clear the promise tracker when done
251
- roomState.persistPromise = null;
252
- logger_1.default.debug({ roomId: roomState.id }, "[ROOMS] Persistence promise cleared.");
253
- });
259
+ logger_1.default.debug({ roomsChecked: rooms.size, roomsUpdatedThisInterval: updatedRoomCount }, "[ROOMS] Periodic persistence check completed.");
260
+ if (rooms.size === 0) {
261
+ stopPersistenceInterval();
254
262
  }
255
- }
256
- logger_1.default.debug({ roomsChecked: rooms.size, roomsUpdatedThisInterval: updatedRoomCount }, "[ROOMS] Periodic persistence check completed.");
257
- }, SAVE_INTERVAL_MS);
258
- // --- End Periodic Persistence ---
263
+ }, SAVE_INTERVAL_MS);
264
+ }
package/dist/server.js CHANGED
@@ -48,13 +48,17 @@ app.register(async (svc) => {
48
48
  const sessionId = req.query?.sessionId;
49
49
  // Client provides sessionId via query param, handled by TLSocketRoom
50
50
  // Capture the Authorization header from the incoming request
51
- const authHeader = req.headers.authorization;
52
- if (!authHeader) {
51
+ // Capture both potential auth headers
52
+ const credentials = {
53
+ authorization: req.headers.authorization,
54
+ cookie: req.headers.cookie,
55
+ };
56
+ if (!credentials) {
53
57
  req.log.warn({ roomId }, `[SERVER] Connection attempt without Authorization header.`);
54
58
  }
55
59
  try {
56
60
  // Get or create the room instance (loads/creates state)
57
- const room = await (0, rooms_1.getOrCreateRoom)(roomId, authHeader);
61
+ const room = await (0, rooms_1.getOrCreateRoom)(roomId, credentials);
58
62
  req.log.debug(`[SERVER] Handling WebSocket connection for room ${roomId}`);
59
63
  room.handleSocketConnect({ sessionId, socket });
60
64
  }
@@ -73,7 +77,10 @@ app.register(async (svc) => {
73
77
  svc.put("/assets/:id", async (req, reply) => {
74
78
  const { id } = req.params;
75
79
  const contentType = req.headers["content-type"] || "application/octet-stream";
76
- const authHeader = req.headers.authorization;
80
+ const credentials = {
81
+ authorization: req.headers.authorization,
82
+ cookie: req.headers.cookie,
83
+ };
77
84
  // Extract original filename from custom header
78
85
  const originalFilenameHeaderRaw = req.headers["x-original-filename"];
79
86
  const originalFilenameHeader = Array.isArray(originalFilenameHeaderRaw)
@@ -108,7 +115,7 @@ app.register(async (svc) => {
108
115
  }
109
116
  try {
110
117
  // Call the asset storage logic (which proxies to the backend)
111
- await (0, assets_1.storeAsset)(id, req.raw, contentType, originalFilename, authHeader);
118
+ await (0, assets_1.storeAsset)(id, req.raw, contentType, originalFilename, credentials);
112
119
  req.log.debug({ assetId: id }, `[SERVER] Asset stored successfully.`);
113
120
  reply.code(200).send({ success: true });
114
121
  }
@@ -128,11 +135,14 @@ app.register(async (svc) => {
128
135
  app.get("/assets/:id", async (req, reply) => {
129
136
  const { id } = req.params;
130
137
  req.log.debug({ assetId: id }, `[SERVER] GET /assets/:id`);
131
- // Capture the auth header if included from client
132
- const authHeader = req.headers.authorization;
138
+ // Capture the credentials if included from client
139
+ const credentials = {
140
+ authorization: req.headers.authorization,
141
+ cookie: req.headers.cookie,
142
+ };
133
143
  try {
134
144
  // Call the asset loading logic (which proxies to the backend)
135
- const { stream: dataStream, contentType } = await (0, assets_1.loadAsset)(id, authHeader);
145
+ const { stream: dataStream, contentType } = await (0, assets_1.loadAsset)(id, credentials);
136
146
  req.log.debug({ assetId: id, contentType: contentType }, `[SERVER] Asset loaded. Sending reply...`);
137
147
  // Set the correct Content-Type header and send the stream
138
148
  reply.header("Content-Type", contentType);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothglue/sync-whiteboard",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "main": "dist/server.js",
5
5
  "scripts": {
6
6
  "dev": "ts-node-dev --respawn --transpile-only src/server.ts",
@@ -19,10 +19,10 @@
19
19
  "README.md",
20
20
  "LICENSE"
21
21
  ],
22
- "homepage": "https://gitlab.com/braingu/realtime-sync/-/blob/main/sync-whiteboard/README.md",
22
+ "homepage": "https://code.build.smoothglue.io/braingu/smoothglue/frontend/sync-whiteboard/blob/main/README.md",
23
23
  "repository": {
24
24
  "type": "git",
25
- "url": "https://gitlab.com/braingu/realtime-sync/-/tree/main/sync-whiteboard"
25
+ "url": "https://code.build.smoothglue.io/braingu/smoothglue/frontend/sync-whiteboard.git"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.14.1",