hypercore-fetch 8.6.0 → 8.6.2-0

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/index.js CHANGED
@@ -1,787 +1,599 @@
1
- const resolveDatPath = require('resolve-dat-path')
2
- const Headers = require('fetch-headers')
3
- const mime = require('mime/lite')
4
- const SDK = require('hyper-sdk')
5
- const parseRange = require('range-parser')
6
- const makeDir = require('make-dir')
7
- const { Readable } = require('streamx')
8
- const makeFetch = require('make-fetch')
9
- const { EventIterator } = require('event-iterator')
10
- const Busboy = require('busboy')
11
- const posixPath = require('path').posix
1
+ import { posix } from 'path'
12
2
 
13
- const DEFAULT_TIMEOUT = 5000
14
-
15
- const NUMBER_REGEX = /^\d+$/
16
- const PROTOCOL_REGEX = /^\w+:\/\//
17
- const NOT_WRITABLE_ERROR = 'Archive not writable'
3
+ import { Readable, pipelinePromise } from 'streamx'
4
+ import Hyperdrive from 'hyperdrive'
5
+ import { makeRoutedFetch } from 'make-fetch'
6
+ import mime from 'mime/lite.js'
7
+ import parseRange from 'range-parser'
8
+ import { EventIterator } from 'event-iterator'
18
9
 
19
- const READABLE_ALLOW = ['GET', 'HEAD']
20
- const WRITABLE_ALLOW = ['PUT', 'POST', 'DELETE']
21
- const ALL_ALLOW = READABLE_ALLOW.concat(WRITABLE_ALLOW)
10
+ const DEFAULT_TIMEOUT = 5000
22
11
 
23
- const SPECIAL_FOLDER = '/$/'
24
- const TAGS_FOLDER_NAME = 'tags/'
25
- const TAGS_FOLDER = SPECIAL_FOLDER + TAGS_FOLDER_NAME
26
- const EXTENSIONS_FOLDER_NAME = 'extensions/'
27
- const EXTENSIONS_FOLDER = SPECIAL_FOLDER + EXTENSIONS_FOLDER_NAME
12
+ const SPECIAL_DOMAIN = 'localhost'
13
+ const SPECIAL_FOLDER = '$'
14
+ const EXTENSIONS_FOLDER_NAME = 'extensions'
28
15
  const EXTENSION_EVENT = 'extension-message'
29
16
  const PEER_OPEN = 'peer-open'
30
17
  const PEER_REMOVE = 'peer-remove'
31
18
 
32
- // TODO: Add caching support
33
- const { resolveURL: DEFAULT_RESOLVE_URL } = require('hyper-dns')
19
+ const MIME_TEXT_PLAIN = 'text/plain; charset=utf-8'
20
+ const MIME_APPLICATION_JSON = 'application/json'
21
+ const MIME_TEXT_HTML = 'text/html; charset=utf-8'
22
+ const MIME_EVENT_STREAM = 'text/event-stream; charset=utf-8'
23
+
24
+ const HEADER_CONTENT_TYPE = 'Content-Type'
25
+
26
+ async function DEFAULT_RENDER_INDEX (url, files, fetch) {
27
+ return `
28
+ <!DOCTYPE html>
29
+ <title>Index of ${url.pathname}</title>
30
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
31
+ <h1>Index of ${url.pathname}</h1>
32
+ <ul>
33
+ <li><a href="../">../</a></li>
34
+ ${files.map((file) => `<li><a href="${file}">./${file}</a></li>`).join('\n')}
35
+ </ul>
36
+ `
37
+ }
38
+
39
+ export default async function makeHyperFetch ({
40
+ sdk,
41
+ writable = false,
42
+ extensionMessages = writable,
43
+ timeout = DEFAULT_TIMEOUT,
44
+ renderIndex = DEFAULT_RENDER_INDEX
45
+ }) {
46
+ const { fetch, router } = makeRoutedFetch()
47
+
48
+ // Map loaded drive hostnames to their keys
49
+ // TODO: Track LRU + cache clearing
50
+ const drives = new Map()
51
+ const extensions = new Map()
52
+ const cores = new Map()
53
+
54
+ async function getCore (hostname) {
55
+ if (cores.has(hostname)) {
56
+ return cores.get(hostname)
57
+ }
58
+ const core = await sdk.get(hostname)
59
+ await core.ready()
60
+ cores.set(core.id, core)
61
+ cores.set(core.url, core)
62
+ return core
63
+ }
34
64
 
35
- module.exports = function makeHyperFetch (opts = {}) {
36
- let {
37
- Hyperdrive,
38
- resolveURL = DEFAULT_RESOLVE_URL,
39
- base,
40
- timeout = DEFAULT_TIMEOUT,
41
- writable = false
42
- } = opts
65
+ async function getDBCoreForName (name) {
66
+ const corestore = sdk.namespace(name)
67
+ const dbCore = corestore.get({ name: 'db' })
68
+ await dbCore.ready()
69
+
70
+ if (!dbCore.discovery) {
71
+ const discovery = sdk.join(dbCore.discoveryKey)
72
+ dbCore.discovery = discovery
73
+ dbCore.once('close', () => {
74
+ discovery.destroy()
75
+ })
76
+ await discovery.flushed()
77
+ }
78
+
79
+ return dbCore
80
+ }
81
+
82
+ async function getDrive (hostname) {
83
+ if (drives.has(hostname)) {
84
+ return drives.get(hostname)
85
+ }
86
+
87
+ const core = await getCore(hostname)
88
+
89
+ const corestore = sdk.namespace(core.id)
90
+ const drive = new Hyperdrive(corestore, core.key)
91
+
92
+ await drive.ready()
93
+
94
+ drives.set(drive.core.id, drive)
95
+ drives.set(hostname, drive)
96
+
97
+ return drive
98
+ }
99
+
100
+ async function getDriveFromKey (key, errorOnNew = false) {
101
+ if (drives.has(key)) {
102
+ return drives.get(key)
103
+ }
104
+ const core = await getDBCoreForName(key)
105
+ if (!core.length && errorOnNew) {
106
+ return { status: 400, body: 'Must create key with POST before reading' }
107
+ }
43
108
 
44
- let sdk = null
45
- let gettingSDK = null
46
- let onClose = async () => undefined
109
+ const corestore = sdk.namespace(key)
110
+ const drive = new Hyperdrive(corestore)
47
111
 
48
- const isSourceDat = base && base.startsWith('hyper://')
112
+ await drive.ready()
49
113
 
50
- const fetch = makeFetch(hyperFetch)
114
+ drives.set(key, drive)
115
+ drives.set(drive.core.id, drive)
51
116
 
52
- fetch.close = () => onClose()
117
+ return drive
118
+ }
53
119
 
54
- function getExtension (archive, name) {
55
- const existing = archive.metadata.extensions.get(name)
56
- if (existing) return existing
120
+ async function getExtension (core, name) {
121
+ const key = core.url + name
122
+ if (extensions.has(key)) {
123
+ return extensions.get(key)
124
+ }
125
+ const existing = core.extensions.get(name)
126
+ if (existing) {
127
+ extensions.set(key, existing)
128
+ return existing
129
+ }
57
130
 
58
- const extension = archive.registerExtension(name, {
131
+ const extension = core.registerExtension(name, {
59
132
  encoding: 'utf8',
60
133
  onmessage: (content, peer) => {
61
- archive.emit(EXTENSION_EVENT, name, content, peer)
134
+ core.emit(EXTENSION_EVENT, name, content, peer)
62
135
  }
63
136
  })
64
137
 
138
+ extensions.set(key, extension)
139
+
65
140
  return extension
66
141
  }
67
142
 
68
- function getExtensionPeers (archive, name) {
143
+ async function getExtensionPeers (core, name) {
69
144
  // List peers with this extension
70
- const allPeers = archive.peers
145
+ const allPeers = core.peers
71
146
  return allPeers.filter((peer) => {
72
- const { remoteExtensions } = peer
73
-
74
- if (!remoteExtensions) return false
75
-
76
- const { names } = remoteExtensions
77
-
78
- if (!names) return false
79
-
80
- return names.includes(name)
147
+ return peer?.extensions?.has(name)
81
148
  })
82
149
  }
83
150
 
84
- function listExtensionNames (archive) {
85
- return archive.metadata.extensions.names()
151
+ function listExtensionNames (core) {
152
+ return [...core.extensions.keys()]
86
153
  }
87
154
 
88
- async function loadArchive (key) {
89
- const Hyperdrive = await getHyperdrive()
90
- return Hyperdrive(key)
91
- }
155
+ if (extensionMessages) {
156
+ router.get(`hyper://*/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/`, async function listExtensions (request) {
157
+ const { hostname } = new URL(request.url)
158
+ const accept = request.headers.get('Accept') || ''
92
159
 
93
- return fetch
160
+ const core = await getCore(`hyper://${hostname}/`)
94
161
 
95
- async function hyperFetch ({ url, headers: rawHeaders, method, signal, body }) {
96
- const isHyperURL = url.startsWith('hyper://')
97
- const urlHasProtocol = url.match(PROTOCOL_REGEX)
162
+ if (accept.includes('text/event-stream')) {
163
+ const events = new EventIterator(({ push }) => {
164
+ function onMessage (name, content, peer) {
165
+ const id = peer.remotePublicKey.toString('hex')
166
+ // TODO: Fancy verification on the `name`?
167
+ // Send each line of content separately on a `data` line
168
+ const data = content.split('\n').map((line) => `data:${line}\n`).join('')
98
169
 
99
- const shouldIntercept = isHyperURL || (!urlHasProtocol && isSourceDat)
170
+ push(`id:${id}\nevent:${name}\n${data}\n`)
171
+ }
172
+ function onPeerOpen (peer) {
173
+ const id = peer.remotePublicKey.toString('hex')
174
+ push(`id:${id}\nevent:${PEER_OPEN}\n\n`)
175
+ }
176
+ function onPeerRemove (peer) {
177
+ // Whatever, probably an uninitialized peer
178
+ if (!peer.remotePublicKey) return
179
+ const id = peer.remotePublicKey.toString('hex')
180
+ push(`id:${id}\nevent:${PEER_REMOVE}\n\n`)
181
+ }
182
+ core.on(EXTENSION_EVENT, onMessage)
183
+ core.on(PEER_OPEN, onPeerOpen)
184
+ core.on(PEER_REMOVE, onPeerRemove)
185
+ return () => {
186
+ core.removeListener(EXTENSION_EVENT, onMessage)
187
+ core.removeListener(PEER_OPEN, onPeerOpen)
188
+ core.removeListener(PEER_REMOVE, onPeerRemove)
189
+ }
190
+ })
100
191
 
101
- if (!shouldIntercept) throw new Error('Invalid protocol, must be hyper://')
192
+ return {
193
+ statusCode: 200,
194
+ headers: {
195
+ [HEADER_CONTENT_TYPE]: MIME_EVENT_STREAM
196
+ },
197
+ body: events
198
+ }
199
+ }
102
200
 
103
- const headers = new Headers(rawHeaders || {})
201
+ const extensions = listExtensionNames(core)
202
+ return {
203
+ status: 200,
204
+ headers: { [HEADER_CONTENT_TYPE]: MIME_APPLICATION_JSON },
205
+ body: JSON.stringify(extensions, null, '\t')
206
+ }
207
+ })
208
+ router.get(`hyper://*/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/*`, async function listenExtension (request) {
209
+ const { hostname, pathname } = new URL(request.url)
210
+ const name = pathname.slice(`/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/`.length)
211
+
212
+ const core = await getCore(`hyper://${hostname}/`)
104
213
 
105
- const responseHeaders = {}
106
- responseHeaders['Access-Control-Allow-Origin'] = '*'
107
- responseHeaders['Allow-CSP-From'] = '*'
108
- responseHeaders['Access-Control-Allow-Headers'] = '*'
214
+ await getExtension(core, name)
109
215
 
110
- try {
111
- let { pathname: path, key, version, searchParams } = parseDatURL(url)
112
- if (!path) path = '/'
113
- if (!path.startsWith('/')) path = '/' + path
216
+ const peers = await getExtensionPeers(core, name)
217
+ const finalPeers = formatPeers(peers)
218
+ const body = JSON.stringify(finalPeers, null, '\t')
114
219
 
115
- try {
116
- const resolvedURL = await resolveURL(`hyper://${key}`)
117
- key = resolvedURL.hostname
118
- } catch (e) {
119
- // Probably a domain that couldn't resolve
120
- if (key.includes('.')) throw e
220
+ return {
221
+ status: 200,
222
+ body,
223
+ headers: {
224
+ [HEADER_CONTENT_TYPE]: MIME_APPLICATION_JSON
225
+ }
121
226
  }
227
+ })
228
+ router.post(`hyper://*/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/*`, async function broadcastExtension (request) {
229
+ const { hostname, pathname } = new URL(request.url)
230
+ const name = pathname.slice(`/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/`.length)
231
+
232
+ const core = await getCore(`hyper://${hostname}/`)
122
233
 
123
- let archive = await loadArchive(key)
234
+ const extension = await getExtension(core, name)
235
+ const data = await request.text()
236
+ extension.broadcast(data)
124
237
 
125
- if (!archive) {
238
+ return { status: 200 }
239
+ })
240
+ router.post(`hyper://*/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/*/*`, async function extensionToPeer (request) {
241
+ const { hostname, pathname } = new URL(request.url)
242
+ const subFolder = pathname.slice(`/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/`.length)
243
+ const [name, extensionPeer] = subFolder.split('/')
244
+
245
+ const core = await getCore(`hyper://${hostname}/`)
246
+
247
+ const extension = await getExtension(core, name)
248
+ const peers = await getExtensionPeers(core, name)
249
+ const peer = peers.find(({ remotePublicKey }) => remotePublicKey.toString('hex') === extensionPeer)
250
+ if (!peer) {
126
251
  return {
127
- statusCode: 404,
128
- headers: responseHeaders,
129
- data: intoAsyncIterable('Unknown drive')
252
+ status: 404,
253
+ headers: {
254
+ [HEADER_CONTENT_TYPE]: MIME_TEXT_PLAIN
255
+ },
256
+ body: 'Peer Not Found'
130
257
  }
131
258
  }
259
+ const data = await request.arrayBuffer()
260
+ extension.send(data, peer)
261
+ return { status: 200 }
262
+ })
263
+ }
132
264
 
133
- await archive.ready()
265
+ if (writable) {
266
+ router.get(`hyper://${SPECIAL_DOMAIN}/`, async function getKey (request) {
267
+ const key = new URL(request.url).searchParams.get('key')
268
+ if (!key) {
269
+ return { status: 400, body: 'Must specify key parameter to resolve' }
270
+ }
134
271
 
135
- if (!archive.version) {
136
- if (!archive.peers.length) {
137
- await new Promise((resolve, reject) => {
138
- setTimeout(() => reject(new Error('Timed out looking for peers')), timeout)
139
- archive.once('peer-open', resolve)
140
- })
141
- }
142
- await new Promise((resolve, reject) => {
143
- archive.metadata.update({ ifAvailable: true }, (err) => {
144
- if (err) reject(err)
145
- else resolve()
146
- })
147
- })
272
+ const drive = await getDriveFromKey(key, true)
273
+
274
+ return { body: drive.url }
275
+ })
276
+ router.post(`hyper://${SPECIAL_DOMAIN}/`, async function createKey (request) {
277
+ // TODO: Allow importing secret keys here
278
+ // Maybe specify a seed to use for generating the blobs?
279
+ // Else we'd need to specify the blobs keys and metadata keys
280
+
281
+ const key = new URL(request.url).searchParams.get('key')
282
+ if (!key) {
283
+ return { status: 400, body: 'Must specify key parameter to resolve' }
148
284
  }
149
285
 
150
- if (version) {
151
- if (NUMBER_REGEX.test(version)) {
152
- archive = await archive.checkout(version)
153
- } else {
154
- archive = await archive.checkout(await archive.getTaggedVersion(version))
286
+ const drive = await getDriveFromKey(key, false)
287
+
288
+ return { body: drive.core.url }
289
+ })
290
+
291
+ router.put('hyper://*/**', async function putFiles (request) {
292
+ const { hostname, pathname } = new URL(request.url)
293
+ const contentType = request.headers.get('Content-Type') || ''
294
+ const isFormData = contentType.includes('multipart/form-data')
295
+
296
+ const drive = await getDrive(hostname)
297
+
298
+ if (isFormData) {
299
+ // It's a form! Get the files out and process them
300
+ const formData = await request.formData()
301
+ for (const [name, data] of formData) {
302
+ if (name !== 'file') continue
303
+ const filePath = posix.join(pathname, data.name)
304
+ await pipelinePromise(
305
+ Readable.from(data.stream()),
306
+ drive.createWriteStream(filePath, {
307
+ metadata: {
308
+ mtime: Date.now()
309
+ }
310
+ })
311
+ )
155
312
  }
156
- await archive.ready()
313
+ } else {
314
+ await pipelinePromise(
315
+ Readable.from(request.body),
316
+ drive.createWriteStream(pathname)
317
+ )
157
318
  }
158
319
 
159
- const canonical = `hyper://${archive.key.toString('hex')}${path || ''}`
160
- responseHeaders.Link = `<${canonical}>; rel="canonical"`
161
-
162
- const isWritable = writable && archive.writable
163
- const allowHeaders = isWritable ? ALL_ALLOW : READABLE_ALLOW
164
- responseHeaders.Allow = allowHeaders.join(', ')
165
-
166
- // We can say the file hasn't changed if the drive version hasn't changed
167
- responseHeaders.ETag = `"${archive.version}"`
168
-
169
- if (path.startsWith(SPECIAL_FOLDER)) {
170
- if (path === SPECIAL_FOLDER) {
171
- const files = [
172
- TAGS_FOLDER_NAME,
173
- EXTENSIONS_FOLDER_NAME
174
- ]
175
-
176
- const data = renderFiles(headers, responseHeaders, url, path, files)
177
- if (method === 'HEAD') {
178
- return {
179
- statusCode: 204,
180
- headers: responseHeaders,
181
- data: intoAsyncIterable('')
182
- }
183
- } else {
184
- return {
185
- statusCode: 200,
186
- headers: responseHeaders,
187
- data
188
- }
189
- }
190
- } else if (path.startsWith(TAGS_FOLDER)) {
191
- if (method === 'GET') {
192
- if (path === TAGS_FOLDER) {
193
- responseHeaders['x-is-directory'] = 'true'
194
- const tags = await archive.getAllTags()
195
- const tagsObject = Object.fromEntries(tags)
196
- const json = JSON.stringify(tagsObject, null, '\t')
197
-
198
- responseHeaders['Content-Type'] = 'application/json; charset=utf-8'
199
-
200
- return {
201
- statusCode: 200,
202
- headers: responseHeaders,
203
- data: intoAsyncIterable(json)
204
- }
205
- } else {
206
- const tagName = path.slice(TAGS_FOLDER.length)
207
- try {
208
- const tagVersion = await archive.getTaggedVersion(tagName)
209
-
210
- return {
211
- statusCode: 200,
212
- headers: responseHeaders,
213
- data: intoAsyncIterable(`${tagVersion}`)
214
- }
215
- } catch {
216
- return {
217
- statusCode: 404,
218
- headers: responseHeaders,
219
- data: intoAsyncIterable('Tag Not Found')
220
- }
221
- }
222
- }
223
- } else if (method === 'DELETE') {
224
- checkWritable(archive)
225
- const tagName = path.slice(TAGS_FOLDER.length)
226
- await archive.deleteTag(tagName || version)
227
- responseHeaders.ETag = `"${archive.version}"`
228
-
229
- return {
230
- statusCode: 200,
231
- headers: responseHeaders,
232
- data: intoAsyncIterable('')
233
- }
234
- } else if (method === 'PUT') {
235
- checkWritable(archive)
236
- const tagName = path.slice(TAGS_FOLDER.length)
237
- const tagVersion = archive.version
238
-
239
- await archive.createTag(tagName, tagVersion)
240
- responseHeaders['Content-Type'] = 'text/plain; charset=utf-8'
241
- responseHeaders.ETag = `"${archive.version}"`
242
-
243
- return {
244
- statusCode: 200,
245
- headers: responseHeaders,
246
- data: intoAsyncIterable(`${tagVersion}`)
247
- }
248
- } else if (method === 'HEAD') {
249
- return {
250
- statusCode: 204,
251
- headers: responseHeaders,
252
- data: intoAsyncIterable('')
253
- }
254
- } else {
255
- return {
256
- statusCode: 405,
257
- headers: responseHeaders,
258
- data: intoAsyncIterable('Method Not Allowed')
259
- }
260
- }
261
- } else if (path.startsWith(EXTENSIONS_FOLDER)) {
262
- if (path === EXTENSIONS_FOLDER) {
263
- if (method === 'GET') {
264
- const accept = headers.get('Accept') || ''
265
- if (!accept.includes('text/event-stream')) {
266
- responseHeaders['x-is-directory'] = 'true'
267
-
268
- const extensions = listExtensionNames(archive)
269
- const data = renderFiles(headers, responseHeaders, url, path, extensions)
270
-
271
- return {
272
- statusCode: 204,
273
- headers: responseHeaders,
274
- data
275
- }
276
- }
320
+ // TODO: Use 201 with location in response headers
321
+ return { status: 200 }
322
+ })
323
+ router.delete('hyper://*/**', async function putFiles (request) {
324
+ const { hostname, pathname } = new URL(request.url)
277
325
 
278
- const events = new EventIterator(({ push }) => {
279
- function onMessage (name, content, peer) {
280
- const id = peer.remotePublicKey.toString('hex')
281
- // TODO: Fancy verification on the `name`?
282
- // Send each line of content separately on a `data` line
283
- const data = content.split('\n').map((line) => `data:${line}\n`).join('')
284
- push(`id:${id}\nevent:${name}\n${data}\n`)
285
- }
286
- function onPeerOpen (peer) {
287
- const id = peer.remotePublicKey.toString('hex')
288
- push(`id:${id}\nevent:${PEER_OPEN}\n\n`)
289
- }
290
- function onPeerRemove (peer) {
291
- // Whatever, probably an uninitialized peer
292
- if (!peer.remotePublicKey) return
293
- const id = peer.remotePublicKey.toString('hex')
294
- push(`id:${id}\nevent:${PEER_REMOVE}\n\n`)
295
- }
296
- archive.on(EXTENSION_EVENT, onMessage)
297
- archive.on(PEER_OPEN, onPeerOpen)
298
- archive.on(PEER_REMOVE, onPeerRemove)
299
- return () => {
300
- archive.removeListener(EXTENSION_EVENT, onMessage)
301
- archive.removeListener(PEER_OPEN, onPeerOpen)
302
- archive.removeListener(PEER_REMOVE, onPeerRemove)
303
- }
304
- })
305
-
306
- responseHeaders['Content-Type'] = 'text/event-stream'
307
-
308
- return {
309
- statusCode: 200,
310
- headers: responseHeaders,
311
- data: events
312
- }
313
- } else {
314
- return {
315
- statusCode: 405,
316
- headers: responseHeaders,
317
- data: intoAsyncIterable('Method Not Allowed')
318
- }
319
- }
320
- } else {
321
- let extensionName = path.slice(EXTENSIONS_FOLDER.length)
322
- let extensionPeer = null
323
- if (extensionName.includes('/')) {
324
- const split = extensionName.split('/')
325
- extensionName = split[0]
326
- if (split[1]) extensionPeer = split[1]
327
- }
328
- if (method === 'POST') {
329
- const extension = getExtension(archive, extensionName)
330
- if (extensionPeer) {
331
- const peers = getExtensionPeers(archive, extensionName)
332
- const peer = peers.find(({ remotePublicKey }) => remotePublicKey.toString('hex') === extensionPeer)
333
- if (!peer) {
334
- return {
335
- statusCode: 404,
336
- headers: responseHeaders,
337
- data: intoAsyncIterable('Peer Not Found')
338
- }
339
- }
340
- extension.send(await collect(body), peer)
341
- } else {
342
- extension.broadcast(await collect(body))
343
- }
344
- return {
345
- statusCode: 200,
346
- headers: responseHeaders,
347
- data: intoAsyncIterable('')
348
- }
349
- } else if (method === 'GET') {
350
- const accept = headers.get('Accept') || ''
351
- if (!accept.includes('text/event-stream')) {
352
- // Load up the extension into memory
353
- getExtension(archive, extensionName)
354
-
355
- const extensionPeers = getExtensionPeers(archive, extensionName)
356
- const finalPeers = formatPeers(extensionPeers)
357
-
358
- const json = JSON.stringify(finalPeers, null, '\t')
359
-
360
- return {
361
- statusCode: 200,
362
- header: responseHeaders,
363
- data: intoAsyncIterable(json)
364
- }
365
- }
366
- } else {
367
- return {
368
- statusCode: 405,
369
- headers: responseHeaders,
370
- data: intoAsyncIterable('Method Not Allowed')
371
- }
372
- }
373
- }
374
- } else {
375
- return {
376
- statusCode: 404,
377
- headers: responseHeaders,
378
- data: intoAsyncIterable('Not Found')
379
- }
326
+ const drive = await getDrive(hostname)
327
+
328
+ if (pathname.endsWith('/')) {
329
+ let didDelete = false
330
+ for await (const entry of drive.list(pathname)) {
331
+ await drive.del(entry.key)
332
+ didDelete = true
380
333
  }
334
+ if (!didDelete) {
335
+ return { status: 404, body: 'Not Found', headers: { [HEADER_CONTENT_TYPE]: MIME_TEXT_PLAIN } }
336
+ }
337
+ return { status: 200 }
381
338
  }
382
339
 
383
- if (method === 'PUT') {
384
- checkWritable(archive)
385
- const contentType = headers.get('Content-Type') || headers.get('content-type')
386
- const isFormData = contentType && contentType.includes('multipart/form-data')
387
-
388
- if (path.endsWith('/')) {
389
- await makeDir(path, { fs: archive })
390
- const busboy = new Busboy({ headers: rawHeaders })
391
-
392
- const toUpload = new EventIterator(({ push, stop, fail }) => {
393
- busboy.once('error', fail)
394
- busboy.once('finish', stop)
395
-
396
- busboy.on('file', async (fieldName, fileData, fileName) => {
397
- const finalPath = posixPath.join(path, fileName)
398
-
399
- const source = Readable.from(fileData)
400
- const destination = archive.createWriteStream(finalPath)
401
-
402
- source.pipe(destination)
403
- try {
404
- Promise.race([
405
- once(source, 'error').then((e) => { throw e }),
406
- once(destination, 'error').then((e) => { throw e }),
407
- once(source, 'end')
408
- ])
409
- } catch (e) {
410
- fail(e)
411
- }
412
- })
340
+ const entry = await drive.entry(pathname)
413
341
 
414
- // TODO: Does busboy need to be GC'd?
415
- return () => {}
416
- })
342
+ if (!entry) {
343
+ return { status: 404, body: 'Not Found', headers: { [HEADER_CONTENT_TYPE]: MIME_TEXT_PLAIN } }
344
+ }
345
+ await drive.del(pathname)
417
346
 
418
- Readable.from(body).pipe(busboy)
347
+ return { status: 200 }
348
+ })
349
+ }
419
350
 
420
- await Promise.all(await collect(toUpload))
351
+ router.head('hyper://*/**', async function headFiles (request) {
352
+ const url = new URL(request.url)
353
+ const { hostname, pathname, searchParams } = url
354
+ const accept = request.headers.get('Accept') || ''
355
+ const isRanged = request.headers.get('Range') || ''
356
+ const noResolve = searchParams.has('noResolve')
357
+ const isDirectory = pathname.endsWith('/')
421
358
 
422
- return {
423
- statusCode: 200,
424
- headers: responseHeaders,
425
- data: intoAsyncIterable(canonical)
426
- }
427
- } else {
428
- if (isFormData) {
429
- return {
430
- statusCode: 400,
431
- headers: responseHeaders,
432
- data: intoAsyncIterable('FormData only supported for folders (ending with a /)')
433
- }
434
- }
435
- const parentDir = path.split('/').slice(0, -1).join('/')
436
- if (parentDir) {
437
- await makeDir(parentDir, { fs: archive })
438
- }
359
+ const drive = await getDrive(hostname)
439
360
 
440
- const source = Readable.from(body)
441
- const destination = archive.createWriteStream(path)
442
- // The sink is needed because Hyperdrive's write stream is duplex
361
+ const resHeaders = {
362
+ ETag: `${drive.version}`,
363
+ 'Accept-Ranges': 'bytes',
364
+ Link: `<${drive.core.url}>; rel="canonical"`
365
+ }
443
366
 
444
- source.pipe(destination)
367
+ if (isDirectory) {
368
+ const entries = await listEntries(drive, pathname)
445
369
 
446
- await Promise.race([
447
- once(source, 'error'),
448
- once(destination, 'error'),
449
- once(source, 'end')
450
- ])
451
- }
452
- responseHeaders.ETag = `"${archive.version}"`
370
+ const hasItems = entries.length
453
371
 
372
+ if (!hasItems && pathname !== '/') {
454
373
  return {
455
- statusCode: 200,
456
- headers: responseHeaders,
457
- data: intoAsyncIterable(canonical)
458
- }
459
- } else if (method === 'DELETE') {
460
- if (headers.get('x-clear') === 'cache') {
461
- await archive.clear(path)
462
- return {
463
- statusCode: 200,
464
- headers: responseHeaders,
465
- data: intoAsyncIterable('')
374
+ status: 404,
375
+ headers: {
376
+ [HEADER_CONTENT_TYPE]: MIME_TEXT_PLAIN
466
377
  }
467
- } else {
468
- checkWritable(archive)
469
-
470
- const stats = await archive.stat(path)
471
- // Weird stuff happening up in here...
472
- const stat = Array.isArray(stats) ? stats[0] : stats
378
+ }
379
+ }
473
380
 
474
- if (stat.isDirectory()) {
475
- await archive.rmdir(path)
476
- } else {
477
- await archive.unlink(path)
381
+ if (!noResolve) {
382
+ if (entries.includes('index.html')) {
383
+ return {
384
+ status: 204,
385
+ headers: {
386
+ ...resHeaders,
387
+ [HEADER_CONTENT_TYPE]: MIME_TEXT_HTML
388
+ }
478
389
  }
479
- responseHeaders.ETag = `"${archive.version}"`
390
+ }
391
+ }
480
392
 
481
- return {
482
- statusCode: 200,
483
- headers: responseHeaders,
484
- data: intoAsyncIterable('')
393
+ // TODO: Add range header calculation
394
+ if (accept.includes('text/html')) {
395
+ return {
396
+ status: 204,
397
+ headers: {
398
+ ...resHeaders,
399
+ [HEADER_CONTENT_TYPE]: MIME_TEXT_HTML
485
400
  }
486
401
  }
487
- } else if ((method === 'GET') || (method === 'HEAD')) {
488
- if (method === 'GET' && headers.get('Accept') === 'text/event-stream') {
489
- const contentFeed = await archive.getContent()
490
- const events = new EventIterator(({ push, fail }) => {
491
- const watcher = archive.watch(path, () => {
492
- const event = 'change'
493
- const data = archive.version
494
- push({ event, data })
495
- })
496
- watcher.on('error', fail)
497
- function onDownloadMetadata (index) {
498
- const event = 'download'
499
- const source = archive.metadata.key.toString('hex')
500
- const data = { index, source }
501
- push({ event, data })
502
- }
503
- function onUploadMetadata (index) {
504
- const event = 'download'
505
- const source = archive.metadata.key.toString('hex')
506
- const data = { index, source }
507
- push({ event, data })
508
- }
402
+ }
509
403
 
510
- function onDownloadContent (index) {
511
- const event = 'download'
512
- const source = contentFeed.key.toString('hex')
513
- const data = { index, source }
514
- push({ event, data })
515
- }
516
- function onUploadContent (index) {
517
- const event = 'download'
518
- const source = contentFeed.key.toString('hex')
519
- const data = { index, source }
520
- push({ event, data })
521
- }
404
+ return {
405
+ status: 204,
406
+ headers: {
407
+ ...resHeaders,
408
+ [HEADER_CONTENT_TYPE]: MIME_APPLICATION_JSON
409
+ }
410
+ }
411
+ }
412
+ const entry = await drive.entry(pathname)
522
413
 
523
- // TODO: Filter out indexes that don't belong to files?
524
-
525
- archive.metadata.on('download', onDownloadMetadata)
526
- archive.metadata.on('upload', onUploadMetadata)
527
- contentFeed.on('download', onDownloadContent)
528
- contentFeed.on('upload', onUploadMetadata)
529
- return () => {
530
- watcher.destroy()
531
- archive.metadata.removeListener('download', onDownloadMetadata)
532
- archive.metadata.removeListener('upload', onUploadMetadata)
533
- contentFeed.removeListener('download', onDownloadContent)
534
- contentFeed.removeListener('upload', onUploadContent)
535
- }
536
- })
537
- async function * startReader () {
538
- for await (const { event, data } of events) {
539
- yield `event:${event}\ndata:${JSON.stringify(data)}\n\n`
540
- }
541
- }
414
+ if (!entry) {
415
+ return { status: 404, body: 'Not Found' }
416
+ }
542
417
 
543
- responseHeaders['Content-Type'] = 'text/event-stream'
418
+ resHeaders.ETag = `${entry.seq}`
544
419
 
545
- return {
546
- statusCode: 200,
547
- headers: responseHeaders,
548
- data: startReader()
549
- }
550
- }
420
+ const contentType = getMimeType(pathname)
421
+ resHeaders['Content-Type'] = contentType
551
422
 
552
- let stat = null
553
- let finalPath = path
423
+ if (entry.metadata?.mtime) {
424
+ const date = new Date(entry.metadata.mtime)
425
+ resHeaders['Last-Modified'] = date.toUTCString()
426
+ }
554
427
 
555
- if (headers.get('x-download') === 'cache') {
556
- await archive.download(path)
557
- }
428
+ const size = entry.value.byteLength
429
+ if (isRanged) {
430
+ const ranges = parseRange(size, isRanged)
558
431
 
559
- // Legacy DNS spec from Dat protocol: https://github.com/datprotocol/DEPs/blob/master/proposals/0005-dns.md
560
- if (finalPath === '/.well-known/dat') {
561
- const { key } = archive
562
- const entry = `dat://${key.toString('hex')}\nttl=3600`
563
- return {
564
- statusCode: 200,
565
- headers: responseHeaders,
566
- data: intoAsyncIterable(entry)
567
- }
568
- }
432
+ if (ranges && ranges.length && ranges.type === 'bytes') {
433
+ const [{ start, end }] = ranges
434
+ const length = (end - start + 1)
569
435
 
570
- // New spec from hyper-dns https://github.com/martinheidegger/hyper-dns
571
- if (finalPath === '/.well-known/hyper') {
572
- const { key } = archive
573
- const entry = `hyper://${key.toString('hex')}\nttl=3600`
574
- return {
575
- statusCode: 200,
576
- headers: responseHeaders,
577
- data: intoAsyncIterable(entry)
578
- }
579
- }
580
- try {
581
- if (searchParams.has('noResolve')) {
582
- const stats = await archive.stat(path)
583
- stat = stats[0]
584
- } else {
585
- const resolved = await resolveDatPath(archive, path)
586
- finalPath = resolved.path
587
- stat = resolved.stat
588
- }
589
- } catch (e) {
590
- responseHeaders['Content-Type'] = 'text/plain; charset=utf-8'
591
- return {
592
- statusCode: 404,
593
- headers: responseHeaders,
594
- data: intoAsyncIterable(e.stack)
436
+ return {
437
+ status: 200,
438
+ headers: {
439
+ ...resHeaders,
440
+ 'Content-Length': `${length}`,
441
+ 'Content-Range': `bytes ${start}-${end}/${size}`
595
442
  }
596
443
  }
444
+ }
445
+ }
597
446
 
598
- responseHeaders['Content-Type'] = getMimeType(finalPath)
599
- responseHeaders['Last-Modified'] = stat.mtime.toUTCString()
600
-
601
- let data = null
602
- const isRanged = headers.get('Range') || headers.get('range')
603
- let statusCode = 200
447
+ return {
448
+ status: 200,
449
+ headers: resHeaders
450
+ }
451
+ })
604
452
 
605
- if (stat.isDirectory()) {
606
- responseHeaders['x-is-directory'] = 'true'
607
- const stats = await archive.readdir(finalPath, { includeStats: true })
608
- const files = stats.map(({ stat, name }) => (stat.isDirectory() ? `${name}/` : name))
453
+ // TODO: Redirect on directories without trailing slash
454
+ router.get('hyper://*/**', async function getFiles (request) {
455
+ const url = new URL(request.url)
456
+ const { hostname, pathname, searchParams } = url
457
+ const accept = request.headers.get('Accept') || ''
458
+ const noResolve = searchParams.has('noResolve')
459
+ const isDirectory = pathname.endsWith('/')
609
460
 
610
- // Add special directory
611
- if (finalPath === '/') files.unshift('$/')
461
+ const drive = await getDrive(hostname)
612
462
 
613
- data = renderFiles(headers, responseHeaders, url, path, files)
614
- } else {
615
- responseHeaders['Accept-Ranges'] = 'bytes'
463
+ if (isDirectory) {
464
+ const resHeaders = {
465
+ ETag: `${drive.version}`,
466
+ Link: `<${drive.core.url}>; rel="canonical"`
467
+ }
616
468
 
617
- try {
618
- const { blocks, downloadedBlocks } = await archive.stats(finalPath)
619
- responseHeaders['X-Blocks'] = `${blocks}`
620
- responseHeaders['X-Blocks-Downloaded'] = `${downloadedBlocks}`
621
- } catch (e) {
622
- // Don't worry about it, it's optional.
623
- }
469
+ const entries = await listEntries(drive, pathname)
624
470
 
625
- const { size } = stat
626
- responseHeaders['Content-Length'] = `${size}`
627
-
628
- if (isRanged) {
629
- const ranges = parseRange(size, isRanged)
630
- if (ranges && ranges.length && ranges.type === 'bytes') {
631
- statusCode = 206
632
- const [{ start, end }] = ranges
633
- const length = (end - start + 1)
634
- responseHeaders['Content-Length'] = `${length}`
635
- responseHeaders['Content-Range'] = `bytes ${start}-${end}/${size}`
636
- if (method !== 'HEAD') {
637
- data = archive.createReadStream(finalPath, {
638
- start,
639
- end
640
- })
641
- }
642
- } else {
643
- if (method !== 'HEAD') {
644
- data = archive.createReadStream(finalPath)
645
- }
646
- }
647
- } else if (method !== 'HEAD') {
648
- data = archive.createReadStream(finalPath)
471
+ if (!entries.length && pathname !== '/') {
472
+ return {
473
+ status: 404,
474
+ body: '[]',
475
+ headers: {
476
+ ...resHeaders,
477
+ [HEADER_CONTENT_TYPE]: MIME_APPLICATION_JSON
649
478
  }
650
479
  }
480
+ }
651
481
 
652
- if (method === 'HEAD') {
653
- return {
654
- statusCode: 204,
655
- headers: responseHeaders,
656
- data: intoAsyncIterable('')
657
- }
658
- } else {
659
- return {
660
- statusCode,
661
- headers: responseHeaders,
662
- data
663
- }
482
+ if (!noResolve) {
483
+ if (entries.includes('index.html')) {
484
+ return serveFile(request.headers, drive, posix.join(pathname, 'index.html'))
664
485
  }
665
- } else {
486
+ }
487
+
488
+ if (accept.includes('text/html')) {
489
+ const body = renderIndex(url, entries, fetch)
666
490
  return {
667
- statusCode: 405,
668
- headers: responseHeaders,
669
- data: intoAsyncIterable('Method Not Allowed')
491
+ status: 200,
492
+ body,
493
+ headers: {
494
+ ...resHeaders,
495
+ [HEADER_CONTENT_TYPE]: MIME_TEXT_HTML
496
+ }
670
497
  }
671
498
  }
672
- } catch (e) {
673
- const isUnauthorized = (e.message === NOT_WRITABLE_ERROR)
674
- const statusCode = isUnauthorized ? 403 : 500
675
- const statusText = isUnauthorized ? 'Not Authorized' : 'Server Error'
499
+
676
500
  return {
677
- statusCode,
678
- statusText,
679
- headers: responseHeaders,
680
- data: intoAsyncIterable(e.stack)
501
+ status: 200,
502
+ body: JSON.stringify(entries, null, '\t'),
503
+ headers: {
504
+ ...resHeaders,
505
+ [HEADER_CONTENT_TYPE]: MIME_APPLICATION_JSON
506
+ }
681
507
  }
682
508
  }
683
- }
684
-
685
- function getHyperdrive () {
686
- if (Hyperdrive) return Hyperdrive
687
- return getSDK().then(({ Hyperdrive }) => Hyperdrive)
688
- }
509
+ const entry = await drive.entry(pathname)
689
510
 
690
- function getSDK () {
691
- if (sdk) return Promise.resolve(sdk)
692
- if (gettingSDK) return gettingSDK
693
- gettingSDK = SDK(opts).then((gotSDK) => {
694
- sdk = gotSDK
695
- gettingSDK = null
696
- onClose = async () => sdk.close()
697
- Hyperdrive = sdk.Hyperdrive
698
-
699
- return sdk
700
- })
701
-
702
- return gettingSDK
703
- }
704
-
705
- function checkWritable (archive) {
706
- if (!writable) throw new Error(NOT_WRITABLE_ERROR)
707
- if (!archive.writable) {
708
- throw new Error(NOT_WRITABLE_ERROR)
511
+ if (!entry) {
512
+ return { status: 404, body: 'Not Found' }
709
513
  }
710
- }
711
- }
712
514
 
713
- function parseDatURL (url) {
714
- const parsed = new URL(url)
715
- let key = parsed.hostname
716
- let version = null
717
- if (key.includes('+')) [key, version] = key.split('+')
718
-
719
- parsed.key = key
720
- parsed.version = version
515
+ return serveFile(request.headers, drive, pathname)
516
+ })
721
517
 
722
- return parsed
518
+ return fetch
723
519
  }
724
520
 
725
- async function * intoAsyncIterable (data) {
726
- yield Buffer.from(data)
727
- }
521
+ async function serveFile (headers, drive, pathname) {
522
+ const isRanged = headers.get('Range') || ''
523
+ const contentType = getMimeType(pathname)
728
524
 
729
- function getMimeType (path) {
730
- let mimeType = mime.getType(path) || 'text/plain; charset=utf-8'
731
- if (mimeType.startsWith('text/')) mimeType = `${mimeType}; charset=utf-8`
732
- return mimeType
733
- }
525
+ const entry = await drive.entry(pathname)
734
526
 
735
- function renderDirectory (url, path, files) {
736
- return `<!DOCTYPE html>
737
- <title>${url}</title>
738
- <meta name="viewport" content="width=device-width, initial-scale=1" />
739
- <h1>Index of ${path}</h1>
740
- <ul>
741
- <li><a href="../">../</a></li>${files.map((file) => `
742
- <li><a href="${file}">./${file}</a></li>
743
- `).join('')}
744
- </ul>
745
- `
746
- }
527
+ const resHeaders = {
528
+ ETag: `${entry.seq}`,
529
+ [HEADER_CONTENT_TYPE]: contentType,
530
+ 'Accept-Ranges': 'bytes',
531
+ Link: `<${drive.core.url}>; rel="canonical"`
532
+ }
747
533
 
748
- function renderFiles (headers, responseHeaders, url, path, files) {
749
- if (headers.get('Accept') && headers.get('Accept').includes('text/html')) {
750
- const page = renderDirectory(url, path, files)
751
- responseHeaders['Content-Type'] = 'text/html; charset=utf-8'
752
- return intoAsyncIterable(page)
753
- } else {
754
- const json = JSON.stringify(files, null, '\t')
755
- responseHeaders['Content-Type'] = 'application/json; charset=utf-8'
756
- return intoAsyncIterable(json)
534
+ if (entry.metadata?.mtime) {
535
+ const date = new Date(entry.metadata.mtime)
536
+ resHeaders['Last-Modified'] = date.toUTCString()
757
537
  }
758
- }
759
538
 
760
- function once (ee, name) {
761
- return new Promise((resolve, reject) => {
762
- const isError = name === 'error'
763
- const cb = isError ? reject : resolve
764
- ee.once(name, cb)
765
- })
766
- }
539
+ const size = entry.value.blob.byteLength
540
+ if (isRanged) {
541
+ const ranges = parseRange(size, isRanged)
767
542
 
768
- async function collect (source) {
769
- let buffer = ''
543
+ if (ranges && ranges.length && ranges.type === 'bytes') {
544
+ const [{ start, end }] = ranges
545
+ const length = (end - start + 1)
770
546
 
771
- for await (const chunk of source) {
772
- buffer += chunk
547
+ return {
548
+ status: 200,
549
+ body: drive.createReadStream(pathname, {
550
+ start,
551
+ end
552
+ }),
553
+ headers: {
554
+ ...resHeaders,
555
+ 'Content-Length': `${length}`,
556
+ 'Content-Range': `bytes ${start}-${end}/${size}`
557
+ }
558
+ }
559
+ }
560
+ }
561
+ return {
562
+ status: 200,
563
+ headers: {
564
+ ...resHeaders,
565
+ 'Content-Length': `${size}`
566
+ },
567
+ body: drive.createReadStream(pathname)
773
568
  }
569
+ }
774
570
 
775
- return buffer
571
+ async function listEntries (drive, pathname = '/') {
572
+ const entries = []
573
+ for await (const path of drive.readdir(pathname)) {
574
+ const stat = await drive.entry(path)
575
+ if (stat === null) {
576
+ entries.push(path + '/')
577
+ } else {
578
+ entries.push(path)
579
+ }
580
+ }
581
+ return entries
776
582
  }
777
583
 
778
584
  function formatPeers (peers) {
779
- return peers.map(({ remotePublicKey, remoteAddress, remoteType, stats }) => {
585
+ return peers.map((peer) => {
586
+ const remotePublicKey = peer.remotePublicKey.toString('hex')
587
+ const remoteHost = peer.stream?.rawStream?.remoteHost
780
588
  return {
781
- remotePublicKey: remotePublicKey.toString('hex'),
782
- remoteType,
783
- remoteAddress,
784
- stats
589
+ remotePublicKey,
590
+ remoteHost
785
591
  }
786
592
  })
787
593
  }
594
+
595
+ function getMimeType (path) {
596
+ let mimeType = mime.getType(path) || 'text/plain; charset=utf-8'
597
+ if (mimeType.startsWith('text/')) mimeType = `${mimeType}; charset=utf-8`
598
+ return mimeType
599
+ }