blogwright-analytics 0.3.3
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 +162 -0
- package/dist/adapters/duckdb-ingest.d.ts +76 -0
- package/dist/adapters/duckdb-ingest.js +173 -0
- package/dist/adapters/duckdb-query.d.ts +56 -0
- package/dist/adapters/duckdb-query.js +80 -0
- package/dist/adapters/duckdb-session.d.ts +168 -0
- package/dist/adapters/duckdb-session.js +330 -0
- package/dist/app/_app/immutable/assets/0.BTQrrh5B.css +1 -0
- package/dist/app/_app/immutable/assets/2.CZSK3rT8.css +1 -0
- package/dist/app/_app/immutable/assets/BrushContext.D7c8UPey.css +1 -0
- package/dist/app/_app/immutable/assets/ChartAnnotations.CPxIG7Mw.css +1 -0
- package/dist/app/_app/immutable/assets/Circle.C5MKzgk2.css +1 -0
- package/dist/app/_app/immutable/assets/DefaultTooltip.C5-uctZ7.css +1 -0
- package/dist/app/_app/immutable/assets/Group.DV48xipa.css +1 -0
- package/dist/app/_app/immutable/assets/Labels.BxZ4NUVz.css +1 -0
- package/dist/app/_app/immutable/assets/Legend.CxnrE4Ye.css +1 -0
- package/dist/app/_app/immutable/assets/Line.fkmsECm9.css +1 -0
- package/dist/app/_app/immutable/assets/Path.CvpwNZ6g.css +1 -0
- package/dist/app/_app/immutable/assets/Rect.CtRaGMmQ.css +1 -0
- package/dist/app/_app/immutable/assets/Text.j9l35qB0.css +1 -0
- package/dist/app/_app/immutable/assets/TransformContext.Bs_HkpAk.css +1 -0
- package/dist/app/_app/immutable/assets/Voronoi.ce7atosu.css +1 -0
- package/dist/app/_app/immutable/chunks/-aNGNaBT.js +1 -0
- package/dist/app/_app/immutable/chunks/6djn-yLs.js +1 -0
- package/dist/app/_app/immutable/chunks/B1amyutE.js +1 -0
- package/dist/app/_app/immutable/chunks/B3vZDoek.js +1 -0
- package/dist/app/_app/immutable/chunks/B5KRA4hC.js +1 -0
- package/dist/app/_app/immutable/chunks/BClnVG6H.js +1 -0
- package/dist/app/_app/immutable/chunks/BID1NNRh.js +1 -0
- package/dist/app/_app/immutable/chunks/BR2LaRms.js +1 -0
- package/dist/app/_app/immutable/chunks/Bd1gDe3Y.js +1 -0
- package/dist/app/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/dist/app/_app/immutable/chunks/Bl052uUt.js +1 -0
- package/dist/app/_app/immutable/chunks/Bye3lL0c.js +1 -0
- package/dist/app/_app/immutable/chunks/C58PZtCD.js +4 -0
- package/dist/app/_app/immutable/chunks/CAzydqEO.js +1 -0
- package/dist/app/_app/immutable/chunks/CCch3uox.js +1 -0
- package/dist/app/_app/immutable/chunks/CIlSMUH9.js +1 -0
- package/dist/app/_app/immutable/chunks/CO1vUXfR.js +1 -0
- package/dist/app/_app/immutable/chunks/CPbD8C65.js +5 -0
- package/dist/app/_app/immutable/chunks/CRTcXoMo.js +1 -0
- package/dist/app/_app/immutable/chunks/CjjyIQAO.js +1 -0
- package/dist/app/_app/immutable/chunks/CuXAxjvF.js +1 -0
- package/dist/app/_app/immutable/chunks/CvyVA_jC.js +1 -0
- package/dist/app/_app/immutable/chunks/CxGCFVdy.js +1 -0
- package/dist/app/_app/immutable/chunks/D0Ty6LN0.js +1 -0
- package/dist/app/_app/immutable/chunks/D2AaQUUW.js +1 -0
- package/dist/app/_app/immutable/chunks/D2BnX0Uk.js +3 -0
- package/dist/app/_app/immutable/chunks/DJc8C0NK.js +1 -0
- package/dist/app/_app/immutable/chunks/DKMlMI4a.js +1 -0
- package/dist/app/_app/immutable/chunks/DVXZkpbf.js +1 -0
- package/dist/app/_app/immutable/chunks/DVt8ukQ_.js +1 -0
- package/dist/app/_app/immutable/chunks/DZPlYdq_.js +1 -0
- package/dist/app/_app/immutable/chunks/Db0q5_zr.js +1 -0
- package/dist/app/_app/immutable/chunks/Dfvzj6n2.js +1 -0
- package/dist/app/_app/immutable/chunks/Dh958be7.js +1 -0
- package/dist/app/_app/immutable/chunks/DjKLLdnY.js +15 -0
- package/dist/app/_app/immutable/chunks/Doz7YX1W.js +1 -0
- package/dist/app/_app/immutable/chunks/DthYhn6Y.js +2 -0
- package/dist/app/_app/immutable/chunks/DtuTIrAM.js +1 -0
- package/dist/app/_app/immutable/chunks/HclGiUj8.js +1 -0
- package/dist/app/_app/immutable/chunks/Hx0TNsV3.js +1 -0
- package/dist/app/_app/immutable/chunks/RobXhXPM.js +1 -0
- package/dist/app/_app/immutable/chunks/V9ZjaxiY.js +1 -0
- package/dist/app/_app/immutable/chunks/Y5urAfNy.js +1 -0
- package/dist/app/_app/immutable/chunks/caXkbKD3.js +1 -0
- package/dist/app/_app/immutable/chunks/devYm2ud.js +1 -0
- package/dist/app/_app/immutable/chunks/mtZWP0zR.js +1 -0
- package/dist/app/_app/immutable/chunks/vDgBJUjM.js +1 -0
- package/dist/app/_app/immutable/chunks/xIq_fFFM.js +1 -0
- package/dist/app/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/dist/app/_app/immutable/chunks/z05MoCFz.js +1 -0
- package/dist/app/_app/immutable/entry/app.CLAerUAN.js +2 -0
- package/dist/app/_app/immutable/entry/start.D3MqnNci.js +1 -0
- package/dist/app/_app/immutable/nodes/0.UTMEigHJ.js +1 -0
- package/dist/app/_app/immutable/nodes/1.Cn4f11bT.js +1 -0
- package/dist/app/_app/immutable/nodes/2.B39cIcr2.js +6 -0
- package/dist/app/_app/version.json +1 -0
- package/dist/app/index.html +82 -0
- package/dist/aws/clients.d.ts +70 -0
- package/dist/aws/clients.js +52 -0
- package/dist/aws/errors.d.ts +41 -0
- package/dist/aws/errors.js +70 -0
- package/dist/aws/firehose.d.ts +228 -0
- package/dist/aws/firehose.js +347 -0
- package/dist/aws/glue.d.ts +103 -0
- package/dist/aws/glue.js +225 -0
- package/dist/aws/lambda.d.ts +132 -0
- package/dist/aws/lambda.js +339 -0
- package/dist/aws/s3tables.d.ts +120 -0
- package/dist/aws/s3tables.js +281 -0
- package/dist/backfill.d.ts +100 -0
- package/dist/backfill.js +294 -0
- package/dist/commands.d.ts +124 -0
- package/dist/commands.js +336 -0
- package/dist/config.d.ts +162 -0
- package/dist/config.js +317 -0
- package/dist/fixture-ingest.d.ts +49 -0
- package/dist/fixture-ingest.js +43 -0
- package/dist/fixture-query.d.ts +39 -0
- package/dist/fixture-query.js +70 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +35 -0
- package/dist/nodes.d.ts +404 -0
- package/dist/nodes.js +2708 -0
- package/dist/paths.d.ts +45 -0
- package/dist/paths.js +47 -0
- package/dist/plugin.d.ts +102 -0
- package/dist/plugin.js +248 -0
- package/dist/ports.d.ts +113 -0
- package/dist/ports.js +35 -0
- package/dist/queries.d.ts +301 -0
- package/dist/queries.js +414 -0
- package/dist/schema.d.ts +240 -0
- package/dist/schema.js +154 -0
- package/dist/server.d.ts +150 -0
- package/dist/server.js +499 -0
- package/dist/transform/bots.d.ts +47 -0
- package/dist/transform/bots.js +73 -0
- package/dist/transform/handler.d.ts +135 -0
- package/dist/transform/handler.js +177 -0
- package/dist/transform/map-record.d.ts +110 -0
- package/dist/transform/map-record.js +275 -0
- package/dist/transform/visitor-key.d.ts +83 -0
- package/dist/transform/visitor-key.js +120 -0
- package/dist/transform-bundle/index.mjs +21456 -0
- package/dist/transform-bundle/transform-manifest.json +4 -0
- package/dist/transform-hash.d.ts +135 -0
- package/dist/transform-hash.js +186 -0
- package/dist/write-transform-manifest.mjs +365 -0
- package/package.json +59 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package's **edge**: the one module that imports `node:http`, and the one
|
|
3
|
+
* place an HTTP request becomes a call on the {@link AnalyticsQuery} port.
|
|
4
|
+
* Everything below the port - `queries.ts`, `schema.ts`, the transform, the
|
|
5
|
+
* nodes - is reached only through {@link createDashboardServer}, so a reviewer
|
|
6
|
+
* who wants to know what a browser can make this package do reads this file
|
|
7
|
+
* and stops. See [the change spec's §Analytics dashboard → Local
|
|
8
|
+
* server](../../../.specs/changes/merged/2026-07-26-analytics_plugin.md).
|
|
9
|
+
*
|
|
10
|
+
* **Loopback, always.** The listener binds {@link LOOPBACK_ADDRESS} - a named
|
|
11
|
+
* constant, never `0.0.0.0` and never a wildcard - because the dashboard reads
|
|
12
|
+
* the Iceberg table over the *operator's own* AWS credentials
|
|
13
|
+
* (`adapters/duckdb-query.ts`). A wildcard bind would put an unauthenticated
|
|
14
|
+
* read of the site's traffic, signed as the operator, on every interface the
|
|
15
|
+
* machine has. The port is whatever the caller resolved from
|
|
16
|
+
* `config.analytics.dashboard.port` (task 44); this module reads no
|
|
17
|
+
* environment variable and states no port of its own, so
|
|
18
|
+
* `DEFAULT_DASHBOARD_PORT` keeps the single home task 44 gave it. Contrast
|
|
19
|
+
* the build agent (`packages/build-agent/src/server.ts`), which binds
|
|
20
|
+
* `0.0.0.0` from `process.env.PORT` - correct for a MicroVM behind a security
|
|
21
|
+
* group, wrong for a program holding an operator's session.
|
|
22
|
+
*
|
|
23
|
+
* **There is no route that accepts SQL, and that is structural rather than
|
|
24
|
+
* filtered.** The request surface is exactly two families:
|
|
25
|
+
*
|
|
26
|
+
* - `GET {@link QUERY_ROUTE_PREFIX}<name>` - one of the seven names
|
|
27
|
+
* `queries.ts` declares, resolved through that module's own
|
|
28
|
+
* {@link queryDefinition} lookup, which is `Object.hasOwn`-guarded so
|
|
29
|
+
* `constructor`, `toString` and `__proto__` are unknown names and not
|
|
30
|
+
* inherited functions. An unknown name answers 404 listing the ones that
|
|
31
|
+
* resolve.
|
|
32
|
+
* - everything else - a static file from `appDir`, served only if it is on
|
|
33
|
+
* the allow-list `FileSystem.listFiles(appDir)` returned.
|
|
34
|
+
*
|
|
35
|
+
* A caller therefore has nowhere to put a statement. The query string is
|
|
36
|
+
* checked against {@link QUERY_STRING_PARAMS} and an unrecognised key is
|
|
37
|
+
* *rejected*, not ignored, so `?sql=...` is a 400 and never a silently
|
|
38
|
+
* dropped parameter; the only methods answered are `GET` and `HEAD` - the two
|
|
39
|
+
* RFC 9110 §9.1 makes mandatory - and everything else is 405 naming them; and
|
|
40
|
+
* no handler in this module attaches a `data` listener to a request, so a
|
|
41
|
+
* request body is never a value this program holds. The type system says the
|
|
42
|
+
* same thing one layer down - `AnalyticsQuery.run` takes a `QueryName`, and
|
|
43
|
+
* `queries.ts` mints SQL only through a module-private tagged template - and
|
|
44
|
+
* this module is where that guarantee would have been thrown away, because
|
|
45
|
+
* here the compiler's knowledge of the name has already been erased to
|
|
46
|
+
* `string`.
|
|
47
|
+
*
|
|
48
|
+
* **Nothing this module holds is ever concatenated into a statement.** The
|
|
49
|
+
* date range and the bot-inclusion flag are parsed here, validated here
|
|
50
|
+
* through task 45's own {@link prepareQuery}, and handed to
|
|
51
|
+
* `AnalyticsQuery.run(name, params)` **unmodified**. The `PreparedQuery` that
|
|
52
|
+
* validation returns is used for exactly one field, its `name` - the
|
|
53
|
+
* `QueryName` the lookup just proved - and its `sql` is deliberately untouched:
|
|
54
|
+
* a statement is the adapter's business, and an edge module that held one
|
|
55
|
+
* would be one refactor away from letting a request shape it.
|
|
56
|
+
*
|
|
57
|
+
* **Loopback is not an origin, so the `Host` header is checked too.** Binding
|
|
58
|
+
* {@link LOOPBACK_ADDRESS} keeps other *machines* out; it does nothing about
|
|
59
|
+
* other *origins* in the operator's own browser. A page the operator visits
|
|
60
|
+
* can point a hostname it controls at 127.0.0.1 and fetch this server through
|
|
61
|
+
* that name - the DNS-rebinding shape - and because a browser judges
|
|
62
|
+
* same-origin by hostname, that page reads the responses. This module sends no
|
|
63
|
+
* CORS headers, and their absence is what refuses an ordinary cross-origin
|
|
64
|
+
* read; the rebinding case defeats that precisely by making the origin match,
|
|
65
|
+
* so no response header can answer it. What answers it is
|
|
66
|
+
* {@link ALLOWED_HOST_NAMES}: a request whose `Host` is not this listener's own
|
|
67
|
+
* address is refused with a 403 before anything else about it is read. The
|
|
68
|
+
* exposure that closes is bounded - the site's own traffic figures, nothing
|
|
69
|
+
* writable, no route that takes SQL - which is why it is three lines here
|
|
70
|
+
* rather than a design.
|
|
71
|
+
*
|
|
72
|
+
* {@link COMMON_HEADERS} carries `X-Content-Type-Options: nosniff` on every
|
|
73
|
+
* response for a neighbouring reason: `dist/app` is a build output whose file
|
|
74
|
+
* names this module does not choose, and an extension {@link CONTENT_TYPES}
|
|
75
|
+
* does not know is served as {@link DEFAULT_CONTENT_TYPE}, which a sniffing
|
|
76
|
+
* browser is otherwise free to re-read as something it will execute.
|
|
77
|
+
*/
|
|
78
|
+
import { createServer } from 'node:http';
|
|
79
|
+
import { join, sep } from 'node:path';
|
|
80
|
+
import { FileNotFoundError } from 'blogwright-core';
|
|
81
|
+
import { prepareQuery, queryDefinition } from './queries.js';
|
|
82
|
+
/**
|
|
83
|
+
* The address the listener binds. A named constant because the assertion that
|
|
84
|
+
* matters is on this exact value: a dashboard reachable from another host is
|
|
85
|
+
* an unauthenticated read of the site's traffic under the operator's
|
|
86
|
+
* credentials. `localhost` would not do - it resolves through the host's name
|
|
87
|
+
* service, so it can be `::1`, both, or whatever a `/etc/hosts` entry says.
|
|
88
|
+
*/
|
|
89
|
+
const LOOPBACK_ADDRESS = '127.0.0.1';
|
|
90
|
+
/**
|
|
91
|
+
* The host names a request may be addressed to, each paired with the bound
|
|
92
|
+
* port to make the `Host` values this server answers. Two spellings because
|
|
93
|
+
* both reach a loopback listener from a browser's address bar and the
|
|
94
|
+
* dashboard prints one of them; anything else - a name that merely *resolves*
|
|
95
|
+
* to 127.0.0.1 - is a different origin wearing this server's address, and is
|
|
96
|
+
* refused. See this module's doc comment for why the bind alone does not cover
|
|
97
|
+
* it.
|
|
98
|
+
*
|
|
99
|
+
* No entry is portless. Task 44 floors `dashboard.port` at 1024, so this
|
|
100
|
+
* listener never holds a default port and a browser therefore always sends the
|
|
101
|
+
* port in `Host`.
|
|
102
|
+
*/
|
|
103
|
+
const ALLOWED_HOST_NAMES = ['127.0.0.1', 'localhost'];
|
|
104
|
+
/**
|
|
105
|
+
* Headers on every response this module writes, whatever it is answering.
|
|
106
|
+
* `nosniff` is here rather than beside one route because the reason for it -
|
|
107
|
+
* a served file whose extension nothing in this module recognised - reaches
|
|
108
|
+
* both the asset route and the refusals written about it.
|
|
109
|
+
*/
|
|
110
|
+
const COMMON_HEADERS = {
|
|
111
|
+
'x-content-type-options': 'nosniff',
|
|
112
|
+
};
|
|
113
|
+
/** Path prefix under which the named queries - and nothing else - are answered. */
|
|
114
|
+
const QUERY_ROUTE_PREFIX = '/api/queries/';
|
|
115
|
+
/**
|
|
116
|
+
* Every query-string key a named-query route accepts. An unrecognised key is
|
|
117
|
+
* refused rather than ignored: ignoring it would mean `?sql=DROP TABLE …`
|
|
118
|
+
* returned 200 with the fake having run a real query, which is a weaker
|
|
119
|
+
* property than the one this server claims. `from` and `to` are the range
|
|
120
|
+
* `queries.ts` binds; `includeBots` is the bot-inclusion flag, and is optional
|
|
121
|
+
* because its default is `config.analytics.bots`, applied inside the port -
|
|
122
|
+
* this module must not restate it.
|
|
123
|
+
*/
|
|
124
|
+
const QUERY_STRING_PARAMS = ['from', 'to', 'includeBots'];
|
|
125
|
+
/** The two spellings the bot-inclusion flag takes, mapped to what it means. */
|
|
126
|
+
const INCLUDE_BOTS_VALUES = { true: true, false: false };
|
|
127
|
+
/**
|
|
128
|
+
* The methods every route answers. `HEAD` is here because RFC 9110 §9.1 makes
|
|
129
|
+
* it mandatory beside `GET`: no browser needs it, but `curl -I` and an uptime
|
|
130
|
+
* probe both use it, and a 405 there reads as a broken server rather than a
|
|
131
|
+
* deliberate one.
|
|
132
|
+
*
|
|
133
|
+
* It is answered *as* `GET` with the body suppressed - same status, same
|
|
134
|
+
* headers, `content-length` included - which is why no handler below branches
|
|
135
|
+
* on the method and why the asset route still reads the bytes it would have
|
|
136
|
+
* sent. Node drops the payload itself once the request method is `HEAD`, so
|
|
137
|
+
* the headers a probe reads are computed by the same code that serves a `GET`
|
|
138
|
+
* rather than by a second path that could drift from it.
|
|
139
|
+
*/
|
|
140
|
+
const ALLOWED_METHODS = ['GET', 'HEAD'];
|
|
141
|
+
/**
|
|
142
|
+
* What a 405 reports as allowed. RFC 9110 §10.2.1 asks for the whole supported
|
|
143
|
+
* set, so this is derived from {@link ALLOWED_METHODS} rather than restated.
|
|
144
|
+
*/
|
|
145
|
+
const ALLOW_HEADER = ALLOWED_METHODS.join(', ');
|
|
146
|
+
/** The file a directory-shaped request path resolves to, the way a static host resolves one. */
|
|
147
|
+
const DIRECTORY_INDEX = 'index.html';
|
|
148
|
+
/**
|
|
149
|
+
* Content types for the extensions a built SvelteKit application emits. A
|
|
150
|
+
* narrower copy of the build agent's map (`packages/build-agent/src/build.ts`)
|
|
151
|
+
* rather than an import of it: that package is a deployed artifact, not a
|
|
152
|
+
* dependency of this one, and this server hands out one prebuilt directory
|
|
153
|
+
* rather than a user's arbitrary site.
|
|
154
|
+
*/
|
|
155
|
+
const CONTENT_TYPES = {
|
|
156
|
+
html: 'text/html; charset=utf-8',
|
|
157
|
+
css: 'text/css; charset=utf-8',
|
|
158
|
+
js: 'text/javascript; charset=utf-8',
|
|
159
|
+
mjs: 'text/javascript; charset=utf-8',
|
|
160
|
+
json: 'application/json',
|
|
161
|
+
map: 'application/json',
|
|
162
|
+
webmanifest: 'application/manifest+json',
|
|
163
|
+
svg: 'image/svg+xml',
|
|
164
|
+
png: 'image/png',
|
|
165
|
+
jpg: 'image/jpeg',
|
|
166
|
+
jpeg: 'image/jpeg',
|
|
167
|
+
webp: 'image/webp',
|
|
168
|
+
avif: 'image/avif',
|
|
169
|
+
gif: 'image/gif',
|
|
170
|
+
ico: 'image/x-icon',
|
|
171
|
+
woff: 'font/woff',
|
|
172
|
+
woff2: 'font/woff2',
|
|
173
|
+
txt: 'text/plain; charset=utf-8',
|
|
174
|
+
wasm: 'application/wasm',
|
|
175
|
+
};
|
|
176
|
+
/** Served for any extension {@link CONTENT_TYPES} does not cover. */
|
|
177
|
+
const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
|
|
178
|
+
/** What an error says for itself, whether or not it is an `Error`. */
|
|
179
|
+
function describeError(err) {
|
|
180
|
+
return err instanceof Error ? err.message : String(err);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* A request this server refuses, carrying the status it refuses with. Private
|
|
184
|
+
* to this module: it is how a rejection travels from the place that can name
|
|
185
|
+
* the offending value to the place that writes a response, never a type a
|
|
186
|
+
* caller observes.
|
|
187
|
+
*/
|
|
188
|
+
class RequestRejected extends Error {
|
|
189
|
+
status;
|
|
190
|
+
constructor(status, message) {
|
|
191
|
+
super(message);
|
|
192
|
+
this.status = status;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Whether the response can still be written to. A client that dropped its
|
|
197
|
+
* socket - or a shutdown that destroyed it out from under an in-flight
|
|
198
|
+
* handler - leaves a response nothing can be sent on, and writing to it raises
|
|
199
|
+
* from inside a handler that has nowhere to report.
|
|
200
|
+
*/
|
|
201
|
+
function writable(res) {
|
|
202
|
+
return !res.writableEnded && !res.destroyed;
|
|
203
|
+
}
|
|
204
|
+
/** Write a JSON response. Every route in this module answers through here or {@link sendAsset}. */
|
|
205
|
+
function sendJson(res, status, body) {
|
|
206
|
+
if (!writable(res))
|
|
207
|
+
return;
|
|
208
|
+
const text = JSON.stringify(body);
|
|
209
|
+
res.writeHead(status, {
|
|
210
|
+
...COMMON_HEADERS,
|
|
211
|
+
'content-type': 'application/json; charset=utf-8',
|
|
212
|
+
'content-length': Buffer.byteLength(text),
|
|
213
|
+
// Traffic figures change under the reader; a cached chart is a wrong chart.
|
|
214
|
+
'cache-control': 'no-store',
|
|
215
|
+
});
|
|
216
|
+
res.end(text);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Write a static file's bytes. Unconditional: on a `HEAD` request Node keeps
|
|
220
|
+
* the headers and discards the payload, so the `content-length` a probe reads
|
|
221
|
+
* is the one a `GET` of the same path would have carried.
|
|
222
|
+
*/
|
|
223
|
+
function sendAsset(res, contentType, bytes) {
|
|
224
|
+
if (!writable(res))
|
|
225
|
+
return;
|
|
226
|
+
res.writeHead(200, {
|
|
227
|
+
...COMMON_HEADERS,
|
|
228
|
+
'content-type': contentType,
|
|
229
|
+
'content-length': bytes.byteLength,
|
|
230
|
+
});
|
|
231
|
+
res.end(Buffer.from(bytes));
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The lowercase extension of a path, or `undefined` when it has none - a
|
|
235
|
+
* leading dot is a dotfile, not an extension. Mirrors `extensionOf`
|
|
236
|
+
* (`packages/build-agent/src/build.ts`).
|
|
237
|
+
*/
|
|
238
|
+
function extensionOf(path) {
|
|
239
|
+
const base = path.split('/').pop() ?? '';
|
|
240
|
+
const dot = base.lastIndexOf('.');
|
|
241
|
+
if (dot <= 0 || dot === base.length - 1)
|
|
242
|
+
return undefined;
|
|
243
|
+
return base.slice(dot + 1).toLowerCase();
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Content type for a served file. Guarded with `Object.hasOwn` for the reason
|
|
247
|
+
* `contentType` (`packages/build-agent/src/build.ts`) is: an unguarded index
|
|
248
|
+
* answers every `Object.prototype` key, so a file named `x.constructor` would
|
|
249
|
+
* resolve to a truthy function and be written into a header.
|
|
250
|
+
*/
|
|
251
|
+
function contentTypeOf(path) {
|
|
252
|
+
const extension = extensionOf(path);
|
|
253
|
+
if (extension === undefined)
|
|
254
|
+
return DEFAULT_CONTENT_TYPE;
|
|
255
|
+
const type = Object.hasOwn(CONTENT_TYPES, extension) ? CONTENT_TYPES[extension] : undefined;
|
|
256
|
+
return type ?? DEFAULT_CONTENT_TYPE;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The name a query route asks for. Deliberately **not** percent-decoded: every
|
|
260
|
+
* name `queries.ts` declares is an ASCII identifier, so decoding would only
|
|
261
|
+
* mint second spellings of the same name (`%63ountries`), and anything that
|
|
262
|
+
* needs decoding to look like a name is not one. What resolves is exactly what
|
|
263
|
+
* {@link queryDefinition} holds as an own key.
|
|
264
|
+
*/
|
|
265
|
+
function requestedQueryName(pathname) {
|
|
266
|
+
return pathname.slice(QUERY_ROUTE_PREFIX.length);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Read one required query-string value, refusing an absent one rather than
|
|
270
|
+
* standing in a window the server picked - a chart over a range the reader did
|
|
271
|
+
* not choose is indexed by nothing. `searchParams.get` answers `null` for an
|
|
272
|
+
* absent key, which never becomes a value here: absence raises.
|
|
273
|
+
*/
|
|
274
|
+
function requiredParam(params, key) {
|
|
275
|
+
const value = params.get(key);
|
|
276
|
+
if (value === null) {
|
|
277
|
+
throw new RequestRejected(400, `missing required query parameter "${key}" - this route takes ${QUERY_STRING_PARAMS.join(', ')}`);
|
|
278
|
+
}
|
|
279
|
+
return value;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Parse the request's parameters into the {@link QueryParams} the port takes.
|
|
283
|
+
* An unrecognised key is refused here, which is what makes "no route accepts
|
|
284
|
+
* SQL" a property of the routing layer rather than of what the port happens to
|
|
285
|
+
* ignore.
|
|
286
|
+
*/
|
|
287
|
+
function parseQueryParams(search) {
|
|
288
|
+
for (const key of search.keys()) {
|
|
289
|
+
if (!QUERY_STRING_PARAMS.some((known) => known === key)) {
|
|
290
|
+
throw new RequestRejected(400, `unknown query parameter "${key}" - this route takes ${QUERY_STRING_PARAMS.join(', ')} and nothing else`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const range = { from: requiredParam(search, 'from'), to: requiredParam(search, 'to') };
|
|
294
|
+
const flag = search.get('includeBots');
|
|
295
|
+
if (flag === null) {
|
|
296
|
+
// Absent on purpose: `config.analytics.bots` decides, inside the port.
|
|
297
|
+
return { range };
|
|
298
|
+
}
|
|
299
|
+
if (!Object.hasOwn(INCLUDE_BOTS_VALUES, flag)) {
|
|
300
|
+
throw new RequestRejected(400, `query parameter "includeBots" must be ${Object.keys(INCLUDE_BOTS_VALUES).join(' or ')}, got "${flag}"`);
|
|
301
|
+
}
|
|
302
|
+
return { range, includeBots: INCLUDE_BOTS_VALUES[flag] };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Start the dashboard's HTTP listener on {@link LOOPBACK_ADDRESS}. Resolves
|
|
306
|
+
* once the socket is bound, with the address read back off it, so a caller
|
|
307
|
+
* prints where the server *is* rather than where it was asked to be.
|
|
308
|
+
*/
|
|
309
|
+
export async function createDashboardServer(opts) {
|
|
310
|
+
/**
|
|
311
|
+
* The files `appDir` holds, keyed by the URL path that serves each one. An
|
|
312
|
+
* **allow-list**, and that is the whole traversal defence: a request path is
|
|
313
|
+
* looked up in this map and a miss is a 404, so `..`, `%2e%2e`, an absolute
|
|
314
|
+
* path and a symlink alike name nothing that can be served. No path
|
|
315
|
+
* arithmetic decides what escapes the directory, because none is done -
|
|
316
|
+
* contrast `resolveWithin` (`packages/build-agent/src/build.ts`), which has
|
|
317
|
+
* to get a containment check right because it resolves a caller's string.
|
|
318
|
+
*
|
|
319
|
+
* Read once, lazily, and cached on success only, following the DuckDB
|
|
320
|
+
* adapter's session: the listener therefore binds before any filesystem is
|
|
321
|
+
* consulted, and a run started before task 57's `dist/app` existed picks the
|
|
322
|
+
* directory up once it does rather than repeating the first failure forever.
|
|
323
|
+
*/
|
|
324
|
+
let listing;
|
|
325
|
+
/**
|
|
326
|
+
* The `Host` values this listener answers - {@link ALLOWED_HOST_NAMES}, each
|
|
327
|
+
* with the port the socket actually bound. Built from the *bound* port and
|
|
328
|
+
* not from `opts.port`, so a caller that asks for port 0 gets an allow-list
|
|
329
|
+
* naming the port it really got; assigned inside the `listen` callback,
|
|
330
|
+
* before the promise below resolves, so it is already set by the time the
|
|
331
|
+
* listener can be connected to at all. `undefined` therefore refuses rather
|
|
332
|
+
* than admitting: a request this server cannot name its own address for is
|
|
333
|
+
* not one it should answer.
|
|
334
|
+
*/
|
|
335
|
+
let allowedHostHeaders;
|
|
336
|
+
/**
|
|
337
|
+
* Refuse a request addressed to a name that is not this listener's own. The
|
|
338
|
+
* body is drained rather than left unread, for the reason the 405 path
|
|
339
|
+
* drains it: nothing here reads a body, and an undrained socket stalls.
|
|
340
|
+
*/
|
|
341
|
+
function rejectForeignHost(req) {
|
|
342
|
+
const host = req.headers.host?.toLowerCase();
|
|
343
|
+
if (host !== undefined && allowedHostHeaders?.has(host) === true)
|
|
344
|
+
return;
|
|
345
|
+
req.resume();
|
|
346
|
+
throw new RequestRejected(403, `this dashboard answers only ${[...(allowedHostHeaders ?? [])].join(' and ')} - a request for host ${host === undefined ? '<absent>' : `"${host}"`} is addressed to some other origin that resolved to this address`);
|
|
347
|
+
}
|
|
348
|
+
async function readAppFiles() {
|
|
349
|
+
const entries = await opts.fs.listFiles(opts.appDir);
|
|
350
|
+
// `listFiles` answers platform-separated relative paths; a URL path is
|
|
351
|
+
// always `/`-separated, so the key is normalised and the value keeps the
|
|
352
|
+
// form the port will be asked to read back.
|
|
353
|
+
return new Map(entries.map((entry) => [entry.split(sep).join('/'), entry]));
|
|
354
|
+
}
|
|
355
|
+
function appFiles() {
|
|
356
|
+
const pending = (listing ??= readAppFiles());
|
|
357
|
+
return pending.catch((err) => {
|
|
358
|
+
if (listing === pending)
|
|
359
|
+
listing = undefined;
|
|
360
|
+
throw err;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
async function handleQuery(pathname, search) {
|
|
364
|
+
const name = requestedQueryName(pathname);
|
|
365
|
+
// The unknown-name guard. `queryDefinition` raises listing every name that
|
|
366
|
+
// resolves, and its `Object.hasOwn` lookup is why an inherited key
|
|
367
|
+
// (`constructor`, `toString`, `__proto__`) is an unknown name here rather
|
|
368
|
+
// than a truthy definition that fails deeper in.
|
|
369
|
+
let definition;
|
|
370
|
+
try {
|
|
371
|
+
definition = queryDefinition(name);
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
throw new RequestRejected(404, describeError(err));
|
|
375
|
+
}
|
|
376
|
+
const params = parseQueryParams(search);
|
|
377
|
+
// Task 45's own parameter validation, run at the boundary so a malformed
|
|
378
|
+
// or inverted range is a 400 naming the offending value rather than a 500
|
|
379
|
+
// raised from inside the port. Exactly one field of the result is used -
|
|
380
|
+
// `name`, the `QueryName` the lookup above proved - and `sql` is left
|
|
381
|
+
// untouched: see this module's doc comment.
|
|
382
|
+
let resolved;
|
|
383
|
+
try {
|
|
384
|
+
resolved = prepareQuery(name, params, opts.config);
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
throw new RequestRejected(400, describeError(err));
|
|
388
|
+
}
|
|
389
|
+
// `params` goes through unmodified. Nothing between the request and this
|
|
390
|
+
// call reshapes a caller's value, and nothing concatenates one.
|
|
391
|
+
const rows = await opts.query.run(resolved.name, params);
|
|
392
|
+
return {
|
|
393
|
+
name: resolved.name,
|
|
394
|
+
rowMeaning: definition.rowMeaning,
|
|
395
|
+
resultColumns: definition.resultColumns,
|
|
396
|
+
rows,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
async function handleAsset(pathname, res) {
|
|
400
|
+
let decoded;
|
|
401
|
+
try {
|
|
402
|
+
decoded = decodeURIComponent(pathname);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
throw new RequestRejected(400, `path "${pathname}" is not valid percent-encoding`);
|
|
406
|
+
}
|
|
407
|
+
const trimmed = decoded.replace(/^\/+/, '');
|
|
408
|
+
const key = trimmed === '' || trimmed.endsWith('/') ? `${trimmed}${DIRECTORY_INDEX}` : trimmed;
|
|
409
|
+
let files;
|
|
410
|
+
try {
|
|
411
|
+
files = await appFiles();
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
if (err instanceof FileNotFoundError) {
|
|
415
|
+
throw new RequestRejected(503, `the dashboard application has not been built: no directory at ${opts.appDir}`);
|
|
416
|
+
}
|
|
417
|
+
throw err;
|
|
418
|
+
}
|
|
419
|
+
const relative = files.get(key);
|
|
420
|
+
if (relative === undefined)
|
|
421
|
+
throw new RequestRejected(404, `no such file "${key}"`);
|
|
422
|
+
sendAsset(res, contentTypeOf(key), await opts.fs.readBytes(join(opts.appDir, relative)));
|
|
423
|
+
}
|
|
424
|
+
async function route(req, res) {
|
|
425
|
+
// First, and before the method: "is this addressed to me" precedes "may
|
|
426
|
+
// you do that here", and a page on some other origin should learn nothing
|
|
427
|
+
// about this server - not even which methods it answers.
|
|
428
|
+
rejectForeignHost(req);
|
|
429
|
+
if (!ALLOWED_METHODS.some((allowed) => allowed === req.method)) {
|
|
430
|
+
// Refused on the method alone, before anything about the request is
|
|
431
|
+
// read. No handler in this module attaches a `data` listener, so a body
|
|
432
|
+
// - SQL or otherwise - is never a value this program holds; `resume`
|
|
433
|
+
// drains and discards it so the socket does not stall.
|
|
434
|
+
req.resume();
|
|
435
|
+
res.setHeader('allow', ALLOW_HEADER);
|
|
436
|
+
throw new RequestRejected(405, `${String(req.method)} is not allowed - this server answers ${ALLOW_HEADER}`);
|
|
437
|
+
}
|
|
438
|
+
let url;
|
|
439
|
+
try {
|
|
440
|
+
url = new URL(req.url ?? '/', `http://${LOOPBACK_ADDRESS}`);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
throw new RequestRejected(400, 'the request target is not a URL');
|
|
444
|
+
}
|
|
445
|
+
if (url.pathname.startsWith(QUERY_ROUTE_PREFIX)) {
|
|
446
|
+
sendJson(res, 200, await handleQuery(url.pathname, url.searchParams));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
await handleAsset(url.pathname, res);
|
|
450
|
+
}
|
|
451
|
+
const server = createServer((req, res) => {
|
|
452
|
+
void route(req, res).catch((err) => {
|
|
453
|
+
if (!writable(res))
|
|
454
|
+
return;
|
|
455
|
+
if (res.headersSent) {
|
|
456
|
+
res.destroy();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const status = err instanceof RequestRejected ? err.status : 500;
|
|
460
|
+
sendJson(res, status, { error: describeError(err) });
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
// The build agent's precedent: a malformed request line kills its own socket
|
|
464
|
+
// rather than the process.
|
|
465
|
+
server.on('clientError', (_err, socket) => socket.destroy());
|
|
466
|
+
const address = await new Promise((resolve, reject) => {
|
|
467
|
+
const onError = (err) => {
|
|
468
|
+
reject(new Error(`analytics dashboard cannot bind ${LOOPBACK_ADDRESS}:${opts.port}: ${err.message}`));
|
|
469
|
+
};
|
|
470
|
+
server.once('error', onError);
|
|
471
|
+
server.listen(opts.port, LOOPBACK_ADDRESS, () => {
|
|
472
|
+
server.off('error', onError);
|
|
473
|
+
const bound = server.address();
|
|
474
|
+
if (bound === null || typeof bound === 'string') {
|
|
475
|
+
reject(new Error(`analytics dashboard bound no TCP address on port ${opts.port}`));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
allowedHostHeaders = new Set(ALLOWED_HOST_NAMES.map((name) => `${name}:${bound.port}`));
|
|
479
|
+
resolve({ host: bound.address, port: bound.port });
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
let closed;
|
|
483
|
+
return {
|
|
484
|
+
address,
|
|
485
|
+
url: `http://${address.host}:${address.port}/`,
|
|
486
|
+
close() {
|
|
487
|
+
// Idempotent: a caller that stops on a signal and a caller that stops
|
|
488
|
+
// explicitly may both arrive, and `Server.close` calls back with
|
|
489
|
+
// ERR_SERVER_NOT_RUNNING the second time.
|
|
490
|
+
closed ??= new Promise((resolve, reject) => {
|
|
491
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
492
|
+
// Without this a keep-alive connection holds the port after `close`
|
|
493
|
+
// resolves nothing, and the caller's next bind fails with EADDRINUSE.
|
|
494
|
+
server.closeAllConnections();
|
|
495
|
+
});
|
|
496
|
+
return closed;
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `is_bot`: the user-agent heuristic behind step 4 of
|
|
3
|
+
* [§Record transformation](../../../../.specs/changes/merged/2026-07-26-analytics_plugin.md).
|
|
4
|
+
*
|
|
5
|
+
* A matched record is **flagged, never dropped**. `is_bot` is a column and
|
|
6
|
+
* every dashboard query takes a bot-inclusion flag, so a wrong match costs a
|
|
7
|
+
* wrong boolean that a query can ignore, while a dropped record is a page view
|
|
8
|
+
* nobody can get back. That asymmetry is the whole reason this returns a
|
|
9
|
+
* boolean rather than a verdict the transform acts on.
|
|
10
|
+
*
|
|
11
|
+
* The patterns are a deliberately small, self-declaring set rather than an
|
|
12
|
+
* exhaustive registry of crawlers. Every one of them matches text an agent
|
|
13
|
+
* puts in its own user agent to say what it is - `Googlebot/2.1`,
|
|
14
|
+
* `python-requests/2.32`, `curl/8.7.1` - so a match is evidence from the
|
|
15
|
+
* request itself, not a fingerprint. Bots that lie about their user agent are
|
|
16
|
+
* out of reach of any user-agent test and are not what this is for.
|
|
17
|
+
*
|
|
18
|
+
* Each pattern anchors on a word boundary rather than matching a bare
|
|
19
|
+
* substring, because the substrings are short enough to appear inside
|
|
20
|
+
* unrelated words: `bot` alone matches `CUBOT_X30`, an Android phone. The
|
|
21
|
+
* boundary is not a guarantee against every false positive - it cannot be -
|
|
22
|
+
* but it removes the class of them that a plain `includes` walks straight
|
|
23
|
+
* into, and the flag-don't-drop rule bounds the cost of the rest.
|
|
24
|
+
*
|
|
25
|
+
* Absent and empty user agents are **not** flagged. `is_bot` records that the
|
|
26
|
+
* agent named itself as a known bot; a request that named no agent has named
|
|
27
|
+
* nothing, and flagging it would assert something the record does not say -
|
|
28
|
+
* on a column an operator filters their traffic by. It is also the wrong
|
|
29
|
+
* guess in practice: no-user-agent requests are as often a stripped-down
|
|
30
|
+
* client as a crawler.
|
|
31
|
+
*
|
|
32
|
+
* Pure data and pure functions: no clock, no `node:` builtin, no vendor SDK.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* The user-agent patterns that set `is_bot`, each with an agent it is here
|
|
36
|
+
* for. All are case-insensitive: agents spell these tokens every way
|
|
37
|
+
* (`GPTBot`, `bingbot`, `Baiduspider`). None carries the `g` flag - a global
|
|
38
|
+
* regex carries `lastIndex` between `test` calls, which would make the answer
|
|
39
|
+
* depend on how many records had been matched before it.
|
|
40
|
+
*/
|
|
41
|
+
export declare const BOT_USER_AGENT_PATTERNS: readonly RegExp[];
|
|
42
|
+
/**
|
|
43
|
+
* Whether this user agent names a known bot. Total: every input answers,
|
|
44
|
+
* including an empty user agent and a request that carried none at all, both
|
|
45
|
+
* of which answer `false` for the reason in the module comment above.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isBotUserAgent(userAgent: string | undefined): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `is_bot`: the user-agent heuristic behind step 4 of
|
|
3
|
+
* [§Record transformation](../../../../.specs/changes/merged/2026-07-26-analytics_plugin.md).
|
|
4
|
+
*
|
|
5
|
+
* A matched record is **flagged, never dropped**. `is_bot` is a column and
|
|
6
|
+
* every dashboard query takes a bot-inclusion flag, so a wrong match costs a
|
|
7
|
+
* wrong boolean that a query can ignore, while a dropped record is a page view
|
|
8
|
+
* nobody can get back. That asymmetry is the whole reason this returns a
|
|
9
|
+
* boolean rather than a verdict the transform acts on.
|
|
10
|
+
*
|
|
11
|
+
* The patterns are a deliberately small, self-declaring set rather than an
|
|
12
|
+
* exhaustive registry of crawlers. Every one of them matches text an agent
|
|
13
|
+
* puts in its own user agent to say what it is - `Googlebot/2.1`,
|
|
14
|
+
* `python-requests/2.32`, `curl/8.7.1` - so a match is evidence from the
|
|
15
|
+
* request itself, not a fingerprint. Bots that lie about their user agent are
|
|
16
|
+
* out of reach of any user-agent test and are not what this is for.
|
|
17
|
+
*
|
|
18
|
+
* Each pattern anchors on a word boundary rather than matching a bare
|
|
19
|
+
* substring, because the substrings are short enough to appear inside
|
|
20
|
+
* unrelated words: `bot` alone matches `CUBOT_X30`, an Android phone. The
|
|
21
|
+
* boundary is not a guarantee against every false positive - it cannot be -
|
|
22
|
+
* but it removes the class of them that a plain `includes` walks straight
|
|
23
|
+
* into, and the flag-don't-drop rule bounds the cost of the rest.
|
|
24
|
+
*
|
|
25
|
+
* Absent and empty user agents are **not** flagged. `is_bot` records that the
|
|
26
|
+
* agent named itself as a known bot; a request that named no agent has named
|
|
27
|
+
* nothing, and flagging it would assert something the record does not say -
|
|
28
|
+
* on a column an operator filters their traffic by. It is also the wrong
|
|
29
|
+
* guess in practice: no-user-agent requests are as often a stripped-down
|
|
30
|
+
* client as a crawler.
|
|
31
|
+
*
|
|
32
|
+
* Pure data and pure functions: no clock, no `node:` builtin, no vendor SDK.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* The user-agent patterns that set `is_bot`, each with an agent it is here
|
|
36
|
+
* for. All are case-insensitive: agents spell these tokens every way
|
|
37
|
+
* (`GPTBot`, `bingbot`, `Baiduspider`). None carries the `g` flag - a global
|
|
38
|
+
* regex carries `lastIndex` between `test` calls, which would make the answer
|
|
39
|
+
* depend on how many records had been matched before it.
|
|
40
|
+
*/
|
|
41
|
+
export const BOT_USER_AGENT_PATTERNS = [
|
|
42
|
+
/bot\b/i, // Googlebot/2.1, bingbot, GPTBot, ClaudeBot, Twitterbot, UptimeRobot
|
|
43
|
+
/crawler\b/i, // Barkrowler, MJ12Crawler - crawlers that avoid the word "bot"
|
|
44
|
+
/spider\b/i, // Baiduspider/2.0, Sogou Spider
|
|
45
|
+
/\bslurp\b/i, // Yahoo! Slurp
|
|
46
|
+
/\bscrapy\b/i, // Scrapy/2.11 (+https://scrapy.org)
|
|
47
|
+
/feedfetcher/i, // Feedfetcher-Google
|
|
48
|
+
/facebookexternalhit/i, // link-preview fetchers that carry no bot token
|
|
49
|
+
/\bheadlesschrome\b/i, // HeadlessChrome/121.0 - a real browser, driven by a script
|
|
50
|
+
/\blighthouse\b/i, // Chrome-Lighthouse, the page-audit runner
|
|
51
|
+
/\bcurl\//i, // curl/8.7.1
|
|
52
|
+
/\bwget\b/i, // Wget/1.21.4
|
|
53
|
+
/python-requests\b/i, // python-requests/2.32.3
|
|
54
|
+
/python-urllib\b/i, // Python-urllib/3.12
|
|
55
|
+
/\bgo-http-client\b/i, // Go-http-client/2.0
|
|
56
|
+
/\bokhttp\b/i, // okhttp/4.12.0
|
|
57
|
+
/\bjava\//i, // Java/21.0.2
|
|
58
|
+
/\bapache-httpclient\b/i, // Apache-HttpClient/5.3
|
|
59
|
+
/libwww-perl/i, // libwww-perl/6.77
|
|
60
|
+
];
|
|
61
|
+
/**
|
|
62
|
+
* Whether this user agent names a known bot. Total: every input answers,
|
|
63
|
+
* including an empty user agent and a request that carried none at all, both
|
|
64
|
+
* of which answer `false` for the reason in the module comment above.
|
|
65
|
+
*/
|
|
66
|
+
export function isBotUserAgent(userAgent) {
|
|
67
|
+
if (userAgent === undefined)
|
|
68
|
+
return false;
|
|
69
|
+
const candidate = userAgent.trim();
|
|
70
|
+
if (candidate === '')
|
|
71
|
+
return false;
|
|
72
|
+
return BOT_USER_AGENT_PATTERNS.some((pattern) => pattern.test(candidate));
|
|
73
|
+
}
|