@kbve/droid 0.1.1 → 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.
Files changed (120) hide show
  1. package/droid.mjs +2311 -203
  2. package/index.d.ts +3 -0
  3. package/lib/agents/api-types.d.ts +121 -0
  4. package/lib/agents/api-types.d.ts.map +1 -0
  5. package/lib/agents/auth/init.d.ts +8 -0
  6. package/lib/agents/auth/init.d.ts.map +1 -0
  7. package/lib/agents/cache.d.ts +9 -0
  8. package/lib/agents/cache.d.ts.map +1 -0
  9. package/lib/agents/client/callGuildVault.d.ts +3 -0
  10. package/lib/agents/client/callGuildVault.d.ts.map +1 -0
  11. package/lib/agents/client/callJson.d.ts +3 -0
  12. package/lib/agents/client/callJson.d.ts.map +1 -0
  13. package/lib/agents/config.d.ts +36 -0
  14. package/lib/agents/config.d.ts.map +1 -0
  15. package/lib/agents/constants.d.ts +11 -0
  16. package/lib/agents/constants.d.ts.map +1 -0
  17. package/lib/agents/createAgents.d.ts +4 -0
  18. package/lib/agents/createAgents.d.ts.map +1 -0
  19. package/lib/agents/ctx.d.ts +46 -0
  20. package/lib/agents/ctx.d.ts.map +1 -0
  21. package/lib/agents/discord/bot.d.ts +21 -0
  22. package/lib/agents/discord/bot.d.ts.map +1 -0
  23. package/lib/agents/discord/guilds.d.ts +8 -0
  24. package/lib/agents/discord/guilds.d.ts.map +1 -0
  25. package/lib/agents/formDrafts.d.ts +5 -0
  26. package/lib/agents/formDrafts.d.ts.map +1 -0
  27. package/lib/agents/generated/agents-schema.d.ts +73 -0
  28. package/lib/agents/generated/agents-schema.d.ts.map +1 -0
  29. package/lib/agents/generated/discordsh-agents-schema.d.ts +71 -0
  30. package/lib/agents/generated/discordsh-agents-schema.d.ts.map +1 -0
  31. package/lib/agents/github/backfill.d.ts +23 -0
  32. package/lib/agents/github/backfill.d.ts.map +1 -0
  33. package/lib/agents/github/events.d.ts +10 -0
  34. package/lib/agents/github/events.d.ts.map +1 -0
  35. package/lib/agents/github/pat.d.ts +13 -0
  36. package/lib/agents/github/pat.d.ts.map +1 -0
  37. package/lib/agents/github/repoConfig.d.ts +25 -0
  38. package/lib/agents/github/repoConfig.d.ts.map +1 -0
  39. package/lib/agents/github/tokens.d.ts +22 -0
  40. package/lib/agents/github/tokens.d.ts.map +1 -0
  41. package/lib/agents/github/webhook.d.ts +37 -0
  42. package/lib/agents/github/webhook.d.ts.map +1 -0
  43. package/lib/agents/index.d.ts +8 -0
  44. package/lib/agents/index.d.ts.map +1 -0
  45. package/lib/agents/state/atoms.d.ts +122 -0
  46. package/lib/agents/state/atoms.d.ts.map +1 -0
  47. package/lib/agents/types.d.ts +65 -0
  48. package/lib/agents/types.d.ts.map +1 -0
  49. package/lib/gateway/SupabaseGateway.d.ts.map +1 -1
  50. package/lib/gateway/types.d.ts +1 -1
  51. package/lib/gateway/types.d.ts.map +1 -1
  52. package/lib/workers/canvas-worker.js +6 -449
  53. package/lib/workers/data.d.ts.map +1 -1
  54. package/lib/workers/db-worker.d.ts +2 -2
  55. package/lib/workers/db-worker.d.ts.map +1 -1
  56. package/lib/workers/db-worker.js +14 -4170
  57. package/lib/workers/main.d.ts +0 -2
  58. package/lib/workers/main.d.ts.map +1 -1
  59. package/lib/workers/supabase-db-worker.js +11 -9325
  60. package/lib/workers/supabase-shared-worker.js +13 -12623
  61. package/lib/workers/ws-worker.d.ts +1 -2
  62. package/lib/workers/ws-worker.d.ts.map +1 -1
  63. package/lib/workers/ws-worker.js +6 -333
  64. package/main.js +5622 -4786
  65. package/package.json +2 -1
  66. package/src/index.ts +3 -0
  67. package/src/lib/agents/api-types.ts +170 -0
  68. package/src/lib/agents/auth/init.ts +200 -0
  69. package/src/lib/agents/cache.ts +127 -0
  70. package/src/lib/agents/client/callGuildVault.ts +71 -0
  71. package/src/lib/agents/client/callJson.ts +64 -0
  72. package/src/lib/agents/config.ts +83 -0
  73. package/src/lib/agents/constants.ts +11 -0
  74. package/src/lib/agents/createAgents.ts +62 -0
  75. package/src/lib/agents/ctx.ts +63 -0
  76. package/src/lib/agents/discord/bot.ts +169 -0
  77. package/src/lib/agents/discord/guilds.ts +106 -0
  78. package/src/lib/agents/formDrafts.ts +55 -0
  79. package/src/lib/agents/generated/agents-schema.ts +155 -0
  80. package/src/lib/agents/generated/discordsh-agents-schema.ts +96 -0
  81. package/src/lib/agents/github/backfill.ts +126 -0
  82. package/src/lib/agents/github/events.ts +131 -0
  83. package/src/lib/agents/github/pat.ts +150 -0
  84. package/src/lib/agents/github/repoConfig.ts +240 -0
  85. package/src/lib/agents/github/tokens.ts +222 -0
  86. package/src/lib/agents/github/webhook.ts +349 -0
  87. package/src/lib/agents/index.ts +35 -0
  88. package/src/lib/agents/state/atoms.ts +122 -0
  89. package/src/lib/agents/types.ts +83 -0
  90. package/src/lib/api.ts +71 -0
  91. package/src/lib/gateway/SupabaseGateway.ts +55 -2
  92. package/src/lib/gateway/WorkerCommunication.ts +1 -1
  93. package/src/lib/gateway/capabilities.spec.ts +144 -0
  94. package/src/lib/gateway/types.ts +97 -70
  95. package/src/lib/mod/module/supabase/mod-supabase.ts +10 -5
  96. package/src/lib/state/auth.ts +121 -3
  97. package/src/lib/state/bento.spec.ts +76 -0
  98. package/src/lib/state/bento.ts +51 -0
  99. package/src/lib/state/index.ts +64 -0
  100. package/src/lib/state/profile-sync.ts +214 -0
  101. package/src/lib/state/profile.ts +155 -0
  102. package/src/lib/state/staff.ts +128 -0
  103. package/src/lib/state/sync-bus.ts +109 -0
  104. package/src/lib/state/welcome-toast.spec.ts +101 -0
  105. package/src/lib/sw-recovery.spec.ts +165 -0
  106. package/src/lib/sw-recovery.ts +141 -0
  107. package/src/lib/types/event-schemas.spec.ts +120 -0
  108. package/src/lib/types/event-types.spec.ts +5 -2
  109. package/src/lib/types/event-types.ts +74 -0
  110. package/src/lib/workers/data.ts +5 -1
  111. package/src/lib/workers/db-worker.ts +34 -8
  112. package/src/lib/workers/main.ts +3 -18
  113. package/src/lib/workers/supabase-db-worker.ts +25 -3
  114. package/src/lib/workers/supabase-shared-worker.ts +43 -8
  115. package/src/lib/workers/ws-worker.spec.ts +41 -0
  116. package/src/lib/workers/ws-worker.ts +121 -28
  117. package/workers/main.js +2 -12
  118. package/comlink.js +0 -247
  119. package/index.js +0 -9263
  120. package/reference.js +0 -719
@@ -43,6 +43,72 @@ export const DroidUpscaleSchema = z.object({
43
43
  level: z.string(),
44
44
  });
45
45
 
46
+ export const AuthReadySchema = z.object({
47
+ timestamp: z.number(),
48
+ tone: z.enum(['auth', 'anon']),
49
+ name: z.string().optional(),
50
+ });
51
+
52
+ export const AuthErrorSchema = z.object({
53
+ timestamp: z.number(),
54
+ message: z.string(),
55
+ });
56
+
57
+ export const WorkerErrorSchema = z.object({
58
+ timestamp: z.number(),
59
+ worker: z.enum(['shared', 'db', 'ws']),
60
+ operation: z.string(),
61
+ message: z.string(),
62
+ });
63
+
64
+ export const GatewayStrategyFallbackSchema = z.object({
65
+ timestamp: z.number(),
66
+ from: z.enum(['shared-worker', 'web-worker', 'direct']),
67
+ to: z.enum(['shared-worker', 'web-worker', 'direct']),
68
+ reason: z.string(),
69
+ });
70
+
71
+ // Page lifecycle — emitted by @kbve/astro's onMount/onSwap helpers so any
72
+ // module on the bus (mods, plugins, vanilla driver) can react to navigation
73
+ // without knowing whether the host is using ClientRouter, native cross-doc
74
+ // view transitions, full reloads, or bfcache restores.
75
+ export const PageLifecycleSchema = z.object({
76
+ timestamp: z.number(),
77
+ url: z.string(),
78
+ source: z.enum(['initial', 'astro-swap', 'page-reveal', 'bfcache']),
79
+ });
80
+
81
+ export const PalworldLiveSnapshotSchema = z.object({
82
+ ts: z.number(),
83
+ offline: z.boolean(),
84
+ players: z.array(
85
+ z.object({
86
+ name: z.string(),
87
+ level: z.number(),
88
+ x: z.number(),
89
+ y: z.number(),
90
+ }),
91
+ ),
92
+ bosses: z.array(
93
+ z.object({
94
+ id: z.string(),
95
+ x: z.number(),
96
+ y: z.number(),
97
+ defeated_at: z.number(),
98
+ respawn_at: z.number(),
99
+ }),
100
+ ),
101
+ events: z.array(
102
+ z.object({
103
+ kind: z.string(),
104
+ class: z.string(),
105
+ x: z.number(),
106
+ y: z.number(),
107
+ first_seen: z.number(),
108
+ }),
109
+ ),
110
+ });
111
+
46
112
  export const DroidEventSchemas = {
47
113
  'droid-first-connect': DroidFirstConnectSchema,
48
114
  'droid-ready': DroidReadySchema,
@@ -57,6 +123,14 @@ export const DroidEventSchemas = {
57
123
  'tooltip-closed': TooltipPayloadSchema.pick({ id: true }),
58
124
  'modal-opened': ModalPayloadSchema,
59
125
  'modal-closed': ModalPayloadSchema.pick({ id: true }),
126
+ 'auth-ready': AuthReadySchema,
127
+ 'auth-error': AuthErrorSchema,
128
+ 'worker-error': WorkerErrorSchema,
129
+ 'gateway-strategy-fallback': GatewayStrategyFallbackSchema,
130
+ 'page-mount': PageLifecycleSchema,
131
+ 'page-swap': PageLifecycleSchema,
132
+ 'page-hide': PageLifecycleSchema,
133
+ 'palworld-live-snapshot': PalworldLiveSnapshotSchema,
60
134
  };
61
135
 
62
136
  export type DroidEventMap = {
@@ -1,4 +1,8 @@
1
- import { PayloadFormat, JediEnvelopeFlex, MessageKind } from '../types/jedi';
1
+ import {
2
+ PayloadFormat,
3
+ type JediEnvelopeFlex,
4
+ MessageKind,
5
+ } from '../types/jedi';
2
6
  import { builder, toReference } from './flexbuilder';
3
7
 
4
8
  function buildFlexPayload<T extends Record<string, unknown>>(
@@ -1,7 +1,6 @@
1
1
  import { expose } from 'comlink';
2
2
  import Dexie, { type Table } from 'dexie';
3
3
  import type { DiscordServer, DiscordTag, Profile } from '../types/discord';
4
- import { toReference } from './flexbuilder';
5
4
 
6
5
  interface SharedWorkerGlobalScope extends Worker {
7
6
  onconnect: (event: MessageEvent) => void;
@@ -16,7 +15,10 @@ class AppDexie extends Dexie {
16
15
  servers!: Table<DiscordServer>;
17
16
  tags!: Table<DiscordTag>;
18
17
  profiles!: Table<Profile>;
19
- ws_messages!: Table<{ key: string; message: Record<string, unknown> }>;
18
+ ws_messages!: Table<{
19
+ key: string;
20
+ message: Uint8Array | Record<string, unknown>;
21
+ }>;
20
22
  auth_tokens!: Table<{ key: string; value: string; expires_at?: number }>;
21
23
 
22
24
  constructor() {
@@ -36,6 +38,29 @@ class AppDexie extends Dexie {
36
38
  }
37
39
  const db = new AppDexie();
38
40
 
41
+ // --- ws-worker → db-worker direct pipeline ---
42
+ // Listen for WebSocket messages from the ws-worker via BroadcastChannel.
43
+ // This keeps the main thread out of the data storage path entirely.
44
+ if (typeof BroadcastChannel !== 'undefined') {
45
+ const wsChannel = new BroadcastChannel('kbve_ws_data');
46
+ wsChannel.onmessage = (e: MessageEvent) => {
47
+ const msg = e.data;
48
+ if (msg?.type !== 'ws.store') return;
49
+
50
+ const key = `ws:${msg.ts ?? Date.now()}`;
51
+ const message =
52
+ msg.format === 'text'
53
+ ? new TextEncoder().encode(msg.data)
54
+ : msg.data instanceof Uint8Array
55
+ ? msg.data
56
+ : new Uint8Array(msg.data ?? []);
57
+
58
+ db.ws_messages.put({ key, message }).catch((err) => {
59
+ console.error('[DB] Failed to store ws message:', err);
60
+ });
61
+ };
62
+ }
63
+
39
64
  function renderHtmlForServer(server: DiscordServer): string {
40
65
  return `
41
66
  <div class="flex flex-col gap-2 p-2">
@@ -52,11 +77,12 @@ const storageAPI = {
52
77
  // WebSocket
53
78
 
54
79
  async storeWsMessage(key: string, buffer: ArrayBuffer) {
55
- const decoded = toReference(buffer).toObject() as Record<
56
- string,
57
- unknown
58
- >;
59
- await db.ws_messages.put({ key, message: decoded });
80
+ // Store raw bytes format-agnostic so the ws-worker can carry
81
+ // any protocol (IRC text, FlatBuffers, Protobuf, etc.).
82
+ // Uint8Array is natively supported by IndexedDB and safe for
83
+ // Dexie's BroadcastChannel multi-tab sync (no DataCloneError).
84
+ // Consumers decode on read using the appropriate deserializer.
85
+ await db.ws_messages.put({ key, message: new Uint8Array(buffer) });
60
86
  },
61
87
 
62
88
  async getWsMessage(key: string) {
@@ -64,7 +90,7 @@ const storageAPI = {
64
90
  },
65
91
 
66
92
  async getAllWsMessages(): Promise<
67
- { key: string; message: Record<string, unknown> }[]
93
+ { key: string; message: Uint8Array | Record<string, unknown> }[]
68
94
  > {
69
95
  const raw = await db.ws_messages.toArray();
70
96
  return raw.sort((a, b) => {
@@ -1,4 +1,4 @@
1
- import { wrap, proxy } from 'comlink';
1
+ import { wrap } from 'comlink';
2
2
  import type { Remote } from 'comlink';
3
3
  import { persistentMap } from '@nanostores/persistent';
4
4
  import type { LocalStorageAPI } from './db-worker';
@@ -582,22 +582,6 @@ function initSWComlink() {
582
582
  channel.port1.start();
583
583
  }
584
584
 
585
- // --- Bridge ---
586
- export function bridgeWsToDb(
587
- ws: Remote<WSInstance>,
588
- db: Remote<LocalStorageAPI>,
589
- ) {
590
- const handler = proxy(async (data: string | ArrayBuffer) => {
591
- if (typeof data === 'string') return;
592
- dispatchAsync(() => {
593
- const key = `ws:${Date.now()}`;
594
- void db.storeWsMessage(key, data);
595
- });
596
- });
597
-
598
- ws.onMessage(handler);
599
- }
600
-
601
585
  // --- MAIN ---
602
586
  export async function main(opts?: {
603
587
  workerURLs?: Record<string, string>;
@@ -694,7 +678,8 @@ export async function main(opts?: {
694
678
  });
695
679
  }
696
680
 
697
- bridgeWsToDb(ws, api);
681
+ // ws-worker → db-worker storage now happens directly via
682
+ // BroadcastChannel ('kbve_ws_data'), no main-thread bridge needed.
698
683
 
699
684
  const data = scopeData;
700
685
  i18n.api = api;
@@ -85,7 +85,17 @@ const memoryStorage = {
85
85
 
86
86
  console.log('[DB Worker] Initializing...');
87
87
 
88
+ // Dedicated workers receive `event.origin === ''` from their owning page;
89
+ // reject anything else to satisfy CodeQL js/missing-origin-check.
90
+ function isAllowedOrigin(event: MessageEvent): boolean {
91
+ return event.origin === '' || event.origin === self.location.origin;
92
+ }
93
+
88
94
  self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
95
+ if (!isAllowedOrigin(e)) {
96
+ console.warn('[DB Worker] Rejected message from origin:', e.origin);
97
+ return;
98
+ }
89
99
  const msg = e.data;
90
100
  const { id, type, payload } = msg;
91
101
 
@@ -109,8 +119,12 @@ self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
109
119
  ...authOverrides,
110
120
  storage: memoryStorage,
111
121
  storageKey: 'sb-auth-token',
112
- autoRefreshToken: true,
113
- persistSession: true,
122
+ // DB pool workers are stateless (memoryStorage) — token
123
+ // refresh is handled by the SharedWorker which owns the
124
+ // IDB-backed session. Disabling here avoids SDK-internal
125
+ // callbacks that produce non-cloneable objects.
126
+ autoRefreshToken: false,
127
+ persistSession: false,
114
128
  detectSessionInUrl: false,
115
129
  },
116
130
  });
@@ -215,7 +229,15 @@ function respond(
215
229
  id: string,
216
230
  response: { ok: true; data?: unknown } | { ok: false; error: string },
217
231
  ) {
218
- self.postMessage({ id, ...response });
232
+ // JSON round-trip strips non-cloneable SDK internals (functions, circular
233
+ // refs) that would cause DataCloneError in postMessage.
234
+ let safe: typeof response;
235
+ try {
236
+ safe = JSON.parse(JSON.stringify(response));
237
+ } catch {
238
+ safe = { ok: false, error: 'Response contained non-serializable data' };
239
+ }
240
+ self.postMessage({ id, ...safe });
219
241
  }
220
242
 
221
243
  console.log('[DB Worker] Ready');
@@ -97,13 +97,29 @@ type Res =
97
97
 
98
98
  const ports = new Set<MessagePort>();
99
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
+
100
115
  /** Post to all connected ports, removing dead ones on failure */
101
116
  function safeBroadcast(msg: unknown) {
102
117
  for (const p of ports) {
103
118
  try {
104
119
  p.postMessage(msg);
105
- } catch {
120
+ } catch (err) {
106
121
  ports.delete(p);
122
+ broadcastError('postMessage', err);
107
123
  }
108
124
  }
109
125
  }
@@ -234,14 +250,12 @@ async function connectWebSocket(wsUrl?: string) {
234
250
  safeBroadcast({ type: 'ws.message', data: message });
235
251
  comm.broadcast({ type: 'ws.message', data: message });
236
252
  } catch (error) {
237
- console.error(
238
- '[SharedWorker] Failed to parse WebSocket message:',
239
- error,
240
- );
253
+ broadcastError('ws.onmessage', error);
241
254
  }
242
255
  };
243
256
 
244
257
  ws.onerror = () => {
258
+ broadcastError('ws.connect', 'WebSocket connection error');
245
259
  safeBroadcast({
246
260
  type: 'ws.status',
247
261
  status: 'error',
@@ -364,15 +378,35 @@ async function ensureClient(
364
378
  });
365
379
 
366
380
  client.auth.onAuthStateChange(async (_event, session) => {
367
- safeBroadcast({ type: 'auth', session });
368
- comm.broadcast({ type: 'auth', data: { 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 } });
369
388
  });
370
389
 
371
390
  return client;
372
391
  }
373
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
+
374
408
  function reply(port: MessagePort, msg: Res) {
375
- port.postMessage(msg);
409
+ port.postMessage(cloneSafe(msg));
376
410
  }
377
411
 
378
412
  (self as unknown as SharedWorkerGlobalScope).onconnect = (
@@ -559,6 +593,7 @@ function reply(port: MessagePort, msg: Res) {
559
593
  }
560
594
  }
561
595
  } catch (err: unknown) {
596
+ broadcastError(m.type, err);
562
597
  reply(port, {
563
598
  id: m.id,
564
599
  ok: false,
@@ -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
+ });
@@ -6,8 +6,40 @@ interface SharedWorkerGlobalScope extends Worker {
6
6
  declare const self: SharedWorkerGlobalScope;
7
7
 
8
8
  let ws: WebSocket | null = null;
9
- let onMessageCallback: ((data: string | ArrayBuffer) => void) | null = null;
10
- let onStatusCallback: ((status: string) => void) | null = null;
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Transport channels
12
+ //
13
+ // `kbve_ws_data` — raw WebSocket payloads for the db-worker to persist.
14
+ // Payload: { type: 'ws.store', format, data, ts }
15
+ //
16
+ // `kbve_ws_events` — status + message events for UI consumers (chat service,
17
+ // presence, overlays, etc.).
18
+ // Payload:
19
+ // { type: 'status', status: string }
20
+ // { type: 'message', format: 'text', data: string }
21
+ // { type: 'message', format: 'binary', data: Uint8Array }
22
+ //
23
+ // Keeping persistence and UI on separate channels means each consumer only
24
+ // sees what it cares about, and a new subscriber (e.g. cross-tab presence)
25
+ // drops in without filtering db-bound payloads.
26
+ //
27
+ // Historical note: an earlier design exposed onMessage(cb) / onStatus(cb)
28
+ // through Comlink. That failed with DataCloneError because Comlink forwards
29
+ // method args via postMessage, and functions are not structured-cloneable.
30
+ // If a future strategy wants a different transport (SharedArrayBuffer,
31
+ // MessagePort, proxied-Comlink callbacks), add it alongside — don't remove
32
+ // the BroadcastChannel path, it's the fallback that works everywhere.
33
+ // ---------------------------------------------------------------------------
34
+ const dbChannel =
35
+ typeof BroadcastChannel !== 'undefined'
36
+ ? new BroadcastChannel('kbve_ws_data')
37
+ : null;
38
+
39
+ const eventsChannel =
40
+ typeof BroadcastChannel !== 'undefined'
41
+ ? new BroadcastChannel('kbve_ws_events')
42
+ : null;
11
43
 
12
44
  // Heartbeat
13
45
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
@@ -15,12 +47,22 @@ let lastPongTime = 0;
15
47
  const HEARTBEAT_INTERVAL_MS = 30000;
16
48
  const HEARTBEAT_TIMEOUT_MS = 60000;
17
49
 
18
- // Reconnect
19
50
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
20
51
  let reconnectAttempts = 0;
21
52
  let lastUrl: string | null = null;
22
53
  const MAX_RECONNECT_ATTEMPTS = 5;
23
- const RECONNECT_DELAY_MS = 3000;
54
+ const RECONNECT_BASE_MS = 1000;
55
+ const RECONNECT_MAX_MS = 30000;
56
+ const RECONNECT_JITTER_MS = 500;
57
+
58
+ export function reconnectDelayMs(attempt: number): number {
59
+ const exp = Math.min(
60
+ RECONNECT_MAX_MS,
61
+ RECONNECT_BASE_MS * 2 ** Math.max(0, attempt - 1),
62
+ );
63
+ const jitter = Math.floor(Math.random() * RECONNECT_JITTER_MS);
64
+ return exp + jitter;
65
+ }
24
66
 
25
67
  function startHeartbeat() {
26
68
  stopHeartbeat();
@@ -53,8 +95,11 @@ function stopHeartbeat() {
53
95
  }
54
96
  }
55
97
 
56
- function broadcastStatus(status: string) {
57
- onStatusCallback?.(status);
98
+ function broadcastStatus(
99
+ status: string,
100
+ detail?: { code?: number; reason?: string; wasClean?: boolean },
101
+ ) {
102
+ eventsChannel?.postMessage({ type: 'status', status, ...detail });
58
103
  }
59
104
 
60
105
  function attemptReconnect() {
@@ -69,17 +114,25 @@ function attemptReconnect() {
69
114
  }
70
115
 
71
116
  reconnectAttempts++;
117
+ const delay = reconnectDelayMs(reconnectAttempts);
72
118
  console.log(
73
- `[WS] Reconnecting (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`,
119
+ `[WS] Reconnecting (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}) in ${delay}ms...`,
74
120
  );
75
121
  broadcastStatus('reconnecting');
76
122
 
77
123
  reconnectTimer = setTimeout(() => {
78
124
  if (lastUrl) wsInstanceAPI.connect(lastUrl);
79
- }, RECONNECT_DELAY_MS);
125
+ }, delay);
80
126
  }
81
127
 
82
- // --- WebSocket API ---
128
+ // ---------------------------------------------------------------------------
129
+ // WebSocket API exposed via Comlink
130
+ //
131
+ // Only commands that carry structured-cloneable data cross this bridge
132
+ // (strings, typed arrays). Event subscription is deliberately NOT here —
133
+ // it lives on the BroadcastChannel side to avoid DataCloneError when a
134
+ // caller passes a raw function.
135
+ // ---------------------------------------------------------------------------
83
136
  const wsInstanceAPI = {
84
137
  async connect(url: string) {
85
138
  if (ws && ws.readyState === WebSocket.OPEN) return;
@@ -92,11 +145,13 @@ const wsInstanceAPI = {
92
145
  }
93
146
 
94
147
  lastUrl = url;
148
+ const safeUrl = url.replace(/(token=)[^&]+/i, '$1<redacted>');
149
+ console.log('[WS] Connecting to', safeUrl);
95
150
  ws = new WebSocket(url);
96
151
  ws.binaryType = 'arraybuffer';
97
152
 
98
153
  ws.onopen = () => {
99
- console.log('[WS] Connected:', url);
154
+ console.log('[WS] Connected:', safeUrl);
100
155
  reconnectAttempts = 0;
101
156
  lastPongTime = Date.now();
102
157
  startHeartbeat();
@@ -118,10 +173,43 @@ const wsInstanceAPI = {
118
173
  }
119
174
  }
120
175
 
121
- if (e.data instanceof ArrayBuffer) {
122
- onMessageCallback?.(e.data);
123
- } else {
124
- onMessageCallback?.(e.data);
176
+ // Fan out to db-worker (persistence pipeline).
177
+ // Structured-cloneable payloads only — strings and
178
+ // Uint8Array are both safe across BroadcastChannel.
179
+ if (dbChannel) {
180
+ if (typeof e.data === 'string') {
181
+ dbChannel.postMessage({
182
+ type: 'ws.store',
183
+ format: 'text',
184
+ data: e.data,
185
+ ts: Date.now(),
186
+ });
187
+ } else if (e.data instanceof ArrayBuffer) {
188
+ dbChannel.postMessage({
189
+ type: 'ws.store',
190
+ format: 'binary',
191
+ data: new Uint8Array(e.data),
192
+ ts: Date.now(),
193
+ });
194
+ }
195
+ }
196
+
197
+ // Fan out to UI consumers (chat service, presence, etc.).
198
+ // Same structured-cloneable constraint applies.
199
+ if (eventsChannel) {
200
+ if (typeof e.data === 'string') {
201
+ eventsChannel.postMessage({
202
+ type: 'message',
203
+ format: 'text',
204
+ data: e.data,
205
+ });
206
+ } else if (e.data instanceof ArrayBuffer) {
207
+ eventsChannel.postMessage({
208
+ type: 'message',
209
+ format: 'binary',
210
+ data: new Uint8Array(e.data),
211
+ });
212
+ }
125
213
  }
126
214
  } catch (err) {
127
215
  console.error('[WS] Failed to forward message', err);
@@ -129,17 +217,24 @@ const wsInstanceAPI = {
129
217
  };
130
218
 
131
219
  ws.onerror = (e) => {
132
- console.error('[WS] Error:', e);
133
- broadcastStatus('error');
220
+ console.error('[WS] Error event:', e);
221
+ broadcastStatus('error', {
222
+ reason: 'WebSocket error event (no detail in browser API)',
223
+ });
134
224
  };
135
225
 
136
226
  ws.onclose = (event) => {
137
- console.log('[WS] Disconnected:', event.code, event.reason);
227
+ console.log(
228
+ `[WS] Disconnected code=${event.code} reason="${event.reason}" wasClean=${event.wasClean}`,
229
+ );
138
230
  ws = null;
139
231
  stopHeartbeat();
140
- broadcastStatus('disconnected');
232
+ broadcastStatus('disconnected', {
233
+ code: event.code,
234
+ reason: event.reason,
235
+ wasClean: event.wasClean,
236
+ });
141
237
 
142
- // Auto-reconnect on abnormal closure
143
238
  if (event.code !== 1000) {
144
239
  attemptReconnect();
145
240
  }
@@ -148,7 +243,13 @@ const wsInstanceAPI = {
148
243
 
149
244
  async send(payload: Uint8Array) {
150
245
  if (ws?.readyState === WebSocket.OPEN) {
151
- ws.send(payload);
246
+ // Cast narrows Uint8Array<ArrayBufferLike> to BufferSource for
247
+ // WebSocket.send. Newer TS DOM lib parameterizes Uint8Array over
248
+ // ArrayBuffer | SharedArrayBuffer, but send() only accepts
249
+ // ArrayBuffer-backed views. Every caller here posts a freshly
250
+ // encoded payload (Uint8Array from TextEncoder or similar), so
251
+ // the backing buffer is always a plain ArrayBuffer.
252
+ ws.send(payload as BufferSource);
152
253
  } else {
153
254
  console.warn('[WS] Tried to send while disconnected');
154
255
  }
@@ -167,14 +268,6 @@ const wsInstanceAPI = {
167
268
  broadcastStatus('disconnected');
168
269
  },
169
270
 
170
- onMessage(callback: (data: string | ArrayBuffer) => void) {
171
- onMessageCallback = callback;
172
- },
173
-
174
- onStatus(callback: (status: string) => void) {
175
- onStatusCallback = callback;
176
- },
177
-
178
271
  async getStatus(): Promise<string> {
179
272
  if (!ws) return 'disconnected';
180
273
  const map: Record<number, string> = {
package/workers/main.js CHANGED
@@ -1,12 +1,2 @@
1
- import "../comlink.js";
2
- import { a6 as o, K as r, L as i, a7 as l, a8 as c, a9 as m, aa as n, a5 as p } from "../main.js";
3
- export {
4
- o as bridgeWsToDb,
5
- r as downscale,
6
- i as getScaleLevel,
7
- l as i18n,
8
- c as main,
9
- m as resolveWorkerURL,
10
- n as uiux,
11
- p as upscale
12
- };
1
+ import { a as e, i as t, n, o as r, r as i, s as a, t as o } from "../main.js";
2
+ export { o as downscale, n as getScaleLevel, i as i18n, t as main, e as resolveWorkerURL, r as uiux, a as upscale };