implemented save file compression in browser
This commit is contained in:
@@ -18,7 +18,7 @@ Source files live in `src/`:
|
||||
- `app/` for routes,
|
||||
- `components/` for shared UI
|
||||
- `services/` for backend calls
|
||||
- `lib/` for helpers
|
||||
- `lib/` for helpers, with save compression in `lib/saveArchive/`
|
||||
- `config/` for app config
|
||||
- `types/` for .d.ts type declaration files
|
||||
|
||||
@@ -38,8 +38,8 @@ SaveParserBackendService -> /api/proxy/saveParserBackend/* -> backend
|
||||
|
||||
- [src/services/SaveParserBackendService.ts](src/services/SaveParserBackendService.ts)
|
||||
is the only place that talks to the backend. It exposes `uploadSave`,
|
||||
`getSaveStatus` and `getParsedSave` — upload, poll until parsing finishes,
|
||||
then fetch the result.
|
||||
`getSaveStatus`, `getParsedData` and `getSaveFile` — upload, poll until
|
||||
parsing finishes, then fetch the result or the stored save back.
|
||||
- [src/app/api/proxy/saveParserBackend/[[...path]]/route.ts](src/app/api/proxy/saveParserBackend/[[...path]]/route.ts)
|
||||
forwards every method to the configured backend URL. It exists only to keep
|
||||
requests same-origin, and is meant to be deleted once the backend handles
|
||||
@@ -47,25 +47,116 @@ SaveParserBackendService -> /api/proxy/saveParserBackend/* -> backend
|
||||
- Route paths live in [src/app/api/proxy/paths.ts](src/app/api/proxy/paths.ts)
|
||||
rather than as string literals, so both sides of the proxy stay in sync.
|
||||
|
||||
### Save files on the wire
|
||||
|
||||
Saves are **not** sent as the game wrote them. The backend runs on a cheap two
|
||||
core server with little disk space, so the browser re-compresses every save
|
||||
before uploading it:
|
||||
|
||||
```
|
||||
plain text save -> tar { "gamestate" } -> zstd -> upload
|
||||
archived save -> tar { "meta", "gamestate", ... } -> zstd -> upload
|
||||
```
|
||||
|
||||
Uploads are `POST /uploadSave?game=…&originalFileType=text|zip&originalFileName=…`
|
||||
with `Content-Type: application/zstd`. The browser cannot stream a request
|
||||
body outside Chrome, so the compressed result is sent as one blob; responses
|
||||
stream everywhere, so downloads need nothing special. The tar always holds the
|
||||
files the game itself would have in its archive, so the backend reads the game
|
||||
state from the `gamestate` entry either way, and `getSaveStatus` returns `originalFileType`
|
||||
and `originalFileName` so a download can rebuild the right shape.
|
||||
|
||||
The pieces all live in [src/lib/saveArchive/](src/lib/saveArchive/):
|
||||
[gameZip.ts](src/lib/saveArchive/gameZip.ts) (the game's own zip format,
|
||||
written here on the browser's `CompressionStream('deflate-raw')`),
|
||||
[zstdStream.ts](src/lib/saveArchive/zstdStream.ts) (compression, on
|
||||
[@tootallnate/zstd-wasm](https://github.com/TooTallNate/switch-tools/tree/main/packages/zstd-wasm)),
|
||||
[archive.ts](src/lib/saveArchive/archive.ts) (pack and rebuild, with the tar
|
||||
from [modern-tar](https://github.com/ayuhito/modern-tar)),
|
||||
[messages.ts](src/lib/saveArchive/messages.ts) (the worker protocol), and
|
||||
[worker.ts](src/lib/saveArchive/worker.ts) with
|
||||
[client.ts](src/lib/saveArchive/client.ts) (the worker and its main thread
|
||||
side).
|
||||
|
||||
**The whole pipeline is streams.** A save is never held in memory as a whole:
|
||||
the file is read, unzipped, tarred and compressed as it flows, and only the
|
||||
finished result becomes a `Blob`, which is browser managed storage rather than
|
||||
a heap allocation. A real 11 MB EU4 save (84 MB of `gamestate`) compresses to
|
||||
about 4.15 MB in two seconds. Keep it that way — an `arrayBuffer()` call on a
|
||||
save is a bug.
|
||||
|
||||
Three things there are easy to break:
|
||||
|
||||
- **The zip headers are not decoration.** The games reject archives with NTFS
|
||||
timestamps or unexpected header fields, so every field matches a real save:
|
||||
deflate, zeroed DOS date, MS-DOS origin, version made by 63, version needed
|
||||
20, no extra field, no data descriptor. That last pair is why no zip library
|
||||
is used: they all build the DOS date from a `Date` and clamp it to 1980, and
|
||||
the streaming writers add a data descriptor and a zip64 extra field to every
|
||||
entry. Writing the headers here is about a hundred lines, because deflate
|
||||
itself comes from the browser. One consequence to keep: each entry is
|
||||
deflated into a blob before its header is written, since the sizes and the
|
||||
CRC go in front of the data — the _compressed_ bytes are buffered, never the
|
||||
save.
|
||||
- **The zstd wasm is a runtime asset, not part of the bundle.**
|
||||
`@tootallnate/zstd-wasm` deliberately loads no files of its own, so
|
||||
`zstdStream.ts` points `new URL(…, import.meta.url)` at
|
||||
`node_modules/@tootallnate/zstd-wasm/dist/zstd.wasm` and Turbopack emits it
|
||||
as a hashed asset the worker fetches. That path is relative to
|
||||
`src/lib/saveArchive/zstdStream.ts`, so moving the file means fixing it.
|
||||
- **Three zstd libraries were tried and rejected before this one**, so do not
|
||||
swap it lightly. `@oneidentity/zstd-js` compresses whole buffers and leaves a
|
||||
frame unterminated whenever the payload length is an exact multiple of
|
||||
131072, `zstd-codec`'s emscripten build never returns control to the event
|
||||
loop, and `fzstd` is the unreadable single-file kind of code this repository
|
||||
does not depend on.
|
||||
- **Compression runs in a Web Worker** because it would otherwise block the
|
||||
page for seconds on a large save.
|
||||
|
||||
[COMPRESSION.md](COMPRESSION.md) has every zstd level measured on a real save —
|
||||
size, time and the memory the encoder and decoder need. Consult it instead of
|
||||
measuring again when a device cannot cope with the current level.
|
||||
|
||||
Configuration comes from [src/config/AppConfig.ts](src/config/AppConfig.ts)
|
||||
(defaults) merged with `runtimeConfig.json` at the repo root, which holds the
|
||||
backend URL. That file is gitignored, so a fresh clone starts without one and
|
||||
falls back to the defaults.
|
||||
backend URL and the save upload limits. That file is gitignored, so a fresh
|
||||
clone starts without one and falls back to the defaults.
|
||||
|
||||
`AppConfig` is server side only, so values the browser needs are passed down as
|
||||
props: [src/app/page.tsx](src/app/page.tsx) reads `appConfig.saveUpload` and
|
||||
hands it to the upload dialog.
|
||||
|
||||
### Current status
|
||||
|
||||
Much of the app is still scaffold. Do not assume a feature works because the UI
|
||||
for it exists:
|
||||
|
||||
- **The grid is not wired up.** [src/app/browse/page.tsx](src/app/browse/page.tsx)
|
||||
renders two hardcoded rows and never calls the backend. Nothing connects the
|
||||
upload flow to it yet.
|
||||
- **The grid is not wired up.** [src/components/BrowseSaveDataGrid.tsx](src/components/BrowseSaveDataGrid.tsx)
|
||||
renders two hardcoded rows and never calls the backend. `/browse?id=…` only
|
||||
uses the id for the download button so far.
|
||||
- **The backend does not speak the new upload format yet.** It has to accept the
|
||||
zstd compressed tar described above and serve `getSaveFile`; until it does,
|
||||
uploads fail against the running service.
|
||||
- **Auth does not work.** `AuthService` posts to `/api/auth/login` and
|
||||
`/api/auth/register`, which do not exist as routes. The service is slated for
|
||||
removal in favour of Discord OAuth.
|
||||
- **`hashPassword` is a no-op** that returns the password unchanged. Treat any
|
||||
current auth code as a placeholder, not as something to build on.
|
||||
|
||||
## Tools and files
|
||||
|
||||
**Prefer the editor's own tools over shell commands that do the same thing.**
|
||||
Reading, editing, writing and searching files have dedicated tools; reaching
|
||||
for `cat`, `sed`, `grep` or a throwaway script instead does the same work while
|
||||
spending more tokens, and every command may need its own approval. Use the shell
|
||||
for what only the shell can do — running the build, the linter, git, a real
|
||||
program.
|
||||
|
||||
**Work inside the project directory.** For temporary files — scratch scripts,
|
||||
intermediate output, test data — create a `TEMP/` directory in the project root
|
||||
and keep everything there, then delete that directory once the work is done.
|
||||
Ask before reading or writing anything outside the project directory.
|
||||
|
||||
## Terminal
|
||||
|
||||
**On Windows, prefer `bash.exe`** — but only a real MSYS2, Cygwin, or
|
||||
@@ -134,11 +225,6 @@ editing, or match the surrounding style exactly.
|
||||
Import from within `src/` using the `@/` alias, for example
|
||||
`import { getMsgFromError } from '@/lib/errors'`.
|
||||
|
||||
Write doc comments as JSDoc (`/** ... */`), which editors show on hover.
|
||||
Plain `//` and `///` comments do not appear in tooltips.
|
||||
In `.ts` files put only prose in the JSDoc and let the signature carry the types;
|
||||
do not duplicate types in `{braces}`.
|
||||
|
||||
Catch blocks should not annotate the error. Let it be `unknown` and convert it
|
||||
with the shared helper:
|
||||
|
||||
@@ -151,6 +237,56 @@ with the shared helper:
|
||||
|
||||
Avoid `any`; `no-explicit-any` is enforced. Prefer `unknown` plus narrowing.
|
||||
|
||||
**Comment the code you write.** This codebase is read by human programmers who
|
||||
did not write it, and a wall of uncommented agent-written code is expensive to
|
||||
review. Keep comments simple and preferably single line, placed above the code
|
||||
they describe, and write them in plain language rather than in terms of the
|
||||
types. Say _why_ the code does what it does — what is non-obvious, what would
|
||||
break if it were written the obvious way — and leave out what the code already
|
||||
states plainly. A tricky constant, a workaround for someone else's bug, or an
|
||||
ordering that matters always deserves a line.
|
||||
|
||||
Write doc comments as JSDoc (`/** ... */`), which editors show on hover.
|
||||
Plain `//` and `///` comments do not appear in tooltips.
|
||||
In `.ts` files put only prose in the JSDoc and let the signature carry the types;
|
||||
do not duplicate types in `{braces}`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Search for an existing library before writing your own implementation.**
|
||||
Formats, protocols and codecs are full of details that only show up against
|
||||
real data, and a maintained library has already met them. This project once
|
||||
carried a hand-written tar reader/writer and zip writer; both were deleted in
|
||||
favour of libraries.
|
||||
|
||||
**Read the library's source before adding it, and judge it on that.** Download
|
||||
counts say nothing about quality. Even a popular package may turn out to be a
|
||||
single ten thousand line file whose variables and functions are all named `a`,
|
||||
`b` and `c`, with no comments anywhere. Such code cannot be read, cannot be
|
||||
maintained, and is very likely to be carrying bugs nobody can find — do not
|
||||
depend on it. Look for readable sources, comments that explain intent, and an
|
||||
API that fits the way the code here is written.
|
||||
|
||||
If nothing of good quality exists, say so and propose writing an implementation
|
||||
here instead, rather than pulling in the least bad option.
|
||||
|
||||
Do not add a second library for a job a package already in `package.json` can
|
||||
do. A thin wrapper that shapes an installed library's API to what the code
|
||||
needs is better than a new dependency. But if the API of the library
|
||||
doesn't fit the project, the wrapper can be bigger than your own implementation
|
||||
of the algorithm.
|
||||
|
||||
## Checking browser support
|
||||
|
||||
**Never use caniuse.com.** Its data is wrong and incomplete, missing features
|
||||
that shipped five or more years ago, and a decision made from it will be wrong.
|
||||
|
||||
Use MDN's [browser-compat-data](https://github.com/mdn/browser-compat-data),
|
||||
which is the source behind the MDN compatibility tables, and
|
||||
[webstatus.dev](https://webstatus.dev) for the baseline status of a feature. If
|
||||
those disagree or say nothing, the browsers' own release notes and bug trackers
|
||||
settle it.
|
||||
|
||||
## Things that will bite you
|
||||
|
||||
**Both TypeScript 6 and 7 are installed on purpose, under swapped names.**
|
||||
|
||||
Reference in New Issue
Block a user