fix(web): lazy-parse env so docker build doesn't crash on missing DATABASE_URL

`scripts/publish.sh` failed during the web image build at
"Collecting page data" with:
  ZodError: DATABASE_URL: Required

next build walks every route module including api/events/route.ts,
which imports env from @/env. The previous shape ran
envSchema.parse(process.env) at module top level, so the parse fired
inside the build container where DATABASE_URL deliberately isn't set.

Wrap the parse in a Proxy that resolves on first property access.
The build's page-data pass doesn't read any env property, so the
parse never runs at build time. Runtime callers (db.ts, media.ts,
api/events/route.ts) hit the proxy on first use and get the same
strict Zod validation as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 22:13:30 +08:00
parent 49f5c16b19
commit 6893ca6ba9
2 changed files with 72 additions and 1 deletions

View File

@ -8,4 +8,25 @@ const envSchema = z.object({
});
export type Env = z.infer<typeof envSchema>;
export const env = envSchema.parse(process.env);
// Lazy parse via Proxy. Next.js's `next build` does a
// "Collecting page data" pass that imports every route module —
// including api/events/route.ts which depends on this env. With a
// top-level `envSchema.parse(process.env)` the parse ran during
// the build container, where DATABASE_URL isn't (and shouldn't be)
// set, and Zod aborted the build with:
// ZodError: DATABASE_URL: Required
// Deferring the parse until first property access lets the build
// finish (no consumer accesses env during page-data collection)
// while still failing loudly at runtime if the var is missing.
let cached: Env | null = null;
function read(): Env {
if (cached) return cached;
cached = envSchema.parse(process.env);
return cached;
}
export const env: Env = new Proxy({} as Env, {
get(_t, prop) {
return read()[prop as keyof Env];
},
}) as Env;

50
envs/ENV Normal file
View File

@ -0,0 +1,50 @@
# === Postgres ===
DATABASE_URL=postgres://waBot:cJe3SGjHHAitNBE4@192.168.0.210:5432/wabot
# === App data paths (inside containers) ===
DATA_DIR=/data
SESSIONS_DIR=/data/sessions
MEDIA_DIR=/data/media
# === Bot service ===
BOT_HEALTH_PORT=8081
BOT_LOG_LEVEL=info
# Reminder fan-out tuning. Defaults aim for an established WhatsApp
# account (~30-60 msg/min safe band). Bump cautiously.
# BOT_FIRE_CONCURRENCY pg-boss workers; max accounts firing in parallel.
# BOT_GROUP_CONCURRENCY per-account parallel group sends; parts within a
# group stay serial.
# BOT_MAX_SEND_PER_MINUTE per-account token-bucket rate.
BOT_FIRE_CONCURRENCY=8
BOT_GROUP_CONCURRENCY=3
BOT_MAX_SEND_PER_MINUTE=40
# === Seed (used by scripts/db.sh seed) ===
# The bootstrap operator's username. After seed, set their password
# via: echo 'change-me-now' | scripts/set-password.sh admin
SEED_OPERATOR_USERNAME=admin
SEED_OPERATOR_NAME=Operator
# === Web / Auth ===
# Port the Next.js container exposes on the host. Production deployment
# (wabot.04080616.xyz) uses 8100; dev/staging (test.04080616.xyz) uses 9000.
WEB_PORT=8100
# 32-byte secret used to derive the AES-256-GCM key for session cookies.
# DO NOT leave blank — the web container will refuse to issue cookies.
# Generate via: scripts/gen_auth_secret.sh --write
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c
# Bumping this invalidates every outstanding session cookie globally on
# the next request. Treat it as a kill switch (e.g. after a key leak)
# rather than a routine value.
OPERATOR_TOKEN_VERSION=1
# === Docker Registry (used by scripts/publish.sh) ===
# Tag pushed alongside latest. Override with the CLI arg or
# DOCKER_IMAGE_TAG=v1.2.3 scripts/publish.sh.
DOCKER_IMAGE_TAG=latest
# Buildx target platforms. linux/amd64 is the prod host arch; add
# linux/arm64 if you cross-build for an Apple-silicon runner.
CM_IMAGE_PLATFORMS=linux/amd64