trellis
Version:
Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications
217 lines (214 loc) • 5.91 kB
JavaScript
// src/sync/rate-limiter.ts
var RateLimiter = class {
options;
peerBuckets = /* @__PURE__ */ new Map();
constructor(opts = {}) {
this.options = {
maxMessages: 100,
windowMs: 1e3,
...opts
};
}
/**
* Check if a peer is allowed to send a message.
*/
check(peerId) {
const now = Date.now();
const bucket = this.peerBuckets.get(peerId);
if (!bucket || now > bucket.resetTime) {
const resetTime = now + this.options.windowMs;
this.peerBuckets.set(peerId, { count: 1, resetTime });
return { allowed: true, remaining: this.options.maxMessages - 1, resetTime };
}
if (bucket.count >= this.options.maxMessages) {
return { allowed: false, remaining: 0, resetTime: bucket.resetTime };
}
bucket.count++;
return { allowed: true, remaining: this.options.maxMessages - bucket.count, resetTime: bucket.resetTime };
}
/**
* Reset a peer's rate limit bucket.
*/
reset(peerId) {
this.peerBuckets.delete(peerId);
}
/**
* Clear all peer buckets.
*/
clear() {
this.peerBuckets.clear();
}
/**
* Get current state for a peer.
*/
getState(peerId) {
const bucket = this.peerBuckets.get(peerId);
if (!bucket) return null;
const now = Date.now();
if (now > bucket.resetTime) {
return { allowed: true, remaining: this.options.maxMessages - 1, resetTime: now + this.options.windowMs };
}
return {
allowed: bucket.count < this.options.maxMessages,
remaining: Math.max(0, this.options.maxMessages - bucket.count),
resetTime: bucket.resetTime
};
}
};
// src/sync/websocket-transport.ts
var WebSocketTransport = class {
options;
ws = null;
messageHandler = null;
state = {
connected: false,
reconnectAttempts: 0
};
reconnectTimer = null;
rateLimiter;
constructor(opts) {
this.options = {
reconnectDelay: 5e3,
maxReconnectAttempts: Infinity,
maxMessageSize: 10 * 1024 * 1024,
// 10MB default
enforceTls: process.env.TRELLIS_SYNC_ENV === "production",
...opts
};
this.rateLimiter = new RateLimiter(this.options.rateLimiter);
}
/**
* Connect to the sync server.
*/
async connect() {
if (this.ws?.readyState === WebSocket.OPEN) {
return;
}
if (this.options.enforceTls && this.options.url.startsWith("ws://")) {
throw new Error(
"TLS enforcement: ws:// not allowed in production environment. Use wss://"
);
}
return new Promise((resolve, reject) => {
try {
const wsUrl = new URL(this.options.url);
if (this.options.authToken) {
wsUrl.searchParams.set("token", this.options.authToken);
}
this.ws = new WebSocket(wsUrl.toString());
this.ws.onopen = () => {
this.state.connected = true;
this.state.reconnectAttempts = 0;
this.state.lastError = void 0;
resolve();
};
this.ws.onmessage = (event) => {
const messageSize = event.data.length;
if (messageSize > (this.options.maxMessageSize ?? 10 * 1024 * 1024)) {
console.error(
`Message size (${messageSize} bytes) exceeds limit (${this.options.maxMessageSize} bytes)`
);
this.ws?.close();
return;
}
const rateLimit = this.rateLimiter.check("server");
if (!rateLimit.allowed) {
console.error(
`Rate limit exceeded for peer: server. Reset at ${new Date(rateLimit.resetTime).toISOString()}`
);
return;
}
if (this.messageHandler) {
try {
const message = JSON.parse(event.data.toString());
this.messageHandler(message);
} catch (err) {
console.error("Failed to parse sync message:", err);
}
}
};
this.ws.onerror = (error) => {
this.state.lastError = error instanceof Error ? error.message : "Unknown error";
reject(error);
};
this.ws.onclose = () => {
this.state.connected = false;
this.scheduleReconnect();
};
} catch (err) {
reject(err);
}
});
}
/**
* Disconnect from the sync server.
*/
async disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.state.connected = false;
}
/**
* Send a message to the sync server.
*/
async send(_peerId, message) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket not connected");
}
this.ws.send(JSON.stringify(message));
}
/**
* Register a handler for incoming messages.
*/
onMessage(handler) {
this.messageHandler = handler;
}
/**
* List connected peers.
* Note: WebSocket transport connects to a single server, so this returns
* the server as a peer if connected.
*/
peers() {
if (!this.state.connected) {
return [];
}
return [
{
id: "server",
name: "Sync Server",
lastSeen: (/* @__PURE__ */ new Date()).toISOString()
}
];
}
/**
* Get current transport state.
*/
getState() {
return { ...this.state };
}
/**
* Schedule reconnection attempt.
*/
scheduleReconnect() {
if (this.state.reconnectAttempts >= (this.options.maxReconnectAttempts ?? Infinity)) {
console.error("Max reconnection attempts reached");
return;
}
this.reconnectTimer = setTimeout(() => {
this.state.reconnectAttempts++;
this.connect().catch((err) => {
console.error("Reconnection failed:", err);
});
}, this.options.reconnectDelay);
}
};
export {
RateLimiter,
WebSocketTransport
};