diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..c9ce1fc --- /dev/null +++ b/examples/README.md @@ -0,0 +1,29 @@ +# Tern Framework Examples + +All examples are standalone packages and depend on the local repo build via `"@hookflo/tern": "../../"`. + +## Setup handler with Tern CLI (recommended) + +```bash +npx @hookflo/tern-cli +``` + +Then choose framework + Stripe platform, set `STRIPE_WEBHOOK_SECRET`, and use generated file path for your framework. + +## Quick starts + +- **Hono**: `cd examples/hono && npm install && STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev` +- **Next.js**: `cd examples/nextjs && npm install && STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev` +- **Cloudflare Workers**: `cd examples/cloudflare-workers && npm install && STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev` +- **Express**: `cd examples/express && npm install && STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev` + +## Stripe CLI testing + +```bash +stripe listen --forward-to localhost:3000/webhooks/stripe +stripe trigger payment_intent.succeeded +``` + +Use the printed `whsec_...` secret as `STRIPE_WEBHOOK_SECRET`. + +See per-example READMEs for deployment notes. diff --git a/examples/cloudflare-workers/README.md b/examples/cloudflare-workers/README.md new file mode 100644 index 0000000..44b84eb --- /dev/null +++ b/examples/cloudflare-workers/README.md @@ -0,0 +1,38 @@ +# Tern + Cloudflare Workers Example + +## Setup handler with Tern CLI + +```bash +npx @hookflo/tern-cli +``` + +Choose: +- Framework: `Cloudflare Workers` +- Platform: `Stripe` +- Env var: `STRIPE_WEBHOOK_SECRET` +- Handler path: `examples/cloudflare-workers/src/index.ts` + +## Manual setup + +```bash +cd examples/cloudflare-workers +npm install +STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev +``` + +Endpoint: `POST http://localhost:8787/webhooks/stripe` + +## Local test with Stripe CLI + +```bash +stripe listen --forward-to localhost:8787/webhooks/stripe +stripe trigger payment_intent.succeeded +``` + +## Deploy + +```bash +cd examples/cloudflare-workers +wrangler secret put STRIPE_WEBHOOK_SECRET +wrangler deploy +``` diff --git a/examples/cloudflare-workers/package.json b/examples/cloudflare-workers/package.json new file mode 100644 index 0000000..94a1c39 --- /dev/null +++ b/examples/cloudflare-workers/package.json @@ -0,0 +1,17 @@ +{ + "name": "tern-example-cloudflare-workers", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@hookflo/tern": "../../" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250430.0", + "typescript": "^5.8.3", + "wrangler": "^4.13.2" + } +} diff --git a/examples/cloudflare-workers/src/index.ts b/examples/cloudflare-workers/src/index.ts new file mode 100644 index 0000000..c0471fb --- /dev/null +++ b/examples/cloudflare-workers/src/index.ts @@ -0,0 +1,34 @@ +import { WebhookVerificationService } from '@hookflo/tern'; + +interface Env { + STRIPE_WEBHOOK_SECRET: string; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/' && request.method === 'GET') { + return Response.json({ status: 'ok', framework: 'cloudflare-workers' }); + } + + if (url.pathname === '/webhooks/stripe' && request.method === 'POST') { + // Workers use the standard Web API Request — tern works natively. + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'stripe', + env.STRIPE_WEBHOOK_SECRET, + ); + + if (!result.isValid) { + console.error('❌ Verification failed:', result.error); + return Response.json({ error: result.error }, { status: 400 }); + } + + console.log('✅ Verified. Event type:', result.payload?.type); + return Response.json({ received: true }); + } + + return Response.json({ error: 'Not found' }, { status: 404 }); + }, +}; diff --git a/examples/cloudflare-workers/tsconfig.json b/examples/cloudflare-workers/tsconfig.json new file mode 100644 index 0000000..20ade48 --- /dev/null +++ b/examples/cloudflare-workers/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2021"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/cloudflare-workers/wrangler.toml b/examples/cloudflare-workers/wrangler.toml new file mode 100644 index 0000000..bf5774a --- /dev/null +++ b/examples/cloudflare-workers/wrangler.toml @@ -0,0 +1,7 @@ +name = "tern-example-workers" +main = "src/index.ts" +compatibility_date = "2024-01-01" + +# Set your real secret with: wrangler secret put STRIPE_WEBHOOK_SECRET +[vars] +STRIPE_WEBHOOK_SECRET = "" diff --git a/examples/express/README.md b/examples/express/README.md new file mode 100644 index 0000000..d54cb54 --- /dev/null +++ b/examples/express/README.md @@ -0,0 +1,35 @@ +# Tern + Express Example + +## Setup handler with Tern CLI + +```bash +npx @hookflo/tern-cli +``` + +Choose: +- Framework: `Express` +- Platform: `Stripe` +- Env var: `STRIPE_WEBHOOK_SECRET` +- Handler path: `examples/express/src/index.ts` + +## Manual setup + +```bash +cd examples/express +npm install +STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev +``` + +Endpoint: `POST http://localhost:3002/webhooks/stripe` + +## Local test with Stripe CLI + +```bash +stripe listen --forward-to localhost:3002/webhooks/stripe +stripe trigger payment_intent.succeeded +``` + +## Deploy notes + +- Set `STRIPE_WEBHOOK_SECRET` in your host env vars. +- Keep webhook route registration before `app.use(express.json())`. diff --git a/examples/express/package.json b/examples/express/package.json new file mode 100644 index 0000000..6a52bf1 --- /dev/null +++ b/examples/express/package.json @@ -0,0 +1,19 @@ +{ + "name": "tern-example-express", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@hookflo/tern": "../../", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/express": "^5.0.1", + "tsx": "^4.19.3", + "typescript": "^5.8.3" + } +} diff --git a/examples/express/src/index.ts b/examples/express/src/index.ts new file mode 100644 index 0000000..4133a34 --- /dev/null +++ b/examples/express/src/index.ts @@ -0,0 +1,66 @@ +import express from 'express'; +import { createWebhookMiddleware } from '@hookflo/tern/express'; + +const app = express(); + +// ─── CRITICAL ORDER ─────────────────────────────────────────────────────────── +// Register the webhook route BEFORE app.use(express.json()). +// If express.json() runs first it consumes the raw body and signature +// verification will always fail — tern cannot recover the original bytes. +// ───────────────────────────────────────────────────────────────────────────── + +app.get('/', (_req, res) => { + res.json({ status: 'ok', framework: 'express' }); +}); + +// Recommended: use the tern Express adapter. +// It applies express.raw() semantics internally by reading the raw stream/body +// and attaches the verified payload to req.webhook before calling next(). +app.post( + '/webhooks/stripe', + createWebhookMiddleware({ + platform: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET ?? '', + }), + (req, res) => { + const event = (req as any).webhook?.payload; + console.log('✅ Verified. Event type:', event?.type); + res.json({ received: true }); + }, +); + +// ── Alternative (no adapter) ────────────────────────────────────────────────── +// If you want to use WebhookVerificationService directly, reconstruct a +// Web API Request from the raw buffer that express.raw() gives you: +// +// import { WebhookVerificationService } from '@hookflo/tern' +// +// app.post( +// '/webhooks/stripe/raw', +// express.raw({ type: '*/*' }), // <-- must come before express.json() +// async (req, res) => { +// const webRequest = new Request('https://example.com/webhooks/stripe', { +// method: 'POST', +// headers: req.headers as Record, +// body: req.body, // Buffer from express.raw() +// }) +// const result = await WebhookVerificationService.verifyWithPlatformConfig( +// webRequest, +// 'stripe', +// process.env.STRIPE_WEBHOOK_SECRET ?? '' +// ) +// if (!result.isValid) return res.status(400).json({ error: result.error }) +// res.json({ received: true }) +// } +// ) +// ───────────────────────────────────────────────────────────────────────────── + +// Global JSON parser — AFTER the webhook route +app.use(express.json()); + +app.get('/health', (_req, res) => res.json({ ok: true })); + +const port = Number(process.env.PORT ?? 3002); +app.listen(port, () => + console.log(`🚀 Express running at http://localhost:${port}`), +); diff --git a/examples/express/tsconfig.json b/examples/express/tsconfig.json new file mode 100644 index 0000000..4ab9c55 --- /dev/null +++ b/examples/express/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/examples/hono/README.md b/examples/hono/README.md new file mode 100644 index 0000000..ebebfeb --- /dev/null +++ b/examples/hono/README.md @@ -0,0 +1,35 @@ +# Tern + Hono Example + +## Setup handler with Tern CLI + +```bash +npx @hookflo/tern-cli +``` + +Choose: +- Framework: `Hono` +- Platform: `Stripe` +- Env var: `STRIPE_WEBHOOK_SECRET` +- Handler path: `examples/hono/src/index.ts` + +## Manual setup + +```bash +cd examples/hono +npm install +STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev +``` + +Endpoint: `POST http://localhost:3000/webhooks/stripe` + +## Local test with Stripe CLI + +```bash +stripe listen --forward-to localhost:3000/webhooks/stripe +stripe trigger payment_intent.succeeded +``` + +## Deploy notes + +- This example runs on Node via `@hono/node-server`. +- Deploy to any Node host (Railway, Render, Fly.io, etc.) and set `STRIPE_WEBHOOK_SECRET`. diff --git a/examples/hono/package.json b/examples/hono/package.json new file mode 100644 index 0000000..8af8328 --- /dev/null +++ b/examples/hono/package.json @@ -0,0 +1,19 @@ +{ + "name": "tern-example-hono", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@hookflo/tern": "../../", + "@hono/node-server": "^1.13.7", + "hono": "^4.7.10" + }, + "devDependencies": { + "tsx": "^4.19.3", + "typescript": "^5.8.3" + } +} diff --git a/examples/hono/src/index.ts b/examples/hono/src/index.ts new file mode 100644 index 0000000..eaa0fd3 --- /dev/null +++ b/examples/hono/src/index.ts @@ -0,0 +1,30 @@ +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { WebhookVerificationService } from '@hookflo/tern'; + +const app = new Hono(); + +app.get('/', (c) => c.json({ status: 'ok', framework: 'hono' })); + +app.post('/webhooks/stripe', async (c) => { + // IMPORTANT: pass c.req.raw — the native Web API Request object. + // Never call c.req.json() or c.req.text() before this; it consumes the stream. + const result = await WebhookVerificationService.verifyWithPlatformConfig( + c.req.raw, + 'stripe', + process.env.STRIPE_WEBHOOK_SECRET ?? '', + ); + + if (!result.isValid) { + console.error('❌ Verification failed:', result.error); + return c.json({ error: result.error }, 400); + } + + console.log('✅ Verified. Event type:', result.payload?.type); + return c.json({ received: true }); +}); + +const port = Number(process.env.PORT ?? 3000); +serve({ fetch: app.fetch, port }, () => + console.log(`🚀 Hono running at http://localhost:${port}`), +); diff --git a/examples/hono/tsconfig.json b/examples/hono/tsconfig.json new file mode 100644 index 0000000..4ab9c55 --- /dev/null +++ b/examples/hono/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md new file mode 100644 index 0000000..5a6ed99 --- /dev/null +++ b/examples/nextjs/README.md @@ -0,0 +1,35 @@ +# Tern + Next.js (App Router) Example + +## Setup handler with Tern CLI + +```bash +npx @hookflo/tern-cli +``` + +Choose: +- Framework: `Next.js (App Router)` +- Platform: `Stripe` +- Env var: `STRIPE_WEBHOOK_SECRET` +- Handler path: `examples/nextjs/app/api/webhooks/route.ts` + +## Manual setup + +```bash +cd examples/nextjs +npm install +STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev +``` + +Endpoint: `POST http://localhost:3001/api/webhooks` + +## Local test with Stripe CLI + +```bash +stripe listen --forward-to localhost:3001/api/webhooks +stripe trigger payment_intent.succeeded +``` + +## Deploy notes + +- Deploy to Vercel or any Node-compatible host for Next.js. +- Add `STRIPE_WEBHOOK_SECRET` in project environment variables. diff --git a/examples/nextjs/app/api/webhooks/route.ts b/examples/nextjs/app/api/webhooks/route.ts new file mode 100644 index 0000000..53cfb3b --- /dev/null +++ b/examples/nextjs/app/api/webhooks/route.ts @@ -0,0 +1,13 @@ +import { createWebhookHandler } from '@hookflo/tern/nextjs'; + +// createWebhookHandler wraps the App Router POST export. +// It reads the raw body correctly — no need to call req.json() yourself. +export const POST = createWebhookHandler({ + platform: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, + handler: async (payload) => { + console.log('✅ Verified. Event type:', payload?.type); + // Return value is sent as JSON with status 200 + return { received: true }; + }, +}); diff --git a/examples/nextjs/next-env.d.ts b/examples/nextjs/next-env.d.ts new file mode 100644 index 0000000..1b3be08 --- /dev/null +++ b/examples/nextjs/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json new file mode 100644 index 0000000..dc14e04 --- /dev/null +++ b/examples/nextjs/package.json @@ -0,0 +1,21 @@ +{ + "name": "tern-example-nextjs", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start --port 3001" + }, + "dependencies": { + "@hookflo/tern": "../../", + "next": "^15.3.1", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "@types/react": "^19.1.2", + "typescript": "^5.8.3" + } +} diff --git a/examples/nextjs/tsconfig.json b/examples/nextjs/tsconfig.json new file mode 100644 index 0000000..3a9b24c --- /dev/null +++ b/examples/nextjs/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/src/adapters/express.ts b/src/adapters/express.ts index 6b57025..c9ea7e8 100644 --- a/src/adapters/express.ts +++ b/src/adapters/express.ts @@ -5,7 +5,12 @@ import { import { WebhookVerificationService } from '../index'; import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue'; import { QueueOption } from '../upstash/types'; -import { toWebRequest, MinimalNodeRequest, hasParsedBody } from './shared'; +import { + toWebRequest, + MinimalNodeRequest, + hasParsedBody, + hasUsableRawBody, +} from './shared'; import { dispatchWebhookAlert } from '../notifications/dispatch'; import type { AlertConfig, SendAlertOptions } from '../notifications/types'; @@ -41,7 +46,7 @@ export function createWebhookMiddleware( ): Promise => { try { const strictRawBody = options.strictRawBody ?? true; - if (strictRawBody && hasParsedBody(req)) { + if (strictRawBody && hasParsedBody(req) && !hasUsableRawBody(req)) { res.status(400).json({ error: 'Webhook request body must be raw bytes. Configure express.raw({ type: "*/*" }) before this middleware.', errorCode: 'VERIFICATION_ERROR', diff --git a/src/adapters/shared.ts b/src/adapters/shared.ts index 9861980..3e82060 100644 --- a/src/adapters/shared.ts +++ b/src/adapters/shared.ts @@ -2,6 +2,7 @@ export interface MinimalNodeRequest { method?: string; headers: Record; body?: unknown; + rawBody?: unknown; protocol?: string; get?: (name: string) => string | undefined; originalUrl?: string; @@ -31,6 +32,20 @@ export function hasParsedBody( && !(body instanceof ArrayBuffer); } +function hasRawByteBody( + body: unknown, +): boolean { + return body instanceof Uint8Array + || body instanceof ArrayBuffer + || typeof body === 'string'; +} + +export function hasUsableRawBody( + request: MinimalNodeRequest, +): boolean { + return hasRawByteBody(request.rawBody) || hasRawByteBody(request.body); +} + async function readIncomingMessageBodyAsBuffer( request: MinimalNodeRequest, ): Promise { @@ -69,7 +84,7 @@ async function readIncomingMessageBodyAsBuffer( export async function extractRawBody( request: MinimalNodeRequest, ): Promise { - const { body } = request; + const body = request.rawBody ?? request.body; if (body instanceof Uint8Array) { return body;