@scoutline-ai/middleware 0.2.0 → 0.3.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 +2 -0
- package/dist/browser-script.d.ts +4 -3
- package/dist/browser-script.js +15 -3
- package/dist/browser.d.ts +9 -0
- package/dist/browser.js +30 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.js +19 -5
- 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
|
@@ -146,6 +146,8 @@ From the server: the method, pathname, user agent, whether a few standard header
|
|
|
146
146
|
|
|
147
147
|
Query strings, cookies, auth headers, bodies, page text and field values are never sent.
|
|
148
148
|
|
|
149
|
+
**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.
|
|
150
|
+
|
|
149
151
|
**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
152
|
|
|
151
153
|
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`.
|
package/dist/browser-script.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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`.
|
|
6
7
|
*/
|
|
7
|
-
export declare const BROWSER_SCRIPT = "(function(){\n var ENDPOINT=\"/_scoutline/e\",MAX=20,queue=[],path=location.pathname;\n var nav=performance.getEntriesByType?performance.getEntriesByType(\"navigation\")[0]:null;\n var automated=navigator.webdriver===true;\n function serverId(){\n var timing=nav&&nav.serverTiming;\n for(var i=0;timing&&i<timing.length;i++){\n if(timing[i].name===\"scoutline\")return timing[i].description;\n }\n }\n function send(){\n if(!queue.length)return;\n var now=Date.now(),events=[];\n for(var i=0;i<queue.length;i++){\n var e=queue[i];e.ageMs=now-e.at;delete e.at;events.push(e);\n }\n queue=[];\n var body=JSON.stringify({automated:automated,events:events});\n if(!navigator.sendBeacon||!navigator.sendBeacon(ENDPOINT,body)){\n fetch(ENDPOINT,{method:\"POST\",body:body,keepalive:true})[\"catch\"](function(){});\n }\n }\n function add(type,extra){\n var e=extra||{};e.type=type;e.path=location.pathname;e.at=Date.now();\n queue.push(e);\n if(queue.length>=MAX)send();\n }\n function firstView(){\n add(\"page_view\",{\n parent:serverId(),\n status:nav&&nav.responseStatus?nav.responseStatus:undefined,\n durationMs:nav&&nav.duration?Math.round(nav.duration):undefined\n });\n }\n function changed(){\n if(location.pathname===path)return;\n path=location.pathname;add(\"page_view\");\n }\n [\"pushState\",\"replaceState\"].forEach(function(name){\n var original=history[name];\n history[name]=function(){var r=original.apply(this,arguments);changed();return r;};\n });\n addEventListener(\"popstate\",changed);\n document.addEventListener(\"click\",function(event){\n var t=event.target&&event.target.closest&&event.target.closest(\"a,button,[role=button],input[type=submit]\");\n if(t)add(\"click\",{element:t.tagName.toLowerCase()});\n },true);\n document.addEventListener(\"submit\",function(){add(\"form_submit\");},true);\n addEventListener(\"error\",function(event){if(event.message)add(\"error\");});\n addEventListener(\"unhandledrejection\",function(){add(\"error\");});\n addEventListener(\"pagehide\",send);\n document.addEventListener(\"visibilitychange\",function(){\n if(document.visibilityState===\"hidden\")send();\n });\n if(document.readyState===\"complete\")setTimeout(firstView);\n else addEventListener(\"load\",function(){setTimeout(firstView);});\n})();";
|
|
8
|
+
export declare const BROWSER_SCRIPT = "(function(){\n var ENDPOINT=\"/_scoutline/e\",MAX=20,queue=[],path=location.pathname;\n var nav=performance.getEntriesByType?performance.getEntriesByType(\"navigation\")[0]:null;\n var automated=navigator.webdriver===true;\n var device={touch:navigator.maxTouchPoints>0};\n var shortSide=window.screen?Math.min(screen.width,screen.height):0;\n if(shortSide)device.screen=shortSide<600?\"small\":shortSide<900?\"medium\":\"large\";\n if(window.matchMedia)device.coarsePointer=matchMedia(\"(pointer:coarse)\").matches;\n var agent=navigator.userAgentData;\n if(agent&&agent.getHighEntropyValues){\n agent.getHighEntropyValues([\"platformVersion\",\"model\"]).then(function(values){\n device.platform=agent.platform;device.mobile=agent.mobile;\n device.platformVersion=values.platformVersion;device.model=values.model;\n })[\"catch\"](function(){});\n }\n function serverId(){\n var timing=nav&&nav.serverTiming;\n for(var i=0;timing&&i<timing.length;i++){\n if(timing[i].name===\"scoutline\")return timing[i].description;\n }\n }\n function send(){\n if(!queue.length)return;\n var now=Date.now(),events=[];\n for(var i=0;i<queue.length;i++){\n var e=queue[i];e.ageMs=now-e.at;delete e.at;events.push(e);\n }\n queue=[];\n var body=JSON.stringify({automated:automated,device:device,events:events});\n if(!navigator.sendBeacon||!navigator.sendBeacon(ENDPOINT,body)){\n fetch(ENDPOINT,{method:\"POST\",body:body,keepalive:true})[\"catch\"](function(){});\n }\n }\n function add(type,extra){\n var e=extra||{};e.type=type;e.path=location.pathname;e.at=Date.now();\n queue.push(e);\n if(queue.length>=MAX)send();\n }\n function firstView(){\n add(\"page_view\",{\n parent:serverId(),\n status:nav&&nav.responseStatus?nav.responseStatus:undefined,\n durationMs:nav&&nav.duration?Math.round(nav.duration):undefined\n });\n }\n function changed(){\n if(location.pathname===path)return;\n path=location.pathname;add(\"page_view\");\n }\n [\"pushState\",\"replaceState\"].forEach(function(name){\n var original=history[name];\n history[name]=function(){var r=original.apply(this,arguments);changed();return r;};\n });\n addEventListener(\"popstate\",changed);\n document.addEventListener(\"click\",function(event){\n var t=event.target&&event.target.closest&&event.target.closest(\"a,button,[role=button],input[type=submit]\");\n if(t)add(\"click\",{element:t.tagName.toLowerCase()});\n },true);\n document.addEventListener(\"submit\",function(){add(\"form_submit\");},true);\n addEventListener(\"error\",function(event){if(event.message)add(\"error\");});\n addEventListener(\"unhandledrejection\",function(){add(\"error\");});\n addEventListener(\"pagehide\",send);\n document.addEventListener(\"visibilitychange\",function(){\n if(document.visibilityState===\"hidden\")send();\n });\n if(document.readyState===\"complete\")setTimeout(firstView);\n else addEventListener(\"load\",function(){setTimeout(firstView);});\n})();";
|
package/dist/browser-script.js
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
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`.
|
|
6
7
|
*/
|
|
7
8
|
export const BROWSER_SCRIPT = `(function(){
|
|
8
9
|
var ENDPOINT="/_scoutline/e",MAX=20,queue=[],path=location.pathname;
|
|
9
10
|
var nav=performance.getEntriesByType?performance.getEntriesByType("navigation")[0]:null;
|
|
10
11
|
var automated=navigator.webdriver===true;
|
|
12
|
+
var device={touch:navigator.maxTouchPoints>0};
|
|
13
|
+
var shortSide=window.screen?Math.min(screen.width,screen.height):0;
|
|
14
|
+
if(shortSide)device.screen=shortSide<600?"small":shortSide<900?"medium":"large";
|
|
15
|
+
if(window.matchMedia)device.coarsePointer=matchMedia("(pointer:coarse)").matches;
|
|
16
|
+
var agent=navigator.userAgentData;
|
|
17
|
+
if(agent&&agent.getHighEntropyValues){
|
|
18
|
+
agent.getHighEntropyValues(["platformVersion","model"]).then(function(values){
|
|
19
|
+
device.platform=agent.platform;device.mobile=agent.mobile;
|
|
20
|
+
device.platformVersion=values.platformVersion;device.model=values.model;
|
|
21
|
+
})["catch"](function(){});
|
|
22
|
+
}
|
|
11
23
|
function serverId(){
|
|
12
24
|
var timing=nav&&nav.serverTiming;
|
|
13
25
|
for(var i=0;timing&&i<timing.length;i++){
|
|
@@ -21,7 +33,7 @@ export const BROWSER_SCRIPT = `(function(){
|
|
|
21
33
|
var e=queue[i];e.ageMs=now-e.at;delete e.at;events.push(e);
|
|
22
34
|
}
|
|
23
35
|
queue=[];
|
|
24
|
-
var body=JSON.stringify({automated:automated,events:events});
|
|
36
|
+
var body=JSON.stringify({automated:automated,device:device,events:events});
|
|
25
37
|
if(!navigator.sendBeacon||!navigator.sendBeacon(ENDPOINT,body)){
|
|
26
38
|
fetch(ENDPOINT,{method:"POST",body:body,keepalive:true})["catch"](function(){});
|
|
27
39
|
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -29,5 +29,14 @@ export declare function toBrowserEvent(raw: unknown, actor: Actor, now: number,
|
|
|
29
29
|
} | null;
|
|
30
30
|
export declare function parseBrowserBody(text: string): {
|
|
31
31
|
automated: boolean;
|
|
32
|
+
device: {
|
|
33
|
+
touch: boolean | undefined;
|
|
34
|
+
coarsePointer: boolean | undefined;
|
|
35
|
+
mobile: boolean | undefined;
|
|
36
|
+
screen: string | undefined;
|
|
37
|
+
platform: string | undefined;
|
|
38
|
+
platformVersion: string | undefined;
|
|
39
|
+
model: string | undefined;
|
|
40
|
+
} | undefined;
|
|
32
41
|
events: unknown[];
|
|
33
42
|
} | null;
|
package/dist/browser.js
CHANGED
|
@@ -9,6 +9,10 @@ const MAX_DURATION_MS = 60 * 60 * 1000;
|
|
|
9
9
|
const TYPES = new Set(["page_view", "click", "form_submit", "error"]);
|
|
10
10
|
const ELEMENTS = new Set(["a", "button", "input", "div", "span"]);
|
|
11
11
|
const SERVER_ID = /^[0-9a-f-]{36}$/;
|
|
12
|
+
const SCREENS = new Set(["small", "medium", "large"]);
|
|
13
|
+
const MAX_MODEL_CHARS = 64;
|
|
14
|
+
const PLATFORM = /^[A-Za-z][A-Za-z0-9 ]{0,31}$/;
|
|
15
|
+
const MODEL = /^[\w .()+-]+$/;
|
|
12
16
|
/**
|
|
13
17
|
* Anything in the page can be forged, so only a same-origin browser request is
|
|
14
18
|
* accepted. This keeps out other sites, not a determined client, which is why
|
|
@@ -82,6 +86,31 @@ export function toBrowserEvent(raw, actor, now, sampleRate) {
|
|
|
82
86
|
classifierVersion: BROWSER_CLASSIFIER_VERSION,
|
|
83
87
|
};
|
|
84
88
|
}
|
|
89
|
+
/** Forged values can only mislabel the page that sent them. */
|
|
90
|
+
function deviceOf(raw) {
|
|
91
|
+
if (typeof raw !== "object" || raw === null)
|
|
92
|
+
return undefined;
|
|
93
|
+
const { coarsePointer, mobile, model, platform, platformVersion, screen, touch } = raw;
|
|
94
|
+
const major = typeof platformVersion === "string"
|
|
95
|
+
? /^\d{1,4}/.exec(platformVersion)?.[0]
|
|
96
|
+
: undefined;
|
|
97
|
+
return {
|
|
98
|
+
touch: typeof touch === "boolean" ? touch : undefined,
|
|
99
|
+
coarsePointer: typeof coarsePointer === "boolean" ? coarsePointer : undefined,
|
|
100
|
+
mobile: typeof mobile === "boolean" ? mobile : undefined,
|
|
101
|
+
screen: typeof screen === "string" && SCREENS.has(screen) ? screen : undefined,
|
|
102
|
+
platform: typeof platform === "string" && PLATFORM.test(platform)
|
|
103
|
+
? platform
|
|
104
|
+
: undefined,
|
|
105
|
+
// The major version is enough, and the rest narrows down one machine.
|
|
106
|
+
platformVersion: major,
|
|
107
|
+
model: typeof model === "string" &&
|
|
108
|
+
model.length <= MAX_MODEL_CHARS &&
|
|
109
|
+
MODEL.test(model)
|
|
110
|
+
? model
|
|
111
|
+
: undefined,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
85
114
|
export function parseBrowserBody(text) {
|
|
86
115
|
if (text.length > MAX_BODY_CHARS)
|
|
87
116
|
return null;
|
|
@@ -92,6 +121,7 @@ export function parseBrowserBody(text) {
|
|
|
92
121
|
}
|
|
93
122
|
return {
|
|
94
123
|
automated: body.automated === true,
|
|
124
|
+
device: deviceOf(body.device),
|
|
95
125
|
events: body.events,
|
|
96
126
|
};
|
|
97
127
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -61,9 +61,10 @@ export declare function hostFromHeaders(headers: Headers): string | null;
|
|
|
61
61
|
/** The site's hostname. */
|
|
62
62
|
export declare function hostOf(request: Request): string | undefined;
|
|
63
63
|
/**
|
|
64
|
-
* What the collector needs to classify a request
|
|
65
|
-
*
|
|
66
|
-
*
|
|
64
|
+
* What the collector needs to classify a request and name its device. The
|
|
65
|
+
* three Client Hints are the browser's own low-detail summary. For the others,
|
|
66
|
+
* only whether a header exists is sent. Rules live on the collector, so they can
|
|
67
|
+
* change without a new release of this package.
|
|
67
68
|
*/
|
|
68
69
|
export declare function requestFacts(headers: Headers, options: {
|
|
69
70
|
sendClientIp?: boolean;
|
|
@@ -71,6 +72,11 @@ export declare function requestFacts(headers: Headers, options: {
|
|
|
71
72
|
}): {
|
|
72
73
|
userAgent: string | undefined;
|
|
73
74
|
clientIp: string | undefined;
|
|
75
|
+
clientHints: {
|
|
76
|
+
brands: string | undefined;
|
|
77
|
+
mobile: string | undefined;
|
|
78
|
+
platform: string | undefined;
|
|
79
|
+
};
|
|
74
80
|
requestShape: {
|
|
75
81
|
clientHints: boolean;
|
|
76
82
|
fetchMetadata: boolean;
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ const RATE_LIMITED_BACKOFF_MS = 60 * 1000;
|
|
|
14
14
|
const MAX_FIELD_CHARS = 128;
|
|
15
15
|
const MAX_URL_CHARS = 2048;
|
|
16
16
|
const MAX_USER_AGENT_CHARS = 512;
|
|
17
|
+
const MAX_HINT_CHARS = 256;
|
|
17
18
|
/** Caps browser events per server instance, so a page can't flood the queue. */
|
|
18
19
|
const MAX_BROWSER_EVENTS_PER_MINUTE = 2000;
|
|
19
20
|
const SCRIPT_CACHE_SECONDS = 3600;
|
|
@@ -69,9 +70,10 @@ export function hostOf(request) {
|
|
|
69
70
|
return limited(host.toLowerCase(), 255);
|
|
70
71
|
}
|
|
71
72
|
/**
|
|
72
|
-
* What the collector needs to classify a request
|
|
73
|
-
*
|
|
74
|
-
*
|
|
73
|
+
* What the collector needs to classify a request and name its device. The
|
|
74
|
+
* three Client Hints are the browser's own low-detail summary. For the others,
|
|
75
|
+
* only whether a header exists is sent. Rules live on the collector, so they can
|
|
76
|
+
* change without a new release of this package.
|
|
75
77
|
*/
|
|
76
78
|
export function requestFacts(headers, options) {
|
|
77
79
|
return {
|
|
@@ -79,6 +81,11 @@ export function requestFacts(headers, options) {
|
|
|
79
81
|
clientIp: options.sendClientIp === false
|
|
80
82
|
? undefined
|
|
81
83
|
: (options.clientIp ?? clientIpFromHeaders(headers)),
|
|
84
|
+
clientHints: {
|
|
85
|
+
brands: limited(headers.get("sec-ch-ua"), MAX_HINT_CHARS),
|
|
86
|
+
mobile: limited(headers.get("sec-ch-ua-mobile"), MAX_HINT_CHARS),
|
|
87
|
+
platform: limited(headers.get("sec-ch-ua-platform"), MAX_HINT_CHARS),
|
|
88
|
+
},
|
|
82
89
|
requestShape: {
|
|
83
90
|
clientHints: headers.has("sec-ch-ua"),
|
|
84
91
|
fetchMetadata: headers.has("sec-fetch-mode") || headers.has("sec-fetch-site"),
|
|
@@ -305,8 +312,15 @@ export function createScoutline(options = {}) {
|
|
|
305
312
|
});
|
|
306
313
|
for (const raw of body.events) {
|
|
307
314
|
const event = toBrowserEvent(raw, actor, now, sampled ? sampleUnknown : 1);
|
|
308
|
-
if (event)
|
|
309
|
-
|
|
315
|
+
if (!event)
|
|
316
|
+
continue;
|
|
317
|
+
const device = event.action === "browser:page_view" ? body.device : null;
|
|
318
|
+
enqueue({
|
|
319
|
+
...event,
|
|
320
|
+
...facts,
|
|
321
|
+
host: hostOf(request),
|
|
322
|
+
device: device ?? undefined,
|
|
323
|
+
});
|
|
310
324
|
}
|
|
311
325
|
return emptyResponse(204);
|
|
312
326
|
}
|
package/package.json
CHANGED