cursedbelt 5.0.1 → 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/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/MediaThumb.d.ts.map +1 -1
- package/dist/react/media/MediaThumb.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/styles-areas/activity-bar.css +8 -11
- package/dist/styles-areas/analytics.css +30 -653
- package/dist/styles-areas/auth.css +10 -135
- package/dist/styles-areas/calendar.css +22 -397
- package/dist/styles-areas/chart-table.css +8 -20
- package/dist/styles-areas/charts.css +9 -70
- package/dist/styles-areas/chat.css +17 -345
- package/dist/styles-areas/clipboard.css +7 -7
- package/dist/styles-areas/code-editor.css +8 -111
- package/dist/styles-areas/companion-link.css +8 -51
- package/dist/styles-areas/core.css +131 -2488
- package/dist/styles-areas/dashboard-grid.css +10 -65
- package/dist/styles-areas/data-table.css +30 -638
- package/dist/styles-areas/deep-link.css +7 -7
- package/dist/styles-areas/diff-viewer.css +8 -11
- package/dist/styles-areas/disk-usage.css +13 -243
- package/dist/styles-areas/emoji.css +9 -135
- package/dist/styles-areas/fields.css +32 -563
- package/dist/styles-areas/file-tree.css +8 -14
- package/dist/styles-areas/filter-rail.css +14 -216
- package/dist/styles-areas/folder-tree.css +12 -200
- package/dist/styles-areas/keep-awake.css +7 -7
- package/dist/styles-areas/layout-engine.css +12 -220
- package/dist/styles-areas/markdown.css +9 -68
- package/dist/styles-areas/master-detail.css +14 -220
- package/dist/styles-areas/master-lock.css +9 -113
- package/dist/styles-areas/media-gallery.css +15 -332
- package/dist/styles-areas/media.css +20 -623
- package/dist/styles-areas/nav.css +24 -469
- package/dist/styles-areas/notifications.css +12 -226
- package/dist/styles-areas/overlays.css +14 -114
- package/dist/styles-areas/palette.css +15 -121
- package/dist/styles-areas/pdf-viewer.css +8 -28
- package/dist/styles-areas/problem-detail.css +9 -78
- package/dist/styles-areas/rich-text.css +9 -148
- package/dist/styles-areas/sharing.css +13 -134
- package/dist/styles-areas/spreadsheet.css +8 -18
- package/dist/styles-areas/stats.css +19 -262
- package/dist/styles-areas/test-report.css +13 -114
- package/dist/styles-areas/virtual.css +8 -11
- package/dist/styles-areas/wizard.css +21 -362
- package/dist/styles-areas/workbench.css +27 -524
- package/dist/styles-areas/workbook-viewer.css +8 -25
- package/dist/styles-static.css +1 -0
- package/dist/styles.css +1 -0
- package/package.json +20 -2
- package/scripts/checkAreaStyles.spec.ts +158 -1
- package/scripts/checkAreaStyles.ts +226 -6
- package/scripts/cssRules.ts +8 -1
- package/scripts/gate.ts +1 -1
- package/scripts/generateAreaStyles.ts +130 -10
- package/scripts/paths.ts +1 -1
- package/scripts/styleAreas.ts +2 -0
- package/src/react/components/ActionBar.spec.tsx +1 -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/file-tree/FileTree.tsx +3 -3
- package/src/react/lib/directUpload.spec.ts +128 -0
- package/src/react/lib/directUpload.ts +334 -0
- package/src/react/media/FocusViewer.tsx +1 -0
- package/src/react/media/MediaThumb.tsx +0 -1
- package/src/react/media/VideoTurner.tsx +0 -1
- package/src/react/media-gallery/GalleryTable.tsx +0 -1
- package/src/react/media-gallery/MediaGallery.tsx +0 -1
- package/src/styles-areas/activity-bar.css +8 -11
- package/src/styles-areas/analytics.css +30 -653
- package/src/styles-areas/auth.css +10 -135
- package/src/styles-areas/calendar.css +22 -397
- package/src/styles-areas/chart-table.css +8 -20
- package/src/styles-areas/charts.css +9 -70
- package/src/styles-areas/chat.css +17 -345
- package/src/styles-areas/clipboard.css +7 -7
- package/src/styles-areas/code-editor.css +8 -111
- package/src/styles-areas/companion-link.css +8 -51
- package/src/styles-areas/core.css +131 -2488
- package/src/styles-areas/dashboard-grid.css +10 -65
- package/src/styles-areas/data-table.css +30 -638
- package/src/styles-areas/deep-link.css +7 -7
- package/src/styles-areas/diff-viewer.css +8 -11
- package/src/styles-areas/disk-usage.css +13 -243
- package/src/styles-areas/emoji.css +9 -135
- package/src/styles-areas/fields.css +32 -563
- package/src/styles-areas/file-tree.css +8 -14
- package/src/styles-areas/filter-rail.css +14 -216
- package/src/styles-areas/folder-tree.css +12 -200
- package/src/styles-areas/keep-awake.css +7 -7
- package/src/styles-areas/layout-engine.css +12 -220
- package/src/styles-areas/markdown.css +9 -68
- package/src/styles-areas/master-detail.css +14 -220
- package/src/styles-areas/master-lock.css +9 -113
- package/src/styles-areas/media-gallery.css +15 -332
- package/src/styles-areas/media.css +20 -623
- package/src/styles-areas/nav.css +24 -469
- package/src/styles-areas/notifications.css +12 -226
- package/src/styles-areas/overlays.css +14 -114
- package/src/styles-areas/palette.css +15 -121
- package/src/styles-areas/pdf-viewer.css +8 -28
- package/src/styles-areas/problem-detail.css +9 -78
- package/src/styles-areas/rich-text.css +9 -148
- package/src/styles-areas/sharing.css +13 -134
- package/src/styles-areas/spreadsheet.css +8 -18
- package/src/styles-areas/stats.css +19 -262
- package/src/styles-areas/test-report.css +13 -114
- package/src/styles-areas/virtual.css +8 -11
- package/src/styles-areas/wizard.css +21 -362
- package/src/styles-areas/workbench.css +27 -524
- package/src/styles-areas/workbook-viewer.css +8 -25
- package/src/styles-static.css +1 -0
- package/src/styles.css +1 -0
- package/src/stylesAreas.spec.ts +12 -7
|
@@ -2121,7 +2121,7 @@ export function FileTree({
|
|
|
2121
2121
|
: null;
|
|
2122
2122
|
|
|
2123
2123
|
const rowBody = (
|
|
2124
|
-
//
|
|
2124
|
+
// It IS focusable — `tabIndex`
|
|
2125
2125
|
// is set below by the roving-focus rule (one row at 0, the rest at -1).
|
|
2126
2126
|
<div
|
|
2127
2127
|
role='treeitem'
|
|
@@ -2205,7 +2205,7 @@ export function FileTree({
|
|
|
2205
2205
|
{...externalHandlers(node)}
|
|
2206
2206
|
>
|
|
2207
2207
|
{row.expandable ? (
|
|
2208
|
-
//
|
|
2208
|
+
// A disclosure twisty
|
|
2209
2209
|
// inside a `treeitem`, which already carries `aria-expanded` — a nested
|
|
2210
2210
|
// <button> would be a second focus stop announcing the same state twice.
|
|
2211
2211
|
<span
|
|
@@ -2444,7 +2444,7 @@ export function FileTree({
|
|
|
2444
2444
|
</div>
|
|
2445
2445
|
) : null}
|
|
2446
2446
|
|
|
2447
|
-
{/*
|
|
2447
|
+
{/* The blank area under the tree is
|
|
2448
2448
|
a DROP TARGET, not a control — "get this out of here" is the one gesture with no
|
|
2449
2449
|
row to aim at. Every move it can express is also on a row's own menu. */}
|
|
2450
2450
|
<div
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,334 @@
|
|
|
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
|
+
|
|
37
|
+
/** Everything the browser needs to run an upload, minted server-side by `createUploadSession`
|
|
38
|
+
* in `cursedbelt-server/binary-store/upload-session`. Contains a capability token scoped to ONE key, and no key
|
|
39
|
+
* material — it is safe to hand to the page that will do the uploading. */
|
|
40
|
+
export interface UploadSession {
|
|
41
|
+
/** binary-server's public base, e.g. `https://binary-server.cursedalchemy.com`. */
|
|
42
|
+
baseUrl: string;
|
|
43
|
+
/** App-prefixed destination key, e.g. `family/media/<uuid>`. */
|
|
44
|
+
path: string;
|
|
45
|
+
/** Signed token authorizing this session (carries `sid`, `tc` and the source length `sz`). */
|
|
46
|
+
token: string;
|
|
47
|
+
/** Chunk session id. */
|
|
48
|
+
sid: string;
|
|
49
|
+
/** Total parts the file was split into. */
|
|
50
|
+
totalChunks: number;
|
|
51
|
+
/** Bytes per part — must match what the token's `tc` was computed from. */
|
|
52
|
+
chunkBytes: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DirectUploadOptions {
|
|
56
|
+
/** 0..1, called as parts complete. */
|
|
57
|
+
onProgress?: (fraction: number, bytesSent: number, totalBytes: number) => void;
|
|
58
|
+
/** Parts in flight at once. Default 3 — enough to saturate a home upstream, few enough
|
|
59
|
+
* that a failure retries cheaply. */
|
|
60
|
+
concurrency?: number;
|
|
61
|
+
/** Attempts per part before giving up. Default 5, with backoff. */
|
|
62
|
+
retries?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Called once, before any part is sent, when the store reports less free space than this
|
|
65
|
+
* upload is likely to need.
|
|
66
|
+
*
|
|
67
|
+
* 🔴 A WARNING and nothing else — it cannot stop the upload, and there is deliberately no
|
|
68
|
+
* way for it to. The owner's instruction (2026-08-13) is that space must never block them:
|
|
69
|
+
* *"Remove all blockers for space and only allow warning me. I'll manage space myself
|
|
70
|
+
* beyond that."* Returning `false` from here, or throwing, would quietly reinstate exactly
|
|
71
|
+
* the blocker that was removed.
|
|
72
|
+
*/
|
|
73
|
+
onLowDisk?: (info: { freeBytes: number; needBytes: number }) => void;
|
|
74
|
+
/** Abort the whole upload (a cancel button, or unmount). */
|
|
75
|
+
signal?: AbortSignal;
|
|
76
|
+
/** Re-mint an expired session token mid-upload. A 10 GB upload can outlive any TTL short
|
|
77
|
+
* enough to be safe, so the page supplies a way to ask its own server for a fresh one
|
|
78
|
+
* for the SAME `sid`. Without it, a long upload simply fails on expiry. */
|
|
79
|
+
refreshToken?: () => Promise<string>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What binary-server reports once the last part lands. */
|
|
83
|
+
export interface DirectUploadResult {
|
|
84
|
+
key: string;
|
|
85
|
+
size: number;
|
|
86
|
+
checksum: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
90
|
+
|
|
91
|
+
/** Split `[0, total)` into `chunkBytes`-sized part indices. */
|
|
92
|
+
export const chunkCount = (totalBytes: number, chunkBytes: number): number =>
|
|
93
|
+
Math.max(1, Math.ceil(totalBytes / chunkBytes));
|
|
94
|
+
|
|
95
|
+
const chunkUrl = (s: UploadSession, token: string, ci: number): string =>
|
|
96
|
+
`${s.baseUrl}/upload-chunk/${s.path
|
|
97
|
+
.split("/")
|
|
98
|
+
.map(encodeURIComponent)
|
|
99
|
+
.join("/")}?token=${encodeURIComponent(token)}&ci=${ci}`;
|
|
100
|
+
|
|
101
|
+
const statusUrl = (s: UploadSession, token: string): string =>
|
|
102
|
+
`${s.baseUrl}/upload-chunk/${s.path
|
|
103
|
+
.split("/")
|
|
104
|
+
.map(encodeURIComponent)
|
|
105
|
+
.join("/")}?token=${encodeURIComponent(token)}`;
|
|
106
|
+
|
|
107
|
+
/** Which parts binary-server already holds, plus whether the object is already complete.
|
|
108
|
+
* A fresh session answers `{ received: [], complete: false }`, so callers need no special
|
|
109
|
+
* case for "first attempt". */
|
|
110
|
+
export async function fetchSessionState(
|
|
111
|
+
session: UploadSession,
|
|
112
|
+
token = session.token,
|
|
113
|
+
signal?: AbortSignal,
|
|
114
|
+
): Promise<{
|
|
115
|
+
received: Set<number>;
|
|
116
|
+
complete: boolean;
|
|
117
|
+
size: number | null;
|
|
118
|
+
checksum: string | null;
|
|
119
|
+
/** Bytes binary-server still has room for, or null if it could not tell. */
|
|
120
|
+
freeBytes: number | null;
|
|
121
|
+
}> {
|
|
122
|
+
const res = await fetch(statusUrl(session, token), { signal });
|
|
123
|
+
if (!res.ok)
|
|
124
|
+
return {
|
|
125
|
+
received: new Set(),
|
|
126
|
+
complete: false,
|
|
127
|
+
size: null,
|
|
128
|
+
checksum: null,
|
|
129
|
+
freeBytes: null,
|
|
130
|
+
};
|
|
131
|
+
const body = (await res.json()) as {
|
|
132
|
+
received?: number[];
|
|
133
|
+
complete?: boolean;
|
|
134
|
+
size?: number | null;
|
|
135
|
+
checksum?: string | null;
|
|
136
|
+
freeBytes?: number | null;
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
received: new Set(body.received ?? []),
|
|
140
|
+
complete: Boolean(body.complete),
|
|
141
|
+
size: body.size ?? null,
|
|
142
|
+
checksum: body.checksum ?? null,
|
|
143
|
+
freeBytes: body.freeBytes ?? null,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Thrown when binary-server has no room for the file. Distinct from a transport failure so a
|
|
148
|
+
* UI can say "the server is full" — which is actionable — rather than "upload failed", which
|
|
149
|
+
* invites the person to retry forever against a disk that will never have space. */
|
|
150
|
+
export class InsufficientStorageError extends Error {
|
|
151
|
+
constructor(
|
|
152
|
+
readonly needBytes: number,
|
|
153
|
+
readonly freeBytes: number | null,
|
|
154
|
+
/**
|
|
155
|
+
* 🔴 Vestigial since 2026-08-13 and kept only so an older deployed binary-server can
|
|
156
|
+
* still be understood: THIS service no longer refuses an upload for disk space at all,
|
|
157
|
+
* so nothing here should be producing an overridable refusal any more. If you see one,
|
|
158
|
+
* the origin is running a build from before that change.
|
|
159
|
+
*/
|
|
160
|
+
readonly overridable = false,
|
|
161
|
+
) {
|
|
162
|
+
super(
|
|
163
|
+
freeBytes === null
|
|
164
|
+
? "binary-server is out of space for this upload"
|
|
165
|
+
: `binary-server has ${(freeBytes / 1024 ** 3).toFixed(1)} GB free, which is not enough for a ${(needBytes / 1024 ** 3).toFixed(1)} GB upload`,
|
|
166
|
+
);
|
|
167
|
+
this.name = "InsufficientStorageError";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Upload `file` to binary-server in parts, resuming anything already delivered.
|
|
173
|
+
*
|
|
174
|
+
* Resolves once every part has landed and bs has assembled the object. The caller then tells
|
|
175
|
+
* its own app server to record the metadata — see each app's `/complete` route.
|
|
176
|
+
*/
|
|
177
|
+
export async function uploadDirect(
|
|
178
|
+
file: Blob,
|
|
179
|
+
session: UploadSession,
|
|
180
|
+
opts: DirectUploadOptions = {},
|
|
181
|
+
): Promise<DirectUploadResult> {
|
|
182
|
+
const { onProgress, signal, refreshToken } = opts;
|
|
183
|
+
const concurrency = Math.max(1, opts.concurrency ?? 3);
|
|
184
|
+
const retries = Math.max(1, opts.retries ?? 5);
|
|
185
|
+
const { onLowDisk } = opts;
|
|
186
|
+
const total = session.totalChunks;
|
|
187
|
+
|
|
188
|
+
// A token can expire mid-upload on a slow line; `refreshToken` swaps in a new one and
|
|
189
|
+
// every subsequent request picks it up.
|
|
190
|
+
let token = session.token;
|
|
191
|
+
|
|
192
|
+
const prior = await fetchSessionState(session, token, signal);
|
|
193
|
+
if (prior.complete) {
|
|
194
|
+
return {
|
|
195
|
+
key: session.path,
|
|
196
|
+
size: prior.size ?? file.size,
|
|
197
|
+
checksum: prior.checksum,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Bail before sending anything if the file plainly cannot land. bs keeps a reserve so a
|
|
202
|
+
// full disk never takes the Mac down, and it reports free space on the status call above —
|
|
203
|
+
// so an upload with nowhere to go costs one request instead of gigabytes of transfer.
|
|
204
|
+
const remainingBytes = file.size - prior.received.size * session.chunkBytes;
|
|
205
|
+
/*
|
|
206
|
+
* 🔴 NO PREFLIGHT REFUSAL. Owner instruction, 2026-08-13: *"Remove all blockers for space
|
|
207
|
+
* and only allow warning me. I'll manage space myself beyond that."*
|
|
208
|
+
*
|
|
209
|
+
* This used to throw when the remaining bytes exceeded the reported free space. It is gone
|
|
210
|
+
* rather than softened because a client-side estimate is the WORST place to make this
|
|
211
|
+
* call: `remainingBytes` counts every part not yet delivered, `freeBytes` was read before
|
|
212
|
+
* the transfer began, and neither accounts for what the run itself frees or the owner
|
|
213
|
+
* clears while it goes. binary-server no longer refuses either; the real limit is ENOSPC
|
|
214
|
+
* from the filesystem, which is about the actual write rather than a projection.
|
|
215
|
+
*
|
|
216
|
+
* `onLowDisk` is the replacement, and it only WARNS.
|
|
217
|
+
*/
|
|
218
|
+
if (prior.freeBytes !== null && remainingBytes > prior.freeBytes) {
|
|
219
|
+
onLowDisk?.({ freeBytes: prior.freeBytes, needBytes: remainingBytes });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const pending = Array.from({ length: total }, (_, i) => i).filter((i) => !prior.received.has(i));
|
|
223
|
+
let done = total - pending.length;
|
|
224
|
+
let assembled: DirectUploadResult | null = null;
|
|
225
|
+
|
|
226
|
+
const report = (): void =>
|
|
227
|
+
onProgress?.(total === 0 ? 1 : done / total, done * session.chunkBytes, file.size);
|
|
228
|
+
report();
|
|
229
|
+
|
|
230
|
+
const sendOne = async (ci: number): Promise<void> => {
|
|
231
|
+
const start = ci * session.chunkBytes;
|
|
232
|
+
// Lazy view onto the file — nothing is read until fetch streams it.
|
|
233
|
+
const part = file.slice(start, Math.min(start + session.chunkBytes, file.size));
|
|
234
|
+
|
|
235
|
+
let lastErr: unknown;
|
|
236
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
237
|
+
signal?.throwIfAborted();
|
|
238
|
+
try {
|
|
239
|
+
const res = await fetch(chunkUrl(session, token, ci), {
|
|
240
|
+
method: "PUT",
|
|
241
|
+
body: part,
|
|
242
|
+
// bs stores the mime from the assembling request's content-type.
|
|
243
|
+
// 🔴 `x-allow-low-disk` is what turns binary-server's disk RESERVE from a
|
|
244
|
+
// wall into a warning. Owner instruction 2026-08-13, after a large upload
|
|
245
|
+
// stopped dead: *"Do not let the server block me. It can warn me and I can
|
|
246
|
+
// say do it anyway or cancel if I want."* The reserve is sized for the worst
|
|
247
|
+
// case a chunked upload can transiently need, so it refused uploads that
|
|
248
|
+
// would have fit. bs still refuses absolutely below its hard floor, where
|
|
249
|
+
// the machine itself is what is at risk.
|
|
250
|
+
headers: file.type ? { "content-type": file.type } : undefined,
|
|
251
|
+
signal,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (res.status === 403 && refreshToken) {
|
|
255
|
+
// Almost always an expired session token on a long upload. Re-mint once per
|
|
256
|
+
// attempt and let the loop retry with it.
|
|
257
|
+
token = await refreshToken();
|
|
258
|
+
throw new Error("token refreshed, retrying");
|
|
259
|
+
}
|
|
260
|
+
// Out of space is terminal — retrying cannot make room, and a 10 GB upload
|
|
261
|
+
// hammering a full disk five times over is the opposite of helpful.
|
|
262
|
+
if (res.status === 507) {
|
|
263
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
264
|
+
freeBytes?: number;
|
|
265
|
+
overridable?: boolean;
|
|
266
|
+
};
|
|
267
|
+
// Terminal — retrying cannot make room, and a 10 GB upload hammering a full
|
|
268
|
+
// disk five times over is the opposite of helpful. 🔴 A CURRENT
|
|
269
|
+
// binary-server never sends this for its own reserve any more (see
|
|
270
|
+
// `overridable`); reaching here means the volume is genuinely out of room
|
|
271
|
+
// and the filesystem said so.
|
|
272
|
+
throw new InsufficientStorageError(
|
|
273
|
+
file.size,
|
|
274
|
+
body.freeBytes ?? null,
|
|
275
|
+
body.overridable === true,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (!res.ok) throw new Error(`chunk ${ci}: HTTP ${res.status}`);
|
|
279
|
+
|
|
280
|
+
const body = (await res.json()) as {
|
|
281
|
+
assembled?: boolean;
|
|
282
|
+
size?: number;
|
|
283
|
+
checksum?: string | null;
|
|
284
|
+
key?: string;
|
|
285
|
+
};
|
|
286
|
+
if (body.assembled) {
|
|
287
|
+
assembled = {
|
|
288
|
+
key: body.key ?? session.path,
|
|
289
|
+
size: body.size ?? file.size,
|
|
290
|
+
checksum: body.checksum ?? null,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
done++;
|
|
294
|
+
report();
|
|
295
|
+
return;
|
|
296
|
+
} catch (err) {
|
|
297
|
+
if (signal?.aborted) throw err;
|
|
298
|
+
// Terminal: no amount of backoff creates disk space.
|
|
299
|
+
if (err instanceof InsufficientStorageError) throw err;
|
|
300
|
+
lastErr = err;
|
|
301
|
+
// Backoff, capped — a home connection that dropped needs seconds, not minutes.
|
|
302
|
+
await sleep(Math.min(1000 * 2 ** attempt, 15_000));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
throw new Error(
|
|
306
|
+
`chunk ${ci} failed after ${retries} attempts: ${
|
|
307
|
+
lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
308
|
+
}`,
|
|
309
|
+
);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Bounded worker pool over the pending indices.
|
|
313
|
+
const queue = [...pending];
|
|
314
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
315
|
+
for (;;) {
|
|
316
|
+
const next = queue.shift();
|
|
317
|
+
if (next === undefined) return;
|
|
318
|
+
await sendOne(next);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
await Promise.all(workers);
|
|
322
|
+
|
|
323
|
+
if (assembled) return assembled;
|
|
324
|
+
|
|
325
|
+
// Every part reported OK but no response carried the assemble result — possible if the
|
|
326
|
+
// final 201 was the one that got retried after its response was lost. Ask.
|
|
327
|
+
const after = await fetchSessionState(session, token, signal);
|
|
328
|
+
if (after.complete) {
|
|
329
|
+
return { key: session.path, size: after.size ?? file.size, checksum: after.checksum };
|
|
330
|
+
}
|
|
331
|
+
throw new Error(
|
|
332
|
+
`upload finished but binary-server did not assemble ${session.path} (${after.received.size}/${total} parts)`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
@@ -141,6 +141,7 @@ export interface FocusViewerProps {
|
|
|
141
141
|
onSaveSnapshot?: (
|
|
142
142
|
blob: Blob,
|
|
143
143
|
meta: { atSeconds: number },
|
|
144
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: a handler that returns nothing is the common case, and `undefined` would reject every `() => {}` an app already passes
|
|
144
145
|
) => Promise<FocusSaveResult | void> | FocusSaveResult | void;
|
|
145
146
|
/** An arbitrary control in the action row — collections puts its folder picker here. */
|
|
146
147
|
destination?: React.ReactNode;
|
|
@@ -168,7 +168,6 @@ export function VideoTurner({
|
|
|
168
168
|
full-width. Same correction, and the same measurement, as the gallery stage makes. */}
|
|
169
169
|
<div className='cbpe-frame cbpe-frame-film' data-turned={swapsAxes(edit.turns) ? 'true' : undefined}>
|
|
170
170
|
{cannotShow === null ? (
|
|
171
|
-
/* biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored footage carries none */
|
|
172
171
|
<video
|
|
173
172
|
// A new source is a new attempt — see `failedSrc`.
|
|
174
173
|
key={src}
|
|
@@ -1383,7 +1383,6 @@ function GalleryStage({
|
|
|
1383
1383
|
still takes a dwell timer: a run that reaches a track it cannot advance past stops
|
|
1384
1384
|
dead. A mixed folder holding one voice memo used to end the album there. The two
|
|
1385
1385
|
props stay mutually exclusive — a looping element never fires `ended`. */}
|
|
1386
|
-
{/* biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions */}
|
|
1387
1386
|
<audio src={media.url} controls loop={repeat} {...(repeat ? {} : { onEnded })} />
|
|
1388
1387
|
</div>
|
|
1389
1388
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* cursedbelt — the `activity-bar` area. GENERATED BUILD ARTIFACT. Never hand-edit.
|
|
2
|
+
* cursedbelt — the `activity-bar` area's utility candidates. GENERATED BUILD ARTIFACT. Never hand-edit.
|
|
3
3
|
*
|
|
4
4
|
* the `cursedbelt/react/activity-bar` rail — app chrome most apps do not mount.
|
|
5
5
|
*
|
|
@@ -13,18 +13,15 @@
|
|
|
13
13
|
* @import "cursedbelt/styles-areas/activity-bar.css";
|
|
14
14
|
* @import "cursedbelt/theme.css";
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* These are CANDIDATES (`@source inline`), not CSS: the app's own Tailwind pass generates
|
|
17
|
+
* them, sorted together with the app's utilities, so an area can never put a base utility
|
|
18
|
+
* after an app's responsive variant (task 2124). An app that imports every area gets exactly
|
|
19
|
+
* what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
|
|
20
|
+
* src/stylesAreas.spec.ts, which is also what fails when this file is stale.
|
|
21
|
+
* 1 candidate(s).
|
|
19
22
|
*
|
|
20
23
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
24
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
22
25
|
* import closures rather than a hand-written list, is scripts/styleAreas.ts.
|
|
23
26
|
*/
|
|
24
|
-
|
|
25
|
-
@layer theme, base, components, utilities;
|
|
26
|
-
@layer utilities {
|
|
27
|
-
.collapse {
|
|
28
|
-
visibility: collapse;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
27
|
+
@source inline("collapse");
|