Skip to content

Commit 362c97e

Browse files
authored
feat: add customer origin url fallback (#37)
* feat: add customer origin url fallback * remove unused code * simplify code * feat: fallback from flat file origins * use format to determine file format * rename origin url * DRY up fetch options * raw fallback url * address comments * fix * fixes * remove unused code * fix again
1 parent b809e94 commit 362c97e

File tree

7 files changed

+181
-94
lines changed

7 files changed

+181
-94
lines changed

src/client.js

Lines changed: 87 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,28 @@ import { isErrorUnavoidable } from './utils/errors.js'
1414
const MAX_NODE_WEIGHT = 100
1515
/**
1616
* @typedef {import('./types.js').Node} Node
17+
* @typedef {import('./types.js').FetchOptions} FetchOptions
1718
*/
1819

1920
export class Saturn {
2021
static nodesListKey = 'saturn-nodes'
2122
static defaultRaceCount = 3
2223
/**
2324
*
24-
* @param {object} [opts={}]
25-
* @param {string} [opts.clientKey]
26-
* @param {string} [opts.clientId=randomUUID()]
27-
* @param {string} [opts.cdnURL=saturn.ms]
28-
* @param {number} [opts.connectTimeout=5000]
29-
* @param {number} [opts.downloadTimeout=0]
30-
* @param {string} [opts.orchURL]
31-
* @param {number} [opts.fallbackLimit]
32-
* @param {boolean} [opts.experimental]
33-
* @param {import('./storage/index.js').Storage} [opts.storage]
25+
* @param {object} [config={}]
26+
* @param {string} [config.clientKey]
27+
* @param {string} [config.clientId=randomUUID()]
28+
* @param {string} [config.cdnURL=saturn.ms]
29+
* @param {number} [config.connectTimeout=5000]
30+
* @param {number} [config.downloadTimeout=0]
31+
* @param {string} [config.orchURL]
32+
* @param {string} [config.customerFallbackURL]
33+
* @param {number} [config.fallbackLimit]
34+
* @param {boolean} [config.experimental]
35+
* @param {import('./storage/index.js').Storage} [config.storage]
3436
*/
35-
constructor (opts = {}) {
36-
this.opts = Object.assign({}, {
37+
constructor (config = {}) {
38+
this.config = Object.assign({}, {
3739
clientId: randomUUID(),
3840
cdnURL: 'l1s.saturn.ms',
3941
logURL: 'https://twb3qukm2i654i3tnvx36char40aymqq.lambda-url.us-west-2.on.aws/',
@@ -42,9 +44,9 @@ export class Saturn {
4244
fallbackLimit: 5,
4345
connectTimeout: 5_000,
4446
downloadTimeout: 0
45-
}, opts)
47+
}, config)
4648

47-
if (!this.opts.clientKey) {
49+
if (!this.config.clientKey) {
4850
throw new Error('clientKey is required')
4951
}
5052

@@ -55,28 +57,24 @@ export class Saturn {
5557
if (this.reportingLogs && this.hasPerformanceAPI) {
5658
this._monitorPerformanceBuffer()
5759
}
58-
this.storage = this.opts.storage || memoryStorage()
59-
this.loadNodesPromise = this.opts.experimental ? this._loadNodes(this.opts) : null
60+
this.storage = this.config.storage || memoryStorage()
61+
this.loadNodesPromise = this.config.experimental ? this._loadNodes(this.config) : null
6062
}
6163

6264
/**
6365
*
6466
* @param {string} cidPath
65-
* @param {object} [opts={}]
66-
* @param {Node[]} [opts.nodes]
67-
* @param {Node} [opts.node]
68-
* @param {('car'|'raw')} [opts.format]
69-
* @param {number} [opts.connectTimeout=5000]
70-
* @param {number} [opts.downloadTimeout=0]
67+
* @param {FetchOptions} [opts={}]
7168
* @returns {Promise<object>}
7269
*/
7370
async fetchCIDWithRace (cidPath, opts = {}) {
74-
const [cid] = (cidPath ?? '').split('/')
75-
CID.parse(cid)
76-
77-
const jwt = await getJWT(this.opts, this.storage)
78-
79-
const options = Object.assign({}, this.opts, { format: 'car', jwt }, opts)
71+
const options = Object.assign({}, this.config, { format: 'car' }, opts)
72+
if (!opts.originFallback) {
73+
const [cid] = (cidPath ?? '').split('/')
74+
CID.parse(cid)
75+
const jwt = await getJWT(options, this.storage)
76+
options.jwt = jwt
77+
}
8078

8179
if (!isBrowserContext) {
8280
options.headers = {
@@ -87,7 +85,7 @@ export class Saturn {
8785

8886
let nodes = options.nodes
8987
if (!nodes || nodes.length === 0) {
90-
const replacementNode = options.node ?? { url: this.opts.cdnURL }
88+
const replacementNode = { url: options.cdnURL }
9189
nodes = [replacementNode]
9290
}
9391
const controllers = []
@@ -157,22 +155,20 @@ export class Saturn {
157155
/**
158156
*
159157
* @param {string} cidPath
160-
* @param {object} [opts={}]
161-
* @param {('car'|'raw')} [opts.format]
162-
* @param {Node} [opts.node]
163-
* @param {number} [opts.connectTimeout=5000]
164-
* @param {number} [opts.downloadTimeout=0]
158+
* @param {FetchOptions} [opts={}]
165159
* @returns {Promise<object>}
166160
*/
167161
async fetchCID (cidPath, opts = {}) {
168-
const [cid] = (cidPath ?? '').split('/')
169-
CID.parse(cid)
170-
171-
const jwt = await getJWT(this.opts, this.storage)
162+
const options = Object.assign({}, this.config, { format: 'car' }, opts)
163+
if (!opts.originFallback) {
164+
const [cid] = (cidPath ?? '').split('/')
165+
CID.parse(cid)
166+
const jwt = await getJWT(this.config, this.storage)
167+
options.jwt = jwt
168+
}
172169

173-
const options = Object.assign({}, this.opts, { format: 'car', jwt }, opts)
174-
const node = options.node
175-
const origin = node?.url ?? this.opts.cdnURL
170+
const node = options.nodes && options.nodes[0]
171+
const origin = node?.url ?? this.config.cdnURL
176172
const url = this.createRequestURL(cidPath, { ...options, url: origin })
177173

178174
let log = {
@@ -242,20 +238,15 @@ export class Saturn {
242238
/**
243239
*
244240
* @param {string} cidPath
245-
* @param {object} [opts={}]
246-
* @param {('car'|'raw')} [opts.format]
247-
* @param {boolean} [opts.raceNodes]
248-
* @param {string} [opts.url]
249-
* @param {number} [opts.connectTimeout=5000]
250-
* @param {number} [opts.downloadTimeout=0]
251-
* @param {AbortController} [opts.controller]
241+
* @param {FetchOptions} [opts={}]
252242
* @returns {Promise<AsyncIterable<Uint8Array>>}
253243
*/
254244
async * fetchContentWithFallback (cidPath, opts = {}) {
255-
const upstreamController = opts.controller;
256-
delete opts.controller;
245+
const upstreamController = opts.controller
246+
delete opts.controller
257247

258248
let lastError = null
249+
let skipNodes = false
259250
// we use this to checkpoint at which chunk a request failed.
260251
// this is temporary until range requests are supported.
261252
let byteCountCheckpoint = 0
@@ -264,16 +255,17 @@ export class Saturn {
264255
throw new Error(`All attempts to fetch content have failed. Last error: ${lastError.message}`)
265256
}
266257

267-
const fetchContent = async function * () {
268-
const controller = new AbortController();
269-
opts.controller = controller;
258+
const fetchContent = async function * (options) {
259+
const controller = new AbortController()
260+
opts.controller = controller
270261
if (upstreamController) {
271262
upstreamController.signal.addEventListener('abort', () => {
272-
controller.abort();
273-
});
263+
controller.abort()
264+
})
274265
}
275266
let byteCount = 0
276-
const byteChunks = await this.fetchContent(cidPath, opts)
267+
const fetchOptions = Object.assign(opts, { format: 'car' }, options)
268+
const byteChunks = await this.fetchContent(cidPath, fetchOptions)
277269
for await (const chunk of byteChunks) {
278270
// avoid sending duplicate chunks
279271
if (byteCount < byteCountCheckpoint) {
@@ -291,33 +283,34 @@ export class Saturn {
291283
}
292284
}.bind(this)
293285

286+
// Use CDN origin if node list is not loaded
294287
if (this.nodes.length === 0) {
295288
// fetch from origin in the case that no nodes are loaded
296-
opts.url = this.opts.cdnURL
289+
opts.nodes = Array({ url: this.config.cdnURL })
297290
try {
298291
yield * fetchContent()
299292
return
300293
} catch (err) {
301294
lastError = err
302295
if (err.res?.status === 410 || isErrorUnavoidable(err)) {
303-
throwError()
296+
skipNodes = true
297+
} else {
298+
await this.loadNodesPromise
304299
}
305-
await this.loadNodesPromise
306300
}
307301
}
308302

309303
let fallbackCount = 0
310304
const nodes = this.nodes
311305
for (let i = 0; i < nodes.length; i++) {
312-
if (fallbackCount > this.opts.fallbackLimit || upstreamController?.signal.aborted) {
313-
return
306+
if (fallbackCount > this.config.fallbackLimit || skipNodes || upstreamController?.signal.aborted) {
307+
break
314308
}
315309
if (opts.raceNodes) {
316310
opts.nodes = nodes.slice(i, i + Saturn.defaultRaceCount)
317311
} else {
318-
opts.node = nodes[i]
312+
opts.nodes = Array(nodes[i])
319313
}
320-
321314
try {
322315
yield * fetchContent()
323316
return
@@ -331,18 +324,25 @@ export class Saturn {
331324
}
332325

333326
if (lastError) {
327+
const originUrl = opts.customerFallbackURL ?? this.config.customerFallbackURL
328+
// Use customer origin if cid is not retrievable by lassie.
329+
if (originUrl) {
330+
opts.nodes = Array({ url: originUrl })
331+
try {
332+
yield * fetchContent({ format: null, originFallback: true })
333+
return
334+
} catch (err) {
335+
lastError = err
336+
}
337+
}
334338
throwError()
335339
}
336340
}
337341

338342
/**
339343
*
340344
* @param {string} cidPath
341-
* @param {object} [opts={}]
342-
* @param {('car'|'raw')} [opts.format]
343-
* @param {boolean} [opts.raceNodes]
344-
* @param {number} [opts.connectTimeout=5000]
345-
* @param {number} [opts.downloadTimeout=0]
345+
* @param {FetchOptions} [opts={}]
346346
* @returns {Promise<AsyncIterable<Uint8Array>>}
347347
*/
348348
async * fetchContent (cidPath, opts = {}) {
@@ -365,7 +365,11 @@ export class Saturn {
365365

366366
try {
367367
const itr = metricsIterable(asAsyncIterable(res.body))
368-
yield * extractVerifiedContent(cidPath, itr)
368+
if (opts.format === 'car') {
369+
yield * extractVerifiedContent(cidPath, itr)
370+
} else {
371+
yield * itr
372+
}
369373
} catch (err) {
370374
log.error = err.message
371375
controller.abort()
@@ -379,11 +383,7 @@ export class Saturn {
379383
/**
380384
*
381385
* @param {string} cidPath
382-
* @param {object} [opts={}]
383-
* @param {('car'|'raw')} [opts.format]
384-
* @param {boolean} [opts.raceNodes]
385-
* @param {number} [opts.connectTimeout=5000]
386-
* @param {number} [opts.downloadTimeout=0]
386+
* @param {FetchOptions} [opts={}]
387387
* @returns {Promise<Uint8Array>}
388388
*/
389389
async fetchContentBuffer (cidPath, opts = {}) {
@@ -395,14 +395,21 @@ export class Saturn {
395395
* @param {string} cidPath
396396
* @param {object} [opts={}]
397397
* @param {string} [opts.url]
398+
* @param {string} [opts.format]
399+
* @param {string} [opts.originFallback]
400+
* @param {object} [opts.jwt]
398401
* @returns {URL}
399402
*/
400-
createRequestURL (cidPath, opts) {
401-
let origin = opts.url ?? this.opts.cdnURL
403+
createRequestURL (cidPath, opts = {}) {
404+
let origin = opts.url ?? this.config.cdnURL
402405
origin = addHttpPrefix(origin)
406+
if (opts.originFallback) {
407+
return new URL(origin)
408+
}
403409
const url = new URL(`${origin}/ipfs/${cidPath}`)
404410

405-
url.searchParams.set('format', opts.format)
411+
if (opts.format) url.searchParams.set('format', opts.format)
412+
406413
if (opts.format === 'car') {
407414
url.searchParams.set('dag-scope', 'entity')
408415
}
@@ -444,10 +451,10 @@ export class Saturn {
444451
: this.logs
445452

446453
await fetch(
447-
this.opts.logURL,
454+
this.config.logURL,
448455
{
449456
method: 'POST',
450-
body: JSON.stringify({ bandwidthLogs, logSender: this.opts.logSender })
457+
body: JSON.stringify({ bandwidthLogs, logSender: this.config.logSender })
451458
}
452459
)
453460

@@ -569,7 +576,7 @@ export class Saturn {
569576

570577
const url = new URL(origin)
571578
const controller = new AbortController()
572-
const options = Object.assign({}, { method: 'GET' }, this.opts)
579+
const options = Object.assign({}, { method: 'GET' }, this.config)
573580

574581
const connectTimeout = setTimeout(() => {
575582
controller.abort()

src/types.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,18 @@
1212
* @property {string} url
1313
*/
1414

15+
/**
16+
* Common options for fetch functions.
17+
*
18+
* @typedef {object} FetchOptions
19+
* @property {Node[]} [nodes] - An array of nodes.
20+
* @property {('car'|'raw')} [format] - The format of the fetched content.
21+
* @property {boolean} [originFallback] - Is this a fallback to the customer origin
22+
* @property {boolean} [raceNodes] - Does the fetch race multiple nodes on requests.
23+
* @property {string} [customerFallbackURL] - Customer Origin that is a fallback.
24+
* @property {number} [connectTimeout=5000] - Connection timeout in milliseconds.
25+
* @property {number} [downloadTimeout=0] - Download timeout in milliseconds.
26+
* @property {AbortController} [controller]
27+
*/
28+
1529
export {}

src/utils/errors.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export function isErrorUnavoidable (error) {
1919
/file does not exist/,
2020
/Cannot read properties of undefined \(reading '([^']+)'\)/,
2121
/([a-zA-Z_.]+) is undefined/,
22-
/undefined is not an object \(evaluating '([^']+)'\)/
22+
/undefined is not an object \(evaluating '([^']+)'\)/,
23+
/all retrievals failed/
2324
]
2425

2526
for (const pattern of errorPatterns) {

0 commit comments

Comments
 (0)