mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 19:42:13 +00:00
ui, plan: web project skeleton and tsc build
This commit is contained in:
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.
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user