<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-US">
  <id>https://zhul.in</id>
  <title>Zhullyb&apos;s Blog</title>
  <updated>2026-04-30T00:00:00.000Z</updated>
  <subtitle>This is my personal blog focused on technology, learning notes, and everyday observations. I write about frontend development, backend architecture, AI, programming techniques, useful tools, and the lessons I learn along the way.</subtitle>
  <rights>© 2026 竹林里有冰</rights>
  <link href="https://zhul.in/en/rss.xml" rel="self" type="application/atom+xml"></link>
  <link href="https://zhul.in" rel="alternate"></link>
  <author>
    <name>竹林里有冰</name>
    <email>zhullyb@outlook.com</email>
    <uri>https://zhul.in</uri>
  </author>
  <icon>https://static.031130.xyz/avatar.webp</icon>
  <logo>https://static.031130.xyz/avatar.webp</logo>
  <entry>
    <id>https://zhul.in/2026/04/30/xiaomi-book-pro-14-2026-on-linux/</id>
    <title>Xiaomi Book Pro 14 (Ultra X7) Linux Compatibility Testing</title>
    <updated>2026-04-30T00:00:00.000Z</updated>
    <link href="https://zhul.in/2026/04/30/xiaomi-book-pro-14-2026-on-linux/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<h2>Late-Night "Impulse" Purchase</h2>
<p>A few nights ago at midnight, I was lying in bed idly browsing JD.com when I miraculously caught this perpetually out-of-stock Xiaomi Book Pro 14 2026 top-spec version in stock! The suddenly appearing purchase button was practically waving at me. Although my brain wrestled with the decision for a bit, my hands were very honest and pressed the payment button.</p>
<p>While I missed the launch discount and the original price of 10,499 CNY stung a little, fortunately I could stack some local government subsidies, bringing the actual price down to 8,999 CNY—quite a bargain. Thinking about it carefully, it's not a loss at all. After all, it was time to let my hardworking companion, the ThinkPad T14 Gen2i that I'd been tinkering with for four years, gracefully retire to secondary duty.</p>
<h2>Linux Compatibility: A "Gamble" and Testing Plan</h2>
<p>This laptop has performed impressively in media reviews over the past month, so I won't elaborate too much. In short, the performance boost and battery life provided by its Panther Lake processor are excellent, and its featherweight 1.07kg made me very excited. The only remaining uncertainty was its Linux compatibility. Since it's a new machine with the extremely low-volume Panther Lake processor, I didn't expect any Linux users to get their hands on it for testing right away. Seeing it was an Intel platform with an Intel network card, I decided to take a gamble. After delivery, I wouldn't activate it directly—I'd use <code>oobe\bypassnro</code> to bypass Microsoft account binding, enter the desktop, disable fast startup, and then pull out a LiveCD for testing.</p>
<p><img src="https://static.031130.xyz/uploads/2026/04/30/effcba4d1ab8d.webp" alt="（The desk is messy; the image has been processed by AI.）"></p>
<p>My plan was this: if I could boot into the LiveCD and after simple testing found no major issues, I'd confirm receipt and activate online; if there were Linux compatibility problems that couldn't be resolved quickly, I'd have to return it, as I'm truly a heavy Linux user.</p>
<h2>Test Items and LiveCD Results</h2>
<p>The test items were as follows:</p>
<ul>
<li>Properly entering the desktop environment</li>
<li>WiFi</li>
<li>Bluetooth</li>
<li>Sound card</li>
<li>Camera</li>
<li>Built-in keyboard</li>
<li>Touchpad</li>
</ul>
<blockquote>
<p><em>Note: I should mention upfront that for <strong>fingerprint recognition</strong> and <strong>power management (including system sleep, hibernation, and other state transitions)</strong>, I didn't specifically verify these features in this testing, mainly because I rarely use fingerprint functionality in my daily Linux usage, so I didn't dwell on this aspect.</em></p>
</blockquote>
<p>Fortunately, the test results for the core basic configurations didn't disappoint me. Let me briefly discuss the LiveCD test results:</p>
<table>
<thead>
<tr>
<th>Distribution</th>
<th>Kernel Version</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ubuntu 26.04</td>
<td>7.0</td>
<td>Keyboard not recognized, occasional screen artifacts on built-in display</td>
</tr>
<tr>
<td>Fedora 44</td>
<td>6.18</td>
<td>Keyboard not recognized, occasional screen artifacts on built-in display, no sound</td>
</tr>
<tr>
<td>CachyOS 260426</td>
<td>6.18</td>
<td>Keyboard not recognized, occasional screen artifacts on built-in display, no sound</td>
</tr>
</tbody>
</table>
<h2>Solving the Problem: The Almighty Kernel Parameters</h2>
<p>From the test results above, you can see that kernel versions 7.0 and above fixed the sound issue. After some searching, I found this blog post that could solve the keyboard recognition and screen artifact problems.</p>
<p>Re-entering the LiveCD, I pressed 'e' at the GRUB interface to enter edit mode, and added <code>i8042.nopnp=1 i8042.dumbkbd=1 xe.force_probe=b081 i915.force_probe=!b081 xe.enable_psr=0</code> parameters to the end of the linux line, then pressed F10 to boot. After entering the system, the keyboard worked normally and the screen artifact issue no longer appeared. I then connected to WiFi and played several hours of YouTube 8K videos without any obvious problems. Bluetooth, sound card, and camera all tested normally as well.</p>
<h2>System Migration and Final Experience</h2>
<p>With this, the Linux compatibility of this Xiaomi Book Pro 14 2026 can be said to exceed expectations. During the formal system installation, you only need to add a few kernel parameters to solve the problems encountered in previous testing.</p>
<p>Then came the migration process. I won't expand on this part—in short, it involved creating a new Linux root partition, using rsync to migrate the old system, rebuilding the fstab partition table and GRUB bootloader, and finally adding the aforementioned kernel parameters after system installation. After rebooting, everything worked normally.</p>
<p>The rsync command was roughly like this:</p>
<pre><code class="language-bash">rsync -axHAWXS --numeric-ids &#x3C;source> &#x3C;destination>
</code></pre>
<p><img src="https://static.031130.xyz/uploads/2026/04/30/99f106c17dd0c.webp" alt=""></p>
<h2>Summary</h2>
<p>Overall, the Linux compatibility of this Xiaomi Book Pro 14 2026 is quite excellent. Although I encountered some issues during initial testing, these problems were effectively resolved by adding kernel parameters. I've now activated the pre-installed Windows system online and confirmed receipt, while also successfully migrating my Linux system. The overall experience is very satisfying.</p>
<h2>See Also</h2>
<ul>
<li><a href="https://meixg.cn/2026/03/25/xiaomi-book-pro-14-omarchy/">在 Xiaomi Book Pro 14 (2026) 上运行 Omarchy (Arch + Hyprland) </a></li>
</ul>]]>
    </content>
    <published>2026-04-30T00:00:00.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Linux"></category>
    <category term="Hardware"></category>
    <category term="Xiaomi"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2026/02/20/xiaomi-china-version-fcm-push-debug/</id>
    <title>Mainland China Xiaomi FCM Screen-Off Disconnection: Attempts and Possible Solutions in a Rootless Environment</title>
    <updated>2026-02-20T00:00:00.000Z</updated>
    <link href="https://zhul.in/2026/02/20/xiaomi-china-version-fcm-push-debug/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>In November last year, I got a new mainland China version Xiaomi 15 as my daily driver. Actually, my previous Redmi K70 Ultra was released in the same year as this Xiaomi 15, and performance-wise they're comparable. However, during my Gap Year while traveling across the country, I realized it was a shame not being able to capture the night scenes I encountered, so I wanted to switch to a phone with better camera capabilities. The newly released Xiaomi 17 was at a premium price point, and didn't offer significant improvements over the 15, so I went directly for the Xiaomi 15.</p>
<p>After getting it, I followed my usual habits and enabled "Google Base Services" in settings, installed "Google Play" and started using it normally. But over these past few months, I discovered that my FCM push notifications—whether Outlook email notifications or a certain instant messaging app that looks like a paper airplane—never seemed to work properly from the start. Their notifications would occasionally pop up suddenly after I unlocked my phone, but more often they just disappeared. I would only receive the backlogged notifications when I manually opened the corresponding apps.</p>
<p>Recently I received an offer from an overseas university and will be going abroad in September to pursue a master's degree, so I wanted to resolve this issue before leaving the country. After all, when living in China, most apps can push messages to me through MiPush, and these FCM-dependent apps weren't my most frequently used ones. I could just open them manually when I woke up each day to receive notifications. Even if my message response time wasn't great, it wasn't a big deal—if there was something urgent, my family and friends knew other ways to reach me faster. But once abroad, FCM push notifications become critically important, so I need to fix this issue quickly, or I'll have to consider switching phones.</p>
<h2>Test Environment</h2>
<ul>
<li>Xiaomi 15 mainland China version, 16GB + 512GB, HyperOS 3.0.7.0.WOCCNXM, the latest version as of this writing</li>
<li>"Google Base Services" enabled, "Google Play" installed</li>
<li>Connected to WiFi, with WiFi-routed data exit IP in 🇺🇸 Los Angeles</li>
<li>MIUI optimization not disabled</li>
</ul>
<h2>Getting FCM Running First</h2>
<p>I needed to first resolve the issue of FCM not receiving messages even when the screen is on. In the "Phone" app's dialer, entering <code>*#*#426#*#*</code> opens the FCM Diagnostics interface, which contains logs about FCM connection status.</p>
<p><img src="https://static.031130.xyz/uploads/2026/02/20/6202d116db231.webp" alt="Dialer Input">
<img src="https://static.031130.xyz/uploads/2026/02/20/432922f339849.webp" alt="FCM Diagnostics Interface"></p>
<p>In this interface, I found that FCM's connection status was actually normal, with no obvious error messages in the logs. So I started suspecting that some of HyperOS's power-saving mechanisms were interfering with FCM's normal operation in the background. Through some searching on Xiaohongshu (Little Red Book), I discovered that enabling "Autostart" permission for apps requiring FCM push in the settings interface, and setting battery optimization to "Don't optimize," seemed to solve this problem.</p>
<p><img src="https://static.031130.xyz/uploads/2026/02/20/8c20be931764b.webp" alt="App Settings Interface"></p>
<p>Testing with an IM app (sending messages from one account to another), I found that with the screen on, FCM could now receive messages normally even when the app was closed in the background.</p>
<h2>The Problem of FCM Disconnecting One Minute After Screen-Off</h2>
<p>However, after turning off the screen and leaving it idle for a minute, FCM stopped working completely. Whether email notifications or IM messages, none could be pushed to the phone via FCM. Only when I lit up the screen would the backlogged notifications suddenly appear. Checking the FCM Diagnostics interface again, FCM's connection status was either disconnected or had just connected for a few seconds. This indicates that in the lock screen state, FCM's connection gets terminated by the system, preventing message reception.</p>
<p>PS: I discovered that when charging, even with the phone locked, FCM maintains its connection and receives messages normally. This further confirms that HyperOS's power-saving mechanisms are interfering with FCM's normal operation.</p>
<h2>Possible Solutions</h2>
<p>After searching on Xiaohongshu and Xiaolüshu (CoolApk), I found some attempts and solutions from others:</p>
<h3>1. Disable MIUI Optimization</h3>
<p>This is my least favorite solution, because MIUI optimization is actually an important reason why I like HyperOS (MIUI). After disabling MIUI optimization, I found that the remaining battery percentage information can't be displayed inside the status bar battery icon—it must appear to the right of the battery icon. This is a huge waste of status bar space after HyperOS introduced the Super Island (Dynamic Island). Of course, there are other features that would be affected, but this is what I care about most.</p>
<p>Additionally, in HyperOS 3's developer mode, the "Disable MIUI optimization" option can no longer be found, though some users report you can make this option reappear through methods like resetting settings status, but I don't think this is a good solution.</p>
<h3>2. Freeze "Battery &#x26; Performance" App or Replace with International Version</h3>
<p>On HyperOS 2, some users reported that tampering with the "Battery &#x26; Performance" system app could solve the FCM screen-off disconnection problem. I tried using adb shell to freeze it, but after testing, this wasn't a useful solution, and it might cause some system scheduling anomalies. <strong>Also, don't enter ultra power saving mode, because that mode's UI is provided by the "Battery &#x26; Performance" app!!</strong></p>
<p>The adb command is as follows:</p>
<pre><code class="language-bash">adb shell pm uninstall --user 0 com.miui.powerkeeper
</code></pre>
<p>If you want to restore it, you can reinstall this app with the following command:</p>
<pre><code class="language-bash">adb shell cmd package install-existing com.miui.powerkeeper
</code></pre>
<p>Some people also reported that replacing it with the international version of the "Battery &#x26; Performance" app could solve this problem, but I couldn't find the installation package for the HyperOS 3 international version's "Battery &#x26; Performance" app. After downloading and unpacking the complete ROM for the Xiaomi 15 overseas version, that app's apk couldn't be directly updated or installed via adb either.</p>
<p><img src="https://static.031130.xyz/uploads/2026/02/20/58ba6ce88ef49.webp" alt="Update Failed"></p>
<h3>3. Use <a href="https://github.com/kooritea/fcmfix">fcmfix</a> and Other Xposed Modules After Unlocking Bootloader</h3>
<p>This solution is... forget it. Although I have a level 5 account on the Xiaomi community, I don't have the energy to participate in the "Xiaomi entrance exam" (I heard it's been suspended anyway). Moreover, after unlocking the bootloader, I might face a series of problems like payment apps not working. If I wanted to cover up related traces, I'd have to go through more hassle—it feels like more trouble than it's worth.</p>
<h3>4. Use <a href="https://github.com/shaobin0604/HeartbeatFixerForGCM">HeartbeatFixerForGCM</a></h3>
<p>This software has been removed from Google Play and hasn't been updated in a long time. Testing shows it cannot prevent FCM from being disconnected by the system in lock screen state on HyperOS 3.</p>
<h3>5. Use Gboard to Keep FCM Alive</h3>
<p>Although I don't know the principle, some users reported that installing Gboard keyboard allows FCM to maintain its connection and receive messages normally in lock screen state. I tried it too, and indeed after installing Gboard and setting it as the default input method, FCM could maintain its connection in lock screen state.</p>
<p>However, this solution isn't perfect either. After all, I don't like Gboard's input experience, so I can't really accept this solution.</p>
<h2>Some Personal Experiments</h2>
<p>Although I don't have much experience with Android development—only touching it during a sophomore year course project—the AI era has given me the ability to do vibe coding.</p>
<p><img src="https://static.031130.xyz/uploads/2026/02/20/51db4ed4ef15b.webp" alt="Attempting to use vibe coding to modify source code and compile"></p>
<p>So I also had AI modify some logic for keeping FCM alive based on HeartbeatFixerForGCM's open-source code, roughly following these approaches:</p>
<ul>
<li>Input Method Keep-Alive: Continuing Gboard's approach, using the persistent nature of input method services to maintain FCM connection. It could keep FCM alive, but lost the input capability of the input method—pass.</li>
<li>Notification Listening: NotificationListener as a system listening role to increase persistence probability—no success, pass.</li>
<li>Foreground Service: Persistent notification + FGS to elevate process survival priority—no success, pass.</li>
<li>Accessibility Keep-Alive: Accessibility service also as a system listening role to increase persistence probability—no success, pass.</li>
<li>VPN Keep-Alive: Using VPN service's persistent nature to maintain FCM connection. Don't know if it has any effect, <del>but it did manage to disconnect my phone's network</del>—pass.</li>
</ul>
<p>In short, none of these attempts succeeded. FCM still gets disconnected by the system in lock screen state.</p>
<h2>Seems Like I Found a Viable Solution?</h2>
<p>Just when I was at my wit's end and ready to shop for another phone, I saw a Xiaohongshu post mentioning that you could first uninstall updates to "Google Play Services," then update it again to the latest version. The poster's explanation was to first uninstall the mainland-optimized Play Services, then install an un-optimized version from the Play Store, which could solve this problem.</p>
<p>Although you can't directly find the "Google Play Services" app on the Play Store, you can search for "Google Play Services" using your phone's browser, click the link from google.com, and it will automatically jump to the "Google Play Services" app page on the Play Store. You can also click <a href="https://play.google.com/store/apps/details?id=com.google.android.gms">here</a> directly.</p>
<p>I tried it too, and indeed after uninstalling updates to "Google Play Services," FCM could maintain its connection in lock screen state. Although this solution sounds a bit mystical and I can't see the actual principle at work, since it was effective, I didn't dwell on it.</p>
<p>But just when I thought the problem was solved, while writing this article, I tried restarting my phone and found the problem had returned. And after restarting, repeating the above steps of uninstalling updates to "Google Play Services" still didn't solve the problem. This troubled me greatly, so I started recalling the previous operation steps, but I could never reproduce the previous state...</p>
<h2>Wait, There Seems to Be a Fallback</h2>
<p>While discussing this issue with a <a href="https://github.com/Rurikobaka/">seasoned Android enthusiast</a>, he pointed out that mainland OS indeed disconnects FCM's long connection after screen-off to save power, but still maintains a periodic check mechanism. This seems to match some isolated cases I encountered during testing. This periodic check interval is quite long—according to his estimate, around 10-20 minutes. I also conducted a round of testing myself. The process was:</p>
<ol>
<li>Screen off for one minute, use secondary account to send message to main account, wait 6 minutes until main account receives the message, notified by my Xiaomi band's vibration that the message arrived.</li>
<li>Immediately use secondary account to send another message to main account, wait for main account to receive the second message, record the time difference between the two messages.</li>
</ol>
<p>Because the time interval is quite long, I only tested one and a half rounds. The first round's time difference was 28 minutes, and the second round's time difference reached 38 minutes.</p>
<p>Conclusion: <strong>In screen-off state, although FCM's connection gets disconnected by the system, the system will automatically wake up FCM approximately every 30 minutes (inaccurate data) to check for new messages. If there are any, you'll receive notifications.</strong></p>
<h2>Conclusion</h2>
<p>Currently, to receive FCM push notifications on mainland China version Xiaomi phones, you can only choose between using Gboard + real-time push / periodic check mechanism fallback.</p>
<p>The former uses Gboard input method's persistent nature to maintain FCM connection, enabling real-time message reception in screen-off state, but requires sacrificing input experience; the latter uses system periodic FCM wake-ups to check for new messages. While not requiring sacrifice of input experience, there may be delays exceeding half an hour.</p>
<h2>References</h2>
<ul>
<li><a href="https://www.mobile01.com/topicdetail.php?f=634&#x26;t=6892724">【HyperOS】修復小米陸版通知推送 - Mobile01</a></li>
<li><a href="https://www.v2ex.com/t/993090">如何解决原生 Android 续航问题？ - V2EX</a></li>
<li><a href="https://staging.v2ex.com/t/1089681">小米 15/hyperOS 2.0 打开 FCM 通知 - V2EX</a></li>
<li><a href="https://www.threads.com/@silicon.salmon/post/DDR_SD8y3Gp">海外不建議購買內地版小米手機 因為一鎖屏就斷連 FCM， 導致海外 App 收不到消息推送。 亮屏後 FCM，會嘗試重連。 重連成功，才能收到通知。 已開自啟動，電池無限制。 有什麼解決辦法？ 香港澳門| salmon0105</a></li>
<li><a href="https://github.com/shaobin0604/HeartbeatFixerForGCM">shaobin0604/HeartbeatFixerForGCM: Tiny application to fix GCM push notification delay issue</a></li>
<li><a href="https://github.com/kooritea/fcmfix">kooritea/fcmfix: [xposed]让fcm唤醒已完全停止的应用</a></li>
<li>Various discussions and feedback on social platforms like Xiaohongshu and CoolApk. Since the content is rather scattered and unsystematic, I won't list them all here. Interested readers can search for relevant keywords to view them.</li>
</ul>]]>
    </content>
    <published>2026-02-20T00:00:00.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Android"></category>
    <category term="Network"></category>
    <category term="Hardware"></category>
    <category term="Xiaomi"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2026/01/31/mihomo-tun-fail-to-visit-dl-google-com/</id>
    <title>I Can’t Access dl.google.com — A Network Debugging Story Under TUN</title>
    <updated>2026-01-31T00:00:00.000Z</updated>
    <link href="https://zhul.in/2026/01/31/mihomo-tun-fail-to-visit-dl-google-com/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>If you’re familiar enough with the current network environment in mainland China, you probably know that <code>dl.google.com</code> is <strong>often directly accessible without a proxy</strong>.<br>
For example, you can download the Chrome offline installer directly from<br>
<a href="https://google.cn/chrome/?standalone=1">google.cn/chrome/?standalone=1</a> on a domestic network, and the final download domain is <code>dl.google.com</code>.</p>
<p>My usual setup is to keep my proxy tool’s TUN mode enabled 24/7. All traffic goes through a virtual network interface first, and then routing rules decide automatically whether it should go through the proxy or connect directly. Most of the time this setup is pretty hassle-free — until recently, when I was rolling updates on Arch Linux with <code>yay</code> and suddenly ran into an SSL connection failure with <code>dl.google.com</code>.</p>
<p><img src="https://static.031130.xyz/uploads/2026/01/31/df5e547511070.webp" alt="yay update failed"></p>
<p>It wasn’t just <code>yay</code>. My browser showed the exact same result:</p>
<p><img src="https://static.031130.xyz/uploads/2026/01/31/8ada158510aba.webp" alt="Firefox access failed"></p>
<p>My first instinct was: <em>did my routing rules break again?</em><br>
(They’re not written by me, so blaming them first is perfectly reasonable.)</p>
<h2>The Rule Is DIRECT — No One Else to Blame</h2>
<p>I double-checked the routing rules. Traffic with SNI <code>dl.google.com</code> was explicitly configured as <strong>DIRECT</strong>.</p>
<p><img src="https://static.031130.xyz/uploads/2026/01/31/96d27a20f2bf3.webp" alt="Mihomo routing rules"></p>
<p>That made things strange. Logically:</p>
<ul>
<li><code>dl.google.com</code> itself is often directly reachable from domestic networks</li>
<li>The rule clearly says DIRECT</li>
</ul>
<p>To be honest, I’d seen this issue before. Back then I had higher-priority tasks, so I just turned off the proxy tool, finished the update, and moved on. This time, though, I had nothing urgent on my plate — so I decided to finally dig into it properly.</p>
<h2>It Resolved to an Overseas IP</h2>
<p>First, I disabled the proxy tool’s <code>fake-ip</code> mode and switched back to real IP resolution to avoid introducing extra variables. Then I used <code>curl -vv</code> to access a <code>dl.google.com</code> download URL and see where it was actually trying to connect.</p>
<p><img src="https://static.031130.xyz/uploads/2026/01/31/d3dbfc513de9f.webp" alt="curl -vv output"></p>
<p>Looking back now, I can say with confidence: the resolved IP belonged to Google’s <strong>overseas CDN</strong>, not a domestic or domestically reachable data center.</p>
<p><img src="https://static.031130.xyz/uploads/2026/01/31/7ba8a0b8a2579.webp" alt="IP geolocation"></p>
<p>For those unfamiliar: when resolving <code>dl.google.com</code> for users in mainland China, DNS often returns <strong>domestically reachable IPs</strong> (otherwise direct downloads wouldn’t work at all).<br>
The overseas IP returned here was unreachable on my connection. Combined with the fact that I had configured <code>dl.google.com</code> as DIRECT, the result was:</p>
<blockquote>
<p>DNS returned an “overseas IP”</p>
<ul>
<li>Rules enforced DIRECT
= Direct connection to an unreachable destination
= TLS handshake failure</li>
</ul>
</blockquote>
<p>So this wasn’t a case of “the direct rule didn’t work”. It was more like:<br>
<strong>the rule worked perfectly, but DNS led me straight into a ditch.</strong></p>
<h2>Who Is Answering for dl.google.com?</h2>
<p>In the Mihomo core, the main DNS-related configuration options are:</p>
<ol>
<li><code>nameserver</code>: default resolvers (most domains use these)</li>
<li><code>direct-nameserver</code>: resolvers for DIRECT domains (only available in newer versions)</li>
<li><code>proxy-server-nameserver</code>: resolvers for proxy node hostnames (irrelevant here)</li>
<li><code>default-nameserver</code>: used to resolve “domain-form” nameservers in the DNS config itself (not expanded here)</li>
</ol>
<p>Since <code>dl.google.com</code> was marked as a DIRECT domain, Mihomo should theoretically prefer <code>direct-nameserver</code>. If that isn’t set, it falls back to <code>nameserver</code>.</p>
<p>At the time, my <code>nameserver</code> configuration was:</p>
<ul>
<li><code>https://dns.alidns.com/dns-query</code></li>
</ul>
<p>My intuition was simple: if the resolution looked like it came from an overseas CDN pool, I should verify whether AliDNS (DoH) itself was returning that overseas IP.</p>
<h2>Querying AliDNS DoH Directly — Overseas Pool Confirmed</h2>
<p>AliDNS provides a JSON-based query interface, so I tested it directly with curl:</p>
<pre><code class="language-bash">curl -s 'https://dns.alidns.com/resolve?name=dl.google.com&#x26;type=A'
</code></pre>
<p><img src="https://static.031130.xyz/uploads/2026/01/31/b457af9299330.webp" alt="DoH response"></p>
<p>The returned IP was exactly the overseas one I’d seen earlier. At this point, I could confidently conclude:</p>
<p><strong>At least on my current network exit, AliDNS resolves <code>dl.google.com</code> to an overseas IP that is unreachable for me.</strong></p>
<h3>This Requires Two Conditions (Both Are Necessary)</h3>
<p>It’s important to emphasize something here: this is <em>not</em> simply “AliDNS always resolves it wrong”. After running a bunch of comparisons, I found the conditions to be surprisingly strict:</p>
<blockquote>
<p><strong>The issue reliably reproduces only when <em>both</em> “China Mobile broadband” and “AliDNS (including 223.5.5.5 or AliDNS DoH)” are used together.</strong><br>
Remove either condition, and the problem usually disappears.</p>
</blockquote>
<p>More specifically:</p>
<ul>
<li><strong>Switch to China Telecom or China Unicom broadband</strong>: with the same AliDNS, <code>dl.google.com</code> usually resolves correctly</li>
<li><strong>Stay on China Mobile broadband, but don’t use AliDNS</strong>: resolution is usually fine</li>
<li><strong>China Mobile broadband + AliDNS</strong>: high probability of getting an overseas IP pool, and DIRECT connections fail</li>
</ul>
<p>I also ran nationwide resolution tests on itdog, and the reproduction rate was indeed much higher on China Mobile networks.</p>
<p><img src="https://static.031130.xyz/uploads/2026/01/31/d93222096f005.webp" alt="itdog test results"></p>
<p>Why does this happen? Honestly, I can’t provide a single authoritative explanation. What I <em>can</em> say is that the behavior is extremely consistent, and consistent enough for me to conclude:</p>
<p><strong>The problem isn’t TUN itself — it’s that, under TUN, my DNS choice sent <code>dl.google.com</code> to an address pool that’s unreachable on China Mobile.</strong></p>
<h2>How Did I Finally Fix It?</h2>
<p>Since the issue was the combination of <strong>China Mobile + AliDNS</strong>, the solution was straightforward:</p>
<p><strong>Stop letting <code>dl.google.com</code> be resolved by AliDNS.</strong></p>
<p>You can do this by configuring <code>direct-nameserver</code>, using <code>nameserver-policy</code>, switching to another public DNS like <code>119.29.29.29</code>, or even delegating DNS resolution to your home router.</p>
<pre><code class="language-yaml">direct-nameserver:
  - 192.168.8.1

nameserver-policy:
  "dl.google.com": [119.29.29.29]
</code></pre>
<p>After this change, <code>yay</code> updates worked normally again, and browsers could directly access <code>dl.google.com</code> without issues.</p>
<h2>References</h2>
<ul>
<li><a href="https://linux.do/t/topic/1061825">Minimal anti–DNS-leak configuration for the mihomo core (2025) – Development &#x26; Tuning – LINUX DO</a></li>
</ul>]]>
    </content>
    <published>2026-01-31T00:00:00.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Network"></category>
    <category term="DNS"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/12/23/vercel-cache-control/</id>
    <title>Have You Noticed Vercel&apos;s Cache Control?</title>
    <updated>2025-12-23T00:00:00.000Z</updated>
    <link href="https://zhul.in/2025/12/23/vercel-cache-control/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>Vercel's default cache configuration is actually not very reasonable, but few people notice it.</p>
<h2>First, Let's Look at the Results</h2>
<p><img src="https://static.031130.xyz/uploads/2025/12/23/577e579eceb96.webp" alt="Figure 1"></p>
<p><img src="https://static.031130.xyz/uploads/2025/12/23/0cfc9543c857a.webp" alt="Figure 2"></p>
<h2>Analysis</h2>
<h3>Testing Approach</h3>
<p>Both images show test results from my blog on <a href="https://pagespeed.web.dev/">PageSpeed Insights</a>. The testing steps were:</p>
<ol>
<li>Deploy</li>
<li>Run the first test on PageSpeed Insights</li>
<li>Wait 120 seconds to prevent PageSpeed Insights from using cached results</li>
<li>Run the second test and use those results</li>
</ol>
<p><strong>The purpose of using the second test results</strong> is to ensure that the Vercel CDN node accessed by PageSpeed Insights has completed fetching from origin and <strong>cached the content on the CDN node</strong>. This way, the second access retrieves results directly from the CDN cache without needing to fetch from origin.</p>
<p>Looking at the test results in Figure 1, does it seem normal? The loading time for a single HTML page reached <strong>450ms. While this doesn't look terribly slow, there's actually a problem when you examine it closely.</strong></p>
<p>Vercel uses Amazon's global CDN network. After our first visit, the CDN node should have already cached the homepage content, so the second visit should retrieve content directly from the CDN node's cache.</p>
<h3>What Would Be a Reasonable Duration?</h3>
<ul>
<li>TCP connection establishment requires 1.5 round-trip times (RTT) for the three-way handshake, plus 1 RTT for TLS 1.3 handshake, totaling 2.5 RTT.</li>
<li>The HTML file size is 18KB. The initial congestion window (IW) of 10 MSS ≈ 1460 bytes ≈ 14.6KB means it should theoretically be transmitted within two RTTs.</li>
</ul>
<p>Total: 4.5 RTT.</p>
<p>PageSpeed Insights likely uses a node in the United States for testing. Amazon CDN has extensive coverage in the US, so keeping a single RTT under 5ms is more than adequate. Therefore, the theoretical loading time should be around 22.5ms. Adding DNS resolution time (which shouldn't be much since there was an access two minutes prior, so this isn't a cold start) and some uncontrollable network jitter, <strong>it should be completely fine to stay within 50ms.</strong></p>
<p><strong>But the actual measured time was 450ms, nearly 9 times higher—this is very unreasonable.</strong></p>
<p>Now let's look at Figure 2's results. The single HTML loading time dropped to 41ms, which completely meets expectations.</p>
<p>Why is there such a huge difference? The reason lies in Vercel's cache control settings.</p>
<h2>Vercel's Cache Control</h2>
<p>For websites deployed on Vercel, by default, Vercel sets the following cache control header for HTML files:</p>
<pre><code>cache-control: public, max-age=0, must-revalidate
</code></pre>
<p>This setting means:</p>
<ul>
<li><code>public</code>: The response can be cached by any cache, including browsers and CDNs.</li>
<li><code>max-age=0</code>: The maximum cache time for the response is 0 seconds, meaning the response expires immediately after being cached.</li>
<li><code>must-revalidate</code>: Once the response expires, the cache must validate its validity with the origin server.</li>
</ul>
<p>Combined, these three directives mean Vercel is essentially telling CDN nodes: you can cache this HTML file, but you must validate its validity with the origin server before using the cache each time. Since <code>max-age=0</code>, the cache expires immediately upon storage, so every request triggers origin validation.</p>
<p>Although cache validation in HTTP/1.1 and HTTP/2 typically uses conditional requests (such as the file's ETag or Last-Modified headers) to save transmission bandwidth, this still requires round-trip communication with the origin server, adding extra latency overhead.</p>
<p>Therefore, under Vercel's default configuration, responses to any request won't be directly cached by CDN nodes. The general flow is as follows:</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant Client
    participant CDN
    participant Vercel
    Client->>CDN: Request HTML file
    CDN->>Vercel: Conditional request to validate cache
    Vercel->>CDN: Return latest HTML file or 304 Not Modified
    CDN->>Client: Return HTML file
</code></pre>
<h2>Solution</h2>
<p>To solve this problem, we need to adjust Vercel's cache control settings so that HTML files can be cached by CDN nodes for a period of time without needing origin validation every time.</p>
<p>Vercel allows us to create a <code>vercel.json</code> file in the project's root directory to configure various aspects of Vercel's deployment behavior, including HTTP response header configuration.</p>
<p>My blog is built with the Nuxt.js framework, and the generated build artifacts roughly fall into two categories:</p>
<ol>
<li>HTML files: These files' content may change frequently and shouldn't have excessively long cache times;</li>
<li>Static resource files: Including JavaScript, CSS, etc. These files typically have hash values in their filenames and can have longer cache times or even be marked as immutable.</li>
</ol>
<p>In terms of deployment workflow, my blog is first built into static pages in GitHub Actions after each push, then deployed to Vercel. So I created a <code>vercel.json</code> file in my project's public directory (this way the vercel.json file will be in the root of the build artifacts) with the following content:</p>
<pre><code class="language-json">{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=0, s-maxage=600, must-revalidate"
        }
      ]
    },
    {
      "source": "/(.*)\\.(css|js)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    }
  ]
}
</code></pre>
<p>Here I set <code>max-age=31536000, immutable</code> for all CSS and JS files, so these static resource files can be cached long-term by browsers and CDNs. For all other files (mainly HTML files), I set <code>max-age=0, s-maxage=600, must-revalidate</code>, so HTML files can be cached by the CDN for 10 minutes. Requests within these 10 minutes can retrieve content directly from the CDN node's cache without needing origin validation.</p>
<p>With this modified cache control setting, the request flow for HTML files becomes:</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant Client
    participant CDN
    Client->>CDN: Request HTML file
    CDN->>Client: Directly return cached HTML file
</code></pre>
<p>This greatly reduces request latency and improves page loading speed.</p>
<h2>Other Considerations</h2>
<p>Vercel's architecture isn't the traditional "origin server - CDN" architecture, but rather closer to a "global multi-region storage + CDN edge cache" architecture. So even for origin requests, Vercel will try to retrieve content from the storage node closest to the user to reduce latency. However, this doesn't mean origin request latency can be ignored, especially when pursuing optimal loading speed—proper cache control remains very important.</p>
<h2>References</h2>
<ul>
<li><a href="https://vercel.com/docs/headers/cache-control-headers">Cache-Control headers | Vercel</a></li>
<li><a href="https://vercel.com/docs/cdn-cache">Vercel CDN Cache | Vercel</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching">HTTP caching - HTTP | MDN</a></li>
</ul>]]>
    </content>
    <published>2025-12-23T00:00:00.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Vercel"></category>
    <category term="Network"></category>
    <category term="HTTP"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/12/10/caddy-traffic-proxy-on-layer-4/</id>
    <title>Layer 4 Traffic Proxying with Caddy: A Practical Guide</title>
    <updated>2025-12-10T00:00:00.000Z</updated>
    <link href="https://zhul.in/2025/12/10/caddy-traffic-proxy-on-layer-4/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<h2>Background</h2>
<p>On one of my VPS servers with optimized routing, port 443 needs to serve two distinct purposes:</p>
<ol>
<li>Hosting my blog for visitors from mainland China, handling HTTPS encryption/decryption and serving static content</li>
<li>Disguising certain special-purpose traffic to mimic HTTPS traffic from well-known, commonly-allowed websites (yes, I'm talking about Reality)</li>
</ol>
<p>This meant I needed a solution to handle both roles simultaneously on the same port of the same server.</p>
<h2>Choosing the Solution</h2>
<p>I've long been aware that Nginx's <code>stream</code> directive can achieve Layer 4 (raw TCP stream forwarding) traffic splitting based on SNI recognition. However, as a devoted Caddy user who has written numerous <a href="/en/tags/Caddy/">Caddy-related posts</a>, I still prefer Caddy despite Nginx's recent ACME v2 support. The simplicity and usability of Caddyfile keep me coming back.</p>
<p>After some research, I discovered that while the latest Caddy version (v2.10) doesn't natively support Layer 4 traffic proxying, there's a community module called <a href="https://github.com/mholt/caddy-l4">caddy-l4</a> that provides exactly this functionality. With 1.5k stars on GitHub and active maintenance, it seemed worth trying.</p>
<h2>Installation</h2>
<p>Although the official Caddy APT repository doesn't include the caddy-l4 module, I recommend first installing the base Caddy version through APT, then using Caddy's official <a href="https://caddyserver.com/download">download page</a> to build a custom binary with the modules you need. Download it and replace the system Caddy executable. This approach makes systemd service configuration much easier. Just remember to disable the Caddy APT repository to prevent automatic updates from overwriting your custom build.</p>
<p>For future updates, you can simply run:</p>
<pre><code class="language-bash">caddy upgrade
</code></pre>
<p>Caddy will automatically detect the modules included in your current binary, trigger an online build from the official website to generate a new binary with those modules, and perform the replacement. You just need to manually restart the systemd service to complete the update.</p>
<p>If Caddy's online build service fails (it's been somewhat unstable lately), you can <a href="https://caddyserver.com/docs/build#xcaddy">follow the documentation</a> to compile Caddy locally using xcaddy:</p>
<pre><code class="language-bash">xcaddy build --with github.com/mholt/caddy-l4
</code></pre>
<h2>Configuration</h2>
<p>Here's my original Caddyfile configuration for the blog:</p>
<pre><code>zhul.in {
    root * /var/www/zhul.in

    encode zstd gzip
    file_server

    handle_errors {
            rewrite * /404.html
            file_server
    }
}

www.zhul.in {
    redir https://zhul.in{uri}
}
</code></pre>
<p>Since both zhul.in and <a href="http://www.zhul.in">www.zhul.in</a> were using ports 80 and 443, I needed to move the HTTPS listeners to a different port and let caddy-l4 handle port 443.</p>
<p>Here's the modified Caddyfile:</p>
<pre><code>http://zhul.in:80, https://zhul.in:8443 {
    root * /var/www/zhul.in

    encode zstd gzip
    file_server

    handle_errors {
            rewrite * /404.html
            file_server
    }
}

http://www.zhul.in:80, https://www.zhul.in:8443 {
    redir https://zhul.in{uri}
}
</code></pre>
<p>Next, I added the caddy-l4 configuration:</p>
<pre><code>{
    layer4 {
        :443 {
            @zhulin tls sni zhul.in www.zhul.in
            route @zhulin {
                proxy 127.0.0.1:8443
            }

            @proxy tls sni osxapps.itunes.apple.com
            route @proxy {
                proxy 127.0.0.1:20443
            }
        }
    }
}
</code></pre>
<p>The syntax is quite straightforward. First, listen on port 443 within the <code>layer4</code> block. Then define SNI-based matching rules using <code>@name tls sni domain</code>. Finally, use <code>route @name</code> to specify how to handle traffic matching each rule—here I'm using <code>proxy ip:port</code> to forward the traffic.</p>
<p>Since my special traffic masquerades as Apple iTunes traffic, the SNI signature in the configuration is <code>osxapps.itunes.apple.com</code>. This traffic gets forwarded to local port 20443, where another service handles it.</p>
<p>caddy-l4 offers various other matching methods and routing handlers. Check out their <a href="https://github.com/mholt/caddy-l4/tree/master/docs/examples">examples on GitHub</a> for more details.</p>
<p>Once configured, restart the Caddy service:</p>
<pre><code class="language-bash">sudo systemctl restart caddy
</code></pre>
<h2>See Also</h2>
<ul>
<li><a href="https://github.com/mholt/caddy-l4">mholt/caddy-l4: Layer 4 (TCP/UDP) app for Caddy</a></li>
<li><a href="https://caddyserver.com/docs/build">Build from source — Caddy Documentation</a></li>
</ul>]]>
    </content>
    <published>2025-12-10T00:00:00.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Caddy"></category>
    <category term="Network"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/11/25/did-your-tld-slowing-down-your-site/</id>
    <title>Does Your Top-Level Domain Slow Down Your Website? — Another Look at DNS Cold Start</title>
    <updated>2025-11-25T00:00:00.000Z</updated>
    <link href="https://zhul.in/2025/11/25/did-your-tld-slowing-down-your-site/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>In my <a href="/en/2025/11/11/dns-cold-start-dilemma/">previous blog post</a>, I mentioned a core insight: <strong>For small sites with low traffic and geographically dispersed visitors, DNS cold start isn't an occasional "accident" but rather a passive "norm."</strong></p>
<p>For most webmasters, site traffic doesn't increase overnight, so our visitors will likely need to go through the complete DNS resolution process. In the previous post, I discussed switching to authoritative DNS servers that are physically closer to visitors to improve speed, but <strong>TLD (Top-Level Domain) Nameservers are beyond our control</strong> — specifically, the portion highlighted in red in the diagram below.</p>
<pre><code class="language-mermaid">sequenceDiagram
    autonumber
    participant User as User/Browser
    participant Local as Local DNS&#x3C;br>Recursive Resolver
    participant Root as Root Nameserver
    participant TLD as TLD Server
    participant Auth as Authoritative DNS Server

    Note over User,Auth: Complete DNS Recursive Query Process

    User->>Local: Query domain www.example.com
    Note over Local: Check cache (MISS)

    Local->>Root: Query .com TLD server
    Root-->>Local: Return .com TLD server address

    %% --- Highlighted section begins ---
    rect rgb(255, 235, 235)
        Note right of Local: ⚠️ Core focus of this article &#x3C;br> (TLD resolution latency)
        Local->>TLD: Query authoritative server for example.com

        Note left of TLD: Physical distance and Anycast capability&#x3C;br>determine whether hundreds of ms delay exists

        TLD-->>Local: Return authoritative server address for example.com
    end
    %% --- Highlighted section ends ---

    Local->>Auth: Query A record for www.example.com
    Auth-->>Local: Return IP address (e.g., 1.1.1.1)

    Note over Local: Cache result (TTL)

    Local-->>User: Return final IP address

    User->>Auth: Establish TCP connection / HTTP request
</code></pre>
<p>So, if you haven't purchased a domain yet but want to pursue the ultimate first-screen loading time like a true geek (even if you don't have many visitors), which TLD should you choose?</p>
<h2>Simple Test</h2>
<p>A simple method is to directly ping the TLD's nameserver to see how long it takes for the public DNS server requested by visitors to complete this segment of resolution.</p>
<p>Taking my domain zhul.in as an example, on Linux, you can use the <code>dig</code> command to obtain the Nameservers for the <code>in</code> TLD:</p>
<pre><code class="language-bash">dig NS in.
</code></pre>
<p><img src="https://static.031130.xyz/uploads/2025/11/24/b15e97068bd75.webp" alt="TLD Nameservers for .in"></p>
<p>Then you can pick any Nameserver (public DNS servers actually have a selection strategy based on historical performance) and directly ping that domain:</p>
<p><img src="https://static.031130.xyz/uploads/2025/11/24/bed48f4ed30ac.webp" alt="Latency to TLD Nameserver"></p>
<p>My network environment here is Hangzhou Mobile. If I run a DNS recursive server on my local network, this result represents the minimum time required for the red portion in the sequence diagram above (the DNS server needs additional time to process the request).</p>
<p>Using multi-location ping latency tests provided by some websites, we can infer in which countries or regions this TLD has deployed Anycast nodes. Below is the result provided by iplark.com.</p>
<p><img src="https://static.031130.xyz/uploads/2025/11/24/748d25e0beecc.webp" alt="Ping values for .in TLD Nameserver worldwide"></p>
<p>We can infer that the .in TLD Nameserver has deployed Anycast nodes in at least Japan, Hong Kong, USA, Canada, Europe, Australia, Brazil, India, South Africa, and other locations, while latency within mainland China is relatively high.</p>
<hr>
<p>As a comparison, we can use the same method to examine the Anycast nodes for the .cn domain's TLD Nameserver.</p>
<p><img src="https://static.031130.xyz/uploads/2025/11/24/9751de16b460b.webp" alt="Ping values for .cn TLD Nameserver worldwide"></p>
<p>According to tests by itdog.cn, the .cn domain's TLD Nameserver may only have nodes in Beijing.</p>
<h2>A More Advanced Testing Approach</h2>
<p>The above testing method is only a simple judgment method. In reality, many external factors affect DNS cold start resolution time:</p>
<ul>
<li>Peering exists between public DNS servers and TLD Nameservers, making their communication very fast</li>
<li>The TLD Nameserver has poor performance, requiring an additional tens of milliseconds to process your request</li>
<li>Among the TLD's several Nameservers, some are faster than others, and the public DNS server you use can select the faster one based on historical data</li>
<li>...</li>
</ul>
<p>Therefore, we need a testing approach based on actual DNS resolution requests.</p>
<p>Testing DNS cold start has always faced a dilemma — we don't manage public DNS servers and cannot log in to manually clear their cache, so only the first test result may be valid, with subsequent requests hitting the cache. However, this time we're testing the latency between the public DNS server and the TLD Nameserver. With Gemini's reminder, I realized we could test the resolution time for random, non-existent domains across different regions using public DNS, which can reflect differences between various TLDs.</p>
<p>So, the test code is below. You can use bash on common Linux systems to execute this code. Ensure you have the dig and shasum commands installed, and I recommend using tools like screen/tmux to run it in the background, as the entire testing process may last over ten minutes. If your network environment is within mainland China, I suggest changing the public DNS server in the code to 223.5.5.5 / 119.29.29.29, which should better match the usage environment of domestic visitors.</p>
<pre><code class="language-bash">#!/bin/bash

# ================= Configuration Section =================
# CSV filename
OUTPUT_FILE="dns_benchmark_results.csv"

# DNS server
DNS_SERVER="8.8.8.8"

# List of TLDs to test
# Includes: global generic (com), country code (cn, de), popular tech (io, xyz), and potentially slower suffixes
TLDS_TO_TEST=("com" "net" "org" "cn" "in" "de" "cc" "site" "ai" "io" "xyz" "top")

# Number of tests per TLD
SAMPLES=1000

# Interval between queries (seconds), to prevent being flagged as attack by DNS server
# 1000 times * 0.1s = 100s/TLD, total time approximately 15-20 minutes
SLEEP_INTERVAL=0.1
# ===========================================

# Initialize CSV file header
echo "TLD,Domain,QueryTime_ms,Status,Timestamp" > "$OUTPUT_FILE"

echo "============================================="
echo "   DNS TLD Latency Benchmark Tool"
echo "   Target DNS: $DNS_SERVER"
echo "   Samples per TLD: $SAMPLES"
echo "   Output File: $OUTPUT_FILE"
echo "============================================="
echo ""

# Define progress bar function
function show_progress {
    # Parameters: $1=current progress, $2=total, $3=current TLD, $4=current average time
    let _progress=(${1}*100/${2})
    let _done=(${_progress}*4)/10
    let _left=40-$_done

    # Build fill strings
    _fill=$(printf "%${_done}s")
    _empty=$(printf "%${_left}s")

    # \r returns cursor to beginning of line for refresh effect
    printf "\rProgress [${_fill// /#}${_empty// /-}] ${_progress}%% - Testing .${3} (Avg: ${4}ms) "
}

# Main loop
for tld in "${TLDS_TO_TEST[@]}"; do
    # Initialize statistics variables
    total_time_accum=0
    valid_count=0

    for (( i=1; i&#x3C;=${SAMPLES}; i++ )); do
        # 1. Generate random domain (prevent cache hits)
        # Use date +%N (nanoseconds) for sufficient randomness, compatible with Linux/macOS
        RAND_PART=$(date +%s%N | shasum | head -c 10)
        DOMAIN="test-${RAND_PART}.${tld}"
        TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")

        # 2. Execute query
        # +tries=1 +time=2: try once, timeout 2 seconds, avoid script hanging
        result=$(dig @${DNS_SERVER} ${DOMAIN} A +noall +stats +time=2 +tries=1)

        # Extract time (Query time: 12 msec)
        query_time=$(echo "$result" | grep "Query time" | awk '{print $4}')
        # Extract status (status: NXDOMAIN, NOERROR, etc.)
        status=$(echo "$result" | grep "status:" | awk '{print $6}' | tr -d ',')

        # 3. Data cleaning and recording
        if [[ -n "$query_time" &#x26;&#x26; "$query_time" =~ ^[0-9]+$ ]]; then
            # Write to CSV
            echo "${tld},${DOMAIN},${query_time},${status},${TIMESTAMP}" >> "$OUTPUT_FILE"

            # Update statistics
            total_time_accum=$((total_time_accum + query_time))
            valid_count=$((valid_count + 1))
            current_avg=$((total_time_accum / valid_count))
        else
            # Record failure/timeout
            echo "${tld},${DOMAIN},-1,TIMEOUT,${TIMESTAMP}" >> "$OUTPUT_FILE"
            current_avg="N/A"
        fi

        # 4. Display progress bar
        show_progress $i $SAMPLES $tld $current_avg

        sleep $SLEEP_INTERVAL
    done

    # New line after each TLD completes
    echo ""
    echo "✅ Completed .${tld} | Final Avg: ${current_avg} ms"
    echo "---------------------------------------------"
done

echo "🎉 All Done! Results saved to $OUTPUT_FILE"
</code></pre>
<h2>Test Results</h2>
<p><strong>Disclaimer: The following test results are for reference only, do not constitute any purchase recommendations, and only represent network conditions as of the test date (November 24, 2025), with no follow-up updates planned. DNS cold start has almost no impact on large sites; only small sites need to be concerned. In this test, all testing points within China used 223.5.5.5 as the DNS server, while overseas testing points used 8.8.8.8.</strong></p>
<table>
<thead>
<tr>
<th>Test Point/Latency (ms)</th>
<th>.com</th>
<th>.net</th>
<th>.org</th>
<th>.cn</th>
<th>.in</th>
<th>.de</th>
<th>.cc</th>
<th>.site</th>
<th>.ai</th>
<th>.io</th>
<th>.xyz</th>
<th>.top</th>
</tr>
</thead>
<tbody>
<tr>
<td>🇨🇳 Shanghai Tencent Cloud</td>
<td>438</td>
<td>429</td>
<td>470</td>
<td>30</td>
<td>535</td>
<td>353</td>
<td>476</td>
<td>454</td>
<td>367</td>
<td>485</td>
<td>444</td>
<td>43</td>
</tr>
<tr>
<td>🇨🇳 Beijing Tencent Cloud</td>
<td>425</td>
<td>443</td>
<td>469</td>
<td>17</td>
<td>350</td>
<td>420</td>
<td>466</td>
<td>647</td>
<td>582</td>
<td>461</td>
<td>559</td>
<td>9</td>
</tr>
<tr>
<td>🇭🇰 Hong Kong Yxvm</td>
<td>75</td>
<td>75</td>
<td>363</td>
<td>227</td>
<td>6</td>
<td>11</td>
<td>61</td>
<td>6</td>
<td>33</td>
<td>126</td>
<td>5</td>
<td>7</td>
</tr>
<tr>
<td>🇨🇳 Zhanghua (Taiwan) Hinet</td>
<td>90</td>
<td>87</td>
<td>128</td>
<td>213</td>
<td>59</td>
<td>38</td>
<td>76</td>
<td>37</td>
<td>73</td>
<td>94</td>
<td>36</td>
<td>47</td>
</tr>
<tr>
<td>🇯🇵 Osaka Vmiss</td>
<td>20</td>
<td>19</td>
<td>244</td>
<td>309</td>
<td>15</td>
<td>24</td>
<td>17</td>
<td>35</td>
<td>19</td>
<td>65</td>
<td>37</td>
<td>90</td>
</tr>
<tr>
<td>🇸🇬 Singapore Wap</td>
<td>6</td>
<td>9</td>
<td>139</td>
<td>398</td>
<td>6</td>
<td>10</td>
<td>7</td>
<td>17</td>
<td>7</td>
<td>110</td>
<td>17</td>
<td>66</td>
</tr>
<tr>
<td>🇺🇸 Los Angeles ColoCrossing</td>
<td>7</td>
<td>7</td>
<td>307</td>
<td>137</td>
<td>4</td>
<td>64</td>
<td>5</td>
<td>62</td>
<td>5</td>
<td>49</td>
<td>47</td>
<td>231</td>
</tr>
<tr>
<td>🇩🇪 Düsseldorf WIIT AG</td>
<td>16</td>
<td>17</td>
<td>288</td>
<td>82</td>
<td>75</td>
<td>15</td>
<td>14</td>
<td>24</td>
<td>66</td>
<td>73</td>
<td>24</td>
<td>306</td>
</tr>
<tr>
<td>🇦🇺 Sydney Oracle</td>
<td>33</td>
<td>31</td>
<td>12</td>
<td>338</td>
<td>7</td>
<td>13</td>
<td>121</td>
<td>7</td>
<td>10</td>
<td>9</td>
<td>7</td>
<td>191</td>
</tr>
</tbody>
</table>
<p>From the data above, we can see that .cn and .top are the fastest domain suffixes for resolution within mainland China among all tested options, but choosing .cn and .top means sacrificing resolution speed for visitors in other regions. Generic domain suffixes like .com, .net, and .org perform well in most regions globally, but their resolution speed within mainland China is relatively slow because they haven't deployed Anycast nodes on the mainland. In DNS cold start scenarios (if your site has few visitors, almost every visit is a cold start), the first screen loading time can increase by 500ms or more.</p>
<p><strong>As reminded by Showfom, a V2EX user (Showfom ), GoDaddy, acting as a registry operator, hosts Anycast nodes within mainland China for the nameservers of certain TLDs under its management—such as .one, .tv and .moe. Additionally, based on my own testing, the .you domain, operated by Amazon Registry Services, also has Anycast nodes within mainland China. You may verify this independently.</strong></p>
<p>You can click <a href="https://static.031130.xyz/bin/dns_benchmark_results_20251124.tar.zst">here</a> to download the complete test results CSV file for further analysis.</p>]]>
    </content>
    <published>2025-11-25T00:00:00.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Network"></category>
    <category term="DNS"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/11/11/dns-cold-start-dilemma/</id>
    <title>DNS Cold Start: The &quot;Stone of Sisyphus&quot; for Small Sites</title>
    <updated>2025-11-11T00:00:00.000Z</updated>
    <link href="https://zhul.in/2025/11/11/dns-cold-start-dilemma/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>When we talk about website performance, we usually focus on front-end rendering, lazy loading of resources, server response time (TTFB), and so on. However, before the user's browser can even begin to request content, there is a crucial part that is rarely mentioned in performance optimization: DNS resolution. For obscure small sites, a "DNS Cache Miss," or what I call a "DNS Cold Start," can become an unavoidable performance bottleneck. This is the "Stone of Sisyphus" mentioned in the title.</p>
<h2>The Myth's Metaphor: The Long Journey of DNS Resolution</h2>
<p>To understand the weight of this "stone," we must review the complete path of DNS resolution. This isn't a simple lookup, but a global relay race:</p>
<ol>
<li><strong>Starting Point: Public DNS Server</strong> — A user sends a request, and the public DNS server tries to find the answer in its cache.</li>
<li><strong>The First "Push": Root Servers</strong> — Cache Miss. The public DNS server is directed to one of the 13 root server groups worldwide.</li>
<li><strong>The Second Leg: TLD Servers</strong> — The root server points to the Top-Level Domain (TLD) server for the specific suffix (like <code>.com</code>).</li>
<li><strong>The Third Leg: Authoritative Servers</strong> — The TLD server points to the domain's final "steward"—the Authoritative DNS Server.</li>
<li><strong>The Finish Line:</strong> The authoritative server returns the final IP address, which is then returned to the user by the public DNS server.</li>
</ol>
<pre><code class="language-mermaid">sequenceDiagram
    participant User as User/Browser
    participant Local as Local DNS&#x3C;br>Recursive Resolver
    participant Root as Root Server
    participant TLD as TLD Server&#x3C;br>(.com, .org, etc.)
    participant Auth as Authoritative DNS Server

    Note over User,Auth: Full DNS Recursive Query Flow

    User->>Local: 1. Query domain&#x3C;br>[www.example.com](https://www.example.com)
    Note over Local: Check cache&#x3C;br>Record not found

    Local->>Root: 2. Query for .com TLD server
    Root-->>Local: 3. Return .com TLD server address

    Local->>TLD: 4. Query for example.com authoritative server
    TLD-->>Local: 5. Return example.com authoritative server address

    Local->>Auth: 6. Query for [www.example.com](https://www.example.com) A record
    Auth-->>Local: 7. Return IP address (e.g., 1.1.1.1)

    Note over Local: Cache result&#x3C;br>(based on TTL)

    Local-->>User: 8. Return final IP address

    Note over User,Auth: Subsequent process
    User->>Auth: 9. Establish TCP connection with IP&#x3C;br>Start HTTP request
</code></pre>
<p>For a <strong>first-time</strong> or <strong>long-unvisited</strong> request, this process means at least 4 network round-trips (RTT), and even more in cases involving CNAMEs. For large websites with perfect caching, this stone may have already been pushed to the hilltop by someone else. But for a small site, it is always at the foot of the hill, waiting for its Sisyphus.</p>
<h2>A Multiverse: The Mirror Maze of Anycast</h2>
<p>"Since the cost of a DNS cold start is so high, can I use a script to periodically visit my own site to 'warm up' the public DNS cache in advance?" — This was a solution I once envisioned.</p>
<p>However, this idea is often futile under the Anycast architecture of the modern internet.</p>
<p>The core concept of Anycast is: the same IP address exists at multiple global nodes simultaneously, and user requests are routed to the "closest" or "most optimal network path" node.</p>
<p>This means that public DNS servers like Google DNS (8.8.8.8), Cloudflare DNS (1.1.1.1), AliDNS (223.5.5.5), and Tencent DNS (119.29.29.29) are not backed by a single, centralized server, but a cluster of nodes distributed worldwide, routed dynamically.</p>
<p>Thus, the problem arises:</p>
<ul>
<li>The pre-warming script I run in Shanghai might hit the Shanghai node of 223.5.5.5;</li>
<li>But a visitor from Beijing will be routed to the Beijing node of 223.5.5.5;</li>
<li>The caches of these two nodes are <strong>independent and not shared with each other.</strong></li>
</ul>
<p>From a webmaster's perspective, the DNS cache is no longer a predictable entity but has splintered into a "mirror maze" of geographically isolated, ever-changing fragments.</p>
<p>Each visitor is at the base of a different hill, pushing their own stone, as if there are thousands of Sisyphuses in the world, walking their own paths alone.</p>
<h2>Uncontrollable Caching and the "Normalization" of Cold Starts</h2>
<p>This also explains why even if a small site is visited regularly by a script, real visitors might still experience significant DNS latency. Because "pre-warming" is only locally effective—it warms the cache of one Anycast node, not the entire network. And when the TTL expires or the cache is cleared by the public DNS server's algorithm (like LRU), this warmth quietly dissipates.</p>
<p>From a macro perspective, this traps "low-traffic sites" in a kind of fatalistic loop:</p>
<ol>
<li>Due to low traffic, cache hits are rare;</li>
<li>Due to cache misses, resolution time is high;</li>
<li>Due to high resolution time, first-paint performance is poor, and users visit less;</li>
<li>Due to fewer user visits, the cache is even harder to hit.</li>
</ol>
<p><strong>The cold start is no longer an occasional "accident," but a passive "new normal."</strong></p>
<h2>Can We Make the Stone Lighter? — Strategies to Mitigate Cold Start Impact</h2>
<p>The predicament of Sisyphus seems unsolvable, but we are not entirely powerless. While we cannot completely eliminate the DNS cold start, we can significantly reduce the weight of this stone through a series of strategies and shorten the time it takes to be pushed back up the hill after each time it rolls down.</p>
<h3>The Art of the Trade-off: Adjusting DNS TTL (Time-To-Live)</h3>
<p>TTL (Time-To-Live) is a critical value in a DNS record. It tells recursive resolvers (like public DNS, local caches) how long they can cache a record, although they might still be evicted by an LRU algorithm.</p>
<p>Lengthening the TTL can effectively increase the cache hit rate, reduce DNS cold starts, and keep Sisyphus's stone at the top of the hill for as long as possible.</p>
<p>But lengthening the TTL comes at the cost of flexibility: if you need to change the IP address for your domain for any reason, an overly long TTL might cause visitors to retrieve a stale IP address for a long time.</p>
<h3>Choosing a Faster "Messenger": Using the Right Authoritative DNS Server</h3>
<p>The "last mile" of DNS resolution—the time it takes to get from the public DNS server to your authoritative DNS server—is equally critical. If the Nameserver service your domain uses responds slowly, has few global nodes, or is too far from the public DNS server the visitor is querying, then the entire resolution chain will still be slowed down by this final link, even if the user's public DNS node is nearby.</p>
<p>If I were writing this blog post in English, I would just say to switch your Nameserver to a top-tier provider like Cloudflare or Google, and be done with it. These large companies offer free authoritative DNS hosting, have numerous nodes around the world, and are very professional and trustworthy in this regard.</p>
<p>But I am currently using Simplified Chinese. According to my blog's statistics, most of my readers are from mainland China, and the public DNS servers they query are most likely also deployed in mainland China. Whereas Cloudflare/Google Cloud DNS have no authoritative DNS server nodes in mainland China, which will slow things down. So <strong>if your visitors are primarily from mainland China, you might want to try AliYun (Alibaba Cloud) or Dnspod</strong>. Their main authoritative DNS server nodes are within mainland China, which, in theory, can reduce the communication time between the public DNS server and the authoritative DNS server.</p>
<h2>Conclusion: The Stone Pusher</h2>
<p>There has never been a perfect solution to the problem of DNS cold starts. It's like a fated "poetry of latency" within the internet's architecture—each visitor starts from their own network topology, pushing their own stone step-by-step along an invisible path, until they reach the summit of your server, in exchange for the first pixel lighting up on their screen.</p>
<p>For small sites, this may be the weight of destiny; but to understand it, optimize it, and monitor it, is how we, on this long uphill road, polish the stone's edges to make them smoother.</p>
<h2>See Also</h2>
<ul>
<li><a href="https://developers.google.com/speed/public-dns/docs/performance">Performance Benefits | Public DNS | Google for Developers</a></li>
<li><a href="https://falconcloud.ae/about/blog/how-do-dns-queries-affect-website-latency/">How do DNS queries affect website latency? - falconcloud.ae</a></li>
</ul>]]>
    </content>
    <published>2025-11-11T00:00:00.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="DNS"></category>
    <category term="Network"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/11/05/http-2-server-push-is-practically-obsolete/</id>
    <title>HTTP/2 Server Push Has Effectively &quot;Died&quot; - I Miss It</title>
    <updated>2025-11-05T00:00:00.000Z</updated>
    <link href="https://zhul.in/2025/11/05/http-2-server-push-is-practically-obsolete/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>I've been refactoring my blog recently, and while preparing for autumn recruitment and memorizing technical concepts, I came across HTTP/2 server push. I then attempted to configure HTTP/2 server push for my blog during deployment to further optimize first-screen loading speed.</p>
<h2>Why HTTP/2 Server Push Could Improve First-Screen Loading Speed</h2>
<p>As shown in the diagram below, in traditional HTTP/1.1, the browser first downloads <code>index.html</code> and completes the initial parsing, then retrieves the URLs for CSS/JS resources from the parsed data before making a second round of requests. After establishing TCP/TLS connections, it requires <strong>at least two RTTs to retrieve</strong> all the resources needed to fully render the page.</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant Browser
    participant Server

    Browser->>Server: GET /index.html
    Server-->>Browser: 200 OK + HTML
    Browser->>Server: GET /style.css
    Browser->>Server: GET /app.js
    Server-->>Browser: 200 OK + CSS
    Server-->>Browser: 200 OK + JS

    Note over Browser: Browser must wait for HTML to download&#x3C;br/>and parse before making subsequent requests,&#x3C;br/>increasing round-trip latency (RTT)
</code></pre>
<p>In HTTP/2's vision, the process would look like the diagram below. When the browser requests index.html, the server can simultaneously push CSS/JS resources to the client. This way, after establishing the TCP/TLS connection, it only needs <strong>one RTT</strong> to retrieve all resources needed for page rendering.</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant Browser
    participant Server

    Browser->>Server: GET /index.html
    Server-->>Browser: 200 OK + HTML
    Server-->>Browser: PUSH_PROMISE /style.css
    Server-->>Browser: PUSH_PROMISE /app.js
    Server-->>Browser: (Push) style.css + app.js content

    Note over Browser: Browser receives proactive resource push&#x3C;br/>Reduces request rounds and first-screen latency
</code></pre>
<p>To minimize subsequent requests in HTTP/1.1, front-end developers have tried numerous optimization techniques. As Sukka mentioned in "<a href="https://blog.skk.moe/post/http2-server-push/">Static Resource Delivery Optimization: HTTP/2 and Server Push</a>":</p>
<blockquote>
<p>The concepts of critical resources, critical rendering path, and critical request chain have existed for a long time. Asynchronous resource loading is old news: lazy-loading images, videos, iframes, and even lazy-loading CSS, JS, DOM, and lazy execution of functions. However, the approach to critical resource delivery hasn't changed much.</p>
</blockquote>
<p>HTTP/2 Server Push created a new resource delivery paradigm. CSS/JS and other resources don't need to be delivered along with HTML to reach the client within one RTT, and these resources can be cached by the browser without being constrained by HTML's shorter TTL.</p>
<h2>Initial Solution</h2>
<p>Having understood the advantages of HTTP/2 server push, I prepared to implement the optimization. My blog is purely static, using DNS for traffic splitting between domestic and international visitors: domestic traffic accesses a DMIT VPS with cmin2/9929 network optimization, served through Caddy; international traffic goes directly to Vercel, leveraging Amazon's CDN for global edge acceleration. The network architecture looks roughly like this:</p>
<pre><code class="language-mermaid">graph TD
    A[Blog Visitors] --> B[Initiate DNS Resolution]
    B --> C[DNSPod Service]
    C -->|Domestic Visitors: Smart Routing| D[Network-Optimized VPS]
    C -->|International Visitors: Smart Routing| E[Vercel Platform]
    D --> F[Caddy]
    F --> G[Return Blog Content to Domestic Visitors]
    E --> H[Return Blog Content to International Visitors]
</code></pre>
<p>Caddy can implement HTTP/2 server push through the <code>http.handlers.push</code> module. We can write simple push logic in the Caddyfile—no problem there. However, Vercel doesn't provide configuration options for HTTP/2 server push. Fortunately, since I have a static blog with low platform dependency, I considered migrating to Cloudflare Workers, <a href="https://brianli.com/cloudflare-workers-sites-http2-server-push/">which developers implemented five years ago</a>.</p>
<h2>Client Support Status</h2>
<p>Historically, mainstream browser engines (Chrome/Chromium, Firefox, Edge, Safari) widely supported server push technology.</p>
<p>In November 2020, Google announced <a href="https://groups.google.com/a/chromium.org/g/blink-dev/c/K3rYLvmQUBY/m/vOWBKZGoAQAJ">plans to remove server push functionality from Chrome's HTTP/2 and gQUIC (which later evolved into HTTP/3) implementations</a>.</p>
<p>In October 2022, Google announced <a href="https://developer.chrome.com/blog/removing-push/">plans to remove server push from Chrome</a>, citing poor real-world performance, low adoption rates, and better alternatives. Chrome 106 became the first version to disable server push by default.</p>
<p>On October 29, 2024, Mozilla released Firefox 132, <a href="https://www.firefox.com/en-US/firefox/132.0/releasenotes/">removing support for HTTP/2 server push due to "compatibility issues with multiple websites."</a></p>
<p>With this, mainstream browser support for HTTP/2 Server Push has completely ended. From initially being viewed as an innovative feature to "reduce round-trip latency and optimize first-screen loading," to its eventual complete deprecation, HTTP/2 push's lifecycle lasted only a few short years, becoming an important experiment in web performance optimization history.</p>
<h2>Alternative Solutions</h2>
<h3>1. HTTP 103 Early Hints</h3>
<p>103 Early Hints is the most direct "successor" to server push. It's an informational HTTP status code that allows the server to send an "early hint" response with Link headers before generating the complete HTML response (e.g., status code 200 OK).</p>
<p>This Link header can tell the browser: "Hey, I'm still preparing the main course (HTML), but you can start preparing the side dishes (CSS, JS) first." This way, the browser can utilize the server's "thinking time" to preemptively download critical resources or warm up connections to required origins, significantly reducing first-screen rendering time.</p>
<p>Compared to server push:</p>
<ul>
<li>Decision authority lies with the client: Early Hints are just "hints." The browser can decide whether to adopt them based on its own cache status, network conditions, and other factors. This solves server push's biggest pain point—servers cannot know about client caches, leading to redundant resource pushes.</li>
<li>Better compatibility: It's a lighter mechanism that's easier for intermediary proxy servers to understand and relay.</li>
</ul>
<p>103 Early Hints is very meaningful for dynamic blogs. Before backend computation, the 103 response can inform the browser about needed resources, allowing the browser to retrieve other resources first while waiting for the backend to return the final HTML. However, <strong>for static blogs</strong> like mine where <strong>everything is pre-built</strong>, it has <strong>absolutely no meaning</strong>. The gateway has enough time to send the 103 response that it could just directly send the HTML instead.</p>
<h3>2. Resource Hints: Preload &#x26; Prefetch</h3>
<p>Even before server push was deprecated, resource hints implemented through <code>&#x3C;link></code> tags were already common tools for front-end performance optimization. They declare resource loading hints in HTML, with the browser leading the entire process.</p>
<ul>
<li><code>&#x3C;link rel="preload"></code>: Tells the browser about resources that the current page will definitely use, requesting immediate high-priority loading without execution. For example, font files hidden deep in CSS or first-screen images dynamically loaded by JS. Through preload, you can ensure these critical resources are discovered and downloaded early, avoiding render blocking.</li>
<li><code>&#x3C;link rel="prefetch"></code>: Tells the browser about pages or resources the user might access in the future, requesting low-priority background downloads during browser idle time. For example, prefetching article page resources that users are most likely to click on the article list page, achieving near-"instant" navigation experience.</li>
</ul>
<p>Preload and Prefetch completely hand over resource loading control to developers and browsers. Through declarative methods, they allow fine-grained management of resource loading priority and timing. They are currently the most mature and widely applied resource preloading solutions, but still <strong>cannot escape the curse of 2 RTTs</strong>.</p>
<h2>Epilogue: An Elegy for an Idealist</h2>
<p>In the end, I couldn't configure HTTP/2 server push for my blog.</p>
<p><strong>HTTP/2 Server Push has effectively "died." I miss it.</strong></p>
<p>In an ideal model, when the browser requests HTML, the server conveniently pushes the CSS and JS needed for rendering along with it, cleanly compressing what would originally be at least two round trips (RTT) into one. This is such a direct, such an elegant solution—almost the "silver bullet" that front-end engineers dream of when facing first-screen rendering latency issues. Behind it lies an ambitious spirit: attempting to thoroughly solve the "critical request chain" latency problem from the server side, once and for all.</p>
<p>But the Web world is ultimately not an ideal laboratory. It's full of caches, returning users, and all kinds of network environments.</p>
<p>The greatest charm of server push lies in its "proactivity," and its greatest regret also stems precisely from this "proactivity." It cannot know whether the browser's cache already quietly holds that style.css file it's preparing to enthusiastically push. For the sake of the ultimate experience for a small portion of first-time visitors, it might waste precious bandwidth for more returning users.</p>
<p>The Web's evolution ultimately chose a more prudent path with a more collaborative spirit. It returned decision-making authority to the browser, which knows the situation best. The entire interaction changed from the server's "<strong>I push to you</strong>" to the server's "<strong>I suggest you fetch</strong>," with the browser making its own judgment. This may not be romantic enough, not extreme enough, but it's more universal and more robust.</p>
<p>So, I still miss that ambitious Server Push. It represented a pure pursuit of ultimate performance, a beautiful technical idealism. Although it has quietly faded from the historical stage, the dream about "speed" it pointed toward has long been inherited by 103 Early Hints and preload in a more mature, more balanced way.</p>
<h2>See Also</h2>
<ul>
<li><a href="https://developer.chrome.com/blog/removing-push">Remove HTTP/2 Server Push from Chrome  |  Blog  |  Chrome for Developers</a></li>
<li><a href="https://en.wikipedia.org/wiki/HTTP/2_Server_Push">HTTP/2 Server Push - Wikipedia</a></li>
<li><a href="https://blog.skk.moe/post/http2-server-push/">静态资源递送优化：HTTP/2 和 Server Push | Sukka's Blog</a></li>
<li><a href="https://caddyserver.com/docs/modules/http.handlers.push">Module http.handlers.push - Caddy Documentation</a></li>
<li><a href="https://brianli.com/cloudflare-workers-sites-http2-server-push/">How to Configure HTTP/2 Server Push on Cloudflare Workers Sites</a></li>
<li><a href="https://groups.google.com/a/chromium.org/g/blink-dev/c/K3rYLvmQUBY/m/vOWBKZGoAQAJ">Intent to Remove: HTTP/2 and gQUIC server push</a></li>
</ul>]]>
    </content>
    <published>2025-11-05T00:00:00.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="HTTP"></category>
    <category term="Caddy"></category>
    <category term="Network"></category>
    <category term="HTML"></category>
    <category term="Vercel"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/10/20/nuxt-content-v3-z-array-query-challenge/</id>
    <title>Array Field Filtering Challenges and Performance Optimization in Nuxt Content v3</title>
    <updated>2025-10-20T13:52:59.000Z</updated>
    <link href="https://zhul.in/2025/10/20/nuxt-content-v3-z-array-query-challenge/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>Nuxt Content is a powerful module in the Nuxt ecosystem for handling Markdown, YAML, and other content types. Recently, while migrating my blog from Hexo to <strong>Nuxt v4 + Nuxt Content v3</strong>, I encountered a tricky issue: the v3 default query API <strong>does not directly provide</strong> support for "contains" (<code>$contains</code>) operations on array fields.</p>
<p>For example, here's the Front Matter of the blog post I'm currently writing:</p>
<pre><code class="language-markdown">---
title: Array Field Filtering Challenges in Nuxt Content v3
date: 2025-10-20 21:52:59
sticky:
tags:
- Nuxt
- Nuxt Content
- JavaScript
---
</code></pre>
<p>My goal was to create a <strong>Tag page</strong> that lists all articles containing a specific tag (e.g., 'Nuxt').</p>
<h2>The Convenience of v2 and the Limitations of v3</h2>
<p>In Nuxt Content v2, data was stored based on the file system, and the query approach abstracted file content with a syntax similar to <strong>MongoDB's JSON document queries</strong>. We could easily use the <code>$contains</code> method to retrieve all articles with the "Nuxt" tag:</p>
<pre><code class="language-typescript">const tag = decodeURIComponent(route.params.tag as string)

const articles = await queryContent('posts')
  .where({ tags: { $contains: tag } })  // ✅ MongoDB-style queries in v2
  .find()
</code></pre>
<p>However, when using <strong>Nuxt Content v3's <code>queryCollection</code> API</strong>, we naturally try to use the <code>.where()</code> method for filtering:</p>
<pre><code class="language-typescript">const tag = decodeURIComponent(route.params.tag as string)

const { data } = await useAsyncData(`tag-${tag}`, () =>
    queryCollection('posts')
        .where(tag, 'in', 'tags')  // ❌ This will error because the first parameter must be a field name
        .order('date', 'DESC')
        .select('title', 'date', 'path', 'tags')
        .all()
)
</code></pre>
<p>Unfortunately, this approach doesn't work. The <code>.where()</code> method signature requires the field name as the first parameter: <code>where(field: keyof Collection | string, operator: SqlOperator, value?: unknown)</code>.</p>
<p>Since Nuxt Content v3 <strong>uses SQLite as its underlying local database</strong>, all queries must follow SQL-like syntax. If the design doesn't provide built-in operators for array fields (such as an SQL equivalent of <code>$contains</code>), the eventual solution often feels somewhat "awkward."</p>
<h2>Initial Implementation: Sacrificing Performance with "Fetch All"</h2>
<p>Following a "migrate quickly, optimize later" approach, I wrote the following code:</p>
<pre><code class="language-typescript">// Initial implementation: fetch all and filter with JS
const allPosts = (
    await useAsyncData(`tag-${route.params.tag}`, () =>
        queryCollection('posts')
            .order('date', 'DESC')
            .select('title', 'date', 'path', 'tags')
            .all()
    )
).data as Ref&#x3C;Post[]>

const Posts = computed(() => {
    return allPosts.value.filter(post =>
        typeof post.tags?.map === 'function'
            ? post.tags?.includes(decodeURIComponent(route.params.tag as string))
            : false
    )
})
</code></pre>
<p>While this method met the requirements, it came with obvious performance costs: <strong>bloated <code>_payload.json</code> file sizes.</strong></p>
<p>In Nuxt projects, <code>_payload.json</code> stores dynamic data such as <code>useAsyncData</code> results. With the fetch-all approach, <strong>every Tag page</strong> loads a <code>_payload.json</code> containing information for all articles, causing data redundancy. Many tag pages only need data for one or two articles but are forced to load information for all articles, severely impacting performance.</p>
<p><img src="https://static.031130.xyz/uploads/2025/10/20/a748878c03c64.webp" alt="The tags directory occupies 2.9MiB, the largest of all directories"></p>
<p><img src="https://static.031130.xyz/uploads/2025/10/20/8ef786d873da1.webp" alt="_payload.json"></p>
<h2>A Clever Solution: Leveraging SQLite's Storage Characteristics for Optimization</h2>
<p>To reduce the query results returned by <code>useAsyncData</code>, I searched through Nuxt Content's GitHub Discussions and found <a href="https://github.com/nuxt/content/discussions/2955">a "clever" solution proposed during v3.alpha.8</a>.</p>
<p>Since Nuxt Content v3 uses an SQLite database, the <strong><code>tags</code> array originally defined in Front Matter (via <code>z.array()</code>) is ultimately stored as a JSON string</strong> in the database (you can view the exact format in the <code>.nuxt/content/sql_dump.txt</code> file).</p>
<p><img src="https://static.031130.xyz/uploads/2025/10/20/b70036c55bb29.webp" alt="sql_dump.txt"></p>
<p>This means we can leverage SQLite's <strong>string operation</strong> features by using the <strong><code>LIKE</code> operator with wildcards</strong> to perform array containment filtering, essentially querying whether the JSON string contains a specific substring:</p>
<pre><code class="language-typescript">const tag = decodeURIComponent(route.params.tag as string)

const { data } = await useAsyncData(`tag-${route.params.tag}`, () =>
    queryCollection('posts')
        .where('tags', 'LIKE', `%"${tag}"%`)
        .order('date', 'DESC')
        .select('title', 'date', 'path', 'tags')
        .all()
)
</code></pre>
<p>Below are the file sizes after optimization and regeneration - the reduction is quite significant:</p>
<ul>
<li>Tags directory size: 2.9MiB → 1.4MiB</li>
<li>Individual _payload.json size: 23.1KiB → 1.01 KiB</li>
</ul>
<p>Through this method, we successfully pushed the query logic down to the database layer, avoided unnecessary full data transfers, significantly reduced the size of <code>_payload.json</code> in individual directories, and achieved performance optimization.</p>
<p><img src="https://static.031130.xyz/uploads/2025/10/20/007e72e7b476d.webp" alt="Tags directory size reduction"></p>
<p><img src="https://static.031130.xyz/uploads/2025/10/20/17ba3ccbbdf9e.webp" alt="_payload.json"></p>
<h2>See Also</h2>
<p><a href="https://content.nuxt.com/docs/utils/query-collection#wherefield-keyof-collection-string-operator-sqloperator-value-unknown">queryCollection - Nuxt Content</a></p>
<p><a href="https://github.com/nuxt/content/discussions/2955">How do you query <code>z.array()</code> fields (e.g. tags) in the latest nuxt-content module (v3.alpha.8) · nuxt/content · Discussion #2955</a></p>]]>
    </content>
    <published>2025-10-20T13:52:59.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Nuxt"></category>
    <category term="Nuxt Content"></category>
    <category term="JavaScript"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/10/16/how-s-mozilla-crlite-going-now/</id>
    <title>Post-OCSP Era: How Browsers Address New Certificate Revocation Challenges</title>
    <updated>2025-10-16T07:38:50.000Z</updated>
    <link href="https://zhul.in/2025/10/16/how-s-mozilla-crlite-going-now/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>In August 2023, the CA/Browser Forum passed a vote eliminating the requirement for publicly trusted CAs like Let's Encrypt to maintain OCSP servers.</p>
<p>In July 2024, Let's Encrypt published a <a href="https://letsencrypt.org/2024/07/23/replacing-ocsp-with-crls">blog post</a> disclosing its plans to shut down its OCSP server.</p>
<p>In December of the same year, Let's Encrypt released <a href="https://letsencrypt.org/2024/12/05/ending-ocsp">a timeline for shutting down its OCSP server</a>, with the following key dates:</p>
<ul>
<li>January 30, 2025 - Let's Encrypt will no longer accept new certificate issuance requests containing the OCSP Must-Staple extension, unless your account has previously requested such certificates</li>
<li>May 7, 2025 - Newly issued certificates from Let's Encrypt will include CRL URLs but will no longer contain OCSP URLs, and all new certificate issuance requests with the OCSP Must-Staple extension will be rejected</li>
<li>August 6, 2025 - Let's Encrypt will shut down its OCSP servers</li>
</ul>
<p><strong>Let's Encrypt is the world's largest free SSL certificate authority, and this move marks our gradual transition into the post-OCSP era.</strong></p>
<h2>OCSP's Dilemma: The Trade-off Between Performance and Privacy</h2>
<p>Behind Let's Encrypt's decision lies long-accumulated dissatisfaction with OCSP (Online Certificate Status Protocol). OCSP, as a method for real-time certificate validity queries, had a beautiful initial vision: when a browser accesses a website, it can send a brief request to the <strong>CA's (Certificate Authority's)</strong> OCSP server, asking whether the certificate is still valid. This seemed much more efficient than downloading a massive <strong>CRL (Certificate Revocation List)</strong>.</p>
<p>However, OCSP has exposed numerous flaws in practical application:</p>
<p>First is the <strong>performance issue</strong>. Although individual requests are small, when millions of users simultaneously access websites, OCSP servers must handle massive amounts of real-time queries. This not only creates enormous server pressure for CAs but also increases latency for users accessing websites. If OCSP servers respond slowly or even crash, browsers may interrupt connections due to inability to confirm certificate status, or have to "turn a blind eye" for the sake of user experience, both of which undermine OCSP's security.</p>
<p>More seriously is the <strong>privacy issue</strong>. Each OCSP query essentially reports the user's browsing behavior to the CA. This means the CA can know when a user accessed which website. While OCSP queries themselves don't contain personally identifiable information, by combining this information with data like IP addresses, CAs can completely build profiles of users' browsing habits. For privacy-conscious users and developers, this "silent surveillance" is unacceptable. <strong>Even if CAs intentionally don't retain this information, regional laws may force CAs to collect it.</strong></p>
<p>Furthermore, OCSP has <strong>security flaws</strong> in its design. Due to concerns about connection timeouts affecting user experience, browsers typically default to a soft-fail mechanism: if they cannot connect to the OCSP server, they choose to allow rather than block the connection. Attackers can exploit this by blocking communication between the client and OCSP server, causing queries to always timeout, thus easily bypassing certificate status verification.</p>
<h3>OCSP Stapling</h3>
<p>Based on these flaws, we have the OCSP stapling solution, which <a href="/2024/11/19/firefox-is-the-only-mainstream-brower-doing-online-certificate-revocation-checks/#OCSP-%E8%A3%85%E8%AE%A2-OCSP-stapling">I covered in last year's blog post, feel free to review</a>.</p>
<h3>OCSP Must-Staple</h3>
<p>OCSP Must-Staple is an extension option when applying for SSL certificates. This extension tells the browser: if it recognizes this extension in the certificate, it must not send query requests to the certificate authority, but should obtain the stapled copy during the handshake phase. If a valid copy cannot be obtained, the browser should refuse the connection.</p>
<p>This feature gave browser developers the courage for hard-fail, but before OCSP faded from history, Let's Encrypt seemed to be the only mainstream CA supporting this extension, and this feature was not widely used.</p>
<p><del>I originally didn't want to introduce this feature (because literally no one used it), but considering this thing is about to be buried, let's erect a monument for it on the Chinese internet,</del> for more information, refer to <a href="https://letsencrypt.org/2024/12/05/ending-ocsp#must-staple">Let's Encrypt's blog</a>.</p>
<h2>Chromium's Solution: Taking Only a Ladle from Three Thousand Waters</h2>
<p>OCSP's privacy and performance issues are no secret, and browser vendors have long begun their own explorations. In 2012, Chrome disabled CRLs and OCSP checks by default, turning to its own self-designed certificate verification mechanism.</p>
<p>It's well known that revocation lists can be extremely large. If browsers needed to download and parse a complete global revocation list, it would be a performance disaster (Mozilla's team mentioned in <a href="https://hacks.mozilla.org/2025/08/crlite-fast-private-and-comprehensive-certificate-revocation-checking-in-firefox/">this year's blog</a> that file sizes from downloading 3000 active CRLs would reach 300MB). Through analyzing historical data, the Chromium team discovered that most revoked certificates belong to a few high-risk categories, such as the certificate authority (CA) itself being compromised, or certificates from certain large websites being revoked. Based on this insight, CRLSets adopts the following strategy:</p>
<ol>
<li><strong>Tiered Revocation</strong>: Chromium doesn't download all revoked certificate information, but rather the Google team maintains a streamlined list containing the "most important" revocation information. This list is regularly updated and pushed to users through Chrome browser updates.</li>
<li><strong>Streamlined Efficiency</strong>: This list is very small in size, currently about 600KB. It contains certificates that would cause large-scale security incidents if abused, such as CA intermediate certificates, or certificates from some well-known websites (like Google, Facebook).</li>
<li><strong>Sacrificing Partial Security</strong>: The disadvantage of this approach is also obvious—it cannot cover all certificate revocation situations. For an ordinary website's revoked certificate, CRLSets likely cannot detect it. According to Mozilla's blog this year, CRLSets only contains 1%-2% of unexpired revoked certificate information.</li>
</ol>
<p>While CRLSets is an "imperfect" solution, it has found a balance between performance and usability. It ensures basic security for users accessing mainstream websites while avoiding the performance and privacy overhead brought by OCSP. For Chromium, rather than pursuing an OCSP solution that's difficult to implement perfectly in reality, it's better to concentrate efforts on solving the most urgent security threats.</p>
<h2>Firefox's Solution: From CRLs to CRLite</h2>
<p>Unlike Chromium's "taking only a ladle" strategy, Firefox developers have been searching for a solution that can guarantee comprehensiveness while solving performance issues.</p>
<p>To solve this problem, Mozilla proposed an innovative solution: <strong>CRLite</strong>. CRLite's design philosophy is to use data structures like <strong>hash functions and Bloom filters</strong> to compress massive certificate revocation lists into a <strong>compact, downloadable, and easily locally verifiable format</strong>.</p>
<p>CRLite's working principle can be simply summarized as:</p>
<ol>
<li><strong>Data Compression</strong>: CAs periodically generate lists of all their revoked certificates.</li>
<li><strong>Server Processing</strong>: Mozilla's servers collect these lists and use techniques like cryptographic hash functions and Bloom filters to <strong>encode</strong> all revoked certificate information into a very compact data structure.</li>
<li><strong>Client Verification</strong>: The browser downloads this compressed file, and when accessing a website, it only needs to perform hash calculations on the certificate locally, then query this local file to quickly determine whether the certificate has been revoked.</li>
</ol>
<p>Compared to CRLSets, CRLite's advantage is that it can achieve <strong>comprehensive coverage of all revoked certificates</strong> while maintaining an <strong>extremely small size</strong>. More importantly, it <strong>completes verification entirely locally</strong>, which means the browser <strong>doesn't need to send requests to any third-party servers</strong>, thus completely solving OCSP's privacy problem.</p>
<p>Firefox's current strategy performs incremental updates to CRLite data every 12 hours, with daily downloads of approximately 300KB; it performs a full snapshot sync every 45 days, with downloads of approximately 4MB.</p>
<p>Mozilla has opened their data dashboard, where you can find recent CRLite data sizes: <a href="https://yardstick.mozilla.org/dashboard/snapshot/c1WZrxGkNxdm9oZp7xVvGUEFJCELfApN">https://yardstick.mozilla.org/dashboard/snapshot/c1WZrxGkNxdm9oZp7xVvGUEFJCELfApN</a></p>
<p>Starting with Firefox Desktop version 137 released on April 1, 2025, Firefox began gradually replacing OCSP validation with CRLite; on August 19 of the same year, Firefox Desktop 142 officially deprecated OCSP verification for DV certificates.</p>
<p>CRLite has become the core solution for Firefox's future certificate revocation verification, representing a comprehensive pursuit of performance, privacy, and security.</p>
<h2>Outlook for the Post-OCSP Era</h2>
<p>With major CAs like Let's Encrypt shutting down OCSP services, the OCSP era is rapidly drawing to a close. We can see that browser vendors have already begun exploring more efficient and secure alternative solutions.</p>
<ul>
<li><strong>Chromium</strong>, with its CRLSets solution, has achieved a pragmatic balance between <strong>performance and critical security guarantees</strong>.</li>
<li><strong>Firefox</strong>, through the technological innovation of <strong>CRLite</strong>, attempts to find the optimal solution among <strong>comprehensiveness, privacy, and performance</strong>.</li>
</ul>
<p>What these solutions have in common is: <strong>transforming certificate revocation verification from real-time online queries (OCSP) to localized verification</strong>, thereby avoiding OCSP's inherent performance bottlenecks and privacy risks.</p>
<p>In the future, the certificate revocation ecosystem will no longer rely on a single, centralized OCSP server. Instead, a more diverse, distributed, and intelligent new era is arriving. <strong>OCSP as a technology may gradually be phased out, but the core security problem of "certificate revocation" that it attempted to solve will forever remain a focus of browsers and the network security community.</strong></p>
<h2>See Also</h2>
<ul>
<li><a href="https://hacks.mozilla.org/2025/08/crlite-fast-private-and-comprehensive-certificate-revocation-checking-in-firefox/">CRLite: Fast, private, and comprehensive certificate revocation checking in Firefox - Mozilla Hacks - the Web developer blog</a></li>
<li><a href="https://www.feistyduck.com/newsletter/issue_121_the_slow_death_of_ocsp">The Slow Death of OCSP | Feisty Duck</a></li>
<li><a href="https://github.com/mozilla/crlite">mozilla/crlite: Compact certificate revocation lists for the WebPKI</a></li>
<li><a href="https://letsencrypt.org/2025/08/06/ocsp-service-has-reached-end-of-life">OCSP Service Has Reached End of Life - Let's Encrypt</a></li>
<li><a href="https://letsencrypt.org/2024/12/05/ending-ocsp">Ending OCSP Support in 2025 - Let's Encrypt</a></li>
<li><a href="https://letsencrypt.org/2024/07/23/replacing-ocsp-with-crls">Intent to End OCSP Service - Let's Encrypt</a></li>
<li><a href="https://www.chromium.org/Home/chromium-security/crlsets/">CRLSets - The Chromium Projects</a></li>
<li><a href="https://www.pcworld.com/article/474296/google_chrome_will_no_longer_check_for_revoked_ssl_certificates_online-2.html">Google Chrome Will No Longer Check for Revoked SSL Certificates Online | PCWorld</a></li>
<li><a href="https://www.zdnet.com/article/chrome-does-certificate-revocation-better/">Chrome does certificate revocation better | ZDNET</a></li>
<li><a href="https://www.hats-land.com/WIP/2025-technical-and-analysis-of-mainstream-clientbrowser-certificate-revocation-verification-mechanism.html">主流客户端/浏览器证书吊销验证机制技术对与分析 | 帽之岛, Hat's Land</a></li>
<li><a href="https://blog.gslin.org/archives/2025/02/02/12239/ocsp-%E7%9A%84%E6%B7%A1%E5%87%BA/">OCSP 的淡出… – Gea-Suan Lin's BLOG</a></li>
</ul>]]>
    </content>
    <published>2025-10-16T07:38:50.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="SSL"></category>
    <category term="Firefox"></category>
    <category term="Web PKI"></category>
    <category term="OCSP"></category>
    <category term="CRLSets"></category>
    <category term="CRLite"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/09/05/first-try-of-github-action-self-hosted-runner/</id>
    <title>First Experience with GitHub Action Self-hosted Runner: Love is Not Easy</title>
    <updated>2025-09-04T21:54:17.000Z</updated>
    <link href="https://zhul.in/2025/09/05/first-try-of-github-action-self-hosted-runner/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>In August of this year, a GitHub Organization I'm part of frequently triggered CI during private project development, exhausting the <a href="https://docs.github.com/en/get-started/learning-about-github/githubs-plans#github-free-for-organizations">2,000 minutes of monthly Action quota</a> provided by GitHub for the Free Plan (shared across all private repositories, public repositories don't count). After reviewing the CI workflow setup, which seemed reasonable, I needed to find alternative solutions to provide more generous resources. This led me to explore the <a href="https://docs.github.com/en/actions/concepts/runners/self-hosted-runners">GitHub Action Self-hosted Runner</a> mentioned in the article title.</p>
<p>Compared to GitHub's official runners, Self-hosted Runners offer several key advantages:</p>
<ul>
<li>Unlimited Action runtime for private repositories</li>
<li>Ability to configure more powerful hardware computing power and memory</li>
<li>Access to internal network environments, facilitating communication with intranet/LAN devices</li>
</ul>
<h2>Configuration and Installation</h2>
<p>Since I wasn't sure about the network environment requirements, I directly chose an idle Hong Kong VPS for this test, with specs of 4 cores, 4GB RAM, 80GB disk, and 1Gbps bandwidth. Aside from somewhat lacking disk read/write performance, everything else was maxed out.</p>
<p>The configuration of Self-hosted Runner itself is quite straightforward and clear, following the official guidelines without any issues.</p>
<p><img src="https://static.031130.xyz/uploads/2025/09/05/7c0475cdb1aa9.webp" alt=""></p>
<p>All three mainstream platforms are supported, and if utilized properly, it should cover a range of needs including iPhone app packaging.</p>
<p><img src="https://static.031130.xyz/uploads/2025/09/05/96ff7cb263da1.webp" alt=""></p>
<p>Looking at the runner installation file I received, version 2.328.0, the compressed package is around 220MB and includes built-in node20 and node24 runtime environments, two versions each.</p>
<p><img src="https://static.031130.xyz/uploads/2025/09/05/f775e3bcd2cdc.webp" alt=""></p>
<p><img src="https://static.031130.xyz/uploads/2025/09/05/d0d4fe4611a40.webp" alt=""></p>
<p>After executing config.sh, a svc.sh file appears in the current directory, which can be used to leverage systemd for process management and similar needs.</p>
<p><img src="https://static.031130.xyz/uploads/2025/09/05/43c6b19038def.webp" alt=""></p>
<p>Refreshing the web page again shows that the Self-hosted Runner is now online.</p>
<p><img src="https://static.031130.xyz/uploads/2025/09/05/6dad15beff900.webp" alt=""></p>
<h2>Specifying Actions to Use Your Own Runner</h2>
<p>This step is simple - just change the runs-on field in the original Action's yml file:</p>
<pre><code class="language-diff">jobs:
  run:
+    runs-on: self-hosted
-    runs-on: ubuntu-latest
</code></pre>
<h2>Real-World Testing</h2>
<p>When I excitedly switched the CI workflow from GitHub's official runner to the self-hosted runner, problems quickly surfaced, and this is the main reason I "can't love it." The issues were concentrated in the <code>setup-python</code> GitHub Action Flow, which is maintained by GitHub officially, showing an error that version 3.12 wasn't found.</p>
<p><img src="https://static.031130.xyz/uploads/2025/09/05/1c93947170a85.webp" alt=""></p>
<p>In GitHub's official virtual environment, these Actions prepare the specified version of the development environment for us. For example, <code>uses: actions/setup-python</code> combined with <code>with: python-version: '3.12'</code> automatically installs and configures Python 3.12.x in the environment. I had grown accustomed to this and considered it an "out-of-the-box" feature. However, on Self-hosted Runner, the situation is somewhat different. The setup-python <a href="https://github.com/actions/setup-python/blob/main/docs/advanced-usage.md#using-setup-python-with-a-self-hosted-runner">documentation</a> states:</p>
<blockquote>
<p>Python distributions are only available for the same <a href="https://github.com/actions/runner-images#available-images">environments</a> that GitHub Actions hosted environments are available for. If you are using an unsupported version of Ubuntu such as <code>19.04</code> or another Linux distribution such as Fedora, <code>setup-python</code> may not work.</p>
</blockquote>
<p>The setup-python Action <strong>only supports the same operating systems used by GitHub Actions</strong>, and my VPS's Debian is not supported, hence this error, which also sealed Debian's fate.</p>
<h2>The Root Cause: Misunderstanding Self-hosted Runner</h2>
<p>I subconsciously believed that Self-hosted Runner merely transferred the computational cost from GitHub's servers to local infrastructure, and that official standard actions like <code>actions/setup-python</code> should elegantly download, install, and configure everything I need, just like in GitHub-hosted Runners. However, <strong>the essence of Self-hosted Runner is simply receiving tasks from GitHub and executing instructions within the current operating system environment</strong>, without guaranteeing consistency with the runtime environment provided by GitHub's official Runners.</p>
<p>Self-hosted Runner is not an out-of-the-box "service," but rather <strong>"infrastructure" that you need to personally manage</strong>. You are responsible for server installation, configuration, security updates, dependency management, disk cleanup, and a series of operational tasks. It's more suitable for teams or individuals with advanced CI/CD requirements: such as heavy CI/CD consumers, teams needing specific hardware (like ARM, GPU) for builds, or enterprises whose CI workflows deeply depend on internal network resources. For ordinary developers like me who simply want to provide more local computing resources to gain more Action runtime, the operational mental burden it brings seems a bit heavy.</p>]]>
    </content>
    <published>2025-09-04T21:54:17.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Github"></category>
    <category term="Github Action"></category>
    <category term="CI/CD"></category>
    <category term="Experience"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/08/11/dns-resolve-time-destroyed-my-optimization-for-pic-cdn/</id>
    <title>How DNS Resolution Latency Destroyed My Image Hosting Optimization</title>
    <updated>2025-08-10T16:06:40.000Z</updated>
    <link href="https://zhul.in/2025/08/11/dns-resolve-time-destroyed-my-optimization-for-pic-cdn/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>Last summer, I spent considerable time setting up an image hosting solution for my blog. The core goal was <strong>geo-distributed DNS resolution</strong> to ensure fast image loading for visitors both inside and outside China. The technical approach seemed perfect—until recently, when fellow bloggers reported slow image loading on first visits. That's when I discovered the real problem.</p>
<p><img src="https://static.031130.xyz/uploads/2025/08/11/26306b2a483ba.webp" alt=""></p>
<p><strong>955 milliseconds of DNS resolution time!</strong> This number shocked me. After visitors opened my blog, they had to wait nearly a full second just to determine the image server location—completely negating the benefits of CDN optimization.</p>
<blockquote>
<p><strong>Context Note:</strong> In China, there's a significant network divide between domestic and international internet access due to the Great Firewall. Many Chinese websites use geo-DNS to serve domestic users from servers within China and international users from overseas servers, optimizing for both speed and accessibility.</p>
</blockquote>
<h2>Why Didn't I Notice Earlier?</h2>
<p>The main culprit was <strong>DNS caching</strong>. It remembers resolution results for subsequent visits, making both my local tests and return visitor tests appear normal. It wasn't until users reported issues—combined with my recent review of DNS resolution flows for job interview prep (recursive queries, authoritative queries, root domains, TLDs, etc.)—that I pinpointed the problem: <strong>first-visit DNS resolution latency</strong>.</p>
<h2>DNS Resolution Flow Analysis</h2>
<p>Let's examine what happens when a visitor accesses <code>static.031130.xyz</code>:</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant User as Visitor Browser
    participant Local as Local DNS
    participant CF as Cloudflare&#x3C;br/>(Foreign Authority)
    participant DP as DNSPod&#x3C;br/>(Domestic Authority)
    participant CDN as CDN Node

    User->>Local: Request static.031130.xyz
    Local->>CF: Query 031130.xyz authority
    Note over Local,CF: Cross-border query, high latency
    CF->>Local: CNAME: cdn-cname.zhul.in
    Local->>DP: Query zhul.in authority
    DP->>Local: CNAME: small-storage-cdn.b0.aicdn.com
    Local->>DP: Query aicdn.com
    DP->>Local: CNAME: nm.aicdn.com
    Local->>DP: Query final IP
    DP->>Local: Return CDN IP
    Local->>User: Return resolution result
    User->>CDN: Connect and download images
</code></pre>
<p>Here's the problem: <strong>the first two query steps point to Cloudflare's authoritative servers overseas</strong>. For domestic Chinese users, even though the final resolved CDN node is within China, the cross-border DNS queries are enough to cripple the first-visit experience. That 955ms latency is mostly spent communicating with foreign DNS servers.</p>
<blockquote>
<p><strong>Context Note:</strong> DNSPod is Tencent's DNS service provider, widely used in China for its domestic infrastructure and reliability.</p>
</blockquote>
<h2>Optimization Solutions</h2>
<p>To address this issue, I implemented three measures:</p>
<h3>1. DNS Prefetch</h3>
<p>Added to the blog's HTML <code>&#x3C;head></code> section:</p>
<pre><code class="language-html">&#x3C;link rel="dns-prefetch" href="//static.031130.xyz">
</code></pre>
<p>This way, the browser performs DNS resolution for the image hosting domain while rendering the page. By the time images actually need to load, the DNS results may already be ready.</p>
<h3>2. Extended TTL</h3>
<p>Increased the TTL (Time To Live) value for the <code>static.031130.xyz</code> CNAME record from a few minutes to several hours or even a day. This allows local DNS servers to cache results longer, enabling subsequent users to directly use cached results and skip authoritative queries.</p>
<h3>3. Migrate Authoritative DNS (Core Solution)</h3>
<p>Migrated the <strong>authoritative DNS servers</strong> for the <code>031130.xyz</code> domain from Cloudflare to DNSPod in China:</p>
<pre><code class="language-mermaid">graph TB
    subgraph "Before Optimization"
        A1[Visitor] --> B1[Local DNS]
        B1 --> C1[Cloudflare Authority&#x3C;br/>Overseas]
        C1 --> D1[DNSPod Authority&#x3C;br/>Domestic]
        D1 --> E1[CDN Node]
        style C1 fill:#ffcccc
    end
</code></pre>
<pre><code class="language-mermaid">graph TB
    subgraph "After Optimization"
        A2[Visitor] --> B2[Local DNS]
        B2 --> D2[DNSPod Authority&#x3C;br/>Domestic]
        D2 --> E2[CDN Node]
        style D2 fill:#ccffcc
    end
</code></pre>
<p>Benefits after migration:</p>
<ul>
<li>When recursive DNS queries <code>031130.xyz</code>, it directly reaches DNSPod in China with fast response</li>
<li>DNSPod directly returns <code>static.031130.xyz</code> -> <code>small-storage-cdn.b0.aicdn.com</code> without intermediate hops</li>
<li>The entire DNS resolution chain completes domestically, dramatically reducing first-visit latency</li>
</ul>
<h2>Optimization Results</h2>
<p>While DNS caching makes testing difficult, after migrating the authoritative DNS + adjusting TTL + adding prefetch, first-visit DNS resolution time dropped to an acceptable range.</p>
<h2>Lessons Learned</h2>
<ol>
<li>
<p><strong>DNS Location Matters</strong>: When optimizing for multiple regions, the geographic location of authoritative DNS servers significantly impacts first-visit latency. Prioritize using domestic authoritative servers for your target audience.</p>
</li>
<li>
<p><strong>First Visits Are Critical</strong>: While caching helps subsequent visits, the first-visit experience directly affects user impression. Make good use of <code>dns-prefetch</code> and reasonable TTL settings.</p>
</li>
<li>
<p><strong>Monitoring and Feedback Are Important</strong>: Local test environments often benefit from caching; real first-visit experiences need to be discovered through monitoring and user feedback.</p>
</li>
</ol>
<h2>Important Warning: Beware of CNAME Flattening</h2>
<p>If you need geo-distributed resolution to connect visitors to the nearest CDN node, <strong>absolutely avoid CNAME Flattening</strong>.</p>
<h3>What Is CNAME Flattening?</h3>
<p>When an authoritative DNS server (like Cloudflare) sees a CNAME record, it proactively queries the final IP address of the target domain and then directly returns the IP instead of the CNAME.</p>
<h3>Why Does This Cause Problems?</h3>
<p>Geo-distributed resolution (GeoDNS) is implemented at the authoritative DNS server level. When the authoritative server performs CNAME flattening, it queries the target domain's IP from its own location. If the authoritative DNS is in the US, it gets the optimal US node IP, then returns this IP to all regional queries, including Chinese users. This completely breaks your domestic CDN IP strategy configured for Chinese users.</p>
<pre><code class="language-mermaid">graph LR
    subgraph "CNAME Flattening Problem"
        A[Chinese User] --> B[Cloudflare Authority&#x3C;br/>US Node]
        B --> C[Query Target CNAME]
        C --> D[Return US CDN IP]
        D --> A
        style D fill:#ffcccc
    end
</code></pre>
<h3>The Correct Approach</h3>
<p>Honestly use CNAME to point to another domain that supports GeoDNS (such as <code>static.031130.xyz</code> -> <code>cdn-cname.zhul.in</code>, with the latter doing geo-distributed resolution on DNSPod). Only this way can you ensure the routing strategy executes correctly.</p>
<p>If you need geo-distributed resolution functionality, <strong>do not</strong> enable CNAME Flattening (or similar features like ALIAS, ANAME, etc.) on related domains.</p>
<hr>
<blockquote>
<p><strong>Author's Note:</strong> This article reflects the unique challenges of optimizing web services for the Chinese internet landscape, where the network infrastructure divide between domestic and international access requires careful DNS and CDN configuration to provide optimal performance for all users.</p>
</blockquote>]]>
    </content>
    <published>2025-08-10T16:06:40.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="CDN"></category>
    <category term="Image Hosting"></category>
    <category term="DNS"></category>
    <category term="Network"></category>
    <category term="Cloudflare"></category>
    <category term="Dnspod"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/07/13/vue-markdown-render-improvement-2/</id>
    <title>Vue Markdown Rendering Optimization in Practice (Part 2): Farewell to DOM Manipulation, Embrace AST and Functional Rendering</title>
    <updated>2025-07-12T16:01:35.000Z</updated>
    <link href="https://zhul.in/2025/07/13/vue-markdown-render-improvement-2/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<h2>Recap: When <code>morphdom</code> Meets Vue</h2>
<p>In <a href="/2025/07/12/vue-markdown-render-improvement-1/">the previous article</a>, we embarked on a performance optimization journey for Markdown rendering. From the most primitive full refresh with <code>v-html</code>, to block-by-block updates, we eventually brought out the "ultimate weapon" - <code>morphdom</code>. By directly comparing and manipulating the real DOM, it updates the view with minimal cost, perfectly solving the performance bottleneck and interaction state loss issues in real-time rendering.</p>
<p>However, a fundamental problem has always existed: in Vue's territory, bypassing Vue's Virtual DOM and Diff algorithm to let a third-party library directly "operate" on the real DOM always feels somewhat "unorthodox." It's like introducing an old master with a hammer and wrench for manual repairs in a precision automated factory. Although the job is done well, it always feels like it disrupts the original workflow and isn't "Vue" enough.</p>
<p>So, is there a more elegant, more "native" approach that allows us to enjoy precise updates while fully integrating with Vue's ecosystem?</p>
<p>With this question in mind, I consulted friends in frontend development groups.</p>
<blockquote>
<p>If you're building a renderer, your approach isn't the best practice. Each time you update, you generate the full virtual HTML, then optimize performance by subtracting from the HTML. However, the incremental part of each update is clear - why not directly use this incremental part for addition? The incremental part cannot be directly obtained through markdown-it, but a better approach is to transform at this step: first parse the Markdown structure, then use Vue's dynamic rendering capabilities to generate the DOM. This way, DOM reuse can leverage Vue's own abilities. — <a href="https://site.j10c.cc/">j10c</a></p>
</blockquote>
<blockquote>
<p>You can use unified with the remark-parse plugin to parse markdown strings into AST, then render based on the AST using render functions. — bii &#x26; <a href="https://github.com/nekomeowww">nekomeowww</a></p>
</blockquote>
<h2>New Approach: From "String Conversion" to "Structured Rendering"</h2>
<p>Our previous solutions, whether <code>v-html</code> or <code>morphdom</code>, shared a core approach:</p>
<p><code>Markdown String</code> -> <code>markdown-it</code> -> <code>HTML String</code> -> <code>Browser/morphdom</code> -> <code>DOM</code></p>
<p>The problem with this pipeline is that starting from the <code>HTML String</code> step, we lose the <strong>original structural information</strong> of Markdown. We get a bunch of unstructured text that Vue cannot understand its internal logic and can only swallow it whole.</p>
<p>The new approach transforms the process into:</p>
<p><code>Markdown String</code> -> <code>AST (Abstract Syntax Tree)</code> -> <code>Vue VNodes (Virtual Nodes)</code> -> <code>Vue</code> -> <code>DOM</code></p>
<h3>What is AST?</h3>
<p><strong>AST (Abstract Syntax Tree)</strong> is a structured representation of source code or markup language. It parses a long string of text into a hierarchical tree-like object. For Markdown, a level-1 heading becomes a node with <code>type: 'heading', depth: 1</code>, a paragraph becomes a node with <code>type: 'paragraph'</code>, and the text within the paragraph becomes the <code>children</code> of the <code>paragraph</code> node.</p>
<p>Once we convert Markdown into an AST, we essentially have a "structural blueprint" of the entire document. We're no longer facing a pile of ambiguous HTML strings, but rather a clear, programmable JavaScript object.</p>
<h3>Our New Tools: unified and remark</h3>
<p>To implement the <code>Markdown -> AST</code> conversion, we introduce the <code>unified</code> ecosystem.</p>
<ul>
<li><strong><a href="https://github.com/unifiedjs/unified">unified</a></strong>: A powerful content processing engine. Think of it as an assembly line where raw text is the raw material, and you process it through parsing, transformation, and serialization by adding different "plugins."</li>
<li><strong><a href="https://github.com/remarkjs/remark">remark-parse</a></strong>: A <code>unified</code> plugin specifically responsible for parsing Markdown text into AST (specifically in <a href="https://github.com/syntax-tree/mdast">mdast</a> format).</li>
</ul>
<h2>Step 1: Parse Markdown into AST</h2>
<p>First, we need to install the dependencies:</p>
<pre><code class="language-bash">npm install unified remark-parse
</code></pre>
<p>Then, we can easily convert a Markdown string into an AST:</p>
<pre><code class="language-javascript">import { unified } from 'unified'
import remarkParse from 'remark-parse'

const markdownContent = '# Hello, AST!\n\nThis is a paragraph.'

// Create a processor instance
const processor = unified().use(remarkParse)

// Parse Markdown content
const ast = processor.parse(markdownContent)

console.log(JSON.stringify(ast, null, 2))
</code></pre>
<p>Running the above code will give us a JSON object like the following, which is our coveted AST:</p>
<pre><code class="language-json">{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "children": [
        {
          "type": "text",
          "value": "Hello, AST!",
          "position": { ... }
        }
      ],
      "position": { ... }
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "text",
          "value": "This is a paragraph.",
          "position": { ... }
        }
      ],
      "position": { ... }
    }
  ],
  "position": { ... }
}
</code></pre>
<h2>Step 2: From AST to Vue VNodes</h2>
<p>Having obtained the AST, the next step is to actually "construct" this "structural blueprint" into a user-visible interface. In Vue's world, the blueprint for describing UI is the Virtual Node (VNode), and the <code>h()</code> function (i.e., hyperscript) is the brush for creating VNodes.</p>
<p>Our task is to write a render function that can recursively traverse the AST and generate corresponding VNodes for each node type (<code>heading</code>, <code>paragraph</code>, <code>text</code>, etc.).</p>
<p>Here's a simple render function implementation:</p>
<pre><code class="language-javascript">function renderAst(node) {
  if (!node) return null
  switch (node.type) {
    case 'root':
      return h('div', {}, node.children.map(renderAst))
    case 'paragraph':
      return h('p', {}, node.children.map(renderAst))
    case 'text':
      return node.value
    case 'emphasis':
      return h('em', {}, node.children.map(renderAst))
    case 'strong':
      return h('strong', {}, node.children.map(renderAst))
    case 'inlineCode':
      return h('code', {}, node.value)
    case 'heading':
      return h('h' + node.depth, {}, node.children.map(renderAst))
    case 'code':
      return h('pre', {}, [h('code', {}, node.value)])
    case 'list':
      return h(node.ordered ? 'ol' : 'ul', {}, node.children.map(renderAst))
    case 'listItem':
      return h('li', {}, node.children.map(renderAst))
    case 'thematicBreak':
      return h('hr')
    case 'blockquote':
      return h('blockquote', {}, node.children.map(renderAst))
    case 'link':
      return h('a', { href: node.url, target: '_blank' }, node.children.map(renderAst))
    default:
      // Other unimplemented types
      return h('span', { }, `[${node.type}]`)
  }
}
</code></pre>
<h2>Step 3: Encapsulate Vue Component</h2>
<p>Integrating the above logic, we can build a Vue component. Given the characteristic of directly generating VNodes, using a functional component or explicit <code>render</code> function is most appropriate.</p>
<pre><code class="language-vue">&#x3C;template>
  &#x3C;component :is="VNodeTree" />
&#x3C;/template>

&#x3C;script setup>
import { computed, h, shallowRef, watchEffect } from 'vue'
import { unified } from 'unified'
import remarkParse from 'remark-parse'

const props = defineProps({
  mdText: {
    type: String,
    default: ''
  }
})

const ast = shallowRef(null)
const parser = unified().use(remarkParse)

watchEffect(() => {
  ast.value = parser.parse(props.mdText)
})

// AST render function (same as renderAst function above)
function renderAst(node) { ... }

const VNodeTree = computed(() => renderAst(ast.value))

&#x3C;/script>
</code></pre>
<p>Now it can be used like a regular component:</p>
<pre><code class="language-vue">&#x3C;template>
  &#x3C;MarkdownRenderer :mdText="markdownContent" />
&#x3C;/template>

&#x3C;script setup>
import { ref } from 'vue'
import MarkdownRenderer from './MarkdownRenderer.vue'

const markdownContent = ref('# Hello Vue\n\nThis is rendered via AST!')
&#x3C;/script>
</code></pre>
<h2>Huge Advantages of the AST Approach</h2>
<p>After switching to the AST track, we gained unprecedented superpowers:</p>
<ol>
<li>
<p><strong>Native Integration, Excellent Performance</strong>: We no longer need the brute force refresh of <code>v-html</code>, nor do we need "external help" like <code>morphdom</code>. All updates are handled by Vue's own Diff algorithm, which is not only highly performant but also fully aligned with Vue's design philosophy - truly "one of our own."</p>
</li>
<li>
<p><strong>High Flexibility and Extensibility</strong>: AST, as a programmable JavaScript object, provides a solid foundation for customized processing:</p>
<ul>
<li><strong>Element Replacement</strong>: Native elements (like <code>&#x3C;h2></code>) can be seamlessly replaced with custom Vue components (like <code>&#x3C;FancyHeading></code>), only requiring adjustments to the corresponding <code>case</code> logic in the <code>renderAst</code> function.</li>
<li><strong>Logic Injection</strong>: Attributes like <code>target="_blank"</code> and <code>rel="noopener noreferrer"</code> can be conveniently added to external links <code>&#x3C;a></code>, or lazy-load components can wrap images <code>&#x3C;img></code> - such operations are easy to implement at the AST level.</li>
<li><strong>Ecosystem Integration</strong>: Fully leverage <code>unified</code>'s rich plugin ecosystem (such as <code>remark-gfm</code> for GFM syntax support, <code>remark-prism</code> for code highlighting), only requiring the introduction of corresponding plugins in the processor chain (<code>.use(pluginName)</code>).</li>
</ul>
</li>
<li>
<p><strong>Separation of Concerns</strong>: Parsing logic (<code>remark</code>), rendering logic (<code>renderAst</code>), and business logic (Vue components) are clearly separated, resulting in clearer code structure and stronger maintainability.</p>
</li>
<li>
<p><strong>Type Safety and Predictability</strong>: Compared to manipulating strings or raw HTML, rendering logic based on structured AST is easier to type-check and reason about.</p>
</li>
</ol>
<h2>Conclusion: Evolution from Functional Implementation to Architectural Optimization</h2>
<p>Reviewing the optimization journey:</p>
<ul>
<li><strong>v-html</strong>: Simple implementation, but with performance and security concerns.</li>
<li><strong>Block updates</strong>: Alleviated some performance issues, but the solution had limitations.</li>
<li><strong>morphdom</strong>: Effectively improved performance and user experience, but existed in isolation from Vue's core mechanisms.</li>
<li><strong>AST + Functional Rendering</strong>: Returns to Vue's native paradigm, providing an ultimate solution with excellent performance, flexibility, and maintainability.</li>
</ul>
<p>By adopting AST, we not only solved specific technical challenges but, more importantly, achieved a paradigm shift - from result-oriented programming (HTML strings) to process and structure-oriented programming (AST). This enables us to dive deep into the essence of content, thereby achieving precise control over the rendering process.</p>
<p>This optimization practice from "full refresh" to "structured rendering" is not only a technical process of performance improvement but also a systematic exploration of deeply understanding modern frontend engineering thinking. The final Markdown rendering solution achieved high standards in performance, functionality, and architectural elegance.</p>]]>
    </content>
    <published>2025-07-12T16:01:35.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Vue.js"></category>
    <category term="Markdown"></category>
    <category term="AST"></category>
    <category term="JavaScript"></category>
    <category term="Web"></category>
    <category term="unified"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/07/12/vue-markdown-render-improvement-1/</id>
    <title>Vue Markdown Rendering Optimization in Practice (Part 1): From Brute Force Refresh, Chunked Updates to Morphdom&apos;s Elegant Transformation</title>
    <updated>2025-07-12T12:48:56.000Z</updated>
    <link href="https://zhul.in/2025/07/12/vue-markdown-render-improvement-1/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<h2>Background</h2>
<p>In a recent AI project I took over, I needed to implement a ChatGPT-like conversational interface. The core workflow is: the backend continuously pushes AI-generated Markdown text fragments to the frontend via SSE (Server-Sent Events) protocol. The frontend is responsible for dynamically receiving and concatenating these Markdown fragments, ultimately rendering and displaying the complete Markdown text in real-time on the user interface.</p>
<p>Markdown rendering isn't an uncommon requirement, especially in today's world flooded with LLM-based products. Unlike the React ecosystem, which has a popular third-party library with 14k+ stars—<a href="https://github.com/remarkjs/react-markdown">react-markdown</a>—Vue doesn't seem to have an actively maintained Markdown rendering library with significant popularity (at least 2k+ stars). <a href="https://github.com/cloudacy/vue-markdown-render#readme">cloudacy/vue-markdown-render</a> last released a year ago but has only 103 stars as of this writing; <a href="https://github.com/miaolz123/vue-markdown">miaolz123/vue-markdown</a> has 2k stars but its last commit was 7 years ago; <a href="https://github.com/zhaoxuhui1122/vue-markdown">zhaoxuhui1122/vue-markdown</a> is even archived.</p>
<h2>First Version: Simple and Brute Force v-html</h2>
<p>After a quick survey, I confirmed that the Vue ecosystem indeed lacks a robust Markdown rendering library. Since there's no ready-made solution, let's build our own!</p>
<p>Based on most articles and LLM recommendations, we first adopted the markdown-it third-party library to convert Markdown to HTML strings, then passed it through v-html.</p>
<p><strong>PS:</strong> We assume here that the Markdown content is trusted (e.g., generated by our own AI). If the content comes from user input, you must use libraries like <code>DOMPurify</code> to prevent XSS attacks and avoid "opening a window" in your website!</p>
<p>Example code:</p>
<pre><code class="language-vue">&#x3C;template>
  &#x3C;div v-html="renderedHtml">&#x3C;/div>
&#x3C;/template>

&#x3C;script setup>
import { computed, onMounted, ref } from 'vue';
import MarkdownIt from 'markdown-it';

const markdownContent = ref('');
const md = new MarkdownIt();

const renderedHtml = computed(() => md.render(markdownContent.value))

onMounted(() => {
  // markdownContent.value = await fetch() ...
})
&#x3C;/script>
</code></pre>
<h2>Evolution: Chunked Updates for Markdown</h2>
<p>While the above approach achieves basic rendering, it has obvious flaws in real-time update scenarios: <strong>every time a new Markdown fragment is received, the entire document triggers a full re-render</strong>. Even if only the last line is new content, the entire document's DOM gets completely replaced. This leads to two core problems:</p>
<ol>
<li><strong>Performance bottleneck:</strong> As Markdown content grows, the overhead of <code>markdown-it</code> parsing and DOM reconstruction increases linearly.</li>
<li><strong>Lost interaction state:</strong> Full refresh wipes out the user's current operation state. Most notably, if you've selected some text, one refresh and the selection is gone!</li>
</ol>
<p>To solve these two problems, <a href="https://juejin.cn/post/7480900772386734143">we found a chunked rendering solution online</a>—splitting Markdown by two consecutive newlines (<code>\n\n</code>) into chunks. This way, each update only re-renders the last new chunk, while previous chunks reuse the cache. The benefits are obvious:</p>
<ul>
<li>If the user has selected text in a previous chunk, the selection state won't be lost on the next update (because that chunk hasn't changed).</li>
<li>Fewer DOM nodes need re-rendering, naturally improving performance.</li>
</ul>
<p>The adjusted code looks like this:</p>
<pre><code class="language-vue">&#x3C;template>
  &#x3C;div>
    &#x3C;div
      v-for="(block, idx) in renderedBlocks"
      :key="idx"
      v-html="block"
      class="markdown-block"
    >&#x3C;/div>
  &#x3C;/div>
&#x3C;/template>

&#x3C;script setup>
import { ref, computed, watch } from 'vue'
import MarkdownIt from 'markdown-it'

const markdownContent = ref('')
const md = new MarkdownIt()

const renderedBlocks = ref([])
const blockCache = ref([])

watch(
  markdownContent,
  (newContent, oldContent) => {
    const blocks = newContent.split(/\n{2,}/)
    // Only re-render the last block, reuse cache for others
    // Handle cases where blocks decrease or increase
    blockCache.value.length = blocks.length
    for (let i = 0; i &#x3C; blocks.length; i++) {
      // Only render the last one, or new blocks
      if (i === blocks.length - 1 || !blockCache.value[i]) {
        blockCache.value[i] = md.render(blocks[i] || '')
      }
      // Reuse other blocks directly
    }
    renderedBlocks.value = blockCache.value.slice()
  },
  { immediate: true }
)

onMounted(() => {
  // markdownContent.value = await fetch() ...
})
&#x3C;/script>
</code></pre>
<h2>Ultimate Weapon: Precise Updates with morphdom</h2>
<p>While chunked rendering solves most problems, it struggles with Markdown lists. Because in Markdown syntax, list items are usually separated by only one newline, so the entire list is treated as one large chunk. Imagine a list with hundreds of items—even if only the last item is updated, the entire list chunk must be completely re-rendered, and we're back to square one.</p>
<h3>What is morphdom?</h3>
<p><code>morphdom</code> is a JavaScript library of only 5KB (gzipped), with the core functionality of: <strong>receiving two DOM nodes (or HTML strings), calculating the minimal DOM operations needed, and "morphing" the first node into the second, rather than directly replacing it</strong>.</p>
<p>Its working principle is similar to Virtual DOM's Diff algorithm, but <strong>operates directly on the real DOM</strong>:</p>
<ol>
<li>Compare tag names, attributes, text content, etc., between old and new DOM;</li>
<li>Execute only add/delete/modify operations on the differences (like modifying text, updating attributes, moving node positions);</li>
<li>Unchanged DOM nodes are completely preserved, including their event listeners, scroll positions, selection states, etc.</li>
</ol>
<p>Markdown treats lists as a whole, but in the generated HTML, each list item (<code>&#x3C;li></code>) is independent! When <code>morphdom</code> updates later list items, it ensures earlier list items remain untouched, naturally preserving their state.</p>
<p>Isn't this exactly what we've been dreaming of? Real-time Markdown updates while maximally preserving user operation state, and saving a bunch of unnecessary DOM operations!</p>
<h3>Example Code</h3>
<pre><code class="language-vue">&#x3C;template>
  &#x3C;div ref="markdownContainer" class="markdown-container">
    &#x3C;div id="md-root">&#x3C;/div>
  &#x3C;/div>
&#x3C;/template>

&#x3C;script setup>
import { nextTick, ref, watch } from 'vue';
import MarkdownIt from 'markdown-it';
import morphdom from 'morphdom';

const markdownContent = ref('');
const markdownContainer = ref(null);
const md = new MarkdownIt();

const render = () => {
  if (!markdownContainer.value.querySelector('#md-root')) return;

  const newHtml = `&#x3C;div id="md-root">` + md.render(markdownContent.value) + `&#x3C;/div>`

  morphdom(markdownContainer.value, newHtml, {
    childrenOnly: true
  });
}

watch(markdownContent, () => {
    render()
});

onMounted(async () => {
  // Wait for DOM to be mounted
  await nextTick()
  render()
})
&#x3C;/script>

</code></pre>
<h3>Seeing is Believing: Demo Comparison</h3>
<p>The iframe below contains a comparison demo showing the performance differences between different approaches.</p>
<p><strong>Tip:</strong> If you're using Chromium-based browsers like Chrome or Edge, open Developer Tools (DevTools), find the "Rendering" tab, and check "Paint flashing." This way you can visually see which parts get repainted with each update—fewer repainted areas mean better performance!</p>
<p><img src="https://static.031130.xyz/uploads/2025/07/12/d5721c40fb076.webp" alt=""></p>
<iframe src="https://static.031130.xyz/demo/morphdom-vs-markdown-chunk.html" width="100%" height="500" allowfullscreen loading="lazy"></iframe>
<h2>Progress So Far</h2>
<p>From the initial "brute force full refresh," to the "smarter chunked updates," and now to the "surgical precision of <code>morphdom</code> updates," we've progressively eliminated unnecessary rendering overhead, ultimately creating a Markdown real-time rendering solution that's both fast and preserves user state.</p>
<p>However, using <code>morphdom</code>, a third-party library to directly manipulate DOM in Vue components, feels somewhat... not quite "Vue"? While it solves the core performance and state issues, playing this way in the Vue world feels a bit like taking the back door.</p>
<p><strong>Next Episode Preview:</strong> In the next article, we'll discuss whether there's a more elegant, more "native" solution in the Vue world to achieve precise Markdown updates. Stay tuned!</p>]]>
    </content>
    <published>2025-07-12T12:48:56.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Vue.js"></category>
    <category term="Markdown"></category>
    <category term="JavaScript"></category>
    <category term="Web"></category>
    <category term="HTML"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/07/05/node-sass-migration-to-dart-sass/</id>
    <title>Migration from node-sass to dart-sass: A Troubleshooting Chronicle</title>
    <updated>2025-07-05T09:57:02.000Z</updated>
    <link href="https://zhul.in/2025/07/05/node-sass-migration-to-dart-sass/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<h2>Update Goals</h2>
<ul>
<li>node-sass -> sass (dart-sass)</li>
<li>Minimize impact, avoid updating other dependency versions unless necessary</li>
<li>Based on the above two conditions, see if we can upgrade the node.js version</li>
</ul>
<h2>Reasons to Abandon node-sass</h2>
<ul>
<li><a href="https://sass-lang.com/blog/libsass-is-deprecated/">node-sass has been deprecated, dart-sass is the officially recommended successor by Sass</a></li>
<li>node-sass installation on Windows is very troublesome, requiring both Python 2 and Microsoft Visual C++ on the development machine during npm installation</li>
<li>When installing node-sass, resources need to be pulled from GitHub, which has a low success rate in certain network environments</li>
</ul>
<h2>Current Project Dependency Versions</h2>
<ul>
<li><code>node@^12</code></li>
<li><code>vue@^2</code></li>
<li><code>webpack@^3</code></li>
<li><code>vue-loader@^14</code></li>
<li><code>sass-loader@^7.0.3</code></li>
<li><code>node-sass@^4</code></li>
</ul>
<h2>Update Strategy</h2>
<h3>node.js</h3>
<p>Webpack officially doesn't provide the maximum node version supported by webpack 3, and even if webpack officially supports it, related webpack plugins may not. Therefore, whether the node version can be updated can only be determined through testing. Fortunately, although this project's CI/CD runs on node 12, I've been using node 14 for daily development, so I'll take this opportunity to upgrade the node version to 14.</p>
<h3>webpack, sass-loader</h3>
<p>The webpack version is currently in a "ticking time bomb" state of not updating unless necessary. Based on the current webpack 3 limitation, the maximum supported sass-loader version is ^7 (sass-loader's <a href="https://github.com/webpack-contrib/sass-loader/blob/v8.0.0/CHANGELOG.md">8.0.0 version changelog</a> explicitly states that version 8.0.0 requires webpack 4.36.0).</p>
<p>If sass-loader@^7 in the project supports using dart-sass, then there's no need to update sass-loader, and consequently no need to update webpack; otherwise, webpack would need to be updated to 4, and then determine the sass-loader version accordingly.</p>
<p>So does it support it or not? I found this package.json snippet on the <a href="https://www.webpackjs.com/loaders/sass-loader/">webpack official documentation page introducing sass-loader</a>:</p>
<pre><code class="language-json">{
  "devDependencies": {
    "sass-loader": "^7.2.0",
    "sass": "^1.22.10"
  }
}
</code></pre>
<p>This proves that at least sass-loader@7.2.0 already supported dart-sass, so the webpack version can stay at ^3, and sass-loader can temporarily stay at version 7.0.3. If there are issues later, it can be updated to the latest version 7.3.1 in the ^7 range.</p>
<h3>dart-sass</h3>
<p>I couldn't find the maximum sass version supported by sass-loader@^7. GitHub Copilot confidently told me:</p>
<blockquote>
<p><strong>Official documentation quote:</strong></p>
<blockquote>
<p>sass-loader@^7.0.0 requires node-sass >=4.0.0 or sass >=1.3.0, &#x3C;=1.26.5.</p>
</blockquote>
<p><strong>Suggestion:</strong></p>
<ul>
<li>If you need to use a higher version of <code>sass</code>, please upgrade to <code>sass-loader</code> 8 or higher.</li>
</ul>
</blockquote>
<p>However, I couldn't find any trace of this text on the internet. Moreover, the last version in sass's ~1.26 range is 1.26.11, not 1.26.5. <a href="https://docs.npmjs.com/about-semantic-versioning">According to common npm versioning principles</a>, releases that only change the patch version while keeping the major and minor versions the same generally only contain bugfixes without breaking changes. It's unlikely that updating from 1.26.5 to 1.26.11 would suddenly become incompatible with sass-loader 7, so this is more likely an AI hallucination or limited training data.</p>
<p>Out of caution, I ultimately decided to use the last version of sass 1.22 mentioned in the webpack official documentation, which is 1.22.12.</p>
<h2>Analysis Complete, Let's Update</h2>
<h3>Step 1: Uninstall node-sass, Install sass@^1.22.12</h3>
<pre><code class="language-bash">npm uninstall node-sass
npm install sass@^1.22.12
</code></pre>
<h3>Step 2: Update webpack Configuration (Optional)</h3>
<pre><code class="language-diff">module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.(scss|sass)$/,
        use: [
          'style-loader',
          'css-loader',
          {
            loader: 'sass-loader',
+            options: {
+                // In fact, this line doesn't need to be added in most sass-loader versions, sass-loader can automatically detect whether it's sass or node-sass
+                implementation: require('sass')
+              },
            },
          },
        ],
      },
    ],
  },
};
</code></pre>
<h3>Step 3: Batch Replace /deep/ Syntax with ::v-deep</h3>
<p>Because <a href="https://chromestatus.com/feature/4964279606312960">the /deep/ syntax was deprecated in 2017</a>, /deep/ became an unsupported deep selector. node-sass, with its excellent error tolerance, could continue to provide compatibility, but dart-sass doesn't support this syntax. So we need to batch replace the /deep/ syntax with ::v-deep, which, although abandoned in Vue's subsequent RFC, is still effectively supported to this day.</p>
<pre><code class="language-bash"># Something like this, using VSCode's batch replace also works
sed -i 's#\s*/deep/\s*# ::v-deep #g' $(grep -rl '/deep/' .)
</code></pre>
<h3>Step 4: Fix Other Sass Syntax Errors</h3>
<p>During the migration, I found some non-standard syntax in the project. node-sass, with its excellent robustness, silently forced parsing, while dart-sass can't handle this rough work. Therefore, these syntax errors need to be manually fixed based on compilation errors. I encountered two types:</p>
<pre><code class="language-diff">// Extra colon
.foo {
-  color:: #fff;
+  color: #fff;
}

// :nth-last-child without specified number
.bar {
-  &#x26;:nth-last-child() {
+  &#x26;:nth-last-child(1) {
      margin-bottom: 0;
  }
}
</code></pre>
<h2>Pitfalls</h2>
<h3>::v-deep Styles Not Taking Effect</h3>
<p>After updating dependencies, everything seemed fine at first glance, so I pushed to the test environment. Less than a day later, a colleague called me - the ::v-deep deep selector wasn't working?</p>
<p>Trying my luck, GPT gave the following answer:</p>
<blockquote>
<p>In the <strong>Vue 2 + vue-loader + Sass</strong> combination, <strong>this syntax is correct</strong>, <strong>provided that your build toolchain supports the <code>::v-deep</code> syntax</strong> (such as <code>vue-loader@15</code> and above + <code>sass-loader</code>).</p>
</blockquote>
<p>Although I still haven't verified why updating to vue-loader@15 is required to use ::v-deep syntax, after updating vue-loader, the ::v-deep syntax did work. While writing this article, I found some clues that might explain this issue:</p>
<ol>
<li>
<p>The vue-loader <a href="https://vue-loader-v14.vuejs.org/en/features/scoped-css.html#deep-selectors">version 14 official documentation</a> simply doesn't have examples of ::v-deep syntax. <a href="https://github.com/vuejs/vue-loader/commit/2585d254fc774386a898887467fbdd30eb864b53">This example was only added after vue-loader 15.7.0 was released</a>.</p>
</li>
<li>
<p>Someone mentioned in a vue-cli GitHub Issue comment:</p>
<blockquote>
<p><code>::v-deep</code> implemented in @vue/component-compiler-utils v2.6.0, should work after you reinstall the deps.</p>
</blockquote>
<p>And vue-loader only <a href="https://github.com/vuejs/vue-loader/commit/e32cd0e4372fcc6f13b6c307402713807516d71c#diff-7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519">added @vue/component-compiler-utils to its dependencies in version 15.0.0-beta.1</a>, and didn't <a href="https://github.com/vuejs/vue-loader/commit/c359a38db0fbb4135fc97114baec3cd557d4123a">update its @vue/component-compiler-utils version to the required ^3.0.0 until vue-loader 15.7.1</a>.</p>
</li>
</ol>
<p>Can we upgrade to vue-loader 16 or even 17? No. The <a href="https://github.com/vuejs/vue-loader/releases/tag/v16.1.2">vue-loader v16.1.2 changelog</a> clearly states:</p>
<blockquote>
<p>Note: vue-loader v16 is for Vue 3 only.</p>
</blockquote>
<h3>vue-loader 14 -> 15 Breaking Changes</h3>
<p>When migrating vue-loader from 14 upward, running directly without modifying the webpack configuration will encounter Vue syntax recognition issues. Specifically, even though .vue file naming uses correct and valid syntax, the compiler just won't recognize it during build/development, reporting syntax errors. vue-loader officially has a <a href="https://vue-loader.vuejs.org/migrating.html">migration document</a> that needs attention.</p>
<pre><code>ERROR in ./src/......
Module parse failed: Unexpected token(1:0)
You may need an appropriate loader to handle this file type.
</code></pre>
<pre><code class="language-diff">// ...
import path from 'path'
+const VueLoaderPlugin = require('vue-loader/lib/plugin')

// ...

  plugins: [
+    new VueLoaderPlugin()
    // ...
  ]
</code></pre>
<p>Additionally, in my project, I needed to remove the babel-loader for .vue files in the webpack configuration:</p>
<pre><code class="language-diff">{
  test: /\.vue$/,
  use: [
-    {
-      loader: 'babel-loader'
-    },
    {
      loader: 'vue-loader',
    }
  ]
}
</code></pre>
<h2>Final Update Status</h2>
<ul>
<li><code>node@^12</code> -> <code>node@^14</code></li>
<li><code>vue-loader@^14</code> -> <code>vue-loader@^15</code></li>
<li><code>node-sass@^4</code> -> <code>sass@^1.22.12</code></li>
</ul>
<p>All other dependency versions remain unchanged</p>
<h2>References</h2>
<ul>
<li><a href="https://juejin.cn/post/7327094228350500914">Switching from node-sass to dart-sass - Juejin</a></li>
<li><a href="https://sunchenggit.github.io/2021/01/13/node-sass%E8%BF%81%E7%A7%BBdart-sass/">node-sass migration to dart-sass | Bolg</a></li>
<li><a href="https://www.webpackjs.com/loaders/sass-loader/">sass-loader | webpack Documentation</a></li>
<li><a href="https://sass-lang.com/blog/libsass-is-deprecated/">Sass: LibSass is Deprecated</a></li>
<li><a href="https://www.npmjs.com/package/sass?activeTab=versions">sass - npm</a></li>
<li><a href="https://www.npmjs.com/package/node-sass">node-sass - npm</a></li>
<li><a href="https://docs.npmjs.com/about-semantic-versioning">About semantic versioning | npm Docs</a></li>
<li><a href="https://chromestatus.com/feature/4964279606312960">Make /deep/ behave like the descendant combinator " " in CSS live profile - Chrome Platform Status</a></li>
<li><a href="https://github.com/webpack-contrib/sass-loader/blob/v8.0.0/CHANGELOG.md">sass-loader/CHANGELOG.md at v8.0.0 · webpack-contrib/sass-loader</a></li>
<li><a href="https://github.com/vuejs/vue-loader/releases/tag/v16.1.2">Release v16.1.2 · vuejs/vue-loader</a></li>
<li><a href="https://github.com/vuejs/vue-loader/commit/e32cd0e4372fcc6f13b6c307402713807516d71c#diff-7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519">refactor: use @vue/component-compiler-utils · vuejs/vue-loader@e32cd0e</a></li>
<li><a href="https://github.com/vuejs/vue-loader/commit/c359a38db0fbb4135fc97114baec3cd557d4123a">chore: update @vue/component-compiler-utils to v3 · vuejs/vue-loader@c359a38</a></li>
<li><a href="https://github.com/vuejs/vue-cli/issues/3399#issuecomment-466319019">dart-sass does not support /deep/ selector · Issue #3399 · vuejs/vue-cli</a></li>
<li><a href="https://vue-loader-v14.vuejs.org/en/features/scoped-css.html">Scoped CSS · vue-loader v14</a></li>
<li><a href="https://vue-loader.vuejs.org/migrating.html">Migrating from v14 | Vue Loader</a></li>
</ul>]]>
    </content>
    <published>2025-07-05T09:57:02.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Web"></category>
    <category term="Vue.js"></category>
    <category term="Sass"></category>
    <category term="CSS"></category>
    <category term="JavaScript"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/06/08/front-end-bug-gone-when-open-devtool/</id>
    <title>Quantum Mechanics in Frontend Development — The Bug That Vanishes as Soon as You Open F12</title>
    <updated>2025-06-07T17:22:13.000Z</updated>
    <link href="https://zhul.in/2025/06/08/front-end-bug-gone-when-open-devtool/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<h2>First Observation of the Frontend "Quantum State" Phenomenon</h2>
<p>This story sounds bizarre—even surreal. Half a month ago, while happily coding at my desk (with hotpot and songs in the background 🍲🎤), I stumbled upon a truly uncanny bug. As a bona fide human software engineer, writing bugs is normal—but this one defied all intuition: the moment I opened DevTools (F12) to inspect the relevant DOM structure, the bug <em>vanished</em>. Close DevTools, hit Ctrl+F5 to hard-refresh, and <em>bam</em>—the bug reappeared.</p>
<p>Below is a live demo embedded via <code>&#x3C;iframe></code>:
<a href="https://static.031130.xyz/demo/scroll-jump-bug.html">demo</a></p>
<iframe src="https://static.031130.xyz/demo/scroll-jump-bug.html" width="100%" height="500" allowfullscreen loading="lazy"></iframe>
<p><img src="https://static.031130.xyz/uploads/2025/06/08/65620d31fce6f.webp" alt="“How to Observe” Guide"></p>
<p>This bug left me utterly bewildered. I’m no physicist—why did <strong>observer effect</strong> from quantum mechanics show up in my frontend code?! 🤯</p>
<blockquote>
<p><strong>Observer effect</strong>: the act of <em>observation</em> inevitably influences the phenomenon being observed.</p>
<p>In quantum experiments—for example, to measure an electron’s velocity—you might fire two photons at it over a time interval. But the first photon already disturbs the electron’s motion, making the original velocity impossible to determine (Heisenberg’s uncertainty principle). Similarly, rapidly observing a decaying particle can apparently slow its decay rate.</p>
<p>— Wikipedia</p>
</blockquote>
<h2>Quantum Fog ❌ → Browser Mechanics ✅</h2>
<p>Let’s briefly examine the problematic code snippet from the demo:</p>
<pre><code class="language-javascript">if (scrollIndex >= groupLength) {
  setTimeout(() => {
    wrapper.style.transition = "none";
    scrollIndex = 0;
    wrapper.style.transform = `translateY(-${scrollIndex * itemHeight}px)`;

    requestAnimationFrame(() => {
      wrapper.style.transition = "transform 0.5s cubic-bezier(0.25, 0.1, 0.25, 1)";
    });
  }, 500);
}
</code></pre>
<p>The requirement was to implement an <em>infinite vertical scrolling</em> title list. Three titles are shown at a time; every 2 seconds, the list scrolls up as a group—the top item exits view, and a new item enters from below (again, the demo clarifies this visually).</p>
<p>When reaching the bottom, we disable CSS transitions, instantly relocate (<code>transform</code>) the wrapper back to the “top” of the logical loop (i.e., visually reset to the same three titles), then re-enable transitions—creating a seamless infinite loop illusion.</p>
<p>But here’s the twist: even though we explicitly set <code>transition: none</code>, the jump <em>still exhibited a transition animation</em>.</p>
<p>Frankly, this was the most despair-inducing bug in my short dev career. Not only was the behavior seemingly supernatural, but searching online felt hopeless—I didn’t even know how to phrase the issue!</p>
<p><img src="https://static.031130.xyz/uploads/2025/06/08/475a61b332454.webp" alt="This is Xiao Maicha, the senior who got me into frontend development"></p>
<p>Out of desperation, I fed the code to ChatGPT-4o—and got a lifeline:</p>
<blockquote>
<p>The phenomenon you describe — “<strong>a jarring upward jump on the 9th scroll</strong>, which <strong>disappears when DevTools is open</strong>” — is almost certainly due to <strong>frame skipping</strong> (frame rate fluctuations) or <strong>timer precision issues</strong> in the browser’s rendering pipeline.</p>
<p>Such bugs commonly arise when combining <code>setInterval</code>-driven animation with improperly timed style switches (e.g., <code>transition</code> toggles), causing <em>transition frame skips</em>. Opening DevTools often <strong>forces frame redraws</strong> or <strong>increases timer resolution</strong>, thereby masking the issue.</p>
</blockquote>
<h2>Great News: <code>requestAnimationFrame</code> to the Rescue! 🎉</h2>
<blockquote>
<p><strong><code>window.requestAnimationFrame()</code></strong> tells the browser you wish to perform an animation and requests that the browser call a specified function to update an animation before the next repaint.</p>
<p>— MDN</p>
</blockquote>
<p>Here’s the fix suggested by GPT—simple yet highly effective:</p>
<pre><code class="language-diff">if (scrollIndex >= groupLength) {
  setTimeout(() => {
    wrapper.style.transition = "none";
    scrollIndex = 0;
    wrapper.style.transform = `translateY(-${scrollIndex * itemHeight}px)`;

    requestAnimationFrame(() => {
+     requestAnimationFrame(() => {
        wrapper.style.transition = "transform 0.5s cubic-bezier(0.25, 0.1, 0.25, 1)";
+     });
    });
  }, 500);
}
</code></pre>
<p>If nested <code>requestAnimationFrame</code> calls feel confusing, here’s an equivalent—but clearer—version:</p>
<pre><code class="language-javascript">if (scrollIndex >= groupLength) {
  setTimeout(() => {
    scrollIndex = 0;

    requestAnimationFrame(() => {
      // Frame 1: Apply instant jump (no transition)
      wrapper.style.transition = "none";
      wrapper.style.transform = `translateY(-${scrollIndex * itemHeight}px)`;

      // Enqueue next frame
      requestAnimationFrame(() => {
        // Frame 2: Re-enable smooth transition
        wrapper.style.transition = "transform 0.5s cubic-bezier(0.25, 0.1, 0.25, 1)";
      });
    });
  }, 500);
}
</code></pre>
<p>The core idea: we must <em>guarantee</em> that setting the new <code>transform</code> (the “teleport”) and re-enabling <code>transition</code> happen in <em>separate animation frames</em>. Two nested <code>requestAnimationFrame</code> calls ensure exactly that.</p>
<iframe src="https://static.031130.xyz/demo/scroll-jump-bug-fixed.html" width="100%" height="500" allowfullscreen loading="lazy"></iframe>
<h2>Taming the Quantum State: A New Skill for Frontend Developers</h2>
<p>With double <code>requestAnimationFrame</code>, we’ve successfully tamed this “quantum” bug. Now, regardless of whether DevTools is open, the animation behaves consistently—no more vanishing acts.</p>
<p>So it turns out: in frontend development, we need not just JavaScript expertise—<del>but perhaps a dash of quantum mechanics, too</del>. 😉
Next time you encounter a bug that “collapses upon observation”, try this <strong>“quantum entanglement solution”</strong>: double <code>requestAnimationFrame</code>—it might just decohere your bug from a probabilistic “quantum state” into a deterministic “classical state”.</p>
<p>And if you’ve battled even weirder bugs—please share! After all, in the universe of code, we never know what exotic form the next bug will take. Perhaps, that’s precisely where the <em>fun</em> of programming lies. 🐛✨</p>
<blockquote>
<p><em>This article was co-authored with assistance from ChatGPT and DeepSeek—but the bug? Sadly, 100% real (and tearful).</em></p>
</blockquote>
<h2>See Also</h2>
<ul>
<li><a href="https://en.wikipedia.org/wiki/Observer_effect_(physics)">Observer Effect — Wikipedia</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame"><code>Window.requestAnimationFrame()</code> — MDN Web Docs</a></li>
<li><a href="https://www.ruanyifeng.com/blog/2015/09/web-page-performance-in-depth.html">A Deep Dive into Web Page Performance — Ruan Yifeng’s Blog</a>"</li>
</ul>]]>
    </content>
    <published>2025-06-07T17:22:13.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Web"></category>
    <category term="HTML"></category>
    <category term="CSS"></category>
    <category term="JavaScript"></category>
    <category term="Debug"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/06/02/choosing-the-right-video-compression-format-for-web-in-2025/</id>
    <title>In 2025, How to Choose the Right Video Compression Algorithm for Web Pages?</title>
    <updated>2025-06-02T12:59:10.000Z</updated>
    <link href="https://zhul.in/2025/06/02/choosing-the-right-video-compression-format-for-web-in-2025/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>The issue arose from the need to display a ~5‑minute product showcase video on a webpage. The original H.264-encoded file weighed 60 MB, with a bitrate of 1646 kbps—impractical for network delivery: not only does it inflate CDN bandwidth costs, but it also severely degrades the user experience under weak network conditions. Thus, we must compress the video using more modern codecs while ensuring broad browser compatibility (fortunately, IE is no longer a concern).</p>
<h2>What Are the Mainstream Compression Algorithms Today?</h2>
<h3>AV1</h3>
<p>As of 2025, AV1 stands as the most compression-efficient mainstream video codec. It has been fully adopted by major platforms such as YouTube, Netflix, and Bilibili, making it the top choice for new deployments. Beyond its superior compression, AV1’s royalty-free status encourages hardware vendors and browser developers to integrate support freely—both in hardware and software.</p>
<p><img src="https://static.031130.xyz/uploads/2025/06/02/aec1af1718064.webp" alt="">
<img src="https://static.031130.xyz/uploads/2025/06/02/76a312b5a668b.webp" alt=""></p>
<p>Unfortunately, Safari still lacks <em>software</em> decoding for AV1. Only Apple devices with M3 (or later) chips and iPhone 15 Pro (or later) support <em>hardware</em> AV1 decoding. Older Apple devices simply cannot play AV1 videos in Safari.
<em>(Let’s just say Safari has become the modern-day IE—a clear roadblock to Web advancement.)</em></p>
<p><img src="https://static.031130.xyz/uploads/2025/06/02/01ddcc3948406.webp" alt="Safari fails outright on a MacBook Pro with an M2 Pro chip."></p>
<p>Beyond playback compatibility, AV1 encoding is demanding. Among consumer GPUs, only NVIDIA RTX 40-series, AMD RX 7000-series, and Intel Arc A380 (and newer) support <em>hardware</em> AV1 <em>encoding</em>. Apple’s M-series chips still offer <em>no</em> AV1 encoding hardware acceleration whatsoever. On my ThinkPad with an Intel Core i7‑1165G7, software encoding via <code>libaom-av1</code> crawls at ~0.0025× real-time speed—compressing a 5‑minute 1080p video takes over a full day.</p>
<p><img src="https://static.031130.xyz/uploads/2025/06/02/923ca02e1d835.webp" alt=""></p>
<h3>H.265 / HEVC</h3>
<p>As the official successor to H.264, HEVC (H.265) has arguably squandered its potential. It is governed by multiple patent pools (e.g., MPEG LA, HEVC Advance, Velos Media), resulting in fragmented and costly licensing terms. These high licensing fees have drastically limited its adoption—especially in open ecosystems and web contexts.</p>
<p>Chromium and Firefox refuse to shoulder HEVC licensing costs, so they do <em>not</em> ship with built-in HEVC <em>software</em> decoders. Browsers today generally follow a <strong>“hardware decode if possible; otherwise, give up”</strong> policy. Firefox on Linux, however, tries a workaround: if hardware decode fails, it leverages system-installed FFmpeg for software decoding. Fortunately, HEVC—standardized in 2013—has now gained near-universal hardware decode support: Apple treats HEVC as a first-class citizen, supporting it across its entire product lineup.</p>
<p>Uncovered edge cases remain: Chromium/Firefox on Windows 7, and Chromium on Linux (including domestic Chinese distros like UOS and Kylin).</p>
<p><img src="https://static.031130.xyz/uploads/2025/06/02/2e8e5100f645a.webp" alt="Chrome on Linux, lacking HEVC hardware decode, mistakenly treats the video as audio-only."></p>
<h3>VP9</h3>
<p>VP9, introduced by Google in 2013, serves as one of H.264’s successors. Its compression efficiency rivals HEVC—yet its biggest advantage is being <strong>completely royalty-free</strong>. VP9 was Google’s defiant response to HEVC’s licensing hurdles: <em>“You keep feasting on royalties; I’ll host a free buffet.”</em></p>
<p><img src="https://static.031130.xyz/uploads/2025/06/03/a9b473a3bd120.webp" alt=""></p>
<p>Thanks to its zero-cost licensing and aggressive promotion across Google’s ecosystem (YouTube, WebRTC, Chrome), VP9 quickly became the de facto standard for web video—especially before AV1’s rise. In fact, it even pressured Apple—longtime gatekeeper of proprietary codecs—to add VP9 support to Safari in macOS 11 (Big Sur) and iOS 14 (though WebM container support arrived slightly later).</p>
<p>VP9 enjoys near-universal <em>software</em> decode support: Chromium, Firefox, Edge, and even Safari include native support. Hardware decode support is also widespread: Intel Skylake (6th Gen Core) and later, NVIDIA GTX 950+, and AMD Vega/RDNA GPUs all support full VP9 decode. Unless you're running museum-grade hardware, VP9 playback “just works.”</p>
<p>Encoding remains VP9’s weak point—Google’s reference encoder, <code>libvpx</code>, lags behind <code>x264</code>/<code>x265</code> in speed. Without hardware acceleration, VP9 encoding can still take hours. Still, compared to AV1, VP9 is far more usable: Intel introduced VP9 <em>hardware encoding</em> as early as Kaby Lake (7th Gen Core), and vendor support has matured significantly since.</p>
<h3>H.264 / AVC</h3>
<p>H.264—the venerable veteran—remains the undisputed champion of video codecs over the past two decades. Since its standardization in 2003, it has dominated everything: web streaming, Blu-ray, live broadcasts, surveillance, and smartphone recording.</p>
<p>Its unbeatable strength? <strong>Universal compatibility</strong>. Almost any screen-equipped device can decode H.264. <em>Software</em> decode has been standard in browsers and players for well over a decade; <em>hardware</em> decode dates back to Intel Sandy Bridge, NVIDIA Fermi, AMD VLIW4—and even Raspberry Pis or smart refrigerators can handle it.</p>
<p>While H.264 does involve patents, its licensing is comparatively pragmatic: MPEG LA’s pool is affordable, and free online video streaming incurs no fees. Thus, Chromium, Firefox, Safari, and Edge all ship with H.264 software decode built in.</p>
<p>That said, as a 20‑year‑old codec, H.264 lags far behind VP9, HEVC, and AV1 in compression efficiency. At equivalent quality, H.264 typically needs 30–50% higher bitrates than AV1—making it suboptimal for bandwidth- or storage-constrained scenarios. Yet in today’s pragmatic world—where <em>playability beats perfection</em>—H.264 remains the default fallback, the “reliable old workhorse”.</p>
<p>So even as AV1, HEVC, and VP9 gain ground, H.264 retains its central role in the video ecosystem—so long as some browsers lack AV1 support (looking at you, Safari), servers want to avoid costly transcoding, or users still run legacy devices.</p>
<h3>Summary</h3>
<p>Browsers can no longer singlehandedly abstract away hardware and OS discrepancies. Real-world edge cases persist beyond what compatibility tables can capture.</p>
<table>
<thead>
<tr>
<th>Codec</th>
<th>Compression Efficiency</th>
<th>Browser Support</th>
<th>Desktop Support</th>
<th>Mobile Support</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>AV1</strong></td>
<td>★★★</td>
<td>Chrome / Chromium: ✅ v70+ (Oct 2018)</td>
<td>✅ (hardware preferred, software fallback)</td>
<td>✅</td>
<td>Safari: hardware-only (M3/A17 Pro+); <strong>no software decode</strong></td>
</tr>
<tr>
<td></td>
<td></td>
<td>Firefox: ✅ v67+ (May 2019) desktop; ✅ v113+ (May 2023) Android</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td><strong>HEVC (H.265)</strong></td>
<td>★★☆</td>
<td>Chrome / Chromium: ⚠️ hardware decode only (no software, unless via MS Store plugin on Windows)</td>
<td>⚠️</td>
<td>⚠️</td>
<td>Firefox: hardware decode only; Linux may fall back to system FFmpeg</td>
</tr>
<tr>
<td></td>
<td></td>
<td>Safari: ✅ full support (macOS 10.13+/iOS 11+, 2017)</td>
<td></td>
<td></td>
<td>Apple is HEVC’s strongest advocate</td>
</tr>
<tr>
<td><strong>VP9</strong></td>
<td>★★☆</td>
<td>Chrome / Chromium / Firefox / Edge: ✅</td>
<td>✅ (Intel Skylake+, NVIDIA GTX 950+, AMD Vega/RDNA)</td>
<td>✅</td>
<td>Safari: ✅ (v14.1+/iOS 17.4+, 2021–2024; WebM support slightly later)</td>
</tr>
<tr>
<td><strong>H.264 (AVC)</strong></td>
<td>★☆☆</td>
<td>✅ All major browsers (desktop &#x26; mobile)</td>
<td>✅ (Intel Sandy Bridge+, NVIDIA Fermi+, AMD VLIW4+, Raspberry Pi, etc.)</td>
<td>✅</td>
<td>Universal fallback; “if it plays anywhere, it’s H.264”</td>
</tr>
</tbody>
</table>
<h2>How to Choose?</h2>
<p>We aren’t a professional video platform like YouTube or Bilibili, capable of offering users multiple resolutions and codec options.</p>
<p><img src="https://static.031130.xyz/uploads/2025/06/03/096484dbc0f3a.webp" alt="Bilibili offers three codec options for the same video."></p>
<p>Our final strategy must balance <strong>compression efficiency</strong>, <strong>playback compatibility</strong>, and <strong>encoding time</strong>.</p>
<h3>Option 1: AV1 as Primary, H.264 as Fallback</h3>
<p>Modern browsers support multiple <code>&#x3C;source></code> elements inside <code>&#x3C;video></code>, letting the browser pick the first playable one:</p>
<pre><code class="language-html">&#x3C;video controls poster="preview.jpg">
  &#x3C;source src="video.av1.webm" type='video/webm; codecs="av01"' />
  &#x3C;source src="video.h264.mp4" type='video/mp4; codecs="avc1"' />
  Your browser does not support video playback.
&#x3C;/video>
</code></pre>
<p>This approach leverages AV1 for bandwidth savings on modern devices, while H.264 ensures compatibility on older browsers (e.g., legacy Safari). No JavaScript or complex detection logic is needed.</p>
<p>However, this may not be ideal for us:</p>
<ul>
<li>My top-end Apple M4 MacBook still lacks <em>AV1 hardware encoding</em>—a 5‑minute encode takes ~3 hours via software.</li>
<li>Iterative tuning (adjusting CRF, bitrate, etc.) would be painfully slow.</li>
<li>Even with AV1 <em>decode</em> supported in Chromium/Firefox, older hardware may suffer high CPU usage—leading to fan noise and complaints (as seen during YouTube’s AV1 rollout).</li>
</ul>
<h3>Option 2: VP9 as the Sole Choice</h3>
<p>Given AV1’s encoding costs and playback burdens, VP9 emerges as a compelling alternative. Its browser support is robust, eliminating the need for an H.264 fallback. Moreover, VP9 <em>hardware encoding</em> is now widely available on recent CPUs/GPUs—enabling rapid iteration to find the optimal trade-off between file size and visual quality.</p>
<blockquote>
<p><em>Note</em>: While VP9 is most commonly packaged in WebM, the Opus audio codec (standard in WebM) still has spotty Safari support (only since Safari 17.4, March 2024). Consider using AAC audio in an MP4 container for broader compatibility.</p>
</blockquote>
<p><img src="https://static.031130.xyz/uploads/2025/06/03/ec3b5dbcbcc29.webp" alt="Opus audio codec browser support (Can I Use)"></p>
<h2>Audio Bitrate Too High? Trim It Further</h2>
<p>So far, we’ve focused on <em>video</em> compression, but the <em>audio</em> track offers room for savings too.</p>
<pre><code>Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 128 kb/s (default)
</code></pre>
<p>For a product demo video, 128 kbps stereo AAC is excessive. Switching to 64 kbps mono saved an additional ~2 MB—without perceptible quality loss in narration-heavy content.</p>
<h2>Final Thoughts</h2>
<p>For frontend developers, selecting a video codec is no longer just about “smallest file size”. It’s a balancing act among device capabilities, browser support, user experience, and development overhead.</p>
<p>We must embrace modern codecs like AV1 and VP9 <em>where feasible</em>, yet remain grounded in real-world constraints. HTML5’s <code>&#x3C;source></code> fallback mechanism empowers us to deliver elegant, resilient video experiences—even as the underlying codec landscape fragments.</p>
<p>After all, the Web has never lacked <em>possibility</em>—what’s rare is <em>elegance in execution</em>. If codec development is the domain of hardware engineers and video platforms, then those few lines of <code>&#x3C;source></code> within a <code>&#x3C;video></code> tag?
That’s <em>our</em> trench—the frontend engineer’s battlefield.</p>
<h2>References</h2>
<ul>
<li><a href="https://developer.mozilla.org/zh-CN/docs/Web/Media/Guides/Formats/Video_codecs">网页视频编码指南 - Web 媒体技术 | MDN</a></li>
<li><a href="https://research.netflix.com/research-area/video-encoding-and-quality">Encoding &#x26; Quality - Netflix Research</a></li>
<li><a href="https://optiview.dolby.com/resources/blog/playback/how-the-vp9-codec-supports-now-streaming-to-apple-devices-more/">How the VP9 Codec Supports Now Streaming to Apple Devices &#x26; More | dolby.io</a></li>
<li><a href="https://www.chromium.org/audio-video/">Audio/Video | The Chromium Project</a></li>
<li><a href="https://caniuse.com/av1">AV1 video format | Can I use... Support tables for HTML5, CSS3, etc</a></li>
<li><a href="https://caniuse.com/webm">WebM video format | Can I use... Support tables for HTML5, CSS3, etc</a></li>
<li><a href="https://caniuse.com/hevc">HEVC/H.265 video format | Can I use... Support tables for HTML5, CSS3, etc</a></li>
<li><a href="https://caniuse.com/opus">Opus audio format | Can I use... Support tables for HTML5, CSS3, etc</a></li>
<li><a href="https://caniuse.com/mpeg4">MPEG-4/H.264 video format | Can I use... Support tables for HTML5, CSS3, etc</a></li>
<li><a href="https://en.wikipedia.org/wiki/AV1">AV1 - Wikipedia</a></li>
<li><a href="https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding">High Efficiency Video Coding - Wikipedia</a></li>
<li><a href="https://en.wikipedia.org/wiki/VP9">VP9 - Wikipedia</a></li>
<li><a href="https://en.wikipedia.org/wiki/Advanced_Video_Coding">Advanced Video Coding - Wikipedia</a></li>
<li><a href="https://www.intel.com/content/www/us/en/developer/articles/technical/encode-and-decode-capabilities-for-7th-generation-intel-core-processors-and-newer.html">Encode and Decode Capabilities for 7th Generation Intel® Core™...</a></li>
<li><a href="https://zh.wikipedia.org/zh-cn/MacOS_High_Sierra">macOS High Sierra - 维基百科，自由的百科全书</a></li>
<li><a href="https://www.androidpolice.com/2018/10/17/chrome-70-adds-av1-video-support-improves-pwas-windows-apk-download/">Chrome 70 adds AV1 video support, improves PWAs on Windows, and more [APK Download]</a></li>
<li><a href="https://www.mozilla.org/en-US/firefox/android/113.0/releasenotes/">Firefox for Android 113.0, See All New Features, Updates and Fixes</a></li>
<li><a href="https://www.bilibili.com/video/BV1nW4y1V7kR/">视频网站的“蓝光”是怎么骗你的？——视频画质全解析【柴知道】_哔哩哔哩_bilibili</a></li>
<li>“Why 4K Today Looks Worse Than 4 Years Ago” (original video unavailable)</li>
</ul>]]>
    </content>
    <published>2025-06-02T12:59:10.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="HTML"></category>
    <category term="Web"></category>
    <category term="Network"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/05/31/el-image-and-el-table-why-the-fight-and-what-is-a-stacking-context/</id>
    <title>Why Do el-image and el-table Clash? What Is a Stacking Context?</title>
    <updated>2025-05-30T16:29:40.000Z</updated>
    <link href="https://zhul.in/2025/05/31/el-image-and-el-table-why-the-fight-and-what-is-a-stacking-context/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>This happened during the internal development of Jinghong’s image hosting service. A freshman reported that <code>el-image</code> and <code>el-table</code> were “clashing.”</p>
<p><img src="https://static.031130.xyz/uploads/2025/05/31/c6674f6f13955.webp" alt="Screenshot"></p>
<p>Demo iframe:</p>
<p><a href="https://static.031130.xyz/demo/el-image-el-table-conflict.html">demo</a></p>
<iframe src="https://static.031130.xyz/demo/el-image-el-table-conflict.html" width="100%" height="500" allowfullscreen loading="lazy"></iframe>
<p>When I saw that the table behind was showing through the <code>el-image</code> preview overlay, my first reaction was to ask the student to check whether the <code>z-index</code> was correct—specifically whether the <code>el-image</code> mask overlay had a higher <code>z-index</code> than the table.</p>
<p><img src="https://static.031130.xyz/uploads/2025/05/31/1c20b4ea0b37e.webp" alt=""></p>
<p>After testing locally, I confirmed that the <code>z-index</code> was indeed set correctly. So why were the elements behind showing through? A quick Google search led me to this article:</p>
<p><img src="https://static.031130.xyz/uploads/2025/05/31/99845899e3524.webp" alt=""></p>
<blockquote>
<p>Simply add the following CSS to <code>el-table</code>:</p>
<pre><code class="language-css">.el-table__cell {
    position: static !important;
}
</code></pre>
</blockquote>
<p>Testing locally confirmed that this solution works. But why? This is where <strong>Stacking Context</strong> comes into play.</p>
<h2>What Exactly Is a Stacking Context?</h2>
<p>Simply put, a Stacking Context is like a canvas. On the same canvas, elements with higher <code>z-index</code> values appear on top of those with lower values, which is why I initially suggested checking <code>z-index</code>. But the catch is that <strong>Stacking Contexts themselves have a hierarchy</strong>.</p>
<p>Imagine we have two canvases, A and B. On A, there’s an element with <code>z-index: 1145141919810</code>. This element has a very high priority and should appear at the very top of the browser window. But if canvas B has higher priority than canvas A, all elements on B will be displayed above A (essentially “winning by default”). So how is a canvas’s priority determined?</p>
<ul>
<li><strong>Between sibling Stacking Contexts, priority is determined by <code>z-index</code>.</strong></li>
<li><strong>For Stacking Contexts with the same <code>z-index</code>, elements appearing later in the HTML document have higher priority.</strong></li>
</ul>
<p>The second rule explains why in the demo, only elements that come after the image cell in the table are showing through.</p>
<h2>So Why Do <code>el-image</code> and <code>el-table</code> Clash?</h2>
<p>This conflict is mainly caused by two factors:</p>
<ol>
<li>
<p><code>el-table</code> sets <code>position: relative</code> for each cell. When <code>position</code> is set to <code>relative</code>, the element creates a new Stacking Context.</p>
<p><img src="https://static.031130.xyz/uploads/2025/05/31/9df43b865b3c6.webp" alt="image-20250531013029154"></p>
<p>So a table with ten cells actually generates ten canvases, each with <code>z-index: 1</code>. According to the rules above, table cells that appear later in the HTML document have higher priority than the earlier image cell.</p>
</li>
<li>
<p>The overlay used by <code>el-image</code>’s preview feature is placed <strong>inside the <code>el-image</code> element itself</strong>.</p>
<p><img src="https://static.031130.xyz/uploads/2025/05/31/f18a2b54afd63.webp" alt=""></p>
<p>The orange area in the image above is the overlay used during preview. The default behavior of Element Plus is to insert the preview overlay inside the <code>&#x3C;el-image></code> tag. This traps the overlay inside a low-priority Stacking Context, allowing content from later table cells to appear on top.</p>
</li>
</ol>
<h2>So What’s the Solution?</h2>
<h3>Changing the <code>position</code> value works</h3>
<p>The solution found online, forcing <code>position: static</code> on table cells, works because <code>static</code> does <strong>not</strong> create a new Stacking Context, avoiding the current problem.</p>
<h3>Placing top-layer elements in the highest-priority context is more common</h3>
<p>Other component libraries usually handle this by inserting the overlay directly into the <code>body</code> element and giving it a high <code>z-index</code>. This ensures it always appears on top of the screen (same principle used for dialogs, popovers, etc.).</p>
<p>In fact, Element Plus supports this feature:</p>
<blockquote>
<p><strong>preview-teleported:</strong> Determines whether the image viewer overlay is inserted into the <code>body</code>. Should be set to <code>true</code> if the parent element may modify its properties.</p>
</blockquote>
<p>So using <code>:preview-teleported="true"</code> with <code>el-image</code> is a more robust approach, because we cannot guarantee that the parent of <code>el-image</code> (besides <code>el-table</code> cells) won’t create another Stacking Context.</p>
<h2>References</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context">Stacking Context - CSS | MDN</a></li>
<li><a href="https://juejin.cn/post/6844903667175260174">Completely Understand CSS Stacking Contexts, Levels, Order, and z-index</a></li>
<li><a href="https://www.zhangxinxu.com/wordpress/2016/01/understand-css-stacking-context-order-z-index/">Deep Dive into CSS Stacking Context and Stacking Order</a></li>
<li><a href="https://element-plus.org/zh-CN/component/image.html">Image | Element Plus</a></li>
<li><a href="https://blog.csdn.net/qq_61402485/article/details/131202117">el-image and el-table display issue</a></li>
</ul>]]>
    </content>
    <published>2025-05-30T16:29:40.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Vue.js"></category>
    <category term="JavaScript"></category>
    <category term="CSS"></category>
    <category term="HTML"></category>
    <category term="Web"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/04/21/how-we-copy-text-to-clipboard-with-js-in-2025/</id>
    <title>How to Copy Text to Clipboard with JavaScript in 2025</title>
    <updated>2025-04-21T11:48:05.000Z</updated>
    <link href="https://zhul.in/2025/04/21/how-we-copy-text-to-clipboard-with-js-in-2025/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<h2>Fundamental Principles</h2>
<p>If you try searching for this article's title on a search engine, the articles you find will likely suggest using the following two APIs. <del>I hope your search engine isn't so outdated that it's still recommending Flash-based ZeroClipboard solutions in 2025</del></p>
<h3><a href="https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand">document.execCommand</a></h3>
<p>2012 brought us not only the supposed end of the world, but also IE 10. With IE 10's release on September 4th of that year, the execCommand family welcomed two new members—the copy/cut commands (this claim comes from <a href="https://developer.chrome.com/blog/cut-and-copy-commands">Chrome's blog</a>, while <a href="https://web.archive.org/web/20160315042044/https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand">MDN believes IE 9 already supported it</a>). Three years later, when Google Chrome version 42 (released April 14, 2015) added support for execCommand's copy/cut, more and more browser vendors began implementing this standard. Finally, with Safari 10 on iOS released on September 13, 2016, web developers at last obtained the first non-Flash JavaScript solution for copying to clipboard in history.</p>
<p>When document.execCommand's first parameter is "copy", it can copy user-selected text to the clipboard. Based on this API implementation, someone quickly developed what became the most common JavaScript implementation on the web today—first create an invisible DOM element, use JavaScript to simulate user text selection, and call execCommand('copy') to copy the text to the user's clipboard. The rough code implementation is as follows:</p>
<pre><code class="language-javascript">// From the article "Pitfalls and Complete Solution for JS Copy Text to Clipboard", with a link at the end of this article

const textArea = document.createElement("textArea");
textArea.value = val;
textArea.style.width = 0;
textArea.style.position = "fixed";
textArea.style.left = "-999px";
textArea.style.top = "10px";
textArea.setAttribute("readonly", "readonly");
document.body.appendChild(textArea);

textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
</code></pre>
<p>Although <strong>this API has long been deprecated by W3C</strong> and is marked as Deprecated on MDN, it remains the most common solution in the market. While writing this article, I examined MDN's English original page on archive.org and its change history on Github. This API was first marked as Obsolete in <a href="https://web.archive.org/web/20200221235207/https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand">January-February 2020</a>, then first marked as Deprecated in <a href="https://github.com/mdn/content/commit/0c31e2bc4d6601a079bc57521e79529539c8cf68#diff-85ef9d1e72565f0ae2ffd8199d10b34c11c615aec5d116057ac2a33c21cc072f">January 2021</a>, with a red background color warning developers that the API <strong>may stop working at any time</strong>. However, as of this article's publication, all commonly used browsers still maintain compatibility with this API, at least for the copy command.</p>
<p>This API has been widely used on so many sites that removing support for it would cause massive site failures. I believe all browser engines likely have no motivation in the short term to remove this API at the cost of losing compatibility. This means that this (seemingly bizarre) workaround of creating an invisible DOM to replace user text selection and executing execCommand to copy to the user's clipboard has left a significant mark in the history of frontend development.</p>
<h3><a href="https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText">Clipboard.writeText()</a></h3>
<p>As native JavaScript was gradually enhanced, developers finally completed the Clipboard piece of the puzzle. On April 17, 2018, Chrome 66 took the first step; on October 23 of the same year, Firefox followed with the Clipboard API implementation. Finally, on March 24, 2020, with Apple's Safari 13.4 belatedly joining, frontend developers could finally breathe a sigh of relief, once again obtaining a copying solution that works across mainstream browsers.</p>
<p><strong>So if execCommand already achieved pure JavaScript text copying to clipboard, why do we still need the Clipboard API? Or rather, what advantages does this deliberately implemented Clipboard API actually have?</strong></p>
<ol>
<li>The traditional execCommand solution typically requires creating a temporary invisible DOM, placing text, selecting text with JS, and executing the copy command. Setting aside how inelegant this hacky approach is when writing code, the operation of using JS to select text modifies the user's current text selection state, sometimes leading to decreased user experience.</li>
<li>The Clipboard API is asynchronous, meaning it won't block the main thread when copying large amounts of text.</li>
<li>The Clipboard API provides more capabilities, such as <code>write()</code> and <code>read()</code> allowing reading and writing more complex data to the clipboard, like rich text or images.</li>
<li>The Clipboard API has more modern, explicit permission controls—write operations need to be called by active user actions, while read operations require users to explicitly grant permission in the browser UI. These permission controls give users greater control, so when execCommand exits the historical stage, web security will be further improved.</li>
</ol>
<p>However, at the current stage, <code>Clipboard.writeText()</code> may not solve all problems. Setting aside old browser compatibility issues, <code>navigator.clipboard</code> <strong>is only available on pages accessed via HTTPS</strong> (or localhost). If your project is deployed on a LAN and you try to access it directly via 192.18.1.x IP + port, then <code>navigator.clipboard</code> will be in an <code>undefined</code> state.</p>
<p><img src="https://static.031130.xyz/uploads/2025/04/19/3437b1c022853.webp" alt=""></p>
<p>Additionally, <strong>Android native WebView</strong> has the problem of <strong>not being able to use</strong> the Clipboard API because the Permissions API isn't implemented.</p>
<p>For these reasons, many websites now prioritize trying to use <code>navigator.clipboard.writeText()</code>, then fall back to using <code>execCommand('copy')</code> if that fails. The rough code implementation is as follows:</p>
<pre><code class="language-javascript">// From the article "Pitfalls and Complete Solution for JS Copy Text to Clipboard", with a link at the end of this article

const copyText = async val => {
  if (navigator.clipboard &#x26;&#x26; navigator.permissions) {
    await navigator.clipboard.writeText(val);
  } else {
    const textArea = document.createElement("textArea");
    textArea.value = val;
    textArea.style.width = 0;
    textArea.style.position = "fixed";
    textArea.style.left = "-999px";
    textArea.style.top = "10px";
    textArea.setAttribute("readonly", "readonly");
    document.body.appendChild(textArea);

    textArea.select();
    document.execCommand("copy");
    document.body.removeChild(textArea);
  }
};
</code></pre>
<h3><del>Flash Solution (<a href="https://github.com/zeroclipboard/zeroclipboard">ZeroClipboard</a>)</del></h3>
<p>Actually, the two APIs above pretty much cover the fundamental principles, but while researching, I discovered that before the execCommand solution, the frontend mostly relied on Flash to implement copying text to clipboard. How could I not mention this?</p>
<p>Currently, the oldest tag that can be found in the ZeroClipboard Github repository is <a href="https://github.com/zeroclipboard/zeroclipboard/releases/tag/v1.0.7">v1.0.7</a>, released on June 9, 2012. I bet this project wasn't the first to implement copying text to clipboard through Flash. Someone must have implemented this functionality using Flash before, just not open-sourced as a separate library.</p>
<p>ZeroClipboard worked by creating a transparent Flash Movie overlaying the trigger button. When users clicked the button, they actually clicked on the Flash Movie. JavaScript then communicated with the Flash Movie through <code>ExternalInterface</code>, passing the text to be copied to Flash, which then wrote the text to the user's clipboard via Flash's API.</p>
<p>In that era's context, this was the only solution that could implement cross-browser text copying to clipboard (although not every computer had Flash installed, and iOS didn't support Flash). The 6.6k star Github repository witnessed that chaotic era when each browser held onto its own private APIs, ultimately falling along with Flash as the execCommand solution rose.</p>
<h3>Other Imperfect Solutions</h3>
<h4>window.clipboardData.setData</h4>
<p>This API was mainly used around 2000-2010 and only worked in IE browsers. Firefox didn't support pure JavaScript copying to browser during this period; Chrome's first version wasn't released until 2008 and hadn't yet become mainstream.</p>
<pre><code class="language-javascript">window.clipboardData.setData("Text", text2copy);
</code></pre>
<h4>Giving Up (prompt)</h4>
<p>Call a prompt popup to let users copy themselves.</p>
<pre><code class="language-javascript">prompt('Press Ctrl + C, then Enter to copy to clipboard','copy me')
</code></pre>
<p><img src="https://static.031130.xyz/uploads/2025/04/19/7f5310ca03c80.webp" alt=""></p>
<h2>Third-Party Library Wrappers</h2>
<p>Since the execCommand solution is too abstract and not elegant enough, we have some ready-made third-party libraries that wrap the clipboard copying code.</p>
<h3><a href="https://github.com/zenorocha/clipboard.js/">clipboard.js</a></h3>
<p>clipboard.js is the most renowned third-party library, with 34.1k stars on Github as of this article's completion. The earliest tag version was released on October 28, 2015, one month after Firefox supported execCommand and all three major PC browsers achieved full compatibility.</p>
<p>clipboard.js <a href="https://github.com/zenorocha/clipboard.js/blob/master/src/common/command.js">only uses execCommand</a> to implement clipboard copying. The project owner expects developers to use <code>ClipboardJS.isSupported()</code> to determine whether the user's browser supports the execCommand solution, and arrange success/failure actions based on the command execution's return value.</p>
<p>However, what I find strange is that clipboard.js requires developers to pass a DOM selector (or HTML element/element list) when instantiating. It must have an actual HTML element to set event listeners to trigger the copy operation, rather than providing a JavaScript function for developers to call—although this isn't a limitation from execCommand. Example as follows:</p>
<pre><code class="language-html">&#x3C;!-- Target -->
&#x3C;input id="foo" value="text2copy" />

&#x3C;!-- Trigger -->
&#x3C;button class="btn" data-clipboard-target="#foo">&#x3C;/button>

&#x3C;script>
	new ClipboardJS('.btn');
&#x3C;/script>
</code></pre>
<p>Yes, just one line of JavaScript can add listeners to all DOMs with the btn class. Perhaps this is why this repository got 34.1k stars. In 2015, when most people were still writing frontends with vanilla HTML/CSS/JS, clipboard.js could reduce code volume without requiring developers to set up listeners themselves.</p>
<p>clipboard.js also provides many advanced options to meet different developers' needs, such as allowing you to pass a function to get the text you need users to copy, or use Event listeners to provide feedback on whether copying succeeded. In short, the flexibility is sufficient.</p>
<h3><a href="https://github.com/sudodoki/copy-to-clipboard">copy-to-clipboard</a></h3>
<p>Another third-party library <a href="https://github.com/sudodoki/copy-to-clipboard/blob/main/index.js#L79">using execCommand</a>, though with only 1.3k stars. The first tag version was released on May 24, 2015, even earlier than clipboard.js. Compared to clipboard.js, copy-to-clipboard doesn't depend on HTML elements and can be called directly in JavaScript, which I personally prefer. In modern frontend frameworks like Vue/React, we generally don't directly manipulate the DOM, so clipboard.js isn't very suitable, but this copy-to-clipboard works well. Additionally, besides the execCommand solution, copy-to-clipboard specifically adapts the <code>window.clipboardData.setData</code> solution for older IE browsers, and calls a prompt window to let users copy manually as a final fallback when both fail.</p>
<p>Example as follows:</p>
<pre><code class="language-javascript">import copy from 'copy-to-clipboard';

copy('Text');
</code></pre>
<p>Compared to clipboard.js, the usage approach is more intuitive, but unfortunately it was born at the wrong time and isn't as famous as clipboard.js (naming might also be a factor).</p>
<h3><a href="https://vueuse.org/core/useClipboard/">VueUse - useClipboard</a></h3>
<p>VueUse's implementation of useClipboard is the one I'm most satisfied with. useClipboard fully considers browser compatibility, <strong>prioritizing <code>navigator.clipboard.writeText()</code></strong> when conditions for using navigator.clipboard are met, then <strong>falling back to execCommand-implemented legacyCopy</strong> when navigator.clipboard isn't supported or <code>navigator.clipboard.writeText()</code> fails. It also leverages Vue3's Composables to implement a copied variable that automatically resets to its initial state after 1.5 seconds, which is quite thoughtful.</p>
<pre><code class="language-vue">const { text, copy, copied, isSupported } = useClipboard({ source })
&#x3C;/script>

&#x3C;template>
  &#x3C;div v-if="isSupported">
    &#x3C;button @click="copy(source)">
      &#x3C;!-- by default, `copied` will be reset in 1.5s -->
      &#x3C;span v-if="!copied">Copy&#x3C;/span>
      &#x3C;span v-else>Copied!&#x3C;/span>
    &#x3C;/button>
    &#x3C;p>Current copied: &#x3C;code>{{ text || 'none' }}&#x3C;/code>&#x3C;/p>
  &#x3C;/div>
  &#x3C;p v-else>
    Your browser does not support Clipboard API
  &#x3C;/p>
&#x3C;/template>
</code></pre>
<h3>React-Related Ecosystem</h3>
<p>Unlike Vue's VueUse dominating the scene, React has many available hooks libraries, so let's go through them all.</p>
<h4><a href="https://github.com/streamich/react-use">react-use - useCopyToClipboard</a></h4>
<p>react-use is the largest React Hooks library I could find, with 42.9k stars. The copying solution directly depends on <a href="https://github.com/sudodoki/copy-to-clipboard">copy-to-clipboard</a> mentioned above, which is the execCommand solution.</p>
<pre><code class="language-jsx">const Demo = () => {
  const [text, setText] = React.useState('');
  const [state, copyToClipboard] = useCopyToClipboard();

  return (
    &#x3C;div>
      &#x3C;input value={text} onChange={e => setText(e.target.value)} />
      &#x3C;button type="button" onClick={() => copyToClipboard(text)}>copy text&#x3C;/button>
      {state.error
        ? &#x3C;p>Unable to copy value: {state.error.message}&#x3C;/p>
        : state.value &#x26;&#x26; &#x3C;p>Copied {state.value}&#x3C;/p>}
    &#x3C;/div>
  )
}
</code></pre>
<h4><a href="https://ant.design/components/typography#typography-demo-copyable">Ant Design - Typography</a></h4>
<p>ahooks was the first React hooks library mentioned to me. It's maintained by the Ant Design team. However, it doesn't have clipboard encapsulation in the repository, so I looked into Ant Design's Typography implementation of copying capability. Like react-use above, it directly uses <a href="https://github.com/sudodoki/copy-to-clipboard">copy-to-clipboard</a>, which is the execCommand solution.</p>
<h4><a href="https://usehooks.com/usecopytoclipboard">usehooks - useCopyToClipboard</a></h4>
<p>I learned about this library from an LLM, and it now has 10.5k stars. What's quite absurd is that all its logic code is implemented in a single file called index.js, which is truly baffling. It first attempts to write using <code>navigator.clipboard.writeText()</code>, then switches to the execCommand solution if that fails. The hooks usage is similar to react-use above.</p>
<h4><a href="https://usehooks-ts.com/react-hook/use-copy-to-clipboard">usehooks-ts - useCopyToClipboard</a></h4>
<p>I wonder if this library was created to solve the lack of TypeScript support in the above one. It only uses <code>navigator.clipboard.writeText()</code> to attempt writing to the clipboard, and directly logs a <code>console.warn</code> error if it fails, with no fallback solution.</p>
<h2>Conclusion</h2>
<p>From a results perspective, VueUse's encapsulation is undoubtedly the most satisfying to me. It prioritizes trying the best-performing Clipboard API, then falls back to execCommand, while providing multiple reactive variables to assist development, but doesn't presumptuously use prompt as a guarantee, maximizing the operational space left to developers.</p>
<p>Looking back from the vantage point of 2025, the evolution trajectory of frontend clipboard operation technology is clearly visible: from early fragile Flash-dependent solutions, to execCommand's workaround, and finally moving toward the elegant implementation of standardized Clipboard API. This journey is not only a microcosm of technological iteration, but also reflects the unique "art of compromise" in frontend development.</p>
<p>For a long time to come, we may still be finding balance between "elegant implementation" and "backward compatibility," dancing ballet in shackles within the browser sandbox. But those temporary solutions born for compatibility will eventually become precious footnotes witnessing the evolution of frontend history.</p>
<h2>References</h2>
<ul>
<li><a href="https://liruifengv.com/posts/copy-text/">Pitfalls and Complete Solution for JS Copy Text to Clipboard</a></li>
<li><a href="https://jiongks.name/blog/zeroclipboard-intro">ZeroClipboard Study Notes | Jiongks</a></li>
<li><a href="https://developer.chrome.com/blog/cut-and-copy-commands">Cut and copy commands | Blog | Chrome for Developers</a></li>
<li><a href="https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms536419(v=vs.85)">execCommand method (Internet Explorer) | Microsoft Learn</a></li>
<li><a href="https://github.com/sudodoki/copy-to-clipboard">sudodoki/copy-to-clipboard</a></li>
<li><a href="https://github.com/zenorocha/clipboard.js/">zenorocha/clipboard.js</a></li>
<li><a href="https://vueuse.org/core/useClipboard/">useClipboard | VueUse</a></li>
<li><a href="https://streamich.github.io/react-use/?path=/story/side-effects-usecopytoclipboard--docs">Side-effects / useCopyToClipboard - Docs ⋅ Storybook</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand">document.execCommand - Web API | MDN</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText">Clipboard.writeText() - Web API | MDN</a></li>
<li><a href="https://www.sitepoint.com/community/t/onclick-select-all-and-copy-to-clipboard/3837/2">Onclick Select All and Copy to Clipboard? - JavaScript - SitePoint Forums | Web Development &#x26; Design Community</a></li>
<li><a href="https://stackoverflow.com/questions/16526814/how-would-i-implement-copy-url-to-clipboard-from-a-link-or-button-using-javasc">How would I implement 'copy url to clipboard' from a link or button using javascript or dojo without flash - Stack Overflow</a></li>
</ul>]]>
    </content>
    <published>2025-04-21T11:48:05.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="JavaScript"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/03/30/apt-upgrade-on-internal-server-via-ssh-tunnel-and-reverse-proxy/</id>
    <title>SSH to the Rescue - Running APT Updates on Internal Network Servers via SSH Tunnels</title>
    <updated>2025-03-30T13:45:24.000Z</updated>
    <link href="https://zhul.in/2025/03/30/apt-upgrade-on-internal-server-via-ssh-tunnel-and-reverse-proxy/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<p>This all started when Jinghong's <a href="https://blog.cnpatrickstar.com/">former technical director</a> complained that the school's internal network servers couldn't connect to the external network, making APT installation and updates extremely difficult. He had to manually download packages from the source, along with their dependencies, and the dependencies of those dependencies... and then transfer all these packages to the server via sftp/rsync or similar methods for manual installation.</p>
<p><img src="https://static.031130.xyz/uploads/2025/03/30/0447b7d64886a.webp" alt=""></p>
<p>Thus, this article was born. We can use Caddy (Nginx works too, of course) on our local machine to reverse proxy an APT mirror site, establish port forwarding through an SSH tunnel, allowing the internal network server to access the local Caddy server and thereby reach the external mirror site.</p>
<h2>Prerequisites</h2>
<ul>
<li>Your control machine (your own computer) can directly connect to the computer via SSH (possibly using some network tools), rather than first logging into a jump host via SSH and then logging into the target server from there. The latter situation can certainly achieve our goal using similar methods, but would be more complex.</li>
<li>Your control machine (your own computer) can connect to public mirror sites while connected to the internal network server (<del>if not, you might want to sync a local mirror in advance to create an offline mirror site</del>).</li>
</ul>
<h2>Reverse Proxying the Mirror Site</h2>
<p>I chose Caddy over Nginx here - on one hand, Caddy's configuration files are simpler to write, and on the other hand, Caddy is written in Golang, so it's a single binary that works everywhere, and Windows can directly <a href="https://caddyserver.com/download">download</a> and run it.</p>
<p>Using the most common Tsinghua TUNA mirror site as an example, a simple Caddy configuration file looks like this:</p>
<pre><code class="language-nginx">:8080 {
    reverse_proxy https://mirrors.tuna.tsinghua.edu.cn {
        header_up Host {http.reverse_proxy.upstream.hostport}
    }
}
</code></pre>
<p>Save the above code as a file named Caddyfile, then run it using the caddy command in the saved path:</p>
<pre><code class="language-bash">caddy run --config ./Caddyfile
</code></pre>
<p><img src="https://static.031130.xyz/uploads/2025/03/30/8ef15a08e4852.webp" alt=""></p>
<p>If there are no errors, you should be able to see the Tsinghua mirror site on your local port 8080:</p>
<p><img src="https://static.031130.xyz/uploads/2025/03/30/a9083c95c07a2.webp" alt=""></p>
<blockquote>
<p>You may notice that the reverse proxied page differs slightly from Tsinghua's mirror site - it doesn't have Tsinghua's logo. This is probably because the page's JavaScript checks the host, and if it's not Tsinghua or BFSU's page, it won't add the school name. But this doesn't affect our ability to fetch updates from these mirror sites.</p>
</blockquote>
<h2>Establishing the SSH Tunnel</h2>
<p>To establish the tunnel, use the following command:</p>
<pre><code class="language-bash">ssh -R 8085:localhost:8080 root@remote.example.com
</code></pre>
<p>The -R flag indicates establishing a reverse tunnel. For other parameter options, you can refer to this blog post "<a href="https://www.entropy-tree.top/2024/04/18/ssh-tunneling-techniques/">SSH Tunneling Techniques</a>", also written by a Jinghong senior.</p>
<p>At this point, we've established SSH port forwarding from the internal network server's port 8085 to our local machine's port 8080. (I used port 8085 to distinguish it from port 8080; in practice, you can use any available port)</p>
<p>We can test whether we can access it normally on the server using curl. Here I simply accessed a README file in the Debian source root directory:</p>
<pre><code class="language-bash">curl http://localhost:8085/debian/README
</code></pre>
<p><img src="https://static.031130.xyz/uploads/2025/03/30/597c4af0d398d.webp" alt=""></p>
<h2>Changing Sources</h2>
<p>So now we have a reverse proxy of the Tsinghua open source mirror site on the internal network server's port 8085, and we can access all content in the mirror site through port 8085.</p>
<p>First, follow the <a href="https://mirrors.tuna.tsinghua.edu.cn/help/debian/">instructions from Tsinghua Open Source Mirror Site</a> to change sources. <strong>Remember to check "Force security updates to use mirror"</strong>.</p>
<p><img src="https://static.031130.xyz/uploads/2025/03/30/46e3c7030ded4.webp" alt=""></p>
<p>Then, we replace all instances of <a href="https://mirrors.tuna.tsinghua.edu.cn">https://mirrors.tuna.tsinghua.edu.cn</a> in the sources with <a href="http://localhost:8085">http://localhost:8085</a>:</p>
<pre><code class="language-bash">sed -i 's|https\?://mirrors\.tuna\.tsinghua\.edu\.cn|http://localhost:8085|g' `grep -rlE 'http(s)?://mirrors\.tuna\.tsinghua\.edu\.cn' /etc/apt/`
</code></pre>
<p><img src="https://static.031130.xyz/uploads/2025/03/30/a8f0c70d48f5b.webp" alt="Running apt update"></p>
<p><img src="https://static.031130.xyz/uploads/2025/03/30/07919bf939e92.webp" alt="Installing unzip using apt"></p>
<p>As you can see, we've successfully implemented APT updates and software installation on the internal network server through an SSH tunnel.</p>
<blockquote>
<p>Friendly reminder: SSH tunnels were frequently used in the early 2010s to establish cross-border access, but quickly faded from the scene due to their distinctive traffic characteristics. Therefore, don't use SSH for large amounts of cross-border network transmission, as it's easy to get blocked.</p>
</blockquote>
<p>Of course, there are many ways to achieve this goal. Other tools like frp can achieve the same effect, but the SSH tunnel solution can be started and stopped on demand without additional configuration, which is why I primarily recommend it.</p>]]>
    </content>
    <published>2025-03-30T13:45:24.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="Apt"></category>
    <category term="Network"></category>
    <category term="OpenSSH"></category>
    <category term="Caddy"></category>
    <category term="Linux"></category>
    <category term="Debian"></category>
  </entry>
  <entry>
    <id>https://zhul.in/2025/02/28/cudy-tr3000-daed-install-record/</id>
    <title>Cudy TR3000 daed Installation Guide</title>
    <updated>2025-02-28T13:18:34.000Z</updated>
    <link href="https://zhul.in/2025/02/28/cudy-tr3000-daed-install-record/" rel="alternate"></link>
    <content type="html">
      <![CDATA[<h2>Background</h2>
<p>Not long ago, I spotted the Cudy TR3000 router I'd been eyeing for a while at a discount price of ¥153 on JD.com. While it wasn't quite the all-time low of ¥130 (or even ¥110 with bundled deals), it was within my acceptable range. So I immediately ordered this Cudy TR3000 mini router I'd been longing for, to help ease my pre-semester anxiety (a mental condition).</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/23/8b0a4d5812179.webp" alt=""></p>
<p>This router is powered by Type-C and features one 2.5Gbps WAN port and one 1Gbps LAN port. Additionally, it has a USB port that can be used for printer sharing, mounting external storage, Android phone USB network sharing, and various other purposes. What really attracted me was its compact size, making it perfect for business trips, travel, short-term rentals, and similar scenarios. Considering I might need to rent a place for an upcoming internship, I seized this opportunity to order it.</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/23/ef2b394e6fc0d.webp" alt="Size comparison with a Xiaomi 8 phone"></p>
<p>The official firmware is a customized version of OpenWRT with limited functionality, so I decided to flash the original OpenWRT system to increase its capabilities. On the Enshan wireless forum, I found that someone had already compiled <a href="https://www.right.com.cn/forum/forum.php?mod=viewthread&#x26;tid=8418091">an OpenWRT system based on Linux kernel 6.6</a>. This meets the kernel version requirement (>= 5.17) for dae's Bind to LAN feature, and the 512MB memory size just reaches the recommended minimum. So I definitely had to give dae a try. If successful, this would be my first hardware router running dae.</p>
<hr>
<h2>Starting the Flashing Process</h2>
<p>The router's official system management interface is at 192.168.10.1. On first access, you'll be asked to set a password, then just click through the initialization process to reach the main page. My unit has firmware version <code>2.3.2-20241226</code>. I'm not sure if later versions can still use this method.</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/1c066cb1dab3f.webp" alt=""></p>
<h3>Transition Firmware</h3>
<p>First, we need to flash the so-called "transition firmware". The purpose of flashing transition firmware is that it can be recognized by the official system's upgrade program, allowing us to proceed with subsequent operations.</p>
<p>The transition firmware filename and MD5 hash:</p>
<pre><code>b8333d8eebd067fcb43bec855ac22364  cudy_tr3000-v1-sysupgrade.bin
</code></pre>
<p>Then we can find the firmware upgrade section in the basic settings of the router's management page, and select the transition firmware to upload and update in the local update section.</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/3582e569954a6.webp" alt=""></p>
<h3>Flash Firmware to Unlock FIP Partition Write Permission</h3>
<p>After flashing the transition firmware, wait about one minute for the router's DHCP to restart, and we can access the transition firmware's management page at 192.168.1.1.</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/6fe8107a87e87.webp" alt=""></p>
<p>There's no password on first login - you can enter anything to log in successfully. Considering we might need to restore factory settings later, it's recommended to back up the FIP partition at this step.</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/79caf5f643689.webp" alt=""></p>
<p>This time we need to flash the following LEDE firmware to unlock FIP partition write permission. The filename and MD5 are provided below:</p>
<pre><code>4af5129368cbf0d556061f682b1614f2  openwrt-mediatek-filogic-cudy_tr3000-v1-squashfs-sysupgrade.bin
</code></pre>
<p>Select flash firmware below, upload the firmware we need to flash, and proceed.</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/79d300bb33d21.webp" alt=""></p>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/bc29dc9cad24a.webp" alt=""></p>
<h3>Flash U-Boot</h3>
<p>After waiting about another minute for the computer to reconnect to the router, we can access this firmware with unlocked FIP partition write permission. The default password is <code>password</code>.</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/f98051faba608.webp" alt=""></p>
<p>Select File Transfer in the sidebar and upload the U-Boot to be flashed. The filename and MD5 are below. <strong>Note: Extract the zip file first</strong></p>
<pre><code>e5ff31bac07108b6ac6cd63189b4d113  dhcp-mt7981_cudy_tr3000-fip-fixed-parts-multi-layout.bin
</code></pre>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/547c5d324f0a0.webp" alt=""></p>
<p>Then access the TTYD terminal from the sidebar, enter the default username/password root / password, and execute the command to flash U-Boot:</p>
<pre><code class="language-bash">mtd write /tmp/upload/dhcp-mt7981_cudy_tr3000-fip-fixed-parts-multi-layout.bin FIP
</code></pre>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/46b4fc5be8c82.webp" alt=""></p>
<h3>Flash Self-Compiled ImmortalWRT</h3>
<p>After flashing U-Boot, power off the router. Ensure the ethernet cable connects the computer to the router's LAN port, then press and hold the reset button while plugging in the power. Hold until the white light blinks four times and turns red, then release the reset button to enter U-Boot.</p>
<p>I compiled a 112m layout, so I need to select the <code>mod-112m</code> MTD layout before uploading and flashing the firmware.</p>
<pre><code>8c9a44f29c8c5a0617e61d49bf8ad45d  112m-immortalwrt-cudy_tr3000-ebpf_by_zhullyb_20250325-squashfs-sysupgrade.bin
</code></pre>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/41c250db91756.webp" alt=""></p>
<p>Wait again for the computer to reconnect to the router. This is the final system with daed support. Again, there's no default password - just enter anything to access. After connecting to the network, go to System - Software Packages page and update the package list.</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/e56084ff09a5d.webp" alt=""></p>
<p>Then you can install dae/daed related software. Choose <code>luci-i18n-dae-zh-cn</code> or <code>luci-i18n-daed-zh-cn</code> based on your needs. Other packages will be installed as dependencies. I installed daed here.</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/dc4fa4a688cf5.webp" alt=""></p>
<p>After installation, refresh the page and you'll see daed in the Services section of the top bar.</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/551f1f2eb9ab4.webp" alt=""></p>
<p>Daed runs normally and can fully utilize my home's 300Mbps downstream bandwidth (single-thread actual test: 250Mbps). CPU usage graph at peak speed is shown below.</p>
<p><img src="https://static.031130.xyz/uploads/2025/02/28/651bed7ad4aba.webp" alt=""></p>
<p><img src="https://static.031130.xyz/uploads/2025/03/01/9fcee79afa63b.png" alt="Multi-thread speed test"></p>
<p><img src="https://static.031130.xyz/uploads/2025/03/01/6716d723a2b0d.png" alt="Single-thread speed test"></p>
<h2>Files Mentioned in This Article</h2>
<p><a href="https://www.123684.com/s/gfprVv-wEQ8d">https://www.123684.com/s/gfprVv-wEQ8d</a></p>
<p><a href="https://www.123912.com/s/gfprVv-wEQ8d">https://www.123912.com/s/gfprVv-wEQ8d</a></p>
<h2>References</h2>
<ul>
<li><a href="https://www.right.com.cn/forum/forum.php?mod=viewthread&#x26;tid=8410353">Cudy TR3000 Flashing Tutorial Guide</a></li>
<li><a href="https://abxy.fun/post/immortalwrt-dae/">Using ImmortalWrt+Dae to Configure Transparent Proxy for Windows</a></li>
<li><a href="https://www.right.com.cn/forum/forum.php?mod=viewthread&#x26;tid=8415351">Cudy TR3000 v1 Chinese Three-Partition DHCP U-Boot Second Edition</a></li>
<li><a href="https://blog.imouto.in/#/posts/10">Booting Mainline OpenWrt Firmware Using hanwckf/bl-mt798x</a></li>
<li><a href="https://github.com/QiuSimons/luci-app-daed">QiuSimons/luci-app-daed</a></li>
</ul>]]>
    </content>
    <published>2025-02-28T13:18:34.000Z</published>
    <author>
      <name>竹林里有冰</name>
      <email>zhullyb@outlook.com</email>
      <uri>https://zhul.in</uri>
    </author>
    <category term="OpenSource Project"></category>
    <category term="Hardware"></category>
    <category term="Network"></category>
    <category term="Router"></category>
    <category term="OpenWRT"></category>
    <category term="ImmortalWRT"></category>
  </entry>
</feed>
