@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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearProfileCache,
|
|
3
|
+
setProfileCache,
|
|
4
|
+
type DroidProfile,
|
|
5
|
+
} from './profile';
|
|
6
|
+
import { clearStaffPermsCache, setStaffPermsCache } from './staff';
|
|
7
|
+
|
|
8
|
+
const CHANNEL_NAME = 'kbve-droid-sync';
|
|
9
|
+
|
|
10
|
+
export type SyncBusMessage =
|
|
11
|
+
| { type: 'profile-refresh'; profile: DroidProfile }
|
|
12
|
+
| { type: 'profile-clear' }
|
|
13
|
+
| {
|
|
14
|
+
type: 'staff-refresh';
|
|
15
|
+
user_id: string;
|
|
16
|
+
bitmask: number;
|
|
17
|
+
}
|
|
18
|
+
| { type: 'staff-clear' };
|
|
19
|
+
|
|
20
|
+
let channel: BroadcastChannel | null = null;
|
|
21
|
+
let installed = false;
|
|
22
|
+
|
|
23
|
+
function getChannel(): BroadcastChannel | null {
|
|
24
|
+
if (typeof BroadcastChannel === 'undefined') return null;
|
|
25
|
+
if (!channel) {
|
|
26
|
+
try {
|
|
27
|
+
channel = new BroadcastChannel(CHANNEL_NAME);
|
|
28
|
+
} catch {
|
|
29
|
+
channel = null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return channel;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe to cross-context cache refresh messages. The service
|
|
37
|
+
* worker / shared worker / other tabs can post `SyncBusMessage`
|
|
38
|
+
* values to update the persistent stores. localStorage `storage`
|
|
39
|
+
* events already handle cross-tab sync for the persistentAtom
|
|
40
|
+
* itself; this bus exists so workers (which have no localStorage)
|
|
41
|
+
* can drive writes through.
|
|
42
|
+
*/
|
|
43
|
+
const noop = (): void => {
|
|
44
|
+
/* no-op cleanup */
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function installSyncBusListener(): () => void {
|
|
48
|
+
if (installed) return noop;
|
|
49
|
+
const bc = getChannel();
|
|
50
|
+
if (!bc) return noop;
|
|
51
|
+
installed = true;
|
|
52
|
+
|
|
53
|
+
const onMessage = (event: MessageEvent<SyncBusMessage>) => {
|
|
54
|
+
const msg = event.data;
|
|
55
|
+
if (!msg || typeof msg !== 'object') return;
|
|
56
|
+
switch (msg.type) {
|
|
57
|
+
case 'profile-refresh':
|
|
58
|
+
if (msg.profile?.user_id) setProfileCache(msg.profile);
|
|
59
|
+
break;
|
|
60
|
+
case 'profile-clear':
|
|
61
|
+
clearProfileCache();
|
|
62
|
+
break;
|
|
63
|
+
case 'staff-refresh':
|
|
64
|
+
if (msg.user_id && typeof msg.bitmask === 'number') {
|
|
65
|
+
setStaffPermsCache(msg.user_id, msg.bitmask);
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
case 'staff-clear':
|
|
69
|
+
clearStaffPermsCache();
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
bc.addEventListener('message', onMessage);
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
bc.removeEventListener('message', onMessage);
|
|
78
|
+
installed = false;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function broadcastProfileRefresh(profile: DroidProfile): void {
|
|
83
|
+
if (!profile?.user_id) return;
|
|
84
|
+
getChannel()?.postMessage({
|
|
85
|
+
type: 'profile-refresh',
|
|
86
|
+
profile,
|
|
87
|
+
} satisfies SyncBusMessage);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function broadcastProfileClear(): void {
|
|
91
|
+
getChannel()?.postMessage({
|
|
92
|
+
type: 'profile-clear',
|
|
93
|
+
} satisfies SyncBusMessage);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function broadcastStaffRefresh(userId: string, bitmask: number): void {
|
|
97
|
+
if (!userId) return;
|
|
98
|
+
getChannel()?.postMessage({
|
|
99
|
+
type: 'staff-refresh',
|
|
100
|
+
user_id: userId,
|
|
101
|
+
bitmask,
|
|
102
|
+
} satisfies SyncBusMessage);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function broadcastStaffClear(): void {
|
|
106
|
+
getChannel()?.postMessage({
|
|
107
|
+
type: 'staff-clear',
|
|
108
|
+
} satisfies SyncBusMessage);
|
|
109
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { showWelcomeToast } from './welcome-toast';
|
|
3
|
+
import { $auth, AuthPresets, resetAuth, setAuth } from './auth';
|
|
4
|
+
import { $toasts } from './toasts';
|
|
5
|
+
|
|
6
|
+
const SHOWN_KEY = 'kbve:welcome-shown';
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
sessionStorage.clear();
|
|
10
|
+
$toasts.set({});
|
|
11
|
+
$auth.set({
|
|
12
|
+
tone: 'loading',
|
|
13
|
+
flags: AuthPresets.LOADING,
|
|
14
|
+
name: '',
|
|
15
|
+
username: undefined,
|
|
16
|
+
avatar: undefined,
|
|
17
|
+
id: '',
|
|
18
|
+
error: undefined,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.useRealTimers();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('showWelcomeToast', () => {
|
|
27
|
+
it('emits a "Welcome back, <name>" success toast for an authed user', () => {
|
|
28
|
+
setAuth({ tone: 'auth', name: 'Alice' });
|
|
29
|
+
showWelcomeToast();
|
|
30
|
+
const toasts = Object.values($toasts.get());
|
|
31
|
+
expect(toasts).toHaveLength(1);
|
|
32
|
+
expect(toasts[0]).toMatchObject({
|
|
33
|
+
message: 'Welcome back, Alice',
|
|
34
|
+
severity: 'success',
|
|
35
|
+
});
|
|
36
|
+
expect(sessionStorage.getItem(SHOWN_KEY)).toBe('1');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('emits a generic info toast for an anon visitor', () => {
|
|
40
|
+
setAuth({ tone: 'anon' });
|
|
41
|
+
showWelcomeToast();
|
|
42
|
+
const toasts = Object.values($toasts.get());
|
|
43
|
+
expect(toasts[0]).toMatchObject({
|
|
44
|
+
message: 'Welcome!',
|
|
45
|
+
severity: 'info',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not emit twice in the same session', () => {
|
|
50
|
+
setAuth({ tone: 'anon' });
|
|
51
|
+
showWelcomeToast();
|
|
52
|
+
showWelcomeToast();
|
|
53
|
+
expect(Object.values($toasts.get())).toHaveLength(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('emits when auth resolves after a delay (subscription path)', () => {
|
|
57
|
+
showWelcomeToast();
|
|
58
|
+
expect(Object.values($toasts.get())).toHaveLength(0);
|
|
59
|
+
|
|
60
|
+
setAuth({ tone: 'auth', name: 'Bob' });
|
|
61
|
+
const toasts = Object.values($toasts.get());
|
|
62
|
+
expect(toasts).toHaveLength(1);
|
|
63
|
+
expect(toasts[0]).toMatchObject({ message: 'Welcome back, Bob' });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('falls back to a generic toast after the 10s timeout when auth never resolves', () => {
|
|
67
|
+
vi.useFakeTimers();
|
|
68
|
+
showWelcomeToast();
|
|
69
|
+
expect(Object.values($toasts.get())).toHaveLength(0);
|
|
70
|
+
|
|
71
|
+
vi.advanceTimersByTime(10_000);
|
|
72
|
+
const toasts = Object.values($toasts.get());
|
|
73
|
+
expect(toasts).toHaveLength(1);
|
|
74
|
+
expect(toasts[0]).toMatchObject({
|
|
75
|
+
message: 'Welcome!',
|
|
76
|
+
severity: 'info',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('marks shown without emitting when tone is error', () => {
|
|
81
|
+
setAuth({ tone: 'error', error: 'boom' });
|
|
82
|
+
showWelcomeToast();
|
|
83
|
+
expect(Object.values($toasts.get())).toHaveLength(0);
|
|
84
|
+
expect(sessionStorage.getItem(SHOWN_KEY)).toBe('1');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('treats sessionStorage failures as not-shown without throwing', () => {
|
|
88
|
+
const original = Storage.prototype.getItem;
|
|
89
|
+
Storage.prototype.getItem = () => {
|
|
90
|
+
throw new Error('private mode');
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
expect(() => {
|
|
94
|
+
resetAuth();
|
|
95
|
+
showWelcomeToast();
|
|
96
|
+
}).not.toThrow();
|
|
97
|
+
} finally {
|
|
98
|
+
Storage.prototype.getItem = original;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { swRecovery } from './sw-recovery';
|
|
3
|
+
|
|
4
|
+
type Reg = {
|
|
5
|
+
scriptURL: string;
|
|
6
|
+
unregister: ReturnType<typeof vi.fn>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function makeReg(scriptURL: string, unregisterReturn = true): Reg {
|
|
10
|
+
return {
|
|
11
|
+
scriptURL,
|
|
12
|
+
unregister: vi.fn().mockResolvedValue(unregisterReturn),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function installNavigator(regs: Reg[]) {
|
|
17
|
+
const registrations = regs.map((r) => ({
|
|
18
|
+
active: { scriptURL: r.scriptURL },
|
|
19
|
+
installing: null,
|
|
20
|
+
waiting: null,
|
|
21
|
+
unregister: r.unregister,
|
|
22
|
+
})) as unknown as ServiceWorkerRegistration[];
|
|
23
|
+
|
|
24
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
25
|
+
value: {
|
|
26
|
+
serviceWorker: {
|
|
27
|
+
getRegistrations: vi.fn().mockResolvedValue(registrations),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
configurable: true,
|
|
31
|
+
writable: true,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function installCaches(keys: string[]) {
|
|
36
|
+
const cacheStore: Record<string, boolean> = Object.fromEntries(
|
|
37
|
+
keys.map((k) => [k, true]),
|
|
38
|
+
);
|
|
39
|
+
(globalThis as unknown as { caches: CacheStorage }).caches = {
|
|
40
|
+
keys: vi.fn().mockResolvedValue([...keys]),
|
|
41
|
+
delete: vi.fn(async (key: string) => {
|
|
42
|
+
const had = !!cacheStore[key];
|
|
43
|
+
delete cacheStore[key];
|
|
44
|
+
return had;
|
|
45
|
+
}),
|
|
46
|
+
match: vi.fn(),
|
|
47
|
+
has: vi.fn(),
|
|
48
|
+
open: vi.fn(),
|
|
49
|
+
} as unknown as CacheStorage;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clearGlobals() {
|
|
53
|
+
delete (globalThis as unknown as { navigator?: unknown }).navigator;
|
|
54
|
+
delete (globalThis as unknown as { caches?: unknown }).caches;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
clearGlobals();
|
|
59
|
+
vi.spyOn(console, 'info').mockImplementation(vi.fn());
|
|
60
|
+
vi.spyOn(console, 'warn').mockImplementation(vi.fn());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
clearGlobals();
|
|
65
|
+
vi.restoreAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('swRecovery', () => {
|
|
69
|
+
it('returns unsupported when navigator has no serviceWorker', async () => {
|
|
70
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
71
|
+
value: {},
|
|
72
|
+
configurable: true,
|
|
73
|
+
writable: true,
|
|
74
|
+
});
|
|
75
|
+
const result = await swRecovery();
|
|
76
|
+
expect(result.supported).toBe(false);
|
|
77
|
+
expect(result.unregistered).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('unregisters all SWs when allowedScripts is empty', async () => {
|
|
81
|
+
const stale = makeReg('https://chat.kbve.com/sw.js');
|
|
82
|
+
const other = makeReg('https://chat.kbve.com/old-sw.js');
|
|
83
|
+
installNavigator([stale, other]);
|
|
84
|
+
installCaches(['a', 'b']);
|
|
85
|
+
|
|
86
|
+
const result = await swRecovery();
|
|
87
|
+
expect(result.supported).toBe(true);
|
|
88
|
+
expect(result.unregistered).toEqual([
|
|
89
|
+
'https://chat.kbve.com/sw.js',
|
|
90
|
+
'https://chat.kbve.com/old-sw.js',
|
|
91
|
+
]);
|
|
92
|
+
expect(result.keptScripts).toEqual([]);
|
|
93
|
+
expect(result.cachesCleared).toEqual(['a', 'b']);
|
|
94
|
+
expect(stale.unregister).toHaveBeenCalledTimes(1);
|
|
95
|
+
expect(other.unregister).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('keeps SWs whose absolute scriptURL is in allowedScripts', async () => {
|
|
99
|
+
const keep = makeReg('https://chat.kbve.com/sw.js');
|
|
100
|
+
const drop = makeReg('https://chat.kbve.com/legacy.js');
|
|
101
|
+
installNavigator([keep, drop]);
|
|
102
|
+
installCaches([]);
|
|
103
|
+
|
|
104
|
+
const result = await swRecovery({
|
|
105
|
+
allowedScripts: ['https://chat.kbve.com/sw.js'],
|
|
106
|
+
});
|
|
107
|
+
expect(result.keptScripts).toEqual(['https://chat.kbve.com/sw.js']);
|
|
108
|
+
expect(result.unregistered).toEqual([
|
|
109
|
+
'https://chat.kbve.com/legacy.js',
|
|
110
|
+
]);
|
|
111
|
+
expect(keep.unregister).not.toHaveBeenCalled();
|
|
112
|
+
expect(drop.unregister).toHaveBeenCalledTimes(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('keeps SWs whose pathname matches an allowed pathname rule', async () => {
|
|
116
|
+
const keep = makeReg('https://chat.kbve.com/sw.js');
|
|
117
|
+
installNavigator([keep]);
|
|
118
|
+
installCaches([]);
|
|
119
|
+
|
|
120
|
+
const result = await swRecovery({
|
|
121
|
+
allowedScripts: ['/sw.js'],
|
|
122
|
+
});
|
|
123
|
+
expect(result.keptScripts).toEqual(['https://chat.kbve.com/sw.js']);
|
|
124
|
+
expect(keep.unregister).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('skips cache clearing when clearCaches is false', async () => {
|
|
128
|
+
installNavigator([makeReg('https://chat.kbve.com/old.js')]);
|
|
129
|
+
installCaches(['x']);
|
|
130
|
+
|
|
131
|
+
const result = await swRecovery({ clearCaches: false });
|
|
132
|
+
expect(result.cachesCleared).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does not reload when nothing was removed', async () => {
|
|
136
|
+
installNavigator([]);
|
|
137
|
+
installCaches([]);
|
|
138
|
+
|
|
139
|
+
const reload = vi.fn();
|
|
140
|
+
Object.defineProperty(globalThis, 'window', {
|
|
141
|
+
value: { location: { reload } },
|
|
142
|
+
configurable: true,
|
|
143
|
+
writable: true,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const result = await swRecovery({ reloadOnCleanup: true });
|
|
147
|
+
expect(result.reloaded).toBe(false);
|
|
148
|
+
expect(reload).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('continues when an individual unregister rejects', async () => {
|
|
152
|
+
const ok = makeReg('https://chat.kbve.com/a.js');
|
|
153
|
+
const bad: Reg = {
|
|
154
|
+
scriptURL: 'https://chat.kbve.com/b.js',
|
|
155
|
+
unregister: vi.fn().mockRejectedValue(new Error('boom')),
|
|
156
|
+
};
|
|
157
|
+
installNavigator([ok, bad]);
|
|
158
|
+
installCaches([]);
|
|
159
|
+
|
|
160
|
+
const result = await swRecovery();
|
|
161
|
+
expect(result.unregistered).toEqual(['https://chat.kbve.com/a.js']);
|
|
162
|
+
expect(ok.unregister).toHaveBeenCalled();
|
|
163
|
+
expect(bad.unregister).toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
export interface SwRecoveryOptions {
|
|
2
|
+
allowedScripts?: readonly string[];
|
|
3
|
+
clearCaches?: boolean;
|
|
4
|
+
reloadOnCleanup?: boolean;
|
|
5
|
+
logPrefix?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SwRecoveryResult {
|
|
9
|
+
supported: boolean;
|
|
10
|
+
unregistered: string[];
|
|
11
|
+
keptScripts: string[];
|
|
12
|
+
cachesCleared: string[];
|
|
13
|
+
reloaded: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_OPTS: Required<Omit<SwRecoveryOptions, 'allowedScripts'>> & {
|
|
17
|
+
allowedScripts: readonly string[];
|
|
18
|
+
} = {
|
|
19
|
+
allowedScripts: [],
|
|
20
|
+
clearCaches: true,
|
|
21
|
+
reloadOnCleanup: false,
|
|
22
|
+
logPrefix: '[sw-recovery]',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function getScriptURL(reg: ServiceWorkerRegistration): string | null {
|
|
26
|
+
return (
|
|
27
|
+
reg.active?.scriptURL ??
|
|
28
|
+
reg.installing?.scriptURL ??
|
|
29
|
+
reg.waiting?.scriptURL ??
|
|
30
|
+
null
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function scriptIsAllowed(
|
|
35
|
+
scriptURL: string | null,
|
|
36
|
+
allowed: readonly string[],
|
|
37
|
+
): boolean {
|
|
38
|
+
if (!scriptURL) return false;
|
|
39
|
+
if (allowed.length === 0) return false;
|
|
40
|
+
return allowed.some((rule) => {
|
|
41
|
+
if (rule === scriptURL) return true;
|
|
42
|
+
try {
|
|
43
|
+
return new URL(scriptURL).pathname === rule;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function swRecovery(
|
|
51
|
+
opts: SwRecoveryOptions = {},
|
|
52
|
+
): Promise<SwRecoveryResult> {
|
|
53
|
+
const cfg = { ...DEFAULT_OPTS, ...opts };
|
|
54
|
+
const result: SwRecoveryResult = {
|
|
55
|
+
supported: false,
|
|
56
|
+
unregistered: [],
|
|
57
|
+
keptScripts: [],
|
|
58
|
+
cachesCleared: [],
|
|
59
|
+
reloaded: false,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
typeof navigator === 'undefined' ||
|
|
64
|
+
!('serviceWorker' in navigator) ||
|
|
65
|
+
typeof navigator.serviceWorker?.getRegistrations !== 'function'
|
|
66
|
+
) {
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
result.supported = true;
|
|
71
|
+
|
|
72
|
+
let registrations: readonly ServiceWorkerRegistration[] = [];
|
|
73
|
+
try {
|
|
74
|
+
registrations = await navigator.serviceWorker.getRegistrations();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.warn(`${cfg.logPrefix} getRegistrations failed:`, err);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const reg of registrations) {
|
|
81
|
+
const scriptURL = getScriptURL(reg);
|
|
82
|
+
if (scriptIsAllowed(scriptURL, cfg.allowedScripts)) {
|
|
83
|
+
result.keptScripts.push(scriptURL as string);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const ok = await reg.unregister();
|
|
89
|
+
if (ok) {
|
|
90
|
+
result.unregistered.push(scriptURL ?? '<unknown>');
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.warn(
|
|
94
|
+
`${cfg.logPrefix} unregister failed for ${scriptURL}:`,
|
|
95
|
+
err,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (cfg.clearCaches && typeof caches !== 'undefined') {
|
|
101
|
+
try {
|
|
102
|
+
const keys = await caches.keys();
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
try {
|
|
105
|
+
const ok = await caches.delete(key);
|
|
106
|
+
if (ok) result.cachesCleared.push(key);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.warn(
|
|
109
|
+
`${cfg.logPrefix} cache delete failed for ${key}:`,
|
|
110
|
+
err,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn(`${cfg.logPrefix} caches.keys failed:`, err);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const removedAnything =
|
|
120
|
+
result.unregistered.length > 0 || result.cachesCleared.length > 0;
|
|
121
|
+
|
|
122
|
+
if (removedAnything) {
|
|
123
|
+
console.info(`${cfg.logPrefix} cleaned`, {
|
|
124
|
+
unregistered: result.unregistered,
|
|
125
|
+
kept: result.keptScripts,
|
|
126
|
+
caches: result.cachesCleared,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
cfg.reloadOnCleanup &&
|
|
132
|
+
removedAnything &&
|
|
133
|
+
typeof window !== 'undefined' &&
|
|
134
|
+
typeof window.location?.reload === 'function'
|
|
135
|
+
) {
|
|
136
|
+
result.reloaded = true;
|
|
137
|
+
window.location.reload();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
DroidEventSchemas,
|
|
4
|
+
DroidFirstConnectSchema,
|
|
5
|
+
DroidReadySchema,
|
|
6
|
+
AuthReadySchema,
|
|
7
|
+
AuthErrorSchema,
|
|
8
|
+
WorkerErrorSchema,
|
|
9
|
+
PageLifecycleSchema,
|
|
10
|
+
PanelEventSchema,
|
|
11
|
+
DroidDownscaleSchema,
|
|
12
|
+
DroidUpscaleSchema,
|
|
13
|
+
DroidModReadySchema,
|
|
14
|
+
} from './event-types';
|
|
15
|
+
|
|
16
|
+
describe('DroidEventSchemas — happy path round-trips', () => {
|
|
17
|
+
it.each([
|
|
18
|
+
['droid-ready', DroidReadySchema, { timestamp: 1 }],
|
|
19
|
+
[
|
|
20
|
+
'droid-first-connect',
|
|
21
|
+
DroidFirstConnectSchema,
|
|
22
|
+
{ timestamp: 1, workersFirst: { db: true, ws: false } },
|
|
23
|
+
],
|
|
24
|
+
[
|
|
25
|
+
'droid-mod-ready',
|
|
26
|
+
DroidModReadySchema,
|
|
27
|
+
{ meta: { id: 'x', name: 'X', version: '1.0.0' }, timestamp: 1 },
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
'droid-downscale',
|
|
31
|
+
DroidDownscaleSchema,
|
|
32
|
+
{ timestamp: 1, level: 'minimal' },
|
|
33
|
+
],
|
|
34
|
+
['droid-upscale', DroidUpscaleSchema, { timestamp: 1, level: 'full' }],
|
|
35
|
+
[
|
|
36
|
+
'panel-open',
|
|
37
|
+
PanelEventSchema,
|
|
38
|
+
{ id: 'right', payload: { foo: 'bar' } },
|
|
39
|
+
],
|
|
40
|
+
['panel-close', PanelEventSchema, { id: 'top' }],
|
|
41
|
+
[
|
|
42
|
+
'auth-ready',
|
|
43
|
+
AuthReadySchema,
|
|
44
|
+
{ timestamp: 1, tone: 'auth' as const, name: 'Alice' },
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
'auth-error',
|
|
48
|
+
AuthErrorSchema,
|
|
49
|
+
{ timestamp: 1, message: 'invalid grant' },
|
|
50
|
+
],
|
|
51
|
+
[
|
|
52
|
+
'worker-error',
|
|
53
|
+
WorkerErrorSchema,
|
|
54
|
+
{
|
|
55
|
+
timestamp: 1,
|
|
56
|
+
worker: 'ws' as const,
|
|
57
|
+
operation: 'connect',
|
|
58
|
+
message: 'closed',
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
'page-mount',
|
|
63
|
+
PageLifecycleSchema,
|
|
64
|
+
{ timestamp: 1, url: '/foo', source: 'initial' as const },
|
|
65
|
+
],
|
|
66
|
+
])('%s accepts a valid payload', (_name, schema, payload) => {
|
|
67
|
+
expect(schema.safeParse(payload).success).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('DroidEventSchemas — rejection cases', () => {
|
|
72
|
+
it('rejects droid-first-connect without workersFirst', () => {
|
|
73
|
+
expect(
|
|
74
|
+
DroidFirstConnectSchema.safeParse({ timestamp: 1 }).success,
|
|
75
|
+
).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects auth-ready with an unknown tone', () => {
|
|
79
|
+
expect(
|
|
80
|
+
AuthReadySchema.safeParse({ timestamp: 1, tone: 'staff' }).success,
|
|
81
|
+
).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects worker-error with an unknown worker name', () => {
|
|
85
|
+
expect(
|
|
86
|
+
WorkerErrorSchema.safeParse({
|
|
87
|
+
timestamp: 1,
|
|
88
|
+
worker: 'gateway',
|
|
89
|
+
operation: 'x',
|
|
90
|
+
message: 'y',
|
|
91
|
+
}).success,
|
|
92
|
+
).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('rejects page-lifecycle with an unknown source', () => {
|
|
96
|
+
expect(
|
|
97
|
+
PageLifecycleSchema.safeParse({
|
|
98
|
+
timestamp: 1,
|
|
99
|
+
url: '/x',
|
|
100
|
+
source: 'turbolinks',
|
|
101
|
+
}).success,
|
|
102
|
+
).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects schemas missing the timestamp field', () => {
|
|
106
|
+
expect(DroidReadySchema.safeParse({}).success).toBe(false);
|
|
107
|
+
expect(AuthErrorSchema.safeParse({ message: 'x' }).success).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('DroidEventSchemas — registry contract', () => {
|
|
112
|
+
it('every key resolves to a schema with safeParse', () => {
|
|
113
|
+
for (const [key, schema] of Object.entries(DroidEventSchemas)) {
|
|
114
|
+
expect(typeof (schema as { safeParse?: unknown }).safeParse).toBe(
|
|
115
|
+
'function',
|
|
116
|
+
);
|
|
117
|
+
expect(key).toMatch(/^[a-z]+(-[a-z]+)+$/);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -23,10 +23,13 @@ describe('DroidEventSchemas', () => {
|
|
|
23
23
|
expect(keys).toContain('tooltip-closed');
|
|
24
24
|
expect(keys).toContain('modal-opened');
|
|
25
25
|
expect(keys).toContain('modal-closed');
|
|
26
|
+
expect(keys).toContain('auth-ready');
|
|
27
|
+
expect(keys).toContain('auth-error');
|
|
28
|
+
expect(keys).toContain('palworld-live-snapshot');
|
|
26
29
|
});
|
|
27
30
|
|
|
28
|
-
it('has exactly
|
|
29
|
-
expect(Object.keys(DroidEventSchemas)).toHaveLength(
|
|
31
|
+
it('has exactly 21 event types', () => {
|
|
32
|
+
expect(Object.keys(DroidEventSchemas)).toHaveLength(21);
|
|
30
33
|
});
|
|
31
34
|
});
|
|
32
35
|
|