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/schema.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single home for the `page_views` Iceberg table: its column set, its
|
|
3
|
+
* `day` partition, the CloudFront standard-logging (v2) fields the delivery
|
|
4
|
+
* selects, and the mapping between the two. The transform Lambda, the table
|
|
5
|
+
* node and the delivery node all read these constants instead of restating
|
|
6
|
+
* them - see [§Table schema](../../../.specs/changes/merged/2026-07-26-analytics_plugin.md).
|
|
7
|
+
*
|
|
8
|
+
* Why this file is worth being careful with: Firehose matches incoming JSON
|
|
9
|
+
* keys to Iceberg column names **exactly** and silently discards any field
|
|
10
|
+
* that does not match a column - no error, no dead-letter record, nothing
|
|
11
|
+
* that shows up in a log. A typo here does not fail loudly; it fills a
|
|
12
|
+
* column with nulls forever, and nothing points back at this file as the
|
|
13
|
+
* cause. Column names are lowercase throughout - an S3 Tables catalog
|
|
14
|
+
* requirement, not a style choice.
|
|
15
|
+
*
|
|
16
|
+
* `cs(Cookie)` and `x-forwarded-for` carry personal data and have no
|
|
17
|
+
* analytic use, so they are never selected: they never reach Firehose, the
|
|
18
|
+
* transform, or the table. This governs the analytics delivery only. The
|
|
19
|
+
* site's existing CloudWatch delivery (`packages/cli/src/nodes.ts`'s
|
|
20
|
+
* `logDeliveryNode`, wired through `packages/core/src/aws/logs.ts`'s
|
|
21
|
+
* `createDelivery`) is created with no `recordFields`, so AWS's default
|
|
22
|
+
* field list - which includes both excluded fields - still applies to that
|
|
23
|
+
* copy. Narrowing that is a separate change to the site's node, not this
|
|
24
|
+
* one; do not "fix" that inconsistency here.
|
|
25
|
+
*
|
|
26
|
+
* Field names are verified against AWS's CloudFront standard-logging (v2)
|
|
27
|
+
* documentation (docs.aws.amazon.com/AmazonCloudFront, "Configure standard
|
|
28
|
+
* logging (v2)" and "Standard logging reference", current as of 2026-08-30):
|
|
29
|
+
* the `recordFields` the CreateDelivery API accepts, and the field
|
|
30
|
+
* descriptions in the log-file-field reference.
|
|
31
|
+
*
|
|
32
|
+
* Pure data and pure functions only: no `node:` builtin, no vendor SDK, no
|
|
33
|
+
* `fetch`.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* The `page_views` table, in the order the spec's `PageView` `$defs` block
|
|
37
|
+
* lists it. `required` is `true` exactly for the spec's `PageView.required`
|
|
38
|
+
* set (`event_time`, `day`, `host`, `uri`, `status`); every other column may
|
|
39
|
+
* be null - CloudFront itself writes `-` for several of these when a request
|
|
40
|
+
* has nothing to say (no referrer, no query string, and so on).
|
|
41
|
+
*/
|
|
42
|
+
export const PAGE_VIEWS_COLUMNS = [
|
|
43
|
+
{ name: 'event_time', icebergType: 'timestamp', required: true },
|
|
44
|
+
{ name: 'day', icebergType: 'date', required: true },
|
|
45
|
+
{ name: 'host', icebergType: 'string', required: true },
|
|
46
|
+
{ name: 'uri', icebergType: 'string', required: true },
|
|
47
|
+
{ name: 'query', icebergType: 'string', required: false },
|
|
48
|
+
{ name: 'method', icebergType: 'string', required: false },
|
|
49
|
+
{ name: 'status', icebergType: 'int', required: true },
|
|
50
|
+
{ name: 'referrer', icebergType: 'string', required: false },
|
|
51
|
+
{ name: 'user_agent', icebergType: 'string', required: false },
|
|
52
|
+
{ name: 'country', icebergType: 'string', required: false },
|
|
53
|
+
{ name: 'asn', icebergType: 'string', required: false },
|
|
54
|
+
{ name: 'edge_location', icebergType: 'string', required: false },
|
|
55
|
+
{ name: 'result_type', icebergType: 'string', required: false },
|
|
56
|
+
{ name: 'bytes_sent', icebergType: 'long', required: false },
|
|
57
|
+
{ name: 'time_taken', icebergType: 'double', required: false },
|
|
58
|
+
{ name: 'content_type', icebergType: 'string', required: false },
|
|
59
|
+
{ name: 'protocol', icebergType: 'string', required: false },
|
|
60
|
+
{ name: 'request_id', icebergType: 'string', required: false },
|
|
61
|
+
{ name: 'visitor_key', icebergType: 'string', required: false },
|
|
62
|
+
{ name: 'is_bot', icebergType: 'boolean', required: false },
|
|
63
|
+
];
|
|
64
|
+
/** `page_views` is partitioned by this column. */
|
|
65
|
+
export const PAGE_VIEWS_PARTITION_COLUMN = 'day';
|
|
66
|
+
/**
|
|
67
|
+
* The CloudFront viewer-IP field. Selected so the transform can derive
|
|
68
|
+
* `visitor_key` from it (IP + user agent + a secret daily salt); the raw
|
|
69
|
+
* value is discarded by the transform and never written to any column, so
|
|
70
|
+
* it has no entry in `FIELD_TO_COLUMN`.
|
|
71
|
+
*/
|
|
72
|
+
export const VIEWER_IP_FIELD = 'c-ip';
|
|
73
|
+
/**
|
|
74
|
+
* The CloudFront millisecond-epoch timestamp field. Selected so the
|
|
75
|
+
* transform can derive both `event_time` and the `day` partition from it;
|
|
76
|
+
* because it feeds two columns rather than renaming into one, it has no
|
|
77
|
+
* entry in `FIELD_TO_COLUMN` either. Exported for its one consumer,
|
|
78
|
+
* `transform/map-record.ts`, which reads the field off the record and must
|
|
79
|
+
* not spell its name a second time - the field name is not a valid column
|
|
80
|
+
* name, so a divergence between the two spellings would silently stop
|
|
81
|
+
* filling `event_time` and `day`.
|
|
82
|
+
*/
|
|
83
|
+
export const TIMESTAMP_MS_FIELD = 'timestamp(ms)';
|
|
84
|
+
/**
|
|
85
|
+
* Selected CloudFront fields that feed a `DERIVED_COLUMNS` entry instead of
|
|
86
|
+
* being renamed 1:1 into a column of their own.
|
|
87
|
+
*/
|
|
88
|
+
export const DERIVATION_ONLY_FIELDS = [VIEWER_IP_FIELD, TIMESTAMP_MS_FIELD];
|
|
89
|
+
/**
|
|
90
|
+
* The CloudFront standard-logging (v2) field name each `page_views` column
|
|
91
|
+
* is filled from, a straight rename with no other transformation. Field
|
|
92
|
+
* names and meanings are as AWS documents them under "Standard logging
|
|
93
|
+
* reference":
|
|
94
|
+
*
|
|
95
|
+
* - `x-host-header` (not `cs(Host)`) is the Host header the viewer actually
|
|
96
|
+
* sent - the alternate domain name (CNAME) when the site has one, and the
|
|
97
|
+
* distribution's own domain otherwise. `cs(Host)` always reports the raw
|
|
98
|
+
* CloudFront distribution domain regardless of what the viewer requested,
|
|
99
|
+
* which would make `host` a constant for every custom-domain site.
|
|
100
|
+
* - `x-edge-result-type` (not `x-edge-response-result-type` or
|
|
101
|
+
* `x-edge-detailed-result-type`) is the standard hit/miss/error
|
|
102
|
+
* classification after the response finished sending - the field AWS's own
|
|
103
|
+
* sample cache-hit-ratio queries group by. The other two exist for
|
|
104
|
+
* diagnosing mid-response client disconnects and origin-error detail,
|
|
105
|
+
* which this table does not carry a column for.
|
|
106
|
+
* - `sc-bytes` (not `cs-bytes` or `sc-content-len`) is the total bytes
|
|
107
|
+
* CloudFront sent to the viewer, matching `bytes_sent`; `cs-bytes` is the
|
|
108
|
+
* viewer's request size and `sc-content-len` is only the `Content-Length`
|
|
109
|
+
* header value.
|
|
110
|
+
*/
|
|
111
|
+
export const FIELD_TO_COLUMN = {
|
|
112
|
+
'x-host-header': 'host',
|
|
113
|
+
'cs-uri-stem': 'uri',
|
|
114
|
+
'cs-uri-query': 'query',
|
|
115
|
+
'cs-method': 'method',
|
|
116
|
+
'sc-status': 'status',
|
|
117
|
+
'cs(Referer)': 'referrer',
|
|
118
|
+
'cs(User-Agent)': 'user_agent',
|
|
119
|
+
'c-country': 'country',
|
|
120
|
+
asn: 'asn',
|
|
121
|
+
'x-edge-location': 'edge_location',
|
|
122
|
+
'x-edge-result-type': 'result_type',
|
|
123
|
+
'sc-bytes': 'bytes_sent',
|
|
124
|
+
'time-taken': 'time_taken',
|
|
125
|
+
'sc-content-type': 'content_type',
|
|
126
|
+
'cs-protocol': 'protocol',
|
|
127
|
+
'x-edge-request-id': 'request_id',
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* The CloudFront fields the analytics delivery selects: every
|
|
131
|
+
* `FIELD_TO_COLUMN` key plus the two derivation-only inputs. `cs(Cookie)`
|
|
132
|
+
* and `x-forwarded-for` are deliberately absent - see the module doc comment.
|
|
133
|
+
*/
|
|
134
|
+
export const CLOUDFRONT_RECORD_FIELDS = [
|
|
135
|
+
...Object.keys(FIELD_TO_COLUMN),
|
|
136
|
+
...DERIVATION_ONLY_FIELDS,
|
|
137
|
+
];
|
|
138
|
+
/**
|
|
139
|
+
* The four `page_views` columns no CloudFront field maps to 1:1 - each is
|
|
140
|
+
* computed by the transform from one or more selected fields rather than
|
|
141
|
+
* renamed from a single one:
|
|
142
|
+
*
|
|
143
|
+
* - `event_time` and `day` are both derived from `timestamp(ms)`.
|
|
144
|
+
* - `visitor_key` is a salted hash of `c-ip`, `cs(User-Agent)` and a secret
|
|
145
|
+
* daily salt - no single field determines it.
|
|
146
|
+
* - `is_bot` is a user-agent match against `cs(User-Agent)`, which already
|
|
147
|
+
* has its own entry in `FIELD_TO_COLUMN` (-> `user_agent`).
|
|
148
|
+
*/
|
|
149
|
+
export const DERIVED_COLUMNS = [
|
|
150
|
+
'event_time',
|
|
151
|
+
'day',
|
|
152
|
+
'visitor_key',
|
|
153
|
+
'is_bot',
|
|
154
|
+
];
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
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 { type FileSystem } from 'blogwright-core';
|
|
79
|
+
import type { AnalyticsConfig } from './config.js';
|
|
80
|
+
import type { AnalyticsQuery } from './ports.js';
|
|
81
|
+
/** What {@link createDashboardServer} is built from. */
|
|
82
|
+
export interface DashboardServerOptions {
|
|
83
|
+
/**
|
|
84
|
+
* The port every route reaches the table through. The *only* surface this
|
|
85
|
+
* module touches below itself: `run(name, params)` and nothing wider, so
|
|
86
|
+
* there is no statement to supply and no write path to reach. The dashboard
|
|
87
|
+
* command constructs the DuckDB adapter and passes it here; a test passes
|
|
88
|
+
* the fixture-backed fake from `fixture-query.ts`.
|
|
89
|
+
*/
|
|
90
|
+
readonly query: AnalyticsQuery;
|
|
91
|
+
/**
|
|
92
|
+
* The validated `analytics` block, taken for its `bots` mode alone - what
|
|
93
|
+
* {@link prepareQuery} needs to know whether an *absent* `includeBots`
|
|
94
|
+
* counts bot rows. Passing it, rather than picking a mode here, is what
|
|
95
|
+
* keeps the default in the one place task 44 put it.
|
|
96
|
+
*/
|
|
97
|
+
readonly config: Pick<AnalyticsConfig, 'bots'>;
|
|
98
|
+
/**
|
|
99
|
+
* The port to bind, resolved from `config.analytics.dashboard.port`. Never
|
|
100
|
+
* a literal at the call site and never `process.env`: task 44 owns the
|
|
101
|
+
* default and its bounds.
|
|
102
|
+
*/
|
|
103
|
+
readonly port: number;
|
|
104
|
+
/**
|
|
105
|
+
* The directory of prebuilt static files to serve - task 57's `dist/app`.
|
|
106
|
+
* Absent until that task lands, which is a 503 naming this directory rather
|
|
107
|
+
* than a stack trace.
|
|
108
|
+
*/
|
|
109
|
+
readonly appDir: string;
|
|
110
|
+
/**
|
|
111
|
+
* How `appDir` is read. The `FileSystem` port, not `node:fs`: this package
|
|
112
|
+
* imports no filesystem builtin anywhere, so the listing and the reads go
|
|
113
|
+
* through the port the CLI already hands every plugin (`ctx.ports.fs`), and
|
|
114
|
+
* a test serves an application that never touched a disk.
|
|
115
|
+
*/
|
|
116
|
+
readonly fs: FileSystem;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Where the listener actually ended up, read back off the socket rather than
|
|
120
|
+
* assumed. Not exported, following `ports.ts`' `QueryValue`: no consumer needs
|
|
121
|
+
* the type by name - one reaches it as `DashboardServer['address']` - and an
|
|
122
|
+
* export nothing names is what `pnpm knip` reports. Export it when a real
|
|
123
|
+
* consumer needs it by name.
|
|
124
|
+
*/
|
|
125
|
+
interface DashboardAddress {
|
|
126
|
+
/** The bound host. Always {@link LOOPBACK_ADDRESS}; asserted, not trusted. */
|
|
127
|
+
readonly host: string;
|
|
128
|
+
/** The bound port - the one that was asked for, since this server never binds port 0 itself. */
|
|
129
|
+
readonly port: number;
|
|
130
|
+
}
|
|
131
|
+
/** A running dashboard server. */
|
|
132
|
+
export interface DashboardServer {
|
|
133
|
+
/** The address the listener is actually bound to. */
|
|
134
|
+
readonly address: DashboardAddress;
|
|
135
|
+
/** The URL to open, built from {@link address} rather than from the requested port. */
|
|
136
|
+
readonly url: string;
|
|
137
|
+
/**
|
|
138
|
+
* Stop listening and release the port. Resolves once the listener is closed
|
|
139
|
+
* and every open connection has been destroyed, so a caller that awaits it
|
|
140
|
+
* can bind the same port again immediately. Idempotent.
|
|
141
|
+
*/
|
|
142
|
+
close(): Promise<void>;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Start the dashboard's HTTP listener on {@link LOOPBACK_ADDRESS}. Resolves
|
|
146
|
+
* once the socket is bound, with the address read back off it, so a caller
|
|
147
|
+
* prints where the server *is* rather than where it was asked to be.
|
|
148
|
+
*/
|
|
149
|
+
export declare function createDashboardServer(opts: DashboardServerOptions): Promise<DashboardServer>;
|
|
150
|
+
export {};
|