Skip to content

Commit 6f92bbf

Browse files
authored
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
1 parent d15bb2f commit 6f92bbf

File tree

6 files changed

+304
-308
lines changed

6 files changed

+304
-308
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ npm install @filecoin-saturn/js-client
1111
## Usage
1212

1313
```js
14-
import Saturn from '@filecoin-saturn/js-client'
14+
import { Saturn } from '@filecoin-saturn/js-client'
1515

1616
const client = new Saturn()
1717

src/client.js

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { CID } from 'multiformats'
2+
3+
import { extractVerifiedContent } from './utils/car.js'
4+
import { asAsyncIterable, asyncIteratorToBuffer } from './utils/itr.js'
5+
import { randomUUID } from './utils/uuid.js'
6+
import { memoryStorage } from './storage/index.js'
7+
import { getJWT } from './utils/jwt.js'
8+
9+
export class Saturn {
10+
/**
11+
*
12+
* @param {object} [opts={}]
13+
* @param {string} [opts.clientKey]
14+
* @param {string} [opts.clientId=randomUUID()]
15+
* @param {string} [opts.cdnURL=saturn.ms]
16+
* @param {number} [opts.connectTimeout=5000]
17+
* @param {number} [opts.downloadTimeout=0]
18+
* @param {import('./storage/index.js').Storage} [opts.storage]
19+
*/
20+
constructor (opts = {}) {
21+
this.opts = Object.assign({}, {
22+
clientId: randomUUID(),
23+
cdnURL: 'saturn.ms',
24+
logURL: 'https://twb3qukm2i654i3tnvx36char40aymqq.lambda-url.us-west-2.on.aws/',
25+
authURL: 'https://fz3dyeyxmebszwhuiky7vggmsu0rlkoy.lambda-url.us-west-2.on.aws/',
26+
connectTimeout: 5_000,
27+
downloadTimeout: 0
28+
}, opts)
29+
30+
if (!this.opts.clientKey) {
31+
throw new Error('clientKey is required')
32+
}
33+
34+
this.logs = []
35+
this.storage = this.opts.storage || memoryStorage()
36+
this.reportingLogs = process?.env?.NODE_ENV !== 'development'
37+
this.hasPerformanceAPI = typeof window !== 'undefined' && window?.performance
38+
this.isBrowser = typeof window !== 'undefined'
39+
if (this.reportingLogs && this.hasPerformanceAPI) {
40+
this._monitorPerformanceBuffer()
41+
}
42+
}
43+
44+
/**
45+
*
46+
* @param {string} cidPath
47+
* @param {object} [opts={}]
48+
* @param {('car'|'raw')} [opts.format]
49+
* @param {number} [opts.connectTimeout=5000]
50+
* @param {number} [opts.downloadTimeout=0]
51+
* @returns {Promise<object>}
52+
*/
53+
async fetchCID (cidPath, opts = {}) {
54+
const [cid] = (cidPath ?? '').split('/')
55+
CID.parse(cid)
56+
57+
const jwt = await getJWT(this.opts, this.storage)
58+
59+
const options = Object.assign({}, this.opts, { format: 'car', jwt }, opts)
60+
const url = this.createRequestURL(cidPath, options)
61+
62+
const log = {
63+
url,
64+
startTime: new Date()
65+
}
66+
67+
const controller = options.controller ?? new AbortController()
68+
const connectTimeout = setTimeout(() => {
69+
controller.abort()
70+
}, options.connectTimeout)
71+
72+
if (!this.isBrowser) {
73+
options.headers = {
74+
...(options.headers || {}),
75+
Authorization: 'Bearer ' + options.jwt
76+
}
77+
}
78+
79+
let res
80+
try {
81+
res = await fetch(url, { signal: controller.signal, ...options })
82+
83+
clearTimeout(connectTimeout)
84+
85+
const { headers } = res
86+
log.ttfbMs = new Date() - log.startTime
87+
log.httpStatusCode = res.status
88+
log.cacheHit = headers.get('saturn-cache-status') === 'HIT'
89+
log.nodeId = headers.get('saturn-node-id')
90+
log.requestId = headers.get('saturn-transfer-id')
91+
log.httpProtocol = headers.get('quic-status')
92+
93+
if (!res.ok) {
94+
throw new Error(
95+
`Non OK response received: ${res.status} ${res.statusText}`
96+
)
97+
}
98+
} catch (err) {
99+
if (!res) {
100+
log.error = err.message
101+
}
102+
// Report now if error, otherwise report after download is done.
103+
this._finalizeLog(log)
104+
105+
throw err
106+
}
107+
108+
return { res, controller, log }
109+
}
110+
111+
/**
112+
*
113+
* @param {string} cidPath
114+
* @param {object} [opts={}]
115+
* @param {('car'|'raw')} [opts.format]
116+
* @param {number} [opts.connectTimeout=5000]
117+
* @param {number} [opts.downloadTimeout=0]
118+
* @returns {Promise<AsyncIterable<Uint8Array>>}
119+
*/
120+
async * fetchContent (cidPath, opts = {}) {
121+
const { res, controller, log } = await this.fetchCID(cidPath, opts)
122+
123+
async function * metricsIterable (itr) {
124+
log.numBytesSent = 0
125+
126+
for await (const chunk of itr) {
127+
log.numBytesSent += chunk.length
128+
yield chunk
129+
}
130+
}
131+
132+
try {
133+
const itr = metricsIterable(asAsyncIterable(res.body))
134+
yield * extractVerifiedContent(cidPath, itr)
135+
} catch (err) {
136+
log.error = err.message
137+
controller.abort()
138+
139+
throw err
140+
} finally {
141+
this._finalizeLog(log)
142+
}
143+
}
144+
145+
/**
146+
*
147+
* @param {string} cidPath
148+
* @param {object} [opts={}]
149+
* @param {('car'|'raw')} [opts.format]
150+
* @param {number} [opts.connectTimeout=5000]
151+
* @param {number} [opts.downloadTimeout=0]
152+
* @returns {Promise<Uint8Array>}
153+
*/
154+
async fetchContentBuffer (cidPath, opts = {}) {
155+
return await asyncIteratorToBuffer(this.fetchContent(cidPath, opts))
156+
}
157+
158+
/**
159+
*
160+
* @param {string} cidPath
161+
* @param {object} [opts={}]
162+
* @returns {URL}
163+
*/
164+
createRequestURL (cidPath, opts) {
165+
let origin = opts.cdnURL
166+
if (!origin.startsWith('http')) {
167+
origin = `https://${origin}`
168+
}
169+
const url = new URL(`${origin}/ipfs/${cidPath}`)
170+
171+
url.searchParams.set('format', opts.format)
172+
if (opts.format === 'car') {
173+
url.searchParams.set('dag-scope', 'entity')
174+
}
175+
176+
if (this.isBrowser) {
177+
url.searchParams.set('jwt', opts.jwt)
178+
}
179+
180+
return url
181+
}
182+
183+
/**
184+
*
185+
* @param {object} log
186+
*/
187+
_finalizeLog (log) {
188+
log.requestDurationSec = (new Date() - log.startTime) / 1000
189+
this.reportLogs(log)
190+
}
191+
192+
/**
193+
*
194+
* @param {object} log
195+
*/
196+
reportLogs (log) {
197+
if (!this.reportingLogs) return
198+
199+
this.logs.push(log)
200+
this.reportLogsTimeout && clearTimeout(this.reportLogsTimeout)
201+
this.reportLogsTimeout = setTimeout(this._reportLogs.bind(this), 3_000)
202+
}
203+
204+
async _reportLogs () {
205+
if (!this.logs.length) {
206+
return
207+
}
208+
209+
const bandwidthLogs = this.hasPerformanceAPI
210+
? this._matchLogsWithPerformanceMetrics(this.logs)
211+
: this.logs
212+
213+
await fetch(
214+
this.opts.logURL,
215+
{
216+
method: 'POST',
217+
body: JSON.stringify({ bandwidthLogs, logSender: this.opts.logSender })
218+
}
219+
)
220+
221+
this.logs = []
222+
this._clearPerformanceBuffer()
223+
}
224+
225+
/**
226+
*
227+
* @param {Array<object>} logs
228+
*/
229+
_matchLogsWithPerformanceMetrics (logs) {
230+
return logs
231+
.map(log => ({ ...log, ...this._getPerformanceMetricsForLog(log) }))
232+
.filter(log => !log.isFromBrowserCache)
233+
.map(log => {
234+
const { isFromBrowserCache: _, ...cleanLog } = log
235+
return cleanLog
236+
})
237+
}
238+
239+
/**
240+
*
241+
* @param {object} log
242+
* @returns {object}
243+
*/
244+
_getPerformanceMetricsForLog (log) {
245+
const metrics = {}
246+
247+
// URL is the best differentiator available, though there can be multiple entries per URL.
248+
// It's a good enough heuristic.
249+
const entry = performance
250+
.getEntriesByType('resource')
251+
.find((r) => r.name === log.url.href)
252+
253+
if (entry) {
254+
const dnsStart = entry.domainLookupStart
255+
const dnsEnd = entry.domainLookupEnd
256+
const hasDnsMetrics = dnsEnd > 0 && dnsStart > 0
257+
258+
if (hasDnsMetrics) {
259+
metrics.dnsTimeMs = Math.round(dnsEnd - dnsStart)
260+
metrics.ttfbAfterDnsMs = Math.round(
261+
entry.responseStart - entry.requestStart
262+
)
263+
}
264+
265+
if (entry.nextHopProtocol) {
266+
metrics.httpProtocol = entry.nextHopProtocol
267+
}
268+
269+
metrics.isFromBrowserCache = (
270+
entry.deliveryType === 'cache' ||
271+
(log.httpStatusCode && entry.transferSize === 0)
272+
)
273+
}
274+
275+
return metrics
276+
}
277+
278+
_monitorPerformanceBuffer () {
279+
// Using static method prevents multiple unnecessary listeners.
280+
performance.addEventListener('resourcetimingbufferfull', Saturn._setResourceBufferSize)
281+
}
282+
283+
static _setResourceBufferSize () {
284+
const increment = 250
285+
const maxSize = 1000
286+
const size = performance.getEntriesByType('resource').length
287+
const newSize = Math.min(size + increment, maxSize)
288+
289+
performance.setResourceTimingBufferSize(newSize)
290+
}
291+
292+
_clearPerformanceBuffer () {
293+
if (this.hasPerformanceAPI) {
294+
performance.clearResourceTimings()
295+
}
296+
}
297+
}

0 commit comments

Comments
 (0)