diff --git a/.dockerignore b/.dockerignore index 3982507..ea367ab 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,11 @@ node_modules dist .git -.gitignore -.env +.env.development +.env.production .env.local *.log -.vscode +.DS_Store .idea -coverage +.fleet +.cursor diff --git a/Dockerfile b/Dockerfile index 89bb00d..21c1b05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,39 @@ -# داک‌پلوی: Build Type = Dockerfile ، پورت ۸۰ -FROM node:22-alpine AS build +FROM docker.iranserver.com/node:20 + WORKDIR /app COPY package.json package-lock.json ./ -RUN npm config set registry https://mirror2.chabokan.net/npm/ -RUN npm ci +COPY scripts ./scripts + +RUN npm config set registry https://mirror2.chabokan.net/npm/ \ + && npm config set replace-registry-host npmjs \ + && npm config set fetch-retry-mintimeout 10000 \ + && npm config set fetch-retry-maxtimeout 120000 \ + && npm config set fetch-timeout 120000 \ + && npm config set fetch-retries 5 + +ENV NODE_ENV=development +RUN npm ci --include=dev --foreground-scripts --registry=https://mirror2.chabokan.net/npm/ COPY . . -ARG VITE_API_BASE_URL=https://parche.montest.ir/server/api -ENV VITE_API_BASE_URL=$VITE_API_BASE_URL -RUN npm run build-only -FROM nginx:1.27-alpine -COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=build /app/dist /usr/share/nginx/html -EXPOSE 80 +# نام‌ها باید با Environment و Build-time Arguments در Dokploy یکی باشند +ARG VITE_API_BASE_URL +ARG VITE_USE_MOCK_AUTH +ARG VITE_BASE_PATH + +RUN node scripts/write-docker-vite-env.mjs \ + && unset VITE_API_BASE_URL VITE_USE_MOCK_AUTH VITE_BASE_PATH \ + && npm run build + +ENV NODE_ENV=production +ENV VITE_BASE_PATH=/ +ENV PORT=7676 + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 7676 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["node", "scripts/serve-user.mjs"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..7a05368 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +load_env_file() { + file="$1" + [ -f "$file" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + val="${line#*=}" + eval "current=\${$key-}" + if [ -z "$current" ]; then + export "$key=$val" + fi + done < "$file" +} + +load_env_file /app/.env.production +load_env_file /app/.env + +escape_sed() { + printf '%s\n' "$1" | sed -e 's/[\/&]/\\&/g' -e 's/\./\\\./g' +} + +ESCAPED_API_BASE_URL=$(escape_sed "$VITE_API_BASE_URL") + +if [ -d /app/dist ]; then + find /app/dist -type f \( -name "*.js" -o -name "*.html" \) -exec sed -i \ + -e "s/__VITE_API_BASE_URL__/${ESCAPED_API_BASE_URL}/g" \ + {} \; +fi + +exec "$@" diff --git a/scripts/serve-user.mjs b/scripts/serve-user.mjs new file mode 100644 index 0000000..3d69f91 --- /dev/null +++ b/scripts/serve-user.mjs @@ -0,0 +1,84 @@ +import http from 'node:http' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const port = Number(process.env.PORT || 7676) +const distDir = path.resolve(process.env.DIST_DIR || path.join(__dirname, '..', 'dist')) +const rawBase = String(process.env.VITE_BASE_PATH ?? '/').trim() || '/' +const publicBase = rawBase === '/' ? '' : rawBase.replace(/\/+$/, '') + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.txt': 'text/plain; charset=utf-8', + '.webmanifest': 'application/manifest+json', +} + +function sendFile(res, filePath) { + const type = MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream' + res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'public, max-age=0' }) + fs.createReadStream(filePath).pipe(res) +} + +function sendIndex(res) { + sendFile(res, path.join(distDir, 'index.html')) +} + +function resolveSafe(urlPath) { + const decoded = decodeURIComponent(urlPath.split('?')[0] || '/') + const relative = publicBase && decoded.startsWith(`${publicBase}/`) + ? decoded.slice(publicBase.length) + : publicBase && decoded === publicBase + ? '/' + : decoded + const target = path.resolve(distDir, `.${relative}`) + if (!target.startsWith(distDir)) return null + return target +} + +const server = http.createServer((req, res) => { + const url = req.url || '/' + const pathname = decodeURIComponent(url.split('?')[0] || '/') + + if (pathname === '/health' || (publicBase && pathname === `${publicBase}/health`)) { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + return res.end('ok') + } + + if (publicBase && pathname === publicBase) { + res.writeHead(302, { Location: `${publicBase}/` }) + return res.end() + } + + if (publicBase && pathname === '/') { + res.writeHead(302, { Location: `${publicBase}/` }) + return res.end() + } + + const target = resolveSafe(url) + if (target && fs.existsSync(target) && fs.statSync(target).isFile()) { + return sendFile(res, target) + } + + return sendIndex(res) +}) + +server.listen(port, '0.0.0.0', () => { + console.log(`fabric-trade user listening on ${port} → ${publicBase || ''}/`) +}) diff --git a/scripts/write-docker-vite-env.mjs b/scripts/write-docker-vite-env.mjs new file mode 100644 index 0000000..ba3a441 --- /dev/null +++ b/scripts/write-docker-vite-env.mjs @@ -0,0 +1,56 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' + +const keys = ['VITE_API_BASE_URL', 'VITE_USE_MOCK_AUTH', 'VITE_BASE_PATH'] + +function parseEnv(text) { + const out = {} + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const index = trimmed.indexOf('=') + if (index <= 0) continue + const key = trimmed.slice(0, index).trim() + let value = trimmed.slice(index + 1) + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + out[key] = value + } + return out +} + +const merged = {} + +if (existsSync('.env')) { + Object.assign(merged, parseEnv(readFileSync('.env', 'utf8'))) +} + +for (const key of keys) { + const value = process.env[key] + if (value) merged[key] = value +} + +if (!merged.VITE_API_BASE_URL) { + merged.VITE_API_BASE_URL = 'https://parche.montest.ir/server/api' +} + +if (!merged.VITE_USE_MOCK_AUTH) { + merged.VITE_USE_MOCK_AUTH = 'false' +} + +if (!merged.VITE_BASE_PATH || merged.VITE_BASE_PATH === '/') { + merged.VITE_BASE_PATH = '/' +} else if (!merged.VITE_BASE_PATH.endsWith('/')) { + merged.VITE_BASE_PATH = `${merged.VITE_BASE_PATH}/` +} + +const lines = Object.entries(merged) + .filter(([key]) => key.startsWith('VITE_')) + .map(([key, value]) => `${key}=${value}`) + +writeFileSync('.env.production', `${lines.join('\n')}\n`) +console.log('Vite build env:') +for (const line of lines) console.log(` ${line}`)