fix(build): keep the server logger out of client bundles
CI / check (push) Successful in 33s
CI / tests-imaging-browsers (push) Successful in 1m50s
CI / tests-integration (push) Successful in 2m28s
CI / tests-unit (push) Successful in 3m6s
CI / tests-quality (push) Successful in 1m40s
CI / tests-ui (push) Successful in 5m56s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m58s

The production webpack build failed with UnhandledSchemeError: client
components imported @/lib/logger, which binds pino, AsyncLocalStorage
(node:async_hooks), node:crypto and node:fs — and validates server env on
import. The UI fixture aliased those modules, so the harness and tests
never hit it.

- add src/lib/browser-log.ts: browser-safe logger with the same interface;
  errors are forwarded to /api/diagnostics/errors via reportBrowserError
- guard src/lib/logger.ts with server-only and drop its now-dead browser
  branches
- switch the 11 client components/hooks to @/lib/browser-log

Verified with the Dockerfile's production build (pnpm run build) and the
full vitest suite.
This commit is contained in:
openhands committed 2026-10-17 17:49:53 +02:00
1 parent fb470123c3
commit c15177a52e
13 files changed
+71 -42

No files matched your search

+1 -1
View File
@@ -45,8 +45,8 @@ import {
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import { logger } from "@/lib/browser-log";
import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache"; import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
import { logger } from "@/lib/logger";
import { parseTraxChannels } from "@/lib/trax-format"; import { parseTraxChannels } from "@/lib/trax-format";
import { TraxPlayer } from "./trax-player"; import { TraxPlayer } from "./trax-player";
+1 -1
View File
@@ -19,7 +19,7 @@ import { Loader2, Play, Square } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/browser-log";
import { TRAX_TICK_SECONDS, type TraxChannel } from "@/lib/trax-format"; import { TRAX_TICK_SECONDS, type TraxChannel } from "@/lib/trax-format";
const TICK_SECONDS = TRAX_TICK_SECONDS; const TICK_SECONDS = TRAX_TICK_SECONDS;
@@ -30,9 +30,9 @@ import {
suggestCatalogLayoutFromBaseItems, suggestCatalogLayoutFromBaseItems,
suggestCategoryName, suggestCategoryName,
} from "@/features/catalog/domain/organize"; } from "@/features/catalog/domain/organize";
import { logger } from "@/lib/browser-log";
import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts"; import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts";
import { getLocalIconUrl } from "@/lib/furni/classname"; import { getLocalIconUrl } from "@/lib/furni/classname";
import { logger } from "@/lib/logger";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LiveCatalogPreview } from "./live-catalog-preview"; import { LiveCatalogPreview } from "./live-catalog-preview";
@@ -2,7 +2,7 @@
import { ChevronRight, Home } from "lucide-react"; import { ChevronRight, Home } from "lucide-react";
import { Fragment, useEffect } from "react"; import { Fragment, useEffect } from "react";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/browser-log";
import { useCatalogManager } from "./use-catalog-store"; import { useCatalogManager } from "./use-catalog-store";
export function BreadcrumbBar({ export function BreadcrumbBar({
@@ -62,7 +62,7 @@ import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-
import { useLiveCatalog } from "@/features/catalog/client/use-live-catalog"; import { useLiveCatalog } from "@/features/catalog/client/use-live-catalog";
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog"; import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
import type { DuplicateInput } from "@/features/catalog/domain/duplicate"; import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/browser-log";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
type FlatTreeNode, type FlatTreeNode,
+1 -1
View File
@@ -11,8 +11,8 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { logger } from "@/lib/browser-log";
import { catalogueIconUrl } from "@/lib/catalog-assets"; import { catalogueIconUrl } from "@/lib/catalog-assets";
import { logger } from "@/lib/logger";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface IconPickerProps { interface IconPickerProps {
@@ -13,8 +13,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { logger } from "@/lib/browser-log";
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname"; import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
import { logger } from "@/lib/logger";
interface FurniResult { interface FurniResult {
id: number; id: number;
+1 -1
View File
@@ -4,8 +4,8 @@ import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { deleteMedia, uploadMedia } from "@/actions/admin-media"; import { deleteMedia, uploadMedia } from "@/actions/admin-media";
import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { logger } from "@/lib/browser-log";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { logger } from "@/lib/logger";
import type { MediaFile } from "@/lib/media-files"; import type { MediaFile } from "@/lib/media-files";
import { loadMediaFiles, validImageUrl } from "@/lib/media-files"; import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
@@ -52,13 +52,13 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog"; import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog";
import { logger } from "@/lib/browser-log";
import { import {
CATALOG_LAYOUTS, CATALOG_LAYOUTS,
type CatalogLayout, type CatalogLayout,
isValidCatalogLayout, isValidCatalogLayout,
LAYOUT_DESCRIPTIONS, LAYOUT_DESCRIPTIONS,
} from "@/lib/catalog-layouts"; } from "@/lib/catalog-layouts";
import { logger } from "@/lib/logger";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MAX_ITEMS_PER_RUN = 500; const MAX_ITEMS_PER_RUN = 500;
+1 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/browser-log";
import { useEventSource } from "@/lib/use-event-source"; import { useEventSource } from "@/lib/use-event-source";
/** /**
+1 -1
View File
@@ -4,7 +4,7 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRef, useState, useTransition } from "react"; import { useRef, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/browser-log";
import type { ActionResult } from "@/lib/safe-action-shared"; import type { ActionResult } from "@/lib/safe-action-shared";
interface UseServerActionOptions { interface UseServerActionOptions {
+53
View File
@@ -0,0 +1,53 @@
import { reportBrowserError } from "./browser-errors";
/**
* Browser counterpart of `@/lib/logger` for client components.
*
* The server logger pulls in pino, AsyncLocalStorage, node:crypto and the
* filesystem, so it must never end up in a client bundle. Client components
* import this module instead — same interface (debug/info/warn/error), but
* console-based. Errors are also forwarded to the diagnostics endpoint so
* they surface in housekeeping, and `error()` returns the error reference id.
*/
function formatMeta(meta: Record<string, unknown> = {}): string {
const keys = Object.keys(meta);
if (keys.length === 0) return "";
return keys
.map((k) => {
const v = meta[k];
return `${k}=${v instanceof Error ? v.message : String(v)}`;
})
.join(" ");
}
const canReport = typeof window !== "undefined";
export const logger = {
debug(message: string, meta: Record<string, unknown> = {}): void {
console.debug(`[debug] ${message} ${formatMeta(meta)}`.trim());
},
info(message: string, meta: Record<string, unknown> = {}): void {
console.info(`[info] ${message} ${formatMeta(meta)}`.trim());
},
warn(message: string, meta: Record<string, unknown> = {}): void {
console.warn(`[warn] ${message} ${formatMeta(meta)}`.trim());
},
error(message: string, meta: Record<string, unknown> = {}): string {
console.error(`[error] ${message} ${formatMeta(meta)}`.trim());
if (!canReport) return "";
const error =
meta.err instanceof Error
? meta.err
: meta.error instanceof Error
? meta.error
: new Error(
typeof meta.err === "string"
? meta.err
: typeof meta.error === "string"
? meta.error
: message,
);
return reportBrowserError(error) ?? "";
},
};
+7 -31
View File
@@ -1,3 +1,4 @@
import "server-only";
import pino from "pino"; import pino from "pino";
import { env } from "@/env"; import { env } from "@/env";
import { captureCmsError } from "./error-monitor"; import { captureCmsError } from "./error-monitor";
@@ -5,9 +6,6 @@ import { getOperationContext } from "./foundation/request-context";
type LogLevel = "debug" | "info" | "warn" | "error"; type LogLevel = "debug" | "info" | "warn" | "error";
const isBrowser =
typeof window !== "undefined" && typeof window.console !== "undefined";
const level: LogLevel = const level: LogLevel =
(env.LOG_LEVEL as LogLevel | undefined) ?? (env.LOG_LEVEL as LogLevel | undefined) ??
(env.NODE_ENV === "production" ? "info" : "debug"); (env.NODE_ENV === "production" ? "info" : "debug");
@@ -35,45 +33,23 @@ export function generateRequestId(): string {
return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`; return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`;
} }
function formatMeta(meta: Record<string, unknown> = {}): string { /**
const keys = Object.keys(meta); * Server-side logger — pino with request context, errors are persisted by the
if (keys.length === 0) return ""; * CMS error monitor. Client components must use `@/lib/browser-log` instead:
return keys * this module is guarded by `server-only` and binds pino, AsyncLocalStorage
.map((k) => { * and the filesystem, so it can never be part of a client bundle.
const v = meta[k]; */
return `${k}=${v instanceof Error ? v.message : String(v)}`;
})
.join(" ");
}
/** Universal logger: pino on server, console in browser. Same interface everywhere. */
export const logger = { export const logger = {
debug(message: string, meta: Record<string, unknown> = {}): void { debug(message: string, meta: Record<string, unknown> = {}): void {
if (isBrowser) {
console.debug(`[debug] ${message} ${formatMeta(meta)}`.trim());
return;
}
pinoLogger.debug({ ...meta, ...getOperationContext() }, message); pinoLogger.debug({ ...meta, ...getOperationContext() }, message);
}, },
info(message: string, meta: Record<string, unknown> = {}): void { info(message: string, meta: Record<string, unknown> = {}): void {
if (isBrowser) {
console.info(`[info] ${message} ${formatMeta(meta)}`.trim());
return;
}
pinoLogger.info({ ...meta, ...getOperationContext() }, message); pinoLogger.info({ ...meta, ...getOperationContext() }, message);
}, },
warn(message: string, meta: Record<string, unknown> = {}): void { warn(message: string, meta: Record<string, unknown> = {}): void {
if (isBrowser) {
console.warn(`[warn] ${message} ${formatMeta(meta)}`.trim());
return;
}
pinoLogger.warn({ ...meta, ...getOperationContext() }, message); pinoLogger.warn({ ...meta, ...getOperationContext() }, message);
}, },
error(message: string, meta: Record<string, unknown> = {}): string { error(message: string, meta: Record<string, unknown> = {}): string {
if (isBrowser) {
console.error(`[error] ${message} ${formatMeta(meta)}`.trim());
return "";
}
const error = const error =
meta.err instanceof Error meta.err instanceof Error
? meta.err ? meta.err