Files
worldmonitor/package.json
Elie Habib 4853645d53 fix(brief): switch carousel to @vercel/og on edge runtime (#3210)
* 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)
2026-04-19 15:18:12 +04:00

146 lines
7.5 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",
"@sentry/browser": "^10.39.0",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.36.1",
"@vercel/analytics": "^2.0.0",
"@vercel/og": "^0.11.1",
"@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",
"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"
}
}