Skip to content

extforge/messaging

extforge/messaging is a typed-route RPC layer over chrome.runtime.sendMessage / onMessage. Routes register their request and response shapes via TypeScript module augmentation, so call sites get full inference both ways.

Augment the MessageMap interface from extforge/messaging. Route names are arbitrary strings; the request and response types are whatever your handlers return.

declare module 'extforge/messaging' {
interface MessageMap {
'get-user': { req: { id: number }; res: { name: string; email: string } };
'log': { req: { msg: string }; res: void };
}
}

This block usually lives in a shared types.ts imported by every script that talks the protocol (background, popup, content).

src/background/index.ts
import { defineHandler, setupMessaging } from 'extforge/messaging';
defineHandler('get-user', async (req, sender) => {
const user = await db.find(req.id);
return { name: user.name, email: user.email };
});
defineHandler('log', async (req) => {
console.log('[client]', req.msg);
});
setupMessaging(); // installs the chrome.runtime.onMessage listener

setupMessaging is idempotent — safe to call on every dev-mode HMR reload.

src/ui/popup/index.ts
import { sendMessage } from 'extforge/messaging';
const user = await sendMessage('get-user', { id: 1 });
// ^? { name: string; email: string }
FunctionUse case
sendMessage(route, payload)Send to background SW (or any other context with a registered handler)
sendMessageToTab(tabId, route, payload)Background → content script in a specific tab

Both throw if no handler is registered. Handler exceptions become rejections at the caller with the original error message preserved.

For streaming or many-message conversations, use a port:

// background
import { onPort } from 'extforge/messaging';
onPort<{ tick: number }, void>('clock', (port, sender) => {
let n = 0;
const interval = setInterval(() => port.post({ tick: n++ }), 1000);
port.onMessage(() => {/* receive from the other side */});
port.onDisconnect((reason) => clearInterval(interval));
});
// popup
import { openPort } from 'extforge/messaging';
const port = openPort<void, { tick: number }>('clock');
port.onMessage((msg) => console.log('tick', msg.tick));
port.onDisconnect((reason) => console.warn('clock port closed:', reason));
// later: port.close();
MethodDescription
post(msg)Send a typed message to the other side
onMessage(cb)Subscribe to inbound messages. Returns an unsubscribe fn
onDisconnect(cb)Fires at most once when the underlying chrome port disconnects. Receives chrome.runtime.lastError.message if Chrome set one. Returns an unsubscribe fn
close()Disconnect the underlying port

The wrapper reads chrome.runtime.lastError at the disconnect boundary so Chrome doesn’t log “Unchecked runtime.lastError” warnings into the page console. It also auto-removes all onMessage listeners on disconnect, so the port reference can be garbage-collected without a manual unsubscribe.

Ports survive the SW going to sleep IF the active page keeps the connection open. Chrome documents this lifecycle here.

Every message is wrapped:

{ "__extforge": "msg", "route": "get-user", "payload": { "id": 1 } }

Replies:

{ "__extforge": "ok", "result": { /* res */ } }
{ "__extforge": "err", "error": "stringified Error.message" }

Foreign messages (no __extforge: 'msg') are silently passed through. ExtForge never swallows messages it doesn’t recognize.