mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-29 13:19:35 +00:00
ui, plan: guard web asset and module name collisions
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
// Node's own modules only: this project is capped at one devDependency, tsc.
|
||||
|
||||
import {execFileSync} from "node:child_process"
|
||||
import {cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"
|
||||
import {cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"
|
||||
import {dirname, join, posix, relative, resolve, sep} from "node:path"
|
||||
import {fileURLToPath} from "node:url"
|
||||
|
||||
@@ -26,6 +26,10 @@ const DEV_HTML = "dev.html"
|
||||
// The only token that names something other than a file. The service reads the
|
||||
// real value from [web] support_contact; dev.html has no configuration to read.
|
||||
const NON_FILE_TOKENS = new Map([["support_contact", "https://example.invalid/dev-support-contact"]])
|
||||
// A token names a served file by its path relative to dist/, which for a
|
||||
// nested asset is `img/x.svg` — a `/` this pattern does not accept, so nothing
|
||||
// can name one. Harmless while every asset is flat; D4's server-side resolver
|
||||
// must keep the same key and the same charset or the two will disagree.
|
||||
const TOKEN_RE = /@@([\w.-]+)@@/g
|
||||
// Relative import specifiers in the emitted modules: `from "./x.js"`,
|
||||
// `import "./x.js"` and `import("./x.js")`. Textual, so a specifier-shaped
|
||||
@@ -33,29 +37,56 @@ const TOKEN_RE = /@@([\w.-]+)@@/g
|
||||
// reports a path that does not exist, which is worth knowing either way.
|
||||
const IMPORT_RES = [/\bfrom\s*["'](\.[^"']*)["']/g, /^\s*import\s*["'](\.[^"']*)["']/gm, /\bimport\s*\(\s*["'](\.[^"']*)["']\s*\)/g]
|
||||
|
||||
// Rebuild from empty, so that a deleted module or asset also leaves dist/ and
|
||||
// the committed build stays a function of the sources alone.
|
||||
rmSync(distDir, {recursive: true, force: true})
|
||||
|
||||
compile()
|
||||
copyAssets()
|
||||
const built = listFiles(distDir)
|
||||
checkImportsResolve(built)
|
||||
writeDevHtml(built)
|
||||
console.log(`built ${built.length} asset(s) and ${DEV_HTML} into ${relative(webDir, distDir)}/`)
|
||||
|
||||
function compile() {
|
||||
// Spawned through node rather than through the PATH shim, so that the script
|
||||
// also works when run as `node build.mjs`.
|
||||
execFileSync(process.execPath, [tscBin, "--project", webDir], {stdio: "inherit"})
|
||||
// Every failure here is a message that says what to do; a stack trace over it
|
||||
// only buries it, and tsc has already printed its own diagnostics by then.
|
||||
try {
|
||||
main()
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Rebuild from empty, so that a deleted module or asset also leaves dist/ and
|
||||
// the committed build stays a function of the sources alone.
|
||||
rmSync(distDir, {recursive: true, force: true})
|
||||
compile()
|
||||
copyAssets()
|
||||
const built = listFiles(distDir)
|
||||
checkImportsResolve(built)
|
||||
writeDevHtml(built)
|
||||
console.log(`built ${built.length} file(s) and ${DEV_HTML} into ${relative(webDir, distDir)}/`)
|
||||
}
|
||||
|
||||
function compile() {
|
||||
try {
|
||||
// Spawned through node rather than through the PATH shim, so that the
|
||||
// script also works when run as `node build.mjs`.
|
||||
execFileSync(process.execPath, [tscBin, "--project", webDir], {stdio: "inherit"})
|
||||
} catch {
|
||||
throw new Error("tsc failed; its diagnostics are above.")
|
||||
}
|
||||
}
|
||||
|
||||
// Copied file by file rather than by directory, so that a collision with a
|
||||
// compiled module is caught at any depth. tsc emits into the same flat dist/,
|
||||
// so assets/main.js would otherwise replace the compiled main.js: both checks
|
||||
// below would still pass, the build would report success, and the page would be
|
||||
// dead in a browser. Assets and modules share one namespace here.
|
||||
function copyAssets() {
|
||||
mkdirSync(distDir, {recursive: true})
|
||||
for (const entry of readdirSync(assetsDir)) {
|
||||
// .gitkeep and friends: they keep the directory in git, they are not assets.
|
||||
if (entry.startsWith(".")) continue
|
||||
cpSync(join(assetsDir, entry), join(distDir, entry), {recursive: true})
|
||||
for (const name of listFiles(assetsDir)) {
|
||||
// .gitkeep and friends keep the directory in git, they are not assets.
|
||||
if (name.split(posix.sep).some((part) => part.startsWith("."))) continue
|
||||
const dest = join(distDir, name)
|
||||
if (existsSync(dest)) {
|
||||
throw new Error(
|
||||
`assets/${name} would overwrite ${relative(webDir, dest)}, which the compiler just emitted. ` +
|
||||
`Compiled modules and assets share one flat namespace in dist/; rename one of them.`
|
||||
)
|
||||
}
|
||||
mkdirSync(dirname(dest), {recursive: true})
|
||||
cpSync(join(assetsDir, name), dest)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "node build.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"author": "SimpleX Chat",
|
||||
"license": "AGPL-3.0"
|
||||
|
||||
@@ -778,7 +778,7 @@ Phase D ends with a browsable, priced site wizard whose Pay button reaches a rea
|
||||
- Commit `dist/`, and say so in a header comment in `index.html`.
|
||||
- Do not add a bundler, a framework, or a CSS toolchain.
|
||||
|
||||
**Verify:** `npm ci && npm run build` from a clean checkout produces the `dist/` modules, the copied assets and `dist/dev.html`; `npx tsc --noEmit` is clean; a second `npm run build` leaves `git diff --exit-code apps/simplex-badge-service/web/dist` empty, which is what D8 gates; `dist/dev.html` carries no unresolved `@@…@@` token, resolves `styles.css` to `../styles.css` and every other file token to `./<name>`; the emitted `main.js` imports a sibling module by a relative specifier. Then serve `apps/simplex-badge-service/web` with `python3 -m http.server` and `curl` every URL the page references — `dev.html`, the stylesheet one level up, and both modules — for a 200. Rendering the page in a browser is D2's manual pass (§9).
|
||||
**Verify:** `npm ci && npm run build` from a clean checkout produces the `dist/` modules, the copied assets and `dist/dev.html`; `npx tsc --noEmit` is clean; a second `npm run build` leaves `git status --porcelain apps/simplex-badge-service/web/dist` empty, which is what D8 gates — `git diff --exit-code` alone would miss a built file that was never added (§9); `dist/dev.html` carries no unresolved `@@…@@` token, resolves `styles.css` to `../styles.css` and every other file token to `./<name>`; the emitted `main.js` imports a sibling module by a relative specifier. Then serve `apps/simplex-badge-service/web` with `python3 -m http.server` and `curl` every URL the page references — `dev.html`, the stylesheet one level up, and both modules — for a 200. Rendering the page in a browser is D2's manual pass (§9).
|
||||
|
||||
#### D2 — Design system and site wizard shell
|
||||
|
||||
@@ -797,6 +797,8 @@ Phase D ends with a browsable, priced site wizard whose Pay button reaches a rea
|
||||
|
||||
**Verify:** Manual: serve `apps/simplex-badge-service/web` with `python3 -m http.server` and open `/dist/dev.html`, whose screens carry hardcoded placeholder options at this step, and step through every screen with the keyboard only, reaching and activating every option. In both light and dark themes at 320 px width, confirm that no screen scrolls horizontally, that the selected option card's 2px accent border is visible, and that the `:focus-visible` ring is visible against both backgrounds.
|
||||
|
||||
**This pass is also D1's first browser run, and confirms three things about its plumbing that no environment without a browser could check (§9).** Confirm each explicitly, because a blank or unstyled page here is as likely to be D1's mechanism as this step's markup: that `dev.html` loads at all rather than rendering an empty shell; that `../styles.css` is *applied* and not merely served with a `text/css` header — the colour tokens are the visible proof; and that the browser executes the module graph across files, `main.js` importing `ui.js` by the relative specifier `tsc` does not rewrite. The third is what decision 7 and D4's single-prefix asset hash both rest on, and this is the first time anything runs it.
|
||||
|
||||
#### D3 — Catalog fetch and the four site screens
|
||||
|
||||
**Files:** `apps/simplex-badge-service/web/src/{catalog.ts,steps.ts,main.ts}`
|
||||
@@ -1482,6 +1484,9 @@ Append here when a step contradicts this plan: the step id, what was wrong, and
|
||||
|
||||
`dist/` is also wiped before each build, so a deleted module or asset leaves the committed artefact rather than lingering in it and staying embedded; and `noEmitOnError` is on, so a type error cannot leave a half-written `dist/` for D8 to diff. Sources are compiled with `lib: ["ES2020", "DOM"]` and `types: []`: this is browser code and no `@types/node` is available under the cap.
|
||||
- **D1 — `index.html` may not use the token syntax in prose, and its charset declaration has a byte budget. Both bind D2 onwards.** The step requires a header comment saying `dist/` is committed, and the first draft of that comment used `@@name@@` to describe the mechanism — which the generic substitution then tried to resolve, correctly failing the build. Under D4 the same stray token fails the *service* at startup. Separately, the comment sits above `<meta charset>`, and HTML5 requires the encoding declaration to be complete within the first 1024 bytes; `dev.html` prepends a generated banner on top of it, so the budget is shared. The comment is short and ASCII-only for that reason, the banner is one line, and `index.html` says so. A step that grows the header comment has to re-check both.
|
||||
- **D1 — its Verify line was rewritten from a manual browser check to a mechanical one, and the browser half is now carried by D2's Verify.** The original line was "Manual: `npm ci && npm run build` … `npx tsc --noEmit` is clean", which under rule 4 made D1 unfalsifiable in CI and in any environment without a display. It now specifies the reproducible rebuild (which is what D8 gates), the token resolution in `dev.html`, the emitted relative import, and a `curl` of every URL the page references over `python3 -m http.server`. **Exactly four things were therefore not run when D1 landed, and none of them is claimed to work:** that `dev.html` renders at all; that the browser executes the `main.js` → `ui.js` module graph; that `../styles.css` is applied rather than merely served with a `text/css` header; and that a `file://` open fails where HTTP succeeds, which the step asserts and which was not tested in either direction. Under rule 10 a deferral needs the deferring step to own the assertions, so **D2's Verify line now names the first three explicitly** — D2 replaces `main.ts`, `ui.ts`, `styles.css` and `index.html` wholesale, and without that its implementer would read a blank page as their own markup rather than as D1's mechanism. The `file://` claim is carried untested; nothing in the plan depends on it beyond the instruction not to do it, which `dev.html`'s own banner repeats.
|
||||
- **D1/D8 — `git diff --exit-code -- dist` does not see untracked files, so the staleness gate has a blind spot.** The check now sits in D1's Verify line and is what D8 is to gate CI on. `dist/` is wiped and rebuilt every run, so a *deleted* module or asset is caught: the file vanishes from the working tree and the diff reports it. But a *new* asset that was built and never `git add`ed is untracked, and `git diff` ignores untracked paths entirely — CI would run `npm ci && npm run build`, produce a file that is not in the repository, and report the tree clean. The service would then embed a `dist/` missing that asset while `index.html` carries its token, which under D4's rule fails at **startup**, not in CI. **D8 must assert on untracked files too** — `git status --porcelain apps/simplex-badge-service/web/dist` being empty covers both directions, where `git diff --exit-code` covers only one.
|
||||
- **D1/D4 — a nested asset gets a served-set key no token can name, and the two resolvers must agree on this.** `dist/` is walked recursively, so an asset at `assets/img/x.svg` enters the served set keyed `img/x.svg`, while the token pattern is `@@([\w.-]+)@@` and does not accept `/`. Nothing can reference such a file, and the mismatch surfaces as the ordinary "token names nothing" build failure rather than as anything explaining itself. No asset is nested today and none is planned — D2's two logos and E5's encoder are all flat — so this is recorded rather than fixed. **D4 implements the same resolver server-side over `embedDir`'s `[(FilePath, ByteString)]`, whose keys are also relative paths**, and must use the same key and the same token charset; if it accepts `/` in a token while the build script does not, or keys its set differently, the two resolvers diverge on the first nested asset and the page that builds cleanly fails at startup. Whichever step first needs a subdirectory decides for both, in one place.
|
||||
|
||||
## 10. End-to-end verification
|
||||
|
||||
|
||||
Reference in New Issue
Block a user