Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ never sees the page.
pi install npm:@getpipher/keystone
```

Exposes the `keystone` skill (Build + Audit verbs) and the `keystone_render`
tool in one pi package.
Exposes the `keystone` skill (Build + Audit verbs) and the render layer in one
package: the `keystone_render` tool on pi hosts, omp's built-in browser on omp
(eval-kernel import — no bundled Chromium driver), and `--render` CLI mode.

## What you get

Expand Down
6 changes: 2 additions & 4 deletions engine/audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,9 @@ async function main() {

async function loadRender() {
try {
const { tsImport } = await import("tsx/esm/api")
return await tsImport(new URL("../extensions/render.ts", import.meta.url).href, import.meta.url)
return await import("./render.mjs")
} catch (e) {
console.error("audit: failed to load the render extension:", e instanceof Error ? e.message : String(e))
console.error("(requires the tsx runtime — run via pi, or: npm i tsx)")
console.error("audit: failed to load the render engine:", e instanceof Error ? e.message : String(e))
process.exit(1)
}
}
Expand Down
15 changes: 6 additions & 9 deletions engine/check-gates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,20 @@ if (args.log) {
}

async function main() {
// --render: bootstrap the TS render extension via tsx, run Chromium
// --render: run headless Chromium via the playwright driver (child process —
// no omp extension-loader involvement; v1.1.0 dropped the tsx bootstrap).
if (args.render) {
let renderModule
let render
try {
const { tsImport } = await import("tsx/esm/api")
// check-gates.mjs lives in engine/, so resolve UP to the repo root's extensions/
renderModule = await tsImport(new URL("../extensions/render.ts", import.meta.url).href, import.meta.url)
;({ render } = await import("./render.mjs"))
} catch (e) {
// Log the real error so a path/config issue isn't masked as a missing-tsx message.
console.error("--render failed to load the render extension:", e instanceof Error ? e.message : String(e))
console.error("(requires the tsx runtime — run via pi, or: npm i tsx)")
console.error("--render failed to load the render engine:", e instanceof Error ? e.message : String(e))
process.exit(1)
}

let out2
try {
out2 = await renderModule.render({
out2 = await render({
htmlPath: args.html,
viewports: viewportsArg,
outDir: join(out, "keystone-render"),
Expand Down
4 changes: 4 additions & 0 deletions engine/driver-omp-native.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Type declarations for engine/driver-omp-native.mjs.

export declare function ompBrowserAvailable(): boolean
export declare function createOmpNativeDriver(): import("./driver-playwright.mjs").RenderDriver
72 changes: 72 additions & 0 deletions engine/driver-omp-native.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// engine/driver-omp-native.mjs — RenderDriver over omp's built-in browser.
//
// omp bundles a Puppeteer-backed browser exposed as the `browser` global in its
// eval kernels (can1357/oh-my-pi#11091 tracks exposing it to extensions). This
// driver runs INSIDE that eval kernel — the skill imports engine/render-omp.mjs
// from the installed package path and calls render(); no playwright-core, no
// second Chromium stack, nothing for omp's extension loader to preload.
//
// Lifecycle: omp owns the browser (shared, headless, launch-on-first-use).
// Each openPage maps to a uniquely named tab; close() releases that tab only.
// Tab profile isolation is weaker than playwright's per-context isolation
// (tabs share the browser profile); keystone renders local files and one-off
// audit URLs, so cross-viewport cookie bleed is out of scope.

/** @returns {boolean} */
export function ompBrowserAvailable() {
return typeof globalThis.browser === "object" && globalThis.browser !== null
&& typeof globalThis.browser.open === "function"
}
async function writeScreenshot(tab, destPath) {
const { copyFileSync, writeFileSync } = await import("node:fs")
const r = await tab.screenshot()
if (typeof r === "string") {
if (r.startsWith("data:")) {
const comma = r.indexOf(",")
writeFileSync(destPath, Buffer.from(r.slice(comma + 1), "base64"))
return
}
// omp writes captures to a temp file and returns its path — copy it over.
copyFileSync(r, destPath)
return
}
if (r instanceof Uint8Array) { writeFileSync(destPath, r); return }
if (r && typeof r.arrayBuffer === "function") { writeFileSync(destPath, new Uint8Array(await r.arrayBuffer())); return }
throw new Error(`omp-native driver: unhandled screenshot return type ${Object.prototype.toString.call(r)}`)
}
/** @returns {import("./driver-playwright.mjs").RenderDriver} */
export function createOmpNativeDriver() {
if (!ompBrowserAvailable()) {
throw new Error("omp-native driver requires the omp eval-kernel `browser` global (run via omp's eval kernel, not node)")
}
const b = globalThis.browser
const tabs = []
let seq = 0
return {
async openPage({ width, height }) {
const name = `keystone-${width}-${Date.now().toString(36)}-${seq++}`
const tab = await b.open({ name, viewport: { width, height }, url: "about:blank" })
tabs.push(name)
return {
async goto(url) {
await tab.goto(url)
// settle: playwright's networkidle isn't exposed on the tab helper;
// waiting for <body> covers late-arriving document paint.
await tab.waitForSelector("body", { timeout: 10_000 }).catch(() => {})
},
url: () => tab.url(),
screenshot: (path) => writeScreenshot(tab, path),
// tab.run serializes the fn — closures don't cross; pass the expr via args.
evaluate: (expr) => tab.run(async ({ page }, code) => page.evaluate(code), { args: [expr] }),
content: () => tab.run(async ({ page }) => page.content()),
async close() {
await b.close({ name }).catch(() => {})
},
}
},
async close() {
// tabs are closed individually by render-core (page.close); nothing to do —
// the browser itself is omp-owned and shared.
},
}
}
19 changes: 19 additions & 0 deletions engine/driver-playwright.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Type declarations for engine/driver-playwright.mjs (consumed by
// extensions/render.ts under strict tsc; the implementation is plain JS and
// stays the source of truth).

export interface RenderPage {
goto(url: string): Promise<void>
url(): Promise<string>
screenshot(path: string): Promise<void>
evaluate(expr: string): Promise<unknown>
content(): Promise<string>
close(): Promise<void>
}

export interface RenderDriver {
openPage(viewport: { width: number; height: number }): Promise<RenderPage>
close(): Promise<void>
}

export declare function createPlaywrightDriver(): Promise<RenderDriver>
64 changes: 64 additions & 0 deletions engine/driver-playwright.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// engine/driver-playwright.mjs — RenderDriver over playwright-core.
//
// Used by (a) the CLI child process (check-gates.mjs --render, audit.mjs) and
// (b) the pi extension tool. NOT imported by omp's extension path — omp skips
// tool registration there and the skill renders via driver-omp-native.mjs
// through omp's built-in browser.
//
// The playwright-core specifier is assembled at runtime (["playwright","-core"].join(""))
// because omp's guarded extension loader preloads every statically resolvable
// import specifier at startup — a static reference costs ~20s per omp start
// (getpipher/keystone#21). Harmless in a plain node child process; kept uniform.

/**
* @typedef {Object} RenderPage
* @property {(url: string) => Promise<void>} goto
* @property {() => Promise<string>} url
* @property {(path: string) => Promise<void>} screenshot PNG to path
* @property {(expr: string) => Promise<any>} evaluate page-context expression (string!)
* @property {() => Promise<string>} content serialized DOM
* @property {() => Promise<void>} close
*
* @typedef {Object} RenderDriver
* @property {(viewport: {width: number, height: number}) => Promise<RenderPage>} openPage
* @property {() => Promise<void>} close
*/

let cachedChromium

async function loadChromium() {
const spec = ["playwright", "-core"].join("")
const mod = await import(spec)
return mod.chromium
}

async function getChromium() {
cachedChromium ??= await loadChromium()
return cachedChromium
}

/** @returns {Promise<RenderDriver>} */
export async function createPlaywrightDriver() {
const chromium = await getChromium()
const browser = await chromium.launch({ headless: true })
return {
async openPage({ width, height }) {
// fresh context per viewport: cookies/storage isolated between passes
const ctx = await browser.newContext({ viewport: { width, height } })
const page = await ctx.newPage()
return {
goto: (url) => page.goto(url, { waitUntil: "networkidle" }),
url: () => Promise.resolve(page.url()),
screenshot: (path) => page.screenshot({ path, fullPage: false }),
evaluate: (expr) => page.evaluate(expr),
content: () => page.content(),
async close() {
await ctx.close()
},
}
},
async close() {
await browser.close()
},
}
}
8 changes: 8 additions & 0 deletions engine/host.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Type declarations for engine/host.mjs.

export interface HostIdentity {
argv0?: string
entry?: string
}

export declare function isOmpRuntime(identity?: HostIdentity): boolean
21 changes: 21 additions & 0 deletions engine/host.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// engine/host.mjs — which host is this module running in?
//
// omp and pi share the extension API, so the keystone extension needs a hard
// signal for "this is omp" to skip tool registration there (omp renders via the
// omp-native driver from its eval kernel instead — see render-omp.mjs).
//
// Env vars are useless: omp mirrors OMP_* to PI_* and neither leaves reliable
// markers in extension runtime env. What IS stable: omp ships as a bun-compiled
// single binary — argv0 is the bare "omp" name and argv[1] is the embedded
// bunfs entry point ("/$bunfs/root/omp-<platform>"). pi (node-based) matches
// neither. Verified against omp 18.1.12 on darwin-arm64.

/**
* @param {{ argv0?: string, entry?: string }} [identity] defaults to the real
* process identity; injectable for tests (process.argv0 is read-only).
* @returns {boolean} true when running inside the omp host
*/
export function isOmpRuntime(identity = { argv0: process.argv0, entry: process.argv[1] }) {
if (identity.argv0 === "omp") return true
return (identity.entry ?? "").startsWith("/$bunfs/root/omp-")
}
37 changes: 37 additions & 0 deletions engine/render-core.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Type declarations for engine/render-core.mjs.

export interface RenderInput {
htmlPath: string
url?: string
viewports?: number[]
outDir?: string
}

export interface HeroRect {
eyebrow: { top: number; bottom: number } | null
headline: { top: number; bottom: number }
lede: { top: number; bottom: number } | null
cta: { top: number; bottom: number } | null
}

export interface ViewportMetric {
width: number
scrollWidth: number
innerWidth: number
innerHeight: number
hero?: HeroRect
}

export interface RenderOutput {
screenshots: { width: number; path: string }[]
computedStylesPath: string
domSnapshotPath: string
viewportMetrics: ViewportMetric[]
finalUrl: string
clickableMetrics: { viewport: number; selector: string; offsetHeight: number; lineHeight: number }[]
}

export declare function renderWithDriver(
input: RenderInput,
driver: import("./driver-playwright.mjs").RenderDriver,
): Promise<RenderOutput>
Loading
Loading