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(" {