Files
paradox-save-parser-frontend/AGENTS.md
T
2026-09-23 18:24:45 +02:00

365 lines
18 KiB
Markdown

# AGENTS.md
Guidance for AI coding agents working in this repository.
## Project
Next.js frontend for a Paradox save file parser. It uploads a game save file to a
backend service, polls until parsing finishes, and displays the result in a
grid.
- **Next.js 16** (App Router, Turbopack) with **React 19**
- **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/`:
- `app/` for routes,
- `components/` for shared UI
- `services/` for backend calls
- `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
MinGW/Git Bash installation, for example `D:\msys2\usr\bin\bash.exe`.
**Never use `C:\Windows\System32\bash.exe`.** Despite its name it is not a
POSIX bash; it is the launcher stub for WSL. Do not start WSL to run ordinary
commands.
If no MSYS/Cygwin/MinGW bash is installed, **use PowerShell** as the terminal.
PowerShell is the fallback, never WSL.
If bash reports `command not found` for `node` or `npm`, that is a `PATH`
problem rather than a missing install: MSYS2 defaults `MSYS2_PATH_TYPE` to
`minimal`, which discards the Windows `PATH`. Setting it to `inherit` fixes it.
## Commands
```sh
npm run dev # dev server
npm run build # production build
npm run lint # eslint
npm run format # prettier, rewrites everything except .gitignore/.prettierignore
npx tsc --noEmit # typecheck
```
### Checking your work
**Always run `npm run lint` and `npm run build` after changing code.** They are
the primary way to find errors in this project, and they catch different
things:
- `npm run lint` reports code errors and style violations. It must finish with
zero problems — no errors and no warnings.
- `npm run build` catches type errors and build-time problems that lint cannot
see, such as Node APIs leaking into the edge runtime. It must exit cleanly
and print no warnings.
`npx tsc --noEmit` is a faster way to check types alone while iterating, but it
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**.
**Read-only git commands are fine to use without asking.**
`git status`, `git log`, `git diff`, `git show`, `git blame` and similar inspection commands
may be run freely.
**Never run a git command that changes the repository without the user's
explicit permission.**
This is a strict ban, and it covers everything that writes to the history, the index,
or the working tree including `add`, `commit`, `checkout`, `restore`, `reset`,
`stash`, `branch`, `merge`, `rebase`, `push`, `rm`, etc.
Ask first and wait for a clear yes before running such git command.
## Conventions
Prettier settings live in `.prettierrc` and are not negotiable: **4 spaces, no
semicolons, single quotes, 80 column width**. Run `npm run format` after
editing, or match the surrounding style exactly.
Import from within `src/` using the `@/` alias, for example
`import { getMsgFromError } from '@/lib/errors'`.
Catch blocks should not annotate the error. Let it be `unknown` and convert it
with the shared helper:
```ts
} catch (err) {
const errMsg = getMsgFromError(err)
...
}
```
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.**
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
`process.env.NEXT_RUNTIME === 'nodejs'` guard, or the build warns and pulls
Node-only code into the edge bundle.