@waniwani/relay 0.3.0 → 0.4.1

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
@@ -17,14 +17,17 @@ The [setup screen](https://app.waniwani.ai/agent-traffic/setup) in Waniwani give
17
17
  ### `isAgent(request)`
18
18
 
19
19
  ```ts
20
- function isAgent(request: Request | CloudFrontRequest): boolean;
20
+ function isAgent(
21
+ request: Request | CloudFrontRequest,
22
+ options?: Pick<RelayOptions, "region" | "apiUrl" | "waitUntil" | "rules">,
23
+ ): boolean;
21
24
  ```
22
25
 
23
- Tells whether the request comes from an AI agent. It reads the headers and the client IP only. It makes no network call, keeps no state between requests and never throws. It returns `true` when any of these hold:
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:
24
27
 
25
- - 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.
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.
26
29
  - The request has a `Signature-Agent` header ([Web Bot Auth](https://datatracker.ietf.org/wg/webbotauth/about/)).
27
- - 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).
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).
28
31
  - The request has a `DPoP` header, or its `Authorization` header uses the `DPoP` scheme.
29
32
 
30
33
  ### `recordAgentVisit(request, options?)`
@@ -46,6 +49,7 @@ Posts one event for the request to Waniwani after the response ([`AgentVisitReco
46
49
  | `apiUrl` | The host of `region` | Another Waniwani host, for local development |
47
50
  | `key` | `process.env.WANIWANI_AGENT_TRAFFIC_KEY` | The key from the setup screen, `wwt_<token>.<org key>` |
48
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 |
49
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. |
50
54
 
51
55
  #### `AgentVisitRecord`
@@ -84,6 +88,102 @@ The `headers` field can hold only these request headers:
84
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 |
85
89
  | Other | No, when you pass `waitUntil` | 2 s, one retry after 500 ms |
86
90
 
91
+ ### `serveAgentPage(request, options?)`
92
+
93
+ ```ts
94
+ function serveAgentPage(
95
+ request: Request,
96
+ options?: ServeAgentPageOptions,
97
+ ): Promise<Response | null>;
98
+ function serveAgentPage(
99
+ request: CloudFrontRequest,
100
+ options?: ServeAgentPageOptions,
101
+ ): Promise<CloudFrontResultResponse | null>;
102
+ ```
103
+
104
+ `ServeAgentPageOptions` takes `region`, `apiUrl` and `key` from `RelayOptions`.
105
+
106
+ 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:
107
+
108
+ - A key is set.
109
+ - The method is `GET` or `HEAD`.
110
+ - The request has no `x-waniwani-render` header. Waniwani sends that header when it renders your page.
111
+ - 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.
112
+ - 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.
113
+ - Waniwani sends the stored page in time. The rules and the deadlines come from your Waniwani settings.
114
+
115
+ You can block AI agents per site in Waniwani. When the `User-Agent` of a `GET` or `HEAD` request contains a blocked token, the function answers `403` with the text body `Forbidden`. That check runs before the agent page checks and before the `x-waniwani-render` check, so it works with agent pages off. The response carries `x-waniwani-blocked: 1`, `cache-control: private, no-store` and `vary: user-agent`.
116
+
117
+ The page 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.
118
+
119
+ On Vercel, in `middleware.ts`:
120
+
121
+ ```ts
122
+ import { isAgent, recordAgentVisit, serveAgentPage } from "@waniwani/relay";
123
+ import { NextResponse, type NextRequest } from "next/server";
124
+
125
+ export async function middleware(request: NextRequest) {
126
+ if (isAgent(request)) {
127
+ recordAgentVisit(request);
128
+ return (await serveAgentPage(request)) ?? NextResponse.next();
129
+ }
130
+ return NextResponse.next();
131
+ }
132
+ ```
133
+
134
+ In a Cloudflare Worker, pass the key and fall back to your origin:
135
+
136
+ ```ts
137
+ return (await serveAgentPage(request, { key: env.WANIWANI_AGENT_TRAFFIC_KEY })) ?? fetch(request);
138
+ ```
139
+
140
+ ### `handleViewerRequest(request, options?)` and CloudFront
141
+
142
+ ```ts
143
+ function handleViewerRequest(
144
+ request: CloudFrontRequest,
145
+ options?: ServeAgentPageOptions,
146
+ ): Promise<CloudFrontResultResponse | null>;
147
+ ```
148
+
149
+ CloudFront needs two Lambda@Edge functions, one on each of two events. On Vercel and Cloudflare one function sends the agent page. On CloudFront that is not possible, because of a Lambda@Edge limit on the body of a response that the function makes itself:
150
+
151
+ | Event | Body limit |
152
+ | --- | --- |
153
+ | Viewer request | 40 KB |
154
+ | Origin request | 1 MB |
155
+
156
+ Most agent pages are larger than 40 KB, so the page must come from origin request. But origin request runs only on a cache miss. When people already opened `/pricing`, CloudFront has that page in its cache, and an agent request for `/pricing` gets the cached copy without a call to origin request. The viewer-request function therefore changes the path of an agent request, so that the request has a different cache key and is always a cache miss.
157
+
158
+ The mark goes in the path because the path is the only part of the request that is in the cache key with every cache policy. A header or the query string is in the cache key only when the cache policy of your behavior includes it, and the managed `CachingOptimized` policy includes neither. The mark also carries the hostname of the site, because at origin request CloudFront usually replaces the `Host` header with the domain of your origin.
159
+
160
+ The two events do these jobs:
161
+
162
+ - **Viewer request** runs on every request, before the CloudFront cache. `handleViewerRequest` decides what to do with the request:
163
+ - When the site blocks this `User-Agent`, it answers `403`. CloudFront does not cache a response from this event, so a change to your block list applies to the next request.
164
+ - When an AI agent that acts for a user asks for a path that has an agent page, it marks the request. To mark it, it adds `/__waniwani-agent/<host>` in front of the path in `request.uri`. On CloudFront, `request.uri` is the path only, with no host and no query string. For `https://example.com/pricing?a=1`, `request.uri` changes from `/pricing` to `/__waniwani-agent/example.com/pricing`, and the query string stays as it is.
165
+ - In all other cases it returns `null`, and the request continues with no change.
166
+ - **Origin request** runs only on a cache miss, which is when CloudFront has no stored response for the request. The agent page has `cache-control: private, no-store`, so CloudFront never stores it, and a marked request is always a cache miss. `serveAgentPage` sends the agent page for a marked request. It removes the mark first. So when it cannot send the page, `?? request` sends the original path to your origin.
167
+
168
+ A client cannot mark its own request. The viewer handler removes a `/__waniwani-agent/` prefix that arrives from the client. Neither function throws.
169
+
170
+ Viewer-request handler:
171
+
172
+ ```ts
173
+ const request = event.Records[0].cf.request;
174
+ if (isAgent(request)) await recordAgentVisit(request, { key });
175
+ return (await handleViewerRequest(request, { key })) ?? request;
176
+ ```
177
+
178
+ Origin-request handler:
179
+
180
+ ```ts
181
+ const request = event.Records[0].cf.request;
182
+ return (await serveAgentPage(request, { key })) ?? request;
183
+ ```
184
+
185
+ Only requests that will get an agent page are marked, so crawlers and people share the normal cache entry, and each listed path gets one extra entry. Lambda@Edge has no environment variables, so pass `key`. People never wait on the site rules: the viewer handler loads them only for an AI agent. A page too large for the 1 MB limit of Lambda@Edge falls through to your origin, and the body goes out as UTF-8 text.
186
+
87
187
  ## Dependencies
88
188
 
89
189
  The package has no runtime dependencies. The IP range check uses `BlockList` from `node:net`.
@@ -94,10 +194,10 @@ The package has no runtime dependencies. The IP range check uses `BlockList` fro
94
194
  bun install
95
195
  bun run build # typecheck, lint and dist/index.js
96
196
  bun run test # unit tests (run the build first, one test imports dist)
97
- bun run rules # refreshes src/logic/ip-ranges.generated.json from the vendors' published lists
197
+ bun run rules # writes the two src/logic/*.generated.json files from the app rules file
98
198
  ```
99
199
 
100
- `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.
200
+ `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.
101
201
 
102
202
  ## License
103
203
 
package/dist/index.d.ts CHANGED
@@ -19,9 +19,26 @@ declare const settings: {
19
19
  };
20
20
  requestTimeoutMs: number;
21
21
  cloudFrontTimeoutMs: number;
22
+ cloudFrontMaxUriBytes: number;
23
+ cloudFrontMaxBodyBytes: number;
22
24
  retryDelayMs: number;
25
+ rulesRefreshMs: number;
26
+ rulesRetryMs: number;
27
+ siteRulesTimeoutMs: number;
28
+ siteRulesRefreshMs: number;
29
+ siteRulesRetryMs: number;
23
30
  };
24
31
 
32
+ /** The response a Lambda@Edge handler returns in place of the request. */
33
+ type CloudFrontResultResponse = {
34
+ status: string;
35
+ statusDescription?: string;
36
+ headers: Record<string, Array<{
37
+ key: string;
38
+ value: string;
39
+ }>>;
40
+ body?: string;
41
+ };
25
42
  type Region = keyof typeof settings.apiUrls;
26
43
  type RelayOptions = {
27
44
  /** The region of your WaniWani organization. Defaults to `"us"`. */
@@ -34,7 +51,11 @@ type RelayOptions = {
34
51
  waitUntil?: (promise: Promise<unknown>) => void;
35
52
  /** Header names, and the fields `matchKey`, `userAgent` or `referer`, to leave out of every event. */
36
53
  omit?: Array<string>;
54
+ /** `"remote"` loads the newest AI bot list from WaniWani every 12 hours. `"bundled"` keeps the list shipped in this version. Defaults to `"remote"`. */
55
+ rules?: "remote" | "bundled";
37
56
  };
57
+ type IsAgentOptions = Pick<RelayOptions, "region" | "apiUrl" | "waitUntil" | "rules">;
58
+ type ServeAgentPageOptions = Pick<RelayOptions, "region" | "apiUrl" | "key">;
38
59
  type AgentVisitRecord = {
39
60
  occurredAt: string;
40
61
  method: string;
@@ -48,14 +69,31 @@ type AgentVisitRecord = {
48
69
  relayVersion: string;
49
70
  platform?: Platform;
50
71
  };
51
- /** Tells whether a request, or the `cf.request` of a Lambda@Edge event, comes from an AI agent. Reads headers only, makes no network call. */
52
- declare function isAgent(request: Request | CloudFrontRequest): boolean;
72
+ /**
73
+ * 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.
74
+ * Every 12 hours it starts one background fetch of the newest AI bot list, unless `rules` is `"bundled"`.
75
+ */
76
+ declare function isAgent(request: Request | CloudFrontRequest, options?: IsAgentOptions): boolean;
53
77
  /**
54
78
  * Posts one event for this request to WaniWani after the response.
55
79
  * Sends nothing when no key is set. Never throws, never rejects and never touches the request.
56
80
  * The promise settles when the post is done, so Lambda@Edge, which has no `waitUntil`, awaits it.
57
81
  */
58
82
  declare function recordAgentVisit(request: Request | CloudFrontRequest, options?: RelayOptions): Promise<void>;
83
+ /**
84
+ * The stored agent page for this request, when its site lists the path and the user agent acts for a user. A 403 when the site blocks the user agent. Otherwise null, and the caller serves the origin.
85
+ * A `CloudFrontRequest` comes from the origin-request handler. Only a URI that `handleViewerRequest` marked gets the page, read for the host in the mark. The function restores `request.uri` to the original path before anything else, and it never returns a 403 on this path.
86
+ * Never throws and never rejects.
87
+ */
88
+ declare function serveAgentPage(request: Request, options?: ServeAgentPageOptions): Promise<Response | null>;
89
+ declare function serveAgentPage(request: CloudFrontRequest, options?: ServeAgentPageOptions): Promise<CloudFrontResultResponse | null>;
90
+ /**
91
+ * For the viewer-request handler on CloudFront. Strips a `/__waniwani-agent/` prefix the client sent, then, for a GET or HEAD from an AI agent, loads the rules of the site of the `host` header.
92
+ * Returns the 403 response when the site blocks the user agent. CloudFront never caches a response made at viewer-request, so a block takes effect at once.
93
+ * When the site serves an agent page for the path and the user agent acts for a user, rewrites `request.uri` to `/__waniwani-agent/<host><original uri>` so the origin-request handler serves the page under its own cache key. The request stays unmarked when the new URI would pass the CloudFront URI limit.
94
+ * Call `recordAgentVisit` before it, so the event holds the original path. Never throws and never rejects.
95
+ */
96
+ declare function handleViewerRequest(request: CloudFrontRequest, options?: ServeAgentPageOptions): Promise<CloudFrontResultResponse | null>;
59
97
 
60
- export { isAgent, recordAgentVisit };
61
- export type { AgentVisitRecord, CloudFrontRequest, Region, RelayOptions };
98
+ export { handleViewerRequest, isAgent, recordAgentVisit, serveAgentPage };
99
+ export type { AgentVisitRecord, CloudFrontRequest, CloudFrontResultResponse, IsAgentOptions, Region, RelayOptions, ServeAgentPageOptions };