catcrafts.net/project.cpp

377 lines
19 KiB
C++
Raw Normal View History

/*
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;
2026-08-05 04:18:37 +02:00
// 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.
2026-08-15 00:54:05 +02:00
// crafter-build --product=server
2026-08-05 04:18:37 +02:00
//
// 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.
2026-08-15 00:54:05 +02:00
//
// 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.
2026-08-05 04:18:37 +02:00
// 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>();
2026-08-15 00:54:05 +02:00
// 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();
2026-08-05 04:18:37 +02:00
shared->path = "./";
shared->name = "Catcrafts.Shared";
shared->outputName = "Catcrafts.Shared";
ApplyStandardArgs(*shared, args); // inherits --target / --debug from the parent
shared->type = ConfigurationType::LibraryStatic;
2026-08-10 01:37:26 +02:00
std::array<fs::path, 11> ifaces = {
2026-08-05 04:18:37 +02:00
"shared/interfaces/Catcrafts.Shared",
"shared/interfaces/Catcrafts.Shared-Html",
"shared/interfaces/Catcrafts.Shared-Form",
"shared/interfaces/Catcrafts.Shared-Json",
"shared/interfaces/Catcrafts.Shared-Model",
2026-08-10 01:37:26 +02:00
"shared/interfaces/Catcrafts.Shared-Media",
"shared/interfaces/Catcrafts.Shared-Markdown",
2026-08-05 04:18:37 +02:00
"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();
}
2026-08-15 00:54:05 +02:00
// 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.
2026-08-15 00:54:05 +02:00
//
// 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.
2026-08-15 00:54:05 +02:00
core->linkFlags.push_back("-lssl");
return core.get();
}
2026-08-05 04:18:37 +02:00
// 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) {
2026-08-05 04:18:37 +02:00
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 == "--local") { 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,
});
2026-08-15 00:54:05 +02:00
Configuration* core = ServerCore(args, network);
Configuration* shared = SharedLibrary(args);
2026-08-05 04:18:37 +02:00
Configuration cfg;
cfg.path = "./";
cfg.name = "Catcrafts.Server";
cfg.outputName = "catcrafts-server";
cfg.type = ConfigurationType::Executable;
ApplyStandardArgs(cfg, args);
2026-08-15 00:54:05 +02:00
cfg.dependencies = { core, shared, network };
2026-08-05 04:18:37 +02:00
2026-08-15 00:54:05 +02:00
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = {
2026-08-05 04:18:37 +02:00
"server/implementations/main",
};
cfg.GetInterfacesAndImplementations(ifaces, impls);
2026-08-05 05:41:22 +02:00
// 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.
2026-08-05 05:50:01 +02:00
// 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");
2026-08-05 05:41:22 +02:00
2026-08-05 04:18:37 +02:00
ApplyReleaseTrimming(cfg);
2026-08-15 00:54:05 +02:00
// ── 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 });
2026-08-15 00:54:05 +02:00
// ── 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));
2026-08-05 04:18:37 +02:00
return cfg;
}
// ── web (default) ─────────────────────────────────────────────────
std::vector<std::string> depArgs(args.begin(), args.end());
depArgs.push_back("--target=wasm32-wasip1");
// --local 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 == "--local") { useLocal = true; break; }
}
if (useLocal && std::find(depArgs.begin(), depArgs.end(), std::string("--local")) == depArgs.end()) {
depArgs.push_back("--local");
}
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);
2026-08-05 04:18:37 +02:00
// 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",
2026-07-19 01:13:30 +02:00
"interfaces/Catcrafts-Demo",
};
2026-08-05 04:18:37 +02:00
std::array<fs::path, 4> impls = {
"implementations/main",
"implementations/Catcrafts-Root",
"implementations/Catcrafts-Views",
2026-07-19 01:13:30 +02:00
"implementations/Catcrafts-Demo",
};
cfg.GetInterfacesAndImplementations(ifaces, impls);
cfg.files.emplace_back(fs::path("styles/styles.css"));
cfg.files.emplace_back(fs::path("robots.txt"));
2026-08-05 04:18:37 +02:00
// 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"));
2026-08-05 04:18:37 +02:00
cfg.files.emplace_back(fs::path("feed.xml"));
cfg.files.emplace_back(fs::path("favicon.svg"));
2026-08-18 21:21:41 +02:00
// 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"));
2026-07-19 01:13:30 +02:00
// 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"));
2026-08-05 04:18:37 +02:00
// Product photography. CC BY-SA 4.0, © Fairphone (official render via
// Wikimedia Commons) — the attribution lives 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"));
// 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"));
2026-08-05 04:18:37 +02:00
ApplyReleaseTrimming(cfg);
EnableWasiBrowserRuntime(cfg);
return cfg;
}