@scoutline-ai/middleware 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +50 -4
- package/dist/browser-script.d.ts +16 -7
- package/dist/browser-script.js +148 -9
- package/dist/browser.d.ts +46 -0
- package/dist/browser.js +88 -2
- package/dist/fetch.js +12 -2
- package/dist/index.d.ts +15 -28
- package/dist/index.js +18 -67
- package/dist/next.js +2 -1
- package/dist/node.js +2 -1
- package/dist/outcomes.d.ts +2 -0
- package/dist/outcomes.js +3 -2
- package/dist/request.d.ts +55 -0
- package/dist/request.js +103 -0
- package/package.json +2 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Scoutline
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -85,10 +85,22 @@ The server sees every request, including crawlers that never run JavaScript. The
|
|
|
85
85
|
|
|
86
86
|
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.
|
|
87
87
|
|
|
88
|
-
It stores nothing and sets no cookies. A page links to its server request through a `Server-Timing` header.
|
|
88
|
+
It stores nothing and sets no cookies. A page links to its server request through a `Server-Timing` header. Page views are sent straight away. Other events wait for the next page view, the page closing, or 20 events, and each batch is one request to your server. Returning visitors can keep a cached script for up to an hour after you upgrade this package.
|
|
89
89
|
|
|
90
90
|
Browser events can be forged, so they are never `verified` and can't confirm an outcome. Reports keep them out of request counts.
|
|
91
91
|
|
|
92
|
+
## Custom events
|
|
93
|
+
|
|
94
|
+
Name something that happened on a page, and use it as a funnel step.
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
window.scoutline?.track("signup_started", { plan: "pro" });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Names can be up to 64 letters, numbers, spaces and `_ . : -`. Each event keeps up to 8 properties, and each property's value is cut to 128 characters. Keys must start with a letter or number. Never put personal data in a name or property: unlike everything else on this page, what you send here is your choice.
|
|
101
|
+
|
|
102
|
+
`window.scoutline` exists once the script has loaded, so call this from a click or after the page is ready. A page can forge these, so a custom event can never confirm an outcome, even with the same name.
|
|
103
|
+
|
|
92
104
|
## Outcomes
|
|
93
105
|
|
|
94
106
|
Outcomes are what your server confirms really happened, such as an order or a signup. They are never inferred from a 200.
|
|
@@ -120,7 +132,7 @@ await outcomes.record("checkout_attempted", { taskId, errorCategory: "card_decli
|
|
|
120
132
|
|
|
121
133
|
- **`id`** makes repeats safe: the same id counts once. It is hashed before sending. Without it, every call counts.
|
|
122
134
|
- **`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.
|
|
135
|
+
- **`headers`** are the incoming request's headers, which record who acted and the trace id. Without them an outcome can't be tied to a visitor, so it can't be a funnel step.
|
|
124
136
|
|
|
125
137
|
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
138
|
|
|
@@ -140,15 +152,47 @@ withScoutline(yourProxy, {
|
|
|
140
152
|
|
|
141
153
|
Requests that share a W3C `traceparent` with an event in the task also appear on its timeline, labelled as linked by trace.
|
|
142
154
|
|
|
155
|
+
## Recognising visitors
|
|
156
|
+
|
|
157
|
+
Without any setup, each visitor gets an anonymous id that changes every day and differs per site. It is a hash of their address and browser, made on the collector with a random salt that is thrown away afterwards. There is no cookie, and it can't follow anyone across days or sites. With `sendClientIp: false` there is no id, so those visitors can't appear in funnels or sessions.
|
|
158
|
+
|
|
159
|
+
People on one address with the same browser share a daily id, and a session ends when the id changes at midnight UTC. A session also ends after 30 minutes with nothing from that visitor. Funnels count a visitor who finishes the steps in order within 24 hours. `sampleUnknown` below 1 leaves gaps in both.
|
|
160
|
+
|
|
161
|
+
To recognise visitors who agree across days, turn consent on. This is off by default, and without it this package never sets a cookie.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
withScoutline(yourProxy, {
|
|
165
|
+
consent: { banner: { privacyUrl: "/privacy" } },
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
This shows a small banner with equal Accept and Decline buttons. Nothing is stored until someone chooses. Accepting sets a random first-party id in `_scoutline_id` for 13 months, and any choice is remembered in `_scoutline_consent`. Declining removes the id. Browsers sending Global Privacy Control count as declining. You can change the wording with `message`, `accept` and `decline`.
|
|
170
|
+
|
|
171
|
+
If you already have a consent tool, leave out `banner` and connect it instead:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
withScoutline(yourProxy, { consent: {} });
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
window.scoutline.consent(true); // or false, which also removes the id
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
To let visitors change their mind, call `window.scoutline.showConsent()` from a link in your footer. Your privacy policy still needs to describe this, and whether you need consent for the daily id depends on where your visitors are.
|
|
182
|
+
|
|
143
183
|
## What is sent
|
|
144
184
|
|
|
145
185
|
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.
|
|
146
186
|
|
|
147
|
-
|
|
187
|
+
**Referrers** are the other site's hostname only, such as `google.com`. **Outbound links** keep their origin and path, such as `https://stripe.com/pay`.
|
|
188
|
+
|
|
189
|
+
Query strings, auth headers, bodies, page text and field values are never sent, and neither are cookies, apart from Scoutline's own id if you turn on consent and the visitor agrees. The only other thing that leaves your site is what you pass to `track`.
|
|
190
|
+
|
|
191
|
+
**Device details** come from the browser's Client Hints and, where your page runs the script, its major OS version, device model, whether it has a touch screen, and a screen size group such as `small`. Exact screen sizes and full versions are not sent, and these details are never combined into an identifier. Safari and Firefox share less, so some fields stay empty rather than guessed.
|
|
148
192
|
|
|
149
193
|
**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
194
|
|
|
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
|
|
195
|
+
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 decides `verified`. The collector does, when a crawler's address is in the list its operator publishes and that address came from something a client can't fake: Vercel, Cloudflare, a direct connection, or your `trustedIpHeader`.
|
|
152
196
|
|
|
153
197
|
## Options
|
|
154
198
|
|
|
@@ -159,4 +203,6 @@ The collector classifies each request, so new crawlers are recognised without up
|
|
|
159
203
|
| `sampleUnknown` | `1` | Share of unclassified traffic to send, from 0 to 1 |
|
|
160
204
|
| `ignore` | none | `(pathname) => boolean` to skip a path |
|
|
161
205
|
| `sendClientIp` | `true` | Send the visitor's IP for the country and network lookup. It is never stored |
|
|
206
|
+
| `trustedIpHeader` | none | The header your own proxy sets to the visitor's IP. Needed to verify crawlers on self-hosted sites |
|
|
207
|
+
| `consent` | off | `{ banner: true }` shows a banner, and `{}` connects your own consent tool |
|
|
162
208
|
| `context` | none | `(request) => ({ taskId, sessionId, resourceId, contentVersion })` adds your own ids |
|
package/dist/browser-script.d.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
export type ConsentBanner = {
|
|
2
|
+
message?: string;
|
|
3
|
+
accept?: string;
|
|
4
|
+
decline?: string;
|
|
5
|
+
/** Linked from the banner. Must start with `/` or `https://`. */
|
|
6
|
+
privacyUrl?: string;
|
|
7
|
+
};
|
|
8
|
+
export type ConsentOptions = {
|
|
9
|
+
/**
|
|
10
|
+
* Shows Scoutline's banner. Leave it out if you already have a consent tool,
|
|
11
|
+
* and call `window.scoutline.consent(true)` or `(false)` from that instead.
|
|
12
|
+
*/
|
|
13
|
+
banner?: boolean | ConsentBanner;
|
|
14
|
+
};
|
|
15
|
+
export declare function browserScript(consent?: ConsentOptions): string;
|
|
16
|
+
export declare const BROWSER_SCRIPT: string;
|
package/dist/browser-script.js
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Served from the customer's own domain at /_scoutline/s. It stays plain ES5 and
|
|
3
3
|
* dependency free so it can load on any page. It collects no text, field values
|
|
4
|
-
* or query strings, and stores nothing
|
|
5
|
-
*
|
|
4
|
+
* or query strings, and stores nothing. Device details stay coarse, such as a
|
|
5
|
+
* screen size group, and are never combined into an identifier. Everything it
|
|
6
|
+
* sends is an unverified claim and is revalidated in `browser.ts`. It only touches
|
|
7
|
+
* cookies when a site turns consent on, and then only after the visitor agrees.
|
|
8
|
+
* A page view goes out straight away, because a visitor who stays on a page
|
|
9
|
+
* shouldn't be missing from sessions until they leave.
|
|
6
10
|
*/
|
|
7
|
-
|
|
8
|
-
var ENDPOINT="/_scoutline/e",MAX=20,queue=[],path=location.pathname;
|
|
11
|
+
const SCRIPT = `/*CONSENT*/(function(){
|
|
12
|
+
var ENDPOINT="/_scoutline/e",MAX=20,MAX_PROPERTIES=8,MAX_VALUE=128,queue=[],path=location.pathname;
|
|
9
13
|
var nav=performance.getEntriesByType?performance.getEntriesByType("navigation")[0]:null;
|
|
10
14
|
var automated=navigator.webdriver===true;
|
|
15
|
+
var device={touch:navigator.maxTouchPoints>0};
|
|
16
|
+
var shortSide=window.screen?Math.min(screen.width,screen.height):0;
|
|
17
|
+
if(shortSide)device.screen=shortSide<600?"small":shortSide<900?"medium":"large";
|
|
18
|
+
if(window.matchMedia)device.coarsePointer=matchMedia("(pointer:coarse)").matches;
|
|
19
|
+
var agent=navigator.userAgentData,details=null;
|
|
20
|
+
if(agent&&agent.getHighEntropyValues){
|
|
21
|
+
details=agent.getHighEntropyValues(["platformVersion","model"]).then(function(values){
|
|
22
|
+
device.platform=agent.platform;device.mobile=agent.mobile;
|
|
23
|
+
device.platformVersion=values.platformVersion;device.model=values.model;
|
|
24
|
+
})["catch"](function(){});
|
|
25
|
+
}
|
|
11
26
|
function serverId(){
|
|
12
27
|
var timing=nav&&nav.serverTiming;
|
|
13
28
|
for(var i=0;timing&&i<timing.length;i++){
|
|
@@ -21,7 +36,7 @@ export const BROWSER_SCRIPT = `(function(){
|
|
|
21
36
|
var e=queue[i];e.ageMs=now-e.at;delete e.at;events.push(e);
|
|
22
37
|
}
|
|
23
38
|
queue=[];
|
|
24
|
-
var body=JSON.stringify({automated:automated,events:events});
|
|
39
|
+
var body=JSON.stringify({automated:automated,device:device,events:events});
|
|
25
40
|
if(!navigator.sendBeacon||!navigator.sendBeacon(ENDPOINT,body)){
|
|
26
41
|
fetch(ENDPOINT,{method:"POST",body:body,keepalive:true})["catch"](function(){});
|
|
27
42
|
}
|
|
@@ -29,7 +44,23 @@ export const BROWSER_SCRIPT = `(function(){
|
|
|
29
44
|
function add(type,extra){
|
|
30
45
|
var e=extra||{};e.type=type;e.path=location.pathname;e.at=Date.now();
|
|
31
46
|
queue.push(e);
|
|
32
|
-
if(queue.length>=MAX)send();
|
|
47
|
+
if(queue.length>=MAX||type==="page_view")send();
|
|
48
|
+
}
|
|
49
|
+
function outboundUrl(link){
|
|
50
|
+
if(link.tagName!=="A"||!/^https?:$/.test(link.protocol))return;
|
|
51
|
+
var host=location.hostname.replace(/^www\\./,"");
|
|
52
|
+
if(link.hostname.replace(/^www\\./,"")===host)return;
|
|
53
|
+
return link.origin+link.pathname;
|
|
54
|
+
}
|
|
55
|
+
function properties(raw){
|
|
56
|
+
var clean={},count=0;
|
|
57
|
+
for(var key in raw){
|
|
58
|
+
var value=raw[key],type=typeof value;
|
|
59
|
+
if(count<MAX_PROPERTIES&&(type==="string"||type==="number"||type==="boolean")){
|
|
60
|
+
clean[key]=String(value).slice(0,MAX_VALUE);count++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return clean;
|
|
33
64
|
}
|
|
34
65
|
function firstView(){
|
|
35
66
|
add("page_view",{
|
|
@@ -49,7 +80,10 @@ export const BROWSER_SCRIPT = `(function(){
|
|
|
49
80
|
addEventListener("popstate",changed);
|
|
50
81
|
document.addEventListener("click",function(event){
|
|
51
82
|
var t=event.target&&event.target.closest&&event.target.closest("a,button,[role=button],input[type=submit]");
|
|
52
|
-
if(t)
|
|
83
|
+
if(!t)return;
|
|
84
|
+
var url=outboundUrl(t);
|
|
85
|
+
if(url)add("outbound",{url:url});
|
|
86
|
+
else add("click",{element:t.tagName.toLowerCase()});
|
|
53
87
|
},true);
|
|
54
88
|
document.addEventListener("submit",function(){add("form_submit");},true);
|
|
55
89
|
addEventListener("error",function(event){if(event.message)add("error");});
|
|
@@ -58,6 +92,111 @@ export const BROWSER_SCRIPT = `(function(){
|
|
|
58
92
|
document.addEventListener("visibilitychange",function(){
|
|
59
93
|
if(document.visibilityState==="hidden")send();
|
|
60
94
|
});
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
window.scoutline=window.scoutline||{};
|
|
96
|
+
window.scoutline.track=function(name,raw){
|
|
97
|
+
if(typeof name==="string")add("custom",{name:name,properties:properties(raw)});
|
|
98
|
+
};
|
|
99
|
+
function start(){if(details)details.then(firstView);else firstView();}
|
|
100
|
+
if(document.readyState==="complete")setTimeout(start);
|
|
101
|
+
else addEventListener("load",function(){setTimeout(start);});
|
|
63
102
|
})();`;
|
|
103
|
+
/**
|
|
104
|
+
* Added only for sites that turn consent on, so every other site's script has
|
|
105
|
+
* no cookie code in it at all. Nothing is stored until a visitor agrees. The
|
|
106
|
+
* choice itself is remembered, which is what a consent cookie is for, and Global
|
|
107
|
+
* Privacy Control counts as a refusal.
|
|
108
|
+
*/
|
|
109
|
+
const CONSENT = `(function(banner){
|
|
110
|
+
var ID="_scoutline_id",CHOICE="_scoutline_consent",AGE=34128000,box;
|
|
111
|
+
function read(name){
|
|
112
|
+
var match=document.cookie.match("(?:^|; )"+name+"=([^;]*)");
|
|
113
|
+
return match?match[1]:null;
|
|
114
|
+
}
|
|
115
|
+
function write(name,value,age){
|
|
116
|
+
document.cookie=name+"="+value+"; Max-Age="+age+"; Path=/; SameSite=Lax"+
|
|
117
|
+
(location.protocol==="https:"?"; Secure":"");
|
|
118
|
+
}
|
|
119
|
+
function randomId(){
|
|
120
|
+
if(crypto.randomUUID)return crypto.randomUUID();
|
|
121
|
+
var b=crypto.getRandomValues(new Uint8Array(16)),h=[];
|
|
122
|
+
b[6]=b[6]&15|64;b[8]=b[8]&63|128;
|
|
123
|
+
for(var i=0;i<16;i++)h.push((b[i]+256).toString(16).slice(1));
|
|
124
|
+
return h.slice(0,4).join("")+"-"+h.slice(4,6).join("")+"-"+h.slice(6,8).join("")+
|
|
125
|
+
"-"+h.slice(8,10).join("")+"-"+h.slice(10).join("");
|
|
126
|
+
}
|
|
127
|
+
function hide(){if(box&&box.parentNode)box.parentNode.removeChild(box);box=null;}
|
|
128
|
+
function choose(yes){
|
|
129
|
+
write(CHOICE,yes?"yes":"no",AGE);
|
|
130
|
+
if(!yes)write(ID,"",0);
|
|
131
|
+
else if(!read(ID))write(ID,randomId(),AGE);
|
|
132
|
+
hide();
|
|
133
|
+
}
|
|
134
|
+
function button(label,yes){
|
|
135
|
+
var b=document.createElement("button");
|
|
136
|
+
b.type="button";b.textContent=label;
|
|
137
|
+
b.style.cssText="flex:1;padding:8px 12px;border:1px solid #d4d4d8;border-radius:8px;"+
|
|
138
|
+
"background:#fff;color:#18181b;font:inherit;cursor:pointer";
|
|
139
|
+
b.addEventListener("click",function(){choose(yes);});
|
|
140
|
+
return b;
|
|
141
|
+
}
|
|
142
|
+
function show(){
|
|
143
|
+
if(box||!document.body)return;
|
|
144
|
+
box=document.createElement("div");
|
|
145
|
+
box.setAttribute("role","dialog");
|
|
146
|
+
box.setAttribute("aria-label","Cookie choice");
|
|
147
|
+
box.style.cssText="position:fixed;left:16px;right:16px;bottom:16px;z-index:2147483647;"+
|
|
148
|
+
"max-width:380px;box-sizing:border-box;padding:16px;border-radius:12px;background:#fff;"+
|
|
149
|
+
"color:#18181b;box-shadow:0 8px 30px rgba(0,0,0,.18);"+
|
|
150
|
+
"font:14px/1.45 system-ui,sans-serif";
|
|
151
|
+
var text=document.createElement("p");
|
|
152
|
+
text.style.margin="0 0 12px";
|
|
153
|
+
text.textContent=banner.message;
|
|
154
|
+
if(banner.privacyUrl){
|
|
155
|
+
var link=document.createElement("a");
|
|
156
|
+
link.href=banner.privacyUrl;link.textContent=" Privacy policy";
|
|
157
|
+
link.style.color="inherit";
|
|
158
|
+
text.appendChild(link);
|
|
159
|
+
}
|
|
160
|
+
var row=document.createElement("div");
|
|
161
|
+
row.style.cssText="display:flex;gap:8px";
|
|
162
|
+
row.appendChild(button(banner.decline,false));
|
|
163
|
+
row.appendChild(button(banner.accept,true));
|
|
164
|
+
box.appendChild(text);box.appendChild(row);
|
|
165
|
+
document.body.appendChild(box);
|
|
166
|
+
}
|
|
167
|
+
window.scoutline=window.scoutline||{};
|
|
168
|
+
window.scoutline.consent=choose;
|
|
169
|
+
window.scoutline.showConsent=function(){if(banner)show();};
|
|
170
|
+
if(navigator.globalPrivacyControl===true){
|
|
171
|
+
if(read(ID))write(ID,"",0);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if(!banner||read(CHOICE))return;
|
|
175
|
+
if(document.body)show();
|
|
176
|
+
else addEventListener("DOMContentLoaded",show);
|
|
177
|
+
})(/*BANNER*/);`;
|
|
178
|
+
const DEFAULT_BANNER = {
|
|
179
|
+
message: "We use a cookie to recognise you when you come back and to understand how this site is used.",
|
|
180
|
+
accept: "Accept",
|
|
181
|
+
decline: "Decline",
|
|
182
|
+
};
|
|
183
|
+
function bannerConfig(banner) {
|
|
184
|
+
if (!banner)
|
|
185
|
+
return null;
|
|
186
|
+
const custom = banner === true ? {} : banner;
|
|
187
|
+
return {
|
|
188
|
+
...DEFAULT_BANNER,
|
|
189
|
+
...custom,
|
|
190
|
+
privacyUrl: custom.privacyUrl && /^(\/|https:\/\/)/.test(custom.privacyUrl)
|
|
191
|
+
? custom.privacyUrl
|
|
192
|
+
: undefined,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export function browserScript(consent) {
|
|
196
|
+
if (!consent)
|
|
197
|
+
return BROWSER_SCRIPT;
|
|
198
|
+
// Escaped so a site's own text can't close the script or add to it.
|
|
199
|
+
const banner = JSON.stringify(bannerConfig(consent.banner)).replace(/</g, "\\u003c");
|
|
200
|
+
return SCRIPT.replace("/*CONSENT*/", () => CONSENT.replace("/*BANNER*/", () => banner));
|
|
201
|
+
}
|
|
202
|
+
export const BROWSER_SCRIPT = SCRIPT.replace("/*CONSENT*/", "");
|
package/dist/browser.d.ts
CHANGED
|
@@ -14,6 +14,43 @@ export declare function isSameOrigin(headers: Headers): boolean;
|
|
|
14
14
|
* load also produces a server event, and Googlebot runs this script too.
|
|
15
15
|
*/
|
|
16
16
|
export declare function toBrowserEvent(raw: unknown, actor: Actor, now: number, sampleRate: number): {
|
|
17
|
+
eventName: string;
|
|
18
|
+
eventProperties: {
|
|
19
|
+
[k: string]: any;
|
|
20
|
+
} | undefined;
|
|
21
|
+
outboundUrl?: undefined;
|
|
22
|
+
eventId: `${string}-${string}-${string}-${string}-${string}`;
|
|
23
|
+
occurredAt: number;
|
|
24
|
+
family: string;
|
|
25
|
+
actor: Actor;
|
|
26
|
+
action: string;
|
|
27
|
+
url: string;
|
|
28
|
+
interface: string;
|
|
29
|
+
parentActionId: string | undefined;
|
|
30
|
+
status: number | undefined;
|
|
31
|
+
durationMs: number | undefined;
|
|
32
|
+
sampleRate: number;
|
|
33
|
+
classifierVersion: string;
|
|
34
|
+
} | {
|
|
35
|
+
outboundUrl: string;
|
|
36
|
+
eventName?: undefined;
|
|
37
|
+
eventProperties?: undefined;
|
|
38
|
+
eventId: `${string}-${string}-${string}-${string}-${string}`;
|
|
39
|
+
occurredAt: number;
|
|
40
|
+
family: string;
|
|
41
|
+
actor: Actor;
|
|
42
|
+
action: string;
|
|
43
|
+
url: string;
|
|
44
|
+
interface: string;
|
|
45
|
+
parentActionId: string | undefined;
|
|
46
|
+
status: number | undefined;
|
|
47
|
+
durationMs: number | undefined;
|
|
48
|
+
sampleRate: number;
|
|
49
|
+
classifierVersion: string;
|
|
50
|
+
} | {
|
|
51
|
+
eventName?: undefined;
|
|
52
|
+
eventProperties?: undefined;
|
|
53
|
+
outboundUrl?: undefined;
|
|
17
54
|
eventId: `${string}-${string}-${string}-${string}-${string}`;
|
|
18
55
|
occurredAt: number;
|
|
19
56
|
family: string;
|
|
@@ -29,5 +66,14 @@ export declare function toBrowserEvent(raw: unknown, actor: Actor, now: number,
|
|
|
29
66
|
} | null;
|
|
30
67
|
export declare function parseBrowserBody(text: string): {
|
|
31
68
|
automated: boolean;
|
|
69
|
+
device: {
|
|
70
|
+
touch: boolean | undefined;
|
|
71
|
+
coarsePointer: boolean | undefined;
|
|
72
|
+
mobile: boolean | undefined;
|
|
73
|
+
screen: string | undefined;
|
|
74
|
+
platform: string | undefined;
|
|
75
|
+
platformVersion: string | undefined;
|
|
76
|
+
model: string | undefined;
|
|
77
|
+
} | undefined;
|
|
32
78
|
events: unknown[];
|
|
33
79
|
} | null;
|
package/dist/browser.js
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
export const SCRIPT_PATH = "/_scoutline/s";
|
|
2
2
|
export const EVENTS_PATH = "/_scoutline/e";
|
|
3
3
|
export const BROWSER_CLASSIFIER_VERSION = "browser-0.1";
|
|
4
|
-
|
|
4
|
+
/** Twenty custom events at their limits, and under the 64 KiB a beacon can carry. */
|
|
5
|
+
const MAX_BODY_CHARS = 48 * 1024;
|
|
5
6
|
const MAX_EVENTS = 20;
|
|
6
7
|
const MAX_AGE_MS = 10 * 60 * 1000;
|
|
7
8
|
const MAX_PATH_CHARS = 2048;
|
|
8
9
|
const MAX_DURATION_MS = 60 * 60 * 1000;
|
|
9
|
-
const TYPES = new Set([
|
|
10
|
+
const TYPES = new Set([
|
|
11
|
+
"page_view",
|
|
12
|
+
"click",
|
|
13
|
+
"form_submit",
|
|
14
|
+
"error",
|
|
15
|
+
"custom",
|
|
16
|
+
"outbound",
|
|
17
|
+
]);
|
|
10
18
|
const ELEMENTS = new Set(["a", "button", "input", "div", "span"]);
|
|
11
19
|
const SERVER_ID = /^[0-9a-f-]{36}$/;
|
|
20
|
+
const SCREENS = new Set(["small", "medium", "large"]);
|
|
21
|
+
const MAX_MODEL_CHARS = 64;
|
|
22
|
+
const PLATFORM = /^[A-Za-z][A-Za-z0-9 ]{0,31}$/;
|
|
23
|
+
const MODEL = /^[\w .()+-]+$/;
|
|
24
|
+
const EVENT_NAME = /^[\w.:\- ]{1,64}$/;
|
|
25
|
+
const PROPERTY_KEY = /^[A-Za-z0-9][\w.-]{0,39}$/;
|
|
26
|
+
const MAX_PROPERTIES = 8;
|
|
27
|
+
const MAX_PROPERTY_CHARS = 128;
|
|
28
|
+
const MAX_OUTBOUND_CHARS = 512;
|
|
12
29
|
/**
|
|
13
30
|
* Anything in the page can be forged, so only a same-origin browser request is
|
|
14
31
|
* accepted. This keeps out other sites, not a determined client, which is why
|
|
@@ -47,6 +64,45 @@ function pathOf(value) {
|
|
|
47
64
|
return null;
|
|
48
65
|
}
|
|
49
66
|
}
|
|
67
|
+
/** Keeps the origin and path. A query can carry a token, and a link is enough. */
|
|
68
|
+
function outboundOf(value) {
|
|
69
|
+
if (typeof value !== "string" || value.length > MAX_OUTBOUND_CHARS)
|
|
70
|
+
return null;
|
|
71
|
+
try {
|
|
72
|
+
const url = new URL(value);
|
|
73
|
+
return /^https?:$/.test(url.protocol) ? `${url.origin}${url.pathname}` : null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function propertiesOf(value) {
|
|
80
|
+
if (typeof value !== "object" || value === null)
|
|
81
|
+
return undefined;
|
|
82
|
+
const entries = Object.entries(value)
|
|
83
|
+
.filter(([key, item]) => PROPERTY_KEY.test(key) &&
|
|
84
|
+
typeof item === "string" &&
|
|
85
|
+
item.length <= MAX_PROPERTY_CHARS)
|
|
86
|
+
.slice(0, MAX_PROPERTIES);
|
|
87
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
88
|
+
}
|
|
89
|
+
/** Only the two types that carry something more. A bad one is dropped whole. */
|
|
90
|
+
function detailsOf(event) {
|
|
91
|
+
if (event.type === "custom") {
|
|
92
|
+
if (typeof event.name !== "string" || !EVENT_NAME.test(event.name)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
eventName: event.name,
|
|
97
|
+
eventProperties: propertiesOf(event.properties),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (event.type === "outbound") {
|
|
101
|
+
const outboundUrl = outboundOf(event.url);
|
|
102
|
+
return outboundUrl === null ? null : { outboundUrl };
|
|
103
|
+
}
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
50
106
|
/**
|
|
51
107
|
* Rebuilds each event from an allowlist, so unexpected fields are dropped. The
|
|
52
108
|
* `browser:` action prefix keeps these out of request counts, because a page
|
|
@@ -61,6 +117,9 @@ export function toBrowserEvent(raw, actor, now, sampleRate) {
|
|
|
61
117
|
const url = pathOf(event.path);
|
|
62
118
|
if (url === null)
|
|
63
119
|
return null;
|
|
120
|
+
const details = detailsOf(event);
|
|
121
|
+
if (details === null)
|
|
122
|
+
return null;
|
|
64
123
|
const element = typeof event.element === "string" && ELEMENTS.has(event.element)
|
|
65
124
|
? `:${event.element}`
|
|
66
125
|
: "";
|
|
@@ -80,6 +139,32 @@ export function toBrowserEvent(raw, actor, now, sampleRate) {
|
|
|
80
139
|
durationMs: wholeNumber(event.durationMs, MAX_DURATION_MS),
|
|
81
140
|
sampleRate,
|
|
82
141
|
classifierVersion: BROWSER_CLASSIFIER_VERSION,
|
|
142
|
+
...details,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Forged values can only mislabel the page that sent them. */
|
|
146
|
+
function deviceOf(raw) {
|
|
147
|
+
if (typeof raw !== "object" || raw === null)
|
|
148
|
+
return undefined;
|
|
149
|
+
const { coarsePointer, mobile, model, platform, platformVersion, screen, touch } = raw;
|
|
150
|
+
const major = typeof platformVersion === "string"
|
|
151
|
+
? /^\d{1,4}/.exec(platformVersion)?.[0]
|
|
152
|
+
: undefined;
|
|
153
|
+
return {
|
|
154
|
+
touch: typeof touch === "boolean" ? touch : undefined,
|
|
155
|
+
coarsePointer: typeof coarsePointer === "boolean" ? coarsePointer : undefined,
|
|
156
|
+
mobile: typeof mobile === "boolean" ? mobile : undefined,
|
|
157
|
+
screen: typeof screen === "string" && SCREENS.has(screen) ? screen : undefined,
|
|
158
|
+
platform: typeof platform === "string" && PLATFORM.test(platform)
|
|
159
|
+
? platform
|
|
160
|
+
: undefined,
|
|
161
|
+
// The major version is enough, and the rest narrows down one machine.
|
|
162
|
+
platformVersion: major,
|
|
163
|
+
model: typeof model === "string" &&
|
|
164
|
+
model.length <= MAX_MODEL_CHARS &&
|
|
165
|
+
MODEL.test(model)
|
|
166
|
+
? model
|
|
167
|
+
: undefined,
|
|
83
168
|
};
|
|
84
169
|
}
|
|
85
170
|
export function parseBrowserBody(text) {
|
|
@@ -92,6 +177,7 @@ export function parseBrowserBody(text) {
|
|
|
92
177
|
}
|
|
93
178
|
return {
|
|
94
179
|
automated: body.automated === true,
|
|
180
|
+
device: deviceOf(body.device),
|
|
95
181
|
events: body.events,
|
|
96
182
|
};
|
|
97
183
|
}
|
package/dist/fetch.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { SCRIPT_PATH } from "./browser.js";
|
|
2
|
-
import {
|
|
2
|
+
import { usableIp } from "./client-ip.js";
|
|
3
|
+
import { createScoutline } from "./index.js";
|
|
4
|
+
import { isDocumentRequest, serverTiming } from "./request.js";
|
|
3
5
|
const SCRIPT_TAG = `<script src="${SCRIPT_PATH}" defer></script>`;
|
|
4
6
|
function forPage(response, eventId) {
|
|
5
7
|
if (!response.headers.get("content-type")?.includes("text/html")) {
|
|
@@ -18,6 +20,12 @@ function forPage(response, eventId) {
|
|
|
18
20
|
})
|
|
19
21
|
.transform(page);
|
|
20
22
|
}
|
|
23
|
+
/** Only a real Worker has `cf`, so this header can't be faked there. */
|
|
24
|
+
function workerIp(request) {
|
|
25
|
+
return request.cf
|
|
26
|
+
? usableIp(request.headers.get("cf-connecting-ip"))
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
21
29
|
function contentLength(response) {
|
|
22
30
|
const value = Number(response.headers.get("content-length"));
|
|
23
31
|
return response.headers.has("content-length") && Number.isFinite(value)
|
|
@@ -39,7 +47,8 @@ export function withScoutline(handler, options) {
|
|
|
39
47
|
const scoutline = createScoutline(options);
|
|
40
48
|
return async function scoutlineFetch(request, ...args) {
|
|
41
49
|
const context = args.find((arg) => typeof arg?.waitUntil === "function");
|
|
42
|
-
const
|
|
50
|
+
const clientIp = workerIp(request);
|
|
51
|
+
const own = scoutline.handle(request, clientIp);
|
|
43
52
|
if (own) {
|
|
44
53
|
const answer = await own;
|
|
45
54
|
context?.waitUntil(scoutline.flush());
|
|
@@ -48,6 +57,7 @@ export function withScoutline(handler, options) {
|
|
|
48
57
|
const started = Date.now();
|
|
49
58
|
const finish = (status, bytes) => {
|
|
50
59
|
const eventId = scoutline.observe(request, {
|
|
60
|
+
clientIp,
|
|
51
61
|
status,
|
|
52
62
|
bytes,
|
|
53
63
|
durationMs: Date.now() - started,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { type ConsentOptions } from "./browser-script.js";
|
|
1
2
|
export { classify } from "./classify.js";
|
|
3
|
+
export type { ConsentBanner, ConsentOptions } from "./browser-script.js";
|
|
2
4
|
/**
|
|
3
5
|
* What your app knows about a request that Scoutline can't see. Everything is
|
|
4
6
|
* optional, and only what you return here leaves your server.
|
|
@@ -23,10 +25,21 @@ export type ScoutlineOptions = {
|
|
|
23
25
|
/** Adds your own ids to a request. A failure here only leaves them out. */
|
|
24
26
|
context?: (request: Request) => RequestContext | null | undefined;
|
|
25
27
|
/**
|
|
26
|
-
* Sends the visitor's IP so the collector can find its country and network
|
|
27
|
-
* The collector never stores it. Default true.
|
|
28
|
+
* Sends the visitor's IP so the collector can find its country and network,
|
|
29
|
+
* and verify crawlers. The collector never stores it. Default true.
|
|
28
30
|
*/
|
|
29
31
|
sendClientIp?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The header your own proxy sets to the visitor's IP. Set this on self-hosted
|
|
34
|
+
* sites, or crawlers can't be verified, because any client can fake the rest.
|
|
35
|
+
* Vercel and Cloudflare are detected automatically.
|
|
36
|
+
*/
|
|
37
|
+
trustedIpHeader?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Lets visitors who agree be recognised across days with a first-party cookie.
|
|
40
|
+
* Off by default, and without it this package never sets a cookie.
|
|
41
|
+
*/
|
|
42
|
+
consent?: ConsentOptions;
|
|
30
43
|
};
|
|
31
44
|
export type Observation = {
|
|
32
45
|
/** Set by adapters that must announce the id before the response finishes. */
|
|
@@ -53,32 +66,6 @@ export type Scoutline = {
|
|
|
53
66
|
*/
|
|
54
67
|
handle(request: Request, clientIp?: string): Promise<Response> | null;
|
|
55
68
|
};
|
|
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
69
|
/**
|
|
83
70
|
* Failure here must never reach the customer's site. A missing key turns the
|
|
84
71
|
* client into a no-op, and a full queue drops the oldest events.
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { EVENTS_PATH, isSameOrigin, parseBrowserBody, SCRIPT_PATH, toBrowserEvent, } from "./browser.js";
|
|
2
|
-
import {
|
|
2
|
+
import { browserScript } from "./browser-script.js";
|
|
3
3
|
import { classify, CLASSIFIER_VERSION } from "./classify.js";
|
|
4
|
-
import { clientIpFromHeaders } from "./client-ip.js";
|
|
5
4
|
import { DEFAULT_ENDPOINT, env } from "./config.js";
|
|
5
|
+
import { hostOf, isPrefetch, limited, referrerHost, requestFacts, traceId, } from "./request.js";
|
|
6
6
|
export { classify } from "./classify.js";
|
|
7
7
|
const MAX_QUEUED = 500;
|
|
8
8
|
const MAX_BATCH = 100;
|
|
@@ -11,9 +11,7 @@ const FLUSH_INTERVAL_MS = 5000;
|
|
|
11
11
|
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
|
|
12
12
|
const REJECTED_BACKOFF_MS = 60 * 1000;
|
|
13
13
|
const RATE_LIMITED_BACKOFF_MS = 60 * 1000;
|
|
14
|
-
const MAX_FIELD_CHARS = 128;
|
|
15
14
|
const MAX_URL_CHARS = 2048;
|
|
16
|
-
const MAX_USER_AGENT_CHARS = 512;
|
|
17
15
|
/** Caps browser events per server instance, so a page can't flood the queue. */
|
|
18
16
|
const MAX_BROWSER_EVENTS_PER_MINUTE = 2000;
|
|
19
17
|
const SCRIPT_CACHE_SECONDS = 3600;
|
|
@@ -24,17 +22,8 @@ const FRAMEWORK_PATH = /^\/(?:_next|_vercel|\.well-known\/vercel)\//;
|
|
|
24
22
|
* to serve, so they stay for anyone identified. PDFs always stay.
|
|
25
23
|
*/
|
|
26
24
|
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
|
-
|
|
28
|
-
|
|
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, {
|
|
25
|
+
function scriptResponse(consent) {
|
|
26
|
+
return new Response(browserScript(consent), {
|
|
38
27
|
headers: {
|
|
39
28
|
"Content-Type": "text/javascript; charset=utf-8",
|
|
40
29
|
"Cache-Control": `public, max-age=${SCRIPT_CACHE_SECONDS}`,
|
|
@@ -48,9 +37,6 @@ function ownRoute(request) {
|
|
|
48
37
|
const { pathname } = new URL(request.url);
|
|
49
38
|
return pathname === SCRIPT_PATH || pathname === EVENTS_PATH ? pathname : null;
|
|
50
39
|
}
|
|
51
|
-
function limited(value, max = MAX_FIELD_CHARS) {
|
|
52
|
-
return value && value.length <= max ? value : undefined;
|
|
53
|
-
}
|
|
54
40
|
function interfaceFor(pathname, headers) {
|
|
55
41
|
if (pathname === "/mcp" || pathname.startsWith("/mcp/"))
|
|
56
42
|
return "tool";
|
|
@@ -58,43 +44,6 @@ function interfaceFor(pathname, headers) {
|
|
|
58
44
|
return "api";
|
|
59
45
|
return headers.get("accept")?.includes("application/json") ? "api" : "web";
|
|
60
46
|
}
|
|
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
47
|
function contextOf(request, read) {
|
|
99
48
|
try {
|
|
100
49
|
return read?.(request) ?? {};
|
|
@@ -126,10 +75,8 @@ function toEvent(request, observation, sampleUnknown, options) {
|
|
|
126
75
|
// The pathname only: queries, cookies, headers and bodies never leave here.
|
|
127
76
|
url: pathname.slice(0, MAX_URL_CHARS),
|
|
128
77
|
host: hostOf(request),
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
clientIp: observation.clientIp,
|
|
132
|
-
}),
|
|
78
|
+
referrer: referrerHost(headers),
|
|
79
|
+
...requestFacts(headers, { ...options, clientIp: observation.clientIp }),
|
|
133
80
|
interface: interfaceFor(pathname, headers),
|
|
134
81
|
requestId: limited(headers.get("x-vercel-id") ??
|
|
135
82
|
headers.get("cf-ray") ??
|
|
@@ -165,7 +112,7 @@ export function createScoutline(options = {}) {
|
|
|
165
112
|
handle(request) {
|
|
166
113
|
const route = ownRoute(request);
|
|
167
114
|
if (route === SCRIPT_PATH)
|
|
168
|
-
return Promise.resolve(scriptResponse());
|
|
115
|
+
return Promise.resolve(scriptResponse(options.consent));
|
|
169
116
|
return route ? Promise.resolve(emptyResponse(204)) : null;
|
|
170
117
|
},
|
|
171
118
|
};
|
|
@@ -299,14 +246,18 @@ export function createScoutline(options = {}) {
|
|
|
299
246
|
if (sampled && Math.random() >= sampleUnknown)
|
|
300
247
|
return emptyResponse(204);
|
|
301
248
|
const now = Date.now();
|
|
302
|
-
const facts = requestFacts(request.headers, {
|
|
303
|
-
sendClientIp: options.sendClientIp,
|
|
304
|
-
clientIp,
|
|
305
|
-
});
|
|
249
|
+
const facts = requestFacts(request.headers, { ...options, clientIp });
|
|
306
250
|
for (const raw of body.events) {
|
|
307
251
|
const event = toBrowserEvent(raw, actor, now, sampled ? sampleUnknown : 1);
|
|
308
|
-
if (event)
|
|
309
|
-
|
|
252
|
+
if (!event)
|
|
253
|
+
continue;
|
|
254
|
+
const device = event.action === "browser:page_view" ? body.device : null;
|
|
255
|
+
enqueue({
|
|
256
|
+
...event,
|
|
257
|
+
...facts,
|
|
258
|
+
host: hostOf(request),
|
|
259
|
+
device: device ?? undefined,
|
|
260
|
+
});
|
|
310
261
|
}
|
|
311
262
|
return emptyResponse(204);
|
|
312
263
|
}
|
|
@@ -317,7 +268,7 @@ export function createScoutline(options = {}) {
|
|
|
317
268
|
function handle(request, clientIp) {
|
|
318
269
|
const route = ownRoute(request);
|
|
319
270
|
if (route === SCRIPT_PATH)
|
|
320
|
-
return Promise.resolve(scriptResponse());
|
|
271
|
+
return Promise.resolve(scriptResponse(options.consent));
|
|
321
272
|
return route ? acceptBrowserEvents(request, clientIp) : null;
|
|
322
273
|
}
|
|
323
274
|
return { observe, flush, handle };
|
package/dist/next.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createScoutline
|
|
1
|
+
import { createScoutline } from "./index.js";
|
|
2
|
+
import { isDocumentRequest, serverTiming } from "./request.js";
|
|
2
3
|
/**
|
|
3
4
|
* Wraps a Next.js `proxy.ts`. It adds no latency, because sending happens in
|
|
4
5
|
* `waitUntil` after the response is on its way.
|
package/dist/node.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EVENTS_PATH, SCRIPT_PATH } from "./browser.js";
|
|
2
2
|
import { usableIp } from "./client-ip.js";
|
|
3
|
-
import { createScoutline,
|
|
3
|
+
import { createScoutline, } from "./index.js";
|
|
4
|
+
import { isDocumentRequest, serverTiming } from "./request.js";
|
|
4
5
|
const MAX_BODY_BYTES = 32 * 1024;
|
|
5
6
|
function toRequest(request, body) {
|
|
6
7
|
const headers = new Headers();
|
package/dist/outcomes.d.ts
CHANGED
package/dist/outcomes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { classify, CLASSIFIER_VERSION } from "./classify.js";
|
|
2
2
|
import { DEFAULT_ENDPOINT, env } from "./config.js";
|
|
3
|
-
import { hostFromHeaders, requestFacts, traceId } from "./
|
|
3
|
+
import { hostFromHeaders, requestFacts, traceId } from "./request.js";
|
|
4
4
|
const SEND_TIMEOUT_MS = 2000;
|
|
5
5
|
const MAX_ID_CHARS = 128;
|
|
6
6
|
function toHeaders(headers) {
|
|
@@ -87,7 +87,8 @@ export function createOutcomes(options = {}) {
|
|
|
87
87
|
family,
|
|
88
88
|
outcomeKey,
|
|
89
89
|
actor: classify(userAgent),
|
|
90
|
-
|
|
90
|
+
// A site with consent on has this cookie, and it must match their pages.
|
|
91
|
+
...requestFacts(headers, { ...options, consent: true }),
|
|
91
92
|
host: hostFromHeaders(headers)?.toLowerCase() ?? undefined,
|
|
92
93
|
interface: "app",
|
|
93
94
|
traceId: traceId(headers),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export declare function limited(value: string | null, max?: number): string | undefined;
|
|
2
|
+
export declare function isPrefetch(headers: Headers): boolean;
|
|
3
|
+
/** Only full page loads carry a server id for the browser script to read. */
|
|
4
|
+
export declare function isDocumentRequest(request: Request): boolean;
|
|
5
|
+
export declare function serverTiming(eventId: string): string;
|
|
6
|
+
/** A proxy may list several hosts, nearest client first. */
|
|
7
|
+
export declare function hostFromHeaders(headers: Headers): string | null;
|
|
8
|
+
/** The site's hostname. */
|
|
9
|
+
export declare function hostOf(request: Request): string | undefined;
|
|
10
|
+
/** The site a visitor came from. Its path and query can carry anything. */
|
|
11
|
+
export declare function referrerHost(headers: Headers): string | undefined;
|
|
12
|
+
/** The W3C trace id from `traceparent`, a claim until something validates it. */
|
|
13
|
+
export declare function traceId(headers: Headers): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* What the collector needs to classify a request and name its device. The
|
|
16
|
+
* three Client Hints are the browser's own low-detail summary. For the others,
|
|
17
|
+
* only whether a header exists is sent. Rules live on the collector, so they can
|
|
18
|
+
* change without a new release of this package.
|
|
19
|
+
*/
|
|
20
|
+
export declare function requestFacts(headers: Headers, options: {
|
|
21
|
+
sendClientIp?: boolean;
|
|
22
|
+
clientIp?: string;
|
|
23
|
+
trustedIpHeader?: string;
|
|
24
|
+
consent?: unknown;
|
|
25
|
+
}): {
|
|
26
|
+
clientHints: {
|
|
27
|
+
brands: string | undefined;
|
|
28
|
+
mobile: string | undefined;
|
|
29
|
+
platform: string | undefined;
|
|
30
|
+
};
|
|
31
|
+
requestShape: {
|
|
32
|
+
clientHints: boolean;
|
|
33
|
+
fetchMetadata: boolean;
|
|
34
|
+
acceptLanguage: boolean;
|
|
35
|
+
};
|
|
36
|
+
clientIp: string;
|
|
37
|
+
clientIpSource: string;
|
|
38
|
+
userAgent: string | undefined;
|
|
39
|
+
visitorId: string | undefined;
|
|
40
|
+
} | {
|
|
41
|
+
clientHints: {
|
|
42
|
+
brands: string | undefined;
|
|
43
|
+
mobile: string | undefined;
|
|
44
|
+
platform: string | undefined;
|
|
45
|
+
};
|
|
46
|
+
requestShape: {
|
|
47
|
+
clientHints: boolean;
|
|
48
|
+
fetchMetadata: boolean;
|
|
49
|
+
acceptLanguage: boolean;
|
|
50
|
+
};
|
|
51
|
+
clientIp?: undefined;
|
|
52
|
+
clientIpSource?: undefined;
|
|
53
|
+
userAgent: string | undefined;
|
|
54
|
+
visitorId: string | undefined;
|
|
55
|
+
};
|
package/dist/request.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { clientIpFromHeaders, usableIp } from "./client-ip.js";
|
|
2
|
+
import { env } from "./config.js";
|
|
3
|
+
const MAX_FIELD_CHARS = 128;
|
|
4
|
+
const MAX_USER_AGENT_CHARS = 512;
|
|
5
|
+
const MAX_HINT_CHARS = 256;
|
|
6
|
+
const VISITOR_COOKIE = /(?:^|;\s*)_scoutline_id=([0-9a-f-]{36})(?:;|$)/i;
|
|
7
|
+
const CONSENT_COOKIE = /(?:^|;\s*)_scoutline_consent=yes(?:;|$)/;
|
|
8
|
+
export function limited(value, max = MAX_FIELD_CHARS) {
|
|
9
|
+
return value && value.length <= max ? value : undefined;
|
|
10
|
+
}
|
|
11
|
+
export function isPrefetch(headers) {
|
|
12
|
+
return (headers.has("next-router-prefetch") ||
|
|
13
|
+
headers.get("purpose") === "prefetch" ||
|
|
14
|
+
headers.get("sec-purpose")?.includes("prefetch") === true);
|
|
15
|
+
}
|
|
16
|
+
/** Only full page loads carry a server id for the browser script to read. */
|
|
17
|
+
export function isDocumentRequest(request) {
|
|
18
|
+
return (request.method === "GET" &&
|
|
19
|
+
request.headers.get("accept")?.includes("text/html") === true &&
|
|
20
|
+
!isPrefetch(request.headers));
|
|
21
|
+
}
|
|
22
|
+
export function serverTiming(eventId) {
|
|
23
|
+
return `scoutline;desc="${eventId}"`;
|
|
24
|
+
}
|
|
25
|
+
/** A proxy may list several hosts, nearest client first. */
|
|
26
|
+
export function hostFromHeaders(headers) {
|
|
27
|
+
const forwarded = headers.get("x-forwarded-host")?.split(",")[0];
|
|
28
|
+
return forwarded?.trim() || headers.get("host") || null;
|
|
29
|
+
}
|
|
30
|
+
/** The site's hostname. */
|
|
31
|
+
export function hostOf(request) {
|
|
32
|
+
const host = hostFromHeaders(request.headers) ?? new URL(request.url).host;
|
|
33
|
+
return limited(host.toLowerCase(), 255);
|
|
34
|
+
}
|
|
35
|
+
/** The site a visitor came from. Its path and query can carry anything. */
|
|
36
|
+
export function referrerHost(headers) {
|
|
37
|
+
try {
|
|
38
|
+
const referer = headers.get("referer");
|
|
39
|
+
return referer ? limited(new URL(referer).hostname.toLowerCase(), 255) : undefined;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** The W3C trace id from `traceparent`, a claim until something validates it. */
|
|
46
|
+
export function traceId(headers) {
|
|
47
|
+
return limited(headers.get("traceparent")?.split("-")[1] ?? null);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Says how the address was learned, because only the collector decides what that
|
|
51
|
+
* proves. A forged header must never earn a crawler `verified`, so anything that
|
|
52
|
+
* isn't set by the connection, the platform or the site's own proxy is `header`.
|
|
53
|
+
*/
|
|
54
|
+
function clientAddress(headers, options) {
|
|
55
|
+
if (options.clientIp) {
|
|
56
|
+
return { clientIp: options.clientIp, clientIpSource: "connection" };
|
|
57
|
+
}
|
|
58
|
+
const configured = options.trustedIpHeader
|
|
59
|
+
? usableIp(headers.get(options.trustedIpHeader)?.split(",")[0])
|
|
60
|
+
: undefined;
|
|
61
|
+
if (configured)
|
|
62
|
+
return { clientIp: configured, clientIpSource: "configured" };
|
|
63
|
+
const platform = env("VERCEL") ? usableIp(headers.get("x-real-ip")) : undefined;
|
|
64
|
+
if (platform)
|
|
65
|
+
return { clientIp: platform, clientIpSource: "platform" };
|
|
66
|
+
const claimed = clientIpFromHeaders(headers);
|
|
67
|
+
return claimed ? { clientIp: claimed, clientIpSource: "header" } : {};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Only for sites that turned consent on, and only Scoutline's own id, and only
|
|
71
|
+
* while the visitor's choice says yes. No other cookie is ever read.
|
|
72
|
+
*/
|
|
73
|
+
function consentedVisitor(headers, consent) {
|
|
74
|
+
if (!consent)
|
|
75
|
+
return undefined;
|
|
76
|
+
const cookies = headers.get("cookie") ?? "";
|
|
77
|
+
if (!CONSENT_COOKIE.test(cookies))
|
|
78
|
+
return undefined;
|
|
79
|
+
return VISITOR_COOKIE.exec(cookies)?.[1];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* What the collector needs to classify a request and name its device. The
|
|
83
|
+
* three Client Hints are the browser's own low-detail summary. For the others,
|
|
84
|
+
* only whether a header exists is sent. Rules live on the collector, so they can
|
|
85
|
+
* change without a new release of this package.
|
|
86
|
+
*/
|
|
87
|
+
export function requestFacts(headers, options) {
|
|
88
|
+
return {
|
|
89
|
+
userAgent: limited(headers.get("user-agent"), MAX_USER_AGENT_CHARS),
|
|
90
|
+
visitorId: consentedVisitor(headers, options.consent),
|
|
91
|
+
...(options.sendClientIp === false ? {} : clientAddress(headers, options)),
|
|
92
|
+
clientHints: {
|
|
93
|
+
brands: limited(headers.get("sec-ch-ua"), MAX_HINT_CHARS),
|
|
94
|
+
mobile: limited(headers.get("sec-ch-ua-mobile"), MAX_HINT_CHARS),
|
|
95
|
+
platform: limited(headers.get("sec-ch-ua-platform"), MAX_HINT_CHARS),
|
|
96
|
+
},
|
|
97
|
+
requestShape: {
|
|
98
|
+
clientHints: headers.has("sec-ch-ua"),
|
|
99
|
+
fetchMetadata: headers.has("sec-fetch-mode") || headers.has("sec-fetch-site"),
|
|
100
|
+
acceptLanguage: headers.has("accept-language"),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
package/package.json
CHANGED