-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathyjs-server.js
More file actions
267 lines (239 loc) · 10.6 KB
/
Copy pathyjs-server.js
File metadata and controls
267 lines (239 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
#!/usr/bin/env node
/**
* Hocuspocus WebSocket Server for collaborative editing
* Run this server to enable real-time collaboration with Yjs
*
* Usage: node yjs-server.js [port]
* Default port: 1234
*
* Auth model (see onAuthenticate below):
* - `internal:<INTERNAL_AGENT_SECRET>` — trusted server-to-server writer
* (used by src/lib/server-yjs-writer.ts on the agent side).
* - `share:<sessionId>:<shareToken>` — unauthenticated collaborator on a
* public session. Grants access only if the sessionId in the token
* matches the room's sessionId AND the session is `isPublic` in DB.
* - Anything else — treated as a Clerk session JWT. Verified with
* @clerk/backend, then checked against the room's session owner.
*
* Env vars:
* PORT / YJS_PORT (default 1234)
* DATABASE_URL (required — session ownership lookups)
* CLERK_SECRET_KEY (required — JWT verification)
* INTERNAL_AGENT_SECRET (required — trusted writer bypass)
* YJS_MAX_PAYLOAD_MB (optional, default 4 — down from 100)
* YJS_MAX_DOC_BYTES (optional, default 5_000_000 — cap per doc)
*/
import { Server } from '@hocuspocus/server';
import { verifyToken } from '@clerk/backend';
import * as Y from 'yjs';
import pg from 'pg';
const port = process.env.PORT || process.env.YJS_PORT || process.argv[2] || 1234;
// Cap payload size hard. The previous 100MB ceiling let a single client OOM
// the WS server; typical Monaco updates are <10KB, so 4MB is generous.
const MAX_PAYLOAD_BYTES = Number(process.env.YJS_MAX_PAYLOAD_MB ?? '4') * 1024 * 1024;
// Per-doc byte cap — reject onChange results that would push us past this.
// Y.encodeStateAsUpdate is bytes-in-the-CRDT, so a runaway paste-in-a-loop
// tops out here rather than growing without bound.
const MAX_DOC_BYTES = Number(process.env.YJS_MAX_DOC_BYTES ?? '5000000');
// Minimal Postgres pool — Prisma isn't reachable from an ESM script without
// generating the client, and Hocuspocus wants a plain node process. One
// SELECT per authenticate() call keyed on the covered @@index([id]).
const pool = process.env.DATABASE_URL
? new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 4 })
: null;
if (!pool) {
console.warn(
'[Hocuspocus] DATABASE_URL missing — session-ownership checks will fail-closed. ' +
'Set DATABASE_URL to the same connection string Next.js uses.',
);
}
async function loadSession(sessionId) {
if (!pool) return null;
try {
const res = await pool.query(
'SELECT "userId", "isPublic", "shareToken" FROM "Session" WHERE "id" = $1',
[sessionId],
);
return res.rows[0] ?? null;
} catch (err) {
console.error('[Hocuspocus] session lookup failed:', err.message);
return null;
}
}
// Parse a Yjs room name into (sessionId, subKey). Rooms are
// "${sessionId}-${filePath}" or "${sessionId}-__session".
//
// sessionId is either:
// - a UUIDv4 (36 chars, hyphens at 8/13/18/23) — crypto.randomUUID()
// - a cuid (25+ chars, no hyphens) — legacy Prisma @default(cuid())
//
// Because UUIDs contain hyphens, a naive split on the first hyphen gives
// only the leading 8-char chunk. We match a UUID prefix explicitly, then
// fall back to "prefix before the first hyphen" for cuid-shaped ids.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
function parseRoom(name) {
const uuidMatch = name.match(UUID_RE);
if (uuidMatch) {
const sessionId = uuidMatch[0];
// room shape is `${sessionId}-${subKey}`; the char after the match must
// be the separating hyphen for it to be a valid room name.
if (name[sessionId.length] !== '-') return { sessionId: null, subKey: null };
return { sessionId, subKey: name.slice(sessionId.length + 1) };
}
// cuid path: no hyphens in the id itself, so split on the first hyphen.
const idx = name.indexOf('-');
if (idx < 20) return { sessionId: null, subKey: null };
return { sessionId: name.slice(0, idx), subKey: name.slice(idx + 1) };
}
async function verifyClerkJwt(token) {
const secretKey = process.env.CLERK_SECRET_KEY;
if (!secretKey) throw new Error('CLERK_SECRET_KEY missing');
const claims = await verifyToken(token, { secretKey });
if (!claims.sub) throw new Error('Token has no subject');
return claims.sub;
}
// In-memory persistence. Hocuspocus unloads docs after a short debounce when
// no clients are connected — without an onLoadDocument that returns prior
// state, content is lost between the agent's write (writeToYjsRoom connects,
// transacts, disconnects) and the moment the user opens that file in their
// editor. We hold the encoded Y state per room so unloaded docs come back
// with their content. Process restart still wipes — swap for SQLite or
// Postgres extension when durability is needed.
const docStore = new Map();
const server = new Server({
port: Number(port),
quiet: false,
maxPayload: MAX_PAYLOAD_BYTES,
async onLoadDocument({ documentName, document }) {
const stored = docStore.get(documentName);
if (stored) {
Y.applyUpdate(document, stored);
console.log(`[Hocuspocus] Restored ${stored.byteLength} bytes for ${documentName}`);
} else {
console.log(`[Hocuspocus] Document loaded (empty): ${documentName}`);
}
return document;
},
async onStoreDocument({ documentName, document }) {
const bytes = Y.encodeStateAsUpdate(document);
if (bytes.byteLength > MAX_DOC_BYTES) {
// Refuse to persist a doc that would push us past the cap. The delta
// that got us here is still in the running Y.Doc, but we won't save
// it — the next unload cycle drops the ballooning content. Log
// loudly so this shows up in monitoring.
console.warn(
`[Hocuspocus] Doc ${documentName} exceeds ${MAX_DOC_BYTES} bytes (${bytes.byteLength}) — refusing to persist.`,
);
return;
}
docStore.set(documentName, bytes);
console.log(`[Hocuspocus] Document stored: ${documentName} (${bytes.byteLength} bytes)`);
},
async onAuthenticate({ documentName, token }) {
if (!token) {
console.warn(`[Hocuspocus] auth denied: missing_token (doc=${documentName})`);
throw new Error('missing_token');
}
// 1. Server-to-server: the agent's Yjs writer uses this scheme so it
// can push codePatch results into rooms without owning a Clerk JWT.
const internalSecret = process.env.INTERNAL_AGENT_SECRET;
if (internalSecret && token === `internal:${internalSecret}`) {
return { user: { id: '__internal__', name: 'Internal Writer' } };
}
const { sessionId } = parseRoom(documentName);
if (!sessionId) {
console.warn(`[Hocuspocus] auth denied: bad_room_name (doc=${documentName})`);
throw new Error('bad_room_name');
}
// 2. Share-token collaborators (may be signed out entirely). Format:
// `share:<sessionId>:<shareToken>`. Sanity-checks the shareToken
// against Postgres AND requires isPublic.
if (token.startsWith('share:')) {
const [, tokSessionId, tokShareToken] = token.split(':');
if (tokSessionId !== sessionId) {
console.warn(`[Hocuspocus] auth denied: room_session_mismatch (doc=${documentName})`);
throw new Error('room_session_mismatch');
}
const session = await loadSession(sessionId);
if (!session) {
console.warn(`[Hocuspocus] auth denied: session_not_found (sessionId=${sessionId})`);
throw new Error('session_not_found');
}
if (!session.isPublic) {
console.warn(`[Hocuspocus] auth denied: session_private (sessionId=${sessionId})`);
throw new Error('session_private');
}
if (session.shareToken !== tokShareToken) {
console.warn(`[Hocuspocus] auth denied: bad_share_token (sessionId=${sessionId})`);
throw new Error('bad_share_token');
}
return { user: { id: `share:${tokShareToken.slice(0, 8)}`, name: 'Collaborator' } };
}
// 3. Clerk JWT — signed-in owner (or, when we later add ACLs, an
// explicit collaborator). Verify the token, load the session,
// require ownership.
let userId;
try {
userId = await verifyClerkJwt(token);
} catch (err) {
console.warn(`[Hocuspocus] auth denied: bad_jwt (doc=${documentName}) ${err.message}`);
throw new Error(`bad_jwt: ${err.message}`);
}
const session = await loadSession(sessionId);
if (!session) {
console.warn(`[Hocuspocus] auth denied: session_not_found (sessionId=${sessionId} userId=${userId})`);
throw new Error('session_not_found');
}
if (session.userId !== userId) {
// Not the owner — fall back to public + share-token would have been
// caught above. Deny.
console.warn(`[Hocuspocus] auth denied: not_session_owner (sessionId=${sessionId} userId=${userId} ownerId=${session.userId})`);
throw new Error('not_session_owner');
}
return { user: { id: userId, name: 'Owner' } };
},
// Plain HTTP /health endpoint for Render health checks + the homepage
// warmup ping. Hocuspocus' onRequest contract: throw a FALSY value to
// short-circuit the default "Welcome to Hocuspocus!" response. Throwing
// a truthy value (Error or non-empty string) rethrows and crashes the
// process under Node 20's strict unhandled-rejection mode.
// See: node_modules/@hocuspocus/server/src/Server.ts:118-135
async onRequest({ request, response }) {
if (request.url === '/health') {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ ok: true, docs: docStore.size }));
throw '';
}
},
onConnect: (data) => {
console.log(`[Hocuspocus] ✅ Client connected to document: ${data.documentName}`);
},
onDisconnect: (data) => {
console.log(`[Hocuspocus] Client disconnected from: ${data.documentName}`);
},
onChange: (data) => {
console.log(`[Hocuspocus] Document changed: ${data.documentName}`);
},
onStateless: (data) => {
console.log(`[Hocuspocus] Received stateless message for: ${data.documentName}`);
},
});
await server.listen();
console.log(`🚀 Hocuspocus server running on ws://localhost:${port}`);
console.log(' Ready for collaborative editing!');
console.log(' Press Ctrl+C to stop');
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\n[Hocuspocus] Shutting down server...');
await server.destroy();
if (pool) await pool.end();
console.log('[Hocuspocus] Server closed');
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('\n[Hocuspocus] Shutting down server...');
await server.destroy();
if (pool) await pool.end();
console.log('[Hocuspocus] Server closed');
process.exit(0);
});