⬆️(yhub) upgrade @y/hub to 0.10.1

Last version of @y/hub contains a fix we need, it fixes the websocket
reconnection loop for anonymous user on a read only document sending a
keepalive every 20 seconds. In both version 0.10.0 and 0.10.1 breaking
changes are introduced, one is impacting us by removing the
computePool.patchYdoc function used by the create-ydoc endpoint declated
in our yhub server. To fix it, we are doing what the version 0.9.0 was
doing and we keep the same behavior.
A new environment variable YHUB_AUTH_TIMEOUT_MS is added to configure
the server.authTimeout parameters in the createYhub options
This commit is contained in:
Manuel Raynaud
2026-10-16 14:22:10 +02:00
parent e9cde2ab2f
commit 82d43d01fe
15 changed files with 465 additions and 49 deletions
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to
- ♻️(frontend) replace outdated AGPL mentions of BlockNote XL packages #2775
- 💄(frontend) update upload loader and error ui #2778
- ⬆️(yhub) upgrade @y/hub to 0.10.1 #2853
## [v6.0.1] - 2026-10-15
+15
View File
@@ -16,6 +16,21 @@ the following command inside your docker container:
## [Unreleased]
- ⚠️ **The collaboration server moves to `@y/hub` 0.10.1: run its schema
script again before starting it.** It adds the `yhub_ydoc_versions_v1`
table, without which every hard deletion of a document fails, and two
indexes. The helm chart's `init-db` job does it on sync (`yhub.initDb`); with
docker compose, `make migrate-yhub`. Each index build holds a SHARE lock on
its table, which pauses compaction for a few seconds per million rows: run it
in a quiet moment.
- Redis 6.2 or later (or Valkey) is now required by the collaboration server.
- Roll its workers before its servers when they are deployed apart
(`yhub.worker.enabled`). The other order is harmless: a stale entry in a
Redis index until the document's next write.
- yhub now gives up on an authentication that takes longer than
`YHUB_AUTH_TIMEOUT_MS` (default 30000) and asks the caller to retry. Raise it
along with `YHUB_BACKEND_REQUEST_TIMEOUT_MS`.
## [6.0.0] - 2026-10-05
⚠️ This release replaces the collaboration server. The content of a document
+1
View File
@@ -203,6 +203,7 @@ documents what each of them changes.
| YHUB_TASK_DEBOUNCE_MS | How long an update waits on the redis stream before a worker persists it | 10000 |
| YHUB_MIN_MESSAGE_LIFETIME_MS | How long persisted updates stay replayable from redis rather than read back from postgres | 60000 |
| YHUB_BACKEND_REQUEST_TIMEOUT_MS | How long a call to the backend (`users/me`, `documents/{id}`, `accesses/me`) may take before it is given up on and the caller told to retry, in ms | 5000 |
| YHUB_AUTH_TIMEOUT_MS | How long yhub waits for the whole authentication of a connection (backend calls, legacy seed) before the caller is told to retry, in ms | 30000 |
| YHUB_IDENTITY_CACHE_TTL_MS | How long a resolved identity is reused for the same session cookie, in ms. 0 disables this layer (default) | 0 |
| YHUB_ABILITIES_CACHE_TTL_MS | How long a document abilities verdict is served from cache, in ms, before it is re-read; deleted cluster-wide by the reset-connections kick. 0 disables it | 0 |
| YHUB_ACCESS_CACHE_TTL_MS | How long an access date (the history bound) is served from cache, in ms. 0 disables it (default) | 0 |
+1
View File
@@ -442,6 +442,7 @@ Allow: /
| `yhub.envVars.YHUB_TASK_DEBOUNCE_MS` | How long an update waits on the redis stream before a worker persists it, in ms (default: 10000) | |
| `yhub.envVars.YHUB_MIN_MESSAGE_LIFETIME_MS` | How long persisted updates stay replayable from redis, in ms (default: 60000) | |
| `yhub.envVars.YHUB_BACKEND_REQUEST_TIMEOUT_MS` | How long a call to the backend may take before yhub gives up on it and tells the caller to retry, in ms (default: 5000) | |
| `yhub.envVars.YHUB_AUTH_TIMEOUT_MS` | How long yhub waits for the whole authentication of a connection or request (backend calls and legacy seed) before it tells the caller to retry, in ms (default: 30000) | |
| `yhub.envVars.SOFT_MIGRATION` | Set to "true" to seed rooms from the legacy Django/S3 document store on first access | |
| `yhub.envVars.LEGACY_S3_ENDPOINT_URL` | Required by SOFT_MIGRATION, endpoint of the legacy Django media bucket, without a path (e.g. https://s3.example.com) | |
| `yhub.envVars.LEGACY_S3_ACCESS_KEY_ID` | Required by SOFT_MIGRATION, read access to the legacy bucket (or LEGACY_S3_ACCESS_KEY_ID_FILE) | |
+1
View File
@@ -1189,6 +1189,7 @@ yhub:
## @extra yhub.envVars.YHUB_TASK_DEBOUNCE_MS How long an update waits on the redis stream before a worker persists it, in ms (default: 10000)
## @extra yhub.envVars.YHUB_MIN_MESSAGE_LIFETIME_MS How long persisted updates stay replayable from redis, in ms (default: 60000)
## @extra yhub.envVars.YHUB_BACKEND_REQUEST_TIMEOUT_MS How long a call to the backend may take before yhub gives up on it and tells the caller to retry, in ms (default: 5000)
## @extra yhub.envVars.YHUB_AUTH_TIMEOUT_MS How long yhub waits for the whole authentication of a connection or request (backend calls and legacy seed) before it tells the caller to retry, in ms (default: 30000)
## @extra yhub.envVars.YHUB_IDENTITY_CACHE_TTL_MS How long a resolved identity is reused for the same session cookie, in ms; 0 disables this layer (default)
## @extra yhub.envVars.YHUB_ABILITIES_CACHE_TTL_MS How long a document abilities verdict is served from cache, in ms, before it is re-read; deleted cluster-wide by the reset-connections kick; 0 disables it
## @extra yhub.envVars.YHUB_ACCESS_CACHE_TTL_MS How long an access date (the history bound) is served from cache, in ms; 0 disables it (default)
+1
View File
@@ -274,6 +274,7 @@ were configurable:
| `YHUB_TASK_DEBOUNCE_MS` | `10000` | How long an update waits on the stream before a worker persists it |
| `YHUB_MIN_MESSAGE_LIFETIME_MS` | `60000` | How long persisted updates stay replayable from redis |
| `YHUB_BACKEND_REQUEST_TIMEOUT_MS` | `5000` | How long a call to the Docs backend may take before it is given up on |
| `YHUB_AUTH_TIMEOUT_MS` | `30000` | How long yhub waits for `authenticate` or `authorize` as a whole (backend calls, legacy seed) before it answers 503; raise it with `YHUB_BACKEND_REQUEST_TIMEOUT_MS` |
| `YHUB_IDENTITY_CACHE_TTL_MS` | `0` | How long a resolved `users/me` identity is reused for the same Cookie header (authz cache, off by default) |
| `YHUB_ABILITIES_CACHE_TTL_MS` | `0` | How long a `documents/{id}` verdict is served from cache before it is re-read (deleted by `reset-connections`, see the authz-cache section) |
| `YHUB_ACCESS_CACHE_TTL_MS` | `0` | How long an `accesses/me` date is served from cache before it is re-read |
+266
View File
@@ -0,0 +1,266 @@
// src/api.ts — the custom REST endpoints. What is covered here is
// `create-ydoc`, the one handler that built its stream message out of a yhub
// internal: it diffed the posted update in the compute pool's `patchYdoc`,
// which yhub 0.9.1 removed. The handler wrapped that call in a catch-all that
// answers "Invalid Yjs update", so the removal turned every document creation
// into a 400 without a type error at runtime, and with no test failing.
//
// `@y/hub` is faked down to what the module imports; `@y/y` is real, so the
// updates are genuine and the contentmap is decoded the way yhub decodes it.
// The fake instance has the compute pool of yhub 0.10 — no `patchYdoc` — and
// these tests would answer 400 again if the handler went back to it.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as Y from '@y/y';
import { makeUpdate } from './_helpers.js';
vi.mock('@y/hub', () => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: () => logger,
};
return {
logger,
checkPermissions: vi.fn(),
createApiEndpoint: (name: string, opts: unknown) => ({ name, opts }),
createDocumentPermissions: (x: unknown) => x,
};
});
vi.mock('../src/backend.js', () => ({ backendPublicJwk: null }));
vi.mock('../src/authz-cache.js', () => ({ invalidateDocumentAuthz: vi.fn() }));
vi.mock('../src/migration.js', () => ({
SOFT_MIGRATION: false,
fullMigrate: vi.fn(),
}));
const DOCID = '00000000-0000-4000-8000-000000000000';
type Handler = (req: unknown) => Promise<Response>;
const createYdoc = async (): Promise<Handler> => {
const { api } = await import('../src/api.js');
const endpoint = (
api as unknown as Array<{
name: string;
opts: { post: { handler: Handler } };
}>
).find((e) => e.name === 'create-ydoc');
if (endpoint == null) throw new Error('create-ydoc is not mounted');
return endpoint.opts.post.handler;
};
// The surface of yhub 0.10 the handler reaches: the room read, the stream
// write, and a compute pool without the `patchYdoc` 0.9.1 removed.
const makeYhub = (gcDoc: Uint8Array | null = null) => ({
getDoc: vi.fn(async () => ({ gcDoc })),
stream: { addMessage: vi.fn(async () => {}) },
computePool: { mergeUpdates: vi.fn() },
});
const makeReq = (
body: Uint8Array,
{
yhub = makeYhub(),
authInfo = { userid: 'system', admin: true } as Record<
string,
unknown
> | null,
headers = { 'x-user-id': 'user-1' } as Record<string, string>,
} = {},
) => ({
org: 'docs',
docid: DOCID,
branch: 'main',
docRef: { org: 'docs', docid: DOCID, branch: 'main' },
// uws hands the body over as a Node Buffer, which the handler re-views
bytes: async () => Buffer.from(body),
headers,
authInfo,
permissions: {},
query: {},
yhub,
});
// Every attribute the contentmap puts on the inserted ids, by name.
const insertAttributes = (contentmap: Uint8Array) => {
const attrs = new Map<string, unknown>();
Y.decodeContentMap(contentmap).inserts.clients.forEach((ranges) =>
ranges
.getIds()
.forEach((range) =>
range.attrs.forEach((attr) => attrs.set(attr.name, attr.val)),
),
);
return attrs;
};
beforeEach(() => {
vi.resetModules();
});
describe('create-ydoc', () => {
it('creates the document without the compute pool’s removed patchYdoc', async () => {
const handler = await createYdoc();
const yhub = makeYhub();
const update = makeUpdate('hello world');
const res = await handler(makeReq(update, { yhub }));
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ message: 'Document created' });
expect(yhub.computePool.mergeUpdates).not.toHaveBeenCalled();
expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1);
});
it('appends the update as posted, as yhub’s own PATCH does since 0.9.1', async () => {
const handler = await createYdoc();
const yhub = makeYhub();
const update = makeUpdate('hello world');
await handler(makeReq(update, { yhub }));
const [docRef, message] = yhub.stream.addMessage.mock
.calls[0] as unknown as [
unknown,
{ type: string; update: Uint8Array; contentmap: Uint8Array },
];
expect(docRef).toEqual({ org: 'docs', docid: DOCID, branch: 'main' });
expect(message.type).toBe('ydoc:update:v1');
// an exact Uint8Array, not the Buffer `req.bytes()` resolved to: the
// stream-message schema compares constructors
expect(message.update.constructor).toBe(Uint8Array);
expect(Array.from(message.update)).toEqual(Array.from(update));
// and it is a document yhub can read back
const doc = new Y.Doc();
Y.applyUpdate(doc, message.update);
expect(doc.get('t', 'text').toString()).toBe('hello world');
});
it('attributes every inserted id to the user the admin token names, now', async () => {
const handler = await createYdoc();
const yhub = makeYhub();
const update = makeUpdate('hello world');
const before = Date.now();
await handler(makeReq(update, { yhub }));
const [, { contentmap }] = yhub.stream.addMessage.mock
.calls[0] as unknown as [unknown, { contentmap: Uint8Array }];
// the contentmap covers exactly the ids the update inserts
const covered = Y.createContentIdsFromContentMap(
Y.decodeContentMap(contentmap),
);
const posted = Y.createContentIdsFromUpdate(update);
expect(Y.excludeContentIds(posted, covered).inserts.clients.size).toBe(0);
expect(Y.excludeContentIds(covered, posted).inserts.clients.size).toBe(0);
// with the author and the timestamp the activity timeline reads
const attrs = insertAttributes(contentmap);
expect(attrs.get('insert')).toBe('user-1');
expect(attrs.get('insertAt')).toBeGreaterThanOrEqual(before);
expect(attrs.get('insertAt')).toBeLessThanOrEqual(Date.now());
});
it('attributes a user’s own create to them, whatever X-User-Id says', async () => {
const handler = await createYdoc();
const yhub = makeYhub();
await handler(
makeReq(makeUpdate(), {
yhub,
authInfo: { userid: 'user-2' },
headers: { 'x-user-id': 'someone-else' },
}),
);
const [, { contentmap }] = yhub.stream.addMessage.mock
.calls[0] as unknown as [unknown, { contentmap: Uint8Array }];
expect(insertAttributes(contentmap).get('insert')).toBe('user-2');
});
it('falls back to the admin identity when no X-User-Id is sent', async () => {
const handler = await createYdoc();
const yhub = makeYhub();
await handler(makeReq(makeUpdate(), { yhub, headers: {} }));
const [, { contentmap }] = yhub.stream.addMessage.mock
.calls[0] as unknown as [unknown, { contentmap: Uint8Array }];
expect(insertAttributes(contentmap).get('insert')).toBe('system');
});
it.each([
['truncated', makeUpdate('hello world').slice(0, -5)],
['garbage', new Uint8Array([7, 7, 7, 7, 7, 7, 7, 7, 7, 7])],
[
'corrupted',
(() => {
const update = makeUpdate('hello world');
update[5] ^= 0xff;
update[10] ^= 0xff;
return update;
})(),
],
])('refuses a %s update with 400 and writes nothing', async (_, update) => {
const handler = await createYdoc();
const yhub = makeYhub();
const res = await handler(makeReq(update, { yhub }));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: 'Invalid Yjs update' });
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
});
it('refuses an update that inserts nothing, which would create no room', async () => {
// a delete set alone is a valid update of more than three bytes
const doc = new Y.Doc();
doc.get('t', 'text').insert(0, 'abc');
const stateVector = Y.encodeStateVector(doc);
doc.get('t', 'text').delete(0, 3);
const deleteOnly = Y.encodeStateAsUpdate(doc, stateVector);
expect(deleteOnly.byteLength).toBeGreaterThan(3);
const handler = await createYdoc();
const yhub = makeYhub();
const res = await handler(makeReq(deleteOnly, { yhub }));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: 'Empty update' });
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
});
it('refuses an empty body before reading the room', async () => {
const handler = await createYdoc();
const yhub = makeYhub();
const res = await handler(makeReq(new Uint8Array([0, 0]), { yhub }));
expect(res.status).toBe(400);
expect(yhub.getDoc).not.toHaveBeenCalled();
});
it('answers 409 on a room that already has content, and writes nothing', async () => {
const handler = await createYdoc();
const yhub = makeYhub(makeUpdate('already here'));
const res = await handler(makeReq(makeUpdate(), { yhub }));
expect(res.status).toBe(409);
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
});
it('refuses an anonymous caller', async () => {
const handler = await createYdoc();
const yhub = makeYhub();
const res = await handler(makeReq(makeUpdate(), { yhub, authInfo: null }));
expect(res.status).toBe(401);
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
});
});
+23
View File
@@ -25,6 +25,7 @@ beforeEach(() => {
'YHUB_TASK_DEBOUNCE_MS',
'YHUB_MIN_MESSAGE_LIFETIME_MS',
'YHUB_BACKEND_REQUEST_TIMEOUT_MS',
'YHUB_AUTH_TIMEOUT_MS',
'YHUB_IDENTITY_CACHE_TTL_MS',
'YHUB_ABILITIES_CACHE_TTL_MS',
'YHUB_ACCESS_CACHE_TTL_MS',
@@ -117,6 +118,28 @@ describe('the numeric tuning knobs', () => {
vi.stubEnv('YHUB_BACKEND_REQUEST_TIMEOUT_MS', '5s');
await expect(load()).rejects.toThrow(/YHUB_BACKEND_REQUEST_TIMEOUT_MS/);
});
it('gives the auth hooks 30s, above the 10s yhub 0.10 defaults to', async () => {
// `authorize` chains two backend calls and the 10s S3 fetch of a legacy
// seed: yhub's own default would cut a slow seed short
const { AUTH_TIMEOUT_MS } = await load();
expect(AUTH_TIMEOUT_MS).toBe(30000);
vi.resetModules();
vi.stubEnv('YHUB_AUTH_TIMEOUT_MS', '45000');
expect((await load()).AUTH_TIMEOUT_MS).toBe(45000);
});
it('refuses an auth timeout that is not a positive whole number of ms', async () => {
vi.stubEnv('YHUB_AUTH_TIMEOUT_MS', '0');
await expect(load()).rejects.toThrow(
/YHUB_AUTH_TIMEOUT_MS must be an integer >= 1/,
);
vi.resetModules();
vi.stubEnv('YHUB_AUTH_TIMEOUT_MS', '30s');
await expect(load()).rejects.toThrow(/YHUB_AUTH_TIMEOUT_MS/);
});
});
describe('the origin allowlist', () => {
@@ -173,11 +173,43 @@ describe('everything the browser must not reach', () => {
'reset-connections',
'restore-ydoc',
'reset-ydoc',
// the built-ins yhub 0.9.1 and 0.10.1 added: named versions, and the
// listing of the documents an org changed
'version',
'updated',
'some-endpoint-added-later',
])('refuses %s to an editor — there is no "*" fallback', (name) => {
expect(grants(editor, { endpoint: { [name]: '-r--' } })).toBe(false);
expect(grants(editor, { endpoint: { [name]: 'c---' } })).toBe(false);
});
// yhub 0.9.1 added `version` (crud over named versions) and `publish` to the
// history facet. Docs builds its version panel from the activity alone, so
// neither is granted: a named version would outlive the access that wrote it.
it('grants no named versions to anyone, an editor included', () => {
for (const who of [reader, editor]) {
expect(
grants(who, { history: { from: ACCESS_SINCE, version: '-r--' } }),
).toBe(false);
expect(
grants(who, { history: { from: ACCESS_SINCE, version: 'c---' } }),
).toBe(false);
expect(
grants(who, { history: { from: ACCESS_SINCE, publish: true } }),
).toBe(false);
}
});
it('normalizes the two new history keys to nothing granted', () => {
// what yhub hands a handler as `req.permissions.history` since 0.9.1
expect(editor.history).toEqual({
from: ACCESS_SINCE,
rollback: true,
prune: false,
version: '----',
publish: false,
});
});
});
describe('the admin token', () => {
@@ -197,6 +229,16 @@ describe('the admin token', () => {
expect(grants(admin, { delete: ['hard'] })).toBe(false);
expect(grants(admin, { history: { from: 0, prune: true } })).toBe(false);
});
it('reaches the named-version route but is granted no version in it', () => {
// the `'*'` endpoint fallback covers yhub 0.9.1's `version` route, and the
// history facet is what still refuses every verb on it
expect(grants(admin, { endpoint: { version: 'crud' } })).toBe(true);
expect(grants(admin, { history: { from: 0, version: '-r--' } })).toBe(
false,
);
expect(grants(admin, { history: { from: 0, publish: true } })).toBe(false);
});
});
describe('the public global routes', () => {
+32
View File
@@ -107,6 +107,7 @@ beforeEach(() => {
'YHUB_TASK_DEBOUNCE_MS',
'YHUB_MIN_MESSAGE_LIFETIME_MS',
'YHUB_BACKEND_REQUEST_TIMEOUT_MS',
'YHUB_AUTH_TIMEOUT_MS',
'YHUB_S3_PERSISTENCE',
'YHUB_S3_ENDPOINT_URL',
'YHUB_S3_ACCESS_KEY_ID',
@@ -144,6 +145,19 @@ describe('a valid, minimal environment', () => {
expect(redis.minMessageLifetime).toBe(60000);
expect(worker.taskConcurrency).toBe(5);
});
it('hands yhub our auth timeout rather than leaving its 10s default', async () => {
// yhub 0.10 abandons an auth hook after `server.authTimeout`; left unset,
// a legacy seed slower than 10s would refuse the connection it serves
await boot();
expect(createYHub.lastConfig.server.authTimeout).toBe(30000);
});
it('threads YHUB_AUTH_TIMEOUT_MS into the yhub config', async () => {
vi.stubEnv('YHUB_AUTH_TIMEOUT_MS', '45000');
await boot();
expect(createYHub.lastConfig.server.authTimeout).toBe(45000);
});
});
describe('the process role', () => {
@@ -342,4 +356,22 @@ describe('the authorize authz-cache wiring', () => {
).resolves.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('denies the org scope, which closes yhub 0.10’s updated-documents listing', async () => {
// `GET /updated/v1/{org}` is authorized against the org scope: a plugin
// that answered it would list every changed document id of the org
const { authorize, fetchMock } = await bootAuthorize();
// the org scope's resource is the org name, not a docRef
const authorizeOrg = authorize as unknown as (
scope: 'org',
org: string,
user: unknown,
) => Promise<unknown>;
await expect(authorizeOrg('org', 'docs', user('u1'))).resolves.toBeNull();
await expect(
authorizeOrg('org', 'docs', { userid: 'system', admin: true }),
).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -17,8 +17,8 @@
"@aws-sdk/client-s3": "3.1146.0",
"@sentry/node": "10.75.0",
"@sentry/profiling-node": "10.75.0",
"@y/hub": "0.9.0",
"@y/y": "14.0.0-rc.25",
"@y/hub": "0.10.1",
"@y/y": "14.0.0-rc.29",
"jose": "6.2.12",
"lru-cache": "11.5.3",
"prom-client": "15.1.3"
+46 -28
View File
@@ -15,12 +15,12 @@ import {
createDocumentPermissions,
logger,
} from '@y/hub';
import * as Y from '@y/y';
import { backendPublicJwk } from './backend.js';
import { invalidateDocumentAuthz } from './authz-cache.js';
import {
EMPTY_UPDATE_MAX_BYTES,
EMPTY_YDOC,
MAX_CREATE_BYTES,
ORG,
READINESS_TIMEOUT_MS,
@@ -71,6 +71,30 @@ const eraseContent = async (
await yhubApi.persistence.deleteTombstone(docRef);
};
// The contentmap yhub's own `PATCH ydoc` stamps on an update (its
// `createContentMap`, which the package does not export): every id the update
// inserts or deletes, attributed to `userid` at this moment. The insertAt is
// what gives the seeded content its entry in the activity timeline.
const attributeTo = (
contentids: Y.ContentIds,
userid: string,
): Uint8Array<ArrayBuffer> => {
const now = Date.now();
return Y.encodeContentMap(
Y.createContentMapFromContentIds(
contentids,
[
Y.createContentAttribute('insert', userid),
Y.createContentAttribute('insertAt', now),
],
[
Y.createContentAttribute('delete', userid),
Y.createContentAttribute('deleteAt', now),
],
),
);
};
const readyLog = logger.child({ module: 'readiness' });
const resetLog = logger.child({ module: 'reset-ydoc' });
@@ -272,7 +296,7 @@ export const api = [
return jsonResponse(400, { error: 'Unknown branch' });
}
const body = await req.bytes();
// req.bytes() resolves to a Node Buffer, but the compute-task schema
// req.bytes() resolves to a Node Buffer, but the stream-message schema
// requires an exact Uint8Array (lib0 $constructedBy compares the
// constructor) — re-view the same bytes without copying
const update = new Uint8Array(
@@ -286,8 +310,8 @@ export const api = [
// ("HTTP/1.1 413 ") — legal, and callers switch on the code
return jsonResponse(413, { error: 'Update too large' });
}
// yhub's "no effective content" convention — reject before it reaches
// a worker
// yhub's "no effective content" convention — reject before reading the
// room
if (update.byteLength <= EMPTY_UPDATE_MAX_BYTES) {
return jsonResponse(400, { error: 'Empty update' });
}
@@ -316,38 +340,32 @@ export const api = [
const userid =
(authInfo.admin === true && req.headers['x-user-id']) ||
authInfo.userid;
let result;
let contentids: Y.ContentIds;
try {
// diffs the posted update against the (empty) current doc and
// stamps the attribution contentmap
result = await req.yhub.computePool.patchYdoc(
{
update,
currentDoc: gcDoc ?? EMPTY_YDOC,
userid,
customAttributions: [],
},
{ docRef: req.docRef },
);
// The structural scan yhub runs on an update before appending it, on
// the socket and in its own PATCH: a malformed or truncated update
// throws here. It used to be the compute worker's `patchYdoc` that
// threw, until yhub 0.9.1 removed it and started appending updates
// as they come. The update is the only untrusted input here, so a
// throw maps to 400; getDoc / addMessage failures stay generic 500s.
contentids = Y.createContentIdsFromUpdate(update);
} catch {
// a malformed update makes the compute worker throw (yhub logs
// 'worker failed' and replaces the thread). The update is the only
// untrusted input here, so a rejection maps to 400; getDoc /
// addMessage failures stay generic 500s.
return jsonResponse(400, { error: 'Invalid Yjs update' });
}
if (result == null) {
// structurally valid but no effective content (e.g. delete-set
// only). A "successful" create that leaves the room nonexistent
// would lie to the caller — a later create would not 409.
if (contentids.inserts.clients.size === 0) {
// structurally valid but no content (e.g. delete-set only). A
// "successful" create that leaves the room nonexistent would lie to
// the caller — a later create would not 409.
return jsonResponse(400, { error: 'Empty update' });
}
// on a fresh room this creates the stream, schedules compaction, and
// fans out to any live subscribers — nothing else to do
// Appended as posted, which is what yhub's PATCH does since 0.9.1: the
// room was checked empty above, so there is nothing to diff it against.
// On a fresh room this creates the stream, schedules compaction, and
// fans out to any live subscribers — nothing else to do.
await req.yhub.stream.addMessage(req.docRef, {
type: 'ydoc:update:v1',
contentmap: result.contentmap,
update: result.update,
contentmap: attributeTo(contentids, userid),
update,
});
return jsonResponse(201, { message: 'Document created' });
},
+10 -5
View File
@@ -152,15 +152,12 @@ export const YHUB_AUDIENCE = 'yhub';
// miss its S3 object and fork the document's lineage)
export const UUID4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
// an empty Yjs update (what `Y.encodeStateAsUpdate(new Y.Doc())` encodes to) —
// hardcoded so we don't import @y/y for two bytes
export const EMPTY_YDOC = new Uint8Array([0, 0]);
// yhub's "no effective content" convention: an empty update encodes to 2 bytes,
// and anything up to 3 is read as an empty document
export const EMPTY_UPDATE_MAX_BYTES = 3;
// uws buffers the whole body before the handler sees it, so this cap does not
// bound upload memory — it bounds what a single create hands to a compute
// worker and writes to the valkey stream as one message. Creates carry one
// bound upload memory — it bounds what a single create scans on the main
// thread and writes to the valkey stream as one message. Creates carry one
// freshly-converted snapshot (typically KBs); anything bigger belongs on the
// websocket path.
export const MAX_CREATE_BYTES = 10 * 1024 * 1024;
@@ -178,6 +175,14 @@ export const BACKEND_REQUEST_TIMEOUT_MS = intEnv(
'YHUB_BACKEND_REQUEST_TIMEOUT_MS',
5000,
);
// How long yhub waits for `authenticate` or `authorize` before it gives up on
// the call and answers 503 (a websocket recheck closes with 1013); yhub
// defaults to 10s since 0.10.0. Our `authorize` chains two backend calls and,
// on the first open of a legacy document, its seed from S3 (10s on its own),
// so 10s would turn every slow seed into a refused connection even though each
// of those steps is bounded already. This is the backstop above them, and it
// has to be raised along with YHUB_BACKEND_REQUEST_TIMEOUT_MS.
export const AUTH_TIMEOUT_MS = intEnv('YHUB_AUTH_TIMEOUT_MS', 30000);
// The authz cache (see authz-cache.ts): how long each layer may serve what a
// previous connection read from the backend. All three default to 0 — caching
// off, the behavior yhub has always had — and a deployment turns them up once
+5
View File
@@ -23,6 +23,7 @@ import {
import {
ANONYMOUS_USERID,
API_PREFIX,
AUTH_TIMEOUT_MS,
BACKEND_REQUEST_TIMEOUT_MS,
MIN_MESSAGE_LIFETIME_MS,
ORG,
@@ -468,6 +469,9 @@ const yhub: YHub = (await createYHub({
// on a cross-origin `fetch`; it is also why the list has to be concrete, browsers
// refusing "*" together with Access-Control-Allow-Credentials.
cors: { origin: allowedOrigins, credentials: true },
// see AUTH_TIMEOUT_MS: yhub's own default is shorter than the legacy
// seed `authorize` may legitimately wait on
authTimeout: AUTH_TIMEOUT_MS,
}
: null,
worker: RUNS_WORKER
@@ -502,6 +506,7 @@ logger.info(
taskDebounceMs: yhub.stream.taskDebounce,
minMessageLifetimeMs: yhub.stream.minMessageLifetime,
backendRequestTimeoutMs: BACKEND_REQUEST_TIMEOUT_MS,
authTimeoutMs: RUNS_SERVER ? AUTH_TIMEOUT_MS : null,
},
'yhub configuration',
);
+19 -14
View File
@@ -823,16 +823,16 @@
resolved "https://registry.yarnpkg.com/@y-crdt/yn/-/yn-0.1.4.tgz#8cd8329bcf0b30a7614a10fbd1018dde5ab54f45"
integrity sha512-BrRpTE4tvONSx+hYXbpZERu7Cx3/xyHFNXKNnBd9/maTJNsCGaVwfkfH3PN9fx/IrIXxa3GTjX0t8zJam273BQ==
"@y/hub@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@y/hub/-/hub-0.9.0.tgz#9a323fd43cb2e728920b9d9d51358f347fc08656"
integrity sha512-xGzLoPpJ1lrZo++ULu0IAs3bnLpjCNhsEgHcRiIf+XaTO5iu7GjwnbO6HGqI4LiP45Dn952kkh+WrmhK8qkfaQ==
"@y/hub@0.10.1":
version "0.10.1"
resolved "https://registry.yarnpkg.com/@y/hub/-/hub-0.10.1.tgz#6e0145c1bdcab3ac26283a673ad850556b9e841f"
integrity sha512-etcSmaZs4ItqArc0v+bwIixs03quzDBMGJWixpNHA27eZj+AutHMlykXWuSz6H41J/wGYPd6mCLdiYbSdiEGJQ==
dependencies:
"@y-crdt/yn" "^0.1.4"
"@y/protocols" "^1.0.6-rc.1"
"@y/y" "^14.0.0-rc.24"
lib0 "^1.0.0-rc.27"
minio "^8.0.6"
"@y/y" "^14.0.0-rc.29"
lib0 "^1.0.0-rc.36"
minio "^8.0.7"
pino "^10.3.1"
postgres "^3.4.3"
redis "^5.10.0"
@@ -845,12 +845,12 @@
dependencies:
lib0 "^1.0.0-rc.1"
"@y/y@14.0.0-rc.25", "@y/y@^14.0.0-rc.24":
version "14.0.0-rc.25"
resolved "https://registry.yarnpkg.com/@y/y/-/y-14.0.0-rc.25.tgz#e5b2d825406984dc71fe65a71977ffe005ffd273"
integrity sha512-kG61I9GLF85aNtBelJKcf6D6PhLoWUBWQ2kyTAKwOWRUnuCkzGRCp3gUdwcTB9TINtgC9w5kPAKzstNDIpwSXw==
"@y/y@14.0.0-rc.29", "@y/y@^14.0.0-rc.29":
version "14.0.0-rc.29"
resolved "https://registry.yarnpkg.com/@y/y/-/y-14.0.0-rc.29.tgz#0a0a4edea1e8f64308ab5e0ee62dd4bde8bf3dd0"
integrity sha512-A4W9Bnrfj7N7IgampoaMSygJlzShLkWZ2uqKjx92tsEqGnbjpfr7jFP1i3zQ37h3L1cXIeipr9QDneUrZHuDjA==
dependencies:
lib0 "^1.0.0-rc.28"
lib0 "^1.0.0-rc.35"
acorn-jsx@^5.3.2:
version "5.3.2"
@@ -1374,11 +1374,16 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
lib0@^1.0.0-rc.1, lib0@^1.0.0-rc.27, lib0@^1.0.0-rc.28:
lib0@^1.0.0-rc.1:
version "1.0.0-rc.30"
resolved "https://registry.yarnpkg.com/lib0/-/lib0-1.0.0-rc.30.tgz#2c2aec2c91a99230891918e0a551472a6cc8052e"
integrity sha512-WhIbHSbBMttOT3Ke/HDA5+kIf9KRDSd5L+aJMBkvJSxwqL7w7004/0X5fjXj8loBIm5PkdgSMRmlPjC2mIzuCw==
lib0@^1.0.0-rc.35, lib0@^1.0.0-rc.36:
version "1.0.0-rc.36"
resolved "https://registry.yarnpkg.com/lib0/-/lib0-1.0.0-rc.36.tgz#aa0b3cdfacb275c2cbce401cae3c8a99a3a49cc2"
integrity sha512-7t/I+PCQqakal22rhXq5giOUtDP/RfecQtdeKvzCtnAvPkxSLAE+/Zvzi/kJDopg+hBAYKo46qmxmlkhX0tYlQ==
lightningcss-android-arm64@1.33.0:
version "1.33.0"
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz#9a6841f88ae50fc83502903892b41af41bc2b907"
@@ -1496,7 +1501,7 @@ minimatch@^10.2.1, minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5:
dependencies:
brace-expansion "^5.0.8"
minio@^8.0.6:
minio@^8.0.7:
version "8.0.7"
resolved "https://registry.yarnpkg.com/minio/-/minio-8.0.7.tgz#6a651151f3d2df1077ca894e46efc47f4a1ec25c"
integrity sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==