@dockerforge/core 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dockerforge/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "DockerForge engine: analyse a local project and generate production-grade Dockerfiles, .dockerignore, and Compose, and lint Dockerfiles. Offline, no network.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Docker Forge",
@@ -707,6 +707,36 @@ async function analyseNode(projectPath, files, hints, projectRootPath = null, is
707
707
  // Role
708
708
  const role = await detectRole(projectPath, pkg);
709
709
 
710
+ // Detect which conventional source directories actually exist on disk, so the
711
+ // generator copies real directories instead of guessing the layout (e.g. app/ vs src/).
712
+ const SOURCE_DIR_CANDIDATES = [
713
+ 'src', 'app', 'pages', 'components', 'lib', 'styles',
714
+ 'public', 'server', 'config', 'content', 'i18n', 'locales', 'hooks', 'utils',
715
+ ];
716
+ const sourceDirs = [];
717
+ for (const d of SOURCE_DIR_CANDIDATES) {
718
+ try { if ((await fs.stat(path.join(projectPath, d))).isDirectory()) sourceDirs.push(d); }
719
+ catch { /* absent */ }
720
+ }
721
+
722
+ // Detect framework build/runtime config files that exist, so we copy real filenames
723
+ // instead of guessing a single hard-coded one. Next.js also reads its config at runtime.
724
+ const FRAMEWORK_CONFIG_CANDIDATES = [
725
+ 'next.config.js', 'next.config.mjs', 'next.config.cjs', 'next.config.ts',
726
+ 'tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs', 'tailwind.config.mjs',
727
+ 'components.json', 'jsconfig.json',
728
+ 'remix.config.js', 'svelte.config.js', 'nuxt.config.ts', 'nuxt.config.js',
729
+ ];
730
+ const frameworkConfigFiles = [];
731
+ for (const f of FRAMEWORK_CONFIG_CANDIDATES) {
732
+ try { if ((await fs.stat(path.join(projectPath, f))).isFile()) frameworkConfigFiles.push(f); }
733
+ catch { /* absent */ }
734
+ }
735
+ // The config Next.js reads on `next start` (first match wins) — must be in the runtime image.
736
+ const nextRuntimeConfig = framework === 'nextjs'
737
+ ? (frameworkConfigFiles.find(f => f.startsWith('next.config.')) || null)
738
+ : null;
739
+
710
740
  // Detect sibling directories referenced via require('../xxx') in the entry point.
711
741
  // These are root-level dirs that sit alongside the service dir and must be COPYed
712
742
  // into the image so cross-directory requires resolve at runtime.
@@ -731,6 +761,9 @@ async function analyseNode(projectPath, files, hints, projectRootPath = null, is
731
761
  rootBuildDeps, // root-level dirs imported by vite config via '../dirname' — must be copied before build
732
762
  hasScopedPackages,
733
763
  siblingDeps, // root-level dirs referenced via require('../xxx') — must be COPYed into runtime image
764
+ sourceDirs, // conventional source dirs that actually exist on disk (real, not guessed)
765
+ frameworkConfigFiles, // framework build/runtime config files present (next.config, tailwind.config, ...)
766
+ nextRuntimeConfig, // the next.config.* file Next.js reads at runtime, or null
734
767
  assumptions,
735
768
  };
736
769
  }
@@ -6,8 +6,12 @@ const path = require('path');
6
6
  const { STACKS, BASE_IMAGES } = require('../constants');
7
7
  const { isSecretLikeEnvKey, isSecretLikeEnvValue } = require('../security/security');
8
8
 
9
- const STATIC_RUNTIME_IMAGE = 'nginx:1.27-alpine';
10
9
  const PNPM_VERSION = '9';
10
+ const STATIC_SERVER_PACKAGE = 'serve@14.2.4';
11
+
12
+ // Frameworks that produce a long-running Node server (SSR, API routes, dynamic rendering).
13
+ // These run with `next start` / a framework server instead of a generic static file server.
14
+ const SERVER_RENDERED_FRAMEWORKS = new Set(['nextjs', 'remix', 'sveltekit', 'nuxt']);
11
15
 
12
16
  function installPnpmGlobalCmd() {
13
17
  return `npm install -g pnpm@${PNPM_VERSION}`;
@@ -32,39 +36,20 @@ function runtimeStartCmd(a) {
32
36
  return ['node', `${buildOut}/${entry.replace(/^\.\//, '')}`];
33
37
  }
34
38
 
35
- function nginxStaticServerBlock(port) {
36
- return 'COPY nginx.conf /etc/nginx/conf.d/default.conf';
37
- }
39
+ function staticServeRuntimeStage(baseImage, port, buildAssetCopyLine, outputDir) {
40
+ return `FROM ${baseImage}
38
41
 
39
- function nginxConf(port) {
40
- return `server {
41
- listen ${port};
42
- root /usr/share/nginx/html;
43
- index index.html;
42
+ WORKDIR /app
44
43
 
45
- add_header X-Frame-Options "SAMEORIGIN";
46
- add_header X-Content-Type-Options "nosniff";
47
- add_header Referrer-Policy "strict-origin-when-cross-origin";
44
+ ENV NODE_ENV=production
48
45
 
49
- location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
50
- expires 1y;
51
- add_header Cache-Control "public, immutable";
52
- }
46
+ RUN npm install -g ${STATIC_SERVER_PACKAGE} && npm cache clean --force
47
+ ${buildAssetCopyLine}
48
+ USER node
53
49
 
54
- location /health { return 200 "ok"; add_header Content-Type text/plain; }
55
- location / { try_files $uri $uri/ /index.html; }
56
-
57
- gzip on;
58
- gzip_types text/plain text/css application/json application/javascript
59
- text/xml application/xml image/svg+xml font/woff2 application/manifest+json;
60
- }`.trim();
61
- }
62
-
63
- function nginxNonRootPrepBlock() {
64
- return [
65
- 'RUN mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp /var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp /var/cache/nginx/scgi_temp',
66
- 'RUN touch /var/run/nginx.pid && chown -R nginx:nginx /var/cache/nginx /var/run/nginx.pid /var/log/nginx /etc/nginx/conf.d /usr/share/nginx/html',
67
- ].join('\n');
50
+ EXPOSE ${port}
51
+ ${simpleHttpHealthcheck(port)}
52
+ CMD ["serve", "-s", "${outputDir}", "-l", "${port}"]`;
68
53
  }
69
54
 
70
55
  function simpleHttpHealthcheck(port) {
@@ -189,6 +174,22 @@ function nodeSourceCopyBlock(a, configCopyLine = '', frontendAssetCopyLine = '')
189
174
  return blocks.join('\n');
190
175
  }
191
176
 
177
+ // Build-stage source copy for Next.js. Copies the config files and source directories that
178
+ // actually exist on disk (detected by the analyser) instead of guessing app/ vs src/.
179
+ function nextSourceCopyBlock(a, rootConfigFiles = []) {
180
+ const lines = [];
181
+ const configFiles = [...new Set([
182
+ ...rootConfigFiles.filter(f => !['package.json', a.lockFile].includes(f)),
183
+ ...(a.frameworkConfigFiles || []),
184
+ ])];
185
+ if (configFiles.length > 0) lines.push(`COPY ${configFiles.join(' ')} ./`);
186
+ const dirs = (Array.isArray(a.sourceDirs) && a.sourceDirs.length > 0)
187
+ ? a.sourceDirs
188
+ : ['app', 'pages', 'components', 'public']; // fallback only if nothing was detected
189
+ for (const d of dirs) lines.push(`COPY ${d}/ ./${d}/`);
190
+ return lines.join('\n');
191
+ }
192
+
192
193
  function pythonBuilderImage(a) {
193
194
  return a.hasNativeDeps ? `python:${a.version}` : BASE_IMAGES[STACKS.PYTHON](a.version);
194
195
  }
@@ -273,6 +274,9 @@ function buildEnvBlock(envVars) {
273
274
  }
274
275
 
275
276
  function buildCommandPrefix(service) {
277
+ // Server-rendered frameworks (Next.js, Remix, ...) run their own build; the CRA-specific
278
+ // env prefix (DISABLE_ESLINT_PLUGIN etc.) does not apply and just adds noise.
279
+ if (SERVER_RENDERED_FRAMEWORKS.has(service.framework)) return '';
276
280
  // Fire for CRA regardless of how detection landed — framework='cra', role='frontend',
277
281
  // or any build command that invokes react-scripts directly.
278
282
  const isReactScripts =
@@ -668,8 +672,7 @@ ENTRYPOINT ["dotnet", "${be.projectName}.dll"]`.trim());
668
672
  }
669
673
  improvements.push('HEALTHCHECK should probe /health for each runtime service; add a /health endpoint returning 2xx where missing');
670
674
 
671
- const collectedNginxConf = frontends.length > 0 ? nginxConf(frontends[0].port) : undefined;
672
- return { dockerfile, dockerignore, improvements, nginxConf: collectedNginxConf };
675
+ return { dockerfile, dockerignore, improvements };
673
676
  }
674
677
 
675
678
  // ── Node at root (original behaviour) ───────────────────────────────────────
@@ -690,10 +693,18 @@ function generateNodeRoot(a, envVars = [], rootConfigFiles = []) {
690
693
  const buildOut = a.buildOutputDir || 'dist';
691
694
  const sourceCopyBlock = nodeSourceCopyBlock(a, configCopyLine, frontendAssetCopyLine);
692
695
 
693
- // CRA / pure-frontend: runtime is a static file server, not a Node app.
694
- // react-scripts is a devDep it won't be present after --production install,
695
- // and react-scripts start is a dev server anyway.
696
- if (a.role === 'frontend' || a.framework === 'cra') {
696
+ // Next.js (and other SSR frameworks): a Node server, NOT a static site.
697
+ // Build with `next build`, then run `next start` on a lean Node runtime. Serving the
698
+ // .next output via a generic static server would break SSR, API routes, and dynamic rendering.
699
+ if (a.framework === 'nextjs') {
700
+ const prodInstall = nodeInstallLine(a.packageManager, true, { hasScopedPackages: a.hasScopedPackages });
701
+ const nextSrcCopy = nextSourceCopyBlock(a, rootConfigFiles);
702
+ const hasPublic = Array.isArray(a.sourceDirs) && a.sourceDirs.includes('public');
703
+ const publicCopy = hasPublic ? `COPY --from=builder /app/public ./public\n` : '';
704
+ const runtimeConfigCopy = a.nextRuntimeConfig
705
+ ? `COPY --from=builder /app/${a.nextRuntimeConfig} ./${a.nextRuntimeConfig}\n`
706
+ : '';
707
+
697
708
  dockerfile = `
698
709
  # ─── Stage 1: Build ───────────────────────────────────────
699
710
  ${builderFrom(baseImage, 'builder')}
@@ -703,23 +714,62 @@ WORKDIR /app
703
714
  COPY ${a.lockFile} package.json ./
704
715
  ${buildInstall}
705
716
 
706
- # Copy source
707
- ${sourceCopyBlock}
717
+ # Copy source and build-time config
718
+ ${nextSrcCopy}
708
719
  RUN ${buildPrefix}${a.buildCommand}
709
720
 
710
721
  # ─── Stage 2: Runtime ─────────────────────────────────────
711
- FROM ${STATIC_RUNTIME_IMAGE}
722
+ FROM ${baseImage}
723
+
724
+ WORKDIR /app
725
+
726
+ ${buildEnvBlock(envVars)}
712
727
 
713
- COPY --from=builder /app/${buildOut} /usr/share/nginx/html
714
- ${nginxStaticServerBlock(a.port)}
728
+ COPY ${a.lockFile} package.json ./
729
+ ${prodInstall}
730
+
731
+ # Next.js runs as a Node server; copy the build output, static assets, and the config it reads at runtime
732
+ COPY --from=builder /app/${buildOut} ./${buildOut}
733
+ ${publicCopy}${runtimeConfigCopy}${runtimeUserBlock(true)}
715
734
 
735
+ ${nodeStopSignalBlock()}
716
736
  EXPOSE ${a.port}
717
737
  ${simpleHttpHealthcheck(a.port)}
718
- CMD ["nginx", "-g", "daemon off;"]`.trim();
738
+ CMD ${startCmdJson}`.trim();
739
+
740
+ improvements.push('Next.js is built and run as a Node server (next start) so SSR and API routes work — a generic static server image would break them.');
741
+ improvements.push('Smaller image option: set `output: "standalone"` in next.config, then copy .next/standalone + .next/static instead of installing node_modules.');
742
+ improvements.push('Only known framework config files are auto-copied — verify any project-specific build config is also COPYed (e.g. sentry.*.config.ts, next-i18next.config).');
743
+ improvements.push('HEALTHCHECK probes /health, which Next.js does not expose by default — add a health route or point the healthcheck at an existing path.');
744
+ return { dockerfile, dockerignore: nodeDockerignore(), improvements };
745
+ }
746
+
747
+ // CRA / pure-frontend SPA: runtime is a static file server, not a Node app.
748
+ // react-scripts is a devDep — it won't be present after --production install,
749
+ // and react-scripts start is a dev server anyway. Server-rendered frameworks are
750
+ // explicitly excluded so they never get the static-file-server treatment.
751
+ const isStaticSpa = (a.role === 'frontend' || a.framework === 'cra')
752
+ && !SERVER_RENDERED_FRAMEWORKS.has(a.framework);
753
+ if (isStaticSpa) {
754
+ dockerfile = `
755
+ # ─── Stage 1: Build ───────────────────────────────────────
756
+ ${builderFrom(baseImage, 'builder')}
757
+
758
+ WORKDIR /app
759
+
760
+ COPY ${a.lockFile} package.json ./
761
+ ${buildInstall}
762
+
763
+ # Copy source
764
+ ${sourceCopyBlock}
765
+ RUN ${buildPrefix}${a.buildCommand}
766
+
767
+ # ─── Stage 2: Runtime ─────────────────────────────────────
768
+ ${staticServeRuntimeStage(baseImage, a.port, `COPY --from=builder /app/${buildOut} ./${buildOut}`, buildOut)}`.trim();
719
769
 
720
770
  const validationDockerfile = dockerfile;
721
- improvements.push(`Frontend-only: built then served with ${STATIC_RUNTIME_IMAGE}. No Node runtime or node_modules in final image.`);
722
- return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), nginxConf: nginxConf(a.port), improvements }, validationDockerfile);
771
+ improvements.push(`Frontend-only: built then served with ${STATIC_SERVER_PACKAGE} as the non-root node user.`);
772
+ return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), improvements }, validationDockerfile);
723
773
 
724
774
  } else {
725
775
  const pruneCmd = nodePruneLine(a.packageManager);
@@ -807,8 +857,9 @@ function generateNodeSub(a, dir, sharedDirs, staticAssets = [], rootConfigFiles
807
857
 
808
858
  let dockerfile;
809
859
 
810
- if (a.hasBuild && a.role === 'frontend') {
811
- // Frontend build → serve static via nginx
860
+ if (a.hasBuild && a.role === 'frontend' && !SERVER_RENDERED_FRAMEWORKS.has(a.framework)) {
861
+ // Static SPA build → serve the compiled output. Server-rendered frameworks (Next.js, Remix, ...)
862
+ // are excluded here and fall through to the Node-server runtime branch below.
812
863
  const stageName = 'build';
813
864
 
814
865
  // When lock file is at project root, copy root files + service package.json and
@@ -883,18 +934,11 @@ ${sourceCopy}
883
934
  ${sharedConfigCopy}${buildRun}
884
935
 
885
936
  # ─── Stage 2: Serve ───────────────────────────────────────
886
- FROM ${STATIC_RUNTIME_IMAGE}
887
-
888
- COPY --from=${stageName} ${buildOutput} /usr/share/nginx/html
889
- ${nginxStaticServerBlock(a.port)}
890
-
891
- EXPOSE ${a.port}
892
- ${simpleHttpHealthcheck(a.port)}
893
- CMD ["nginx", "-g", "daemon off;"]`.trim();
937
+ ${staticServeRuntimeStage(baseImage, a.port, `COPY --from=${stageName} ${buildOutput} ./${a.buildOutputDir || 'build'}`, a.buildOutputDir || 'build')}`.trim();
894
938
 
895
939
  const validationDockerfile = dockerfile;
896
- improvements.push(`Frontend-only: built then served with ${STATIC_RUNTIME_IMAGE}. If a backend also serves the files, merge into one multi-service build.`);
897
- return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), nginxConf: nginxConf(a.port), improvements }, validationDockerfile);
940
+ improvements.push(`Frontend-only: built then served with ${STATIC_SERVER_PACKAGE} as the non-root node user. If a backend also serves the files, merge into one multi-service build.`);
941
+ return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), improvements }, validationDockerfile);
898
942
 
899
943
 
900
944
  } else {
@@ -132,7 +132,7 @@ function applyValidationEvidence(confidenceResult, validation) {
132
132
 
133
133
  /**
134
134
  * Strip BuildKit-specific features from a Dockerfile to produce the simple default output.
135
- * The full version (with mounts, CRA flags, etc.) becomes powerDockerfile.
135
+ * Build environment prefixes stay in both outputs because they are part of the recipe.
136
136
  */
137
137
  function toSimpleDockerfile(dockerfile) {
138
138
  return dockerfile.split('\n').map(line => {
@@ -140,11 +140,6 @@ function toSimpleDockerfile(dockerfile) {
140
140
  if (!isRun) return line;
141
141
  // Strip --mount=type=cache,... and --mount=type=secret,...
142
142
  line = line.replace(/--mount=type=\w+,\S+\s+/g, '');
143
- // Strip CRA build environment prefix (CI=false GENERATE_SOURCEMAP=false ...)
144
- line = line.replace(
145
- /^(RUN\s+)CI=false GENERATE_SOURCEMAP=false NODE_OPTIONS=--max-old-space-size=\d+ DISABLE_ESLINT_PLUGIN=true\s+/,
146
- '$1'
147
- );
148
143
  return line;
149
144
  }).join('\n');
150
145
  }