From b2082c460195ebf0b83c844a41c4c1bd46418653 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 9 May 2026 20:46:29 +0700 Subject: [PATCH] fix: use Vercel classic Node runtime API instead of Web Standards Request Vercel `nodejs` runtime passes IncomingMessage/ServerResponse with shouldAddHelpers=true (auto-parsed JSON body, .status/.send helpers), not the Web Standards Request/Response. Calling `req.headers.get(...)` on the classic IncomingMessage threw `TypeError: req.headers.get is not a function` and crashed every webhook + cron invocation with 500. Switch both handlers to (req, res) signature, read headers as plain object (lowercased keys), use req.body for parsed JSON, and respond via res.status().send(). Caught during Phase 6 smoke test of the first prod deploy. --- api/cron.js | 18 ++++++++++-------- api/webhook.js | 35 +++++++++++++++++++---------------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/api/cron.js b/api/cron.js index 3ff9739..4e1faf6 100644 --- a/api/cron.js +++ b/api/cron.js @@ -1,21 +1,23 @@ // Daily check entry. Triggered by Vercel Cron at 00:00 UTC = 07:00 Asia/Saigon -// (schedule lives in vercel.json). Replaces the prior Cloudflare Worker -// `scheduled` handler. Validates the Authorization: Bearer $CRON_SECRET header -// to prevent random POSTs from triggering the daily check. +// (schedule lives in vercel.json). Validates the Authorization: Bearer +// $CRON_SECRET header to prevent random POSTs from triggering the daily check. +// +// Vercel's `nodejs` runtime passes IncomingMessage/ServerResponse — req.headers +// is a plain object with lowercased keys, not a Web Headers instance. import { buildApp } from '../src/app-builder.js'; import { runDailyCheck } from '../src/scheduler/scheduler.js'; export const config = { runtime: 'nodejs', maxDuration: 60 }; -export default async function handler(req) { +export default async function handler(req, res) { // Fail closed if CRON_SECRET is unset — otherwise the comparison would be // `Bearer undefined`, which an attacker could replay as a literal string // and bypass auth. const expected = process.env.CRON_SECRET; - const auth = req.headers.get('authorization'); + const auth = req.headers['authorization']; if (!expected || auth !== `Bearer ${expected}`) { - return new Response('Unauthorized', { status: 401 }); + return res.status(401).send('Unauthorized'); } let app; @@ -23,10 +25,10 @@ export default async function handler(req) { app = buildApp(process.env); } catch (err) { console.log(JSON.stringify({ level: 'error', msg: 'config error', err: err.message })); - return new Response('Server misconfigured', { status: 500 }); + return res.status(500).send('Server misconfigured'); } // Cron runs synchronously up to maxDuration; no waitUntil needed. await runDailyCheck(app.config, app.store, app.sender, app.appleScraper, app.googleScraper); - return new Response('OK'); + return res.status(200).send('OK'); } diff --git a/api/webhook.js b/api/webhook.js index e37f4e5..0a5d8e0 100644 --- a/api/webhook.js +++ b/api/webhook.js @@ -1,7 +1,11 @@ -// Telegram webhook entry. Vercel serverless function — replaces the prior -// Cloudflare Worker `fetch` handler. Validates the X-Telegram-Bot-Api-Secret-Token -// header, acks fast, then dispatches in waitUntil so Telegram doesn't retry on -// slow downstream calls. +// Telegram webhook entry. Vercel serverless function (classic Node runtime). +// Validates the X-Telegram-Bot-Api-Secret-Token header, acks fast, then +// dispatches in waitUntil so Telegram doesn't retry on slow downstream calls. +// +// Vercel's `nodejs` runtime passes IncomingMessage/ServerResponse with +// `shouldAddHelpers: true` (so req.body is auto-parsed JSON and res has +// .status/.send helpers). req.headers is a plain object — header keys are +// lowercased. import { waitUntil } from '@vercel/functions'; import { buildApp } from '../src/app-builder.js'; @@ -9,9 +13,9 @@ import { dispatch } from '../src/bot/dispatch.js'; export const config = { runtime: 'nodejs' }; -export default async function handler(req) { +export default async function handler(req, res) { if (req.method !== 'POST') { - return new Response('Not found', { status: 404 }); + return res.status(404).send('Not found'); } let app; @@ -19,21 +23,20 @@ export default async function handler(req) { app = buildApp(process.env); } catch (err) { console.log(JSON.stringify({ level: 'error', msg: 'config error', err: err.message })); - return new Response('Server misconfigured', { status: 500 }); + return res.status(500).send('Server misconfigured'); } - const secret = req.headers.get('x-telegram-bot-api-secret-token'); + const secret = req.headers['x-telegram-bot-api-secret-token']; if (secret !== app.config.telegramWebhookSecret) { - return new Response('Unauthorized', { status: 401 }); + return res.status(401).send('Unauthorized'); } - let update; - try { - update = await req.json(); - } catch { - return new Response('Bad request', { status: 400 }); + // req.body is auto-parsed by Vercel helpers when Content-Type is JSON. + // Falsy / non-object guards mirror the prior CF handler. + const update = req.body; + if (!update || typeof update !== 'object' || !update.message) { + return res.status(200).send('OK'); } - if (!update?.message) return new Response('OK'); waitUntil( dispatch(update.message, { @@ -43,5 +46,5 @@ export default async function handler(req) { logger: app.config.logger, }), ); - return new Response('OK'); + return res.status(200).send('OK'); }