feat(saves): keep players' game saves on their account

Saves games keep in the browser now sync with the signed-in player's
account: Ruffle SharedObjects and HTML5 localStorage, IndexedDB databases
(exported and imported whole, typed arrays and blobs included), and streamed
games' save files. Games share the app's origin, so storage that changes
while a game runs is attributed to it. Before a game starts, the account's
save is loaded unless this browser has a newer one; changes upload as they
happen; and a Saves dialog in the player restores any of the last 10, keeping
the save it replaces as a backup.
This commit is contained in:
2026-10-12 16:17:09 -07:00
parent 0980b789d5
commit 1e098674d8
14 changed files with 1369 additions and 99 deletions
+1
View File
@@ -80,6 +80,7 @@
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.4.0",
"msw": "^2.12.7",
"postcss": "^8.4.38",
+142 -99
View File
@@ -1,9 +1,20 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { Fragment, useState, useEffect, useRef, useMemo } from 'react';
import { logger } from '@/lib/logger';
import { AlertCircle, Maximize2, Minimize2, Loader2, ArrowLeft, MonitorPlay } from 'lucide-react';
import {
AlertCircle,
Maximize2,
Minimize2,
Loader2,
ArrowLeft,
MonitorPlay,
History,
} from 'lucide-react';
import { LazyImage } from '@/components/common/LazyImage';
import { RufflePlayer } from './RufflePlayer';
import { StreamPlayer } from './StreamPlayer';
import { SavesDialog } from './SavesDialog';
import { useGameSaveSync } from '@/hooks/useGameSaveSync';
import { useAuthStore } from '@/store/auth';
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
import { PlatformIcon } from '@/components/ui/platform-icon';
@@ -59,6 +70,10 @@ function GamePlayerInternal({
const [isFullscreen, setIsFullscreen] = useState(initialFullscreen);
// Streams a game Ruffle can play with the original player, for when Ruffle falls short
const [preferStream, setPreferStream] = useState(false);
// Signed-in players' saves are kept on their account
const isSignedIn = useAuthStore((state) => state.isAuthenticated);
const saveSync = useGameSaveSync(gameId, isSignedIn && (canPlayInBrowser || streamable));
const [savesOpen, setSavesOpen] = useState(false);
const [iframeError, setIframeError] = useState<string | null>(null);
const [logoError, setLogoError] = useState(false);
@@ -234,6 +249,17 @@ function GamePlayerInternal({
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{isSignedIn && gameId ? (
<button
onClick={() => setSavesOpen(true)}
className="flex items-center gap-2 px-3 py-2 bg-secondary hover:bg-secondary/80 rounded-lg transition-colors flex-shrink-0"
title="Saves kept on your account"
aria-label="Saves"
>
<History size={18} aria-hidden="true" />
<span className="text-sm hidden sm:inline">Saves</span>
</button>
) : null}
{canSwitchPlayer ? (
<button
onClick={() => setPreferStream((prev) => !prev)}
@@ -286,106 +312,123 @@ function GamePlayerInternal({
}
}
>
{/* Other platforms - stream from the server */}
{isStreamed && gameId ? (
<StreamPlayer gameId={gameId} title={title} />
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
<RufflePlayer
swfUrl={contentUrl}
width="100%"
height="100%"
className="w-full h-full"
scaleMode={defaultScaleMode}
onLoadError={(error) => {
logger.error('Ruffle load error:', error);
}}
onLoadSuccess={() => {
logger.debug('Ruffle loaded successfully');
}}
/>
) : /* Flash HTML wrapper - use iframe */ flashContentType === 'html-wrapper' &&
contentUrl ? (
<>
<iframe
src={contentUrl}
className="w-full h-full border-0"
title={title}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-pointer-lock allow-top-navigation-by-user-activation"
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
onLoad={() => {
logger.debug('[Flash HTML Wrapper] Loaded successfully:', contentUrl);
setIframeError(null);
}}
onError={(e) => {
logger.error('[Flash HTML Wrapper] Load error:', e);
setIframeError('Failed to load Flash game');
}}
/>
{iframeError ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/90 z-10">
<div className="text-center max-w-md p-6">
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2">Failed to Load Game</h3>
<p className="mb-4">{iframeError}</p>
<div className="bg-card rounded p-3 mb-4 border border-border">
<p className="text-sm text-muted-foreground text-left">
<strong>URL:</strong> {contentUrl}
</p>
</div>
<p className="text-sm text-muted-foreground">
Check the browser console for more details.
</p>
</div>
</div>
) : null}
</>
) : /* HTML5 game - use iframe */ platform === 'HTML5' && contentUrl ? (
<>
<iframe
src={contentUrl}
className="w-full h-full border-0"
title={title}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-pointer-lock allow-top-navigation-by-user-activation"
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
onLoad={() => {
logger.debug('[HTML5 Game] Loaded successfully:', contentUrl);
setIframeError(null);
}}
onError={(e) => {
logger.error('[HTML5 Game] Load error:', e);
setIframeError('Failed to load HTML5 game');
}}
/>
{iframeError ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/90 z-10">
<div className="text-center max-w-md p-6">
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2">Failed to Load Game</h3>
<p className="mb-4">{iframeError}</p>
<div className="bg-card rounded p-3 mb-4 border border-border">
<p className="text-sm text-muted-foreground text-left">
<strong>URL:</strong> {contentUrl}
</p>
</div>
<p className="text-sm text-muted-foreground">
Check the browser console for more details.
</p>
</div>
</div>
) : null}
</>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<AlertCircle size={48} className="text-yellow-500 mx-auto mb-4" />
<p>No content URL available for this game</p>
<p className="text-sm text-muted-foreground mt-2">
Launch command: {launchCommand || 'N/A'}
</p>
</div>
{!saveSync.ready ? (
<div className="flex items-center justify-center h-full gap-3 text-muted-foreground">
<Loader2 className="animate-spin" aria-hidden="true" />
<span>Loading your save...</span>
</div>
) : (
<Fragment key={saveSync.epoch}>
{/* Other platforms - stream from the server */}
{isStreamed && gameId ? (
<StreamPlayer gameId={gameId} title={title} />
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
<RufflePlayer
swfUrl={contentUrl}
width="100%"
height="100%"
className="w-full h-full"
scaleMode={defaultScaleMode}
onLoadError={(error) => {
logger.error('Ruffle load error:', error);
}}
onLoadSuccess={() => {
logger.debug('Ruffle loaded successfully');
}}
/>
) : /* Flash HTML wrapper - use iframe */ flashContentType === 'html-wrapper' &&
contentUrl ? (
<>
<iframe
src={contentUrl}
className="w-full h-full border-0"
title={title}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-pointer-lock allow-top-navigation-by-user-activation"
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
onLoad={() => {
logger.debug('[Flash HTML Wrapper] Loaded successfully:', contentUrl);
setIframeError(null);
}}
onError={(e) => {
logger.error('[Flash HTML Wrapper] Load error:', e);
setIframeError('Failed to load Flash game');
}}
/>
{iframeError ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/90 z-10">
<div className="text-center max-w-md p-6">
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2">Failed to Load Game</h3>
<p className="mb-4">{iframeError}</p>
<div className="bg-card rounded p-3 mb-4 border border-border">
<p className="text-sm text-muted-foreground text-left">
<strong>URL:</strong> {contentUrl}
</p>
</div>
<p className="text-sm text-muted-foreground">
Check the browser console for more details.
</p>
</div>
</div>
) : null}
</>
) : /* HTML5 game - use iframe */ platform === 'HTML5' && contentUrl ? (
<>
<iframe
src={contentUrl}
className="w-full h-full border-0"
title={title}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-pointer-lock allow-top-navigation-by-user-activation"
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
onLoad={() => {
logger.debug('[HTML5 Game] Loaded successfully:', contentUrl);
setIframeError(null);
}}
onError={(e) => {
logger.error('[HTML5 Game] Load error:', e);
setIframeError('Failed to load HTML5 game');
}}
/>
{iframeError ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/90 z-10">
<div className="text-center max-w-md p-6">
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2">Failed to Load Game</h3>
<p className="mb-4">{iframeError}</p>
<div className="bg-card rounded p-3 mb-4 border border-border">
<p className="text-sm text-muted-foreground text-left">
<strong>URL:</strong> {contentUrl}
</p>
</div>
<p className="text-sm text-muted-foreground">
Check the browser console for more details.
</p>
</div>
</div>
) : null}
</>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<AlertCircle size={48} className="text-yellow-500 mx-auto mb-4" />
<p>No content URL available for this game</p>
<p className="text-sm text-muted-foreground mt-2">
Launch command: {launchCommand || 'N/A'}
</p>
</div>
</div>
)}
</Fragment>
)}
</div>
{isSignedIn && gameId ? (
<SavesDialog
gameId={gameId}
isOpen={savesOpen}
onClose={() => setSavesOpen(false)}
onRestore={saveSync.restore}
/>
) : null}
</div>
);
}
@@ -0,0 +1,114 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { FormattedDate } from '@/components/common/FormattedDate';
import { useDialog } from '@/contexts/DialogContext';
import { gameSavesApi } from '@/lib/api/gameSaves';
import { getErrorMessage } from '@/types/api-error';
interface SavesDialogProps {
gameId: string;
isOpen: boolean;
onClose: () => void;
onRestore: (snapshotId: number) => Promise<void>;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/** Lists the saves kept on the player's account for a game, and restores one. */
export function SavesDialog({ gameId, isOpen, onClose, onRestore }: SavesDialogProps) {
const queryClient = useQueryClient();
const { showConfirm, showToast } = useDialog();
const [restoringId, setRestoringId] = useState<number | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ['game-saves', gameId],
queryFn: () => gameSavesApi.get(gameId),
enabled: isOpen,
staleTime: 0,
});
const restore = async (snapshotId: number) => {
const confirmed = await showConfirm({
title: 'Restore this save?',
message: 'The game restarts from this save. Your current save is kept as a backup.',
confirmText: 'Restore',
variant: 'warning',
});
if (!confirmed) return;
setRestoringId(snapshotId);
try {
await onRestore(snapshotId);
await queryClient.invalidateQueries({ queryKey: ['game-saves', gameId] });
showToast('Save restored', 'success');
onClose();
} catch (restoreError) {
showToast(getErrorMessage(restoreError) || 'Failed to restore save', 'error');
} finally {
setRestoringId(null);
}
};
const snapshots = data?.snapshots ?? [];
return (
<Dialog open={isOpen} onOpenChange={(open: boolean) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Saves</DialogTitle>
</DialogHeader>
<DialogBody>
<p className="text-sm text-muted-foreground mb-4">
Your saves for this game are kept on your account, so another browser picks up where you
left off. The last 10 are kept.
</p>
{isLoading ? (
<div className="flex justify-center py-6">
<Loader2 className="animate-spin text-muted-foreground" aria-label="Loading saves" />
</div>
) : error ? (
<p className="text-sm text-destructive">{getErrorMessage(error)}</p>
) : snapshots.length === 0 ? (
<p className="text-sm text-muted-foreground">No saves yet.</p>
) : (
<ul className="divide-y divide-border">
{snapshots.map((snapshot, index) => (
<li key={snapshot.id} className="flex items-center justify-between py-3 gap-4">
<div className="text-sm">
<FormattedDate date={snapshot.savedAt} type="datetime" />
<div className="text-muted-foreground">
{formatSize(snapshot.size)}
{index === 0 ? ' · Current' : ''}
</div>
</div>
{index === 0 ? null : (
<Button
variant="secondary"
size="sm"
disabled={restoringId !== null}
onClick={() => void restore(snapshot.id)}
>
{restoringId === snapshot.id ? 'Restoring...' : 'Restore'}
</Button>
)}
</li>
))}
</ul>
)}
</DialogBody>
</DialogContent>
</Dialog>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { IDBFactory } from 'fake-indexeddb';
import { useGameSaveSync } from './useGameSaveSync';
import { saveHash, storeSyncState, type GameSaveData } from '@/lib/saves/gameSaves';
const api = vi.hoisted(() => ({
get: vi.fn(),
getSnapshot: vi.fn(),
upload: vi.fn(),
uploadOnUnload: vi.fn(),
}));
vi.mock('@/lib/api/gameSaves', () => ({ gameSavesApi: api }));
const GAME = '247a4c93-c252-49c8-8bc9-fb43d5c039df';
const save = (value: string): GameSaveData => ({ localStorage: { 'site//save': value } });
function snapshot(value: string, savedAt: string, id = 1) {
const data = save(value);
return { id, hash: saveHash(data) ?? '', size: 10, savedAt, createdAt: savedAt, data };
}
async function haveLocal(value: string, syncedHash: string | null, savedAt: string | null) {
localStorage.setItem('site//save', value);
await storeSyncState({ gameId: GAME, keys: ['site//save'], databases: [], syncedHash, savedAt });
}
async function render(enabled = true) {
const hook = renderHook(() => useGameSaveSync(GAME, enabled));
await waitFor(() => expect(hook.result.current.ready).toBe(true));
return hook;
}
const uploadedValues = () =>
api.upload.mock.calls.map(([, upload]) => upload.data.localStorage['site//save']);
describe('useGameSaveSync', () => {
afterEach(async () => {
// Unmounting saves what's left asynchronously; let it finish before the next test resets storage
cleanup();
await new Promise((resolve) => setTimeout(resolve, 100));
});
beforeEach(() => {
vi.clearAllMocks();
globalThis.indexedDB = new IDBFactory();
localStorage.clear();
api.upload.mockResolvedValue({});
});
it("loads the account's save into a browser that has none", async () => {
api.get.mockResolvedValue({
latest: snapshot('from account', '2026-10-12T10:00:00Z'),
snapshots: [],
});
await render();
expect(localStorage.getItem('site//save')).toBe('from account');
expect(api.upload).not.toHaveBeenCalled();
});
it('uploads a save the account does not have yet', async () => {
api.get.mockResolvedValue({ latest: null, snapshots: [] });
await haveLocal('local only', null, '2026-10-12T10:00:00Z');
await render();
expect(uploadedValues()).toEqual(['local only']);
});
it('uploads a save changed here since the last sync', async () => {
const account = snapshot('synced', '2026-10-12T09:00:00Z');
api.get.mockResolvedValue({ latest: account, snapshots: [] });
await haveLocal('played further', account.hash, '2026-10-12T10:00:00Z');
await render();
expect(localStorage.getItem('site//save')).toBe('played further');
expect(uploadedValues()).toEqual(['played further']);
});
it("takes the account's save when only another browser changed it", async () => {
const synced = saveHash(save('synced'));
api.get.mockResolvedValue({
latest: snapshot('elsewhere', '2026-10-12T11:00:00Z'),
snapshots: [],
});
await haveLocal('synced', synced, '2026-10-12T09:00:00Z');
await render();
expect(localStorage.getItem('site//save')).toBe('elsewhere');
expect(api.upload).not.toHaveBeenCalled();
});
it('keeps the newer save when both changed, backing up the other', async () => {
api.get.mockResolvedValue({
latest: snapshot('elsewhere', '2026-10-12T11:00:00Z'),
snapshots: [],
});
await haveLocal('here', saveHash(save('long ago')), '2026-10-12T10:00:00Z');
await render();
expect(localStorage.getItem('site//save')).toBe('elsewhere');
// This browser's save is uploaded first so it stays as a backup, then the winner again
expect(uploadedValues()).toEqual(['here', 'elsewhere']);
});
it('restores a backup and remounts the game', async () => {
api.get.mockResolvedValue({
latest: snapshot('current', '2026-10-12T11:00:00Z'),
snapshots: [],
});
api.getSnapshot.mockResolvedValue(snapshot('older', '2026-10-12T08:00:00Z', 2));
const { result } = await render();
const epoch = result.current.epoch;
await act(() => result.current.restore(2));
expect(localStorage.getItem('site//save')).toBe('older');
expect(uploadedValues()).toContain('older');
expect(result.current.epoch).toBe(epoch + 1);
});
it('does nothing for guests', async () => {
await render(false);
expect(api.get).not.toHaveBeenCalled();
});
});
+177
View File
@@ -0,0 +1,177 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { gameSavesApi, type GameSaveUpload } from '@/lib/api/gameSaves';
import { logger } from '@/lib/logger';
import {
SaveChangeTracker,
applySave,
collectSave,
loadSyncState,
saveHash,
storeSyncState,
type SaveSyncState,
} from '@/lib/saves/gameSaves';
const CHECK_INTERVAL_MS = 5000;
const UPLOAD_DELAY_MS = 10000;
export interface GameSaveSync {
/** False until this browser's save is reconciled with the account's */
ready: boolean;
/** Changes whenever a restore replaces the save, to remount the game */
epoch: number;
/** Replaces the save with an account snapshot, keeping the current one as a backup */
restore: (snapshotId: number) => Promise<void>;
}
/**
* Keeps a signed-in player's game save on their account. Before the game runs,
* loads the account's save if this browser has nothing newer; while it runs,
* uploads changes, the account keeping the last few as backups.
*/
export function useGameSaveSync(gameId: string | undefined, enabled: boolean): GameSaveSync {
const active = enabled && Boolean(gameId);
const [ready, setReady] = useState(!active);
const [epoch, setEpoch] = useState(0);
const stateRef = useRef<SaveSyncState | null>(null);
const trackerRef = useRef<SaveChangeTracker | null>(null);
const uploadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<GameSaveUpload | null>(null);
const persist = useCallback(async (state: SaveSyncState) => {
stateRef.current = state;
await storeSyncState(state);
}, []);
const upload = useCallback(async () => {
const state = stateRef.current;
if (!state || !gameId) return;
const data = await collectSave(state);
const hash = saveHash(data);
if (!hash || hash === state.syncedHash) return;
const savedAt = state.savedAt ?? new Date().toISOString();
pendingRef.current = { data, hash, savedAt };
await gameSavesApi.upload(gameId, { data, hash, savedAt });
pendingRef.current = null;
await persist({ ...state, syncedHash: hash });
}, [gameId, persist]);
/** Checks for changes, and schedules an upload if there are any. */
const check = useCallback(async () => {
const tracker = trackerRef.current;
const state = stateRef.current;
if (!tracker || !state) return;
const result = await tracker.check(state);
if (!result.changed) return;
const next = { ...result.state, savedAt: new Date().toISOString() };
await persist(next);
pendingRef.current = { data: await collectSave(next), hash: '', savedAt: next.savedAt };
if (uploadTimerRef.current) clearTimeout(uploadTimerRef.current);
uploadTimerRef.current = setTimeout(() => {
upload().catch((error) => logger.error('[GameSaves] Upload failed:', error));
}, UPLOAD_DELAY_MS);
}, [persist, upload]);
// Reconcile before the game starts
useEffect(() => {
if (!active || !gameId) return;
let cancelled = false;
setReady(false);
(async () => {
let state = await loadSyncState(gameId);
const local = await collectSave(state);
const localHash = saveHash(local);
const { latest } = await gameSavesApi.get(gameId);
if (!latest) {
stateRef.current = state;
if (localHash) await upload();
} else if (latest.hash === localHash) {
await persist({ ...state, syncedHash: latest.hash });
} else if (!localHash || localHash === state.syncedHash) {
// Unchanged here since the last sync: the account's save is newer
state = await applySave(state, latest.data);
await persist({ ...state, syncedHash: latest.hash, savedAt: latest.savedAt });
} else {
// Changed here; also changed elsewhere unless the account still holds what was synced
const localIsNewer =
latest.hash === state.syncedHash ||
(state.savedAt !== null && Date.parse(state.savedAt) > Date.parse(latest.savedAt));
stateRef.current = state;
await upload();
if (!localIsNewer) {
// The account's save wins; the upload above keeps this one as a backup
state = await applySave(stateRef.current ?? state, latest.data);
await gameSavesApi.upload(gameId, {
data: latest.data,
hash: latest.hash,
savedAt: latest.savedAt,
});
await persist({ ...state, syncedHash: latest.hash, savedAt: latest.savedAt });
}
}
})()
.catch((error) => logger.error('[GameSaves] Could not sync with the account:', error))
.then(async () => {
stateRef.current ??= await loadSyncState(gameId);
trackerRef.current = await SaveChangeTracker.start(gameId);
})
.catch((error) => logger.error('[GameSaves] Could not start tracking saves:', error))
.finally(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, [active, gameId, persist, upload, epoch]);
// Watch for changes while the game runs, and save what's left when it stops
useEffect(() => {
if (!active || !ready || !gameId) return;
const interval = setInterval(() => {
check().catch((error) => logger.error('[GameSaves] Check failed:', error));
}, CHECK_INTERVAL_MS);
const onPageHide = () => {
const pending = pendingRef.current;
const state = stateRef.current;
if (pending && state) {
const hash = saveHash(pending.data);
if (hash && hash !== state.syncedHash) {
gameSavesApi.uploadOnUnload(gameId, { ...pending, hash });
}
}
};
window.addEventListener('pagehide', onPageHide);
return () => {
clearInterval(interval);
window.removeEventListener('pagehide', onPageHide);
if (uploadTimerRef.current) clearTimeout(uploadTimerRef.current);
check()
.then(upload)
.catch((error) => logger.error('[GameSaves] Final upload failed:', error));
};
}, [active, ready, gameId, check, upload]);
const restore = useCallback(
async (snapshotId: number) => {
if (!gameId) return;
await check();
await upload();
const snapshot = await gameSavesApi.getSnapshot(gameId, snapshotId);
const state = await applySave(
stateRef.current ?? (await loadSyncState(gameId)),
snapshot.data
);
const savedAt = new Date().toISOString();
await gameSavesApi.upload(gameId, { data: snapshot.data, hash: snapshot.hash, savedAt });
await persist({ ...state, syncedHash: snapshot.hash, savedAt });
setEpoch((value) => value + 1);
},
[gameId, check, upload, persist]
);
return { ready: ready || !active, epoch, restore };
}
+62
View File
@@ -0,0 +1,62 @@
import { apiClient } from './client';
import type { GameSaveData } from '@/lib/saves/gameSaves';
export interface GameSaveSnapshotMeta {
id: number;
hash: string;
size: number;
savedAt: string;
createdAt: string;
}
export interface GameSaveSnapshot extends GameSaveSnapshotMeta {
data: GameSaveData;
}
export interface GameSaves {
latest: GameSaveSnapshot | null;
snapshots: GameSaveSnapshotMeta[];
}
export interface GameSaveUpload {
data: GameSaveData;
hash: string;
savedAt: string;
}
/** Keepalive requests outliving a closing page may carry at most 64 KiB. */
const KEEPALIVE_LIMIT = 60 * 1024;
export const gameSavesApi = {
get: async (gameId: string): Promise<GameSaves> => {
const { data } = await apiClient.get<GameSaves>(`/game-saves/${gameId}`);
return data;
},
getSnapshot: async (gameId: string, snapshotId: number): Promise<GameSaveSnapshot> => {
const { data } = await apiClient.get<GameSaveSnapshot>(`/game-saves/${gameId}/${snapshotId}`);
return data;
},
upload: async (gameId: string, upload: GameSaveUpload): Promise<GameSaveSnapshotMeta> => {
const { data } = await apiClient.post<GameSaveSnapshotMeta>(`/game-saves/${gameId}`, upload);
return data;
},
/**
* Uploads while the page is closing, when an axios request would be cancelled.
* Returns false when the save is too large for a keepalive request.
*/
uploadOnUnload: (gameId: string, upload: GameSaveUpload): boolean => {
const body = JSON.stringify(upload);
if (body.length > KEEPALIVE_LIMIT) return false;
void fetch(`/api/game-saves/${gameId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
keepalive: true,
body,
}).catch(() => undefined);
return true;
},
};
+1
View File
@@ -5,6 +5,7 @@ export { apiClient } from './client';
export { authApi } from './auth';
export { gamesApi } from './games';
export { gameSavesApi } from './gameSaves';
export { playlistsApi, communityPlaylistsApi } from './playlists';
export type { CommunityPlaylistsResponse } from './playlists';
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { IDBFactory } from 'fake-indexeddb';
import {
SaveChangeTracker,
applySave,
collectSave,
loadSyncState,
saveHash,
storeSyncState,
} from './gameSaves';
import { loadSaves, storeSaves } from '@/lib/streamSaves';
const GAME = '247a4c93-c252-49c8-8bc9-fb43d5c039df';
async function writeDatabase(name: string, value: string): Promise<void> {
const open = indexedDB.open(name, 1);
open.onupgradeneeded = () => open.result.createObjectStore('state');
const db = await new Promise<IDBDatabase>(
(resolve) => (open.onsuccess = () => resolve(open.result))
);
const tx = db.transaction('state', 'readwrite');
tx.objectStore('state').put(value, 'save');
await new Promise((resolve) => (tx.oncomplete = resolve));
db.close();
}
describe('game saves in the browser', () => {
beforeEach(() => {
globalThis.indexedDB = new IDBFactory();
localStorage.clear();
});
it('attributes storage that changes while a game runs, ignoring the app and untouched storage', async () => {
localStorage.setItem('other-game/save', 'unchanged');
await writeDatabase('other-game-db', 'unchanged');
const tracker = await SaveChangeTracker.start(GAME);
let state = await loadSyncState(GAME);
expect((await tracker.check(state)).changed).toBe(false);
localStorage.setItem('games.example/game-proxy/http:/example.com/game.swf/save', 'AMF');
localStorage.setItem('flashpoint-theme-settings', '{}');
await writeDatabase('wildlife-rescue:/scope/', 'level 3');
await storeSaves(GAME, { 'cookies.txt': 'YQ==' });
const result = await tracker.check(state);
expect(result.changed).toBe(true);
state = result.state;
expect(state.keys).toEqual(['games.example/game-proxy/http:/example.com/game.swf/save']);
expect(state.databases).toEqual(['wildlife-rescue:/scope/']);
const save = await collectSave(state);
expect(save.localStorage).toEqual({
'games.example/game-proxy/http:/example.com/game.swf/save': 'AMF',
});
expect(save.stream).toEqual({ 'cookies.txt': 'YQ==' });
expect(save.indexedDB?.map((db) => db.name)).toEqual(['wildlife-rescue:/scope/']);
expect(saveHash(save)).not.toBeNull();
expect(saveHash({})).toBeNull();
});
it('applies a save into a fresh browser, replacing what the game kept before', async () => {
localStorage.setItem('key-a', 'old');
const tracker = await SaveChangeTracker.start(GAME);
localStorage.setItem('key-a', 'mine');
await writeDatabase('game-db', 'mine');
await storeSaves(GAME, { 'cookies.txt': 'YQ==' });
const { state } = await tracker.check(await loadSyncState(GAME));
const save = JSON.parse(JSON.stringify(await collectSave(state)));
await storeSyncState(state);
globalThis.indexedDB = new IDBFactory();
localStorage.clear();
localStorage.setItem('key-a', 'stale');
const applied = await applySave(await loadSyncState(GAME), save);
expect(localStorage.getItem('key-a')).toBe('mine');
expect(await loadSaves(GAME)).toEqual({ 'cookies.txt': 'YQ==' });
expect(saveHash(await collectSave(applied))).toBe(saveHash(save));
// Applying a save without a key removes the key the game had
await applySave(applied, { indexedDB: save.indexedDB });
expect(localStorage.getItem('key-a')).toBeNull();
expect(await loadSaves(GAME)).toEqual({});
});
});
+222
View File
@@ -0,0 +1,222 @@
import { loadSaves, storeSaves, type SaveFiles } from '@/lib/streamSaves';
import { hashSave } from './hash';
import {
deleteDatabase,
exportDatabase,
importDatabase,
listDatabases,
type SerializedDatabase,
} from './indexedDb';
/** Everything a game keeps in this browser, in the form stored on the account. */
export interface GameSaveData {
/** Files a streamed game keeps in its saves directory */
stream?: SaveFiles;
/** Ruffle SharedObjects and HTML5 localStorage entries */
localStorage?: Record<string, string>;
/** Databases HTML5 games save in, such as Unity WebGL's /idbfs */
indexedDB?: SerializedDatabase[];
}
/** What this browser knows about a game's save. */
export interface SaveSyncState {
gameId: string;
/** localStorage keys and IndexedDB databases seen changing while the game ran */
keys: string[];
databases: string[];
/** Hash of the save last uploaded or downloaded */
syncedHash: string | null;
/** When the game last changed its save here */
savedAt: string | null;
}
const STATE_DB = 'flashpoint-save-sync';
const STATE_STORE = 'games';
/** flashpoint-web's own storage, never part of a game's save */
const APP_DATABASES = new Set(['flashpoint-stream-saves', STATE_DB]);
// Unity WebGL's asset cache: large and re-downloadable, not a save
const IGNORED_DATABASES = new Set(['UnityCache']);
function isAppKey(key: string): boolean {
return key.startsWith('flashpoint-') || key === 'activityDashboardCollapsed';
}
function isGameDatabase(name: string): boolean {
return !APP_DATABASES.has(name) && !IGNORED_DATABASES.has(name);
}
function openStateDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(STATE_DB, 1);
request.onupgradeneeded = () =>
request.result.createObjectStore(STATE_STORE, { keyPath: 'gameId' });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function withStateStore<T>(
mode: IDBTransactionMode,
fn: (store: IDBObjectStore) => IDBRequest<T>
): Promise<T> {
const db = await openStateDb();
try {
return await new Promise<T>((resolve, reject) => {
const request = fn(db.transaction(STATE_STORE, mode).objectStore(STATE_STORE));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
} finally {
db.close();
}
}
export async function loadSyncState(gameId: string): Promise<SaveSyncState> {
const stored = await withStateStore<SaveSyncState | undefined>('readonly', (s) => s.get(gameId));
return stored ?? { gameId, keys: [], databases: [], syncedHash: null, savedAt: null };
}
export async function storeSyncState(state: SaveSyncState): Promise<void> {
await withStateStore('readwrite', (s) => s.put(state));
}
export function isEmptySave(data: GameSaveData): boolean {
return !data.stream && !data.localStorage && !data.indexedDB;
}
/** Hash of a save, or null when there is nothing to save. */
export function saveHash(data: GameSaveData): string | null {
return isEmptySave(data) ? null : hashSave(data);
}
/** Gathers a game's save from this browser. */
export async function collectSave(state: SaveSyncState): Promise<GameSaveData> {
const data: GameSaveData = {};
const stream = await loadSaves(state.gameId);
if (Object.keys(stream).length > 0) data.stream = stream;
const entries = state.keys
.map((key) => [key, localStorage.getItem(key)] as const)
.filter((entry): entry is readonly [string, string] => entry[1] !== null);
if (entries.length > 0) data.localStorage = Object.fromEntries(entries);
const databases = (await Promise.all(state.databases.map(exportDatabase))).filter(
(db): db is SerializedDatabase => db !== null
);
if (databases.length > 0) data.indexedDB = databases;
return data;
}
/** Makes this browser hold exactly the given save, and returns the updated state. */
export async function applySave(state: SaveSyncState, data: GameSaveData): Promise<SaveSyncState> {
if (data.stream || Object.keys(await loadSaves(state.gameId)).length > 0) {
await storeSaves(state.gameId, data.stream ?? {});
}
const keys = Object.keys(data.localStorage ?? {});
for (const key of state.keys.filter((k) => !keys.includes(k))) {
localStorage.removeItem(key);
}
for (const [key, value] of Object.entries(data.localStorage ?? {})) {
localStorage.setItem(key, value);
}
const databases = (data.indexedDB ?? []).map((db) => db.name);
for (const name of state.databases.filter((n) => !databases.includes(n))) {
await deleteDatabase(name);
}
for (const db of data.indexedDB ?? []) {
await importDatabase(db);
}
return { ...state, keys, databases };
}
function gameStorage(): Map<string, string> {
const values = new Map<string, string>();
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key !== null && !isAppKey(key)) {
values.set(key, localStorage.getItem(key) ?? '');
}
}
return values;
}
async function gameDatabaseHashes(): Promise<Map<string, string>> {
const hashes = new Map<string, string>();
const names = (await listDatabases().catch(() => [] as string[])).filter(isGameDatabase);
for (const name of names) {
const db = await exportDatabase(name).catch(() => null);
if (db) hashes.set(name, hashSave(db));
}
return hashes;
}
/**
* Notices what a running game changes. Games share flashpoint-web's origin and
* Ruffle saves may use a site-wide path, so storage that changes while a game
* runs is attributed to it rather than guessed from names.
*/
export class SaveChangeTracker {
private storage: Map<string, string>;
private databases: Map<string, string>;
private streamHash: string | null;
private constructor(
storage: Map<string, string>,
databases: Map<string, string>,
streamHash: string | null
) {
this.storage = storage;
this.databases = databases;
this.streamHash = streamHash;
}
static async start(gameId: string): Promise<SaveChangeTracker> {
return new SaveChangeTracker(
gameStorage(),
await gameDatabaseHashes(),
hashSave(await loadSaves(gameId))
);
}
/** Attributes storage changed since the last check to the game; returns whether anything changed. */
async check(state: SaveSyncState): Promise<{ state: SaveSyncState; changed: boolean }> {
let changed = false;
const keys = new Set(state.keys);
const databases = new Set(state.databases);
const storage = gameStorage();
for (const [key, value] of storage) {
if (this.storage.get(key) !== value) {
keys.add(key);
changed = true;
}
}
for (const key of this.storage.keys()) {
if (!storage.has(key) && keys.has(key)) changed = true;
}
this.storage = storage;
const hashes = await gameDatabaseHashes();
for (const [name, hash] of hashes) {
if (this.databases.get(name) !== hash) {
databases.add(name);
changed = true;
}
}
this.databases = hashes;
const streamHash = hashSave(await loadSaves(state.gameId));
if (streamHash !== this.streamHash) {
this.streamHash = streamHash;
changed = true;
}
return { state: { ...state, keys: [...keys], databases: [...databases] }, changed };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest';
import { canonicalJson, hashSave } from './hash';
describe('save hashing', () => {
it('ignores key order and detects changes', () => {
expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe('{"a":{"c":3,"d":2},"b":1}');
expect(hashSave({ b: 1, a: 2 })).toBe(hashSave({ a: 2, b: 1 }));
expect(hashSave({ a: 1 })).not.toBe(hashSave({ a: 2 }));
});
});
+27
View File
@@ -0,0 +1,27 @@
/** JSON with object keys sorted, so equal data always serializes the same way. */
export function canonicalJson(value: unknown): string {
return JSON.stringify(value, (_key, v: unknown) =>
v && typeof v === 'object' && !Array.isArray(v)
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
: v
);
}
/**
* 53-bit cyrb53 hash, used to tell whether a save changed. Not cryptographic:
* crypto.subtle is unavailable on plain-HTTP LAN deployments, and nothing here
* needs collision resistance against an attacker.
*/
export function hashSave(value: unknown): string {
const text = canonicalJson(value);
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < text.length; i++) {
const ch = text.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
}
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { IDBFactory } from 'fake-indexeddb';
import { exportDatabase, importDatabase, listDatabases } from './indexedDb';
function request<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function seed(): Promise<void> {
const open = indexedDB.open('/idbfs', 21);
open.onupgradeneeded = () => {
const db = open.result;
const files = db.createObjectStore('FILE_DATA');
files.createIndex('timestamp', 'timestamp');
db.createObjectStore('players', { keyPath: 'id', autoIncrement: true });
};
const db = await request(open);
const tx = db.transaction(['FILE_DATA', 'players'], 'readwrite');
tx.objectStore('FILE_DATA').put(
{
timestamp: new Date(1700000000000),
mode: 33206,
contents: new Uint8Array([0, 1, 254, 255]),
floats: new Float32Array([1.5, -2]),
buffer: new Uint8Array([9, 8]).buffer,
tags: new Set(['a', 'b']),
meta: new Map<string, unknown>([['level', 3]]),
tricky: { $t: 'not a tag', v: 1 },
missing: undefined,
notANumber: NaN,
big: 12345678901234567890n,
},
'/idbfs/save.dat'
);
tx.objectStore('players').put({ name: 'eliza', points: 10 });
await new Promise((resolve) => (tx.oncomplete = resolve));
db.close();
}
describe('IndexedDB export and import', () => {
beforeEach(() => {
globalThis.indexedDB = new IDBFactory();
});
it('round-trips a database, including non-JSON values, indexes and generated keys', async () => {
await seed();
const exported = await exportDatabase('/idbfs');
expect(exported).not.toBeNull();
// Survives being sent as JSON
const copy = JSON.parse(JSON.stringify(exported));
globalThis.indexedDB = new IDBFactory();
await importDatabase(copy);
expect(await listDatabases()).toEqual(['/idbfs']);
const db = await request(indexedDB.open('/idbfs'));
expect(db.version).toBe(21);
const tx = db.transaction(['FILE_DATA', 'players']);
const file = await request(tx.objectStore('FILE_DATA').get('/idbfs/save.dat'));
expect(file.timestamp).toEqual(new Date(1700000000000));
// Compared by content: fake-indexeddb clones into Node's realm, not jsdom's
expect(Object.prototype.toString.call(file.contents)).toBe('[object Uint8Array]');
expect(Array.from(file.contents)).toEqual([0, 1, 254, 255]);
expect(Array.from(file.floats)).toEqual([1.5, -2]);
expect(Array.from(new Uint8Array(file.buffer))).toEqual([9, 8]);
expect(file.tags).toEqual(new Set(['a', 'b']));
expect(file.meta).toEqual(new Map([['level', 3]]));
expect(file.tricky).toEqual({ $t: 'not a tag', v: 1 });
expect('missing' in file && file.missing === undefined).toBe(true);
expect(file.notANumber).toBeNaN();
expect(file.big).toBe(12345678901234567890n);
expect([...tx.objectStore('FILE_DATA').indexNames]).toEqual(['timestamp']);
expect(await request(tx.objectStore('players').get(1))).toEqual({
id: 1,
name: 'eliza',
points: 10,
});
db.close();
});
it('exports nothing for a database that does not exist', async () => {
expect(await exportDatabase('nope')).toBeNull();
expect(await listDatabases()).toEqual([]);
});
});
+271
View File
@@ -0,0 +1,271 @@
/**
* Exports and imports whole IndexedDB databases as JSON, so games that save in
* IndexedDB (Unity WebGL's /idbfs, many HTML5 games) can have those saves copied.
* Values are structured-clone data, so non-JSON types are tagged.
*/
type Encoded =
| null
| boolean
| number
| string
| Encoded[]
| { [key: string]: Encoded }
| { $t: string; v: Encoded };
export interface SerializedIndex {
name: string;
keyPath: string | string[];
unique: boolean;
multiEntry: boolean;
}
export interface SerializedStore {
name: string;
keyPath: string | string[] | null;
autoIncrement: boolean;
indexes: SerializedIndex[];
records: { key: Encoded; value: Encoded }[];
}
export interface SerializedDatabase {
name: string;
version: number;
stores: SerializedStore[];
}
const TYPED_ARRAYS = {
Int8Array,
Uint8Array,
Uint8ClampedArray,
Int16Array,
Uint16Array,
Int32Array,
Uint32Array,
Float32Array,
Float64Array,
} as const;
function toBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
function fromBase64(text: string): Uint8Array<ArrayBuffer> {
const binary = atob(text);
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Type tag that also works for values from another realm, such as a game's
* iframe, where instanceof against this window's constructors fails.
*/
function typeOf(value: object): string {
return Object.prototype.toString.call(value).slice(8, -1);
}
async function encode(value: unknown): Promise<Encoded> {
if (value === null || typeof value === 'boolean' || typeof value === 'string') {
return value;
}
if (value === undefined) return { $t: 'undefined', v: null };
if (typeof value === 'number') {
return Number.isFinite(value) ? value : { $t: 'number', v: String(value) };
}
if (typeof value === 'bigint') return { $t: 'bigint', v: value.toString() };
if (typeof value !== 'object') {
throw new Error(`Cannot save a value of type ${typeof value}`);
}
const type = typeOf(value);
if (type === 'Date') return { $t: 'Date', v: (value as Date).getTime() };
if (type === 'ArrayBuffer') {
return { $t: 'ArrayBuffer', v: toBase64(new Uint8Array(value as ArrayBuffer)) };
}
if (ArrayBuffer.isView(value)) {
const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
return { $t: type, v: toBase64(bytes) };
}
if (type === 'Blob' || type === 'File') {
const blob = value as Blob;
const bytes = new Uint8Array(await blob.arrayBuffer());
return { $t: 'Blob', v: { type: blob.type, data: toBase64(bytes) } };
}
if (type === 'Map') {
const entries = [...(value as Map<unknown, unknown>)];
return {
$t: 'Map',
v: await Promise.all(entries.map(async ([k, v]) => [await encode(k), await encode(v)])),
};
}
if (type === 'Set') {
return { $t: 'Set', v: await Promise.all([...(value as Set<unknown>)].map(encode)) };
}
if (Array.isArray(value)) return Promise.all(value.map(encode));
const entries = await Promise.all(
Object.entries(value as Record<string, unknown>).map(
async ([k, v]) => [k, await encode(v)] as const
)
);
const object = Object.fromEntries(entries);
// An object that happens to have a $t key must not be read back as a tagged value
return '$t' in object ? { $t: 'Object', v: object } : object;
}
function decode(value: Encoded): unknown {
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(decode);
if (!('$t' in value)) {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, decode(v)]));
}
const { $t, v } = value as { $t: string; v: Encoded };
switch ($t) {
case 'undefined':
return undefined;
case 'number':
return Number(v);
case 'bigint':
return BigInt(v as string);
case 'Date':
return new Date(v as number);
case 'ArrayBuffer':
return fromBase64(v as string).buffer;
case 'Blob': {
const { type, data } = v as { type: string; data: string };
return new Blob([fromBase64(data)], { type });
}
case 'Map':
return new Map((v as Encoded[][]).map(([k, val]) => [decode(k), decode(val)]));
case 'Set':
return new Set((v as Encoded[]).map(decode));
case 'Object':
return Object.fromEntries(
Object.entries(v as Record<string, Encoded>).map(([k, x]) => [k, decode(x)])
);
case 'DataView': {
const bytes = fromBase64(v as string);
return new DataView(bytes.buffer);
}
default: {
const TypedArray = TYPED_ARRAYS[$t as keyof typeof TYPED_ARRAYS];
if (!TypedArray) throw new Error(`Unknown saved value type: ${$t}`);
return new TypedArray(fromBase64(v as string).buffer);
}
}
}
function promised<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** Names of the databases on this origin. */
export async function listDatabases(): Promise<string[]> {
const databases = await indexedDB.databases();
return databases.map((d) => d.name).filter((name): name is string => Boolean(name));
}
export async function exportDatabase(name: string): Promise<SerializedDatabase | null> {
const known = await listDatabases();
if (!known.includes(name)) return null;
const db = await promised(indexedDB.open(name));
try {
const storeNames = [...db.objectStoreNames];
if (storeNames.length === 0) {
return { name, version: db.version, stores: [] };
}
// Read everything inside one transaction, then encode outside it: encoding
// Blobs is asynchronous and would let the transaction close
const tx = db.transaction(storeNames, 'readonly');
const raw = await Promise.all(
storeNames.map(async (storeName) => {
const store = tx.objectStore(storeName);
const [keys, values] = await Promise.all([
promised(store.getAllKeys()),
promised(store.getAll()),
]);
return { store, keys, values };
})
);
const stores = await Promise.all(
raw.map(async ({ store, keys, values }) => ({
name: store.name,
keyPath: store.keyPath,
autoIncrement: store.autoIncrement,
indexes: [...store.indexNames].map((indexName) => {
const index = store.index(indexName);
return {
name: index.name,
keyPath: index.keyPath,
unique: index.unique,
multiEntry: index.multiEntry,
};
}),
records: await Promise.all(
keys.map(async (key, i) => ({ key: await encode(key), value: await encode(values[i]) }))
),
}))
);
return { name, version: db.version, stores };
} finally {
db.close();
}
}
/** Replaces a database with a serialized copy. */
export async function importDatabase(serialized: SerializedDatabase): Promise<void> {
await promised(indexedDB.deleteDatabase(serialized.name));
const request = indexedDB.open(serialized.name, serialized.version);
request.onupgradeneeded = () => {
const db = request.result;
for (const store of serialized.stores) {
const objectStore = db.createObjectStore(store.name, {
keyPath: store.keyPath ?? undefined,
autoIncrement: store.autoIncrement,
});
for (const index of store.indexes) {
objectStore.createIndex(index.name, index.keyPath, {
unique: index.unique,
multiEntry: index.multiEntry,
});
}
}
};
const db = await promised(request);
try {
if (serialized.stores.length === 0) return;
const tx = db.transaction(
serialized.stores.map((s) => s.name),
'readwrite'
);
for (const store of serialized.stores) {
const objectStore = tx.objectStore(store.name);
for (const record of store.records) {
if (store.keyPath === null) {
objectStore.put(decode(record.value), decode(record.key) as IDBValidKey);
} else {
objectStore.put(decode(record.value));
}
}
}
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
} finally {
db.close();
}
}
export async function deleteDatabase(name: string): Promise<void> {
await promised(indexedDB.deleteDatabase(name));
}
+37
View File
@@ -2355,6 +2355,7 @@
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.4.0",
"msw": "^2.12.7",
"postcss": "^8.4.38",
@@ -4817,6 +4818,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4833,6 +4835,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4849,6 +4852,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4865,6 +4869,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4881,6 +4886,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4897,6 +4903,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4913,6 +4920,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4929,6 +4937,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4945,6 +4954,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4961,6 +4971,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4977,6 +4988,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4993,6 +5005,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5009,6 +5022,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5025,6 +5039,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5041,6 +5056,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5057,6 +5073,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5073,6 +5090,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5089,6 +5107,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5105,6 +5124,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5121,6 +5141,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5137,6 +5158,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5153,6 +5175,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5169,6 +5192,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5185,6 +5209,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5201,6 +5226,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5217,6 +5243,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -11947,6 +11974,16 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fake-indexeddb": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",