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| {