hypercore-fetch 9.9.1 → 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,12 +1,19 @@
1
1
  import { posix } from 'path'
2
2
 
3
+ // @ts-ignore
3
4
  import { Readable, pipelinePromise } from 'streamx'
4
- import Hyperdrive from 'hyperdrive'
5
5
  import { makeRoutedFetch } from 'make-fetch'
6
6
  import mime from 'mime/index.js'
7
7
  import parseRange from 'range-parser'
8
8
  import { EventIterator } from 'event-iterator'
9
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
+
10
17
  const DEFAULT_TIMEOUT = 5000
11
18
 
12
19
  const SPECIAL_DOMAIN = 'localhost'
@@ -48,6 +55,7 @@ const INDEX_FILES = [
48
55
  'README.org'
49
56
  ]
50
57
 
58
+ /** @type {RenderIndexHandler} */
51
59
  async function DEFAULT_RENDER_INDEX (url, files, fetch) {
52
60
  return `
53
61
  <!DOCTYPE html>
@@ -66,12 +74,28 @@ mime.define({
66
74
  'text/gemini': ['gmi', 'gemini']
67
75
  }, true)
68
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
+ */
69
91
  export default async function makeHyperFetch ({
70
92
  sdk,
71
93
  writable = false,
72
94
  extensionMessages = writable,
73
95
  timeout = DEFAULT_TIMEOUT,
74
- renderIndex = DEFAULT_RENDER_INDEX
96
+ renderIndex = DEFAULT_RENDER_INDEX,
97
+ onLoad = noop,
98
+ onDelete = noop
75
99
  }) {
76
100
  const { fetch, router } = makeRoutedFetch({
77
101
  onError
@@ -79,9 +103,7 @@ export default async function makeHyperFetch ({
79
103
 
80
104
  // Map loaded drive hostnames to their keys
81
105
  // TODO: Track LRU + cache clearing
82
- const drives = new Map()
83
106
  const extensions = new Map()
84
- const cores = new Map()
85
107
 
86
108
  if (extensionMessages) {
87
109
  router.get(`hyper://*/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/`, listExtensions)
@@ -106,8 +128,14 @@ export default async function makeHyperFetch ({
106
128
  router.get('hyper://*/**', getFiles)
107
129
  router.head('hyper://*/**', headFiles)
108
130
 
109
- async function onError (e, request) {
131
+ /**
132
+ *
133
+ * @param {Error} e
134
+ * @returns {Promise<import('make-fetch').ResponseLike>}
135
+ */
136
+ async function onError (e) {
110
137
  return {
138
+ // @ts-ignore
111
139
  status: e.statusCode || 500,
112
140
  headers: {
113
141
  'Content-Type': 'text/plain; charset=utf-8'
@@ -116,95 +144,12 @@ export default async function makeHyperFetch ({
116
144
  }
117
145
  }
118
146
 
119
- async function getCore (hostname) {
120
- if (cores.has(hostname)) {
121
- return cores.get(hostname)
122
- }
123
- const core = await sdk.get(hostname)
124
- await core.ready()
125
- cores.set(core.id, core)
126
- cores.set(core.url, core)
127
- return core
128
- }
129
-
130
- async function getDBCoreForName (name) {
131
- const corestore = sdk.namespace(name)
132
- const dbCore = corestore.get({ name: 'db' })
133
- await dbCore.ready()
134
-
135
- if (!dbCore.discovery) {
136
- const discovery = sdk.join(dbCore.discoveryKey)
137
- dbCore.discovery = discovery
138
- dbCore.once('close', () => {
139
- discovery.destroy()
140
- })
141
- await discovery.flushed()
142
- }
143
-
144
- return dbCore
145
- }
146
-
147
- async function getDrive (hostname, errorOnNew = false) {
148
- if (drives.has(hostname)) {
149
- return drives.get(hostname)
150
- }
151
-
152
- const core = await getCore(hostname)
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
-
161
- const corestore = sdk.namespace(core.id)
162
- const drive = new Hyperdrive(corestore, core.key)
163
-
164
- await drive.ready()
165
-
166
- drive.once('close', () => {
167
- drives.delete(drive.core.id)
168
- drives.delete(hostname)
169
- })
170
-
171
- drives.set(drive.core.id, drive)
172
- drives.set(drive.core.url, drive)
173
- drives.set(hostname, drive)
174
-
175
- return drive
176
- }
177
-
178
- async function getDriveFromKey (key, errorOnNew = false) {
179
- if (drives.has(key)) {
180
- return drives.get(key)
181
- }
182
- const core = await getDBCoreForName(key)
183
-
184
- if (!core.length && errorOnNew) {
185
- const e = new Error(ERROR_KEY_NOT_CREATED)
186
- e.statusCode = 404
187
- throw e
188
- }
189
-
190
- const corestore = sdk.namespace(key)
191
- const drive = new Hyperdrive(corestore)
192
-
193
- await drive.ready()
194
-
195
- drive.once('close', () => {
196
- drives.delete(key)
197
- drives.delete(drive.url)
198
- drives.delete(drive.core.id)
199
- })
200
-
201
- drives.set(key, drive)
202
- drives.set(drive.url, drive)
203
- drives.set(drive.core.id, drive)
204
-
205
- return drive
206
- }
207
-
147
+ /**
148
+ *
149
+ * @param {Hypercore} core
150
+ * @param {string} name
151
+ * @returns
152
+ */
208
153
  async function getExtension (core, name) {
209
154
  const key = core.url + name
210
155
  if (extensions.has(key)) {
@@ -217,7 +162,7 @@ export default async function makeHyperFetch ({
217
162
  }
218
163
 
219
164
  const extension = core.registerExtension(name, {
220
- encoding: 'utf8',
165
+ encoding: 'utf-8',
221
166
  onmessage: (content, peer) => {
222
167
  core.emit(EXTENSION_EVENT, name, content, peer)
223
168
  }
@@ -228,6 +173,11 @@ export default async function makeHyperFetch ({
228
173
  return extension
229
174
  }
230
175
 
176
+ /**
177
+ * @param {Hypercore} core
178
+ * @param {string} name
179
+ * @returns
180
+ */
231
181
  async function getExtensionPeers (core, name) {
232
182
  // List peers with this extension
233
183
  const allPeers = core.peers
@@ -236,18 +186,32 @@ export default async function makeHyperFetch ({
236
186
  })
237
187
  }
238
188
 
189
+ /**
190
+ * @param {Hypercore} core
191
+ * @returns
192
+ */
239
193
  function listExtensionNames (core) {
240
194
  return [...core.extensions.keys()]
241
195
  }
242
196
 
197
+ /**
198
+ * @param {Request} request
199
+ * @returns
200
+ */
243
201
  async function listExtensions (request) {
244
202
  const { hostname } = new URL(request.url)
245
203
  const accept = request.headers.get('Accept') || ''
246
204
 
247
- const core = await getCore(`hyper://${hostname}/`)
205
+ const core = await sdk.get(`hyper://${hostname}/`)
248
206
 
249
207
  if (accept.includes('text/event-stream')) {
250
208
  const events = new EventIterator(({ push }) => {
209
+ /**
210
+ *
211
+ * @param {string} name
212
+ * @param {string} content
213
+ * @param {import('hypercore').Peer} peer
214
+ */
251
215
  function onMessage (name, content, peer) {
252
216
  const id = peer.remotePublicKey.toString('hex')
253
217
  // TODO: Fancy verification on the `name`?
@@ -256,10 +220,18 @@ export default async function makeHyperFetch ({
256
220
 
257
221
  push(`id:${id}\nevent:${name}\n${data}\n`)
258
222
  }
223
+
224
+ /**
225
+ * @param {import('hypercore').Peer} peer
226
+ */
259
227
  function onPeerOpen (peer) {
260
228
  const id = peer.remotePublicKey.toString('hex')
261
229
  push(`id:${id}\nevent:${PEER_OPEN}\n\n`)
262
230
  }
231
+
232
+ /**
233
+ * @param {import('hypercore').Peer} peer
234
+ */
263
235
  function onPeerRemove (peer) {
264
236
  // Whatever, probably an uninitialized peer
265
237
  if (!peer.remotePublicKey) return
@@ -293,12 +265,16 @@ export default async function makeHyperFetch ({
293
265
  }
294
266
  }
295
267
 
268
+ /**
269
+ * @param {Request} request
270
+ * @returns
271
+ */
296
272
  async function listenExtension (request) {
297
273
  const { hostname, pathname: rawPathname } = new URL(request.url)
298
274
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
299
275
  const name = pathname.slice(`/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/`.length)
300
276
 
301
- const core = await getCore(`hyper://${hostname}/`)
277
+ const core = await sdk.get(`hyper://${hostname}/`)
302
278
 
303
279
  await getExtension(core, name)
304
280
 
@@ -315,13 +291,17 @@ export default async function makeHyperFetch ({
315
291
  }
316
292
  }
317
293
 
294
+ /**
295
+ * @param {Request} request
296
+ * @returns
297
+ */
318
298
  async function broadcastExtension (request) {
319
299
  const { hostname, pathname: rawPathname } = new URL(request.url)
320
300
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
321
301
 
322
302
  const name = pathname.slice(`/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/`.length)
323
303
 
324
- const core = await getCore(`hyper://${hostname}/`)
304
+ const core = await sdk.get(`hyper://${hostname}/`)
325
305
 
326
306
  const extension = await getExtension(core, name)
327
307
  const data = await request.text()
@@ -330,6 +310,10 @@ export default async function makeHyperFetch ({
330
310
  return { status: 200 }
331
311
  }
332
312
 
313
+ /**
314
+ * @param {Request} request
315
+ * @returns
316
+ */
333
317
  async function extensionToPeer (request) {
334
318
  const { hostname, pathname: rawPathname } = new URL(request.url)
335
319
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
@@ -337,7 +321,7 @@ export default async function makeHyperFetch ({
337
321
  const subFolder = pathname.slice(`/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/`.length)
338
322
  const [name, extensionPeer] = subFolder.split('/')
339
323
 
340
- const core = await getCore(`hyper://${hostname}/`)
324
+ const core = await sdk.get(`hyper://${hostname}/`)
341
325
 
342
326
  const extension = await getExtension(core, name)
343
327
  const peers = await getExtensionPeers(core, name)
@@ -356,6 +340,10 @@ export default async function makeHyperFetch ({
356
340
  return { status: 200 }
357
341
  }
358
342
 
343
+ /**
344
+ * @param {Request} request
345
+ * @returns
346
+ */
359
347
  async function getKey (request) {
360
348
  const key = new URL(request.url).searchParams.get('key')
361
349
  if (!key) {
@@ -363,14 +351,16 @@ export default async function makeHyperFetch ({
363
351
  }
364
352
 
365
353
  try {
366
- const drive = await getDriveFromKey(key, true)
354
+ const drive = await sdk.getDrive(key)
355
+ onLoad(new URL('/', drive.url), drive.writable, key)
367
356
 
368
357
  return { body: drive.url }
369
358
  } catch (e) {
370
- if (e.message === ERROR_KEY_NOT_CREATED) {
359
+ const message = /** @type Error */(e).message
360
+ if (message === ERROR_KEY_NOT_CREATED) {
371
361
  return {
372
362
  status: 400,
373
- body: e.message,
363
+ body: message,
374
364
  headers: {
375
365
  [HEADER_CONTENT_TYPE]: MIME_TEXT_PLAIN
376
366
  }
@@ -379,6 +369,10 @@ export default async function makeHyperFetch ({
379
369
  }
380
370
  }
381
371
 
372
+ /**
373
+ * @param {Request} request
374
+ * @returns
375
+ */
382
376
  async function createKey (request) {
383
377
  // TODO: Allow importing secret keys here
384
378
  // Maybe specify a seed to use for generating the blobs?
@@ -389,29 +383,38 @@ export default async function makeHyperFetch ({
389
383
  return { status: 400, body: 'Must specify key parameter to resolve' }
390
384
  }
391
385
 
392
- const drive = await getDriveFromKey(key, false)
386
+ const drive = await sdk.getDrive(key)
387
+ onLoad(new URL('/', drive.url), drive.writable, key)
393
388
 
394
389
  return { body: drive.url }
395
390
  }
396
391
 
392
+ /**
393
+ * @param {Request} request
394
+ * @returns
395
+ */
397
396
  async function putFiles (request) {
398
397
  const { hostname, pathname: rawPathname } = new URL(request.url)
399
398
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
400
399
  const contentType = request.headers.get('Content-Type') || ''
401
- 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()
402
402
  const isFormData = contentType.includes('multipart/form-data')
403
403
 
404
- const drive = await getDrive(`hyper://${hostname}/`, true)
404
+ const drive = await sdk.getDrive(`hyper://${hostname}/`)
405
405
 
406
- if (!drive.db.feed.writable) {
406
+ if (!drive.writable) {
407
407
  return { status: 403, body: `Cannot PUT file to read-only drive: ${drive.url}`, headers: { Location: request.url } }
408
408
  }
409
409
 
410
+ onLoad(new URL('/', request.url), drive.writable)
411
+
410
412
  if (isFormData) {
411
413
  // It's a form! Get the files out and process them
412
414
  const formData = await request.formData()
413
415
  for (const [name, data] of formData) {
414
416
  if (name !== 'file') continue
417
+ if (typeof data === 'string') continue
415
418
  const filePath = posix.join(pathname, data.name)
416
419
  await pipelinePromise(
417
420
  Readable.from(data.stream()),
@@ -449,6 +452,10 @@ export default async function makeHyperFetch ({
449
452
  return { status: 201, headers }
450
453
  }
451
454
 
455
+ /**
456
+ * @param {Request} request
457
+ * @returns
458
+ */
452
459
  function putFilesVersioned (request) {
453
460
  return {
454
461
  status: 405,
@@ -457,25 +464,35 @@ export default async function makeHyperFetch ({
457
464
  }
458
465
  }
459
466
 
467
+ /**
468
+ * @param {Request} request
469
+ * @returns
470
+ */
460
471
  async function deleteDrive (request) {
461
472
  const { hostname } = new URL(request.url)
462
- const drive = await getDrive(`hyper://${hostname}/`, true)
473
+ const drive = await sdk.getDrive(`hyper://${hostname}/`)
474
+ onDelete(new URL('/', request.url))
463
475
 
464
476
  await drive.purge()
465
477
 
466
478
  return { status: 200 }
467
479
  }
468
480
 
481
+ /**
482
+ * @param {Request} request
483
+ * @returns
484
+ */
469
485
  async function deleteFiles (request) {
470
486
  const { hostname, pathname: rawPathname } = new URL(request.url)
471
487
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
472
488
 
473
- const drive = await getDrive(`hyper://${hostname}/`, true)
474
-
475
- if (!drive.db.feed.writable) {
489
+ const drive = await sdk.getDrive(`hyper://${hostname}/`)
490
+ if (!drive.writable) {
476
491
  return { status: 403, body: `Cannot DELETE file in read-only drive: ${drive.url}`, headers: { Location: request.url } }
477
492
  }
478
493
 
494
+ onLoad(new URL('/', request.url), drive.writable)
495
+
479
496
  if (pathname.endsWith('/')) {
480
497
  let didDelete = false
481
498
  for await (const entry of drive.list(pathname)) {
@@ -505,10 +522,18 @@ export default async function makeHyperFetch ({
505
522
  return { status: 200, headers }
506
523
  }
507
524
 
525
+ /**
526
+ * @param {Request} request
527
+ * @returns
528
+ */
508
529
  function deleteFilesVersioned (request) {
509
530
  return { status: 405, body: 'Cannot DELETE old version', headers: { Location: request.url } }
510
531
  }
511
532
 
533
+ /**
534
+ * @param {Request} request
535
+ * @returns
536
+ */
512
537
  async function headFilesVersioned (request) {
513
538
  const url = new URL(request.url)
514
539
  const { hostname, pathname: rawPathname, searchParams } = url
@@ -519,16 +544,27 @@ export default async function makeHyperFetch ({
519
544
  const noResolve = searchParams.has('noResolve')
520
545
 
521
546
  const parts = pathname.split('/')
522
- const version = parts[3]
547
+ const version = parseInt(parts[3], 10)
523
548
  const realPath = ensureLeadingSlash(parts.slice(4).join('/'))
524
549
 
525
- const drive = await getDrive(`hyper://${hostname}/`, true)
550
+ const drive = await sdk.getDrive(`hyper://${hostname}/`)
551
+
552
+ if (!drive.writable && !drive.core.length) {
553
+ return {
554
+ status: 404,
555
+ body: 'Peers Not Found'
556
+ }
557
+ }
526
558
 
527
559
  const snapshot = await drive.checkout(version)
528
560
 
529
561
  return serveHead(snapshot, realPath, { accept, isRanged, noResolve })
530
562
  }
531
563
 
564
+ /**
565
+ * @param {Request} request
566
+ * @returns
567
+ */
532
568
  async function headFiles (request) {
533
569
  const url = new URL(request.url)
534
570
  const { hostname, pathname: rawPathname, searchParams } = url
@@ -538,24 +574,42 @@ export default async function makeHyperFetch ({
538
574
  const isRanged = request.headers.get('Range') || ''
539
575
  const noResolve = searchParams.has('noResolve')
540
576
 
541
- const drive = await getDrive(`hyper://${hostname}/`, true)
577
+ const drive = await sdk.getDrive(`hyper://${hostname}/`)
578
+
579
+ if (!drive.writable && !drive.core.length) {
580
+ return {
581
+ status: 404,
582
+ body: 'Peers Not Found'
583
+ }
584
+ }
542
585
 
543
586
  return serveHead(drive, pathname, { accept, isRanged, noResolve })
544
587
  }
545
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
+ */
546
599
  async function serveHead (drive, pathname, { accept, isRanged, noResolve }) {
547
600
  const isDirectory = pathname.endsWith('/')
548
601
  const fullURL = new URL(pathname, drive.url).href
549
602
 
550
- const isWritable = writable && drive.db.feed.writable
603
+ const isWritable = writable && drive.writable
551
604
 
552
605
  const Allow = isWritable ? BASIC_METHODS.concat(WRITABLE_METHODS) : BASIC_METHODS
553
606
 
607
+ /** @type {{[key: string]: string}} */
554
608
  const resHeaders = {
555
609
  ETag: `${drive.version}`,
556
610
  'Accept-Ranges': 'bytes',
557
611
  Link: `<${fullURL}>; rel="canonical"`,
558
- Allow
612
+ Allow: Allow.toString()
559
613
  }
560
614
 
561
615
  if (isDirectory) {
@@ -627,8 +681,9 @@ export default async function makeHyperFetch ({
627
681
  const size = entry.value.blob.byteLength
628
682
  if (isRanged) {
629
683
  const ranges = parseRange(size, isRanged)
684
+ const isRangeValid = ranges !== -1 && ranges !== -2 && ranges
630
685
 
631
- if (ranges && ranges.length && ranges.type === 'bytes') {
686
+ if (isRangeValid && ranges.length && ranges.type === 'bytes') {
632
687
  const [{ start, end }] = ranges
633
688
  const length = (end - start + 1)
634
689
 
@@ -649,6 +704,10 @@ export default async function makeHyperFetch ({
649
704
  }
650
705
  }
651
706
 
707
+ /**
708
+ * @param {Request} request
709
+ * @returns
710
+ */
652
711
  async function getFilesVersioned (request) {
653
712
  const url = new URL(request.url)
654
713
  const { hostname, pathname: rawPathname, searchParams } = url
@@ -659,18 +718,31 @@ export default async function makeHyperFetch ({
659
718
  const noResolve = searchParams.has('noResolve')
660
719
 
661
720
  const parts = pathname.split('/')
662
- const version = parts[3]
721
+ const version = parseInt(parts[3], 10)
663
722
  const realPath = ensureLeadingSlash(parts.slice(4).join('/'))
664
723
 
665
- const drive = await getDrive(`hyper://${hostname}/`, true)
724
+ const drive = await sdk.getDrive(`hyper://${hostname}/`)
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)
666
734
 
667
735
  const snapshot = await drive.checkout(version)
668
736
 
669
737
  return serveGet(snapshot, realPath, { accept, isRanged, noResolve })
670
738
  }
671
739
 
672
- // TODO: Redirect on directories without trailing slash
740
+ /**
741
+ * @param {Request} request
742
+ * @returns
743
+ */
673
744
  async function getFiles (request) {
745
+ // TODO: Redirect on directories without trailing slash
674
746
  const url = new URL(request.url)
675
747
  const { hostname, pathname: rawPathname, searchParams } = url
676
748
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
@@ -679,11 +751,29 @@ export default async function makeHyperFetch ({
679
751
  const isRanged = request.headers.get('Range') || ''
680
752
  const noResolve = searchParams.has('noResolve')
681
753
 
682
- const drive = await getDrive(`hyper://${hostname}/`, true)
754
+ const drive = await sdk.getDrive(`hyper://${hostname}/`)
755
+
756
+ if (!drive.writable && !drive.core.length) {
757
+ return {
758
+ status: 404,
759
+ body: 'Peers Not Found'
760
+ }
761
+ }
762
+
763
+ onLoad(new URL('/', request.url), drive.writable)
683
764
 
684
765
  return serveGet(drive, pathname, { accept, isRanged, noResolve })
685
766
  }
686
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
+ */
687
777
  async function serveGet (drive, pathname, { accept, isRanged, noResolve }) {
688
778
  const isDirectory = pathname.endsWith('/')
689
779
  const fullURL = new URL(pathname, drive.url).href
@@ -749,6 +839,13 @@ export default async function makeHyperFetch ({
749
839
  return fetch
750
840
  }
751
841
 
842
+ /**
843
+ *
844
+ * @param {Hyperdrive} drive
845
+ * @param {string} pathname
846
+ * @param {string} [isRanged]
847
+ * @returns
848
+ */
752
849
  async function serveFile (drive, pathname, isRanged) {
753
850
  const contentType = getMimeType(pathname)
754
851
 
@@ -756,6 +853,7 @@ async function serveFile (drive, pathname, isRanged) {
756
853
 
757
854
  const entry = await drive.entry(pathname)
758
855
 
856
+ /** @type {{[key: string]: string}} */
759
857
  const resHeaders = {
760
858
  ETag: `${entry.seq + 1}`,
761
859
  [HEADER_CONTENT_TYPE]: contentType,
@@ -771,8 +869,9 @@ async function serveFile (drive, pathname, isRanged) {
771
869
  const size = entry.value.blob.byteLength
772
870
  if (isRanged) {
773
871
  const ranges = parseRange(size, isRanged)
872
+ const isRangeValid = ranges !== -1 && ranges !== -2 && ranges
774
873
 
775
- if (ranges && ranges.length && ranges.type === 'bytes') {
874
+ if (isRangeValid && ranges.length && ranges.type === 'bytes') {
776
875
  const [{ start, end }] = ranges
777
876
  const length = (end - start + 1)
778
877
 
@@ -800,6 +899,10 @@ async function serveFile (drive, pathname, isRanged) {
800
899
  }
801
900
  }
802
901
 
902
+ /**
903
+ * @param {string} pathname
904
+ * @returns {string[]}
905
+ */
803
906
  function makeToTry (pathname) {
804
907
  return [
805
908
  pathname,
@@ -808,6 +911,12 @@ function makeToTry (pathname) {
808
911
  ]
809
912
  }
810
913
 
914
+ /**
915
+ * @param {Hyperdrive} drive
916
+ * @param {string} pathname
917
+ * @param {boolean} noResolve
918
+ * @returns
919
+ */
811
920
  async function resolvePath (drive, pathname, noResolve) {
812
921
  if (noResolve) {
813
922
  const entry = await drive.entry(pathname)
@@ -825,6 +934,12 @@ async function resolvePath (drive, pathname, noResolve) {
825
934
  return { entry: null, path: null }
826
935
  }
827
936
 
937
+ /**
938
+ *
939
+ * @param {Hyperdrive} drive
940
+ * @param {string} [pathname]
941
+ * @returns
942
+ */
828
943
  async function listEntries (drive, pathname = '/') {
829
944
  const entries = []
830
945
  for await (const path of drive.readdir(pathname)) {
@@ -839,9 +954,15 @@ async function listEntries (drive, pathname = '/') {
839
954
  return entries
840
955
  }
841
956
 
957
+ /**
958
+ *
959
+ * @param {import('hypercore').Peer[]} peers
960
+ * @returns
961
+ */
842
962
  function formatPeers (peers) {
843
963
  return peers.map((peer) => {
844
964
  const remotePublicKey = peer.remotePublicKey.toString('hex')
965
+ // @ts-ignore
845
966
  const remoteHost = peer.stream?.rawStream?.remoteHost
846
967
  return {
847
968
  remotePublicKey,
@@ -850,12 +971,22 @@ function formatPeers (peers) {
850
971
  })
851
972
  }
852
973
 
974
+ /**
975
+ *
976
+ * @param {string} path
977
+ * @returns {string}
978
+ */
853
979
  function getMimeType (path) {
854
980
  let mimeType = mime.getType(path) || 'text/plain; charset=utf-8'
855
981
  if (mimeType.startsWith('text/')) mimeType = `${mimeType}; charset=utf-8`
856
982
  return mimeType
857
983
  }
858
984
 
985
+ /**
986
+ *
987
+ * @param {string} path
988
+ * @returns {string}
989
+ */
859
990
  function ensureLeadingSlash (path) {
860
991
  return path.startsWith('/') ? path : '/' + path
861
992
  }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "hypercore-fetch",
3
- "version": "9.9.1",
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,16 +28,21 @@
24
28
  "homepage": "https://github.com/RangerMauve/hypercore-fetch#readme",
25
29
  "dependencies": {
26
30
  "event-iterator": "^2.0.0",
27
- "hyperdrive": "^11.8.1",
28
- "make-fetch": "^3.1.1",
31
+ "make-fetch": "^3.2.3",
29
32
  "mime": "^3.0.0",
30
33
  "range-parser": "^1.2.1",
31
34
  "streamx": "^2.13.0"
32
35
  },
33
36
  "devDependencies": {
34
- "@rangermauve/fetch-event-source": "^1.0.3",
35
- "hyper-sdk": "^4.4.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",
36
44
  "standard": "^17.0.0",
37
- "tape": "^5.2.2"
45
+ "tape": "^5.2.2",
46
+ "typescript": "^5.9.3"
38
47
  }
39
48
  }
package/test.js CHANGED
@@ -3,18 +3,34 @@ import * as SDK from 'hyper-sdk'
3
3
  import test from 'tape'
4
4
  import createEventSource from '@rangermauve/fetch-event-source'
5
5
  import { once } from 'events'
6
+ import os from 'os'
7
+ import { join } from 'path'
8
+ import { rm } from 'fs/promises'
6
9
 
7
10
  import makeHyperFetch from './index.js'
8
11
 
12
+ /** @import {OnLoadHandler, OnDeleteHandler} from './index.js' */
13
+
9
14
  const SAMPLE_CONTENT = 'Hello World'
10
15
  const DNS_DOMAIN = 'blog.mauve.moe'
11
16
  let count = 0
17
+
18
+ /** @type {OnLoadHandler | null} */
19
+ let onLoad = null
20
+ /** @type {OnDeleteHandler | null} */
21
+ let onDelete = null
22
+
12
23
  function next () {
13
24
  return count++
14
25
  }
15
26
 
16
- async function nextURL (t) {
17
- 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}`, {
18
34
  method: 'post'
19
35
  })
20
36
  await checkResponse(createResponse, t, 'Created new drive')
@@ -23,12 +39,17 @@ async function nextURL (t) {
23
39
  return created
24
40
  }
25
41
 
26
- const sdk1 = await SDK.create({ storage: false })
27
- const sdk2 = await SDK.create({ storage: false })
42
+ const tmpSuffix = Math.random().toString().slice(3, 8)
43
+ const tmp = join(os.tmpdir(), `hp-ftch-${tmpSuffix}`)
44
+
45
+ const sdk1 = await SDK.create({ storage: join(tmp, 'sdk1') })
46
+ const sdk2 = await SDK.create({ storage: join(tmp, 'sdk2') })
28
47
 
29
48
  const fetch = await makeHyperFetch({
30
49
  sdk: sdk1,
31
- writable: true
50
+ writable: true,
51
+ onLoad: (...args) => onLoad && onLoad(...args),
52
+ onDelete: (...args) => onDelete && onDelete(...args)
32
53
  })
33
54
 
34
55
  const fetch2 = await makeHyperFetch({
@@ -36,9 +57,12 @@ const fetch2 = await makeHyperFetch({
36
57
  writable: true
37
58
  })
38
59
 
39
- test.onFinish(() => {
40
- sdk1.close()
41
- sdk2.close()
60
+ test.onFinish(async () => {
61
+ await Promise.all([
62
+ sdk1.close(),
63
+ sdk2.close()
64
+ ])
65
+ await rm(tmp, { recursive: true })
42
66
  })
43
67
 
44
68
  test('Quick check', async (t) => {
@@ -73,7 +97,7 @@ test('Quick check', async (t) => {
73
97
 
74
98
  const content = await uploadedContentResponse.text()
75
99
  const contentType = uploadedContentResponse.headers.get('Content-Type')
76
- const contentLink = uploadedContentResponse.headers.get('Link')
100
+ const contentLink = uploadedContentResponse.headers.get('Link') ?? ''
77
101
 
78
102
  t.match(contentLink, /^<hyper:\/\/[0-9a-z]{52}\/example%20.txt>; rel="canonical"$/, 'Link header includes both public key and path.')
79
103
  t.equal(contentType, 'text/plain; charset=utf-8', 'Content got expected mime type')
@@ -89,14 +113,6 @@ test('Quick check', async (t) => {
89
113
  test('GET full url for created keys', async (t) => {
90
114
  const keyURL = `hyper://localhost/?key=example${next()}`
91
115
 
92
- const nonExistingResponse = await fetch(keyURL)
93
-
94
- t.notOk(nonExistingResponse.ok, 'response has error before key is created')
95
- const errorMessage = await nonExistingResponse.text()
96
-
97
- t.equal(nonExistingResponse.status, 400, 'Got 400 error code')
98
- t.notOk(errorMessage.startsWith('hyper://'), 'did not return hyper URL')
99
-
100
116
  const createResponse = await fetch(keyURL, { method: 'post' })
101
117
  await checkResponse(createResponse, t, 'Able to create drive')
102
118
 
@@ -126,7 +142,7 @@ test('HEAD request', async (t) => {
126
142
  const headersContentLength = headResponse.headers.get('Content-Length')
127
143
  const headersAcceptRanges = headResponse.headers.get('Accept-Ranges')
128
144
  const headersLastModified = headResponse.headers.get('Last-Modified')
129
- const headersLink = headResponse.headers.get('Link')
145
+ const headersLink = headResponse.headers.get('Link') ?? ''
130
146
 
131
147
  t.equal(headResponse.status, 204, 'Response had expected status')
132
148
  // Version at which the file was added
@@ -143,7 +159,7 @@ test('PUT file', async (t) => {
143
159
 
144
160
  const uploadLocation = new URL('./example.txt', created)
145
161
 
146
- const fakeDate = new Date(Date.parse(0)).toUTCString()
162
+ const fakeDate = new Date().toUTCString()
147
163
  const uploadResponse = await fetch(uploadLocation, {
148
164
  method: 'put',
149
165
  body: SAMPLE_CONTENT,
@@ -302,7 +318,7 @@ test('DELETE a directory', async (t) => {
302
318
  const entries = await listDirRequest.json()
303
319
  t.deepEqual(entries, [], 'subfolder got deleted')
304
320
  })
305
- test('DELETE a drive from storage', async (t) => {
321
+ test.skip('DELETE a drive from storage', async (t) => {
306
322
  const created = await nextURL(t)
307
323
 
308
324
  const uploadLocation = new URL('./subfolder/example.txt', created)
@@ -463,12 +479,12 @@ test('EventSource extension messages', async (t) => {
463
479
  t.deepEqual(extensionList, ['example'], 'Got expected list of extensions')
464
480
 
465
481
  const peerResponse1 = await fetch(extensionURL)
466
- const peerList1 = await peerResponse1.json()
482
+ const peerList1 = /** @type {object[]} */ (await peerResponse1.json())
467
483
 
468
484
  t.equal(peerList1.length, 1, 'Got one peer for extension message on peer1')
469
485
 
470
486
  const peerResponse2 = await fetch2(extensionURL)
471
- const peerList2 = await peerResponse2.json()
487
+ const peerList2 = /** @type {object[]} */ (await peerResponse2.json())
472
488
 
473
489
  t.equal(peerList2.length, 1, 'Got one peer for extension message on peer2')
474
490
 
@@ -501,11 +517,13 @@ test('EventSource extension messages', async (t) => {
501
517
  test('Resolve DNS', async (t) => {
502
518
  const loadResponse = await fetch(`hyper://${DNS_DOMAIN}/?noResolve`)
503
519
 
504
- const entries = await loadResponse.json()
520
+ const entries = /** @type {object[]} */ (await loadResponse.json())
505
521
 
506
522
  t.ok(entries.length, 'Loaded contents with some files present')
507
523
 
508
- const rawLink = loadResponse.headers.get('Link').match(/<(.+)>/)[1]
524
+ const linkHeader = loadResponse.headers.get('Link') || ''
525
+ // @ts-ignore
526
+ const rawLink = linkHeader.match(/<(.+)>/)[1]
509
527
  const loadRawURLResponse = await fetch(rawLink + '?noResolve')
510
528
 
511
529
  const rawLinkEntries = await loadRawURLResponse.json()
@@ -518,7 +536,7 @@ test('Doing a `GET` on an invalid domain/public key should cause an error', asyn
518
536
 
519
537
  const invalidPublicKeyResponse = await fetch('hyper://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/')
520
538
  t.notOk(invalidPublicKeyResponse.ok, 'Response errored out due to invalid public key')
521
- t.equal(invalidPublicKeyResponse.status, 404, 'Invalid public key should 404')
539
+ t.equal(invalidPublicKeyResponse.status, 404, 'Invalid public key should error')
522
540
  })
523
541
 
524
542
  test('Old versions in VERSION folder', async (t) => {
@@ -615,9 +633,8 @@ test('Handle empty string pathname', async (t) => {
615
633
  await checkResponse(versionedGetResponse, t)
616
634
  t.deepEqual(await versionedGetResponse.json(), ['example.txt', 'example2.txt'], 'Returns root directory prior to DELETE')
617
635
 
618
-
619
636
  // DELETE
620
- await checkResponse(await fetch(urlNoTrailingSlash, { method: 'DELETE' }), t, 'Able to delete root')
637
+ // await checkResponse(await fetch(urlNoTrailingSlash, { method: 'DELETE' }), t, 'Able to delete root')
621
638
  })
622
639
 
623
640
  test('Return status 403 Forbidden on attempt to modify read-only hyperdrive', async (t) => {
@@ -646,17 +663,65 @@ test('Check hyperdrive writability', async (t) => {
646
663
  const readOnlyHeadersAllow = readOnlyHeadResponse.headers.get('Allow')
647
664
  t.equal(readOnlyHeadersAllow, 'HEAD,GET', 'Expected read-only Allows header')
648
665
 
649
- const writableRootDirectory = new URL('/', created)
650
- const writableHeadResponse = await fetch(writableRootDirectory, { method: 'HEAD' })
666
+ const writableHeadResponse = await fetch(created, { method: 'HEAD' })
651
667
  await checkResponse(writableHeadResponse, t, 'Able to load HEAD')
652
668
  const writableHeadersAllow = writableHeadResponse.headers.get('Allow')
653
669
  t.equal(writableHeadersAllow, 'HEAD,GET,PUT,DELETE', 'Expected writable Allows header')
654
670
  })
655
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
+ */
656
721
  async function checkResponse (response, t, successMessage = 'Response OK') {
657
722
  if (!response.ok) {
658
723
  const message = await response.text()
659
- t.fail(new Error(`HTTP Error ${response.status}:\n${message}`))
724
+ t.fail(`HTTP Error ${response.status}:\n${message}`)
660
725
  return false
661
726
  } else {
662
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
+ }