ui, plan: web project skeleton and tsc build

This commit is contained in:
shum committed 2026-08-27 10:28:27 +00:00
1 parent 13ea3862de
commit 2b50b4f312
14 files changed
+294 -3

No files matched your search

@@ -0,0 +1,4 @@
node_modules/
# dist/ is committed: the service embeds it, so the Haskell build never needs
# node. This overrides the repository-wide `dist` rule.
!dist/
Whitespace-only changes.
+115
View File
@@ -0,0 +1,115 @@
// Build for the supporter-badge checkout site. Run it with `npm run build`.
//
// Compiles src/ to ES modules in dist/, copies assets/ in beside them, checks
// that the emitted modules can resolve each other in a browser, and writes
// dist/dev.html — index.html with its @@name@@ tokens resolved, for design work
// over a local static server before the service can serve the real page.
//
// 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 {dirname, join, posix, relative, resolve, sep} from "node:path"
import {fileURLToPath} from "node:url"
const webDir = dirname(fileURLToPath(import.meta.url))
const assetsDir = join(webDir, "assets")
const distDir = join(webDir, "dist")
const tscBin = join(webDir, "node_modules", "typescript", "bin", "tsc")
// Served from web/ rather than from dist/, so that an edit to styles.css is
// picked up without a rebuild in web_dir mode. dev.html sits in dist/, so it
// reaches both of them one directory up.
const ROOT_FILES = ["index.html", "styles.css"]
// Generated into dist/, never served: the service filters it out of the assets.
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"]])
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
// string inside a comment or a literal would be checked too; that only ever
// 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"})
}
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})
}
}
// Every file in dir, as a path relative to it, in POSIX form and sorted.
function listFiles(dir) {
const walk = (d) =>
readdirSync(d, {withFileTypes: true}).flatMap((e) => {
const p = join(d, e.name)
return e.isDirectory() ? walk(p) : [relative(dir, p).split(sep).join(posix.sep)]
})
return walk(dir).sort()
}
// tsc does not rewrite import specifiers, so an extensionless or misspelled one
// compiles cleanly and then 404s in the browser, leaving a blank page.
function checkImportsResolve(built) {
const emitted = new Set(built)
for (const name of built) {
if (!name.endsWith(".js")) continue
const source = readFileSync(join(distDir, name), "utf8")
for (const re of IMPORT_RES) {
for (const [, specifier] of source.matchAll(re)) {
const target = relative(distDir, resolve(dirname(join(distDir, name)), specifier)).split(sep).join(posix.sep)
if (!emitted.has(target)) {
throw new Error(`${name} imports "${specifier}", which is not in the build. A browser would fail to load it.`)
}
}
}
}
}
// index.html with every token resolved. The rule is the service's, minus the
// hashed prefix: a token naming a file in the served set resolves to that file,
// anything else is an error. Nothing here knows which files exist today, so an
// asset added later needs a token and no change to this script.
function writeDevHtml(built) {
// dist/ was wiped above and dev.html is written last, so it is not in `built`
// and cannot resolve a token of its own name.
const served = new Map()
for (const name of built) served.set(name, `./${name}`)
for (const name of ROOT_FILES) served.set(name, `../${name}`)
const html = readFileSync(join(webDir, "index.html"), "utf8").replace(TOKEN_RE, (_, token) => {
const href = served.get(token)
if (href !== undefined) return href
const value = NON_FILE_TOKENS.get(token)
if (value !== undefined) return value
throw new Error(
`index.html references @@${token}@@, which is neither a file in the served set ` +
`(${[...served.keys()].join(", ")}) nor a known non-file token. ` +
`Add the file to assets/ or src/, or add the token to NON_FILE_TOKENS here and to the service.`
)
})
// One short line: everything above index.html's charset declaration has to
// fit in the 1024 bytes HTML5 gives it.
const banner = "<!-- Generated from index.html. Open over a static server rooted at web/, not file://. -->\n"
writeFileSync(join(distDir, DEV_HTML), banner + html)
}
+30
View File
@@ -0,0 +1,30 @@
<!-- Generated from index.html. Open over a static server rooted at web/, not file://. -->
<!doctype html>
<!--
Checkout site for SimpleX supporter badges, served by simplex-badge-service.
dist/ IS COMMITTED: the service embeds it, so the Haskell build never needs
node. Run `npm run build` and commit dist/ with any change under src/ or
assets/.
Assets are named by the double-at tokens below, not by path. The service and
`npm run build` both resolve any token naming a file in the served set, and
both reject one that names nothing, this comment included, so keep the token
syntax out of prose. Only this file is substituted: styles.css is served
verbatim, so a token in a CSS rule would reach the browser as literal text.
Keep the charset declaration below within the first 1024 bytes (HTML5).
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SimpleX supporter badges</title>
<link rel="stylesheet" href="../styles.css" />
</head>
<body>
<main id="app"></main>
<footer><a href="https://example.invalid/dev-support-contact">Contact support</a></footer>
<script type="module" src="./main.js"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
// Placeholder entry point; the wizard shell replaces it.
// The relative specifier keeps its ".js" extension into the emitted module:
// tsc does not rewrite specifiers and the browser resolves them itself.
import { renderPlaceholder } from "./ui.js";
const app = document.getElementById("app");
if (app)
renderPlaceholder(app);
+6
View File
@@ -0,0 +1,6 @@
// Placeholder view layer; the wizard shell replaces it.
export function renderPlaceholder(root) {
const heading = document.createElement("h1");
heading.textContent = "SimpleX supporter badges";
root.replaceChildren(heading);
}
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<!--
Checkout site for SimpleX supporter badges, served by simplex-badge-service.
dist/ IS COMMITTED: the service embeds it, so the Haskell build never needs
node. Run `npm run build` and commit dist/ with any change under src/ or
assets/.
Assets are named by the double-at tokens below, not by path. The service and
`npm run build` both resolve any token naming a file in the served set, and
both reject one that names nothing, this comment included, so keep the token
syntax out of prose. Only this file is substituted: styles.css is served
verbatim, so a token in a CSS rule would reach the browser as literal text.
Keep the charset declaration below within the first 1024 bytes (HTML5).
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SimpleX supporter badges</title>
<link rel="stylesheet" href="@@styles.css@@" />
</head>
<body>
<main id="app"></main>
<footer><a href="@@support_contact@@">Contact support</a></footer>
<script type="module" src="@@main.js@@"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "simplex-badge-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simplex-badge-web",
"version": "0.1.0",
"license": "AGPL-3.0",
"devDependencies": {
"typescript": "^5.9.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
@@ -0,0 +1,14 @@
{
"name": "simplex-badge-web",
"version": "0.1.0",
"private": true,
"description": "Checkout site for SimpleX supporter badges, served by simplex-badge-service",
"scripts": {
"build": "node build.mjs"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"author": "SimpleX Chat",
"license": "AGPL-3.0"
}
@@ -0,0 +1,8 @@
// Placeholder entry point; the wizard shell replaces it.
// The relative specifier keeps its ".js" extension into the emitted module:
// tsc does not rewrite specifiers and the browser resolves them itself.
import {renderPlaceholder} from "./ui.js"
const app = document.getElementById("app")
if (app) renderPlaceholder(app)
+7
View File
@@ -0,0 +1,7 @@
// Placeholder view layer; the wizard shell replaces it.
export function renderPlaceholder(root: HTMLElement): void {
const heading = document.createElement("h1")
heading.textContent = "SimpleX supporter badges"
root.replaceChildren(heading)
}
+12
View File
@@ -0,0 +1,12 @@
/* Placeholder stylesheet; the design system replaces it.
Served from web/ rather than from dist/, so that an edit is picked up
without a rebuild when the service runs with [web] web_dir set. */
:root {
color-scheme: light dark;
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@@ -0,0 +1,22 @@
{
"include": ["src"],
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "Node",
"lib": ["ES2020", "DOM"],
"types": [],
"rootDir": "src",
"outDir": "dist",
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"noEmitOnError": true
}
}
@@ -150,7 +150,7 @@ The two `-m` filters are needed because the badge tests live under two hspec pat
| C4 | Redeem path wired end to end | B6, B7, B9, C3 | ☑ |
| C5 | Client and service integration tests | B10, C4 | ☑ |
| D0 | Store layer: orders, invoices, provider events | A3, A5, B1 | ☑ |
| D1 | Web project skeleton and tsc build | — | ☐ |
| D1 | Web project skeleton and tsc build | — | ☑ |
| D2 | Design system and site wizard shell | D1 | ☐ |
| D3 | Catalog fetch and the four site screens | D2, D4 | ☐ |
| D4 | Warp listener, asset embedding, routing | A2, A4, A6, B1, D1 | ☐ |
@@ -765,7 +765,7 @@ Phase D ends with a browsable, priced site wizard whose Pay button reaches a rea
#### D1 — Web project skeleton and tsc build
**Files:** `apps/simplex-badge-service/web/{package.json,package-lock.json,tsconfig.json,index.html,styles.css}`, `apps/simplex-badge-service/web/src/`, `apps/simplex-badge-service/web/assets/.gitkeep`, `apps/simplex-badge-service/web/dist/`
**Files:** `apps/simplex-badge-service/web/{package.json,package-lock.json,tsconfig.json,build.mjs,index.html,styles.css,.gitignore}`, `apps/simplex-badge-service/web/src/`, `apps/simplex-badge-service/web/assets/.gitkeep`, `apps/simplex-badge-service/web/dist/`
**Do:**
@@ -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:** Manual: `npm ci && npm run build` from a clean checkout produces the `dist/` modules and `dist/dev.html`; `npx tsc --noEmit` is clean.
**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).
#### D2 — Design system and site wizard shell
@@ -1475,6 +1475,13 @@ Append here when a step contradicts this plan: the step id, what was wrong, and
- **D0 — `createOrder` takes an `invoiceId`, which the plan's field list did not name.** `@invoices.invoice_id` is `TEXT NOT NULL PRIMARY KEY` with no default and the store mints no identifiers (it opens no transaction either), so D6 mints it alongside the `orderId`. It must NOT be the `orderId` itself: the order id is a bearer capability for the code (decision 9) and putting it in a second table widens the surface for no gain. Two more shapes the field list left open, both now fixed in code: `@invoices.price` and `@invoices.amount` both carry A4's `offerTotal` with `discount_amount`/`credit_amount` NULL, because an offer's discount is expressed as free months so the total IS the price; and `@invoices.payment_crypto_currency` is not written at all (A3 — `method` is the single source).
- **D0 — every order read is an INNER join to `@invoices`, and three new `ServiceError` constructors.** `getOrder`, `getOrderByProviderRef`, `getOrderByShortRef` and `getStuckOrders` all go through one `orderSelect`, joined rather than left-joined: `createOrder` is the only writer of a `@web_orders` row and always writes the invoice with it, so an order without one does not exist and `WebOrder` can hold the invoice's NOT NULL columns unwrapped. `SEOrderNotFound` is what `updateOrderStatus`, `setOrderProviderRef` and `setOrderSettled` throw when the order they name is absent; `SEProviderEventNotFound` is `markProviderEventProcessed`'s, reachable only if it and `recordProviderEvent` disagree about the key. `SEInvoiceNotFound` is the invariant guard: the `@invoices.status` write is an `UPDATE … RETURNING` like every other writer here, so an order that resolves to no invoice fails instead of moving one status and leaving the other behind — `createOrder` makes that unreachable, and the guard is there for whoever next writes this table. **`setOrderSettled` takes `settledAt` and `now` separately**: the provider's reported settlement instant is routinely in the past for an on-chain confirmation, and writing it to `updated_at` would move that column backwards past an earlier `updateOrderStatus`; E3, F2 and H3 pass both. **`setOrderSettled` does not guard on the current status**: settlement is idempotent and monotonic toward `paid` (E3), but that is E3's rule to apply, because the same decision governs whether a code row is written — a guard here would answer `SEOrderNotFound` for a replay, which is worse than no guard.
- **D0 — `getStuckOrders`' status filter leaves a recovery gap, implemented as specified. H3 decides.** The step says `invoiced` or `pending`, and that is what shipped. But E3 can move `expired` and `failed` to `paid` on a late webhook, so an order that one webhook marked `expired` and that then settles on chain with THAT webhook missed is never re-read from the provider by H3's pass — the buyer has paid and only support (H2) recovers it. Missed webhooks are exactly what H3 exists for, so this is worth a decision rather than an assumption; widening the filter to all four non-`paid` statuses is a change to H3's contract, not to this query, and the haddock on `getStuckOrders` says so.
- **D1 — the repository ignores `dist`, so the committed build needs `web/.gitignore` to re-include it.** `.gitignore:14` carries a bare `dist` under "# Haskell", which matches a directory of that name at any depth: `git add apps/simplex-badge-service/web/dist` was a silent no-op, and decision 7's committed artefact, D4's `embedDir` source and D8's gate would all have been comparing against a directory git never tracked. `web/.gitignore` now holds `node_modules/` and `!dist/`, following the per-package `.gitignore` convention of `packages/simplex-chat-webrtc` and `apps/simplex-support-bot` rather than editing the root file; a nested `.gitignore` overrides a parent's rule, and the negation works because no parent directory of `dist/` is itself excluded. Added to the step's **Files**. Anyone moving this directory must move the negation with it — `git status` will not report the loss.
- **D1 — the build is `build.mjs`, which the step's Files list omitted, and it checks two things `tsc` cannot.** The step describes the build (compile, copy `assets/`, emit `dev.html`) without naming a file for it; decision 7's one-devDependency cap makes it a node script over `fs`/`path` alone. Two build-time checks were added, both of which fail loudly rather than producing a page that is broken only in a browser, which is the one environment CI does not have:
- **Relative import specifiers must resolve inside the build.** `tsc` does not rewrite specifiers (decision 7), so `import … from "./ui"` type-checks, emits, and then 404s in the browser leaving a blank page. Each emitted `.js` is scanned for relative specifiers and each must name an emitted file. This is the premise D4's single-prefix asset hash exists to protect, checked at the point it is created.
- **Every `@@…@@` token must name a file in the served set or a known non-file token,** or the build fails naming the token and listing the set. This is D4's startup rule ("a token naming a file that is not in the served set fails at startup"), applied one stage earlier; D4 should implement the same rule rather than a second, looser one.
`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.
## 10. End-to-end verification