fetchurl-sdk 0.1.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/fetchurl.js +404 -0
- package/package.json +25 -0
package/fetchurl.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetchurl SDK for JavaScript.
|
|
3
|
+
*
|
|
4
|
+
* Protocol-level client for fetchurl content-addressable cache servers.
|
|
5
|
+
* Uses Web Crypto API — works in Node.js 19+, Deno, Bun, and browsers.
|
|
6
|
+
* Pass any spec-compliant `fetch` function for dependency injection.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { fetchurl, parseFetchurlServer } from './fetchurl.js';
|
|
10
|
+
*
|
|
11
|
+
* const servers = parseFetchurlServer(process.env.FETCHURL_SERVER ?? '');
|
|
12
|
+
* const data = await fetchurl({
|
|
13
|
+
* fetch,
|
|
14
|
+
* servers,
|
|
15
|
+
* algo: 'sha256',
|
|
16
|
+
* hash: 'e3b0c44...',
|
|
17
|
+
* sourceUrls: ['https://cdn.example.com/file.tar.gz'],
|
|
18
|
+
* });
|
|
19
|
+
* // data is Uint8Array, hash-verified
|
|
20
|
+
*
|
|
21
|
+
* @module fetchurl
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// --- Errors ---
|
|
25
|
+
|
|
26
|
+
export class FetchUrlError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'FetchUrlError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class UnsupportedAlgorithmError extends FetchUrlError {
|
|
34
|
+
constructor(algo) {
|
|
35
|
+
super(`unsupported algorithm: ${algo}`);
|
|
36
|
+
this.name = 'UnsupportedAlgorithmError';
|
|
37
|
+
this.algo = algo;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class HashMismatchError extends FetchUrlError {
|
|
42
|
+
constructor(expected, actual) {
|
|
43
|
+
super(`hash mismatch: expected ${expected}, got ${actual}`);
|
|
44
|
+
this.name = 'HashMismatchError';
|
|
45
|
+
this.expected = expected;
|
|
46
|
+
this.actual = actual;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class AllSourcesFailedError extends FetchUrlError {
|
|
51
|
+
constructor(lastError = null) {
|
|
52
|
+
super('all sources failed');
|
|
53
|
+
this.name = 'AllSourcesFailedError';
|
|
54
|
+
this.lastError = lastError;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class PartialWriteError extends FetchUrlError {
|
|
59
|
+
constructor(cause) {
|
|
60
|
+
super(`partial write: ${cause?.message ?? cause}`);
|
|
61
|
+
this.name = 'PartialWriteError';
|
|
62
|
+
this.cause = cause;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Algorithm helpers ---
|
|
67
|
+
|
|
68
|
+
/** Map from normalized algo name to Web Crypto algorithm identifier. */
|
|
69
|
+
const WEBCRYPTO_ALGOS = {
|
|
70
|
+
sha1: 'SHA-1',
|
|
71
|
+
sha256: 'SHA-256',
|
|
72
|
+
sha512: 'SHA-512',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Normalize algorithm name per spec: lowercase, only [a-z0-9].
|
|
77
|
+
* @param {string} name
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeAlgo(name) {
|
|
81
|
+
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if a hash algorithm is supported.
|
|
86
|
+
* @param {string} algo
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
export function isSupported(algo) {
|
|
90
|
+
return normalizeAlgo(algo) in WEBCRYPTO_ALGOS;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- SFV helpers (RFC 8941 string lists) ---
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Encode URLs as an RFC 8941 string list for the X-Source-Urls header.
|
|
97
|
+
* @param {string[]} urls
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
export function encodeSourceUrls(urls) {
|
|
101
|
+
return urls
|
|
102
|
+
.map((url) => `"${url.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`)
|
|
103
|
+
.join(', ');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse FETCHURL_SERVER env var (RFC 8941 string list).
|
|
108
|
+
* @param {string} value
|
|
109
|
+
* @returns {string[]}
|
|
110
|
+
*/
|
|
111
|
+
export function parseFetchurlServer(value) {
|
|
112
|
+
const results = [];
|
|
113
|
+
let i = 0;
|
|
114
|
+
while (i < value.length) {
|
|
115
|
+
while (i < value.length && (value[i] === ' ' || value[i] === '\t')) i++;
|
|
116
|
+
if (i >= value.length) break;
|
|
117
|
+
|
|
118
|
+
if (value[i] !== '"') {
|
|
119
|
+
while (i < value.length && value[i] !== ',') i++;
|
|
120
|
+
if (i < value.length) i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
i++;
|
|
124
|
+
|
|
125
|
+
let s = '';
|
|
126
|
+
while (i < value.length) {
|
|
127
|
+
if (value[i] === '\\' && i + 1 < value.length) {
|
|
128
|
+
s += value[i + 1];
|
|
129
|
+
i += 2;
|
|
130
|
+
} else if (value[i] === '"') {
|
|
131
|
+
i++;
|
|
132
|
+
break;
|
|
133
|
+
} else {
|
|
134
|
+
s += value[i];
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
results.push(s);
|
|
139
|
+
|
|
140
|
+
while (i < value.length && value[i] !== ',') i++;
|
|
141
|
+
if (i < value.length) i++;
|
|
142
|
+
}
|
|
143
|
+
return results;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Hashing ---
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Try to import node:crypto for incremental hashing (Node/Deno/Bun).
|
|
150
|
+
* Falls back to Web Crypto (buffers entire content) in browsers.
|
|
151
|
+
*/
|
|
152
|
+
let _nodeCrypto = null;
|
|
153
|
+
try {
|
|
154
|
+
_nodeCrypto = await import('node:crypto');
|
|
155
|
+
} catch {
|
|
156
|
+
// Not available (browser) — will use Web Crypto fallback
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toHex(buffer) {
|
|
160
|
+
return Array.from(new Uint8Array(buffer))
|
|
161
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
162
|
+
.join('');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Create an incremental hasher.
|
|
167
|
+
*
|
|
168
|
+
* Uses node:crypto when available (streaming, no buffering).
|
|
169
|
+
* Falls back to Web Crypto (must call finish() with full data).
|
|
170
|
+
*
|
|
171
|
+
* @param {string} algo - Normalized algo name.
|
|
172
|
+
* @returns {{ update(chunk: Uint8Array): void, finish(): Promise<string> }}
|
|
173
|
+
*/
|
|
174
|
+
export function createHasher(algo) {
|
|
175
|
+
if (_nodeCrypto) {
|
|
176
|
+
const h = _nodeCrypto.createHash(algo);
|
|
177
|
+
return {
|
|
178
|
+
update(chunk) {
|
|
179
|
+
h.update(chunk);
|
|
180
|
+
},
|
|
181
|
+
async finish() {
|
|
182
|
+
return h.digest('hex');
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// Web Crypto fallback — accumulate and hash at the end
|
|
187
|
+
const chunks = [];
|
|
188
|
+
let totalLen = 0;
|
|
189
|
+
return {
|
|
190
|
+
update(chunk) {
|
|
191
|
+
chunks.push(new Uint8Array(chunk));
|
|
192
|
+
totalLen += chunk.byteLength;
|
|
193
|
+
},
|
|
194
|
+
async finish() {
|
|
195
|
+
const full = new Uint8Array(totalLen);
|
|
196
|
+
let offset = 0;
|
|
197
|
+
for (const c of chunks) {
|
|
198
|
+
full.set(c, offset);
|
|
199
|
+
offset += c.byteLength;
|
|
200
|
+
}
|
|
201
|
+
const webAlgo = WEBCRYPTO_ALGOS[algo];
|
|
202
|
+
return toHex(await crypto.subtle.digest(webAlgo, full));
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Hash data and return hex string.
|
|
209
|
+
* @param {string} algo - Normalized algo name (sha1, sha256, sha512).
|
|
210
|
+
* @param {Uint8Array} data
|
|
211
|
+
* @returns {Promise<string>} Hex hash.
|
|
212
|
+
*/
|
|
213
|
+
export async function hashData(algo, data) {
|
|
214
|
+
const h = createHasher(algo);
|
|
215
|
+
h.update(data);
|
|
216
|
+
return h.finish();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Verify that data matches the expected hash.
|
|
221
|
+
* @param {string} algo - Normalized algo name.
|
|
222
|
+
* @param {string} expectedHash - Expected hex hash.
|
|
223
|
+
* @param {Uint8Array} data
|
|
224
|
+
* @returns {Promise<void>}
|
|
225
|
+
* @throws {HashMismatchError}
|
|
226
|
+
*/
|
|
227
|
+
export async function verifyHash(algo, expectedHash, data) {
|
|
228
|
+
const actual = await hashData(algo, data);
|
|
229
|
+
if (actual !== expectedHash) {
|
|
230
|
+
throw new HashMismatchError(expectedHash, actual);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- FetchAttempt ---
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @typedef {Object} FetchAttempt
|
|
238
|
+
* @property {string} url - The URL to GET.
|
|
239
|
+
* @property {Record<string, string>} headers - Headers to include.
|
|
240
|
+
*/
|
|
241
|
+
|
|
242
|
+
// --- FetchSession ---
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* State machine driving the fetchurl client protocol.
|
|
246
|
+
*
|
|
247
|
+
* Servers are tried first (with X-Source-Urls), then direct
|
|
248
|
+
* source URLs in random order per spec.
|
|
249
|
+
*/
|
|
250
|
+
export class FetchSession {
|
|
251
|
+
#attempts = [];
|
|
252
|
+
#current = 0;
|
|
253
|
+
#algo;
|
|
254
|
+
#hash;
|
|
255
|
+
#done = false;
|
|
256
|
+
#success = false;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {Object} options
|
|
260
|
+
* @param {string[]} options.servers - Cache server base URLs.
|
|
261
|
+
* @param {string} options.algo - Hash algorithm name.
|
|
262
|
+
* @param {string} options.hash - Expected hex hash.
|
|
263
|
+
* @param {string[]} options.sourceUrls - Direct source URLs.
|
|
264
|
+
*/
|
|
265
|
+
constructor({ servers = [], algo, hash, sourceUrls = [] }) {
|
|
266
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
267
|
+
servers = parseFetchurlServer(process.env.FETCHURL_SERVER || '');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
this.#algo = normalizeAlgo(algo);
|
|
271
|
+
if (!isSupported(this.#algo)) {
|
|
272
|
+
throw new UnsupportedAlgorithmError(this.#algo);
|
|
273
|
+
}
|
|
274
|
+
this.#hash = hash;
|
|
275
|
+
|
|
276
|
+
const sourceHeader =
|
|
277
|
+
sourceUrls.length > 0 ? encodeSourceUrls(sourceUrls) : null;
|
|
278
|
+
|
|
279
|
+
for (const server of servers) {
|
|
280
|
+
const base = server.replace(/\/+$/, '');
|
|
281
|
+
const url = `${base}/api/fetchurl/${this.#algo}/${hash}`;
|
|
282
|
+
const headers = {};
|
|
283
|
+
if (sourceHeader) headers['X-Source-Urls'] = sourceHeader;
|
|
284
|
+
this.#attempts.push({ url, headers });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const shuffled = [...sourceUrls];
|
|
288
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
289
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
290
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
291
|
+
}
|
|
292
|
+
for (const url of shuffled) {
|
|
293
|
+
this.#attempts.push({ url, headers: {} });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Algorithm used (normalized). */
|
|
298
|
+
get algo() {
|
|
299
|
+
return this.#algo;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Expected hash. */
|
|
303
|
+
get hash() {
|
|
304
|
+
return this.#hash;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Get the next attempt, or null if session is finished.
|
|
309
|
+
* If an attempt fails without writing bytes, just call nextAttempt() again.
|
|
310
|
+
* @returns {FetchAttempt | null}
|
|
311
|
+
*/
|
|
312
|
+
nextAttempt() {
|
|
313
|
+
if (this.#done || this.#current >= this.#attempts.length) return null;
|
|
314
|
+
return this.#attempts[this.#current++];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Mark session as successful. */
|
|
318
|
+
reportSuccess() {
|
|
319
|
+
this.#done = true;
|
|
320
|
+
this.#success = true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Mark that bytes were written before failure. Stops further attempts. */
|
|
324
|
+
reportPartial() {
|
|
325
|
+
this.#done = true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** @returns {boolean} */
|
|
329
|
+
succeeded() {
|
|
330
|
+
return this.#success;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- High-level fetch ---
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Fetch and verify a file from fetchurl servers or direct sources.
|
|
338
|
+
*
|
|
339
|
+
* @param {Object} options
|
|
340
|
+
* @param {typeof globalThis.fetch} options.fetch - The fetch function (DI).
|
|
341
|
+
* @param {string[]} [options.servers] - Cache server base URLs.
|
|
342
|
+
* @param {string} options.algo - Hash algorithm (sha1, sha256, sha512).
|
|
343
|
+
* @param {string} options.hash - Expected hex hash.
|
|
344
|
+
* @param {string[]} [options.sourceUrls] - Direct source URLs.
|
|
345
|
+
* @returns {Promise<Uint8Array>} Hash-verified content.
|
|
346
|
+
* @throws {AllSourcesFailedError|PartialWriteError|UnsupportedAlgorithmError}
|
|
347
|
+
*/
|
|
348
|
+
export async function fetchurl({
|
|
349
|
+
fetch: fetchFn,
|
|
350
|
+
servers = [],
|
|
351
|
+
algo,
|
|
352
|
+
hash,
|
|
353
|
+
sourceUrls = [],
|
|
354
|
+
}) {
|
|
355
|
+
const session = new FetchSession({ servers, algo, hash, sourceUrls });
|
|
356
|
+
let lastError = null;
|
|
357
|
+
let attempt;
|
|
358
|
+
|
|
359
|
+
while ((attempt = session.nextAttempt())) {
|
|
360
|
+
let resp;
|
|
361
|
+
try {
|
|
362
|
+
resp = await fetchFn(attempt.url, { headers: attempt.headers });
|
|
363
|
+
} catch (e) {
|
|
364
|
+
lastError = e;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!resp.ok) {
|
|
369
|
+
lastError = new FetchUrlError(`unexpected status ${resp.status}`);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const hasher = createHasher(session.algo);
|
|
374
|
+
const chunks = [];
|
|
375
|
+
let bytesRead = 0;
|
|
376
|
+
try {
|
|
377
|
+
for await (const chunk of resp.body) {
|
|
378
|
+
hasher.update(chunk);
|
|
379
|
+
chunks.push(new Uint8Array(chunk));
|
|
380
|
+
bytesRead += chunk.byteLength;
|
|
381
|
+
}
|
|
382
|
+
const actualHash = await hasher.finish();
|
|
383
|
+
if (actualHash !== session.hash) {
|
|
384
|
+
throw new HashMismatchError(session.hash, actualHash);
|
|
385
|
+
}
|
|
386
|
+
const result = new Uint8Array(bytesRead);
|
|
387
|
+
let offset = 0;
|
|
388
|
+
for (const c of chunks) {
|
|
389
|
+
result.set(c, offset);
|
|
390
|
+
offset += c.byteLength;
|
|
391
|
+
}
|
|
392
|
+
session.reportSuccess();
|
|
393
|
+
return result;
|
|
394
|
+
} catch (e) {
|
|
395
|
+
lastError = e;
|
|
396
|
+
if (bytesRead > 0) {
|
|
397
|
+
session.reportPartial();
|
|
398
|
+
throw new PartialWriteError(e);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
throw new AllSourcesFailedError(lastError);
|
|
404
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fetchurl-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Protocol-level client SDK for fetchurl content-addressable cache servers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": "./fetchurl.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"fetchurl.js"
|
|
9
|
+
],
|
|
10
|
+
"keywords": [
|
|
11
|
+
"fetchurl",
|
|
12
|
+
"cache",
|
|
13
|
+
"content-addressable",
|
|
14
|
+
"hash",
|
|
15
|
+
"sha256"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "lucasew",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/lucasew/fetchurl.git",
|
|
22
|
+
"directory": "sdk/js"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/lucasew/fetchurl"
|
|
25
|
+
}
|