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