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}`, + ), +);