Contributing and reference6 of 12
The build pipeline
On this page
pip install kotoba-companion gives you a working web app without Node ever being on the machine. That
works because the compiled frontend is copied inside the Python package before the wheel is built.
Two files do it:
| File | Job |
|---|---|
scripts/build_web.py | runs Node, checks the output, copies it in, stamps it |
api/kotoba_build.py | the PEP 517 build backend; refuses to package a build that is absent or stale |
api/kotoba_build.py never runs npm. That is the whole point: installing the package must not need
Node.
python scripts/build_web.pywas not run while writing this page — it rewritesapi/src/kotoba/web/. Its individual checks were run against the build already in the tree, and the numbers below are what they returned.
What build_web.py does, in order
Before it starts Node, three refusals:
- A stray env file at the repository root.
.env,.env.local,.env.production,.env.production.local,.env.development,.env.development.local. Next inlines anyNEXT_PUBLIC_*it finds, and those values would then ship to everyone who installs the wheel. - A Live2D model under
public/. Any.moc3or.model3.json. It walks symlinked directories too, because a model linked intopublic/was invisible to a plain glob while the build packaged it anyway. A directory it cannot read raises rather than being skipped — a licence guard must not fail open. - No
npmon PATH.
Then it builds. npm run build runs with every NEXT_PUBLIC_* stripped from the environment, plus
DOCKER_BUILD and KOTOBA_BACKEND_URL, and with KOTOBA_STATIC_EXPORT=1 and
NEXT_TELEMETRY_DISABLED=1 set. The static export lands in .next-export/.
Then it checks what came out:
- All four pages exist:
app.html,login.html,setup.html,404.html. - There is at least one
.jsunder_next/static. - The stranger scanner finds nothing (below).
- The metadata scanner finds nothing (below).
Then it copies and stamps. .next-export/ is copied to api/src/kotoba/web/, api/build/ is
cleared, and kotoba-build.json is written:
{
"version": "0.10.0",
"files": 69,
"web_sha256": "…",
"sources_sha256": "…"
}
The digest stamp, and what "stale" means
The stamp answers two different questions.
web_sha256andfiles— is the build intact. Hashed over every file inweb/except the stamp itself, which cannot cover itself.sources_sha256— has the frontend moved on since. Hashed overapp,components,lib,public,next.config.ts,tsconfig.json,global.d.ts,package.jsonandpackage-lock.json, skipping any__tests__directory and all ofpublic/models.
That second one is the one that actually ships broken: the build is perfectly intact, and out of date.
Both digests hash in order of the path text, not by sorting Path objects. PurePath compares
case-folded on Windows and plain everywhere else, so an upper-case sibling changes place — and a digest
written on one platform then failed to verify on another. This project ships on both.
Measured against the build currently in the tree:
stamp version : 0.10.0 package: 0.10.0
stamp files : 69 counted: 69 match
web digest : match
sources digest: match
The scanner that refuses to ship your machine
This is the unusual one, and it is worth knowing about even if you never touch packaging.
next build inlines whatever it finds. A NEXT_PUBLIC_* variable, an address in a config, a tunnel URL
from yesterday's testing — all of it gets frozen into a minified chunk, where one string looks like any
other. Then it goes to PyPI and installs on everyone's machine. Nobody notices, because nothing is
broken; the build simply carries the address of the computer that made it.
So before anything is copied, every file in the export is read and judged.
It calibrates itself to whoever runs it. builder_marks() asks the operating system for this
machine's hostname, its fully-qualified name, the current username and the home directory — at scan
time, so nothing has to be written down and no name ever appears in the repository. Names shorter than
five characters are dropped, as are the 36 names too generic to mean anything (user, runner, ci,
ubuntu, docker, github, and so on). On this machine it found 3 marks.
What it refuses, checked in this order:
| Shape | Example |
|---|---|
| An IPv4 or IPv6 literal | http://192.168.1.42:8000 |
| A loopback host in a URL | ws://localhost:9777 |
| A private-network name | desktop-abc.lan, anything ending .local, .internal, .home.arpa, .corp, … |
| A tunnel hostname | 23 domains, including ngrok, cloudflared, localtunnel, serveo, zrok, bore |
| The builder's own name | whatever builder_marks() found |
| A home directory | /home/…, /Users/…, C:\Users\… |
| A source map | a .map file, or a sourceMappingURL comment |
| A voice agent id | agent_… |
| Any host not on the allowlist | anything else with a dot and a known TLD |
The order is load-bearing. Every shape that can only be a private machine is judged before the
allowlist is consulted, so putting something.lan on the allowlist would not let it through.
The allowlist itself is short and every entry has a reason written beside it: www.w3.org for XML
namespaces, react.dev for React's minified error decoder, cdn.jsdelivr.net for a worklet the voice
SDK fetches at runtime, api.openai.com for the placeholder shown in Settings, and a handful more.
It reads more than plain text. Before matching, it un-escapes JavaScript's \/ and \u002f,
decodes %3A-style percent escapes, and decodes every base64-looking blob it finds and scans that too.
It also scans the file names, not only the contents.
A second scanner reads image metadata. PNG, WebP and JPEG files are walked chunk by chunk and any chunk outside the known-good set is reported — EXIF, ICC profiles, XMP, comments. SVG files are checked for editor signatures and RDF metadata blocks. A camera's EXIF or an editor's author field travels with every install otherwise.
Measured against the build in the tree: 0 hits, 0 metadata findings.
And measured firing, against a planted sample:
/home/somebody (home path)
192.168.1.42:8000 (ip literal, url)
desktop-abc.lan:3000 (private name, url)
localhost:9777 (loopback, url)
silly-name.ngrok-free.app (tunnel, url)
sourceMappingURL (source map)
If the scanner stops you, do not add an allowlist entry to make it quiet. Find where the value came from — it is almost always an env file or a shell variable that Next read.
The wheel refuses a stale build
api/kotoba_build.py wraps setuptools. On build_wheel and build_sdist it:
- Mirrors the licences.
LICENSEandTHIRD_PARTY_NOTICES.mdare copied from the repository root intoapi/, becausepyproject.toml'slicense-filescannot reach outside its own directory. The copies are not kept in the repository — they would drift — so a fresh checkout has none, and without this step the wheel would ship with no licence at all. It refuses outright if neither the original nor a copy exists. - Verifies the packaged web UI through
api/_web_check.py, which reports every problem in the words the person who has to fix it needs: missing pages, no JavaScript, no stamp, a stamp for another version, a digest that no longer matches, or a frontend that moved on since the build. Then:build it first: python scripts/build_web.py (needs Node). - Clears
api/build/lib. setuptools copies there and never cleans it, so an in-tree wheel would otherwise carry every file of every earlier build — renamed chunks, modules deleted months ago. Nothing downstream would notice, because every other check reads the source tree, not the staging.
build_editable is different on purpose: it mirrors the licences and does not require the frontend
build, because a contributor runs the dev server. That is why pip install -e works on a fresh clone
with no Node at all.
api/_web_check.py lives beside the build backend rather than inside it, so it can be exercised
where setuptools is absent. A guard nobody has ever seen refuse is not a guard.
The tests that cover this
pytest api/tests/test_the_wheel_carries_the_web_ui.py -q # measured: 24 passed
pytest api/tests/test_packaged_data.py -q # measured: part of 20 passed
The first skips its build-reading tests when no build exists, so a clone stays usable. Setting
KOTOBA_REQUIRE_WEB=1 turns that skip into a failure — see continuous
integration.
