mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(brief): switch carousel to @vercel/og on edge runtime Every attempt to ship the Phase 8 Telegram carousel on Vercel's Node serverless runtime has failed at cold start: - PR #3174 direct satori + @resvg/resvg-wasm: Vercel edge bundler refused the `?url` asset import required by resvg-wasm. - PR #3174 (fix) direct satori + @resvg/resvg-js native binding: Node runtime accepted it, but Vercel's nft tracer does not follow @resvg/resvg-js/js-binding.js's conditional `require('@resvg/resvg-js-<platform>-<arch>-<libc>')` pattern, so the linux-x64-gnu peer package was never bundled. Cold start threw MODULE_NOT_FOUND, isolate crashed, FUNCTION_INVOCATION_FAILED on every request including OPTIONS, and Telegram reported WEBPAGE_CURL_FAILED with no other signal. - PR #3204 added `vercel.json` `functions.includeFiles` to force the binding in, but (a) the initial key was a literal path that Vercel micromatch read as a character class (PR #3206 fixed), (b) even with the corrected `api/brief/carousel/**` wildcard, the function still 500'd across the board. The `functions.includeFiles` path appears honored in the deployment manifest but not at runtime for this particular native-binding pattern. Fix: swap the renderer to @vercel/og's ImageResponse, which is Vercel's first-party wrapper around satori + resvg-wasm with Vercel-native bundling. Runs on Edge runtime — matches every other API route in the project. No native binding, no includeFiles, no nft tracing surprises. Cold start ~300ms, warm ~30ms. Changes: - server/_shared/brief-carousel-render.ts: replace renderCarouselPng (Uint8Array) with renderCarouselImageResponse (ImageResponse). Drop ensureLibs + satori + @resvg/resvg-js dynamic-import dance. Keep layout builders (buildCover/buildThreads/buildStory) and font loading unchanged — the Satori object trees are wire-compatible with ImageResponse. - api/brief/carousel/[userId]/[issueDate]/[page].ts: flip `runtime: 'nodejs'` -> `runtime: 'edge'`. Delegate rendering to the renderer's ImageResponse and return it directly; error path still 503 no-store so CDN + Telegram don't pin a bad render. - vercel.json: drop the now-useless `functions.includeFiles` block. - package.json: drop direct `@resvg/resvg-js` and `satori` deps (both now bundled inside @vercel/og). - tests/deploy-config.test.mjs: replace the native-binding regression guards with an assertion that no `functions` block exists (with a comment pointing at the skill documenting the micromatch gotcha for future routes). - tests/brief-carousel.test.mjs: updated comment references. Verified: - typecheck + typecheck:api clean - test:data 5814/5814 pass - node -e test: @vercel/og imports cleanly in Node (tests that reach through the renderer file no longer depend on native bindings) Post-deploy validation: curl -I -H "User-Agent: TelegramBot (like TwitterBot)" \ "https://www.worldmonitor.app/api/brief/carousel/<uid>/<slot>/0" # Expect: HTTP/2 403 (no token) or 200 (valid token) # NOT: HTTP/2 500 FUNCTION_INVOCATION_FAILED Then tail Railway digest logs on the next tick; the `[digest] Telegram carousel 400 ... WEBPAGE_CURL_FAILED` line should stop appearing, and the 3-image preview should actually land on Telegram. * Add renderer smoke test + fix Cache-Control duplication Reviewer flagged residual risk: no dedicated carousel-route smoke test for the @vercel/og path. Adds one, and catches a real bug in the process. Findings during test-writing: 1. @vercel/og's ImageResponse runs CLEANLY in Node via tsx — the comment in brief-carousel.test.mjs saying "we can't test the render in Node" was true for direct satori + @resvg/resvg-wasm but no longer holds after PR #3210. Pure Node render works end-to-end: satori tree-parse, jsdelivr font fetch, resvg-wasm init, PNG output. ~850ms first call, ~20ms warm. 2. ImageResponse sets its own default `Cache-Control: public, immutable, no-transform, max-age=31536000`. Passing Cache-Control via the constructor's headers option APPENDS rather than overrides, producing a duplicated comma-joined value like `public, immutable, no-transform, max-age=31536000, public, max-age=60` on the Response. The route handler was doing exactly this via extraHeaders. Fix: drop our Cache-Control override and rely on @vercel/og's 1-year immutable default — envelope is only immutable for its 7d Redis TTL so the effective ceiling is 7d anyway (after that the route 404s before render). Changes: - tests/brief-carousel.test.mjs: 6 new assertions under `renderCarouselImageResponse`: * renders cover / threads / story pages, each returning a valid PNG (magic bytes + size range) * rejects a structurally empty envelope * threads non-cache extraHeaders onto the Response * pins @vercel/og's Cache-Control default so it survives caller-supplied Cache-Control overrides (regression guard for the bug fixed in this commit) - api/brief/carousel/[userId]/[issueDate]/[page].ts: remove the stacked Cache-Control; lean on @vercel/og default. Drop the now- unused `PAGE_CACHE_TTL` constant. Comment explains why. Verified: - test:data 5820/5820 pass (was 5814, +6 smoke) - typecheck + typecheck:api clean - Render smoke: cover 825ms / threads 23ms / story 16ms first run (wasm init dominates first render)
278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
// Phase 8 — carousel URL parsing + page index helpers + renderer smoke.
|
|
//
|
|
// After the @vercel/og refactor (PR #3210), the full render path
|
|
// actually runs cleanly in Node via tsx — ImageResponse wraps satori
|
|
// + resvg-wasm and both work in plain Node. So in addition to the
|
|
// pure plumbing tests (URL derivation + page index mapping) we now
|
|
// end-to-end each of the three layouts, asserting PNG magic bytes
|
|
// and a plausible byte range. This catches Satori tree-shape
|
|
// regressions, font-load breakage, and resvg-wasm init issues long
|
|
// before they'd surface in a Vercel deploy.
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { pageFromIndex, renderCarouselImageResponse } from '../server/_shared/brief-carousel-render.ts';
|
|
|
|
// Import the URL helper via dynamic eval of the private function.
|
|
// The digest cron is .mjs; we re-declare the same logic here to lock
|
|
// the behaviour. If the cron's copy drifts, this test stops guarding
|
|
// the contract and should be migrated to shared import.
|
|
//
|
|
// Kept in-sync via a grep assertion at the bottom of this file.
|
|
function carouselUrlsFrom(magazineUrl) {
|
|
try {
|
|
const u = new URL(magazineUrl);
|
|
const m = u.pathname.match(/^\/api\/brief\/([^/]+)\/(\d{4}-\d{2}-\d{2}-\d{4})\/?$/);
|
|
if (!m) return null;
|
|
const [, userId, issueSlot] = m;
|
|
const token = u.searchParams.get('t');
|
|
if (!token) return null;
|
|
return [0, 1, 2].map(
|
|
(p) => `${u.origin}/api/brief/carousel/${userId}/${issueSlot}/${p}?t=${token}`,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
describe('pageFromIndex', () => {
|
|
it('maps 0 → cover, 1 → threads, 2 → story', () => {
|
|
assert.equal(pageFromIndex(0), 'cover');
|
|
assert.equal(pageFromIndex(1), 'threads');
|
|
assert.equal(pageFromIndex(2), 'story');
|
|
});
|
|
|
|
it('returns null for out-of-range indices', () => {
|
|
assert.equal(pageFromIndex(-1), null);
|
|
assert.equal(pageFromIndex(3), null);
|
|
assert.equal(pageFromIndex(100), null);
|
|
assert.equal(pageFromIndex(Number.NaN), null);
|
|
});
|
|
});
|
|
|
|
describe('carouselUrlsFrom', () => {
|
|
const magazine = 'https://www.worldmonitor.app/api/brief/user_abc/2026-04-18-0800?t=XXX';
|
|
|
|
it('derives three signed carousel URLs from a valid magazine URL', () => {
|
|
const urls = carouselUrlsFrom(magazine);
|
|
assert.ok(urls);
|
|
assert.equal(urls.length, 3);
|
|
assert.equal(urls[0], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18-0800/0?t=XXX');
|
|
assert.equal(urls[1], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18-0800/1?t=XXX');
|
|
assert.equal(urls[2], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18-0800/2?t=XXX');
|
|
});
|
|
|
|
it('preserves origin (localhost, preview deploys, etc.)', () => {
|
|
const urls = carouselUrlsFrom('http://localhost:3000/api/brief/user_a/2026-04-18-1300?t=T');
|
|
assert.equal(urls[0], 'http://localhost:3000/api/brief/carousel/user_a/2026-04-18-1300/0?t=T');
|
|
});
|
|
|
|
it('returns null for a URL without a token', () => {
|
|
assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/brief/user_a/2026-04-18-0800'), null);
|
|
});
|
|
|
|
it('returns null when the path is not the magazine route', () => {
|
|
assert.equal(carouselUrlsFrom('https://worldmonitor.app/dashboard?t=X'), null);
|
|
assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/other/path/2026-04-18-0800?t=X'), null);
|
|
});
|
|
|
|
it('returns null when the slot is date-only (no HHMM suffix)', () => {
|
|
assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/brief/user_a/2026-04-18?t=X'), null);
|
|
});
|
|
|
|
it('returns null when slot is not YYYY-MM-DD-HHMM', () => {
|
|
assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/brief/user_a/today?t=X'), null);
|
|
});
|
|
|
|
it('returns null on garbage input without throwing', () => {
|
|
assert.equal(carouselUrlsFrom('not a url'), null);
|
|
assert.equal(carouselUrlsFrom(''), null);
|
|
assert.equal(carouselUrlsFrom(null), null);
|
|
});
|
|
});
|
|
|
|
describe('carouselUrlsFrom — contract parity with seed-digest-notifications.mjs', () => {
|
|
it('the cron embeds the same function body (guards drift)', async () => {
|
|
const { readFileSync } = await import('node:fs');
|
|
const { fileURLToPath } = await import('node:url');
|
|
const { dirname, resolve } = await import('node:path');
|
|
const __d = dirname(fileURLToPath(import.meta.url));
|
|
const src = readFileSync(resolve(__d, '../scripts/seed-digest-notifications.mjs'), 'utf-8');
|
|
assert.match(src, /function carouselUrlsFrom\(magazineUrl\)/, 'cron must export carouselUrlsFrom');
|
|
assert.match(src, /\/api\/brief\/carousel\/\$\{userId\}\/\$\{issueSlot\}\/\$\{p\}\?t=\$\{token\}/, 'cron path template must match test fixture');
|
|
});
|
|
});
|
|
|
|
// REGRESSION: PR #3174 review P1. The edge route MUST NOT return
|
|
// a 200 placeholder PNG on render failure. A 1x1 blank cached 7d
|
|
// immutable by Telegram/CDN would lock in a broken preview for
|
|
// the life of the brief. Only 200s serve PNG bytes; every failure
|
|
// path is a non-2xx JSON with no-cache.
|
|
describe('carousel route — no placeholder PNG on failure', () => {
|
|
it('the route source never serves image/png on the render-failed path', async () => {
|
|
const { readFileSync } = await import('node:fs');
|
|
const { fileURLToPath } = await import('node:url');
|
|
const { dirname, resolve } = await import('node:path');
|
|
const __d = dirname(fileURLToPath(import.meta.url));
|
|
const src = readFileSync(
|
|
resolve(__d, '../api/brief/carousel/[userId]/[issueDate]/[page].ts'),
|
|
'utf-8',
|
|
);
|
|
// Old impl had errorPng() returning a 1x1 transparent PNG at 200 +
|
|
// 7d cache. If that pattern ever comes back, this test fails.
|
|
assert.doesNotMatch(src, /\berrorPng\b/, 'errorPng helper must not be reintroduced');
|
|
// Render-failed branch must return 503 with noStore.
|
|
assert.match(
|
|
src,
|
|
/render_failed.{0,200}503.{0,400}noStore:\s*true/s,
|
|
'render failure must 503 with no-store',
|
|
);
|
|
});
|
|
|
|
it('FONT_URL uses a Satori-parseable format (ttf / otf / woff — NOT woff2)', async () => {
|
|
// REGRESSION: an earlier head shipped a woff2 URL. Satori parses
|
|
// ttf / otf / woff only — a woff2 buffer throws on every render,
|
|
// the route returns 503, the carousel never delivers. Lock the
|
|
// format here so a future swap can't regress.
|
|
const { readFileSync } = await import('node:fs');
|
|
const { fileURLToPath } = await import('node:url');
|
|
const { dirname, resolve } = await import('node:path');
|
|
const __d = dirname(fileURLToPath(import.meta.url));
|
|
const src = readFileSync(
|
|
resolve(__d, '../server/_shared/brief-carousel-render.ts'),
|
|
'utf-8',
|
|
);
|
|
const fontUrlMatch = src.match(/const FONT_URL\s*=\s*['"]([^'"]+)['"]/);
|
|
assert.ok(fontUrlMatch, 'FONT_URL constant must exist');
|
|
const url = fontUrlMatch[1];
|
|
assert.doesNotMatch(url, /\.woff2($|\?|#)/i, 'woff2 is NOT supported by Satori — use ttf/otf/woff');
|
|
assert.match(url, /\.(ttf|otf|woff)($|\?|#)/i, 'FONT_URL must end in .ttf, .otf, or .woff');
|
|
});
|
|
|
|
it('the renderer honestly declares Google Fonts as a runtime dependency', async () => {
|
|
const { readFileSync } = await import('node:fs');
|
|
const { fileURLToPath } = await import('node:url');
|
|
const { dirname, resolve } = await import('node:path');
|
|
const __d = dirname(fileURLToPath(import.meta.url));
|
|
const src = readFileSync(
|
|
resolve(__d, '../server/_shared/brief-carousel-render.ts'),
|
|
'utf-8',
|
|
);
|
|
// Earlier comment lied about a "safe embedded/fallback path" that
|
|
// didn't exist. The corrected comment must either honestly declare
|
|
// the CDN dependency OR actually ship an embedded fallback font.
|
|
const hasHonestDependency =
|
|
/RUNTIME DEPENDENCY/i.test(src) || /hard runtime dependency/i.test(src);
|
|
const hasEmbeddedFallback = /const EMBEDDED_FONT_BASE64/.test(src);
|
|
assert.ok(
|
|
hasHonestDependency || hasEmbeddedFallback,
|
|
'font loading must EITHER declare the CDN dependency OR ship an embedded fallback',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── End-to-end renderer smoke ───────────────────────────────────────────
|
|
//
|
|
// Exercises @vercel/og's ImageResponse against each layout. Catches:
|
|
// - Satori tree-shape regressions (bad style/children keys throw)
|
|
// - Font fetch breakage (jsdelivr down, wrong format, etc.)
|
|
// - resvg-wasm init failure (rare but has happened)
|
|
// - PNG output corruption (wrong magic, zero bytes)
|
|
//
|
|
// Hits the real jsdelivr CDN for the Noto Serif TTF. Same network
|
|
// footprint as the rest of the data suite (which calls FRED, IMF,
|
|
// etc.). If that ever becomes a problem, swap loadFont() to an
|
|
// embedded base64 TTF per the comment in brief-carousel-render.ts.
|
|
|
|
const SAMPLE_ENVELOPE = {
|
|
version: 1,
|
|
issuedAt: Date.now(),
|
|
data: {
|
|
issue: '001',
|
|
dateLong: '19 April 2026',
|
|
user: { name: 'Test User' },
|
|
digest: {
|
|
greeting: 'Good morning',
|
|
lead: 'A sample lead line that gives the reader the day in one sentence.',
|
|
threads: [
|
|
{ tag: 'MIDDLE EAST', teaser: 'Iran re-closes the Strait of Hormuz' },
|
|
{ tag: 'UKRAINE', teaser: 'Kyiv authorities investigate terror attack' },
|
|
{ tag: 'LEBANON', teaser: 'French UNIFIL peacekeeper killed in attack' },
|
|
],
|
|
},
|
|
stories: [
|
|
{
|
|
category: 'Geopolitics',
|
|
country: 'IR',
|
|
threatLevel: 'HIGH',
|
|
headline: 'Iran closes Strait of Hormuz again, cites US blockade',
|
|
source: 'Reuters',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
|
|
async function assertRendersPng(page) {
|
|
const res = await renderCarouselImageResponse(SAMPLE_ENVELOPE, page);
|
|
assert.equal(res.status, 200, `${page}: status should be 200`);
|
|
assert.equal(
|
|
res.headers.get('content-type'),
|
|
'image/png',
|
|
`${page}: content-type must be image/png`,
|
|
);
|
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
assert.ok(buf.byteLength > 5_000, `${page}: PNG body should be > 5KB, got ${buf.byteLength}`);
|
|
assert.ok(buf.byteLength < 500_000, `${page}: PNG body should be < 500KB, got ${buf.byteLength}`);
|
|
for (let i = 0; i < PNG_MAGIC.length; i++) {
|
|
assert.equal(buf[i], PNG_MAGIC[i], `${page}: byte ${i} should be PNG magic 0x${PNG_MAGIC[i].toString(16)}, got 0x${buf[i].toString(16)}`);
|
|
}
|
|
}
|
|
|
|
describe('renderCarouselImageResponse', () => {
|
|
it('renders the cover page to a valid PNG', async () => {
|
|
await assertRendersPng('cover');
|
|
});
|
|
|
|
it('renders the threads page to a valid PNG', async () => {
|
|
await assertRendersPng('threads');
|
|
});
|
|
|
|
it('renders the story page to a valid PNG', async () => {
|
|
await assertRendersPng('story');
|
|
});
|
|
|
|
it('rejects a structurally empty envelope', async () => {
|
|
await assert.rejects(
|
|
() => renderCarouselImageResponse({}, 'cover'),
|
|
/invalid envelope/,
|
|
);
|
|
});
|
|
|
|
it('threads the extraHeaders argument onto the Response', async () => {
|
|
const res = await renderCarouselImageResponse(SAMPLE_ENVELOPE, 'cover', {
|
|
'X-Test-Marker': 'carousel-smoke',
|
|
'Referrer-Policy': 'no-referrer',
|
|
});
|
|
assert.equal(res.headers.get('x-test-marker'), 'carousel-smoke');
|
|
assert.equal(res.headers.get('referrer-policy'), 'no-referrer');
|
|
});
|
|
|
|
it('keeps @vercel/og default Cache-Control (extraHeaders must NOT override it)', async () => {
|
|
// ImageResponse APPENDS rather than overrides Cache-Control when
|
|
// the caller passes one via headers. Guards the route handler
|
|
// choice to rely on @vercel/og's 1-year immutable default instead
|
|
// of stacking our own. If @vercel/og ever changes this semantics,
|
|
// this test fails and the route needs a review.
|
|
const res = await renderCarouselImageResponse(SAMPLE_ENVELOPE, 'cover', {
|
|
'Cache-Control': 'public, max-age=60',
|
|
});
|
|
const cc = res.headers.get('cache-control') ?? '';
|
|
assert.ok(
|
|
cc.includes('max-age=31536000'),
|
|
`expected @vercel/og's default 1-year cache to survive, got "${cc}"`,
|
|
);
|
|
});
|
|
});
|