@smoothglue/sync-whiteboard 0.1.0
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/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/assets.js +126 -0
- package/dist/rooms.js +195 -0
- package/dist/schema.js +11 -0
- package/dist/server.js +147 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 BrainGu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
## Overview
|
|
2
|
+
|
|
3
|
+
A simple standalone NodeJS-based sync server designed to provide a realtime, collaborative whiteboard experience for frontends integrated with tlDraw. It functions as a microservice, handling websocket connections to sync shared whiteboard edits in real-time using tlDraw's `sync-core` library.
|
|
4
|
+
|
|
5
|
+
The core responsibilities of this service include:
|
|
6
|
+
|
|
7
|
+
- Managing WebSocket connections for real-time synchronization of whiteboard edits.
|
|
8
|
+
- Facilitating data persistence (snapshots) via a backend API.
|
|
9
|
+
- Handling storage of assets (like images or videos) via a backend API.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Real-time Collaboration:** Leverages WebSockets and `@tldraw/sync-core` for multi-user interaction on a shared whiteboard via Fastify and `@fastify/websocket`.
|
|
14
|
+
- **Snapshot Persistence:** Delegates saving and loading whiteboard state snapshots to a configurable backend service. The development environment includes a mock backend for this.
|
|
15
|
+
- **Asset Handling:** Delegates uploading and retrieving large binary assets to a configurable backend service. The development environment includes a mock backend for this.
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
This service is designed as a NodeJS microservice using Fastify . It connects to client applications using tlDraw via WebSockets for real-time data synchronization. It relies on external services (configurable via environment variables) for persisting snapshots and storing assets.
|
|
20
|
+
|
|
21
|
+
## Development Environment (Docker)
|
|
22
|
+
|
|
23
|
+
This project includes a Docker-based development environment configured in `dev-env/docker-compose.yml`. This makes it easy to run the `sync-whiteboard` server along with its dependencies (mock backend API, database, object storage) and a mock frontend client.
|
|
24
|
+
|
|
25
|
+
**Prerequisites:**
|
|
26
|
+
|
|
27
|
+
- Docker ([https://www.docker.com/get-started](https://www.docker.com/get-started))
|
|
28
|
+
- Docker Compose ([https://docs.docker.com/compose/install/](https://docs.docker.com/compose/install/))
|
|
29
|
+
|
|
30
|
+
**Configuration:**
|
|
31
|
+
|
|
32
|
+
1. Navigate to the `dev-env` directory:
|
|
33
|
+
```bash
|
|
34
|
+
cd sync-whiteboard/dev-env
|
|
35
|
+
```
|
|
36
|
+
2. Create a `.env` file by copying the example or using your own settings. This file is used by `docker-compose.yml` to configure services like the database and object storage. Key variables include:
|
|
37
|
+
- `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` for the database.
|
|
38
|
+
- `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `MINIO_BUCKET` for the object storage.
|
|
39
|
+
- The `SNAPSHOT_STORAGE_URL` and `ASSET_STORAGE_URL` environment variables for the `sync-whiteboard` service itself are set within the `docker-compose.yml` to point to the `mock-backend` service.
|
|
40
|
+
|
|
41
|
+
**Running the Environment:**
|
|
42
|
+
|
|
43
|
+
1. From the `dev-env` directory, run:
|
|
44
|
+
```bash
|
|
45
|
+
docker-compose up --build
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Services:**
|
|
49
|
+
|
|
50
|
+
The Docker Compose setup starts the following services:
|
|
51
|
+
|
|
52
|
+
- **`sync-whiteboard`**: The main NodeJS sync server.
|
|
53
|
+
- Accessible via WebSocket at `ws://localhost:5858`. Builds from the `Dockerfile` in the parent directory.
|
|
54
|
+
- **`mock-backend`**: A Flask-based API simulating backend storage for snapshots (using PostgreSQL) and assets (using MinIO).
|
|
55
|
+
- Accessible at `http://localhost:5000`.
|
|
56
|
+
- **`postgres`**: PostgreSQL database for the mock backend.
|
|
57
|
+
- Data is persisted in a Docker volume (`postgres_data`).
|
|
58
|
+
- Port `5433` on the host is mapped to `5432` in the container.
|
|
59
|
+
- **`minio`**: MinIO object storage for the mock backend.
|
|
60
|
+
- Data is persisted in a Docker volume (`minio_data`).
|
|
61
|
+
- API accessible at `http://localhost:9000`.
|
|
62
|
+
- Console accessible at `http://localhost:9001`.
|
|
63
|
+
- **`mock-frontend`**: A simple Vite+React frontend client for testing.
|
|
64
|
+
- Accessible at `http://localhost:8080`.
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.storeAsset = storeAsset;
|
|
4
|
+
exports.loadAsset = loadAsset;
|
|
5
|
+
const stream_1 = require("stream");
|
|
6
|
+
// --- Configuration ---
|
|
7
|
+
const ASSET_STORAGE_URL = process.env.ASSET_STORAGE_URL;
|
|
8
|
+
if (!ASSET_STORAGE_URL) {
|
|
9
|
+
// Critical configuration missing, exit the process.
|
|
10
|
+
console.error("FATAL ERROR: ASSET_STORAGE_URL environment variable is not set.");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
console.log(`[ASSETS] Using Asset Storage URL: ${ASSET_STORAGE_URL}`);
|
|
14
|
+
// --- End Configuration ---
|
|
15
|
+
/**
|
|
16
|
+
* Stores an asset by proxying a PUT request to the configured asset storage backend.
|
|
17
|
+
* @param id - The unique identifier for the asset (generated by the client).
|
|
18
|
+
* @param fileStream - The readable stream containing the asset data (from the client request).
|
|
19
|
+
* @param contentType - The MIME type of the asset.
|
|
20
|
+
* @param originalFilename - The original filename provided by the client.
|
|
21
|
+
* @returns The asset ID upon successful storage.
|
|
22
|
+
* @throws Throws an error if the request to the backend fails.
|
|
23
|
+
*/
|
|
24
|
+
async function storeAsset(id, fileStream, contentType = "application/octet-stream", originalFilename) {
|
|
25
|
+
const url = `${ASSET_STORAGE_URL}/${id}`;
|
|
26
|
+
console.log(`[ASSETS] Storing asset id: ${id}, filename: ${originalFilename}. Target URL: ${url}`);
|
|
27
|
+
// Ensure we have a readable stream
|
|
28
|
+
if (!(fileStream instanceof stream_1.Readable)) {
|
|
29
|
+
console.error("[ASSETS] Error: storeAsset received a non-readable stream type.");
|
|
30
|
+
throw new Error("Invalid stream type provided to storeAsset.");
|
|
31
|
+
}
|
|
32
|
+
let webStream = null;
|
|
33
|
+
try {
|
|
34
|
+
// Convert Node.js stream to Web Standard stream for fetch body
|
|
35
|
+
webStream = stream_1.Readable.toWeb(fileStream);
|
|
36
|
+
// Make the PUT request to the actual asset storage backend (mock-backend in this case)
|
|
37
|
+
const response = await fetch(url, {
|
|
38
|
+
method: "PUT",
|
|
39
|
+
headers: {
|
|
40
|
+
"Content-Type": contentType,
|
|
41
|
+
// Pass original filename for backend to determine correct mimetype on GET
|
|
42
|
+
"X-Original-Filename": encodeURIComponent(originalFilename),
|
|
43
|
+
},
|
|
44
|
+
body: webStream, // Cast is necessary due to type mismatches
|
|
45
|
+
// @ts-ignore - duplex: 'half' is required for streaming request bodies with Node fetch
|
|
46
|
+
duplex: "half",
|
|
47
|
+
});
|
|
48
|
+
console.log(`[ASSETS] Backend PUT response status for ${id}: ${response.status}`);
|
|
49
|
+
// Handle backend errors
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const errorBody = await response.text();
|
|
52
|
+
console.error(`[ASSETS] Error response from backend storing asset ${id}: ${response.status} ${response.statusText}`, errorBody);
|
|
53
|
+
// Ensure streams are closed on error
|
|
54
|
+
if (webStream) {
|
|
55
|
+
await webStream
|
|
56
|
+
.cancel()
|
|
57
|
+
.catch((err) => console.error(`[ASSETS] Error cancelling upload webStream for ${id} after failed fetch:`, err));
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`Backend failed to store asset ${id}. Status: ${response.status}. Body: ${errorBody}`);
|
|
60
|
+
}
|
|
61
|
+
console.log(`[ASSETS] Successfully proxied storage for asset ${id} to ${url}`);
|
|
62
|
+
return id; // Return the ID, confirming success
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
console.error(`[ASSETS] Network or fetch error storing asset ${id} to ${url}:`, error);
|
|
66
|
+
// Clean up streams on error
|
|
67
|
+
if (fileStream instanceof stream_1.Readable && !fileStream.destroyed) {
|
|
68
|
+
fileStream.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
69
|
+
}
|
|
70
|
+
if (webStream) {
|
|
71
|
+
await webStream
|
|
72
|
+
.cancel()
|
|
73
|
+
.catch((err) => console.error(`[ASSETS] Error cancelling upload webStream during error handling for ${id}:`, err));
|
|
74
|
+
}
|
|
75
|
+
throw error; // Re-throw error for the server handler
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Loads an asset by proxying a GET request to the configured asset storage backend.
|
|
80
|
+
* @param id - The unique identifier for the asset.
|
|
81
|
+
* @returns An object containing the asset's data as a Readable stream and its Content-Type.
|
|
82
|
+
* @throws Throws an error if the request to the backend fails or the asset is not found.
|
|
83
|
+
*/
|
|
84
|
+
async function loadAsset(id) {
|
|
85
|
+
const url = `${ASSET_STORAGE_URL}/${id}`;
|
|
86
|
+
console.log(`[ASSETS] Loading asset id: ${id}. Target URL: ${url}`);
|
|
87
|
+
try {
|
|
88
|
+
// Make the GET request to the actual asset storage backend
|
|
89
|
+
const response = await fetch(url, {
|
|
90
|
+
method: "GET",
|
|
91
|
+
});
|
|
92
|
+
console.log(`[ASSETS] Backend GET response status for ${id}: ${response.status}`);
|
|
93
|
+
// Handle backend errors (like 404 Not Found)
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
if (response.status === 404) {
|
|
96
|
+
console.warn(`[ASSETS] Asset ${id} not found at backend ${url} (404)`);
|
|
97
|
+
const notFoundError = new Error(`Asset ${id} not found.`);
|
|
98
|
+
notFoundError.code = "ENOENT"; // Mimic filesystem error code
|
|
99
|
+
throw notFoundError;
|
|
100
|
+
}
|
|
101
|
+
// Handle other non-OK statuses
|
|
102
|
+
const errorBody = await response.text();
|
|
103
|
+
console.error(`[ASSETS] Error response from backend loading asset ${id}: ${response.status} ${response.statusText}`, errorBody);
|
|
104
|
+
throw new Error(`Backend failed to load asset ${id}. Status: ${response.status}. Body: ${errorBody}`);
|
|
105
|
+
}
|
|
106
|
+
// Ensure response body exists
|
|
107
|
+
if (!response.body) {
|
|
108
|
+
console.error(`[ASSETS] No response body received from backend for asset ${id} from ${url}`);
|
|
109
|
+
throw new Error(`No response body received for asset ${id}.`);
|
|
110
|
+
}
|
|
111
|
+
// Get the Content-Type header provided by the backend
|
|
112
|
+
const contentType = response.headers.get("Content-Type") || "application/octet-stream";
|
|
113
|
+
console.log(`[ASSETS] Received Content-Type from backend for ${id}: ${contentType}`);
|
|
114
|
+
// Convert the Web Standard stream from fetch response to a Node.js stream
|
|
115
|
+
const nodeStream = stream_1.Readable.fromWeb(response.body);
|
|
116
|
+
// Return the stream and content type for the server handler to use
|
|
117
|
+
return { stream: nodeStream, contentType: contentType };
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
// Avoid double-logging known 'Not Found' errors
|
|
121
|
+
if (error.code !== "ENOENT") {
|
|
122
|
+
console.error(`[ASSETS] Network or fetch error loading asset ${id} from ${url}:`, error);
|
|
123
|
+
}
|
|
124
|
+
throw error; // Re-throw error for the server handler
|
|
125
|
+
}
|
|
126
|
+
}
|
package/dist/rooms.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getOrCreateRoom = getOrCreateRoom;
|
|
4
|
+
const sync_core_1 = require("@tldraw/sync-core");
|
|
5
|
+
const schema_1 = require("./schema");
|
|
6
|
+
// --- Configuration ---
|
|
7
|
+
const SNAPSHOT_STORAGE_URL = process.env.SNAPSHOT_STORAGE_URL;
|
|
8
|
+
if (!SNAPSHOT_STORAGE_URL) {
|
|
9
|
+
console.error("FATAL ERROR: SNAPSHOT_STORAGE_URL environment variable is not set.");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
console.log(`[ROOMS] Using Snapshot Storage URL: ${SNAPSHOT_STORAGE_URL}`);
|
|
13
|
+
const SAVE_INTERVAL_MS = process.env.SAVE_INTERVAL_MS
|
|
14
|
+
? parseInt(process.env.SAVE_INTERVAL_MS, 10)
|
|
15
|
+
: 5000;
|
|
16
|
+
console.log(`[ROOMS] Snapshot save interval: ${SAVE_INTERVAL_MS}ms`);
|
|
17
|
+
// In-memory map holding active room states, keyed by roomId
|
|
18
|
+
const rooms = new Map();
|
|
19
|
+
// Mutex to prevent race conditions when multiple requests try to create the same room simultaneously
|
|
20
|
+
let createRoomMutex = Promise.resolve(undefined);
|
|
21
|
+
// --- End Room State Tracking ---
|
|
22
|
+
/**
|
|
23
|
+
* Reads the latest snapshot for a room from the backend API via HTTP GET.
|
|
24
|
+
* @param roomId - The ID of the room.
|
|
25
|
+
* @returns The snapshot data, or undefined if the backend returns 404.
|
|
26
|
+
* @throws Throws an error for non-404 HTTP errors or network issues.
|
|
27
|
+
*/
|
|
28
|
+
async function readSnapshotFromBackend(roomId) {
|
|
29
|
+
const url = `${SNAPSHOT_STORAGE_URL}/${roomId}`;
|
|
30
|
+
console.log(`[ROOMS] Loading snapshot for room ${roomId} from ${url}`);
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(url, {
|
|
33
|
+
method: "GET",
|
|
34
|
+
headers: {
|
|
35
|
+
Accept: "application/json" /* TODO: Add auth headers if needed */,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
if (response.ok) {
|
|
39
|
+
const snapshot = await response.json();
|
|
40
|
+
console.log(`[ROOMS] Snapshot loaded successfully for room ${roomId}`);
|
|
41
|
+
return snapshot;
|
|
42
|
+
}
|
|
43
|
+
else if (response.status === 404) {
|
|
44
|
+
console.log(`[ROOMS] No existing snapshot found for room ${roomId} (404)`);
|
|
45
|
+
return undefined; // Expected case for a new room
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// Handle unexpected errors from the backend
|
|
49
|
+
const errorBody = await response.text();
|
|
50
|
+
console.error(`[ROOMS] Error loading snapshot for room ${roomId}: ${response.status} ${response.statusText}`, errorBody);
|
|
51
|
+
throw new Error(`Backend failed to load snapshot for ${roomId}. Status: ${response.status}. Body: ${errorBody}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
console.error(`[ROOMS] Network or fetch error loading snapshot for room ${roomId} from ${url}:`, error);
|
|
56
|
+
throw error; // Propagate error to getOrCreateRoom
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Saves the current room snapshot to the backend API via HTTP POST.
|
|
61
|
+
* @param roomId - The ID of the room.
|
|
62
|
+
* @param room - The TLSocketRoom instance containing the state to save.
|
|
63
|
+
*/
|
|
64
|
+
async function saveSnapshotToBackend(roomId, room) {
|
|
65
|
+
const url = `${SNAPSHOT_STORAGE_URL}/${roomId}`;
|
|
66
|
+
const snapshot = room.getCurrentSnapshot();
|
|
67
|
+
console.log(`[ROOMS] Saving snapshot for room ${roomId} to ${url}`);
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(url, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: {
|
|
72
|
+
"Content-Type": "application/json" /* TODO: Add auth headers if needed */,
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(snapshot),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const errorBody = await response.text();
|
|
78
|
+
console.error(`[ROOMS] Error saving snapshot for room ${roomId}: ${response.status} ${response.statusText}`, errorBody);
|
|
79
|
+
// Log error but don't throw, to avoid breaking the save interval
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
console.log(`[ROOMS] Snapshot saved successfully for room ${roomId}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
console.error(`[ROOMS] Network or fetch error saving snapshot for room ${roomId} to ${url}:`, error);
|
|
87
|
+
// Log error but don't throw
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Retrieves an existing active room instance from memory or creates a new one.
|
|
92
|
+
* If creating, it loads the initial state from the backend.
|
|
93
|
+
* Uses a mutex to handle concurrent requests safely.
|
|
94
|
+
* @param roomId - The ID of the room to get or create.
|
|
95
|
+
* @returns A promise resolving to the TLSocketRoom instance.
|
|
96
|
+
* @throws Throws an error if backend interaction fails during creation.
|
|
97
|
+
*/
|
|
98
|
+
async function getOrCreateRoom(roomId) {
|
|
99
|
+
// Chain onto the mutex promise to ensure sequential access to the `rooms` map
|
|
100
|
+
createRoomMutex = createRoomMutex.then(async () => {
|
|
101
|
+
// Check if an active room instance already exists
|
|
102
|
+
if (rooms.has(roomId)) {
|
|
103
|
+
const existingRoomState = rooms.get(roomId);
|
|
104
|
+
if (!existingRoomState.room.isClosed()) {
|
|
105
|
+
return; // Room exists and is active
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
console.log(`[ROOMS] Found closed room ${roomId}, removing before creating new one.`);
|
|
109
|
+
rooms.delete(roomId); // Clean up closed room reference
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
console.log(`[ROOMS] Creating or recreating room: ${roomId}`);
|
|
113
|
+
// Fetch initial state from the backend API (can throw error)
|
|
114
|
+
const initialSnapshot = await readSnapshotFromBackend(roomId);
|
|
115
|
+
// Define logger for the tldraw room instance
|
|
116
|
+
const logger = {
|
|
117
|
+
warn: (...args) => console.warn(`[TLDRAW ROOM ${roomId} WARN]`, ...args),
|
|
118
|
+
error: (...args) => console.error(`[TLDRAW ROOM ${roomId} ERROR]`, ...args),
|
|
119
|
+
};
|
|
120
|
+
// Create the new room state object
|
|
121
|
+
const newRoomState = {
|
|
122
|
+
id: roomId,
|
|
123
|
+
needsPersist: false,
|
|
124
|
+
persistPromise: null,
|
|
125
|
+
room: new sync_core_1.TLSocketRoom({
|
|
126
|
+
schema: schema_1.whiteboardSchema, // Our defined tldraw schema
|
|
127
|
+
initialSnapshot, // Initial state from backend (or undefined)
|
|
128
|
+
log: logger, // Logger for internal tldraw messages
|
|
129
|
+
/** Callback when a user session is removed (e.g., disconnects/times out) */
|
|
130
|
+
onSessionRemoved(room, args) {
|
|
131
|
+
console.log(`[ROOMS] Session removed from room ${roomId}. Remaining: ${args.numSessionsRemaining}`);
|
|
132
|
+
// If last user leaves, trigger a final save and close the room
|
|
133
|
+
if (args.numSessionsRemaining === 0) {
|
|
134
|
+
console.log(`[ROOMS] Last user left room ${roomId}. Triggering final save.`);
|
|
135
|
+
// Ensure any pending periodic save completes first
|
|
136
|
+
const savePromise = newRoomState.persistPromise ?? Promise.resolve();
|
|
137
|
+
savePromise.finally(() => {
|
|
138
|
+
console.log(`[ROOMS] Performing final save for room ${roomId}...`);
|
|
139
|
+
saveSnapshotToBackend(roomId, room).finally(() => {
|
|
140
|
+
console.log(`[ROOMS] Closing room ${roomId} after final save.`);
|
|
141
|
+
room.close(); // Mark the tldraw room as closed
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
/** Callback when data within the room changes */
|
|
147
|
+
onDataChange() {
|
|
148
|
+
// Flag that the room needs to be saved on the next interval
|
|
149
|
+
newRoomState.needsPersist = true;
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
// Store the new room state in our map
|
|
154
|
+
rooms.set(roomId, newRoomState);
|
|
155
|
+
console.log(`[ROOMS] Room ${roomId} created successfully.`);
|
|
156
|
+
});
|
|
157
|
+
// Wait for the mutex-protected operation (lookup/creation) to complete
|
|
158
|
+
await createRoomMutex;
|
|
159
|
+
// Retrieve the room state (should always exist after the mutex)
|
|
160
|
+
const roomState = rooms.get(roomId);
|
|
161
|
+
if (!roomState || roomState.room.isClosed()) {
|
|
162
|
+
// Defensive check in case something went wrong
|
|
163
|
+
console.error(`[ROOMS] Failed to get or create a valid room instance for ${roomId} after mutex.`);
|
|
164
|
+
throw new Error(`Failed to retrieve valid room instance for ${roomId}`);
|
|
165
|
+
}
|
|
166
|
+
// Return the tldraw room object
|
|
167
|
+
return roomState.room;
|
|
168
|
+
}
|
|
169
|
+
// --- Periodic Persistence ---
|
|
170
|
+
// Saves snapshots for rooms marked as `needsPersist` at regular intervals.
|
|
171
|
+
setInterval(() => {
|
|
172
|
+
for (const roomState of rooms.values()) {
|
|
173
|
+
// Clean up closed rooms from memory
|
|
174
|
+
if (roomState.room.isClosed()) {
|
|
175
|
+
console.log(`[ROOMS] Removing closed room ${roomState.id} during periodic check.`);
|
|
176
|
+
rooms.delete(roomState.id);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// If room has changes and isn't already saving, start a save operation
|
|
180
|
+
if (roomState.needsPersist && !roomState.persistPromise) {
|
|
181
|
+
roomState.needsPersist = false; // Reset flag
|
|
182
|
+
// Track the save operation promise
|
|
183
|
+
roomState.persistPromise = saveSnapshotToBackend(roomState.id, roomState.room)
|
|
184
|
+
.catch((error) => {
|
|
185
|
+
// Log errors from periodic save but don't stop the interval
|
|
186
|
+
console.error(`[ROOMS] Periodic save failed for room ${roomState.id}:`, error);
|
|
187
|
+
})
|
|
188
|
+
.finally(() => {
|
|
189
|
+
// Clear the promise tracker when done
|
|
190
|
+
roomState.persistPromise = null;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}, SAVE_INTERVAL_MS);
|
|
195
|
+
// --- End Periodic Persistence ---
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.whiteboardSchema = void 0;
|
|
4
|
+
const tlschema_1 = require("@tldraw/tlschema");
|
|
5
|
+
exports.whiteboardSchema = (0, tlschema_1.createTLSchema)({
|
|
6
|
+
shapes: {
|
|
7
|
+
...tlschema_1.defaultShapeSchemas,
|
|
8
|
+
//TODO: add custom shapes here
|
|
9
|
+
},
|
|
10
|
+
bindings: tlschema_1.defaultBindingSchemas,
|
|
11
|
+
});
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fastify_1 = __importDefault(require("fastify"));
|
|
7
|
+
const websocket_1 = __importDefault(require("@fastify/websocket"));
|
|
8
|
+
const cors_1 = __importDefault(require("@fastify/cors"));
|
|
9
|
+
const rooms_1 = require("./rooms");
|
|
10
|
+
const assets_1 = require("./assets");
|
|
11
|
+
const stream_1 = require("stream");
|
|
12
|
+
// Configuration
|
|
13
|
+
const PORT = parseInt(process.env.PORT || "5858", 10);
|
|
14
|
+
const HOST = process.env.HOST || "0.0.0.0"; // Listen on all interfaces by default
|
|
15
|
+
// Initialize Fastify app with logging
|
|
16
|
+
const app = (0, fastify_1.default)({ logger: { level: "info" } }); // Use 'info' level for less verbosity
|
|
17
|
+
// --- Register Plugins ---
|
|
18
|
+
app.register(websocket_1.default); // Enable WebSocket support
|
|
19
|
+
app.register(cors_1.default, {
|
|
20
|
+
// Configure CORS
|
|
21
|
+
origin: "*", // Allow all origins (restrict in production)
|
|
22
|
+
methods: ["GET", "PUT", "POST", "DELETE", "OPTIONS"], // Allowed HTTP methods
|
|
23
|
+
allowedHeaders: ["Content-Type", "Authorization", "X-Original-Filename"], // Allowed headers
|
|
24
|
+
});
|
|
25
|
+
// --- Define Routes ---
|
|
26
|
+
app.register(async (app) => {
|
|
27
|
+
// Health check endpoint
|
|
28
|
+
app.get("/", async () => ({
|
|
29
|
+
status: "sync-whiteboard is running",
|
|
30
|
+
time: new Date().toISOString(),
|
|
31
|
+
}));
|
|
32
|
+
// WebSocket connection endpoint for tldraw sync
|
|
33
|
+
app.get("/connect/:roomId", { websocket: true }, async (socket, req) => {
|
|
34
|
+
const { roomId } = req.params;
|
|
35
|
+
const sessionId = req.query?.sessionId;
|
|
36
|
+
// Client provides sessionId via query param, handled by TLSocketRoom
|
|
37
|
+
try {
|
|
38
|
+
// Get or create the room instance (loads/creates state)
|
|
39
|
+
const room = await (0, rooms_1.getOrCreateRoom)(roomId);
|
|
40
|
+
app.log.info(`[SERVER] Handling WebSocket connection for room ${roomId}`);
|
|
41
|
+
// Connect the client's socket to the tldraw room handler
|
|
42
|
+
room.handleSocketConnect({ sessionId, socket });
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
app.log.error(`[SERVER] Error initializing room ${roomId}:`, error);
|
|
46
|
+
// Close socket with error code if room initialization fails
|
|
47
|
+
socket.close(1011, "Internal server error during room initialization");
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
// --- Asset Handling ---
|
|
51
|
+
// Allow raw body parsing for asset uploads
|
|
52
|
+
app.addContentTypeParser("*", (_, __, done) => done(null));
|
|
53
|
+
/**
|
|
54
|
+
* Handles asset uploads (PUT /assets/:id).
|
|
55
|
+
* Proxies the request body stream to the asset storage backend via storeAsset.
|
|
56
|
+
*/
|
|
57
|
+
app.put("/assets/:id", async (req, reply) => {
|
|
58
|
+
const { id } = req.params;
|
|
59
|
+
const contentType = req.headers["content-type"] || "application/octet-stream";
|
|
60
|
+
// Extract original filename from custom header
|
|
61
|
+
const originalFilenameHeaderRaw = req.headers["x-original-filename"];
|
|
62
|
+
const originalFilenameHeader = Array.isArray(originalFilenameHeaderRaw)
|
|
63
|
+
? originalFilenameHeaderRaw[0]
|
|
64
|
+
: originalFilenameHeaderRaw;
|
|
65
|
+
let originalFilename = "unknown_asset";
|
|
66
|
+
if (typeof originalFilenameHeader === "string" &&
|
|
67
|
+
originalFilenameHeader.length > 0) {
|
|
68
|
+
try {
|
|
69
|
+
originalFilename = decodeURIComponent(originalFilenameHeader);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
app.log.warn(`[SERVER] Failed to decode X-Original-Filename header: ${originalFilenameHeader}`);
|
|
73
|
+
originalFilename = "decode_error";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
app.log.warn(`[SERVER] X-Original-Filename header missing or invalid: ${originalFilenameHeaderRaw}`);
|
|
78
|
+
}
|
|
79
|
+
app.log.info(`[SERVER] PUT /assets/${id}, Content-Type: ${contentType}, Filename: ${originalFilename}`);
|
|
80
|
+
// Validate request body is a stream
|
|
81
|
+
if (!(req.raw instanceof stream_1.Readable)) {
|
|
82
|
+
app.log.error(`[SERVER] Error: Request raw body is not a Readable stream for asset ${id}`);
|
|
83
|
+
return reply.code(500).send({
|
|
84
|
+
success: false,
|
|
85
|
+
error: "Internal server error: Invalid request body stream.",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
// Call the asset storage logic (which proxies to the backend)
|
|
90
|
+
await (0, assets_1.storeAsset)(id, req.raw, contentType, originalFilename);
|
|
91
|
+
app.log.info(`[SERVER] Asset ${id} stored successfully.`);
|
|
92
|
+
reply.code(200).send({ success: true });
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
app.log.error(`[SERVER] Error storing asset ${id}:`, error);
|
|
96
|
+
const statusCode = error?.code === "ENOENT" ? 404 : 500; // Check for specific errors if needed
|
|
97
|
+
reply.code(statusCode).send({
|
|
98
|
+
success: false,
|
|
99
|
+
error: error.message || "Failed to store asset",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
/**
|
|
104
|
+
* Handles asset retrieval (GET /assets/:id).
|
|
105
|
+
* Proxies the request to the asset storage backend via loadAsset and streams the response.
|
|
106
|
+
*/
|
|
107
|
+
app.get("/assets/:id", async (req, reply) => {
|
|
108
|
+
const { id } = req.params;
|
|
109
|
+
app.log.info(`[SERVER] GET /assets/${id}`);
|
|
110
|
+
try {
|
|
111
|
+
// Call the asset loading logic (which proxies to the backend)
|
|
112
|
+
const { stream: dataStream, contentType } = await (0, assets_1.loadAsset)(id);
|
|
113
|
+
app.log.info(`[SERVER] Asset ${id} loaded. Content-Type: ${contentType}. Sending reply...`);
|
|
114
|
+
// Set the correct Content-Type header and send the stream
|
|
115
|
+
reply.header("Content-Type", contentType);
|
|
116
|
+
reply.send(dataStream);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
app.log.error(`[SERVER] Error loading asset ${id}:`, error);
|
|
120
|
+
if (error.code === "ENOENT") {
|
|
121
|
+
// Asset not found by the backend
|
|
122
|
+
reply.code(404).send({ success: false, error: "Asset not found" });
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// Other errors during load
|
|
126
|
+
reply.code(500).send({
|
|
127
|
+
success: false,
|
|
128
|
+
error: error.message || "Failed to load asset",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// --- End Asset Handling ---
|
|
134
|
+
});
|
|
135
|
+
// --- End Define Routes ---
|
|
136
|
+
// --- Start Server ---
|
|
137
|
+
const start = async () => {
|
|
138
|
+
try {
|
|
139
|
+
await app.listen({ port: PORT, host: HOST });
|
|
140
|
+
app.log.info(`Sync Whiteboard server running on http://${HOST}:${PORT}`);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
app.log.error(err);
|
|
144
|
+
process.exit(1); // Exit if server fails to start
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
start();
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@smoothglue/sync-whiteboard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "dist/server.js",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"start": "node dist/server.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [],
|
|
11
|
+
"author": "Wade Tait",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"description": "A minimal sync server for tlDraw based whiteboards",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"homepage": "https://gitlab.com/braingu/realtime-sync/-/blob/main/sync-whiteboard/README.md",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://gitlab.com/braingu/realtime-sync/-/tree/main/sync-whiteboard"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.14.1",
|
|
29
|
+
"@types/ws": "^8.18.1",
|
|
30
|
+
"ts-node": "^10.9.2",
|
|
31
|
+
"ts-node-dev": "^2.0.0",
|
|
32
|
+
"typescript": "^5.8.3"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@fastify/cors": "^11.0.1",
|
|
36
|
+
"@fastify/websocket": "^11.0.2",
|
|
37
|
+
"@tldraw/sync-core": "^3.12.0",
|
|
38
|
+
"@tldraw/tlschema": "^3.12.0",
|
|
39
|
+
"fastify": "^5.3.0",
|
|
40
|
+
"ws": "^8.18.1"
|
|
41
|
+
}
|
|
42
|
+
}
|