@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,125 @@
|
|
|
1
|
+
import type { Remote } from 'comlink';
|
|
2
|
+
import type { CanvasWorkerAPI } from '../workers/canvas-worker';
|
|
3
|
+
import { addToast, removeToast } from './toasts';
|
|
4
|
+
import { openTooltip, closeTooltip, openModal, closeModal } from './ui';
|
|
5
|
+
import type {
|
|
6
|
+
ToastPayload,
|
|
7
|
+
TooltipPayload,
|
|
8
|
+
ModalPayload,
|
|
9
|
+
} from '../types/ui-event-types';
|
|
10
|
+
|
|
11
|
+
export type RenderPath = 'dom' | 'canvas' | 'auto';
|
|
12
|
+
|
|
13
|
+
interface OverlayManagerConfig {
|
|
14
|
+
preferredPath: RenderPath;
|
|
15
|
+
canvasWorker?: Remote<CanvasWorkerAPI>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Unified overlay manager that routes to DOM or Canvas rendering.
|
|
20
|
+
*
|
|
21
|
+
* - 'dom': Uses nanostores → React components (Phases 1–6)
|
|
22
|
+
* - 'canvas': Uses CanvasUIRenderer in canvas-worker (Phase 9)
|
|
23
|
+
* - 'auto': Uses canvas if OffscreenCanvas is available AND a canvas worker
|
|
24
|
+
* is bound, otherwise falls back to DOM
|
|
25
|
+
*/
|
|
26
|
+
export class OverlayManager {
|
|
27
|
+
private path: RenderPath;
|
|
28
|
+
private canvasWorker: Remote<CanvasWorkerAPI> | null;
|
|
29
|
+
private canvasBound = false;
|
|
30
|
+
|
|
31
|
+
constructor(config: OverlayManagerConfig) {
|
|
32
|
+
this.path = config.preferredPath;
|
|
33
|
+
this.canvasWorker = config.canvasWorker ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private get effectivePath(): 'dom' | 'canvas' {
|
|
37
|
+
if (this.path === 'canvas' && this.canvasBound && this.canvasWorker) {
|
|
38
|
+
return 'canvas';
|
|
39
|
+
}
|
|
40
|
+
if (this.path === 'auto' && this.canvasBound && this.canvasWorker) {
|
|
41
|
+
return 'canvas';
|
|
42
|
+
}
|
|
43
|
+
return 'dom';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Bind a <canvas> element for off-thread overlay rendering.
|
|
48
|
+
* Must be called before canvas path can be used.
|
|
49
|
+
*/
|
|
50
|
+
async bindCanvas(
|
|
51
|
+
canvasEl: HTMLCanvasElement,
|
|
52
|
+
dbGet: (key: string) => Promise<string | null>,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
if (!this.canvasWorker) {
|
|
55
|
+
console.warn('[OverlayManager] No canvas worker available');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const offscreen = canvasEl.transferControlToOffscreen();
|
|
59
|
+
await this.canvasWorker.bindUICanvas(offscreen, dbGet);
|
|
60
|
+
this.canvasBound = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Toast ──
|
|
64
|
+
|
|
65
|
+
toast(payload: ToastPayload): void {
|
|
66
|
+
if (this.effectivePath === 'canvas') {
|
|
67
|
+
void this.canvasWorker?.addCanvasToast(payload);
|
|
68
|
+
}
|
|
69
|
+
// Always update DOM state (nanostores) so event listeners and
|
|
70
|
+
// any DOM-rendered components stay in sync
|
|
71
|
+
addToast(payload);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
dismissToast(id: string): void {
|
|
75
|
+
if (this.effectivePath === 'canvas') {
|
|
76
|
+
void this.canvasWorker?.removeCanvasToast(id);
|
|
77
|
+
}
|
|
78
|
+
removeToast(id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Tooltip ──
|
|
82
|
+
|
|
83
|
+
showTooltip(payload: TooltipPayload): void {
|
|
84
|
+
if (this.effectivePath === 'canvas') {
|
|
85
|
+
void this.canvasWorker?.showCanvasTooltip(payload);
|
|
86
|
+
}
|
|
87
|
+
openTooltip(payload.id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
hideTooltip(id?: string): void {
|
|
91
|
+
if (this.effectivePath === 'canvas') {
|
|
92
|
+
void this.canvasWorker?.showCanvasTooltip(null);
|
|
93
|
+
}
|
|
94
|
+
closeTooltip(id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Modal ──
|
|
98
|
+
|
|
99
|
+
showModal(payload: ModalPayload): void {
|
|
100
|
+
if (this.effectivePath === 'canvas') {
|
|
101
|
+
void this.canvasWorker?.showCanvasModal(payload);
|
|
102
|
+
}
|
|
103
|
+
openModal(payload.id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
hideModal(id?: string): void {
|
|
107
|
+
if (this.effectivePath === 'canvas') {
|
|
108
|
+
void this.canvasWorker?.showCanvasModal(null);
|
|
109
|
+
}
|
|
110
|
+
closeModal(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Switch rendering path at runtime */
|
|
114
|
+
setPath(path: RenderPath): void {
|
|
115
|
+
this.path = path;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Unbind canvas and clean up */
|
|
119
|
+
async destroy(): Promise<void> {
|
|
120
|
+
if (this.canvasBound && this.canvasWorker) {
|
|
121
|
+
await this.canvasWorker.unbindUICanvas();
|
|
122
|
+
this.canvasBound = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { broadcastProfileRefresh, broadcastStaffRefresh } from './sync-bus';
|
|
2
|
+
import {
|
|
3
|
+
$profileCache,
|
|
4
|
+
PROFILE_CACHE_TTL_MS,
|
|
5
|
+
fetchProfileFromApi,
|
|
6
|
+
setProfileCache,
|
|
7
|
+
type DroidProfile,
|
|
8
|
+
} from './profile';
|
|
9
|
+
import {
|
|
10
|
+
$staffPermissions,
|
|
11
|
+
STAFF_CACHE_TTL_MS,
|
|
12
|
+
fetchStaffPermissionsFromApi,
|
|
13
|
+
setStaffPermsCache,
|
|
14
|
+
} from './staff';
|
|
15
|
+
|
|
16
|
+
const FALLBACK_REFRESH_MS = 15 * 60 * 1000;
|
|
17
|
+
const DEBOUNCE_MS = 250;
|
|
18
|
+
|
|
19
|
+
export interface AuthEventLike {
|
|
20
|
+
session?: {
|
|
21
|
+
access_token?: string;
|
|
22
|
+
user?: { id?: string } | null;
|
|
23
|
+
} | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ProfileSyncOptions {
|
|
27
|
+
apiBase: string;
|
|
28
|
+
supabaseUrl: string;
|
|
29
|
+
supabaseAnonKey: string;
|
|
30
|
+
/** Provide a way to subscribe to auth events from the SharedWorker / gateway. */
|
|
31
|
+
subscribeAuth: (handler: (event: AuthEventLike) => void) => () => void;
|
|
32
|
+
/**
|
|
33
|
+
* Optional override for the recurring fallback cadence. Defaults to
|
|
34
|
+
* 15 minutes. Pass `0` to disable.
|
|
35
|
+
*/
|
|
36
|
+
fallbackIntervalMs?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SyncState {
|
|
40
|
+
apiBase: string;
|
|
41
|
+
supabaseUrl: string;
|
|
42
|
+
supabaseAnonKey: string;
|
|
43
|
+
timer: ReturnType<typeof setInterval> | null;
|
|
44
|
+
debounce: ReturnType<typeof setTimeout> | null;
|
|
45
|
+
inflight: Promise<void> | null;
|
|
46
|
+
disposers: Array<() => void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let active: SyncState | null = null;
|
|
50
|
+
|
|
51
|
+
async function refreshProfileAndStaff(state: SyncState): Promise<void> {
|
|
52
|
+
if (state.inflight) return state.inflight;
|
|
53
|
+
state.inflight = (async () => {
|
|
54
|
+
try {
|
|
55
|
+
const sessionRaw = readSession();
|
|
56
|
+
const userId = sessionRaw?.user?.id;
|
|
57
|
+
const token = sessionRaw?.access_token;
|
|
58
|
+
if (!userId || !token) return;
|
|
59
|
+
|
|
60
|
+
const profilePromise = fetchProfileFromApi({
|
|
61
|
+
token,
|
|
62
|
+
apiBase: state.apiBase,
|
|
63
|
+
});
|
|
64
|
+
const staffPromise = fetchStaffPermissionsFromApi({
|
|
65
|
+
token,
|
|
66
|
+
apikey: state.supabaseAnonKey,
|
|
67
|
+
supabaseUrl: state.supabaseUrl,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const [profile, staff] = await Promise.all([
|
|
71
|
+
profilePromise.catch(() => null),
|
|
72
|
+
staffPromise.catch(() => null),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
if (profile && profile.user_id) {
|
|
76
|
+
setProfileCache(profile);
|
|
77
|
+
broadcastProfileRefresh(profile);
|
|
78
|
+
}
|
|
79
|
+
if (typeof staff === 'number') {
|
|
80
|
+
setStaffPermsCache(userId, staff);
|
|
81
|
+
broadcastStaffRefresh(userId, staff);
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
if (active === state) state.inflight = null;
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
return state.inflight;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readSession(): AuthEventLike['session'] {
|
|
91
|
+
if (typeof localStorage === 'undefined') return null;
|
|
92
|
+
const raw = localStorage.getItem('sb-auth-token');
|
|
93
|
+
if (!raw) return null;
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(raw);
|
|
96
|
+
if (parsed?.currentSession?.access_token) return parsed.currentSession;
|
|
97
|
+
if (parsed?.access_token) return parsed;
|
|
98
|
+
return null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function scheduleRefresh(state: SyncState): void {
|
|
105
|
+
if (state.debounce) clearTimeout(state.debounce);
|
|
106
|
+
state.debounce = setTimeout(() => {
|
|
107
|
+
state.debounce = null;
|
|
108
|
+
void refreshProfileAndStaff(state);
|
|
109
|
+
}, DEBOUNCE_MS);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isProfileStale(): boolean {
|
|
113
|
+
const entry = $profileCache.get();
|
|
114
|
+
if (!entry) return true;
|
|
115
|
+
return Date.now() - entry.cached_at > PROFILE_CACHE_TTL_MS;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isStaffStale(): boolean {
|
|
119
|
+
const entry = $staffPermissions.get();
|
|
120
|
+
if (!entry) return true;
|
|
121
|
+
return Date.now() - entry.cached_at > STAFF_CACHE_TTL_MS;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Install a profile + staff_permissions sync coordinator. Returns a
|
|
126
|
+
* dispose function that tears down listeners and the fallback timer.
|
|
127
|
+
*
|
|
128
|
+
* Refresh triggers:
|
|
129
|
+
* - auth events from the SharedWorker (token refresh, sign-in)
|
|
130
|
+
* - `document.visibilitychange` (only if at least one cache is stale)
|
|
131
|
+
* - `window.focus` (only if at least one cache is stale)
|
|
132
|
+
* - fallback `setInterval` every `fallbackIntervalMs` (default 15m)
|
|
133
|
+
*
|
|
134
|
+
* Each refresh is debounced (250ms) and reentrancy-guarded; a refresh
|
|
135
|
+
* in flight is reused rather than queued.
|
|
136
|
+
*
|
|
137
|
+
* Idempotent — calling twice tears down the previous coordinator first
|
|
138
|
+
* so callers can re-run safely.
|
|
139
|
+
*/
|
|
140
|
+
export function installProfileSync(opts: ProfileSyncOptions): () => void {
|
|
141
|
+
disposeProfileSync();
|
|
142
|
+
|
|
143
|
+
const state: SyncState = {
|
|
144
|
+
apiBase: opts.apiBase,
|
|
145
|
+
supabaseUrl: opts.supabaseUrl,
|
|
146
|
+
supabaseAnonKey: opts.supabaseAnonKey,
|
|
147
|
+
timer: null,
|
|
148
|
+
debounce: null,
|
|
149
|
+
inflight: null,
|
|
150
|
+
disposers: [],
|
|
151
|
+
};
|
|
152
|
+
active = state;
|
|
153
|
+
|
|
154
|
+
const unsubscribeAuth = opts.subscribeAuth((evt) => {
|
|
155
|
+
if (!evt?.session?.user?.id) return;
|
|
156
|
+
scheduleRefresh(state);
|
|
157
|
+
});
|
|
158
|
+
state.disposers.push(unsubscribeAuth);
|
|
159
|
+
|
|
160
|
+
if (typeof document !== 'undefined') {
|
|
161
|
+
const onVisibility = () => {
|
|
162
|
+
if (document.visibilityState !== 'visible') return;
|
|
163
|
+
if (!isProfileStale() && !isStaffStale()) return;
|
|
164
|
+
scheduleRefresh(state);
|
|
165
|
+
};
|
|
166
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
167
|
+
state.disposers.push(() =>
|
|
168
|
+
document.removeEventListener('visibilitychange', onVisibility),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (typeof window !== 'undefined') {
|
|
172
|
+
const onFocus = () => {
|
|
173
|
+
if (!isProfileStale() && !isStaffStale()) return;
|
|
174
|
+
scheduleRefresh(state);
|
|
175
|
+
};
|
|
176
|
+
window.addEventListener('focus', onFocus);
|
|
177
|
+
state.disposers.push(() =>
|
|
178
|
+
window.removeEventListener('focus', onFocus),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const interval = opts.fallbackIntervalMs ?? FALLBACK_REFRESH_MS;
|
|
183
|
+
if (interval > 0) {
|
|
184
|
+
state.timer = setInterval(() => {
|
|
185
|
+
void refreshProfileAndStaff(state);
|
|
186
|
+
}, interval);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return disposeProfileSync;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function disposeProfileSync(): void {
|
|
193
|
+
if (!active) return;
|
|
194
|
+
const state = active;
|
|
195
|
+
active = null;
|
|
196
|
+
if (state.timer) clearInterval(state.timer);
|
|
197
|
+
if (state.debounce) clearTimeout(state.debounce);
|
|
198
|
+
for (const dispose of state.disposers) {
|
|
199
|
+
try {
|
|
200
|
+
dispose();
|
|
201
|
+
} catch {
|
|
202
|
+
/* best effort */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
state.disposers.length = 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Trigger an immediate refresh if a sync coordinator is installed. */
|
|
209
|
+
export async function refreshProfileSyncNow(): Promise<void> {
|
|
210
|
+
if (!active) return;
|
|
211
|
+
await refreshProfileAndStaff(active);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type { DroidProfile };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { persistentAtom } from '@nanostores/persistent';
|
|
2
|
+
|
|
3
|
+
export interface DroidProfile {
|
|
4
|
+
user_id: string;
|
|
5
|
+
username?: string;
|
|
6
|
+
email?: string;
|
|
7
|
+
profile_exists?: boolean;
|
|
8
|
+
discord?: {
|
|
9
|
+
username?: string;
|
|
10
|
+
avatar_url?: string;
|
|
11
|
+
is_guild_member?: boolean;
|
|
12
|
+
};
|
|
13
|
+
github?: { username?: string; avatar_url?: string };
|
|
14
|
+
twitch?: { username?: string; avatar_url?: string; is_live?: boolean };
|
|
15
|
+
connected_providers?: string[];
|
|
16
|
+
[k: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProfileCacheEnvelope {
|
|
20
|
+
profile: DroidProfile;
|
|
21
|
+
cached_at: number;
|
|
22
|
+
user_id: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PROFILE_CACHE_KEY = 'cache:profile:me';
|
|
26
|
+
const SUPABASE_SESSION_STORAGE_KEY = 'sb-auth-token';
|
|
27
|
+
export const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
28
|
+
export const PROFILE_DEFAULT_API_BASE = 'https://kbve.com';
|
|
29
|
+
|
|
30
|
+
export const $profileCache = persistentAtom<ProfileCacheEnvelope | null>(
|
|
31
|
+
PROFILE_CACHE_KEY,
|
|
32
|
+
null,
|
|
33
|
+
{
|
|
34
|
+
encode: (value) => JSON.stringify(value),
|
|
35
|
+
decode: (raw) => {
|
|
36
|
+
if (!raw || raw === 'null') return null;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(raw) as ProfileCacheEnvelope;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export function getProfileFromCache(userId: string): DroidProfile | null {
|
|
47
|
+
const entry = $profileCache.get();
|
|
48
|
+
if (!entry) return null;
|
|
49
|
+
if (entry.user_id !== userId) return null;
|
|
50
|
+
if (Date.now() - entry.cached_at > PROFILE_CACHE_TTL_MS) return null;
|
|
51
|
+
return entry.profile;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setProfileCache(profile: DroidProfile): void {
|
|
55
|
+
if (!profile.user_id) return;
|
|
56
|
+
$profileCache.set({
|
|
57
|
+
profile,
|
|
58
|
+
cached_at: Date.now(),
|
|
59
|
+
user_id: profile.user_id,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function clearProfileCache(): void {
|
|
64
|
+
$profileCache.set(null);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CachedSupabaseSession {
|
|
68
|
+
access_token: string;
|
|
69
|
+
refresh_token?: string;
|
|
70
|
+
expires_at?: number;
|
|
71
|
+
user?: {
|
|
72
|
+
id: string;
|
|
73
|
+
email?: string;
|
|
74
|
+
user_metadata?: Record<string, unknown>;
|
|
75
|
+
[k: string]: unknown;
|
|
76
|
+
};
|
|
77
|
+
[k: string]: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readSupabaseSessionFromStorage(): CachedSupabaseSession | null {
|
|
81
|
+
if (typeof localStorage === 'undefined') return null;
|
|
82
|
+
const raw = localStorage.getItem(SUPABASE_SESSION_STORAGE_KEY);
|
|
83
|
+
if (!raw) return null;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(raw);
|
|
86
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
87
|
+
if (
|
|
88
|
+
parsed.currentSession &&
|
|
89
|
+
typeof parsed.currentSession === 'object' &&
|
|
90
|
+
'access_token' in parsed.currentSession
|
|
91
|
+
) {
|
|
92
|
+
return parsed.currentSession as CachedSupabaseSession;
|
|
93
|
+
}
|
|
94
|
+
if ('access_token' in parsed) {
|
|
95
|
+
return parsed as CachedSupabaseSession;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface FetchProfileOptions {
|
|
104
|
+
token: string;
|
|
105
|
+
apiBase?: string;
|
|
106
|
+
signal?: AbortSignal;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function fetchProfileFromApi(
|
|
110
|
+
opts: FetchProfileOptions,
|
|
111
|
+
): Promise<DroidProfile | null> {
|
|
112
|
+
const base = opts.apiBase ?? PROFILE_DEFAULT_API_BASE;
|
|
113
|
+
const res = await fetch(`${base}/api/v1/profile/me`, {
|
|
114
|
+
method: 'GET',
|
|
115
|
+
headers: {
|
|
116
|
+
Authorization: `Bearer ${opts.token}`,
|
|
117
|
+
Accept: 'application/json',
|
|
118
|
+
},
|
|
119
|
+
signal: opts.signal,
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) return null;
|
|
122
|
+
try {
|
|
123
|
+
const json = (await res.json()) as DroidProfile;
|
|
124
|
+
if (!json || !json.user_id) return null;
|
|
125
|
+
return json;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function fetchAndCacheProfile(
|
|
132
|
+
opts: FetchProfileOptions,
|
|
133
|
+
): Promise<DroidProfile | null> {
|
|
134
|
+
const profile = await fetchProfileFromApi(opts);
|
|
135
|
+
if (profile) setProfileCache(profile);
|
|
136
|
+
return profile;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface ProfileFastPaintResult {
|
|
140
|
+
session: CachedSupabaseSession;
|
|
141
|
+
profile: DroidProfile;
|
|
142
|
+
stale: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function readProfileForFastPaint(): ProfileFastPaintResult | null {
|
|
146
|
+
const session = readSupabaseSessionFromStorage();
|
|
147
|
+
if (!session?.user?.id || !session.access_token) return null;
|
|
148
|
+
const entry = $profileCache.get();
|
|
149
|
+
if (!entry || entry.user_id !== session.user.id) return null;
|
|
150
|
+
return {
|
|
151
|
+
session,
|
|
152
|
+
profile: entry.profile,
|
|
153
|
+
stale: Date.now() - entry.cached_at > PROFILE_CACHE_TTL_MS,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { atom } from 'nanostores';
|
|
2
|
+
|
|
3
|
+
export const $currentPath = atom('/');
|
|
4
|
+
|
|
5
|
+
let _routerBooted = false;
|
|
6
|
+
|
|
7
|
+
export function bootRouter(initial?: string): void {
|
|
8
|
+
if (_routerBooted) return;
|
|
9
|
+
_routerBooted = true;
|
|
10
|
+
|
|
11
|
+
if (initial) $currentPath.set(initial);
|
|
12
|
+
|
|
13
|
+
if (typeof window === 'undefined') return;
|
|
14
|
+
|
|
15
|
+
$currentPath.set(window.location.pathname);
|
|
16
|
+
|
|
17
|
+
document.addEventListener('astro:page-load', () => {
|
|
18
|
+
$currentPath.set(window.location.pathname);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
window.addEventListener('popstate', () => {
|
|
22
|
+
$currentPath.set(window.location.pathname);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { persistentAtom } from '@nanostores/persistent';
|
|
2
|
+
import { $auth, setAuth, AuthPresets } from './auth';
|
|
3
|
+
|
|
4
|
+
export interface StaffPermsEnvelope {
|
|
5
|
+
bitmask: number;
|
|
6
|
+
cached_at: number;
|
|
7
|
+
user_id: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const STAFF_CACHE_KEY = 'cache:staff:perms';
|
|
11
|
+
const SUPABASE_DEFAULT_URL = 'https://supabase.kbve.com';
|
|
12
|
+
export const STAFF_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
export const $staffPermissions = persistentAtom<StaffPermsEnvelope | null>(
|
|
15
|
+
STAFF_CACHE_KEY,
|
|
16
|
+
null,
|
|
17
|
+
{
|
|
18
|
+
encode: (value) => JSON.stringify(value),
|
|
19
|
+
decode: (raw) => {
|
|
20
|
+
if (!raw || raw === 'null') return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(raw) as StaffPermsEnvelope;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export function getStaffPermsFromCache(userId: string): number | null {
|
|
31
|
+
const entry = $staffPermissions.get();
|
|
32
|
+
if (!entry) return null;
|
|
33
|
+
if (entry.user_id !== userId) return null;
|
|
34
|
+
if (Date.now() - entry.cached_at > STAFF_CACHE_TTL_MS) return null;
|
|
35
|
+
return entry.bitmask;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setStaffPermsCache(userId: string, bitmask: number): void {
|
|
39
|
+
if (!userId) return;
|
|
40
|
+
$staffPermissions.set({ bitmask, cached_at: Date.now(), user_id: userId });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function clearStaffPermsCache(): void {
|
|
44
|
+
$staffPermissions.set(null);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FetchStaffPermsOptions {
|
|
48
|
+
token: string;
|
|
49
|
+
apikey: string;
|
|
50
|
+
supabaseUrl?: string;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function fetchStaffPermissionsFromApi(
|
|
55
|
+
opts: FetchStaffPermsOptions,
|
|
56
|
+
): Promise<number | null> {
|
|
57
|
+
const base = opts.supabaseUrl ?? SUPABASE_DEFAULT_URL;
|
|
58
|
+
const res = await fetch(`${base}/rest/v1/rpc/staff_permissions`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
Authorization: `Bearer ${opts.token}`,
|
|
62
|
+
'Content-Type': 'application/json',
|
|
63
|
+
apikey: opts.apikey,
|
|
64
|
+
},
|
|
65
|
+
body: '{}',
|
|
66
|
+
signal: opts.signal,
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) return null;
|
|
69
|
+
try {
|
|
70
|
+
const data = await res.json();
|
|
71
|
+
if (typeof data === 'number') return data;
|
|
72
|
+
return null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function fetchAndCacheStaffPermissions(
|
|
79
|
+
opts: FetchStaffPermsOptions & { userId: string },
|
|
80
|
+
): Promise<number | null> {
|
|
81
|
+
const bitmask = await fetchStaffPermissionsFromApi(opts);
|
|
82
|
+
if (bitmask === null) return null;
|
|
83
|
+
setStaffPermsCache(opts.userId, bitmask);
|
|
84
|
+
return bitmask;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Apply STAFF flag to `$auth` if the cached staff permissions for
|
|
89
|
+
* `userId` are non-zero. Returns the bitmask (or null when no cache /
|
|
90
|
+
* stale / non-matching user).
|
|
91
|
+
*/
|
|
92
|
+
export function applyStaffFlagFromCache(userId: string): number | null {
|
|
93
|
+
const bitmask = getStaffPermsFromCache(userId);
|
|
94
|
+
if (bitmask === null) return null;
|
|
95
|
+
if (bitmask > 0) {
|
|
96
|
+
const state = $auth.get();
|
|
97
|
+
if (state.tone === 'auth' && state.id === userId) {
|
|
98
|
+
setAuth({ flags: AuthPresets.STAFF });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return bitmask;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compose: synchronously paint STAFF flag from cache, then refresh in
|
|
106
|
+
* the background. Caller decides whether to await the returned
|
|
107
|
+
* promise.
|
|
108
|
+
*/
|
|
109
|
+
export function bootStaffPermissions(opts: {
|
|
110
|
+
userId: string;
|
|
111
|
+
token: string;
|
|
112
|
+
apikey: string;
|
|
113
|
+
supabaseUrl?: string;
|
|
114
|
+
}): { cachedBitmask: number | null; refresh: Promise<number | null> } {
|
|
115
|
+
const cachedBitmask = applyStaffFlagFromCache(opts.userId);
|
|
116
|
+
const refresh = fetchAndCacheStaffPermissions(opts)
|
|
117
|
+
.then((bitmask) => {
|
|
118
|
+
if (bitmask !== null && bitmask > 0) {
|
|
119
|
+
const state = $auth.get();
|
|
120
|
+
if (state.tone === 'auth' && state.id === opts.userId) {
|
|
121
|
+
setAuth({ flags: AuthPresets.STAFF });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return bitmask;
|
|
125
|
+
})
|
|
126
|
+
.catch(() => null);
|
|
127
|
+
return { cachedBitmask, refresh };
|
|
128
|
+
}
|