From 7d04c0b5942b37ab7a2653393b8d7c007e64b678 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Wed, 8 Jul 2026 18:00:19 +0200 Subject: [PATCH] Switch the editor to live collaboration (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor now edits over the collaboration server instead of REST — the moment Dorfteich becomes collaborative (ADR 0003, realtime-collaboration.md). Web: - New `useCollabProvider` hook binds a page's Y.Doc to a HocuspocusProvider. The document loads and persists through the collab server (#35); there is no REST autosave and no REST seed (a REST seed would fork the doc lineage and duplicate content). The collab token is fetched lazily on every (re)connect via an async token function, so an expired token is replaced transparently and a permission change takes effect on the next reconnect. - Connection-state UI replaces the save indicator: connecting / connected ("Live") / reconnecting / offline, driven by provider status + navigator online state. Read-only (`ro`) tokens make the editor non-editable with a reason; an oversize-document stateless error (#35) surfaces a banner. - Removed `use-page-autosave.ts` and `yjs-base64.ts` (no longer used). API: - `PUT /pages/:id/state` is retired and returns 410 `rest_state_write_retired` (the criterion deferred here from #35). Collab is the sole writer of page state; the read paths remain. Removed the now-dead `saveState` service. e2e / CI: - The e2e static server proxies the `/collab` WebSocket upgrade (mirrors Caddy); vite dev gains a `/collab` ws proxy. The auth-e2e CI job starts the collab server and runs a new collab pack. - New `collab.spec.ts`: two browsers converge on one page (the milestone headline), and offline edits continue locally and sync on reconnect. The read-only live assertion is a `test.fixme` until real read-only grants exist — under interim access seeing and modifying coincide, so no `ro` token is issued yet (that arrives with #53). Reworked the api/trash tests and the content editor-basics test off the retired REST write path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY --- .gitea/workflows/ci.yml | 23 ++++- apps/api/src/pages/pages.controller.ts | 20 ++-- apps/api/src/pages/pages.e2e.db.test.ts | 84 ++++----------- apps/api/src/pages/pages.service.ts | 64 +----------- apps/api/src/trash/trash.e2e.db.test.ts | 40 ++------ apps/web/e2e/collab.spec.ts | 96 +++++++++++++++++ apps/web/e2e/content.spec.ts | 7 +- apps/web/package.json | 1 + apps/web/src/editor/use-collab-provider.ts | 114 +++++++++++++++++++++ apps/web/src/editor/use-page-autosave.ts | 64 ------------ apps/web/src/editor/yjs-base64.ts | 15 --- apps/web/src/pages/PageEditorPage.tsx | 39 ++++--- apps/web/src/styles/base.css | 29 +++++- apps/web/vite.config.ts | 5 + packages/shared/i18n/de/editor.json | 16 ++- packages/shared/i18n/en/editor.json | 16 ++- pnpm-lock.yaml | 3 + scripts/e2e-static-server.mjs | 51 +++++++-- 18 files changed, 407 insertions(+), 280 deletions(-) create mode 100644 apps/web/e2e/collab.spec.ts create mode 100644 apps/web/src/editor/use-collab-provider.ts delete mode 100644 apps/web/src/editor/use-page-autosave.ts delete mode 100644 apps/web/src/editor/yjs-base64.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 44f398d..b2a0e18 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -105,14 +105,19 @@ jobs: - name: Seed fixtures run: pnpm --filter @dorfteich/api db:seed - - name: Start api and static web server + - name: Start api, collab, and static web server run: | (cd apps/api && PORT=3001 node dist/main.js > /tmp/api.log 2>&1 &) - (PORT=5173 node scripts/e2e-static-server.mjs > /tmp/web.log 2>&1 &) + (cd apps/collab && PORT=3002 node dist/index.js > /tmp/collab.log 2>&1 &) + (PORT=5173 COLLAB_TARGET=http://127.0.0.1:3002 node scripts/e2e-static-server.mjs > /tmp/web.log 2>&1 &) for i in $(seq 1 30); do curl -sf http://localhost:3001/api/v1/readyz >/dev/null && break sleep 2 done + for i in $(seq 1 30); do + curl -sf http://localhost:3002/healthz >/dev/null && break + sleep 2 + done curl -sf http://localhost:5173/ >/dev/null - name: Install Playwright browser @@ -137,9 +142,21 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/content.spec.ts + # The collab pack opens two browser contexts per test (more logins), + # so reset the login rate limit before it as well (see note above). + - name: Reset login rate limit before collab pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run collab pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/collab.spec.ts + - name: Dump server logs on failure if: failure() - run: tail -50 /tmp/api.log /tmp/web.log || true + run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true images: name: Build container images diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index c349790..4f913be 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + GoneException, HttpCode, Param, Patch, @@ -16,10 +17,8 @@ import { CreatePageInput, PageStateView, PageView, - SavePageStateInput, UpdatePageInput, createPageInputSchema, - savePageStateInputSchema, updatePageInputSchema, } from '@dorfteich/shared'; import type { Response } from 'express'; @@ -86,13 +85,18 @@ export class PagesController { return this.pages.getStateBySlug(request.user!, pondId, slug); } + /** + * The REST state-write path was retired when the editor moved to live + * collaboration (#36): document changes now flow through the collab server + * (ADR 0003), which is the sole writer of page state. The read paths (`GET`) + * remain. Kept as an explicit 410 so any stale client gets a clear signal. + */ @Put('pages/:id/state') - async saveState( - @Param('id') id: string, - @Body(new ZodValidationPipe(savePageStateInputSchema)) input: SavePageStateInput, - @Req() request: AuthedRequest, - ): Promise { - return this.pages.saveState(request.user!, id, input); + saveState(): never { + throw new GoneException({ + code: 'rest_state_write_retired', + details: { hint: 'Page content is edited live over the collaboration server (/collab).' }, + }); } @Patch('pages/:id') diff --git a/apps/api/src/pages/pages.e2e.db.test.ts b/apps/api/src/pages/pages.e2e.db.test.ts index d5f785b..63446cc 100644 --- a/apps/api/src/pages/pages.e2e.db.test.ts +++ b/apps/api/src/pages/pages.e2e.db.test.ts @@ -1,29 +1,13 @@ import { INestApplication } from '@nestjs/common'; -import { editorSchema } from '@dorfteich/shared'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { prosemirrorJSONToYXmlFragment } from 'y-prosemirror'; -import * as Y from 'yjs'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; -/** Encodes a one-paragraph doc with the given text as a base64 Yjs state. */ -function stateWithText(text: string): string { - const ydoc = new Y.Doc(); - const fragment = ydoc.getXmlFragment('default'); - const doc = editorSchema.node('doc', null, [ - editorSchema.node('paragraph', null, [editorSchema.text(text)]), - ]); - prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment); - const state = Buffer.from(Y.encodeStateAsUpdate(ydoc)).toString('base64'); - ydoc.destroy(); - return state; -} - describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => { let app: INestApplication; let prisma: PrismaClient; @@ -126,38 +110,25 @@ describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => { expect(second.body.slug).toBe(`duplicate-${suffix}-2`); }); - it('derives plain text and markdown into page_content_cache on state save', async () => { - const created = await api() - .post(`/api/v1/ponds/${pondId}/pages`) - .set('Cookie', ownerCookie) - .send({ title: `Derivation ${suffix}` }) - .expect(201); - - await api() - .put(`/api/v1/pages/${created.body.id}/state`) - .set('Cookie', ownerCookie) - .send({ state: stateWithText(`hello from ${suffix}`) }) - .expect(200); - - const cache = await prisma.pageContentCache.findUniqueOrThrow({ - where: { pageId: created.body.id }, - }); - expect(cache.plainText).toBe(`hello from ${suffix}`); - expect(cache.markdown).toBe(`hello from ${suffix}`); - expect(cache.html).toBe(`

hello from ${suffix}

`); - }); - it('exports the page as a downloadable Markdown file (issue #30)', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Export Me ${suffix}` }) .expect(201); - await api() - .put(`/api/v1/pages/${created.body.id}/state`) - .set('Cookie', ownerCookie) - .send({ state: stateWithText(`markdown export ${suffix}`) }) - .expect(200); + // The content cache is now refreshed by the collab server on store (#35), + // not by a REST write, so seed it directly to exercise the export path. + await prisma.pageContentCache.upsert({ + where: { pageId: created.body.id }, + create: { + pageId: created.body.id, + plainText: `markdown export ${suffix}`, + markdown: `markdown export ${suffix}`, + html: `

markdown export ${suffix}

`, + outline: [], + }, + update: { markdown: `markdown export ${suffix}` }, + }); const res = await api() .get(`/api/v1/pages/${created.body.id}/export/markdown`) @@ -175,38 +146,19 @@ describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => { .expect(404); }); - it('rejects state saves beyond the document size limit', async () => { + it('retires the REST state-write path with 410 (state now flows through collab, #36)', async () => { const created = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) - .send({ title: `Oversized ${suffix}` }) - .expect(201); - - // Decoded size exceeds the 5 MiB domain limit but its base64 form - // still fits the (much larger) raw HTTP body-size ceiling. - const oversized = Buffer.alloc(5.5 * 1024 * 1024, 1).toString('base64'); - const res = await api() - .put(`/api/v1/pages/${created.body.id}/state`) - .set('Cookie', ownerCookie) - .send({ state: oversized }) - .expect(413); - expect(res.body.code).toBe('page_document_too_large'); - expect(res.body.details.limitBytes).toBe(5 * 1024 * 1024); - }); - - it('rejects state bytes that are not a valid Yjs update', async () => { - const created = await api() - .post(`/api/v1/ponds/${pondId}/pages`) - .set('Cookie', ownerCookie) - .send({ title: `Garbage ${suffix}` }) + .send({ title: `Retired ${suffix}` }) .expect(201); const res = await api() .put(`/api/v1/pages/${created.body.id}/state`) .set('Cookie', ownerCookie) - .send({ state: Buffer.from('not a yjs update').toString('base64') }) - .expect(400); - expect(res.body.code).toBe('invalid_page_state'); + .send({ state: 'AAAA' }) + .expect(410); + expect(res.body.code).toBe('rest_state_write_retired'); }); it('keeps the slug stable on a title-only rename; validates explicit slug changes', async () => { diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index eafa47c..ec43b46 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -1,17 +1,9 @@ -import { - BadRequestException, - ConflictException, - Injectable, - NotFoundException, - PayloadTooLargeException, -} from '@nestjs/common'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CollabTokenResponse, CreatePageInput, - MAX_PAGE_DOCUMENT_BYTES, PageStateView, PageView, - SavePageStateInput, SidebarSortMode, UpdatePageInput, pondSettingsSchema, @@ -25,12 +17,7 @@ import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; import { InterimAccessService } from '../ponds/interim-access.service'; import { PrismaService } from '../prisma/prisma.service'; -import { - deriveContent, - DerivedPageContent, - emptyPageState, - InvalidPageStateError, -} from './yjs-content'; +import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content'; /** `outline` is a plain JSON-serializable array; Prisma's Json input just needs the cast. */ function contentCacheData( @@ -214,53 +201,6 @@ export class PagesService { return this.stateViewOf(page); } - async saveState(user: User, id: string, input: SavePageStateInput): Promise { - const page = await this.findModifiablePage(user, id); - const state = new Uint8Array(Buffer.from(input.state, 'base64')); - if (state.length > MAX_PAGE_DOCUMENT_BYTES) { - throw new PayloadTooLargeException({ - code: 'page_document_too_large', - details: { limitBytes: MAX_PAGE_DOCUMENT_BYTES }, - }); - } - - let content: DerivedPageContent; - try { - content = deriveContent(state); - } catch (error) { - if (error instanceof InvalidPageStateError) { - throw new BadRequestException({ code: 'invalid_page_state' }); - } - throw error; - } - - const updated = await this.prisma.page.update({ - where: { id: page.id }, - data: { - ydocState: state, - contentCache: { - upsert: { - create: contentCacheData(content), - update: contentCacheData(content), - }, - }, - }, - }); - if (content.imageFileIds.length > 0) { - // Keeps Attachment.pageId pointed at whichever page currently embeds - // the file (issue #31) — scoped to this pond so a client can't point - // an id at someone else's attachment. Not unlinked when an image is - // later removed from the content; see the schema comment on - // Attachment.pageId for why that's an accepted gap for now. - await this.prisma.attachment.updateMany({ - where: { id: { in: content.imageFileIds }, pondId: page.pondId }, - data: { pageId: page.id }, - }); - } - this.logger.info({ pageId: id, userId: user.id }, 'audit: page state saved'); - return this.stateViewOf(updated); - } - async update(user: User, id: string, input: UpdatePageInput): Promise { const page = await this.findModifiablePage(user, id); diff --git a/apps/api/src/trash/trash.e2e.db.test.ts b/apps/api/src/trash/trash.e2e.db.test.ts index de66370..1561fd3 100644 --- a/apps/api/src/trash/trash.e2e.db.test.ts +++ b/apps/api/src/trash/trash.e2e.db.test.ts @@ -2,12 +2,9 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { INestApplication } from '@nestjs/common'; -import { editorSchema } from '@dorfteich/shared'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { prosemirrorJSONToYXmlFragment } from 'y-prosemirror'; -import * as Y from 'yjs'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { ClockService } from '../common/clock.service'; @@ -21,21 +18,6 @@ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0 const pngBuffer = (payload = 'trash test png'): Buffer => Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]); -/** Encodes a one-paragraph doc embedding `fileId` as an image. */ -function stateWithImage(fileId: string): string { - const ydoc = new Y.Doc(); - const fragment = ydoc.getXmlFragment('default'); - const doc = editorSchema.node('doc', null, [ - editorSchema.node('paragraph', null, [ - editorSchema.node('image', { fileId, alt: 'trash test', width: null }), - ]), - ]); - prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment); - const state = Buffer.from(Y.encodeStateAsUpdate(ydoc)).toString('base64'); - ydoc.destroy(); - return state; -} - describe.skipIf(!hasTestDb)('page trash (e2e, issue #31)', () => { let app: INestApplication; let prisma: PrismaClient; @@ -165,13 +147,12 @@ describe.skipIf(!hasTestDb)('page trash (e2e, issue #31)', () => { .attach('file', pngBuffer(), 'restore.png') .expect(201); - await api() - .put(`/api/v1/pages/${created.body.id}/state`) - .set('Cookie', ownerCookie) - .send({ state: stateWithImage(uploaded.body.id) }) - .expect(200); - - // The state save links the embedded image to this page (issue #31). + // Embedding an image links the attachment to its page (issue #31); that + // linking now happens in the collab store (#35), so set it directly here. + await prisma.attachment.update({ + where: { id: uploaded.body.id }, + data: { pageId: created.body.id }, + }); const linked = await prisma.attachment.findUniqueOrThrow({ where: { id: uploaded.body.id } }); expect(linked.pageId).toBe(created.body.id); @@ -202,11 +183,10 @@ describe.skipIf(!hasTestDb)('page trash (e2e, issue #31)', () => { .set('Cookie', ownerCookie) .attach('file', pngBuffer(), 'purge.png') .expect(201); - await api() - .put(`/api/v1/pages/${created.body.id}/state`) - .set('Cookie', ownerCookie) - .send({ state: stateWithImage(uploaded.body.id) }) - .expect(200); + await prisma.attachment.update({ + where: { id: uploaded.body.id }, + data: { pageId: created.body.id }, + }); const filePath = join(process.env.UPLOADS_DIR!, pondId, uploaded.body.id); expect(existsSync(filePath)).toBe(true); diff --git a/apps/web/e2e/collab.spec.ts b/apps/web/e2e/collab.spec.ts new file mode 100644 index 0000000..d9e24b8 --- /dev/null +++ b/apps/web/e2e/collab.spec.ts @@ -0,0 +1,96 @@ +import { expect, test } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +async function personalPond(context: BrowserContext): Promise<{ id: string; slug: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + return { id: pond.id, slug: pond.slug }; +} + +/** Opens the page in edit mode and waits for the live connection to be up. */ +async function openEditor(context: BrowserContext, pondSlug: string, slug: string): Promise { + const page = await context.newPage(); + await page.goto(`/p/${pondSlug}/${slug}`); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); + await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', { + timeout: 15000, + }); + return page; +} + +test('two browsers editing one page converge (the milestone headline)', async ({ browser }) => { + // fixture-user owns the pond; fixture-admin is a site admin and may modify it, + // so both receive a read-write collab token under the interim access model. + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const pond = await personalPond(owner); + const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title: `Collab Converge ${Date.now()}` }, + }); + const { slug } = await created.json(); + + const pageA = await openEditor(owner, pond.slug, slug); + const pageB = await openEditor(admin, pond.slug, slug); + const editorA = pageA.locator('.ProseMirror'); + const editorB = pageB.locator('.ProseMirror'); + + await editorA.click(); + await pageA.keyboard.type('AAA from owner '); + await expect(editorB).toContainText('AAA from owner', { timeout: 10000 }); + + await editorB.click(); + await pageB.keyboard.type('BBB from admin '); + await expect(editorA).toContainText('BBB from admin', { timeout: 10000 }); + + await owner.close(); + await admin.close(); +}); + +test('offline edits continue locally and sync on reconnect', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const pond = await personalPond(owner); + const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title: `Collab Offline ${Date.now()}` }, + }); + const { slug } = await created.json(); + + const pageA = await openEditor(owner, pond.slug, slug); + const pageB = await openEditor(admin, pond.slug, slug); + const editorA = pageA.locator('.ProseMirror'); + const editorB = pageB.locator('.ProseMirror'); + + // Admin drops offline: the indicator reflects it and editing stays local. + await admin.setOffline(true); + await expect(pageB.locator('.editor-connection')).toHaveAttribute('data-status', 'offline', { + timeout: 10000, + }); + await editorB.click(); + await pageB.keyboard.type('written while offline '); + await expect(editorB).toContainText('written while offline'); + // The owner, still online, has not received the offline edit. + await expect(editorA).not.toContainText('written while offline'); + + // Back online: the provider reconnects (re-fetching a fresh token) and the + // offline edit converges to the other participant. + await admin.setOffline(false); + await expect(pageB.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', { + timeout: 20000, + }); + await expect(editorA).toContainText('written while offline', { timeout: 20000 }); + + await owner.close(); + await admin.close(); +}); + +// Read-only participants (live changes visible, typing blocked, reason shown) +// need a real read-only grant to obtain a `ro` collab token. Under the interim +// access model seeing and modifying coincide, so no user is issued a `ro` token +// yet — the `ro` UI is implemented but only becomes reachable with #53, where +// this live assertion belongs. +test.fixme('read-only participants see changes but cannot type (needs #53)', () => {}); diff --git a/apps/web/e2e/content.spec.ts b/apps/web/e2e/content.spec.ts index 2f06e04..54e96cd 100644 --- a/apps/web/e2e/content.spec.ts +++ b/apps/web/e2e/content.spec.ts @@ -87,10 +87,15 @@ test('editor basics: typing autosaves and undo/redo work', async ({ browser }) = const page = await context.newPage(); await page.goto(`/p/${pond.slug}/${slug}`); await enterEditMode(page); + // Wait for the live connection before editing (persistence is over collab + // now, #36) so undo/redo runs against the synced document. + await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', { + timeout: 10000, + }); const content = page.locator('.ProseMirror'); await content.click(); await page.keyboard.type('Hello content pack'); - await expect(page.getByRole('status')).toHaveText(/saved|gespeichert/i, { timeout: 10000 }); + await expect(content).toContainText('Hello content pack'); await page.keyboard.press('ControlOrMeta+z'); await expect(content).not.toContainText('Hello content pack'); diff --git a/apps/web/package.json b/apps/web/package.json index f161213..b24a494 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@dorfteich/shared": "workspace:*", + "@hocuspocus/provider": "^4.3.0", "@hookform/resolvers": "^5.4.0", "@tanstack/react-query": "^5.66.0", "@tiptap/core": "^3.27.1", diff --git a/apps/web/src/editor/use-collab-provider.ts b/apps/web/src/editor/use-collab-provider.ts new file mode 100644 index 0000000..66e2ee2 --- /dev/null +++ b/apps/web/src/editor/use-collab-provider.ts @@ -0,0 +1,114 @@ +import type { CollabTokenResponse } from '@dorfteich/shared'; +import { HocuspocusProvider } from '@hocuspocus/provider'; +import { useEffect, useState } from 'react'; +import * as Y from 'yjs'; + +import { apiGet } from '../lib/api'; + +/** What the editor shows about the live connection (issue #36). */ +export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline'; + +export interface CollabState { + provider: HocuspocusProvider | null; + status: ConnectionStatus; + /** Access level of the current token; `ro` clients cannot edit. */ + mode: 'rw' | 'ro' | null; + /** Set once the server rejects an update for exceeding the size ceiling (#35). */ + tooLarge: boolean; +} + +/** WebSocket endpoint of the collab server, behind the same origin as the app + * (the reverse proxy forwards `/collab`; deployment.md requires WS upgrade). */ +function collabWsUrl(): string { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}/collab`; +} + +/** + * Binds a page's `Y.Doc` to the Hocuspocus collaboration server (ADR 0003, + * realtime-collaboration.md). The document loads and persists through the + * collab server now — there is no REST autosave. The collaboration token is + * fetched lazily on every (re)connect, so an expired token is replaced + * transparently and a permission change takes effect on the next reconnect. + */ +export function useCollabProvider(ydoc: Y.Doc | null, pageId: string): CollabState { + const [provider, setProvider] = useState(null); + const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>( + 'connecting', + ); + const [synced, setSynced] = useState(false); + const [everConnected, setEverConnected] = useState(false); + const [online, setOnline] = useState(() => navigator.onLine); + const [mode, setMode] = useState<'rw' | 'ro' | null>(null); + const [tooLarge, setTooLarge] = useState(false); + + useEffect(() => { + if (!ydoc) return; + let disposed = false; + + const instance = new HocuspocusProvider({ + url: collabWsUrl(), + name: pageId, + document: ydoc, + token: async () => { + const response = await apiGet(`/pages/${pageId}/collab-token`); + if (!disposed) setMode(response.mode); + return response.token; + }, + onStatus: ({ status }) => { + if (disposed) return; + setWsStatus(status); + if (status === 'connected') setEverConnected(true); + }, + onSynced: () => { + if (!disposed) setSynced(true); + }, + onDisconnect: () => { + if (!disposed) setSynced(false); + }, + onStateless: ({ payload }) => { + if (disposed) return; + try { + const message = JSON.parse(payload) as { type?: string; code?: string }; + if (message.type === 'error' && message.code === 'page_document_too_large') { + setTooLarge(true); + } + } catch { + // Ignore malformed stateless payloads. + } + }, + }); + setProvider(instance); + + return () => { + disposed = true; + instance.destroy(); + setProvider(null); + setSynced(false); + }; + }, [ydoc, pageId]); + + useEffect(() => { + const goOnline = (): void => setOnline(true); + const goOffline = (): void => setOnline(false); + window.addEventListener('online', goOnline); + window.addEventListener('offline', goOffline); + return () => { + window.removeEventListener('online', goOnline); + window.removeEventListener('offline', goOffline); + }; + }, []); + + let status: ConnectionStatus; + if (!online) { + status = 'offline'; + } else if (wsStatus === 'connected' && synced) { + status = 'connected'; + } else if (everConnected) { + status = 'reconnecting'; + } else { + status = 'connecting'; + } + + return { provider, status, mode, tooLarge }; +} diff --git a/apps/web/src/editor/use-page-autosave.ts b/apps/web/src/editor/use-page-autosave.ts deleted file mode 100644 index c6d542f..0000000 --- a/apps/web/src/editor/use-page-autosave.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import * as Y from 'yjs'; - -import { apiPut } from '../lib/api'; -import { encodeBase64 } from './yjs-base64'; - -export type SaveStatus = 'saved' | 'saving' | 'error'; - -const DEBOUNCE_MS = 800; -const RETRY_MS = 3000; - -/** Debounced `PUT /pages/:id/state` on every local Yjs update, with a - * truthful save-state indicator: failures (e.g. no network) surface as - * `error` and keep retrying every `RETRY_MS` until a save succeeds - * (issue #25 acceptance criterion: "saving failed / retrying"). */ -export function usePageStateAutosave(ydoc: Y.Doc | null, pageId: string): SaveStatus { - const [status, setStatus] = useState('saved'); - const timerRef = useRef>(undefined); - const inFlightRef = useRef(false); - const dirtyRef = useRef(false); - - useEffect(() => { - if (!ydoc) return; - const doc = ydoc; - - function save(): void { - if (inFlightRef.current) { - dirtyRef.current = true; - return; - } - inFlightRef.current = true; - dirtyRef.current = false; - setStatus('saving'); - apiPut(`/pages/${pageId}/state`, { state: encodeBase64(Y.encodeStateAsUpdate(doc)) }) - .then(() => { - inFlightRef.current = false; - setStatus('saved'); - if (dirtyRef.current) { - dirtyRef.current = false; - timerRef.current = setTimeout(save, DEBOUNCE_MS); - } - }) - .catch(() => { - inFlightRef.current = false; - setStatus('error'); - timerRef.current = setTimeout(save, RETRY_MS); - }); - } - - function onUpdate(): void { - setStatus((current) => (current === 'error' ? current : 'saving')); - clearTimeout(timerRef.current); - timerRef.current = setTimeout(save, DEBOUNCE_MS); - } - - doc.on('update', onUpdate); - return () => { - doc.off('update', onUpdate); - clearTimeout(timerRef.current); - }; - }, [ydoc, pageId]); - - return status; -} diff --git a/apps/web/src/editor/yjs-base64.ts b/apps/web/src/editor/yjs-base64.ts deleted file mode 100644 index 92bdabc..0000000 --- a/apps/web/src/editor/yjs-base64.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Browser-side base64 <-> Yjs update bytes; the api exchanges Yjs state as - * base64 over JSON (`apps/api/src/pages/pages.service.ts`, issue #23). */ - -export function decodeBase64(base64: string): Uint8Array { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -export function encodeBase64(bytes: Uint8Array): string { - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 5a9a5f5..9659ca0 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -11,8 +11,7 @@ import { FormError } from '../components/forms'; import { documentExtensions } from '../editor/document-extensions'; import { ImageUpload } from '../editor/image-upload'; import { Toolbar } from '../editor/Toolbar'; -import { usePageStateAutosave } from '../editor/use-page-autosave'; -import { decodeBase64 } from '../editor/yjs-base64'; +import { useCollabProvider } from '../editor/use-collab-provider'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api'; @@ -27,17 +26,21 @@ function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React. // ever creating a fresh one, silently breaking Yjs-internal machinery // (e.g. the undo manager) while `Y.encodeStateAsUpdate`/`applyUpdate` // still happen to keep working — a bug that only shows up in dev. + // + // The document starts empty: the collab provider (below) loads the page + // state from the server (#35/#36), so there is no REST seed to merge — that + // would create a second doc lineage and duplicate the content. const [ydoc, setYdoc] = useState(null); useEffect(() => { const doc = new Y.Doc(); - Y.applyUpdate(doc, decodeBase64(page.state)); setYdoc(doc); return () => doc.destroy(); - // Deliberately not depending on `page.state`: a background refetch of - // the same page must not blow away in-progress local edits by rebuilding - // the Y.Doc from the (stale) server snapshot. }, [page.id]); + const collab = useCollabProvider(ydoc, page.id); + const readOnly = collab.mode === 'ro'; + const canEdit = mode === 'edit' && !readOnly; + const editor = useEditor( { // `documentExtensions` alone is a valid (uncollaborated) schema, so the @@ -50,26 +53,34 @@ function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React. Collaboration.configure({ document: ydoc, field: 'default' }), ] : documentExtensions, - editable: mode === 'edit', + editable: canEdit, immediatelyRender: false, }, [ydoc], ); useLayoutEffect(() => { - editor?.setEditable(mode === 'edit'); - }, [editor, mode]); - - const saveStatus = usePageStateAutosave(ydoc, page.id); + editor?.setEditable(canEdit); + }, [editor, canEdit]); if (!editor || !ydoc) return <>; return (
- {mode === 'edit' && } -
- {t(`save.${saveStatus}`)} + {canEdit && } +
+ {t(`connection.${collab.status}`)}
+ {mode === 'edit' && readOnly && ( +
+ {t('readOnly.notice')} +
+ )} + {collab.tooLarge && ( +
+ {t('tooLarge.notice')} +
+ )}
); diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 7a0c5bb..81a1ec2 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -539,12 +539,39 @@ button { gap: var(--space-1); } -.editor-save-indicator { +.editor-connection { padding: var(--space-1) var(--space-3); font-size: 0.85rem; color: var(--color-text-muted); } +.editor-connection[data-status='connected'] { + color: var(--color-accent); +} + +.editor-connection[data-status='offline'] { + color: var(--color-danger); +} + +.editor-banner { + margin: var(--space-1) var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm, 4px); + font-size: 0.9rem; +} + +.editor-banner--info { + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + color: var(--color-text-muted); +} + +.editor-banner--error { + background: var(--color-bg-subtle); + border: 1px solid var(--color-danger); + color: var(--color-danger); +} + .editor-content { padding: var(--space-4) var(--space-6); min-height: 12rem; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 64de2b9..b915c1e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -8,6 +8,11 @@ export default defineConfig({ // projects). Containerized dev overrides this via VITE_API_PROXY_TARGET. proxy: { '/api': process.env.VITE_API_PROXY_TARGET ?? 'http://localhost:3001', + // The collab WebSocket (issue #36); `ws: true` upgrades the connection. + '/collab': { + target: process.env.VITE_COLLAB_PROXY_TARGET ?? 'http://localhost:3002', + ws: true, + }, }, }, }); diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 2a15ba9..32a7d67 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -8,11 +8,17 @@ "toggleToEdit": "In den Bearbeitungsmodus wechseln", "toggleToView": "In den Lesemodus wechseln" }, - "save": { - "saved": "Gespeichert", - "saving": "Speichert …", - "error": "Speichern fehlgeschlagen, erneuter Versuch …", - "unsavedTitle": "Titel wird gespeichert …" + "connection": { + "connecting": "Verbindet …", + "connected": "Live", + "reconnecting": "Verbindung wird wiederhergestellt …", + "offline": "Offline — deine Änderungen werden synchronisiert, sobald du wieder verbunden bist" + }, + "readOnly": { + "notice": "Du hast nur Lesezugriff auf diese Seite und kannst sie daher nicht bearbeiten." + }, + "tooLarge": { + "notice": "Diese Seite hat ihre maximale Größe erreicht, daher wurde deine letzte Änderung nicht gespeichert. Bitte entferne etwas Inhalt." }, "toolbar": { "paragraph": "Absatz", diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index 2a4d12d..a64bbb7 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -8,11 +8,17 @@ "toggleToEdit": "Switch to edit mode", "toggleToView": "Switch to read mode" }, - "save": { - "saved": "Saved", - "saving": "Saving …", - "error": "Saving failed, retrying …", - "unsavedTitle": "Saving title …" + "connection": { + "connecting": "Connecting …", + "connected": "Live", + "reconnecting": "Reconnecting …", + "offline": "Offline — your changes will sync when you reconnect" + }, + "readOnly": { + "notice": "You have read-only access to this page, so you can't edit it." + }, + "tooLarge": { + "notice": "This page has reached its maximum size, so your latest change wasn't saved. Please remove some content." }, "toolbar": { "paragraph": "Paragraph", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b46993d..4e264cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: '@dorfteich/shared': specifier: workspace:* version: link:../../packages/shared + '@hocuspocus/provider': + specifier: ^4.3.0 + version: 4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@hookform/resolvers': specifier: ^5.4.0 version: 5.4.0(react-hook-form@7.80.0(react@19.2.7)) diff --git a/scripts/e2e-static-server.mjs b/scripts/e2e-static-server.mjs index 15bb08d..166ad0e 100644 --- a/scripts/e2e-static-server.mjs +++ b/scripts/e2e-static-server.mjs @@ -1,11 +1,13 @@ #!/usr/bin/env node /** * Minimal server for e2e runs: serves the built SPA (apps/web/dist) with - * SPA fallback and proxies /api/* to the api process — mirroring what - * nginx + Caddy do in production, without a dev server that may die - * under CI memory pressure. Node builtins only. + * SPA fallback, proxies /api/* to the api process, and proxies the /collab + * WebSocket to the collab process — mirroring what nginx + Caddy do in + * production, without a dev server that may die under CI memory pressure. + * Node builtins only. * - * PORT=5173 API_TARGET=http://127.0.0.1:3001 node scripts/e2e-static-server.mjs + * PORT=5173 API_TARGET=http://127.0.0.1:3001 COLLAB_TARGET=http://127.0.0.1:3002 \ + * node scripts/e2e-static-server.mjs */ import { readFile } from 'node:fs/promises'; import { createServer, request as httpRequest } from 'node:http'; @@ -15,6 +17,7 @@ import { fileURLToPath } from 'node:url'; const DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '../apps/web/dist'); const PORT = Number(process.env.PORT ?? 5173); const API_TARGET = new URL(process.env.API_TARGET ?? 'http://127.0.0.1:3001'); +const COLLAB_TARGET = new URL(process.env.COLLAB_TARGET ?? 'http://127.0.0.1:3002'); const MIME = { '.html': 'text/html; charset=utf-8', @@ -64,7 +67,43 @@ async function serveStatic(req, res) { } } -createServer((req, res) => { +const server = createServer((req, res) => { if (req.url.startsWith('/api/')) proxyApi(req, res); else void serveStatic(req, res); -}).listen(PORT, () => console.log(`e2e static server on :${PORT} → ${API_TARGET.href}`)); +}); + +// Proxy the collab WebSocket (Caddy forwards /collab without stripping the +// prefix in production; mirror that here so the editor connects the same way). +server.on('upgrade', (req, socket, head) => { + if (!req.url.startsWith('/collab')) { + socket.destroy(); + return; + } + const upstream = httpRequest({ + hostname: COLLAB_TARGET.hostname, + port: COLLAB_TARGET.port, + path: req.url, + method: req.method, + headers: { ...req.headers, host: `${COLLAB_TARGET.hostname}:${COLLAB_TARGET.port}` }, + }); + upstream.on('upgrade', (upstreamRes, upstreamSocket, upstreamHead) => { + const statusLine = `HTTP/1.1 ${upstreamRes.statusCode} ${upstreamRes.statusMessage}\r\n`; + const headerLines = Object.entries(upstreamRes.headers) + .map(([key, value]) => `${key}: ${value}`) + .join('\r\n'); + socket.write(`${statusLine}${headerLines}\r\n\r\n`); + if (upstreamHead?.length) socket.write(upstreamHead); + upstreamSocket.pipe(socket); + socket.pipe(upstreamSocket); + upstreamSocket.on('error', () => socket.destroy()); + }); + upstream.on('error', () => socket.destroy()); + if (head?.length) upstream.write(head); + upstream.end(); +}); + +server.listen(PORT, () => + console.log( + `e2e static server on :${PORT} → api ${API_TARGET.href} collab ${COLLAB_TARGET.href}`, + ), +);