diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ace3332 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.DS_Store +.git +.gitignore + +**/.pnpm-store +**/.output +**/dist +**/node_modules + +Commands +Docker +Documentation +Env diff --git a/Commands/Local/prod.just b/Commands/Local/prod.just index b1e6f43..1bb5842 100644 --- a/Commands/Local/prod.just +++ b/Commands/Local/prod.just @@ -1,11 +1,32 @@ project_root := justfile_directory() -frontend_dir := project_root + "/Frontend" -frontend_bake := project_root + "/Frontend/docker-bake.hcl" +proxy_bake := project_root + "/Proxy/docker-bake.hcl" +local_compose := project_root + "/Docker/docker-compose.local.prod.yaml" -# Build the Frontend production image locally. +# Build the local production proxy image locally. build: - cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' prod + cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod -# Rebuild the Frontend production image locally. +# Start the local production stack in the background using the current image. +up: + docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate + +# Build first, then start the local production stack in the background. +start: build + docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate + +# Rebuild the local production proxy image locally. rebuild: - cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' --set '*.no-cache=true' prod + cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod + docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate + +# Stop and remove the local production stack. +down: + docker compose -f '{{local_compose}}' down --remove-orphans --volumes + +# Follow logs for the local production stack. +logs: + docker compose -f '{{local_compose}}' logs -f + +# Restart the local production stack. +restart: + docker compose -f '{{local_compose}}' restart diff --git a/Docker/docker-compose.local.prod.yaml b/Docker/docker-compose.local.prod.yaml index 8f70156..95ed45e 100644 --- a/Docker/docker-compose.local.prod.yaml +++ b/Docker/docker-compose.local.prod.yaml @@ -1 +1,7 @@ -# Reserved for a future local production compose stack. +services: + proxy: + image: moku/work-proxy:local-prod + container_name: moku-work-proxy-local + restart: unless-stopped + ports: + - "8080:80" diff --git a/Documentation/TODO.md b/Documentation/TODO.md index e2e137e..16045d7 100644 --- a/Documentation/TODO.md +++ b/Documentation/TODO.md @@ -11,7 +11,15 @@ - [ ] Project-Structure - [ ] Stack-Decisions - [ ] Proxy + - [ ] Local-Prod-NGINX-Proxy + - [ ] Static-Frontend-Serving - [ ] Dev-and-Prod-Builds + - [x] Local-Dev-Just-Commands + - [x] Local-Dev-Docker-Compose + - [ ] Local-Prod-Just-Commands + - [ ] Local-Prod-Docker-Compose + - [ ] Frontend-Production-Dockerfile + - [ ] Frontend-docker-bake #### Backend diff --git a/Frontend/Dockerfile b/Frontend/Dockerfile index 247e45b..3e47793 100644 --- a/Frontend/Dockerfile +++ b/Frontend/Dockerfile @@ -1,5 +1,8 @@ # syntax=docker/dockerfile:1.7 +# Frontend development image only. +# Production static serving is owned by Proxy/Local/Dockerfile. + FROM node:22-alpine AS base WORKDIR /app diff --git a/Frontend/docker-bake.hcl b/Frontend/docker-bake.hcl index f493dc2..cdf1408 100644 --- a/Frontend/docker-bake.hcl +++ b/Frontend/docker-bake.hcl @@ -17,30 +17,18 @@ target "dev" { tags = ["moku/work-frontend:dev"] } -target "prod" { - inherits = ["_app"] - target = "production" - tags = ["moku/work-frontend:prod"] -} - target "dev-image" { inherits = ["_app"] target = "development" tags = ["${REGISTRY}/moku/work-frontend:dev-${TAG}"] } -target "prod-image" { - inherits = ["_app"] - target = "production" - tags = ["${REGISTRY}/moku/work-frontend:prod-${TAG}"] -} - group "local" { - targets = ["dev", "prod"] + targets = ["dev"] } group "registry" { - targets = ["dev-image", "prod-image"] + targets = ["dev-image"] } group "default" { diff --git a/Frontend/package.json b/Frontend/package.json index 0e94400..05df22c 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -11,6 +11,7 @@ "scripts": { "dev": "vite dev", "build": "vite build", + "build:static": "pnpm build && node ./scripts/render-static-index.mjs", "typecheck": "tsc --noEmit", "start": "vite preview", "preview": "vite preview" diff --git a/Frontend/scripts/render-static-index.mjs b/Frontend/scripts/render-static-index.mjs new file mode 100644 index 0000000..356ea19 --- /dev/null +++ b/Frontend/scripts/render-static-index.mjs @@ -0,0 +1,49 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +const manifestPath = resolve("dist/client/.vite/manifest.json"); +const outputPath = resolve("dist/client/index.html"); + +const themeBootstrapScript = ` +(() => { + try { + const storageKey = "theme"; + const stored = localStorage.getItem(storageKey); + const theme = stored === "light" || stored === "dark" + ? stored + : (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); + + document.documentElement.setAttribute("data-theme", theme); + } catch { + document.documentElement.setAttribute("data-theme", "light"); + } +})(); +`.trim(); + +const manifest = JSON.parse(await readFile(manifestPath, "utf8")); +const entry = manifest["src/entry-client.tsx"]; + +if (!entry?.file) { + throw new Error("Could not find src/entry-client.tsx in the client manifest."); +} + +const cssLinks = Array.isArray(entry.css) ? entry.css.map((href) => ` `).join("\n") : ""; + +const html = ` + + + + + + +${cssLinks} + + +
+ + + +`; + +await mkdir(dirname(outputPath), { recursive: true }); +await writeFile(outputPath, html, "utf8"); diff --git a/Frontend/vite.config.ts b/Frontend/vite.config.ts index cfba717..2badcf4 100644 --- a/Frontend/vite.config.ts +++ b/Frontend/vite.config.ts @@ -1,6 +1,5 @@ // Path: Frontend/vite.config.ts -import { nitroV2Plugin as nitro } from "@solidjs/vite-plugin-nitro-2"; import { defineConfig } from "vite"; import { solidStart } from "@solidjs/start/config"; @@ -11,7 +10,7 @@ const extraAllowedHosts = (process.env.ALLOWED_HOSTS ?? "") .filter(Boolean); export default defineConfig({ - plugins: [solidStart(), nitro()], + plugins: [solidStart({ ssr: false })], server: { allowedHosts: ["localhost", ...extraAllowedHosts], }, diff --git a/Proxy/Local/Dockerfile b/Proxy/Local/Dockerfile new file mode 100644 index 0000000..8826ed4 --- /dev/null +++ b/Proxy/Local/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:22-alpine AS frontend-dependencies + +WORKDIR /workspace/Frontend + +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate + +COPY Frontend/package.json Frontend/pnpm-lock.yaml Frontend/tsconfig.json Frontend/vite.config.ts ./ +RUN pnpm install --frozen-lockfile + +FROM frontend-dependencies AS frontend-build + +ENV NODE_ENV=production + +COPY Frontend/ ./ + +RUN pnpm build:static + +FROM nginx:1.29-alpine AS production + +COPY Proxy/Local/default.conf /etc/nginx/conf.d/default.conf +COPY --from=frontend-build /workspace/Frontend/dist/client /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/Proxy/Local/default.conf b/Proxy/Local/default.conf new file mode 100644 index 0000000..449892a --- /dev/null +++ b/Proxy/Local/default.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /favicon.ico { + try_files $uri =404; + access_log off; + log_not_found off; + } + + location /_build/ { + access_log off; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } +} diff --git a/Proxy/docker-bake.hcl b/Proxy/docker-bake.hcl new file mode 100644 index 0000000..1e0e868 --- /dev/null +++ b/Proxy/docker-bake.hcl @@ -0,0 +1,36 @@ +variable "REGISTRY" { + default = "registry.example.com" +} + +variable "TAG" { + default = "latest" +} + +target "_proxy" { + context = "." + dockerfile = "Proxy/Local/Dockerfile" +} + +target "prod" { + inherits = ["_proxy"] + target = "production" + tags = ["moku/work-proxy:local-prod"] +} + +target "prod-image" { + inherits = ["_proxy"] + target = "production" + tags = ["${REGISTRY}/moku/work-proxy:prod-${TAG}"] +} + +group "local" { + targets = ["prod"] +} + +group "registry" { + targets = ["prod-image"] +} + +group "default" { + targets = ["prod"] +}