diff --git a/README.md b/README.md index 011858d..1d81342 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Options are saved between runs into `package.json`. To invert a boolean options, * `--alpine` - use [alpine](https://www.alpinelinux.org/) as base image * `--build=CMD` - command to be used to build your application. * `--cache` - use build caching to speed up builds +* `--caddy` - serve static sites (Vite, static Astro) with [Caddy](https://caddyserver.com/) instead of nginx. * `--cmd=CMD` - CMD to use in Dockerfile * `--defer-build` - may be needed when your build step requires access to secrets that are not available at image build time. Results in larger images and slower deployments. * `--dev` - include `devDependencies` in the production image. diff --git a/gdf.js b/gdf.js index a97255e..8005ded 100755 --- a/gdf.js +++ b/gdf.js @@ -15,6 +15,7 @@ export const defaults = { bun: false, build: '', cache: false, + caddy: false, cmd: '', deferBuild: false, dev: false, @@ -130,16 +131,67 @@ export class GDF { } get astro() { - return !!(this.#pj.dependencies?.astro) + return !!(this.#pj.dependencies?.astro || this.#pj.devDependencies?.astro) + } + + // contents of astro.config.*, or an empty string + get astroConfig() { + if (!this.astro) return '' + + for (const ext of ['mjs', 'js', 'ts', 'mts', 'cjs', 'cts']) { + const file = path.join(this._appdir, `astro.config.${ext}`) + if (fs.existsSync(file)) return fs.readFileSync(file, 'utf-8') + } + + return '' + } + + // value of a top level string option in astro.config.*, eg. outDir or base + astroOption(name) { + const match = this.astroConfig.match(new RegExp(`\\b${name}\\s*:\\s*(["'\`])(.+?)\\1`)) + return match?.[2] + } + + get astroAdapter() { + return !!(this.#pj.dependencies?.['@astrojs/node'] || this.#pj.devDependencies?.['@astrojs/node']) + } + + // middleware mode builds a handler for a custom server, which the start script runs + get astroMiddleware() { + return this.astroAdapter && /mode\s*:\s*(["'`])middleware\1/.test(this.astroConfig) } get astroSSR() { - return !!(this.#pj.dependencies?.astro) && - !!(this.#pj.dependencies?.['@astrojs/node']) + return this.astro && this.astroAdapter && !this.astroMiddleware + } + + // the server imports astro, the adapter and their dependencies at runtime + get astroSSRDevDependencies() { + return this.astroSSR && + !!(this.#pj.devDependencies?.astro || this.#pj.devDependencies?.['@astrojs/node']) } get astroStatic() { - return this.astro && !this.astroSSR + return this.astro && !this.astroAdapter + } + + get astroOutDir() { + const outDir = this.astroOption('outDir') + if (!outDir) return 'dist' + return path.posix.normalize(outDir).replace(/\/$/, '') + } + + // path prefix the site is served from, eg. /docs; empty for the root + get astroBase() { + if (!this.astroStatic) return '' + const base = this.astroOption('base') + if (!base) return '' + return path.posix.join('/', base).replace(/\/$/, '') + } + + // directory containing the built static site + get staticDir() { + return this.astroStatic ? this.astroOutDir : 'dist' } get svelte() { @@ -986,10 +1038,12 @@ export class GDF { return ['node', 'main.js'] } else if (this.gatsby) { return [this.npx, 'gatsby', 'serve', '-H', '0.0.0.0'] + } else if ((this.vite || this.astroStatic) && this.options.caddy) { + return ['caddy', 'run', '--config', '/etc/caddy/Caddyfile', '--adapter', 'caddyfile'] } else if (this.vite || this.astroStatic) { return ['/usr/sbin/nginx', '-g', 'daemon off;'] } else if (this.astroSSR) { - return ['node', './dist/server/entry.mjs'] + return ['node', `./${this.astroOutDir}/server/entry.mjs`] } else if (this.runtime === 'Node.js' && this.#pj.scripts?.start?.includes('fastify')) { let start = this.#pj.scripts.start if (!start.includes('-a') && !start.includes('--address')) { diff --git a/index.js b/index.js index ea089e4..a973751 100755 --- a/index.js +++ b/index.js @@ -40,6 +40,10 @@ const options = yargs((hideBin(process.argv))) describe: 'use build caching to speed up builds', type: 'string' }) + .option('caddy', { + describe: 'serve static sites with caddy instead of nginx', + type: 'boolean' + }) .option('defer-build', { describe: 'if true, run build at deploy time', type: 'boolean' diff --git a/templates/Dockerfile.ejs b/templates/Dockerfile.ejs index 55f2396..9c3f3dc 100644 --- a/templates/Dockerfile.ejs +++ b/templates/Dockerfile.ejs @@ -120,7 +120,7 @@ RUN cd bundle/bundle/programs/server && meteor npm install # Build application RUN <%- mountSecrets %><%= build %> -<% if (devDependencies && !options.dev && !nestjs && !adonisjs && !prismaSeed && !nuxtjs) { -%> +<% if (devDependencies && !options.dev && !nestjs && !adonisjs && !prismaSeed && !nuxtjs && !astroStatic && !astroSSRDevDependencies) { -%> # Remove development dependencies RUN <%- buildCache %><%- packagerPrune %> @@ -142,6 +142,8 @@ WORKDIR /app # Set production environment <%- emitEnv(baseEnv) %> +<% } else if ((vite || astroStatic) && options.caddy) { -%> +FROM caddy:2-alpine <% } else if (vite || astroStatic) { -%> FROM nginx <% } else { -%> @@ -170,11 +172,13 @@ RUN wget https://github.com/benbjohnson/litestream/releases/download/v0.3.13/lit <% } -%> # Copy built application -<% if (vite || astroStatic) { -%> -COPY --from=build /app/dist /usr/share/nginx/html +<% if ((vite || astroStatic) && options.caddy) { -%> +COPY --from=build /app/<%= staticDir %> /srv<%= astroBase %> +<% } else if (vite || astroStatic) { -%> +COPY --from=build /app/<%= staticDir %> /usr/share/nginx/html<%= astroBase %> <% } else if (astroSSR) { -%> COPY --from=build /app/node_modules /app/node_modules -COPY --from=build /app/dist /app/dist +COPY --from=build /app/<%= astroOutDir %> /app/<%= astroOutDir %> ENV PORT=4321 ENV HOST=0.0.0.0 @@ -240,6 +244,41 @@ ENTRYPOINT <%- entrypoint %> # Deploy arguments <%- emitArgs(options.args.deploy) %> +<% } -%> +<% if (astroStatic && options.caddy) { -%> +# Serve the site, with its own 404 page +COPY <<-"EOF" /etc/caddy/Caddyfile +:<%= port %> { + root * /srv + file_server + handle_errors 404 { + rewrite * <%= astroBase %>/404.html + file_server + } +} +EOF + +<% } else if (vite && options.caddy) { -%> +# Serve the site +COPY <<-"EOF" /etc/caddy/Caddyfile +:<%= port %> { + root * /srv + file_server +} +EOF + +<% } else if (astroStatic) { -%> +# Serve the site, with its own 404 page and relative redirects +COPY <<-"EOF" /etc/nginx/conf.d/default.conf +server { + listen <%= port %>; + listen [::]:<%= port %>; + root /usr/share/nginx/html; + absolute_redirect off; + error_page 404 <%= astroBase %>/404.html; +} +EOF + <% } -%> <% if (options.nginxRoot) { -%> # configure nginx diff --git a/test/frameworks/astro-caddy/.dockerignore b/test/frameworks/astro-caddy/.dockerignore new file mode 100644 index 0000000..74340d4 --- /dev/null +++ b/test/frameworks/astro-caddy/.dockerignore @@ -0,0 +1,6 @@ +/.git +/node_modules +.dockerignore +.env +Dockerfile +fly.toml diff --git a/test/frameworks/astro-caddy/Dockerfile b/test/frameworks/astro-caddy/Dockerfile new file mode 100644 index 0000000..e722d1a --- /dev/null +++ b/test/frameworks/astro-caddy/Dockerfile @@ -0,0 +1,54 @@ +# syntax = docker/dockerfile:1 + +# Adjust NODE_VERSION as desired +ARG NODE_VERSION=xxx +FROM node:${NODE_VERSION}-slim AS base + +LABEL fly_launch_runtime="Astro" + +# Astro app lives here +WORKDIR /app + +# Set production environment +ENV NODE_ENV="production" + + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build node modules +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 + +# Install node modules +COPY package-lock.json package.json ./ +RUN npm ci + +# Copy application code +COPY . . + +# Build application +RUN npm run build + + +# Final stage for app image +FROM caddy:2-alpine + +# Copy built application +COPY --from=build /app/dist /srv + +# Serve the site, with its own 404 page +COPY <<-"EOF" /etc/caddy/Caddyfile +:80 { + root * /srv + file_server + handle_errors 404 { + rewrite * /404.html + file_server + } +} +EOF + +# Start the server by default, this can be overwritten at runtime +EXPOSE 80 +CMD [ "caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile" ] diff --git a/test/frameworks/astro-caddy/package-lock.json b/test/frameworks/astro-caddy/package-lock.json new file mode 100644 index 0000000..16411f2 --- /dev/null +++ b/test/frameworks/astro-caddy/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "astro-caddy", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/test/frameworks/astro-caddy/package.json b/test/frameworks/astro-caddy/package.json new file mode 100644 index 0000000..e441f32 --- /dev/null +++ b/test/frameworks/astro-caddy/package.json @@ -0,0 +1,17 @@ +{ + "name": "astro-caddy", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "astro": "^5.15.0" + }, + "dockerfile": { + "caddy": true + } +} diff --git a/test/frameworks/astro-config/.dockerignore b/test/frameworks/astro-config/.dockerignore new file mode 100644 index 0000000..74340d4 --- /dev/null +++ b/test/frameworks/astro-config/.dockerignore @@ -0,0 +1,6 @@ +/.git +/node_modules +.dockerignore +.env +Dockerfile +fly.toml diff --git a/test/frameworks/astro-config/Dockerfile b/test/frameworks/astro-config/Dockerfile new file mode 100644 index 0000000..d4b975d --- /dev/null +++ b/test/frameworks/astro-config/Dockerfile @@ -0,0 +1,53 @@ +# syntax = docker/dockerfile:1 + +# Adjust NODE_VERSION as desired +ARG NODE_VERSION=xxx +FROM node:${NODE_VERSION}-slim AS base + +LABEL fly_launch_runtime="Astro" + +# Astro app lives here +WORKDIR /app + +# Set production environment +ENV NODE_ENV="production" + + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build node modules +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 + +# Install node modules +COPY package-lock.json package.json ./ +RUN npm ci --include=dev + +# Copy application code +COPY . . + +# Build application +RUN npm run build + + +# Final stage for app image +FROM nginx + +# Copy built application +COPY --from=build /app/build /usr/share/nginx/html/docs + +# Serve the site, with its own 404 page and relative redirects +COPY <<-"EOF" /etc/nginx/conf.d/default.conf +server { + listen 80; + listen [::]:80; + root /usr/share/nginx/html; + absolute_redirect off; + error_page 404 /docs/404.html; +} +EOF + +# Start the server by default, this can be overwritten at runtime +EXPOSE 80 +CMD [ "/usr/sbin/nginx", "-g", "daemon off;" ] diff --git a/test/frameworks/astro-config/astro.config.mjs b/test/frameworks/astro-config/astro.config.mjs new file mode 100644 index 0000000..f344690 --- /dev/null +++ b/test/frameworks/astro-config/astro.config.mjs @@ -0,0 +1,8 @@ +// @ts-check +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + outDir: './build', + base: '/docs', +}); diff --git a/test/frameworks/astro-config/package-lock.json b/test/frameworks/astro-config/package-lock.json new file mode 100644 index 0000000..9c1a941 --- /dev/null +++ b/test/frameworks/astro-config/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "astro-config", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/test/frameworks/astro-config/package.json b/test/frameworks/astro-config/package.json new file mode 100644 index 0000000..8b68bda --- /dev/null +++ b/test/frameworks/astro-config/package.json @@ -0,0 +1,14 @@ +{ + "name": "astro-config", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "devDependencies": { + "astro": "^5.15.0" + } +} diff --git a/test/frameworks/astro-middleware/.dockerignore b/test/frameworks/astro-middleware/.dockerignore new file mode 100644 index 0000000..74340d4 --- /dev/null +++ b/test/frameworks/astro-middleware/.dockerignore @@ -0,0 +1,6 @@ +/.git +/node_modules +.dockerignore +.env +Dockerfile +fly.toml diff --git a/test/frameworks/astro-middleware/Dockerfile b/test/frameworks/astro-middleware/Dockerfile new file mode 100644 index 0000000..91f3152 --- /dev/null +++ b/test/frameworks/astro-middleware/Dockerfile @@ -0,0 +1,42 @@ +# syntax = docker/dockerfile:1 + +# Adjust NODE_VERSION as desired +ARG NODE_VERSION=xxx +FROM node:${NODE_VERSION}-slim AS base + +LABEL fly_launch_runtime="Astro" + +# Astro app lives here +WORKDIR /app + +# Set production environment +ENV NODE_ENV="production" + + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build node modules +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 + +# Install node modules +COPY package-lock.json package.json ./ +RUN npm ci + +# Copy application code +COPY . . + +# Build application +RUN npm run build + + +# Final stage for app image +FROM base + +# Copy built application +COPY --from=build /app /app + +# Start the server by default, this can be overwritten at runtime +EXPOSE 3000 +CMD [ "npm", "run", "start" ] diff --git a/test/frameworks/astro-middleware/astro.config.mjs b/test/frameworks/astro-middleware/astro.config.mjs new file mode 100644 index 0000000..81538b6 --- /dev/null +++ b/test/frameworks/astro-middleware/astro.config.mjs @@ -0,0 +1,12 @@ +// @ts-check +import { defineConfig } from 'astro/config'; + +import node from '@astrojs/node'; + +// https://astro.build/config +export default defineConfig({ + output: 'server', + adapter: node({ + mode: 'middleware' + }) +}); diff --git a/test/frameworks/astro-middleware/package-lock.json b/test/frameworks/astro-middleware/package-lock.json new file mode 100644 index 0000000..44d62f2 --- /dev/null +++ b/test/frameworks/astro-middleware/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "astro-middleware", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/test/frameworks/astro-middleware/package.json b/test/frameworks/astro-middleware/package.json new file mode 100644 index 0000000..aa9905d --- /dev/null +++ b/test/frameworks/astro-middleware/package.json @@ -0,0 +1,16 @@ +{ + "name": "astro-middleware", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "start": "node ./server.mjs", + "astro": "astro" + }, + "dependencies": { + "@astrojs/node": "^9.5.0", + "astro": "^5.15.0", + "express": "^5.1.0" + } +} diff --git a/test/frameworks/astro-static/Dockerfile b/test/frameworks/astro-static/Dockerfile index 240b22e..272d30f 100644 --- a/test/frameworks/astro-static/Dockerfile +++ b/test/frameworks/astro-static/Dockerfile @@ -37,6 +37,17 @@ FROM nginx # Copy built application COPY --from=build /app/dist /usr/share/nginx/html +# Serve the site, with its own 404 page and relative redirects +COPY <<-"EOF" /etc/nginx/conf.d/default.conf +server { + listen 80; + listen [::]:80; + root /usr/share/nginx/html; + absolute_redirect off; + error_page 404 /404.html; +} +EOF + # Start the server by default, this can be overwritten at runtime EXPOSE 80 CMD [ "/usr/sbin/nginx", "-g", "daemon off;" ] diff --git a/test/frameworks/vite-caddy/.dockerignore b/test/frameworks/vite-caddy/.dockerignore new file mode 100644 index 0000000..74340d4 --- /dev/null +++ b/test/frameworks/vite-caddy/.dockerignore @@ -0,0 +1,6 @@ +/.git +/node_modules +.dockerignore +.env +Dockerfile +fly.toml diff --git a/test/frameworks/vite-caddy/Dockerfile b/test/frameworks/vite-caddy/Dockerfile new file mode 100644 index 0000000..d38d4a4 --- /dev/null +++ b/test/frameworks/vite-caddy/Dockerfile @@ -0,0 +1,53 @@ +# syntax = docker/dockerfile:1 + +# Adjust NODE_VERSION as desired +ARG NODE_VERSION=xxx +FROM node:${NODE_VERSION}-slim AS base + +LABEL fly_launch_runtime="Vite" + +# Vite app lives here +WORKDIR /app + +# Set production environment +ENV NODE_ENV="production" + + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build node modules +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 + +# Install node modules +COPY package-lock.json package.json ./ +RUN npm ci --include=dev + +# Copy application code +COPY . . + +# Build application +RUN npm run build + +# Remove development dependencies +RUN npm prune --omit=dev + + +# Final stage for app image +FROM caddy:2-alpine + +# Copy built application +COPY --from=build /app/dist /srv + +# Serve the site +COPY <<-"EOF" /etc/caddy/Caddyfile +:80 { + root * /srv + file_server +} +EOF + +# Start the server by default, this can be overwritten at runtime +EXPOSE 80 +CMD [ "caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile" ] diff --git a/test/frameworks/vite-caddy/package-lock.json b/test/frameworks/vite-caddy/package-lock.json new file mode 100644 index 0000000..275dc56 --- /dev/null +++ b/test/frameworks/vite-caddy/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "vite-caddy", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/test/frameworks/vite-caddy/package.json b/test/frameworks/vite-caddy/package.json new file mode 100644 index 0000000..20ff536 --- /dev/null +++ b/test/frameworks/vite-caddy/package.json @@ -0,0 +1,17 @@ +{ + "name": "vite-caddy", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^7.1.0" + }, + "dockerfile": { + "caddy": true + } +} diff --git a/test/gdf.test.js b/test/gdf.test.js index b736c13..381c478 100644 --- a/test/gdf.test.js +++ b/test/gdf.test.js @@ -50,6 +50,41 @@ describe('gdf: framework detection', function() { expect(gdf.astroSSR).to.equal(true) }) + it('detects astro and its adapter in devDependencies', async function() { + let gdf = await sandbox().run({ devDependencies: { astro: '^5' } }) + expect(gdf.runtime).to.equal('Astro') + expect(gdf.astroStatic).to.equal(true) + + gdf = await sandbox().run({ dependencies: { astro: '^5' }, devDependencies: { '@astrojs/node': '^9' } }) + expect(gdf.astroSSR).to.equal(true) + // the adapter is needed at runtime, so it must not be pruned + expect(sandbox().read('Dockerfile')).not.to.include('npm prune') + }) + + it('runs the start script for astro middleware mode', async function() { + sandbox().write('astro.config.mjs', "export default defineConfig({ adapter: node({ mode: 'middleware' }) })") + const gdf = await sandbox().run({ dependencies: { astro: '^5', '@astrojs/node': '^9' }, scripts: { start: 'node server.mjs' } }) + expect(gdf.astroSSR).to.equal(false) + expect(gdf.astroStatic).to.equal(false) + expect(gdf.startCommand).to.deep.equal(['npm', 'run', 'start']) + }) + + it('reads outDir and base from astro.config', async function() { + sandbox().write('astro.config.ts', "export default defineConfig({ outDir: './build/', base: 'docs/' })") + const gdf = await sandbox().run({ dependencies: { astro: '^5' } }) + expect(gdf.astroOutDir).to.equal('build') + expect(gdf.astroBase).to.equal('/docs') + expect(sandbox().read('Dockerfile')).to.include('COPY --from=build /app/build /usr/share/nginx/html/docs') + }) + + it('serves static sites with caddy when asked', async function() { + await sandbox().run({ dependencies: { astro: '^5' } }, { caddy: true }) + const dockerfile = sandbox().read('Dockerfile') + expect(dockerfile).to.include('FROM caddy:2-alpine') + expect(dockerfile).to.include('COPY --from=build /app/dist /srv') + expect(dockerfile).not.to.include('nginx') + }) + it('detects trpc only when both client and server are present', async function() { let gdf = await sandbox().run({ dependencies: { '@trpc/server': '^10' } }) expect(gdf.trpc).to.equal(false)