Compare commits

...

11 Commits

Author SHA1 Message Date
Aidan Timson 3011334034 Shared build and runner for all build,dev,test flows 2026-07-30 10:35:47 +01:00
Aidan Timson b772ec248e Forward SIGHUP to foreground processes 2026-07-30 09:31:14 +01:00
Aidan Timson 9fafa9ff6c 1
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-30 08:43:14 +01:00
Aidan Timson 7095d7d051 Use single compress task 2026-07-30 08:01:22 +01:00
copilot-swe-agent[bot] 543118fc61 Address managed build review feedback
Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>
2026-07-29 15:36:50 +01:00
Aidan Timson 85a3fbc539 Clean stale build state on status 2026-07-29 15:36:50 +01:00
Aidan Timson 6d84091db8 Share generated output process ownership 2026-07-29 15:36:50 +01:00
Aidan Timson 1081689768 Harden managed process file operations 2026-07-29 15:36:50 +01:00
Aidan Timson db1ad563c1 Update managed process testing guidance 2026-07-29 15:36:50 +01:00
Aidan Timson 5754be6db4 Harden managed build process controls 2026-07-29 15:36:50 +01:00
Aidan Timson d030fdcb32 Add managed modern production builds 2026-07-29 15:36:50 +01:00
17 changed files with 1408 additions and 300 deletions
+22 -1
View File
@@ -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
+1 -1
View File
@@ -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
+278
View File
@@ -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
View File
@@ -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);
+31
View File
@@ -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",
},
};
+47 -5
View File
@@ -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
)
);
+14 -3
View File
@@ -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
)
);
+26 -7
View File
@@ -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
)
);
+20 -5
View File
@@ -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
)
);
+15 -2
View File
@@ -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"],
+14 -3
View File
@@ -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
)
);
+14 -3
View File
@@ -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
)
);
+11
View File
@@ -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(
+10 -3
View File
@@ -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"]])
);
+361
View File
@@ -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;
}
);
};
+126
View File
@@ -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
View File
@@ -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",