Switch the editor to live collaboration (#36)
All checks were successful
CD / Build and push images (push) Successful in 2m59s
CI / Lint, typecheck, test (push) Successful in 2m0s
CI / Auth e2e pack (push) Successful in 2m10s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
Claude Opus 4.8 2026-07-08 18:00:19 +02:00
parent 7d8f331870
commit 7d04c0b594
18 changed files with 407 additions and 280 deletions

View File

@ -105,14 +105,19 @@ jobs:
- name: Seed fixtures - name: Seed fixtures
run: pnpm --filter @dorfteich/api db:seed run: pnpm --filter @dorfteich/api db:seed
- name: Start api and static web server - name: Start api, collab, and static web server
run: | run: |
(cd apps/api && PORT=3001 node dist/main.js > /tmp/api.log 2>&1 &) (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 for i in $(seq 1 30); do
curl -sf http://localhost:3001/api/v1/readyz >/dev/null && break curl -sf http://localhost:3001/api/v1/readyz >/dev/null && break
sleep 2 sleep 2
done 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 curl -sf http://localhost:5173/ >/dev/null
- name: Install Playwright browser - name: Install Playwright browser
@ -137,9 +142,21 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/content.spec.ts 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 - name: Dump server logs on failure
if: 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: images:
name: Build container images name: Build container images

View File

@ -3,6 +3,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
GoneException,
HttpCode, HttpCode,
Param, Param,
Patch, Patch,
@ -16,10 +17,8 @@ import {
CreatePageInput, CreatePageInput,
PageStateView, PageStateView,
PageView, PageView,
SavePageStateInput,
UpdatePageInput, UpdatePageInput,
createPageInputSchema, createPageInputSchema,
savePageStateInputSchema,
updatePageInputSchema, updatePageInputSchema,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import type { Response } from 'express'; import type { Response } from 'express';
@ -86,13 +85,18 @@ export class PagesController {
return this.pages.getStateBySlug(request.user!, pondId, slug); 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') @Put('pages/:id/state')
async saveState( saveState(): never {
@Param('id') id: string, throw new GoneException({
@Body(new ZodValidationPipe(savePageStateInputSchema)) input: SavePageStateInput, code: 'rest_state_write_retired',
@Req() request: AuthedRequest, details: { hint: 'Page content is edited live over the collaboration server (/collab).' },
): Promise<PageStateView> { });
return this.pages.saveState(request.user!, id, input);
} }
@Patch('pages/:id') @Patch('pages/:id')

View File

@ -1,29 +1,13 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { editorSchema } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import request from 'supertest'; import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; 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)', () => { describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => {
let app: INestApplication; let app: INestApplication;
let prisma: PrismaClient; let prisma: PrismaClient;
@ -126,38 +110,25 @@ describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => {
expect(second.body.slug).toBe(`duplicate-${suffix}-2`); 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(`<p>hello from ${suffix}</p>`);
});
it('exports the page as a downloadable Markdown file (issue #30)', async () => { it('exports the page as a downloadable Markdown file (issue #30)', async () => {
const created = await api() const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`) .post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie) .set('Cookie', ownerCookie)
.send({ title: `Export Me ${suffix}` }) .send({ title: `Export Me ${suffix}` })
.expect(201); .expect(201);
await api() // The content cache is now refreshed by the collab server on store (#35),
.put(`/api/v1/pages/${created.body.id}/state`) // not by a REST write, so seed it directly to exercise the export path.
.set('Cookie', ownerCookie) await prisma.pageContentCache.upsert({
.send({ state: stateWithText(`markdown export ${suffix}`) }) where: { pageId: created.body.id },
.expect(200); create: {
pageId: created.body.id,
plainText: `markdown export ${suffix}`,
markdown: `markdown export ${suffix}`,
html: `<p>markdown export ${suffix}</p>`,
outline: [],
},
update: { markdown: `markdown export ${suffix}` },
});
const res = await api() const res = await api()
.get(`/api/v1/pages/${created.body.id}/export/markdown`) .get(`/api/v1/pages/${created.body.id}/export/markdown`)
@ -175,38 +146,19 @@ describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => {
.expect(404); .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() const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`) .post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie) .set('Cookie', ownerCookie)
.send({ title: `Oversized ${suffix}` }) .send({ title: `Retired ${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}` })
.expect(201); .expect(201);
const res = await api() const res = await api()
.put(`/api/v1/pages/${created.body.id}/state`) .put(`/api/v1/pages/${created.body.id}/state`)
.set('Cookie', ownerCookie) .set('Cookie', ownerCookie)
.send({ state: Buffer.from('not a yjs update').toString('base64') }) .send({ state: 'AAAA' })
.expect(400); .expect(410);
expect(res.body.code).toBe('invalid_page_state'); expect(res.body.code).toBe('rest_state_write_retired');
}); });
it('keeps the slug stable on a title-only rename; validates explicit slug changes', async () => { it('keeps the slug stable on a title-only rename; validates explicit slug changes', async () => {

View File

@ -1,17 +1,9 @@
import { import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
PayloadTooLargeException,
} from '@nestjs/common';
import { import {
CollabTokenResponse, CollabTokenResponse,
CreatePageInput, CreatePageInput,
MAX_PAGE_DOCUMENT_BYTES,
PageStateView, PageStateView,
PageView, PageView,
SavePageStateInput,
SidebarSortMode, SidebarSortMode,
UpdatePageInput, UpdatePageInput,
pondSettingsSchema, pondSettingsSchema,
@ -25,12 +17,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { InterimAccessService } from '../ponds/interim-access.service'; import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content';
deriveContent,
DerivedPageContent,
emptyPageState,
InvalidPageStateError,
} from './yjs-content';
/** `outline` is a plain JSON-serializable array; Prisma's Json input just needs the cast. */ /** `outline` is a plain JSON-serializable array; Prisma's Json input just needs the cast. */
function contentCacheData( function contentCacheData(
@ -214,53 +201,6 @@ export class PagesService {
return this.stateViewOf(page); return this.stateViewOf(page);
} }
async saveState(user: User, id: string, input: SavePageStateInput): Promise<PageStateView> {
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<PageView> { async update(user: User, id: string, input: UpdatePageInput): Promise<PageView> {
const page = await this.findModifiablePage(user, id); const page = await this.findModifiablePage(user, id);

View File

@ -2,12 +2,9 @@ import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { editorSchema } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import request from 'supertest'; import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 { AuthTokensService } from '../auth/auth-tokens.service';
import { ClockService } from '../common/clock.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 => const pngBuffer = (payload = 'trash test png'): Buffer =>
Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]); 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)', () => { describe.skipIf(!hasTestDb)('page trash (e2e, issue #31)', () => {
let app: INestApplication; let app: INestApplication;
let prisma: PrismaClient; let prisma: PrismaClient;
@ -165,13 +147,12 @@ describe.skipIf(!hasTestDb)('page trash (e2e, issue #31)', () => {
.attach('file', pngBuffer(), 'restore.png') .attach('file', pngBuffer(), 'restore.png')
.expect(201); .expect(201);
await api() // Embedding an image links the attachment to its page (issue #31); that
.put(`/api/v1/pages/${created.body.id}/state`) // linking now happens in the collab store (#35), so set it directly here.
.set('Cookie', ownerCookie) await prisma.attachment.update({
.send({ state: stateWithImage(uploaded.body.id) }) where: { id: uploaded.body.id },
.expect(200); data: { pageId: created.body.id },
});
// The state save links the embedded image to this page (issue #31).
const linked = await prisma.attachment.findUniqueOrThrow({ where: { id: uploaded.body.id } }); const linked = await prisma.attachment.findUniqueOrThrow({ where: { id: uploaded.body.id } });
expect(linked.pageId).toBe(created.body.id); expect(linked.pageId).toBe(created.body.id);
@ -202,11 +183,10 @@ describe.skipIf(!hasTestDb)('page trash (e2e, issue #31)', () => {
.set('Cookie', ownerCookie) .set('Cookie', ownerCookie)
.attach('file', pngBuffer(), 'purge.png') .attach('file', pngBuffer(), 'purge.png')
.expect(201); .expect(201);
await api() await prisma.attachment.update({
.put(`/api/v1/pages/${created.body.id}/state`) where: { id: uploaded.body.id },
.set('Cookie', ownerCookie) data: { pageId: created.body.id },
.send({ state: stateWithImage(uploaded.body.id) }) });
.expect(200);
const filePath = join(process.env.UPLOADS_DIR!, pondId, uploaded.body.id); const filePath = join(process.env.UPLOADS_DIR!, pondId, uploaded.body.id);
expect(existsSync(filePath)).toBe(true); expect(existsSync(filePath)).toBe(true);

View File

@ -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<Page> {
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)', () => {});

View File

@ -87,10 +87,15 @@ test('editor basics: typing autosaves and undo/redo work', async ({ browser }) =
const page = await context.newPage(); const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${slug}`); await page.goto(`/p/${pond.slug}/${slug}`);
await enterEditMode(page); 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'); const content = page.locator('.ProseMirror');
await content.click(); await content.click();
await page.keyboard.type('Hello content pack'); 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 page.keyboard.press('ControlOrMeta+z');
await expect(content).not.toContainText('Hello content pack'); await expect(content).not.toContainText('Hello content pack');

View File

@ -15,6 +15,7 @@
}, },
"dependencies": { "dependencies": {
"@dorfteich/shared": "workspace:*", "@dorfteich/shared": "workspace:*",
"@hocuspocus/provider": "^4.3.0",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@tiptap/core": "^3.27.1", "@tiptap/core": "^3.27.1",

View File

@ -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<HocuspocusProvider | null>(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<CollabTokenResponse>(`/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 };
}

View File

@ -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<SaveStatus>('saved');
const timerRef = useRef<ReturnType<typeof setTimeout>>(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;
}

View File

@ -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);
}

View File

@ -11,8 +11,7 @@ import { FormError } from '../components/forms';
import { documentExtensions } from '../editor/document-extensions'; import { documentExtensions } from '../editor/document-extensions';
import { ImageUpload } from '../editor/image-upload'; import { ImageUpload } from '../editor/image-upload';
import { Toolbar } from '../editor/Toolbar'; import { Toolbar } from '../editor/Toolbar';
import { usePageStateAutosave } from '../editor/use-page-autosave'; import { useCollabProvider } from '../editor/use-collab-provider';
import { decodeBase64 } from '../editor/yjs-base64';
import { useForceSidebarHidden } from '../layout/sidebar-chrome'; import { useForceSidebarHidden } from '../layout/sidebar-chrome';
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api'; 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 // ever creating a fresh one, silently breaking Yjs-internal machinery
// (e.g. the undo manager) while `Y.encodeStateAsUpdate`/`applyUpdate` // (e.g. the undo manager) while `Y.encodeStateAsUpdate`/`applyUpdate`
// still happen to keep working — a bug that only shows up in dev. // 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<Y.Doc | null>(null); const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
useEffect(() => { useEffect(() => {
const doc = new Y.Doc(); const doc = new Y.Doc();
Y.applyUpdate(doc, decodeBase64(page.state));
setYdoc(doc); setYdoc(doc);
return () => doc.destroy(); 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]); }, [page.id]);
const collab = useCollabProvider(ydoc, page.id);
const readOnly = collab.mode === 'ro';
const canEdit = mode === 'edit' && !readOnly;
const editor = useEditor( const editor = useEditor(
{ {
// `documentExtensions` alone is a valid (uncollaborated) schema, so the // `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' }), Collaboration.configure({ document: ydoc, field: 'default' }),
] ]
: documentExtensions, : documentExtensions,
editable: mode === 'edit', editable: canEdit,
immediatelyRender: false, immediatelyRender: false,
}, },
[ydoc], [ydoc],
); );
useLayoutEffect(() => { useLayoutEffect(() => {
editor?.setEditable(mode === 'edit'); editor?.setEditable(canEdit);
}, [editor, mode]); }, [editor, canEdit]);
const saveStatus = usePageStateAutosave(ydoc, page.id);
if (!editor || !ydoc) return <></>; if (!editor || !ydoc) return <></>;
return ( return (
<div className="editor-shell"> <div className="editor-shell">
{mode === 'edit' && <Toolbar editor={editor} />} {canEdit && <Toolbar editor={editor} />}
<div className="editor-save-indicator" role="status"> <div className="editor-connection" role="status" data-status={collab.status}>
{t(`save.${saveStatus}`)} {t(`connection.${collab.status}`)}
</div> </div>
{mode === 'edit' && readOnly && (
<div className="editor-banner editor-banner--info" role="note">
{t('readOnly.notice')}
</div>
)}
{collab.tooLarge && (
<div className="editor-banner editor-banner--error" role="alert">
{t('tooLarge.notice')}
</div>
)}
<EditorContent editor={editor} className="editor-content" /> <EditorContent editor={editor} className="editor-content" />
</div> </div>
); );

View File

@ -539,12 +539,39 @@ button {
gap: var(--space-1); gap: var(--space-1);
} }
.editor-save-indicator { .editor-connection {
padding: var(--space-1) var(--space-3); padding: var(--space-1) var(--space-3);
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); 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 { .editor-content {
padding: var(--space-4) var(--space-6); padding: var(--space-4) var(--space-6);
min-height: 12rem; min-height: 12rem;

View File

@ -8,6 +8,11 @@ export default defineConfig({
// projects). Containerized dev overrides this via VITE_API_PROXY_TARGET. // projects). Containerized dev overrides this via VITE_API_PROXY_TARGET.
proxy: { proxy: {
'/api': process.env.VITE_API_PROXY_TARGET ?? 'http://localhost:3001', '/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,
},
}, },
}, },
}); });

View File

@ -8,11 +8,17 @@
"toggleToEdit": "In den Bearbeitungsmodus wechseln", "toggleToEdit": "In den Bearbeitungsmodus wechseln",
"toggleToView": "In den Lesemodus wechseln" "toggleToView": "In den Lesemodus wechseln"
}, },
"save": { "connection": {
"saved": "Gespeichert", "connecting": "Verbindet …",
"saving": "Speichert …", "connected": "Live",
"error": "Speichern fehlgeschlagen, erneuter Versuch …", "reconnecting": "Verbindung wird wiederhergestellt …",
"unsavedTitle": "Titel wird gespeichert …" "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": { "toolbar": {
"paragraph": "Absatz", "paragraph": "Absatz",

View File

@ -8,11 +8,17 @@
"toggleToEdit": "Switch to edit mode", "toggleToEdit": "Switch to edit mode",
"toggleToView": "Switch to read mode" "toggleToView": "Switch to read mode"
}, },
"save": { "connection": {
"saved": "Saved", "connecting": "Connecting …",
"saving": "Saving …", "connected": "Live",
"error": "Saving failed, retrying …", "reconnecting": "Reconnecting …",
"unsavedTitle": "Saving title …" "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": { "toolbar": {
"paragraph": "Paragraph", "paragraph": "Paragraph",

3
pnpm-lock.yaml generated
View File

@ -190,6 +190,9 @@ importers:
'@dorfteich/shared': '@dorfteich/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared 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': '@hookform/resolvers':
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.80.0(react@19.2.7)) version: 5.4.0(react-hook-form@7.80.0(react@19.2.7))

View File

@ -1,11 +1,13 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Minimal server for e2e runs: serves the built SPA (apps/web/dist) with * Minimal server for e2e runs: serves the built SPA (apps/web/dist) with
* SPA fallback and proxies /api/* to the api process mirroring what * SPA fallback, proxies /api/* to the api process, and proxies the /collab
* nginx + Caddy do in production, without a dev server that may die * WebSocket to the collab process mirroring what nginx + Caddy do in
* under CI memory pressure. Node builtins only. * 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 { readFile } from 'node:fs/promises';
import { createServer, request as httpRequest } from 'node:http'; 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 DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '../apps/web/dist');
const PORT = Number(process.env.PORT ?? 5173); const PORT = Number(process.env.PORT ?? 5173);
const API_TARGET = new URL(process.env.API_TARGET ?? 'http://127.0.0.1:3001'); 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 = { const MIME = {
'.html': 'text/html; charset=utf-8', '.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); if (req.url.startsWith('/api/')) proxyApi(req, res);
else void serveStatic(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}`,
),
);