@waniwani/relay 0.1.1 → 0.4.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.
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # @waniwani/relay
2
2
 
3
- `@waniwani/relay` records the visits of AI agents to your website and sends them to [WaniWani](https://waniwani.ai). It runs in the middleware of your site, the code that sees each request before your pages do. For each request from an AI agent, it posts one small event to WaniWani after the response has gone out. It never changes the request or the response, and a person who visits your site sends nothing.
4
-
5
- The code is Apache 2.0 licensed and short, so your security team can read all of it before you deploy.
3
+ `@waniwani/relay` records the visits of AI agents to your website and sends them to [Waniwani](https://waniwani.ai). It runs in the middleware of your site, the code that sees each request before your pages do. For each request from an AI agent, it posts one small event to Waniwani after the response has gone out. It never changes the request or the response, and a person who visits your site sends nothing.
6
4
 
7
5
  ## Install
8
6
 
@@ -10,75 +8,53 @@ The code is Apache 2.0 licensed and short, so your security team can read all of
10
8
  npm install @waniwani/relay
11
9
  ```
12
10
 
13
- Copy the key from the WaniWani setup screen into the environment variable `WANIWANI_AGENT_TRAFFIC_KEY` of your production deployment. When the variable is not set, the library sends nothing, so preview deployments and local runs stay silent.
11
+ ## Setup
14
12
 
15
- ## Vercel
13
+ The [setup screen](https://app.waniwani.ai/agent-traffic/setup) in Waniwani gives you the code for your platform: Vercel, Cloudflare Workers or AWS CloudFront (Lambda@Edge). For an organization in the EU region, use [eu.app.waniwani.ai](https://eu.app.waniwani.ai/agent-traffic/setup).
16
14
 
17
- ### Next.js
15
+ ## API
18
16
 
19
- Next.js 16 calls its middleware file `proxy.ts` (it was `middleware.ts` before). Add the two calls to that file:
17
+ ### `isAgent(request)`
20
18
 
21
19
  ```ts
22
- // proxy.ts
23
- import { isAgent, recordAgentVisit } from "@waniwani/relay";
24
-
25
- export default function proxy(request: Request) {
26
- if (isAgent(request)) {
27
- recordAgentVisit(request);
28
- }
29
- }
30
-
31
- export const config = {
32
- matcher: ["/((?!api/|_next/|.*\\.[a-z0-9]{1,5}$).*)", "/robots.txt", "/llms.txt"],
33
- };
20
+ function isAgent(
21
+ request: Request | CloudFrontRequest,
22
+ options?: Pick<RelayOptions, "region" | "apiUrl" | "waitUntil" | "rules">,
23
+ ): boolean;
34
24
  ```
35
25
 
36
- If you already have a proxy, put the `if` block at the top of your proxy function and leave the rest as it is. The matcher keeps `/api/`, `/_next/` and every path with a file extension out of the middleware, so scripts, images, fonts and API calls never run it. It lists `/robots.txt` and `/llms.txt` by name, because AI agents read them.
37
-
38
- ### Other frameworks
39
-
40
- Point Vercel at the middleware file with the `proxy` property in `vercel.json` (the same key works in `vercel.ts`):
26
+ Tells whether the request comes from an AI agent. It reads the headers and the client IP only, never waits on the network and never throws. It returns `true` when any of these hold:
41
27
 
42
- ```json
43
- {
44
- "proxy": {
45
- "entrypoint": "proxy.ts",
46
- "matcher": ["/((?!api/|.*\\.[a-z0-9]{1,5}$).*)", "/robots.txt", "/llms.txt"]
47
- }
48
- }
49
- ```
50
-
51
- ```ts
52
- // proxy.ts
53
- import { isAgent, recordAgentVisit } from "@waniwani/relay";
54
-
55
- export default function proxy(request: Request) {
56
- if (isAgent(request)) {
57
- recordAgentVisit(request);
58
- }
59
- }
60
- ```
28
+ - The `User-Agent` header contains a token from the AI bot list, for example `ChatGPT-User`, `Claude-User`, `GPTBot` or `PerplexityBot`. The list holds the AI bots in the Waniwani bot catalog and the AI bots on [bots.fyi](https://bots.fyi): crawlers that collect training data, AI search bots, fetchers that act for a user and browsing agents. Classic search indexers such as `Googlebot` and `Bingbot`, browsers, `curl` and uptime monitors are not on it.
29
+ - The request has a `Signature-Agent` header ([Web Bot Auth](https://datatracker.ietf.org/wg/webbotauth/about/)).
30
+ - The client IP sits inside an IP range that an AI vendor publishes for its bots, for example the lists of OpenAI, Anthropic, Perplexity, Mistral, Apple, Common Crawl and DuckDuckGo (DuckAssistBot).
31
+ - The request has a `DPoP` header, or its `Authorization` header uses the `DPoP` scheme.
61
32
 
62
- If your organization is in the EU region, pass the region. Its events then go only to the EU host:
33
+ ### `recordAgentVisit(request, options?)`
63
34
 
64
35
  ```ts
65
- recordAgentVisit(request, { region: "eu" });
36
+ function recordAgentVisit(
37
+ request: Request | CloudFrontRequest,
38
+ options?: RelayOptions,
39
+ ): Promise<void>;
66
40
  ```
67
41
 
68
- The setup screen shows the code for your organization.
69
-
70
- ## How a request is matched
42
+ Posts one event for the request to Waniwani after the response ([`AgentVisitRecord`](#agentvisitrecord)). Needs a secret key setup, otherwise sends nothing. The function never throws, and the promise never rejects.
71
43
 
72
- `isAgent(request)` reads a few request headers and the client IP. It makes no network call and keeps no state between requests. It returns `true` when any of these hold:
44
+ #### `RelayOptions`
73
45
 
74
- - The `User-Agent` header contains a token from the AI bot list, for example `ChatGPT-User`, `Claude-User`, `GPTBot` or `PerplexityBot`. The list comes from the AI bots in the WaniWani bot catalog: crawlers that collect training data, AI search bots and fetchers that act for a user. Classic search indexers such as `Googlebot` and `Bingbot`, browsers, `curl` and uptime monitors are not on it.
75
- - The request has a `Signature-Agent` header ([Web Bot Auth](https://datatracker.ietf.org/wg/webbotauth/about/)).
76
- - The client IP sits inside an IP range that an AI vendor publishes for its bots. Today the ranges come from OpenAI, Perplexity, Mistral, Common Crawl and DuckDuckGo (DuckAssistBot).
77
- - The request has a `DPoP` header, or its `Authorization` header uses the `DPoP` scheme.
46
+ | Option | Default | Purpose |
47
+ | --- | --- | --- |
48
+ | `region` | `"us"` | The region of your organization: `"us"` sends to `https://app.waniwani.ai`, `"eu"` to `https://eu.app.waniwani.ai` |
49
+ | `apiUrl` | The host of `region` | Another Waniwani host, for local development |
50
+ | `key` | `process.env.WANIWANI_AGENT_TRAFFIC_KEY` | The key from the setup screen, `wwt_<token>.<org key>` |
51
+ | `waitUntil` | Found by itself on Vercel, none on other platforms | Keeps the post alive after the response |
52
+ | `rules` | `"remote"` | `"remote"` loads the newest bot list from Waniwani every 12 hours, `"bundled"` uses only the list that ships in the package |
53
+ | `omit` | `[]` | Header names, and the fields `matchKey`, `userAgent` and `referer`, to leave out of every event. An event without `matchKey` still counts, but Waniwani then joins it with the chat embed's events on site, user agent and path only, which is less exact. |
78
54
 
79
- ## What is sent
55
+ #### `AgentVisitRecord`
80
56
 
81
- For each matched request, `recordAgentVisit` posts one JSON record to `<host>/api/v2/agent-traffic/ingest/middleware/<token>`:
57
+ For each matched request, `recordAgentVisit` posts one JSON record of type `AgentVisitRecord` to Waniwani platform:
82
58
 
83
59
  | Field | Value |
84
60
  | --- | --- |
@@ -87,72 +63,74 @@ For each matched request, `recordAgentVisit` posts one JSON record to `<host>/ap
87
63
  | `host` | The hostname of the request URL, without the port |
88
64
  | `path` | The path with its query string |
89
65
  | `userAgent`, `referer` | Copies of those two headers |
90
- | `headers` | The allowed headers that the request has (see below) |
91
- | `matchKey` | `HMAC-SHA256(org key, client IP)` as hex. WaniWani uses it to join this event with other events of the same visit. |
66
+ | `headers` | The request headers from the next table that the request has |
67
+ | `matchKey` | `HMAC-SHA256(org key, client IP)` as hex. Waniwani uses it to join this event with other events of the same visit. |
92
68
  | `sourceEventId` | A random UUID for this request |
93
69
  | `relayVersion` | The version of this library |
70
+ | `platform` | `vercel`, `cloudflare` or `cloudfront`, when the library knows the platform it runs on |
94
71
 
95
- Only these headers can ever go into an event: `user-agent`, `referer`, `accept`, `accept-language`, `from`, `signature-agent`, `signature-input`, `signature`, `dpop`, `sec-ch-ua`, `sec-ch-ua-platform`, `sec-ch-ua-mobile` and `authorization`. For `authorization` the library sends the scheme word alone, in its standard spelling, and only when a credential follows it and the word is one of `Basic`, `Bearer`, `Digest`, `DPoP`, `HOBA`, `Mutual`, `Negotiate`, `NTLM`, `OAuth`, `PrivateToken`, `SCRAM-SHA-1`, `SCRAM-SHA-256`, `vapid` or `AWS4-HMAC-SHA256`. Any other value sends no `authorization` header at all, so a raw API key in that header never leaves your deployment.
72
+ The `headers` field can hold only these request headers:
96
73
 
97
- These never leave your deployment:
74
+ | Header | What is sent |
75
+ | --- | --- |
76
+ | `user-agent`, `referer`, `accept`, `accept-language`, `from` | The value as it came |
77
+ | `signature-agent`, `signature-input`, `signature` | The value as it came (Web Bot Auth) |
78
+ | `dpop` | The value as it came |
79
+ | `sec-ch-ua`, `sec-ch-ua-platform`, `sec-ch-ua-mobile` | The value as it came |
80
+ | `authorization` | The secret never leaves. Only the scheme word goes out, for example `Bearer`. It goes out only when a credential follows it and the word is one of `Basic`, `Bearer`, `Digest`, `DPoP`, `HOBA`, `Mutual`, `Negotiate`, `NTLM`, `OAuth`, `PrivateToken`, `SCRAM-SHA-1`, `SCRAM-SHA-256`, `vapid` or `AWS4-HMAC-SHA256`. For any other value, such as a raw API key, the event has no `authorization` header. |
98
81
 
99
- - The `cookie` header. The library never reads it.
100
- - The credential in the `Authorization` header.
101
- - The raw client IP. The library turns it into the match key with your org key and never logs it. The org key is the part of `WANIWANI_AGENT_TRAFFIC_KEY` after the dot, so two organizations get unrelated match keys for the same IP.
102
- - The request body, other headers and your environment variables.
82
+ #### Platform differences
103
83
 
104
- ### Sending less
84
+ | Platform | Await the call | Post timeout |
85
+ | --- | --- | --- |
86
+ | Vercel | No | 2 s, one retry after 500 ms |
87
+ | Cloudflare Workers | No | 2 s, one retry after 500 ms |
88
+ | AWS CloudFront (Lambda@Edge) | Yes, before the handler returns. Lambda freezes the function when the handler returns, so a post event request that is not awaited is lost. | 1 s, no retry |
89
+ | Other | No, when you pass `waitUntil` | 2 s, one retry after 500 ms |
105
90
 
106
- `omit` takes header names, plus the field names `matchKey`, `userAgent` and `referer`. Whatever it names is left out of every event:
91
+ ### `serveAgentPage(request, options?)`
107
92
 
108
93
  ```ts
109
- recordAgentVisit(request, {
110
- omit: ["accept-language", "matchKey"],
111
- });
94
+ function serveAgentPage(
95
+ request: Request,
96
+ options?: Pick<RelayOptions, "region" | "apiUrl" | "key">,
97
+ ): Promise<Response | null>;
112
98
  ```
113
99
 
114
- An event without a match key still counts. WaniWani then joins it with the chat embed's events on site, user agent and path alone, which is less exact.
100
+ Serves the agent page of a request: a text-first copy of the page that Waniwani renders and stores. It returns that page when all of these hold, and `null` otherwise, so your middleware passes the request on to your site:
115
101
 
116
- ## When WaniWani is slow or down
102
+ - A key is set.
103
+ - The method is `GET` or `HEAD`.
104
+ - Agent pages are on in Waniwani for the hostname of the request, and its path is in the list of agent page paths. An entry is an exact path such as `/pricing`, or a folder such as `/products/*`, which covers `/products` and every path under it. `*` covers every path.
105
+ - The `User-Agent` belongs to an AI agent that acts for a user, for example `ChatGPT-User` or `Claude-User`. Crawlers that collect training data get your site as usual.
106
+ - Waniwani sends the stored page in time. The rules and the deadlines come from your Waniwani settings.
117
107
 
118
- Your site does not notice. The post runs after the response, inside the `waitUntil` of your platform, with a 2 second timeout. After a network error, a timeout, a 429 or a 5xx it tries once more after 500 ms. Any other 4xx gets no retry. If the second try fails as well, the library writes one line to your function logs and drops the event. It never throws.
108
+ The response carries the stored content type, `x-waniwani-agent-page: hit`, `cache-control: private, no-store` and `vary: user-agent, accept`. A `HEAD` request gets the same headers with no body. The settings load on the first call, with a 300 ms limit, and stay in memory for 60 seconds. After that the relay keeps serving the cached settings while it loads new ones in the background. The function never throws, and the promise never rejects. It takes a `Request` only, so it works on Vercel and Cloudflare Workers.
119
109
 
120
- ## Dependencies
121
-
122
- The package has no runtime dependencies. The IP range check uses `BlockList` from `node:net`. On Vercel it finds `waitUntil` by itself, in the request context that the Vercel runtime sets for each request. On other platforms you pass `waitUntil` in the options. The bot list, the IP ranges and the match code ship inside the package, and the library never downloads code. To get a newer bot list, upgrade the package.
110
+ On Vercel, in `middleware.ts`:
123
111
 
124
- ## Cloudflare Workers and other platforms
112
+ ```ts
113
+ import { isAgent, recordAgentVisit, serveAgentPage } from "@waniwani/relay";
114
+ import { NextResponse, type NextRequest } from "next/server";
125
115
 
126
- The library imports `node:net` for its IP range check, so a Cloudflare Worker needs the `nodejs_compat` flag. Workers with a compatibility date of 2026-08-04 or later have it on by default.
116
+ export async function middleware(request: NextRequest) {
117
+ if (isAgent(request)) {
118
+ recordAgentVisit(request);
119
+ return (await serveAgentPage(request)) ?? NextResponse.next();
120
+ }
121
+ return NextResponse.next();
122
+ }
123
+ ```
127
124
 
128
- Outside Vercel, pass the key and `waitUntil` yourself. In a Cloudflare Worker that looks like this:
125
+ In a Cloudflare Worker, pass the key and fall back to your origin:
129
126
 
130
127
  ```ts
131
- import { isAgent, recordAgentVisit, type RelayOptions } from "@waniwani/relay";
132
-
133
- export default {
134
- async fetch(request: Request, env: { WANIWANI_AGENT_TRAFFIC_KEY: string }, ctx: ExecutionContext) {
135
- const relay: RelayOptions = {
136
- key: env.WANIWANI_AGENT_TRAFFIC_KEY,
137
- waitUntil: ctx.waitUntil.bind(ctx),
138
- };
139
- if (isAgent(request)) {
140
- recordAgentVisit(request, relay);
141
- }
142
- return fetch(request);
143
- },
144
- };
128
+ return (await serveAgentPage(request, { key: env.WANIWANI_AGENT_TRAFFIC_KEY })) ?? fetch(request);
145
129
  ```
146
130
 
147
- ## Options
131
+ ## Dependencies
148
132
 
149
- | Option | Default | Purpose |
150
- | --- | --- | --- |
151
- | `region` | `"us"` | The region of your organization: `"us"` sends to `https://app.waniwani.ai`, `"eu"` to `https://eu.app.waniwani.ai` |
152
- | `apiUrl` | The host of `region` | Another WaniWani host, for local development |
153
- | `key` | `process.env.WANIWANI_AGENT_TRAFFIC_KEY` | The key from the setup screen, `wwt_<token>.<org key>` |
154
- | `waitUntil` | Found by itself on Vercel, none on other platforms | Keeps the post alive after the response |
155
- | `omit` | `[]` | Headers and fields to leave out of every event |
133
+ The package has no runtime dependencies. The IP range check uses `BlockList` from `node:net`.
156
134
 
157
135
  ## Development
158
136
 
@@ -160,10 +138,10 @@ export default {
160
138
  bun install
161
139
  bun run build # typecheck, lint and dist/index.js
162
140
  bun run test # unit tests (run the build first, one test imports dist)
163
- bun run rules # refreshes src/logic/ip-ranges.generated.json from the vendors' published lists
141
+ bun run rules # writes the two src/logic/*.generated.json files from the app rules file
164
142
  ```
165
143
 
166
- `src/logic/agents.json` is copied by hand from the WaniWani bot catalog (`src/lib/agent-traffic/bot-catalog.ts` in the WaniWani app): every entry of kind `training`, `search` or `fetcher`, using its `token` when it has one and its lowercased name otherwise.
144
+ `bun run rules` reads `https://app.waniwani.ai/api/v2/agent-traffic/rules`, or the URL in `RULES_URL`, and writes `src/logic/agents.generated.json` and `src/logic/ip-ranges.generated.json`, the fallback list that ships in the package. Never edit them by hand. `npm version` runs it before every release and commits the two files with the new version. When the rules route does not answer, the release stops before the version changes.
167
145
 
168
146
  ## License
169
147
 
package/dist/index.d.ts CHANGED
@@ -1,10 +1,30 @@
1
+ type Platform = "vercel" | "cloudflare" | "cloudfront";
2
+
3
+ /** `event.Records[0].cf.request` of a Lambda@Edge viewer-request event. */
4
+ type CloudFrontRequest = {
5
+ clientIp: string;
6
+ method: string;
7
+ uri: string;
8
+ querystring: string;
9
+ headers: Record<string, Array<{
10
+ key?: string;
11
+ value: string;
12
+ }>>;
13
+ };
14
+
1
15
  declare const settings: {
2
16
  apiUrls: {
3
17
  us: string;
4
18
  eu: string;
5
19
  };
6
20
  requestTimeoutMs: number;
21
+ cloudFrontTimeoutMs: number;
7
22
  retryDelayMs: number;
23
+ rulesRefreshMs: number;
24
+ rulesRetryMs: number;
25
+ siteRulesTimeoutMs: number;
26
+ siteRulesRefreshMs: number;
27
+ siteRulesRetryMs: number;
8
28
  };
9
29
 
10
30
  type Region = keyof typeof settings.apiUrls;
@@ -19,7 +39,11 @@ type RelayOptions = {
19
39
  waitUntil?: (promise: Promise<unknown>) => void;
20
40
  /** Header names, and the fields `matchKey`, `userAgent` or `referer`, to leave out of every event. */
21
41
  omit?: Array<string>;
42
+ /** `"remote"` loads the newest AI bot list from WaniWani every 12 hours. `"bundled"` keeps the list shipped in this version. Defaults to `"remote"`. */
43
+ rules?: "remote" | "bundled";
22
44
  };
45
+ type IsAgentOptions = Pick<RelayOptions, "region" | "apiUrl" | "waitUntil" | "rules">;
46
+ type ServeAgentPageOptions = Pick<RelayOptions, "region" | "apiUrl" | "key">;
23
47
  type AgentVisitRecord = {
24
48
  occurredAt: string;
25
49
  method: string;
@@ -31,14 +55,24 @@ type AgentVisitRecord = {
31
55
  matchKey?: string;
32
56
  sourceEventId: string;
33
57
  relayVersion: string;
58
+ platform?: Platform;
34
59
  };
35
- /** Tells whether a request comes from an AI agent. Reads headers only, makes no network call. */
36
- declare function isAgent(request: Request): boolean;
60
+ /**
61
+ * Tells whether a request, or the `cf.request` of a Lambda@Edge event, comes from an AI agent. Reads headers only and never waits on the network.
62
+ * Every 12 hours it starts one background fetch of the newest AI bot list, unless `rules` is `"bundled"`.
63
+ */
64
+ declare function isAgent(request: Request | CloudFrontRequest, options?: IsAgentOptions): boolean;
37
65
  /**
38
66
  * Posts one event for this request to WaniWani after the response.
39
- * Sends nothing when no key is set. Never throws and never touches the request.
67
+ * Sends nothing when no key is set. Never throws, never rejects and never touches the request.
68
+ * The promise settles when the post is done, so Lambda@Edge, which has no `waitUntil`, awaits it.
69
+ */
70
+ declare function recordAgentVisit(request: Request | CloudFrontRequest, options?: RelayOptions): Promise<void>;
71
+ /**
72
+ * The stored agent page for this request, when its site lists the path and the user agent acts for a user. Otherwise null, and the caller serves the origin.
73
+ * Never throws and never rejects.
40
74
  */
41
- declare function recordAgentVisit(request: Request, options?: RelayOptions): void;
75
+ declare function serveAgentPage(request: Request, options?: ServeAgentPageOptions): Promise<Response | null>;
42
76
 
43
- export { isAgent, recordAgentVisit };
44
- export type { AgentVisitRecord, Region, RelayOptions };
77
+ export { isAgent, recordAgentVisit, serveAgentPage };
78
+ export type { AgentVisitRecord, CloudFrontRequest, IsAgentOptions, Region, RelayOptions, ServeAgentPageOptions };