⚗️(collab) yhub experiment

This commit is contained in:
Manuel Raynaud
2026-06-11 10:28:14 +02:00
parent 7ae175fda3
commit 20c664beb7
69 changed files with 4664 additions and 1176 deletions
+14
View File
@@ -60,11 +60,25 @@ jobs:
should_push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
docker_user: 1001:127
# build-and-push-yhub:
# uses: ./.github/workflows/docker-publish.yml
# permissions:
# contents: read
# secrets: inherit
# with:
# image_name: lasuite/impress-yhub
# context: .
# file: src/frontend/servers/yhub/Dockerfile
# target: yhub
# should_push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
# docker_user: 1001:127
notify-argocd:
needs:
- build-and-push-backend
- build-and-push-frontend
- build-and-push-y-provider
# - build-and-push-yhub
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
steps:
+47
View File
@@ -158,3 +158,50 @@ jobs:
run: |
docker system prune -af
docker volume prune -f
# build-and-push-yhub:
# runs-on: ubuntu-latest
# if: github.event.repository.fork == true
# permissions:
# contents: read
# packages: write
# steps:
# - name: Checkout repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# - name: Set up QEMU
# uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
# - name: Docker meta
# id: meta
# uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
# with:
# images: ${{ env.REGISTRY }}/${{ github.repository }}/yhub
# tags: |
# type=ref,event=branch
# type=ref,event=pr
# type=semver,pattern={{version}}
# type=semver,pattern={{major}}.{{minor}}
# type=sha
# - name: Login to GHCR
# uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
# with:
# registry: ${{ env.REGISTRY }}
# username: ${{ github.actor }}
# password: ${{ secrets.GITHUB_TOKEN }}
# - name: Build and push
# uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
# with:
# context: .
# file: ./src/frontend/servers/yhub/Dockerfile
# target: yhub
# platforms: linux/amd64,linux/arm64
# build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
# push: true
# tags: ${{ steps.meta.outputs.tags }}
# labels: ${{ steps.meta.outputs.labels }}
# - name: Cleanup Docker after build
# if: always()
# run: |
# docker system prune -af
# docker volume prune -f
+12 -5
View File
@@ -189,7 +189,7 @@ bootstrap-e2e: \
build: cache ?=
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@$(MAKE) build-yhub cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
.PHONY: build
@@ -203,6 +203,11 @@ build-yjs-provider: ## build the y-provider container
@$(COMPOSE) build y-provider-development $(cache)
.PHONY: build-yjs-provider
build-yhub: cache ?=
build-yhub: ## build the yhub collaboration server container
@$(COMPOSE) build yhub-development $(cache)
.PHONY: build-yhub
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend-development $(cache)
@@ -212,7 +217,8 @@ build-e2e: cache ?=
build-e2e: ## build the e2e container
@$(MAKE) build-backend cache=$(cache)
@$(COMPOSE_E2E) build frontend $(cache)
@$(COMPOSE_E2E) build y-provider $(cache)
@$(COMPOSE_E2E) build yhub $(cache)
@$(COMPOSE_E2E) build y-provider-converter $(cache)
.PHONY: build-e2e
nginx-frontend: ## build the nginx-frontend container
@@ -231,7 +237,7 @@ run-backend: ## Start only the backend application and all needed services
@$(MAKE) create-docker-network
@$(COMPOSE) up --force-recreate -d docspec
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider-development
@$(COMPOSE) up --force-recreate -d yhub-development
@$(COMPOSE) up --force-recreate -d y-provider-development-converter
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend
@@ -245,9 +251,10 @@ run:
run-e2e: ## start the e2e server
run-e2e:
@$(MAKE) run-backend
@$(COMPOSE_E2E) stop y-provider-development
@$(COMPOSE_E2E) stop yhub-development
@$(COMPOSE_E2E) stop y-provider-development-converter
@$(COMPOSE_E2E) up --force-recreate -d frontend
@$(COMPOSE_E2E) up --force-recreate -d y-provider
@$(COMPOSE_E2E) up --force-recreate -d yhub
@$(COMPOSE_E2E) up --force-recreate -d y-provider-converter
.PHONY: run-e2e
+23 -14
View File
@@ -13,7 +13,29 @@ services:
ports:
- "3000:3000"
y-provider:
yhub:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/servers/yhub/Dockerfile
target: yhub
image: impress:yhub-production
restart: unless-stopped
environment:
- REDIS_URL=redis://redis:6379
- POSTGRES_URL=postgres://dinum:pass@postgresql:5432/impress
env_file:
- env.d/development/common
- env.d/development/common.local
ports:
- "4444:4444"
depends_on:
redis:
condition: service_healthy
postgresql:
condition: service_healthy
y-provider-converter:
user: ${DOCKER_USER:-1000}
build:
context: .
@@ -24,16 +46,3 @@ services:
env_file:
- env.d/development/common
- env.d/development/common.local
ports:
- "4444:4444"
y-provider-converter:
user: ${DOCKER_USER:-1000}
image: impress:y-provider-production
restart: unless-stopped
env_file:
- env.d/development/common
- env.d/development/common.local
depends_on:
y-provider:
condition: service_started
+35 -5
View File
@@ -17,7 +17,13 @@ services:
- ./docker/files/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:ro
redis:
image: redis:5
# ≥ 6.2 required by the yhub collaboration server (XAUTOCLAIM)
image: redis:7
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 1s
timeout: 2s
retries: 60
mailcatcher:
image: sj26/mailcatcher:latest
@@ -192,8 +198,6 @@ services:
env_file:
- env.d/development/common
- env.d/development/common.local
ports:
- "4444:4444"
volumes:
- ./src/frontend/:/home/frontend
- /home/frontend/node_modules
@@ -210,9 +214,35 @@ services:
- ./src/frontend/:/home/frontend
- /home/frontend/node_modules
- /home/frontend/servers/y-provider/node_modules
# depends_on:
# y-provider-development:
# condition: service_started
yhub-development:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/servers/yhub/Dockerfile
target: yhub-development
image: impress:yhub-development
restart: unless-stopped
environment:
- REDIS_URL=redis://redis:6379
- POSTGRES_URL=postgres://dinum:pass@postgresql:5432/impress
env_file:
- env.d/development/common
- env.d/development/common.local
ports:
- "4444:4444"
volumes:
- ./src/frontend/:/home/frontend
- /home/frontend/node_modules
- /home/frontend/servers/yhub/node_modules
depends_on:
y-provider-development:
condition: service_started
redis:
condition: service_healthy
postgresql:
condition: service_healthy
kc_postgresql:
image: postgres:14.3
@@ -55,14 +55,19 @@ server {
try_files $uri @proxy_to_docs_backend;
}
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Collaboration server (yhub).
# Transition setup: frontend bundles still in browser caches use the old
# Hocuspocus URL form `/collaboration/ws/?room={uuid}` (exact match below,
# routed to y-provider); current bundles use the y-websocket form
# `/collaboration/ws/{uuid}` (regex, routed to yhub). Drop the exact-match
# block and YPROVIDER_HOST WS routing once the fleet has converged.
location = /collaboration/ws/ {
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
# Legacy collaboration server (Hocuspocus)
proxy_pass http://${YPROVIDER_HOST}:4444;
# Set appropriate timeout for WebSocket
@@ -75,9 +80,28 @@ server {
proxy_set_header Host $host;
}
location ~ ^/collaboration/ws/[0-9a-fA-F-]{36}$ {
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server (yhub)
proxy_pass http://${YHUB_HOST}:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Origin $http_origin;
proxy_set_header Host $host;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://${YPROVIDER_HOST}:4444;
# Collaboration server (yhub)
proxy_pass http://${YHUB_HOST}:4444;
proxy_set_header Host $host;
}
+20
View File
@@ -41,6 +41,8 @@ services:
redis:
condition: service_started
# Conversion endpoint only (/api/convert/); realtime collaboration is
# served by the yhub service below.
y-provider:
image: lasuite/impress-y-provider:latest
user: ${DOCKER_USER:-1000}
@@ -48,6 +50,24 @@ services:
- env.d/common
- env.d/yprovider
# Realtime collaboration server (routed by nginx via ${YHUB_HOST}).
# Requires Redis >= 6.2 and a PostgreSQL database for its document cache.
yhub:
image: lasuite/impress-yhub:latest
user: ${DOCKER_USER:-1000}
environment:
- REDIS_URL=redis://redis:6379
# Must match the credentials in env.d/postgresql
- POSTGRES_URL=postgres://docs:${DB_PASSWORD}@postgresql:5432/docs
env_file:
- env.d/common
- env.d/yprovider
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_started
frontend:
image: lasuite/impress-frontend:latest
user: "101"
+1 -1
View File
@@ -72,7 +72,7 @@ OIDC_RS_ALLOWED_AUDIENCES=""
USER_RECONCILIATION_FORM_URL=http://localhost:3000
# Collaboration
COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
COLLABORATION_API_URL=http://yhub-development:4444/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
+1 -1
View File
@@ -1,6 +1,6 @@
# For the CI job test-e2e
BURST_THROTTLE_RATES="1000/minute"
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
COLLABORATION_API_URL=http://yhub:4444/collaboration/api/
SUSTAINED_THROTTLE_RATES="1000/minute"
Y_PROVIDER_API_BASE_URL=http://y-provider-converter:4444/api/
+1
View File
@@ -4,6 +4,7 @@ S3_HOST=storage.domain.tld
BACKEND_HOST=backend
FRONTEND_HOST=frontend
YPROVIDER_HOST=y-provider
YHUB_HOST=yhub
BUCKET_NAME=docs-media-storage
REALM_NAME=docs
#COLLABORATION_WS_URL=wss://${DOCS_HOST}/collaboration/ws/
@@ -7,6 +7,14 @@ import { openSuggestionMenu, writeInEditor } from './utils-editor';
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
/**
* The y-websocket provider builds the collaboration WebSocket URL as
* `${serverUrl}/${documentId}` — a path segment, no `?room=` query string.
*/
const COLLABORATION_WS_URL_PATTERN = /\/collaboration\/ws\/[0-9a-f-]{36}$/;
const isCollaborationWsUrl = (url: string) =>
COLLABORATION_WS_URL_PATTERN.test(url);
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
@@ -20,9 +28,7 @@ test.describe('Doc Collaboration', () => {
*/
test('checks the connection with collaborative server', async ({ page }) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
return isCollaborationWsUrl(webSocket.url());
});
await page
@@ -32,9 +38,7 @@ test.describe('Doc Collaboration', () => {
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
expect(webSocket.url()).toMatch(COLLABORATION_WS_URL_PATTERN);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
@@ -60,9 +64,7 @@ test.describe('Doc Collaboration', () => {
// Check the ws is connected again
webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
return isCollaborationWsUrl(webSocket.url());
});
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
@@ -210,18 +212,14 @@ test.describe('Doc Collaboration', () => {
const webSocketPromise = otherPage.waitForEvent(
'websocket',
(webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
return isCollaborationWsUrl(webSocket.url());
},
);
await otherPage.goto(urlChildDoc);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
expect(webSocket.url()).toMatch(COLLABORATION_WS_URL_PATTERN);
await verifyDocName(otherPage, childTitle);
@@ -287,9 +285,7 @@ test.describe('Doc Collaboration', () => {
await page.goto('/');
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
return isCollaborationWsUrl(webSocket.url());
});
await page
@@ -299,9 +295,7 @@ test.describe('Doc Collaboration', () => {
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
expect(webSocket.url()).toMatch(COLLABORATION_WS_URL_PATTERN);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
@@ -330,9 +324,7 @@ test.describe('Doc Collaboration', () => {
// Check the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
return isCollaborationWsUrl(webSocket.url());
});
// Simulate the tab becoming visible again
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -43,7 +43,6 @@
"@gouvfr-lasuite/cunningham-react": "4.3.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.23.2",
"@hocuspocus/provider": "3.4.4",
"@mantine/core": "9.2.1",
"@mantine/hooks": "9.2.1",
"@react-aria/live-announcer": "3.5.0",
@@ -78,6 +77,7 @@
"use-debounce": "10.1.1",
"uuid": "14.0.0",
"y-protocols": "1.0.7",
"y-websocket": "3.0.0",
"yjs": "*",
"zod": "4.4.3",
"zustand": "5.0.13"
@@ -13,5 +13,10 @@ export const useCollaborationUrl = (room?: string) => {
? `wss://${window.location.host}/collaboration/ws/`
: '');
return `${base}?room=${room}`;
/**
* The y-websocket provider builds `${serverUrl}/${room}` itself; stripping
* the trailing slash keeps existing COLLABORATION_WS_URL values (which end
* with `/`) working unchanged.
*/
return base.replace(/\/+$/, '');
};
@@ -27,15 +27,9 @@ export function useComments(
encodeURIComponent(user?.full_name || ''),
canComment,
),
provider?.document,
provider?.doc,
);
}, [
docId,
canComment,
provider?.awareness,
provider?.document,
user?.full_name,
]);
}, [docId, canComment, provider?.awareness, provider?.doc, user?.full_name]);
useEffect(() => {
if (canComment) {
@@ -28,8 +28,8 @@ vi.mock('../../doc-management', async () => {
useIsCollaborativeEditable: () => ({ isEditable: true, isLoading: false }),
useProviderStore: () => ({
provider: {
configuration: { name: 'test-doc-id' },
document: {
roomname: 'test-doc-id',
doc: {
getXmlFragment: () => null,
},
},
@@ -17,11 +17,10 @@ import {
ThreadsSidebar,
useCreateBlockNote,
} from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { Awareness } from 'y-protocols/awareness';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
@@ -85,7 +84,7 @@ export const blockNoteSchema = (withMultiColumn?.(baseBlockNoteSchema) ||
interface BlockNoteEditorProps {
doc: Doc;
provider: HocuspocusProvider;
provider: WebsocketProvider;
}
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
@@ -93,7 +92,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { setEditor } = useEditorStore();
const { themeTokens } = useCunninghamTheme();
const refEditorContainer = useRef<HTMLDivElement>(null);
useSaveDoc(doc.id, provider.document);
useSaveDoc(doc.id, provider.doc);
const { i18n, t } = useTranslation();
const langLocalesBN =
@@ -150,8 +149,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const editor: DocsBlockNoteEditor = useCreateBlockNote(
{
collaboration: {
provider: provider as { awareness?: Awareness | undefined },
fragment: provider.document.getXmlFragment('document-store'),
provider: provider,
fragment: provider.doc.getXmlFragment('document-store'),
user: {
name: cursorName,
color: randomColor(),
@@ -142,16 +142,10 @@ interface DocCoreEditorProps {
export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
const { provider, isReady } = useProviderStore();
const isProviderReady = isReady && provider;
const showContent = !!(
isProviderReady && provider?.configuration.name === doc.id
);
const showContent = !!(isProviderReady && provider?.roomname === doc.id);
const { skeletonVisible, isFadingOut } = useSkeletonFadeOut(showContent);
if (
skeletonVisible ||
!isProviderReady ||
provider?.configuration.name !== doc.id
) {
if (skeletonVisible || !isProviderReady || provider?.roomname !== doc.id) {
return (
<SkeletonEditorCore
isFadingOut={isFadingOut}
@@ -165,7 +159,7 @@ export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
if (readOnly) {
return (
<BlockNoteReader
initialContent={provider.document.getXmlFragment('document-store')}
initialContent={provider.doc.getXmlFragment('document-store')}
docId={doc.id}
/>
);
@@ -71,7 +71,7 @@ export const useCollaboration = (room: string) => {
* when the document visibility changes.
*/
useEffect(() => {
if (!room || broadcastProvider?.document?.guid !== room) {
if (!room || broadcastProvider?.doc?.guid !== room) {
return;
}
@@ -80,7 +80,7 @@ export const useCollaboration = (room: string) => {
queryKey: [KEY_DOC, { id: room }],
});
});
}, [addTask, room, queryClient, broadcastProvider?.document?.guid]);
}, [addTask, room, queryClient, broadcastProvider?.doc?.guid]);
/**
* Set the provider when the collaboration URL and the document content are available.
@@ -52,7 +52,7 @@ export const useSaveDoc = (docId: string, yDoc: Y.Doc) => {
* so we check if the origin constructor to know where
* the transaction comes from.
* "PluginKey" constructor comes from the current user, but transaction.local is more reliable
* "HocuspocusProvider" constructor comes from other users from the collaboration server,
* "WebsocketProvider" constructor comes from other users from the collaboration server,
* it seems quite reliable too.
* The AI constructor name seems to not be reliable enough, but by deduction if it's not local
* and not from other users, it has to be from the AI.
@@ -61,7 +61,7 @@ export const useSaveDoc = (docId: string, yDoc: Y.Doc) => {
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const transactionOrigin = transaction?.origin?.constructor?.name;
const PROVIDER_ORIGIN_CONSTRUCTOR = 'HocuspocusProvider';
const PROVIDER_ORIGIN_CONSTRUCTOR = 'WebsocketProvider';
const isAIChange =
!transaction.local && transactionOrigin !== PROVIDER_ORIGIN_CONSTRUCTOR;
@@ -70,14 +70,12 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
mutationFn: async (variables) => {
// Save the document if we can first, to ensure the latest state is duplicated
const canSave =
variables.canSave &&
provider &&
provider.document.guid === variables.docId;
variables.canSave && provider && provider.doc.guid === variables.docId;
if (canSave) {
await updateDocContent({
id: variables.docId,
content: toBase64(Y.encodeStateAsUpdate(provider.document)),
content: toBase64(Y.encodeStateAsUpdate(provider.doc)),
});
}
@@ -1,5 +1,4 @@
import { CloseEvent } from '@hocuspocus/common';
import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { create } from 'zustand';
@@ -7,15 +6,15 @@ import { Base64 } from '@/docs/doc-management';
export interface UseCollaborationStore {
createProvider: (
providerUrl: string,
serverUrl: string,
storeId: string,
initialDoc?: Base64,
) => HocuspocusProvider;
) => WebsocketProvider;
destroyProvider: () => void;
setReady: (value: boolean) => void;
pauseForInactivity: () => void;
resumeFromInactivity: () => void;
provider: HocuspocusProvider | undefined;
provider: WebsocketProvider | undefined;
isConnected: boolean;
isReady: boolean;
isSynced: boolean;
@@ -33,7 +32,29 @@ const defaultValues = {
isPausedForInactivity: false,
};
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
/**
* Close code sent by the collaboration server when Django resets the
* connections of a room (permission change). The provider auto-reconnects
* and re-authenticates against the gateway with its updated rights.
*/
const KICK_CLOSE_CODE = 4000;
/**
* The gateway rejects unauthorized or WS-blocked connections at the HTTP
* level, before the WebSocket handshake. After this many consecutive failed
* attempts we mark the editor as ready so the user can work without
* collaboration (direct Django save fallback) — but the provider keeps
* retrying in the background (backoff capped at MAX_BACKOFF_TIME_MS), so a
* transient outage such as a server deploy recovers automatically.
*/
const READY_WITHOUT_COLLAB_FAILURES = 3;
/**
* Cap on y-websocket's exponential reconnect backoff. The default (2.5s)
* would hammer a WS-blocked network forever; 30s keeps retries cheap while
* still recovering from outages without a page reload.
*/
const MAX_BACKOFF_TIME_MS = 30000;
/**
* When a massive simultaneous disconnection occurs (e.g. infra restart), all
@@ -41,15 +62,13 @@ type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
* time, causing a possible DB spike. Adding random jitter spreads these events over a
* time window so the load is absorbed gradually.
*/
const RECONNECT_BASE_DELAY_MS = 1000;
const RECONNECT_JITTER_MAX_MS = 3000;
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined;
let lostConnectionTimeout: ReturnType<typeof setTimeout> | undefined;
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
...defaultValues,
createProvider: (wsUrl, storeId, initialDoc) => {
createProvider: (serverUrl, storeId, initialDoc) => {
const doc = new Y.Doc({
guid: storeId,
});
@@ -58,102 +77,79 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
}
const provider = new HocuspocusProvider({
url: wsUrl,
name: storeId,
document: doc,
onDisconnect(data) {
// Skip reconnect when the disconnect was triggered by inactivity:
// reconnection only happens once the user becomes active again.
if (get().isPausedForInactivity) {
return;
}
const provider = new WebsocketProvider(serverUrl, storeId, doc, {
// Cross-tab sync is handled by useBroadcastStore through the shared
// Y.Doc tasks; the provider reconnection logic is enough here.
disableBc: true,
maxBackoffTime: MAX_BACKOFF_TIME_MS,
});
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
if ((data.event as ExtendedCloseEvent).wasClean) {
if (data.event.reason === 'No cookies' && data.event.code === 4001) {
console.error(
'Disconnection due to missing cookies. Not attempting to reconnect.',
);
void provider.disconnect();
set({
isReady: true,
isConnected: false,
});
return;
}
let consecutiveFailures = 0;
clearTimeout(reconnectTimeout);
provider.on('status', ({ status }) => {
const isConnected = status === 'connected';
const wasConnected = get().isConnected;
// Jitter spreading for reconnection attempts
// Math.random() generates a random delay to avoid all clients
// reconnecting at the same time
reconnectTimeout = setTimeout(
() => void provider.connect(),
RECONNECT_BASE_DELAY_MS + Math.random() * RECONNECT_JITTER_MAX_MS,
);
}
},
onAuthenticationFailed() {
set({ isReady: true, isConnected: false });
},
onAuthenticated() {
set({ isReady: true, isConnected: true });
},
onStatus: ({ status }) => {
const isConnected = status === WebSocketStatus.Connected;
const wasConnected = get().isConnected;
if (isConnected) {
// Connected implies authenticated: the gateway authenticates against
// the backend before accepting the WebSocket handshake.
consecutiveFailures = 0;
clearTimeout(lostConnectionTimeout);
set({ isConnected: true, isReady: true });
return;
}
if (isConnected) {
clearTimeout(lostConnectionTimeout);
}
// If we were previously connected and now we're not,
// we might have lost the connection
else if (wasConnected && !get().isPausedForInactivity) {
clearTimeout(lostConnectionTimeout);
// Jitter spreading for reconnection attempts
// Math.random() generates a random delay to avoid all clients
// reconnecting at the same time
lostConnectionTimeout = setTimeout(
() => set({ hasLostConnection: true }),
Math.random() * RECONNECT_JITTER_MAX_MS,
);
}
// If we were previously connected and now we're not,
// we might have lost the connection
if (
status === 'disconnected' &&
wasConnected &&
!get().isPausedForInactivity
) {
clearTimeout(lostConnectionTimeout);
// Math.random() generates a random delay to avoid all clients
// refetching at the same time
lostConnectionTimeout = setTimeout(
() => set({ hasLostConnection: true }),
Math.random() * RECONNECT_JITTER_MAX_MS,
);
}
set((state) => {
/**
* status === WebSocketStatus.Connected does not mean we are totally connected
* because authentication can still be in progress and failed
* So we only update isConnected when we lose the connection
*/
const connected =
status !== WebSocketStatus.Connected
? {
isConnected: false,
}
: undefined;
set((state) => ({
isConnected: false,
isReady: state.isReady || status === 'disconnected',
}));
});
return {
...connected,
isReady: state.isReady || status === WebSocketStatus.Disconnected,
};
provider.on('sync', (isSynced: boolean) => {
set({ isSynced, isReady: true });
});
provider.on('connection-close', (event) => {
if (event?.code === KICK_CLOSE_CODE) {
// Server-side reset: the automatic reconnection re-authenticates
// with up-to-date permissions. Not a failure.
consecutiveFailures = 0;
}
});
provider.on('connection-error', () => {
if (get().isPausedForInactivity) {
return;
}
consecutiveFailures += 1;
if (consecutiveFailures === READY_WITHOUT_COLLAB_FAILURES) {
// Unblock the editor (Django save fallback); reconnection attempts
// keep running in the background and re-enable collaboration when
// the server becomes reachable again.
console.warn(
'Collaboration server unreachable; editing without realtime sync.',
);
set({
isReady: true,
isConnected: false,
});
},
onSynced: ({ state }) => {
set({ isSynced: state, isReady: true });
},
onClose(data) {
/**
* Handle the "Reset Connection" event from the server
* This is triggered when the server wants to reset the connection
* for clients in the room.
* A disconnect is made automatically but it takes time to be triggered,
* so we force the disconnection here.
*/
if (data.event.code === 1000) {
provider.disconnect();
}
},
}
});
set({
@@ -163,7 +159,6 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
return provider;
},
destroyProvider: () => {
clearTimeout(reconnectTimeout);
clearTimeout(lostConnectionTimeout);
const provider = get().provider;
if (provider) {
@@ -177,7 +172,6 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
if (get().isPausedForInactivity) {
return;
}
clearTimeout(reconnectTimeout);
clearTimeout(lostConnectionTimeout);
set({ isPausedForInactivity: true, hasLostConnection: false });
get().provider?.disconnect();
@@ -188,7 +182,7 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
}
clearTimeout(lostConnectionTimeout);
set({ isPausedForInactivity: false });
void get().provider?.connect();
get().provider?.connect();
},
resetLostConnection: () => set({ hasLostConnection: false }),
}));
@@ -58,11 +58,7 @@ export const ModalConfirmationVersion = ({
return;
}
revertUpdate(
provider.document,
provider.document,
base64ToYDoc(version.content),
);
revertUpdate(provider.doc, provider.doc, base64ToYDoc(version.content));
threadStore?.refreshThreads();
@@ -20,8 +20,7 @@ export const RightPanel = () => {
const { setIsPanelOpen, isPanelOpen, activePanel } = useRightPanelStore();
const { isMobile } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
const isProviderReady =
isReady && provider && provider?.configuration.name === doc?.id;
const isProviderReady = isReady && provider && provider?.roomname === doc?.id;
const { restoreFocus } = useFocusStore();
/**
@@ -1,4 +1,4 @@
import { HocuspocusProvider } from '@hocuspocus/provider';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { create } from 'zustand';
@@ -6,10 +6,10 @@ interface BroadcastState {
addTask: (taskLabel: string, action: () => void) => void;
broadcast: (taskLabel: string) => void;
cleanupBroadcast: () => void;
getBroadcastProvider: () => HocuspocusProvider | undefined;
getBroadcastProvider: () => WebsocketProvider | undefined;
handleProviderSync: () => void;
provider?: HocuspocusProvider;
setBroadcastProvider: (provider: HocuspocusProvider) => void;
provider?: WebsocketProvider;
setBroadcastProvider: (provider: WebsocketProvider) => void;
setTask: (
taskLabel: string,
task: Y.Array<string>,
@@ -34,10 +34,10 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
// Clean up old provider listeners
const oldProvider = get().provider;
if (oldProvider) {
oldProvider.off('synced', get().handleProviderSync);
oldProvider.off('sync', get().handleProviderSync);
}
provider.on('synced', get().handleProviderSync);
provider.on('sync', get().handleProviderSync);
set({ provider });
},
handleProviderSync: () => {
@@ -61,7 +61,7 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
return;
}
const task = provider.document.getArray<string>(taskLabel);
const task = provider.doc.getArray<string>(taskLabel);
get().setTask(taskLabel, task, action);
},
setTask: (taskLabel: string, task: Y.Array<string>, action: () => void) => {
@@ -102,7 +102,7 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
cleanupBroadcast: () => {
const provider = get().provider;
if (provider) {
provider.off('synced', get().handleProviderSync);
provider.off('sync', get().handleProviderSync);
}
// Unobserve all document-specific tasks
+90
View File
@@ -30144,6 +30144,96 @@
"dependencies": {
"@y/hub": "^0.2.22"
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+6 -4
View File
@@ -17,19 +17,21 @@
"APP_E2E": "yarn workspace app-e2e",
"I18N": "yarn workspace packages-i18n",
"COLLABORATION_SERVER": "yarn workspace server-y-provider",
"YHUB_SERVER": "yarn workspace server-yhub",
"app:dev": "yarn APP_IMPRESS run dev",
"app:start": "yarn APP_IMPRESS run start",
"app:build": "yarn APP_IMPRESS run build",
"app:test": "yarn APP_IMPRESS run test",
"ci:build": "yarn APP_IMPRESS run build:ci",
"build": "yarn APP_IMPRESS run build && yarn COLLABORATION_SERVER run build",
"build": "yarn APP_IMPRESS run build && yarn COLLABORATION_SERVER run build && yarn YHUB_SERVER run build",
"e2e:test": "yarn APP_E2E run test",
"lint": "yarn APP_IMPRESS run lint && yarn APP_E2E run lint && yarn workspace eslint-plugin-docs run lint && yarn I18N run lint && yarn COLLABORATION_SERVER run lint",
"lint": "yarn APP_IMPRESS run lint && yarn APP_E2E run lint && yarn workspace eslint-plugin-docs run lint && yarn I18N run lint && yarn COLLABORATION_SERVER run lint && yarn YHUB_SERVER run lint",
"i18n:extract": "yarn I18N run extract-translation",
"i18n:deploy": "yarn I18N run format-deploy && yarn APP_IMPRESS prettier",
"i18n:test": "yarn I18N run test",
"test": "yarn server:test && yarn app:test",
"server:test": "yarn COLLABORATION_SERVER run test"
"test": "yarn server:test && yarn yhub:test && yarn app:test",
"server:test": "yarn COLLABORATION_SERVER run test",
"yhub:test": "yarn YHUB_SERVER run test"
},
"resolutions": {
"@tiptap/extensions": "3.23.6",
+19 -5
View File
@@ -1,10 +1,24 @@
# Collaboration server migration: y-provider (Hocuspocus) → yhub (@y/hub)
> Status: **proposed plan** — analysis date 2026-06-10, against `@y/hub@0.2.22`.
> Status: **implemented (Phase 1)** — plan written and executed 2026-06-10, against
> `@y/hub@0.2.22`.
> Scope: a brand-new collaboration server in `src/frontend/servers/yhub`, replacing the
> realtime part of `src/frontend/servers/y-provider`. The conversion endpoint
> (`POST /api/convert/`) is **out of scope**: it stays in y-provider and will be moved to a
> dedicated application later.
>
> Implementation status:
> - Step 0 spike **passed 13/13** (yjs-13 ↔ @y/y-14-rc round-trip incl. incremental
> merges/gc/state-vectors/PG path; purge primitive; HTTP-level auth rejection).
> - Server implemented in `servers/yhub` (44 unit + 2 integration tests, lint/build clean).
> - Frontend swapped to `y-websocket` (tsc/eslint clean, 250 impress tests pass).
> - Infra done: Dockerfile (node:22-trixie-slim, `yarn cache clean` in install layers),
> compose (`yhub-development` on :4444, redis:7, y-provider keeps the converter role),
> e2e compose (`yhub` + `y-provider-converter`), Makefile targets, nginx prod
> dual-location template, docker-hub/ghcr publish jobs, `uws` lockfile entry switched
> to git+https (CI-friendly).
> - Remaining: full-stack Playwright e2e run; production rollout per §3.12; Phase 2
> (server-side write-back) is unstarted by design.
Hard constraints driving every decision below:
@@ -213,7 +227,7 @@ same auth plugin.
| **Durable room state** (Redis+PG) vs Hocuspocus amnesia | Stale cache can resurrect content after an out-of-band Django change (restore/import). Needs an invalidation strategy (§3.5) |
| Merge-only persistence — no rewind primitive | "Django wins" requires actual row deletion + stream trim, not `unsafePersistDoc` |
| ESM-only; package `exports` allow only `.` and `./plugins/s3` | New server must be ESM; no reaching into internals like `protocol.js` |
| `uws` glibc-only | Docker base `node:22-slim`, **not** alpine |
| `uws` glibc-only | Docker base `node:22-trixie-slim` (glibc ≥ 2.38), **not** alpine or Debian ≤ 12 |
| Redis ≥ 6.2 required (`XAUTOCLAIM` for worker task claiming) | Dev compose runs `redis:5` — must upgrade |
| `@y/y@14-rc` server-side vs `yjs` 13 in the browser | Wire compatibility is the design intent ("y-websocket compatible") but must be **proven by a spike before anything else** (§3.10 step 0) |
| Beta (0.2.x), CORS hardcoded `*` on its REST API | Pin the exact version; keep the uws server internal-only |
@@ -366,7 +380,7 @@ same `_FILE` secret pattern, same logger style).
package.json "type": "module"; deps: @y/hub 0.2.22 (PINNED), express 5.2.1,
ws ^8, axios 1.16.1, redis ^5, cors 2.8.6, @sentry/node;
devDeps += y-websocket + yjs + y-protocols (test clients)
Dockerfile node:22-slim (uws is glibc-only — NEVER alpine), y-provider stages
Dockerfile node:22-trixie-slim (uws needs glibc ≥ 2.38 — never alpine), y-provider stages
src/
env.ts config (§3.7)
routes.ts route constants
@@ -444,7 +458,7 @@ New:
healthy; side-by-side on host port `4453`, swapped to `4444` at switchover). Dev
reuses the `postgresql:16` impress DB (`yhub_ydoc_v1` is namespaced); production
guidance: dedicated database/instance so cache churn doesn't share Django's DB.
- **Dockerfile**: mirror y-provider's stages on `node:22-slim`, with a loud comment that
- **Dockerfile**: mirror y-provider's stages on `node:22-trixie-slim`, with a loud comment that
alpine breaks `uws` at *runtime*, plus the AGPL notice (§3.13 #4).
- **nginx prod template**: introduce `${YHUB_HOST}`; during transition:
- `location = /collaboration/ws/` (exact: old `?room=` URLs from cached bundles) →
@@ -464,7 +478,7 @@ New:
booted `createYHub` (`stream.addMessage` → `getDoc` → re-apply in yjs 13; BlockNote
fragment `document-store`) — *if the @y/y-14 ↔ yjs-13 binary formats don't round-trip,
the architecture needs a transcode step and this plan stops here*; (b) redis:7 smoke
incl. Django; (c) `createYHub` boots on node:22-slim; (d) observe upgrade-rejection
incl. Django; (c) `createYHub` boots in the target container image; (d) observe upgrade-rejection
statuses and `maxDocSize`-exceeded behavior.
1. Scaffold (`package.json` rewrite to ESM, configs copied, `/ping`, Sentry) — builds,
starts, pings.
+72
View File
@@ -0,0 +1,72 @@
# uws (a @y/hub dependency) only ships glibc prebuilt binaries requiring
# glibc >= 2.38: this image must stay on a Debian >= 13 (trixie) Node image.
# Switching to alpine/musl or an older Debian would fail at RUNTIME (not
# build time) when loading uws_linux_*.node.
FROM node:22-trixie-slim AS base
# Upgrade system packages to install security updates.
# git is required by yarn to fetch the uws dependency (git+https).
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends git ca-certificates && \
rm -rf /var/lib/apt/lists/*
FROM base AS yhub-deps
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/servers/yhub/package.json ./servers/yhub/package.json
COPY ./src/frontend/packages/eslint-plugin-docs/package.json ./packages/eslint-plugin-docs/package.json
# yarn v1 caches every package of the workspace lockfile (several GB);
# clean it in the same layer to keep the image small.
RUN yarn install && yarn cache clean
COPY ./src/frontend/packages/eslint-plugin-docs ./packages/eslint-plugin-docs
COPY ./src/frontend/servers/yhub ./servers/yhub
FROM yhub-deps AS yhub-development
WORKDIR /home/frontend/servers/yhub
EXPOSE 4444
CMD [ "yarn", "dev"]
FROM yhub-deps AS yhub-builder
WORKDIR /home/frontend/servers/yhub
RUN yarn build
FROM base AS yhub
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/servers/yhub/package.json ./servers/yhub/package.json
WORKDIR /home/frontend/servers/yhub
COPY --from=yhub-builder \
/home/frontend/servers/yhub/dist \
./dist
RUN NODE_ENV=production yarn install --frozen-lockfile && yarn cache clean
# Remove npm, contains CVE related to cross-spawn and we don't use it.
RUN rm -rf /usr/local/bin/npm /usr/local/lib/node_modules/npm
ENV NODE_OPTIONS="--max-old-space-size=2048"
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["yarn", "start"]
@@ -0,0 +1,13 @@
# License notice — @y/hub (AGPL-3.0)
This server embeds [`@y/hub`](https://github.com/yjs/yhub), which is
dual-licensed **AGPL-3.0** / proprietary. The project uses it under the AGPL.
Consequence: running this collaboration server as a network service triggers
the AGPL source-availability obligation for the combined work. That obligation
is satisfied by this repository being publicly available; the project's own
code stays MIT-licensed, `@y/hub` itself remains AGPL.
If this ever needs to change (closed-source deployment, objection to AGPL),
a proprietary license must be obtained from the @y/hub author
(kevin.jahns at pm.me) — see `node_modules/@y/hub/README.md`.
+66
View File
@@ -0,0 +1,66 @@
# yhub — docs collaboration server
Realtime collaboration server for docs, built on [`@y/hub`](https://github.com/yjs/yhub)
(see `LICENSE-NOTICE.md` for the AGPL implications). It replaces the realtime part of
`servers/y-provider`; the conversion endpoint (`/api/convert/`) stays in y-provider.
Architecture, rationale and rollout plan: `../YHUB_MIGRATION_PLAN.md`.
## How it works
One Node process containing two layers:
- **Gateway** (Express + raw WebSocket upgrade, port `4444`, the only published port):
authenticates browsers against Django with their cookies
(`GET /api/v1.0/documents/{id}/` abilities), keeps the connection registry in Redis
(kick + count across replicas), purges a room's cached state when its first client
connects (Django is the source of truth — yhub state is a disposable cache), then
byte-pipes the WebSocket to the embedded @y/hub server using a single-use token.
- **Embedded @y/hub** (uws server on `YHUB_INTERNAL_PORT`, never published): the actual
y-websocket relay — Redis streams for fan-out, Postgres (`yhub_ydoc_v1`, blobs inline)
for spillover persistence, plus the compaction worker.
HTTP surface (contracts identical to y-provider):
| Route | Purpose |
|---|---|
| `WS /collaboration/ws/{room}` | y-websocket endpoint (cookies + allowed origin required) |
| `POST /collaboration/api/reset-connections/?room=` (+ opt. `x-user-id` header) | kick connections (close code `4000`), fanned out to all instances via Redis pub/sub |
| `GET /collaboration/api/get-connections/?room=&sessionKey=` | `{count, exists}` for Django's save arbitration (404 on empty room) |
| `GET /ping` | health check |
## Configuration
Carried over from y-provider: `PORT` (4444), `COLLABORATION_LOGGING`,
`COLLABORATION_SERVER_ORIGIN`, `COLLABORATION_SERVER_SECRET[_FILE]`,
`Y_PROVIDER_API_KEY[_FILE]`, `COLLABORATION_BACKEND_BASE_URL`, `SENTRY_DSN`.
New:
| Variable | Default | Purpose |
|---|---|---|
| `REDIS_URL` | `redis://redis:6379` | streams + registry + kick pub/sub (**Redis ≥ 6.2**) |
| `POSTGRES_URL` | dev impress DB | `yhub_ydoc_v1` table (dedicated DB recommended in production) |
| `YHUB_REDIS_PREFIX` | `yhub` | namespace for all Redis keys |
| `YHUB_INTERNAL_PORT` | `4445` | embedded uws server — never expose it |
| `YHUB_TASK_DEBOUNCE` / `YHUB_MIN_MESSAGE_LIFETIME` / `YHUB_TASK_CONCURRENCY` / `YHUB_MAX_DOC_SIZE` | @y/hub defaults | tuning passthrough |
| `CONN_TTL_SECONDS` / `CONN_HEARTBEAT_SECONDS` | `30` / `10` | registry liveness (crashed instances self-clean) |
| `AUTH_TOKEN_TTL_MS` | `10000` | gateway → embedded-yhub one-time token TTL |
## Develop & test
```bash
yarn dev # nodemon (build + start)
yarn test # unit tests (no infrastructure needed)
# integration tests need a real Redis ≥ 7 and PostgreSQL:
docker run -d -p 16379:6379 redis:7
docker run -d -p 15432:5432 -e POSTGRES_USER=yhub -e POSTGRES_PASSWORD=yhub \
-e POSTGRES_DB=yhub postgres:16-alpine
REDIS_URL=redis://localhost:16379 \
POSTGRES_URL=postgres://yhub:yhub@localhost:15432/yhub yarn test:integration
```
The Docker image must stay on a glibc ≥ 2.38 base (`node:22-trixie-slim`): `uws`
ships no musl binaries and its glibc builds require 2.38+ — alpine or Debian ≤ 12
fail at runtime, not at build time.
@@ -0,0 +1,51 @@
import { describe, expect, test } from 'vitest';
import { createGatewayAuthPlugin } from '@/yhubauth/authPlugin';
import { issueToken } from '@/yhubauth/internalToken';
const uwsRequest = (query: string) =>
({ getQuery: () => query }) as Parameters<
ReturnType<typeof createGatewayAuthPlugin>['readAuthInfo']
>[0];
describe('gateway auth plugin', () => {
const plugin = createGatewayAuthPlugin();
test('readAuthInfo resolves claims for a valid token', async () => {
const token = issueToken({ userid: 'u1', room: 'r1', canEdit: true });
await expect(
plugin.readAuthInfo(uwsRequest(`yauth=${token}`)),
).resolves.toEqual({
userid: 'u1',
room: 'r1',
canEdit: true,
});
});
// The plugin throws synchronously (before any await) — the upstream uws
// upgrade handler wraps the call in try/catch, so this surfaces as a 401.
test('readAuthInfo throws without a token', () => {
expect(() => plugin.readAuthInfo(uwsRequest(''))).toThrow();
});
test('readAuthInfo throws for a replayed token (single use)', async () => {
const token = issueToken({ userid: 'u1', room: 'r1', canEdit: true });
await plugin.readAuthInfo(uwsRequest(`yauth=${token}`));
expect(() => plugin.readAuthInfo(uwsRequest(`yauth=${token}`))).toThrow();
});
test.each([
[{ org: 'docs', branch: 'main', docid: 'r1' }, true, 'rw'],
[{ org: 'docs', branch: 'main', docid: 'r1' }, false, 'r'],
[{ org: 'docs', branch: 'main', docid: 'other' }, true, null],
[{ org: 'evil', branch: 'main', docid: 'r1' }, true, null],
[{ org: 'docs', branch: 'dev', docid: 'r1' }, true, null],
])(
'getAccessType room binding: %o canEdit=%s → %s',
async (room, canEdit, expected) => {
await expect(
plugin.getAccessType({ userid: 'u1', room: 'r1', canEdit }, room),
).resolves.toBe(expected);
},
);
});
@@ -0,0 +1,62 @@
import axios from 'axios';
import { describe, expect, test, vi } from 'vitest';
vi.mock('../src/env', () => ({
COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
Y_PROVIDER_API_KEY: 'test-yprovider-key',
}));
describe('collaborationBackend', () => {
test('fetchDocument forwards cookie/origin and the X-Y-Provider-Key header', async () => {
const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
status: 200,
data: {
id: 'test-doc-id',
abilities: { retrieve: true, update: true },
},
});
const { fetchDocument } = await import('@/api/collaborationBackend');
await fetchDocument('test-document-123', {
cookie: 'test-cookie',
origin: 'http://localhost:3000',
});
expect(axiosGetSpy).toHaveBeenCalledWith(
'http://app-dev:8000/api/v1.0/documents/test-document-123/',
expect.objectContaining({
headers: expect.objectContaining({
'X-Y-Provider-Key': 'test-yprovider-key',
cookie: 'test-cookie',
origin: 'http://localhost:3000',
}),
}),
);
axiosGetSpy.mockRestore();
});
test('fetchCurrentUser forwards headers', async () => {
const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
status: 200,
data: { id: 'test-user-id', email: 'test@example.com' },
});
const { fetchCurrentUser } = await import('@/api/collaborationBackend');
await fetchCurrentUser({ cookie: 'test-cookie' });
expect(axiosGetSpy).toHaveBeenCalledWith(
'http://app-dev:8000/api/v1.0/users/me/',
expect.objectContaining({
headers: expect.objectContaining({
'X-Y-Provider-Key': 'test-yprovider-key',
cookie: 'test-cookie',
}),
}),
);
axiosGetSpy.mockRestore();
});
});
@@ -0,0 +1,85 @@
/**
* Minimal in-memory stand-in for the node-redis v5 client, covering only the
* surface the gateway uses (registry keys, lock, kick pub/sub). Preferred
* over a mocking library so SET NX / TTL semantics are real.
*/
export class FakeRedis {
store = new Map<string, { value: string; expiresAt: number | null }>();
published: Array<{ channel: string; message: string }> = [];
private isExpired(key: string): boolean {
const entry = this.store.get(key);
return !!entry && entry.expiresAt !== null && entry.expiresAt <= Date.now();
}
private liveEntry(key: string) {
if (this.isExpired(key)) {
this.store.delete(key);
}
return this.store.get(key);
}
set(
key: string,
value: string,
opts?: { EX?: number; PX?: number; NX?: boolean },
): Promise<string | null> {
if (opts?.NX && this.liveEntry(key)) {
return Promise.resolve(null);
}
const ttlMs = opts?.PX ?? (opts?.EX !== undefined ? opts.EX * 1000 : null);
this.store.set(key, {
value,
expiresAt: ttlMs === null ? null : Date.now() + ttlMs,
});
return Promise.resolve('OK');
}
get(key: string): Promise<string | null> {
return Promise.resolve(this.liveEntry(key)?.value ?? null);
}
del(key: string): Promise<number> {
const existed = this.liveEntry(key) ? 1 : 0;
this.store.delete(key);
return Promise.resolve(existed);
}
mGet(keys: string[]): Promise<Array<string | null>> {
return Promise.resolve(
keys.map((key) => this.liveEntry(key)?.value ?? null),
);
}
async *scanIterator(opts: { MATCH: string; COUNT?: number }) {
const prefix = opts.MATCH.endsWith('*')
? opts.MATCH.slice(0, -1)
: opts.MATCH;
const matches = [...this.store.keys()].filter(
(key) => key.startsWith(prefix) && !this.isExpired(key),
);
// node-redis v5 iterators yield batches
if (matches.length > 0) {
yield matches;
}
}
eval(
script: string,
opts: { keys: string[]; arguments: string[] },
): Promise<number> {
// Only the compare-and-delete lock release script is used.
const [key] = opts.keys;
const [token] = opts.arguments;
if (this.liveEntry(key)?.value === token) {
this.store.delete(key);
return Promise.resolve(1);
}
return Promise.resolve(0);
}
publish(channel: string, message: string): Promise<number> {
this.published.push({ channel, message });
return Promise.resolve(1);
}
}
@@ -0,0 +1,139 @@
import { Server } from 'http';
import { AddressInfo } from 'net';
import { YHub } from '@y/hub';
import axios from 'axios';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
test,
vi,
} from 'vitest';
import { WebSocket } from 'ws';
import { ConnectionRegistry, RedisClient } from '@/registry/connectionRegistry';
import { initServer } from '@/servers/appServer';
import { FakeRedis } from './fakeRedis';
const ROOM = '123e4567-e89b-42d3-a456-426614174000';
const ORIGIN = 'http://localhost:3000'; // default COLLABORATION_SERVER_ORIGIN
type Outcome =
| { outcome: 'http'; status: number }
| { outcome: 'open' }
| { outcome: 'error'; message: string }
| { outcome: 'timeout' };
const tryUpgrade = (
url: string,
headers: Record<string, string>,
): Promise<Outcome> =>
new Promise((resolve) => {
const ws = new WebSocket(url, { headers });
const timer = setTimeout(() => {
ws.terminate();
resolve({ outcome: 'timeout' });
}, 3000);
ws.on('unexpected-response', (_request, response) => {
clearTimeout(timer);
ws.terminate();
resolve({ outcome: 'http', status: response.statusCode ?? 0 });
});
ws.on('open', () => {
clearTimeout(timer);
ws.close();
resolve({ outcome: 'open' });
});
ws.on('error', (error) => {
clearTimeout(timer);
resolve({ outcome: 'error', message: error.message });
});
});
describe('gateway upgrade guards', () => {
let server: Server;
let base: string;
beforeAll(async () => {
const redis = new FakeRedis();
const registry = new ConnectionRegistry(
redis as unknown as RedisClient,
'instance-1',
);
server = initServer({
yhub: {} as unknown as YHub, // guards reject before yhub is touched
redis: redis as unknown as RedisClient,
registry,
});
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `ws://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(() => {
server.close();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('unknown path → 404', async () => {
await expect(tryUpgrade(`${base}/other/path`, {})).resolves.toEqual({
outcome: 'http',
status: 404,
});
});
test('invalid room → 400', async () => {
await expect(
tryUpgrade(`${base}/collaboration/ws/not-a-uuid`, { origin: ORIGIN }),
).resolves.toEqual({ outcome: 'http', status: 400 });
});
test('missing room → 400', async () => {
await expect(
tryUpgrade(`${base}/collaboration/ws/`, { origin: ORIGIN }),
).resolves.toEqual({ outcome: 'http', status: 400 });
});
test('bad origin → 403', async () => {
await expect(
tryUpgrade(`${base}/collaboration/ws/${ROOM}`, {
origin: 'http://evil.example',
}),
).resolves.toEqual({ outcome: 'http', status: 403 });
});
test('no cookies → 401', async () => {
await expect(
tryUpgrade(`${base}/collaboration/ws/${ROOM}`, { origin: ORIGIN }),
).resolves.toEqual({ outcome: 'http', status: 401 });
});
test('backend error → 403', async () => {
vi.spyOn(axios, 'get').mockRejectedValue(new Error('backend down'));
await expect(
tryUpgrade(`${base}/collaboration/ws/${ROOM}`, {
origin: ORIGIN,
cookie: 'docs_sessionid=abc',
}),
).resolves.toEqual({ outcome: 'http', status: 403 });
});
test('missing retrieve ability → 403', async () => {
vi.spyOn(axios, 'get').mockResolvedValue({
status: 200,
data: { id: ROOM, abilities: { retrieve: false, update: false } },
});
await expect(
tryUpgrade(`${base}/collaboration/ws/${ROOM}`, {
origin: ORIGIN,
cookie: 'docs_sessionid=abc',
}),
).resolves.toEqual({ outcome: 'http', status: 403 });
});
});
@@ -0,0 +1,254 @@
/**
* Integration tests — need a real Redis ≥ 7 and PostgreSQL:
*
* docker run -d -p 16379:6379 redis:7
* docker run -d -p 15432:5432 -e POSTGRES_USER=yhub -e POSTGRES_PASSWORD=yhub \
* -e POSTGRES_DB=yhub postgres:16-alpine
*
* Override with REDIS_URL / POSTGRES_URL. Run with `yarn test:integration`.
*/
import { randomUUID } from 'crypto';
import { Server } from 'http';
import { AddressInfo } from 'net';
import express from 'express';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { WebSocket as WS } from 'ws';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
// Must be set before '@/env' is imported (hence the dynamic import below).
process.env.REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:16379';
process.env.POSTGRES_URL =
process.env.POSTGRES_URL ?? 'postgres://yhub:yhub@localhost:15432/yhub';
process.env.YHUB_REDIS_PREFIX = `int-${randomUUID().slice(0, 8)}`;
process.env.YHUB_INTERNAL_PORT = '14545';
process.env.YHUB_TASK_DEBOUNCE = '700';
process.env.YHUB_MIN_MESSAGE_LIFETIME = '1500';
const ORIGIN = 'http://localhost:3000';
const SECRET = 'secret-api-key';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const waitFor = async (
condition: () => boolean | Promise<boolean>,
what: string,
timeoutMs = 10_000,
) => {
const deadline = Date.now() + timeoutMs;
while (!(await condition())) {
if (Date.now() > deadline) {
throw new Error(`Timed out waiting for: ${what}`);
}
await sleep(50);
}
};
type Gateway = Awaited<ReturnType<(typeof import('@/boot'))['bootGateway']>>;
describe('collaboration through the gateway (integration)', () => {
let gateway: Gateway;
let gatewayPort: number;
let djangoStub: Server;
const providers: WebsocketProvider[] = [];
/** Stub Django: cookie `mode=read` → read-only, `mode=none` → no access. */
const startDjangoStub = async () => {
const app = express();
app.get('/api/v1.0/documents/:id/', (req, res) => {
const cookie = req.headers.cookie ?? '';
if (cookie.includes('mode=none')) {
res.status(403).json({ detail: 'forbidden' });
return;
}
res.json({
id: req.params.id,
abilities: { retrieve: true, update: !cookie.includes('mode=read') },
});
});
app.get('/api/v1.0/users/me/', (req, res) => {
const match = /uid=([^;]+)/.exec(req.headers.cookie ?? '');
if (!match) {
res.status(401).json({ detail: 'unauthenticated' });
return;
}
res.json({ id: match[1], email: `${match[1]}@example.com` });
});
djangoStub = app.listen(0);
await new Promise((resolve) => djangoStub.once('listening', resolve));
const { port } = djangoStub.address() as AddressInfo;
process.env.COLLABORATION_BACKEND_BASE_URL = `http://127.0.0.1:${port}`;
};
const connect = (room: string, cookie: string) => {
const doc = new Y.Doc();
class WSWithHeaders extends WS {
constructor(url: string, protocols?: string | string[]) {
super(url, protocols, { headers: { origin: ORIGIN, cookie } });
}
}
const provider = new WebsocketProvider(
`ws://127.0.0.1:${gatewayPort}/collaboration/ws`,
room,
doc,
{
WebSocketPolyfill: WSWithHeaders as unknown as typeof WebSocket,
disableBc: true,
},
);
providers.push(provider);
return { doc, provider };
};
const gatewayHttp = (path: string, init?: RequestInit) =>
fetch(`http://127.0.0.1:${gatewayPort}${path}`, {
...init,
headers: { Authorization: SECRET, ...(init?.headers ?? {}) },
});
beforeAll(async () => {
await startDjangoStub();
const { bootGateway } = await import('@/boot');
gateway = await bootGateway();
await new Promise<void>((resolve) => gateway.server.listen(0, resolve));
gatewayPort = (gateway.server.address() as AddressInfo).port;
}, 30_000);
afterAll(async () => {
providers.forEach((provider) => provider.destroy());
await gateway?.shutdown();
djangoStub?.close();
}, 30_000);
test('two clients sync edits and awareness; read-only edits are dropped; kick and purge behave', async () => {
const room = randomUUID();
// --- two read-write clients sync both ways
const a = connect(room, 'docs_sessionid=sessA; uid=userA');
const b = connect(room, 'docs_sessionid=sessB; uid=userB');
await waitFor(() => a.provider.synced && b.provider.synced, 'initial sync');
a.doc.getText('content').insert(0, 'hello');
await waitFor(
() => b.doc.getText('content').toString() === 'hello',
'A→B propagation',
);
b.doc.getText('content').insert(5, ' world');
await waitFor(
() => a.doc.getText('content').toString() === 'hello world',
'B→A propagation',
);
// --- awareness propagates
a.provider.awareness.setLocalStateField('user', { name: 'Alice' });
await waitFor(
() =>
[...b.provider.awareness.getStates().values()].some(
(state) =>
(state as { user?: { name?: string } }).user?.name === 'Alice',
),
'awareness propagation',
);
// --- read-only client: receives but cannot write
const r = connect(room, 'docs_sessionid=sessR; uid=userR; mode=read');
await waitFor(
() => r.doc.getText('content').toString() === 'hello world',
'read-only client receives state',
);
r.doc.getText('content').insert(0, 'EVIL-');
await sleep(700);
expect(a.doc.getText('content').toString()).toBe('hello world');
// --- get-connections: counts rw connections only, sessionKey matching
const connectionsResponse = await gatewayHttp(
`/collaboration/api/get-connections/?room=${room}&sessionKey=sessA`,
);
expect(connectionsResponse.status).toBe(200);
expect(await connectionsResponse.json()).toEqual({
count: 2,
exists: true,
});
const strangerResponse = await gatewayHttp(
`/collaboration/api/get-connections/?room=${room}&sessionKey=nope`,
);
expect(
(await strangerResponse.json()) as { exists: boolean },
).toMatchObject({
exists: false,
});
// --- user-scoped kick: only userB is closed, with code 4000
const closeCodes: number[] = [];
b.provider.on('connection-close', (event: { code?: number } | null) => {
if (event?.code) {
closeCodes.push(event.code);
}
});
const resetResponse = await gatewayHttp(
`/collaboration/api/reset-connections/?room=${room}`,
{ method: 'POST', headers: { 'X-User-Id': 'userB' } },
);
expect(resetResponse.status).toBe(200);
await waitFor(() => closeCodes.includes(4000), 'kick close code 4000');
expect(a.provider.wsconnected).toBe(true); // userA untouched
// --- disposable cache: disconnect everyone, then a fresh join purges
providers.forEach((provider) => provider.destroy());
providers.length = 0;
await waitFor(async () => {
const response = await gatewayHttp(
`/collaboration/api/get-connections/?room=${room}&sessionKey=x`,
);
return response.status === 404;
}, 'all connections deregistered');
// wait until the worker compacted the session into Postgres
await waitFor(
async () => {
const rows = await gateway.yhub.persistence.sql`
SELECT count(*)::int AS count FROM yhub_ydoc_v1 WHERE docid = ${room}
`;
return (rows[0] as { count: number }).count > 0;
},
'worker compaction persisted rows',
15_000,
);
const fresh = connect(room, 'docs_sessionid=sessC; uid=userC');
await waitFor(() => fresh.provider.synced, 'fresh client sync');
// the previous session's content is gone: the cache was purged on 0→1
expect(fresh.doc.getText('content').toString()).toBe('');
const rowsAfter = await gateway.yhub.persistence.sql`
SELECT count(*)::int AS count FROM yhub_ydoc_v1 WHERE docid = ${room}
`;
expect((rowsAfter[0] as { count: number }).count).toBe(0);
}, 60_000);
test('no-access user is rejected at the gateway', async () => {
const room = randomUUID();
const outcome = await new Promise<string>((resolve) => {
const ws = new WS(
`ws://127.0.0.1:${gatewayPort}/collaboration/ws/${room}`,
{ headers: { origin: ORIGIN, cookie: 'docs_sessionid=x; mode=none' } },
);
const timer = setTimeout(() => {
ws.terminate();
resolve('timeout');
}, 4000);
ws.on('error', () => undefined); // aborted handshakes also emit 'error'
ws.on('unexpected-response', (_request, response) => {
clearTimeout(timer);
ws.terminate();
resolve(`http-${response.statusCode}`);
});
ws.on('open', () => {
clearTimeout(timer);
ws.close();
resolve('open');
});
});
expect(outcome).toBe('http-403');
}, 15_000);
});
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
consumeToken,
issueToken,
sweepExpiredTokens,
} from '@/yhubauth/internalToken';
const claims = { userid: 'user-1', room: 'room-1', canEdit: true };
describe('internalToken', () => {
afterEach(() => {
vi.useRealTimers();
});
test('issued token can be consumed once', () => {
const token = issueToken(claims);
expect(consumeToken(token)).toEqual(claims);
expect(consumeToken(token)).toBeNull();
});
test('unknown token returns null', () => {
expect(consumeToken('not-a-token')).toBeNull();
});
test('expired token returns null', () => {
vi.useFakeTimers();
const token = issueToken(claims);
vi.advanceTimersByTime(10_001); // AUTH_TOKEN_TTL_MS default is 10s
expect(consumeToken(token)).toBeNull();
});
test('tokens are unique and unguessable-sized', () => {
const a = issueToken(claims);
const b = issueToken(claims);
expect(a).not.toEqual(b);
expect(a.length).toBeGreaterThanOrEqual(43); // 32 bytes base64url
});
test('sweep removes expired tokens without touching live ones', () => {
vi.useFakeTimers();
const oldToken = issueToken(claims);
vi.advanceTimersByTime(9_000);
const newToken = issueToken(claims);
sweepExpiredTokens(Date.now() + 2_000); // old is past TTL, new is not
vi.advanceTimersByTime(2_000);
expect(consumeToken(oldToken)).toBeNull();
expect(consumeToken(newToken)).toEqual(claims);
});
});
@@ -0,0 +1,154 @@
import { Server } from 'http';
import { YHub } from '@y/hub';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { ConnectionRegistry, RedisClient } from '@/registry/connectionRegistry';
import { initServer } from '@/servers/appServer';
import { FakeRedis } from './fakeRedis';
// Default secrets from src/env.ts
const SECRET = 'secret-api-key';
describe('management endpoints', () => {
let server: Server;
let redis: FakeRedis;
let registry: ConnectionRegistry;
beforeAll(() => {
redis = new FakeRedis();
registry = new ConnectionRegistry(
redis as unknown as RedisClient,
'instance-1',
);
server = initServer({
yhub: {} as unknown as YHub, // not touched by these routes
redis: redis as unknown as RedisClient,
registry,
});
});
afterAll(() => {
server.close();
});
describe('GET /ping', () => {
test('returns pong', async () => {
const response = await request(server).get('/ping');
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'pong' });
});
});
describe('unknown routes', () => {
test('return 403', async () => {
const response = await request(server).get('/whatever');
expect(response.status).toBe(403);
});
});
describe('POST /collaboration/api/reset-connections/', () => {
test('requires authentication', async () => {
const response = await request(server).post(
'/collaboration/api/reset-connections/?room=room-1',
);
expect(response.status).toBe(401);
});
test('rejects an invalid api key', async () => {
const response = await request(server)
.post('/collaboration/api/reset-connections/?room=room-1')
.set('Authorization', 'wrong-key');
expect(response.status).toBe(401);
});
test('requires the room parameter', async () => {
const response = await request(server)
.post('/collaboration/api/reset-connections/')
.set('Authorization', SECRET);
expect(response.status).toBe(400);
});
test('publishes a room-wide kick (raw secret, as Django sends it)', async () => {
const response = await request(server)
.post('/collaboration/api/reset-connections/?room=room-1')
.set('Authorization', SECRET);
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Connections reset' });
expect(redis.published).toContainEqual({
channel: 'yhub:gw:kick',
message: JSON.stringify({ room: 'room-1' }),
});
});
test('publishes a user-scoped kick with x-user-id (Bearer accepted too)', async () => {
const response = await request(server)
.post('/collaboration/api/reset-connections/?room=room-1')
.set('Authorization', `Bearer ${SECRET}`)
.set('X-User-Id', 'user-9');
expect(response.status).toBe(200);
expect(redis.published).toContainEqual({
channel: 'yhub:gw:kick',
message: JSON.stringify({ room: 'room-1', userId: 'user-9' }),
});
});
});
describe('GET /collaboration/api/get-connections/', () => {
test('requires authentication', async () => {
const response = await request(server).get(
'/collaboration/api/get-connections/?room=room-1&sessionKey=s1',
);
expect(response.status).toBe(401);
});
test('requires room and sessionKey', async () => {
const noRoom = await request(server)
.get('/collaboration/api/get-connections/?sessionKey=s1')
.set('Authorization', SECRET);
expect(noRoom.status).toBe(400);
const noKey = await request(server)
.get('/collaboration/api/get-connections/?room=room-1')
.set('Authorization', SECRET);
expect(noKey.status).toBe(400);
});
test('returns 404 for an empty room (Django maps it to 0/False)', async () => {
const response = await request(server)
.get('/collaboration/api/get-connections/?room=empty&sessionKey=s1')
.set('Authorization', SECRET);
expect(response.status).toBe(404);
});
test('counts only writable connections and matches sessionKey', async () => {
await registry.register({
id: 'c1',
room: 'room-2',
sessionKey: 's-writer',
canEdit: true,
});
await registry.register({
id: 'c2',
room: 'room-2',
sessionKey: 's-reader',
canEdit: false,
});
const asWriter = await request(server)
.get(
'/collaboration/api/get-connections/?room=room-2&sessionKey=s-writer',
)
.set('Authorization', SECRET);
expect(asWriter.status).toBe(200);
expect(asWriter.body).toEqual({ count: 1, exists: true });
const asStranger = await request(server)
.get('/collaboration/api/get-connections/?room=room-2&sessionKey=s-x')
.set('Authorization', SECRET);
expect(asStranger.body).toEqual({ count: 1, exists: false });
});
});
});
@@ -0,0 +1,82 @@
import { describe, expect, test } from 'vitest';
import { ConnectionRegistry, RedisClient } from '@/registry/connectionRegistry';
import { FakeRedis } from './fakeRedis';
const makeRegistry = () => {
const redis = new FakeRedis();
const registry = new ConnectionRegistry(
redis as unknown as RedisClient,
'instance-1',
);
return { redis, registry };
};
describe('ConnectionRegistry', () => {
test('register/listRoom/deregister round-trip', async () => {
const { registry } = makeRegistry();
const conn = {
id: 'c1',
room: 'room-1',
userId: 'u1',
sessionKey: 's1',
canEdit: true,
};
await registry.register(conn);
const entries = await registry.listRoom('room-1');
expect(entries).toEqual([
{
connId: 'c1',
userId: 'u1',
sessionKey: 's1',
canEdit: true,
instanceId: 'instance-1',
},
]);
expect(registry.localRoom('room-1')).toHaveLength(1);
await registry.deregister(conn);
expect(await registry.listRoom('room-1')).toEqual([]);
expect(registry.localRoom('room-1')).toHaveLength(0);
});
test('listRoom only matches the requested room', async () => {
const { registry } = makeRegistry();
await registry.register({ id: 'c1', room: 'room-1', canEdit: true });
await registry.register({ id: 'c2', room: 'room-2', canEdit: false });
expect(await registry.listRoom('room-1')).toHaveLength(1);
expect(await registry.listRoom('room-2')).toHaveLength(1);
expect(await registry.listRoom('room-3')).toHaveLength(0);
});
test('entries from other instances are listed too', async () => {
const { redis, registry } = makeRegistry();
const other = new ConnectionRegistry(
redis as unknown as RedisClient,
'instance-2',
);
await registry.register({ id: 'c1', room: 'room-1', canEdit: true });
await other.register({ id: 'c2', room: 'room-1', canEdit: false });
const entries = await registry.listRoom('room-1');
expect(entries).toHaveLength(2);
expect(new Set(entries.map((entry) => entry.instanceId))).toEqual(
new Set(['instance-1', 'instance-2']),
);
// but local sockets stay per-instance
expect(registry.localRoom('room-1')).toHaveLength(1);
});
test('unparsable redis entries are dropped', async () => {
const { redis, registry } = makeRegistry();
await registry.register({ id: 'c1', room: 'room-1', canEdit: true });
await redis.set('yhub:gw:conn:room-1:bad', 'not-json');
const entries = await registry.listRoom('room-1');
expect(entries).toHaveLength(1);
expect(entries[0].connId).toBe('c1');
});
});
@@ -0,0 +1,149 @@
import { YHub } from '@y/hub';
import { describe, expect, test, vi } from 'vitest';
import { ConnectionRegistry, RedisClient } from '@/registry/connectionRegistry';
import {
ensureFreshRoomAndRegister,
purgeRoom,
withRedisLock,
} from '@/rooms/roomLifecycle';
import { FakeRedis } from './fakeRedis';
const fakeYHub = (references: unknown[] = [{ assetId: {}, asset: {} }]) => {
const retrieveDoc = vi.fn().mockResolvedValue({ references });
const deleteReferences = vi.fn().mockResolvedValue(undefined);
return {
yhub: { persistence: { retrieveDoc, deleteReferences } } as unknown as YHub,
retrieveDoc,
deleteReferences,
};
};
describe('withRedisLock', () => {
test('serializes concurrent critical sections', async () => {
const redis = new FakeRedis() as unknown as RedisClient;
const order: string[] = [];
await Promise.all([
withRedisLock(redis, 'lock:a', async () => {
order.push('a-start');
await new Promise((resolve) => setTimeout(resolve, 100));
order.push('a-end');
}),
(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
await withRedisLock(redis, 'lock:a', () => {
order.push('b');
return Promise.resolve();
});
})(),
]);
expect(order).toEqual(['a-start', 'a-end', 'b']);
});
test('releases the lock on error', async () => {
const redis = new FakeRedis() as unknown as RedisClient;
await expect(
withRedisLock(redis, 'lock:b', () => Promise.reject(new Error('boom'))),
).rejects.toThrow('boom');
// lock is free again
await withRedisLock(redis, 'lock:b', () => Promise.resolve());
});
});
describe('purgeRoom', () => {
test('deletes persisted rows and the redis stream', async () => {
const redis = new FakeRedis();
await redis.set('yhub:room:docs:room-1:main', 'stream-placeholder');
const { yhub, retrieveDoc, deleteReferences } = fakeYHub();
await purgeRoom(yhub, redis as unknown as RedisClient, 'room-1');
expect(retrieveDoc).toHaveBeenCalledWith(
{ org: 'docs', docid: 'room-1', branch: 'main' },
{
gc: true,
nongc: true,
contentmap: true,
contentids: true,
references: true,
},
);
expect(deleteReferences).toHaveBeenCalled();
expect(await redis.get('yhub:room:docs:room-1:main')).toBeNull();
});
test('skips deleteReferences when nothing is persisted', async () => {
const redis = new FakeRedis();
const { yhub, deleteReferences } = fakeYHub([]);
await purgeRoom(yhub, redis as unknown as RedisClient, 'room-1');
expect(deleteReferences).not.toHaveBeenCalled();
});
});
describe('ensureFreshRoomAndRegister', () => {
const setup = () => {
const redis = new FakeRedis();
const registry = new ConnectionRegistry(
redis as unknown as RedisClient,
'instance-1',
);
return { redis, registry };
};
test('purges on the 0→1 transition then registers', async () => {
const { redis, registry } = setup();
const { yhub, deleteReferences } = fakeYHub();
const conn = { id: 'c1', room: 'room-1', canEdit: true };
await ensureFreshRoomAndRegister(
{ yhub, redis: redis as unknown as RedisClient, registry },
conn,
);
expect(deleteReferences).toHaveBeenCalledTimes(1);
expect(await registry.listRoom('room-1')).toHaveLength(1);
});
test('does not purge when the room is already occupied', async () => {
const { redis, registry } = setup();
const { yhub, deleteReferences } = fakeYHub();
const deps = { yhub, redis: redis as unknown as RedisClient, registry };
await ensureFreshRoomAndRegister(deps, {
id: 'c1',
room: 'room-1',
canEdit: true,
});
await ensureFreshRoomAndRegister(deps, {
id: 'c2',
room: 'room-1',
canEdit: true,
});
expect(deleteReferences).toHaveBeenCalledTimes(1); // only the first join
expect(await registry.listRoom('room-1')).toHaveLength(2);
});
test('concurrent first joiners purge exactly once', async () => {
const { redis, registry } = setup();
const { yhub, deleteReferences } = fakeYHub();
const deps = { yhub, redis: redis as unknown as RedisClient, registry };
await Promise.all([
ensureFreshRoomAndRegister(deps, {
id: 'c1',
room: 'room-1',
canEdit: true,
}),
ensureFreshRoomAndRegister(deps, {
id: 'c2',
room: 'room-1',
canEdit: true,
}),
]);
expect(deleteReferences).toHaveBeenCalledTimes(1);
expect(await registry.listRoom('room-1')).toHaveLength(2);
});
});
@@ -0,0 +1,153 @@
import { Server } from 'http';
import { AddressInfo } from 'net';
import axios from 'axios';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
test,
vi,
} from 'vitest';
import { WebSocket } from 'ws';
import type { ConnectionRegistry as ConnectionRegistryType } from '@/registry/connectionRegistry';
import type { initServer as initServerType } from '@/servers/appServer';
import { FakeRedis } from './fakeRedis';
// Must be set before '@/env' is loaded (hence the dynamic imports below):
// nothing listens on this port, so every upstream dial fails.
process.env.YHUB_INTERNAL_PORT = '14799';
const ROOM = '123e4567-e89b-42d3-a456-426614174000';
const ORIGIN = 'http://localhost:3000';
const fakeYHub = {
persistence: {
retrieveDoc: () => Promise.resolve({ references: [] }),
deleteReferences: () => Promise.resolve(),
},
};
const waitFor = async (
condition: () => Promise<boolean> | boolean,
what: string,
) => {
const deadline = Date.now() + 5_000;
while (!(await condition())) {
if (Date.now() > deadline) {
throw new Error(`Timed out waiting for ${what}`);
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
};
describe('upgrade lifecycle cleanup', () => {
let server: Server;
let base: string;
let registry: ConnectionRegistryType;
beforeAll(async () => {
const { ConnectionRegistry } =
await import('@/registry/connectionRegistry');
const { initServer }: { initServer: typeof initServerType } =
await import('@/servers/appServer');
const redis = new FakeRedis();
registry = new ConnectionRegistry(redis as never, 'instance-1');
server = initServer({
yhub: fakeYHub as never,
redis: redis as never,
registry,
});
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `ws://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(() => {
server.close();
});
afterEach(() => {
vi.restoreAllMocks();
});
const grantAccess = () =>
vi.spyOn(axios, 'get').mockImplementation((url) =>
Promise.resolve(
String(url).includes('/users/me/')
? { status: 200, data: { id: 'user-1' } }
: {
status: 200,
data: { id: ROOM, abilities: { retrieve: true, update: true } },
},
),
);
test('rejects with 503 and releases the registration when the upstream dial fails', async () => {
grantAccess();
const outcome = await new Promise<string>((resolve) => {
const ws = new WebSocket(`${base}/collaboration/ws/${ROOM}`, {
headers: { origin: ORIGIN, cookie: 'docs_sessionid=s1' },
});
const timer = setTimeout(() => {
ws.terminate();
resolve('timeout');
}, 5_000);
ws.on('error', () => undefined);
ws.on('unexpected-response', (_request, response) => {
clearTimeout(timer);
ws.terminate();
resolve(`http-${response.statusCode}`);
});
ws.on('open', () => {
clearTimeout(timer);
ws.close();
resolve('open');
});
});
expect(outcome).toBe('http-503');
// no ghost left behind: the failed dial must deregister the connection
await waitFor(
async () => (await registry.listRoom(ROOM)).length === 0,
'deregistration after failed upstream dial',
);
});
test('releases the registration when the client disappears during auth', async () => {
let resolveAuth: () => void = () => undefined;
vi.spyOn(axios, 'get').mockImplementation((url) => {
if (String(url).includes('/users/me/')) {
return Promise.resolve({ status: 200, data: { id: 'user-1' } });
}
return new Promise((resolve) => {
resolveAuth = () =>
resolve({
status: 200,
data: { id: ROOM, abilities: { retrieve: true, update: true } },
});
});
});
const ws = new WebSocket(`${base}/collaboration/ws/${ROOM}`, {
headers: { origin: ORIGIN, cookie: 'docs_sessionid=s2' },
});
ws.on('error', () => undefined);
// let the upgrade reach the (blocked) Django auth call, then vanish
await new Promise((resolve) => setTimeout(resolve, 100));
ws.terminate();
await new Promise((resolve) => setTimeout(resolve, 50));
resolveAuth();
await waitFor(
async () => (await registry.listRoom(ROOM)).length === 0,
'no ghost registration after client vanished mid-auth',
);
expect(registry.localRoom(ROOM)).toHaveLength(0);
});
});
@@ -0,0 +1,20 @@
import { defineConfig } from '@eslint/config-helpers';
import docsPlugin from 'eslint-plugin-docs';
const eslintConfig = defineConfig([
{
ignores: ['dist/**'],
},
{
files: ['**/*.mjs', '**/*.ts', '**/*.tsx'],
plugins: {
docs: docsPlugin,
},
extends: ['docs/next'],
rules: {
'@next/next/no-html-link-for-pages': 'off',
},
},
]);
export default eslintConfig;
+5
View File
@@ -0,0 +1,5 @@
{
"watch": ["src"],
"ext": "ts",
"exec": "yarn build && yarn start"
}
+42 -9
View File
@@ -1,15 +1,48 @@
{
"name": "yhub",
"version": "1.0.0",
"description": "backend server for yjs collaboration",
"name": "server-yhub",
"version": "0.1.0",
"description": "Collaboration server for docs, built on @y/hub",
"repository": "https://github.com/suitenumerique/docs",
"license": "MIT",
"author": "",
"type": "commonjs",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"build": "tsc -p tsconfig.build.json && tsc-alias",
"dev": "cross-env COLLABORATION_LOGGING=true && nodemon --config nodemon.json",
"start": "node ./dist/start-server.js",
"lint": "eslint",
"test": "vitest run --exclude '__tests__/integration/**'",
"test:integration": "vitest run __tests__/integration"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"@y/hub": "^0.2.22"
}
"@sentry/node": "10.53.1",
"@sentry/profiling-node": "10.53.1",
"@y/hub": "0.2.22",
"axios": "1.16.1",
"cors": "2.8.6",
"express": "5.2.1",
"redis": "5.10.0",
"uuid": "14.0.0",
"ws": "8.21.0"
},
"devDependencies": {
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/node": "*",
"@types/supertest": "7.2.0",
"@types/ws": "8.18.1",
"cross-env": "10.1.0",
"eslint-plugin-docs": "*",
"nodemon": "3.1.14",
"supertest": "7.2.2",
"tsc-alias": "1.8.17",
"typescript": "*",
"vitest": "4.1.7",
"y-protocols": "1.0.7",
"y-websocket": "3.0.0",
"yjs": "*"
},
"packageManager": "yarn@1.22.22"
}
@@ -0,0 +1,88 @@
import { IncomingHttpHeaders } from 'http';
import axios from 'axios';
import { COLLABORATION_BACKEND_BASE_URL, Y_PROVIDER_API_KEY } from '@/env';
export interface User {
id: string;
email: string;
full_name: string;
short_name: string;
language: string;
}
type Base64 = string;
export interface Doc {
id: string;
title?: string;
content?: Base64;
creator: string;
is_favorite: boolean;
link_reach: 'restricted' | 'public' | 'authenticated';
link_role: 'reader' | 'editor';
nb_accesses_ancestors: number;
nb_accesses_direct: number;
created_at: string;
updated_at: string;
abilities: {
accesses_manage: boolean;
accesses_view: boolean;
ai_proxy: boolean;
ai_transform: boolean;
ai_translate: boolean;
attachment_upload: boolean;
children_create: boolean;
children_list: boolean;
collaboration_auth: boolean;
destroy: boolean;
favorite: boolean;
invite_owner: boolean;
link_configuration: boolean;
media_auth: boolean;
move: boolean;
partial_update: boolean;
restore: boolean;
retrieve: boolean;
update: boolean;
versions_destroy: boolean;
versions_list: boolean;
versions_retrieve: boolean;
};
}
async function fetch<T>(
path: string,
requestHeaders: IncomingHttpHeaders,
): Promise<T> {
const response = await axios.get<T>(
`${COLLABORATION_BACKEND_BASE_URL}${path}`,
{
headers: {
cookie: requestHeaders['cookie'],
origin: requestHeaders['origin'],
'X-Y-Provider-Key': Y_PROVIDER_API_KEY,
},
},
);
if (response.status !== 200) {
throw new Error(`Failed to fetch ${path}: ${response.statusText}`);
}
return response.data;
}
export function fetchDocument(
room: string,
requestHeaders: IncomingHttpHeaders,
): Promise<Doc> {
return fetch<Doc>(`/api/v1.0/documents/${room}/`, requestHeaders);
}
export function fetchCurrentUser(
requestHeaders: IncomingHttpHeaders,
): Promise<User> {
return fetch<User>('/api/v1.0/users/me/', requestHeaders);
}
+67
View File
@@ -0,0 +1,67 @@
import { randomUUID } from 'crypto';
import { Server } from 'http';
import { YHub } from '@y/hub';
import { createClient } from 'redis';
import { REDIS_URL } from '@/env';
import { ConnectionRegistry, RedisClient } from '@/registry/connectionRegistry';
import { KICK_CLOSE_CODE, subscribeKicks } from '@/registry/kickChannel';
import { createEmbeddedYHub, ensureWorkerStream, initServer } from '@/servers';
import { logger } from '@/utils';
export interface Gateway {
server: Server;
yhub: YHub;
registry: ConnectionRegistry;
redis: RedisClient;
shutdown: () => Promise<void>;
}
/** Boots the whole gateway: redis clients, embedded @y/hub, registry, kick
* subscription and the HTTP server (not yet listening). */
export const bootGateway = async (): Promise<Gateway> => {
const redis = createClient({ url: REDIS_URL });
await redis.connect();
const subscriber = redis.duplicate();
await subscriber.connect();
// The worker consumer group must exist before @y/hub's worker polls it.
await ensureWorkerStream(redis);
const yhub = await createEmbeddedYHub();
const registry = new ConnectionRegistry(redis, randomUUID());
registry.startHeartbeat();
await subscribeKicks(subscriber, ({ room, userId }) => {
for (const conn of registry.localRoom(room)) {
if (!userId || conn.userId === userId) {
logger('kicking connection', room, conn.userId ?? 'anonymous');
// The flag covers connections whose handshake is still in flight
// (no socket to close yet): the upgrade handler closes them with
// the kick code as soon as the socket attaches.
conn.kicked = true;
conn.client?.close(KICK_CLOSE_CODE, 'connection-reset');
}
}
});
const server = initServer({ yhub, redis, registry });
const shutdown = async () => {
server.close();
for (const conn of registry.localConnections()) {
conn.client?.close(1001, 'Server shutting down');
await registry.deregister(conn).catch(() => undefined);
}
registry.stopHeartbeat();
yhub.stopWorker();
await yhub.computePool.destroy().catch(() => undefined);
await yhub.server?.destroy().catch(() => undefined);
await subscriber.quit().catch(() => undefined);
await redis.quit().catch(() => undefined);
};
return { server, yhub, registry, redis, shutdown };
};
+52
View File
@@ -0,0 +1,52 @@
import { readFileSync } from 'fs';
export const COLLABORATION_LOGGING =
process.env.COLLABORATION_LOGGING || 'false';
export const COLLABORATION_SERVER_ORIGIN =
process.env.COLLABORATION_SERVER_ORIGIN || 'http://localhost:3000';
export const COLLABORATION_SERVER_SECRET = process.env
.COLLABORATION_SERVER_SECRET_FILE
? readFileSync(process.env.COLLABORATION_SERVER_SECRET_FILE, 'utf-8')
: process.env.COLLABORATION_SERVER_SECRET || 'secret-api-key';
export const Y_PROVIDER_API_KEY = process.env.Y_PROVIDER_API_KEY_FILE
? readFileSync(process.env.Y_PROVIDER_API_KEY_FILE, 'utf-8')
: process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key';
export const COLLABORATION_BACKEND_BASE_URL =
process.env.COLLABORATION_BACKEND_BASE_URL || 'http://app-dev:8000';
export const PORT = Number(process.env.PORT || 4444);
export const SENTRY_DSN = process.env.SENTRY_DSN || '';
/**
* @y/hub backing services. In development both point at the shared compose
* services; in production a dedicated Redis/Postgres is recommended (the
* yhub state is a disposable cache, but its churn should not share the
* Django database).
*/
export const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
export const POSTGRES_URL =
process.env.POSTGRES_URL || 'postgres://dinum:pass@postgresql:5432/impress';
export const YHUB_REDIS_PREFIX = process.env.YHUB_REDIS_PREFIX || 'yhub';
/** Internal @y/hub uws server. Must never be published outside the container. */
export const YHUB_INTERNAL_PORT = Number(
process.env.YHUB_INTERNAL_PORT || 4445,
);
export const YHUB_TASK_DEBOUNCE = process.env.YHUB_TASK_DEBOUNCE
? Number(process.env.YHUB_TASK_DEBOUNCE)
: undefined;
export const YHUB_MIN_MESSAGE_LIFETIME = process.env.YHUB_MIN_MESSAGE_LIFETIME
? Number(process.env.YHUB_MIN_MESSAGE_LIFETIME)
: undefined;
export const YHUB_TASK_CONCURRENCY = Number(
process.env.YHUB_TASK_CONCURRENCY || 3,
);
export const YHUB_MAX_DOC_SIZE = process.env.YHUB_MAX_DOC_SIZE
? Number(process.env.YHUB_MAX_DOC_SIZE)
: undefined;
/** Connection registry liveness (crash entries expire after CONN_TTL_SECONDS). */
export const CONN_TTL_SECONDS = Number(process.env.CONN_TTL_SECONDS || 30);
export const CONN_HEARTBEAT_SECONDS = Number(
process.env.CONN_HEARTBEAT_SECONDS || 10,
);
/** One-time internal tokens handed from the gateway to the embedded yhub. */
export const AUTH_TOKEN_TTL_MS = Number(process.env.AUTH_TOKEN_TTL_MS || 10000);
@@ -0,0 +1,200 @@
import { randomUUID } from 'crypto';
import { IncomingMessage } from 'http';
import { Duplex } from 'stream';
import { WebSocket, WebSocketServer } from 'ws';
import { fetchCurrentUser, fetchDocument } from '@/api/collaborationBackend';
import { YHUB_INTERNAL_PORT } from '@/env';
import { allowedOrigins } from '@/middlewares';
import { GatewayConnection } from '@/registry/connectionRegistry';
import { KICK_CLOSE_CODE } from '@/registry/kickChannel';
import {
RoomLifecycleDeps,
ensureFreshRoomAndRegister,
} from '@/rooms/roomLifecycle';
import { YHUB_ORG, routes } from '@/routes';
import { getCookieValue, isValidRoom, logger } from '@/utils';
import { issueToken } from '@/yhubauth/internalToken';
import { startPipe } from './wsPipe';
const UPSTREAM_DIAL_TIMEOUT_MS = 10_000;
/**
* Rejecting before the 101 handshake means y-websocket clients see a failed
* connection attempt (`connection-error` + backoff) instead of an
* open-then-close — and unauthorized clients never reach @y/hub.
*/
const rejectUpgrade = (socket: Duplex, status: number, message: string) => {
if (socket.writable) {
socket.write(
`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`,
);
}
socket.destroy();
};
/** Extracts the room from `/collaboration/ws/{room}` (or legacy `?room=`). */
export const extractRoom = (url: URL): string | null => {
if (!url.pathname.startsWith(routes.COLLABORATION_WS)) {
return null;
}
const segment = decodeURIComponent(
url.pathname.slice(routes.COLLABORATION_WS.length),
).replace(/\/+$/, '');
return segment || url.searchParams.get('room');
};
export interface UpgradeHandlerDeps extends RoomLifecycleDeps {
wss: WebSocketServer;
}
export const createUpgradeHandler =
(deps: UpgradeHandlerDeps) =>
async (req: IncomingMessage, socket: Duplex, head: Buffer) => {
const { wss, registry } = deps;
let conn: GatewayConnection | null = null;
try {
const url = new URL(req.url ?? '', 'http://gateway.internal');
const room = extractRoom(url);
if (room === null && !url.pathname.startsWith(routes.COLLABORATION_WS)) {
rejectUpgrade(socket, 404, 'Not Found');
return;
}
if (!room || !isValidRoom(room)) {
logger('upgrade rejected: invalid room', req.url);
rejectUpgrade(socket, 400, 'Bad Request');
return;
}
const origin = req.headers.origin;
if (!origin || !allowedOrigins.includes(origin)) {
logger('upgrade rejected: origin not allowed', origin);
rejectUpgrade(socket, 403, 'Forbidden');
return;
}
if (!req.headers.cookie) {
logger('upgrade rejected: no cookies');
rejectUpgrade(socket, 401, 'Unauthorized');
return;
}
let canEdit = false;
try {
const document = await fetchDocument(room, req.headers);
if (!document.abilities.retrieve) {
logger('upgrade rejected: no retrieve ability', room);
rejectUpgrade(socket, 403, 'Forbidden');
return;
}
canEdit = document.abilities.update === true;
} catch (error) {
logger('upgrade rejected: backend error', error);
rejectUpgrade(socket, 403, 'Forbidden');
return;
}
let userId: string | undefined;
try {
userId = (await fetchCurrentUser(req.headers)).id;
} catch {
// Anonymous users on public documents are legitimate.
userId = undefined;
}
const sessionKey = getCookieValue(req.headers.cookie, 'docs_sessionid');
conn = { id: randomUUID(), room, userId, sessionKey, canEdit };
await ensureFreshRoomAndRegister(deps, conn);
const registered = conn;
const token = issueToken({
userid: userId ?? `anon:${conn.id}`,
room,
canEdit,
});
const upstream = new WebSocket(
`ws://127.0.0.1:${YHUB_INTERNAL_PORT}/ws/${YHUB_ORG}/${room}?yauth=${token}`,
{ handshakeTimeout: UPSTREAM_DIAL_TIMEOUT_MS },
);
/**
* Single cleanup path for everything that can go wrong between
* registration and pipe start. Idempotent: once the pipe has started
* (or a cleanup ran), `settled` short-circuits every later signal.
*/
let settled = false;
const failBeforePipe = (status: number, message: string) => {
if (settled) {
return;
}
settled = true;
socket.off('close', onClientGone);
socket.off('error', onClientGone);
upstream.terminate();
void registry.deregister(registered);
rejectUpgrade(socket, status, message);
};
// If the browser disappears while we wait on the upstream dial,
// release the registration immediately: wss.handleUpgrade aborts on a
// dead socket WITHOUT invoking its callback, so the pipe (and its
// deregister hook) would never attach — the connection would otherwise
// stay registered forever, kept alive by the registry heartbeat.
const onClientGone = () => failBeforePipe(400, 'Client Closed');
socket.once('close', onClientGone);
socket.once('error', onClientGone);
if (socket.destroyed) {
// 'close' may already have fired before the listeners were attached
onClientGone();
return;
}
upstream.once('open', () => {
if (settled) {
upstream.terminate();
return;
}
if (!socket.writable) {
failBeforePipe(400, 'Client Closed');
return;
}
wss.handleUpgrade(req, socket, head, (client) => {
if (settled) {
client.terminate();
upstream.terminate();
return;
}
settled = true;
socket.off('close', onClientGone);
socket.off('error', onClientGone);
registered.client = client;
registered.upstream = upstream;
logger('client connected', room, registered.userId ?? 'anonymous');
startPipe(client, upstream, () => {
logger('client disconnected', room);
void registry.deregister(registered);
});
if (registered.kicked) {
// A reset-connections arrived while this handshake was in
// flight (it could not close a socket that did not exist yet):
// close now so the client reconnects with fresh permissions.
client.close(KICK_CLOSE_CODE, 'connection-reset');
}
});
});
// Covers dial failures AND handshake rejections. Do not gate this on
// readyState: when the upstream answers with a non-101 response, ws
// runs abortHandshake which sets readyState to CLOSING *before*
// emitting 'error' on the next tick.
upstream.on('error', (error) => {
logger('upstream connection error', error.message);
failBeforePipe(503, 'Service Unavailable');
});
} catch (error) {
logger('upgrade error', error);
if (conn) {
void registry.deregister(conn);
}
rejectUpgrade(socket, 500, 'Internal Server Error');
}
};
@@ -0,0 +1,40 @@
import { Request, Response } from 'express';
import { ConnectionRegistry } from '@/registry/connectionRegistry';
import { logger } from '@/utils';
/**
* Used by Django's save arbitration (`_can_user_edit_document`). Response
* contract is identical to y-provider: `count` only counts connections with
* write access, `exists` tells whether the given session is among them.
* 404 on an empty room — Django maps it to (0, False).
*/
export const createGetConnectionsHandler =
(registry: ConnectionRegistry) => async (req: Request, res: Response) => {
const room = req.query.room as string | undefined;
const sessionKey = req.query.sessionKey as string | undefined;
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
if (!sessionKey) {
res.status(400).json({ error: 'Session key not provided' });
return;
}
try {
const entries = await registry.listRoom(room);
if (entries.length === 0) {
res.status(404).json({ error: 'Room not found' });
return;
}
res.status(200).json({
count: entries.filter((entry) => entry.canEdit).length,
exists: entries.some((entry) => entry.sessionKey === sessionKey),
});
} catch (error) {
logger('get-connections error', error);
res.status(500).json({ error: 'Failed to get connections' });
}
};
@@ -0,0 +1,4 @@
export * from './collaborationUpgradeHandler';
export * from './getConnectionsHandler';
export * from './resetConnectionsHandler';
export * from './wsPipe';
@@ -0,0 +1,29 @@
import { Request, Response } from 'express';
import { RedisClient } from '@/registry/connectionRegistry';
import { publishKick } from '@/registry/kickChannel';
import { logger } from '@/utils';
/**
* Called by Django when permissions change. The kick is published on Redis so
* every gateway instance closes its matching local sockets (code 4000); the
* clients then reconnect and re-run the gateway auth with their new rights.
*/
export const createResetConnectionsHandler =
(redis: RedisClient) => async (req: Request, res: Response) => {
const room = req.query.room as string | undefined;
const userId = req.headers['x-user-id'] as string | undefined;
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
try {
await publishKick(redis, { room, ...(userId && { userId }) });
res.status(200).json({ message: 'Connections reset' });
} catch (error) {
logger('reset-connections error', error);
res.status(500).json({ error: 'Failed to reset connections' });
}
};
@@ -0,0 +1,83 @@
import { WebSocket } from 'ws';
import { logger } from '@/utils';
/** Slow-consumer protection: terminate clients that stop draining. */
const MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
/** `ws` only allows sending 1000-1003, 1007-1014 (minus reserved) and 3000-4999. */
const sanitizeCloseCode = (code: number): number =>
code === 1000 ||
code === 1001 ||
code === 1011 ||
(code >= 3000 && code <= 4999)
? code
: 1000;
const safeClose = (ws: WebSocket, code: number, reason: string) => {
if (
ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING
) {
try {
ws.close(sanitizeCloseCode(code), reason.slice(0, 100));
} catch {
ws.terminate();
}
}
};
/**
* Bidirectional byte relay between the browser socket and the embedded
* @y/hub socket. Both legs speak the y-websocket wire protocol; the gateway
* never decodes frames (read-only enforcement happens inside @y/hub).
*/
export const startPipe = (
client: WebSocket,
upstream: WebSocket,
onClose: () => void,
) => {
let closed = false;
const finish = () => {
if (!closed) {
closed = true;
onClose();
}
};
client.on('message', (data: Buffer, isBinary: boolean) => {
if (upstream.readyState === WebSocket.OPEN) {
upstream.send(data, { binary: isBinary });
}
});
upstream.on('message', (data: Buffer, isBinary: boolean) => {
if (client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
if (client.bufferedAmount > MAX_BUFFERED_BYTES) {
logger('pipe: closing slow consumer', client.bufferedAmount);
client.terminate();
}
}
});
client.on('close', (code, reason) => {
safeClose(upstream, code, reason.toString());
finish();
});
upstream.on('close', (code, reason) => {
safeClose(client, code, reason.toString());
finish();
});
client.on('error', (error) => {
logger('pipe client error', error.message);
client.terminate();
});
upstream.on('error', (error) => {
logger('pipe upstream error', error.message);
upstream.terminate();
});
};
@@ -0,0 +1,41 @@
import cors from 'cors';
import { NextFunction, Request, Response } from 'express';
import {
COLLABORATION_SERVER_ORIGIN,
COLLABORATION_SERVER_SECRET,
Y_PROVIDER_API_KEY,
} from '@/env';
const VALID_API_KEYS = [COLLABORATION_SERVER_SECRET, Y_PROVIDER_API_KEY];
export const allowedOrigins = COLLABORATION_SERVER_ORIGIN.split(',');
export const corsMiddleware = cors({
origin: allowedOrigins,
methods: ['GET', 'POST'],
credentials: true,
});
export const httpSecurity = (
req: Request,
res: Response,
next: NextFunction,
): void => {
let apiKey = req.headers['authorization'];
if (!apiKey) {
res.status(401).json({ error: 'Unauthorized: No credentials given' });
return;
}
if (apiKey?.startsWith('Bearer ')) {
apiKey = apiKey.slice('Bearer '.length);
}
if (!VALID_API_KEYS.includes(apiKey)) {
res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
return;
}
next();
};
@@ -0,0 +1,146 @@
import { createClient } from 'redis';
import { WebSocket } from 'ws';
import {
CONN_HEARTBEAT_SECONDS,
CONN_TTL_SECONDS,
YHUB_REDIS_PREFIX,
} from '@/env';
import { logger } from '@/utils';
export type RedisClient = ReturnType<typeof createClient>;
export interface GatewayConnection {
id: string;
room: string;
userId?: string;
sessionKey?: string;
canEdit: boolean;
client?: WebSocket;
upstream?: WebSocket;
/** Set when a kick targets this connection before its socket is attached. */
kicked?: boolean;
}
export interface RegistryEntry {
connId: string;
userId?: string;
sessionKey?: string;
canEdit: boolean;
instanceId: string;
}
/**
* Tracks gateway connections. The local Map holds the sockets (for kicks);
* Redis holds one TTL'd key per connection so that counts are correct across
* gateway instances and crashed instances self-clean within CONN_TTL_SECONDS.
*/
export class ConnectionRegistry {
private local = new Map<string, Set<GatewayConnection>>();
private heartbeat: NodeJS.Timeout | null = null;
constructor(
private redis: RedisClient,
readonly instanceId: string,
) {}
private key(room: string, connId: string) {
return `${YHUB_REDIS_PREFIX}:gw:conn:${room}:${connId}`;
}
private entryValue(conn: GatewayConnection): string {
return JSON.stringify({
userId: conn.userId,
sessionKey: conn.sessionKey,
canEdit: conn.canEdit,
instanceId: this.instanceId,
});
}
async register(conn: GatewayConnection) {
let conns = this.local.get(conn.room);
if (!conns) {
conns = new Set();
this.local.set(conn.room, conns);
}
conns.add(conn);
await this.redis.set(this.key(conn.room, conn.id), this.entryValue(conn), {
EX: CONN_TTL_SECONDS,
});
}
async deregister(conn: GatewayConnection) {
const conns = this.local.get(conn.room);
if (conns) {
conns.delete(conn);
if (conns.size === 0) {
this.local.delete(conn.room);
}
}
await this.redis.del(this.key(conn.room, conn.id));
}
localRoom(room: string): GatewayConnection[] {
return [...(this.local.get(room) ?? [])];
}
localConnections(): GatewayConnection[] {
return [...this.local.values()].flatMap((conns) => [...conns]);
}
async listRoom(room: string): Promise<RegistryEntry[]> {
const keys: string[] = [];
for await (const reply of this.redis.scanIterator({
MATCH: `${YHUB_REDIS_PREFIX}:gw:conn:${room}:*`,
COUNT: 100,
})) {
// node-redis v5 iterators yield batches, v4 yielded single keys
keys.push(...(Array.isArray(reply) ? reply : [reply]));
}
if (keys.length === 0) {
return [];
}
const values = await this.redis.mGet(keys);
const entries: RegistryEntry[] = [];
values.forEach((value, index) => {
if (value === null) {
return; // expired between SCAN and MGET
}
try {
const parsed = JSON.parse(value) as Omit<RegistryEntry, 'connId'>;
entries.push({
...parsed,
connId: keys[index].split(':').pop() ?? '',
});
} catch {
logger('registry: dropping unparsable entry', keys[index]);
}
});
return entries;
}
startHeartbeat() {
if (this.heartbeat) {
return;
}
this.heartbeat = setInterval(() => {
void (async () => {
for (const conn of this.localConnections()) {
await this.redis
.set(this.key(conn.room, conn.id), this.entryValue(conn), {
EX: CONN_TTL_SECONDS,
})
.catch((error) => logger('registry heartbeat error', error));
}
})();
}, CONN_HEARTBEAT_SECONDS * 1000);
this.heartbeat.unref();
}
stopHeartbeat() {
if (this.heartbeat) {
clearInterval(this.heartbeat);
this.heartbeat = null;
}
}
}
@@ -0,0 +1,39 @@
import { YHUB_REDIS_PREFIX } from '@/env';
import { logger } from '@/utils';
import { RedisClient } from './connectionRegistry';
export interface KickMessage {
room: string;
userId?: string;
}
/** Close code received by kicked clients; they reconnect and re-auth. */
export const KICK_CLOSE_CODE = 4000;
export const kickChannel = () => `${YHUB_REDIS_PREFIX}:gw:kick`;
export const publishKick = async (redis: RedisClient, message: KickMessage) => {
await redis.publish(kickChannel(), JSON.stringify(message));
};
/**
* Every gateway instance subscribes (on a dedicated subscriber connection)
* and closes its matching local sockets — that is what makes reset-connections
* correct with multiple gateway replicas.
*/
export const subscribeKicks = async (
subscriber: RedisClient,
onKick: (message: KickMessage) => void,
) => {
await subscriber.subscribe(kickChannel(), (raw) => {
try {
const message = JSON.parse(raw) as KickMessage;
if (typeof message.room === 'string') {
onKick(message);
}
} catch {
logger('kick channel: dropping unparsable message', raw);
}
});
};
@@ -0,0 +1,107 @@
import { randomUUID } from 'crypto';
import { YHub } from '@y/hub';
import { YHUB_REDIS_PREFIX } from '@/env';
import {
ConnectionRegistry,
GatewayConnection,
RedisClient,
} from '@/registry/connectionRegistry';
import { YHUB_BRANCH, YHUB_ORG } from '@/routes';
import { logger } from '@/utils';
const LOCK_TTL_MS = 5_000;
const LOCK_ACQUIRE_TIMEOUT_MS = 5_000;
const LOCK_RETRY_MS = 50;
const RELEASE_SCRIPT = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
export const withRedisLock = async <T>(
redis: RedisClient,
key: string,
fn: () => Promise<T>,
): Promise<T> => {
const token = randomUUID();
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
while (
(await redis.set(key, token, { NX: true, PX: LOCK_TTL_MS })) === null
) {
if (Date.now() > deadline) {
throw new Error(`Timed out acquiring lock ${key}`);
}
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
}
try {
return await fn();
} finally {
await redis
.eval(RELEASE_SCRIPT, { keys: [key], arguments: [token] })
.catch((error) => logger('lock release error', key, error));
}
};
/**
* Drops everything @y/hub knows about a room: the persisted Postgres rows
* (and any plugin-stored assets) plus the Redis stream backlog.
*/
export const purgeRoom = async (
yhub: YHub,
redis: RedisClient,
room: string,
) => {
const yroom = { org: YHUB_ORG, docid: room, branch: YHUB_BRANCH };
// All columns must be included: retrieveDoc only reports references for
// the columns it actually read, and deleteReferences deletes rows by their
// referenced timestamps.
const { references } = await yhub.persistence.retrieveDoc(yroom, {
gc: true,
nongc: true,
contentmap: true,
contentids: true,
references: true,
});
if (references && references.length > 0) {
await yhub.persistence.deleteReferences(references);
}
await redis.del(
`${YHUB_REDIS_PREFIX}:room:${YHUB_ORG}:${room}:${YHUB_BRANCH}`,
);
logger('purged room', room, `(${references?.length ?? 0} persisted rows)`);
};
export interface RoomLifecycleDeps {
yhub: YHub;
redis: RedisClient;
registry: ConnectionRegistry;
}
/**
* Disposable-cache semantics (plan §3.5): when the first client (re)opens a
* room, drop whatever @y/hub cached from the previous session — the client
* arrives seeded with Django's content (the source of truth) and re-seeds the
* room through the normal sync. The lock serializes concurrent first joiners:
* whoever wins purges, the loser then sees a non-empty registry and skips.
*
* Residual races (both benign — same Y.Doc lineage merges cleanly):
* - rejoin while a compaction task is still in flight may re-insert last
* session's rows after the purge;
* - ghost registry keys after an instance crash make the room look occupied
* for up to CONN_TTL_SECONDS, skipping the purge.
*/
export const ensureFreshRoomAndRegister = async (
{ yhub, redis, registry }: RoomLifecycleDeps,
conn: GatewayConnection,
) => {
await withRedisLock(
redis,
`${YHUB_REDIS_PREFIX}:gw:purgelock:${conn.room}`,
async () => {
const entries = await registry.listRoom(conn.room);
if (entries.length === 0) {
await purgeRoom(yhub, redis, conn.room);
}
await registry.register(conn);
},
);
};
+10
View File
@@ -0,0 +1,10 @@
export const routes = {
/** Prefix only — the room UUID is appended as a path segment by y-websocket. */
COLLABORATION_WS: '/collaboration/ws/',
COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/',
COLLABORATION_GET_CONNECTIONS: '/collaboration/api/get-connections/',
};
/** Fixed @y/hub room addressing for docs. */
export const YHUB_ORG = 'docs';
export const YHUB_BRANCH = 'main';
@@ -0,0 +1,60 @@
import { Server, createServer } from 'http';
import * as Sentry from '@sentry/node';
import express from 'express';
import { WebSocketServer } from 'ws';
import {
createGetConnectionsHandler,
createResetConnectionsHandler,
createUpgradeHandler,
} from '@/handlers';
import { corsMiddleware, httpSecurity } from '@/middlewares';
import { RoomLifecycleDeps } from '@/rooms/roomLifecycle';
import { routes } from '@/routes';
import { logger } from '@/utils';
import '../services/sentry';
/**
* The gateway: Express for the HTTP surface, a raw `upgrade` listener for the
* WebSocket endpoint (auth happens before the 101 handshake).
*/
export const initServer = (deps: RoomLifecycleDeps): Server => {
const app = express();
app.use(corsMiddleware);
app.post(
routes.COLLABORATION_RESET_CONNECTIONS,
httpSecurity,
express.json(),
createResetConnectionsHandler(deps.redis),
);
app.get(
routes.COLLABORATION_GET_CONNECTIONS,
httpSecurity,
createGetConnectionsHandler(deps.registry),
);
Sentry.setupExpressErrorHandler(app);
app.get('/ping', (req, res) => {
res.status(200).json({ message: 'pong' });
});
app.use((req, res) => {
logger('Invalid route:', req.url);
res.status(403).json({ error: 'Forbidden' });
});
const server = createServer(app);
const wss = new WebSocketServer({ noServer: true });
const handleUpgrade = createUpgradeHandler({ ...deps, wss });
server.on('upgrade', (req, socket, head) => {
void handleUpgrade(req, socket, head);
});
return server;
};
@@ -0,0 +1,2 @@
export * from './appServer';
export * from './yhubServer';
@@ -0,0 +1,79 @@
import { YHub, createYHub } from '@y/hub';
import {
POSTGRES_URL,
REDIS_URL,
YHUB_INTERNAL_PORT,
YHUB_MAX_DOC_SIZE,
YHUB_MIN_MESSAGE_LIFETIME,
YHUB_REDIS_PREFIX,
YHUB_TASK_CONCURRENCY,
YHUB_TASK_DEBOUNCE,
} from '@/env';
import { RedisClient } from '@/registry/connectionRegistry';
import { createGatewayAuthPlugin } from '@/yhubauth/authPlugin';
/**
* Replicates @y/hub's bin/init-db.js: the worker claims compaction tasks from
* a Redis consumer group that must exist before the worker polls it.
*/
export const ensureWorkerStream = async (redis: RedisClient) => {
const name = `${YHUB_REDIS_PREFIX}:worker`;
try {
await redis.xGroupCreate(name, name, '0', { MKSTREAM: true });
} catch (error) {
if (!(error instanceof Error) || !error.message.includes('BUSYGROUP')) {
throw error;
}
}
};
/** Replicates the yhub_ydoc_v1 table creation from @y/hub's bin/init-db.js. */
export const ensureSchema = async (yhub: YHub) => {
await yhub.persistence.sql`
CREATE TABLE IF NOT EXISTS yhub_ydoc_v1 (
org text,
docid text,
branch text,
t text,
created INT8,
gcDoc bytea,
nongcDoc bytea,
contentmap bytea,
contentids bytea,
PRIMARY KEY (org,docid,branch,t)
);
`;
};
/**
* Boots the embedded @y/hub: the internal uws WebSocket server on
* YHUB_INTERNAL_PORT (never published outside the container — only the
* gateway dials it, with single-use tokens) and the compaction worker.
*/
export const createEmbeddedYHub = async (): Promise<YHub> => {
const yhub = await createYHub({
redis: {
url: REDIS_URL,
prefix: YHUB_REDIS_PREFIX,
...(YHUB_TASK_DEBOUNCE !== undefined && {
taskDebounce: YHUB_TASK_DEBOUNCE,
}),
...(YHUB_MIN_MESSAGE_LIFETIME !== undefined && {
minMessageLifetime: YHUB_MIN_MESSAGE_LIFETIME,
}),
},
postgres: POSTGRES_URL,
persistence: [],
server: {
port: YHUB_INTERNAL_PORT,
auth: createGatewayAuthPlugin(),
// Note: @y/hub 0.2.22 overwrites maxDocSize with 500MB in the YHub
// constructor; kept here for forward compatibility.
...(YHUB_MAX_DOC_SIZE !== undefined && { maxDocSize: YHUB_MAX_DOC_SIZE }),
},
worker: { taskConcurrency: YHUB_TASK_CONCURRENCY },
});
await ensureSchema(yhub);
return yhub;
};
@@ -0,0 +1,12 @@
import * as Sentry from '@sentry/node';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
import { SENTRY_DSN } from '../env';
Sentry.init({
dsn: SENTRY_DSN,
integrations: [nodeProfilingIntegration()],
tracesSampleRate: 0.1,
profilesSampleRate: 1.0,
});
Sentry.setTag('application', 'yhub');
@@ -0,0 +1,25 @@
import { bootGateway } from '@/boot';
import { PORT } from '@/env';
const main = async () => {
const gateway = await bootGateway();
gateway.server.listen(PORT, () =>
console.log('App listening on port :', PORT),
);
let shuttingDown = false;
const shutdown = () => {
if (shuttingDown) {
return;
}
shuttingDown = true;
void gateway.shutdown().then(() => process.exit(0));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
};
main().catch((error) => {
console.error('Failed to start yhub server:', error);
process.exit(1);
});
+22
View File
@@ -0,0 +1,22 @@
import { validate as uuidValidate, version as uuidVersion } from 'uuid';
import { COLLABORATION_LOGGING } from './env';
export function logger(...args: unknown[]) {
if (COLLABORATION_LOGGING === 'true') {
console.log(new Date().toISOString(), ' --- ', ...args);
}
}
export const isValidRoom = (room: string): boolean =>
uuidValidate(room) && uuidVersion(room) === 4;
export const getCookieValue = (
cookieHeader: string | undefined,
name: string,
): string | undefined =>
cookieHeader
?.split(';')
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith(`${name}=`))
?.split('=')[1];
@@ -0,0 +1,39 @@
import { createAuthPlugin } from '@y/hub';
import { YHUB_BRANCH, YHUB_ORG } from '@/routes';
import { logger } from '@/utils';
import { TokenClaims, consumeToken } from './internalToken';
/**
* Auth plugin for the embedded @y/hub server. The gateway has already
* authenticated the user against Django; it hands the result over through a
* single-use token in the `yauth` query parameter of the internal dial.
*/
export const createGatewayAuthPlugin = () =>
createAuthPlugin<TokenClaims>({
readAuthInfo: (req) => {
// The uws request is only valid synchronously — read the query before
// any await. consumeToken is a synchronous Map lookup, so this whole
// handler completes before uws invalidates the request.
const token = new URLSearchParams(req.getQuery() ?? '').get('yauth');
const claims = token ? consumeToken(token) : null;
if (!claims) {
logger('yhub auth: invalid or expired internal token');
throw new Error('Invalid internal token');
}
return Promise.resolve(claims);
},
getAccessType: (authInfo, room) => {
// The token is bound to a single room: a replayed or misrouted token
// cannot open any other document.
if (
room.org !== YHUB_ORG ||
room.branch !== YHUB_BRANCH ||
room.docid !== authInfo.room
) {
return Promise.resolve(null);
}
return Promise.resolve(authInfo.canEdit ? 'rw' : 'r');
},
});
@@ -0,0 +1,50 @@
import { randomBytes } from 'crypto';
import { AUTH_TOKEN_TTL_MS } from '@/env';
/**
* Claims handed from the gateway to the embedded @y/hub server. `userid` is
* required by @y/hub (used for content attributions); `room` binds the token
* to a single document.
*/
export interface TokenClaims {
userid: string;
room: string;
canEdit: boolean;
}
const tokens = new Map<string, { claims: TokenClaims; expiresAt: number }>();
const SWEEP_INTERVAL_MS = 30_000;
setInterval(() => sweepExpiredTokens(), SWEEP_INTERVAL_MS).unref();
export const sweepExpiredTokens = (now = Date.now()) => {
for (const [token, entry] of tokens) {
if (entry.expiresAt < now) {
tokens.delete(token);
}
}
};
export const issueToken = (claims: TokenClaims): string => {
const token = randomBytes(32).toString('base64url');
tokens.set(token, { claims, expiresAt: Date.now() + AUTH_TOKEN_TTL_MS });
return token;
};
/**
* Single use: the token is deleted on first read. The lookup is synchronous
* on purpose — it lets the @y/hub auth plugin satisfy the uws constraint of
* reading the request before any await.
*/
export const consumeToken = (token: string): TokenClaims | null => {
const entry = tokens.get(token);
if (!entry) {
return null;
}
tokens.delete(token);
if (entry.expiresAt < Date.now()) {
return null;
}
return entry.claims;
};
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist", "__tests__"],
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": false,
"outDir": "./dist",
"paths": {
"@/*": ["./src/*"]
}
},
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
},
"include": ["**/*.ts", "**/*.mjs"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,11 @@
import { URL, fileURLToPath } from 'url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
});
+1582 -952
View File
File diff suppressed because it is too large Load Diff