Aller au contenu principal

Sync API Reference

The sync API enables bidirectional state synchronization between jods stores over socket-like connections such as WebSockets, BroadcastChannel, or postMessage.

Basic Usage

import { store, sync } from "jods";

// Create a store
const myStore = store({
count: 0,
text: "Hello World",
user: {
name: "User",
preferences: {
theme: "light",
},
},
});

// Connect to a WebSocket
const socket = new WebSocket("wss://example.com");

// Start syncing store with the socket
const stopSync = sync(socket, myStore);

// Later, to stop syncing
stopSync();

API Signature

function sync<T extends StoreState>(
socket: SyncSocket,
store: T,
options?: SyncOptions<T>
): () => void;

Parameters

  • socket (SyncSocket): A socket-like object with messaging capabilities
  • store (T): A jods store created with the store function
  • options (SyncOptions<T>): Optional configuration options
  • Returns: A function that stops syncing and removes all listeners

Type Definitions

SyncSocket

Any object that provides the following methods:

interface SyncSocket {
// Send a message as a string
send: (msg: string) => void;

// Optional onmessage handler
onmessage?: ((event: { data: string }) => void) | null;

// Optional addEventListener/removeEventListener methods
addEventListener?: (
type: "message",
fn: (event: { data: string }) => void
) => void;

removeEventListener?: (
type: "message",
fn: (event: { data: string }) => void
) => void;

// Optional readyState property (like WebSocket)
readyState?: number;
}

This interface is compatible with:

  • WebSocket
  • BroadcastChannel
  • Worker messaging
  • iframe postMessage (with an adapter)
  • Custom transport mechanisms

SyncOptions

interface SyncOptions<T extends StoreState> {
/**
* Number of milliseconds to wait before sending updates (throttling)
* @default 100
*/
throttleMs?: number;

/**
* Prefix for message type, useful for multiplexing multiple stores on one socket
* @default "jods-sync"
*/
prefix?: string;

/**
* Filter function to selectively sync parts of the store
* Return true to allow changes, false to block them
*/
filter?: (changes: Changes<T>) => boolean;

/**
* Called when a diff is sent to the remote
*/
onDiffSend?: (message: SyncMessage<T>) => void;

/**
* Called when a patch is received from the remote
* Return modified patch or null to prevent applying
*/
onPatchReceive?: (message: SyncMessage<T>) => SyncMessage<T> | null;

/**
* Error handler for sync operations
*/
onError?: (err: Error) => void;

/**
* Only receive updates, never send local changes
* @default false
*/
receiveOnly?: boolean;

/**
* Array of top-level keys that are allowed to be synced (for security)
* If provided, only these properties will be synced
*/
allowKeys?: Array<keyof T>;

/**
* Array of nested property paths that are allowed to be synced
* Examples: ["user.settings", "preferences.*"]
*/
allowPaths?: string[];

/**
* Array of property paths that should never be synced
* Takes precedence over allowKeys and allowPaths
* Examples: ["user.apiKey", "secrets"]
*/
sensitiveKeys?: string[];

/**
* Maximum size of messages in bytes
* @default 1048576 (1MB)
*/
maxMessageSize?: number;

/**
* Schema to validate incoming patches
* Must have a parse() method (like Zod schemas)
*/
validateSchema?: {
parse: (data: any) => any;
};
}

SyncMessage

The message format used by the sync API:

interface SyncMessage<T extends StoreState> {
/** Unique ID for the client that sent the message */
clientId: string;

/** Changes to apply to the store */
changes: Changes<T>;

/** Optional message namespace/channel */
prefix?: string;

/** Optional timestamp for debugging */
timestamp?: number;
}

Options Reference

Security Options

allowKeys

Limits which top-level properties can be synced:

// Only sync these properties
const stopSync = sync(socket, store, {
allowKeys: ["messages", "users", "status"],
});

allowPaths

Controls which nested properties can be synced:

// Only sync these nested paths
const stopSync = sync(socket, store, {
allowPaths: [
"user.profile", // Allow all of user.profile
"settings.theme", // Allow only settings.theme
"messages.*", // Allow all properties under messages
],
});

sensitiveKeys

Prevents specific properties from being synced:

// Never sync these properties, even if they're under allowKeys
const stopSync = sync(socket, store, {
allowKeys: ["user", "settings"],
sensitiveKeys: ["user.apiKey", "user.password", "settings.authToken"],
});

validateSchema

Validates incoming data against a schema:

import { z } from "zod";
// or import { j } from 'jods';

const schema = z.object({
messages: z.array(
z.object({
id: z.string(),
text: z.string(),
user: z.string(),
})
),
users: z.array(
z.object({
id: z.string(),
name: z.string(),
})
),
});

const stopSync = sync(socket, store, {
validateSchema: schema,
});

Performance Options

throttleMs

Controls how frequently updates are sent:

const stopSync = sync(socket, store, {
throttleMs: 300, // Send at most one update every 300ms
});

maxMessageSize

Prevents transmitting excessively large messages:

const stopSync = sync(socket, store, {
maxMessageSize: 100 * 1024, // 100KB max
});

Event Handlers

onDiffSend

Called before sending changes to the remote:

const stopSync = sync(socket, store, {
onDiffSend: (message) => {
console.log("Sending changes:", message.changes);
// Optionally modify message
},
});

onPatchReceive

Called when receiving changes, before applying them:

const stopSync = sync(socket, store, {
onPatchReceive: (message) => {
console.log("Received changes:", message.changes);

// Optionally modify the message
if (message.changes.count < 0) {
message.changes.count = 0; // Enforce non-negative count
}

return message; // Return modified message

// Or return null to prevent applying the changes
// if (hasInvalidData(message.changes)) return null;
},
});

onError

Called when synchronization encounters an error:

const stopSync = sync(socket, store, {
onError: (error) => {
console.error("Sync error:", error);
store.status = "error";
},
});

Special Options

prefix

Multiplexes multiple stores over the same socket:

// Same socket, different prefixes
const stopUserSync = sync(socket, userStore, {
prefix: "users",
});

const stopMessageSync = sync(socket, messageStore, {
prefix: "messages",
});

receiveOnly

Creates a read-only sync that never sends changes:

// Only receive updates, never send
const stopSync = sync(socket, store, {
receiveOnly: true,
});

filter

Provides custom filtering logic:

const stopSync = sync(socket, store, {
filter: (changes) => {
// Only sync if certain conditions are met
if (changes.hugeArray && changes.hugeArray.length > 1000) {
return false; // Don't sync huge arrays
}
return true; // Sync everything else
},
});

Example: Secure Chat Application

import { store, sync } from "jods";

// Create chat store
const chatStore = store({
messages: [],
users: [],
privateNotes: {}, // Should not be synced
authToken: "", // Should not be synced
status: "disconnected",
});

// Connect to chat server
const socket = new WebSocket("wss://chat.example.com");

// Start secure sync
const stopSync = sync(socket, chatStore, {
// Only sync messages, users and status
allowKeys: ["messages", "users", "status"],

// Never sync these sensitive paths
sensitiveKeys: ["messages.*.privateFlag", "users.*.email"],

// Limit update frequency
throttleMs: 300,

// Handle connection errors
onError: (err) => {
console.error("Sync error:", err);
chatStore.status = "error";
},

// Filter outgoing messages
onDiffSend: (message) => {
// Log outgoing data
console.log("Syncing:", Object.keys(message.changes));
},

// Validate incoming data
onPatchReceive: (message) => {
// Sanitize incoming messages
if (message.changes.messages) {
message.changes.messages.forEach((msg) => {
// Remove any HTML to prevent XSS
if (msg.text) {
msg.text = msg.text.replace(/<[^>]*>/g, "");
}
});
}
return message;
},
});

// Update store - changes will be synced
chatStore.status = "connected";
chatStore.messages.push({
id: 1,
text: "Hello!",
timestamp: Date.now(),
});

// Won't be synced (not in allowKeys)
chatStore.privateNotes = { todo: "Remember to buy milk" };
chatStore.authToken = "jwt-token-123";

// Clean up when done
stopSync();
socket.close();

Transport Adapters

Socket.io Adapter

function createSocketIOAdapter(socket) {
return {
send: (message) => {
socket.emit("jods-sync", JSON.parse(message));
},
addEventListener: (type, listener) => {
if (type === "message") {
socket.on("jods-sync", (data) => {
listener({ data: JSON.stringify(data) });
});
}
},
removeEventListener: (type) => {
if (type === "message") {
socket.off("jods-sync");
}
},
};
}

// Usage
import io from "socket.io-client";
const socket = io("https://example.com");
const adapter = createSocketIOAdapter(socket);
const stopSync = sync(adapter, store);

iframe postMessage Adapter

function createIframeAdapter(iframe) {
const handler = (event) => {
if (
event.source === iframe.contentWindow &&
event.data &&
event.data.type === "jods-sync"
) {
if (adapter.onmessage) {
adapter.onmessage({ data: JSON.stringify(event.data.payload) });
}

const listeners = adapter.eventListeners?.get("message") || [];
listeners.forEach((fn) =>
fn({ data: JSON.stringify(event.data.payload) })
);
}
};

window.addEventListener("message", handler);

const adapter = {
eventListeners: new Map([["message", []]]),
onmessage: null,

send: (message) => {
iframe.contentWindow.postMessage(
{
type: "jods-sync",
payload: JSON.parse(message),
},
"*"
);
},

addEventListener: (type, fn) => {
if (type === "message") {
if (!adapter.eventListeners.has(type)) {
adapter.eventListeners.set(type, []);
}
adapter.eventListeners.get(type).push(fn);
}
},

removeEventListener: (type, fn) => {
if (type === "message" && adapter.eventListeners.has(type)) {
const listeners = adapter.eventListeners.get(type);
const index = listeners.indexOf(fn);
if (index !== -1) {
listeners.splice(index, 1);
}
}
},

close: () => {
window.removeEventListener("message", handler);
adapter.eventListeners.clear();
},
};

return adapter;
}

// Usage
const iframe = document.getElementById("my-iframe");
const adapter = createIframeAdapter(iframe);
const stopSync = sync(adapter, store);