mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
fix: pre-landing review fixes for the Aside-first branch
Review army + adversarial passes (Claude and Codex) on the merged branch:
setup
- _prune_stale_generated scans the host dirs too (the generator already
removed the render before setup ran, so the host branch was dead), skips
symlinks in the render tree (rm -rf on a slash-terminated link empties its
target), removes a host symlink only when it resolves into gstack, cleans a
bannered real dir through _cleanup_weak_dir, recognizes frontmatter-renamed
skills, and logs through log. The always-run codex render passes every host
dir that may link to it.
- NEEDS_BUILD checks all three binaries (with $_EXE) and lib/ sources; the
browser hint and the bootstrap summary honor GSTACK_SKIP_ASIDE, treat a
requested skip as a request, and derive one skill list.
lib/aside-render.ts + bin/gstack-render.ts
- The loopback server carries a per-render secret path, checks containment on
the real path (symlink escapes are 403), and rejects malformed encoding.
- Inline eval results are one base64 line, so page text cannot forge
ASIDE_DIR= or the sentinel; the last ASIDE_DIR wins.
- runProc escalates SIGTERM to SIGKILL, bounds every wait, and clears every
timer (an uncleared one kept gstack-render alive after printing OK).
- renderTmpDir refuses a shared /tmp name owned by someone else; the work dir
and server are created inside try; goto's budget follows the render budget.
- probeAside classifies a present-but-failing CLI as ASIDE_NOT_RUNNING like
the skills' bash probe; render() retries on gstack's own browser when Aside
could not start or its private CDP bridge is gone (never on a page error
or a timeout of a running script); the CLI reports the engine that actually
rendered, exits 0 on --help, rejects non-numeric flags, documents
--wait-timeout, fences EVAL/PAGE_ERRORS as untrusted content, and names the
daemon's cookie-import JS lock remedy.
- The browse path passes --scale only when asked (a scale change rebuilds
the daemon context) and restores the viewport after a sized screenshot.
resolvers / templates
- The bash probe honors GSTACK_SKIP_ASIDE and has a perl deadline on stock
macOS; .local is no longer LOCAL (mDNS); same-origin filters compare parsed
origins; link status is HEAD-checked only on LOCAL targets; every
aside exec goes through the receipted _aside_exec prelude
({{ASIDE_EXEC_PRELUDE}}), including nine template blocks that called it
bare; the design sketch and diagram staging use private directories.
- The generator prunes only bannered renders and never a host whose
generation failed.
Docs, stale comments and dead code cleaned; goldens re-rendered; tests
updated and added for every behavior above.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ea61bd65be
commit
444f8feff8
+133
-38
@@ -45,9 +45,24 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
export const RENDER_SENTINEL = 'GSTACK_RENDER_OK';
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
/** Slack over the script budget so the `aside repl` process can wind down before we kill it. */
|
||||
const ASIDE_PROCESS_SLACK_MS = 10_000;
|
||||
/** Default budget for a waitFor selector/expression, on either engine. */
|
||||
const DEFAULT_WAIT_MS = 30_000;
|
||||
/** Default cap (chars) on an inline eval result. */
|
||||
const DEFAULT_MAX_INLINE = 20_000;
|
||||
/** Screenshot height when only a width is given (4:3). */
|
||||
const DEFAULT_ASPECT = 0.75;
|
||||
/** Widths at or below this emulate a mobile device. */
|
||||
const MOBILE_MAX_WIDTH = 1024;
|
||||
/** Device scale for sized screenshots on the Aside path (the daemon keeps its own scale: a change there rebuilds its context). */
|
||||
const DEFAULT_DEVICE_SCALE = 2;
|
||||
/** The page-number footer shared by make-pdf, gstack-render and the browse `pdf` command. */
|
||||
export const PAGE_NUMBER_FOOTER = '<div style="font-size:9pt; font-family:Helvetica,Arial,sans-serif; color:#666; width:100%; text-align:center;"><span class="pageNumber"></span> of <span class="totalPages"></span></div>';
|
||||
|
||||
// ─── Availability ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,9 +76,14 @@ export function probeAside(timeoutMs = 30_000): AsideProbe {
|
||||
return { ok: false, reason: 'NEEDS_ASIDE', detail: 'GSTACK_SKIP_ASIDE=1 — Aside skipped by request' };
|
||||
}
|
||||
const which = spawnSync('aside', ['--version'], { encoding: 'utf8', timeout: 10_000 });
|
||||
if (which.error || which.status !== 0) {
|
||||
if (which.error) {
|
||||
return { ok: false, reason: 'NEEDS_ASIDE', detail: 'the `aside` CLI is not on PATH — install the Aside browser (macOS 15+) from aside.com' };
|
||||
}
|
||||
if (which.status !== 0) {
|
||||
// Present but not answering: the same class the skills' bash probe reports
|
||||
// (open or repair the app), never "install it".
|
||||
return { ok: false, reason: 'ASIDE_NOT_RUNNING', detail: `\`aside --version\` exited ${which.status}: ${(which.stderr || which.stdout || '').trim().slice(0, 300) || 'no output'}` };
|
||||
}
|
||||
const probe = spawnSync('aside', ['repl', 'console.log("ASIDE_READY " + pwd)'], { encoding: 'utf8', timeout: timeoutMs });
|
||||
const out = `${probe.stdout ?? ''}${probe.stderr ?? ''}`;
|
||||
if (!/^ASIDE_READY /m.test(out)) {
|
||||
@@ -180,13 +200,13 @@ export function buildRenderScript(url: string, spec: RenderSpec): string {
|
||||
// bundle (lib/diagram-render) never satisfies the interactive heuristic and
|
||||
// times out at 30s, while `load` fires in ~0.5s. Readiness is then explicit
|
||||
// via waitFor (selector attached / expression truthy).
|
||||
L.push(`await pg.goto(${JSON.stringify(url)}, { waitUntil: "load", timeout: ${spec.waitFor?.timeoutMs ?? 90_000} });`);
|
||||
L.push(`await pg.goto(${JSON.stringify(url)}, { waitUntil: "load", timeout: ${Math.min(90_000, spec.timeoutMs ?? DEFAULT_TIMEOUT_MS)} });`);
|
||||
const wait = spec.waitFor;
|
||||
if (wait?.selector) {
|
||||
L.push(`await pg.waitForSelector(${JSON.stringify(wait.selector)}, { state: "attached", timeout: ${wait.timeoutMs ?? 30_000} });`);
|
||||
L.push(`await pg.waitForSelector(${JSON.stringify(wait.selector)}, { state: "attached", timeout: ${wait.timeoutMs ?? DEFAULT_WAIT_MS} });`);
|
||||
}
|
||||
if (wait?.expression) {
|
||||
L.push(`{ const deadline = Date.now() + ${wait.timeoutMs ?? 30_000}; let ok = false; while (Date.now() < deadline) { try { ok = !!(await pg.evaluate((src) => (0, eval)(src), ${JSON.stringify(wait.expression)})); } catch (e) {} if (ok) break; await sleep(150); } if (!ok) throw new Error("waitFor expression never became truthy: " + ${JSON.stringify(wait.expression)}); }`);
|
||||
L.push(`{ const deadline = Date.now() + ${wait.timeoutMs ?? DEFAULT_WAIT_MS}; let ok = false; while (Date.now() < deadline) { try { ok = !!(await pg.evaluate((src) => (0, eval)(src), ${JSON.stringify(wait.expression)})); } catch (e) {} if (ok) break; await sleep(150); } if (!ok) throw new Error("waitFor expression never became truthy: " + ${JSON.stringify(wait.expression)}); }`);
|
||||
}
|
||||
spec.steps.forEach((step, i) => {
|
||||
if (step.kind === 'pdf') {
|
||||
@@ -205,7 +225,7 @@ export function buildRenderScript(url: string, spec: RenderSpec): string {
|
||||
if (step.type) shot.type = step.type;
|
||||
if (step.quality !== undefined) shot.quality = step.quality;
|
||||
if (step.width) {
|
||||
L.push(`await pg._sendToTarget("Emulation.setDeviceMetricsOverride", ${JSON.stringify({ width: step.width, height: step.height ?? Math.round(step.width * 0.75), deviceScaleFactor: step.deviceScaleFactor ?? 2, mobile: step.mobile ?? step.width < 1024 })}); await sleep(250);`);
|
||||
L.push(`await pg._sendToTarget("Emulation.setDeviceMetricsOverride", ${JSON.stringify({ width: step.width, height: step.height ?? Math.round(step.width * DEFAULT_ASPECT), deviceScaleFactor: step.deviceScaleFactor ?? DEFAULT_DEVICE_SCALE, mobile: step.mobile ?? step.width <= MOBILE_MAX_WIDTH })}); await sleep(250);`);
|
||||
}
|
||||
if (step.selector) {
|
||||
const sel: Record<string, unknown> = { path: name };
|
||||
@@ -226,8 +246,10 @@ export function buildRenderScript(url: string, spec: RenderSpec): string {
|
||||
L.push(` else await fs.writeFile(path.join(pwd, name), JSON.stringify(v));`);
|
||||
L.push(` console.log("STEP_OK ${i}"); }`);
|
||||
} else {
|
||||
const max = step.maxInline ?? 20_000;
|
||||
L.push(` const s = typeof v === "string" ? v : JSON.stringify(v); console.log("EVAL_START ${i}"); console.log(String(s ?? "").slice(0, ${max})); console.log("EVAL_END ${i}"); console.log("STEP_OK ${i}"); }`);
|
||||
const max = step.maxInline ?? DEFAULT_MAX_INLINE;
|
||||
// One base64 line: the value is page-controlled text, and a newline in it
|
||||
// must never be able to forge ASIDE_DIR= or the sentinel below.
|
||||
L.push(` const s = typeof v === "string" ? v : JSON.stringify(v); console.log("EVAL ${i} " + Buffer.from(String(s ?? "").slice(0, ${max}), "utf8").toString("base64")); console.log("STEP_OK ${i}"); }`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -240,20 +262,35 @@ export function buildRenderScript(url: string, spec: RenderSpec): string {
|
||||
|
||||
// ─── Loopback server ─────────────────────────────────────────────────────────
|
||||
|
||||
function serveDir(root: string): { url: string; stop: () => void } {
|
||||
/**
|
||||
* Serve `root` on 127.0.0.1 for one render. The URL carries a per-render secret
|
||||
* as its first path segment: a local process that does not know it gets 404 for
|
||||
* everything, so the render window exposes nothing to neighbours on the box.
|
||||
* Containment is checked on the REAL path (symlinks are followed only when they
|
||||
* stay inside the root), and directories are never listed.
|
||||
*/
|
||||
export function serveDir(root: string, nonce: string = randomBytes(16).toString('hex')): { url: string; stop: () => void } {
|
||||
const realRoot = fs.realpathSync(root);
|
||||
const prefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
|
||||
const inside = (p: string) => p === realRoot || p.startsWith(prefix);
|
||||
const server = Bun.serve({
|
||||
hostname: '127.0.0.1',
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const pathname = decodeURIComponent(new URL(req.url).pathname);
|
||||
let pathname: string;
|
||||
try { pathname = decodeURIComponent(new URL(req.url).pathname); } catch { return new Response('bad request', { status: 400 }); }
|
||||
if (!pathname.startsWith(`/${nonce}/`)) return new Response('not found', { status: 404 });
|
||||
pathname = pathname.slice(nonce.length + 1);
|
||||
const target = path.resolve(realRoot, '.' + pathname);
|
||||
if (!target.startsWith(realRoot + path.sep) && target !== realRoot) return new Response('forbidden', { status: 403 });
|
||||
if (!fs.existsSync(target) || fs.statSync(target).isDirectory()) return new Response('not found', { status: 404 });
|
||||
return new Response(Bun.file(target));
|
||||
if (!inside(target)) return new Response('forbidden', { status: 403 });
|
||||
let real: string;
|
||||
try { real = fs.realpathSync(target); } catch { return new Response('not found', { status: 404 }); }
|
||||
if (!inside(real)) return new Response('forbidden', { status: 403 });
|
||||
if (fs.statSync(real).isDirectory()) return new Response('not found', { status: 404 });
|
||||
return new Response(Bun.file(real));
|
||||
},
|
||||
});
|
||||
return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true) };
|
||||
return { url: `http://127.0.0.1:${server.port}/${nonce}`, stop: () => server.stop(true) };
|
||||
}
|
||||
|
||||
// ─── Async spawn (keeps the loopback server's event loop free) ────────────────
|
||||
@@ -266,10 +303,20 @@ async function runProc(cmd: string, args: string[], timeoutMs: number): Promise<
|
||||
return { code: null, stdout: '', stderr: '', error: (e as Error).message };
|
||||
}
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, timeoutMs);
|
||||
const [stdout, stderr] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
||||
const code = await child.exited;
|
||||
clearTimeout(timer);
|
||||
// Every timer is tracked and cleared on exit: a dangling one keeps the event
|
||||
// loop alive and a CLI with no explicit process.exit (gstack-render) would sit
|
||||
// for up to timeoutMs after printing its result.
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const after = (ms: number, fn: () => void) => { timers.push(setTimeout(fn, ms)); };
|
||||
after(timeoutMs, () => { timedOut = true; try { child.kill(); } catch {} });
|
||||
// A child that ignores SIGTERM (a CLI blocked on its app) gets SIGKILL; a
|
||||
// grandchild holding the pipes open must not hang the render either.
|
||||
after(timeoutMs + 5_000, () => { try { child.kill('SIGKILL'); } catch {} });
|
||||
const read = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
||||
const giveUp = new Promise<[string, string]>((resolve) => after(timeoutMs + 10_000, () => resolve(['', ''])));
|
||||
const [stdout, stderr] = await Promise.race([read, giveUp]);
|
||||
const code = await Promise.race([child.exited, new Promise<null>((resolve) => after(5_000, () => resolve(null)))]);
|
||||
for (const t of timers) clearTimeout(t);
|
||||
return { code, stdout, stderr, error: timedOut ? `timed out after ${timeoutMs}ms` : undefined };
|
||||
}
|
||||
|
||||
@@ -292,17 +339,20 @@ async function asideRender(spec: RenderSpec): Promise<RenderResult> {
|
||||
const script = buildRenderScript(url, spec);
|
||||
// Async spawn: a synchronous wait would block this event loop, and the
|
||||
// loopback server above runs on it — Page.navigate would then time out.
|
||||
const proc = await runProc('aside', ['repl', script], spec.timeoutMs ?? DEFAULT_TIMEOUT_MS + 10_000);
|
||||
const proc = await runProc('aside', ['repl', script], (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS) + ASIDE_PROCESS_SLACK_MS);
|
||||
const stdout = `${proc.stdout}${proc.stderr}`.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
const evals: Record<number, string> = {};
|
||||
for (const m of stdout.matchAll(/^EVAL_START (\d+)\n([\s\S]*?)\nEVAL_END \1$/gm)) evals[Number(m[1])] = m[2];
|
||||
for (const m of stdout.matchAll(/^EVAL (\d+) ([A-Za-z0-9+/=]*)$/gm)) evals[Number(m[1])] = Buffer.from(m[2], 'base64').toString('utf8');
|
||||
|
||||
if (proc.error) return { ok: false, outputs: [], evals, stdout, error: `aside repl did not run: ${proc.error}` };
|
||||
if (!stdout.split('\n').some((l) => l.trim() === RENDER_SENTINEL)) {
|
||||
const errLine = stdout.split('\n').find((l) => /^(\[error|Error:|\w*Error:)/.test(l.trim())) ?? stdout.trim().split('\n').slice(-3).join(' | ');
|
||||
return { ok: false, outputs: [], evals, stdout, error: `render script did not finish: ${errLine || 'no output'}` };
|
||||
return { ok: false, outputs: [], evals, stdout, error: `render script did not finish: ${errLine || 'no output'} (GSTACK_SKIP_ASIDE=1 forces gstack's own browser)` };
|
||||
}
|
||||
const dir = stdout.match(/^ASIDE_DIR=(.+)$/m)?.[1]?.trim();
|
||||
// Control lines are ours alone (eval output is one base64 token, PAGE_ERRORS
|
||||
// is one JSON line); still take the LAST ASIDE_DIR so nothing earlier wins.
|
||||
const dirs = [...stdout.matchAll(/^ASIDE_DIR=(.+)$/gm)];
|
||||
const dir = dirs.length ? dirs[dirs.length - 1][1].trim() : undefined;
|
||||
if (!dir) return { ok: false, outputs: [], evals, stdout, error: 'render script printed no ASIDE_DIR' };
|
||||
|
||||
const outputs: string[] = [];
|
||||
@@ -323,8 +373,16 @@ async function asideRender(spec: RenderSpec): Promise<RenderResult> {
|
||||
/** Where callers may stage HTML so the loopback server can reach it. */
|
||||
export function renderTmpDir(): string {
|
||||
const dir = path.join(os.tmpdir(), 'gstack-render');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
||||
// Ours: a real directory we own. Anything else at the shared name (another
|
||||
// user's directory, a planted symlink) is never staged into — fall back to a
|
||||
// private mkdtemp so a neighbour on the box cannot swap files under a render.
|
||||
const ours = (): boolean => {
|
||||
try { const st = fs.lstatSync(dir); return st.isDirectory() && !st.isSymbolicLink() && (uid === undefined || st.uid === uid); } catch { return false; }
|
||||
};
|
||||
if (ours()) return dir;
|
||||
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch { /* exists or unwritable — decided below */ }
|
||||
return ours() ? dir : fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-render-'));
|
||||
}
|
||||
|
||||
// ─── Render: browse (gstack's own headless browser, the fallback) ────────────
|
||||
@@ -336,7 +394,7 @@ const BROWSE_ROOTS = [
|
||||
path.join(os.homedir(), '.claude/skills/gstack'),
|
||||
];
|
||||
/** The daemon only reads/writes under its safe dirs; /tmp is always one of them. */
|
||||
const BROWSE_TMP = process.platform === 'win32' ? os.tmpdir() : '/tmp';
|
||||
export const SAFE_TMP_DIR = process.platform === 'win32' ? os.tmpdir() : '/tmp';
|
||||
|
||||
/** A regular, executable file — probing .exe/.cmd/.bat on Windows, where X_OK degrades to an existence check. */
|
||||
function executable(p: string): string | null {
|
||||
@@ -431,7 +489,13 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
|
||||
const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, deadline - Date.now())));
|
||||
log.push(`$ browse ${args.join(' ').slice(0, 300)}\n${r.stdout}${r.stderr}`.trim());
|
||||
if (r.error || r.code !== 0) {
|
||||
throw new Error(`browse ${args[0]} failed: ${(r.stderr || r.stdout || r.error || '').trim().split('\n')[0]}`);
|
||||
const first = (r.stderr || r.stdout || r.error || '').trim().split('\n')[0];
|
||||
if (/JS execution blocked/.test(`${r.stderr}${r.stdout}`)) {
|
||||
// After `$B cookie-import` the daemon refuses page JS on every other
|
||||
// origin, 127.0.0.1 included; a local-HTML render cannot proceed in it.
|
||||
throw new Error(`browse ${args[0]} refused: the daemon has imported cookies and blocks page JS on other origins (127.0.0.1 included) — restart it ($B stop) before rendering local HTML, or open Aside`);
|
||||
}
|
||||
throw new Error(`browse ${args[0]} failed: ${first}`);
|
||||
}
|
||||
return r.stdout;
|
||||
};
|
||||
@@ -442,10 +506,12 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
|
||||
outputs.push(out);
|
||||
};
|
||||
|
||||
const work = fs.mkdtempSync(path.join(BROWSE_TMP, 'gstack-render-browse-'));
|
||||
const srv = serveDir(root);
|
||||
let work: string | undefined;
|
||||
let srv: { url: string; stop: () => void } | undefined;
|
||||
let tab: number | undefined;
|
||||
try {
|
||||
work = fs.mkdtempSync(path.join(SAFE_TMP_DIR, 'gstack-render-browse-'));
|
||||
srv = serveDir(root);
|
||||
const opened = (await run(['newtab', '--json'])).match(/\{[^\n]*"tabId"[^\n]*\}/)?.[0];
|
||||
tab = opened ? JSON.parse(opened).tabId : undefined;
|
||||
if (typeof tab !== 'number') throw new Error('browse newtab --json returned no tabId');
|
||||
@@ -469,10 +535,12 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
|
||||
// must still run; a waitFor or eval step that is genuinely blocked fails
|
||||
// below with the daemon's own message.
|
||||
const bestEffortJs = async (expr: string, what: string) => { try { return await js(expr); } catch (e) { log.push(`${what} unavailable: ${(e as Error).message}`); return null; } };
|
||||
// Known divergence from the Aside path: the daemon exposes no
|
||||
// pre-navigation hook, so errors logged during load are not captured here.
|
||||
await bestEffortJs(HOOK, 'console hook');
|
||||
const wait = spec.waitFor;
|
||||
if (wait?.selector) await until(`document.querySelector(${JSON.stringify(wait.selector)})`, `waitFor selector never attached: ${wait.selector}`, wait.timeoutMs ?? 30_000);
|
||||
if (wait?.expression) await until(wait.expression, `waitFor expression never became truthy: ${wait.expression}`, wait.timeoutMs ?? 30_000);
|
||||
if (wait?.selector) await until(`document.querySelector(${JSON.stringify(wait.selector)})`, `waitFor selector never attached: ${wait.selector}`, wait.timeoutMs ?? DEFAULT_WAIT_MS);
|
||||
if (wait?.expression) await until(wait.expression, `waitFor expression never became truthy: ${wait.expression}`, wait.timeoutMs ?? DEFAULT_WAIT_MS);
|
||||
|
||||
for (const [i, step] of spec.steps.entries()) {
|
||||
if (step.kind === 'pdf') {
|
||||
@@ -484,18 +552,24 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
|
||||
} else if (step.kind === 'screenshot') {
|
||||
const tmp = path.join(work, screenshotName(i, step));
|
||||
if (step.width) {
|
||||
const vp = [`${step.width}x${step.height ?? Math.round(step.width * 0.75)}`];
|
||||
const vp = [`${step.width}x${step.height ?? Math.round(step.width * DEFAULT_ASPECT)}`];
|
||||
// `--scale` recreates the daemon's browser context (and is refused in
|
||||
// headed mode), so it is passed only when the caller asked for it; the
|
||||
// 2x default stays Aside-only (see the header's "not mirrored" list).
|
||||
if (step.deviceScaleFactor) vp.push('--scale', String(step.deviceScaleFactor));
|
||||
await run(['viewport', ...vp, ...T]);
|
||||
}
|
||||
await run([...browseScreenshotArgs(step, tmp), ...T]);
|
||||
copyOut(tmp, step.out, i);
|
||||
// Aside clears its device override after each shot; restore the daemon's
|
||||
// default so a later un-sized screenshot is not taken at this width.
|
||||
if (step.width) await run(['viewport', '1280x720', ...T]);
|
||||
} else if (step.out) {
|
||||
const tmp = path.join(work, artifactName(i, step.out));
|
||||
await js(step.expression, ['--out', tmp]); // the daemon decodes data: URLs to bytes itself
|
||||
copyOut(tmp, step.out, i);
|
||||
} else {
|
||||
evals[i] = (await js(step.expression)).slice(0, step.maxInline ?? 20_000);
|
||||
evals[i] = (await js(step.expression)).slice(0, step.maxInline ?? DEFAULT_MAX_INLINE);
|
||||
}
|
||||
}
|
||||
const errs = await bestEffortJs('JSON.stringify(window.__gstackErrs || [])', 'PAGE_ERRORS');
|
||||
@@ -505,8 +579,8 @@ export async function renderWithBrowse(spec: RenderSpec, bin: string | null = re
|
||||
return fail((e as Error).message);
|
||||
} finally {
|
||||
if (tab !== undefined) await runProc(bin, ['closetab', String(tab)], 15_000);
|
||||
srv.stop();
|
||||
fs.rmSync(work, { recursive: true, force: true });
|
||||
srv?.stop();
|
||||
if (work) fs.rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,19 +597,40 @@ export type EngineChoice =
|
||||
let chosen: EngineChoice | undefined;
|
||||
|
||||
/** Aside when it answers, else gstack's own browser, else neither. Cached per process (the Aside probe is a round-trip). */
|
||||
export function pickEngine(fresh = false): EngineChoice {
|
||||
export function pickEngine(fresh = false, deps: { probe?: () => AsideProbe; resolveBin?: () => string | null } = {}): EngineChoice {
|
||||
if (chosen && !fresh) return chosen;
|
||||
const probe = probeAside();
|
||||
const probe = (deps.probe ?? probeAside)();
|
||||
if (probe.ok) return (chosen = { engine: 'aside', version: probe.version });
|
||||
const bin = resolveBrowseBin();
|
||||
const bin = (deps.resolveBin ?? resolveBrowseBin)();
|
||||
if (bin) return (chosen = { engine: 'browse', bin });
|
||||
return (chosen = { engine: null, probe, error: `${NO_BROWSER}: ${NO_BROWSER_HELP} (${probe.reason}: ${probe.detail})` });
|
||||
}
|
||||
|
||||
/** Render through whichever browser is available; `error` starts with NO_BROWSER when neither is. */
|
||||
/**
|
||||
* Render through whichever browser is available; `error` starts with NO_BROWSER
|
||||
* when neither is. If Aside was chosen but its process could not run (the app
|
||||
* quit mid-job, the CLI hung past its budget), the same spec is retried once on
|
||||
* gstack's own browser when that is built, and the choice sticks for the rest
|
||||
* of the process. A script-level failure (the page itself) is NOT retried.
|
||||
*/
|
||||
export async function render(spec: RenderSpec): Promise<RenderResult> {
|
||||
const c = pickEngine();
|
||||
if (c.engine === 'aside') return renderWithAside(spec);
|
||||
if (c.engine === 'aside') {
|
||||
const r = await renderWithAside(spec);
|
||||
// Retry on gstack's own browser when Aside could not START (spawn error,
|
||||
// not a timeout of a script that was already navigating) or its private
|
||||
// CDP bridge is gone (an Aside release renamed `_sendToTarget`). A page
|
||||
// failure is the page's, on either engine.
|
||||
if (!r.ok && /^aside repl did not run: (?!timed out)|_sendToTarget|openTab is not defined/.test(r.error ?? '')) {
|
||||
const bin = resolveBrowseBin();
|
||||
if (bin) {
|
||||
chosen = { engine: 'browse', bin };
|
||||
const fb = await renderWithBrowse(spec, bin);
|
||||
return { ...fb, stdout: `[aside unavailable mid-run: ${r.error}] retried on gstack's own browser\n${fb.stdout}` };
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
if (c.engine === 'browse') return renderWithBrowse(spec, c.bin);
|
||||
return { ok: false, outputs: [], evals: {}, stdout: '', error: c.error };
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "gstack-diagram-render",
|
||||
"sha256": "e59f8839cd0d42acb2b21bbde0825a1806c45ca8cbbcfc4367f7be27640b120d",
|
||||
"sha256": "46ed274ca8b6bc763308c87fea05fa9ad940d7f6bcfbd1bc0abbf56c6c0c705d",
|
||||
"srcSha256": "07238fae312bc0444f62b0a0a3404a8a38c45cef505aa1528c60a0ded17cbe06",
|
||||
"bytes": 7955445,
|
||||
"bytes": 7901685,
|
||||
"bunVersion": "1.3.10",
|
||||
"deps": {
|
||||
"@excalidraw/excalidraw": "0.18.1",
|
||||
|
||||
+692
-705
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user