From c15177a52e4de84eb58b979616e2dfce0e34d7e1 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 17 Oct 2026 17:49:53 +0200 Subject: [PATCH] fix(build): keep the server logger out of client bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/admin/sounds/sounds-client.tsx | 2 +- src/app/admin/sounds/trax-player.tsx | 2 +- .../catalog-manager/auto-category-dialog.tsx | 2 +- .../admin/catalog-manager/breadcrumb-bar.tsx | 2 +- .../admin/catalog-manager/sortable-tree.tsx | 2 +- src/components/admin/catalog/icon-picker.tsx | 2 +- .../admin/catalog/quick-add-furni.tsx | 2 +- src/components/admin/media-grid.tsx | 2 +- .../admin/studio/organize-imports-dialog.tsx | 2 +- src/components/public/radio-player.tsx | 2 +- src/hooks/use-server-action.ts | 2 +- src/lib/browser-log.ts | 53 +++++++++++++++++++ src/lib/logger.ts | 38 +++---------- 13 files changed, 71 insertions(+), 42 deletions(-) create mode 100644 src/lib/browser-log.ts diff --git a/src/app/admin/sounds/sounds-client.tsx b/src/app/admin/sounds/sounds-client.tsx index 7ddcd0f5..6695edf6 100644 --- a/src/app/admin/sounds/sounds-client.tsx +++ b/src/app/admin/sounds/sounds-client.tsx @@ -45,8 +45,8 @@ import { import { Textarea } from "@/components/ui/textarea"; import { useServerAction } from "@/hooks/use-server-action"; import { adminFetch } from "@/lib/admin-fetch"; +import { logger } from "@/lib/browser-log"; import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache"; -import { logger } from "@/lib/logger"; import { parseTraxChannels } from "@/lib/trax-format"; import { TraxPlayer } from "./trax-player"; diff --git a/src/app/admin/sounds/trax-player.tsx b/src/app/admin/sounds/trax-player.tsx index 656db8b0..db92e677 100644 --- a/src/app/admin/sounds/trax-player.tsx +++ b/src/app/admin/sounds/trax-player.tsx @@ -19,7 +19,7 @@ import { Loader2, Play, Square } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; 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"; const TICK_SECONDS = TRAX_TICK_SECONDS; diff --git a/src/components/admin/catalog-manager/auto-category-dialog.tsx b/src/components/admin/catalog-manager/auto-category-dialog.tsx index c1e35361..e537f17b 100644 --- a/src/components/admin/catalog-manager/auto-category-dialog.tsx +++ b/src/components/admin/catalog-manager/auto-category-dialog.tsx @@ -30,9 +30,9 @@ import { suggestCatalogLayoutFromBaseItems, suggestCategoryName, } from "@/features/catalog/domain/organize"; +import { logger } from "@/lib/browser-log"; import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts"; import { getLocalIconUrl } from "@/lib/furni/classname"; -import { logger } from "@/lib/logger"; import { cn } from "@/lib/utils"; import { LiveCatalogPreview } from "./live-catalog-preview"; diff --git a/src/components/admin/catalog-manager/breadcrumb-bar.tsx b/src/components/admin/catalog-manager/breadcrumb-bar.tsx index 26d168a2..a20268f7 100644 --- a/src/components/admin/catalog-manager/breadcrumb-bar.tsx +++ b/src/components/admin/catalog-manager/breadcrumb-bar.tsx @@ -2,7 +2,7 @@ import { ChevronRight, Home } from "lucide-react"; import { Fragment, useEffect } from "react"; -import { logger } from "@/lib/logger"; +import { logger } from "@/lib/browser-log"; import { useCatalogManager } from "./use-catalog-store"; export function BreadcrumbBar({ diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index 35f13e20..0cfd489c 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -62,7 +62,7 @@ import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog- import { useLiveCatalog } from "@/features/catalog/client/use-live-catalog"; import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog"; 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 { type FlatTreeNode, diff --git a/src/components/admin/catalog/icon-picker.tsx b/src/components/admin/catalog/icon-picker.tsx index 5b99977e..6d9c71e7 100644 --- a/src/components/admin/catalog/icon-picker.tsx +++ b/src/components/admin/catalog/icon-picker.tsx @@ -11,8 +11,8 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { logger } from "@/lib/browser-log"; import { catalogueIconUrl } from "@/lib/catalog-assets"; -import { logger } from "@/lib/logger"; import { cn } from "@/lib/utils"; interface IconPickerProps { diff --git a/src/components/admin/catalog/quick-add-furni.tsx b/src/components/admin/catalog/quick-add-furni.tsx index 2ddd3cad..6d114449 100644 --- a/src/components/admin/catalog/quick-add-furni.tsx +++ b/src/components/admin/catalog/quick-add-furni.tsx @@ -13,8 +13,8 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { useServerAction } from "@/hooks/use-server-action"; +import { logger } from "@/lib/browser-log"; import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname"; -import { logger } from "@/lib/logger"; interface FurniResult { id: number; diff --git a/src/components/admin/media-grid.tsx b/src/components/admin/media-grid.tsx index 43695a17..fd331d52 100644 --- a/src/components/admin/media-grid.tsx +++ b/src/components/admin/media-grid.tsx @@ -4,8 +4,8 @@ import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { deleteMedia, uploadMedia } from "@/actions/admin-media"; import { useConfirmDialog } from "@/components/admin/confirm-dialog"; +import { logger } from "@/lib/browser-log"; import { formatDate } from "@/lib/format-date"; -import { logger } from "@/lib/logger"; import type { MediaFile } from "@/lib/media-files"; import { loadMediaFiles, validImageUrl } from "@/lib/media-files"; diff --git a/src/components/admin/studio/organize-imports-dialog.tsx b/src/components/admin/studio/organize-imports-dialog.tsx index 2467dd07..53070b45 100644 --- a/src/components/admin/studio/organize-imports-dialog.tsx +++ b/src/components/admin/studio/organize-imports-dialog.tsx @@ -52,13 +52,13 @@ import { SelectValue, } from "@/components/ui/select"; import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog"; +import { logger } from "@/lib/browser-log"; import { CATALOG_LAYOUTS, type CatalogLayout, isValidCatalogLayout, LAYOUT_DESCRIPTIONS, } from "@/lib/catalog-layouts"; -import { logger } from "@/lib/logger"; import { cn } from "@/lib/utils"; const MAX_ITEMS_PER_RUN = 500; diff --git a/src/components/public/radio-player.tsx b/src/components/public/radio-player.tsx index 4d3028e2..b1280e6a 100644 --- a/src/components/public/radio-player.tsx +++ b/src/components/public/radio-player.tsx @@ -1,7 +1,7 @@ "use client"; 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"; /** diff --git a/src/hooks/use-server-action.ts b/src/hooks/use-server-action.ts index daf1de36..c4860f6a 100644 --- a/src/hooks/use-server-action.ts +++ b/src/hooks/use-server-action.ts @@ -4,7 +4,7 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useRef, useState, useTransition } from "react"; import { toast } from "sonner"; -import { logger } from "@/lib/logger"; +import { logger } from "@/lib/browser-log"; import type { ActionResult } from "@/lib/safe-action-shared"; interface UseServerActionOptions { diff --git a/src/lib/browser-log.ts b/src/lib/browser-log.ts new file mode 100644 index 00000000..392a0100 --- /dev/null +++ b/src/lib/browser-log.ts @@ -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 { + 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 = {}): void { + console.debug(`[debug] ${message} ${formatMeta(meta)}`.trim()); + }, + info(message: string, meta: Record = {}): void { + console.info(`[info] ${message} ${formatMeta(meta)}`.trim()); + }, + warn(message: string, meta: Record = {}): void { + console.warn(`[warn] ${message} ${formatMeta(meta)}`.trim()); + }, + error(message: string, meta: Record = {}): 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) ?? ""; + }, +}; diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 78ff9660..4aea4145 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,3 +1,4 @@ +import "server-only"; import pino from "pino"; import { env } from "@/env"; import { captureCmsError } from "./error-monitor"; @@ -5,9 +6,6 @@ import { getOperationContext } from "./foundation/request-context"; type LogLevel = "debug" | "info" | "warn" | "error"; -const isBrowser = - typeof window !== "undefined" && typeof window.console !== "undefined"; - const level: LogLevel = (env.LOG_LEVEL as LogLevel | undefined) ?? (env.NODE_ENV === "production" ? "info" : "debug"); @@ -35,45 +33,23 @@ export function generateRequestId(): string { return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`; } -function formatMeta(meta: Record = {}): 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(" "); -} - -/** Universal logger: pino on server, console in browser. Same interface everywhere. */ +/** + * Server-side logger — pino with request context, errors are persisted by the + * CMS error monitor. Client components must use `@/lib/browser-log` instead: + * this module is guarded by `server-only` and binds pino, AsyncLocalStorage + * and the filesystem, so it can never be part of a client bundle. + */ export const logger = { debug(message: string, meta: Record = {}): void { - if (isBrowser) { - console.debug(`[debug] ${message} ${formatMeta(meta)}`.trim()); - return; - } pinoLogger.debug({ ...meta, ...getOperationContext() }, message); }, info(message: string, meta: Record = {}): void { - if (isBrowser) { - console.info(`[info] ${message} ${formatMeta(meta)}`.trim()); - return; - } pinoLogger.info({ ...meta, ...getOperationContext() }, message); }, warn(message: string, meta: Record = {}): void { - if (isBrowser) { - console.warn(`[warn] ${message} ${formatMeta(meta)}`.trim()); - return; - } pinoLogger.warn({ ...meta, ...getOperationContext() }, message); }, error(message: string, meta: Record = {}): string { - if (isBrowser) { - console.error(`[error] ${message} ${formatMeta(meta)}`.trim()); - return ""; - } const error = meta.err instanceof Error ? meta.err