diff --git a/apps/public/app/api/[...op]/route.ts b/apps/public/app/api/[...op]/route.ts index 82945e54..8c9ad4a3 100644 --- a/apps/public/app/api/[...op]/route.ts +++ b/apps/public/app/api/[...op]/route.ts @@ -1,7 +1,5 @@ -import { - createNextRouteHandler, - createScriptHandler, -} from '@openpanel/nextjs/server'; +import { createRouteHandler } from '@openpanel/nextjs/server'; -export const POST = createNextRouteHandler(); -export const GET = createScriptHandler(); +const routeHandler = createRouteHandler(); +export const GET = routeHandler; +export const POST = routeHandler; diff --git a/apps/public/app/test/page.tsx b/apps/public/app/test/page.tsx new file mode 100644 index 00000000..36330dd5 --- /dev/null +++ b/apps/public/app/test/page.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { useOpenPanel } from '@openpanel/nextjs'; + +export default function TestPage() { + const op = useOpenPanel(); + return ( +
+

Test Page

+ + + +
+ ); +} diff --git a/apps/public/content/docs/(tracking)/sdks/nextjs.mdx b/apps/public/content/docs/(tracking)/sdks/nextjs.mdx index 1f0ad37f..8afef4a5 100644 --- a/apps/public/content/docs/(tracking)/sdks/nextjs.mdx +++ b/apps/public/content/docs/(tracking)/sdks/nextjs.mdx @@ -273,13 +273,16 @@ export function GET() { ### Proxy events -With `createNextRouteHandler` you can proxy your events through your server, this will ensure all events are tracked since there is a lot of adblockers that block requests to third party domains. You'll also need to either host our tracking script or you can use `createScriptHandler` function which proxies this as well. +With `createRouteHandler` you can proxy your events through your server, this will ensure all events are tracked since there is a lot of adblockers that block requests to third party domains. The handler automatically routes requests based on the path, supporting both API endpoints (like `/track` and `/track/device-id`) and the tracking script (`/op1.js`). ```typescript title="/app/api/[...op]/route.ts" -import { createNextRouteHandler, createScriptHandler } from '@openpanel/nextjs/server'; +import { createRouteHandler } from '@openpanel/nextjs/server'; -export const POST = createNextRouteHandler(); -export const GET = createScriptHandler() +const routeHandler = createRouteHandler(); + +// Export the same handler for all HTTP methods - it routes internally based on pathname +export const GET = routeHandler; +export const POST = routeHandler; ``` Remember to change the `apiUrl` and `cdnUrl` in the `OpenPanelComponent` to your own server. diff --git a/apps/public/package.json b/apps/public/package.json index 9eeb79b2..231f4690 100644 --- a/apps/public/package.json +++ b/apps/public/package.json @@ -14,7 +14,7 @@ "@hyperdx/node-opentelemetry": "^0.8.1", "@number-flow/react": "0.3.5", "@openpanel/common": "workspace:*", - "@openpanel/nextjs": "^1.0.17", + "@openpanel/nextjs": "^1.1.0", "@openpanel/payments": "workspace:^", "@openpanel/sdk-info": "workspace:^", "@openstatus/react": "0.0.3", diff --git a/packages/sdks/nextjs/createNextRouteHandler.ts b/packages/sdks/nextjs/createNextRouteHandler.ts index 35dccdcc..41e91ab3 100644 --- a/packages/sdks/nextjs/createNextRouteHandler.ts +++ b/packages/sdks/nextjs/createNextRouteHandler.ts @@ -3,80 +3,141 @@ import { createHash } from 'node:crypto'; // with esm and nextjs (when using pages dir) import { NextResponse } from 'next/server.js'; -type CreateNextRouteHandlerOptions = { +type RouteHandlerOptions = { apiUrl?: string; }; -export function createNextRouteHandler( - options?: CreateNextRouteHandlerOptions, -) { - return async function POST(req: Request) { - const apiUrl = options?.apiUrl ?? 'https://api.openpanel.dev'; - const headers = new Headers(); +const DEFAULT_API_URL = 'https://api.openpanel.dev'; +const SCRIPT_URL = 'https://openpanel.dev'; +const SCRIPT_PATH = '/op1.js'; - const ip = - req.headers.get('cf-connecting-ip') ?? - req.headers.get('x-forwarded-for')?.split(',')[0] ?? - req.headers.get('x-vercel-forwarded-for'); - headers.set('Content-Type', 'application/json'); - headers.set( - 'openpanel-client-id', - req.headers.get('openpanel-client-id') ?? '', - ); - headers.set('origin', req.headers.get('origin') ?? ''); - headers.set('User-Agent', req.headers.get('user-agent') ?? ''); - if (ip) { - headers.set('openpanel-client-ip', ip); - } +function getClientHeaders(req: Request): Headers { + const headers = new Headers(); + const ip = + req.headers.get('cf-connecting-ip') ?? + req.headers.get('x-forwarded-for')?.split(',')[0] ?? + req.headers.get('x-vercel-forwarded-for'); - try { - const res = await fetch(`${apiUrl}/track`, { - method: 'POST', - headers, - body: JSON.stringify(await req.json()), - }); - return NextResponse.json(await res.text(), { status: res.status }); - } catch (e) { - return NextResponse.json(e); - } - }; + headers.set('Content-Type', 'application/json'); + headers.set( + 'openpanel-client-id', + req.headers.get('openpanel-client-id') ?? '', + ); + + // Construct origin: browsers send Origin header for POST requests and cross-origin requests, + // but not for same-origin GET requests. Fallback to constructing from request URL. + const origin = + req.headers.get('origin') ?? + (() => { + const url = new URL(req.url); + return `${url.protocol}//${url.host}`; + })(); + headers.set('origin', origin); + + headers.set('User-Agent', req.headers.get('user-agent') ?? ''); + if (ip) { + headers.set('openpanel-client-ip', ip); + } + + return headers; } -export function createScriptHandler() { - return async function GET(req: Request) { - const url = new URL(req.url); - const query = url.searchParams.toString(); +async function handleApiRoute( + req: Request, + apiUrl: string, + apiPath: string, +): Promise { + const headers = getClientHeaders(req); - if (!url.pathname.endsWith('op1.js')) { + try { + const res = await fetch(`${apiUrl}${apiPath}`, { + method: req.method, + headers, + body: + req.method === 'POST' ? JSON.stringify(await req.json()) : undefined, + }); + + if (res.headers.get('content-type')?.includes('application/json')) { + return NextResponse.json(await res.json(), { status: res.status }); + } + return NextResponse.json(await res.text(), { status: res.status }); + } catch (e) { + return NextResponse.json( + { + error: 'Failed to proxy request', + message: e instanceof Error ? e.message : String(e), + }, + { status: 500 }, + ); + } +} + +async function handleScriptProxyRoute(req: Request): Promise { + const url = new URL(req.url); + const pathname = url.pathname; + + if (!pathname.endsWith(SCRIPT_PATH)) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + let scriptUrl = `${SCRIPT_URL}${SCRIPT_PATH}`; + if (url.searchParams.size > 0) { + scriptUrl += `?${url.searchParams.toString()}`; + } + + try { + const res = await fetch(scriptUrl, { + // @ts-ignore + next: { revalidate: 86400 }, + }); + const text = await res.text(); + const etag = `"${createHash('md5') + .update(scriptUrl + text) + .digest('hex')}"`; + + return new NextResponse(text, { + headers: { + 'Content-Type': 'text/javascript', + 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=86400', + ETag: etag, + }, + }); + } catch (e) { + return NextResponse.json( + { + error: 'Failed to fetch script', + message: e instanceof Error ? e.message : String(e), + }, + { status: 500 }, + ); + } +} + +function createRouteHandler(options?: RouteHandlerOptions) { + const apiUrl = options?.apiUrl ?? DEFAULT_API_URL; + + return async function handler(req: Request): Promise { + const url = new URL(req.url); + const pathname = url.pathname; + const method = req.method; + + // Handle script proxy: GET /op1.js + if (method === 'GET' && pathname.endsWith(SCRIPT_PATH)) { + return handleScriptProxyRoute(req); + } + + const apiPathMatch = pathname.indexOf('/track'); + if (apiPathMatch === -1) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - const scriptUrl = 'https://openpanel.dev/op1.js'; - try { - const res = await fetch(scriptUrl, { - // @ts-ignore - next: { revalidate: 86400 }, - }); - const text = await res.text(); - const etag = `"${createHash('md5') - .update(text + query) - .digest('hex')}"`; - return new NextResponse(text, { - headers: { - 'Content-Type': 'text/javascript', - 'Cache-Control': - 'public, max-age=86400, stale-while-revalidate=86400', - ETag: etag, - }, - }); - } catch (e) { - return NextResponse.json( - { - error: 'Failed to fetch script', - message: e instanceof Error ? e.message : String(e), - }, - { status: 500 }, - ); - } + const apiPath = pathname.substring(apiPathMatch); + return handleApiRoute(req, apiUrl, apiPath); }; } + +export { createRouteHandler }; + +// const routeHandler = createRouteHandler(); +// export const GET = routeHandler; +// export const POST = routeHandler; diff --git a/packages/sdks/nextjs/index.tsx b/packages/sdks/nextjs/index.tsx index 123ac958..56e22cce 100644 --- a/packages/sdks/nextjs/index.tsx +++ b/packages/sdks/nextjs/index.tsx @@ -68,9 +68,17 @@ export function OpenPanelComponent({ value: globalProperties, }); } + + const appendVersion = (url: string) => { + if (url.endsWith('.js')) { + return `${url}?v=${process.env.NEXTJS_VERSION!}`; + } + return url; + }; + return ( <> -