@oharness/server 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/analytics.d.ts +32 -29
  2. package/dist/analytics.js +225 -278
  3. package/dist/analytics.js.map +1 -1
  4. package/dist/i18n.js +152 -8
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/icons.js +1 -0
  7. package/dist/icons.js.map +1 -1
  8. package/dist/model-order.js +1 -0
  9. package/dist/model-order.js.map +1 -1
  10. package/dist/page/client/account.js +140 -4
  11. package/dist/page/client/account.js.map +1 -1
  12. package/dist/page/client/base.js +40 -16
  13. package/dist/page/client/base.js.map +1 -1
  14. package/dist/page/client/browser.js +1 -1
  15. package/dist/page/client/composer.js +111 -7
  16. package/dist/page/client/composer.js.map +1 -1
  17. package/dist/page/client/dialogs.js +75 -0
  18. package/dist/page/client/dialogs.js.map +1 -1
  19. package/dist/page/client/events.js +25 -2
  20. package/dist/page/client/events.js.map +1 -1
  21. package/dist/page/client/index.js +4 -0
  22. package/dist/page/client/index.js.map +1 -1
  23. package/dist/page/client/panels.js +4 -4
  24. package/dist/page/client/ripple.d.ts +13 -0
  25. package/dist/page/client/ripple.js +247 -0
  26. package/dist/page/client/ripple.js.map +1 -0
  27. package/dist/page/client/routines.js +173 -41
  28. package/dist/page/client/routines.js.map +1 -1
  29. package/dist/page/client/shell.js +200 -54
  30. package/dist/page/client/shell.js.map +1 -1
  31. package/dist/page/client/state.js +7 -2
  32. package/dist/page/client/state.js.map +1 -1
  33. package/dist/page/client/terminal.js +34 -7
  34. package/dist/page/client/terminal.js.map +1 -1
  35. package/dist/page/client/theme.d.ts +17 -0
  36. package/dist/page/client/theme.js +365 -0
  37. package/dist/page/client/theme.js.map +1 -0
  38. package/dist/page/client/transcript.js +63 -7
  39. package/dist/page/client/transcript.js.map +1 -1
  40. package/dist/page/style.js +183 -22
  41. package/dist/page/style.js.map +1 -1
  42. package/dist/page.js +4 -5
  43. package/dist/page.js.map +1 -1
  44. package/dist/prefs.d.ts +17 -0
  45. package/dist/prefs.js +5 -0
  46. package/dist/prefs.js.map +1 -1
  47. package/dist/server.js +465 -42
  48. package/dist/server.js.map +1 -1
  49. package/dist/suggest.d.ts +42 -0
  50. package/dist/suggest.js +215 -0
  51. package/dist/suggest.js.map +1 -0
  52. package/dist/terminal.d.ts +28 -2
  53. package/dist/terminal.js +45 -3
  54. package/dist/terminal.js.map +1 -1
  55. package/dist/theme-assets.d.ts +39 -0
  56. package/dist/theme-assets.js +109 -0
  57. package/dist/theme-assets.js.map +1 -0
  58. package/dist/theme-store.d.ts +50 -0
  59. package/dist/theme-store.js +124 -0
  60. package/dist/theme-store.js.map +1 -0
  61. package/dist/theme-tools.d.ts +25 -0
  62. package/dist/theme-tools.js +243 -0
  63. package/dist/theme-tools.js.map +1 -0
  64. package/dist/turn-shots.d.ts +18 -2
  65. package/dist/turn-shots.js +10 -1
  66. package/dist/turn-shots.js.map +1 -1
  67. package/dist/user-theme.d.ts +143 -0
  68. package/dist/user-theme.js +510 -0
  69. package/dist/user-theme.js.map +1 -0
  70. package/package.json +2 -2
@@ -1,47 +1,50 @@
1
1
  export interface AnalyticsState {
2
- /** True only when this build carries a vendor and the user has opted in. */
2
+ /** True only when this build carries a key and the user has opted in. */
3
3
  enabled: boolean;
4
4
  /**
5
- * Whether this build carries either vendor at all — the difference between
6
- * "off" and "absent". One switch covers both: a person consenting to usage
7
- * analytics is not being asked to arbitrate between two vendors they did not
8
- * choose, and a build that carries only one is the normal case.
5
+ * Whether this build carries a key at all — the difference between "off"
6
+ * and "absent", so Settings can leave out a switch that would do nothing.
9
7
  */
10
8
  available: boolean;
11
- /** PostHog, present only when enabled and this build carries a key. */
12
- key?: string;
13
- host?: string;
14
- options?: Record<string, unknown>;
15
- /** DataFast, present only when enabled and this build carries an id. */
16
- datafast?: {
17
- id: string;
18
- domain: string;
19
- src: string;
20
- };
21
9
  }
10
+ /** What `GET /analytics` and `POST /analytics` answer. */
11
+ export declare function analyticsState(): Promise<AnalyticsState>;
12
+ /** Called after Settings writes the pref, so the next event sees it. */
13
+ export declare function setConsent(value: boolean): void;
14
+ /** Once a day the app runs. Safe to call as often as convenient. */
15
+ export declare function heartbeat(): Promise<void>;
16
+ /** Once a day the app is used — a prompt sent, not merely a window left open. */
17
+ export declare function markUsed(): Promise<void>;
18
+ /** Hourly, so a window left open across midnight still counts the new day. */
19
+ export declare function startHeartbeat(): () => void;
22
20
  /**
23
- * What `GET /analytics` answers.
21
+ * What the page may send, reduced to what it is allowed to send.
24
22
  *
25
- * `available` is reported separately so Settings can say "this build carries
26
- * no analytics" rather than showing a switch that would do nothing — a control
27
- * that silently does nothing is worse than no control.
23
+ * The page's calls already pass only enumerable values, and this is the check
24
+ * that keeps it so: an event name, at most ten properties, each a boolean, a
25
+ * number or a short string. Anything that could be text someone typed — a long
26
+ * string, one carrying the page's token — is dropped here rather than trusted.
28
27
  */
29
- export declare function analyticsState(): Promise<AnalyticsState>;
28
+ export declare function cleanEvent(body: Record<string, unknown>): {
29
+ event: string;
30
+ props: Record<string, unknown>;
31
+ } | null;
32
+ /** A usage event. Dropped unless this build has a key and the user opted in. */
33
+ export declare function track(event: string, props?: Record<string, unknown>): Promise<void>;
34
+ /** Send what is queued. Called on a timer, and on close so a quit loses nothing. */
35
+ export declare function flushAnalytics(): Promise<void>;
30
36
  /**
31
37
  * The queue, for the document head.
32
38
  *
33
39
  * Boot fires events before anything could have decided whether to send them,
34
- * so they wait here. Nothing in this stub touches the network: it exists only
35
- * so that `track()` reads the same whether analytics end up on or off, and so
36
- * that no caller has to remember which branch it is writing in.
40
+ * so they wait here. Nothing in this stub touches the network.
37
41
  */
38
- export declare const ANALYTICS_STUB = "<script>\nwindow.__ANALYTICS_QUEUE = [];\nwindow.__TRACK = function (event, props) {\n // Bounded: a queue nothing will ever drain is a leak in a page left open all\n // day, and the events worth having are the early ones anyway.\n if (window.__ANALYTICS_QUEUE.length < 100) window.__ANALYTICS_QUEUE.push([event, props]);\n};\n</script>";
42
+ export declare const ANALYTICS_STUB = "<script>\nwindow.__ANALYTICS_QUEUE = [];\nwindow.__TRACK = function (event, props) {\n if (window.__ANALYTICS_QUEUE.length < 100) window.__ANALYTICS_QUEUE.push([event, props]);\n};\n</script>";
39
43
  /**
40
- * The loader, appended to the client script where the token already exists.
44
+ * Hands events to this server, and only once it has said they are wanted.
41
45
  *
42
- * One request to `/analytics` answers for both vendors, and one consent
43
- * decides both — so a person who has not opted in has neither vendor's key in
44
- * their page source, which is the property that made the key server-served in
45
- * the first place.
46
+ * No vendor code and no vendor address: the page talks to its own origin and
47
+ * nothing else, which is the rule page.ts states for everything in it. A build
48
+ * without a key does not even ship the forwarding, only the drain.
46
49
  */
47
50
  export declare const ANALYTICS_CLIENT: string;
package/dist/analytics.js CHANGED
@@ -1,323 +1,270 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { userPath } from "@oharness/engine/paths.ts";
5
+ import { parseJson } from "@oharness/engine/util/json.ts";
1
6
  import { readPrefs } from "./prefs.js";
2
7
  /**
3
- * Usage analytics for the browser frontend. Off until the person using it says
4
- * otherwise, and absent entirely from a build nobody published.
8
+ * Usage analytics, sent from this process rather than from the page.
5
9
  *
6
- * This is not the website. The page this feeds runs on the user's own machine,
7
- * in front of an agent that has a shell there, and its address carries the
8
- * token that grants that shell. So it is gated twice, and the two gates answer
9
- * different questions:
10
+ * The page is served from `127.0.0.1:<port>` with a token in its address, and
11
+ * the port is a new one on every launch. Analytics run inside it had two
12
+ * problems no setting could fix: the vendor's id lived in that origin's
13
+ * localStorage, so every launch was a new person and nothing about retention
14
+ * could be read; and the vendor's script read the address, so the token had to
15
+ * be stripped from every property it might turn up in. Here the page holds no
16
+ * vendor code at all. It hands an event name and a few enumerable properties to
17
+ * `POST /track`, and this module decides whether it goes anywhere.
10
18
  *
11
- * A key, which only an official build carries. It is injected at packaging
12
- * time and is not in this repository — deliberately, because a default
13
- * compiled into the source would mean every fork and every self-hosted
14
- * install reporting into the publisher's dashboard. Someone who builds this
15
- * themselves gets a build that cannot report anywhere, and gets it without
16
- * having to configure anything.
19
+ * Gated twice, as before, and the two gates answer different questions:
17
20
  *
18
- * Consent, which only the user grants. Stored in prefs, absent by default,
19
- * and revocable from Settings.
21
+ * A key, which only an official build carries. Injected at packaging time
22
+ * and not in this repository, because a default compiled into the source
23
+ * would be every fork reporting into the publisher's project. A build made
24
+ * from a checkout has no key, writes no id, and sends nothing — of either
25
+ * kind below.
20
26
  *
21
- * Neither implies the other, and the page loads nothing until both are true —
22
- * which is why the key is served from `/analytics` rather than inlined into
23
- * the HTML. A user who never opts in never has it in their page source at all.
27
+ * Consent, stored in prefs, off by default and revocable from Settings.
24
28
  *
25
- * What it sends: named events for actions taken — a panel opened, a prompt
26
- * submitted, a permission answered, a model switched — and nothing about their
27
- * content. No prompts, no file paths, no diffs, no transcript. Autocapture is
28
- * off, so a click is never recorded by the text on the button; session replay
29
- * is off; and URL properties are stripped, because the URL is where the token
30
- * is. See `track()` in page.ts for the calls themselves.
31
- */
32
- /**
33
- * The published build's key, or nothing.
34
- *
35
- * Read from the environment so that the desktop shell can set it from the file
36
- * packaging injects, and so that `oharness-web` run from a checkout can be
37
- * pointed at a project of its own for a day without editing anything.
38
- */
39
- const KEY = process.env["OHARNESS_POSTHOG_KEY"] ?? "";
40
- /** US cloud unless told otherwise. EU is https://eu.i.posthog.com. */
41
- const HOST = process.env["OHARNESS_POSTHOG_HOST"] ?? "https://us.i.posthog.com";
42
- /**
43
- * DataFast, alongside PostHog and under the same two gates.
44
- *
45
- * The site runs both for the reason written in the website's own
46
- * `lib/analytics.ts`: PostHog answers product questions, DataFast answers "how
47
- * many, from where" without anyone learning a query language. Here it answers
48
- * one question PostHog also answers — how many launches, from which country —
49
- * and it is here so that the desktop app and the site show up on the same
50
- * dashboard rather than in two places that have to be added up by hand.
29
+ * Two kinds of event, and only the first needs consent:
51
30
  *
52
- * Both shells, and gated on the one thing that actually matters: whether this
53
- * page's address carries the token.
31
+ * Usage — a panel opened, a prompt submitted, a model switched. Named events
32
+ * with enumerable properties and never their content: no prompts, file
33
+ * paths, diffs or transcripts. Sent only after the user turns it on.
54
34
  *
55
- * DataFast's script reads `window.location.href` unconditionally and has no
56
- * hook to filter what it sends — its entire configuration surface is six
57
- * `data-*` attributes, none of which redacts anything. PostHog has
58
- * `property_denylist` and `before_send`; DataFast has nothing, so the URL has
59
- * to be clean before the script is allowed to exist.
35
+ * Counting — `app_installed` once, `daily_ping` once a day the app runs,
36
+ * `daily_used` once a day it is used. They carry the install id, the version
37
+ * and the platform and nothing else, and they are what makes "how many
38
+ * people use this" a number rather than a guess from the few who opted in.
39
+ * The privacy policy names them.
60
40
  *
61
- * Today that means the desktop shell and not `oharness-web`, which is where
62
- * this landed anyway — but by measuring the thing itself rather than by asking
63
- * which shell it is. `IS_DESKTOP` would be a proxy for the real question and
64
- * would silently become wrong the day a third shell appears, or the day the
65
- * desktop window is handed a URL with a query string on it.
66
- *
67
- * Taking the token out of the browser's address bar was tried and does not
68
- * work: the document request on a reload is the one the page cannot influence,
69
- * so a scrubbed URL means F5 returns 401. The desktop shell gets around that
70
- * with `onBeforeSendHeaders`; a browser tab has no equivalent. Anyone
71
- * revisiting this should read that as the constraint it is — browser-mode
72
- * DataFast needs the credential to stop travelling in the URL, and that is an
73
- * authentication change, not an analytics one.
41
+ * Both are filed under one install id: a random UUID in the user directory,
42
+ * created the first time an official build runs, joined to nothing — not the
43
+ * account, not the machine. Desktop and `oharness-web` on the same machine
44
+ * share it, because they share that directory.
74
45
  */
75
- const DATAFAST_ID = process.env["OHARNESS_DATAFAST_ID"] ?? "";
76
- /**
77
- * The site this files under. `oharness.ai`, not the page's own hostname —
78
- * the page's hostname is `127.0.0.1`, which is nobody's site.
79
- */
80
- const DATAFAST_DOMAIN = process.env["OHARNESS_DATAFAST_DOMAIN"] ?? "oharness.ai";
81
- /** Served from DataFast's own origin. There is nothing here to proxy it through. */
82
- const DATAFAST_SRC = "https://datafa.st/js/script.js";
83
- /**
84
- * Every property PostHog would otherwise attach that carries the address of
85
- * this page.
86
- *
87
- * The address is `http://127.0.0.1:<port>/?token=<uuid>`, and that token is
88
- * the whole of the server's authentication. It must not travel, and "the
89
- * analytics vendor is trustworthy" is not the argument to rely on — a property
90
- * that is never collected cannot be leaked by anyone's later mistake.
91
- *
92
- * The `$session_entry_` family is easy to miss and was: it is filled in once
93
- * when a session starts and then rides along on every event afterwards, so
94
- * denying `$current_url` alone still sent the URL — with the token in it — on
95
- * all of them. Anything added to this list wants checking against a real
96
- * payload rather than against the documentation.
97
- */
98
- const URL_PROPERTIES = [
99
- "$current_url",
100
- "$pathname",
101
- "$host",
102
- "$referrer",
103
- "$referring_domain",
104
- "$initial_current_url",
105
- "$initial_pathname",
106
- "$initial_host",
107
- "$initial_referrer",
108
- "$initial_referring_domain",
109
- "$session_entry_url",
110
- "$session_entry_host",
111
- "$session_entry_pathname",
112
- "$session_entry_referrer",
113
- "$session_entry_referring_domain",
114
- ];
115
- const OPTIONS = {
116
- api_host: HOST,
117
- // Autocapture records clicks by the element they landed on, including the
118
- // text of the button. In this page that text is session titles, file names
119
- // and model ids the user typed. Named events only.
120
- autocapture: false,
121
- capture_pageview: false,
122
- capture_pageleave: false,
123
- rageclick: false,
124
- disable_session_recording: true,
125
- disable_surveys: true,
126
- // Nothing here reads a flag or shows a survey, so PostHog never needs to
127
- // fetch a second script — and with this set it cannot. A page that can be
128
- // reached with the token is not a page to leave an open script channel in.
129
- disable_external_dependency_loading: true,
130
- advanced_disable_feature_flags: true,
131
- // Counting installs, not people: no profile is created, and the identifier
132
- // is a random id in this browser's local storage that is never joined to
133
- // anything. Nothing in the app calls `identify`.
134
- person_profiles: "never",
135
- persistence: "localStorage",
136
- property_denylist: URL_PROPERTIES,
137
- // Someone who has asked their browser not to be tracked has answered this
138
- // question already.
139
- respect_dnt: true,
140
- };
46
+ const KEY = process.env["OHARNESS_POSTHOG_KEY"] ?? "";
47
+ /** US cloud unless told otherwise. EU is https://eu.i.posthog.com. */
48
+ const HOST = (process.env["OHARNESS_POSTHOG_HOST"] ?? "https://us.i.posthog.com").replace(/\/+$/, "");
49
+ /** Set by the desktop shell from the app bundle; a checkout reports "dev". */
50
+ const VERSION = process.env["OHARNESS_APP_VERSION"] ?? "dev";
51
+ /** Which frontend is serving: the desktop shell says so, anything else is the browser one. */
52
+ const SHELL = process.env["OHARNESS_SHELL"] ?? "web";
53
+ /** What `GET /analytics` and `POST /analytics` answer. */
54
+ export async function analyticsState() {
55
+ const available = KEY !== "";
56
+ return { enabled: available && (await consented()), available };
57
+ }
58
+ let consent;
59
+ async function consented() {
60
+ consent ??= (await readPrefs()).analytics === true;
61
+ return consent;
62
+ }
63
+ /** Called after Settings writes the pref, so the next event sees it. */
64
+ export function setConsent(value) {
65
+ consent = value;
66
+ }
67
+ const FILE = () => userPath("telemetry.json");
68
+ let identity;
69
+ function loadIdentity() {
70
+ identity ??= (async () => {
71
+ try {
72
+ const parsed = parseJson(await readFile(FILE(), "utf8"));
73
+ if (parsed.ok && typeof parsed.value?.installId === "string" && parsed.value.installId) {
74
+ return parsed.value;
75
+ }
76
+ }
77
+ catch {
78
+ // No file yet: this is the first run of an official build.
79
+ }
80
+ const created = { installId: randomUUID() };
81
+ await save(created);
82
+ // A user directory that already has prefs in it is a copy that was here
83
+ // before counting existed, updating into it — not a new install.
84
+ capture("app_installed", { existing: await exists(userPath("prefs.json")) }, created.installId);
85
+ return created;
86
+ })();
87
+ return identity;
88
+ }
89
+ let writing = Promise.resolve();
90
+ function save(telemetry) {
91
+ writing = writing.then(async () => {
92
+ try {
93
+ await mkdir(path.dirname(FILE()), { recursive: true });
94
+ await writeFile(FILE(), JSON.stringify(telemetry, null, 2) + "\n");
95
+ }
96
+ catch {
97
+ // A read-only home directory costs the count, never the app.
98
+ }
99
+ });
100
+ return writing;
101
+ }
102
+ async function exists(file) {
103
+ try {
104
+ await access(file);
105
+ return true;
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ const today = () => new Date().toISOString().slice(0, 10);
112
+ // --- counting ---------------------------------------------------------------
113
+ /** Once a day the app runs. Safe to call as often as convenient. */
114
+ export async function heartbeat() {
115
+ if (!KEY)
116
+ return;
117
+ const telemetry = await loadIdentity();
118
+ const day = today();
119
+ if (telemetry.pinged === day)
120
+ return;
121
+ telemetry.pinged = day;
122
+ await save(telemetry);
123
+ capture("daily_ping", {}, telemetry.installId);
124
+ }
125
+ /** Once a day the app is used — a prompt sent, not merely a window left open. */
126
+ export async function markUsed() {
127
+ if (!KEY)
128
+ return;
129
+ const telemetry = await loadIdentity();
130
+ const day = today();
131
+ if (telemetry.used === day)
132
+ return;
133
+ telemetry.used = day;
134
+ await save(telemetry);
135
+ capture("daily_used", {}, telemetry.installId);
136
+ }
137
+ /** Hourly, so a window left open across midnight still counts the new day. */
138
+ export function startHeartbeat() {
139
+ if (!KEY)
140
+ return () => { };
141
+ void heartbeat();
142
+ const timer = setInterval(() => void heartbeat(), 60 * 60 * 1000);
143
+ timer.unref?.();
144
+ return () => clearInterval(timer);
145
+ }
146
+ // --- usage ------------------------------------------------------------------
147
+ const EVENT = /^[a-z][a-z_]{0,39}$/;
148
+ const PROPERTY = /^[a-z][a-z_]{0,31}$/;
141
149
  /**
142
- * What `GET /analytics` answers.
150
+ * What the page may send, reduced to what it is allowed to send.
143
151
  *
144
- * `available` is reported separately so Settings can say "this build carries
145
- * no analytics" rather than showing a switch that would do nothing — a control
146
- * that silently does nothing is worse than no control.
152
+ * The page's calls already pass only enumerable values, and this is the check
153
+ * that keeps it so: an event name, at most ten properties, each a boolean, a
154
+ * number or a short string. Anything that could be text someone typed — a long
155
+ * string, one carrying the page's token — is dropped here rather than trusted.
147
156
  */
148
- export async function analyticsState() {
149
- const available = KEY !== "" || DATAFAST_ID !== "";
150
- const consented = (await readPrefs()).analytics === true;
151
- if (!available || !consented)
152
- return { enabled: false, available };
153
- // Each vendor is reported only if this build carries it. A build with one
154
- // and not the other is not a broken build — it is what a fork gets, and what
155
- // an official build gets if only one id made it into the packaging step.
156
- const state = { enabled: true, available };
157
- if (KEY !== "") {
158
- state.key = KEY;
159
- state.host = HOST;
160
- state.options = OPTIONS;
157
+ export function cleanEvent(body) {
158
+ const event = body["event"];
159
+ if (typeof event !== "string" || !EVENT.test(event))
160
+ return null;
161
+ const props = {};
162
+ const raw = body["props"];
163
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
164
+ for (const [key, value] of Object.entries(raw).slice(0, 10)) {
165
+ if (!PROPERTY.test(key))
166
+ continue;
167
+ if (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) {
168
+ props[key] = value;
169
+ }
170
+ else if (typeof value === "string" && value.length <= 80 && !value.includes("token=")) {
171
+ props[key] = value;
172
+ }
173
+ }
174
+ }
175
+ return { event, props };
176
+ }
177
+ /** A usage event. Dropped unless this build has a key and the user opted in. */
178
+ export async function track(event, props = {}) {
179
+ if (!KEY || !(await consented()))
180
+ return;
181
+ const telemetry = await loadIdentity();
182
+ capture(event, props, telemetry.installId);
183
+ }
184
+ // --- sending ----------------------------------------------------------------
185
+ const queue = [];
186
+ let flushTimer;
187
+ function capture(event, props, distinctId) {
188
+ // Bounded: a machine that is offline all week must not grow this all week.
189
+ if (queue.length >= 500)
190
+ return;
191
+ queue.push({
192
+ event,
193
+ timestamp: new Date().toISOString(),
194
+ properties: {
195
+ ...props,
196
+ distinct_id: distinctId,
197
+ app_version: VERSION,
198
+ platform: process.platform,
199
+ arch: process.arch,
200
+ shell: SHELL,
201
+ $lib: "oharness-app",
202
+ // Counting installs, not people: no profile is ever created.
203
+ $process_person_profile: false,
204
+ },
205
+ });
206
+ flushTimer ??= setTimeout(() => void flushAnalytics(), 10_000);
207
+ flushTimer.unref?.();
208
+ }
209
+ /** Send what is queued. Called on a timer, and on close so a quit loses nothing. */
210
+ export async function flushAnalytics() {
211
+ if (flushTimer)
212
+ clearTimeout(flushTimer);
213
+ flushTimer = undefined;
214
+ if (!KEY || !queue.length)
215
+ return;
216
+ const batch = queue.splice(0);
217
+ try {
218
+ await fetch(`${HOST}/batch/`, {
219
+ method: "POST",
220
+ headers: { "content-type": "application/json" },
221
+ body: JSON.stringify({ api_key: KEY, batch }),
222
+ signal: AbortSignal.timeout(5000),
223
+ });
161
224
  }
162
- if (DATAFAST_ID !== "") {
163
- state.datafast = { id: DATAFAST_ID, domain: DATAFAST_DOMAIN, src: DATAFAST_SRC };
225
+ catch {
226
+ // Offline, blocked, or PostHog is down. Analytics are never something a
227
+ // person notices while trying to work, so there is no retry and no error.
164
228
  }
165
- return state;
166
229
  }
230
+ // --- the page's half ----------------------------------------------------------
167
231
  /**
168
232
  * The queue, for the document head.
169
233
  *
170
234
  * Boot fires events before anything could have decided whether to send them,
171
- * so they wait here. Nothing in this stub touches the network: it exists only
172
- * so that `track()` reads the same whether analytics end up on or off, and so
173
- * that no caller has to remember which branch it is writing in.
235
+ * so they wait here. Nothing in this stub touches the network.
174
236
  */
175
237
  export const ANALYTICS_STUB = `<script>
176
238
  window.__ANALYTICS_QUEUE = [];
177
239
  window.__TRACK = function (event, props) {
178
- // Bounded: a queue nothing will ever drain is a leak in a page left open all
179
- // day, and the events worth having are the early ones anyway.
180
240
  if (window.__ANALYTICS_QUEUE.length < 100) window.__ANALYTICS_QUEUE.push([event, props]);
181
241
  };
182
242
  </script>`;
183
243
  /**
184
- * What a build carrying neither vendor ships instead of the loader.
244
+ * Hands events to this server, and only once it has said they are wanted.
185
245
  *
186
- * Not the loader with a disabled flag: a page that cannot report anywhere
187
- * should not carry the code for reporting, and `PAGE` should not so much as
188
- * contain either vendor's name. The suite asserts exactly that, which is how a
189
- * later change that quietly starts shipping the loader to everyone gets
190
- * caught. It still drains the queue, because `track()` calls are unconditional
191
- * and a queue nobody empties grows all day.
246
+ * No vendor code and no vendor address: the page talks to its own origin and
247
+ * nothing else, which is the rule page.ts states for everything in it. A build
248
+ * without a key does not even ship the forwarding, only the drain.
192
249
  */
193
- const NO_ANALYTICS_CLIENT = String.raw `
250
+ export const ANALYTICS_CLIENT = KEY === "" ? String.raw `
194
251
  function startAnalytics() {
195
252
  window.__TRACK = function () {};
196
253
  window.__ANALYTICS_QUEUE = [];
197
254
  }
198
- `;
199
- /**
200
- * The DataFast tag, or a no-op where this build carries no id.
201
- *
202
- * Two conditions, and the second is the load-bearing one. `config.datafast`
203
- * says this build carries an id and the user has consented; the `href` check
204
- * says this page's address has no token in it. See the comment on
205
- * `DATAFAST_ID` for why the second cannot be relaxed — the script reads
206
- * `location.href` and offers nothing to filter it with.
207
- *
208
- * Checked against the live `href` rather than against which shell this is, so
209
- * that a regression in the token scrub turns this off rather than turning it
210
- * into a leak. It is the last gate before a third party sees the address, and
211
- * the only one that cannot be wrong about what the address contains.
212
- *
213
- * `data-allow-localhost` because the page is served from 127.0.0.1 and the
214
- * script drops every event from a loopback host unless told not to. That flag
215
- * is the only reason this reports at all, and it is also the reason the check
216
- * above has to hold.
217
- */
218
- const DATAFAST_CLIENT = DATAFAST_ID === "" ? String.raw `
219
- function startDataFast() {}
220
- ` : String.raw `
221
- function startDataFast(config) {
222
- if (!config.datafast) return;
223
- if (location.href.indexOf("token=") !== -1) return;
224
- try {
225
- const script = document.createElement("script");
226
- script.src = config.datafast.src;
227
- script.defer = true;
228
- script.setAttribute("data-website-id", config.datafast.id);
229
- script.setAttribute("data-domain", config.datafast.domain);
230
- script.setAttribute("data-allow-localhost", "true");
231
- document.head.appendChild(script);
232
- } catch (err) {
233
- // Analytics that fail must never be something a person notices while
234
- // trying to work. There is nothing to retry and nothing to report.
235
- }
236
- }
237
- `;
238
- /**
239
- * PostHog, or a no-op where this build carries no key.
240
- *
241
- * `array.js` loaded on its own rather than PostHog's inlined snippet: the
242
- * snippet is a minified blob whose job is to queue calls made before the
243
- * library arrives, and this does that job in code that can be read — which is
244
- * the bar for anything that runs in this page.
245
- *
246
- * This is the half that drains the queue in every branch, because `track()` is
247
- * PostHog's alone — DataFast counts launches and has no named events to send.
248
- *
249
- * A failure at any point is silence. Analytics that cannot reach their server
250
- * must never be something a person notices while trying to work.
251
- */
252
- const POSTHOG_CLIENT = KEY === "" ? String.raw `
253
- function startPostHog() {
254
- window.__TRACK = function () {};
255
- window.__ANALYTICS_QUEUE = [];
256
- }
257
255
  ` : String.raw `
258
- function startPostHog(config) {
259
- if (!config.key) {
260
- window.__TRACK = function () {};
261
- window.__ANALYTICS_QUEUE = [];
262
- return;
263
- }
264
-
265
- const script = document.createElement("script");
266
- script.src = config.host + "/static/array.js";
267
- script.async = true;
268
- script.onload = function () {
269
- try {
270
- window.posthog.init(config.key, Object.assign({}, config.options, {
271
- // The denylist names the properties known to carry this page's address
272
- // today. This catches the ones a future version of the library adds:
273
- // whatever the property is called, if its value contains the token
274
- // then it is the URL, and it does not leave.
275
- before_send: function (event) {
276
- const props = event && event.properties;
277
- for (const key in props) {
278
- if (typeof props[key] === "string" && props[key].indexOf("token=") !== -1) {
279
- delete props[key];
280
- }
281
- }
282
- return event;
283
- },
284
- }));
285
- window.__TRACK = function (event, props) { window.posthog.capture(event, props); };
286
- for (const queued of window.__ANALYTICS_QUEUE) window.__TRACK(queued[0], queued[1]);
287
- } catch (err) {
288
- window.__TRACK = function () {};
289
- }
290
- window.__ANALYTICS_QUEUE = [];
291
- };
292
- script.onerror = function () {
293
- window.__TRACK = function () {};
294
- window.__ANALYTICS_QUEUE = [];
295
- };
296
- document.head.appendChild(script);
297
- }
298
- `;
299
- /**
300
- * The loader, appended to the client script where the token already exists.
301
- *
302
- * One request to `/analytics` answers for both vendors, and one consent
303
- * decides both — so a person who has not opted in has neither vendor's key in
304
- * their page source, which is the property that made the key server-served in
305
- * the first place.
306
- */
307
- export const ANALYTICS_CLIENT = KEY === "" && DATAFAST_ID === "" ? NO_ANALYTICS_CLIENT : String.raw `
308
- ${DATAFAST_CLIENT}
309
- ${POSTHOG_CLIENT}
310
256
  async function startAnalytics() {
311
257
  const config = await getJson("/analytics");
258
+ const queued = window.__ANALYTICS_QUEUE || [];
259
+ window.__ANALYTICS_QUEUE = [];
312
260
  if (!config || config.error || !config.enabled) {
313
- // Off, or this build carries nothing. Drop the queue rather than hold it:
314
- // the answer will not change without a reload.
315
261
  window.__TRACK = function () {};
316
- window.__ANALYTICS_QUEUE = [];
317
262
  return;
318
263
  }
319
- startDataFast(config);
320
- startPostHog(config);
264
+ window.__TRACK = function (event, props) {
265
+ void api("/track", { event: event, props: props || {} });
266
+ };
267
+ for (const entry of queued) window.__TRACK(entry[0], entry[1]);
321
268
  }
322
269
  `;
323
270
  //# sourceMappingURL=analytics.js.map