Y/hub is a collaborative document backend built on Yjs. It implements the standard y-websocket protocol and extends it with attribution, history management, and selective undo/redo capabilities.
All endpoints require an auth-cookie which will be checked via the PERM
CALLBACK.
It is assumed that all documents can be identified by a unique {org}/{docid}
combination. Furthermore, all "body" content is encoded via lib0/encoding's
encodeAny. All binary data in parameters is encoded via base64.
The standard WebSocket backend that is compatible with y-websocket, and TipTapProvider.
For each Yjs document, there is always a gc'd version, and a non-gc'd version. Optionally, you may fork the document to a branch, which users can use for implementing suggestions. Branched documents have a gc'd version and a non-gc'd version as well.
ws://{host}/ws/{org}/{docid}parameters:{ gc?: boolean, branch?: string, customAttributions?: string }gc=true(default): standard garbage-collected documentgc=false: full document history which can be used to reconstruct editing history.branch="main": (default) The default branch-name if not specified otherwise.branch=string: Optionally, define a custom branch. Changes won't be automatically synced with other branches.customAttributions=string: optional comma-separatedkey:valuepairs (e.g.source:ai,model:gpt4). All updates sent through this connection will include these custom attributions in the contentmap, stored asinsert:<key>/delete:<key>attribution attributes alongside the standard ones.
Retrieve and update the Yjs document via REST API.
Retrieve the current state of the Yjs document.
GET /ydoc/{org}/{docid}parameters:{ gc?: boolean, branch?: string, awareness?: boolean }gc=true(default): retrieve the garbage-collected documentgc=false: retrieve the full document history (non-gc version)branch="main"(default): the branch to retrieveawareness=true: also include the room's merged awareness state in the response (default: omitted)- Returns
{ doc: Uint8Array, awareness?: Uint8Array }.docis the encoded Yjs document update.awareness, when requested and non-empty, contains the bare awareness update bytes (same format asencodeAwarenessUpdate(...)and as theawarenessfield accepted byPATCH /ydoc) — directly consumable byapplyAwarenessUpdate. Omitted when the room has no awareness state.
Update the Yjs document with new changes. Requires write access.
PATCH /ydoc/{org}/{docid}body:{ update?: Uint8Array, awareness?: Uint8Array, customAttributions?: Array<{ k: string, v: string }> }parameters:{ branch?: string }update: optional Yjs update (encoded viaY.encodeStateAsUpdateor similar). Diffed against the current document state — only new content is applied and attributed. Attributions are automatically assigned to the authenticated user.awareness: optional awareness update bytes — the bare output ofencodeAwarenessUpdate(awareness, clientIds)from@y/protocols/awareness(nomessageAwarenesswire-format prefix). Distributed to connected clients through the same Redis channel the WebSocket path uses.customAttributions: optional array of key-value pairs to attach as custom attributions to theupdate's changes. Stored asinsert:<key>/delete:<key>attribution attributes alongside the standard ones. Has no effect when onlyawarenessis supplied.branch="main"(default): the branch to update- At least one of
updateorawarenessmust be present; an empty body returns400 Bad Request. - Changes are distributed to connected WebSocket clients.
- Returns
{ success: true, message: string }on success.
import * as Y from 'yjs'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
// Retrieve the current document
const getResponse = await fetch('/ydoc/my-org/my-doc-id')
const getBuffer = await getResponse.arrayBuffer()
const getDecoder = decoding.createDecoder(new Uint8Array(getBuffer))
const { doc } = decoding.readAny(getDecoder)
// Apply the remote state to a local document
const ydoc = new Y.Doc()
Y.applyUpdate(ydoc, doc)
// Make local changes
ydoc.getText('content').insert(0, 'Hello World')
// Encode the update and send it
const update = Y.encodeStateAsUpdate(ydoc)
const encoder = encoding.createEncoder()
encoding.writeAny(encoder, { update })
const body = encoding.toUint8Array(encoder)
const patchResponse = await fetch('/ydoc/my-org/my-doc-id', {
method: 'PATCH',
headers: { 'Content-Type': 'application/octet-stream' },
body
})Rollback all changes that match the pattern. The changes will be distributed via websockets.
POST /rollback/{org}/{docid}body:{ from?: number, to?: number, by?: string, contentIds?: Y.ContentIds, customAttributions?: Array<{ k: string, v: string }>, withCustomAttributions?: Array<{ k: string, v: string }> }from/to: unix timestamp range filterby=string: comma-separated list of user-ids that matches the attributionscontentIds: Changeset that describes the changes between two versions.customAttributions: optional array of key-value pairs to attach as custom attributions to the rollback changes themselves (the undo operation).withCustomAttributions: optional array of key-value pairs to filter which changes to undo. Only changes whose attributions match all specified key-value pairs will be rolled back.
- Rollback all changes that happened after timestamp
X:POST /rollback/{org}/{docid}?from=X- If your "versions" have timestamps, this call enables you to revert to a specific version of the document.
- Rollback all changes from user-id
Uthat happened between timestampXandY:POST /rollback/{org}/{docid}?by=U&from=X&to=Y- This call enables you to undo all changes within a certain editing-interval.
- Rollback all changes of a certain user between two versions:
POST /rollback/{org}/{docid}body:{ by: userid, contentIds: Y.createContentIdsFromDocDiff(prevYDoc, nextYDoc) }
Visualize attributed changes using either pure deltas or by retrieving the before and after state of a Yjs doc. Optionally, include relevant attributions.
GET /changeset/{org}/docidparameters:{ from?: number, to?: number, by?: string, ydoc?: boolean, contentIds?: Y.ContentIds, delta?: boolean, attributions?: boolean, withCustomAttributions?: string }from/to: unix timestamp range filterby=string: comma-separated list of user-ids that matches the attributionswithCustomAttributions=string: filter by custom attributions usingkey:valuepairs, comma-separated (e.g.source:import,tag:v2). Only changes matching all specified attributions are included.contentIds: Changeset that describes the changes between two versions. @todo not implementedydoc=true: include the encoded document as it was atto— a single partially garbage-collected Yjs update. Deleted content outside the attribution window is gc'd; in-range deletes are kept restorable.delta=true: include the delta representation — the document attowith the in-range attributions highlighted.attributions=true: include the attributionsContentMap.- Returns
{ ydoc?: Uint8Array, attributions?: Y.ContentMap, delta?: Delta }.
The ydoc is the document at to; its alive content already is that point-in-time state, so you render its differences by applying it to a gc: false doc and overlaying the attributions with an AttributionsRenderer (see Rendering with AttributionsRenderer).
- Retrieve activity
GET /activity/{org}/{docid}?from={now-1day} - Optionally, bundle changes that belong to each other:
[1, 2, 70, 71] ⇒ [2, 71]- because1,2and70,71belong to each other. - For each timestamp:
GET /changeset/{org}/{docid}?from=timestamps[I - 1]&to=timestamps[I]&delta=true&attributions=true - The
deltarenders the document as it was atto, with the changes attributed to the[from, to]interval highlighted.
Retrieve all editing-timestamps for a certain document. Use the activity API and the changeset API to reconstruct an editing trail.
GET /activity/{org}/{docid}parameters:{ from?: number, to?: number, by?: string, limit?: number, order?: string, group?: boolean, groupMaxGap?: number, groupMaxDuration?: number, delta?: boolean, withCustomAttributions?: string, customAttributions?: boolean, contentIds?: string }from/to: unix timestamp range filterby=string: comma-separated list of user-ids to filter bywithCustomAttributions=string: filter by custom attributions usingkey:valuepairs, comma-separated (e.g.source:import,tag:v2). Only changes matching all specified attributions are included.contentIds=string: base64-encodedY.ContentIdsbinary. When provided, only activity entries whose content intersects the given content set are returned. Encode viabuffer.toBase64(Y.encodeContentIds(contentIds))(import * as buffer from 'lib0/buffer').limit=number: maximum number of entries to returnorder='asc'|'desc':"asc"(oldest first) or"desc"(newest first, default)group=boolean: bundle consecutive changes from the same user into a single entry (experimental)groupMaxGap=number: maximum time gap (in milliseconds) between consecutive changes by the same user that still merges them into a single entry (default:1000). Only applies when grouping is enabled.groupMaxDuration=number: maximum total span (in milliseconds) of a grouped entry (entry.to - entry.from). A change is not merged into a group if the resulting span would exceed this value (default: unlimited). Only applies when grouping is enabled.delta=boolean: include a delta representation for each activity entry — the document at that entry'sto, with the entry's changes highlighted.ydoc=boolean: return a single shared partially-gc'd document for the whole list, plus per entry arenderedContentIdSet (= content alive at the entry'sto). The response shape becomes{ ydoc, activity }. Render any entry client-side by applyingydocto agc: falsedoc and overlaying anAttributionsRendererwith that entry'srenderedContent(andattributions) — see Rendering with AttributionsRenderer.attributions=boolean: include each entry's attributionContentMap(asattributions: Uint8Array).customAttributions=true: include the list of custom attributions associated with each activity entry. When enabled, each entry includes acustomAttributionsfield containing deduplicated{ k, v }pairs collected from the underlying attribution attributes (e.g.insert:<key>). When grouping is enabled, custom attributions from merged entries are combined and deduplicated.- Returns
{ activity: Array<{ from: number, to: number, by: string?, delta?: Delta, renderedContent?: Uint8Array, attributions?: Uint8Array, customAttributions?: Array<{ k: string, v: string }> }>, ydoc?: Uint8Array }. The top-level shape is stable regardless ofydoc.ydocis present only whenydoc=true;renderedContenton each entry only whenydoc=true;attributionsonly whenattributions=true;customAttributionsonly whencustomAttributions=true.
Both APIs return Yjs updates plus attribution metadata that you render with @y/y's
AttributionsRenderer. Always apply the returned document to a gc: false doc so deleted
content can be restored for the diff.
Changeset — the returned ydoc is already the document at to, so the attributions alone
render the diff (its alive content is the point-in-time baseline):
import * as Y from '@y/y'
const doc = new Y.Doc({ gc: false })
Y.applyUpdate(doc, changeset.ydoc)
const delta = doc.get().toDelta({
renderer: Y.createAttributionsRenderer(Y.decodeContentMap(changeset.attributions))
})Activity — one shared ydoc is re-projected to each entry's moment via that entry's
renderedContent (the content alive at the entry's to):
const doc = new Y.Doc({ gc: false })
Y.applyUpdate(doc, res.ydoc)
const root = doc.share.keys().next().value || ''
res.activity.forEach(entry => {
const renderer = Y.createAttributionsRenderer(
Y.decodeContentMap(entry.attributions),
{ renderedContent: Y.decodeIdSet(entry.renderedContent) }
)
const delta = doc.get(root).toDeltaDeep({ renderer })
})When delta=true, the server performs exactly this rendering and returns the result as
changeset.delta / entry.delta.
Permanently prune churned history — content that was both inserted and deleted within the requested range. The pruned content is garbage-collected from the document's history so it no longer appears in the activity or changeset APIs, making the stored history more compact. Content that is still live (inserted but never deleted) and the document's current visible state are never affected.
Warning: pruning is irreversible. Once a worker bakes the prune into persistence, the removed history cannot be recovered. The operation is distributed as a directive on the Redis stream and applied the next time the document is retrieved or compacted.
POST /prune/{org}/{docid}body:{ from?: number, to?: number, by?: string, contentIds?: Y.ContentIds, withCustomAttributions?: Array<{ k: string, v: string }> }from/to: unix timestamp range filter. Only content whose insertion and deletion both fall within[from, to]is pruned.by=string: comma-separated list of user-ids that matches the attributionscontentIds: restrict pruning to the changes described by aY.ContentIdswithCustomAttributions: only prune content whose attributions match all specified key-value pairs- At least one filter is required; an empty body is rejected with
400. - Returns
{ success: true }.
Given the edits insert "a" → delete "a" → insert "b", the activity API can reconstruct all
three steps. Pruning a range that contains all three collapses the churned "a", so only
insert "b" remains visible — the insertion of "b" is kept because it was never deleted.
- Compact all churn within an editing interval:
POST /prune/{org}/{docid}body:{ from: X, to: Y } - Compact only a specific user's churn in that interval: body:
{ from: X, to: Y, by: U } - Compact the entire document history — pass an all-encompassing range: body:
{ from: 0, to: Number.MAX_SAFE_INTEGER }
import * as buffer from 'lib0/buffer'
const prune = body => fetch(`http://${yhubHost}/prune/${org}/${docid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: buffer.encodeAny(body)
})
// prune churn within a specific editing interval
await prune({ from: startTimestamp, to: endTimestamp })
// prune the entire history of the document
await prune({ from: 0, to: Number.MAX_SAFE_INTEGER })The activity API supplies the timestamps that drive a prune. Pruning the range spanned by two activity entries removes all the churn between them — effectively merging those two entries into a single step in the timeline:
import * as buffer from 'lib0/buffer'
// read the editing timeline (responses are lib0-encoded binary)
const res = await fetch(`http://${yhubHost}/activity/${org}/${docid}?group=false`)
const activity = buffer.decodeAny(new Uint8Array(await res.arrayBuffer()))
// merge activity entries `i..j`: prune everything that was inserted and deleted between them
await prune({ from: activity[i].from, to: activity[j].to })Define your own rest endpoints — served from the same process and guarded by the same auth plugin
as the built-in endpoints — via the server.api config section. Every custom endpoint lives under
/api/{version}/{name}/..., a namespace that is contractually reserved for your endpoints:
y/hub will never register a built-in route under /api/*.
import { createYHub, createAuthPlugin, apiError } from '@y/hub'
const yhub = await createYHub({
// ...
server: {
port: 8080,
auth: createAuthPlugin({ /* see below */ }),
api: [
// doc scope (default) → GET/POST /api/v1/comments/{org}/{docid}
{
name: 'comments',
accessPurpose: 'comments', // forwarded to getAccessType as `purpose`
get: async req => ({ comments: await readComments(req.room), viewer: req.authInfo.userid }),
post: async req => {
const { text } = await req.any()
if (!text) throw apiError(400, 'text is required')
await saveComment(req.room, req.authInfo.userid, text)
}
},
// item route sharing the collection's name → GET /api/v1/comments/{org}/{docid}/{commentId}
{ name: 'comments', path: '/:commentId', get: async req => await getComment(req.room, req.params.commentId) },
// a breaking revision of the same endpoint → GET /api/v2/comments/{org}/{docid}
{ name: 'comments', version: 'v2', get: async req => ({ comments: await readCommentsV2(req.room) }) },
// org scope → GET /api/v1/docs/{org}
{ name: 'docs', scope: 'org', get: async req => ({ docs: await listDocs(req.org) }) },
// global scope → GET /api/v1/stats
{ name: 'stats', scope: 'global', get: async req => ({ uptime: process.uptime() }) }
]
}
})server.api is an array of endpoint definitions, so multiple sources (your app, plugins) can
contribute endpoints by concatenation. One name may serve several routes with distinct url
depths — that's how a collection route and an item route (path: '/:commentId') share one
resource name. Two endpoints with the same (name, version) and the same url depth throw at
startup.
| Field | Type | Default | Description |
|---|---|---|---|
name |
string |
— | required; a single path segment (^[A-Za-z0-9_-]+$). Prefer camelCase — the name doubles as a property name in future typed clients. |
version |
string |
'v1' |
a single path segment; bump for breaking revisions of an endpoint |
scope |
'doc' | 'org' | 'global' |
'doc' |
route shape: /api/{version}/{name}/{org}/{docid}, /api/{version}/{name}/{org}, or /api/{version}/{name} |
path |
string |
'' |
extra named path segments appended to the route, e.g. '/:commentId' (named params only, available via req.params) |
accessPurpose |
string |
null |
forwarded as purpose to the auth access callback (see below) |
get, post, put, patch, delete |
(req) => any |
— | async handlers; at least one is required. get requires 'r' access, all other methods require 'rw'. |
Because names and versions are single segments, every request under /api/ has the fixed shape
/api/{version}/{apiname}/... — easy for proxies to inspect positionally.
Handlers are typed by scope: doc-scoped handlers receive a non-null req.room, org-scoped
handlers receive req.org only, global handlers neither. Plain object literals inside api: [...]
get these typings automatically. For endpoints defined in separate modules (where contextual typing
can't reach), use the createApiEndpoint helper — it also preserves the literal endpoint name for
tooling:
import { createApiEndpoint } from '@y/hub'
export const comments = createApiEndpoint('comments', {
get: async req => ({ comments: await readComments(req.room) }) // req.room: Room (non-null)
})Access requirements are automatic: get requires 'r', all other methods require 'rw'. The
customization point is the endpoint's accessPurpose, which the auth callbacks receive as the
trailing purpose argument:
createAuthPlugin({
async readAuthInfo (req) { /* unchanged */ },
// purpose is the accessPurpose of a custom api endpoint (null when unset). Built-in endpoints
// and websocket connections don't supply it - treat `purpose == null` (loose) as "no purpose".
async getAccessType (authInfo, room, purpose) {
if (purpose === 'comments') return 'rw' // e.g. allow commenting on read-only docs
if (purpose === 'moderation') return isAdmin(authInfo) ? 'rw' : null // admin-only endpoint
return lookupDocAccess(authInfo, room)
},
// authorize org-scoped custom endpoints. When missing, org-scoped endpoints deny all access.
async getOrgAccessType (authInfo, org, purpose) { return lookupOrgAccess(authInfo, org) },
// authorize global-scoped custom endpoints. When missing, global-scoped endpoints deny all access.
async getGlobalAccessType (authInfo, purpose) { return 'r' }
})Note that purpose is advisory: a getAccessType implementation that ignores it simply applies
the user's plain doc access to every doc-scoped endpoint. An endpoint's accessPurpose broadens or
narrows access only when the auth plugin acts on it.
Handlers receive a single request object. All properties are plain snapshots — safe to access at
any time, also after awaits:
| Property | Type | Description |
|---|---|---|
yhub |
YHub |
the yhub instance — query documents via yhub.getDoc(req.room, ...), access stream, persistence, computePool, agentTask |
method |
string |
'get' | 'post' | 'put' | 'patch' | 'delete' |
path |
string |
the request path, e.g. /api/v1/comments/acme/readme |
org |
string | null |
null for global scope |
docid, branch, room |
only set for doc scope; branch from ?branch= (default 'main') |
|
params |
{ [name]: string } |
the named path segments declared via path |
query |
URLSearchParams |
parsed url query |
headers |
{ [name]: string } |
lowercased request headers |
authInfo |
whatever readAuthInfo returned |
|
accessType |
'r' | 'rw' |
the granted access |
aborted |
boolean |
becomes true when the client disconnects — check between expensive steps and return early |
bytes() |
() => Promise<Uint8Array> |
the raw request body |
any() |
() => Promise<any> |
the request body, lib0-any-decoded |
| Handler returns | Response |
|---|---|
Response |
status, headers, and body are taken from the Response — the full-control escape hatch. The default CORS headers are added unless the Response sets its own Access-Control-Allow-Origin; content-length/transfer-encoding are managed by the server. |
undefined / null |
204 No Content |
string |
200, text/plain; charset=utf-8 |
Uint8Array / Buffer |
200, application/octet-stream, as-is |
| anything else | 200, application/x-lib0any, lib0-any-encoded — the same encoding all built-in endpoints use. The dedicated content type keeps the wire self-describing: clients decode by content type (x-lib0any → decodeAny, text/* → text, else raw bytes). |
Throw apiError(status, message, extra?) (exported by @y/hub) to respond with a specific status
code and an any-encoded { error: message, ...extra } body — use extra for machine-readable
fields, conventionally { code: 'comment-not-found' }. Any other exception is logged and produces
a generic 500 without leaking internals.
import * as buffer from 'lib0/buffer'
// call a custom endpoint
const res = await fetch(`http://${yhubHost}/api/v1/comments/${org}/${docid}`)
const { comments } = buffer.decodeAny(new Uint8Array(await res.arrayBuffer()))The YHub class is available when importing @y/hub directly. It exposes methods for reading and writing documents programmatically, bypassing the WebSocket and REST layers. This is useful for server-side scripts, migrations, and data pipelines.
import { createYHub } from '@y/hub'
const yhub = await createYHub(config)| Field | Type | Required | Description |
|---|---|---|---|
redis.url |
string |
yes | Redis connection URL |
redis.prefix |
string |
yes | Key prefix for all Redis entries (use a unique value per environment) |
redis.taskDebounce |
number |
no | Milliseconds before a worker picks up a compaction task. Default: 120 000 |
redis.minMessageLifetime |
number |
no | Minimum time in ms that update messages are kept in Redis streams before compaction. Default: 60 000 |
redis.cacheTtl |
number |
no | TTL in seconds for cached API responses. Default: 10 |
redis.clientOptions |
object |
no | Additional options passed to the node-redis client, e.g. { pingInterval: 10000 }. YHub still controls url; redis.socket is merged into the final socket config; clientOptions.scripts are merged with YHub's Lua scripts. |
redis.socket |
object |
no | Custom socket options merged into the Redis client socket config. See node-redis socket options for available options. |
postgres |
string |
yes | PostgreSQL connection string |
persistence |
PersistencePlugin[] |
yes | One or more storage plugins (e.g. S3PersistenceV1). At least one is required. |
computePoolSize |
number |
no | Worker threads in the compute pool for CPU-intensive Yjs work (merging, state vectors, changesets). Default: number of cpus - 1. Set this explicitly when the process is restricted to a subset of cores — os.cpus().length does not reflect taskset or cgroup limits. |
server |
object | null |
no | HTTP/WebSocket server config. Set to null to run without a server (worker/script mode). |
server.port |
number |
yes* | Port to listen on |
server.auth |
AuthPlugin |
yes* | Auth plugin created with createAuthPlugin. getAccessType(authInfo, room, purpose) receives the accessPurpose of custom api endpoints as purpose (null-ish otherwise). The optional getOrgAccessType(authInfo, org, purpose) / getGlobalAccessType(authInfo, purpose) callbacks authorize org-/global-scoped custom endpoints — when missing, endpoints of that scope deny all access. |
server.api |
ApiSpec[] |
no | Custom rest endpoints served under /api/{version}/{name}/.... See Custom API endpoints. |
server.maxDocSize |
number |
no | Maximum Ydoc size in bytes, used for WebSocket payload limits. Default: 500 MB |
worker |
object | null |
no | Background compaction worker config. Set to null to disable. |
worker.taskConcurrency |
number |
yes* | Maximum number of compaction tasks to process in parallel |
worker.events.docUpdate |
function |
no | Called after each compaction with the merged DocTable |
Example: full server setup
import { createYHub, createAuthPlugin } from '@y/hub'
import { S3PersistenceV1 } from '@y/hub/plugins/s3'
const yhub = await createYHub({
redis: {
url: 'redis://localhost:6379',
prefix: 'yhub:prod',
// Optional: pass node-redis client options, such as keepalive PINGs.
// clientOptions: { pingInterval: 10000 },
// Optional: custom socket options for TLS, etc.
// socket: { rejectUnauthorized: false, ca: fs.readFileSync('/path/to/ca.pem', 'utf-8') }
},
postgres: 'postgres://user:pass@localhost/yhub',
persistence: [
new S3PersistenceV1({ bucket: 'my-bucket', /* ... */ })
],
server: {
port: 8080,
auth: createAuthPlugin({
async readAuthInfo (req) { return { userid: req.getHeader('x-user-id') } },
async getAccessType (authInfo, room) { return 'rw' }
})
},
worker: { taskConcurrency: 10 }
})Example: script / worker-only mode (no HTTP server)
const yhub = await createYHub({
redis: { url: 'redis://localhost:6379', prefix: 'yhub:prod' },
postgres: 'postgres://user:pass@localhost/yhub',
persistence: [ new S3PersistenceV1({ /* ... */ }) ],
server: null,
worker: null
})Retrieve the current state of a document, merging any in-memory Redis updates with the persisted state.
yhub.getDoc(
room: { org: string, docid: string, branch: string },
include: {
gc?: boolean,
nongc?: boolean,
contentmap?: boolean,
contentids?: boolean,
references?: boolean,
awareness?: boolean
},
opts?: { gcOnMerge?: boolean } // default: true
): Promise<{
gcDoc: Uint8Array | null,
nongcDoc: Uint8Array | null,
contentmap: Uint8Array | null,
contentids: Uint8Array | null,
references: Array<{ assetId, asset }> | null,
awareness: Uint8Array | null,
lastClock: string,
lastPersistedClock: string
}>Only fields listed in include with a truthy value are populated; the rest are null. Set gcOnMerge: false to keep full history in the returned gcDoc.
Example
import * as Y from '@y/y'
const { gcDoc } = await yhub.getDoc(
{ org: 'my-org', docid: 'my-doc', branch: 'main' },
{ gc: true }
)
const ydoc = Y.createDocFromUpdate(gcDoc)Attribute and persist a Yjs update directly to the database, without distributing it via Redis or WebSocket. Multiple calls for the same room are merged on next retrieval.
Warning: connected clients will not see the changes until they reconnect.
yhub.unsafePersistDoc(
room: { org: string, docid: string, branch: string },
update: Uint8Array, // encoded Yjs update (e.g. Y.encodeStateAsUpdate)
attributions: { by?: string } // optional author user-id
): Promise<void>Example
import * as Y from '@y/y'
const ydoc = new Y.Doc()
ydoc.getText('content').insert(0, 'Hello from a script')
await yhub.unsafePersistDoc(
{ org: 'my-org', docid: 'my-doc', branch: 'main' },
Y.encodeStateAsUpdate(ydoc),
{ by: 'import-script' }
)Permanently prune churned history — content that was both inserted and deleted within the
filtered range. The prune is distributed via the Redis stream and baked into persistence on the next
compaction. This is the programmatic equivalent of POST /prune/{org}/{docid}.
Warning: pruning is irreversible. Live content (inserted but never deleted) and the current visible document state are never affected.
yhub.pruneDoc(
room: { org: string, docid: string, branch: string },
filters: {
from?: number,
to?: number,
by?: string,
contentIds?: Uint8Array, // encoded Y.ContentIds
withCustomAttributions?: Array<{ k: string, v: string }> | null
}
): Promise<void>Provide at least one filter. If no churned content matches the filters, the call is a no-op.
Example
const room = { org: 'my-org', docid: 'my-doc', branch: 'main' }
// Compact all churn from the last hour
await yhub.pruneDoc(room, { from: Date.now() - 60 * 60 * 1000, to: Date.now() })
// Compact the entire document history
await yhub.pruneDoc(room, { from: 0, to: Number.MAX_SAFE_INTEGER })Force a permission re-check for the WebSocket connections of a room — use it when permissions on a
document changed and connected clients should be affected immediately, not just on their next
connect. The directive is distributed via the Redis stream, so it reaches connections on all
servers. Each matching connection re-evaluates auth.getAccessType(authInfo, room) and is
disconnected with close code 4401 ('permission revoked') when its access type changed —
including an rw → r downgrade (the client reconnects, re-authenticates, and resyncs at its new
access level; a still-revoked client is rejected with 401 Unauthorized at upgrade). A failing
auth plugin fails closed: the connection is disconnected.
yhub.recheckAuth(
room: { org: string, docid: string, branch: string },
opts?: {
users?: Array<string | { [key: string]: any }> | null, // default: null = every connection
forceDisconnect?: boolean // default: false
}
): Promise<void>Matchers. users: null matches every connection in the room. A string matches connections with
that userid. A plain object matches a connection when each of its top-level properties deep-equals
the corresponding property of the connection's authInfo — the authInfo may have additional
properties, so { userid: 'X' } matches the authInfo { userid: 'X', name: 'Kevin' }, and {}
matches everything. Properties are compared with deep equality as a whole (no recursive subset
matching: { roles: ['editor'] } does not match an authInfo with roles: ['editor', 'admin']).
Matchers must be plain JSON-ish values from trusted code — values that don't survive lib0
encodeAny are unsupported: functions never match, while a Date or Map decodes to the empty
object {} and therefore matches every connection.
forceDisconnect: true disconnects matching connections without re-checking. This drops
sessions, it does not revoke access: clients auto-reconnect and re-authenticate within
milliseconds, so revoke in your auth backend first. Force-disconnecting every connection of a busy
room causes a reconnect thundering herd — all clients re-run the upgrade auth and initial sync at
once.
Client handling. @y/websocket reconnects indefinitely by default — a kicked, still-revoked
client retries every ≤2.5s, hitting your auth backend with a 401 each time. Handle the close code
in the app:
provider.on('connection-close', event => {
if (event?.code === 4401) provider.disconnect() // permission revoked - stop reconnecting
})Auth plugin contract. The re-check reuses the authInfo captured at connect (readAuthInfo
cannot be re-run — the original HTTP request is gone). getAccessType must consult a live
authority (database, permission service) for the re-check to be meaningful; if access is derived
from claims embedded in the authInfo itself (e.g. rooms listed in a JWT payload), a re-check
recomputes the same stale answer — rely on forceDisconnect plus short token TTLs instead.
There is deliberately no built-in REST route: expose it as a custom API endpoint
guarded by your own accessPurpose if you need HTTP access:
createApiEndpoint('recheck-auth', {
accessPurpose: 'admin',
post: async req => { await req.yhub.recheckAuth(req.room, await req.any()) }
})Rolling upgrades: servers and workers running a version older than this feature fail reading a room stream that contains an
auth:check:v1entry (for up tominMessageLifetime). Deploy the new version to all processes before the firstrecheckAuthcall.
Run an LLM agent task against a room. The handler receives a freshly hydrated Y.Doc (snapshot of the room's current state) and a new Awareness instance bound to it. Edits made inside the handler are streamed to all connected clients in real time with attribution derived from the options. The returned promise resolves with the handler's return value only after the agent's awareness has been cleared.
yhub.agentTask(
room: { org: string, docid: string, branch: string },
opts: {
author?: string, // user-id recorded as `insert` / `delete`
displayedAuthor?: string, // awareness `user.name` (defaults to `author`)
promptBy?: string, // sugar for customAttributions: [{ k: 'promptBy', v: promptBy }]
customAttributions?: Array<{ k: string, v: string }>,
clearAwareness?: number | false // seconds; 0 = immediate (default); false = leave in place
},
handler: (ydoc: Y.Doc, awareness: Awareness) => Promise<R> | R
): Promise<R>Behavior:
- The handler's
ydocis a snapshot at task start; concurrent edits from other clients during the task are not merged back in. The handler's own edits are still distributed live. authorflows into the standardinsert/deletecontent attributions, the same way the authenticated user-id does on the WS and REST paths.customAttributionsentries becomeinsert:${k}/delete:${k}attributions, matching thecustomAttributionsshape accepted byPATCH /ydocand the WebSocket query param.promptByis sugar for one such entry and is merged with any explicitcustomAttributions.displayedAuthoris pre-seeded into the agent's awareness as{ user: { name: displayedAuthor } }so other clients can render the agent (cursor labels, presence panels). It is never recorded in the contentmap. The handler can replace or augment it withawareness.setLocalState(...).- On success, awareness is cleared after
clearAwarenessseconds (default0= immediately;false= don't clear). On any error — from the handler or from the underlying stream forwarding — awareness is cleared immediately regardless ofclearAwareness, and the error is re-thrown from the returned promise.
Example
await yhub.agentTask(
{ org: 'my-org', docid: 'my-doc', branch: 'main' },
{
author: 'agent-user-id',
displayedAuthor: 'Claude',
promptBy: 'kevin-user-id',
customAttributions: [{ k: 'model', v: 'opus-4.7' }],
clearAwareness: 20
},
async (ydoc, awareness) => {
// awareness already advertises { user: { name: 'Claude' } }
ydoc.get().applyDelta(delta.create().insert('Hello from the agent').done())
}
)