@smoothglue/sync-whiteboard 1.0.0 → 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,14 +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
- // Make the PUT request to the actual asset storage backend (mock-backend in this case)
40
+ // Sanitize the filename for use in the Content-Disposition header
41
+ // This removes non-ASCII characters that can crash the fetch call.
42
+ const sanitizedFilename = originalFilename.replace(/[^\x00-\x7F]/g, "");
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
41
53
  const response = await fetch(url, {
42
54
  method: "PUT",
43
- headers: {
44
- "Content-Type": contentType,
45
- // Pass original filename for backend to determine correct mimetype on GET
46
- "X-Original-Filename": encodeURIComponent(originalFilename),
47
- },
55
+ headers: headers,
48
56
  body: webStream, // Cast is necessary due to type mismatches
49
57
  // @ts-ignore - duplex: 'half' is required for streaming request bodies with Node fetch
50
58
  duplex: "half",
@@ -53,14 +61,21 @@ async function storeAsset(id, fileStream, contentType = "application/octet-strea
53
61
  // Handle backend errors
54
62
  if (!response.ok) {
55
63
  const errorBody = await response.text();
56
- const err = new Error(`Backend failed to store asset ${id}. Status: ${response.status}. Body: ${errorBody}`);
57
- logger_1.default.error({ err, assetId: id, responseStatus: response.status, responseStatusText: response.statusText, responseBody: errorBody }, `[ASSETS] Error response from backend storing asset`);
64
+ const err = new Error(`Backend failed to store asset ${id}. Status: ${response.status}. Body: ${errorBody}. Headers ${headers}`);
65
+ logger_1.default.error({
66
+ err,
67
+ assetId: id,
68
+ responseStatus: response.status,
69
+ responseStatusText: response.statusText,
70
+ responseBody: errorBody,
71
+ }, `[ASSETS] Error response from backend storing asset`);
58
72
  // Ensure streams are closed on error
59
73
  if (webStream) {
60
- await webStream
61
- .cancel()
62
- .catch((cancelErr) => // Changed variable name to avoid shadowing
63
- logger_1.default.error({ err: cancelErr, assetId: id, stage: 'cancel_upload_after_failed_fetch' }, `[ASSETS] Error cancelling upload webStream`));
74
+ await webStream.cancel().catch((cancelErr) => logger_1.default.error({
75
+ err: cancelErr,
76
+ assetId: id,
77
+ stage: "cancel_upload_after_failed_fetch",
78
+ }, `[ASSETS] Error cancelling upload webStream`));
64
79
  }
65
80
  throw err;
66
81
  }
@@ -68,16 +83,17 @@ async function storeAsset(id, fileStream, contentType = "application/octet-strea
68
83
  return id; // Return the ID, confirming success
69
84
  }
70
85
  catch (error) {
71
- logger_1.default.error({ err: error, assetId: id, targetUrl: url, operation: 'storeAsset' }, `[ASSETS] Network or fetch error storing asset`);
86
+ logger_1.default.error({ err: error, assetId: id, targetUrl: url, operation: "storeAsset" }, `[ASSETS] Network or fetch error storing asset`);
72
87
  // Clean up streams on error
73
88
  if (fileStream instanceof stream_1.Readable && !fileStream.destroyed) {
74
89
  fileStream.destroy(error instanceof Error ? error : new Error(String(error)));
75
90
  }
76
91
  if (webStream) {
77
- await webStream
78
- .cancel()
79
- .catch((cancelErr) => // Changed variable name
80
- logger_1.default.error({ err: cancelErr, assetId: id, stage: 'cancel_upload_during_error_handling' }, `[ASSETS] Error cancelling upload webStream`));
92
+ await webStream.cancel().catch((cancelErr) => logger_1.default.error({
93
+ err: cancelErr,
94
+ assetId: id,
95
+ stage: "cancel_upload_during_error_handling",
96
+ }, `[ASSETS] Error cancelling upload webStream`));
81
97
  }
82
98
  throw error; // Re-throw error for the server handler
83
99
  }
@@ -88,13 +104,18 @@ async function storeAsset(id, fileStream, contentType = "application/octet-strea
88
104
  * @returns An object containing the asset's data as a Readable stream and its Content-Type.
89
105
  * @throws Throws an error if the request to the backend fails or the asset is not found.
90
106
  */
91
- async function loadAsset(id) {
107
+ async function loadAsset(id, authHeader) {
92
108
  const url = `${ASSET_STORAGE_URL}/${id}`;
93
109
  logger_1.default.debug({ assetId: id, targetUrl: url }, `[ASSETS] Loading asset`);
110
+ const headers = {};
111
+ if (authHeader) {
112
+ headers.Authorization = authHeader;
113
+ }
94
114
  try {
95
115
  // Make the GET request to the actual asset storage backend
96
116
  const response = await fetch(url, {
97
117
  method: "GET",
118
+ headers,
98
119
  });
99
120
  logger_1.default.debug({ assetId: id, status: response.status }, `[ASSETS] Backend GET response status`);
100
121
  // Handle backend errors (like 404 Not Found)
@@ -108,8 +129,14 @@ async function loadAsset(id) {
108
129
  // Handle other non-OK statuses
109
130
  const errorBody = await response.text();
110
131
  const err = new Error(// better logging context
111
- `Backend failed to load asset ${id}. Status: ${response.status}. Body: ${errorBody}`);
112
- logger_1.default.error({ err, assetId: id, responseStatus: response.status, responseStatusText: response.statusText, responseBody: errorBody }, `[ASSETS] Error response from backend loading asset`);
132
+ `Backend failed to load asset ${id}. Status: ${response.status}. Body: ${errorBody}. Headers ${headers}`);
133
+ logger_1.default.error({
134
+ err,
135
+ assetId: id,
136
+ responseStatus: response.status,
137
+ responseStatusText: response.statusText,
138
+ responseBody: errorBody,
139
+ }, `[ASSETS] Error response from backend loading asset`);
113
140
  throw err;
114
141
  }
115
142
  // Ensure response body exists
@@ -128,7 +155,7 @@ async function loadAsset(id) {
128
155
  catch (error) {
129
156
  // Avoid double-logging known 'Not Found' errors
130
157
  if (error.code !== "ENOENT") {
131
- logger_1.default.error({ err: error, assetId: id, targetUrl: url, operation: 'loadAsset' }, `[ASSETS] Network or fetch error loading asset`);
158
+ logger_1.default.error({ err: error, assetId: id, targetUrl: url, operation: "loadAsset" }, `[ASSETS] Network or fetch error loading asset`);
132
159
  }
133
160
  throw error; // Re-throw error for the server handler
134
161
  }
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
@@ -12,8 +12,10 @@ const logger_1 = require("./logger");
12
12
  const rooms_1 = require("./rooms");
13
13
  // Configuration
14
14
  const parseCorsWhitelist = (cors) => {
15
- const normalized = (cors || "").replace(/\s/g, '');
16
- return normalized === "*" ? normalized : normalized.split(',').filter(x => x.length > 0);
15
+ const normalized = (cors || "").replace(/\s/g, "");
16
+ return normalized === "*"
17
+ ? normalized
18
+ : normalized.split(",").filter((x) => x.length > 0);
17
19
  };
18
20
  const PORT = parseInt(process.env.SWB_PORT || "5858", 10);
19
21
  const HOST = process.env.SWB_HOST || "0.0.0.0"; // Listen on all interfaces by default
@@ -26,7 +28,12 @@ app.register(cors_1.default, {
26
28
  // Configure CORS
27
29
  origin: CORS_WHITELIST,
28
30
  methods: ["GET", "PUT", "POST", "DELETE", "OPTIONS"], // Allowed HTTP methods
29
- allowedHeaders: ["Content-Type", "Authorization", "X-Original-Filename"], // Allowed headers
31
+ allowedHeaders: [
32
+ "Content-Type",
33
+ "Authorization",
34
+ "X-Original-Filename",
35
+ "Content-Disposition",
36
+ ],
30
37
  });
31
38
  // --- Define Routes ---
32
39
  app.register(async (svc) => {
@@ -40,9 +47,14 @@ app.register(async (svc) => {
40
47
  const { roomId } = req.params;
41
48
  const sessionId = req.query?.sessionId;
42
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
+ }
43
55
  try {
44
56
  // Get or create the room instance (loads/creates state)
45
- const room = await (0, rooms_1.getOrCreateRoom)(roomId);
57
+ const room = await (0, rooms_1.getOrCreateRoom)(roomId, authHeader);
46
58
  req.log.debug(`[SERVER] Handling WebSocket connection for room ${roomId}`);
47
59
  room.handleSocketConnect({ sessionId, socket });
48
60
  }
@@ -61,6 +73,7 @@ app.register(async (svc) => {
61
73
  svc.put("/assets/:id", async (req, reply) => {
62
74
  const { id } = req.params;
63
75
  const contentType = req.headers["content-type"] || "application/octet-stream";
76
+ const authHeader = req.headers.authorization;
64
77
  // Extract original filename from custom header
65
78
  const originalFilenameHeaderRaw = req.headers["x-original-filename"];
66
79
  const originalFilenameHeader = Array.isArray(originalFilenameHeaderRaw)
@@ -95,7 +108,7 @@ app.register(async (svc) => {
95
108
  }
96
109
  try {
97
110
  // Call the asset storage logic (which proxies to the backend)
98
- await (0, assets_1.storeAsset)(id, req.raw, contentType, originalFilename);
111
+ await (0, assets_1.storeAsset)(id, req.raw, contentType, originalFilename, authHeader);
99
112
  req.log.debug({ assetId: id }, `[SERVER] Asset stored successfully.`);
100
113
  reply.code(200).send({ success: true });
101
114
  }
@@ -115,9 +128,11 @@ app.register(async (svc) => {
115
128
  app.get("/assets/:id", async (req, reply) => {
116
129
  const { id } = req.params;
117
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;
118
133
  try {
119
134
  // Call the asset loading logic (which proxies to the backend)
120
- const { stream: dataStream, contentType } = await (0, assets_1.loadAsset)(id);
135
+ const { stream: dataStream, contentType } = await (0, assets_1.loadAsset)(id, authHeader);
121
136
  req.log.debug({ assetId: id, contentType: contentType }, `[SERVER] Asset loaded. Sending reply...`);
122
137
  // Set the correct Content-Type header and send the stream
123
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.0",
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",