Merge pull request 'Keep players' game saves on their account' (#5) from account-saves into main

Reviewed-on: branderson/flashpoint-web#5
Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
2026-10-12 16:37:13 -07:00
20 changed files with 1728 additions and 100 deletions
+21
View File
@@ -0,0 +1,21 @@
-- ===================================
-- Migration 008: Game saves
-- ===================================
-- Copies of the saves games keep in a player's browser, so a fresh browser can
-- restore them. Each row is one snapshot; the newest few per user and game are
-- kept as backups.
CREATE TABLE IF NOT EXISTS game_save_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
game_id TEXT NOT NULL,
content_hash TEXT NOT NULL,
data TEXT NOT NULL,
size INTEGER NOT NULL,
saved_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_game_save_snapshots_user_game
ON game_save_snapshots(user_id, game_id, id DESC);
+121
View File
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import express, { Express } from 'express';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import fs from 'fs';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
// UserDatabaseService creates the directory
const tempDir = vi.hoisted(
() => `${process.env.TMPDIR ?? '/tmp'}/game-saves-${process.pid}-${Date.now()}`
);
vi.mock('../config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config')>();
return { config: { ...actual.config, userDbPath: `${tempDir}/user.db` } };
});
import gameSavesRouter from './game-saves';
import { errorHandler } from '../middleware/errorHandler';
import { UserDatabaseService } from '../services/UserDatabaseService';
import { AuthService } from '../services/AuthService';
const GAME_ID = '247a4c93-c252-49c8-8bc9-fb43d5c039df';
describe('game saves API', () => {
let app: Express;
let alice: string;
let bob: string;
beforeAll(async () => {
await UserDatabaseService.initialize();
const authService = new AuthService();
alice = (await authService.trustedLogin('alice', null)).tokens.accessToken;
bob = (await authService.trustedLogin('bobby', null)).tokens.accessToken;
app = express();
app.use(cookieParser());
app.use('/api/game-saves', gameSavesRouter);
app.use(errorHandler);
});
afterAll(() => {
UserDatabaseService.close();
fs.rmSync(tempDir, { recursive: true, force: true });
});
const save = (token: string, hash: string, data: object = { localStorage: { k: hash } }) =>
request(app)
.post(`/api/game-saves/${GAME_ID}`)
.set('Authorization', `Bearer ${token}`)
.send({ data, hash, savedAt: '2026-10-12T10:00:00.000Z' });
const fetchSaves = (token: string) =>
request(app).get(`/api/game-saves/${GAME_ID}`).set('Authorization', `Bearer ${token}`);
it('starts empty and requires a signed-in user', async () => {
expect((await request(app).get(`/api/game-saves/${GAME_ID}`)).status).toBe(401);
expect((await fetchSaves(alice)).body).toEqual({ latest: null, snapshots: [] });
});
it('stores saves, skipping one identical to the current save', async () => {
const first = await save(alice, 'h1');
expect(first.status).toBe(201);
expect((await save(alice, 'h1')).status).toBe(200);
expect((await save(alice, 'h2')).status).toBe(201);
const { body } = await fetchSaves(alice);
expect(body.latest).toMatchObject({ hash: 'h2', data: { localStorage: { k: 'h2' } } });
expect(body.snapshots.map((s: { hash: string }) => s.hash)).toEqual(['h2', 'h1']);
const older = await request(app)
.get(`/api/game-saves/${GAME_ID}/${first.body.id}`)
.set('Authorization', `Bearer ${alice}`);
expect(older.body).toMatchObject({ hash: 'h1', data: { localStorage: { k: 'h1' } } });
});
it('keeps only the newest 10 saves', async () => {
for (let i = 3; i <= 14; i++) {
await save(alice, `h${i}`);
}
const { body } = await fetchSaves(alice);
expect(body.snapshots).toHaveLength(10);
expect(body.snapshots[0].hash).toBe('h14');
expect(body.snapshots[9].hash).toBe('h5');
});
it("keeps each user's saves separate", async () => {
expect((await fetchSaves(bob)).body.latest).toBeNull();
const aliceSnapshot = (await fetchSaves(alice)).body.latest.id;
const res = await request(app)
.get(`/api/game-saves/${GAME_ID}/${aliceSnapshot}`)
.set('Authorization', `Bearer ${bob}`);
expect(res.status).toBe(404);
});
it('accepts saves over the app-wide 1 MB body limit, and clamps future timestamps', async () => {
const big = { indexedDB: 'x'.repeat(2 * 1024 * 1024) };
const res = await request(app)
.post(`/api/game-saves/${GAME_ID}`)
.set('Authorization', `Bearer ${bob}`)
.send({ data: big, hash: 'big', savedAt: '2999-01-01T00:00:00.000Z' });
expect(res.status).toBe(201);
expect(res.body.size).toBeGreaterThan(2 * 1024 * 1024);
expect(Date.parse(res.body.savedAt)).toBeLessThanOrEqual(Date.now());
});
it('rejects invalid input', async () => {
const bad = await request(app)
.post('/api/game-saves/not-a-game')
.set('Authorization', `Bearer ${alice}`)
.send({ data: {}, hash: 'x', savedAt: '2026-10-12T10:00:00.000Z' });
expect(bad.status).toBe(400);
const noHash = await request(app)
.post(`/api/game-saves/${GAME_ID}`)
.set('Authorization', `Bearer ${alice}`)
.send({ data: {}, savedAt: '2026-10-12T10:00:00.000Z' });
expect(noHash.status).toBe(400);
});
});
+74
View File
@@ -0,0 +1,74 @@
import express, { Router } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth';
import { requirePermission } from '../middleware/rbac';
import { AppError } from '../middleware/errorHandler';
import { asyncHandler } from '../middleware/asyncHandler';
import { GameSaveService } from '../services/GameSaveService';
const router = Router();
const gameSaveService = new GameSaveService();
/** Saves can outgrow the app's 1 MB JSON limit; server.ts leaves parsing to this router. */
export const GAME_SAVE_BODY_LIMIT = '10mb';
const gameIdSchema = z.string().uuid('Invalid game ID format');
const saveSchema = z.object({
data: z.record(z.unknown()),
hash: z.string().min(1).max(64),
savedAt: z.string().datetime(),
});
router.use(express.json({ limit: GAME_SAVE_BODY_LIMIT }));
router.use(authenticate, requirePermission('games.play'));
function userId(req: express.Request): number {
if (!req.user) {
throw new AppError(401, 'Authentication required');
}
return req.user.id;
}
// The current save with its data, and every kept snapshot's metadata
router.get(
'/:gameId',
asyncHandler(async (req, res) => {
const gameId = gameIdSchema.parse(req.params.gameId);
const id = userId(req);
res.json({
latest: gameSaveService.get(id, gameId),
snapshots: gameSaveService.list(id, gameId),
});
})
);
router.get(
'/:gameId/:snapshotId',
asyncHandler(async (req, res) => {
const gameId = gameIdSchema.parse(req.params.gameId);
const snapshotId = parseInt(req.params.snapshotId, 10);
if (isNaN(snapshotId)) {
throw new AppError(400, 'Invalid snapshot ID');
}
const snapshot = gameSaveService.get(userId(req), gameId, snapshotId);
if (!snapshot) {
throw new AppError(404, 'Save not found');
}
res.json(snapshot);
})
);
router.post(
'/:gameId',
asyncHandler(async (req, res) => {
const gameId = gameIdSchema.parse(req.params.gameId);
const body = saveSchema.parse(req.body);
// A browser's clock can run ahead; a save can't be from the future
const savedAt = new Date(Math.min(Date.parse(body.savedAt), Date.now())).toISOString();
const result = gameSaveService.save(userId(req), gameId, body.data, body.hash, savedAt);
res.status(result.created ? 201 : 200).json(result.snapshot);
})
);
export default router;
+2
View File
@@ -22,6 +22,7 @@ import userPlaylistsRouter from './user-playlists';
import sharedPlaylistsRouter from './shared-playlists';
import favoritesRouter from './favorites';
import ratingsRouter from './ratings';
import gameSavesRouter from './game-saves';
import ruffleRouter from './ruffle';
import githubRouter from './github';
import domainsRouter from './domains';
@@ -49,6 +50,7 @@ export function setupRoutes(app: Express): void {
app.use('/api/playlists/shared', sharedPlaylistsRouter);
app.use('/api/favorites', favoritesRouter);
app.use('/api/ratings', ratingsRouter);
app.use('/api/game-saves', gameSavesRouter);
app.use('/api/games', gamesRouter);
app.use('/api/games', downloadsRouter);
app.use('/api/platforms', platformsRouter);
+5 -1
View File
@@ -209,7 +209,11 @@ async function startServer() {
})
);
app.use(express.json({ limit: '1mb' }));
// Game saves parse their own, larger bodies (routes/game-saves.ts)
const jsonParser = express.json({ limit: '1mb' });
app.use((req, res, next) =>
req.path.startsWith('/api/game-saves') ? next() : jsonParser(req, res, next)
);
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(cookieParser());
+102
View File
@@ -0,0 +1,102 @@
import { UserDatabaseService } from './UserDatabaseService';
/** Snapshots kept per user and game, the newest being the current save. */
export const SNAPSHOTS_KEPT = 10;
export interface GameSaveSnapshotMeta {
id: number;
hash: string;
size: number;
savedAt: string;
createdAt: string;
}
export interface GameSaveSnapshot extends GameSaveSnapshotMeta {
data: unknown;
}
interface SnapshotRow {
id: number;
content_hash: string;
size: number;
saved_at: string;
created_at: string;
data?: string;
}
function toMeta(row: SnapshotRow): GameSaveSnapshotMeta {
return {
id: row.id,
hash: row.content_hash,
size: row.size,
savedAt: row.saved_at,
createdAt: row.created_at,
};
}
/** Stores copies of the saves games keep in a player's browser. */
export class GameSaveService {
/** All snapshots for a game, newest first. */
list(userId: number, gameId: string): GameSaveSnapshotMeta[] {
return UserDatabaseService.all<SnapshotRow>(
`SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots
WHERE user_id = ? AND game_id = ? ORDER BY id DESC`,
[userId, gameId]
).map(toMeta);
}
get(userId: number, gameId: string, id?: number): GameSaveSnapshot | null {
const row = UserDatabaseService.get<SnapshotRow>(
`SELECT id, content_hash, size, saved_at, created_at, data FROM game_save_snapshots
WHERE user_id = ? AND game_id = ? ${id === undefined ? '' : 'AND id = ?'}
ORDER BY id DESC LIMIT 1`,
id === undefined ? [userId, gameId] : [userId, gameId, id]
);
return row ? { ...toMeta(row), data: JSON.parse(row.data ?? 'null') } : null;
}
/**
* Stores a snapshot as the game's current save, unless it matches the current
* one, then drops snapshots beyond the newest SNAPSHOTS_KEPT.
*/
save(
userId: number,
gameId: string,
data: unknown,
hash: string,
savedAt: string
): { snapshot: GameSaveSnapshotMeta; created: boolean } {
const json = JSON.stringify(data);
const db = UserDatabaseService.getDatabase();
return db.transaction(() => {
const latest = db
.prepare(
`SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots
WHERE user_id = ? AND game_id = ? ORDER BY id DESC LIMIT 1`
)
.get(userId, gameId) as SnapshotRow | undefined;
if (latest?.content_hash === hash) {
return { snapshot: toMeta(latest), created: false };
}
const result = db
.prepare(
`INSERT INTO game_save_snapshots (user_id, game_id, content_hash, data, size, saved_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(userId, gameId, hash, json, Buffer.byteLength(json), savedAt);
db.prepare(
`DELETE FROM game_save_snapshots WHERE user_id = ? AND game_id = ? AND id NOT IN (
SELECT id FROM game_save_snapshots WHERE user_id = ? AND game_id = ?
ORDER BY id DESC LIMIT ?)`
).run(userId, gameId, userId, gameId, SNAPSHOTS_KEPT);
const row = db
.prepare(
`SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots WHERE id = ?`
)
.get(result.lastInsertRowid) as SnapshotRow;
return { snapshot: toMeta(row), created: true };
})();
}
}
+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",
+45 -2
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,6 +312,13 @@ function GamePlayerInternal({
}
}
>
{!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} />
@@ -385,7 +418,17 @@ function GamePlayerInternal({
</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';
+107
View File
@@ -0,0 +1,107 @@
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', '{}');
// Selkies' settings, from a streamed game's iframe on the same origin
const selkiesPrefix = `${location.origin}/stream/`.replace(/[^a-zA-Z0-9._-]/g, '_');
localStorage.setItem(
`${selkiesPrefix}0123456789abcdef0123456789abcdef_scaleLocallyManual`,
'true'
);
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('leaves out Selkies settings a save picked up before they were excluded', async () => {
const selkiesKey =
`${location.origin}/stream/`.replace(/[^a-zA-Z0-9._-]/g, '_') + 'abc_scaleLocallyManual';
localStorage.setItem(selkiesKey, 'true');
localStorage.setItem('game-key', 'save');
const save = await collectSave({
gameId: GAME,
keys: [selkiesKey, 'game-key'],
databases: [],
syncedHash: null,
savedAt: null,
});
expect(save.localStorage).toEqual({ 'game-key': 'save' });
});
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({});
});
});
+235
View File
@@ -0,0 +1,235 @@
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']);
/**
* Selkies, the client in a streamed game's iframe, shares this origin and keys
* its settings by its page address (/stream/<session>/), sanitized like this.
*/
function streamClientPrefix(): string {
return `${window.location.origin}/stream/`.replace(/[^a-zA-Z0-9._-]/g, '_');
}
function isAppKey(key: string): boolean {
return (
key.startsWith('flashpoint-') ||
key === 'activityDashboardCollapsed' ||
key.startsWith(streamClientPrefix())
);
}
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
.filter((key) => !isAppKey(key))
.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",