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
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:
parent
7d8f331870
commit
7d04c0b594
@ -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
|
||||
|
||||
@ -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<PageStateView> {
|
||||
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')
|
||||
|
||||
@ -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(`<p>hello from ${suffix}</p>`);
|
||||
});
|
||||
|
||||
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: `<p>markdown export ${suffix}</p>`,
|
||||
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 () => {
|
||||
|
||||
@ -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<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> {
|
||||
const page = await this.findModifiablePage(user, id);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
96
apps/web/e2e/collab.spec.ts
Normal file
96
apps/web/e2e/collab.spec.ts
Normal 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)', () => {});
|
||||
@ -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');
|
||||
|
||||
@ -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",
|
||||
|
||||
114
apps/web/src/editor/use-collab-provider.ts
Normal file
114
apps/web/src/editor/use-collab-provider.ts
Normal 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 };
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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<Y.Doc | null>(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 (
|
||||
<div className="editor-shell">
|
||||
{mode === 'edit' && <Toolbar editor={editor} />}
|
||||
<div className="editor-save-indicator" role="status">
|
||||
{t(`save.${saveStatus}`)}
|
||||
{canEdit && <Toolbar editor={editor} />}
|
||||
<div className="editor-connection" role="status" data-status={collab.status}>
|
||||
{t(`connection.${collab.status}`)}
|
||||
</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" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -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))
|
||||
|
||||
@ -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}`,
|
||||
),
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user