@scoutline-ai/middleware 0.0.0-stage → 0.2.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.
@@ -0,0 +1,86 @@
1
+ export { classify } from "./classify.js";
2
+ /**
3
+ * What your app knows about a request that Scoutline can't see. Everything is
4
+ * optional, and only what you return here leaves your server.
5
+ */
6
+ export type RequestContext = {
7
+ /** Your id for the piece of work, shared by the requests and outcomes in it. */
8
+ taskId?: string | null;
9
+ sessionId?: string | null;
10
+ /** The business object, such as a product, so its URLs count as one. */
11
+ resourceId?: string | null;
12
+ contentVersion?: string | null;
13
+ };
14
+ export type ScoutlineOptions = {
15
+ /** Ingest key. Falls back to the SCOUTLINE_KEY environment variable. */
16
+ key?: string;
17
+ /** Collector URL, for self-hosting or testing. Falls back to SCOUTLINE_ENDPOINT. */
18
+ endpoint?: string;
19
+ /** Share of unclassified traffic to send, from 0 to 1. Default 1. */
20
+ sampleUnknown?: number;
21
+ /** Skip a path entirely. Runs on the pathname only. */
22
+ ignore?: (pathname: string) => boolean;
23
+ /** Adds your own ids to a request. A failure here only leaves them out. */
24
+ context?: (request: Request) => RequestContext | null | undefined;
25
+ /**
26
+ * Sends the visitor's IP so the collector can find its country and network.
27
+ * The collector never stores it. Default true.
28
+ */
29
+ sendClientIp?: boolean;
30
+ };
31
+ export type Observation = {
32
+ /** Set by adapters that must announce the id before the response finishes. */
33
+ eventId?: string;
34
+ /** Set by adapters that can see the connection, not just the headers. */
35
+ clientIp?: string;
36
+ /** Null when the response isn't visible from this position in the stack. */
37
+ status?: number | null;
38
+ durationMs?: number | null;
39
+ bytes?: number | null;
40
+ cacheStatus?: string | null;
41
+ };
42
+ export type Scoutline = {
43
+ /**
44
+ * Records a request and returns its event id. Synchronous and never throws,
45
+ * so it can't break a site. Returns nothing if the request was skipped.
46
+ */
47
+ observe(request: Request, observation?: Observation): string | undefined;
48
+ /** Sends what is queued. Never rejects. Pass this to `waitUntil`. */
49
+ flush(): Promise<void>;
50
+ /**
51
+ * Serves the browser script and its events from the site's own domain.
52
+ * Returns null for every other path. Must run before any auth check.
53
+ */
54
+ handle(request: Request, clientIp?: string): Promise<Response> | null;
55
+ };
56
+ /** Only full page loads carry a server id for the browser script to read. */
57
+ export declare function isDocumentRequest(request: Request): boolean;
58
+ export declare function serverTiming(eventId: string): string;
59
+ /** A proxy may list several hosts, nearest client first. */
60
+ export declare function hostFromHeaders(headers: Headers): string | null;
61
+ /** The site's hostname. */
62
+ export declare function hostOf(request: Request): string | undefined;
63
+ /**
64
+ * What the collector needs to classify a request. Only whether these headers
65
+ * exist is sent, never their values, and rules live there so they can change
66
+ * without a new release of this package.
67
+ */
68
+ export declare function requestFacts(headers: Headers, options: {
69
+ sendClientIp?: boolean;
70
+ clientIp?: string;
71
+ }): {
72
+ userAgent: string | undefined;
73
+ clientIp: string | undefined;
74
+ requestShape: {
75
+ clientHints: boolean;
76
+ fetchMetadata: boolean;
77
+ acceptLanguage: boolean;
78
+ };
79
+ };
80
+ /** The W3C trace id from `traceparent`, a claim until something validates it. */
81
+ export declare function traceId(headers: Headers): string | undefined;
82
+ /**
83
+ * Failure here must never reach the customer's site. A missing key turns the
84
+ * client into a no-op, and a full queue drops the oldest events.
85
+ */
86
+ export declare function createScoutline(options?: ScoutlineOptions): Scoutline;
package/dist/index.js ADDED
@@ -0,0 +1,324 @@
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
+ import { clientIpFromHeaders } from "./client-ip.js";
5
+ import { DEFAULT_ENDPOINT, env } from "./config.js";
6
+ export { classify } from "./classify.js";
7
+ const MAX_QUEUED = 500;
8
+ const MAX_BATCH = 100;
9
+ const SEND_TIMEOUT_MS = 2000;
10
+ const FLUSH_INTERVAL_MS = 5000;
11
+ const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
12
+ const REJECTED_BACKOFF_MS = 60 * 1000;
13
+ const RATE_LIMITED_BACKOFF_MS = 60 * 1000;
14
+ const MAX_FIELD_CHARS = 128;
15
+ const MAX_URL_CHARS = 2048;
16
+ const MAX_USER_AGENT_CHARS = 512;
17
+ /** Caps browser events per server instance, so a page can't flood the queue. */
18
+ const MAX_BROWSER_EVENTS_PER_MINUTE = 2000;
19
+ const SCRIPT_CACHE_SECONDS = 3600;
20
+ const FRAMEWORK_PATH = /^\/(?:_next|_vercel|\.well-known\/vercel)\//;
21
+ /**
22
+ * A person's page load fetches these as a side effect, so they would only add
23
+ * noise. A crawler chooses to fetch them, and they can be most of what it costs
24
+ * to serve, so they stay for anyone identified. PDFs always stay.
25
+ */
26
+ const ASSET_PATH = /\.(?:css|js|mjs|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|mp4|m4v|webm|mov|ogv|mp3|m4a|aac|flac|wav|ogg|opus)$/i;
27
+ /** Only full page loads carry a server id for the browser script to read. */
28
+ export function isDocumentRequest(request) {
29
+ return (request.method === "GET" &&
30
+ request.headers.get("accept")?.includes("text/html") === true &&
31
+ !isPrefetch(request.headers));
32
+ }
33
+ export function serverTiming(eventId) {
34
+ return `scoutline;desc="${eventId}"`;
35
+ }
36
+ function scriptResponse() {
37
+ return new Response(BROWSER_SCRIPT, {
38
+ headers: {
39
+ "Content-Type": "text/javascript; charset=utf-8",
40
+ "Cache-Control": `public, max-age=${SCRIPT_CACHE_SECONDS}`,
41
+ },
42
+ });
43
+ }
44
+ function emptyResponse(status) {
45
+ return new Response(null, { status });
46
+ }
47
+ function ownRoute(request) {
48
+ const { pathname } = new URL(request.url);
49
+ return pathname === SCRIPT_PATH || pathname === EVENTS_PATH ? pathname : null;
50
+ }
51
+ function limited(value, max = MAX_FIELD_CHARS) {
52
+ return value && value.length <= max ? value : undefined;
53
+ }
54
+ function interfaceFor(pathname, headers) {
55
+ if (pathname === "/mcp" || pathname.startsWith("/mcp/"))
56
+ return "tool";
57
+ if (pathname.startsWith("/api/"))
58
+ return "api";
59
+ return headers.get("accept")?.includes("application/json") ? "api" : "web";
60
+ }
61
+ /** A proxy may list several hosts, nearest client first. */
62
+ export function hostFromHeaders(headers) {
63
+ const forwarded = headers.get("x-forwarded-host")?.split(",")[0];
64
+ return forwarded?.trim() || headers.get("host") || null;
65
+ }
66
+ /** The site's hostname. */
67
+ export function hostOf(request) {
68
+ const host = hostFromHeaders(request.headers) ?? new URL(request.url).host;
69
+ return limited(host.toLowerCase(), 255);
70
+ }
71
+ /**
72
+ * What the collector needs to classify a request. Only whether these headers
73
+ * exist is sent, never their values, and rules live there so they can change
74
+ * without a new release of this package.
75
+ */
76
+ export function requestFacts(headers, options) {
77
+ return {
78
+ userAgent: limited(headers.get("user-agent"), MAX_USER_AGENT_CHARS),
79
+ clientIp: options.sendClientIp === false
80
+ ? undefined
81
+ : (options.clientIp ?? clientIpFromHeaders(headers)),
82
+ requestShape: {
83
+ clientHints: headers.has("sec-ch-ua"),
84
+ fetchMetadata: headers.has("sec-fetch-mode") || headers.has("sec-fetch-site"),
85
+ acceptLanguage: headers.has("accept-language"),
86
+ },
87
+ };
88
+ }
89
+ function isPrefetch(headers) {
90
+ return (headers.has("next-router-prefetch") ||
91
+ headers.get("purpose") === "prefetch" ||
92
+ headers.get("sec-purpose")?.includes("prefetch") === true);
93
+ }
94
+ /** The W3C trace id from `traceparent`, a claim until something validates it. */
95
+ export function traceId(headers) {
96
+ return limited(headers.get("traceparent")?.split("-")[1] ?? null);
97
+ }
98
+ function contextOf(request, read) {
99
+ try {
100
+ return read?.(request) ?? {};
101
+ }
102
+ catch {
103
+ return {};
104
+ }
105
+ }
106
+ function toEvent(request, observation, sampleUnknown, options) {
107
+ const { pathname } = new URL(request.url);
108
+ if (FRAMEWORK_PATH.test(pathname) || options.ignore?.(pathname))
109
+ return null;
110
+ const { headers } = request;
111
+ if (isPrefetch(headers))
112
+ return null;
113
+ const actor = classify(headers.get("user-agent"));
114
+ if (actor.class === "unknown" && ASSET_PATH.test(pathname))
115
+ return null;
116
+ const sampled = actor.class === "unknown" && sampleUnknown < 1;
117
+ if (sampled && Math.random() >= sampleUnknown)
118
+ return null;
119
+ const context = contextOf(request, options.context);
120
+ return {
121
+ eventId: observation.eventId ?? crypto.randomUUID(),
122
+ occurredAt: Date.now(),
123
+ family: "resource_access",
124
+ actor,
125
+ action: request.method.slice(0, 64),
126
+ // The pathname only: queries, cookies, headers and bodies never leave here.
127
+ url: pathname.slice(0, MAX_URL_CHARS),
128
+ host: hostOf(request),
129
+ ...requestFacts(headers, {
130
+ sendClientIp: options.sendClientIp,
131
+ clientIp: observation.clientIp,
132
+ }),
133
+ interface: interfaceFor(pathname, headers),
134
+ requestId: limited(headers.get("x-vercel-id") ??
135
+ headers.get("cf-ray") ??
136
+ headers.get("x-request-id")),
137
+ traceId: traceId(headers),
138
+ taskId: limited(context.taskId ?? null),
139
+ sessionId: limited(context.sessionId ?? null),
140
+ resourceId: limited(context.resourceId ?? null),
141
+ contentVersion: limited(context.contentVersion ?? null),
142
+ status: observation.status ?? undefined,
143
+ durationMs: observation.durationMs ?? undefined,
144
+ bytes: observation.bytes ?? undefined,
145
+ cacheStatus: limited(observation.cacheStatus ?? null, 32),
146
+ sampleRate: sampled ? sampleUnknown : 1,
147
+ classifierVersion: CLASSIFIER_VERSION,
148
+ };
149
+ }
150
+ /**
151
+ * Failure here must never reach the customer's site. A missing key turns the
152
+ * client into a no-op, and a full queue drops the oldest events.
153
+ */
154
+ export function createScoutline(options = {}) {
155
+ const key = options.key ?? env("SCOUTLINE_KEY");
156
+ const endpoint = (options.endpoint ||
157
+ env("SCOUTLINE_ENDPOINT") ||
158
+ DEFAULT_ENDPOINT).replace(/\/+$/, "");
159
+ if (!key) {
160
+ console.warn("[scoutline] Missing SCOUTLINE_KEY, so nothing is collected.");
161
+ return {
162
+ observe: () => undefined,
163
+ flush: async () => { },
164
+ // The script tag still gets a valid answer, so pages don't log a 404.
165
+ handle(request) {
166
+ const route = ownRoute(request);
167
+ if (route === SCRIPT_PATH)
168
+ return Promise.resolve(scriptResponse());
169
+ return route ? Promise.resolve(emptyResponse(204)) : null;
170
+ },
171
+ };
172
+ }
173
+ const sampleUnknown = options.sampleUnknown ?? 1;
174
+ let queue = [];
175
+ let timer;
176
+ let lastSentAt = 0;
177
+ let pausedUntil = 0;
178
+ let browserWindowStart = 0;
179
+ let browserEventsInWindow = 0;
180
+ async function post(events) {
181
+ const response = await fetch(`${endpoint}/v1/events`, {
182
+ method: "POST",
183
+ headers: {
184
+ Authorization: `Bearer ${key}`,
185
+ "Content-Type": "application/json",
186
+ },
187
+ body: JSON.stringify({ events }),
188
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
189
+ });
190
+ await response.body?.cancel();
191
+ return response.status;
192
+ }
193
+ async function drain() {
194
+ while (queue.length > 0 && Date.now() >= pausedUntil) {
195
+ const batch = queue.slice(0, MAX_BATCH);
196
+ queue = queue.slice(batch.length);
197
+ let status;
198
+ try {
199
+ status = await post(batch);
200
+ }
201
+ catch {
202
+ status = undefined;
203
+ }
204
+ if (status === 401) {
205
+ // Retrying can't fix a bad key, so don't spend the site's time on it.
206
+ console.warn("[scoutline] The collector refused this key.");
207
+ pausedUntil = Date.now() + REJECTED_BACKOFF_MS;
208
+ return;
209
+ }
210
+ if (status === 429) {
211
+ // The collector asked us to slow down, so hold these and try later.
212
+ pausedUntil = Date.now() + RATE_LIMITED_BACKOFF_MS;
213
+ queue = [...batch, ...queue].slice(-MAX_QUEUED);
214
+ return;
215
+ }
216
+ if (status === undefined || status >= 500) {
217
+ // Event ids are stable, so a repeat of a partial send dedupes downstream.
218
+ queue = [...batch, ...queue].slice(-MAX_QUEUED);
219
+ return;
220
+ }
221
+ lastSentAt = Date.now();
222
+ }
223
+ }
224
+ /** A quiet site isn't a broken collector. An empty batch says so. */
225
+ async function heartbeat() {
226
+ if (Date.now() - lastSentAt < HEARTBEAT_INTERVAL_MS)
227
+ return;
228
+ if (Date.now() < pausedUntil)
229
+ return;
230
+ lastSentAt = Date.now();
231
+ await post([]).catch(() => { });
232
+ }
233
+ /**
234
+ * Each call drains by itself rather than sharing one in-flight promise,
235
+ * because Cloudflare doesn't let a request wait on another request's work.
236
+ * The queue hands each batch out once, so overlapping calls are safe.
237
+ */
238
+ function flush() {
239
+ return drain()
240
+ .then(heartbeat)
241
+ .catch(() => { });
242
+ }
243
+ function enqueue(event) {
244
+ queue.push(event);
245
+ if (queue.length > MAX_QUEUED)
246
+ queue = queue.slice(-MAX_QUEUED);
247
+ // Keeps long-running servers sending. Serverless flushes via waitUntil.
248
+ if (!timer) {
249
+ timer = setTimeout(() => {
250
+ timer = undefined;
251
+ void flush();
252
+ }, FLUSH_INTERVAL_MS);
253
+ timer.unref?.();
254
+ }
255
+ }
256
+ function observe(request, observation = {}) {
257
+ try {
258
+ const event = toEvent(request, observation, sampleUnknown, options);
259
+ if (!event)
260
+ return undefined;
261
+ enqueue(event);
262
+ return event.eventId;
263
+ }
264
+ catch {
265
+ // Observation is optional. The customer's request is not.
266
+ return undefined;
267
+ }
268
+ }
269
+ function withinBrowserLimit(count) {
270
+ const now = Date.now();
271
+ if (now - browserWindowStart > 60 * 1000) {
272
+ browserWindowStart = now;
273
+ browserEventsInWindow = 0;
274
+ }
275
+ browserEventsInWindow += count;
276
+ return browserEventsInWindow <= MAX_BROWSER_EVENTS_PER_MINUTE;
277
+ }
278
+ /** The class comes from the server's view. A page may only make it weaker. */
279
+ function browserActor(request, automated) {
280
+ const actor = classify(request.headers.get("user-agent"));
281
+ if (actor.class === "unknown" && automated) {
282
+ return { class: "inferred", mode: "browser" };
283
+ }
284
+ return actor;
285
+ }
286
+ async function acceptBrowserEvents(request, clientIp) {
287
+ try {
288
+ if (request.method !== "POST")
289
+ return emptyResponse(405);
290
+ if (!isSameOrigin(request.headers))
291
+ return emptyResponse(403);
292
+ const body = parseBrowserBody(await request.text());
293
+ if (!body)
294
+ return emptyResponse(400);
295
+ if (!withinBrowserLimit(body.events.length))
296
+ return emptyResponse(204);
297
+ const actor = browserActor(request, body.automated);
298
+ const sampled = actor.class === "unknown" && sampleUnknown < 1;
299
+ if (sampled && Math.random() >= sampleUnknown)
300
+ return emptyResponse(204);
301
+ const now = Date.now();
302
+ const facts = requestFacts(request.headers, {
303
+ sendClientIp: options.sendClientIp,
304
+ clientIp,
305
+ });
306
+ for (const raw of body.events) {
307
+ const event = toBrowserEvent(raw, actor, now, sampled ? sampleUnknown : 1);
308
+ if (event)
309
+ enqueue({ ...event, ...facts, host: hostOf(request) });
310
+ }
311
+ return emptyResponse(204);
312
+ }
313
+ catch {
314
+ return emptyResponse(204);
315
+ }
316
+ }
317
+ function handle(request, clientIp) {
318
+ const route = ownRoute(request);
319
+ if (route === SCRIPT_PATH)
320
+ return Promise.resolve(scriptResponse());
321
+ return route ? acceptBrowserEvents(request, clientIp) : null;
322
+ }
323
+ return { observe, flush, handle };
324
+ }
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,129 @@
1
+ import { EVENTS_PATH, SCRIPT_PATH } from "./browser.js";
2
+ import { usableIp } from "./client-ip.js";
3
+ import { createScoutline, isDocumentRequest, serverTiming, } from "./index.js";
4
+ const MAX_BODY_BYTES = 32 * 1024;
5
+ function toRequest(request, body) {
6
+ const headers = new Headers();
7
+ for (const [name, value] of Object.entries(request.headers)) {
8
+ if (value !== undefined) {
9
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
10
+ }
11
+ }
12
+ const host = headers.get("host") ?? "localhost";
13
+ const path = request.originalUrl ?? request.url ?? "/";
14
+ return new Request(`http://${host}${path}`, {
15
+ method: request.method,
16
+ headers,
17
+ body,
18
+ });
19
+ }
20
+ /**
21
+ * A public peer is the visitor, and can't fake that the way it can fake a
22
+ * header. A private one is a proxy, so its headers are used instead.
23
+ */
24
+ function connectionIp(request) {
25
+ return usableIp(request.socket?.remoteAddress);
26
+ }
27
+ /** A body parser may have run first, and a stream can only be read once. */
28
+ async function readBody(request) {
29
+ if (typeof request.body === "string")
30
+ return request.body;
31
+ if (request.body !== undefined)
32
+ return JSON.stringify(request.body);
33
+ const chunks = [];
34
+ let size = 0;
35
+ for await (const chunk of request) {
36
+ size += chunk.length;
37
+ if (size > MAX_BODY_BYTES)
38
+ break;
39
+ chunks.push(chunk);
40
+ }
41
+ return Buffer.concat(chunks).toString("utf8");
42
+ }
43
+ async function serveOwnRoute(scoutline, request, response) {
44
+ const body = request.method === "POST" ? await readBody(request) : undefined;
45
+ const answer = await scoutline.handle(toRequest(request, body), connectionIp(request));
46
+ const text = (await answer?.text()) ?? "";
47
+ response.writeHead(answer?.status ?? 204, Object.fromEntries(answer?.headers ?? []));
48
+ response.end(text);
49
+ void scoutline.flush();
50
+ }
51
+ function contentLength(response) {
52
+ const value = Number(response.getHeader("content-length"));
53
+ return Number.isFinite(value) && value > 0 ? value : null;
54
+ }
55
+ /**
56
+ * Records the request once the response has finished, so status and duration
57
+ * are real. A client that hangs up early never got a status, so none is sent.
58
+ * The event id goes out first, because headers can't change after that.
59
+ */
60
+ function track(scoutline, request, response) {
61
+ const started = Date.now();
62
+ const web = toRequest(request);
63
+ const eventId = crypto.randomUUID();
64
+ if (isDocumentRequest(web)) {
65
+ response.appendHeader("Server-Timing", serverTiming(eventId));
66
+ }
67
+ let done = false;
68
+ const finish = (completed) => {
69
+ if (done)
70
+ return;
71
+ done = true;
72
+ scoutline.observe(web, {
73
+ eventId,
74
+ clientIp: connectionIp(request),
75
+ status: completed ? response.statusCode : null,
76
+ bytes: completed ? contentLength(response) : null,
77
+ durationMs: Date.now() - started,
78
+ });
79
+ };
80
+ response.once("finish", () => finish(true));
81
+ response.once("close", () => finish(false));
82
+ }
83
+ /** Returns true when the request was one of Scoutline's own routes. */
84
+ function handleRequest(scoutline, request, response) {
85
+ const path = (request.originalUrl ?? request.url ?? "/").split("?")[0];
86
+ if (path === SCRIPT_PATH || path === EVENTS_PATH) {
87
+ serveOwnRoute(scoutline, request, response).catch(() => {
88
+ if (!response.headersSent)
89
+ response.writeHead(204);
90
+ response.end();
91
+ });
92
+ return true;
93
+ }
94
+ try {
95
+ track(scoutline, request, response);
96
+ }
97
+ catch {
98
+ // Observation is optional. The customer's request is not.
99
+ }
100
+ return false;
101
+ }
102
+ /**
103
+ * Express middleware. Add it before your routes and body parsers.
104
+ *
105
+ * app.use(scoutlineExpress());
106
+ */
107
+ export function scoutlineExpress(options) {
108
+ const scoutline = createScoutline(options);
109
+ return function scoutlineMiddleware(request, response, next) {
110
+ if (!handleRequest(scoutline, request, response))
111
+ next();
112
+ };
113
+ }
114
+ /**
115
+ * Fastify plugin. `skip-override` applies it to the whole app rather than only
116
+ * the plugin's own scope, the same thing `fastify-plugin` does.
117
+ *
118
+ * app.register(scoutlineFastify);
119
+ */
120
+ export const scoutlineFastify = Object.assign(async function scoutlinePlugin(app, options) {
121
+ const scoutline = createScoutline(options);
122
+ app.addHook("onRequest", (request, reply, done) => {
123
+ const path = (request.raw.url ?? "/").split("?")[0];
124
+ if (path === SCRIPT_PATH || path === EVENTS_PATH)
125
+ reply.hijack();
126
+ if (!handleRequest(scoutline, request.raw, reply.raw))
127
+ done();
128
+ });
129
+ }, { [Symbol.for("skip-override")]: true });