Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .do/app.yaml
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
78 changes: 60 additions & 18 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: ./merjs --no-dev
86 changes: 83 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<a href="#-features">Features</a> ·
<a href="#-demo">Demo</a> ·
<a href="#-how-it-works">How It Works</a> ·
<a href="#-deploy-to-cloudflare-workers">Deploy</a> ·
<a href="#-deploy">Deploy</a> ·
<a href="CHANGELOG.md">Changelog</a>
</p>

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 18 additions & 3 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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", .{});
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand All @@ -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);
}

Expand Down Expand Up @@ -351,20 +366,20 @@ 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(
\\<?xml version="1.0" encoding="UTF-8"?>
\\<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
\\<plist version="1.0">
\\<dict>
\\ <key>CFBundleExecutable</key> <string>merapp</string>
\\ <key>CFBundleIdentifier</key> <string>com.merjs.desktop</string>
\\ <key>CFBundleName</key> <string>MerApp</string>
\\ <key>CFBundleVersion</key> <string>0.2.5</string>
\\ <key>CFBundleVersion</key> <string>{s}</string>
\\ <key>NSHighResolutionCapable</key><true/>
\\ <key>NSPrincipalClass</key> <string>NSApplication</string>
\\</dict>
\\</plist>
);
, .{zon.version}));
const bundle_bin = b.addInstallFile(
desktop_exe.getEmittedBin(),
"MerApp.app/Contents/MacOS/merapp",
Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.{
.name = .merjs,
.fingerprint = 0xaa135c45924bbfa8,
.version = "0.2.5",
.version = "0.2.53",
.minimum_zig_version = "0.16.0",
.dependencies = .{
.dhi = .{
Expand Down
Loading