@helia/verified-fetch 0.0.0-8a5bc6f
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.
- package/LICENSE +4 -0
- package/README.md +262 -0
- package/dist/index.min.js +140 -0
- package/dist/src/index.d.ts +259 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +251 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/utils/get-content-type.d.ts +11 -0
- package/dist/src/utils/get-content-type.d.ts.map +1 -0
- package/dist/src/utils/get-content-type.js +43 -0
- package/dist/src/utils/get-content-type.js.map +1 -0
- package/dist/src/utils/get-stream-and-content-type.d.ts +10 -0
- package/dist/src/utils/get-stream-and-content-type.d.ts.map +1 -0
- package/dist/src/utils/get-stream-and-content-type.js +37 -0
- package/dist/src/utils/get-stream-and-content-type.js.map +1 -0
- package/dist/src/utils/parse-resource.d.ts +18 -0
- package/dist/src/utils/parse-resource.d.ts.map +1 -0
- package/dist/src/utils/parse-resource.js +24 -0
- package/dist/src/utils/parse-resource.js.map +1 -0
- package/dist/src/utils/parse-url-string.d.ts +26 -0
- package/dist/src/utils/parse-url-string.d.ts.map +1 -0
- package/dist/src/utils/parse-url-string.js +109 -0
- package/dist/src/utils/parse-url-string.js.map +1 -0
- package/dist/src/utils/tlru.d.ts +15 -0
- package/dist/src/utils/tlru.d.ts.map +1 -0
- package/dist/src/utils/tlru.js +40 -0
- package/dist/src/utils/tlru.js.map +1 -0
- package/dist/src/utils/walk-path.d.ts +13 -0
- package/dist/src/utils/walk-path.d.ts.map +1 -0
- package/dist/src/utils/walk-path.js +17 -0
- package/dist/src/utils/walk-path.js.map +1 -0
- package/dist/src/verified-fetch.d.ts +64 -0
- package/dist/src/verified-fetch.d.ts.map +1 -0
- package/dist/src/verified-fetch.js +261 -0
- package/dist/src/verified-fetch.js.map +1 -0
- package/package.json +176 -0
- package/src/index.ts +310 -0
- package/src/utils/get-content-type.ts +55 -0
- package/src/utils/get-stream-and-content-type.ts +44 -0
- package/src/utils/parse-resource.ts +40 -0
- package/src/utils/parse-url-string.ts +139 -0
- package/src/utils/tlru.ts +52 -0
- package/src/utils/walk-path.ts +34 -0
- package/src/verified-fetch.ts +323 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { peerIdFromString } from '@libp2p/peer-id'
|
|
2
|
+
import { CID } from 'multiformats/cid'
|
|
3
|
+
import { TLRU } from './tlru.js'
|
|
4
|
+
import type { IPNS, IPNSRoutingEvents, ResolveDnsLinkProgressEvents, ResolveProgressEvents, ResolveResult } from '@helia/ipns'
|
|
5
|
+
import type { ComponentLogger } from '@libp2p/interface'
|
|
6
|
+
import type { ProgressOptions } from 'progress-events'
|
|
7
|
+
|
|
8
|
+
const ipnsCache = new TLRU<ResolveResult>(1000)
|
|
9
|
+
|
|
10
|
+
export interface ParseUrlStringInput {
|
|
11
|
+
urlString: string
|
|
12
|
+
ipns: IPNS
|
|
13
|
+
logger: ComponentLogger
|
|
14
|
+
}
|
|
15
|
+
export interface ParseUrlStringOptions extends ProgressOptions<ResolveProgressEvents | IPNSRoutingEvents | ResolveDnsLinkProgressEvents> {
|
|
16
|
+
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ParsedUrlStringResults {
|
|
20
|
+
protocol: string
|
|
21
|
+
path: string
|
|
22
|
+
cid: CID
|
|
23
|
+
query: Record<string, string>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const URL_REGEX = /^(?<protocol>ip[fn]s):\/\/(?<cidOrPeerIdOrDnsLink>[^/$?]+)\/?(?<path>[^$?]*)\??(?<queryString>.*)$/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A function that parses ipfs:// and ipns:// URLs, returning an object with easily recognizable properties.
|
|
30
|
+
*
|
|
31
|
+
* After determining the protocol successfully, we process the cidOrPeerIdOrDnsLink:
|
|
32
|
+
* * If it's ipfs, it parses the CID or throws an Aggregate error
|
|
33
|
+
* * If it's ipns, it attempts to resolve the PeerId and then the DNSLink. If both fail, an Aggregate error is thrown.
|
|
34
|
+
*/
|
|
35
|
+
export async function parseUrlString ({ urlString, ipns, logger }: ParseUrlStringInput, options?: ParseUrlStringOptions): Promise<ParsedUrlStringResults> {
|
|
36
|
+
const log = logger.forComponent('helia:verified-fetch:parse-url-string')
|
|
37
|
+
const match = urlString.match(URL_REGEX)
|
|
38
|
+
|
|
39
|
+
if (match == null || match.groups == null) {
|
|
40
|
+
throw new TypeError(`Invalid URL: ${urlString}, please use ipfs:// or ipns:// URLs only.`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { protocol, cidOrPeerIdOrDnsLink, path: urlPath, queryString } = match.groups
|
|
44
|
+
|
|
45
|
+
let cid: CID | undefined
|
|
46
|
+
let resolvedPath: string | undefined
|
|
47
|
+
const errors: Error[] = []
|
|
48
|
+
|
|
49
|
+
if (protocol === 'ipfs') {
|
|
50
|
+
try {
|
|
51
|
+
cid = CID.parse(cidOrPeerIdOrDnsLink)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
log.error(err)
|
|
54
|
+
errors.push(new TypeError('Invalid CID for ipfs://<cid> URL'))
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
let resolveResult = ipnsCache.get(cidOrPeerIdOrDnsLink)
|
|
58
|
+
|
|
59
|
+
if (resolveResult != null) {
|
|
60
|
+
cid = resolveResult.cid
|
|
61
|
+
resolvedPath = resolveResult.path
|
|
62
|
+
log.trace('resolved %s to %c from cache', cidOrPeerIdOrDnsLink, cid)
|
|
63
|
+
} else {
|
|
64
|
+
// protocol is ipns
|
|
65
|
+
log.trace('Attempting to resolve PeerId for %s', cidOrPeerIdOrDnsLink)
|
|
66
|
+
let peerId = null
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
peerId = peerIdFromString(cidOrPeerIdOrDnsLink)
|
|
70
|
+
resolveResult = await ipns.resolve(peerId, { onProgress: options?.onProgress })
|
|
71
|
+
cid = resolveResult?.cid
|
|
72
|
+
resolvedPath = resolveResult?.path
|
|
73
|
+
log.trace('resolved %s to %c', cidOrPeerIdOrDnsLink, cid)
|
|
74
|
+
ipnsCache.set(cidOrPeerIdOrDnsLink, resolveResult, 60 * 1000 * 2)
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (peerId == null) {
|
|
77
|
+
log.error('Could not parse PeerId string "%s"', cidOrPeerIdOrDnsLink, err)
|
|
78
|
+
errors.push(new TypeError(`Could not parse PeerId in ipns url "${cidOrPeerIdOrDnsLink}", ${(err as Error).message}`))
|
|
79
|
+
} else {
|
|
80
|
+
log.error('Could not resolve PeerId %c', peerId, err)
|
|
81
|
+
errors.push(new TypeError(`Could not resolve PeerId "${cidOrPeerIdOrDnsLink}", ${(err as Error).message}`))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (cid == null) {
|
|
86
|
+
log.trace('Attempting to resolve DNSLink for %s', cidOrPeerIdOrDnsLink)
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
resolveResult = await ipns.resolveDns(cidOrPeerIdOrDnsLink, { onProgress: options?.onProgress })
|
|
90
|
+
cid = resolveResult?.cid
|
|
91
|
+
resolvedPath = resolveResult?.path
|
|
92
|
+
log.trace('resolved %s to %c', cidOrPeerIdOrDnsLink, cid)
|
|
93
|
+
ipnsCache.set(cidOrPeerIdOrDnsLink, resolveResult, 60 * 1000 * 2)
|
|
94
|
+
} catch (err) {
|
|
95
|
+
log.error('Could not resolve DnsLink for "%s"', cidOrPeerIdOrDnsLink, err)
|
|
96
|
+
errors.push(err as Error)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (cid == null) {
|
|
103
|
+
throw new AggregateError(errors, `Invalid resource. Cannot determine CID from URL "${urlString}"`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// parse query string
|
|
107
|
+
const query: Record<string, string> = {}
|
|
108
|
+
|
|
109
|
+
if (queryString != null && queryString.length > 0) {
|
|
110
|
+
const queryParts = queryString.split('&')
|
|
111
|
+
for (const part of queryParts) {
|
|
112
|
+
const [key, value] = part.split('=')
|
|
113
|
+
query[key] = decodeURIComponent(value)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* join the path from resolve result & given path.
|
|
119
|
+
* e.g. /ipns/<peerId>/ that is resolved to /ipfs/<cid>/<path1>, when requested as /ipns/<peerId>/<path2>, should be
|
|
120
|
+
* resolved to /ipfs/<cid>/<path1>/<path2>
|
|
121
|
+
*/
|
|
122
|
+
const pathParts = []
|
|
123
|
+
|
|
124
|
+
if (urlPath.length > 0) {
|
|
125
|
+
pathParts.push(urlPath)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (resolvedPath != null && resolvedPath.length > 0) {
|
|
129
|
+
pathParts.push(resolvedPath)
|
|
130
|
+
}
|
|
131
|
+
const path = pathParts.join('/')
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
protocol,
|
|
135
|
+
cid,
|
|
136
|
+
path,
|
|
137
|
+
query
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import hashlru from 'hashlru'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Time Aware Least Recent Used Cache
|
|
5
|
+
*
|
|
6
|
+
* @see https://arxiv.org/pdf/1801.00390
|
|
7
|
+
*/
|
|
8
|
+
export class TLRU<T> {
|
|
9
|
+
private readonly lru: ReturnType<typeof hashlru>
|
|
10
|
+
|
|
11
|
+
constructor (maxSize: number) {
|
|
12
|
+
this.lru = hashlru(maxSize)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get (key: string): T | undefined {
|
|
16
|
+
const value = this.lru.get(key)
|
|
17
|
+
|
|
18
|
+
if (value != null) {
|
|
19
|
+
if (value.expire != null && value.expire < Date.now()) {
|
|
20
|
+
this.lru.remove(key)
|
|
21
|
+
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return value.value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
set (key: string, value: T, ttl: number): void {
|
|
32
|
+
this.lru.set(key, { value, expire: Date.now() + ttl })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
has (key: string): boolean {
|
|
36
|
+
const value = this.get(key)
|
|
37
|
+
|
|
38
|
+
if (value != null) {
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
remove (key: string): void {
|
|
46
|
+
this.lru.remove(key)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
clear (): void {
|
|
50
|
+
this.lru.clear()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -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,323 @@
|
|
|
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 { getStreamAndContentType } from './utils/get-stream-and-content-type.js'
|
|
14
|
+
import { parseResource } from './utils/parse-resource.js'
|
|
15
|
+
import { walkPath, type PathWalkerFn } from './utils/walk-path.js'
|
|
16
|
+
import type { CIDDetail, 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
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
|
36
|
+
interface VerifiedFetchInit {
|
|
37
|
+
|
|
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
|
+
export class VerifiedFetch {
|
|
67
|
+
private readonly helia: Helia
|
|
68
|
+
private readonly ipns: IPNS
|
|
69
|
+
private readonly unixfs: HeliaUnixFs
|
|
70
|
+
private readonly dagJson: DAGJSON
|
|
71
|
+
private readonly dagCbor: DAGCBOR
|
|
72
|
+
private readonly json: JSON
|
|
73
|
+
private readonly pathWalker: PathWalkerFn
|
|
74
|
+
private readonly log: Logger
|
|
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.log.trace('created VerifiedFetch instance')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// handle vnd.ipfs.ipns-record
|
|
94
|
+
private async handleIPNSRecord ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
|
|
95
|
+
const response = new Response('vnd.ipfs.ipns-record support is not implemented', { status: 501 })
|
|
96
|
+
response.headers.set('X-Content-Type-Options', 'nosniff') // see https://specs.ipfs.tech/http-gateways/path-gateway/#x-content-type-options-response-header
|
|
97
|
+
return response
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// handle vnd.ipld.car
|
|
101
|
+
private async handleIPLDCar ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
|
|
102
|
+
const response = new Response('vnd.ipld.car support is not implemented', { status: 501 })
|
|
103
|
+
response.headers.set('X-Content-Type-Options', 'nosniff') // see https://specs.ipfs.tech/http-gateways/path-gateway/#x-content-type-options-response-header
|
|
104
|
+
return response
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async handleDagJson ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
|
|
108
|
+
this.log.trace('fetching %c/%s', cid, path)
|
|
109
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
|
|
110
|
+
const result = await this.dagJson.get(cid, {
|
|
111
|
+
signal: options?.signal,
|
|
112
|
+
onProgress: options?.onProgress
|
|
113
|
+
})
|
|
114
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
|
|
115
|
+
const response = new Response(JSON.stringify(result), { status: 200 })
|
|
116
|
+
response.headers.set('content-type', 'application/json')
|
|
117
|
+
return response
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async handleJson ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
|
|
121
|
+
this.log.trace('fetching %c/%s', cid, path)
|
|
122
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
|
|
123
|
+
const result: Record<any, any> = await this.json.get(cid, {
|
|
124
|
+
signal: options?.signal,
|
|
125
|
+
onProgress: options?.onProgress
|
|
126
|
+
})
|
|
127
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
|
|
128
|
+
const response = new Response(JSON.stringify(result), { status: 200 })
|
|
129
|
+
response.headers.set('content-type', 'application/json')
|
|
130
|
+
return response
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async handleDagCbor ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
|
|
134
|
+
this.log.trace('fetching %c/%s', cid, path)
|
|
135
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
|
|
136
|
+
const result = await this.dagCbor.get(cid, {
|
|
137
|
+
signal: options?.signal,
|
|
138
|
+
onProgress: options?.onProgress
|
|
139
|
+
})
|
|
140
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
|
|
141
|
+
const response = new Response(JSON.stringify(result), { status: 200 })
|
|
142
|
+
response.headers.set('content-type', 'application/json')
|
|
143
|
+
return response
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async handleDagPb ({ cid, path, options, terminalElement }: FetchHandlerFunctionArg): Promise<Response> {
|
|
147
|
+
this.log.trace('fetching %c/%s', cid, path)
|
|
148
|
+
let resolvedCID = terminalElement?.cid ?? cid
|
|
149
|
+
let stat: UnixFSStats
|
|
150
|
+
if (terminalElement?.type === 'directory') {
|
|
151
|
+
const dirCid = terminalElement.cid
|
|
152
|
+
|
|
153
|
+
const rootFilePath = 'index.html'
|
|
154
|
+
try {
|
|
155
|
+
this.log.trace('found directory at %c/%s, looking for index.html', cid, path)
|
|
156
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: dirCid.toString(), path: rootFilePath }))
|
|
157
|
+
stat = await this.unixfs.stat(dirCid, {
|
|
158
|
+
path: rootFilePath,
|
|
159
|
+
signal: options?.signal,
|
|
160
|
+
onProgress: options?.onProgress
|
|
161
|
+
})
|
|
162
|
+
this.log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, stat.cid)
|
|
163
|
+
path = rootFilePath
|
|
164
|
+
resolvedCID = stat.cid
|
|
165
|
+
// terminalElement = stat
|
|
166
|
+
} catch (err: any) {
|
|
167
|
+
this.log('error loading path %c/%s', dirCid, rootFilePath, err)
|
|
168
|
+
return new Response('Unable to find index.html for directory at given path. Support for directories with implicit root is not implemented', { status: 501 })
|
|
169
|
+
} finally {
|
|
170
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: dirCid.toString(), path: rootFilePath }))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: resolvedCID.toString(), path: '' }))
|
|
175
|
+
const asyncIter = this.unixfs.cat(resolvedCID, {
|
|
176
|
+
signal: options?.signal,
|
|
177
|
+
onProgress: options?.onProgress
|
|
178
|
+
})
|
|
179
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: resolvedCID.toString(), path: '' }))
|
|
180
|
+
this.log('got async iterator for %c/%s', cid, path)
|
|
181
|
+
|
|
182
|
+
const { contentType, stream } = await getStreamAndContentType(asyncIter, path ?? '', this.helia.logger, {
|
|
183
|
+
onProgress: options?.onProgress
|
|
184
|
+
})
|
|
185
|
+
const response = new Response(stream, { status: 200 })
|
|
186
|
+
response.headers.set('content-type', contentType)
|
|
187
|
+
|
|
188
|
+
return response
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async handleRaw ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
|
|
192
|
+
this.log.trace('fetching %c/%s', cid, path)
|
|
193
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:start', { cid: cid.toString(), path }))
|
|
194
|
+
const result = await this.helia.blockstore.get(cid)
|
|
195
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: cid.toString(), path }))
|
|
196
|
+
const response = new Response(decode(result), { status: 200 })
|
|
197
|
+
response.headers.set('content-type', 'application/octet-stream')
|
|
198
|
+
return response
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Determines the format requested by the client, defaults to `null` if no format is requested.
|
|
203
|
+
*
|
|
204
|
+
* @see https://specs.ipfs.tech/http-gateways/path-gateway/#format-request-query-parameter
|
|
205
|
+
* @default 'raw'
|
|
206
|
+
*/
|
|
207
|
+
private getFormat ({ headerFormat, queryFormat }: { headerFormat: string | null, queryFormat: string | null }): string | null {
|
|
208
|
+
const formatMap: Record<string, string> = {
|
|
209
|
+
'vnd.ipld.raw': 'raw',
|
|
210
|
+
'vnd.ipld.car': 'car',
|
|
211
|
+
'application/x-tar': 'tar',
|
|
212
|
+
'application/vnd.ipld.dag-json': 'dag-json',
|
|
213
|
+
'application/vnd.ipld.dag-cbor': 'dag-cbor',
|
|
214
|
+
'application/json': 'json',
|
|
215
|
+
'application/cbor': 'cbor',
|
|
216
|
+
'vnd.ipfs.ipns-record': 'ipns-record'
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (headerFormat != null) {
|
|
220
|
+
for (const format in formatMap) {
|
|
221
|
+
if (headerFormat.includes(format)) {
|
|
222
|
+
return formatMap[format]
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} else if (queryFormat != null) {
|
|
226
|
+
return queryFormat
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Map of format to specific handlers for that format.
|
|
234
|
+
* These format handlers should adjust the response headers as specified in https://specs.ipfs.tech/http-gateways/path-gateway/#response-headers
|
|
235
|
+
*/
|
|
236
|
+
private readonly formatHandlers: Record<string, FetchHandlerFunction> = {
|
|
237
|
+
raw: async () => new Response('application/vnd.ipld.raw support is not implemented', { status: 501 }),
|
|
238
|
+
car: this.handleIPLDCar,
|
|
239
|
+
'ipns-record': this.handleIPNSRecord,
|
|
240
|
+
tar: async () => new Response('application/x-tar support is not implemented', { status: 501 }),
|
|
241
|
+
'dag-json': async () => new Response('application/vnd.ipld.dag-json support is not implemented', { status: 501 }),
|
|
242
|
+
'dag-cbor': async () => new Response('application/vnd.ipld.dag-cbor support is not implemented', { status: 501 }),
|
|
243
|
+
json: async () => new Response('application/json support is not implemented', { status: 501 }),
|
|
244
|
+
cbor: async () => new Response('application/cbor support is not implemented', { status: 501 })
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private readonly codecHandlers: Record<number, FetchHandlerFunction> = {
|
|
248
|
+
[dagJsonCode]: this.handleDagJson,
|
|
249
|
+
[dagPbCode]: this.handleDagPb,
|
|
250
|
+
[jsonCode]: this.handleJson,
|
|
251
|
+
[dagCborCode]: this.handleDagCbor,
|
|
252
|
+
[rawCode]: this.handleRaw
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async fetch (resource: Resource, opts?: VerifiedFetchOptions): Promise<Response> {
|
|
256
|
+
const options = convertOptions(opts)
|
|
257
|
+
const { path, query, ...rest } = await parseResource(resource, { ipns: this.ipns, logger: this.helia.logger }, options)
|
|
258
|
+
const cid = rest.cid
|
|
259
|
+
let response: Response | undefined
|
|
260
|
+
|
|
261
|
+
const format = this.getFormat({ headerFormat: new Headers(options?.headers).get('accept'), queryFormat: query.format ?? null })
|
|
262
|
+
|
|
263
|
+
if (format != null) {
|
|
264
|
+
// TODO: These should be handled last when they're returning something other than 501
|
|
265
|
+
const formatHandler = this.formatHandlers[format]
|
|
266
|
+
|
|
267
|
+
if (formatHandler != null) {
|
|
268
|
+
response = await formatHandler.call(this, { cid, path, options })
|
|
269
|
+
|
|
270
|
+
if (response.status === 501) {
|
|
271
|
+
return response
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let terminalElement: UnixFSEntry | undefined
|
|
277
|
+
let ipfsRoots: CID[] | undefined
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const pathDetails = await this.pathWalker(this.helia.blockstore, `${cid.toString()}/${path}`, options)
|
|
281
|
+
ipfsRoots = pathDetails.ipfsRoots
|
|
282
|
+
terminalElement = pathDetails.terminalElement
|
|
283
|
+
} catch (err) {
|
|
284
|
+
this.log.error('Error walking path %s', path, err)
|
|
285
|
+
// return new Response(`Error walking path: ${(err as Error).message}`, { status: 500 })
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (response == null) {
|
|
289
|
+
const codecHandler = this.codecHandlers[cid.code]
|
|
290
|
+
|
|
291
|
+
if (codecHandler != null) {
|
|
292
|
+
response = await codecHandler.call(this, { cid, path, options, terminalElement })
|
|
293
|
+
} else {
|
|
294
|
+
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 })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
response.headers.set('etag', cid.toString()) // https://specs.ipfs.tech/http-gateways/path-gateway/#etag-response-header
|
|
299
|
+
response.headers.set('cache-cotrol', 'public, max-age=29030400, immutable')
|
|
300
|
+
response.headers.set('X-Ipfs-Path', resource.toString()) // https://specs.ipfs.tech/http-gateways/path-gateway/#x-ipfs-path-response-header
|
|
301
|
+
|
|
302
|
+
if (ipfsRoots != null) {
|
|
303
|
+
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
|
|
304
|
+
}
|
|
305
|
+
// response.headers.set('Content-Disposition', `TODO`) // https://specs.ipfs.tech/http-gateways/path-gateway/#content-disposition-response-header
|
|
306
|
+
|
|
307
|
+
return response
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Start the Helia instance
|
|
312
|
+
*/
|
|
313
|
+
async start (): Promise<void> {
|
|
314
|
+
await this.helia.start()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Shut down the Helia instance
|
|
319
|
+
*/
|
|
320
|
+
async stop (): Promise<void> {
|
|
321
|
+
await this.helia.stop()
|
|
322
|
+
}
|
|
323
|
+
}
|