From d5abfd66e88f459f2cd5dc8c7a4501a28b6a636e Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Wed, 1 Jul 2026 21:14:52 -0300 Subject: [PATCH] feat(templates): JS/TS + monorepo app templates with hot reload (spec 31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six built-in project templates for the JS/TS ecosystem, each with dev-mode hot reload: - node.express, node.nestjs, node.next, react.vite, bun.app, turborepo. Each bind-mounts the project source (`..:/app` — the generated compose lives in `/.devstack/`) with an anonymous node_modules volume, runs `install` + the framework dev server (nodemon / nest --watch / next dev / vite / bun run dev / turbo run dev), and sets file-watch polling env (CHOKIDAR_USEPOLLING / WATCHPACK_POLLING) for WSL2/9p reliability. - templates/*/{template.yaml,build/Dockerfile}; registered in embed.go. - js_templates_test.go: each resolves, validates via compose-go, and emits the /app mount + dev command. - docs: templates guide + README table + whats-next (now shipped). make ci + determinism green; all six pass `template lint`. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/templates.md | 6 ++++ docs/guide/whats-next.md | 15 +++++----- internal/cli/js_templates_test.go | 37 +++++++++++++++++++++++++ templates/README.md | 11 ++++++++ templates/bun.app/build/Dockerfile | 6 ++++ templates/bun.app/template.yaml | 20 +++++++++++++ templates/embed.go | 2 +- templates/node.express/build/Dockerfile | 6 ++++ templates/node.express/template.yaml | 24 ++++++++++++++++ templates/node.nestjs/build/Dockerfile | 6 ++++ templates/node.nestjs/template.yaml | 24 ++++++++++++++++ templates/node.next/build/Dockerfile | 6 ++++ templates/node.next/template.yaml | 24 ++++++++++++++++ templates/react.vite/build/Dockerfile | 6 ++++ templates/react.vite/template.yaml | 24 ++++++++++++++++ templates/turborepo/build/Dockerfile | 6 ++++ templates/turborepo/template.yaml | 24 ++++++++++++++++ 17 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 internal/cli/js_templates_test.go create mode 100644 templates/bun.app/build/Dockerfile create mode 100644 templates/bun.app/template.yaml create mode 100644 templates/node.express/build/Dockerfile create mode 100644 templates/node.express/template.yaml create mode 100644 templates/node.nestjs/build/Dockerfile create mode 100644 templates/node.nestjs/template.yaml create mode 100644 templates/node.next/build/Dockerfile create mode 100644 templates/node.next/template.yaml create mode 100644 templates/react.vite/build/Dockerfile create mode 100644 templates/react.vite/template.yaml create mode 100644 templates/turborepo/build/Dockerfile create mode 100644 templates/turborepo/template.yaml diff --git a/docs/guide/templates.md b/docs/guide/templates.md index cdb76c3..6f25c9b 100644 --- a/docs/guide/templates.md +++ b/docs/guide/templates.md @@ -46,6 +46,12 @@ what you need. A missing **required** param fails fast. | `php.nginx` | — (parent) | — | — | `phpVersion` ("8.3") | | `php.laravel.nginx` | — (extends `php.nginx`) | — | — | `appEnv` (local) | | `node.vite` | — | — | — | `nodeVersion` ("20"); runs `npm run dev` | +| `node.express` | — | — | — | `nodeVersion` ("20"); `npm run dev`, hot reload | +| `node.nestjs` | — | — | — | `nodeVersion` ("20"); `nest start --watch` | +| `node.next` | — | — | — | `nodeVersion` ("20"); `next dev`, `WATCHPACK_POLLING` | +| `react.vite` | — | — | — | `nodeVersion` ("20"); Vite HMR | +| `bun.app` | — | — | — | `bunVersion` ("1"); `bun run dev` | +| `turborepo` | — | — | — | `nodeVersion` ("20"); `turbo run dev` (monorepo) | | `kafka` (Redpanda) | kafka | host, port, adminPort | 9092 | `image` | | `nats` | nats | host, port, monitorPort | 4222 | `version` ("2") | | `rabbitmq` | amqp | host, port, mgmtPort | 5672 | `version` ("3"), `user` (devstack) | diff --git a/docs/guide/whats-next.md b/docs/guide/whats-next.md index 6169858..a7c1078 100644 --- a/docs/guide/whats-next.md +++ b/docs/guide/whats-next.md @@ -74,19 +74,20 @@ task graph. We don't want to overclaim — today, if you need `build → test → deploy` task graphs across packages, use your existing task runner alongside devstack; devstack handles the infra those tasks talk to. -### Framework dev servers with watch mode (Next.js, NestJS) +### Framework dev servers with watch mode (Next.js, NestJS) — ✅ shipped -**Partially covered — the pattern already works, first-class templates don't ship -yet.** The `node.vite` built-in builds from `build/Dockerfile` and runs -`command: ["npm", "run", "dev"]` in watch mode. Next.js and NestJS are just **new -templates you can author right now** with `template new`, bind-mounting source -for hot reload: +**Now built-in.** `node.express`, `node.nestjs`, `node.next`, `react.vite`, +`bun.app`, and `turborepo` ship as templates with **dev-mode hot reload** — they +bind-mount the project source (`..:/app`), keep an anonymous `node_modules` +volume, run `install` + the dev server, and set the framework's file-watch polling +env for WSL2. See [templates.md](templates.md). You can still author your own +variant with `template new`; a minimal `node.next`-style template: ```bash devstack template new node.next --kind app ``` -A minimal `node.next` template that runs `next dev` with hot reload: +A minimal template that runs `next dev` with hot reload: ```yaml # ~/.devstack/templates/node.next/template.yaml diff --git a/internal/cli/js_templates_test.go b/internal/cli/js_templates_test.go new file mode 100644 index 0000000..19fb8d7 --- /dev/null +++ b/internal/cli/js_templates_test.go @@ -0,0 +1,37 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/template" +) + +// TestJSTemplatesRenderAndValidate guards the built-in JS/monorepo app templates: +// each resolves through the embedded source, validates via compose-go, and emits +// the dev-mode hot-reload shape (a /app mount + a dev command). +func TestJSTemplatesRenderAndValidate(t *testing.T) { + src := builtinSource() + for _, name := range []string{ + "node.express", "node.nestjs", "node.next", "react.vite", "bun.app", "turborepo", + } { + res, err := template.Resolve(src, name, nil) + if err != nil { + t.Errorf("%s resolve: %v", name, err) + continue + } + compose, err := generate.LintResolved(name, res) + if err != nil { + t.Errorf("%s lint: %v", name, err) + continue + } + doc := string(compose) + if !strings.Contains(doc, "/app") { + t.Errorf("%s: missing /app mount for hot reload:\n%s", name, doc) + } + if !strings.Contains(doc, "dev") { + t.Errorf("%s: missing a dev command", name) + } + } +} diff --git a/templates/README.md b/templates/README.md index 595c846..232ce13 100644 --- a/templates/README.md +++ b/templates/README.md @@ -14,6 +14,17 @@ Built-in service templates, compiled into the binary via `go:embed` | `php.nginx` | project base | PHP-FPM build (`build/Dockerfile`); parent template | | `php.laravel.nginx` | project | `extends: php.nginx`; adds Laravel env + entrypoint | | `node.vite` | project | Node + Vite dev server build | +| `node.express` | project | Express dev server (`npm run dev`), source bind-mount + hot reload | +| `node.nestjs` | project | NestJS (`nest start --watch`), hot reload | +| `node.next` | project | Next.js (`next dev`), `WATCHPACK_POLLING` for WSL2 | +| `react.vite` | project | React + Vite dev server (HMR) | +| `bun.app` | project | Bun app (`bun run dev`), `oven/bun` base | +| `turborepo` | project (monorepo) | `turbo run dev` across packages; pairs with `devstack run` | + +**Dev-mode hot reload.** The JS/TS app templates bind-mount the project source +(`..:/app`, since the generated compose lives in `/.devstack/`) with an +anonymous `node_modules` volume, run `npm/bun install` then the dev server, and +set the framework's file-watch polling env for WSL2/9p reliability. ## A template is a directory diff --git a/templates/bun.app/build/Dockerfile b/templates/bun.app/build/Dockerfile new file mode 100644 index 0000000..90eda77 --- /dev/null +++ b/templates/bun.app/build/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG BUN_VERSION=[[ .params.bunVersion ]] +FROM oven/bun:${BUN_VERSION}-alpine +WORKDIR /app +EXPOSE 3000 +CMD ["bun", "run", "dev"] diff --git a/templates/bun.app/template.yaml b/templates/bun.app/template.yaml new file mode 100644 index 0000000..59c1bdc --- /dev/null +++ b/templates/bun.app/template.yaml @@ -0,0 +1,20 @@ +schemaVersion: 1 +description: "Bun app dev server (bun run dev) with hot reload." +params: + bunVersion: + type: string + default: "1" + description: "Bun major version image tag." +service: + build: + context: build + dockerfile: Dockerfile + args: + BUN_VERSION: "[[ .params.bunVersion ]]" + restart: unless-stopped + command: ["sh", "-lc", "bun install && bun run dev"] + environment: + NODE_ENV: development + volumes: + - "..:/app" + - "/app/node_modules" diff --git a/templates/embed.go b/templates/embed.go index 4cdade7..cea3bfa 100644 --- a/templates/embed.go +++ b/templates/embed.go @@ -7,7 +7,7 @@ package templates import "embed" -//go:embed all:postgres all:redis all:minio all:php.nginx all:php.laravel.nginx all:node.vite all:localstack all:ministack all:nats all:kafka all:rabbitmq +//go:embed all:postgres all:redis all:minio all:php.nginx all:php.laravel.nginx all:node.vite all:localstack all:ministack all:nats all:kafka all:rabbitmq all:node.express all:node.nestjs all:node.next all:react.vite all:bun.app all:turborepo var builtinFS embed.FS // FS is the embedded built-in templates root: template-name directories at the diff --git a/templates/node.express/build/Dockerfile b/templates/node.express/build/Dockerfile new file mode 100644 index 0000000..cc52328 --- /dev/null +++ b/templates/node.express/build/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=[[ .params.nodeVersion ]] +FROM node:${NODE_VERSION}-alpine +WORKDIR /app +EXPOSE 3000 +CMD ["npm", "run", "dev"] diff --git a/templates/node.express/template.yaml b/templates/node.express/template.yaml new file mode 100644 index 0000000..f745a47 --- /dev/null +++ b/templates/node.express/template.yaml @@ -0,0 +1,24 @@ +schemaVersion: 1 +description: "Express dev server (npm run dev / nodemon) with hot reload." +params: + nodeVersion: + type: string + default: "20" + description: "Node major version image tag." +service: + build: + context: build + dockerfile: Dockerfile + args: + NODE_VERSION: "[[ .params.nodeVersion ]]" + restart: unless-stopped + # Install deps into the container (anon node_modules volume) then run the dev + # server. Source is bind-mounted from the project dir for hot reload. + command: ["sh", "-lc", "npm install && npm run dev"] + environment: + NODE_ENV: development + CHOKIDAR_USEPOLLING: "true" + volumes: + # compose lives in /.devstack, so ".." is the project source. + - "..:/app" + - "/app/node_modules" diff --git a/templates/node.nestjs/build/Dockerfile b/templates/node.nestjs/build/Dockerfile new file mode 100644 index 0000000..cc52328 --- /dev/null +++ b/templates/node.nestjs/build/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=[[ .params.nodeVersion ]] +FROM node:${NODE_VERSION}-alpine +WORKDIR /app +EXPOSE 3000 +CMD ["npm", "run", "dev"] diff --git a/templates/node.nestjs/template.yaml b/templates/node.nestjs/template.yaml new file mode 100644 index 0000000..7888237 --- /dev/null +++ b/templates/node.nestjs/template.yaml @@ -0,0 +1,24 @@ +schemaVersion: 1 +description: "NestJS dev server (nest start --watch) with hot reload." +params: + nodeVersion: + type: string + default: "20" + description: "Node major version image tag." +service: + build: + context: build + dockerfile: Dockerfile + args: + NODE_VERSION: "[[ .params.nodeVersion ]]" + restart: unless-stopped + # Install deps into the container (anon node_modules volume) then run the dev + # server. Source is bind-mounted from the project dir for hot reload. + command: ["sh", "-lc", "npm install && npm run start:dev"] + environment: + NODE_ENV: development + CHOKIDAR_USEPOLLING: "true" + volumes: + # compose lives in /.devstack, so ".." is the project source. + - "..:/app" + - "/app/node_modules" diff --git a/templates/node.next/build/Dockerfile b/templates/node.next/build/Dockerfile new file mode 100644 index 0000000..cc52328 --- /dev/null +++ b/templates/node.next/build/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=[[ .params.nodeVersion ]] +FROM node:${NODE_VERSION}-alpine +WORKDIR /app +EXPOSE 3000 +CMD ["npm", "run", "dev"] diff --git a/templates/node.next/template.yaml b/templates/node.next/template.yaml new file mode 100644 index 0000000..16e967d --- /dev/null +++ b/templates/node.next/template.yaml @@ -0,0 +1,24 @@ +schemaVersion: 1 +description: "Next.js dev server (next dev) with hot reload." +params: + nodeVersion: + type: string + default: "20" + description: "Node major version image tag." +service: + build: + context: build + dockerfile: Dockerfile + args: + NODE_VERSION: "[[ .params.nodeVersion ]]" + restart: unless-stopped + # Install deps into the container (anon node_modules volume) then run the dev + # server. Source is bind-mounted from the project dir for hot reload. + command: ["sh", "-lc", "npm install && npm run dev"] + environment: + NODE_ENV: development + WATCHPACK_POLLING: "true" + volumes: + # compose lives in /.devstack, so ".." is the project source. + - "..:/app" + - "/app/node_modules" diff --git a/templates/react.vite/build/Dockerfile b/templates/react.vite/build/Dockerfile new file mode 100644 index 0000000..dc30719 --- /dev/null +++ b/templates/react.vite/build/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=[[ .params.nodeVersion ]] +FROM node:${NODE_VERSION}-alpine +WORKDIR /app +EXPOSE 5173 +CMD ["npm", "run", "dev"] diff --git a/templates/react.vite/template.yaml b/templates/react.vite/template.yaml new file mode 100644 index 0000000..afbdf40 --- /dev/null +++ b/templates/react.vite/template.yaml @@ -0,0 +1,24 @@ +schemaVersion: 1 +description: "React + Vite dev server with HMR." +params: + nodeVersion: + type: string + default: "20" + description: "Node major version image tag." +service: + build: + context: build + dockerfile: Dockerfile + args: + NODE_VERSION: "[[ .params.nodeVersion ]]" + restart: unless-stopped + # Install deps into the container (anon node_modules volume) then run the dev + # server. Source is bind-mounted from the project dir for hot reload. + command: ["sh", "-lc", "npm install && npm run dev -- --host"] + environment: + NODE_ENV: development + CHOKIDAR_USEPOLLING: "true" + volumes: + # compose lives in /.devstack, so ".." is the project source. + - "..:/app" + - "/app/node_modules" diff --git a/templates/turborepo/build/Dockerfile b/templates/turborepo/build/Dockerfile new file mode 100644 index 0000000..cc52328 --- /dev/null +++ b/templates/turborepo/build/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=[[ .params.nodeVersion ]] +FROM node:${NODE_VERSION}-alpine +WORKDIR /app +EXPOSE 3000 +CMD ["npm", "run", "dev"] diff --git a/templates/turborepo/template.yaml b/templates/turborepo/template.yaml new file mode 100644 index 0000000..c8e862d --- /dev/null +++ b/templates/turborepo/template.yaml @@ -0,0 +1,24 @@ +schemaVersion: 1 +description: "Turborepo monorepo dev (turbo run dev across packages)." +params: + nodeVersion: + type: string + default: "20" + description: "Node major version image tag." +service: + build: + context: build + dockerfile: Dockerfile + args: + NODE_VERSION: "[[ .params.nodeVersion ]]" + restart: unless-stopped + # Install deps into the container (anon node_modules volume) then run the dev + # server. Source is bind-mounted from the project dir for hot reload. + command: ["sh", "-lc", "npm install && npx turbo run dev"] + environment: + NODE_ENV: development + TURBO_TELEMETRY_DISABLED: "1" + volumes: + # compose lives in /.devstack, so ".." is the project source. + - "..:/app" + - "/app/node_modules"