perf(build): make standalone image deterministic
This commit is contained in:
+14
-5
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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.
Reference in New Issue
Block a user