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.
- package/dist/react/engagement/beacon.d.ts +38 -0
- package/dist/react/engagement/beacon.d.ts.map +1 -0
- package/dist/react/engagement/beacon.js +62 -0
- package/dist/react/engagement/beacon.js.map +1 -0
- package/dist/react/engagement/beaconClient.d.ts +119 -0
- package/dist/react/engagement/beaconClient.d.ts.map +1 -0
- package/dist/react/engagement/beaconClient.js +203 -0
- package/dist/react/engagement/beaconClient.js.map +1 -0
- package/dist/react/engagement/index.d.ts +3 -0
- package/dist/react/engagement/index.d.ts.map +1 -0
- package/dist/react/engagement/index.js +5 -0
- package/dist/react/engagement/index.js.map +1 -0
- package/dist/react/lib/directUpload.d.ts +130 -0
- package/dist/react/lib/directUpload.d.ts.map +1 -0
- package/dist/react/lib/directUpload.js +228 -0
- package/dist/react/lib/directUpload.js.map +1 -0
- package/dist/styles-areas/auth.css +2 -2
- package/dist/styles-areas/core.css +2 -2
- package/package.json +13 -1
- package/scripts/checkAreaStyles.spec.ts +23 -0
- package/scripts/checkAreaStyles.ts +20 -1
- package/scripts/styleAreas.ts +2 -0
- package/src/react/engagement/beacon.tsx +63 -0
- package/src/react/engagement/beaconClient.spec.ts +138 -0
- package/src/react/engagement/beaconClient.ts +228 -0
- package/src/react/engagement/index.ts +13 -0
- package/src/react/lib/directUpload.spec.ts +128 -0
- package/src/react/lib/directUpload.ts +334 -0
- package/src/styles-areas/auth.css +2 -2
- package/src/styles-areas/core.css +2 -2
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt/react/upload-direct` — browser-side resumable upload, straight from the file input
|
|
3
|
+
* to binary-server.
|
|
4
|
+
*
|
|
5
|
+
* Lifted 2026-09-23 (cursedbelt 5.2.0, task 056-343's browser half) from `src/kit/directUpload.ts`,
|
|
6
|
+
* identical in collections and family. The server half — minting the session — is
|
|
7
|
+
* `cursedbelt-server/binary-store/upload-session`'s `createUploadSession`.
|
|
8
|
+
*
|
|
9
|
+
* 🔴 THIS FILE RUNS IN A BROWSER. It must never import `node:*`, `binaryStore.ts`, or
|
|
10
|
+
* anything that reaches a private key — see {@link UploadSession}. Keep it dependency-free.
|
|
11
|
+
*
|
|
12
|
+
* ## Why the bytes skip the app server
|
|
13
|
+
*
|
|
14
|
+
* Every satellite's upload route reads the whole body (`formData()` → `arrayBuffer()`) before
|
|
15
|
+
* handing it to binary-server, inside a unit capped at `MemoryMax=300M` on a shared t3.micro
|
|
16
|
+
* that also runs nginx and every other app. That is why the fleet's upload ceiling is 32 MB
|
|
17
|
+
* and why raising the number was never the fix: a 10 GB video does not fit in the process, in
|
|
18
|
+
* the nginx body limit, or in the Cloudflare Tunnel's ~100 MB request wall.
|
|
19
|
+
*
|
|
20
|
+
* So a large file does not go through the app server at all. The app server mints a session
|
|
21
|
+
* token naming exactly one destination key; the browser PUTs bounded parts directly to
|
|
22
|
+
* binary-server, which assembles them; the app server is told afterwards and records the
|
|
23
|
+
* metadata. No byte of video touches the EC2 box, which is the owner's standing rule for
|
|
24
|
+
* video (2026-08-12) and also the only shape that survives the memory limit.
|
|
25
|
+
*
|
|
26
|
+
* ## What makes it survive 10 GB
|
|
27
|
+
*
|
|
28
|
+
* - **Resume.** `GET /upload-chunk` reports which parts already landed, so an upload that
|
|
29
|
+
* dies at 90% resumes instead of restarting. That answer comes from the parts on disk, so
|
|
30
|
+
* it survives a binary-server restart too.
|
|
31
|
+
* - **Bounded concurrency.** A few parts in flight keeps a home upstream saturated without
|
|
32
|
+
* putting the whole file in memory. Only one part's bytes are ever read at a time per slot.
|
|
33
|
+
* - **Per-part retry.** A dropped part costs one part, not the upload.
|
|
34
|
+
* - **Slicing, not reading.** `Blob.slice` is lazy — the 10 GB file is never materialized.
|
|
35
|
+
*/
|
|
36
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
37
|
+
/** Split `[0, total)` into `chunkBytes`-sized part indices. */
|
|
38
|
+
export const chunkCount = (totalBytes, chunkBytes) => Math.max(1, Math.ceil(totalBytes / chunkBytes));
|
|
39
|
+
const chunkUrl = (s, token, ci) => `${s.baseUrl}/upload-chunk/${s.path
|
|
40
|
+
.split("/")
|
|
41
|
+
.map(encodeURIComponent)
|
|
42
|
+
.join("/")}?token=${encodeURIComponent(token)}&ci=${ci}`;
|
|
43
|
+
const statusUrl = (s, token) => `${s.baseUrl}/upload-chunk/${s.path
|
|
44
|
+
.split("/")
|
|
45
|
+
.map(encodeURIComponent)
|
|
46
|
+
.join("/")}?token=${encodeURIComponent(token)}`;
|
|
47
|
+
/** Which parts binary-server already holds, plus whether the object is already complete.
|
|
48
|
+
* A fresh session answers `{ received: [], complete: false }`, so callers need no special
|
|
49
|
+
* case for "first attempt". */
|
|
50
|
+
export async function fetchSessionState(session, token = session.token, signal) {
|
|
51
|
+
const res = await fetch(statusUrl(session, token), { signal });
|
|
52
|
+
if (!res.ok)
|
|
53
|
+
return {
|
|
54
|
+
received: new Set(),
|
|
55
|
+
complete: false,
|
|
56
|
+
size: null,
|
|
57
|
+
checksum: null,
|
|
58
|
+
freeBytes: null,
|
|
59
|
+
};
|
|
60
|
+
const body = (await res.json());
|
|
61
|
+
return {
|
|
62
|
+
received: new Set(body.received ?? []),
|
|
63
|
+
complete: Boolean(body.complete),
|
|
64
|
+
size: body.size ?? null,
|
|
65
|
+
checksum: body.checksum ?? null,
|
|
66
|
+
freeBytes: body.freeBytes ?? null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Thrown when binary-server has no room for the file. Distinct from a transport failure so a
|
|
70
|
+
* UI can say "the server is full" — which is actionable — rather than "upload failed", which
|
|
71
|
+
* invites the person to retry forever against a disk that will never have space. */
|
|
72
|
+
export class InsufficientStorageError extends Error {
|
|
73
|
+
needBytes;
|
|
74
|
+
freeBytes;
|
|
75
|
+
overridable;
|
|
76
|
+
constructor(needBytes, freeBytes,
|
|
77
|
+
/**
|
|
78
|
+
* 🔴 Vestigial since 2026-08-13 and kept only so an older deployed binary-server can
|
|
79
|
+
* still be understood: THIS service no longer refuses an upload for disk space at all,
|
|
80
|
+
* so nothing here should be producing an overridable refusal any more. If you see one,
|
|
81
|
+
* the origin is running a build from before that change.
|
|
82
|
+
*/
|
|
83
|
+
overridable = false) {
|
|
84
|
+
super(freeBytes === null
|
|
85
|
+
? "binary-server is out of space for this upload"
|
|
86
|
+
: `binary-server has ${(freeBytes / 1024 ** 3).toFixed(1)} GB free, which is not enough for a ${(needBytes / 1024 ** 3).toFixed(1)} GB upload`);
|
|
87
|
+
this.needBytes = needBytes;
|
|
88
|
+
this.freeBytes = freeBytes;
|
|
89
|
+
this.overridable = overridable;
|
|
90
|
+
this.name = "InsufficientStorageError";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Upload `file` to binary-server in parts, resuming anything already delivered.
|
|
95
|
+
*
|
|
96
|
+
* Resolves once every part has landed and bs has assembled the object. The caller then tells
|
|
97
|
+
* its own app server to record the metadata — see each app's `/complete` route.
|
|
98
|
+
*/
|
|
99
|
+
export async function uploadDirect(file, session, opts = {}) {
|
|
100
|
+
const { onProgress, signal, refreshToken } = opts;
|
|
101
|
+
const concurrency = Math.max(1, opts.concurrency ?? 3);
|
|
102
|
+
const retries = Math.max(1, opts.retries ?? 5);
|
|
103
|
+
const { onLowDisk } = opts;
|
|
104
|
+
const total = session.totalChunks;
|
|
105
|
+
// A token can expire mid-upload on a slow line; `refreshToken` swaps in a new one and
|
|
106
|
+
// every subsequent request picks it up.
|
|
107
|
+
let token = session.token;
|
|
108
|
+
const prior = await fetchSessionState(session, token, signal);
|
|
109
|
+
if (prior.complete) {
|
|
110
|
+
return {
|
|
111
|
+
key: session.path,
|
|
112
|
+
size: prior.size ?? file.size,
|
|
113
|
+
checksum: prior.checksum,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Bail before sending anything if the file plainly cannot land. bs keeps a reserve so a
|
|
117
|
+
// full disk never takes the Mac down, and it reports free space on the status call above —
|
|
118
|
+
// so an upload with nowhere to go costs one request instead of gigabytes of transfer.
|
|
119
|
+
const remainingBytes = file.size - prior.received.size * session.chunkBytes;
|
|
120
|
+
/*
|
|
121
|
+
* 🔴 NO PREFLIGHT REFUSAL. Owner instruction, 2026-08-13: *"Remove all blockers for space
|
|
122
|
+
* and only allow warning me. I'll manage space myself beyond that."*
|
|
123
|
+
*
|
|
124
|
+
* This used to throw when the remaining bytes exceeded the reported free space. It is gone
|
|
125
|
+
* rather than softened because a client-side estimate is the WORST place to make this
|
|
126
|
+
* call: `remainingBytes` counts every part not yet delivered, `freeBytes` was read before
|
|
127
|
+
* the transfer began, and neither accounts for what the run itself frees or the owner
|
|
128
|
+
* clears while it goes. binary-server no longer refuses either; the real limit is ENOSPC
|
|
129
|
+
* from the filesystem, which is about the actual write rather than a projection.
|
|
130
|
+
*
|
|
131
|
+
* `onLowDisk` is the replacement, and it only WARNS.
|
|
132
|
+
*/
|
|
133
|
+
if (prior.freeBytes !== null && remainingBytes > prior.freeBytes) {
|
|
134
|
+
onLowDisk?.({ freeBytes: prior.freeBytes, needBytes: remainingBytes });
|
|
135
|
+
}
|
|
136
|
+
const pending = Array.from({ length: total }, (_, i) => i).filter((i) => !prior.received.has(i));
|
|
137
|
+
let done = total - pending.length;
|
|
138
|
+
let assembled = null;
|
|
139
|
+
const report = () => onProgress?.(total === 0 ? 1 : done / total, done * session.chunkBytes, file.size);
|
|
140
|
+
report();
|
|
141
|
+
const sendOne = async (ci) => {
|
|
142
|
+
const start = ci * session.chunkBytes;
|
|
143
|
+
// Lazy view onto the file — nothing is read until fetch streams it.
|
|
144
|
+
const part = file.slice(start, Math.min(start + session.chunkBytes, file.size));
|
|
145
|
+
let lastErr;
|
|
146
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
147
|
+
signal?.throwIfAborted();
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetch(chunkUrl(session, token, ci), {
|
|
150
|
+
method: "PUT",
|
|
151
|
+
body: part,
|
|
152
|
+
// bs stores the mime from the assembling request's content-type.
|
|
153
|
+
// 🔴 `x-allow-low-disk` is what turns binary-server's disk RESERVE from a
|
|
154
|
+
// wall into a warning. Owner instruction 2026-08-13, after a large upload
|
|
155
|
+
// stopped dead: *"Do not let the server block me. It can warn me and I can
|
|
156
|
+
// say do it anyway or cancel if I want."* The reserve is sized for the worst
|
|
157
|
+
// case a chunked upload can transiently need, so it refused uploads that
|
|
158
|
+
// would have fit. bs still refuses absolutely below its hard floor, where
|
|
159
|
+
// the machine itself is what is at risk.
|
|
160
|
+
headers: file.type ? { "content-type": file.type } : undefined,
|
|
161
|
+
signal,
|
|
162
|
+
});
|
|
163
|
+
if (res.status === 403 && refreshToken) {
|
|
164
|
+
// Almost always an expired session token on a long upload. Re-mint once per
|
|
165
|
+
// attempt and let the loop retry with it.
|
|
166
|
+
token = await refreshToken();
|
|
167
|
+
throw new Error("token refreshed, retrying");
|
|
168
|
+
}
|
|
169
|
+
// Out of space is terminal — retrying cannot make room, and a 10 GB upload
|
|
170
|
+
// hammering a full disk five times over is the opposite of helpful.
|
|
171
|
+
if (res.status === 507) {
|
|
172
|
+
const body = (await res.json().catch(() => ({})));
|
|
173
|
+
// Terminal — retrying cannot make room, and a 10 GB upload hammering a full
|
|
174
|
+
// disk five times over is the opposite of helpful. 🔴 A CURRENT
|
|
175
|
+
// binary-server never sends this for its own reserve any more (see
|
|
176
|
+
// `overridable`); reaching here means the volume is genuinely out of room
|
|
177
|
+
// and the filesystem said so.
|
|
178
|
+
throw new InsufficientStorageError(file.size, body.freeBytes ?? null, body.overridable === true);
|
|
179
|
+
}
|
|
180
|
+
if (!res.ok)
|
|
181
|
+
throw new Error(`chunk ${ci}: HTTP ${res.status}`);
|
|
182
|
+
const body = (await res.json());
|
|
183
|
+
if (body.assembled) {
|
|
184
|
+
assembled = {
|
|
185
|
+
key: body.key ?? session.path,
|
|
186
|
+
size: body.size ?? file.size,
|
|
187
|
+
checksum: body.checksum ?? null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
done++;
|
|
191
|
+
report();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
if (signal?.aborted)
|
|
196
|
+
throw err;
|
|
197
|
+
// Terminal: no amount of backoff creates disk space.
|
|
198
|
+
if (err instanceof InsufficientStorageError)
|
|
199
|
+
throw err;
|
|
200
|
+
lastErr = err;
|
|
201
|
+
// Backoff, capped — a home connection that dropped needs seconds, not minutes.
|
|
202
|
+
await sleep(Math.min(1000 * 2 ** attempt, 15_000));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw new Error(`chunk ${ci} failed after ${retries} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
206
|
+
};
|
|
207
|
+
// Bounded worker pool over the pending indices.
|
|
208
|
+
const queue = [...pending];
|
|
209
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
210
|
+
for (;;) {
|
|
211
|
+
const next = queue.shift();
|
|
212
|
+
if (next === undefined)
|
|
213
|
+
return;
|
|
214
|
+
await sendOne(next);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
await Promise.all(workers);
|
|
218
|
+
if (assembled)
|
|
219
|
+
return assembled;
|
|
220
|
+
// Every part reported OK but no response carried the assemble result — possible if the
|
|
221
|
+
// final 201 was the one that got retried after its response was lost. Ask.
|
|
222
|
+
const after = await fetchSessionState(session, token, signal);
|
|
223
|
+
if (after.complete) {
|
|
224
|
+
return { key: session.path, size: after.size ?? file.size, checksum: after.checksum };
|
|
225
|
+
}
|
|
226
|
+
throw new Error(`upload finished but binary-server did not assemble ${session.path} (${after.received.size}/${total} parts)`);
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=directUpload.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"directUpload.js","sourceRoot":"","sources":["../../../src/react/lib/directUpload.ts"],"names":[],"mappings":"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"}
|
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
* after an app's responsive variant (task 2124). An app that imports every area gets exactly
|
|
19
19
|
* what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
|
|
20
20
|
* src/stylesAreas.spec.ts, which is also what fails when this file is stale.
|
|
21
|
-
*
|
|
21
|
+
* 10 candidate(s).
|
|
22
22
|
*
|
|
23
23
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
24
24
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
25
25
|
* import closures rather than a hand-written list, is scripts/styleAreas.ts.
|
|
26
26
|
*/
|
|
27
|
-
@source inline("caret-transparent
|
|
27
|
+
@source inline("caret-transparent focus-within:border-field-ring focus-within:ring-2");
|
|
28
28
|
@source inline("focus-within:ring-field-ring/30 h-12 has-[[aria-invalid=true]]:border-field-error min-h-dvh");
|
|
29
29
|
@source inline("self-start self-stretch sm:w-11");
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* after an app's responsive variant (task 2124). An app that imports every area gets exactly
|
|
16
16
|
* what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
|
|
17
17
|
* src/stylesAreas.spec.ts, which is also what fails when this file is stale.
|
|
18
|
-
*
|
|
18
|
+
* 706 candidate(s).
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
@source inline("border-collapse border-current border-destructive/30 border-destructive/40 border-destructive/50");
|
|
53
53
|
@source inline("border-field-border border-success/30 border-success/40 border-success/50 border-t");
|
|
54
54
|
@source inline("border-transparent border-warning/30 border-warning/40 border-warning/50 border-x-4");
|
|
55
|
-
@source inline("border-x-transparent bot bottom bottom-0 break break-all c ca cap capitalize co contain");
|
|
55
|
+
@source inline("border-x-transparent bot bottom bottom-0 break break-all c ca cap capitalize co com contain");
|
|
56
56
|
@source inline("container content contents cursor cursor-col-resize cursor-default cursor-ew-resize");
|
|
57
57
|
@source inline("cursor-pointer cursor-row-resize d dark dark:bg-card-overlay dark:bg-card-raised");
|
|
58
58
|
@source inline("dark:bg-gray-800 data data-[disabled]:hover:bg-border data-[disabled]:opacity-50");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
|
|
@@ -203,6 +203,12 @@
|
|
|
203
203
|
"source": "./src/react/components/DropdownMenu.tsx",
|
|
204
204
|
"import": "./dist/react/components/DropdownMenu.js"
|
|
205
205
|
},
|
|
206
|
+
"./react/engagement-beacon": {
|
|
207
|
+
"types": "./dist/react/engagement/index.d.ts",
|
|
208
|
+
"bun": "./src/react/engagement/index.ts",
|
|
209
|
+
"source": "./src/react/engagement/index.ts",
|
|
210
|
+
"import": "./dist/react/engagement/index.js"
|
|
211
|
+
},
|
|
206
212
|
"./react/file-tree": {
|
|
207
213
|
"types": "./dist/react/file-tree/index.d.ts",
|
|
208
214
|
"bun": "./src/react/file-tree/index.ts",
|
|
@@ -395,6 +401,12 @@
|
|
|
395
401
|
"source": "./src/react/components/Switch.tsx",
|
|
396
402
|
"import": "./dist/react/components/Switch.js"
|
|
397
403
|
},
|
|
404
|
+
"./react/upload-direct": {
|
|
405
|
+
"types": "./dist/react/lib/directUpload.d.ts",
|
|
406
|
+
"bun": "./src/react/lib/directUpload.ts",
|
|
407
|
+
"source": "./src/react/lib/directUpload.ts",
|
|
408
|
+
"import": "./dist/react/lib/directUpload.js"
|
|
409
|
+
},
|
|
398
410
|
"./react/workbook-viewer": {
|
|
399
411
|
"types": "./dist/react/workbook-viewer/index.d.ts",
|
|
400
412
|
"bun": "./src/react/workbook-viewer/index.ts",
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
classesInCss,
|
|
9
9
|
hasVariantPrefix,
|
|
10
10
|
inlineCandidatesIn,
|
|
11
|
+
onlyAsPropertyName,
|
|
11
12
|
onlyInMergeTables,
|
|
12
13
|
readBeltSheets,
|
|
13
14
|
tokensInJs,
|
|
@@ -133,6 +134,28 @@ describe('🔴 tailwind-merge tables are not usage (task 2118)', () => {
|
|
|
133
134
|
});
|
|
134
135
|
});
|
|
135
136
|
|
|
137
|
+
describe('🔴 a property NAME is not class usage (task 2131, station)', () => {
|
|
138
|
+
// Verbatim from station's build: d3's scale check, inside recharts.
|
|
139
|
+
const STATION = 'function RE(e){if(e!=null)return"invert"in e&&typeof e.invert=="function"?e.invert:null}';
|
|
140
|
+
|
|
141
|
+
test('an `in` test and an object key are excused', () => {
|
|
142
|
+
expect(onlyAsPropertyName('invert', STATION)).toBe(true);
|
|
143
|
+
expect(onlyAsPropertyName('invert', 'const o={"invert":1}')).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('refused when the token is ALSO a class, or a lone string value (`cn("italic")` IS usage)', () => {
|
|
147
|
+
expect(onlyAsPropertyName('invert', `${STATION};x({className:"invert grayscale"})`)).toBe(false);
|
|
148
|
+
expect(onlyAsPropertyName('italic', 'x=cn("italic",y)')).toBe(false);
|
|
149
|
+
expect(onlyAsPropertyName('h-8', 'const v={size:{sm:"h-8"}}')).toBe(false);
|
|
150
|
+
expect(onlyAsPropertyName('invert', '')).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('the station shape reports no `invert` miss', () => {
|
|
154
|
+
const { missing } = checkAreaStyles({ js: [STATION], css: [''] }, readBeltSheets());
|
|
155
|
+
expect(missing.map((m) => m.className)).not.toContain('invert');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
136
159
|
describe('an area is `@source inline` candidates since 5.1.0 (task 2124)', () => {
|
|
137
160
|
test('inlineCandidatesIn reads both quote kinds and skips markers', () => {
|
|
138
161
|
const css = `/* banner */\n@source inline("flex sm:hidden peer");\n@source inline('before:content-[""]');\n`;
|
|
@@ -132,6 +132,25 @@ export function onlyInMergeTables(token: string, js: string): boolean {
|
|
|
132
132
|
return seen > 0 && [...js.matchAll(bare)].length === seen;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Does `token` occur in the built JS only as a PROPERTY NAME — the left side of an `in` test
|
|
137
|
+
* (`"invert"in e`, d3's scale check inside recharts) or an object key (`{"invert":…}`)? A className
|
|
138
|
+
* is always a string VALUE, never either (task 2131, measured on station's build 2026-09-23: the
|
|
139
|
+
* only false reference left after the tailwind-merge filter, and it demanded the `media` area).
|
|
140
|
+
* Refused as soon as the token also appears in any class string.
|
|
141
|
+
*/
|
|
142
|
+
export function onlyAsPropertyName(token: string, js: string): boolean {
|
|
143
|
+
const quoted = new RegExp(`(["'])${escapeRe(token)}\\1`, 'g');
|
|
144
|
+
let seen = 0;
|
|
145
|
+
for (const m of js.matchAll(quoted)) {
|
|
146
|
+
seen++;
|
|
147
|
+
const after = js.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 4);
|
|
148
|
+
if (!/^\s*(?::|in\b)/.test(after)) return false;
|
|
149
|
+
}
|
|
150
|
+
const bare = new RegExp(`(^|[\\s"'\`])${escapeRe(token)}(?=[\\s"'\`]|$)`, 'g');
|
|
151
|
+
return seen > 0 && [...js.matchAll(bare)].length === seen;
|
|
152
|
+
}
|
|
153
|
+
|
|
135
154
|
// ── the ORDER check (5.1.0, task 2124) ─────────────────────────────────────────────────
|
|
136
155
|
|
|
137
156
|
export interface CascadeFault {
|
|
@@ -290,7 +309,7 @@ export function checkAreaStyles(
|
|
|
290
309
|
const allJs = input.js.join('\n');
|
|
291
310
|
const missing = [...referenced]
|
|
292
311
|
.filter((name) => !emitted.has(name))
|
|
293
|
-
.filter((name) => !onlyInMergeTables(name, allJs))
|
|
312
|
+
.filter((name) => !onlyInMergeTables(name, allJs) && !onlyAsPropertyName(name, allJs))
|
|
294
313
|
.sort()
|
|
295
314
|
.map((className) => ({ className, area: areaOf.get(className) as string }));
|
|
296
315
|
return { missing, referenced: referenced.size };
|
package/scripts/styleAreas.ts
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The React adapter over `beaconClient.ts` — one hook, one component.
|
|
3
|
+
*
|
|
4
|
+
* Separate file from the plain-JS half so an app with no React (or a test with
|
|
5
|
+
* no DOM) can import `reportPlace` without pulling React in, and so the app's
|
|
6
|
+
* own router stays the thing that decides what "a place" is.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect } from "react";
|
|
9
|
+
import { type ReportPlaceOptions, reportPlace, startLocationBeacon } from "./beaconClient.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Report `place` whenever it changes.
|
|
13
|
+
*
|
|
14
|
+
* `place` should be the app's own route-manifest id — the same string the
|
|
15
|
+
* server resolves against. Pass `null` while the route is unresolved (a lazy
|
|
16
|
+
* view still loading, a signed-out shell) rather than a placeholder: a
|
|
17
|
+
* placeholder becomes a real row in the console.
|
|
18
|
+
*/
|
|
19
|
+
export function useEngagementBeacon(place: string | null, options: ReportPlaceOptions = {}): void {
|
|
20
|
+
const basePath = options.basePath;
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!place) return;
|
|
23
|
+
// Fire and forget; `reportPlace` never rejects. Not awaited on purpose —
|
|
24
|
+
// a route change must not wait on telemetry.
|
|
25
|
+
void reportPlace(place, basePath ? { basePath } : {});
|
|
26
|
+
}, [place, basePath]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Report wherever the browser IS, and keep reporting as it moves.
|
|
31
|
+
*
|
|
32
|
+
* This is the form every app should use, and it is why adoption is one line
|
|
33
|
+
* rather than a per-app mapping: the client sends its LOCATION and the server
|
|
34
|
+
* resolves it against that app's own route manifest (`policy.ts:resolvePlace`).
|
|
35
|
+
* A `route.view → place id` table written in twelve app shells would be twelve
|
|
36
|
+
* places for the names to drift, and a drifted name reads on the board as a
|
|
37
|
+
* page nobody opens — a wrong answer, not a gap.
|
|
38
|
+
*
|
|
39
|
+
* Listens to `hashchange` and `popstate`, which between them cover both kinds of
|
|
40
|
+
* router — hash-routed apps like this one, and a pushState app. `reportPlace`
|
|
41
|
+
* de-duplicates, so a re-render costs nothing.
|
|
42
|
+
*/
|
|
43
|
+
export function useLocationBeacon(options: ReportPlaceOptions = {}): void {
|
|
44
|
+
const basePath = options.basePath;
|
|
45
|
+
// Delegates to the plain-JS starter so there is ONE implementation of the
|
|
46
|
+
// listener set. Most apps call `startLocationBeacon()` from `main.tsx`
|
|
47
|
+
// instead; this exists for a shell that would rather own the lifetime.
|
|
48
|
+
useEffect(() => startLocationBeacon(basePath ? { basePath } : {}), [basePath]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The location beacon as an element, for a shell that mounts declaratively.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately takes no `place` prop. Offering both would mean two callers of
|
|
55
|
+
* one module-level de-duplication memory, and an app that passed a place would
|
|
56
|
+
* silently suppress the location half or vice versa — use
|
|
57
|
+
* {@link useEngagementBeacon} directly when an app really does know its own
|
|
58
|
+
* place ids.
|
|
59
|
+
*/
|
|
60
|
+
export function EngagementBeacon(props: { basePath?: string }): null {
|
|
61
|
+
useLocationBeacon(props.basePath ? { basePath: props.basePath } : {});
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The beacon's SILENCE contract.
|
|
3
|
+
*
|
|
4
|
+
* `beacon.client.ts` says failure is silence, and on 2026-08-14 that turned out
|
|
5
|
+
* to be true only of the JavaScript. A `fetch` that comes back 401 leaves the
|
|
6
|
+
* browser's own *"Failed to load resource"* in the console however carefully the
|
|
7
|
+
* promise is handled — so a beacon fired from a sign-in card put an error on the
|
|
8
|
+
* front door of every private satellite, and `music`'s and `learn`'s signed-out
|
|
9
|
+
* route health failed on it against live prod with nothing else wrong.
|
|
10
|
+
*
|
|
11
|
+
* These tests are about the request that is NOT made. `window` is deliberately
|
|
12
|
+
* untouched: `reportPlace` is the whole mechanism and needs no DOM, which is why
|
|
13
|
+
* this file runs in the package's plain `bun test`.
|
|
14
|
+
*/
|
|
15
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
16
|
+
import { reportPlace, resetEngagementBeacon, SESSION_PATH } from "./beaconClient.js";
|
|
17
|
+
|
|
18
|
+
/** A `fetch` that records every call and answers from a script. */
|
|
19
|
+
function recordingFetch(answers: Record<string, { status?: number; body?: unknown }>) {
|
|
20
|
+
const calls: string[] = [];
|
|
21
|
+
const impl = (async (input: string) => {
|
|
22
|
+
const url = String(input);
|
|
23
|
+
calls.push(url);
|
|
24
|
+
const answer = answers[url] ?? { status: 404 };
|
|
25
|
+
const status = answer.status ?? 200;
|
|
26
|
+
return {
|
|
27
|
+
ok: status >= 200 && status < 300,
|
|
28
|
+
status,
|
|
29
|
+
json: async () => answer.body ?? {},
|
|
30
|
+
};
|
|
31
|
+
}) as unknown as typeof fetch;
|
|
32
|
+
return { impl, calls };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SIGNED_IN = { body: { authenticated: true } };
|
|
36
|
+
const SIGNED_OUT = { body: { authenticated: false } };
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
resetEngagementBeacon();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("signed out, the beacon makes no gated request at all", () => {
|
|
43
|
+
test("it asks the public session probe and then stays quiet", async () => {
|
|
44
|
+
const { impl, calls } = recordingFetch({ [SESSION_PATH]: SIGNED_OUT });
|
|
45
|
+
|
|
46
|
+
expect(await reportPlace("/#/hub", { fetchImpl: impl })).toBe(false);
|
|
47
|
+
|
|
48
|
+
// The probe, and nothing else. A POST to the gated recorder is exactly the
|
|
49
|
+
// request that produced the console line.
|
|
50
|
+
expect(calls).toEqual([SESSION_PATH]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("🔴 it does not consume the de-duplication slot", async () => {
|
|
54
|
+
// The bug this ordering prevents: a signed-out arrival at `/#/hub` marking
|
|
55
|
+
// `/#/hub` as already-reported, so the FIRST real view after signing in —
|
|
56
|
+
// which lands on that same place — is dropped forever as a duplicate.
|
|
57
|
+
const out = recordingFetch({ [SESSION_PATH]: SIGNED_OUT });
|
|
58
|
+
expect(await reportPlace("/#/hub", { fetchImpl: out.impl })).toBe(false);
|
|
59
|
+
|
|
60
|
+
resetEngagementBeacon(); // what a full page load (the SSO callback) does
|
|
61
|
+
const inn = recordingFetch({
|
|
62
|
+
[SESSION_PATH]: SIGNED_IN,
|
|
63
|
+
"/api/engagement/view": { status: 200 },
|
|
64
|
+
});
|
|
65
|
+
expect(await reportPlace("/#/hub", { fetchImpl: inn.impl })).toBe(true);
|
|
66
|
+
expect(inn.calls).toContain("/api/engagement/view");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("signed in, nothing that used to be counted stops being counted", () => {
|
|
71
|
+
test("the view is reported, after the probe", async () => {
|
|
72
|
+
const { impl, calls } = recordingFetch({
|
|
73
|
+
[SESSION_PATH]: SIGNED_IN,
|
|
74
|
+
"/api/engagement/view": { status: 200 },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(await reportPlace("/#/stats", { fetchImpl: impl })).toBe(true);
|
|
78
|
+
expect(calls).toEqual([SESSION_PATH, "/api/engagement/view"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("the probe is asked ONCE per page load, not once per navigation", async () => {
|
|
82
|
+
const { impl, calls } = recordingFetch({
|
|
83
|
+
[SESSION_PATH]: SIGNED_IN,
|
|
84
|
+
"/api/engagement/view": { status: 200 },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await reportPlace("/#/hub", { fetchImpl: impl });
|
|
88
|
+
await reportPlace("/#/stats", { fetchImpl: impl });
|
|
89
|
+
await reportPlace("/#/archive", { fetchImpl: impl });
|
|
90
|
+
|
|
91
|
+
expect(calls.filter((url) => url === SESSION_PATH)).toHaveLength(1);
|
|
92
|
+
expect(calls.filter((url) => url === "/api/engagement/view")).toHaveLength(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a repeated place is still one view — the old rule is untouched", async () => {
|
|
96
|
+
const { impl, calls } = recordingFetch({
|
|
97
|
+
[SESSION_PATH]: SIGNED_IN,
|
|
98
|
+
"/api/engagement/view": { status: 200 },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await reportPlace("/#/hub", { fetchImpl: impl });
|
|
102
|
+
expect(await reportPlace("/#/hub", { fetchImpl: impl })).toBe(false);
|
|
103
|
+
expect(calls.filter((url) => url === "/api/engagement/view")).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("the probe fails CLOSED", () => {
|
|
108
|
+
// One uncounted view is cheap. A false positive is the console line on the
|
|
109
|
+
// front door, which is the whole thing this path exists to prevent.
|
|
110
|
+
test.each([
|
|
111
|
+
["the probe itself errors", null],
|
|
112
|
+
["the probe answers non-2xx", { status: 500 }],
|
|
113
|
+
["the body carries no verdict", { body: {} }],
|
|
114
|
+
["the body says authenticated is a string", { body: { authenticated: "yes" } }],
|
|
115
|
+
])("%s → nothing is sent", async (_name, answer) => {
|
|
116
|
+
const impl = (async (input: string) => {
|
|
117
|
+
if (answer === null) throw new Error("network down");
|
|
118
|
+
const status = (answer as { status?: number }).status ?? 200;
|
|
119
|
+
return {
|
|
120
|
+
ok: status >= 200 && status < 300,
|
|
121
|
+
status,
|
|
122
|
+
json: async () => (answer as { body?: unknown }).body ?? {},
|
|
123
|
+
_url: input,
|
|
124
|
+
};
|
|
125
|
+
}) as unknown as typeof fetch;
|
|
126
|
+
|
|
127
|
+
expect(await reportPlace("/#/hub", { fetchImpl: impl })).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("a caller that already knows may skip the probe", () => {
|
|
132
|
+
test("assumeSignedIn reports with no second answer to the same question", async () => {
|
|
133
|
+
const { impl, calls } = recordingFetch({ "/api/engagement/view": { status: 200 } });
|
|
134
|
+
|
|
135
|
+
expect(await reportPlace("/#/hub", { fetchImpl: impl, assumeSignedIn: true })).toBe(true);
|
|
136
|
+
expect(calls).toEqual(["/api/engagement/view"]);
|
|
137
|
+
});
|
|
138
|
+
});
|