Calling a Native Function
A “native function” in ekrooh is just a consumer plugin event: the web layer sends an invoke, a worklet handler runs the capability, and the result comes back to the UI. The framework provides the wire protocol, request/response matching, and the transport — you own the event contract and the handler logic.
The smallest complete example is the repo’s
examples/consumer-basic (app.basic plugin): one invoke (basic.ping), one
backend → web push (basic.beep), and a web button that calls both. This guide
traces that flow end to end and shows how to add your own capability by following
the same four steps.
The shape of a native call
Section titled “The shape of a native call”flowchart LR W[Web UI] -->|invoke| M[Protocol Messenger] M -->|ENVELOPE over transport| R[Worklet runtime] R -->|handler runs capability| H[Plugin invoke handler] H -->|ok(result)| R R -->|INVOKE_RESPONSE| M M -->|bus.invoke result| WTwo pieces are consumer-owned:
- the event contract — the event name plus its
args/resulttypes, and - the handler logic — what the capability actually does.
Everything else (wire framing, requestId matching, the WebSocket transport, the worklet runtime) is framework-provided.
1. Define the event contract (shared)
Section titled “1. Define the event contract (shared)”One EventSpec per event is the single source of truth. It drives the manifest,
the handler table, and the typed client builders, so the wire name and the
client-side type can never drift apart.
import { EventSpec, invokeEvent } from '@ekrooh/bare/core';
export const helloSpecs = { greet: { pluginId: 'app.hello', name: 'hello.greet', args: {} as { name?: string }, result: {} as { reply: string }, },} as const satisfies Record<string, EventSpec<any, any>>;
export const helloEvents = { greet(name = 'world') { return invokeEvent(helloSpecs.greet, { name }); },};invokeEvent(spec, args) returns a typed invoke envelope. Keep this file free of
runtime logic — both the worklet and the web layer import it so they agree on the
contract.
2. Implement the worklet handler
Section titled “2. Implement the worklet handler”definePlugin wires the EventSpec to a handler table. Each invoke handler
returns Either<CoreError, TResult> via ok/err.
import { definePlugin, ok } from '@ekrooh/bare/core';import type { LoopbackPush } from '@ekrooh/bare/runtime';import { helloSpecs } from '../shared/hello-events';
export function createHelloPlugin(deps: { push: LoopbackPush }) { return definePlugin('app.hello', helloSpecs, { capabilities: ['hello'], invoke: { greet(args) { return ok({ reply: `hello, ${args?.name ?? 'world'}` }); }, }, });}The pluginId must match the spec’s pluginId, or definePlugin throws. This is
where native work happens: read a file, open a camera, call into a host delegate —
anything the worklet runtime can do.
3. Register it in the worklet entry
Section titled “3. Register it in the worklet entry”Create the worklet runtime, build the push seam, and register the plugin before starting:
import { createLoopbackPush, createWorkletRuntime, resolveWorkletConfig,} from '@ekrooh/bare/runtime';import { createHelloPlugin } from './hello-plugin';
const runtime = createWorkletRuntime(resolveWorkletConfig());const push = createLoopbackPush(runtime.server, runtime.protocol);runtime.pluginRegistry.register(createHelloPlugin({ push }));void runtime.start();resolveWorkletConfig() reads the host-supplied configuration (device assets,
dev flags). Without host arguments or CLI tokens it resolves to {}, which is
device mode (auth on, ephemeral port); the dev backend needs an explicit
mode=dev token. createWorkletRuntime is synchronous; start() binds the
server and resolves to the credentials. Plugins that depend on the loopback
socket are created after the runtime exists.
4. Call it from the web
Section titled “4. Call it from the web”The web layer wraps the transport in a protocol messenger and a plugin bus, then invokes the typed event:
import { MessageType, createPluginBus, createProtocolMessenger,} from '@ekrooh/bare/core';import { createWebSocketTransport } from '@ekrooh/bare/transports';import { helloEvents } from '../shared/hello-events';
const transport = createWebSocketTransport();const messenger = createProtocolMessenger((request, payload) => { transport.send(MessageType.ENVELOPE, request, payload);});// Let the messenger match INVOKE_RESPONSE requestIds back to `bus.invoke`.transport.subscribe((message) => messenger.handleIncoming(message.header));const bus = createPluginBus(messenger);
const [err, result] = await bus.invoke(helloEvents.greet('consumer'));// result?.reply === 'hello, consumer'createProtocolMessenger hands request bytes to transport.send, and
handleIncoming matches each INVOKE_RESPONSE requestId back to bus.invoke.
MessageTransport itself only has send, subscribe and an optional
close; the plugin bus is how a page calls a plugin. bus.invoke never
throws: it resolves to [error, null] or [null, result].
Beyond request/response: backend → web push
Section titled “Beyond request/response: backend → web push”Not every update is a reply to an invoke. A server-initiated update — a progress
tick, a notification — uses a DISPATCH (no requestId). The app.basic example
pushes a basic.beep on every ping via LoopbackPush:
// in the worklet handlerdeps.push(basicBeepHeader(beepCount));The web layer matches the DISPATCH header by pluginId/event and re-renders.
LoopbackPush (built from runtime.server + runtime.protocol) is the
framework-provided seam for backend → web messages — see
Backend → Web Push.
Host-owned capabilities (the IPC seam)
Section titled “Host-owned capabilities (the IPC seam)”Some capabilities are owned by the host, not the worklet: camera, photo capture,
permissions. A consumer plugin declares the events but forwards the invoke to the
host instead of answering locally, using HOST_INVOKE_REQUEST, and returns the
host’s answer. This is the integration point with native iOS/Android code.
The framework’s own plugins show the reference pattern:
plugins/media/plugin.ts— picker/capture delegates to the host.plugins/permissions/plugin.ts— permission state comes from the host.
Follow those for any capability whose implementation lives in native code; the
web → worklet → host → worklet → web roundtrip is otherwise identical to the
app.hello flow above.
Framework-provided vs consumer-owned
Section titled “Framework-provided vs consumer-owned”| Concern | Owner | Where |
|---|---|---|
| Wire protocol, framing, requestId matching | Framework | createProtocolMessenger, createPluginBus |
| Transport (WebSocket loopback) | Framework | createWebSocketTransport (Transports) |
| Worklet runtime + config resolution | Framework | createWorkletRuntime, resolveWorkletConfig |
| Backend → web push seam | Framework | LoopbackPush / createLoopbackPush |
| Plugin ID + event names + arg/result types | You | EventSpec (shared) |
| Handler logic / native integration | You | definePlugin invoke handlers |
| Worklet entry wiring | You | runtime.pluginRegistry.register(...) |
| Web UI + invoke calls | You | bus.invoke(...) |
Try it
Section titled “Try it”- Run the worked example:
examples/consumer-basic(npm run devboots the worklet + Vite; the browser buttons exercisebasic.pingandbasic.beep). - Start from a browser-only hello world in
Web Quickstart, then add your own
app.*plugin using the four steps above. - For richer handler patterns, see Authoring a Plugin and Worklet Entry.