Server-side save states via js-dos fsChanges hooks

This commit is contained in:
2026-08-31 17:15:24 +02:00
parent 8324ecd1e7
commit 54bc21123a
8 changed files with 420 additions and 10 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ A nostalgic DOS game hub. Upload your old DOS games, auto-scrape artwork and met
### 🎮 Play in the browser ### 🎮 Play in the browser
Every uploaded game is packaged into a `.jsdos` bundle — a standard ZIP with embedded DOSBox configuration. When you hit **Play**, js-dos v8 is loaded from CDN and starts the emulator instantly in your browser. No plugins, no native installs. Every uploaded game is packaged into a `.jsdos` bundle — a standard ZIP with embedded DOSBox configuration. When you hit **Play**, js-dos v8 is loaded from CDN and starts the emulator instantly in your browser. No plugins, no native installs.
**Save states are automatic**js-dos persists your game progress to the browser's storage. Come back anytime and pick up where you left off. **Saves are automatic**in-game saves (js-dos FS changes) are persisted to the server (`data/saves/`). Come back from any browser or device and pick up where you left off.
### 📦 Handles any file structure ### 📦 Handles any file structure
DOS games come in all shapes. DOStalgia handles them transparently: DOS games come in all shapes. DOStalgia handles them transparently:
@@ -131,4 +131,4 @@ java -jar target/quarkus-app/quarkus-run.jar
- **Frontend**: Svelte 5 SPA with hash-based routing - **Frontend**: Svelte 5 SPA with hash-based routing
- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly - **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly
- **Storage**: JSON-per-game under `/data/games/{id}/game.json` - **Storage**: JSON-per-game under `/data/games/{id}/game.json`
- **Saves**: Browser localStorage / indexedDB (managed by js-dos) - **Saves**: `data/saves/` on the server (js-dos FS-changes bundles, synced via `/api/games/{id}/saves`)
+119 -2
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { artworkUrl, bundleUrl, setupBundleUrl } from "../api.js"; import {
artworkUrl,
bundleUrl,
setupBundleUrl,
pullSaves,
pushSaves,
deleteSaves,
} from "../api.js";
describe("artworkUrl", () => { describe("artworkUrl", () => {
it("returns null for null input", () => { it("returns null for null input", () => {
@@ -66,3 +73,113 @@ describe("setupBundleUrl", () => {
expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle"); expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle");
}); });
}); });
describe("pullSaves", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns null when there is no save (404)", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ status: 404, ok: false }));
await expect(pullSaves("game-1")).resolves.toBeNull();
expect(fetch).toHaveBeenCalledWith("/api/games/game-1/saves");
});
it("returns the raw bytes on success", async () => {
const bytes = new Uint8Array([1, 2, 3]);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
status: 200,
ok: true,
arrayBuffer: () => Promise.resolve(bytes.buffer),
})
);
await expect(pullSaves("game-1")).resolves.toEqual(bytes);
});
it("throws on server error", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
status: 500,
ok: false,
json: () => Promise.resolve({ error: "boom" }),
})
);
await expect(pullSaves("game-1")).rejects.toThrow("boom");
});
});
describe("pushSaves", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("PUTs the raw bundle to the saves endpoint", async () => {
const fetchMock = vi.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({ status: "saved" }),
});
vi.stubGlobal("fetch", fetchMock);
const data = new Uint8Array([9, 8, 7]);
await expect(pushSaves("game-1", data)).resolves.toEqual({
status: "saved",
});
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/games/game-1/saves");
expect(opts.method).toBe("PUT");
expect(opts.headers["Content-Type"]).toBe("application/octet-stream");
expect(opts.body).toBe(data);
});
it("throws on server error", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
status: 400,
ok: false,
json: () => Promise.resolve({ error: "Empty save data" }),
})
);
await expect(pushSaves("game-1", new Uint8Array())).rejects.toThrow(
"Empty save data"
);
});
});
describe("deleteSaves", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("DELETEs the saves endpoint", async () => {
const fetchMock = vi.fn().mockResolvedValue({
status: 200,
ok: true,
json: () => Promise.resolve({ status: "deleted" }),
});
vi.stubGlobal("fetch", fetchMock);
await expect(deleteSaves("game-1")).resolves.toEqual({ status: "deleted" });
expect(fetchMock.mock.calls[0]).toEqual([
"/api/games/game-1/saves",
{ method: "DELETE" },
]);
});
it("throws on server error", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
status: 500,
ok: false,
json: () => Promise.resolve({ error: "boom" }),
})
);
await expect(deleteSaves("game-1")).rejects.toThrow("boom");
});
});
+35
View File
@@ -90,6 +90,41 @@ export function downloadGame(id) {
window.open(`${BASE}/api/games/${id}/download`, '_blank'); window.open(`${BASE}/api/games/${id}/download`, '_blank');
} }
/** Pull saved js-dos FS changes (in-game saves) — Uint8Array or null if none */
export async function pullSaves(gameId) {
const res = await fetch(`${BASE}/api/games/${gameId}/saves`);
if (res.status === 404) return null;
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return new Uint8Array(await res.arrayBuffer());
}
/** Push js-dos FS changes (in-game saves) to the server */
export async function pushSaves(gameId, data) {
const res = await fetch(`${BASE}/api/games/${gameId}/saves`, {
method: "PUT",
headers: { "Content-Type": "application/octet-stream" },
body: data,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
/** Delete server-side saves for a game */
export async function deleteSaves(gameId) {
const res = await fetch(`${BASE}/api/games/${gameId}/saves`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
/** Check IGDB status */ /** Check IGDB status */
export async function igdbStatus() { export async function igdbStatus() {
const res = await fetch(`${BASE}/api/igdb/status`); const res = await fetch(`${BASE}/api/igdb/status`);
+33 -5
View File
@@ -1,5 +1,5 @@
<script> <script>
import { getGame, bundleUrl, setupBundleUrl } from "../lib/api.js"; import { getGame, bundleUrl, setupBundleUrl, pullSaves, pushSaves, deleteSaves } from "../lib/api.js";
import { push } from "../lib/router.js"; import { push } from "../lib/router.js";
let { id } = $props(); let { id } = $props();
@@ -70,7 +70,26 @@
try { try {
const url = isSetup ? setupBundleUrl(game) : bundleUrl(game); const url = isSetup ? setupBundleUrl(game) : bundleUrl(game);
if (!url || !window.Dos) throw new Error("Failed to resolve game bundle"); if (!url || !window.Dos) throw new Error("Failed to resolve game bundle");
dosCI = await window.Dos(dosContainer, { url }); dosCI = window.Dos(dosContainer, {
url,
autoSave: true,
// Server-backed FS persistence: in-game saves survive sessions and
// follow the player across browsers/devices. Both normal and setup
// mode share one save set so SETUP.EXE changes carry over.
fsChanges: {
urlToKey: async () => String(game.id),
pull: async (key) => {
try {
return await pullSaves(key);
} catch (e) {
console.warn("Failed to load saves:", e);
return null;
}
},
push: (key, data) => pushSaves(key, data),
delete: (key) => deleteSaves(key),
},
});
running = true; running = true;
} catch (e) { } catch (e) {
error = e.message; error = e.message;
@@ -78,7 +97,12 @@
booting = false; booting = false;
} }
function stopEmulator() { async function stopEmulator() {
// Persist FS changes before teardown — the reload below kills the
// emulator worker, so the save request must fire first (best effort).
if (dosCI?.save) {
try { await dosCI.save(); } catch (_) {}
}
// Force full page reload which terminates all workers, AudioContexts // Force full page reload which terminates all workers, AudioContexts
// and WASM threads. Hash is preserved so the SPA routes correctly. // and WASM threads. Hash is preserved so the SPA routes correctly.
window.location.hash = `#/game/${game.id}`; window.location.hash = `#/game/${game.id}`;
@@ -107,8 +131,12 @@
$effect(() => { $effect(() => {
return () => { return () => {
if (dosCI) { if (dosCI) {
try { dosCI.exit(); } catch (_) {} // Persist FS changes, then kill the emulation (DosProps.stop()).
const props = dosCI;
dosCI = null; dosCI = null;
Promise.resolve(props.save?.()).catch(() => {}).finally(() => {
try { props.stop?.(); } catch (_) {}
});
} }
if (dosContainer) { if (dosContainer) {
dosContainer.innerHTML = ""; dosContainer.innerHTML = "";
@@ -171,7 +199,7 @@
<p class="save-hint">⚙️ Configure controls, then exit SETUP to save</p> <p class="save-hint">⚙️ Configure controls, then exit SETUP to save</p>
{:else} {:else}
<p>Starting emulator...</p> <p>Starting emulator...</p>
<p class="save-hint">💾 Game saves are stored automatically</p> <p class="save-hint">💾 Game saves are stored on the server</p>
{/if} {/if}
{:else if error} {:else if error}
<div class="overlay-icon">⚠️</div> <div class="overlay-icon">⚠️</div>
+48
View File
@@ -7,6 +7,9 @@ vi.mock("../../lib/api.js", () => ({
getGame: vi.fn(), getGame: vi.fn(),
bundleUrl: vi.fn(), bundleUrl: vi.fn(),
setupBundleUrl: vi.fn(), setupBundleUrl: vi.fn(),
pullSaves: vi.fn(),
pushSaves: vi.fn(),
deleteSaves: vi.fn(),
})); }));
// Mock router // Mock router
@@ -74,4 +77,49 @@ describe("Play", () => {
expect(screen.getByText("Starting emulator...")).toBeTruthy(); expect(screen.getByText("Starting emulator...")).toBeTruthy();
}); });
}); });
it("starts js-dos with server-backed save hooks", async () => {
api.getGame.mockResolvedValue(makeGame());
api.bundleUrl.mockReturnValue("/games/game-1.jsdos");
const dosMock = vi.fn().mockReturnValue({
save: vi.fn().mockResolvedValue(true),
stop: vi.fn().mockResolvedValue(undefined),
});
window.Dos = dosMock;
try {
render(Play, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(dosMock).toHaveBeenCalled();
});
const opts = dosMock.mock.calls[0][1];
expect(opts.url).toBe("/games/game-1.jsdos");
expect(opts.autoSave).toBe(true);
// Both normal and setup bundles map to the same per-game save key
await expect(opts.fsChanges.urlToKey("/games/game-1.jsdos")).resolves.toBe("game-1");
// pull() forwards to the API and returns null when there is no save
api.pullSaves.mockResolvedValue(null);
await expect(opts.fsChanges.pull("game-1")).resolves.toBeNull();
expect(api.pullSaves).toHaveBeenCalledWith("game-1");
// push()/delete() forward to the API
api.pushSaves.mockResolvedValue({ status: "saved" });
await opts.fsChanges.push("game-1", new Uint8Array([1]));
expect(api.pushSaves).toHaveBeenCalledWith("game-1", expect.any(Uint8Array));
api.deleteSaves.mockResolvedValue({ status: "deleted" });
await opts.fsChanges.delete("game-1");
expect(api.deleteSaves).toHaveBeenCalledWith("game-1");
// pull() survives API errors so the game still boots
api.pullSaves.mockRejectedValue(new Error("offline"));
await expect(opts.fsChanges.pull("game-1")).resolves.toBeNull();
} finally {
delete window.Dos;
}
});
}); });
@@ -10,6 +10,7 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam; import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput; import jakarta.ws.rs.core.StreamingOutput;
@@ -160,6 +161,68 @@ public class GameController {
} }
} }
/**
* Stream the saved js-dos FS-changes bundle (in-game saves) for a game.
* 404 when the game or the save doesn't exist.
*/
@GET
@Path("/{id}/saves")
@Produces(MediaType.APPLICATION_OCTET_STREAM)
public Response getSave(@PathParam("id") String id) {
try {
svc.load(id);
byte[] data = svc.loadSave(id);
if (data == null) {
return Response.status(404).entity(Map.of("error", "No save found")).build();
}
return Response.ok(data).build();
} catch (NoSuchFileException e) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
} catch (IllegalArgumentException e) {
return Response.status(400).entity(Map.of("error", e.getMessage())).build();
} catch (Exception e) {
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
/** Persist the js-dos FS-changes bundle (in-game saves) for a game. Body = raw bundle bytes. */
@PUT
@Path("/{id}/saves")
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
public Response putSave(@PathParam("id") String id, byte[] data) {
try {
svc.load(id);
if (data == null || data.length == 0) {
return Response.status(400).entity(Map.of("error", "Empty save data")).build();
}
svc.saveChanges(id, data);
return Response.ok(Map.of("status", "saved")).build();
} catch (NoSuchFileException e) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
} catch (IllegalArgumentException e) {
return Response.status(400).entity(Map.of("error", e.getMessage())).build();
} catch (Exception e) {
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
/** Delete the saved js-dos FS-changes for a game (fresh start on next launch). */
@DELETE
@Path("/{id}/saves")
public Response deleteSave(@PathParam("id") String id) {
try {
svc.load(id);
svc.deleteSave(id);
return Response.ok(Map.of("status", "deleted")).build();
} catch (NoSuchFileException e) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
} catch (IllegalArgumentException e) {
return Response.status(400).entity(Map.of("error", e.getMessage())).build();
} catch (Exception e) {
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
@DELETE @DELETE
@Path("/{id}") @Path("/{id}")
public Response delete(@PathParam("id") String id) { public Response delete(@PathParam("id") String id) {
+40 -1
View File
@@ -42,13 +42,16 @@ public class GameService implements GameStore {
Path gamesDir; Path gamesDir;
Path savesDir;
@PostConstruct @PostConstruct
void init() { void init() {
gamesDir = Path.of(dataDir, "games"); gamesDir = Path.of(dataDir, "games");
savesDir = Path.of(dataDir, "saves");
try { try {
Files.createDirectories(gamesDir); Files.createDirectories(gamesDir);
Files.createDirectories(Path.of(dataDir, "artwork")); Files.createDirectories(Path.of(dataDir, "artwork"));
Files.createDirectories(Path.of(dataDir, "saves")); Files.createDirectories(savesDir);
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException("Cannot create data directories", e); throw new RuntimeException("Cannot create data directories", e);
} }
@@ -122,11 +125,47 @@ public class GameService implements GameStore {
/** Delete a game and all its files. */ /** Delete a game and all its files. */
public void delete(String id) throws IOException { public void delete(String id) throws IOException {
FileUtils.deleteDirectory(gameDir(id)); FileUtils.deleteDirectory(gameDir(id));
deleteSave(id);
// Clean up old flat .jsdos locations (pre-game-dir layout — backward compat) // Clean up old flat .jsdos locations (pre-game-dir layout — backward compat)
Files.deleteIfExists(gamesDir.resolve(id + ".jsdos")); Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
Files.deleteIfExists(gamesDir.resolve(id + ".setup.jsdos")); Files.deleteIfExists(gamesDir.resolve(id + ".setup.jsdos"));
} }
/** Path of the js-dos FS-changes save file for a game (in-game saves). */
public Path savePath(String id) {
assertSafeId(id);
return savesDir.resolve(id + ".fschanges.jsdos");
}
/** Load the saved js-dos FS changes for a game, or null if none exist. */
public byte[] loadSave(String id) throws IOException {
Path path = savePath(id);
if (!Files.exists(path)) {
return null;
}
return Files.readAllBytes(path);
}
/** Persist js-dos FS changes for a game (atomic write). */
public void saveChanges(String id, byte[] data) throws IOException {
Path path = savePath(id);
Path tmp = path.resolveSibling(path.getFileName() + ".tmp");
Files.write(tmp, data);
Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
/** Delete saved js-dos FS changes for a game. Returns true if a save existed. */
public boolean deleteSave(String id) throws IOException {
return Files.deleteIfExists(savePath(id));
}
/** Reject ids that could escape the saves directory. */
private static void assertSafeId(String id) {
if (id == null || id.contains("/") || id.contains("\\") || id.contains("..")) {
throw new IllegalArgumentException("Invalid game id: " + id);
}
}
/** Sanitize a string for use as a game directory ID. */ /** Sanitize a string for use as a game directory ID. */
public static String sanitizeId(String s) { public static String sanitizeId(String s) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
@@ -241,6 +241,86 @@ class GameServiceTest {
assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos"))); assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos")));
} }
// -- js-dos FS-changes saves tests --
@Test
void saveChanges_andLoadSave_roundTrip(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
svc.save(new Game("game1", "Game One"));
byte[] data = "PK\u0003\u0004fake-bundle".getBytes();
svc.saveChanges("game1", data);
assertArrayEquals(data, svc.loadSave("game1"));
assertEquals(
tempDir.resolve("saves/game1.fschanges.jsdos"),
svc.savePath("game1"));
}
@Test
void loadSave_noSave_returnsNull(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
assertNull(svc.loadSave("game1"));
}
@Test
void saveChanges_overwritesPreviousWithoutLeavingTmp(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
svc.saveChanges("game1", "first".getBytes());
svc.saveChanges("game1", "second".getBytes());
assertArrayEquals("second".getBytes(), svc.loadSave("game1"));
try (var stream = Files.list(tempDir.resolve("saves"))) {
assertEquals(1, stream.count());
}
}
@Test
void deleteSave_removesFileAndReportsExistence(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
svc.saveChanges("game1", "data".getBytes());
assertTrue(svc.deleteSave("game1"));
assertNull(svc.loadSave("game1"));
assertFalse(svc.deleteSave("game1"));
}
@Test
void savePath_rejectsPathTraversal(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
assertThrows(IllegalArgumentException.class, () -> svc.savePath("../evil"));
assertThrows(IllegalArgumentException.class, () -> svc.savePath("a/b"));
assertThrows(IllegalArgumentException.class, () -> svc.savePath("a\\b"));
assertThrows(IllegalArgumentException.class, () -> svc.savePath(null));
}
@Test
void delete_removesSaveFile(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
svc.save(new Game("game1", "Game One"));
svc.saveChanges("game1", "data".getBytes());
svc.delete("game1");
assertFalse(Files.exists(tempDir.resolve("saves/game1.fschanges.jsdos")));
}
// -- JSON serialization tests -- // -- JSON serialization tests --
@Test @Test