mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-13 21:27:53 +02:00
⚗️(collab) yhub experiment
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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/
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
+5
-6
@@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+95
-101
@@ -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 }),
|
||||
}));
|
||||
|
||||
+1
-5
@@ -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
|
||||
|
||||
Generated
+90
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"ext": "ts",
|
||||
"exec": "yarn build && yarn start"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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__"],
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user