UNPKG

@durable-streams/y-durable-streams

Version:

Yjs provider for Durable Streams - sync Yjs documents over append-only streams

1,382 lines (1,218 loc) 40 kB
/** * YjsServer - HTTP server implementing the Yjs Durable Streams Protocol. * * This server proxies Durable Streams protocol requests to an underlying DS server, * adding Yjs-specific logic: * - Single URL path per document with query parameters * - Snapshot discovery via offset=snapshot sentinel (307 redirects) * - Automatic compaction when updates exceed threshold * - Awareness via ?awareness=<name> query parameter * * Protocol: https://github.com/durable-streams/durable-streams/blob/main/packages/y-durable-streams/PROTOCOL.md */ import { createServer } from "node:http" import { DurableStream, DurableStreamError, FetchError, } from "@durable-streams/client" import { Compactor } from "./compaction" import { PathUtils, YJS_HEADERS, YjsStreamPaths } from "./types" import type { IncomingMessage, Server, ServerResponse } from "node:http" import type { YjsDocumentState, YjsIndexEntry, YjsServerOptions } from "./types" const DEFAULT_COMPACTION_THRESHOLD = 1024 * 1024 // 1MB /** * Check if an error is a 404 Not Found error. */ function isNotFoundError(err: unknown): boolean { return ( (err instanceof DurableStreamError && err.code === `NOT_FOUND`) || (err instanceof FetchError && err.status === 404) ) } /** * Route match result. */ interface RouteMatch { service: string docPath: string } /** * Parse the URL path and extract route parameters. * Expected format: /v1/yjs/:service/docs/:docPath * where docPath can include forward slashes. */ function parseRoute(path: string): RouteMatch | null { const match = path.match(/^\/v1\/yjs\/([^/]+)\/docs\/(.+)$/) if (!match) return null const docPath = PathUtils.normalize(match[2]!) if (!docPath) return null return { service: match[1]!, docPath, } } /** * HTTP server implementing the Yjs Durable Streams Protocol. */ export class YjsServer { private readonly dsServerUrl: string private readonly dsServerHeaders: Record<string, string> private readonly compactionThreshold: number private readonly port: number private readonly host: string private readonly compactor: Compactor private readonly documentStates = new Map<string, YjsDocumentState>() private stateKey(service: string, docPath: string): string { return `${service}/${docPath}` } private server: Server | null = null private _url: string | null = null constructor(options: YjsServerOptions) { this.dsServerUrl = options.dsServerUrl this.dsServerHeaders = options.dsServerHeaders ?? {} this.compactionThreshold = options.compactionThreshold ?? DEFAULT_COMPACTION_THRESHOLD this.port = options.port ?? 0 this.host = options.host ?? `127.0.0.1` this.compactor = new Compactor(this) } async start(): Promise<string> { if (this.server) { throw new Error(`Server already started`) } return new Promise((resolve, reject) => { this.server = createServer((req, res) => { this.handleRequest(req, res).catch((err) => { console.error(`[YjsServer] Request error:`, err) if (!res.headersSent) { res.writeHead(500, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `INTERNAL_ERROR`, message: `Internal error` }, }) ) } }) }) this.server.on(`error`, reject) this.server.listen(this.port, this.host, () => { const addr = this.server!.address() if (typeof addr === `string`) { this._url = addr } else if (addr) { this._url = `http://${this.host}:${addr.port}` } resolve(this._url!) }) }) } async stop(): Promise<void> { if (!this.server) return this.server.closeAllConnections() return new Promise((resolve, reject) => { this.server!.close((err) => { if (err) { reject(err) return } this.server = null this._url = null resolve() }) }) } get url(): string { if (!this._url) { throw new Error(`Server not started`) } return this._url } // ---- Request handling ---- private async handleRequest( req: IncomingMessage, res: ServerResponse ): Promise<void> { const rawUrl = req.url ?? `/` const url = new URL(rawUrl, `http://${req.headers.host}`) const path = url.pathname const method = req.method?.toUpperCase() // CORS headers res.setHeader(`access-control-allow-origin`, `*`) res.setHeader( `access-control-allow-methods`, `GET, POST, PUT, DELETE, OPTIONS` ) res.setHeader( `access-control-allow-headers`, `authorization, content-type, stream-offset, stream-live, producer-id, producer-epoch, producer-seq, stream-producer-id, stream-producer-epoch, stream-producer-seq` ) res.setHeader( `access-control-expose-headers`, `stream-next-offset, stream-up-to-date, stream-cursor, location` ) if (method === `OPTIONS`) { res.writeHead(204) res.end() return } // Check for path traversal in raw URL (before URL normalization) // This catches attempts to use /.. or /. segments if ( rawUrl.includes(`/..`) || rawUrl.includes(`/./`) || rawUrl.endsWith(`/.`) ) { res.writeHead(400, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `INVALID_REQUEST`, message: `Invalid document path` }, }) ) return } const route = parseRoute(path) if (!route) { // Check if this looks like a Yjs path but failed validation // (e.g., path traversal attempts) if (path.startsWith(`/v1/yjs/`)) { res.writeHead(400, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `INVALID_REQUEST`, message: `Invalid document path`, }, }) ) return } // Not a Yjs route - proxy to DS server await this.proxyToDsServer(req, res, rawUrl) return } const offset = url.searchParams.get(`offset`) const awareness = url.searchParams.get(`awareness`) const live = url.searchParams.get(`live`) try { // Handle awareness streams if (awareness !== null) { await this.handleAwareness(req, res, route, awareness, url) return } // Handle snapshot discovery if (offset === `snapshot`) { await this.handleSnapshotDiscovery(res, route, url) return } // Handle snapshot read if (offset && offset.endsWith(`_snapshot`)) { await this.handleSnapshotRead(req, res, route, offset, url) return } // Handle document updates if (method === `GET`) { await this.handleUpdatesRead(req, res, route, offset, live, url) } else if (method === `HEAD`) { await this.handleDocumentHead(req, res, route) } else if (method === `POST`) { await this.handleUpdateWrite(req, res, route) } else if (method === `PUT`) { await this.handleDocumentCreate(req, res, route) } else if (method === `DELETE`) { await this.handleDocumentDelete(res, route) } else { res.writeHead(405, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `INVALID_REQUEST`, message: `Method not allowed` }, }) ) } } catch (err) { console.error(`[YjsServer] Error handling ${method} ${path}:`, err) if (!res.headersSent) { res.writeHead(500, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `INTERNAL_ERROR`, message: `Internal error` }, }) ) } } } // ---- Proxy for non-Yjs routes ---- /** * Proxy requests that don't match Yjs routes to the underlying DS server. * This allows clients to use a single endpoint for both Yjs and raw DS operations. */ private async proxyToDsServer( req: IncomingMessage, res: ServerResponse, path: string ): Promise<void> { const targetUrl = `${this.dsServerUrl}${path}` const method = req.method ?? `GET` // Read request body if present let body: Buffer | undefined if (method !== `GET` && method !== `HEAD`) { const chunks: Array<Buffer> = [] for await (const chunk of req) { chunks.push(chunk as Buffer) } if (chunks.length > 0) { body = Buffer.concat(chunks) } } // Forward headers, excluding host const headers: Record<string, string> = { ...this.dsServerHeaders } for (const [key, value] of Object.entries(req.headers)) { if (key.toLowerCase() !== `host` && value) { headers[key] = Array.isArray(value) ? value.join(`, `) : value } } try { const response = await fetch(targetUrl, { method, headers, body: body ? new Uint8Array(body) : undefined, }) // Copy response status and headers // Note: fetch() automatically decompresses gzip/deflate responses, // so we must NOT forward content-encoding (data is already decompressed) const responseHeaders: Record<string, string> = {} response.headers.forEach((value, key) => { // Skip headers that don't apply after fetch decompression if (key === `content-encoding` || key === `content-length`) return responseHeaders[key] = value }) res.writeHead(response.status, responseHeaders) // Stream response body if (response.body) { for await (const chunk of response.body) { res.write(chunk) } } res.end() } catch (err) { console.error(`[YjsServer] Proxy error for ${method} ${path}:`, err) if (!res.headersSent) { res.writeHead(502, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `PROXY_ERROR`, message: `Failed to proxy request` }, }) ) } } } /** * POST with auto-create on 404: try POST, if stream doesn't exist * create it via PUT and retry. Handles awareness streams that may * have expired due to TTL. */ private async postWithAutoCreate( req: IncomingMessage, res: ServerResponse, dsPath: string, docDsPath?: string ): Promise<void> { const body = await this.readBody(req) const headers: Record<string, string> = { ...this.dsServerHeaders, "content-type": req.headers[`content-type`] ?? `application/octet-stream`, } const targetUrl = `${this.dsServerUrl}${dsPath}` const response = await fetch(targetUrl, { method: `POST`, headers, body: body.length > 0 ? new Uint8Array(body) : undefined, }) if (response.status === 404) { // Stream doesn't exist — check parent document before re-creating await response.arrayBuffer() // If a document path was provided, verify the document exists if (docDsPath) { const headUrl = `${this.dsServerUrl}${docDsPath}` const headResponse = await fetch(headUrl, { method: `HEAD`, headers: this.dsServerHeaders, }) if (headResponse.status === 404) { res.writeHead(404, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `DOCUMENT_NOT_FOUND`, message: `Document does not exist`, }, }) ) return } if (!headResponse.ok) { throw new Error( `Document existence check failed: ${headResponse.status}` ) } } await this.tryCreateStream(dsPath) const retryResponse = await fetch(targetUrl, { method: `POST`, headers, body: body.length > 0 ? new Uint8Array(body) : undefined, }) await this.forwardResponse(res, retryResponse) } else { await this.forwardResponse(res, response) } } // ---- Snapshot Discovery ---- private async handleSnapshotDiscovery( res: ServerResponse, route: RouteMatch, originalUrl: URL ): Promise<void> { // Check if the document stream exists const dsPath = YjsStreamPaths.dsStream(route.service, route.docPath) const headUrl = `${this.dsServerUrl}${dsPath}` try { const headResponse = await fetch(headUrl, { method: `HEAD`, headers: this.dsServerHeaders, }) if (headResponse.status === 404) { res.writeHead(404, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `DOCUMENT_NOT_FOUND`, message: `Document does not exist`, }, }) ) return } } catch { // If HEAD fails for non-404 reasons, proceed with snapshot discovery } const state = this.getOrCreateDocumentState(route.service, route.docPath) // If no snapshot in memory, try to load from index stream if (!state.snapshotOffset) { const loadedOffset = await this.loadSnapshotOffsetFromIndex( route.service, route.docPath ) if (loadedOffset) { state.snapshotOffset = loadedOffset } } // Build redirect URL const redirectUrl = new URL(originalUrl.href) if (state.snapshotOffset) { // Snapshot exists - redirect to snapshot URL redirectUrl.searchParams.set( `offset`, YjsStreamPaths.snapshotKey(state.snapshotOffset) ) } else { // No snapshot - redirect to beginning of stream redirectUrl.searchParams.set(`offset`, `-1`) } // Build relative redirect path const redirectPath = `${redirectUrl.pathname}${redirectUrl.search}` res.writeHead(307, { location: redirectPath, "cache-control": `private, max-age=5`, }) res.end() } /** * Load the latest snapshot offset from the internal index stream. * Returns null if no index exists or it's empty. */ private async loadSnapshotOffsetFromIndex( service: string, docPath: string ): Promise<string | null> { const indexUrl = `${this.dsServerUrl}${YjsStreamPaths.indexStream(service, docPath)}` try { const stream = new DurableStream({ url: indexUrl, headers: this.dsServerHeaders, contentType: `application/json`, }) const response = await stream.stream({ offset: `-1` }) const body = await response.text() if (!body || body.trim().length === 0) { return null } // Prefer JSON array format (DS JSON streams return arrays) try { const parsed = JSON.parse(body) as unknown if (Array.isArray(parsed)) { const last = parsed[parsed.length - 1] as YjsIndexEntry | undefined if (last?.snapshotOffset) { return last.snapshotOffset } } else if ( parsed && typeof parsed === `object` && `snapshotOffset` in parsed ) { return (parsed as YjsIndexEntry).snapshotOffset } } catch { // Fall through to newline-delimited parsing } // Fallback: parse newline-delimited entries const lines = body.trim().split(`\n`) for (let i = lines.length - 1; i >= 0; i -= 1) { const line = lines[i]?.trim() if (!line) continue try { const entry = JSON.parse(line) as YjsIndexEntry if (entry.snapshotOffset) { return entry.snapshotOffset } } catch { // Keep scanning } } return null } catch (err) { if (isNotFoundError(err)) { // No index stream yet - that's fine return null } console.error( `[YjsServer] Error loading index for ${service}/${docPath}:`, err ) return null } } // ---- Snapshot Read ---- private async handleSnapshotRead( req: IncomingMessage, res: ServerResponse, route: RouteMatch, offset: string, _url: URL ): Promise<void> { const snapshotOffset = YjsStreamPaths.parseSnapshotOffset(offset) if (!snapshotOffset) { res.writeHead(400, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `INVALID_REQUEST`, message: `Invalid snapshot offset`, }, }) ) return } // Build DS URL for snapshot storage const dsPath = YjsStreamPaths.snapshotStream( route.service, route.docPath, offset ) const dsUrl = new URL(dsPath, this.dsServerUrl) try { const dsResponse = await fetch(dsUrl.toString(), { method: `GET`, headers: { ...this.dsServerHeaders, "stream-offset": `-1`, // Read from beginning of snapshot stream }, }) if (!dsResponse.ok) { if (dsResponse.status === 404) { res.writeHead(404, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `SNAPSHOT_NOT_FOUND`, message: `Snapshot not found`, }, }) ) return } throw new Error(`DS server returned ${dsResponse.status}`) } // Set headers - the next offset to read updates from is snapshotOffset + 1 // But we return the snapshot offset so client knows where to continue const responseHeaders: Record<string, string> = { "content-type": `application/octet-stream`, [YJS_HEADERS.STREAM_NEXT_OFFSET]: this.incrementOffset(snapshotOffset), } res.writeHead(200, responseHeaders) // Stream the snapshot body if (dsResponse.body) { const reader = dsResponse.body.getReader() try { for (;;) { const { done, value } = await reader.read() if (done) break res.write(value) } } finally { reader.releaseLock() } } res.end() } catch (err) { if (isNotFoundError(err)) { res.writeHead(404, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `SNAPSHOT_NOT_FOUND`, message: `Snapshot not found`, }, }) ) return } throw err } } /** * Increment an offset string for the next read position. * Offsets are formatted as "{timestamp}_{sequence}" padded strings. * Increments the sequence portion by 1 so the client reads from the * position after the snapshot, not from the snapshot offset itself. */ private incrementOffset(offset: string): string { const parts = offset.split(`_`) if (parts.length !== 2) return offset const seq = parseInt(parts[1]!, 10) if (isNaN(seq)) return offset const nextSeq = (seq + 1).toString().padStart(parts[1]!.length, `0`) return `${parts[0]}_${nextSeq}` } // ---- Proxies to .updates stream ---- /** * GET - Proxy to read from .updates stream. */ private async handleUpdatesRead( req: IncomingMessage, res: ServerResponse, route: RouteMatch, offset: string | null, live: string | null, _url: URL ): Promise<void> { let dsPath = YjsStreamPaths.dsStream(route.service, route.docPath) // Build query string const params = new URLSearchParams() if (offset !== null) { params.set(`offset`, offset) } if (live) { params.set(`live`, live === `true` ? `long-poll` : live) } const query = params.toString() if (query) { dsPath = `${dsPath}?${query}` } // Use flush-aware proxy for SSE to ensure immediate delivery if (live === `sse`) { await this.proxyWithSseFlush(req, res, dsPath) } else { await this.proxyToDsServer(req, res, dsPath) } } /** * HEAD - Proxy to check .updates stream existence. */ private async handleDocumentHead( req: IncomingMessage, res: ServerResponse, route: RouteMatch ): Promise<void> { const dsPath = YjsStreamPaths.dsStream(route.service, route.docPath) await this.proxyToDsServer(req, res, dsPath) } /** * PUT - Create document: creates both .updates and .awareness.default streams. */ private async handleDocumentCreate( req: IncomingMessage, res: ServerResponse, route: RouteMatch ): Promise<void> { const dsPath = YjsStreamPaths.dsStream(route.service, route.docPath) // Proxy the PUT to create the .updates stream const targetUrl = `${this.dsServerUrl}${dsPath}` const headers: Record<string, string> = { ...this.dsServerHeaders } for (const [key, value] of Object.entries(req.headers)) { if (key.toLowerCase() !== `host` && value) { headers[key] = Array.isArray(value) ? value.join(`, `) : value } } // Default content type for Yjs streams if (!headers[`content-type`]) { headers[`content-type`] = `application/octet-stream` } const body = await this.readBody(req) const dsResponse = await fetch(targetUrl, { method: `PUT`, headers, body: body.length > 0 ? new Uint8Array(body) : undefined, }) // If the document was created (201) or already exists with matching config (200), // also ensure the awareness stream exists if (dsResponse.status === 201 || dsResponse.status === 200) { const awarenessPath = YjsStreamPaths.awarenessStream( route.service, route.docPath, `default` ) await this.tryCreateStream(awarenessPath).catch((err) => { console.error(`[YjsServer] Failed to create awareness stream:`, err) }) } await this.forwardResponse(res, dsResponse) } /** * POST - Streaming proxy to write to .updates stream. * Client sends lib0-framed updates; we pass through directly. * Returns 404 if the document does not exist. */ private async handleUpdateWrite( req: IncomingMessage, res: ServerResponse, route: RouteMatch ): Promise<void> { const stateKey = this.stateKey(route.service, route.docPath) // Client sends lib0-framed updates - pass through directly // (Client frames each update before batching to handle IdempotentProducer concatenation) const body = await this.readBody(req) const dsPath = YjsStreamPaths.dsStream(route.service, route.docPath) const targetUrl = `${this.dsServerUrl}${dsPath}` // Forward headers including producer headers const headers: Record<string, string> = { ...this.dsServerHeaders, "content-type": `application/octet-stream`, } for (const h of [ `producer-id`, `producer-epoch`, `producer-seq`, `stream-producer-id`, `stream-producer-epoch`, `stream-producer-seq`, ]) { const v = req.headers[h] if (typeof v === `string`) headers[h] = v } const dsResponse = await fetch(targetUrl, { method: `POST`, headers, body: body.length > 0 ? new Uint8Array(body) : undefined, }) await this.forwardResponse(res, dsResponse) // Track for compaction on success if (dsResponse.status >= 200 && dsResponse.status < 300) { if (!this.documentStates.has(stateKey)) { this.documentStates.set(stateKey, { snapshotOffset: null, updatesSizeBytes: 0, compacting: false, }) } const state = this.documentStates.get(stateKey)! state.updatesSizeBytes += body.length // Trigger compaction if thresholds met if (this.shouldTriggerCompaction(state)) { this.compactor .triggerCompaction(route.service, route.docPath) .catch((err) => { console.error(`[YjsServer] Compaction error:`, err) }) } } } /** * DELETE - Delete document and cascade to associated streams. */ private async handleDocumentDelete( res: ServerResponse, route: RouteMatch ): Promise<void> { const { service, docPath } = route const dsPath = YjsStreamPaths.dsStream(service, docPath) const dsUrl = `${this.dsServerUrl}${dsPath}` // Delete the document update stream (MUST) const response = await fetch(dsUrl, { method: `DELETE`, headers: this.dsServerHeaders, }) if (response.status === 404) { await response.arrayBuffer() res.writeHead(404, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `DOCUMENT_NOT_FOUND`, message: `Document does not exist`, }, }) ) return } if (!response.ok) { const text = await response.text().catch(() => ``) throw new Error( `Failed to delete document stream: ${response.status} ${text}` ) } await response.arrayBuffer() // Clean up in-memory state const stateKey = this.stateKey(service, docPath) this.documentStates.delete(stateKey) // Best-effort cascade: delete associated streams (SHOULD) // Awaited so streams are deleted before responding, but errors don't fail the request await this.cascadeDeleteStreams(service, docPath).catch((err) => { console.error(`[YjsServer] Cascade delete failed for ${docPath}:`, err) }) res.writeHead(204) res.end() } /** * Best-effort cascade delete of snapshot and awareness streams. * Errors are logged but do not propagate. */ private async cascadeDeleteStreams( service: string, docPath: string ): Promise<void> { const deleteStream = async (dsPath: string): Promise<void> => { try { await DurableStream.delete({ url: `${this.dsServerUrl}${dsPath}`, headers: this.dsServerHeaders, }) } catch { // Best-effort: ignore failures (stream may not exist) } } // Load indices in parallel const [snapshotOffsets, awarenessNames] = await Promise.all([ this.loadIndexEntries( YjsStreamPaths.indexStream(service, docPath), (entry) => entry.snapshotOffset as string | undefined ), this.loadIndexEntries( YjsStreamPaths.awarenessIndexStream(service, docPath), (entry) => entry.name as string | undefined ), ]) // Build list of all paths to delete const pathsToDelete: Array<string> = [] for (const offset of snapshotOffsets) { const snapshotKey = YjsStreamPaths.snapshotKey(offset) pathsToDelete.push( YjsStreamPaths.snapshotStream(service, docPath, snapshotKey) ) } pathsToDelete.push(YjsStreamPaths.indexStream(service, docPath)) pathsToDelete.push( YjsStreamPaths.awarenessStream(service, docPath, `default`) ) for (const name of awarenessNames) { pathsToDelete.push(YjsStreamPaths.awarenessStream(service, docPath, name)) } pathsToDelete.push(YjsStreamPaths.awarenessIndexStream(service, docPath)) // Delete all streams in parallel (best-effort) await Promise.allSettled(pathsToDelete.map(deleteStream)) } /** * Load entries from an index stream, extracting a value from each entry. * Returns deduplicated values. Returns empty array if index doesn't exist. */ private async loadIndexEntries( dsPath: string, extractValue: (entry: Record<string, unknown>) => string | undefined ): Promise<Array<string>> { const indexUrl = `${this.dsServerUrl}${dsPath}` try { const stream = new DurableStream({ url: indexUrl, headers: this.dsServerHeaders, contentType: `application/json`, }) const response = await stream.stream({ offset: `-1` }) const body = await response.text() if (!body || body.trim().length === 0) { return [] } const values = new Set<string>() // Prefer JSON array format (DS JSON streams return arrays) try { const parsed = JSON.parse(body) as unknown if (Array.isArray(parsed)) { for (const entry of parsed) { const val = extractValue(entry) if (val) values.add(val) } return [...values] } } catch { // Fall through to newline-delimited parsing } // Fallback: parse newline-delimited entries const lines = body.trim().split(`\n`) for (const line of lines) { const trimmed = line.trim() if (!trimmed) continue try { const entry = JSON.parse(trimmed) as Record<string, unknown> const val = extractValue(entry) if (val) values.add(val) } catch { // Skip malformed entries } } return [...values] } catch (err) { if (!isNotFoundError(err)) { console.error(`[YjsServer] Error loading index ${dsPath}:`, err) } return [] } } // ---- Awareness ---- private async handleAwareness( req: IncomingMessage, res: ServerResponse, route: RouteMatch, awarenessName: string, url: URL ): Promise<void> { const method = req.method?.toUpperCase() const dsPath = YjsStreamPaths.awarenessStream( route.service, route.docPath, awarenessName ) if (method === `PUT`) { // Check that the parent document exists before creating awareness stream const docDsPath = YjsStreamPaths.dsStream(route.service, route.docPath) const headUrl = `${this.dsServerUrl}${docDsPath}` try { const headResponse = await fetch(headUrl, { method: `HEAD`, headers: this.dsServerHeaders, }) if (headResponse.status === 404) { res.writeHead(404, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `DOCUMENT_NOT_FOUND`, message: `Document does not exist`, }, }) ) return } } catch (err) { // If HEAD fails for non-404 reasons, log and proceed with creation attempt console.error( `[YjsServer] HEAD check for document existence failed:`, err ) } // Create awareness stream try { const created = await this.tryCreateStream(dsPath) // Record non-default awareness streams in the awareness index for discovery if (created && awarenessName !== `default`) { const indexPath = YjsStreamPaths.awarenessIndexStream( route.service, route.docPath ) await this.appendToIndexStream(indexPath, { name: awarenessName, createdAt: Date.now(), }).catch((err) => { console.error( `[YjsServer] Failed to append to awareness index:`, err ) }) } res.writeHead(created ? 201 : 200, { "content-type": `application/json`, }) res.end() } catch (err) { console.error(`[YjsServer] Failed to create awareness stream:`, err) if (!res.headersSent) { res.writeHead(500, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `INTERNAL_ERROR`, message: `Failed to create awareness stream`, }, }) ) } } } else if (method === `POST`) { // Proxy raw binary to awareness stream. // Auto-create on 404 since awareness streams have TTL and may expire. const docDsPath = YjsStreamPaths.dsStream(route.service, route.docPath) await this.postWithAutoCreate(req, res, dsPath, docDsPath) } else if (method === `GET`) { // Build path with query params const offset = url.searchParams.get(`offset`) const live = url.searchParams.get(`live`) const params = new URLSearchParams() if (offset !== null) { params.set(`offset`, offset) } if (live) { // live=true means "server picks transport" - use SSE for awareness params.set(`live`, live === `true` ? `sse` : live) } const query = params.toString() const fullPath = query ? `${dsPath}?${query}` : dsPath // For SSE, we need special handling with flush if (live === `sse` || live === `true`) { await this.proxyWithSseFlush(req, res, fullPath) } else { await this.proxyToDsServer(req, res, fullPath) } } else if (method === `HEAD`) { await this.proxyToDsServer(req, res, dsPath) } else if (method === `DELETE`) { // Delete awareness stream by proxying DELETE to DS server const response = await fetch(`${this.dsServerUrl}${dsPath}`, { method: `DELETE`, headers: this.dsServerHeaders, }) if (response.status === 404) { await response.arrayBuffer() res.writeHead(404, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `STREAM_NOT_FOUND`, message: `Awareness stream not found`, }, }) ) return } if (!response.ok) { const text = await response.text().catch(() => ``) throw new Error( `Failed to delete awareness stream: ${response.status} ${text}` ) } await response.arrayBuffer() res.writeHead(204) res.end() } else { res.writeHead(405, { "content-type": `application/json` }) res.end( JSON.stringify({ error: { code: `INVALID_REQUEST`, message: `Method not allowed` }, }) ) } } /** * Forward a fetch Response to the client ServerResponse. */ private async forwardResponse( res: ServerResponse, response: globalThis.Response ): Promise<void> { const responseHeaders: Record<string, string> = {} response.headers.forEach((value, key) => { if (key === `content-encoding` || key === `content-length`) return responseHeaders[key] = value }) res.writeHead(response.status, responseHeaders) if (response.body) { for await (const chunk of response.body) { res.write(chunk) } } res.end() } /** * Proxy with SSE-specific handling: flush after each chunk for immediate delivery. */ private async proxyWithSseFlush( req: IncomingMessage, res: ServerResponse, path: string ): Promise<void> { const targetUrl = `${this.dsServerUrl}${path}` // Forward request headers const headers: Record<string, string> = { ...this.dsServerHeaders } for (const [key, value] of Object.entries(req.headers)) { if (key.toLowerCase() !== `host` && value) { headers[key] = Array.isArray(value) ? value.join(`, `) : value } } const response = await fetch(targetUrl, { method: `GET`, headers, }) // Forward headers with SSE additions (skip content-encoding/length - fetch decompresses) const responseHeaders: Record<string, string> = { "cache-control": `no-cache`, connection: `keep-alive`, } response.headers.forEach((value, key) => { if (key === `content-encoding` || key === `content-length`) return responseHeaders[key] = value }) res.writeHead(response.status, responseHeaders) if (response.body) { for await (const chunk of response.body) { res.write(chunk) // Flush for SSE to ensure immediate delivery const flushable = res as unknown as { flush?: () => void } flushable.flush?.() } } res.end() } // ---- Stream management ---- /** * Try to create a stream at the given DS path. * Returns true if the stream was created, false if it already existed. */ private async tryCreateStream( dsPath: string, contentType: string = `application/octet-stream` ): Promise<boolean> { const url = `${this.dsServerUrl}${dsPath}` const response = await fetch(url, { method: `PUT`, headers: { ...this.dsServerHeaders, "content-type": contentType, }, }) if (response.status === 201) { await response.arrayBuffer() return true } if (response.status === 200 || response.status === 409) { await response.arrayBuffer() return false } // Unexpected status — consume body and throw const text = await response.text().catch(() => ``) throw new Error( `Failed to create stream ${dsPath}: ${response.status} ${text}` ) } /** * Append a JSON entry to an index stream, creating the stream if needed. */ async appendToIndexStream( dsPath: string, entry: Record<string, unknown> ): Promise<void> { await this.tryCreateStream(dsPath, `application/json`) const stream = new DurableStream({ url: `${this.dsServerUrl}${dsPath}`, headers: this.dsServerHeaders, contentType: `application/json`, }) await stream.append(JSON.stringify(entry) + `\n`, { contentType: `application/json`, }) } private getOrCreateDocumentState( service: string, docPath: string ): YjsDocumentState { const stateKey = this.stateKey(service, docPath) let state = this.documentStates.get(stateKey) if (!state) { state = { snapshotOffset: null, updatesSizeBytes: 0, compacting: false, } this.documentStates.set(stateKey, state) } return state } // ---- Compaction support ---- shouldTriggerCompaction(state: YjsDocumentState): boolean { return ( !state.compacting && state.updatesSizeBytes >= this.compactionThreshold ) } getDocumentState( service: string, docPath: string ): YjsDocumentState | undefined { return this.documentStates.get(this.stateKey(service, docPath)) } /** * Atomically check if compaction can start and set compacting=true if so. * Returns true if compaction was started, false if already compacting or state not found. */ tryStartCompaction(service: string, docPath: string): boolean { const state = this.documentStates.get(this.stateKey(service, docPath)) if (!state || state.compacting) { return false } state.compacting = true return true } setCompacting(service: string, docPath: string, compacting: boolean): void { const state = this.documentStates.get(this.stateKey(service, docPath)) if (state) { state.compacting = compacting } } resetUpdateCounters(service: string, docPath: string): void { const state = this.documentStates.get(this.stateKey(service, docPath)) if (state) { state.updatesSizeBytes = 0 } } updateSnapshotOffset(service: string, docPath: string, offset: string): void { const state = this.documentStates.get(this.stateKey(service, docPath)) if (state) { state.snapshotOffset = offset } } getDsServerUrl(): string { return this.dsServerUrl } getDsServerHeaders(): Record<string, string> { return this.dsServerHeaders } // ---- Helpers ---- private readBody(req: IncomingMessage): Promise<Uint8Array> { return new Promise((resolve, reject) => { const chunks: Array<Buffer> = [] req.on(`data`, (chunk: Buffer) => { chunks.push(chunk) }) req.on(`end`, () => { const body = Buffer.concat(chunks) resolve(new Uint8Array(body)) }) req.on(`error`, reject) }) } }