diff --git a/.do/app.yaml b/.do/app.yaml new file mode 100644 index 0000000..65f5587 --- /dev/null +++ b/.do/app.yaml @@ -0,0 +1,28 @@ +# DigitalOcean App Platform spec for merjs. +# +# `doctl apps create --spec .do/app.yaml` +# or paste this into the "App Spec" tab in the DO control panel. + +name: merjs +region: nyc + +services: + - name: web + github: + repo: justrach/merjs + branch: main + deploy_on_push: true + dockerfile_path: Dockerfile + instance_count: 1 + instance_size_slug: basic-xxs + http_port: 3000 + health_check: + http_path: /_mer/health + initial_delay_seconds: 5 + period_seconds: 15 + timeout_seconds: 2 + failure_threshold: 3 + envs: + - key: HOST + value: "0.0.0.0" + scope: RUN_TIME diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..d1c583a --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,55 @@ +name: Publish Docker image + +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+"] + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + docker: + runs-on: ubuntu-latest + name: Build & push multi-arch image to GHCR + + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build & push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/AGENTS.md b/AGENTS.md index 0126bc6..41671f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,17 @@ zig build prod - `http://localhost:3000/_mer/debug?format=json` — same as above, machine-readable JSON - `http://localhost:3000/_mer/events` — SSE hot reload stream +### Production endpoints (always available) + +- `GET /_mer/health` — liveness probe, returns `{"status":"ok",...}` JSON +- `GET /_mer/ready` — readiness probe, same payload (separate path for k8s) + +### Environment variables for deployment + +- `PORT` — listening port (PaaS standard: Fly, Render, Railway, Heroku, Cloud Run inject this) +- `HOST` — bind interface; defaults to `0.0.0.0` in `--no-dev`, `127.0.0.1` in dev +- `MERJS_DEV=0` — disable hot reload (equivalent to `--no-dev`) + ### Verbose mode Run with `--verbose` to log every request with timing: diff --git a/Dockerfile b/Dockerfile index da3e832..694073f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,37 +1,79 @@ -# merjs Dockerfile — multi-stage build -# Usage: +# merjs Dockerfile — multi-stage, multi-arch, distroless-friendly. +# +# Quick start: # docker build -t merjs . -# docker run -p 3000:3000 merjs +# docker run --rm -p 3000:3000 merjs +# +# Build a specific arch: +# docker buildx build --platform linux/amd64,linux/arm64 -t merjs . # ── Stage 1: Build ─────────────────────────────────────────────────────────── FROM ubuntu:24.04 AS builder -ARG ZIG_VERSION=0.15.2 +# Pin Zig version. Match build.zig.zon `minimum_zig_version`. +ARG ZIG_VERSION=0.16.0 -RUN apt-get update && apt-get install -y curl xz-utils && rm -rf /var/lib/apt/lists/* +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl xz-utils ca-certificates \ + && rm -rf /var/lib/apt/lists/* -# Install Zig (auto-detect arch) -RUN ARCH=$(uname -m) && \ - curl -sL "https://ziglang.org/download/${ZIG_VERSION}/zig-${ARCH}-linux-${ZIG_VERSION}.tar.xz" \ - | tar -xJ -C /opt && \ - ln -s /opt/zig-${ARCH}-linux-${ZIG_VERSION}/zig /usr/local/bin/zig +# Install Zig (auto-detect arch). Both x86_64 and aarch64 are supported. +RUN ARCH=$(uname -m) \ + && curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-${ARCH}-linux-${ZIG_VERSION}.tar.xz" \ + | tar -xJ -C /opt \ + && ln -s /opt/zig-${ARCH}-linux-${ZIG_VERSION}/zig /usr/local/bin/zig \ + && zig version WORKDIR /app + +# Copy build configuration first to maximize Docker layer cache reuse. +COPY build.zig build.zig.zon ./ + +# Pre-warm the package cache (best-effort; ignore failures on first build). +RUN zig build --help >/dev/null 2>&1 || true + +# Now copy the rest of the source. COPY . . + +# Clean any cached build artifacts that may have been copied in. RUN rm -rf .zig-cache zig-out src/generated -RUN zig build codegen -RUN zig build wasm -RUN zig build -Doptimize=ReleaseSmall +RUN zig build codegen \ + && zig build -Doptimize=ReleaseSmall -# ── Stage 2: Runtime ───────────────────────────────────────────────────────── +# ── Stage 2: Runtime ──────────────────────────────────────────────────────── FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +LABEL org.opencontainers.image.source="https://github.com/justrach/merjs" +LABEL org.opencontainers.image.description="merjs — Next.js-style web framework written in Zig." +LABEL org.opencontainers.image.licenses="MIT" + +# tini = proper PID 1 (forwards signals, reaps zombies). Only ~80KB. +# ca-certificates = HTTPS for outgoing fetch.zig calls. +# wget = HEALTHCHECK probe (also handy for debugging). +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tini wget \ + && rm -rf /var/lib/apt/lists/* + +# Run as a non-root user — required by many PaaS (OpenShift, Cloud Run, k8s). +RUN useradd --system --create-home --shell /usr/sbin/nologin --uid 10001 merjs WORKDIR /app -COPY --from=builder /app/zig-out/bin/merjs ./merjs -COPY --from=builder /app/examples/site/public ./public + +COPY --from=builder --chown=merjs:merjs /app/zig-out/bin/merjs ./merjs +COPY --from=builder --chown=merjs:merjs /app/examples/site/public ./public + +USER merjs + +# PORT is honored by main.zig (PaaS standard). Override with `-e PORT=8080`. +ENV PORT=3000 \ + HOST=0.0.0.0 EXPOSE 3000 -CMD ["./merjs", "--host", "0.0.0.0"] + +# Liveness probe — relies on the `/_mer/health` endpoint added in main. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider "http://127.0.0.1:${PORT}/_mer/health" || exit 1 + +ENTRYPOINT ["/usr/bin/tini", "--", "/app/merjs"] +CMD ["--no-dev"] diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..6f56e86 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: ./merjs --no-dev diff --git a/README.md b/README.md index 70beb3e..148a2f9 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Features · Demo · How It Works · - Deploy · + Deploy · Changelog

@@ -293,9 +293,68 @@ Singapore data dashboard: **[sgdata.merlionjs.com](https://sgdata.merlionjs.com) --- -## Deploy to Cloudflare Workers +## Deploy -1. Edit `worker/wrangler.toml` — set your project name, route/domain, and any R2 bindings you need. +merjs ships ready-to-go configs for every major host. The same Docker image works +everywhere; PaaS providers inject `PORT` and `main.zig` reads it. Health checks +hit `/_mer/health` (always available, no extra setup). + +### Docker (any host, any machine) + +```bash +docker build -t merjs . +docker run --rm -p 3000:3000 merjs +# or: docker compose up --build +``` + +The image: +- Pins Zig 0.16.0 (matches `build.zig.zon`) +- Runs as a non-root user (`uid 10001`) +- Uses `tini` as PID 1 so `Ctrl-C` and orchestrator stop signals work +- Has a `HEALTHCHECK` against `/_mer/health` +- Multi-arch: `linux/amd64` + `linux/arm64` + +A multi-arch image is published to GHCR on every tag: + +```bash +docker pull ghcr.io/justrach/merjs:latest +docker run --rm -p 3000:3000 ghcr.io/justrach/merjs:latest +``` + +### Fly.io + +```bash +flyctl launch --copy-config --no-deploy +flyctl deploy +``` + +`fly.toml` is included. Fly injects `PORT` and the health check hits `/_mer/health`. + +### Render.com + +`render.yaml` is included — push the repo and click **New Blueprint Instance** in +the Render dashboard. + +### Railway + +`railway.json` is included — point Railway at the repo and it picks up the +Dockerfile + healthcheck automatically. + +### DigitalOcean App Platform + +```bash +doctl apps create --spec .do/app.yaml +``` + +### Heroku-style PaaS (Procfile) + +A `Procfile` is included for any platform that respects the +[Heroku 12-factor process model](https://12factor.net/) — it runs the binary +directly with `--no-dev`, and the platform's `PORT` env var binds correctly. + +### Cloudflare Workers (zero cold start, edge) + +1. Edit `worker/wrangler.toml` — set your project name, route/domain, and any R2 bindings. 2. Build and deploy: ```bash @@ -308,6 +367,27 @@ If your routes use secrets (API keys, etc.), set them first: `wrangler secret pu The `worker/worker.js` shim handles the fetch event and passes requests to the WASM binary. +### Runtime selection (io_uring) + +`src/runtime.zig` exposes a single `runtime.io` instance and a `Backend` enum +(`evented` / `threaded`). On startup, you'll see: + +``` +info(runtime): io backend: Threaded (blocking syscalls) +``` + +The framework is wired to opportunistically pick **Evented** (Linux io_uring) +when the toolchain supports it, and gracefully **fall back to Threaded** if +io_uring init fails (old kernel, restricted seccomp, sandboxed container, …) so +the same binary boots on every host. + +While merjs is pinned to Zig **0.16.0**, the io_uring path is gated off — the +release tarball's `std/Io/Uring.zig` has a known compile-time bug (missing +`error.ReadOnlyFileSystem` in `Dir.OpenError`/`Dir.RealPathFileError`) that +prevents Evented from being analyzed at all. Flip +`stdlib_evented_works = true` in `src/runtime.zig` once the toolchain pin +moves past 0.16.0; no other code changes needed. + --- ## How It Works diff --git a/build.zig b/build.zig index 8fc514f..471a536 100644 --- a/build.zig +++ b/build.zig @@ -4,10 +4,21 @@ const examples = @import("build/examples.zig"); const tools = @import("build/tools.zig"); const packages = @import("build/packages.zig"); +/// Single source of truth for the framework version. +/// Every runtime constant (mer.version, cli.version, the macOS bundle plist, +/// /_mer/health JSON) is derived from `zon.version` via the build_options +/// module wired below. To bump the version, edit build.zig.zon only. +const zon = @import("build.zig.zon"); + pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + // ── build_options module (zon-derived constants) ──────────────────────── + const build_opts = b.addOptions(); + build_opts.addOption([]const u8, "version", zon.version); + const build_options_mod = build_opts.createModule(); + // ── dhi dependency ────────────────────────────────────────────────────── const dhi_dep = b.dependency("dhi", .{}); const dhi_model_mod = dhi_dep.module("model"); @@ -33,6 +44,7 @@ pub fn build(b: *std.Build) void { mer_mod.addImport("dhi_model", dhi_model_mod); mer_mod.addImport("dhi_validator", dhi_validator_mod); mer_mod.addImport("runtime", runtime_mod); + mer_mod.addImport("build_options", build_options_mod); // ── turboapi-core (shared router + HTTP utilities) ── const core_dep = b.dependency("turboapi_core", .{}); @@ -176,6 +188,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); cli_mod.addImport("runtime", runtime_mod); + cli_mod.addImport("build_options", build_options_mod); const cli_exe = b.addExecutable(.{ .name = "mer", .root_module = cli_mod }); b.step("cli", "Build the `mer` CLI binary").dependOn(&b.addInstallArtifact(cli_exe, .{}).step); @@ -212,6 +225,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); + cli_test_mod.addImport("build_options", build_options_mod); test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = cli_test_mod })).step); } // Run router + runtime inline tests (through mer.zig as root to avoid @@ -228,6 +242,7 @@ pub fn build(b: *std.Build) void { mer_test_mod.addImport("dhi_validator", dhi_validator_mod); mer_test_mod.addImport("turboapi-core", core_mod); mer_test_mod.addImport("mer", mer_test_mod); + mer_test_mod.addImport("build_options", build_options_mod); test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = mer_test_mod })).step); } @@ -351,7 +366,7 @@ pub fn build(b: *std.Build) void { desktop_step.dependOn(&desktop_install.step); // ── .app bundle — MerApp.app/Contents/MacOS/merapp + Info.plist ────── - const plist = b.addWriteFile("MerApp.app/Contents/Info.plist", + const plist = b.addWriteFile("MerApp.app/Contents/Info.plist", b.fmt( \\ \\ \\ @@ -359,12 +374,12 @@ pub fn build(b: *std.Build) void { \\ CFBundleExecutable merapp \\ CFBundleIdentifier com.merjs.desktop \\ CFBundleName MerApp - \\ CFBundleVersion 0.2.5 + \\ CFBundleVersion {s} \\ NSHighResolutionCapable \\ NSPrincipalClass NSApplication \\ \\ - ); + , .{zon.version})); const bundle_bin = b.addInstallFile( desktop_exe.getEmittedBin(), "MerApp.app/Contents/MacOS/merapp", diff --git a/build.zig.zon b/build.zig.zon index 644b93a..803315c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,7 +1,7 @@ .{ .name = .merjs, .fingerprint = 0xaa135c45924bbfa8, - .version = "0.2.5", + .version = "0.2.53", .minimum_zig_version = "0.16.0", .dependencies = .{ .dhi = .{ diff --git a/cli.zig b/cli.zig index 86dcf97..0c7b65f 100644 --- a/cli.zig +++ b/cli.zig @@ -11,7 +11,7 @@ const std = @import("std"); const builtin = @import("builtin"); const runtime = @import("runtime"); -pub const version = "0.2.5"; +pub const version = @import("build_options").version; const print = std.debug.print; @@ -970,3 +970,11 @@ fn printUsage() void { print(" mer --version print version\n", .{}); print("\n https://github.com/justrach/merjs\n\n", .{}); } + + +// --- Tests ------------------------------------------------------------------ + +test "cli version matches build.zig.zon" { + const expected = @import("build_options").version; + try std.testing.expectEqualStrings(expected, version); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3268948 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +# docker-compose.yml — one-command run for merjs. +# +# docker compose up --build +# +# Set PORT to publish on a different host port: +# PORT=8080 docker compose up --build + +services: + merjs: + build: . + image: merjs:latest + restart: unless-stopped + environment: + PORT: "3000" + HOST: "0.0.0.0" + # Forward standard observability env vars if you set them in your shell. + SENTRY_DSN: "${SENTRY_DSN:-}" + DD_AGENT_HOST: "${DD_AGENT_HOST:-}" + DD_DOGSTATSD_PORT: "${DD_DOGSTATSD_PORT:-}" + ports: + - "${PORT:-3000}:3000" + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/_mer/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..f69b426 --- /dev/null +++ b/fly.toml @@ -0,0 +1,43 @@ +# fly.toml — Fly.io deployment config for merjs. +# +# Quick deploy: +# flyctl launch --copy-config --no-deploy # generate app name + region +# flyctl deploy +# +# Fly automatically injects PORT — main.zig reads it. No code changes required. + +app = "merjs" +primary_region = "iad" +kill_signal = "SIGINT" +kill_timeout = "10s" + +[build] + dockerfile = "Dockerfile" + +[env] + HOST = "0.0.0.0" + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + + [http_service.concurrency] + type = "requests" + soft_limit = 500 + hard_limit = 1000 + + [[http_service.checks]] + interval = "15s" + timeout = "2s" + grace_period = "5s" + method = "GET" + path = "/_mer/health" + +[[vm]] + memory = "256mb" + cpu_kind = "shared" + cpus = 1 diff --git a/install.sh b/install.sh index 76e72cc..82afe05 100755 --- a/install.sh +++ b/install.sh @@ -1,161 +1,138 @@ -#!/bin/bash -# install.sh — One-liner installer for merjs -# Usage: curl -fsSL https://merjs.trilok.ai/install.sh | bash -# Or: wget -qO- https://merjs.trilok.ai/install.sh | bash - -set -e - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Config -REPO="justrach/merjs" -INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" -VERSION="${VERSION:-latest}" - -# Detect OS - detect_os() { - case "$(uname -s)" in - Linux*) echo "linux";; - Darwin*) echo "macos";; - CYGWIN*) echo "windows";; - MINGW*) echo "windows";; - MSYS*) echo "windows";; - *) echo "unknown";; - esac +#!/bin/sh +# install.sh — One-liner installer for the `mer` CLI from merjs releases. +# Usage: +# curl -fsSL https://merjs.trilok.ai/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/justrach/merjs/main/install.sh | sh +# +# Env overrides: +# MER_INSTALL_VERSION=v0.2.5 pin a specific release (default: latest) +# MER_INSTALL_DIR=/opt/bin pick install location +# MER_INSTALL_REPO=fork/merjs install from a fork + +set -eu + +REPO="${MER_INSTALL_REPO:-justrach/merjs}" +VERSION="${MER_INSTALL_VERSION:-latest}" + +# ── helpers ──────────────────────────────────────────────────────────────── +die() { printf '\033[0;31merror:\033[0m %s\n' "$*" >&2; exit 1; } +info() { printf '\033[0;34m::\033[0m %s\n' "$*" >&2; } +ok() { printf '\033[0;32m::\033[0m %s\n' "$*" >&2; } + +need() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" } -# Detect architecture -detect_arch() { - case "$(uname -m)" in - x86_64|amd64) echo "x86_64";; - arm64|aarch64) echo "arm64";; - armv7l) echo "armv7";; - i386|i686) echo "x86";; - *) echo "unknown";; - esac -} - -OS=$(detect_os) -ARCH=$(detect_arch) - -if [ "$OS" = "unknown" ] || [ "$ARCH" = "unknown" ]; then - echo -e "${RED}Error: Unsupported platform: ${OS}/${ARCH}${NC}" - echo "Supported: linux/x86_64, linux/arm64, macos/x86_64, macos/arm64" - exit 1 +if command -v curl >/dev/null 2>&1; then + fetch() { curl -fsSL "$1" -o "$2"; } +elif command -v wget >/dev/null 2>&1; then + fetch() { wget -qO "$2" "$1"; } +else + die "need curl or wget to download releases" fi -echo -e "${BLUE}🚀 merjs installer${NC}" -echo " Platform: ${OS}/${ARCH}" -echo " Install dir: ${INSTALL_DIR}" -echo "" - -# Check for required tools - check_deps() { - if ! command -v curl &> /dev/null && ! command -v wget &> /dev/null; then - echo -e "${RED}Error: curl or wget is required${NC}" - exit 1 - fi - - if ! command -v tar &> /dev/null; then - echo -e "${RED}Error: tar is required${NC}" - exit 1 - fi -} - -download() { - local url="$1" - local output="$2" - - if command -v curl &> /dev/null; then - curl -fsSL "$url" -o "$output" - else - wget -q "$url" -O "$output" - fi -} - -# Get latest version if not specified +need uname +need mktemp +need chmod +need mkdir +need mv + +# ── platform detection ────────────────────────────────────────────────────── +case "$(uname -s)" in + Darwin) os="macos" ;; + Linux) os="linux" ;; + *) die "unsupported OS: $(uname -s) (supported: macOS, Linux)" ;; +esac + +case "$(uname -m)" in + arm64|aarch64) arch="aarch64" ;; + x86_64|amd64) arch="x86_64" ;; + *) die "unsupported architecture: $(uname -m) (supported: x86_64, aarch64)" ;; +esac + +# ── asset resolution ─────────────────────────────────────────────────────── +asset="mer-${os}-${arch}" +base="https://github.com/${REPO}/releases" if [ "$VERSION" = "latest" ]; then - echo -e "${YELLOW}📦 Fetching latest version...${NC}" - VERSION=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') - if [ -z "$VERSION" ]; then - VERSION="v0.2.5" # fallback - fi -fi - -echo -e "${BLUE} Version: ${VERSION}${NC}" - -# Build download URL -FILENAME="merjs-${VERSION}-${OS}-${ARCH}.tar.gz" -URL="https://github.com/${REPO}/releases/download/${VERSION}/${FILENAME}" - -# Create temp directory -TMP_DIR=$(mktemp -d) -trap "rm -rf $TMP_DIR" EXIT - -echo -e "${YELLOW}⬇️ Downloading...${NC}" -if ! download "$URL" "${TMP_DIR}/${FILENAME}"; then - echo -e "${RED}Error: Failed to download ${URL}${NC}" - echo "You may need to build from source:" - echo " git clone https://github.com/${REPO}.git" - echo " cd merjs && zig build cli" - exit 1 + bin_url="${base}/latest/download/${asset}" + sums_url="${base}/latest/download/checksums.txt" +else + bin_url="${base}/download/${VERSION}/${asset}" + sums_url="${base}/download/${VERSION}/checksums.txt" fi -echo -e "${YELLOW}📂 Extracting...${NC}" -tar -xzf "${TMP_DIR}/${FILENAME}" -C "$TMP_DIR" - -# Find binaries -MER_BIN=$(find "$TMP_DIR" -name "mer" -type f | head -1) -MERJS_BIN=$(find "$TMP_DIR" -name "merjs" -type f | head -1) - -if [ -z "$MER_BIN" ]; then - echo -e "${RED}Error: mer binary not found in archive${NC}" - exit 1 +# ── install location ─────────────────────────────────────────────────────── +if [ -n "${MER_INSTALL_DIR:-}" ]; then + install_dir="$MER_INSTALL_DIR" +elif [ -w /usr/local/bin ]; then + install_dir="/usr/local/bin" +else + install_dir="${HOME}/.local/bin" fi -echo -e "${YELLOW}🔧 Installing...${NC}" +info "merjs installer" +info " platform ${os}/${arch}" +info " version ${VERSION}" +info " install dir ${install_dir}" + +# ── download + verify ────────────────────────────────────────────────────── +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT INT TERM + +bin_path="${tmpdir}/mer" +sums_path="${tmpdir}/checksums.txt" + +info "downloading ${asset}" +fetch "$bin_url" "$bin_path" || die "failed to download ${bin_url}" +fetch "$sums_url" "$sums_path" || die "failed to download checksums" + +verify() { + expected=$(grep " ${asset}\$" "$sums_path" | awk '{print $1}') + [ -n "$expected" ] || { info "no checksum entry for ${asset}; skipping verification"; return 0; } + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$bin_path" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$bin_path" | awk '{print $1}') + else + info "no sha256 tool found; skipping verification" + return 0 + fi + [ "$expected" = "$actual" ] || die "checksum mismatch for ${asset}: expected ${expected}, got ${actual}" + ok "checksum verified" +} +verify -# Check if we need sudo -if [ -w "$INSTALL_DIR" ]; then +# ── install ──────────────────────────────────────────────────────────────── +chmod +x "$bin_path" +if [ -w "$install_dir" ] || [ ! -e "$install_dir" ]; then SUDO="" else - echo -e "${YELLOW} (may prompt for sudo password)${NC}" + info " (may prompt for sudo password)" SUDO="sudo" fi - -# Install binaries -$SUDO mkdir -p "$INSTALL_DIR" -$SUDO cp "$MER_BIN" "${INSTALL_DIR}/mer" -$SUDO chmod +x "${INSTALL_DIR}/mer" - -if [ -n "$MERJS_BIN" ]; then - $SUDO cp "$MERJS_BIN" "${INSTALL_DIR}/merjs" - $SUDO chmod +x "${INSTALL_DIR}/merjs" -fi - -# Verify installation -if command -v mer &> /dev/null; then - INSTALLED_VERSION=$(mer --version 2>/dev/null || echo "unknown") - echo -e "${GREEN}✅ merjs installed successfully!${NC}" - echo "" - echo " Version: ${INSTALLED_VERSION}" - echo " Location: $(which mer)" - echo "" - echo -e "${BLUE}Next steps:${NC}" - echo " mer init myapp # Create a new project" - echo " mer dev # Start dev server" - echo "" -else - echo -e "${YELLOW}⚠️ mer installed but not in PATH${NC}" - echo " Add this to your shell profile:" - echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" -fi - -# Print quickstart -echo -e "${BLUE}Documentation:${NC} https://merjs.trilok.ai/docs" -echo -e "${BLUE}GitHub:${NC} https://github.com/${REPO}" +$SUDO mkdir -p "$install_dir" +$SUDO mv "$bin_path" "${install_dir}/mer" + +ok "installed mer to ${install_dir}/mer" + +# ── PATH check ───────────────────────────────────────────────────────────── +case ":${PATH}:" in + *":${install_dir}:"*) ;; + *) + info "" + info "${install_dir} is not in your PATH. Add it to your shell profile:" + info " export PATH=\"${install_dir}:\$PATH\"" + info "" + ;; +esac + +# ── next steps ───────────────────────────────────────────────────────────── +cat >&2 < Interface to bind. Default: 127.0.0.1 (dev), 0.0.0.0 (--no-dev). + \\ --port Port to listen on. Default: 3000. + \\ --no-dev Disable hot reload + dev endpoints (production mode). + \\ --verbose, -v Log every request with timing. + \\ --debug Spawn kuri sidecar for browser automation. + \\ --prerender SSG: render pages to dist/ and exit. + \\ --help, -h Show this message. + \\ + \\Environment variables (read from .env or process env): + \\ PORT Same as --port. Auto-injected by Fly, Render, Railway, Heroku, Cloud Run. + \\ HOST Same as --host. + \\ MERJS_DEV=0 Same as --no-dev. + \\ + \\Health endpoints (always available, prod-safe): + \\ GET /_mer/health Liveness probe — returns {"status":"ok"} JSON. + \\ GET /_mer/ready Readiness probe — same payload. + \\ + ); +} diff --git a/src/mer.zig b/src/mer.zig index 6fbd2f2..0e75b03 100644 --- a/src/mer.zig +++ b/src/mer.zig @@ -11,8 +11,11 @@ const fetch_mod = @import("fetch.zig"); // Compile-time CSS generation (experimental) pub const mercss = @import("mercss.zig"); -/// Framework version — kept in sync with build.zig.zon. -pub const version = "0.2.5"; +/// Framework version. Single source of truth: `build.zig.zon`. +/// Wired in via the `build_options` module in `build.zig` so this constant +/// can never drift from the package version. Drift is also asserted by a +/// test in this file (`test "version matches build.zig.zon"`). +pub const version = @import("build_options").version; // --- Streaming SSR ---------------------------------------------------------- @@ -204,3 +207,22 @@ pub const Config = @import("server.zig").Config; pub const ServerReady = @import("server.zig").ServerReady; pub const Watcher = @import("watcher.zig").Watcher; pub const runPrerender = @import("prerender.zig").run; + +// --- Tests ------------------------------------------------------------------ + +test "version is sourced from build.zig.zon via build_options" { + // Guard against future reverts to a hardcoded literal that drifts from + // the package version in build.zig.zon. Both sides resolve to the same + // build-time string today; the test fails if anyone breaks that chain. + const expected = @import("build_options").version; + try std.testing.expectEqualStrings(expected, version); + + // Sanity-check the shape so a typo in build.zig.zon (e.g. "0.2" or "") + // surfaces here rather than at runtime in /_mer/health. + try std.testing.expect(version.len >= 5); + var dots: u32 = 0; + for (version) |c| { + if (c == '.') dots += 1; + } + try std.testing.expectEqual(@as(u32, 2), dots); +} diff --git a/src/runtime.zig b/src/runtime.zig index d18b25b..fe1ccb8 100644 --- a/src/runtime.zig +++ b/src/runtime.zig @@ -1,58 +1,94 @@ const std = @import("std"); const builtin = @import("builtin"); +const log = std.log.scoped(.runtime); + /// Runtime Io instance with platform-conditional backend. /// -/// - Linux: Uses Evented (io_uring) for best performance -/// - macOS/BSD: Uses Threaded (blocking syscalls) - Evented has a stdlib bug in 0.16 -/// - Other: Uses Threaded as safe fallback +/// Selection order: +/// 1. Linux: try Evented (io_uring) — best performance. +/// Falls back to Threaded if io_uring is unavailable +/// (old kernel, restricted seccomp, sandboxed container, …). +/// This makes the same binary deployable on every Linux host +/// regardless of io_uring support. +/// 2. macOS/BSD/Other: Threaded (blocking syscalls). +/// Evented on macOS has a stdlib bug in deinit() (Dispatch.zig:584). +/// +/// Override with build option `-Druntime=threaded` to force Threaded +/// (useful for benchmarking, or when you know io_uring won't help). +pub const Backend = enum { evented, threaded }; + pub var threaded: std.Io.Threaded = undefined; pub var io: std.Io = undefined; +var active: Backend = .threaded; -// Evented is only defined on platforms where it's supported -const use_evented = blk: { +// Evented is only available on platforms where the stdlib provides it. +// +// NOTE: Zig 0.16.0's release stdlib ships a known bug in `std/Io/Uring.zig` +// (the Linux/io_uring Evented backend) — `Dir.OpenError` and +// `Dir.RealPathFileError` don't include `error.ReadOnlyFileSystem`, but the +// Uring vtable's `else => |e| return e` branch tries to forward it. Merely +// calling `Evented.init().io()` forces analysis of every Uring vtable +// function and triggers compile errors. The bug is fixed in zig master but +// not in the 0.16.0 release tarball that CI uses. +// +// Until the toolchain pin moves past 0.16.0, force Threaded everywhere so the +// binary actually builds on stock 0.16.0. The whole fallback architecture +// below is preserved — flipping `stdlib_evented_works` to `true` re-enables +// the Evented (io_uring) path with no other code changes. +const stdlib_evented_works = false; +const evented_supported = blk: { + if (!stdlib_evented_works) break :blk false; if (!@hasDecl(std.Io, "Evented")) break :blk false; if (std.Io.Evented == void) break :blk false; - // Only use Evented on Linux where Uring (io_uring) is available - // macOS Dispatch has a bug in deinit() (Dispatch.zig:584) + // Only attempt Evented on Linux (io_uring). Other Evented backends are + // either unavailable or buggy in stdlib 0.16. break :blk builtin.os.tag == .linux; }; -// Evented storage only exists when supported -var evented: if (use_evented) std.Io.Evented else void = undefined; +// Storage for the Evented instance — only allocated on supported platforms. +var evented: if (evented_supported) std.Io.Evented else void = undefined; pub fn init(gpa: std.mem.Allocator) !void { - if (use_evented) { - // Linux: Use Evented (io_uring) + if (evented_supported) { evented = undefined; - try std.Io.Evented.init(&evented, gpa, .{}); - io = evented.io(); - } else { - // macOS/Other: Use Threaded (Evented has bugs or isn't available) - threaded = std.Io.Threaded.init(gpa, .{}); - io = threaded.io(); + if (std.Io.Evented.init(&evented, gpa, .{})) { + io = evented.io(); + active = .evented; + return; + } else |err| { + // io_uring not available — fall back to Threaded so we still boot. + // Common causes: old kernel, restricted seccomp profile, sandboxed + // container runtime (Docker default seccomp profile, gVisor, etc.). + log.warn("io_uring init failed ({s}); falling back to Threaded backend", .{@errorName(err)}); + } } + threaded = std.Io.Threaded.init(gpa, .{}); + io = threaded.io(); + active = .threaded; } pub fn deinit() void { - if (use_evented) { - evented.deinit(); - } else { - threaded.deinit(); + switch (active) { + .evented => if (evented_supported) evented.deinit(), + .threaded => threaded.deinit(), } } -/// Returns true if using Evented backend (io_uring) +/// Returns true if the active backend is Evented (io_uring). pub fn isEvented() bool { - return use_evented; + return active == .evented; +} + +/// Returns the active backend. +pub fn backend() Backend { + return active; } -/// Log which backend is active at startup +/// Log which backend is active at startup. pub fn logBackend() void { - const log = std.log.scoped(.runtime); - if (use_evented) { - log.info("Using std.Io.Evented (io_uring)", .{}); - } else { - log.info("Using std.Io.Threaded (blocking syscalls)", .{}); + switch (active) { + .evented => log.info("io backend: Evented (io_uring)", .{}), + .threaded => log.info("io backend: Threaded (blocking syscalls)", .{}), } } diff --git a/src/server.zig b/src/server.zig index c3f4be3..c5e2abf 100644 --- a/src/server.zig +++ b/src/server.zig @@ -242,6 +242,19 @@ fn serveRequest( else ""; + // Health & readiness endpoints — always available (dev or prod). + // For container HEALTHCHECK, k8s liveness/readiness probes, PaaS health checks. + if (std.mem.eql(u8, path, "/_mer/health") or std.mem.eql(u8, path, "/_mer/ready")) { + const headers = [_]std.http.Header{ + .{ .name = "content-type", .value = "application/json" }, + .{ .name = "cache-control", .value = "no-store" }, + }; + try std_req.respond("{\"status\":\"ok\",\"service\":\"merjs\",\"version\":\"" ++ mer.version ++ "\"}", .{ + .extra_headers = &headers, + }); + return; + } + // SSE hot-reload endpoint. if (dev and std.mem.eql(u8, path, "/_mer/events")) { if (watcher) |w| {