From 8d4d3b57e0048c9d34e4ec21abd44fcfc9e5154d Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 24 Jul 2026 23:32:05 +0800 Subject: [PATCH] feat(studio): add tanstack variant to the studio docker image (#48091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the self-hosted Docker image buildable with the TanStack/Vite build alongside the existing Next one. The Dockerfile's new `STUDIO_FRAMEWORK` build arg (default: `next`) selects which framework lands in the image — the same variable `scripts/dispatch.js` keys on everywhere else, so `--build-arg STUDIO_FRAMEWORK=tanstack` is the docker spelling of the existing switch. Both flavors assemble a normalized `/srv` tree, so a single production stage serves either with the same CMD (`node apps/studio/server.js`), port 3000, and healthcheck. Unlike Next's self-contained standalone output, the Vite SSR bundle externalizes studio's dependencies and resolves them from `node_modules` at request time, so the tanstack runtime tree is a prod-only `pnpm deploy` plus the built `dist/`. The boot smoke test runs a second time against that pruned tree, so a runtime import that's missing from `dependencies` fails the image build instead of 500ing the deployed container — which is exactly how this PR caught four packages misclassified as devDependencies (`braintrust` + `@smithy/property-provider` via the AI routes, `libpg-query` via the parse-query API route, `@radix-ui/react-use-escape-keydown` via the Queues panel; split into its own commit). **Changed:** - `apps/studio/Dockerfile`: `ARG STUDIO_FRAMEWORK` selects `build-next` / `build-tanstack` stages via `FROM build-${STUDIO_FRAMEWORK}`; both normalize into one production layout - `apps/studio/package.json`: moved the four runtime-imported packages from devDependencies to dependencies (versions unchanged) - `apps/studio/vite.config.ts`: pinned `preview.host` to `127.0.0.1` — the prerender step boots `vite preview` and crawls its resolved URL, and the default `localhost` host lets the server bind the IPv6 loopback while the crawler fetches `127.0.0.1`, which ECONNREFUSEDs the whole build inside BuildKit containers - `.github/workflows/studio-docker-build.yml`: builds the tanstack image as a second step (reuses the first build's layer cache; job name unchanged) **Added:** - `build:studio:docker:tanstack` root script Note: the tanstack image is ~2.0GB vs ~1.2GB for Next (externalized `node_modules`); shrinking it via file tracing is a follow-up. Nothing self-hosters pull changes until a tanstack-built image is published — this makes it buildable and CI-checked. ## To test - `pnpm build:studio:docker` then run the image against a stack — behavior unchanged (healthcheck `/api/platform/profile` 200, `/` 307s to `/project/default`) - `pnpm build:studio:docker:tanstack` then run that image with the same env — same healthcheck, redirect, and data endpoints (projects, pg-meta) respond 200; browser loads Project Overview / Table Editor with no requests leaving the container - Both verified locally against the CLI stack (`host.docker.internal` env, container reports `healthy`) - Vercel + e2e checks on this PR exercise the `preview.host` change on their runners ## Summary by CodeRabbit - **New Features** - Added TanStack-based Studio build support with a framework-selectable Docker image. - Added a local build command for the TanStack Studio Docker image. - **Build & Deployment** - Updated the Studio Docker build workflow to also publish a TanStack-tagged Studio image when relevant. - **Bug Fixes** - Improved `vite preview` behavior in containers by binding to IPv4 loopback. - Standardized the Studio container runtime port to `3000`. - **Chores** - Updated Studio runtime packages to support the TanStack build. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .github/workflows/studio-docker-build.yml | 5 ++ apps/studio/Dockerfile | 71 +++++++++++++++++++++-- apps/studio/package.json | 8 +-- apps/studio/vite.config.ts | 9 +++ package.json | 1 + pnpm-lock.yaml | 24 ++++---- pnpm-workspace.yaml | 11 ++++ 7 files changed, 107 insertions(+), 22 deletions(-) diff --git a/.github/workflows/studio-docker-build.yml b/.github/workflows/studio-docker-build.yml index 409d2b9edbd..dcd33805726 100644 --- a/.github/workflows/studio-docker-build.yml +++ b/.github/workflows/studio-docker-build.yml @@ -36,3 +36,8 @@ jobs: - name: Build if: steps.filter.outputs.studio == 'true' run: docker build . -f apps/studio/Dockerfile --target production -t supabase-studio:local --build-arg NEXT_PUBLIC_STUDIO_AUTH_MODE=supabase --no-cache + # Reuses the shared stages (deps/dev) from the first build's layer + # cache; only the tanstack build/runtime stages run fresh. + - name: Build (tanstack) + if: steps.filter.outputs.studio == 'true' + run: docker build . -f apps/studio/Dockerfile --target production -t supabase-studio:local-tanstack --build-arg NEXT_PUBLIC_STUDIO_AUTH_MODE=supabase --build-arg STUDIO_FRAMEWORK=tanstack diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index 237b543b41c..9922ee29469 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -1,13 +1,22 @@ # To be run in the root of the turbo monorepo # NOTE: It's highly recommended to use the new builder, Buildkit. https://docs.docker.com/build/buildkit/ ## USAGE: -# Build: docker build . -f apps/studio/Dockerfile --target production -t studio:latest +# Build (Next): docker build . -f apps/studio/Dockerfile --target production -t studio:latest +# Build (TanStack): docker build . -f apps/studio/Dockerfile --target production -t studio:latest --build-arg STUDIO_FRAMEWORK=tanstack # Run: docker run -p 3000:3000 supabase/studio # Deploy: docker push supabase/studio:latest # Clean build: # docker builder prune # docker build . -f apps/studio/Dockerfile --target production -t studio:latest --no-cache +# Which framework's build ends up in the image. This is the same variable +# scripts/dispatch.js keys on for the dev/build/start scripts, so +# `--build-arg STUDIO_FRAMEWORK=tanstack` is the docker spelling of the +# switch used everywhere else. Framework selection happens at image build +# time (the two runtimes need different build outputs and dependency +# trees), not at container start. +ARG STUDIO_FRAMEWORK=next + FROM node:22-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" @@ -50,15 +59,65 @@ EXPOSE 8082 CMD ["pnpm", "dev:studio"] # Compile Next.js -FROM dev AS builder - +FROM dev AS build-next RUN pnpm --filter studio exec next build +# Assemble the runtime tree at /srv in the layout the production stage +# serves from: Next's self-contained standalone output is the app root, +# with the static assets and public/ laid alongside it. +RUN mkdir -p /srv && \ + cp -a apps/studio/.next/standalone/. /srv/ && \ + mkdir -p /srv/apps/studio/.next && \ + cp -a apps/studio/.next/static /srv/apps/studio/.next/static && \ + cp -a apps/studio/public /srv/apps/studio/public + +# Compile TanStack Start (Vite) +FROM dev AS build-tanstack +# build:tanstack = vite build --mode production, then a smoke test that +# boots the server bundle so module-scope crashes fail the image build. +RUN NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter studio run build:tanstack + +# Assemble the runtime tree at /srv. Unlike Next's standalone output, the +# Vite SSR bundle externalizes studio's dependencies and resolves them from +# node_modules at request time, so the tree is a prod-only `pnpm deploy` of +# studio (node_modules + manifest) plus the built dist/ and the runtime +# scripts. `scripts/serve.js` is the HTTP server (the same entry +# start:tanstack uses); the server.js shim gives the production stage a +# single CMD that works for both frameworks. `.env` is kept because +# serve.js loads it as the base of the runtime env cascade (container env +# vars always win over file values). +# +# --ignore-scripts: pnpm 11 hard-errors (ERR_PNPM_IGNORED_BUILDS) on +# dependency build scripts without an allowBuilds entry, and deploy turns +# the workspace packages into file: deps whose `only-allow pnpm` preinstall +# guards trip it. No lifecycle script is needed here anyway: the tree is +# fully prebuilt, and nothing in studio's prod graph is approved to build +# (allowBuilds only permits node-pty and supabase, both dev-only). +RUN pnpm --filter studio deploy --prod --legacy --ignore-scripts /srv/apps/studio && \ + cd /srv/apps/studio && \ + find . -mindepth 1 -maxdepth 1 \ + ! -name node_modules ! -name package.json ! -name scripts \ + ! -name instrument.server.mjs ! -name .env \ + -exec rm -rf {} + && \ + cp -a /app/apps/studio/dist ./dist && \ + printf "import('./scripts/serve.js')\n" > server.js + +# Boot the pruned tree exactly the way the container will run it, so a +# dependency that's runtime-imported but missing from `dependencies` +# (present only in devDependencies) fails the build here instead of +# 500ing the deployed container. +RUN cd /srv/apps/studio && node scripts/smoke-server.mjs + +# Alias whichever framework build was selected so the production stage can +# COPY from a single stage name. BuildKit only builds the selected branch. +FROM build-${STUDIO_FRAMEWORK} AS build + # Copy only compiled code and dependencies FROM base AS production -COPY --from=builder /app/apps/studio/public ./apps/studio/public -COPY --from=builder /app/apps/studio/.next/standalone ./ -COPY --from=builder /app/apps/studio/.next/static ./apps/studio/.next/static +COPY --from=build /srv ./ +# serve.js (TanStack) defaults to port 8082; pin both servers to the port +# the healthcheck and compose files expect. Next's server.js reads PORT too. +ENV PORT=3000 EXPOSE 3000 ENTRYPOINT ["docker-entrypoint.sh"] HEALTHCHECK --interval=5s --timeout=5s --retries=3 CMD node -e "fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})" diff --git a/apps/studio/package.json b/apps/studio/package.json index 3a409431595..8bc27056365 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -61,9 +61,11 @@ "@monaco-editor/react": "catalog:", "@next/bundle-analyzer": "16.2.3", "@number-flow/react": "^0.3.2", + "@radix-ui/react-use-escape-keydown": "^1.1.1", "@sentry/nextjs": "catalog:", "@sentry/react": "^10.27.0", "@sentry/tanstackstart-react": "catalog:", + "@smithy/property-provider": "^4.0.4", "@std/path": "npm:@jsr/std__path@^1.0.8", "@stripe/react-stripe-js": "6.1.0", "@stripe/stripe-js": "9.1.0", @@ -91,6 +93,7 @@ "ai": "^6.0.174", "ai-commands": "workspace:*", "awesome-debounce-promise": "^2.1.0", + "braintrust": "^3.9.0", "class-variance-authority": "^0.7.1", "common": "workspace:*", "common-tags": "^1.8.2", @@ -112,6 +115,7 @@ "idb": "^8.0.2", "ip-num": "^1.5.1", "json-logic-js": "^2.0.2", + "libpg-query": "17.6.0", "lodash": "catalog:", "lodash-es": "catalog:", "lucide-react": "^0.436.0", @@ -164,8 +168,6 @@ "@faker-js/faker": "^9.9.0", "@graphql-codegen/cli": "5.0.5", "@graphql-typed-document-node/core": "^3.2.0", - "@radix-ui/react-use-escape-keydown": "^1.1.1", - "@smithy/property-provider": "^4.0.4", "@tailwindcss/vite": "4.2.4", "@tanstack/devtools-vite": "^0.6.0", "@testing-library/dom": "^10.0.0", @@ -193,7 +195,6 @@ "@vitest/ui": "catalog:", "api-types": "workspace:*", "autoevals": "^0.0.132", - "braintrust": "^3.9.0", "common": "workspace:*", "config": "workspace:*", "date-fns": "^2.30.0", @@ -205,7 +206,6 @@ "graphql-ws": "5.14.1", "import-in-the-middle": "^2.0.0", "jsdom-testing-mocks": "^1.13.1", - "libpg-query": "17.6.0", "magic-string": "^0.30.21", "msw": "^2.3.0", "next-router-mock": "^0.9.13", diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index 7300e86046c..29998d6d8a2 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -670,6 +670,15 @@ export default defineConfig(({ command, mode }) => { server: { port: 3000, }, + preview: { + // The prerender step (@tanstack/start-plugin-core) boots `vite preview` + // on an ephemeral port and crawls the first resolved URL. With the + // default host (`localhost`) the server can bind the IPv6 loopback + // while the crawler's fetch connects to 127.0.0.1 — split name + // resolution that ECONNREFUSEDs the whole prerender inside docker + // build containers. Pin both sides to IPv4 loopback. + host: '127.0.0.1', + }, resolve: { tsconfigPaths: true, alias: [ diff --git a/package.json b/package.json index 7ccfdf72a22..69710876339 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "turbo run build", "build:studio": "turbo run build --filter=studio", "build:studio:docker": "docker build . -f apps/studio/Dockerfile --target production -t supabase-studio:local --build-arg NEXT_PUBLIC_STUDIO_AUTH_MODE=supabase --no-cache", + "build:studio:docker:tanstack": "docker build . -f apps/studio/Dockerfile --target production -t supabase-studio:local-tanstack --build-arg NEXT_PUBLIC_STUDIO_AUTH_MODE=supabase --build-arg STUDIO_FRAMEWORK=tanstack --no-cache", "build:design-system": "turbo run build --filter=design-system", "build:docs": "turbo run build --filter=docs", "clean": "turbo run clean --parallel && rimraf -G .turbo/cache && pnpm purge", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 774c859a789..4a09e2af934 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -951,6 +951,9 @@ importers: '@number-flow/react': specifier: ^0.3.2 version: 0.3.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-escape-keydown': + specifier: ^1.1.1 + version: 1.1.1(@types/react@19.2.14)(react@19.2.6) '@sentry/nextjs': specifier: 'catalog:' version: 10.59.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.2.6(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6)(supports-color@8.1.1)(vite@8.0.16(@types/node@22.13.14)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1)) @@ -960,6 +963,9 @@ importers: '@sentry/tanstackstart-react': specifier: 'catalog:' version: 10.59.0(encoding@0.1.13)(react@19.2.6)(rollup@4.60.3)(supports-color@8.1.1)(vite@8.0.16(@types/node@22.13.14)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.22.4)(yaml@2.9.0)) + '@smithy/property-provider': + specifier: ^4.0.4 + version: 4.0.4 '@std/path': specifier: npm:@jsr/std__path@^1.0.8 version: '@jsr/std__path@1.0.8' @@ -1044,6 +1050,9 @@ importers: awesome-debounce-promise: specifier: ^2.1.0 version: 2.1.0 + braintrust: + specifier: ^3.9.0 + version: 3.9.0(@aws-sdk/credential-provider-web-identity@3.972.38)(supports-color@8.1.1)(zod@3.25.76) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1107,6 +1116,9 @@ importers: json-logic-js: specifier: ^2.0.2 version: 2.0.2 + libpg-query: + specifier: 17.6.0 + version: 17.6.0 lodash: specifier: ^4.18.1 version: 4.18.1 @@ -1258,12 +1270,6 @@ importers: '@jridgewell/remapping': specifier: ^2.3.5 version: 2.3.5 - '@radix-ui/react-use-escape-keydown': - specifier: ^1.1.1 - version: 1.1.1(@types/react@19.2.14)(react@19.2.6) - '@smithy/property-provider': - specifier: ^4.0.4 - version: 4.0.4 '@tailwindcss/vite': specifier: 4.2.4 version: 4.2.4(vite@8.0.16(@types/node@22.13.14)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -1345,9 +1351,6 @@ importers: autoevals: specifier: ^0.0.132 version: 0.0.132(ws@8.21.0) - braintrust: - specifier: ^3.9.0 - version: 3.9.0(@aws-sdk/credential-provider-web-identity@3.972.38)(supports-color@8.1.1)(zod@3.25.76) date-fns: specifier: ^2.30.0 version: 2.30.0 @@ -1375,9 +1378,6 @@ importers: jsdom-testing-mocks: specifier: ^1.13.1 version: 1.13.1 - libpg-query: - specifier: 17.6.0 - version: 17.6.0 magic-string: specifier: ^0.30.21 version: 0.30.21 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 95dd48fb645..079705c1466 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -56,16 +56,27 @@ catalog: allowBuilds: '@parcel/watcher': false '@sentry/cli': false + '@supabase/build-icons@file:packages/build-icons': set this to true or false + '@supabase/pg-meta@file:packages/pg-meta': set this to true or false + ai-commands@file:packages/ai-commands: set this to true or false + api-types@file:packages/api-types: set this to true or false + common@file:packages/common: set this to true or false + config@file:packages/config: set this to true or false contentlayer2: false core-js: false + dev-tools@file:packages/dev-tools: set this to true or false es5-ext: false esbuild: false + icons@file:packages/icons: set this to true or false libpg-query: false msw: false node-pty: true protobufjs: false + shared-data@file:packages/shared-data: set this to true or false sharp: false supabase: true + ui-patterns@file:packages/ui-patterns: set this to true or false + ui@file:packages/ui: set this to true or false minimumReleaseAge: 4320