@smoothglue/sync-whiteboard 1.0.1 → 1.0.2

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
@@ -25,7 +25,7 @@ logger_1.default.info({ assetStorageUrl: ASSET_STORAGE_URL }, `[ASSETS] Using As
25
25
  * @returns The asset ID upon successful storage.
26
26
  * @throws Throws an error if the request to the backend fails.
27
27
  */
28
- async function storeAsset(id, fileStream, contentType = "application/octet-stream", originalFilename) {
28
+ async function storeAsset(id, fileStream, contentType = "application/octet-stream", originalFilename, authHeader) {
29
29
  const url = `${ASSET_STORAGE_URL}/${id}`;
30
30
  logger_1.default.debug({ assetId: id, filename: originalFilename, targetUrl: url }, `[ASSETS] Storing asset`);
31
31
  // Ensure we have a readable stream
@@ -37,19 +37,22 @@ async function storeAsset(id, fileStream, contentType = "application/octet-strea
37
37
  try {
38
38
  // Convert Node.js stream to Web Standard stream for fetch body
39
39
  webStream = stream_1.Readable.toWeb(fileStream);
40
- // --- ADDED: Sanitize the filename for use in the Content-Disposition header ---
40
+ // Sanitize the filename for use in the Content-Disposition header
41
41
  // This removes non-ASCII characters that can crash the fetch call.
42
42
  const sanitizedFilename = originalFilename.replace(/[^\x00-\x7F]/g, "");
43
- // Make the PUT request to the actual asset storage backend
43
+ const headers = {
44
+ "Content-Type": contentType,
45
+ "X-Original-Filename": encodeURIComponent(originalFilename), // Pass the original filename (properly encoded) for the backend to use
46
+ "Content-Disposition": `attachment; filename="${sanitizedFilename}"`, // Use the sanitized filename in the Content-Disposition header to avoid errors
47
+ };
48
+ // forward auth headers if included from client
49
+ if (authHeader) {
50
+ headers.Authorization = authHeader;
51
+ }
52
+ // Make the PUT request to the asset storage backend
44
53
  const response = await fetch(url, {
45
54
  method: "PUT",
46
- headers: {
47
- "Content-Type": contentType,
48
- // Pass the original filename (properly encoded) for the backend to use
49
- "X-Original-Filename": encodeURIComponent(originalFilename),
50
- // Use the sanitized filename in the Content-Disposition header to avoid errors
51
- "Content-Disposition": `attachment; filename="${sanitizedFilename}"`,
52
- },
55
+ headers: headers,
53
56
  body: webStream, // Cast is necessary due to type mismatches
54
57
  // @ts-ignore - duplex: 'half' is required for streaming request bodies with Node fetch
55
58
  duplex: "half",
@@ -58,7 +61,7 @@ async function storeAsset(id, fileStream, contentType = "application/octet-strea
58
61
  // Handle backend errors
59
62
  if (!response.ok) {
60
63
  const errorBody = await response.text();
61
- const err = new Error(`Backend failed to store asset ${id}. Status: ${response.status}. Body: ${errorBody}`);
64
+ const err = new Error(`Backend failed to store asset ${id}. Status: ${response.status}. Body: ${errorBody}. Headers ${headers}`);
62
65
  logger_1.default.error({
63
66
  err,
64
67
  assetId: id,
@@ -68,9 +71,7 @@ async function storeAsset(id, fileStream, contentType = "application/octet-strea
68
71
  }, `[ASSETS] Error response from backend storing asset`);
69
72
  // Ensure streams are closed on error
70
73
  if (webStream) {
71
- await webStream
72
- .cancel()
73
- .catch((cancelErr) => logger_1.default.error({
74
+ await webStream.cancel().catch((cancelErr) => logger_1.default.error({
74
75
  err: cancelErr,
75
76
  assetId: id,
76
77
  stage: "cancel_upload_after_failed_fetch",
@@ -88,9 +89,7 @@ async function storeAsset(id, fileStream, contentType = "application/octet-strea
88
89
  fileStream.destroy(error instanceof Error ? error : new Error(String(error)));
89
90
  }
90
91
  if (webStream) {
91
- await webStream
92
- .cancel()
93
- .catch((cancelErr) => logger_1.default.error({
92
+ await webStream.cancel().catch((cancelErr) => logger_1.default.error({
94
93
  err: cancelErr,
95
94
  assetId: id,
96
95
  stage: "cancel_upload_during_error_handling",
@@ -105,13 +104,18 @@ async function storeAsset(id, fileStream, contentType = "application/octet-strea
105
104
  * @returns An object containing the asset's data as a Readable stream and its Content-Type.
106
105
  * @throws Throws an error if the request to the backend fails or the asset is not found.
107
106
  */
108
- async function loadAsset(id) {
107
+ async function loadAsset(id, authHeader) {
109
108
  const url = `${ASSET_STORAGE_URL}/${id}`;
110
109
  logger_1.default.debug({ assetId: id, targetUrl: url }, `[ASSETS] Loading asset`);
110
+ const headers = {};
111
+ if (authHeader) {
112
+ headers.Authorization = authHeader;
113
+ }
111
114
  try {
112
115
  // Make the GET request to the actual asset storage backend
113
116
  const response = await fetch(url, {
114
117
  method: "GET",
118
+ headers,
115
119
  });
116
120
  logger_1.default.debug({ assetId: id, status: response.status }, `[ASSETS] Backend GET response status`);
117
121
  // Handle backend errors (like 404 Not Found)
@@ -125,7 +129,7 @@ async function loadAsset(id) {
125
129
  // Handle other non-OK statuses
126
130
  const errorBody = await response.text();
127
131
  const err = new Error(// better logging context
128
- `Backend failed to load asset ${id}. Status: ${response.status}. Body: ${errorBody}`);
132
+ `Backend failed to load asset ${id}. Status: ${response.status}. Body: ${errorBody}. Headers ${headers}`);
129
133
  logger_1.default.error({
130
134
  err,
131
135
  assetId: id,
package/dist/rooms.js CHANGED
@@ -14,7 +14,7 @@ if (!SNAPSHOT_STORAGE_URL) {
14
14
  process.exit(1);
15
15
  }
16
16
  logger_1.default.info({ snapshotStorageUrl: SNAPSHOT_STORAGE_URL }, `[ROOMS] Using Snapshot Storage URL`);
17
- const SAVE_INTERVAL_MS = parseInt(process.env.SWB_SAVE_INTERVAL_MS || '5000', 10);
17
+ const SAVE_INTERVAL_MS = parseInt(process.env.SWB_SAVE_INTERVAL_MS || "5000", 10);
18
18
  logger_1.default.info({ saveIntervalMs: SAVE_INTERVAL_MS }, `[ROOMS] Snapshot save interval`);
19
19
  // In-memory map holding active room states, keyed by roomId
20
20
  const rooms = new Map();
@@ -27,15 +27,21 @@ let createRoomMutex = Promise.resolve(undefined);
27
27
  * @returns The snapshot data, or undefined if the backend returns 404.
28
28
  * @throws Throws an error for non-404 HTTP errors or network issues.
29
29
  */
30
- async function readSnapshotFromBackend(roomId) {
30
+ async function readSnapshotFromBackend(roomId, authHeader) {
31
31
  const url = `${SNAPSHOT_STORAGE_URL}/${roomId}`;
32
32
  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;
40
+ }
33
41
  try {
34
42
  const response = await fetch(url, {
35
43
  method: "GET",
36
- headers: {
37
- Accept: "application/json" /* TODO: Add auth headers if needed */,
38
- },
44
+ headers: headers,
39
45
  });
40
46
  if (response.ok) {
41
47
  const snapshot = await response.json();
@@ -50,8 +56,15 @@ async function readSnapshotFromBackend(roomId) {
50
56
  // Handle unexpected errors from the backend
51
57
  const errorBody = await response.text();
52
58
  const err = new Error(// better logging context
53
- `Backend failed to load snapshot for ${roomId}. Status: ${response.status}. Body: ${errorBody}`);
54
- logger_1.default.error({ err, roomId, url, responseStatus: response.status, responseBody: errorBody }, `[ROOMS] Error loading snapshot for room ${roomId}`);
59
+ `Backend failed to load snapshot for ${roomId}. Status: ${response.status}. Body: ${errorBody}. Headers: ${headers}`);
60
+ logger_1.default.error({
61
+ err,
62
+ roomId,
63
+ url,
64
+ responseStatus: response.status,
65
+ responseBody: errorBody,
66
+ responseHeader: headers,
67
+ }, `[ROOMS] Error loading snapshot for room ${roomId}`);
55
68
  throw err;
56
69
  }
57
70
  }
@@ -65,21 +78,33 @@ async function readSnapshotFromBackend(roomId) {
65
78
  * @param roomId - The ID of the room.
66
79
  * @param room - The TLSocketRoom instance containing the state to save.
67
80
  */
68
- async function saveSnapshotToBackend(roomId, room) {
81
+ async function saveSnapshotToBackend(roomId, room, authHeader) {
69
82
  const url = `${SNAPSHOT_STORAGE_URL}/${roomId}`;
70
83
  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;
91
+ }
71
92
  logger_1.default.debug({ roomId, url, snapshotSize: JSON.stringify(snapshot).length }, `[ROOMS] Saving snapshot for room ${roomId} to ${url}`);
72
93
  try {
73
94
  const response = await fetch(url, {
74
95
  method: "POST",
75
- headers: {
76
- "Content-Type": "application/json" /* TODO: Add auth headers if needed */,
77
- },
96
+ headers: headers,
78
97
  body: JSON.stringify(snapshot),
79
98
  });
80
99
  if (!response.ok) {
81
100
  const errorBody = await response.text();
82
- logger_1.default.warn({ roomId, url, responseStatus: response.status, responseBody: errorBody }, // No err: new Error() here, just context
101
+ logger_1.default.warn({
102
+ roomId,
103
+ url,
104
+ responseStatus: response.status,
105
+ responseBody: errorBody,
106
+ responseHeaders: headers,
107
+ }, // No err: new Error() here, just context
83
108
  `[ROOMS] Error saving snapshot for room ${roomId}: ${response.status} ${response.statusText}`);
84
109
  // Log error but don't throw, to avoid breaking the save interval
85
110
  }
@@ -100,7 +125,7 @@ async function saveSnapshotToBackend(roomId, room) {
100
125
  * @returns A promise resolving to the TLSocketRoom instance.
101
126
  * @throws Throws an error if backend interaction fails during creation.
102
127
  */
103
- async function getOrCreateRoom(roomId) {
128
+ async function getOrCreateRoom(roomId, authHeader) {
104
129
  // Chain onto the mutex promise to ensure sequential access to the `rooms` map
105
130
  createRoomMutex = createRoomMutex.then(async () => {
106
131
  // Check if an active room instance already exists
@@ -119,32 +144,41 @@ async function getOrCreateRoom(roomId) {
119
144
  // Fetch initial state from the backend API (can throw error)
120
145
  const initialSnapshot = await readSnapshotFromBackend(roomId);
121
146
  // Define child logger for the tldraw room instance
122
- const tldrawInstanceLogger = logger_1.default.child({ tldrawRoomId: roomId, component: 'tldraw-sync-core' });
147
+ const tldrawInstanceLogger = logger_1.default.child({
148
+ tldrawRoomId: roomId,
149
+ component: "tldraw-sync-core",
150
+ });
123
151
  const tldrawLogAdapter = {
124
152
  warn: (...args) => {
125
- const msg = args.find(arg => typeof arg === 'string') || 'tldraw room warning';
126
- const details = args.filter(arg => typeof arg !== 'string');
153
+ const msg = args.find((arg) => typeof arg === "string") || "tldraw room warning";
154
+ const details = args.filter((arg) => typeof arg !== "string");
127
155
  tldrawInstanceLogger.warn(details.length ? { details } : {}, msg);
128
156
  },
129
157
  error: (...args) => {
130
- const errorArg = args.find(arg => arg instanceof Error);
158
+ const errorArg = args.find((arg) => arg instanceof Error);
131
159
  if (errorArg) {
132
- const msg = args.filter(arg => typeof arg === 'string' && arg !== errorArg.message).join(' ') || errorArg.message || 'tldraw room error';
133
- const details = args.filter(arg => arg !== errorArg && typeof arg !== 'string');
160
+ const msg = args
161
+ .filter((arg) => typeof arg === "string" && arg !== errorArg.message)
162
+ .join(" ") ||
163
+ errorArg.message ||
164
+ "tldraw room error";
165
+ const details = args.filter((arg) => arg !== errorArg && typeof arg !== "string");
134
166
  tldrawInstanceLogger.error({ err: errorArg, details: details.length ? details : undefined }, msg);
135
167
  }
136
168
  else {
137
- const msg = args.find(arg => typeof arg === 'string') || 'tldraw room error (no Error instance)';
138
- const details = args.filter(arg => typeof arg !== 'string');
169
+ const msg = args.find((arg) => typeof arg === "string") ||
170
+ "tldraw room error (no Error instance)";
171
+ const details = args.filter((arg) => typeof arg !== "string");
139
172
  tldrawInstanceLogger.error(details.length ? { details } : {}, msg);
140
173
  }
141
- }
174
+ },
142
175
  };
143
176
  // Create the new room state object
144
177
  const newRoomState = {
145
178
  id: roomId,
146
179
  needsPersist: false,
147
180
  persistPromise: null,
181
+ authHeader: authHeader,
148
182
  room: new sync_core_1.TLSocketRoom({
149
183
  schema: schema_1.whiteboardSchema, // Our defined tldraw schema
150
184
  initialSnapshot, // Initial state from backend (or undefined)
@@ -206,7 +240,7 @@ setInterval(() => {
206
240
  roomState.needsPersist = false; // Reset flag
207
241
  updatedRoomCount++;
208
242
  // Track the save operation promise
209
- roomState.persistPromise = saveSnapshotToBackend(roomState.id, roomState.room)
243
+ roomState.persistPromise = saveSnapshotToBackend(roomState.id, roomState.room, roomState.authHeader)
210
244
  .catch((error) => {
211
245
  // Log errors from periodic save but don't stop the interval
212
246
  logger_1.default.error({ err: error, roomId: roomState.id }, // Pass error object
package/dist/server.js CHANGED
@@ -33,7 +33,7 @@ app.register(cors_1.default, {
33
33
  "Authorization",
34
34
  "X-Original-Filename",
35
35
  "Content-Disposition",
36
- ], // Allowed headers
36
+ ],
37
37
  });
38
38
  // --- Define Routes ---
39
39
  app.register(async (svc) => {
@@ -47,9 +47,14 @@ app.register(async (svc) => {
47
47
  const { roomId } = req.params;
48
48
  const sessionId = req.query?.sessionId;
49
49
  // Client provides sessionId via query param, handled by TLSocketRoom
50
+ // Capture the Authorization header from the incoming request
51
+ const authHeader = req.headers.authorization;
52
+ if (!authHeader) {
53
+ req.log.warn({ roomId }, `[SERVER] Connection attempt without Authorization header.`);
54
+ }
50
55
  try {
51
56
  // Get or create the room instance (loads/creates state)
52
- const room = await (0, rooms_1.getOrCreateRoom)(roomId);
57
+ const room = await (0, rooms_1.getOrCreateRoom)(roomId, authHeader);
53
58
  req.log.debug(`[SERVER] Handling WebSocket connection for room ${roomId}`);
54
59
  room.handleSocketConnect({ sessionId, socket });
55
60
  }
@@ -68,6 +73,7 @@ app.register(async (svc) => {
68
73
  svc.put("/assets/:id", async (req, reply) => {
69
74
  const { id } = req.params;
70
75
  const contentType = req.headers["content-type"] || "application/octet-stream";
76
+ const authHeader = req.headers.authorization;
71
77
  // Extract original filename from custom header
72
78
  const originalFilenameHeaderRaw = req.headers["x-original-filename"];
73
79
  const originalFilenameHeader = Array.isArray(originalFilenameHeaderRaw)
@@ -102,7 +108,7 @@ app.register(async (svc) => {
102
108
  }
103
109
  try {
104
110
  // Call the asset storage logic (which proxies to the backend)
105
- await (0, assets_1.storeAsset)(id, req.raw, contentType, originalFilename);
111
+ await (0, assets_1.storeAsset)(id, req.raw, contentType, originalFilename, authHeader);
106
112
  req.log.debug({ assetId: id }, `[SERVER] Asset stored successfully.`);
107
113
  reply.code(200).send({ success: true });
108
114
  }
@@ -122,9 +128,11 @@ app.register(async (svc) => {
122
128
  app.get("/assets/:id", async (req, reply) => {
123
129
  const { id } = req.params;
124
130
  req.log.debug({ assetId: id }, `[SERVER] GET /assets/:id`);
131
+ // Capture the auth header if included from client
132
+ const authHeader = req.headers.authorization;
125
133
  try {
126
134
  // Call the asset loading logic (which proxies to the backend)
127
- const { stream: dataStream, contentType } = await (0, assets_1.loadAsset)(id);
135
+ const { stream: dataStream, contentType } = await (0, assets_1.loadAsset)(id, authHeader);
128
136
  req.log.debug({ assetId: id, contentType: contentType }, `[SERVER] Asset loaded. Sending reply...`);
129
137
  // Set the correct Content-Type header and send the stream
130
138
  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.1",
3
+ "version": "1.0.2",
4
4
  "main": "dist/server.js",
5
5
  "scripts": {
6
6
  "dev": "ts-node-dev --respawn --transpile-only src/server.ts",