hypercore-fetch 10.0.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,23 +11,35 @@ import * as SDK from 'hyper-sdk'
11
11
  // Create in-memory hyper-sdk instance
12
12
  const sdk = await SDK.create({storage: false})
13
13
 
14
+ // Init
14
15
  const fetch = await makeFetch({
15
16
  sdk: true,
16
17
  writable: true
17
18
  })
18
19
 
19
- const someURL = `hyper://TODO_REAL_URL_HERE_PLEASE`
20
+ // Download
21
+ const someURL = `hyper://blog.mauve.moe/`
20
22
 
21
23
  const response = await fetch(someURL)
22
24
 
23
25
  const data = await response.text()
24
26
 
25
27
  console.log(data)
28
+
29
+ const response = await fetch('hyper://localhost/example.txt', {
30
+ method: 'PUT',
31
+ body: 'Hello World'
32
+ })
33
+
34
+ // This is where the data got uploaded
35
+ const location = response.headers.get('Location')
36
+ // Clear the response body or else electron will flip out
37
+ await response.text()
26
38
  ```
27
39
 
28
40
  ## API
29
41
 
30
- ### `makeHyperFetch({sdk, writable=false, extensionMessages = writable, renderIndex}) => fetch()`
42
+ ### `makeHyperFetch({sdk, writable=false, extensionMessages = writable, renderIndex, onLoad, onDelete}) => fetch()`
31
43
 
32
44
  Creates a hypercore-fetch instance.
33
45
 
@@ -40,6 +52,10 @@ The `writable` flag toggles whether the `PUT`/`POST`/`DELETE` methods are availa
40
52
  `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
53
  This function takes the `url`, `files` array and `fetch` instance as arguments.
42
54
 
55
+ `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).
56
+
57
+ `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.
58
+
43
59
  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
60
 
45
61
  ### Common Headers
@@ -100,6 +116,8 @@ In order to create a writable Hyperdrive with its own URL, you must first genera
100
116
 
101
117
  `NAME` can be any alphanumeric string which can be used for key generation in [Corestore](https://github.com/holepunchto/corestore).
102
118
 
119
+ If you omit the `NAME`, it will use the name `default`.
120
+
103
121
  The response body will contain a `hyper://` URL with the new Hyperdrive.
104
122
 
105
123
  You can then use this with `PUT`/`DELETE` requests.
@@ -112,6 +130,8 @@ If you want to resolve the public key URL of a previously created Hyperdrive, yo
112
130
 
113
131
  `NAME` can be any alphanumeric string which can be used for key generation in [Corestore](https://github.com/holepunchto/corestore).
114
132
 
133
+ If you omit the `NAME`, it will use the name `default`.
134
+
115
135
  The response body will contain a `hyper://` URL with the new Hyperdrive.
116
136
 
117
137
  You can then use this with `PUT`/`DELETE` requests.
@@ -126,7 +146,7 @@ flag.
126
146
 
127
147
  The `body` can be any of the options supported by the Fetch API such as a `String`, `Blob`, `FormData`, or `ReadableStream`.
128
148
 
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.
149
+ `NAME` can either be the 52 character [z32 encoded](https://github.com/mafintosh/z32) key for a Hyperdrive, `localhost` for the `default` drive, or a domain to parse with the [DNSLink](https://www.dnslink.io/) standard.
130
150
 
131
151
  The mtime metadata is automatically set to the current time when
132
152
  uploading. To override this value, pass a `Last-Modified` header with a value
@@ -145,7 +165,7 @@ The `filename` will be the filename within the directory that gets created.
145
165
 
146
166
  Note that you must use the name `file` for uploaded files.
147
167
 
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.
168
+ `NAME` can either be the 52 character [z32 encoded](https://github.com/mafintosh/z32) key for a Hyperdrive, `localhost` for the `default` drive, or a domain to parse with the [DNSLink](https://www.dnslink.io/) standard.
149
169
 
150
170
  ### `fetch('hyper://NAME/', {method: 'DELETE'})`
151
171
 
@@ -155,13 +175,13 @@ If this is a writable drive, your data will get fully clearned and trying to wri
155
175
 
156
176
  If you try to load this drive again data will be loaded from scratch.
157
177
 
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.
178
+ `NAME` can either be the 52 character [z32 encoded](https://github.com/mafintosh/z32) key for a Hyperdrive, `localhost` for the `default` drive, or a domain to parse with the [DNSLink](https://www.dnslink.io/) standard.
159
179
 
160
180
  ### `fetch('hyper://NAME/example.txt', {method: 'DELETE'})`
161
181
 
162
182
  You can delete a file or directory tree in a Hyperdrive by using the `DELETE` method.
163
183
 
164
- `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.
184
+ `NAME` can either be the 52 character [z32 encoded](https://github.com/mafintosh/z32) key for a Hyperdrive, `localhost` for the `default` drive, or a domain to parse with the [DNSLink](https://www.dnslink.io/) standard.
165
185
 
166
186
  Note that this is only available with the `writable: true` flag.
167
187
 
@@ -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,21 @@
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
18
+ const DEFAULT_DRIVE = 'default'
10
19
 
11
20
  const SPECIAL_DOMAIN = 'localhost'
12
21
  const SPECIAL_FOLDER = '$'
@@ -47,6 +56,7 @@ const INDEX_FILES = [
47
56
  'README.org'
48
57
  ]
49
58
 
59
+ /** @type {RenderIndexHandler} */
50
60
  async function DEFAULT_RENDER_INDEX (url, files, fetch) {
51
61
  return `
52
62
  <!DOCTYPE html>
@@ -65,12 +75,30 @@ mime.define({
65
75
  'text/gemini': ['gmi', 'gemini']
66
76
  }, true)
67
77
 
78
+ function noop () {}
79
+
80
+ /**
81
+ *
82
+ * @param {object} options
83
+ * @param {import('hyper-sdk').SDK} options.sdk
84
+ * @param {boolean} [options.writable]
85
+ * @param {boolean} [options.extensionMessages]
86
+ * @param {number} [options.timeout]
87
+ * @param {string} [options.defaultDrive]
88
+ * @param {typeof DEFAULT_RENDER_INDEX} [options.renderIndex]
89
+ * @param {OnLoadHandler} [options.onLoad]
90
+ * @param {OnDeleteHandler} [options.onDelete]
91
+ * @returns {Promise<typeof globalThis.fetch>}
92
+ */
68
93
  export default async function makeHyperFetch ({
69
94
  sdk,
70
95
  writable = false,
71
96
  extensionMessages = writable,
72
97
  timeout = DEFAULT_TIMEOUT,
73
- renderIndex = DEFAULT_RENDER_INDEX
98
+ defaultDrive = DEFAULT_DRIVE,
99
+ renderIndex = DEFAULT_RENDER_INDEX,
100
+ onLoad = noop,
101
+ onDelete = noop
74
102
  }) {
75
103
  const { fetch, router } = makeRoutedFetch({
76
104
  onError
@@ -90,6 +118,10 @@ export default async function makeHyperFetch ({
90
118
  if (writable) {
91
119
  router.get(`hyper://${SPECIAL_DOMAIN}/`, getKey)
92
120
  router.post(`hyper://${SPECIAL_DOMAIN}/`, createKey)
121
+ router.put(`hyper://${SPECIAL_DOMAIN}/`, putFilesDefault)
122
+ router.delete('hyper://SPECIAL_DOMAIN/**', deleteFilesDefault)
123
+ router.get(`hyper://${SPECIAL_DOMAIN}/**`, getFilesDefault)
124
+ router.head(`hyper://${SPECIAL_DOMAIN}/**`, headFilesDefault)
93
125
 
94
126
  router.put(`hyper://*/${SPECIAL_FOLDER}/${VERSION_FOLDER_NAME}/**`, putFilesVersioned)
95
127
  router.put('hyper://*/**', putFiles)
@@ -103,8 +135,14 @@ export default async function makeHyperFetch ({
103
135
  router.get('hyper://*/**', getFiles)
104
136
  router.head('hyper://*/**', headFiles)
105
137
 
106
- async function onError (e, request) {
138
+ /**
139
+ *
140
+ * @param {Error} e
141
+ * @returns {Promise<import('make-fetch').ResponseLike>}
142
+ */
143
+ async function onError (e) {
107
144
  return {
145
+ // @ts-ignore
108
146
  status: e.statusCode || 500,
109
147
  headers: {
110
148
  'Content-Type': 'text/plain; charset=utf-8'
@@ -113,6 +151,12 @@ export default async function makeHyperFetch ({
113
151
  }
114
152
  }
115
153
 
154
+ /**
155
+ *
156
+ * @param {Hypercore} core
157
+ * @param {string} name
158
+ * @returns
159
+ */
116
160
  async function getExtension (core, name) {
117
161
  const key = core.url + name
118
162
  if (extensions.has(key)) {
@@ -125,7 +169,7 @@ export default async function makeHyperFetch ({
125
169
  }
126
170
 
127
171
  const extension = core.registerExtension(name, {
128
- encoding: 'utf8',
172
+ encoding: 'utf-8',
129
173
  onmessage: (content, peer) => {
130
174
  core.emit(EXTENSION_EVENT, name, content, peer)
131
175
  }
@@ -136,6 +180,11 @@ export default async function makeHyperFetch ({
136
180
  return extension
137
181
  }
138
182
 
183
+ /**
184
+ * @param {Hypercore} core
185
+ * @param {string} name
186
+ * @returns
187
+ */
139
188
  async function getExtensionPeers (core, name) {
140
189
  // List peers with this extension
141
190
  const allPeers = core.peers
@@ -144,10 +193,18 @@ export default async function makeHyperFetch ({
144
193
  })
145
194
  }
146
195
 
196
+ /**
197
+ * @param {Hypercore} core
198
+ * @returns
199
+ */
147
200
  function listExtensionNames (core) {
148
201
  return [...core.extensions.keys()]
149
202
  }
150
203
 
204
+ /**
205
+ * @param {Request} request
206
+ * @returns
207
+ */
151
208
  async function listExtensions (request) {
152
209
  const { hostname } = new URL(request.url)
153
210
  const accept = request.headers.get('Accept') || ''
@@ -156,6 +213,12 @@ export default async function makeHyperFetch ({
156
213
 
157
214
  if (accept.includes('text/event-stream')) {
158
215
  const events = new EventIterator(({ push }) => {
216
+ /**
217
+ *
218
+ * @param {string} name
219
+ * @param {string} content
220
+ * @param {import('hypercore').Peer} peer
221
+ */
159
222
  function onMessage (name, content, peer) {
160
223
  const id = peer.remotePublicKey.toString('hex')
161
224
  // TODO: Fancy verification on the `name`?
@@ -164,10 +227,18 @@ export default async function makeHyperFetch ({
164
227
 
165
228
  push(`id:${id}\nevent:${name}\n${data}\n`)
166
229
  }
230
+
231
+ /**
232
+ * @param {import('hypercore').Peer} peer
233
+ */
167
234
  function onPeerOpen (peer) {
168
235
  const id = peer.remotePublicKey.toString('hex')
169
236
  push(`id:${id}\nevent:${PEER_OPEN}\n\n`)
170
237
  }
238
+
239
+ /**
240
+ * @param {import('hypercore').Peer} peer
241
+ */
171
242
  function onPeerRemove (peer) {
172
243
  // Whatever, probably an uninitialized peer
173
244
  if (!peer.remotePublicKey) return
@@ -201,6 +272,10 @@ export default async function makeHyperFetch ({
201
272
  }
202
273
  }
203
274
 
275
+ /**
276
+ * @param {Request} request
277
+ * @returns
278
+ */
204
279
  async function listenExtension (request) {
205
280
  const { hostname, pathname: rawPathname } = new URL(request.url)
206
281
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
@@ -223,6 +298,10 @@ export default async function makeHyperFetch ({
223
298
  }
224
299
  }
225
300
 
301
+ /**
302
+ * @param {Request} request
303
+ * @returns
304
+ */
226
305
  async function broadcastExtension (request) {
227
306
  const { hostname, pathname: rawPathname } = new URL(request.url)
228
307
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
@@ -238,6 +317,10 @@ export default async function makeHyperFetch ({
238
317
  return { status: 200 }
239
318
  }
240
319
 
320
+ /**
321
+ * @param {Request} request
322
+ * @returns
323
+ */
241
324
  async function extensionToPeer (request) {
242
325
  const { hostname, pathname: rawPathname } = new URL(request.url)
243
326
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
@@ -264,21 +347,24 @@ export default async function makeHyperFetch ({
264
347
  return { status: 200 }
265
348
  }
266
349
 
350
+ /**
351
+ * @param {Request} request
352
+ * @returns
353
+ */
267
354
  async function getKey (request) {
268
- const key = new URL(request.url).searchParams.get('key')
269
- if (!key) {
270
- return { status: 400, body: 'Must specify key parameter to resolve' }
271
- }
355
+ const key = new URL(request.url).searchParams.get('key') || defaultDrive
272
356
 
273
357
  try {
274
358
  const drive = await sdk.getDrive(key)
359
+ onLoad(new URL('/', drive.url), drive.writable, key)
275
360
 
276
361
  return { body: drive.url }
277
362
  } catch (e) {
278
- if (e.message === ERROR_KEY_NOT_CREATED) {
363
+ const message = /** @type Error */(e).message
364
+ if (message === ERROR_KEY_NOT_CREATED) {
279
365
  return {
280
366
  status: 400,
281
- body: e.message,
367
+ body: message,
282
368
  headers: {
283
369
  [HEADER_CONTENT_TYPE]: MIME_TEXT_PLAIN
284
370
  }
@@ -287,39 +373,67 @@ export default async function makeHyperFetch ({
287
373
  }
288
374
  }
289
375
 
376
+ /**
377
+ * @param {Request} request
378
+ * @returns
379
+ */
290
380
  async function createKey (request) {
291
381
  // TODO: Allow importing secret keys here
292
382
  // Maybe specify a seed to use for generating the blobs?
293
383
  // Else we'd need to specify the blobs keys and metadata keys
294
384
 
295
- const key = new URL(request.url).searchParams.get('key')
296
- if (!key) {
297
- return { status: 400, body: 'Must specify key parameter to resolve' }
298
- }
385
+ const key = new URL(request.url).searchParams.get('key') || defaultDrive
299
386
 
300
387
  const drive = await sdk.getDrive(key)
388
+ onLoad(new URL('/', drive.url), drive.writable, key)
301
389
 
302
390
  return { body: drive.url }
303
391
  }
304
392
 
393
+ /**
394
+ * @param {Request} request
395
+ */
396
+ async function putFilesDefault (request) {
397
+ const finalURL = await resolveInDefault(request.url)
398
+
399
+ return putTo(finalURL, request)
400
+ }
401
+
402
+ /**
403
+ * @param {Request} request
404
+ * @returns
405
+ */
305
406
  async function putFiles (request) {
306
- const { hostname, pathname: rawPathname } = new URL(request.url)
407
+ return putTo(request.url, request)
408
+ }
409
+
410
+ /**
411
+ * @param {string} url
412
+ * @param {Request} request
413
+ * @returns
414
+ */
415
+ async function putTo (url, request) {
416
+ const { hostname, pathname: rawPathname } = new URL(url)
307
417
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
308
418
  const contentType = request.headers.get('Content-Type') || ''
309
- const mtime = Date.parse(request.headers.get('Last-Modified')) || Date.now()
419
+ const lastModified = request.headers.get('Last-Modified')
420
+ const mtime = lastModified ? Date.parse(lastModified) : Date.now()
310
421
  const isFormData = contentType.includes('multipart/form-data')
311
422
 
312
423
  const drive = await sdk.getDrive(`hyper://${hostname}/`)
313
424
 
314
- if (!drive.db.feed.writable) {
425
+ if (!drive.writable) {
315
426
  return { status: 403, body: `Cannot PUT file to read-only drive: ${drive.url}`, headers: { Location: request.url } }
316
427
  }
317
428
 
429
+ onLoad(new URL('/', url), drive.writable)
430
+
318
431
  if (isFormData) {
319
432
  // It's a form! Get the files out and process them
320
433
  const formData = await request.formData()
321
434
  for (const [name, data] of formData) {
322
435
  if (name !== 'file') continue
436
+ if (typeof data === 'string') continue
323
437
  const filePath = posix.join(pathname, data.name)
324
438
  await pipelinePromise(
325
439
  Readable.from(data.stream()),
@@ -332,7 +446,7 @@ export default async function makeHyperFetch ({
332
446
  }
333
447
  } else {
334
448
  if (pathname.endsWith('/')) {
335
- return { status: 405, body: 'Cannot PUT file with trailing slash', headers: { Location: request.url } }
449
+ return { status: 405, body: 'Cannot PUT file with trailing slash', headers: { Location: url } }
336
450
  } else {
337
451
  await pipelinePromise(
338
452
  Readable.from(request.body),
@@ -357,6 +471,10 @@ export default async function makeHyperFetch ({
357
471
  return { status: 201, headers }
358
472
  }
359
473
 
474
+ /**
475
+ * @param {Request} request
476
+ * @returns
477
+ */
360
478
  function putFilesVersioned (request) {
361
479
  return {
362
480
  status: 405,
@@ -365,25 +483,54 @@ export default async function makeHyperFetch ({
365
483
  }
366
484
  }
367
485
 
486
+ /**
487
+ * @param {Request} request
488
+ * @returns
489
+ */
368
490
  async function deleteDrive (request) {
369
491
  const { hostname } = new URL(request.url)
370
492
  const drive = await sdk.getDrive(`hyper://${hostname}/`)
493
+ onDelete(new URL('/', request.url))
371
494
 
372
495
  await drive.purge()
373
496
 
374
497
  return { status: 200 }
375
498
  }
376
499
 
500
+ /**
501
+ * @param {Request} request
502
+ * @returns
503
+ */
504
+ async function deleteFilesDefault (request) {
505
+ const finalURL = await resolveInDefault(request.url)
506
+
507
+ return deleteFilesTo(finalURL, request)
508
+ }
509
+
510
+ /**
511
+ * @param {Request} request
512
+ * @returns
513
+ */
377
514
  async function deleteFiles (request) {
378
- const { hostname, pathname: rawPathname } = new URL(request.url)
515
+ return deleteFilesTo(request.url, request)
516
+ }
517
+
518
+ /**
519
+ * @param {string} url
520
+ * @param {Request} request
521
+ * @returns
522
+ */
523
+ async function deleteFilesTo (url, request) {
524
+ const { hostname, pathname: rawPathname } = new URL(url)
379
525
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
380
526
 
381
527
  const drive = await sdk.getDrive(`hyper://${hostname}/`)
382
-
383
- if (!drive.db.feed.writable) {
384
- return { status: 403, body: `Cannot DELETE file in read-only drive: ${drive.url}`, headers: { Location: request.url } }
528
+ if (!drive.writable) {
529
+ return { status: 403, body: `Cannot DELETE file in read-only drive: ${drive.url}`, headers: { Location: url } }
385
530
  }
386
531
 
532
+ onLoad(new URL('/', url), drive.writable)
533
+
387
534
  if (pathname.endsWith('/')) {
388
535
  let didDelete = false
389
536
  for await (const entry of drive.list(pathname)) {
@@ -413,10 +560,18 @@ export default async function makeHyperFetch ({
413
560
  return { status: 200, headers }
414
561
  }
415
562
 
563
+ /**
564
+ * @param {Request} request
565
+ * @returns
566
+ */
416
567
  function deleteFilesVersioned (request) {
417
568
  return { status: 405, body: 'Cannot DELETE old version', headers: { Location: request.url } }
418
569
  }
419
570
 
571
+ /**
572
+ * @param {Request} request
573
+ * @returns
574
+ */
420
575
  async function headFilesVersioned (request) {
421
576
  const url = new URL(request.url)
422
577
  const { hostname, pathname: rawPathname, searchParams } = url
@@ -427,7 +582,7 @@ export default async function makeHyperFetch ({
427
582
  const noResolve = searchParams.has('noResolve')
428
583
 
429
584
  const parts = pathname.split('/')
430
- const version = parts[3]
585
+ const version = parseInt(parts[3], 10)
431
586
  const realPath = ensureLeadingSlash(parts.slice(4).join('/'))
432
587
 
433
588
  const drive = await sdk.getDrive(`hyper://${hostname}/`)
@@ -444,8 +599,31 @@ export default async function makeHyperFetch ({
444
599
  return serveHead(snapshot, realPath, { accept, isRanged, noResolve })
445
600
  }
446
601
 
602
+ /**
603
+ * @param {Request} request
604
+ * @returns
605
+ */
606
+ async function headFilesDefault (request) {
607
+ const finalURL = await resolveInDefault(request.url)
608
+
609
+ return headFilesFrom(finalURL, request)
610
+ }
611
+
612
+ /**
613
+ * @param {Request} request
614
+ * @returns
615
+ */
447
616
  async function headFiles (request) {
448
- const url = new URL(request.url)
617
+ return headFilesFrom(request.url, request)
618
+ }
619
+
620
+ /**
621
+ * @param {string} _url
622
+ * @param {Request} request
623
+ * @returns
624
+ */
625
+ async function headFilesFrom (_url, request) {
626
+ const url = new URL(_url)
449
627
  const { hostname, pathname: rawPathname, searchParams } = url
450
628
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
451
629
 
@@ -465,19 +643,30 @@ export default async function makeHyperFetch ({
465
643
  return serveHead(drive, pathname, { accept, isRanged, noResolve })
466
644
  }
467
645
 
646
+ /**
647
+ *
648
+ * @param {Hyperdrive} drive
649
+ * @param {*} pathname
650
+ * @param {object} options
651
+ * @param {string} options.accept
652
+ * @param {string} options.isRanged
653
+ * @param {boolean} options.noResolve
654
+ * @returns
655
+ */
468
656
  async function serveHead (drive, pathname, { accept, isRanged, noResolve }) {
469
657
  const isDirectory = pathname.endsWith('/')
470
658
  const fullURL = new URL(pathname, drive.url).href
471
659
 
472
- const isWritable = writable && drive.db.feed.writable
660
+ const isWritable = writable && drive.writable
473
661
 
474
662
  const Allow = isWritable ? BASIC_METHODS.concat(WRITABLE_METHODS) : BASIC_METHODS
475
663
 
664
+ /** @type {{[key: string]: string}} */
476
665
  const resHeaders = {
477
666
  ETag: `${drive.version}`,
478
667
  'Accept-Ranges': 'bytes',
479
668
  Link: `<${fullURL}>; rel="canonical"`,
480
- Allow
669
+ Allow: Allow.toString()
481
670
  }
482
671
 
483
672
  if (isDirectory) {
@@ -549,8 +738,9 @@ export default async function makeHyperFetch ({
549
738
  const size = entry.value.blob.byteLength
550
739
  if (isRanged) {
551
740
  const ranges = parseRange(size, isRanged)
741
+ const isRangeValid = ranges !== -1 && ranges !== -2 && ranges
552
742
 
553
- if (ranges && ranges.length && ranges.type === 'bytes') {
743
+ if (isRangeValid && ranges.length && ranges.type === 'bytes') {
554
744
  const [{ start, end }] = ranges
555
745
  const length = (end - start + 1)
556
746
 
@@ -571,6 +761,10 @@ export default async function makeHyperFetch ({
571
761
  }
572
762
  }
573
763
 
764
+ /**
765
+ * @param {Request} request
766
+ * @returns
767
+ */
574
768
  async function getFilesVersioned (request) {
575
769
  const url = new URL(request.url)
576
770
  const { hostname, pathname: rawPathname, searchParams } = url
@@ -581,19 +775,51 @@ export default async function makeHyperFetch ({
581
775
  const noResolve = searchParams.has('noResolve')
582
776
 
583
777
  const parts = pathname.split('/')
584
- const version = parts[3]
778
+ const version = parseInt(parts[3], 10)
585
779
  const realPath = ensureLeadingSlash(parts.slice(4).join('/'))
586
780
 
587
781
  const drive = await sdk.getDrive(`hyper://${hostname}/`)
588
782
 
783
+ if (!drive.writable && !drive.core.length) {
784
+ return {
785
+ status: 404,
786
+ body: 'Peers Not Found'
787
+ }
788
+ }
789
+
790
+ onLoad(new URL('/', request.url), drive.writable)
791
+
589
792
  const snapshot = await drive.checkout(version)
590
793
 
591
794
  return serveGet(snapshot, realPath, { accept, isRanged, noResolve })
592
795
  }
593
796
 
594
- // TODO: Redirect on directories without trailing slash
797
+ /**
798
+ * @param {Request} request
799
+ * @returns
800
+ */
801
+ async function getFilesDefault (request) {
802
+ const finalURL = await resolveInDefault(request.url)
803
+
804
+ return getFilesFrom(finalURL, request)
805
+ }
806
+
807
+ /**
808
+ * @param {Request} request
809
+ * @returns
810
+ */
595
811
  async function getFiles (request) {
596
- const url = new URL(request.url)
812
+ return getFilesFrom(request.url, request)
813
+ }
814
+
815
+ /**
816
+ * @param {string} _url
817
+ * @param {Request} request
818
+ * @returns
819
+ */
820
+ async function getFilesFrom (_url, request) {
821
+ // TODO: Redirect on directories without trailing slash
822
+ const url = new URL(_url)
597
823
  const { hostname, pathname: rawPathname, searchParams } = url
598
824
  const pathname = decodeURI(ensureLeadingSlash(rawPathname))
599
825
 
@@ -610,9 +836,20 @@ export default async function makeHyperFetch ({
610
836
  }
611
837
  }
612
838
 
839
+ onLoad(new URL('/', url), drive.writable)
840
+
613
841
  return serveGet(drive, pathname, { accept, isRanged, noResolve })
614
842
  }
615
843
 
844
+ /**
845
+ * @param {Hyperdrive} drive
846
+ * @param {string} pathname
847
+ * @param {object} options
848
+ * @param {string} options.accept
849
+ * @param {string} options.isRanged
850
+ * @param {boolean} options.noResolve
851
+ * @returns
852
+ */
616
853
  async function serveGet (drive, pathname, { accept, isRanged, noResolve }) {
617
854
  const isDirectory = pathname.endsWith('/')
618
855
  const fullURL = new URL(pathname, drive.url).href
@@ -675,9 +912,30 @@ export default async function makeHyperFetch ({
675
912
  return serveFile(drive, path, isRanged)
676
913
  }
677
914
 
915
+ /**
916
+ * Resolve a URL with a pathname to be within the default drive
917
+ * @param {string} url
918
+ * @returns {Promise<string>}
919
+ */
920
+ async function resolveInDefault (url) {
921
+ const { pathname } = new URL(url)
922
+
923
+ const drive = await sdk.getDrive(defaultDrive)
924
+ const finalURL = new URL(pathname, drive.url).href
925
+
926
+ return finalURL
927
+ }
928
+
678
929
  return fetch
679
930
  }
680
931
 
932
+ /**
933
+ *
934
+ * @param {Hyperdrive} drive
935
+ * @param {string} pathname
936
+ * @param {string} [isRanged]
937
+ * @returns
938
+ */
681
939
  async function serveFile (drive, pathname, isRanged) {
682
940
  const contentType = getMimeType(pathname)
683
941
 
@@ -685,6 +943,7 @@ async function serveFile (drive, pathname, isRanged) {
685
943
 
686
944
  const entry = await drive.entry(pathname)
687
945
 
946
+ /** @type {{[key: string]: string}} */
688
947
  const resHeaders = {
689
948
  ETag: `${entry.seq + 1}`,
690
949
  [HEADER_CONTENT_TYPE]: contentType,
@@ -700,8 +959,9 @@ async function serveFile (drive, pathname, isRanged) {
700
959
  const size = entry.value.blob.byteLength
701
960
  if (isRanged) {
702
961
  const ranges = parseRange(size, isRanged)
962
+ const isRangeValid = ranges !== -1 && ranges !== -2 && ranges
703
963
 
704
- if (ranges && ranges.length && ranges.type === 'bytes') {
964
+ if (isRangeValid && ranges.length && ranges.type === 'bytes') {
705
965
  const [{ start, end }] = ranges
706
966
  const length = (end - start + 1)
707
967
 
@@ -729,6 +989,10 @@ async function serveFile (drive, pathname, isRanged) {
729
989
  }
730
990
  }
731
991
 
992
+ /**
993
+ * @param {string} pathname
994
+ * @returns {string[]}
995
+ */
732
996
  function makeToTry (pathname) {
733
997
  return [
734
998
  pathname,
@@ -737,6 +1001,12 @@ function makeToTry (pathname) {
737
1001
  ]
738
1002
  }
739
1003
 
1004
+ /**
1005
+ * @param {Hyperdrive} drive
1006
+ * @param {string} pathname
1007
+ * @param {boolean} noResolve
1008
+ * @returns
1009
+ */
740
1010
  async function resolvePath (drive, pathname, noResolve) {
741
1011
  if (noResolve) {
742
1012
  const entry = await drive.entry(pathname)
@@ -754,6 +1024,12 @@ async function resolvePath (drive, pathname, noResolve) {
754
1024
  return { entry: null, path: null }
755
1025
  }
756
1026
 
1027
+ /**
1028
+ *
1029
+ * @param {Hyperdrive} drive
1030
+ * @param {string} [pathname]
1031
+ * @returns
1032
+ */
757
1033
  async function listEntries (drive, pathname = '/') {
758
1034
  const entries = []
759
1035
  for await (const path of drive.readdir(pathname)) {
@@ -768,9 +1044,15 @@ async function listEntries (drive, pathname = '/') {
768
1044
  return entries
769
1045
  }
770
1046
 
1047
+ /**
1048
+ *
1049
+ * @param {import('hypercore').Peer[]} peers
1050
+ * @returns
1051
+ */
771
1052
  function formatPeers (peers) {
772
1053
  return peers.map((peer) => {
773
1054
  const remotePublicKey = peer.remotePublicKey.toString('hex')
1055
+ // @ts-ignore
774
1056
  const remoteHost = peer.stream?.rawStream?.remoteHost
775
1057
  return {
776
1058
  remotePublicKey,
@@ -779,12 +1061,22 @@ function formatPeers (peers) {
779
1061
  })
780
1062
  }
781
1063
 
1064
+ /**
1065
+ *
1066
+ * @param {string} path
1067
+ * @returns {string}
1068
+ */
782
1069
  function getMimeType (path) {
783
1070
  let mimeType = mime.getType(path) || 'text/plain; charset=utf-8'
784
1071
  if (mimeType.startsWith('text/')) mimeType = `${mimeType}; charset=utf-8`
785
1072
  return mimeType
786
1073
  }
787
1074
 
1075
+ /**
1076
+ *
1077
+ * @param {string} path
1078
+ * @returns {string}
1079
+ */
788
1080
  function ensureLeadingSlash (path) {
789
1081
  return path.startsWith('/') ? path : '/' + path
790
1082
  }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "hypercore-fetch",
3
- "version": "10.0.0",
3
+ "version": "10.2.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('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
+ }