feat(i18n): harden build artifact tracing for Hostinger deploy

Re-applies PR #182's next-intl scaffold with defensive measures aimed
at the most-likely-but-unconfirmed causes of the 503 that hit prod
after that PR merged. PR #183 reverted #182 to restore service; this
branch carries the same i18n payload plus the safeguards below.

Defensive changes vs PR #182:

1. **`outputFileTracingRoot: __dirname`** in next.config.ts.
   Builds were warning that Next.js inferred the workspace root as
   the parent GHWebhosting directory (because of a stray lockfile
   there). With the wrong root, file tracing for the production
   artifact can include the wrong files or skip ours. Pinning the
   root to the wacrm directory silences the warning and makes tracing
   deterministic across local / CI / Hostinger.

2. **`outputFileTracingIncludes: { "/**/*": ["./messages/**/*.json"] }`**.
   Belt-and-suspenders: explicitly force the i18n message dictionaries
   into the production artifact even if static-import tracing somehow
   misses them in Hostinger's build environment.

3. **Static locale import map in `src/i18n/request.ts`**.
   Replaces `import(\`../../messages/\${locale}.json\`)` with an
   explicit `{ en: () => import(...), es: ..., lt: ... }` map.
   Template-literal dynamic imports are well-supported by Turbopack's
   tracer but the static form leaves zero room for the tracer to
   skip a locale.

4. **Try/catch around the intl middleware call**.
   If `intlMiddleware(request)` ever throws at runtime, fall through
   to auth-only handling and log the error rather than 503-ing every
   page request behind a single bad call.

5. **Try/catch around `LOADERS[locale]()`** with English fallback.
   Same idea: a missing locale bundle at runtime returns English
   content instead of crashing the render path.

6. **`engines.node: ">=20.10.0"`** (was `>=20.0.0`). Next 16.2's
   actual minimum. Tells Hostinger's Node.js app picker not to land
   us on a too-old subversion.

Verified locally:
- npm run lint → 0 errors (same 18 pre-existing warnings)
- npm run typecheck → clean
- npm run test → 366 passing
- npm run build → 62 prerendered pages across en/es/lt; workspace
  root warning gone; messages bundled into `.next/server/chunks/ssr/
  messages_{en,es,lt}_json_[json]_cjs_*.js`
- npm start probes:
    /              → 307 → /en
    /en/login      → 200
    /es/login      → 200
    /lt/login      → 200
    /en/dashboard  → 307 → /en/login (signed out)
    /api/whatsapp/templates → 401

If 503 reappears in prod after this lands, the local repro is clean,
so the next step is hPanel build/runtime logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-31 21:01:13 +03:00
parent 8124640d80
commit 0fa06d8b14
No known key found for this signature in database
5 changed files with 57 additions and 8 deletions

View File

@ -1,3 +1,4 @@
import path from "node:path";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
@ -57,6 +58,20 @@ const SECURITY_HEADERS = [
] as const; ] as const;
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Pin file-tracing to this directory so Next.js doesn't ascend to the
// parent GHWebhosting folder (which has its own lockfile) and either
// bundle the wrong stuff or skip ours. Was visible as a workspace-root
// warning during builds. Also see `outputFileTracingIncludes` below —
// a wrong root makes the includes match incorrectly.
outputFileTracingRoot: path.join(__dirname),
// Force the production artifact to include the i18n message dictionaries.
// `src/i18n/request.ts` loads them via a static import map but the
// tracer can still miss JSON-only files that no code statically
// imports relative to this entry point. Belt-and-suspenders.
outputFileTracingIncludes: {
"/**/*": ["./messages/**/*.json"],
},
/** /**
* Cache-Control policy. * Cache-Control policy.
* *

2
package-lock.json generated
View File

@ -45,7 +45,7 @@
"vitest": "^4.1.7" "vitest": "^4.1.7"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.10.0"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {

View File

@ -25,7 +25,7 @@
"template" "template"
], ],
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.10.0"
}, },
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@ -1,12 +1,35 @@
import { hasLocale } from 'next-intl'; import { hasLocale } from 'next-intl';
import { getRequestConfig } from 'next-intl/server'; import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing'; import { routing, type Locale } from './routing';
// Static import map. Template-literal dynamic imports (e.g.
// `import(\`../../messages/\${locale}.json\`)`) can confuse Next.js's
// file tracer in some build environments — the JSONs may be missing
// from the production artifact even though they resolve in `next dev`
// and `next start` locally. Naming each path explicitly forces every
// supported locale's message file into the bundle.
const LOADERS: Record<Locale, () => Promise<{ default: Record<string, unknown> }>> = {
en: () => import('../../messages/en.json'),
es: () => import('../../messages/es.json'),
lt: () => import('../../messages/lt.json'),
};
export default getRequestConfig(async ({ requestLocale }) => { export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale; const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale; const locale: Locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
const messages = (await import(`../../messages/${locale}.json`)).default; // Fall back to English if the requested locale's bundle is missing
// at runtime. A missing locale should display English content rather
// than 503 the whole render path.
let messages: Record<string, unknown>;
try {
messages = (await LOADERS[locale]()).default;
} catch (err) {
console.error(`[i18n] failed to load messages for locale "${locale}", falling back to en:`, err);
messages = (await LOADERS[routing.defaultLocale]()).default;
}
return { return {
locale, locale,

View File

@ -33,12 +33,23 @@ export async function middleware(request: NextRequest) {
// routes don't live under [locale] and would be wrongly prefixed). // routes don't live under [locale] and would be wrongly prefixed).
// If next-intl redirects (missing locale), return immediately; // If next-intl redirects (missing locale), return immediately;
// the redirected URL re-enters middleware with the prefix in place. // the redirected URL re-enters middleware with the prefix in place.
//
// The intlMiddleware call is wrapped because an unhandled throw
// here would cascade to a 503 in front of every page — we'd
// rather degrade to "no locale routing applied for this request"
// than take the whole site down. Errors are logged so they show
// up in Hostinger's process logs without being swallowed silently.
let intlResponse: NextResponse | null = null let intlResponse: NextResponse | null = null
if (!isApiRoute) { if (!isApiRoute) {
try {
intlResponse = intlMiddleware(request) intlResponse = intlMiddleware(request)
if (intlResponse.headers.get('location')) { if (intlResponse.headers.get('location')) {
return intlResponse return intlResponse
} }
} catch (err) {
console.error('[middleware] intl middleware threw, falling through to auth-only:', err)
intlResponse = null
}
} }
// 2. Run Supabase auth against the locale-stripped pathname so the // 2. Run Supabase auth against the locale-stripped pathname so the