feat: add deployment reload controls

This commit is contained in:
2026-08-23 14:33:26 +00:00 Unverified
parent 25dccdfa4f
commit a630e0cc2f
11 changed files with 341 additions and 32 deletions
+3 -1
View File
@@ -18,7 +18,8 @@ ENV NEXT_TELEMETRY_DISABLED=1
COPY . . COPY . .
# Build Next.js # Build Next.js
RUN --mount=type=cache,target=/app/.next/cache \ 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 # 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. # 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/.next/standalone ./
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.version ./.version
EXPOSE 3000 EXPOSE 3000
CMD ["bun", "server.js"] CMD ["bun", "server.js"]
+9
View File
@@ -1,10 +1,19 @@
"use server"; "use server";
import { revalidateTag } from "next/cache"; 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"]; const validPlatforms = ["youtube", "roblox", "discord", "tiktok"];
type FollowerFetcher = () => Promise<unknown>; type FollowerFetcher = () => Promise<unknown>;
export async function forceRefresh(prefix: string | null) {
await requireAdmin();
const normalizedPrefix = normalizeReloadPrefix(prefix);
await sse.active.pub("refresh", normalizedPrefix);
}
export async function refetchPlatform( export async function refetchPlatform(
platform: string platform: string
): Promise<{ count: number | null }> { ): Promise<{ count: number | null }> {
+117 -29
View File
@@ -3,14 +3,24 @@
import { useState } from "react"; import { useState } from "react";
import { import {
Card, Card,
CardAction,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { RefreshCw } from "lucide-react"; import {
import { refetchPlatform } from "./actions"; 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"; import { toast } from "sonner";
interface Platform { interface Platform {
@@ -28,6 +38,8 @@ function formatCount(n: number | null): string {
export function BackendClient({ platforms }: { platforms: Platform[] }) { export function BackendClient({ platforms }: { platforms: Platform[] }) {
const [data, setData] = useState<Platform[]>(platforms); const [data, setData] = useState<Platform[]>(platforms);
const [loading, setLoading] = useState<string | null>(null); const [loading, setLoading] = useState<string | null>(null);
const [pathPrefix, setPathPrefix] = useState("");
const [reloadTarget, setReloadTarget] = useState<"all" | "path" | null>(null);
const handleRefetch = async (platform: string) => { const handleRefetch = async (platform: string) => {
setLoading(platform); 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 ( return (
<div className="grid gap-4 sm:grid-cols-2"> <div className="flex flex-col gap-4">
{data.map((p) => ( <div className="grid gap-4 sm:grid-cols-2">
<Card key={p.name}> {data.map((p) => (
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <Card key={p.name}>
<CardTitle className="text-sm font-medium capitalize"> <CardHeader>
{p.name} <CardTitle className="text-sm font-medium capitalize">
</CardTitle> {p.name}
<Badge </CardTitle>
variant={p.cachedCount !== null ? "default" : "secondary"} <CardAction>
> <Badge
{formatCount(p.cachedCount)} variant={p.cachedCount !== null ? "default" : "secondary"}
</Badge> >
</CardHeader> {formatCount(p.cachedCount)}
<CardContent> </Badge>
<Button </CardAction>
variant="outline" </CardHeader>
size="sm" <CardContent>
className="gap-2" <Button
disabled={loading !== null} variant="outline"
onClick={() => handleRefetch(p.name)} size="sm"
> disabled={loading !== null}
<RefreshCw onClick={() => handleRefetch(p.name)}
className={`w-3 h-3 ${loading === p.name ? "animate-spin" : ""}`} >
{loading === p.name ? (
<Spinner data-icon="inline-start" />
) : (
<RefreshCw data-icon="inline-start" />
)}
Refetch
</Button>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>Client reload</CardTitle>
<CardDescription>
Reload every connected client or only clients under a pathname prefix.
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
disabled={reloadTarget !== null}
onClick={() => handleForceRefresh("all", null)}
>
{reloadTarget === "all" ? (
<Spinner data-icon="inline-start" />
) : (
<Bomb data-icon="inline-start" />
)}
Force reload all
</Button>
<FieldGroup>
<Field data-invalid={pathPrefix.length > 0 && !pathIsValid}>
<FieldLabel htmlFor="reload-path-prefix">Path prefix</FieldLabel>
<Input
id="reload-path-prefix"
value={pathPrefix}
onChange={(event) => setPathPrefix(event.target.value)}
placeholder="/admin"
aria-invalid={pathPrefix.length > 0 && !pathIsValid}
/> />
Refetch <FieldDescription>
Reloads clients whose pathname starts with this value.
</FieldDescription>
</Field>
<Button
variant="destructive"
disabled={reloadTarget !== null || !pathIsValid}
onClick={() => handleForceRefresh("path", normalizedPathPrefix)}
>
{reloadTarget === "path" ? (
<Spinner data-icon="inline-start" />
) : (
<Bomb data-icon="inline-start" />
)}
Reload matching path
</Button> </Button>
</CardContent> </FieldGroup>
</Card> </CardContent>
))} </Card>
</div> </div>
); );
} }
+28
View File
@@ -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 },
});
}
+2
View File
@@ -69,6 +69,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { PageProgress } from "@/components/page-progress"; import { PageProgress } from "@/components/page-progress";
import { VersionCheck } from "@/components/version-check";
export default function RootLayout({ export default function RootLayout({
children, children,
@@ -96,6 +97,7 @@ export default function RootLayout({
> >
<TooltipProvider> <TooltipProvider>
<Toaster richColors closeButton position="bottom-right" theme="dark" /> <Toaster richColors closeButton position="bottom-right" theme="dark" />
<VersionCheck />
{children} {children}
</TooltipProvider> </TooltipProvider>
</ThemeProvider> </ThemeProvider>
+71
View File
@@ -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<string | null>(null);
useEffect(() => {
let successTimer: ReturnType<typeof setTimeout> | 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;
}
+37
View File
@@ -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 /",
);
});
});
+27
View File
@@ -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;
}
+25
View File
@@ -1,6 +1,31 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { isSseTopic, sse } from "@/lib/realtime/sse"; 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", () => { describe("leaderboard SSE contract", () => {
test("registers the public leaderboards topic", () => { test("registers the public leaderboards topic", () => {
expect(isSseTopic("leaderboards")).toBeTrue(); expect(isSseTopic("leaderboards")).toBeTrue();
+21 -2
View File
@@ -19,8 +19,16 @@ type SubscriptionOptions = {
onopen?: (event: Event) => void; onopen?: (event: Event) => void;
}; };
type StreamOptions = { type StreamMessage<T extends EventMap> = {
[K in Extract<keyof T, string>]: {
event: K;
data: z.input<T[K]>;
};
}[Extract<keyof T, string>];
type StreamOptions<T extends EventMap> = {
signal?: AbortSignal; signal?: AbortSignal;
motd?: StreamMessage<T> | StreamMessage<T>[];
}; };
const redisEnvelopeSchema = z.object({ const redisEnvelopeSchema = z.object({
@@ -154,7 +162,11 @@ export class SseEndpoint<T extends EventMap> {
} }
} }
async stream({ signal }: StreamOptions = {}) { async stream({ signal, motd }: StreamOptions<T> = {}) {
const initialMessages = (Array.isArray(motd) ? motd : motd ? [motd] : [])
.map(({ event, data }) =>
encodeEvent(event, this.events[event].parse(data)),
);
const subscriber = await getRedisSubscriber(); const subscriber = await getRedisSubscriber();
const topic = this.topic; const topic = this.topic;
const channel = channelName(this.topic); const channel = channelName(this.topic);
@@ -223,6 +235,7 @@ export class SseEndpoint<T extends EventMap> {
start(streamController) { start(streamController) {
controller = streamController; controller = streamController;
write(`retry: ${DEFAULT_RETRY_MS}\n\n: connected ${topic}\n\n`); write(`retry: ${DEFAULT_RETRY_MS}\n\n: connected ${topic}\n\n`);
for (const message of initialMessages) write(message);
receive = handleMessage; receive = handleMessage;
for (const message of pendingMessages.splice(0)) { for (const message of pendingMessages.splice(0)) {
@@ -287,6 +300,12 @@ export function createSseEndpoints<
const updateActionSchema = z.enum(["created", "updated", "deleted"]); const updateActionSchema = z.enum(["created", "updated", "deleted"]);
export const sse = createSseEndpoints({ export const sse = createSseEndpoints({
active: {
events: {
version: z.string().min(1),
refresh: z.string().min(1).nullable(),
},
},
forms: { forms: {
events: { events: {
update: z.object({ update: z.object({
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
deploymentId: process.env.NEXT_DEPLOYMENT_ID,
experimental: { experimental: {
serverActions: { serverActions: {
bodySizeLimit: "60mb", bodySizeLimit: "60mb",