@helia/verified-fetch 0.0.0-31cdfa8

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.
Files changed (44) hide show
  1. package/LICENSE +4 -0
  2. package/README.md +299 -0
  3. package/dist/index.min.js +115 -0
  4. package/dist/src/index.d.ts +321 -0
  5. package/dist/src/index.d.ts.map +1 -0
  6. package/dist/src/index.js +290 -0
  7. package/dist/src/index.js.map +1 -0
  8. package/dist/src/singleton.d.ts +3 -0
  9. package/dist/src/singleton.d.ts.map +1 -0
  10. package/dist/src/singleton.js +15 -0
  11. package/dist/src/singleton.js.map +1 -0
  12. package/dist/src/utils/get-stream-from-async-iterable.d.ts +10 -0
  13. package/dist/src/utils/get-stream-from-async-iterable.d.ts.map +1 -0
  14. package/dist/src/utils/get-stream-from-async-iterable.js +38 -0
  15. package/dist/src/utils/get-stream-from-async-iterable.js.map +1 -0
  16. package/dist/src/utils/parse-resource.d.ts +18 -0
  17. package/dist/src/utils/parse-resource.d.ts.map +1 -0
  18. package/dist/src/utils/parse-resource.js +24 -0
  19. package/dist/src/utils/parse-resource.js.map +1 -0
  20. package/dist/src/utils/parse-url-string.d.ts +26 -0
  21. package/dist/src/utils/parse-url-string.d.ts.map +1 -0
  22. package/dist/src/utils/parse-url-string.js +109 -0
  23. package/dist/src/utils/parse-url-string.js.map +1 -0
  24. package/dist/src/utils/tlru.d.ts +15 -0
  25. package/dist/src/utils/tlru.d.ts.map +1 -0
  26. package/dist/src/utils/tlru.js +40 -0
  27. package/dist/src/utils/tlru.js.map +1 -0
  28. package/dist/src/utils/walk-path.d.ts +13 -0
  29. package/dist/src/utils/walk-path.d.ts.map +1 -0
  30. package/dist/src/utils/walk-path.js +17 -0
  31. package/dist/src/utils/walk-path.js.map +1 -0
  32. package/dist/src/verified-fetch.d.ts +67 -0
  33. package/dist/src/verified-fetch.d.ts.map +1 -0
  34. package/dist/src/verified-fetch.js +303 -0
  35. package/dist/src/verified-fetch.js.map +1 -0
  36. package/package.json +180 -0
  37. package/src/index.ts +377 -0
  38. package/src/singleton.ts +20 -0
  39. package/src/utils/get-stream-from-async-iterable.ts +45 -0
  40. package/src/utils/parse-resource.ts +40 -0
  41. package/src/utils/parse-url-string.ts +139 -0
  42. package/src/utils/tlru.ts +52 -0
  43. package/src/utils/walk-path.ts +34 -0
  44. package/src/verified-fetch.ts +370 -0
@@ -0,0 +1,34 @@
1
+ import { walkPath as exporterWalk, type ExporterOptions, type ReadableStorage, type UnixFSEntry } from 'ipfs-unixfs-exporter'
2
+ import type { CID } from 'multiformats/cid'
3
+
4
+ export interface PathWalkerOptions extends ExporterOptions {
5
+
6
+ }
7
+ export interface PathWalkerResponse {
8
+ ipfsRoots: CID[]
9
+ terminalElement: UnixFSEntry
10
+
11
+ }
12
+
13
+ export interface PathWalkerFn {
14
+ (blockstore: ReadableStorage, path: string, options?: PathWalkerOptions): Promise<PathWalkerResponse>
15
+ }
16
+
17
+ export async function walkPath (blockstore: ReadableStorage, path: string, options?: PathWalkerOptions): Promise<PathWalkerResponse> {
18
+ const ipfsRoots: CID[] = []
19
+ let terminalElement: UnixFSEntry | undefined
20
+
21
+ for await (const entry of exporterWalk(path, blockstore, options)) {
22
+ ipfsRoots.push(entry.cid)
23
+ terminalElement = entry
24
+ }
25
+
26
+ if (terminalElement == null) {
27
+ throw new Error('No terminal element found')
28
+ }
29
+
30
+ return {
31
+ ipfsRoots,
32
+ terminalElement
33
+ }
34
+ }
@@ -0,0 +1,370 @@
1
+ import { dagCbor as heliaDagCbor, type DAGCBOR } from '@helia/dag-cbor'
2
+ import { dagJson as heliaDagJson, type DAGJSON } from '@helia/dag-json'
3
+ import { ipns as heliaIpns, type IPNS } from '@helia/ipns'
4
+ import { dnsJsonOverHttps } from '@helia/ipns/dns-resolvers'
5
+ import { json as heliaJson, type JSON } from '@helia/json'
6
+ import { unixfs as heliaUnixFs, type UnixFS as HeliaUnixFs, type UnixFSStats } from '@helia/unixfs'
7
+ import { code as dagCborCode } from '@ipld/dag-cbor'
8
+ import { code as dagJsonCode } from '@ipld/dag-json'
9
+ import { code as dagPbCode } from '@ipld/dag-pb'
10
+ import { code as jsonCode } from 'multiformats/codecs/json'
11
+ import { decode, code as rawCode } from 'multiformats/codecs/raw'
12
+ import { identity } from 'multiformats/hashes/identity'
13
+ import { CustomProgressEvent } from 'progress-events'
14
+ import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
15
+ import { parseResource } from './utils/parse-resource.js'
16
+ import { walkPath, type PathWalkerFn } from './utils/walk-path.js'
17
+ import type { CIDDetail, ContentTypeParser, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
18
+ import type { Helia } from '@helia/interface'
19
+ import type { AbortOptions, Logger } from '@libp2p/interface'
20
+ import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
21
+ import type { CID } from 'multiformats/cid'
22
+
23
+ interface VerifiedFetchComponents {
24
+ helia: Helia
25
+ ipns?: IPNS
26
+ unixfs?: HeliaUnixFs
27
+ dagJson?: DAGJSON
28
+ json?: JSON
29
+ dagCbor?: DAGCBOR
30
+ pathWalker?: PathWalkerFn
31
+ }
32
+
33
+ /**
34
+ * Potential future options for the VerifiedFetch constructor.
35
+ */
36
+ interface VerifiedFetchInit {
37
+ contentTypeParser?: ContentTypeParser
38
+ }
39
+
40
+ interface FetchHandlerFunctionArg {
41
+ cid: CID
42
+ path: string
43
+ terminalElement?: UnixFSEntry
44
+ options?: Omit<VerifiedFetchOptions, 'signal'> & AbortOptions
45
+ }
46
+
47
+ interface FetchHandlerFunction {
48
+ (options: FetchHandlerFunctionArg): Promise<Response>
49
+ }
50
+
51
+ function convertOptions (options?: VerifiedFetchOptions): (Omit<VerifiedFetchOptions, 'signal'> & AbortOptions) | undefined {
52
+ if (options == null) {
53
+ return undefined
54
+ }
55
+
56
+ let signal: AbortSignal | undefined
57
+ if (options?.signal === null) {
58
+ signal = undefined
59
+ }
60
+ return {
61
+ ...options,
62
+ signal
63
+ }
64
+ }
65
+
66
+ function okResponse (body?: BodyInit | null): Response {
67
+ return new Response(body, {
68
+ status: 200,
69
+ statusText: 'OK'
70
+ })
71
+ }
72
+
73
+ function notSupportedResponse (body?: BodyInit | null): Response {
74
+ return new Response(body, {
75
+ status: 501,
76
+ statusText: 'Not Implemented'
77
+ })
78
+ }
79
+
80
+ export class VerifiedFetch {
81
+ private readonly helia: Helia
82
+ private readonly ipns: IPNS
83
+ private readonly unixfs: HeliaUnixFs
84
+ private readonly dagJson: DAGJSON
85
+ private readonly dagCbor: DAGCBOR
86
+ private readonly json: JSON
87
+ private readonly pathWalker: PathWalkerFn
88
+ private readonly log: Logger
89
+ private readonly contentTypeParser: ContentTypeParser | undefined
90
+
91
+ constructor ({ helia, ipns, unixfs, dagJson, json, dagCbor, pathWalker }: VerifiedFetchComponents, init?: VerifiedFetchInit) {
92
+ this.helia = helia
93
+ this.log = helia.logger.forComponent('helia:verified-fetch')
94
+ this.ipns = ipns ?? heliaIpns(helia, {
95
+ resolvers: [
96
+ dnsJsonOverHttps('https://mozilla.cloudflare-dns.com/dns-query'),
97
+ dnsJsonOverHttps('https://dns.google/resolve')
98
+ ]
99
+ })
100
+ this.unixfs = unixfs ?? heliaUnixFs(helia)
101
+ this.dagJson = dagJson ?? heliaDagJson(helia)
102
+ this.json = json ?? heliaJson(helia)
103
+ this.dagCbor = dagCbor ?? heliaDagCbor(helia)
104
+ this.pathWalker = pathWalker ?? walkPath
105
+ this.contentTypeParser = init?.contentTypeParser
106
+ this.log.trace('created VerifiedFetch instance')
107
+ }
108
+
109
+ // handle vnd.ipfs.ipns-record
110
+ private async handleIPNSRecord ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
111
+ const response = notSupportedResponse('vnd.ipfs.ipns-record support is not implemented')
112
+ response.headers.set('X-Content-Type-Options', 'nosniff') // see https://specs.ipfs.tech/http-gateways/path-gateway/#x-content-type-options-response-header
113
+ return response
114
+ }
115
+
116
+ // handle vnd.ipld.car
117
+ private async handleIPLDCar ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
118
+ const response = notSupportedResponse('vnd.ipld.car support is not implemented')
119
+ response.headers.set('X-Content-Type-Options', 'nosniff') // see https://specs.ipfs.tech/http-gateways/path-gateway/#x-content-type-options-response-header
120
+ return response
121
+ }
122
+
123
+ private async handleDagJson ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
124
+ this.log.trace('fetching %c/%s', cid, path)
125
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid, path }))
126
+ const result = await this.dagJson.get(cid, {
127
+ signal: options?.signal,
128
+ onProgress: options?.onProgress
129
+ })
130
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid, path }))
131
+ const response = okResponse(JSON.stringify(result))
132
+ response.headers.set('content-type', 'application/json')
133
+ return response
134
+ }
135
+
136
+ private async handleJson ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
137
+ this.log.trace('fetching %c/%s', cid, path)
138
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid, path }))
139
+ const result: Record<any, any> = await this.json.get(cid, {
140
+ signal: options?.signal,
141
+ onProgress: options?.onProgress
142
+ })
143
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid, path }))
144
+ const response = okResponse(JSON.stringify(result))
145
+ response.headers.set('content-type', 'application/json')
146
+ return response
147
+ }
148
+
149
+ private async handleDagCbor ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
150
+ this.log.trace('fetching %c/%s', cid, path)
151
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid, path }))
152
+ const result = await this.dagCbor.get<Uint8Array>(cid, {
153
+ signal: options?.signal,
154
+ onProgress: options?.onProgress
155
+ })
156
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid, path }))
157
+ const response = okResponse(JSON.stringify(result))
158
+ await this.setContentType(result, path, response)
159
+ return response
160
+ }
161
+
162
+ private async handleDagPb ({ cid, path, options, terminalElement }: FetchHandlerFunctionArg): Promise<Response> {
163
+ this.log.trace('fetching %c/%s', cid, path)
164
+ let resolvedCID = terminalElement?.cid ?? cid
165
+ let stat: UnixFSStats
166
+ if (terminalElement?.type === 'directory') {
167
+ const dirCid = terminalElement.cid
168
+
169
+ const rootFilePath = 'index.html'
170
+ try {
171
+ this.log.trace('found directory at %c/%s, looking for index.html', cid, path)
172
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: dirCid, path: rootFilePath }))
173
+ stat = await this.unixfs.stat(dirCid, {
174
+ path: rootFilePath,
175
+ signal: options?.signal,
176
+ onProgress: options?.onProgress
177
+ })
178
+ this.log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, stat.cid)
179
+ path = rootFilePath
180
+ resolvedCID = stat.cid
181
+ // terminalElement = stat
182
+ } catch (err: any) {
183
+ this.log('error loading path %c/%s', dirCid, rootFilePath, err)
184
+ return notSupportedResponse('Unable to find index.html for directory at given path. Support for directories with implicit root is not implemented')
185
+ } finally {
186
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: dirCid, path: rootFilePath }))
187
+ }
188
+ }
189
+
190
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: resolvedCID, path: '' }))
191
+ const asyncIter = this.unixfs.cat(resolvedCID, {
192
+ signal: options?.signal,
193
+ onProgress: options?.onProgress
194
+ })
195
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: resolvedCID, path: '' }))
196
+ this.log('got async iterator for %c/%s', cid, path)
197
+
198
+ const { stream, firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
199
+ onProgress: options?.onProgress
200
+ })
201
+ const response = okResponse(stream)
202
+ await this.setContentType(firstChunk, path, response)
203
+
204
+ return response
205
+ }
206
+
207
+ private async handleRaw ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
208
+ this.log.trace('fetching %c/%s', cid, path)
209
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid, path }))
210
+ const result = await this.helia.blockstore.get(cid)
211
+ options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid, path }))
212
+ const response = okResponse(decode(result))
213
+ await this.setContentType(result, path, response)
214
+ return response
215
+ }
216
+
217
+ private async setContentType (bytes: Uint8Array, path: string, response: Response): Promise<void> {
218
+ let contentType = 'application/octet-stream'
219
+
220
+ if (this.contentTypeParser != null) {
221
+ try {
222
+ let fileName = path.split('/').pop()?.trim()
223
+ fileName = fileName === '' ? undefined : fileName
224
+ const parsed = this.contentTypeParser(bytes, fileName)
225
+
226
+ if (isPromise(parsed)) {
227
+ const result = await parsed
228
+
229
+ if (result != null) {
230
+ contentType = result
231
+ }
232
+ } else if (parsed != null) {
233
+ contentType = parsed
234
+ }
235
+ } catch (err) {
236
+ this.log.error('Error parsing content type', err)
237
+ }
238
+ }
239
+
240
+ response.headers.set('content-type', contentType)
241
+ }
242
+
243
+ /**
244
+ * Determines the format requested by the client, defaults to `null` if no format is requested.
245
+ *
246
+ * @see https://specs.ipfs.tech/http-gateways/path-gateway/#format-request-query-parameter
247
+ * @default 'raw'
248
+ */
249
+ private getFormat ({ headerFormat, queryFormat }: { headerFormat: string | null, queryFormat: string | null }): string | null {
250
+ const formatMap: Record<string, string> = {
251
+ 'vnd.ipld.raw': 'raw',
252
+ 'vnd.ipld.car': 'car',
253
+ 'application/x-tar': 'tar',
254
+ 'application/vnd.ipld.dag-json': 'dag-json',
255
+ 'application/vnd.ipld.dag-cbor': 'dag-cbor',
256
+ 'application/json': 'json',
257
+ 'application/cbor': 'cbor',
258
+ 'vnd.ipfs.ipns-record': 'ipns-record'
259
+ }
260
+
261
+ if (headerFormat != null) {
262
+ for (const format in formatMap) {
263
+ if (headerFormat.includes(format)) {
264
+ return formatMap[format]
265
+ }
266
+ }
267
+ } else if (queryFormat != null) {
268
+ return queryFormat
269
+ }
270
+
271
+ return null
272
+ }
273
+
274
+ /**
275
+ * Map of format to specific handlers for that format.
276
+ * These format handlers should adjust the response headers as specified in https://specs.ipfs.tech/http-gateways/path-gateway/#response-headers
277
+ */
278
+ private readonly formatHandlers: Record<string, FetchHandlerFunction> = {
279
+ raw: async () => notSupportedResponse('application/vnd.ipld.raw support is not implemented'),
280
+ car: this.handleIPLDCar,
281
+ 'ipns-record': this.handleIPNSRecord,
282
+ tar: async () => notSupportedResponse('application/x-tar support is not implemented'),
283
+ 'dag-json': async () => notSupportedResponse('application/vnd.ipld.dag-json support is not implemented'),
284
+ 'dag-cbor': async () => notSupportedResponse('application/vnd.ipld.dag-cbor support is not implemented'),
285
+ json: async () => notSupportedResponse('application/json support is not implemented'),
286
+ cbor: async () => notSupportedResponse('application/cbor support is not implemented')
287
+ }
288
+
289
+ private readonly codecHandlers: Record<number, FetchHandlerFunction> = {
290
+ [dagJsonCode]: this.handleDagJson,
291
+ [dagPbCode]: this.handleDagPb,
292
+ [jsonCode]: this.handleJson,
293
+ [dagCborCode]: this.handleDagCbor,
294
+ [rawCode]: this.handleRaw,
295
+ [identity.code]: this.handleRaw
296
+ }
297
+
298
+ async fetch (resource: Resource, opts?: VerifiedFetchOptions): Promise<Response> {
299
+ const options = convertOptions(opts)
300
+ const { path, query, ...rest } = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options)
301
+ const cid = rest.cid
302
+ let response: Response | undefined
303
+
304
+ const format = this.getFormat({ headerFormat: new Headers(options?.headers).get('accept'), queryFormat: query.format ?? null })
305
+
306
+ if (format != null) {
307
+ // TODO: These should be handled last when they're returning something other than 501
308
+ const formatHandler = this.formatHandlers[format]
309
+
310
+ if (formatHandler != null) {
311
+ response = await formatHandler.call(this, { cid, path, options })
312
+
313
+ if (response.status === 501) {
314
+ return response
315
+ }
316
+ }
317
+ }
318
+
319
+ let terminalElement: UnixFSEntry | undefined
320
+ let ipfsRoots: CID[] | undefined
321
+
322
+ try {
323
+ const pathDetails = await this.pathWalker(this.helia.blockstore, `${cid.toString()}/${path}`, options)
324
+ ipfsRoots = pathDetails.ipfsRoots
325
+ terminalElement = pathDetails.terminalElement
326
+ } catch (err) {
327
+ this.log.error('Error walking path %s', path, err)
328
+ // return new Response(`Error walking path: ${(err as Error).message}`, { status: 500 })
329
+ }
330
+
331
+ if (response == null) {
332
+ const codecHandler = this.codecHandlers[cid.code]
333
+
334
+ if (codecHandler != null) {
335
+ response = await codecHandler.call(this, { cid, path, options, terminalElement })
336
+ } else {
337
+ return notSupportedResponse(`Support for codec with code ${cid.code} is not yet implemented. Please open an issue at https://github.com/ipfs/helia/issues/new`)
338
+ }
339
+ }
340
+
341
+ response.headers.set('etag', cid.toString()) // https://specs.ipfs.tech/http-gateways/path-gateway/#etag-response-header
342
+ response.headers.set('cache-control', 'public, max-age=29030400, immutable')
343
+ response.headers.set('X-Ipfs-Path', resource.toString()) // https://specs.ipfs.tech/http-gateways/path-gateway/#x-ipfs-path-response-header
344
+
345
+ if (ipfsRoots != null) {
346
+ response.headers.set('X-Ipfs-Roots', ipfsRoots.map(cid => cid.toV1().toString()).join(',')) // https://specs.ipfs.tech/http-gateways/path-gateway/#x-ipfs-roots-response-header
347
+ }
348
+ // response.headers.set('Content-Disposition', `TODO`) // https://specs.ipfs.tech/http-gateways/path-gateway/#content-disposition-response-header
349
+
350
+ return response
351
+ }
352
+
353
+ /**
354
+ * Start the Helia instance
355
+ */
356
+ async start (): Promise<void> {
357
+ await this.helia.start()
358
+ }
359
+
360
+ /**
361
+ * Shut down the Helia instance
362
+ */
363
+ async stop (): Promise<void> {
364
+ await this.helia.stop()
365
+ }
366
+ }
367
+
368
+ function isPromise <T> (p?: any): p is Promise<T> {
369
+ return p?.then != null
370
+ }