@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,144 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
detectCapabilities,
|
|
4
|
+
getStrategyDescription,
|
|
5
|
+
logCapabilities,
|
|
6
|
+
selectStrategy,
|
|
7
|
+
} from './capabilities';
|
|
8
|
+
|
|
9
|
+
const realUA = navigator.userAgent;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
13
|
+
value: realUA,
|
|
14
|
+
configurable: true,
|
|
15
|
+
});
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function setUA(ua: string) {
|
|
20
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
21
|
+
value: ua,
|
|
22
|
+
configurable: true,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('detectCapabilities', () => {
|
|
27
|
+
it('flags Safari when Safari is in UA but Chrome is not', () => {
|
|
28
|
+
setUA(
|
|
29
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
|
|
30
|
+
);
|
|
31
|
+
const caps = detectCapabilities();
|
|
32
|
+
expect(caps.isSafari).toBe(true);
|
|
33
|
+
expect(caps.isAndroid).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('does not flag Safari for Chrome on macOS', () => {
|
|
37
|
+
setUA(
|
|
38
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36',
|
|
39
|
+
);
|
|
40
|
+
const caps = detectCapabilities();
|
|
41
|
+
expect(caps.isSafari).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('flags Android UA', () => {
|
|
45
|
+
setUA(
|
|
46
|
+
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36',
|
|
47
|
+
);
|
|
48
|
+
const caps = detectCapabilities();
|
|
49
|
+
expect(caps.isAndroid).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('selectStrategy', () => {
|
|
54
|
+
it('picks shared-worker on capable desktop', () => {
|
|
55
|
+
expect(
|
|
56
|
+
selectStrategy({
|
|
57
|
+
hasSharedWorker: true,
|
|
58
|
+
hasWorker: true,
|
|
59
|
+
hasBroadcastChannel: true,
|
|
60
|
+
isAndroid: false,
|
|
61
|
+
isSafari: false,
|
|
62
|
+
}),
|
|
63
|
+
).toBe('shared-worker');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('falls back to web-worker on Android even with SharedWorker support', () => {
|
|
67
|
+
expect(
|
|
68
|
+
selectStrategy({
|
|
69
|
+
hasSharedWorker: true,
|
|
70
|
+
hasWorker: true,
|
|
71
|
+
hasBroadcastChannel: true,
|
|
72
|
+
isAndroid: true,
|
|
73
|
+
isSafari: false,
|
|
74
|
+
}),
|
|
75
|
+
).toBe('web-worker');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('falls back to web-worker without SharedWorker', () => {
|
|
79
|
+
expect(
|
|
80
|
+
selectStrategy({
|
|
81
|
+
hasSharedWorker: false,
|
|
82
|
+
hasWorker: true,
|
|
83
|
+
hasBroadcastChannel: true,
|
|
84
|
+
isAndroid: false,
|
|
85
|
+
isSafari: true,
|
|
86
|
+
}),
|
|
87
|
+
).toBe('web-worker');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('falls back to direct without BroadcastChannel', () => {
|
|
91
|
+
expect(
|
|
92
|
+
selectStrategy({
|
|
93
|
+
hasSharedWorker: true,
|
|
94
|
+
hasWorker: true,
|
|
95
|
+
hasBroadcastChannel: false,
|
|
96
|
+
isAndroid: false,
|
|
97
|
+
isSafari: false,
|
|
98
|
+
}),
|
|
99
|
+
).toBe('direct');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns direct when Worker is unavailable', () => {
|
|
103
|
+
expect(
|
|
104
|
+
selectStrategy({
|
|
105
|
+
hasSharedWorker: false,
|
|
106
|
+
hasWorker: false,
|
|
107
|
+
hasBroadcastChannel: false,
|
|
108
|
+
isAndroid: false,
|
|
109
|
+
isSafari: false,
|
|
110
|
+
}),
|
|
111
|
+
).toBe('direct');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('getStrategyDescription', () => {
|
|
116
|
+
it('returns a description for every strategy variant', () => {
|
|
117
|
+
expect(getStrategyDescription('shared-worker')).toMatch(/SharedWorker/);
|
|
118
|
+
expect(getStrategyDescription('web-worker')).toMatch(/WebSocket/);
|
|
119
|
+
expect(getStrategyDescription('direct')).toMatch(/Direct/);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('logCapabilities', () => {
|
|
124
|
+
it('writes a single console.log with the capability snapshot', () => {
|
|
125
|
+
const spy = vi
|
|
126
|
+
.spyOn(console, 'log')
|
|
127
|
+
.mockImplementation(() => undefined);
|
|
128
|
+
logCapabilities({
|
|
129
|
+
hasSharedWorker: true,
|
|
130
|
+
hasWorker: true,
|
|
131
|
+
hasBroadcastChannel: true,
|
|
132
|
+
isAndroid: false,
|
|
133
|
+
isSafari: false,
|
|
134
|
+
});
|
|
135
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
136
|
+
const [, payload] = spy.mock.calls[0];
|
|
137
|
+
expect(payload).toMatchObject({
|
|
138
|
+
SharedWorker: 'yes',
|
|
139
|
+
Worker: 'yes',
|
|
140
|
+
BroadcastChannel: 'yes',
|
|
141
|
+
Platform: 'Desktop',
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Browser capability detection for strategy selection
|
|
2
|
+
|
|
3
|
+
import type { BrowserCapabilities, StrategyType } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect browser capabilities for worker and communication support
|
|
7
|
+
*/
|
|
8
|
+
export function detectCapabilities(): BrowserCapabilities {
|
|
9
|
+
// SSR guard
|
|
10
|
+
if (typeof window === 'undefined') {
|
|
11
|
+
return {
|
|
12
|
+
hasSharedWorker: false,
|
|
13
|
+
hasWorker: false,
|
|
14
|
+
hasBroadcastChannel: false,
|
|
15
|
+
isAndroid: false,
|
|
16
|
+
isSafari: false,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const userAgent = navigator.userAgent || '';
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
hasSharedWorker: typeof SharedWorker !== 'undefined',
|
|
24
|
+
hasWorker: typeof Worker !== 'undefined',
|
|
25
|
+
hasBroadcastChannel: typeof BroadcastChannel !== 'undefined',
|
|
26
|
+
isAndroid: /Android/i.test(userAgent),
|
|
27
|
+
isSafari: /Safari/i.test(userAgent) && !/Chrome/i.test(userAgent),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Select the best strategy based on browser capabilities
|
|
33
|
+
*
|
|
34
|
+
* Priority:
|
|
35
|
+
* 1. SharedWorker + Worker Pool (desktop Chrome, Firefox, Edge)
|
|
36
|
+
* 2. WebWorker Pool only (Android, Safari, or no SharedWorker)
|
|
37
|
+
* 3. Direct/Main thread (emergency fallback)
|
|
38
|
+
*/
|
|
39
|
+
export function selectStrategy(capabilities?: BrowserCapabilities): StrategyType {
|
|
40
|
+
const caps = capabilities || detectCapabilities();
|
|
41
|
+
|
|
42
|
+
// Desktop with SharedWorker support (best performance)
|
|
43
|
+
if (caps.hasSharedWorker && caps.hasBroadcastChannel && !caps.isAndroid) {
|
|
44
|
+
return 'shared-worker';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Android or browsers without SharedWorker (still use worker pool)
|
|
48
|
+
if (caps.hasWorker && caps.hasBroadcastChannel) {
|
|
49
|
+
return 'web-worker';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fallback to main thread (no workers or BroadcastChannel)
|
|
53
|
+
return 'direct';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Log detected capabilities for debugging
|
|
58
|
+
*/
|
|
59
|
+
export function logCapabilities(capabilities: BrowserCapabilities) {
|
|
60
|
+
console.log('[SupabaseGateway] Browser capabilities:', {
|
|
61
|
+
SharedWorker: capabilities.hasSharedWorker ? 'yes' : 'no',
|
|
62
|
+
Worker: capabilities.hasWorker ? 'yes' : 'no',
|
|
63
|
+
BroadcastChannel: capabilities.hasBroadcastChannel ? 'yes' : 'no',
|
|
64
|
+
Platform: capabilities.isAndroid ? 'Android' : capabilities.isSafari ? 'Safari' : 'Desktop',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get a human-readable description of the selected strategy
|
|
70
|
+
*/
|
|
71
|
+
export function getStrategyDescription(strategy: StrategyType): string {
|
|
72
|
+
switch (strategy) {
|
|
73
|
+
case 'shared-worker':
|
|
74
|
+
return 'SharedWorker + DB Worker Pool (optimal for desktop)';
|
|
75
|
+
case 'web-worker':
|
|
76
|
+
return 'WebSocket Worker + DB Worker Pool (optimized for mobile)';
|
|
77
|
+
case 'direct':
|
|
78
|
+
return 'Direct/Main thread (compatibility fallback)';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Main exports for Supabase Gateway
|
|
2
|
+
|
|
3
|
+
export { SupabaseGateway } from './SupabaseGateway';
|
|
4
|
+
export { WorkerPool } from './WorkerPool';
|
|
5
|
+
export { getWorkerCommunication, WorkerCommunication } from './WorkerCommunication';
|
|
6
|
+
export {
|
|
7
|
+
detectCapabilities,
|
|
8
|
+
selectStrategy,
|
|
9
|
+
logCapabilities,
|
|
10
|
+
getStrategyDescription
|
|
11
|
+
} from './capabilities';
|
|
12
|
+
export * from './types';
|
|
13
|
+
export { SharedWorkerStrategy } from './strategies/SharedWorkerStrategy';
|
|
14
|
+
export { WebWorkerStrategy } from './strategies/WebWorkerStrategy';
|
|
15
|
+
export { DirectStrategy } from './strategies/DirectStrategy';
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// Fallback strategy for browsers without worker support
|
|
2
|
+
// Runs everything on the main thread
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
SupabaseClient,
|
|
6
|
+
RealtimePostgresChangesFilter,
|
|
7
|
+
} from '@supabase/supabase-js';
|
|
8
|
+
import { REALTIME_POSTGRES_CHANGES_LISTEN_EVENT } from '@supabase/supabase-js';
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ISupabaseStrategy,
|
|
12
|
+
SelectOptions,
|
|
13
|
+
SessionResponse,
|
|
14
|
+
WebSocketStatus,
|
|
15
|
+
} from '../types';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* DirectStrategy: Everything runs in main thread
|
|
19
|
+
*
|
|
20
|
+
* Architecture:
|
|
21
|
+
* - No workers, all operations on main thread
|
|
22
|
+
* - Uses Supabase client directly
|
|
23
|
+
* - Implements same interface as worker strategies
|
|
24
|
+
*
|
|
25
|
+
* Best for: Browsers without Worker/BroadcastChannel support, or emergency fallback
|
|
26
|
+
*
|
|
27
|
+
* Note: Requires `@supabase/supabase-js` to be available at runtime.
|
|
28
|
+
* This strategy dynamically imports it to avoid bundling Supabase in the main droid build.
|
|
29
|
+
*/
|
|
30
|
+
export class DirectStrategy implements ISupabaseStrategy {
|
|
31
|
+
private client: SupabaseClient | null = null;
|
|
32
|
+
private ws: WebSocket | null = null;
|
|
33
|
+
private wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
34
|
+
private wsReconnectAttempts = 0;
|
|
35
|
+
private wsHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
36
|
+
private wsLastPongTime = 0;
|
|
37
|
+
private subscriptions = new Map<
|
|
38
|
+
string,
|
|
39
|
+
{ unsubscribe: () => Promise<void> | void }
|
|
40
|
+
>();
|
|
41
|
+
private listeners = new Map<string, Set<(payload: unknown) => void>>();
|
|
42
|
+
|
|
43
|
+
private readonly WS_MAX_RECONNECT_ATTEMPTS = 5;
|
|
44
|
+
private readonly WS_RECONNECT_DELAY_MS = 3000;
|
|
45
|
+
private readonly WS_HEARTBEAT_INTERVAL_MS = 30000;
|
|
46
|
+
private readonly WS_HEARTBEAT_TIMEOUT_MS = 60000;
|
|
47
|
+
|
|
48
|
+
async init(
|
|
49
|
+
url: string,
|
|
50
|
+
anonKey: string,
|
|
51
|
+
options?: Record<string, unknown>,
|
|
52
|
+
): Promise<SessionResponse> {
|
|
53
|
+
console.log('[DirectStrategy] Initializing...');
|
|
54
|
+
|
|
55
|
+
// Dynamic import to avoid bundling Supabase when not using DirectStrategy
|
|
56
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
57
|
+
|
|
58
|
+
const authOptions =
|
|
59
|
+
options?.['auth'] && typeof options['auth'] === 'object'
|
|
60
|
+
? (options['auth'] as Record<string, unknown>)
|
|
61
|
+
: {};
|
|
62
|
+
|
|
63
|
+
this.client = createClient(url, anonKey, {
|
|
64
|
+
...options,
|
|
65
|
+
auth: {
|
|
66
|
+
...authOptions,
|
|
67
|
+
autoRefreshToken: true,
|
|
68
|
+
persistSession: true,
|
|
69
|
+
detectSessionInUrl: false,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.client.auth.onAuthStateChange((_event, session) => {
|
|
74
|
+
this.emit('auth', { session });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const {
|
|
78
|
+
data: { session },
|
|
79
|
+
error,
|
|
80
|
+
} = await this.client.auth.getSession();
|
|
81
|
+
|
|
82
|
+
if (error) {
|
|
83
|
+
console.error('[DirectStrategy] Session error:', error);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('[DirectStrategy] Initialized');
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
session: session as SessionResponse['session'],
|
|
90
|
+
user: (session?.user as unknown as Record<string, unknown>) || null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
on(event: string, callback: (payload: unknown) => void): () => void {
|
|
95
|
+
if (!this.listeners.has(event)) {
|
|
96
|
+
this.listeners.set(event, new Set());
|
|
97
|
+
}
|
|
98
|
+
this.listeners.get(event)?.add(callback);
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
const set = this.listeners.get(event);
|
|
102
|
+
if (set) {
|
|
103
|
+
set.delete(callback);
|
|
104
|
+
if (set.size === 0) this.listeners.delete(event);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private emit(event: string, payload: unknown) {
|
|
110
|
+
this.listeners.get(event)?.forEach((cb) => {
|
|
111
|
+
try {
|
|
112
|
+
cb(payload);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error(
|
|
115
|
+
`[DirectStrategy] Listener error for ${event}:`,
|
|
116
|
+
err,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Auth
|
|
123
|
+
async getSession(): Promise<SessionResponse> {
|
|
124
|
+
if (!this.client) throw new Error('Client not initialized');
|
|
125
|
+
const { data, error } = await this.client.auth.getSession();
|
|
126
|
+
if (error) throw error;
|
|
127
|
+
return data as unknown as SessionResponse;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async signInWithPassword(
|
|
131
|
+
email: string,
|
|
132
|
+
password: string,
|
|
133
|
+
): Promise<SessionResponse> {
|
|
134
|
+
if (!this.client) throw new Error('Client not initialized');
|
|
135
|
+
const { data, error } = await this.client.auth.signInWithPassword({
|
|
136
|
+
email,
|
|
137
|
+
password,
|
|
138
|
+
});
|
|
139
|
+
if (error) throw error;
|
|
140
|
+
return data as unknown as SessionResponse;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async signOut(): Promise<void> {
|
|
144
|
+
if (!this.client) throw new Error('Client not initialized');
|
|
145
|
+
const { error } = await this.client.auth.signOut();
|
|
146
|
+
if (error) throw error;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Database
|
|
150
|
+
async select(table: string, opts?: SelectOptions): Promise<unknown[]> {
|
|
151
|
+
if (!this.client) throw new Error('Client not initialized');
|
|
152
|
+
let query = this.client.from(table).select(opts?.columns || '*');
|
|
153
|
+
if (opts?.match) {
|
|
154
|
+
for (const [key, value] of Object.entries(opts.match)) {
|
|
155
|
+
query = query.eq(key, value);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (opts?.limit) query = query.limit(opts.limit);
|
|
159
|
+
const { data, error } = await query;
|
|
160
|
+
if (error) throw error;
|
|
161
|
+
return data;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async insert(
|
|
165
|
+
table: string,
|
|
166
|
+
data: Record<string, unknown> | Record<string, unknown>[],
|
|
167
|
+
): Promise<unknown[]> {
|
|
168
|
+
if (!this.client) throw new Error('Client not initialized');
|
|
169
|
+
const { data: result, error } = await this.client
|
|
170
|
+
.from(table)
|
|
171
|
+
.insert(data)
|
|
172
|
+
.select();
|
|
173
|
+
if (error) throw error;
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async update(
|
|
178
|
+
table: string,
|
|
179
|
+
data: Record<string, unknown>,
|
|
180
|
+
match: Record<string, unknown>,
|
|
181
|
+
): Promise<unknown[]> {
|
|
182
|
+
if (!this.client) throw new Error('Client not initialized');
|
|
183
|
+
let query = this.client.from(table).update(data);
|
|
184
|
+
for (const [key, value] of Object.entries(match)) {
|
|
185
|
+
query = query.eq(key, value);
|
|
186
|
+
}
|
|
187
|
+
const { data: result, error } = await query.select();
|
|
188
|
+
if (error) throw error;
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async upsert(
|
|
193
|
+
table: string,
|
|
194
|
+
data: Record<string, unknown> | Record<string, unknown>[],
|
|
195
|
+
): Promise<unknown[]> {
|
|
196
|
+
if (!this.client) throw new Error('Client not initialized');
|
|
197
|
+
const { data: result, error } = await this.client
|
|
198
|
+
.from(table)
|
|
199
|
+
.upsert(data)
|
|
200
|
+
.select();
|
|
201
|
+
if (error) throw error;
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async delete(
|
|
206
|
+
table: string,
|
|
207
|
+
match: Record<string, unknown>,
|
|
208
|
+
): Promise<unknown[]> {
|
|
209
|
+
if (!this.client) throw new Error('Client not initialized');
|
|
210
|
+
let query = this.client.from(table).delete();
|
|
211
|
+
for (const [key, value] of Object.entries(match)) {
|
|
212
|
+
query = query.eq(key, value);
|
|
213
|
+
}
|
|
214
|
+
const { data: result, error } = await query.select();
|
|
215
|
+
if (error) throw error;
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async rpc(fn: string, args?: Record<string, unknown>): Promise<unknown> {
|
|
220
|
+
if (!this.client) throw new Error('Client not initialized');
|
|
221
|
+
const { data, error } = await this.client.rpc(fn, args || {});
|
|
222
|
+
if (error) throw error;
|
|
223
|
+
return data;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Realtime
|
|
227
|
+
subscribePostgres(
|
|
228
|
+
key: string,
|
|
229
|
+
params: Record<string, unknown>,
|
|
230
|
+
callback: (payload: unknown) => void,
|
|
231
|
+
): () => void {
|
|
232
|
+
if (!this.client) throw new Error('Client not initialized');
|
|
233
|
+
|
|
234
|
+
const channel = this.client
|
|
235
|
+
.channel(key)
|
|
236
|
+
.on(
|
|
237
|
+
'postgres_changes',
|
|
238
|
+
params as RealtimePostgresChangesFilter<`${REALTIME_POSTGRES_CHANGES_LISTEN_EVENT}`>,
|
|
239
|
+
(payload: unknown) => {
|
|
240
|
+
this.emit(`realtime:${key}`, payload);
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
channel.subscribe();
|
|
245
|
+
this.subscriptions.set(key, {
|
|
246
|
+
unsubscribe: async () => {
|
|
247
|
+
await channel.unsubscribe();
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const off = this.on(`realtime:${key}`, callback);
|
|
252
|
+
|
|
253
|
+
return () => {
|
|
254
|
+
off();
|
|
255
|
+
const sub = this.subscriptions.get(key);
|
|
256
|
+
if (sub) {
|
|
257
|
+
sub.unsubscribe();
|
|
258
|
+
this.subscriptions.delete(key);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// WebSocket
|
|
264
|
+
async connectWebSocket(wsUrl?: string): Promise<WebSocketStatus> {
|
|
265
|
+
if (
|
|
266
|
+
this.ws &&
|
|
267
|
+
(this.ws.readyState === WebSocket.CONNECTING ||
|
|
268
|
+
this.ws.readyState === WebSocket.OPEN)
|
|
269
|
+
) {
|
|
270
|
+
return this.getWebSocketStatus();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!this.client) throw new Error('Client not initialized');
|
|
274
|
+
|
|
275
|
+
const {
|
|
276
|
+
data: { session },
|
|
277
|
+
error,
|
|
278
|
+
} = await this.client.auth.getSession();
|
|
279
|
+
if (error || !session?.access_token) {
|
|
280
|
+
throw new Error('No active session');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const url = wsUrl || this.getDefaultWebSocketUrl();
|
|
284
|
+
const authenticatedUrl = `${url}?token=${encodeURIComponent(session.access_token)}`;
|
|
285
|
+
|
|
286
|
+
console.log('[DirectStrategy] Connecting to WebSocket:', url);
|
|
287
|
+
this.ws = new WebSocket(authenticatedUrl);
|
|
288
|
+
|
|
289
|
+
this.ws.onopen = () => {
|
|
290
|
+
this.wsReconnectAttempts = 0;
|
|
291
|
+
this.wsLastPongTime = Date.now();
|
|
292
|
+
this.startHeartbeat();
|
|
293
|
+
this.emit('ws.status', { status: 'connected', url });
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
this.ws.onmessage = (event) => {
|
|
297
|
+
try {
|
|
298
|
+
const message = JSON.parse(event.data as string) as Record<
|
|
299
|
+
string,
|
|
300
|
+
unknown
|
|
301
|
+
>;
|
|
302
|
+
if (message['type'] === 'pong') {
|
|
303
|
+
this.wsLastPongTime = Date.now();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
this.emit('ws.message', message);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error(
|
|
309
|
+
'[DirectStrategy] Failed to parse message:',
|
|
310
|
+
error,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
this.ws.onerror = () => {
|
|
316
|
+
this.emit('ws.status', {
|
|
317
|
+
status: 'error',
|
|
318
|
+
error: 'Connection error',
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
this.ws.onclose = (event) => {
|
|
323
|
+
this.ws = null;
|
|
324
|
+
this.stopHeartbeat();
|
|
325
|
+
this.emit('ws.status', {
|
|
326
|
+
status: 'disconnected',
|
|
327
|
+
code: event.code,
|
|
328
|
+
reason: event.reason,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (
|
|
332
|
+
event.code !== 1000 &&
|
|
333
|
+
this.wsReconnectAttempts < this.WS_MAX_RECONNECT_ATTEMPTS
|
|
334
|
+
) {
|
|
335
|
+
this.wsReconnectAttempts++;
|
|
336
|
+
this.wsReconnectTimer = setTimeout(() => {
|
|
337
|
+
this.connectWebSocket(wsUrl).catch(() => {
|
|
338
|
+
/* reconnect best-effort */
|
|
339
|
+
});
|
|
340
|
+
}, this.WS_RECONNECT_DELAY_MS);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
return this.getWebSocketStatus();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async disconnectWebSocket(): Promise<void> {
|
|
348
|
+
if (this.wsReconnectTimer) {
|
|
349
|
+
clearTimeout(this.wsReconnectTimer);
|
|
350
|
+
this.wsReconnectTimer = null;
|
|
351
|
+
}
|
|
352
|
+
this.stopHeartbeat();
|
|
353
|
+
if (this.ws) {
|
|
354
|
+
this.ws.close(1000, 'Client request');
|
|
355
|
+
this.ws = null;
|
|
356
|
+
this.wsReconnectAttempts = 0;
|
|
357
|
+
this.emit('ws.status', { status: 'disconnected' });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async sendWebSocketMessage(data: unknown): Promise<void> {
|
|
362
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
363
|
+
throw new Error('WebSocket not connected');
|
|
364
|
+
}
|
|
365
|
+
const message = typeof data === 'string' ? data : JSON.stringify(data);
|
|
366
|
+
this.ws.send(message);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async getWebSocketStatus(): Promise<WebSocketStatus> {
|
|
370
|
+
if (!this.ws) return { status: 'disconnected', readyState: null };
|
|
371
|
+
const readyStateMap: Record<number, WebSocketStatus['status']> = {
|
|
372
|
+
[WebSocket.CONNECTING]: 'connecting',
|
|
373
|
+
[WebSocket.OPEN]: 'connected',
|
|
374
|
+
[WebSocket.CLOSING]: 'disconnected',
|
|
375
|
+
[WebSocket.CLOSED]: 'disconnected',
|
|
376
|
+
};
|
|
377
|
+
return {
|
|
378
|
+
status: readyStateMap[this.ws.readyState] || 'disconnected',
|
|
379
|
+
readyState: this.ws.readyState,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
onWebSocketMessage(callback: (message: unknown) => void): () => void {
|
|
384
|
+
return this.on('ws.message', callback);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
onWebSocketStatus(callback: (status: WebSocketStatus) => void): () => void {
|
|
388
|
+
return this.on('ws.status', callback as (payload: unknown) => void);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Helpers
|
|
392
|
+
private getDefaultWebSocketUrl(): string {
|
|
393
|
+
if (typeof window === 'undefined') return 'ws://localhost:4321/ws';
|
|
394
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
395
|
+
const isLocalhost =
|
|
396
|
+
window.location.hostname === 'localhost' ||
|
|
397
|
+
window.location.hostname === '127.0.0.1';
|
|
398
|
+
const port = isLocalhost ? '4321' : window.location.port;
|
|
399
|
+
const host = window.location.hostname;
|
|
400
|
+
return `${protocol}//${host}${port ? ':' + port : ''}/ws`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private startHeartbeat() {
|
|
404
|
+
this.stopHeartbeat();
|
|
405
|
+
this.wsHeartbeatTimer = setInterval(() => {
|
|
406
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
407
|
+
this.stopHeartbeat();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (
|
|
411
|
+
this.wsLastPongTime > 0 &&
|
|
412
|
+
Date.now() - this.wsLastPongTime > this.WS_HEARTBEAT_TIMEOUT_MS
|
|
413
|
+
) {
|
|
414
|
+
console.warn('[DirectStrategy] Heartbeat timeout');
|
|
415
|
+
this.stopHeartbeat();
|
|
416
|
+
this.ws.close(1001, 'Heartbeat timeout');
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
421
|
+
} catch (error) {
|
|
422
|
+
console.error(
|
|
423
|
+
'[DirectStrategy] Failed to send heartbeat:',
|
|
424
|
+
error,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}, this.WS_HEARTBEAT_INTERVAL_MS);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private stopHeartbeat() {
|
|
431
|
+
if (this.wsHeartbeatTimer) {
|
|
432
|
+
clearInterval(this.wsHeartbeatTimer);
|
|
433
|
+
this.wsHeartbeatTimer = null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
terminate() {
|
|
438
|
+
this.disconnectWebSocket();
|
|
439
|
+
this.subscriptions.forEach((sub) => sub.unsubscribe());
|
|
440
|
+
this.subscriptions.clear();
|
|
441
|
+
this.listeners.clear();
|
|
442
|
+
}
|
|
443
|
+
}
|