@scoutline-ai/middleware 0.0.0-stage → 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.
package/README.md CHANGED
@@ -1,3 +1,92 @@
1
- # Temporary Holding Version
1
+ # @scoutline-ai/middleware
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Records how agents use your site. It fails open: if Scoutline is down, misconfigured or slow, your site behaves exactly the same. Sending happens after your response, so it adds no latency.
4
+
5
+ Set your key as an environment variable:
6
+
7
+ ```sh
8
+ SCOUTLINE_KEY=sl_live_...
9
+ ```
10
+
11
+ ## Next.js
12
+
13
+ Wrap your proxy in `proxy.ts` (`middleware.ts` before Next.js 16). Your existing handler keeps working.
14
+
15
+ ```ts
16
+ import { withScoutline } from "@scoutline-ai/middleware/next";
17
+
18
+ export default withScoutline(yourProxy); // or withScoutline() if you have none
19
+
20
+ export const config = {
21
+ matcher: [
22
+ {
23
+ source: "/((?!_next/static|_next/image).*)",
24
+ missing: [
25
+ { type: "header", key: "next-router-prefetch" },
26
+ { type: "header", key: "purpose", value: "prefetch" },
27
+ ],
28
+ },
29
+ ],
30
+ };
31
+ ```
32
+
33
+ Next.js removes prefetch headers before your proxy runs, so only the matcher can exclude them. The `missing` rule skips your whole proxy for prefetches, so leave it out if your proxy enforces auth. Prefetches then count as `unknown` traffic. They only come from browsers, so crawler and agent counts are unaffected.
34
+
35
+ A proxy runs before the page renders, so normal page loads have no status or duration. Scoutline leaves those empty and only records a status when the proxy itself responds, such as a redirect.
36
+
37
+ ## Cloudflare Workers and Hono
38
+
39
+ ```ts
40
+ import { withScoutline } from "@scoutline-ai/middleware/fetch";
41
+
42
+ export default { fetch: withScoutline(app.fetch) };
43
+ ```
44
+
45
+ This sees the full response, so it records status, duration and size.
46
+
47
+ ## Express and Fastify
48
+
49
+ ```ts
50
+ import { scoutlineExpress, scoutlineFastify } from "@scoutline-ai/middleware/node";
51
+
52
+ app.use(scoutlineExpress()); // Express: before your routes and body parsers
53
+
54
+ app.register(scoutlineFastify); // Fastify
55
+ ```
56
+
57
+ Both see the full response, so they record status, duration and size. Events send every few seconds.
58
+
59
+ ## Browser events
60
+
61
+ The server sees every request, including crawlers that never run JavaScript. The browser script adds what only a page can see: real status and load time, in-page navigation, clicks and form submits, and errors.
62
+
63
+ **Cloudflare Workers:** nothing to do. HTML pages get the script automatically.
64
+
65
+ **Next.js, Hono, Express and Fastify:** add one tag to your root layout or HTML template.
66
+
67
+ ```html
68
+ <script src="/_scoutline/s" defer></script>
69
+ ```
70
+
71
+ The script and its events are served from your own domain by this package, so there is no public key and no third-party request. If your proxy `matcher` is limited to certain paths, include `/_scoutline/:path*`. These routes run before your own proxy, so an auth check won't block them.
72
+
73
+ It stores nothing and sets no cookies. A page links to its server request through a `Server-Timing` header. Each batch of events is one request to your server, sent when the page closes or after 20 events.
74
+
75
+ Browser events can be forged, so they are never `verified` and can't confirm an outcome. Reports keep them out of request counts.
76
+
77
+ ## What is sent
78
+
79
+ From the server: the method, pathname, classification from the user agent, and request and trace ids. From the browser: the pathname, event type, element type such as `button`, status, and load time.
80
+
81
+ Query strings, cookies, auth headers, bodies, page text and field values are never sent.
82
+
83
+ A matching user agent is recorded as `declared`, because any client can send one. Other automation is `inferred`, and everything else is `unknown`. This package never reports `verified`.
84
+
85
+ ## Options
86
+
87
+ | Option | Default | Purpose |
88
+ | --- | --- | --- |
89
+ | `key` | `SCOUTLINE_KEY` | Ingest key |
90
+ | `endpoint` | `SCOUTLINE_ENDPOINT`, then `https://ingest.scoutline.ai` | Collector URL |
91
+ | `sampleUnknown` | `1` | Share of unclassified traffic to send, from 0 to 1 |
92
+ | `ignore` | none | `(pathname) => boolean` to skip a path |
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Served from the customer's own domain at /_scoutline/s. It stays plain ES5 and
3
+ * dependency free so it can load on any page. It collects no text, field values
4
+ * or query strings, and stores nothing, so no cookie or consent is involved.
5
+ * Everything it sends is an unverified claim and is revalidated in `browser.ts`.
6
+ */
7
+ export declare const BROWSER_SCRIPT = "(function(){\n var ENDPOINT=\"/_scoutline/e\",MAX=20,queue=[],path=location.pathname;\n var nav=performance.getEntriesByType?performance.getEntriesByType(\"navigation\")[0]:null;\n var automated=navigator.webdriver===true;\n function serverId(){\n var timing=nav&&nav.serverTiming;\n for(var i=0;timing&&i<timing.length;i++){\n if(timing[i].name===\"scoutline\")return timing[i].description;\n }\n }\n function send(){\n if(!queue.length)return;\n var now=Date.now(),events=[];\n for(var i=0;i<queue.length;i++){\n var e=queue[i];e.ageMs=now-e.at;delete e.at;events.push(e);\n }\n queue=[];\n var body=JSON.stringify({automated:automated,events:events});\n if(!navigator.sendBeacon||!navigator.sendBeacon(ENDPOINT,body)){\n fetch(ENDPOINT,{method:\"POST\",body:body,keepalive:true})[\"catch\"](function(){});\n }\n }\n function add(type,extra){\n var e=extra||{};e.type=type;e.path=location.pathname;e.at=Date.now();\n queue.push(e);\n if(queue.length>=MAX)send();\n }\n function firstView(){\n add(\"page_view\",{\n parent:serverId(),\n status:nav&&nav.responseStatus?nav.responseStatus:undefined,\n durationMs:nav&&nav.duration?Math.round(nav.duration):undefined\n });\n }\n function changed(){\n if(location.pathname===path)return;\n path=location.pathname;add(\"page_view\");\n }\n [\"pushState\",\"replaceState\"].forEach(function(name){\n var original=history[name];\n history[name]=function(){var r=original.apply(this,arguments);changed();return r;};\n });\n addEventListener(\"popstate\",changed);\n document.addEventListener(\"click\",function(event){\n var t=event.target&&event.target.closest&&event.target.closest(\"a,button,[role=button],input[type=submit]\");\n if(t)add(\"click\",{element:t.tagName.toLowerCase()});\n },true);\n document.addEventListener(\"submit\",function(){add(\"form_submit\");},true);\n addEventListener(\"error\",function(event){if(event.message)add(\"error\");});\n addEventListener(\"unhandledrejection\",function(){add(\"error\");});\n addEventListener(\"pagehide\",send);\n document.addEventListener(\"visibilitychange\",function(){\n if(document.visibilityState===\"hidden\")send();\n });\n if(document.readyState===\"complete\")setTimeout(firstView);\n else addEventListener(\"load\",function(){setTimeout(firstView);});\n})();";
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Served from the customer's own domain at /_scoutline/s. It stays plain ES5 and
3
+ * dependency free so it can load on any page. It collects no text, field values
4
+ * or query strings, and stores nothing, so no cookie or consent is involved.
5
+ * Everything it sends is an unverified claim and is revalidated in `browser.ts`.
6
+ */
7
+ export const BROWSER_SCRIPT = `(function(){
8
+ var ENDPOINT="/_scoutline/e",MAX=20,queue=[],path=location.pathname;
9
+ var nav=performance.getEntriesByType?performance.getEntriesByType("navigation")[0]:null;
10
+ var automated=navigator.webdriver===true;
11
+ function serverId(){
12
+ var timing=nav&&nav.serverTiming;
13
+ for(var i=0;timing&&i<timing.length;i++){
14
+ if(timing[i].name==="scoutline")return timing[i].description;
15
+ }
16
+ }
17
+ function send(){
18
+ if(!queue.length)return;
19
+ var now=Date.now(),events=[];
20
+ for(var i=0;i<queue.length;i++){
21
+ var e=queue[i];e.ageMs=now-e.at;delete e.at;events.push(e);
22
+ }
23
+ queue=[];
24
+ var body=JSON.stringify({automated:automated,events:events});
25
+ if(!navigator.sendBeacon||!navigator.sendBeacon(ENDPOINT,body)){
26
+ fetch(ENDPOINT,{method:"POST",body:body,keepalive:true})["catch"](function(){});
27
+ }
28
+ }
29
+ function add(type,extra){
30
+ var e=extra||{};e.type=type;e.path=location.pathname;e.at=Date.now();
31
+ queue.push(e);
32
+ if(queue.length>=MAX)send();
33
+ }
34
+ function firstView(){
35
+ add("page_view",{
36
+ parent:serverId(),
37
+ status:nav&&nav.responseStatus?nav.responseStatus:undefined,
38
+ durationMs:nav&&nav.duration?Math.round(nav.duration):undefined
39
+ });
40
+ }
41
+ function changed(){
42
+ if(location.pathname===path)return;
43
+ path=location.pathname;add("page_view");
44
+ }
45
+ ["pushState","replaceState"].forEach(function(name){
46
+ var original=history[name];
47
+ history[name]=function(){var r=original.apply(this,arguments);changed();return r;};
48
+ });
49
+ addEventListener("popstate",changed);
50
+ document.addEventListener("click",function(event){
51
+ var t=event.target&&event.target.closest&&event.target.closest("a,button,[role=button],input[type=submit]");
52
+ if(t)add("click",{element:t.tagName.toLowerCase()});
53
+ },true);
54
+ document.addEventListener("submit",function(){add("form_submit");},true);
55
+ addEventListener("error",function(event){if(event.message)add("error");});
56
+ addEventListener("unhandledrejection",function(){add("error");});
57
+ addEventListener("pagehide",send);
58
+ document.addEventListener("visibilitychange",function(){
59
+ if(document.visibilityState==="hidden")send();
60
+ });
61
+ if(document.readyState==="complete")setTimeout(firstView);
62
+ else addEventListener("load",function(){setTimeout(firstView);});
63
+ })();`;
@@ -0,0 +1,33 @@
1
+ import type { Actor } from "./classify.js";
2
+ export declare const SCRIPT_PATH = "/_scoutline/s";
3
+ export declare const EVENTS_PATH = "/_scoutline/e";
4
+ export declare const BROWSER_CLASSIFIER_VERSION = "browser-0.1";
5
+ /**
6
+ * Anything in the page can be forged, so only a same-origin browser request is
7
+ * accepted. This keeps out other sites, not a determined client, which is why
8
+ * these events stay low trust however they arrive.
9
+ */
10
+ export declare function isSameOrigin(headers: Headers): boolean;
11
+ /**
12
+ * Rebuilds each event from an allowlist, so unexpected fields are dropped. The
13
+ * `browser:` action prefix keeps these out of request counts, because a page
14
+ * load also produces a server event, and Googlebot runs this script too.
15
+ */
16
+ export declare function toBrowserEvent(raw: unknown, actor: Actor, now: number, sampleRate: number): {
17
+ eventId: `${string}-${string}-${string}-${string}-${string}`;
18
+ occurredAt: number;
19
+ family: string;
20
+ actor: Actor;
21
+ action: string;
22
+ url: string;
23
+ interface: string;
24
+ parentActionId: string | undefined;
25
+ status: number | undefined;
26
+ durationMs: number | undefined;
27
+ sampleRate: number;
28
+ classifierVersion: string;
29
+ } | null;
30
+ export declare function parseBrowserBody(text: string): {
31
+ automated: boolean;
32
+ events: unknown[];
33
+ } | null;
@@ -0,0 +1,101 @@
1
+ export const SCRIPT_PATH = "/_scoutline/s";
2
+ export const EVENTS_PATH = "/_scoutline/e";
3
+ export const BROWSER_CLASSIFIER_VERSION = "browser-0.1";
4
+ const MAX_BODY_CHARS = 16 * 1024;
5
+ const MAX_EVENTS = 20;
6
+ const MAX_AGE_MS = 10 * 60 * 1000;
7
+ const MAX_PATH_CHARS = 2048;
8
+ const MAX_DURATION_MS = 60 * 60 * 1000;
9
+ const TYPES = new Set(["page_view", "click", "form_submit", "error"]);
10
+ const ELEMENTS = new Set(["a", "button", "input", "div", "span"]);
11
+ const SERVER_ID = /^[0-9a-f-]{36}$/;
12
+ /**
13
+ * Anything in the page can be forged, so only a same-origin browser request is
14
+ * accepted. This keeps out other sites, not a determined client, which is why
15
+ * these events stay low trust however they arrive.
16
+ */
17
+ export function isSameOrigin(headers) {
18
+ const site = headers.get("sec-fetch-site");
19
+ if (site && site !== "same-origin")
20
+ return false;
21
+ const origin = headers.get("origin");
22
+ if (!origin)
23
+ return site === "same-origin";
24
+ const host = headers.get("x-forwarded-host") ?? headers.get("host");
25
+ try {
26
+ return new URL(origin).host === host;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ function wholeNumber(value, max) {
33
+ return typeof value === "number" &&
34
+ Number.isInteger(value) &&
35
+ value >= 0 &&
36
+ value <= max
37
+ ? value
38
+ : undefined;
39
+ }
40
+ function pathOf(value) {
41
+ if (typeof value !== "string" || value.length > MAX_PATH_CHARS)
42
+ return null;
43
+ try {
44
+ return new URL(value, "https://redacted.invalid").pathname;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Rebuilds each event from an allowlist, so unexpected fields are dropped. The
52
+ * `browser:` action prefix keeps these out of request counts, because a page
53
+ * load also produces a server event, and Googlebot runs this script too.
54
+ */
55
+ export function toBrowserEvent(raw, actor, now, sampleRate) {
56
+ if (typeof raw !== "object" || raw === null)
57
+ return null;
58
+ const event = raw;
59
+ if (typeof event.type !== "string" || !TYPES.has(event.type))
60
+ return null;
61
+ const url = pathOf(event.path);
62
+ if (url === null)
63
+ return null;
64
+ const element = typeof event.element === "string" && ELEMENTS.has(event.element)
65
+ ? `:${event.element}`
66
+ : "";
67
+ const parent = typeof event.parent === "string" && SERVER_ID.test(event.parent)
68
+ ? event.parent
69
+ : undefined;
70
+ return {
71
+ eventId: crypto.randomUUID(),
72
+ occurredAt: now - (wholeNumber(event.ageMs, MAX_AGE_MS) ?? 0),
73
+ family: "resource_access",
74
+ actor,
75
+ action: `browser:${event.type}${element}`,
76
+ url,
77
+ interface: "web",
78
+ parentActionId: parent,
79
+ status: wholeNumber(event.status, 599),
80
+ durationMs: wholeNumber(event.durationMs, MAX_DURATION_MS),
81
+ sampleRate,
82
+ classifierVersion: BROWSER_CLASSIFIER_VERSION,
83
+ };
84
+ }
85
+ export function parseBrowserBody(text) {
86
+ if (text.length > MAX_BODY_CHARS)
87
+ return null;
88
+ try {
89
+ const body = JSON.parse(text);
90
+ if (!Array.isArray(body.events) || body.events.length > MAX_EVENTS) {
91
+ return null;
92
+ }
93
+ return {
94
+ automated: body.automated === true,
95
+ events: body.events,
96
+ };
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
@@ -0,0 +1,11 @@
1
+ export declare const CLASSIFIER_VERSION = "sdk-ua-0.1";
2
+ type Purpose = "indexing" | "research";
3
+ type Mode = "api" | "browser";
4
+ export type Actor = {
5
+ class: "declared" | "inferred" | "unknown";
6
+ operator?: string;
7
+ purpose?: Purpose;
8
+ mode?: Mode;
9
+ };
10
+ export declare function classify(userAgent: string | null): Actor;
11
+ export {};
@@ -0,0 +1,48 @@
1
+ export const CLASSIFIER_VERSION = "sdk-ua-0.1";
2
+ /**
3
+ * Published user agent tokens. A match is only a declaration, since any client
4
+ * can send these strings, so this never returns `verified`.
5
+ */
6
+ const DECLARED = [
7
+ ["GPTBot", "OpenAI", "indexing"],
8
+ ["OAI-SearchBot", "OpenAI", "indexing"],
9
+ ["ChatGPT-User", "OpenAI", "research"],
10
+ ["ClaudeBot", "Anthropic", "indexing"],
11
+ ["Claude-SearchBot", "Anthropic", "indexing"],
12
+ ["Claude-User", "Anthropic", "research"],
13
+ ["PerplexityBot", "Perplexity", "indexing"],
14
+ ["Perplexity-User", "Perplexity", "research"],
15
+ ["Googlebot", "Google", "indexing"],
16
+ ["GoogleOther", "Google", "indexing"],
17
+ ["Google-CloudVertexBot", "Google", "indexing"],
18
+ ["bingbot", "Microsoft", "indexing"],
19
+ ["Applebot", "Apple", "indexing"],
20
+ ["Amazonbot", "Amazon", "indexing"],
21
+ ["meta-externalagent", "Meta", "indexing"],
22
+ ["meta-externalfetcher", "Meta", "research"],
23
+ ["Bytespider", "ByteDance", "indexing"],
24
+ ["CCBot", "Common Crawl", "indexing"],
25
+ ["cohere-ai", "Cohere", "indexing"],
26
+ ["MistralAI-User", "Mistral", "research"],
27
+ ["DuckAssistBot", "DuckDuckGo", "research"],
28
+ ["DuckDuckBot", "DuckDuckGo", "indexing"],
29
+ ];
30
+ const AUTOMATED_BROWSER = /headless|playwright|puppeteer|selenium|phantomjs/i;
31
+ const AUTOMATED_CLIENT = /bot\b|crawl|spider|scrape|curl\/|wget\/|python-requests|python-httpx|aiohttp|go-http-client|okhttp|axios\/|node-fetch|undici/i;
32
+ export function classify(userAgent) {
33
+ if (!userAgent)
34
+ return { class: "unknown" };
35
+ const lower = userAgent.toLowerCase();
36
+ for (const [token, operator, purpose] of DECLARED) {
37
+ if (lower.includes(token.toLowerCase())) {
38
+ return { class: "declared", operator, purpose, mode: "api" };
39
+ }
40
+ }
41
+ if (AUTOMATED_BROWSER.test(userAgent)) {
42
+ return { class: "inferred", mode: "browser" };
43
+ }
44
+ if (AUTOMATED_CLIENT.test(userAgent)) {
45
+ return { class: "inferred", mode: "api" };
46
+ }
47
+ return { class: "unknown" };
48
+ }
@@ -0,0 +1,10 @@
1
+ import { type ScoutlineOptions } from "./index.js";
2
+ /**
3
+ * Wraps any fetch-style handler, such as a Cloudflare Worker or Hono's
4
+ * `app.fetch`. It sees the whole response, so status and duration are real.
5
+ * Bytes stay empty when the response has no Content-Length. On Cloudflare it
6
+ * also adds the browser script to HTML pages, so no code change is needed.
7
+ *
8
+ * export default { fetch: withScoutline(app.fetch) };
9
+ */
10
+ export declare function withScoutline<Args extends unknown[]>(handler: (request: Request, ...args: Args) => Response | Promise<Response>, options?: ScoutlineOptions): (request: Request, ...args: Args) => Promise<Response>;
package/dist/fetch.js ADDED
@@ -0,0 +1,74 @@
1
+ import { SCRIPT_PATH } from "./browser.js";
2
+ import { createScoutline, isDocumentRequest, serverTiming, } from "./index.js";
3
+ const SCRIPT_TAG = `<script src="${SCRIPT_PATH}" defer></script>`;
4
+ function forPage(response, eventId) {
5
+ if (!response.headers.get("content-type")?.includes("text/html")) {
6
+ return response;
7
+ }
8
+ const page = new Response(response.body, response);
9
+ if (eventId)
10
+ page.headers.append("Server-Timing", serverTiming(eventId));
11
+ if (typeof HTMLRewriter === "undefined")
12
+ return page;
13
+ return new HTMLRewriter()
14
+ .on("head", {
15
+ element(head) {
16
+ head.append(SCRIPT_TAG, { html: true });
17
+ },
18
+ })
19
+ .transform(page);
20
+ }
21
+ function contentLength(response) {
22
+ const value = Number(response.headers.get("content-length"));
23
+ return response.headers.has("content-length") && Number.isFinite(value)
24
+ ? value
25
+ : null;
26
+ }
27
+ /**
28
+ * Wraps any fetch-style handler, such as a Cloudflare Worker or Hono's
29
+ * `app.fetch`. It sees the whole response, so status and duration are real.
30
+ * Bytes stay empty when the response has no Content-Length. On Cloudflare it
31
+ * also adds the browser script to HTML pages, so no code change is needed.
32
+ *
33
+ * export default { fetch: withScoutline(app.fetch) };
34
+ */
35
+ export function withScoutline(handler, options) {
36
+ const scoutline = createScoutline(options);
37
+ return async function scoutlineFetch(request, ...args) {
38
+ const context = args.find((arg) => typeof arg?.waitUntil === "function");
39
+ const own = scoutline.handle(request);
40
+ if (own) {
41
+ const answer = await own;
42
+ context?.waitUntil(scoutline.flush());
43
+ return answer;
44
+ }
45
+ const started = Date.now();
46
+ const finish = (status, bytes) => {
47
+ const eventId = scoutline.observe(request, {
48
+ status,
49
+ bytes,
50
+ durationMs: Date.now() - started,
51
+ cacheStatus: null,
52
+ });
53
+ context?.waitUntil(scoutline.flush());
54
+ return eventId;
55
+ };
56
+ let response;
57
+ try {
58
+ response = await handler(request, ...args);
59
+ }
60
+ catch (error) {
61
+ finish(null, null);
62
+ throw error;
63
+ }
64
+ const eventId = finish(response.status, contentLength(response));
65
+ if (!isDocumentRequest(request))
66
+ return response;
67
+ try {
68
+ return forPage(response, eventId);
69
+ }
70
+ catch {
71
+ return response;
72
+ }
73
+ };
74
+ }
@@ -0,0 +1,44 @@
1
+ export { classify } from "./classify.js";
2
+ export type ScoutlineOptions = {
3
+ /** Ingest key. Falls back to the SCOUTLINE_KEY environment variable. */
4
+ key?: string;
5
+ /** Collector URL, for self-hosting or testing. Falls back to SCOUTLINE_ENDPOINT. */
6
+ endpoint?: string;
7
+ /** Share of unclassified traffic to send, from 0 to 1. Default 1. */
8
+ sampleUnknown?: number;
9
+ /** Skip a path entirely. Runs on the pathname only. */
10
+ ignore?: (pathname: string) => boolean;
11
+ };
12
+ export type Observation = {
13
+ /** Set by adapters that must announce the id before the response finishes. */
14
+ eventId?: string;
15
+ /** Null when the response isn't visible from this position in the stack. */
16
+ status?: number | null;
17
+ durationMs?: number | null;
18
+ bytes?: number | null;
19
+ cacheStatus?: string | null;
20
+ };
21
+ export type Scoutline = {
22
+ /**
23
+ * Records a request and returns its event id. Synchronous and never throws,
24
+ * so it can't break a site. Returns nothing if the request was skipped.
25
+ */
26
+ observe(request: Request, observation?: Observation): string | undefined;
27
+ /** Sends what is queued. Never rejects. Pass this to `waitUntil`. */
28
+ flush(): Promise<void>;
29
+ /**
30
+ * Serves the browser script and its events from the site's own domain.
31
+ * Returns null for every other path. Must run before any auth check.
32
+ */
33
+ handle(request: Request): Promise<Response> | null;
34
+ };
35
+ /** Only full page loads carry a server id for the browser script to read. */
36
+ export declare function isDocumentRequest(request: Request): boolean;
37
+ export declare function serverTiming(eventId: string): string;
38
+ /** The site's hostname. A proxy may list several, nearest client first. */
39
+ export declare function hostOf(request: Request): string | undefined;
40
+ /**
41
+ * Failure here must never reach the customer's site. A missing key turns the
42
+ * client into a no-op, and a full queue drops the oldest events.
43
+ */
44
+ export declare function createScoutline(options?: ScoutlineOptions): Scoutline;
package/dist/index.js ADDED
@@ -0,0 +1,277 @@
1
+ import { EVENTS_PATH, isSameOrigin, parseBrowserBody, SCRIPT_PATH, toBrowserEvent, } from "./browser.js";
2
+ import { BROWSER_SCRIPT } from "./browser-script.js";
3
+ import { classify, CLASSIFIER_VERSION } from "./classify.js";
4
+ export { classify } from "./classify.js";
5
+ const DEFAULT_ENDPOINT = "https://ingest.scoutline.ai";
6
+ const MAX_QUEUED = 500;
7
+ const MAX_BATCH = 100;
8
+ const SEND_TIMEOUT_MS = 2000;
9
+ const FLUSH_INTERVAL_MS = 5000;
10
+ const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
11
+ const REJECTED_BACKOFF_MS = 60 * 1000;
12
+ const RATE_LIMITED_BACKOFF_MS = 60 * 1000;
13
+ const MAX_FIELD_CHARS = 128;
14
+ const MAX_URL_CHARS = 2048;
15
+ /** Caps browser events per server instance, so a page can't flood the queue. */
16
+ const MAX_BROWSER_EVENTS_PER_MINUTE = 2000;
17
+ const SCRIPT_CACHE_SECONDS = 3600;
18
+ /** Prefetches aren't visits, and assets aren't what agents come for. */
19
+ const IGNORED_PATH = /^\/(?:_next|_vercel|\.well-known\/vercel)\/|\.(?:css|js|mjs|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf)$/i;
20
+ /** Only full page loads carry a server id for the browser script to read. */
21
+ export function isDocumentRequest(request) {
22
+ return (request.method === "GET" &&
23
+ request.headers.get("accept")?.includes("text/html") === true &&
24
+ !isPrefetch(request.headers));
25
+ }
26
+ export function serverTiming(eventId) {
27
+ return `scoutline;desc="${eventId}"`;
28
+ }
29
+ function scriptResponse() {
30
+ return new Response(BROWSER_SCRIPT, {
31
+ headers: {
32
+ "Content-Type": "text/javascript; charset=utf-8",
33
+ "Cache-Control": `public, max-age=${SCRIPT_CACHE_SECONDS}`,
34
+ },
35
+ });
36
+ }
37
+ function emptyResponse(status) {
38
+ return new Response(null, { status });
39
+ }
40
+ function ownRoute(request) {
41
+ const { pathname } = new URL(request.url);
42
+ return pathname === SCRIPT_PATH || pathname === EVENTS_PATH ? pathname : null;
43
+ }
44
+ function env(name) {
45
+ return typeof process === "undefined" ? undefined : process?.env?.[name];
46
+ }
47
+ function limited(value, max = MAX_FIELD_CHARS) {
48
+ return value && value.length <= max ? value : undefined;
49
+ }
50
+ function interfaceFor(pathname, headers) {
51
+ if (pathname === "/mcp" || pathname.startsWith("/mcp/"))
52
+ return "tool";
53
+ if (pathname.startsWith("/api/"))
54
+ return "api";
55
+ return headers.get("accept")?.includes("application/json") ? "api" : "web";
56
+ }
57
+ /** The site's hostname. A proxy may list several, nearest client first. */
58
+ export function hostOf(request) {
59
+ const forwarded = request.headers.get("x-forwarded-host")?.split(",")[0];
60
+ const host = forwarded?.trim() ||
61
+ request.headers.get("host") ||
62
+ new URL(request.url).host;
63
+ return limited(host.toLowerCase(), 255);
64
+ }
65
+ function isPrefetch(headers) {
66
+ return (headers.has("next-router-prefetch") ||
67
+ headers.get("purpose") === "prefetch" ||
68
+ headers.get("sec-purpose")?.includes("prefetch") === true);
69
+ }
70
+ /** The W3C trace id from `traceparent`, a claim until something validates it. */
71
+ function traceId(headers) {
72
+ return limited(headers.get("traceparent")?.split("-")[1] ?? null);
73
+ }
74
+ function toEvent(request, observation, sampleUnknown, ignore) {
75
+ const { pathname } = new URL(request.url);
76
+ if (IGNORED_PATH.test(pathname) || ignore?.(pathname))
77
+ return null;
78
+ const { headers } = request;
79
+ if (isPrefetch(headers))
80
+ return null;
81
+ const actor = classify(headers.get("user-agent"));
82
+ const sampled = actor.class === "unknown" && sampleUnknown < 1;
83
+ if (sampled && Math.random() >= sampleUnknown)
84
+ return null;
85
+ return {
86
+ eventId: observation.eventId ?? crypto.randomUUID(),
87
+ occurredAt: Date.now(),
88
+ family: "resource_access",
89
+ actor,
90
+ action: request.method.slice(0, 64),
91
+ // The pathname only: queries, cookies, headers and bodies never leave here.
92
+ url: pathname.slice(0, MAX_URL_CHARS),
93
+ host: hostOf(request),
94
+ interface: interfaceFor(pathname, headers),
95
+ requestId: limited(headers.get("x-vercel-id") ??
96
+ headers.get("cf-ray") ??
97
+ headers.get("x-request-id")),
98
+ traceId: traceId(headers),
99
+ status: observation.status ?? undefined,
100
+ durationMs: observation.durationMs ?? undefined,
101
+ bytes: observation.bytes ?? undefined,
102
+ cacheStatus: limited(observation.cacheStatus ?? null, 32),
103
+ sampleRate: sampled ? sampleUnknown : 1,
104
+ classifierVersion: CLASSIFIER_VERSION,
105
+ };
106
+ }
107
+ /**
108
+ * Failure here must never reach the customer's site. A missing key turns the
109
+ * client into a no-op, and a full queue drops the oldest events.
110
+ */
111
+ export function createScoutline(options = {}) {
112
+ const key = options.key ?? env("SCOUTLINE_KEY");
113
+ const endpoint = (options.endpoint ||
114
+ env("SCOUTLINE_ENDPOINT") ||
115
+ DEFAULT_ENDPOINT).replace(/\/+$/, "");
116
+ if (!key) {
117
+ console.warn("[scoutline] Missing SCOUTLINE_KEY, so nothing is collected.");
118
+ return {
119
+ observe: () => undefined,
120
+ flush: async () => { },
121
+ // The script tag still gets a valid answer, so pages don't log a 404.
122
+ handle(request) {
123
+ const route = ownRoute(request);
124
+ if (route === SCRIPT_PATH)
125
+ return Promise.resolve(scriptResponse());
126
+ return route ? Promise.resolve(emptyResponse(204)) : null;
127
+ },
128
+ };
129
+ }
130
+ const sampleUnknown = options.sampleUnknown ?? 1;
131
+ let queue = [];
132
+ let timer;
133
+ let lastSentAt = 0;
134
+ let pausedUntil = 0;
135
+ let browserWindowStart = 0;
136
+ let browserEventsInWindow = 0;
137
+ async function post(events) {
138
+ const response = await fetch(`${endpoint}/v1/events`, {
139
+ method: "POST",
140
+ headers: {
141
+ Authorization: `Bearer ${key}`,
142
+ "Content-Type": "application/json",
143
+ },
144
+ body: JSON.stringify({ events }),
145
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
146
+ });
147
+ await response.body?.cancel();
148
+ return response.status;
149
+ }
150
+ async function drain() {
151
+ while (queue.length > 0 && Date.now() >= pausedUntil) {
152
+ const batch = queue.slice(0, MAX_BATCH);
153
+ queue = queue.slice(batch.length);
154
+ let status;
155
+ try {
156
+ status = await post(batch);
157
+ }
158
+ catch {
159
+ status = undefined;
160
+ }
161
+ if (status === 401) {
162
+ // Retrying can't fix a bad key, so don't spend the site's time on it.
163
+ console.warn("[scoutline] The collector refused this key.");
164
+ pausedUntil = Date.now() + REJECTED_BACKOFF_MS;
165
+ return;
166
+ }
167
+ if (status === 429) {
168
+ // The collector asked us to slow down, so hold these and try later.
169
+ pausedUntil = Date.now() + RATE_LIMITED_BACKOFF_MS;
170
+ queue = [...batch, ...queue].slice(-MAX_QUEUED);
171
+ return;
172
+ }
173
+ if (status === undefined || status >= 500) {
174
+ // Event ids are stable, so a repeat of a partial send dedupes downstream.
175
+ queue = [...batch, ...queue].slice(-MAX_QUEUED);
176
+ return;
177
+ }
178
+ lastSentAt = Date.now();
179
+ }
180
+ }
181
+ /** A quiet site isn't a broken collector. An empty batch says so. */
182
+ async function heartbeat() {
183
+ if (Date.now() - lastSentAt < HEARTBEAT_INTERVAL_MS)
184
+ return;
185
+ if (Date.now() < pausedUntil)
186
+ return;
187
+ lastSentAt = Date.now();
188
+ await post([]).catch(() => { });
189
+ }
190
+ /**
191
+ * Each call drains by itself rather than sharing one in-flight promise,
192
+ * because Cloudflare doesn't let a request wait on another request's work.
193
+ * The queue hands each batch out once, so overlapping calls are safe.
194
+ */
195
+ function flush() {
196
+ return drain()
197
+ .then(heartbeat)
198
+ .catch(() => { });
199
+ }
200
+ function enqueue(event) {
201
+ queue.push(event);
202
+ if (queue.length > MAX_QUEUED)
203
+ queue = queue.slice(-MAX_QUEUED);
204
+ // Keeps long-running servers sending. Serverless flushes via waitUntil.
205
+ if (!timer) {
206
+ timer = setTimeout(() => {
207
+ timer = undefined;
208
+ void flush();
209
+ }, FLUSH_INTERVAL_MS);
210
+ timer.unref?.();
211
+ }
212
+ }
213
+ function observe(request, observation = {}) {
214
+ try {
215
+ const event = toEvent(request, observation, sampleUnknown, options.ignore);
216
+ if (!event)
217
+ return undefined;
218
+ enqueue(event);
219
+ return event.eventId;
220
+ }
221
+ catch {
222
+ // Observation is optional. The customer's request is not.
223
+ return undefined;
224
+ }
225
+ }
226
+ function withinBrowserLimit(count) {
227
+ const now = Date.now();
228
+ if (now - browserWindowStart > 60 * 1000) {
229
+ browserWindowStart = now;
230
+ browserEventsInWindow = 0;
231
+ }
232
+ browserEventsInWindow += count;
233
+ return browserEventsInWindow <= MAX_BROWSER_EVENTS_PER_MINUTE;
234
+ }
235
+ /** The class comes from the server's view. A page may only make it weaker. */
236
+ function browserActor(request, automated) {
237
+ const actor = classify(request.headers.get("user-agent"));
238
+ if (actor.class === "unknown" && automated) {
239
+ return { class: "inferred", mode: "browser" };
240
+ }
241
+ return actor;
242
+ }
243
+ async function acceptBrowserEvents(request) {
244
+ try {
245
+ if (request.method !== "POST")
246
+ return emptyResponse(405);
247
+ if (!isSameOrigin(request.headers))
248
+ return emptyResponse(403);
249
+ const body = parseBrowserBody(await request.text());
250
+ if (!body)
251
+ return emptyResponse(400);
252
+ if (!withinBrowserLimit(body.events.length))
253
+ return emptyResponse(204);
254
+ const actor = browserActor(request, body.automated);
255
+ const sampled = actor.class === "unknown" && sampleUnknown < 1;
256
+ if (sampled && Math.random() >= sampleUnknown)
257
+ return emptyResponse(204);
258
+ const now = Date.now();
259
+ for (const raw of body.events) {
260
+ const event = toBrowserEvent(raw, actor, now, sampled ? sampleUnknown : 1);
261
+ if (event)
262
+ enqueue({ ...event, host: hostOf(request) });
263
+ }
264
+ return emptyResponse(204);
265
+ }
266
+ catch {
267
+ return emptyResponse(204);
268
+ }
269
+ }
270
+ function handle(request) {
271
+ const route = ownRoute(request);
272
+ if (route === SCRIPT_PATH)
273
+ return Promise.resolve(scriptResponse());
274
+ return route ? acceptBrowserEvents(request) : null;
275
+ }
276
+ return { observe, flush, handle };
277
+ }
package/dist/next.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { type ScoutlineOptions } from "./index.js";
2
+ type WaitUntil = {
3
+ waitUntil(promise: Promise<unknown>): void;
4
+ };
5
+ type Proxy<Req extends Request, Event extends WaitUntil> = (request: Req, event: Event) => Response | null | undefined | void | Promise<Response | null | undefined | void>;
6
+ /**
7
+ * Wraps a Next.js `proxy.ts`. It adds no latency, because sending happens in
8
+ * `waitUntil` after the response is on its way.
9
+ *
10
+ * A proxy runs before the page renders, so a pass-through has no status or
11
+ * duration to report. Those stay empty rather than guessed. A status is only
12
+ * recorded when the proxy itself answered, such as a redirect or a 401.
13
+ *
14
+ * export default withScoutline(clerkMiddleware());
15
+ *
16
+ * It also serves the browser script and its events on the site's own domain,
17
+ * ahead of the wrapped proxy, so a signed-out page can still report.
18
+ */
19
+ export declare function withScoutline<Req extends Request, Event extends WaitUntil>(proxy?: Proxy<Req, Event>, options?: ScoutlineOptions): (request: Req, event: Event) => Promise<void | Response | null>;
20
+ export {};
package/dist/next.js ADDED
@@ -0,0 +1,58 @@
1
+ import { createScoutline, isDocumentRequest, serverTiming, } from "./index.js";
2
+ /**
3
+ * Wraps a Next.js `proxy.ts`. It adds no latency, because sending happens in
4
+ * `waitUntil` after the response is on its way.
5
+ *
6
+ * A proxy runs before the page renders, so a pass-through has no status or
7
+ * duration to report. Those stay empty rather than guessed. A status is only
8
+ * recorded when the proxy itself answered, such as a redirect or a 401.
9
+ *
10
+ * export default withScoutline(clerkMiddleware());
11
+ *
12
+ * It also serves the browser script and its events on the site's own domain,
13
+ * ahead of the wrapped proxy, so a signed-out page can still report.
14
+ */
15
+ export function withScoutline(proxy, options) {
16
+ const scoutline = createScoutline(options);
17
+ return async function scoutlineProxy(request, event) {
18
+ // Before the wrapped proxy, so an auth check can't block the page's events.
19
+ const own = scoutline.handle(request);
20
+ if (own) {
21
+ const answer = await own;
22
+ event.waitUntil(scoutline.flush());
23
+ return answer;
24
+ }
25
+ let response;
26
+ try {
27
+ response = await proxy?.(request, event);
28
+ }
29
+ catch (error) {
30
+ scoutline.observe(request);
31
+ event.waitUntil(scoutline.flush());
32
+ throw error;
33
+ }
34
+ const answered = response instanceof Response &&
35
+ !response.headers.has("x-middleware-next") &&
36
+ !response.headers.has("x-middleware-rewrite");
37
+ const eventId = scoutline.observe(request, answered ? { status: response.status } : {});
38
+ event.waitUntil(scoutline.flush());
39
+ if (!eventId || answered || !isDocumentRequest(request))
40
+ return response;
41
+ return withServerId(response, eventId);
42
+ };
43
+ }
44
+ /**
45
+ * Hands the page its server event id, so the browser script can link to this
46
+ * request without a cookie. `x-middleware-next` is what `NextResponse.next()`
47
+ * sets, so a proxy with nothing to return can still pass the header on.
48
+ */
49
+ function withServerId(response, id) {
50
+ try {
51
+ const target = response ?? new Response(null, { headers: { "x-middleware-next": "1" } });
52
+ target.headers.append("Server-Timing", serverTiming(id));
53
+ return target;
54
+ }
55
+ catch {
56
+ return response;
57
+ }
58
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type ScoutlineOptions } from "./index.js";
3
+ /**
4
+ * Express middleware. Add it before your routes and body parsers.
5
+ *
6
+ * app.use(scoutlineExpress());
7
+ */
8
+ export declare function scoutlineExpress(options?: ScoutlineOptions): (request: IncomingMessage, response: ServerResponse, next: (error?: unknown) => void) => void;
9
+ type FastifyLike = {
10
+ addHook(name: "onRequest", hook: (request: {
11
+ raw: IncomingMessage;
12
+ }, reply: {
13
+ raw: ServerResponse;
14
+ hijack(): void;
15
+ }, done: () => void) => void): void;
16
+ };
17
+ /**
18
+ * Fastify plugin. `skip-override` applies it to the whole app rather than only
19
+ * the plugin's own scope, the same thing `fastify-plugin` does.
20
+ *
21
+ * app.register(scoutlineFastify);
22
+ */
23
+ export declare const scoutlineFastify: ((app: FastifyLike, options?: ScoutlineOptions) => Promise<void>) & {
24
+ [x: symbol]: boolean;
25
+ };
26
+ export {};
package/dist/node.js ADDED
@@ -0,0 +1,120 @@
1
+ import { EVENTS_PATH, SCRIPT_PATH } from "./browser.js";
2
+ import { createScoutline, isDocumentRequest, serverTiming, } from "./index.js";
3
+ const MAX_BODY_BYTES = 32 * 1024;
4
+ function toRequest(request, body) {
5
+ const headers = new Headers();
6
+ for (const [name, value] of Object.entries(request.headers)) {
7
+ if (value !== undefined) {
8
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
9
+ }
10
+ }
11
+ const host = headers.get("host") ?? "localhost";
12
+ const path = request.originalUrl ?? request.url ?? "/";
13
+ return new Request(`http://${host}${path}`, {
14
+ method: request.method,
15
+ headers,
16
+ body,
17
+ });
18
+ }
19
+ /** A body parser may have run first, and a stream can only be read once. */
20
+ async function readBody(request) {
21
+ if (typeof request.body === "string")
22
+ return request.body;
23
+ if (request.body !== undefined)
24
+ return JSON.stringify(request.body);
25
+ const chunks = [];
26
+ let size = 0;
27
+ for await (const chunk of request) {
28
+ size += chunk.length;
29
+ if (size > MAX_BODY_BYTES)
30
+ break;
31
+ chunks.push(chunk);
32
+ }
33
+ return Buffer.concat(chunks).toString("utf8");
34
+ }
35
+ async function serveOwnRoute(scoutline, request, response) {
36
+ const body = request.method === "POST" ? await readBody(request) : undefined;
37
+ const answer = await scoutline.handle(toRequest(request, body));
38
+ const text = (await answer?.text()) ?? "";
39
+ response.writeHead(answer?.status ?? 204, Object.fromEntries(answer?.headers ?? []));
40
+ response.end(text);
41
+ void scoutline.flush();
42
+ }
43
+ function contentLength(response) {
44
+ const value = Number(response.getHeader("content-length"));
45
+ return Number.isFinite(value) && value > 0 ? value : null;
46
+ }
47
+ /**
48
+ * Records the request once the response has finished, so status and duration
49
+ * are real. A client that hangs up early never got a status, so none is sent.
50
+ * The event id goes out first, because headers can't change after that.
51
+ */
52
+ function track(scoutline, request, response) {
53
+ const started = Date.now();
54
+ const web = toRequest(request);
55
+ const eventId = crypto.randomUUID();
56
+ if (isDocumentRequest(web)) {
57
+ response.appendHeader("Server-Timing", serverTiming(eventId));
58
+ }
59
+ let done = false;
60
+ const finish = (completed) => {
61
+ if (done)
62
+ return;
63
+ done = true;
64
+ scoutline.observe(web, {
65
+ eventId,
66
+ status: completed ? response.statusCode : null,
67
+ bytes: completed ? contentLength(response) : null,
68
+ durationMs: Date.now() - started,
69
+ });
70
+ };
71
+ response.once("finish", () => finish(true));
72
+ response.once("close", () => finish(false));
73
+ }
74
+ /** Returns true when the request was one of Scoutline's own routes. */
75
+ function handleRequest(scoutline, request, response) {
76
+ const path = (request.originalUrl ?? request.url ?? "/").split("?")[0];
77
+ if (path === SCRIPT_PATH || path === EVENTS_PATH) {
78
+ serveOwnRoute(scoutline, request, response).catch(() => {
79
+ if (!response.headersSent)
80
+ response.writeHead(204);
81
+ response.end();
82
+ });
83
+ return true;
84
+ }
85
+ try {
86
+ track(scoutline, request, response);
87
+ }
88
+ catch {
89
+ // Observation is optional. The customer's request is not.
90
+ }
91
+ return false;
92
+ }
93
+ /**
94
+ * Express middleware. Add it before your routes and body parsers.
95
+ *
96
+ * app.use(scoutlineExpress());
97
+ */
98
+ export function scoutlineExpress(options) {
99
+ const scoutline = createScoutline(options);
100
+ return function scoutlineMiddleware(request, response, next) {
101
+ if (!handleRequest(scoutline, request, response))
102
+ next();
103
+ };
104
+ }
105
+ /**
106
+ * Fastify plugin. `skip-override` applies it to the whole app rather than only
107
+ * the plugin's own scope, the same thing `fastify-plugin` does.
108
+ *
109
+ * app.register(scoutlineFastify);
110
+ */
111
+ export const scoutlineFastify = Object.assign(async function scoutlinePlugin(app, options) {
112
+ const scoutline = createScoutline(options);
113
+ app.addHook("onRequest", (request, reply, done) => {
114
+ const path = (request.raw.url ?? "/").split("?")[0];
115
+ if (path === SCRIPT_PATH || path === EVENTS_PATH)
116
+ reply.hijack();
117
+ if (!handleRequest(scoutline, request.raw, reply.raw))
118
+ done();
119
+ });
120
+ }, { [Symbol.for("skip-override")]: true });
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@scoutline-ai/middleware",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "In-path agent observation for Scoutline. Fails open and adds no work to the hot path.",
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./next": {
15
+ "types": "./dist/next.d.ts",
16
+ "default": "./dist/next.js"
17
+ },
18
+ "./fetch": {
19
+ "types": "./dist/fetch.d.ts",
20
+ "default": "./dist/fetch.js"
21
+ },
22
+ "./node": {
23
+ "types": "./dist/node.d.ts",
24
+ "default": "./dist/node.js"
25
+ }
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "prepublishOnly": "tsc -p tsconfig.json"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "keywords": [
35
+ "scoutline",
36
+ "ai agents",
37
+ "analytics",
38
+ "middleware"
39
+ ],
40
+ "homepage": "https://scoutline.ai"
41
+ }