All checks were successful
Deploy / build-deploy (push) Successful in 3m6s
380 lines
19 KiB
C++
380 lines
19 KiB
C++
/*
|
|
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<Configuration>`
|
|
// both branches can depend on.
|
|
//
|
|
// The tests hang off the server product (tests/<Name>/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<const std::string_view> args) {
|
|
static auto shared = std::make_unique<Configuration>();
|
|
// 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<fs::path, 11> 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<fs::path, 0> 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 Mollie/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<const std::string_view> args, Configuration* network) {
|
|
static auto core = std::make_unique<Configuration>();
|
|
// 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<fs::path, 1> ifaces = {
|
|
"server/interfaces/Catcrafts.Server",
|
|
};
|
|
std::array<fs::path, 8> impls = {
|
|
"server/implementations/Catcrafts.Server-Http",
|
|
"server/implementations/Catcrafts.Server-Orders",
|
|
"server/implementations/Catcrafts.Server-Mollie",
|
|
"server/implementations/Catcrafts.Server-Invoice",
|
|
"server/implementations/Catcrafts.Server-Eurc",
|
|
"server/implementations/Catcrafts.Server-Shipping",
|
|
"server/implementations/Catcrafts.Server-Mail",
|
|
"server/implementations/Catcrafts.Server-Financials",
|
|
};
|
|
core->GetInterfacesAndImplementations(ifaces, impls);
|
|
|
|
// Both rails reach their provider over TLS, which is what libssl is for
|
|
// here; libcrypto arrives as its dependency. (It was named explicitly
|
|
// while the bunq callback verified RSA body signatures through EVP — that
|
|
// integration is retired.) 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");
|
|
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<const std::string_view> 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<std::string> 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<fs::path, 0> ifaces = {};
|
|
std::array<fs::path, 1> 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/<Name>/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("ShouldParseMolliePayments").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 });
|
|
|
|
// ── 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<fs::path, 1> 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));
|
|
|
|
return cfg;
|
|
}
|
|
|
|
// ── web (default) ─────────────────────────────────────────────────
|
|
std::vector<std::string> 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<std::string_view> sharedArgs(depArgs.begin(), depArgs.end());
|
|
cfg.dependencies = { graphics, SharedLibrary(sharedArgs) };
|
|
|
|
std::array<fs::path, 4> ifaces = {
|
|
"interfaces/Catcrafts",
|
|
"interfaces/Catcrafts-Views",
|
|
"interfaces/Catcrafts-Root",
|
|
"interfaces/Catcrafts-Demo",
|
|
};
|
|
std::array<fs::path, 4> 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 <script type="module"> before runtime.js by
|
|
// EnableWasiBrowserRuntime — sets <title>/<link> tags since the
|
|
// Dom partition has no head-element access.
|
|
cfg.files.emplace_back(fs::path("catcrafts-head.js"));
|
|
// Product photography. Both renders are © Fairphone under CC BY-SA 4.0 and
|
|
// are cropped and recompressed here — the Gen. 6 one came via Wikimedia
|
|
// Commons, the Gen. 6+ one from Fairphone's own Flickr. The attribution and
|
|
// the adaptation notice the licence requires live on /legal/imprint. NOT in
|
|
// cfg.assets: that pipeline transcodes to .ctex, which <img> cannot decode.
|
|
cfg.files.emplace_back(fs::path("images/fp6-pmos.jpg"));
|
|
cfg.files.emplace_back(fs::path("images/fp6plus-pmos.jpg"));
|
|
// ECB rates ride with the content so the wasm-rendered shop (backend-down
|
|
// fallback) can emit the same indicative prices the server does.
|
|
cfg.files.emplace_back(fs::path("content/rates.json"));
|
|
// Site content. Loaded from the VFS at startup rather than compiled in, so
|
|
// editing a project blurb or refreshing the post list is not a recompile.
|
|
// NOTE: cfg.files flattens to the bundle root (copied by filename), so
|
|
// this is read back as "posts.json". Products, projects, legal and demos
|
|
// are COMPILED IN (Catcrafts.Shared:Content) — only pipeline-generated
|
|
// data still travels as files.
|
|
cfg.files.emplace_back(fs::path("content/posts.json"));
|
|
|
|
ApplyReleaseTrimming(cfg);
|
|
EnableWasiBrowserRuntime(cfg);
|
|
|
|
return cfg;
|
|
}
|