Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ worker/merjs.wasm
worker/grep.wasm
examples/kanban/worker/merjs.wasm
examples/singapore-data-dashboard/worker/merjs.wasm
examples/site/fastly/merjs.wasm

# Toolchain binaries (downloaded on first build)
tools/tailwindcss
Expand Down
39 changes: 35 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@
<h3 align="center">Next.js-style web framework. Written in Zig. Zero Node.js.</h3>

<p align="center">
File-based routing · SSR · Type-safe APIs · Hot reload · WASM client logic · Cloudflare Workers
File-based routing · SSR · Type-safe APIs · Hot reload · WASM client logic · Cloudflare Workers · Fastly Compute
</p>

<p align="center">
<a href="#-quick-start">Quick Start</a> ·
<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,7 +293,11 @@ Singapore data dashboard: **[sgdata.merlionjs.com](https://sgdata.merlionjs.com)

---

## Deploy to Cloudflare Workers
## Deploy

merjs compiles to edge-native WASM for both Cloudflare Workers and Fastly Compute.

### Cloudflare Workers

1. Edit `worker/wrangler.toml` — set your project name, route/domain, and any R2 bindings you need.
2. Build and deploy:
Expand All @@ -308,6 +312,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.

### Fastly Compute

1. Edit `examples/site/fastly/fastly.toml` — set your service name and backend origins.
2. Build and deploy:

```bash
zig build fastly # compile to WASI WASM
cd examples/site/fastly
fastly compute deploy
```

The Fastly target compiles to `wasm32-wasi` and runs natively on Fastly's Compute platform — no JS shim needed. Static assets from `public/` are embedded directly into the WASM binary for zero-latency serving. Outbound HTTP requests (for SSR data fetching) are routed through Fastly backends configured in `fastly.toml`.

To test locally with [Viceroy](https://github.com/fastly/Viceroy):

```bash
cd examples/site/fastly
viceroy ./merjs.wasm -C fastly.toml
# → http://127.0.0.1:7676
```

---

## How It Works
Expand All @@ -327,6 +352,11 @@ zig build serve
zig build worker
└── compiles to wasm32-freestanding
└── worker/worker.js wraps WASM in a CF Workers fetch handler

zig build fastly
└── compiles to wasm32-wasi
└── embeds public/ assets into the binary
└── runs natively on Fastly Compute (no JS shim)
```

**Thread model:** `std.Thread.Pool` with CPU-count-based sizing, kernel backlog 512, 64 KB write buffers.
Expand Down Expand Up @@ -355,7 +385,8 @@ merjs/
├── examples/
│ ├── desktop/ # native macOS app (experimental) — zig build desktop
│ ├── kanban/ # Kanban board demo (merboard.merlionjs.com)
│ └── singapore-data-dashboard/
│ ├── singapore-data-dashboard/
│ └── site/fastly/ # Fastly Compute deploy target — zig build fastly
├── tools/
│ ├── codegen.zig
│ └── tailwindcss # Tailwind v4 standalone CLI (no npm)
Expand Down
24 changes: 24 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,30 @@ pub fn build(b: *std.Build) void {
worker_step.dependOn(&install_worker.step);
worker_step.dependOn(&install_grep.step);

// ── Fastly Compute WASM ───────────────────────────────────────────────
const fastly_target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .wasi,
});
const zigly_dep = b.dependency("zigly", .{});
const fastly_mod = b.createModule(.{
.root_source_file = b.path("src/fastly.zig"),
.target = fastly_target,
.optimize = .ReleaseSmall,
});
fastly_mod.addImport("mer", mer_mod);
fastly_mod.addImport("zigly", zigly_dep.module("zigly"));
fastly_mod.addImport("counter_config", counter_config_mod);
helpers.addStaticAssets(b, fastly_mod, "examples/site/public");
helpers.addDirModules(b, fastly_mod, mer_mod, "examples/site/app", "app", site_extras);
helpers.addDirModules(b, fastly_mod, mer_mod, "examples/site/api", "api", &.{});
helpers.addRoutesModule(b, fastly_mod, mer_mod, "src/generated/routes.zig", "examples/site/app", "examples/site/api", site_extras);
const fastly_exe = b.addExecutable(.{ .name = "merjs-fastly", .root_module = fastly_mod });
fastly_exe.step.dependOn(&run_codegen.step);
const install_fastly = b.addInstallFile(fastly_exe.getEmittedBin(), "../examples/site/fastly/merjs.wasm");
const fastly_step = b.step("fastly", "Compile Fastly Compute WASM module");
fastly_step.dependOn(&install_fastly.step);

// ── Examples (sgdata, kanban) ────────────────────────────────────────────
examples.addExamples(b, mer_mod, wasm_target);

Expand Down
4 changes: 4 additions & 0 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
.url = "git+https://github.com/justrach/turboapi-core.git#6c4217b8d0cdc297f5b827527cdc9237213b14f1",
.hash = "turboapi_core-0.1.0-DjdHgu19AABfWBxcHyuAg51BxbZ7jmtlJ5VRt9GTURAP",
},
.zigly = .{
.url = "git+https://github.com/jedisct1/zigly.git#3a610a269f692375a0d2590780a8cc8dc5e2bb60",
.hash = "zigly-0.1.14-QJiBOSjkAgA7nkYjAs1qVfofOkn8QOqkIth0VK_pKQK1",
},
},
.paths = .{
"build.zig",
Expand Down
81 changes: 81 additions & 0 deletions build/helpers.zig
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,87 @@ pub fn addRoutesModule(
mod.addImport("routes", routes_mod);
}

/// Scan a public directory and generate a static_assets module for the Fastly target.
/// Each file is embedded via a WriteFile step, avoiding @embedFile path-escape issues.
pub fn addStaticAssets(
b: *std.Build,
mod: *std.Build.Module,
public_dir: []const u8,
) void {
const wf = b.addWriteFiles();
var buf: std.ArrayList(u8) = .empty;

buf.appendSlice(b.allocator,
\\pub const Asset = struct { data: []const u8, content_type: []const u8 };
\\pub fn get(path: []const u8) ?Asset {
\\ for (assets) |a| {
\\ if (eql(path, a.path)) return .{ .data = a.data, .content_type = a.ct };
\\ }
\\ return null;
\\}
\\fn eql(a: []const u8, c: []const u8) bool {
\\ return a.len == c.len and for (a, c) |x, y| { if (x != y) break false; } else true;
\\}
\\const E = struct { path: []const u8, data: []const u8, ct: []const u8 };
\\const assets = [_]E{
\\
) catch @panic("OOM");

var d = std.Io.Dir.cwd().openDir(b.graph.io, public_dir, .{ .iterate = true }) catch return;
defer d.close(b.graph.io);
var walker = d.walk(b.allocator) catch return;
defer walker.deinit();
var count: usize = 0;
while (walker.next(b.graph.io) catch null) |entry| {
if (entry.kind != .file) continue;
if (entry.path[0] == '.') continue;
const rel = entry.path;
const file_path = b.fmt("{s}/{s}", .{ public_dir, rel });
const copy_name = b.fmt("__asset_{d}", .{count});
const ct = mimeForExt(rel);

_ = wf.addCopyFile(b.path(file_path), copy_name);

buf.print(b.allocator, " .{{ .path = \"/{s}\", .data = @embedFile(\"{s}\"), .ct = \"{s}\" }},\n", .{ rel, copy_name, ct }) catch @panic("OOM");
count += 1;
}

buf.appendSlice(b.allocator, "};\n") catch @panic("OOM");

const source = wf.add("static_assets.zig", buf.items);

const assets_mod = b.createModule(.{
.root_source_file = source,
});
mod.addImport("static_assets", assets_mod);
}

fn mimeForExt(path: []const u8) []const u8 {
const table = [_]struct { []const u8, []const u8 }{
.{ ".html", "text/html; charset=utf-8" },
.{ ".css", "text/css; charset=utf-8" },
.{ ".js", "application/javascript" },
.{ ".json", "application/json" },
.{ ".wasm", "application/wasm" },
.{ ".png", "image/png" },
.{ ".jpg", "image/jpeg" },
.{ ".jpeg", "image/jpeg" },
.{ ".gif", "image/gif" },
.{ ".svg", "image/svg+xml" },
.{ ".ico", "image/x-icon" },
.{ ".webp", "image/webp" },
.{ ".txt", "text/plain; charset=utf-8" },
.{ ".woff", "font/woff" },
.{ ".woff2", "font/woff2" },
.{ ".ttf", "font/ttf" },
.{ ".map", "application/json" },
};
for (table) |entry| {
if (std.mem.endsWith(u8, path, entry[0])) return entry[1];
}
return "application/octet-stream";
}

/// Create a WASM executable target with standard settings.
pub fn addWasmExe(b: *std.Build, name: []const u8, source: []const u8, wasm_target: std.Build.ResolvedTarget) *std.Build.Step.Compile {
const exe = b.addExecutable(.{
Expand Down
14 changes: 14 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ merjs mixes two things in the top level — the **framework** and its own **webs
| `public/` | **merjs website** static assets |
| `wasm/` | **merjs website** client-side WASM modules |
| `worker/` | **merjs website** Cloudflare Workers deploy target |
| `examples/site/fastly/` | Fastly Compute deploy target (WASI) |
| `examples/` | Standalone demo apps |

## Request lifecycle (native dev server)
Expand All @@ -36,6 +37,19 @@ Cloudflare edge
→ HTTP Response
```

## Request lifecycle (Fastly Compute)

```
Fastly edge
→ src/fastly.zig WASI _start entry point
→ tryServeStatic() check embedded static assets (public/*)
→ buildMerRequest() parse downstream request into mer.Request
→ dispatch hash-map route lookup → page fn pointer
→ sendFastlyResponse() write status + headers + body to downstream
```

Unlike the Workers target, Fastly Compute runs native WASI — no JS shim is required. Static assets are embedded at compile time via `@embedFile`, so serving them is a memory lookup with no I/O. Outbound HTTP fetches during SSR are routed through named backends configured in `fastly.toml`.

## Streaming SSR

Pages can opt into streaming by exporting `renderStream` instead of `render`:
Expand Down
16 changes: 16 additions & 0 deletions examples/site/fastly/fastly.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
manifest_version = 3
name = "merjs-site"
description = "MerJS demo site on Fastly Compute"
language = "other"

[scripts]
build = "cd ../../.. && zig build fastly"

[local_server]

[local_server.backends]
[local_server.backends.origin]
url = "https://httpbin.org"

[local_server.backends.assets]
url = "http://localhost:8080"
Loading