Message Protocol
All cross-context communication in skeeditor (content script ↔ background, popup ↔ background) goes through browser.runtime.sendMessage.
Typed message system (src/shared/messages.ts)
The module exports:
- A
MessageRequestdiscriminated union — all valid message shapes. - A
ResponseFor<T>conditional type — maps each request variant to its response type. - A
sendMessage<T>(request: T): Promise<ResponseFor<T>>helper that wrapsbrowser.runtime.sendMessagewith correct TypeScript inference.
TypeScript enforces the request/response contract at compile time. You cannot call sendMessage with a valid request type and receive the wrong response type.
Usage
Popup or Web Component
ts
import { sendMessage } from '@src/shared/messages';
// Check auth status
const status = await sendMessage({ type: 'AUTH_GET_STATUS' });
if (status.authenticated) {
console.log('Signed in as', status.did);
}
// Trigger sign-in
await sendMessage({ type: 'AUTH_SIGN_IN' });
// Sign out
await sendMessage({ type: 'AUTH_SIGN_OUT' });Content script
ts
import { sendMessage } from '@src/shared/messages';
// Fetch a post record
const response = await sendMessage({
type: 'GET_RECORD',
repo: 'did:plc:alice',
collection: 'app.bsky.feed.post',
rkey: '3jxyz',
});
if ('error' in response) {
console.error('Fetch failed:', response.error);
} else {
console.log('Got record, CID:', response.cid);
}
// Save edited record with optimistic concurrency
const result = await sendMessage({
type: 'PUT_RECORD',
repo: 'did:plc:alice',
collection: 'app.bsky.feed.post',
rkey: '3jxyz',
record: { $type: 'app.bsky.feed.post', text: 'edited text', facets: [...] },
swapRecord: response.cid, // reject if record changed on server
});
switch (result.type) {
case 'PUT_RECORD_SUCCESS':
console.log('Saved at', result.uri, '— new CID:', result.cid);
break;
case 'PUT_RECORD_CONFLICT':
// Offer the user a merge or retry UI
break;
case 'PUT_RECORD_ERROR':
console.error('Save failed:', result.message);
break;
}Message catalogue
Request type | Payload fields | Response type |
|---|---|---|
AUTH_SIGN_IN | — | { ok: true } |
AUTH_SIGN_OUT | — | { ok: true } |
AUTH_REAUTHORIZE | — | { ok: true } |
AUTH_GET_STATUS | — | { authenticated: false } or { authenticated: true, did, expiresAt } |
GET_RECORD | repo, collection, rkey | { value, cid } or { error } |
PUT_RECORD | repo, collection, rkey, record, swapRecord? | See PUT_RECORD responses below |
PUT_RECORD response shapes
ts
// Write accepted by the PDS
{ type: 'PUT_RECORD_SUCCESS'; uri: string; cid: string }
// swapRecord CID did not match the current server CID (HTTP 409)
{ type: 'PUT_RECORD_CONFLICT'; error: PutRecordWithSwapError; conflict?: PutRecordConflictDetails }
// Auth failure, validation error, or unexpected XRPC error
{ type: 'PUT_RECORD_ERROR'; message: string }PutRecordConflictDetails is populated when the PDS returns the current record in the 409 error body:
ts
interface PutRecordConflictDetails {
currentCid: string;
currentValue: Record<string, unknown>;
}Payload validation
The background message router validates all incoming payloads before any XRPC or auth logic runs:
GET_RECORD:repo,collection, andrkeymust be non-empty strings.PUT_RECORD: same string checks, plusrecordmust be a non-null object with a non-empty$typestring.
Invalid payloads return an error response immediately without touching the network.
Adding a new message type
- Add a request interface (e.g.
MyNewRequest) and response type tosrc/shared/messages.ts. - Extend the
MessageRequestunion to includeMyNewRequest. - Add a branch to the
ResponseFor<T>conditional type:T extends MyNewRequest ? MyNewResponse : .... - Add a payload validator function in
src/background/message-router.ts(isValidMyNewPayload). - Add a
case 'MY_NEW_TYPE':branch in the message router switch that calls the validator and invokes the appropriate logic. - Write unit tests for: happy path, unauthenticated path, and all invalid payload shapes.