Skip to content

Commit 15bf85b

Browse files
AmeanAsadDiego Rodríguez Baqueroguanzo
authored
feat: implement client fallback mechanism (#16)
* feat: implement a nodes list for clients * feat: implement a storage interface using indexedDb * feat: implement a test suite for fallback * fix: remove unused code * fix: eslint an jsdoc * fix: formatting and consistency * fix: indexDbCheck * chore: change storage implementation * enhancement: simplify node loading * naive fallback implementation * modify fallback * fix formatting and typos * typos * Update .eslintrc Co-authored-by: Diego Rodríguez Baquero <[email protected]> * enhancement: edit storage impl * enhancement: deal with overlapping byte chunks * feat: add fallback test suite * fix: tests running * cleanup content fetch with fallback * add initial origin fetch to fallback * formatting and file re-org * feat: merge main into fallback branch (#22) * Abort on error (#19) * feat: use controller from options if exists. abort fetch if error occurs. * test: check if external abort controller is used * build: move build output to dist/ folder * fix: newline * 0.1.1 * Build exports (#20) * chore: rename file * feat: add new entrypoint with exports. Switch Saturn to named export * build: expose entire module instead of just the default export * docs: update README * 0.2.0 * feat: include worker scopes when checking for browser runtime (#21) * 0.3.0 --------- Co-authored-by: Eric Guan <[email protected]> * load nodes on first success * add fallback limit * fix: fallback bug * put eslint settings in package.json * add nodesListKey as static * fix: resolve process in browser --------- Co-authored-by: Diego Rodríguez Baquero <[email protected]> Co-authored-by: Eric Guan <[email protected]>
1 parent b55dfc8 commit 15bf85b

File tree

8 files changed

+2186
-147
lines changed

8 files changed

+2186
-147
lines changed

package-lock.json

Lines changed: 1515 additions & 107 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"main": "src/index.js",
77
"type": "module",
88
"scripts": {
9-
"test": "NODE_ENV=development node --test test/",
9+
"test": "NODE_ENV=development TESTING=true node --test test/",
1010
"build": "webpack --mode production",
1111
"prepack": "npm run build"
1212
},
@@ -30,6 +30,7 @@
3030
"browser-readablestream-to-it": "^2.0.4",
3131
"idb": "^7.1.1",
3232
"ipfs-unixfs-exporter": "https://gitpkg.now.sh/filecoin-saturn/js-ipfs-unixfs/packages/ipfs-unixfs-exporter?build",
33+
"msw": "^1.3.2",
3334
"multiformats": "^12.1.1"
3435
},
3536
"devDependencies": {
@@ -41,10 +42,17 @@
4142
"webpack-cli": "^4.10.0"
4243
},
4344
"eslintConfig": {
45+
"plugins": [
46+
"jsdoc",
47+
"promise"
48+
],
4449
"extends": "ipfs",
4550
"parserOptions": {
46-
"ecmaVersion": "latest",
47-
"sourceType": "module"
51+
"sourceType": "module",
52+
"ecmaVersion": "latest"
53+
},
54+
"rules": {
55+
"jsdoc/no-undefined-types": 1
4856
}
4957
},
5058
"imports": {
@@ -53,4 +61,4 @@
5361
"publishConfig": {
5462
"access": "public"
5563
}
56-
}
64+
}

src/client.js

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
// @ts-check
2+
13
import { CID } from 'multiformats'
24

35
import { extractVerifiedContent } from './utils/car.js'
46
import { asAsyncIterable, asyncIteratorToBuffer } from './utils/itr.js'
57
import { randomUUID } from './utils/uuid.js'
68
import { memoryStorage } from './storage/index.js'
79
import { getJWT } from './utils/jwt.js'
10+
import { parseUrl, addHttpPrefix } from './utils/url.js'
811
import { isBrowserContext } from './utils/runtime.js'
912

1013
export class Saturn {
14+
static nodesListKey = 'saturn-nodes'
1115
/**
1216
*
1317
* @param {object} [opts={}]
@@ -16,14 +20,18 @@ export class Saturn {
1620
* @param {string} [opts.cdnURL=saturn.ms]
1721
* @param {number} [opts.connectTimeout=5000]
1822
* @param {number} [opts.downloadTimeout=0]
23+
* @param {string} [opts.orchURL]
24+
* @param {number} [opts.fallbackLimit]
1925
* @param {import('./storage/index.js').Storage} [opts.storage]
2026
*/
2127
constructor (opts = {}) {
2228
this.opts = Object.assign({}, {
2329
clientId: randomUUID(),
2430
cdnURL: 'l1s.saturn.ms',
2531
logURL: 'https://twb3qukm2i654i3tnvx36char40aymqq.lambda-url.us-west-2.on.aws/',
32+
orchURL: 'https://orchestrator.strn.pl/nodes?maxNodes=100',
2633
authURL: 'https://fz3dyeyxmebszwhuiky7vggmsu0rlkoy.lambda-url.us-west-2.on.aws/',
34+
fallbackLimit: 5,
2735
connectTimeout: 5_000,
2836
downloadTimeout: 0
2937
}, opts)
@@ -33,13 +41,14 @@ export class Saturn {
3341
}
3442

3543
this.logs = []
36-
this.storage = this.opts.storage || memoryStorage()
44+
this.nodes = []
3745
this.reportingLogs = process?.env?.NODE_ENV !== 'development'
3846
this.hasPerformanceAPI = isBrowserContext && self?.performance
39-
4047
if (this.reportingLogs && this.hasPerformanceAPI) {
4148
this._monitorPerformanceBuffer()
4249
}
50+
this.storage = this.opts.storage || memoryStorage()
51+
this.loadNodesPromise = this._loadNodes(this.opts)
4352
}
4453

4554
/**
@@ -76,10 +85,9 @@ export class Saturn {
7685
Authorization: 'Bearer ' + options.jwt
7786
}
7887
}
79-
8088
let res
8189
try {
82-
res = await fetch(url, { signal: controller.signal, ...options })
90+
res = await fetch(parseUrl(url), { signal: controller.signal, ...options })
8391

8492
clearTimeout(connectTimeout)
8593

@@ -109,6 +117,74 @@ export class Saturn {
109117
return { res, controller, log }
110118
}
111119

120+
/**
121+
*
122+
* @param {string} cidPath
123+
* @param {object} [opts={}]
124+
* @param {('car'|'raw')} [opts.format]
125+
* @param {string} [opts.url]
126+
* @param {number} [opts.connectTimeout=5000]
127+
* @param {number} [opts.downloadTimeout=0]
128+
* @returns {Promise<AsyncIterable<Uint8Array>>}
129+
*/
130+
async * fetchContentWithFallback (cidPath, opts = {}) {
131+
let lastError = null
132+
// we use this to checkpoint at which chunk a request failed.
133+
// this is temporary until range requests are supported.
134+
let byteCountCheckpoint = 0
135+
136+
const fetchContent = async function * () {
137+
let byteCount = 0
138+
const byteChunks = await this.fetchContent(cidPath, opts)
139+
for await (const chunk of byteChunks) {
140+
// avoid sending duplicate chunks
141+
if (byteCount < byteCountCheckpoint) {
142+
// checks for overlapping chunks
143+
const remainingBytes = byteCountCheckpoint - byteCount
144+
if (remainingBytes < chunk.length) {
145+
yield chunk.slice(remainingBytes)
146+
byteCountCheckpoint += chunk.length - remainingBytes
147+
}
148+
} else {
149+
yield chunk
150+
byteCountCheckpoint += chunk.length
151+
}
152+
byteCount += chunk.length
153+
}
154+
}.bind(this)
155+
156+
if (this.nodes.length === 0) {
157+
// fetch from origin in the case that no nodes are loaded
158+
opts.url = this.opts.cdnURL
159+
try {
160+
yield * fetchContent()
161+
return
162+
} catch (err) {
163+
lastError = err
164+
await this.loadNodesPromise
165+
}
166+
}
167+
168+
let fallbackCount = 0
169+
for (const origin of this.nodes) {
170+
if (fallbackCount > this.opts.fallbackLimit) {
171+
return
172+
}
173+
opts.url = origin.url
174+
try {
175+
yield * fetchContent()
176+
return
177+
} catch (err) {
178+
lastError = err
179+
}
180+
fallbackCount += 1
181+
}
182+
183+
if (lastError) {
184+
throw new Error(`All attempts to fetch content have failed. Last error: ${lastError.message}`)
185+
}
186+
}
187+
112188
/**
113189
*
114190
* @param {string} cidPath
@@ -163,10 +239,8 @@ export class Saturn {
163239
* @returns {URL}
164240
*/
165241
createRequestURL (cidPath, opts) {
166-
let origin = opts.cdnURL
167-
if (!origin.startsWith('http')) {
168-
origin = `https://${origin}`
169-
}
242+
let origin = opts.url || opts.cdnURL
243+
origin = addHttpPrefix(origin)
170244
const url = new URL(`${origin}/ipfs/${cidPath}`)
171245

172246
url.searchParams.set('format', opts.format)
@@ -196,7 +270,6 @@ export class Saturn {
196270
*/
197271
reportLogs (log) {
198272
if (!this.reportingLogs) return
199-
200273
this.logs.push(log)
201274
this.reportLogsTimeout && clearTimeout(this.reportLogsTimeout)
202275
this.reportLogsTimeout = setTimeout(this._reportLogs.bind(this), 3_000)
@@ -295,4 +368,46 @@ export class Saturn {
295368
performance.clearResourceTimings()
296369
}
297370
}
371+
372+
async _loadNodes (opts) {
373+
let origin = opts.orchURL
374+
375+
let cacheNodesListPromise
376+
if (this.storage) {
377+
cacheNodesListPromise = this.storage.get(Saturn.nodesListKey)
378+
}
379+
380+
origin = addHttpPrefix(origin)
381+
382+
const url = new URL(origin)
383+
const controller = new AbortController()
384+
const options = Object.assign({}, { method: 'GET' }, this.opts)
385+
386+
const connectTimeout = setTimeout(() => {
387+
controller.abort()
388+
}, options.connectTimeout)
389+
390+
const orchResponse = await fetch(parseUrl(url), { signal: controller.signal, ...options })
391+
const orchNodesListPromise = orchResponse.json()
392+
clearTimeout(connectTimeout)
393+
394+
// This promise races fetching nodes list from the orchestrator and
395+
// and the provided storage object (localStorage, sessionStorage, etc.)
396+
// to insure we have a fallback set as quick as possible
397+
let nodes
398+
if (cacheNodesListPromise) {
399+
nodes = await Promise.any([orchNodesListPromise, cacheNodesListPromise])
400+
} else {
401+
nodes = await orchNodesListPromise
402+
}
403+
404+
// if storage returns first, update based on cached storage.
405+
if (nodes === await cacheNodesListPromise) {
406+
this.nodes = nodes
407+
}
408+
// we always want to update from the orchestrator regardless.
409+
nodes = await orchNodesListPromise
410+
this.nodes = nodes
411+
this.storage?.set(Saturn.nodesListKey, nodes)
412+
}
298413
}

src/utils/url.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// @ts-check
2+
3+
/**
4+
*
5+
* @param {URL} url
6+
* @returns {URL|string}
7+
*/
8+
export function parseUrl (url) {
9+
try {
10+
// This is a temp function to resolve URLs for mock testing
11+
// See issue here: https://github.com/mswjs/msw/issues/1597
12+
if (process?.env?.TESTING) {
13+
return url.toJSON()
14+
}
15+
} catch (e) {}
16+
17+
return url
18+
}
19+
20+
/**
21+
*
22+
* @param {string} url
23+
* @returns {string}
24+
*/
25+
export function addHttpPrefix (url) {
26+
// This is a temp function to resolve URLs for mock testing
27+
// See issue here: https://github.com/mswjs/msw/issues/1597
28+
if (!url.startsWith('http')) {
29+
url = `https://${url}`
30+
}
31+
return url
32+
}

test/car.spec.js

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,13 @@
11
import assert from 'node:assert/strict'
22
import fs from 'node:fs'
33
import { describe, it } from 'node:test'
4-
import { resolve, dirname } from 'node:path'
5-
import { fileURLToPath } from 'node:url'
4+
import { getFixturePath, concatChunks } from './test-utils.js'
65

76
import { CarReader, CarWriter } from '@ipld/car'
87
import { CID } from 'multiformats/cid'
98

109
import { extractVerifiedContent } from '#src/utils/car.js'
1110

12-
const __dirname = dirname(fileURLToPath(import.meta.url))
13-
14-
function getFixturePath (filename) {
15-
return resolve(__dirname, `./fixtures/${filename}`)
16-
}
17-
18-
async function concatChunks (itr) {
19-
const arr = []
20-
for await (const chunk of itr) {
21-
arr.push(chunk)
22-
}
23-
return new Uint8Array(...arr)
24-
}
25-
2611
describe('CAR Verification', () => {
2712
it('should extract content from a valid CAR', async () => {
2813
const cidPath =
@@ -149,8 +134,7 @@ describe('CAR Verification', () => {
149134

150135
await assert.rejects(
151136
async () => {
152-
for await (const _ of extractVerifiedContent(cidPath, out)) {
153-
}
137+
for await (const _ of extractVerifiedContent(cidPath, out)) {}
154138
},
155139
{
156140
name: 'VerificationError',

0 commit comments

Comments
 (0)