diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 619baf3..432c8c9 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -2,6 +2,7 @@ import { notFound, redirect } from "next/navigation"; import { connection } from "next/server"; import { EditorWorkspace } from "@/components/admin/editor-workspace"; +import { LiveRefresh } from "@/components/live-refresh"; import { getAdminSession } from "@/lib/auth/server"; import { loadAdminNavigation, loadAdminPage } from "@/lib/content/loaders"; @@ -18,5 +19,16 @@ export default async function AdminEditorPage({ loadAdminNavigation(), ]); if (!snapshot) notFound(); - return ; + return ( + <> + {process.env.BUZZ_DEMO_MODE === "true" ? null : ( + + )} + + + ); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 632f083..7829f23 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -4,6 +4,7 @@ import { redirect } from "next/navigation"; import { connection } from "next/server"; import { AdminSidebar } from "@/components/admin/admin-sidebar"; +import { LiveRefresh } from "@/components/live-refresh"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -25,6 +26,9 @@ export default async function AdminPage() { return ( + {process.env.BUZZ_DEMO_MODE === "true" ? null : ( + + )}
diff --git a/app/admin/templates/page.tsx b/app/admin/templates/page.tsx index ea5f381..a093700 100644 --- a/app/admin/templates/page.tsx +++ b/app/admin/templates/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { connection } from "next/server"; import { AdminSidebar } from "@/components/admin/admin-sidebar"; +import { LiveRefresh } from "@/components/live-refresh"; import { TemplateGallery } from "@/components/admin/template-gallery"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { getAdminSession } from "@/lib/auth/server"; @@ -15,6 +16,9 @@ export default async function TemplatesPage() { return ( + {process.env.BUZZ_DEMO_MODE === "true" ? null : ( + + )}
diff --git a/app/api/events/admin/route.ts b/app/api/events/admin/route.ts new file mode 100644 index 0000000..df99276 --- /dev/null +++ b/app/api/events/admin/route.ts @@ -0,0 +1,24 @@ +import { + AdminAuthorizationError, + requireAdmin, +} from "@/lib/auth/server"; +import { + createRedisEventResponse, + eventsUnavailableResponse, +} from "@/lib/events/redis-stream"; + +export async function GET(request: Request) { + try { + await requireAdmin(); + } catch (cause) { + if (cause instanceof AdminAuthorizationError) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + throw cause; + } + try { + return await createRedisEventResponse("admin", request.signal); + } catch { + return eventsUnavailableResponse(); + } +} diff --git a/app/api/events/directory/route.ts b/app/api/events/directory/route.ts new file mode 100644 index 0000000..9298c59 --- /dev/null +++ b/app/api/events/directory/route.ts @@ -0,0 +1,12 @@ +import { + createRedisEventResponse, + eventsUnavailableResponse, +} from "@/lib/events/redis-stream"; + +export async function GET(request: Request) { + try { + return await createRedisEventResponse("directory", request.signal); + } catch { + return eventsUnavailableResponse(); + } +} diff --git a/app/api/events/page/[id]/route.ts b/app/api/events/page/[id]/route.ts new file mode 100644 index 0000000..fed7121 --- /dev/null +++ b/app/api/events/page/[id]/route.ts @@ -0,0 +1,22 @@ +import { + createRedisEventResponse, + eventsUnavailableResponse, +} from "@/lib/events/redis-stream"; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + if (!UUID_PATTERN.test(id)) { + return Response.json({ error: "invalid-page-id" }, { status: 400 }); + } + try { + return await createRedisEventResponse(`page:${id}`, request.signal); + } catch { + return eventsUnavailableResponse(); + } +} diff --git a/app/page.tsx b/app/page.tsx index 8e99031..86662ce 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,7 @@ import { connection } from "next/server"; import { CharacterDirectory } from "@/components/public/character-directory"; import { SiteHeader } from "@/components/public/site-header"; +import { LiveRefresh } from "@/components/live-refresh"; import { loadPublicDirectory } from "@/lib/content/loaders"; export default async function HomePage() { @@ -10,6 +11,9 @@ export default async function HomePage() { return (
+ {process.env.BUZZ_DEMO_MODE === "true" ? null : ( + + )}
diff --git a/components/live-refresh.tsx b/components/live-refresh.tsx new file mode 100644 index 0000000..350561c --- /dev/null +++ b/components/live-refresh.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +import { parseInvalidationEvent } from "@/lib/events/invalidation"; +import { createRefreshScheduler } from "@/lib/events/refresh"; + +export function LiveRefresh({ endpoint }: { endpoint: string }) { + const router = useRouter(); + + useEffect(() => { + const scheduler = createRefreshScheduler(() => router.refresh()); + const source = new EventSource(endpoint); + const connected = () => scheduler.connected(); + const notified = (message: Event) => { + if (!(message instanceof MessageEvent) || typeof message.data !== "string") { + return; + } + const event = parseInvalidationEvent(message.data); + if (event) scheduler.notified(event); + }; + source.addEventListener("open", connected); + source.addEventListener("invalidation", notified); + return () => { + source.removeEventListener("open", connected); + source.removeEventListener("invalidation", notified); + source.close(); + scheduler.dispose(); + }; + }, [endpoint, router]); + + return null; +} diff --git a/components/public/guide-shell.tsx b/components/public/guide-shell.tsx index f0f662e..f74279a 100644 --- a/components/public/guide-shell.tsx +++ b/components/public/guide-shell.tsx @@ -7,6 +7,7 @@ import { } from "@/components/public/block-renderer"; import { SectionDrawer } from "@/components/public/section-drawer"; import { SiteHeader } from "@/components/public/site-header"; +import { LiveRefresh } from "@/components/live-refresh"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; @@ -18,6 +19,9 @@ export function GuideShell({ snapshot }: { snapshot: PageSnapshot }) { return (
+ {process.env.BUZZ_DEMO_MODE === "true" ? null : ( + + )}
diff --git a/lib/events/invalidation.test.ts b/lib/events/invalidation.test.ts new file mode 100644 index 0000000..da05cea --- /dev/null +++ b/lib/events/invalidation.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { + parseInvalidationEvent, + toInvalidationEvent, +} from "./invalidation"; + +describe("invalidation event envelopes", () => { + it("strips every field except type, opaque id, and version", () => { + const event = toInvalidationEvent({ + topic: "directory", + aggregateId: "4afeea7b-6f24-43f3-b747-2db98335e01e", + eventType: "directory.updated", + payload: { + id: "4afeea7b-6f24-43f3-b747-2db98335e01e", + version: 3, + title: "private draft title", + blocks: [{ content: "private" }], + }, + }); + expect(event).toEqual({ + type: "directory.updated", + id: "4afeea7b-6f24-43f3-b747-2db98335e01e", + version: 3, + }); + }); + + it("rejects unknown, malformed, and topic-mismatched messages", () => { + expect(parseInvalidationEvent("not json")).toBeNull(); + expect( + parseInvalidationEvent( + JSON.stringify({ type: "page.content", id: "id", version: 1 }), + ), + ).toBeNull(); + expect( + toInvalidationEvent({ + topic: "admin", + aggregateId: "id", + eventType: "page.updated", + payload: { id: "id", version: 1 }, + }), + ).toBeNull(); + }); +}); diff --git a/lib/events/redis-stream.ts b/lib/events/redis-stream.ts new file mode 100644 index 0000000..0ad0788 --- /dev/null +++ b/lib/events/redis-stream.ts @@ -0,0 +1,58 @@ +import { + expectedTopic, + parseInvalidationEvent, +} from "@/lib/events/invalidation"; +import { + createEventStream, + eventStreamHeaders, + type EventStreamChannel, +} from "@/lib/events/sse"; +import { + connectRedisClient, + createRedisClient, + redisEventChannel, +} from "@/lib/redis/client"; + +export async function createRedisEventResponse( + topic: string, + signal: AbortSignal, +): Promise { + const redis = createRedisClient(`buzz-sheet-sse:${topic}`); + const channel = redisEventChannel(topic); + try { + await connectRedisClient(redis); + if (signal.aborted) throw new Error("Request closed before subscription."); + await redis.subscribe(channel); + } catch (cause) { + redis.disconnect(); + throw cause; + } + + const onMessage = (receivedChannel: string, value: string) => { + if (receivedChannel !== channel) return; + const event = parseInvalidationEvent(value); + if (event && expectedTopic(event) === topic) eventStream.send(event); + }; + const eventStream: EventStreamChannel = createEventStream({ + signal, + onClose: () => { + redis.off("message", onMessage); + redis.disconnect(); + }, + }); + redis.on("message", onMessage); + + return new Response(eventStream.stream, { + headers: eventStreamHeaders(), + }); +} + +export function eventsUnavailableResponse(): Response { + return Response.json( + { error: "events-unavailable" }, + { + status: 503, + headers: { "Cache-Control": "no-store" }, + }, + ); +} diff --git a/lib/events/refresh.test.ts b/lib/events/refresh.test.ts new file mode 100644 index 0000000..fbc3540 --- /dev/null +++ b/lib/events/refresh.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createRefreshScheduler } from "./refresh"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("authoritative refresh scheduler", () => { + it("refetches on connection and coalesces duplicate notifications", async () => { + vi.useFakeTimers(); + const refresh = vi.fn(); + const scheduler = createRefreshScheduler(refresh, { delayMs: 25 }); + const event = { type: "page.updated" as const, id: "page", version: 5 }; + + scheduler.connected(); + await vi.advanceTimersByTimeAsync(25); + expect(refresh).toHaveBeenCalledTimes(1); + + scheduler.notified(event); + scheduler.notified(event); + await vi.advanceTimersByTimeAsync(25); + expect(refresh).toHaveBeenCalledTimes(2); + + scheduler.connected(); + await vi.advanceTimersByTimeAsync(25); + expect(refresh).toHaveBeenCalledTimes(3); + scheduler.dispose(); + }); +}); diff --git a/lib/events/refresh.ts b/lib/events/refresh.ts new file mode 100644 index 0000000..19a5f0c --- /dev/null +++ b/lib/events/refresh.ts @@ -0,0 +1,58 @@ +import type { InvalidationEvent } from "./invalidation"; + +export interface RefreshScheduler { + connected(): void; + notified(event: InvalidationEvent): void; + dispose(): void; +} + +export interface RefreshSchedulerOptions { + delayMs?: number; + duplicateWindowMs?: number; + now?: () => number; +} + +export function createRefreshScheduler( + refresh: () => void, + options: RefreshSchedulerOptions = {}, +): RefreshScheduler { + const delayMs = options.delayMs ?? 75; + const duplicateWindowMs = options.duplicateWindowMs ?? 2_000; + const now = options.now ?? Date.now; + let pending: ReturnType | undefined; + let lastEventKey: string | undefined; + let lastEventAt = 0; + + const schedule = (event?: InvalidationEvent) => { + if (event) { + const key = `${event.type}:${event.id}:${event.version}`; + const timestamp = now(); + if ( + key === lastEventKey && + timestamp - lastEventAt < duplicateWindowMs + ) { + return; + } + lastEventKey = key; + lastEventAt = timestamp; + } + if (pending) return; + pending = setTimeout(() => { + pending = undefined; + refresh(); + }, delayMs); + }; + + return { + connected() { + schedule(); + }, + notified(event) { + schedule(event); + }, + dispose() { + if (pending) clearTimeout(pending); + pending = undefined; + }, + }; +} diff --git a/lib/events/sse.test.ts b/lib/events/sse.test.ts new file mode 100644 index 0000000..6a4099b --- /dev/null +++ b/lib/events/sse.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createEventStream, + eventStreamHeaders, + SSE_HEARTBEAT_MS, + SSE_MAX_LIFETIME_MS, +} from "./sse"; + +const decoder = new TextDecoder(); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("SSE stream", () => { + it("uses the required heartbeat, reconnect lifetime, and proxy headers", () => { + expect(SSE_HEARTBEAT_MS).toBe(90_000); + expect(SSE_MAX_LIFETIME_MS).toBe(30 * 60 * 1_000); + const headers = eventStreamHeaders(); + expect(headers.get("content-type")).toContain("text/event-stream"); + expect(headers.get("cache-control")).toBe("no-cache, no-transform"); + expect(headers.get("x-accel-buffering")).toBe("no"); + }); + + it("heartbeats, sends only the supplied envelope, and closes on schedule", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + const channel = createEventStream({ + heartbeatMs: 50, + maximumLifetimeMs: 99, + onClose, + }); + const reader = channel.stream.getReader(); + + expect(decoder.decode((await reader.read()).value)).toBe(": connected\n\n"); + channel.send({ type: "page.updated", id: "opaque", version: 7 }); + expect(decoder.decode((await reader.read()).value)).toBe( + 'event: invalidation\ndata: {"type":"page.updated","id":"opaque","version":7}\n\n', + ); + await vi.advanceTimersByTimeAsync(50); + expect(decoder.decode((await reader.read()).value)).toMatch(/^: heartbeat /u); + await vi.advanceTimersByTimeAsync(49); + expect((await reader.read()).done).toBe(true); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/events/sse.ts b/lib/events/sse.ts new file mode 100644 index 0000000..b23491c --- /dev/null +++ b/lib/events/sse.ts @@ -0,0 +1,94 @@ +import type { InvalidationEvent } from "./invalidation"; + +export const SSE_HEARTBEAT_MS = 90_000; +export const SSE_MAX_LIFETIME_MS = 30 * 60 * 1_000; + +const encoder = new TextEncoder(); + +export interface EventStreamOptions { + signal?: AbortSignal; + heartbeatMs?: number; + maximumLifetimeMs?: number; + onClose?: () => void; +} + +export interface EventStreamChannel { + stream: ReadableStream; + send(event: InvalidationEvent): boolean; + close(): void; +} + +export function eventStreamHeaders(): Headers { + return new Headers({ + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "Content-Type": "text/event-stream; charset=utf-8", + "X-Accel-Buffering": "no", + }); +} + +export function createEventStream( + options: EventStreamOptions = {}, +): EventStreamChannel { + const heartbeatMs = options.heartbeatMs ?? SSE_HEARTBEAT_MS; + const maximumLifetimeMs = + options.maximumLifetimeMs ?? SSE_MAX_LIFETIME_MS; + let controller: ReadableStreamDefaultController | undefined; + let heartbeat: ReturnType | undefined; + let lifetime: ReturnType | undefined; + let closed = false; + + const enqueue = (value: string): boolean => { + if (closed || !controller) return false; + try { + controller.enqueue(encoder.encode(value)); + return true; + } catch { + close(false); + return false; + } + }; + + const abort = () => close(true); + const close = (closeController: boolean) => { + if (closed) return; + closed = true; + if (heartbeat) clearInterval(heartbeat); + if (lifetime) clearTimeout(lifetime); + options.signal?.removeEventListener("abort", abort); + if (closeController) { + try { + controller?.close(); + } catch { + // The browser may have cancelled the stream before cleanup ran. + } + } + options.onClose?.(); + }; + + const stream = new ReadableStream({ + start(streamController) { + controller = streamController; + enqueue(": connected\n\n"); + heartbeat = setInterval(() => { + enqueue(`: heartbeat ${Date.now()}\n\n`); + }, heartbeatMs); + lifetime = setTimeout(() => close(true), maximumLifetimeMs); + if (options.signal?.aborted) close(true); + else options.signal?.addEventListener("abort", abort, { once: true }); + }, + cancel() { + close(false); + }, + }); + + return { + stream, + send(event) { + return enqueue(`event: invalidation\ndata: ${JSON.stringify(event)}\n\n`); + }, + close() { + close(true); + }, + }; +} diff --git a/tests/security-boundaries.test.ts b/tests/security-boundaries.test.ts index 6ec89df..916bd05 100644 --- a/tests/security-boundaries.test.ts +++ b/tests/security-boundaries.test.ts @@ -5,6 +5,7 @@ const protectedEntryPoints = [ "app/api/admin/media/presign/route.ts", "app/api/admin/media/[id]/complete/route.ts", "app/api/admin/media/[id]/route.ts", + "app/api/events/admin/route.ts", ] as const; describe("administrative security boundaries", () => {