cursedbelt 5.1.0 → 5.2.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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * `cursedbelt/react/engagement-beacon` — the engagement beacon's plain-JS half (no React). Lifted
3
+ * 2026-09-23 (cursedbelt 5.2.0, task 098-280's browser half) from `src/kit/engagement/beacon.client.ts`,
4
+ * identical in collections, family, music and roms (auth's differed only in a comment). The
5
+ * server half is `cursedbelt-server/engagement`.
6
+ *
7
+ * The browser half — report which PLACE the user is on, once per arrival.
8
+ *
9
+ * ── Why an explicit beacon and not the request log ──────────────────────────
10
+ * Most satellites are SPA-routed: moving from Notes' Files page to its Ask page
11
+ * fires zero navigations and, on a warm cache, zero HTTP requests. So the
12
+ * request metrics already in `metrics/` can tell you an app is used and cannot
13
+ * tell you which of its pages are — which is exactly the question the owner
14
+ * asked ("notes is used but its slideshow is not"). One small event, sent by
15
+ * the router, is the only thing that can answer it.
16
+ *
17
+ * ── It is ONE implementation, adopted; never per-app ────────────────────────
18
+ * Eleven hand-rolled beacons would be eleven different place vocabularies, and
19
+ * the console would be comparing strings that mean different things. Apps call
20
+ * `reportPlace` (or mount `useEngagementBeacon`) and send an id from their own
21
+ * route manifest; the server resolves it against that same manifest.
22
+ *
23
+ * ── What it does NOT send ───────────────────────────────────────────────────
24
+ * No path, no query, no title, no referrer, no identifiers of any kind — just
25
+ * `{ place }`. The account comes from the session cookie the request already
26
+ * carries, server-side. There is nothing in the payload that could carry a note
27
+ * id, a ROM title or a search string, which is the property that makes this
28
+ * safe to run inside private apps at all. See `policy.ts`.
29
+ *
30
+ * ── Failure is silence ──────────────────────────────────────────────────────
31
+ * Every call is fire-and-forget and swallows its error. Telemetry that can
32
+ * surface an error toast, block a route change, or reject an unhandled promise
33
+ * is telemetry that makes the app worse than having none.
34
+ *
35
+ * 🔴 …and "silence" includes the BROWSER's console, which is why the beacon
36
+ * asks whether there is a session before it reports (`SESSION_PATH` below).
37
+ * Swallowing the rejection in JS is not enough: a `fetch` that comes back 401
38
+ * leaves the browser's own *"Failed to load resource: the server responded with
39
+ * a status of 401"* in the console no matter what the caller does with the
40
+ * promise. Measured 2026-08-14 against live prod — `music` and `learn` both
41
+ * failed their signed-out route-health check on exactly this, with a correctly
42
+ * rendered sign-in card on screen and nothing else wrong. `apps/roms`' App.tsx
43
+ * already wrote the rule down for the feedback provider: *"an app that logs an
44
+ * error on its front door is an app nobody can tell apart from a broken one."*
45
+ * The recorder's 401 is deliberate and stays — `api.ts` mounts it AFTER the
46
+ * session gate on purpose, because the gate is what puts the user where
47
+ * `resolveUser` can read them — so the fix belongs on this side.
48
+ *
49
+ * It is also the semantically right answer, not merely a quieter one: the
50
+ * recorder attributes a view to an account, so a signed-out arrival could never
51
+ * have been recorded. Nothing that used to be counted stops being counted.
52
+ */
53
+
54
+ /** Where the recorder is mounted. Apps that remount it pass `basePath`. */
55
+ export const ENGAGEMENT_BASE_PATH = "/api/engagement";
56
+
57
+ /**
58
+ * The public session probe every satellite serves
59
+ * (`ssoConsumer.server.ts` → `{ authenticated, user?, accountsUrl }`).
60
+ *
61
+ * Deliberately NOT session-gated on the server, which is what makes it usable
62
+ * here: asking it costs one small same-origin GET and can never itself produce
63
+ * the console line this exists to avoid.
64
+ */
65
+ export const SESSION_PATH = "/api/auth/session";
66
+
67
+ export interface ReportPlaceOptions {
68
+ basePath?: string;
69
+ /** Injected in tests; defaults to the global. */
70
+ fetchImpl?: typeof fetch;
71
+ /** Override the session probe. Apps that remount the consumer elsewhere. */
72
+ sessionPath?: string;
73
+ /**
74
+ * Skip the session probe and report unconditionally.
75
+ *
76
+ * For a caller that ALREADY knows there is a session — a React tree below the
77
+ * authenticated branch, say — where the probe would be a second answer to a
78
+ * question already asked. Never set it from `main.tsx`, which runs before
79
+ * anything knows.
80
+ */
81
+ assumeSignedIn?: boolean;
82
+ }
83
+
84
+ /**
85
+ * The last place reported, so a router that re-renders does not re-report.
86
+ *
87
+ * A React router re-runs its effects on every state change that touches the
88
+ * route object, and a beacon per keystroke in a search box would turn one visit
89
+ * into forty views — inflating exactly the number the console reads as
90
+ * "interest". De-duplicating on the place id is what keeps a view meaning "an
91
+ * arrival".
92
+ */
93
+ let lastReported: string | null = null;
94
+
95
+ /**
96
+ * Where the browser is, as a string the server can resolve.
97
+ *
98
+ * The hash when there is one (every satellite but roms routes on it), else the
99
+ * pathname. Never the search string: `normalizePlaceId` strips one anyway, and
100
+ * not sending it in the first place means user text does not travel at all.
101
+ */
102
+ export function currentLocationPlace(): string {
103
+ const { hash, pathname } = window.location;
104
+ const raw = hash && hash !== "#" ? hash : pathname;
105
+ return raw.split("?")[0] ?? "/";
106
+ }
107
+
108
+ /**
109
+ * The answer to "is anyone signed in?", remembered for this page load.
110
+ *
111
+ * One probe per load, not one per navigation: the answer cannot change without
112
+ * a navigation that replaces the document (the SSO callback is a full page
113
+ * load), and re-asking on every hash change would trade one wasted request for
114
+ * dozens.
115
+ */
116
+ let sessionAnswer: Promise<boolean> | null = null;
117
+
118
+ /** Reset the de-duplication memory. Tests, and a sign-out. */
119
+ export function resetEngagementBeacon(): void {
120
+ lastReported = null;
121
+ sessionAnswer = null;
122
+ }
123
+
124
+ /**
125
+ * Is there a session? Silent, cached, and false on any doubt.
126
+ *
127
+ * Fails CLOSED — a probe that throws, answers non-2xx or returns a body this
128
+ * cannot read means "do not report". The cost of a false negative is one
129
+ * uncounted view; the cost of a false positive is the console line on the front
130
+ * door that this whole path exists to prevent.
131
+ */
132
+ async function hasSession(options: ReportPlaceOptions): Promise<boolean> {
133
+ const doFetch = options.fetchImpl ?? globalThis.fetch;
134
+ if (typeof doFetch !== "function") return false;
135
+ try {
136
+ const res = await doFetch(options.sessionPath ?? SESSION_PATH, {
137
+ credentials: "same-origin",
138
+ });
139
+ if (!res.ok) return false;
140
+ const body = (await res.json()) as { authenticated?: unknown } | null;
141
+ return body?.authenticated === true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Report an arrival at `place`. Returns whether anything was sent.
149
+ *
150
+ * Not awaited by callers in practice — the promise is returned so a test can
151
+ * settle it without a timer.
152
+ */
153
+ export async function reportPlace(
154
+ place: string,
155
+ options: ReportPlaceOptions = {},
156
+ ): Promise<boolean> {
157
+ if (!place || place === lastReported) return false;
158
+ const doFetch = options.fetchImpl ?? globalThis.fetch;
159
+ if (typeof doFetch !== "function") return false;
160
+
161
+ // 🔴 BEFORE the de-duplication is committed, not after. A signed-out arrival
162
+ // must leave no trace at all — recording it as `lastReported` would mean the
163
+ // view that happens once the visitor signs in and lands back on this same
164
+ // place is silently dropped as a duplicate.
165
+ if (options.assumeSignedIn !== true) {
166
+ sessionAnswer ??= hasSession(options);
167
+ if (!(await sessionAnswer)) return false;
168
+ }
169
+
170
+ if (place === lastReported) return false;
171
+ lastReported = place;
172
+ try {
173
+ await doFetch(`${options.basePath ?? ENGAGEMENT_BASE_PATH}/view`, {
174
+ method: "POST",
175
+ headers: { "content-type": "application/json" },
176
+ // Same-origin credentials: the session cookie is the only identity
177
+ // this request carries, and it is the only one it may carry.
178
+ credentials: "same-origin",
179
+ body: JSON.stringify({ place }),
180
+ keepalive: true,
181
+ });
182
+ return true;
183
+ } catch {
184
+ // See the header: telemetry never becomes the user's problem.
185
+ return false;
186
+ }
187
+ }
188
+
189
+ /** Detaches the listeners `startLocationBeacon` installed. */
190
+ export type StopBeacon = () => void;
191
+
192
+ let running: StopBeacon | null = null;
193
+
194
+ /**
195
+ * Start reporting wherever the browser is, and keep reporting as it moves.
196
+ *
197
+ * The form every app adopts, called once from `main.tsx` beside
198
+ * `initColorScheme()` — one line, no React, and no per-app mapping from a
199
+ * router's internal view name to a place id. The server resolves the LOCATION
200
+ * against that app's own `routes.manifest.json` (`policy.ts:resolvePlace`),
201
+ * which is the list the app already maintains and already gates on.
202
+ *
203
+ * `hashchange` and `popstate` between them cover both kinds of router, which is
204
+ * why BOTH are listened for when `collections` — hash-routed — only needs the
205
+ * first. The census that settled it was the previous generation's twelve apps:
206
+ * eleven hash routers and one pushState app. Keeping the second listener costs
207
+ * a no-op call, and losing it would be silent.
208
+ *
209
+ * Idempotent — a second call replaces the first rather than doubling every
210
+ * view, which matters because React 18 StrictMode mounts twice in development
211
+ * and a doubled count is a wrong number rather than a visible fault.
212
+ */
213
+ export function startLocationBeacon(options: ReportPlaceOptions = {}): StopBeacon {
214
+ running?.();
215
+ const send = (): void => {
216
+ void reportPlace(currentLocationPlace(), options);
217
+ };
218
+ send();
219
+ window.addEventListener("hashchange", send);
220
+ window.addEventListener("popstate", send);
221
+ const stop: StopBeacon = () => {
222
+ window.removeEventListener("hashchange", send);
223
+ window.removeEventListener("popstate", send);
224
+ if (running === stop) running = null;
225
+ };
226
+ running = stop;
227
+ return stop;
228
+ }
@@ -0,0 +1,13 @@
1
+ // `cursedbelt/react/engagement-beacon` — the engagement beacon: `reportPlace` and
2
+ // `startLocationBeacon` (plain JS, no React) and the React hooks over them.
3
+ export {
4
+ ENGAGEMENT_BASE_PATH,
5
+ type ReportPlaceOptions,
6
+ reportPlace,
7
+ resetEngagementBeacon,
8
+ SESSION_PATH,
9
+ type StopBeacon,
10
+ startLocationBeacon,
11
+ currentLocationPlace,
12
+ } from './beaconClient.js';
13
+ export { EngagementBeacon, useEngagementBeacon, useLocationBeacon } from './beacon.js';
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `cursedbelt/react/upload-direct` — the browser half of a resumable upload to binary-server.
3
+ *
4
+ * Neither app that carried this module (collections, family) had a test of it, so these pin the
5
+ * behaviours a 10 GB upload depends on, against a scripted `fetch`: resume skips delivered parts,
6
+ * parts are PUT with their bytes, a 403 re-mints the token, a 507 is terminal and never retried, a
7
+ * dropped part is retried, low disk only WARNS, and a lost final response is recovered by asking.
8
+ */
9
+ import { afterEach, describe, expect, test } from 'bun:test';
10
+ import { chunkCount, fetchSessionState, InsufficientStorageError, type UploadSession, uploadDirect } from './directUpload.js';
11
+
12
+ const SESSION: UploadSession = {
13
+ baseUrl: 'https://bs.test',
14
+ path: 'family/media/abc def',
15
+ token: 't1',
16
+ sid: 's',
17
+ totalChunks: 3,
18
+ chunkBytes: 4,
19
+ };
20
+ const FILE = new Blob(['aaaabbbbcc'], { type: 'image/jpeg' });
21
+
22
+ interface Call {
23
+ method: string;
24
+ url: URL;
25
+ body: string | null;
26
+ }
27
+
28
+ const realFetch = globalThis.fetch;
29
+ afterEach(() => {
30
+ globalThis.fetch = realFetch;
31
+ });
32
+
33
+ /** Install a `fetch` that records every call and answers from `answer`. */
34
+ function script(answer: (call: Call) => Response | Promise<Response>): Call[] {
35
+ const calls: Call[] = [];
36
+ globalThis.fetch = (async (input: string, init?: RequestInit) => {
37
+ const body = init?.body instanceof Blob ? await init.body.text() : null;
38
+ const call = { method: init?.method ?? 'GET', url: new URL(String(input)), body };
39
+ calls.push(call);
40
+ return answer(call);
41
+ }) as unknown as typeof fetch;
42
+ return calls;
43
+ }
44
+
45
+ const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status });
46
+ const status = (received: number[], extra: Record<string, unknown> = {}) => json(200, { received, complete: false, ...extra });
47
+
48
+ describe('chunkCount and fetchSessionState', () => {
49
+ test('parts round UP, and an empty file is still one part', () => {
50
+ expect(chunkCount(10, 4)).toBe(3);
51
+ expect(chunkCount(8, 4)).toBe(2);
52
+ expect(chunkCount(0, 4)).toBe(1);
53
+ });
54
+
55
+ test('a status the server cannot answer is a FRESH session, not a failure', async () => {
56
+ script(() => new Response('nope', { status: 500 }));
57
+ const state = await fetchSessionState(SESSION);
58
+ expect([...state.received]).toEqual([]);
59
+ expect(state.complete).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe('uploadDirect', () => {
64
+ test('resumes: sends only the parts binary-server does not hold, with their bytes, to an encoded path', async () => {
65
+ const calls = script((c) =>
66
+ c.method === 'GET' ? status([1]) : json(200, c.url.searchParams.get('ci') === '2' ? { assembled: true, size: 10, checksum: 'h', key: SESSION.path } : {}),
67
+ );
68
+ const result = await uploadDirect(FILE, SESSION, { concurrency: 1 });
69
+ const puts = calls.filter((c) => c.method === 'PUT');
70
+ expect(puts.map((c) => [c.url.searchParams.get('ci'), c.body])).toEqual([
71
+ ['0', 'aaaa'],
72
+ ['2', 'cc'],
73
+ ]);
74
+ expect(puts[0]?.url.pathname).toBe('/upload-chunk/family/media/abc%20def');
75
+ expect(result).toEqual({ key: SESSION.path, size: 10, checksum: 'h' });
76
+ });
77
+
78
+ test('an object already complete sends NOTHING', async () => {
79
+ const calls = script(() => json(200, { received: [0, 1, 2], complete: true, size: 10, checksum: 'h' }));
80
+ expect(await uploadDirect(FILE, SESSION)).toEqual({ key: SESSION.path, size: 10, checksum: 'h' });
81
+ expect(calls.filter((c) => c.method === 'PUT')).toEqual([]);
82
+ });
83
+
84
+ test('a 403 re-mints the token and the retry carries the new one', async () => {
85
+ let first = true;
86
+ const calls = script((c) => {
87
+ if (c.method === 'GET') return status([0, 1]);
88
+ if (first) {
89
+ first = false;
90
+ return new Response('expired', { status: 403 });
91
+ }
92
+ return json(200, { assembled: true, size: 10, checksum: null });
93
+ });
94
+ await uploadDirect(FILE, SESSION, { refreshToken: async () => 't2', retries: 3 });
95
+ const puts = calls.filter((c) => c.method === 'PUT').map((c) => c.url.searchParams.get('token'));
96
+ expect(puts).toEqual(['t1', 't2']);
97
+ }, 10_000);
98
+
99
+ test('🔴 a 507 is TERMINAL — one attempt, a named error, no retry storm against a full disk', async () => {
100
+ const calls = script((c) => (c.method === 'GET' ? status([0, 1]) : json(507, { freeBytes: 0 })));
101
+ await expect(uploadDirect(FILE, SESSION, { retries: 5 })).rejects.toBeInstanceOf(InsufficientStorageError);
102
+ expect(calls.filter((c) => c.method === 'PUT')).toHaveLength(1);
103
+ });
104
+
105
+ test('🔴 low disk only WARNS — the upload still runs (the owner, 2026-08-13)', async () => {
106
+ const warned: unknown[] = [];
107
+ script((c) => (c.method === 'GET' ? status([], { freeBytes: 1 }) : json(200, { assembled: c.url.searchParams.get('ci') === '2' })));
108
+ await uploadDirect(FILE, SESSION, { onLowDisk: (info) => warned.push(info) });
109
+ expect(warned).toEqual([{ freeBytes: 1, needBytes: 10 }]);
110
+ });
111
+
112
+ test('a lost final response is recovered by ASKING, not by re-sending', async () => {
113
+ let gets = 0;
114
+ const calls = script((c) => {
115
+ if (c.method === 'GET') return ++gets === 1 ? status([0, 1]) : json(200, { received: [0, 1, 2], complete: true, size: 10, checksum: 'z' });
116
+ return json(200, {}); // the part landed; the assemble result never reached us
117
+ });
118
+ expect(await uploadDirect(FILE, SESSION)).toEqual({ key: SESSION.path, size: 10, checksum: 'z' });
119
+ expect(calls.filter((c) => c.method === 'PUT')).toHaveLength(1);
120
+ });
121
+
122
+ test('progress reaches 1', async () => {
123
+ const seen: number[] = [];
124
+ script((c) => (c.method === 'GET' ? status([]) : json(200, { assembled: c.url.searchParams.get('ci') === '2' })));
125
+ await uploadDirect(FILE, SESSION, { onProgress: (f) => seen.push(f) });
126
+ expect(seen.at(-1)).toBe(1);
127
+ });
128
+ });