From 65dfcac8d1c30997db3955d631b4844ca7d05125 Mon Sep 17 00:00:00 2001 From: AxelleDev <139721736+AxelleDev@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:02:55 +0200 Subject: [PATCH 1/2] made shared pages update live via server-sent events --- README.md | 4 +- backend/API.md | 15 +-- .../src/controllers/projects.controller.js | 38 ++++++ backend/src/docs/paths/projects.js | 22 ++++ backend/src/routes/projects.routes.js | 17 +++ backend/src/routes/share.routes.js | 1 + .../src/services/projectSharing.service.js | 19 +++ backend/src/services/projects.service.js | 2 + backend/src/services/shareEvents.service.js | 115 ++++++++++++++++++ .../tests/unit/projects.controller.test.js | 51 ++++++++ backend/tests/unit/projects.routes.test.js | 37 +++++- .../tests/unit/shareEvents.service.test.js | 76 ++++++++++++ e2e/tests/critical-path.spec.js | 29 +++++ frontend/src/hooks/useShareLiveUpdates.js | 49 ++++++++ frontend/src/pages/SharedProject.jsx | 69 +++++++---- .../tests/integration/SharedProject.test.jsx | 87 ++++++++++++- 16 files changed, 598 insertions(+), 33 deletions(-) create mode 100644 backend/src/services/shareEvents.service.js create mode 100644 backend/tests/unit/shareEvents.service.test.js create mode 100644 frontend/src/hooks/useShareLiveUpdates.js diff --git a/README.md b/README.md index a817bd5..5b64355 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,9 @@ from one drawing to the next. scannable, downloadable QR code for showing the sheet on a phone. Shared links **unfurl with a live preview image** of the project's actual palette (rendered server-side) on WhatsApp, LinkedIn, Discord, Slack and the like, - instead of a generic logo card. + instead of a generic logo card. And the shared page is **live**: anyone + viewing it sees your edits appear in real time (Server-Sent Events under + the hood), including the link being revoked. - **Accessible & resilient** — keyboard-operable throughout (including drag-and-drop, which always has a keyboard alternative), a warning before leaving a page with unsaved changes, a heads-up before your session expires, and clear rate-limit diff --git a/backend/API.md b/backend/API.md index 4a05919..d4344c2 100644 --- a/backend/API.md +++ b/backend/API.md @@ -165,13 +165,14 @@ Pinned projects sort before unpinned ones on `GET /projects`. ### Sharing -| Method | Path | Auth | Success | -| -------- | --------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `POST` | `/projects/:id/share` | ✓ | `{ shareToken }` — mints (or returns the existing) public share token | -| `DELETE` | `/projects/:id/share` | ✓ | `{ success }` — revokes the link immediately | -| `GET` | `/share/:token` | – | the read-only reference sheet: `{ name, brushNorms[], typographyNorms[], palette[], ownerName }` — public, rate limited per IP (60/min); `404` if the token is invalid, revoked, or the project is trashed | -| `GET` | `/share/:token/preview.png` | – | a 1200×630 PNG of the project (name, owner credit, palette swatches) rendered server-side — the og:image behind share links; same rate limit and 404 contract | -| `GET` | `/share/:token/embed` | – | minimal HTML carrying the Open Graph/Twitter tags for the share link — social crawlers are rewritten here by the frontend (they don't run the SPA); humans get redirected to the real page | +| Method | Path | Auth | Success | +| -------- | --------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/projects/:id/share` | ✓ | `{ shareToken }` — mints (or returns the existing) public share token | +| `DELETE` | `/projects/:id/share` | ✓ | `{ success }` — revokes the link immediately | +| `GET` | `/share/:token` | – | the read-only reference sheet: `{ name, brushNorms[], typographyNorms[], palette[], ownerName }` — public, rate limited per IP (60/min); `404` if the token is invalid, revoked, or the project is trashed | +| `GET` | `/share/:token/preview.png` | – | a 1200×630 PNG of the project (name, owner credit, palette swatches) rendered server-side — the og:image behind share links; same rate limit and 404 contract | +| `GET` | `/share/:token/embed` | – | minimal HTML carrying the Open Graph/Twitter tags for the share link — social crawlers are rewritten here by the frontend (they don't run the SPA); humans get redirected to the real page | +| `GET` | `/share/:token/events` | – | Server-Sent Events stream: a bare `changed` event fires on every owner mutation (subscribers refetch the share endpoint); heartbeats keep proxies alive, and a `full` event ends the stream when the per-project viewer cap is reached | `ownerName` is the owner's display name only — never their id or email — shown as a "Made by …" credit on the public page and in the exported PDF. diff --git a/backend/src/controllers/projects.controller.js b/backend/src/controllers/projects.controller.js index 89b9932..42682fa 100644 --- a/backend/src/controllers/projects.controller.js +++ b/backend/src/controllers/projects.controller.js @@ -6,6 +6,7 @@ const { getAuthenticatedUserId, createControllerLogger } = require('../utils/auth.utils'); const projectsService = require('../services/projects.service'); const sharePreviewService = require('../services/sharePreview.service'); +const shareEventsService = require('../services/shareEvents.service'); const logProjectsControllerError = createControllerLogger('projects'); @@ -301,6 +302,42 @@ const getSharedProjectEmbed = async (req, res) => { } }; +// PUBLIC (no auth): the live-update stream behind a shared page. Long-lived +// SSE response; subscribers get a bare `changed` ping whenever the owner +// mutates the project (see the notify middleware in projects.routes) and +// refetch the share endpoint themselves — no content ever flows through here. +const getSharedProjectEvents = async (req, res) => { + let projectId; + try { + projectId = await projectsService.getSharedProjectIdByToken(req.params.token); + } catch (error) { + if (error.code === 'not_found') { + return res.status(404).json({ error: 'This link is no longer active.' }); + } + logProjectsControllerError(req, 'get_shared_events', error); + return res.status(500).json({ error: 'Server error.' }); + } + + res.set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + // Tells nginx-style proxies not to buffer the stream. + 'X-Accel-Buffering': 'no', + }); + res.flushHeaders(); + // EventSource reconnect delay after a drop (proxies do reap long requests). + res.write('retry: 3000\n\n'); + + if (!shareEventsService.subscribe(projectId, res)) { + // Per-project cap reached: end politely, the page just isn't live. + res.write('event: full\ndata: {}\n\n'); + return res.end(); + } + + req.on('close', () => shareEventsService.unsubscribe(projectId, res)); +}; + // Rename a project owned by the user and refresh its last_edited timestamp. // Same name rule as creation, enforced by the shared service validator. const updateProjectName = async (req, res) => { @@ -755,6 +792,7 @@ module.exports = { getSharedProject, getSharedProjectPreview, getSharedProjectEmbed, + getSharedProjectEvents, addBrushNorm, addTypographyNorm, updatePalette, diff --git a/backend/src/docs/paths/projects.js b/backend/src/docs/paths/projects.js index 5c275d7..fe30553 100644 --- a/backend/src/docs/paths/projects.js +++ b/backend/src/docs/paths/projects.js @@ -488,6 +488,28 @@ module.exports = { }, }, }, + '/api/share/{token}/events': { + get: { + tags: ['Projects'], + summary: 'PUBLIC: live-update stream for a shared page (no auth, SSE)', + description: + 'A Server-Sent Events stream (`text/event-stream`). Subscribers receive a bare ' + + '`changed` event whenever the owner mutates the project (palette, standards, name, ' + + 'trash/restore, revocation) and are expected to refetch `/api/share/{token}` — no ' + + 'content flows through the stream itself. Heartbeat comments keep proxies from ' + + 'reaping the connection; when a project reaches its viewer cap the server sends a ' + + '`full` event and ends the stream.', + parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + 200: { + description: 'The event stream.', + content: { 'text/event-stream': { schema: { type: 'string' } } }, + }, + 404: { $ref: '#/components/responses/NotFound' }, + 429: { $ref: '#/components/responses/RateLimited' }, + }, + }, + }, '/api/projects/{id}/brush-norms': { post: { tags: ['Projects'], diff --git a/backend/src/routes/projects.routes.js b/backend/src/routes/projects.routes.js index 2d9d2e5..20945cf 100644 --- a/backend/src/routes/projects.routes.js +++ b/backend/src/routes/projects.routes.js @@ -8,9 +8,26 @@ const express = require('express'); const projectsController = require('../controllers/projects.controller'); const authenticateToken = require('../middleware/authenticateToken'); const { projectCreateLimiter, paletteWriteLimiter } = require('../middleware/projectCreateLimiter'); +const shareEvents = require('../services/shareEvents.service'); const router = express.Router(); +// Live share: after ANY successful mutation under /projects/:id/…, ping the +// SSE subscribers of that project's shared page (see shareEvents.service). +// One hook for every current and future mutating route, so a new endpoint can +// never forget to notify. The id is parsed from the path because Express +// resets req.params once the route layer unwinds (before 'finish' fires). +router.use((req, res, next) => { + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return next(); + const match = req.path.match(/^\/(\d+)(?:\/|$)/); + if (!match) return next(); + const projectId = Number(match[1]); + res.on('finish', () => { + if (res.statusCode < 300) shareEvents.notifyProjectChanged(projectId); + }); + next(); +}); + router.get('/', authenticateToken, projectsController.listProjects); // Global search (Ctrl+K): one term across project names, palette colors and // standards. Literal segment, kept clear of the '/:id' patterns below. diff --git a/backend/src/routes/share.routes.js b/backend/src/routes/share.routes.js index f436d78..c60f494 100644 --- a/backend/src/routes/share.routes.js +++ b/backend/src/routes/share.routes.js @@ -23,6 +23,7 @@ const shareViewLimiter = rateLimit({ // Specific paths first, then the bare token read. router.get('/:token/preview.png', shareViewLimiter, projectsController.getSharedProjectPreview); router.get('/:token/embed', shareViewLimiter, projectsController.getSharedProjectEmbed); +router.get('/:token/events', shareViewLimiter, projectsController.getSharedProjectEvents); router.get('/:token', shareViewLimiter, projectsController.getSharedProject); module.exports = router; diff --git a/backend/src/services/projectSharing.service.js b/backend/src/services/projectSharing.service.js index 5a91a83..2b1195b 100644 --- a/backend/src/services/projectSharing.service.js +++ b/backend/src/services/projectSharing.service.js @@ -109,9 +109,28 @@ const getSharedProjectByToken = async (rawToken) => { }; }; +// Resolves a share token to just the project id (for the live-events stream, +// which subscribes by project and never sends content itself). Same token +// validation and same 'not_found' contract as the full read. +const getSharedProjectIdByToken = async (rawToken) => { + const token = typeof rawToken === 'string' ? rawToken.trim() : ''; + if (!SHARE_TOKEN_PATTERN.test(token)) { + throw new ProjectServiceError('not_found'); + } + const [rows] = await db.query( + 'SELECT id FROM projects WHERE share_token = ? AND deleted_at IS NULL', + [token], + ); + if (rows.length === 0) { + throw new ProjectServiceError('not_found'); + } + return rows[0].id; +}; + module.exports = { fetchLiveProjectChildren, enableProjectSharing, disableProjectSharing, getSharedProjectByToken, + getSharedProjectIdByToken, }; diff --git a/backend/src/services/projects.service.js b/backend/src/services/projects.service.js index acc4e19..e90d9a9 100644 --- a/backend/src/services/projects.service.js +++ b/backend/src/services/projects.service.js @@ -37,6 +37,7 @@ const { enableProjectSharing, disableProjectSharing, getSharedProjectByToken, + getSharedProjectIdByToken, } = require('./projectSharing.service'); // Upper bound on palette size to cap per-request work and storage. @@ -1011,6 +1012,7 @@ module.exports = { enableProjectSharing, disableProjectSharing, getSharedProjectByToken, + getSharedProjectIdByToken, TRASH_RETENTION_DAYS, addBrushNormToProject, addTypographyNormToProject, diff --git a/backend/src/services/shareEvents.service.js b/backend/src/services/shareEvents.service.js new file mode 100644 index 0000000..afbf8c3 --- /dev/null +++ b/backend/src/services/shareEvents.service.js @@ -0,0 +1,115 @@ +/** + * Live-share event hub: an in-memory registry of SSE subscribers per project, + * so an open shared page can refetch the sheet the moment the owner edits it. + * + * Deliberately minimal: events carry NO data — subscribers just get a + * `changed` ping and re-read the public share endpoint, so the shared page + * has exactly one code path for content (and revocation naturally surfaces + * as the refetch 404ing). Single-process by design, which matches the one + * Railway instance; if the API ever scales out, this is the seam where a + * pub/sub backend (e.g. Redis) would slot in. + */ + +// projectId -> Set of open SSE responses. +const subscribersByProject = new Map(); + +// Caps runaway fan-out from one very popular link; beyond this, extra viewers +// simply don't get live updates (the page still works, just not live). +const MAX_SUBSCRIBERS_PER_PROJECT = 100; + +// Proxies (Railway's included) reap idle connections; a periodic comment line +// keeps them open. One shared timer for all subscribers, started lazily and +// stopped when nobody is listening (also lets Jest exit cleanly). +const HEARTBEAT_MS = 25000; +let heartbeatTimer = null; + +const totalSubscribers = () => { + let count = 0; + subscribersByProject.forEach((set) => { + count += set.size; + }); + return count; +}; + +const stopHeartbeatIfIdle = () => { + if (heartbeatTimer && totalSubscribers() === 0) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } +}; + +const startHeartbeat = () => { + if (heartbeatTimer) return; + heartbeatTimer = setInterval(() => { + subscribersByProject.forEach((set) => { + set.forEach((res) => { + try { + res.write(': ping\n\n'); + } catch { + /* the close handler removes broken subscribers */ + } + }); + }); + }, HEARTBEAT_MS); + // Never keep the process alive just for heartbeats. + if (heartbeatTimer.unref) heartbeatTimer.unref(); +}; + +/** + * Registers an open SSE response for a project. Returns false when the + * per-project cap is reached (caller ends the stream gracefully). + * The caller is responsible for calling unsubscribe on connection close. + */ +const subscribe = (projectId, res) => { + let set = subscribersByProject.get(projectId); + if (!set) { + set = new Set(); + subscribersByProject.set(projectId, set); + } + if (set.size >= MAX_SUBSCRIBERS_PER_PROJECT) return false; + set.add(res); + startHeartbeat(); + return true; +}; + +const unsubscribe = (projectId, res) => { + const set = subscribersByProject.get(projectId); + if (!set) return; + set.delete(res); + if (set.size === 0) subscribersByProject.delete(projectId); + stopHeartbeatIfIdle(); +}; + +/** + * Tells every open shared page of this project to refetch. Fire-and-forget: + * a broken pipe never breaks the mutation that triggered the notify. + */ +const notifyProjectChanged = (projectId) => { + const set = subscribersByProject.get(projectId); + if (!set) return; + set.forEach((res) => { + try { + res.write('event: changed\ndata: {}\n\n'); + } catch { + /* the close handler removes broken subscribers */ + } + }); +}; + +// Test hook: drops every subscriber and stops the heartbeat. +const resetForTests = () => { + subscribersByProject.clear(); + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } +}; + +module.exports = { + subscribe, + unsubscribe, + notifyProjectChanged, + resetForTests, + MAX_SUBSCRIBERS_PER_PROJECT, + HEARTBEAT_MS, +}; diff --git a/backend/tests/unit/projects.controller.test.js b/backend/tests/unit/projects.controller.test.js index fa32ff1..8ba3835 100644 --- a/backend/tests/unit/projects.controller.test.js +++ b/backend/tests/unit/projects.controller.test.js @@ -865,6 +865,10 @@ describe('projects controller', () => { describe('shared project social preview (public)', () => { const token = 'a'.repeat(32); + + afterEach(() => { + require('../../src/services/shareEvents.service').resetForTests(); + }); const mockSharedProjectQueries = () => { db.query .mockResolvedValueOnce([[{ id: 7, name: 'Neo-Tokyo', owner_name: 'Axelle' }]]) @@ -932,6 +936,53 @@ describe('projects controller', () => { expect(html).toContain('Neo <b>'); }); + it('events opens an SSE stream, subscribes the project and cleans up on close', async () => { + const shareEvents = require('../../src/services/shareEvents.service'); + db.query.mockResolvedValueOnce([[{ id: 7 }]]); + const closeHandlers = {}; + const req = { params: { token }, on: jest.fn((event, cb) => (closeHandlers[event] = cb)) }; + const res = { + set: jest.fn(), + flushHeaders: jest.fn(), + write: jest.fn(), + end: jest.fn(), + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + await projectsController.getSharedProjectEvents(req, res); + + expect(res.set).toHaveBeenCalledWith( + expect.objectContaining({ 'Content-Type': 'text/event-stream' }), + ); + expect(res.flushHeaders).toHaveBeenCalled(); + expect(res.write).toHaveBeenCalledWith('retry: 3000\n\n'); + + // The stream is genuinely registered: a notify reaches it… + shareEvents.notifyProjectChanged(7); + expect(res.write).toHaveBeenCalledWith('event: changed\ndata: {}\n\n'); + + // …and closing the request unsubscribes it. + closeHandlers.close(); + res.write.mockClear(); + shareEvents.notifyProjectChanged(7); + expect(res.write).not.toHaveBeenCalled(); + }); + + it('events returns 404 for an unknown token without opening a stream', async () => { + db.query.mockResolvedValueOnce([[]]); + const req = { params: { token }, on: jest.fn() }; + const res = { + set: jest.fn(), + flushHeaders: jest.fn(), + write: jest.fn(), + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + await projectsController.getSharedProjectEvents(req, res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.flushHeaders).not.toHaveBeenCalled(); + }); + it('embed returns 404 for an unknown token', async () => { db.query.mockResolvedValueOnce([[]]); const req = { params: { token } }; diff --git a/backend/tests/unit/projects.routes.test.js b/backend/tests/unit/projects.routes.test.js index 2dd5327..6184461 100644 --- a/backend/tests/unit/projects.routes.test.js +++ b/backend/tests/unit/projects.routes.test.js @@ -48,14 +48,21 @@ const buildTestApp = () => { next(); }); + const shareEventsMock = { notifyProjectChanged: jest.fn() }; + jest.doMock('../../src/services/shareEvents.service', () => shareEventsMock); + const projectsRoutes = require('../../src/routes/projects.routes'); const app = express(); app.use(express.json()); app.use('/projects', projectsRoutes); - return { app, controllerMocks }; + return { app, controllerMocks, shareEventsMock }; }; +// The 'finish' event fires as the response is flushed; one macrotask tick +// guarantees it ran before we assert. +const flushFinishHandlers = () => new Promise((resolve) => setImmediate(resolve)); + describe('projects routes', () => { afterEach(() => { jest.clearAllMocks(); @@ -150,4 +157,32 @@ describe('projects routes', () => { expect(controllerMocks.searchProjects).toHaveBeenCalledTimes(1); expect(controllerMocks.getProject).toHaveBeenCalledTimes(1); }); + + describe('live-share notify middleware', () => { + it('pings the project subscribers after any successful mutation under /:id', async () => { + const { app, shareEventsMock } = buildTestApp(); + + await request(app).delete('/projects/123'); + await flushFinishHandlers(); + + expect(shareEventsMock.notifyProjectChanged).toHaveBeenCalledWith(123); + }); + + it('stays silent for reads, failures and id-less routes', async () => { + const { app, controllerMocks, shareEventsMock } = buildTestApp(); + + // Read: no notify. + await request(app).get('/projects/123'); + // Failed mutation: no notify. + controllerMocks.deleteProject.mockImplementationOnce((req, res) => + res.status(404).json({ error: 'nope' }), + ); + await request(app).delete('/projects/123'); + // Mutation without a project id (creation): no notify. + await request(app).post('/projects').send({ name: 'x' }); + await flushFinishHandlers(); + + expect(shareEventsMock.notifyProjectChanged).not.toHaveBeenCalled(); + }); + }); }); diff --git a/backend/tests/unit/shareEvents.service.test.js b/backend/tests/unit/shareEvents.service.test.js new file mode 100644 index 0000000..93f6767 --- /dev/null +++ b/backend/tests/unit/shareEvents.service.test.js @@ -0,0 +1,76 @@ +/** + * The live-share event hub: subscription registry, SSE frame format, the + * per-project cap, and the keep-alive heartbeat. + */ +const shareEvents = require('../../src/services/shareEvents.service'); + +const makeRes = () => ({ write: jest.fn() }); + +describe('shareEvents service', () => { + afterEach(() => { + shareEvents.resetForTests(); + jest.useRealTimers(); + }); + + it('notifies every subscriber of the changed project with a proper SSE frame', () => { + const resA = makeRes(); + const resB = makeRes(); + const other = makeRes(); + shareEvents.subscribe(7, resA); + shareEvents.subscribe(7, resB); + shareEvents.subscribe(8, other); + + shareEvents.notifyProjectChanged(7); + + expect(resA.write).toHaveBeenCalledWith('event: changed\ndata: {}\n\n'); + expect(resB.write).toHaveBeenCalledWith('event: changed\ndata: {}\n\n'); + expect(other.write).not.toHaveBeenCalled(); + }); + + it('notifying a project with no subscribers is a no-op', () => { + expect(() => shareEvents.notifyProjectChanged(999)).not.toThrow(); + }); + + it('stops notifying after unsubscribe', () => { + const res = makeRes(); + shareEvents.subscribe(7, res); + shareEvents.unsubscribe(7, res); + + shareEvents.notifyProjectChanged(7); + + expect(res.write).not.toHaveBeenCalled(); + }); + + it('a broken subscriber never breaks the broadcast to the others', () => { + const broken = { + write: jest.fn(() => { + throw new Error('EPIPE'); + }), + }; + const healthy = makeRes(); + shareEvents.subscribe(7, broken); + shareEvents.subscribe(7, healthy); + + expect(() => shareEvents.notifyProjectChanged(7)).not.toThrow(); + expect(healthy.write).toHaveBeenCalledWith('event: changed\ndata: {}\n\n'); + }); + + it('refuses subscribers beyond the per-project cap', () => { + for (let i = 0; i < shareEvents.MAX_SUBSCRIBERS_PER_PROJECT; i += 1) { + expect(shareEvents.subscribe(7, makeRes())).toBe(true); + } + expect(shareEvents.subscribe(7, makeRes())).toBe(false); + // Another project is unaffected by the full one. + expect(shareEvents.subscribe(8, makeRes())).toBe(true); + }); + + it('sends a heartbeat comment to keep proxies from reaping idle streams', () => { + jest.useFakeTimers(); + const res = makeRes(); + shareEvents.subscribe(7, res); + + jest.advanceTimersByTime(shareEvents.HEARTBEAT_MS); + + expect(res.write).toHaveBeenCalledWith(': ping\n\n'); + }); +}); diff --git a/e2e/tests/critical-path.spec.js b/e2e/tests/critical-path.spec.js index ec3298c..2860afa 100644 --- a/e2e/tests/critical-path.spec.js +++ b/e2e/tests/critical-path.spec.js @@ -126,6 +126,35 @@ test.describe('critical path: register, verify, project, share, export', () => { await guestContext.close(); }); + test('the shared page updates live while the owner edits', async ({ browser }) => { + const shareUrl = await page.getByTestId('share-url').innerText(); + + // A guest keeps the shared page open… + const guestContext = await browser.newContext(); + const guestPage = await guestContext.newPage(); + await guestPage.goto(shareUrl); + await expect(guestPage.getByText(colorName)).toBeVisible(); + // …and the SSE stream is connected (the Live badge is up). + await expect(guestPage.getByText('Live', { exact: true })).toBeVisible(); + + // Meanwhile the owner adds a color from their own session. + await page.getByRole('link', { name: 'Palette' }).click(); + await page.getByRole('button', { name: 'New color' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByLabel('Color usage').fill('Live Lime'); + await dialog.getByLabel('Color', { exact: true }).fill('#32CD32'); + await dialog.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText('Live Lime')).toBeVisible(); + + // The guest page catches it without any reload. + await expect(guestPage.getByText('Live Lime')).toBeVisible({ timeout: 10_000 }); + await guestContext.close(); + + // Back to the export page for the following tests. + await page.getByRole('link', { name: 'Export' }).click(); + await expect(page).toHaveURL(/\/export$/); + }); + test('the share link serves a real social-preview image and crawler tags', async ({ request, }) => { diff --git a/frontend/src/hooks/useShareLiveUpdates.js b/frontend/src/hooks/useShareLiveUpdates.js new file mode 100644 index 0000000..e64b2e6 --- /dev/null +++ b/frontend/src/hooks/useShareLiveUpdates.js @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from 'react'; + +// Mirrors the api service's base so the stream rides the same /api proxy. +const API_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/$/, ''); + +/** + * Live updates for a shared page: subscribes to the share's SSE stream and + * calls `onChanged` whenever the owner edits the project, so the caller can + * silently refetch. Returns whether the stream is currently connected (the + * "Live" badge). EventSource reconnects by itself after a drop; a reconnect + * also triggers `onChanged` once, so edits made during the gap are never + * missed. No-ops when the browser lacks EventSource or `enabled` is false. + */ +export default function useShareLiveUpdates(token, { enabled = true, onChanged } = {}) { + const [isLive, setIsLive] = useState(false); + // Ref'd so a new callback identity never tears the connection down. + const onChangedRef = useRef(onChanged); + onChangedRef.current = onChanged; + + useEffect(() => { + if (!enabled || !token || typeof EventSource === 'undefined') return undefined; + + const source = new EventSource(`${API_BASE}/share/${token}/events`); + let wasConnected = false; + + source.onopen = () => { + // Catch up after a reconnect: anything could have changed while offline. + if (wasConnected) onChangedRef.current?.(); + wasConnected = true; + setIsLive(true); + }; + // Auto-reconnect is built into EventSource; just reflect the state. + source.onerror = () => setIsLive(false); + source.addEventListener('changed', () => onChangedRef.current?.()); + // The server ends the stream when the project's viewer cap is reached: + // stop reconnect-hammering it, the page simply isn't live. + source.addEventListener('full', () => { + source.close(); + setIsLive(false); + }); + + return () => { + source.close(); + setIsLive(false); + }; + }, [token, enabled]); + + return isLive; +} diff --git a/frontend/src/pages/SharedProject.jsx b/frontend/src/pages/SharedProject.jsx index 4b0a6dc..4fdbbbf 100644 --- a/frontend/src/pages/SharedProject.jsx +++ b/frontend/src/pages/SharedProject.jsx @@ -7,9 +7,10 @@ // authenticated ProjectPalette and ProjectNorms pages (same shapes, radii, // badges and typography), so a shared link and the in-app editor never look // like two different products. -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import api from '../services/api'; +import useShareLiveUpdates from '../hooks/useShareLiveUpdates'; import Card from '../components/Card'; import Seo from '../components/Seo'; import PublicTopBar from '../components/PublicTopBar'; @@ -30,26 +31,34 @@ export default function SharedProject() { const [status, setStatus] = useState('loading'); // 'loading' | 'ready' | 'not-found' | 'error' const { copy, copiedValue } = useClipboard({ timeout: 1200 }); + // One fetch path for the initial load AND the live refreshes: a silent + // refetch swaps the sheet in place (no loading flash), and a 404 flips the + // page to "link inactive" — which is exactly what a live revocation does. + const loadSheet = useCallback( + (options = {}) => { + if (!options.silent) setStatus('loading'); + return api + .get(`/share/${token}`, { skipTokenRefresh: true }) + .then((data) => { + setSheet(data); + setStatus('ready'); + }) + .catch((error) => { + setStatus(error?.status === 404 ? 'not-found' : 'error'); + }); + }, + [token], + ); + useEffect(() => { - let cancelled = false; - setStatus('loading'); - - api - .get(`/share/${token}`, { skipTokenRefresh: true }) - .then((data) => { - if (cancelled) return; - setSheet(data); - setStatus('ready'); - }) - .catch((error) => { - if (cancelled) return; - setStatus(error?.status === 404 ? 'not-found' : 'error'); - }); - - return () => { - cancelled = true; - }; - }, [token]); + loadSheet(); + }, [loadSheet]); + + // Server-sent events: the owner edits, this page refetches — live. + const isLive = useShareLiveUpdates(token, { + enabled: status === 'ready', + onChanged: () => loadSheet({ silent: true }), + }); const palette = sheet?.palette || []; const typographyNorms = sheet?.typographyNorms || []; @@ -103,9 +112,23 @@ export default function SharedProject() { {status === 'ready' && sheet && (
-

- Shared reference sheet -

+
+

+ Shared reference sheet +

+ {isLive && ( + + + )} +

{sheet.name}

diff --git a/frontend/tests/integration/SharedProject.test.jsx b/frontend/tests/integration/SharedProject.test.jsx index 6f37682..bf06383 100644 --- a/frontend/tests/integration/SharedProject.test.jsx +++ b/frontend/tests/integration/SharedProject.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, act } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; import SharedProject from '../../src/pages/SharedProject'; @@ -75,4 +75,89 @@ describe('SharedProject (public page)', () => { expect(await screen.findByText(/this link is no longer active/i)).toBeInTheDocument(); expect(screen.getByRole('link', { name: /discover frameset/i })).toBeInTheDocument(); }); + + describe('live updates (SSE)', () => { + // Minimal EventSource stand-in: captures the connected URL and lets the + // test drive open/changed events by hand. + let sources; + class FakeEventSource { + constructor(url) { + this.url = url; + this.listeners = {}; + this.closed = false; + sources.push(this); + } + + addEventListener(type, cb) { + this.listeners[type] = cb; + } + + close() { + this.closed = true; + } + + emitOpen() { + this.onopen?.(); + } + + emit(type) { + this.listeners[type]?.(); + } + } + + beforeEach(() => { + sources = []; + vi.stubGlobal('EventSource', FakeEventSource); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const sheet = (overrides = {}) => ({ + name: 'Neo-Tokyo Editorial', + ownerName: 'Axelle', + palette: [{ id: 1, name: 'Ink', hex: '#112233' }], + typographyNorms: [], + brushNorms: [], + ...overrides, + }); + + it('subscribes once loaded, shows the Live badge and applies edits in place', async () => { + apiMock.get.mockResolvedValueOnce(sheet()); + renderPage(); + expect(await screen.findByText('Ink')).toBeInTheDocument(); + + // The stream targets the share events endpoint for this token. + expect(sources).toHaveLength(1); + expect(sources[0].url).toContain(`/share/${'a'.repeat(32)}/events`); + + await act(async () => sources[0].emitOpen()); + expect(screen.getByText('Live')).toBeInTheDocument(); + + // Owner edits: a changed ping makes the page refetch and swap the sheet + // in place — no loading state in between. + apiMock.get.mockResolvedValueOnce( + sheet({ palette: [{ id: 1, name: 'Ink Renamed', hex: '#112233' }] }), + ); + await act(async () => sources[0].emit('changed')); + + expect(await screen.findByText('Ink Renamed')).toBeInTheDocument(); + expect(screen.queryByText('Ink')).not.toBeInTheDocument(); + expect(apiMock.get).toHaveBeenCalledTimes(2); + }); + + it('a live revocation flips the page to the inactive state', async () => { + apiMock.get.mockResolvedValueOnce(sheet()); + renderPage(); + expect(await screen.findByText('Ink')).toBeInTheDocument(); + + const notFound = new Error('gone'); + notFound.status = 404; + apiMock.get.mockRejectedValueOnce(notFound); + await act(async () => sources[0].emit('changed')); + + expect(await screen.findByText(/this link is no longer active/i)).toBeInTheDocument(); + }); + }); }); From 35e46e15fbb69cae3e247385a34b21c1a0c858cd Mon Sep 17 00:00:00 2001 From: AxelleDev <139721736+AxelleDev@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:08:04 +0200 Subject: [PATCH 2/2] mentioned the live share behavior on the export page --- frontend/src/pages/ProjectExport.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/ProjectExport.jsx b/frontend/src/pages/ProjectExport.jsx index ba761b9..6df22c9 100644 --- a/frontend/src/pages/ProjectExport.jsx +++ b/frontend/src/pages/ProjectExport.jsx @@ -488,8 +488,8 @@ export default function ProjectExport() {

Public share link

A read-only web page of this reference sheet — palette, typography and brush - standards. Anyone with the link can view it, no account needed. Disable it anytime - to revoke access. + standards. Anyone with the link can view it, no account needed, and the page updates + live as you edit the project. Disable it anytime to revoke access.

{shareUrl ? (