@zzclub/pipeline 0.13.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zzp": "./src/cli.ts",
@@ -70,7 +70,7 @@ Options:
70
70
  --title Page title for the render shell (optional)
71
71
  --preview-shell-out Path to exact final-HTML preview (optional)
72
72
  --custom-css CSS override path; replaces account customCss (optional)
73
- --timeout-ms Chrome virtual-time-budget in ms (default: 15000)
73
+ --timeout-ms Real browser wait limit in ms (default: 15000)
74
74
  --debug-dir Write intermediate artifacts for debugging (optional)
75
75
  --preview Register result with local preview server (default: true)
76
76
  --no-preview Do not register with preview server
@@ -1,9 +1,9 @@
1
1
  import { expect, test } from "bun:test";
2
- import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { PNG } from "pngjs";
6
- import { screenshotReadyHtml } from "./chrome-render";
6
+ import { dumpReadyHtml, screenshotReadyHtml } from "./chrome-render";
7
7
  import { findChrome } from "./runtime";
8
8
 
9
9
  test("capture waits for image and layout readiness instead of a fixed delay", async () => {
@@ -36,3 +36,18 @@ test("capture waits for image and layout readiness instead of a fixed delay", as
36
36
  await rm(directory, { recursive: true, force: true });
37
37
  }
38
38
  }, 30_000);
39
+
40
+ test("DOM export uses a real deadline and cleans up Chrome after timeout or startup failure", async () => {
41
+ const directories = async () => (await readdir(tmpdir())).filter(name => name.startsWith("zzhub-chrome-")).sort();
42
+ const before = await directories();
43
+ const started = Date.now();
44
+ await expect(dumpReadyHtml({
45
+ chromePath: findChrome()!, timeoutMs: 2000,
46
+ html: '<html><body><script>window.__zzhubRenderReady = new Promise(() => {});</script></body></html>',
47
+ })).rejects.toMatchObject({ kind: "timeout" });
48
+ expect(Date.now() - started).toBeGreaterThanOrEqual(1900);
49
+ expect(Date.now() - started).toBeLessThan(8000);
50
+ await expect(dumpReadyHtml({ chromePath: "/missing/chrome", html: "", timeoutMs: 2000 }))
51
+ .rejects.toMatchObject({ kind: "chrome_failed" });
52
+ expect(await directories()).toEqual(before);
53
+ }, 15_000);
@@ -4,39 +4,54 @@ import { tmpdir } from "node:os";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { injectStaticRenderStyle } from "./runtime";
6
6
 
7
- /** 等待真实资源和排版 Promise,就绪后在同一浏览器页面立即截图。 */
8
- export async function screenshotReadyHtml(options: {
9
- chromePath: string; html: string; outPath: string; width: number; height: number;
10
- cover?: boolean;
11
- }): Promise<void> {
7
+ /** 浏览器启动失败与真实超时使用不同分类。 */
8
+ export class ChromeRenderError extends Error {
9
+ constructor(message: string, readonly kind: "timeout" | "chrome_failed", readonly stderr: string) {
10
+ super(message);
11
+ this.name = "ChromeRenderError";
12
+ }
13
+ }
14
+
15
+ type ChromePageSend = (method: string, params?: Record<string, unknown>) => Promise<any>;
16
+
17
+ /** 截图和 HTML 导出共享浏览器生命周期,任务结束后清理全部资源。 */
18
+ async function withChromePage<T>(options: {
19
+ chromePath: string; html: string; timeoutMs: number;
20
+ width?: number; height?: number;
21
+ }, action: (send: ChromePageSend) => Promise<T>): Promise<T> {
12
22
  const directory = await mkdtemp(join(tmpdir(), "zzhub-chrome-"));
13
23
  const htmlPath = join(directory, "render.html");
14
- await writeFile(htmlPath, injectStaticRenderStyle(options.html));
15
- const chrome = Bun.spawn([
16
- options.chromePath, "--headless", "--disable-gpu", "--no-sandbox", "--no-first-run",
17
- "--no-default-browser-check", "--disable-background-networking", "--allow-file-access-from-files",
18
- "--remote-debugging-port=0", `--user-data-dir=${join(directory, "profile")}`, "about:blank",
19
- ], { stdout: "ignore", stderr: "pipe" });
24
+ let chrome: Bun.Subprocess<"ignore", "ignore", "pipe"> | undefined;
20
25
  let socket: WebSocket | undefined;
21
26
  let nextId = 0;
22
27
  let timedOut = false;
28
+ let stderr = "";
29
+ let terminalError: Error | undefined;
23
30
  const pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>();
24
31
  let onLoad: (() => void) | undefined;
25
32
  let rejectLoad: ((error: Error) => void) | undefined;
33
+ let rejectOpen: ((error: Error) => void) | undefined;
26
34
  function fail(error: Error) {
35
+ terminalError ??= error;
27
36
  for (const operation of pending.values()) operation.reject(error);
28
37
  pending.clear();
29
38
  rejectLoad?.(error);
39
+ rejectOpen?.(error);
30
40
  }
31
41
  // 仅作为卡死保护,成功路径不会等满这个上限。
32
42
  const deadline = setTimeout(() => {
33
43
  timedOut = true;
34
- fail(new Error("图片渲染超时:字体、图片或排版未就绪"));
44
+ fail(new ChromeRenderError(`Chrome 渲染超时(${options.timeoutMs}ms):资源或排版未就绪`, "timeout", stderr));
35
45
  socket?.close();
36
- if (chrome.exitCode === null) chrome.kill("SIGKILL");
37
- }, 30_000);
46
+ if (chrome?.exitCode === null) chrome.kill("SIGKILL");
47
+ }, options.timeoutMs);
38
48
  try {
39
- let stderr = "";
49
+ await writeFile(htmlPath, injectStaticRenderStyle(options.html));
50
+ chrome = Bun.spawn([
51
+ options.chromePath, "--headless", "--disable-gpu", "--no-sandbox", "--no-first-run",
52
+ "--no-default-browser-check", "--disable-background-networking", "--allow-file-access-from-files",
53
+ "--remote-debugging-port=0", `--user-data-dir=${join(directory, "profile")}`, "about:blank",
54
+ ], { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
40
55
  let endpoint = "";
41
56
  const reader = chrome.stderr.getReader();
42
57
  const decoder = new TextDecoder();
@@ -51,13 +66,14 @@ export async function screenshotReadyHtml(options: {
51
66
  void drain.catch(() => {});
52
67
  socket = new WebSocket(endpoint);
53
68
  await new Promise<void>((resolve, reject) => {
69
+ rejectOpen = reject;
54
70
  socket!.addEventListener("open", () => resolve(), { once: true });
55
71
  socket!.addEventListener("error", () => reject(new Error("无法连接 Chrome 渲染会话")), { once: true });
56
72
  socket!.addEventListener("close", () => reject(new Error("Chrome 渲染会话已关闭")), { once: true });
57
73
  });
58
74
  socket.addEventListener("message", event => {
59
75
  const message = JSON.parse(String(event.data));
60
- if (message.method === "Page.loadEventFired") onLoad?.();
76
+ if (message.method === "Page.domContentEventFired") onLoad?.();
61
77
  const operation = pending.get(message.id);
62
78
  if (!operation) return;
63
79
  pending.delete(message.id);
@@ -66,6 +82,7 @@ export async function screenshotReadyHtml(options: {
66
82
  });
67
83
  socket.addEventListener("close", () => fail(new Error("Chrome 渲染会话意外关闭")));
68
84
  function send(method: string, params: Record<string, unknown> = {}, sessionId?: string): Promise<any> {
85
+ if (terminalError) return Promise.reject(terminalError);
69
86
  const id = ++nextId;
70
87
  return new Promise((resolve, reject) => {
71
88
  pending.set(id, { resolve, reject });
@@ -75,7 +92,9 @@ export async function screenshotReadyHtml(options: {
75
92
  const { targetId } = await send("Target.createTarget", { url: "about:blank" });
76
93
  const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
77
94
  await send("Page.enable", {}, sessionId);
78
- await send("Emulation.setDeviceMetricsOverride", { width: options.width, height: options.height, deviceScaleFactor: 1, mobile: false }, sessionId);
95
+ if (options.width !== undefined && options.height !== undefined) {
96
+ await send("Emulation.setDeviceMetricsOverride", { width: options.width, height: options.height, deviceScaleFactor: 1, mobile: false }, sessionId);
97
+ }
79
98
  const loaded = new Promise<void>((resolve, reject) => { onLoad = resolve; rejectLoad = reject; });
80
99
  await Promise.all([
81
100
  send("Page.navigate", { url: pathToFileURL(htmlPath).href }, sessionId).then(navigation => {
@@ -83,6 +102,47 @@ export async function screenshotReadyHtml(options: {
83
102
  }),
84
103
  loaded,
85
104
  ]);
105
+ return await action((method, params) => send(method, params, sessionId));
106
+ } catch (error) {
107
+ throw new ChromeRenderError(
108
+ error instanceof Error ? error.message : String(error),
109
+ timedOut ? "timeout" : "chrome_failed",
110
+ stderr,
111
+ );
112
+ } finally {
113
+ clearTimeout(deadline);
114
+ socket?.close();
115
+ if (chrome?.exitCode === null) chrome.kill("SIGKILL");
116
+ await chrome?.exited;
117
+ await rm(directory, { recursive: true, force: true });
118
+ }
119
+ }
120
+
121
+ /** 等待页面明确结束渲染,再提取包含成功或失败结果的 HTML。 */
122
+ export async function dumpReadyHtml(options: {
123
+ chromePath: string; html: string; timeoutMs: number;
124
+ }): Promise<string> {
125
+ return withChromePage(options, async (send) => {
126
+ const result = await send("Runtime.evaluate", {
127
+ expression: `(async () => {
128
+ if (!window.__zzhubRenderReady) throw new Error('Missing render completion promise');
129
+ await window.__zzhubRenderReady;
130
+ return document.documentElement.outerHTML;
131
+ })()`,
132
+ awaitPromise: true, returnByValue: true,
133
+ });
134
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text);
135
+ if (typeof result.result?.value !== "string") throw new Error("Chrome 未返回渲染结果");
136
+ return result.result.value;
137
+ });
138
+ }
139
+
140
+ /** 等待真实资源和排版 Promise,就绪后在同一浏览器页面立即截图。 */
141
+ export async function screenshotReadyHtml(options: {
142
+ chromePath: string; html: string; outPath: string; width: number; height: number;
143
+ cover?: boolean;
144
+ }): Promise<void> {
145
+ await withChromePage({ ...options, timeoutMs: 30_000 }, async (send) => {
86
146
  const ready = await send("Runtime.evaluate", {
87
147
  expression: `(async () => {
88
148
  await window.__zzhubRenderReady;
@@ -95,16 +155,10 @@ export async function screenshotReadyHtml(options: {
95
155
  return true;
96
156
  })()`,
97
157
  awaitPromise: true, returnByValue: true,
98
- }, sessionId);
158
+ });
99
159
  if (ready.exceptionDetails) throw new Error(ready.exceptionDetails.exception?.description ?? ready.exceptionDetails.text);
100
- const screenshot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }, sessionId);
160
+ const screenshot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
101
161
  await mkdir(dirname(options.outPath), { recursive: true });
102
162
  await writeFile(options.outPath, Buffer.from(screenshot.data, "base64"));
103
- } finally {
104
- clearTimeout(deadline);
105
- socket?.close();
106
- if (chrome.exitCode === null) chrome.kill("SIGKILL");
107
- await chrome.exited;
108
- await rm(directory, { recursive: true, force: true });
109
- }
163
+ });
110
164
  }
@@ -214,67 +214,6 @@ export function screenshotHtml(options: {
214
214
  rmSync(tempShotDir, { recursive: true, force: true });
215
215
  }
216
216
 
217
- export class ChromeDumpError extends Error {
218
- readonly stderr: string;
219
- readonly tempHtmlPath?: string;
220
- readonly exitCode: number | null;
221
-
222
- constructor(message: string, options: {
223
- stderr: string;
224
- tempHtmlPath?: string;
225
- exitCode: number | null;
226
- }) {
227
- super(message);
228
- this.name = "ChromeDumpError";
229
- this.stderr = options.stderr;
230
- this.tempHtmlPath = options.tempHtmlPath;
231
- this.exitCode = options.exitCode;
232
- }
233
- }
234
-
235
- export function dumpHtmlDom(options: {
236
- chromePath: string;
237
- html: string;
238
- virtualTimeBudgetMs?: number;
239
- /** When true, leave the temp shell HTML on disk if Chrome fails (caller must clean up). */
240
- keepTempOnError?: boolean;
241
- }): string {
242
- const tempHtml = writeTempHtml(injectStaticRenderStyle(options.html));
243
- const fileUrl = pathToFileURL(tempHtml).href;
244
- const chromeArgs = [
245
- options.chromePath,
246
- "--headless",
247
- "--disable-gpu",
248
- "--no-sandbox",
249
- "--allow-file-access-from-files",
250
- "--disable-web-security=false",
251
- ];
252
- if ((options.virtualTimeBudgetMs ?? 0) > 0) {
253
- chromeArgs.push(`--virtual-time-budget=${options.virtualTimeBudgetMs}`);
254
- }
255
- chromeArgs.push("--dump-dom", fileUrl);
256
-
257
- const result = Bun.spawnSync({
258
- cmd: chromeArgs,
259
- stdout: "pipe",
260
- stderr: "pipe",
261
- });
262
- if (result.exitCode !== 0) {
263
- const stderr = new TextDecoder().decode(result.stderr);
264
- if (!options.keepTempOnError) {
265
- cleanupTempFile(tempHtml);
266
- }
267
- throw new ChromeDumpError(`Chrome dump-dom failed (exit ${result.exitCode}):\n${stderr}`, {
268
- stderr,
269
- tempHtmlPath: options.keepTempOnError ? tempHtml : undefined,
270
- exitCode: result.exitCode,
271
- });
272
- }
273
- const dom = new TextDecoder().decode(result.stdout);
274
- cleanupTempFile(tempHtml);
275
- return dom;
276
- }
277
-
278
217
  export function cropTop(options: {
279
218
  inputPath: string;
280
219
  outPath: string;