<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Deepak Kumar on Medium]]></title>
        <description><![CDATA[Stories by Deepak Kumar on Medium]]></description>
        <link>https://medium.com/@i-am-chitti?source=rss-08cb4a84e71e------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*QxEAjj13jyYbqzYW</url>
            <title>Stories by Deepak Kumar on Medium</title>
            <link>https://medium.com/@i-am-chitti?source=rss-08cb4a84e71e------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 07:58:05 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@i-am-chitti/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Load-Testing a URL Shortener]]></title>
            <link>https://medium.com/@i-am-chitti/load-testing-a-url-shortener-e3c7f9b4e504?source=rss-08cb4a84e71e------2</link>
            <guid isPermaLink="false">https://medium.com/p/e3c7f9b4e504</guid>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[nodejs]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[system-design-interview]]></category>
            <category><![CDATA[backend]]></category>
            <dc:creator><![CDATA[Deepak Kumar]]></dc:creator>
            <pubDate>Sun, 27 Sep 2026 01:22:35 GMT</pubDate>
            <atom:updated>2026-09-27T01:42:07.701Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Part 2 of two. </em><a href="https://medium.com/@i-am-chitti/building-a-url-shortener-six-design-decisions-and-the-alternatives-i-rejected-c580c8f7ee16"><em>Part 1</em></a><em> is the design. This part is the measurement.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H7dFONEzoi3M-Ea8Tpt2NA.jpeg" /></figure><p><a href="https://medium.com/@i-am-chitti/building-a-url-shortener-six-design-decisions-and-the-alternatives-i-rejected-c580c8f7ee16">Part 1</a> built the thing: a cache in front of the database, a queue for the clicks, a redirect that returns in a few milliseconds. What I didn’t have was a number: how many redirects per second that shape sustains, and which component runs out of capacity first.</p><p>So I pointed k6 at it and pushed until something gave. It wasn’t the component I’d designed the path around.</p><p>Key takeaways:</p><ul><li>A fixed-VU load test measures concurrency, not throughput, and reports latency inflated by its own queueing.</li><li>Use k6’s ramping-arrival-rate executor when the target is stated as a rate.</li><li>On a ramped run, the reported average RPS is diluted by the ramps. Read the P95 at the hold instead.</li><li>At 3,000 req/s the single Node process saturated one CPU core while Redis and Postgres sat at ~25%.</li></ul><p>🔗 Live: <a href="https://linkpulse.thedeepak.dev/">linkpulse.thedeepak.dev</a></p><p><em>📦 Repo</em>: <a href="https://github.com/i-am-chitti/linkpulse">github.com/i-am-chitti/linkpulse</a>.</p><h4>🧪 What’s covered:</h4><ul><li><a href="#39a6">The hot path being measured</a></li><li><a href="#95ef">First test: fixed VUs, wrong measurement</a></li><li><a href="#074c">Results</a></li><li><a href="#ba50">Past the target: one CPU core</a></li><li><a href="#800c">Limitations</a></li></ul><h3>The hot path, briefly</h3><p>What GET /:shortCode costs on a warm key (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/routes/redirect.ts">redirect.ts</a>):</p><ol><li>A Lua rate-limit call to Redis.</li><li>A regex gate on the code.</li><li>A Redis GET for the cached destination.</li><li>A 302.</li><li>After the response is sent, not awaited: an LPUSH of the click event to Redis. A separate worker drains that in batches of 100 into Postgres.</li></ol><p>Two synchronous Redis calls, no database. Redis should be the ceiling, if anything is. That was my bet.</p><p><strong>Setup</strong>: Node 24, Express 5, one API process; Redis 7; PostgreSQL 16; k6. Everything ran in Docker Compose on one laptop (Apple M2 Pro, 10 cores, 16 GB), not the live EC2 instance. One overlay file (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/docker-compose.loadtest.yml">docker-compose.loadtest.yml</a>) raises the rate limiter’s ceilings, because k6 traffic comes from one IP and the real anonymous limit would throttle the load generator before the hot path was under any load.</p><h3>First test: fixed VUs, wrong measurement</h3><p>The target: over 2,500 RPS on the redirect, P95 under 50ms.</p><p>The first k6 script was the obvious one: 500 virtual users in a closed loop, each firing a request, getting the response, firing the next. It blew past 2,500 RPS and reported a P95 around 170ms.</p><p>That’s not the system failing. It’s the wrong measurement.</p><p>A closed-loop VU test controls how many clients are connected. Each VU sends the instant the previous request returns, so throughput is whatever latency permits: 500 VUs at 2ms each is 250,000 RPS in theory, at 200ms it’s 2,500. The request rate isn’t something you set; it’s an output. And with 500 clients waiting on one single-process API, the latency they see includes time queued behind each other. The 170ms didn’t mean the redirect path is slow. It meant 500 clients with no think-time will saturate whatever they hit, then measure the saturation.</p><p>The question I wanted answered was: if 2,500 requests arrive per second, is each answered in under 50ms? Arrival rate is the input, latency is the output, client count is an implementation detail. k6’s ramping-arrival-rate executor asks for the rate directly and allocates however many VUs it needs (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/benchmarks/k6/redirect-load-test.js">redirect-load-test.js</a>):</p><pre>export const options = {<br>  scenarios: {<br>    redirect_throughput: {<br>      executor: &#39;ramping-arrival-rate&#39;,<br>      timeUnit: &#39;1s&#39;,<br>      startRate: 0,<br>      preAllocatedVUs: 300,<br>      maxVUs: 1000,<br>      stages: [<br>        { target: 1250, duration: &#39;20s&#39; },<br>        { target: 2500, duration: &#39;20s&#39; },<br>        { target: 2500, duration: &#39;2m&#39; },  // hold at target<br>        { target: 0,    duration: &#39;20s&#39; },<br>      ],<br>    },<br>  },<br>  thresholds: {<br>    http_req_duration: [&#39;p(95)&lt;50&#39;],<br>    http_req_failed: [&#39;rate&lt;0.01&#39;],<br>  },<br>};</pre><p>No sleep() in the iteration; the executor owns the rate.</p><p>The other three scenarios (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/benchmarks/k6/create-link-test.js">create</a>, <a href="https://github.com/i-am-chitti/linkpulse/blob/main/benchmarks/k6/analytics-read-test.js">analytics</a>, <a href="https://github.com/i-am-chitti/linkpulse/blob/main/benchmarks/k6/mixed-workload-test.js">mixed</a>) keep fixed VUs, because their targets are “stable with N concurrent users”, which is a closed-loop question. Only the redirect target is phrased as a rate.</p><h3>Results</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RhCiWviqWmrzUiqYoqSnYA.jpeg" /><figcaption>k6 results</figcaption></figure><p>All thresholds passed (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/benchmarks/reports/RESULTS.md">RESULTS.md</a>). Two notes on the redirect row.</p><p>The 2,077 is a whole-run average, diluted by 40 seconds of ramp-up and 20 of ramp-down. The two-minute hold at 2,500 supplies about 80% of the requests, so the P95 is the number to trust: the service held 2,500 req/s at 40ms.</p><p>The gap between P50 and P95. 0.8ms median, 40ms at the 95th. A warm redirect is sub-millisecond. The tail is what happens when 2,500 of them a second land on one thread.</p><h3>Past the target: one CPU core</h3><p>Passing a threshold says the system is fine at the number you chose. It doesn’t say why, or how far past that number it stays fine. So I raised the hold to 3,000 req/s and watched docker stats.</p><p>P95 crossed 50ms. Container CPU:</p><ul><li>api: ~110-130%</li><li>redis: ~20-26%</li><li>postgres: ~20-26%</li></ul><p>Node runs JavaScript on a single thread. 110–130% means that one core is full, with a little extra from garbage collection and I/O helper threads. Redis and Postgres were barely awake.</p><p>The cache I’d designed the path around had three-quarters of its capacity spare. The database that isn’t even on the warm path was idle. The bottleneck was the box doing the least work per request (parse HTTP, two middleware, two Redis calls, a 302), because it was doing it 3,000 times a second on one thread. The architecture diagram has a box labelled API, and nothing in it says that the box is one core.</p><p><strong>The fix</strong> is horizontal: API replicas behind a load balancer. The design already allows it. JWTs are stateless, refresh tokens live in Postgres and rate-limit state is in Redis, so there are no sticky sessions and no code change. I haven’t built it. The diagnosed limit and the documented next step were the point; a second replica would move the same question one layer out.</p><h3>Limitations</h3><ul><li>The load generator shared the machine with the system under test. k6 was running 300+ VUs on the same 10 cores as the API container, so some of the API’s contention was k6. On a dedicated host the ceiling is probably somewhat higher. The shape of the finding survives; the exact number doesn’t.</li><li>These are not numbers from the live deployment. Production is one EC2 instance behind Caddy with an external Neon Postgres. I haven’t measured it and I’m not quoting a laptop number as a production number.</li><li>The cache was warm and held only 20 keys, so every redirect request was a hit. Real traffic has misses, expiries and scanners hitting the negative cache, all of which touch Postgres.</li><li>These are one run’s numbers, not medians across repeats.</li></ul><h3>Takeaways</h3><ul><li>The executor is part of the hypothesis. “Handles 2,500 RPS” and “handles 500 concurrent users” are different sentences, and a test that measures one can’t prove the other. My first test was a correct measurement of a question I hadn’t asked.</li><li>Pass the threshold, then break it. The passing run said the design works at target. The 3,000 req/s run said why, and it was a different why than I’d have written down beforehand.</li><li>Write the limit down. A table with four green ticks is marketing. The same table with “the ceiling is one core, here’s docker stats, here&#39;s the next step&quot; is the only version that told me something I didn&#39;t already believe.</li></ul><p><em>Part 1: </em><a href="https://medium.com/@i-am-chitti/building-a-url-shortener-six-design-decisions-and-the-alternatives-i-rejected-c580c8f7ee16"><em>Building a URL shortener: six design decisions and the alternatives I rejected</em></a><em>.</em></p><p><em>Benchmark scripts and reporter: </em><a href="https://github.com/i-am-chitti/linkpulse/tree/main/benchmarks"><em>linkpulse/benchmarks</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e3c7f9b4e504" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a URL Shortener: Six Design Decisions and the Alternatives I Rejected]]></title>
            <link>https://medium.com/@i-am-chitti/building-a-url-shortener-six-design-decisions-and-the-alternatives-i-rejected-c580c8f7ee16?source=rss-08cb4a84e71e------2</link>
            <guid isPermaLink="false">https://medium.com/p/c580c8f7ee16</guid>
            <category><![CDATA[redis]]></category>
            <category><![CDATA[nodejs]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[system-design-interview]]></category>
            <category><![CDATA[backend]]></category>
            <dc:creator><![CDATA[Deepak Kumar]]></dc:creator>
            <pubDate>Sun, 27 Sep 2026 01:19:48 GMT</pubDate>
            <atom:updated>2026-09-27T01:38:56.930Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Part 1 of two. This part is the design. </em><a href="https://medium.com/@i-am-chitti/load-testing-a-url-shortener-e3c7f9b4e504"><em>Part 2</em></a><em> is the load test.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tMwqFXEYDixM9mAke5ea-g.jpeg" /></figure><p>The URL shortener is the hello world of system design: hash the URL, store the mapping, cache the hot ones, redirect. Four boxes.</p><p>I built one and put it online, with a real login, real analytics, and a public URL that anyone (and any bot) can hit. Running it turned up a second layer of decisions underneath those four boxes. Which redirect status code to send. Where the click gets counted. What happens when two requests race the rate limiter. What a stolen refresh token looks like from the server’s side.</p><p>This post is six of those decisions, each with the alternative I rejected and why.</p><p>🔗 Live: <a href="https://linkpulse.thedeepak.dev/">linkpulse.thedeepak.dev</a></p><p>📦 Repo: <a href="https://github.com/i-am-chitti/linkpulse">github.com/i-am-chitti/linkpulse</a>.</p><h4><strong>Key takeaways:</strong></h4><ul><li>Send a 302, not a 301. Browsers can cache a 301 indefinitely, so the service stops seeing the clicks it is trying to count.</li><li>Cache the misses as well as the hits, or a bot scanning random codes turns every probe into a database query.</li><li>Do not rate limit with INCR and EXPIRE. A fixed window lets through twice the configured rate at the boundary.</li><li>Enrich clicks in a separate process. The geo-IP database costs 110 MB of memory that the redirect path never needs.</li></ul><h4><strong>🧪 What’s covered:</strong></h4><ul><li><a href="#cfc9">What it does, and the performance targets</a></li><li><a href="#5062">Architecture</a></li><li><a href="#6916">Redirect hot path: cache, negative cache, 302 vs 301</a></li><li><a href="#d6c4">Click tracking: async queue and a separate worker process</a></li><li><a href="#b690">Rate limiting: sliding window in a Lua script, and why a rate limit alone isn’t enough</a></li><li><a href="#cb48">Auth: refresh token rotation with reuse detection</a></li><li><a href="#e821">What I left out: Safe Browsing API and horizontal scaling</a></li><li><a href="#4324">Deployment: one VM with Docker Compose</a></li></ul><h3>What it does</h3><p>LinkPulse shortens a URL to a random base62 code or a custom alias. Registered users get per-link analytics: clicks over time, unique visitors, countries, devices, browsers and referrers. Visitors can also shorten a link from the landing page without an account, and those guest links expire after 24 hours. Every create and edit passes through a rate limiter, a per-account link quota and a malicious-URL blocklist, and the anonymous browser forms are behind a Cloudflare Turnstile challenge.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OxLgTU6DPHSgzpVGIBwW6A.png" /><figcaption>Per-link analytics: clicks over time, countries, browsers, devices and referrers</figcaption></figure><p>Targets, set before writing code:</p><ul><li>Redirect GET /:shortCode: over 2,500 RPS, P95 under 50ms</li><li>Create POST /api/links: over 200 RPS, P95 under 200ms</li><li>Analytics read: over 100 RPS, P95 under 300ms</li></ul><p>The redirect is the only operation that happens at machine speed; everything else happens as fast as people fill in forms. Every decision below follows from keeping the redirect path cheap.</p><h3>Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uzUYhRO4mk9jXwDc0R2BsQ.jpeg" /><figcaption>LinkPulse architecture</figcaption></figure><p>Two things worth pointing out in this diagram. The redirect path is Browser → API → Redis, and on a cache hit that’s all: Postgres and the worker are not involved. Redis is hit twice per redirect, once for the rate limiter and once for the cache, so the rate limiter is on the hot path too. And the worker is a separate process, not a background function in the API, for a reason I’ll measure below.</p><p>Redis does three jobs here: cache, click queue, rate-limiter state. Fine at this scale, and one fewer thing to run. It’s the first thing I’d split if Redis showed up as the bottleneck. It didn’t.</p><h3>1. Redirect hot path</h3><p><a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/routes/redirect.ts">redirect.ts</a> does, in order:</p><ol><li>Rate-limit check. One Lua script call to Redis (decision 3 below).</li><li>Regex gate. A valid code is 3–32 characters of [a-zA-Z0-9_-] (generated codes are 7; custom aliases can be longer). Anything else is a 404 before the cache is consulted, so scanners can&#39;t fill it with keys that could never be valid.</li><li>Cache lookup (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/services/cacheService.ts">cacheService.ts</a>). On a hit, that’s the second and last Redis call.</li><li>On a miss, Postgres, then write back. Misses are cached too: a code that doesn’t exist is stored as missing for 60 seconds, so a bot probing random codes costs one query per code per minute instead of one per request. “Gone” (expired or disabled) is cached separately so the response can say which.</li><li>Respond with a 302.</li></ol><p><strong>Why 302 and not 301.</strong> A 301 is permanent, and browsers cache it, sometimes indefinitely, so subsequent clicks never reach the service. That freezes the destination (breaking edit) and silently stops counting clicks. So 302, plus Cache-Control: no-store.</p><p><strong>Rejected:</strong> querying Postgres on every redirect. That is fine at low traffic, but it ties the one latency-critical path to whatever else the database is doing.</p><p>Short codes are 7 characters from a 62-character alphabet, 6²⁷ ≈ 3.5 trillion, generated randomly rather than by hashing the URL. Hashing would give the same URL the same code, so two users couldn’t each have their own analytics for the same link. A collision on insert regenerates and retries (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/services/urlService.ts">urlService.ts</a>, <a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/utils/base62.ts">base62.ts</a>). In practice it doesn’t fire.</p><h3>2. Click tracking</h3><p>Every click needs a timestamp, IP → country, user agent → device and browser, and the referrer. None of that belongs in the request.</p><p>The handler sends the 302, then pushes a small JSON event onto a Redis list without awaiting it (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/services/clickQueue.ts">clickQueue.ts</a>). If the push fails, the visitor was still redirected; a lost analytics row is the right thing to lose. A separate worker (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/worker.ts">worker.ts</a>) pops in batches of 100, enriches (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/workers/clickProcessor.ts">clickProcessor.ts</a>), and batch-inserts into Postgres.</p><p><strong>Why a separate process</strong> rather than an async function in the API: the geoip-lite database is about 110 MB resident once loaded. Measured live, the API container sits at ~50 MB and the worker at ~170 MB. Loading geo-IP into the API would tax every redirect for a lookup the redirect never performs, and a burst of clicks would compete with redirects for the same event loop.</p><p><strong>The trade-off:</strong> the worker pops a batch, then writes it, so a crash in between loses up to 100 clicks. For analytics that is acceptable. An acknowledged queue is the fix if it ever isn’t.</p><p>The worker also runs the hourly sweep of expired guest links and stale refresh tokens. It already touches Postgres on its own schedule, so the housekeeping was free.</p><h3>3. Rate limiting</h3><p>The textbook Redis rate limiter is a fixed window: INCR a key named after the current minute, EXPIRE it, reject over the limit. Three lines, and wrong by up to 2x.</p><p>With a 100-per-minute limit, a client sends 100 requests at 11:59:59.9 and 100 more at 12:00:00.1. Both windows see 100, both allow, and the client has done 200 requests in 200 milliseconds.</p><p>The fix is a sliding window counter: the current window’s count exactly, plus the previous window’s count weighted by how much of it still overlaps the trailing minute. At 12:00:15 the previous window contributes 75%. A client who maxed the tail of the last window is still bounded.</p><p>The second problem is concurrency. If the check is a GET, a compare in Node, then an INCR, two requests can both read 99 and both pass. So the whole decision is one Lua script on the Redis side (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/lib/rateLimiter.ts">rateLimiter.ts</a>; per-route tiers in <a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/middleware/rateLimit.ts">rateLimit.ts</a>):</p><pre>local current  = tonumber(redis.call(&#39;GET&#39;, KEYS[1]) or &#39;0&#39;)<br>local previous = tonumber(redis.call(&#39;GET&#39;, KEYS[2]) or &#39;0&#39;)<br>local limit    = tonumber(ARGV[1])<br>local elapsed  = tonumber(ARGV[2])   -- fraction of current window elapsed, 0..1<br>local ttl      = tonumber(ARGV[3])<br><br>local weighted = previous * (1 - elapsed) + current<br><br>if weighted &gt;= limit then<br>  return { 0, math.floor(weighted + 0.5) }<br>end<br><br>current = redis.call(&#39;INCR&#39;, KEYS[1])<br>redis.call(&#39;EXPIRE&#39;, KEYS[1], ttl)<br>return { 1, math.floor(weighted + 0.5) + 1 }</pre><p>Loaded once, called via EVALSHA, one round-trip. Redis runs one script at a time, so two requests can&#39;t interleave inside it.</p><p>Two more choices sit on top of that script:</p><ul><li>Two tiers per route. Anonymous callers are keyed by IP with a tight limit; authenticated callers by user ID with a looser one. Configured separately for redirects, link creation, general API and auth, because someone hammering the login form and someone’s link going viral are different problems.</li><li>Fail open. If Redis is down, requests go through. The limiter exists to protect Redis and Postgres from abusive load, and blocking everyone because the thing it protects is unavailable gets the priorities backwards.</li></ul><h4>A rate limit bounds speed, not identity</h4><p>The per-user limit has a hole in it that took me a while to see. Accounts are free, so an identity is not a scarce resource. One machine that registers ten accounts gets ten times the per-user creation budget, and every link it creates is permanent. The limiter is working exactly as designed and the table still fills up.</p><p>Three layers close that, and each does something the other two cannot:</p><ul><li>A per-IP link-creation budget on `POST /api/links`, charged on top of the per-user limit and keyed by IP no matter who is signed in. Registering more accounts buys no extra creation rate from one machine.</li><li>A per-account link quota (`MAX_LINKS_PER_USER`, 500 by default). Rate limits bound how fast links appear; this bounds how many exist. Without it an account can sit just under the rate ceiling forever and still grow the table without limit.</li><li>A Cloudflare Turnstile challenge on the anonymous browser forms: register, login and guest shorten. Optional, and off unless a secret key is configured.</li></ul><h3>4. Auth</h3><p>The requirement: sessions that survive a refresh, can be revoked, and don’t hand an attacker a long-lived credential after one XSS.</p><p>Access tokens are 15-minute JWTs held in browser memory only. Not localStorage, not a cookie. A script injected via XSS can’t read what isn’t stored.</p><p>Refresh tokens are opaque random strings, stored in Postgres as a SHA-256 hash so a database dump isn’t a session dump, delivered in an httpOnly cookie that the browser only sends to the refresh endpoint.</p><p>Every refresh rotates (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/services/authService.ts">authService.ts</a>, <a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/lib/tokens.ts">tokens.ts</a>): present a refresh token, get a new one, the old one is revoked. The interesting part is a revoked token showing up again. That means two parties hold it, the user and whoever copied it, and the server can’t tell which is which. So it revokes the whole token family and forces both to log in. The user gets a login prompt; the attacker gets one they can’t answer. This is the standard rotation-with-reuse-detection pattern from the OAuth 2.0 Security BCP (RFC 9700); the only decision here was to implement it directly rather than through a library.</p><p><strong>Rejected:</strong> <a href="https://next-auth.js.org/">NextAuth.js</a>. It issues and verifies sessions inside the Next.js server. Here sessions are owned by a separate Express API that also serves k6, curl and anything else with a bearer token. NextAuth would have meant two session systems, or restructuring auth around Next.js. The hand-rolled version is one design, checked the same way everywhere: rate-limit tiers, ownership checks, the OAuth callback.</p><p><strong>One consequence:</strong> the system does not support account linking. Sign up with GitHub, then register with the same email via password, and the second attempt is rejected rather than merged. Merging safely needs its own confirmation flow, and a quick version of it is a way to take over someone else’s account.</p><h3>5. What I left out</h3><p>I skipped the Google Safe Browsing API. The malicious-URL check is a static domain blocklist (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/packages/api/src/lib/urlBlocklist.ts">urlBlocklist.ts</a>), shipping Google’s own documented Safe Browsing test domains by default so it demonstrably works without an API key. A real deployment would want live threat intelligence, but this project needs to run with docker compose up and no external account.</p><p>I also skipped horizontal scaling. The API runs as one process. The design is already stateless, since JWTs verify anywhere, refresh state is in Postgres and rate-limit state is in Redis, so a second replica needs no sticky sessions and no code change. I left it out because I didn’t yet know whether it was needed. <a href="https://medium.com/@i-am-chitti/load-testing-a-url-shortener-e3c7f9b4e504">Part 2</a> is about finding out.</p><h3>6. Deployment</h3><p>One EC2 instance running the same docker compose shape as local dev, minus Postgres (external, on Neon) and plus Caddy for HTTPS. Caddy routes /api/* and short codes to the API container and everything else to Next.js, so the browser talks to one origin.</p><p>Deploys are a manually triggered GitHub Actions workflow (<a href="https://github.com/i-am-chitti/linkpulse/blob/main/.github/workflows/deploy.yml">deploy.yml</a>, <a href="https://github.com/i-am-chitti/linkpulse/blob/main/docker-compose.prod.yml">docker-compose.prod.yml</a>, <a href="https://github.com/i-am-chitti/linkpulse/blob/main/Caddyfile">Caddyfile</a>): build and push images to GHCR, run prisma migrate deploy against Neon, SSH in and restart the stack. Secrets come from GitHub, not a laptop.</p><p><strong>Rejected:</strong> a PaaS. It is faster on day one, but its deploy config doesn’t carry to another provider, so moving later means building this VM-plus-Compose recipe anyway.</p><p><strong>Also rejected:</strong> RDS. The point of an external database is that it stays put when the compute moves. RDS is still an AWS resource, so it would have to move too. Neon never does.</p><h3>Summary</h3><p>The four-box diagram is accurate as far as it goes. Each decision in this post sits one level below it:</p><ul><li>A cache in front of the database also has to cache the misses, or a bot scanning random codes reaches Postgres on every request.</li><li>The redirect has to be a 302, or browsers stop sending the clicks you are counting.</li><li>The rate limiter needs a sliding window, since INCR and EXPIRE let through twice the configured rate at a boundary.</li><li>The click queue needs its own process, because the geo-IP lookup costs 110 MB of memory the redirect path never uses.</li></ul><p>None of these needed traffic to find. They all came up while building on one machine.</p><p>What did need traffic was the question of which component runs out of capacity first. I assumed Redis. Part 2 measures it.</p><p><em>Part 2: </em><a href="https://medium.com/@i-am-chitti/load-testing-a-url-shortener-e3c7f9b4e504"><em>Load-testing a URL shortener: the bottleneck was Node, not Redis or Postgres</em></a></p><p><em>Stack: Node 24, Express 5, TypeScript, PostgreSQL 16 via Prisma, Redis 7, Next.js 16; Vitest and Supertest for tests, k6 for load. Full write-up, including testing and security notes: </em><a href="https://github.com/i-am-chitti/linkpulse/blob/main/ARCHITECTURE.md"><em>ARCHITECTURE.md</em></a><em>.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c580c8f7ee16" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Testing WP-CLI Commands with PHPUnit and Behat]]></title>
            <link>https://medium.com/@i-am-chitti/testing-wp-cli-commands-with-phpunit-and-behat-a0d285230acf?source=rss-08cb4a84e71e------2</link>
            <guid isPermaLink="false">https://medium.com/p/a0d285230acf</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[software-testing]]></category>
            <category><![CDATA[unit-testing]]></category>
            <category><![CDATA[wordpress]]></category>
            <category><![CDATA[php]]></category>
            <dc:creator><![CDATA[Deepak Kumar]]></dc:creator>
            <pubDate>Sat, 05 Sep 2026 19:15:09 GMT</pubDate>
            <atom:updated>2026-09-05T19:22:52.712Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*L3dmDi58OPoaQk3A5_1lZg.jpeg" /></figure><p><em>A practical, run-tested guide to unit testing WP-CLI commands with PHPUnit and Behat.</em></p><p>A migration command reassigns 40,000 posts from a legacy author ID to the WordPress user that owns them. It runs unattended in a deploy step. It processes 14,203 posts cleanly. Then it hits one post whose legacy ID isn’t in the mapping.</p><p>What happens next — does it stop, skip the post and keep going, write post_author = 0? Nobody knows, because nobody has run this exact case. The command works: it runs, it prints output, and for most CLI scripts that&#39;s where testing stops.</p><p>A broken web request breaks one page for one visitor, and you hear about it in minutes. A command that mishandles one row in a 40,000-row batch can quietly corrupt every row after it — running with full database access, unattended, often straight against production, with nobody reading the output. The failure mode isn’t a crash. It’s wrong data that looks completely fine.</p><p>This post covers what to prove about a command like that before it runs unattended, and the specific ways WP_CLI_Command classes resist PHPUnit more than the rest of a WordPress codebase.</p><p><em>📦 Full repo: </em><a href="https://github.com/i-am-chitti/wp-cli-command-testing"><strong><em>github.com/i-am-chitti/wp-cli-command-testing</em></strong></a></p><h3>🧪 Table of Contents</h3><ol><li><a href="#ba7d"><strong>The Command</strong> — What one unmapped row actually costs</a></li><li><a href="#0f7d"><strong>WP_CLI_Command Doesn&#39;t Exist Until You Load It</strong> — WP-CLI as a Composer dependency</a></li><li><a href="#42bc"><strong>The Test That Watches Nothing</strong> — Why </a><a href="#42bc">expectOutputString() never sees WP-CLI output</a></li><li><a href="#59c1"><strong>Proving the Command Stops</strong> — </a><a href="#59c1">WP_CLI::$capture_exit is private; what to do instead</a></li><li><a href="#f823"><strong>What </strong></a><a href="#f823"><strong>runcommand() Is Actually For</strong> — And why PHPUnit can&#39;t reach it</a></li><li><a href="#0543"><strong>What Unit Tests Can’t See</strong> — Behat against the real </a><a href="#0543">wp binary</a></li><li><a href="#6b41"><strong>Wiring It Into CI</strong> — Matrix floors, credentials, and versions that don’t vary</a></li><li><a href="#136a"><strong>Key Takeaways</strong></a></li></ol><h3>1. The command, and what’s actually at stake</h3><p>The command, trimmed to the relevant part:</p><pre>class Migrate_Authors_Command extends WP_CLI_Command {<br><br>    public function run( $args, $assoc_args ) {<br>        $mapping  = $this-&gt;load_mapping( $assoc_args[&#39;map&#39;] );<br>        $post_ids = get_posts( [<br>            &#39;post_type&#39;      =&gt; &#39;post&#39;,<br>            &#39;fields&#39;         =&gt; &#39;ids&#39;,<br>            &#39;posts_per_page&#39; =&gt; -1,<br>        ] );<br><br>        $migrated = 0;<br><br>        foreach ( $post_ids as $post_id ) {<br>            $legacy_id = get_post_meta( $post_id, &#39;_legacy_author_id&#39;, true );<br><br>            if ( ! isset( $mapping[ $legacy_id ] ) ) {<br>                WP_CLI::error( &quot;No mapping for legacy author ID {$legacy_id} (post {$post_id})&quot; );<br>            }<br><br>            wp_update_post( [ &#39;ID&#39; =&gt; $post_id, &#39;post_author&#39; =&gt; $mapping[ $legacy_id ] ] );<br>            $migrated++;<br>        }<br><br>        WP_CLI::success( &quot;{$migrated} posts migrated.&quot; );<br>    }<br>}</pre><p><a href="https://make.wordpress.org/cli/handbook/references/internal-api/wp-cli-error/">WP_CLI::error()</a> inside the loop does all the safety work here. It halts the command on the first bad row.</p><p>That safety is one refactor away from disappearing. Change it to log a warning and continue — a reasonable-looking change, so one bad row doesn&#39;t kill a long run — and every post after that row keeps its old author, the command still exits 0, and the deploy reports success.</p><p>So the property worth testing is: an incomplete mapping stops the command, and posts already migrated stay migrated. Proving that means working around three things WP-CLI does deliberately.</p><h3>2. WP_CLI_Command doesn&#39;t exist until you load it</h3><p>Run a plain PHPUnit test against that command and it fails before the first assertion:</p><pre>Error: Class &quot;WP_CLI_Command&quot; not found</pre><p>WP_CLI_Command only exists inside the wp binary, and PHPUnit never touches that code path.</p><p>The fix is to install <a href="https://github.com/wp-cli/wp-cli">wp-cli/wp-cli</a> as a normal Composer dev dependency. Its own composer.json already points at the classes, so vendor/autoload.php loads WP_CLI_Command for you — no hunting for a wp binary on disk.</p><pre>{<br>  &quot;require-dev&quot;: {<br>    &quot;wp-cli/wp-cli&quot;: &quot;^2.11&quot;,<br>    &quot;wp-phpunit/wp-phpunit&quot;: &quot;^7.1&quot;,<br>    &quot;yoast/phpunit-polyfills&quot;: &quot;^2.0&quot;,<br>    &quot;phpunit/phpunit&quot;: &quot;^9.6&quot;,<br>    &quot;roots/wordpress&quot;: &quot;^7.1&quot;<br>  },<br>  &quot;extra&quot;: {<br>    &quot;wordpress-install-dir&quot;: &quot;wordpress&quot;<br>  }<br>}</pre><p><a href="https://github.com/wp-phpunit/wp-phpunit">wp-phpunit</a> is only WordPress&#39;s <em>test</em> suite; <a href="https://github.com/roots/wordpress">roots/wordpress</a> is what puts core itself on disk, so composer install is the only setup step.</p><p>PHPUnit is pinned to ^9.6, not the ^9.6 || ^10.5 you&#39;ll see suggested elsewhere. The 10.5 release I tried removed an internal PHPUnit\Util\Test method that wp-phpunit&#39;s test-case class still calls — not an assertion, so phpunit-polyfills doesn&#39;t cover it. Check that combination yourself before trusting it.</p><p>tests/bootstrap.php ties it together:</p><pre>require_once dirname( __DIR__ ) . &#39;/vendor/autoload.php&#39;;<br><br>putenv( &#39;WP_TESTS_PHPUNIT_POLYFILLS_PATH=&#39; . dirname( __DIR__ ) . &#39;/vendor/yoast/phpunit-polyfills&#39; );<br><br>$_tests_dir = getenv( &#39;WP_TESTS_DIR&#39; ) ?: dirname( __DIR__ ) . &#39;/vendor/wp-phpunit/wp-phpunit&#39;;<br>require $_tests_dir . &#39;/includes/functions.php&#39;;<br><br>tests_add_filter( &#39;muplugins_loaded&#39;, function () {<br>    require dirname( __DIR__ ) . &#39;/plugin.php&#39;;<br>} );<br><br>require $_tests_dir . &#39;/includes/bootstrap.php&#39;;</pre><p>yoast/phpunit-polyfills needs a word. WordPress&#39;s test tools call assertion methods that newer PHPUnit versions renamed or removed; the polyfill restores the old names as thin wrappers, and WordPress&#39;s bootstrap picks it up via WP_TESTS_PHPUNIT_POLYFILLS_PATH. Without it the suite breaks differently depending on which PHPUnit is installed, and local and CI don&#39;t always match.</p><p>🔗 <a href="https://github.com/i-am-chitti/wp-cli-command-testing/blob/main/composer.json"><em>composer.json</em></a></p><p>🔗 <a href="https://github.com/i-am-chitti/wp-cli-command-testing/blob/main/tests/bootstrap.php"><em>tests/bootstrap.php</em></a></p><h3>3. The test that watches nothing</h3><p>This test looks reasonable and passes:</p><pre>public function test_run_reports_success() {<br>    ( new Migrate_Authors_Command() )-&gt;run( [], [ &#39;map&#39; =&gt; $this-&gt;fixture_map() ] );<br><br>    $this-&gt;expectOutputString( &#39;3 posts migrated.&#39; );<br>}</pre><p>It also asserts nothing.</p><p>WP_CLI::success(), log(), and error() don&#39;t use echo. They go through a Logger — a small class whose only job is printing to your terminal — and the default one writes straight to php://stdout, skipping PHP&#39;s output buffer entirely. expectOutputString() and ob_start() only see that buffer, so the test above was comparing against an empty string the whole time. A command that logs nothing and one that logs correctly look identical to it.</p><p>set_logger() swaps in a Logger you can read from:</p><pre>class Spy_Logger extends \WP_CLI\Loggers\Base {<br>    public array $messages = [];<br><br>    public function info( $message, $newline = true ) { $this-&gt;messages[&#39;info&#39;][] = $message; }<br>    public function success( $message ) { $this-&gt;messages[&#39;success&#39;][] = $message; }<br>    public function warning( $message ) { $this-&gt;messages[&#39;warning&#39;][] = $message; }<br>    public function error( $message ) { $this-&gt;messages[&#39;error&#39;][] = $message; }<br>}</pre><p>It’s extends \WP_CLI\Loggers\Base, not an interface called Logger. Base only forces info(), success() and warning() on you — but WP_CLI::error() calls $logger-&gt;error() directly, so leave that one out and you get &quot;call to undefined method&quot; the first time a real error fires.</p><p><a href="https://github.com/i-am-chitti/wp-cli-command-testing/blob/main/src/Testing/Spy_Logger.php"><em>Spy_Logger.php</em></a></p><pre>public function test_run_migrates_every_post_with_a_valid_mapping() {<br>    $logger = new Spy_Logger();<br>    \WP_CLI::set_logger( $logger );<br><br>    $post_ids = $this-&gt;create_posts_with_legacy_authors( [ 101, 102, 103 ] );<br>    ( new Migrate_Authors_Command() )-&gt;run( [], [ &#39;map&#39; =&gt; $this-&gt;fixture_map() ] );<br><br>    foreach ( $post_ids as $post_id ) {<br>        $this-&gt;assertNotSame( 0, get_post( $post_id )-&gt;post_author );<br>    }<br>    $this-&gt;assertStringContainsString( &#39;3 posts migrated&#39;, $logger-&gt;messages[&#39;success&#39;][0] );<br>}</pre><p>(fixture_map() is a stand-in here — the actual test builds the mapping file inline with tempnam(): <a href="https://github.com/i-am-chitti/wp-cli-command-testing/blob/main/tests/Unit/Migrate_Authors_Command_Test.php">Migrate_Authors_Command_Test.php</a>.)</p><p>The first assertion checks post_author in the database, not the log message. The log line reports what the command <em>says</em> it did; the database holds what it did.</p><p>The Logger isn’t the only output path. --format=json, --format=csv and --format=yaml use plain echo, so ob_start() captures those normally. Only log, success, warning and error messages bypass the buffer.</p><h3>4. Proving the command actually stops</h3><p>This is the test that catches a hard stop being turned into a skip. Two approaches don’t work.</p><p>Setting WP_CLI::$capture_exit = true before calling run() would make error() throw a catchable WP_CLI\ExitException instead of calling exit(). The property exists and controls exactly that, but it&#39;s private — a test file can&#39;t set it.</p><p>The only thing that flips it is runcommand() internally, with exit_error =&gt; false. Routing the call through runcommand() hits a harder wall: it goes through WP-CLI&#39;s Runner, and a Runner that was never started has a null config, so it dies on in_array(&#39;migrate-authors&#39;, null, true). Populating that by hand means reimplementing WP-CLI&#39;s boot sequence.</p><p>The alternative is to change what the test touches. The loop that matters — walk the posts, stop on the first unmapped one — doesn’t need WP_CLI at all:</p><pre>public function migrate( array $mapping ): int {<br>    $post_ids = get_posts( [ &#39;post_type&#39; =&gt; &#39;post&#39;, &#39;fields&#39; =&gt; &#39;ids&#39;, &#39;posts_per_page&#39; =&gt; -1 ] );<br>    $migrated = 0;<br><br>    foreach ( $post_ids as $post_id ) {<br>        $legacy_id = get_post_meta( $post_id, &#39;_legacy_author_id&#39;, true );<br><br>        if ( ! isset( $mapping[ $legacy_id ] ) ) {<br>            throw new Mapping_Error( &quot;No mapping for legacy author ID {$legacy_id} (post {$post_id})&quot; );<br>        }<br><br>        wp_update_post( [ &#39;ID&#39; =&gt; $post_id, &#39;post_author&#39; =&gt; $mapping[ $legacy_id ] ] );<br>        $migrated++;<br>    }<br><br>    return $migrated;<br>}</pre><p>run() becomes a thin wrapper around it:</p><pre>public function run( $args, $assoc_args ) {<br>    $mapping = $this-&gt;load_mapping( $assoc_args[&#39;map&#39;] );<br><br>    try {<br>        $migrated = $this-&gt;migrate( $mapping );<br>    } catch ( Mapping_Error $e ) {<br>        WP_CLI::error( $e-&gt;getMessage() );<br>        return;<br>    }<br><br>    WP_CLI::success( &quot;{$migrated} posts migrated.&quot; );<br>}</pre><p>Now the test that matters doesn’t touch WP_CLI:: at all:</p><pre>public function test_migrate_stops_before_touching_posts_past_an_unmapped_id() {<br>    $mapped_author   = self::factory()-&gt;user-&gt;create();<br>    $original_author = self::factory()-&gt;user-&gt;create();<br><br>    // Post A has a mapped legacy author. Post B does not. Post C comes after B.<br>    [ $post_a, $post_b, $post_c ] = $this-&gt;create_posts_with_legacy_authors( [ 101, 999, 102 ] );<br>    foreach ( [ $post_a, $post_b, $post_c ] as $post_id ) {<br>        wp_update_post( [ &#39;ID&#39; =&gt; $post_id, &#39;post_author&#39; =&gt; $original_author ] );<br>    }<br><br>    $this-&gt;expectException( Mapping_Error::class );<br><br>    try {<br>        ( new Migrate_Authors_Command() )-&gt;migrate( [ &#39;101&#39; =&gt; $mapped_author, &#39;102&#39; =&gt; $mapped_author ] );<br>    } finally {<br>        $this-&gt;assertSame( $mapped_author, (int) get_post( $post_a )-&gt;post_author, &#39;Post before the failure should be migrated.&#39; );<br>        $this-&gt;assertSame( $original_author, (int) get_post( $post_c )-&gt;post_author, &#39;Post after the failure should be untouched.&#39; );<br>    }<br>}</pre><p>Change the error handling to skip-and-continue and this fails immediately, long before a real 40,000-row run. No ExitException, no capture_exit, no simulated runtime — a plain exception and a database assertion. What it doesn&#39;t cover is run()&#39;s own error path, whether it really calls WP_CLI::error() and exits. That&#39;s Behat&#39;s job, below.</p><p>🔗 <a href="https://github.com/i-am-chitti/wp-cli-command-testing/blob/main/src/Commands/Migrate_Authors_Command.php"><em>Migrate_Authors_Command.php</em></a></p><p>🔗 <a href="https://github.com/i-am-chitti/wp-cli-command-testing/blob/main/tests/Unit/Migrate_Authors_Command_Test.php"><em>Migrate_Authors_Command_Test.php</em></a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qltYNoXBm-iT1kUC8gxvCw.png" /><figcaption>PHPUnit passing against real MySQL and WordPress</figcaption></figure><p>Note the casts. post_author comes back as a numeric <em>string</em> — a raw DB row property WordPress doesn&#39;t convert — so assertSame() fails without them. assertEquals() would hide the difference.</p><h3>5. What runcommand() is actually for</h3><p>run should still refuse to start on a mapping nobody has verified — say, by calling a separate wp migrate-authors verify-mapping. WP_CLI::runcommand() exists for exactly that: one command invoking another by name, with no hard class dependency.</p><p>Testing that composition from PHPUnit hits the same wall as Section 4: no started Runner. That constraint is tolerable, because runcommand() is worth its cost mainly when the two commands live in different, independently-versioned packages — where a direct call isn&#39;t available anyway. That case only executes inside a live wp process, which makes it Behat&#39;s to verify.</p><p>When both commands are in the same package, a plain method call does the job and stays testable:</p><pre>public function run( $args, $assoc_args ) {<br>    $mapping = $this-&gt;load_mapping( $assoc_args[&#39;map&#39;] );<br><br>    $invalid = $this-&gt;find_invalid_mapping( $mapping );<br><br>    if ( null !== $invalid ) {<br>        WP_CLI::error( $invalid );<br>        return;<br>    }<br><br>    // ...<br>}</pre><p>It calls find_invalid_mapping(), not verify_mapping(). The subcommand ends in WP_CLI::success( &#39;...mappings verified.&#39; ), which is correct when someone runs it by hand and noise as a pre-flight check. Keeping the check in a private method that returns a message or null lets each caller report it appropriately.</p><p>The rule: use runcommand() when you don&#39;t control the other command&#39;s class. Otherwise call the method — that&#39;s the version PHPUnit can reach.</p><h3>6. What unit tests can’t see</h3><p>Everything so far tests the command’s logic with WP-CLI’s internals simulated around it. None of it verifies that wp migrate-authors run --help produces sensible help text, or that the command behaves when invoked exactly as it would be on a real server.</p><p>WP-CLI ships an official tool for that gap: <a href="https://github.com/wp-cli/wp-cli-tests">wp-cli/wp-cli-tests</a>, which runs your command through the real wp binary against a throwaway WordPress install, using <a href="https://behat.org/">Behat</a>. Scenarios read like plain English — set up state, run a real command, check the output:</p><pre>Feature: migrate-authors<br><br>  Background:<br>    Given a WP install<br>    And I run `wp user create migrated-author migrated-author@example.com --porcelain`<br>    And save STDOUT as {MIGRATED_AUTHOR_ID}<br><br>  Scenario: Halting on a post whose legacy author ID isn&#39;t mapped<br>    Given I run `wp post create --post_title=&quot;Unmapped post&quot; --post_status=publish --porcelain`<br>    And save STDOUT as {POST_ID}<br>    And I run `wp post meta update {POST_ID} _legacy_author_id 999`<br>    And a wp-content/mu-plugins/author-mapping.csv file:<br>      &quot;&quot;&quot;<br>      101,{MIGRATED_AUTHOR_ID}<br>      &quot;&quot;&quot;<br><br>    When I try `wp migrate-authors run --map=wp-content/mu-plugins/author-mapping.csv`<br>    Then STDERR should contain:<br>      &quot;&quot;&quot;<br>      No mapping for legacy author ID 999<br>      &quot;&quot;&quot;<br>    And the return code should be 1</pre><p>Every step there ships with wp-cli-tests&#39; FeatureContext — nothing custom. There&#39;s no shorthand for &quot;a post with a given meta value&quot;; you compose it from wp post create and wp post meta update, same as you would at a terminal.</p><p>Use I try, not I run. I run fails the scenario as soon as the command exits non-zero, which is what this scenario asserts.</p><p>(Trimmed — the <a href="https://github.com/i-am-chitti/wp-cli-command-testing/blob/main/features/migrate-authors.feature">feature file</a> also covers the successful path.)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vCyosLQVoPca2UDLyDki7g.png" /><figcaption>Behat passing against the real wp binary</figcaption></figure><p>Behat runs alongside PHPUnit, not instead of it. PHPUnit runs in milliseconds and takes every edge case; Behat boots an actual WordPress site, so it covers only the scenarios that need the real binary.</p><p>Two things to know before starting. WP_CLI_TEST_DBTYPE=sqlite removes the database server requirement entirely — FeatureContext skips its mysql and mysqldump shell-outs — which reduces setup to composer behat. And wp-cli/wp-cli alone doesn&#39;t provide wp post or wp user for fixtures; those live in wp-cli/entity-command.</p><h3>7. Wiring it into CI</h3><p>A suite that only runs when someone remembers to run it isn’t a safety net. The full workflow is <a href="https://github.com/i-am-chitti/wp-cli-command-testing/blob/main/.github/workflows/tests.yml">in the repo</a>; three things in it aren’t obvious from reading it.</p><p>The PHP matrix has a floor. wp-cli/wp-cli-tests pulls in behat/behat, which requires PHP 8.2+. Include 8.1 and composer install fails outright — a dependency resolution failure, not a test failure — and GitHub&#39;s default fail-fast cancels the remaining legs before they report.</p><p>Database credentials belong at the job level, not on the step that creates the database. Scoped to that step, creation succeeds and the test steps then fail with “Error establishing a database connection”, which reads like a credentials problem and isn’t.</p><p>Verify the matrix actually varies something. A wp: [&#39;6.7&#39;, &#39;6.4&#39;] matrix here tested identical WordPress on both legs: WP_VERSION reaches only Behat, while PHPUnit takes its copy from roots/wordpress in composer.json. Pin both sources together and confirm the version that ran.</p><p><a href="https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/">wp-env</a> is the lighter alternative — WordPress&#39;s own tool, running PHPUnit inside Docker with none of this setup, and a --ci=github flag for the logs.</p><p>🔗 <a href="https://github.com/i-am-chitti/wp-cli-command-testing/actions"><em>CI Actions</em></a></p><p>🔗 <a href="https://github.com/i-am-chitti/wp-cli-command-testing/blob/main/.github/workflows/tests.yml"><em>Test CI Workflow</em></a></p><h3>Key Takeaways</h3><ul><li>A crash isn’t the real risk. Silently wrong data is — a bad row deep in a big batch, with nobody noticing.</li><li>Install wp-cli/wp-cli as a Composer dependency and WP_CLI_Command just loads.</li><li>WP-CLI’s Logger writes to php://stdout, so expectOutputString() and ob_start() never see it. Swap it with set_logger(), and assert against the database, not just the log line. --format=json and --format=csv are the exception — those go through plain echo.</li><li>WP_CLI::$capture_exit is private and runcommand() needs a live Runner, so neither works from a bare PHPUnit test. Pull the exit-prone logic into a method that throws a plain exception, and leave the real exit behavior to Behat.</li><li>Reach for runcommand() only when you don&#39;t control the other command&#39;s class. Same package, call the method.</li><li>PHPUnit for logic, Behat for the real binary, both in CI.</li></ul><p>Related, on a different layer of the same stack: <a href="https://medium.com/@i-am-chitti/enterprise-gutenberg-blocks-a-typescript-first-architecture-for-wordpress-at-scale-67af6c3c32c4">building type-safe Gutenberg blocks at scale</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a0d285230acf" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Built a Twitter-Style Feed to Find Out What Fan-Out Actually Costs]]></title>
            <link>https://medium.com/@i-am-chitti/i-built-a-twitter-style-feed-to-find-out-what-fan-out-actually-costs-80f7af9e2ccd?source=rss-08cb4a84e71e------2</link>
            <guid isPermaLink="false">https://medium.com/p/80f7af9e2ccd</guid>
            <category><![CDATA[system-design-interview]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[backend]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[redis]]></category>
            <dc:creator><![CDATA[Deepak Kumar]]></dc:creator>
            <pubDate>Fri, 21 Aug 2026 16:56:40 GMT</pubDate>
            <atom:updated>2026-08-22T13:12:03.814Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WiQFPckspjciz5Bc7v_uiA.jpeg" /></figure><p><em>Part 2 of two. </em><a href="https://medium.com/@i-am-chitti/celebrities-vs-the-rest-of-us-designing-a-twitter-style-feed-11c6b3b52a2f"><em>Part 1</em></a><em> covers the architecture — push, pull, and why a hybrid is the only thing that scales. This part is the measurement.</em></p><p>The standard advice about feed systems is easy to find and easy to agree with:</p><ul><li>Push makes reads fast, because timelines are built in advance.</li><li>Push gets expensive when an account has many followers.</li><li>Pull makes writes cheap, because nothing is copied.</li><li>Pull gets expensive when a user follows many accounts.</li></ul><p>Every one of those is true. None of them is a number.</p><p>That bothered me while writing <a href="https://medium.com/@i-am-chitti/celebrities-vs-the-rest-of-us-designing-a-twitter-style-feed-11c6b3b52a2f">Part 1</a>, because the whole design hinges on quantities nobody states. How expensive is “expensive”? Where exactly should the line between a normal account and a celebrity sit? Is the read advantage large or is it a rounding error?</p><p>So I built both models over the same data and measured them.</p><p>GitHub Repo Codebase — <a href="https://github.com/i-am-chitti/twitter-feed-benchmark">https://github.com/i-am-chitti/twitter-feed-benchmark</a></p><p><strong>What’s covered:</strong></p><ul><li>How the benchmark was set up, and what makes the dataset honest</li><li>What fan-out costs per follower, and what that extrapolates to</li><li>Where the celebrity cutoff comes from once you have a real number</li><li>The memory bill, measured rather than estimated</li><li>The read result I did not expect</li><li>What this benchmark cannot prove</li></ul><h3>What I Built</h3><p>Two endpoints, /feed/push and /feed/pull, sitting over one shared dataset. The push endpoint reads a pre-computed Redis sorted set and merges in a live query for followed celebrities. The pull endpoint runs the naive query: find who you follow, fetch their recent tweets, sort, return twenty.</p><p>Both return the same twenty tweets. That mattered more than I expected, and I’ll come back to it.</p><h3>The stack</h3><ul><li><strong>API:</strong> Node.js 24 with Express, in Docker</li><li><strong>Cache:</strong> managed Redis</li><li><strong>Database:</strong> managed PostgreSQL 15</li><li><strong>Hosting:</strong> Render, each component as its own service — a starter web instance, starter Redis, and a basic-256mb Postgres</li><li><strong>Load generator:</strong> autocannon, running on my laptop against the deployed endpoints over the public internet</li></ul><p>That last point matters and I’ll return to it in the limitations.</p><h3>The dataset</h3><ul><li>1,000 users, 30,000 tweets, 20,000 follow edges</li><li>Generated from a fixed PRNG seed, so the same input reproduces exactly — it produced identical graph statistics across two independent runs and across both SQLite and PostgreSQL</li><li>Followees drawn from a <strong>Zipf distribution</strong>, not uniformly</li></ul><p>That last choice is the one that makes the experiment mean anything. My first version had every user follow twenty random accounts, which gives every account roughly the same follower count. That erases celebrities from the dataset entirely, so there is nothing left to measure. With a power law, the follower counts came out as: median 7, 90th percentile 34, 99th percentile 281, maximum 960, mean 20.</p><p>Celebrity accounts weren’t hardcoded. They were <strong>derived</strong> from the graph — any account with 200 or more followers, which selected 14 of the 1,000.</p><h3>How I measured</h3><ul><li><strong>Read tests:</strong> concurrency swept at 1, 10, 50, 100 and 400 connections, 30 seconds per run, three repeats per level</li><li><strong>Warm-up and ordering:</strong> the two models alternated which one ran first between repeats, so warm-cache and warm-query-plan effects cancel out</li><li><strong>Access pattern:</strong> userId randomised per request, rather than hammering one hot key</li><li><strong>Reported values:</strong> medians across the three repeats, with the spread recorded</li><li><strong>Errors:</strong> zero non-2xx responses across every run</li><li><strong>Server-side timings:</strong> a Server-Timing header breaks each request into stages, so latency could be attributed rather than guessed at. Medians of five samples, discarding a cold first request.</li><li><strong>Write tests:</strong> eleven probe accounts spanning 4 to 960 followers, five publishes each, median reported. Timed server-side inside the request, so my network latency doesn’t pollute it.</li></ul><p>One thing I added before trusting any number: a test asserting <strong>both endpoints return the identical page</strong>. Comparing two architectures only means something if they’re doing equal work. That test caught a bug where a tweet reachable from both halves of the hybrid appeared twice in the page, which would have quietly made the comparison unequal. I ran it before every benchmark.</p><h3>Result 1: Fan-Out Cost Is Linear</h3><p>This is where push is supposed to break, so it went first. I published from accounts across the follower range and timed only the copying step.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2Wp_mPI4YT3xtsctzBF3-Q.jpeg" /><figcaption>Fan-out cost versus follower count</figcaption></figure><ul><li>4 followers: 1.05 ms</li><li>63 followers: 1.50 ms</li><li>191 followers: 2.79 ms</li><li>570 followers: 7.07 ms</li><li>960 followers: 13.64 ms</li></ul><p>That fits a straight line: roughly <strong>0.8 ms of fixed cost plus 12 microseconds per follower</strong>.</p><p>The linearity matters more than the constant, because a linear relationship extrapolates and a mysterious one doesn’t. Twelve microseconds sounds like nothing until you multiply it out:</p><ul><li>100,000 followers → about 1.2 seconds</li><li>1 million followers → about 12 seconds</li><li>80 million followers → about <strong>16 minutes</strong></li></ul><p><strong>These are extrapolations from this benchmark, not production figures.</strong> They assume the linear relationship holds four orders of magnitude beyond anything I measured, on hardware I didn’t test, with no batching or sharding of the fan-out work. Treat them as an illustration of the shape of the problem rather than a prediction.</p><p>Even so, the shape is the point. Sixteen minutes of copying for one tweet is why a large account cannot use push, and it gives the celebrity threshold something better than folklore to stand on.</p><p><strong>One constant, two very different values.</strong> The same fan-out code cost <strong>4.5 microseconds per follower</strong> against a Redis container on my laptop, and <strong>12 microseconds</strong> against the managed Redis. Nearly three times the difference, from identical code.</p><p>So this coefficient describes your infrastructure, not the algorithm. Anyone reusing the 12 µs figure without measuring their own setup is doing the exact thing this article is arguing against.</p><h3>Result 2: The Cutoff Becomes Arithmetic</h3><p>Part 1 left the celebrity threshold as a formula with a hole in it:</p><pre>cutoff = fan-out time budget ÷ cost per follower</pre><p>The measurement fills the hole. With a five-second fan-out budget:</p><pre>5 seconds ÷ 12 microseconds ≈ 417,000 followers</pre><p>To be precise about what that number is: <strong>it’s the cutoff for this hardware, this Redis, this fan-out implementation, and a five-second budget I chose.</strong> It is not an industry threshold, and if you ran the same calculation on your own stack you would get a different answer.</p><p>The transferable part isn’t 417,000. It’s that the threshold is a division you can perform, using one number you can measure in an afternoon and one number your product team can decide. Most write-ups on this topic pick a threshold and never say where it came from.</p><h3>Result 3: The Memory Bill</h3><p>Push costs memory that pull doesn’t, and at scale that becomes money.</p><p>I measured actual key sizes with MEMORY USAGE against live feed keys, rather than reasoning about Redis internals:</p><ul><li>a 330-entry timeline: 27,555 bytes → about 83 bytes per entry</li><li>a 360-entry timeline: 28,899 bytes → about 80 bytes per entry</li><li>a 420-entry timeline: 32,827 bytes → about 78 bytes per entry</li></ul><p>So roughly <strong>80 bytes per timeline entry</strong>. That’s an observed figure on this Redis version and configuration, not a Redis constant — sorted set encoding changes with size, and the numbers will shift with your setup and your Redis version.</p><p>Taking 80 bytes and an 800-entry timeline gives about 64 KB per user, so:</p><pre>100 million users × 64 KB ≈ 6.4 TB</pre><p>That’s timeline structure alone. Cached tweet bodies measured about 130 bytes each, which means the half that <em>feels</em> expensive is the cheap half.</p><p>6.4 TB is also the number that makes “don’t build timelines for inactive users” non-negotiable rather than a nice optimisation. At one in ten users active, the same design needs around 640 GB.</p><h3>Result 4: The Read Path, Which Did Not Go As Expected</h3><p>Push exists to make reads cheap. I expected the clearest result of the three here. I got the murkiest one.</p><p>Under load, the cached push timeline and the naive SQL pull query finished <strong>within about 1% of each other</strong>. Below saturation I could not reliably tell them apart. Push did pull ahead once the server was genuinely saturated, sustaining roughly <strong>1.5 times</strong> the throughput — that ratio held across two independent runs, at 1.48 and 1.58.</p><p>Then the server-side timings said something stranger. Push was doing <em>more</em> work per request:</p><ul><li><strong>Pull:</strong> 1.10 ms finding who you follow, plus 1.21 ms fetching their tweets — 2.31 ms, all of it PostgreSQL</li><li><strong>Push:</strong> 0.50 ms reading the sorted set, 0.34 ms fetching tweet bodies, 1.76 ms querying celebrities, 0.03 ms merging — 2.63 ms, of which 1.76 ms is PostgreSQL</li></ul><p>Push pays two cache round trips <em>and</em> still queries the database for celebrities. Per request it is slower. Yet it sustained 1.5× the traffic.</p><p>Both measurements are correct, and reconciling them is the most useful thing I got out of this:</p><blockquote><em>The cache didn’t make requests faster. It made them </em><strong><em>cheaper</em></strong><em>. It removed one of the two database round trips a read needs, moving load off the one resource that was running out of room.</em></blockquote><p>Which gives a lesson that generalises well beyond feeds: <strong>an unloaded per-request timing tells you about latency, not about capacity.</strong> My stage timings predicted pull would win. The concurrency sweep said the opposite. They disagree because they measure different things, and under load it’s the second one that matters.</p><h3>What This Benchmark Cannot Tell You</h3><p>This section matters more than the results, because every number above has a boundary.</p><p><strong>The dataset is small.</strong> 30,000 tweets and twenty follows per user. Pull only truly collapses when a single read merges hundreds of index ranges across millions of rows. I measured the gap opening, not the gap at full width — so treat the 1.5× as a floor, not a verdict.</p><p><strong>The load generator sat on my laptop, across the internet.</strong> Roughly 500 ms of round-trip time and TLS went into every read sample. It’s identical for both models so it doesn’t invalidate the comparison, but it dilutes the difference and makes the low-concurrency latency figures useless for anything except establishing that the difference was small.</p><p><strong>The 1.5× came from a saturated test.</strong> At 400 connections the system is well past its knee, and once you’re there average latency is arithmetically determined by throughput — they are one measurement, not two. I’m quoting the throughput ceiling for that reason, and not quoting a latency improvement alongside it as though it were independent evidence.</p><p><strong>The database connection pool was left at its default of ten, uncontrolled.</strong> Pull makes two round trips per request where push makes one, so that default could plausibly amplify the gap. I intended to test it and didn’t.</p><p><strong>Fan-out ran inline, not through a queue.</strong> Production systems enqueue and let workers drain it, which changes the write-latency picture completely. The 12 µs per follower is the cost of the work, not the cost of the request.</p><p><strong>The fan-out coefficient is hardware-specific</strong>, by a factor of nearly three between two environments I actually tried.</p><p><strong>No sharding, no replication, no multiple regions, single instance of everything.</strong> Production feed systems are batched, sharded and replicated, and each of those changes the arithmetic.</p><h3>What I’d Take Away</h3><p>Two of the three results are clean, and both are about writing rather than reading. That surprised me, because the read path is the part everyone argues about.</p><p>The reason is structural. Fan-out cost and memory cost both scale with <strong>follower count</strong>, which means you can characterise them without a production-sized database. Push’s read advantage only appears once your data is genuinely large, which is exactly why it was the one I couldn’t pin down.</p><p>So if you’re deciding whether to build this, the write and memory numbers are the ones to decide on. They’re also the ones you can get in an afternoon.</p><p>Which leads to the thing I’d tell anyone starting out:</p><blockquote><em>The common failure isn’t skipping this design. It’s building it too early.</em></blockquote><p>At thirty thousand tweets, a pre-built timeline system gains you almost nothing measurable and hands you a cache to keep in sync forever. A well-indexed SQL query stays the right answer for far longer than most articles admit.</p><p>If I were starting again, I’d measure my own fan-out cost before writing any of the architecture. It takes an afternoon, and it tells you more than any article can — including this one.</p><p><em>Part 1 covers the architecture this benchmark was testing: </em><a href="https://medium.com/@i-am-chitti/celebrities-vs-the-rest-of-us-designing-a-twitter-style-feed-11c6b3b52a2f"><em>Celebrities vs. The Rest of Us — Designing a Twitter-Style Feed</em></a><em>.</em></p><p><em>Code, including the seeding, both endpoints, the benchmark runner and the parity test: </em><a href="https://github.com/i-am-chitti/twitter-feed-benchmark"><em>github.com/i-am-chitti/twitter-feed-benchmark</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=80f7af9e2ccd" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Celebrities vs. The Rest of Us: Designing a Twitter-Style Feed]]></title>
            <link>https://medium.com/@i-am-chitti/celebrities-vs-the-rest-of-us-designing-a-twitter-style-feed-11c6b3b52a2f?source=rss-08cb4a84e71e------2</link>
            <guid isPermaLink="false">https://medium.com/p/11c6b3b52a2f</guid>
            <category><![CDATA[backend]]></category>
            <category><![CDATA[system-design-concepts]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[scalability]]></category>
            <dc:creator><![CDATA[Deepak Kumar]]></dc:creator>
            <pubDate>Fri, 21 Aug 2026 16:41:02 GMT</pubDate>
            <atom:updated>2026-08-21T16:59:51.240Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*U-N1wjUpXtaSJmW4HOL3Bg.jpeg" /></figure><p>Two things happened on Twitter in the last second. Someone with eleven followers posted a photo of their lunch. Someone with sixty million followers posted a single word.</p><p>The same servers handled both.</p><p>And that’s the part that’s actually hard — not the scale. Plenty of systems serve a hundred million people. What makes a feed awkward is that those hundred million look nothing like each other. Whatever you build for the person with eleven followers is the wrong thing to build for the person with sixty million.</p><p>I’d read the standard explanation before — push, pull, hybrid models. What I could never find was a number attached to any of it. Nobody tells what copying one tweet costs, or where the “celebrity” line should sit. So I built it and measured it.</p><h4><strong>What’s covered:</strong></h4><ul><li><a href="#5902">What we are building</a></li><li><a href="#1c32">The numbers we are designing for</a></li><li><a href="#a062">Two ways to build a feed: push and pull</a></li><li><a href="#b7af">The hybrid model, and why celebrities are treated differently</a></li><li><a href="#5cd2">Two caches, not one</a></li><li><a href="#38d0">What production adds</a></li><li><a href="#561b">But is any of this worth it?</a></li><li><a href="#3525">Takeaway</a></li></ul><p>The design is below. The measurements are in <a href="https://medium.com/@i-am-chitti/i-built-a-twitter-style-feed-to-find-out-what-fan-out-actually-costs-80f7af9e2ccd">Part 2</a>, including one result that undercut the thing I set out to prove. No system design background needed, and the code is on <a href="https://github.com/i-am-chitti/twitter-feed-benchmark">GitHub</a> if you’d like to poke at it.</p><h3>1. What We Are Building</h3><p>Only three things happen in this system:</p><ul><li>A user <strong>posts</strong> a short message. Let’s call it a tweet.</li><li>A user <strong>follows</strong> other users.</li><li>A user <strong>reads a timeline</strong>: recent tweets from everyone they follow, newest first, twenty at a time.</li></ul><p>That third one is the whole problem, and everything below answers a single question. When you open the app, where does that list of twenty tweets come from?</p><h4>The Pieces</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cfbxW596SFEW-UfQobI9Gw.jpeg" /></figure><ul><li><strong>Client</strong> — your phone or browser, asking for a timeline.</li><li><strong>Load balancer</strong> — spreads requests across a pool of identical servers.</li><li><strong>API servers</strong> — the application code. They hold no data; they fetch it and assemble a timeline, which is why you scale them by simply adding more.</li><li><strong>Database</strong> — the permanent record. It holds the tweets and the follow graph, meaning who follows whom. Reliable but slow, because it reads from disk.</li><li><strong>Cache</strong> — Redis. It keeps data in memory, so reads are roughly a hundred times faster than disk, but memory costs far more per gigabyte and a cache can lose what it holds. Ready-made timelines live here.</li></ul><h4>Five Words You’ll Need</h4><p><strong>Timeline -</strong> The list of tweets shown to one user, newest first.</p><p><strong>Follower and followe -</strong> If Sita follows Ram, then Sita is the follower and Ram is the followee. Ram’s tweets show up in Sita’s timeline.</p><p><strong>Fan-out -</strong> Copying one tweet out to many places. Ram posts once, but that tweet has to reach every one of Ram’s followers, and the spreading out is what we call fan-out. The central question in this post is <em>when</em> it happens: at the moment Ram posts, or at the moment Sita reads.</p><p><strong>QPS -</strong> Queries per second, which is how we size servers.</p><p><a href="https://redis.io/docs/latest/develop/data-types/sorted-sets/"><strong>Sorted set</strong></a><strong> -</strong> A Redis structure where every item carries a number called a score, and Redis keeps them ordered by it. If the score is a tweet’s timestamp, the timeline sorts itself for free.</p><h3>2. The Numbers We Are Designing For</h3><p>Suppose we’re designing for a hundred million daily active users, each posting about 0.1 times a day and refreshing their feed about ten times a day. These are assumptions for the exercise rather than Twitter’s real traffic — the point is the shape of the result, not the precision of the inputs. There are 86,400 seconds in a day, so:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XmTooD9ylg98q2akXaDEwQ.jpeg" /><figcaption>Capacity estimation and write amplification</figcaption></figure><ul><li><strong>Writes:</strong> 100M × 0.1 ÷ 86,400 = roughly <strong>115 tweets per second</strong></li><li><strong>Reads:</strong> 100M × 10 ÷ 86,400 = roughly <strong>11,500 reads per second</strong></li></ul><p>Reads outnumber writes a hundred to one, which is a number to keep in mind.</p><p>That write figure is worth a second look. 115 tweets per second is nothing at all — a laptop would handle it without noticing. So why does the simple approach fall over?</p><p>Because if you build timelines ahead of time, each of those tweets has to be copied into every follower’s timeline. The figure that matters isn’t how many people post, it’s how many copies that turns into:</p><pre>timeline writes per second = tweets per second × average followers</pre><p>At 200 followers on average, those 115 tweets per second become <strong>23,000 timeline writes per second</strong>, which is still perfectly manageable. The trouble lives in the tail. When one account with 80 million followers posts a single time, that’s <strong>80 million writes</strong> from one tweet. To be clear about what that means: it’s 80 million writes of <em>work</em>, not one request performing 80 million operations. Section 6 covers how that work gets spread across background workers. But the work still has to happen, and somebody has to pay for it.</p><p>Cheap on average and brutal at the top end. That gap is the reason for everything in Section 4.</p><h3>3. Two Ways To Build A Feed</h3><p>There are two options, and they differ on one decision: do you do the work when the tweet is posted, or when someone reads it?</p><h3>Option A: Push (fan-out on write)</h3><p>Ram posts, and the system immediately looks up all of Ram’s followers and writes the tweet ID into each of their timelines. All the work happens here, at the moment of posting, so that reading is left with nothing to do.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2ui0YrV6iPOqc0iTCzm3Xg.jpeg" /></figure><p>The upside is that reads are fast and predictable. It makes no difference whether Sita follows ten people or ten thousand, because fetching her timeline costs the same either way. You’ll often see this called O(1). It isn’t, strictly — <a href="https://redis.io/docs/latest/commands/zrange/">the range read is O(log N + M)</a> in the size of the set and the page. But the useful part holds: the cost doesn’t grow with how many people you follow.</p><p>The downsides all come from that copying, and there are three of them.</p><p><strong>Posting gets expensive -</strong> One tweet from an account with 80 million followers means 80 million cache writes. While those are running, every other post is queued behind them, so one celebrity slows the whole platform down.</p><p><strong>You pay for memory you may never read -</strong> Every user needs a stored timeline whether they open the app or not, and that memory bill arrives monthly.</p><p><strong>Corrections are brutal -</strong> Delete a tweet, unfollow someone, block an account, and the wrong data is already sitting in millions of timelines. Undoing a copy is far harder than making one.</p><h3>Option B: Pull (fan-out on read)</h3><p>Ram posts and the system saves one row, and that’s the end of it. Nothing gets prepared in advance, so the work happens later, when Sita opens the app. At that point the system has to find who Sita follows, fetch recent tweets from each of those accounts, then merge and sort the results.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ne77r52-GgxzKZVGTP3m3g.jpeg" /></figure><p>The upside is that posting is cheap, and it costs the same whether you have four followers or four million. Nothing is copied, so there’s no cache to keep in sync and no stale data to correct. Deletes and unfollows just work.</p><p>The downsides are the mirror image.</p><p><strong>Every read does real work -</strong> If Sita follows 500 people, that’s 500 index lookups to merge and sort, and she’ll do it ten times today along with everyone else. Given the 100:1 ratio from Section 2, this is the expensive side to be slow on.</p><p><strong>It gets worse as you grow, in two directions at once -</strong> More tweets in the table means each of those lookups scans more, and users following more accounts means more lookups per read. Push’s cost stays flat as your data grows. Pull’s doesn’t.</p><h4>Neither One Wins</h4><p>Side by side, they look like this:</p><ul><li><strong>Cost</strong> — Push is expensive to write and cheap to read. Pull is the exact reverse.</li><li><strong>Freshness and memory</strong> — Push lags slightly while copying, and needs a stored timeline for every user. Pull is always current and stores nothing extra.</li><li><strong>Where it falls apart</strong> — Push on accounts with millions of followers. Pull on users who follow a lot of busy accounts.</li></ul><p>That last line is the way through, and it’s what the next section is about.</p><h3>4. The Hybrid Model</h3><p>So we use both. Which raises the question I got stuck on for a while: which model goes where, and why that way round rather than the other?</p><h4>The Two Costs Point In Opposite Directions</h4><p>The answer comes from asking where each cost actually gets paid.</p><p><strong>Push costs one write per follower of whoever is posting.</strong> An account with 400 followers costs 400 writes per tweet.</p><p><strong>Pull costs one lookup per account the reader follows.</strong> Sita follows 500 accounts, so a single read costs her 500 lookups.</p><p>Those are two different directions through the follow graph, and the two numbers behave nothing alike. Followee counts have a natural ceiling, because a human has to sit there and tap follow, so almost nobody follows more than a few thousand accounts. Follower counts have no ceiling at all — one account can have eighty million.</p><p>So Pull’s worst case is bounded and Push’s isn’t. That single asymmetry decides the design: use Pull exactly where Push runs off the end of the scale.</p><h4>Which Gives You The Split</h4><p><strong>Normal accounts get Push — </strong>They have few followers, so copying is cheap. There are millions of them, so you’d rather pay that cost once when they post than on every read for the rest of time.</p><p><strong>Celebrity accounts get Pull — </strong>Copying their tweets is hopeless, and pulling them is cheap for a reason that’s easy to miss: Sita may follow 500 accounts, but only a handful of those are celebrities. Her read splits into 495 accounts already sitting in her cache, plus a live query against maybe five.</p><p>And it only works this way round. Flip it — push the celebrities, pull everyone else — and you get the worst of both: eighty million writes every time a celebrity posts, <em>and</em> a 495-account query on every single read. The split isn’t a compromise between the two models. It’s the one arrangement of them that works.</p><p>Reading now takes two steps instead of one:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*O36AchNqUncX8YZ7BqOIDw.jpeg" /></figure><ol><li>Read Sita’s ready-made timeline from the cache, which holds tweets from the normal accounts she follows.</li><li>Work out which of the accounts Sita follows are celebrities.</li><li>Fetch those celebrities’ recent tweets from the database.</li><li>Merge the two lists, sort by time, and return the top twenty.</li></ol><p>One trap here caught me out. The two halves can overlap, because an account that only just crossed into celebrity status still has older tweets sitting in people’s caches, so the same tweet arrives twice — once from the cache and once from the live query. My first version happily returned duplicates, and a test comparing the two endpoints was what caught it. So the merge needs to dedupe by tweet ID (<a href="https://github.com/i-am-chitti/twitter-feed-benchmark/blob/306602ffe56b85b0cd24a086901326f2776ee24a/routes/feed.js#L119-L127">nine lines, here</a>), or your readers see doubles.</p><h4>Where Does The Cutoff Come From?</h4><p>Most articles pick a number at this point. Ten thousand followers, or twenty-five thousand, or just “a lot”. Almost none of them explain where it came from.</p><p>You don’t have to guess though, because it’s a division:</p><pre>cutoff = how long fan-out may take ÷ cost per follower</pre><p>It starts with a budget. Suppose a tweet has to reach its followers within five seconds. You then measure what a single copy costs on your servers, and divide. Anything below the resulting line gets Push, anything above it gets Pull.</p><p>Both halves of that are yours to control. The budget is a product decision, and the cost per follower depends entirely on your hardware — which is why the answer has to be measured rather than looked up. <a href="https://medium.com/@i-am-chitti/i-built-a-twitter-style-feed-to-find-out-what-fan-out-actually-costs-80f7af9e2ccd">Part 2</a> measures it on one particular stack and shows how far the number moves between environments.</p><h3>5. Two Caches, Not One</h3><p>A tweet has text, an author, and maybe a link or an image attached. If you store all of that inside every follower’s timeline, you end up keeping millions of copies of the same thing, so the cache gets split in two.</p><p><strong>Tier one holds the timeline.</strong> One sorted set per user, named feed:{user_id}, where the score is the timestamp and the value is nothing more than the tweet ID. Because the score is a timestamp, the list keeps itself in newest-first order.</p><p><strong>Tier two holds the content.</strong> The tweet itself lives here, stored exactly once, under tweet:{tweet_id}.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*18evLwynYSPHT7mCKMjC7Q.jpeg" /></figure><p>Reading a page then means one range query for the IDs, followed by one bulk fetch for the text.</p><p>The timeline half is the expensive half, and the arithmetic behind it is simple:</p><pre>total memory = users × timeline length × bytes per entry</pre><p>Each of those three is a lever you can pull, and they pull with very different force.</p><ul><li><strong>Bytes per entry</strong> is why we store bare numbers and not full text.</li><li><strong>Timeline length</strong> is why you’d keep a few hundred entries per user instead of full history. A few hundred is the usual choice — 800 entries is about forty pages, which is a reasonable bet on how far anyone scrolls before giving up or switching to a profile view. Past that depth you fall back to querying the database.</li><li><strong>Users</strong> is the biggest lever, and it leads to a trick most large feeds rely on: don’t build timelines for people who aren’t there. You build them for whoever opened the app this month, and leave the rest to be built on demand.</li></ul><p>Section 6 covers that on-demand rebuild, and <a href="https://medium.com/@i-am-chitti/i-built-a-twitter-style-feed-to-find-out-what-fan-out-actually-costs-80f7af9e2ccd">Part 2</a> puts a number on what it saves.</p><h3>6. What Production Adds</h3><p>The design above works on paper. Four things are missing before it works in real life.</p><h4>Copying Must Happen In The Background</h4><p>The Push diagram is quietly misleading on one point. It implies Ram’s post request walks through all of his followers before returning, which for a large account isn’t a request at all — it’s a batch job holding a connection open for minutes. Real systems keep the posting path almost empty:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qhdWS9iKJuADjPRn4iG6Fg.jpeg" /></figure><p>You save the tweet, drop its ID onto a queue, and return success. A pool of workers drains the queue and updates timelines afterwards, so followers see the tweet a moment later — and that moment is what the budget in Section 4 pays for. Celebrity tweets get their own lower-priority queue, so one viral account can’t block everyone else.</p><h4>Timelines Must Be Rebuildable</h4><p>A cached timeline is a copy, built from the tweets and follows tables, and any copy needs a way back to its source. So think about what happens when feed:sita isn&#39;t there — Redis dropped it under memory pressure, or Sita hasn&#39;t logged in for a year, or you just added a cache node. The naive version finds nothing, returns an empty timeline, and reports success. Sita sees a blank screen and nothing looks broken.</p><p>So the fix is to rebuild on a miss. If the cache comes back empty, you fall back to Pull, write the result into the cache, then return it. Once that works, skipping inactive users costs you nothing, because they take the rebuild path on their next visit.</p><h4>Deleting Is Where Push Hurts</h4><p>Someone deletes a tweet that already reached 80 million timelines, and you aren’t going to run 80 million deletes. So you don’t. You mark it deleted in the database and filter it out when a page is read — the ID stays in the sorted sets and gets dropped once the text comes back missing. Unfollows and blocks work the same way.</p><p>So Push buys fast reads by making writes expensive, then hands some of that back as filtering on every read. A trade, not a free win.</p><h4>Paging Needs A Cursor</h4><p>LIMIT 20 OFFSET 200 makes the database walk past two hundred rows and throw them away, getting slower the deeper you scroll. The sorted set offers something better: pass the oldest timestamp you&#39;ve seen and ask for the next twenty, so every page costs the same however far you go. This is why the score is a timestamp and not a counter. Past a few hundred entries the timeline runs out and you fall back to Pull.</p><h3>7. But Is Any Of This Worth It?</h3><p>Everything above is the design as it’s usually taught, and I notice it asks you to take a lot on faith. Copying is expensive — how expensive? The cutoff is a division — divided by what? Push makes reads faster — by how much, and starting when?</p><p>I didn’t want to pick the cutoff from folklore, so I built both models over the same dataset and measured them. Three things came out of it.</p><p><strong>Fan-out cost turned out to be linear</strong>, at roughly 12 microseconds per follower on the hardware I used, which is what finally gave the cutoff formula a real number to work with.</p><p><strong>The memory bill was bigger than I expected.</strong> Around 80 bytes per timeline entry, which at a hundred million users is measured in terabytes — and that’s what turns “skip inactive users” from an optimisation into a requirement.</p><p><strong>And the read result went the wrong way.</strong> Push was supposed to win comfortably. At the scale I tested, the pre-computed timeline and the naive SQL query landed within about 1% of each other. Push only pulled ahead once the server was saturated, and even then the reason wasn’t what I assumed.</p><p>The setup, the numbers, what surprised me, and — importantly — what the benchmark can’t prove are all in Part 2: <a href="https://medium.com/@i-am-chitti/i-built-a-twitter-style-feed-to-find-out-what-fan-out-actually-costs-80f7af9e2ccd"><strong>I Built a Twitter-Style Feed to Find Out What Fan-Out Actually Costs</strong></a>.</p><h3>What To Take Away</h3><ul><li><strong>The lopsided data is the whole story.</strong> Every decision here — two strategies, the cutoff, the split cache, skipping dormant users — comes from the fact that follower counts are wildly unequal.</li><li><strong>Write amplification is the real limit, not write volume.</strong> 115 tweets a second is nothing, but one celebrity tweet is 80 million writes.</li><li><strong>Push and Pull fail at opposite ends of the follower range</strong>, which is exactly why a hybrid works: the case that breaks one is the rare case for the other.</li><li><strong>The cutoff is a division, not folklore</strong> — your fan-out budget divided by your measured cost per follower. Both numbers are yours to find out.</li><li><strong>Split the cache, and only build timelines for active users.</strong> Timeline structure is the expensive half, and skipping dormant accounts changes the memory bill by an order of magnitude.</li><li><strong>Build it when your own numbers say to.</strong> Measuring your own fan-out cost takes an afternoon, and it decides the question better than any article can.</li></ul><h3>Further Reading</h3><ul><li><a href="https://redis.io/docs/latest/develop/data-types/sorted-sets/">Redis sorted sets</a> — the structure the whole timeline design rests on, and worth reading properly if you’ve only used them for leaderboards.</li><li><a href="https://redis.io/docs/latest/commands/zrange/">ZRANGE</a> — the actual complexity of a range read, for anyone who wants to check my arithmetic.</li><li><a href="https://www.infoq.com/presentations/Twitter-Timeline-Scalability/">Timelines at Scale by Raffi Krikorian</a> — Twitter’s own engineering talk on how they ran fan-out. The closest thing to a primary source on the real architecture, as opposed to the reconstructions that circulate in interview prep.</li><li><a href="https://medium.com/@i-am-chitti/i-built-a-twitter-style-feed-to-find-out-what-fan-out-actually-costs-80f7af9e2ccd">Part 2 of this pair</a> — the measurements, and where they contradict what I expected.</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=11c6b3b52a2f" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Enterprise Gutenberg Blocks: A TypeScript-First Architecture for WordPress at Scale]]></title>
            <link>https://medium.com/@i-am-chitti/enterprise-gutenberg-blocks-a-typescript-first-architecture-for-wordpress-at-scale-67af6c3c32c4?source=rss-08cb4a84e71e------2</link>
            <guid isPermaLink="false">https://medium.com/p/67af6c3c32c4</guid>
            <category><![CDATA[wordpress-themes]]></category>
            <category><![CDATA[gutenberg-editor]]></category>
            <category><![CDATA[wordpress]]></category>
            <category><![CDATA[wordpress-plugins]]></category>
            <category><![CDATA[typescript]]></category>
            <dc:creator><![CDATA[Deepak Kumar]]></dc:creator>
            <pubDate>Sun, 26 Jul 2026 11:30:20 GMT</pubDate>
            <atom:updated>2026-07-26T11:30:20.139Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WBIWSmvDyMWS7Kq9jUPulw.jpeg" /></figure><h3>🏗️ Table of Contents</h3><ol><li><strong>Introduction</strong> — The problem with scaling Gutenberg blocks</li><li><strong>Anatomy of a Gutenberg Block</strong> — Directory structure &amp; Static vs. Dynamic blocks</li><li><strong>Comparing Setup Architectures</strong> — Standard wp-scripts vs. Enterprise Webpack</li><li><strong>Step-by-Step Block Implementation: Testimonials Slider</strong></li><li><strong>The PHP Counterpart &amp; Inline Schema Injection</strong></li><li><strong>Styling Architecture: Modular SCSS with Editor Context</strong></li><li><strong>Plugin Bootstrap: Enqueuing Compiled Bundles</strong></li><li><strong>Key Takeaways</strong></li></ol><h3>1. Introduction</h3><p>We had 40+ custom blocks spread across an enterprise WordPress theme. A single attribute rename broke 12 blocks silently — no compile errors, no warnings. The editor loaded fine but the frontend rendered empty divs. The root cause? No type system. Every block attribute was a loose Record&lt;string, any&gt;.</p><p>This guide documents the architecture we adopted to prevent this:</p><ul><li><strong>TypeScript</strong> for compile-time attribute safety and IDE autocomplete across all @wordpress/* packages.</li><li><strong>A custom Webpack pipeline</strong> that compiles dozens of blocks into a single editor bundle instead of producing 40+ separate scripts.</li><li><strong>A modular SCSS system</strong> with context-aware compilation that shares styles between the frontend and the Gutenberg editor iframe.</li></ul><p>We’ll walk through building a <strong>dynamic Testimonials Slider</strong> block end-to-end — from block.json to PHP rendering with inline Schema.org injection.</p><blockquote><em>📦 The complete working plugin is available on GitHub: </em><a href="https://github.com/i-am-chitti/type-safe-blocks"><strong><em>github.com/i-am-chitti/type-safe-blocks</em></strong></a></blockquote><h3>2. Anatomy of a Gutenberg Block</h3><h3>The Directory Structure</h3><p>A clean, modular block folder typically contains these core files:</p><pre>src/editor/blocks/testimonials-slider/<br>├── block.json      # Metadata, settings, and attribute schemas<br>├── types.ts        # Shared TypeScript interfaces<br>├── index.ts        # Registers block settings locally<br>├── edit.tsx        # React interface in the WordPress editor<br>├── save.tsx        # Defines the saved post output (or dynamic placeholder)<br>├── style.scss      # Frontend layout styles<br>├── editor.scss     # Editor-specific style overrides<br>└── index.php       # PHP backend renderer (Server-side rendering &amp; bootstrap)</pre><p>Depending on how these files are configured, WordPress splits blocks into two categories: <strong>Static</strong> and <strong>Dynamic</strong>.</p><h3>Static vs. Dynamic Blocks</h3><h4>Static Blocks</h4><ul><li><strong>Output Source:</strong> Generated by JavaScript in the editor and saved directly as static HTML inside the post content in the database.</li><li><strong>PHP Counterpart:</strong> Needs index.php only to register block metadata on the server. No custom render callback is required.</li><li><strong>Ideal For:</strong> Content-heavy structures that rarely change (e.g. Callouts, Cards, Buttons).</li><li><strong>Schema Changes:</strong> If the HTML layout is updated in block code, existing posts in the database will trigger Gutenberg validation errors when loaded in the editor.</li></ul><p>In save.tsx, a static block generates the final markup. Notice how we type the props with BlockSaveProps&lt;BlockAttributes&gt; for compile-time safety:</p><pre>export default function Save(props: BlockSaveProps&lt;BlockAttributes&gt;) {<br>  const { title, content, buttonText, buttonUrl } = props.attributes;<br>  const blockProps = useBlockProps.save({ className: &#39;promo-card-output&#39; });<br>  return (<br>    &lt;div {...blockProps}&gt;<br>      &lt;RichText.Content tagName=&quot;h2&quot; value={title} /&gt;<br>      &lt;RichText.Content tagName=&quot;p&quot; value={content} /&gt;<br>      {buttonText &amp;&amp; buttonUrl &amp;&amp; (<br>        &lt;a href={buttonUrl} className=&quot;promo-card-button&quot;&gt;{buttonText}&lt;/a&gt;<br>      )}<br>    &lt;/div&gt;<br>  );<br>}</pre><h4>Dynamic Blocks</h4><ul><li><strong>Output Source:</strong> Saved as raw attributes only. The final HTML is compiled on the server side by PHP at runtime.</li><li><strong>PHP Counterpart:</strong> Requires a custom render_callback function inside index.php to generate the HTML.</li><li><strong>Ideal For:</strong> Layouts showing dynamic data (e.g., Latest Posts, Product Sliders, Review feeds).</li><li><strong>Schema Changes:</strong> Layout code updates render instantly across all pages without triggering validation errors.</li></ul><p>In save.tsx, a dynamic block simply returns null — no markup is stored:</p><pre>export default function Save() {<br>  return null;<br>}</pre><h3>3. Comparing Setup Architectures</h3><h3>Approach A: The @wordpress/scripts Setup (Best for Plugins)</h3><p>The standard WordPress build system (@wordpress/scripts) is a zero-configuration CLI wrapper.</p><ul><li><strong>How it works:</strong> It expects an entrypoint for each block and compiles them separately, producing individual .js, .css, and .asset.php files per block.</li><li><strong>Pros:</strong> Quick setup, excellent for isolated blocks in a plugin.</li><li><strong>Cons:</strong> Bulky for large codebases.</li></ul><h4>Why @wordpress/scripts Should Be Avoided in Larger Codebases:</h4><ol><li><strong>Asset Inflation:</strong> 40 blocks = 40 JS files + 40 CSS files + 40 .asset.php files. Enqueuing these individually triggers dozens of HTTP requests and disk reads.</li><li><strong>Code Duplication:</strong> Shared code (components, utilities) is compiled into <em>every</em> block’s bundle. No chunk splitting.</li><li><strong>Rigid Configuration:</strong> Extending the internal Webpack/Babel/PostCSS config is fragile. No native support for path aliasing or custom TypeScript configs.</li><li><strong>Degraded DX:</strong> Watching dozens of independent entry points concurrently is CPU-intensive with slow rebuilds.</li></ol><h3>Approach B: Custom Webpack Bundling (Best for Enterprise Setups)</h3><p>For enterprise setups, compiling everything into unified bundles is far more efficient.</p><ul><li><strong>How it works:</strong> Webpack compiles block scripts into a single dist/editor.js, and compiles styles into two distinct output stylesheets: dist/style.css (for shared frontend and editor layout styling) and dist/editor-styles.css (for editor-only overrides like form containers and toggle states).</li><li><strong>The iFrame Isolation Caveat:</strong> <a href="https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/#version-3-wordpress-6-3">Modern WordPress (6.3+)</a> renders the editor canvas inside an isolated &lt;iframe&gt;. Traditional admin enqueues (like wp_enqueue_style hooked to admin_enqueue_scripts) load CSS on the parent page, not inside the iframe. However, enqueue_block_assets does run inside the iframe context, making it the correct hook for plugins. For themes, add_editor_style() is the standard approach.</li><li><strong>Conditional Sass Compilation:</strong> By configuring sass-loader with additionalData, we dynamically inject a $is-editor context variable. This lets blocks share a single styling system while compiling editor-specific rules conditionally.</li></ul><p>The key parts of our Webpack config:</p><pre>entry: {<br>  editor: &#39;./src/editor/index.ts&#39;,            // Single editor JS bundle<br>  style: &#39;./src/style.scss&#39;,                  // Frontend CSS<br>  &#39;editor-styles&#39;: &#39;./src/editor/editor.scss&#39;, // Editor-only CSS<br>},<br>plugins: [<br>  new RemoveEmptyScriptsPlugin(),  // Prevents empty .js for CSS-only entries<br>  new MiniCssExtractPlugin({ filename: &#39;[name].css&#39; }),<br>  new DependencyExtractionWebpackPlugin(),<br>],</pre><p>The $is-editor injection happens inside the sass-loader options:</p><pre>{<br>  loader: &#39;sass-loader&#39;,<br>  options: {<br>    additionalData: (content, loaderContext) =&gt; {<br>      const isEditor = loaderContext.resourcePath.includes(&#39;src/editor&#39;);<br>      return `$is-editor: ${isEditor};\n` + content;<br>    }<br>  }<br>}</pre><blockquote><em>See the full Webpack configuration → </em><a href="https://github.com/i-am-chitti/type-safe-blocks/blob/main/webpack.config.js"><strong><em>webpack.config.js on GitHub</em></strong></a></blockquote><h3>4. Step-by-Step Block Implementation: Testimonials Slider</h3><p>Let’s build our type-safe dynamic <strong>Testimonials Slider</strong> block. Managing testimonials as a structured array of objects (rather than loose child blocks) prevents authors from accidentally breaking the slider’s layout.</p><h3>Step 4.1: Defining Metadata (block.json)</h3><p>We define a testimonials attribute of type array, plus an isPreview boolean that controls the editor&#39;s preview/edit toggle (defaulting to true so the block loads in preview mode):</p><pre>{<br>  &quot;$schema&quot;: &quot;https://schemas.wp.org/trunk/block.json&quot;,<br>  &quot;apiVersion&quot;: 3,<br>  &quot;name&quot;: &quot;custom/testimonials-slider&quot;,<br>  &quot;title&quot;: &quot;Testimonials Slider&quot;,<br>  &quot;category&quot;: &quot;theme&quot;,<br>  &quot;icon&quot;: &quot;format-quote&quot;,<br>  &quot;attributes&quot;: {<br>    &quot;testimonials&quot;: {<br>      &quot;type&quot;: &quot;array&quot;,<br>      &quot;default&quot;: []<br>    },<br>    &quot;autoPlay&quot;: {<br>      &quot;type&quot;: &quot;boolean&quot;,<br>      &quot;default&quot;: false<br>    },<br>    &quot;isPreview&quot;: {<br>      &quot;type&quot;: &quot;boolean&quot;,<br>      &quot;default&quot;: true<br>    }<br>  },<br>  &quot;supports&quot;: {<br>    &quot;anchor&quot;: true<br>  }<br>}</pre><h3>Step 4.2: Defining TypeScript Interfaces (types.ts)</h3><p>To compile type-safely in WordPress, we must add an index signature ([key: string]: any;) to the block attributes type. This satisfies Gutenberg&#39;s internal Record&lt;string, unknown&gt; constraint:</p><pre>export interface TestimonialItem {<br>  name: string;<br>  role: string;<br>  quote: string;<br>  rating: number;<br>}<br><br>export type BlockAttributes = {<br>  testimonials: TestimonialItem[];<br>  autoPlay: boolean;<br>  isPreview: boolean;<br>  anchor?: string;<br>  [key: string]: any;<br>};</pre><h3>Step 4.3: Building the Editor UI (edit.tsx)</h3><p>The editor implements a <strong>Preview/Edit toggle</strong> pattern. By default (isPreview: true), the block renders a live preview matching the frontend. Toggling the sidebar switch reveals a spacious data-entry form.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TF1aSos9UV7nnAReDPt0Rw.png" /><figcaption>Preview mode in editor</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aX3SdMMplN7twy5LpiKT2Q.png" /><figcaption>Edit mode</figcaption></figure><p>The sidebar configuration — note how both the preview toggle and autoplay setting live in InspectorControls:</p><pre>&lt;InspectorControls&gt;<br>  &lt;PanelBody title=&quot;Testimonials Configuration&quot;&gt;<br>    &lt;ToggleControl<br>      label=&quot;Preview Mode&quot;<br>      help=&quot;Toggle between editing and viewing the live frontend design.&quot;<br>      checked={isPreview}<br>      onChange={(val) =&gt; setAttributes({ isPreview: val })}<br>    /&gt;<br>    &lt;ToggleControl<br>      label=&quot;AutoPlay Slides&quot;<br>      checked={autoPlay}<br>      onChange={(val) =&gt; setAttributes({ autoPlay: val })}<br>    /&gt;<br>  &lt;/PanelBody&gt;<br>&lt;/InspectorControls&gt;</pre><p>The preview branch renders with the same CSS classes as the PHP frontend output, so the editor is a true WYSIWYG representation:</p><pre>{isPreview ? (<br>  &lt;div className=&quot;testimonials-slider-container&quot;&gt;<br>    &lt;div className=&quot;testimonials-track&quot;&gt;<br>      {testimonials.map((item, index) =&gt; (<br>        &lt;div key={index} className=&quot;testimonial-card&quot;&gt;<br>          &lt;blockquote className=&quot;testimonial-quote&quot;&gt;<br>            &quot;{item.quote || &#39;Quote text...&#39;}&quot;<br>          &lt;/blockquote&gt;<br>          &lt;div className=&quot;testimonial-meta&quot;&gt;<br>            &lt;strong className=&quot;testimonial-name&quot;&gt;{item.name || &#39;Author Name&#39;}&lt;/strong&gt;<br>            &lt;span className=&quot;testimonial-role&quot;&gt; - {item.role || &#39;Position&#39;}&lt;/span&gt;<br>          &lt;/div&gt;<br>          &lt;div className=&quot;testimonial-rating&quot;&gt;<br>            {&#39;★&#39;.repeat(item.rating || 5)}<br>          &lt;/div&gt;<br>        &lt;/div&gt;<br>      ))}<br>    &lt;/div&gt;<br>  &lt;/div&gt;<br>) : (<br>  /* Edit form with TextControl, TextareaControl, RangeControl per item */<br>)}</pre><p>The immutable update pattern for array items uses keyof TestimonialItem to prevent typos at compile-time:</p><pre>const updateTestimonial = (index: number, key: keyof TestimonialItem, value: string | number) =&gt; {<br>  const updated = [...testimonials];<br>  updated[index] = { ...updated[index], [key]: value };<br>  setAttributes({ testimonials: updated });<br>};</pre><blockquote><em>See the full edit.tsx with both preview and edit branches → </em><a href="https://github.com/i-am-chitti/type-safe-blocks/blob/main/src/editor/blocks/testimonials-slider/edit.tsx"><strong><em>edit.tsx on GitHub</em></strong></a></blockquote><h3>Step 4.4: Defining the Save Placeholder (save.tsx)</h3><p>Because this block’s markup is rendered dynamically by PHP, we return null:</p><pre>export default function Save() {<br>  return null;<br>}</pre><h3>Step 4.5: Registering the Block (index.ts)</h3><p>To make registration type-safe, explicitly parameterize BlockConfiguration&lt;BlockAttributes&gt; and registerBlockType&lt;BlockAttributes&gt;:</p><pre>import { BlockConfiguration, registerBlockType } from &#39;@wordpress/blocks&#39;;<br>import edit from &#39;./edit&#39;;<br>import save from &#39;./save&#39;;<br>import metadata from &#39;./block.json&#39;;<br>import { BlockAttributes } from &#39;./types&#39;;<br><br>export const { name }: { name: string } = metadata;<br><br>export const settings: BlockConfiguration&lt;BlockAttributes&gt; = {<br>  ...metadata,<br>  edit,<br>  save,<br>};<br><br>export const init = (): void =&gt; {<br>  registerBlockType&lt;BlockAttributes&gt;(name, settings);<br>};</pre><h3>5. The PHP Counterpart &amp; Inline Schema Injection</h3><p>The PHP file bootstraps the block and handles server-side rendering. The key design decisions:</p><ol><li><strong>register_block_type_from_metadata</strong> reads block.json directly — no manual attribute re-declaration.</li><li><strong>Schema.org JSON-LD</strong> is injected inline inside the block’s HTML, not via wp_head. Since the render_callback fires during the_content (after wp_head has already executed), hooking into wp_head from inside render() would silently fail.</li></ol><p>The bootstrap and schema compilation:</p><pre>namespace CustomTheme\Blocks\TestimonialsSlider;<br><br>function bootstrap(): void {<br>  register_block_type_from_metadata(<br>    __DIR__,<br>    [ &#39;render_callback&#39; =&gt; __NAMESPACE__ . &#39;\\render&#39; ]<br>  );<br>}<br>add_action( &#39;init&#39;, __NAMESPACE__ . &#39;\\bootstrap&#39; );</pre><p>Inside the render callback, we compile the JSON-LD schema from testimonial data and inject it inline:</p><pre>$schema_html = &#39;&#39;;<br>if ( ! empty( $schema_reviews ) ) {<br>  $schema = [<br>    &#39;@context&#39; =&gt; &#39;https://schema.org&#39;,<br>    &#39;@type&#39;    =&gt; &#39;Product&#39;,<br>    &#39;name&#39;     =&gt; get_the_title() . &#39; Testimonials&#39;,<br>    &#39;review&#39;   =&gt; $schema_reviews,<br>  ];<br>  $schema_html = &#39;&lt;script type=&quot;application/ld+json&quot;&gt;&#39; . wp_json_encode( $schema ) . &#39;&lt;/script&gt;&#39;;<br>}</pre><p>The HTML output uses the same CSS classes as the editor preview, keeping frontend and editor visually in sync:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eT37d8cXNiQUtvrwEMUm_A.png" /><figcaption>frontend</figcaption></figure><pre>&lt;div class=&quot;testimonials-slider-container&quot;&gt;<br>  &lt;?php echo $schema_html; ?&gt;<br>  &lt;div class=&quot;testimonials-track&quot;&gt;<br>    &lt;?php foreach ( $testimonials as $item ) : ?&gt;<br>      &lt;div class=&quot;testimonial-card&quot;&gt;<br>        &lt;blockquote class=&quot;testimonial-quote&quot;&gt;&quot;&lt;?php echo esc_html( $item[&#39;quote&#39;] ); ?&gt;&quot;&lt;/blockquote&gt;<br>        &lt;div class=&quot;testimonial-meta&quot;&gt;<br>          &lt;strong class=&quot;testimonial-name&quot;&gt;&lt;?php echo esc_html( $item[&#39;name&#39;] ); ?&gt;&lt;/strong&gt;<br>          &lt;span class=&quot;testimonial-role&quot;&gt; - &lt;?php echo esc_html( $item[&#39;role&#39;] ); ?&gt;&lt;/span&gt;<br>        &lt;/div&gt;<br>        &lt;div class=&quot;testimonial-rating&quot;&gt;&lt;?php echo esc_html( str_repeat( &#39;★&#39;, $item[&#39;rating&#39;] ) ); ?&gt;&lt;/div&gt;<br>      &lt;/div&gt;<br>    &lt;?php endforeach; ?&gt;<br>  &lt;/div&gt;<br>&lt;/div&gt;</pre><blockquote><em>See the full index.php → </em><a href="https://github.com/i-am-chitti/type-safe-blocks/blob/main/src/editor/blocks/testimonials-slider/index.php"><strong><em>index.php on GitHub</em></strong></a></blockquote><h3>6. Styling Architecture: Modular SCSS with Editor Context</h3><p>Each block has two SCSS files:</p><ul><li><strong>style.scss</strong> — Layout styles shared between the frontend and the editor canvas.</li><li><strong>editor.scss</strong> — Editor-only overrides (edit form borders, mode badges, etc.).</li></ul><p>These are imported into two global entrypoints (src/style.scss and src/editor/editor.scss) that Webpack compiles into dist/style.css and dist/editor-styles.css.</p><p>The $is-editor variable (injected by Webpack&#39;s sass-loader based on the file path) enables conditional compilation:</p><pre>// style.scss — shared layout<br>.testimonials-slider-container {<br>  width: 100%;<br>  .testimonials-track {<br>    display: flex;<br>    flex-direction: column;<br>    gap: 15px;<br>  }<br>  .testimonial-card {<br>    width: 100%;<br>    border: 1px solid #ddd;<br>    padding: 15px;<br>    border-radius: 5px;<br>    background: #fff;<br>  }<br>  .testimonial-rating {<br>    color: #ffb400;<br>    margin-top: 5px;<br>  }<br>}</pre><pre>// editor.scss — editor-specific overrides<br>.testimonials-slider-editor {<br>  .testimonials-edit-form .form-item {<br>    border: 1px solid #e0e0e0;<br>    padding: 20px;<br>    margin-bottom: 15px;<br>    border-radius: 6px;<br>    background: #fafafa;<br>  }<br>  .form-item-header {<br>    display: flex;<br>    justify-content: space-between;<br>    align-items: center;<br>    margin-bottom: 15px;<br>  }<br>}</pre><blockquote><em>See the full SCSS files → </em><a href="https://github.com/i-am-chitti/type-safe-blocks/blob/main/src/editor/blocks/testimonials-slider/style.scss"><strong><em>style.scss</em></strong></a><em> | </em><a href="https://github.com/i-am-chitti/type-safe-blocks/blob/main/src/editor/blocks/testimonials-slider/editor.scss"><strong><em>editor.scss</em></strong></a></blockquote><h3>7. Plugin Bootstrap: Enqueuing Compiled Bundles</h3><p>The main plugin file (type-safe-blocks.php) ties everything together. It loads each block&#39;s PHP bootstrap, enqueues the compiled CSS bundles, and registers the editor script.</p><pre>namespace TypeSafeBlocks;<br><br>// Load PHP counterparts for each block<br>require_once __DIR__ . &#39;/src/editor/blocks/testimonials-slider/index.php&#39;;<br>require_once __DIR__ . &#39;/src/editor/blocks/promo-card/index.php&#39;;</pre><p>Frontend and editor stylesheets are enqueued via enqueue_block_assets — the only hook that runs inside both the frontend and the Gutenberg editor iframe:</p><pre>function enqueue_global_block_assets() {<br>  wp_enqueue_style(<br>    &#39;type-safe-blocks-frontend&#39;,<br>    plugins_url(https://medium.com/&#39;dist/style.css&#39;,%20__FILE__),<br>    [],<br>    filemtime( __DIR__ . &#39;/dist/style.css&#39; )<br>  );<br><br>  // Editor-only overrides — only loaded in the admin iframe<br>  if ( is_admin() ) {<br>    wp_enqueue_style(<br>      &#39;type-safe-blocks-editor-overrides&#39;,<br>      plugins_url(https://medium.com/&#39;dist/editor-styles.css&#39;,%20__FILE__),<br>      [],<br>      filemtime( __DIR__ . &#39;/dist/editor-styles.css&#39; )<br>    );<br>  }<br>}<br>add_action( &#39;enqueue_block_assets&#39;, __NAMESPACE__ . &#39;\\enqueue_global_block_assets&#39; );</pre><p>The unified editor script is loaded only in the block editor, with dependencies auto-extracted from the .asset.php manifest:</p><pre>function enqueue_editor_assets() {<br>  $asset_file = require __DIR__ . &#39;/dist/editor.asset.php&#39;;<br>  wp_enqueue_script(<br>    &#39;type-safe-blocks-editor&#39;,<br>    plugins_url(https://medium.com/&#39;dist/editor.js&#39;,%20__FILE__),<br>    $asset_file[&#39;dependencies&#39;] ?? [],<br>    $asset_file[&#39;version&#39;] ?? &#39;1.0.0&#39;,<br>    true<br>  );<br>}<br>add_action( &#39;enqueue_block_editor_assets&#39;, __NAMESPACE__ . &#39;\\enqueue_editor_assets&#39; );</pre><blockquote><em>See the full plugin bootstrap → </em><a href="https://github.com/i-am-chitti/type-safe-blocks/blob/main/type-safe-blocks.php"><strong><em>type-safe-blocks.php on GitHub</em></strong></a></blockquote><h3>8. Key Takeaways</h3><ul><li><strong>Choose the right builder.</strong> Use @wordpress/scripts for standalone plugins with a few blocks. Write a custom Webpack config when you need unified bundles, SCSS compilation, and fine-grained TypeScript control.</li><li><strong>Type your block attributes end-to-end.</strong> Define interfaces in types.ts, parameterize BlockEditProps&lt;T&gt;, BlockSaveProps&lt;T&gt;, and registerBlockType&lt;T&gt;. A single [key: string]: any index signature satisfies Gutenberg&#39;s internal type constraints.</li><li><strong>Static blocks save HTML, dynamic blocks save nothing.</strong> Static blocks commit markup to the database via save.tsx. Dynamic blocks return null and rely on PHP&#39;s render_callback. Choose dynamic when your layout depends on runtime data or when you need to inject structured schema.</li><li><strong>The editor is an iframe.</strong> Styles enqueued via admin_enqueue_scripts load on the admin wrapper, not inside the editor canvas. Use enqueue_block_assets for plugins or add_editor_style() for themes.</li><li><strong>Inject Schema.org inline, not via </strong><strong>wp_head.</strong> The block&#39;s render_callback fires during the_content — after wp_head has already executed. Inline &lt;script type=&quot;application/ld+json&quot;&gt; is fully supported by Google&#39;s crawler and avoids the silent lifecycle mismatch.</li><li><strong>Share styles, split concerns.</strong> Use a shared style.scss for layout and a separate editor.scss for editor-only overrides. Webpack&#39;s sass-loader can inject context variables ($is-editor) dynamically based on file paths, enabling conditional compilation from a single codebase.</li></ul><h3>📦 Full Source Code</h3><p>The complete working plugin — with both blocks (Promo Card + Testimonials Slider), the custom Webpack pipeline, modular SCSS architecture, and inline Schema.org injection — is available on GitHub:</p><p><strong>→ </strong><a href="https://github.com/i-am-chitti/type-safe-blocks"><strong>github.com/i-am-chitti/type-safe-blocks</strong></a></p><p>Clone it, run npm install &amp;&amp; npm run build, and activate the plugin in any WordPress 6.3+ installation.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=67af6c3c32c4" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Software Architectural Patterns]]></title>
            <link>https://medium.com/@i-am-chitti/software-architectural-patterns-a99cbde19027?source=rss-08cb4a84e71e------2</link>
            <guid isPermaLink="false">https://medium.com/p/a99cbde19027</guid>
            <category><![CDATA[peer-to-peer]]></category>
            <category><![CDATA[software-design-patterns]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[event-driven-architecture]]></category>
            <category><![CDATA[microservices]]></category>
            <dc:creator><![CDATA[Deepak Kumar]]></dc:creator>
            <pubDate>Sat, 02 Aug 2025 11:07:55 GMT</pubDate>
            <atom:updated>2025-08-02T11:07:55.210Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*7gM2aTTacV6_gTao.png" /></figure><p>Architectural patterns in software are high-level strategies that define the structure and organization of a system. They serve as blueprints for designing software systems that are scalable, maintainable, and robust. Not all patterns have everything equally. Here are some of the most common architectural patterns:</p><h3>Layered Architecture</h3><p>Also called <strong>n-tier architecture.</strong></p><p>Organizes software into layers with distinct responsibilities (e.g., presentation, business logic, data access). Each layer has a job, no mixing and it’s clean.</p><p><strong>Use-cases:</strong> simple CRUD app</p><p>✅ Keeps your code as organized as your kitchen drawers after a weekend cleaning spree.</p><p>🔴 Eventually, every bug gets stuck in the layer you swore you weren’t responsible for.</p><h3>Client Server Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*VC7CUaE2yDez-FI_.png" /></figure><p>Splits the system into two parts: client (frontend) and server (backend). It’s the best friend of a web developer.</p><p><strong>Use-cases: </strong>web applications, networked systems</p><p>✅ A clean breakup: one does the thinking, the other just looks pretty.</p><p>🔴 If the server goes down, the client is basically a confused toddler staring at a blank wall</p><h3>Monolithic Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*uwgYbRrQ5lMN0zBR.png" /></figure><p>Classic all-rounder software pattern — everything together. The UI, business logic, database calls, caching, etc. All components are interconnected and interdependent.</p><p><strong>Use-cases: </strong>Simple applications</p><p>✅ One repo to rule them all</p><p>🔴 Deploying it in enterprise is like trying to move a mountain… with a toothpick.</p><h3>Microservice Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/987/0*jCCEp8uSiU5FLj_G.png" /></figure><p>Divides an application into small, tiny, loosely coupled services that communicate over APIs. Each service does its best job. It’s highly scalable and teams can independently work.</p><p><strong>Use-cases: </strong>Large scale app needing high availability, independent deployments.</p><p>✅ Divide and conquer — every team gets their own mess to clean!</p><p>🔴 I love microservices, they’re like a thousand tiny bugs instead of one big one.</p><h3>Event Driven Architecture</h3><p>Systems use events to trigger and communicate between decoupled components. We have producers (who fires event), consumers (who react) and an event broker (central intermediary).</p><p><strong>Use-cases: </strong>Real-time apps, asynchronous processing</p><p>✅ Like a surprise birthday party — everything responds to something unexpected!</p><p>🔴 Eventually, no one knows who’s throwing the party or why it’s on fire.</p><h3>Service Oriented Architecture (SOA)</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*k75taY4wz8-EoppL.jpg" /></figure><p>Emphasizes building systems from independent, reusable services. Communication between services is facilitated by <strong>ESB (enterprise service bus)</strong>. ESB is a centralized service that routes service requests to the appropriate service. It also transforms the request into a format that is acceptable for the service’s underlying platform and programing language.</p><p><strong>Use-cases: </strong>Integration between legacy systems</p><p>✅ Enterprise therapy: teaching old systems to talk to each other again.</p><p>🔴 Spend more time configuring the message bus than coding the actual app.</p><h3>Model-View-Controller (MVC)</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*IlUfnQ06yA9THfGM.png" /></figure><p>Separates application into three components: Model (data), View (UI), and Controller (logic).</p><p>Use-cases: Web and desktop applications.</p><p>✅ Separates concerns like a good therapist.</p><p>🔴 Eventually, you’ll need a controller just to manage your other controllers.</p><h3>Peer-to-Peer (P2P) Architecture</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*iGQIS4DsAilG60AV.png" /></figure><p>All nodes have equal responsibility and can act as both clients and servers. This design allows the direct sharing of resources, be it bandwidth, storage, or processing power, creating cooperative networking and efficient resource discovery without relying on a central server.</p><p><strong>Use-cases:</strong> File sharing, blockchain, decentralized applications.</p><p>✅ Everyone’s equal — communism for computers!</p><p>🔴 Unless one peer forgets to share… then it’s just chaos.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a99cbde19027" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Caching Proxy Server in Node]]></title>
            <link>https://medium.com/@i-am-chitti/from-scratch-build-your-own-caching-proxy-server-c9fde6158037?source=rss-08cb4a84e71e------2</link>
            <guid isPermaLink="false">https://medium.com/p/c9fde6158037</guid>
            <category><![CDATA[servers]]></category>
            <category><![CDATA[proxy]]></category>
            <category><![CDATA[cli]]></category>
            <category><![CDATA[nodejs]]></category>
            <dc:creator><![CDATA[Deepak Kumar]]></dc:creator>
            <pubDate>Sat, 16 Nov 2024 11:40:12 GMT</pubDate>
            <atom:updated>2024-11-19T18:12:18.674Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/500/1*Em5RjOjz5dxqFtASpVliaQ.jpeg" /></figure><p>A caching proxy server is a type of intermediary server that forwards client requests to the actual server while storing (or caching) the responses. When the same request is made again, the cached response is returned instead of forwarding the request to the server, significantly reducing latency and improving performance. This concept, often referred to as a ‘web accelerator,’ helps speed up website access, reduce server load, and enhance user experience.</p><p>In this blog, we’ll build a CLI tool to implement a caching proxy server step by step inspired by <a href="https://roadmap.sh/projects/caching-server">roadmap.sh</a> project. This tool will act as a gateway between clients and servers, showcasing how caching mechanisms can optimize web applications.</p><p>GitHub Repo — <a href="https://github.com/i-am-chitti/caching-proxy-server">https://github.com/i-am-chitti/caching-proxy-server</a></p><h4>Table of Contents</h4><ul><li><a href="#7032">Requirements</a></li><li><a href="#4814">Basic Setup</a></li><li><a href="#8805">Cache Preparation</a></li><li><a href="#5700">File Lookup Challenge</a></li><li><a href="#c4e4">Serve Cache</a></li><li><a href="#9599">Cache Purge</a></li></ul><h3>Requirements</h3><ol><li>First request should hit the origin server. Receive the response and cache locally. Return the response with X-CACHE:MISS header.</li><li>Subsequent request should be retrieved from the local cache and served with header X-CACHE:HIT header.</li></ol><p>We’ll develop this project in Node.js. Read how to build a CLI app with Node.js <a href="https://medium.com/@deepak.kumar852182/building-a-cli-app-in-node-js-5ac6fd88f82b">here</a>.</p><h3>Basic Setup</h3><p>Create a directory and initialize project —</p><pre>mkdir caching-proxy<br>cd caching-proxy<br>npm init<br>npm install express commander</pre><p>Create a index.js file. Register the CLI command —</p><pre>#!/usr/bin/env node<br><br>import { program } from &quot;commander&quot;;<br><br>program.version(&#39;1.0.0&#39;).description(&#39;A caching proxy server&#39;);<br><br>// Add command to start proxy server.<br>program.command(&#39;start&#39;).description(&#39;Start the proxy server&#39;)<br>    .requiredOption(&#39;-p, --port &lt;port&gt;&#39;, &#39;Port to run the server on&#39;)<br>    .requiredOption(&#39;-o, --origin &lt;origin&gt;&#39;, &#39;Origin server to which requests will be forwarded to&#39; ).action((options) =&gt; {<br>        // Validate the port number<br>        const port = parseInt(options.port);<br>        if (isNaN(port) || port &lt; 1 || port &gt; 65535) {<br>            console.error(&#39;Invalid port number&#39;);<br>            return;<br>        }<br><br>        // Validate the origin URL<br>        try {<br>            new URL(options.origin);<br>        } catch (error) {<br>            console.error(&#39;Invalid origin URL&#39;);<br>            return;<br>        }<br><br>        // @todo startServer(options);<br>    });<br><br>program.parse(process.argv);</pre><p>It contains basic validation of port number and URL address. We will soon define the startServer function.</p><p>Link the project -</p><pre>npm link</pre><p>To run the command, open terminal and do -</p><pre>caching-proxy start -p 3000 -o https://example.com</pre><p>Our next step is to hit the origin sever and prepare the cache.</p><h3>Cache Preparation</h3><p>Create a server.js file where our startServer code would reside. Using express js, start the server -</p><pre>import fs from &quot;fs&quot;;<br>import path from &quot;path&quot;;<br>import express from &quot;express&quot;;<br><br>const app = express();<br><br>const startServer = async (options) =&gt; {<br>    // Check if the port is present.<br>    if ( ! options.port ) {<br>        console.error(&#39;Port is required&#39;);<br>        return;<br>    }<br><br>    // Check if the origin is present.<br>    if ( ! options.origin ) {<br>        console.error(&#39;Origin is required&#39;);<br>        return;<br>    }<br><br>    app.listen(options.port, () =&gt; {<br>        console.log(`Server is running on port ${options.port}`);<br>    });<br><br>    app.get(&quot;*&quot;, async (req, res) =&gt; {<br>        // @TODO<br>    });<br>}<br><br>export default startServer;</pre><p>In the route, I’ve added the wildcard * meaning all GET requests would be handled.</p><p>Now, we’ll extract the path from the incoming request. This path resembles a file path. We’ll leverage this and use the file system to store and retreive the cache response. Meaning, the responses will be written to file on hitting the origin server. When a request comes again, it will be retreived from the same file.</p><p>There are two structures of path here —</p><pre>/example<br>/example.png</pre><p>One path has the file extension while other doesn’t. The one which doesn’t have, we’ll store the file as index.{fileExtension}. The file extension part would be deduced from the content-type header of the response.</p><pre>app.get(&quot;*&quot;, async (req, res) =&gt; {<br>    const requestPath = req.path;<br><br>    // @todo Check in cache.<br><br>    // Get file extension is present<br>    const fileExtension = path.extname(requestPath);<br><br>    // File path.<br>    let filePath = `cache${requestPath}`;<br><br>    // Parse the request path with cache directory and append index.<br>    if ( ! fileExtension ) {<br>        filePath = path.join(filePath, &#39;index&#39;);<br>    }<br><br>    // Remote URL.<br>    const urlPath = `${options.origin}${requestPath}`;<br>    <br>    const response = await fetch(urlPath);<br><br>    // Check if the response is successful<br>    if (!response.ok) {<br>        res.status(response.status).send(response.statusText);<br>        return;<br>    }<br><br>    // Initialize the headers object<br>    const headers = {};<br><br>    // Forward the headers from the origin server to the client<br>    response.headers.forEach((value, key) =&gt; {<br>        const lowerCaseKey = key.toLowerCase();<br><br>        // Skip the content-encoding header and headers containing the word cache<br>        if (lowerCaseKey !== &#39;content-encoding&#39; &amp;&amp; !lowerCaseKey.includes(&#39;x-cache&#39;)) {<br>            headers[key] = value;<br>            res.setHeader(key, value);<br>        }<br>    });<br><br>    // Add the x-cache header.<br>    headers[&#39;x-cache&#39;] = &#39;HIT&#39;;<br>    <br>    // Deduce the file extension.<br>    const contentType = response.headers.get(&#39;content-type&#39;);<br>    if ( ! fileExtension ) {<br><br>        // Get file extension from content type<br>        const ext = mime.extension(contentType);<br><br>        // Add file extension to the file path<br>        filePath = `${filePath}.${ext}`;<br>    }<br><br>    // Create the directory if it doesn&#39;t exist<br>    fs.mkdirSync(path.dirname(filePath), { recursive: true });<br><br>    // Save the file to the cache<br>    const buffer = Buffer.from(await response.arrayBuffer());<br>    fs.writeFileSync(filePath, buffer);<br><br>    // Send the file to the client<br>    res.setHeader(&#39;x-cache&#39;, &#39;MISS&#39;);<br>    res.sendFile(path.resolve(filePath));<br>});</pre><p>Here, we’ve made a remote call to origin server and saved the returned response as a file. If a file extension already exists, for example an image or .doc file, then it will be saved as it is. But, for HTML response or json response where the request path doesn’t contain the file extension, we’re saving it as index.{fileExtension}.</p><p>Also, note that we are excluding the content-encoding header as other wise the client browser will try to incorrectly decode ther response. Though origin server will be sending the encoded content (gzip, br etc), but once it’s saved to file, it plain content without any encoding. So, the ehader is stripped off. Moreover, the x-cache header is also stripped off to add our custom header.</p><p>To deduce the file extension from the content type, we’re using mime-types library. It can be installed as -</p><pre>npm install mime-types</pre><p>Now, this way our response gets saved to the cache directory -</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/644/1*L4DUjaqkOrDTQfHquoVdiA.png" /></figure><h3>File lookup challenge</h3><p>Next challenge is to serve the cached file. We’ve the request path and there is an existing file. But we don’t know the file extension always. For example, if request was for static content like an image or doc, it would contain the file extension and we can directly read the file and pass to client in O(1). Else, between /api/v1/posts and /solutions/gt-matrix , we can’t differenciate if the file is a jsonor html or any other type as all files are saved as prefix index .</p><p>To solve this, we’ll introduce a database layer — sqlite. You can use Redis as well. But the DB should be very performant. I’m preferring sqlite over Redis as Redis is a server which involves IPC, network roundtrips. But sqlite is file based. So, only one IO is required to fetch the record.</p><p>I’ll use the <a href="https://www.prisma.io/orm">Prisma</a> ORM to move fast -</p><pre>npm install prisma --save-dev</pre><p>Go through this <a href="https://www.prisma.io/docs/getting-started/quickstart-sqlite">offical doc</a> where you can understand how to setup the prisma with sqlite.</p><p>Create a db.js file and initialize the prisma client -</p><pre>import { PrismaClient } from &quot;@prisma/client&quot;;<br><br>const prisma = new PrismaClient();<br><br>export default prisma;</pre><p>We’ll have a Path model in schema —</p><pre>model Path {<br>  id          Int    @id @default(autoincrement())<br>  requestPath String @unique<br>  contentType String<br>  filePath    String @unique<br>  headers     String<br>}</pre><ul><li>requestPath — the incoming path from request object.</li><li>contentType — content-type from header</li><li>filePath — the file path where the cached response is stored</li><li>headers — all the headers received from the origin server except the content-encoding and x-cache</li></ul><p>Let’s store the info after hitting the origin server using <a href="https://www.prisma.io/docs/orm/reference/prisma-client-reference#upsert">upsert</a> -</p><pre>// Create the directory if it doesn&#39;t exist<br>fs.mkdirSync(path.dirname(filePath), { recursive: true });<br><br>// Save the file to the cache<br>const buffer = Buffer.from(await response.arrayBuffer());<br>fs.writeFileSync(filePath, buffer);<br><br>// Store the file path in the database<br>try {<br>    await prisma.path.upsert({<br>        where: {<br>            requestPath<br>        },<br>        update: {<br>            filePath,<br>            contentType,<br>            headers: headersString<br>        },<br>        create: {<br>            requestPath,<br>            filePath,<br>            contentType,<br>            headers: headersString<br>        }<br>    });<br>} catch (error) {<br>    res.send(&#39;Error&#39;);<br>    return;<br>}</pre><h3>Serve Cache</h3><p>Finally, we we’ll implement the logic for cached resposne —</p><pre>// Check if the file path is present in the database<br>const pathData = await prisma.path.findUnique({<br>    where: {<br>        requestPath<br>    }<br>});<br><br>if (pathData) {<br>    const absolutePath = path.resolve(pathData.filePath);<br><br>    // Check if this path exists<br>    if ( fs.existsSync(absolutePath) ) {<br>        // Send headers<br>        const headers = JSON.parse(pathData.headers);<br>        Object.keys(headers).forEach(key =&gt; {<br>            res.setHeader(key, headers[key]);<br>        });<br><br>        // Send the file to the client<br>        res.sendFile(absolutePath);<br>        return;<br>    }<br>}</pre><p>Here, if we found that there is a record in DB and a file actually exits, we’ll send the file along with headers.</p><p>So, final code becomes — <a href="https://github.com/i-am-chitti/caching-proxy-server/blob/main/server.js">https://github.com/i-am-chitti/caching-proxy-server/blob/main/server.js</a></p><p>Try this in action. Start server with CLI command. Hit a url, inspect the response header, it should be X-CACHE: MISS . Next request should be X-CACHE: HIT .</p><h3>Cache Purge</h3><p>There is one more requirement. One should be able to purge the cache — empty the DB and remove the cache directory. Let’s register one more command in index.js —</p><pre>// Add command to clear the cache<br>program.command(&#39;clear&#39;).description(&#39;Clear the cache&#39;).action(async () =&gt; {<br>    try {<br>        // Clear the database<br>        await prisma.path.deleteMany();<br><br>        // Remove the cache directory<br>        fs.rmSync(&#39;cache&#39;, { recursive: true, force: true });<br><br>        console.log(&#39;Cache cleared&#39;);<br>    } catch (error) {<br>        console.error(&#39;Error clearing cache&#39;);<br>    }<br>});</pre><p>This way, we are deleting all the records from the DB. Any further requests will directly hit the origin server and get cached. Also, the cache directory is emptied.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c9fde6158037" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a CLI app in Node.js]]></title>
            <link>https://medium.com/@i-am-chitti/building-a-cli-app-in-node-js-5ac6fd88f82b?source=rss-08cb4a84e71e------2</link>
            <guid isPermaLink="false">https://medium.com/p/5ac6fd88f82b</guid>
            <category><![CDATA[apps]]></category>
            <category><![CDATA[github]]></category>
            <category><![CDATA[nodejs]]></category>
            <category><![CDATA[cli]]></category>
            <dc:creator><![CDATA[Deepak Kumar]]></dc:creator>
            <pubDate>Sat, 02 Nov 2024 18:20:49 GMT</pubDate>
            <atom:updated>2024-11-02T18:32:47.500Z</atom:updated>
            <content:encoded><![CDATA[<p>Command Line Interface (CLI) applications are essential tools for developers, providing a faster, more efficient way to execute tasks. While Graphical User Interfaces (GUIs) are popular for general usage, many developers prefer working directly in the CLI for its performance, simplicity, and the powerful control it provides.</p><p>One common example is the Git CLI, which offers more flexibility and speed than its GUI counterpart, GitHub Desktop. Using the CLI, you can chain commands, apply conditions, and quickly navigate through complex workflows that might otherwise require multiple clicks in a UI.</p><p>In this guide, we’ll explore how to create a CLI application using Node.js, leveraging libraries and tools to make a versatile, developer-friendly command-line tool. Let’s dive in!</p><h3>Table of Content</h3><ul><li><a href="#3bcf">Prerequisite</a></li><li><a href="#c9f7">Kickoff</a><br>- <a href="#c9f7">Bootstrap</a><br>- <a href="#858d">Add commander.js</a><br>- <a href="#45cc">Implement command</a></li><li><a href="#c84e">User experience</a><br><a href="#1791">- Beautifying CLI</a><br>- <a href="#e091">Prompting CLI</a><br>- <a href="#dcd2">Cool Loaders</a><br>- <a href="#5b7a">ASCII art</a></li><li><a href="#3820">Project</a></li><li><a href="#8e0f">Kick Off</a><br>- <a href="#6b2f">Bootstrap</a><br>- <a href="#fc51">Update package.json</a><br>- <a href="#a85e">Add index.js</a><br>- <a href="#00ff">Implement handler</a><br>- <a href="#87d2">Event parser</a><br>- <a href="#ebe9">End Result</a></li><li><a href="#8661">Best Practices</a></li></ul><h3>Prerequisite</h3><p>Node.js</p><h3>Kick Off</h3><h4>Step 1: Bootstrap</h4><p>Create project folder and init npm -</p><pre>mkdir github-user-activity<br>cd github-user-activity<br>npm init</pre><h4>Step 2: Install commander.js</h4><p><a href="https://github.com/tj/commander.js">commander.js</a> is a popular JS library for parsing, handling errors, help text on CLI.</p><pre>npm install commander</pre><h4>Step 3: Implementing command</h4><p>Create index.js in root directory. This is where the CLI starts.</p><pre>#!/usr/bin/env node<br><br>import { program } from &quot;commander&quot;;<br><br>program<br>  .version(&quot;1.0.0&quot;)<br>  .description(&quot;My CLI&quot;)<br>  .option(&quot;-n, --name &lt;type&gt;&quot;, &quot;Add your name&quot;)<br>  .action((options) =&gt; {<br>    console.log(`Hey, ${options.name}!`);<br>  });<br><br>program.parse(process.argv);</pre><p>Add bin to your package.json — <a href="https://docs.npmjs.com/cli/v10/configuring-npm/package-json#bin">https://docs.npmjs.com/cli/v10/configuring-npm/package-json#bin</a></p><pre>&quot;bin&quot;: {<br>    &quot;my-cli&quot;: &quot;./index.js&quot;<br>},<br>&quot;type&quot;: &quot;module&quot;</pre><p>Link the project — <a href="https://docs.npmjs.com/cli/v10/commands/npm-link">https://docs.npmjs.com/cli/v10/commands/npm-link</a></p><pre>npm link</pre><p>Now run from your terminal</p><pre>my-cli --name YourName</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/854/1*ybqa8FS0lON2KeDkpJqcxA.png" /></figure><h3>User Experience</h3><h4>Beautifying CLI</h4><p><a href="https://www.npmjs.com/package/chalk">Chalk</a> is perfect for styling CLI’s output pop with color.</p><pre>npm install chalk</pre><p>Let’s improve</p><pre>#!/usr/bin/env node<br><br>import { program } from &quot;commander&quot;;<br>import chalk from &quot;chalk&quot;;<br><br>program<br>  .version(&quot;1.0.0&quot;)<br>  .description(&quot;My CLI&quot;)<br>  .option(&quot;-n, --name &lt;type&gt;&quot;, &quot;Add your name&quot;)<br>  .action((options) =&gt; {<br>    console.log(chalk.blue(`Hey, ${options.name}!`));<br>    console.log(chalk.green(`Hey, ${options.name}!`));<br>    console.log(chalk.red(`Hey, ${options.name}!`));<br>  });<br><br>program.parse(process.argv);</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/952/1*UQOI-MtQQ7g_UpzQX-Yyqg.png" /></figure><h4>Prompting CLI</h4><p>Let’s add some interactivity with <a href="https://www.npmjs.com/package/inquirer">Inquirer.js</a></p><pre>npm install inquirer</pre><p>Asking user -</p><pre>#!/usr/bin/env node<br><br>import { program } from &quot;commander&quot;;<br>import chalk from &quot;chalk&quot;;<br>import inquirer from &quot;inquirer&quot;;<br><br>program.version(&quot;1.0.0&quot;).description(&quot;My CLI&quot;);<br><br>program.action(() =&gt; {<br>  inquirer<br>    .prompt([<br>      {<br>        type: &quot;input&quot;,<br>        name: &quot;name&quot;,<br>        message: &quot;What&#39;s your name?&quot;,<br>      },<br>    ])<br>    .then((answers) =&gt; {<br>      console.log(chalk.green(`Hey there, ${answers.name}!`));<br>    });<br>});<br><br>program.parse(process.argv);</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/702/1*t9o9Ji0-JmV2RpadMR6v2g.png" /></figure><p>You can use this library to ask user for various input types — password, checkbox, expand, rawlist, list, confirm</p><h4>Cool Loaders</h4><p>Loading spinner animation should be shown for async actions. <a href="https://www.npmjs.com/package/ora">ora</a> is popular for this.</p><pre>npm install ora</pre><p>Add the loader that takes time —</p><pre>#!/usr/bin/env node<br><br>import { program } from &quot;commander&quot;;<br>import chalk from &quot;chalk&quot;;<br>import inquirer from &quot;inquirer&quot;;<br>import ora from &quot;ora&quot;;<br><br>program.version(&quot;1.0.0&quot;).description(&quot;My CLI&quot;);<br><br>program.action(() =&gt; {<br>  inquirer<br>    .prompt([<br>      {<br>        type: &quot;input&quot;,<br>        name: &quot;name&quot;,<br>        message: &quot;What&#39;s your name?&quot;,<br>      },<br>    ])<br>    .then((result) =&gt; {<br>      const spinner = ora(`Doing ${result.choice}...`).start(); // Start the spinner<br><br>      setTimeout(() =&gt; {<br>        spinner.succeed(chalk.green(&quot;Done!&quot;));<br>      }, 3000);<br>    });<br>});<br><br>program.parse(process.argv);</pre><h4>ASCII art</h4><p>Using <a href="https://www.npmjs.com/package/figlet">Figlet.js</a>, we can add cool arts -</p><pre>npm install figlet</pre><p>Adding this -</p><pre>import figlet from &quot;figlet&quot;;<br><br>console.log(<br>  chalk.yellow(figlet.textSync(&quot;My CLI&quot;, { horizontalLayout: &quot;full&quot; }))<br>);</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/712/1*U9otawUgvLbgGmIhseZTgg.png" /></figure><h3>Project</h3><p>True learning comes from putting knowledge into practice. So, let’s jump in and build a CLI app with Node.js. We can make use of a fantastic <a href="https://roadmap.sh/projects/github-user-activity">Roadmap project</a> to both learn and create at the same time. The task is straightforward: develop a CLI that retrieves and displays a GitHub user’s recent activity.</p><pre>github-user-activity &lt;username&gt;</pre><h3>Kick Off</h3><h4>Step 1: Bootstrap</h4><p>Create project folder and init npm, install libraries</p><pre>mkdir github-user-activity<br>cd github-user-activity<br>npm init<br>npm install commander ora chalk figlet</pre><h4>Step 2: Update package.json</h4><pre>&quot;bin&quot;: {<br>    &quot;my-cli&quot;: &quot;./index.js&quot;<br>},<br>&quot;type&quot;: &quot;module&quot;</pre><h4>Step 3: Add index.js</h4><p>Create index.js file and add the welcome message -</p><pre>#!/usr/bin/env node<br><br>import chalk from &#39;chalk&#39;;<br>import { program } from &#39;commander&#39;;<br>import figlet from &#39;figlet&#39;;<br>import ora from &#39;ora&#39;;<br>import eventParser from &#39;./src/utils/event-parser.js&#39;;<br><br>console.log(chalk.yellow( figlet.textSync( &#39;GH Activity&#39;)) );<br><br>program.version(&#39;1.0.0&#39;).description(&#39;A simple CLI for getting GitHub user activity&#39;);<br><br>program.argument(&#39;&lt;username&gt;&#39;).action(displayGitHubActivity);<br><br>program.parse(process.argv);</pre><p>The displayGitHubActivity is the action handler. &lt;&gt; around the argument means that it’s required while, you can use [] to add the optional argument.</p><h4>Step 4: Implement handler</h4><p>Add username validation in order to avoid empty username. We’re using the <a href="https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-events-for-the-authenticated-user">Events API endpoint</a> to fetch the GitHub events. By default, it provides the public events. You can add an optional argument for taking token string in CLI and pass it while making API call, you’ll also get the private events. Maybe you can expand on this!</p><pre>const displayGitHubActivity = async (username) =&gt; {<br>    username = username.trim();<br><br>    if ( ! username ) {<br>        console.log( chalk.red(&#39;Please provide a valid GitHub username.&#39;) );<br>        return;<br>    }<br><br>    const apiURL = `https://api.github.com/users/${username}/events`;<br><br>    const spinner = ora(&#39;Loading user activity...&#39;).start();<br><br>    try {<br>        const response = await fetch(apiURL);<br>        const data = await response.json();<br><br>        spinner.stop();<br><br>        if ( ! Array.isArray(data) ) {<br>            console.log( chalk.red(&#39;An error occurred while fetching user activity. Check if the username is valid.&#39;) );<br>            return;<br>        }<br><br>        if ( ! data.length ) {<br>            console.log( chalk.red(`No activity found for user ${username}.`) );<br>            return;<br>        }<br><br>        console.log( chalk.green(`Hey, ${username}! Here are your recent activities on GitHub:`) );<br><br>        console.log( chalk.white(&#39;---&#39;) );<br><br>        data.forEach((activity) =&gt; {<br>                const { message } = eventParser(activity);<br><br>                if ( ! message ) {<br>                    return;<br>                }<br><br>                console.log( chalk.white(`- ${message}`) );<br>        });<br><br>        console.log( chalk.white(&#39;---&#39;) );<br>    } catch (error) {<br>        spinner.stop();<br>        console.log( chalk.red(&#39;An error occurred while fetching user activity. &#39;) );<br>    }<br>};</pre><p>This has reference to eventParser which returns a human-readable message to be displayed on CLI.</p><h4>Step 5: Event parser</h4><p>Create a event-parser.js file under src/utils directory -</p><pre>const eventParser = (activity) =&gt; {<br>    const { type, actor, repo, created_at } = activity;<br><br>    let message = &#39;&#39;;<br><br>    switch(type) {<br>        case &#39;PushEvent&#39;:<br>            if ( activity.payload.size === 0 ) {<br>                break;<br>            }<br>            message = `Pushed ${activity.payload.size} ${activity.payload.size &gt; 1 ? &#39;commits&#39; : &#39;commit&#39;} to ${activity.payload.ref.replace(&#39;refs/heads/&#39;, &#39;&#39;)} at https://github.com/${repo.name}`;<br>            break;<br>        case &#39;CreateEvent&#39;:<br>            message = `Created a new ${activity.payload.ref_type} ${activity.payload.ref} at ${repo.name}`;<br>            break;<br>        case &#39;DeleteEvent&#39;:<br>            message = `Deleted ${activity.payload.ref_type} ${activity.payload.ref} at ${repo.name}`;<br>            break;<br>        case &#39;WatchEvent&#39;:<br>            message = `Starred ${repo.name}`;<br>            break;<br>        case &#39;IssuesEvent&#39;:<br>            message = `${activity.payload.action} issue #${activity.payload.issue.number} at ${repo.name}. More at ${activity.payload.issue.html_url}`;<br>            break;<br>        case &#39;IssueCommentEvent&#39;:<br>            message = `Commented on issue #${activity.payload.issue.number} at ${repo.name}. More at ${activity.payload.comment.html_url}`;<br>            break;<br>        case &#39;ForkEvent&#39;:<br>            message = `Forked ${repo.name}`;<br>            break;<br>        case &#39;PullRequestEvent&#39;:<br>            message = `${activity.payload.action} pull request #${activity.payload.number} at ${repo.name}. More at ${activity.payload.pull_request.html_url}`;<br>            break;<br>        case &#39;PullRequestReviewCommentEvent&#39;:<br>            message = `Commented on pull request #${activity.payload.pull_request.number} at ${repo.name}. More at ${activity.payload.comment.html_url}`;<br>            break;<br>        case &#39;ReleaseEvent&#39;:<br>            message = `Released ${activity.payload.release.tag_name} at ${repo.name}`;<br>            break;<br>        case &#39;PublicEvent&#39;:<br>            message = `Open sourced ${repo.name}`;<br>            break;<br>        case &#39;MemberEvent&#39;:<br>            message = `Added ${activity.payload.member.login} as a collaborator to ${repo.name}`;<br>            break;<br>        case &#39;GollumEvent&#39;:<br>            message = `Created or updated a wiki page in ${repo.name}`;<br>            break;<br>        case &#39;CommitCommentEvent&#39;:<br>            message = `Commented on a commit at ${repo.name}`;<br>            break;<br>        case &#39;PullRequestReviewEvent&#39;:<br>            message = `${activity.payload.action} a review on pull request #${activity.payload.pull_request.number} at ${repo.name}. More at ${activity.payload.review.html_url}`;<br>            break;<br>        case &#39;ProjectCardEvent&#39;:<br>            message = `${activity.payload.action} a project card at ${repo.name}`;<br>            break;<br>        case &#39;ProjectColumnEvent&#39;:<br>            message = `${activity.payload.action} a project column at ${repo.name}`;<br>            break;<br>        case &#39;ProjectEvent&#39;:<br>            message = `${activity.payload.action} a project at ${repo.name}`;<br>            break;<br>        case &#39;SecurityAdvisoryEvent&#39;:<br>            message = `${activity.payload.action} a security advisory at ${repo.name}`;<br>            break;<br>        case &#39;DeploymentEvent&#39;:<br>            message = `${activity.payload.action} a deployment at ${repo.name}`;<br>            break;<br>        case &#39;DeploymentStatusEvent&#39;:<br>            message = `${activity.payload.action} a deployment status at ${repo.name}`;<br>            break;<br>        case &#39;OrganizationEvent&#39;:<br>            message = `${activity.payload.action} an organization`;<br>            break;<br>        case &#39;MarketplacePurchaseEvent&#39;:<br>            message = `${activity.payload.action} a marketplace purchase`;<br>            break;<br>        case &#39;LabelEvent&#39;:<br>            message = `${activity.payload.action} a label at ${repo.name}`;<br>            break;<br>        case &#39;ContentReferenceEvent&#39;:<br>            message = `${activity.payload.action} a content reference at ${repo.name}`;<br>            break;<br>        case &#39;CheckRunEvent&#39;:<br>            message = `${activity.payload.action} a check run at ${repo.name}`;<br>            break;<br>        case &#39;CheckSuiteEvent&#39;:<br>            message = `${activity.payload.action} a check suite at ${repo.name}`;<br>            break;<br>        case &#39;StatusEvent&#39;:<br>            message = `${activity.payload.state} the commit status at ${repo.name}`;<br>            break;<br>        default:<br>            message = `Performed a ${type} event at ${repo.name}`;<br>            break;<br>    }<br>    <br>    return {<br>        type,<br>        actor: actor.display_login,<br>        repo: repo.name,<br>        created_at,<br>        message<br>    };<br><br>}<br><br>export default eventParser;</pre><p>It parses various events and constructs the message.</p><h4>Step 6: End Result</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bhpzNHH-rlJfPSzPO-6EDg.gif" /></figure><h3>Best Practices</h3><p>Before diving into the CLI logic, I highly recommend checking out <a href="https://github.com/lirantal/nodejs-cli-apps-best-practices">this repository by Liran Tal</a>, which has earned over 3,000 stars and is packed with best practices for building CLI applications.</p><p>One key takeaway from Liran’s repo is the idea of providing a stateful experience for users. Instead of requiring them to re-enter the same information each time, you can use a package like conf to save data such as usernames, email addresses, or API tokens. This approach enhances usability and makes for a much smoother, more efficient experience.</p><p>Ready to see it all in action? Head over to <a href="https://github.com/i-am-chitti/github-user-activity-cli">my GitHub page</a> to explore the complete project, including all example files. Dive in, experiment, and feel free to fork or star the repository if you find it useful!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5ac6fd88f82b" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>