Why a Custom RPC Layer?
Plasmo ships with a solid messaging API (@plasmohq/messaging) that covers most internal extension communication — popup to background, options page to background, and so on. But when you are building a Web3 wallet or any extension that exposes an API to external web pages, you hit a wall: dApp pages cannot use sendToBackground(). They live in a completely different execution context.
The solution is a custom JSON-RPC 2.0 layer that bridges three process boundaries:
- The dApp page (untrusted web context)
- The content script (runs in the page's tab but with extension privileges)
- The background service worker (the extension's brain)
This is exactly how production Solana wallets like Cilantro handle wallet standard methods — connect, signTransaction, signMessage, and more.
If you are new to Plasmo, start with our Get Started with Plasmo tutorial first.
Architecture Overview
Here is the full picture of how messages flow through the extension:
┌─────────────┐ window.postMessage ┌─────────────────┐ chrome.runtime.Port ┌──────────────┐
│ dApp Page │ ────────────────────> │ Content Script │ ────────────────────> │ Background │
│ (injected) │ <──────────────────── │ (bridge) │ <──────────────────── │ Service │
└─────────────┘ WindowTransport └─────────────────┘ PortTransport │ Worker │
└──────┬───────┘
│
chrome.runtime.Port│
PortTransport │
│
┌──────┴───────┐
│ Popup UI │
│ (approval) │
└──────────────┘
There are two independent communication mechanisms at play:
- JSON-RPC 2.0 (custom, built on
jayson) — for dApp-to-wallet communication across page / content script / background boundaries - Plasmo Messaging (
@plasmohq/messaging) — for internal UI-to-background communication (popup fetching balances, sending tokens, etc.)
Prerequisites
- A working Plasmo project (see Get Started with Plasmo)
- Node.js 18+
- TypeScript
jaysonlibrary for JSON-RPC 2.0
npm install jaysonProject Structure
my-wallet-extension/
├── src/
│ ├── background/
│ │ ├── index.ts # Service worker — exposes RPC methods
│ │ └── messages/
│ │ └── sendToken.ts # Plasmo message handler
│ ├── contents/
│ │ ├── index.ts # Content script — the bridge
│ │ └── window.ts # Injected page script — RPC client
│ ├── messages/
│ │ ├── transport.ts # Transport interface + factories
│ │ ├── serialization.ts # Uint8Array <-> base64 codec
│ │ ├── rpc.ts # RPC engine (callMethod / exposeMethod)
│ │ ├── ports.ts # Port name constants
│ │ └── index.ts # Barrel export
│ ├── constants/
│ │ └── index.ts # RPC method name constants
│ ├── utils/
│ │ └── rpcErrorHandler.ts # Standardized error codes
│ ├── views/
│ │ └── approval/
│ │ └── ConnectApproval.tsx # Popup approval screen
│ ├── rpc/
│ │ └── index.ts # Popup RPC instance
│ └── popup.tsx # Popup entry point
├── package.json
└── tsconfig.json
Step 1: The Transport Layer
The transport is the lowest abstraction. It wraps whatever messaging channel you are crossing — window.postMessage or chrome.runtime.Port — behind a uniform interface.
The Interface
// src/messages/transport.ts
export type Callback = (data: any) => void;
export interface Transport {
addListener: (listener: Callback) => void;
removeListener: (listener: Callback) => void;
write: (data: any) => void;
}Three methods, no more. Any messaging channel that can send and receive arbitrary data can implement this.
PortTransport — Chrome Runtime Ports
Use this for content script ↔ background and popup ↔ background communication:
// src/messages/transport.ts
export function createPortTransport(port: chrome.runtime.Port): Transport {
const listeners: Set<Callback> = new Set();
port.onMessage.addListener((message) => {
listeners.forEach((listener) => listener(message));
});
return {
addListener(listener: Callback) {
listeners.add(listener);
WindowTransport — window.postMessage
Use this for injected page script ↔ content script communication:
// src/messages/transport.ts
export function createWindowTransport(targetWindow: Window): Transport {
const listeners: Set<Callback> = new Set();
const messageHandler = (event: MessageEvent) => {
if (event.source !== targetWindow) return;
listeners.forEach((listener) => listener(event.data));
};
targetWindow.addEventListener("message", messageHandler
The WindowTransport uses "*" as the target origin for simplicity. In production, validate event.origin against a whitelist to prevent cross-origin attacks.
Step 2: Serialization
Solana operations pass Uint8Array values everywhere — public keys, transaction bytes, signatures. Standard JSON.stringify drops these into {}. We need a custom serializer.
// src/messages/serialization.ts
function replacer(_key: string, value: any): any {
if (value instanceof Uint8Array) {
return {
type: "Bytes",
data: Buffer.from(value).toString("base64"),
};
}
return value;
}
function reviver(_key: string, value: any): any {
if (value && value
The serializer is transparent — you never call it directly. The RPC engine uses it internally for every message that crosses a transport boundary.
This pattern works for any binary data, not just Solana. If your extension handles file uploads, crypto operations, or raw buffers, the same base64 codec applies.
Step 3: The RPC Engine
This is the core abstraction. It wraps a Transport into a JSON-RPC 2.0 client/server that can both call remote methods and expose local ones.
// src/messages/rpc.ts
import jayson from "jayson/lib/client/browser";
import { stringify, parse } from "./serialization";
import type { Transport } from "./transport";
export interface RPC {
callMethod: (method: string, params?: any[]) => Promise<any>;
exposeMethod: (method: string, listener: MethodCallback) => void;
end: () => void;
}
Usage at a Glance
Calling a remote method (client side):
const result = await rpc.callMethod("connect", [origin, options]);Exposing a method (server side):
rpc.exposeMethod("connect", async (params) => {
const [origin, options] = params;
// ... handle connection request ...
return publicKey;
});Cleaning up:
rpc.end(); // removes all listeners, prevents memory leaksStep 4: Port Names and Method Constants
Keep all port names and RPC method names in a single constants file. This prevents typos and gives you a single source of truth.
Port Names
// src/messages/ports.ts
export const CONTENT_PORT_NAME = "content";
export const POPUP_PORT_NAME = "popup";RPC Method Constants
// src/constants/index.ts
export const CONNECT_METHOD = "connect";
export const DISCONNECT_METHOD = "disconnect";
export const SIGN_TRANSACTION_METHOD = "signTransaction";
export const SIGN_ALL_TRANSACTIONS_METHOD = "signAllTransactions";
export const SIGN_MESSAGE_METHOD = "signMessage";
export const SIGN_AND_SEND_TRANSACTION_METHOD = "signAndSendTransaction";
export const SIGN_IN_METHOD = "signIn";
// Response keys for popup-mediated flows
export const RESPONSE = {
CONNECT: "connect_response",
SIGN_TRANSACTION: "signTransaction_response",
Step 5: The Content Script Bridge
The content script is the glue between the dApp's page context and the background service worker. It does not contain business logic — it simply relays messages bidirectionally.
// src/contents/index.ts
import type { PlasmoCSConfig } from "plasmo";
import { CONTENT_PORT_NAME } from "~messages/ports";
export const config: PlasmoCSConfig = {
matches: ["<all_urls>"],
run_at: "document_start",
};
const port = chrome.runtime.connect({ name: CONTENT_PORT_NAME });
// Relay page messages → background
window.addEventListener("message", (event) => {
if (event.
Set run_at: "document_start" so the content script is ready before any dApp code executes. Otherwise, early wallet detection calls will be missed.
Step 6: The Injected Page Script (RPC Client)
The injected script runs in the page's own context — it can modify window and expose a global API that dApps use. Plasmo's world: "MAIN" config makes this seamless.
// src/contents/window.ts
import type { PlasmoCSConfig } from "plasmo";
import { createWindowTransport } from "~messages/transport";
import { createRPC } from "~messages/rpc";
import {
CONNECT_METHOD,
DISCONNECT_METHOD,
SIGN_TRANSACTION_METHOD,
SIGN_MESSAGE_METHOD,
} from "~constants";
export const config: PlasmoCSConfig = {
matches: ["<all_urls>"],
world: "MAIN",
run_at: "document_start",
};
For Solana Wallet Standard compliance, you would register the provider using @wallet-standard/wallet instead of assigning to window. The RPC layer underneath stays identical.
Step 7: Background Service Worker (RPC Server)
The background is where all the real logic lives. It receives RPC calls from the content script bridge, processes them (often by opening a popup for user approval), and sends back results.
// src/background/index.ts
import { createPortTransport } from "~messages/transport";
import { createRPC } from "~messages/rpc";
import { CONTENT_PORT_NAME, POPUP_PORT_NAME } from "~messages/ports";
import {
CONNECT_METHOD,
SIGN_TRANSACTION_METHOD,
SIGN_MESSAGE_METHOD,
} from "~constants";
import { createRpcError, RpcErrorCode } from "~utils/rpcErrorHandler";
let popupRPC: ReturnType<typeof createRPC> | null = null;
chrome.
The Key Insight: Two-Way RPC
Notice that the background both exposes methods (for dApp requests via the content port) and calls methods (on the popup port). This bidirectional pattern is what enables user-approval flows:
- dApp calls
connect→ background receives it - Background opens popup and calls
connecton the popup's RPC - Popup shows approval screen, user clicks "Approve"
- Popup resolves the promise → background gets the result → dApp gets the public key
Step 8: Popup Approval (Exposing Methods from the UI)
The popup creates its own RPC connection to the background and exposes methods that the background can call to request user input.
Popup RPC Instance
// src/rpc/index.ts
import { createPortTransport } from "~messages/transport";
import { createRPC } from "~messages/rpc";
import { POPUP_PORT_NAME } from "~messages/ports";
const port = chrome.runtime.connect({ name: POPUP_PORT_NAME });
const transport = createPortTransport(port);
export const popupRPC = createRPC(transport);Approval Screen Component
// src/views/approval/ConnectApproval.tsx
import { useEffect, useState } from "react";
import { popupRPC } from "~rpc";
import { CONNECT_METHOD } from "~constants";
export function ConnectApproval() {
const [origin, setOrigin] = useState<string>("");
const [resolver, setResolver] = useState<{
approve: (key: string) => void;
deny: () => void;
Step 9: Plasmo Messaging for Internal IPC
Not everything needs the custom RPC layer. For internal operations — fetching balances, sending tokens, updating settings — Plasmo's built-in messaging is simpler and better suited.
When to Use Which
| Scenario | Use |
|---|---|
dApp calls connect() | Custom JSON-RPC |
dApp calls signTransaction() | Custom JSON-RPC |
| Popup fetches SOL balance | Plasmo Messaging |
| Popup sends a token transfer | Plasmo Messaging |
| Options page updates settings | Plasmo Messaging |
Background Message Handler
// src/background/messages/getBalance.ts
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { Connection, PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js";
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
const { publicKey, rpcUrl } = req.body;
const connection = new Connection(rpcUrl);
const balance = await connection.getBalance(new PublicKey(
Calling from the Popup
import { sendToBackground } from "@plasmohq/messaging";
async function fetchBalance(publicKey: string) {
const response = await sendToBackground({
name: "getBalance",
body: {
publicKey,
rpcUrl: "https://api.mainnet-beta.solana.com",
},
});
return response.balance;
}Plasmo's messaging is file-name based — the handler file name (getBalance.ts) becomes the message name. No manual routing needed.
Step 10: Error Handling
Standardize error codes across the entire RPC layer so dApps get predictable, actionable error responses.
// src/utils/rpcErrorHandler.ts
export enum RpcErrorCode {
USER_REJECTED = 4001,
TIMEOUT = 4002,
INVALID_INPUT = 4003,
UNAUTHORIZED = 4100,
INTERNAL_ERROR = -32603,
NETWORK_ERROR = -32002,
UNKNOWN_ERROR = -32000,
}
interface RpcError {
code: number;
message: string;
}
export function createRpcError(
message: string,
code
Error codes 4001 and 4100 follow the EIP-1193 provider error standard, which Solana wallets commonly adopt. Using standard codes ensures compatibility with dApp error handling libraries.
Step 11: Adding a New RPC Method
Follow these five steps every time you need to add a new method:
1. Define the constant:
// src/constants/index.ts
export const OPEN_SWAP_METHOD = "openSwap";2. Add a response key (if popup approval is needed):
export const RESPONSE = {
// ...existing
OPEN_SWAP: "openSwap_response",
};3. Expose in the background:
// src/background/index.ts — inside the CONTENT_PORT_NAME block
rpc.exposeMethod(OPEN_SWAP_METHOD, async (params) => {
const [origin, swapParams] = params;
// Open swap UI, wait for confirmation
return { txSignature: "..." };
});4. Call from the wallet implementation:
// src/contents/window.ts — inside WalletProvider
async openSwap(params: SwapParams) {
const origin = window.location.origin;
return await rpc.callMethod(OPEN_SWAP_METHOD, [origin, params]);
}5. If popup is involved, expose the method in the popup view:
popupRPC.exposeMethod(OPEN_SWAP_METHOD, async (params) => {
return new Promise((resolve, reject) => {
// Show swap confirmation UI
// resolve(result) on approve, reject() on deny
});
});Step 12: Best Practices
Always Clean Up Ports
Call rpc.end() on every port.onDisconnect event. Failing to do this causes memory leaks, especially in long-running background service workers:
port.onDisconnect.addListener(() => {
rpc.end();
});Validate Origins
Never blindly trust the origin parameter. Maintain an allowlist of connected dApps and verify every incoming request:
rpc.exposeMethod(SIGN_TRANSACTION_METHOD, async (params) => {
const [origin, transaction] = params;
const isConnected = await checkConnection(origin);
if (!isConnected) {
throw createRpcError("Not connected", RpcErrorCode.UNAUTHORIZED);
}
// proceed...
});Use Timeouts
Every popup-mediated flow should have a timeout. Users can close the popup, navigate away, or simply forget about it:
const APPROVAL_TIMEOUT = 60_000; // 1 minute
const result = await Promise.race([
popupRPC.callMethod(method, params),
new Promise((_, reject) =>
setTimeout(
() => reject(createRpcError("Timed out", RpcErrorCode.TIMEOUT)),
APPROVAL_TIMEOUT
)
),
]);Keep Methods Idempotent
If a dApp sends connect twice, the second call should return the cached public key rather than opening a new popup. This prevents UI spam and improves the user experience.
Separate Port Names
Never reuse port names between the content script and popup connections. The background uses the port name to determine which RPC methods to expose:
if (port.name === CONTENT_PORT_NAME) {
// dApp-facing methods
}
if (port.name === POPUP_PORT_NAME) {
// popup-facing methods (approval flows)
}Wrapping Up
Building a custom RPC layer for a Plasmo extension might seem like over-engineering at first, but it pays off immediately once your extension needs to interact with external web pages. The architecture we covered — transports, serialization, a JSON-RPC engine, and clean separation between dApp-facing and internal messaging — scales from a simple wallet connector to a full-featured Web3 wallet.
The key takeaways:
- Transport abstraction makes it trivial to swap the underlying messaging channel
- JSON-RPC 2.0 gives you a standard protocol with request IDs, error codes, and bidirectional communication
- Plasmo Messaging handles internal IPC without the ceremony of the full RPC stack
- Bidirectional popup RPC enables clean user-approval flows without callback hell
This is the same architecture used in production Solana wallets handling thousands of daily transactions. Start with the transport layer, build up to the RPC engine, and add methods as your extension grows.