Files
simplex-chat/apps/simplex-badge-service/web/build.js
T
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

161 lines
7.0 KiB
JavaScript

#!/usr/bin/env node
// The build hash is written back into public/index.html and public/sw.js, so a served shell can never name a build other than the one on disk.
import { createHash } from "node:crypto";
import { copyFileSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = fileURLToPath(new URL("./", import.meta.url));
export const paths = {
compiled: `${root}build/src`,
stylesheet: `${root}public/styles.css`,
images: `${root}public/img`,
fonts: `${root}public/fonts`,
indexHtml: `${root}public/index.html`,
worker: `${root}public/sw.js`,
site: `${root}dist`,
};
/** This is long enough to avoid a collision and short enough to read in a URL. */
export const HASH_CHARS = 16;
export const ASSET_PATTERN = new RegExp(`/assets/[0-9a-f]{${HASH_CHARS}}/`, "g");
export const BUILD_PATTERN = /const BUILD = "[0-9a-f]+";/;
/** The sourceMappingURL comment is removed because the maps are not copied and would 404 on every devtools open. */
export function served(source) {
return source.replace(/\n?\/\/# sourceMappingURL=.*\n?$/, "\n");
}
export function hashOf(files) {
const digest = createHash("sha256");
for (const [name, content] of [...files].sort((a, b) => a[0].localeCompare(b[0]))) {
digest.update(name);
digest.update("\0");
digest.update(content);
}
return digest.digest("hex").slice(0, HASH_CHARS);
}
const IMAGE_TYPES = [".png", ".svg"];
const FONT_TYPES = [".woff2"];
/** Images and fonts sit flat beside styles.css so that a url(hero-light.png) or url(Manrope-Regular.woff2) reference in the stylesheet resolves. */
/**
* This runs as a classic non-module script so that it executes before the first paint.
* The CSP allows script-src 'self' but not inline, so it must be served as a file rather than inlined.
*/
const INIT_JS = `(function () {
var r = document.documentElement;
try {
var raw = localStorage.getItem("sb.theme.v1");
var t = raw ? JSON.parse(raw) : "system";
if (t !== "light" && t !== "dark" && t !== "system") t = "system";
if (t === "system") r.removeAttribute("data-theme"); else r.setAttribute("data-theme", t);
r.style.colorScheme = (t === "dark" || (t === "system" && matchMedia("(prefers-color-scheme: dark)").matches)) ? "dark" : "light";
} catch (e) {}
try {
var h = location.hash;
var landing = (h === "" || h === "#" || h === "#/") && location.search.indexOf("order=") < 0;
if (!landing) r.classList.add("sb-booting");
} catch (e) {}
})();
`;
export function assets(compiled = paths.compiled, stylesheet = paths.stylesheet, images = paths.images, fonts = paths.fonts) {
const modules = readdirSync(compiled).filter((f) => f.endsWith(".js")).sort();
if (modules.length === 0) throw new Error("build: build/src holds no modules — run tsc first");
const pictures = readdirSync(images).filter((f) => IMAGE_TYPES.some((t) => f.endsWith(t))).sort();
const faces = readdirSync(fonts).filter((f) => FONT_TYPES.some((t) => f.endsWith(t))).sort();
return [
...modules.map((name) => [name, served(readFileSync(`${compiled}/${name}`, "utf8"))]),
["styles.css", readFileSync(stylesheet, "utf8")],
["init.js", INIT_JS],
...pictures.map((name) => [name, readFileSync(`${images}/${name}`)]),
...faces.map((name) => [name, readFileSync(`${fonts}/${name}`)]),
];
}
/** This throws rather than returning the text unchanged, because a silent no-op would ship a shell that still names the previous build. */
export function retarget(text, pattern, replacement, what) {
if (!new RegExp(pattern.source).test(text)) throw new Error(`build: no ${what} to rewrite`);
return text.replace(pattern, replacement);
}
export function withBuild(html, build) {
return retarget(html, ASSET_PATTERN, `/assets/${build}/`, "asset path");
}
export async function prerenderShell() {
const dom = await import("./build/test/stub-dom.js");
const prevDoc = Object.getOwnPropertyDescriptor(globalThis, "document");
const prevNav = Object.getOwnPropertyDescriptor(globalThis, "navigator");
dom.installDocument();
try {
const screens = await import("./build/src/screens.js");
const noop = () => {};
const chromeHtml = screens.chrome({
theme: "system", onNewPurchase: noop, onHistory: noop, onTheme: noop, onToggle: noop, onHome: noop,
}).node.serialize();
const landingHtml = screens.landing({ onStart: noop }).serialize();
return { chromeHtml, appHtml: `<div class="track"><div class="rail">${landingHtml}</div></div>` };
} finally {
if (prevDoc) Object.defineProperty(globalThis, "document", prevDoc); else delete globalThis.document;
if (prevNav) Object.defineProperty(globalThis, "navigator", prevNav); else delete globalThis.navigator;
}
}
export const SHELL_SLOTS = /** @type {const} */ ([
["chrome", /(<!--shell:chrome-->)[\s\S]*?(<!--\/shell:chrome-->)/],
["app", /(<!--shell:app-->)[\s\S]*?(<!--\/shell:app-->)/],
]);
/** The markers are kept so the next build can find the slots again. */
export function injectShell(html, shell) {
let out = html;
for (const [slot, pattern] of SHELL_SLOTS) {
const body = slot === "chrome" ? shell.chromeHtml : shell.appHtml;
out = retarget(out, pattern, (_m, open, close) => `${open}${body}${close}`, `${slot} shell slot`);
}
return out;
}
export function withBuildId(js, build) {
return retarget(js, BUILD_PATTERN, `const BUILD = "${build}";`, "BUILD constant");
}
function put(file, content) {
let before = null;
try { before = readFileSync(file, "utf8"); } catch { /* the file may not exist yet */ }
if (before === content) return false;
writeFileSync(file, content);
return true;
}
export async function assemble() {
const files = assets();
const build = hashOf(files);
const shell = await prerenderShell();
// The whole site directory is removed first so a stale build hash cannot remain beside the current one.
rmSync(paths.site, { recursive: true, force: true });
mkdirSync(`${paths.site}/assets/${build}`, { recursive: true });
for (const [name, content] of files) writeFileSync(`${paths.site}/assets/${build}/${name}`, content);
// The shell is injected before the build hash is rewritten, because the injected markup carries no asset URLs while the hash rewrite still finds the ones in the head.
const indexSource = withBuild(injectShell(readFileSync(paths.indexHtml, "utf8"), shell), build);
const moved = [
put(paths.indexHtml, indexSource),
put(paths.worker, withBuildId(readFileSync(paths.worker, "utf8"), build)),
].some(Boolean);
copyFileSync(paths.indexHtml, `${paths.site}/index.html`);
copyFileSync(paths.worker, `${paths.site}/sw.js`);
return { build, files: files.length, moved };
}
if (process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url) {
const { build, files, moved } = await assemble();
console.log(`build ${build}: ${files} files in dist/assets/${build}/, with index.html and sw.js`);
if (moved) console.log("build: public/index.html and public/sw.js now name this build — commit them");
}