From d2fec48bb1b25131aa42eef428f0437118bc9770 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Fri, 9 Oct 2026 11:08:29 +0200 Subject: [PATCH 1/3] fix(umami): restore /stats rewrite dropped during env-agnostic refactor The async rewrites() block was removed when the compile was made environment-agnostic, but it reads process.env dynamically at server startup (not build time) so it never leaks into the bundle. Without it browser-side /stats/api/send requests hit a missing route, Next.js tries to proxy them to localhost:3002, and ECONNREFUSED errors flood the server log in CI and any environment where Umami is unreachable. Restored with a runtime guard (no-op when NEXT_PUBLIC_UMAMI_URL is unset) and a comment explaining why the dynamic read is safe here. --- next.config.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/next.config.ts b/next.config.ts index b4ba7ca9..574acea8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -274,6 +274,20 @@ export default async (phase, { defaultConfig }) => { // return config // }, + async rewrites() { + // Proxy browser-side Umami analytics requests through /stats so the + // outgoing host is the site's own domain rather than the Umami server, + // keeping it out of ad-blocker filter lists. Only wired when the URL is + // set — if it isn't, the route simply doesn't exist and the client's + // sendUmamiPayload calls fail silently. + // Read dynamically (not NEXT_PUBLIC_* static ref) so one compiled output + // works across environments. This function runs at server startup, not at + // build time, so the env value is always fresh. + const umamiUrl = process.env['NEXT_PUBLIC_UMAMI_URL'] + if (!umamiUrl) return [] + return [{ source: '/stats/:match*', destination: `${umamiUrl}/:match*` }] + }, + async headers() { return [ { From 5d954e19c367eb547b0c62e222cff08644e6375c Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Fri, 9 Oct 2026 11:32:14 +0200 Subject: [PATCH 2/3] fix(umami): replace rewrites() proxy with a route handler to avoid env-leak next.config.ts rewrites() embeds the destination URL into routes-manifest.json at build time, which causes check-env-leak to flag NEXT_PUBLIC_UMAMI_URL in the compiled output. Replace the rewrite with a /stats/[...path] route handler that reads the Umami URL at request time instead. --- app/(frontend)/stats/[...path]/route.ts | 40 +++++++++++++++++++++++++ next.config.ts | 14 --------- src/lib/umami/sendUmamiPayload.ts | 4 +-- 3 files changed, 42 insertions(+), 16 deletions(-) create mode 100644 app/(frontend)/stats/[...path]/route.ts diff --git a/app/(frontend)/stats/[...path]/route.ts b/app/(frontend)/stats/[...path]/route.ts new file mode 100644 index 00000000..e46d5420 --- /dev/null +++ b/app/(frontend)/stats/[...path]/route.ts @@ -0,0 +1,40 @@ +import { type NextRequest, NextResponse } from 'next/server' + +// Proxy browser-side Umami analytics requests through /stats so the +// outgoing host is the site's own domain rather than the Umami server, +// keeping it out of ad-blocker filter lists that target known analytics +// hosts/paths. +// +// The Umami URL is read here at request time (not at build time), so one +// compiled output works across environments without embedding the value +// into routes-manifest.json (which would trigger check-env-leak in CI). +// `sendUmamiPayload.ts` routes browser requests to /stats/api/send. + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +): Promise { + const umamiUrl = process.env['NEXT_PUBLIC_UMAMI_URL'] + if (!umamiUrl) { + return new NextResponse(null, { status: 204 }) + } + + const { path } = await params + const destination = `${umamiUrl}/${path.join('/')}` + + const headers = new Headers(req.headers) + headers.delete('host') + + const upstream = await fetch(destination, { + method: 'POST', + headers, + body: req.body, + // @ts-ignore -- duplex is required for streaming request bodies in Node fetch + duplex: 'half', + }) + + return new NextResponse(upstream.body, { + status: upstream.status, + headers: upstream.headers, + }) +} diff --git a/next.config.ts b/next.config.ts index 574acea8..b4ba7ca9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -274,20 +274,6 @@ export default async (phase, { defaultConfig }) => { // return config // }, - async rewrites() { - // Proxy browser-side Umami analytics requests through /stats so the - // outgoing host is the site's own domain rather than the Umami server, - // keeping it out of ad-blocker filter lists. Only wired when the URL is - // set — if it isn't, the route simply doesn't exist and the client's - // sendUmamiPayload calls fail silently. - // Read dynamically (not NEXT_PUBLIC_* static ref) so one compiled output - // works across environments. This function runs at server startup, not at - // build time, so the env value is always fresh. - const umamiUrl = process.env['NEXT_PUBLIC_UMAMI_URL'] - if (!umamiUrl) return [] - return [{ source: '/stats/:match*', destination: `${umamiUrl}/:match*` }] - }, - async headers() { return [ { diff --git a/src/lib/umami/sendUmamiPayload.ts b/src/lib/umami/sendUmamiPayload.ts index 02ac4b27..c54bb094 100644 --- a/src/lib/umami/sendUmamiPayload.ts +++ b/src/lib/umami/sendUmamiPayload.ts @@ -39,8 +39,8 @@ const sendViaFetch = async (url: string, body: string, keepalive: boolean) => { * `console.debug` so tracking issues never break the calling code. */ export const sendUmamiPayload = async (payload: UmamiSendPayload): Promise => { - // In the browser, route through the same-origin `/stats` rewrite (see - // next.config.ts) so the request doesn't visibly hit the Umami domain, + // In the browser, route through the same-origin `/stats` proxy route (see + // app/(frontend)/stats/[...path]/route.ts) so the request doesn't visibly hit the Umami domain, // which keeps it out of ad-blocker filter lists that target known // analytics hosts/paths. On the server there is no origin to resolve a // relative URL against and no rewrite applies to a raw server fetch, so From 757f8cc792137a565005068138bc59292ed9aa38 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Fri, 9 Oct 2026 11:38:39 +0200 Subject: [PATCH 3/3] fix(umami): read Umami URL via readRuntimeConfigFromEnv to avoid bundler inlining process.env['NEXT_PUBLIC_UMAMI_URL'] in a server route file is still inlined by the Next.js bundler into server chunks, triggering check-env-leak. Switch to readRuntimeConfigFromEnv().umamiUrl which uses the bracket-notation readEnv() helper that the bundler does not statically inline. --- app/(frontend)/stats/[...path]/route.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/(frontend)/stats/[...path]/route.ts b/app/(frontend)/stats/[...path]/route.ts index e46d5420..4a58027a 100644 --- a/app/(frontend)/stats/[...path]/route.ts +++ b/app/(frontend)/stats/[...path]/route.ts @@ -1,20 +1,23 @@ import { type NextRequest, NextResponse } from 'next/server' +import { readRuntimeConfigFromEnv } from '@/lib/runtimeConfig' + // Proxy browser-side Umami analytics requests through /stats so the // outgoing host is the site's own domain rather than the Umami server, // keeping it out of ad-blocker filter lists that target known analytics // hosts/paths. // -// The Umami URL is read here at request time (not at build time), so one -// compiled output works across environments without embedding the value -// into routes-manifest.json (which would trigger check-env-leak in CI). +// The Umami URL is read via readRuntimeConfigFromEnv() (bracket-notation +// process.env lookup) so the bundler never inlines the value at build time, +// which keeps one compiled output deployable across environments without +// triggering check-env-leak in CI. // `sendUmamiPayload.ts` routes browser requests to /stats/api/send. export async function POST( req: NextRequest, { params }: { params: Promise<{ path: string[] }> }, ): Promise { - const umamiUrl = process.env['NEXT_PUBLIC_UMAMI_URL'] + const umamiUrl = readRuntimeConfigFromEnv().umamiUrl if (!umamiUrl) { return new NextResponse(null, { status: 204 }) }