@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
TypeScript
//#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 };