From be095ab236dc9798635795856aa92d22b4f9447f Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 29 Aug 2026 07:13:04 +0000 Subject: [PATCH] feat(deploy): notify clients of new releases --- Plan.md | 2 +- README.md | 4 ++ app/api/active/route.ts | 16 +++++++ app/layout.tsx | 5 ++ components/deployment-update-notifier.tsx | 57 +++++++++++++++++++++++ lib/deployment/version.test.ts | 20 ++++++++ lib/deployment/version.ts | 15 ++++++ lib/events/sse.test.ts | 12 +++++ lib/events/sse.ts | 18 +++++++ tests/deployment-contract.test.ts | 14 ++++++ 10 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 app/api/active/route.ts create mode 100644 components/deployment-update-notifier.tsx create mode 100644 lib/deployment/version.test.ts create mode 100644 lib/deployment/version.ts diff --git a/Plan.md b/Plan.md index 5dc860d..5c4d2a4 100644 --- a/Plan.md +++ b/Plan.md @@ -156,7 +156,7 @@ flowchart LR - PDB with minimum one available replica - Least-privilege CI deployer RBAC - Use the existing external PostgreSQL, Redis, and S3-compatible services through Kubernetes secrets; do not provision an in-cluster database or Redis instance. -- Share a stable `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` across replicas and set a deployment ID from the immutable image revision to protect Server Actions and client navigation during rolling updates. +- Share a stable `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` across replicas and set a deployment ID from the immutable image revision to protect Server Actions and client navigation during rolling updates. Notify active clients when that revision changes and offer a full reload into the new release. - Build immutable application and migration images in Gitea Actions, defaulting to `registry.neko-piranha.ts.net/astral/buzz-sheet`. - Run a versioned Drizzle migration Job before rollout. Use expand/contract migrations so the previous application image remains compatible during rolling deployment. - Produce deployment manifests and CI configuration but do not contact or mutate the production cluster during implementation. diff --git a/README.md b/README.md index d10a6fc..64f9ab8 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,10 @@ NEXT_SERVER_ACTIONS_ENCRYPTION_KEY across replicas and rolling deployments. Do not place secret values in the ConfigMap or commit them to this repository. +Every page also subscribes to `/api/active`. When a serving pod reports a newer +deployment ID than the browser's current build, a persistent Thai notification +offers a full reload into the new release. + The base manifests contain deliberate `replace-me` image and deployment values. For initial bootstrap, the platform operator applies `k8s/base` through an environment overlay that supplies an existing immutable image revision. The diff --git a/app/api/active/route.ts b/app/api/active/route.ts new file mode 100644 index 0000000..c42b410 --- /dev/null +++ b/app/api/active/route.ts @@ -0,0 +1,16 @@ +import { connection } from "next/server"; + +import { normalizeDeploymentId } from "@/lib/deployment/version"; +import { createEventStream, eventStreamHeaders } from "@/lib/events/sse"; + +export async function GET(request: Request) { + await connection(); + const deploymentId = + normalizeDeploymentId(process.env.NEXT_DEPLOYMENT_ID) ?? "development"; + const channel = createEventStream({ signal: request.signal }); + channel.sendNamed("deployment", deploymentId); + + return new Response(channel.stream, { + headers: eventStreamHeaders(), + }); +} diff --git a/app/layout.tsx b/app/layout.tsx index 835ce4f..8fa794c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Anuphan, Geist_Mono } from "next/font/google"; +import { DeploymentUpdateNotifier } from "@/components/deployment-update-notifier"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -26,6 +27,9 @@ export const metadata: Metadata = { }; export default function RootLayout({ children }: LayoutProps<"/">) { + const deploymentId = + process.env.NEXT_DEPLOYMENT_ID?.trim() || "development"; + return ( ) { {children} + diff --git a/components/deployment-update-notifier.tsx b/components/deployment-update-notifier.tsx new file mode 100644 index 0000000..1584ab2 --- /dev/null +++ b/components/deployment-update-notifier.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; + +import { + hasDeploymentChanged, + normalizeDeploymentId, +} from "@/lib/deployment/version"; + +const UPDATE_TOAST_ID = "deployment-update"; + +export function DeploymentUpdateNotifier({ + deploymentId, +}: { + deploymentId: string; +}) { + const currentDeployment = useRef(normalizeDeploymentId(deploymentId)); + const updateAvailable = useRef(false); + + useEffect(() => { + const source = new EventSource("/api/active"); + const receiveDeployment = (event: Event) => { + if ( + updateAvailable.current || + !(event instanceof MessageEvent) || + typeof event.data !== "string" + ) { + return; + } + + const nextDeployment = normalizeDeploymentId(event.data); + if (!hasDeploymentChanged(currentDeployment.current, nextDeployment)) { + return; + } + + updateAvailable.current = true; + toast("มีอัปเดตใหม่พร้อมใช้งาน", { + id: UPDATE_TOAST_ID, + description: "รีโหลดเพื่ออัปเดตเป็นเวอร์ชันล่าสุด", + action: { + label: "รีโหลด", + onClick: () => window.location.reload(), + }, + duration: Infinity, + }); + }; + + source.addEventListener("deployment", receiveDeployment); + return () => { + source.removeEventListener("deployment", receiveDeployment); + source.close(); + }; + }, []); + + return null; +} diff --git a/lib/deployment/version.test.ts b/lib/deployment/version.test.ts new file mode 100644 index 0000000..0445b1c --- /dev/null +++ b/lib/deployment/version.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { + hasDeploymentChanged, + normalizeDeploymentId, +} from "./version"; + +describe("deployment version comparison", () => { + it("normalizes identifiers before comparing them", () => { + expect(normalizeDeploymentId(" release-2 ")).toBe("release-2"); + expect(normalizeDeploymentId(" ")).toBeNull(); + expect(hasDeploymentChanged("release-1", " release-2 ")).toBe(true); + }); + + it("does not announce the initial or unchanged deployment", () => { + expect(hasDeploymentChanged(null, "release-2")).toBe(false); + expect(hasDeploymentChanged("release-2", "release-2")).toBe(false); + expect(hasDeploymentChanged("release-2", "")).toBe(false); + }); +}); diff --git a/lib/deployment/version.ts b/lib/deployment/version.ts new file mode 100644 index 0000000..b2e4367 --- /dev/null +++ b/lib/deployment/version.ts @@ -0,0 +1,15 @@ +export function normalizeDeploymentId( + value: string | null | undefined, +): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +export function hasDeploymentChanged( + current: string | null | undefined, + next: string | null | undefined, +): boolean { + const currentId = normalizeDeploymentId(current); + const nextId = normalizeDeploymentId(next); + return currentId !== null && nextId !== null && currentId !== nextId; +} diff --git a/lib/events/sse.test.ts b/lib/events/sse.test.ts index 6a4099b..c39ed27 100644 --- a/lib/events/sse.test.ts +++ b/lib/events/sse.test.ts @@ -38,10 +38,22 @@ describe("SSE stream", () => { expect(decoder.decode((await reader.read()).value)).toBe( 'event: invalidation\ndata: {"type":"page.updated","id":"opaque","version":7}\n\n', ); + channel.sendNamed("deployment", "release-2"); + expect(decoder.decode((await reader.read()).value)).toBe( + "event: deployment\ndata: release-2\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); }); + + it("rejects event names that could inject another SSE field", () => { + const channel = createEventStream(); + expect(() => channel.sendNamed("deployment\ndata", "release-2")).toThrow( + "SSE event names", + ); + channel.close(); + }); }); diff --git a/lib/events/sse.ts b/lib/events/sse.ts index b23491c..bc6ed61 100644 --- a/lib/events/sse.ts +++ b/lib/events/sse.ts @@ -5,6 +5,20 @@ export const SSE_MAX_LIFETIME_MS = 30 * 60 * 1_000; const encoder = new TextEncoder(); +function namedEvent(eventName: string, data: string): string { + if (!/^[a-z0-9_-]+$/iu.test(eventName)) { + throw new Error( + "SSE event names may contain only letters, numbers, underscores, and hyphens.", + ); + } + const lines = data + .replace(/\r\n?/gu, "\n") + .split("\n") + .map((line) => `data: ${line}`) + .join("\n"); + return `event: ${eventName}\n${lines}\n\n`; +} + export interface EventStreamOptions { signal?: AbortSignal; heartbeatMs?: number; @@ -15,6 +29,7 @@ export interface EventStreamOptions { export interface EventStreamChannel { stream: ReadableStream; send(event: InvalidationEvent): boolean; + sendNamed(eventName: string, data: string): boolean; close(): void; } @@ -87,6 +102,9 @@ export function createEventStream( send(event) { return enqueue(`event: invalidation\ndata: ${JSON.stringify(event)}\n\n`); }, + sendNamed(eventName, data) { + return enqueue(namedEvent(eventName, data)); + }, close() { close(true); }, diff --git a/tests/deployment-contract.test.ts b/tests/deployment-contract.test.ts index 780aba7..362a337 100644 --- a/tests/deployment-contract.test.ts +++ b/tests/deployment-contract.test.ts @@ -110,6 +110,20 @@ describe("production deployment contract", () => { ); expect(workflow).not.toMatch(/playwright|chromium/iu); }); + + it("mounts the deployment stream and offers a full reload for new releases", async () => { + const [layout, notifier, route] = await Promise.all([ + repositoryFile("app/layout.tsx"), + repositoryFile("components/deployment-update-notifier.tsx"), + repositoryFile("app/api/active/route.ts"), + ]); + + expect(layout).toContain(" {