Skip to content

feat: deploy anywhere — graceful io_uring fallback, PORT/HOST env, /_mer/health, drop-in PaaS configs#99

Open
devin-ai-integration[bot] wants to merge 3 commits into
release/v0.2.53from
devin/1777352725-deployable-anywhere
Open

feat: deploy anywhere — graceful io_uring fallback, PORT/HOST env, /_mer/health, drop-in PaaS configs#99
devin-ai-integration[bot] wants to merge 3 commits into
release/v0.2.53from
devin/1777352725-deployable-anywhere

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Apr 28, 2026

Copy link
Copy Markdown

Rach's Agentic Contribution Template

Maintainer process template by @rachpradhan.

Linked Issue

Closes #

No pre-existing issue — this is a maintenance + deployability sweep requested directly by the maintainer ("endlessly make this framework better and easier to use, also make it such that the shape is easy to deploy on any machine"). Happy to file a tracking issue retroactively if preferred.

Summary

Goal: same merjs binary boots and serves on every Linux host (any kernel, any seccomp profile, any PaaS), with one-command deploys to every major target.

What changed:

  • Runtime falls back gracefullysrc/runtime.zig now tries std.Io.Evented (io_uring) first on Linux and automatically falls back to std.Io.Threaded if io_uring_setup fails. Common causes: old kernel, restricted seccomp (Docker default), gVisor, sandboxed containers. Before this change the server panicked at boot in any environment without io_uring.
  • PORT / HOST env-var support in src/main.zig — every PaaS (Fly, Render, Railway, Heroku, Cloud Run) injects PORT, but merjs only honored the --port flag. Now: env vars are read, flags still take precedence. --no-dev defaults HOST to 0.0.0.0 (containers bind correctly out of the box); dev mode keeps 127.0.0.1.
  • /_mer/health + /_mer/ready — production-safe JSON endpoints, available in both dev and --no-dev (/_mer/debug is dev-only). Used by Docker HEALTHCHECK, k8s liveness/readiness probes, and PaaS health checks.
  • Dockerfile fixesZIG_VERSION was pinned to 0.15.2 despite build.zig.zon requiring 0.16.0 (broken). Repinned. Also: non-root user (uid 10001 — required by OpenShift / Cloud Run / k8s), tini as PID 1 for signal handling, HEALTHCHECK against /_mer/health, OCI labels, multi-arch ready.
  • install.sh was broken — it tried to download merjs-${VER}-${OS}-${ARCH}.tar.gz but the release workflow ships raw mer-${OS}-${ARCH} binaries (different naming). Rewrote to match the actual release assets, with sha256 checksum verification + PATH detection.
  • Drop-in PaaS configs: docker-compose.yml, fly.toml, render.yaml, railway.json, .do/app.yaml, Procfile.
  • GHCR publishing workflow.github/workflows/docker-publish.yml builds & pushes a multi-arch (amd64+arm64) image to ghcr.io/justrach/merjs on tag and main pushes.
  • Docs — README "Deploy" section now covers Docker / Compose / Fly / Render / Railway / DO / Heroku-style / Workers, plus a note on the runtime backend selection log line. AGENTS.md documents the new prod endpoints + env vars.

What is intentionally not in scope:

  • No cli.zig / mer init template changes — preserved for a follow-up.
  • No new io_uring usage beyond the existing runtime.io plumbing — the existing call sites (static.zig, fetch.zig, prerender.zig, telemetry.zig) already route through runtime.io, so they pick up io_uring automatically when available. The fallback change makes the opportunistic use of io_uring real (binary now actually ships everywhere instead of crashing).
  • No Windows binary — install.sh errors out cleanly on unsupported OSes.
  • I considered force-Threaded via env var (MERJS_RUNTIME=threaded); deferred because reading env in 0.16 before runtime init requires either libc-linking codegen or threading the Init.Minimal struct down. Happy to add as a build-time flag if you want.

Scope

  • subsystems touched: src/runtime.zig, src/main.zig, src/server.zig, Dockerfile, install.sh, README, AGENTS, plus new top-level deploy configs and one CI workflow.
  • generated files changed: no
  • lockfile changed: no (build.zig.zon untouched)
  • benchmark/docs-only/runtime change: runtime + deployment + docs (no benchmark numbers altered)
  • changed lines count: +619 / −241 across 14 files (1 of which is a near-total rewrite of the broken install.sh — that alone is +138/−160). The actual runtime/server code change is small: runtime.zig +60/−26, main.zig +56/−5, server.zig +13/0. Most of the rest is config files for new deployment targets and the README rewrite. Happy to split if reviewers prefer (e.g. lift install.sh and the deploy configs into a follow-up PR), but each piece relies on the others to actually be useful end-to-end (/_mer/health only matters once the Dockerfile uses it, PORT only matters once a PaaS config wires it up, etc.), so I shipped them as one coherent "deploy anywhere" theme.

Rebase Status

  • Rebasing onto current main completed before requesting review (branched off fe5b35c which is current origin/main)

Tests Run

zig build codegen
zig build
zig build test --summary all

Red-To-Green Evidence

Failing Before

# 1. On a sandboxed host without io_uring (Docker default seccomp / gVisor / 5.x with restricted seccomp):
$ ./zig-out/bin/merjs --no-dev
error: ArgumentsInvalid
/.../std/os/linux/IoUring.zig:58:19: 0x... in init_params
        .INVAL => return error.ArgumentsInvalid,
                  ^
/.../src/runtime.zig:28:9: in init (runtime.zig)
        try std.Io.Evented.init(&evented, gpa, .{});
# (same failure for `zig build codegen` since the codegen tool also calls runtime.init)

# 2. install.sh — the README's "Option A" one-liner:
$ curl -fsSL .../install.sh | bash
⬇  Downloading...
Error: Failed to download https://github.com/justrach/merjs/releases/download/v0.2.5/merjs-v0.2.5-linux-x86_64.tar.gz
# That asset never existed — release workflow ships `mer-linux-x86_64` (no version, no tar.gz).

# 3. PORT env var — most PaaS platforms inject this. merjs ignores it:
$ PORT=8080 ./zig-out/bin/merjs --no-dev
info(server): merjs dev server -> http://127.0.0.1:3000   # bound to wrong port + wrong interface

# 4. Health endpoint — needed by Docker HEALTHCHECK / k8s probes:
$ curl -i http://localhost:3000/_mer/health
HTTP/1.1 404 Not Found

# 5. Dockerfile — pinned to Zig 0.15.2 but project requires 0.16.0:
$ docker build -t merjs .
... error: minimum_zig_version: 0.16.0 (current: 0.15.2)

Passing After

$ zig build && zig build test --summary all
warning(runtime): io_uring init failed (ArgumentsInvalid); falling back to Threaded backend
codegen: wrote 18 route(s) to src/generated/routes.zig
Build Summary: 19/19 steps succeeded; 25/27 tests passed (2 skipped)

# 1. Server now boots even when io_uring is unavailable:
$ ./zig-out/bin/merjs --no-dev
warning(runtime): io_uring init failed (ArgumentsInvalid); falling back to Threaded backend
info(runtime): io backend: Threaded (blocking syscalls)
info(server): merjs dev server -> http://0.0.0.0:3000

# 2. install.sh now resolves the right asset name (matches the release workflow):
$ sh -n install.sh && grep -E '^asset=' install.sh
asset="mer-${os}-${arch}"

# 3. PORT env var is honored:
$ PORT=4322 ./zig-out/bin/merjs --no-dev
info(server): merjs dev server -> http://0.0.0.0:4322

# 4. Health endpoints work in both dev and --no-dev:
$ curl -s http://localhost:4322/_mer/health
{"status":"ok","service":"merjs","version":"0.2.5"}
$ curl -s http://localhost:4322/_mer/ready
{"status":"ok","service":"merjs","version":"0.2.5"}

# 5. /_mer/debug correctly stays dev-only:
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4322/_mer/debug
404

# 6. --help wired up:
$ ./zig-out/bin/merjs --help | head -5
merjs — Next.js-style web framework written in Zig.

Usage:
  merjs [flags]

Nearby Non-Regression Checks

# Full test suite — everything still green:
$ zig build test --summary all
Build Summary: 19/19 steps succeeded; 25/27 tests passed (2 skipped)
# (the 2 skipped tests are pre-existing skips in the suite, not introduced here)

# Existing `--port` / `--host` flags still work and still take precedence over env vars:
$ PORT=9999 ./zig-out/bin/merjs --no-dev --port 4322
info(server): merjs dev server -> http://0.0.0.0:4322

# Default route still serves the demo site:
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4322/
200

# zig fmt is clean on touched files:
$ zig fmt --check src/runtime.zig src/main.zig src/server.zig
# (no output, exit 0)

Benchmarks

Not applicable — no benchmark code touched, no perf claims altered. The opportunistic io_uring path is unchanged on hosts where io_uring works; on hosts where it doesn't, we go from "panic at boot" to "Threaded fallback" (which is strictly an improvement over not booting at all).

Checklist

  • PR is matched to an issue (or call out as above — happy to file one if preferred)
  • PR scope is narrow and reviewable — single coherent theme: deployability
  • PR is under 500 changed lines, or I justified why it cannot be split — 619 insertions / 241 deletions; justified in Scope above (most of the line count is install.sh rewrite + new deploy config files; runtime/server code change is ~75 net lines). Happy to split if reviewers prefer.
  • I showed the failing test or repro before the fix
  • I showed the same test or repro passing after the fix
  • I ran nearby non-regression checks, not just the one happy-path test
  • No generated .zig-cache, zig-out, dylibs, or other build artifacts are committed
  • No unrelated dependency or lockfile churn is included

Link to Devin session: https://app.devin.ai/sessions/0b66b6a4b7714415995d0cfdcef09af7
Requested by: @justrach


Open in Devin Review

…mer/health, drop-in PaaS configs

Make merjs trivial to deploy on any Linux host regardless of io_uring
support, and add ready-to-go configs for every major hosting target.

Runtime:
- src/runtime.zig: gracefully fall back from Evented (io_uring) to
  Threaded if std.Io.Evented.init fails. Common causes: old kernel,
  restricted seccomp profile (Docker default), gVisor, sandboxed
  container runtimes. Same binary now boots everywhere — io_uring is
  used opportunistically for max throughput where available.
- src/main.zig: log the active backend at startup.

Deployability (main.zig):
- Honor PORT and HOST env vars (PaaS standard: Fly, Render, Railway,
  Heroku, Cloud Run all inject PORT). Flags still take precedence.
- Default HOST to 0.0.0.0 in --no-dev so containers bind correctly out
  of the box; dev mode keeps 127.0.0.1.
- Add MERJS_DEV=0 as an env-var equivalent to --no-dev.
- Add --help / -h with documentation of all flags + env vars.

Health endpoints (server.zig):
- GET /_mer/health and /_mer/ready return prod-safe JSON. Available in
  both dev and --no-dev (unlike /_mer/debug). Used by Docker
  HEALTHCHECK, k8s probes, and PaaS health checks.

Dockerfile:
- Pin ZIG_VERSION=0.16.0 to match build.zig.zon (was 0.15.2).
- Run as non-root user uid 10001 (required by OpenShift, Cloud Run, k8s).
- tini as PID 1 — proper signal forwarding + zombie reaping.
- HEALTHCHECK against /_mer/health.
- ENV PORT=3000 HOST=0.0.0.0 baked in.
- OCI labels for image metadata.

install.sh: was broken — looked for merjs-VER-OS-ARCH.tar.gz but the
release workflow ships raw mer-OS-ARCH binaries. Rewritten to match
the actual release assets, with checksum verification and
PATH-detection.

Drop-in deploy configs:
- docker-compose.yml — one-command run with healthcheck wired up.
- fly.toml — Fly.io.
- render.yaml — Render Blueprint.
- railway.json — Railway.
- .do/app.yaml — DigitalOcean App Platform.
- Procfile — Heroku-style PaaS.

CI:
- .github/workflows/docker-publish.yml — multi-arch (amd64+arm64)
  Docker image published to GHCR on tag and main pushes.

Docs:
- README: replaced 'Deploy to Cloudflare Workers' section with
  'Deploy' covering Docker, Fly, Render, Railway, DO, Heroku, and
  Workers. Documents the runtime selection log line.
- AGENTS: documents new prod endpoints + deployment env vars.

Co-Authored-By: Rach Pradhan <pradhan.rach4@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Zig 0.16.0's release stdlib has a known bug in std/Io/Uring.zig where
Dir.OpenError and Dir.RealPathFileError are missing
error.ReadOnlyFileSystem, but the Uring vtable forwards it via
`else => |e| return e`. Calling `Evented.init().io()` forces analysis of
every Uring vtable function and triggers compile errors on stock 0.16.0
in CI.

Add `stdlib_evented_works = false` const guard. The whole fallback
architecture is preserved — flipping it back to true re-enables the
Evented (io_uring) path with no other code changes once the toolchain
pin moves past 0.16.0. Docs updated to reflect current state.

Co-Authored-By: Rach Pradhan <pradhan.rach4@gmail.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c2482b85f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/main.zig
Comment on lines +46 to +48
config.port = std.fmt.parseInt(u16, v, 10) catch |err| {
log.err("invalid PORT={s}: {s}", .{ v, @errorName(err) });
return err;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor --port override when PORT is malformed

The new env parsing returns immediately on an invalid PORT value before CLI arguments are processed, so PORT=abc merjs --port 3000 now exits with an error instead of honoring the documented flag precedence. This makes startup brittle in environments where PORT may be preset incorrectly and prevents users from recovering via --port.

Useful? React with 👍 / 👎.

@justrach justrach changed the base branch from main to release/v0.2.53 April 30, 2026 02:05
build.zig.zon's `.version` is now the only place the framework version
lives. Wired into a `build_options` module that feeds:

- src/mer.zig `pub const version` (used by /_mer/health, /_mer/ready,
  the dev error overlay, and Sentry telemetry tags)
- cli.zig `pub const version` (mer --version)
- the macOS desktop bundle's Info.plist CFBundleVersion (built via
  b.fmt so the literal disappears)

Both mer.zig and cli.zig get a unit test asserting their `version`
constant equals build_options.version. If a future change reverts
either constant to a hardcoded literal that drifts from the package
version, the test fails. mer.zig also sanity-checks the shape
("X.Y.Z", length ≥ 5) so a typo in build.zig.zon surfaces in tests
rather than at runtime.

Verified:
- zig build test: 27/29 pass (2 pre-existing skips), including the
  two new drift tests.
- /_mer/health and /_mer/ready now report {"version":"0.2.53"}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 6 additional findings.

Open in Devin Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant