|
| 1 | +import { Database } from '@hocuspocus/extension-database' |
| 2 | +import { Server } from '@hocuspocus/server' |
| 3 | +import { once } from 'node:events' |
| 4 | +import fs from 'node:fs/promises' |
| 5 | +import path from 'node:path' |
| 6 | +import { DatabaseSync } from 'node:sqlite' |
| 7 | +import * as Y from 'yjs' |
| 8 | + |
| 9 | +// -- CLI -- |
| 10 | +if (process.argv.length !== 3) { |
| 11 | + console.error('Usage: node server.js <projectDir>') |
| 12 | + process.exit(1) |
| 13 | +} |
| 14 | + |
| 15 | +const projectDir = process.argv[2] |
| 16 | +const socketPath = path.join(process.cwd(), 'collab.sock') |
| 17 | +const dbPath = path.join(process.cwd(), 'collab.db') |
| 18 | + |
| 19 | +// -- DB -- |
| 20 | +const db = new DatabaseSync(dbPath) |
| 21 | +db.exec('CREATE TABLE IF NOT EXISTS document (path TEXT PRIMARY KEY NOT NULL, data BLOB NOT NULL)') |
| 22 | + |
| 23 | +const selectDocumentStatement = db.prepare('SELECT data FROM document WHERE path = ?') |
| 24 | +const selectDocument = (path: string): Uint8Array | undefined => |
| 25 | + (selectDocumentStatement.get(path) as { data: Uint8Array } | undefined)?.data |
| 26 | +const upsertDocumentStatement = db.prepare( |
| 27 | + 'INSERT INTO document (path, data) VALUES (?, ?) ON CONFLICT(path) DO UPDATE SET data = excluded.data', |
| 28 | +) |
| 29 | +const upsertDocument = (path: string, data: Uint8Array): void => { |
| 30 | + upsertDocumentStatement.run(path, data) |
| 31 | +} |
| 32 | + |
| 33 | +// -- YJS FILE MANAGEMENT -- |
| 34 | +// TODO: use const imported from single-source-of-truth module |
| 35 | +const YTEXT_KEY = 'content' |
| 36 | + |
| 37 | +function checkedToDiskPath(documentName: string): string { |
| 38 | + const file = path.normalize(documentName) |
| 39 | + if (!file.startsWith(projectDir)) { |
| 40 | + throw new Error(`Path traversal in document name: '${documentName}' escapes '${projectDir}'`) |
| 41 | + } |
| 42 | + return file |
| 43 | +} |
| 44 | + |
| 45 | +// -- HTTPS/WS SERVER -- |
| 46 | +const server = new Server({ |
| 47 | + extensions: [ |
| 48 | + // Note: we can't use the SQLite extension. |
| 49 | + // Its onLoadDocument would be called after ours, |
| 50 | + // but we want to try it *before* trying the filesystem. |
| 51 | + new Database({ |
| 52 | + async fetch({ documentName }) { |
| 53 | + const data = selectDocument(documentName) |
| 54 | + if (data) return data |
| 55 | + let content: string |
| 56 | + try { |
| 57 | + content = await fs.readFile(checkedToDiskPath(documentName), 'utf-8') |
| 58 | + } catch { |
| 59 | + return null |
| 60 | + } |
| 61 | + const doc = new Y.Doc() |
| 62 | + doc.getText(YTEXT_KEY).insert(0, content) |
| 63 | + return Y.encodeStateAsUpdate(doc) |
| 64 | + }, |
| 65 | + async store({ documentName, state }) { |
| 66 | + upsertDocument(documentName, state) |
| 67 | + }, |
| 68 | + }), |
| 69 | + ], |
| 70 | +}) |
| 71 | + |
| 72 | +// TODO: listen for fs events to avoid lost writes. |
| 73 | +// VSCs could inform the server about which saves came from them, |
| 74 | +// as opposed to other processes (e.g. CLI tools). |
| 75 | +// Non-VSC edits could be applied to the Y.Doc as whole-file replacements. |
| 76 | + |
| 77 | +// `server.listen` exposes a port. We use a socket which needs direct `httpServer` access. |
| 78 | +server.httpServer.listen(socketPath, () => { |
| 79 | + // Cosmetic monkey-patches to display the correct start screen. Server works regardless of these. |
| 80 | + Object.defineProperty(server, 'webSocketURL', { |
| 81 | + get: () => `ws+unix:${socketPath}`, |
| 82 | + }) |
| 83 | + Object.defineProperty(server, 'httpURL', { |
| 84 | + get: () => `http+unix:${socketPath}`, |
| 85 | + }) |
| 86 | + server['showStartScreen']() |
| 87 | + |
| 88 | + // No need to call `onListen` hooks here since we don't register any. |
| 89 | +}) |
| 90 | + |
| 91 | +await Promise.race([once(process, 'SIGINT'), once(process, 'SIGQUIT'), once(process, 'SIGTERM')]) |
| 92 | +console.log('Hocuspocus shutting down..') |
| 93 | +await server.destroy() |
| 94 | +db.close() |
| 95 | + |
| 96 | +// TODO: ensure writes are flushed to disk. |
0 commit comments