@kbve/droid 0.0.6 → 0.1.2
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/droid.mjs +2312 -74
- package/index.d.ts +32 -0
- package/lib/agents/api-types.d.ts +121 -0
- package/lib/agents/api-types.d.ts.map +1 -0
- package/lib/agents/auth/init.d.ts +8 -0
- package/lib/agents/auth/init.d.ts.map +1 -0
- package/lib/agents/cache.d.ts +9 -0
- package/lib/agents/cache.d.ts.map +1 -0
- package/lib/agents/client/callGuildVault.d.ts +3 -0
- package/lib/agents/client/callGuildVault.d.ts.map +1 -0
- package/lib/agents/client/callJson.d.ts +3 -0
- package/lib/agents/client/callJson.d.ts.map +1 -0
- package/lib/agents/config.d.ts +36 -0
- package/lib/agents/config.d.ts.map +1 -0
- package/lib/agents/constants.d.ts +11 -0
- package/lib/agents/constants.d.ts.map +1 -0
- package/lib/agents/createAgents.d.ts +4 -0
- package/lib/agents/createAgents.d.ts.map +1 -0
- package/lib/agents/ctx.d.ts +46 -0
- package/lib/agents/ctx.d.ts.map +1 -0
- package/lib/agents/discord/bot.d.ts +21 -0
- package/lib/agents/discord/bot.d.ts.map +1 -0
- package/lib/agents/discord/guilds.d.ts +8 -0
- package/lib/agents/discord/guilds.d.ts.map +1 -0
- package/lib/agents/formDrafts.d.ts +5 -0
- package/lib/agents/formDrafts.d.ts.map +1 -0
- package/lib/agents/generated/agents-schema.d.ts +73 -0
- package/lib/agents/generated/agents-schema.d.ts.map +1 -0
- package/lib/agents/generated/discordsh-agents-schema.d.ts +71 -0
- package/lib/agents/generated/discordsh-agents-schema.d.ts.map +1 -0
- package/lib/agents/github/backfill.d.ts +23 -0
- package/lib/agents/github/backfill.d.ts.map +1 -0
- package/lib/agents/github/events.d.ts +10 -0
- package/lib/agents/github/events.d.ts.map +1 -0
- package/lib/agents/github/pat.d.ts +13 -0
- package/lib/agents/github/pat.d.ts.map +1 -0
- package/lib/agents/github/repoConfig.d.ts +25 -0
- package/lib/agents/github/repoConfig.d.ts.map +1 -0
- package/lib/agents/github/tokens.d.ts +22 -0
- package/lib/agents/github/tokens.d.ts.map +1 -0
- package/lib/agents/github/webhook.d.ts +37 -0
- package/lib/agents/github/webhook.d.ts.map +1 -0
- package/lib/agents/index.d.ts +8 -0
- package/lib/agents/index.d.ts.map +1 -0
- package/lib/agents/state/atoms.d.ts +122 -0
- package/lib/agents/state/atoms.d.ts.map +1 -0
- package/lib/agents/types.d.ts +65 -0
- package/lib/agents/types.d.ts.map +1 -0
- package/lib/gateway/SupabaseGateway.d.ts +38 -0
- package/lib/gateway/SupabaseGateway.d.ts.map +1 -0
- package/lib/gateway/WorkerCommunication.d.ts +39 -0
- package/lib/gateway/WorkerCommunication.d.ts.map +1 -0
- package/lib/gateway/WorkerPool.d.ts +55 -0
- package/lib/gateway/WorkerPool.d.ts.map +1 -0
- package/lib/gateway/capabilities.d.ts +23 -0
- package/lib/gateway/capabilities.d.ts.map +1 -0
- package/lib/gateway/index.d.ts +9 -0
- package/lib/gateway/index.d.ts.map +1 -0
- package/lib/gateway/strategies/DirectStrategy.d.ts +52 -0
- package/lib/gateway/strategies/DirectStrategy.d.ts.map +1 -0
- package/lib/gateway/strategies/SharedWorkerStrategy.d.ts +46 -0
- package/lib/gateway/strategies/SharedWorkerStrategy.d.ts.map +1 -0
- package/lib/gateway/strategies/WebWorkerStrategy.d.ts +45 -0
- package/lib/gateway/strategies/WebWorkerStrategy.d.ts.map +1 -0
- package/lib/gateway/types.d.ts +81 -0
- package/lib/gateway/types.d.ts.map +1 -0
- package/lib/workers/canvas-ui-renderer.d.ts +25 -0
- package/lib/workers/canvas-ui-renderer.d.ts.map +1 -0
- package/lib/workers/canvas-worker.d.ts +14 -0
- package/lib/workers/canvas-worker.d.ts.map +1 -0
- package/lib/workers/canvas-worker.js +5 -302
- package/lib/workers/data.d.ts +40 -0
- package/lib/workers/data.d.ts.map +1 -0
- package/lib/workers/db-worker.d.ts +69 -0
- package/lib/workers/db-worker.d.ts.map +1 -0
- package/lib/workers/db-worker.js +13 -3636
- package/lib/workers/events.d.ts +11 -0
- package/lib/workers/events.d.ts.map +1 -0
- package/lib/workers/flexbuilder.d.ts +4 -0
- package/lib/workers/flexbuilder.d.ts.map +1 -0
- package/lib/workers/init.d.ts +11 -0
- package/lib/workers/init.d.ts.map +1 -0
- package/lib/workers/main.d.ts +105 -0
- package/lib/workers/main.d.ts.map +1 -0
- package/lib/workers/supabase-db-worker.d.ts +2 -0
- package/lib/workers/supabase-db-worker.d.ts.map +1 -0
- package/lib/workers/supabase-db-worker.js +23 -0
- package/lib/workers/supabase-shared-worker.d.ts +2 -0
- package/lib/workers/supabase-shared-worker.d.ts.map +1 -0
- package/lib/workers/supabase-shared-worker.js +25 -0
- package/lib/workers/tools.d.ts +4 -0
- package/lib/workers/tools.d.ts.map +1 -0
- package/lib/workers/validate.d.ts +15 -0
- package/lib/workers/validate.d.ts.map +1 -0
- package/lib/workers/ws-worker.d.ts +10 -0
- package/lib/workers/ws-worker.d.ts.map +1 -0
- package/lib/workers/ws-worker.js +5 -273
- package/main.js +5711 -0
- package/package.json +18 -6
- package/src/index.ts +41 -13
- package/src/lib/agents/api-types.ts +170 -0
- package/src/lib/agents/auth/init.ts +200 -0
- package/src/lib/agents/cache.ts +127 -0
- package/src/lib/agents/client/callGuildVault.ts +71 -0
- package/src/lib/agents/client/callJson.ts +64 -0
- package/src/lib/agents/config.ts +83 -0
- package/src/lib/agents/constants.ts +11 -0
- package/src/lib/agents/createAgents.ts +62 -0
- package/src/lib/agents/ctx.ts +63 -0
- package/src/lib/agents/discord/bot.ts +169 -0
- package/src/lib/agents/discord/guilds.ts +106 -0
- package/src/lib/agents/formDrafts.ts +55 -0
- package/src/lib/agents/generated/agents-schema.ts +155 -0
- package/src/lib/agents/generated/discordsh-agents-schema.ts +96 -0
- package/src/lib/agents/github/backfill.ts +126 -0
- package/src/lib/agents/github/events.ts +131 -0
- package/src/lib/agents/github/pat.ts +150 -0
- package/src/lib/agents/github/repoConfig.ts +240 -0
- package/src/lib/agents/github/tokens.ts +222 -0
- package/src/lib/agents/github/webhook.ts +349 -0
- package/src/lib/agents/index.ts +35 -0
- package/src/lib/agents/state/atoms.ts +122 -0
- package/src/lib/agents/types.ts +83 -0
- package/src/lib/api.ts +71 -0
- package/src/lib/droid.spec.ts +14 -19
- package/src/lib/droid.ts +48 -4
- package/src/lib/gateway/SupabaseGateway.ts +236 -0
- package/src/lib/gateway/WorkerCommunication.ts +132 -0
- package/src/lib/gateway/WorkerPool.ts +304 -0
- package/src/lib/gateway/capabilities.spec.ts +144 -0
- package/src/lib/gateway/capabilities.ts +80 -0
- package/src/lib/gateway/index.ts +15 -0
- package/src/lib/gateway/strategies/DirectStrategy.ts +443 -0
- package/src/lib/gateway/strategies/SharedWorkerStrategy.ts +278 -0
- package/src/lib/gateway/strategies/WebWorkerStrategy.ts +278 -0
- package/src/lib/gateway/types.ts +139 -0
- package/src/lib/mod/mod-manager.ts +13 -8
- package/src/lib/mod/module/bento/mod-bento.worker.ts +1 -1
- package/src/lib/mod/module/supabase/mod-supabase.spec.ts +21 -25
- package/src/lib/mod/module/supabase/mod-supabase.ts +73 -0
- package/src/lib/mod/module/supabase/mod-supabase.worker.ts +1 -56
- package/src/lib/state/auth.spec.ts +58 -0
- package/src/lib/state/auth.ts +148 -0
- package/src/lib/state/bento.spec.ts +76 -0
- package/src/lib/state/bento.ts +51 -0
- package/src/lib/state/index.ts +98 -0
- package/src/lib/state/overlay-manager.ts +125 -0
- package/src/lib/state/profile-sync.ts +214 -0
- package/src/lib/state/profile.ts +155 -0
- package/src/lib/state/router.ts +24 -0
- package/src/lib/state/staff.ts +128 -0
- package/src/lib/state/sync-bus.ts +109 -0
- package/src/lib/state/theme-sync.ts +75 -0
- package/src/lib/state/toasts.spec.ts +106 -0
- package/src/lib/state/toasts.ts +34 -0
- package/src/lib/state/ui.spec.ts +148 -0
- package/src/lib/state/ui.ts +45 -0
- package/src/lib/state/welcome-toast.spec.ts +101 -0
- package/src/lib/state/welcome-toast.ts +76 -0
- package/src/lib/sw-recovery.spec.ts +165 -0
- package/src/lib/sw-recovery.ts +141 -0
- package/src/lib/types/bento.spec.ts +116 -0
- package/src/lib/types/bento.ts +94 -55
- package/src/lib/types/event-schemas.spec.ts +120 -0
- package/src/lib/types/event-types.spec.ts +96 -0
- package/src/lib/types/event-types.ts +113 -3
- package/src/lib/types/modules.ts +12 -5
- package/src/lib/types/ui-event-types.ts +66 -0
- package/src/lib/workers/canvas-ui-renderer.ts +266 -0
- package/src/lib/workers/canvas-worker.ts +94 -9
- package/src/lib/workers/data.ts +64 -37
- package/src/lib/workers/db-worker.ts +105 -32
- package/src/lib/workers/events.spec.ts +208 -0
- package/src/lib/workers/events.ts +27 -14
- package/src/lib/workers/flexbuilder.ts +2 -2
- package/src/lib/workers/init.ts +25 -17
- package/src/lib/workers/main.ts +506 -327
- package/src/lib/workers/supabase-db-worker.ts +243 -0
- package/src/lib/workers/supabase-shared-worker.ts +607 -0
- package/src/lib/workers/validate.ts +64 -0
- package/src/lib/workers/ws-worker.spec.ts +41 -0
- package/src/lib/workers/ws-worker.ts +255 -31
- package/src/setup-vitest.ts +4 -2
- package/src/types.ts +30 -0
- package/src/vite-env.d.ts +1 -0
- package/workers/main.js +2 -1125
- package/comlink.js +0 -253
- package/index.js +0 -2972
- package/reference.js +0 -259
- package/src/types.d.ts +0 -31
|
@@ -0,0 +1,607 @@
|
|
|
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
|
+
/** Emit a worker-error event via BroadcastChannel so the main thread can surface it. */
|
|
101
|
+
function broadcastError(operation: string, err: unknown) {
|
|
102
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
103
|
+
console.error(`[SharedWorker] ${operation}:`, message);
|
|
104
|
+
comm.broadcast({
|
|
105
|
+
type: 'worker-error',
|
|
106
|
+
data: {
|
|
107
|
+
timestamp: Date.now(),
|
|
108
|
+
worker: 'shared',
|
|
109
|
+
operation,
|
|
110
|
+
message,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Post to all connected ports, removing dead ones on failure */
|
|
116
|
+
function safeBroadcast(msg: unknown) {
|
|
117
|
+
for (const p of ports) {
|
|
118
|
+
try {
|
|
119
|
+
p.postMessage(msg);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
ports.delete(p);
|
|
122
|
+
broadcastError('postMessage', err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- IDB-backed auth storage using Dexie 4 ----
|
|
128
|
+
interface KVPair {
|
|
129
|
+
key: string;
|
|
130
|
+
value: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class AuthDB extends Dexie {
|
|
134
|
+
kv!: Table<KVPair>;
|
|
135
|
+
|
|
136
|
+
constructor() {
|
|
137
|
+
super('sb-auth-v2');
|
|
138
|
+
this.version(1).stores({
|
|
139
|
+
kv: 'key',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class IDBStorage {
|
|
145
|
+
private db: AuthDB;
|
|
146
|
+
|
|
147
|
+
constructor() {
|
|
148
|
+
this.db = new AuthDB();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async getItem(key: string): Promise<string | null> {
|
|
152
|
+
try {
|
|
153
|
+
const item = await this.db.kv.get(key);
|
|
154
|
+
return item?.value ?? null;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error('[Worker IDBStorage] getItem error:', err);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async setItem(key: string, value: string): Promise<void> {
|
|
162
|
+
await this.db.kv.put({ key, value });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async removeItem(key: string): Promise<void> {
|
|
166
|
+
await this.db.kv.delete(key);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- Supabase management ----
|
|
171
|
+
let client: SupabaseClient | null = null;
|
|
172
|
+
const storage = new IDBStorage();
|
|
173
|
+
const comm = getWorkerCommunication();
|
|
174
|
+
const subscriptions = new Map<
|
|
175
|
+
string,
|
|
176
|
+
{ unsubscribe: () => Promise<void> | void }
|
|
177
|
+
>();
|
|
178
|
+
|
|
179
|
+
// ---- WebSocket management ----
|
|
180
|
+
let ws: WebSocket | null = null;
|
|
181
|
+
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
182
|
+
let wsReconnectAttempts = 0;
|
|
183
|
+
const WS_MAX_RECONNECT_ATTEMPTS = 5;
|
|
184
|
+
const WS_RECONNECT_DELAY_MS = 3000;
|
|
185
|
+
|
|
186
|
+
// ---- Heartbeat management ----
|
|
187
|
+
let wsHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
188
|
+
let wsLastPongTime = 0;
|
|
189
|
+
const WS_HEARTBEAT_INTERVAL_MS = 30000;
|
|
190
|
+
const WS_HEARTBEAT_TIMEOUT_MS = 60000;
|
|
191
|
+
|
|
192
|
+
function getWebSocketUrl(customUrl?: string): string {
|
|
193
|
+
if (customUrl) return customUrl;
|
|
194
|
+
if (typeof location !== 'undefined') {
|
|
195
|
+
const isLocalhost =
|
|
196
|
+
location.hostname === 'localhost' ||
|
|
197
|
+
location.hostname === '127.0.0.1';
|
|
198
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
199
|
+
const port = isLocalhost ? '4321' : location.port;
|
|
200
|
+
const host = location.hostname;
|
|
201
|
+
return `${protocol}//${host}${port ? ':' + port : ''}/ws`;
|
|
202
|
+
}
|
|
203
|
+
return 'ws://localhost:4321/ws';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function connectWebSocket(wsUrl?: string) {
|
|
207
|
+
if (
|
|
208
|
+
ws &&
|
|
209
|
+
(ws.readyState === WebSocket.CONNECTING ||
|
|
210
|
+
ws.readyState === WebSocket.OPEN)
|
|
211
|
+
) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!client) throw new Error('Supabase client not initialized');
|
|
216
|
+
|
|
217
|
+
const {
|
|
218
|
+
data: { session },
|
|
219
|
+
error,
|
|
220
|
+
} = await client.auth.getSession();
|
|
221
|
+
if (error || !session?.access_token) {
|
|
222
|
+
throw new Error('No active session');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const url = getWebSocketUrl(wsUrl);
|
|
226
|
+
const authenticatedUrl = `${url}?token=${encodeURIComponent(session.access_token)}`;
|
|
227
|
+
|
|
228
|
+
console.log('[SharedWorker] Connecting to WebSocket:', url);
|
|
229
|
+
ws = new WebSocket(authenticatedUrl);
|
|
230
|
+
|
|
231
|
+
ws.onopen = () => {
|
|
232
|
+
wsReconnectAttempts = 0;
|
|
233
|
+
wsLastPongTime = Date.now();
|
|
234
|
+
startHeartbeat();
|
|
235
|
+
|
|
236
|
+
safeBroadcast({ type: 'ws.status', status: 'connected', url });
|
|
237
|
+
comm.broadcast({
|
|
238
|
+
type: 'ws.status',
|
|
239
|
+
data: { status: 'connected', url },
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
ws.onmessage = (event) => {
|
|
244
|
+
try {
|
|
245
|
+
const message = JSON.parse(event.data);
|
|
246
|
+
if (message.type === 'pong') {
|
|
247
|
+
wsLastPongTime = Date.now();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
safeBroadcast({ type: 'ws.message', data: message });
|
|
251
|
+
comm.broadcast({ type: 'ws.message', data: message });
|
|
252
|
+
} catch (error) {
|
|
253
|
+
broadcastError('ws.onmessage', error);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
ws.onerror = () => {
|
|
258
|
+
broadcastError('ws.connect', 'WebSocket connection error');
|
|
259
|
+
safeBroadcast({
|
|
260
|
+
type: 'ws.status',
|
|
261
|
+
status: 'error',
|
|
262
|
+
error: 'WebSocket connection error',
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
ws.onclose = (event) => {
|
|
267
|
+
ws = null;
|
|
268
|
+
stopHeartbeat();
|
|
269
|
+
|
|
270
|
+
safeBroadcast({
|
|
271
|
+
type: 'ws.status',
|
|
272
|
+
status: 'disconnected',
|
|
273
|
+
code: event.code,
|
|
274
|
+
reason: event.reason,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (
|
|
278
|
+
event.code !== 1000 &&
|
|
279
|
+
wsReconnectAttempts < WS_MAX_RECONNECT_ATTEMPTS
|
|
280
|
+
) {
|
|
281
|
+
wsReconnectAttempts++;
|
|
282
|
+
wsReconnectTimer = setTimeout(() => {
|
|
283
|
+
connectWebSocket(wsUrl).catch(() => {
|
|
284
|
+
/* reconnect best-effort */
|
|
285
|
+
});
|
|
286
|
+
}, WS_RECONNECT_DELAY_MS);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function startHeartbeat() {
|
|
292
|
+
stopHeartbeat();
|
|
293
|
+
wsHeartbeatTimer = setInterval(() => {
|
|
294
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
295
|
+
stopHeartbeat();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (
|
|
299
|
+
wsLastPongTime > 0 &&
|
|
300
|
+
Date.now() - wsLastPongTime > WS_HEARTBEAT_TIMEOUT_MS
|
|
301
|
+
) {
|
|
302
|
+
console.warn('[SharedWorker] Heartbeat timeout');
|
|
303
|
+
stopHeartbeat();
|
|
304
|
+
ws.close(1001, 'Heartbeat timeout');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
309
|
+
} catch (error) {
|
|
310
|
+
console.error('[SharedWorker] Failed to send heartbeat:', error);
|
|
311
|
+
}
|
|
312
|
+
}, WS_HEARTBEAT_INTERVAL_MS);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function stopHeartbeat() {
|
|
316
|
+
if (wsHeartbeatTimer) {
|
|
317
|
+
clearInterval(wsHeartbeatTimer);
|
|
318
|
+
wsHeartbeatTimer = null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function disconnectWebSocket() {
|
|
323
|
+
if (wsReconnectTimer) {
|
|
324
|
+
clearTimeout(wsReconnectTimer);
|
|
325
|
+
wsReconnectTimer = null;
|
|
326
|
+
}
|
|
327
|
+
stopHeartbeat();
|
|
328
|
+
if (ws) {
|
|
329
|
+
ws.close(1000, 'Requested by client');
|
|
330
|
+
ws = null;
|
|
331
|
+
wsReconnectAttempts = 0;
|
|
332
|
+
safeBroadcast({ type: 'ws.status', status: 'disconnected' });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function sendWebSocketMessage(data: unknown) {
|
|
337
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
338
|
+
throw new Error('WebSocket not connected');
|
|
339
|
+
const message = typeof data === 'string' ? data : JSON.stringify(data);
|
|
340
|
+
ws.send(message);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function getWebSocketStatus() {
|
|
344
|
+
if (!ws) return { status: 'disconnected', readyState: null };
|
|
345
|
+
const readyStateMap: Record<number, string> = {
|
|
346
|
+
[WebSocket.CONNECTING]: 'connecting',
|
|
347
|
+
[WebSocket.OPEN]: 'connected',
|
|
348
|
+
[WebSocket.CLOSING]: 'closing',
|
|
349
|
+
[WebSocket.CLOSED]: 'disconnected',
|
|
350
|
+
};
|
|
351
|
+
return {
|
|
352
|
+
status: readyStateMap[ws.readyState] || 'unknown',
|
|
353
|
+
readyState: ws.readyState,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function ensureClient(
|
|
358
|
+
url: string,
|
|
359
|
+
anonKey: string,
|
|
360
|
+
options: Record<string, unknown> = {},
|
|
361
|
+
) {
|
|
362
|
+
if (client) return client;
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
new URL(url);
|
|
366
|
+
} catch {
|
|
367
|
+
throw new Error(`Invalid Supabase URL: ${url}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
client = createClient(url, anonKey, {
|
|
371
|
+
auth: {
|
|
372
|
+
storage,
|
|
373
|
+
persistSession: true,
|
|
374
|
+
autoRefreshToken: true,
|
|
375
|
+
},
|
|
376
|
+
realtime: { params: { eventsPerSecond: 5 } },
|
|
377
|
+
...options,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
client.auth.onAuthStateChange(async (_event, session) => {
|
|
381
|
+
// Supabase session objects may contain non-cloneable references
|
|
382
|
+
// (internal SDK callbacks, circular refs). JSON round-trip strips
|
|
383
|
+
// them so postMessage / BroadcastChannel.postMessage won't throw
|
|
384
|
+
// DataCloneError.
|
|
385
|
+
const safe = session ? JSON.parse(JSON.stringify(session)) : null;
|
|
386
|
+
safeBroadcast({ type: 'auth', session: safe });
|
|
387
|
+
comm.broadcast({ type: 'auth', data: { session: safe } });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
return client;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** JSON round-trip to strip non-cloneable refs (functions, circular) before postMessage. */
|
|
394
|
+
function cloneSafe(obj: Res): Res {
|
|
395
|
+
try {
|
|
396
|
+
return JSON.parse(JSON.stringify(obj));
|
|
397
|
+
} catch {
|
|
398
|
+
// JSON.stringify failed (circular refs, BigInt, etc.)
|
|
399
|
+
// Return a safe error response instead of the non-cloneable original
|
|
400
|
+
return {
|
|
401
|
+
id: (obj as { id?: string }).id ?? 'unknown',
|
|
402
|
+
ok: false,
|
|
403
|
+
error: 'Response contained non-serializable data',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function reply(port: MessagePort, msg: Res) {
|
|
409
|
+
port.postMessage(cloneSafe(msg));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
(self as unknown as SharedWorkerGlobalScope).onconnect = (
|
|
413
|
+
evt: MessageEvent,
|
|
414
|
+
) => {
|
|
415
|
+
const port = evt.ports[0];
|
|
416
|
+
ports.add(port);
|
|
417
|
+
|
|
418
|
+
port.onmessage = async (e: MessageEvent<Req>) => {
|
|
419
|
+
const m = e.data;
|
|
420
|
+
try {
|
|
421
|
+
switch (m.type) {
|
|
422
|
+
case 'init': {
|
|
423
|
+
const c = await ensureClient(
|
|
424
|
+
m.payload.url,
|
|
425
|
+
m.payload.anonKey,
|
|
426
|
+
m.payload.options,
|
|
427
|
+
);
|
|
428
|
+
const { data, error } = await c.auth.getSession();
|
|
429
|
+
if (error) throw error;
|
|
430
|
+
reply(port, {
|
|
431
|
+
id: m.id,
|
|
432
|
+
ok: true,
|
|
433
|
+
data: { session: data.session },
|
|
434
|
+
});
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
case 'getSession': {
|
|
438
|
+
if (!client) throw new Error('Not initialized');
|
|
439
|
+
const { data, error } = await client.auth.getSession();
|
|
440
|
+
if (error) throw error;
|
|
441
|
+
reply(port, { id: m.id, ok: true, data });
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case 'signInWithPassword': {
|
|
445
|
+
if (!client) throw new Error('Not initialized');
|
|
446
|
+
const { email, password } = m.payload;
|
|
447
|
+
const { data, error } =
|
|
448
|
+
await client.auth.signInWithPassword({
|
|
449
|
+
email,
|
|
450
|
+
password,
|
|
451
|
+
});
|
|
452
|
+
if (error) throw error;
|
|
453
|
+
reply(port, { id: m.id, ok: true, data });
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
case 'signOut': {
|
|
457
|
+
if (!client) throw new Error('Not initialized');
|
|
458
|
+
const { error } = await client.auth.signOut();
|
|
459
|
+
if (error) throw error;
|
|
460
|
+
reply(port, { id: m.id, ok: true });
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
case 'from.select': {
|
|
464
|
+
if (!client) throw new Error('Not initialized');
|
|
465
|
+
let query = client
|
|
466
|
+
.from(m.payload.table)
|
|
467
|
+
.select(m.payload.columns ?? '*');
|
|
468
|
+
if (m.payload.match) query = query.match(m.payload.match);
|
|
469
|
+
if (m.payload.limit) query = query.limit(m.payload.limit);
|
|
470
|
+
const { data, error } = await query;
|
|
471
|
+
if (error) throw error;
|
|
472
|
+
reply(port, { id: m.id, ok: true, data });
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
case 'from.insert': {
|
|
476
|
+
if (!client) throw new Error('Not initialized');
|
|
477
|
+
const { data, error } = await client
|
|
478
|
+
.from(m.payload.table)
|
|
479
|
+
.insert(m.payload.data)
|
|
480
|
+
.select();
|
|
481
|
+
if (error) throw error;
|
|
482
|
+
reply(port, { id: m.id, ok: true, data });
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
case 'from.update': {
|
|
486
|
+
if (!client) throw new Error('Not initialized');
|
|
487
|
+
const { data, error } = await client
|
|
488
|
+
.from(m.payload.table)
|
|
489
|
+
.update(m.payload.data)
|
|
490
|
+
.match(m.payload.match)
|
|
491
|
+
.select();
|
|
492
|
+
if (error) throw error;
|
|
493
|
+
reply(port, { id: m.id, ok: true, data });
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
case 'from.upsert': {
|
|
497
|
+
if (!client) throw new Error('Not initialized');
|
|
498
|
+
const { data, error } = await client
|
|
499
|
+
.from(m.payload.table)
|
|
500
|
+
.upsert(m.payload.data)
|
|
501
|
+
.select();
|
|
502
|
+
if (error) throw error;
|
|
503
|
+
reply(port, { id: m.id, ok: true, data });
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
case 'from.delete': {
|
|
507
|
+
if (!client) throw new Error('Not initialized');
|
|
508
|
+
const { data, error } = await client
|
|
509
|
+
.from(m.payload.table)
|
|
510
|
+
.delete()
|
|
511
|
+
.match(m.payload.match)
|
|
512
|
+
.select();
|
|
513
|
+
if (error) throw error;
|
|
514
|
+
reply(port, { id: m.id, ok: true, data });
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
case 'rpc': {
|
|
518
|
+
if (!client) throw new Error('Not initialized');
|
|
519
|
+
const { fn, args } = m.payload;
|
|
520
|
+
const { data, error } = await client.rpc(fn, args ?? {});
|
|
521
|
+
if (error) throw error;
|
|
522
|
+
reply(port, { id: m.id, ok: true, data });
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
case 'realtime.subscribe': {
|
|
526
|
+
if (!client) throw new Error('Not initialized');
|
|
527
|
+
const { key, params } = m.payload;
|
|
528
|
+
const channel = client
|
|
529
|
+
.channel(key)
|
|
530
|
+
.on(
|
|
531
|
+
'postgres_changes',
|
|
532
|
+
params as RealtimePostgresChangesFilter<`${REALTIME_POSTGRES_CHANGES_LISTEN_EVENT}`>,
|
|
533
|
+
(payload: unknown) => {
|
|
534
|
+
safeBroadcast({
|
|
535
|
+
type: 'realtime',
|
|
536
|
+
key,
|
|
537
|
+
payload,
|
|
538
|
+
});
|
|
539
|
+
},
|
|
540
|
+
);
|
|
541
|
+
await channel.subscribe();
|
|
542
|
+
subscriptions.set(key, {
|
|
543
|
+
unsubscribe: async () => {
|
|
544
|
+
await channel.unsubscribe();
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
reply(port, { id: m.id, ok: true });
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
case 'realtime.unsubscribe': {
|
|
551
|
+
const sub = subscriptions.get(m.payload.key);
|
|
552
|
+
if (sub) {
|
|
553
|
+
await sub.unsubscribe();
|
|
554
|
+
subscriptions.delete(m.payload.key);
|
|
555
|
+
}
|
|
556
|
+
reply(port, { id: m.id, ok: true });
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
case 'ws.connect': {
|
|
560
|
+
await connectWebSocket(m.payload?.wsUrl);
|
|
561
|
+
reply(port, {
|
|
562
|
+
id: m.id,
|
|
563
|
+
ok: true,
|
|
564
|
+
data: getWebSocketStatus(),
|
|
565
|
+
});
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
case 'ws.disconnect': {
|
|
569
|
+
disconnectWebSocket();
|
|
570
|
+
reply(port, { id: m.id, ok: true });
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
case 'ws.send': {
|
|
574
|
+
sendWebSocketMessage(m.payload.data);
|
|
575
|
+
reply(port, { id: m.id, ok: true });
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
case 'ws.status': {
|
|
579
|
+
reply(port, {
|
|
580
|
+
id: m.id,
|
|
581
|
+
ok: true,
|
|
582
|
+
data: getWebSocketStatus(),
|
|
583
|
+
});
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
default: {
|
|
587
|
+
const _exhaustive: never = m;
|
|
588
|
+
reply(port, {
|
|
589
|
+
id: (_exhaustive as unknown as { id: string }).id,
|
|
590
|
+
ok: false,
|
|
591
|
+
error: 'Unknown message type',
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
} catch (err: unknown) {
|
|
596
|
+
broadcastError(m.type, err);
|
|
597
|
+
reply(port, {
|
|
598
|
+
id: m.id,
|
|
599
|
+
ok: false,
|
|
600
|
+
error: err instanceof Error ? err.message : String(err),
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
port.start();
|
|
606
|
+
port.postMessage({ type: 'ready' });
|
|
607
|
+
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { reconnectDelayMs } from './ws-worker';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.restoreAllMocks();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe('reconnectDelayMs', () => {
|
|
9
|
+
it('returns base delay on first attempt', () => {
|
|
10
|
+
vi.spyOn(Math, 'random').mockReturnValue(0);
|
|
11
|
+
expect(reconnectDelayMs(1)).toBe(1000);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('doubles each attempt', () => {
|
|
15
|
+
vi.spyOn(Math, 'random').mockReturnValue(0);
|
|
16
|
+
expect(reconnectDelayMs(1)).toBe(1000);
|
|
17
|
+
expect(reconnectDelayMs(2)).toBe(2000);
|
|
18
|
+
expect(reconnectDelayMs(3)).toBe(4000);
|
|
19
|
+
expect(reconnectDelayMs(4)).toBe(8000);
|
|
20
|
+
expect(reconnectDelayMs(5)).toBe(16000);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('caps at the 30s ceiling', () => {
|
|
24
|
+
vi.spyOn(Math, 'random').mockReturnValue(0);
|
|
25
|
+
expect(reconnectDelayMs(10)).toBe(30000);
|
|
26
|
+
expect(reconnectDelayMs(100)).toBe(30000);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('adds jitter up to 500ms', () => {
|
|
30
|
+
vi.spyOn(Math, 'random').mockReturnValue(0.99999);
|
|
31
|
+
const delay = reconnectDelayMs(1);
|
|
32
|
+
expect(delay).toBeGreaterThanOrEqual(1000);
|
|
33
|
+
expect(delay).toBeLessThan(1500);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('treats 0 and negative attempts as the first attempt', () => {
|
|
37
|
+
vi.spyOn(Math, 'random').mockReturnValue(0);
|
|
38
|
+
expect(reconnectDelayMs(0)).toBe(1000);
|
|
39
|
+
expect(reconnectDelayMs(-5)).toBe(1000);
|
|
40
|
+
});
|
|
41
|
+
});
|