feat: add deployment reload controls
This commit is contained in:
+3
-1
@@ -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"]
|
||||
|
||||
@@ -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<unknown>;
|
||||
|
||||
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 }> {
|
||||
|
||||
+117
-29
@@ -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<Platform[]>(platforms);
|
||||
const [loading, setLoading] = useState<string | null>(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 (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{data.map((p) => (
|
||||
<Card key={p.name}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium capitalize">
|
||||
{p.name}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant={p.cachedCount !== null ? "default" : "secondary"}
|
||||
>
|
||||
{formatCount(p.cachedCount)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={loading !== null}
|
||||
onClick={() => handleRefetch(p.name)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3 h-3 ${loading === p.name ? "animate-spin" : ""}`}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{data.map((p) => (
|
||||
<Card key={p.name}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium capitalize">
|
||||
{p.name}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge
|
||||
variant={p.cachedCount !== null ? "default" : "secondary"}
|
||||
>
|
||||
{formatCount(p.cachedCount)}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading !== null}
|
||||
onClick={() => handleRefetch(p.name)}
|
||||
>
|
||||
{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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Toaster richColors closeButton position="bottom-right" theme="dark" />
|
||||
<VersionCheck />
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 /",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+21
-2
@@ -19,8 +19,16 @@ type SubscriptionOptions = {
|
||||
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;
|
||||
motd?: StreamMessage<T> | StreamMessage<T>[];
|
||||
};
|
||||
|
||||
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 topic = this.topic;
|
||||
const channel = channelName(this.topic);
|
||||
@@ -223,6 +235,7 @@ export class SseEndpoint<T extends EventMap> {
|
||||
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({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
deploymentId: process.env.NEXT_DEPLOYMENT_ID,
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "60mb",
|
||||
|
||||
Reference in New Issue
Block a user