@durable-streams/y-durable-streams
Version:
Yjs provider for Durable Streams - sync Yjs documents over append-only streams
254 lines (217 loc) • 6.75 kB
text/typescript
/**
* 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.
*/
export 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.
*/
export 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.
*/
export 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.
*/
export 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.
*/
export 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).
*/
export const YJS_HEADERS = {
/** Content offset for the next read */
STREAM_NEXT_OFFSET: `stream-next-offset`,
/** Whether the client is caught up */
STREAM_UP_TO_DATE: `stream-up-to-date`,
/** Cursor for CDN collapsing */
STREAM_CURSOR: `stream-cursor`,
} as const
/**
* 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.
*/
export const YjsStreamPaths = {
/**
* Get the base path for a document.
* docPath can include forward slashes (e.g., "project/chapter-1").
*/
doc(service: string, docPath: string): string {
return `/v1/yjs/${service}/docs/${docPath}`
},
/**
* Get the underlying DS stream path for document updates.
* Uses `.updates` suffix to avoid collisions with user document paths.
*/
dsStream(service: string, docPath: string): string {
return `/v1/stream/yjs/${service}/docs/${docPath}/.updates`
},
/**
* 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.
*/
indexStream(service: string, docPath: string): string {
return `/v1/stream/yjs/${service}/docs/${docPath}/.index`
},
/**
* Get the snapshot stream path for a given offset.
* Uses `.snapshots` prefix to avoid collisions with user document paths.
*/
snapshotStream(
service: string,
docPath: string,
snapshotKey: string
): string {
return `/v1/stream/yjs/${service}/docs/${docPath}/.snapshots/${snapshotKey}`
},
/**
* Get the awareness stream path for a given name.
* Uses `.awareness` prefix to avoid collisions with user document paths.
*/
awarenessStream(service: string, docPath: string, name: string): string {
return `/v1/stream/yjs/${service}/docs/${docPath}/.awareness/${name}`
},
/**
* 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.
*/
awarenessIndexStream(service: string, docPath: string): string {
return `/v1/stream/yjs/${service}/docs/${docPath}/.awareness/.index`
},
/**
* Get the snapshot storage key for a given offset.
*/
snapshotKey(offset: string): string {
return `${offset}_snapshot`
},
/**
* Parse a snapshot offset from a snapshot key (e.g., "4782_snapshot" -> "4782").
* Returns null if not a valid snapshot key.
*/
parseSnapshotOffset(key: string): string | null {
const match = key.match(/^(.+)_snapshot$/)
return match ? match[1]! : null
},
} as const
/**
* Error codes for Yjs protocol errors.
*/
export type YjsErrorCode =
| `INVALID_REQUEST`
| `UNAUTHORIZED`
| `SNAPSHOT_NOT_FOUND`
| `DOCUMENT_NOT_FOUND`
| `OFFSET_EXPIRED`
| `RATE_LIMITED`
| `STREAM_NOT_FOUND`
| `INTERNAL_ERROR`
/**
* Error response format.
*/
export interface YjsError {
error: {
code: YjsErrorCode
message: string
}
}
/**
* Path normalization utilities.
*/
export const PathUtils = {
/**
* Validate and normalize a document path.
* - URL-decodes the path first
* - Rejects paths with ".." or "." segments
* - Collapses double slashes
* - Returns null if path is invalid
*/
normalize(path: string): string | null {
// URL-decode the path to catch encoded path traversal attempts
let decoded: string
try {
decoded = decodeURIComponent(path)
} catch {
return null // Invalid URL encoding
}
// Collapse double slashes
const normalized = decoded.replace(/\/+/g, `/`)
// Remove leading/trailing slashes for segment analysis
const trimmed = normalized.replace(/^\/|\/$/g, ``)
// Check for invalid segments
const segments = trimmed.split(`/`)
for (const segment of segments) {
if (segment === `..` || segment === `.`) {
return null
}
}
// Validate characters: [a-zA-Z0-9_-/]
if (!/^[a-zA-Z0-9_\-/]*$/.test(normalized)) {
return null
}
// Check max length
if (normalized.length > 256) {
return null
}
return normalized
},
} as const