expose currently means two different things depending on which client you pick, and only one of them is built.
Current state
| Client |
Signature |
What happens today |
| JS |
expose(name: string, fn: string) — fn is JS source |
Works. Defines window[name] from that source, persists across navigation since #297. |
| Python |
expose(name: str, fn: str) — JS source |
Same as JS. |
| Java |
expose(String, Function<Object[], Object>) — a Java callback |
Non-functional. Sends no fn, so the server answers {"error":"fn is required"}. Subscribes to a vibium:page.exposedFunction event the server has never emitted. Carries // TODO: send result back if bidirectional exposed functions are supported. |
| CLI / MCP |
not offered |
— |
So there are two features sharing one name:
A. Inject a named JS function — built, JS and Python. Equivalent to context.addInitScript("window[name] = fn"), plus injection into the already-open document.
B. Expose a host function to the page — not built. The page calls window[name](x), a function in your program runs, and the return value comes back. This is what exposeFunction means in Playwright and Puppeteer, what Java's signature promises, and what #135 was filed expecting.
B is the reason the name exists. A is a convenience that addInitScript already covers.
What it should be
await page.expose('save', async (data) => { fs.writeFileSync('out.json', data); return 'ok'; });
// in the page:
const result = await window.save(JSON.stringify(x)); // -> 'ok'
How Playwright does it
One native binding for all exposed functions, multiplexed by name, with a sequence number correlating calls to replies.
Runtime.addBinding({name: '__playwright__binding__'}) once per page (crPage.ts:1034)
- page side:
window[name] returns a promise, stores {resolve, reject} under seq, and sends {name, seq, serializedArgs} through the binding (bindingsController.ts:50)
- host side:
Runtime.bindingCalled fires, the callback runs, and the result is delivered by evaluating deliverBindingResult({name, seq, result}) into the page (page.ts:1071)
- page side resolves the stored promise by
seq; errors take the same path with {error} and reject
Mapping to BiDi
Every piece has an equivalent, and we already run this pattern for WebSocket monitoring.
| Playwright (CDP) |
BiDi |
Runtime.addBinding |
script.ChannelValue argument to script.addPreloadScript |
Runtime.bindingCalled |
script.message event ({channel, data, source}) |
addInitScript + evaluate in frames |
script.addPreloadScript + script.callFunction — already done in #297 |
deliverBindingResult via evaluate |
script.callFunction into the context from source |
BiDi carries source (realm and context) on script.message, so the calling frame is known without putting it in the payload.
handlers_websocket.go is a working example of the channel half: it passes {"type":"channel","value":{"channel":...}} to addPreloadScript and subscribes to script.message. It is fire-and-forget, so the round trip back into the page is the part that does not exist yet.
Two gotchas already learned in this codebase
Omit contexts on the preload script. Pinning it to one context means it never fires on later pages — see the comment at handlers_websocket.go:97.
Guard against double-install. The preload script and the current-document injection both run, so the script needs an idempotence check like wsMonitorPreloadScript's. Without it the second install replaces the first's pending-callback map and in-flight calls hang.
Then decide what happens to A
Once B exists, expose(name, jsSource) is a differently-named addInitScript. Either drop it, or keep it under a name that says what it does. Leaving a method called expose that means something else industry-wide is what produced Java's unbuildable signature and the misfiled diagnosis in #135.
Related: #135 (the Java client half), #68 / #90 / #81 (new clients, whose authors will expect the Playwright meaning).
exposecurrently means two different things depending on which client you pick, and only one of them is built.Current state
expose(name: string, fn: string)—fnis JS sourcewindow[name]from that source, persists across navigation since #297.expose(name: str, fn: str)— JS sourceexpose(String, Function<Object[], Object>)— a Java callbackfn, so the server answers{"error":"fn is required"}. Subscribes to avibium:page.exposedFunctionevent the server has never emitted. Carries// TODO: send result back if bidirectional exposed functions are supported.So there are two features sharing one name:
A. Inject a named JS function — built, JS and Python. Equivalent to
context.addInitScript("window[name] = fn"), plus injection into the already-open document.B. Expose a host function to the page — not built. The page calls
window[name](x), a function in your program runs, and the return value comes back. This is whatexposeFunctionmeans in Playwright and Puppeteer, what Java's signature promises, and what #135 was filed expecting.B is the reason the name exists. A is a convenience that
addInitScriptalready covers.What it should be
How Playwright does it
One native binding for all exposed functions, multiplexed by name, with a sequence number correlating calls to replies.
Runtime.addBinding({name: '__playwright__binding__'})once per page (crPage.ts:1034)window[name]returns a promise, stores{resolve, reject}underseq, and sends{name, seq, serializedArgs}through the binding (bindingsController.ts:50)Runtime.bindingCalledfires, the callback runs, and the result is delivered by evaluatingdeliverBindingResult({name, seq, result})into the page (page.ts:1071)seq; errors take the same path with{error}and rejectMapping to BiDi
Every piece has an equivalent, and we already run this pattern for WebSocket monitoring.
Runtime.addBindingscript.ChannelValueargument toscript.addPreloadScriptRuntime.bindingCalledscript.messageevent ({channel, data, source})addInitScript+ evaluate in framesscript.addPreloadScript+script.callFunction— already done in #297deliverBindingResultvia evaluatescript.callFunctioninto the context fromsourceBiDi carries
source(realm and context) onscript.message, so the calling frame is known without putting it in the payload.handlers_websocket.gois a working example of the channel half: it passes{"type":"channel","value":{"channel":...}}toaddPreloadScriptand subscribes toscript.message. It is fire-and-forget, so the round trip back into the page is the part that does not exist yet.Two gotchas already learned in this codebase
Omit
contextson the preload script. Pinning it to one context means it never fires on later pages — see the comment athandlers_websocket.go:97.Guard against double-install. The preload script and the current-document injection both run, so the script needs an idempotence check like
wsMonitorPreloadScript's. Without it the second install replaces the first's pending-callback map and in-flight calls hang.Then decide what happens to A
Once B exists,
expose(name, jsSource)is a differently-namedaddInitScript. Either drop it, or keep it under a name that says what it does. Leaving a method calledexposethat means something else industry-wide is what produced Java's unbuildable signature and the misfiled diagnosis in #135.Related: #135 (the Java client half), #68 / #90 / #81 (new clients, whose authors will expect the Playwright meaning).