<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ishan Naik</title>
    <description>The latest articles on DEV Community by Ishan Naik (@ishannaik).</description>
    <link>https://dev.to/ishannaik</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3703707%2Fca7b9a1d-5a10-4c47-9785-6f08e881f539.jpg</url>
      <title>DEV Community: Ishan Naik</title>
      <link>https://dev.to/ishannaik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ishannaik"/>
    <language>en</language>
    <item>
      <title>On-Device Computer Vision in React Native: Auto-Aligning Progress Photos with MediaPipe and Expo</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:25:01 +0000</pubDate>
      <link>https://dev.to/ishannaik/on-device-computer-vision-in-react-native-auto-aligning-progress-photos-with-mediapipe-and-expo-o3j</link>
      <guid>https://dev.to/ishannaik/on-device-computer-vision-in-react-native-auto-aligning-progress-photos-with-mediapipe-and-expo-o3j</guid>
      <description>&lt;p&gt;A person can take thirty photos of the same face and still produce a timelapse that looks like camera shake. On Monday they stand closer to the mirror. On Tuesday they tilt their phone. By Friday they frame their eyes near the top of the image.&lt;/p&gt;

&lt;p&gt;For fitness, skincare, and haircut timelines, viewers need to see the change in the person without chasing their face around the screen. In &lt;a href="https://phasesapp.vercel.app" rel="noopener noreferrer"&gt;Phases&lt;/a&gt;, the useful visual reference is the eye line: keep both eyes at the same output coordinates across the sequence.&lt;/p&gt;

&lt;p&gt;This article develops the React Native and Expo architecture for that workflow: detect landmarks on the device, compute a two-point transform, retain originals in local storage, and render a reel without a rendering server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implementation scope:&lt;/strong&gt; the public Phases site currently describes a browser app that users can add to their home screen. It also describes consent-based analytics. The native Expo architecture and zero-telemetry policy below describe the reference design for this article, rather than a verified description of that deployed browser build. The project link is &lt;a href="https://github.com/Ishannaik/phases" rel="noopener noreferrer"&gt;github.com/Ishannaik/phases&lt;/a&gt;; the implementation examples below stand on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate the photograph from its alignment
&lt;/h2&gt;

&lt;p&gt;A capture guide helps the user repeat their pose, but it cannot guarantee matching camera distance or roll. I would keep the original photo and store its alignment metadata beside it. That lets the user change the reel's framing without reshooting or accumulating JPEG compression from repeated edits.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[Camera capture] --&amp;gt; B[Normalize orientation and mirroring]
    B --&amp;gt; C[On-device facial landmark detection]
    C --&amp;gt; D{Usable eye anchors?}
    D --&amp;gt;|Yes| E[Compute affine matrix]
    D --&amp;gt;|No| F[Ask user to retake or mark anchors]
    B --&amp;gt; G[Original photo in app filesystem]
    E --&amp;gt; H[SQLite metadata and transform]
    G --&amp;gt; I[Local Canvas or WebGL composite]
    H --&amp;gt; I
    I --&amp;gt; J[On-device video encoder]
    J --&amp;gt; K[Timelapse reel on device]
    G -. Optional user-enabled backup .-&amp;gt; L[Cloud backup]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The engineer should treat capture, detection, and rendering as separate steps. After a successful detection, the renderer needs the photo URI and six transform coefficients. It does not need another inference pass each time the user plays the timeline.&lt;/p&gt;

&lt;p&gt;For a native app identified as &lt;code&gt;io.phases.app&lt;/code&gt;, use its app-private filesystem for photos and SQLite for timestamps, dimensions, eye coordinates, and alignment settings. &lt;code&gt;io.phases.app&lt;/code&gt; is an application identifier, not a portable filesystem path. Android and iOS choose the sandbox's physical location.&lt;/p&gt;

&lt;p&gt;With Expo, &lt;code&gt;expo-file-system&lt;/code&gt; provides document storage, and &lt;code&gt;expo-sqlite&lt;/code&gt; provides a persistent database. Store large image files in the filesystem rather than putting base64 photo strings into database rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose eye anchors your detector can supply
&lt;/h2&gt;

&lt;p&gt;With MediaPipe, you can derive eye anchors from a face mesh. The older Face Mesh API offers iris refinement through &lt;code&gt;refine_landmarks&lt;/code&gt;; the current Face Landmarker task provides a denser landmark result. Google documents 478 landmarks for its current model bundle.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzrio6hgsiwdj7dpb5jm5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzrio6hgsiwdj7dpb5jm5.png" alt="MediaPipe canonical face mesh with landmark indices and eye-region topology" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Google's &lt;a href="https://github.com/google-ai-edge/mediapipe/blob/master/mediapipe/modules/face_geometry/data/canonical_face_model_uv_visualization.png" rel="noopener noreferrer"&gt;canonical face model UV visualization&lt;/a&gt;. This repository asset illustrates landmark topology, not a Phases screenshot or a detector result from this article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;ML Kit can provide eye landmarks through its face detection API, but an eye landmark is not equivalent to a measured pupil center. If you choose ML Kit, name those anchors eye centers. If you need iris-center estimates, choose a detector configuration that supplies iris landmarks and document your index mapping against that model version.&lt;/p&gt;

&lt;p&gt;Even an iris center gives an estimate of the visible iris, not a clinical measurement of pupil position. For framing a selfie sequence, the distinction matters because eye movement can introduce jitter. A user looking left and then right can change the anchors without moving their head. A consistent gaze and capture guide help. Eye-contour centers offer another anchor convention when gaze changes dominate the result.&lt;/p&gt;

&lt;p&gt;I would run detection on each captured still image, with one intended face. A low-resolution preview can guide capture, but I would calculate the saved transform from the final image. Preview crops and final camera output often use different coordinate systems.&lt;/p&gt;

&lt;p&gt;Before measuring the eye vector:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Decode the photograph into a consistent upright orientation.&lt;/li&gt;
&lt;li&gt;Apply the chosen mirror policy to both pixels and landmarks.&lt;/li&gt;
&lt;li&gt;Convert detector coordinates into source-image pixels.&lt;/li&gt;
&lt;li&gt;Preserve consistent eye identities across photographs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a detector that returns normalized coordinates, multiply &lt;code&gt;x&lt;/code&gt; by image width and &lt;code&gt;y&lt;/code&gt; by image height. Measuring an angle in normalized coordinates distorts it whenever width and height differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two anchors determine rotation, scale, and translation
&lt;/h2&gt;

&lt;p&gt;Let the source eye anchors be:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
p_1=(u_1,v_1),\qquad p_2=(u_2,v_2).&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;Choose fixed normalized output anchors:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
q_1=(x_1,y_1),\qquad q_2=(x_2,y_2).&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;For horizontal eyes, choose $y_1=y_2$. In an output with width $W$ and height $H$, convert those targets to pixels:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
Q_1=(Wx_1,Hy_1),\qquad Q_2=(Wx_2,Hy_2).&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;For example, on a 1080 by 1920 portrait canvas, &lt;code&gt;(0.38, 0.35)&lt;/code&gt; and &lt;code&gt;(0.62, 0.35)&lt;/code&gt; place the eyes at &lt;code&gt;(410.4, 672)&lt;/code&gt; and &lt;code&gt;(669.6, 672)&lt;/code&gt;. Their separation is 259.2 pixels. These are illustrative framing choices, not measured Phases defaults.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SOURCE PHOTO                         OUTPUT CANVAS

                 p2 *                Q1 *=============* Q2
                   /                     fixed eye axis
                  / d
             p1 *

       tilted eye axis               horizontal eye axis
       variable separation           fixed separation

             translate + rotate + uniform scale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Define the source and target eye vectors:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
d=p_2-p_1,\qquad D=Q_2-Q_1.&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;Then calculate:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
s=\frac{\lVert D\rVert}{\lVert d\rVert},\qquad&lt;br&gt;
\theta=\operatorname{atan2}(D_y,D_x)-\operatorname{atan2}(d_y,d_x).&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;Use the source midpoint $m=(p_1+p_2)/2$ and target midpoint $M=(Q_1+Q_2)/2$. For any source pixel $p$:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
T(p)=sR(\theta)(p-m)+M,&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;where:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
R(\theta)=&lt;br&gt;
\begin{bmatrix}&lt;br&gt;
\cos\theta &amp;amp; -\sin\theta\&lt;br&gt;
\sin\theta &amp;amp; \cos\theta&lt;br&gt;
\end{bmatrix}.&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;You can write this as a homogeneous affine matrix:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
\begin{bmatrix}&lt;br&gt;
x'\y'\1&lt;/p&gt;
&lt;h1&gt;
  
  
  \end{bmatrix}
&lt;/h1&gt;

&lt;p&gt;\begin{bmatrix}&lt;br&gt;
a&amp;amp;c&amp;amp;e\b&amp;amp;d&amp;amp;f\0&amp;amp;0&amp;amp;1&lt;br&gt;
\end{bmatrix}&lt;br&gt;
\begin{bmatrix}&lt;br&gt;
x\y\1&lt;br&gt;
\end{bmatrix}.&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;Two point pairs determine this &lt;strong&gt;similarity transform&lt;/strong&gt;, which uses uniform scale, rotation, and translation. They do not determine an arbitrary six-parameter affine transform with shear and independent axis scales. A similarity transform belongs to the affine family and preserves the face's proportions.&lt;/p&gt;

&lt;p&gt;For a horizontal target eye line, $\theta$ cancels the source eye angle. The coordinate system uses positive &lt;code&gt;y&lt;/code&gt; downward, as Canvas does; calculate both angles in that same system.&lt;/p&gt;
&lt;h2&gt;
  
  
  Calculate the Canvas coefficients in TypeScript
&lt;/h2&gt;

&lt;p&gt;The following reference implementation accepts source anchors in pixels and target anchors in normalized output coordinates. It returns the six values in the order that Canvas expects for &lt;code&gt;setTransform(a, b, c, d, e, f)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Point&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Readonly&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Affine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;eyeAlignment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;source1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;source2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;target1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;target2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Affine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;source1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;source1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;source2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;source2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;target1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alignment needs finite coordinates and positive dimensions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;target1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Target anchors must lie within normalized output bounds&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;source1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;source1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;target1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;target1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sourceDistance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hypot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetDistance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hypot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ty&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceDistance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;targetDistance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Eye anchors must be distinct&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targetDistance&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sourceDistance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;source2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;my&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;source2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;target2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;target2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;qx&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;mx&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;my&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;qy&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;mx&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;my&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;drawAlignedFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CanvasRenderingContext2D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CanvasImageSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;imageWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;imageHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Affine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTransform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTransform&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;imageWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;imageHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;imageWidth&lt;/code&gt; and &lt;code&gt;imageHeight&lt;/code&gt; from the same oriented image coordinate system as the source anchors. The code assumes the caller owns the canvas, with no inherited clipping region.&lt;/p&gt;

&lt;p&gt;The one-pixel check prevents division by a degenerate eye vector. Production acceptance needs a stronger, image-relative criterion: a two-pixel eye separation on a large image can still produce an unusable scale. Check confidence, face size, and acceptable scale before saving alignment metadata.&lt;/p&gt;

&lt;p&gt;To check the matrix mathematically, apply it to each source anchor. You should recover the corresponding target pixel coordinate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a*u1 + c*v1 + e = W*x1
b*u1 + d*v1 + f = H*y1

a*u2 + c*v2 + e = W*x2
b*u2 + d*v2 + f = H*y2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These equations provide useful unit-test assertions with a floating-point tolerance. They describe invariants, not test results from a running Phases build.&lt;/p&gt;

&lt;h2&gt;
  
  
  React Native needs a rendering boundary
&lt;/h2&gt;

&lt;p&gt;React Native does not expose a browser DOM Canvas as a built-in drawing surface. The &lt;code&gt;CanvasRenderingContext2D&lt;/code&gt; example belongs in a browser or a WebView. In a native renderer, adapt the same coefficients to that renderer's matrix convention.&lt;/p&gt;

&lt;p&gt;You can use a WebView-hosted Canvas for a shared web rendering path, or use a native GPU drawing surface. With WebGL, upload a photo texture and apply the transform while drawing a quad. Account for the renderer's origin, texture orientation, and matrix layout; copying Canvas coefficients into an unrelated matrix API can invert or transpose the result.&lt;/p&gt;

&lt;p&gt;For MediaPipe or ML Kit integrations that require native code outside Expo Go, create an Expo development build. Installing a JavaScript wrapper cannot add its native implementation to the Expo Go binary. Expo documents this boundary in its &lt;a href="https://docs.expo.dev/develop/development-builds/introduction/" rel="noopener noreferrer"&gt;development build guidance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Keep decoded image data near the detector and renderer. Passing a full-resolution base64 image through repeated JavaScript bridge messages adds allocation and copying. Transfer a URI or native image handle where the integration supports it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Render frames locally, then encode locally
&lt;/h2&gt;

&lt;p&gt;Drawing aligned photos on a canvas gives you frames. To export a video file, you also need an encoder and a container writer.&lt;/p&gt;

&lt;p&gt;In a native implementation, connect the rendering surface to an on-device encoder. In a browser implementation, select a supported local recording or encoding API and handle device-specific codec availability. Do not assume a Canvas screenshot sequence equals an MP4 export.&lt;/p&gt;

&lt;p&gt;For an export with one photograph per time interval, calculate presentation timestamps from the intended playback cadence. Processing speed should not determine the video's timing. A slow decode must delay export work, not stretch one day into a longer frame.&lt;/p&gt;

&lt;p&gt;Memory makes sequential processing important. One 1080 by 1920 RGBA frame takes 8,294,400 bytes, about 7.9 MiB before renderer and decoder overhead. Holding 365 such frames would consume about 2.8 GiB in pixel buffers alone. Decode a bounded number of photos, render a frame, submit it to the encoder, and release resources as the encoder permits.&lt;/p&gt;

&lt;p&gt;You should also choose how to handle uncovered canvas areas. Rotation can expose triangles at the corners; scaling cannot recover pixels outside the original capture. Use a consistent background, crop, or capture margin. Avoid changing the crop per frame, since that reintroduces apparent zoom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local storage and an optional backup path
&lt;/h2&gt;

&lt;p&gt;A useful local record links a photo URI to its capture time, oriented dimensions, source eye anchors, detector version, and alignment settings version. Retain the originals so you can recalculate transforms after changing the target eye positions.&lt;/p&gt;

&lt;p&gt;Coordinate filesystem writes with database commits. Write the image into durable document storage before committing a row that references it. If the database commit fails, remove the new file or reconcile it on the next launch. SQLite cannot roll back a separate filesystem write.&lt;/p&gt;

&lt;p&gt;The reference design uses a zero-telemetry policy: no analytics events, session replay, or automatic diagnostic uploads. Developers must apply that policy to crash-reporting SDKs and dependencies as well as application code. Local inference does not by itself guarantee zero network traffic.&lt;/p&gt;

&lt;p&gt;For offline detection on first use, package the model with the app. A model download on first launch introduces a network prerequisite even if subsequent inference stays local.&lt;/p&gt;

&lt;p&gt;Keep cloud backup outside the alignment and rendering path. A user who declines backup should still capture, align, preview, and export. If they enable backup, explain which originals and metadata you upload, request the provider's permissions, and make local deletion versus remote deletion explicit. App-private storage also does not imply encryption or exclusion from operating-system backups; configure those policies for the threat model you support.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://phasesapp.vercel.app" rel="noopener noreferrer"&gt;public Phases FAQ&lt;/a&gt; describes optional Google Drive backup and analytics that remain off until consent. Readers evaluating the deployed app should use that published policy rather than the stricter native reference policy above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eye alignment has a defined limit
&lt;/h2&gt;

&lt;p&gt;A two-eye transform corrects translation, camera roll, and apparent face scale. It cannot correct a person turning their head, pitching their chin, changing expression, or photographing themselves under a different light source. On a fitness timeline, the eyes also cannot guarantee matching shoulder position or body pose.&lt;/p&gt;

&lt;p&gt;I would keep those constraints visible in capture UX: guide the user toward the same gaze, pose, and lighting, then use alignment to absorb small framing errors. For a failed detection or an occluded eye, offer a retake or manual anchors. Do not invent a plausible transform and label it a successful alignment.&lt;/p&gt;

&lt;p&gt;Phases' core visual goal is concrete: put the eye axis in the same place across days. With consistent image coordinates, a four-degree-of-freedom transform, and a local rendering pipeline, you can achieve that framing without sending a user's progress photos to an inference or rendering service.&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://phasesapp.vercel.app" rel="noopener noreferrer"&gt;Phases and its published FAQ&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Ishannaik/phases" rel="noopener noreferrer"&gt;Phases project repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.google.dev/edge/mediapipe/solutions/vision/face_landmarker" rel="noopener noreferrer"&gt;MediaPipe Face Landmarker model and task documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/google-ai-edge/mediapipe/blob/master/docs/solutions/face_mesh.md" rel="noopener noreferrer"&gt;MediaPipe legacy Face Mesh and iris refinement&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/ml-kit/vision/face-detection/face-detection-concepts" rel="noopener noreferrer"&gt;ML Kit face detection concepts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.expo.dev/versions/latest/sdk/sqlite/" rel="noopener noreferrer"&gt;Expo SQLite&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.expo.dev/versions/latest/sdk/filesystem/" rel="noopener noreferrer"&gt;Expo filesystem&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Written by &lt;a href="https://github.com/Ishannaik" rel="noopener noreferrer"&gt;Ishan Naik&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>computervision</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Bypassing TLS Fingerprints Without Proxies: Building a Bun &amp; SQLite Ticket Watcher for BookMyShow</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:24:29 +0000</pubDate>
      <link>https://dev.to/ishannaik/bypassing-tls-fingerprints-without-proxies-building-a-bun-sqlite-ticket-watcher-for-bookmyshow-59nj</link>
      <guid>https://dev.to/ishannaik/bypassing-tls-fingerprints-without-proxies-building-a-bun-sqlite-ticket-watcher-for-bookmyshow-59nj</guid>
      <description>&lt;p&gt;I sent BookMyShow requests from an Oracle Cloud VPS and got &lt;code&gt;403&lt;/code&gt;. I changed the User-Agent, added Chrome headers, kept cookies, and forced HTTP/1.1. BookMyShow rejected those requests too.&lt;/p&gt;

&lt;p&gt;On the same VPS, &lt;code&gt;node-tls-client&lt;/code&gt; with the &lt;code&gt;safari_ios_18_0&lt;/code&gt; profile returned &lt;code&gt;200&lt;/code&gt;. I kept the datacenter IP and dropped the proxy shopping.&lt;/p&gt;

&lt;p&gt;That experiment shaped &lt;a href="https://github.com/Ishannaik/seatsniper" rel="noopener noreferrer"&gt;SeatSniper&lt;/a&gt;, my self-hosted ticket watcher. Users submit a BookMyShow link through a Discord slash command. I store their watches in SQLite, share upstream requests across users, and send notifications when matching dates or cinemas appear. I run the bot with Bun 1.2, TypeScript, and Discord.js v14.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FIshannaik%2Fseatsniper%2Fmain%2Fassets%2Fdm-preview.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FIshannaik%2Fseatsniper%2Fmain%2Fassets%2Fdm-preview.svg" alt="SeatSniper notification preview" width="620" height="246"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;SeatSniper observes listings. I do not automate purchases, hold seats, or fill carts. A notification means I found a matching listing during a poll, not that I reserved a ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnose the handshake before renting an IP
&lt;/h2&gt;

&lt;p&gt;A browser sends more than HTTP headers. During TLS negotiation, the client proposes cipher suites, extensions, supported groups, and application protocols through its ClientHello. Browser families produce recognizable combinations. A server can also inspect HTTP/2 settings after the handshake.&lt;/p&gt;

&lt;p&gt;Security teams use JA3 and JA4 to summarize aspects of those fingerprints. Neither fingerprint represents a complete browser identity, and neither tells an operator the entire reason for a Cloudflare decision. IP reputation, geography, request volume, and challenges can still affect access.&lt;/p&gt;

&lt;p&gt;For this deployment, I could isolate the client fingerprint as a practical blocker: I changed the client while keeping the VPS fixed. An apparent datacenter-IP ban can hide a TLS or HTTP/2 fingerprint problem. Buying a residential proxy would have changed another variable before I understood the first one.&lt;/p&gt;

&lt;p&gt;I recorded these results on July 27, 2026 in the repository's &lt;a href="https://github.com/Ishannaik/seatsniper/blob/main/docs/superpowers/specs/2026-07-27-bms-access-findings.md" rel="noopener noreferrer"&gt;access findings&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Oracle VPS, datacenter IP&lt;/td&gt;
&lt;td&gt;Bare curl&lt;/td&gt;
&lt;td&gt;&lt;code&gt;403&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same VPS&lt;/td&gt;
&lt;td&gt;Curl with Chrome User-Agent and headers&lt;/td&gt;
&lt;td&gt;&lt;code&gt;403&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same VPS&lt;/td&gt;
&lt;td&gt;Bun &lt;code&gt;fetch()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;403&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same VPS&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;node-tls-client&lt;/code&gt;, &lt;code&gt;safari_ios_18_0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same VPS&lt;/td&gt;
&lt;td&gt;Python &lt;code&gt;curl_cffi&lt;/code&gt;, Safari or Edge impersonation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Residential connection&lt;/td&gt;
&lt;td&gt;Curl on Raspberry Pi, OpenSSL 1.1.1w&lt;/td&gt;
&lt;td&gt;&lt;code&gt;403&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Python result matters: the language did not determine access. Python curl impersonation worked; a conventional client could fail on a residential connection.&lt;/p&gt;

&lt;p&gt;In a ten-request Safari-profile run on Oracle, I recorded ten &lt;code&gt;200&lt;/code&gt; responses, with 37 ms minimum, 48 ms median, and 116 ms maximum latency. Those numbers describe that experiment, not a latency guarantee or a benchmark against every proxy provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep the browser profile coherent
&lt;/h3&gt;

&lt;p&gt;I use &lt;a href="https://github.com/Sahil1337/node-tls-client" rel="noopener noreferrer"&gt;&lt;code&gt;node-tls-client&lt;/code&gt;&lt;/a&gt;, which wraps the Go &lt;a href="https://github.com/bogdanfinn/tls-client" rel="noopener noreferrer"&gt;&lt;code&gt;tls-client&lt;/code&gt;&lt;/a&gt; implementation. Through that stack, I select a browser-shaped TLS ClientHello and HTTP/2 configuration without launching a browser process.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://github.com/Ishannaik/seatsniper/blob/main/src/bms.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/bms.ts&lt;/code&gt;&lt;/a&gt;, I initialize the native layer once, then create a reusable session. I default to &lt;code&gt;safari_ios_18_0&lt;/code&gt; and expose &lt;code&gt;BMS_TLS_PROFILE&lt;/code&gt; for supported Safari or Firefox profile overrides.&lt;/p&gt;

&lt;p&gt;I send no custom headers on BookMyShow requests. In my measurements, adding a full Chrome header set to a working residential Bun request changed the response from &lt;code&gt;200&lt;/code&gt; to &lt;code&gt;403&lt;/code&gt;. A Chrome User-Agent does not change the TLS handshake. I treat the mismatch as a reason to keep the profile and request shape together, rather than stacking headers from unrelated examples.&lt;/p&gt;

&lt;p&gt;I cannot promise that Safari impersonation will keep working. Cloudflare can change its scoring, BookMyShow can add a JavaScript challenge, and operators outside India can encounter geographic restrictions. SeatSniper reports blocked requests instead of treating them as an absence of tickets. Respect the site's terms and restrictions; this setup provides no authorization to evade access controls or increase traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store user intent once, share upstream work per cycle
&lt;/h2&gt;

&lt;p&gt;Fifty users can watch the same film. If I fetch the same listing fifty times, I increase load without learning anything new.&lt;/p&gt;

&lt;p&gt;I separate two kinds of deduplication:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Persistent watch deduplication:&lt;/strong&gt; SQLite rejects a duplicate watch through &lt;code&gt;UNIQUE (user_id, event_code, date)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request coalescing:&lt;/strong&gt; an in-memory map shares one promise for a &lt;code&gt;(city, eventCode)&lt;/code&gt; lookup during a poll cycle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SQLite stores the watch queue and notification history. I do not store the network cache in SQLite. The per-cycle promise maps live in &lt;a href="https://github.com/Ishannaik/seatsniper/blob/main/src/bms.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/bms.ts&lt;/code&gt;&lt;/a&gt;, and I reset them through &lt;code&gt;beginCycle()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    actor User
    participant Bot as Discord bot
    participant DB as SQLite watch queue
    participant Cache as Per-cycle promise map
    participant BMS as BookMyShow via Safari TLS
    participant Discord as Discord delivery

    User-&amp;gt;&amp;gt;Bot: /watch link date filters
    Bot-&amp;gt;&amp;gt;BMS: Validate movie through Safari profile
    BMS--&amp;gt;&amp;gt;Bot: Movie title and listing data
    Bot-&amp;gt;&amp;gt;DB: Insert watch with unique user/event/date
    DB--&amp;gt;&amp;gt;Bot: Saved ID or duplicate
    Bot--&amp;gt;&amp;gt;User: Watch confirmation

    loop Each poll cycle
        Bot-&amp;gt;&amp;gt;DB: Read active watches
        Bot-&amp;gt;&amp;gt;Cache: Clear previous cycle
        Bot-&amp;gt;&amp;gt;Cache: Lookup city and event
        alt First watch for this movie
            Cache-&amp;gt;&amp;gt;BMS: Fetch bookable dates
            BMS--&amp;gt;&amp;gt;Cache: Dates, venues, format chips
        else Another watch for same movie
            Cache--&amp;gt;&amp;gt;Bot: Reuse stored promise
        end
        Cache--&amp;gt;&amp;gt;Bot: Shared availability result
        Bot-&amp;gt;&amp;gt;Bot: Apply watch-specific filters
        opt Matching new availability
            Bot-&amp;gt;&amp;gt;Discord: Send direct message
            Discord--&amp;gt;&amp;gt;User: Ticket notification
            Discord--&amp;gt;&amp;gt;Bot: Delivery outcome
            Bot-&amp;gt;&amp;gt;DB: Record seen state or retire dated watch
        end
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;I include the city in the cache key because BookMyShow returns different cinemas and showtimes for Mumbai, Delhi, and Pune. I omit the slug because I measured that BookMyShow uses the event code as the authoritative movie identifier.&lt;/p&gt;

&lt;p&gt;For fifty watches on the same movie and city, I make &lt;strong&gt;one shared bookable-date request&lt;/strong&gt; per cycle. That does not mean the entire cycle must cost one request. Once a date opens, I may need showtimes for it. I cache those requests under &lt;code&gt;(city, eventCode, date)&lt;/code&gt;. Format-specific child events can add requests too.&lt;/p&gt;

&lt;h3&gt;
  
  
  A coalesced loop with rate jitter
&lt;/h3&gt;

&lt;p&gt;This reduced excerpt shows the repository's polling structure. &lt;code&gt;checkWatch()&lt;/code&gt; uses &lt;code&gt;bookableDatesCached()&lt;/code&gt; and, when needed, &lt;code&gt;fetchShowtimesCached()&lt;/code&gt;. I omit command handling and notification formatting so the cache boundary remains visible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;beginCycle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;coalescedCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./bms.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;allWatches&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./db.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;staggerBounds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;staggerDelayMs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./stagger.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bounds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;staggerBounds&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Defaults: 2000 to 5000 ms&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;poll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;watches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;allWatches&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;beginCycle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;watch&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;watches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Existing application functions from src/index.ts:&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expireStale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkWatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[watch &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] poll failed:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Bun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;staggerDelayMs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`[poll] &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;watches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; watches; `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;coalescedCount&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt; requests saved`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the cache, I insert the promise before returning it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetchBookableDates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;cycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By caching the promise, I share an in-flight request as well as a completed answer. I keep that answer within the cycle, then fetch fresh data next time. A rejected promise also remains shared within that cycle, so I avoid fifty retries against the same blocked endpoint.&lt;/p&gt;

&lt;p&gt;I configure the delay through &lt;code&gt;STAGGER_MS_MIN&lt;/code&gt; and &lt;code&gt;STAGGER_MS_MAX&lt;/code&gt;. The defaults sample a uniform delay from 2,000 ms up to 5,000 ms between watches. Jitter spreads activity; it does not grant permission to poll faster or solve a challenge.&lt;/p&gt;

&lt;p&gt;I also pay for that pacing in notification latency. Fifty watches incur about 175 seconds of stagger delay on average, before network and delivery time. The repository schedules polls every 600 seconds by default. Users should read that as periodic detection, not an instantaneous guarantee. At larger watch counts, an operator must account for cycle duration and the current interval scheduler's potential for overlapping cycles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the date on each show
&lt;/h2&gt;

&lt;p&gt;An HTTP &lt;code&gt;200&lt;/code&gt; and a page full of showtimes can still produce the wrong answer.&lt;/p&gt;

&lt;p&gt;BookMyShow can return the nearest bookable date when I request a date that has not opened. The URL and page-level current-date field can echo my requested date while the show objects describe a different day.&lt;/p&gt;

&lt;p&gt;I check the &lt;code&gt;showDateCode&lt;/code&gt; inside each show's &lt;code&gt;additionalData&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matching&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;shows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;showDateCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;requestedDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the recorded test, I requested July 30 and parsed 34 shows. None carried &lt;code&gt;showDateCode: "20260730"&lt;/code&gt;; they belonged to July 27. Counting shows would have produced a false notification.&lt;/p&gt;

&lt;p&gt;For the shared movie-level check, I fetch the buytickets HTML using the probe date &lt;code&gt;20991231&lt;/code&gt;. BookMyShow includes the requested date in its date list, so I remove that sentinel from the parsed &lt;code&gt;dateCode&lt;/code&gt; values. I verified the remaining date sets against individual date requests for three films in the recorded investigation. After a positive date-level result, I inspect actual showtimes before sending a dated-watch alert.&lt;/p&gt;

&lt;p&gt;I validate the page before accepting an empty result. The current provider checks for &lt;code&gt;window.__INITIAL_STATE__&lt;/code&gt; and a movie title. A challenge page without the app marker raises an error. A recognizable movie page with no &lt;code&gt;showtimesSections&lt;/code&gt; can represent a legitimate empty listing. That distinction matters when upstream HTML changes: I need an error for an unknown page, not a fabricated “tickets have not opened” answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the filter engine tied to show data
&lt;/h2&gt;

&lt;p&gt;A watcher becomes noisy if a user wants IMAX and I notify them about any screening.&lt;/p&gt;

&lt;p&gt;Users can submit filters through &lt;code&gt;/watch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/watch link:&amp;lt;BookMyShow URL&amp;gt; date:any
       format:IMAX,4DX theatre:PVR,INOX
       days:fri,sat after:18:00 before:23:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I normalize format names and compare them without case or whitespace sensitivity. &lt;code&gt;IMAX&lt;/code&gt; matches &lt;code&gt;IMAX 3D&lt;/code&gt;; &lt;code&gt;SCREENX&lt;/code&gt; matches &lt;code&gt;SCREEN X&lt;/code&gt;. BookMyShow also lists some formats under separate child event codes. I parse the format-selector chips and inspect matching child events, rather than assuming the parent event contains every screen type.&lt;/p&gt;

&lt;p&gt;For theatres, I match a case-insensitive substring of the venue name or venue code. A user can choose &lt;code&gt;PVR&lt;/code&gt; or paste a code such as &lt;code&gt;IMOB&lt;/code&gt;. I apply theatre restrictions to new-cinema alerts using the venue data I already fetched.&lt;/p&gt;

&lt;p&gt;Time windows use Indian Standard Time, UTC+05:30, regardless of the host's timezone. I convert BookMyShow's IST show datetime into an epoch and compare the show's IST wall-clock minutes against the user's bounds. I treat &lt;code&gt;after:18:00&lt;/code&gt; as inclusive and &lt;code&gt;before:23:00&lt;/code&gt; as exclusive. I reject a window that wraps through midnight; users cannot express &lt;code&gt;after:23:00 before:01:00&lt;/code&gt; as one watch window.&lt;/p&gt;

&lt;p&gt;Subscriptions need an extra distinction. A &lt;code&gt;date:any&lt;/code&gt; watch can report a new date or a new cinema. I apply format, weekday, and showtime checks to date alerts. I do not claim that a new-cinema alert satisfies those checks: I detect fresh cinemas by venue code, and apply the theatre filter there. The &lt;a href="https://github.com/Ishannaik/seatsniper#commands" rel="noopener noreferrer"&gt;command documentation&lt;/a&gt; spells out that boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persist notification history, then handle delivery failure
&lt;/h2&gt;

&lt;p&gt;I use Bun's built-in &lt;a href="https://bun.sh/docs/api/sqlite" rel="noopener noreferrer"&gt;&lt;code&gt;bun:sqlite&lt;/code&gt;&lt;/a&gt; API in &lt;a href="https://github.com/Ishannaik/seatsniper/blob/main/src/db.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/db.ts&lt;/code&gt;&lt;/a&gt;. I keep &lt;code&gt;watches&lt;/code&gt;, &lt;code&gt;seen_dates&lt;/code&gt;, and &lt;code&gt;seen_venues&lt;/code&gt; in &lt;code&gt;seatsniper.db&lt;/code&gt;, or the path configured through &lt;code&gt;DB_PATH&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;seatsniper.db
+ watches
|   user, city, event, date, filters, failure state
+ seen_dates
|   watch_id + date_code
+ seen_venues
    watch_id + venue_code

Per-cycle memory
+ city | event         -&amp;gt; Promise&amp;lt;bookable dates&amp;gt;
+ city | event | date  -&amp;gt; Promise&amp;lt;showtimes&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I enable WAL mode and foreign keys. WAL mode can create &lt;code&gt;seatsniper.db-wal&lt;/code&gt; and &lt;code&gt;seatsniper.db-shm&lt;/code&gt;, so “single-file database” describes deployment simplicity, not a promise that a running SQLite process uses only one filesystem entry. For live backups, use SQLite-aware backup tooling rather than copying the main file while ignoring pending WAL writes.&lt;/p&gt;

&lt;p&gt;I seed a subscription's seen-state from the listings available when the user creates it. I then notify them about later additions. For a dated watch, I send one alert and retire the watch after successful delivery.&lt;/p&gt;

&lt;p&gt;I attempt the DM first. If Discord rejects it, I try the originating text channel and tell the user that I used the fallback. If both attempts fail, I retain the dated watch or leave subscription additions unrecorded, then retry on a later poll.&lt;/p&gt;

&lt;p&gt;I record seen-state after delivery. That ordering reduces lost notifications, but I cannot make SQLite writes and Discord delivery one atomic transaction. A crash after Discord accepts a message and before I commit seen-state can cause a duplicate on restart. Operators should expect that failure window rather than promise exactly-once messaging.&lt;/p&gt;

&lt;p&gt;For upstream checks, I track consecutive failures and send a warning at the third failure. I reset the counter after a successful check. That lets a user distinguish an unavailable movie date from a watcher that cannot read BookMyShow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it without adding infrastructure
&lt;/h2&gt;

&lt;p&gt;I keep the manual path small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Ishannaik/seatsniper.git
&lt;span class="nb"&gt;cd &lt;/span&gt;seatsniper
bun &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your Discord bot token as &lt;code&gt;DISCORD_TOKEN&lt;/code&gt; and application ID as &lt;code&gt;DISCORD_CLIENT_ID&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;. Keep those values out of Git. Then register commands and start the bot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run commands
bun run start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository also provides Docker instructions and a Linux/macOS installer. I would inspect the installer before running a downloaded shell script. Windows users can use manual setup or Docker.&lt;/p&gt;

&lt;p&gt;For monitoring, I can configure &lt;code&gt;UPTIME_KUMA_PUSH_URL&lt;/code&gt;. I send its heartbeat after a completed poll, including idle cycles. A running container alone does not prove that the bot logged into Discord or completed an upstream check.&lt;/p&gt;

&lt;p&gt;I chose this design for a small self-hosted service: one Bun process, one local SQLite database, one native TLS client, and Discord delivery. I still have to preserve the database volume, monitor failed polls, and react when BookMyShow changes its HTML or access policy.&lt;/p&gt;

&lt;p&gt;The two decisions I would repeat are the controlled client experiment and the per-cycle cache. By keeping the VPS fixed, I found a working transport without paying for residential egress. By sharing each movie lookup, I let fifty users observe the same release without sending fifty copies of the same request.&lt;/p&gt;

&lt;p&gt;Source and deployment instructions: &lt;a href="https://github.com/Ishannaik/seatsniper" rel="noopener noreferrer"&gt;Ishannaik/seatsniper&lt;/a&gt;. The &lt;a href="https://github.com/Ishannaik/seatsniper/blob/main/docs/superpowers/specs/2026-07-27-bms-access-findings.md" rel="noopener noreferrer"&gt;measured access findings&lt;/a&gt; include the response matrix, date-substitution tests, and limits behind the claims above.&lt;/p&gt;

</description>
      <category>bunjs</category>
      <category>typescript</category>
      <category>sqlite</category>
      <category>automation</category>
    </item>
    <item>
      <title>Building a Hyperlocal Rain Nowcaster on a $0 Stack: Benchmarking Open-Meteo Against Airport Radar</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:23:54 +0000</pubDate>
      <link>https://dev.to/ishannaik/building-a-hyperlocal-rain-nowcaster-on-a-0-stack-benchmarking-open-meteo-against-airport-radar-1jnc</link>
      <guid>https://dev.to/ishannaik/building-a-hyperlocal-rain-nowcaster-on-a-0-stack-benchmarking-open-meteo-against-airport-radar-1jnc</guid>
      <description>&lt;p&gt;A rain forecast for Mumbai can tell you to carry an umbrella while your street stays dry. During the monsoon, a useful next-two-hour prediction needs more local evidence than a global model alone can provide.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://rain.ishannaik.com" rel="noopener noreferrer"&gt;Mumbai Rain&lt;/a&gt;, also called पाऊस, around that problem. I collect forecasts, compare them with observations at Mumbai airport, and train a small correction that visitors run in their browsers.&lt;/p&gt;

&lt;p&gt;First, a qualification about the title: &lt;strong&gt;I do not ingest airport radar.&lt;/strong&gt; I use METAR surface-weather reports from station &lt;strong&gt;VABB&lt;/strong&gt;, Chhatrapati Shivaji Maharaj International Airport. Airport observers and instruments report present weather; I convert their precipitation codes into hourly rain labels. Radar measures precipitation across an area. A METAR report describes conditions at a station. That distinction sets the limits of the entire experiment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jdxutr72r8iied1fy79.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jdxutr72r8iied1fy79.png" alt="Mumbai Rain social preview card from the public repository" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/Ishannaik/mumbai-rain" rel="noopener noreferrer"&gt;source repository&lt;/a&gt; contains the data diary, training code, browser arithmetic, and exported model. You can inspect the &lt;a href="https://rain.ishannaik.com/about" rel="noopener noreferrer"&gt;method&lt;/a&gt; and &lt;a href="https://rain.ishannaik.com/scoreboard" rel="noopener noreferrer"&gt;scoreboard&lt;/a&gt; without accepting a headline accuracy claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a narrower prediction target
&lt;/h2&gt;

&lt;p&gt;Engineers who build global models such as ECMWF and GFS solve atmospheric dynamics over grids. A forecast grid cell cannot resolve each Mumbai street, coastal exposure, or passing monsoon shower. Asking for a coordinate does not create street-level observational coverage.&lt;/p&gt;

&lt;p&gt;For this project, I ask a narrower question: given the forecast inputs available now, how likely is an hourly rain observation at the airport?&lt;/p&gt;

&lt;p&gt;I use Open-Meteo as the delivery API for two forecast streams:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;best_match&lt;/code&gt;, the primary forecast feed, including precipitation and relative humidity.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ecmwf_ifs025&lt;/code&gt;, an explicit ECMWF-IFS precipitation benchmark.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I do not train a replacement for ECMWF. I train logistic regression to correct the relationship between forecast features and an independent binary observation. Visitors receive a local-coordinate forecast with a correction learned at VABB. They should not read that as a measured rain probability for every neighbourhood.&lt;/p&gt;

&lt;h2&gt;
  
  
  Collect forecasts before the outcome arrives
&lt;/h2&gt;

&lt;p&gt;The hourly &lt;a href="https://github.com/Ishannaik/mumbai-rain/blob/main/.github/workflows/collect.yml" rel="noopener noreferrer"&gt;collection workflow&lt;/a&gt; schedules a GitHub Actions run at minute five:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub can delay scheduled jobs. I treat the cron as a collection schedule, not a real-time delivery guarantee.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://github.com/Ishannaik/mumbai-rain/blob/main/pipeline/log_snapshot.py" rel="noopener noreferrer"&gt;&lt;code&gt;pipeline/log_snapshot.py&lt;/code&gt;&lt;/a&gt;, I collect at &lt;code&gt;(19.12, 72.85)&lt;/code&gt;, record the forecast issue time, and append forecast-hour rows to &lt;code&gt;data/log.csv&lt;/code&gt;. On subsequent runs, I fetch the last 24 hours of METAR reports and fill labels for matching hours.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    Clock[Hourly GitHub Actions collection] --&amp;gt; Forecast[Open-Meteo best_match and ECMWF-IFS]
    Forecast --&amp;gt; Diary[data/log.csv: forecasts with blank labels]
    Airport[VABB METAR present-weather reports] --&amp;gt; Labels[Convert UTC to IST; label RA and DZ]
    Labels --&amp;gt; Diary
    Diary --&amp;gt; Filter[Keep labelled forward forecasts]
    Daily[Daily GitHub Actions retraining] --&amp;gt; Filter
    Filter --&amp;gt; Train[Fit logistic regression]
    Train --&amp;gt; Gate[Holdout scores and purged walk-forward folds]
    Gate --&amp;gt; Decision{Promotion criteria met?}
    Decision --&amp;gt;|Yes| Export[public/model.json]
    Decision --&amp;gt;|No| Retain[Retain champion or evaluate raw fallback]
    Export --&amp;gt; CDN[Astro assets on CDN]
    CDN --&amp;gt; Browser[Browser loads model]
    Live[Live Open-Meteo forecast] --&amp;gt; Browser
    Browser --&amp;gt; Math[Six features and sigmoid]
    Math --&amp;gt; Verdict[Hourly rain calls and next-two-hour verdict]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;I record both timestamps because they answer different questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;issued_at          Forecast snapshot time, UTC
valid_at           Forecast target hour, Asia/Kolkata
fc_bestmatch_mm    Primary forecast precipitation
fc_ecmwf_mm        ECMWF forecast precipitation
fc_rh_bestmatch    Forecast relative humidity
hour               Target local hour
recent_rain_mm     Recent precipitation feature
observed_raining   Blank until labelled, then 0 or 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I can then ask what I knew when I issued a forecast, rather than download a later forecast and pretend I had predicted the outcome.&lt;/p&gt;

&lt;p&gt;The collector stores the returned day's hourly series, including hours that may precede the issue time. Before training, I filter those rows out. In &lt;code&gt;matured()&lt;/code&gt;, I convert &lt;code&gt;issued_at&lt;/code&gt; from UTC to IST and require &lt;code&gt;valid_at &amp;gt;= issued_at&lt;/code&gt;. I train on forward forecasts because the browser serves forward hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Label rain without grading a forecast against itself
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://github.com/Ishannaik/mumbai-rain/blob/main/pipeline/labels.py" rel="noopener noreferrer"&gt;&lt;code&gt;pipeline/labels.py&lt;/code&gt;&lt;/a&gt;, I read the METAR &lt;code&gt;wxString&lt;/code&gt; field:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Report token&lt;/th&gt;
&lt;th&gt;Label interpretation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;RA&lt;/code&gt;, including &lt;code&gt;-RA&lt;/code&gt;, &lt;code&gt;+RA&lt;/code&gt;, &lt;code&gt;SHRA&lt;/code&gt;, &lt;code&gt;TSRA&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DZ&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Drizzle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BR&lt;/code&gt;, &lt;code&gt;HZ&lt;/code&gt;, &lt;code&gt;FG&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Mist, haze, fog: not a rain label&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I convert each report timestamp from UTC to IST and floor it to the local hour. If I find multiple reports in one hour, I label that hour as rainy if any report includes rain or drizzle.&lt;/p&gt;

&lt;p&gt;For a missing report, I leave the label blank. I do not equate missing observations with dry weather.&lt;/p&gt;

&lt;p&gt;This gives me an independent target, with several qualifications. Airport reports can miss a shower between observations. A binary label gives me no measured rainfall amount. An hourly label loses sub-hour timing. And a dry airport cannot establish that Bandra, Thane, or a visitor's GPS coordinate stayed dry.&lt;/p&gt;

&lt;p&gt;I also keep the recent-rain feature separate from the label. Despite the name &lt;code&gt;recent_rain_mm&lt;/code&gt;, I compute that feature from Open-Meteo's precipitation series over the three hours before the current hour. I do not obtain a neighbourhood rain-gauge measurement through that field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Git as the database, within its limits
&lt;/h2&gt;

&lt;p&gt;I keep the diary in &lt;a href="https://github.com/Ishannaik/mumbai-rain/blob/main/data/log.csv" rel="noopener noreferrer"&gt;&lt;code&gt;data/log.csv&lt;/code&gt;&lt;/a&gt;. The bot commits the changed CSV and health metrics after collection. In each row, I retain the forecast and later add the observed label.&lt;/p&gt;

&lt;p&gt;For one collection point and hourly jobs, I get a readable history and a training dataset that contributors can download without credentials. I also avoid provisioning a database just to collect this experiment.&lt;/p&gt;

&lt;p&gt;I accept the costs of that choice. The logger reads and rewrites the CSV. Repeated commits increase repository history. Separate collection and training workflows can encounter competing pushes; their individual concurrency groups do not create a shared database transaction. Git suits the current small workload, but I would revisit storage before collecting hundreds of stations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the daily model earn deployment
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/Ishannaik/mumbai-rain/blob/main/.github/workflows/retrain.yml" rel="noopener noreferrer"&gt;retraining workflow&lt;/a&gt; schedules a run at &lt;code&gt;01:30 UTC&lt;/code&gt;. It runs the Python tests, trains the candidate, refreshes health metrics, and commits an artifact if the training decision changes it.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://github.com/Ishannaik/mumbai-rain/blob/main/pipeline/train.py" rel="noopener noreferrer"&gt;&lt;code&gt;pipeline/train.py&lt;/code&gt;&lt;/a&gt;, I require at least 200 labelled forward rows. I also require both rainy and dry examples in training and holdout data.&lt;/p&gt;

&lt;p&gt;I fit six features in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;x = [best_match_mm, ecmwf_mm, relative_humidity,
     sin(2π hour / 24), cos(2π hour / 24), recent_rain_mm]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I encode the hour as sine and cosine so midnight and 23:00 remain neighbours in the feature space. I keep the feature order identical in Python and &lt;a href="https://github.com/Ishannaik/mumbai-rain/blob/main/src/lib/nowcast.js" rel="noopener noreferrer"&gt;&lt;code&gt;src/lib/nowcast.js&lt;/code&gt;&lt;/a&gt;. Swapping humidity and precipitation would produce valid JavaScript and invalid predictions.&lt;/p&gt;

&lt;p&gt;For evaluation, I use the Brier score:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
\operatorname{Brier} = \frac{1}{N}\sum_{i=1}^{N}(p_i-y_i)^2&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;A confident wrong prediction incurs a large penalty. A lower score means lower squared probability error on the evaluated observations.&lt;/p&gt;

&lt;p&gt;I compare the candidate against the existing logistic champion, a raw rain call, and climatology: the training set's rain frequency. The main holdout contains the last 20% of rows after time sorting.&lt;/p&gt;
&lt;h3&gt;
  
  
  Purge by time, not row count
&lt;/h3&gt;

&lt;p&gt;One target hour can appear in several forecast snapshots. If I shuffle rows, I can train on one prediction of an hour and test on another prediction of the same observed event.&lt;/p&gt;

&lt;p&gt;I add four expanding-window walk-forward folds. I place boundaries between distinct &lt;code&gt;valid_at&lt;/code&gt; timestamps and purge six hours before each test window. I skip folds with fewer than 25 training or test rows, or without both label classes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Time advances to the right

Fold 1: [training] [6h purge] [test]
Fold 2: [     expanded training     ] [6h purge] [test]
Fold 3: [          expanded training          ] [6h purge] [test]
Fold 4: [               expanded training               ] [6h purge] [test]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each fold, I calculate Brier skill against the raw call:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
\operatorname{BSS} = 1 - \frac{\operatorname{Brier}&lt;em&gt;{candidate}}{\operatorname{Brier}&lt;/em&gt;{raw}}&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;I require at least two usable folds, a positive median skill, and at least two positive fold scores. For a perfect raw reference, the implementation reports zero skill rather than divide by zero.&lt;/p&gt;

&lt;p&gt;This excerpt expresses the promotion decision using the functions and score variables from the training module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Score candidate, champion, raw call, and climatology on the holdout.
# Calculate fold_skills with purged expanding-window evaluation first.
&lt;/span&gt;&lt;span class="n"&gt;walk_forward_ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;passes_walk_forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fold_skills&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;walk_forward_ok&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;passes_gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;cand_brier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cand_b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;champ_brier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;champ_b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;raw_brier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;raw_b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;clim_brier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;clim_b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MODEL_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two implementation details matter when interpreting the benchmark:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The automated raw gate uses &lt;code&gt;best_match&lt;/code&gt;, not ECMWF alone.&lt;/strong&gt; The code thresholds the first feature at &lt;code&gt;0.3 mm/h&lt;/code&gt; to construct its raw binary forecast. I collect ECMWF as a separate benchmark and model input, but I cannot describe &lt;code&gt;raw_brier&lt;/code&gt; as an ECMWF-only result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The holdout gate accepts ties.&lt;/strong&gt; &lt;code&gt;passes_gate()&lt;/code&gt; rejects a candidate whose Brier score exceeds a reference score. The walk-forward median must exceed zero, but the holdout comparisons mean “no worse,” rather than a strict improvement against each reference.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I should also avoid promising that future skill can only improve. I evaluate each deployment against the available historical data. Weather regimes change. The code includes a conditional raw-forecast fallback when a champion loses to the raw forecast or climatology and the walk-forward check permits that decision path.&lt;/p&gt;

&lt;p&gt;The four purged folds reduce temporal leakage, but the separate 80/20 row split does not use the same timestamp-grouping and purge logic. Repeated target hours can straddle that split. I treat the walk-forward requirement as essential and retain this limitation when reading the holdout scores.&lt;/p&gt;

&lt;h3&gt;
  
  
  A scorecard you can inspect
&lt;/h3&gt;

&lt;p&gt;The repository's &lt;a href="https://github.com/Ishannaik/mumbai-rain/blob/main/public/model.json" rel="noopener noreferrer"&gt;&lt;code&gt;public/model.json&lt;/code&gt;&lt;/a&gt;, stamped &lt;code&gt;2026-10-06T07:40:36Z&lt;/code&gt;, records:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Artifact field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Training rows&lt;/td&gt;
&lt;td&gt;6,904&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Holdout rows&lt;/td&gt;
&lt;td&gt;1,727&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Candidate Brier&lt;/td&gt;
&lt;td&gt;0.0936&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw best-match call Brier&lt;/td&gt;
&lt;td&gt;0.2131&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Climatology Brier&lt;/td&gt;
&lt;td&gt;0.1088&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Previous champion Brier&lt;/td&gt;
&lt;td&gt;0.0943&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Walk-forward median BSS&lt;/td&gt;
&lt;td&gt;0.6236&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The four fold skill values are &lt;code&gt;0.6555&lt;/code&gt;, &lt;code&gt;0.8399&lt;/code&gt;, &lt;code&gt;0.5918&lt;/code&gt;, and &lt;code&gt;0.5734&lt;/code&gt;. These are historical artifact values, not a benchmark I reran for this article. The row counts include repeated target hours from different issue times; they do not represent 8,631 independent weather events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Export arithmetic, serve it in the browser
&lt;/h2&gt;

&lt;p&gt;I export six coefficients, an intercept, feature names, and evaluation metadata to &lt;code&gt;public/model.json&lt;/code&gt;. The payload fits within roughly 2 KB; the current artifact is smaller. Visitors fetch the model as a static asset, then fetch a live Open-Meteo forecast at their selected coordinates.&lt;/p&gt;

&lt;p&gt;For each hour, I calculate:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
z = b + \sum_{j=1}^{6} w_j x_j,&lt;br&gt;
\qquad p = \frac{1}{1+e^{-z}}&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;Here is an illustrative input using the coefficients in the dated artifact, not a recorded weather observation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;best_match precipitation    1.0 mm
ECMWF precipitation         0.6 mm
relative humidity          90.0 percent
local hour                 18:00
hour sine                  -1.0
hour cosine                 0.0
recent precipitation        2.0 mm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The arithmetic is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;z = -16.791748
    + 0.300222 × 1.0
    + 0.667161 × 0.6
    + 0.173399 × 90.0
    + (-0.271461) × (-1.0)
    + (-0.900516) × 0.0
    + 0.041041 × 2.0

z ≈ -0.131776
p = 1 / (1 + exp(0.131776)) ≈ 0.4671
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser calls an hour rainy at &lt;code&gt;p &amp;gt;= 0.5&lt;/code&gt;, so this example falls below the rain-call threshold even though both raw precipitation inputs exceed &lt;code&gt;0.3 mm/h&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The JavaScript implementation clamps the logit to the same range as Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;intercept&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;features&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;probability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;clamped&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I evaluate each forecast hour in the requested window and build the verdict from those hourly calls. When I display precipitation intensity, I use the raw forecast amount. Logistic regression returns a probability, not millimetres of rain. The maximum hourly probability also should not be interpreted as the probability of any rain across the entire window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Account for the $0 claim
&lt;/h2&gt;

&lt;p&gt;I use an Astro site, static model assets, keyless forecast requests, and GitHub Actions jobs on a public repository. I do not provision a backend ML inference server. For the browser path, visitors perform the six-feature calculation on their own devices.&lt;/p&gt;

&lt;p&gt;That removes a network round trip to an inference service. It does &lt;strong&gt;not&lt;/strong&gt; make the application zero-latency: visitors still download assets, fetch Open-Meteo responses, and wait on network conditions. The repository also includes an on-demand &lt;code&gt;/api/nowcast&lt;/code&gt; endpoint for API consumers; the browser inference path does not require that endpoint.&lt;/p&gt;

&lt;p&gt;The operating stack fits the project's current free-tier usage. I do not treat &lt;code&gt;$0&lt;/code&gt; as an unlimited-capacity guarantee. Forecast-provider terms, rate limits, hosting allowances, and scheduled-job availability still apply, and domain registration sits outside this runtime budget.&lt;/p&gt;

&lt;p&gt;You can inspect the pipeline locally with the repository's documented commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv &lt;span class="nb"&gt;sync
&lt;/span&gt;uv run python &lt;span class="nt"&gt;-m&lt;/span&gt; pipeline.health
uv run python &lt;span class="nt"&gt;-m&lt;/span&gt; pipeline.train
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The training command applies the promotion decision and can overwrite &lt;code&gt;public/model.json&lt;/code&gt; if the candidate qualifies. Use a working copy when experimenting.&lt;/p&gt;

&lt;p&gt;For this project, I care about the chain from a forecast captured before an event to an independent observation and a deployment decision I can inspect. I keep that chain in a CSV, a Python module, and a small JSON artifact. Until I collect observations beyond VABB, I will describe Mumbai Rain as a forecast corrector trained at the airport, with neighbourhood forecasts and explicit geographic limits.&lt;/p&gt;

</description>
      <category>python</category>
      <category>astro</category>
      <category>weather</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Local-First Semantic Search on Windows: Running SigLIP2 and DINOv3 with Tauri 2 and ONNX</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:23:22 +0000</pubDate>
      <link>https://dev.to/ishannaik/local-first-semantic-search-on-windows-running-siglip2-and-dinov3-with-tauri-2-and-onnx-19f7</link>
      <guid>https://dev.to/ishannaik/local-first-semantic-search-on-windows-running-siglip2-and-dinov3-with-tauri-2-and-onnx-19f7</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FIshannaik%2Fpixafind-releases%2Fmain%2Fassets%2Flogo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FIshannaik%2Fpixafind-releases%2Fmain%2Fassets%2Flogo.png" alt="PixaFind" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://pixafind.com" rel="noopener noreferrer"&gt;PixaFind&lt;/a&gt; for the search you do when you remember a file's contents but have forgotten its name. You might remember a cat sitting in sunlight, a whiteboard from a meeting, or a paragraph about a budget. None of those memories give you a path to type into Explorer.&lt;/p&gt;

&lt;p&gt;On Windows, I use a Tauri 2 shell, a Rust backend, ONNX Runtime inference, and local SQLite vector storage. SigLIP2 handles text-to-image search. DINOv3 handles image-to-image similarity. For documents, I combine dense embeddings with a BM25 keyword index.&lt;/p&gt;

&lt;p&gt;The engineering cost sits in the indexing pipeline: decoding files, preparing model inputs, running inference, and keeping the index consistent as users edit their folders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the inference boundary on the user's machine
&lt;/h2&gt;

&lt;p&gt;I keep file access, inference, and retrieval in the Rust backend. Users interact with a floating search window through a global hotkey, &lt;code&gt;Ctrl+Space&lt;/code&gt;, without switching away from their current application.&lt;/p&gt;

&lt;p&gt;The native Tauri shell weighs about 5 MB. That figure describes the executable, not the full installation. Users also need model weights, ONNX Runtime dependencies, and index storage. The &lt;a href="https://github.com/Ishannaik/pixafind-releases#download--install" rel="noopener noreferrer"&gt;release README&lt;/a&gt; describes a one-time model download of roughly 500 MB.&lt;/p&gt;

&lt;p&gt;Tauri uses the system webview on Windows. I avoid shipping a separate Chromium distribution, but I still have to account for webview memory, model memory, and GPU allocations. A small executable does not imply a 5 MB runtime footprint.&lt;/p&gt;

&lt;p&gt;This diagram shows the architecture at the component level, rather than private source-code interfaces:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    F["Selected folders"] --&amp;gt; W["Filesystem watcher"]
    W --&amp;gt; C["Rust: mtime and SHA-256 change detection"]
    C --&amp;gt; P["Decode images or extract document text"]
    P --&amp;gt; O["ONNX Runtime: DirectML, CUDA, or CPU"]
    O --&amp;gt; V["Vector DB: local SQLite storage"]
    P --&amp;gt; B["BM25 keyword index"]
    V --&amp;gt; R["Rust retrieval and ranking"]
    B --&amp;gt; R
    H["Global hotkey"] --&amp;gt; U["Spotlight UI: Tauri 2"]
    U --&amp;gt; Q["Query or selected image"]
    Q --&amp;gt; O
    R --&amp;gt; U&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;I separate background indexing from interactive retrieval because the two workloads compete for resources. During indexing, a user may still want to search the files that have finished processing. An implementation needs bounded work queues and an explicit policy for sharing GPU time; launching an inference task for every filesystem event would exhaust memory before it improved throughput.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use SigLIP2 for descriptions, DINOv3 for visual neighbors
&lt;/h2&gt;

&lt;p&gt;For a query such as &lt;code&gt;cat in a sunbeam&lt;/code&gt;, I need an image representation that shares a space with a text representation. SigLIP2 supplies that alignment. During indexing, I encode an image. At search time, I encode the user's description and compare it with the stored image embeddings.&lt;/p&gt;

&lt;p&gt;The user does not need to tag the image or include the word &lt;code&gt;cat&lt;/code&gt; in its filename. Zero-shot matching means I can search with a description without training a classifier for each phrase.&lt;/p&gt;

&lt;p&gt;For normalized embeddings, cosine similarity reduces to a dot product:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query text -&amp;gt; SigLIP2 text encoder  -&amp;gt; q
photo      -&amp;gt; SigLIP2 image encoder -&amp;gt; v

score(q, v) = sum(q[i] * v[i])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A description such as &lt;code&gt;blurry photo of whiteboard&lt;/code&gt; asks for visual meaning. It does not guarantee that I can retrieve a sentence written on that whiteboard. Users still depend on the image resolution and the model's ability to recognize the scene. Exact text inside an image requires an OCR path; semantic image matching alone does not establish one.&lt;/p&gt;

&lt;p&gt;When a user selects a photo and asks for similar images, I use DINOv3. With that route, I compare visual embeddings from the selected image against visual embeddings from the library. The user can look for similar compositions or objects without translating them into a sentence.&lt;/p&gt;

&lt;p&gt;I keep the two embedding spaces separate. A DINOv3 vector and a SigLIP2 text vector do not acquire compatible meaning because both contain floating-point numbers. Each search route needs the matching model, preprocessing, and vector collection.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    T["Text: cat in a sunbeam"] --&amp;gt; ST["SigLIP2 text encoder"]
    I["Indexed image"] --&amp;gt; SI["SigLIP2 image encoder"]
    ST --&amp;gt; S["Compare within SigLIP2 space"]
    SI --&amp;gt; S
    X["Selected image"] --&amp;gt; DX["DINOv3 encoder"]
    I --&amp;gt; DI["DINOv3 encoder"]
    DX --&amp;gt; D["Compare within DINOv3 space"]
    DI --&amp;gt; D
    S --&amp;gt; A["Rank image results"]
    D --&amp;gt; A&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Run ONNX inference without guessing the model contract
&lt;/h2&gt;

&lt;p&gt;I use ONNX Runtime to execute the exported models on Windows. PixaFind supports DirectML and CUDA acceleration, with a CPU fallback. DirectML covers compatible DirectX 12 GPUs across vendors; CUDA targets supported NVIDIA hardware.&lt;/p&gt;

&lt;p&gt;I still need to ship a runtime that contains the selected execution provider. Enabling a Rust Cargo feature does not install CUDA libraries or give an ONNX Runtime binary DirectML support. I also need to check the exported graph's operators, tensor shapes, and provider compatibility.&lt;/p&gt;

&lt;p&gt;The following example uses the documented API from &lt;a href="https://github.com/pykeio/ort/tree/v2.0.0-rc.10" rel="noopener noreferrer"&gt;&lt;code&gt;ort&lt;/code&gt; 2.0.0-rc.10&lt;/a&gt;. It illustrates a DirectML image-embedding inference boundary, not a copy of PixaFind's private implementation. The caller supplies the actual model path, tensor names, spatial dimensions, and preprocessed pixels from its export contract.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;path&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;
    &lt;span class="nn"&gt;execution_providers&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;DirectMLExecutionProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;session&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;value&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;TensorRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;open_image_encoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nn"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.with_parallel_execution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.with_memory_pattern&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.with_execution_providers&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nn"&gt;DirectMLExecutionProvider&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.commit_from_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;infer_embedding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;input_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pixels_nchw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The tensor view borrows the caller's preprocessed image buffer.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;TensorRef&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_array_view&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1usize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;pixels_nchw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;outputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="nf"&gt;.run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;inputs!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;input_name&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_shape&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;output_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="py"&gt;.try_extract_tensor&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Preserve the result after the runtime output values leave scope.&lt;/span&gt;
    &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="nf"&gt;.clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="nf"&gt;.extend_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reuse the session across images instead of loading model weights for each forward pass. I borrow the input buffer and copy the output into caller-owned storage because I need that embedding after the runtime releases its output values. The caller can reuse the destination allocation across files.&lt;/p&gt;

&lt;p&gt;The caller must decode the image, apply the export's resize or crop rule, normalize channels with the correct constants, and produce the expected NCHW layout. It must also verify that the chosen output contains a pooled image embedding rather than logits or a sequence of patch features. Changing any of those choices can change retrieval quality without triggering a tensor-type error.&lt;/p&gt;

&lt;p&gt;For SigLIP2 text inference, I need a separate input contract: the matching tokenizer, token IDs, padding policy, and any required mask tensors. I cannot substitute image preprocessing or an unrelated tokenizer.&lt;/p&gt;

&lt;p&gt;Microsoft's &lt;a href="https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html#configuration-options" rel="noopener noreferrer"&gt;DirectML execution-provider documentation&lt;/a&gt; requires sequential graph execution and disabled memory-pattern optimization. It also prohibits concurrent &lt;code&gt;Run&lt;/code&gt; calls on the same DirectML session. That constraint affects worker design: I serialize access to a shared session, or provision separate sessions and accept their additional memory cost.&lt;/p&gt;

&lt;p&gt;The documentation now describes DirectML as being in sustained engineering, with new Windows deployment development moving toward WinML. For developers choosing a runtime today, that distinction matters even when an existing DirectML deployment continues to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep exact document terms alongside dense embeddings
&lt;/h2&gt;

&lt;p&gt;For document search, I combine dense vector retrieval with BM25. The release README documents approximately 30 document extensions, including &lt;code&gt;.pdf&lt;/code&gt;, &lt;code&gt;.docx&lt;/code&gt;, &lt;code&gt;.pptx&lt;/code&gt;, &lt;code&gt;.md&lt;/code&gt;, &lt;code&gt;.txt&lt;/code&gt;, and source-code formats. The product website advertises a broader 76-plus-format count. I use the release README's narrower document scope here rather than treating those two counts as interchangeable.&lt;/p&gt;

&lt;p&gt;A developer searching for &lt;code&gt;vacation policy&lt;/code&gt; may want a paragraph about annual leave. Dense embeddings can retrieve that semantic relationship. Another developer searching for &lt;code&gt;ERR_CONNECTION_RESET&lt;/code&gt;, a function name, or an invoice identifier needs exact terms. BM25 covers that lexical route.&lt;/p&gt;

&lt;p&gt;For a hybrid implementation, I would use this retrieval sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Extracted document text
    |
    +-&amp;gt; bounded text chunks -&amp;gt; dense embeddings -&amp;gt; semantic candidates
    |
    +-&amp;gt; tokenized text      -&amp;gt; BM25 index        -&amp;gt; keyword candidates
                                                    |
                              merge, deduplicate, rank by file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sketch describes a design pattern, not a claim about PixaFind's private chunk size or fusion formula. The public release repository documents hybrid search without publishing those internals.&lt;/p&gt;

&lt;p&gt;Developers need a ranking policy because BM25 scores and vector similarities have different scales. Adding the raw values can let one route dominate by numerical range. Rank-based fusion offers one option: assign a contribution according to a candidate's position in each list, then deduplicate document chunks before presenting file results. Tuning that policy requires queries from the target library, including both paraphrases and exact identifiers.&lt;/p&gt;

&lt;p&gt;Text extraction also sets a ceiling on recall. A scanned PDF may contain pixels without a text layer. Office documents can include tables whose reading order affects the extracted text. Source-code tokenization can split identifiers that users expect to search as a unit. I would inspect extraction output before blaming the embedding model for a missing result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoid paying for the same forward pass twice
&lt;/h2&gt;

&lt;p&gt;I use modification-time checks and SHA-256 content hashing to avoid redundant inference on unchanged files. Reading metadata costs less than decoding an image and running a vision encoder. Hashing costs a full read of the file, so I do not treat it as free.&lt;/p&gt;

&lt;p&gt;The conceptual decision tree looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Filesystem event
    |
    v
Compare stored metadata with current metadata
    |
    +-&amp;gt; unchanged: retain the existing index entry
    |
    v
Compute SHA-256 for a changed candidate
    |
    +-&amp;gt; same content digest: update metadata, retain embeddings
    |
    v
Decode or extract -&amp;gt; run inference -&amp;gt; replace index entries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Modification times provide a cheap filter, not proof of identical content. Users can restore timestamps, and some workflows preserve them while replacing files. A correctness-focused implementation needs reconciliation or content verification for those cases. It also needs to avoid committing an embedding for bytes that changed midway through the read.&lt;/p&gt;

&lt;p&gt;For index maintenance, I would update file metadata and its associated vectors in one SQLite transaction. Deletions need removal from both vector and keyword search. A rename should preserve the relationship between the searchable content and its current path rather than leave an obsolete result that the user cannot open.&lt;/p&gt;

&lt;p&gt;The content digest answers whether the file changed. It does not answer whether its embedding contract changed. After upgrading a model, tokenizer, or image preprocessing rule, developers need to invalidate the affected embeddings even when the source bytes remain identical. Mixing model revisions inside one vector collection produces rankings that users cannot interpret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Budget for the index as well as the models
&lt;/h2&gt;

&lt;p&gt;Local SQLite storage keeps vectors and file metadata on the user's disk. I do not need a remote vector service for this architecture, but I still need a retrieval strategy that fits the library size. Storing vectors in SQLite alone does not establish whether an implementation uses an exact scan or an approximate nearest-neighbor index.&lt;/p&gt;

&lt;p&gt;For float32 storage, I can estimate the vector payload before accounting for database pages and metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;payload bytes = item count * dimensions * 4

Illustrative sizing, not PixaFind's model dimensions:
100,000 items * 768 dimensions * 4 bytes = 307,200,000 bytes
                                               about 293 MiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I store a second image embedding for visual similarity, I add its own dimensionality and item count to that calculation. Document chunks can create several vectors per file. GPU inference reduces compute time; it does not reduce those disk costs.&lt;/p&gt;

&lt;p&gt;I would measure decode time, inference time, database write time, and interactive query latency as separate stages. Without hardware, model export, batch size, and library composition, a single indexing-speed number tells a developer little. This article makes no benchmark claim for those stages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draw the offline boundary around setup and search
&lt;/h2&gt;

&lt;p&gt;PixaFind performs indexing and search on the machine, stores the index locally, and sends no telemetry. Users do not upload filenames, file contents, thumbnails, or embeddings to a search service.&lt;/p&gt;

&lt;p&gt;Installation has a separate lifecycle. The release README describes first-launch model downloads and an in-app updater. Users therefore need network access for normal initial provisioning, even though subsequent search needs none. I would not describe that bootstrap process as zero network activity.&lt;/p&gt;

&lt;p&gt;For an air-gapped deployment, an operator needs to provision the installer, runtime dependencies, and complete model assets before disconnecting the machine. Offline use assumes those assets already exist locally. The public README does not document a dedicated offline model-import command, so I cannot offer one here.&lt;/p&gt;

&lt;p&gt;Local storage also requires a threat model. An embedding index can expose information about the source library to someone with access to that Windows account or disk. Keeping the computation local removes the remote search-service boundary; users still need filesystem permissions, disk encryption where appropriate, and a policy for deleting index data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the search routes on your own folders
&lt;/h2&gt;

&lt;p&gt;Download the Windows installer from &lt;a href="https://github.com/Ishannaik/pixafind-releases/releases/latest" rel="noopener noreferrer"&gt;PixaFind Releases&lt;/a&gt;, complete model provisioning, and choose a folder you want to index. The public repository hosts installers and update artifacts. PixaFind is commercial software, and I have not published its application source there.&lt;/p&gt;

&lt;p&gt;To evaluate retrieval, choose queries with different demands:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Text-to-image&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cat in a sunbeam&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Does the result match the remembered scene?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text-to-image&lt;/td&gt;
&lt;td&gt;&lt;code&gt;blurry photo of whiteboard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Can you find the photo without knowing its name?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image-to-image&lt;/td&gt;
&lt;td&gt;Select a photo and request similar images&lt;/td&gt;
&lt;td&gt;Do the neighbors share useful visual features?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Document semantics&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vacation policy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Can you find a relevant passage phrased as annual leave?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Document keywords&lt;/td&gt;
&lt;td&gt;An identifier you know exists in a file&lt;/td&gt;
&lt;td&gt;Does hybrid ranking retain the exact match?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then edit one indexed file and leave the others untouched. That exercise exposes a different part of the system: freshness, deletion handling, and the cost of incremental work. After provisioning, repeat your searches without network connectivity to evaluate the offline path.&lt;/p&gt;

&lt;p&gt;I chose this architecture so users can search from descriptions while keeping their files and query processing on Windows. Its maintenance work remains concrete: compatible model exports, correct preprocessing, provider-specific session rules, and an index that tracks the files users have today.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by &lt;a href="https://github.com/Ishannaik" rel="noopener noreferrer"&gt;Ishan Naik&lt;/a&gt;. Product: &lt;a href="https://pixafind.com" rel="noopener noreferrer"&gt;PixaFind&lt;/a&gt;. Downloads and public documentation: &lt;a href="https://github.com/Ishannaik/pixafind-releases" rel="noopener noreferrer"&gt;pixafind-releases&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rust</category>
      <category>windows</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Breaking the DOM to Stop Doomscrolling: Glitch Shaders, Scroll Physics, and Chrome MV3 Network Throttling</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:22:50 +0000</pubDate>
      <link>https://dev.to/ishannaik/breaking-the-dom-to-stop-doomscrolling-glitch-shaders-scroll-physics-and-chrome-mv3-network-5gd5</link>
      <guid>https://dev.to/ishannaik/breaking-the-dom-to-stop-doomscrolling-glitch-shaders-scroll-physics-and-chrome-mv3-network-5gd5</guid>
      <description>&lt;p&gt;Scroll through a feed long enough and DoomBreaker makes the page harder to read. Color drains. Glass cracks spread across the viewport. At full damage, Chrome blocks the requests that would fetch another batch of posts.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/Ishannaik/doombreaker" rel="noopener noreferrer"&gt;DoomBreaker&lt;/a&gt; around a behavioral premise: introduce discomfort into the interface you keep consuming. The base degradation mode leaves your existing content on screen and restores readability when you stop. You do not dismiss a guilt popup to return to an untouched feed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4kyrj6llelgd1b6wbs8s.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4kyrj6llelgd1b6wbs8s.jpg" alt="DoomBreaker at high damage, with cracks and visual distortion" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A repository screenshot of the damaged interface, rather than a synthetic rendering.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A source note matters before the engineering details. The design brief describes exponential healing near 0.8% per second and WebGL displacement shaders. The current repository implements a different tuning: linear healing after 20 seconds of idle time, plus CSS clip-path glitches. I distinguish those implementations below. The title's network throttling refers to stopping matching feed requests, not imposing a bandwidth limit. Chrome's rule action here is &lt;code&gt;block&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The current extension also includes a companion cat, optional daily budgets, and a Gatekeeper mode that enforces timed breaks. Those modes add blocking behavior beyond the original discomfort premise. This article follows the damage, rendering, and network pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  One damage value across the feed
&lt;/h2&gt;

&lt;p&gt;DoomBreaker uses a scalar damage value, $d \in [0,1]$. In &lt;a href="https://github.com/Ishannaik/doombreaker/blob/main/meter.js" rel="noopener noreferrer"&gt;&lt;code&gt;meter.js&lt;/code&gt;&lt;/a&gt;, I keep the arithmetic separate from Chrome and DOM APIs. A content script supplies input; the meter updates a plain object with &lt;code&gt;d&lt;/code&gt;, &lt;code&gt;last&lt;/code&gt;, and &lt;code&gt;lastTick&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For wheel input, the current calculation is:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
d_{next}=\min\left(1, d+\frac{|\Delta y|\,s}{40000}\right)&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;Here, $s$ represents the popup sensitivity setting. At sensitivity 1, 40,000 accumulated pixels take a fresh meter to full damage. Reversing scroll direction still adds damage because I count absolute distance.&lt;/p&gt;

&lt;p&gt;Wheel events report three units. In &lt;a href="https://github.com/Ishannaik/doombreaker/blob/main/content.js" rel="noopener noreferrer"&gt;&lt;code&gt;content.js&lt;/code&gt;&lt;/a&gt;, I treat pixel deltas as pixels, multiply line deltas by 16, and multiply page deltas by the viewport height. That 16-pixel conversion is a heuristic, not a measurement of the host page's line height.&lt;/p&gt;

&lt;p&gt;I register passive input listeners and update state inside them. A 250 ms loop handles visual changes. Keeping DOM mutations out of wheel handlers avoids adding layout work to each burst of input.&lt;/p&gt;

&lt;p&gt;Keyboard and touch scrolling need another route. I compare successive &lt;code&gt;window.scrollY&lt;/code&gt; values, but ignore scroll events for 300 ms after a wheel event. A wheel gesture often produces both events; without that exclusion, I would charge the same gesture twice. The fallback also excludes deltas of 2,000 pixels or more. That choice avoids charging some large jumps, but can miss legitimate movement.&lt;/p&gt;
&lt;h3&gt;
  
  
  Short videos need a different unit
&lt;/h3&gt;

&lt;p&gt;YouTube Shorts and Instagram Reels can advance without moving the document through a meaningful pixel distance. I count qualifying video-path changes instead. The current meter adds &lt;code&gt;0.06 * sensitivity&lt;/code&gt; per advancement, so 17 advancements reach full damage at sensitivity 1.&lt;/p&gt;

&lt;p&gt;The intended product tuning describes Shorts and Reels as accumulating damage about three times faster than ordinary scrolling. The implementation does not multiply wheel damage by three. It assigns videos a separate unit cost. A time-based comparison depends on your scroll speed and how long you watch each video.&lt;/p&gt;

&lt;p&gt;The initial route does not count as an advancement. Subsequent qualifying route changes do. That distinction avoids charging damage just because you opened a Shorts URL.&lt;/p&gt;

&lt;p&gt;I store the meter under the shared &lt;code&gt;damage.all&lt;/code&gt; key in &lt;code&gt;chrome.storage.local&lt;/code&gt;. Switching from X to Reddit retains damage. Content scripts watch storage changes and adopt newer timestamped writes.&lt;/p&gt;

&lt;p&gt;That synchronization has a limit: last-write-wins adoption does not provide an atomic sum of simultaneous inputs from several tabs. A shared key prevents a fresh budget per site, but a rigorous multi-tab accumulator would need a serialized owner for damage updates.&lt;/p&gt;
&lt;h2&gt;
  
  
  Thresholds turn arithmetic into discomfort
&lt;/h2&gt;


&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    W[Wheel distance normalized to pixels] --&amp;gt; A[Add damage and clamp to 0 through 1]
    V[Shorts or Reels video advancement] --&amp;gt; A
    A --&amp;gt; D[Shared damage meter]
    I[Idle time] --&amp;gt; H[Apply healing using elapsed time]
    H --&amp;gt; D
    D --&amp;gt; P[Persist damage and synchronize tabs]
    D --&amp;gt; R[250 ms visual update loop]
    R --&amp;gt; B{Damage at least 0.30?}
    B --&amp;gt;|Yes| F[Blur and grayscale with vignette]
    R --&amp;gt; C{Damage at least 0.60?}
    C --&amp;gt;|Yes| G[Reveal SVG cracks and enable CSS glitch]
    R --&amp;gt; S{Damage at least 0.90?}
    S --&amp;gt;|Yes| K[Enable shake]
    D --&amp;gt; N{Damage at least 0.995?}
    N --&amp;gt;|Yes| M[Message service worker to install feed blocks]
    D --&amp;gt; U{Damage below 0.85?}
    U --&amp;gt;|Yes| O[Message service worker to remove feed blocks]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;At &lt;code&gt;0.30&lt;/code&gt;, I enable blur and grayscale on the body. A separate radial-gradient overlay darkens the edges; the CSS starts its opacity ramp below the main blur threshold, at &lt;code&gt;0.25&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At &lt;code&gt;0.60&lt;/code&gt;, I reveal SVG cracks and enable the glitch animation. More cracks appear at &lt;code&gt;0.70&lt;/code&gt;, &lt;code&gt;0.80&lt;/code&gt;, &lt;code&gt;0.90&lt;/code&gt;, and &lt;code&gt;0.97&lt;/code&gt;. At &lt;code&gt;0.90&lt;/code&gt;, I add shake.&lt;/p&gt;

&lt;p&gt;At &lt;code&gt;0.995&lt;/code&gt;, I request a network block. I leave that block active until damage falls below &lt;code&gt;0.85&lt;/code&gt;. Those separate entry and exit thresholds provide hysteresis. A meter hovering near full damage should not alternate between adding and removing Chrome rules.&lt;/p&gt;

&lt;p&gt;The cracks have their own recovery behavior: I clear them below &lt;code&gt;0.50&lt;/code&gt;. The user sees cracks persist through part of the healing interval instead of watching the geometry toggle around &lt;code&gt;0.60&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Generate the cracks once, reveal them later
&lt;/h2&gt;

&lt;p&gt;I generate three impact points with a seeded pseudorandom generator. Each impact gets four to six rays; each ray gets three to five jittered segments. I create two SVG paths per ray: a broad, dim backing stroke and a narrower bright stroke.&lt;/p&gt;

&lt;p&gt;I generate that geometry once per page-session crack set. During progression, I reveal existing paths with &lt;code&gt;stroke-dashoffset&lt;/code&gt;. I do not generate another crack on every frame.&lt;/p&gt;

&lt;p&gt;This condensed excerpt shows the CSS/SVG mechanism. The caller supplies &lt;code&gt;d&lt;/code&gt; from the meter and a path from the seeded geometry generator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SVG_NS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://www.w3.org/2000/svg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;addCrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pathData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElementNS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SVG_NS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pathData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fill&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stroke&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgba(255,255,255,.75)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stroke-width&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTotalLength&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeDasharray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeDashoffset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stroke-dashoffset 0.7s ease-out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeDashoffset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyDamage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;db-blur&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;db-glitch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nf"&gt;#db-overlay&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2147483647&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="nc"&gt;.db-blur&lt;/span&gt; &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.7&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;5px&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
          &lt;span class="n"&gt;grayscale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;db-glitch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="err"&gt;12&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;clip-path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;7&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;clip-path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8%&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;61%&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-6px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;9&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;clip-path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;55%&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;12%&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="nc"&gt;.db-glitch&lt;/span&gt; &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;db-glitch&lt;/span&gt; &lt;span class="m"&gt;2.5s&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="nc"&gt;.db-glitch&lt;/span&gt; &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full &lt;a href="https://github.com/Ishannaik/doombreaker/blob/main/effects.css" rel="noopener noreferrer"&gt;&lt;code&gt;effects.css&lt;/code&gt;&lt;/a&gt; includes more glitch bursts, damage-scaled shake, and reduced-motion overrides for both animations.&lt;/p&gt;

&lt;p&gt;I append the overlay to &lt;code&gt;document.documentElement&lt;/code&gt;, outside the body I filter. This placement preserves crisp crack strokes above blurred content. With &lt;code&gt;pointer-events: none&lt;/code&gt;, I leave links and buttons reachable through the overlay.&lt;/p&gt;

&lt;p&gt;Filtering the body changes positioning behavior for some fixed descendants. A header can stop behaving like a viewport-fixed header. In this project I accept that distortion as part of the damaged interface, but I would not copy the technique into an ordinary UI without checking its positioning consequences.&lt;/p&gt;

&lt;h3&gt;
  
  
  A shader needs something to sample
&lt;/h3&gt;

&lt;p&gt;A WebGL displacement shader could offset texture coordinates by scanline, time, and damage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="k"&gt;precision&lt;/span&gt; &lt;span class="kt"&gt;mediump&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;uniform&lt;/span&gt; &lt;span class="kt"&gt;sampler2D&lt;/span&gt; &lt;span class="n"&gt;uTexture&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;uniform&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;uDamage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;uniform&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;uTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;varying&lt;/span&gt; &lt;span class="kt"&gt;vec2&lt;/span&gt; &lt;span class="n"&gt;vUv&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;band&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vUv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;noise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;band&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;9898&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uTime&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                      &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;43758&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5453&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;strength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uDamage&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;noise&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mo"&gt;04&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;strength&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kt"&gt;vec2&lt;/span&gt; &lt;span class="n"&gt;uv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vUv&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="kt"&gt;vec2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;gl_FragColor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;texture2D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uTexture&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fragment shader illustrates the proposed rendering route; DoomBreaker does not ship it. A developer would also need vertex-shader plumbing, uniforms, and a texture source.&lt;/p&gt;

&lt;p&gt;The texture source creates the hard problem. A WebGL canvas cannot sample an arbitrary live DOM tree as a texture. Capturing or reconstructing the page adds permission, fidelity, and update-cost questions, especially for cross-origin images and playing video. I use CSS clipping and transforms in the current extension to distort the host DOM without building that capture pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Healing: distinguish the model from the constants
&lt;/h2&gt;

&lt;p&gt;For the proposed exponential model, you would apply:&lt;/p&gt;

&lt;p&gt;$$&lt;br&gt;
d(t+\Delta t)=d(t)e^{-k\Delta t}&lt;br&gt;
$$&lt;/p&gt;

&lt;p&gt;Taking a 0.8% relative reduction per second gives $k=-\ln(0.992)\approx0.00803$. Starting at &lt;code&gt;1&lt;/code&gt;, that model reaches the &lt;code&gt;0.85&lt;/code&gt; unblock boundary after about 20.2 seconds of healing. It approaches zero without reaching it.&lt;/p&gt;

&lt;p&gt;The README describes healing near 0.8% per second, but the current meter uses a linear subtraction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;CFG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;BUDGET_PX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;VIDEO_HIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;HEAL_PER_SEC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;IDLE_MS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After 20 seconds without input, the meter subtracts &lt;code&gt;HEAL_PER_SEC * healMultiplier * elapsedSeconds&lt;/code&gt;. At the default multiplier, that rate equals about 0.0278 percentage points per second. Dropping from &lt;code&gt;1&lt;/code&gt; to &lt;code&gt;0.85&lt;/code&gt; takes about nine minutes of healing, plus the idle delay; the strict below-&lt;code&gt;0.85&lt;/code&gt; check releases on a subsequent tick.&lt;/p&gt;

&lt;p&gt;I calculate elapsed time from timestamps instead of subtracting a fixed amount per callback. Browser scheduling delays should change update cadence, not define the healing rate. These constants deserve attention during tuning because a 20-second recovery and a nine-minute recovery produce different user experiences.&lt;/p&gt;

&lt;h2&gt;
  
  
  MV3: block the next page of posts
&lt;/h2&gt;

&lt;p&gt;A blur cannot stop someone from scrolling. At full damage, I ask the service worker to install dynamic &lt;code&gt;declarativeNetRequest&lt;/code&gt; rules.&lt;/p&gt;

&lt;p&gt;The content script sends a &lt;code&gt;db-feedkill&lt;/code&gt; message with the desired state and hostname. In &lt;a href="https://github.com/Ishannaik/doombreaker/blob/main/background.js" rel="noopener noreferrer"&gt;&lt;code&gt;background.js&lt;/code&gt;&lt;/a&gt;, I generate rules from &lt;code&gt;config.kill&lt;/code&gt; and call &lt;code&gt;updateDynamicRules()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This condensed snippet follows the repository's precise-rule generation, including its six reserved IDs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RULE_IDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;102&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;103&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;104&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;105&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;106&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;preciseRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filters&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kill&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;urlFilter&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;106&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;urlFilter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;resourceTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xmlhttprequest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setFeedBlocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;declarativeNetRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateDynamicRules&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;removeRuleIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RULE_IDS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;addRules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;preciseRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The extension declares &lt;code&gt;storage&lt;/code&gt;, &lt;code&gt;declarativeNetRequest&lt;/code&gt;, and &lt;code&gt;alarms&lt;/code&gt; permissions in its MV3 manifest. Chrome performs the matching and blocking; I do not keep a JavaScript request listener alive to intercept each feed fetch.&lt;/p&gt;

&lt;p&gt;The current filters include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reserved IDs    Endpoint family
101, 102        x.com/i/api/graphql and twitter.com/i/api/graphql
103             reddit.com/svc/shreddit/
104             instagram.com/graphql/query
105             youtube.com/youtubei/v1/reel/
106             linkedin.com/voyager/api/feed

Damage 0.995 -&amp;gt; install rules -&amp;gt; future matching requests fail
Damage &amp;lt;0.85 -&amp;gt; remove rules  -&amp;gt; later requests can proceed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These filters match endpoint families, not a verified feed operation within each GraphQL payload. A broad GraphQL filter can affect other features that share that URL. I also install the precise rules as a group, rather than limiting them to the tab that requested the block.&lt;/p&gt;

&lt;p&gt;For an unknown host, the worker adds a generic rule for that host's XHR-class requests. That fallback extends coverage but can block unrelated API calls on the same site. The current host-hash ID allocation also permits collisions; it does not guarantee a unique ID for every hostname.&lt;/p&gt;

&lt;p&gt;Chrome documents that &lt;a href="https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest" rel="noopener noreferrer"&gt;dynamic rules persist across browser sessions and extension upgrades&lt;/a&gt;. Worker shutdown does not remove them. That persistence makes cleanup and recovery important: after healing, the extension must remove the rules, not just change a variable in the content script.&lt;/p&gt;

&lt;p&gt;Blocking a request does not erase posts already in memory, stop an in-flight response, or force a site's feed component to retry. After removal, the site may need another user action to fetch again. The network gate controls future matching requests, not the entire application lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remote configuration without remote JavaScript
&lt;/h2&gt;

&lt;p&gt;A feed extension depends on URLs and routing conventions that site owners can change. I keep those rules in &lt;a href="https://github.com/Ishannaik/doombreaker/blob/main/config/sites.json" rel="noopener noreferrer"&gt;&lt;code&gt;config/sites.json&lt;/code&gt;&lt;/a&gt;: hostname regexes, active path prefixes, video path patterns, and network filters.&lt;/p&gt;

&lt;p&gt;The worker refreshes configuration on install and browser startup, then through a six-hour alarm. It loads cached data first, validates fetched JSON, stores a version and fetch timestamp, and retains the cached or bundled configuration when fetching fails.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    J[Repository config/sites.json] --&amp;gt; F[Service worker fetch]
    F --&amp;gt; V{Valid configuration?}
    V --&amp;gt;|Yes| C[Cache data and fetch timestamp in storage]
    V --&amp;gt;|No or offline| K[Keep cached or bundled configuration]
    C --&amp;gt; S[Content scripts update site matching]
    C --&amp;gt; N[Worker generates network rules]
    K --&amp;gt; S
    K --&amp;gt; N&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;I use the same broad remote-asset pattern as filter-list tools such as uBlock Origin: distribute changing declarative data apart from executable extension code. A maintainer can edit endpoint patterns without submitting a new executable package for each URL change.&lt;/p&gt;

&lt;p&gt;The current file contains path and host rules, not arbitrary DOM selectors. The extension still ships its parsing and matching code in the package. Remote JSON does not grant permission to download and execute JavaScript; Chrome's &lt;a href="https://developer.chrome.com/docs/extensions/develop/migrate/remote-hosted-code" rel="noopener noreferrer"&gt;remote-hosted-code guidance&lt;/a&gt; draws that distinction.&lt;/p&gt;

&lt;p&gt;A six-hour alarm is a refresh cadence, not a delivery guarantee. A sleeping browser, lost network access, or an invalid update delays adoption. Remote configuration also cannot expand the six-rule budget in &lt;code&gt;preciseRules()&lt;/code&gt;; adding a seventh filter to JSON requires a code change to avoid silent omission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspect the real pipeline
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu54xkb66n230c06oltco.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu54xkb66n230c06oltco.jpg" alt="DoomBreaker blur stage" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The blur stage preserves the page structure while reducing readability.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The repository includes unit tests and a visual harness. These are commands for reproducing the development workflow, not a report that I ran them for this article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm install
npm test
python -m http.server 8377

Visual harness:
http://localhost:8377/test/harness.html

Unpacked extension:
chrome://extensions
  Developer mode
  Load unpacked
  Select the repository directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The harness lets you drive damage thresholds through the rendering pipeline. To inspect network blocking, use the loaded extension on a supported site and watch the next matching request in DevTools. Inspect the service worker's dynamic rules as well: visible damage alone does not establish that Chrome installed a block.&lt;/p&gt;

&lt;p&gt;I kept the central pieces separate: meter arithmetic in &lt;code&gt;meter.js&lt;/code&gt;, input and rendering orchestration in &lt;code&gt;content.js&lt;/code&gt;, declarative site data in &lt;code&gt;config/sites.json&lt;/code&gt;, and rule installation in &lt;code&gt;background.js&lt;/code&gt;. That separation lets a maintainer tune the discomfort curve without rewriting Chrome integration, or repair a changed feed endpoint without touching crack geometry.&lt;/p&gt;

&lt;p&gt;The remaining engineering questions are concrete: choose a healing curve that matches the intended break length, reconcile simultaneous tab input, narrow shared API filters, and keep network-rule removal reliable after worker restarts. Those choices determine how the extension behaves after the screenshot, when someone stops scrolling and expects their page to recover.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/Ishannaik/doombreaker" rel="noopener noreferrer"&gt;Ishan Naik / DoomBreaker&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>css</category>
    </item>
    <item>
      <title>Building a Terminal UI BitTorrent Client in Rust with Ratatui and Local Streaming</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:22:18 +0000</pubDate>
      <link>https://dev.to/ishannaik/building-a-terminal-ui-bittorrent-client-in-rust-with-ratatui-and-local-streaming-1g1a</link>
      <guid>https://dev.to/ishannaik/building-a-terminal-ui-bittorrent-client-in-rust-with-ratatui-and-local-streaming-1g1a</guid>
      <description>&lt;p&gt;A video player reads a file in playback order. A BitTorrent peer can deliver whichever pieces it has. When I built &lt;a href="https://github.com/Ishannaik/harbour" rel="noopener noreferrer"&gt;Harbour&lt;/a&gt;, I had to connect those two access patterns without making the terminal wait for the swarm.&lt;/p&gt;

&lt;p&gt;Harbour lets you search, download, and open a video in VLC or mpv from one terminal window. You can start playback before the download finishes. I use Rust 2024, Ratatui for rendering, Tokio for asynchronous work, and &lt;code&gt;librqbit&lt;/code&gt; for the BitTorrent engine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fneqb8csi6rcz1al2a15v.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fneqb8csi6rcz1al2a15v.gif" alt="Harbour TUI Demo" width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demo uses the project's Creative Commons catalog, including Blender Foundation films. Use files you own or have permission to distribute. A protocol-neutral client does not grant distribution rights.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep rendering away from network work
&lt;/h2&gt;

&lt;p&gt;I keep the engine adapter in &lt;a href="https://github.com/Ishannaik/harbour/blob/main/src/engine/rqbit.rs" rel="noopener noreferrer"&gt;&lt;code&gt;src/engine/rqbit.rs&lt;/code&gt;&lt;/a&gt;. The queue and UI work with Harbour's types rather than &lt;code&gt;librqbit&lt;/code&gt; handles. That boundary gives me one place to translate engine statistics, metadata, and errors.&lt;/p&gt;

&lt;p&gt;For example, &lt;code&gt;librqbit&lt;/code&gt; reports speeds in MiB/s. I convert those values in the adapter rather than asking each widget to remember the engine's units. I also represent an unavailable peer count with &lt;code&gt;Option&lt;/code&gt;, because a paused torrent has no live peer statistics. Showing zero would confuse “no measurement” with “connected to no peers.”&lt;/p&gt;

&lt;p&gt;I use Tokio tasks for asynchronous operations and event channels to bring results back to application state. Ratatui renders that state. I do not fetch metadata or wait for a peer inside a widget's draw function.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    User[Keyboard and mouse input] --&amp;gt; App[Harbour application state]
    App --&amp;gt; Queue[Queue manager]
    Queue --&amp;gt; Adapter[librqbit adapter]
    Adapter --&amp;gt; Swarm[DHT, trackers, and peers]
    Adapter --&amp;gt; Events[Tokio event channels]
    Events --&amp;gt; App
    App --&amp;gt; UI[Ratatui views]
    App --&amp;gt; Source[HTTP Source adapter]
    Source --&amp;gt; Indexer[Local indexer API]
    Adapter --&amp;gt; HTTP[Loopback HTTP stream]
    HTTP --&amp;gt; Player[VLC or mpv]
    Queue --&amp;gt; Ledger[downloads.json]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;I target a 30fps terminal render cadence. That number describes the UI schedule, not torrent throughput or video frame rate. A swarm can stop delivering data while you keep navigating the queue.&lt;/p&gt;

&lt;p&gt;For this layout, I need separate clocks: network work follows socket readiness, playback follows the player's requests, and terminal rendering follows the UI cadence. Coupling those clocks would make a slow peer visible as input lag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch mode: a file reader backed by incomplete data
&lt;/h2&gt;

&lt;p&gt;BitTorrent downloads do not have to arrive in file order. A peer might supply a piece near the end before anyone supplies the next piece needed for playback. The engine must verify received pieces and make requested bytes available in the order the reader needs them.&lt;/p&gt;

&lt;p&gt;For watch mode, I delegate this work to &lt;code&gt;librqbit&lt;/code&gt;'s HTTP API. In Harbour's adapter, &lt;code&gt;stream_server()&lt;/code&gt; creates a Tokio listener with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;listener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;net&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;TcpListener&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"127.0.0.1:0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;:0&lt;/code&gt; asks the operating system for an available port. I bind to &lt;code&gt;127.0.0.1&lt;/code&gt; rather than &lt;code&gt;0.0.0.0&lt;/code&gt;, so the playback endpoint does not listen on the machine's LAN interfaces. Loopback limits network exposure; it does not authenticate other processes running on the same machine.&lt;/p&gt;

&lt;p&gt;I start the server on the first watch request and reuse it for later requests. Harbour constructs a URL with this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://127.0.0.1:&amp;lt;assigned-port&amp;gt;/torrents/&amp;lt;info-hash&amp;gt;/stream/&amp;lt;file-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I pass that URL to the external player. VLC or mpv then requests bytes over HTTP while Tokio workers continue contacting peers through DHT and trackers. The player uses its own demuxer, codecs, and playback buffers. I do not decode video inside the TUI.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    autonumber
    participant U as User
    participant H as Harbour
    participant B as librqbit piece scheduler
    participant P as Swarm peers
    participant S as Localhost HTTP server
    participant V as VLC or mpv
    U-&amp;gt;&amp;gt;H: Watch selected video
    H-&amp;gt;&amp;gt;B: Resolve metadata and select file
    H-&amp;gt;&amp;gt;S: Start or reuse 127.0.0.1 listener
    H-&amp;gt;&amp;gt;V: Launch player with stream URL
    V-&amp;gt;&amp;gt;S: HTTP request for file bytes
    S-&amp;gt;&amp;gt;B: Read requested byte range
    B-&amp;gt;&amp;gt;P: Prioritize pieces needed by reader
    P--&amp;gt;&amp;gt;B: Piece blocks
    B-&amp;gt;&amp;gt;B: Verify and expose ordered bytes
    B--&amp;gt;&amp;gt;S: Contiguous bytes for requested range
    S--&amp;gt;&amp;gt;V: Stream response body
    V-&amp;gt;&amp;gt;V: Buffer, demux, and decode
    Note over B,V: Missing required pieces can stall playback
    V-&amp;gt;&amp;gt;S: Range request after seek
    S-&amp;gt;&amp;gt;B: Read new offset and prioritize pieces&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The “playback pipe” here means the HTTP response body. Harbour launches the player with a URL, rather than pushing video into its standard input.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sequential reads still need random access
&lt;/h3&gt;

&lt;p&gt;During normal playback, the player consumes bytes in sequence. On a seek, it can ask for a different range. Some media containers also require information outside the first bytes of the file.&lt;/p&gt;

&lt;p&gt;A production endpoint therefore needs byte-range handling, including &lt;code&gt;206 Partial Content&lt;/code&gt;, &lt;code&gt;Content-Range&lt;/code&gt;, and correct offsets. A stream that serves only the first byte onward can support a simple forward reader but cannot satisfy the player's full file-access contract.&lt;/p&gt;

&lt;p&gt;I avoid building a second piece scheduler or range server in Harbour. The adapter uses &lt;code&gt;librqbit::http_api::HttpApi&lt;/code&gt;, which keeps the HTTP reader connected to the engine's piece availability and prioritization.&lt;/p&gt;

&lt;p&gt;I also wait for torrent metadata before returning the default video URL. The current adapter allows eight seconds and polls every 200ms. If metadata never arrives, or the torrent contains no recognized video file, Harbour can warn you before launching a player with an unusable URL.&lt;/p&gt;

&lt;p&gt;For the default selection, I choose the largest recognized video file. A season pack may contain a small &lt;code&gt;sample.mkv&lt;/code&gt;; alphabetical selection can send the player to that sample. File selection needs torrent metadata, not a guess based on the search result's display name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The channel boundary behind an HTTP body
&lt;/h2&gt;

&lt;p&gt;You can model the asynchronous handoff with a bounded Tokio channel. The producer reads ordered, verified bytes; the HTTP consumer drains the channel as the connection permits.&lt;/p&gt;

&lt;p&gt;The following teaching example uses Axum's &lt;a href="https://docs.rs/axum/latest/axum/body/struct.Body.html#method.from_stream" rel="noopener noreferrer"&gt;&lt;code&gt;Body::from_stream&lt;/code&gt;&lt;/a&gt;, which supplies a body for the underlying Hyper stack. It illustrates the channel boundary, not Harbour's implementation. Harbour calls &lt;code&gt;librqbit&lt;/code&gt;'s HTTP API instead of adding this helper.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;axum&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;body&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;futures_util&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;StreamExt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;AsyncRead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;tokio_stream&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;wrappers&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ReceiverStream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;tokio_util&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ReaderStream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Pass a torrent-backed reader that yields verified bytes in file order.&lt;/span&gt;
&lt;span class="c1"&gt;// Range parsing and response headers belong to the HTTP handler.&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;body_from_verified_reader&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncRead&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Unpin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;'static&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;ReaderStream&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;with_capacity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="nf"&gt;.next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="nf"&gt;.is_err&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

            &lt;span class="c1"&gt;// A full channel suspends this producer. A disconnected&lt;/span&gt;
            &lt;span class="c1"&gt;// player drops the receiver and makes send return an error.&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="nf"&gt;.is_err&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nn"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;ReceiverStream&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a standalone example, include &lt;code&gt;axum&lt;/code&gt;, &lt;code&gt;futures-util&lt;/code&gt;, &lt;code&gt;tokio&lt;/code&gt;, &lt;code&gt;tokio-stream&lt;/code&gt;, and &lt;code&gt;tokio-util&lt;/code&gt;; enable Tokio's runtime, I/O, and synchronization support and &lt;code&gt;tokio-util&lt;/code&gt;'s &lt;code&gt;io&lt;/code&gt; feature. Those are example dependencies, not Harbour's direct dependency list.&lt;/p&gt;

&lt;p&gt;I use eight channel slots to bound the number of chunks waiting at this handoff. With the reader's 64KiB capacity, that suggests roughly 512KiB of queued payload under typical chunking. It does not cap memory in the torrent engine, operating system, or player, and the slot count alone does not enforce a byte limit for arbitrary producers.&lt;/p&gt;

&lt;p&gt;When the channel fills, &lt;code&gt;send().await&lt;/code&gt; pauses this reader task rather than blocking an operating-system thread. When the player disconnects, a later send fails and the task exits. A producer waiting for a missing piece may need explicit cancellation to stop before that read completes; channel closure by itself cannot interrupt an unrelated pending read.&lt;/p&gt;

&lt;p&gt;The reader must also propagate a missing-piece read failure as an error. Returning end-of-stream would tell the player that the file ended, which hides the transfer failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep search providers outside the client
&lt;/h2&gt;

&lt;p&gt;I use the Stremio/Jackett addon paradigm for search: Harbour contains zero built-in scrapers and talks to a decoupled HTTP indexer. This describes the architectural split, not automatic compatibility with either project's wire protocol.&lt;/p&gt;

&lt;p&gt;The default indexer URL is &lt;code&gt;http://127.0.0.1:8765&lt;/code&gt;. The &lt;a href="https://github.com/Ishannaik/harbour/blob/main/docs/indexer-guide.md" rel="noopener noreferrer"&gt;Harbour indexer contract&lt;/a&gt; defines three endpoints:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Client operation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /search?q=...&amp;amp;exclude=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Retrieve results and omit user-disabled source IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /magnet?hash=...&amp;amp;source=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resolve a magnet if the result omitted one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /health&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Check indexer connectivity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The indexer returns fields such as &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;info_hash&lt;/code&gt;, &lt;code&gt;size_bytes&lt;/code&gt;, &lt;code&gt;seeders&lt;/code&gt;, and &lt;code&gt;source&lt;/code&gt;. Harbour can merge and deduplicate results by info hash without knowing how the provider obtained them.&lt;/p&gt;

&lt;p&gt;If you maintain a provider, you can change its parsing, credentials, or catalog independently of the Rust client. You also own another service: you must start it, configure its URL, and diagnose connection failures. Direct magnet and &lt;code&gt;.torrent&lt;/code&gt; inputs do not need a search indexer.&lt;/p&gt;

&lt;p&gt;I keep that distinction visible in the UI. An unavailable search provider should produce a search error, not imply that the BitTorrent engine has stopped working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recover a queue without erasing the evidence
&lt;/h2&gt;

&lt;p&gt;A crash can leave two different problems: an interrupted state write and uncertainty about which transfers should restart. I handle those through separate mechanisms in &lt;a href="https://github.com/Ishannaik/harbour/blob/main/src/persist.rs" rel="noopener noreferrer"&gt;&lt;code&gt;src/persist.rs&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;downloads.json&lt;/code&gt;, I serialize the ledger, write a sibling temporary file, sync that file, and rename it over the destination. The current temporary path is &lt;code&gt;downloads.json.tmp&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.harbour/
  downloads.json       saved queue ledger
  downloads.json.tmp   sibling file during a write
  boot.marker          interrupted-run detection
  cache/torrents/      cached .torrent metadata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I place the temporary file in the same directory so the replacement stays on one filesystem. I call &lt;code&gt;sync_all()&lt;/code&gt; before the rename to reduce the chance that the replacement becomes visible before the file's contents reach storage.&lt;/p&gt;

&lt;p&gt;Atomic replacement and power-loss durability have different guarantees. The implementation syncs the temporary file; it does not sync the parent directory after the rename. I would not claim that this sequence proves durability under every filesystem and power-loss scenario.&lt;/p&gt;

&lt;p&gt;For recovery, I use &lt;code&gt;boot.marker&lt;/code&gt; to detect an interrupted prior run. Harbour restores entries paused in Safe Mode, leaving you to resume them. On clean exit, I save the ledger before clearing the marker. If that save fails, I retain the marker rather than claiming a clean checkpoint.&lt;/p&gt;

&lt;p&gt;I also preserve an unreadable ledger as &lt;code&gt;downloads.json.corrupt&lt;/code&gt; rather than overwriting its contents with an empty queue. If the top-level JSON array parses but individual rows fail, I restore valid rows and report the skipped count. That distinction lets you recover usable entries while keeping the failure visible.&lt;/p&gt;

&lt;p&gt;Piece verification belongs to the BitTorrent engine; ledger recovery belongs to Harbour. A valid JSON queue does not prove that the corresponding file bytes still exist or match their piece hashes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make terminal controls readable and reachable
&lt;/h2&gt;

&lt;p&gt;I use Ratatui for the views and crossterm for terminal input and lifecycle. You can navigate with keys, click a search result, toggle sources, and choose VLC or mpv with the mouse. I keep both paths because selecting a player from a short list should not require learning another modal key sequence.&lt;/p&gt;

&lt;p&gt;The theme loader supports the 67-token omp schema: semantic colors, named variables, and symbols. Harbour's views consume a subset, including &lt;code&gt;accent&lt;/code&gt;, &lt;code&gt;border&lt;/code&gt;, &lt;code&gt;success&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt;, &lt;code&gt;warning&lt;/code&gt;, &lt;code&gt;selectedBg&lt;/code&gt;, and &lt;code&gt;statusLineBg&lt;/code&gt;. Schema compatibility does not mean each Harbour screen uses all 67 tokens.&lt;/p&gt;

&lt;p&gt;You can put a custom theme in &lt;code&gt;~/.harbour/themes/&amp;lt;name&amp;gt;.json&lt;/code&gt;. Semantic tokens let me style selection and failure states without putting RGB literals into individual widgets.&lt;/p&gt;

&lt;p&gt;For color detection, I recognize &lt;code&gt;COLORTERM=truecolor&lt;/code&gt;, &lt;code&gt;COLORTERM=24bit&lt;/code&gt;, and a nonempty &lt;code&gt;WT_SESSION&lt;/code&gt;. Otherwise, I use the 256-color path and quantize RGB values to ANSI palette indices. You can still read progress and errors in a terminal that lacks Truecolor support. Labels and selection remain necessary because color alone cannot convey state to every reader.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the client with a permitted file
&lt;/h2&gt;

&lt;p&gt;Download the binary for your platform from &lt;a href="https://github.com/Ishannaik/harbour/releases" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;, install VLC or mpv, and start Harbour. To build from source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Ishannaik/harbour.git
&lt;span class="nb"&gt;cd &lt;/span&gt;harbour
cargo run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Rust toolchain with 2024 edition support is required. For the Creative Commons demo workflow, search for &lt;code&gt;sintel&lt;/code&gt;, select a result, and press Enter or &lt;code&gt;w&lt;/code&gt; to watch. Choose your player when Harbour prompts you. Press Tab for the downloads view and &lt;code&gt;?&lt;/code&gt; for help. Configure your own local indexer when you want to search your own sources.&lt;/p&gt;

&lt;p&gt;I keep the terminal client responsible for interaction, queue state, and recovery. I let &lt;code&gt;librqbit&lt;/code&gt; manage peer traffic and verified file access, and I let VLC or mpv handle media playback. Those boundaries give me specific places to investigate a stalled watch session: metadata resolution, required piece availability, the loopback response, or the player. They also let me change a view without touching the code that decides which bytes are safe to serve.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/Ishannaik/harbour" rel="noopener noreferrer"&gt;Ishannaik/harbour&lt;/a&gt;, by &lt;a href="https://github.com/Ishannaik" rel="noopener noreferrer"&gt;Ishan Naik&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>terminal</category>
      <category>cli</category>
      <category>programming</category>
    </item>
    <item>
      <title>Zero-Knowledge by Design: Client-Side AES-256 Encryption in SvelteKit</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:16:06 +0000</pubDate>
      <link>https://dev.to/ishannaik/zero-knowledge-by-design-client-side-aes-256-encryption-in-sveltekit-2698</link>
      <guid>https://dev.to/ishannaik/zero-knowledge-by-design-client-side-aes-256-encryption-in-sveltekit-2698</guid>
      <description>&lt;p&gt;A developer pastes an API key into a conventional pastebin. The service receives the plaintext, writes it to a database, and returns a link. Anyone who can read that database can read the key: an administrator, an attacker with stolen credentials, or someone restoring a backup.&lt;/p&gt;

&lt;p&gt;HTTPS protects the connection against passive network eavesdroppers. It does not prevent the service operator from reading the request after TLS termination. A pastebin that accepts plaintext has that access by design.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://cloakbin.com" rel="noopener noreferrer"&gt;CloakBin&lt;/a&gt; around browser-side encryption. Senders encrypt before uploading. Recipients decrypt after downloading. The backend stores an encrypted content blob and operational metadata, without the content key or password.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkr2dvobz0oun63ltz1hn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkr2dvobz0oun63ltz1hn.gif" alt="CloakBin Zero Knowledge Paste" width="720" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/Ishannaik/CloakBin" rel="noopener noreferrer"&gt;source repository&lt;/a&gt; uses SvelteKit and TypeScript. You can inspect the cryptographic boundary in &lt;a href="https://github.com/Ishannaik/CloakBin/blob/main/src/lib/crypto.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/lib/crypto.ts&lt;/code&gt;&lt;/a&gt; and the implementation details in &lt;a href="https://github.com/Ishannaik/CloakBin/blob/main/docs/SECURITY-MODEL.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/SECURITY-MODEL.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate the identifier from the secret
&lt;/h2&gt;

&lt;p&gt;For a paste without password protection, the sender shares a link shaped like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://cloakbin.com/p/abc#ENCRYPTION_KEY
                        |  |
                        |  +-- browser-side decryption key
                        +----- server-side paste identifier

Normal HTTP navigation:
GET /p/abc HTTP/1.1
Host: cloakbin.com

No #ENCRYPTION_KEY in the request target.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The recipient's browser requests &lt;code&gt;/p/abc&lt;/code&gt;. Client-side JavaScript reads &lt;code&gt;location.hash&lt;/code&gt;, imports the key, fetches the encrypted paste, and decrypts it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc3986#section-3.5" rel="noopener noreferrer"&gt;RFC 3986, section 3.5&lt;/a&gt; defines the fragment as a component that the client separates from the URI before dereferencing it. During normal HTTP navigation, browsers do not include the fragment in the request target. Browsers also omit fragments from the normal &lt;code&gt;Referer&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;That rule does not stop application JavaScript from sending a fragment in a JSON body, an analytics event, or a custom header. Developers must keep the key out of those code paths. Putting a key after &lt;code&gt;#&lt;/code&gt; gives the application a useful boundary, not permission to log &lt;code&gt;location.href&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A query parameter would cross that boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/p/abc?key=SECRET   -&amp;gt; server receives the key
/p/abc#SECRET       -&amp;gt; browser retains the fragment during navigation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat the full share link as a credential. Anyone who receives both the identifier and fragment key can decrypt the paste.&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow the bytes from sender to recipient
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant A as Browser A (sender)
    participant S as CloakBin server
    participant D as Database
    participant B as Browser B (recipient)
    A-&amp;gt;&amp;gt;A: Generate AES-256 key and fresh 96-bit IV
    A-&amp;gt;&amp;gt;A: Compress plaintext and encrypt with AES-GCM
    A-&amp;gt;&amp;gt;S: POST ciphertext frame and metadata
    S-&amp;gt;&amp;gt;D: Store encrypted content
    S--&amp;gt;&amp;gt;A: Return paste ID
    A-&amp;gt;&amp;gt;A: Build share URL with local fragment key
    A--&amp;gt;&amp;gt;B: Share /p/abc#key through chosen channel
    B-&amp;gt;&amp;gt;S: Request /p/abc without fragment
    B-&amp;gt;&amp;gt;B: Extract key from location.hash
    B-&amp;gt;&amp;gt;S: Fetch encrypted paste by ID
    S-&amp;gt;&amp;gt;D: Read encrypted content
    D--&amp;gt;&amp;gt;S: Ciphertext frame
    S--&amp;gt;&amp;gt;B: Ciphertext frame and metadata
    B-&amp;gt;&amp;gt;B: Import key, authenticate, decrypt, decompress&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The server needs the identifier to retrieve the paste. It does not need the key to perform that lookup.&lt;/p&gt;

&lt;p&gt;Operators still observe metadata: content length, creation time, expiration, language selection, and access patterns. At the HTTP layer they can also observe IP addresses. In this article, “zero-knowledge” describes content confidentiality against a backend that follows the protocol. It does not claim anonymity or a formal zero-knowledge proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encrypt with Web Crypto
&lt;/h2&gt;

&lt;p&gt;CloakBin uses the browser's Web Crypto API, available through &lt;code&gt;window.crypto.subtle&lt;/code&gt;. The implementation uses the shorter global &lt;code&gt;crypto.subtle&lt;/code&gt; spelling.&lt;/p&gt;

&lt;p&gt;For random-key pastes, the browser generates a 256-bit AES key and a fresh 12-byte initialization vector for each encryption. AES-GCM authenticates the encrypted bytes as well as encrypting them. With the 128-bit tag used here, Web Crypto appends 16 bytes of authentication data to its encryption output.&lt;/p&gt;

&lt;p&gt;Never reuse an IV with the same AES-GCM key. Reuse can expose relationships between messages and undermine authentication. Use &lt;code&gt;crypto.getRandomValues&lt;/code&gt;, not &lt;code&gt;Math.random&lt;/code&gt;, for the 96-bit IV.&lt;/p&gt;

&lt;p&gt;The following teaching example shows the AES-GCM core without CloakBin's compression or version framing. It runs in a browser secure context, such as HTTPS or localhost. It is not a replacement for the repository's serialized format.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;EncryptedPaste&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generatePasteKey&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CryptoKey&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Export the key for the share-link fragment.&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;encrypt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decrypt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encryptPaste&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CryptoKey&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;EncryptedPaste&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tagLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;bytes&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ciphertext&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decryptPaste&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;encrypted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EncryptedPaste&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CryptoKey&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plaintext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encrypted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;tagLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;encrypted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ciphertext&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fatal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone changes the ciphertext or supplies the wrong key, &lt;code&gt;subtle.decrypt&lt;/code&gt; rejects authentication. The caller should display a decryption error, without treating the bytes as plaintext or attempting to display unauthenticated content.&lt;/p&gt;

&lt;p&gt;The IV does not need secrecy. The sender can store it alongside the ciphertext. The AES key does need secrecy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Export only to the fragment
&lt;/h3&gt;

&lt;p&gt;A raw AES-256 key contains 32 bytes. CloakBin exports those bytes and encodes them as base64url, replacing &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt; with URL-safe characters and dropping padding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;keyToFragment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CryptoKey&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/=+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;keyFromFragment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fragment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CryptoKey&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fragment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;padded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;padded&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid AES-256 key length&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decrypt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After the upload returns an ID, run this in the sender's browser:&lt;/span&gt;
&lt;span class="c1"&gt;// const url = new URL(`/p/${id}`, window.location.origin);&lt;/span&gt;
&lt;span class="c1"&gt;// url.hash = await keyToFragment(key);&lt;/span&gt;
&lt;span class="c1"&gt;// Share url.href, without posting it back to the API.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;window&lt;/code&gt; and &lt;code&gt;location.hash&lt;/code&gt; access in browser event handlers or Svelte's &lt;code&gt;onMount&lt;/code&gt;. SvelteKit renders components on the server too; server rendering cannot read the recipient's fragment. The &lt;a href="https://svelte.dev/docs/kit/faq#How-do-I-use-a-client-side-library-accessing-document-or-window" rel="noopener noreferrer"&gt;SvelteKit FAQ&lt;/a&gt; covers this browser-only execution boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serialize an encrypted frame, not plaintext JSON
&lt;/h2&gt;

&lt;p&gt;CloakBin's current encoder compresses UTF-8 text with gzip before encryption. It then writes a versioned binary frame and base64-encodes that frame for storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Current v1 frame, before base64 encoding

Offset   Length     Field
0        2 bytes    "CB" magic: 0x43 0x42
2        1 byte     Version: 0x01
3        12 bytes   Random AES-GCM IV
15       remainder Encrypted gzip bytes + GCM authentication tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The recipient decodes base64, reads the version and IV, authenticates and decrypts the encrypted bytes, then decompresses the result. CloakBin also accepts its older uncompressed format so existing pastes remain readable.&lt;/p&gt;

&lt;p&gt;Base64 provides a transport encoding. Anyone can decode it to recover the frame, including its public IV. They still need the AES key to recover the compressed plaintext.&lt;/p&gt;

&lt;p&gt;Compression reduces upload size for repetitive text, but an observer can still learn the encrypted length. Do not interpret compression or encryption as padding that hides message size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Password protection: distinguish derivation from wrapping
&lt;/h2&gt;

&lt;p&gt;CloakBin's current password mode derives the content-encryption key from the password. It does &lt;strong&gt;not&lt;/strong&gt; derive a secondary wrapping key around a random content key.&lt;/p&gt;

&lt;p&gt;That distinction matters when reviewing an implementation. In a wrapping design, developers would generate a random content key, derive a password-based key, and encrypt or wrap the content key with it. CloakBin instead uses the password-derived AES key to encrypt the paste itself.&lt;/p&gt;

&lt;p&gt;The browser performs these steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate a random 16-byte salt.&lt;/li&gt;
&lt;li&gt;Import the password as PBKDF2 key material.&lt;/li&gt;
&lt;li&gt;Derive a 256-bit AES-GCM key with PBKDF2-HMAC-SHA-256 and &lt;strong&gt;100,000 iterations&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Encrypt the paste with that key and a fresh 96-bit IV.&lt;/li&gt;
&lt;li&gt;Upload the ciphertext and salt, without the password.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The recipient receives a link without a fragment key, enters the password, and repeats the derivation using the stored salt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;material&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PBKDF2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deriveKey&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deriveKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PBKDF2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;iterations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;material&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;encrypt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decrypt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attacker with the salt and ciphertext can attempt offline password guesses. They can derive a candidate key and use GCM authentication to check it. The iteration count adds work per guess; it cannot give a short password the entropy of a random 256-bit key. Choose a long, unpredictable password and share it through a separate trusted channel when that separation matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Burn-after-read needs an atomic database operation
&lt;/h2&gt;

&lt;p&gt;A developer who implements burn as a read followed by a delete creates a race:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reader A: read document ---------- delete
Reader B:       read document ---------- delete
Result: both readers can obtain the same content.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CloakBin's MongoDB adapter uses a conditional &lt;code&gt;findOneAndDelete&lt;/code&gt; through its model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOneAndDelete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;burnAfterRead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$gt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MongoDB performs this as one atomic find-and-delete operation, using the find-and-modify family of semantics. Developers do not need a separate read and a later delete for this single-document operation. Among concurrent matching burn operations, at most one caller receives the deleted document.&lt;/p&gt;

&lt;p&gt;The UI asks the recipient to confirm before calling the burn endpoint. A normal page GET does not consume the paste. “First read” therefore means the first successful confirmed consumption, not the first page navigation or link preview. Review &lt;a href="https://github.com/Ishannaik/CloakBin/blob/main/src/lib/db/adapters/mongodb.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/lib/db/adapters/mongodb.ts&lt;/code&gt;&lt;/a&gt; and the &lt;a href="https://github.com/Ishannaik/CloakBin/blob/main/src/routes/api/paste/%5Bid%5D/burn/+server.ts" rel="noopener noreferrer"&gt;burn endpoint&lt;/a&gt; when checking that boundary.&lt;/p&gt;

&lt;p&gt;Atomic consumption cannot guarantee that the recipient sees the plaintext. If the server deletes the document and the connection fails before delivery, the recipient may lose the paste. Nor can deletion erase a copy that a recipient saved, an operator retained, or a backup contains. Check the adapter you deploy: the repository's security model notes that some adapters lack a completed atomic burn implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the boundary in Chrome DevTools
&lt;/h2&gt;

&lt;p&gt;Use a disposable marker such as &lt;code&gt;cloakbin-network-check-12345&lt;/code&gt;, not a production API key. These steps describe a reproducible inspection, not a claim that I ran a live audit in this article.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open Chrome DevTools, select &lt;strong&gt;Network&lt;/strong&gt;, and enable &lt;strong&gt;Preserve log&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Create a paste without password protection. Select the outgoing &lt;code&gt;POST /api/paste&lt;/code&gt; request and inspect &lt;strong&gt;Payload&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Check that &lt;code&gt;content&lt;/code&gt; contains the base64 encrypted frame rather than your marker. Inspect the other fields: expiration, burn preference, and language metadata can remain readable. “Ciphertext-only content” does not mean that the entire JSON request contains no metadata.&lt;/li&gt;
&lt;li&gt;Compare the generated address with the request URL. The share link includes &lt;code&gt;#key&lt;/code&gt;; the upload should contain neither the key nor the full share link.&lt;/li&gt;
&lt;li&gt;Open the share link in another tab with DevTools recording. Inspect the document request and paste API requests. Their request URLs should omit the fragment. Inspect headers and bodies too, checking for application code that might copy the key into a request.&lt;/li&gt;
&lt;li&gt;Inspect the paste response. You should receive encrypted &lt;code&gt;content&lt;/code&gt;, then see the browser display the decrypted marker after local processing.&lt;/li&gt;
&lt;li&gt;Repeat with password protection. Expect a salt, no uploaded password, and a share link without a fragment key.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not publish a HAR file from a real secret-sharing session without reviewing it. Even if normal HTTP requests omit the fragment, debugging tools, screenshots, and copied browser addresses can expose sensitive material.&lt;/p&gt;

&lt;p&gt;A Network inspection gives evidence about that interaction. It cannot prove that a future client build, a browser extension, or an injected script will preserve the same boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser remains part of the trust model
&lt;/h2&gt;

&lt;p&gt;A database dump gives an attacker ciphertext and metadata. With random-key pastes, it does not give them the fragment key. A passive network observer faces HTTPS plus application-layer encryption for paste contents.&lt;/p&gt;

&lt;p&gt;An attacker who controls the JavaScript delivered to the sender or recipient has a different opportunity. They can change the client to upload plaintext, read &lt;code&gt;location.hash&lt;/code&gt;, or capture a password before derivation. XSS and malicious browser extensions create similar risks. Browser-side encryption does not protect you from malicious code running inside the browser that handles the secret.&lt;/p&gt;

&lt;p&gt;Deploy with HTTPS, review client dependencies, restrict script execution, and avoid analytics or logging that captures full URLs. Keep the share link out of issue trackers and support transcripts. Use short expiration when a paste has a short useful lifetime, and rotate credentials after sharing when your workflow permits it.&lt;/p&gt;

&lt;p&gt;You can inspect &lt;a href="https://github.com/Ishannaik/CloakBin" rel="noopener noreferrer"&gt;CloakBin on GitHub&lt;/a&gt; or try &lt;a href="https://cloakbin.com" rel="noopener noreferrer"&gt;cloakbin.com&lt;/a&gt;. Start your review at the browser crypto functions, follow the POST body into storage, and check the recipient's fragment handling. Those three places determine whether the server ever receives the secret it claims not to know.&lt;/p&gt;

</description>
      <category>security</category>
      <category>svelte</category>
      <category>typescript</category>
      <category>cryptography</category>
    </item>
    <item>
      <title>Zero Cloud Storage: Building a P2P File Transfer App with WebRTC DataChannels and Cloudflare Workers</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:15:16 +0000</pubDate>
      <link>https://dev.to/ishannaik/zero-cloud-storage-building-a-p2p-file-transfer-app-with-webrtc-datachannels-and-cloudflare-workers-103i</link>
      <guid>https://dev.to/ishannaik/zero-cloud-storage-building-a-p2p-file-transfer-app-with-webrtc-datachannels-and-cloudflare-workers-103i</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8o4vqvhjiyctf54s6j14.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8o4vqvhjiyctf54s6j14.jpg" alt="Warp transfer queue" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://warp.ishannaik.com" rel="noopener noreferrer"&gt;Warp&lt;/a&gt; so two people can transfer a file without uploading it to my infrastructure. The sender keeps the source file open. The receiver accepts the offer. Their browsers exchange the bytes through an encrypted WebRTC DataChannel.&lt;/p&gt;

&lt;p&gt;That choice removes the hosted file store, its retention policy, and its bandwidth bill. It also leaves me with engineering work that an upload service would hide: NAT traversal, browser send queues, receiver storage quotas, and incomplete writes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Ishannaik/warp" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fes5t8yvt1zyysi3i2u0d.png" alt="Warp logo" width="512" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/Ishannaik/warp" rel="noopener noreferrer"&gt;MIT-licensed repository&lt;/a&gt; contains a React 19 and TypeScript frontend in &lt;code&gt;web/&lt;/code&gt;, plus a Cloudflare Worker and Durable Object in &lt;code&gt;server/&lt;/code&gt;. The production app uses Cloudflare's free tier. File size does not determine the signaling server's bandwidth use because the browsers carry the file traffic themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill follows the file path
&lt;/h2&gt;

&lt;p&gt;When you send a large archive through WeTransfer, Dropbox Transfer, or SendGB, you use a hosted transfer product. Those products offer different quotas, prices, and retention rules. I would not infer their internal storage providers from those limits or claim that they all use S3.&lt;/p&gt;

&lt;p&gt;For a store-and-forward design, though, the cost model is concrete: the operator accepts the upload, retains the object, and serves downloads. An S3-style implementation needs object storage, upload handling, and a delivery path. If three recipients download a 10 GiB archive, the operator delivers 30 GiB of payload, apart from the sender's initial upload. Pricing depends on the provider, caching, region, and plan.&lt;/p&gt;

&lt;p&gt;I chose a direct transfer path for Warp. The sender's connection carries the outbound traffic, and each recipient needs a live connection to the sender. I give up the ability to close the sending laptop and let someone download tomorrow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Store-and-forward:
  sender -&amp;gt; hosted object -&amp;gt; recipient
                 |
                 +-&amp;gt; retention, quota, delivery costs

Warp:
  sender =================&amp;gt; recipient
         encrypted file bytes
     \                      /
      +---- signaling -----+
            SDP and ICE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This drawing describes an architecture comparison, not measured traffic from a benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate rendezvous from file transport
&lt;/h2&gt;

&lt;p&gt;I split the system into a signaling plane and a data plane.&lt;/p&gt;

&lt;p&gt;In the signaling plane, the browsers open WebSockets to the Cloudflare Worker. The Durable Object assigns a room code, tracks connected peers, and relays WebRTC offers, answers, and ICE candidates. The browsers need this rendezvous before they know how to reach each other.&lt;/p&gt;

&lt;p&gt;In the data plane, each pair of browsers opens an &lt;code&gt;RTCDataChannel&lt;/code&gt;. WebRTC carries SCTP messages over DTLS. The endpoints encrypt the traffic; the signaling service receives no file payload in this transfer path.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant A as Sender browser
    participant W as Cloudflare Worker
    participant D as Signaling Durable Object
    participant B as Receiver browser
    participant S as Google STUN

    rect rgb(238, 242, 255)
        Note over A,B: Signaling phase over WebSockets
        A-&amp;gt;&amp;gt;W: Create room
        W-&amp;gt;&amp;gt;D: Upgrade and register socket
        D--&amp;gt;&amp;gt;A: Room code
        B-&amp;gt;&amp;gt;W: Join room code
        W-&amp;gt;&amp;gt;D: Register second socket
        A-&amp;gt;&amp;gt;S: Discover public address
        B-&amp;gt;&amp;gt;S: Discover public address
        A-&amp;gt;&amp;gt;D: SDP offer and ICE candidates
        D-&amp;gt;&amp;gt;B: Relay signaling
        B-&amp;gt;&amp;gt;D: SDP answer and ICE candidates
        D-&amp;gt;&amp;gt;A: Relay signaling
    end

    rect rgb(236, 253, 245)
        Note over A,B: Direct transfer phase: DTLS and SCTP
        A-&amp;gt;&amp;gt;B: File offer over DataChannel
        B-&amp;gt;&amp;gt;A: Accept
        A-&amp;gt;&amp;gt;B: Binary file chunks
        B-&amp;gt;&amp;gt;B: Write accepted bytes to receive sink
        A-&amp;gt;&amp;gt;B: File end
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;I keep the signaling socket available after connection setup. Warp uses it for membership changes, recovery, and an out-of-band cancel message. Calling the service an introducer does not mean the browser closes that socket once the channel opens.&lt;/p&gt;

&lt;p&gt;The source boundaries help contributors trace a transfer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;th&gt;Source path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Worker routing and room membership&lt;/td&gt;
&lt;td&gt;&lt;code&gt;server/src/index.js&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser WebSocket client&lt;/td&gt;
&lt;td&gt;&lt;code&gt;web/src/lib/warp/signaling.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peer connection and send pump&lt;/td&gt;
&lt;td&gt;&lt;code&gt;web/src/lib/warp/peer.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control-frame types&lt;/td&gt;
&lt;td&gt;&lt;code&gt;web/src/lib/warp/transfer.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receive sink accounting&lt;/td&gt;
&lt;td&gt;&lt;code&gt;web/src/lib/warp/receiveController.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OPFS worker and staging&lt;/td&gt;
&lt;td&gt;&lt;code&gt;web/src/lib/warp/opfsStage.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React orchestration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;web/src/lib/warp/useWarpTransfer.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Hibernation changes how I keep room state
&lt;/h2&gt;

&lt;p&gt;A connected WebSocket does not need to keep a Durable Object's JavaScript instance resident. With &lt;a href="https://developers.cloudflare.com/durable-objects/best-practices/websockets/" rel="noopener noreferrer"&gt;Cloudflare's WebSocket Hibernation API&lt;/a&gt;, I accept sockets through the Durable Object context. Cloudflare can evict an idle instance while retaining its connections, then reconstruct the instance when a message arrives.&lt;/p&gt;

&lt;p&gt;That makes an ordinary in-memory &lt;code&gt;Map&lt;/code&gt; a poor source of room membership. The constructor runs again after hibernation; the previous map no longer exists.&lt;/p&gt;

&lt;p&gt;Warp attaches &lt;code&gt;{ ip, peerId, room }&lt;/code&gt; to each socket with &lt;code&gt;serializeAttachment()&lt;/code&gt;. The server reconstructs membership from &lt;code&gt;getWebSockets()&lt;/code&gt; and the socket attachments. The current implementation routes connections to one global Durable Object, which fits the project's present scale. Sharding would require another routing design.&lt;/p&gt;

&lt;p&gt;I also need to distinguish &lt;strong&gt;zero cloud file storage&lt;/strong&gt; from &lt;strong&gt;zero persistent metadata&lt;/strong&gt;. Warp stores no file bytes on the server. The current server does write a &lt;code&gt;reclaim:&amp;lt;code&amp;gt;&lt;/code&gt; record when the last socket leaves a room. That record reserves the rendezvous code for three minutes so disconnected peers can rejoin. It contains no transfer payload, and the server checks its expiry before reclaiming the room.&lt;/p&gt;

&lt;p&gt;The rooms remain session-oriented. I have no object store of uploaded files and no long-lived download archive. Describing this version as having no persistent server state at all would conceal the reclaim record.&lt;/p&gt;

&lt;p&gt;Hibernation avoids idle duration charges; it does not eliminate the cost accounting for incoming messages, execution, or storage operations. Warp runs at $0 within the free plan's allowances. Readers who self-host should check &lt;a href="https://developers.cloudflare.com/durable-objects/platform/pricing/" rel="noopener noreferrer"&gt;Cloudflare's current Durable Objects pricing&lt;/a&gt; before treating a hobby deployment as an unlimited service.&lt;/p&gt;

&lt;h2&gt;
  
  
  STUN-only gives me a defined failure mode
&lt;/h2&gt;

&lt;p&gt;Warp configures public Google STUN endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stun:stun.l.google.com:19302&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stun:stun1.l.google.com:19302&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDataChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;binaryType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;arraybuffer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;STUN lets the browsers discover server-reflexive addresses. ICE tests candidate pairs and attempts to establish a direct path. The STUN server handles address-discovery traffic, not the file stream.&lt;/p&gt;

&lt;p&gt;I configure no TURN server. A TURN relay could help when direct traversal fails, but its operator would carry the file bytes and pay for that bandwidth. Adding one would change both Warp's economics and its file-path guarantee.&lt;/p&gt;

&lt;p&gt;Some symmetric NAT combinations, UDP restrictions, firewalls, and network isolation policies prevent the browsers from connecting. Warp reports &lt;code&gt;nat-failed&lt;/code&gt; when initial traversal fails. I do not promise that sharing a Wi-Fi network guarantees success: guest isolation or endpoint policy can still block the path.&lt;/p&gt;

&lt;p&gt;The user can try another network. I accept that loss of connectivity coverage instead of adding a paid relay fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chunk size and queue size solve different problems
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;peer.ts&lt;/code&gt;, Warp targets &lt;strong&gt;256 KiB messages&lt;/strong&gt;, meaning &lt;code&gt;256 * 1024&lt;/code&gt; bytes. A DataChannel message is not an IP packet; the WebRTC stack can fragment it below the application layer.&lt;/p&gt;

&lt;p&gt;I cap each message against the negotiated &lt;code&gt;pc.sctp.maxMessageSize&lt;/code&gt;. Sending 256 KiB without checking the peer's limit risks a failed &lt;code&gt;send()&lt;/code&gt;. The target also exceeds the 16 KB recommendation in &lt;a href="https://www.rfc-editor.org/rfc/rfc8831#section-6.6" rel="noopener noreferrer"&gt;RFC 8831, section 6.6&lt;/a&gt;, which addresses monopolization when message interleaving is unavailable. Warp's bulk-transfer workload favors fewer messages, but compatibility still constrains the final size.&lt;/p&gt;

&lt;p&gt;The production sender reads the source in &lt;strong&gt;4 MiB blocks&lt;/strong&gt; and splits each block into smaller sends. That reduces asynchronous file-read calls compared with reading once per 256 KiB message.&lt;/p&gt;

&lt;p&gt;Neither constant bounds the browser's send queue. For that, I inspect &lt;code&gt;channel.bufferedAmount&lt;/code&gt;, which counts application bytes queued for transmission. Warp uses an &lt;strong&gt;8 MiB high-water mark&lt;/strong&gt; and a &lt;strong&gt;1 MiB low-water mark&lt;/strong&gt;. Before sending a chunk, I include its length in the high-water comparison.&lt;/p&gt;

&lt;p&gt;Without that check, a fast disk can outpace the network. The browser queue grows until &lt;code&gt;send()&lt;/code&gt; throws or memory pressure disrupts the tab. An arbitrary multi-gigabyte source should not require a multi-gigabyte JavaScript buffer.&lt;/p&gt;

&lt;h3&gt;
  
  
  A TypeScript sender loop for a React 19 app
&lt;/h3&gt;

&lt;p&gt;The following example isolates the sender mechanism. It is explanatory code, not a copy of Warp's full engine: the production implementation also handles offers, cancellation, resume, and other protocol features. Call it from a React event handler after receiver acceptance and channel negotiation. Keep the connection object outside render state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TARGET_CHUNK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;READ_BLOCK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HIGH_WATER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LOW_WATER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForDrain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RTCDataChannel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleanup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bufferedamountlow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DataChannel closed while draining&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;aborted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DOMException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Aborted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bufferedAmount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;LOW_WATER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bufferedamountlow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendFileBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RTCDataChannel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;onQueued&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sctp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Negotiate an open SCTP channel first&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// A negotiated zero means no advertised message-size limit.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;negotiated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sctp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxMessageSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunkSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;negotiated&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;TARGET_CHUNK&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TARGET_CHUNK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;negotiated&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunkSize&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid SCTP message limit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bufferedAmountLowThreshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LOW_WATER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;queued&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;READ_BLOCK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throwIfAborted&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;READ_BLOCK&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunkSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunkSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bufferedAmount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;HIGH_WATER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForDrain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throwIfAborted&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DataChannel closed before send&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;queued&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;onQueued&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queued&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I register the event listeners before rechecking the buffer so a drain that happens near listener setup cannot strand the promise. I also reject on channel closure and abort, then remove all listeners. Waiting only for &lt;code&gt;bufferedamountlow&lt;/code&gt; can leave a dead transfer suspended forever.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;onQueued&lt;/code&gt; reports bytes handed to the channel, not bytes saved by the recipient. Finishing this loop does not prove receiver completion. The file protocol must finish its framing and the receiver must settle its writes before the UI claims success.&lt;/p&gt;

&lt;p&gt;In React, I update progress once per read block rather than once per message. Keeping networking in TypeScript modules also lets me change queue policy without coupling it to component rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receive into a sink, then account for the write
&lt;/h2&gt;

&lt;p&gt;A receiver that collects all chunks in an array and constructs one giant &lt;code&gt;Blob&lt;/code&gt; can still exhaust memory even when the sender respects its own queue limit. I select a sink before accepting the file.&lt;/p&gt;

&lt;p&gt;Warp's current &lt;a href="https://github.com/Ishannaik/warp/blob/main/docs/BROWSER-SUPPORT.md" rel="noopener noreferrer"&gt;browser support matrix&lt;/a&gt; describes three large-file paths. A batch reaches the large-file threshold when its total size or one file reaches &lt;strong&gt;256 MiB&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Receiver capability&lt;/th&gt;
&lt;th&gt;Large-file path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Matching native save picker&lt;/td&gt;
&lt;td&gt;Stream to the user-selected file or folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No selected native target, OPFS available&lt;/td&gt;
&lt;td&gt;Stage in an origin-private file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OPFS unavailable&lt;/td&gt;
&lt;td&gt;Stage Blob records in IndexedDB, subject to guards&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For smaller transfers, Warp uses the in-memory tray. For origin storage, I check the browser's quota estimate before accepting. OPFS stores bytes on the receiver's device; it does not give the sender or my server a cloud copy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Receiver backpressure has another boundary
&lt;/h3&gt;

&lt;p&gt;The Streams API gives an application a way to await sink writes and propagate pressure through a pipeline. A DataChannel's &lt;code&gt;message&lt;/code&gt; event does not provide the same interface as a &lt;code&gt;ReadableStream&lt;/code&gt;: an &lt;code&gt;async&lt;/code&gt; event listener does not stop later messages from arriving.&lt;/p&gt;

&lt;p&gt;The diagram separates transport flow control from application write ordering. It does not claim that awaiting an OPFS write alone pauses a remote sender.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[Sender file blocks] --&amp;gt; B[Check bufferedAmount plus next chunk]
    B --&amp;gt; C[RTCDataChannel over SCTP and DTLS]
    C --&amp;gt; D[Receiver message events]
    D --&amp;gt; E[Ordered receive sink queue]
    E --&amp;gt; F[Transfer ArrayBuffer to OPFS worker]
    F --&amp;gt; G[Write through sync access handle]
    G --&amp;gt; H[Acknowledge completed write]
    H --&amp;gt; I[Advance live-session bytesWritten]
    G --&amp;gt; J[Flush every 16 MiB and on close]
    J --&amp;gt; K[File-backed completed result]
    C -. SCTP transport flow control .-&amp;gt; B
    H -. Release current sink operation .-&amp;gt; E&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Warp serializes sink operations and waits for worker acknowledgments. The OPFS worker receives an &lt;code&gt;ArrayBuffer&lt;/code&gt; through a transfer list, writes through a &lt;code&gt;FileSystemSyncAccessHandle&lt;/code&gt;, and handles short writes by continuing at the correct offset. I keep this work off the React thread.&lt;/p&gt;

&lt;p&gt;A serialized write chain preserves order; it does not establish a bounded application backlog. If the receiver's disk stays slower than the connection, queued message buffers can still accumulate. Strict end-to-end memory bounds would require receiver credits, acknowledged write windows, or an equivalent bounded ingress mechanism. Sender &lt;code&gt;bufferedAmount&lt;/code&gt; alone cannot promise that bound.&lt;/p&gt;

&lt;p&gt;I also distinguish write acknowledgment from crash durability. Warp's OPFS worker flushes in &lt;strong&gt;16 MiB batches&lt;/strong&gt; and on close, rather than flushing each 256 KiB message. That avoids paying the flush cost for each chunk. After an unclean reload, I must reconcile the saved ledger offset with the actual surviving file length before resuming.&lt;/p&gt;

&lt;p&gt;Within the running session, I advance &lt;code&gt;bytesWritten&lt;/code&gt; after a successful write acknowledgment. If opening, writing, or closing fails, I poison the sink and report the failure. At file completion, I wait for pending writes and require the received byte count to equal the declared file size. Disk-full errors must not produce a completed transfer badge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encryption still has a trust boundary
&lt;/h2&gt;

&lt;p&gt;DTLS protects the direct channel against network observers. In the intended deployment, the signaling service relays connection metadata and stays outside the payload path.&lt;/p&gt;

&lt;p&gt;That leaves metadata exposure: the service can see connecting IP addresses, room codes, peer identifiers, timing, and SDP/ICE signaling. STUN operators also see the discovery requests. “No cloud file storage” does not mean anonymous communication.&lt;/p&gt;

&lt;p&gt;I treat the short room code as a rendezvous token, not a durable password. Recipients should verify who joined and accept files they expect. Transport encryption also cannot protect a browser after someone compromises its JavaScript, or authenticate a human identity without another verification mechanism. A malicious signaling operator has a different threat model from a passive network observer.&lt;/p&gt;

&lt;p&gt;The repository's &lt;a href="https://github.com/Ishannaik/warp/blob/main/docs/THREAT-MODEL.md" rel="noopener noreferrer"&gt;threat model&lt;/a&gt; documents these boundaries. Keeping them visible matters more than attaching an unqualified privacy claim to the landing page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running Warp and reading the engine
&lt;/h2&gt;

&lt;p&gt;For local development, start the frontend and signaling service in separate terminals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nb"&gt;install
&lt;/span&gt;pnpm dev:server
pnpm dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The documented development ports are &lt;code&gt;8787&lt;/code&gt; for signaling and &lt;code&gt;5173&lt;/code&gt; for the web app. Configure the frontend's &lt;code&gt;VITE_SIGNALING_URL&lt;/code&gt; for your signaling deployment; Vite embeds it at build time, so changing the deployed URL requires rebuilding the frontend.&lt;/p&gt;

&lt;p&gt;I recommend reading &lt;code&gt;peer.ts&lt;/code&gt; alongside &lt;code&gt;opfsStage.ts&lt;/code&gt;. The sender has to respect a negotiated message limit and a local queue ceiling. The receiver has to preserve write order, survive storage failures, and distinguish acknowledged bytes from crash-persistent bytes. Those constraints remain whether the UI shows a single progress bar or a multi-device transfer tray.&lt;/p&gt;

&lt;p&gt;Try &lt;a href="https://warp.ishannaik.com" rel="noopener noreferrer"&gt;Warp&lt;/a&gt;, or inspect &lt;a href="https://github.com/Ishannaik/warp" rel="noopener noreferrer"&gt;the source on GitHub&lt;/a&gt;. For this design, the important trade-off is concrete: both peers stay online, direct ICE traversal must succeed, and the receiver needs enough local storage. In return, I do not operate a file warehouse or a relay carrying users' archives.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AI Coding Agents Are Leaking Your Secrets: Building a Local Pre-Commit DLP for Claude Code</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:15:10 +0000</pubDate>
      <link>https://dev.to/ishannaik/ai-coding-agents-are-leaking-your-secrets-building-a-local-pre-commit-dlp-for-claude-code-1i9g</link>
      <guid>https://dev.to/ishannaik/ai-coding-agents-are-leaking-your-secrets-building-a-local-pre-commit-dlp-for-claude-code-1i9g</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhp671yjbczkvou2x4hg6.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhp671yjbczkvou2x4hg6.gif" alt="AgentSweep scanning and redacting secrets" width="400" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You paste a &lt;code&gt;.env&lt;/code&gt; file into Claude Code to debug a database connection. You remove the credentials from your next message, clean the repository, and rotate the database password. Your local conversation history still contains the original paste.&lt;/p&gt;

&lt;p&gt;Claude Code keeps JSONL transcripts beneath &lt;code&gt;~/.claude/projects/&lt;/code&gt;. Codex keeps session JSONL beneath &lt;code&gt;~/.codex/sessions/&lt;/code&gt;. A process running as your user can read those files without asking either agent for permission.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/Ishannaik/agent-sweep" rel="noopener noreferrer"&gt;AgentSweep&lt;/a&gt; to scan that history, report credential-shaped strings, and redact them without breaking the history format. I also added a pre-commit hook so developers can check this separate store of secrets before committing code.&lt;/p&gt;

&lt;p&gt;The headline describes a credential exposure, not proof that your coding assistant has sent a key to an attacker. Developers create the exposure when they paste secrets or let an agent capture secret-bearing tool output. Attackers exploit the copy left on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The credential store outside your repository
&lt;/h2&gt;

&lt;p&gt;A repository scanner checks the files you stage or commit. Your agent's history lives elsewhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Developer home
|
+ .claude/
|  + projects/
|     + &amp;lt;encoded-project-path&amp;gt;/
|        + &amp;lt;session&amp;gt;.jsonl
|        + conversations/&amp;lt;session&amp;gt;.jsonl  (layout varies)
|
+ .codex/
|  + sessions/&amp;lt;year&amp;gt;/&amp;lt;month&amp;gt;/&amp;lt;day&amp;gt;/rollout-*.jsonl
|  + history.jsonl
|
+ workspace/
   + .env
   + .git/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Agent versions use different layouts. AgentSweep walks Claude Code's project trees for &lt;code&gt;*.jsonl&lt;/code&gt; rather than assuming that a &lt;code&gt;conversations/&lt;/code&gt; directory exists. It also honors &lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt; and &lt;code&gt;CODEX_HOME&lt;/code&gt;, because a scan of the default home misses a developer's relocated profile.&lt;/p&gt;

&lt;p&gt;Developers accumulate more than API keys in these records: PostgreSQL URLs with passwords, AWS access key IDs, GitHub tokens, private-key blocks, and wallet recovery phrases. An assistant response can repeat a credential from the prompt. Tool output can introduce another copy.&lt;/p&gt;

&lt;p&gt;Cursor, Windsurf, and Aider keep local history too, but their storage formats differ. Aider uses Markdown history; other integrations use JSON or SQLite. Treating all coding assistants as JSONL producers would miss those stores. AgentSweep uses source adapters for discovery, string extraction, and format-specific redaction.&lt;/p&gt;

&lt;p&gt;Rotating a key invalidates that credential at its provider. It does not remove its bytes from a transcript, backup, or filesystem snapshot. Cleaning Git history does not touch these directories either.&lt;/p&gt;

&lt;h2&gt;
  
  
  An install script runs with your filesystem access
&lt;/h2&gt;

&lt;p&gt;An attacker who compromises a package can run code during installation. An npm &lt;code&gt;postinstall&lt;/code&gt; script inherits the installing user's access. Attackers can use Python package build or installation execution paths for the same purpose; PyPI does not use npm's lifecycle-hook names.&lt;/p&gt;

&lt;p&gt;Researchers have documented this class of theft. In &lt;a href="https://www.aikido.dev/blog/shai-hulud-npm-bitwarden-cli-compromise" rel="noopener noreferrer"&gt;Aikido's analysis of the compromised &lt;code&gt;@bitwarden/cli@2026.4.0&lt;/code&gt;&lt;/a&gt;, the malicious &lt;code&gt;preinstall&lt;/code&gt; payload targeted &lt;code&gt;.env&lt;/code&gt;, cloud credentials, &lt;code&gt;~/.claude.json&lt;/code&gt;, and &lt;code&gt;~/.claude/mcp.json&lt;/code&gt;, among other files. That report establishes theft of AI-tool configuration credentials. It does not establish that this particular payload harvested conversation JSONL.&lt;/p&gt;

&lt;p&gt;A transcript harvester needs the same filesystem access. The diagram below models that attack path:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    D[Developer installs an npm dependency] --&amp;gt; P[Compromised package runs postinstall]
    P --&amp;gt; U[Payload runs as the developer's user]
    U --&amp;gt; H[Read files beneath ~/.claude/projects/]
    H --&amp;gt; J[Parse JSONL prompts and responses]
    J --&amp;gt; K[Extract tokens and database credentials]
    K --&amp;gt; E[Send credentials to attacker endpoint]
    E --&amp;gt; A[Attacker uses credentials that remain valid]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;No exploit in JSONL parsing is necessary. The attacker reads an ordinary file. An owner-only permission mode helps against other local users, but it does not stop malware running under your account.&lt;/p&gt;

&lt;p&gt;AgentSweep reduces the stored-history exposure. It cannot undo exfiltration, remove a provider's copy of a prompt, or stop malware from reading a live &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five stages, with an explicit write boundary
&lt;/h2&gt;

&lt;p&gt;I separated discovery and detection from mutation. &lt;code&gt;scan&lt;/code&gt; ends with a findings report. &lt;code&gt;fix&lt;/code&gt; adds the redaction step, and developers still need to revoke exposed credentials themselves.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    D[1. Discover: walk source history roots] --&amp;gt; S[2. Scan: Aho-Corasick and regex rules plus BIP-39]
    S --&amp;gt; F[3. Findings: masked report and locations]
    F --&amp;gt; C{Developer selects fix}
    C --&amp;gt;|No| X[Stop without modifying history]
    C --&amp;gt;|Yes, after safety checks| R[4. Redact: backup, temp write, validation, atomic replace]
    R --&amp;gt; K[5. Revocation guidance: provider URLs]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;During scanning and redaction, AgentSweep makes zero network requests. It reads local history, performs local matching, and writes local results. Installation downloads packages, and opening a revocation URL uses your browser. The separate update command contacts PyPI. None of those actions belongs to the scan or redaction pipeline.&lt;/p&gt;

&lt;p&gt;I chose that boundary because a credential-cleanup tool should not upload the material it inspects.&lt;/p&gt;

&lt;h3&gt;
  
  
  Discovery: extract strings without guessing the schema
&lt;/h3&gt;

&lt;p&gt;For JSONL, a source adapter parses records and yields string values with their locations. The scanner can associate a finding with a file, physical line, and nested key path. The redactor can then reach that value in the parsed record.&lt;/p&gt;

&lt;p&gt;The source layer matters for SQLite and Markdown too. A database adapter needs row-aware updates and an integrity check. A text adapter needs line-preserving replacement. Sharing detection rules does not require sharing a serialization strategy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scan: avoid running 207 patterns on every string
&lt;/h3&gt;

&lt;p&gt;AgentSweep documents 207 regex rules, plus mnemonic detection. Applying each pattern to each prompt would repeat a lot of work on ordinary source code and prose.&lt;/p&gt;

&lt;p&gt;I use Aho-Corasick pre-filtering to identify rule keywords in a shared pass. The matcher represents those keywords in a trie with failure links. After finding a keyword, the scanner selects the associated rules for regex evaluation.&lt;/p&gt;

&lt;p&gt;A keyword hit does not prove that the string contains a credential. The regex still checks the shape and boundaries. Conversely, a rule without a safe keyword gate must remain eligible without a keyword hit. Otherwise the optimization would introduce false negatives.&lt;/p&gt;

&lt;p&gt;For wallet phrases, membership in a word list is insufficient. The BIP-39 detector checks candidate lengths of 12, 15, 18, 21, or 24 words and verifies the checksum. That rejects many stretches of English prose that happen to contain mnemonic words. A valid checksum identifies a mnemonic-shaped value; it does not prove that someone funded the associated wallet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Regex engines: keep compatibility when adding RE2
&lt;/h3&gt;

&lt;p&gt;The default installation uses Python's &lt;code&gt;re&lt;/code&gt;. Developers can install the optional native backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s1"&gt;'agentsweep[fast]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AgentSweep uses &lt;code&gt;google-re2&lt;/code&gt; for compatible patterns. RE2 avoids backtracking and provides linear-time matching for those expressions, which helps when developers paste large logs into a session.&lt;/p&gt;

&lt;p&gt;RE2 does not support every Python regex construct. Lookarounds, Python-specific anchors, and Unicode semantics require care. AgentSweep retains the Python path for unsupported rules and semantic edge cases, including guarded non-ASCII inputs. It also retains that path for short strings and dense matches where native dispatch would not help.&lt;/p&gt;

&lt;p&gt;I did not claim linear-time scanning for the entire mixed pipeline. Python fallback rules still use Python's engine.&lt;/p&gt;

&lt;p&gt;The checked-in &lt;a href="https://github.com/Ishannaik/agent-sweep/blob/main/docs/RE2_COMPATIBILITY.md" rel="noopener noreferrer"&gt;compatibility audit&lt;/a&gt; records 144 RE2 rules and 58 stdlib rules in a 202-rule snapshot. That snapshot predates the README's 207-rule count. Treat it as evidence for the routing design, not a current inventory. Rule counts need a version alongside them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a read-only report
&lt;/h2&gt;

&lt;p&gt;AgentSweep requires Python 3.11 or newer. Install the CLI in an isolated tool environment, then select a source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv tool &lt;span class="nb"&gt;install &lt;/span&gt;agentsweep
agentsweep list-sources &lt;span class="nt"&gt;--detected&lt;/span&gt;
agentsweep scan &lt;span class="nt"&gt;--source&lt;/span&gt; claude-code &lt;span class="nt"&gt;--no-color&lt;/span&gt;
agentsweep scan &lt;span class="nt"&gt;--source&lt;/span&gt; codex &lt;span class="nt"&gt;--no-color&lt;/span&gt;
agentsweep scan &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--detected&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; findings.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scan exit codes give scripts a small contract:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No findings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Findings exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An error occurred&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a concrete report example, suppose a test transcript contains the AWS documentation sample key &lt;code&gt;AKIAIOSFODNN7EXAMPLE&lt;/code&gt;. The following block illustrates the finding information, not an execution transcript or a byte-for-byte promise about terminal formatting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;source       claude-code
file         &amp;lt;project&amp;gt;/session.jsonl
line         1
rule         aws-access-key
preview      AKIA...MPLE
next action  Review the finding; revoke a real exposed key
scan status  Findings exist (exit 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Review the report without copying full credentials into an issue or another chat. A detector can recognize credential syntax without knowing whether the provider still accepts the value. AgentSweep stays offline, so it does not test key validity.&lt;/p&gt;

&lt;p&gt;For a known false positive, use a narrow &lt;code&gt;.agentsweepignore&lt;/code&gt; entry. Suppressing an entire provider rule makes future leaks from that provider invisible to the hook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redact JSON values, preserve parseable history
&lt;/h2&gt;

&lt;p&gt;A raw substitution over serialized JSON can damage escaping or consume syntax outside the intended value. I redact string values in parsed records, then serialize the records again.&lt;/p&gt;

&lt;p&gt;Consider this illustrative one-record transcript. The AWS key comes from a public documentation example, not a working credential.&lt;/p&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"AWS_ACCESS_KEY_ID=[REDACTED:aws-access-key]"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Value-level diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
&lt;/span&gt;&lt;span class="gi"&gt;+ AWS_ACCESS_KEY_ID=[REDACTED:aws-access-key]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The redactor preserves the record's structure. Re-serialization can change whitespace, so I do not promise byte-for-byte identity outside the secret.&lt;/p&gt;

&lt;p&gt;For production history, close the agent and allow the recent-write window to expire before fixing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agentsweep fix &lt;span class="nt"&gt;--source&lt;/span&gt; claude-code &lt;span class="nt"&gt;--allow-production&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In interactive mode, review the findings and type &lt;code&gt;REDACT&lt;/code&gt; when prompted. The current alpha requires the production-root opt-in. Keep backups enabled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Eight core write protections
&lt;/h3&gt;

&lt;p&gt;I use eight core protections around this operation. The project also documents an alpha production gate and an audit log.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Protection&lt;/th&gt;
&lt;th&gt;Engineering reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Parse before replacement&lt;/td&gt;
&lt;td&gt;Replace string values without editing JSON delimiters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Atomic replacement&lt;/td&gt;
&lt;td&gt;Write a sibling temporary file, flush it with &lt;code&gt;fsync()&lt;/code&gt;, then call &lt;code&gt;os.replace()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Format-aware validation&lt;/td&gt;
&lt;td&gt;Parse non-empty JSONL records and compare line counts before committing the replacement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backup with no clobber&lt;/td&gt;
&lt;td&gt;Create &lt;code&gt;.bak&lt;/code&gt; by default with exclusive creation and mode &lt;code&gt;0600&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path containment&lt;/td&gt;
&lt;td&gt;Refuse targets outside the selected source's history roots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Symlink refusal&lt;/td&gt;
&lt;td&gt;Refuse symlink targets rather than follow them into another file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recent-write gate&lt;/td&gt;
&lt;td&gt;Refuse files modified within the previous 60 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running-process gate&lt;/td&gt;
&lt;td&gt;Refuse redaction when the relevant agent appears to be running&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;fsync()&lt;/code&gt; and replacement sequence prevents a partial rewrite of the target during normal atomic-replace operation. It does not justify a blanket guarantee about storage hardware or power-loss durability on every filesystem.&lt;/p&gt;

&lt;p&gt;The format check validates the rewritten content before &lt;code&gt;os.replace()&lt;/code&gt; commits it. Describing that as validation after replacing the original would give readers the wrong failure model.&lt;/p&gt;

&lt;p&gt;On POSIX, mode &lt;code&gt;0600&lt;/code&gt; limits backup access to the owner. Windows uses ACLs with different semantics; do not interpret Python's mode argument as an equivalent Windows security boundary.&lt;/p&gt;

&lt;p&gt;Developers can bypass the recent-write and process gates with &lt;code&gt;--force&lt;/code&gt;. That flag does not bypass containment, symlink rejection, or invalid-content failures. Prefer closing the agent over overriding its concurrency checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make history scanning a pre-commit checkpoint
&lt;/h2&gt;

&lt;p&gt;Add the project's hook to &lt;code&gt;.pre-commit-config.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/Ishannaik/agent-sweep&lt;/span&gt;
    &lt;span class="na"&gt;rev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v0.1.9&lt;/span&gt;
    &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;agentsweep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project's README uses this released tag in its example. Review release changes before choosing a newer pin.&lt;/p&gt;

&lt;p&gt;Install the hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pre-commit &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/Ishannaik/agent-sweep/blob/main/.pre-commit-hooks.yaml" rel="noopener noreferrer"&gt;hook definition&lt;/a&gt; invokes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agentsweep scan &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--detected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It sets &lt;code&gt;pass_filenames: false&lt;/code&gt; and &lt;code&gt;always_run: true&lt;/code&gt;. Developers therefore get one history scan per commit regardless of which files they staged. With no detected history root, the scan exits clean.&lt;/p&gt;

&lt;p&gt;The hook blocks a commit when the detector finds a credential-shaped value. It does not redact anything during the commit. Developers review the report, revoke real exposed credentials, and run a separate fix operation.&lt;/p&gt;

&lt;p&gt;This is a local data-loss-prevention checkpoint with limits. A developer can bypass Git hooks. Malware can read a transcript before the next commit. A clean result means the scanner found nothing within the selected sources and rules, not that the workstation contains no secrets.&lt;/p&gt;

&lt;p&gt;Keep a staged-file secret scanner alongside it. The two scans inspect different stores.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revoke the key, then remove the backup
&lt;/h2&gt;

&lt;p&gt;A redaction changes your local history. The provider still accepts a live key until you revoke it.&lt;/p&gt;

&lt;p&gt;AgentSweep includes provider-specific guidance. You can inspect a rule without scanning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agentsweep explain stripe-live
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For common findings, developers can review &lt;a href="https://github.com/settings/tokens" rel="noopener noreferrer"&gt;GitHub token settings&lt;/a&gt;, &lt;a href="https://platform.openai.com/api-keys" rel="noopener noreferrer"&gt;OpenAI API keys&lt;/a&gt;, or &lt;a href="https://console.anthropic.com/settings/keys" rel="noopener noreferrer"&gt;Anthropic API keys&lt;/a&gt;. AWS credentials require the appropriate IAM access-key workflow. AgentSweep prints guidance; it does not invoke those providers' APIs.&lt;/p&gt;

&lt;p&gt;Backups contain the original plaintext. A same-user attacker who can read the transcript can also read its &lt;code&gt;.bak&lt;/code&gt; file. After rotating the exposed credentials and confirming that you no longer need recovery, purge the backups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agentsweep purge &lt;span class="nt"&gt;--source&lt;/span&gt; claude-code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need recovery before that point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agentsweep undo &lt;span class="nt"&gt;--source&lt;/span&gt; claude-code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Undo restores the secret-bearing original. Purge removes the backup files; it does not promise secure erasure from SSD blocks, snapshots, or cloud backup systems.&lt;/p&gt;

&lt;p&gt;My workflow is to avoid pasting secrets, scan existing history, review findings, revoke exposed keys, redact local copies, and remove recovery backups when I no longer need them. The pre-commit hook provides another checkpoint as new history accumulates.&lt;/p&gt;

&lt;p&gt;You can inspect the source, detection rules, and safety code in &lt;a href="https://github.com/Ishannaik/agent-sweep" rel="noopener noreferrer"&gt;Ishannaik/agent-sweep&lt;/a&gt;. If you report a missed credential format, share a synthetic example with the same shape. Do not attach the transcript you are trying to clean.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by &lt;a href="https://github.com/Ishannaik" rel="noopener noreferrer"&gt;Ishan Naik&lt;/a&gt;.&lt;/em&gt; &lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>python</category>
      <category>devtools</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Sun, 11 Jan 2026 03:34:15 +0000</pubDate>
      <link>https://dev.to/ishannaik/-34n2</link>
      <guid>https://dev.to/ishannaik/-34n2</guid>
      <description>&lt;div class="ltag__link"&gt;
  &lt;a href="/ishannaik" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__pic"&gt;
      &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3703707%2Fca7b9a1d-5a10-4c47-9785-6f08e881f539.jpg" alt="ishannaik"&gt;
    &lt;/div&gt;
  &lt;/a&gt;
  &lt;a href="https://dev.to/ishannaik/i-built-a-pastebin-where-even-i-cant-read-your-data-3fj4" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__content"&gt;
      &lt;h2&gt;I Built a Pastebin Where Even I Can't Read Your Data&lt;/h2&gt;
      &lt;h3&gt;Ishan Naik ・ Jan 10&lt;/h3&gt;
      &lt;div class="ltag__link__taglist"&gt;
        &lt;span class="ltag__link__tag"&gt;#security&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#privacy&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#webdev&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#opensource&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/a&gt;
&lt;/div&gt;


</description>
      <category>security</category>
      <category>privacy</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My First saas</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Sat, 10 Jan 2026 15:31:02 +0000</pubDate>
      <link>https://dev.to/ishannaik/my-first-saas-1hgb</link>
      <guid>https://dev.to/ishannaik/my-first-saas-1hgb</guid>
      <description>&lt;div class="ltag__link"&gt;
  &lt;a href="/ishannaik" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__pic"&gt;
      &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3703707%2Fca7b9a1d-5a10-4c47-9785-6f08e881f539.jpg" alt="ishannaik"&gt;
    &lt;/div&gt;
  &lt;/a&gt;
  &lt;a href="https://dev.to/ishannaik/i-built-a-pastebin-where-even-i-cant-read-your-data-3fj4" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__content"&gt;
      &lt;h2&gt;I Built a Pastebin Where Even I Can't Read Your Data&lt;/h2&gt;
      &lt;h3&gt;Ishan Naik ・ Jan 10&lt;/h3&gt;
      &lt;div class="ltag__link__taglist"&gt;
        &lt;span class="ltag__link__tag"&gt;#security&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#privacy&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#webdev&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#opensource&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/a&gt;
&lt;/div&gt;


</description>
      <category>security</category>
      <category>privacy</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Ishan Naik</dc:creator>
      <pubDate>Sat, 10 Jan 2026 10:44:58 +0000</pubDate>
      <link>https://dev.to/ishannaik/-1g9j</link>
      <guid>https://dev.to/ishannaik/-1g9j</guid>
      <description>&lt;div class="ltag__link"&gt;
  &lt;a href="/ishannaik" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__pic"&gt;
      &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3703707%2Fca7b9a1d-5a10-4c47-9785-6f08e881f539.jpg" alt="ishannaik"&gt;
    &lt;/div&gt;
  &lt;/a&gt;
  &lt;a href="https://dev.to/ishannaik/i-built-a-pastebin-where-even-i-cant-read-your-data-3fj4" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__content"&gt;
      &lt;h2&gt;I Built a Pastebin Where Even I Can't Read Your Data&lt;/h2&gt;
      &lt;h3&gt;Ishan Naik ・ Jan 10&lt;/h3&gt;
      &lt;div class="ltag__link__taglist"&gt;
        &lt;span class="ltag__link__tag"&gt;#security&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#privacy&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#webdev&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#opensource&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/a&gt;
&lt;/div&gt;


</description>
      <category>security</category>
      <category>privacy</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
