Skip to content

Implement exposed functions: let the page call back into the client #298

Description

@hugs

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.callFunctionalready 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions