jevstrudel.git / website / src / repl / useWebSocketMCP.jsx
useWebSocketMCP.jsx170 lines · 6.1 KB · raw
1/*
2useWebSocketMCP.jsx - MCP integration with the Strudel REPL
3
4jevstrudel: the tab holds a WebSocket to the MCP hub in the jevstrudel
5Worker (worker/src/hub.ts), which exists only under `wrangler dev --env dev`
6(started by `buck2 run //:dev`). The tab probes /strudel/status first: on
7the public site the Worker has no hub and answers 404, so MCP stays off (no
8session, no badge). In the dev server the probe repeats until the Worker is
9up, and the socket reconnects whenever the Worker reloads.
10
11The same session id also names this tab in its signed-in listener's own
12hub, everywhere, production included (jev/myTabs.mjs): the hosted MCP for
13everyone's own AI, whose code plays only in the sandbox. `mine` is that
14connection's state: 'off' (signed out), 'connecting' or 'connected'.
15*/
16
17import { useEffect, useState } from 'react';
18import { getAudioContext } from '@strudel/webaudio';
19import { joinMyTabs } from '../jev/myTabs.mjs';
20import { setOwnCode } from '../jev/sandbox.mjs';
21import { TAB_TOOLS } from '../jev/tabTools.mjs';
22
23const generateSessionId = () => {
24  const letters = Math.random().toString(36).substring(2, 5);
25  const digit = Math.floor(Math.random() * 10);
26  return letters + digit;
27};
28
29// What the tab has logged since the last play, for get_logs: Strudel's
30// logger dispatches every message as a `strudel.log` event, and uncaught
31// errors are added. Consecutive repeats fold into one line with a count.
32const LOG_LIMIT = 1000;
33let logs = [];
34let logStart = typeof performance !== 'undefined' ? performance.now() : 0;
35function record(message) {
36  const last = logs.at(-1);
37  if (last?.message === message) {
38    last.count++;
39    return;
40  }
41  logs.push({ at: (performance.now() - logStart) / 1000, message, count: 1 });
42  if (logs.length > LOG_LIMIT) logs = logs.slice(-LOG_LIMIT);
43}
44function clearLogs() {
45  logs = [];
46  logStart = performance.now();
47}
48
49async function hubIsUp() {
50  try {
51    const res = await fetch('/strudel/status');
52    const status = res.ok ? await res.json() : null;
53    return Array.isArray(status?.active_sessions);
54  } catch {
55    return false;
56  }
57}
58
59async function run(editor, command) {
60  if (!editor) return { error: 'the editor is not ready' };
61  switch (command.type) {
62    case 'play': {
63      // Evaluation waits for the tab's first click (browsers keep audio off
64      // until then), so say so rather than hang until the hub times out.
65      if (getAudioContext().state !== 'running') {
66        return { error: 'this tab has not been clicked yet, so its audio is off; click the page once' };
67      }
68      // Songs count cycles from 0, and only stop() resets the count.
69      await editor.stop();
70      clearLogs();
71      // Claude's code, in the dev server only: the page's own
72      setOwnCode(editor, command.code);
73      await editor.evaluate();
74      const err = editor.repl.state.evalError;
75      return err ? { error: err.message ?? String(err) } : { ok: true };
76    }
77    case 'stop':
78      await editor.stop();
79      return { ok: true };
80    case 'get-code':
81      return { code: editor.code };
82    case 'get-logs':
83      return { logs };
84    default: {
85      // the tools both MCPs share (jev/tabTools.mjs), loaded on first use
86      if (!TAB_TOOLS.has(command.type)) return { error: `unknown command ${command.type}` };
87      if (command.type === 'play-song' || (command.type === 'ask-song' && command.play)) clearLogs();
88      const { runTabTool } = await import('../jev/tabCommands.mjs');
89      return runTabTool(editor, command);
90    }
91  }
92}
93
94export function useWebSocketMCP(editorRef) {
95  // `?session=name` pins the id, so it survives reloads (the headless tab
96  // Claude checks songs in uses ?session=probe); otherwise it is random.
97  const [sessionId] = useState(() =>
98    typeof window === 'undefined'
99      ? ''
100      : (new URLSearchParams(window.location.search).get('session') ?? generateSessionId()),
101  );
102  const [mcpAvailable, setMcpAvailable] = useState(false);
103  const [connectionStatus, setConnectionStatus] = useState('disconnected');
104  const [mine, setMine] = useState('off');
105
106  // the signed-in listener's own hub (jev/myTabs.mjs)
107  useEffect(() => joinMyTabs(editorRef, sessionId, setMine), []);
108
109  useEffect(() => {
110    let closed = false;
111    let socket;
112    let retry;
113
114    const onLog = (e) => record(String(e.detail?.message ?? ''));
115    const onError = (e) => record(`[uncaught] ${e.message ?? e.reason?.message ?? e.reason}`);
116    document.addEventListener('strudel.log', onLog);
117    window.addEventListener('error', onError);
118    window.addEventListener('unhandledrejection', onError);
119
120    const connect = () => {
121      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
122      socket = new WebSocket(`${protocol}//${window.location.host}/strudel/ws?session_id=${sessionId}`);
123      socket.onopen = () => setConnectionStatus('connected');
124      socket.onmessage = async (event) => {
125        let message;
126        try {
127          message = JSON.parse(event.data);
128        } catch {
129          return;
130        }
131        if (message.type !== 'command') return;
132        let reply;
133        try {
134          reply = await run(editorRef.current, message.command);
135        } catch (e) {
136          reply = { error: e.message ?? String(e) };
137        }
138        socket.send(JSON.stringify({ type: 'reply', id: message.id, ...reply }));
139      };
140      socket.onclose = () => {
141        setConnectionStatus('disconnected');
142        if (!closed) retry = setTimeout(connect, 2000);
143      };
144      socket.onerror = () => setConnectionStatus('error');
145    };
146
147    const probe = async () => {
148      if (closed) return;
149      if (await hubIsUp()) {
150        setMcpAvailable(true);
151        connect();
152      } else if (import.meta.env.DEV) {
153        // the Worker may still be starting beside the dev server
154        retry = setTimeout(probe, 5000);
155      }
156    };
157    probe();
158
159    return () => {
160      closed = true;
161      clearTimeout(retry);
162      socket?.close();
163      document.removeEventListener('strudel.log', onLog);
164      window.removeEventListener('error', onError);
165      window.removeEventListener('unhandledrejection', onError);
166    };
167  }, []);
168
169  return { sessionId, connectionStatus, isMCPEnabled: mcpAvailable, mine };
170}