Skip to main content

The Contract

One definePlugin call is the single source of truth. Payload and response types flow from it to both ends — the web client's hooks and the host's handlers — with zero manual casting.

Actions

An action is a request-response pair. In phantom mode you declare the types as generics; nothing is validated at runtime:

import { action, definePlugin } from '@webview-ts/shared';

export const camera = definePlugin('camera', {
takePhoto: action<{ quality?: number }, { uri: string }>(),
// ^payload ^response
});

Action names are automatically namespaced: takePhoto becomes camera.takePhoto on the wire.

Phantom mode vs schema mode

Every action is written in one of two modes, and the difference is where the types live:

Phantom modeaction<Payload, Response>(). The generics exist only at compile time; they vanish when TypeScript compiles ("phantom" types — the runtime marker object carries no trace of them). Zero runtime cost, zero dependencies, but also zero runtime checking: if the other side sends a shape the contract didn't promise, nothing catches it at the boundary.

Schema modeaction({ payload: z.object(...), response: z.object(...) }). You pass Standard Schema objects (zod, valibot, arktype) instead of generics. Types are inferred from the schema — no generics to repeat — and the same schema validates real payloads at the receiving boundary at runtime.

PhantomSchema
Type sourcegenerics you writeinferred from the schema
Runtime validationnoneat both receiving boundaries
Version-skew detection✅ (VALIDATION_ERROR)
.default() / .transform()✅ applied across the bridge
Runtime cost / depszeroschema library + validation per message

How to choose: phantom mode is enough when both sides ship together and you trust the wire — the compiler already guarantees both codebases agree. Reach for schema mode where the boundary is untrusted or can drift: independently deployed web/host versions (skew), external embeds, or payloads whose shape genuinely needs checking. The two modes mix freely per action in one plugin — validate the risky actions, keep the rest free. See Schema validation for the full behavior.

Per-action options

Timeout, retry, and cache are part of the contract, not scattered call-site configuration:

export const device = definePlugin('device', {
getInfo: action<void, DeviceInfo>({
timeout: 5000,
retry: { maxAttempts: 2, delay: 300 },
cache: 60_000, // ms TTL, or `true` for indefinite
}),
});

See Timeout, retry & cache for resolution order and retry semantics.

Per-action interceptors

takePhoto: action<P, R>().interceptors.request.use(compressionInterceptor),

See Interceptors.

Events

Events are one-way, host → client:

import { action, definePlugin, event } from '@webview-ts/shared';

export const location = definePlugin(
'location',
{ get: action<void, { lat: number; lng: number }>() },
{
events: {
updated: event<{ lat: number; lng: number }>(),
},
}
);

Event names are namespaced the same way: location.updated. Both ends are typed — the client's on() handler and the host's sendEvent / ctx.emit. See Events.

Fallback mocks

Plugins ship their own browser-dev mocks:

export const camera = definePlugin('camera', {
takePhoto: action<P, R>(),
}).withFallback({
takePhoto: async () => ({ uri: 'https://picsum.photos/400/300' }),
});

The mock signatures are typed from the action markers — a wrong mock shape is a compile error. See Fallback mode.

Host handlers

plugin.host(handlers) produces the host-side registration with the same inference:

camera.host({
takePhoto: async (payload, ctx) => {
// ^? { quality?: number }
return { uri: '...' }; // ✅ checked against the response type
},
});
  • Every declared action must be implemented — a missing handler is a compile error.
  • When the plugin declares events, ctx.emit('updated', payload) is available and typed against the event map.
  • When an action has a payload schema, the payload is validated before your handler runs.

Type guarantees

The inference chain is covered by compile-time tests (run by tsc and vitest's typecheck mode). What the contract guarantees:

  • Action and event names stay exactuseAction('camera.nope') is a compile error, not a runtime 404.
  • Payloads and responses are checked at every surface: execute, call, handlers, mocks, sendEvent, emit.
  • With schemas, input and output types split correctly: senders use the schema's input type (.default() fields optional), receivers get the output type.