@kbve/droid 0.0.5 → 0.1.1

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.
Files changed (109) hide show
  1. package/README.md +3 -1
  2. package/comlink.js +1 -7
  3. package/droid.mjs +191 -61
  4. package/index.d.ts +29 -0
  5. package/index.js +9054 -2763
  6. package/lib/gateway/SupabaseGateway.d.ts +38 -0
  7. package/lib/gateway/SupabaseGateway.d.ts.map +1 -0
  8. package/lib/gateway/WorkerCommunication.d.ts +39 -0
  9. package/lib/gateway/WorkerCommunication.d.ts.map +1 -0
  10. package/lib/gateway/WorkerPool.d.ts +55 -0
  11. package/lib/gateway/WorkerPool.d.ts.map +1 -0
  12. package/lib/gateway/capabilities.d.ts +23 -0
  13. package/lib/gateway/capabilities.d.ts.map +1 -0
  14. package/lib/gateway/index.d.ts +9 -0
  15. package/lib/gateway/index.d.ts.map +1 -0
  16. package/lib/gateway/strategies/DirectStrategy.d.ts +52 -0
  17. package/lib/gateway/strategies/DirectStrategy.d.ts.map +1 -0
  18. package/lib/gateway/strategies/SharedWorkerStrategy.d.ts +46 -0
  19. package/lib/gateway/strategies/SharedWorkerStrategy.d.ts.map +1 -0
  20. package/lib/gateway/strategies/WebWorkerStrategy.d.ts +45 -0
  21. package/lib/gateway/strategies/WebWorkerStrategy.d.ts.map +1 -0
  22. package/lib/gateway/types.d.ts +81 -0
  23. package/lib/gateway/types.d.ts.map +1 -0
  24. package/lib/workers/canvas-ui-renderer.d.ts +25 -0
  25. package/lib/workers/canvas-ui-renderer.d.ts.map +1 -0
  26. package/lib/workers/canvas-worker.d.ts +14 -0
  27. package/lib/workers/canvas-worker.d.ts.map +1 -0
  28. package/lib/workers/canvas-worker.js +291 -145
  29. package/lib/workers/data.d.ts +40 -0
  30. package/lib/workers/data.d.ts.map +1 -0
  31. package/lib/workers/db-worker.d.ts +69 -0
  32. package/lib/workers/db-worker.d.ts.map +1 -0
  33. package/lib/workers/db-worker.js +2680 -2147
  34. package/lib/workers/events.d.ts +11 -0
  35. package/lib/workers/events.d.ts.map +1 -0
  36. package/lib/workers/flexbuilder.d.ts +4 -0
  37. package/lib/workers/flexbuilder.d.ts.map +1 -0
  38. package/lib/workers/init.d.ts +11 -0
  39. package/lib/workers/init.d.ts.map +1 -0
  40. package/lib/workers/main.d.ts +107 -0
  41. package/lib/workers/main.d.ts.map +1 -0
  42. package/lib/workers/supabase-db-worker.d.ts +2 -0
  43. package/lib/workers/supabase-db-worker.d.ts.map +1 -0
  44. package/lib/workers/supabase-db-worker.js +9337 -0
  45. package/lib/workers/supabase-shared-worker.d.ts +2 -0
  46. package/lib/workers/supabase-shared-worker.d.ts.map +1 -0
  47. package/lib/workers/supabase-shared-worker.js +12635 -0
  48. package/lib/workers/tools.d.ts +4 -0
  49. package/lib/workers/tools.d.ts.map +1 -0
  50. package/lib/workers/validate.d.ts +15 -0
  51. package/lib/workers/validate.d.ts.map +1 -0
  52. package/lib/workers/ws-worker.d.ts +11 -0
  53. package/lib/workers/ws-worker.d.ts.map +1 -0
  54. package/lib/workers/ws-worker.js +174 -115
  55. package/main.js +4875 -0
  56. package/package.json +17 -6
  57. package/reference.js +688 -228
  58. package/src/index.ts +38 -13
  59. package/src/lib/droid.spec.ts +14 -19
  60. package/src/lib/droid.ts +48 -4
  61. package/src/lib/gateway/SupabaseGateway.ts +183 -0
  62. package/src/lib/gateway/WorkerCommunication.ts +132 -0
  63. package/src/lib/gateway/WorkerPool.ts +304 -0
  64. package/src/lib/gateway/capabilities.ts +80 -0
  65. package/src/lib/gateway/index.ts +15 -0
  66. package/src/lib/gateway/strategies/DirectStrategy.ts +443 -0
  67. package/src/lib/gateway/strategies/SharedWorkerStrategy.ts +278 -0
  68. package/src/lib/gateway/strategies/WebWorkerStrategy.ts +278 -0
  69. package/src/lib/gateway/types.ts +112 -0
  70. package/src/lib/mod/mod-manager.ts +13 -8
  71. package/src/lib/mod/module/bento/mod-bento.worker.ts +1 -1
  72. package/src/lib/mod/module/supabase/mod-supabase.spec.ts +21 -25
  73. package/src/lib/mod/module/supabase/mod-supabase.ts +68 -0
  74. package/src/lib/mod/module/supabase/mod-supabase.worker.ts +1 -56
  75. package/src/lib/state/auth.spec.ts +58 -0
  76. package/src/lib/state/auth.ts +30 -0
  77. package/src/lib/state/index.ts +34 -0
  78. package/src/lib/state/overlay-manager.ts +125 -0
  79. package/src/lib/state/router.ts +24 -0
  80. package/src/lib/state/theme-sync.ts +75 -0
  81. package/src/lib/state/toasts.spec.ts +106 -0
  82. package/src/lib/state/toasts.ts +34 -0
  83. package/src/lib/state/ui.spec.ts +148 -0
  84. package/src/lib/state/ui.ts +45 -0
  85. package/src/lib/state/welcome-toast.ts +76 -0
  86. package/src/lib/types/bento.spec.ts +116 -0
  87. package/src/lib/types/bento.ts +94 -55
  88. package/src/lib/types/event-types.spec.ts +93 -0
  89. package/src/lib/types/event-types.ts +39 -3
  90. package/src/lib/types/modules.ts +12 -5
  91. package/src/lib/types/ui-event-types.ts +66 -0
  92. package/src/lib/workers/canvas-ui-renderer.ts +266 -0
  93. package/src/lib/workers/canvas-worker.ts +94 -9
  94. package/src/lib/workers/data.ts +59 -36
  95. package/src/lib/workers/db-worker.ts +79 -32
  96. package/src/lib/workers/events.spec.ts +208 -0
  97. package/src/lib/workers/events.ts +27 -14
  98. package/src/lib/workers/flexbuilder.ts +2 -2
  99. package/src/lib/workers/init.ts +25 -17
  100. package/src/lib/workers/main.ts +509 -272
  101. package/src/lib/workers/supabase-db-worker.ts +221 -0
  102. package/src/lib/workers/supabase-shared-worker.ts +572 -0
  103. package/src/lib/workers/validate.ts +64 -0
  104. package/src/lib/workers/ws-worker.ts +156 -25
  105. package/src/setup-vitest.ts +4 -2
  106. package/src/types.ts +30 -0
  107. package/src/vite-env.d.ts +1 -0
  108. package/workers/main.js +10 -1089
  109. package/src/types.d.ts +0 -31
@@ -0,0 +1,572 @@
1
+ /// <reference lib="webworker" />
2
+ // Supabase SharedWorker: Handles auth, realtime, and WebSocket connections
3
+ // Shared across tabs via MessagePort
4
+
5
+ import {
6
+ createClient,
7
+ type SupabaseClient,
8
+ type RealtimePostgresChangesFilter,
9
+ REALTIME_POSTGRES_CHANGES_LISTEN_EVENT,
10
+ } from '@supabase/supabase-js';
11
+ import Dexie, { type Table } from 'dexie';
12
+ import { getWorkerCommunication } from '../gateway/WorkerCommunication';
13
+
14
+ type RealtimeSubscribeParams = {
15
+ event: string;
16
+ schema: string;
17
+ table?: string;
18
+ filter?: string;
19
+ };
20
+
21
+ type Req =
22
+ | {
23
+ id: string;
24
+ type: 'init';
25
+ payload: {
26
+ url: string;
27
+ anonKey: string;
28
+ options?: Record<string, unknown>;
29
+ };
30
+ }
31
+ | { id: string; type: 'getSession' }
32
+ | {
33
+ id: string;
34
+ type: 'signInWithPassword';
35
+ payload: { email: string; password: string };
36
+ }
37
+ | { id: string; type: 'signOut' }
38
+ | {
39
+ id: string;
40
+ type: 'from.select';
41
+ payload: {
42
+ table: string;
43
+ columns?: string;
44
+ match?: Record<string, unknown>;
45
+ limit?: number;
46
+ };
47
+ }
48
+ | {
49
+ id: string;
50
+ type: 'from.insert';
51
+ payload: {
52
+ table: string;
53
+ data: Record<string, unknown> | Record<string, unknown>[];
54
+ };
55
+ }
56
+ | {
57
+ id: string;
58
+ type: 'from.update';
59
+ payload: {
60
+ table: string;
61
+ data: Record<string, unknown>;
62
+ match: Record<string, unknown>;
63
+ };
64
+ }
65
+ | {
66
+ id: string;
67
+ type: 'from.upsert';
68
+ payload: {
69
+ table: string;
70
+ data: Record<string, unknown> | Record<string, unknown>[];
71
+ };
72
+ }
73
+ | {
74
+ id: string;
75
+ type: 'from.delete';
76
+ payload: { table: string; match: Record<string, unknown> };
77
+ }
78
+ | {
79
+ id: string;
80
+ type: 'rpc';
81
+ payload: { fn: string; args?: Record<string, unknown> };
82
+ }
83
+ | {
84
+ id: string;
85
+ type: 'realtime.subscribe';
86
+ payload: { key: string; params: RealtimeSubscribeParams };
87
+ }
88
+ | { id: string; type: 'realtime.unsubscribe'; payload: { key: string } }
89
+ | { id: string; type: 'ws.connect'; payload?: { wsUrl?: string } }
90
+ | { id: string; type: 'ws.disconnect' }
91
+ | { id: string; type: 'ws.send'; payload: { data: unknown } }
92
+ | { id: string; type: 'ws.status' };
93
+
94
+ type Res =
95
+ | { id: string; ok: true; data?: unknown }
96
+ | { id: string; ok: false; error: string };
97
+
98
+ const ports = new Set<MessagePort>();
99
+
100
+ /** Post to all connected ports, removing dead ones on failure */
101
+ function safeBroadcast(msg: unknown) {
102
+ for (const p of ports) {
103
+ try {
104
+ p.postMessage(msg);
105
+ } catch {
106
+ ports.delete(p);
107
+ }
108
+ }
109
+ }
110
+
111
+ // ---- IDB-backed auth storage using Dexie 4 ----
112
+ interface KVPair {
113
+ key: string;
114
+ value: string;
115
+ }
116
+
117
+ class AuthDB extends Dexie {
118
+ kv!: Table<KVPair>;
119
+
120
+ constructor() {
121
+ super('sb-auth-v2');
122
+ this.version(1).stores({
123
+ kv: 'key',
124
+ });
125
+ }
126
+ }
127
+
128
+ class IDBStorage {
129
+ private db: AuthDB;
130
+
131
+ constructor() {
132
+ this.db = new AuthDB();
133
+ }
134
+
135
+ async getItem(key: string): Promise<string | null> {
136
+ try {
137
+ const item = await this.db.kv.get(key);
138
+ return item?.value ?? null;
139
+ } catch (err) {
140
+ console.error('[Worker IDBStorage] getItem error:', err);
141
+ return null;
142
+ }
143
+ }
144
+
145
+ async setItem(key: string, value: string): Promise<void> {
146
+ await this.db.kv.put({ key, value });
147
+ }
148
+
149
+ async removeItem(key: string): Promise<void> {
150
+ await this.db.kv.delete(key);
151
+ }
152
+ }
153
+
154
+ // ---- Supabase management ----
155
+ let client: SupabaseClient | null = null;
156
+ const storage = new IDBStorage();
157
+ const comm = getWorkerCommunication();
158
+ const subscriptions = new Map<
159
+ string,
160
+ { unsubscribe: () => Promise<void> | void }
161
+ >();
162
+
163
+ // ---- WebSocket management ----
164
+ let ws: WebSocket | null = null;
165
+ let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
166
+ let wsReconnectAttempts = 0;
167
+ const WS_MAX_RECONNECT_ATTEMPTS = 5;
168
+ const WS_RECONNECT_DELAY_MS = 3000;
169
+
170
+ // ---- Heartbeat management ----
171
+ let wsHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
172
+ let wsLastPongTime = 0;
173
+ const WS_HEARTBEAT_INTERVAL_MS = 30000;
174
+ const WS_HEARTBEAT_TIMEOUT_MS = 60000;
175
+
176
+ function getWebSocketUrl(customUrl?: string): string {
177
+ if (customUrl) return customUrl;
178
+ if (typeof location !== 'undefined') {
179
+ const isLocalhost =
180
+ location.hostname === 'localhost' ||
181
+ location.hostname === '127.0.0.1';
182
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
183
+ const port = isLocalhost ? '4321' : location.port;
184
+ const host = location.hostname;
185
+ return `${protocol}//${host}${port ? ':' + port : ''}/ws`;
186
+ }
187
+ return 'ws://localhost:4321/ws';
188
+ }
189
+
190
+ async function connectWebSocket(wsUrl?: string) {
191
+ if (
192
+ ws &&
193
+ (ws.readyState === WebSocket.CONNECTING ||
194
+ ws.readyState === WebSocket.OPEN)
195
+ ) {
196
+ return;
197
+ }
198
+
199
+ if (!client) throw new Error('Supabase client not initialized');
200
+
201
+ const {
202
+ data: { session },
203
+ error,
204
+ } = await client.auth.getSession();
205
+ if (error || !session?.access_token) {
206
+ throw new Error('No active session');
207
+ }
208
+
209
+ const url = getWebSocketUrl(wsUrl);
210
+ const authenticatedUrl = `${url}?token=${encodeURIComponent(session.access_token)}`;
211
+
212
+ console.log('[SharedWorker] Connecting to WebSocket:', url);
213
+ ws = new WebSocket(authenticatedUrl);
214
+
215
+ ws.onopen = () => {
216
+ wsReconnectAttempts = 0;
217
+ wsLastPongTime = Date.now();
218
+ startHeartbeat();
219
+
220
+ safeBroadcast({ type: 'ws.status', status: 'connected', url });
221
+ comm.broadcast({
222
+ type: 'ws.status',
223
+ data: { status: 'connected', url },
224
+ });
225
+ };
226
+
227
+ ws.onmessage = (event) => {
228
+ try {
229
+ const message = JSON.parse(event.data);
230
+ if (message.type === 'pong') {
231
+ wsLastPongTime = Date.now();
232
+ return;
233
+ }
234
+ safeBroadcast({ type: 'ws.message', data: message });
235
+ comm.broadcast({ type: 'ws.message', data: message });
236
+ } catch (error) {
237
+ console.error(
238
+ '[SharedWorker] Failed to parse WebSocket message:',
239
+ error,
240
+ );
241
+ }
242
+ };
243
+
244
+ ws.onerror = () => {
245
+ safeBroadcast({
246
+ type: 'ws.status',
247
+ status: 'error',
248
+ error: 'WebSocket connection error',
249
+ });
250
+ };
251
+
252
+ ws.onclose = (event) => {
253
+ ws = null;
254
+ stopHeartbeat();
255
+
256
+ safeBroadcast({
257
+ type: 'ws.status',
258
+ status: 'disconnected',
259
+ code: event.code,
260
+ reason: event.reason,
261
+ });
262
+
263
+ if (
264
+ event.code !== 1000 &&
265
+ wsReconnectAttempts < WS_MAX_RECONNECT_ATTEMPTS
266
+ ) {
267
+ wsReconnectAttempts++;
268
+ wsReconnectTimer = setTimeout(() => {
269
+ connectWebSocket(wsUrl).catch(() => {
270
+ /* reconnect best-effort */
271
+ });
272
+ }, WS_RECONNECT_DELAY_MS);
273
+ }
274
+ };
275
+ }
276
+
277
+ function startHeartbeat() {
278
+ stopHeartbeat();
279
+ wsHeartbeatTimer = setInterval(() => {
280
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
281
+ stopHeartbeat();
282
+ return;
283
+ }
284
+ if (
285
+ wsLastPongTime > 0 &&
286
+ Date.now() - wsLastPongTime > WS_HEARTBEAT_TIMEOUT_MS
287
+ ) {
288
+ console.warn('[SharedWorker] Heartbeat timeout');
289
+ stopHeartbeat();
290
+ ws.close(1001, 'Heartbeat timeout');
291
+ return;
292
+ }
293
+ try {
294
+ ws.send(JSON.stringify({ type: 'ping' }));
295
+ } catch (error) {
296
+ console.error('[SharedWorker] Failed to send heartbeat:', error);
297
+ }
298
+ }, WS_HEARTBEAT_INTERVAL_MS);
299
+ }
300
+
301
+ function stopHeartbeat() {
302
+ if (wsHeartbeatTimer) {
303
+ clearInterval(wsHeartbeatTimer);
304
+ wsHeartbeatTimer = null;
305
+ }
306
+ }
307
+
308
+ function disconnectWebSocket() {
309
+ if (wsReconnectTimer) {
310
+ clearTimeout(wsReconnectTimer);
311
+ wsReconnectTimer = null;
312
+ }
313
+ stopHeartbeat();
314
+ if (ws) {
315
+ ws.close(1000, 'Requested by client');
316
+ ws = null;
317
+ wsReconnectAttempts = 0;
318
+ safeBroadcast({ type: 'ws.status', status: 'disconnected' });
319
+ }
320
+ }
321
+
322
+ function sendWebSocketMessage(data: unknown) {
323
+ if (!ws || ws.readyState !== WebSocket.OPEN)
324
+ throw new Error('WebSocket not connected');
325
+ const message = typeof data === 'string' ? data : JSON.stringify(data);
326
+ ws.send(message);
327
+ }
328
+
329
+ function getWebSocketStatus() {
330
+ if (!ws) return { status: 'disconnected', readyState: null };
331
+ const readyStateMap: Record<number, string> = {
332
+ [WebSocket.CONNECTING]: 'connecting',
333
+ [WebSocket.OPEN]: 'connected',
334
+ [WebSocket.CLOSING]: 'closing',
335
+ [WebSocket.CLOSED]: 'disconnected',
336
+ };
337
+ return {
338
+ status: readyStateMap[ws.readyState] || 'unknown',
339
+ readyState: ws.readyState,
340
+ };
341
+ }
342
+
343
+ async function ensureClient(
344
+ url: string,
345
+ anonKey: string,
346
+ options: Record<string, unknown> = {},
347
+ ) {
348
+ if (client) return client;
349
+
350
+ try {
351
+ new URL(url);
352
+ } catch {
353
+ throw new Error(`Invalid Supabase URL: ${url}`);
354
+ }
355
+
356
+ client = createClient(url, anonKey, {
357
+ auth: {
358
+ storage,
359
+ persistSession: true,
360
+ autoRefreshToken: true,
361
+ },
362
+ realtime: { params: { eventsPerSecond: 5 } },
363
+ ...options,
364
+ });
365
+
366
+ client.auth.onAuthStateChange(async (_event, session) => {
367
+ safeBroadcast({ type: 'auth', session });
368
+ comm.broadcast({ type: 'auth', data: { session } });
369
+ });
370
+
371
+ return client;
372
+ }
373
+
374
+ function reply(port: MessagePort, msg: Res) {
375
+ port.postMessage(msg);
376
+ }
377
+
378
+ (self as unknown as SharedWorkerGlobalScope).onconnect = (
379
+ evt: MessageEvent,
380
+ ) => {
381
+ const port = evt.ports[0];
382
+ ports.add(port);
383
+
384
+ port.onmessage = async (e: MessageEvent<Req>) => {
385
+ const m = e.data;
386
+ try {
387
+ switch (m.type) {
388
+ case 'init': {
389
+ const c = await ensureClient(
390
+ m.payload.url,
391
+ m.payload.anonKey,
392
+ m.payload.options,
393
+ );
394
+ const { data, error } = await c.auth.getSession();
395
+ if (error) throw error;
396
+ reply(port, {
397
+ id: m.id,
398
+ ok: true,
399
+ data: { session: data.session },
400
+ });
401
+ break;
402
+ }
403
+ case 'getSession': {
404
+ if (!client) throw new Error('Not initialized');
405
+ const { data, error } = await client.auth.getSession();
406
+ if (error) throw error;
407
+ reply(port, { id: m.id, ok: true, data });
408
+ break;
409
+ }
410
+ case 'signInWithPassword': {
411
+ if (!client) throw new Error('Not initialized');
412
+ const { email, password } = m.payload;
413
+ const { data, error } =
414
+ await client.auth.signInWithPassword({
415
+ email,
416
+ password,
417
+ });
418
+ if (error) throw error;
419
+ reply(port, { id: m.id, ok: true, data });
420
+ break;
421
+ }
422
+ case 'signOut': {
423
+ if (!client) throw new Error('Not initialized');
424
+ const { error } = await client.auth.signOut();
425
+ if (error) throw error;
426
+ reply(port, { id: m.id, ok: true });
427
+ break;
428
+ }
429
+ case 'from.select': {
430
+ if (!client) throw new Error('Not initialized');
431
+ let query = client
432
+ .from(m.payload.table)
433
+ .select(m.payload.columns ?? '*');
434
+ if (m.payload.match) query = query.match(m.payload.match);
435
+ if (m.payload.limit) query = query.limit(m.payload.limit);
436
+ const { data, error } = await query;
437
+ if (error) throw error;
438
+ reply(port, { id: m.id, ok: true, data });
439
+ break;
440
+ }
441
+ case 'from.insert': {
442
+ if (!client) throw new Error('Not initialized');
443
+ const { data, error } = await client
444
+ .from(m.payload.table)
445
+ .insert(m.payload.data)
446
+ .select();
447
+ if (error) throw error;
448
+ reply(port, { id: m.id, ok: true, data });
449
+ break;
450
+ }
451
+ case 'from.update': {
452
+ if (!client) throw new Error('Not initialized');
453
+ const { data, error } = await client
454
+ .from(m.payload.table)
455
+ .update(m.payload.data)
456
+ .match(m.payload.match)
457
+ .select();
458
+ if (error) throw error;
459
+ reply(port, { id: m.id, ok: true, data });
460
+ break;
461
+ }
462
+ case 'from.upsert': {
463
+ if (!client) throw new Error('Not initialized');
464
+ const { data, error } = await client
465
+ .from(m.payload.table)
466
+ .upsert(m.payload.data)
467
+ .select();
468
+ if (error) throw error;
469
+ reply(port, { id: m.id, ok: true, data });
470
+ break;
471
+ }
472
+ case 'from.delete': {
473
+ if (!client) throw new Error('Not initialized');
474
+ const { data, error } = await client
475
+ .from(m.payload.table)
476
+ .delete()
477
+ .match(m.payload.match)
478
+ .select();
479
+ if (error) throw error;
480
+ reply(port, { id: m.id, ok: true, data });
481
+ break;
482
+ }
483
+ case 'rpc': {
484
+ if (!client) throw new Error('Not initialized');
485
+ const { fn, args } = m.payload;
486
+ const { data, error } = await client.rpc(fn, args ?? {});
487
+ if (error) throw error;
488
+ reply(port, { id: m.id, ok: true, data });
489
+ break;
490
+ }
491
+ case 'realtime.subscribe': {
492
+ if (!client) throw new Error('Not initialized');
493
+ const { key, params } = m.payload;
494
+ const channel = client
495
+ .channel(key)
496
+ .on(
497
+ 'postgres_changes',
498
+ params as RealtimePostgresChangesFilter<`${REALTIME_POSTGRES_CHANGES_LISTEN_EVENT}`>,
499
+ (payload: unknown) => {
500
+ safeBroadcast({
501
+ type: 'realtime',
502
+ key,
503
+ payload,
504
+ });
505
+ },
506
+ );
507
+ await channel.subscribe();
508
+ subscriptions.set(key, {
509
+ unsubscribe: async () => {
510
+ await channel.unsubscribe();
511
+ },
512
+ });
513
+ reply(port, { id: m.id, ok: true });
514
+ break;
515
+ }
516
+ case 'realtime.unsubscribe': {
517
+ const sub = subscriptions.get(m.payload.key);
518
+ if (sub) {
519
+ await sub.unsubscribe();
520
+ subscriptions.delete(m.payload.key);
521
+ }
522
+ reply(port, { id: m.id, ok: true });
523
+ break;
524
+ }
525
+ case 'ws.connect': {
526
+ await connectWebSocket(m.payload?.wsUrl);
527
+ reply(port, {
528
+ id: m.id,
529
+ ok: true,
530
+ data: getWebSocketStatus(),
531
+ });
532
+ break;
533
+ }
534
+ case 'ws.disconnect': {
535
+ disconnectWebSocket();
536
+ reply(port, { id: m.id, ok: true });
537
+ break;
538
+ }
539
+ case 'ws.send': {
540
+ sendWebSocketMessage(m.payload.data);
541
+ reply(port, { id: m.id, ok: true });
542
+ break;
543
+ }
544
+ case 'ws.status': {
545
+ reply(port, {
546
+ id: m.id,
547
+ ok: true,
548
+ data: getWebSocketStatus(),
549
+ });
550
+ break;
551
+ }
552
+ default: {
553
+ const _exhaustive: never = m;
554
+ reply(port, {
555
+ id: (_exhaustive as unknown as { id: string }).id,
556
+ ok: false,
557
+ error: 'Unknown message type',
558
+ });
559
+ }
560
+ }
561
+ } catch (err: unknown) {
562
+ reply(port, {
563
+ id: m.id,
564
+ ok: false,
565
+ error: err instanceof Error ? err.message : String(err),
566
+ });
567
+ }
568
+ };
569
+
570
+ port.start();
571
+ port.postMessage({ type: 'ready' });
572
+ };
@@ -0,0 +1,64 @@
1
+ import {
2
+ ToastPayloadSchema,
3
+ TooltipPayloadSchema,
4
+ ModalPayloadSchema,
5
+ } from '../types/ui-event-types';
6
+ import type { z } from 'zod';
7
+
8
+ export type UIMessageType =
9
+ | 'toast'
10
+ | 'toast-remove'
11
+ | 'tooltip-open'
12
+ | 'tooltip-close'
13
+ | 'modal-open'
14
+ | 'modal-close';
15
+
16
+ const SCHEMAS: Partial<Record<UIMessageType, z.ZodType<unknown>>> = {
17
+ toast: ToastPayloadSchema,
18
+ 'tooltip-open': TooltipPayloadSchema,
19
+ 'modal-open': ModalPayloadSchema,
20
+ };
21
+
22
+ export interface ValidatedWorkerMessage {
23
+ type: UIMessageType;
24
+ payload: unknown;
25
+ }
26
+
27
+ /**
28
+ * Validate a UI message payload BEFORE postMessage to main thread.
29
+ * Returns the validated message or throws with a descriptive error.
30
+ *
31
+ * Usage (inside worker):
32
+ * const msg = validateUIMessage('toast', rawPayload);
33
+ * emitFromWorker(msg); // guaranteed valid
34
+ */
35
+ export function validateUIMessage(
36
+ type: UIMessageType,
37
+ payload: unknown,
38
+ ): ValidatedWorkerMessage {
39
+ const schema = SCHEMAS[type];
40
+ if (schema) {
41
+ const result = schema.safeParse(payload);
42
+ if (!result.success) {
43
+ throw new Error(
44
+ `[KBVE Worker] Invalid ${type} payload: ${result.error.message}`,
45
+ );
46
+ }
47
+ return { type, payload: result.data };
48
+ }
49
+ // Types without full schemas (toast-remove, tooltip-close, modal-close)
50
+ // only require an id field
51
+ if (
52
+ type === 'toast-remove' ||
53
+ type === 'tooltip-close' ||
54
+ type === 'modal-close'
55
+ ) {
56
+ if (
57
+ !payload ||
58
+ typeof (payload as Record<string, unknown>)['id'] !== 'string'
59
+ ) {
60
+ throw new Error(`[KBVE Worker] ${type} requires { id: string }`);
61
+ }
62
+ }
63
+ return { type, payload };
64
+ }