Compare commits
6 Commits
9c25922a3b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 03eaa951f7 | |||
| 7d64ab4809 | |||
| f1a60a208c | |||
| a022dd00dc | |||
| 54ddc11b44 | |||
| 5da5fd2c7e |
@@ -40,3 +40,9 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# ai agent settings
|
||||
.claude
|
||||
|
||||
# scratch directory for temporary files, deleted once a task is done
|
||||
/TEMP
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
package-lock.json
|
||||
@@ -9,7 +9,8 @@ backend service, polls until parsing finishes, and displays the result in a
|
||||
grid.
|
||||
|
||||
- **Next.js 16** (App Router, Turbopack) with **React 19**
|
||||
- **TypeScript 6**, **ESLint 9** (flat config), **Prettier 3**
|
||||
- **TypeScript 6 and 7** side by side (see "Things that will bite you"),
|
||||
**ESLint 9** (flat config), **Prettier 3**
|
||||
- **ag-grid** for the data grid, **Bootstrap 5** for styling
|
||||
|
||||
Source files live in `src/`:
|
||||
@@ -17,10 +18,155 @@ 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
|
||||
|
||||
## Architecture
|
||||
|
||||
The parsing backend is a **separate service in its own repository**, not part
|
||||
of this project. Start it manually before running the frontend; it is expected
|
||||
at `http://127.0.0.1:5226`.
|
||||
|
||||
The browser never calls that backend directly. Requests go through a Next route
|
||||
handler that forwards them:
|
||||
|
||||
```
|
||||
SaveParserBackendService -> /api/proxy/saveParserBackend/* -> backend
|
||||
(client, fetch) (server, route handler)
|
||||
```
|
||||
|
||||
- [src/services/SaveParserBackendService.ts](src/services/SaveParserBackendService.ts)
|
||||
is the only place that talks to the backend. It exposes `uploadSave`,
|
||||
`getSaveStatus`, `getSaveData` 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
|
||||
CORS itself.
|
||||
- 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.
|
||||
|
||||
`getSaveData` is the deliberate exception: the backend stores parsed save data
|
||||
as zstd compressed json, and the service decompresses that response on the main
|
||||
thread instead of in the worker. Decompression is cheap and its cost does not
|
||||
depend on the level — the whole 87 MB of an EU4 save comes back in 0.27 s at
|
||||
level 10, and the parsed json is a fraction of that, so it is a matter of
|
||||
milliseconds. A worker round trip would cost more than it saves.
|
||||
|
||||
[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 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/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 only supports EU4.** It accepts the zstd compressed tar
|
||||
described above, rejects anything else, and serves `getSaveFile`. Uploads
|
||||
for other games, Stellaris included, are rejected with 400 until the backend
|
||||
gets a parser for them. It also enforces its own upload limits and answers
|
||||
413 when a save is over them. Those limits are separate from the ones in
|
||||
`runtimeConfig.json`, so keep the two in sync.
|
||||
- **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
|
||||
@@ -43,7 +189,7 @@ problem rather than a missing install: MSYS2 defaults `MSYS2_PATH_TYPE` to
|
||||
npm run dev # dev server
|
||||
npm run build # production build
|
||||
npm run lint # eslint
|
||||
npm run format # prettier, rewrites files in src/
|
||||
npm run format # prettier, rewrites everything except .gitignore/.prettierignore
|
||||
npx tsc --noEmit # typecheck
|
||||
```
|
||||
|
||||
@@ -64,6 +210,12 @@ does not replace a full build.
|
||||
|
||||
Do not report a change as finished until both commands pass.
|
||||
|
||||
### Tests and benchmarks
|
||||
|
||||
**Never run a test suite or a benchmark without explicit permission.** Ask
|
||||
first and wait for a clear yes, however easy the command looks. Lint and build
|
||||
are the exception, and are expected after every change.
|
||||
|
||||
## Version control
|
||||
|
||||
This project is managed with **git**.
|
||||
@@ -89,11 +241,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:
|
||||
|
||||
@@ -106,11 +253,110 @@ 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
|
||||
|
||||
**TypeScript is pinned to 6 on purpose.** Do not upgrade it to 7. The Next.js
|
||||
ESLint config depends on typescript-eslint, which refuses to run under
|
||||
TypeScript 7, and that would break `npm run lint` entirely.
|
||||
**Both TypeScript 6 and 7 are installed on purpose, under swapped names.**
|
||||
TypeScript 7 is the native Go compiler and type-checks much faster, but
|
||||
typescript-eslint refuses to run under it, which would break `npm run lint`
|
||||
entirely. So each tool gets the version it can use:
|
||||
|
||||
| package.json entry | resolves to | used by |
|
||||
| -------------------- | ------------------------------------------------ | ----------------- |
|
||||
| `typescript` | `@typescript/typescript6` (TS 6 API, bin `tsc6`) | typescript-eslint |
|
||||
| `@typescript/native` | `typescript@7` (Go compiler) | `next build` |
|
||||
|
||||
The alias is deliberately reversed: typescript-eslint looks the compiler up by
|
||||
the package _name_ `typescript`, so that name must stay on TS 6. Do not "fix"
|
||||
this by renaming the entries.
|
||||
|
||||
`next.config.ts` sets `experimental.useTypeScriptCli`, which makes Next spawn a
|
||||
`tsc` binary instead of using the compiler API. Next resolves that binary from
|
||||
the package named `typescript` too, so it would also get TS 6. The `prebuild`
|
||||
script [scripts/replace-tsc6-with-tsc7.mjs](scripts/replace-tsc6-with-tsc7.mjs)
|
||||
works around this by rewriting `node_modules/typescript/bin/tsc6` into a
|
||||
one-line stub that imports TS 7's `lib/tsc.js`.
|
||||
|
||||
Consequences to keep in mind:
|
||||
|
||||
- The patch lives in `node_modules`, so `npm install` undoes it. `prebuild`
|
||||
reapplies it, so `npm run build` is always correct; nothing else needs doing.
|
||||
- `npx tsc --noEmit` is unaffected either way. `node_modules/.bin/tsc` links
|
||||
straight to `@typescript/native`, so the standalone typecheck is always TS 7.
|
||||
- Do not drop the `prebuild` script while leaving `useTypeScriptCli` enabled.
|
||||
Nothing breaks visibly — the build just silently goes back to type-checking
|
||||
with TS 6.
|
||||
|
||||
**All of this is temporary.** The only reason TS 6 is still here is that
|
||||
TypeScript 7.0 ships no stable programmatic JS API, so typescript-eslint cannot
|
||||
run on it. That API arrives in **TypeScript 7.1, planned for 2026-11-24**. Once
|
||||
it is out and typescript-eslint supports it, tear the whole arrangement down:
|
||||
drop `@typescript/native` and the `@typescript/typescript6` alias for a plain
|
||||
`"typescript": "^7.1"`, delete `scripts/replace-tsc6-with-tsc7.mjs` and the
|
||||
`prebuild` script, and re-check whether `useTypeScriptCli` is still worth
|
||||
keeping.
|
||||
|
||||
**Env vars in npm scripts must go through `cross-env`.** The `dev` and `build`
|
||||
scripts set `NEXT_TELEMETRY_DISABLED=1`, which stops Next from collecting
|
||||
anonymous usage data and saves the ~0.5s the telemetry flush adds to every
|
||||
build. The `FOO=1 cmd` prefix is POSIX shell syntax, and on Windows npm runs
|
||||
scripts through `cmd`, where it is a syntax error — so the prefix is written as
|
||||
`cross-env NEXT_TELEMETRY_DISABLED=1 next ...` instead. Keep `cross-env` on any
|
||||
new script that needs an env var.
|
||||
|
||||
Setting the variable from `prebuild` would not work: `prebuild` and `build` are
|
||||
sibling processes, so an env var exported by one is not visible to the other.
|
||||
Each script that needs it sets it itself. `prebuild` has no `cross-env` prefix
|
||||
because it only rewrites the tsc bin and never talks to telemetry.
|
||||
|
||||
**`instrumentation.ts` is compiled for the edge runtime too and it can't be disabled.**
|
||||
Anything using `fs`, `path`, or other Node APIs must be imported behind the
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Compression levels
|
||||
|
||||
Reference for `saveUpload.zstdCompressionLevel` in
|
||||
[src/config/AppConfig.ts](src/config/AppConfig.ts). If compression ever turns
|
||||
out to be too slow or too memory hungry on some device, pick a different level
|
||||
from the table below rather than measuring again.
|
||||
|
||||
The level only affects the uploader. Decompression costs the same at every
|
||||
level, both on the parser backend and in the browser.
|
||||
|
||||
## What was measured
|
||||
|
||||
A real Europa Universalis IV save, as the game wrote it:
|
||||
|
||||
| | |
|
||||
| ------------- | ----------------------------------------------------- |
|
||||
| File | `Ibevar1475_01_02save.eu4`, a zip archive |
|
||||
| Size on disk | **11,502,739 bytes** (11.0 MB) |
|
||||
| Entries | `gamestate` (87,188,760 bytes), `meta`, `ai` |
|
||||
| Input to zstd | **87,342,592 bytes** — the tar of those three entries |
|
||||
|
||||
So the ratios below are against the original 11.5 MB game archive, and zstd
|
||||
itself is working on the 87 MB of uncompressed save text inside it.
|
||||
|
||||
Each level ran through the project's own `packSave` in a separate process. The
|
||||
heap columns are the WebAssembly linear memory the encoder and the decoder grow
|
||||
to, read from the instance itself — that is the memory a browser tab spends,
|
||||
and it is what to look at when a device runs out. The machine was a desktop;
|
||||
**a phone is several times slower**, so treat the times as a lower bound.
|
||||
|
||||
Every level round tripped to the same 87,342,592 byte tar, so each row is
|
||||
correct output rather than merely fast output.
|
||||
|
||||
## The table
|
||||
|
||||
| level | compressed | ratio | compress | decompress | encoder heap | decoder heap |
|
||||
| -----: | ------------: | --------: | ---------: | ---------: | -----------: | -----------: |
|
||||
| 1 | 6,698,281 | 1.72x | 0.54 s | 0.28 s | 8 MB | 8 MB |
|
||||
| 2 | 6,600,451 | 1.74x | 0.54 s | 0.29 s | 8 MB | 8 MB |
|
||||
| 3 | 6,094,116 | 1.89x | 0.57 s | 0.28 s | 8 MB | 8 MB |
|
||||
| 4 | 6,082,362 | 1.89x | 0.61 s | 0.28 s | 8 MB | 8 MB |
|
||||
| 5 | 5,280,090 | 2.18x | 0.92 s | 0.28 s | 8 MB | 8 MB |
|
||||
| 6 | 4,943,366 | 2.33x | 1.13 s | 0.27 s | 8 MB | 8 MB |
|
||||
| 7 | 4,738,515 | 2.43x | 1.27 s | 0.27 s | 9 MB | 8 MB |
|
||||
| 8 | 4,514,853 | 2.55x | 1.52 s | 0.28 s | 9 MB | 8 MB |
|
||||
| 9 | 4,445,368 | 2.59x | 1.51 s | 0.26 s | 16 MB | 8 MB |
|
||||
| **10** | **4,280,567** | **2.69x** | **1.74 s** | **0.27 s** | **26 MB** | **8 MB** |
|
||||
| 11 | 4,157,684 | 2.77x | 2.44 s | 0.32 s | 26 MB | 8 MB |
|
||||
| 12 | 4,155,668 | 2.77x | 2.39 s | 0.27 s | 46 MB | 8 MB |
|
||||
| 13 | 4,257,141 | 2.70x | 3.76 s | 0.27 s | 38 MB | 8 MB |
|
||||
| 14 | 4,108,558 | 2.80x | 5.39 s | 0.28 s | 54 MB | 8 MB |
|
||||
| 15 | 3,968,262 | 2.90x | 8.13 s | 0.33 s | 70 MB | 8 MB |
|
||||
| 16 | 3,862,217 | 2.98x | 11.63 s | 0.26 s | 38 MB | 8 MB |
|
||||
| 17 | 3,733,198 | 3.08x | 12.39 s | 0.26 s | 58 MB | 10 MB |
|
||||
| 18 | 3,705,141 | 3.10x | 15.11 s | 0.26 s | 59 MB | 10 MB |
|
||||
| 19 | 3,449,475 | 3.33x | 27.78 s | 0.28 s | 91 MB | 10 MB |
|
||||
| 20 | 3,344,642 | 3.44x | 28.24 s | 0.27 s | 195 MB | 34 MB |
|
||||
| 21 | 3,285,836 | 3.50x | 33.91 s | 0.30 s | 387 MB | 66 MB |
|
||||
| 22 | 3,245,980 | 3.54x | 49.02 s | 0.30 s | 835 MB | 130 MB |
|
||||
|
||||
## Picking a level
|
||||
|
||||
**10 is the default**, because it is where the curve flattens. It gives good compression in reasonable time: 11 is only 3% smaller but takes 40% longer.
|
||||
|
||||
If there will be troubles with this level in the future, just change it in config.
|
||||
|
||||
## Reproducing
|
||||
|
||||
There is no script in the repository for this; it was a one-off measurement.
|
||||
To repeat it, stream a save through `packSave` once per level in a separate
|
||||
process, and read `memory.buffer.byteLength` off the encoder and decoder
|
||||
instances of [@tootallnate/zstd-wasm](https://www.npmjs.com/package/@tootallnate/zstd-wasm)
|
||||
for the heap figures.
|
||||
@@ -1 +1,20 @@
|
||||
# paradox-save-parser-frontend
|
||||
|
||||
## Install dependencies
|
||||
|
||||
```sh
|
||||
npm i
|
||||
```
|
||||
|
||||
## Build release
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Run release
|
||||
|
||||
```sh
|
||||
cd .next/standalone
|
||||
node --enable-source-maps ./server.js
|
||||
```
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'
|
||||
import nextTypescript from 'eslint-config-next/typescript'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['.next/**', 'node_modules/**', 'next-env.d.ts']),
|
||||
globalIgnores(['.next/**', 'node_modules/**', 'TEMP/**', 'next-env.d.ts']),
|
||||
nextCoreWebVitals,
|
||||
nextTypescript,
|
||||
])
|
||||
|
||||
+28
-4
@@ -1,7 +1,31 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
experimental: {
|
||||
// call tsc6 instead of import 'typescript'
|
||||
useTypeScriptCli: true,
|
||||
// faster "npm run dev"
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
// don't create temporary files in .next/ during "npm run build"
|
||||
turbopackFileSystemCacheForBuild: false,
|
||||
// options to make standalone build more debuggable
|
||||
serverSourceMaps: true,
|
||||
serverMinification: false,
|
||||
},
|
||||
// produce .next/standalone
|
||||
// which can be started by `node server.js` without any dependencies
|
||||
output: 'standalone',
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
// remove next/image from build, which this project doesn't use
|
||||
outputFileTracingExcludes: {
|
||||
'*': ['node_modules/sharp/**', 'node_modules/@img/**'],
|
||||
},
|
||||
// sometimes production builds ran without proper type checking
|
||||
typescript: {
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
export default nextConfig
|
||||
|
||||
Generated
+890
-469
File diff suppressed because it is too large
Load Diff
+14
-7
@@ -3,27 +3,34 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"dev": "cross-env NEXT_TELEMETRY_DISABLED=1 next dev --turbopack",
|
||||
"prebuild": "node scripts/replace-tsc6-with-tsc7.mjs",
|
||||
"build": "cross-env NEXT_TELEMETRY_DISABLED=1 next build",
|
||||
"postbuild": "node scripts/prepare-build.mjs",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"format": "npx prettier src --write"
|
||||
"format": "npx prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"ag-grid-react": "^36",
|
||||
"bootstrap": "^5",
|
||||
"next": "^16",
|
||||
"react": "^19",
|
||||
"react-dom": "^19"
|
||||
"react-dom": "^19",
|
||||
"bootstrap": "^5",
|
||||
"ag-grid-community": "^36",
|
||||
"ag-grid-react": "^36",
|
||||
"@tootallnate/zstd-wasm": "^0.1",
|
||||
"modern-tar": "^0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@typescript/native": "npm:typescript@^7.0",
|
||||
"cross-env": "^10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^16",
|
||||
"prettier": "^3",
|
||||
"typescript": "^6"
|
||||
"typescript": "npm:@typescript/typescript6@^6.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"unrs-resolver": false
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
Assembles a self-contained deployment folder at ./build/ from the
|
||||
`output: 'standalone'` next build output. Next.js does not include
|
||||
.next/static or public/ in .next/standalone by itself, so this script
|
||||
copies them in. The result can be deployed as-is and started with
|
||||
`node server.js` from inside ./build/, without running `npm install`.
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
const standaloneDir = path.join(root, '.next', 'standalone')
|
||||
const buildDir = path.join(root, 'build')
|
||||
|
||||
if (!existsSync(standaloneDir)) {
|
||||
console.error(
|
||||
`${standaloneDir} not found. Run "next build" with output: 'standalone' set in next.config.ts first.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
rmSync(buildDir, { recursive: true, force: true })
|
||||
cpSync(standaloneDir, buildDir, { recursive: true })
|
||||
cpSync(
|
||||
path.join(root, '.next', 'static'),
|
||||
path.join(buildDir, '.next', 'static'),
|
||||
{ recursive: true }
|
||||
)
|
||||
|
||||
const publicDir = path.join(root, 'public')
|
||||
if (existsSync(publicDir)) {
|
||||
cpSync(publicDir, path.join(buildDir, 'public'), { recursive: true })
|
||||
}
|
||||
|
||||
console.log(`build assembled at ${buildDir}`)
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
Replaces tsc6 script which is called by next.js to run tsc 7,
|
||||
which is a native executable written in go and works faster.
|
||||
The problem is typescript 7 is supported with nextjs,
|
||||
but is not supported by eslint which we use for static code analysis.
|
||||
This script solves the problem.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const tsc6 = path.join(
|
||||
path.dirname(require.resolve('typescript/package.json')),
|
||||
'bin',
|
||||
'tsc6'
|
||||
)
|
||||
const nativeTsc = path.join(
|
||||
path.dirname(require.resolve('@typescript/native/package.json')),
|
||||
'lib',
|
||||
'tsc.js'
|
||||
)
|
||||
const specifier = path
|
||||
.relative(path.dirname(tsc6), nativeTsc)
|
||||
.split(path.sep)
|
||||
.join('/')
|
||||
|
||||
writeFileSync(tsc6, `#!/usr/bin/env node\nimport('${specifier}')\n`)
|
||||
|
||||
console.log(`patched ${tsc6} -> ${nativeTsc}`)
|
||||
@@ -4,6 +4,10 @@ import { redirectRequest, respondToOPTIONS } from '@/app/api/proxy/functions'
|
||||
import { PROXY_PATHS } from '@/app/api/proxy/paths'
|
||||
import { appConfig } from '@/lib/configLoader'
|
||||
|
||||
//TODO: remove this proxy once the backend handles CORS itself. It only exists
|
||||
// so the browser can make same-origin requests; with CORS on the backend the
|
||||
// client can call it directly and this extra hop disappears.
|
||||
|
||||
// Forwards the incoming request to the save parser backend.
|
||||
async function redirect(req: NextRequest): Promise<NextResponse> {
|
||||
return await redirectRequest(
|
||||
|
||||
+20
-62
@@ -1,71 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { AgGridReact } from 'ag-grid-react'
|
||||
import {
|
||||
AllCommunityModule,
|
||||
themeQuartz,
|
||||
colorSchemeDark,
|
||||
} from 'ag-grid-community'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
const DARK_SCHEME_QUERY = '(prefers-color-scheme: dark)'
|
||||
|
||||
// add the vertical lines between cells
|
||||
const BASE_THEME = themeQuartz.withParams({
|
||||
columnBorder: true,
|
||||
headerColumnBorder: true,
|
||||
})
|
||||
// Built once, so the grid keeps seeing the same theme object between renders.
|
||||
const LIGHT_THEME = BASE_THEME
|
||||
const DARK_THEME = BASE_THEME.withPart(colorSchemeDark)
|
||||
|
||||
// Notifies React whenever the browser's light/dark color scheme changes.
|
||||
function subscribeToColorScheme(onChange: () => void) {
|
||||
const mediaQuery = window.matchMedia(DARK_SCHEME_QUERY)
|
||||
mediaQuery.addEventListener('change', onChange)
|
||||
return () => mediaQuery.removeEventListener('change', onChange)
|
||||
}
|
||||
|
||||
function isDarkSchemePreferred() {
|
||||
return window.matchMedia(DARK_SCHEME_QUERY).matches
|
||||
}
|
||||
|
||||
// The server has no color scheme, so it always prerenders the light theme.
|
||||
function isDarkSchemePreferredOnServer() {
|
||||
return false
|
||||
}
|
||||
import BrowseSaveDataGrid from '@/components/BrowseSaveDataGrid'
|
||||
import ConditionalView from '@/components/ConditionalView'
|
||||
import SaveDownloadButton from '@/components/SaveDownloadButton'
|
||||
|
||||
// TODO: create tabs with general info, tables, plots
|
||||
// TODO: feed the grid with the parsed save the id points at
|
||||
|
||||
interface BrowseSaveDataPageProps {
|
||||
searchParams: Promise<{ id?: string }>
|
||||
}
|
||||
|
||||
// Page that shows a save file's parsed data in a grid.
|
||||
export default function BrowseSaveDataPage() {
|
||||
// Read the color scheme straight from the browser instead of mirroring it
|
||||
// into state, which would need an extra render to catch up.
|
||||
const prefersDark = useSyncExternalStore(
|
||||
subscribeToColorScheme,
|
||||
isDarkSchemePreferred,
|
||||
isDarkSchemePreferredOnServer
|
||||
)
|
||||
const agTheme = prefersDark ? DARK_THEME : LIGHT_THEME
|
||||
export default async function BrowseSaveDataPage(
|
||||
props: BrowseSaveDataPageProps
|
||||
) {
|
||||
const { id } = await props.searchParams
|
||||
|
||||
return (
|
||||
<div style={{ height: '500px' }}>
|
||||
<AgGridReact
|
||||
theme={agTheme}
|
||||
modules={[AllCommunityModule]}
|
||||
rowData={[
|
||||
{ id: 1, col1: 'Hello', col2: 'AG Grid' },
|
||||
{ id: 2, col1: 'Dark Mode', col2: 'Support' },
|
||||
]}
|
||||
columnDefs={[
|
||||
{
|
||||
field: 'col1',
|
||||
headerName: 'Column 1',
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{ field: 'col2', headerName: 'Column 2' },
|
||||
]}
|
||||
/>
|
||||
<div>
|
||||
<ConditionalView cond={id}>
|
||||
<div className="container mb-3">
|
||||
<SaveDownloadButton saveId={id ?? ''} />
|
||||
</div>
|
||||
</ConditionalView>
|
||||
|
||||
<BrowseSaveDataGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
font-family: var(--font-ibm-plex-sans), monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
+2
-4
@@ -1,8 +1,8 @@
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import './globals.css'
|
||||
import type { Metadata } from 'next'
|
||||
import { ModuleRegistry, AllCommunityModule } from 'ag-grid-community'
|
||||
import Navbar from '@/components/Navbar'
|
||||
import { font_IbmPlexSans } from '@/lib/myFonts'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Home',
|
||||
@@ -12,14 +12,12 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
// Register the ag-grid community features
|
||||
ModuleRegistry.registerModules([AllCommunityModule])
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>paradox-save-parser-frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<body className={font_IbmPlexSans.className}>
|
||||
<Navbar />
|
||||
{children}
|
||||
</body>
|
||||
|
||||
+4
-1
@@ -1,11 +1,14 @@
|
||||
import SaveFileUploadingDialog from '@/components/SaveFileUploadingDialog'
|
||||
import { appConfig } from '@/lib/configLoader'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="container mt-5 text-center">
|
||||
<h1 className="mb-4">Welcome to Paradox Save Parser!</h1>
|
||||
|
||||
<SaveFileUploadingDialog />
|
||||
{/* The config is read on the server, so the limits are handed to
|
||||
the dialog instead of being fetched by it. */}
|
||||
<SaveFileUploadingDialog saveUploadConfig={appConfig.saveUpload} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { AgGridReact } from 'ag-grid-react'
|
||||
import {
|
||||
AllCommunityModule,
|
||||
themeQuartz,
|
||||
colorSchemeDark,
|
||||
} from 'ag-grid-community'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
const DARK_SCHEME_QUERY = '(prefers-color-scheme: dark)'
|
||||
|
||||
// add the vertical lines between cells
|
||||
// Built once, so the grid keeps seeing the same theme object between renders.
|
||||
const LIGHT_THEME = themeQuartz.withParams({
|
||||
columnBorder: true,
|
||||
headerColumnBorder: true,
|
||||
headerCellBackgroundTransitionDuration: 0,
|
||||
})
|
||||
const DARK_THEME = LIGHT_THEME.withPart(colorSchemeDark)
|
||||
|
||||
// Notifies React whenever the browser's light/dark color scheme changes.
|
||||
function subscribeToColorScheme(onChange: () => void) {
|
||||
const mediaQuery = window.matchMedia(DARK_SCHEME_QUERY)
|
||||
mediaQuery.addEventListener('change', onChange)
|
||||
return () => mediaQuery.removeEventListener('change', onChange)
|
||||
}
|
||||
|
||||
function isDarkSchemePreferred() {
|
||||
return window.matchMedia(DARK_SCHEME_QUERY).matches
|
||||
}
|
||||
|
||||
// The server has no color scheme, so it always prerenders the light theme.
|
||||
function isDarkSchemePreferredOnServer() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Grid showing a save file's parsed data.
|
||||
export default function BrowseSaveDataGrid() {
|
||||
// Read the color scheme straight from the browser instead of mirroring it
|
||||
// into state, which would need an extra render to catch up.
|
||||
const prefersDark = useSyncExternalStore(
|
||||
subscribeToColorScheme,
|
||||
isDarkSchemePreferred,
|
||||
isDarkSchemePreferredOnServer
|
||||
)
|
||||
const agTheme = prefersDark ? DARK_THEME : LIGHT_THEME
|
||||
|
||||
return (
|
||||
<div style={{ height: '500px' }}>
|
||||
<AgGridReact
|
||||
theme={agTheme}
|
||||
modules={[AllCommunityModule]}
|
||||
// ddisable all animations
|
||||
animateRows={false}
|
||||
suppressColumnMoveAnimation={true}
|
||||
rowData={[
|
||||
{ id: 1, col1: 'Hello', col2: 'AG Grid' },
|
||||
{ id: 2, col1: 'Dark Mode', col2: 'Support' },
|
||||
]}
|
||||
columnDefs={[
|
||||
{
|
||||
field: 'col1',
|
||||
headerName: 'Column 1',
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{ field: 'col2', headerName: 'Column 2' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
interface ConditionalViewProps {
|
||||
cond: unknown
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// Shows its children only when `cond` is truthy, and nothing otherwise.
|
||||
// Unlike `cond && <View/>`, a falsy value here never leaks into the page: JSX
|
||||
// renders 0 and '' as text, so an empty string or a count of zero would show up.
|
||||
export default function ConditionalView(props: ConditionalViewProps) {
|
||||
if (!props.cond) return null
|
||||
|
||||
return <>{props.children}</>
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { font_Hack } from '@/lib/myFonts'
|
||||
|
||||
// Top navigation bar with a home link and login/register buttons.
|
||||
export default function Navbar() {
|
||||
return (
|
||||
<nav
|
||||
className={`navbar navbar-expand-lg navbar-dark bg-dark px-3 ${font_Hack.className}`}
|
||||
>
|
||||
<nav className={`navbar navbar-expand-lg navbar-dark bg-dark px-3`}>
|
||||
<div className="container-fluid">
|
||||
{/* Left: Home Link */}
|
||||
<Link href="/" className="navbar-brand">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import ConditionalView from './ConditionalView'
|
||||
import FormError from './FormError'
|
||||
import { downloadBlobAsFile } from '@/lib/downloadFile'
|
||||
import { getMsgFromError } from '@/lib/errors'
|
||||
import { rebuildOriginalSave } from '@/lib/saveArchive/client'
|
||||
import { SAVE_PROGRESS_STAGE_LABELS } from '@/lib/saveArchive/messages'
|
||||
import {
|
||||
saveParserBackendService,
|
||||
SaveStatusResponse,
|
||||
} from '@/services/SaveParserBackendService'
|
||||
|
||||
interface SaveDownloadButtonProps {
|
||||
saveId: string
|
||||
// Status of the save, when the caller already has it. It is fetched on
|
||||
// demand otherwise, since the download needs the shape the save was
|
||||
// uploaded in and its file name.
|
||||
status?: SaveStatusResponse | null
|
||||
}
|
||||
|
||||
// Downloads a stored save and rebuilds the file the game originally wrote.
|
||||
export default function SaveDownloadButton(props: SaveDownloadButtonProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState<string | null>(null)
|
||||
|
||||
const handleDownload = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const status =
|
||||
props.status ??
|
||||
(await saveParserBackendService.getSaveStatus(props.saveId))
|
||||
|
||||
setProgress(SAVE_PROGRESS_STAGE_LABELS.downloading)
|
||||
const archive = await saveParserBackendService.getSaveFile(
|
||||
props.saveId
|
||||
)
|
||||
|
||||
const saveFile = await rebuildOriginalSave(
|
||||
archive,
|
||||
status.originalFileType,
|
||||
(stage) => setProgress(SAVE_PROGRESS_STAGE_LABELS[stage])
|
||||
)
|
||||
downloadBlobAsFile(saveFile, status.originalFileName)
|
||||
} catch (err) {
|
||||
const errMsg = getMsgFromError(err)
|
||||
console.error('Error while downloading save:', errMsg)
|
||||
setError(errMsg)
|
||||
} finally {
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormError message={error} />
|
||||
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={progress !== null}
|
||||
>
|
||||
Download original save
|
||||
</button>
|
||||
|
||||
<ConditionalView cond={progress}>
|
||||
<div className="mt-2">{progress}</div>
|
||||
</ConditionalView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,30 +2,34 @@
|
||||
|
||||
import {
|
||||
saveParserBackendService,
|
||||
SaveStatusResponse,
|
||||
UploadSaveResponse,
|
||||
} from '@/services/SaveParserBackendService'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import FormError from './FormError'
|
||||
import ConditionalView from './ConditionalView'
|
||||
import type { SaveUploadConfig } from '@/config/AppConfig'
|
||||
import { GameEnum } from '@/config/games'
|
||||
import { getMsgFromError } from '@/lib/errors'
|
||||
import { SAVE_PROGRESS_STAGE_LABELS } from '@/lib/saveArchive/messages'
|
||||
|
||||
export enum GameEnum {
|
||||
EU4 = 'eu4',
|
||||
Stellaris = 'stellaris',
|
||||
interface SaveFileUploadingDialogProps {
|
||||
// Comes from the server, where runtimeConfig.json is read.
|
||||
saveUploadConfig: SaveUploadConfig
|
||||
}
|
||||
|
||||
//TODO: move to separate page
|
||||
//TODO: add redirect to ?id={id} to see status of long parsing save
|
||||
const IN_BROWSER = '[In Browser]'
|
||||
const ON_SERVER = '[On Server]'
|
||||
|
||||
// Dialog for picking a game and save file, uploading it, and polling the parse status until done.
|
||||
export default function SaveFileUploadingDialog() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
export default function SaveFileUploadingDialog(
|
||||
props: SaveFileUploadingDialogProps
|
||||
) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [game, setGame] = useState<GameEnum>(GameEnum.EU4)
|
||||
const [uploadDisabled, setUploadDisabled] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatusResponse | null>(
|
||||
null
|
||||
)
|
||||
// This is what user will see
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||
|
||||
const [uploadResult, setUploadResult] = useState<UploadSaveResponse | null>(
|
||||
null
|
||||
)
|
||||
@@ -42,10 +46,10 @@ export default function SaveFileUploadingDialog() {
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads the selected save file, then repeatedly polls its status until parsing is done or an error occurs.
|
||||
// Compresses and uploads the selected save file, then repeatedly polls its status until parsing is done or an error occurs.
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setStatusMessage(null)
|
||||
setUploadDisabled(true)
|
||||
try {
|
||||
if (!selectedFile) return
|
||||
@@ -53,10 +57,17 @@ export default function SaveFileUploadingDialog() {
|
||||
console.log('Uploading file:', selectedFile.name)
|
||||
const uploadResponse = await saveParserBackendService.uploadSave(
|
||||
game,
|
||||
selectedFile
|
||||
selectedFile,
|
||||
props.saveUploadConfig,
|
||||
(stage) =>
|
||||
setStatusMessage(
|
||||
`${IN_BROWSER} ${SAVE_PROGRESS_STAGE_LABELS[stage]}`
|
||||
)
|
||||
)
|
||||
console.log('Upload response:', uploadResponse)
|
||||
setUploadResult(uploadResponse)
|
||||
setStatusMessage(
|
||||
`${ON_SERVER} Waiting for the save to be parsed...`
|
||||
)
|
||||
|
||||
const pollInterval = 1000 // ms
|
||||
// Poll the backend every second for the parsing status until it reports "done".
|
||||
@@ -67,23 +78,25 @@ export default function SaveFileUploadingDialog() {
|
||||
uploadResponse.id
|
||||
)
|
||||
console.log('Save status:', statusResponse.status)
|
||||
setSaveStatus(statusResponse)
|
||||
setStatusMessage(`${ON_SERVER} ${statusResponse.status}`)
|
||||
if (statusResponse.status.toLowerCase() === 'done') {
|
||||
clearInterval(intervalId)
|
||||
// Only a parsed save is worth showing or browsing.
|
||||
setUploadResult(uploadResponse)
|
||||
setUploadDisabled(false)
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = getMsgFromError(err)
|
||||
console.error('Error while polling status:', errMsg)
|
||||
clearInterval(intervalId)
|
||||
setError(errMsg)
|
||||
setStatusMessage(`${ON_SERVER} ${errMsg}`)
|
||||
setUploadDisabled(false)
|
||||
}
|
||||
}, pollInterval)
|
||||
} catch (err) {
|
||||
const errMsg = getMsgFromError(err)
|
||||
console.error(errMsg)
|
||||
setError(errMsg)
|
||||
setStatusMessage(`${IN_BROWSER} ${errMsg}`)
|
||||
setUploadDisabled(false)
|
||||
}
|
||||
}
|
||||
@@ -97,8 +110,6 @@ export default function SaveFileUploadingDialog() {
|
||||
|
||||
return (
|
||||
<div className="container mt-5" style={{ maxWidth: '500px' }}>
|
||||
<FormError message={error} />
|
||||
|
||||
<form onSubmit={handleUpload} className="text-start">
|
||||
<label htmlFor="gameSelect" className="form-label">
|
||||
Select Game
|
||||
@@ -134,21 +145,21 @@ export default function SaveFileUploadingDialog() {
|
||||
</form>
|
||||
|
||||
<div className="container mt-5 text-center">
|
||||
{/* TODO: extract this to a component
|
||||
<ConditionalView cond={uploadResult}> <LabeledValue val={}/> </ConditionalView> */}
|
||||
{uploadResult && (
|
||||
<div className="mb-3">
|
||||
Save Id: <strong>{uploadResult.id}</strong>
|
||||
</div>
|
||||
)}
|
||||
<ConditionalView cond={statusMessage}>
|
||||
<div className="mb-3">{statusMessage}</div>
|
||||
</ConditionalView>
|
||||
|
||||
{saveStatus && (
|
||||
<ConditionalView cond={uploadResult}>
|
||||
<div className="mb-3">
|
||||
Status: <strong>{saveStatus.status}</strong>
|
||||
Save Id: <strong>{uploadResult?.id}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TODO: add link to <ConditionalView cond={done}> /browse?id=${saveStatus.id} */}
|
||||
<Link
|
||||
href={`/browse?id=${uploadResult?.id}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Browse parsed save
|
||||
</Link>
|
||||
</ConditionalView>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+12
-2
@@ -5,9 +5,19 @@ export const defaultConfig = {
|
||||
saveParserBackend: {
|
||||
url: 'http://127.0.0.1:5226',
|
||||
},
|
||||
// TODO: implement auth api
|
||||
auth: {},
|
||||
},
|
||||
saveUpload: {
|
||||
// Size of a save if it is uploaded as uncompressed. The browser packs
|
||||
// saves as a stream, so these limits are about the backend's disk
|
||||
// rather than about memory.
|
||||
maxSizePlainTextMB: 200,
|
||||
// Archives produced by the game, recognized by the zip magic bytes.
|
||||
maxSizeCompressedMB: 60,
|
||||
// read COMPRESSION.md
|
||||
zstdCompressionLevel: 10,
|
||||
},
|
||||
}
|
||||
|
||||
export type AppConfig = typeof defaultConfig
|
||||
|
||||
export type SaveUploadConfig = AppConfig['saveUpload']
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Games the parser backend can handle. The values go into the upload request.
|
||||
//TODO: add more games
|
||||
export enum GameEnum {
|
||||
EU4 = 'eu4',
|
||||
Stellaris = 'stellaris',
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the game state gets inside a save archive, per game.
|
||||
*
|
||||
* A save produced by the game is either a zip archive holding this file (plus
|
||||
* companion files like `meta`), or the very same content as plain text. When a
|
||||
* plain text save is uploaded, this is the name it is stored under, so the
|
||||
* backend always reads the 'gamestate' or whatever is set for the specific game.
|
||||
* The parser backend keeps the same map on its side.
|
||||
*/
|
||||
export const GAME_SAVE_ENTRY_NAMES: Record<GameEnum, string> = {
|
||||
[GameEnum.EU4]: 'gamestate',
|
||||
[GameEnum.Stellaris]: 'gamestate',
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { AppConfig, defaultConfig } from '@/config/AppConfig'
|
||||
|
||||
// TODO: get rid of this file and read about configs for next.js. Maybe use .env file where variables NEXT_PUBLIC_* are avaliable from client.
|
||||
const configFilePath = path.join(process.cwd(), 'runtimeConfig.json')
|
||||
|
||||
// Reads runtimeConfig.json (merging it over the defaults) and creates new config file if missing
|
||||
@@ -18,7 +19,7 @@ function loadConfig(): AppConfig {
|
||||
console.log(`creating default config file '${configFilePath}'`)
|
||||
fs.mkdirSync(path.dirname(configFilePath), { recursive: true })
|
||||
}
|
||||
fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 4))
|
||||
fs.writeFileSync(configFilePath, JSON.stringify(config, null, 4))
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Revoking the object url right after the click can cancel the download in
|
||||
// some browsers, so it is kept alive for a while first.
|
||||
const URL_LIFETIME_MS = 20_000
|
||||
|
||||
/**
|
||||
* Makes the browser save a blob to disk under the given name.
|
||||
*/
|
||||
export function downloadBlobAsFile(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
link.click()
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(url), URL_LIFETIME_MS)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const KILOBYTE = 1024
|
||||
export const MEGABYTE = 1024 * KILOBYTE
|
||||
|
||||
/**
|
||||
* Formats a byte count for error messages and status lines, e.g. "12.3 MB".
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= MEGABYTE) return `${(bytes / MEGABYTE).toFixed(1)} MB`
|
||||
if (bytes >= KILOBYTE) return `${(bytes / KILOBYTE).toFixed(1)} KB`
|
||||
return `${bytes} B`
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import localFont from 'next/font/local'
|
||||
import { IBM_Plex_Sans } from 'next/font/google'
|
||||
|
||||
// Loads the Google-hosted IBM Plex Sans font.
|
||||
@@ -9,31 +8,3 @@ export const font_IbmPlexSans = IBM_Plex_Sans({
|
||||
display: 'swap',
|
||||
variable: '--font-ibm-plex-sans',
|
||||
})
|
||||
|
||||
// Loads the local Hack font used by the navbar.
|
||||
export const font_Hack = localFont({
|
||||
src: [
|
||||
{
|
||||
path: '../../public/fonts/hack-v3.003/Hack-Regular.ttf',
|
||||
weight: '400',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: '../../public/fonts/hack-v3.003/Hack-Bold.ttf',
|
||||
weight: '700',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: '../../public/fonts/hack-v3.003/Hack-Italic.ttf',
|
||||
weight: '400',
|
||||
style: 'italic',
|
||||
},
|
||||
{
|
||||
path: '../../public/fonts/hack-v3.003/Hack-BoldItalic.ttf',
|
||||
weight: '700',
|
||||
style: 'italic',
|
||||
},
|
||||
],
|
||||
display: 'swap',
|
||||
variable: '--font-hack',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Converting between the save files a game reads and the archive the parser
|
||||
// backend stores. The tar always holds the files the game itself would have in
|
||||
// its archive, so the backend reads the game state from the same entry either
|
||||
// way, and a download rebuilds whichever shape was uploaded:
|
||||
//
|
||||
// plain text save -> tar { "gamestate" } -> zstd
|
||||
// archived save -> tar { "meta", "gamestate", ... } -> zstd
|
||||
//
|
||||
// Everything runs as a stream: a save is never held in memory as a whole.
|
||||
|
||||
import { createTarDecoder, createTarPacker } from 'modern-tar'
|
||||
import { createGameZipBuilder, readGameZip } from './gameZip'
|
||||
import { OriginalFileType, SaveProgressStage } from './messages'
|
||||
import {
|
||||
createZstdCompressStream,
|
||||
createZstdDecompressStream,
|
||||
} from './zstdStream'
|
||||
|
||||
type StageCallback = (stage: SaveProgressStage) => void
|
||||
|
||||
// A fixed timestamp keeps uploads of the same save byte for byte identical.
|
||||
const TAR_ENTRY_MTIME = new Date(0)
|
||||
|
||||
export interface PackSaveOptions {
|
||||
/** Whether the file is an archive produced by the game. */
|
||||
isZip: boolean
|
||||
/** Name of the single entry used for a plain text save. */
|
||||
entryName: string
|
||||
compressionLevel: number
|
||||
}
|
||||
|
||||
// Collects a stream into a blob, whose storage the browser manages. The blob
|
||||
// takes its type from the response's Content-Type.
|
||||
function collectBlob(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
type: string
|
||||
): Promise<Blob> {
|
||||
return new Response(stream, { headers: { 'Content-Type': type } }).blob()
|
||||
}
|
||||
|
||||
// Writes the files of the save into the tar, in the order the game stored
|
||||
// them. Runs alongside the compression, which consumes the tar as it is built.
|
||||
async function packEntries(
|
||||
file: Blob,
|
||||
options: PackSaveOptions,
|
||||
controller: ReturnType<typeof createTarPacker>['controller']
|
||||
) {
|
||||
try {
|
||||
if (options.isZip) {
|
||||
await readGameZip(file, (entry) =>
|
||||
// Returns writable stream.
|
||||
// Zip decoder decodes file entry stream and pipes it to the tar entry stream.
|
||||
controller.add({
|
||||
name: entry.name,
|
||||
size: entry.size,
|
||||
mtime: TAR_ENTRY_MTIME,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// Writes text file to tar entry
|
||||
await file.stream().pipeTo(
|
||||
controller.add({
|
||||
name: options.entryName,
|
||||
size: file.size,
|
||||
mtime: TAR_ENTRY_MTIME,
|
||||
})
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
controller.finalize()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a save file the user picked into the archive that is uploaded.
|
||||
*/
|
||||
export async function packSave(
|
||||
file: Blob,
|
||||
options: PackSaveOptions,
|
||||
onStage: StageCallback
|
||||
): Promise<Blob> {
|
||||
onStage('compressing')
|
||||
|
||||
const { readable, controller } = createTarPacker()
|
||||
// The tar is written while it is being compressed, so neither side of the
|
||||
// pipe ever holds the whole save.
|
||||
const packed = packEntries(file, options, controller)
|
||||
const compressed = collectBlob(
|
||||
readable.pipeThrough(
|
||||
createZstdCompressStream(options.compressionLevel)
|
||||
),
|
||||
'application/zstd'
|
||||
)
|
||||
|
||||
const [, archive] = await Promise.all([packed, compressed])
|
||||
return archive
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a stored archive back into a file the game can load.
|
||||
*/
|
||||
export async function rebuildSave(
|
||||
archive: Blob,
|
||||
originalFileType: OriginalFileType,
|
||||
onStage: StageCallback
|
||||
): Promise<Blob> {
|
||||
onStage('decompressing')
|
||||
|
||||
const entries = archive
|
||||
.stream()
|
||||
.pipeThrough(createZstdDecompressStream())
|
||||
.pipeThrough(createTarDecoder())
|
||||
.getReader()
|
||||
|
||||
const zipBuilder =
|
||||
originalFileType === 'zip' ? createGameZipBuilder() : null
|
||||
let saveFile: Blob | null = null
|
||||
|
||||
for (;;) {
|
||||
const { done, value: entry } = await entries.read()
|
||||
if (done) break
|
||||
|
||||
if (zipBuilder) {
|
||||
onStage('rebuilding')
|
||||
await zipBuilder.add(entry.header.name, entry.body)
|
||||
} else if (saveFile) {
|
||||
// A plain text save is a single file, so anything after it would
|
||||
// be silently dropped.
|
||||
await entry.body.cancel()
|
||||
} else {
|
||||
saveFile = await collectBlob(entry.body, 'application/octet-stream')
|
||||
}
|
||||
}
|
||||
|
||||
if (zipBuilder) return await zipBuilder.finish()
|
||||
if (!saveFile) throw new Error('Stored save archive is empty')
|
||||
return saveFile
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Main thread side of the save archive worker: checks the file, starts the
|
||||
// worker and reports what it is doing.
|
||||
|
||||
import type { SaveUploadConfig } from '@/config/AppConfig'
|
||||
import { GAME_SAVE_ENTRY_NAMES, GameEnum } from '@/config/games'
|
||||
import { formatBytes, MEGABYTE } from '@/lib/formatBytes'
|
||||
import { isZipFile } from './gameZip'
|
||||
import {
|
||||
OriginalFileType,
|
||||
SaveArchiveRequest,
|
||||
SaveArchiveResponse,
|
||||
SaveProgressStage,
|
||||
} from './messages'
|
||||
|
||||
export type StageCallback = (stage: SaveProgressStage) => void
|
||||
|
||||
export interface PackedSave {
|
||||
data: Blob
|
||||
originalFileType: OriginalFileType
|
||||
originalFileName: string
|
||||
}
|
||||
|
||||
// Runs a single request in a fresh worker, which is terminated once it answers.
|
||||
function runInWorker(
|
||||
request: SaveArchiveRequest,
|
||||
onStage: StageCallback
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
})
|
||||
|
||||
worker.onmessage = (event: MessageEvent<SaveArchiveResponse>) => {
|
||||
const message = event.data
|
||||
if (message.kind === 'progress') {
|
||||
onStage(message.stage)
|
||||
return
|
||||
}
|
||||
|
||||
worker.terminate()
|
||||
if (message.kind === 'error') {
|
||||
reject(new Error(message.message))
|
||||
} else {
|
||||
resolve(message.data)
|
||||
}
|
||||
}
|
||||
|
||||
worker.onerror = (event) => {
|
||||
worker.terminate()
|
||||
reject(new Error(event.message || 'Save file compression failed'))
|
||||
}
|
||||
|
||||
worker.postMessage(request)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a save file the user picked into the archive that is uploaded.
|
||||
*
|
||||
* Plain text and archived saves are both accepted, are told apart by the zip
|
||||
* magic bytes rather than by the file extension, and are checked against their
|
||||
* own size limit.
|
||||
*/
|
||||
export async function packSaveForUpload(
|
||||
game: GameEnum,
|
||||
file: File,
|
||||
config: SaveUploadConfig,
|
||||
onStage: StageCallback
|
||||
): Promise<PackedSave> {
|
||||
const isZip = await isZipFile(file)
|
||||
// The limits are configured in megabytes, file sizes are in bytes.
|
||||
const sizeLimit =
|
||||
(isZip ? config.maxSizeCompressedMB : config.maxSizePlainTextMB) *
|
||||
MEGABYTE
|
||||
|
||||
if (file.size > sizeLimit) {
|
||||
const saveKind = isZip ? 'archived' : 'plain text'
|
||||
throw new Error(
|
||||
`Save file is ${formatBytes(file.size)}, but the limit for ` +
|
||||
`${saveKind} saves is ${formatBytes(sizeLimit)}`
|
||||
)
|
||||
}
|
||||
|
||||
const data = await runInWorker(
|
||||
{
|
||||
kind: 'pack',
|
||||
file,
|
||||
isZip,
|
||||
entryName: GAME_SAVE_ENTRY_NAMES[game],
|
||||
compressionLevel: config.zstdCompressionLevel,
|
||||
},
|
||||
onStage
|
||||
)
|
||||
|
||||
return {
|
||||
data,
|
||||
originalFileType: isZip ? 'zip' : 'text',
|
||||
originalFileName: file.name,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the save file as the game wrote it, out of a stored archive.
|
||||
*/
|
||||
export async function rebuildOriginalSave(
|
||||
archive: Blob,
|
||||
originalFileType: OriginalFileType,
|
||||
onStage: StageCallback
|
||||
): Promise<Blob> {
|
||||
return await runInWorker(
|
||||
{ kind: 'rebuild', data: archive, originalFileType },
|
||||
onStage
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// Reading and writing the zip archives the Paradox games themselves use.
|
||||
//
|
||||
// This is written here rather than taken from a library because the games are
|
||||
// picky about the headers and no browser zip library can produce them: they
|
||||
// all derive the DOS date from a Date and clamp it to 1980, while the games
|
||||
// write zeros, and the streaming writers emit a data descriptor plus a Zip64
|
||||
// extra field for every entry, which the games reject. The heavy part of zip,
|
||||
// deflate, is in the browser itself as CompressionStream('deflate-raw'), so
|
||||
// what is left is the header layout below.
|
||||
//
|
||||
// The field values follow a known good reference, which produces archives the
|
||||
// games accept: http://pastebin.com/nyX1B4rH
|
||||
// A real EU4 save agrees with it: MS-DOS as the system of origin, encoding
|
||||
// software 6.3, extract version 2.0, no data descriptor, no extra field and a
|
||||
// zeroed DOS date. 7-Zip's NTFS timestamps are a known way to produce a save
|
||||
// that will not load: https://stellaris.paradoxwikis.com/Save-game_editing
|
||||
|
||||
const LOCAL_HEADER_SIGNATURE = 0x04034b50
|
||||
const CENTRAL_HEADER_SIGNATURE = 0x02014b50
|
||||
const END_OF_CENTRAL_DIR_SIGNATURE = 0x06054b50
|
||||
|
||||
const LOCAL_HEADER_SIZE = 30
|
||||
const CENTRAL_HEADER_SIZE = 46
|
||||
const END_OF_CENTRAL_DIR_SIZE = 22
|
||||
|
||||
const METHOD_STORED = 0
|
||||
const METHOD_DEFLATE = 8
|
||||
|
||||
// "version needed to extract" 2.0, which is what deflate requires.
|
||||
const EXTRACT_VERSION = 20
|
||||
// "version made by": MS-DOS (0) in the high byte, 6.3 (63) in the low byte.
|
||||
const CREATE_VERSION = (0 << 8) | 63
|
||||
|
||||
// Anything at or above this needs the zip64 format, which no save comes near.
|
||||
const MAX_ENTRY_SIZE = 0xffffffff
|
||||
|
||||
// First bytes of every zip archive.
|
||||
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]
|
||||
|
||||
export interface GameZipEntry {
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
// The standard CRC-32 lookup table.
|
||||
const CRC_TABLE = new Uint32Array(256).map((_, i) => {
|
||||
let value = i
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
|
||||
}
|
||||
return value >>> 0
|
||||
})
|
||||
|
||||
// Continues a CRC-32 over one more chunk. Start with 0; the result is the
|
||||
// checksum of everything so far, so an entry is summed as it streams past.
|
||||
function updateCrc(crc: number, data: Uint8Array): number {
|
||||
let value = ~crc
|
||||
for (const byte of data) {
|
||||
value = CRC_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8)
|
||||
}
|
||||
return ~value >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells whether the file is a zip archive, by its magic bytes rather than by
|
||||
* its extension: the games use the same extension for both an archived and a
|
||||
* plain text save.
|
||||
*/
|
||||
export async function isZipFile(file: Blob): Promise<boolean> {
|
||||
const header = new Uint8Array(
|
||||
await file.slice(0, ZIP_MAGIC.length).arrayBuffer()
|
||||
)
|
||||
return ZIP_MAGIC.every((byte, i) => header[i] === byte)
|
||||
}
|
||||
|
||||
// Little endian reader over a slice of the archive.
|
||||
function reader(data: Uint8Array) {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
return {
|
||||
u16: (offset: number) => view.getUint16(offset, true),
|
||||
u32: (offset: number) => view.getUint32(offset, true),
|
||||
}
|
||||
}
|
||||
|
||||
interface CentralEntry {
|
||||
name: string
|
||||
method: number
|
||||
compressedSize: number
|
||||
size: number
|
||||
localHeaderOffset: number
|
||||
}
|
||||
|
||||
// Reads the central directory, which lists every entry with its size and
|
||||
// position. Only the last few hundred bytes of the archive are touched.
|
||||
async function readCentralDirectory(zip: Blob): Promise<CentralEntry[]> {
|
||||
const end = new Uint8Array(
|
||||
await zip.slice(zip.size - END_OF_CENTRAL_DIR_SIZE).arrayBuffer()
|
||||
)
|
||||
const endFields = reader(end)
|
||||
if (endFields.u32(0) !== END_OF_CENTRAL_DIR_SIGNATURE) {
|
||||
throw new Error('Save archive has no end of central directory')
|
||||
}
|
||||
|
||||
const entryCount = endFields.u16(10)
|
||||
const directoryOffset = endFields.u32(16)
|
||||
const directory = new Uint8Array(
|
||||
await zip.slice(directoryOffset, zip.size).arrayBuffer()
|
||||
)
|
||||
const fields = reader(directory)
|
||||
|
||||
const entries: CentralEntry[] = []
|
||||
let offset = 0
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
if (fields.u32(offset) !== CENTRAL_HEADER_SIGNATURE) {
|
||||
throw new Error('Save archive has a corrupted entry header')
|
||||
}
|
||||
|
||||
const nameLength = fields.u16(offset + 28)
|
||||
const extraLength = fields.u16(offset + 30)
|
||||
const commentLength = fields.u16(offset + 32)
|
||||
const nameStart = offset + CENTRAL_HEADER_SIZE
|
||||
entries.push({
|
||||
name: new TextDecoder().decode(
|
||||
directory.subarray(nameStart, nameStart + nameLength)
|
||||
),
|
||||
method: fields.u16(offset + 10),
|
||||
compressedSize: fields.u32(offset + 20),
|
||||
size: fields.u32(offset + 24),
|
||||
localHeaderOffset: fields.u32(offset + 42),
|
||||
})
|
||||
offset += CENTRAL_HEADER_SIZE + nameLength + extraLength + commentLength
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every file of a save archive in the order they are stored, asking the
|
||||
* caller where each one should go and writing it straight into that stream.
|
||||
*
|
||||
* Sizes come from the central directory, so they are known before an entry
|
||||
* starts streaming, and the entry data itself is never held in memory.
|
||||
*/
|
||||
export async function readGameZip(
|
||||
zip: Blob,
|
||||
openEntry: (entry: GameZipEntry) => WritableStream<Uint8Array>
|
||||
): Promise<void> {
|
||||
for (const entry of await readCentralDirectory(zip)) {
|
||||
// The local header repeats the name and may carry a different extra
|
||||
// field, so the data can only be located through it.
|
||||
const header = new Uint8Array(
|
||||
await zip
|
||||
.slice(
|
||||
entry.localHeaderOffset,
|
||||
entry.localHeaderOffset + LOCAL_HEADER_SIZE
|
||||
)
|
||||
.arrayBuffer()
|
||||
)
|
||||
const fields = reader(header)
|
||||
if (fields.u32(0) !== LOCAL_HEADER_SIGNATURE) {
|
||||
throw new Error(`Save archive entry "${entry.name}" is corrupted`)
|
||||
}
|
||||
|
||||
const dataStart =
|
||||
entry.localHeaderOffset +
|
||||
LOCAL_HEADER_SIZE +
|
||||
fields.u16(26) +
|
||||
fields.u16(28)
|
||||
const data = zip.slice(dataStart, dataStart + entry.compressedSize)
|
||||
|
||||
if (entry.method === METHOD_STORED) {
|
||||
await data.stream().pipeTo(openEntry(entry))
|
||||
} else if (entry.method === METHOD_DEFLATE) {
|
||||
await data
|
||||
.stream()
|
||||
.pipeThrough(new DecompressionStream('deflate-raw'))
|
||||
.pipeTo(openEntry(entry))
|
||||
} else {
|
||||
throw new Error(
|
||||
`Save archive entry "${entry.name}" uses compression ` +
|
||||
`method ${entry.method}, which the games do not produce`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Writes a local file header. The date and time fields stay zero, which is
|
||||
// what the games write and what a Date cannot express.
|
||||
function localHeader(entry: WrittenEntry): Uint8Array<ArrayBuffer> {
|
||||
const header = new Uint8Array(LOCAL_HEADER_SIZE + entry.nameBytes.length)
|
||||
const view = new DataView(header.buffer)
|
||||
view.setUint32(0, LOCAL_HEADER_SIGNATURE, true)
|
||||
view.setUint16(4, EXTRACT_VERSION, true)
|
||||
view.setUint16(8, METHOD_DEFLATE, true)
|
||||
view.setUint32(14, entry.crc, true)
|
||||
view.setUint32(18, entry.compressedSize, true)
|
||||
view.setUint32(22, entry.size, true)
|
||||
view.setUint16(26, entry.nameBytes.length, true)
|
||||
header.set(entry.nameBytes, LOCAL_HEADER_SIZE)
|
||||
return header
|
||||
}
|
||||
|
||||
// Writes the central directory record for one entry.
|
||||
function centralHeader(entry: WrittenEntry): Uint8Array<ArrayBuffer> {
|
||||
const header = new Uint8Array(CENTRAL_HEADER_SIZE + entry.nameBytes.length)
|
||||
const view = new DataView(header.buffer)
|
||||
view.setUint32(0, CENTRAL_HEADER_SIGNATURE, true)
|
||||
view.setUint16(4, CREATE_VERSION, true)
|
||||
view.setUint16(6, EXTRACT_VERSION, true)
|
||||
view.setUint16(10, METHOD_DEFLATE, true)
|
||||
view.setUint32(16, entry.crc, true)
|
||||
view.setUint32(20, entry.compressedSize, true)
|
||||
view.setUint32(24, entry.size, true)
|
||||
view.setUint16(28, entry.nameBytes.length, true)
|
||||
view.setUint32(42, entry.localHeaderOffset, true)
|
||||
header.set(entry.nameBytes, CENTRAL_HEADER_SIZE)
|
||||
return header
|
||||
}
|
||||
|
||||
// Writes the end of central directory record that closes the archive.
|
||||
function endOfCentralDirectory(
|
||||
entries: WrittenEntry[],
|
||||
directoryOffset: number,
|
||||
directorySize: number
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
const record = new Uint8Array(END_OF_CENTRAL_DIR_SIZE)
|
||||
const view = new DataView(record.buffer)
|
||||
view.setUint32(0, END_OF_CENTRAL_DIR_SIGNATURE, true)
|
||||
view.setUint16(8, entries.length, true)
|
||||
view.setUint16(10, entries.length, true)
|
||||
view.setUint32(12, directorySize, true)
|
||||
view.setUint32(16, directoryOffset, true)
|
||||
return record
|
||||
}
|
||||
|
||||
interface WrittenEntry {
|
||||
nameBytes: Uint8Array
|
||||
crc: number
|
||||
size: number
|
||||
compressedSize: number
|
||||
localHeaderOffset: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a save archive the games accept, one streamed file at a time.
|
||||
*/
|
||||
export function createGameZipBuilder() {
|
||||
const entries: WrittenEntry[] = []
|
||||
// Headers are plain bytes and entry data is a blob, so assembling the
|
||||
// archive at the end copies nothing through memory.
|
||||
const parts: BlobPart[] = []
|
||||
let offset = 0
|
||||
|
||||
return {
|
||||
async add(name: string, body: ReadableStream<Uint8Array>) {
|
||||
const nameBytes = new TextEncoder().encode(name)
|
||||
let crc = 0
|
||||
let size = 0
|
||||
|
||||
// Sums the entry as it goes past. The cast only drops the shared
|
||||
// buffer case from the type; a save never arrives in one.
|
||||
const summed = body.pipeThrough(
|
||||
new TransformStream<Uint8Array, Uint8Array<ArrayBuffer>>({
|
||||
transform(chunk, controller) {
|
||||
crc = updateCrc(crc, chunk)
|
||||
size += chunk.length
|
||||
controller.enqueue(chunk as Uint8Array<ArrayBuffer>)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// The sizes and the checksum belong in the local header, which is
|
||||
// written before the data, so the entry is deflated first. Only
|
||||
// the compressed bytes are kept, and in a blob rather than in the
|
||||
// javascript heap.
|
||||
const compressed = await new Response(
|
||||
summed.pipeThrough(new CompressionStream('deflate-raw'))
|
||||
).blob()
|
||||
|
||||
if (size > MAX_ENTRY_SIZE) {
|
||||
throw new Error(`Save file "${name}" is too large for a zip`)
|
||||
}
|
||||
|
||||
const entry: WrittenEntry = {
|
||||
nameBytes,
|
||||
crc,
|
||||
size,
|
||||
compressedSize: compressed.size,
|
||||
localHeaderOffset: offset,
|
||||
}
|
||||
entries.push(entry)
|
||||
|
||||
const header = localHeader(entry)
|
||||
parts.push(header, compressed)
|
||||
offset += header.length + compressed.size
|
||||
},
|
||||
|
||||
async finish(): Promise<Blob> {
|
||||
const directory = entries.map(centralHeader)
|
||||
const directorySize = directory.reduce(
|
||||
(total, header) => total + header.length,
|
||||
0
|
||||
)
|
||||
return new Blob(
|
||||
[
|
||||
...parts,
|
||||
...directory,
|
||||
endOfCentralDirectory(entries, offset, directorySize),
|
||||
],
|
||||
{ type: 'application/zip' }
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Types shared between the save archive worker and the code that drives it.
|
||||
// This module deliberately imports nothing, so importing it never pulls the
|
||||
// compression code into a bundle.
|
||||
|
||||
/** Shape the user originally uploaded, and the shape a download rebuilds. */
|
||||
export type OriginalFileType = 'text' | 'zip'
|
||||
|
||||
/**
|
||||
* Steps worth showing to the user, since each of them takes seconds on a big
|
||||
* save. The worker reports the ones it runs itself; uploading and downloading
|
||||
* are reported by the service around it.
|
||||
*/
|
||||
export type SaveProgressStage =
|
||||
'compressing' | 'uploading' | 'downloading' | 'decompressing' | 'rebuilding'
|
||||
|
||||
export type SaveArchiveRequest =
|
||||
| {
|
||||
kind: 'pack'
|
||||
file: File
|
||||
isZip: boolean
|
||||
entryName: string
|
||||
compressionLevel: number
|
||||
}
|
||||
| {
|
||||
kind: 'rebuild'
|
||||
data: Blob
|
||||
originalFileType: OriginalFileType
|
||||
}
|
||||
|
||||
export type SaveArchiveResponse =
|
||||
// Blobs are handles to storage the browser manages, so passing one between
|
||||
// threads copies nothing.
|
||||
| { kind: 'progress'; stage: SaveProgressStage }
|
||||
| { kind: 'done'; data: Blob }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
/** Text shown while a stage is running. */
|
||||
export const SAVE_PROGRESS_STAGE_LABELS: Record<SaveProgressStage, string> = {
|
||||
compressing: 'Compressing save file...',
|
||||
uploading: 'Uploading save file...',
|
||||
downloading: 'Downloading save file...',
|
||||
decompressing: 'Decompressing save file...',
|
||||
rebuilding: 'Rebuilding save file...',
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Worker that packs and rebuilds save archives.
|
||||
//
|
||||
// Compressing a save is seconds of solid cpu work, so on the main thread it
|
||||
// would freeze the page long enough for the browser to offer to kill it.
|
||||
// Keeping the compression code here also keeps it out of every other bundle,
|
||||
// including the 534 kB zstd wasm.
|
||||
|
||||
import { getMsgFromError } from '@/lib/errors'
|
||||
import { packSave, rebuildSave } from './archive'
|
||||
import {
|
||||
SaveArchiveRequest,
|
||||
SaveArchiveResponse,
|
||||
SaveProgressStage,
|
||||
} from './messages'
|
||||
|
||||
// The part of the worker global scope this file uses. It is declared here
|
||||
// because the project compiles against the dom types, where `self` is a window
|
||||
// whose postMessage takes a target origin; the worker types live in a lib that
|
||||
// cannot be loaded alongside them. Naming the message types also checks both
|
||||
// ends of the protocol, which DedicatedWorkerGlobalScope would leave as `any`.
|
||||
interface SaveArchiveWorkerScope {
|
||||
postMessage(message: SaveArchiveResponse, transfer?: Transferable[]): void
|
||||
addEventListener(
|
||||
type: 'message',
|
||||
listener: (event: MessageEvent<SaveArchiveRequest>) => void
|
||||
): void
|
||||
}
|
||||
|
||||
const workerScope = self as unknown as SaveArchiveWorkerScope
|
||||
|
||||
function reportStage(stage: SaveProgressStage) {
|
||||
workerScope.postMessage({ kind: 'progress', stage })
|
||||
}
|
||||
|
||||
// Runs one request and answers with its result or with the error it failed on.
|
||||
async function handleRequest(request: SaveArchiveRequest) {
|
||||
try {
|
||||
const result =
|
||||
request.kind === 'pack'
|
||||
? await packSave(
|
||||
request.file,
|
||||
{
|
||||
isZip: request.isZip,
|
||||
entryName: request.entryName,
|
||||
compressionLevel: request.compressionLevel,
|
||||
},
|
||||
reportStage
|
||||
)
|
||||
: await rebuildSave(
|
||||
request.data,
|
||||
request.originalFileType,
|
||||
reportStage
|
||||
)
|
||||
|
||||
workerScope.postMessage({ kind: 'done', data: result })
|
||||
} catch (err) {
|
||||
workerScope.postMessage({
|
||||
kind: 'error',
|
||||
message: getMsgFromError(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
workerScope.addEventListener('message', (event) => {
|
||||
void handleRequest(event.data)
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
// Zstandard compression and decompression as stream transforms.
|
||||
//
|
||||
// Saves are re-compressed in the browser because the parser backend runs on a
|
||||
// cheap two core server with little disk space. zstd was picked over lzma2 and
|
||||
// brotli because its decompression speed does not depend on the level the data
|
||||
// was compressed with, so the level can be raised without costing the server
|
||||
// anything, and because its wasm build is by far the smallest of the three.
|
||||
//
|
||||
// @tootallnate/zstd-wasm does the work: it is upstream zstd 1.5.7 built with
|
||||
// wasi-sdk and driven through ZSTD_compressStream2, so it compresses a stream
|
||||
// as it arrives rather than a buffer at a time. A save of any size comes out
|
||||
// as one zstd frame, and memory stays at the library's own input and output
|
||||
// buffers.
|
||||
|
||||
import {
|
||||
ZstdCompressStream,
|
||||
ZstdDecompressStream,
|
||||
} from '@tootallnate/zstd-wasm'
|
||||
|
||||
// The wasm file ships inside the package. Turbopack rewrites this into the URL
|
||||
// it emits the asset at, so the 534 kB binary is fetched once at runtime and
|
||||
// cached by the browser instead of being inlined into the worker bundle.
|
||||
const wasmUrl = new URL(
|
||||
'../../../node_modules/@tootallnate/zstd-wasm/dist/zstd.wasm',
|
||||
import.meta.url
|
||||
)
|
||||
|
||||
// Compiling takes a moment, so it happens once and both directions share it.
|
||||
let wasmModule: Promise<WebAssembly.Module> | null = null
|
||||
|
||||
function getWasm(): Promise<WebAssembly.Module> {
|
||||
// compileStreaming would need the server to send Content-Type:
|
||||
// application/wasm, which is not worth depending on.
|
||||
wasmModule ??= fetch(wasmUrl)
|
||||
.then((res) => res.arrayBuffer())
|
||||
.then((bytes) => WebAssembly.compile(bytes))
|
||||
return wasmModule
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a stream into a single zstd frame. The level goes from 1 to 22.
|
||||
*/
|
||||
export function createZstdCompressStream(
|
||||
compressionLevel: number
|
||||
): TransformStream<Uint8Array, Uint8Array> {
|
||||
return new ZstdCompressStream(getWasm(), compressionLevel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompresses a zstd stream. Frames that follow one another are read straight
|
||||
* through, so an archive packed as several frames still opens.
|
||||
*/
|
||||
export function createZstdDecompressStream(): TransformStream<
|
||||
Uint8Array,
|
||||
Uint8Array
|
||||
> {
|
||||
return new ZstdDecompressStream(getWasm())
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
//TODO: controllable rate limits
|
||||
//TODO: replace this whole service with Discord OAuth. The target audience
|
||||
// already uses Discord, so email/password login is not worth building. The
|
||||
// /api/auth/login and /api/auth/register routes below are never implemented —
|
||||
// delete them along with this service.
|
||||
|
||||
class AuthService {
|
||||
private API_BASE = '/api/auth'
|
||||
|
||||
// Hashes the password before sending it to the backend
|
||||
private hashPassword(password: string) {
|
||||
// TODO: password hashing
|
||||
return password
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { PROXY_PATHS } from '@/app/api/proxy/paths'
|
||||
import type { SaveUploadConfig } from '@/config/AppConfig'
|
||||
import { GameEnum } from '@/config/games'
|
||||
import { packSaveForUpload, StageCallback } from '@/lib/saveArchive/client'
|
||||
import { OriginalFileType } from '@/lib/saveArchive/messages'
|
||||
import { createZstdDecompressStream } from '@/lib/saveArchive/zstdStream'
|
||||
|
||||
export interface UploadSaveResponse {
|
||||
id: string
|
||||
@@ -9,6 +14,10 @@ export interface SaveStatusResponse {
|
||||
game: string
|
||||
status: string
|
||||
uploadDateTime: string
|
||||
// Shape the save had when it was uploaded, needed to rebuild a file the
|
||||
// game can load again.
|
||||
originalFileType: OriginalFileType
|
||||
originalFileName: string
|
||||
}
|
||||
|
||||
// Tries to read the response body as string, returning an empty string if that fails.
|
||||
@@ -23,16 +32,34 @@ async function getBodyTextOrEmpty(res: Response): Promise<string> {
|
||||
export class SaveParserBackendService {
|
||||
private API_BASE = PROXY_PATHS.saveParserBackend
|
||||
|
||||
//TODO: save size limit 30 mb
|
||||
// Uploads a save file for the given game and returns its assigned id.
|
||||
async uploadSave(game: string, file: File): Promise<UploadSaveResponse> {
|
||||
const url = `${this.API_BASE}/uploadSave?game=${encodeURIComponent(game)}`
|
||||
/**
|
||||
* Re-compresses a save file and uploads it, returning its assigned id.
|
||||
*
|
||||
* Saves are not uploaded as the game wrote them. Plain text and archived
|
||||
* saves are both packed into a tar inside a zstd frame, which is a lot
|
||||
* smaller than the game's own zip and cheap for the backend to unpack.
|
||||
* The shape the file originally had travels along, so it can be rebuilt
|
||||
* when someone downloads the save later.
|
||||
*/
|
||||
async uploadSave(
|
||||
game: GameEnum,
|
||||
file: File,
|
||||
config: SaveUploadConfig,
|
||||
onStage: StageCallback
|
||||
): Promise<UploadSaveResponse> {
|
||||
const save = await packSaveForUpload(game, file, config, onStage)
|
||||
onStage('uploading')
|
||||
|
||||
const url =
|
||||
`${this.API_BASE}/uploadSave?game=${encodeURIComponent(game)}` +
|
||||
`&originalFileType=${encodeURIComponent(save.originalFileType)}` +
|
||||
`&originalFileName=${encodeURIComponent(save.originalFileName)}`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Type': 'application/zstd',
|
||||
},
|
||||
body: file,
|
||||
body: save.data,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -60,9 +87,14 @@ export class SaveParserBackendService {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// Fetches the fully parsed data for a save once its parsing is done.
|
||||
async getParsedSave(id: string): Promise<Record<string, unknown>> {
|
||||
const url = `${this.API_BASE}/getParsedSave?id=${encodeURIComponent(id)}`
|
||||
/**
|
||||
* Fetches the fully parsed data for a save once its parsing is done.
|
||||
*
|
||||
* The backend stores and serves that data as zstd compressed json, so the
|
||||
* body is decompressed on the way in rather than read as text first.
|
||||
*/
|
||||
async getSaveData(id: string): Promise<Record<string, unknown>> {
|
||||
const url = `${this.API_BASE}/getSaveData?id=${encodeURIComponent(id)}`
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -71,8 +103,34 @@ export class SaveParserBackendService {
|
||||
(await getBodyTextOrEmpty(response))
|
||||
)
|
||||
}
|
||||
// A body is only missing on a response that cannot carry one, which a
|
||||
// successful getSaveData never is.
|
||||
if (!response.body) {
|
||||
throw new Error('Failed to get parsed save: response has no body')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
// Decompresses as the bytes arrive, so the compressed json is never
|
||||
// held whole; only the parsed object stays in memory.
|
||||
const json = response.body.pipeThrough(createZstdDecompressStream())
|
||||
return await new Response(json).json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the stored save archive, exactly as it was uploaded. Turning it
|
||||
* back into a file the game can load is done in the browser.
|
||||
*/
|
||||
async getSaveFile(id: string): Promise<Blob> {
|
||||
const url = `${this.API_BASE}/getSaveFile?id=${encodeURIComponent(id)}`
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get save file: ${response.statusText} ` +
|
||||
(await getBodyTextOrEmpty(response))
|
||||
)
|
||||
}
|
||||
|
||||
return await response.blob()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -29,5 +29,5 @@
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "TEMP"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user