extract Ghost Browser code to shared ghost-browser.ts

- Constants, types, proxy factory, and handler in one file
- Works in both Node.js (executor) and Chrome (extension) environments
- executor.ts imports createGhostBrowserChrome factory
- background.ts imports handleGhostBrowserCommand handler
This commit is contained in:
Tommy D. Rossi
2026-02-04 13:16:29 +01:00
parent f2faee50c0
commit c623c91891
3 changed files with 170 additions and 92 deletions
+8 -44
View File
@@ -4,6 +4,7 @@ import { createStore } from 'zustand/vanilla'
import type { ExtensionState, ConnectionState, TabState, TabInfo } from './types'
import type { CDPEvent, Protocol } from 'playwriter/src/cdp-types'
import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/protocol'
import { handleGhostBrowserCommand, type GhostBrowserCommandParams } from 'playwriter/src/ghost-browser'
import {
getActiveRecordings,
handleStartRecording,
@@ -278,51 +279,14 @@ class ConnectionManager {
// This allows calling chrome.ghostPublicAPI, chrome.ghostProxies, chrome.projects
// from the playwriter executor sandbox when running in Ghost Browser
if (message.method === 'ghost-browser') {
const { namespace, method, args } = message.params as {
namespace: 'ghostPublicAPI' | 'ghostProxies' | 'projects'
method: string
args: unknown[]
}
try {
const api = (chrome as any)[namespace]
if (!api) {
sendMessage({
id: message.id,
result: { success: false, error: `chrome.${namespace} not available (not running in Ghost Browser?)` }
})
return
}
const fn = api[method]
if (typeof fn !== 'function') {
// Check if it's a constant/property
if (method in api) {
sendMessage({ id: message.id, result: { success: true, result: api[method] } })
return
}
sendMessage({
id: message.id,
result: { success: false, error: `chrome.${namespace}.${method} is not a function or property` }
})
return
}
// Ghost Browser APIs use callback pattern, wrap in Promise
const result = await new Promise((resolve, reject) => {
fn.call(api, ...args, (result: unknown) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message))
} else {
resolve(result)
}
})
})
sendMessage({ id: message.id, result: { success: true, result } })
} catch (error: any) {
logger.error('Ghost Browser API error:', error)
sendMessage({ id: message.id, result: { success: false, error: error.message } })
const result = await handleGhostBrowserCommand(
message.params as GhostBrowserCommandParams,
chrome
)
if (!result.success) {
logger.error('Ghost Browser API error:', result.error)
}
sendMessage({ id: message.id, result })
return
}
+11 -48
View File
@@ -28,6 +28,7 @@ import {
type ScreenshotResult,
type SnapshotFormat,
} from './aria-snapshot.js'
import { createGhostBrowserChrome, type GhostBrowserCommandResult } from './ghost-browser.js'
export type { SnapshotFormat }
import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
import { getPageMarkdown, type GetPageMarkdownOptions } from './page-markdown.js'
@@ -741,55 +742,17 @@ export class PlaywrightExecutor {
}
const self = this
// Ghost Browser API integration - creates proxy objects that mirror chrome.ghostPublicAPI,
// chrome.ghostProxies, and chrome.projects APIs. Only works when running in Ghost Browser.
// See extension/src/ghost-browser-api.d.ts for full API documentation.
const createGhostBrowserProxy = (
namespace: 'ghostPublicAPI' | 'ghostProxies' | 'projects',
constants: Record<string, unknown> = {}
) => {
const sendGhostBrowserCommand = async (method: string, args: unknown[]) => {
const cdp = await getCDPSession({ page })
const result = await cdp.send('ghost-browser' as any, { namespace, method, args })
const typed = result as { success: boolean; result?: unknown; error?: string }
if (!typed.success) {
throw new Error(typed.error || `Ghost Browser API call failed: ${namespace}.${method}`)
}
return typed.result
// Ghost Browser API - creates chrome object that mirrors Ghost Browser's APIs
// See extension/src/ghost-browser-api.d.ts for full API documentation
const chromeGhostBrowser = createGhostBrowserChrome(async (namespace, method, args) => {
const cdp = await getCDPSession({ page })
const result = await cdp.send('ghost-browser' as any, { namespace, method, args })
const typed = result as GhostBrowserCommandResult
if (!typed.success) {
throw new Error(typed.error || `Ghost Browser API call failed: ${namespace}.${method}`)
}
return new Proxy(constants, {
get(target, prop: string) {
// Return constants directly (no await needed)
if (prop in target) {
return target[prop]
}
// Return function that sends ghost-browser command
return (...args: unknown[]) => sendGhostBrowserCommand(prop, args)
}
})
}
// Chrome object with Ghost Browser API namespaces
// These mirror the exact shape of chrome.ghostPublicAPI, chrome.ghostProxies, chrome.projects
const chromeGhostBrowser = {
ghostPublicAPI: createGhostBrowserProxy('ghostPublicAPI', {
NEW_TEMPORARY_IDENTITY: 'OpenInNewSession',
DEFAULT_IDENTITY: '',
MAX_TEMPORARY_IDENTITIES: 25,
}),
ghostProxies: createGhostBrowserProxy('ghostProxies', {
DIRECT_PROXY: '8f513494-8cf5-41c7-b318-936392222104',
SYSTEM_PROXY: '2485b989-7ffb-4442-a45a-d7f9a10c6171',
}),
projects: createGhostBrowserProxy('projects', {
PROJECT_ID_HOME: 'f0673216-13b9-48be-aa41-90763e229e78',
PROJECT_ID_UNSAVED: 'fe061488-8a8e-40f0-9e5e-93a1a5e2c273',
SESSIONS_MAX: 25,
NEW_SESSION: 'OpenInNewSession',
GLOBAL_SESSION: '',
}),
}
return typed.result
})
let vmContextObj: any = {
page,
+151
View File
@@ -0,0 +1,151 @@
/**
* Ghost Browser API integration for Playwriter
*
* Shared code for both executor (Node.js) and extension (Chrome) environments.
* See extension/src/ghost-browser-api.d.ts for full API documentation.
*/
// =============================================================================
// Constants - same values as in Ghost Browser
// =============================================================================
export const GHOST_PUBLIC_API_CONSTANTS = {
NEW_TEMPORARY_IDENTITY: 'OpenInNewSession',
DEFAULT_IDENTITY: '',
MAX_TEMPORARY_IDENTITIES: 25,
} as const
export const GHOST_PROXIES_CONSTANTS = {
DIRECT_PROXY: '8f513494-8cf5-41c7-b318-936392222104',
SYSTEM_PROXY: '2485b989-7ffb-4442-a45a-d7f9a10c6171',
} as const
export const GHOST_PROJECTS_CONSTANTS = {
PROJECT_ID_HOME: 'f0673216-13b9-48be-aa41-90763e229e78',
PROJECT_ID_UNSAVED: 'fe061488-8a8e-40f0-9e5e-93a1a5e2c273',
SESSIONS_MAX: 25,
NEW_SESSION: 'OpenInNewSession',
GLOBAL_SESSION: '',
} as const
// =============================================================================
// Types
// =============================================================================
export type GhostBrowserNamespace = 'ghostPublicAPI' | 'ghostProxies' | 'projects'
export type GhostBrowserCommandParams = {
namespace: GhostBrowserNamespace
method: string
args: unknown[]
}
export type GhostBrowserCommandResult =
| { success: true; result: unknown }
| { success: false; error: string }
/**
* Function signature for sending ghost-browser commands.
* In executor: sends via CDP session
* In extension: calls chrome.* APIs directly
*/
export type SendGhostBrowserCommand = (
namespace: GhostBrowserNamespace,
method: string,
args: unknown[]
) => Promise<unknown>
// =============================================================================
// Executor: Chrome object factory
// =============================================================================
/**
* Creates a proxy object for a Ghost Browser API namespace.
* Constants are returned directly, methods are forwarded via sendCommand.
*/
function createGhostBrowserProxy(
namespace: GhostBrowserNamespace,
constants: Record<string, unknown>,
sendCommand: SendGhostBrowserCommand
) {
return new Proxy(constants, {
get(target, prop: string) {
// Return constants directly (no await needed)
if (prop in target) {
return target[prop]
}
// Return function that sends ghost-browser command
return (...args: unknown[]) => sendCommand(namespace, prop, args)
},
})
}
/**
* Creates the chrome object with Ghost Browser API namespaces for the executor sandbox.
* Mirrors the exact shape of chrome.ghostPublicAPI, chrome.ghostProxies, chrome.projects.
*
* @param sendCommand - Function to send commands to the extension
*/
export function createGhostBrowserChrome(sendCommand: SendGhostBrowserCommand) {
return {
ghostPublicAPI: createGhostBrowserProxy('ghostPublicAPI', GHOST_PUBLIC_API_CONSTANTS, sendCommand),
ghostProxies: createGhostBrowserProxy('ghostProxies', GHOST_PROXIES_CONSTANTS, sendCommand),
projects: createGhostBrowserProxy('projects', GHOST_PROJECTS_CONSTANTS, sendCommand),
}
}
// =============================================================================
// Extension: Command handler
// =============================================================================
/**
* Handles ghost-browser commands in the extension.
* Calls the appropriate chrome.* API and returns the result.
*
* @param params - Command parameters (namespace, method, args)
* @param chromeApi - The chrome object (passed to avoid global dependency)
* @returns Result object with success/error status
*/
export async function handleGhostBrowserCommand(
params: GhostBrowserCommandParams,
chromeApi: typeof chrome
): Promise<GhostBrowserCommandResult> {
const { namespace, method, args } = params
try {
const api = (chromeApi as any)[namespace]
if (!api) {
return {
success: false,
error: `chrome.${namespace} not available (not running in Ghost Browser?)`,
}
}
const fn = api[method]
if (typeof fn !== 'function') {
// Check if it's a constant/property
if (method in api) {
return { success: true, result: api[method] }
}
return {
success: false,
error: `chrome.${namespace}.${method} is not a function or property`,
}
}
// Ghost Browser APIs use callback pattern, wrap in Promise
const result = await new Promise((resolve, reject) => {
fn.call(api, ...args, (result: unknown) => {
if (chromeApi.runtime.lastError) {
reject(new Error(chromeApi.runtime.lastError.message))
} else {
resolve(result)
}
})
})
return { success: true, result }
} catch (error: any) {
return { success: false, error: error.message }
}
}