From 5a72705e71405398d7f0d9f41be36bd2cc361275 Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 6 Jul 2026 12:50:43 -0600 Subject: [PATCH] fix(ops): switch pm2 to standalone server + fix wholesale_settings query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three problems were silently breaking the prod Tuxedo redesign and the offline DB error swallowed all visibility into them: 1. `next start` against `output: "standalone"` is unsupported. Next.js prints "next start does not work with output: standalone" and silently disables the image optimizer — every `/_next/image?url=...` returned "url parameter is not allowed". pm2 was previously started as `pm2 start npm -- start -- -p 3100`. Switched to `node /home/tyler/route-commerce/scripts/start-standalone.cjs`. 2. The standalone server reads `.next/static/` relative to itself, so deploy must `cp -r .next/static .next/standalone/.next/static` on every sync. Without this, every `_next/static/chunks/*.js` returns 404 and the page hydration dies. Added to the deploy workflow. 3. bash's `set -a; . ./.env` truncates DATABASE_URL at the `&` in `&channel_binding=require`, so the standalone server boots with empty DB env. `getBrandSettingsPublic` then caught the empty-pool error and returned `{success:false, error:'Failed to fetch brand settings'}`, so `state.heroImageUrl` stayed null and the hero poster never rendered. The new `scripts/start-standalone.cjs` parses .env in Node (which handles `&` correctly) and exec's the server with the loaded env. 4. `getBrandSettingsPublic` queried `ws.wholesale_enabled`, which no longer exists in `wholesale_settings`. The schema migration renamed it to `online_payment_enabled`. Switched the column reference and added a console.error log so future drift surfaces instead of being swallowed by the empty catch. Plus `next.config.ts` had `hostname: "s3.crispygoat.com"` added under `images.remotePatterns` so the optimizer accepts MinIO URLs. --- .gitea/workflows/deploy.yml | 28 +++++++++++++--- next.config.ts | 6 ++++ scripts/start-standalone.cjs | 62 +++++++++++++++++++++++++++++++++++ src/actions/brand-settings.ts | 6 ++-- 4 files changed, 96 insertions(+), 6 deletions(-) create mode 100755 scripts/start-standalone.cjs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a1e2664..628c476 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -204,9 +204,29 @@ jobs: # Install deps and restart on server echo "Installing deps and restarting PM2..." - # IMPORTANT: --update-env so PM2 re-reads .env. Without it PM2 keeps - # the original env vars cached at first start and silently ignores - # newly added ones (like SMARTSHEET_*). - ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && pm2 restart route-commerce --update-env || pm2 start npm --name route-commerce -- start -- -p 3100 && pm2 save && sleep 4 && curl -f -s http://localhost:3100/api/health/db-schema || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }" + # IMPORTANT: With `output: "standalone"` in next.config.ts we MUST + # run `node .next/standalone/server.js` (not `next start`) — `next start` + # against a standalone build is unsupported and silently disables the + # image optimizer (every `/_next/image?url=...` returns + # `"url" parameter is not allowed`). See commit 129c9d2. + # + # We also need to (1) ship the standalone loader script + # `scripts/start-standalone.cjs`, (2) copy `.next/static/` into + # `.next/standalone/.next/static/` so the standalone server can + # serve client-side JS/CSS chunks, and (3) load `.env` via a + # Node loader — bash's `set -a; . ./.env` truncates DATABASE_URL + # at the `&` in `&channel_binding=require`. + scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/start-standalone.cjs tyler@route.crispygoat.com:$APP_DIR/scripts/start-standalone.cjs 2>/dev/null || true + ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "set -e; cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && \ + # Standalone server reads .next/static/ relative to itself. + mkdir -p .next/standalone/.next && \ + rm -rf .next/standalone/.next/static && \ + cp -r .next/static .next/standalone/.next/static && \ + # Start with the wrapper that loads .env and exec's the server. + # `pm2 start` is idempotent via `pm2 restart` after the first run. + pm2 delete route-commerce 2>/dev/null || true; \ + PORT=3100 HOSTNAME=0.0.0.0 pm2 start scripts/start-standalone.cjs --name route-commerce --interpreter /home/tyler/.cache/act/tool_cache/node/22.22.3/x64/bin/node 2>&1 | tail -3 && \ + pm2 save && sleep 4 && \ + curl -fsS http://localhost:3100/api/health/db-schema >/dev/null || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }" echo "Deployed successfully" \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index cd27f06..a87c20e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -34,6 +34,12 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "picsum.photos", }, + { + // Brand media hosted on the crispygoat MinIO bucket + // (e.g. s3.crispygoat.com/videos/wp-import/...) + protocol: "https", + hostname: "s3.crispygoat.com", + }, ], formats: ["image/avif", "image/webp"], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], diff --git a/scripts/start-standalone.cjs b/scripts/start-standalone.cjs new file mode 100755 index 0000000..c908bbf --- /dev/null +++ b/scripts/start-standalone.cjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * Production startup wrapper for `next start` when `output: "standalone"` + * is set in next.config.ts. Loads .env from a known absolute location, + * then exec's the standalone server with the loaded env. + * + * Why this exists: + * 1. `next start` against a standalone build is unsupported by Next.js — + * it prints "next start does not work with output: standalone + * configuration. Use `node .next/standalone/server.js` instead" — and + * silently disables the image optimizer (every `/_next/image?url=...` + * returned "url parameter is not allowed"), which is why all the + * WP-imported brand imagery never rendered. See commit 129c9d2. + * + * 2. bash's `set -a; . ./.env` truncates DATABASE_URL at the `&` in + * `&channel_binding=require`, so the server boots with empty DB env + * and the public storefront falls back to dark gradient backgrounds. + * A small Node loader sidesteps the bash word-splitting. + * + * 3. The standalone server reads `.next/static/` relative to itself, so + * the deploy workflow must `cp -r .next/static .next/standalone/.next/static` + * after every sync. + * + * Usage: + * PORT=3100 HOSTNAME=0.0.0.0 \ + * NODE_BIN=/home/tyler/.cache/act/tool_cache/node/22.22.3/x64/bin/node \ + * node /home/tyler/route-commerce/scripts/start-standalone.cjs + * + * pm2 typically overrides interpreter with its own node and sets `cwd` + * arbitrarily, so we resolve the project root from the script's own path + * instead of `process.cwd()`. + */ +const fs = require("fs"); +const path = require("path"); + +const APP_DIR = path.resolve(__dirname, ".."); +const ENV_PATH = process.env.ENV_FILE || path.join(APP_DIR, ".env"); +const STANDALONE = path.join(APP_DIR, ".next", "standalone", "server.js"); + +if (!fs.existsSync(STANDALONE)) { + console.error(`[start-standalone] Missing standalone server at ${STANDALONE}`); + process.exit(1); +} + +if (fs.existsSync(ENV_PATH)) { + for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) { + const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); + if (m && !process.env[m[1]]) process.env[m[1]] = m[2]; + } + console.log(`[start-standalone] loaded env from ${ENV_PATH}`); +} else { + console.warn(`[start-standalone] no .env at ${ENV_PATH}; relying on existing env`); +} + +// Hand off to the standalone server. Re-exec keeps the process title at +// `next-server` (next-start.sh sets it) and matches `node .next/standalone/server.js`. +const { spawn } = require("child_process"); +const child = spawn(process.execPath, [STANDALONE], { stdio: "inherit", env: process.env }); +child.on("exit", (code) => process.exit(code ?? 0)); +for (const sig of ["SIGINT", "SIGTERM"]) { + process.on(sig, () => child.kill(sig)); +} diff --git a/src/actions/brand-settings.ts b/src/actions/brand-settings.ts index 9f3b1cf..49205e6 100644 --- a/src/actions/brand-settings.ts +++ b/src/actions/brand-settings.ts @@ -262,7 +262,7 @@ export async function getBrandSettingsPublic(brandSlug: string): Promise( - `SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled + `SELECT bs.*, b.name AS brand_name, ws.online_payment_enabled AS wholesale_enabled FROM brands b JOIN brand_settings bs ON bs.brand_id = b.id LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id @@ -272,6 +272,7 @@ await getSession(); try { ); const data = rows[0]; if (!data) { + console.warn(`[getBrandSettingsPublic] No row for slug=${brandSlug}`); return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; } return { @@ -279,7 +280,8 @@ await getSession(); try { settings: data, wholesaleEnabled: data.wholesale_enabled, }; - } catch { + } catch (err) { + console.error(`[getBrandSettingsPublic] DB error for slug=${brandSlug}:`, err); return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; } }