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.
Files changed (131) hide show
  1. package/README.md +162 -0
  2. package/dist/adapters/duckdb-ingest.d.ts +76 -0
  3. package/dist/adapters/duckdb-ingest.js +173 -0
  4. package/dist/adapters/duckdb-query.d.ts +56 -0
  5. package/dist/adapters/duckdb-query.js +80 -0
  6. package/dist/adapters/duckdb-session.d.ts +168 -0
  7. package/dist/adapters/duckdb-session.js +330 -0
  8. package/dist/app/_app/immutable/assets/0.BTQrrh5B.css +1 -0
  9. package/dist/app/_app/immutable/assets/2.CZSK3rT8.css +1 -0
  10. package/dist/app/_app/immutable/assets/BrushContext.D7c8UPey.css +1 -0
  11. package/dist/app/_app/immutable/assets/ChartAnnotations.CPxIG7Mw.css +1 -0
  12. package/dist/app/_app/immutable/assets/Circle.C5MKzgk2.css +1 -0
  13. package/dist/app/_app/immutable/assets/DefaultTooltip.C5-uctZ7.css +1 -0
  14. package/dist/app/_app/immutable/assets/Group.DV48xipa.css +1 -0
  15. package/dist/app/_app/immutable/assets/Labels.BxZ4NUVz.css +1 -0
  16. package/dist/app/_app/immutable/assets/Legend.CxnrE4Ye.css +1 -0
  17. package/dist/app/_app/immutable/assets/Line.fkmsECm9.css +1 -0
  18. package/dist/app/_app/immutable/assets/Path.CvpwNZ6g.css +1 -0
  19. package/dist/app/_app/immutable/assets/Rect.CtRaGMmQ.css +1 -0
  20. package/dist/app/_app/immutable/assets/Text.j9l35qB0.css +1 -0
  21. package/dist/app/_app/immutable/assets/TransformContext.Bs_HkpAk.css +1 -0
  22. package/dist/app/_app/immutable/assets/Voronoi.ce7atosu.css +1 -0
  23. package/dist/app/_app/immutable/chunks/-aNGNaBT.js +1 -0
  24. package/dist/app/_app/immutable/chunks/6djn-yLs.js +1 -0
  25. package/dist/app/_app/immutable/chunks/B1amyutE.js +1 -0
  26. package/dist/app/_app/immutable/chunks/B3vZDoek.js +1 -0
  27. package/dist/app/_app/immutable/chunks/B5KRA4hC.js +1 -0
  28. package/dist/app/_app/immutable/chunks/BClnVG6H.js +1 -0
  29. package/dist/app/_app/immutable/chunks/BID1NNRh.js +1 -0
  30. package/dist/app/_app/immutable/chunks/BR2LaRms.js +1 -0
  31. package/dist/app/_app/immutable/chunks/Bd1gDe3Y.js +1 -0
  32. package/dist/app/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  33. package/dist/app/_app/immutable/chunks/Bl052uUt.js +1 -0
  34. package/dist/app/_app/immutable/chunks/Bye3lL0c.js +1 -0
  35. package/dist/app/_app/immutable/chunks/C58PZtCD.js +4 -0
  36. package/dist/app/_app/immutable/chunks/CAzydqEO.js +1 -0
  37. package/dist/app/_app/immutable/chunks/CCch3uox.js +1 -0
  38. package/dist/app/_app/immutable/chunks/CIlSMUH9.js +1 -0
  39. package/dist/app/_app/immutable/chunks/CO1vUXfR.js +1 -0
  40. package/dist/app/_app/immutable/chunks/CPbD8C65.js +5 -0
  41. package/dist/app/_app/immutable/chunks/CRTcXoMo.js +1 -0
  42. package/dist/app/_app/immutable/chunks/CjjyIQAO.js +1 -0
  43. package/dist/app/_app/immutable/chunks/CuXAxjvF.js +1 -0
  44. package/dist/app/_app/immutable/chunks/CvyVA_jC.js +1 -0
  45. package/dist/app/_app/immutable/chunks/CxGCFVdy.js +1 -0
  46. package/dist/app/_app/immutable/chunks/D0Ty6LN0.js +1 -0
  47. package/dist/app/_app/immutable/chunks/D2AaQUUW.js +1 -0
  48. package/dist/app/_app/immutable/chunks/D2BnX0Uk.js +3 -0
  49. package/dist/app/_app/immutable/chunks/DJc8C0NK.js +1 -0
  50. package/dist/app/_app/immutable/chunks/DKMlMI4a.js +1 -0
  51. package/dist/app/_app/immutable/chunks/DVXZkpbf.js +1 -0
  52. package/dist/app/_app/immutable/chunks/DVt8ukQ_.js +1 -0
  53. package/dist/app/_app/immutable/chunks/DZPlYdq_.js +1 -0
  54. package/dist/app/_app/immutable/chunks/Db0q5_zr.js +1 -0
  55. package/dist/app/_app/immutable/chunks/Dfvzj6n2.js +1 -0
  56. package/dist/app/_app/immutable/chunks/Dh958be7.js +1 -0
  57. package/dist/app/_app/immutable/chunks/DjKLLdnY.js +15 -0
  58. package/dist/app/_app/immutable/chunks/Doz7YX1W.js +1 -0
  59. package/dist/app/_app/immutable/chunks/DthYhn6Y.js +2 -0
  60. package/dist/app/_app/immutable/chunks/DtuTIrAM.js +1 -0
  61. package/dist/app/_app/immutable/chunks/HclGiUj8.js +1 -0
  62. package/dist/app/_app/immutable/chunks/Hx0TNsV3.js +1 -0
  63. package/dist/app/_app/immutable/chunks/RobXhXPM.js +1 -0
  64. package/dist/app/_app/immutable/chunks/V9ZjaxiY.js +1 -0
  65. package/dist/app/_app/immutable/chunks/Y5urAfNy.js +1 -0
  66. package/dist/app/_app/immutable/chunks/caXkbKD3.js +1 -0
  67. package/dist/app/_app/immutable/chunks/devYm2ud.js +1 -0
  68. package/dist/app/_app/immutable/chunks/mtZWP0zR.js +1 -0
  69. package/dist/app/_app/immutable/chunks/vDgBJUjM.js +1 -0
  70. package/dist/app/_app/immutable/chunks/xIq_fFFM.js +1 -0
  71. package/dist/app/_app/immutable/chunks/xihTtKlq.js +1 -0
  72. package/dist/app/_app/immutable/chunks/z05MoCFz.js +1 -0
  73. package/dist/app/_app/immutable/entry/app.CLAerUAN.js +2 -0
  74. package/dist/app/_app/immutable/entry/start.D3MqnNci.js +1 -0
  75. package/dist/app/_app/immutable/nodes/0.UTMEigHJ.js +1 -0
  76. package/dist/app/_app/immutable/nodes/1.Cn4f11bT.js +1 -0
  77. package/dist/app/_app/immutable/nodes/2.B39cIcr2.js +6 -0
  78. package/dist/app/_app/version.json +1 -0
  79. package/dist/app/index.html +82 -0
  80. package/dist/aws/clients.d.ts +70 -0
  81. package/dist/aws/clients.js +52 -0
  82. package/dist/aws/errors.d.ts +41 -0
  83. package/dist/aws/errors.js +70 -0
  84. package/dist/aws/firehose.d.ts +228 -0
  85. package/dist/aws/firehose.js +347 -0
  86. package/dist/aws/glue.d.ts +103 -0
  87. package/dist/aws/glue.js +225 -0
  88. package/dist/aws/lambda.d.ts +132 -0
  89. package/dist/aws/lambda.js +339 -0
  90. package/dist/aws/s3tables.d.ts +120 -0
  91. package/dist/aws/s3tables.js +281 -0
  92. package/dist/backfill.d.ts +100 -0
  93. package/dist/backfill.js +294 -0
  94. package/dist/commands.d.ts +124 -0
  95. package/dist/commands.js +336 -0
  96. package/dist/config.d.ts +162 -0
  97. package/dist/config.js +317 -0
  98. package/dist/fixture-ingest.d.ts +49 -0
  99. package/dist/fixture-ingest.js +43 -0
  100. package/dist/fixture-query.d.ts +39 -0
  101. package/dist/fixture-query.js +70 -0
  102. package/dist/index.d.ts +35 -0
  103. package/dist/index.js +35 -0
  104. package/dist/nodes.d.ts +404 -0
  105. package/dist/nodes.js +2708 -0
  106. package/dist/paths.d.ts +45 -0
  107. package/dist/paths.js +47 -0
  108. package/dist/plugin.d.ts +102 -0
  109. package/dist/plugin.js +248 -0
  110. package/dist/ports.d.ts +113 -0
  111. package/dist/ports.js +35 -0
  112. package/dist/queries.d.ts +301 -0
  113. package/dist/queries.js +414 -0
  114. package/dist/schema.d.ts +240 -0
  115. package/dist/schema.js +154 -0
  116. package/dist/server.d.ts +150 -0
  117. package/dist/server.js +499 -0
  118. package/dist/transform/bots.d.ts +47 -0
  119. package/dist/transform/bots.js +73 -0
  120. package/dist/transform/handler.d.ts +135 -0
  121. package/dist/transform/handler.js +177 -0
  122. package/dist/transform/map-record.d.ts +110 -0
  123. package/dist/transform/map-record.js +275 -0
  124. package/dist/transform/visitor-key.d.ts +83 -0
  125. package/dist/transform/visitor-key.js +120 -0
  126. package/dist/transform-bundle/index.mjs +21456 -0
  127. package/dist/transform-bundle/transform-manifest.json +4 -0
  128. package/dist/transform-hash.d.ts +135 -0
  129. package/dist/transform-hash.js +186 -0
  130. package/dist/write-transform-manifest.mjs +365 -0
  131. 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
+ ];
@@ -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 {};