From 42baaf7bfcc44ff25050d61cc1f1922fb3dccee0 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 3 Jun 2026 00:03:11 +0200 Subject: [PATCH] fix(docker): complete prod node_modules for the custom server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the NODE_PATH attempt, which fixed 'accepts' but not the general case: server-custom.js is CJS (esbuild --packages=external) and require()s deps the Next standalone trace ships ESM-only or omits, e.g. drizzle-orm/index.cjs (present-but-incomplete in the traced tree, so a NODE_PATH fallback can't rescue it). Replace the traced node_modules with the complete hoisted prod tree so every external resolves. That tree is prod-only, so move @next/bundle-analyzer (required at runtime by next.config — its import is unconditional even though enabled is gated on ANALYZE) from devDependencies to dependencies; otherwise the standalone config load throws MODULE_NOT_FOUND in prod. Validated end-to-end on a host prod install + standalone assembly: socket server boots, Socket.io initializes, HTTP listens, /api/health → 200, no MODULE_NOT_FOUND, no AsyncLocalStorage invariant. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 30 +++++++++++++++--------------- package.json | 2 +- pnpm-lock.yaml | 6 +++--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Dockerfile b/Dockerfile index 93b8714c..00282f87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,21 +41,21 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/dist/server.js ./server-custom.js -# The custom socket.io server (server-custom.js, built with esbuild -# --packages=external) resolves socket.io and its FULL transitive closure -# (engine.io → accepts/ws/cors, @socket.io/redis-adapter, ...) from -# node_modules at runtime. The Next tracer omits these from -# .next/standalone because no Next route imports the socket server -# (→ MODULE_NOT_FOUND 'accepts'). Stage the complete hoisted prod tree in -# a SEPARATE dir on NODE_PATH rather than touching the standalone -# node_modules: overlaying real dirs onto its pnpm symlinks (e.g. -# @react-pdf/renderer) fails the COPY, and replacing it wholesale swaps -# out the standalone-tuned `next` and breaks Next's runtime -# (AsyncLocalStorage invariant). NODE_PATH is searched AFTER the local -# walk, so Next still resolves its own deps from ./node_modules; only the -# socket server's otherwise-missing deps fall through to here. -COPY --from=prod-deps --chown=nextjs:nodejs /app/node_modules ./_server_deps -ENV NODE_PATH=/app/_server_deps +# server-custom.js is CJS (esbuild --packages=external) and require()s its +# deps at runtime — socket.io's full closure (engine.io→accepts/ws/cors), +# drizzle-orm's CJS entry (index.cjs), zod, etc. The Next standalone trace +# builds node_modules for the APP's ESM imports, so it omits the socket +# server's deps entirely (MODULE_NOT_FOUND 'accepts') AND ships ESM-only +# entries for shared packages (drizzle-orm/index.cjs missing). A NODE_PATH +# fallback can't fix the latter — Node finds the incomplete package in the +# standalone tree and errors instead of falling through. So replace the +# traced node_modules with the complete hoisted prod tree: every external +# the custom server requires resolves. Next's standalone .next runs fine +# on the full `next` package (same version, superset of the trace); the +# one thing the standalone bootstrap would set — globalThis.AsyncLocalStorage +# — is handled up-front by src/server-runtime-preamble.ts. +RUN rm -rf ./node_modules +COPY --from=prod-deps --chown=nextjs:nodejs /app/node_modules ./node_modules USER nextjs EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ diff --git a/package.json b/package.json index db587922..8a3b444b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@dnd-kit/utilities": "^3.2.2", "@formkit/auto-animate": "^0.9.0", "@hookform/resolvers": "^5.2.2", + "@next/bundle-analyzer": "^16.2.6", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", @@ -140,7 +141,6 @@ "@axe-core/playwright": "^4.11.3", "@faker-js/faker": "^10.4.0", "@hookform/devtools": "^4.4.0", - "@next/bundle-analyzer": "^16.2.6", "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.3.0", "@total-typescript/ts-reset": "^0.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80b55480..dff8ddc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.75.0(react@19.2.6)) + '@next/bundle-analyzer': + specifier: ^16.2.6 + version: 16.2.6 '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -332,9 +335,6 @@ importers: '@hookform/devtools': specifier: ^4.4.0 version: 4.4.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@next/bundle-analyzer': - specifier: ^16.2.6 - version: 16.2.6 '@playwright/test': specifier: ^1.60.0 version: 1.60.0