feat: add deployment reload controls
This commit is contained in:
+3
-1
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
|
|||||||
@@ -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 { 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
@@ -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({
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user