UNPKG

@durable-streams/y-durable-streams

Version:

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

297 lines (294 loc) 10.2 kB
//#region src/server/types.d.ts /** * Server-side types for the Yjs Durable Streams Protocol. * * These types define the wire format and internal structures used by the * Yjs server layer that sits between clients and the durable streams server. */ /** * Configuration options for the Yjs server. */ interface YjsServerOptions { /** Port to listen on (0 for random available port) */ port?: number; /** Host to bind to */ host?: string; /** URL of the underlying durable streams server */ dsServerUrl: string; /** * Threshold in bytes for triggering compaction. * When cumulative update size exceeds this, a new snapshot is created. * @default 1048576 (1MB) */ compactionThreshold?: number; /** * Optional headers to send to the durable streams server. */ dsServerHeaders?: Record<string, string>; } /** * Internal state for tracking document metadata. */ interface YjsDocumentState { /** * Current snapshot offset, or null if no snapshot exists yet. * When set, the snapshot is available at ?offset={snapshotOffset}_snapshot */ snapshotOffset: string | null; /** Cumulative size of updates since last compaction (bytes) */ updatesSizeBytes: number; /** Whether compaction is currently in progress */ compacting: boolean; } /** * Result from a compaction operation. */ interface CompactionResult { /** The offset at which the snapshot was taken */ snapshotOffset: string; /** Size of the new snapshot in bytes */ snapshotSizeBytes: number; /** Previous snapshot offset (to be deleted), or null if first compaction */ oldSnapshotOffset: string | null; } /** * Index entry stored in the internal index stream. * Each compaction appends a new entry with the current snapshot offset. */ interface YjsIndexEntry extends Record<string, unknown> { /** The snapshot offset (used to construct the snapshot key) */ snapshotOffset: string; /** Timestamp when the snapshot was created */ createdAt: number; } /** * Internal representation of a Yjs document on the server. */ interface YjsDocument { /** Service identifier */ service: string; /** Document path (can include forward slashes) */ docPath: string; /** Current document state */ state: YjsDocumentState; } /** * Headers used by the Yjs protocol layer (lowercase per protocol spec). */ declare const YJS_HEADERS: { /** Content offset for the next read */ readonly STREAM_NEXT_OFFSET: "stream-next-offset"; /** Whether the client is caught up */ readonly STREAM_UP_TO_DATE: "stream-up-to-date"; /** Cursor for CDN collapsing */ readonly STREAM_CURSOR: "stream-cursor"; }; /** * Stream path builders for consistent path generation. * All operations use the same document URL with query parameters. * * Internal streams use `.` prefixed segments (e.g., `.updates`, `.index`, `.snapshots`) * which are safe from user collisions since document paths reject `.` characters. */ declare const YjsStreamPaths: { /** * Get the base path for a document. * docPath can include forward slashes (e.g., "project/chapter-1"). */ readonly doc: (service: string, docPath: string) => string; /** * Get the underlying DS stream path for document updates. * Uses `.updates` suffix to avoid collisions with user document paths. */ readonly dsStream: (service: string, docPath: string) => string; /** * Get the internal index stream path for a document. * This stream stores snapshot offsets and is used internally by the server. * Uses `.index` suffix to avoid collisions with user document paths. */ readonly indexStream: (service: string, docPath: string) => string; /** * Get the snapshot stream path for a given offset. * Uses `.snapshots` prefix to avoid collisions with user document paths. */ readonly snapshotStream: (service: string, docPath: string, snapshotKey: string) => string; /** * Get the awareness stream path for a given name. * Uses `.awareness` prefix to avoid collisions with user document paths. */ readonly awarenessStream: (service: string, docPath: string, name: string) => string; /** * Get the awareness index stream path for a document. * This append-only stream tracks which named awareness streams have been created, * enabling discovery during cascade delete. */ readonly awarenessIndexStream: (service: string, docPath: string) => string; /** * Get the snapshot storage key for a given offset. */ readonly snapshotKey: (offset: string) => string; /** * Parse a snapshot offset from a snapshot key (e.g., "4782_snapshot" -> "4782"). * Returns null if not a valid snapshot key. */ readonly parseSnapshotOffset: (key: string) => string | null; }; //#endregion //#region src/server/yjs-server.d.ts /** * Error codes for Yjs protocol errors. */ /** * HTTP server implementing the Yjs Durable Streams Protocol. */ declare class YjsServer { private readonly dsServerUrl; private readonly dsServerHeaders; private readonly compactionThreshold; private readonly port; private readonly host; private readonly compactor; private readonly documentStates; private stateKey; private server; private _url; constructor(options: YjsServerOptions); start(): Promise<string>; stop(): Promise<void>; get url(): string; private handleRequest; /** * 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 proxyToDsServer; /** * 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 postWithAutoCreate; private handleSnapshotDiscovery; /** * Load the latest snapshot offset from the internal index stream. * Returns null if no index exists or it's empty. */ private loadSnapshotOffsetFromIndex; private handleSnapshotRead; /** * 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; /** * GET - Proxy to read from .updates stream. */ private handleUpdatesRead; /** * HEAD - Proxy to check .updates stream existence. */ private handleDocumentHead; /** * PUT - Create document: creates both .updates and .awareness.default streams. */ private handleDocumentCreate; /** * 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 handleUpdateWrite; /** * DELETE - Delete document and cascade to associated streams. */ private handleDocumentDelete; /** * Best-effort cascade delete of snapshot and awareness streams. * Errors are logged but do not propagate. */ private cascadeDeleteStreams; /** * Load entries from an index stream, extracting a value from each entry. * Returns deduplicated values. Returns empty array if index doesn't exist. */ private loadIndexEntries; private handleAwareness; /** * Forward a fetch Response to the client ServerResponse. */ private forwardResponse; /** * Proxy with SSE-specific handling: flush after each chunk for immediate delivery. */ private proxyWithSseFlush; /** * Try to create a stream at the given DS path. * Returns true if the stream was created, false if it already existed. */ private tryCreateStream; /** * Append a JSON entry to an index stream, creating the stream if needed. */ appendToIndexStream(dsPath: string, entry: Record<string, unknown>): Promise<void>; private getOrCreateDocumentState; shouldTriggerCompaction(state: YjsDocumentState): boolean; getDocumentState(service: string, docPath: string): YjsDocumentState | undefined; /** * 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; setCompacting(service: string, docPath: string, compacting: boolean): void; resetUpdateCounters(service: string, docPath: string): void; updateSnapshotOffset(service: string, docPath: string, offset: string): void; getDsServerUrl(): string; getDsServerHeaders(): Record<string, string>; private readBody; } //#endregion //#region src/server/compaction.d.ts /** * Interface for the server that the Compactor works with. */ interface CompactorServer { getDsServerUrl: () => string; getDsServerHeaders: () => Record<string, string>; getDocumentState: (service: string, docPath: string) => YjsDocumentState | undefined; /** Atomically check if compaction can start and set compacting=true if so */ tryStartCompaction: (service: string, docPath: string) => boolean; setCompacting: (service: string, docPath: string, compacting: boolean) => void; resetUpdateCounters: (service: string, docPath: string) => void; updateSnapshotOffset: (service: string, docPath: string, offset: string) => void; /** Append a JSON entry to an index stream, creating the stream if needed */ appendToIndexStream: (dsPath: string, entry: Record<string, unknown>) => Promise<void>; } /** * Handles document compaction. */ declare class Compactor { private readonly server; constructor(server: CompactorServer); /** * Trigger compaction for a document. * Uses atomic check-and-set to prevent concurrent compactions. */ triggerCompaction(service: string, docPath: string): Promise<void>; /** * Perform the actual compaction. */ private performCompaction; /** * Write a new entry to the internal index stream. * This persists the snapshot offset so it survives server restarts. */ private writeIndexEntry; /** * Delete old snapshot. */ private deleteOldSnapshot; } //#endregion /** * Frame a Yjs update with lib0 encoding for storage. */ export { CompactionResult, Compactor, YJS_HEADERS, YjsDocument, YjsDocumentState, YjsIndexEntry, YjsServer, YjsServerOptions, YjsStreamPaths };