feat(deploy): notify clients of new releases
This commit is contained in:
@@ -156,7 +156,7 @@ flowchart LR
|
|||||||
- PDB with minimum one available replica
|
- PDB with minimum one available replica
|
||||||
- Least-privilege CI deployer RBAC
|
- 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.
|
- 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`.
|
- 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.
|
- 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.
|
- Produce deployment manifests and CI configuration but do not contact or mutate the production cluster during implementation.
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
|
|||||||
across replicas and rolling deployments. Do not place secret values in the
|
across replicas and rolling deployments. Do not place secret values in the
|
||||||
ConfigMap or commit them to this repository.
|
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.
|
The base manifests contain deliberate `replace-me` image and deployment values.
|
||||||
For initial bootstrap, the platform operator applies `k8s/base` through an
|
For initial bootstrap, the platform operator applies `k8s/base` through an
|
||||||
environment overlay that supplies an existing immutable image revision. The
|
environment overlay that supplies an existing immutable image revision. The
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Anuphan, Geist_Mono } from "next/font/google";
|
import { Anuphan, Geist_Mono } from "next/font/google";
|
||||||
|
|
||||||
|
import { DeploymentUpdateNotifier } from "@/components/deployment-update-notifier";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
const deploymentId =
|
||||||
|
process.env.NEXT_DEPLOYMENT_ID?.trim() || "development";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="th"
|
lang="th"
|
||||||
@@ -34,6 +38,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
<body className="flex min-h-full flex-col">
|
<body className="flex min-h-full flex-col">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{children}
|
{children}
|
||||||
|
<DeploymentUpdateNotifier deploymentId={deploymentId} />
|
||||||
<Toaster position="bottom-right" />
|
<Toaster position="bottom-right" />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -38,10 +38,22 @@ describe("SSE stream", () => {
|
|||||||
expect(decoder.decode((await reader.read()).value)).toBe(
|
expect(decoder.decode((await reader.read()).value)).toBe(
|
||||||
'event: invalidation\ndata: {"type":"page.updated","id":"opaque","version":7}\n\n',
|
'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);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
expect(decoder.decode((await reader.read()).value)).toMatch(/^: heartbeat /u);
|
expect(decoder.decode((await reader.read()).value)).toMatch(/^: heartbeat /u);
|
||||||
await vi.advanceTimersByTimeAsync(49);
|
await vi.advanceTimersByTimeAsync(49);
|
||||||
expect((await reader.read()).done).toBe(true);
|
expect((await reader.read()).done).toBe(true);
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ export const SSE_MAX_LIFETIME_MS = 30 * 60 * 1_000;
|
|||||||
|
|
||||||
const encoder = new TextEncoder();
|
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 {
|
export interface EventStreamOptions {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
heartbeatMs?: number;
|
heartbeatMs?: number;
|
||||||
@@ -15,6 +29,7 @@ export interface EventStreamOptions {
|
|||||||
export interface EventStreamChannel {
|
export interface EventStreamChannel {
|
||||||
stream: ReadableStream<Uint8Array>;
|
stream: ReadableStream<Uint8Array>;
|
||||||
send(event: InvalidationEvent): boolean;
|
send(event: InvalidationEvent): boolean;
|
||||||
|
sendNamed(eventName: string, data: string): boolean;
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +102,9 @@ export function createEventStream(
|
|||||||
send(event) {
|
send(event) {
|
||||||
return enqueue(`event: invalidation\ndata: ${JSON.stringify(event)}\n\n`);
|
return enqueue(`event: invalidation\ndata: ${JSON.stringify(event)}\n\n`);
|
||||||
},
|
},
|
||||||
|
sendNamed(eventName, data) {
|
||||||
|
return enqueue(namedEvent(eventName, data));
|
||||||
|
},
|
||||||
close() {
|
close() {
|
||||||
close(true);
|
close(true);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -110,6 +110,20 @@ describe("production deployment contract", () => {
|
|||||||
);
|
);
|
||||||
expect(workflow).not.toMatch(/playwright|chromium/iu);
|
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("<DeploymentUpdateNotifier");
|
||||||
|
expect(notifier).toContain('new EventSource("/api/active")');
|
||||||
|
expect(notifier).toContain("window.location.reload()");
|
||||||
|
expect(notifier).toContain('label: "รีโหลด"');
|
||||||
|
expect(route).toContain('channel.sendNamed("deployment", deploymentId)');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("environment template contract", () => {
|
describe("environment template contract", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user