mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(brief): Phase 8 — Telegram carousel images via Satori + resvg-wasm
Implements the Phase 8 carousel renderer (Option B): server-side PNG
generation in a Vercel edge function using Satori (JSX to SVG) +
@resvg/resvg-wasm (SVG to PNG). Zero new Railway infra, zero
Chromium, same edge runtime that already serves the magazine HTML.
Files:
server/_shared/brief-carousel-render.ts (new)
Pure renderer: (BriefEnvelope, CarouselPage) -> Uint8Array PNG.
Three layouts (cover/threads/story), 1200x630 OG size.
Satori + resvg + WASM are lazy-imported so Node tests don't trip
over '?url' asset imports and the 800KB wasm doesn't ship in
every bundle. Font: Noto Serif regular, fetched once from Google
Fonts and memoised on the edge isolate.
api/brief/carousel/[userId]/[issueDate]/[page].ts (new)
Public edge function reusing the magazine route's HMAC token —
same signer, same (userId, issueDate) binding, so one token
unlocks magazine HTML AND all three carousel images. Returns
image/png with 7d immutable cache headers. 404 on invalid page
index, 403 on bad token, 404 on Redis miss, 503 on missing
signing secret. Render failure falls back to a 1x1 transparent
PNG so Telegram's sendMediaGroup doesn't 500 the brief.
scripts/seed-digest-notifications.mjs
carouselUrlsFrom(magazineUrl) derives the 3 signed carousel
URLs from the already-signed magazine URL. sendTelegramBriefCarousel
calls Telegram's sendMediaGroup with those URLs + short caption.
Runs before the existing sendTelegram(text) so the carousel is
the header and the text the body — long-form stories remain
forwardable as text. Best-effort: carousel failure doesn't
block text delivery.
package.json + package-lock.json
satori ^0.10.14 + @resvg/resvg-wasm ^2.6.2.
Tests (tests/brief-carousel.test.mjs, 9 cases):
- pageFromIndex mapping + out-of-range
- carouselUrlsFrom: valid URL, localhost origin preserved, missing
token, wrong path, invalid issueDate, garbage input
- Drift guard: cron must still declare the same helper + template
string. If it drifts, test fails with a pointer to move the impl
into a shared module.
PNG render itself isn't unit-tested — Satori + WASM need a
browser/edge runtime. Covered by smoke validation step in the
deploy monitoring plan.
Both tsconfigs typecheck clean. 152/152 brief tests pass.
Scope boundaries (deferred):
- Slack + Discord image attachments (different payload shapes)
- notification-relay.cjs brief_ready dispatch (real-time route)
- Redis caching of rendered PNG (edge Cache-Control is enough for
MVP)
* fix(brief): address two P1 review findings on Phase 8 carousel
P1-A: 200 placeholder PNG cached 7d on render failure.
Route config said runtime: 'edge' but a comment contradicted it
claiming Node semantics. More importantly, any render/init failure
(WASM load, Satori, Google Fonts) was converted to a 1x1 transparent
PNG returned with Cache-Control: public, max-age=7d, immutable.
Telegram's media fetcher and Vercel's CDN would cache that blank
for the full brief TTL per chat message — one cold-start mismatch
= every reader of that brief sees blank carousel previews for a
week.
Fix: deleted errorPng(). Render failure now returns 503 with
Cache-Control: no-store. sendMediaGroup fails cleanly for that
carousel (the digest cron already treats it as best-effort and
still sends the long-form text message), next cron tick re-renders
from a fresh isolate. Self-healing across ticks. Contradictory
comment about Node runtime removed.
P1-B: Google Fonts as silent hard dependency.
The renderer claimed 'safe embedded/fallback path' in comments but
no fallback existed. loadFont() fetches Noto Serif from gstatic.com
and rethrows on any failure. Combined with P1-A's old 200-cache-7d
path, a transient CDN blip would lock in a blank carousel for a
week.
Fix: updated comments to honestly declare the CDN dependency plus
document the self-healing semantics now that P1-A's fix no longer
caches the failure. If Google Fonts reliability becomes a problem,
swap the fetch for a bundled base64 TTF — noted as the escape hatch.
Tests (tests/brief-carousel.test.mjs): 2 new regression cases.
11/11 carousel tests pass. Both tsconfigs typecheck clean locally.
Note on currently-red CI: failures are NOT typecheck errors — npm
ci dies fetching libvips for sharp (504 Gateway Time-out from
GitHub releases). sharp is a transitive dep via @xenova/transformers,
pre-existing, not touched by this PR. Transient infra flake.
* fix(brief): switch carousel to Node + @resvg/resvg-js (fixes deploy block)
Vercel edge bundler fails the carousel deploy with:
'Edge Function is referencing unsupported modules:
@resvg/resvg-wasm/index_bg.wasm?url'
The ?url asset-import syntax is a Vite-ism that Vercel's edge
bundler doesn't resolve. Two ways out: find a Vercel-blessed edge
WASM import incantation, or switch to Node runtime with the native
@resvg/resvg-js binding. The second is simpler, faster per request,
and avoids the whole WASM-in-edge-bundler rabbit hole.
Changes:
- package.json: @resvg/resvg-wasm -> @resvg/resvg-js ^2.6.2
- api/brief/carousel/.../[page].ts: runtime 'edge' -> 'nodejs20.x'
- server/_shared/brief-carousel-render.ts: drop initWasm path,
dynamic-import resvg-js in ensureLibs(). Satori and resvg load
in parallel via Promise.all, shaving ~30ms off cold start.
Also addresses the P2 finding from review: the old ensureLibsAndWasm
had a concurrent-cold-start race where two callers could reach
'await initWasm()' simultaneously. Replaced the boolean flag with a
shared _libsLoadPromise so concurrent callers await the same load.
On failure the promise resets so the NEXT request retries rather
than poisoning the isolate for its lifetime.
Cold start ~700ms (Satori + resvg-js native init), warm ~40ms.
Carousel images are not latency-critical — fetched by Telegram's
media service, CDN-cached 7d.
Both tsconfigs typecheck clean. 11/11 carousel tests pass.
* fix(brief): carousel runtime = 'nodejs' (was 'nodejs20.x', rejected by Vercel)
Vercel's functions config validator rejects 'nodejs20.x' at deploy
time:
unsupported "runtime" value in config: "nodejs20.x"
(must be one of: ["edge","experimental-edge","nodejs"])
The Node version comes from the project's default (currently Node 20
via package.json engines + Vercel project settings), not from the
runtime string. Use 'nodejs' — unversioned — and let the platform
resolve it.
11/11 carousel tests pass.
* fix(brief): swap carousel font from woff2 to woff (Satori can't parse woff2)
Review on PR #3174: the FONT_URL was pointing at a gstatic.com woff2
file. Satori parses ttf / otf / woff v1 — NOT woff2. Every render
was about to throw on font decode, the route would return 503, and
the carousel would never deliver a single image.
Fix: point FONT_URL at @fontsource's Noto Serif Latin 400 WOFF v1
via jsdelivr. WOFF v1 is a TrueType wrapper that Satori parses
natively (verified: file says 'Web Open Font Format, TrueType,
version 1.1'). Same cold-start semantics as before — one fetch per
warm isolate, memoised.
Regression test: asserts FONT_URL ends in ttf/otf/woff and explicitly
rejects any .woff2 suffix. A future swap that silently reintroduces
woff2 now fails CI loudly instead of shipping a permanently-broken
renderer.
12/12 carousel tests pass. Both tsconfigs typecheck clean.
147 lines
7.6 KiB
JSON
147 lines
7.6 KiB
JSON
{
|
|
"name": "world-monitor",
|
|
"private": true,
|
|
"version": "2.6.7",
|
|
"license": "AGPL-3.0-only",
|
|
"type": "module",
|
|
"scripts": {
|
|
"lint": "biome lint ./src ./server ./api ./tests ./e2e ./scripts ./middleware.ts",
|
|
"lint:fix": "biome check ./src ./server ./api ./tests ./e2e ./scripts ./middleware.ts --fix",
|
|
"lint:boundaries": "node scripts/lint-boundaries.mjs",
|
|
"lint:unicode": "node scripts/check-unicode-safety.mjs",
|
|
"lint:unicode:staged": "node scripts/check-unicode-safety.mjs --staged",
|
|
"lint:md": "markdownlint-cli2 '**/*.md' '!**/node_modules/**' '!.agent/**' '!.agents/**' '!.claude/**' '!.factory/**' '!.windsurf/**' '!skills/**' '!docs/internal/**' '!docs/Docs_To_Review/**' '!todos/**' '!docs/plans/**'",
|
|
"version:sync": "node scripts/sync-desktop-version.mjs",
|
|
"version:check": "node scripts/sync-desktop-version.mjs --check",
|
|
"dev": "vite",
|
|
"dev:tech": "cross-env VITE_VARIANT=tech vite",
|
|
"dev:finance": "cross-env VITE_VARIANT=finance vite",
|
|
"dev:happy": "cross-env VITE_VARIANT=happy vite",
|
|
"dev:commodity": "cross-env VITE_VARIANT=commodity vite",
|
|
"postinstall": "cd blog-site && npm ci --prefer-offline",
|
|
"build:blog": "cd blog-site && npm run build && rm -rf ../public/blog && mkdir -p ../public/blog && cp -r dist/* ../public/blog/",
|
|
"build:pro": "cd pro-test && npm install && npm run build",
|
|
"build": "npm run build:blog && tsc && vite build",
|
|
"build:sidecar-sebuf": "node scripts/build-sidecar-sebuf.mjs",
|
|
"build:desktop": "node scripts/build-sidecar-sebuf.mjs && node scripts/build-sidecar-handlers.mjs && tsc && vite build",
|
|
"build:full": "npm run build:blog && cross-env-shell VITE_VARIANT=full \"tsc && vite build\"",
|
|
"build:tech": "cross-env-shell VITE_VARIANT=tech \"tsc && vite build\"",
|
|
"build:finance": "cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"",
|
|
"build:happy": "cross-env-shell VITE_VARIANT=happy \"tsc && vite build\"",
|
|
"build:commodity": "cross-env-shell VITE_VARIANT=commodity \"tsc && vite build\"",
|
|
"typecheck": "tsc --noEmit",
|
|
"typecheck:api": "tsc --noEmit -p tsconfig.api.json",
|
|
"typecheck:all": "tsc --noEmit && tsc --noEmit -p tsconfig.api.json",
|
|
"tauri": "tauri",
|
|
"preview": "vite preview",
|
|
"test:e2e:full": "cross-env VITE_VARIANT=full playwright test",
|
|
"test:e2e:tech": "cross-env VITE_VARIANT=tech playwright test",
|
|
"test:e2e:finance": "cross-env VITE_VARIANT=finance playwright test",
|
|
"test:e2e:runtime": "cross-env VITE_VARIANT=full playwright test e2e/runtime-fetch.spec.ts",
|
|
"test:e2e": "npm run test:e2e:runtime && npm run test:e2e:full && npm run test:e2e:tech && npm run test:e2e:finance",
|
|
"test:data": "tsx --test tests/*.test.mjs tests/*.test.mts",
|
|
"test:feeds": "node scripts/validate-rss-feeds.mjs",
|
|
"test:sidecar": "node --test src-tauri/sidecar/local-api-server.test.mjs api/_cors.test.mjs api/youtube/embed.test.mjs api/cyber-threats.test.mjs api/usni-fleet.test.mjs scripts/ais-relay-rss.test.cjs api/loaders-xml-wms-regression.test.mjs",
|
|
"test:e2e:visual:full": "cross-env VITE_VARIANT=full playwright test -g \"matches golden screenshots per layer and zoom\"",
|
|
"test:e2e:visual:tech": "cross-env VITE_VARIANT=tech playwright test -g \"matches golden screenshots per layer and zoom\"",
|
|
"test:e2e:visual": "npm run test:e2e:visual:full && npm run test:e2e:visual:tech",
|
|
"test:e2e:visual:update:full": "cross-env VITE_VARIANT=full playwright test -g \"matches golden screenshots per layer and zoom\" --update-snapshots",
|
|
"test:e2e:visual:update:tech": "cross-env VITE_VARIANT=tech playwright test -g \"matches golden screenshots per layer and zoom\" --update-snapshots",
|
|
"test:e2e:visual:update": "npm run test:e2e:visual:update:full && npm run test:e2e:visual:update:tech",
|
|
"desktop:dev": "npm run version:sync && cross-env VITE_DESKTOP_RUNTIME=1 tauri dev -f devtools",
|
|
"desktop:build:full": "npm run version:sync && cross-env VITE_VARIANT=full VITE_DESKTOP_RUNTIME=1 tauri build",
|
|
"desktop:build:tech": "npm run version:sync && cross-env VITE_VARIANT=tech VITE_DESKTOP_RUNTIME=1 tauri build --config src-tauri/tauri.tech.conf.json",
|
|
"desktop:build:finance": "npm run version:sync && cross-env VITE_VARIANT=finance VITE_DESKTOP_RUNTIME=1 tauri build --config src-tauri/tauri.finance.conf.json",
|
|
"desktop:package:macos:full": "node scripts/desktop-package.mjs --os macos --variant full",
|
|
"desktop:package:macos:tech": "node scripts/desktop-package.mjs --os macos --variant tech",
|
|
"desktop:package:windows:full": "node scripts/desktop-package.mjs --os windows --variant full",
|
|
"desktop:package:windows:tech": "node scripts/desktop-package.mjs --os windows --variant tech",
|
|
"desktop:package:macos:full:sign": "node scripts/desktop-package.mjs --os macos --variant full --sign",
|
|
"desktop:package:macos:tech:sign": "node scripts/desktop-package.mjs --os macos --variant tech --sign",
|
|
"desktop:package:windows:full:sign": "node scripts/desktop-package.mjs --os windows --variant full --sign",
|
|
"desktop:package:windows:tech:sign": "node scripts/desktop-package.mjs --os windows --variant tech --sign",
|
|
"desktop:package": "node scripts/desktop-package.mjs",
|
|
"test:convex": "vitest run --config vitest.config.mts",
|
|
"test:convex:watch": "vitest --config vitest.config.mts"
|
|
},
|
|
"devDependencies": {
|
|
"@biomejs/biome": "^2.4.7",
|
|
"@bufbuild/buf": "^1.66.0",
|
|
"@edge-runtime/vm": "^5.0.0",
|
|
"@playwright/test": "^1.52.0",
|
|
"@tauri-apps/cli": "^2.10.0",
|
|
"@types/canvas-confetti": "^1.9.0",
|
|
"@types/d3": "^7.4.3",
|
|
"@types/dompurify": "^3.0.5",
|
|
"@types/geojson": "^7946.0.14",
|
|
"@types/maplibre-gl": "^1.13.2",
|
|
"@types/marked": "^5.0.2",
|
|
"@types/papaparse": "^5.5.2",
|
|
"@types/supercluster": "^7.1.3",
|
|
"@types/three": "^0.183.1",
|
|
"@types/topojson-client": "^3.1.5",
|
|
"@types/topojson-specification": "^1.0.5",
|
|
"convex-test": "^0.0.43",
|
|
"cross-env": "^10.1.0",
|
|
"esbuild": "^0.27.3",
|
|
"exceljs": "^4.4.0",
|
|
"h3-js": "^4.4.0",
|
|
"markdownlint-cli2": "^0.21.0",
|
|
"tsx": "^4.21.0",
|
|
"typescript": "^5.7.2",
|
|
"vite": "^6.0.7",
|
|
"vite-plugin-pwa": "^1.2.0",
|
|
"vitest": "^4.1.0"
|
|
},
|
|
"dependencies": {
|
|
"@anthropic-ai/sdk": "^0.82.0",
|
|
"@aws-sdk/client-s3": "^3.1009.0",
|
|
"@clerk/clerk-js": "^5.56.0",
|
|
"@deck.gl/aggregation-layers": "^9.2.11",
|
|
"@deck.gl/core": "^9.2.11",
|
|
"@deck.gl/geo-layers": "^9.2.11",
|
|
"@deck.gl/layers": "^9.2.11",
|
|
"@deck.gl/mapbox": "^9.2.11",
|
|
"@dodopayments/convex": "^0.2.8",
|
|
"@protomaps/basemaps": "^5.7.1",
|
|
"@resvg/resvg-js": "^2.6.2",
|
|
"@sentry/browser": "^10.39.0",
|
|
"@upstash/ratelimit": "^2.0.8",
|
|
"@upstash/redis": "^1.36.1",
|
|
"@vercel/analytics": "^2.0.0",
|
|
"@xenova/transformers": "^2.17.2",
|
|
"canvas-confetti": "^1.9.4",
|
|
"convex": "^1.32.0",
|
|
"d3": "^7.9.0",
|
|
"deck.gl": "^9.2.11",
|
|
"dodopayments-checkout": "^1.8.0",
|
|
"dompurify": "^3.1.7",
|
|
"fast-xml-parser": "^5.3.7",
|
|
"globe.gl": "^2.45.0",
|
|
"hls.js": "^1.6.15",
|
|
"i18next": "^25.8.10",
|
|
"i18next-browser-languagedetector": "^8.2.1",
|
|
"jose": "^6.2.2",
|
|
"maplibre-gl": "^5.16.0",
|
|
"marked": "^17.0.3",
|
|
"onnxruntime-web": "^1.23.2",
|
|
"papaparse": "^5.5.3",
|
|
"pmtiles": "^4.4.0",
|
|
"preact": "^10.25.4",
|
|
"satellite.js": "^6.0.2",
|
|
"satori": "^0.10.14",
|
|
"supercluster": "^8.0.1",
|
|
"telegram": "^2.26.22",
|
|
"topojson-client": "^3.1.0",
|
|
"uqr": "^0.1.2",
|
|
"ws": "^8.19.0",
|
|
"youtubei.js": "^16.0.1"
|
|
},
|
|
"overrides": {
|
|
"fast-xml-parser": "^5.3.7",
|
|
"serialize-javascript": "^7.0.4",
|
|
"node-forge": "^1.4.0",
|
|
"srvx": "^0.11.13"
|
|
}
|
|
}
|