Skip to content

Commit 855ed24

Browse files
committed
feat(webhooks): add WebhookView, events panel (SSE) and server event-store/stream
1 parent f60c5bc commit 855ed24

File tree

7 files changed

+448
-7
lines changed

7 files changed

+448
-7
lines changed

server/src/index.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,110 @@ app.post("/api/webhook/:userPath", (req: Request, res: Response) => {
7272
console.warn('Failed to log events', (err as Error).message);
7373
}
7474

75+
// Append events to in-memory store for this path
76+
try {
77+
const now = new Date().toISOString();
78+
const records: EventRecord[] = events.map((e: any) => ({
79+
id: e.id,
80+
eventType: e.eventType,
81+
timestamp: e.eventTime || now,
82+
data: e.data,
83+
raw: e,
84+
}));
85+
86+
const existing = eventStore.get(userPath) || [];
87+
// Newest at start
88+
const combined = [...records.reverse(), ...existing];
89+
// Trim to cap
90+
const trimmed = combined.slice(0, EVENT_STORE_CAP);
91+
eventStore.set(userPath, trimmed);
92+
} catch (err) {
93+
console.warn('Failed to store events in memory', (err as Error).message);
94+
}
95+
96+
// Notify any SSE clients connected to this webhook path
97+
try {
98+
for (const e of events) {
99+
const payload = { id: e.id, eventType: e.eventType, timestamp: e.eventTime || new Date().toISOString() };
100+
sendSseToPath(userPath, payload);
101+
}
102+
} catch (err) {
103+
console.warn('Failed to broadcast SSE', (err as Error).message);
104+
}
105+
75106
// TODO: integrate with persistence / user properties: look up which user has this key
76107
// and forward/enqueue events appropriately.
77108

78109
return res.status(200).json({ received: events.length });
79110
});
80111

112+
// In-memory event store per webhook path (newest items at start). This is ephemeral and
113+
// will be lost if the server restarts. We keep a modest cap per path.
114+
type EventRecord = {
115+
id?: string;
116+
eventType: string;
117+
timestamp: string; // ISO
118+
data: any;
119+
raw: any;
120+
};
121+
122+
const EVENT_STORE_CAP = 200;
123+
const eventStore = new Map<string, EventRecord[]>();
124+
125+
// SSE clients per webhook path
126+
const sseClients = new Map<string, Set<Response>>();
127+
128+
function sendSseToPath(userPath: string, payload: any) {
129+
const clients = sseClients.get(userPath);
130+
if (!clients) return;
131+
const data = typeof payload === 'string' ? payload : JSON.stringify(payload);
132+
for (const res of clients) {
133+
try {
134+
res.write(`data: ${data}\n\n`);
135+
} catch (err) {
136+
console.warn('Failed to write SSE to client', (err as Error).message);
137+
}
138+
}
139+
}
140+
141+
// Return events for a specific webhook path (newest first). No auth is enforced here;
142+
// possession of the path acts as the access key. If you want stronger auth, wire this
143+
// up to your user system and verify ownership.
144+
app.get('/api/webhook/:userPath/events', (_req: Request, res: Response) => {
145+
const userPath = _req.params.userPath;
146+
const list = eventStore.get(userPath) || [];
147+
return res.json({ count: list.length, events: list });
148+
});
149+
150+
// Server-Sent Events stream for a webhook path.
151+
app.get('/api/webhook/:userPath/stream', (req: Request, res: Response) => {
152+
const userPath = req.params.userPath;
153+
154+
// Set SSE headers
155+
res.writeHead(200, {
156+
'Content-Type': 'text/event-stream',
157+
'Cache-Control': 'no-cache',
158+
Connection: 'keep-alive',
159+
});
160+
161+
// Send an initial comment to establish the stream
162+
res.write(': connected\n\n');
163+
164+
// Register client
165+
const set = sseClients.get(userPath) || new Set<Response>();
166+
set.add(res);
167+
sseClients.set(userPath, set);
168+
169+
req.on('close', () => {
170+
// Remove client when they disconnect
171+
const clients = sseClients.get(userPath);
172+
if (clients) {
173+
clients.delete(res);
174+
if (clients.size === 0) sseClients.delete(userPath);
175+
}
176+
});
177+
});
178+
81179
// Serve static frontend if present in the final image at '../editor-dist'
82180
const staticPath = path.join(__dirname, "..", "editor-dist");
83181
const FRONTEND_INDEX = path.join(staticPath, "index.html");

src/App.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ScriptEditor } from './components/ScriptEditor';
88
import { NodeEditor } from './components/NodeEditor';
99
import { DialogManager } from './components/DialogManager';
1010
import { DocViewer } from './components/DocViewer';
11+
import WebhookView from './components/WebhookView';
1112
import { EnvironmentDebug } from './components/EnvironmentDebug';
1213
import { Activity, Step, ScriptData, isActivity, isStep, LogicNode } from './types';
1314
import { normalizeScript } from './normalizer';
@@ -697,11 +698,15 @@ export default function App() {
697698
)}
698699
</div>
699700
</div>
700-
) : (
701+
) : activeTab === 'documentation' ? (
701702
<div className="documentation-flex">
702703
<DocViewer />
703704
</div>
704-
)}
705+
) : activeTab === 'webhook' ? (
706+
<div className="documentation-flex">
707+
<WebhookView />
708+
</div>
709+
) : null}
705710
</div>
706711
);
707712
}

src/components/TabBar.css

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
.tab-bar {
2+
display: flex;
3+
align-items: center;
4+
background: #fff;
5+
}
6+
7+
.tab-container {
8+
display: flex;
9+
gap: 8px;
10+
}
11+
12+
.tab {
13+
background: transparent;
14+
border: none;
15+
padding: 8px 12px;
16+
cursor: pointer;
17+
color: #333;
18+
}
19+
20+
.tab.active {
21+
border-bottom: 2px solid #0078d4;
22+
font-weight: 600;
23+
}
24+
25+
.webhook-panel {
26+
padding: 24px;
27+
}
28+
29+
.webhook-error {
30+
color: #c92a2a;
31+
}
32+
33+
.webhook-url-row {
34+
display: flex;
35+
gap: 8px;
36+
align-items: center;
37+
max-width: 600px;
38+
}
39+
40+
.webhook-url-input {
41+
flex: 1;
42+
padding: 8px;
43+
border: 1px solid #ddd;
44+
border-radius: 4px;
45+
}
46+
47+
.webhook-copy-button {
48+
padding: 8px 12px;
49+
background: #0078d4;
50+
color: #fff;
51+
border: none;
52+
border-radius: 4px;
53+
cursor: pointer;
54+
}

src/components/TabBar.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,50 @@
11
import React from 'react';
22

3+
import { useQuery } from 'urql';
4+
import { GET_USER_PROPERTIES_QUERY, APP_SCRIPT_APPLICATION } from '../graphql/userProperties';
5+
import WebhookView from './WebhookView';
6+
7+
type TabKey = 'edit' | 'documentation' | 'webhook';
8+
39
interface TabBarProps {
4-
activeTab: 'edit' | 'documentation';
5-
onTabChange: (tab: 'edit' | 'documentation') => void;
10+
activeTab: TabKey;
11+
onTabChange: (tab: TabKey) => void;
612
}
713

814
export const TabBar: React.FC<TabBarProps> = ({ activeTab, onTabChange }) => {
15+
const [result] = useQuery({
16+
query: GET_USER_PROPERTIES_QUERY,
17+
variables: { application: APP_SCRIPT_APPLICATION },
18+
});
19+
920
return (
1021
<div className="tab-bar">
1122
<div className="tab-container">
12-
<button
23+
<button
1324
className={`tab ${activeTab === 'edit' ? 'active' : ''}`}
1425
onClick={() => onTabChange('edit')}
1526
>
1627
Edit
1728
</button>
18-
<button
29+
<button
1930
className={`tab ${activeTab === 'documentation' ? 'active' : ''}`}
2031
onClick={() => onTabChange('documentation')}
2132
>
2233
Documentation
2334
</button>
35+
<button
36+
className={`tab ${activeTab === 'webhook' ? 'active' : ''}`}
37+
onClick={() => onTabChange('webhook')}
38+
>
39+
Webhook
40+
</button>
2441
</div>
2542
</div>
2643
);
27-
};
44+
};
45+
export const TabBarContent: React.FC<{ activeTab: string }> = ({ activeTab }) => {
46+
if (activeTab === 'webhook') return <WebhookView /> as any;
47+
return null as any;
48+
};
49+
50+
export default TabBar;
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import React from 'react';
2+
3+
type EventItem = {
4+
id?: string;
5+
eventType: string;
6+
timestamp: string;
7+
};
8+
9+
interface Props {
10+
userPath: string;
11+
}
12+
13+
export const WebhookEventsPanel: React.FC<Props> = ({ userPath }) => {
14+
const [events, setEvents] = React.useState<EventItem[]>([]);
15+
const [connected, setConnected] = React.useState(false);
16+
17+
// Fetch initial events
18+
React.useEffect(() => {
19+
if (!userPath) return;
20+
let cancelled = false;
21+
(async () => {
22+
try {
23+
const r = await fetch(`/api/webhook/${encodeURIComponent(userPath)}/events`);
24+
if (!r.ok) throw new Error(await r.text());
25+
const j = await r.json();
26+
if (!cancelled && Array.isArray(j.events)) {
27+
// ensure newest-first
28+
setEvents(j.events.map((e: any) => ({ id: e.id, eventType: e.eventType, timestamp: e.timestamp })));
29+
}
30+
} catch (err) {
31+
console.warn('Failed to load events', err);
32+
}
33+
})();
34+
return () => { cancelled = true; };
35+
}, [userPath]);
36+
37+
// SSE subscription
38+
React.useEffect(() => {
39+
if (!userPath) return;
40+
let es: EventSource | null = null;
41+
let reconnectTimer: number | null = null;
42+
43+
const connect = () => {
44+
es = new EventSource(`/api/webhook/${encodeURIComponent(userPath)}/stream`);
45+
es.onopen = () => {
46+
setConnected(true);
47+
};
48+
es.onerror = () => {
49+
setConnected(false);
50+
// Try to reconnect after a short delay
51+
if (es) es.close();
52+
reconnectTimer = window.setTimeout(() => connect(), 3000);
53+
};
54+
es.onmessage = (ev) => {
55+
try {
56+
const data = JSON.parse(ev.data);
57+
// Prepend newest-first
58+
setEvents(prev => [{ id: data.id, eventType: data.eventType, timestamp: data.timestamp }, ...prev]);
59+
} catch (err) {
60+
console.warn('Invalid SSE payload', err);
61+
}
62+
};
63+
};
64+
65+
connect();
66+
67+
return () => {
68+
if (reconnectTimer) window.clearTimeout(reconnectTimer);
69+
if (es) es.close();
70+
setConnected(false);
71+
};
72+
}, [userPath]);
73+
74+
return (
75+
<div style={{ marginTop: 16 }}>
76+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
77+
<strong>Events</strong>
78+
<span style={{ color: connected ? 'green' : 'gray' }}>{connected ? 'live' : 'disconnected'}</span>
79+
</div>
80+
<div style={{ maxHeight: 360, overflow: 'auto', border: '1px solid #eee', padding: 8 }}>
81+
{events.length === 0 ? (
82+
<div>No events yet.</div>
83+
) : (
84+
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
85+
{events.map((e, idx) => (
86+
<li key={e.id || idx} style={{ padding: '6px 8px', borderBottom: '1px solid #f1f1f1' }}>
87+
<div style={{ fontSize: 12, color: '#666' }}>{new Date(e.timestamp).toLocaleString()}</div>
88+
<div style={{ fontWeight: 600 }}>{e.eventType}</div>
89+
</li>
90+
))}
91+
</ul>
92+
)}
93+
</div>
94+
</div>
95+
);
96+
};
97+
98+
export default WebhookEventsPanel;

0 commit comments

Comments
 (0)