hypercore-fetch 8.6.1 → 9.0.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,625 @@
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'
34
25
 
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
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
+ }
43
38
 
44
- let sdk = null
45
- let gettingSDK = null
46
- let onClose = async () => undefined
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
+ }
47
64
 
48
- const isSourceDat = base && base.startsWith('hyper://')
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
+ }
49
78
 
50
- const fetch = makeFetch(hyperFetch)
79
+ return dbCore
80
+ }
51
81
 
52
- fetch.close = () => onClose()
82
+ async function getDrive (hostname) {
83
+ if (drives.has(hostname)) {
84
+ return drives.get(hostname)
85
+ }
53
86
 
54
- function getExtension (archive, name) {
55
- const existing = archive.metadata.extensions.get(name)
56
- if (existing) return existing
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
+ }
108
+
109
+ const corestore = sdk.namespace(key)
110
+ const drive = new Hyperdrive(corestore)
111
+
112
+ await drive.ready()
113
+
114
+ drives.set(key, drive)
115
+ drives.set(drive.core.id, drive)
116
+
117
+ return drive
118
+ }
57
119
 
58
- const extension = archive.registerExtension(name, {
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
+ }
130
+
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('')
169
+
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
+ })
98
191
 
99
- const shouldIntercept = isHyperURL || (!urlHasProtocol && isSourceDat)
192
+ return {
193
+ statusCode: 200,
194
+ headers: {
195
+ [HEADER_CONTENT_TYPE]: MIME_EVENT_STREAM
196
+ },
197
+ body: events
198
+ }
199
+ }
100
200
 
101
- if (!shouldIntercept) throw new Error('Invalid protocol, must be hyper://')
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)
102
211
 
103
- const headers = new Headers(rawHeaders || {})
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)
122
231
 
123
- let archive = await loadArchive(key)
232
+ const core = await getCore(`hyper://${hostname}/`)
124
233
 
125
- if (!archive) {
234
+ const extension = await getExtension(core, name)
235
+ const data = await request.text()
236
+ extension.broadcast(data)
237
+
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
+ }
264
+
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
+ }
132
271
 
133
- await archive.ready()
272
+ const drive = await getDriveFromKey(key, true)
134
273
 
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
- })
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
+ return { status: 201, headers: { Location: request.url } }
321
+ })
322
+ router.delete('hyper://*/**', async function putFiles (request) {
323
+ const { hostname, pathname } = new URL(request.url)
277
324
 
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
- }
325
+ const drive = await getDrive(hostname)
326
+
327
+ if (pathname.endsWith('/')) {
328
+ let didDelete = false
329
+ for await (const entry of drive.list(pathname)) {
330
+ await drive.del(entry.key)
331
+ didDelete = true
332
+ }
333
+ if (!didDelete) {
334
+ return { status: 404, body: 'Not Found', headers: { [HEADER_CONTENT_TYPE]: MIME_TEXT_PLAIN } }
380
335
  }
336
+ return { status: 200 }
381
337
  }
382
338
 
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
- })
339
+ const entry = await drive.entry(pathname)
413
340
 
414
- // TODO: Does busboy need to be GC'd?
415
- return () => {}
416
- })
341
+ if (!entry) {
342
+ return { status: 404, body: 'Not Found', headers: { [HEADER_CONTENT_TYPE]: MIME_TEXT_PLAIN } }
343
+ }
344
+ await drive.del(pathname)
417
345
 
418
- Readable.from(body).pipe(busboy)
346
+ return { status: 200 }
347
+ })
348
+ }
419
349
 
420
- await Promise.all(await collect(toUpload))
350
+ router.head('hyper://*/**', async function headFiles (request) {
351
+ const url = new URL(request.url)
352
+ const { hostname, pathname, searchParams } = url
353
+ const accept = request.headers.get('Accept') || ''
354
+ const isRanged = request.headers.get('Range') || ''
355
+ const noResolve = searchParams.has('noResolve')
356
+ const isDirectory = pathname.endsWith('/')
421
357
 
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
- }
358
+ const drive = await getDrive(hostname)
439
359
 
440
- const source = Readable.from(body)
441
- const destination = archive.createWriteStream(path)
442
- // The sink is needed because Hyperdrive's write stream is duplex
360
+ const resHeaders = {
361
+ ETag: `${drive.version}`,
362
+ 'Accept-Ranges': 'bytes',
363
+ Link: `<${drive.core.url}>; rel="canonical"`
364
+ }
443
365
 
444
- source.pipe(destination)
366
+ if (isDirectory) {
367
+ const entries = await listEntries(drive, pathname)
445
368
 
446
- await Promise.race([
447
- once(source, 'error'),
448
- once(destination, 'error'),
449
- once(source, 'end')
450
- ])
451
- }
452
- responseHeaders.ETag = `"${archive.version}"`
369
+ const hasItems = entries.length
453
370
 
371
+ if (!hasItems && pathname !== '/') {
454
372
  return {
455
- statusCode: 200,
456
- headers: responseHeaders,
457
- data: intoAsyncIterable(canonical)
373
+ status: 404,
374
+ headers: {
375
+ [HEADER_CONTENT_TYPE]: MIME_TEXT_PLAIN
376
+ }
458
377
  }
459
- } else if (method === 'DELETE') {
460
- if (headers.get('x-clear') === 'cache') {
461
- await archive.clear(path)
378
+ }
379
+
380
+ if (!noResolve) {
381
+ if (entries.includes('index.html')) {
462
382
  return {
463
- statusCode: 200,
464
- headers: responseHeaders,
465
- data: intoAsyncIterable('')
383
+ status: 204,
384
+ headers: {
385
+ ...resHeaders,
386
+ [HEADER_CONTENT_TYPE]: MIME_TEXT_HTML
387
+ }
466
388
  }
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
389
+ }
390
+ }
473
391
 
474
- if (stat.isDirectory()) {
475
- await archive.rmdir(path)
476
- } else {
477
- await archive.unlink(path)
392
+ // TODO: Add range header calculation
393
+ if (accept.includes('text/html')) {
394
+ return {
395
+ status: 204,
396
+ headers: {
397
+ ...resHeaders,
398
+ [HEADER_CONTENT_TYPE]: MIME_TEXT_HTML
478
399
  }
479
- responseHeaders.ETag = `"${archive.version}"`
400
+ }
401
+ }
480
402
 
481
- return {
482
- statusCode: 200,
483
- headers: responseHeaders,
484
- data: intoAsyncIterable('')
485
- }
403
+ return {
404
+ status: 204,
405
+ headers: {
406
+ ...resHeaders,
407
+ [HEADER_CONTENT_TYPE]: MIME_APPLICATION_JSON
486
408
  }
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
- }
409
+ }
410
+ }
509
411
 
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
- }
412
+ const { entry, path } = await resolvePath(drive, pathname, noResolve)
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(path)
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 = await 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
509
 
685
- function getHyperdrive () {
686
- if (Hyperdrive) return Hyperdrive
687
- return getSDK().then(({ Hyperdrive }) => Hyperdrive)
688
- }
510
+ const { entry, path } = await resolvePath(drive, pathname, noResolve)
689
511
 
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
512
+ if (!entry) {
513
+ return { status: 404, body: 'Not Found' }
514
+ }
698
515
 
699
- return sdk
700
- })
516
+ return serveFile(request.headers, drive, path)
517
+ })
518
+
519
+ return fetch
520
+ }
521
+
522
+ async function serveFile (headers, drive, pathname) {
523
+ const isRanged = headers.get('Range') || ''
524
+ const contentType = getMimeType(pathname)
701
525
 
702
- return gettingSDK
526
+ const entry = await drive.entry(pathname)
527
+
528
+ const resHeaders = {
529
+ ETag: `${entry.seq}`,
530
+ [HEADER_CONTENT_TYPE]: contentType,
531
+ 'Accept-Ranges': 'bytes',
532
+ Link: `<${drive.core.url}>; rel="canonical"`
703
533
  }
704
534
 
705
- function checkWritable (archive) {
706
- if (!writable) throw new Error(NOT_WRITABLE_ERROR)
707
- if (!archive.writable) {
708
- throw new Error(NOT_WRITABLE_ERROR)
709
- }
535
+ if (entry.metadata?.mtime) {
536
+ const date = new Date(entry.metadata.mtime)
537
+ resHeaders['Last-Modified'] = date.toUTCString()
710
538
  }
711
- }
712
539
 
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('+')
540
+ const size = entry.value.blob.byteLength
541
+ if (isRanged) {
542
+ const ranges = parseRange(size, isRanged)
718
543
 
719
- parsed.key = key
720
- parsed.version = version
544
+ if (ranges && ranges.length && ranges.type === 'bytes') {
545
+ const [{ start, end }] = ranges
546
+ const length = (end - start + 1)
721
547
 
722
- return parsed
548
+ return {
549
+ status: 200,
550
+ body: drive.createReadStream(pathname, {
551
+ start,
552
+ end
553
+ }),
554
+ headers: {
555
+ ...resHeaders,
556
+ 'Content-Length': `${length}`,
557
+ 'Content-Range': `bytes ${start}-${end}/${size}`
558
+ }
559
+ }
560
+ }
561
+ }
562
+ return {
563
+ status: 200,
564
+ headers: {
565
+ ...resHeaders,
566
+ 'Content-Length': `${size}`
567
+ },
568
+ body: drive.createReadStream(pathname)
569
+ }
723
570
  }
724
571
 
725
- async function * intoAsyncIterable (data) {
726
- yield Buffer.from(data)
572
+ function makeToTry (pathname) {
573
+ return [
574
+ pathname,
575
+ pathname + '.html',
576
+ pathname + '.md'
577
+ ]
727
578
  }
728
579
 
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
- }
580
+ async function resolvePath (drive, pathname, noResolve) {
581
+ if (noResolve) {
582
+ const entry = drive.entry(pathname)
734
583
 
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
- }
584
+ return { entry, path: pathname }
585
+ }
747
586
 
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)
587
+ for (const path of makeToTry(pathname)) {
588
+ const entry = await drive.entry(path)
589
+ if (entry) {
590
+ return { entry, path }
591
+ }
757
592
  }
758
- }
759
593
 
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
- })
594
+ return { entry: null, path: null }
766
595
  }
767
596
 
768
- async function collect (source) {
769
- let buffer = ''
770
-
771
- for await (const chunk of source) {
772
- buffer += chunk
597
+ async function listEntries (drive, pathname = '/') {
598
+ const entries = []
599
+ for await (const path of drive.readdir(pathname)) {
600
+ const stat = await drive.entry(path)
601
+ if (stat === null) {
602
+ entries.push(path + '/')
603
+ } else {
604
+ entries.push(path)
605
+ }
773
606
  }
774
-
775
- return buffer
607
+ return entries
776
608
  }
777
609
 
778
610
  function formatPeers (peers) {
779
- return peers.map(({ remotePublicKey, remoteAddress, remoteType, stats }) => {
611
+ return peers.map((peer) => {
612
+ const remotePublicKey = peer.remotePublicKey.toString('hex')
613
+ const remoteHost = peer.stream?.rawStream?.remoteHost
780
614
  return {
781
- remotePublicKey: remotePublicKey.toString('hex'),
782
- remoteType,
783
- remoteAddress,
784
- stats
615
+ remotePublicKey,
616
+ remoteHost
785
617
  }
786
618
  })
787
619
  }
620
+
621
+ function getMimeType (path) {
622
+ let mimeType = mime.getType(path) || 'text/plain; charset=utf-8'
623
+ if (mimeType.startsWith('text/')) mimeType = `${mimeType}; charset=utf-8`
624
+ return mimeType
625
+ }