@traceten/ai-crawl 0.1.0

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +260 -0
  4. package/dist/adapters/cloudflare-pages.d.ts +42 -0
  5. package/dist/adapters/cloudflare-pages.d.ts.map +1 -0
  6. package/dist/adapters/cloudflare-pages.js +46 -0
  7. package/dist/adapters/cloudflare-pages.js.map +1 -0
  8. package/dist/adapters/cloudflare-workers.d.ts +41 -0
  9. package/dist/adapters/cloudflare-workers.d.ts.map +1 -0
  10. package/dist/adapters/cloudflare-workers.js +49 -0
  11. package/dist/adapters/cloudflare-workers.js.map +1 -0
  12. package/dist/adapters/express.d.ts +49 -0
  13. package/dist/adapters/express.d.ts.map +1 -0
  14. package/dist/adapters/express.js +91 -0
  15. package/dist/adapters/express.js.map +1 -0
  16. package/dist/adapters/hono.d.ts +48 -0
  17. package/dist/adapters/hono.d.ts.map +1 -0
  18. package/dist/adapters/hono.js +64 -0
  19. package/dist/adapters/hono.js.map +1 -0
  20. package/dist/adapters/next.d.ts +41 -0
  21. package/dist/adapters/next.d.ts.map +1 -0
  22. package/dist/adapters/next.js +70 -0
  23. package/dist/adapters/next.js.map +1 -0
  24. package/dist/config.d.ts +21 -0
  25. package/dist/config.d.ts.map +1 -0
  26. package/dist/config.js +99 -0
  27. package/dist/config.js.map +1 -0
  28. package/dist/crawlers.d.ts +68 -0
  29. package/dist/crawlers.d.ts.map +1 -0
  30. package/dist/crawlers.js +248 -0
  31. package/dist/crawlers.js.map +1 -0
  32. package/dist/filter.d.ts +33 -0
  33. package/dist/filter.d.ts.map +1 -0
  34. package/dist/filter.js +169 -0
  35. package/dist/filter.js.map +1 -0
  36. package/dist/index.d.ts +21 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +20 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/ip.d.ts +35 -0
  41. package/dist/ip.d.ts.map +1 -0
  42. package/dist/ip.js +109 -0
  43. package/dist/ip.js.map +1 -0
  44. package/dist/matcher.d.ts +44 -0
  45. package/dist/matcher.d.ts.map +1 -0
  46. package/dist/matcher.js +111 -0
  47. package/dist/matcher.js.map +1 -0
  48. package/dist/report.d.ts +43 -0
  49. package/dist/report.d.ts.map +1 -0
  50. package/dist/report.js +116 -0
  51. package/dist/report.js.map +1 -0
  52. package/dist/track.d.ts +30 -0
  53. package/dist/track.d.ts.map +1 -0
  54. package/dist/track.js +96 -0
  55. package/dist/track.js.map +1 -0
  56. package/dist/types.d.ts +184 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +11 -0
  59. package/dist/types.js.map +1 -0
  60. package/package.json +87 -0
  61. package/src/adapters/cloudflare-pages.ts +64 -0
  62. package/src/adapters/cloudflare-workers.ts +70 -0
  63. package/src/adapters/express.ts +113 -0
  64. package/src/adapters/hono.ts +89 -0
  65. package/src/adapters/next.ts +87 -0
  66. package/src/config.ts +127 -0
  67. package/src/crawlers.ts +269 -0
  68. package/src/filter.ts +178 -0
  69. package/src/index.ts +46 -0
  70. package/src/ip.ts +112 -0
  71. package/src/matcher.ts +119 -0
  72. package/src/report.ts +149 -0
  73. package/src/track.ts +117 -0
  74. package/src/types.ts +190 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Public types for @traceten/ai-crawl.
3
+ *
4
+ * The package is a PRE-FILTER only: it decides "is this plausibly an AI
5
+ * crawler worth reporting" locally, and sends a minimal payload. Provider
6
+ * classification, category, verification and confidence are all decided
7
+ * server-side, so the crawler list can change without customers upgrading.
8
+ * The local token list is a cost filter, not the source of truth.
9
+ */
10
+ /**
11
+ * The four-category taxonomy Traceten uses server-side. Used locally ONLY to
12
+ * apply the `disable*` opt-outs — the authoritative category is assigned
13
+ * server-side.
14
+ */
15
+ export type CrawlerCategory = "answer_fetch" | "search_index" | "training" | "ai_crawler";
16
+ /** Result of the local two-tier user-agent match. */
17
+ export interface CrawlerMatch {
18
+ /** Exact agent token that matched (tier 1), or `null` for a tier-2 provider-alias match. */
19
+ readonly agent: string | null;
20
+ /** Coarse provider slug (e.g. `"openai"`). */
21
+ readonly provider: string;
22
+ /** Local category guess — used only for the `disable*` opt-outs. */
23
+ readonly category: CrawlerCategory;
24
+ /** `"exact"` = tier-1 agent token; `"provider"` = tier-2 coarse alias. */
25
+ readonly tier: "exact" | "provider";
26
+ }
27
+ /** User-supplied configuration. Validate with {@link import("./config.js").defineAiCrawlConfig}. */
28
+ export interface AiCrawlConfig {
29
+ /**
30
+ * The site's public site key — the `ttid_…` value shown on the dashboard's
31
+ * install page (`data-site`). Case-sensitive. Sent as `site_id` on the
32
+ * wire. Required.
33
+ */
34
+ siteId: string;
35
+ /**
36
+ * Server-side auth token (`tt_bot_...`). REQUIRED — `POST /v1/ai-crawls`
37
+ * rejects unauthenticated reports. Unlike the browser snippet, a
38
+ * server-side package can hold a real secret; requiring it removes the
39
+ * cost-attack and data-poisoning surface. Missing/malformed tokens fail
40
+ * loudly at construction rather than silently sending requests that 401.
41
+ */
42
+ authToken: string;
43
+ /** Ingestion endpoint. Default: `https://ingest.traceten.com/v1/ai-crawls`. */
44
+ endpoint?: string;
45
+ /** HTTP methods to consider. Default `["GET", "HEAD"]`. Replaces the default. */
46
+ allowedMethods?: readonly string[];
47
+ /** Extra path prefixes to deny, ADDED to the built-in list (never replaces it). */
48
+ extraDenyPathPrefixes?: readonly string[];
49
+ /** Extra file extensions to deny (with or without leading dot), ADDED to the built-in list. */
50
+ extraDenyExtensions?: readonly string[];
51
+ /** Skip reporting `answer_fetch` crawlers (ChatGPT-User, Claude-User, …). */
52
+ disableAnswerFetch?: boolean;
53
+ /** Skip reporting `search_index` crawlers (OAI-SearchBot, PerplexityBot, …). */
54
+ disableSearchCrawlers?: boolean;
55
+ /** Skip reporting `training` crawlers (GPTBot, ClaudeBot, CCBot, …). */
56
+ disableTrainingCrawlers?: boolean;
57
+ /** Skip reporting `ai_crawler` (uncategorised) crawlers and tier-2 provider-alias matches. */
58
+ disableOtherCrawlers?: boolean;
59
+ /**
60
+ * Trust `x-forwarded-for` when resolving the crawler IP. Default `false`:
61
+ * XFF is attacker-controlled unless a proxy you operate strips/appends it,
62
+ * so it is never trusted blindly. Set `true` only when your app sits behind
63
+ * proxies you control, and set {@link proxyDepth} to how many of them
64
+ * append to XFF.
65
+ */
66
+ trustProxy?: boolean;
67
+ /**
68
+ * Trust the `cf-connecting-ip` header. Default `false`: on an origin NOT
69
+ * behind Cloudflare, any client can forge this header, and a forged
70
+ * vendor-range IP paired with a vendor UA is exactly the spoof the
71
+ * server-side verification exists to catch. The two Cloudflare adapters
72
+ * enable it automatically (the platform strips and rewrites the header
73
+ * there). Set it manually only when your origin genuinely sits behind
74
+ * Cloudflare (e.g. Express behind orange-cloud DNS).
75
+ */
76
+ trustCfConnectingIp?: boolean;
77
+ /**
78
+ * Number of trusted reverse proxies that append to `x-forwarded-for`.
79
+ * Default `1`. With N trusted proxies the crawler IP is the Nth entry from
80
+ * the right (rightmost-untrusted-aware selection). Only used when
81
+ * {@link trustProxy} is `true`.
82
+ */
83
+ proxyDepth?: number;
84
+ /**
85
+ * Origin (e.g. `"https://example.com"`) used to rebuild the reported URL
86
+ * when the runtime sees an internal one (containers / reverse proxies that
87
+ * construct `request.url` from an internal host).
88
+ */
89
+ publicOrigin?: string;
90
+ /**
91
+ * Called when a report does not reach the endpoint: a non-2xx response, or
92
+ * a network/timeout failure.
93
+ *
94
+ * Delivery stays silent by contract. This never throws into your request
95
+ * path, nothing is retried, and the response is not surfaced any other way.
96
+ * Without it a rejected report is indistinguishable from a delivered one, so
97
+ * a misconfigured token or blocked egress reads as "no crawlers visited"
98
+ * indefinitely. Wire it to your logger in staging at minimum.
99
+ *
100
+ * Your callback is itself wrapped in try/catch. Throwing from it cannot
101
+ * break the host response.
102
+ */
103
+ onError?: (error: AiCrawlDeliveryError) => void;
104
+ /** Injectable fetch, for tests. Defaults to the global `fetch`. */
105
+ fetch?: typeof fetch;
106
+ }
107
+ /** Why a report failed to land. Passed to {@link AiCrawlConfig.onError}. */
108
+ export interface AiCrawlDeliveryError {
109
+ /**
110
+ * `"http"` when the endpoint answered with a non-2xx status.
111
+ * `"network"` when the request never completed (DNS, TLS, timeout, abort).
112
+ */
113
+ kind: "http" | "network";
114
+ /** Status when `kind` is `"http"`. A 401/403 means the token or the origin. */
115
+ status?: number;
116
+ /** The thrown value when `kind` is `"network"`. */
117
+ cause?: unknown;
118
+ /** The endpoint the report was addressed to. */
119
+ endpoint: string;
120
+ }
121
+ /** Config after validation + defaulting. Produced by `defineAiCrawlConfig`. */
122
+ export interface ResolvedAiCrawlConfig {
123
+ readonly siteId: string;
124
+ readonly authToken: string;
125
+ readonly endpoint: string;
126
+ readonly allowedMethods: readonly string[];
127
+ readonly denyPathPrefixes: readonly string[];
128
+ readonly denyExtensions: readonly string[];
129
+ readonly disableAnswerFetch: boolean;
130
+ readonly disableSearchCrawlers: boolean;
131
+ readonly disableTrainingCrawlers: boolean;
132
+ readonly disableOtherCrawlers: boolean;
133
+ readonly trustProxy: boolean;
134
+ readonly trustCfConnectingIp: boolean;
135
+ readonly proxyDepth: number;
136
+ readonly publicOrigin: string | undefined;
137
+ readonly onError: ((error: AiCrawlDeliveryError) => void) | undefined;
138
+ readonly fetch: typeof fetch | undefined;
139
+ /** Brand so per-request paths can tell a validated config from a raw one. */
140
+ readonly __resolved: true;
141
+ }
142
+ /**
143
+ * Wire payload for `POST /v1/ai-crawls`. snake_case on the wire, per
144
+ * Traceten's wire-format convention.
145
+ *
146
+ * `ip` is the CRAWLER's IP as observed by the customer's server — the only
147
+ * place it is observable, since the crawler's TCP connection terminates
148
+ * there. Traceten's edge verifies it against vendor-published ranges while
149
+ * it is in scope; retention is verdict-dependent and decided server-side.
150
+ * The field is omitted entirely when no trustworthy value is derivable —
151
+ * never guessed.
152
+ *
153
+ * ⚠️ `ip` is a CLAIM made by customer-controlled software, not network
154
+ * proof. The receiving service must verify it (CIDR / rDNS) before treating
155
+ * it as crawler infrastructure, and must never let an unverified claimed IP
156
+ * mint a verified/raw-retained row.
157
+ */
158
+ export interface AiCrawlWirePayload {
159
+ site_id: string;
160
+ /** Full request URL (absolute where derivable). */
161
+ url: string;
162
+ method: string;
163
+ /** Response status where the adapter can observe it; omitted otherwise. */
164
+ status?: number;
165
+ user_agent: string;
166
+ /** Crawler IP. Omitted rather than wrong. */
167
+ ip?: string;
168
+ /** Epoch milliseconds at observation time. */
169
+ ts: number;
170
+ }
171
+ /**
172
+ * Runtime-agnostic view of a request, produced by each adapter.
173
+ * Header names are looked up case-insensitively by the adapter.
174
+ */
175
+ export interface RequestFacts {
176
+ method: string;
177
+ /** Absolute URL if the runtime provides one, else a path like `/docs/x`. */
178
+ url: string;
179
+ /** Case-insensitive header lookup; returns `null` when absent. */
180
+ header: (name: string) => string | null;
181
+ /** Socket remote address, where the runtime exposes one (Node servers). */
182
+ socketAddr?: string | undefined;
183
+ }
184
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public types for @traceten/ai-crawl.
3
+ *
4
+ * The package is a PRE-FILTER only: it decides "is this plausibly an AI
5
+ * crawler worth reporting" locally, and sends a minimal payload. Provider
6
+ * classification, category, verification and confidence are all decided
7
+ * server-side, so the crawler list can change without customers upgrading.
8
+ * The local token list is a cost filter, not the source of truth.
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@traceten/ai-crawl",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Traceten server-side AI crawler tracking — see the GPTBot/ClaudeBot/PerplexityBot crawls that browser JavaScript can never see. Five adapters, zero dependencies, never blocks a response.",
6
+ "keywords": [
7
+ "ai-crawler",
8
+ "gptbot",
9
+ "claudebot",
10
+ "perplexitybot",
11
+ "bot-detection",
12
+ "analytics",
13
+ "traceten"
14
+ ],
15
+ "license": "MIT",
16
+ "homepage": "https://docs.traceten.com/install/ai-crawler-tracking",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/traceten/ai-crawl.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/traceten/ai-crawl/issues"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "sideEffects": false,
28
+ "type": "module",
29
+ "main": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js",
35
+ "default": "./dist/index.js"
36
+ },
37
+ "./next": {
38
+ "types": "./dist/adapters/next.d.ts",
39
+ "import": "./dist/adapters/next.js",
40
+ "default": "./dist/adapters/next.js"
41
+ },
42
+ "./cloudflare-pages": {
43
+ "types": "./dist/adapters/cloudflare-pages.d.ts",
44
+ "import": "./dist/adapters/cloudflare-pages.js",
45
+ "default": "./dist/adapters/cloudflare-pages.js"
46
+ },
47
+ "./cloudflare-workers": {
48
+ "types": "./dist/adapters/cloudflare-workers.d.ts",
49
+ "import": "./dist/adapters/cloudflare-workers.js",
50
+ "default": "./dist/adapters/cloudflare-workers.js"
51
+ },
52
+ "./express": {
53
+ "types": "./dist/adapters/express.d.ts",
54
+ "import": "./dist/adapters/express.js",
55
+ "default": "./dist/adapters/express.js"
56
+ },
57
+ "./hono": {
58
+ "types": "./dist/adapters/hono.d.ts",
59
+ "import": "./dist/adapters/hono.js",
60
+ "default": "./dist/adapters/hono.js"
61
+ }
62
+ },
63
+ "files": [
64
+ "dist",
65
+ "LICENSE",
66
+ "CHANGELOG.md",
67
+ "src",
68
+ "!src/**/*.test.ts"
69
+ ],
70
+ "engines": {
71
+ "node": ">=18"
72
+ },
73
+ "scripts": {
74
+ "build": "tsc --project tsconfig.json && node build.mjs && ./check-size.sh",
75
+ "prepack": "npm run build",
76
+ "test": "vitest run",
77
+ "test:watch": "vitest",
78
+ "typecheck": "tsc --noEmit --project tsconfig.json",
79
+ "lint": "echo 'no linter configured yet'"
80
+ },
81
+ "devDependencies": {
82
+ "@types/node": "^20.0.0",
83
+ "esbuild": "^0.24.0",
84
+ "typescript": "^5.7.0",
85
+ "vitest": "^2.0.0"
86
+ }
87
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Cloudflare Pages Functions adapter — drop into `functions/_middleware.ts`.
3
+ *
4
+ * ```ts
5
+ * // functions/_middleware.ts
6
+ * import { defineAiCrawlConfig } from "@traceten/ai-crawl";
7
+ * import { createAICrawlerPagesMiddleware } from "@traceten/ai-crawl/cloudflare-pages";
8
+ *
9
+ * export const onRequest = createAICrawlerPagesMiddleware(
10
+ * defineAiCrawlConfig({
11
+ * siteId: "…", // or read from context.env inside your own wrapper
12
+ * authToken: "tt_bot_…",
13
+ * }),
14
+ * );
15
+ * ```
16
+ *
17
+ * Construction throws on invalid config (deploy-time failure); the returned
18
+ * middleware never throws and never delays the response — delivery goes
19
+ * through `context.waitUntil` and observes the REAL response status.
20
+ */
21
+
22
+ import { defineAiCrawlConfig, isResolvedConfig } from "../config.js";
23
+ import { factsFromFetchRequest, trackFacts, type FetchLikeRequest } from "../track.js";
24
+ import type { AiCrawlConfig, ResolvedAiCrawlConfig } from "../types.js";
25
+
26
+ /** Structural subset of Pages Functions' `EventContext`. */
27
+ export interface PagesContext {
28
+ request: FetchLikeRequest;
29
+ next(): Promise<{ status: number }> | { status: number };
30
+ waitUntil(promise: Promise<unknown>): void;
31
+ }
32
+
33
+ type PagesMiddleware = (context: PagesContext) => Promise<{ status: number }>;
34
+
35
+ /**
36
+ * Build a Pages Functions middleware. THROWS at construction when the config
37
+ * is invalid — that is deploy time, exactly where a bad token should fail.
38
+ */
39
+ export function createAICrawlerPagesMiddleware(
40
+ config: AiCrawlConfig | ResolvedAiCrawlConfig,
41
+ ): PagesMiddleware {
42
+ const resolved = isResolvedConfig(config) ? config : defineAiCrawlConfig(config);
43
+ // On Pages the platform strips and rewrites cf-connecting-ip, so it is
44
+ // trustworthy here (and only here / Workers) by default.
45
+ const cfg: ResolvedAiCrawlConfig = { ...resolved, trustCfConnectingIp: true };
46
+
47
+ return async (context: PagesContext) => {
48
+ // The customer's response comes first and is never touched. If next()
49
+ // itself throws, that error propagates untouched — swallowing it would
50
+ // change the site's behaviour.
51
+ const response = await context.next();
52
+ try {
53
+ trackFacts(
54
+ cfg,
55
+ factsFromFetchRequest(context.request),
56
+ typeof response?.status === "number" ? response.status : undefined,
57
+ (p) => context.waitUntil(p),
58
+ );
59
+ } catch {
60
+ /* silent by contract */
61
+ }
62
+ return response;
63
+ };
64
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Cloudflare Workers adapter — wrap your fetch handler.
3
+ *
4
+ * For a customer already behind Cloudflare this is copy-paste with no
5
+ * application deploy, which is why this adapter matters disproportionately.
6
+ *
7
+ * ```ts
8
+ * import { defineAiCrawlConfig } from "@traceten/ai-crawl";
9
+ * import { withAICrawlerTracking } from "@traceten/ai-crawl/cloudflare-workers";
10
+ *
11
+ * const config = defineAiCrawlConfig({ siteId: "…", authToken: "tt_bot_…" });
12
+ *
13
+ * export default {
14
+ * fetch: withAICrawlerTracking(async (request, env, ctx) => {
15
+ * return await handle(request);
16
+ * }, config),
17
+ * };
18
+ * ```
19
+ *
20
+ * Construction throws on invalid config; the wrapper never throws its own
21
+ * errors, captures the REAL status code from the handler's response, and
22
+ * schedules delivery via `ctx.waitUntil`. Handler errors propagate untouched.
23
+ */
24
+
25
+ import { defineAiCrawlConfig, isResolvedConfig } from "../config.js";
26
+ import { factsFromFetchRequest, trackFacts, type FetchLikeRequest } from "../track.js";
27
+ import type { AiCrawlConfig, ResolvedAiCrawlConfig } from "../types.js";
28
+
29
+ /** Structural subset of the Workers `ExecutionContext`. */
30
+ export interface WorkersExecutionContext {
31
+ waitUntil(promise: Promise<unknown>): void;
32
+ }
33
+
34
+ type WorkersHandler<Env, Res extends { status: number }> = (
35
+ request: FetchLikeRequest,
36
+ env: Env,
37
+ ctx: WorkersExecutionContext,
38
+ ) => Promise<Res> | Res;
39
+
40
+ /**
41
+ * Wrap a Workers fetch handler with AI crawler tracking. THROWS at
42
+ * construction (module scope — deploy time) when the config is invalid.
43
+ */
44
+ export function withAICrawlerTracking<Env, Res extends { status: number }>(
45
+ handler: WorkersHandler<Env, Res>,
46
+ config: AiCrawlConfig | ResolvedAiCrawlConfig,
47
+ ): WorkersHandler<Env, Res> {
48
+ const resolved = isResolvedConfig(config) ? config : defineAiCrawlConfig(config);
49
+ // On Workers the platform strips and rewrites cf-connecting-ip, so it is
50
+ // trustworthy here (and only here / Pages) by default.
51
+ const cfg: ResolvedAiCrawlConfig = { ...resolved, trustCfConnectingIp: true };
52
+
53
+ return async (request, env, ctx) => {
54
+ // Handler errors propagate untouched — tracking must not change behaviour.
55
+ const response = await handler(request, env, ctx);
56
+ try {
57
+ trackFacts(
58
+ cfg,
59
+ factsFromFetchRequest(request),
60
+ typeof response?.status === "number" ? response.status : undefined,
61
+ ctx !== undefined && typeof ctx.waitUntil === "function"
62
+ ? (p) => ctx.waitUntil(p)
63
+ : undefined,
64
+ );
65
+ } catch {
66
+ /* silent by contract */
67
+ }
68
+ return response;
69
+ };
70
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Express adapter (also works for Connect-style and bare Node servers with a
3
+ * compatible `(req, res, next)` signature).
4
+ *
5
+ * ```ts
6
+ * import { createTracetenAICrawlerMiddleware } from "@traceten/ai-crawl/express";
7
+ *
8
+ * app.use(
9
+ * createTracetenAICrawlerMiddleware({
10
+ * siteId: process.env.TRACETEN_SITE_ID!,
11
+ * authToken: process.env.TRACETEN_CRAWL_TOKEN!,
12
+ * }),
13
+ * );
14
+ * ```
15
+ *
16
+ * Construction throws on invalid config (server boot — where it should).
17
+ * The middleware calls `next()` IMMEDIATELY and attaches a `finish`
18
+ * listener, so the real status code is reported and the request is never
19
+ * delayed. Everything inside is wrapped; failure is silent.
20
+ *
21
+ * Structural types only — no dependency on Express, not even for types.
22
+ */
23
+
24
+ import { defineAiCrawlConfig, isResolvedConfig } from "../config.js";
25
+ import { evaluateRequest } from "../track.js";
26
+ import { sendReport } from "../report.js";
27
+ import type { AiCrawlConfig, RequestFacts, ResolvedAiCrawlConfig } from "../types.js";
28
+
29
+ /** Structural subset of `express.Request` / Node's `IncomingMessage`. */
30
+ export interface NodeLikeRequest {
31
+ method?: string | undefined;
32
+ /** Path + query as received (`/docs/x?y=1`). */
33
+ url?: string | undefined;
34
+ /** Express keeps the pre-router-mount path here; prefer it when present. */
35
+ originalUrl?: string | undefined;
36
+ headers: Record<string, string | string[] | undefined>;
37
+ /** Express convenience; used for URL scheme when present. */
38
+ protocol?: string | undefined;
39
+ socket?: { remoteAddress?: string | undefined } | undefined;
40
+ }
41
+
42
+ /** Structural subset of `express.Response` / Node's `ServerResponse`. */
43
+ export interface NodeLikeResponse {
44
+ statusCode?: number | undefined;
45
+ on(event: "finish", listener: () => void): unknown;
46
+ }
47
+
48
+ export type NodeLikeNext = (err?: unknown) => void;
49
+
50
+ function headerLookup(req: NodeLikeRequest): (name: string) => string | null {
51
+ return (name: string): string | null => {
52
+ try {
53
+ const value = req.headers[name.toLowerCase()];
54
+ if (value === undefined) return null;
55
+ return Array.isArray(value) ? (value[0] ?? null) : value;
56
+ } catch {
57
+ return null;
58
+ }
59
+ };
60
+ }
61
+
62
+ /** Build an absolute URL for the request, preferring `publicOrigin`. */
63
+ function requestUrl(cfg: ResolvedAiCrawlConfig, req: NodeLikeRequest): string {
64
+ const path = req.originalUrl ?? req.url ?? "/";
65
+ if (path.indexOf("://") !== -1) return path; // already absolute (rare)
66
+ if (cfg.publicOrigin !== undefined) return cfg.publicOrigin + path;
67
+ const hostHeader = req.headers["host"];
68
+ const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader;
69
+ if (host === undefined || host === "") return path;
70
+ const proto = req.protocol === "http" ? "http" : "https";
71
+ return `${proto}://${host}${path}`;
72
+ }
73
+
74
+ /**
75
+ * Create the middleware. THROWS at construction (server boot) when the
76
+ * config is invalid; the returned middleware itself never throws.
77
+ */
78
+ export function createTracetenAICrawlerMiddleware(
79
+ config: AiCrawlConfig | ResolvedAiCrawlConfig,
80
+ ): (req: NodeLikeRequest, res: NodeLikeResponse, next: NodeLikeNext) => void {
81
+ const cfg = isResolvedConfig(config) ? config : defineAiCrawlConfig(config);
82
+
83
+ return (req, res, next) => {
84
+ try {
85
+ const facts: RequestFacts = {
86
+ method: req.method ?? "GET",
87
+ url: requestUrl(cfg, req),
88
+ header: headerLookup(req),
89
+ socketAddr: req.socket?.remoteAddress,
90
+ };
91
+
92
+ // Evaluate up-front so non-crawler traffic attaches no listener at
93
+ // all; report on `finish` so the REAL status code is captured.
94
+ const payload = evaluateRequest(cfg, facts);
95
+ if (payload !== null) {
96
+ res.on("finish", () => {
97
+ try {
98
+ const status = res.statusCode;
99
+ if (typeof status === "number" && status >= 100 && status <= 599) {
100
+ payload.status = status;
101
+ }
102
+ void sendReport(cfg, payload);
103
+ } catch {
104
+ /* silent by contract */
105
+ }
106
+ });
107
+ }
108
+ } catch {
109
+ /* silent by contract */
110
+ }
111
+ next();
112
+ };
113
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Hono adapter + generic `Request`/`Response` helper.
3
+ *
4
+ * Hono (structural types — no dependency on hono):
5
+ * ```ts
6
+ * import { aiCrawlerTracking } from "@traceten/ai-crawl/hono";
7
+ * app.use("*", aiCrawlerTracking({ siteId: "…", authToken: "tt_bot_…" }));
8
+ * ```
9
+ *
10
+ * Any framework that exposes fetch-API `Request`/`Response`:
11
+ * ```ts
12
+ * import { defineAiCrawlConfig } from "@traceten/ai-crawl";
13
+ * import { trackAICrawlerFetch } from "@traceten/ai-crawl/hono";
14
+ * const config = defineAiCrawlConfig({ … });
15
+ * // after producing the response:
16
+ * trackAICrawlerFetch(request, response, config, ctx?.waitUntil?.bind(ctx));
17
+ * ```
18
+ */
19
+
20
+ import { defineAiCrawlConfig, isResolvedConfig } from "../config.js";
21
+ import { factsFromFetchRequest, trackFacts, type FetchLikeRequest } from "../track.js";
22
+ import type { AiCrawlConfig, ResolvedAiCrawlConfig } from "../types.js";
23
+
24
+ /**
25
+ * Generic helper for any `Request`/`Response` runtime. Never throws; pass a
26
+ * `waitUntil` when the runtime has one, otherwise delivery is
27
+ * fire-and-forget.
28
+ */
29
+ export function trackAICrawlerFetch(
30
+ request: FetchLikeRequest,
31
+ response: { status: number } | undefined,
32
+ config: ResolvedAiCrawlConfig,
33
+ waitUntil?: ((promise: Promise<unknown>) => void) | undefined,
34
+ ): void {
35
+ try {
36
+ if (!isResolvedConfig(config)) return; // raw config: refuse silently, never throw per-request
37
+ trackFacts(
38
+ config,
39
+ factsFromFetchRequest(request),
40
+ response !== undefined && typeof response.status === "number" ? response.status : undefined,
41
+ waitUntil,
42
+ );
43
+ } catch {
44
+ /* silent by contract */
45
+ }
46
+ }
47
+
48
+ /** Structural subset of Hono's `Context`. */
49
+ export interface HonoLikeContext {
50
+ req: { raw: FetchLikeRequest };
51
+ res: { status: number };
52
+ /** Throws on runtimes without an execution context — accessed inside try. */
53
+ executionCtx?: { waitUntil(promise: Promise<unknown>): void };
54
+ }
55
+
56
+ /**
57
+ * Hono middleware factory. THROWS at construction when the config is
58
+ * invalid; the middleware itself never throws its own errors (route errors
59
+ * propagate untouched).
60
+ */
61
+ export function aiCrawlerTracking(
62
+ config: AiCrawlConfig | ResolvedAiCrawlConfig,
63
+ ): (c: HonoLikeContext, next: () => Promise<void>) => Promise<void> {
64
+ const cfg = isResolvedConfig(config) ? config : defineAiCrawlConfig(config);
65
+
66
+ return async (c, next) => {
67
+ // Route errors propagate untouched — tracking must not change behaviour.
68
+ await next();
69
+ try {
70
+ let waitUntil: ((p: Promise<unknown>) => void) | undefined;
71
+ try {
72
+ const ctx = c.executionCtx; // getter throws on Node's hono server
73
+ if (ctx !== undefined && typeof ctx.waitUntil === "function") {
74
+ waitUntil = (p) => ctx.waitUntil(p);
75
+ }
76
+ } catch {
77
+ waitUntil = undefined;
78
+ }
79
+ trackFacts(
80
+ cfg,
81
+ factsFromFetchRequest(c.req.raw),
82
+ typeof c.res?.status === "number" ? c.res.status : undefined,
83
+ waitUntil,
84
+ );
85
+ } catch {
86
+ /* silent by contract */
87
+ }
88
+ };
89
+ }