fix: Use available package manager for previews (#6251)

## Summary

- Use the npm or pnpm launcher that started Webstudio for preview
dependency installation, builds, and server startup.
- Use pnpm-compatible isolated-install flags and report the selected
package-manager path on failure.
- Preserve the existing installed-dependency reuse path before
package-manager resolution.

## Technical choices

- Read the standard `npm_execpath` metadata already supplied by npm and
pnpm; users do not need to configure another environment variable.
- Run JavaScript launchers such as `pnpm.cjs` with the selected Node
runtime, supporting bundled Node distributions without adjacent npm.
- Keep the existing npx-to-npm mapping and fallback behavior unchanged.
- Distinguish numeric package-manager exit codes from process-launch
errors such as `ENOENT`.

## Todo

- [x] Reproduce the bundled Node + pnpm + missing adjacent npm failure.
- [x] Add fail-before/pass-after coverage for dependency installation,
production build, and preview startup.
- [x] Reuse the detected pnpm launcher.
- [x] Preserve installed-dependency reuse.
- [x] Include the selected launcher path in installation failures.
- [x] Report package-manager exits and process-launch errors accurately.
- [x] Review documentation impact and update the CLI manual plus
generated MCP documentation.
- [x] Review fixture impact; no fixture inputs or outputs are affected.

## Verification

- Focused install diagnostic regression suite — 28 tests passed.
- `pnpm --filter webstudio typecheck`
- `pnpm lint`
- `pnpm --filter webstudio test` — 1,029 of 1,030 tests passed; an
existing process-supervisor cleanup test hit a transient `ENOENT`, then
passed 4/4 when rerun in isolation.
- Generated documentation checks passed.
- Real pnpm JavaScript launcher invocation with isolated preview install
flags.
- Agent evaluations were not run because they require separate approval.

Closes #6250
This commit is contained in:
Oleg Isonen authored and GitHub committed 2026-08-28 00:23:01 +02:00
1 parent fc6b5f2fde
commit cbe4950cd0
25 files changed
+2032 -1399

No files matched your search

+18 -16
View File
@@ -422,28 +422,30 @@ Vision-capable AI can use MCP to see what it is building:
4. Read `preview.status.stale` before relying on generated output. When present, `renderedProjectVersion` identifies the last project version materialized into the preview; a stale preview refreshes automatically on the next managed screenshot or `preview.start` call.
5. `preview.start` and `webstudio preview` install generated app dependencies under `.webstudio/preview` and reuse them across regenerations.
6. Session previews download missing project assets into `.webstudio/assets`. If `PREVIEW_ASSET_DOWNLOAD_FAILED` occurs, restore network and project asset access, then retry `preview.start`.
7. Dependency installation honors `npm_config_cache`, including a caller-provided writable cache on Windows.
8. Do not add generated-preview dependencies to the repository root `package.json` or `pnpm-lock.yaml`.
9. If dependency installation fails, the error includes sanitized npm diagnostics. Check the reported npm and network configuration, then reinstall or update the Webstudio CLI if the problem persists.
10. After MCP mutations, path-based screenshots regenerate the current session in place, wait for its exact project version, and normally reload the route. The server and browser remain alive. From one-shot shell calls or another process, pass `baseUrl` with `path` to capture an already-running generated site without starting it. Use preview.stop only in the same long-running MCP server or `webstudio mcp run` process that started preview; a separate one-shot `single-op-call` process does not own another process's preview controller.
11. For multi-page work, capture each changed page by path through the same preview server, for example screenshot({ path: "/" }), screenshot({ path: "/pricing" }), and screenshot({ path: "/about" }). The screenshot tool navigates directly to the requested route; no browser click navigation is required.
12. For responsive work, call list-breakpoints first, then capture screenshots at viewport widths based on the Builder breakpoints plus a narrow mobile and desktop width.
13. Call screenshot with { path: "/" } or the changed page path and viewport such as { width: 375, height: 812 } and { width: 1440, height: 900 }. For an existing preview in another process, call screenshot with { baseUrl: "http://127.0.0.1:5177", path: "/" }. Use waitForSelector when the page has a reliable ready marker, waitUntil:"networkidle" for network-heavy pages, and waitForTimeout only for final visual settling.
14. An explicit occupied `port` fails immediately with `PREVIEW_PORT_IN_USE`. To capture a generated site already running in another process, pass its `baseUrl` with `path`; otherwise choose another port.
15. Automatic browser discovery checks system installations, configured browser paths, and Chromium installations in the Playwright browser cache.
16. The screenshot timeout bounds browser capture after the preview is ready. A timeout returns `SCREENSHOT_TIMEOUT`, resets the reusable browser session, and releases the shared preview lifecycle for cleanup.
17. When a baseline PNG exists, call screenshot.diff with baselinePath, currentPath, and outputDir for each page/viewport pair. Add expectedText when a specific visible phrase must be present; its assertions report pass/fail plus found and missing text. Add expectedVisual to set pass/fail limits for mismatch percentage, the number of changed regions, or an overall dominant color/brightness direction.
18. Read screenshot.diff textAnalysis: it reports OCR status plus text that appeared, disappeared, moved, changed content, or changed font/style geometry. If OCR is unavailable, expectedText assertions fail and textAnalysis reports why; ask the user for permission to install Tesseract, then call vision.install-ocr with { "confirm": true }, or rely on visual inspection.
19. Inspect every viewport PNG and any diff artifacts with vision, then compare layout, OCR text evidence, color, spacing, imagery, and responsive framing against the user intent.
20. If the screenshot does not match, apply another focused mutation and repeat screenshot verification.
7. When launcher metadata is available, dependency installation reuses a supported npm or pnpm launcher. Without launcher metadata, it defaults to npm.
8. When npm is selected, dependency installation honors `npm_config_cache`, including a caller-provided writable cache on Windows.
9. Do not add generated-preview dependencies to the repository root `package.json` or `pnpm-lock.yaml`.
10. If dependency installation fails, the error includes the selected package-manager path and sanitized diagnostics. Check that path and the network configuration, then reinstall or update the Webstudio CLI if the problem persists.
11. After MCP mutations, path-based screenshots regenerate the current session in place, wait for its exact project version, and normally reload the route. The server and browser remain alive. From one-shot shell calls or another process, pass `baseUrl` with `path` to capture an already-running generated site without starting it. Use preview.stop only in the same long-running MCP server or `webstudio mcp run` process that started preview; a separate one-shot `single-op-call` process does not own another process's preview controller.
12. For multi-page work, capture each changed page by path through the same preview server, for example screenshot({ path: "/" }), screenshot({ path: "/pricing" }), and screenshot({ path: "/about" }). The screenshot tool navigates directly to the requested route; no browser click navigation is required.
13. For responsive work, call list-breakpoints first, then capture screenshots at viewport widths based on the Builder breakpoints plus a narrow mobile and desktop width.
14. Call screenshot with { path: "/" } or the changed page path and viewport such as { width: 375, height: 812 } and { width: 1440, height: 900 }. For an existing preview in another process, call screenshot with { baseUrl: "http://127.0.0.1:5177", path: "/" }. Use waitForSelector when the page has a reliable ready marker, waitUntil:"networkidle" for network-heavy pages, and waitForTimeout only for final visual settling.
15. An explicit occupied `port` fails immediately with `PREVIEW_PORT_IN_USE`. To capture a generated site already running in another process, pass its `baseUrl` with `path`; otherwise choose another port.
16. Automatic browser discovery checks system installations, configured browser paths, and Chromium installations in the Playwright browser cache.
17. The screenshot timeout bounds browser capture after the preview is ready. A timeout returns `SCREENSHOT_TIMEOUT`, resets the reusable browser session, and releases the shared preview lifecycle for cleanup.
18. When a baseline PNG exists, call screenshot.diff with baselinePath, currentPath, and outputDir for each page/viewport pair. Add expectedText when a specific visible phrase must be present; its assertions report pass/fail plus found and missing text. Add expectedVisual to set pass/fail limits for mismatch percentage, the number of changed regions, or an overall dominant color/brightness direction.
19. Read screenshot.diff textAnalysis: it reports OCR status plus text that appeared, disappeared, moved, changed content, or changed font/style geometry. If OCR is unavailable, expectedText assertions fail and textAnalysis reports why; ask the user for permission to install Tesseract, then call vision.install-ocr with { "confirm": true }, or rely on visual inspection.
20. Inspect every viewport PNG and any diff artifacts with vision, then compare layout, OCR text evidence, color, spacing, imagery, and responsive framing against the user intent.
21. If the screenshot does not match, apply another focused mutation and repeat screenshot verification.
Generated app setup:
- `preview.start` and `webstudio preview` install generated app dependencies under `.webstudio/preview` and reuse them across regenerations.
- Session previews download missing project assets into `.webstudio/assets`. If `PREVIEW_ASSET_DOWNLOAD_FAILED` occurs, restore network and project asset access, then retry `preview.start`.
- Dependency installation honors `npm_config_cache`, including a caller-provided writable cache on Windows.
- When launcher metadata is available, dependency installation reuses a supported npm or pnpm launcher. Without launcher metadata, it defaults to npm.
- When npm is selected, dependency installation honors `npm_config_cache`, including a caller-provided writable cache on Windows.
- Do not add generated-preview dependencies to the repository root `package.json` or `pnpm-lock.yaml`.
- If dependency installation fails, the error includes sanitized npm diagnostics. Check the reported npm and network configuration, then reinstall or update the Webstudio CLI if the problem persists.
- If dependency installation fails, the error includes the selected package-manager path and sanitized diagnostics. Check that path and the network configuration, then reinstall or update the Webstudio CLI if the problem persists.
## MCP argument examples
+98 -2
View File
@@ -29,6 +29,23 @@ import {
import type { CommonYargsArgv } from "./yargs-types";
import { generatedFilesManifest } from "../prebuild";
const readWindowsPackageFile = (path: string) => {
if (path.endsWith("node_modules\\pnpm\\package.json")) {
return JSON.stringify({
name: "pnpm",
bin: { pnpm: "bin/pnpm.cjs" },
});
}
if (path.endsWith("node_modules\\npm\\package.json")) {
return JSON.stringify({
name: "npm",
bin: { npm: "bin/npm-cli.js", npx: "bin/npx-cli.js" },
});
}
throw Object.assign(new Error("missing"), { code: "ENOENT" });
};
const resolveWindowsLauncherPath = (path: string) => path;
test("rejects empty preview host", async () => {
await expect(
preview({
@@ -466,6 +483,7 @@ test("installs isolated generated dependencies when the cli does not ship them",
readFile: vi.fn(async () => '{"dependencies":{"vite":"1.0.0"}}'),
writeFile,
nodeExecPath: "/opt/webstudio-node/bin/node",
npmExecPath: undefined,
platform: "linux",
env: { PATH: "/usr/bin" },
});
@@ -529,7 +547,10 @@ test("replaces a parent workspace dependency link for published previews", async
"dir"
);
await ensurePreviewDependencies(previewProjectDir, { execFile });
await ensurePreviewDependencies(previewProjectDir, {
execFile,
npmExecPath: undefined,
});
expect(await realpath(join(previewProjectDir, "node_modules"))).not.toBe(
await realpath(parentNodeModules)
@@ -565,6 +586,8 @@ test("reuses the npm cli that launched webstudio on windows", async () => {
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
npmExecPath:
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
readPackageFile: readWindowsPackageFile,
resolveLauncherPath: resolveWindowsLauncherPath,
platform: "win32",
writeFile: vi.fn(async () => undefined),
});
@@ -602,6 +625,8 @@ test("uses npm-cli from an npx launcher and forwards a writable npm cache", asyn
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
npmExecPath:
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js",
readPackageFile: readWindowsPackageFile,
resolveLauncherPath: resolveWindowsLauncherPath,
platform: "win32",
env,
writeFile: vi.fn(async () => undefined),
@@ -624,6 +649,46 @@ test("uses npm-cli from an npx launcher and forwards a writable npm cache", asyn
);
});
test("installs generated dependencies through the available windows pnpm launcher", async () => {
let installed = false;
const execFile = vi.fn(async () => {
installed = true;
return { stdout: "", stderr: "" };
});
await ensurePreviewDependencies("C:/project/.webstudio/preview", {
access: vi.fn(async (path) => {
if (installed && path.startsWith("C:/project/.webstudio/preview")) {
return;
}
throw Object.assign(new Error("missing"), { code: "ENOENT" });
}),
execFile,
lstat: vi.fn(async () => {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
}),
readFile: vi.fn(async () => '{"dependencies":{"vite":"1.0.0"}}'),
nodeExecPath: "C:\\Program Files\\Codex\\node.exe",
npmExecPath: "C:\\Program Files\\Codex\\node_modules\\pnpm\\bin\\pnpm.cjs",
readPackageFile: readWindowsPackageFile,
resolveLauncherPath: resolveWindowsLauncherPath,
platform: "win32",
writeFile: vi.fn(async () => undefined),
});
expect(execFile).toHaveBeenCalledWith(
"C:\\Program Files\\Codex\\node.exe",
[
"C:\\Program Files\\Codex\\node_modules\\pnpm\\bin\\pnpm.cjs",
"install",
"--no-lockfile",
"--loglevel=error",
"--ignore-workspace",
],
expect.objectContaining({ cwd: "C:/project/.webstudio/preview" })
);
});
test("reports an actionable error when generated dependencies cannot install", async () => {
const access = vi.fn(async () => {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
@@ -665,14 +730,43 @@ test("reports sanitized package-manager diagnostics for preview install failures
throw Object.assign(new Error("missing"), { code: "ENOENT" });
}),
readFile: vi.fn(async () => '{"dependencies":{"vite":"1.0.0"}}'),
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
npmExecPath:
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
readPackageFile: readWindowsPackageFile,
resolveLauncherPath: resolveWindowsLauncherPath,
platform: "win32",
});
await expect(promise).rejects.toThrow(
"with npm at C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js"
);
await expect(promise).rejects.toThrow("npm error code EACCES");
await expect(promise).rejects.toThrow("package-manager exit code 1");
await expect(promise).rejects.toThrow("authToken=[redacted]");
await expect(promise).rejects.not.toThrow("private-token");
});
test("distinguishes package-manager process errors from exit codes", async () => {
const promise = ensurePreviewDependencies("/tmp/project/.webstudio/preview", {
access: vi.fn(async () => {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
}),
execFile: vi.fn(async () => {
throw Object.assign(new Error("spawn npm ENOENT"), { code: "ENOENT" });
}),
lstat: vi.fn(async () => {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
}),
readFile: vi.fn(async () => '{"dependencies":{"vite":"1.0.0"}}'),
npmExecPath: undefined,
platform: "linux",
});
await expect(promise).rejects.toThrow("package-manager process error ENOENT");
await expect(promise).rejects.not.toThrow("exit code ENOENT");
});
test("rejects an incomplete generated dependency tree", async () => {
await expect(
ensurePreviewDependencies("/tmp/project/.webstudio/preview", {
@@ -684,6 +778,7 @@ test("rejects an incomplete generated dependency tree", async () => {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
}),
readFile: vi.fn(async () => '{"dependencies":{"vite":"1.0.0"}}'),
npmExecPath: undefined,
platform: "linux",
})
).rejects.toThrow(
@@ -842,7 +937,8 @@ test("documents generated app dependency setup", () => {
expect(epilogueText).toContain("isolated under .webstudio/preview");
expect(epilogueText).toContain("reused across regenerations");
expect(epilogueText).toContain("Do not add generated-preview dependencies");
expect(epilogueText).toContain("check npm and network configuration");
expect(epilogueText).not.toContain("WEBSTUDIO_PREVIEW_PACKAGE_MANAGER");
expect(epilogueText).toContain("reported package-manager path");
});
test("reports the locked preview path, owned PID, and scoped recovery", async () => {
+35 -19
View File
@@ -27,11 +27,11 @@ import { LOCAL_CONFIG_FILE, LOCAL_DATA_FILE } from "../config";
import { HandledCliError } from "../errors";
import {
getPreviewUrl,
getNpmInvocation,
getNodeRuntimeEnv,
previewBuildCacheMarker,
previewProcessOwnerFile,
runPreviewBuild,
resolvePreviewPackageManager,
startPreviewServer,
waitForPreviewExit,
} from "../preview-server";
@@ -106,7 +106,8 @@ export const previewOptions = (yargs: CommonYargsArgv) =>
"Preview builds the generated app and starts its production server.",
"Preview dependencies are isolated under .webstudio/preview and reused across regenerations.",
"Do not add generated-preview dependencies to the repository root package.json or pnpm-lock.yaml.",
"If dependency installation fails, check npm and network configuration, then reinstall or update the Webstudio CLI if the problem persists.",
"When launcher metadata is available, Preview reuses a supported npm or pnpm launcher. Without launcher metadata, it defaults to npm.",
"If dependency installation fails, check the reported package-manager path and network configuration, then reinstall or update the Webstudio CLI if the problem persists.",
].join("\n")
);
@@ -174,6 +175,8 @@ type PreviewDependencyOperations = {
) => Promise<unknown>;
lstat: (path: string) => Promise<{ isSymbolicLink: () => boolean }>;
readFile: (path: string, encoding: "utf8") => Promise<string>;
readPackageFile?: (path: string) => string;
resolveLauncherPath?: (path: string) => string;
rm: (
path: string,
options: { recursive: true; force: true }
@@ -203,11 +206,13 @@ const getPreviewInstallFailureDiagnostics = (error: unknown) => {
)
.trim()
.slice(-2000);
const code =
typeof record.code === "string" || typeof record.code === "number"
? String(record.code)
: undefined;
return `${code === undefined ? "" : `npm exit code ${code}. `}${diagnostics}`;
const codeDiagnostics =
typeof record.code === "number"
? `package-manager exit code ${record.code}. `
: typeof record.code === "string"
? `package-manager process error ${record.code}. `
: "";
return `${codeDiagnostics}${diagnostics}`;
};
export const getPreviewProjectDir = (projectDir = cwd()) =>
@@ -354,16 +359,27 @@ export const ensurePreviewDependencies = async (
}
}
const installArgs = [
"install",
"--legacy-peer-deps",
"--no-audit",
"--no-fund",
"--package-lock=false",
"--loglevel=error",
"--workspaces=false",
];
const invocation = getNpmInvocation(installArgs, operations);
const packageManager = resolvePreviewPackageManager(operations);
const installArgs =
packageManager.name === "pnpm"
? ["install", "--no-lockfile", "--loglevel=error", "--ignore-workspace"]
: [
"install",
"--legacy-peer-deps",
"--no-audit",
"--no-fund",
"--package-lock=false",
"--loglevel=error",
"--workspaces=false",
];
const invocation = {
command: packageManager.command,
args: [...packageManager.argsPrefix, ...installArgs],
};
const packageManagerPath =
invocation.command === operations.nodeExecPath
? invocation.args[0]
: invocation.command;
try {
await operations.execFile(invocation.command, invocation.args, {
@@ -377,13 +393,13 @@ export const ensurePreviewDependencies = async (
});
} catch (error) {
throw new Error(
`PREVIEW_DEPENDENCY_INSTALL_FAILED: Could not install the generated preview dependencies. Check the npm/network configuration, then reinstall or update webstudio if the problem persists.\n\nPackage-manager diagnostics:\n${getPreviewInstallFailureDiagnostics(error)}`,
`PREVIEW_DEPENDENCY_INSTALL_FAILED: Could not install the generated preview dependencies with ${packageManager.name} at ${packageManagerPath}. Check the package-manager/network configuration, then reinstall or update webstudio if the problem persists.\n\nPackage-manager diagnostics:\n${getPreviewInstallFailureDiagnostics(error)}`,
{ cause: error }
);
}
if ((await hasRequiredDependencies(previewNodeModules)) === false) {
throw new Error(
"PREVIEW_DEPENDENCIES_MISSING: npm completed without installing every dependency declared by the generated preview. Reinstall or update webstudio, then retry."
`PREVIEW_DEPENDENCIES_MISSING: ${packageManager.name} completed without installing every dependency declared by the generated preview. Reinstall or update webstudio, then retry.`
);
}
await operations.writeFile(
+2 -2
View File
@@ -2,7 +2,7 @@
// Edit markdown files in src/docs instead.
export const cliDocs = {
"api-use-cases":
'# CLI API Use Cases\n\n## Link/configure one project\n\nCommands:\n\n- webstudio init --link <api-share-link> --json\n\nNotes:\n\n- Writes local project id and global origin/token config.\n\n## Import synced project bundle into another project\n\nCommands:\n\n- webstudio sync\n- webstudio import --to <destination-share-link>\n- MCP tool: import {"to":"<destination-share-link>"}\n\nNotes:\n\n- Imports local `.webstudio/data.json` into the destination project.\n- Destination share link must allow build/import access.\n- Use `--skip-assets` only when asset rows and files should not be imported.\n\n## Identify current token\n\nCommands:\n\n- MCP tool: whoami {}\n\n## Check token permissions\n\nCommands:\n\n- webstudio permissions --json\n\n## Inspect project/build/version\n\nCommands:\n\n- MCP tool: inspect {}\n- MCP tool: snapshot {"include":["pages","instances","styles"]}\n\n## Discover CLI/API capabilities\n\nCommands:\n\n- webstudio schema api\n- webstudio schema mcp\n- webstudio man --json\n- webstudio man llm --json\n- MCP tool: meta.index {}\n- MCP tool: meta.guide {"brief":"Create a pricing page"}\n- MCP tool: meta.get-more-tools {"brief":"update-styles"}\n- webstudio mcp list-resources\n- webstudio mcp read-resource webstudio://project/guide\n- webstudio mcp read-resource webstudio://project/expressions\n\nNotes:\n\n- Use `webstudio schema mcp` for a compact machine-readable MCP tool overview. Add `--verbose` or use focused `meta.get-more-tools` calls only when exact input schemas are needed.\n- Use focused MCP tools for discovery first: `meta.index`, `meta.guide`, `meta.get-more-tools`, `components.list`, `components.summary`, `components.search`, `components.get`, `templates.list`, and `templates.get`. Protocol clients can use `resources/list` and `resources/read`; shell agents can use `webstudio mcp list-resources` and `webstudio mcp read-resource <uri>`. Read longer resources such as `webstudio://project/tools` and `webstudio://project/components` only when focused tools are insufficient.\n- `components.summary` returns counts by default; request `{"detail":"components","limit":20}` for paginated entries. Registry list tools return compact metadata, while `components.get` and `templates.get` return focused full details.\n- Read `webstudio://project/expressions` before authoring unfamiliar computed text, prop bindings, resource expressions, actions, or Collection item bindings.\n- From a shell, call one MCP tool with the shortcut form `webstudio <tool> \'<json>\'`, for example `webstudio components.summary`. The explicit equivalent is `webstudio mcp single-op-call <tool> \'<json>\'`. Use `--input-file` for large payloads.\n\n## Inspect external shadcn registry items\n\nCommands:\n\n- webstudio registry inspect --source https://example.com/r/registry.json --item button --json\n- webstudio registry inspect --source ./registry.json --item dialog --json\n- webstudio registry inspect --source https://example.com/r/button.json --json\n\nNotes:\n\n- Reads a local or remote registry item without installing files or changing the configured Webstudio project.\n- Returns the item name, description, package and registry dependencies, file paths/targets, available docs, and a read-only compatibility report.\n- The report explicitly says whether installation or editable-component conversion is supported, lists declared requirements and manual steps, and says when arbitrary source code was not analyzed.\n- This is an inspection step only. It does not install files or change the configured project.\n\n## Understand what MCP can do\n\nCommands:\n\n- webstudio man mcp\n- MCP tool: meta.index {}\n- MCP tool: meta.guide {"brief":"What can Webstudio MCP do?"}\n\nMCP lets agents work on one configured Webstudio project. Agents can:\n\n- Inspect the linked project, token permissions, and latest editable build.\n- Read selected project data for audits, migrations, and repair.\n- Search values and ids across every Builder namespace without sending full project data to the model.\n- Audit accessibility, security, SEO, performance settings, unused assets, ineffective Collection styles, and unused or duplicate style data.\n- Create and edit pages, folders, redirects, breakpoints, and page templates.\n- Create pages from reusable templates.\n- Update page metadata, SEO fields, auth settings, and marketplace metadata.\n- Insert components and styled JSX sections.\n- Create data-driven lists, grids, cards, and similar repeated UI from array or object data in one Collection operation.\n- Move, copy, wrap, unwrap, convert, rename, retag, and delete elements.\n- Update text, rich text, props, bindings, and actions.\n- Create and update local styles, design tokens, style sources, and CSS variables.\n- Create static data variables and JSON variables.\n- Create HTTP, GraphQL, and system resources.\n- Use system resources for sitemap, current date, and assets.\n- Bind resources to rendered data or form/action props.\n- Manage nest Line truncated
'# CLI API Use Cases\n\n## Link/configure one project\n\nCommands:\n\n- webstudio init --link <api-share-link> --json\n\nNotes:\n\n- Writes local project id and global origin/token config.\n\n## Import synced project bundle into another project\n\nCommands:\n\n- webstudio sync\n- webstudio import --to <destination-share-link>\n- MCP tool: import {"to":"<destination-share-link>"}\n\nNotes:\n\n- Imports local `.webstudio/data.json` into the destination project.\n- Destination share link must allow build/import access.\n- Use `--skip-assets` only when asset rows and files should not be imported.\n\n## Identify current token\n\nCommands:\n\n- MCP tool: whoami {}\n\n## Check token permissions\n\nCommands:\n\n- webstudio permissions --json\n\n## Inspect project/build/version\n\nCommands:\n\n- MCP tool: inspect {}\n- MCP tool: snapshot {"include":["pages","instances","styles"]}\n\n## Discover CLI/API capabilities\n\nCommands:\n\n- webstudio schema api\n- webstudio schema mcp\n- webstudio man --json\n- webstudio man llm --json\n- MCP tool: meta.index {}\n- MCP tool: meta.guide {"brief":"Create a pricing page"}\n- MCP tool: meta.get-more-tools {"brief":"update-styles"}\n- webstudio mcp list-resources\n- webstudio mcp read-resource webstudio://project/guide\n- webstudio mcp read-resource webstudio://project/expressions\n\nNotes:\n\n- Use `webstudio schema mcp` for a compact machine-readable MCP tool overview. Add `--verbose` or use focused `meta.get-more-tools` calls only when exact input schemas are needed.\n- Use focused MCP tools for discovery first: `meta.index`, `meta.guide`, `meta.get-more-tools`, `components.list`, `components.summary`, `components.search`, `components.get`, `templates.list`, and `templates.get`. Protocol clients can use `resources/list` and `resources/read`; shell agents can use `webstudio mcp list-resources` and `webstudio mcp read-resource <uri>`. Read longer resources such as `webstudio://project/tools` and `webstudio://project/components` only when focused tools are insufficient.\n- `components.summary` returns counts by default; request `{"detail":"components","limit":20}` for paginated entries. Registry list tools return compact metadata, while `components.get` and `templates.get` return focused full details.\n- Read `webstudio://project/expressions` before authoring unfamiliar computed text, prop bindings, resource expressions, actions, or Collection item bindings.\n- From a shell, call one MCP tool with the shortcut form `webstudio <tool> \'<json>\'`, for example `webstudio components.summary`. The explicit equivalent is `webstudio mcp single-op-call <tool> \'<json>\'`. Use `--input-file` for large payloads.\n\n## Inspect external shadcn registry items\n\nCommands:\n\n- webstudio registry inspect --source https://example.com/r/registry.json --item button --json\n- webstudio registry inspect --source ./registry.json --item dialog --json\n- webstudio registry inspect --source https://example.com/r/button.json --json\n\nNotes:\n\n- Reads a local or remote registry item without installing files or changing the configured Webstudio project.\n- Returns the item name, description, package and registry dependencies, file paths/targets, available docs, and a read-only compatibility report.\n- The report explicitly says whether installation or editable-component conversion is supported, lists declared requirements and manual steps, and says when arbitrary source code was not analyzed.\n- This is an inspection step only. It does not install files or change the configured project.\n\n## Understand what MCP can do\n\nCommands:\n\n- webstudio man mcp\n- MCP tool: meta.index {}\n- MCP tool: meta.guide {"brief":"What can Webstudio MCP do?"}\n\nMCP lets agents work on one configured Webstudio project. Agents can:\n\n- Inspect the linked project, token permissions, and latest editable build.\n- Read selected project data for audits, migrations, and repair.\n- Search values and ids across every Builder namespace without sending full project data to the model.\n- Audit accessibility, security, SEO, performance settings, unused assets, ineffective Collection styles, and unused or duplicate style data.\n- Create and edit pages, folders, redirects, breakpoints, and page templates.\n- Create pages from reusable templates.\n- Update page metadata, SEO fields, auth settings, and marketplace metadata.\n- Insert components and styled JSX sections.\n- Create data-driven lists, grids, cards, and similar repeated UI from array or object data in one Collection operation.\n- Move, copy, wrap, unwrap, convert, rename, retag, and delete elements.\n- Update text, rich text, props, bindings, and actions.\n- Create and update local styles, design tokens, style sources, and CSS variables.\n- Create static data variables and JSON variables.\n- Create HTTP, GraphQL, and system resources.\n- Use system resources for sitemap, current date, and assets.\n- Bind resources to rendered data or form/action props.\n- Manage nest Line truncated
"manual-api":
'# Webstudio API CLI Manual\n\nThe API commands operate on the single project configured by:\n\n- .webstudio/config.json: projectId\n- global Webstudio config: origin and token\n\n- Pass --json to API/discovery commands that support it. Do not add --json to top-level commands unless their help/schema documents it.\n- Never pass a project id. Commands use configured project only.\n- Read ids before writing. Do not invent ids for existing records.\n- stdout is one JSON object. stderr is diagnostics.\n- Prefer MCP semantic tools for detailed project edits. Use MCP apply-patch only when no semantic tool exists.\n\n## Start\n\n{{start}}\n\n## Read First\n\n{{readFirst}}\n\n## Project Session Cache\n\n- CLI commands use one local ProjectSession snapshot for the configured project.\n- Local-capable reads use cached namespaces when compatible and fetch only missing or stale namespaces.\n- Local-capable mutations build patches from the local snapshot, then commit with the cached build version.\n- Successful mutation commits update the local snapshot only after the remote commit succeeds.\n- Server-only commands run remotely and invalidate/refetch namespaces declared by the operation catalog.\n- Use --refresh on local-capable commands to refresh required namespaces before running.\n- Successful JSON responses include compact meta.session with operationId, buildId, version, source, committed, namespaceCounts, diagnosticCount, non-empty diagnostic summaries, and optional compatibilityVersion.\n\n## Search project data\n\nRun `webstudio search-project \'{"query":"known value"}\'` to search all Builder\nnamespaces in the local project session. Narrow it with\n`{"query":"Brand","namespaces":["styleSources","styles"]}` when only specific\nnamespaces are relevant. Results contain the current value, stable match id,\nnamespace path, owning entity, resolved references, and affected pages.\n\nValues in recognized credential fields are excluded and reported in\n`excludedSensitiveValueCount`.\n\nThe command synchronizes its required Builder namespaces, then searches locally\nand returns only matches. Namespace filters limit values matched; related\nnamespaces may still supply route and reference context, and synchronization is\nunchanged. This reduces data sent to the model, not project synchronization. It\nsearches asset metadata, not asset binaries or document contents.\n\n## CLI Capability Inventory\n\nFor a short end-consumer summary of what MCP can do, see\n`manual mcp` / `webstudio man mcp`. The MCP inventory describes the same\nproject, page, element, style, data, asset, publish, domain, and visual\nverification capabilities without internal command names.\n\n### Top-Level Commands\n\n{{topLevelCapabilityIndex}}\n\n### High-Level API Commands By Area\n\n{{apiCapabilityIndex}}\n\n### MCP Tool Operations\n\nThese are MCP tools. From a shell, call them with the shortcut form `webstudio <tool> \'<json>\'` or with the explicit form `webstudio mcp single-op-call <tool> \'<json>\'`:\n\n{{mcpOnlyCommandIndex}}\n\n## Task Recipes\n\n{{taskRecipeIndex}}\n\n## Use Case Index\n\n{{useCaseIndex}}\n\n## Known CLI Gaps\n\n{{knownCliGapIndex}}\n\n## Input File Shapes\n\n{{inputFileShapeIndex}}\n\n## Raw Patch Fallback\n\napply-patch accepts either BuildPatchTransaction[] or { "transactions": BuildPatchTransaction[] }.\n\nEach transaction has:\n\n{\n"id": "patch-transaction-label",\n"payload": [\n{\n"namespace": "projectSettings",\n"patches": [\n{ "op": "replace", "path": ["meta", "siteName"], "value": "New Site" }\n]\n}\n]\n}\n\nThe transaction id is a patch label used for optimistic synchronization. It is\nnot a Builder record id. Do not invent ids for pages, instances, props,\nbreakpoints, resources, variables, folders, assets, or other project records.\n\nPatch paths are JSON-patch-like paths into Builder store data. Map-like namespaces use ids as the first path item.\n\nSupported namespaces:\n\n- pages: redirects, page records, and folders\n- projectSettings: project-wide metadata and compiler settings\n- instances: element instances and children, including text/expression children\n- props: element props, bindings, page references, resource bindings\n- styles: CSS declarations keyed by style declaration key\n- styleSources: local style sources and reusable design tokens\n- styleSourceSelections: instance-to-style-source connections\n- dataSources: data variables, parameters, and resource data sources\n- resources: data resource definitions\n- assets: project asset records handled by the existing asset patch path\n- breakpoints: responsive breakpoints\n- marketplaceProduct: marketplace metadata\n\n## Data Sources\n\n`dataSources` is the internal Builder namespace for variables. Public API, CLI,\nand MCP tools expose it through two user-facing groups:\n\n- data variables: `list-variables`, `create-variable`, `update-variable`, and\n `delete-variable`\n- data resources: `list-resources`, `create-resource`, `update-resource`, and\Line truncated
"manual-llm":
@@ -12,7 +12,7 @@ export const cliDocs = {
"mcp-startup-epilogue":
'If you are inside the Webstudio monorepo, use the local CLI exactly as `node packages/cli/local.js ...` as your first command path. Do not use `packages/cli/bin.js` for local source-tree work; it is the packaged build entry and may use stale built output.\n\nPlain `webstudio mcp` starts the stdio MCP server for real MCP clients. Do not manually type or pipe raw JSON-RPC frames into it from an interactive shell or PTY. From a shell, use shortcut calls such as `webstudio meta.index` and `webstudio insert-fragment \'<json>\' --dry-run` for one bounded tool call. The explicit equivalent is `webstudio mcp single-op-call <tool> \'<json>\'`. Use `webstudio mcp run \'[{"tool":"components.find","input":{"brief":"button"}}]\'` for small multi-call batches in one shared CLI session. For large batches, pass a normal JSON file path such as `.temp/mcp-calls.json`. Do not use shell process substitution like `<(...)`; use inline JSON or a real file.\n\nPut each local CLI call in its own shell command; do not chain helper commands with `&&`, `;`, command substitution, or shell wrappers around the CLI when reporting a CLI step. Do not use `pnpm exec webstudio`, `pnpm --filter webstudio exec webstudio`, or a global `webstudio`; those can resolve an older binary. For example:\n\n```sh\nnode packages/cli/local.js mcp single-op-call meta.index\nnode packages/cli/local.js mcp single-op-call meta.get-more-tools \'{"tools":["insert-fragment"]}\'\nnode packages/cli/local.js mcp single-op-call insert-fragment --input-file .temp/insert-fragment.json --dry-run\n```\n\nSave the input as `.temp/insert-fragment.json`; use single quotes for JSX attributes so the JSON needs no backslash escaping:\n\n```json\n{\n "parentInstanceId": "parent-id",\n "fragment": "<ws.element ws:tag=\'section\' ws:style={css`padding: 32px; display: grid; gap: 12px;`}><ws.element ws:tag=\'h2\'>Launch Kit</ws.element><ws.element ws:tag=\'p\'>A focused section created with Webstudio JSX.</ws.element><ws.element ws:tag=\'button\'>Get started</ws.element></ws.element>"\n}\n```\n\nThe shorter local shortcut form is preferred for simple shell steps:\n\n```sh\nnode packages/cli/local.js meta.index\nnode packages/cli/local.js meta.get-more-tools \'{"tools":["insert-fragment"]}\'\nnode packages/cli/local.js insert-fragment --input-file .temp/insert-fragment.json --dry-run\n```\n\nFor simple authored/styled sections, run the three commands above. Do not grep source files, dump full MCP resources, or write parser scripts first. Use `list-pages`, `get-page-by-path`, or `list-instances` only when you still need the target `parentInstanceId`.\n\nWhen building for a Content-mode editor, use a Content Block (`ws:block`) around every region that editor must change. Content-mode text and supported props are editable only in Content Block descendants; content outside is read-only. Keep reusable insertable source options in the block\'s `ws:block-template` child. Templates themselves are protected; an editor\'s inserted copy becomes an editable direct child of the Content Block. Verify this structure before handoff.\n\nRun it from the linked Webstudio project root. The startup status line prints that absolute root; use it for local files such as `<project root>/.temp/script.mjs`, screenshots, and generated artifacts.\n\nFor experiments, pass `--dry-run` to local-capable mutation calls. Read the computed transaction from `meta.session.transaction` and its base build version from `meta.session.version`. Copying a `.webstudio` folder is not an isolated project clone; `.webstudio/config.json` still points to the same remote project, so non-dry-run mutations can commit to that project.\n\nStartup marks cached ProjectSession data stale so MCP tools read the current Builder dev build.\n\nAfter startup, use focused discovery tools with concrete JSON. For delegated design-system or “use every component” tasks, start with exactly one command: `webstudio workflow.next \'{"goal":"design-system-page"}\'`; report the returned checkpoint to the parent/user and stop until continued. For other tasks, read `meta.index` first. From a shell, `webstudio mcp list-tools` is a concise alias for the initial tool catalog. Then call shortcuts such as `webstudio meta.guide \'{"brief":"Create a design system page using every component"}\'`, `webstudio workflow.next \'{"goal":"design-system-page"}\'`, `webstudio meta.get-more-tools \'{"tools":["insert-fragment"]}\'`, `webstudio components.list \'{"source":"all"}\'`, `webstudio components.summary`, `webstudio components.search \'{"brief":"radix select"}\'`, `webstudio components.get \'{"component":"@webstudio-is/sdk-components-react-radix:Select"}\'`, `webstudio templates.list`, and `webstudio templates.get \'{"component":"@webstudio-is/sdk-components-react-radix:Select"}\'`. Use `webstudio mcp single-op-call` when you need the explicit MCP form. Prefer `insert-fragment` for authored/styled sections; use `insert-component` only when inserting one auto Line truncated
"mcp-vision":
'# MCP Vision Verification\n\n## Generated App Dependency Notes\n\n- `preview.start` and `webstudio preview` install generated app dependencies under `.webstudio/preview` and reuse them across regenerations.\n- Session previews download missing project assets into `.webstudio/assets`. If `PREVIEW_ASSET_DOWNLOAD_FAILED` occurs, restore network and project asset access, then retry `preview.start`.\n- Dependency installation honors `npm_config_cache`, including a caller-provided writable cache on Windows.\n- Do not add generated-preview dependencies to the repository root `package.json` or `pnpm-lock.yaml`.\n- If dependency installation fails, the error includes sanitized npm diagnostics. Check the reported npm and network configuration, then reinstall or update the Webstudio CLI if the problem persists.\n\n## Visual Verification Rule\n\nAsk the user whether they want visual verification before starting it, unless the user explicitly requested screenshots, visual verification, or a rendered audit. Never start `preview.start`, `screenshot`, `screenshot.diff`, `vision.install-ocr`, or a rendered audit automatically. If the user does not opt in, use focused non-visual assertions and report that vision was not run. After the user opts in, use `preview.start` and `screenshot({ path })` so vision can inspect the current MCP session and verify that generated project files are current. Iterative preview is the default: it keeps one generated-site server and browser alive, regenerates changed files, and performs an ordinary page reload without Vite HMR. Use `mode: "production"` only for release-like verification; rendered audit selects it automatically. `preview.start` is long-lived and cannot be used through one-shot `mcp single-op-call`; from a shell, use `webstudio mcp run` for preview.start/screenshot/preview.stop in one shared process, or use a long-running MCP server. When a baseline exists, use screenshot.diff to get pixel regions, OCR text changes, and diff PNG artifacts.\n\nAn authenticated project share URL is used with `webstudio init --link`; it is\nnot a generated-site preview URL. Project screenshots and rendered audits use\nthe generated local preview owned by the current CLI/MCP process. Do not pass a\nBuilder/share URL to `screenshot`, even without query parameters. Use `path`\nafter starting preview, or use `baseUrl` only for an intentional generated site\nthat is already running. Path captures verify the generated-site root marker\nand fail instead of returning a screenshot of Builder chrome.\n\n## Vision Verification Loop\n\n- Ask the user whether they want visual verification unless they explicitly requested it. Stop before calling preview, capture, comparison, OCR, or rendered-audit tools until the user opts in.\n- Make focused page/content/style changes with semantic MCP tools.\n- Call preview.start once to keep the iterative generated site running. In shell-driven workflows, run preview.start, screenshot, and preview.stop inside one `webstudio mcp run` call so they share the same preview owner.\n- Read `preview.status.stale` before relying on generated output. When present, `renderedProjectVersion` identifies the last project version materialized into the preview; a stale preview refreshes automatically on the next managed screenshot or `preview.start` call.\n- {{dependency-notes}}\n- After MCP mutations, path-based screenshots regenerate the current session in place, wait for its exact project version, and normally reload the route. The server and browser remain alive. From one-shot shell calls or another process, pass `baseUrl` with `path` to capture an already-running generated site without starting it. Use preview.stop only in the same long-running MCP server or `webstudio mcp run` process that started preview; a separate one-shot `single-op-call` process does not own another process\'s preview controller.\n- For multi-page work, capture each changed page by path through the same preview server, for example screenshot({ path: "/" }), screenshot({ path: "/pricing" }), and screenshot({ path: "/about" }). The screenshot tool navigates directly to the requested route; no browser click navigation is required.\n- For responsive work, call list-breakpoints first, then capture screenshots at viewport widths based on the Builder breakpoints plus a narrow mobile and desktop width.\n- Call screenshot with { path: "/" } or the changed page path and viewport such as { width: 375, height: 812 } and { width: 1440, height: 900 }. For an existing preview in another process, call screenshot with { baseUrl: "http://127.0.0.1:5177", path: "/" }. Use waitForSelector when the page has a reliable ready marker, waitUntil:"networkidle" for network-heavy pages, and waitForTimeout only for final visual settling.\n- An explicit occupied `port` fails immediately with `PREVIEW_PORT_IN_USE`. To capture a generated site already running in another process, pass its `baseUrl` with `path`; otherwise choose another port.\Line truncated
'# MCP Vision Verification\n\n## Generated App Dependency Notes\n\n- `preview.start` and `webstudio preview` install generated app dependencies under `.webstudio/preview` and reuse them across regenerations.\n- Session previews download missing project assets into `.webstudio/assets`. If `PREVIEW_ASSET_DOWNLOAD_FAILED` occurs, restore network and project asset access, then retry `preview.start`.\n- When launcher metadata is available, dependency installation reuses a supported npm or pnpm launcher. Without launcher metadata, it defaults to npm.\n- When npm is selected, dependency installation honors `npm_config_cache`, including a caller-provided writable cache on Windows.\n- Do not add generated-preview dependencies to the repository root `package.json` or `pnpm-lock.yaml`.\n- If dependency installation fails, the error includes the selected package-manager path and sanitized diagnostics. Check that path and the network configuration, then reinstall or update the Webstudio CLI if the problem persists.\n\n## Visual Verification Rule\n\nAsk the user whether they want visual verification before starting it, unless the user explicitly requested screenshots, visual verification, or a rendered audit. Never start `preview.start`, `screenshot`, `screenshot.diff`, `vision.install-ocr`, or a rendered audit automatically. If the user does not opt in, use focused non-visual assertions and report that vision was not run. After the user opts in, use `preview.start` and `screenshot({ path })` so vision can inspect the current MCP session and verify that generated project files are current. Iterative preview is the default: it keeps one generated-site server and browser alive, regenerates changed files, and performs an ordinary page reload without Vite HMR. Use `mode: "production"` only for release-like verification; rendered audit selects it automatically. `preview.start` is long-lived and cannot be used through one-shot `mcp single-op-call`; from a shell, use `webstudio mcp run` for preview.start/screenshot/preview.stop in one shared process, or use a long-running MCP server. When a baseline exists, use screenshot.diff to get pixel regions, OCR text changes, and diff PNG artifacts.\n\nAn authenticated project share URL is used with `webstudio init --link`; it is\nnot a generated-site preview URL. Project screenshots and rendered audits use\nthe generated local preview owned by the current CLI/MCP process. Do not pass a\nBuilder/share URL to `screenshot`, even without query parameters. Use `path`\nafter starting preview, or use `baseUrl` only for an intentional generated site\nthat is already running. Path captures verify the generated-site root marker\nand fail instead of returning a screenshot of Builder chrome.\n\n## Vision Verification Loop\n\n- Ask the user whether they want visual verification unless they explicitly requested it. Stop before calling preview, capture, comparison, OCR, or rendered-audit tools until the user opts in.\n- Make focused page/content/style changes with semantic MCP tools.\n- Call preview.start once to keep the iterative generated site running. In shell-driven workflows, run preview.start, screenshot, and preview.stop inside one `webstudio mcp run` call so they share the same preview owner.\n- Read `preview.status.stale` before relying on generated output. When present, `renderedProjectVersion` identifies the last project version materialized into the preview; a stale preview refreshes automatically on the next managed screenshot or `preview.start` call.\n- {{dependency-notes}}\n- After MCP mutations, path-based screenshots regenerate the current session in place, wait for its exact project version, and normally reload the route. The server and browser remain alive. From one-shot shell calls or another process, pass `baseUrl` with `path` to capture an already-running generated site without starting it. Use preview.stop only in the same long-running MCP server or `webstudio mcp run` process that started preview; a separate one-shot `single-op-call` process does not own another process\'s preview controller.\n- For multi-page work, capture each changed page by path through the same preview server, for example screenshot({ path: "/" }), screenshot({ path: "/pricing" }), and screenshot({ path: "/about" }). The screenshot tool navigates directly to the requested route; no browser click navigation is required.\n- For responsive work, call list-breakpoints first, then capture screenshots at viewport widths based on the Builder breakpoints plus a narrow mobile and desktop width.\n- Call screenshot with { path: "/" } or the changed page path and viewport such as { width: 375, height: 812 } and { width: 1440, height: 900 }. For an existing preview in another process, call screenshot with { baseUrl: "http://127.0.0.1:5177", path: "/" }. Use waitForSelector when the page has a reliable ready marker, waitUntil:"networkidle" for network-heavy pages, and waitForTimeout only for final visual settling.Line truncated
} as const;
export const cliDocTitles = {
+2 -1
View File
@@ -160,7 +160,8 @@ Notes:
- The screenshot timeout bounds browser capture after the preview is ready. A timeout returns `SCREENSHOT_TIMEOUT`, resets the reusable browser session, and releases the shared preview lifecycle for cleanup.
- Preview installs generated app dependencies under `.webstudio/preview` and reuses them across regenerations.
- Do not add generated-preview dependencies to the repository root `package.json` or `pnpm-lock.yaml`.
- If dependency installation fails, check npm and network configuration, then reinstall or update the Webstudio CLI if the problem persists.
- When launcher metadata is available, Preview reuses a supported npm or pnpm launcher. Without launcher metadata, it defaults to npm.
- If dependency installation fails, check the reported package-manager path and network configuration, then reinstall or update the Webstudio CLI if the problem persists.
- When a baseline exists, use screenshot.diff once per baseline/current page or viewport pair to get changed regions, OCR textAnalysis, and diff artifact paths before deciding whether the result matches. Pass expectedText for explicit pass/fail current-screen text assertions with found and missing text. Pass expectedVisual for pass/fail limits on pixel mismatch percentage, changed-region count, or the overall dominant color/brightness direction.
- If screenshot.diff reports OCR unavailable and the user agrees to install it, call vision.install-ocr {"confirm":true}; otherwise continue with pixel diff and visual inspection.
- Compare the PNG, OCR text evidence, and diff artifacts against the user's intent for layout, typography, colors, spacing, imagery, and responsive framing; then iterate with focused mutations.
+3 -2
View File
@@ -4,9 +4,10 @@
- `preview.start` and `webstudio preview` install generated app dependencies under `.webstudio/preview` and reuse them across regenerations.
- Session previews download missing project assets into `.webstudio/assets`. If `PREVIEW_ASSET_DOWNLOAD_FAILED` occurs, restore network and project asset access, then retry `preview.start`.
- Dependency installation honors `npm_config_cache`, including a caller-provided writable cache on Windows.
- When launcher metadata is available, dependency installation reuses a supported npm or pnpm launcher. Without launcher metadata, it defaults to npm.
- When npm is selected, dependency installation honors `npm_config_cache`, including a caller-provided writable cache on Windows.
- Do not add generated-preview dependencies to the repository root `package.json` or `pnpm-lock.yaml`.
- If dependency installation fails, the error includes sanitized npm diagnostics. Check the reported npm and network configuration, then reinstall or update the Webstudio CLI if the problem persists.
- If dependency installation fails, the error includes the selected package-manager path and sanitized diagnostics. Check that path and the network configuration, then reinstall or update the Webstudio CLI if the problem persists.
## Visual Verification Rule
+5 -1
View File
@@ -11,10 +11,14 @@ test("documents generated app setup for visual verification", () => {
expect(generatedAppDependencyNotes).toEqual([
expect.stringContaining("install generated app dependencies"),
expect.stringContaining("PREVIEW_ASSET_DOWNLOAD_FAILED"),
expect.stringContaining("npm or pnpm launcher"),
expect.stringContaining("npm_config_cache"),
expect.stringContaining("Do not add generated-preview dependencies"),
expect.stringContaining("Check the reported npm and network configuration"),
expect.stringContaining("selected package-manager path"),
]);
expect(generatedAppDependencyNotes.join("\n")).not.toContain(
"WEBSTUDIO_PREVIEW_PACKAGE_MANAGER"
);
expect(visualVerificationRule).toContain(
"generated project files are current"
);
-805
View File
@@ -1,805 +0,0 @@
import {
spawn,
type ChildProcess,
type StdioOptions,
} from "node:child_process";
import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { basename, delimiter, dirname, join, parse, win32 } from "node:path";
import { fileURLToPath } from "node:url";
import detectPort from "detect-port";
import getPort from "get-port";
import pathKey from "path-key";
import { parse as parseHtml, type DefaultTreeAdapterMap } from "parse5";
import type { ProjectPreviewMode } from "@webstudio-is/project-build/preview";
export type PreviewMode = ProjectPreviewMode;
export type PreviewServerOptions = {
host: string;
port: number;
mode?: PreviewMode;
cwd?: string;
imageDomains?: string[];
};
export type PreviewServerResult = {
url: string;
process: ChildProcess;
};
export type PreviewServerDependencies = {
spawn: typeof spawn;
parentProcess: {
pid: number;
once: (signal: NodeJS.Signals, handler: () => void) => unknown;
off: (signal: NodeJS.Signals, handler: () => void) => unknown;
kill: (pid: number, signal: NodeJS.Signals) => boolean;
};
fetch: typeof fetch;
cp: typeof cp;
mkdir: typeof mkdir;
readdir: typeof readdir;
readFile: typeof readFile;
writeFile: typeof writeFile;
sleep: (ms: number) => Promise<void>;
nodeExecPath: string;
npmExecPath?: string;
processExecArgv: string[];
supervisorPath: string;
platform: typeof process.platform;
};
const supervisorPath = fileURLToPath(
new URL(
import.meta.url.includes("/src/")
? "./preview-process-supervisor.ts"
: "./preview-process-supervisor.js",
import.meta.url
)
);
export const defaultPreviewServerDependencies: PreviewServerDependencies = {
spawn,
parentProcess: process,
fetch,
cp,
mkdir,
readdir,
readFile,
writeFile,
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
nodeExecPath: process.execPath,
npmExecPath: process.env.npm_execpath,
processExecArgv: process.execArgv,
supervisorPath,
platform: process.platform,
};
export const findAvailablePort = (host = "127.0.0.1") => getPort({ host });
export const isPreviewPortAvailable = async (host: string, port: number) => {
const availablePort = await detectPort({ hostname: host, port });
return availablePort === port;
};
const processEnv = () => process.env;
const getAncestorBinPaths = (directory: string) => {
const paths: string[] = [];
let currentDirectory = directory;
while (true) {
paths.push(join(currentDirectory, "node_modules", ".bin"));
paths.push(
join(currentDirectory, "node_modules", ".pnpm", "node_modules", ".bin")
);
const parentDirectory = dirname(currentDirectory);
if (
parentDirectory === currentDirectory ||
currentDirectory === parse(currentDirectory).root
) {
return paths;
}
currentDirectory = parentDirectory;
}
};
const getPreviewEnv = ({
cwd,
extraEnv,
nodeExecPath,
platform,
}: {
cwd?: string;
extraEnv: NodeJS.ProcessEnv;
nodeExecPath: string;
platform: NodeJS.Platform;
}) => {
const key = pathKey({ env: extraEnv, platform });
const separator = platform === "win32" ? win32.delimiter : delimiter;
const nodeBinDirectory =
platform === "win32" ? win32.dirname(nodeExecPath) : dirname(nodeExecPath);
return {
...extraEnv,
[key]: [
nodeBinDirectory,
...(cwd === undefined ? [] : getAncestorBinPaths(cwd)),
extraEnv[key],
]
.filter(Boolean)
.join(separator),
};
};
export const getNodeRuntimeEnv = (
nodeExecPath: string,
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform
) => getPreviewEnv({ extraEnv: env, nodeExecPath, platform });
export const getPreviewUrl = ({
host,
port,
path = "/",
}: PreviewServerOptions & { path?: string }) =>
new URL(path, `http://${host}:${port}`).toString();
export const getPreviewBuildArgs = () => ["run", "build"];
export const getPreviewStartArgs = (options: PreviewServerOptions) =>
options.mode === "iterative"
? [
"run",
"dev",
"--",
"--host",
options.host,
"--port",
String(options.port),
"--strictPort",
]
: ["run", "start"];
export const getNpmInvocation = (
args: string[],
{
nodeExecPath = process.execPath,
npmExecPath = process.env.npm_execpath,
platform = process.platform,
}: {
nodeExecPath?: string;
npmExecPath?: string;
platform?: typeof process.platform;
} = {}
) => {
const npmCliName =
npmExecPath === undefined
? undefined
: platform === "win32"
? win32.basename(npmExecPath)
: basename(npmExecPath);
if (npmExecPath !== undefined && npmCliName !== undefined) {
const npmCliPath =
npmCliName === "npm-cli.js"
? npmExecPath
: npmCliName === "npx-cli.js"
? platform === "win32"
? win32.join(win32.dirname(npmExecPath), "npm-cli.js")
: join(dirname(npmExecPath), "npm-cli.js")
: undefined;
if (npmCliPath !== undefined) {
return { command: nodeExecPath, args: [npmCliPath, ...args] };
}
}
if (platform === "win32") {
return {
command: nodeExecPath,
args: [
win32.join(
win32.dirname(nodeExecPath),
"node_modules",
"npm",
"bin",
"npm-cli.js"
),
...args,
],
};
}
return { command: "npm", args };
};
export const runPreviewBuild = async (
dependencies = defaultPreviewServerDependencies,
cwd?: string,
stdio: StdioOptions = "inherit"
) => {
const invocation = getNpmInvocation(getPreviewBuildArgs(), dependencies);
const buildProcess = dependencies.spawn(invocation.command, invocation.args, {
cwd,
stdio,
env: getPreviewEnv({
cwd,
extraEnv: {
...processEnv(),
CI: "1",
NODE_ENV: "production",
},
nodeExecPath: dependencies.nodeExecPath,
platform: dependencies.platform,
}),
});
let output = "";
const appendOutput = (chunk: unknown) => {
output = `${output}${String(chunk)}`.slice(-4000);
};
buildProcess.stdout?.on("data", appendOutput);
buildProcess.stderr?.on("data", appendOutput);
try {
await waitForPreviewExit(buildProcess);
await materializePreviewAssets(cwd, dependencies);
} catch (error) {
if (output.length === 0) {
throw error;
}
throw new Error(
`${error instanceof Error ? error.message : String(error)}\n\nPreview build output:\n${output}`,
{ cause: error }
);
}
};
export const materializePreviewAssets = async (
cwd: string | undefined,
dependencies = defaultPreviewServerDependencies
) => {
if (cwd === undefined) {
return;
}
const source = join(cwd, "public", "assets");
const destination = join(cwd, "build", "client", "assets");
try {
await dependencies.mkdir(destination, { recursive: true });
await dependencies.cp(source, destination, {
recursive: true,
force: true,
});
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return;
}
throw error;
}
};
export const startPreviewServer = (
options: PreviewServerOptions & {
stdio?: StdioOptions;
detached?: boolean;
},
dependencies = defaultPreviewServerDependencies
): PreviewServerResult => {
const invocation = getNpmInvocation(
getPreviewStartArgs(options),
dependencies
);
const stdio = options.stdio ?? "inherit";
const supervisorStdio: StdioOptions = Array.isArray(stdio)
? [...stdio.slice(0, 3), "ipc"]
: [stdio, stdio, stdio, "ipc"];
const supervisorExecArgv = dependencies.supervisorPath.endsWith(".ts")
? dependencies.processExecArgv.filter(
(argument) =>
argument.startsWith("--conditions=") ||
argument.startsWith("--import=")
)
: [];
const previewProcess = dependencies.spawn(
dependencies.nodeExecPath,
[
...supervisorExecArgv,
dependencies.supervisorPath,
JSON.stringify({
command: invocation.command,
args: invocation.args,
cwd: options.cwd,
...(options.cwd === undefined
? {}
: {
ownerFile: join(dirname(options.cwd), previewProcessOwnerFile),
}),
}),
],
{
cwd: options.cwd,
...(options.detached === undefined ? {} : { detached: options.detached }),
stdio: supervisorStdio,
env: getPreviewEnv({
cwd: options.cwd,
extraEnv: {
...processEnv(),
HOST: options.host,
PORT: String(options.port),
...(options.imageDomains === undefined
? {}
: { DOMAINS: options.imageDomains.join(",") }),
NODE_ENV: options.mode === "iterative" ? "development" : "production",
...(options.mode === "iterative"
? { WEBSTUDIO_PREVIEW_HMR: "disabled" }
: {}),
},
nodeExecPath: dependencies.nodeExecPath,
platform: dependencies.platform,
}),
}
);
return {
url: getPreviewUrl(options),
process: previewProcess,
};
};
const stopPreviewProcess = (previewProcess: ChildProcess) => {
if (previewProcess.connected) {
previewProcess.disconnect();
return true;
}
return previewProcess.kill("SIGTERM");
};
export const waitForPreviewExit = async (process: ChildProcess) => {
const code = await new Promise<number | null>((resolve, reject) => {
process.once("error", reject);
process.once("exit", resolve);
});
if (code !== 0 && code !== null) {
throw new Error(`Preview server exited with code ${code}.`);
}
};
export type PreviewControllerResult = {
url?: string;
pid?: number;
running: boolean;
mode?: PreviewMode;
};
type PreviewControllerStartOptions = Partial<PreviewServerOptions> & {
restart?: boolean;
buildCacheKey?: string;
};
export const arePreviewImageDomainsEqual = (
left: string[] | undefined,
right: string[] | undefined
) =>
left === right ||
(left !== undefined &&
right !== undefined &&
left.length === right.length &&
left.every((value, index) => value === right[index]));
export const previewBuildCacheMarker = ".webstudio-preview-build";
export const previewProcessOwnerFile = "preview-process.json";
const getPreviewProjectIdentity = async (
cwd: string | undefined,
dependencies = defaultPreviewServerDependencies
) => {
if (cwd === undefined) {
return undefined;
}
try {
const data = JSON.parse(
await dependencies.readFile(join(cwd, ".webstudio", "data.json"), "utf8")
) as { build?: { projectId?: unknown; version?: unknown } };
if (typeof data.build?.projectId !== "string") {
throw new Error("projectId is missing");
}
return {
projectId: data.build.projectId,
...(typeof data.build.version === "number"
? { version: data.build.version }
: {}),
};
} catch (error) {
throw new Error(
`Could not identify the generated preview project in ${join(cwd, ".webstudio", "data.json")}.`,
{ cause: error }
);
}
};
const getGeneratedSiteIdentity = (html: string) => {
const document = parseHtml(html);
const visit = (
node: DefaultTreeAdapterMap["node"]
): { projectId: string; version?: number } | undefined => {
if ("attrs" in node && Array.isArray(node.attrs)) {
const attributes = new Map(
node.attrs.map(({ name, value }) => [name, value])
);
const projectId = attributes.get("data-ws-project");
const versionValue = attributes.get("data-ws-version");
if (projectId !== undefined) {
const version = Number(versionValue);
return {
projectId,
...(Number.isFinite(version) ? { version } : {}),
};
}
}
if ("childNodes" in node) {
for (const child of node.childNodes) {
const identity = visit(child);
if (identity !== undefined) {
return identity;
}
}
}
};
return visit(document);
};
export const waitForPreviewReady = async (
url: string,
{
timeoutMs = 30_000,
intervalMs = 250,
isRunning,
requiredAssetNames = [],
requiredProject,
}: {
timeoutMs?: number;
intervalMs?: number;
isRunning?: () => boolean;
requiredAssetNames?: string[];
requiredProject?: { projectId: string; version?: number };
} = {},
dependencies = defaultPreviewServerDependencies
) => {
const deadline = Date.now() + timeoutMs;
let sawStaleServer = false;
let sawUnexpectedProject = false;
while (Date.now() <= deadline) {
if (isRunning?.() === false) {
throw new Error(
`Preview server exited before it became ready at ${url}.`
);
}
try {
const attemptTimeoutMs = Math.max(
1,
Math.min(5000, deadline - Date.now())
);
const response = await dependencies.fetch(url, {
method: "GET",
signal: AbortSignal.timeout(attemptTimeoutMs),
});
if (response.status === 401 && requiredProject !== undefined) {
const identityResponse = await dependencies.fetch(
new URL("/__webstudio/preview.json", url),
{
method: "GET",
signal: AbortSignal.timeout(attemptTimeoutMs),
}
);
if (identityResponse.ok) {
const identity = (await identityResponse.json()) as {
projectId?: unknown;
version?: unknown;
};
if (
identity.projectId === requiredProject.projectId &&
(requiredProject.version === undefined ||
identity.version === requiredProject.version)
) {
return;
}
sawUnexpectedProject = true;
}
}
if (response.status < 500) {
if (requiredAssetNames.length === 0 && requiredProject === undefined) {
return;
}
const html = await response.text();
const identity = getGeneratedSiteIdentity(html);
const servesExpectedProject =
requiredProject === undefined ||
(identity?.projectId === requiredProject.projectId &&
(requiredProject.version === undefined ||
identity.version === requiredProject.version));
const servesLatestAssets =
requiredAssetNames.length === 0 ||
requiredAssetNames.some((name) => html.includes(name));
if (servesExpectedProject && servesLatestAssets) {
return;
}
sawUnexpectedProject ||= servesExpectedProject === false;
sawStaleServer ||= servesLatestAssets === false;
}
} catch {
// Server is still starting.
}
await dependencies.sleep(intervalMs);
}
if (sawUnexpectedProject) {
throw new Error(
`Preview server at ${url} did not serve the expected generated project. Stop the existing preview server on this port, then retry.`
);
}
if (sawStaleServer) {
throw new Error(
`Preview server at ${url} did not serve the latest build assets. Stop the existing preview server on this port, then retry.`
);
}
throw new Error(`Preview server did not become ready at ${url}.`);
};
const getPreviewCssAssetNames = async (
cwd: string | undefined,
dependencies = defaultPreviewServerDependencies
) => {
if (cwd === undefined) {
return [];
}
try {
return (await dependencies.readdir(join(cwd, "build", "client", "assets")))
.filter((name) => name.endsWith(".css"))
.sort();
} catch {
return [];
}
};
const formatPreviewServerStartupError = ({
message,
output,
url,
}: {
message: string;
output: string;
url: string;
}) => {
const portHint = output.includes("EADDRINUSE")
? `\n\nPort is already in use. Stop the existing preview server for ${url}, or start preview with a different port.`
: "";
return `${message}\n\nPreview server output:\n${output}${portHint}`;
};
export const createPreviewController = (
defaults: PreviewServerOptions,
dependencies = defaultPreviewServerDependencies,
{ manageProcessSignals = true }: { manageProcessSignals?: boolean } = {}
) => {
let server: PreviewServerResult | undefined;
let currentOptions = defaults;
let currentCwd = defaults.cwd;
let serverOutput = "";
const terminationSignals = ["SIGHUP", "SIGINT", "SIGTERM"] as const;
let isTerminating = false;
const terminationHandlers = new Map<NodeJS.Signals, () => void>();
const removeTerminationHandlers = () => {
for (const [signal, handler] of terminationHandlers) {
dependencies.parentProcess.off(signal, handler);
}
terminationHandlers.clear();
};
const installTerminationHandlers = () => {
if (manageProcessSignals === false || terminationHandlers.size > 0) {
return;
}
for (const signal of terminationSignals) {
const handler = () => {
if (isTerminating) {
return;
}
isTerminating = true;
const activeServer = server;
server = undefined;
removeTerminationHandlers();
if (activeServer === undefined) {
dependencies.parentProcess.kill(
dependencies.parentProcess.pid,
signal
);
return;
}
try {
stopPreviewProcess(activeServer.process);
} finally {
dependencies.parentProcess.kill(
dependencies.parentProcess.pid,
signal
);
}
};
terminationHandlers.set(signal, handler);
dependencies.parentProcess.once(signal, handler);
}
};
const appendServerOutput = (chunk: unknown) => {
serverOutput = `${serverOutput}${String(chunk)}`.slice(-4000);
};
const isRunning = () =>
server !== undefined &&
server.process.killed === false &&
server.process.exitCode === null &&
server.process.signalCode === null;
const getStatus = (): PreviewControllerResult => {
if (isRunning() === false) {
return { running: false };
}
return {
url: getPreviewUrl(currentOptions),
pid: server?.process.pid,
running: true,
mode: currentOptions.mode ?? "production",
};
};
const resolveOptions = (
options: PreviewControllerStartOptions
): PreviewServerOptions => {
const running = isRunning();
return {
host: options.host ?? (running ? currentOptions.host : defaults.host),
port: options.port ?? (running ? currentOptions.port : defaults.port),
cwd: options.cwd ?? (running ? currentCwd : defaults.cwd),
imageDomains:
options.imageDomains ??
(running ? currentOptions.imageDomains : defaults.imageDomains),
mode:
options.mode ??
(running ? currentOptions.mode : defaults.mode) ??
"production",
};
};
const canReuse = (options: PreviewControllerStartOptions = {}) => {
if (isRunning() === false) {
return false;
}
const nextOptions = resolveOptions(options);
return (
nextOptions.host === currentOptions.host &&
nextOptions.port === currentOptions.port &&
nextOptions.cwd === currentCwd &&
nextOptions.mode === (currentOptions.mode ?? "production") &&
arePreviewImageDomainsEqual(
nextOptions.imageDomains,
currentOptions.imageDomains
)
);
};
const stop = async () => {
if (server === undefined) {
return;
}
const process = server.process;
server = undefined;
removeTerminationHandlers();
if (
process.killed ||
process.exitCode !== null ||
process.signalCode !== null
) {
return;
}
const exited = new Promise<void>((resolve, reject) => {
process.once("error", reject);
process.once("exit", () => resolve());
});
if (stopPreviewProcess(process) === false) {
return;
}
await exited;
};
const start = async (
options: PreviewControllerStartOptions = {}
): Promise<PreviewControllerResult> => {
const running = isRunning();
const nextOptions = resolveOptions(options);
if (running) {
if (options.restart !== true) {
if (canReuse(options) === false) {
throw new Error(
`Preview server is already running at ${getPreviewUrl(currentOptions)}. Stop it before starting a different preview server.`
);
}
return getStatus();
}
await stop();
}
currentOptions = nextOptions;
currentCwd = nextOptions.cwd;
const cachedBuildKey =
options.buildCacheKey === undefined || currentCwd === undefined
? undefined
: await dependencies
.readFile(join(currentCwd, previewBuildCacheMarker), "utf8")
.catch(() => undefined);
if (
nextOptions.mode === "production" &&
(options.buildCacheKey === undefined ||
cachedBuildKey !== options.buildCacheKey)
) {
await runPreviewBuild(dependencies, currentCwd, [
"ignore",
"pipe",
"pipe",
]);
if (options.buildCacheKey !== undefined && currentCwd !== undefined) {
await dependencies.writeFile(
join(currentCwd, previewBuildCacheMarker),
options.buildCacheKey
);
}
}
serverOutput = "";
const startedServer = startPreviewServer(
{
...nextOptions,
detached: dependencies.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
},
dependencies
);
server = startedServer;
installTerminationHandlers();
startedServer.process.stdout?.on("data", appendServerOutput);
startedServer.process.stderr?.on("data", appendServerOutput);
startedServer.process.once("exit", () => {
if (server === startedServer) {
server = undefined;
removeTerminationHandlers();
}
});
return getStatus();
};
return {
canReuse,
start,
async startAndWait(
options: PreviewControllerStartOptions = {}
): Promise<PreviewControllerResult> {
const nextCwd = options.cwd ?? (isRunning() ? currentCwd : defaults.cwd);
const requiredProject = await getPreviewProjectIdentity(
nextCwd,
dependencies
);
const result = await start(options);
const url = result.url ?? getPreviewUrl(currentOptions);
const requiredAssetNames =
result.mode === "production"
? await getPreviewCssAssetNames(currentCwd, dependencies)
: [];
try {
await waitForPreviewReady(
url,
{ isRunning, requiredAssetNames, requiredProject },
dependencies
);
} catch (error) {
const output = serverOutput.trim();
if (output !== "" && error instanceof Error) {
throw new Error(
formatPreviewServerStartupError({
message: error.message,
output,
url,
})
);
}
throw error;
}
return result;
},
async stop(): Promise<PreviewControllerResult> {
await stop();
return getStatus();
},
status: getStatus,
resolveUrl(path = "/") {
return getPreviewUrl({ ...currentOptions, path });
},
};
};
@@ -0,0 +1,2 @@
export const previewBuildCacheMarker = ".webstudio-preview-build";
export const previewProcessOwnerFile = "preview-process.json";
@@ -1,389 +1,10 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test, vi } from "vitest";
import { createPreviewController } from "./controller";
import {
createPreviewController,
findAvailablePort,
getPreviewBuildArgs,
getNpmInvocation,
getPreviewStartArgs,
getPreviewUrl,
isPreviewPortAvailable,
materializePreviewAssets,
runPreviewBuild,
startPreviewServer,
waitForPreviewReady,
type PreviewServerDependencies,
} from "./preview-server";
test("allocates an available local preview port", async () => {
const port = await findAvailablePort();
expect(port).toBeGreaterThan(0);
expect(port).toBeLessThanOrEqual(65_535);
});
test("repeatedly reports an unoccupied explicit preview port as available", async () => {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("Expected the test server to have a TCP address.");
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error === undefined ? resolve() : reject(error)));
});
await expect(isPreviewPortAvailable("127.0.0.1", address.port)).resolves.toBe(
true
);
await expect(isPreviewPortAvailable("127.0.0.1", address.port)).resolves.toBe(
true
);
});
const createDependencies = (
overrides: Partial<PreviewServerDependencies> = {}
): PreviewServerDependencies => ({
spawn: vi.fn(),
fetch: vi.fn(async () => new Response("", { status: 200 })),
cp: vi.fn(async () => undefined),
mkdir: vi.fn(async () => undefined),
readdir: vi.fn(async () => []),
readFile: vi.fn(async () => "") as never,
writeFile: vi.fn(async () => undefined) as never,
sleep: vi.fn(async () => undefined),
parentProcess: {
pid: 456,
once: vi.fn(),
off: vi.fn(),
kill: vi.fn(() => true),
},
nodeExecPath: "/usr/bin/node",
npmExecPath: undefined,
processExecArgv: [],
supervisorPath: "/tmp/preview-process-supervisor.js",
platform: "linux",
...overrides,
});
const createPreviewProcess = (
overrides: Partial<ReturnType<typeof startPreviewServer>["process"]> = {}
) =>
({
pid: 123,
killed: false,
exitCode: null,
signalCode: null,
connected: true,
once: vi.fn(),
disconnect: vi.fn(),
kill: vi.fn(() => true),
...overrides,
}) as ReturnType<typeof startPreviewServer>["process"];
const resolveProcessExit = (
process: ReturnType<typeof startPreviewServer>["process"],
code: number | null = 0
) => {
vi.mocked(
process.once as (event: string, callback: unknown) => unknown
).mockImplementation((event, callback) => {
if (event === "exit" && typeof callback === "function") {
(
callback as (code: number | null, signal: NodeJS.Signals | null) => void
)(code, null);
}
return process;
});
};
test("builds preview urls", () => {
expect(getPreviewUrl({ host: "127.0.0.1", port: 5173, path: "/" })).toBe(
"http://127.0.0.1:5173/"
);
expect(
getPreviewUrl({ host: "127.0.0.1", port: 5173, path: "/pricing" })
).toBe("http://127.0.0.1:5173/pricing");
});
test("builds npm production preview args", () => {
expect(getPreviewBuildArgs()).toEqual(["run", "build"]);
expect(getPreviewStartArgs({ host: "127.0.0.1", port: 5173 })).toEqual([
"run",
"start",
]);
});
test("builds iterative preview args for an ordinary reload server", () => {
expect(
getPreviewStartArgs({
host: "127.0.0.1",
port: 5173,
mode: "iterative",
})
).toEqual([
"run",
"dev",
"--",
"--host",
"127.0.0.1",
"--port",
"5173",
"--strictPort",
]);
});
test("reuses the npm cli that launched webstudio for preview commands", () => {
expect(
getNpmInvocation(["run", "build"], {
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
npmExecPath:
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
platform: "win32",
})
).toEqual({
command: "C:\\Program Files\\nodejs\\node.exe",
args: [
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
"run",
"build",
],
});
});
test("uses npm-cli when webstudio was launched through npx on windows", () => {
expect(
getNpmInvocation(["run", "build"], {
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
npmExecPath:
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js",
platform: "win32",
})
).toEqual({
command: "C:\\Program Files\\nodejs\\node.exe",
args: [
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
"run",
"build",
],
});
});
test("uses npm-cli when windows npm launcher metadata is unavailable", () => {
expect(
getNpmInvocation(["run", "build"], {
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
npmExecPath: undefined,
platform: "win32",
})
).toEqual({
command: "C:\\Program Files\\nodejs\\node.exe",
args: [
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
"run",
"build",
],
});
});
test("runs generated project production build", async () => {
const process = createPreviewProcess();
const spawn = vi.fn(() => process);
resolveProcessExit(process);
await runPreviewBuild(
createDependencies({
spawn: spawn as never,
nodeExecPath: "/opt/webstudio-node/bin/node",
}),
"/tmp/preview"
);
expect(spawn).toHaveBeenCalledWith("npm", ["run", "build"], {
cwd: "/tmp/preview",
stdio: "inherit",
env: expect.objectContaining({
CI: "1",
NODE_ENV: "production",
PATH: expect.stringMatching(/^\/opt\/webstudio-node\/bin:/),
}),
});
});
test("materializes downloaded assets into the production client tree", async () => {
const directory = await mkdtemp(join(tmpdir(), "webstudio-preview-assets-"));
try {
await mkdir(join(directory, "public", "assets"), { recursive: true });
await writeFile(
join(directory, "public", "assets", "image.png"),
"downloaded"
);
await materializePreviewAssets(directory);
await expect(
readFile(
join(directory, "build", "client", "assets", "image.png"),
"utf8"
)
).resolves.toBe("downloaded");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("does not fail when the generated preview has no downloaded assets", async () => {
const directory = await mkdtemp(join(tmpdir(), "webstudio-preview-assets-"));
try {
await expect(materializePreviewAssets(directory)).resolves.toBeUndefined();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("runs generated project production build through windows npm-cli", async () => {
const process = createPreviewProcess();
const spawn = vi.fn(() => process);
resolveProcessExit(process);
await runPreviewBuild(
createDependencies({
spawn: spawn as never,
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
platform: "win32",
}),
"C:/project/.webstudio/preview"
);
expect(spawn).toHaveBeenCalledWith(
"C:\\Program Files\\nodejs\\node.exe",
[
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
"run",
"build",
],
{
cwd: "C:/project/.webstudio/preview",
stdio: "inherit",
env: expect.objectContaining({ NODE_ENV: "production" }),
}
);
});
test("starts generated project production server with inherited stdio", () => {
const process = {} as ReturnType<typeof startPreviewServer>["process"];
const spawn = vi.fn(() => process);
expect(
startPreviewServer(
{ host: "127.0.0.1", port: 5173, cwd: "/tmp/preview" },
createDependencies({ spawn: spawn as never })
)
).toEqual({
url: "http://127.0.0.1:5173/",
process,
});
expect(spawn).toHaveBeenCalledWith(
"/usr/bin/node",
[
"/tmp/preview-process-supervisor.js",
JSON.stringify({
command: "npm",
args: ["run", "start"],
cwd: "/tmp/preview",
ownerFile: "/tmp/preview-process.json",
}),
],
{
cwd: "/tmp/preview",
stdio: ["inherit", "inherit", "inherit", "ipc"],
env: expect.objectContaining({
HOST: "127.0.0.1",
PORT: "5173",
NODE_ENV: "production",
}),
}
);
});
test("passes explicit external image domains to the preview optimizer", () => {
const process = {} as ReturnType<typeof startPreviewServer>["process"];
const spawn = vi.fn(() => process);
startPreviewServer(
{
host: "127.0.0.1",
port: 5173,
cwd: "/tmp/preview",
imageDomains: ["storage.example.com", "images.example.org"],
},
createDependencies({ spawn: spawn as never })
);
expect(spawn).toHaveBeenCalledWith(
"/usr/bin/node",
["/tmp/preview-process-supervisor.js", expect.any(String)],
expect.objectContaining({
env: expect.objectContaining({
DOMAINS: "storage.example.com,images.example.org",
}),
})
);
});
test("starts generated project production server through windows npm-cli", () => {
const process = {} as ReturnType<typeof startPreviewServer>["process"];
const spawn = vi.fn(() => process);
expect(
startPreviewServer(
{
host: "127.0.0.1",
port: 5173,
cwd: "C:/project/.webstudio/preview",
},
createDependencies({
spawn: spawn as never,
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
platform: "win32",
})
)
).toEqual({
url: "http://127.0.0.1:5173/",
process,
});
expect(spawn).toHaveBeenCalledWith(
"C:\\Program Files\\nodejs\\node.exe",
[
"/tmp/preview-process-supervisor.js",
JSON.stringify({
command: "C:\\Program Files\\nodejs\\node.exe",
args: [
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
"run",
"start",
],
cwd: "C:/project/.webstudio/preview",
ownerFile: "C:/project/.webstudio/preview-process.json",
}),
],
{
cwd: "C:/project/.webstudio/preview",
stdio: ["inherit", "inherit", "inherit", "ipc"],
env: expect.objectContaining({
HOST: "127.0.0.1",
PORT: "5173",
NODE_ENV: "production",
}),
}
);
});
createDependencies,
createPreviewProcess,
resolveProcessExit,
} from "./test-utils";
test("preview controller builds once and reuses a running server", async () => {
const process = createPreviewProcess();
@@ -841,172 +462,6 @@ test("ignores a delayed exit from a previously owned preview server", async () =
});
});
test("waits for preview server readiness", async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error("not ready"))
.mockResolvedValueOnce(new Response("", { status: 200 }));
const sleep = vi.fn(async () => undefined);
await waitForPreviewReady(
"http://127.0.0.1:5173/",
{ timeoutMs: 1000, intervalMs: 5 },
createDependencies({ fetch, sleep })
);
expect(fetch).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledWith(5);
});
test("waits until the latest preview build asset is served", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
new Response('<link rel="stylesheet" href="/assets/index-old.css" />', {
status: 200,
})
)
.mockResolvedValueOnce(
new Response('<link rel="stylesheet" href="/assets/index-new.css" />', {
status: 200,
})
);
const sleep = vi.fn(async () => undefined);
await waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1000,
intervalMs: 5,
requiredAssetNames: ["index-new.css"],
},
createDependencies({ fetch, sleep })
);
expect(fetch).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledWith(5);
});
test("requires the exact generated project even when build assets match", async () => {
const fetch = vi.fn(
async () =>
new Response(
'<html data-ws-project="other-project"><link rel="stylesheet" href="/assets/index-new.css" /></html>',
{ status: 200 }
)
);
await expect(
waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1,
intervalMs: 5,
requiredAssetNames: ["index-new.css"],
requiredProject: { projectId: "expected-project" },
},
createDependencies({ fetch })
)
).rejects.toThrow(
"Preview server at http://127.0.0.1:5173/ did not serve the expected generated project."
);
});
test("accepts the generated preview with the expected project marker", async () => {
const fetch = vi.fn(
async () =>
new Response(
'<html data-ws-project="expected-project"><link rel="stylesheet" href="/assets/index-new.css" /></html>',
{ status: 200 }
)
);
await expect(
waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1000,
requiredAssetNames: ["index-new.css"],
requiredProject: { projectId: "expected-project" },
},
createDependencies({ fetch })
)
).resolves.toBeUndefined();
});
test("uses the static identity marker when page authentication blocks readiness", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(new Response("Unauthorized", { status: 401 }))
.mockResolvedValueOnce(Response.json({ projectId: "project", version: 5 }));
await expect(
waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1000,
requiredProject: { projectId: "project", version: 5 },
},
createDependencies({ fetch })
)
).resolves.toBeUndefined();
expect(fetch).toHaveBeenNthCalledWith(
2,
new URL("http://127.0.0.1:5173/__webstudio/preview.json"),
expect.objectContaining({ method: "GET" })
);
});
test("waits for the exact generated session version", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
new Response(
'<html data-ws-project="project" data-ws-version="4"></html>'
)
)
.mockResolvedValueOnce(
new Response(
'<html data-ws-project="project" data-ws-version="5"></html>'
)
);
await waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1000,
intervalMs: 5,
requiredProject: { projectId: "project", version: 5 },
},
createDependencies({ fetch })
);
expect(fetch).toHaveBeenCalledTimes(2);
});
test("rejects stale preview servers that serve a previous build", async () => {
const fetch = vi.fn(
async () =>
new Response('<link rel="stylesheet" href="/assets/index-old.css" />', {
status: 200,
})
);
await expect(
waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1,
intervalMs: 5,
requiredAssetNames: ["index-new.css"],
},
createDependencies({ fetch })
)
).rejects.toThrow(
"Preview server at http://127.0.0.1:5173/ did not serve the latest build assets."
);
});
test("preview controller waits when starting through startAndWait", async () => {
const process = createPreviewProcess();
const buildProcess = createPreviewProcess();
@@ -0,0 +1,293 @@
import { join } from "node:path";
import { previewBuildCacheMarker } from "./constants";
import { defaultPreviewServerDependencies } from "./dependencies";
import {
getPreviewCssAssetNames,
getPreviewProjectIdentity,
waitForPreviewReady,
} from "./readiness";
import {
getPreviewUrl,
runPreviewBuild,
startPreviewServer,
stopPreviewProcess,
} from "./server";
import type {
PreviewMode,
PreviewServerOptions,
PreviewServerResult,
} from "./types";
export type PreviewControllerResult = {
url?: string;
pid?: number;
running: boolean;
mode?: PreviewMode;
};
type PreviewControllerStartOptions = Partial<PreviewServerOptions> & {
restart?: boolean;
buildCacheKey?: string;
};
export const arePreviewImageDomainsEqual = (
left: string[] | undefined,
right: string[] | undefined
) =>
left === right ||
(left !== undefined &&
right !== undefined &&
left.length === right.length &&
left.every((value, index) => value === right[index]));
const formatPreviewServerStartupError = ({
message,
output,
url,
}: {
message: string;
output: string;
url: string;
}) => {
const portHint = output.includes("EADDRINUSE")
? `\n\nPort is already in use. Stop the existing preview server for ${url}, or start preview with a different port.`
: "";
return `${message}\n\nPreview server output:\n${output}${portHint}`;
};
export const createPreviewController = (
defaults: PreviewServerOptions,
dependencies = defaultPreviewServerDependencies,
{ manageProcessSignals = true }: { manageProcessSignals?: boolean } = {}
) => {
let server: PreviewServerResult | undefined;
let currentOptions = defaults;
let currentCwd = defaults.cwd;
let serverOutput = "";
const terminationSignals = ["SIGHUP", "SIGINT", "SIGTERM"] as const;
let isTerminating = false;
const terminationHandlers = new Map<NodeJS.Signals, () => void>();
const removeTerminationHandlers = () => {
for (const [signal, handler] of terminationHandlers) {
dependencies.parentProcess.off(signal, handler);
}
terminationHandlers.clear();
};
const installTerminationHandlers = () => {
if (manageProcessSignals === false || terminationHandlers.size > 0) {
return;
}
for (const signal of terminationSignals) {
const handler = () => {
if (isTerminating) {
return;
}
isTerminating = true;
const activeServer = server;
server = undefined;
removeTerminationHandlers();
if (activeServer === undefined) {
dependencies.parentProcess.kill(
dependencies.parentProcess.pid,
signal
);
return;
}
try {
stopPreviewProcess(activeServer.process);
} finally {
dependencies.parentProcess.kill(
dependencies.parentProcess.pid,
signal
);
}
};
terminationHandlers.set(signal, handler);
dependencies.parentProcess.once(signal, handler);
}
};
const appendServerOutput = (chunk: unknown) => {
serverOutput = `${serverOutput}${String(chunk)}`.slice(-4000);
};
const isRunning = () =>
server !== undefined &&
server.process.killed === false &&
server.process.exitCode === null &&
server.process.signalCode === null;
const getStatus = (): PreviewControllerResult => {
if (isRunning() === false) {
return { running: false };
}
return {
url: getPreviewUrl(currentOptions),
pid: server?.process.pid,
running: true,
mode: currentOptions.mode ?? "production",
};
};
const resolveOptions = (
options: PreviewControllerStartOptions
): PreviewServerOptions => {
const running = isRunning();
return {
host: options.host ?? (running ? currentOptions.host : defaults.host),
port: options.port ?? (running ? currentOptions.port : defaults.port),
cwd: options.cwd ?? (running ? currentCwd : defaults.cwd),
imageDomains:
options.imageDomains ??
(running ? currentOptions.imageDomains : defaults.imageDomains),
mode:
options.mode ??
(running ? currentOptions.mode : defaults.mode) ??
"production",
};
};
const canReuse = (options: PreviewControllerStartOptions = {}) => {
if (isRunning() === false) {
return false;
}
const nextOptions = resolveOptions(options);
return (
nextOptions.host === currentOptions.host &&
nextOptions.port === currentOptions.port &&
nextOptions.cwd === currentCwd &&
nextOptions.mode === (currentOptions.mode ?? "production") &&
arePreviewImageDomainsEqual(
nextOptions.imageDomains,
currentOptions.imageDomains
)
);
};
const stop = async () => {
if (server === undefined) {
return;
}
const process = server.process;
server = undefined;
removeTerminationHandlers();
if (
process.killed ||
process.exitCode !== null ||
process.signalCode !== null
) {
return;
}
const exited = new Promise<void>((resolve, reject) => {
process.once("error", reject);
process.once("exit", () => resolve());
});
if (stopPreviewProcess(process) === false) {
return;
}
await exited;
};
const start = async (
options: PreviewControllerStartOptions = {}
): Promise<PreviewControllerResult> => {
const running = isRunning();
const nextOptions = resolveOptions(options);
if (running) {
if (options.restart !== true) {
if (canReuse(options) === false) {
throw new Error(
`Preview server is already running at ${getPreviewUrl(currentOptions)}. Stop it before starting a different preview server.`
);
}
return getStatus();
}
await stop();
}
currentOptions = nextOptions;
currentCwd = nextOptions.cwd;
const cachedBuildKey =
options.buildCacheKey === undefined || currentCwd === undefined
? undefined
: await dependencies
.readFile(join(currentCwd, previewBuildCacheMarker), "utf8")
.catch(() => undefined);
if (
nextOptions.mode === "production" &&
(options.buildCacheKey === undefined ||
cachedBuildKey !== options.buildCacheKey)
) {
await runPreviewBuild(dependencies, currentCwd, [
"ignore",
"pipe",
"pipe",
]);
if (options.buildCacheKey !== undefined && currentCwd !== undefined) {
await dependencies.writeFile(
join(currentCwd, previewBuildCacheMarker),
options.buildCacheKey
);
}
}
serverOutput = "";
const startedServer = startPreviewServer(
{
...nextOptions,
detached: dependencies.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
},
dependencies
);
server = startedServer;
installTerminationHandlers();
startedServer.process.stdout?.on("data", appendServerOutput);
startedServer.process.stderr?.on("data", appendServerOutput);
startedServer.process.once("exit", () => {
if (server === startedServer) {
server = undefined;
removeTerminationHandlers();
}
});
return getStatus();
};
return {
canReuse,
start,
async startAndWait(
options: PreviewControllerStartOptions = {}
): Promise<PreviewControllerResult> {
const nextCwd = options.cwd ?? (isRunning() ? currentCwd : defaults.cwd);
const requiredProject = await getPreviewProjectIdentity(
nextCwd,
dependencies
);
const result = await start(options);
const url = result.url ?? getPreviewUrl(currentOptions);
const requiredAssetNames =
result.mode === "production"
? await getPreviewCssAssetNames(currentCwd, dependencies)
: [];
try {
await waitForPreviewReady(
url,
{ isRunning, requiredAssetNames, requiredProject },
dependencies
);
} catch (error) {
const output = serverOutput.trim();
if (output !== "" && error instanceof Error) {
throw new Error(
formatPreviewServerStartupError({
message: error.message,
output,
url,
})
);
}
throw error;
}
return result;
},
async stop(): Promise<PreviewControllerResult> {
await stop();
return getStatus();
},
status: getStatus,
resolveUrl(path = "/") {
return getPreviewUrl({ ...currentOptions, path });
},
};
};
@@ -0,0 +1,56 @@
import { spawn } from "node:child_process";
import { readFileSync, realpathSync } from "node:fs";
import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
export type PreviewServerDependencies = {
spawn: typeof spawn;
parentProcess: {
pid: number;
once: (signal: NodeJS.Signals, handler: () => void) => unknown;
off: (signal: NodeJS.Signals, handler: () => void) => unknown;
kill: (pid: number, signal: NodeJS.Signals) => boolean;
};
fetch: typeof fetch;
cp: typeof cp;
mkdir: typeof mkdir;
readdir: typeof readdir;
readFile: typeof readFile;
readPackageFile: (path: string) => string;
resolveLauncherPath: (path: string) => string;
writeFile: typeof writeFile;
sleep: (ms: number) => Promise<void>;
nodeExecPath: string;
npmExecPath?: string;
processExecArgv: string[];
supervisorPath: string;
platform: typeof process.platform;
};
const supervisorPath = fileURLToPath(
new URL(
import.meta.url.includes("/src/")
? "./preview-process-supervisor.ts"
: "./preview-process-supervisor.js",
import.meta.url
)
);
export const defaultPreviewServerDependencies: PreviewServerDependencies = {
spawn,
parentProcess: process,
fetch,
cp,
mkdir,
readdir,
readFile,
readPackageFile: (path) => readFileSync(path, "utf8"),
resolveLauncherPath: realpathSync,
writeFile,
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
nodeExecPath: process.execPath,
npmExecPath: process.env.npm_execpath,
processExecArgv: process.execArgv,
supervisorPath,
platform: process.platform,
};
@@ -0,0 +1,56 @@
import { delimiter, dirname, join, parse, win32 } from "node:path";
import pathKey from "path-key";
export const processEnv = () => process.env;
const getAncestorBinPaths = (directory: string) => {
const paths: string[] = [];
let currentDirectory = directory;
while (true) {
paths.push(join(currentDirectory, "node_modules", ".bin"));
paths.push(
join(currentDirectory, "node_modules", ".pnpm", "node_modules", ".bin")
);
const parentDirectory = dirname(currentDirectory);
if (
parentDirectory === currentDirectory ||
currentDirectory === parse(currentDirectory).root
) {
return paths;
}
currentDirectory = parentDirectory;
}
};
export const getPreviewEnv = ({
cwd,
extraEnv,
nodeExecPath,
platform,
}: {
cwd?: string;
extraEnv: NodeJS.ProcessEnv;
nodeExecPath: string;
platform: NodeJS.Platform;
}) => {
const key = pathKey({ env: extraEnv, platform });
const separator = platform === "win32" ? win32.delimiter : delimiter;
const nodeBinDirectory =
platform === "win32" ? win32.dirname(nodeExecPath) : dirname(nodeExecPath);
return {
...extraEnv,
[key]: [
nodeBinDirectory,
...(cwd === undefined ? [] : getAncestorBinPaths(cwd)),
extraEnv[key],
]
.filter(Boolean)
.join(separator),
};
};
export const getNodeRuntimeEnv = (
nodeExecPath: string,
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform
) => getPreviewEnv({ extraEnv: env, nodeExecPath, platform });
+32
View File
@@ -0,0 +1,32 @@
export {
defaultPreviewServerDependencies,
type PreviewServerDependencies,
} from "./dependencies";
export { getNodeRuntimeEnv } from "./environment";
export {
getPackageManagerInvocation,
resolvePreviewPackageManager,
} from "./package-manager";
export {
arePreviewImageDomainsEqual,
createPreviewController,
type PreviewControllerResult,
} from "./controller";
export { previewBuildCacheMarker, previewProcessOwnerFile } from "./constants";
export { waitForPreviewReady } from "./readiness";
export {
findAvailablePort,
getPreviewBuildArgs,
getPreviewStartArgs,
getPreviewUrl,
isPreviewPortAvailable,
materializePreviewAssets,
runPreviewBuild,
startPreviewServer,
waitForPreviewExit,
} from "./server";
export type {
PreviewMode,
PreviewServerOptions,
PreviewServerResult,
} from "./types";
@@ -0,0 +1,249 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { getPackageManagerInvocation } from "./package-manager";
import {
readWindowsPackageFile,
resolveWindowsLauncherPath,
} from "./test-utils";
test("reuses the npm cli that launched webstudio for preview commands", () => {
expect(
getPackageManagerInvocation(["run", "build"], {
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
npmExecPath:
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
platform: "win32",
readPackageFile: readWindowsPackageFile,
resolveLauncherPath: resolveWindowsLauncherPath,
})
).toEqual({
command: "C:\\Program Files\\nodejs\\node.exe",
args: [
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
"run",
"build",
],
});
});
test("uses npm-cli when webstudio was launched through npx on windows", () => {
expect(
getPackageManagerInvocation(["run", "build"], {
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
npmExecPath:
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js",
platform: "win32",
readPackageFile: readWindowsPackageFile,
resolveLauncherPath: resolveWindowsLauncherPath,
})
).toEqual({
command: "C:\\Program Files\\nodejs\\node.exe",
args: [
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
"run",
"build",
],
});
});
test("detects pnpm from the launcher's owning package metadata", () => {
expect(
getPackageManagerInvocation(["run", "build"], {
nodeExecPath: "C:\\Program Files\\Codex\\node.exe",
npmExecPath:
"C:\\Program Files\\Codex\\node_modules\\bundled-manager\\bin\\launcher.cjs",
platform: "win32",
readPackageFile: (path) => {
if (path.endsWith("node_modules\\bundled-manager\\package.json")) {
return JSON.stringify({
name: "pnpm",
bin: { pnpm: "bin/launcher.cjs" },
});
}
throw Object.assign(new Error("missing"), { code: "ENOENT" });
},
resolveLauncherPath: resolveWindowsLauncherPath,
})
).toEqual({
command: "C:\\Program Files\\Codex\\node.exe",
args: [
"C:\\Program Files\\Codex\\node_modules\\bundled-manager\\bin\\launcher.cjs",
"run",
"build",
],
});
});
test("detects a package manager declared with a string bin", () => {
expect(
getPackageManagerInvocation(["run", "build"], {
nodeExecPath: "/usr/local/bin/node",
npmExecPath: "/usr/local/lib/node_modules/pnpm/bin/pnpm.cjs",
platform: "linux",
readPackageFile: (path) => {
if (path === "/usr/local/lib/node_modules/pnpm/package.json") {
return JSON.stringify({ name: "pnpm", bin: "bin/pnpm.cjs" });
}
throw Object.assign(new Error("missing"), { code: "ENOENT" });
},
resolveLauncherPath: (path) => path,
})
).toEqual({
command: "/usr/local/bin/node",
args: ["/usr/local/lib/node_modules/pnpm/bin/pnpm.cjs", "run", "build"],
});
});
test("continues past a nested package that does not own the launcher", () => {
expect(
getPackageManagerInvocation(["run", "build"], {
nodeExecPath: "/usr/local/bin/node",
npmExecPath: "/opt/corepack/dist/pnpm.js",
platform: "linux",
readPackageFile: (path) => {
if (path === "/opt/corepack/dist/package.json") {
return JSON.stringify({ name: "internal", bin: "other.js" });
}
if (path === "/opt/corepack/package.json") {
return JSON.stringify({
name: "corepack",
bin: { pnpm: "dist/pnpm.js" },
});
}
throw Object.assign(new Error("missing"), { code: "ENOENT" });
},
resolveLauncherPath: (path) => path,
})
).toEqual({
command: "/usr/local/bin/node",
args: ["/opt/corepack/dist/pnpm.js", "run", "build"],
});
});
test("matches windows launcher paths without case sensitivity", () => {
expect(
getPackageManagerInvocation(["run", "build"], {
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
npmExecPath: "C:\\TOOLS\\PNPM\\BIN\\PNPM.CJS",
platform: "win32",
readPackageFile: (path) => {
if (path.toLowerCase() === "c:\\tools\\pnpm\\package.json") {
return JSON.stringify({ name: "pnpm", bin: "bin/pnpm.cjs" });
}
throw Object.assign(new Error("missing"), { code: "ENOENT" });
},
resolveLauncherPath: (path) => path,
})
).toEqual({
command: "C:\\Program Files\\nodejs\\node.exe",
args: ["C:\\TOOLS\\PNPM\\BIN\\PNPM.CJS", "run", "build"],
});
});
test.skipIf(process.platform === "win32")(
"detects pnpm through a package-owned launcher symlink",
async () => {
const directory = await mkdtemp(join(tmpdir(), "webstudio-pnpm-launcher-"));
const packageDirectory = join(directory, "node_modules", "corepack");
const launcher = join(packageDirectory, "dist", "pnpm.js");
const shim = join(directory, "bin", "pnpm");
try {
await mkdir(join(packageDirectory, "dist"), { recursive: true });
await mkdir(join(directory, "bin"));
await writeFile(
join(packageDirectory, "package.json"),
JSON.stringify({
name: "corepack",
bin: { pnpm: "dist/pnpm.js", yarn: "dist/yarn.js" },
})
);
await writeFile(launcher, "#!/usr/bin/env node\n");
await symlink(launcher, shim);
expect(
getPackageManagerInvocation(["run", "build"], {
nodeExecPath: process.execPath,
npmExecPath: shim,
platform: process.platform,
})
).toEqual({ command: shim, args: ["run", "build"] });
} finally {
await rm(directory, { recursive: true, force: true });
}
}
);
test("rejects a detected package manager without preview support", () => {
expect(() =>
getPackageManagerInvocation(["run", "build"], {
nodeExecPath: "C:\\Program Files\\Codex\\node.exe",
npmExecPath: "C:\\Program Files\\Codex\\node_modules\\yarn\\bin\\yarn.js",
platform: "win32",
readPackageFile: () =>
JSON.stringify({ name: "yarn", bin: { yarn: "bin/yarn.js" } }),
resolveLauncherPath: resolveWindowsLauncherPath,
})
).toThrow("Preview supports npm and pnpm");
});
test("reports invalid package-manager metadata", () => {
expect(() =>
getPackageManagerInvocation(["run", "build"], {
npmExecPath: "/opt/pnpm/bin/pnpm.cjs",
platform: "linux",
readPackageFile: () => "not json",
resolveLauncherPath: (path) => path,
})
).toThrow(
"Could not read package-manager metadata at /opt/pnpm/bin/package.json."
);
});
test("reports when no package owns the launcher", () => {
expect(() =>
getPackageManagerInvocation(["run", "build"], {
npmExecPath: "/opt/pnpm/bin/pnpm.cjs",
platform: "linux",
readPackageFile: () => {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
},
resolveLauncherPath: (path) => path,
})
).toThrow(
"Could not identify the package manager for launcher /opt/pnpm/bin/pnpm.cjs."
);
});
test("reports when the launcher path cannot be resolved", () => {
expect(() =>
getPackageManagerInvocation(["run", "build"], {
npmExecPath: "/missing/pnpm",
platform: "linux",
readPackageFile: () => {
throw new Error("should not read package metadata");
},
resolveLauncherPath: () => {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
},
})
).toThrow("Could not resolve package-manager launcher /missing/pnpm.");
});
test("uses npm-cli when windows npm launcher metadata is unavailable", () => {
expect(
getPackageManagerInvocation(["run", "build"], {
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
npmExecPath: undefined,
platform: "win32",
})
).toEqual({
command: "C:\\Program Files\\nodejs\\node.exe",
args: [
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
"run",
"build",
],
});
});
@@ -0,0 +1,204 @@
import { readFileSync, realpathSync } from "node:fs";
import { posix, win32 } from "node:path";
import { defaultPreviewServerDependencies } from "./dependencies";
type PreviewPackageManagerOptions = {
nodeExecPath?: string;
npmExecPath?: string;
platform?: typeof process.platform;
readPackageFile?: (path: string) => string;
resolveLauncherPath?: (path: string) => string;
};
type PreviewPackageManager = {
name: "npm" | "pnpm";
command: string;
argsPrefix: string[];
};
const findLauncherPackage = (
executablePath: string,
platform: typeof process.platform,
readPackageFile: (path: string) => string,
resolveLauncherPath: (path: string) => string
) => {
const pathApi = platform === "win32" ? win32 : posix;
let resolvedExecutablePath: string;
try {
resolvedExecutablePath = resolveLauncherPath(executablePath);
} catch (error) {
throw new Error(
`Could not resolve package-manager launcher ${executablePath}.`,
{ cause: error }
);
}
const normalizePath = (path: string) =>
platform === "win32" ? path.toLowerCase() : path;
let directory = pathApi.dirname(resolvedExecutablePath);
while (true) {
const packagePath = pathApi.join(directory, "package.json");
try {
const value: unknown = JSON.parse(readPackageFile(packagePath));
if (typeof value === "object" && value !== null) {
const packageValue = value as {
name?: unknown;
bin?: unknown;
};
const name =
typeof packageValue.name === "string" ? packageValue.name : undefined;
const binEntries =
typeof packageValue.bin === "string" && name !== undefined
? [[name.split("/").at(-1) ?? name, packageValue.bin] as const]
: typeof packageValue.bin === "object" && packageValue.bin !== null
? Object.entries(packageValue.bin).filter(
(entry): entry is [string, string] =>
typeof entry[1] === "string"
)
: [];
const matchedBin = binEntries.find(([, binPath]) => {
const resolvedBinPath = pathApi.resolve(directory, binPath);
return (
normalizePath(resolvedBinPath) ===
normalizePath(resolvedExecutablePath)
);
});
if (matchedBin !== undefined) {
return {
directory,
value: { name, bin: Object.fromEntries(binEntries) },
commandName: matchedBin[0],
};
}
}
} catch (error) {
if (
typeof error !== "object" ||
error === null ||
!("code" in error) ||
(error.code !== "ENOENT" && error.code !== "ENOTDIR")
) {
throw new Error(
`Could not read package-manager metadata at ${packagePath}.`,
{ cause: error }
);
}
}
const parentDirectory = pathApi.dirname(directory);
if (parentDirectory === directory) {
return;
}
directory = parentDirectory;
}
};
const resolveLauncherPackage = (
executablePath: string,
platform: typeof process.platform,
readPackageFile: (path: string) => string,
resolveLauncherPath: (path: string) => string
): {
directory: string;
value: { name?: string; bin: Record<string, string> };
commandName: string;
name: PreviewPackageManager["name"];
} => {
const packageInfo = findLauncherPackage(
executablePath,
platform,
readPackageFile,
resolveLauncherPath
);
const packageName = packageInfo?.value.name;
const commandName = packageInfo?.commandName;
const name =
commandName === "npm" || commandName === "npx"
? "npm"
: commandName === "pnpm" || commandName === "pnpx"
? "pnpm"
: packageName === "npm" || packageName === "pnpm"
? packageName
: undefined;
if (packageInfo !== undefined && name !== undefined) {
return {
directory: packageInfo.directory,
value: packageInfo.value,
commandName: packageInfo.commandName,
name,
};
}
if (packageInfo !== undefined) {
throw new Error(
`Preview supports npm and pnpm, but ${executablePath} is the ${commandName} command from ${packageName ?? "an unidentified package"}.`
);
}
throw new Error(
`Could not identify the package manager for launcher ${executablePath}.`
);
};
export const resolvePreviewPackageManager = (
options: PreviewPackageManagerOptions = defaultPreviewServerDependencies
): PreviewPackageManager => {
const nodeExecPath = options.nodeExecPath ?? process.execPath;
const npmExecPath = options.npmExecPath;
const platform = options.platform ?? process.platform;
if (npmExecPath !== undefined) {
const pathApi = platform === "win32" ? win32 : posix;
const packageInfo = resolveLauncherPackage(
npmExecPath,
platform,
options.readPackageFile ?? ((path) => readFileSync(path, "utf8")),
options.resolveLauncherPath ?? realpathSync
);
let launcherPath = npmExecPath;
const packageBin = packageInfo.value.bin;
if (
packageInfo.commandName !== packageInfo.name &&
packageBin[packageInfo.name] !== undefined
) {
launcherPath = pathApi.resolve(
packageInfo.directory,
packageBin[packageInfo.name]
);
}
if (
[".js", ".cjs", ".mjs"].includes(
pathApi.parse(launcherPath).ext.toLowerCase()
)
) {
return {
name: packageInfo.name,
command: nodeExecPath,
argsPrefix: [launcherPath],
};
}
return { name: packageInfo.name, command: launcherPath, argsPrefix: [] };
}
if (platform === "win32") {
return {
name: "npm",
command: nodeExecPath,
argsPrefix: [
win32.join(
win32.dirname(nodeExecPath),
"node_modules",
"npm",
"bin",
"npm-cli.js"
),
],
};
}
return { name: "npm", command: "npm", argsPrefix: [] };
};
export const getPackageManagerInvocation = (
args: string[],
options: PreviewPackageManagerOptions = defaultPreviewServerDependencies
) => {
const packageManager = resolvePreviewPackageManager(options);
return {
command: packageManager.command,
args: [...packageManager.argsPrefix, ...args],
};
};
@@ -0,0 +1,169 @@
import { expect, test, vi } from "vitest";
import { waitForPreviewReady } from "./readiness";
import { createDependencies } from "./test-utils";
test("waits for preview server readiness", async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error("not ready"))
.mockResolvedValueOnce(new Response("", { status: 200 }));
const sleep = vi.fn(async () => undefined);
await waitForPreviewReady(
"http://127.0.0.1:5173/",
{ timeoutMs: 1000, intervalMs: 5 },
createDependencies({ fetch, sleep })
);
expect(fetch).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledWith(5);
});
test("waits until the latest preview build asset is served", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
new Response('<link rel="stylesheet" href="/assets/index-old.css" />', {
status: 200,
})
)
.mockResolvedValueOnce(
new Response('<link rel="stylesheet" href="/assets/index-new.css" />', {
status: 200,
})
);
const sleep = vi.fn(async () => undefined);
await waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1000,
intervalMs: 5,
requiredAssetNames: ["index-new.css"],
},
createDependencies({ fetch, sleep })
);
expect(fetch).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledWith(5);
});
test("requires the exact generated project even when build assets match", async () => {
const fetch = vi.fn(
async () =>
new Response(
'<html data-ws-project="other-project"><link rel="stylesheet" href="/assets/index-new.css" /></html>',
{ status: 200 }
)
);
await expect(
waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1,
intervalMs: 5,
requiredAssetNames: ["index-new.css"],
requiredProject: { projectId: "expected-project" },
},
createDependencies({ fetch })
)
).rejects.toThrow(
"Preview server at http://127.0.0.1:5173/ did not serve the expected generated project."
);
});
test("accepts the generated preview with the expected project marker", async () => {
const fetch = vi.fn(
async () =>
new Response(
'<html data-ws-project="expected-project"><link rel="stylesheet" href="/assets/index-new.css" /></html>',
{ status: 200 }
)
);
await expect(
waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1000,
requiredAssetNames: ["index-new.css"],
requiredProject: { projectId: "expected-project" },
},
createDependencies({ fetch })
)
).resolves.toBeUndefined();
});
test("uses the static identity marker when page authentication blocks readiness", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(new Response("Unauthorized", { status: 401 }))
.mockResolvedValueOnce(Response.json({ projectId: "project", version: 5 }));
await expect(
waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1000,
requiredProject: { projectId: "project", version: 5 },
},
createDependencies({ fetch })
)
).resolves.toBeUndefined();
expect(fetch).toHaveBeenNthCalledWith(
2,
new URL("http://127.0.0.1:5173/__webstudio/preview.json"),
expect.objectContaining({ method: "GET" })
);
});
test("waits for the exact generated session version", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
new Response(
'<html data-ws-project="project" data-ws-version="4"></html>'
)
)
.mockResolvedValueOnce(
new Response(
'<html data-ws-project="project" data-ws-version="5"></html>'
)
);
await waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1000,
intervalMs: 5,
requiredProject: { projectId: "project", version: 5 },
},
createDependencies({ fetch })
);
expect(fetch).toHaveBeenCalledTimes(2);
});
test("rejects stale preview servers that serve a previous build", async () => {
const fetch = vi.fn(
async () =>
new Response('<link rel="stylesheet" href="/assets/index-old.css" />', {
status: 200,
})
);
await expect(
waitForPreviewReady(
"http://127.0.0.1:5173/",
{
timeoutMs: 1,
intervalMs: 5,
requiredAssetNames: ["index-new.css"],
},
createDependencies({ fetch })
)
).rejects.toThrow(
"Preview server at http://127.0.0.1:5173/ did not serve the latest build assets."
);
});
@@ -0,0 +1,174 @@
import { join } from "node:path";
import { parse as parseHtml, type DefaultTreeAdapterMap } from "parse5";
import { defaultPreviewServerDependencies } from "./dependencies";
export const getPreviewProjectIdentity = async (
cwd: string | undefined,
dependencies = defaultPreviewServerDependencies
) => {
if (cwd === undefined) {
return undefined;
}
try {
const data = JSON.parse(
await dependencies.readFile(join(cwd, ".webstudio", "data.json"), "utf8")
) as { build?: { projectId?: unknown; version?: unknown } };
if (typeof data.build?.projectId !== "string") {
throw new Error("projectId is missing");
}
return {
projectId: data.build.projectId,
...(typeof data.build.version === "number"
? { version: data.build.version }
: {}),
};
} catch (error) {
throw new Error(
`Could not identify the generated preview project in ${join(cwd, ".webstudio", "data.json")}.`,
{ cause: error }
);
}
};
const getGeneratedSiteIdentity = (html: string) => {
const document = parseHtml(html);
const visit = (
node: DefaultTreeAdapterMap["node"]
): { projectId: string; version?: number } | undefined => {
if ("attrs" in node && Array.isArray(node.attrs)) {
const attributes = new Map(
node.attrs.map(({ name, value }) => [name, value])
);
const projectId = attributes.get("data-ws-project");
const versionValue = attributes.get("data-ws-version");
if (projectId !== undefined) {
const version = Number(versionValue);
return {
projectId,
...(Number.isFinite(version) ? { version } : {}),
};
}
}
if ("childNodes" in node) {
for (const child of node.childNodes) {
const identity = visit(child);
if (identity !== undefined) {
return identity;
}
}
}
};
return visit(document);
};
export const waitForPreviewReady = async (
url: string,
{
timeoutMs = 30_000,
intervalMs = 250,
isRunning,
requiredAssetNames = [],
requiredProject,
}: {
timeoutMs?: number;
intervalMs?: number;
isRunning?: () => boolean;
requiredAssetNames?: string[];
requiredProject?: { projectId: string; version?: number };
} = {},
dependencies = defaultPreviewServerDependencies
) => {
const deadline = Date.now() + timeoutMs;
let sawStaleServer = false;
let sawUnexpectedProject = false;
while (Date.now() <= deadline) {
if (isRunning?.() === false) {
throw new Error(
`Preview server exited before it became ready at ${url}.`
);
}
try {
const attemptTimeoutMs = Math.max(
1,
Math.min(5000, deadline - Date.now())
);
const response = await dependencies.fetch(url, {
method: "GET",
signal: AbortSignal.timeout(attemptTimeoutMs),
});
if (response.status === 401 && requiredProject !== undefined) {
const identityResponse = await dependencies.fetch(
new URL("/__webstudio/preview.json", url),
{
method: "GET",
signal: AbortSignal.timeout(attemptTimeoutMs),
}
);
if (identityResponse.ok) {
const identity = (await identityResponse.json()) as {
projectId?: unknown;
version?: unknown;
};
if (
identity.projectId === requiredProject.projectId &&
(requiredProject.version === undefined ||
identity.version === requiredProject.version)
) {
return;
}
sawUnexpectedProject = true;
}
}
if (response.status < 500) {
if (requiredAssetNames.length === 0 && requiredProject === undefined) {
return;
}
const html = await response.text();
const identity = getGeneratedSiteIdentity(html);
const servesExpectedProject =
requiredProject === undefined ||
(identity?.projectId === requiredProject.projectId &&
(requiredProject.version === undefined ||
identity.version === requiredProject.version));
const servesLatestAssets =
requiredAssetNames.length === 0 ||
requiredAssetNames.some((name) => html.includes(name));
if (servesExpectedProject && servesLatestAssets) {
return;
}
sawUnexpectedProject ||= servesExpectedProject === false;
sawStaleServer ||= servesLatestAssets === false;
}
} catch {
// Server is still starting.
}
await dependencies.sleep(intervalMs);
}
if (sawUnexpectedProject) {
throw new Error(
`Preview server at ${url} did not serve the expected generated project. Stop the existing preview server on this port, then retry.`
);
}
if (sawStaleServer) {
throw new Error(
`Preview server at ${url} did not serve the latest build assets. Stop the existing preview server on this port, then retry.`
);
}
throw new Error(`Preview server did not become ready at ${url}.`);
};
export const getPreviewCssAssetNames = async (
cwd: string | undefined,
dependencies = defaultPreviewServerDependencies
) => {
if (cwd === undefined) {
return [];
}
try {
return (await dependencies.readdir(join(cwd, "build", "client", "assets")))
.filter((name) => name.endsWith(".css"))
.sort();
} catch {
return [];
}
};
@@ -0,0 +1,343 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test, vi } from "vitest";
import {
findAvailablePort,
getPreviewBuildArgs,
getPreviewStartArgs,
getPreviewUrl,
isPreviewPortAvailable,
materializePreviewAssets,
runPreviewBuild,
startPreviewServer,
} from "./server";
import {
createDependencies,
createPreviewProcess,
resolveProcessExit,
} from "./test-utils";
test("allocates an available local preview port", async () => {
const port = await findAvailablePort();
expect(port).toBeGreaterThan(0);
expect(port).toBeLessThanOrEqual(65_535);
});
test("repeatedly reports an unoccupied explicit preview port as available", async () => {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (address === null || typeof address === "string") {
throw new Error("Expected the test server to have a TCP address.");
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error === undefined ? resolve() : reject(error)));
});
await expect(isPreviewPortAvailable("127.0.0.1", address.port)).resolves.toBe(
true
);
await expect(isPreviewPortAvailable("127.0.0.1", address.port)).resolves.toBe(
true
);
});
test("builds preview urls", () => {
expect(getPreviewUrl({ host: "127.0.0.1", port: 5173, path: "/" })).toBe(
"http://127.0.0.1:5173/"
);
expect(
getPreviewUrl({ host: "127.0.0.1", port: 5173, path: "/pricing" })
).toBe("http://127.0.0.1:5173/pricing");
});
test("builds npm production preview args", () => {
expect(getPreviewBuildArgs()).toEqual(["run", "build"]);
expect(getPreviewStartArgs({ host: "127.0.0.1", port: 5173 })).toEqual([
"run",
"start",
]);
});
test("builds iterative preview args for an ordinary reload server", () => {
expect(
getPreviewStartArgs({
host: "127.0.0.1",
port: 5173,
mode: "iterative",
})
).toEqual([
"run",
"dev",
"--",
"--host",
"127.0.0.1",
"--port",
"5173",
"--strictPort",
]);
});
test("runs generated project production build", async () => {
const process = createPreviewProcess();
const spawn = vi.fn(() => process);
resolveProcessExit(process);
await runPreviewBuild(
createDependencies({
spawn: spawn as never,
nodeExecPath: "/opt/webstudio-node/bin/node",
}),
"/tmp/preview"
);
expect(spawn).toHaveBeenCalledWith("npm", ["run", "build"], {
cwd: "/tmp/preview",
stdio: "inherit",
env: expect.objectContaining({
CI: "1",
NODE_ENV: "production",
PATH: expect.stringMatching(/^\/opt\/webstudio-node\/bin:/),
}),
});
});
test("runs generated project production build through windows pnpm", async () => {
const process = createPreviewProcess();
const spawn = vi.fn(() => process);
resolveProcessExit(process);
await runPreviewBuild(
createDependencies({
spawn: spawn as never,
nodeExecPath: "C:\\Program Files\\Codex\\node.exe",
npmExecPath:
"C:\\Program Files\\Codex\\node_modules\\pnpm\\bin\\pnpm.cjs",
platform: "win32",
}),
"C:/project/.webstudio/preview"
);
expect(spawn).toHaveBeenCalledWith(
"C:\\Program Files\\Codex\\node.exe",
[
"C:\\Program Files\\Codex\\node_modules\\pnpm\\bin\\pnpm.cjs",
"run",
"build",
],
expect.objectContaining({ cwd: "C:/project/.webstudio/preview" })
);
});
test("materializes downloaded assets into the production client tree", async () => {
const directory = await mkdtemp(join(tmpdir(), "webstudio-preview-assets-"));
try {
await mkdir(join(directory, "public", "assets"), { recursive: true });
await writeFile(
join(directory, "public", "assets", "image.png"),
"downloaded"
);
await materializePreviewAssets(directory);
await expect(
readFile(
join(directory, "build", "client", "assets", "image.png"),
"utf8"
)
).resolves.toBe("downloaded");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("does not fail when the generated preview has no downloaded assets", async () => {
const directory = await mkdtemp(join(tmpdir(), "webstudio-preview-assets-"));
try {
await expect(materializePreviewAssets(directory)).resolves.toBeUndefined();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("runs generated project production build through windows npm-cli", async () => {
const process = createPreviewProcess();
const spawn = vi.fn(() => process);
resolveProcessExit(process);
await runPreviewBuild(
createDependencies({
spawn: spawn as never,
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
platform: "win32",
}),
"C:/project/.webstudio/preview"
);
expect(spawn).toHaveBeenCalledWith(
"C:\\Program Files\\nodejs\\node.exe",
[
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
"run",
"build",
],
{
cwd: "C:/project/.webstudio/preview",
stdio: "inherit",
env: expect.objectContaining({ NODE_ENV: "production" }),
}
);
});
test("starts generated project production server with inherited stdio", () => {
const process = {} as ReturnType<typeof startPreviewServer>["process"];
const spawn = vi.fn(() => process);
expect(
startPreviewServer(
{ host: "127.0.0.1", port: 5173, cwd: "/tmp/preview" },
createDependencies({ spawn: spawn as never })
)
).toEqual({
url: "http://127.0.0.1:5173/",
process,
});
expect(spawn).toHaveBeenCalledWith(
"/usr/bin/node",
[
"/tmp/preview-process-supervisor.js",
JSON.stringify({
command: "npm",
args: ["run", "start"],
cwd: "/tmp/preview",
ownerFile: "/tmp/preview-process.json",
}),
],
{
cwd: "/tmp/preview",
stdio: ["inherit", "inherit", "inherit", "ipc"],
env: expect.objectContaining({
HOST: "127.0.0.1",
PORT: "5173",
NODE_ENV: "production",
}),
}
);
});
test("passes explicit external image domains to the preview optimizer", () => {
const process = {} as ReturnType<typeof startPreviewServer>["process"];
const spawn = vi.fn(() => process);
startPreviewServer(
{
host: "127.0.0.1",
port: 5173,
cwd: "/tmp/preview",
imageDomains: ["storage.example.com", "images.example.org"],
},
createDependencies({ spawn: spawn as never })
);
expect(spawn).toHaveBeenCalledWith(
"/usr/bin/node",
["/tmp/preview-process-supervisor.js", expect.any(String)],
expect.objectContaining({
env: expect.objectContaining({
DOMAINS: "storage.example.com,images.example.org",
}),
})
);
});
test("starts generated project production server through windows npm-cli", () => {
const process = {} as ReturnType<typeof startPreviewServer>["process"];
const spawn = vi.fn(() => process);
expect(
startPreviewServer(
{
host: "127.0.0.1",
port: 5173,
cwd: "C:/project/.webstudio/preview",
},
createDependencies({
spawn: spawn as never,
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
platform: "win32",
})
)
).toEqual({
url: "http://127.0.0.1:5173/",
process,
});
expect(spawn).toHaveBeenCalledWith(
"C:\\Program Files\\nodejs\\node.exe",
[
"/tmp/preview-process-supervisor.js",
JSON.stringify({
command: "C:\\Program Files\\nodejs\\node.exe",
args: [
"C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
"run",
"start",
],
cwd: "C:/project/.webstudio/preview",
ownerFile: "C:/project/.webstudio/preview-process.json",
}),
],
{
cwd: "C:/project/.webstudio/preview",
stdio: ["inherit", "inherit", "inherit", "ipc"],
env: expect.objectContaining({
HOST: "127.0.0.1",
PORT: "5173",
NODE_ENV: "production",
}),
}
);
});
test("starts generated project production server through windows pnpm", () => {
const process = {} as ReturnType<typeof startPreviewServer>["process"];
const spawn = vi.fn(() => process);
startPreviewServer(
{
host: "127.0.0.1",
port: 5173,
cwd: "C:/project/.webstudio/preview",
},
createDependencies({
spawn: spawn as never,
nodeExecPath: "C:\\Program Files\\Codex\\node.exe",
npmExecPath:
"C:\\Program Files\\Codex\\node_modules\\pnpm\\bin\\pnpm.cjs",
platform: "win32",
})
);
expect(spawn).toHaveBeenCalledWith(
"C:\\Program Files\\Codex\\node.exe",
[
"/tmp/preview-process-supervisor.js",
JSON.stringify({
command: "C:\\Program Files\\Codex\\node.exe",
args: [
"C:\\Program Files\\Codex\\node_modules\\pnpm\\bin\\pnpm.cjs",
"run",
"start",
],
cwd: "C:/project/.webstudio/preview",
ownerFile: "C:/project/.webstudio/preview-process.json",
}),
],
expect.objectContaining({ cwd: "C:/project/.webstudio/preview" })
);
});
+190
View File
@@ -0,0 +1,190 @@
import type { ChildProcess, StdioOptions } from "node:child_process";
import { dirname, join } from "node:path";
import detectPort from "detect-port";
import getPort from "get-port";
import { previewProcessOwnerFile } from "./constants";
import { defaultPreviewServerDependencies } from "./dependencies";
import { getPreviewEnv, processEnv } from "./environment";
import { getPackageManagerInvocation } from "./package-manager";
import type { PreviewServerOptions, PreviewServerResult } from "./types";
export const findAvailablePort = (host = "127.0.0.1") => getPort({ host });
export const isPreviewPortAvailable = async (host: string, port: number) => {
const availablePort = await detectPort({ hostname: host, port });
return availablePort === port;
};
export const getPreviewUrl = ({
host,
port,
path = "/",
}: PreviewServerOptions & { path?: string }) =>
new URL(path, `http://${host}:${port}`).toString();
export const getPreviewBuildArgs = () => ["run", "build"];
export const getPreviewStartArgs = (options: PreviewServerOptions) =>
options.mode === "iterative"
? [
"run",
"dev",
"--",
"--host",
options.host,
"--port",
String(options.port),
"--strictPort",
]
: ["run", "start"];
export const runPreviewBuild = async (
dependencies = defaultPreviewServerDependencies,
cwd?: string,
stdio: StdioOptions = "inherit"
) => {
const invocation = getPackageManagerInvocation(
getPreviewBuildArgs(),
dependencies
);
const buildProcess = dependencies.spawn(invocation.command, invocation.args, {
cwd,
stdio,
env: getPreviewEnv({
cwd,
extraEnv: {
...processEnv(),
CI: "1",
NODE_ENV: "production",
},
nodeExecPath: dependencies.nodeExecPath,
platform: dependencies.platform,
}),
});
let output = "";
const appendOutput = (chunk: unknown) => {
output = `${output}${String(chunk)}`.slice(-4000);
};
buildProcess.stdout?.on("data", appendOutput);
buildProcess.stderr?.on("data", appendOutput);
try {
await waitForPreviewExit(buildProcess);
await materializePreviewAssets(cwd, dependencies);
} catch (error) {
if (output.length === 0) {
throw error;
}
throw new Error(
`${error instanceof Error ? error.message : String(error)}\n\nPreview build output:\n${output}`,
{ cause: error }
);
}
};
export const materializePreviewAssets = async (
cwd: string | undefined,
dependencies = defaultPreviewServerDependencies
) => {
if (cwd === undefined) {
return;
}
const source = join(cwd, "public", "assets");
const destination = join(cwd, "build", "client", "assets");
try {
await dependencies.mkdir(destination, { recursive: true });
await dependencies.cp(source, destination, {
recursive: true,
force: true,
});
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return;
}
throw error;
}
};
export const startPreviewServer = (
options: PreviewServerOptions & {
stdio?: StdioOptions;
detached?: boolean;
},
dependencies = defaultPreviewServerDependencies
): PreviewServerResult => {
const invocation = getPackageManagerInvocation(
getPreviewStartArgs(options),
dependencies
);
const stdio = options.stdio ?? "inherit";
const supervisorStdio: StdioOptions = Array.isArray(stdio)
? [...stdio.slice(0, 3), "ipc"]
: [stdio, stdio, stdio, "ipc"];
const supervisorExecArgv = dependencies.supervisorPath.endsWith(".ts")
? dependencies.processExecArgv.filter(
(argument) =>
argument.startsWith("--conditions=") ||
argument.startsWith("--import=")
)
: [];
const previewProcess = dependencies.spawn(
dependencies.nodeExecPath,
[
...supervisorExecArgv,
dependencies.supervisorPath,
JSON.stringify({
command: invocation.command,
args: invocation.args,
cwd: options.cwd,
...(options.cwd === undefined
? {}
: {
ownerFile: join(dirname(options.cwd), previewProcessOwnerFile),
}),
}),
],
{
cwd: options.cwd,
...(options.detached === undefined ? {} : { detached: options.detached }),
stdio: supervisorStdio,
env: getPreviewEnv({
cwd: options.cwd,
extraEnv: {
...processEnv(),
HOST: options.host,
PORT: String(options.port),
...(options.imageDomains === undefined
? {}
: { DOMAINS: options.imageDomains.join(",") }),
NODE_ENV: options.mode === "iterative" ? "development" : "production",
...(options.mode === "iterative"
? { WEBSTUDIO_PREVIEW_HMR: "disabled" }
: {}),
},
nodeExecPath: dependencies.nodeExecPath,
platform: dependencies.platform,
}),
}
);
return {
url: getPreviewUrl(options),
process: previewProcess,
};
};
export const stopPreviewProcess = (previewProcess: ChildProcess) => {
if (previewProcess.connected) {
previewProcess.disconnect();
return true;
}
return previewProcess.kill("SIGTERM");
};
export const waitForPreviewExit = async (process: ChildProcess) => {
const code = await new Promise<number | null>((resolve, reject) => {
process.once("error", reject);
process.once("exit", resolve);
});
if (code !== 0 && code !== null) {
throw new Error(`Preview server exited with code ${code}.`);
}
};
@@ -0,0 +1,78 @@
import { vi } from "vitest";
import { startPreviewServer } from "./server";
import type { PreviewServerDependencies } from "./dependencies";
export const readWindowsPackageFile = (path: string) => {
if (path.endsWith("node_modules\\pnpm\\package.json")) {
return JSON.stringify({
name: "pnpm",
bin: { pnpm: "bin/pnpm.cjs" },
});
}
if (path.endsWith("node_modules\\npm\\package.json")) {
return JSON.stringify({
name: "npm",
bin: { npm: "bin/npm-cli.js", npx: "bin/npx-cli.js" },
});
}
throw Object.assign(new Error("missing"), { code: "ENOENT" });
};
export const resolveWindowsLauncherPath = (path: string) => path;
export const createDependencies = (
overrides: Partial<PreviewServerDependencies> = {}
): PreviewServerDependencies => ({
spawn: vi.fn(),
fetch: vi.fn(async () => new Response("", { status: 200 })),
cp: vi.fn(async () => undefined),
mkdir: vi.fn(async () => undefined),
readdir: vi.fn(async () => []),
readFile: vi.fn(async () => "") as never,
readPackageFile: readWindowsPackageFile,
resolveLauncherPath: resolveWindowsLauncherPath,
writeFile: vi.fn(async () => undefined) as never,
sleep: vi.fn(async () => undefined),
parentProcess: {
pid: 456,
once: vi.fn(),
off: vi.fn(),
kill: vi.fn(() => true),
},
nodeExecPath: "/usr/bin/node",
npmExecPath: undefined,
processExecArgv: [],
supervisorPath: "/tmp/preview-process-supervisor.js",
platform: "linux",
...overrides,
});
export const createPreviewProcess = (
overrides: Partial<ReturnType<typeof startPreviewServer>["process"]> = {}
) =>
({
pid: 123,
killed: false,
exitCode: null,
signalCode: null,
connected: true,
once: vi.fn(),
disconnect: vi.fn(),
kill: vi.fn(() => true),
...overrides,
}) as ReturnType<typeof startPreviewServer>["process"];
export const resolveProcessExit = (
process: ReturnType<typeof startPreviewServer>["process"],
code: number | null = 0
) => {
vi.mocked(
process.once as (event: string, callback: unknown) => unknown
).mockImplementation((event, callback) => {
if (event === "exit" && typeof callback === "function") {
(
callback as (code: number | null, signal: NodeJS.Signals | null) => void
)(code, null);
}
return process;
});
};
+17
View File
@@ -0,0 +1,17 @@
import type { ChildProcess } from "node:child_process";
import type { ProjectPreviewMode } from "@webstudio-is/project-build/preview";
export type PreviewMode = ProjectPreviewMode;
export type PreviewServerOptions = {
host: string;
port: number;
mode?: PreviewMode;
cwd?: string;
imageDomains?: string[];
};
export type PreviewServerResult = {
url: string;
process: ChildProcess;
};
+1 -1
View File
@@ -53,7 +53,7 @@ export default defineConfig({
esmExternals: true,
},
lib: {
entry: ["src/cli.ts", "src/preview-process-supervisor.ts"],
entry: ["src/cli.ts", "src/preview-server/preview-process-supervisor.ts"],
formats: ["es"],
},
rollupOptions: {