swarmlite 0.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/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # swarmlite
2
+
3
+ **Verifiable serverless SQLite on [Ethereum Swarm](https://www.ethswarm.org/).**
4
+
5
+ Publish an ordinary SQLite file to Swarm and run `SELECT` against it —
6
+ from a browser or Node — fetching only the 4 KB B-tree pages the query
7
+ plan touches. Measured live: a cold point lookup on a published
8
+ 41.9 MB database fetched **5 pages / 20 KB**; a whole 4-query session
9
+ 23 pages. There is no database server anywhere, and with
10
+ `verify: true` every byte is checked client-side against the 32-byte
11
+ content address — a tampering gateway is caught on the first bad chunk.
12
+
13
+ Everything is vendored (wa-sqlite, js-sha3, noble-secp256k1): zero
14
+ runtime dependencies, and the whole reader can itself be published on
15
+ Swarm next to your data — one immutable root serves the page, the
16
+ engine, and the database.
17
+
18
+ ## Read (browser or Node)
19
+
20
+ ```js
21
+ import { open, resolveFeed } from 'swarmlite';
22
+
23
+ const db = await open('http://localhost:1633/bzz/<root>/site.db');
24
+ const rows = await db.query('SELECT title FROM posts WHERE id = ?', [12345]);
25
+ console.log(db.stats()); // { readCount, pagesFetched, bytesFetched, fileSize }
26
+ ```
27
+
28
+ Any URL served with HTTP Range support works. Follow a feed for
29
+ always-latest (resolve once, at load — a database must not move
30
+ mid-session), and pass `{ verify: true }` on untrusted gateways:
31
+
32
+ ```js
33
+ const { reference } = await resolveFeed(api, owner, 'mysite', { verify: true });
34
+ const db = await open(`${api}/bzz/${reference}/site.db`, { verify: true });
35
+ ```
36
+
37
+ In Node, pass the wasm explicitly:
38
+ `open(url, { moduleOptions: { wasmBinary: await readFile(...) } })`.
39
+
40
+ ## Publish (Node)
41
+
42
+ ```js
43
+ import { publish } from 'swarmlite/publisher';
44
+
45
+ const { root } = await publish(await readFile('site.db'), {
46
+ feed: 'mysite', // optional: stable URL + version history
47
+ signer: process.env.SWARMLITE_SIGNER,
48
+ log: console.error,
49
+ });
50
+ ```
51
+
52
+ or from the shell (no Python required):
53
+
54
+ ```bash
55
+ npx swarmlite publish site.db --feed mysite --signer <key hex>
56
+ npx swarmlite query "bzz://<root>/site.db" "SELECT count(*) FROM posts" --stats
57
+ ```
58
+
59
+ The publisher runs the same checklist as the Python CLI — page size
60
+ 4096 (one page = one Swarm chunk), ANALYZE, VACUUM, integrity check,
61
+ missing-index warnings — entirely in memory via wa-sqlite, then uploads
62
+ and (optionally) signs a feed update. Feed signatures are byte-for-byte
63
+ identical to the Python implementation's (checked in CI against
64
+ fixtures the Python side generates). `--buy` prices and purchases a
65
+ postage batch from the node's wallet, with the cost shown first.
66
+
67
+ Writes are the publisher's job by design: work on a local SQLite file
68
+ with full SQL, then ship an immutable snapshot. Reader connections are
69
+ strictly read-only — the URL *is* the content hash.
70
+
71
+ ## More
72
+
73
+ The [project repository](https://github.com/petfold/swarmlite) has the
74
+ full User Guide (stamps, feeds, snapshots, verification, patterns), the
75
+ Python reader/publisher, and a self-contained demo site. BSD-3-Clause.
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ // swarmlite CLI (pure JS — no Python needed):
3
+ //
4
+ // swarmlite publish <db> [--name F] [--feed TOPIC] [--signer HEX]
5
+ // [--stamp ID] [--buy] [--ttl 1d] [--yes]
6
+ // [--api-url URL] [--quiet]
7
+ // swarmlite query <url> <sql> [--verify] [--api-url URL]
8
+ //
9
+ // Same behavior as the Python CLI: bare root on stdout, human lines on
10
+ // stderr, one-line errors, exit 1 on failure. SWARMLITE_DEBUG=1 shows
11
+ // tracebacks; the signer key falls back to $SWARMLITE_SIGNER.
12
+
13
+ import { readFile } from 'node:fs/promises';
14
+ import { basename } from 'node:path';
15
+ import { parseArgs } from 'node:util';
16
+ import { createInterface } from 'node:readline/promises';
17
+
18
+ const wasmBinary = await readFile(
19
+ new URL('../vendor/wa-sqlite/dist/wa-sqlite-async.wasm', import.meta.url));
20
+ const moduleOptions = {
21
+ wasmBinary,
22
+ // Emscripten's random-device probe is noisy but harmless in Node
23
+ printErr: (t) => { if (!/initRandomDevice/.test(String(t))) console.error(t); },
24
+ };
25
+
26
+ const UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 7 * 86400 };
27
+
28
+ function parseTtl(text) {
29
+ const unit = UNITS[text.slice(-1)];
30
+ const value = Number(unit ? text.slice(0, -1) : text);
31
+ if (!Number.isInteger(value) || value <= 0) {
32
+ throw new Error(`cannot parse TTL '${text}' — use e.g. 36h, 7d, 4w, or seconds`);
33
+ }
34
+ return value * (unit ?? 1);
35
+ }
36
+
37
+ function rejectPlaceholders(values) {
38
+ for (const [name, value] of Object.entries(values)) {
39
+ const m = value && /<[^<> ]+>/.exec(value);
40
+ if (m) {
41
+ throw new Error(
42
+ `${name} contains the placeholder ${m[0]} — replace it with the ` +
43
+ 'real value (the root/reference is printed by publish)');
44
+ }
45
+ }
46
+ }
47
+
48
+ async function cmdPublish(args) {
49
+ const { values, positionals } = parseArgs({
50
+ args,
51
+ allowPositionals: true,
52
+ options: {
53
+ name: { type: 'string' },
54
+ feed: { type: 'string' },
55
+ signer: { type: 'string' },
56
+ stamp: { type: 'string', default: 'auto' },
57
+ buy: { type: 'boolean', default: false },
58
+ ttl: { type: 'string', default: '1d' },
59
+ yes: { type: 'boolean', default: false },
60
+ 'api-url': { type: 'string' },
61
+ quiet: { type: 'boolean', default: false },
62
+ },
63
+ });
64
+ const [dbPath] = positionals;
65
+ if (!dbPath) throw new Error('usage: swarmlite publish <db> [options]');
66
+ rejectPlaceholders({
67
+ db_path: dbPath, name: values.name, feed: values.feed,
68
+ stamp: values.stamp, 'api-url': values['api-url'],
69
+ });
70
+ const apiUrl = values['api-url'] ?? process.env.BEE_API_URL ?? 'http://localhost:1633';
71
+ const log = values.quiet ? () => {} : (l) => console.error(l);
72
+
73
+ const bytes = new Uint8Array(await readFile(dbPath).catch(() => {
74
+ throw new Error(
75
+ `no such database file: '${dbPath}' — publish uploads an existing ` +
76
+ 'local SQLite file');
77
+ }));
78
+
79
+ let stamp = values.stamp;
80
+ if (values.buy) {
81
+ if (stamp !== 'auto') throw new Error('--buy and --stamp are mutually exclusive');
82
+ const { planBatch, buyBatch } = await import('../src/publisher.js');
83
+ const plan = await planBatch(apiUrl, bytes.byteLength, parseTtl(values.ttl));
84
+ console.error(
85
+ `batch for ${(bytes.byteLength / 2 ** 20).toFixed(1)} MB: depth ${plan.depth}, ` +
86
+ `amount ${plan.amount}, lasting ~${Math.round(plan.ttlSecs / 3600)} h ` +
87
+ `-> ${plan.costBzz.toFixed(4)} xBZZ from the node's wallet`);
88
+ if (!values.yes) {
89
+ if (!process.stdin.isTTY) {
90
+ throw new Error('--buy needs a terminal to confirm; pass --yes to skip');
91
+ }
92
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
93
+ const answer = (await rl.question('buy it? [y/N] ')).trim().toLowerCase();
94
+ rl.close();
95
+ if (answer !== 'y' && answer !== 'yes') {
96
+ throw new Error('purchase declined; nothing was bought');
97
+ }
98
+ }
99
+ console.error('buying (waits for on-chain confirmation) ...');
100
+ stamp = await buyBatch(apiUrl, plan.amount, plan.depth);
101
+ console.error(`bought batch ${stamp}`);
102
+ }
103
+
104
+ const { publish } = await import('../src/publisher.js');
105
+ const { root } = await publish(bytes, {
106
+ apiUrl,
107
+ name: values.name ?? basename(dbPath),
108
+ stamp,
109
+ feed: values.feed ?? null,
110
+ signer: values.signer ?? process.env.SWARMLITE_SIGNER ?? null,
111
+ moduleOptions,
112
+ log,
113
+ });
114
+ console.log(root);
115
+ }
116
+
117
+ async function cmdQuery(args) {
118
+ const { values, positionals } = parseArgs({
119
+ args,
120
+ allowPositionals: true,
121
+ options: {
122
+ verify: { type: 'boolean', default: false },
123
+ stats: { type: 'boolean', default: false },
124
+ 'api-url': { type: 'string' },
125
+ },
126
+ });
127
+ const [url, sql] = positionals;
128
+ if (!url || !sql) throw new Error('usage: swarmlite query <url> <sql> [--verify]');
129
+ rejectPlaceholders({ url });
130
+ // accept bzz://<root>/<path> as shorthand for a gateway URL
131
+ const apiUrl = values['api-url'] ?? process.env.BEE_API_URL ?? 'http://localhost:1633';
132
+ const resolved = url.startsWith('bzz://')
133
+ ? `${apiUrl}/bzz/${url.slice('bzz://'.length)}`
134
+ : url;
135
+
136
+ const { open } = await import('../src/index.js');
137
+ const db = await open(resolved, { verify: values.verify, moduleOptions });
138
+ try {
139
+ for (const row of await db.query(sql)) {
140
+ console.log(Object.values(row).join('\t'));
141
+ }
142
+ if (values.stats) {
143
+ const s = db.stats();
144
+ console.error(
145
+ `fetched ${s.pagesFetched} pages (${(s.bytesFetched / 1024).toFixed(0)} KB) ` +
146
+ `in ${s.readCount} reads, of a ${(s.fileSize / 2 ** 20).toFixed(1)} MB file` +
147
+ (s.chunksFetched ? ` — ${s.chunksFetched} verified chunks` : ''));
148
+ }
149
+ } finally {
150
+ await db.close();
151
+ }
152
+ }
153
+
154
+ const [command, ...rest] = process.argv.slice(2);
155
+ try {
156
+ if (command === 'publish') await cmdPublish(rest);
157
+ else if (command === 'query') await cmdQuery(rest);
158
+ else {
159
+ console.error(
160
+ 'usage: swarmlite publish <db> [options] | swarmlite query <url> <sql> [--verify]');
161
+ process.exit(2);
162
+ }
163
+ } catch (e) {
164
+ if (process.env.SWARMLITE_DEBUG) throw e;
165
+ console.error(`swarmlite: ${e.message}`);
166
+ process.exit(1);
167
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "swarmlite",
3
+ "version": "0.2.0",
4
+ "description": "Verifiable serverless SQLite on Ethereum Swarm: lazy SELECT over gateway range reads (browser and Node), client-side verification, and a pure-JS publisher",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./publisher": "./src/publisher.js",
10
+ "./prepare": "./src/prepare.js",
11
+ "./verify": "./src/verify.js",
12
+ "./feeds": "./src/feeds.js"
13
+ },
14
+ "bin": {
15
+ "swarmlite": "bin/swarmlite.mjs"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "vendor",
20
+ "bin"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/petfold/swarmlite.git",
28
+ "directory": "js"
29
+ },
30
+ "homepage": "https://github.com/petfold/swarmlite#readme",
31
+ "keywords": [
32
+ "swarm",
33
+ "ethswarm",
34
+ "sqlite",
35
+ "wasm",
36
+ "serverless",
37
+ "decentralized",
38
+ "web3",
39
+ "vfs",
40
+ "content-addressed"
41
+ ],
42
+ "license": "BSD-3-Clause",
43
+ "author": "Peter Foldiak",
44
+ "scripts": {
45
+ "test": "node test/feed.mjs && node test/verify.mjs && node test/publish.mjs",
46
+ "smoke": "node test/smoke.mjs",
47
+ "serve": "node test/serve.mjs"
48
+ }
49
+ }
@@ -0,0 +1,274 @@
1
+ // swarmlite browser VFS: lazy, read-only SQLite pages over Swarm gateway
2
+ // range reads. The JS twin of swarmlite/vfs.py — same design, same
3
+ // counters (docs/DESIGN.md section 4).
4
+ //
5
+ // Works with wa-sqlite's Asyncify build: VFS methods may return
6
+ // handleAsync(async () => ...), so page fetches are ordinary async
7
+ // fetch() calls — no SharedArrayBuffer, no COOP/COEP headers.
8
+ //
9
+ // The "filename" SQLite opens is a URL: anything a gateway serves with
10
+ // HTTP Range support, typically
11
+ // http://localhost:1633/bzz/<root>/site.db (pin, immutable)
12
+ // https://gateway.example/bzz/<root>/site.db
13
+ // Feed URLs must be resolved to a root by the caller before opening
14
+ // (a feed can move mid-session; a database must not).
15
+ //
16
+ // Read-only by design: journal/WAL are reported absent, writes fail.
17
+ // Chunk-level BMT verification is a documented follow-up (roadmap):
18
+ // against a local light node, retrieved chunks are verified by the node.
19
+
20
+ import * as VFS from '../vendor/wa-sqlite/src/VFS.js';
21
+
22
+ export const PAGE_SIZE = 4096; // SQLite default page == Swarm chunk
23
+ const DEFAULT_CACHE_PAGES = 1024; // ~4 MiB
24
+ const MAX_RUN_PAGES = 16; // cap one Range request at 64 KiB — big ranges
25
+ // are the ones gateways abort on fresh content
26
+ const FETCH_TIMEOUT_MS = 30_000;
27
+
28
+ export class SwarmVFS extends VFS.Base {
29
+ name = 'swarmlite';
30
+ mxPathName = 1024; // filenames are URLs
31
+
32
+ /** @type {Map<number, object>} sqlite3_file id -> open file state */
33
+ #files = new Map();
34
+ /** @type {Map<string, string>} short alias -> URL */
35
+ #urls = new Map();
36
+ /** @type {Map<string, object>} short alias -> {size, read} source */
37
+ #sources = new Map();
38
+ #cachePages;
39
+ #fetch;
40
+
41
+ constructor({ cachePages = DEFAULT_CACHE_PAGES, fetchFn } = {}) {
42
+ super();
43
+ this.#cachePages = Math.max(1, cachePages);
44
+ this.#fetch = fetchFn ?? ((...args) => globalThis.fetch(...args));
45
+ }
46
+
47
+ /** Map a short alias to a database URL. SQLite opens the alias; the
48
+ * VFS fetches the URL. Necessary because VFS filename length is
49
+ * limited (mxPathName can be as small as 64 in some builds), and it
50
+ * keeps URLs out of SQLite's filename handling entirely. */
51
+ registerUrl(alias, url) {
52
+ this.#urls.set(alias, url);
53
+ }
54
+
55
+ /** Map an alias to a custom page source instead of a URL:
56
+ * `{ size, read(from, toInclusive) -> Promise<Uint8Array> }`. Used
57
+ * for verified reads, where pages come from a chunk-tree walk rather
58
+ * than gateway Range requests. */
59
+ registerSource(alias, source) {
60
+ this.#sources.set(alias, source);
61
+ }
62
+
63
+ xOpen(name, fileId, flags, pOutFlags) {
64
+ return this.handleAsync(async () => {
65
+ if (!(flags & VFS.SQLITE_OPEN_MAIN_DB)) {
66
+ return VFS.SQLITE_CANTOPEN; // no journals, no temp files
67
+ }
68
+ const source = this.#sources.get(name);
69
+ const url = this.#urls.get(name) ?? name;
70
+ try {
71
+ let size;
72
+ if (source) {
73
+ size = source.size;
74
+ } else {
75
+ // one 1-byte range read: existence check + total size
76
+ const resp = await this.#fetch(url, {
77
+ headers: { Range: 'bytes=0-0' },
78
+ });
79
+ size = contentRangeTotal(resp);
80
+ if (size === null) {
81
+ console.error(
82
+ `swarmlite: ${url} -> HTTP ${resp.status}, ` +
83
+ `Content-Range ${resp.headers.get('content-range')} — ` +
84
+ `need a gateway URL with Range support`,
85
+ );
86
+ return VFS.SQLITE_CANTOPEN;
87
+ }
88
+ }
89
+ this.#files.set(fileId, {
90
+ alias: name,
91
+ url,
92
+ source,
93
+ size,
94
+ cache: new Map(), // page index -> Uint8Array (Map keeps LRU order)
95
+ stats: { readCount: 0, pagesFetched: 0, bytesFetched: 0, fileSize: size },
96
+ });
97
+ pOutFlags.setInt32(0, VFS.SQLITE_OPEN_READONLY, true);
98
+ return VFS.SQLITE_OK;
99
+ } catch (e) {
100
+ console.error(`swarmlite: open ${url} failed:`, e);
101
+ return VFS.SQLITE_CANTOPEN;
102
+ }
103
+ });
104
+ }
105
+
106
+ xClose(fileId) {
107
+ this.#files.delete(fileId);
108
+ return VFS.SQLITE_OK;
109
+ }
110
+
111
+ xRead(fileId, pData, iOffset) {
112
+ return this.handleAsync(async () => {
113
+ const file = this.#files.get(fileId);
114
+ if (!file) return VFS.SQLITE_IOERR;
115
+ try {
116
+ const wanted = Math.min(iOffset + pData.byteLength, file.size) - iOffset;
117
+ if (wanted > 0) {
118
+ const data = await this.#readRange(file, iOffset, wanted);
119
+ pData.set(data, 0);
120
+ }
121
+ if (wanted < pData.byteLength) {
122
+ // short read past EOF: zero-fill, per SQLite contract
123
+ pData.fill(0, Math.max(wanted, 0));
124
+ return VFS.SQLITE_IOERR_SHORT_READ;
125
+ }
126
+ return VFS.SQLITE_OK;
127
+ } catch (e) {
128
+ console.error(`swarmlite: read @${iOffset} failed:`, e);
129
+ return VFS.SQLITE_IOERR;
130
+ }
131
+ });
132
+ }
133
+
134
+ xFileSize(fileId, pSize64) {
135
+ const file = this.#files.get(fileId);
136
+ if (!file) return VFS.SQLITE_IOERR;
137
+ pSize64.setBigInt64(0, BigInt(file.size), true);
138
+ return VFS.SQLITE_OK;
139
+ }
140
+
141
+ xAccess(name, flags, pResOut) {
142
+ pResOut.setInt32(0, 0, true); // journals/WAL never exist
143
+ return VFS.SQLITE_OK;
144
+ }
145
+
146
+ xSectorSize(fileId) {
147
+ return PAGE_SIZE;
148
+ }
149
+
150
+ xDeviceCharacteristics(fileId) {
151
+ return VFS.SQLITE_IOCAP_IMMUTABLE; // content-addressed: never changes
152
+ }
153
+
154
+ xWrite() {
155
+ return VFS.SQLITE_READONLY;
156
+ }
157
+
158
+ xTruncate() {
159
+ return VFS.SQLITE_READONLY;
160
+ }
161
+
162
+ xDelete() {
163
+ return VFS.SQLITE_READONLY;
164
+ }
165
+
166
+ /** Read counters for an open database URL or alias (last opened wins). */
167
+ statsFor(urlOrAlias) {
168
+ for (const file of this.#files.values()) {
169
+ if (file.url === urlOrAlias || file.alias === urlOrAlias) {
170
+ return { ...file.stats };
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ // ---- transport ----------------------------------------------------------
177
+
178
+ /** Ranged fetch with retry: gateways occasionally close a range read
179
+ * mid-stream on freshly uploaded content (seen live against Bee 2.8.1);
180
+ * a short backoff and retry recovers. */
181
+ async #fetchRange(url, from, to, attempts = 5) {
182
+ let lastErr;
183
+ for (let attempt = 1; attempt <= attempts; attempt++) {
184
+ try {
185
+ const resp = await this.#fetch(url, {
186
+ headers: { Range: `bytes=${from}-${to}` },
187
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
188
+ });
189
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} for bytes=${from}-${to}`);
190
+ const blob = new Uint8Array(await resp.arrayBuffer());
191
+ if (blob.byteLength !== to - from + 1) {
192
+ throw new Error(
193
+ `truncated range read: got ${blob.byteLength} of ${to - from + 1}`);
194
+ }
195
+ return blob;
196
+ } catch (e) {
197
+ lastErr = e;
198
+ if (attempt < attempts) {
199
+ // seen live: freshly uploaded content can be unretrievable for
200
+ // seconds while the node syncs — back off up to ~7.5 s total
201
+ console.warn(
202
+ `swarmlite: retry ${attempt} for bytes=${from}-${to} (${e})`);
203
+ await new Promise((r) => setTimeout(r, 500 * 2 ** (attempt - 1)));
204
+ }
205
+ }
206
+ }
207
+ throw lastErr;
208
+ }
209
+
210
+ // ---- paging ------------------------------------------------------------
211
+
212
+ /** Serve [offset, offset+length) from the page cache, fetching each
213
+ * contiguous run of missing pages with a single Range request. */
214
+ async #readRange(file, offset, length) {
215
+ const first = Math.floor(offset / PAGE_SIZE);
216
+ const last = Math.floor((offset + length - 1) / PAGE_SIZE);
217
+
218
+ const pages = new Map();
219
+ for (let i = first; i <= last; i++) {
220
+ const hit = file.cache.get(i);
221
+ if (hit !== undefined) {
222
+ file.cache.delete(i); // refresh LRU position
223
+ file.cache.set(i, hit);
224
+ pages.set(i, hit);
225
+ }
226
+ }
227
+
228
+ let i = first;
229
+ while (i <= last) {
230
+ if (pages.has(i)) { i++; continue; }
231
+ let j = i;
232
+ while (j <= last && !pages.has(j) && j - i < MAX_RUN_PAGES) j++;
233
+ const from = i * PAGE_SIZE;
234
+ const to = Math.min(j * PAGE_SIZE, file.size) - 1;
235
+ const blob = file.source
236
+ ? await file.source.read(from, to)
237
+ : await this.#fetchRange(file.url, from, to);
238
+ file.stats.readCount += 1;
239
+ file.stats.bytesFetched += blob.byteLength;
240
+ for (let k = i; k < j; k++) {
241
+ const page = blob.subarray((k - i) * PAGE_SIZE, (k - i + 1) * PAGE_SIZE);
242
+ pages.set(k, page);
243
+ file.cache.set(k, page);
244
+ file.stats.pagesFetched += 1;
245
+ }
246
+ i = j;
247
+ }
248
+
249
+ while (file.cache.size > this.#cachePages) {
250
+ file.cache.delete(file.cache.keys().next().value); // evict LRU
251
+ }
252
+
253
+ const out = new Uint8Array(length);
254
+ let outPos = 0;
255
+ let cur = offset;
256
+ while (outPos < length) {
257
+ const k = Math.floor(cur / PAGE_SIZE);
258
+ const page = pages.get(k);
259
+ const pageStart = cur - k * PAGE_SIZE;
260
+ const n = Math.min(PAGE_SIZE - pageStart, length - outPos);
261
+ out.set(page.subarray(pageStart, pageStart + n), outPos);
262
+ outPos += n;
263
+ cur += n;
264
+ }
265
+ return out;
266
+ }
267
+ }
268
+
269
+ /** Parse the total size out of a 206 response's Content-Range header. */
270
+ function contentRangeTotal(resp) {
271
+ if (resp.status !== 206) return null;
272
+ const m = /\/(\d+)\s*$/.exec(resp.headers.get('content-range') ?? '');
273
+ return m ? Number(m[1]) : null;
274
+ }