352 lines
17 KiB
Markdown
352 lines
17 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`, `getParsedData` and `getSaveFile` — upload, poll until
|
|
parsing finishes, then fetch the result or the stored save back.
|
|
- [src/app/api/proxy/saveParserBackend/[[...path]]/route.ts](src/app/api/proxy/saveParserBackend/[[...path]]/route.ts)
|
|
forwards every method to the configured backend URL. It exists only to keep
|
|
requests same-origin, and is meant to be deleted once the backend handles
|
|
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.
|
|
|
|
[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.
|
|
|
|
## 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.
|