apptvty 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-QERJ6LJA.mjs → chunk-BO7AVDLZ.mjs} +5 -5
- package/dist/chunk-BO7AVDLZ.mjs.map +1 -0
- package/dist/{chunk-JILPRROG.mjs → chunk-UEMFLSQL.mjs} +5 -5
- package/dist/{chunk-JILPRROG.mjs.map → chunk-UEMFLSQL.mjs.map} +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/middleware/express.d.mts +1 -1
- package/dist/middleware/express.d.ts +1 -1
- package/dist/middleware/express.js +4 -4
- package/dist/middleware/express.js.map +1 -1
- package/dist/middleware/express.mjs +1 -1
- package/dist/middleware/nextjs.d.mts +1 -1
- package/dist/middleware/nextjs.d.ts +1 -1
- package/dist/middleware/nextjs.js +4 -4
- package/dist/middleware/nextjs.js.map +1 -1
- package/dist/middleware/nextjs.mjs +1 -1
- package/dist/setup.d.mts +12 -1
- package/dist/setup.d.ts +12 -1
- package/dist/setup.js +43 -0
- package/dist/setup.js.map +1 -1
- package/dist/setup.mjs +41 -0
- package/dist/setup.mjs.map +1 -1
- package/dist/{types-Zt2qHOrW.d.mts → types-CwI3lNay.d.mts} +26 -5
- package/dist/{types-Zt2qHOrW.d.ts → types-CwI3lNay.d.ts} +26 -5
- package/package.json +1 -1
- package/dist/chunk-QERJ6LJA.mjs.map +0 -1
|
@@ -51,13 +51,13 @@ function withApptvty(config, next) {
|
|
|
51
51
|
const entry = {
|
|
52
52
|
site_id: config.siteId,
|
|
53
53
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
request_method: request.method,
|
|
55
|
+
request_path: pathname,
|
|
56
|
+
response_status: response.status,
|
|
57
57
|
response_time_ms: responseTimeMs,
|
|
58
58
|
ip_address: getClientIp(headers),
|
|
59
59
|
user_agent: userAgent,
|
|
60
|
-
|
|
60
|
+
referrer: request.headers.get("referer"),
|
|
61
61
|
is_ai_crawler: crawlerInfo.isAi,
|
|
62
62
|
crawler_type: crawlerInfo.name,
|
|
63
63
|
crawler_organization: crawlerInfo.organization,
|
|
@@ -176,4 +176,4 @@ export {
|
|
|
176
176
|
createNextjsQueryHandler,
|
|
177
177
|
createNextjsDashboardHandler
|
|
178
178
|
};
|
|
179
|
-
//# sourceMappingURL=chunk-
|
|
179
|
+
//# sourceMappingURL=chunk-BO7AVDLZ.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/middleware/nextjs.ts"],"sourcesContent":["/**\n * Next.js App Router integration for the Apptvty SDK.\n *\n * Usage — two pieces:\n *\n * 1. Wrap your existing middleware.ts (or use ours standalone):\n *\n * import { withApptvty } from 'apptvty/nextjs';\n * export default withApptvty({ apiKey: 'ak_...', siteId: 'site_...' });\n * // or wrap your existing middleware:\n * export default withApptvty(config, yourMiddleware);\n *\n * 2. Create app/query/route.ts for the AEO query endpoint:\n *\n * import { createNextjsQueryHandler } from 'apptvty/nextjs';\n * export const GET = createNextjsQueryHandler({ apiKey: 'ak_...', siteId: 'site_...' });\n *\n * Ad injection strategy (three layers, applied for all AI/scraper traffic):\n *\n * Layer 1 — <p>[Sponsored]</p> inside <article>/<main>\n * Survives Jina, FireCrawl, Cloudflare Browser Rendering, BeautifulSoup,\n * headless browsers, curl, requests. Applied for all crawler types.\n *\n * Layer 2 — JSON-LD <script> in <head>\n * For direct HTML scrapers (BeautifulSoup, lxml). Skipped for known\n * scraper services (Jina/FireCrawl/Cloudflare) — they drop <head> on\n * Markdown conversion.\n *\n * Layer 3 — X-Sponsored-Content response header\n * For any HTTP client that reads response headers (requests, httpx, curl).\n * Applied for all crawler types.\n */\n\nimport type { NextRequest } from 'next/server';\nimport { NextResponse } from 'next/server';\nimport { ApptvtyClient } from '../client.js';\nimport { detectCrawler, detectScraperService } from '../crawler.js';\nimport { RequestLogger, getClientIp } from '../logger.js';\nimport { createQueryHandler } from '../query-handler.js';\nimport { createDashboardHandler } from '../dashboard-handler.js';\nimport { injectIntoHtml, buildSponsoredHeader } from '../ad-injection.js';\nimport type { ApptvtyConfig, RequestLogEntry } from '../types.js';\n\n/** Convert Next request headers to a plain object (Web API Headers.entries() at runtime). */\nfunction headersToRecord(h: NextRequest['headers']): Record<string, string> {\n const entries = (h as unknown as { entries(): IterableIterator<[string, string]> }).entries();\n return Object.fromEntries(Array.from(entries));\n}\n\n// ─── Shared singleton instances per config (keyed by apiKey) ─────────────────\n\nconst instances = new Map<string, { client: ApptvtyClient; logger: RequestLogger }>();\n\nfunction getInstance(config: ApptvtyConfig) {\n const key = config.apiKey;\n if (!instances.has(key)) {\n const client = new ApptvtyClient(config);\n const logger = new RequestLogger(client, config);\n instances.set(key, { client, logger });\n }\n return instances.get(key)!;\n}\n\n// ─── Middleware wrapper ───────────────────────────────────────────────────────\n\ntype NextMiddleware = (request: NextRequest) => Response | NextResponse | Promise<Response | NextResponse>;\n\n/**\n * Wraps a Next.js middleware function (or creates a passthrough) with\n * Apptvty request logging and multi-layer ad injection for AI/scraper traffic.\n *\n * @example\n * // middleware.ts\n * import { withApptvty } from 'apptvty/nextjs';\n * export default withApptvty({ apiKey: 'ak_...', siteId: 'site_...' });\n */\nexport function withApptvty(\n config: ApptvtyConfig,\n next?: NextMiddleware,\n): NextMiddleware {\n const { client, logger } = getInstance(config);\n const queryPath = config.queryPath ?? '/query';\n\n return async function apptvtyMiddleware(request: NextRequest): Promise<NextResponse> {\n const startMs = Date.now();\n const userAgent = request.headers.get('user-agent') ?? '';\n const crawlerInfo = detectCrawler(userAgent);\n const scraperService = detectScraperService(userAgent);\n const aiCrawlerParam = parseBoolParam(request.nextUrl.searchParams.get('ai_crawler'), false);\n const isCrawler = crawlerInfo.isAi || aiCrawlerParam || scraperService.isScraperService;\n\n // Run the user's middleware (or passthrough)\n let response: Response | NextResponse;\n try {\n response = next ? await next(request) : NextResponse.next();\n } catch (err) {\n throw err;\n }\n\n const responseTimeMs = Date.now() - startMs;\n const { pathname } = request.nextUrl;\n\n if (shouldSkip(pathname)) {\n return response as NextResponse;\n }\n\n const headers = headersToRecord(request.headers);\n const entry: RequestLogEntry = {\n site_id: config.siteId,\n timestamp: new Date().toISOString(),\n request_method: request.method,\n request_path: pathname,\n response_status: response.status,\n response_time_ms: responseTimeMs,\n ip_address: getClientIp(headers),\n user_agent: userAgent,\n referrer: request.headers.get('referer'),\n is_ai_crawler: crawlerInfo.isAi,\n crawler_type: crawlerInfo.name,\n crawler_organization: crawlerInfo.organization,\n confidence_score: crawlerInfo.confidence,\n scraper_service: scraperService.name,\n };\n\n logger.enqueue(entry);\n\n // Ad injection for all AI crawler and scraper service traffic on HTML pages\n if (isCrawler && response.ok && !pathname.startsWith(queryPath)) {\n const contentType = response.headers.get('content-type') ?? '';\n if (contentType.includes('text/html')) {\n try {\n const modified = await injectAdsIntoResponse(\n response,\n client,\n config,\n pathname,\n userAgent,\n getClientIp(headers),\n scraperService.isScraperService,\n );\n if (modified) return modified;\n } catch (err) {\n if (config.debug) console.warn('[apptvty] Ad injection failed:', err);\n }\n }\n }\n\n return response as NextResponse;\n };\n}\n\n// ─── Query route handler ──────────────────────────────────────────────────────\n\n/**\n * Creates a Next.js App Router GET handler for the AEO query endpoint.\n *\n * @example\n * // app/query/route.ts\n * import { createNextjsQueryHandler } from 'apptvty/nextjs';\n * export const GET = createNextjsQueryHandler({ apiKey: 'ak_...', siteId: 'site_...' });\n */\nexport function createNextjsQueryHandler(config: ApptvtyConfig) {\n const { client } = getInstance(config);\n const handleQuery = createQueryHandler(client, config);\n\n return async function GET(request: NextRequest): Promise<NextResponse> {\n const { searchParams } = request.nextUrl;\n const q = searchParams.get('q');\n const lang = searchParams.get('lang');\n const surfaceAds = parseBoolParam(searchParams.get('surface_ads'), true);\n const aiCrawler = parseBoolParam(searchParams.get('ai_crawler'), false);\n const userAgent = request.headers.get('user-agent') ?? '';\n const headers = headersToRecord(request.headers);\n\n const result = await handleQuery({\n query: q,\n lang,\n surface_ads: surfaceAds,\n ai_crawler: aiCrawler,\n userAgent,\n ipAddress: getClientIp(headers),\n requestUrl: request.url,\n });\n\n return NextResponse.json(result.body, {\n status: result.status,\n headers: result.headers,\n });\n };\n}\n\n/**\n * Next.js route handler for the embedded analytics dashboard.\n *\n * Mount this in app/api/apptvty/logs/route.ts (or your preferred path).\n *\n * @example\n * // app/api/apptvty/logs/route.ts\n * import { createNextjsDashboardHandler } from 'apptvty/nextjs';\n * const config = { apiKey: 'ak_...', siteId: 'site_...' };\n * export const GET = createNextjsDashboardHandler(config);\n */\nexport function createNextjsDashboardHandler(config: ApptvtyConfig) {\n const { client } = getInstance(config);\n const handleDashboard = createDashboardHandler(client, config);\n\n return async function dashboardHandler(request: NextRequest) {\n const result = await handleDashboard({\n path: request.nextUrl.pathname + request.nextUrl.search,\n method: request.method,\n apiKey: config.apiKey,\n siteId: config.siteId,\n authHeader: request.headers.get('Authorization'),\n });\n\n if (result.headers['Content-Type'] === 'text/html') {\n return new NextResponse(result.body, {\n status: result.status,\n headers: result.headers,\n });\n }\n\n return NextResponse.json(JSON.parse(result.body), {\n status: result.status,\n headers: result.headers,\n });\n };\n}\n\n// ─── Ad injection ─────────────────────────────────────────────────────────────\n\nasync function injectAdsIntoResponse(\n response: Response,\n client: ApptvtyClient,\n config: ApptvtyConfig,\n pathname: string,\n userAgent: string,\n ipAddress: string,\n isScraperService: boolean,\n): Promise<NextResponse | null> {\n const html = await response.text();\n if (!html) return null;\n\n const pageAds = await client.getAdsForPage({ site_id: config.siteId, page_path: pathname });\n if (!pageAds.ads || pageAds.ads.length === 0) return null;\n\n const modified = injectIntoHtml(html, pageAds.ads, isScraperService);\n if (modified === html) return null; // nothing was injected\n\n const newHeaders = new Headers(response.headers);\n\n // Layer 3: HTTP response header — readable by any HTTP client inspecting headers\n newHeaders.set('X-Sponsored-Content', buildSponsoredHeader(pageAds.ads));\n\n // Log impressions fire-and-forget — triggers billing\n const timestamp = new Date().toISOString();\n for (const ad of pageAds.ads) {\n client\n .logImpression({\n impression_id: ad.impression_id,\n site_id: config.siteId,\n page_path: pathname,\n agent_ua: userAgent,\n agent_ip: ipAddress,\n timestamp,\n })\n .catch(() => {});\n }\n\n return new NextResponse(modified, {\n status: response.status,\n statusText: response.statusText,\n headers: newHeaders,\n });\n}\n\n// ─── Helpers ──────────────────────────────────────────────────────────────────\n\nfunction parseBoolParam(value: string | null, defaultValue: boolean): boolean {\n if (value === null) return defaultValue;\n return value === '1' || value === 'true' || value === 'yes';\n}\n\nfunction shouldSkip(pathname: string): boolean {\n return (\n pathname.startsWith('/_next/') ||\n pathname.startsWith('/api/_') ||\n pathname === '/favicon.ico' ||\n /\\.(svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|css|js\\.map)$/.test(pathname)\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -99,13 +99,13 @@ function createExpressMiddleware(config) {
|
|
|
99
99
|
const entry = {
|
|
100
100
|
site_id: config.siteId,
|
|
101
101
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
102
|
-
|
|
103
|
-
path,
|
|
104
|
-
|
|
102
|
+
request_method: req.method ?? "GET",
|
|
103
|
+
request_path: path,
|
|
104
|
+
response_status: res.statusCode,
|
|
105
105
|
response_time_ms: Date.now() - startMs,
|
|
106
106
|
ip_address: ipAddress,
|
|
107
107
|
user_agent: userAgent,
|
|
108
|
-
|
|
108
|
+
referrer: req.headers["referer"] ?? null,
|
|
109
109
|
is_ai_crawler: crawlerInfo.isAi,
|
|
110
110
|
crawler_type: crawlerInfo.name,
|
|
111
111
|
crawler_organization: crawlerInfo.organization,
|
|
@@ -175,4 +175,4 @@ export {
|
|
|
175
175
|
createExpressQueryHandler,
|
|
176
176
|
createExpressDashboardHandler
|
|
177
177
|
};
|
|
178
|
-
//# sourceMappingURL=chunk-
|
|
178
|
+
//# sourceMappingURL=chunk-UEMFLSQL.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/middleware/express.ts"],"sourcesContent":["/**\n * Express / Node.js integration for the Apptvty SDK.\n *\n * Usage:\n *\n * import express from 'express';\n * import { createExpressMiddleware, createExpressQueryHandler } from 'apptvty/express';\n *\n * const app = express();\n * const config = { apiKey: 'ak_...', siteId: 'site_...' };\n *\n * // 1. Log all traffic + inject ads into AI crawler responses\n * app.use(createExpressMiddleware(config));\n *\n * // 2. Mount the AEO query page\n * app.get('/query', createExpressQueryHandler(config));\n *\n * Works with any Connect-compatible framework (Express, Fastify via @fastify/express, etc.)\n *\n * Ad injection strategy:\n * When an AI crawler or scraper service is detected, the middleware buffers the\n * response body and injects sponsored content using the same three-layer strategy\n * as the Next.js integration. An ad fetch runs in parallel with the app's own\n * request processing, so the latency overhead is the difference between the ad\n * fetch time and the app's own response time — typically near zero.\n */\n\nimport type { IncomingMessage, ServerResponse } from 'node:http';\nimport { ApptvtyClient } from '../client.js';\nimport { detectCrawler, detectScraperService } from '../crawler.js';\nimport { RequestLogger, getClientIp } from '../logger.js';\nimport { createQueryHandler } from '../query-handler.js';\nimport { createDashboardHandler } from '../dashboard-handler.js';\nimport { injectIntoHtml, buildSponsoredHeader, AD_INJECTION_MARKER } from '../ad-injection.js';\nimport type { ApptvtyConfig, PageAdsResponse, RequestLogEntry } from '../types.js';\n\nexport type ConnectMiddleware = (\n req: IncomingMessage,\n res: ServerResponse,\n next: (err?: unknown) => void,\n) => void;\n\nexport type ConnectHandler = (\n req: IncomingMessage,\n res: ServerResponse,\n) => void | Promise<void>;\n\n// ─── Shared singleton instances per config ────────────────────────────────────\n\nconst instances = new Map<string, { client: ApptvtyClient; logger: RequestLogger }>();\n\nfunction getInstance(config: ApptvtyConfig) {\n const key = config.apiKey;\n if (!instances.has(key)) {\n const client = new ApptvtyClient(config);\n const logger = new RequestLogger(client, config);\n instances.set(key, { client, logger });\n }\n return instances.get(key)!;\n}\n\n// ─── Traffic logging + ad injection middleware ────────────────────────────────\n\n/**\n * Express middleware that:\n * 1. Logs every request to Apptvty (all traffic, batched).\n * 2. For AI crawler and scraper service requests on HTML pages: buffers the\n * response, fetches matching ads in parallel, and injects sponsored content\n * using the three-layer strategy (content-stream, JSON-LD, response header).\n *\n * Mount this before your routes so all traffic is captured.\n *\n * @example\n * app.use(createExpressMiddleware({ apiKey: 'ak_...', siteId: 'site_...' }));\n */\nexport function createExpressMiddleware(config: ApptvtyConfig): ConnectMiddleware {\n const { client, logger } = getInstance(config);\n\n return function apptvtyMiddleware(req, res, next) {\n const startMs = Date.now();\n const userAgent = req.headers['user-agent'] ?? '';\n const crawlerInfo = detectCrawler(userAgent);\n const scraperService = detectScraperService(userAgent);\n const path = req.url ?? '/';\n const isCrawler = crawlerInfo.isAi || scraperService.isScraperService;\n const ipAddress = getClientIp(req.headers as Record<string, string | string[] | undefined>);\n\n // Start ad fetch in parallel with the app's request processing.\n // By the time res.end() is called, the fetch is usually already resolved.\n const adsPromise: Promise<PageAdsResponse> =\n isCrawler && !shouldSkip(path)\n ? client\n .getAdsForPage({ site_id: config.siteId, page_path: path })\n .catch(() => ({ ads: [] }))\n : Promise.resolve({ ads: [] });\n\n // Buffer the response body for AI/scraper traffic so we can inject ads.\n if (isCrawler && !shouldSkip(path)) {\n const chunks: Buffer[] = [];\n const originalWrite = res.write.bind(res) as typeof res.write;\n const originalEnd = res.end.bind(res) as typeof res.end;\n\n // Collect response body chunks without sending them yet.\n (res as any).write = function (\n chunk: string | Buffer | Uint8Array,\n encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),\n callback?: (err?: Error | null) => void,\n ): boolean {\n if (chunk != null) {\n chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));\n }\n // Signal to the caller that the write was accepted\n if (typeof encodingOrCallback === 'function') encodingOrCallback();\n else if (typeof callback === 'function') callback();\n return true;\n };\n\n (res as any).end = function (\n chunk?: string | Buffer | Uint8Array,\n encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),\n callback?: (err?: Error | null) => void,\n ): ServerResponse {\n if (chunk != null) {\n chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));\n }\n\n const contentType = (res.getHeader('content-type') as string) ?? '';\n const isHtml = contentType.includes('text/html');\n\n if (!isHtml || chunks.length === 0) {\n // Not HTML — restore and send as-is\n res.write = originalWrite;\n res.end = originalEnd;\n return originalEnd(Buffer.concat(chunks), encodingOrCallback as BufferEncoding, callback);\n }\n\n const html = Buffer.concat(chunks).toString('utf-8');\n\n if (html.includes(AD_INJECTION_MARKER)) {\n // Already injected (e.g. double middleware mount) — send as-is\n res.write = originalWrite;\n res.end = originalEnd;\n return originalEnd(html, encodingOrCallback as BufferEncoding, callback);\n }\n\n // Await the parallel ad fetch and inject, then send.\n // adsPromise is fire-and-forget from Node's perspective; we must eventually\n // call originalEnd regardless of outcome.\n adsPromise\n .then((pageAds) => {\n res.write = originalWrite;\n res.end = originalEnd;\n\n if (!pageAds.ads || pageAds.ads.length === 0) {\n originalEnd(html, encodingOrCallback as BufferEncoding, callback);\n return;\n }\n\n const modified = injectIntoHtml(html, pageAds.ads, scraperService.isScraperService);\n\n // Layer 3: response header\n res.setHeader('X-Sponsored-Content', buildSponsoredHeader(pageAds.ads));\n\n const buf = Buffer.from(modified, 'utf-8');\n // Update Content-Length now that the body has grown\n res.setHeader('Content-Length', buf.length);\n\n // Log impressions fire-and-forget — triggers billing\n const timestamp = new Date().toISOString();\n for (const ad of pageAds.ads) {\n client\n .logImpression({\n impression_id: ad.impression_id,\n site_id: config.siteId,\n page_path: path,\n agent_ua: userAgent,\n agent_ip: ipAddress,\n timestamp,\n })\n .catch(() => {});\n }\n\n originalEnd(buf, encodingOrCallback as BufferEncoding, callback);\n })\n .catch(() => {\n // Ad fetch or injection failed — send the original unmodified HTML\n res.write = originalWrite;\n res.end = originalEnd;\n originalEnd(html, encodingOrCallback as BufferEncoding, callback);\n });\n\n // Return res synchronously — Node's HTTP server will keep the connection\n // open until originalEnd is called by the promise above.\n return res;\n };\n }\n\n // Log after response completes (captures final status code and timing)\n res.on('finish', () => {\n if (shouldSkip(path)) return;\n\n const entry: RequestLogEntry = {\n site_id: config.siteId,\n timestamp: new Date().toISOString(),\n method: req.method ?? 'GET',\n path,\n status_code: res.statusCode,\n response_time_ms: Date.now() - startMs,\n ip_address: ipAddress,\n user_agent: userAgent,\n referer: (req.headers['referer'] as string) ?? null,\n is_ai_crawler: crawlerInfo.isAi,\n crawler_type: crawlerInfo.name,\n crawler_organization: crawlerInfo.organization,\n confidence_score: crawlerInfo.confidence,\n scraper_service: scraperService.name,\n };\n\n logger.enqueue(entry);\n });\n\n next();\n };\n}\n\n// ─── Query endpoint handler ───────────────────────────────────────────────────\n\n/**\n * Express route handler for the AEO query endpoint.\n *\n * Mount this at the path configured in your dashboard (default: /query).\n *\n * @example\n * app.get('/query', createExpressQueryHandler({ apiKey: 'ak_...', siteId: 'site_...' }));\n */\nexport function createExpressQueryHandler(config: ApptvtyConfig): ConnectHandler {\n const { client } = getInstance(config);\n const handleQuery = createQueryHandler(client, config);\n\n return async function queryHandler(req, res) {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);\n const q = url.searchParams.get('q');\n const lang = url.searchParams.get('lang');\n const surfaceAds = parseBoolParam(url.searchParams.get('surface_ads'), true);\n const aiCrawler = parseBoolParam(url.searchParams.get('ai_crawler'), false);\n const userAgent = req.headers['user-agent'] ?? '';\n\n const result = await handleQuery({\n query: q,\n lang,\n surface_ads: surfaceAds,\n ai_crawler: aiCrawler,\n userAgent,\n ipAddress: getClientIp(req.headers as Record<string, string | string[] | undefined>),\n requestUrl: url.toString(),\n });\n\n for (const [key, value] of Object.entries(result.headers)) {\n res.setHeader(key, value);\n }\n res.statusCode = result.status;\n res.end(JSON.stringify(result.body));\n };\n}\n\n/**\n * Express route handler for the embedded analytics dashboard.\n *\n * Mount this at the path where you want to view your logs (default: /apptvty/logs).\n *\n * @example\n * app.get('/apptvty/logs', createExpressDashboardHandler({ apiKey: 'ak_...', siteId: 'site_...' }));\n */\nexport function createExpressDashboardHandler(config: ApptvtyConfig): ConnectHandler {\n const { client } = getInstance(config);\n const handleDashboard = createDashboardHandler(client, config);\n\n return async function dashboardHandler(req, res) {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);\n const result = await handleDashboard({\n path: url.pathname + url.search,\n method: req.method ?? 'GET',\n apiKey: config.apiKey,\n siteId: config.siteId,\n authHeader: (req.headers['authorization'] as string) ?? null,\n });\n\n for (const [key, value] of Object.entries(result.headers)) {\n res.setHeader(key, value);\n }\n res.statusCode = result.status;\n res.end(result.body);\n };\n}\n\n// ─── Helpers ──────────────────────────────────────────────────────────────────\n\nfunction parseBoolParam(value: string | null, defaultValue: boolean): boolean {\n if (value === null) return defaultValue;\n return value === '1' || value === 'true' || value === 'yes';\n}\n\nfunction shouldSkip(path: string): boolean {\n return (\n path.startsWith('/_next/') ||\n /\\.(svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|css|js\\.map)$/.test(path)\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/middleware/express.ts"],"sourcesContent":["/**\n * Express / Node.js integration for the Apptvty SDK.\n *\n * Usage:\n *\n * import express from 'express';\n * import { createExpressMiddleware, createExpressQueryHandler } from 'apptvty/express';\n *\n * const app = express();\n * const config = { apiKey: 'ak_...', siteId: 'site_...' };\n *\n * // 1. Log all traffic + inject ads into AI crawler responses\n * app.use(createExpressMiddleware(config));\n *\n * // 2. Mount the AEO query page\n * app.get('/query', createExpressQueryHandler(config));\n *\n * Works with any Connect-compatible framework (Express, Fastify via @fastify/express, etc.)\n *\n * Ad injection strategy:\n * When an AI crawler or scraper service is detected, the middleware buffers the\n * response body and injects sponsored content using the same three-layer strategy\n * as the Next.js integration. An ad fetch runs in parallel with the app's own\n * request processing, so the latency overhead is the difference between the ad\n * fetch time and the app's own response time — typically near zero.\n */\n\nimport type { IncomingMessage, ServerResponse } from 'node:http';\nimport { ApptvtyClient } from '../client.js';\nimport { detectCrawler, detectScraperService } from '../crawler.js';\nimport { RequestLogger, getClientIp } from '../logger.js';\nimport { createQueryHandler } from '../query-handler.js';\nimport { createDashboardHandler } from '../dashboard-handler.js';\nimport { injectIntoHtml, buildSponsoredHeader, AD_INJECTION_MARKER } from '../ad-injection.js';\nimport type { ApptvtyConfig, PageAdsResponse, RequestLogEntry } from '../types.js';\n\nexport type ConnectMiddleware = (\n req: IncomingMessage,\n res: ServerResponse,\n next: (err?: unknown) => void,\n) => void;\n\nexport type ConnectHandler = (\n req: IncomingMessage,\n res: ServerResponse,\n) => void | Promise<void>;\n\n// ─── Shared singleton instances per config ────────────────────────────────────\n\nconst instances = new Map<string, { client: ApptvtyClient; logger: RequestLogger }>();\n\nfunction getInstance(config: ApptvtyConfig) {\n const key = config.apiKey;\n if (!instances.has(key)) {\n const client = new ApptvtyClient(config);\n const logger = new RequestLogger(client, config);\n instances.set(key, { client, logger });\n }\n return instances.get(key)!;\n}\n\n// ─── Traffic logging + ad injection middleware ────────────────────────────────\n\n/**\n * Express middleware that:\n * 1. Logs every request to Apptvty (all traffic, batched).\n * 2. For AI crawler and scraper service requests on HTML pages: buffers the\n * response, fetches matching ads in parallel, and injects sponsored content\n * using the three-layer strategy (content-stream, JSON-LD, response header).\n *\n * Mount this before your routes so all traffic is captured.\n *\n * @example\n * app.use(createExpressMiddleware({ apiKey: 'ak_...', siteId: 'site_...' }));\n */\nexport function createExpressMiddleware(config: ApptvtyConfig): ConnectMiddleware {\n const { client, logger } = getInstance(config);\n\n return function apptvtyMiddleware(req, res, next) {\n const startMs = Date.now();\n const userAgent = req.headers['user-agent'] ?? '';\n const crawlerInfo = detectCrawler(userAgent);\n const scraperService = detectScraperService(userAgent);\n const path = req.url ?? '/';\n const isCrawler = crawlerInfo.isAi || scraperService.isScraperService;\n const ipAddress = getClientIp(req.headers as Record<string, string | string[] | undefined>);\n\n // Start ad fetch in parallel with the app's request processing.\n // By the time res.end() is called, the fetch is usually already resolved.\n const adsPromise: Promise<PageAdsResponse> =\n isCrawler && !shouldSkip(path)\n ? client\n .getAdsForPage({ site_id: config.siteId, page_path: path })\n .catch(() => ({ ads: [] }))\n : Promise.resolve({ ads: [] });\n\n // Buffer the response body for AI/scraper traffic so we can inject ads.\n if (isCrawler && !shouldSkip(path)) {\n const chunks: Buffer[] = [];\n const originalWrite = res.write.bind(res) as typeof res.write;\n const originalEnd = res.end.bind(res) as typeof res.end;\n\n // Collect response body chunks without sending them yet.\n (res as any).write = function (\n chunk: string | Buffer | Uint8Array,\n encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),\n callback?: (err?: Error | null) => void,\n ): boolean {\n if (chunk != null) {\n chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));\n }\n // Signal to the caller that the write was accepted\n if (typeof encodingOrCallback === 'function') encodingOrCallback();\n else if (typeof callback === 'function') callback();\n return true;\n };\n\n (res as any).end = function (\n chunk?: string | Buffer | Uint8Array,\n encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),\n callback?: (err?: Error | null) => void,\n ): ServerResponse {\n if (chunk != null) {\n chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));\n }\n\n const contentType = (res.getHeader('content-type') as string) ?? '';\n const isHtml = contentType.includes('text/html');\n\n if (!isHtml || chunks.length === 0) {\n // Not HTML — restore and send as-is\n res.write = originalWrite;\n res.end = originalEnd;\n return originalEnd(Buffer.concat(chunks), encodingOrCallback as BufferEncoding, callback);\n }\n\n const html = Buffer.concat(chunks).toString('utf-8');\n\n if (html.includes(AD_INJECTION_MARKER)) {\n // Already injected (e.g. double middleware mount) — send as-is\n res.write = originalWrite;\n res.end = originalEnd;\n return originalEnd(html, encodingOrCallback as BufferEncoding, callback);\n }\n\n // Await the parallel ad fetch and inject, then send.\n // adsPromise is fire-and-forget from Node's perspective; we must eventually\n // call originalEnd regardless of outcome.\n adsPromise\n .then((pageAds) => {\n res.write = originalWrite;\n res.end = originalEnd;\n\n if (!pageAds.ads || pageAds.ads.length === 0) {\n originalEnd(html, encodingOrCallback as BufferEncoding, callback);\n return;\n }\n\n const modified = injectIntoHtml(html, pageAds.ads, scraperService.isScraperService);\n\n // Layer 3: response header\n res.setHeader('X-Sponsored-Content', buildSponsoredHeader(pageAds.ads));\n\n const buf = Buffer.from(modified, 'utf-8');\n // Update Content-Length now that the body has grown\n res.setHeader('Content-Length', buf.length);\n\n // Log impressions fire-and-forget — triggers billing\n const timestamp = new Date().toISOString();\n for (const ad of pageAds.ads) {\n client\n .logImpression({\n impression_id: ad.impression_id,\n site_id: config.siteId,\n page_path: path,\n agent_ua: userAgent,\n agent_ip: ipAddress,\n timestamp,\n })\n .catch(() => {});\n }\n\n originalEnd(buf, encodingOrCallback as BufferEncoding, callback);\n })\n .catch(() => {\n // Ad fetch or injection failed — send the original unmodified HTML\n res.write = originalWrite;\n res.end = originalEnd;\n originalEnd(html, encodingOrCallback as BufferEncoding, callback);\n });\n\n // Return res synchronously — Node's HTTP server will keep the connection\n // open until originalEnd is called by the promise above.\n return res;\n };\n }\n\n // Log after response completes (captures final status code and timing)\n res.on('finish', () => {\n if (shouldSkip(path)) return;\n\n const entry: RequestLogEntry = {\n site_id: config.siteId,\n timestamp: new Date().toISOString(),\n request_method: req.method ?? 'GET',\n request_path: path,\n response_status: res.statusCode,\n response_time_ms: Date.now() - startMs,\n ip_address: ipAddress,\n user_agent: userAgent,\n referrer: (req.headers['referer'] as string) ?? null,\n is_ai_crawler: crawlerInfo.isAi,\n crawler_type: crawlerInfo.name,\n crawler_organization: crawlerInfo.organization,\n confidence_score: crawlerInfo.confidence,\n scraper_service: scraperService.name,\n };\n\n logger.enqueue(entry);\n });\n\n next();\n };\n}\n\n// ─── Query endpoint handler ───────────────────────────────────────────────────\n\n/**\n * Express route handler for the AEO query endpoint.\n *\n * Mount this at the path configured in your dashboard (default: /query).\n *\n * @example\n * app.get('/query', createExpressQueryHandler({ apiKey: 'ak_...', siteId: 'site_...' }));\n */\nexport function createExpressQueryHandler(config: ApptvtyConfig): ConnectHandler {\n const { client } = getInstance(config);\n const handleQuery = createQueryHandler(client, config);\n\n return async function queryHandler(req, res) {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);\n const q = url.searchParams.get('q');\n const lang = url.searchParams.get('lang');\n const surfaceAds = parseBoolParam(url.searchParams.get('surface_ads'), true);\n const aiCrawler = parseBoolParam(url.searchParams.get('ai_crawler'), false);\n const userAgent = req.headers['user-agent'] ?? '';\n\n const result = await handleQuery({\n query: q,\n lang,\n surface_ads: surfaceAds,\n ai_crawler: aiCrawler,\n userAgent,\n ipAddress: getClientIp(req.headers as Record<string, string | string[] | undefined>),\n requestUrl: url.toString(),\n });\n\n for (const [key, value] of Object.entries(result.headers)) {\n res.setHeader(key, value);\n }\n res.statusCode = result.status;\n res.end(JSON.stringify(result.body));\n };\n}\n\n/**\n * Express route handler for the embedded analytics dashboard.\n *\n * Mount this at the path where you want to view your logs (default: /apptvty/logs).\n *\n * @example\n * app.get('/apptvty/logs', createExpressDashboardHandler({ apiKey: 'ak_...', siteId: 'site_...' }));\n */\nexport function createExpressDashboardHandler(config: ApptvtyConfig): ConnectHandler {\n const { client } = getInstance(config);\n const handleDashboard = createDashboardHandler(client, config);\n\n return async function dashboardHandler(req, res) {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);\n const result = await handleDashboard({\n path: url.pathname + url.search,\n method: req.method ?? 'GET',\n apiKey: config.apiKey,\n siteId: config.siteId,\n authHeader: (req.headers['authorization'] as string) ?? null,\n });\n\n for (const [key, value] of Object.entries(result.headers)) {\n res.setHeader(key, value);\n }\n res.statusCode = result.status;\n res.end(result.body);\n };\n}\n\n// ─── Helpers ──────────────────────────────────────────────────────────────────\n\nfunction parseBoolParam(value: string | null, defaultValue: boolean): boolean {\n if (value === null) return defaultValue;\n return value === '1' || value === 'true' || value === 'yes';\n}\n\nfunction shouldSkip(path: string): boolean {\n return (\n path.startsWith('/_next/') ||\n /\\.(svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|css|js\\.map)$/.test(path)\n );\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as CrawlerInfo, A as ApptvtyConfig, R as RequestLogEntry, Q as QueryRequest, B as BackendQueryResponse, P as PageAdsResponse, I as ImpressionLog, S as SiteOverviewStats, D as DailyStat, a as RecentActivityItem, b as RecentQueryItem, c as CrawlerBreakdown, d as SiteWalletInfo, e as CreateCampaignParams, f as CampaignRecord, U as UpdateCampaignParams, g as InsufficientBalanceError, h as AgentQueryResponse, i as QueryEndpointDiscovery, j as AgentErrorResponse } from './types-
|
|
2
|
-
export { k as CampaignStatus, l as PageAd, m as QuerySource, n as SponsoredAd } from './types-
|
|
1
|
+
import { C as CrawlerInfo, A as ApptvtyConfig, R as RequestLogEntry, Q as QueryRequest, B as BackendQueryResponse, P as PageAdsResponse, I as ImpressionLog, S as SiteOverviewStats, D as DailyStat, a as RecentActivityItem, b as RecentQueryItem, c as CrawlerBreakdown, d as SiteWalletInfo, e as CreateCampaignParams, f as CampaignRecord, U as UpdateCampaignParams, g as InsufficientBalanceError, h as AgentQueryResponse, i as QueryEndpointDiscovery, j as AgentErrorResponse } from './types-CwI3lNay.mjs';
|
|
2
|
+
export { k as CampaignStatus, l as PageAd, m as QuerySource, n as SponsoredAd } from './types-CwI3lNay.mjs';
|
|
3
3
|
export { createNextjsQueryHandler, withApptvty } from './middleware/nextjs.mjs';
|
|
4
4
|
export { createExpressMiddleware, createExpressQueryHandler } from './middleware/express.mjs';
|
|
5
5
|
import 'next/server';
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as CrawlerInfo, A as ApptvtyConfig, R as RequestLogEntry, Q as QueryRequest, B as BackendQueryResponse, P as PageAdsResponse, I as ImpressionLog, S as SiteOverviewStats, D as DailyStat, a as RecentActivityItem, b as RecentQueryItem, c as CrawlerBreakdown, d as SiteWalletInfo, e as CreateCampaignParams, f as CampaignRecord, U as UpdateCampaignParams, g as InsufficientBalanceError, h as AgentQueryResponse, i as QueryEndpointDiscovery, j as AgentErrorResponse } from './types-
|
|
2
|
-
export { k as CampaignStatus, l as PageAd, m as QuerySource, n as SponsoredAd } from './types-
|
|
1
|
+
import { C as CrawlerInfo, A as ApptvtyConfig, R as RequestLogEntry, Q as QueryRequest, B as BackendQueryResponse, P as PageAdsResponse, I as ImpressionLog, S as SiteOverviewStats, D as DailyStat, a as RecentActivityItem, b as RecentQueryItem, c as CrawlerBreakdown, d as SiteWalletInfo, e as CreateCampaignParams, f as CampaignRecord, U as UpdateCampaignParams, g as InsufficientBalanceError, h as AgentQueryResponse, i as QueryEndpointDiscovery, j as AgentErrorResponse } from './types-CwI3lNay.js';
|
|
2
|
+
export { k as CampaignStatus, l as PageAd, m as QuerySource, n as SponsoredAd } from './types-CwI3lNay.js';
|
|
3
3
|
export { createNextjsQueryHandler, withApptvty } from './middleware/nextjs.js';
|
|
4
4
|
export { createExpressMiddleware, createExpressQueryHandler } from './middleware/express.js';
|
|
5
5
|
import 'next/server';
|
package/dist/index.js
CHANGED
|
@@ -855,13 +855,13 @@ function withApptvty(config, next) {
|
|
|
855
855
|
const entry = {
|
|
856
856
|
site_id: config.siteId,
|
|
857
857
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
858
|
+
request_method: request.method,
|
|
859
|
+
request_path: pathname,
|
|
860
|
+
response_status: response.status,
|
|
861
861
|
response_time_ms: responseTimeMs,
|
|
862
862
|
ip_address: getClientIp(headers),
|
|
863
863
|
user_agent: userAgent,
|
|
864
|
-
|
|
864
|
+
referrer: request.headers.get("referer"),
|
|
865
865
|
is_ai_crawler: crawlerInfo.isAi,
|
|
866
866
|
crawler_type: crawlerInfo.name,
|
|
867
867
|
crawler_organization: crawlerInfo.organization,
|
|
@@ -1040,13 +1040,13 @@ function createExpressMiddleware(config) {
|
|
|
1040
1040
|
const entry = {
|
|
1041
1041
|
site_id: config.siteId,
|
|
1042
1042
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1043
|
-
|
|
1044
|
-
path,
|
|
1045
|
-
|
|
1043
|
+
request_method: req.method ?? "GET",
|
|
1044
|
+
request_path: path,
|
|
1045
|
+
response_status: res.statusCode,
|
|
1046
1046
|
response_time_ms: Date.now() - startMs,
|
|
1047
1047
|
ip_address: ipAddress,
|
|
1048
1048
|
user_agent: userAgent,
|
|
1049
|
-
|
|
1049
|
+
referrer: req.headers["referer"] ?? null,
|
|
1050
1050
|
is_ai_crawler: crawlerInfo.isAi,
|
|
1051
1051
|
crawler_type: crawlerInfo.name,
|
|
1052
1052
|
crawler_organization: crawlerInfo.organization,
|