@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.
- package/droid.mjs +2311 -203
- package/index.d.ts +3 -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.map +1 -1
- package/lib/gateway/types.d.ts +1 -1
- package/lib/gateway/types.d.ts.map +1 -1
- package/lib/workers/canvas-worker.js +6 -449
- package/lib/workers/data.d.ts.map +1 -1
- package/lib/workers/db-worker.d.ts +2 -2
- package/lib/workers/db-worker.d.ts.map +1 -1
- package/lib/workers/db-worker.js +14 -4170
- package/lib/workers/main.d.ts +0 -2
- package/lib/workers/main.d.ts.map +1 -1
- package/lib/workers/supabase-db-worker.js +11 -9325
- package/lib/workers/supabase-shared-worker.js +13 -12623
- package/lib/workers/ws-worker.d.ts +1 -2
- package/lib/workers/ws-worker.d.ts.map +1 -1
- package/lib/workers/ws-worker.js +6 -333
- package/main.js +5622 -4786
- package/package.json +2 -1
- package/src/index.ts +3 -0
- 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/gateway/SupabaseGateway.ts +55 -2
- package/src/lib/gateway/WorkerCommunication.ts +1 -1
- package/src/lib/gateway/capabilities.spec.ts +144 -0
- package/src/lib/gateway/types.ts +97 -70
- package/src/lib/mod/module/supabase/mod-supabase.ts +10 -5
- package/src/lib/state/auth.ts +121 -3
- package/src/lib/state/bento.spec.ts +76 -0
- package/src/lib/state/bento.ts +51 -0
- package/src/lib/state/index.ts +64 -0
- package/src/lib/state/profile-sync.ts +214 -0
- package/src/lib/state/profile.ts +155 -0
- package/src/lib/state/staff.ts +128 -0
- package/src/lib/state/sync-bus.ts +109 -0
- package/src/lib/state/welcome-toast.spec.ts +101 -0
- package/src/lib/sw-recovery.spec.ts +165 -0
- package/src/lib/sw-recovery.ts +141 -0
- package/src/lib/types/event-schemas.spec.ts +120 -0
- package/src/lib/types/event-types.spec.ts +5 -2
- package/src/lib/types/event-types.ts +74 -0
- package/src/lib/workers/data.ts +5 -1
- package/src/lib/workers/db-worker.ts +34 -8
- package/src/lib/workers/main.ts +3 -18
- package/src/lib/workers/supabase-db-worker.ts +25 -3
- package/src/lib/workers/supabase-shared-worker.ts +43 -8
- package/src/lib/workers/ws-worker.spec.ts +41 -0
- package/src/lib/workers/ws-worker.ts +121 -28
- package/workers/main.js +2 -12
- package/comlink.js +0 -247
- package/index.js +0 -9263
- 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 = {
|
package/src/lib/workers/data.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
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<{
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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) => {
|
package/src/lib/workers/main.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { wrap
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
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(
|
|
57
|
-
|
|
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
|
-
},
|
|
125
|
+
}, delay);
|
|
80
126
|
}
|
|
81
127
|
|
|
82
|
-
//
|
|
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:',
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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(
|
|
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
|
-
|
|
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 "../
|
|
2
|
-
|
|
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 };
|