From a630e0cc2fcfc649475f1b5c3b06feb62f294bea Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 23 Aug 2026 14:33:26 +0000 Subject: [PATCH] feat: add deployment reload controls --- Dockerfile | 4 +- app/admin/backend/actions.ts | 9 ++ app/admin/backend/client.tsx | 146 +++++++++++++++++++----- app/api/active/route.ts | 28 +++++ app/layout.tsx | 2 + components/version-check.tsx | 71 ++++++++++++ lib/realtime/deployment-refresh.test.ts | 37 ++++++ lib/realtime/deployment-refresh.ts | 27 +++++ lib/realtime/sse.test.ts | 25 ++++ lib/realtime/sse.ts | 23 +++- next.config.ts | 1 + 11 files changed, 341 insertions(+), 32 deletions(-) create mode 100644 app/api/active/route.ts create mode 100644 components/version-check.tsx create mode 100644 lib/realtime/deployment-refresh.test.ts create mode 100644 lib/realtime/deployment-refresh.ts diff --git a/Dockerfile b/Dockerfile index 6dab2e0..344b141 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,8 @@ ENV NEXT_TELEMETRY_DISABLED=1 COPY . . # Build Next.js RUN --mount=type=cache,target=/app/.next/cache \ - bun run build + bun -e "await Bun.write('.version', crypto.randomUUID())" && \ + NEXT_DEPLOYMENT_ID="$(bun -e 'process.stdout.write(await Bun.file(".version").text())')" bun run build # The standalone tracer includes both glibc and musl Sharp packages. Alpine only # needs musl, so prune the incompatible copy before it enters a runtime layer. @@ -38,6 +39,7 @@ ENV PORT=3000 COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/public ./public COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/.version ./.version EXPOSE 3000 CMD ["bun", "server.js"] diff --git a/app/admin/backend/actions.ts b/app/admin/backend/actions.ts index c1430a4..c6244ad 100644 --- a/app/admin/backend/actions.ts +++ b/app/admin/backend/actions.ts @@ -1,10 +1,19 @@ "use server"; import { revalidateTag } from "next/cache"; +import { requireAdmin } from "@/lib/auth/auth"; +import { normalizeReloadPrefix } from "@/lib/realtime/deployment-refresh"; +import { sse } from "@/lib/realtime/sse"; const validPlatforms = ["youtube", "roblox", "discord", "tiktok"]; type FollowerFetcher = () => Promise; +export async function forceRefresh(prefix: string | null) { + await requireAdmin(); + const normalizedPrefix = normalizeReloadPrefix(prefix); + await sse.active.pub("refresh", normalizedPrefix); +} + export async function refetchPlatform( platform: string ): Promise<{ count: number | null }> { diff --git a/app/admin/backend/client.tsx b/app/admin/backend/client.tsx index ad5e42d..76fc016 100644 --- a/app/admin/backend/client.tsx +++ b/app/admin/backend/client.tsx @@ -3,14 +3,24 @@ import { useState } from "react"; import { Card, + CardAction, CardContent, + CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { RefreshCw } from "lucide-react"; -import { refetchPlatform } from "./actions"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { Bomb, RefreshCw } from "lucide-react"; +import { forceRefresh, refetchPlatform } from "./actions"; import { toast } from "sonner"; interface Platform { @@ -28,6 +38,8 @@ function formatCount(n: number | null): string { export function BackendClient({ platforms }: { platforms: Platform[] }) { const [data, setData] = useState(platforms); const [loading, setLoading] = useState(null); + const [pathPrefix, setPathPrefix] = useState(""); + const [reloadTarget, setReloadTarget] = useState<"all" | "path" | null>(null); const handleRefetch = async (platform: string) => { setLoading(platform); @@ -46,36 +58,112 @@ export function BackendClient({ platforms }: { platforms: Platform[] }) { } }; + const handleForceRefresh = async ( + target: "all" | "path", + prefix: string | null, + ) => { + setReloadTarget(target); + try { + await forceRefresh(prefix); + toast.success( + prefix ? `Reload sent for ${prefix}` : "Reload sent to all clients", + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to send reload"); + } finally { + setReloadTarget(null); + } + }; + + const normalizedPathPrefix = pathPrefix.trim(); + const pathIsValid = normalizedPathPrefix.startsWith("/"); + return ( -
- {data.map((p) => ( - - - - {p.name} - - - {formatCount(p.cachedCount)} - - - - + + + ))} +
+ + + + Client reload + + Reload every connected client or only clients under a pathname prefix. + + + + + + + 0 && !pathIsValid}> + Path prefix + setPathPrefix(event.target.value)} + placeholder="/admin" + aria-invalid={pathPrefix.length > 0 && !pathIsValid} /> - Refetch + + Reloads clients whose pathname starts with this value. + + + - - - ))} + + + ); } diff --git a/app/api/active/route.ts b/app/api/active/route.ts new file mode 100644 index 0000000..e76143a --- /dev/null +++ b/app/api/active/route.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { sse } from "@/lib/realtime/sse"; + +export const dynamic = "force-dynamic"; + +async function getDeploymentVersion() { + if (process.env.NODE_ENV === "development") return "DEV"; + + try { + const version = await readFile(path.join(process.cwd(), ".version"), "utf8"); + return version.trim() || null; + } catch { + return null; + } +} + +export async function GET(request: Request) { + const version = await getDeploymentVersion(); + if (!version) { + return new Response("Deployment version unavailable", { status: 503 }); + } + + return sse.active.stream({ + signal: request.signal, + motd: { event: "version", data: version }, + }); +} diff --git a/app/layout.tsx b/app/layout.tsx index eededf2..70941b0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -69,6 +69,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { Toaster } from "sonner"; import { ThemeProvider } from "@/components/theme-provider"; import { PageProgress } from "@/components/page-progress"; +import { VersionCheck } from "@/components/version-check"; export default function RootLayout({ children, @@ -96,6 +97,7 @@ export default function RootLayout({ > + {children} diff --git a/components/version-check.tsx b/components/version-check.tsx new file mode 100644 index 0000000..e593edf --- /dev/null +++ b/components/version-check.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { + checkDeploymentVersion, + shouldReloadPath, + UPDATE_HASH, +} from "@/lib/realtime/deployment-refresh"; +import { sse } from "@/lib/realtime/sse"; + +const UPDATE_TOAST_ID = "deployment-update"; + +function reloadWithUpdateMarker() { + window.location.hash = UPDATE_HASH; + queueMicrotask(() => window.location.reload()); +} + +export function VersionCheck() { + const versionRef = useRef(null); + + useEffect(() => { + let successTimer: ReturnType | undefined; + + if (window.location.hash === UPDATE_HASH) { + const url = new URL(window.location.href); + url.hash = ""; + window.history.replaceState(null, "", url); + successTimer = setTimeout( + () => toast.success("คุณอยู่ในเวอร์ชั่นล่าสุดแล้ว"), + 1_000, + ); + } + + const { clean } = sse.active.subMany( + { + version(incomingVersion) { + const result = checkDeploymentVersion( + versionRef.current, + incomingVersion, + ); + versionRef.current = result.version; + if (!result.hasUpdate) return; + + window.location.hash = UPDATE_HASH; + toast("มีอัปเดตใหม่พร้อมใช้งาน", { + id: UPDATE_TOAST_ID, + description: "รีโหลดเพื่ออัปเดตเป็นเวอร์ชันล่าสุด", + action: { + label: "รีโหลด", + onClick: () => window.location.reload(), + }, + duration: Infinity, + }); + }, + refresh(prefix) { + if (!shouldReloadPath(prefix, window.location.pathname)) return; + reloadWithUpdateMarker(); + }, + }, + { endpoint: "/api/active" }, + ); + + return () => { + if (successTimer) clearTimeout(successTimer); + clean(); + }; + }, []); + + return null; +} diff --git a/lib/realtime/deployment-refresh.test.ts b/lib/realtime/deployment-refresh.test.ts new file mode 100644 index 0000000..45fcbb6 --- /dev/null +++ b/lib/realtime/deployment-refresh.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { + checkDeploymentVersion, + normalizeReloadPrefix, + shouldReloadPath, +} from "@/lib/realtime/deployment-refresh"; + +describe("deployment refresh policy", () => { + test("uses the first version as a baseline and detects later changes", () => { + expect(checkDeploymentVersion(null, "build-a")).toEqual({ + version: "build-a", + hasUpdate: false, + }); + expect(checkDeploymentVersion("build-a", "build-a")).toEqual({ + version: "build-a", + hasUpdate: false, + }); + expect(checkDeploymentVersion("build-a", "build-b")).toEqual({ + version: "build-b", + hasUpdate: true, + }); + }); + + test("matches global and pathname-prefix reloads", () => { + expect(shouldReloadPath(null, "/profile")).toBeTrue(); + expect(shouldReloadPath("/admin", "/admin/backend")).toBeTrue(); + expect(shouldReloadPath("/form", "/profile")).toBeFalse(); + }); + + test("normalizes valid prefixes and rejects invalid ones", () => { + expect(normalizeReloadPrefix(null)).toBeNull(); + expect(normalizeReloadPrefix(" /admin ")).toBe("/admin"); + expect(() => normalizeReloadPrefix("admin")).toThrow( + "Reload path must start with /", + ); + }); +}); diff --git a/lib/realtime/deployment-refresh.ts b/lib/realtime/deployment-refresh.ts new file mode 100644 index 0000000..fae4a2a --- /dev/null +++ b/lib/realtime/deployment-refresh.ts @@ -0,0 +1,27 @@ +export const UPDATE_HASH = "#update"; + +export function checkDeploymentVersion( + currentVersion: string | null, + incomingVersion: string, +) { + return { + version: incomingVersion, + hasUpdate: + currentVersion !== null && currentVersion !== incomingVersion, + }; +} + +export function shouldReloadPath(prefix: string | null, pathname: string) { + return prefix === null || pathname.startsWith(prefix); +} + +export function normalizeReloadPrefix(prefix: string | null) { + if (prefix === null) return null; + + const normalized = prefix.trim(); + if (!normalized.startsWith("/")) { + throw new Error("Reload path must start with /"); + } + + return normalized; +} diff --git a/lib/realtime/sse.test.ts b/lib/realtime/sse.test.ts index dd25f66..c5ab4d5 100644 --- a/lib/realtime/sse.test.ts +++ b/lib/realtime/sse.test.ts @@ -1,6 +1,31 @@ import { describe, expect, test } from "bun:test"; import { isSseTopic, sse } from "@/lib/realtime/sse"; +describe("active SSE contract", () => { + test("registers public version and refresh events", () => { + expect(isSseTopic("active")).toBeTrue(); + expect(sse.active.adminOnly).toBeFalse(); + expect(sse.active.parseRedisMessage( + JSON.stringify({ event: "version", data: "build-a" }), + )).toEqual({ event: "version", data: "build-a" }); + expect(sse.active.parseRedisMessage( + JSON.stringify({ event: "refresh", data: "/admin" }), + )).toEqual({ event: "refresh", data: "/admin" }); + expect(sse.active.parseRedisMessage( + JSON.stringify({ event: "refresh", data: null }), + )).toEqual({ event: "refresh", data: null }); + }); + + test("rejects empty active-event values", () => { + expect(sse.active.parseRedisMessage( + JSON.stringify({ event: "version", data: "" }), + )).toBeNull(); + expect(sse.active.parseRedisMessage( + JSON.stringify({ event: "refresh", data: "" }), + )).toBeNull(); + }); +}); + describe("leaderboard SSE contract", () => { test("registers the public leaderboards topic", () => { expect(isSseTopic("leaderboards")).toBeTrue(); diff --git a/lib/realtime/sse.ts b/lib/realtime/sse.ts index 7299902..19e4d66 100644 --- a/lib/realtime/sse.ts +++ b/lib/realtime/sse.ts @@ -19,8 +19,16 @@ type SubscriptionOptions = { onopen?: (event: Event) => void; }; -type StreamOptions = { +type StreamMessage = { + [K in Extract]: { + event: K; + data: z.input; + }; +}[Extract]; + +type StreamOptions = { signal?: AbortSignal; + motd?: StreamMessage | StreamMessage[]; }; const redisEnvelopeSchema = z.object({ @@ -154,7 +162,11 @@ export class SseEndpoint { } } - async stream({ signal }: StreamOptions = {}) { + async stream({ signal, motd }: StreamOptions = {}) { + const initialMessages = (Array.isArray(motd) ? motd : motd ? [motd] : []) + .map(({ event, data }) => + encodeEvent(event, this.events[event].parse(data)), + ); const subscriber = await getRedisSubscriber(); const topic = this.topic; const channel = channelName(this.topic); @@ -223,6 +235,7 @@ export class SseEndpoint { start(streamController) { controller = streamController; write(`retry: ${DEFAULT_RETRY_MS}\n\n: connected ${topic}\n\n`); + for (const message of initialMessages) write(message); receive = handleMessage; for (const message of pendingMessages.splice(0)) { @@ -287,6 +300,12 @@ export function createSseEndpoints< const updateActionSchema = z.enum(["created", "updated", "deleted"]); export const sse = createSseEndpoints({ + active: { + events: { + version: z.string().min(1), + refresh: z.string().min(1).nullable(), + }, + }, forms: { events: { update: z.object({ diff --git a/next.config.ts b/next.config.ts index 7e02640..d27661e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", + deploymentId: process.env.NEXT_DEPLOYMENT_ID, experimental: { serverActions: { bodySizeLimit: "60mb",