|
| 1 | +import { Server } from '@hocuspocus/server' |
| 2 | +import { once } from 'node:events' |
| 3 | +import fs from 'node:fs/promises' |
| 4 | +import path from 'node:path' |
| 5 | + |
| 6 | +// -- CLI -- |
| 7 | +if (process.argv.length !== 4) { |
| 8 | + console.error('Usage: node server.ts <socketPath> <projectDir>') |
| 9 | + process.exit(1) |
| 10 | +} |
| 11 | + |
| 12 | +const socketPath = process.argv[2] |
| 13 | +const projectDir = process.argv[3] |
| 14 | + |
| 15 | +// -- YJS FILE MANAGEMENT -- |
| 16 | +const YTEXT_KEY = 'content' |
| 17 | + |
| 18 | +function toDiskPath(documentName: string): string { |
| 19 | + const joined = path.join(projectDir, documentName) |
| 20 | + if (!joined.startsWith(projectDir)) { |
| 21 | + throw new Error(`Path traversal in document name: ${documentName}`) |
| 22 | + } |
| 23 | + return joined |
| 24 | +} |
| 25 | + |
| 26 | +// -- HTTPS/WS SERVER -- |
| 27 | +const server = new Server({ |
| 28 | + async onLoadDocument(data) { |
| 29 | + const ytext = data.document.getText(YTEXT_KEY) |
| 30 | + if (ytext.length > 0) return |
| 31 | + try { |
| 32 | + const content = await fs.readFile(toDiskPath(data.documentName), 'utf-8') |
| 33 | + ytext.insert(0, content) |
| 34 | + } catch (e) { |
| 35 | + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e |
| 36 | + } |
| 37 | + }, |
| 38 | + async onStoreDocument(data) { |
| 39 | + console.log('onstore', data.documentName, data.clientsCount) |
| 40 | + // TODO: this runs more or less whenever someone edits something. |
| 41 | + // - We want better saving semantics: |
| 42 | + // when *any user* saves, the file is persisted to disk, |
| 43 | + // but not otherwise. |
| 44 | + // - Dirty state should be reflected for all users: |
| 45 | + // a boolean in Y.Doc is one option, |
| 46 | + // whereas Claude recommends storing mtime (or such) in Y.Doc |
| 47 | + // and computing dirty state from that. |
| 48 | + // - We should persist Y.Docs to a (in-memory or on-disk) database; not the project dir. |
| 49 | + const file = toDiskPath(data.documentName) |
| 50 | + const ytext = data.document.getText(YTEXT_KEY) |
| 51 | + await fs.mkdir(path.dirname(file), { recursive: true }) |
| 52 | + await fs.writeFile(file, ytext.toString(), 'utf-8') |
| 53 | + }, |
| 54 | +}) |
| 55 | + |
| 56 | +// `server.listen` exposes a port. We use a socket which needs direct `httpServer` access. |
| 57 | +server.httpServer.listen(socketPath, () => { |
| 58 | + // Cosmetic monkey-patches to display the correct start screen. Server works regardless of these. |
| 59 | + Object.defineProperty(server, 'webSocketURL', { |
| 60 | + get: () => `ws+unix:${socketPath}`, |
| 61 | + }) |
| 62 | + Object.defineProperty(server, 'httpURL', { |
| 63 | + get: () => `http+unix:${socketPath}`, |
| 64 | + }) |
| 65 | + server['showStartScreen']() |
| 66 | + |
| 67 | + // No need to call `onListen` hooks here since we don't register any. |
| 68 | +}) |
| 69 | + |
| 70 | +await Promise.race([once(process, 'SIGINT'), once(process, 'SIGQUIT'), once(process, 'SIGTERM')]) |
| 71 | +console.log('Hocuspocus shutting down..') |
| 72 | +await server.destroy() |
| 73 | + |
| 74 | +// TODO: ensure writes are flushed to disk. |
0 commit comments