hypercore-fetch 10.0.0 → 10.1.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/README.md CHANGED
@@ -16,7 +16,7 @@ const fetch = await makeFetch({
16
16
  writable: true
17
17
  })
18
18
 
19
- const someURL = `hyper://TODO_REAL_URL_HERE_PLEASE`
19
+ const someURL = `hyper://blog.mauve.moe/`
20
20
 
21
21
  const response = await fetch(someURL)
22
22
 
@@ -27,7 +27,7 @@ console.log(data)
27
27
 
28
28
  ## API
29
29
 
30
- ### `makeHyperFetch({sdk, writable=false, extensionMessages = writable, renderIndex}) => fetch()`
30
+ ### `makeHyperFetch({sdk, writable=false, extensionMessages = writable, renderIndex, onLoad, onDelete}) => fetch()`
31
31
 
32
32
  Creates a hypercore-fetch instance.
33
33
 
@@ -40,6 +40,10 @@ The `writable` flag toggles whether the `PUT`/`POST`/`DELETE` methods are availa
40
40
  `renderIndex` is an optional function to override the HTML index rendering functionality. By default it will make a simple page which renders links to files and folders within the directory.
41
41
  This function takes the `url`, `files` array and `fetch` instance as arguments.
42
42
 
43
+ `onLoad` is an optional function that gets called whenever a site is loaded. It gets these arguments passed in: `(url: URL, writable: boolean, key?: string)`. The `key` gets specified on creation based on the name a user assigned it. Use this to track created drives and last access times (e.g. for clearing out old drives).
44
+
45
+ `onDelete` is an optional function that gets called whenever a drive is purged from storage. Similar to `onLoad`, it gets called with a `URL` of the deleted site.
46
+
43
47
  After you've created it, `fetch` will behave like it does in [browsers](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API).
44
48
 
45
49
  ### Common Headers
@@ -0,0 +1,28 @@
1
+ /**
2
+ *
3
+ * @param {object} options
4
+ * @param {import('hyper-sdk').SDK} options.sdk
5
+ * @param {boolean} [options.writable]
6
+ * @param {boolean} [options.extensionMessages]
7
+ * @param {number} [options.timeout]
8
+ * @param {typeof DEFAULT_RENDER_INDEX} [options.renderIndex]
9
+ * @param {OnLoadHandler} [options.onLoad]
10
+ * @param {OnDeleteHandler} [options.onDelete]
11
+ * @returns {Promise<typeof globalThis.fetch>}
12
+ */
13
+ export default function makeHyperFetch({ sdk, writable, extensionMessages, timeout, renderIndex, onLoad, onDelete }: {
14
+ sdk: import("hyper-sdk").SDK;
15
+ writable?: boolean | undefined;
16
+ extensionMessages?: boolean | undefined;
17
+ timeout?: number | undefined;
18
+ renderIndex?: typeof DEFAULT_RENDER_INDEX | undefined;
19
+ onLoad?: OnLoadHandler | undefined;
20
+ onDelete?: OnDeleteHandler | undefined;
21
+ }): Promise<typeof globalThis.fetch>;
22
+ export const ERROR_KEY_NOT_CREATED: "Must create key with POST before reading";
23
+ 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";
24
+ export type RenderIndexHandler = (url: URL, files: string[], fetch: typeof globalThis.fetch) => Promise<string>;
25
+ export type OnLoadHandler = (url: URL, writable: boolean, name?: string) => void;
26
+ export type OnDeleteHandler = (url: URL) => void;
27
+ declare function DEFAULT_RENDER_INDEX(url: URL, files: string[], fetch: typeof globalThis.fetch): Promise<string>;
28
+ export {};
package/dist/test.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/index.js CHANGED
@@ -1,11 +1,19 @@
1
1
  import { posix } from 'path'
2
2
 
3
+ // @ts-ignore
3
4
  import { Readable, pipelinePromise } from 'streamx'
4
5
  import { makeRoutedFetch } from 'make-fetch'
5
6
  import mime from 'mime/index.js'
6
7
  import parseRange from 'range-parser'
7
8
  import { EventIterator } from 'event-iterator'
8
9
 
10
+ /** @import Hypercore from 'hypercore' */
11
+ /** @import Hyperdrive from 'hyperdrive' */
12
+
13
+ /** @typedef {(url: URL, files: string[], fetch: typeof globalThis.fetch) => Promise<string>} RenderIndexHandler */
14
+ /** @typedef {(url: URL, writable: boolean, name?: string) => void} OnLoadHandler */
15
+ /** @typedef {(url: URL) => void} OnDeleteHandler */
16
+
9
17
  const DEFAULT_TIMEOUT = 5000
10
18
 
11
19
  const SPECIAL_DOMAIN = 'localhost'
@@ -47,6 +55,7 @@ const INDEX_FILES = [
47
55
  'README.org'
48
56
  ]
49
57
 
58
+ /** @type {RenderIndexHandler} */
50
59
  async function DEFAULT_RENDER_INDEX (url, files, fetch) {
51
60
  return `
52
61
  <!DOCTYPE html>
@@ -65,12 +74,28 @@ mime.define({
65
74
  'text/gemini': ['gmi', 'gemini']
66
75
  }, true)
67
76
 
77
+ function noop () {}
78
+
79
+ /**
80
+ *
81
+ * @param {object} options
82
+ * @param {import('hyper-sdk').SDK} options.sdk
83
+ * @param {boolean} [options.writable]
84
+ * @param {boolean} [options.extensionMessages]
85
+ * @param {number} [options.timeout]
86
+ * @param {typeof DEFAULT_RENDER_INDEX} [options.renderIndex]
87
+ * @param {OnLoadHandler} [options.onLoad]
88
+ * @param {OnDeleteHandler} [options.onDelete]
89
+ * @returns {Promise<typeof globalThis.fetch>}
90
+ */
68
91
  export default async function makeHyperFetch ({
69
92
  sdk,
70
93
  writable = false,
71
94
  extensionMessages = writable,
72
95
  timeout = DEFAULT_TIMEOUT,
73
- renderIndex = DEFAULT_RENDER_INDEX
96
+ renderIndex = DEFAULT_RENDER_INDEX,
97
+ onLoad = noop,
98
+ onDelete = noop
74
99
  }) {
75
100
  const { fetch, router } = makeRoutedFetch({
76
101
  onError
@@ -103,8 +128,14 @@ export default async function makeHyperFetch ({
103
128
  router.get('hyper://*/**', getFiles)
104
129
  router.head('hyper://*/**', headFiles)
105
130
 
106
- async function onError (e, request) {
131
+ /**
132
+ *
133
+ * @param {Error} e
134
+ * @returns {Promise<import('make-fetch').ResponseLike>}
135
+ */
136
+ async function onError (e) {
107
137
  return {
138
+ // @ts-ignore
108
139
  status: e.statusCode || 500,
109
140
  headers: {
110
141
  'Content-Type': 'text/plain; charset=utf-8'
@@ -113,6 +144,12 @@ export default async function makeHyperFetch ({
113
144
  }
114
145
  }
115
146
 
147
+ /**
148
+ *
149
+ * @param {Hypercore} core
150
+ * @param {string} name
151
+ * @returns
152
+ */
116
153
  async function getExtension (core, name) {
117
154
  const key = core.url + name
118
155
  if (extensions.has(key)) {
@@ -125,7 +162,7 @@ export default async function makeHyperFetch ({
125
162
  }
126
163
 
127
164
  const extension = core.registerExtension(name, {
128
- encoding: 'utf8',
165
+ encoding: 'utf-8',
129
166
  onmessage: (content, peer) => {
130
167
  core.emit(EXTENSION_EVENT, name, content, peer)
131
168
  }
@@ -136,6 +173,11 @@ export default async function makeHyperFetch ({
136
173
  return extension
137
174
  }
138
175
 
176
+ /**
177
+ * @param {Hypercore} core
178
+ * @param {string} name
179
+ * @returns
180
+ */
139
181
  async function getExtensionPeers (core, name) {
140
182
  // List peers with this extension
141
183
  const allPeers = core.peers
@@ -144,10 +186,18 @@ export default async function makeHyperFetch ({
144
186
  })
145
187
  }
146
188
 
189
+ /**
190
+ * @param {Hypercore} core
191
+ * @returns
192
+ */
147
193
  function listExtensionNames (core) {
148
194
  return [...core.extensions.keys()]
149
195
  }
150
196
 
197
+ /**
198
+ * @param {Request} request
199
+ * @returns
200
+ */
151
201
  async function listExtensions (request) {
152
202
  const { hostname } = new URL(request.url)
153
203
  const accept = request.headers.get('Accept') || ''
@@ -156,6 +206,12 @@ export default async function makeHyperFetch ({
156
206
 
157
207
  if (accept.includes('text/event-stream')) {
158
208
  const events = new EventIterator(({ push }) => {
209
+ /**
210
+ *
211
+ * @param {string} name
212
+ * @param {string} content
213
+ * @param {import('hypercore').Peer} peer
214
+ */
159
215
  function onMessage (name, content, peer) {
160
216
  const id = peer.remotePublicKey.toString('hex')
161
217
  // TODO: Fancy verification on the `name`?
@@ -164,10 +220,18 @@ export default async function makeHyperFetch ({
164
220
 
165
221
  push(`id:${id}\nevent:${name}\n${data}\n`)
166
222
  }
223
+
224
+ /**
225
+ * @param {import('hypercore').Peer} peer
226
+ */
167
227
  function onPeerOpen (peer) {
168
228
  const id = peer.remotePublicKey.toString('hex')
169
229
  push(`id:${id}\nevent:${PEER_OPEN}\n\n`)
170
230
  }
231
+
232
+ /**
233
+ * @param {import('hypercore').Peer} peer
234
+ */
171
235
  function onPeerRemove (peer) {
172
236
  // Whatever, probably an uninitialized peer
173
237
  if (!peer.remotePublicKey) return
@@ -201,6 +265,10 @@ export default async function makeHyperFetch ({
201
265
  }
202
266
  }
203
267
 
268
+ /**
269
+ * @param {Request} request
270
+ * @returns
271
+ */
204
272
  async function listenExtension (request) {
205
273
  const { hostname, pathname: rawPathname } = new URL(request.url)
206
274
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
@@ -223,6 +291,10 @@ export default async function makeHyperFetch ({
223
291
  }
224
292
  }
225
293
 
294
+ /**
295
+ * @param {Request} request
296
+ * @returns
297
+ */
226
298
  async function broadcastExtension (request) {
227
299
  const { hostname, pathname: rawPathname } = new URL(request.url)
228
300
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
@@ -238,6 +310,10 @@ export default async function makeHyperFetch ({
238
310
  return { status: 200 }
239
311
  }
240
312
 
313
+ /**
314
+ * @param {Request} request
315
+ * @returns
316
+ */
241
317
  async function extensionToPeer (request) {
242
318
  const { hostname, pathname: rawPathname } = new URL(request.url)
243
319
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
@@ -264,6 +340,10 @@ export default async function makeHyperFetch ({
264
340
  return { status: 200 }
265
341
  }
266
342
 
343
+ /**
344
+ * @param {Request} request
345
+ * @returns
346
+ */
267
347
  async function getKey (request) {
268
348
  const key = new URL(request.url).searchParams.get('key')
269
349
  if (!key) {
@@ -272,13 +352,15 @@ export default async function makeHyperFetch ({
272
352
 
273
353
  try {
274
354
  const drive = await sdk.getDrive(key)
355
+ onLoad(new URL('/', drive.url), drive.writable, key)
275
356
 
276
357
  return { body: drive.url }
277
358
  } catch (e) {
278
- if (e.message === ERROR_KEY_NOT_CREATED) {
359
+ const message = /** @type Error */(e).message
360
+ if (message === ERROR_KEY_NOT_CREATED) {
279
361
  return {
280
362
  status: 400,
281
- body: e.message,
363
+ body: message,
282
364
  headers: {
283
365
  [HEADER_CONTENT_TYPE]: MIME_TEXT_PLAIN
284
366
  }
@@ -287,6 +369,10 @@ export default async function makeHyperFetch ({
287
369
  }
288
370
  }
289
371
 
372
+ /**
373
+ * @param {Request} request
374
+ * @returns
375
+ */
290
376
  async function createKey (request) {
291
377
  // TODO: Allow importing secret keys here
292
378
  // Maybe specify a seed to use for generating the blobs?
@@ -298,28 +384,37 @@ export default async function makeHyperFetch ({
298
384
  }
299
385
 
300
386
  const drive = await sdk.getDrive(key)
387
+ onLoad(new URL('/', drive.url), drive.writable, key)
301
388
 
302
389
  return { body: drive.url }
303
390
  }
304
391
 
392
+ /**
393
+ * @param {Request} request
394
+ * @returns
395
+ */
305
396
  async function putFiles (request) {
306
397
  const { hostname, pathname: rawPathname } = new URL(request.url)
307
398
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
308
399
  const contentType = request.headers.get('Content-Type') || ''
309
- const mtime = Date.parse(request.headers.get('Last-Modified')) || Date.now()
400
+ const lastModified = request.headers.get('Last-Modified')
401
+ const mtime = lastModified ? Date.parse(lastModified) : Date.now()
310
402
  const isFormData = contentType.includes('multipart/form-data')
311
403
 
312
404
  const drive = await sdk.getDrive(`hyper://${hostname}/`)
313
405
 
314
- if (!drive.db.feed.writable) {
406
+ if (!drive.writable) {
315
407
  return { status: 403, body: `Cannot PUT file to read-only drive: ${drive.url}`, headers: { Location: request.url } }
316
408
  }
317
409
 
410
+ onLoad(new URL('/', request.url), drive.writable)
411
+
318
412
  if (isFormData) {
319
413
  // It's a form! Get the files out and process them
320
414
  const formData = await request.formData()
321
415
  for (const [name, data] of formData) {
322
416
  if (name !== 'file') continue
417
+ if (typeof data === 'string') continue
323
418
  const filePath = posix.join(pathname, data.name)
324
419
  await pipelinePromise(
325
420
  Readable.from(data.stream()),
@@ -357,6 +452,10 @@ export default async function makeHyperFetch ({
357
452
  return { status: 201, headers }
358
453
  }
359
454
 
455
+ /**
456
+ * @param {Request} request
457
+ * @returns
458
+ */
360
459
  function putFilesVersioned (request) {
361
460
  return {
362
461
  status: 405,
@@ -365,25 +464,35 @@ export default async function makeHyperFetch ({
365
464
  }
366
465
  }
367
466
 
467
+ /**
468
+ * @param {Request} request
469
+ * @returns
470
+ */
368
471
  async function deleteDrive (request) {
369
472
  const { hostname } = new URL(request.url)
370
473
  const drive = await sdk.getDrive(`hyper://${hostname}/`)
474
+ onDelete(new URL('/', request.url))
371
475
 
372
476
  await drive.purge()
373
477
 
374
478
  return { status: 200 }
375
479
  }
376
480
 
481
+ /**
482
+ * @param {Request} request
483
+ * @returns
484
+ */
377
485
  async function deleteFiles (request) {
378
486
  const { hostname, pathname: rawPathname } = new URL(request.url)
379
487
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
380
488
 
381
489
  const drive = await sdk.getDrive(`hyper://${hostname}/`)
382
-
383
- if (!drive.db.feed.writable) {
490
+ if (!drive.writable) {
384
491
  return { status: 403, body: `Cannot DELETE file in read-only drive: ${drive.url}`, headers: { Location: request.url } }
385
492
  }
386
493
 
494
+ onLoad(new URL('/', request.url), drive.writable)
495
+
387
496
  if (pathname.endsWith('/')) {
388
497
  let didDelete = false
389
498
  for await (const entry of drive.list(pathname)) {
@@ -413,10 +522,18 @@ export default async function makeHyperFetch ({
413
522
  return { status: 200, headers }
414
523
  }
415
524
 
525
+ /**
526
+ * @param {Request} request
527
+ * @returns
528
+ */
416
529
  function deleteFilesVersioned (request) {
417
530
  return { status: 405, body: 'Cannot DELETE old version', headers: { Location: request.url } }
418
531
  }
419
532
 
533
+ /**
534
+ * @param {Request} request
535
+ * @returns
536
+ */
420
537
  async function headFilesVersioned (request) {
421
538
  const url = new URL(request.url)
422
539
  const { hostname, pathname: rawPathname, searchParams } = url
@@ -427,7 +544,7 @@ export default async function makeHyperFetch ({
427
544
  const noResolve = searchParams.has('noResolve')
428
545
 
429
546
  const parts = pathname.split('/')
430
- const version = parts[3]
547
+ const version = parseInt(parts[3], 10)
431
548
  const realPath = ensureLeadingSlash(parts.slice(4).join('/'))
432
549
 
433
550
  const drive = await sdk.getDrive(`hyper://${hostname}/`)
@@ -444,6 +561,10 @@ export default async function makeHyperFetch ({
444
561
  return serveHead(snapshot, realPath, { accept, isRanged, noResolve })
445
562
  }
446
563
 
564
+ /**
565
+ * @param {Request} request
566
+ * @returns
567
+ */
447
568
  async function headFiles (request) {
448
569
  const url = new URL(request.url)
449
570
  const { hostname, pathname: rawPathname, searchParams } = url
@@ -465,19 +586,30 @@ export default async function makeHyperFetch ({
465
586
  return serveHead(drive, pathname, { accept, isRanged, noResolve })
466
587
  }
467
588
 
589
+ /**
590
+ *
591
+ * @param {Hyperdrive} drive
592
+ * @param {*} pathname
593
+ * @param {object} options
594
+ * @param {string} options.accept
595
+ * @param {string} options.isRanged
596
+ * @param {boolean} options.noResolve
597
+ * @returns
598
+ */
468
599
  async function serveHead (drive, pathname, { accept, isRanged, noResolve }) {
469
600
  const isDirectory = pathname.endsWith('/')
470
601
  const fullURL = new URL(pathname, drive.url).href
471
602
 
472
- const isWritable = writable && drive.db.feed.writable
603
+ const isWritable = writable && drive.writable
473
604
 
474
605
  const Allow = isWritable ? BASIC_METHODS.concat(WRITABLE_METHODS) : BASIC_METHODS
475
606
 
607
+ /** @type {{[key: string]: string}} */
476
608
  const resHeaders = {
477
609
  ETag: `${drive.version}`,
478
610
  'Accept-Ranges': 'bytes',
479
611
  Link: `<${fullURL}>; rel="canonical"`,
480
- Allow
612
+ Allow: Allow.toString()
481
613
  }
482
614
 
483
615
  if (isDirectory) {
@@ -549,8 +681,9 @@ export default async function makeHyperFetch ({
549
681
  const size = entry.value.blob.byteLength
550
682
  if (isRanged) {
551
683
  const ranges = parseRange(size, isRanged)
684
+ const isRangeValid = ranges !== -1 && ranges !== -2 && ranges
552
685
 
553
- if (ranges && ranges.length && ranges.type === 'bytes') {
686
+ if (isRangeValid && ranges.length && ranges.type === 'bytes') {
554
687
  const [{ start, end }] = ranges
555
688
  const length = (end - start + 1)
556
689
 
@@ -571,6 +704,10 @@ export default async function makeHyperFetch ({
571
704
  }
572
705
  }
573
706
 
707
+ /**
708
+ * @param {Request} request
709
+ * @returns
710
+ */
574
711
  async function getFilesVersioned (request) {
575
712
  const url = new URL(request.url)
576
713
  const { hostname, pathname: rawPathname, searchParams } = url
@@ -581,18 +718,31 @@ export default async function makeHyperFetch ({
581
718
  const noResolve = searchParams.has('noResolve')
582
719
 
583
720
  const parts = pathname.split('/')
584
- const version = parts[3]
721
+ const version = parseInt(parts[3], 10)
585
722
  const realPath = ensureLeadingSlash(parts.slice(4).join('/'))
586
723
 
587
724
  const drive = await sdk.getDrive(`hyper://${hostname}/`)
588
725
 
726
+ if (!drive.writable && !drive.core.length) {
727
+ return {
728
+ status: 404,
729
+ body: 'Peers Not Found'
730
+ }
731
+ }
732
+
733
+ onLoad(new URL('/', request.url), drive.writable)
734
+
589
735
  const snapshot = await drive.checkout(version)
590
736
 
591
737
  return serveGet(snapshot, realPath, { accept, isRanged, noResolve })
592
738
  }
593
739
 
594
- // TODO: Redirect on directories without trailing slash
740
+ /**
741
+ * @param {Request} request
742
+ * @returns
743
+ */
595
744
  async function getFiles (request) {
745
+ // TODO: Redirect on directories without trailing slash
596
746
  const url = new URL(request.url)
597
747
  const { hostname, pathname: rawPathname, searchParams } = url
598
748
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
@@ -610,9 +760,20 @@ export default async function makeHyperFetch ({
610
760
  }
611
761
  }
612
762
 
763
+ onLoad(new URL('/', request.url), drive.writable)
764
+
613
765
  return serveGet(drive, pathname, { accept, isRanged, noResolve })
614
766
  }
615
767
 
768
+ /**
769
+ * @param {Hyperdrive} drive
770
+ * @param {string} pathname
771
+ * @param {object} options
772
+ * @param {string} options.accept
773
+ * @param {string} options.isRanged
774
+ * @param {boolean} options.noResolve
775
+ * @returns
776
+ */
616
777
  async function serveGet (drive, pathname, { accept, isRanged, noResolve }) {
617
778
  const isDirectory = pathname.endsWith('/')
618
779
  const fullURL = new URL(pathname, drive.url).href
@@ -678,6 +839,13 @@ export default async function makeHyperFetch ({
678
839
  return fetch
679
840
  }
680
841
 
842
+ /**
843
+ *
844
+ * @param {Hyperdrive} drive
845
+ * @param {string} pathname
846
+ * @param {string} [isRanged]
847
+ * @returns
848
+ */
681
849
  async function serveFile (drive, pathname, isRanged) {
682
850
  const contentType = getMimeType(pathname)
683
851
 
@@ -685,6 +853,7 @@ async function serveFile (drive, pathname, isRanged) {
685
853
 
686
854
  const entry = await drive.entry(pathname)
687
855
 
856
+ /** @type {{[key: string]: string}} */
688
857
  const resHeaders = {
689
858
  ETag: `${entry.seq + 1}`,
690
859
  [HEADER_CONTENT_TYPE]: contentType,
@@ -700,8 +869,9 @@ async function serveFile (drive, pathname, isRanged) {
700
869
  const size = entry.value.blob.byteLength
701
870
  if (isRanged) {
702
871
  const ranges = parseRange(size, isRanged)
872
+ const isRangeValid = ranges !== -1 && ranges !== -2 && ranges
703
873
 
704
- if (ranges && ranges.length && ranges.type === 'bytes') {
874
+ if (isRangeValid && ranges.length && ranges.type === 'bytes') {
705
875
  const [{ start, end }] = ranges
706
876
  const length = (end - start + 1)
707
877
 
@@ -729,6 +899,10 @@ async function serveFile (drive, pathname, isRanged) {
729
899
  }
730
900
  }
731
901
 
902
+ /**
903
+ * @param {string} pathname
904
+ * @returns {string[]}
905
+ */
732
906
  function makeToTry (pathname) {
733
907
  return [
734
908
  pathname,
@@ -737,6 +911,12 @@ function makeToTry (pathname) {
737
911
  ]
738
912
  }
739
913
 
914
+ /**
915
+ * @param {Hyperdrive} drive
916
+ * @param {string} pathname
917
+ * @param {boolean} noResolve
918
+ * @returns
919
+ */
740
920
  async function resolvePath (drive, pathname, noResolve) {
741
921
  if (noResolve) {
742
922
  const entry = await drive.entry(pathname)
@@ -754,6 +934,12 @@ async function resolvePath (drive, pathname, noResolve) {
754
934
  return { entry: null, path: null }
755
935
  }
756
936
 
937
+ /**
938
+ *
939
+ * @param {Hyperdrive} drive
940
+ * @param {string} [pathname]
941
+ * @returns
942
+ */
757
943
  async function listEntries (drive, pathname = '/') {
758
944
  const entries = []
759
945
  for await (const path of drive.readdir(pathname)) {
@@ -768,9 +954,15 @@ async function listEntries (drive, pathname = '/') {
768
954
  return entries
769
955
  }
770
956
 
957
+ /**
958
+ *
959
+ * @param {import('hypercore').Peer[]} peers
960
+ * @returns
961
+ */
771
962
  function formatPeers (peers) {
772
963
  return peers.map((peer) => {
773
964
  const remotePublicKey = peer.remotePublicKey.toString('hex')
965
+ // @ts-ignore
774
966
  const remoteHost = peer.stream?.rawStream?.remoteHost
775
967
  return {
776
968
  remotePublicKey,
@@ -779,12 +971,22 @@ function formatPeers (peers) {
779
971
  })
780
972
  }
781
973
 
974
+ /**
975
+ *
976
+ * @param {string} path
977
+ * @returns {string}
978
+ */
782
979
  function getMimeType (path) {
783
980
  let mimeType = mime.getType(path) || 'text/plain; charset=utf-8'
784
981
  if (mimeType.startsWith('text/')) mimeType = `${mimeType}; charset=utf-8`
785
982
  return mimeType
786
983
  }
787
984
 
985
+ /**
986
+ *
987
+ * @param {string} path
988
+ * @returns {string}
989
+ */
788
990
  function ensureLeadingSlash (path) {
789
991
  return path.startsWith('/') ? path : '/' + path
790
992
  }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "hypercore-fetch",
3
- "version": "10.0.0",
3
+ "version": "10.1.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",
7
+ "types": "./dist/index.d.ts",
7
8
  "scripts": {
8
9
  "test": "node test",
9
- "lint": "standard --fix"
10
+ "lint": "standard --fix && tsc --noEmit",
11
+ "build": "tsc",
12
+ "preversion": "npm run lint && npm run test"
10
13
  },
11
14
  "repository": {
12
15
  "type": "git",
@@ -14,6 +17,7 @@
14
17
  },
15
18
  "keywords": [
16
19
  "dat",
20
+ "hypercore",
17
21
  "fetch"
18
22
  ],
19
23
  "author": "RangerMauve",
@@ -24,15 +28,21 @@
24
28
  "homepage": "https://github.com/RangerMauve/hypercore-fetch#readme",
25
29
  "dependencies": {
26
30
  "event-iterator": "^2.0.0",
27
- "make-fetch": "^3.1.1",
31
+ "make-fetch": "^3.2.3",
28
32
  "mime": "^3.0.0",
29
33
  "range-parser": "^1.2.1",
30
34
  "streamx": "^2.13.0"
31
35
  },
32
36
  "devDependencies": {
33
- "@rangermauve/fetch-event-source": "^1.0.3",
34
- "hyper-sdk": "^6.0.0",
37
+ "@rangermauve/fetch-event-source": "^2.0.1",
38
+ "@tsconfig/node20": "^20.1.8",
39
+ "@types/mime": "^3.0.4",
40
+ "@types/range-parser": "^1.2.7",
41
+ "@types/streamx": "^2.9.5",
42
+ "@types/tape": "^5.8.1",
43
+ "hyper-sdk": "^6.2.1",
35
44
  "standard": "^17.0.0",
36
- "tape": "^5.2.2"
45
+ "tape": "^5.2.2",
46
+ "typescript": "^5.9.3"
37
47
  }
38
48
  }
package/test.js CHANGED
@@ -9,15 +9,28 @@ import { rm } from 'fs/promises'
9
9
 
10
10
  import makeHyperFetch from './index.js'
11
11
 
12
+ /** @import {OnLoadHandler, OnDeleteHandler} from './index.js' */
13
+
12
14
  const SAMPLE_CONTENT = 'Hello World'
13
15
  const DNS_DOMAIN = 'blog.mauve.moe'
14
16
  let count = 0
17
+
18
+ /** @type {OnLoadHandler | null} */
19
+ let onLoad = null
20
+ /** @type {OnDeleteHandler | null} */
21
+ let onDelete = null
22
+
15
23
  function next () {
16
24
  return count++
17
25
  }
18
26
 
19
- async function nextURL (t) {
20
- const createResponse = await fetch(`hyper://localhost/?key=example${next()}`, {
27
+ /**
28
+ * @param {import('tape').Test} t
29
+ * @param {string} [name]
30
+ * @returns {Promise<string>}
31
+ */
32
+ async function nextURL (t, name = `example${next()}`) {
33
+ const createResponse = await fetch(`hyper://localhost/?key=${name}`, {
21
34
  method: 'post'
22
35
  })
23
36
  await checkResponse(createResponse, t, 'Created new drive')
@@ -34,7 +47,9 @@ const sdk2 = await SDK.create({ storage: join(tmp, 'sdk2') })
34
47
 
35
48
  const fetch = await makeHyperFetch({
36
49
  sdk: sdk1,
37
- writable: true
50
+ writable: true,
51
+ onLoad: (...args) => onLoad && onLoad(...args),
52
+ onDelete: (...args) => onDelete && onDelete(...args)
38
53
  })
39
54
 
40
55
  const fetch2 = await makeHyperFetch({
@@ -82,7 +97,7 @@ test('Quick check', async (t) => {
82
97
 
83
98
  const content = await uploadedContentResponse.text()
84
99
  const contentType = uploadedContentResponse.headers.get('Content-Type')
85
- const contentLink = uploadedContentResponse.headers.get('Link')
100
+ const contentLink = uploadedContentResponse.headers.get('Link') ?? ''
86
101
 
87
102
  t.match(contentLink, /^<hyper:\/\/[0-9a-z]{52}\/example%20.txt>; rel="canonical"$/, 'Link header includes both public key and path.')
88
103
  t.equal(contentType, 'text/plain; charset=utf-8', 'Content got expected mime type')
@@ -127,7 +142,7 @@ test('HEAD request', async (t) => {
127
142
  const headersContentLength = headResponse.headers.get('Content-Length')
128
143
  const headersAcceptRanges = headResponse.headers.get('Accept-Ranges')
129
144
  const headersLastModified = headResponse.headers.get('Last-Modified')
130
- const headersLink = headResponse.headers.get('Link')
145
+ const headersLink = headResponse.headers.get('Link') ?? ''
131
146
 
132
147
  t.equal(headResponse.status, 204, 'Response had expected status')
133
148
  // Version at which the file was added
@@ -144,7 +159,7 @@ test('PUT file', async (t) => {
144
159
 
145
160
  const uploadLocation = new URL('./example.txt', created)
146
161
 
147
- const fakeDate = new Date(Date.parse(0)).toUTCString()
162
+ const fakeDate = new Date().toUTCString()
148
163
  const uploadResponse = await fetch(uploadLocation, {
149
164
  method: 'put',
150
165
  body: SAMPLE_CONTENT,
@@ -464,12 +479,12 @@ test('EventSource extension messages', async (t) => {
464
479
  t.deepEqual(extensionList, ['example'], 'Got expected list of extensions')
465
480
 
466
481
  const peerResponse1 = await fetch(extensionURL)
467
- const peerList1 = await peerResponse1.json()
482
+ const peerList1 = /** @type {object[]} */ (await peerResponse1.json())
468
483
 
469
484
  t.equal(peerList1.length, 1, 'Got one peer for extension message on peer1')
470
485
 
471
486
  const peerResponse2 = await fetch2(extensionURL)
472
- const peerList2 = await peerResponse2.json()
487
+ const peerList2 = /** @type {object[]} */ (await peerResponse2.json())
473
488
 
474
489
  t.equal(peerList2.length, 1, 'Got one peer for extension message on peer2')
475
490
 
@@ -502,11 +517,13 @@ test('EventSource extension messages', async (t) => {
502
517
  test('Resolve DNS', async (t) => {
503
518
  const loadResponse = await fetch(`hyper://${DNS_DOMAIN}/?noResolve`)
504
519
 
505
- const entries = await loadResponse.json()
520
+ const entries = /** @type {object[]} */ (await loadResponse.json())
506
521
 
507
522
  t.ok(entries.length, 'Loaded contents with some files present')
508
523
 
509
- const rawLink = loadResponse.headers.get('Link').match(/<(.+)>/)[1]
524
+ const linkHeader = loadResponse.headers.get('Link') || ''
525
+ // @ts-ignore
526
+ const rawLink = linkHeader.match(/<(.+)>/)[1]
510
527
  const loadRawURLResponse = await fetch(rawLink + '?noResolve')
511
528
 
512
529
  const rawLinkEntries = await loadRawURLResponse.json()
@@ -652,10 +669,59 @@ test('Check hyperdrive writability', async (t) => {
652
669
  t.equal(writableHeadersAllow, 'HEAD,GET,PUT,DELETE', 'Expected writable Allows header')
653
670
  })
654
671
 
672
+ test.only('onLoad and onDelete handlers', async (t) => {
673
+ /** @type {Parameters<OnLoadHandler> | Parameters<OnDeleteHandler> | null} */
674
+ let args = null
675
+
676
+ onLoad = (..._args) => {
677
+ args = _args
678
+ }
679
+ onDelete = (..._args) => {
680
+ args = _args
681
+ }
682
+
683
+ const created = await nextURL(t, 'example')
684
+
685
+ if (args === null) return t.fail('onLoad did not get called')
686
+ // @ts-ignore For some reason TS can't tell args gets set
687
+ t.equal(args[0].toString(), created, 'onLoad got created URL')
688
+ t.equal(args[1], true, 'onLoad knows created URL is writable')
689
+ t.equal(args[2], 'example', 'onLoad knows key for created URL')
690
+
691
+ args = null
692
+
693
+ const toFetch = `hyper://${DNS_DOMAIN}/`
694
+ const response = await fetch(toFetch + 'index.html')
695
+ await response.text()
696
+
697
+ if (args === null) return t.fail('onLoad did not get called')
698
+ // @ts-ignore For some reason TS can't tell args gets set
699
+ t.equal(args[0].toString(), toFetch, 'onLoad got loaded URL')
700
+ t.equal(args[1], false, 'onLoad knows loaded URL is not writable')
701
+ t.equal(args[2], undefined, 'onLoad lacks key for loaded domain')
702
+
703
+ args = null
704
+
705
+ await fetch(toFetch, {
706
+ method: 'DELETE'
707
+ })
708
+
709
+ if (args === null) return t.fail('onDelete did not get called')
710
+ // @ts-ignore For some reason TS can't tell args gets set
711
+ t.equal(args[0].toString(), toFetch, 'onDelete got URL of deleted drive')
712
+ })
713
+
714
+ /**
715
+ *
716
+ * @param {Response} response
717
+ * @param {import('tape').Test} t
718
+ * @param {string} [successMessage]
719
+ * @returns
720
+ */
655
721
  async function checkResponse (response, t, successMessage = 'Response OK') {
656
722
  if (!response.ok) {
657
723
  const message = await response.text()
658
- t.fail(new Error(`HTTP Error ${response.status}:\n${message}`))
724
+ t.fail(`HTTP Error ${response.status}:\n${message}`)
659
725
  return false
660
726
  } else {
661
727
  t.pass(successMessage)
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2022",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "checkJs": true,
7
+ "allowJs": true,
8
+ "declaration": true,
9
+ "emitDeclarationOnly": true,
10
+ "outDir": "dist",
11
+ },
12
+ "extends": "@tsconfig/node20/tsconfig.json",
13
+ "files": ["./index.js","./test.js"],
14
+ "include": [
15
+ "./index.js",
16
+ "./test.js",
17
+ "./node_modules/hyper-sdk/types/*.d.ts"
18
+ ]
19
+ }