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 +6 -2
- package/dist/index.d.ts +28 -0
- package/dist/test.d.ts +1 -0
- package/index.js +218 -16
- package/package.json +16 -6
- package/test.js +77 -11
- package/tsconfig.json +19 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ const fetch = await makeFetch({
|
|
|
16
16
|
writable: true
|
|
17
17
|
})
|
|
18
18
|
|
|
19
|
-
const someURL = `hyper://
|
|
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
-
|
|
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: '
|
|
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
|
-
|
|
359
|
+
const message = /** @type Error */(e).message
|
|
360
|
+
if (message === ERROR_KEY_NOT_CREATED) {
|
|
279
361
|
return {
|
|
280
362
|
status: 400,
|
|
281
|
-
body:
|
|
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
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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": "^
|
|
34
|
-
"
|
|
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
|
-
|
|
20
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
+
}
|