From 2b0e05737af852fe76badfd56a5a3313d54ee48a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 28 Sep 2026 14:41:58 -0700 Subject: [PATCH] laklok: richer link cards, and lith stops mangling binary v2 responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit og-preview asks YouTube, SoundCloud, Discogs, Reddit and archive.org their own way (oEmbed / public APIs) before the HTML scrape, so Discogs and Reddit links stop coming back 403 and every card gets a byline in siteName ("Discogs · Car Skid And Crash · 1986"), which both laklok sisters already draw. The vector client plays SoundCloud in place. lith's v2 function adapter read every Response with text(), turning og-image's proxied thumbnails into U+FFFD soup; non-text bodies now go through as base64. lith also runs the hourly metrics-daily rollup. tests/browser/laklok-link-previews.test.mjs proves the cards, thumbnails and player against fixtures (LAKLOK_CDP_URL for a remote Chrome). Co-Authored-By: Claude Opus 5.5 --- lith/server.mjs | 35 ++++- system/netlify/functions/og-preview.mjs | 100 +++++++++++++ system/public/html/index.html | 20 +++ tests/browser/laklok-link-previews.test.mjs | 153 ++++++++++++++++++++ toolchain/laklok-sisters/PARITY.md | 3 +- 5 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 tests/browser/laklok-link-previews.test.mjs diff --git a/lith/server.mjs b/lith/server.mjs index f8ccfd22fe..558a548143 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -339,11 +339,17 @@ async function loadFunction(file, bust = false) { }); req.query = event.queryStringParameters; const resp = await v2fn(req, context); - // V2 returns a Web Response object - const body = await resp.text(); + // V2 returns a Web Response object. Binary bodies (og-image's + // proxied thumbnails) go through as base64: text() would swap every + // invalid UTF-8 byte for U+FFFD and ship a broken image. const headers = {}; resp.headers.forEach((v, k) => { headers[k] = v; }); - return { statusCode: resp.status, headers, body }; + const type = headers["content-type"] || ""; + if (resp.body && !/^text\/|json|javascript|xml|svg|x-www-form-urlencoded/i.test(type) && type) { + const bytes = Buffer.from(await resp.arrayBuffer()); + return { statusCode: resp.status, headers, body: bytes.toString("base64"), isBase64Encoded: true }; + } + return { statusCode: resp.status, headers, body: await resp.text() }; }; registered = true; } @@ -1473,6 +1479,29 @@ if (!DEV && process.env.ACCOUNT_DELETION_RUNNER !== "off") { setInterval(runAccountDeletions, 15 * 60_000).unref(); } +// --- Daily metrics --- +// Folds each finished day of visits, downloads and app opens into +// `metrics-daily` (system/backend/metrics-daily.mjs). Idempotent, so an +// hourly check is enough to catch the day turning over. +if (!DEV) { + const rollupMetrics = async () => { + let database; + try { + const { connect } = await import(pathToFileURL(join(SYSTEM, "backend", "database.mjs")).href); + const { rollupMissingDays } = await import(pathToFileURL(join(SYSTEM, "backend", "metrics-daily.mjs")).href); + database = await connect(); + const written = await rollupMissingDays(database.db); + if (written.length) console.log("[lith] metrics-daily:", written.join(", ")); + } catch (error) { + console.error("[lith] metrics-daily failed:", error?.message || error); + } finally { + await database?.disconnect?.(); + } + }; + setTimeout(rollupMetrics, 90_000).unref(); + setInterval(rollupMetrics, 60 * 60_000).unref(); +} + // --- Graceful shutdown --- // On SIGTERM (sent by systemctl restart), stop accepting new connections // and wait for in-flight requests to finish before exiting. diff --git a/system/netlify/functions/og-preview.mjs b/system/netlify/functions/og-preview.mjs index dadb12b881..ffe5cd7b1f 100644 --- a/system/netlify/functions/og-preview.mjs +++ b/system/netlify/functions/og-preview.mjs @@ -151,6 +151,17 @@ export default async function handler(req) { }); } + // Sites that turn scrapers away (or say more through an API) get asked + // their own way first; anything that falls through gets the HTML fetch. + const provided = await providerPreview(parsedUrl).catch((err) => { + console.warn(`[og-preview] provider failed for ${targetUrl}:`, err.message); + return null; + }); + if (provided) { + setCache(targetUrl, provided); + return previewResponse(provided, CACHE_TTL); + } + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { @@ -211,6 +222,95 @@ export default async function handler(req) { } } +// đŸŽ›ïž Providers +// Each returns the same shape as parseMetaTags (plus an optional `player`), +// or null to fall through. siteName carries the byline ("Discogs · Artist · +// 1986") so every client that draws site + title shows it without changes. + +const PROVIDER_UA = "AestheticComputer/1.0 +https://aesthetic.computer"; + +async function getJson(url) { + const response = await fetch(url, { + headers: { "User-Agent": PROVIDER_UA, Accept: "application/json" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + return null; + } + return response.json(); +} + +const byline = (...parts) => parts.filter(Boolean).join(" · "); +const hostIs = (url, ...hosts) => + hosts.some((host) => url.hostname === host || url.hostname.endsWith("." + host)); + +async function providerPreview(url) { + if (hostIs(url, "youtube.com", "youtu.be")) { + const data = await getJson(`https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(url.href)}`); + if (!data?.title) return null; + return { url: url.href, title: data.title, image: data.thumbnail_url || null, + description: null, siteName: byline("YouTube", data.author_name), + favicon: "https://www.youtube.com/favicon.ico" }; + } + + if (hostIs(url, "soundcloud.com") && url.hostname !== "api.soundcloud.com") { + const data = await getJson(`https://soundcloud.com/oembed?format=json&url=${encodeURIComponent(url.href)}`); + if (!data?.title) return null; + // oEmbed titles read "Track by Artist"; the artist moves to the byline. + const by = data.author_name && data.title.endsWith(` by ${data.author_name}`); + return { url: url.href, title: by ? data.title.slice(0, -(data.author_name.length + 4)) : data.title, + image: data.thumbnail_url || null, description: data.description || null, + siteName: byline("SoundCloud", data.author_name), favicon: null, + player: { kind: "soundcloud", + src: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url.href)}&auto_play=true&visual=false&show_comments=false&show_user=true&show_reposts=false` } }; + } + + if (hostIs(url, "discogs.com")) { + const match = url.pathname.match(/\/(release|master|artist|label)\/(\d+)/); + if (!match) return null; + const [, kind, id] = match; + const data = await getJson(`https://api.discogs.com/${kind}s/${id}`); + if (!data) return null; + const artists = (data.artists || []).map((a) => a.name.replace(/ \(\d+\)$/, "")).join(", "); + return { url: url.href, title: data.title || data.name || null, + image: data.thumb || data.images?.[0]?.uri150 || null, + description: data.profile?.slice(0, 200) || null, + siteName: byline("Discogs", artists, data.year || null), favicon: null }; + } + + if (hostIs(url, "reddit.com", "redd.it")) { + // Share links (/r/x/s/abc) are redirects; the post URL is in Location. + let post = url.href; + if (/\/s\/[\w-]+/.test(url.pathname) || url.hostname === "redd.it") { + const hop = await fetch(url.href, { method: "HEAD", redirect: "manual", + headers: { "User-Agent": PROVIDER_UA }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + const location = hop.headers.get("location"); + if (location) post = new URL(location, url).href.split("?")[0]; + } + const data = await getJson(`https://www.reddit.com/oembed?url=${encodeURIComponent(post)}`); + const title = data?.html?.match(/([^<]+)<\/a>/)?.[1]; + if (!title) return null; + const sub = post.match(/\/r\/([\w]+)/)?.[1]; + return { url: url.href, title: decodeHtmlEntities(title), image: null, description: null, + siteName: byline("Reddit", sub && `r/${sub}`, data.author_name && `u/${data.author_name}`), + favicon: "https://www.redditstatic.com/shreddit/assets/favicon/192x192.png" }; + } + + if (hostIs(url, "archive.org")) { + const id = url.pathname.match(/^\/details\/([^/]+)/)?.[1]; + if (!id) return null; + const data = (await getJson(`https://archive.org/metadata/${id}`))?.metadata; + if (!data?.title) return null; + const creator = [].concat(data.creator || [])[0]; + return { url: url.href, title: [].concat(data.title)[0], description: null, + image: `https://archive.org/services/img/${id}`, + siteName: byline("Internet Archive", creator, data.date?.slice(0, 4)), favicon: null }; + } + + return null; +} + // Parse meta tags from HTML to extract OG data function parseMetaTags(html, baseUrl) { const result = { diff --git a/system/public/html/index.html b/system/public/html/index.html index 35ad556033..03379f21b4 100644 --- a/system/public/html/index.html +++ b/system/public/html/index.html @@ -305,6 +305,14 @@ .embed.og .t { font-size: 12px; line-height: 1.3; overflow: hidden; } .embed.og .t .site { color: var(--time); font-size: 10px; display: block; } .embed.og:hover { border-color: var(--hover); } + /* SoundCloud cards carry a ▶ that swaps the card for the widget in place. */ + .embed.og .listen { + flex: 0 0 auto; align-self: center; margin-left: auto; padding: 2px 8px; + border: 1px solid var(--time); border-radius: 3px; color: inherit; + background: none; font: inherit; font-size: 12px; cursor: pointer; + } + .embed.og .listen:hover { border-color: var(--hover); } + .embed.player { width: 300px; max-width: 100%; height: 166px; border: 0; border-radius: 4px; } /* 🔍 Lightbox — the vector's take on chat.mjs's fullscreen painting modal. */ #lightbox { @@ -659,6 +667,18 @@ function ogEmbed(url) { let host = url; try { host = new URL(url).hostname; } catch {} t.append(span("site", meta.siteName || host), document.createTextNode(meta.title || url)); a.append(t); + if (meta.player?.kind === "soundcloud" && /^https:\/\/w\.soundcloud\.com\/player\//.test(meta.player.src)) { + const listen = document.createElement("button"); + listen.className = "listen"; listen.textContent = "▶"; listen.title = "lyt her"; + listen.onclick = (e) => { + e.preventDefault(); e.stopPropagation(); // play in place, don't open the link + const frame = document.createElement("iframe"); + frame.className = "embed player"; frame.allow = "autoplay"; + frame.src = meta.player.src; frame.title = meta.title || "SoundCloud"; + a.replaceWith(frame); + }; + a.append(listen); + } a.classList.add("ready"); }; return a; diff --git a/tests/browser/laklok-link-previews.test.mjs b/tests/browser/laklok-link-previews.test.mjs new file mode 100644 index 0000000000..d42a83e2aa --- /dev/null +++ b/tests/browser/laklok-link-previews.test.mjs @@ -0,0 +1,153 @@ +// laklok-link-previews.test, 2026.09.28 +// End-to-end proof that the vector laklok client (system/public/html/index.html) +// turns the links the room actually shares into cards with a byline, and that +// a SoundCloud card plays in place. The page is the local file, /api/og-preview +// is the local handler (so providers run for real against the upstream +// sites), and the chat socket plus history are fixtures: nothing is posted +// and the live room is never joined. /api/og-image is the local handler too, +// answered as bytes the way lith now sends binary (see lith/server.mjs). +// +// node tests/browser/laklok-link-previews.test.mjs +// LAKLOK_CDP_URL=http://127.0.0.1:9333 node tests/browser/laklok-link-previews.test.mjs +// +// LAKLOK_CDP_URL attaches to an already-running Chrome (e.g. Poorslice's, +// forwarded over ssh) instead of launching one here. + +import puppeteer from "puppeteer"; +import { readFile, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import ogPreview from "../../system/netlify/functions/og-preview.mjs"; +import ogImage from "../../system/netlify/functions/og-image.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SHOTS = join(HERE, "__screens__", "laklok-link-previews"); +const html = await readFile(join(HERE, "../../system/public/html/index.html"), "utf8"); + +// The links from the room, and what each card must say. +const LINKS = [ + { url: "https://soundcloud.com/pikblodplus/some-do-others-do-not?si=77852b8017", site: /^SoundCloud · /, title: "some do - others do not", player: true }, + { url: "https://www.discogs.com/release/252760-Car-Skid-And-Crash-Toys-Are-Terrific", site: /^Discogs · Car Skid And Crash · 1986$/, title: "Toys Are Terrific" }, + { url: "https://www.reddit.com/r/laerklokken_goodiepal/s/5zGA2hedTN", site: /^Reddit · r\/laerklokken_goodiepal/, title: /LĂŠr Klokken/ }, + { url: "https://archive.org/details/1.-klap-perker-lady-smita-version", site: /^Internet Archive · DJ HVAD$/, title: "DJ HVAD - 1996hvadcore" }, + { url: "https://we.tl/t-NWhPivK0mkiJWnxh", site: /^WeTransfer$/, title: /./ }, +]; + +const t0 = Date.parse("2026-09-28T12:00:00Z"); +const history = LINKS.map((link, i) => ({ + id: `fixture-${i}`, from: "@test", sub: "fixture", text: link.url, + when: new Date(t0 + i * 60000).toISOString(), +})); + +let failures = 0; +const check = (ok, label, detail = "") => { + console.log(`${ok ? "✅" : "❌"} ${label}${ok ? "" : ` — ${detail}`}`); + if (!ok) failures++; +}; +const matches = (value, want) => (want instanceof RegExp ? want.test(value) : value === want); + +const browser = process.env.LAKLOK_CDP_URL + ? await puppeteer.connect({ browserURL: process.env.LAKLOK_CDP_URL }) + : await puppeteer.launch({ headless: true }); +const page = await browser.newPage(); +try { + await page.setViewport({ width: 900, height: 900 }); + await mkdir(SHOTS, { recursive: true }); + + // A socket that says hello and nothing else; history comes over REST. + await page.evaluateOnNewDocument(() => { + window.WebSocket = class { + constructor() { + setTimeout(() => { + this.onopen?.(); + this.onmessage?.({ data: JSON.stringify({ type: "connected", + content: JSON.stringify({ chatters: 1, handles: [], messages: [] }) }) }); + }, 50); + } + send() {} + close() {} + }; + }); + + // The page is laklok.com and the API is aesthetic.computer, so every + // stand-in answer needs the CORS header the real endpoints send. + const cors = { "Access-Control-Allow-Origin": "*" }; + await page.setRequestInterception(true); + page.on("request", async (request) => { + const url = new URL(request.url()); + try { + if (url.hostname === "laklok.com" && url.pathname === "/html/") + return request.respond({ contentType: "text/html", body: html }); + if (url.pathname === "/api/chat-messages") + return request.respond({ contentType: "application/json", headers: cors, body: JSON.stringify({ messages: history }) }); + if (url.hostname === "aesthetic.computer" && url.pathname === "/api/og-preview") { + const response = await ogPreview(new Request(url.href)); + return request.respond({ status: response.status, contentType: "application/json", + headers: cors, body: await response.text() }); + } + if (url.hostname === "aesthetic.computer" && url.pathname === "/api/og-image") { + const response = await ogImage(new Request(url.href)); + return request.respond({ status: response.status, headers: cors, + contentType: response.headers.get("content-type") || "application/octet-stream", + body: Buffer.from(await response.arrayBuffer()) }); + } + if (url.pathname === "/api/laklok-theme" || url.pathname === "/api/visit-track") + return request.respond({ status: 204, headers: cors, body: "" }); + return request.continue(); + } catch (error) { + console.warn("interception failed for", url.href, error.message); + return request.abort().catch(() => {}); + } + }); + + await page.goto("https://laklok.com/html/?ac-automation=1", { waitUntil: "domcontentloaded" }); + // Cards hydrate as their rows scroll into view; the fixture is short enough + // to fit, and the handler takes a moment per upstream. + await page.waitForFunction((n) => document.querySelectorAll(".embed.og.ready").length >= n, + { timeout: 45000 }, LINKS.length).catch(() => {}); + + // Thumbnails settle after the cards; give each a moment to decode. + await page.waitForFunction(() => [...document.querySelectorAll(".embed.og img")] + .every((i) => i.complete), { timeout: 20000 }).catch(() => {}); + const cards = await page.$$eval(".embed.og.ready", (els) => els.map((a) => ({ + href: a.href, site: a.querySelector(".site")?.textContent || "", + title: a.querySelector(".t")?.lastChild?.textContent || "", + thumb: a.querySelector("img.thumb, img.fav")?.naturalWidth > 0, listen: !!a.querySelector(".listen"), + }))); + check(cards.length === LINKS.length, `${LINKS.length} cards drawn`, `got ${cards.length}`); + for (const link of LINKS) { + const card = cards.find((c) => c.href === new URL(link.url).href); + if (!card) { check(false, `card for ${link.url}`, "missing"); continue; } + check(matches(card.site, link.site) && matches(card.title, link.title), + `${card.site} — ${card.title}`, `site "${card.site}", title "${card.title}"`); + check(card.thumb, `${new URL(link.url).hostname} thumbnail decoded`); + check(card.listen === !!link.player, `${new URL(link.url).hostname} ${link.player ? "has" : "has no"} ▶`); + } + await page.screenshot({ path: join(SHOTS, "cards.png") }); + + // ▶ swaps the SoundCloud card for the widget, and the widget loads. + await page.click(".embed.og .listen"); + const frame = await page.waitForSelector("iframe.embed.player", { timeout: 10000 }).catch(() => null); + check(!!frame, "▶ swaps the card for a player"); + if (frame) { + const src = await frame.evaluate((f) => f.src); + check(src.startsWith("https://w.soundcloud.com/player/?url="), "player points at the SoundCloud widget", src); + const loaded = await page.waitForFunction(() => { + const f = document.querySelector("iframe.embed.player"); + return f && f.getBoundingClientRect().height > 100; + }, { timeout: 10000 }).then(() => true, () => false); + check(loaded, "player is laid out at full height"); + const widget = page.frames().find((f) => f.url().startsWith("https://w.soundcloud.com/player")); + const ready = widget && await widget.waitForSelector(".sc-button-play, .playButton, button", { timeout: 20000 }) + .then(() => true, () => false); + check(!!ready, "SoundCloud widget rendered its controls"); + await new Promise((r) => setTimeout(r, 1500)); + await page.screenshot({ path: join(SHOTS, "player.png") }); + } +} finally { + await page.close().catch(() => {}); + if (process.env.LAKLOK_CDP_URL) browser.disconnect(); else await browser.close(); +} + +console.log(failures ? `\n${failures} failed` : "\nall passed", `— screenshots in ${SHOTS}`); +process.exit(failures ? 1 : 0); diff --git a/toolchain/laklok-sisters/PARITY.md b/toolchain/laklok-sisters/PARITY.md index a5cb92e338..5d18adeedd 100644 --- a/toolchain/laklok-sisters/PARITY.md +++ b/toolchain/laklok-sisters/PARITY.md @@ -39,7 +39,8 @@ same change — an unmirrored constant is future drift. | Inline `\color\` codes | chat.mjs `isChatColorCode` | `splitChatColors` | keywords checked | | #painting embeds (64px, modal) | chat.mjs painting previews + Ken Burns | `.embed.painting` + lightbox | endpoints checked; Ken Burns is a raster-only flourish | | YouTube embeds (thumb → jump out) | chat.mjs youtube previews | `.embed.yt` | thumb source checked | -| OG link cards | chat.mjs og previews | `.embed.og` (lazy, IntersectionObserver) | endpoints checked | +| OG link cards | chat.mjs og previews | `.embed.og` (lazy, IntersectionObserver) | endpoints checked; og-preview's providers (YouTube, SoundCloud, Discogs, Reddit, archive.org) put the byline in `siteName`, so both sides get it without code | +| SoundCloud play-in-place | — (card opens the link) | `.embed.og .listen` → `.embed.player` iframe | vector is richer here by medium, like file embeds | | Direct file embeds (img/vid/aud) | via OG/preview path | native `/