@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 +49 -22
- package/dist/rooms.js +57 -23
- package/dist/server.js +21 -6
- package/package.json +1 -1
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
|
-
//
|
|
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({
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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({
|
|
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:
|
|
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 ||
|
|
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({
|
|
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({
|
|
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({
|
|
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 ===
|
|
126
|
-
const details = args.filter(arg => typeof arg !==
|
|
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
|
|
133
|
-
|
|
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 ===
|
|
138
|
-
|
|
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 === "*"
|
|
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: [
|
|
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);
|