this repo has no description
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(og-banner): cache pre-generated JPEG in DB for instant og:image serving

The root cause of the Bluesky link card image not appearing was response
latency. The og-banner route was fetching the full-resolution blob from the
PDS and running ImageScript decode+resize+encode on every request. The first
(uncached) hit could take 3–5 s; Cardyb's timeout is shorter than that, so it
returned "Unable to serve image" and cached the failure.

Fix: generate and store the 1200×630 JPEG in a new `og_jpeg` BLOB column at
profile-save time. The og-banner route now returns the cached bytes in < 10 ms
(a single DB SELECT), identical in latency profile to serving a static file.

- lib/db.ts: add `og_jpeg BLOB` column + additive migration
- lib/registry.ts: add `ogJpeg` to UpsertProfileInput; new storeOgJpeg /
getOgJpeg helpers; ON CONFLICT preserves existing og_jpeg when not replaced
- routes/api/registry/profile.ts: run generateOgJpeg() on banner upload bytes
and pass result to upsertProfile
- routes/api/registry/og-banner/[did].ts: fast path returns DB-cached JPEG;
slow path (pre-feature profiles) fetches PDS blob, resizes, stores to DB,
returns result so next request is fast
- routes/api/admin/backfill-og-jpegs.ts: POST endpoint to backfill og_jpeg
for existing profiles that pre-date this feature

Made-with: Cursor

+247 -22
+30
deno.lock
··· 1611 1611 "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" 1612 1612 } 1613 1613 }, 1614 + "remote": { 1615 + "https://deno.land/x/imagescript@1.3.0/ImageScript.js": "cf90773c966031edd781ed176c598f7ed495e7694cd9b86c986d2d97f783cca0", 1616 + "https://deno.land/x/imagescript@1.3.0/mod.ts": "18a6cb83c55e690c873505f6fe867364c678afb64934fe7aef593a6b92f79995", 1617 + "https://deno.land/x/imagescript@1.3.0/png/src/crc.mjs": "5cf50de181d61dd00e66a240d811018ba5070afa8bba302f393604404604de84", 1618 + "https://deno.land/x/imagescript@1.3.0/png/src/mem.mjs": "4968d400dae069b4bf0ef4767c1802fd2cc7d15d90eda4cfadf5b4cd19b96c6d", 1619 + "https://deno.land/x/imagescript@1.3.0/png/src/png.mjs": "96ef0ceff1b5a6cd9304749e5f187b4ab238509fb5f9a8be8ee934240271ed8d", 1620 + "https://deno.land/x/imagescript@1.3.0/png/src/zlib.mjs": "9867dc3fab1d31b664f9344b0d7e977f493d9c912a76c760d012ed2b89f7061c", 1621 + "https://deno.land/x/imagescript@1.3.0/utils/buffer.js": "952cb1beb8827e50a493a5d1f29a4845e8c648789406d389dd51f51205ba02d8", 1622 + "https://deno.land/x/imagescript@1.3.0/utils/crc32.js": "573d6222b3605890714ebc374e687ec2aa3e9a949223ea199483e47ca4864f7d", 1623 + "https://deno.land/x/imagescript@1.3.0/utils/png.js": "fbed9117e0a70602645d70df9c103ff6e79c03e987bd5c1685dcb4200729b6de", 1624 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/font.js": "9e75d842608c057045698d6a7cdf5ffd27241b5cdea0391c89a1917b31294524", 1625 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/gif.js": "8b86f7b96486bb8ff50fbc7c7487f86cb5cef85e6acd71e1def78a1aa2f12e4f", 1626 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/jpeg.js": "75295e2fcf96b4f7bb894b3844fdaa8140d63169d28b466b5d5be89d59a7b6e6", 1627 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/png.js": "0659536a8dd8f892c8346e268b2754b4414fad0ec1e9794dfcde1ba1c804ee02", 1628 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/svg.js": "f5c8a9d1977b51a7c07549ceb6bbbaca9497321a193f28b3dc229a42d91bcf14", 1629 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/tiff.js": "c2d7bdaef094df25aae1752e75167f485e89275d76a1379e39d8949580b7af4f", 1630 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/zlib.js": "749875f83abffe24d3b977475a0cbd5f9b52bee1fbdbef61ec183cbfc17805f6", 1631 + "https://deno.land/x/imagescript@1.3.0/v2/framebuffer.mjs": "add44ff184636659714b3c6d4b896f628545451abffbc30b5bcc2e8d9a73d012", 1632 + "https://deno.land/x/imagescript@1.3.0/v2/ops/blur.mjs": "80716f1ffab8a2aeb54a036f583bf51a2b9dd37e005adc000add803df8e8a12f", 1633 + "https://deno.land/x/imagescript@1.3.0/v2/ops/color.mjs": "5e72cdcbf97dc939a2795223f01e3cb0544c0c56b03ea2aa026050df58348814", 1634 + "https://deno.land/x/imagescript@1.3.0/v2/ops/crop.mjs": "69431fa6f687fd9f0c31eff0ec27d7ac925275005e53a37f0c3fab4cc4d9a9ea", 1635 + "https://deno.land/x/imagescript@1.3.0/v2/ops/fill.mjs": "cf1b9488314753fbc9ebf03410ac74c2a34ea5a69fb6892cd6e8366cd1930d93", 1636 + "https://deno.land/x/imagescript@1.3.0/v2/ops/flip.mjs": "825a34a66567dcf15e76a719f1bf2f66fb106503cd69942292b1b0ae05c5718e", 1637 + "https://deno.land/x/imagescript@1.3.0/v2/ops/index.mjs": "423ba687119be2bba8cec72890577d3afa3621b6b8108912242fe937a183f2aa", 1638 + "https://deno.land/x/imagescript@1.3.0/v2/ops/iterator.mjs": "c2adf3d90ce00719a02c48c97634574176a3501ff026676259bd71aa8f5d69b9", 1639 + "https://deno.land/x/imagescript@1.3.0/v2/ops/overlay.mjs": "7e6e2c2ffd25006d52597ab8babc5f8f503d388a3fdf2fbc0eaea02799a020c9", 1640 + "https://deno.land/x/imagescript@1.3.0/v2/ops/resize.mjs": "814e78ebce8eaf8f1f918688db7b52a141405e06a36ed4b25d04413d69e7d17b", 1641 + "https://deno.land/x/imagescript@1.3.0/v2/ops/rotate.mjs": "a1b65616717bd2eed8db406affea3263b4674dada46b56441ef38167a187455d", 1642 + "https://deno.land/x/imagescript@1.3.0/v2/util/mem.mjs": "4968d400dae069b4bf0ef4767c1802fd2cc7d15d90eda4cfadf5b4cd19b96c6d" 1643 + }, 1614 1644 "workspace": { 1615 1645 "dependencies": [ 1616 1646 "jsr:@fresh/core@^2.2.2",
+6
lib/db.ts
··· 104 104 avatar_mime TEXT, 105 105 banner_cid TEXT, 106 106 banner_mime TEXT, 107 + og_jpeg BLOB, 107 108 icon_cid TEXT, 108 109 icon_mime TEXT, 109 110 icon_status TEXT, ··· 389 390 table: "profile", 390 391 column: "banner_mime", 391 392 ddl: "ALTER TABLE profile ADD COLUMN banner_mime TEXT", 393 + }, 394 + { 395 + table: "profile", 396 + column: "og_jpeg", 397 + ddl: "ALTER TABLE profile ADD COLUMN og_jpeg BLOB", 392 398 }, 393 399 { 394 400 table: "profile",
+52 -2
lib/registry.ts
··· 332 332 avatarMime?: string | null; 333 333 bannerCid?: string | null; 334 334 bannerMime?: string | null; 335 + /** Pre-generated 1200×630 JPEG for og:image, stored as raw bytes. 336 + * Generated at write time so the og-banner route can return it instantly 337 + * (< 10 ms) without fetching from the PDS on every request. */ 338 + ogJpeg?: Uint8Array | null; 335 339 iconCid?: string | null; 336 340 iconMime?: string | null; 337 341 iconBwCid?: string | null; ··· 377 381 INSERT INTO profile ( 378 382 did, handle, profile_type, name, description, main_link, ios_link, android_link, 379 383 categories, subcategories, links, screenshots, 380 - avatar_cid, avatar_mime, banner_cid, banner_mime, 384 + avatar_cid, avatar_mime, banner_cid, banner_mime, og_jpeg, 381 385 icon_cid, icon_mime, icon_status, 382 386 icon_reviewed_by, icon_reviewed_at, icon_rejected_reason, 383 387 icon_bw_cid, icon_bw_mime, icon_bw_status, ··· 388 392 takedown_status, takedown_reason, takedown_by, takedown_at, 389 393 pds_url, record_cid, record_rev, created_at, indexed_at 390 394 ) VALUES ( 391 - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 395 + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 392 396 NULL, NULL, NULL, 393 397 ?, ?, ?, 394 398 NULL, NULL, NULL, ··· 412 416 avatar_mime=excluded.avatar_mime, 413 417 banner_cid=excluded.banner_cid, 414 418 banner_mime=excluded.banner_mime, 419 + og_jpeg=CASE 420 + WHEN excluded.og_jpeg IS NOT NULL THEN excluded.og_jpeg 421 + ELSE profile.og_jpeg 422 + END, 415 423 icon_cid=excluded.icon_cid, 416 424 icon_mime=excluded.icon_mime, 417 425 /** ··· 521 529 input.avatarMime ?? null, 522 530 input.bannerCid ?? null, 523 531 input.bannerMime ?? null, 532 + input.ogJpeg ?? null, 524 533 input.iconCid ?? null, 525 534 input.iconMime ?? null, 526 535 initialIconStatus, ··· 534 543 now, 535 544 ], 536 545 }); 546 + }); 547 + } 548 + 549 + /** 550 + * Update the pre-generated OG JPEG for a profile in-place. 551 + * Called after a banner upload to cache the resized image so 552 + * `/api/registry/og-banner/{did}` can respond instantly without 553 + * touching the PDS or running ImageScript on every request. 554 + */ 555 + export async function storeOgJpeg( 556 + did: string, 557 + jpeg: Uint8Array, 558 + ): Promise<void> { 559 + await withDb((c) => 560 + c.execute({ 561 + sql: `UPDATE profile SET og_jpeg = ? WHERE did = ?`, 562 + args: [jpeg, did], 563 + }) 564 + ); 565 + } 566 + 567 + /** 568 + * Read the pre-generated OG JPEG bytes for a project, or null if none 569 + * has been stored yet (e.g. profile pre-dates this feature). 570 + */ 571 + export async function getOgJpeg(did: string): Promise<Uint8Array | null> { 572 + return withDb(async (c) => { 573 + const r = await c.execute({ 574 + sql: `SELECT og_jpeg FROM profile WHERE did = ?`, 575 + args: [did], 576 + }); 577 + if (!r.rows.length) return null; 578 + const raw = (r.rows[0] as unknown as { og_jpeg: unknown }).og_jpeg; 579 + if (!raw) return null; 580 + if (raw instanceof Uint8Array) return raw; 581 + if (raw instanceof ArrayBuffer) return new Uint8Array(raw); 582 + // libSQL may return a Buffer (Node-compat) on some client builds 583 + if (typeof raw === "object" && "buffer" in raw) { 584 + return new Uint8Array((raw as { buffer: ArrayBuffer }).buffer); 585 + } 586 + return null; 537 587 }); 538 588 } 539 589
+79
routes/api/admin/backfill-og-jpegs.ts
··· 1 + /** 2 + * Admin one-shot: generate and store og_jpeg for all profiles that have a 3 + * banner but no cached og_jpeg yet. Run once after deploying the og_jpeg 4 + * feature to warm the cache for existing profiles. 5 + * 6 + * POST /api/admin/backfill-og-jpegs 7 + * 8 + * Returns a JSON summary: { processed, skipped, errors }. 9 + */ 10 + import { Image } from "https://deno.land/x/imagescript@1.3.0/mod.ts"; 11 + import { define } from "../../../utils.ts"; 12 + import { requireAdminApi } from "../../../lib/admin.ts"; 13 + import { withDb } from "../../../lib/db.ts"; 14 + import { fetchBlobPublic } from "../../../lib/pds.ts"; 15 + import { storeOgJpeg } from "../../../lib/registry.ts"; 16 + 17 + const OG_W = 1200; 18 + const OG_H = 630; 19 + const JPEG_QUALITY = 85; 20 + 21 + export const handler = define.handlers({ 22 + POST: async (ctx) => { 23 + const gate = requireAdminApi(ctx); 24 + if (!gate.ok) return gate.response; 25 + 26 + const rows = await withDb((c) => 27 + c.execute( 28 + `SELECT did, pds_url, banner_cid, banner_mime 29 + FROM profile 30 + WHERE banner_cid IS NOT NULL AND og_jpeg IS NULL 31 + LIMIT 200`, 32 + ) 33 + ); 34 + 35 + let processed = 0; 36 + let skipped = 0; 37 + const errors: string[] = []; 38 + 39 + for ( 40 + const row of rows.rows as unknown as Array<{ 41 + did: string; 42 + pds_url: string; 43 + banner_cid: string; 44 + banner_mime: string | null; 45 + }> 46 + ) { 47 + try { 48 + const upstream = await fetchBlobPublic( 49 + row.pds_url, 50 + row.did, 51 + row.banner_cid, 52 + ); 53 + if (!upstream.ok) { 54 + skipped++; 55 + errors.push(`${row.did}: upstream ${upstream.status}`); 56 + continue; 57 + } 58 + const buf = new Uint8Array(await upstream.arrayBuffer()); 59 + const img = await Image.decode(buf); 60 + const jpeg = new Uint8Array( 61 + await img.cover(OG_W, OG_H).encodeJPEG(JPEG_QUALITY), 62 + ); 63 + await storeOgJpeg(row.did, jpeg); 64 + processed++; 65 + } catch (err) { 66 + skipped++; 67 + errors.push(`${row.did}: ${err instanceof Error ? err.message : err}`); 68 + } 69 + } 70 + 71 + return new Response( 72 + JSON.stringify({ processed, skipped, errors }), 73 + { 74 + status: 200, 75 + headers: { "content-type": "application/json; charset=utf-8" }, 76 + }, 77 + ); 78 + }, 79 + });
+56 -20
routes/api/registry/og-banner/[did].ts
··· 1 1 /** 2 2 * Social / link-preview sized banner for `og:image` only (~1200×630 JPEG). 3 - * The full-resolution `/api/registry/banner/{did}` stream can be 600KB+ PNG; 4 - * Bluesky’s composer often fails to attach that as an external thumb while 5 - * Cardyb still previews it. This route decodes the same blob, center-crops to 6 - * 1.91:1, and re-encodes as JPEG so embed pipelines get a small same-origin 7 - * image similar to `/og-hero.png`. In-page banners keep using `/banner/`. 3 + * 4 + * Fast path: returns the pre-generated JPEG stored in the database at 5 + * profile-save time (< 10 ms, no PDS round-trip, no ImageScript). 6 + * 7 + * Slow path (fallback for older profiles that pre-date the og_jpeg cache): 8 + * fetches the full-resolution blob from the PDS, runs ImageScript 9 + * center-crop + JPEG re-encode, and stores the result back into the DB so 10 + * the next request hits the fast path. 11 + * 12 + * In-page banners keep using `/api/registry/banner/{did}`. 8 13 */ 9 14 import { Image } from "https://deno.land/x/imagescript@1.3.0/mod.ts"; 10 15 import { define } from "../../../../utils.ts"; 11 - import { getProfileByDid } from "../../../../lib/registry.ts"; 16 + import { 17 + getOgJpeg, 18 + getProfileByDid, 19 + storeOgJpeg, 20 + } from "../../../../lib/registry.ts"; 12 21 import { fetchBlobPublic } from "../../../../lib/pds.ts"; 13 22 import { withRateLimit } from "../../../../lib/rate-limit.ts"; 14 23 ··· 16 25 const OG_H = 630; 17 26 const JPEG_QUALITY = 85; 18 27 28 + const OG_HEADERS = { 29 + "content-type": "image/jpeg", 30 + "cache-control": 31 + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 32 + "content-disposition": 'inline; filename="og-banner.jpg"', 33 + "access-control-allow-origin": "*", 34 + "cross-origin-resource-policy": "cross-origin", 35 + } as const; 36 + 19 37 export const handler = define.handlers({ 20 38 GET: withRateLimit(async (ctx) => { 21 39 const did = decodeURIComponent(ctx.params.did); ··· 23 41 if (!profile?.bannerCid) { 24 42 return new Response("not found", { status: 404 }); 25 43 } 44 + 45 + // ── Fast path: pre-generated JPEG already in the DB ────────────────── 46 + const cached = await getOgJpeg(did).catch(() => null); 47 + if (cached && cached.byteLength > 0) { 48 + return new Response(cached.buffer as ArrayBuffer, { 49 + status: 200, 50 + headers: { 51 + ...OG_HEADERS, 52 + "content-length": String(cached.byteLength), 53 + "etag": `${profile.bannerCid}-og`, 54 + }, 55 + }); 56 + } 57 + 58 + // ── Slow path: fetch from PDS, resize, store, return ───────────────── 26 59 try { 27 60 const upstream = await fetchBlobPublic( 28 61 profile.pdsUrl, ··· 35 68 const buf = new Uint8Array(await upstream.arrayBuffer()); 36 69 try { 37 70 const img = await Image.decode(buf); 38 - const cov = img.cover(OG_W, OG_H); 39 - const jpeg = new Uint8Array(await cov.encodeJPEG(JPEG_QUALITY)); 71 + const jpeg = new Uint8Array( 72 + await img.cover(OG_W, OG_H).encodeJPEG(JPEG_QUALITY), 73 + ); 74 + // Store asynchronously so the response isn't blocked by the DB write. 75 + storeOgJpeg(did, jpeg).catch((err) => 76 + console.warn("[og-banner] failed to cache og_jpeg:", err) 77 + ); 40 78 return new Response(jpeg.buffer as ArrayBuffer, { 41 79 status: 200, 42 80 headers: { 43 - "content-type": "image/jpeg", 81 + ...OG_HEADERS, 44 82 "content-length": String(jpeg.byteLength), 45 - "cache-control": 46 - "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 47 83 "etag": `${profile.bannerCid}-og`, 48 - "content-disposition": 'inline; filename="og-banner.jpg"', 49 - "access-control-allow-origin": "*", 50 - "cross-origin-resource-policy": "cross-origin", 51 84 }, 52 85 }); 53 - } catch (err) { 54 - console.warn("[og-banner] resize failed, serving raw bytes:", err); 86 + } catch (resizeErr) { 87 + console.warn( 88 + "[og-banner] resize failed, serving raw bytes:", 89 + resizeErr, 90 + ); 55 91 const ct = upstream.headers.get("content-type") ?? 56 - profile.bannerMime ?? "application/octet-stream"; 92 + profile.bannerMime ?? 93 + "application/octet-stream"; 57 94 return new Response(buf.buffer as ArrayBuffer, { 58 95 status: 200, 59 96 headers: { 60 97 "content-type": ct, 61 98 "content-length": String(buf.byteLength), 62 - "cache-control": 63 - "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 64 - "etag": profile.bannerCid, 99 + "cache-control": OG_HEADERS["cache-control"], 65 100 "content-disposition": "inline", 66 101 "access-control-allow-origin": "*", 67 102 "cross-origin-resource-policy": "cross-origin", 103 + "etag": profile.bannerCid, 68 104 }, 69 105 }); 70 106 }
+24
routes/api/registry/profile.ts
··· 8 8 * PUT /api/registry/profile (create/update profile) 9 9 * DELETE /api/registry/profile (delete profile) 10 10 */ 11 + import { Image } from "https://deno.land/x/imagescript@1.3.0/mod.ts"; 11 12 import { define } from "../../../utils.ts"; 12 13 import { loadSession } from "../../../lib/oauth.ts"; 13 14 import { ··· 31 32 import { sanitizeSvgBytes } from "../../../lib/svg-sanitize.ts"; 32 33 import { getEffectiveAccountType } from "../../../lib/account-types.ts"; 33 34 35 + const OG_W = 1200; 36 + const OG_H = 630; 37 + const OG_JPEG_QUALITY = 85; 38 + 39 + /** Resize `bytes` to a 1200×630 cover-crop JPEG for the og:image cache. 40 + * Returns null if ImageScript fails (e.g. unsupported format), so the 41 + * caller can proceed without crashing the whole profile save. */ 42 + async function generateOgJpeg(bytes: Uint8Array): Promise<Uint8Array | null> { 43 + try { 44 + const img = await Image.decode(bytes); 45 + const cropped = img.cover(OG_W, OG_H); 46 + return new Uint8Array(await cropped.encodeJPEG(OG_JPEG_QUALITY)); 47 + } catch (err) { 48 + console.warn("[profile] og-jpeg pre-generation failed:", err); 49 + return null; 50 + } 51 + } 52 + 34 53 const ICON_MAX_BYTES = 200_000; 35 54 const BANNER_MAX_BYTES = 3_000_000; 36 55 const SCREENSHOT_MAX_BYTES = 5_000_000; ··· 253 272 * referenced from the project page meta tags. 254 273 */ 255 274 let banner = body.banner ?? undefined; 275 + let ogJpeg: Uint8Array | null = null; 256 276 if (body.bannerUpload?.dataBase64) { 257 277 if (!BANNER_MIME_TYPES.has(body.bannerUpload.mimeType)) { 258 278 return new Response("banner must be png, jpeg, or webp", { ··· 274 294 const m = err instanceof Error ? err.message : String(err); 275 295 return new Response(`banner upload failed: ${m}`, { status: 502 }); 276 296 } 297 + // Pre-generate the 1200×630 JPEG for the og:image cache. This runs 298 + // after the PDS upload succeeds so a resize failure never blocks saving. 299 + ogJpeg = await generateOgJpeg(bytes); 277 300 } 278 301 279 302 /** ··· 503 526 avatarMime: validation.value.avatar?.mimeType ?? null, 504 527 bannerCid: validation.value.banner?.ref.$link ?? null, 505 528 bannerMime: validation.value.banner?.mimeType ?? null, 529 + ogJpeg: ogJpeg ?? undefined, 506 530 iconCid: validation.value.icon?.ref.$link ?? null, 507 531 iconMime: validation.value.icon?.mimeType ?? null, 508 532 iconBwCid: validation.value.iconBw?.ref.$link ?? null,