diff --git a/.containerignore b/.containerignore index 70b617e..1812091 100644 --- a/.containerignore +++ b/.containerignore @@ -5,8 +5,18 @@ coverage data dist node_modules +out playwright-report storybook-static test-results .env .env.* +apps/*/.turbo +apps/*/build +apps/*/data +apps/*/dist +apps/*/playwright-report +apps/*/test-results +packages/*/.turbo +packages/*/dist +packages/*/storybook-static diff --git a/.gitignore b/.gitignore index b6326bb..eed0de1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +out/ .svelte-kit/ build/ dist/ diff --git a/README.md b/README.md index 572f9bc..fee9443 100644 --- a/README.md +++ b/README.md @@ -139,10 +139,11 @@ the checked-in `apps/web/drizzle/` directory so startup migrations can run. ### Internal Container -The checked-in `apps/web/Containerfile` builds the React client and Bun server -from the workspace root into a runtime image. For the Dimension Lab internal -host, run it behind Caddy on a loopback port and mount persistent state at -`/data`: +The checked-in `apps/web/Containerfile` runs +`turbo prune @dimensionlab/web --docker`, installs the pruned manifest set, and +builds the React client plus Bun server from the pruned workspace source. For the +Dimension Lab internal host, run it behind Caddy on a loopback port and mount +persistent state at `/data`: ```sh podman build -f apps/web/Containerfile -t localhost/dimensionlab-website:latest . diff --git a/apps/web/Containerfile b/apps/web/Containerfile index 7f12f94..9ec2baa 100644 --- a/apps/web/Containerfile +++ b/apps/web/Containerfile @@ -1,19 +1,25 @@ -FROM docker.io/oven/bun:1.3.14 AS deps +FROM docker.io/oven/bun:1.3.14 AS base WORKDIR /repo -ENV PATH=/repo/apps/web/node_modules/.bin:/repo/packages/ui/node_modules/.bin:/repo/node_modules/.bin:$PATH -COPY package.json bun.lock turbo.json tsconfig.base.json ./ -COPY apps/web/package.json apps/web/package.json -COPY packages/dashboard-model/package.json packages/dashboard-model/package.json -COPY packages/ui/package.json packages/ui/package.json +ENV PATH=/repo/apps/web/node_modules/.bin:/repo/packages/dashboard-model/node_modules/.bin:/repo/packages/ui/node_modules/.bin:/repo/node_modules/.bin:$PATH + +FROM base AS pruner + +COPY . . +RUN bunx turbo prune @dimensionlab/web --docker + +FROM base AS deps + +COPY --from=pruner /repo/out/json/ ./ RUN bun install --frozen-lockfile FROM deps AS build -COPY . . -RUN bun install --frozen-lockfile && bun run build +COPY --from=pruner /repo/out/full/ ./ +COPY --from=pruner /repo/tsconfig.base.json /repo/tsconfig.json ./ +RUN bun run build -FROM deps AS runtime +FROM base AS runtime WORKDIR /repo/apps/web ENV NODE_ENV=production diff --git a/apps/web/package.json b/apps/web/package.json index 2e9fef6..790663c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "bun src/server/dev.ts", - "build": "vite build && bun build src/server/index.ts --target bun --outdir build", + "build": "rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build", "preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js", "check": "tsc --noEmit", "test": "vitest run", diff --git a/apps/web/src/lib/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts index 4f4200c..9cde8e8 100644 --- a/apps/web/src/lib/workspace-boundary.test.ts +++ b/apps/web/src/lib/workspace-boundary.test.ts @@ -114,35 +114,72 @@ describe("workspace boundaries", () => { ); }); - test("stages web workspace dependency manifests before container install", () => { - const webPackage = JSON.parse( - readFileSync(join(root, "apps/web/package.json"), "utf8"), - ) as { dependencies?: Record }; - const workspacePackages = [ - "packages/ui/package.json", - "packages/dashboard-model/package.json", - ].map((manifestPath) => { - const packageJson = JSON.parse( - readFileSync(join(root, manifestPath), "utf8"), - ) as { name?: string }; - - return [packageJson.name, manifestPath] as const; - }); - const manifestsByPackageName = new Map(workspacePackages); + test("builds the internal container from a turbo-pruned web workspace", () => { const containerfile = readFileSync( join(root, "apps/web/Containerfile"), "utf8", ); + const pruneCommand = "turbo prune @dimensionlab/web --docker"; + const jsonCopy = "COPY --from=pruner /repo/out/json/ ./"; + const sourceCopy = "COPY --from=pruner /repo/out/full/ ./"; + const tsconfigCopy = + "COPY --from=pruner /repo/tsconfig.base.json /repo/tsconfig.json ./"; + const installCommand = "RUN bun install --frozen-lockfile"; - const workspaceDependencyManifests = Object.entries( - webPackage.dependencies ?? {}, - ) - .filter(([, version]) => version.startsWith("workspace:")) - .map(([packageName]) => manifestsByPackageName.get(packageName)); + expect(containerfile).toContain("AS pruner"); + expect(containerfile).toContain(pruneCommand); + expect(containerfile).toContain(jsonCopy); + expect(containerfile).toContain(sourceCopy); + expect(containerfile).toContain(tsconfigCopy); + expect(containerfile).not.toContain( + "COPY packages/dashboard-model/package.json", + ); + expect(containerfile).not.toContain("COPY packages/ui/package.json"); - expect(workspaceDependencyManifests).not.toContain(undefined); - for (const manifestPath of workspaceDependencyManifests) { - expect(containerfile).toContain(`COPY ${manifestPath} ${manifestPath}`); - } + const jsonCopyIndex = containerfile.indexOf(jsonCopy); + const installIndex = containerfile.indexOf(installCommand); + const sourceCopyIndex = containerfile.indexOf(sourceCopy); + const tsconfigCopyIndex = containerfile.indexOf(tsconfigCopy); + + expect(jsonCopyIndex).toBeGreaterThan(-1); + expect(installIndex).toBeGreaterThan(jsonCopyIndex); + expect(sourceCopyIndex).toBeGreaterThan(installIndex); + expect(tsconfigCopyIndex).toBeGreaterThan(sourceCopyIndex); + expect(containerfile.indexOf("RUN bun run build")).toBeGreaterThan( + tsconfigCopyIndex, + ); + }); + + test("keeps local turbo prune output out of git and container contexts", () => { + const webPackage = JSON.parse( + readFileSync(join(root, "apps/web/package.json"), "utf8"), + ) as { scripts?: Record }; + const gitignore = readFileSync(join(root, ".gitignore"), "utf8"); + const containerignore = readFileSync(join(root, ".containerignore"), "utf8"); + const containerIgnoreRules = new Set( + containerignore + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean), + ); + + expect(gitignore).toContain("out/"); + expect(containerignore).toContain("out"); + expect([...containerIgnoreRules]).toEqual( + expect.arrayContaining([ + "apps/*/.turbo", + "apps/*/build", + "apps/*/data", + "apps/*/dist", + "apps/*/playwright-report", + "apps/*/test-results", + "packages/*/.turbo", + "packages/*/dist", + "packages/*/storybook-static", + ]), + ); + expect(webPackage.scripts?.build).toBe( + "rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build", + ); }); });