crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

link-worker.js (22275B)


      1 // Crossmate link worker: a stateless pass-through shortener for CKShare
      2 // links. The app rewrites an iCloud share URL
      3 //
      4 //   https://www.icloud.com/share/<token>#<Title_Slug>
      5 //
      6 // into
      7 //
      8 //   https://<this worker>/s/<token>[/<deco>][/<deco>]
      9 //
     10 // so that the link people actually paste into chat is short, carries no
     11 // legible puzzle title, and serves Crossmate's own Open Graph metadata
     12 // instead of iCloud's. Link-preview crawlers get a 200 HTML page with OG
     13 // tags; everyone else gets a 302 straight to iCloud. The share token is the
     14 // entire state, so nothing is stored and a link can never expire on this
     15 // side.
     16 //
     17 // Up to two optional decoration segments personalise the preview. Each is
     18 // self-typed by its leading character, so order doesn't matter and either
     19 // may be absent: a single 0–6 is a weekday-name title, a `t` prefix is a
     20 // custom base64url title, an `s`/`f` structure is the grid silhouette, and
     21 // anything else is a legacy bare-base64url title (links minted before the
     22 // types existed). Decoration is ignored by the redirect, so a stale or
     23 // mangled segment still lands on the right share.
     24 
     25 // Bundled by the Data module rule in wrangler.link.toml; served at /og.png.
     26 import ogImage from "./og.png";
     27 
     28 const ICLOUD_SHARE_BASE = "https://www.icloud.com/share/";
     29 
     30 // Claims the share-link paths as universal links for the app. `/s/*` is all the
     31 // app needs to intercept; the AASA fetch itself and /og(.png)/og image routes
     32 // are unaffected because they don't match.
     33 const APPLE_APP_SITE_ASSOCIATION = JSON.stringify({
     34   applinks: {
     35     details: [
     36       { appIDs: ["7TD7PZBNXP.net.inqk.crossmate"], components: [{ "/": "/s/*" }] }
     37     ]
     38   }
     39 });
     40 
     41 // iCloud share tokens are short base62-ish strings. Restricting the charset
     42 // to RFC 3986 unreserved characters means the redirect target cannot contain
     43 // a path separator, query, or authority — the worker can only ever redirect
     44 // into https://www.icloud.com/share/, never act as an open redirector.
     45 const TOKEN_PATTERN = /^[A-Za-z0-9._~-]{8,128}$/;
     46 
     47 // The optional title segment is unpadded base64url, encoded by
     48 // ShareLinkShortener in the app. The cap bounds the decode work and the
     49 // rendered tag; anything that fails the pattern or the decode falls back to
     50 // the generic preview rather than erroring.
     51 const TITLE_SEGMENT_PATTERN = /^[A-Za-z0-9_-]{1,256}$/;
     52 const MAX_TITLE_LENGTH = 80;
     53 
     54 // Apple Messages fetches previews with a Facebook/Twitter crawler UA, so the
     55 // explicit names cover iMessage as well. The generic bot/crawler/spider tail
     56 // catches the long tail of preview fetchers; a human misclassified as a bot
     57 // still gets through via the page's meta refresh and visible link.
     58 const CRAWLER_PATTERN = new RegExp(
     59   [
     60     "facebookexternalhit", "facebot", "twitterbot", "slackbot", "discordbot",
     61     "telegrambot", "whatsapp", "linkedinbot", "pinterest", "redditbot",
     62     "applebot", "googlebot", "bingbot", "duckduckbot", "yandex", "mastodon",
     63     "bluesky", "skypeuripreview", "iframely", "embedly", "snapchat", "vkshare",
     64     "bot", "crawler", "spider", "preview"
     65   ].join("|"),
     66   "i"
     67 );
     68 
     69 export default {
     70   async fetch(request) {
     71     if (request.method !== "GET" && request.method !== "HEAD") {
     72       return new Response("Method not allowed", { status: 405 });
     73     }
     74 
     75     const url = new URL(request.url);
     76 
     77     // Apple fetches this to authorise universal links, so a tapped share link
     78     // opens the app directly (which paints a placeholder and accepts the share)
     79     // instead of bouncing through Safari and iCloud. Must be served as JSON
     80     // over https with no redirect.
     81     if (
     82       url.pathname === "/apple-app-site-association" ||
     83       url.pathname === "/.well-known/apple-app-site-association"
     84     ) {
     85       return new Response(APPLE_APP_SITE_ASSOCIATION, {
     86         headers: {
     87           "Content-Type": "application/json",
     88           "Cache-Control": "public, max-age=3600"
     89         }
     90       });
     91     }
     92 
     93     if (url.pathname === "/og.png") {
     94       return new Response(ogImage, {
     95         headers: {
     96           "Content-Type": "image/png",
     97           "Cache-Control": "public, max-age=86400"
     98         }
     99       });
    100     }
    101 
    102     // The per-puzzle preview image. The grid silhouette rides in the path, so
    103     // the image is a pure function of it — deterministic and immutably
    104     // cacheable. A malformed or too-small segment falls back to the generic
    105     // card image rather than erroring, so a link preview never breaks on a bad
    106     // shape.
    107     const imageMatch = url.pathname.match(/^\/g\/([A-Za-z0-9_-]+)\.png$/);
    108     if (imageMatch) {
    109       const grid = decodeSilhouette(imageMatch[1]);
    110       if (!grid || !meetsDynamicMinimum(grid)) {
    111         return new Response(ogImage, {
    112           headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=86400" }
    113         });
    114       }
    115       return new Response(await renderSilhouettePNG(grid.width, grid.height, grid.blocks), {
    116         headers: {
    117           "Content-Type": "image/png",
    118           "Cache-Control": "public, max-age=86400, immutable"
    119         }
    120       });
    121     }
    122 
    123     const match = url.pathname.match(/^\/s\/([^/]+)(?:\/([^/]+))?(?:\/([^/]+))?$/);
    124     if (!match) {
    125       return new Response("Not found", { status: 404 });
    126     }
    127 
    128     const token = match[1];
    129     if (!TOKEN_PATTERN.test(token)) {
    130       return new Response("Not found", { status: 404 });
    131     }
    132     const target = ICLOUD_SHARE_BASE + token;
    133 
    134     const userAgent = request.headers.get("User-Agent") || "";
    135     if (!CRAWLER_PATTERN.test(userAgent)) {
    136       // The token→target mapping is immutable, so the redirect can sit in
    137       // the edge cache indefinitely; an hour keeps revalidation cheap.
    138       return new Response(null, {
    139         status: 302,
    140         headers: {
    141           "Location": target,
    142           "Cache-Control": "public, max-age=3600"
    143         }
    144       });
    145     }
    146 
    147     return new Response(previewPage(url.origin, token, target, [match[2], match[3]]), {
    148       status: 200,
    149       headers: {
    150         "Content-Type": "text/html; charset=utf-8",
    151         "Cache-Control": "public, max-age=3600"
    152       }
    153     });
    154   }
    155 };
    156 
    157 function previewPage(origin, token, target, segments) {
    158   const { title: gameTitle, shape } = classifySegments(segments);
    159   const title = gameTitle
    160     ? `Solve ‘${gameTitle}’ together on Crossmate`
    161     : "Solve a crossword together on Crossmate";
    162   const description =
    163     "You’re invited to a collaborative crossword. " +
    164     "Open the link to join the puzzle in Crossmate.";
    165 
    166   // A valid shape with both sides 10-or-larger gets its own rendered
    167   // silhouette; otherwise the generic card. The dimension hints let clients lay
    168   // the card out before the fetch.
    169   const grid = shape ? decodeSilhouette(shape) : null;
    170   const usesDynamicImage = grid && meetsDynamicMinimum(grid);
    171   const imageURL = usesDynamicImage ? `${origin}/g/${shape}.png` : `${origin}/og.png`;
    172   const imageWidth = usesDynamicImage ? silhouettePixelSize(grid.width) : null;
    173   const imageHeight = usesDynamicImage ? silhouettePixelSize(grid.height) : null;
    174 
    175   // og:url stays the token-only canonical form so scrapers collapse links
    176   // that differ only in their title segment onto the same card. The meta
    177   // refresh and visible link are a fallback for anything the UA sniff
    178   // misclassifies as a crawler: real crawlers ignore the refresh and read
    179   // the OG tags, while a human lands on iCloud after a beat.
    180   return `<!doctype html>
    181 <html lang="en">
    182 <head>
    183 <meta charset="utf-8">
    184 <meta name="viewport" content="width=device-width, initial-scale=1">
    185 <title>${escapeHTML(title)}</title>
    186 <meta property="og:type" content="website">
    187 <meta property="og:site_name" content="Crossmate">
    188 <meta property="og:title" content="${escapeHTML(title)}">
    189 <meta property="og:description" content="${description}">
    190 <meta property="og:image" content="${imageURL}">${imageWidth ? `
    191 <meta property="og:image:width" content="${imageWidth}">
    192 <meta property="og:image:height" content="${imageHeight}">` : ""}
    193 <meta property="og:url" content="${origin}/s/${token}">
    194 <meta name="twitter:card" content="summary">
    195 <meta http-equiv="refresh" content="0; url=${target}">
    196 </head>
    197 <body>
    198 <p><a href="${target}">Open the puzzle in Crossmate</a></p>
    199 </body>
    200 </html>
    201 `;
    202 }
    203 
    204 const WEEKDAYS = [
    205   "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
    206 ];
    207 
    208 // A grid-silhouette segment: a tag, one or two base-36 side digits (2…35), then
    209 // the base64url bit payload. Lowercase `s`/`f` carry a single side digit (square
    210 // grids); uppercase `S`/`F` carry a width digit then a height digit (rectangular
    211 // grids). `s`/`S` are 180°-symmetric (half the cells stored); `f`/`F` are a full
    212 // dump. Returns the parsed geometry, or `null` when the structure or the payload
    213 // byte count doesn't line up — so a legacy base64url title is very unlikely to
    214 // be mistaken for a shape, and an unknown tag falls through to the generic card.
    215 function parseShape(seg) {
    216   if (seg.length < 2) return null;
    217   const tag = seg[0];
    218   let width;
    219   let height;
    220   let payload;
    221   if (tag === "s" || tag === "f") {
    222     width = height = parseInt(seg[1], 36);
    223     payload = seg.slice(2);
    224   } else if (tag === "S" || tag === "F") {
    225     if (seg.length < 3) return null;
    226     width = parseInt(seg[1], 36);
    227     height = parseInt(seg[2], 36);
    228     payload = seg.slice(3);
    229   } else {
    230     return null;
    231   }
    232   if (!(width >= 2 && width <= 35) || !(height >= 2 && height <= 35)) return null;
    233   if (!/^[A-Za-z0-9_-]+$/.test(payload)) return null;
    234 
    235   const symmetric = tag === "s" || tag === "S";
    236   const cells = width * height;
    237   const bits = symmetric ? Math.ceil(cells / 2) : cells;
    238   const payloadBytes = Math.floor((payload.length * 6) / 8);
    239   if (payloadBytes < Math.ceil(bits / 8)) return null;
    240   return { symmetric, width, height, payload };
    241 }
    242 
    243 function isShapeSegment(seg) {
    244   return parseShape(seg) !== null;
    245 }
    246 
    247 // Sorts the up-to-two decoration segments into a display title and a
    248 // (reserved) shape segment, classifying each by its leading character so the
    249 // order the app emits them in doesn't matter.
    250 function classifySegments(segments) {
    251   let title = null;
    252   let shape = null;
    253   for (const seg of segments) {
    254     if (!seg) continue;
    255     if (shape === null && isShapeSegment(seg)) {
    256       shape = seg;
    257       continue;
    258     }
    259     if (title === null) {
    260       title = decodeTitleSegment(seg);
    261     }
    262   }
    263   return { title, shape };
    264 }
    265 
    266 function decodeTitleSegment(seg) {
    267   if (/^[0-6]$/.test(seg)) {
    268     return `${WEEKDAYS[Number(seg)]} Crossword`;
    269   }
    270   // A `t` prefix marks a tagged custom title; an untagged segment is a legacy
    271   // bare-base64url title from before the types existed.
    272   return seg[0] === "t" ? decodeBase64Title(seg.slice(1)) : decodeBase64Title(seg);
    273 }
    274 
    275 // The decoded title lands inside HTML the worker serves, so it is treated as
    276 // hostile input end to end: charset-checked while encoded, decoded strictly
    277 // (an invalid UTF-8 sequence falls back to the generic preview), stripped of
    278 // control characters, capped, and entity-escaped at the interpolation site.
    279 function decodeBase64Title(encoded) {
    280   if (!encoded || !TITLE_SEGMENT_PATTERN.test(encoded)) {
    281     return null;
    282   }
    283   try {
    284     const base64 = encoded.replaceAll("-", "+").replaceAll("_", "/");
    285     const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
    286     const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
    287     const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
    288     const cleaned = text.replace(/[\x00-\x1f\x7f]/g, "").trim();
    289     return cleaned ? cleaned.slice(0, MAX_TITLE_LENGTH) : null;
    290   } catch {
    291     return null;
    292   }
    293 }
    294 
    295 function escapeHTML(text) {
    296   return text
    297     .replaceAll("&", "&amp;")
    298     .replaceAll("<", "&lt;")
    299     .replaceAll(">", "&gt;")
    300     .replaceAll('"', "&quot;");
    301 }
    302 
    303 // MARK: - Grid silhouette
    304 
    305 // The JS counterpart of the app's `GridSilhouette` codec — decode only, since
    306 // the worker never mints links. Mirrors the wire format `<tag><size…><payload>`:
    307 // a symmetric tag stores the first ⌈N/2⌉ cells (the rest mirror by 180°
    308 // rotation), a full tag stores all N; the payload is the cell bits MSB-first as
    309 // base64url. The 180° partner of cell `k` is `n-1-k` for any `w×h` grid.
    310 function decodeSilhouette(seg) {
    311   const parsed = parseShape(seg);
    312   if (!parsed) return null;
    313   const { symmetric, width, height, payload } = parsed;
    314   const n = width * height;
    315   const storedCount = symmetric ? Math.ceil(n / 2) : n;
    316   const bytes = base64urlToBytes(payload);
    317   if (!bytes || bytes.length * 8 < storedCount) return null;
    318 
    319   const stored = new Array(storedCount);
    320   for (let i = 0; i < storedCount; i++) {
    321     stored[i] = (bytes[i >> 3] & (0x80 >> (i & 7))) !== 0;
    322   }
    323   const blocks = new Array(n);
    324   for (let k = 0; k < n; k++) {
    325     blocks[k] = k < storedCount ? stored[k] : stored[n - 1 - k];
    326   }
    327   return { width, height, blocks };
    328 }
    329 
    330 function base64urlToBytes(s) {
    331   try {
    332     const base64 = s.replaceAll("-", "+").replaceAll("_", "/");
    333     const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
    334     return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
    335   } catch {
    336     return null;
    337   }
    338 }
    339 
    340 // Render constants: each cell is CELL px with a MARGIN-px quiet border, so each
    341 // image dimension is a pure function of that grid dimension (35 → 842px is the
    342 // largest).
    343 const CELL = 22;
    344 const MARGIN = 36;
    345 const BG = [255, 255, 255];       // empty square / background
    346 const LINE = [205, 205, 205];     // grid line gray
    347 const BLOCK = [0, 0, 0];          // block square / black logo line
    348 const DYNAMIC_IMAGE_MIN_SIDE = 10;
    349 
    350 const LOGO_SOURCE_SIZE = 512;
    351 const LOGO_MIN_SIZE = 64;
    352 const LOGO_MAX_SIZE = 152;
    353 const LOGO_SCALE = 0.22;
    354 const LOGO_PADDING_SCALE = 0.035;
    355 const LOGO_MIN_PADDING = 10;
    356 const LOGO_DARK = [42, 42, 42];
    357 const LOGO_CORAL = [255, 135, 132];
    358 const LOGO_BLUE = [109, 173, 240];
    359 const LOGO_PALE_BLUE = [207, 226, 246];
    360 const LOGO_WHITE = BG;
    361 const LOGO_BLACK = BLOCK;
    362 const SHADOW_OFFSET_X = 0.035;
    363 const SHADOW_OFFSET_Y = 0.055;
    364 const SHADOW_BLUR = 0.150;
    365 const SHADOW_ALPHA = 0.26;
    366 
    367 function silhouettePixelSize(cells) {
    368   return MARGIN * 2 + cells * CELL;
    369 }
    370 
    371 // A grid earns its own rendered silhouette only when both sides clear the
    372 // minimum, so a thin strip never renders as a rich preview.
    373 function meetsDynamicMinimum(grid) {
    374   return Math.min(grid.width, grid.height) >= DYNAMIC_IMAGE_MIN_SIDE;
    375 }
    376 
    377 // Paints the grid and a scaled Crossmate icon into a truecolor PNG. `cols` and
    378 // `rows` are the grid's width and height; the image may be non-square.
    379 async function renderSilhouettePNG(cols, rows, blocks) {
    380   const dimW = silhouettePixelSize(cols);
    381   const dimH = silhouettePixelSize(rows);
    382   const pixels = new Uint8Array(dimW * dimH * 3).fill(255);
    383   const set = (x, y, color) => setRGB(pixels, dimW, x, y, color);
    384 
    385   // Grid lines bounding every cell: cols+1 verticals over the full height, and
    386   // rows+1 horizontals over the full width.
    387   const extentX = cols * CELL;
    388   const extentY = rows * CELL;
    389   for (let i = 0; i <= cols; i++) {
    390     const at = MARGIN + i * CELL;
    391     for (let t = 0; t <= extentY; t++) set(at, MARGIN + t, LINE);
    392   }
    393   for (let i = 0; i <= rows; i++) {
    394     const at = MARGIN + i * CELL;
    395     for (let t = 0; t <= extentX; t++) set(MARGIN + t, at, LINE);
    396   }
    397   // Block squares fill their interior solid, leaving the gray lines between.
    398   for (let r = 0; r < rows; r++) {
    399     for (let c = 0; c < cols; c++) {
    400       if (!blocks[r * cols + c]) continue;
    401       const x0 = MARGIN + c * CELL;
    402       const y0 = MARGIN + r * CELL;
    403       for (let y = 1; y < CELL; y++) {
    404         for (let x = 1; x < CELL; x++) set(x0 + x, y0 + y, BLOCK);
    405       }
    406     }
    407   }
    408 
    409   drawCrossmateIcon(pixels, dimW, dimH);
    410   return await encodeTruecolorPNG(dimW, dimH, pixels);
    411 }
    412 
    413 function setRGB(pixels, width, x, y, color) {
    414   const offset = (y * width + x) * 3;
    415   pixels[offset] = color[0];
    416   pixels[offset + 1] = color[1];
    417   pixels[offset + 2] = color[2];
    418 }
    419 
    420 function drawCrossmateIcon(pixels, dimW, dimH) {
    421   // Size off the smaller image dimension so a tall or wide grid doesn't get an
    422   // oversized badge; anchor it in the bottom-right corner of the canvas.
    423   const basis = Math.min(dimW, dimH);
    424   const size = clamp(Math.round(basis * LOGO_SCALE), LOGO_MIN_SIZE, LOGO_MAX_SIZE);
    425   const padding = Math.max(LOGO_MIN_PADDING, Math.round(basis * LOGO_PADDING_SCALE));
    426   const originX = dimW - padding - size;
    427   const originY = dimH - padding - size;
    428   const radius = Math.round(size * 0.135);
    429   drawRoundedShadow(pixels, dimW, dimH, originX, originY, size, radius);
    430 
    431   for (let y = 0; y < size; y++) {
    432     const sourceY = Math.floor((y * LOGO_SOURCE_SIZE) / size);
    433     for (let x = 0; x < size; x++) {
    434       const coverage = roundedRectCoverage(x, y, size, radius);
    435       if (coverage <= 0) continue;
    436       const sourceX = Math.floor((x * LOGO_SOURCE_SIZE) / size);
    437       blendPixel(pixels, dimW, originX + x, originY + y, crossmateIconColor(sourceX, sourceY), coverage);
    438     }
    439   }
    440 }
    441 
    442 function drawRoundedShadow(pixels, dimW, dimH, iconOriginX, iconOriginY, size, radius) {
    443   const blur = Math.max(10, Math.round(size * SHADOW_BLUR));
    444   const shadowX = iconOriginX + Math.round(size * SHADOW_OFFSET_X);
    445   const shadowY = iconOriginY + Math.round(size * SHADOW_OFFSET_Y);
    446   const minX = shadowX - blur;
    447   const minY = shadowY - blur;
    448   const maxX = shadowX + size + blur;
    449   const maxY = shadowY + size + blur;
    450 
    451   for (let py = minY; py < maxY; py++) {
    452     if (py < 0 || py >= dimH) continue;
    453     for (let px = minX; px < maxX; px++) {
    454       if (px < 0 || px >= dimW) continue;
    455 
    456       const distance = distanceToRoundedRect(px - shadowX, py - shadowY, size, radius);
    457       if (distance > blur) continue;
    458 
    459       const strength = Math.pow(1 - Math.max(distance, 0) / blur, 1.85);
    460       blendPixel(pixels, dimW, px, py, BLOCK, strength * SHADOW_ALPHA);
    461     }
    462   }
    463 }
    464 
    465 function blendPixel(pixels, width, x, y, color, alpha) {
    466   const offset = (y * width + x) * 3;
    467   const keep = 1 - alpha;
    468   pixels[offset] = Math.round(pixels[offset] * keep + color[0] * alpha);
    469   pixels[offset + 1] = Math.round(pixels[offset + 1] * keep + color[1] * alpha);
    470   pixels[offset + 2] = Math.round(pixels[offset + 2] * keep + color[2] * alpha);
    471 }
    472 
    473 function distanceToRoundedRect(x, y, size, radius) {
    474   const half = size / 2;
    475   const qx = Math.abs(x - half + 0.5) - (half - radius);
    476   const qy = Math.abs(y - half + 0.5) - (half - radius);
    477   const outsideX = Math.max(qx, 0);
    478   const outsideY = Math.max(qy, 0);
    479   return Math.hypot(outsideX, outsideY) + Math.min(Math.max(qx, qy), 0) - radius;
    480 }
    481 
    482 function roundedRectCoverage(x, y, size, radius) {
    483   return clamp(0.5 - distanceToRoundedRect(x, y, size, radius), 0, 1);
    484 }
    485 
    486 function crossmateIconColor(x, y) {
    487   if (x >= 252 && x <= 263) return LOGO_BLACK;
    488   if (y >= 124 && y <= 134) return LOGO_BLACK;
    489   if (y >= 390 && y <= 400) return LOGO_BLACK;
    490 
    491   const right = x >= 264;
    492   if (y < 124) return right ? LOGO_CORAL : LOGO_DARK;
    493   if (y < 390) return right ? LOGO_WHITE : LOGO_BLUE;
    494   return right ? LOGO_DARK : LOGO_PALE_BLUE;
    495 }
    496 
    497 function clamp(value, min, max) {
    498   return Math.min(Math.max(value, min), max);
    499 }
    500 
    501 // MARK: - Minimal PNG encoder (8-bit truecolor, stored DEFLATE)
    502 
    503 async function encodeTruecolorPNG(width, height, pixels) {
    504   // Each scanline is prefixed with a filter-type byte (0 = None).
    505   const stride = width * 3;
    506   const raw = new Uint8Array(height * (stride + 1));
    507   for (let y = 0; y < height; y++) {
    508     raw[y * (stride + 1)] = 0;
    509     raw.set(pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
    510   }
    511 
    512   const ihdr = new Uint8Array(13);
    513   const dv = new DataView(ihdr.buffer);
    514   dv.setUint32(0, width);
    515   dv.setUint32(4, height);
    516   ihdr[8] = 8;   // bit depth
    517   ihdr[9] = 2;   // color type: truecolor
    518   // [10..12] compression/filter/interlace = 0
    519 
    520   const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
    521   return concatBytes([
    522     signature,
    523     pngChunk("IHDR", ihdr),
    524     pngChunk("IDAT", await zlibDeflate(raw)),
    525     pngChunk("IEND", new Uint8Array(0))
    526   ]);
    527 }
    528 
    529 async function zlibDeflate(raw) {
    530   if (typeof CompressionStream === "undefined") {
    531     return zlibStore(raw);
    532   }
    533   try {
    534     const stream = new Blob([raw]).stream().pipeThrough(new CompressionStream("deflate"));
    535     return new Uint8Array(await new Response(stream).arrayBuffer());
    536   } catch {
    537     return zlibStore(raw);
    538   }
    539 }
    540 
    541 function pngChunk(type, data) {
    542   const out = new Uint8Array(12 + data.length);
    543   const dv = new DataView(out.buffer);
    544   dv.setUint32(0, data.length);
    545   for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
    546   out.set(data, 8);
    547   dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
    548   return out;
    549 }
    550 
    551 // A zlib stream whose payload is one or more uncompressed (stored) DEFLATE
    552 // blocks — no compression code, just framing — terminated by the Adler-32 of
    553 // the raw data. Fine here: the response is small and edge-cached.
    554 function zlibStore(raw) {
    555   const parts = [new Uint8Array([0x78, 0x01])]; // zlib header
    556   for (let off = 0; off < raw.length; off += 65535) {
    557     const len = Math.min(65535, raw.length - off);
    558     const final = off + len >= raw.length ? 1 : 0;
    559     const header = new Uint8Array(5);
    560     header[0] = final;                 // BFINAL bit; BTYPE 00 = stored
    561     header[1] = len & 0xff;
    562     header[2] = (len >> 8) & 0xff;
    563     const nlen = ~len & 0xffff;
    564     header[3] = nlen & 0xff;
    565     header[4] = (nlen >> 8) & 0xff;
    566     parts.push(header, raw.subarray(off, off + len));
    567   }
    568   const adler = new Uint8Array(4);
    569   new DataView(adler.buffer).setUint32(0, adler32(raw));
    570   parts.push(adler);
    571   return concatBytes(parts);
    572 }
    573 
    574 function crc32(bytes) {
    575   let crc = 0xffffffff;
    576   for (let i = 0; i < bytes.length; i++) {
    577     crc ^= bytes[i];
    578     for (let j = 0; j < 8; j++) {
    579       crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
    580     }
    581   }
    582   return (crc ^ 0xffffffff) >>> 0;
    583 }
    584 
    585 function adler32(bytes) {
    586   let a = 1;
    587   let b = 0;
    588   for (let i = 0; i < bytes.length; i++) {
    589     a = (a + bytes[i]) % 65521;
    590     b = (b + a) % 65521;
    591   }
    592   return ((b << 16) | a) >>> 0;
    593 }
    594 
    595 function concatBytes(chunks) {
    596   const total = chunks.reduce((sum, c) => sum + c.length, 0);
    597   const out = new Uint8Array(total);
    598   let off = 0;
    599   for (const c of chunks) {
    600     out.set(c, off);
    601     off += c.length;
    602   }
    603   return out;
    604 }