Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ bower_components
examples/uploads
npm-debug.log
**/.DS_STORE
package-lock.json
collab.db**
31 changes: 29 additions & 2 deletions examples/server.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
var http = require('http');
var express = require('express');
var app = express();
var bodyParser = require('body-parser')
var path = require('path');
var fs = require('fs');
var gm = require('gm').subClass({imageMagick: true});
var FroalaEditor = require('../lib/froalaEditor.js');
var Collaborative = FroalaEditor.Collaborative;
var CollabPersistence = FroalaEditor.CollabPersistence;

// Permissive CORS for the dev environment so the editor's webpack dev server
// (port 8001) can hit the SDK's REST endpoints (port 3000).
app.use(function (req, res, next) {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE,OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});

app.use(express.static(__dirname + '/'));
app.use('/bower_components', express.static(path.join(__dirname, '../bower_components')));
app.use(express.json());
app.use(bodyParser.urlencoded({ extended: false }));

// Attach suggestion + comment persistence routes.
CollabPersistence.attachRoutes(app, { dbPath: path.join(__dirname, '..', 'collab.db') });

app.get('/', function(req, res) {
res.sendFile(__dirname + '/index.html');
});
Expand Down Expand Up @@ -194,6 +211,16 @@ if (!fs.existsSync(filesDir)){
fs.mkdirSync(filesDir);
}

app.listen(3000, function () {
console.log('Example app listening on port 3000!');
// Health endpoint — reports live room/client counts from the relay.
app.get('/health', function (req, res) {
res.json(Collaborative.getStats());
});

// Wrap Express in a plain HTTP server so the WebSocket relay can share the port.
// Clients connect to: ws://localhost:3000/<roomName>
var server = http.createServer(app);
Collaborative.attachToServer(server);

server.listen(3000, function () {
console.log('Example app + collaborative relay listening on port 3000');
});
234 changes: 234 additions & 0 deletions lib/collab_persistence.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
'use strict';

// ─── Collab persistence (suggestions + comments) ─────────────────────────────
//
// Plain-JSON REST endpoints backed by SQLite for local dev. The Node SDK has
// anchor positions are stored as opaque JSON arrays
// and never interpreted on the server.
//
// Tables (auto-created on first call to `attachRoutes`):
// suggestions(id, room, type, author_id, author_name, timestamp,
// original_text, suggested_text, anchor_start, anchor_end, status)
// comments(id, room, author_id, author_name, timestamp, text,
// anchor_start, anchor_end, resolved, replies)

let _db = null;

function _getDb(dbPath) {
if (_db) return _db;

let Database;
try {
Database = require('better-sqlite3');
} catch (err) {
throw new Error(
'[collab_persistence] better-sqlite3 is required. ' +
'Install it as a dev dependency: npm install --save-dev better-sqlite3'
);
}

_db = new Database(dbPath || 'collab.db');
_db.pragma('journal_mode = WAL');

_db.exec(`
CREATE TABLE IF NOT EXISTS suggestions (
id TEXT PRIMARY KEY,
room TEXT NOT NULL,
type TEXT NOT NULL,
author_id TEXT,
author_name TEXT,
timestamp INTEGER,
original_text TEXT,
suggested_text TEXT,
anchor_start TEXT,
anchor_end TEXT,
status TEXT DEFAULT 'pending'
);

CREATE INDEX IF NOT EXISTS idx_suggestions_room ON suggestions(room);

CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
room TEXT NOT NULL,
author_id TEXT,
author_name TEXT,
timestamp INTEGER,
text TEXT,
anchor_start TEXT,
anchor_end TEXT,
resolved INTEGER DEFAULT 0,
replies TEXT DEFAULT '[]'
);

CREATE INDEX IF NOT EXISTS idx_comments_room ON comments(room);
`);

return _db;
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

function _stringifyAnchor(anchor) {
if (anchor == null) return null;
return typeof anchor === 'string' ? anchor : JSON.stringify(anchor);
}

function _ok(res, body, code) {
res.status(code || 200).json(body);
}

function _bad(res, code, msg) {
res.status(code).json({ error: msg });
}

// ─── Route registration ─────────────────────────────────────────────────────

/**
* Attach the suggestion + comment REST endpoints to an Express app.
*
* @param {object} app Express app instance
* @param {object} [options]
* @param {string} [options.dbPath='collab.db'] SQLite file path
*/
function attachRoutes(app, options) {
options = options || {};
const db = _getDb(options.dbPath);

// ── Suggestions ──────────────────────────────────────────────────────────

app.get('/collab/:room/suggestions', (req, res) => {
try {
const rows = db
.prepare('SELECT * FROM suggestions WHERE room = ? ORDER BY timestamp ASC')
.all(req.params.room);
_ok(res, rows);
} catch (err) {
_bad(res, 500, err.message);
}
});

app.post('/collab/:room/suggestions', (req, res) => {
const s = req.body || {};
if (!s.id || !s.type) return _bad(res, 400, 'id and type are required');

try {
db.prepare(`
INSERT OR IGNORE INTO suggestions
(id, room, type, author_id, author_name, timestamp,
original_text, suggested_text, anchor_start, anchor_end, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
s.id,
req.params.room,
s.type,
s.authorId || null,
s.authorName || null,
s.timestamp || Date.now(),
s.originalText == null ? null : s.originalText,
s.suggestedText == null ? null : s.suggestedText,
_stringifyAnchor(s.anchor && s.anchor.start),
_stringifyAnchor(s.anchor && s.anchor.end),
s.status || 'pending'
);
_ok(res, { id: s.id }, 201);
} catch (err) {
_bad(res, 500, err.message);
}
});

app.patch('/collab/:room/suggestions/:id', (req, res) => {
const status = req.body && req.body.status;
if (!status || !['pending', 'accepted', 'rejected'].includes(status)) {
return _bad(res, 400, 'invalid status');
}
try {
db.prepare(`
UPDATE suggestions SET status = ?
WHERE id = ? AND room = ?
`).run(status, req.params.id, req.params.room);
_ok(res, { id: req.params.id, status });
} catch (err) {
_bad(res, 500, err.message);
}
});

app.delete('/collab/:room/suggestions/:id', (req, res) => {
try {
db.prepare('DELETE FROM suggestions WHERE id = ? AND room = ?')
.run(req.params.id, req.params.room);
res.sendStatus(204);
} catch (err) {
_bad(res, 500, err.message);
}
});

// ── Comments ─────────────────────────────────────────────────────────────

app.get('/collab/:room/comments', (req, res) => {
try {
const rows = db
.prepare('SELECT * FROM comments WHERE room = ? ORDER BY timestamp ASC')
.all(req.params.room);
_ok(res, rows);
} catch (err) {
_bad(res, 500, err.message);
}
});

app.post('/collab/:room/comments', (req, res) => {
const c = req.body || {};
if (!c.id) return _bad(res, 400, 'id is required');

try {
db.prepare(`
INSERT OR IGNORE INTO comments
(id, room, author_id, author_name, timestamp, text,
anchor_start, anchor_end, resolved, replies)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
c.id,
req.params.room,
c.authorId || null,
c.authorName || null,
c.timestamp || Date.now(),
c.text || '',
_stringifyAnchor(c.anchor && c.anchor.start),
_stringifyAnchor(c.anchor && c.anchor.end),
c.resolved ? 1 : 0,
JSON.stringify(c.replies || [])
);
_ok(res, { id: c.id }, 201);
} catch (err) {
_bad(res, 500, err.message);
}
});

app.patch('/collab/:room/comments/:id', (req, res) => {
const body = req.body || {};
try {
if ('resolved' in body) {
db.prepare('UPDATE comments SET resolved = ? WHERE id = ? AND room = ?')
.run(body.resolved ? 1 : 0, req.params.id, req.params.room);
}
if ('replies' in body) {
db.prepare('UPDATE comments SET replies = ? WHERE id = ? AND room = ?')
.run(JSON.stringify(body.replies || []), req.params.id, req.params.room);
}
_ok(res, { id: req.params.id });
} catch (err) {
_bad(res, 500, err.message);
}
});

app.delete('/collab/:room/comments/:id', (req, res) => {
try {
db.prepare('DELETE FROM comments WHERE id = ? AND room = ?')
.run(req.params.id, req.params.room);
res.sendStatus(204);
} catch (err) {
_bad(res, 500, err.message);
}
});
}

exports.CollabPersistence = { attachRoutes };
102 changes: 102 additions & 0 deletions lib/collaborative.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use strict';

const WebSocket = require('ws');

// ─── Structured logger ────────────────────────────────────────────────────────

function log(level, msg, extra) {
// eslint-disable-next-line no-console
console.log(JSON.stringify({ ts: new Date().toISOString(), level, msg, ...extra }));
}

// ─── Room registry ────────────────────────────────────────────────────────────
//
// The server is a pure relay: it groups connections by room and broadcasts
// every incoming message to the other members of that room. All collaboration
// logic lives in the browser clients.
//
// Trade-off: a client that joins an empty room receives no historical state
// until another peer reconnects. For an in-memory relay this is acceptable;
// durable state (Redis, LevelDB, etc.) would belong here if persistence is needed.

const rooms = new Map(); // roomName → Set<WebSocket>

// ─── Core connection handler ──────────────────────────────────────────────────

function setupWSConnection(conn, req) {
const roomName = decodeURIComponent(
(req.url || '/').replace(/^\//, '').split('?')[0] || 'default-room'
);

if (!rooms.has(roomName)) rooms.set(roomName, new Set());
const room = rooms.get(roomName);
room.add(conn);

log('info', 'client connected', { room: roomName, peers: room.size });

conn.on('message', (message) => {
// Relay to every other peer in the room — no parsing, no protocol knowledge.
for (const peer of room) {
if (peer !== conn && peer.readyState === WebSocket.OPEN) {
peer.send(message);
}
}
});

conn.on('close', () => {
room.delete(conn);
if (room.size === 0) rooms.delete(roomName);
log('info', 'client disconnected', { room: roomName, peers: room.size });
});

conn.on('error', (err) => {
log('error', 'socket error', { room: roomName, error: err.message });
room.delete(conn);
if (room.size === 0) rooms.delete(roomName);
});
}

// ─── Stats (for /health) ──────────────────────────────────────────────────────

function getStats() {
let clients = 0;
rooms.forEach(room => { clients += room.size; });
return { rooms: rooms.size, clients };
}

// ─── Standalone server ────────────────────────────────────────────────────────

function createServer(options = {}) {
const http = require('http');

const httpServer = http.createServer((req, res) => {
if (req.url === '/health' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(getStats()));
return;
}
res.writeHead(426, { 'Content-Type': 'text/plain' });
res.end('WebSocket connections only');
});

const wss = new WebSocket.Server({ server: httpServer });
wss.on('connection', setupWSConnection);

const port = options.port || 3000;
httpServer.listen(port, () => {
log('info', 'collaborative relay server started', { port });
});

return wss;
}

// ─── Attach to existing HTTP / Express server ─────────────────────────────────

function attachToServer(httpServer) {
const wss = new WebSocket.Server({ server: httpServer });
wss.on('connection', setupWSConnection);
log('info', 'collaborative relay server attached to existing HTTP server');
return wss;
}

exports.Collaborative = { setupWSConnection, createServer, attachToServer, getStats };
Loading