@helia/verified-fetch 0.0.0-3283a5c

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