fix(tuxedo): copy public/ to standalone, mark storm/founder priority
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Three issues caused the 'images don't work, video doesn't play' report on https://water.tuxedocorn.com/tuxedo: 1. (ROOT CAUSE) Next.js standalone build was missing .next/standalone/public/. The deploy workflow copied .next/static to .next/standalone/.next/static but never mirrored public/. Without that, the image optimizer returns 400 'url parameter is not allowed' on every local-relative URL — so the brand logo (and favicon, etc.) went blank. Symptom: HTTP 400 on /_next/image?url=%2Fbrand-logos%2F64294306-...%2Flogo.png. Fixed by: - cp -r public/. .next/standalone/public/ on the running server (live) - updating .gitea/workflows/deploy.yml to mirror public/ on every deploy - adding scripts/regression-brand-logos.mjs so this can't regress silently 2. Storm-field and founder-portrait images rendered as 'loading=lazy' and appeared blank above the fold. Marked priority so they preload. 3. Pre-existing build error: Next.js 16.2 RSC bundler treats 'export type { FieldSession }' re-exports through server-action modules as runtime exports ('Export FieldSession doesn't exist in target module'). Removed the re-exports in water-log/auth.ts and field.ts; field.ts now imports FieldSession directly from @/lib/water-log/session-server. Verified live (post-fix) on 2026-07-06: - regression-brand-logos.mjs: HTTP 200 image/png, header+footer logos render with naturalWidth=160 - diag-home-only.mjs: all 6 images complete=true, natural sizes match source (storm 1440x1080, founder 604x340, ear 692x476, etc.) - verify-hero-video.mjs: readyState=4, paused=false, currentTime advancing 2.86s -> 6.87s over 4s (=30fps; the prior 'video doesn't play' was likely user perception — the poster+video look near- identical, and 'net::ERR_ABORTED' on the MP4 is normal Chrome behavior for parallel byte-range requests)
This commit is contained in:
@@ -213,15 +213,26 @@ jobs:
|
|||||||
# We also need to (1) ship the standalone loader script
|
# We also need to (1) ship the standalone loader script
|
||||||
# `scripts/start-standalone.cjs`, (2) copy `.next/static/` into
|
# `scripts/start-standalone.cjs`, (2) copy `.next/static/` into
|
||||||
# `.next/standalone/.next/static/` so the standalone server can
|
# `.next/standalone/.next/static/` so the standalone server can
|
||||||
# serve client-side JS/CSS chunks, and (3) load `.env` via a
|
# serve client-side JS/CSS chunks, (3) copy `public/` into
|
||||||
# Node loader — bash's `set -a; . ./.env` truncates DATABASE_URL
|
# `.next/standalone/public/` (REQUIRED for Next.js standalone —
|
||||||
# at the `&` in `&channel_binding=require`.
|
# without it, `/_next/image?url=/brand-logos/...` returns 400
|
||||||
|
# because the optimizer can't resolve local relative paths),
|
||||||
|
# and (4) 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
|
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 && \
|
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.
|
# Standalone server reads .next/static/ relative to itself.
|
||||||
mkdir -p .next/standalone/.next && \
|
mkdir -p .next/standalone/.next && \
|
||||||
rm -rf .next/standalone/.next/static && \
|
rm -rf .next/standalone/.next/static && \
|
||||||
cp -r .next/static .next/standalone/.next/static && \
|
cp -r .next/static .next/standalone/.next/static && \
|
||||||
|
# Next.js standalone REQUIRES public/ in the standalone tree;
|
||||||
|
# without it, the image optimizer returns 400 on local-relative
|
||||||
|
# image URLs (e.g. /brand-logos/.../logo.png) and storefront
|
||||||
|
# logos / favicons go blank. Mirror public/ → .next/standalone/public/.
|
||||||
|
rm -rf .next/standalone/public && \
|
||||||
|
mkdir -p .next/standalone/public && \
|
||||||
|
cp -r public/. .next/standalone/public/ && \
|
||||||
# Start with the wrapper that loads .env and exec's the server.
|
# Start with the wrapper that loads .env and exec's the server.
|
||||||
# `pm2 start` is idempotent via `pm2 restart` after the first run.
|
# `pm2 start` is idempotent via `pm2 restart` after the first run.
|
||||||
pm2 delete route-commerce 2>/dev/null || true; \
|
pm2 delete route-commerce 2>/dev/null || true; \
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Regression test: brand logo (and all /brand-logos/... images) must load
|
||||||
|
// through Next.js Image optimizer on the tuxedo storefront.
|
||||||
|
//
|
||||||
|
// Pre-fix state:
|
||||||
|
// GET https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-...%2Flogo.png
|
||||||
|
// → HTTP 400, body: "url parameter is not allowed"
|
||||||
|
//
|
||||||
|
// Post-fix expectation:
|
||||||
|
// → HTTP 200, content-type: image/* and non-empty body
|
||||||
|
//
|
||||||
|
// Root cause identified: Next.js standalone build is missing the
|
||||||
|
// `.next/standalone/public/` folder. The deploy workflow copies
|
||||||
|
// `.next/static` but not `public/`. Without `public/` in standalone, the
|
||||||
|
// image optimizer can't resolve local-relative URLs.
|
||||||
|
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
|
|
||||||
|
const URL = 'https://water.tuxedocorn.com/tuxedo?_=' + Date.now();
|
||||||
|
const LOGO_PROXY =
|
||||||
|
'https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-5f42-463d-a5e8-2ad6c81a96de%2Flogo.png&w=256&q=75';
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
// 1. Hit the image proxy directly
|
||||||
|
const r = await fetch(LOGO_PROXY, {
|
||||||
|
headers: { 'User-Agent': 'regression-brand-logos/1.0' },
|
||||||
|
});
|
||||||
|
if (r.status !== 200) {
|
||||||
|
failures.push(`Logo proxy returned HTTP ${r.status} (expected 200)`);
|
||||||
|
} else {
|
||||||
|
const ct = r.headers.get('content-type') ?? '';
|
||||||
|
if (!ct.startsWith('image/')) failures.push(`Logo proxy content-type was ${ct} (expected image/*)`);
|
||||||
|
const buf = new Uint8Array(await r.arrayBuffer());
|
||||||
|
if (buf.length < 1000) failures.push(`Logo proxy body too small: ${buf.length} bytes`);
|
||||||
|
console.log(` Logo proxy: HTTP ${r.status} ${ct} ${buf.length}B`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verify via headless browser — visit page, check that the logo <img> actually rendered.
|
||||||
|
const { chromium } = await import('playwright');
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const failed = [];
|
||||||
|
page.on('response', (resp) => {
|
||||||
|
const u = resp.url();
|
||||||
|
if (u.includes('brand-logos') && resp.status() >= 400) failed.push(`HTTP ${resp.status()} ${u.slice(0, 120)}`);
|
||||||
|
});
|
||||||
|
await page.goto(URL, { waitUntil: 'networkidle', timeout: 30000 });
|
||||||
|
// Scroll to bottom so footer logo loads.
|
||||||
|
await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }));
|
||||||
|
await sleep(4000);
|
||||||
|
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
const logoInfo = await page.evaluate(() => {
|
||||||
|
const imgs = Array.from(document.querySelectorAll('img'));
|
||||||
|
const logos = imgs.filter((i) => (i.currentSrc || i.src || '').includes('brand-logos'));
|
||||||
|
return logos.map((i) => ({
|
||||||
|
alt: i.alt.slice(0, 40),
|
||||||
|
currentSrc: (i.currentSrc || i.src || '').slice(0, 130),
|
||||||
|
naturalWidth: i.naturalWidth,
|
||||||
|
naturalHeight: i.naturalHeight,
|
||||||
|
complete: i.complete,
|
||||||
|
visible: i.getBoundingClientRect().height > 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
console.log('\n LOGO IMAGES ON PAGE:');
|
||||||
|
for (const l of logoInfo) console.log(' ', JSON.stringify(l));
|
||||||
|
|
||||||
|
const anyLogoBlank = logoInfo.some((l) => l.naturalWidth === 0 || !l.complete);
|
||||||
|
if (anyLogoBlank) failures.push(`Logo image(s) failed to render: ${JSON.stringify(logoInfo)}`);
|
||||||
|
if (failed.length > 0) failures.push(`Network failed for brand-logos URLs: ${failed.join(' | ')}`);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error('\n❌ REGRESSION FAILED:');
|
||||||
|
for (const f of failures) console.error(' -', f);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('\n✅ Brand logos serve correctly through Next.js Image optimizer.');
|
||||||
|
}
|
||||||
@@ -40,11 +40,16 @@ import {
|
|||||||
|
|
||||||
// ── Result types ────────────────────────────────────────────────────────
|
// ── Result types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// `FieldSession` is now defined in `@/lib/water-log/session.ts` so the
|
// `FieldSession` is now defined in `@/lib/water-log/session-server.ts`
|
||||||
// `FieldSession | { ok: false, error }` shape is shared with
|
// so the `FieldSession | { ok: false, error }` shape is shared with
|
||||||
// `lib/water-log/pin-login.ts` (Phase 2) and the rest of the module
|
// `lib/water-log/pin-login.ts` (Phase 2) and the rest of the module
|
||||||
// surface. Re-export for back-compat with existing call sites.
|
// surface. Consumers should import as:
|
||||||
export type { FieldSession };
|
// `import type { FieldSession } from "@/lib/water-log/session-server";`
|
||||||
|
// The previous `export type { FieldSession }` re-export here caused
|
||||||
|
// Next.js 16.2's RSC bundler to fail with "Export FieldSession doesn't
|
||||||
|
// exist in target module" for pages that reference it indirectly
|
||||||
|
// through server actions — so we removed the re-export and let
|
||||||
|
// downstream consumers point at the source.
|
||||||
|
|
||||||
export type SiteAdminPermission =
|
export type SiteAdminPermission =
|
||||||
| { ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> }
|
| { ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> }
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ import { verifyPin, validatePin } from "@/lib/water-log-pin";
|
|||||||
import { logAlert } from "@/lib/water-log-audit";
|
import { logAlert } from "@/lib/water-log-audit";
|
||||||
import {
|
import {
|
||||||
requireFieldSession,
|
requireFieldSession,
|
||||||
type FieldSession,
|
|
||||||
} from "@/actions/water-log/auth";
|
} from "@/actions/water-log/auth";
|
||||||
import {
|
import {
|
||||||
WL_SESSION_COOKIE,
|
WL_SESSION_COOKIE,
|
||||||
|
type FieldSession,
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
readSessionCookie,
|
readSessionCookie,
|
||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
@@ -45,7 +45,11 @@ import { triggerSyncForEntry } from "@/actions/water-log/smartsheet";
|
|||||||
|
|
||||||
// Re-export so existing call sites that import from
|
// Re-export so existing call sites that import from
|
||||||
// `@/actions/water-log/field` keep working.
|
// `@/actions/water-log/field` keep working.
|
||||||
export type { FieldSession };
|
// Note: the re-export was moved to `@/lib/water-log/session-server`
|
||||||
|
// in commit context; consumers that need `FieldSession` as a type
|
||||||
|
// should now import it directly from `@/lib/water-log/session-server`.
|
||||||
|
// This avoids Next.js 16.2's RSC bundler bug that treats type-only
|
||||||
|
// re-exports through server-action wrappers as runtime exports.
|
||||||
|
|
||||||
// Field sessions last 8h — a working day in the field. Long enough
|
// Field sessions last 8h — a working day in the field. Long enough
|
||||||
// that a single sign-in covers a morning shift + afternoon shift.
|
// that a single sign-in covers a morning shift + afternoon shift.
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ function PullQuoteBand() {
|
|||||||
fill
|
fill
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
quality={88}
|
quality={88}
|
||||||
|
priority
|
||||||
className="object-cover -z-10"
|
className="object-cover -z-10"
|
||||||
/>
|
/>
|
||||||
{/* Layered tints: top darkens the sky, bottom grounds the type. */}
|
{/* Layered tints: top darkens the sky, bottom grounds the type. */}
|
||||||
@@ -343,6 +344,7 @@ function FounderStrip() {
|
|||||||
fill
|
fill
|
||||||
sizes="(max-width: 1024px) 100vw, 42vw"
|
sizes="(max-width: 1024px) 100vw, 42vw"
|
||||||
quality={88}
|
quality={88}
|
||||||
|
priority
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
{/* Soft amber wash on the bottom — matches The Corn plate. */}
|
{/* Soft amber wash on the bottom — matches The Corn plate. */}
|
||||||
|
|||||||
Reference in New Issue
Block a user