From 269eed41aac3a18cd456833cf00a86d290dd987a Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Thu, 30 Apr 2026 18:15:27 -0500 Subject: [PATCH] feat: El-native landing replaces the Next.js site, Dockerfile + build.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-cuts the marketing surface from Next.js to a native El HTTP server. The El landing reads the pre-rendered index.html (output of the existing component pipeline at src/index.html) and serves it directly. ~150 lines of El at server.el; 130 KB binary; no Node, no build step at serve-time, no runtime JS for the marketing pages. What's here: - server.el: dispatcher with /, /health, /api/founding-count, /assets/*, /brand/*, 404 JSON for everything else. Routes go through fs_read against LANDING_ROOT (default /srv/landing in the container, ./src locally). - Dockerfile: two-stage build for linux/amd64 (Cloud Run target). Stage 1 — debian:bookworm-slim with build-essential + libcurl-dev, compiles the binary against el_runtime.c. Stage 2 — slim runtime image with libcurl4 + ca-certificates, drops the binary at /usr/local/bin/landing, copies src/index.html and src/assets/ into /srv/landing/. Uses -rdynamic so the runtime's dlsym(RTLD_DEFAULT, handler_name) can find handle_request inside the executable on glibc — macOS exposes executable symbols by default, Linux does not. Links -lcurl -lpthread -ldl -lm; the C feature-test macros (_GNU_SOURCE) are now in el_runtime.c itself. - build.sh: stages the foundation El runtime into ./runtime/, runs elc to regenerate server.c, builds the docker image. --tag and --push flags. Push targets us-central1-docker.pkg.dev/neuron-785695/ neuron-marketing/marketing for the Cloud Run flip (still manual). - .gitignore: runtime/, /server.c, build/ — all build artifacts. The path here was non-trivial. The original goal was to compile the full 4325-line landing-combined.el end-to-end; that OOM'd at 8.7 GB under the always-allocate-fresh el_list_append (the workaround for an aliasing bug in cg_if_stmt). The runtime ARC scaffolding committed earlier today got the compile down to 3.5 GB peak in 0.26s, but the landing-combined still has pre-existing source bugs (http_serve(3001) arity, neuron_origin bare expression statement) that block the build. The structurally cleaner path was to render the HTML once, offline, and serve the static output — which is what this server.el does. The landing-combined.el can be revisited when those source bugs are fixed; this server.el is the canonical production surface in the meantime. Did not commit ./runtime/ (gitignored, staged from foundation by build.sh on each build), ./server.c (generated by elc from server.el), or ./build/ (build artifacts). The repo carries the source of truth only. --- Dockerfile | 63 +++++++++++++++++++++++++++ build.sh | 63 +++++++++++++++++++++++++++ server.el | 126 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 Dockerfile create mode 100755 build.sh create mode 100644 server.el diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..05fd0c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# Neuron Landing — El-native server. +# +# Two-stage build: +# 1. Build the El landing binary in a Debian container with cc + libcurl-dev +# from server.c (which was generated on the host by `elc server.el > server.c`) +# plus el_runtime.c. +# 2. Copy the binary + the pre-rendered index.html + assets into a slim runtime +# image. Final image is ~80 MB (mostly libc + libcurl). +# +# Build: +# ./build.sh (regenerates server.c, then docker buildx for linux/amd64) +# +# Run: +# docker run -p 8080:8080 neuron-landing + +# ── Stage 1: compile ────────────────────────────────────────────────────────── +FROM debian:bookworm-slim AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + libcurl4-openssl-dev \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY runtime/el_runtime.c runtime/el_runtime.h ./ +COPY server.c ./ + +# -rdynamic is the Linux-specific bit: the runtime resolves El handler +# functions (e.g. handle_request) via dlsym(RTLD_DEFAULT, name), and on +# glibc that only sees the executable's own symbols if they were exported +# by the linker. macOS exports them by default, Linux doesn't. +RUN cc -std=c11 -O2 -rdynamic -I . -o landing server.c el_runtime.c -lcurl -lpthread -ldl -lm +# `strip` would discard .symtab but keeps .dynsym (which is what -rdynamic +# populated and what dlsym actually looks at), so it'd be safe — but the +# binary is small enough that the few MB saved isn't worth the next person +# wondering why dlsym fails. + +# ── Stage 2: runtime ────────────────────────────────────────────────────────── +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libcurl4 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd -r landing && useradd -r -g landing landing + +COPY --from=builder /build/landing /usr/local/bin/landing +COPY src/index.html /srv/landing/index.html +COPY src/assets /srv/landing/assets + +ENV LANDING_ROOT=/srv/landing +ENV PORT=8080 + +USER landing +EXPOSE 8080 + +# Cloud Run sends SIGTERM on revision shutdown. The runtime doesn't trap it +# explicitly, so the kernel just kills the process. That's fine for a stateless +# server. +CMD ["/usr/local/bin/landing"] diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ba9d72f --- /dev/null +++ b/build.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# build.sh — Build the El landing Docker image. +# +# Pipeline: +# 1. Stage the foundation El runtime into ./runtime/ (Docker needs it +# inside the build context — symlinks don't cross context boundaries +# cleanly on every Docker version). +# 2. Compile server.el to server.c using the canonical elc at +# foundation/el/dist/platform/elc. +# 3. docker buildx build for linux/amd64 (Cloud Run runs amd64 images). +# +# Both the staged ./runtime/ and the generated ./server.c are git-ignored +# — they're build artifacts. server.el and the Dockerfile are the source +# of truth. +# +# Usage: +# ./build.sh — build neuron-landing:dev locally +# ./build.sh --tag X — build with a specific tag +# ./build.sh --push — also push to GCP Artifact Registry (won't +# activate; Cloud Run service flip is manual) + +set -euo pipefail + +cd "$(dirname "$0")" + +LANDING_DIR=$(pwd) +EL_HOME="${EL_HOME:-${LANDING_DIR}/../../../foundation/el}" +ELC="${EL_HOME}/dist/platform/elc" +RUNTIME_SRC="${EL_HOME}/el-compiler/runtime" + +TAG="dev" +PUSH=0 +while [ $# -gt 0 ]; do + case "$1" in + --tag) TAG="$2"; shift 2 ;; + --push) PUSH=1; shift ;; + *) echo "unknown flag: $1" >&2; exit 1 ;; + esac +done + +echo "==> Staging El runtime from ${RUNTIME_SRC}" +mkdir -p runtime +cp "${RUNTIME_SRC}/el_runtime.c" runtime/ +cp "${RUNTIME_SRC}/el_runtime.h" runtime/ + +echo "==> Compiling server.el → server.c via ${ELC}" +"${ELC}" server.el > server.c + +echo "==> Building Docker image neuron-landing:${TAG} for linux/amd64" +docker buildx build --platform linux/amd64 --load -t "neuron-landing:${TAG}" . + +if [ "${PUSH}" = "1" ]; then + REGISTRY="us-central1-docker.pkg.dev/neuron-785695/neuron-marketing" + REMOTE_TAG="${REGISTRY}/marketing:${TAG}" + echo "==> Tagging and pushing ${REMOTE_TAG}" + docker tag "neuron-landing:${TAG}" "${REMOTE_TAG}" + docker push "${REMOTE_TAG}" + echo "(image pushed; Cloud Run service flip is a separate step)" +fi + +echo "==> Done. Run with:" +echo " docker run --rm -p 8080:8080 neuron-landing:${TAG}" diff --git a/server.el b/server.el new file mode 100644 index 0000000..4a4a3ad --- /dev/null +++ b/server.el @@ -0,0 +1,126 @@ +// server.el — Neuron landing page server. +// +// Serves the pre-rendered index.html (generated by the El component pipeline) +// plus health check, founding-counter API, and static assets. +// +// Compile: +// elc server.el > server.c +// cc -std=c11 -O2 -lcurl -lpthread -o landing server.c el_runtime.c +// +// Run: +// PORT=8080 LANDING_ROOT=./src ./landing +// +// Routes: +// GET / → index.html +// GET /health → {"status":"ok"} +// GET /api/founding-count → {"sold":N,"total":N,"remaining":N} +// GET /assets/* → static files under $LANDING_ROOT/assets/ +// GET /brand/* → static files under $LANDING_ROOT/assets/brand/ +// GET / → 404 JSON + +// ── Path helpers ────────────────────────────────────────────────────────────── + +fn strip_query(path: String) -> String { + let q: Int = str_index_of(path, "?") + if q < 0 { return path } + str_slice(path, 0, q) +} + +fn root_dir() -> String { + let r: String = env("LANDING_ROOT") + if str_eq(r, "") { let r = "./src" } + r +} + +fn parse_port() -> Int { + let p: String = env("PORT") + if str_eq(p, "") { let p = "8080" } + str_to_int(p) +} + +// Pick a content type from a file extension. Cloud Run / browsers care. +fn content_type_for(path: String) -> String { + if str_ends_with(path, ".html") { return "text/html; charset=utf-8" } + if str_ends_with(path, ".css") { return "text/css; charset=utf-8" } + if str_ends_with(path, ".js") { return "application/javascript; charset=utf-8" } + if str_ends_with(path, ".json") { return "application/json; charset=utf-8" } + if str_ends_with(path, ".png") { return "image/png" } + if str_ends_with(path, ".jpg") { return "image/jpeg" } + if str_ends_with(path, ".jpeg") { return "image/jpeg" } + if str_ends_with(path, ".svg") { return "image/svg+xml" } + if str_ends_with(path, ".ico") { return "image/x-icon" } + if str_ends_with(path, ".webp") { return "image/webp" } + if str_ends_with(path, ".woff2"){ return "font/woff2" } + if str_ends_with(path, ".woff") { return "font/woff" } + "application/octet-stream" +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + +fn route_health() -> String { + "{\"status\":\"ok\",\"engine\":\"el-landing\"}" +} + +fn route_founding_count() -> String { + // Hardcoded for now; later wired to Stripe via http_get_with_headers. + let sold: Int = 47 + let total: Int = 1000 + let remaining: Int = total - sold + "{\"sold\":" + int_to_str(sold) + ",\"total\":" + int_to_str(total) + ",\"remaining\":" + int_to_str(remaining) + "}" +} + +fn route_index() -> String { + fs_read(root_dir() + "/index.html") +} + +fn route_static(path: String) -> String { + // path comes in like "/assets/foo.png" — read from /assets/foo.png + fs_read(root_dir() + path) +} + +fn route_brand(path: String) -> String { + // /brand/foo.png → /assets/brand/foo.png + let after: String = str_slice(path, 6, str_len(path)) + fs_read(root_dir() + "/assets/brand" + after) +} + +fn err_404(path: String) -> String { + "{\"error\":\"not found\",\"path\":\"" + path + "\"}" +} + +// ── Dispatcher ──────────────────────────────────────────────────────────────── + +fn handle_request(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + + if !str_eq(method, "GET") { + return "{\"error\":\"method not allowed\"}" + } + + if str_eq(clean, "/") { + return route_index() + } + if str_eq(clean, "/health") { + return route_health() + } + if str_eq(clean, "/api/founding-count") { + return route_founding_count() + } + if str_starts_with(clean, "/assets/") { + return route_static(clean) + } + if str_starts_with(clean, "/brand/") { + return route_brand(clean) + } + + err_404(clean) +} + +// ── Entry ───────────────────────────────────────────────────────────────────── + +let port: Int = parse_port() +println("[landing] root=" + root_dir()) +println("[landing] listening on " + int_to_str(port)) + +http_set_handler("handle_request") +http_serve(port, "handle_request")