mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-26 01:24:59 +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)
206 lines
9.7 KiB
JSON
206 lines
9.7 KiB
JSON
{
|
|
"ignoreCommand": "bash scripts/vercel-ignore.sh",
|
|
"crons": [],
|
|
"redirects": [
|
|
{ "source": "/docs", "destination": "/docs/documentation", "permanent": false }
|
|
],
|
|
"rewrites": [
|
|
{ "source": "/docs/:match*", "destination": "https://worldmonitor.mintlify.dev/docs/:match*" },
|
|
{ "source": "/pro", "destination": "/pro/index.html" },
|
|
{ "source": "/mcp", "destination": "/api/mcp" },
|
|
{ "source": "/oauth/token", "destination": "/api/oauth/token" },
|
|
{ "source": "/oauth/register", "destination": "/api/oauth/register" },
|
|
{ "source": "/oauth/authorize", "destination": "/api/oauth/authorize" },
|
|
{ "source": "/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known|wm-widget-sandbox\\.html).*)", "destination": "/index.html" }
|
|
],
|
|
"headers": [
|
|
{
|
|
"source": "/api/(.*)",
|
|
"headers": [
|
|
{ "key": "Access-Control-Allow-Origin", "value": "*" },
|
|
{ "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" },
|
|
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization, X-WorldMonitor-Key, X-Widget-Key, X-Pro-Key" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/mcp",
|
|
"headers": [
|
|
{ "key": "Access-Control-Allow-Origin", "value": "*" },
|
|
{ "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" },
|
|
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization, X-WorldMonitor-Key" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/oauth/(.*)",
|
|
"headers": [
|
|
{ "key": "Access-Control-Allow-Origin", "value": "*" },
|
|
{ "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" },
|
|
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/.well-known/oauth-protected-resource",
|
|
"headers": [
|
|
{ "key": "Content-Type", "value": "application/json" },
|
|
{ "key": "Access-Control-Allow-Origin", "value": "*" },
|
|
{ "key": "Cache-Control", "value": "public, max-age=3600" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/.well-known/oauth-authorization-server",
|
|
"headers": [
|
|
{ "key": "Content-Type", "value": "application/json" },
|
|
{ "key": "Access-Control-Allow-Origin", "value": "*" },
|
|
{ "key": "Cache-Control", "value": "public, max-age=3600" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/.well-known/(.*)",
|
|
"headers": [
|
|
{ "key": "Access-Control-Allow-Origin", "value": "*" },
|
|
{ "key": "Cache-Control", "value": "public, max-age=3600" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/docs/:path*",
|
|
"headers": [
|
|
{ "key": "X-Content-Type-Options", "value": "nosniff" },
|
|
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" },
|
|
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/((?!docs).*)",
|
|
"headers": [
|
|
{ "key": "X-Content-Type-Options", "value": "nosniff" },
|
|
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
|
|
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" },
|
|
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
|
|
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(self), accelerometer=(), autoplay=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), bluetooth=(), display-capture=(), encrypted-media=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), midi=(), payment=(self \"https://checkout.dodopayments.com\" \"https://test.checkout.dodopayments.com\" \"https://pay.google.com\" \"https://hooks.stripe.com\" \"https://js.stripe.com\"), picture-in-picture=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\" \"https://challenges.cloudflare.com\"), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=(\"https://challenges.cloudflare.com\")" },
|
|
{ "key": "Content-Security-Policy", "value": "default-src 'self'; connect-src 'self' https: wss: blob: data: https://*.ingest.sentry.io https://*.ingest.us.sentry.io; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'sha256-LnMFPWZxTgVOr2VYwIh9mhQ3l/l3+a3SfNOLERnuHfY=' 'sha256-4Z2xtr1B9QQugoojE/nbpOViG+8l2B7CZVlKgC78AeQ=' 'sha256-903UI9my1I7mqHoiVeZSc56yd50YoRJTB2269QqL76w=' 'sha256-EytE6o1N8rwzpVFMrF+WvBZr2y5UhFLw79o1/4VqS0s=' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://challenges.cloudflare.com https://*.clerk.accounts.dev https://abacus.worldmonitor.app https://*.dodopayments.com https://js.stripe.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://www.google.com https://webcams.windy.com https://challenges.cloudflare.com https://*.clerk.accounts.dev https://vercel.live https://*.vercel.app https://*.dodopayments.com https://pay.google.com https://hooks.stripe.com https://js.stripe.com; frame-ancestors 'self' https://www.worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://worldmonitor.app https://vercel.live https://*.vercel.app; base-uri 'self'; object-src 'none'; form-action 'self' https://api.worldmonitor.app" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/api/slack/oauth/callback",
|
|
"headers": [
|
|
{ "key": "Content-Security-Policy", "value": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/api/discord/oauth/callback",
|
|
"headers": [
|
|
{ "key": "Content-Security-Policy", "value": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/api/brief/(.*)",
|
|
"headers": [
|
|
{ "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' https://cloudflareinsights.com; frame-ancestors 'self' https://www.worldmonitor.app https://worldmonitor.app; base-uri 'self'; object-src 'none'; form-action 'none'" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/index.html",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known|wm-widget-sandbox\\.html).*)",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/assets/(.*)",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/blog/_astro/(.*)",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/pro/assets/(.*)",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/pro/:path*",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/pro",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/favico/(.*)",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "public, max-age=604800" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/map-styles/(.*)",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/data/(.*)",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/textures/(.*)",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/offline.html",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "public, max-age=86400" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/workbox-:hash.js",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/sw.js",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/manifest.webmanifest",
|
|
"headers": [
|
|
{ "key": "Cache-Control", "value": "public, max-age=86400" }
|
|
]
|
|
},
|
|
{
|
|
"source": "/wm-widget-sandbox.html",
|
|
"headers": [
|
|
{ "key": "Content-Security-Policy", "value": "default-src 'none'; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://static.cloudflareinsights.com; style-src 'unsafe-inline'; img-src data:; connect-src https://cdn.jsdelivr.net;" },
|
|
{ "key": "Cache-Control", "value": "public, max-age=86400" }
|
|
]
|
|
}
|
|
]
|
|
}
|