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("&", "&") 298 .replaceAll("<", "<") 299 .replaceAll(">", ">") 300 .replaceAll('"', """); 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 }