perf(build): make standalone image deterministic

This commit is contained in:
2026-08-09 21:34:00 +07:00 Unverified
parent c06b2ef88f
commit 1dc44e0215
10 changed files with 65 additions and 32 deletions
+14 -5
View File
@@ -12,6 +12,7 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
# Stage 2: Build the application # Stage 2: Build the application
FROM oven/bun:1.3.14-alpine AS builder FROM oven/bun:1.3.14-alpine AS builder
WORKDIR /app WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Build Next.js # Build Next.js
@@ -20,18 +21,26 @@ RUN --mount=type=cache,target=/app/.next/cache \
# Stage 3: Production runner # Stage 3: Production runner
FROM oven/bun:1.3.14-alpine AS runner FROM oven/bun:1.3.14-alpine AS runner
ARG TARGETARCH
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
# Copy built assets and dependencies from builder stage # Copy the standalone server and static assets without the full dependency tree
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json # The standalone tracer includes both glibc and musl Sharp packages. Alpine only
# needs musl, so discard the incompatible duplicate without touching runtime deps.
RUN rm -rf \
"./node_modules/@img/sharp-linux-${TARGETARCH}" \
"./node_modules/@img/sharp-libvips-linux-${TARGETARCH}"
EXPOSE 3000 EXPOSE 3000
CMD ["bun", "start"] CMD ["bun", "server.js"]
# Stage 4: Scheduled video upload worker # Stage 4: Scheduled video upload worker
FROM deps AS video-upload-worker FROM deps AS video-upload-worker
+3 -3
View File
@@ -42,10 +42,10 @@ export async function POST(request: Request) {
const stagingDirectory = `${VIDEO_DIR}/staging/${discordId}`; const stagingDirectory = `${VIDEO_DIR}/staging/${discordId}`;
filePath = `${stagingDirectory}/${stagedVideoId}-${safeFileName(originalFilename)}`; filePath = `${stagingDirectory}/${stagedVideoId}-${safeFileName(originalFilename)}`;
await mkdir(stagingDirectory, { recursive: true }); await mkdir(/* turbopackIgnore: true */ stagingDirectory, { recursive: true });
await pipeline( await pipeline(
Readable.fromWeb(request.body as never), Readable.fromWeb(request.body as never),
createWriteStream(filePath, { flags: "wx" }), createWriteStream(/* turbopackIgnore: true */ filePath, { flags: "wx" }),
); );
return NextResponse.json({ return NextResponse.json({
@@ -55,7 +55,7 @@ export async function POST(request: Request) {
size: contentLength, size: contentLength,
}); });
} catch (error) { } catch (error) {
if (filePath) await unlink(filePath).catch(() => undefined); if (filePath) await unlink(/* turbopackIgnore: true */ filePath).catch(() => undefined);
const message = error instanceof Error ? error.message : "Upload failed."; const message = error instanceof Error ? error.message : "Upload failed.";
const status = const status =
message === "Unauthorized" || message === "Not authenticated" ? 401 : 400; message === "Unauthorized" || message === "Not authenticated" ? 401 : 400;
+36
View File
@@ -3,6 +3,42 @@
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/inter-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Anuphan";
font-style: normal;
font-weight: 100 700;
font-display: swap;
src: url("/fonts/anuphan-thai.woff2") format("woff2");
unicode-range: U+02D7, U+0303, U+0331, U+0E01-0E5B, U+200C-200D, U+25CC;
}
@font-face {
font-family: "Anuphan";
font-style: normal;
font-weight: 100 700;
font-display: swap;
src: url("/fonts/anuphan-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
--font-inter: "Inter", Arial, sans-serif;
--font-anuphan: "Anuphan", Tahoma, sans-serif;
}
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
+1 -14
View File
@@ -1,21 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter, Anuphan } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { config } from "@/lib/config"; import { config } from "@/lib/config";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
});
const anuphan = Anuphan({
variable: "--font-anuphan",
subsets: ["latin", "thai"],
display: "swap",
});
const siteUrl = config.site.url; const siteUrl = config.site.url;
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -81,7 +68,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="th" lang="th"
className={cn("h-full", "antialiased", inter.variable, anuphan.variable, "font-sans")} className={cn("h-full", "antialiased", "font-sans")}
suppressHydrationWarning suppressHydrationWarning
> >
<body className="font-sans" suppressHydrationWarning> <body className="font-sans" suppressHydrationWarning>
+3 -3
View File
@@ -219,7 +219,7 @@ async function publishYoutube(upload: typeof videoUploads.$inferSelect) {
throw new Error(`YouTube upload initialization failed (${init.status}).`); throw new Error(`YouTube upload initialization failed (${init.status}).`);
const location = init.headers.get("location"); const location = init.headers.get("location");
if (!location) throw new Error("YouTube did not return an upload URL."); if (!location) throw new Error("YouTube did not return an upload URL.");
const video = await readFile(upload.videoPath); const video = await readFile(/* turbopackIgnore: true */ upload.videoPath);
const result = await fetch(location, { const result = await fetch(location, {
method: "PUT", method: "PUT",
headers: { headers: {
@@ -233,7 +233,7 @@ async function publishYoutube(upload: typeof videoUploads.$inferSelect) {
const data = (await result.json()) as { id?: string }; const data = (await result.json()) as { id?: string };
if (!data.id) throw new Error("YouTube did not return a video ID."); if (!data.id) throw new Error("YouTube did not return a video ID.");
if (upload.thumbnailPath) { if (upload.thumbnailPath) {
const thumbnail = await readFile(upload.thumbnailPath); const thumbnail = await readFile(/* turbopackIgnore: true */ upload.thumbnailPath);
const thumb = await fetch( const thumb = await fetch(
`https://www.googleapis.com/upload/youtube/v3/thumbnails/set?videoId=${encodeURIComponent(data.id)}`, `https://www.googleapis.com/upload/youtube/v3/thumbnails/set?videoId=${encodeURIComponent(data.id)}`,
{ {
@@ -279,7 +279,7 @@ async function publishTiktok(upload: typeof videoUploads.$inferSelect) {
settings.privacyLevel ?? settings.privacyLevel ??
creator.data?.privacy_level_options?.[0] ?? creator.data?.privacy_level_options?.[0] ??
"SELF_ONLY"; "SELF_ONLY";
const video = await readFile(upload.videoPath); const video = await readFile(/* turbopackIgnore: true */ upload.videoPath);
const chunkSize = Math.min(10 * 1024 * 1024, video.byteLength); const chunkSize = Math.min(10 * 1024 * 1024, video.byteLength);
const totalChunkCount = Math.max(1, Math.floor(video.byteLength / chunkSize)); const totalChunkCount = Math.max(1, Math.floor(video.byteLength / chunkSize));
const init = await fetch( const init = await fetch(
+5 -5
View File
@@ -34,24 +34,24 @@ export async function saveVideoUpload(formData: FormData, discordId: string) {
throw new Error("Invalid staged video."); throw new Error("Invalid staged video.");
} }
const stagingDirectory = `${VIDEO_DIR}/staging/${discordId}`; const stagingDirectory = `${VIDEO_DIR}/staging/${discordId}`;
const stagedFile = (await readdir(stagingDirectory)).find((name) => const stagedFile = (await readdir(/* turbopackIgnore: true */ stagingDirectory)).find((name) =>
name.startsWith(`${stagedVideoId}-`), name.startsWith(`${stagedVideoId}-`),
); );
if (!stagedFile) throw new Error("Uploaded video was not found."); if (!stagedFile) throw new Error("Uploaded video was not found.");
videoPath = `${stagingDirectory}/${stagedFile}`; videoPath = `${stagingDirectory}/${stagedFile}`;
originalFilename = String(formData.get("videoName") ?? stagedFile); originalFilename = String(formData.get("videoName") ?? stagedFile);
mimeType = String(formData.get("videoMimeType") ?? ""); mimeType = String(formData.get("videoMimeType") ?? "");
videoSize = (await stat(videoPath)).size; videoSize = (await stat(/* turbopackIgnore: true */ videoPath)).size;
if (!["video/mp4", "video/quicktime", "video/webm"].includes(mimeType)) { if (!["video/mp4", "video/quicktime", "video/webm"].includes(mimeType)) {
throw new Error("Use an MP4, MOV, or WebM video."); throw new Error("Use an MP4, MOV, or WebM video.");
} }
} else if (video instanceof File) { } else if (video instanceof File) {
validateVideoFile(video); validateVideoFile(video);
await mkdir(VIDEO_DIR, { recursive: true }); await mkdir(/* turbopackIgnore: true */ VIDEO_DIR, { recursive: true });
const temporaryId = randomUUID(); const temporaryId = randomUUID();
const safeVideoName = video.name.replace(/[^a-zA-Z0-9._-]/g, "_"); const safeVideoName = video.name.replace(/[^a-zA-Z0-9._-]/g, "_");
videoPath = `${VIDEO_DIR}/${temporaryId}-${safeVideoName}`; videoPath = `${VIDEO_DIR}/${temporaryId}-${safeVideoName}`;
await writeFile(videoPath, Buffer.from(await video.arrayBuffer())); await writeFile(/* turbopackIgnore: true */ videoPath, Buffer.from(await video.arrayBuffer()));
originalFilename = video.name; originalFilename = video.name;
mimeType = video.type; mimeType = video.type;
videoSize = video.size; videoSize = video.size;
@@ -74,7 +74,7 @@ export async function saveVideoUpload(formData: FormData, discordId: string) {
let thumbnailPath: string | null = null; let thumbnailPath: string | null = null;
if (thumbnail instanceof File && thumbnail.size > 0) { if (thumbnail instanceof File && thumbnail.size > 0) {
thumbnailPath = `${VIDEO_DIR}/${id}-thumbnail-${thumbnail.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`; thumbnailPath = `${VIDEO_DIR}/${id}-thumbnail-${thumbnail.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
await writeFile(thumbnailPath, Buffer.from(await thumbnail.arrayBuffer())); await writeFile(/* turbopackIgnore: true */ thumbnailPath, Buffer.from(await thumbnail.arrayBuffer()));
} }
const youtubeSettings: YoutubeSettings = JSON.parse( const youtubeSettings: YoutubeSettings = JSON.parse(
+3 -2
View File
@@ -1,10 +1,11 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
serverExternalPackages: ["bun"], output: "standalone",
turbopack: { turbopack: {
root: process.cwd(), root: __dirname,
}, },
serverExternalPackages: ["bun"],
allowedDevOrigins: ['test.gunshiz.top'], allowedDevOrigins: ['test.gunshiz.top'],
images: { images: {
remotePatterns: [ remotePatterns: [
Binary file not shown.
Binary file not shown.
Binary file not shown.