<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[SnappyKit Engineering Blog]]></title><description><![CDATA[SnappyKit Engineering Blog]]></description><link>https://snappykit.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 10:09:08 GMT</lastBuildDate><atom:link href="https://snappykit.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Decoding iPhone HEIC images in the browser with WebAssembly (no server, no uploads)]]></title><description><![CDATA[How a libheif WASM build, a per-route CSP relaxation, and one webpack rule gave SnappyKit a fully client-side HEIC converter]]></description><link>https://snappykit.hashnode.dev/decoding-iphone-heic-images-in-the-browser-with-webassembly-no-server-no-uploads</link><guid isPermaLink="true">https://snappykit.hashnode.dev/decoding-iphone-heic-images-in-the-browser-with-webassembly-no-server-no-uploads</guid><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[WebAssembly]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[jameszhang]]></dc:creator><pubDate>Thu, 29 Oct 2026 00:47:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae29355353f027fa3810ab7/d5294c01-7cfa-49e4-ba7c-b3e00753a869.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every "free HEIC converter" website I found had the same privacy model: upload your photos to my server, wait, download the result. Family photos. Screenshots. Whatever was in your camera roll. I didn't want my files on someone else's disk, and I doubted other people wanted theirs on mine either.</p>
<p>So I spent a while teaching the browser to do it locally instead. This post is about how the decoding actually works — the WASM integration, the Content Security Policy dance, and the bundler quirks nobody warns you about.</p>
<p>The result is a batch HEIC converter that runs entirely client-side: <a href="https://snappykit.site/heic-converter">snappykit.site/heic-converter</a>. Up to 20 images at once, converts to JPG/PNG/WebP/AVIF, downloads as a ZIP. No account, no upload.</p>
<h2>The decoder</h2>
<p>The heavy lifting comes from libheif compiled to WebAssembly (<code>libheif-js/wasm-bundle</code>, which wraps @seven332/libheif-wasm). The API is refreshingly small:</p>
<pre><code class="language-ts">interface HeifImage {
  get_width(): number;
  get_height(): number;
  display(data: DisplayData, callback: (d: DisplayData | null) =&gt; void): void;
  free(): void;
}

interface HeifLibrary {
  HeifDecoder: new () =&gt; { decode(buffer: ArrayBuffer): HeifImage[] };
}
</code></pre>
<p>The pipeline for one file: read the <code>File</code> into an <code>ArrayBuffer</code>, hand it to <code>HeifDecoder.decode()</code>, call <code>display()</code> to get RGBA pixels, paint them onto a canvas, then <code>canvas.toBlob()</code> for the output format. Standard Canvas work — libheif only does the decode step.</p>
<p>Two details matter:</p>
<p><strong>1. Lazy-load the WASM module and cache the promise.</strong> The bundle is large, and 95% of the site doesn't need it:</p>
<pre><code class="language-ts">let heifLoaderPromise: Promise&lt;HeifLibrary&gt; | undefined;

function loadHeifWasm() {
  heifLoaderPromise ??= import("libheif-js/wasm-bundle").then(
    (m: HeifModule) =&gt; m.default ?? m
  );
  return heifLoaderPromise;
}
</code></pre>
<p>The <code>??=</code> cache means concurrent conversions await the same initialization instead of racing to compile the WASM twice.</p>
<p><strong>2. Always call</strong> <code>free()</code><strong>.</strong> The HeifImage holds WASM memory. Skipping the free call leaks until the tab dies — noticeable when someone drops 20 iPhone photos at once.</p>
<h2>The CSP dance</h2>
<p>My site runs a strict Content Security Policy. WASM compilation needs <code>wasm-unsafe-eval</code> — fine, one directive. But libheif's embind bindings generate function stubs with <code>eval()</code>, which needs full <code>unsafe-eval</code>. I didn't want to open that door site-wide for one tool.</p>
<p>The fix was per-route CSP in Next.js config:</p>
<pre><code class="language-js">// next.config.mjs
const baseCsp = "script-src 'self' 'wasm-unsafe-eval' ...";
const heicCsp = baseCsp.replace(
  "script-src 'self' 'wasm-unsafe-eval'",
  "script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'"
);

// headers() returns baseCsp for /* and heicCsp for /heic-converter
</code></pre>
<p>One route gets the relaxation; the other 60 pages keep the strict policy.</p>
<h2>The bundler quirk</h2>
<p>The WASM package declares ESM but ships CommonJS files, so webpack's static analysis chokes on <code>require()</code>. The fix is telling webpack to treat those files as <code>javascript/auto</code>:</p>
<pre><code class="language-js">config.module.rules.push({
  test: /[\\/]node_modules[\\/]@seven332[\\/]libheif-wasm[\\/].*\.js$/,
  type: "javascript/auto"
});
</code></pre>
<p>If your build fails with "Critical dependency: require function is used in a way in which dependencies cannot be statically extracted" — this is that.</p>
<h2>Guardrails</h2>
<p>Decoding untrusted files means limits: anything above 100 megapixels or 16,000 pixels per side gets rejected before the pixel work starts, and decode failures map to a friendly "this file may be corrupted or use an unsupported codec" instead of a raw embind stack trace.</p>
<p>The part I'm proudest of: the smoke suite literally asserts that no image-upload requests and no external-origin requests are ever made. "Your photos never leave your device" isn't a marketing claim here — it's a failing test if it breaks.</p>
<h2>Try it</h2>
<p>The converter is free, no account: <a href="https://snappykit.site/heic-converter">snappykit.site/heic-converter</a>. It's one of 40+ image tools (compression, conversion, resize, filters, EXIF removal) that all run locally.</p>
<p>Happy to answer questions about the embind/CSP setup, the batch pipeline, or the smoke-test architecture in the comments.</p>
]]></content:encoded></item></channel></rss>