@scoutline-ai/middleware 0.1.0 → 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.
- package/README.md +75 -5
- package/dist/client-ip.d.ts +31 -0
- package/dist/client-ip.js +83 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +4 -0
- package/dist/fetch.d.ts +3 -0
- package/dist/fetch.js +3 -0
- package/dist/index.d.ts +44 -2
- package/dist/index.js +66 -19
- package/dist/node.js +10 -1
- package/dist/outcomes.d.ts +56 -0
- package/dist/outcomes.js +144 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ export default withScoutline(yourProxy); // or withScoutline() if you have none
|
|
|
20
20
|
export const config = {
|
|
21
21
|
matcher: [
|
|
22
22
|
{
|
|
23
|
-
source: "/((?!_next/static|_next/image).*)",
|
|
23
|
+
source: "/((?!_next/static|_next/image|favicon.ico).*)",
|
|
24
24
|
missing: [
|
|
25
25
|
{ type: "header", key: "next-router-prefetch" },
|
|
26
26
|
{ type: "header", key: "purpose", value: "prefetch" },
|
|
@@ -32,9 +32,24 @@ export const config = {
|
|
|
32
32
|
|
|
33
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
34
|
|
|
35
|
+
Your local and preview deployments can use the same key. Reports only count traffic on your site's domain and its subdomains.
|
|
36
|
+
|
|
35
37
|
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
38
|
|
|
37
|
-
## Cloudflare Workers
|
|
39
|
+
## Cloudflare Workers
|
|
40
|
+
|
|
41
|
+
Pass the key in. Depending on its compatibility date and flags, a Worker may have no `process.env` to read it from.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { env } from "cloudflare:workers";
|
|
45
|
+
import { withScoutline } from "@scoutline-ai/middleware/fetch";
|
|
46
|
+
|
|
47
|
+
export default { fetch: withScoutline(app.fetch, { key: env.SCOUTLINE_KEY }) };
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Use `.dev.vars` locally and `npx wrangler secret put SCOUTLINE_KEY` in production. This sees the full response, so it records status, duration and size.
|
|
51
|
+
|
|
52
|
+
## Hono and other fetch handlers
|
|
38
53
|
|
|
39
54
|
```ts
|
|
40
55
|
import { withScoutline } from "@scoutline-ai/middleware/fetch";
|
|
@@ -42,7 +57,7 @@ import { withScoutline } from "@scoutline-ai/middleware/fetch";
|
|
|
42
57
|
export default { fetch: withScoutline(app.fetch) };
|
|
43
58
|
```
|
|
44
59
|
|
|
45
|
-
This
|
|
60
|
+
This also records status, duration and size. If Hono runs on Workers, pass the key as shown above.
|
|
46
61
|
|
|
47
62
|
## Express and Fastify
|
|
48
63
|
|
|
@@ -74,13 +89,66 @@ It stores nothing and sets no cookies. A page links to its server request throug
|
|
|
74
89
|
|
|
75
90
|
Browser events can be forged, so they are never `verified` and can't confirm an outcome. Reports keep them out of request counts.
|
|
76
91
|
|
|
92
|
+
## Outcomes
|
|
93
|
+
|
|
94
|
+
Outcomes are what your server confirms really happened, such as an order or a signup. They are never inferred from a 200.
|
|
95
|
+
|
|
96
|
+
Create an outcome key on your Install page. It is separate from your middleware key.
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
SCOUTLINE_OUTCOME_KEY=sl_live_...
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { createOutcomes } from "@scoutline-ai/middleware/outcomes";
|
|
104
|
+
|
|
105
|
+
const outcomes = createOutcomes();
|
|
106
|
+
|
|
107
|
+
// Runs your action, and confirms the outcome only if it succeeds.
|
|
108
|
+
const order = await outcomes.wrap("order.placed", () => placeOrder(cart), (order) => ({
|
|
109
|
+
id: order.id,
|
|
110
|
+
taskId,
|
|
111
|
+
headers: request.headers,
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
// Or confirm it yourself.
|
|
115
|
+
await outcomes.confirm("signup.completed", { id: user.id, taskId });
|
|
116
|
+
|
|
117
|
+
// Record a step on the way, or where it failed.
|
|
118
|
+
await outcomes.record("checkout_attempted", { taskId, errorCategory: "card_declined" });
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- **`id`** makes repeats safe: the same id counts once. It is hashed before sending. Without it, every call counts.
|
|
122
|
+
- **`taskId`** is your id for one piece of work, and puts the event on that Workflow timeline.
|
|
123
|
+
- **`headers`** are the incoming request's headers, which record who acted and the trace id.
|
|
124
|
+
|
|
125
|
+
Nothing here throws, so a Scoutline problem can't fail your action. Each call returns `{ ok: true }` or `{ ok: false, reason }`. Events are sent straight away, at most twice for up to 2 seconds each, so don't await them if your action can't spare that.
|
|
126
|
+
|
|
127
|
+
## Linking requests to a task
|
|
128
|
+
|
|
129
|
+
Add your own ids to requests with `context`. Only what you return here is sent.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
withScoutline(yourProxy, {
|
|
133
|
+
context: (request) => ({
|
|
134
|
+
taskId: request.headers.get("x-your-task-id"),
|
|
135
|
+
resourceId: productIdFrom(request),
|
|
136
|
+
contentVersion: process.env.VERCEL_GIT_COMMIT_SHA,
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Requests that share a W3C `traceparent` with an event in the task also appear on its timeline, labelled as linked by trace.
|
|
142
|
+
|
|
77
143
|
## What is sent
|
|
78
144
|
|
|
79
|
-
From the server: the method, pathname,
|
|
145
|
+
From the server: the method, pathname, user agent, whether a few standard headers were present, and request and trace ids. Images, video, fonts and similar files are recorded for crawlers and agents, and skipped for everyone else, so one page load counts once. From the browser: the pathname, event type, element type such as `button`, status, and load time.
|
|
80
146
|
|
|
81
147
|
Query strings, cookies, auth headers, bodies, page text and field values are never sent.
|
|
82
148
|
|
|
83
|
-
|
|
149
|
+
**The visitor's IP address** is sent to the collector, which uses it to find the country and network, then discards it. It is never stored. Country is where the network is registered, so cloud traffic may not show where the machine really is. Turn this off with `sendClientIp: false`, and country and network stay empty.
|
|
150
|
+
|
|
151
|
+
The collector classifies each request, so new crawlers are recognised without updating this package. A matching user agent is recorded as `declared`, because any client can send one. Other automation is `inferred`, and everything else is `unknown`. A browser claim that doesn't add up is only `inferred` when two different kinds of evidence agree, such as missing browser headers on a hosting network. This package never reports `verified`.
|
|
84
152
|
|
|
85
153
|
## Options
|
|
86
154
|
|
|
@@ -90,3 +158,5 @@ A matching user agent is recorded as `declared`, because any client can send one
|
|
|
90
158
|
| `endpoint` | `SCOUTLINE_ENDPOINT`, then `https://ingest.scoutline.ai` | Collector URL |
|
|
91
159
|
| `sampleUnknown` | `1` | Share of unclassified traffic to send, from 0 to 1 |
|
|
92
160
|
| `ignore` | none | `(pathname) => boolean` to skip a path |
|
|
161
|
+
| `sendClientIp` | `true` | Send the visitor's IP for the country and network lookup. It is never stored |
|
|
162
|
+
| `context` | none | `(request) => ({ taskId, sessionId, resourceId, contentVersion })` adds your own ids |
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapted from get-client-ip.ts in OpenPanel's Express SDK. The header order
|
|
3
|
+
* here puts platform-set headers first.
|
|
4
|
+
*
|
|
5
|
+
* MIT License
|
|
6
|
+
*
|
|
7
|
+
* Copyright (c) 2026 OpenPanel
|
|
8
|
+
*
|
|
9
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
* in the Software without restriction, including without limitation the rights
|
|
12
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
* furnished to do so, subject to the following conditions:
|
|
15
|
+
*
|
|
16
|
+
* The above copyright notice and this permission notice shall be included in
|
|
17
|
+
* all copies or substantial portions of the Software.
|
|
18
|
+
*
|
|
19
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
* SOFTWARE.
|
|
26
|
+
*/
|
|
27
|
+
/** Node reports an IPv4 client as `::ffff:1.2.3.4` on a dual-stack socket. */
|
|
28
|
+
export declare function withoutIpv4Prefix(ip: string): string;
|
|
29
|
+
export declare function usableIp(value: string | null | undefined): string | undefined;
|
|
30
|
+
/** The first public address a proxy reported for the visitor. */
|
|
31
|
+
export declare function clientIpFromHeaders(headers: Headers): string | undefined;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapted from get-client-ip.ts in OpenPanel's Express SDK. The header order
|
|
3
|
+
* here puts platform-set headers first.
|
|
4
|
+
*
|
|
5
|
+
* MIT License
|
|
6
|
+
*
|
|
7
|
+
* Copyright (c) 2026 OpenPanel
|
|
8
|
+
*
|
|
9
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
* in the Software without restriction, including without limitation the rights
|
|
12
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
* furnished to do so, subject to the following conditions:
|
|
15
|
+
*
|
|
16
|
+
* The above copyright notice and this permission notice shall be included in
|
|
17
|
+
* all copies or substantial portions of the Software.
|
|
18
|
+
*
|
|
19
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
* SOFTWARE.
|
|
26
|
+
*/
|
|
27
|
+
const HEADER_ORDER = [
|
|
28
|
+
"cf-connecting-ip",
|
|
29
|
+
"x-vercel-forwarded-for",
|
|
30
|
+
"true-client-ip",
|
|
31
|
+
"x-real-ip",
|
|
32
|
+
"x-forwarded-for",
|
|
33
|
+
"fastly-client-ip",
|
|
34
|
+
"x-cluster-client-ip",
|
|
35
|
+
"x-appengine-user-ip",
|
|
36
|
+
"do-connecting-ip",
|
|
37
|
+
"x-nf-client-connection-ip",
|
|
38
|
+
"forwarded",
|
|
39
|
+
];
|
|
40
|
+
const IPV4 = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
41
|
+
const IPV6 = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
|
42
|
+
/** Node reports an IPv4 client as `::ffff:1.2.3.4` on a dual-stack socket. */
|
|
43
|
+
export function withoutIpv4Prefix(ip) {
|
|
44
|
+
return ip.startsWith("::ffff:") ? ip.slice("::ffff:".length) : ip;
|
|
45
|
+
}
|
|
46
|
+
function isPublicIp(address) {
|
|
47
|
+
const ip = withoutIpv4Prefix(address);
|
|
48
|
+
if (ip === "::1")
|
|
49
|
+
return false;
|
|
50
|
+
if (/^(fc|fd|fe8|fe9|fea|feb)/i.test(ip) && ip.includes(":"))
|
|
51
|
+
return false;
|
|
52
|
+
if (/^(127|10)\./.test(ip))
|
|
53
|
+
return false;
|
|
54
|
+
if (ip.startsWith("192.168.") || ip.startsWith("169.254."))
|
|
55
|
+
return false;
|
|
56
|
+
if (ip.startsWith("172.")) {
|
|
57
|
+
const second = Number.parseInt(ip.split(".")[1] ?? "0", 10);
|
|
58
|
+
if (second >= 16 && second <= 31)
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
export function usableIp(value) {
|
|
64
|
+
const ip = value ? withoutIpv4Prefix(value.trim()) : "";
|
|
65
|
+
if (!(IPV4.test(ip) || IPV6.test(ip)))
|
|
66
|
+
return undefined;
|
|
67
|
+
return isPublicIp(ip) ? ip : undefined;
|
|
68
|
+
}
|
|
69
|
+
/** The first public address a proxy reported for the visitor. */
|
|
70
|
+
export function clientIpFromHeaders(headers) {
|
|
71
|
+
for (const name of HEADER_ORDER) {
|
|
72
|
+
const value = headers.get(name);
|
|
73
|
+
if (!value)
|
|
74
|
+
continue;
|
|
75
|
+
const candidate = name === "forwarded"
|
|
76
|
+
? /for="?\[?([^\]",;]+)/i.exec(value)?.[1]
|
|
77
|
+
: value.split(",")[0];
|
|
78
|
+
const ip = usableIp(candidate);
|
|
79
|
+
if (ip)
|
|
80
|
+
return ip;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
package/dist/fetch.d.ts
CHANGED
|
@@ -6,5 +6,8 @@ import { type ScoutlineOptions } from "./index.js";
|
|
|
6
6
|
* also adds the browser script to HTML pages, so no code change is needed.
|
|
7
7
|
*
|
|
8
8
|
* export default { fetch: withScoutline(app.fetch) };
|
|
9
|
+
*
|
|
10
|
+
* Workers should pass `{ key: env.SCOUTLINE_KEY }` from `cloudflare:workers`,
|
|
11
|
+
* because older compatibility dates have no `process.env` to read it from.
|
|
9
12
|
*/
|
|
10
13
|
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
CHANGED
|
@@ -31,6 +31,9 @@ function contentLength(response) {
|
|
|
31
31
|
* also adds the browser script to HTML pages, so no code change is needed.
|
|
32
32
|
*
|
|
33
33
|
* export default { fetch: withScoutline(app.fetch) };
|
|
34
|
+
*
|
|
35
|
+
* Workers should pass `{ key: env.SCOUTLINE_KEY }` from `cloudflare:workers`,
|
|
36
|
+
* because older compatibility dates have no `process.env` to read it from.
|
|
34
37
|
*/
|
|
35
38
|
export function withScoutline(handler, options) {
|
|
36
39
|
const scoutline = createScoutline(options);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
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
|
+
};
|
|
2
14
|
export type ScoutlineOptions = {
|
|
3
15
|
/** Ingest key. Falls back to the SCOUTLINE_KEY environment variable. */
|
|
4
16
|
key?: string;
|
|
@@ -8,10 +20,19 @@ export type ScoutlineOptions = {
|
|
|
8
20
|
sampleUnknown?: number;
|
|
9
21
|
/** Skip a path entirely. Runs on the pathname only. */
|
|
10
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;
|
|
11
30
|
};
|
|
12
31
|
export type Observation = {
|
|
13
32
|
/** Set by adapters that must announce the id before the response finishes. */
|
|
14
33
|
eventId?: string;
|
|
34
|
+
/** Set by adapters that can see the connection, not just the headers. */
|
|
35
|
+
clientIp?: string;
|
|
15
36
|
/** Null when the response isn't visible from this position in the stack. */
|
|
16
37
|
status?: number | null;
|
|
17
38
|
durationMs?: number | null;
|
|
@@ -30,13 +51,34 @@ export type Scoutline = {
|
|
|
30
51
|
* Serves the browser script and its events from the site's own domain.
|
|
31
52
|
* Returns null for every other path. Must run before any auth check.
|
|
32
53
|
*/
|
|
33
|
-
handle(request: Request): Promise<Response> | null;
|
|
54
|
+
handle(request: Request, clientIp?: string): Promise<Response> | null;
|
|
34
55
|
};
|
|
35
56
|
/** Only full page loads carry a server id for the browser script to read. */
|
|
36
57
|
export declare function isDocumentRequest(request: Request): boolean;
|
|
37
58
|
export declare function serverTiming(eventId: string): string;
|
|
38
|
-
/**
|
|
59
|
+
/** A proxy may list several hosts, nearest client first. */
|
|
60
|
+
export declare function hostFromHeaders(headers: Headers): string | null;
|
|
61
|
+
/** The site's hostname. */
|
|
39
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;
|
|
40
82
|
/**
|
|
41
83
|
* Failure here must never reach the customer's site. A missing key turns the
|
|
42
84
|
* client into a no-op, and a full queue drops the oldest events.
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { EVENTS_PATH, isSameOrigin, parseBrowserBody, SCRIPT_PATH, toBrowserEvent, } from "./browser.js";
|
|
2
2
|
import { BROWSER_SCRIPT } from "./browser-script.js";
|
|
3
3
|
import { classify, CLASSIFIER_VERSION } from "./classify.js";
|
|
4
|
+
import { clientIpFromHeaders } from "./client-ip.js";
|
|
5
|
+
import { DEFAULT_ENDPOINT, env } from "./config.js";
|
|
4
6
|
export { classify } from "./classify.js";
|
|
5
|
-
const DEFAULT_ENDPOINT = "https://ingest.scoutline.ai";
|
|
6
7
|
const MAX_QUEUED = 500;
|
|
7
8
|
const MAX_BATCH = 100;
|
|
8
9
|
const SEND_TIMEOUT_MS = 2000;
|
|
@@ -12,11 +13,17 @@ const REJECTED_BACKOFF_MS = 60 * 1000;
|
|
|
12
13
|
const RATE_LIMITED_BACKOFF_MS = 60 * 1000;
|
|
13
14
|
const MAX_FIELD_CHARS = 128;
|
|
14
15
|
const MAX_URL_CHARS = 2048;
|
|
16
|
+
const MAX_USER_AGENT_CHARS = 512;
|
|
15
17
|
/** Caps browser events per server instance, so a page can't flood the queue. */
|
|
16
18
|
const MAX_BROWSER_EVENTS_PER_MINUTE = 2000;
|
|
17
19
|
const SCRIPT_CACHE_SECONDS = 3600;
|
|
18
|
-
|
|
19
|
-
|
|
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;
|
|
20
27
|
/** Only full page loads carry a server id for the browser script to read. */
|
|
21
28
|
export function isDocumentRequest(request) {
|
|
22
29
|
return (request.method === "GET" &&
|
|
@@ -41,9 +48,6 @@ function ownRoute(request) {
|
|
|
41
48
|
const { pathname } = new URL(request.url);
|
|
42
49
|
return pathname === SCRIPT_PATH || pathname === EVENTS_PATH ? pathname : null;
|
|
43
50
|
}
|
|
44
|
-
function env(name) {
|
|
45
|
-
return typeof process === "undefined" ? undefined : process?.env?.[name];
|
|
46
|
-
}
|
|
47
51
|
function limited(value, max = MAX_FIELD_CHARS) {
|
|
48
52
|
return value && value.length <= max ? value : undefined;
|
|
49
53
|
}
|
|
@@ -54,34 +58,65 @@ function interfaceFor(pathname, headers) {
|
|
|
54
58
|
return "api";
|
|
55
59
|
return headers.get("accept")?.includes("application/json") ? "api" : "web";
|
|
56
60
|
}
|
|
57
|
-
/**
|
|
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. */
|
|
58
67
|
export function hostOf(request) {
|
|
59
|
-
const
|
|
60
|
-
const host = forwarded?.trim() ||
|
|
61
|
-
request.headers.get("host") ||
|
|
62
|
-
new URL(request.url).host;
|
|
68
|
+
const host = hostFromHeaders(request.headers) ?? new URL(request.url).host;
|
|
63
69
|
return limited(host.toLowerCase(), 255);
|
|
64
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
|
+
}
|
|
65
89
|
function isPrefetch(headers) {
|
|
66
90
|
return (headers.has("next-router-prefetch") ||
|
|
67
91
|
headers.get("purpose") === "prefetch" ||
|
|
68
92
|
headers.get("sec-purpose")?.includes("prefetch") === true);
|
|
69
93
|
}
|
|
70
94
|
/** The W3C trace id from `traceparent`, a claim until something validates it. */
|
|
71
|
-
function traceId(headers) {
|
|
95
|
+
export function traceId(headers) {
|
|
72
96
|
return limited(headers.get("traceparent")?.split("-")[1] ?? null);
|
|
73
97
|
}
|
|
74
|
-
function
|
|
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) {
|
|
75
107
|
const { pathname } = new URL(request.url);
|
|
76
|
-
if (
|
|
108
|
+
if (FRAMEWORK_PATH.test(pathname) || options.ignore?.(pathname))
|
|
77
109
|
return null;
|
|
78
110
|
const { headers } = request;
|
|
79
111
|
if (isPrefetch(headers))
|
|
80
112
|
return null;
|
|
81
113
|
const actor = classify(headers.get("user-agent"));
|
|
114
|
+
if (actor.class === "unknown" && ASSET_PATH.test(pathname))
|
|
115
|
+
return null;
|
|
82
116
|
const sampled = actor.class === "unknown" && sampleUnknown < 1;
|
|
83
117
|
if (sampled && Math.random() >= sampleUnknown)
|
|
84
118
|
return null;
|
|
119
|
+
const context = contextOf(request, options.context);
|
|
85
120
|
return {
|
|
86
121
|
eventId: observation.eventId ?? crypto.randomUUID(),
|
|
87
122
|
occurredAt: Date.now(),
|
|
@@ -91,11 +126,19 @@ function toEvent(request, observation, sampleUnknown, ignore) {
|
|
|
91
126
|
// The pathname only: queries, cookies, headers and bodies never leave here.
|
|
92
127
|
url: pathname.slice(0, MAX_URL_CHARS),
|
|
93
128
|
host: hostOf(request),
|
|
129
|
+
...requestFacts(headers, {
|
|
130
|
+
sendClientIp: options.sendClientIp,
|
|
131
|
+
clientIp: observation.clientIp,
|
|
132
|
+
}),
|
|
94
133
|
interface: interfaceFor(pathname, headers),
|
|
95
134
|
requestId: limited(headers.get("x-vercel-id") ??
|
|
96
135
|
headers.get("cf-ray") ??
|
|
97
136
|
headers.get("x-request-id")),
|
|
98
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),
|
|
99
142
|
status: observation.status ?? undefined,
|
|
100
143
|
durationMs: observation.durationMs ?? undefined,
|
|
101
144
|
bytes: observation.bytes ?? undefined,
|
|
@@ -212,7 +255,7 @@ export function createScoutline(options = {}) {
|
|
|
212
255
|
}
|
|
213
256
|
function observe(request, observation = {}) {
|
|
214
257
|
try {
|
|
215
|
-
const event = toEvent(request, observation, sampleUnknown, options
|
|
258
|
+
const event = toEvent(request, observation, sampleUnknown, options);
|
|
216
259
|
if (!event)
|
|
217
260
|
return undefined;
|
|
218
261
|
enqueue(event);
|
|
@@ -240,7 +283,7 @@ export function createScoutline(options = {}) {
|
|
|
240
283
|
}
|
|
241
284
|
return actor;
|
|
242
285
|
}
|
|
243
|
-
async function acceptBrowserEvents(request) {
|
|
286
|
+
async function acceptBrowserEvents(request, clientIp) {
|
|
244
287
|
try {
|
|
245
288
|
if (request.method !== "POST")
|
|
246
289
|
return emptyResponse(405);
|
|
@@ -256,10 +299,14 @@ export function createScoutline(options = {}) {
|
|
|
256
299
|
if (sampled && Math.random() >= sampleUnknown)
|
|
257
300
|
return emptyResponse(204);
|
|
258
301
|
const now = Date.now();
|
|
302
|
+
const facts = requestFacts(request.headers, {
|
|
303
|
+
sendClientIp: options.sendClientIp,
|
|
304
|
+
clientIp,
|
|
305
|
+
});
|
|
259
306
|
for (const raw of body.events) {
|
|
260
307
|
const event = toBrowserEvent(raw, actor, now, sampled ? sampleUnknown : 1);
|
|
261
308
|
if (event)
|
|
262
|
-
enqueue({ ...event, host: hostOf(request) });
|
|
309
|
+
enqueue({ ...event, ...facts, host: hostOf(request) });
|
|
263
310
|
}
|
|
264
311
|
return emptyResponse(204);
|
|
265
312
|
}
|
|
@@ -267,11 +314,11 @@ export function createScoutline(options = {}) {
|
|
|
267
314
|
return emptyResponse(204);
|
|
268
315
|
}
|
|
269
316
|
}
|
|
270
|
-
function handle(request) {
|
|
317
|
+
function handle(request, clientIp) {
|
|
271
318
|
const route = ownRoute(request);
|
|
272
319
|
if (route === SCRIPT_PATH)
|
|
273
320
|
return Promise.resolve(scriptResponse());
|
|
274
|
-
return route ? acceptBrowserEvents(request) : null;
|
|
321
|
+
return route ? acceptBrowserEvents(request, clientIp) : null;
|
|
275
322
|
}
|
|
276
323
|
return { observe, flush, handle };
|
|
277
324
|
}
|
package/dist/node.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EVENTS_PATH, SCRIPT_PATH } from "./browser.js";
|
|
2
|
+
import { usableIp } from "./client-ip.js";
|
|
2
3
|
import { createScoutline, isDocumentRequest, serverTiming, } from "./index.js";
|
|
3
4
|
const MAX_BODY_BYTES = 32 * 1024;
|
|
4
5
|
function toRequest(request, body) {
|
|
@@ -16,6 +17,13 @@ function toRequest(request, body) {
|
|
|
16
17
|
body,
|
|
17
18
|
});
|
|
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
|
+
}
|
|
19
27
|
/** A body parser may have run first, and a stream can only be read once. */
|
|
20
28
|
async function readBody(request) {
|
|
21
29
|
if (typeof request.body === "string")
|
|
@@ -34,7 +42,7 @@ async function readBody(request) {
|
|
|
34
42
|
}
|
|
35
43
|
async function serveOwnRoute(scoutline, request, response) {
|
|
36
44
|
const body = request.method === "POST" ? await readBody(request) : undefined;
|
|
37
|
-
const answer = await scoutline.handle(toRequest(request, body));
|
|
45
|
+
const answer = await scoutline.handle(toRequest(request, body), connectionIp(request));
|
|
38
46
|
const text = (await answer?.text()) ?? "";
|
|
39
47
|
response.writeHead(answer?.status ?? 204, Object.fromEntries(answer?.headers ?? []));
|
|
40
48
|
response.end(text);
|
|
@@ -63,6 +71,7 @@ function track(scoutline, request, response) {
|
|
|
63
71
|
done = true;
|
|
64
72
|
scoutline.observe(web, {
|
|
65
73
|
eventId,
|
|
74
|
+
clientIp: connectionIp(request),
|
|
66
75
|
status: completed ? response.statusCode : null,
|
|
67
76
|
bytes: completed ? contentLength(response) : null,
|
|
68
77
|
durationMs: Date.now() - started,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type OutcomeOptions = {
|
|
2
|
+
/** Outcome key. Falls back to the SCOUTLINE_OUTCOME_KEY environment variable. */
|
|
3
|
+
key?: string;
|
|
4
|
+
/** Collector URL, for self-hosting or testing. Falls back to SCOUTLINE_ENDPOINT. */
|
|
5
|
+
endpoint?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Sends the visitor's IP from `headers` so the collector can find its country
|
|
8
|
+
* and network. The collector never stores it. Default true.
|
|
9
|
+
*/
|
|
10
|
+
sendClientIp?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type EventDetails = {
|
|
13
|
+
/**
|
|
14
|
+
* Makes repeats safe: the same id counts once, so use your order or signup
|
|
15
|
+
* id. It is hashed before sending, so the id itself never leaves your server.
|
|
16
|
+
*/
|
|
17
|
+
id?: string;
|
|
18
|
+
/** Your id for the piece of work. Shows this event in its Workflow timeline. */
|
|
19
|
+
taskId?: string;
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
resourceId?: string;
|
|
22
|
+
contentVersion?: string;
|
|
23
|
+
/** Your own opaque reference for who the agent acted for. Never an email. */
|
|
24
|
+
principalRef?: string;
|
|
25
|
+
accountRef?: string;
|
|
26
|
+
/** The incoming request's headers, to record who acted and the trace id. */
|
|
27
|
+
headers?: Headers | Record<string, string | string[] | undefined>;
|
|
28
|
+
errorCategory?: string;
|
|
29
|
+
status?: number;
|
|
30
|
+
durationMs?: number;
|
|
31
|
+
};
|
|
32
|
+
/** Something the server knows happened, short of a confirmed outcome. */
|
|
33
|
+
export type ServerEventFamily = "search" | "comparison" | "tool_invocation" | "authentication_required" | "permission_denied" | "quote_created" | "checkout_attempted" | "payment_required" | "payment_settled" | "approval_requested" | "human_takeover" | "workflow_resumed";
|
|
34
|
+
export type SendResult = {
|
|
35
|
+
ok: true;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
reason: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* For the things only your server can confirm. Outcomes are never inferred from
|
|
42
|
+
* a 200, because a page loading doesn't prove the order went through.
|
|
43
|
+
*
|
|
44
|
+
* Nothing here throws, so a Scoutline problem can't fail your action. Events
|
|
45
|
+
* are sent straight away rather than queued, because a serverless function can
|
|
46
|
+
* stop as soon as it returns. Each one is sent at most twice, up to 2 seconds
|
|
47
|
+
* each. Don't await it if your action can't spare that.
|
|
48
|
+
*
|
|
49
|
+
* const outcomes = createOutcomes();
|
|
50
|
+
* await outcomes.confirm("order.placed", { id: order.id, taskId });
|
|
51
|
+
*/
|
|
52
|
+
export declare function createOutcomes(options?: OutcomeOptions): {
|
|
53
|
+
confirm: (outcomeKey: string, details?: EventDetails) => Promise<SendResult>;
|
|
54
|
+
record: (family: ServerEventFamily, details?: EventDetails) => Promise<SendResult>;
|
|
55
|
+
wrap: <Result>(outcomeKey: string, action: () => Promise<Result>, details?: EventDetails | ((result: Result) => EventDetails)) => Promise<Result>;
|
|
56
|
+
};
|
package/dist/outcomes.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { classify, CLASSIFIER_VERSION } from "./classify.js";
|
|
2
|
+
import { DEFAULT_ENDPOINT, env } from "./config.js";
|
|
3
|
+
import { hostFromHeaders, requestFacts, traceId } from "./index.js";
|
|
4
|
+
const SEND_TIMEOUT_MS = 2000;
|
|
5
|
+
const MAX_ID_CHARS = 128;
|
|
6
|
+
function toHeaders(headers) {
|
|
7
|
+
if (!headers || headers instanceof Headers)
|
|
8
|
+
return headers ?? new Headers();
|
|
9
|
+
const result = new Headers();
|
|
10
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
11
|
+
if (value !== undefined) {
|
|
12
|
+
result.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
function bounded(value) {
|
|
18
|
+
return value && value.length <= MAX_ID_CHARS ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
async function stableId(parts) {
|
|
21
|
+
const bytes = new TextEncoder().encode(parts.join("\0"));
|
|
22
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
23
|
+
const hex = [...new Uint8Array(digest)]
|
|
24
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
25
|
+
.join("");
|
|
26
|
+
return `sdk_${hex.slice(0, 40)}`;
|
|
27
|
+
}
|
|
28
|
+
/** Retries can't help with these, so only a failed delivery goes again. */
|
|
29
|
+
function shouldRetry(status) {
|
|
30
|
+
return status === undefined || status >= 500;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* For the things only your server can confirm. Outcomes are never inferred from
|
|
34
|
+
* a 200, because a page loading doesn't prove the order went through.
|
|
35
|
+
*
|
|
36
|
+
* Nothing here throws, so a Scoutline problem can't fail your action. Events
|
|
37
|
+
* are sent straight away rather than queued, because a serverless function can
|
|
38
|
+
* stop as soon as it returns. Each one is sent at most twice, up to 2 seconds
|
|
39
|
+
* each. Don't await it if your action can't spare that.
|
|
40
|
+
*
|
|
41
|
+
* const outcomes = createOutcomes();
|
|
42
|
+
* await outcomes.confirm("order.placed", { id: order.id, taskId });
|
|
43
|
+
*/
|
|
44
|
+
export function createOutcomes(options = {}) {
|
|
45
|
+
const key = options.key ?? env("SCOUTLINE_OUTCOME_KEY");
|
|
46
|
+
const endpoint = (options.endpoint ||
|
|
47
|
+
env("SCOUTLINE_ENDPOINT") ||
|
|
48
|
+
DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
|
49
|
+
if (!key) {
|
|
50
|
+
console.warn("[scoutline] Missing SCOUTLINE_OUTCOME_KEY, so outcomes aren't sent.");
|
|
51
|
+
}
|
|
52
|
+
async function post(event) {
|
|
53
|
+
let status;
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetch(`${endpoint}/v1/events`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: {
|
|
58
|
+
Authorization: `Bearer ${key}`,
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({ events: [event] }),
|
|
62
|
+
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
63
|
+
});
|
|
64
|
+
status = response.status;
|
|
65
|
+
if (status !== 202) {
|
|
66
|
+
await response.body?.cancel();
|
|
67
|
+
return { status };
|
|
68
|
+
}
|
|
69
|
+
const body = (await response.json());
|
|
70
|
+
return { status, rejected: body.rejected?.[0]?.reason };
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { status };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function send(family, outcomeKey, details) {
|
|
77
|
+
if (!key)
|
|
78
|
+
return { ok: false, reason: "no_key" };
|
|
79
|
+
try {
|
|
80
|
+
const headers = toHeaders(details.headers);
|
|
81
|
+
const userAgent = headers.get("user-agent");
|
|
82
|
+
const event = {
|
|
83
|
+
eventId: details.id
|
|
84
|
+
? await stableId([family, outcomeKey ?? "", details.id])
|
|
85
|
+
: crypto.randomUUID(),
|
|
86
|
+
occurredAt: Date.now(),
|
|
87
|
+
family,
|
|
88
|
+
outcomeKey,
|
|
89
|
+
actor: classify(userAgent),
|
|
90
|
+
...requestFacts(headers, { sendClientIp: options.sendClientIp }),
|
|
91
|
+
host: hostFromHeaders(headers)?.toLowerCase() ?? undefined,
|
|
92
|
+
interface: "app",
|
|
93
|
+
traceId: traceId(headers),
|
|
94
|
+
taskId: bounded(details.taskId),
|
|
95
|
+
sessionId: bounded(details.sessionId),
|
|
96
|
+
resourceId: bounded(details.resourceId),
|
|
97
|
+
contentVersion: bounded(details.contentVersion),
|
|
98
|
+
principalRef: bounded(details.principalRef),
|
|
99
|
+
accountRef: bounded(details.accountRef),
|
|
100
|
+
errorCategory: bounded(details.errorCategory),
|
|
101
|
+
status: details.status,
|
|
102
|
+
durationMs: details.durationMs,
|
|
103
|
+
sampleRate: 1,
|
|
104
|
+
classifierVersion: CLASSIFIER_VERSION,
|
|
105
|
+
};
|
|
106
|
+
let result = await post(event);
|
|
107
|
+
if (shouldRetry(result.status))
|
|
108
|
+
result = await post(event);
|
|
109
|
+
if (result.status === 202 && !result.rejected)
|
|
110
|
+
return { ok: true };
|
|
111
|
+
const reason = result.rejected ?? `status_${result.status ?? "none"}`;
|
|
112
|
+
console.warn(`[scoutline] ${family} wasn't accepted: ${reason}`);
|
|
113
|
+
return { ok: false, reason };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { ok: false, reason: "failed" };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Records that the outcome really happened. */
|
|
120
|
+
function confirm(outcomeKey, details = {}) {
|
|
121
|
+
return send("outcome_completed", outcomeKey, details);
|
|
122
|
+
}
|
|
123
|
+
/** Records a server event, such as a failed checkout, on the timeline. */
|
|
124
|
+
function record(family, details = {}) {
|
|
125
|
+
return send(family, undefined, details);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Runs your action and confirms the outcome only if it succeeds. A failure is
|
|
129
|
+
* rethrown unchanged. Pass a function to take the id from the result.
|
|
130
|
+
*/
|
|
131
|
+
async function wrap(outcomeKey, action, details = {}) {
|
|
132
|
+
const result = await action();
|
|
133
|
+
let resolved = {};
|
|
134
|
+
try {
|
|
135
|
+
resolved = typeof details === "function" ? details(result) : details;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Observation is optional. The customer's action already succeeded.
|
|
139
|
+
}
|
|
140
|
+
await confirm(outcomeKey, resolved);
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
return { confirm, record, wrap };
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scoutline-ai/middleware",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "In-path agent observation for Scoutline. Fails open and adds no work to the hot path.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
12
|
"default": "./dist/index.js"
|
|
13
13
|
},
|
|
14
|
+
"./outcomes": {
|
|
15
|
+
"types": "./dist/outcomes.d.ts",
|
|
16
|
+
"default": "./dist/outcomes.js"
|
|
17
|
+
},
|
|
14
18
|
"./next": {
|
|
15
19
|
"types": "./dist/next.d.ts",
|
|
16
20
|
"default": "./dist/next.js"
|