mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-30 10:16:17 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3011334034 | |||
| b772ec248e | |||
| 9fafa9ff6c | |||
| 7095d7d051 | |||
| 543118fc61 | |||
| 85a3fbc539 | |||
| 6d84091db8 | |||
| 1081689768 | |||
| db1ad563c1 | |||
| 5754be6db4 | |||
| d030fdcb32 |
@@ -20,6 +20,7 @@ yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler, run without file arguments
|
||||
yarn test # Vitest
|
||||
yarn build # Full production build
|
||||
yarn dev # App dev server
|
||||
yarn dev:serve # Local serving dev server
|
||||
```
|
||||
@@ -28,6 +29,26 @@ Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `t
|
||||
|
||||
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
|
||||
|
||||
## Production Builds
|
||||
|
||||
Production builds support foreground and managed background execution:
|
||||
|
||||
```bash
|
||||
yarn build # Full foreground build
|
||||
yarn build --background # Full managed background build
|
||||
yarn build --modern # Modern frontend_latest bundle only
|
||||
yarn build --modern --background # Modern managed background build
|
||||
yarn build --status
|
||||
yarn build --logs [--follow]
|
||||
yarn build --stop
|
||||
```
|
||||
|
||||
Use `yarn build --modern --background` for production bundle-size or browser performance comparisons that only need modern browser output. It runs the normal metadata and static preparation, minifies and compresses the modern `frontend_latest` bundle and shared static assets, and generates modern-only entry pages and service workers. It deliberately skips the legacy bundle and its service worker.
|
||||
|
||||
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. App builds and development servers keep exclusive ownership of `hass_frontend/` for their lifetime.
|
||||
|
||||
Top-level app, demo, gallery, e2e-app, cast, and landing-page Gulp workflows serialise the phase that deletes or regenerates shared files under `build/`. Each suite also owns its output directory for the complete build or development-server lifetime. Unrelated development servers can coexist after their shared generation phase, but a build cannot overwrite its matching server output.
|
||||
|
||||
## Unit And Utility Tests
|
||||
|
||||
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
|
||||
@@ -41,7 +62,7 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal. `yarn dev` and `yarn dev:serve` share one managed process slot because both write the app output.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ This is the repository for the official [Home Assistant](https://home-assistant.
|
||||
|
||||
- Initial setup: `script/setup`
|
||||
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
- Production build: `script/build_frontend`
|
||||
- Production build: `yarn build`
|
||||
- Gallery: `cd gallery && script/develop_gallery`
|
||||
|
||||
## Frontend development
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// Manage a Home Assistant frontend production build with an agent-friendly
|
||||
// interface, matching build-scripts/dev-server.mjs.
|
||||
//
|
||||
// node build-scripts/build-manager.mjs [--modern] [mode]
|
||||
//
|
||||
// (no mode) Run in the foreground.
|
||||
// --background Start detached, print the pid, then exit and leave it
|
||||
// running.
|
||||
// --status Report whether a managed build is running.
|
||||
// --stop Stop a managed build.
|
||||
// --logs [--follow] Print (or follow) the background build log.
|
||||
//
|
||||
// --modern Build only the modern frontend_latest bundle.
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
LIFECYCLE_MODE_FLAGS,
|
||||
acquireProcessRecord,
|
||||
isProcessRecordAlive,
|
||||
outputLog,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
runCli,
|
||||
spawnDetachedToLog,
|
||||
spawnForeground,
|
||||
terminateProcess,
|
||||
waitFor,
|
||||
writeProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
import {
|
||||
describeOutputOwner,
|
||||
outputLockEnv,
|
||||
outputLockFile,
|
||||
} from "./output-lock.mjs";
|
||||
import { GULP_TASKS } from "./gulp-tasks.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
|
||||
const stateDir = path.join(repoRoot, "node_modules", ".cache", "ha-build");
|
||||
const logFile = path.join(stateDir, "build.log");
|
||||
const lockFile = outputLockFile("app");
|
||||
|
||||
const usage = () => {
|
||||
process.stderr.write(
|
||||
"Usage: node build-scripts/build-manager.mjs [--modern] " +
|
||||
"[--background | --status | --stop | --logs [--follow]]\n"
|
||||
);
|
||||
};
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const args = {
|
||||
mode: "foreground",
|
||||
modes: [],
|
||||
follow: false,
|
||||
modern: false,
|
||||
unknown: [],
|
||||
};
|
||||
for (const arg of argv) {
|
||||
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
|
||||
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
|
||||
args.modes.push(arg);
|
||||
} else if (arg === "--modern") {
|
||||
args.modern = true;
|
||||
} else if (arg === "--follow") {
|
||||
args.follow = true;
|
||||
} else {
|
||||
args.unknown.push(arg);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const hints = () =>
|
||||
" Stop: yarn build --stop\n" +
|
||||
" Status: yarn build --status\n" +
|
||||
" Logs: yarn build --logs\n";
|
||||
|
||||
const readBuild = () => readProcessRecord(lockFile);
|
||||
|
||||
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
|
||||
|
||||
const acquireBuild = (modern, foreground) => {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const result = acquireProcessRecord(lockFile, {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
processGroup: false,
|
||||
foreground,
|
||||
kind: "build",
|
||||
modern,
|
||||
starting: true,
|
||||
token,
|
||||
});
|
||||
return result.acquired ? { token } : { existing: result.existing };
|
||||
};
|
||||
|
||||
const updateBuild = (token, child, processGroup) => {
|
||||
const existing = readBuild();
|
||||
if (existing?.token !== token) {
|
||||
throw Error("Frontend build lock ownership was lost during startup.");
|
||||
}
|
||||
writeProcessRecord(lockFile, {
|
||||
...existing,
|
||||
pid: child.pid,
|
||||
startTime: processStartTime(child.pid),
|
||||
processGroup,
|
||||
starting: false,
|
||||
});
|
||||
};
|
||||
|
||||
const taskFor = (modern) =>
|
||||
modern ? GULP_TASKS.app.modern : GULP_TASKS.app.build;
|
||||
|
||||
const reportExisting = (existing) => {
|
||||
if (existing?.kind === "output") {
|
||||
process.stdout.write(
|
||||
`${describeOutputOwner(existing)} already owns the app output` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (existing?.kind === "dev") {
|
||||
const command = existing.suite === "app-serve" ? "dev:serve" : "dev";
|
||||
process.stdout.write(
|
||||
`Dev server (${existing.suite}) already running` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
|
||||
` Stop: yarn ${command} --stop\n` +
|
||||
` Status: yarn ${command} --status\n` +
|
||||
` Logs: yarn ${command} --logs\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`Frontend ${existing?.modern ? "modern " : ""}build already running` +
|
||||
`${existing?.pid ? ` (pid ${existing.pid})` : ""}.\n${hints()}`
|
||||
);
|
||||
};
|
||||
|
||||
const runForeground = async (modern) => {
|
||||
const lock = acquireBuild(modern, true);
|
||||
if (!lock.token) {
|
||||
reportExisting(lock.existing);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
return await spawnForeground({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: outputLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateBuild(lock.token, child, true),
|
||||
});
|
||||
} finally {
|
||||
releaseBuild(lock.token);
|
||||
}
|
||||
};
|
||||
|
||||
const runBackground = async (modern) => {
|
||||
const lock = acquireBuild(modern, false);
|
||||
if (!lock.token) {
|
||||
reportExisting(lock.existing);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const child = await spawnDetachedToLog({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: outputLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
updateBuild(lock.token, child, true);
|
||||
process.stdout.write(
|
||||
`Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` +
|
||||
hints()
|
||||
);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
releaseBuild(lock.token);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const runStatus = () => {
|
||||
const existing = readBuild();
|
||||
if (existing?.kind === "build" && isProcessRecordAlive(existing)) {
|
||||
process.stdout.write(
|
||||
`Frontend ${existing.modern ? "modern " : ""}build running (pid ${existing.pid}).\n`
|
||||
);
|
||||
} else {
|
||||
if (existing?.kind === "build") {
|
||||
releaseBuild(existing.token);
|
||||
}
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStop = async () => {
|
||||
let existing = readBuild();
|
||||
if (existing?.kind !== "build") {
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
return 0;
|
||||
}
|
||||
if (existing?.starting) {
|
||||
const token = existing.token;
|
||||
await waitFor(
|
||||
() => {
|
||||
const current = readBuild();
|
||||
return !current?.starting || current.token !== token;
|
||||
},
|
||||
100,
|
||||
5000
|
||||
);
|
||||
existing = readBuild();
|
||||
}
|
||||
if (
|
||||
!existing ||
|
||||
existing.kind !== "build" ||
|
||||
!isProcessRecordAlive(existing)
|
||||
) {
|
||||
if (existing) releaseBuild(existing.token);
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
return 0;
|
||||
}
|
||||
if (
|
||||
!(await terminateProcess({
|
||||
pid: existing.pid,
|
||||
processGroup: existing.processGroup,
|
||||
isStopped: () => !isProcessRecordAlive(existing),
|
||||
}))
|
||||
) {
|
||||
process.stderr.write(
|
||||
`Failed to stop frontend build (pid ${existing.pid}). Stop it manually.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
releaseBuild(existing.token);
|
||||
process.stdout.write(`Stopped frontend build (pid ${existing.pid}).\n`);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runLogs = (follow) =>
|
||||
outputLog(logFile, follow, `No frontend build log yet (${logFile}).\n`);
|
||||
|
||||
const main = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.unknown.length) {
|
||||
process.stderr.write(`Unknown arguments: ${args.unknown.join(" ")}\n`);
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (
|
||||
args.modes.length > 1 ||
|
||||
(args.follow && args.mode !== "logs") ||
|
||||
(args.modern && !["foreground", "background"].includes(args.mode))
|
||||
) {
|
||||
process.stderr.write("Invalid combination of build arguments.\n");
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
const handlers = {
|
||||
foreground: () => runForeground(args.modern),
|
||||
background: () => runBackground(args.modern),
|
||||
status: runStatus,
|
||||
stop: runStop,
|
||||
logs: () => runLogs(args.follow),
|
||||
};
|
||||
return handlers[args.mode]();
|
||||
};
|
||||
|
||||
runCli(main);
|
||||
+417
-266
@@ -20,10 +20,33 @@
|
||||
// no health endpoint, and plain yarn dev has no port at all, so these
|
||||
// track a pidfile and treat the first "Build done" log line as ready.
|
||||
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
LIFECYCLE_MODE_FLAGS,
|
||||
acquireProcessRecord,
|
||||
isProcessRecordAlive,
|
||||
outputLog,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
removeProcessRecord,
|
||||
runCli,
|
||||
sleep,
|
||||
spawnDetachedToLog,
|
||||
spawnForeground,
|
||||
terminateProcess,
|
||||
waitFor,
|
||||
writeProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
import {
|
||||
describeOutputOwner,
|
||||
outputLockEnv,
|
||||
outputLockFile,
|
||||
} from "./output-lock.mjs";
|
||||
import { GULP_TASKS } from "./gulp-tasks.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
@@ -36,48 +59,69 @@ const developAndServeScript = path.join(
|
||||
"develop_and_serve"
|
||||
);
|
||||
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
|
||||
const appOutputLockFile = outputLockFile("app");
|
||||
|
||||
// Each suite names its yarn alias (for hints), a liveness model, and how to
|
||||
// spawn it. health suites carry a fixed port; process suites carry the log line
|
||||
// that means "ready" and, for app-serve, forward extra args to the script.
|
||||
const SUITES = {
|
||||
"e2e-app": {
|
||||
alias: "test:e2e:app:dev",
|
||||
liveness: "health",
|
||||
port: 8095,
|
||||
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
|
||||
},
|
||||
demo: {
|
||||
alias: "dev:demo",
|
||||
liveness: "health",
|
||||
port: 8090,
|
||||
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
||||
},
|
||||
gallery: {
|
||||
alias: "dev:gallery",
|
||||
liveness: "health",
|
||||
port: 8100,
|
||||
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
||||
},
|
||||
app: {
|
||||
alias: "dev",
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
},
|
||||
"app-serve": {
|
||||
alias: "dev:serve",
|
||||
liveness: "process",
|
||||
acceptsArgs: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
},
|
||||
};
|
||||
const SUITES = new Map([
|
||||
[
|
||||
"e2e-app",
|
||||
{
|
||||
alias: "test:e2e:app:dev",
|
||||
liveness: "health",
|
||||
port: 8095,
|
||||
spawn: { cmd: gulpBin, args: [GULP_TASKS.e2eApp.develop] },
|
||||
},
|
||||
],
|
||||
[
|
||||
"demo",
|
||||
{
|
||||
alias: "dev:demo",
|
||||
liveness: "health",
|
||||
port: 8090,
|
||||
spawn: { cmd: gulpBin, args: [GULP_TASKS.demo.develop] },
|
||||
},
|
||||
],
|
||||
[
|
||||
"gallery",
|
||||
{
|
||||
alias: "dev:gallery",
|
||||
liveness: "health",
|
||||
port: 8100,
|
||||
spawn: { cmd: gulpBin, args: [GULP_TASKS.gallery.develop] },
|
||||
},
|
||||
],
|
||||
[
|
||||
"app",
|
||||
{
|
||||
alias: "dev",
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: [GULP_TASKS.app.develop] },
|
||||
processKey: "app",
|
||||
},
|
||||
],
|
||||
[
|
||||
"app-serve",
|
||||
{
|
||||
alias: "dev:serve",
|
||||
liveness: "process",
|
||||
acceptsArgs: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
processKey: "app",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// Cover a cold build on a slow machine before the server starts listening.
|
||||
// Override with HA_DEV_SERVER_TIMEOUT (seconds).
|
||||
const readyTimeoutSeconds = Number(process.env.HA_DEV_SERVER_TIMEOUT || "180");
|
||||
const READY_TIMEOUT_MS =
|
||||
Number(process.env.HA_DEV_SERVER_TIMEOUT || "180") * 1000;
|
||||
Number.isFinite(readyTimeoutSeconds) && readyTimeoutSeconds > 0
|
||||
? readyTimeoutSeconds * 1000
|
||||
: 180_000;
|
||||
|
||||
// Detect a coding agent from a small set of environment markers set by common
|
||||
// agent CLIs (env-only; no process-ancestry detection).
|
||||
@@ -104,7 +148,7 @@ const detectAgent = () => {
|
||||
};
|
||||
|
||||
const usage = () => {
|
||||
const suites = Object.keys(SUITES).join("|");
|
||||
const suites = [...SUITES.keys()].join("|");
|
||||
process.stderr.write(
|
||||
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
|
||||
`[--background | --status | --stop | --logs [--follow]]\n`
|
||||
@@ -115,6 +159,7 @@ const parseArgs = (argv) => {
|
||||
const args = {
|
||||
mode: "foreground",
|
||||
follow: false,
|
||||
modes: [],
|
||||
suite: undefined,
|
||||
passthrough: [],
|
||||
};
|
||||
@@ -124,39 +169,99 @@ const parseArgs = (argv) => {
|
||||
case "--suite":
|
||||
args.suite = argv[++i];
|
||||
break;
|
||||
case "--background":
|
||||
args.mode = "background";
|
||||
break;
|
||||
case "--status":
|
||||
args.mode = "status";
|
||||
break;
|
||||
case "--stop":
|
||||
args.mode = "stop";
|
||||
break;
|
||||
case "--logs":
|
||||
args.mode = "logs";
|
||||
break;
|
||||
case "--follow":
|
||||
args.follow = true;
|
||||
break;
|
||||
default:
|
||||
// Anything unrecognised is forwarded to the underlying script.
|
||||
args.passthrough.push(arg);
|
||||
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
|
||||
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
|
||||
args.modes.push(arg);
|
||||
} else {
|
||||
// Anything unrecognised is forwarded to the underlying script.
|
||||
args.passthrough.push(arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const sleep = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
||||
const pidFileFor = (suite) => path.join(logDir, `${suite}.pid`);
|
||||
const pidFileFor = (suite) =>
|
||||
path.join(logDir, `${SUITES.get(suite).processKey ?? suite}.pid`);
|
||||
const removePidFileIf = (suite, matches) => {
|
||||
const existing = readProcessRecord(pidFileFor(suite));
|
||||
if (!existing) {
|
||||
removeProcessRecord(pidFileFor(suite));
|
||||
return true;
|
||||
}
|
||||
if (matches(existing)) {
|
||||
releaseProcessRecord(appOutputLockFile, existing.token, () => {
|
||||
if (readProcessRecord(pidFileFor(suite))?.token === existing.token) {
|
||||
removeProcessRecord(pidFileFor(suite));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const acquireProcessSuite = (suite) => {
|
||||
const pidFile = pidFileFor(suite);
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
kind: "dev",
|
||||
suite,
|
||||
starting: true,
|
||||
token,
|
||||
};
|
||||
const result = acquireProcessRecord(appOutputLockFile, record);
|
||||
if (!result.acquired) {
|
||||
return { existing: result.existing };
|
||||
}
|
||||
try {
|
||||
writeProcessRecord(pidFile, record);
|
||||
} catch (err) {
|
||||
releaseProcessRecord(appOutputLockFile, token);
|
||||
throw err;
|
||||
}
|
||||
return { token };
|
||||
};
|
||||
|
||||
const updateProcessSuite = (suite, token, child) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing?.token !== token) {
|
||||
throw Error(
|
||||
`Dev server (${suite}) process ownership was lost during startup.`
|
||||
);
|
||||
}
|
||||
const record = {
|
||||
...existing,
|
||||
pid: child.pid,
|
||||
startTime: processStartTime(child.pid),
|
||||
starting: false,
|
||||
};
|
||||
writePidFile(suite, record);
|
||||
const outputOwner = readProcessRecord(appOutputLockFile);
|
||||
if (outputOwner?.token !== token) {
|
||||
throw Error(
|
||||
`Dev server (${suite}) output ownership was lost during startup.`
|
||||
);
|
||||
}
|
||||
writeProcessRecord(appOutputLockFile, record);
|
||||
};
|
||||
|
||||
const releaseProcessSuite = (suite, token) => {
|
||||
releaseProcessRecord(appOutputLockFile, token, () => {
|
||||
if (readPidFile(suite)?.token === token) {
|
||||
removeProcessRecord(pidFileFor(suite));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const hints = (suite) => {
|
||||
const alias = `yarn ${SUITES[suite].alias}`;
|
||||
const alias = `yarn ${SUITES.get(suite).alias}`;
|
||||
return (
|
||||
` Stop: ${alias} --stop\n` +
|
||||
` Status: ${alias} --status\n` +
|
||||
@@ -164,46 +269,63 @@ const hints = (suite) => {
|
||||
);
|
||||
};
|
||||
|
||||
const reportProcessConflict = (suite, existing) => {
|
||||
if (existing?.kind === "output") {
|
||||
process.stdout.write(
|
||||
`${describeOutputOwner(existing)} already owns the app output` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (existing?.kind === "build") {
|
||||
process.stdout.write(
|
||||
`Frontend build already running${existing.pid ? ` (pid ${existing.pid})` : ""}. ` +
|
||||
"Stop it with yarn build --stop.\n"
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`Dev server (${existing?.suite ?? suite}) already running` +
|
||||
`${urlSuffix(existing?.port)} ` +
|
||||
`${existing?.pid ? `(pid ${existing.pid})` : ""}\n` +
|
||||
hints(existing?.suite ?? suite)
|
||||
);
|
||||
};
|
||||
|
||||
const acquireHealthSuite = (suite) => {
|
||||
const file = outputLockFile(suite);
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
processGroup: false,
|
||||
kind: "dev",
|
||||
suite,
|
||||
starting: true,
|
||||
token,
|
||||
};
|
||||
const result = acquireProcessRecord(file, record);
|
||||
return result.acquired ? { file, token } : { existing: result.existing };
|
||||
};
|
||||
|
||||
const updateHealthSuite = (file, token, child) => {
|
||||
const existing = readProcessRecord(file);
|
||||
if (existing?.token !== token) {
|
||||
throw Error("Dev server output ownership was lost during startup.");
|
||||
}
|
||||
writeProcessRecord(file, {
|
||||
...existing,
|
||||
pid: child.pid,
|
||||
startTime: processStartTime(child.pid),
|
||||
processGroup: true,
|
||||
starting: false,
|
||||
});
|
||||
};
|
||||
|
||||
// --- shared spawning and lifecycle ------------------------------------------
|
||||
|
||||
// Signal the whole process group (the background server is its group leader),
|
||||
// falling back to the bare pid if that is not permitted.
|
||||
const killProcessTree = (pid, sig) => {
|
||||
try {
|
||||
process.kill(-pid, sig);
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, sig);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
|
||||
|
||||
// Run a server in the foreground, inheriting stdio; resolve with its exit code.
|
||||
const spawnInherit = (cmd, args) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, { cwd: repoRoot, stdio: "inherit" });
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
|
||||
// Spawn a detached server that writes stdout and stderr to the suite's log file.
|
||||
const spawnDetachedToLog = (suite, cmd, args) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logFile = logFileFor(suite);
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: repoRoot,
|
||||
detached: true,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
fs.closeSync(fd);
|
||||
child.unref();
|
||||
return { child, logFile };
|
||||
};
|
||||
|
||||
// Poll until the server is ready, the child exits, or we time out. Prints the
|
||||
// progress dots and outcome; returns 0 when ready, 1 otherwise. onExit runs if
|
||||
// the child dies before it is ready (used to clear a stale pidfile).
|
||||
@@ -214,8 +336,7 @@ const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
|
||||
});
|
||||
const deadline = Date.now() + READY_TIMEOUT_MS;
|
||||
process.stdout.write(`Starting ${suite} dev server`);
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is ready */
|
||||
while (Date.now() < deadline) {
|
||||
const poll = async () => {
|
||||
if (childExited) {
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
@@ -232,38 +353,37 @@ const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return undefined;
|
||||
}
|
||||
process.stdout.write(".");
|
||||
await sleep(1000);
|
||||
return poll();
|
||||
};
|
||||
const result = await poll();
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) did not become ready within ${
|
||||
READY_TIMEOUT_MS / 1000
|
||||
}s. See ${logFile}\n`
|
||||
);
|
||||
const stopped = await terminateProcess({
|
||||
pid: child.pid,
|
||||
isStopped: () => childExited,
|
||||
});
|
||||
if (stopped) {
|
||||
onExit?.();
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
// Stop a running background server: SIGTERM, wait for it to go, then SIGKILL.
|
||||
// isStopped reports when it is gone; onStopped runs on success (pidfile cleanup).
|
||||
const terminate = async (suite, pid, isStopped, onStopped) => {
|
||||
killProcessTree(pid, "SIGTERM");
|
||||
const deadline = Date.now() + 10_000;
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is gone */
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(300);
|
||||
if (await isStopped()) {
|
||||
onStopped?.();
|
||||
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
// Escalate if it is still up.
|
||||
killProcessTree(pid, "SIGKILL");
|
||||
await sleep(300);
|
||||
if (!(await isStopped())) {
|
||||
if (!(await terminateProcess({ pid, isStopped }))) {
|
||||
process.stderr.write(
|
||||
`Failed to stop dev server (${suite}) (pid ${pid}). Stop it manually.\n`
|
||||
);
|
||||
@@ -284,9 +404,11 @@ const terminate = async (suite, pid, isStopped, onStopped) => {
|
||||
const PROBE_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
||||
|
||||
const probe = async (port, timeoutMs = 1000) => {
|
||||
let sawResponse = false;
|
||||
/* eslint-disable no-await-in-loop -- probe localhost addresses in order, stopping at the first that answers */
|
||||
for (const host of PROBE_HOSTS) {
|
||||
const probeHost = async (index, sawResponse) => {
|
||||
const host = PROBE_HOSTS[index];
|
||||
if (!host) {
|
||||
return sawResponse ? { state: "foreign" } : { state: "free" };
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
@@ -305,9 +427,9 @@ const probe = async (port, timeoutMs = 1000) => {
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
return sawResponse ? { state: "foreign" } : { state: "free" };
|
||||
return probeHost(index + 1, sawResponse);
|
||||
};
|
||||
return probeHost(0, false);
|
||||
};
|
||||
|
||||
// Find the pid listening on a port via the first available tool (no state file).
|
||||
@@ -351,13 +473,35 @@ const runForegroundHealth = async (suite, cfg) => {
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (status.state === "ours") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is serving the ${status.suite ?? "unknown"} dev server; not ${suite}.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (status.state === "foreign") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return spawnInherit(cfg.spawn.cmd, cfg.spawn.args);
|
||||
const lock = acquireHealthSuite(suite);
|
||||
if (!lock.token) {
|
||||
reportProcessConflict(suite, lock.existing);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
return await spawnForeground({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: cfg.spawn.args,
|
||||
cwd: repoRoot,
|
||||
env: outputLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateHealthSuite(lock.file, lock.token, child),
|
||||
});
|
||||
} finally {
|
||||
releaseProcessRecord(lock.file, lock.token);
|
||||
}
|
||||
};
|
||||
|
||||
const runBackgroundHealth = async (suite, cfg) => {
|
||||
@@ -371,6 +515,12 @@ const runBackgroundHealth = async (suite, cfg) => {
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (preflight.state === "ours") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is serving the ${preflight.suite ?? "unknown"} dev server; not ${suite}.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (preflight.state === "foreign") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
@@ -378,21 +528,36 @@ const runBackgroundHealth = async (suite, cfg) => {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
cfg.spawn.args
|
||||
);
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: async () => {
|
||||
const status = await probe(port, 1000);
|
||||
return status.state === "ours" && status.suite === suite;
|
||||
},
|
||||
});
|
||||
const lock = acquireHealthSuite(suite);
|
||||
if (!lock.token) {
|
||||
reportProcessConflict(suite, lock.existing);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const logFile = logFileFor(suite);
|
||||
const child = await spawnDetachedToLog({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: cfg.spawn.args,
|
||||
cwd: repoRoot,
|
||||
env: outputLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
updateHealthSuite(lock.file, lock.token, child);
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: async () => {
|
||||
const status = await probe(port, 1000);
|
||||
return status.state === "ours" && status.suite === suite;
|
||||
},
|
||||
onExit: () => releaseProcessRecord(lock.file, lock.token),
|
||||
});
|
||||
} catch (err) {
|
||||
releaseProcessRecord(lock.file, lock.token);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const runStatusHealth = async (suite, cfg) => {
|
||||
@@ -434,51 +599,24 @@ const runStopHealth = async (suite, cfg) => {
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const lockFile = outputLockFile(suite);
|
||||
const existing = readProcessRecord(lockFile);
|
||||
return terminate(
|
||||
suite,
|
||||
pid,
|
||||
async () => (await probe(port, 800)).state === "free"
|
||||
async () => (await probe(port, 800)).state === "free",
|
||||
() => releaseProcessRecord(lockFile, existing?.token)
|
||||
);
|
||||
};
|
||||
|
||||
// --- process liveness (pidfile + log-readiness) -----------------------------
|
||||
|
||||
const isAlive = (pid) => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// EPERM means the process exists but is owned by someone else.
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
const readPidFile = (suite) => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(pidFileFor(suite), "utf8"));
|
||||
if (data && Number.isInteger(data.pid)) {
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
// Missing or corrupt.
|
||||
}
|
||||
return undefined;
|
||||
return readProcessRecord(pidFileFor(suite));
|
||||
};
|
||||
|
||||
const writePidFile = (suite, data) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(pidFileFor(suite), JSON.stringify(data));
|
||||
};
|
||||
|
||||
const removePidFile = (suite) => {
|
||||
try {
|
||||
fs.rmSync(pidFileFor(suite));
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
writeProcessRecord(pidFileFor(suite), data);
|
||||
};
|
||||
|
||||
const logIsReady = (logFile, readyLog) => {
|
||||
@@ -492,11 +630,13 @@ const logIsReady = (logFile, readyLog) => {
|
||||
// app-serve serves on 8124 by default (8123 in a devcontainer), or whatever -p
|
||||
// the caller passed. Used only to show a URL; liveness comes from the pidfile.
|
||||
const resolveServePort = (passthrough) => {
|
||||
const i = passthrough.indexOf("-p");
|
||||
if (i !== -1) {
|
||||
const port = Number(passthrough[i + 1]);
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
return port;
|
||||
for (let i = passthrough.length - 1; i >= 0; i--) {
|
||||
const arg = passthrough[i];
|
||||
if (arg === "-p" || arg.startsWith("-p")) {
|
||||
const port = Number(arg === "-p" ? passthrough[i + 1] : arg.slice(2));
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
return process.env.DEVCONTAINER ? 8123 : 8124;
|
||||
@@ -508,62 +648,74 @@ const spawnArgs = (cfg, passthrough) => [
|
||||
];
|
||||
|
||||
const runForegroundProcess = async (suite, cfg, passthrough) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running in the background ` +
|
||||
`(pid ${existing.pid}). Stop it with yarn ${cfg.alias} --stop.\n`
|
||||
);
|
||||
const lock = acquireProcessSuite(suite);
|
||||
if (!lock.token) {
|
||||
reportProcessConflict(suite, lock.existing);
|
||||
return 0;
|
||||
}
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
try {
|
||||
return await spawnForeground({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: spawnArgs(cfg, passthrough),
|
||||
cwd: repoRoot,
|
||||
env: outputLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateProcessSuite(suite, lock.token, child),
|
||||
});
|
||||
} finally {
|
||||
releaseProcessSuite(suite, lock.token);
|
||||
}
|
||||
return spawnInherit(cfg.spawn.cmd, spawnArgs(cfg, passthrough));
|
||||
};
|
||||
|
||||
const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n${hints(suite)}`
|
||||
);
|
||||
const lock = acquireProcessSuite(suite);
|
||||
if (!lock.token) {
|
||||
reportProcessConflict(suite, lock.existing);
|
||||
return 0;
|
||||
}
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
|
||||
try {
|
||||
const logFile = logFileFor(suite);
|
||||
const child = await spawnDetachedToLog({
|
||||
cmd: cfg.spawn.cmd,
|
||||
args: spawnArgs(cfg, passthrough),
|
||||
cwd: repoRoot,
|
||||
env: outputLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
|
||||
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
||||
updateProcessSuite(suite, lock.token, child);
|
||||
writePidFile(suite, { ...readPidFile(suite), port });
|
||||
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: () => logIsReady(logFile, cfg.readyLog),
|
||||
onExit: () => releaseProcessSuite(suite, lock.token),
|
||||
});
|
||||
} catch (err) {
|
||||
releaseProcessSuite(suite, lock.token);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
spawnArgs(cfg, passthrough)
|
||||
);
|
||||
|
||||
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
||||
writePidFile(suite, { pid: child.pid, port });
|
||||
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: () => logIsReady(logFile, cfg.readyLog),
|
||||
onExit: () => removePidFile(suite),
|
||||
});
|
||||
};
|
||||
|
||||
const runStatusProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
if (existing && isProcessRecordAlive(existing)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running${urlSuffix(existing.port)} ` +
|
||||
`Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n`
|
||||
);
|
||||
} else {
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
removePidFileIf(
|
||||
suite,
|
||||
(current) =>
|
||||
current.token === existing.token && !isProcessRecordAlive(current)
|
||||
);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
}
|
||||
@@ -571,56 +723,64 @@ const runStatusProcess = async (suite) => {
|
||||
};
|
||||
|
||||
const runStopProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (!existing || !isAlive(existing.pid)) {
|
||||
let existing = readPidFile(suite);
|
||||
if (existing?.starting) {
|
||||
const token = existing.token;
|
||||
await waitFor(
|
||||
() => {
|
||||
const current = readPidFile(suite);
|
||||
return !current?.starting || current.token !== token;
|
||||
},
|
||||
100,
|
||||
5000
|
||||
);
|
||||
existing = readPidFile(suite);
|
||||
}
|
||||
if (!existing || !isProcessRecordAlive(existing)) {
|
||||
// Idempotent: stopping something that is not running is a success.
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
removePidFileIf(
|
||||
suite,
|
||||
(current) =>
|
||||
current.token === existing.token && !isProcessRecordAlive(current)
|
||||
);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
const { pid } = existing;
|
||||
const activeSuite = existing.suite ?? suite;
|
||||
return terminate(
|
||||
suite,
|
||||
activeSuite,
|
||||
pid,
|
||||
() => !isAlive(pid),
|
||||
() => removePidFile(suite)
|
||||
() => !isProcessRecordAlive(existing),
|
||||
() => releaseProcessSuite(activeSuite, existing.token)
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared -----------------------------------------------------------------
|
||||
|
||||
const runLogs = (suite, follow) => {
|
||||
const logFile = logFileFor(suite);
|
||||
if (!fs.existsSync(logFile)) {
|
||||
process.stdout.write(
|
||||
`No log for the ${suite} dev server yet (${logFile}).\n`
|
||||
);
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
if (!follow) {
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
|
||||
tail.on("error", () => {
|
||||
// No tail available; fall back to a one-shot dump.
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
resolve(0);
|
||||
});
|
||||
tail.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
const activeSuite = readPidFile(suite)?.suite ?? suite;
|
||||
return outputLog(
|
||||
logFileFor(activeSuite),
|
||||
follow,
|
||||
`No log for the ${activeSuite} dev server yet (${logFileFor(activeSuite)}).\n`
|
||||
);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cfg = SUITES[args.suite];
|
||||
const cfg = SUITES.get(args.suite);
|
||||
if (!cfg) {
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
|
||||
process.stderr.write("Invalid combination of lifecycle arguments.\n");
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.passthrough.length && !cfg.acceptsArgs) {
|
||||
process.stderr.write(
|
||||
`Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n`
|
||||
@@ -644,35 +804,26 @@ const main = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const health = cfg.liveness === "health";
|
||||
switch (mode) {
|
||||
case "background":
|
||||
return health
|
||||
? runBackgroundHealth(args.suite, cfg)
|
||||
: runBackgroundProcess(args.suite, cfg, args.passthrough);
|
||||
case "status":
|
||||
return health
|
||||
? runStatusHealth(args.suite, cfg)
|
||||
: runStatusProcess(args.suite);
|
||||
case "stop":
|
||||
return health
|
||||
? runStopHealth(args.suite, cfg)
|
||||
: runStopProcess(args.suite);
|
||||
case "logs":
|
||||
return runLogs(args.suite, args.follow);
|
||||
default:
|
||||
return health
|
||||
? runForegroundHealth(args.suite, cfg)
|
||||
: runForegroundProcess(args.suite, cfg, args.passthrough);
|
||||
if (mode === "logs") {
|
||||
return runLogs(args.suite, args.follow);
|
||||
}
|
||||
const handlers =
|
||||
cfg.liveness === "health"
|
||||
? {
|
||||
foreground: () => runForegroundHealth(args.suite, cfg),
|
||||
background: () => runBackgroundHealth(args.suite, cfg),
|
||||
status: () => runStatusHealth(args.suite, cfg),
|
||||
stop: () => runStopHealth(args.suite, cfg),
|
||||
}
|
||||
: {
|
||||
foreground: () =>
|
||||
runForegroundProcess(args.suite, cfg, args.passthrough),
|
||||
background: () =>
|
||||
runBackgroundProcess(args.suite, cfg, args.passthrough),
|
||||
status: () => runStatusProcess(args.suite),
|
||||
stop: () => runStopProcess(args.suite),
|
||||
};
|
||||
return handlers[mode]();
|
||||
};
|
||||
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err?.stack || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
runCli(main);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export const GULP_TASKS = {
|
||||
app: {
|
||||
develop: "develop-app",
|
||||
build: "build-app",
|
||||
modern: "build-app-modern",
|
||||
analyze: "analyze-app",
|
||||
},
|
||||
cast: {
|
||||
develop: "develop-cast",
|
||||
build: "build-cast",
|
||||
},
|
||||
demo: {
|
||||
develop: "develop-demo",
|
||||
build: "build-demo",
|
||||
e2e: "build-demo-e2e",
|
||||
analyze: "analyze-demo",
|
||||
},
|
||||
e2eApp: {
|
||||
develop: "develop-e2e-test-app",
|
||||
build: "build-e2e-test-app",
|
||||
e2e: "build-e2e-test-app-e2e",
|
||||
},
|
||||
gallery: {
|
||||
develop: "develop-gallery",
|
||||
build: "build-gallery",
|
||||
},
|
||||
landingPage: {
|
||||
develop: "develop-landing-page",
|
||||
build: "build-landing-page",
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import gulp from "gulp";
|
||||
import env from "../env.cjs";
|
||||
import { GULP_TASKS } from "../gulp-tasks.mjs";
|
||||
import { createOutputWorkflow } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./compress.js";
|
||||
import "./entry-html.js";
|
||||
@@ -11,12 +13,16 @@ import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
|
||||
const workflow = createOutputWorkflow("app", GULP_TASKS.app);
|
||||
|
||||
gulp.task(
|
||||
"develop-app",
|
||||
workflow.develop.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
workflow.develop.output.acquire,
|
||||
workflow.develop.generated.acquire,
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-service-worker-app-dev",
|
||||
@@ -26,16 +32,19 @@ gulp.task(
|
||||
"build-locale-data"
|
||||
),
|
||||
"copy-static-app",
|
||||
workflow.develop.generated.release,
|
||||
"rspack-watch-app"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-app",
|
||||
workflow.build.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
workflow.build.output.acquire,
|
||||
workflow.build.generated.acquire,
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
@@ -47,17 +56,50 @@ gulp.task(
|
||||
"rspack-prod-app",
|
||||
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
|
||||
// Don't compress running tests
|
||||
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
|
||||
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"]),
|
||||
workflow.build.generated.release,
|
||||
workflow.build.output.release
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"analyze-app",
|
||||
workflow.modern.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
workflow.modern.output.acquire,
|
||||
workflow.modern.generated.acquire,
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"build-translations",
|
||||
"build-locale-data",
|
||||
"gen-licenses"
|
||||
),
|
||||
"copy-static-app",
|
||||
"rspack-prod-app-modern",
|
||||
gulp.parallel(
|
||||
"gen-pages-app-prod-modern",
|
||||
"gen-service-worker-app-prod-modern"
|
||||
),
|
||||
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"]),
|
||||
workflow.modern.generated.release,
|
||||
workflow.modern.output.release
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
workflow.analyze.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.STATS = "1";
|
||||
},
|
||||
workflow.analyze.output.acquire,
|
||||
workflow.analyze.generated.acquire,
|
||||
"clean",
|
||||
"rspack-prod-app"
|
||||
"rspack-prod-app",
|
||||
workflow.analyze.generated.release,
|
||||
workflow.analyze.output.release
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import gulp from "gulp";
|
||||
import { GULP_TASKS } from "../gulp-tasks.mjs";
|
||||
import { createOutputWorkflow } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
@@ -6,32 +8,41 @@ import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
|
||||
const workflow = createOutputWorkflow("cast", GULP_TASKS.cast);
|
||||
|
||||
gulp.task(
|
||||
"develop-cast",
|
||||
workflow.develop.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
workflow.develop.output.acquire,
|
||||
workflow.develop.generated.acquire,
|
||||
"clean-cast",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-cast",
|
||||
"gen-pages-cast-dev",
|
||||
workflow.develop.generated.release,
|
||||
"rspack-dev-server-cast"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-cast",
|
||||
workflow.build.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
workflow.build.output.acquire,
|
||||
workflow.build.generated.acquire,
|
||||
"clean-cast",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-cast",
|
||||
"rspack-prod-cast",
|
||||
"gen-pages-cast-prod"
|
||||
"gen-pages-cast-prod",
|
||||
workflow.build.generated.release,
|
||||
workflow.build.output.release
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import gulp from "gulp";
|
||||
import { GULP_TASKS } from "../gulp-tasks.mjs";
|
||||
import { createOutputWorkflow } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
@@ -7,12 +9,16 @@ import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
|
||||
const workflow = createOutputWorkflow("demo", GULP_TASKS.demo);
|
||||
|
||||
gulp.task(
|
||||
"develop-demo",
|
||||
workflow.develop.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
workflow.develop.output.acquire,
|
||||
workflow.develop.generated.acquire,
|
||||
"clean-demo",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -22,49 +28,62 @@ gulp.task(
|
||||
"build-locale-data"
|
||||
),
|
||||
"copy-static-demo",
|
||||
workflow.develop.generated.release,
|
||||
"rspack-dev-server-demo"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-demo",
|
||||
workflow.build.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
workflow.build.output.acquire,
|
||||
workflow.build.generated.acquire,
|
||||
"clean-demo",
|
||||
// Cast needs to be backwards compatible and older HA has no translations
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-demo",
|
||||
"rspack-prod-demo",
|
||||
"gen-pages-demo-prod"
|
||||
"gen-pages-demo-prod",
|
||||
workflow.build.generated.release,
|
||||
workflow.build.output.release
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-demo-e2e",
|
||||
workflow.e2e.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
workflow.e2e.output.acquire,
|
||||
workflow.e2e.generated.acquire,
|
||||
"clean-demo",
|
||||
// Cast needs to be backwards compatible and older HA has no translations
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-demo",
|
||||
"rspack-prod-demo-e2e",
|
||||
"gen-pages-demo-prod-e2e"
|
||||
"gen-pages-demo-prod-e2e",
|
||||
workflow.e2e.generated.release,
|
||||
workflow.e2e.output.release
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"analyze-demo",
|
||||
workflow.analyze.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.STATS = "1";
|
||||
},
|
||||
workflow.analyze.output.acquire,
|
||||
workflow.analyze.generated.acquire,
|
||||
"clean",
|
||||
"rspack-prod-demo"
|
||||
"rspack-prod-demo",
|
||||
workflow.analyze.generated.release,
|
||||
workflow.analyze.output.release
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import gulp from "gulp";
|
||||
import { GULP_TASKS } from "../gulp-tasks.mjs";
|
||||
import { createOutputWorkflow } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
@@ -6,12 +8,16 @@ import "./gen-icons-json.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
|
||||
const workflow = createOutputWorkflow("e2e-app", GULP_TASKS.e2eApp);
|
||||
|
||||
gulp.task(
|
||||
"develop-e2e-test-app",
|
||||
workflow.develop.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
workflow.develop.output.acquire,
|
||||
workflow.develop.generated.acquire,
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -21,36 +27,45 @@ gulp.task(
|
||||
"build-locale-data"
|
||||
),
|
||||
"copy-static-e2e-test-app",
|
||||
workflow.develop.generated.release,
|
||||
"rspack-dev-server-e2e-test-app"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-e2e-test-app",
|
||||
workflow.build.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
workflow.build.output.acquire,
|
||||
workflow.build.generated.acquire,
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-prod-e2e-test-app",
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
"gen-pages-e2e-test-app-prod",
|
||||
workflow.build.generated.release,
|
||||
workflow.build.output.release
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-e2e-test-app-e2e",
|
||||
workflow.e2e.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
workflow.e2e.output.acquire,
|
||||
workflow.e2e.generated.acquire,
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-prod-e2e-test-app-e2e",
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
"gen-pages-e2e-test-app-prod",
|
||||
workflow.e2e.generated.release,
|
||||
workflow.e2e.output.release
|
||||
)
|
||||
);
|
||||
|
||||
@@ -147,9 +147,11 @@ const genPagesProdTask =
|
||||
{
|
||||
...commonVars,
|
||||
latestEntryJS: entries.map((entry) => latestManifest[`${entry}.js`]),
|
||||
es5EntryJS: entries.map((entry) => es5Manifest[`${entry}.js`]),
|
||||
es5EntryJS: outputES5
|
||||
? entries.map((entry) => es5Manifest[`${entry}.js`])
|
||||
: [],
|
||||
latestCustomPanelJS: latestManifest["custom-panel.js"],
|
||||
es5CustomPanelJS: es5Manifest["custom-panel.js"],
|
||||
es5CustomPanelJS: outputES5 ? es5Manifest["custom-panel.js"] : "",
|
||||
}
|
||||
);
|
||||
minifiedHTML.push(
|
||||
@@ -184,6 +186,17 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-app-prod-modern",
|
||||
genPagesProdTask(
|
||||
APP_PAGE_ENTRIES,
|
||||
paths.root_dir,
|
||||
paths.app_output_root,
|
||||
paths.app_output_latest,
|
||||
undefined
|
||||
)
|
||||
);
|
||||
|
||||
const CAST_PAGE_ENTRIES = {
|
||||
"faq.html": ["launcher"],
|
||||
"index.html": ["launcher"],
|
||||
|
||||
@@ -4,6 +4,8 @@ import gulp from "gulp";
|
||||
import { load as loadYaml } from "js-yaml";
|
||||
import { marked } from "marked";
|
||||
import path from "path";
|
||||
import { GULP_TASKS } from "../gulp-tasks.mjs";
|
||||
import { createOutputWorkflow } from "../output-lock.mjs";
|
||||
import paths from "../paths.cjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
@@ -13,6 +15,8 @@ import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
|
||||
const workflow = createOutputWorkflow("gallery", GULP_TASKS.gallery);
|
||||
|
||||
gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
const pageDir = path.resolve(paths.gallery_dir, "src/pages");
|
||||
const files = await glob(path.resolve(pageDir, "**/*"));
|
||||
@@ -159,11 +163,13 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
});
|
||||
|
||||
gulp.task(
|
||||
"develop-gallery",
|
||||
workflow.develop.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
workflow.develop.output.acquire,
|
||||
workflow.develop.generated.acquire,
|
||||
"clean-gallery",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -174,6 +180,7 @@ gulp.task(
|
||||
),
|
||||
"copy-static-gallery",
|
||||
"gen-pages-gallery-dev",
|
||||
workflow.develop.generated.release,
|
||||
gulp.parallel(
|
||||
"rspack-dev-server-gallery",
|
||||
async function watchMarkdownFiles() {
|
||||
@@ -190,11 +197,13 @@ gulp.task(
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-gallery",
|
||||
workflow.build.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
workflow.build.output.acquire,
|
||||
workflow.build.generated.acquire,
|
||||
"clean-gallery",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -205,6 +214,8 @@ gulp.task(
|
||||
),
|
||||
"copy-static-gallery",
|
||||
"rspack-prod-gallery",
|
||||
"gen-pages-gallery-prod"
|
||||
"gen-pages-gallery-prod",
|
||||
workflow.build.generated.release,
|
||||
workflow.build.output.release
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import gulp from "gulp";
|
||||
import { GULP_TASKS } from "../gulp-tasks.mjs";
|
||||
import { createOutputWorkflow } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./compress.js";
|
||||
import "./entry-html.js";
|
||||
@@ -7,12 +9,16 @@ import "./gen-icons-json.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
|
||||
const workflow = createOutputWorkflow("landing-page", GULP_TASKS.landingPage);
|
||||
|
||||
gulp.task(
|
||||
"develop-landing-page",
|
||||
workflow.develop.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
workflow.develop.output.acquire,
|
||||
workflow.develop.generated.acquire,
|
||||
"clean-landing-page",
|
||||
"translations-enable-merge-backend",
|
||||
"build-landing-page-translations",
|
||||
@@ -20,22 +26,27 @@ gulp.task(
|
||||
"build-locale-data",
|
||||
"copy-static-landing-page",
|
||||
"gen-pages-landing-page-dev",
|
||||
workflow.develop.generated.release,
|
||||
"rspack-watch-landing-page"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-landing-page",
|
||||
workflow.build.task,
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
workflow.build.output.acquire,
|
||||
workflow.build.generated.acquire,
|
||||
"clean-landing-page",
|
||||
"build-landing-page-translations",
|
||||
"copy-translations-landing-page",
|
||||
"build-locale-data",
|
||||
"copy-static-landing-page",
|
||||
"rspack-prod-landing-page",
|
||||
"gen-pages-landing-page-prod"
|
||||
"gen-pages-landing-page-prod",
|
||||
workflow.build.generated.release,
|
||||
workflow.build.output.release
|
||||
)
|
||||
);
|
||||
|
||||
@@ -160,6 +160,17 @@ gulp.task("rspack-prod-app", () =>
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-app-modern", () =>
|
||||
prodBuild(
|
||||
createAppConfig({
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
latestBuild: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-dev-server-demo", () =>
|
||||
runDevServer({
|
||||
compiler: rspack(
|
||||
|
||||
@@ -34,9 +34,9 @@ gulp.task("gen-service-worker-app-dev", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
gulp.task("gen-service-worker-app-prod", () =>
|
||||
const genServiceWorker = (builds) =>
|
||||
Promise.all(
|
||||
Object.entries(SW_MAP).map(async ([outPath, build]) => {
|
||||
builds.map(async ([outPath, build]) => {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(outPath, "manifest.json"), "utf-8")
|
||||
);
|
||||
@@ -83,5 +83,12 @@ gulp.task("gen-service-worker-app-prod", () =>
|
||||
await symlink(basename(swDest), swOld);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("gen-service-worker-app-prod", () =>
|
||||
genServiceWorker(Object.entries(SW_MAP))
|
||||
);
|
||||
|
||||
gulp.task("gen-service-worker-app-prod-modern", () =>
|
||||
genServiceWorker([[paths.app_output_latest, "modern"]])
|
||||
);
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const LIFECYCLE_MODE_FLAGS = new Map([
|
||||
["--background", "background"],
|
||||
["--status", "status"],
|
||||
["--stop", "stop"],
|
||||
["--logs", "logs"],
|
||||
]);
|
||||
|
||||
export const sleep = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
export const waitFor = async (predicate, intervalMs, timeoutMs) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const poll = async () => {
|
||||
if (await predicate()) {
|
||||
return true;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return false;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
return poll();
|
||||
};
|
||||
return poll();
|
||||
};
|
||||
|
||||
export const isProcessAlive = (pid) => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// EPERM means the process exists but is owned by someone else.
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
export const processStartTime = (pid) => {
|
||||
if (!isProcessAlive(pid)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
||||
} catch {
|
||||
try {
|
||||
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const isProcessRecordAlive = ({ pid, startTime }) =>
|
||||
Boolean(startTime) && processStartTime(pid) === startTime;
|
||||
|
||||
export const readProcessRecord = (file) => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
return data && Number.isInteger(data.pid) ? data : undefined;
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT" || err instanceof SyntaxError) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeProcessRecord = (file, data) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temporary, JSON.stringify(data));
|
||||
fs.renameSync(temporary, file);
|
||||
} finally {
|
||||
removeFileIfExists(temporary);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeProcessRecord = (file) => {
|
||||
removeFileIfExists(file);
|
||||
};
|
||||
|
||||
export const acquireProcessRecord = (file, data) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const fd = fs.openSync(file, "wx");
|
||||
try {
|
||||
fs.writeFileSync(fd, JSON.stringify(data));
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return { acquired: true };
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
const existing = readProcessRecord(file);
|
||||
if (existing && isProcessRecordAlive(existing)) {
|
||||
return { acquired: false, existing };
|
||||
}
|
||||
if (!existing && isRecentFile(file)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
const removed = withExclusiveFileLockSync(`${file}.cleanup`, () => {
|
||||
const current = readProcessRecord(file);
|
||||
if (
|
||||
current &&
|
||||
(current.token !== existing?.token || isProcessRecordAlive(current))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
removeProcessRecord(file);
|
||||
return true;
|
||||
});
|
||||
if (!removed.acquired || !removed.value) {
|
||||
return { acquired: false, existing: readProcessRecord(file) };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { acquired: false, existing: readProcessRecord(file) };
|
||||
};
|
||||
|
||||
export const releaseProcessRecord = (file, token, onRelease) => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
withExclusiveFileLockSync(`${file}.cleanup`, () => {
|
||||
const existing = readProcessRecord(file);
|
||||
if (!existing || existing.token === token) {
|
||||
onRelease?.();
|
||||
if (existing) {
|
||||
removeProcessRecord(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const removeFileIfExists = (file) => {
|
||||
try {
|
||||
fs.rmSync(file);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isRecentFile = (file) => {
|
||||
try {
|
||||
return Date.now() - fs.statSync(file).mtimeMs < 5000;
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const withExclusiveFileLockSync = (file, operation) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(file, "wx");
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
const owner = readProcessRecord(file);
|
||||
if (owner && isProcessRecordAlive(owner)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
if (!owner && isRecentFile(file)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
removeFileIfExists(file);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
fd,
|
||||
JSON.stringify({
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
})
|
||||
);
|
||||
return { acquired: true, value: operation() };
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} finally {
|
||||
removeFileIfExists(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { acquired: false };
|
||||
};
|
||||
|
||||
export const spawnForeground = ({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
processGroup = false,
|
||||
onSpawn,
|
||||
}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: processGroup,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
let settled = false;
|
||||
const forwardSigint = () =>
|
||||
signalProcess(child.pid, "SIGINT", processGroup);
|
||||
const forwardSigterm = () =>
|
||||
signalProcess(child.pid, "SIGTERM", processGroup);
|
||||
const forwardSighup = () =>
|
||||
signalProcess(child.pid, "SIGHUP", processGroup);
|
||||
const removeSignalHandlers = () => {
|
||||
process.off("SIGINT", forwardSigint);
|
||||
process.off("SIGTERM", forwardSigterm);
|
||||
process.off("SIGHUP", forwardSighup);
|
||||
};
|
||||
if (processGroup) {
|
||||
process.on("SIGINT", forwardSigint);
|
||||
process.on("SIGTERM", forwardSigterm);
|
||||
process.on("SIGHUP", forwardSighup);
|
||||
}
|
||||
child.once("spawn", () => {
|
||||
try {
|
||||
onSpawn?.(child);
|
||||
} catch (err) {
|
||||
settled = true;
|
||||
signalProcess(child.pid, "SIGTERM", processGroup);
|
||||
removeSignalHandlers();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
child.once("error", (err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
removeSignalHandlers();
|
||||
process.stderr.write(`Failed to start ${cmd}: ${err.message}\n`);
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
removeSignalHandlers();
|
||||
resolve(code ?? 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
let child;
|
||||
try {
|
||||
child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: true,
|
||||
env,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
child.once("spawn", () => {
|
||||
child.unref();
|
||||
resolve(child);
|
||||
});
|
||||
child.once("error", reject);
|
||||
});
|
||||
|
||||
const signalProcess = (pid, signal, processGroup) => {
|
||||
if (processGroup) {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to the process itself.
|
||||
}
|
||||
}
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
};
|
||||
|
||||
export const terminateProcess = async ({
|
||||
pid,
|
||||
isStopped,
|
||||
processGroup = true,
|
||||
graceMs = 10_000,
|
||||
}) => {
|
||||
signalProcess(pid, "SIGTERM", processGroup);
|
||||
if (await waitFor(isStopped, 300, graceMs)) {
|
||||
return true;
|
||||
}
|
||||
signalProcess(pid, "SIGKILL", processGroup);
|
||||
await sleep(300);
|
||||
return await isStopped();
|
||||
};
|
||||
|
||||
export const outputLog = (logFile, follow, missingMessage) => {
|
||||
if (!fs.existsSync(logFile)) {
|
||||
process.stdout.write(missingMessage);
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
if (!follow) {
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
|
||||
let settled = false;
|
||||
tail.once("error", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
tail.once("exit", (code) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(code ?? 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const runCli = (main) => {
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err?.stack || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
acquireProcessRecord,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
|
||||
const GENERATED_LOCK_TOKEN_ENV = "HA_GENERATED_OUTPUT_LOCK_TOKEN";
|
||||
const OUTPUT_LOCK_TOKEN_ENV = "HA_OUTPUT_LOCK_TOKEN";
|
||||
|
||||
export const generatedOutputLockFile = path.join(
|
||||
repoRoot,
|
||||
"node_modules",
|
||||
".cache",
|
||||
"ha-generated-output.lock"
|
||||
);
|
||||
|
||||
export const outputLockFile = (suite) =>
|
||||
path.join(repoRoot, "node_modules", ".cache", `ha-${suite}-output.lock`);
|
||||
|
||||
export const generatedOutputLockEnv = (token) => ({
|
||||
...process.env,
|
||||
[GENERATED_LOCK_TOKEN_ENV]: token,
|
||||
});
|
||||
|
||||
export const outputLockEnv = (token) => ({
|
||||
...process.env,
|
||||
[OUTPUT_LOCK_TOKEN_ENV]: token,
|
||||
});
|
||||
|
||||
export const describeOutputOwner = (owner) => {
|
||||
if (owner?.kind === "build") {
|
||||
return `frontend ${owner.modern ? "modern " : ""}build`;
|
||||
}
|
||||
if (owner?.kind === "dev") {
|
||||
return `dev server (${owner.suite ?? "app"})`;
|
||||
}
|
||||
return owner?.target ? `Gulp task ${owner.target}` : "another process";
|
||||
};
|
||||
|
||||
const createLockTasks = ({ file, inheritedTokenEnv, kind, label, target }) => {
|
||||
let ownedToken;
|
||||
|
||||
const release = async () => {
|
||||
if (!ownedToken) {
|
||||
return;
|
||||
}
|
||||
releaseProcessRecord(file, ownedToken);
|
||||
ownedToken = undefined;
|
||||
process.off("exit", release);
|
||||
};
|
||||
|
||||
const acquire = async () => {
|
||||
const inheritedToken = process.env[inheritedTokenEnv];
|
||||
if (inheritedToken) {
|
||||
if (readProcessRecord(file)?.token !== inheritedToken) {
|
||||
throw Error(
|
||||
`${label} lock ownership was lost before ${target} started.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const result = acquireProcessRecord(file, {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
processGroup: false,
|
||||
kind,
|
||||
target,
|
||||
token,
|
||||
});
|
||||
if (!result.acquired) {
|
||||
const pid = result.existing?.pid;
|
||||
throw Error(
|
||||
`Cannot run ${target}: ${describeOutputOwner(result.existing)} ` +
|
||||
`already owns ${label}${pid ? ` (pid ${pid})` : ""}.`
|
||||
);
|
||||
}
|
||||
|
||||
ownedToken = token;
|
||||
process.once("exit", release);
|
||||
};
|
||||
|
||||
acquire.displayName = `lock-${label}:${target}`;
|
||||
release.displayName = `unlock-${label}:${target}`;
|
||||
|
||||
return { acquire, release };
|
||||
};
|
||||
|
||||
export const createOutputLockTasks = (suite, target) =>
|
||||
createLockTasks({
|
||||
file: outputLockFile(suite),
|
||||
inheritedTokenEnv: OUTPUT_LOCK_TOKEN_ENV,
|
||||
kind: "output",
|
||||
label: `${suite}-output`,
|
||||
target,
|
||||
});
|
||||
|
||||
export const createGeneratedLockTasks = (target) =>
|
||||
createLockTasks({
|
||||
file: generatedOutputLockFile,
|
||||
inheritedTokenEnv: GENERATED_LOCK_TOKEN_ENV,
|
||||
kind: "generated-output",
|
||||
label: "generated-output",
|
||||
target,
|
||||
});
|
||||
|
||||
export const createOutputWorkflow = (suite, targets) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(targets).map(([key, target]) => [
|
||||
key,
|
||||
{
|
||||
task: target,
|
||||
output: createOutputLockTasks(suite, target),
|
||||
generated: createGeneratedLockTasks(target),
|
||||
},
|
||||
])
|
||||
);
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"name": "home-assistant-frontend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build": "script/build_frontend",
|
||||
"build": "node build-scripts/build-manager.mjs",
|
||||
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
|
||||
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
|
||||
Reference in New Issue
Block a user