@dockerforge/core 0.1.1 → 0.1.3

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.3",
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,28 @@ 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 nextSrcCopy = nextSourceCopyBlock(a, rootConfigFiles);
701
+ const hasPublic = Array.isArray(a.sourceDirs) && a.sourceDirs.includes('public');
702
+ const publicCopy = hasPublic ? `COPY --from=builder /app/public ./public\n` : '';
703
+ const runtimeConfigCopy = a.nextRuntimeConfig
704
+ ? `COPY --from=builder /app/${a.nextRuntimeConfig} ./${a.nextRuntimeConfig}\n`
705
+ : '';
706
+ // Prune dev deps to production IN THE BUILDER, then copy node_modules into the runtime
707
+ // stage. This avoids a second install at runtime — which would re-hit the registry (and,
708
+ // for private/scoped packages, need the npmrc secret a second time). The builder already
709
+ // fetched and authenticated everything; pruning just drops the dev-only packages.
710
+ const secretMount = a.hasScopedPackages ? '--mount=type=secret,id=npmrc,target=/root/.npmrc ' : '';
711
+ const prodPrune =
712
+ a.packageManager === 'pnpm' ? 'RUN pnpm prune --prod'
713
+ : a.packageManager === 'yarn' ? `RUN ${secretMount}yarn install --frozen-lockfile --production --ignore-scripts && yarn cache clean`
714
+ : 'RUN npm prune --omit=dev';
715
+ // node images ship npm + yarn, but not pnpm — install it in the runtime stage if needed.
716
+ const runtimePmSetup = a.packageManager === 'pnpm' ? `RUN ${installPnpmGlobalCmd()}\n` : '';
717
+
697
718
  dockerfile = `
698
719
  # ─── Stage 1: Build ───────────────────────────────────────
699
720
  ${builderFrom(baseImage, 'builder')}
@@ -703,23 +724,68 @@ WORKDIR /app
703
724
  COPY ${a.lockFile} package.json ./
704
725
  ${buildInstall}
705
726
 
706
- # Copy source
707
- ${sourceCopyBlock}
727
+ # Copy source and build-time config
728
+ ${nextSrcCopy}
708
729
  RUN ${buildPrefix}${a.buildCommand}
709
730
 
731
+ # Drop dev dependencies so only production node_modules carry into the runtime image
732
+ ${prodPrune}
733
+
710
734
  # ─── Stage 2: Runtime ─────────────────────────────────────
711
- FROM ${STATIC_RUNTIME_IMAGE}
735
+ FROM ${baseImage}
712
736
 
713
- COPY --from=builder /app/${buildOut} /usr/share/nginx/html
714
- ${nginxStaticServerBlock(a.port)}
737
+ WORKDIR /app
738
+
739
+ ${buildEnvBlock(envVars)}
740
+
741
+ # Next.js runs as a Node server. Copy production deps + build output from the builder —
742
+ # no second install here, so the (often private) registry is only contacted once, in the builder.
743
+ ${runtimePmSetup}COPY --from=builder /app/package.json ./package.json
744
+ COPY --from=builder /app/node_modules ./node_modules
745
+ COPY --from=builder /app/${buildOut} ./${buildOut}
746
+ ${publicCopy}${runtimeConfigCopy}${runtimeUserBlock(true)}
715
747
 
748
+ ${nodeStopSignalBlock()}
716
749
  EXPOSE ${a.port}
717
750
  ${simpleHttpHealthcheck(a.port)}
718
- CMD ["nginx", "-g", "daemon off;"]`.trim();
751
+ CMD ${startCmdJson}`.trim();
752
+
753
+ 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.');
754
+ improvements.push('Production node_modules are copied from the build stage (deps fetched once). For an even smaller image, set `output: "standalone"` in next.config and copy .next/standalone + .next/static.');
755
+ 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).');
756
+ if (a.hasScopedPackages) {
757
+ improvements.push('Scoped packages detected (may include a private registry): the install keeps a secret mount — build with `docker build --secret id=npmrc,src=.npmrc .` and a .npmrc that authenticates your scope.');
758
+ }
759
+ 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.');
760
+ return { dockerfile, dockerignore: nodeDockerignore(), improvements };
761
+ }
762
+
763
+ // CRA / pure-frontend SPA: runtime is a static file server, not a Node app.
764
+ // react-scripts is a devDep — it won't be present after --production install,
765
+ // and react-scripts start is a dev server anyway. Server-rendered frameworks are
766
+ // explicitly excluded so they never get the static-file-server treatment.
767
+ const isStaticSpa = (a.role === 'frontend' || a.framework === 'cra')
768
+ && !SERVER_RENDERED_FRAMEWORKS.has(a.framework);
769
+ if (isStaticSpa) {
770
+ dockerfile = `
771
+ # ─── Stage 1: Build ───────────────────────────────────────
772
+ ${builderFrom(baseImage, 'builder')}
773
+
774
+ WORKDIR /app
775
+
776
+ COPY ${a.lockFile} package.json ./
777
+ ${buildInstall}
778
+
779
+ # Copy source
780
+ ${sourceCopyBlock}
781
+ RUN ${buildPrefix}${a.buildCommand}
782
+
783
+ # ─── Stage 2: Runtime ─────────────────────────────────────
784
+ ${staticServeRuntimeStage(baseImage, a.port, `COPY --from=builder /app/${buildOut} ./${buildOut}`, buildOut)}`.trim();
719
785
 
720
786
  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);
787
+ improvements.push(`Frontend-only: built then served with ${STATIC_SERVER_PACKAGE} as the non-root node user.`);
788
+ return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), improvements }, validationDockerfile);
723
789
 
724
790
  } else {
725
791
  const pruneCmd = nodePruneLine(a.packageManager);
@@ -807,8 +873,9 @@ function generateNodeSub(a, dir, sharedDirs, staticAssets = [], rootConfigFiles
807
873
 
808
874
  let dockerfile;
809
875
 
810
- if (a.hasBuild && a.role === 'frontend') {
811
- // Frontend build → serve static via nginx
876
+ if (a.hasBuild && a.role === 'frontend' && !SERVER_RENDERED_FRAMEWORKS.has(a.framework)) {
877
+ // Static SPA build → serve the compiled output. Server-rendered frameworks (Next.js, Remix, ...)
878
+ // are excluded here and fall through to the Node-server runtime branch below.
812
879
  const stageName = 'build';
813
880
 
814
881
  // When lock file is at project root, copy root files + service package.json and
@@ -883,18 +950,11 @@ ${sourceCopy}
883
950
  ${sharedConfigCopy}${buildRun}
884
951
 
885
952
  # ─── 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();
953
+ ${staticServeRuntimeStage(baseImage, a.port, `COPY --from=${stageName} ${buildOutput} ./${a.buildOutputDir || 'build'}`, a.buildOutputDir || 'build')}`.trim();
894
954
 
895
955
  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);
956
+ 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.`);
957
+ return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), improvements }, validationDockerfile);
898
958
 
899
959
 
900
960
  } else {
@@ -132,19 +132,17 @@ 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 => {
139
139
  const isRun = /^RUN\s/.test(line);
140
140
  if (!isRun) return line;
141
- // Strip --mount=type=cache,... and --mount=type=secret,...
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
- );
141
+ // Strip only --mount=type=cache,... (a pure build-speed optimisation).
142
+ // KEEP --mount=type=secret,... — for private/scoped registries it's the only way the
143
+ // default Dockerfile can authenticate (e.g. `docker build --secret id=npmrc,src=.npmrc`).
144
+ // An unsupplied secret mount is a no-op, so this is safe even when no secret is passed.
145
+ line = line.replace(/--mount=type=cache,\S+\s+/g, '');
148
146
  return line;
149
147
  }).join('\n');
150
148
  }