@seed-ship/mcp-ui-solid 2.2.11 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ChatPrompt.cjs +271 -0
- package/dist/components/ChatPrompt.cjs.map +1 -0
- package/dist/components/ChatPrompt.d.ts +33 -0
- package/dist/components/ChatPrompt.d.ts.map +1 -0
- package/dist/components/ChatPrompt.js +271 -0
- package/dist/components/ChatPrompt.js.map +1 -0
- package/dist/components/UIResourceRenderer.cjs +29 -27
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.js +30 -28
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/hooks/useChatBus.cjs +28 -0
- package/dist/hooks/useChatBus.cjs.map +1 -0
- package/dist/hooks/useChatBus.d.ts +56 -0
- package/dist/hooks/useChatBus.d.ts.map +1 -0
- package/dist/hooks/useChatBus.js +28 -0
- package/dist/hooks/useChatBus.js.map +1 -0
- package/dist/index.cjs +11 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/services/chat-bus.cjs +118 -0
- package/dist/services/chat-bus.cjs.map +1 -0
- package/dist/services/chat-bus.d.ts +43 -0
- package/dist/services/chat-bus.d.ts.map +1 -0
- package/dist/services/chat-bus.js +118 -0
- package/dist/services/chat-bus.js.map +1 -0
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/validation.cjs +71 -1
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts +21 -0
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +71 -1
- package/dist/services/validation.js.map +1 -1
- package/dist/types/chat-bus.d.ts +286 -0
- package/dist/types/chat-bus.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/ChatPrompt.test.tsx +280 -0
- package/src/components/ChatPrompt.tsx +263 -0
- package/src/components/UIResourceRenderer.tsx +2 -2
- package/src/hooks/useChatBus.tsx +81 -0
- package/src/index.ts +36 -0
- package/src/services/chat-bus.test.ts +306 -0
- package/src/services/chat-bus.ts +183 -0
- package/src/services/index.ts +4 -0
- package/src/services/validation.test.ts +56 -1
- package/src/services/validation.ts +100 -0
- package/src/types/chat-bus.ts +320 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Chat Bus — createEventEmitter + createCommandHandler + createChatBus
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
6
|
+
import { createEventEmitter, createCommandHandler, createChatBus } from './chat-bus'
|
|
7
|
+
import type { ChatEvents, ChatCommands } from '../types/chat-bus'
|
|
8
|
+
|
|
9
|
+
describe('createEventEmitter', () => {
|
|
10
|
+
it('emits events to subscribed listeners', () => {
|
|
11
|
+
const emitter = createEventEmitter()
|
|
12
|
+
const handler = vi.fn()
|
|
13
|
+
|
|
14
|
+
emitter.on('onToken', handler)
|
|
15
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'hello' })
|
|
16
|
+
|
|
17
|
+
expect(handler).toHaveBeenCalledWith({ streamKey: 'abc', token: 'hello' })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('supports multiple listeners for the same event', () => {
|
|
21
|
+
const emitter = createEventEmitter()
|
|
22
|
+
const handler1 = vi.fn()
|
|
23
|
+
const handler2 = vi.fn()
|
|
24
|
+
|
|
25
|
+
emitter.on('onStreamStart', handler1)
|
|
26
|
+
emitter.on('onStreamStart', handler2)
|
|
27
|
+
emitter.emit('onStreamStart', { streamKey: 'abc' })
|
|
28
|
+
|
|
29
|
+
expect(handler1).toHaveBeenCalledOnce()
|
|
30
|
+
expect(handler2).toHaveBeenCalledOnce()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns unsubscribe function', () => {
|
|
34
|
+
const emitter = createEventEmitter()
|
|
35
|
+
const handler = vi.fn()
|
|
36
|
+
|
|
37
|
+
const unsub = emitter.on('onToken', handler)
|
|
38
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
|
|
39
|
+
expect(handler).toHaveBeenCalledOnce()
|
|
40
|
+
|
|
41
|
+
unsub()
|
|
42
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'b' })
|
|
43
|
+
expect(handler).toHaveBeenCalledOnce() // still 1, not 2
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('clear() removes all listeners', () => {
|
|
47
|
+
const emitter = createEventEmitter()
|
|
48
|
+
const handler = vi.fn()
|
|
49
|
+
|
|
50
|
+
emitter.on('onToken', handler)
|
|
51
|
+
emitter.on('onStreamEnd', handler)
|
|
52
|
+
emitter.clear()
|
|
53
|
+
|
|
54
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
|
|
55
|
+
emitter.emit('onStreamEnd', { streamKey: 'abc', metadata: {} as any })
|
|
56
|
+
|
|
57
|
+
expect(handler).not.toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not throw when emitting with no listeners', () => {
|
|
61
|
+
const emitter = createEventEmitter()
|
|
62
|
+
expect(() => emitter.emit('onToken', { streamKey: 'abc', token: 'a' })).not.toThrow()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('catches errors in handlers without breaking other listeners', () => {
|
|
66
|
+
const emitter = createEventEmitter()
|
|
67
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
68
|
+
const badHandler = vi.fn(() => { throw new Error('boom') })
|
|
69
|
+
const goodHandler = vi.fn()
|
|
70
|
+
|
|
71
|
+
emitter.on('onToken', badHandler)
|
|
72
|
+
emitter.on('onToken', goodHandler)
|
|
73
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
|
|
74
|
+
|
|
75
|
+
expect(badHandler).toHaveBeenCalled()
|
|
76
|
+
expect(goodHandler).toHaveBeenCalled()
|
|
77
|
+
expect(errorSpy).toHaveBeenCalled()
|
|
78
|
+
|
|
79
|
+
errorSpy.mockRestore()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('streamKey filtering', () => {
|
|
83
|
+
it('filters events by streamKey when option is set', () => {
|
|
84
|
+
const emitter = createEventEmitter()
|
|
85
|
+
const handler = vi.fn()
|
|
86
|
+
|
|
87
|
+
emitter.on('onToken', handler, { streamKey: 'stream-1' })
|
|
88
|
+
|
|
89
|
+
emitter.emit('onToken', { streamKey: 'stream-1', token: 'a' })
|
|
90
|
+
emitter.emit('onToken', { streamKey: 'stream-2', token: 'b' })
|
|
91
|
+
|
|
92
|
+
expect(handler).toHaveBeenCalledOnce()
|
|
93
|
+
expect(handler).toHaveBeenCalledWith({ streamKey: 'stream-1', token: 'a' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does not filter when no streamKey option', () => {
|
|
97
|
+
const emitter = createEventEmitter()
|
|
98
|
+
const handler = vi.fn()
|
|
99
|
+
|
|
100
|
+
emitter.on('onToken', handler)
|
|
101
|
+
|
|
102
|
+
emitter.emit('onToken', { streamKey: 'stream-1', token: 'a' })
|
|
103
|
+
emitter.emit('onToken', { streamKey: 'stream-2', token: 'b' })
|
|
104
|
+
|
|
105
|
+
expect(handler).toHaveBeenCalledTimes(2)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('filters onCustomEvent by streamKey in second arg', () => {
|
|
109
|
+
const emitter = createEventEmitter()
|
|
110
|
+
const handler = vi.fn()
|
|
111
|
+
|
|
112
|
+
emitter.on('onCustomEvent', handler, { streamKey: 'stream-1' })
|
|
113
|
+
|
|
114
|
+
// onCustomEvent signature: (type: string, event: ChatEventBase & { data })
|
|
115
|
+
emitter.emit('onCustomEvent', 'my_event', { streamKey: 'stream-1', data: 'yes' })
|
|
116
|
+
emitter.emit('onCustomEvent', 'my_event', { streamKey: 'stream-2', data: 'no' })
|
|
117
|
+
|
|
118
|
+
expect(handler).toHaveBeenCalledOnce()
|
|
119
|
+
expect(handler).toHaveBeenCalledWith('my_event', { streamKey: 'stream-1', data: 'yes' })
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('throttle', () => {
|
|
124
|
+
beforeEach(() => { vi.useFakeTimers() })
|
|
125
|
+
afterEach(() => { vi.useRealTimers() })
|
|
126
|
+
|
|
127
|
+
it('throttles handler calls', () => {
|
|
128
|
+
const emitter = createEventEmitter()
|
|
129
|
+
const handler = vi.fn()
|
|
130
|
+
|
|
131
|
+
emitter.on('onToken', handler, { throttle: 100 })
|
|
132
|
+
|
|
133
|
+
// Rapid fire
|
|
134
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
|
|
135
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'b' })
|
|
136
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'c' })
|
|
137
|
+
|
|
138
|
+
// First call goes through immediately
|
|
139
|
+
expect(handler).toHaveBeenCalledOnce()
|
|
140
|
+
expect(handler).toHaveBeenCalledWith({ streamKey: 'abc', token: 'a' })
|
|
141
|
+
|
|
142
|
+
// After throttle period, last call fires
|
|
143
|
+
vi.advanceTimersByTime(100)
|
|
144
|
+
expect(handler).toHaveBeenCalledTimes(2)
|
|
145
|
+
expect(handler).toHaveBeenLastCalledWith({ streamKey: 'abc', token: 'c' })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('cancels pending throttle timer on unsubscribe', () => {
|
|
149
|
+
const emitter = createEventEmitter()
|
|
150
|
+
const handler = vi.fn()
|
|
151
|
+
|
|
152
|
+
const unsub = emitter.on('onToken', handler, { throttle: 100 })
|
|
153
|
+
|
|
154
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
|
|
155
|
+
expect(handler).toHaveBeenCalledOnce() // immediate
|
|
156
|
+
|
|
157
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'b' }) // queued
|
|
158
|
+
unsub() // should cancel the queued call
|
|
159
|
+
|
|
160
|
+
vi.advanceTimersByTime(200)
|
|
161
|
+
expect(handler).toHaveBeenCalledOnce() // still 1, queued was cancelled
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('catches errors in throttled deferred calls', () => {
|
|
165
|
+
const emitter = createEventEmitter()
|
|
166
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
167
|
+
const badHandler = vi.fn(() => { throw new Error('throttle boom') })
|
|
168
|
+
|
|
169
|
+
emitter.on('onToken', badHandler, { throttle: 50 })
|
|
170
|
+
|
|
171
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'a' }) // immediate — caught by emit
|
|
172
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'b' }) // deferred
|
|
173
|
+
|
|
174
|
+
vi.advanceTimersByTime(50) // fires deferred — should catch
|
|
175
|
+
expect(errorSpy).toHaveBeenCalled()
|
|
176
|
+
|
|
177
|
+
errorSpy.mockRestore()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('non-throttled listeners fire immediately', () => {
|
|
181
|
+
const emitter = createEventEmitter()
|
|
182
|
+
const handler = vi.fn()
|
|
183
|
+
|
|
184
|
+
emitter.on('onToken', handler) // no throttle
|
|
185
|
+
|
|
186
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
|
|
187
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'b' })
|
|
188
|
+
emitter.emit('onToken', { streamKey: 'abc', token: 'c' })
|
|
189
|
+
|
|
190
|
+
expect(handler).toHaveBeenCalledTimes(3)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('createCommandHandler', () => {
|
|
196
|
+
it('executes registered command handlers', () => {
|
|
197
|
+
const commands = createCommandHandler()
|
|
198
|
+
const handler = vi.fn()
|
|
199
|
+
|
|
200
|
+
commands.handle('injectPrompt', handler)
|
|
201
|
+
commands.exec('injectPrompt', 'Hello')
|
|
202
|
+
|
|
203
|
+
expect(handler).toHaveBeenCalledWith('Hello')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('returns handler result', () => {
|
|
207
|
+
const commands = createCommandHandler()
|
|
208
|
+
|
|
209
|
+
commands.handle('sendPrompt', (text: string) => `corr-${text}`)
|
|
210
|
+
const result = commands.exec('sendPrompt', 'test')
|
|
211
|
+
|
|
212
|
+
expect(result).toBe('corr-test')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('warns when executing unregistered command', () => {
|
|
216
|
+
const commands = createCommandHandler()
|
|
217
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
218
|
+
|
|
219
|
+
commands.exec('injectPrompt', 'test')
|
|
220
|
+
|
|
221
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('injectPrompt'))
|
|
222
|
+
warnSpy.mockRestore()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('replaces handler when handle() is called again', () => {
|
|
226
|
+
const commands = createCommandHandler()
|
|
227
|
+
const handler1 = vi.fn()
|
|
228
|
+
const handler2 = vi.fn()
|
|
229
|
+
|
|
230
|
+
commands.handle('injectPrompt', handler1)
|
|
231
|
+
commands.handle('injectPrompt', handler2)
|
|
232
|
+
commands.exec('injectPrompt', 'test')
|
|
233
|
+
|
|
234
|
+
expect(handler1).not.toHaveBeenCalled()
|
|
235
|
+
expect(handler2).toHaveBeenCalledWith('test')
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('createChatBus', () => {
|
|
240
|
+
it('creates a bus with events and commands', () => {
|
|
241
|
+
const bus = createChatBus()
|
|
242
|
+
|
|
243
|
+
expect(bus.events).toBeDefined()
|
|
244
|
+
expect(bus.events.on).toBeTypeOf('function')
|
|
245
|
+
expect(bus.events.emit).toBeTypeOf('function')
|
|
246
|
+
expect(bus.events.clear).toBeTypeOf('function')
|
|
247
|
+
|
|
248
|
+
expect(bus.commands).toBeDefined()
|
|
249
|
+
expect(bus.commands.handle).toBeTypeOf('function')
|
|
250
|
+
expect(bus.commands.exec).toBeTypeOf('function')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('events and commands work together', () => {
|
|
254
|
+
const bus = createChatBus()
|
|
255
|
+
const responses: string[] = []
|
|
256
|
+
|
|
257
|
+
// Agent subscribes to stream end
|
|
258
|
+
bus.events.on('onStreamEnd', (event) => {
|
|
259
|
+
if ((event.metadata as any).needs_period) {
|
|
260
|
+
// Agent sends a command back
|
|
261
|
+
bus.commands.exec('injectPrompt', 'DVF 93 2024')
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// App handles the command
|
|
266
|
+
bus.commands.handle('injectPrompt', (text) => {
|
|
267
|
+
responses.push(text)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Simulate stream end
|
|
271
|
+
bus.events.emit('onStreamEnd', {
|
|
272
|
+
streamKey: 'abc',
|
|
273
|
+
metadata: { needs_period: true } as any,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
expect(responses).toEqual(['DVF 93 2024'])
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('correlationId flows through events→commands→events cycle', () => {
|
|
280
|
+
const bus = createChatBus()
|
|
281
|
+
const receivedCorrelations: (string | undefined)[] = []
|
|
282
|
+
|
|
283
|
+
// App handles sendPrompt — returns correlationId
|
|
284
|
+
bus.commands.handle('sendPrompt', (_text: string) => {
|
|
285
|
+
return 'corr-123'
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// Agent listens for stream end with correlation
|
|
289
|
+
bus.events.on('onStreamEnd', (event) => {
|
|
290
|
+
receivedCorrelations.push(event.correlationId)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// Agent sends prompt
|
|
294
|
+
const corrId = bus.commands.exec('sendPrompt', 'test query')
|
|
295
|
+
expect(corrId).toBe('corr-123')
|
|
296
|
+
|
|
297
|
+
// App bridges stream end with correlation
|
|
298
|
+
bus.events.emit('onStreamEnd', {
|
|
299
|
+
streamKey: 'stream-1',
|
|
300
|
+
correlationId: corrId,
|
|
301
|
+
metadata: {} as any,
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
expect(receivedCorrelations).toEqual(['corr-123'])
|
|
305
|
+
})
|
|
306
|
+
})
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Bus — Event Emitter + Command Handler
|
|
3
|
+
* v2.4.0: Core primitives for the chat event/command bus
|
|
4
|
+
*
|
|
5
|
+
* @experimental — This API may change without major bump until v2.5.0.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ChatEvents,
|
|
10
|
+
ChatCommands,
|
|
11
|
+
ChatEventEmitter,
|
|
12
|
+
ChatCommandHandler,
|
|
13
|
+
ChatBus,
|
|
14
|
+
EventSubscribeOptions,
|
|
15
|
+
} from '../types/chat-bus'
|
|
16
|
+
|
|
17
|
+
// ─── Event Emitter ───────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
interface Listener<F extends (...args: any[]) => any> {
|
|
20
|
+
handler: F
|
|
21
|
+
options?: EventSubscribeOptions
|
|
22
|
+
throttledHandler?: F
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a typed event emitter with throttle and streamKey filtering support.
|
|
27
|
+
*
|
|
28
|
+
* @experimental
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* const emitter = createEventEmitter<ChatEvents>()
|
|
32
|
+
* const unsub = emitter.on('onToken', (event) => console.log(event.token), { throttle: 100 })
|
|
33
|
+
* emitter.emit('onToken', { streamKey: 'abc', token: 'hello' })
|
|
34
|
+
* unsub()
|
|
35
|
+
*/
|
|
36
|
+
export function createEventEmitter(): ChatEventEmitter {
|
|
37
|
+
const listeners = new Map<string, Set<Listener<any>>>()
|
|
38
|
+
|
|
39
|
+
interface ThrottledFn<F> {
|
|
40
|
+
fn: F
|
|
41
|
+
cancel: () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createThrottled<F extends (...args: any[]) => void>(fn: F, ms: number): ThrottledFn<F> {
|
|
45
|
+
let lastCall = 0
|
|
46
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
47
|
+
let lastArgs: any[] | null = null
|
|
48
|
+
let cancelled = false
|
|
49
|
+
|
|
50
|
+
const throttled = ((...args: any[]) => {
|
|
51
|
+
if (cancelled) return
|
|
52
|
+
lastArgs = args
|
|
53
|
+
const now = Date.now()
|
|
54
|
+
const remaining = ms - (now - lastCall)
|
|
55
|
+
|
|
56
|
+
if (remaining <= 0) {
|
|
57
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
58
|
+
lastCall = now
|
|
59
|
+
fn(...args)
|
|
60
|
+
} else if (!timer) {
|
|
61
|
+
timer = setTimeout(() => {
|
|
62
|
+
lastCall = Date.now()
|
|
63
|
+
timer = null
|
|
64
|
+
if (lastArgs && !cancelled) {
|
|
65
|
+
try { fn(...lastArgs) } catch (err) { console.error('[ChatBus] Error in throttled handler:', err) }
|
|
66
|
+
}
|
|
67
|
+
}, remaining)
|
|
68
|
+
}
|
|
69
|
+
}) as F
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
fn: throttled,
|
|
73
|
+
cancel: () => { cancelled = true; if (timer) { clearTimeout(timer); timer = null } },
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
on(event, handler, options) {
|
|
79
|
+
if (!listeners.has(event as string)) {
|
|
80
|
+
listeners.set(event as string, new Set())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const listener: Listener<typeof handler> = { handler, options }
|
|
84
|
+
|
|
85
|
+
// Apply throttle if requested
|
|
86
|
+
let throttleHandle: ThrottledFn<typeof handler> | null = null
|
|
87
|
+
if (options?.throttle && options.throttle > 0) {
|
|
88
|
+
throttleHandle = createThrottled(handler, options.throttle)
|
|
89
|
+
listener.throttledHandler = throttleHandle.fn
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
listeners.get(event as string)!.add(listener)
|
|
93
|
+
|
|
94
|
+
// Return unsubscribe function — cancels pending throttle timers
|
|
95
|
+
return () => {
|
|
96
|
+
throttleHandle?.cancel()
|
|
97
|
+
listeners.get(event as string)?.delete(listener)
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
emit(event, ...args) {
|
|
102
|
+
const set = listeners.get(event as string)
|
|
103
|
+
if (!set) return
|
|
104
|
+
|
|
105
|
+
for (const listener of set) {
|
|
106
|
+
// StreamKey filtering: skip if listener wants a specific streamKey
|
|
107
|
+
// For most events args[0] has streamKey; for onCustomEvent args[1] has it
|
|
108
|
+
if (listener.options?.streamKey) {
|
|
109
|
+
let streamKeyArg: unknown
|
|
110
|
+
for (const arg of args) {
|
|
111
|
+
if (arg && typeof arg === 'object' && 'streamKey' in (arg as any)) {
|
|
112
|
+
streamKeyArg = (arg as any).streamKey
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (streamKeyArg !== undefined && streamKeyArg !== listener.options.streamKey) continue
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const fn = listener.throttledHandler || listener.handler
|
|
120
|
+
try {
|
|
121
|
+
fn(...args)
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`[ChatBus] Error in ${event as string} handler:`, err)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
clear() {
|
|
129
|
+
listeners.clear()
|
|
130
|
+
},
|
|
131
|
+
} as ChatEventEmitter
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Command Handler ─────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Create a typed command handler. The host app registers handlers,
|
|
138
|
+
* agents execute commands.
|
|
139
|
+
*
|
|
140
|
+
* @experimental
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* const commands = createCommandHandler<ChatCommands>()
|
|
144
|
+
* commands.handle('injectPrompt', (text) => setInputValue(text))
|
|
145
|
+
* commands.exec('injectPrompt', 'Hello world')
|
|
146
|
+
*/
|
|
147
|
+
export function createCommandHandler(): ChatCommandHandler {
|
|
148
|
+
const handlers = new Map<string, (...args: any[]) => any>()
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
handle(command, handler) {
|
|
152
|
+
handlers.set(command as string, handler)
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
exec(command, ...args) {
|
|
156
|
+
const handler = handlers.get(command as string)
|
|
157
|
+
if (!handler) {
|
|
158
|
+
console.warn(`[ChatBus] No handler registered for command: ${command as string}`)
|
|
159
|
+
return undefined as any
|
|
160
|
+
}
|
|
161
|
+
return handler(...args)
|
|
162
|
+
},
|
|
163
|
+
} as ChatCommandHandler
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Chat Bus Factory ────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create a complete ChatBus with events + commands.
|
|
170
|
+
*
|
|
171
|
+
* @experimental
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* const bus = createChatBus()
|
|
175
|
+
* bus.events.on('onStreamEnd', (event) => { ... })
|
|
176
|
+
* bus.commands.handle('sendPrompt', (text) => { ... })
|
|
177
|
+
*/
|
|
178
|
+
export function createChatBus(): ChatBus {
|
|
179
|
+
return {
|
|
180
|
+
events: createEventEmitter(),
|
|
181
|
+
commands: createCommandHandler(),
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/services/index.ts
CHANGED
|
@@ -8,8 +8,12 @@ export {
|
|
|
8
8
|
validateComponent,
|
|
9
9
|
validateLayout,
|
|
10
10
|
validateIframeDomain,
|
|
11
|
+
getIframeSandbox,
|
|
11
12
|
DEFAULT_RESOURCE_LIMITS,
|
|
12
13
|
DEFAULT_IFRAME_DOMAINS,
|
|
14
|
+
TRUSTED_IFRAME_DOMAINS,
|
|
13
15
|
} from './validation'
|
|
14
16
|
|
|
15
17
|
export { ComponentRegistry } from './component-registry'
|
|
18
|
+
|
|
19
|
+
export { createEventEmitter, createCommandHandler, createChatBus } from './chat-bus'
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect, vi } from 'vitest'
|
|
9
|
-
import { validateComponent, validateChartComponent } from './validation'
|
|
9
|
+
import { validateComponent, validateChartComponent, getIframeSandbox } from './validation'
|
|
10
10
|
import type { UIComponent, ComponentType } from '../types'
|
|
11
11
|
|
|
12
12
|
/** Helper to create a minimal valid UIComponent for testing */
|
|
@@ -272,3 +272,58 @@ describe('validateChartComponent — H1 null guards', () => {
|
|
|
272
272
|
expect(result.valid).toBe(true)
|
|
273
273
|
})
|
|
274
274
|
})
|
|
275
|
+
|
|
276
|
+
describe('getIframeSandbox — tiered sandbox', () => {
|
|
277
|
+
it('gives full sandbox to trusted domains (Google)', () => {
|
|
278
|
+
const sandbox = getIframeSandbox('https://docs.google.com/spreadsheets/d/123')
|
|
279
|
+
expect(sandbox).toContain('allow-same-origin')
|
|
280
|
+
expect(sandbox).toContain('allow-scripts')
|
|
281
|
+
expect(sandbox).toContain('allow-forms')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('gives full sandbox to Deposium domains', () => {
|
|
285
|
+
const sandbox = getIframeSandbox('https://deposium.com/embed/123')
|
|
286
|
+
expect(sandbox).toContain('allow-same-origin')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('gives full sandbox to payment domains (Stripe)', () => {
|
|
290
|
+
const sandbox = getIframeSandbox('https://checkout.stripe.com/c/pay_123')
|
|
291
|
+
expect(sandbox).toContain('allow-same-origin')
|
|
292
|
+
expect(sandbox).toContain('allow-forms')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('gives full sandbox to Polar.sh', () => {
|
|
296
|
+
const sandbox = getIframeSandbox('https://polar.sh/checkout/123')
|
|
297
|
+
expect(sandbox).toContain('allow-same-origin')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('gives restrictive sandbox to untrusted whitelisted domains (quickchart)', () => {
|
|
301
|
+
const sandbox = getIframeSandbox('https://quickchart.io/chart?c={}')
|
|
302
|
+
expect(sandbox).toContain('allow-scripts')
|
|
303
|
+
expect(sandbox).toContain('allow-popups')
|
|
304
|
+
expect(sandbox).not.toContain('allow-same-origin')
|
|
305
|
+
expect(sandbox).not.toContain('allow-forms')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('gives restrictive sandbox to YouTube', () => {
|
|
309
|
+
const sandbox = getIframeSandbox('https://www.youtube.com/embed/abc123')
|
|
310
|
+
expect(sandbox).not.toContain('allow-same-origin')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('gives restrictive sandbox to unknown domains', () => {
|
|
314
|
+
const sandbox = getIframeSandbox('https://evil.example.com/page')
|
|
315
|
+
expect(sandbox).not.toContain('allow-same-origin')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('handles invalid URLs gracefully', () => {
|
|
319
|
+
const sandbox = getIframeSandbox('not-a-url')
|
|
320
|
+
expect(sandbox).toBe('allow-scripts allow-popups')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('supports custom trusted domains', () => {
|
|
324
|
+
const sandbox = getIframeSandbox('https://my-internal-tool.corp.com/dash', {
|
|
325
|
+
customTrustedDomains: ['my-internal-tool.corp.com'],
|
|
326
|
+
})
|
|
327
|
+
expect(sandbox).toContain('allow-same-origin')
|
|
328
|
+
})
|
|
329
|
+
})
|
|
@@ -160,6 +160,67 @@ export const DEFAULT_IFRAME_DOMAINS = [
|
|
|
160
160
|
'www.clinicaltrials.gov',
|
|
161
161
|
'linear.app',
|
|
162
162
|
'www.linear.app',
|
|
163
|
+
|
|
164
|
+
// Payment platforms (v2.2.12)
|
|
165
|
+
'polar.sh',
|
|
166
|
+
'www.polar.sh',
|
|
167
|
+
'checkout.stripe.com',
|
|
168
|
+
'js.stripe.com',
|
|
169
|
+
'billing.stripe.com',
|
|
170
|
+
'buy.stripe.com',
|
|
171
|
+
'connect.stripe.com',
|
|
172
|
+
'invoice.stripe.com',
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Trusted iframe domains that require allow-same-origin to function.
|
|
177
|
+
* These domains need access to their own cookies/storage for auth.
|
|
178
|
+
* All other whitelisted domains get a restrictive sandbox without allow-same-origin.
|
|
179
|
+
*/
|
|
180
|
+
export const TRUSTED_IFRAME_DOMAINS = [
|
|
181
|
+
// Deposium (own domains)
|
|
182
|
+
'deposium.com',
|
|
183
|
+
'deposium.vip',
|
|
184
|
+
'deposium.ai',
|
|
185
|
+
'localhost',
|
|
186
|
+
|
|
187
|
+
// Google services (need auth cookies)
|
|
188
|
+
'docs.google.com',
|
|
189
|
+
'drive.google.com',
|
|
190
|
+
'sheets.google.com',
|
|
191
|
+
'slides.google.com',
|
|
192
|
+
'maps.google.com',
|
|
193
|
+
'datastudio.google.com',
|
|
194
|
+
'lookerstudio.google.com',
|
|
195
|
+
|
|
196
|
+
// Productivity (need auth)
|
|
197
|
+
'notion.so',
|
|
198
|
+
'www.notion.so',
|
|
199
|
+
'airtable.com',
|
|
200
|
+
'figma.com',
|
|
201
|
+
'www.figma.com',
|
|
202
|
+
'miro.com',
|
|
203
|
+
|
|
204
|
+
// Payment (need auth + cookies for checkout)
|
|
205
|
+
'polar.sh',
|
|
206
|
+
'www.polar.sh',
|
|
207
|
+
'checkout.stripe.com',
|
|
208
|
+
'js.stripe.com',
|
|
209
|
+
'billing.stripe.com',
|
|
210
|
+
'buy.stripe.com',
|
|
211
|
+
'connect.stripe.com',
|
|
212
|
+
'invoice.stripe.com',
|
|
213
|
+
|
|
214
|
+
// Business tools (need auth)
|
|
215
|
+
'app.hubspot.com',
|
|
216
|
+
'share.hubspot.com',
|
|
217
|
+
'app.powerbi.com',
|
|
218
|
+
'linear.app',
|
|
219
|
+
'www.linear.app',
|
|
220
|
+
'calendly.com',
|
|
221
|
+
'typeform.com',
|
|
222
|
+
'cal.com',
|
|
223
|
+
'canva.com',
|
|
163
224
|
]
|
|
164
225
|
|
|
165
226
|
/**
|
|
@@ -470,6 +531,45 @@ export function validateIframeDomain(
|
|
|
470
531
|
}
|
|
471
532
|
}
|
|
472
533
|
|
|
534
|
+
/**
|
|
535
|
+
* Get the appropriate sandbox attribute for an iframe URL.
|
|
536
|
+
*
|
|
537
|
+
* Trusted domains (Google, Deposium, payment, auth-requiring services) get
|
|
538
|
+
* `allow-same-origin` so they can access their own cookies/storage.
|
|
539
|
+
* All other whitelisted domains get a restrictive sandbox without it,
|
|
540
|
+
* preventing access to the parent page's localStorage/cookies.
|
|
541
|
+
*
|
|
542
|
+
* @param url - The iframe URL
|
|
543
|
+
* @param options - Optional custom trusted domains
|
|
544
|
+
* @returns sandbox attribute string
|
|
545
|
+
*/
|
|
546
|
+
export function getIframeSandbox(
|
|
547
|
+
url: string,
|
|
548
|
+
options?: { customTrustedDomains?: string[] }
|
|
549
|
+
): string {
|
|
550
|
+
const baseSandbox = 'allow-scripts allow-popups'
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const domain = new URL(url).hostname
|
|
554
|
+
let trustedList = TRUSTED_IFRAME_DOMAINS
|
|
555
|
+
if (options?.customTrustedDomains) {
|
|
556
|
+
trustedList = [...TRUSTED_IFRAME_DOMAINS, ...options.customTrustedDomains]
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const isTrusted = trustedList.some(
|
|
560
|
+
(trusted) => domain === trusted || domain.endsWith(`.${trusted}`)
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
if (isTrusted) {
|
|
564
|
+
return `${baseSandbox} allow-same-origin allow-forms`
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
// Invalid URL — use restrictive sandbox
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return baseSandbox
|
|
571
|
+
}
|
|
572
|
+
|
|
473
573
|
/**
|
|
474
574
|
* Validate entire component
|
|
475
575
|
*
|