diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4e3bda6..e5dc272 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,7 +3,7 @@ name: Deploy to Cloudflare on: push: branches: - - refacor/tanstack-start + - main workflow_dispatch: jobs: @@ -32,4 +32,4 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: deploy \ No newline at end of file + command: deploy diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 67fa18d..049e3ca 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -33,6 +33,7 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | + type=raw,value=latest,enable={{is_default_branch}} type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} @@ -44,4 +45,4 @@ jobs: context: . push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index f60a4bc..76b78ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,42 +4,32 @@ FROM node:20-alpine AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN npm install -g corepack@latest && corepack enable - WORKDIR /app FROM base AS deps -COPY package.json pnpm-lock.yaml* ./ -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --force +COPY package.json pnpm-lock.yaml ./ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile -FROM base AS builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules +FROM deps AS builder COPY . . - -ENV NEXT_STANDALONE=1 -RUN pnpm run build +RUN pnpm run build && pnpm prune --prod FROM base AS runner +ENV NODE_ENV=production WORKDIR /app -ENV NODE_ENV=production - RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs +RUN adduser --system --uid 1001 nodeapp -COPY --from=builder /app/public ./public +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/server.mjs ./server.mjs -RUN mkdir .next -RUN chown nextjs:nodejs .next - -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -USER nextjs +USER nodeapp EXPOSE 3000 - ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" +ENV HOSTNAME=0.0.0.0 -CMD ["node", "server.js"] \ No newline at end of file +CMD ["node", "server.mjs"] diff --git a/package.json b/package.json index fdad545..3907c6a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite dev", "build": "vite build", - "start": "node .output/server/index.mjs", + "start": "node server.mjs", "preview": "vite preview" }, "dependencies": { diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..44540b6 --- /dev/null +++ b/server.mjs @@ -0,0 +1,143 @@ +import { createServer } from "node:http"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { extname, normalize, resolve } from "node:path"; +import { Readable } from "node:stream"; +import serverEntry from "./dist/server/server.js"; + +const clientDir = resolve(process.cwd(), "dist/client"); +const port = Number(process.env.PORT || 3000); +const host = process.env.HOSTNAME || "0.0.0.0"; + +const MIME_TYPES = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", + ".ttf": "font/ttf", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".xml": "application/xml; charset=utf-8" +}; + +function getContentType(filePath) { + const extension = extname(filePath).toLowerCase(); + return MIME_TYPES[extension] || "application/octet-stream"; +} + +function toHeaders(nodeHeaders) { + const headers = new Headers(); + for (const [key, value] of Object.entries(nodeHeaders)) { + if (typeof value === "undefined") continue; + if (Array.isArray(value)) { + for (const item of value) headers.append(key, item); + } else { + headers.set(key, value); + } + } + return headers; +} + +function resolveStaticFile(pathname) { + const decoded = decodeURIComponent(pathname); + const normalized = normalize(decoded).replace(/^[/\\]+/, ""); + const absolutePath = resolve(clientDir, normalized); + if (!absolutePath.startsWith(clientDir)) return null; + if (!existsSync(absolutePath)) return null; + const stats = statSync(absolutePath); + if (!stats.isFile()) return null; + return absolutePath; +} + +function tryServeStatic(req, res, url) { + if (!url.pathname || url.pathname.endsWith("/")) return false; + const filePath = resolveStaticFile(url.pathname); + if (!filePath) return false; + + res.statusCode = 200; + res.setHeader("Content-Type", getContentType(filePath)); + if (url.pathname.startsWith("/assets/")) { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + } else { + res.setHeader("Cache-Control", "public, max-age=3600"); + } + + if (req.method === "HEAD") { + res.end(); + return true; + } + + createReadStream(filePath).pipe(res); + return true; +} + +function appendSetCookie(res, value) { + const existing = res.getHeader("set-cookie"); + if (!existing) { + res.setHeader("set-cookie", value); + return; + } + if (Array.isArray(existing)) { + res.setHeader("set-cookie", [...existing, value]); + return; + } + res.setHeader("set-cookie", [String(existing), value]); +} + +createServer(async (req, res) => { + try { + const hostHeader = req.headers.host || `localhost:${port}`; + const protocol = (req.headers["x-forwarded-proto"] || "http").toString().split(",")[0].trim(); + const url = new URL(req.url || "/", `${protocol}://${hostHeader}`); + + if (tryServeStatic(req, res, url)) return; + + const method = (req.method || "GET").toUpperCase(); + const hasBody = method !== "GET" && method !== "HEAD"; + const init = { + method, + headers: toHeaders(req.headers) + }; + + if (hasBody) { + init.body = Readable.toWeb(req); + init.duplex = "half"; + } + + const request = new Request(url, init); + const response = await serverEntry.fetch(request); + + res.statusCode = response.status; + response.headers.forEach((value, key) => { + if (key.toLowerCase() === "set-cookie") { + appendSetCookie(res, value); + } else { + res.setHeader(key, value); + } + }); + + if (method === "HEAD" || !response.body) { + res.end(); + return; + } + + Readable.fromWeb(response.body).pipe(res); + } catch (error) { + console.error("Server error:", error); + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + } + res.end("Internal Server Error"); + } +}).listen(port, host, () => { + console.log(`Server running at http://${host}:${port}`); +});