diff --git a/Dockerfile b/Dockerfile index 18bf441..9f67a07 100755 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ FROM ${BASE_IMAGE} LABEL com.wodby.ci.cache="npm" ARG NODE_DEV +# Only development variants implement the workspace runner contract. +LABEL com.wodby.workspace.contract="${NODE_DEV:+1}" ENV NODE_DEV="${NODE_DEV}" ARG NPM_VERSION=11.19.1 ARG TARGETPLATFORM diff --git a/README.md b/README.md index e15a22c..2f7453f 100644 --- a/README.md +++ b/README.md @@ -66,3 +66,30 @@ image. A version without a pin fails before the build starts. When adding a supported base version or variant, add its image index digest to `base-images.mk`. For a custom build, override `BASE_IMAGE` with a complete `repository:tag@sha256:...` reference. + +### Workspace image contract + +Development variants declare `com.wodby.workspace.contract=1`; ordinary variants +leave it empty. The contract covers SSH/tool availability, workspace startup and +preparation, and login-shell tool discovery. CI checks the label and runtime tools. + +`workspace-node` enables Chokidar and Watchpack polling at 1000 ms for shared +volumes. Set `WORKSPACE_POLL_INTERVAL` (100–60000 ms) to tune the cost, or +`WORKSPACE_POLLING=0` to disable these defaults. Explicit watcher variables +are preserved. This does not enable watching in scripts that have no watcher. +Keep dependencies/build output excluded in project watcher configuration. + +`workspace-node next-start` starts the installed Next.js CLI with Webpack polling +(adding `--webpack` on Next 16+), using `HOST` and `PORT`. It does not run custom +package lifecycle scripts; use `WORKSPACE_NODE_COMMAND` for a custom command with +its own shared-volume watcher configuration. Angular requires `ng serve --poll 1000`. +Vite's Chokidar watcher uses the polling environment; other watcher engines need +explicit project configuration such as `server.watch.usePolling: true`. + +Preparation without an npm lockfile uses `--package-lock=false`. Dependency lifecycle +scripts remain project-owned and may change files; review their changes before committing. + +Use `workspace-node vite-start` for React/Vue Vite projects: it loads the existing +Vite config and enables both Chokidar and Rolldown polling in memory. Use +`workspace-node angular-start` for Angular's CLI polling. These helpers call the +framework directly, so custom package lifecycle scripts require a custom command. diff --git a/bin/workspace-node b/bin/workspace-node index 8a5b597..62f7b1c 100755 --- a/bin/workspace-node +++ b/bin/workspace-node @@ -1,10 +1,19 @@ #!/bin/sh -# Run project-owned package scripts without changing the repository or lockfile. +# Prepare dependencies and start workspace commands; project scripts retain their behavior. set -eu export NODE_ENV=development export PORT="${PORT:-${NODE_PORT:-3000}}" export HOST="${HOST:-0.0.0.0}" export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +# Network-backed checkouts need polling; frameworks can explicitly override it. +if [ "${WORKSPACE_POLLING:-1}" != 0 ]; then + interval="${WORKSPACE_POLL_INTERVAL:-1000}" + case "$interval" in ''|*[!0-9]*) echo 'WORKSPACE_POLL_INTERVAL must be milliseconds' >&2; exit 1 ;; esac + [ "$interval" -ge 100 ] && [ "$interval" -le 60000 ] || { echo 'Polling interval must be 100..60000 ms' >&2; exit 1; } + export CHOKIDAR_USEPOLLING="${CHOKIDAR_USEPOLLING:-true}" + export CHOKIDAR_INTERVAL="${CHOKIDAR_INTERVAL:-$interval}" + export WATCHPACK_POLLING="${WATCHPACK_POLLING:-$interval}" +fi cd "${APP_ROOT:-/usr/src/app}" test -f package.json || { echo 'Workspace requires package.json' >&2; exit 1; } manager=$(node -p 'const p=require("./package.json"); (p.packageManager || "").split("@")[0]') @@ -21,11 +30,26 @@ run_manager() { esac } case "${1:-}" in + vite-start) + exec node /usr/local/bin/workspace-vite.mjs + ;; + angular-start) + set -- --host "$HOST" --port "$PORT" + if [ "${WORKSPACE_POLLING:-1}" != 0 ]; then set -- "$@" --poll "${WORKSPACE_POLL_INTERVAL:-1000}"; fi + exec node node_modules/@angular/cli/bin/ng.js serve "$@" + ;; + next-start) + # Next 16 defaults to Turbopack. Use Webpack's polling path for shared volumes. + major=$(node -p 'parseInt(require("./node_modules/next/package.json").version, 10)') + set -- --hostname "$HOST" --port "$PORT" + if [ "$major" -ge 16 ]; then set -- --webpack "$@"; fi + exec node node_modules/next/dist/bin/next dev "$@" + ;; prepare) case "$manager" in npm) if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then npm ci --include=dev --no-audit --no-fund - else npm install --include=dev --no-audit --no-fund; fi ;; + else npm install --include=dev --no-audit --no-fund --package-lock=false; fi ;; pnpm) run_manager install --frozen-lockfile ;; yarn) case "$(run_manager --version)" in @@ -35,15 +59,17 @@ case "${1:-}" in *) run_manager install ;; esac ;; start) - if [ -n "${WODBY_WORKSPACE_COMMAND:-}" ]; then - exec /bin/sh -ec "$WODBY_WORKSPACE_COMMAND" + # Keep the pre-contract image override as a compatibility alias. + command="${WORKSPACE_NODE_COMMAND:-${WODBY_WORKSPACE_COMMAND:-}}" + if [ -n "$command" ]; then + exec /bin/sh -ec "$command" fi script=$(node -p 'const s=require("./package.json").scripts || {}; s.dev ? "dev" : s.start ? "start" : ""') - [ -n "$script" ] || { echo 'Define a dev/start package script or WODBY_WORKSPACE_COMMAND' >&2; exit 1; } + [ -n "$script" ] || { echo 'Define a dev/start package script or WORKSPACE_NODE_COMMAND' >&2; exit 1; } case "$manager" in npm) exec npm run "$script" ;; yarn|pnpm) exec corepack "$manager" run "$script" ;; *) run_manager run "$script" ;; esac ;; - *) echo 'Usage: workspace-node prepare|start' >&2; exit 1 ;; + *) echo 'Usage: workspace-node prepare|start|next-start|vite-start|angular-start' >&2; exit 1 ;; esac diff --git a/bin/workspace-vite.mjs b/bin/workspace-vite.mjs new file mode 100644 index 0000000..b881f00 --- /dev/null +++ b/bin/workspace-vite.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Load the project's own Vite configuration, then enable shared-volume polling +// for both Chokidar and Rolldown without writing a generated config into Git. +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const require = createRequire(join(process.cwd(), 'package.json')); +const { createServer, loadConfigFromFile, mergeConfig } = await import(pathToFileURL(require.resolve('vite')).href); +const configEnv = { command: 'serve', mode: process.env.WORKSPACE_VITE_MODE || 'development', isSsrBuild: false, isPreview: false }; +const loaded = await loadConfigFromFile(configEnv); +const interval = Number(process.env.WORKSPACE_POLL_INTERVAL || 1000); +const polling = process.env.WORKSPACE_POLLING !== '0'; +const server = await createServer(mergeConfig(loaded?.config || {}, { + configFile: false, + mode: configEnv.mode, + server: { + host: process.env.HOST, + port: Number(process.env.PORT), + strictPort: true, + watch: { usePolling: polling, interval, pollInterval: interval }, + }, +})); +await server.listen(); +server.printUrls(); +for (const signal of ['SIGTERM', 'SIGINT']) { + process.once(signal, async () => { await server.close(); process.exit(0); }); +} diff --git a/tests/run.sh b/tests/run.sh index ba682e3..211f8a4 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -2,6 +2,11 @@ set -e +bash "$PWD/workspace-contract.sh" +if docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$IMAGE" | grep -Eq '^NODE_DEV=.+$'; then + bash "$PWD/workspace-reload.sh" +fi + # Validate the development tool contract before application integration tests. docker run --rm --network none --entrypoint /bin/sh -v "$PWD/development-tools.sh:/tmp/development-tools.sh:ro" "${IMAGE}" /tmp/development-tools.sh diff --git a/tests/workspace-contract.sh b/tests/workspace-contract.sh new file mode 100644 index 0000000..c2f6632 --- /dev/null +++ b/tests/workspace-contract.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Ensure ordinary images do not advertise the development-only contract. +set -euo pipefail +label=$(docker image inspect --format '{{index .Config.Labels "com.wodby.workspace.contract"}}' "$IMAGE") +if docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$IMAGE" | grep -Eq '^NODE_DEV=.+$'; then + test "$label" = 1 +else + test -z "$label" +fi diff --git a/tests/workspace-reload.sh b/tests/workspace-reload.sh new file mode 100644 index 0000000..9d06910 --- /dev/null +++ b/tests/workspace-reload.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# HTTP refresh smoke test with a separate container writing the shared checkout. +# This does not substitute for cross-node NFS and browser/WebSocket acceptance. +set -euo pipefail +volume="workspace-contract-smoke-$$" +server="workspace-contract-server-$$" +results=$(mktemp -d) +docker volume create "$volume" >/dev/null +cleanup() { docker rm -f "$server" >/dev/null 2>&1 || true; docker volume rm "$volume" >/dev/null; rm -rf "$results"; } +trap cleanup EXIT +image="${IMAGE:?Set IMAGE to the candidate Node development image}" +docker run --rm --user 0 --entrypoint sh -v "$volume:/fixture" "$image" -ec ' +cd /fixture +printf "{\"name\":\"workspace-smoke\",\"private\":true}" > package.json +npm install --no-audit --no-fund --package-lock=false next@16.3.6 react@19.2.4 react-dom@19.2.4 vite@8.3.0 +mkdir pages +printf "export default function Page(){return