hypercore-fetch 9.6.0 → 9.8.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.
Files changed (4) hide show
  1. package/README.md +17 -2
  2. package/index.js +62 -16
  3. package/package.json +2 -2
  4. package/test.js +39 -28
package/README.md CHANGED
@@ -120,13 +120,18 @@ Note that this is only available with the `writable: true` flag.
120
120
 
121
121
  ### `fetch('hyper://NAME/example.txt', {method: 'PUT', body: 'Hello World'})`
122
122
 
123
- You can add files to archives using a `PUT` method along with a `body`.
123
+ You can add files to archives using a `PUT` method along with a
124
+ `body`. Note that this is only available with the `writable: true`
125
+ flag.
124
126
 
125
127
  The `body` can be any of the options supported by the Fetch API such as a `String`, `Blob`, `FormData`, or `ReadableStream`.
126
128
 
127
129
  `NAME` can either be the 52 character [z32 encoded](https://github.com/mafintosh/z32) key for a Hyperdrive or Hypercore , or a domain to parse with the [DNSLink](https://www.dnslink.io/) standard.
128
130
 
129
- Note that this is only available with the `writable: true` flag.
131
+ The mtime metadata is automatically set to the current time when
132
+ uploading. To override this value, pass a `Last-Modified` header with a value
133
+ set to a date string according to [RFC
134
+ 7231](https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1).
130
135
 
131
136
  An attempt to `PUT` a file to a hyperdrive which is not writable will
132
137
  fail with status `403`.
@@ -142,6 +147,16 @@ Note that you must use the name `file` for uploaded files.
142
147
 
143
148
  `NAME` can either be the 52 character [z32 encoded](https://github.com/mafintosh/z32) key for a Hyperdrive or Hypercore , or a domain to parse with the [DNSLink](https://www.dnslink.io/) standard.
144
149
 
150
+ ### `fetch('hyper://NAME/', {method: 'DELETE'})`
151
+
152
+ You can purge all the stored data for a hyperdrive by sending a `DELETE` to it's root.
153
+
154
+ If this is a writable drive, your data will get fully clearned and trying to write to it again will lead to data corruption.
155
+
156
+ If you try to load this drive again data will be loaded from scratch.
157
+
158
+ `NAME` can either be the 52 character [z32 encoded](https://github.com/mafintosh/z32) key for a Hyperdrive or Hypercore , or a domain to parse with the [DNSLink](https://www.dnslink.io/) standard.
159
+
145
160
  ### `fetch('hyper://NAME/example.txt', {method: 'DELETE'})`
146
161
 
147
162
  You can delete a file or directory tree in a Hyperdrive by using the `DELETE` method.
package/index.js CHANGED
@@ -3,7 +3,7 @@ import { posix } from 'path'
3
3
  import { Readable, pipelinePromise } from 'streamx'
4
4
  import Hyperdrive from 'hyperdrive'
5
5
  import { makeRoutedFetch } from 'make-fetch'
6
- import mime from 'mime/lite.js'
6
+ import mime from 'mime/index.js'
7
7
  import parseRange from 'range-parser'
8
8
  import { EventIterator } from 'event-iterator'
9
9
 
@@ -36,13 +36,16 @@ const BASIC_METHODS = [
36
36
  ]
37
37
 
38
38
  export const ERROR_KEY_NOT_CREATED = 'Must create key with POST before reading'
39
+ export const ERROR_DRIVE_EMPTY = 'Could not find data in drive, make sure your key is correct and that there are peers online to load data from'
39
40
 
40
41
  const INDEX_FILES = [
41
42
  'index.html',
42
43
  'index.md',
43
44
  'index.gmi',
44
45
  'index.gemini',
45
- 'README.md'
46
+ 'index.org',
47
+ 'README.md',
48
+ 'README.org'
46
49
  ]
47
50
 
48
51
  async function DEFAULT_RENDER_INDEX (url, files, fetch) {
@@ -70,7 +73,9 @@ export default async function makeHyperFetch ({
70
73
  timeout = DEFAULT_TIMEOUT,
71
74
  renderIndex = DEFAULT_RENDER_INDEX
72
75
  }) {
73
- const { fetch, router } = makeRoutedFetch()
76
+ const { fetch, router } = makeRoutedFetch({
77
+ onError
78
+ })
74
79
 
75
80
  // Map loaded drive hostnames to their keys
76
81
  // TODO: Track LRU + cache clearing
@@ -91,6 +96,7 @@ export default async function makeHyperFetch ({
91
96
 
92
97
  router.put(`hyper://*/${SPECIAL_FOLDER}/${VERSION_FOLDER_NAME}/**`, putFilesVersioned)
93
98
  router.put('hyper://*/**', putFiles)
99
+ router.delete('hyper://*/', deleteDrive)
94
100
  router.delete(`hyper://*/${SPECIAL_FOLDER}/${VERSION_FOLDER_NAME}/**`, deleteFilesVersioned)
95
101
  router.delete('hyper://*/**', deleteFiles)
96
102
  }
@@ -100,6 +106,16 @@ export default async function makeHyperFetch ({
100
106
  router.get('hyper://*/**', getFiles)
101
107
  router.head('hyper://*/**', headFiles)
102
108
 
109
+ async function onError (e, request) {
110
+ return {
111
+ status: e.statusCode || 500,
112
+ headers: {
113
+ 'Content-Type': 'text/plain; charset=utf-8'
114
+ },
115
+ body: e.stack
116
+ }
117
+ }
118
+
103
119
  async function getCore (hostname) {
104
120
  if (cores.has(hostname)) {
105
121
  return cores.get(hostname)
@@ -128,18 +144,30 @@ export default async function makeHyperFetch ({
128
144
  return dbCore
129
145
  }
130
146
 
131
- async function getDrive (hostname) {
147
+ async function getDrive (hostname, errorOnNew = false) {
132
148
  if (drives.has(hostname)) {
133
149
  return drives.get(hostname)
134
150
  }
135
151
 
136
152
  const core = await getCore(hostname)
137
153
 
154
+ if (!core.length && errorOnNew) {
155
+ await core.close()
156
+ const e = new Error(ERROR_DRIVE_EMPTY)
157
+ e.statusCode = 404
158
+ throw e
159
+ }
160
+
138
161
  const corestore = sdk.namespace(core.id)
139
162
  const drive = new Hyperdrive(corestore, core.key)
140
163
 
141
164
  await drive.ready()
142
165
 
166
+ drive.once('close', () => {
167
+ drives.delete(drive.core.id)
168
+ drives.delete(hostname)
169
+ })
170
+
143
171
  drives.set(drive.core.id, drive)
144
172
  drives.set(hostname, drive)
145
173
 
@@ -151,8 +179,11 @@ export default async function makeHyperFetch ({
151
179
  return drives.get(key)
152
180
  }
153
181
  const core = await getDBCoreForName(key)
182
+
154
183
  if (!core.length && errorOnNew) {
155
- throw new Error(ERROR_KEY_NOT_CREATED)
184
+ const e = new Error(ERROR_KEY_NOT_CREATED)
185
+ e.statusCode = 404
186
+ throw e
156
187
  }
157
188
 
158
189
  const corestore = sdk.namespace(key)
@@ -160,7 +191,14 @@ export default async function makeHyperFetch ({
160
191
 
161
192
  await drive.ready()
162
193
 
194
+ drive.once('close', () => {
195
+ drives.delete(key)
196
+ drives.delete(drive.url)
197
+ drives.delete(drive.core.id)
198
+ })
199
+
163
200
  drives.set(key, drive)
201
+ drives.set(drive.url, drive)
164
202
  drives.set(drive.core.id, drive)
165
203
 
166
204
  return drive
@@ -359,16 +397,15 @@ export default async function makeHyperFetch ({
359
397
  const { hostname, pathname: rawPathname } = new URL(request.url)
360
398
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
361
399
  const contentType = request.headers.get('Content-Type') || ''
400
+ const mtime = Date.parse(request.headers.get('Last-Modified')) || Date.now()
362
401
  const isFormData = contentType.includes('multipart/form-data')
363
402
 
364
- const drive = await getDrive(`hyper://${hostname}`)
403
+ const drive = await getDrive(`hyper://${hostname}/`, true)
365
404
 
366
405
  if (!drive.db.feed.writable) {
367
406
  return { status: 403, body: `Cannot PUT file to read-only drive: ${drive.url}`, headers: { Location: request.url } }
368
407
  }
369
408
 
370
- const mtime = Date.now()
371
-
372
409
  if (isFormData) {
373
410
  // It's a form! Get the files out and process them
374
411
  const formData = await request.formData()
@@ -419,11 +456,20 @@ export default async function makeHyperFetch ({
419
456
  }
420
457
  }
421
458
 
459
+ async function deleteDrive (request) {
460
+ const { hostname } = new URL(request.url)
461
+ const drive = await getDrive(`hyper://${hostname}/`, true)
462
+
463
+ await drive.purge()
464
+
465
+ return { status: 200 }
466
+ }
467
+
422
468
  async function deleteFiles (request) {
423
469
  const { hostname, pathname: rawPathname } = new URL(request.url)
424
470
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
425
471
 
426
- const drive = await getDrive(`hyper://${hostname}`)
472
+ const drive = await getDrive(`hyper://${hostname}/`, true)
427
473
 
428
474
  if (!drive.db.feed.writable) {
429
475
  return { status: 403, body: `Cannot DELETE file in read-only drive: ${drive.url}`, headers: { Location: request.url } }
@@ -475,7 +521,7 @@ export default async function makeHyperFetch ({
475
521
  const version = parts[3]
476
522
  const realPath = ensureLeadingSlash(parts.slice(4).join('/'))
477
523
 
478
- const drive = await getDrive(`hyper://${hostname}`)
524
+ const drive = await getDrive(`hyper://${hostname}/`, true)
479
525
 
480
526
  const snapshot = await drive.checkout(version)
481
527
 
@@ -491,7 +537,7 @@ export default async function makeHyperFetch ({
491
537
  const isRanged = request.headers.get('Range') || ''
492
538
  const noResolve = searchParams.has('noResolve')
493
539
 
494
- const drive = await getDrive(`hyper://${hostname}`)
540
+ const drive = await getDrive(`hyper://${hostname}/`, true)
495
541
 
496
542
  return serveHead(drive, pathname, { accept, isRanged, noResolve })
497
543
  }
@@ -577,7 +623,7 @@ export default async function makeHyperFetch ({
577
623
  resHeaders[HEADER_LAST_MODIFIED] = date.toUTCString()
578
624
  }
579
625
 
580
- const size = entry.value.byteLength
626
+ const size = entry.value.blob.byteLength
581
627
  if (isRanged) {
582
628
  const ranges = parseRange(size, isRanged)
583
629
 
@@ -586,7 +632,7 @@ export default async function makeHyperFetch ({
586
632
  const length = (end - start + 1)
587
633
 
588
634
  return {
589
- status: 200,
635
+ status: 204,
590
636
  headers: {
591
637
  ...resHeaders,
592
638
  'Content-Length': `${length}`,
@@ -597,7 +643,7 @@ export default async function makeHyperFetch ({
597
643
  }
598
644
 
599
645
  return {
600
- status: 200,
646
+ status: 204,
601
647
  headers: resHeaders
602
648
  }
603
649
  }
@@ -615,7 +661,7 @@ export default async function makeHyperFetch ({
615
661
  const version = parts[3]
616
662
  const realPath = ensureLeadingSlash(parts.slice(4).join('/'))
617
663
 
618
- const drive = await getDrive(`hyper://${hostname}`)
664
+ const drive = await getDrive(`hyper://${hostname}/`, true)
619
665
 
620
666
  const snapshot = await drive.checkout(version)
621
667
 
@@ -632,7 +678,7 @@ export default async function makeHyperFetch ({
632
678
  const isRanged = request.headers.get('Range') || ''
633
679
  const noResolve = searchParams.has('noResolve')
634
680
 
635
- const drive = await getDrive(`hyper://${hostname}`)
681
+ const drive = await getDrive(`hyper://${hostname}/`, true)
636
682
 
637
683
  return serveGet(drive, pathname, { accept, isRanged, noResolve })
638
684
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypercore-fetch",
3
- "version": "9.6.0",
3
+ "version": "9.8.0",
4
4
  "description": "Implementation of Fetch that uses the Dat SDK for loading p2p content",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@rangermauve/fetch-event-source": "^1.0.3",
35
- "hyper-sdk": "^4.2.3",
35
+ "hyper-sdk": "^4.3.0",
36
36
  "standard": "^17.0.0",
37
37
  "tape": "^5.2.2"
38
38
  }
package/test.js CHANGED
@@ -127,6 +127,7 @@ test('HEAD request', async (t) => {
127
127
  const headersLastModified = headResponse.headers.get('Last-Modified')
128
128
  const headersLink = headResponse.headers.get('Link')
129
129
 
130
+ t.equal(headResponse.status, 204, 'Response had expected status')
130
131
  // Version at which the file was added
131
132
  t.equal(headersEtag, '2', 'Headers got expected etag')
132
133
  t.equal(headersContentType, 'text/plain; charset=utf-8', 'Headers got expected mime type')
@@ -141,9 +142,13 @@ test('PUT file', async (t) => {
141
142
 
142
143
  const uploadLocation = new URL('./example.txt', created)
143
144
 
145
+ const fakeDate = new Date(Date.parse(0)).toUTCString()
144
146
  const uploadResponse = await fetch(uploadLocation, {
145
147
  method: 'put',
146
- body: SAMPLE_CONTENT
148
+ body: SAMPLE_CONTENT,
149
+ headers: {
150
+ 'Last-Modified': fakeDate
151
+ }
147
152
  })
148
153
 
149
154
  await checkResponse(uploadResponse, t, 'upload successful')
@@ -158,7 +163,7 @@ test('PUT file', async (t) => {
158
163
 
159
164
  t.equal(contentType, 'text/plain; charset=utf-8', 'Content got expected mime type')
160
165
  t.equal(content, SAMPLE_CONTENT, 'Got uploaded content back out')
161
- t.ok(lastModified, 'Last-Modified header got set')
166
+ t.equal(lastModified, fakeDate, 'Last-Modified header was set to value of Date header')
162
167
  })
163
168
  test('PUT FormData', async (t) => {
164
169
  const created = await nextURL(t)
@@ -286,7 +291,7 @@ test('DELETE a directory', async (t) => {
286
291
  })
287
292
  await checkResponse(uploadResponse, t)
288
293
 
289
- const deleteResponse = await fetch(created, {
294
+ const deleteResponse = await fetch(uploadLocation, {
290
295
  method: 'delete'
291
296
  })
292
297
  await checkResponse(deleteResponse, t, 'Able to DELETE')
@@ -296,6 +301,24 @@ test('DELETE a directory', async (t) => {
296
301
  const entries = await listDirRequest.json()
297
302
  t.deepEqual(entries, [], 'subfolder got deleted')
298
303
  })
304
+ test('DELETE a drive from storage', async (t) => {
305
+ const created = await nextURL(t)
306
+
307
+ const uploadLocation = new URL('./subfolder/example.txt', created)
308
+ const uploadResponse = await fetch(uploadLocation, {
309
+ method: 'put',
310
+ body: SAMPLE_CONTENT
311
+ })
312
+ await checkResponse(uploadResponse, t)
313
+
314
+ const purgeResponse = await fetch(created, { method: 'delete' })
315
+
316
+ await checkResponse(purgeResponse, t, 'Able to purge')
317
+
318
+ const listDirRequest = await fetch(created)
319
+
320
+ t.notOk(listDirRequest.ok, 'Error when trying to read after purge')
321
+ })
299
322
  test('Read index.html', async (t) => {
300
323
  const created = await nextURL(t)
301
324
  const uploadLocation = new URL('./index.html', created)
@@ -419,14 +442,6 @@ test('Resolve pretty markdown URLs', async (t) => {
419
442
  t.equal(contentType, 'text/markdown; charset=utf-8', 'Got markdown mime type')
420
443
  })
421
444
 
422
- test('Doing a `GET` on an invalid domain should cause an error', async (t) => {
423
- const url = 'hyper://example/'
424
-
425
- const failedResponse = await fetch(url)
426
-
427
- t.notOk(failedResponse.ok, 'Response errored out due to invalid domain')
428
- })
429
-
430
445
  test('EventSource extension messages', async (t) => {
431
446
  const domain = await nextURL(t)
432
447
 
@@ -490,14 +505,13 @@ test('Resolve DNS', async (t) => {
490
505
  t.ok(entries.length, 'Loaded contents with some files present')
491
506
  })
492
507
 
493
- test('Error on invalid hostname', async (t) => {
494
- const loadResponse = await fetch('hyper://example/')
508
+ test('Doing a `GET` on an invalid domain/public key should cause an error', async (t) => {
509
+ const invalidDomainResponse = await fetch('hyper://example/')
510
+ t.notOk(invalidDomainResponse.ok, 'Response errored out due to invalid domain')
495
511
 
496
- if (loadResponse.ok) {
497
- throw new Error('Loading without DNS or a public key should have failed')
498
- } else {
499
- t.pass('Invalid names led to an error')
500
- }
512
+ const invalidPublicKeyResponse = await fetch('hyper://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/')
513
+ t.notOk(invalidPublicKeyResponse.ok, 'Response errored out due to invalid public key')
514
+ t.equal(invalidPublicKeyResponse.status, 404, 'Invalid public key should 404')
501
515
  })
502
516
 
503
517
  test('Old versions in VERSION folder', async (t) => {
@@ -575,17 +589,10 @@ test('Handle empty string pathname', async (t) => {
575
589
  await fetch(urlNoTrailingSlash, {
576
590
  method: 'put',
577
591
  body: formData
578
- }), t
592
+ }),
593
+ t
579
594
  )
580
595
 
581
- // DELETE
582
- await checkResponse(await fetch(urlNoTrailingSlash, { method: 'DELETE' }), t)
583
-
584
- // HEAD
585
- const headResponse = await fetch(urlNoTrailingSlash, { method: 'HEAD' })
586
- await checkResponse(headResponse, t)
587
- t.deepEqual(headResponse.headers.get('Etag'), '5', 'HEAD request returns correct Etag')
588
-
589
596
  // HEAD (versioned)
590
597
  const versionedHeadResponse = await fetch(versionedURLNoTrailingSlash, { method: 'HEAD' })
591
598
  await checkResponse(versionedHeadResponse, t)
@@ -594,12 +601,16 @@ test('Handle empty string pathname', async (t) => {
594
601
  // GET
595
602
  const getResponse = await fetch(urlNoTrailingSlash)
596
603
  await checkResponse(getResponse, t)
597
- t.deepEqual(await getResponse.json(), [], 'Returns empty root directory')
604
+ t.deepEqual(await getResponse.json(), ['example.txt', 'example2.txt'], 'Returns directory listing')
598
605
 
599
606
  // GET (versioned)
600
607
  const versionedGetResponse = await fetch(versionedURLNoTrailingSlash)
601
608
  await checkResponse(versionedGetResponse, t)
602
609
  t.deepEqual(await versionedGetResponse.json(), ['example.txt', 'example2.txt'], 'Returns root directory prior to DELETE')
610
+
611
+
612
+ // DELETE
613
+ await checkResponse(await fetch(urlNoTrailingSlash, { method: 'DELETE' }), t, 'Able to delete root')
603
614
  })
604
615
 
605
616
  test('Return status 403 Forbidden on attempt to modify read-only hyperdrive', async (t) => {