@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 +23 -19
- package/dist/rooms.js +57 -23
- package/dist/server.js +12 -4
- 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,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
|
-
//
|
|
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
|
-
|
|
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 ||
|
|
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
|
@@ -33,7 +33,7 @@ app.register(cors_1.default, {
|
|
|
33
33
|
"Authorization",
|
|
34
34
|
"X-Original-Filename",
|
|
35
35
|
"Content-Disposition",
|
|
36
|
-
],
|
|
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);
|