Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
38 changes: 32 additions & 6 deletions bin/workspace-node
Original file line number Diff line number Diff line change
@@ -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]')
Expand All @@ -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
Expand All @@ -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
27 changes: 27 additions & 0 deletions bin/workspace-vite.mjs
Original file line number Diff line number Diff line change
@@ -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); });
}
5 changes: 5 additions & 0 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions tests/workspace-contract.sh
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions tests/workspace-reload.sh
Original file line number Diff line number Diff line change
@@ -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 <div>before-edit</div>}" > pages/index.jsx
printf "<script type=\"module\" src=\"/main.js\"></script>" > index.html
printf "document.body.textContent=\"before-edit\";" > main.js
chown -R node:node /fixture
'
for framework in vite next; do
docker run -d --name "$server" --network none --entrypoint /usr/local/bin/workspace-node -e APP_ROOT=/fixture -e NEXT_TELEMETRY_DISABLED=1 -v "$volume:/fixture" "$image" "$framework-start" >/dev/null
ready=0
for i in $(seq 1 40); do
if docker exec "$server" curl -fs http://localhost:3000/ > "$results/response"; then ready=1;break;fi
sleep 1
done
if [ "$ready" != 1 ]; then docker logs "$server";exit 1;fi
if [ "$framework" = vite ]; then
docker exec "$server" curl -fsS http://localhost:3000/main.js | grep -q before-edit
docker run --rm --network none --entrypoint sh -v "$volume:/fixture" "$image" -ec 'printf "document.body.textContent=\"after-edit\";" > /fixture/main.js'
url=http://localhost:3000/main.js
else
grep -q before-edit "$results/response"
docker run --rm --network none --entrypoint sh -v "$volume:/fixture" "$image" -ec 'printf "export default function Page(){return <div>after-edit</div>}" > /fixture/pages/index.jsx'
url=http://localhost:3000/
fi
changed=0
for i in $(seq 1 30); do
if docker exec "$server" curl -fsS "$url" | grep after-edit >/dev/null; then changed=1;break;fi
sleep 1
done
if [ "$changed" != 1 ]; then docker logs "$server";exit 1;fi
docker logs "$server" > "$results/$framework.log" 2>&1
docker rm -f "$server" >/dev/null
echo "$framework: second-container edit appeared in HTTP response"
done
24 changes: 23 additions & 1 deletion tests/workspace-runtime.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ cat > "$fixture/package.json" <<'JSON'
JSON
printf 'console.log(process.env.NODE_ENV + ":" + process.env.PORT)\n' > "$fixture/check.js"
workspace-node prepare
test ! -e "$fixture/package-lock.json"
test -e "$fixture/node_modules/dev-tool/package.json"
workspace-node prepare
test -e "$fixture/node_modules/dev-tool/package.json"
WODBY_WORKSPACE=1 /docker-entrypoint.sh node | grep -q development:3000
WODBY_WORKSPACE=1 WODBY_WORKSPACE_COMMAND='printf custom-command' /docker-entrypoint.sh node | grep -q custom-command
WODBY_WORKSPACE=1 WORKSPACE_NODE_COMMAND='printf custom-command' /docker-entrypoint.sh node | grep -q custom-command
/docker-entrypoint.sh node -e 'console.log("standard-command")' | grep -q standard-command
cat > "$fixture/package.json" <<'JSON'
{"scripts":{"start":"node check.js"}}
Expand All @@ -24,3 +25,24 @@ workspace-node start | grep -q development:3000
printf '{}' > "$fixture/package.json"
if workspace-node start > "$fixture/error" 2>&1; then exit 1; fi
grep -q 'Define a dev/start' "$fixture/error"

# Polling reaches custom/project commands without rewriting project files.
WORKSPACE_NODE_COMMAND='printf "%s:%s" "$CHOKIDAR_USEPOLLING" "$WATCHPACK_POLLING"' workspace-node start | grep -q true:1000
WORKSPACE_POLL_INTERVAL=2000 WORKSPACE_NODE_COMMAND='printf "%s" "$WATCHPACK_POLLING"' workspace-node start | grep -q 2000
if WORKSPACE_POLL_INTERVAL=invalid workspace-node start > "$fixture/error" 2>&1; then exit 1; fi
mkdir -p "$fixture/node_modules/next/dist/bin"
printf '{"version":"16.0.0"}' > "$fixture/node_modules/next/package.json"
printf 'console.log(process.argv.slice(2).join(" "))' > "$fixture/node_modules/next/dist/bin/next"
workspace-node next-start | grep -q 'dev --webpack --hostname 0.0.0.0 --port 3000'
printf '{"version":"15.0.0"}' > "$fixture/node_modules/next/package.json"
workspace-node next-start | grep -q 'dev --hostname 0.0.0.0 --port 3000'

# A tracked lockfile is consumed without being regenerated.
printf '{"scripts":{"dev":"node check.js"}}' > "$fixture/package.json"
(cd "$fixture" && npm install --package-lock-only --ignore-scripts --no-audit --no-fund)
before=$(sha256sum "$fixture/package-lock.json")
workspace-node prepare
test "$before" = "$(sha256sum "$fixture/package-lock.json")"
mkdir -p "$fixture/node_modules/@angular/cli/bin"
printf 'console.log(process.argv.slice(2).join(" "))' > "$fixture/node_modules/@angular/cli/bin/ng.js"
workspace-node angular-start | grep -q 'serve --host 0.0.0.0 --port 3000 --poll 1000'
Loading