Skip to content

Commit ba72663

Browse files
authored
feat(collab-doc): server-authoritative Yjs seeding (#6008)
Server-authoritative Yjs seeding for collaborative file documents: the realtime relay fetches a Yjs seed built from the file's markdown (via the shared TipTap engine) and applies it once per room, replacing the client-seeder election/handshake entirely. - DOM-free markdown<->Yjs conversion core (markdownToYDoc / yDocToMarkdown / applyMarkdownToYDoc) reusing the client markdown engine for parity by construction - Internal x-api-key seed endpoint + realtime fetch; single attempt bounded under the client readiness deadline, guard-release for join-driven retry, read-only fallback on persistent failure - Client readiness gate = synced && server seed flag; jsdom wired for the Next standalone build (serverExternalPackages + outputFileTracingIncludes) Foundation only — copilot-into-doc + markdown projection + durable persistence are Stage C.
1 parent 06ecb6e commit ba72663

23 files changed

Lines changed: 962 additions & 387 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { env, getBaseUrl } from '@/env'
2+
3+
/**
4+
* Bound the seed fetch so a slow/unreachable app never wedges a room's first join. Kept comfortably
5+
* BELOW the client's readiness deadline (`READINESS_DEADLINE_MS`, 12s, in `file-doc-provider.ts`), so
6+
* a single attempt either lands or fails within the window the client is willing to wait — otherwise
7+
* the client gives up first and a late success can't reach it. Generous for a real seed: collab is
8+
* gated client-side to ≤256 KB documents (`isRoundTripSafe`), which convert markdown→Yjs in well
9+
* under a second; the 5 MB server cap is only a backstop and is never reached for a collab file.
10+
*/
11+
const SEED_FETCH_TIMEOUT_MS = 8_000
12+
13+
/**
14+
* Ask the app to build a server-authoritative seed (markdown → Yjs) for a file's collaborative
15+
* document. Only the app can — it owns the editor extensions + blob access — so the relay delegates
16+
* over the internal endpoint.
17+
*
18+
* Returns the Yjs update to apply, or `null` for a genuinely empty/missing file (an empty document
19+
* is correct). THROWS on a transport failure (non-2xx / network / timeout) so the caller can tell a
20+
* real empty from a failure it should be allowed to retry.
21+
*/
22+
export async function fetchFileDocSeed(
23+
workspaceId: string,
24+
fileId: string
25+
): Promise<Uint8Array | null> {
26+
const response = await fetch(`${getBaseUrl()}/api/internal/file-doc/seed`, {
27+
method: 'POST',
28+
headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET },
29+
body: JSON.stringify({ workspaceId, fileId }),
30+
signal: AbortSignal.timeout(SEED_FETCH_TIMEOUT_MS),
31+
})
32+
if (!response.ok) {
33+
throw new Error(`Seed fetch failed for file ${fileId}: ${response.status}`)
34+
}
35+
const body = (await response.json()) as { update?: unknown }
36+
const update = body?.update
37+
// A well-formed response is `{ update: base64-string | null }`. Anything else is a contract
38+
// violation, not a "genuinely empty file" — throw so the caller retries rather than silently
39+
// treating a malformed body as empty and stranding the room unseeded.
40+
if (update === null) return null
41+
if (typeof update !== 'string') {
42+
throw new Error(`Seed fetch for file ${fileId} returned a malformed body`)
43+
}
44+
return new Uint8Array(Buffer.from(update, 'base64'))
45+
}

apps/realtime/src/handlers/file-doc.test.ts

Lines changed: 191 additions & 139 deletions
Large diffs are not rendered by default.

apps/realtime/src/handlers/file-doc.ts

Lines changed: 43 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -36,30 +36,13 @@ import * as awarenessProtocol from 'y-protocols/awareness'
3636
import * as syncProtocol from 'y-protocols/sync'
3737
import * as Y from 'yjs'
3838
import { resolveAvatarUrl } from '@/handlers/avatar'
39+
import { fetchFileDocSeed } from '@/handlers/file-doc-seed'
3940
import { resolveRoomJoinAuth } from '@/handlers/room-join-auth'
4041
import type { AuthenticatedSocket } from '@/middleware/auth'
4142
import type { IRoomManager } from '@/rooms'
4243

4344
const logger = createLogger('FileDocHandlers')
4445

45-
/**
46-
* How long an elected seeder has to import the initial content before the server
47-
* re-elects another client. Seeding is a local, synchronous editor write, so this
48-
* only trips when a seeder never completes it (a bug, or a client withholding the
49-
* seed) — it keeps the document from staying empty for the rest of the room.
50-
*/
51-
const SEED_DEADLINE_MS = 10_000
52-
53-
/**
54-
* Max times the room re-offers seeding to its owners after every one of them has
55-
* missed a deadline. Without this, a sole client whose seed *fetch fails* (not just
56-
* slow) is added to `triedSeeders`, re-election finds no un-tried owner, and the
57-
* document is left permanently empty until that client reloads. Each exhausted round
58-
* clears `triedSeeders` and re-offers, giving a transient failure a bounded number of
59-
* retries (~MAX × deadline) before the room genuinely gives up.
60-
*/
61-
const MAX_SEED_ROUNDS = 3
62-
6346
/** A socket's presence ownership within a room. */
6447
interface FileDocOwner {
6548
/**
@@ -78,23 +61,15 @@ interface FileDocOwner {
7861
}
7962

8063
interface FileDocRoom {
81-
/** The `workspace_files.id` this room edits (for seed-request payloads). */
64+
/** The `workspace_files.id` this room edits. */
8265
fileId: string
8366
doc: Y.Doc
8467
awareness: awarenessProtocol.Awareness
8568
/** socketId → its presence ownership. */
8669
owners: Map<string, FileDocOwner>
87-
/** The socket currently elected to seed initial content, or `null`. */
88-
seederSocketId: string | null
89-
/** Deadline timer for the current seeder to complete the seed, or `null`. */
90-
seedTimer: ReturnType<typeof setTimeout> | null
91-
/** Sockets that were elected but failed to seed within the deadline; skipped
92-
* on re-election so a single stuck/withholding client can't block the room. */
93-
triedSeeders: Set<string>
94-
/** Count of full re-offer rounds spent after every owner missed a deadline;
95-
* bounds the retry loop (see {@link MAX_SEED_ROUNDS}) so a permanently-broken
96-
* seed doesn't reset forever. */
97-
seedRounds: number
70+
/** True once the server-side seed fetch has started, so concurrent joins don't each fetch.
71+
* Reset on a fetch FAILURE so a later join can retry (a genuinely empty file stays empty). */
72+
serverSeedStarted: boolean
9873
}
9974

10075
/** Live documents keyed by Socket.IO room name. Module-global: one Y.Doc per file. */
@@ -183,73 +158,51 @@ function awarenessUpdateClientIds(update: Uint8Array): number[] {
183158
return ids
184159
}
185160

186-
function clearSeedTimer(room: FileDocRoom) {
187-
if (room.seedTimer !== null) {
188-
clearTimeout(room.seedTimer)
189-
room.seedTimer = null
190-
}
191-
}
192-
193161
/**
194-
* Drop a room's document + awareness (and its seed timer) once it has no owners,
195-
* so an idle file holds no memory. A later joiner re-creates and re-seeds it.
162+
* Drop a room's document + awareness once it has no owners, so an idle file holds no memory.
163+
* A later joiner re-creates and re-seeds it from the file's current markdown.
196164
*/
197165
function destroyRoomIfIdle(name: string) {
198166
const room = fileDocRooms.get(name)
199167
if (!room || room.owners.size > 0) return
200-
clearSeedTimer(room)
201168
room.awareness.destroy()
202169
room.doc.destroy()
203170
fileDocRooms.delete(name)
204171
}
205172

206173
/**
207-
* Elect a client to seed an unseeded document and ask it to import the stored
208-
* markdown, if one is needed and not already assigned. Called after a join (to
209-
* elect the newcomer), after the elected seeder leaves (to hand the role to a
210-
* remaining client), and after a seed deadline lapses (to pass over a client that
211-
* never seeded). Skips clients that already failed to seed, and arms a deadline so
212-
* a stuck or withholding seeder can't leave the document empty for the whole room.
213-
* A no-op once the document is seeded, a seeder is already assigned, or no
214-
* un-tried client remains.
174+
* Seed a room's document server-side, once, on the first join: ask the app to build the seed (the
175+
* file's current markdown → Yjs, through the exact editor engine) and apply it, which relays the
176+
* content to every connected client via `doc.on('update')`. No client is elected to import content.
177+
*
178+
* `isDocSeeded` is the sufficient guard: content only ever reaches the doc alongside the seed flag
179+
* (this seed, or a client's offline fallback), so an unseeded doc is genuinely empty and safe to seed.
180+
* A genuinely empty/missing file returns `null` (a read error throws instead), so still set the flag —
181+
* an empty doc must reach readiness, not wait forever. After the fetch, re-check the room is still
182+
* live and unseeded (an owner may have left, or a client seeded it, while the fetch was in flight).
183+
*
184+
* Recovery on failure is deliberately simple — no in-room retry loop: a single attempt bounded by a
185+
* timeout shorter than the client's readiness deadline, then release the guard. A transient failure
186+
* is re-attempted by the next join/reconnect; a persistent one lets the connected client's readiness
187+
* deadline lapse into its read-only fallback. (An in-room backoff retry can outlast that client
188+
* deadline, so it would keep trying a doc the client has already given up on — worse, not better.)
215189
*/
216-
function electSeederIfNeeded(io: Server, room: FileDocRoom) {
217-
if (room.seederSocketId !== null || isDocSeeded(room.doc)) return
218-
219-
let elected: string | null = null
220-
for (const socketId of room.owners.keys()) {
221-
if (!room.triedSeeders.has(socketId)) {
222-
elected = socketId
223-
break
224-
}
190+
async function ensureServerSeed(
191+
name: string,
192+
room: FileDocRoom,
193+
workspaceId: string
194+
): Promise<void> {
195+
if (room.serverSeedStarted || isDocSeeded(room.doc)) return
196+
room.serverSeedStarted = true
197+
try {
198+
const update = await fetchFileDocSeed(workspaceId, room.fileId)
199+
if (fileDocRooms.get(name) !== room || isDocSeeded(room.doc)) return
200+
if (update) Y.applyUpdate(room.doc, update)
201+
else room.doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
202+
} catch (error) {
203+
logger.warn(`Server seed failed for file ${room.fileId} (workspace ${workspaceId})`, error)
204+
room.serverSeedStarted = false
225205
}
226-
if (elected === null) return
227-
228-
room.seederSocketId = elected
229-
io.to(elected).emit(FILE_DOC_EVENTS.SEED_REQUEST, { fileId: room.fileId })
230-
231-
clearSeedTimer(room)
232-
room.seedTimer = setTimeout(() => {
233-
room.seedTimer = null
234-
// Only act if this election is still the pending one and it never seeded.
235-
if (room.seederSocketId !== elected || isDocSeeded(room.doc)) return
236-
room.triedSeeders.add(elected)
237-
room.seederSocketId = null
238-
electSeederIfNeeded(io, room)
239-
// If that left no seeder (every current owner has now been tried) while the doc is still
240-
// unseeded and owners remain, re-offer the whole room a bounded number of times — otherwise a
241-
// sole client whose seed fetch failed leaves the document permanently empty.
242-
if (
243-
room.seederSocketId === null &&
244-
!isDocSeeded(room.doc) &&
245-
room.owners.size > 0 &&
246-
room.seedRounds < MAX_SEED_ROUNDS
247-
) {
248-
room.seedRounds += 1
249-
room.triedSeeders.clear()
250-
electSeederIfNeeded(io, room)
251-
}
252-
}, SEED_DEADLINE_MS)
253206
}
254207

255208
/**
@@ -272,16 +225,11 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
272225
doc,
273226
awareness,
274227
owners: new Map(),
275-
seederSocketId: null,
276-
seedTimer: null,
277-
triedSeeders: new Set(),
278-
seedRounds: 0,
228+
serverSeedStarted: false,
279229
}
280230
fileDocRooms.set(name, room)
281231

282232
doc.on('update', (update: Uint8Array, origin: unknown) => {
283-
// Once the document is seeded, the seed deadline is moot — cancel it.
284-
if (room.seedTimer !== null && isDocSeeded(doc)) clearSeedTimer(room)
285233
const encoder = encoding.createEncoder()
286234
encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC)
287235
syncProtocol.writeUpdate(encoder, update)
@@ -370,8 +318,8 @@ function handleMessage(socket: AuthenticatedSocket, data: unknown) {
370318

371319
/**
372320
* Remove a socket from its file-doc room: clear its awareness state (so its caret
373-
* disappears for everyone else), hand off the seeder role if it left before
374-
* seeding, and drop the room's document when the last collaborator leaves.
321+
* disappears for everyone else) and drop the room's document when the last
322+
* collaborator leaves.
375323
* Exported for the disconnect handler; safe to call for a socket in no room.
376324
*/
377325
export function cleanupFileDocForSocket(socketId: string, io: Server, endOfLife = false): void {
@@ -401,13 +349,6 @@ export function cleanupFileDocForSocket(socketId: string, io: Server, endOfLife
401349
broadcastFileDocPresence(io, name, room)
402350
}
403351

404-
// Hand off the seeder role: if the elected seeder left before it seeded, elect
405-
// a remaining client so the document doesn't stay permanently empty.
406-
if (room.seederSocketId === socketId) {
407-
room.seederSocketId = null
408-
electSeederIfNeeded(io, room)
409-
}
410-
411352
destroyRoomIfIdle(name)
412353
}
413354

@@ -519,10 +460,6 @@ export function setupWorkspaceFileDocHandlers(
519460
awarenessProtocol.removeAwarenessStates(entry.awareness, [owner.clientId], null)
520461
socketToRoomName.delete(otherSid)
521462
io.in(otherSid).socketsLeave(name)
522-
// If the evicted socket held the seeder role, release it so the election at the end of
523-
// this join re-elects (electSeederIfNeeded no-ops while seederSocketId is set) — otherwise
524-
// an unseeded document would stay empty until the seed deadline expires.
525-
if (entry.seederSocketId === otherSid) entry.seederSocketId = null
526463
}
527464

528465
// Only now that the rebind is guaranteed to succeed, leave a previously-joined document if
@@ -568,7 +505,9 @@ export function setupWorkspaceFileDocHandlers(
568505
socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(awarenessEncoder))
569506
}
570507

571-
electSeederIfNeeded(io, entry)
508+
// Seed the document server-side (once). Fire-and-forget: the join completes immediately and
509+
// the seed relays to this socket via `doc.on('update')` the moment it lands.
510+
if (authorized.workspaceId) void ensureServerSeed(name, entry, authorized.workspaceId)
572511

573512
logger.info(`User ${userId} joined file-doc room ${fileId}`)
574513
} catch (error) {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { NextResponse } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockCheckInternalApiKey, mockBuildFileDocSeed } = vi.hoisted(() => ({
9+
mockCheckInternalApiKey: vi.fn(),
10+
mockBuildFileDocSeed: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/copilot/request/http', () => ({
14+
checkInternalApiKey: mockCheckInternalApiKey,
15+
createUnauthorizedResponse: () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
16+
}))
17+
18+
vi.mock('@/lib/collab-doc/seed', () => ({
19+
buildFileDocSeed: mockBuildFileDocSeed,
20+
}))
21+
22+
import { POST } from './route'
23+
24+
function seedRequest(body: unknown) {
25+
return createMockRequest('POST', body, { 'x-api-key': 'internal' })
26+
}
27+
28+
describe('POST /api/internal/file-doc/seed', () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks()
31+
mockCheckInternalApiKey.mockReturnValue({ success: true })
32+
})
33+
34+
// Regression guard for the auth-helper choice: the realtime relay authenticates with
35+
// `x-api-key: INTERNAL_API_SECRET`, so this route MUST gate on `checkInternalApiKey`. Wiring the
36+
// Bearer-JWT-only `checkInternalAuth` (which forbids `x-api-key`) 401s every real seed fetch.
37+
it('401s when the internal api key is rejected, without building a seed', async () => {
38+
mockCheckInternalApiKey.mockReturnValue({ success: false })
39+
const res = await POST(seedRequest({ workspaceId: 'ws-1', fileId: 'file-1' }))
40+
expect(res.status).toBe(401)
41+
expect(mockBuildFileDocSeed).not.toHaveBeenCalled()
42+
})
43+
44+
it('returns the seed as base64 for an authorized request', async () => {
45+
mockBuildFileDocSeed.mockResolvedValue({ update: new Uint8Array([1, 2, 3, 4]) })
46+
const res = await POST(seedRequest({ workspaceId: 'ws-1', fileId: 'file-1' }))
47+
expect(res.status).toBe(200)
48+
expect((await res.json()).update).toBe(Buffer.from([1, 2, 3, 4]).toString('base64'))
49+
expect(mockBuildFileDocSeed).toHaveBeenCalledWith('ws-1', 'file-1')
50+
})
51+
52+
it('returns update:null for a genuinely absent file', async () => {
53+
mockBuildFileDocSeed.mockResolvedValue(null)
54+
const res = await POST(seedRequest({ workspaceId: 'ws-1', fileId: 'missing' }))
55+
expect(res.status).toBe(200)
56+
expect((await res.json()).update).toBeNull()
57+
})
58+
59+
it('400s on a body missing required fields (contract validation, after auth)', async () => {
60+
const res = await POST(seedRequest({ workspaceId: 'ws-1' }))
61+
expect(res.status).toBe(400)
62+
expect(mockBuildFileDocSeed).not.toHaveBeenCalled()
63+
})
64+
65+
it('500s when the seed build throws (a read error the relay should retry)', async () => {
66+
mockBuildFileDocSeed.mockRejectedValue(new Error('db down'))
67+
const res = await POST(seedRequest({ workspaceId: 'ws-1', fileId: 'file-1' }))
68+
expect(res.status).toBe(500)
69+
})
70+
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { NextRequest } from 'next/server'
4+
import { NextResponse } from 'next/server'
5+
import { buildFileDocSeedContract } from '@/lib/api/contracts/file-doc'
6+
import { parseRequest } from '@/lib/api/server'
7+
import { buildFileDocSeed } from '@/lib/collab-doc/seed'
8+
import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
11+
const logger = createLogger('FileDocSeedAPI')
12+
13+
/**
14+
* POST /api/internal/file-doc/seed — build a server-authoritative collaborative-document seed
15+
* (markdown → Yjs) for the realtime relay to apply on room creation. Internal only: gated on the
16+
* shared `x-api-key: INTERNAL_API_SECRET` secret, matching the header the realtime relay sends
17+
* (`apps/realtime/src/handlers/file-doc-seed.ts`) and the realtime server's own inbound validator.
18+
*/
19+
export const POST = withRouteHandler(async (request: NextRequest) => {
20+
const auth = checkInternalApiKey(request)
21+
if (!auth.success) return createUnauthorizedResponse()
22+
23+
const parsed = await parseRequest(buildFileDocSeedContract, request, {})
24+
if (!parsed.success) return parsed.response
25+
const { workspaceId, fileId } = parsed.data.body
26+
27+
try {
28+
const seed = await buildFileDocSeed(workspaceId, fileId)
29+
return NextResponse.json({
30+
update: seed ? Buffer.from(seed.update).toString('base64') : null,
31+
})
32+
} catch (error) {
33+
logger.error('Failed to build file-doc seed', { workspaceId, fileId, error })
34+
return NextResponse.json(
35+
{ error: getErrorMessage(error, 'Failed to build seed') },
36+
{ status: 500 }
37+
)
38+
}
39+
})

0 commit comments

Comments
 (0)