fix(ops): switch pm2 to standalone server + fix wholesale_settings query
Deploy to route.crispygoat.com / deploy (push) Successful in 4m14s

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.
This commit is contained in:
Nora
2026-07-06 12:50:43 -06:00
parent 129c9d253c
commit 5a72705e71
4 changed files with 96 additions and 6 deletions
+24 -4
View File
@@ -204,9 +204,29 @@ jobs:
# Install deps and restart on server # Install deps and restart on server
echo "Installing deps and restarting PM2..." echo "Installing deps and restarting PM2..."
# IMPORTANT: --update-env so PM2 re-reads .env. Without it PM2 keeps # IMPORTANT: With `output: "standalone"` in next.config.ts we MUST
# the original env vars cached at first start and silently ignores # run `node .next/standalone/server.js` (not `next start`) — `next start`
# newly added ones (like SMARTSHEET_*). # against a standalone build is unsupported and silently disables the
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; }" # 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" echo "Deployed successfully"
+6
View File
@@ -34,6 +34,12 @@ const nextConfig: NextConfig = {
protocol: "https", protocol: "https",
hostname: "picsum.photos", 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"], formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
+62
View File
@@ -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));
}
+4 -2
View File
@@ -262,7 +262,7 @@ export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBran
await getSession(); try { await getSession(); try {
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>( const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
`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 FROM brands b
JOIN brand_settings bs ON bs.brand_id = b.id JOIN brand_settings bs ON bs.brand_id = b.id
LEFT JOIN wholesale_settings ws ON ws.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]; const data = rows[0];
if (!data) { if (!data) {
console.warn(`[getBrandSettingsPublic] No row for slug=${brandSlug}`);
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
} }
return { return {
@@ -279,7 +280,8 @@ await getSession(); try {
settings: data, settings: data,
wholesaleEnabled: data.wholesale_enabled, 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 }; return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
} }
} }