Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 19 additions & 43 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions packages/mcp/src/resources/ws-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ServerEvent } from '@relaycast/types';
import type { ServerEvent, WsClientEvent } from '@relaycast/types';
import type { WsClient } from '@relaycast/sdk';
import type { SubscriptionManager } from './subscriptions.js';

Expand Down Expand Up @@ -85,7 +85,12 @@ export class WsBridge {
* Start listening to WebSocket events and dispatching resource notifications.
*/
start(): void {
this.unsubscribeFn = this.wsClient.on('*', (event: ServerEvent) => {
this.unsubscribeFn = this.wsClient.on('*', (event: WsClientEvent) => {
// Filter out client-only events (open, close, error, reconnecting)
// Only process server events that affect resources
if (event.type === 'open' || event.type === 'close' || event.type === 'error' || event.type === 'reconnecting') {
return;
}
const uris = eventToResourceUris(event);
const matched = this.subscriptions.getMatchingSubscriptions(uris);
for (const uri of matched) {
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/hooks/useEvent.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useContext, useEffect, useRef } from 'react';
import type { ServerEvent } from '@relaycast/types';
import type { WsClientEvent } from '@relaycast/types';
import type { EventHandler } from '@relaycast/sdk';
import { ClientContext } from '../context.js';

export function useEvent(eventType: string, handler: EventHandler<ServerEvent>): void {
export function useEvent(eventType: string, handler: EventHandler<WsClientEvent>): void {
const ctx = useContext(ClientContext);
if (!ctx) throw new Error('useEvent must be used within <RelayProvider>');

Expand Down
6 changes: 4 additions & 2 deletions packages/react/src/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo } from 'react';
import { Relay, WsClient } from '@relaycast/sdk';
import type { ServerEvent } from '@relaycast/types';
import { ClientContext, StoreContext } from './context.js';
import { createStore } from './store.js';
import { handleServerEvent } from './reducer.js';
Expand Down Expand Up @@ -43,8 +44,9 @@ export function RelayProvider({ apiKey, agentToken, baseUrl, channels, children

const offAll = ws.on('*', (event) => {
const t = event.type as string;
if (t !== 'pong' && t !== 'open' && t !== 'close') {
handleServerEvent(store, event);
// Filter out client-only events and pong
if (t !== 'pong' && t !== 'open' && t !== 'close' && t !== 'error' && t !== 'reconnecting') {
handleServerEvent(store, event as ServerEvent);
}
});

Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SDK_VERSION } from './index.js';
import { SDK_VERSION } from './version.js';

export interface ClientOptions {
apiKey: string;
Expand Down
3 changes: 1 addition & 2 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export const SDK_VERSION = '0.2.3' as const;

export { SDK_VERSION } from './version.js';
export { Relay } from './relay.js';
export type { RelayOptions } from './relay.js';
export { AgentClient } from './agent.js';
Expand Down
39 changes: 34 additions & 5 deletions packages/sdk/src/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import type {
import { AgentClient } from './agent.js';
import { BillingClient } from './billing.js';
import { HttpClient, RelayError } from './client.js';
import { SDK_VERSION } from './index.js';
import { SDK_VERSION } from './version.js';

export interface RelayOptions {
apiKey: string;
Expand Down Expand Up @@ -58,15 +58,44 @@ export class Relay {
},
body: JSON.stringify({ name }),
});
const parsed = await res.json();

let parsed: unknown;
try {
parsed = await res.json();
} catch (err) {
throw new RelayError(
'invalid_response',
`Failed to parse response as JSON: ${err instanceof Error ? err.message : 'unknown error'}`,
res.status,
);
}

if (typeof parsed !== 'object' || parsed === null || !('ok' in parsed) || typeof parsed.ok !== 'boolean') {
throw new RelayError(
'invalid_response',
'Response is not a valid Relay API response object',
res.status,
);
}

if (!parsed.ok) {
const error = (parsed as { error?: { code?: string; message?: string } }).error;
throw new RelayError(
error?.code ?? 'unknown_error',
error?.message ?? 'Unknown error',
res.status,
);
}

if (!('data' in parsed)) {
throw new RelayError(
parsed.error?.code ?? 'unknown_error',
parsed.error?.message ?? 'Unknown error',
'invalid_response',
'Response is missing required "data" field',
res.status,
);
}
return parsed.data;

return (parsed as { data: CreateWorkspaceResponse }).data;
}

workspace = {
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const SDK_VERSION = '0.2.3' as const;
20 changes: 12 additions & 8 deletions packages/sdk/src/ws.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ServerEvent } from '@relaycast/types';
import type { WsClientEvent, WsOpenEvent, WsErrorEvent, WsReconnectingEvent, WsCloseEvent } from '@relaycast/types';

export type EventHandler<T = ServerEvent> = (event: T) => void;
export type EventHandler<T = WsClientEvent> = (event: T) => void;

export interface WsClientOptions {
token: string;
Expand Down Expand Up @@ -34,12 +34,13 @@ export class WsClient {
this.ws.onopen = () => {
this.reconnectAttempt = 0;
this.startPing();
this.emit('open', { type: 'open' } as unknown as ServerEvent);
const openEvent: WsOpenEvent = { type: 'open' };
this.emit('open', openEvent);
};

this.ws.onmessage = (event: MessageEvent) => {
try {
const data = JSON.parse(String(event.data)) as ServerEvent;
const data = JSON.parse(String(event.data)) as WsClientEvent;
this.emit(data.type, data);
} catch {
// ignore malformed messages
Expand All @@ -53,11 +54,13 @@ export class WsClient {
if (!this.closed) {
this.scheduleReconnect();
}
this.emit('close', { type: 'close' } as unknown as ServerEvent);
const closeEvent: WsCloseEvent = { type: 'close' };
this.emit('close', closeEvent);
};

this.ws.onerror = () => {
this.emit('error', { type: 'error' } as unknown as ServerEvent);
const errorEvent: WsErrorEvent = { type: 'error' };
this.emit('error', errorEvent);
};
}

Expand Down Expand Up @@ -96,7 +99,7 @@ export class WsClient {
this.handlers.get(event)?.delete(handler);
}

private emit(event: string, data: ServerEvent): void {
private emit(event: string, data: WsClientEvent): void {
this.handlers.get(event)?.forEach((h) => h(data));
this.handlers.get('*')?.forEach((h) => h(data));
}
Expand Down Expand Up @@ -125,7 +128,8 @@ export class WsClient {
if (this.reconnectAttempt >= this.maxReconnectAttempts) return;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30_000);
this.reconnectAttempt++;
this.emit('reconnecting', { type: 'reconnecting', attempt: this.reconnectAttempt } as unknown as ServerEvent);
const reconnectingEvent: WsReconnectingEvent = { type: 'reconnecting', attempt: this.reconnectAttempt };
this.emit('reconnecting', reconnectingEvent);
this.reconnectTimer = setTimeout(() => {
this.connect();
}, delay);
Expand Down
23 changes: 23 additions & 0 deletions packages/types/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,24 @@ export interface CommandInvokedEvent {
parameters: Record<string, unknown> | null;
}

// WebSocket client events (emitted by WsClient, not from server)
export interface WsOpenEvent {
type: 'open';
}

export interface WsErrorEvent {
type: 'error';
}

export interface WsReconnectingEvent {
type: 'reconnecting';
attempt: number;
}

export interface WsCloseEvent {
type: 'close';
}

export type ServerEvent =
| MessageCreatedEvent
| MessageUpdatedEvent
Expand All @@ -162,3 +180,8 @@ export type ServerEvent =

export type ServerEventType = ServerEvent['type'];
export type ClientEventType = ClientEvent['type'];

// Union of all events that WsClient can emit (includes server events + client-only events)
export type WsClientEvent = ServerEvent | WsOpenEvent | WsErrorEvent | WsReconnectingEvent | WsCloseEvent;
export type WsClientEventType = WsClientEvent['type'];