@sz.ws/encke
v0.2.1
Published
Apple App Clip Code generator in pure TypeScript. Renders the scannable ring code to SVG, bit-for-bit identical to Apple's AppClipCodeGenerator, with no macOS and no native binary. Works in Node, Next.js, React, browsers and Cloudflare Workers.
Maintainers
Readme
encke
Apple App Clip Codes in pure TypeScript. No macOS. No native binaries. Bit-for-bit identical to Apple's own generator.
Named after the comet with the shortest known orbit — it comes back every 3.3 years.
npm install @sz.ws/enckeApple gives you AppClipCodeGenerator — a macOS-only CLI you have to shell out to. Fine for a one-off poster, useless if you want to generate codes on a server, per request, inside CI, or on any machine that isn't a Mac.
This package reimplements the whole format from scratch: URL compression, Reed-Solomon error correction, and the ring renderer. Same input, same bytes out. Diff one against Apple's tool if you like.
Quick start
import { generateAppClipCode } from "@sz.ws/encke";
const { svg } = generateAppClipCode({ url: "https://oru.okuso.uk/su" });svg is a self-contained SVG string. There's also generateDataURL() for a ready-to-embed data:image/svg+xml URI.
React / Next.js
import { AppClipCode, AppClipCodeImg } from "@sz.ws/encke/react";
<AppClipCode url="https://oru.okuso.uk/su" width={220} />
<AppClipCodeImg url="https://oru.okuso.uk/su" width={220} alt="Scan me" />Where this runs decides what it costs. The encoder needs a 1.7 MB Huffman model. Your users only pay for it if their browser is the thing doing the encoding:
| Where you render | What the browser downloads |
|---|---|
| Server Component, route handler, generateStaticParams — the App Router default | Nothing. The tables are read from disk in Node; the client receives finished SVG markup. |
| Client Component, URL known at build time | Nothing, if you generate on the server and pass the SVG down. |
| Client Component, URL typed by the user | The tables — but as a lazily-fetched chunk, never in your main bundle. |
@sz.ws/encke/react itself is 31 KB. Nothing statically imports the tables.
For that last row, the component loads them on its own and re-renders — no useEffect on your side:
"use client";
<AppClipCode
url={url}
fallback={<Skeleton />}
tables={{ baseUrl: "/appclip-tables" }} // serve data/*.data yourself: 0 KB of JS
onError={e => setMessage(e.message)}
/>Drop tables and it fetches the embedded copy instead (~1.4 MB, still its own chunk). Pass tables={false} if you would rather await loadTables() once yourself at app start.
fallback covers everything that is not a finished code — tables still loading, a URL that overflows 128 bits, a color pair that will not scan — and onError tells you which.
Options
generateAppClipCode({
url: "https://oru.okuso.uk/menu",
templateIndex: 13,
center: '<circle cx="0" cy="0" r="60" fill="#007AFF"/>',
lockupSvg: '<text x="400" y="985" text-anchor="middle" font-size="64">YOUR BRAND</text>',
});Every call returns { svg, rawBits, payloadHex, arcCount, colors } — the debug fields are useful when a URL doesn't fit and you want to know why.
Will this URL fit?
The 128-bit budget is the one real constraint. Ask before you generate:
import { estimatePayloadBits } from "@sz.ws/encke";
estimatePayloadBits("https://oru.okuso.uk/su");
// { bits: 65, limit: 128, headroom: 63, willFit: true }
estimatePayloadBits("https://very-long-subdomain.example.com/a/deep/path?with=query");
// { bits: 173, headroom: -45, willFit: false, reason: "173 bits, 45 over the limit" }It never throws — an unencodable URL comes back as { bits: null, reason }.
Will these colors scan?
import { checkColors, suggestColors } from "@sz.ws/encke";
checkColors("777777", "888888");
// { ok: false, contrast: 1.15, lumaDelta: 17, reason: "brightness difference 17 (needs ≥ 100), contrast 1.15:1 (needs ≥ 2.8:1)" }
suggestColors("777777", "888888"); // three built-in pairs, closest firstgenerateAppClipCode runs this check for you and refuses bad pairs unless you pass allowUnscannableColors.
Browsers and Workers
Node — including Next.js server components and route handlers — needs no setup: the Huffman tables are read from the package's data/ directory on first use, and every API stays synchronous.
Anywhere without a filesystem, load them once:
import { loadTables, generateAppClipCode } from "@sz.ws/encke";
await loadTables(); // ~1.5 MB, a separate lazy chunk
await loadTables({ baseUrl: "/appclip-tables" }); // or serve data/*.data yourself — zero bundle cost
generateAppClipCode({ url }); // synchronous from here ongenerateAppClipCodeAsync() does both in one call. The tables are never part of your main bundle: importing @sz.ws/encke costs about 31 KB.
Workers resolve the workerd / worker / edge-light export conditions, so the Node build and its
node:fs import never enter the bundle. If your bundler picks the Node build anyway, nothing breaks:
the disk loader only registers itself where there is a real filesystem, and loadTables() always
honours the source you name.
HTTP endpoint
@sz.ws/encke/handler turns a query string into an SVG. It takes a standard Request and returns a
standard Response, so the same function deploys to Cloudflare Workers, a Next.js route handler,
Deno, or Bun:
import { createHandler } from "@sz.ws/encke/handler";
export default { fetch: createHandler() }; // Cloudflare Workers
export const GET = createHandler({ allowedHosts: ["example.com"] }); // Next.js app/code/route.tsTo try it locally:
npx @sz.ws/encke serve --port 8787GET /?url=https://example.com/a&foreground=0071e3&background=FFFFFF&size=512| Parameter | Aliases | Meaning |
|---|---|---|
| url | u | The https URL to encode. Required. |
| foreground | fg, f | Ring color, hex. |
| background | bg, b | Background color, hex. |
| tint | | Secondary arc color. Derived from the pair if omitted. |
| index | template, i | Built-in color template, 0–17. Overrides the three above. |
| center | | disc (default) or none. |
| layout | | auto, code, or lockup. |
| size | | Adds width/height in px, 16–4096. viewBox only if omitted. |
| force | | Generate even if the colors will not scan. |
| download | | Send Content-Disposition: attachment. |
Responses are image/svg+xml with an ETag and a one-year immutable Cache-Control — the same
query always produces the same bytes, so it sits behind a CDN with no invalidation story. 400
means a bad parameter; 422 means the parameters were valid but this combination cannot produce a
code (URL too long, colors too close). Both return {"error": "..."}.
Two things worth knowing before you put one on the public internet:
- Set
allowedHosts. Without it your endpoint will happily mint codes pointing at anyone's site. It matches the host and its subdomains. - Custom center and lockup artwork are not exposed over HTTP, by design. Accepting SVG markup
in a query parameter would let a caller put arbitrary markup inside a document served from your
origin, and SVG is scriptable when opened directly. Call
generateAppClipCode()from your own code for that.
Without a server
Generation is pure client-side work, so a static page can take the same query parameters and draw
the code in the browser — no backend, deployable to GitHub Pages or any CDN. The playground in this
repo does exactly that at /render/?url=….
The tradeoff is real and worth stating plainly: a static page is reachable by a browser, not by a
fetcher. <img src>, curl, wget, and social-card crawlers do not run JavaScript, so they
receive the empty HTML shell rather than an image. Use <iframe> to embed one, and reach for
createHandler() above only when something other than a browser has to pull the bytes.
CLI
npx @sz.ws/encke --url https://oru.okuso.uk/su --index 11 --output code.svg
npx @sz.ws/encke serve --port 8787 --hosts example.com
npx @sz.ws/encke estimate --url https://oru.okuso.uk/menu
npx @sz.ws/encke check --foreground 777777 --background 888888
npx @sz.ws/encke templatesFlags mirror Apple's own tool (-u -o -i -f -b), so existing scripts port over. Without --output the SVG goes to stdout.
What's actually in one of these codes
URL ──▶ huffman ──▶ ≤128 bits ──▶ RS + scramble ──▶ 208 bits ──▶ 128 slots + color streamCompression. Apple's encoder throws everything at the 128-bit budget: context-aware Huffman coding (three tries, two-symbol lookback), a 156-word dictionary for path segments, LEB128 for integers, a 6-bit custom alphabet, special codes for ~20 common TLDs, and a "template" mode where a known word like /menu costs 8 bits flat. The encoder tries several strategies per URL and keeps the shortest.
Error correction. The payload is scrambled (reverse + XOR 0xA5), then wrapped in two Reed-Solomon codes — GF(256) over the structure bits, GF(16) over the metadata — so a scuffed, glare-covered print still scans.
Rendering. The 128 bits decide which slots in the five concentric rings are visible (bit 0 = drawn). The bits after that are a color stream painting each visible arc: foreground, or a pastel tint of it. Visible slots swallow adjacent hidden slots, which is why every URL gets its own pattern of arc lengths.
Each Apple template is secretly a triple: foreground, background, and a pastel secondary. Teal 00A6A1 pairs with 88DDCC — not gray. Using a flat gray as the second color breaks the scanner's color clustering and codes quietly stop scanning. We extracted all 18 triples from Apple's own output, so templateIndex just works; for custom colors the package computes a matching tint.
Scannability
- Chunky center wins. Thin outlines and detailed logos fail. ~210 units of solid, high-contrast shape is the sweet spot.
- Contrast beats aesthetics if you roll your own colors.
- Test on a real iPhone before printing. The Camera app opens the App Clip or it doesn't — there's no partial credit.
Verification
Every claim here is checked against Apple's own AppClipCodeGenerator on macOS, not against itself.
- 790 URLs, differentially tested: our compressed bitstream is identical to the native encoder's for every one. The corpus spans all three host formats, LEB128 boundaries, subdomains, fragments, trailing slashes, and path+query combinations.
- 156 dictionary words and 113 fixed TLDs, each index read back individually from native output rather than guessed from the binary's string table.
- 52 of those URLs are committed as fixtures with their full arc geometry, so
npm testreproduces the check on any machine without the native tool.npm run test:oracleregenerates them. - All 18 color templates match the native output exactly, foreground, background and tint.
- 624 color pairs sampled from the native validator back the scannability check; 85 pairs back the custom-color tint.
That comparison is what makes the claim worth anything — it caught a stack of defects that produced perfectly plausible-looking codes:
| Defect | Effect |
|---|---|
| The template-type flag was computed but never written | Decoders read the payload in the wrong mode — the code scanned to a different URL |
| The URL parser only looked for a query when there was no path | …/item?p=7 silently encoded as …/item |
| Two bogus dictionary entries, two real ones missing | 118 of the 156 word indices were off by one — again, wrong URL |
| Host encoding format 1 was never implemented | Codes for 113 TLDs were larger than necessary |
| Tie-breaks preferred the first candidate; Apple prefers a fixed order | Divergent codes whenever two encodings came out the same length |
| The Huffman tie-break compared the alphabetically smallest symbol in a subtree instead of its leftmost leaf | One wrong bit, rarely, which is enough |
| The last arc in a ring did not wrap past 0° | Visibly wrong geometry on most codes |
| compressURL() left-aligned the payload the codec reads right-aligned | Its output did not match what the generator actually encodes |
Full notes in RE_NOTES.md.
Two things remain approximations, and both are measured rather than assumed: the secondary color for custom palettes (exact on 82% of sampled pairs, always within one 4-bit step, hue always preserved), and the scannability threshold (97% agreement with the native validator, with all 18 built-ins passing). Built-in templates use Apple's exact values.
Licensing, and what came from Apple
Not affiliated with Apple. "Apple", "App Clip" and "App Clip Code" are trademarks of Apple Inc.
This package ships zero Apple trademarks or artwork: no "App Clip" badge, no Apple logo, no camera glyph. The ring is the deterministic output of an algorithm applied to your URL — a format, not a logo. Anything decorative in your SVG comes from you. If you want the official badge on marketing material, generate it with Apple's own tools and place it next to the code.
The three files in data/, however, are a different matter. They are byte-for-byte extracts of the Huffman frequency models in Apple's URLCompression.framework, and they are here because a phone decompresses the URL using exactly those models — a statistically similar table built from scratch decodes to the wrong address, or to nothing. The MIT license covers this repository's source code; it does not cover those three files, and no license to them is granted here.
That is a real and unsettled legal question rather than a solved one. NOTICE.md sets out what the files are, the interoperability authorities this project relies on, where those authorities are weaker, and how to avoid the question entirely by supplying tables extracted from your own licensed copy of Apple's tool.
Development
npm run build # tsup → dist/ (ESM + CJS + types)
npm run playground # live preview at localhost:5199
npm test # 173 tests, including the oracle fixtures
npm run test:oracle # regenerate the fixtures from the native tool, then test (macOS only)
npm run test:pack # pack, install into a clean project, exercise every entry pointLicense
MIT. Huffman coding and Reed-Solomon are public-domain mathematics. Color tables and tries were extracted from Apple's distributed binaries as interoperability facts.
