/* catcrafts.net Copyright (C) 2026 Catcrafts The source code of this website is made available for viewing purposes only. No permission is granted to copy, modify, distribute, or create derivative works. */ import std; import Crafter.Build; namespace fs = std::filesystem; using namespace Crafter; // Two products come out of this repo, selected with `--product=`: // // web (default) — the wasm32-wasip1 browser bundle. Crafter.Graphics for // the DOM, Catcrafts.Shared for the page renderers. // crafter-build // // server — the native HTTP server that will render the same pages // server-side for crawlers and no-JS clients, and later // host the shop API. No Crafter.Graphics. // crafter-build --product=server // // One project file rather than three, following the imsd convention: a single // CrafterBuildProject returns a single Configuration, so the products are // branches and the shared library is a `static unique_ptr` // both branches can depend on. // // The tests hang off the server product (tests//main.cpp, the // Crafter.Network layout): // // crafter-build test --product=server // // Two layers, both under the same runner. The unit suites replaced the // --selftest flag the server binary used to carry: same assertions, but each // suite is its own binary with the framework's parallel runner, timeouts and // crash detection instead of one grab-bag function. The black-box suites // (ShouldServeRoutes and friends) replaced tools/e2e.sh: each spawns the // freshly built binary on its own scratch port — via tests/harness/ — and // asserts over real HTTP the things only a real request can show. // Catcrafts.Shared — target-neutral page renderers, built as a static library // for whichever target the selected product is using. `static` because the // Configuration must outlive this function: cfg.dependencies holds a raw // pointer to it. // // `args` MUST carry the consumer's --target=. ApplyStandardArgs is what reads // it, and without it the library silently builds for the host triple while a // wasm consumer links against it — which currently "works" only because there // are no implementation units, so the static archive is empty. The moment a // .cpp lands here that would be a wrong-architecture link. static Configuration* SharedLibrary(std::span args) { static auto shared = std::make_unique(); // Idempotent, and it MUST be: GetInterfacesAndImplementations may run only // once — a second call re-parses the same .cppm files into duplicate // Module entries, and the duplicated units then compile concurrently into // the same PCM paths, failing the build with "module not found" at // whichever partition lost the race. The server branch reaches here twice // (once via ServerCore, once directly), which is what made this real. // Only one product branch runs per process, so first-call args win. if (!shared->name.empty()) return shared.get(); shared->path = "./"; shared->name = "Catcrafts.Shared"; shared->outputName = "Catcrafts.Shared"; ApplyStandardArgs(*shared, args); // inherits --target / --debug from the parent shared->type = ConfigurationType::LibraryStatic; std::array ifaces = { "shared/interfaces/Catcrafts.Shared", "shared/interfaces/Catcrafts.Shared-Html", "shared/interfaces/Catcrafts.Shared-Form", "shared/interfaces/Catcrafts.Shared-Json", "shared/interfaces/Catcrafts.Shared-Model", "shared/interfaces/Catcrafts.Shared-Media", "shared/interfaces/Catcrafts.Shared-Markdown", "shared/interfaces/Catcrafts.Shared-Content", "shared/interfaces/Catcrafts.Shared-Money", "shared/interfaces/Catcrafts.Shared-Route", "shared/interfaces/Catcrafts.Shared-Views", }; std::array impls = {}; shared->GetInterfacesAndImplementations(ifaces, impls); return shared.get(); } // Catcrafts.ServerCore — every implementation unit of the server except // main.cpp, as a static library. The split exists for the tests: a test can // only LINK a library-type dependency (crafter-build exports -L/-l for // libraries alone), and the bunq/EURC/Sendcloud parsers and the invoice // builder all live here. The exe is main.cpp plus this library, so the // split costs nothing at deploy time. // // Same `static unique_ptr` convention as SharedLibrary, and the same warning: // cfg.dependencies holds a raw pointer, so the Configuration must outlive // CrafterBuildProject. static Configuration* ServerCore(std::span args, Configuration* network) { static auto core = std::make_unique(); // Same idempotence rule as SharedLibrary — see the warning there. if (!core->name.empty()) return core.get(); core->path = "./"; core->name = "Catcrafts.ServerCore"; core->outputName = "Catcrafts.ServerCore"; ApplyStandardArgs(*core, args); core->type = ConfigurationType::LibraryStatic; core->dependencies = { SharedLibrary(args), network }; std::array ifaces = { "server/interfaces/Catcrafts.Server", }; std::array impls = { "server/implementations/Catcrafts.Server-Http", "server/implementations/Catcrafts.Server-Orders", "server/implementations/Catcrafts.Server-Rails", "server/implementations/Catcrafts.Server-Invoice", "server/implementations/Catcrafts.Server-Eurc", "server/implementations/Catcrafts.Server-Transfer", "server/implementations/Catcrafts.Server-Bunq", "server/implementations/Catcrafts.Server-Shipping", "server/implementations/Catcrafts.Server-Mail", "server/implementations/Catcrafts.Server-Financials", }; core->GetInterfacesAndImplementations(ifaces, impls); // The rails reach the outside world over TLS, which is what libssl is for. // libcrypto is named EXPLICITLY again because the bunq credit source signs // every request body with RSA-SHA256 through EVP: relying on it arriving as // libssl's transitive dependency worked while nothing called EVP directly, // and would break confusingly the moment a linker ordered them otherwise. // On the lib rather than the exe because link flags propagate to consumers // — the exe and every test get them from here. core->linkFlags.push_back("-lssl"); core->linkFlags.push_back("-lcrypto"); return core.get(); } // Payload trimming, release only. // // The wasi-libc / wasi-libc++ static libs ship with DWARF and wasm-ld keeps // debug sections by default, so a build that never passed -g still ended up // ~84% debug info (3.3 MB of 3.9 MB). Stripping takes the bundle from // 3.9 MB / 1.05 MB gzip to ~700 KB / ~195 KB gzip. static void ApplyReleaseTrimming(Configuration& cfg) { if (cfg.debug) return; // --debug builds want their symbols cfg.compileFlags.push_back("-ffunction-sections"); cfg.compileFlags.push_back("-fdata-sections"); cfg.linkFlags.push_back("-Wl,--gc-sections"); cfg.linkFlags.push_back("-Wl,--strip-debug"); } extern "C" Configuration CrafterBuildProject(std::span args) { bool wantServer = false; for (std::string_view a : args) { if (a == "--product=server") wantServer = true; } // ── server ──────────────────────────────────────────────────────── if (wantServer) { // Crafter.Network supplies the HTTP layer. Notably NOT cpp-httplib or // libcurl: ListenerHTTP1 covers the inbound side (TLS is Caddy's job — // it terminates and reverse-proxies plaintext to localhost, which is // exactly why HTTP/1.1 rather than the HTTP/3 listener, since Caddy // cannot proxy to an h3 upstream), and ClientHTTP1 with // TLSClientCredentials covers outbound HTTPS for the payment and // shipping APIs later — it verifies certificate chain and hostname by // default. std::vector netArgs(args.begin(), args.end()); bool useLocalNet = false; for (std::string_view a : args) { if (a == "") { useLocalNet = true; break; } } Configuration* network = useLocalNet ? LocalProject({ .projectFile = "../Crafter/Crafter.Network/project.cpp", .args = netArgs, }) : GitProject({ .source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Network.git" }, .args = netArgs, }); Configuration* core = ServerCore(args, network); Configuration* shared = SharedLibrary(args); Configuration cfg; cfg.path = "./"; cfg.name = "Catcrafts.Server"; cfg.outputName = "catcrafts-server"; cfg.type = ConfigurationType::Executable; ApplyStandardArgs(cfg, args); cfg.dependencies = { core, shared, network }; std::array ifaces = {}; std::array impls = { "server/implementations/main", }; cfg.GetInterfacesAndImplementations(ifaces, impls); // libmsquic.so.2 is built in crafter-build's external cache, and the // RUNPATH pointing there only exists on the build machine — the first // deploy to a real host died in the loader (exit 127) because of it. // CI ships the .so alongside the binary into /srv/catcrafts-app, and // this rpath makes the loader look there. The path is spelled out // rather than $ORIGIN: the flag passes through a shell on its way to // the linker, where $ORIGIN expands to empty — leaving a bare -rpath // that swallows the next input file (libCatcrafts.Shared.a) and fails // the link with every Shared symbol undefined. cfg.linkFlags.push_back("-Wl,-rpath,/srv/catcrafts-app"); ApplyReleaseTrimming(cfg); // ── tests ───────────────────────────────────────────────────── // crafter-build test --product=server // // Each suite is tests//main.cpp. The first group tests // Catcrafts.Shared alone — the escaping/JSON/form/money layers that // produce every byte of markup the site emits. The second group also // links the server core: provider payload parsers, the invoice and // email builders, and the financials ingest. cfg.AddTest("ShouldEscapeHtml").Dependencies({ shared }); cfg.AddTest("ShouldParseJson").Dependencies({ shared }); cfg.AddTest("ShouldValidateForms").Dependencies({ shared }); cfg.AddTest("ShouldComputeMoney").Dependencies({ shared }); cfg.AddTest("ShouldShipContent").Dependencies({ shared }); cfg.AddTest("ShouldBuildMediaLadders").Dependencies({ shared }); cfg.AddTest("ShouldRenderMarkdown").Dependencies({ shared }); cfg.AddTest("ShouldServePosts").Dependencies({ shared }); cfg.AddTest("ShouldParseSendcloudRates").Dependencies({ core, shared }); cfg.AddTest("ShouldMintOrderTokens").Dependencies({ core, shared }); cfg.AddTest("ShouldParseEurcChains").Dependencies({ core, shared }); cfg.AddTest("ShouldGuardRequestProvenance").Dependencies({ core, shared }); cfg.AddTest("ShouldBuildInvoices").Dependencies({ core, shared }); cfg.AddTest("ShouldPublishFinancials").Dependencies({ core, shared }); cfg.AddTest("ShouldFoldTheOrderLedger").Dependencies({ core, shared }); cfg.AddTest("ShouldIssueEurcAddresses").Dependencies({ core, shared }); cfg.AddTest("ShouldMatchBankTransfers").Dependencies({ core, shared }); cfg.AddTest("ShouldParseBunqPayments").Dependencies({ core, shared }); // ── black-box suites (the tools/e2e.sh port) ────────────────── // Each spawns the REAL binary — depending on &cfg is what builds it // first (an Executable dep is built and mtime-tracked, it just links // nothing) — on its OWN port: the suites run in parallel, so a shared // port would race. The harness module is compiled into each suite via // the interfaces overload; Args hands each binary the exact path to // the exe this configuration produces, so a stale sibling variant // under bin/ can never be the thing under test. // // BinDir() must be read AFTER ApplyReleaseTrimming: compileFlags are // part of the variant hash, so reading it earlier would name a // directory the build never writes. std::array harness = { "tests/harness/Catcrafts.E2eHarness" }; const std::string serverBin = (cfg.BinDir() / cfg.outputName).string(); cfg.AddTest("ShouldServeRoutes", harness) .Dependencies({ &cfg, shared, network }).Args({ serverBin }); cfg.AddTest("ShouldStayScriptFree", harness) .Dependencies({ &cfg, shared, network }).Args({ serverBin }); cfg.AddTest("ShouldEmitStructuredData", harness) .Dependencies({ &cfg, shared, network }).Args({ serverBin }); cfg.AddTest("ShouldBootWasmAtDepth", harness) .Dependencies({ &cfg, shared, network }).Args({ serverBin }); cfg.AddTest("ShouldServePostPages", harness) .Dependencies({ &cfg, shared, network }).Args({ serverBin }); cfg.AddTest("ShouldServeFinancialsLive", harness) .Dependencies({ &cfg, shared, network }).Args({ serverBin }); cfg.AddTest("ShouldSellTheShopFront", harness) .Dependencies({ &cfg, shared, network }).Args({ serverBin }); // The full order lifecycle signs real invoices (ephemeral key) and // waits out the reconciler and mailer cadences, so it both needs gpg // and deserves more than the 60 s default. cfg.AddTest("ShouldProcessCheckout", harness) .Dependencies({ &cfg, shared, network }).Args({ serverBin }) .Requires("tool:gpg") .Timeout(std::chrono::seconds(180)); // The bank rail's full lifecycle against the REAL transfer rail — // not a stand-in. It needs no credential and no network, because a // self-hosted rail has no provider to authenticate to: the rail reads // its credits from a file, so the suite can play the part of the bank // by writing one. Every step in between is production code. It waits // out the reconciler, hence the raised timeout. cfg.AddTest("ShouldSettleBankTransfers", harness) .Dependencies({ &cfg, shared, network }).Args({ serverBin }) .Timeout(std::chrono::seconds(180)); // ── live-provider suite ─────────────────────────────────────── // The EURC rail settling an actual on-chain transfer on a testnet: a // MANDATORY deploy gate in CI (the workflow supplies // EURC_E2E_PRIVATE_KEY and the suite FAILS there if it is missing); // locally it skips unless that variable is exported. The gate lives in // the suite itself rather than in a Requires() — a Requires would skip // silently when the credential vanished, which is exactly the pass // this gate must never give. // // It is the only live suite now. There were two, until the hosted bank // provider closed the account on 2026-08-20; the bank rail that // replaced it has no counterpart to call, which is why its coverage // sits above with the ordinary black-box suites instead. The timeout // waits out block inclusion plus the rail's own poll. cfg.AddTest("ShouldSettleEurcOnTestnet", harness) .Dependencies({ &cfg, shared, network }).Args({ serverBin }) .Timeout(std::chrono::seconds(300)); return cfg; } // ── web (default) ───────────────────────────────────────────────── std::vector depArgs(args.begin(), args.end()); depArgs.push_back("--target=wasm32-wasip1"); // resolves the Crafter.Graphics dep from a sibling working tree // instead of fetching it from forgejo. Mirrors Crafter.Graphics's own // project.cpp convention so edits across the two repos pick up without // commit-and-pull. bool useLocal = false; for (std::string_view a : args) { if (a == "") { useLocal = true; break; } } if (useLocal && std::find(depArgs.begin(), depArgs.end(), std::string("")) == depArgs.end()) { depArgs.push_back(""); } Configuration* graphics = useLocal ? LocalProject({ .projectFile = "../Crafter/Crafter.Graphics/project.cpp", .args = depArgs, }) : GitProject({ .source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Graphics.git" }, .args = depArgs, }); Configuration cfg; cfg.path = "./"; cfg.name = "Catcrafts.Net"; cfg.outputName = "catcrafts"; cfg.type = ConfigurationType::Executable; cfg.target = "wasm32-wasip1"; ApplyStandardArgs(cfg, args); // Catcrafts.Shared has to be built for wasm here, not the host. `args` on // its own does not carry --target= (the caller just runs `crafter-build`), // so hand it the same augmented list the Crafter.Graphics dep gets. std::vector sharedArgs(depArgs.begin(), depArgs.end()); cfg.dependencies = { graphics, SharedLibrary(sharedArgs) }; std::array ifaces = { "interfaces/Catcrafts", "interfaces/Catcrafts-Views", "interfaces/Catcrafts-Root", "interfaces/Catcrafts-Demo", }; std::array impls = { "implementations/main", "implementations/Catcrafts-Root", "implementations/Catcrafts-Views", "implementations/Catcrafts-Demo", }; cfg.GetInterfacesAndImplementations(ifaces, impls); cfg.files.emplace_back(fs::path("styles/styles.css")); cfg.files.emplace_back(fs::path("robots.txt")); // sitemap.xml and feed.xml are GENERATED by the server product before this // build runs (see .forgejo/workflows/deploy.yaml), from the same route // table and Post model the pages use. Checked-in copies would drift. cfg.files.emplace_back(fs::path("sitemap.xml")); cfg.files.emplace_back(fs::path("feed.xml")); cfg.files.emplace_back(fs::path("favicon.svg")); // Icon paths clients ask for without ever being told to. /favicon.ico is // the root-path fallback a client uses when it cannot render the SVG above // (Safari, historically) or never runs the JS that declares it (feed // readers, the chat-app unfurlers, crawlers hitting the static shell); the // apple-touch-icon.png is what Safari wants for its Home Screen and // Favorites tile — its legacy -precomposed path is a Caddy rewrite onto // this same file rather than a second entry here. Both were top entries in // the 404 report until they shipped. Rasterised from favicon.svg by // tools/make-icons.sh and committed — re-run it whenever that SVG is // redrawn. cfg.files.emplace_back(fs::path("favicon.ico")); cfg.files.emplace_back(fs::path("apple-touch-icon.png")); // WGSL for the ray-traced WebGPU demo embedded in the blog (see // interfaces/Catcrafts-Demo.cppm). Fetched at runtime by WebGPUShader. cfg.files.emplace_back(fs::path("shaders/raygen.wgsl")); cfg.files.emplace_back(fs::path("shaders/miss.wgsl")); cfg.files.emplace_back(fs::path("shaders/closesthit.wgsl")); cfg.files.emplace_back(fs::path("shaders/resolve.wgsl")); // Loaded as a