@poe-platform/safe-fs 0.1.799 → 0.1.801
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 +16 -7
- package/dist/safe-fs/contracts/filesystem.d.ts +2 -0
- package/dist/safe-fs/contracts/io.js +26 -36
- package/dist/safe-fs/contracts/path.d.ts +2 -7
- package/dist/safe-fs/contracts/path.js +1 -7
- package/dist/safe-fs/contracts/virtual-path.d.ts +8 -3
- package/dist/safe-fs/contracts/virtual-path.js +22 -5
- package/dist/safe-fs/core.d.ts +1 -0
- package/dist/safe-fs/core.js +1 -0
- package/dist/safe-fs/fs/devices/path.js +18 -17
- package/dist/safe-fs/fs/memory/index.js +57 -43
- package/dist/safe-fs/fs/memory/limits.d.ts +5 -1
- package/dist/safe-fs/fs/memory/limits.js +7 -11
- package/dist/safe-fs/fs/memory/missing-target.d.ts +2 -1
- package/dist/safe-fs/fs/memory/missing-target.js +8 -1
- package/dist/safe-fs/fs/overlay/index.d.ts +4 -1
- package/dist/safe-fs/fs/overlay/index.js +57 -11
- package/dist/safe-fs/fs/overlay/memory-publication.d.ts +11 -4
- package/dist/safe-fs/fs/overlay/memory-publication.js +240 -14
- package/dist/safe-fs/fs/s3/http/request-fetch.d.ts +4 -0
- package/dist/safe-fs/fs/s3/http/request-fetch.js +74 -0
- package/dist/safe-fs/fs/s3/http/request-node.d.ts +4 -0
- package/dist/safe-fs/fs/s3/http/request-node.js +111 -0
- package/dist/safe-fs/fs/s3/http/request.d.ts +5 -4
- package/dist/safe-fs/fs/s3/http/request.js +1 -109
- package/dist/safe-fs/fs/s3/http/signature.js +7 -5
- package/dist/safe-fs/fs/s3/http/transport.js +6 -3
- package/dist/safe-fs/fs/s3/namespace.js +1 -1
- package/dist/safe-fs/fs/scoped.js +2 -2
- package/dist/safe-fs/fs/webdav/webdav.d.ts +3 -0
- package/dist/safe-fs/fs/webdav/webdav.js +5 -4
- package/dist/xml-ast/index.d.ts +2 -0
- package/dist/xml-ast/index.js +68 -31
- package/package.json +9 -2
|
@@ -3,7 +3,10 @@ import { dirname, isPathWithin, validatePath } from "../../contracts/virtual-pat
|
|
|
3
3
|
import { memoryAtomicView } from "../memory/atomic-view.js";
|
|
4
4
|
import { compareIdentity } from "../mount/identity.js";
|
|
5
5
|
import { snapshotConditionalChmod } from "../conditional-chmod.js";
|
|
6
|
+
import { createStagingCleanup } from "../staging-cleanup.js";
|
|
6
7
|
import { runStagingGuard } from "../staging-ancestry.js";
|
|
8
|
+
// Enumerable ownership tokens survive caller snapshots without retaining stats forever.
|
|
9
|
+
const overlayReceipt = Symbol("overlay observation");
|
|
7
10
|
/** Stock Memory mutations commit before returning their promise. No user code or
|
|
8
11
|
* await is allowed between inspecting the two stores and invoking a mutation. */
|
|
9
12
|
export class OverlayMemoryPublication {
|
|
@@ -16,6 +19,7 @@ export class OverlayMemoryPublication {
|
|
|
16
19
|
directories = new Map();
|
|
17
20
|
origins = new Map();
|
|
18
21
|
stages = new WeakMap();
|
|
22
|
+
stagingAncestors = new WeakMap();
|
|
19
23
|
constructor(upper, lower, whiteouts, opaque, hidden) {
|
|
20
24
|
this.upper = upper;
|
|
21
25
|
this.lower = lower;
|
|
@@ -75,7 +79,7 @@ export class OverlayMemoryPublication {
|
|
|
75
79
|
const observation = this.observation(path);
|
|
76
80
|
if (!this.same(physical, this.visible(observation), physical.type === "file"))
|
|
77
81
|
throw new FsError("EAGAIN", { path });
|
|
78
|
-
let logical = physical;
|
|
82
|
+
let logical = { ...physical };
|
|
79
83
|
if (physical.type === "directory") {
|
|
80
84
|
let directory = this.directories.get(path);
|
|
81
85
|
if (!directory || !this.same(directory.upper, observation.upper) || !this.same(directory.lower, observation.lower)) {
|
|
@@ -84,7 +88,9 @@ export class OverlayMemoryPublication {
|
|
|
84
88
|
}
|
|
85
89
|
logical = { ...physical, identityScope: directory.identityScope, dev: directory.dev, ino: directory.ino };
|
|
86
90
|
}
|
|
87
|
-
|
|
91
|
+
const token = {};
|
|
92
|
+
logical[overlayReceipt] = token;
|
|
93
|
+
this.receipts.set(token, observation);
|
|
88
94
|
return logical;
|
|
89
95
|
}
|
|
90
96
|
same(actual, expected, unchanged = false) {
|
|
@@ -94,7 +100,7 @@ export class OverlayMemoryPublication {
|
|
|
94
100
|
&& (!unchanged || ["revision", "size", "mode", "nlink", "mtimeMs", "ctimeMs"].every(key => actual[key] === expected[key]));
|
|
95
101
|
}
|
|
96
102
|
expect(path, expected, unchanged = false) {
|
|
97
|
-
const receipt = this.receipts.get(expected);
|
|
103
|
+
const receipt = this.receipts.get(expected[overlayReceipt] ?? expected);
|
|
98
104
|
if (!receipt || receipt.path !== path)
|
|
99
105
|
throw new FsError("ENOTSUP", { path, message: "unowned overlay publication receipt" });
|
|
100
106
|
const actual = this.observation(path);
|
|
@@ -153,7 +159,7 @@ export class OverlayMemoryPublication {
|
|
|
153
159
|
this.stores();
|
|
154
160
|
this.path(path);
|
|
155
161
|
const retain = (source, snapshot) => {
|
|
156
|
-
const receipt = this.receipts.get(source);
|
|
162
|
+
const receipt = this.receipts.get(source[overlayReceipt] ?? source);
|
|
157
163
|
if (!receipt)
|
|
158
164
|
throw new FsError("ENOTSUP", { path, message: "unowned overlay chmod receipt" });
|
|
159
165
|
this.receipts.set(snapshot, receipt);
|
|
@@ -175,6 +181,164 @@ export class OverlayMemoryPublication {
|
|
|
175
181
|
return true;
|
|
176
182
|
};
|
|
177
183
|
}
|
|
184
|
+
prepareWrite(path, options) {
|
|
185
|
+
this.stores();
|
|
186
|
+
this.path(path);
|
|
187
|
+
const ancestors = this.capture(path);
|
|
188
|
+
return () => {
|
|
189
|
+
options.signal?.throwIfAborted();
|
|
190
|
+
this.stores();
|
|
191
|
+
this.check(ancestors);
|
|
192
|
+
this.expect(dirname(path), options.parent);
|
|
193
|
+
const current = this.inspect(path);
|
|
194
|
+
if (options.expected === null) {
|
|
195
|
+
if (current)
|
|
196
|
+
throw new FsError("EAGAIN", { path });
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
this.expect(path, options.expected, true);
|
|
200
|
+
if (current?.type !== "file")
|
|
201
|
+
throw new FsError("EINVAL", { path });
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async writeFile(path, data, options, maxBytes, guard) {
|
|
207
|
+
const check = this.prepareWrite(path, options);
|
|
208
|
+
check();
|
|
209
|
+
const current = this.inspect(path);
|
|
210
|
+
if (current && ((current.mode >> 6) & 2) !== 2)
|
|
211
|
+
throw new FsError("EACCES", { path });
|
|
212
|
+
if (current && !this.observation(path).upper && current.nlink !== 1)
|
|
213
|
+
throw new FsError("ENOTSUP", { path, message: "ambiguous hardlink copy-up" });
|
|
214
|
+
const controls = options.signal ? { signal: options.signal } : {};
|
|
215
|
+
let bytes = data;
|
|
216
|
+
if (options.append && current && !this.observation(path).upper) {
|
|
217
|
+
const previous = await this.lower.readFile(path, { ...controls, ...(maxBytes === Infinity ? {} : { maxBytes }) });
|
|
218
|
+
if (previous.byteLength + data.byteLength > maxBytes)
|
|
219
|
+
throw new FsError("EFBIG", { path });
|
|
220
|
+
bytes = new Uint8Array(previous.byteLength + data.byteLength);
|
|
221
|
+
bytes.set(previous);
|
|
222
|
+
bytes.set(data, previous.byteLength);
|
|
223
|
+
}
|
|
224
|
+
const ancestors = this.capture(path);
|
|
225
|
+
await this.copyParents(ancestors, controls);
|
|
226
|
+
guard?.();
|
|
227
|
+
check();
|
|
228
|
+
const upper = this.observation(path).upper;
|
|
229
|
+
if (data.byteLength + (options.append ? current?.size ?? 0 : 0) > maxBytes)
|
|
230
|
+
throw new FsError("EFBIG", { path });
|
|
231
|
+
const pending = this.upper.writeFileConditional(path, bytes, {
|
|
232
|
+
...options, parent: this.observation(dirname(path)).upper, expected: upper ?? null,
|
|
233
|
+
append: options.append === true && upper !== undefined,
|
|
234
|
+
...(current ? { mode: current.mode & 0o7777 } : {}),
|
|
235
|
+
...(options.atimeMs === undefined && current ? { atimeMs: current.atimeMs } : {}),
|
|
236
|
+
});
|
|
237
|
+
// Retain the result before another caller can alter the upper store.
|
|
238
|
+
const result = this.inspect(path);
|
|
239
|
+
const receipt = result ? this.stat(path, result) : undefined;
|
|
240
|
+
await pending;
|
|
241
|
+
return receipt;
|
|
242
|
+
}
|
|
243
|
+
async removeFile(path, options) {
|
|
244
|
+
this.prepareWrite(path, options)();
|
|
245
|
+
const parent = this.inspect(dirname(path));
|
|
246
|
+
if (((parent.mode >> 6) & 3) !== 3)
|
|
247
|
+
throw new FsError("EACCES", { path });
|
|
248
|
+
const upper = this.observation(path).upper;
|
|
249
|
+
const removal = upper ? this.upper.removeFileConditional(path, {
|
|
250
|
+
...options, parent: this.observation(dirname(path)).upper, expected: upper,
|
|
251
|
+
}) : undefined;
|
|
252
|
+
if (!upper || !this.physical(this.stores().upper, path))
|
|
253
|
+
this.whiteouts.add(path);
|
|
254
|
+
await removal;
|
|
255
|
+
}
|
|
256
|
+
async prepareDirectory(path, options, guard) {
|
|
257
|
+
const ancestors = this.capture(path);
|
|
258
|
+
const check = () => {
|
|
259
|
+
options.signal?.throwIfAborted();
|
|
260
|
+
guard?.();
|
|
261
|
+
this.check(ancestors);
|
|
262
|
+
this.expect(dirname(path), options.parent);
|
|
263
|
+
const current = this.inspect(path);
|
|
264
|
+
if (options.expected === null) {
|
|
265
|
+
if (current)
|
|
266
|
+
throw new FsError("EAGAIN", { path });
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
this.expect(path, options.expected);
|
|
270
|
+
if (current?.type !== "directory")
|
|
271
|
+
throw new FsError("ENOTDIR", { path });
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
check();
|
|
275
|
+
const current = this.inspect(path);
|
|
276
|
+
await this.copyParents(current ? [...ancestors, { path, stat: options.expected }] : ancestors, options);
|
|
277
|
+
check();
|
|
278
|
+
const pending = this.upper.prepareDirectory(path, {
|
|
279
|
+
...options, parent: this.observation(dirname(path)).upper, expected: this.observation(path).upper ?? null,
|
|
280
|
+
});
|
|
281
|
+
const physical = this.inspect(path);
|
|
282
|
+
if (physical && options.expected === null)
|
|
283
|
+
this.opaque.add(path);
|
|
284
|
+
const receipt = physical ? this.stat(path, physical) : undefined;
|
|
285
|
+
await pending;
|
|
286
|
+
return receipt;
|
|
287
|
+
}
|
|
288
|
+
prepareRename(source, destination) {
|
|
289
|
+
const ancestors = [...this.capture(source), ...this.capture(destination)];
|
|
290
|
+
const target = this.observation(destination);
|
|
291
|
+
const entries = [];
|
|
292
|
+
const pending = [source];
|
|
293
|
+
const stores = this.stores();
|
|
294
|
+
while (pending.length) {
|
|
295
|
+
const path = pending.pop();
|
|
296
|
+
const entry = this.observation(path);
|
|
297
|
+
const visible = this.visible(entry);
|
|
298
|
+
if (!visible)
|
|
299
|
+
continue;
|
|
300
|
+
entries.push(entry);
|
|
301
|
+
if (visible.type !== "directory")
|
|
302
|
+
continue;
|
|
303
|
+
const names = new Set([
|
|
304
|
+
...(entry.upper?.type === "directory" ? stores.upper.names(path) : []),
|
|
305
|
+
...(entry.lower?.type === "directory" && !this.opaque.has(path) ? stores.lower.names(path) : []),
|
|
306
|
+
]);
|
|
307
|
+
for (const name of names)
|
|
308
|
+
pending.push(`${path}/${name}`);
|
|
309
|
+
}
|
|
310
|
+
return () => {
|
|
311
|
+
this.stores();
|
|
312
|
+
this.check(ancestors);
|
|
313
|
+
const current = this.observation(destination);
|
|
314
|
+
if (!this.same(current.upper, target.upper, true) || !this.same(current.lower, target.lower, true)) {
|
|
315
|
+
throw new FsError("EAGAIN", { path: destination });
|
|
316
|
+
}
|
|
317
|
+
for (const entry of entries) {
|
|
318
|
+
const actual = this.observation(entry.path);
|
|
319
|
+
// Copy-up may add upper entries; it never changes the lower store.
|
|
320
|
+
if (!this.same(actual.lower, entry.lower, true)
|
|
321
|
+
|| (entry.upper && !this.same(actual.upper, entry.upper, entry.upper.type !== "directory"))) {
|
|
322
|
+
throw new FsError("EAGAIN", { path: entry.path });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
async rename(source, destination, options, guard) {
|
|
328
|
+
options.signal?.throwIfAborted();
|
|
329
|
+
this.stores();
|
|
330
|
+
const current = this.inspect(destination);
|
|
331
|
+
if (options.noReplace && current)
|
|
332
|
+
throw new FsError("EEXIST", { path: destination });
|
|
333
|
+
guard();
|
|
334
|
+
if (!this.observation(source).upper)
|
|
335
|
+
throw new FsError("ENOENT", { path: source });
|
|
336
|
+
const moved = this.upper.rename(source, destination, options);
|
|
337
|
+
// Stock Memory commits synchronously; publish the whiteout in that same turn.
|
|
338
|
+
if (!this.physical(this.stores().upper, source))
|
|
339
|
+
this.whiteouts.add(source);
|
|
340
|
+
await moved;
|
|
341
|
+
}
|
|
178
342
|
async copyParents(entries, options) {
|
|
179
343
|
for (const entry of entries) {
|
|
180
344
|
options.signal?.throwIfAborted();
|
|
@@ -189,7 +353,7 @@ export class OverlayMemoryPublication {
|
|
|
189
353
|
await pending;
|
|
190
354
|
}
|
|
191
355
|
}
|
|
192
|
-
async create(directory, name, content, options) {
|
|
356
|
+
async create(directory, name, content, options, guard) {
|
|
193
357
|
options.signal?.throwIfAborted();
|
|
194
358
|
this.path(directory);
|
|
195
359
|
this.expect(dirname(directory), options.parent);
|
|
@@ -198,24 +362,30 @@ export class OverlayMemoryPublication {
|
|
|
198
362
|
const ancestors = this.capture(directory);
|
|
199
363
|
await this.copyParents(ancestors, options);
|
|
200
364
|
options.signal?.throwIfAborted();
|
|
365
|
+
guard?.();
|
|
201
366
|
this.check(ancestors);
|
|
202
367
|
this.expect(dirname(directory), options.parent);
|
|
203
368
|
if (this.inspect(directory))
|
|
204
369
|
throw new FsError("EEXIST", { path: directory });
|
|
205
370
|
const parent = this.observation(dirname(directory)).upper;
|
|
206
|
-
const
|
|
207
|
-
|
|
371
|
+
const owned = await this.upper.createStagedFile(directory, name, content, { ...options, parent });
|
|
372
|
+
const staging = Object.freeze({
|
|
373
|
+
parent: owned.parent, directory: owned.directory, file: owned.file,
|
|
374
|
+
...(owned.cleanup ? { cleanup: createStagingCleanup(directory, controls => this.cleanup(staging, controls, true), () => owned.cleanup.close()) } : {}),
|
|
375
|
+
});
|
|
376
|
+
this.stages.set(staging.directory, owned);
|
|
377
|
+
this.stagingAncestors.set(staging.directory, ancestors);
|
|
208
378
|
this.hidden.add(directory);
|
|
209
379
|
return staging;
|
|
210
380
|
}
|
|
211
381
|
async publish(staging, path, options) {
|
|
212
382
|
options.signal?.throwIfAborted();
|
|
213
383
|
this.path(path);
|
|
214
|
-
const owned = this.stages.get(staging);
|
|
384
|
+
const owned = this.stages.get(staging.directory);
|
|
215
385
|
if (!owned)
|
|
216
386
|
throw new FsError("ENOTSUP", { path });
|
|
217
387
|
const paths = this.ancestors(path);
|
|
218
|
-
const ancestors = options.ancestors;
|
|
388
|
+
const ancestors = options.ancestors ?? this.stagingAncestors.get(staging.directory);
|
|
219
389
|
if (!ancestors || ancestors.length !== paths.length || ancestors.some((entry, index) => entry.path !== paths[index])) {
|
|
220
390
|
throw new FsError("EINVAL", { path, message: "complete overlay ancestry is required" });
|
|
221
391
|
}
|
|
@@ -238,16 +408,22 @@ export class OverlayMemoryPublication {
|
|
|
238
408
|
...options, ancestors: translated, parent: translated.at(-1).stat, destination: target ?? null,
|
|
239
409
|
});
|
|
240
410
|
}
|
|
241
|
-
async cleanup(staging, options) {
|
|
411
|
+
async cleanup(staging, options, retained = false) {
|
|
242
412
|
this.stores();
|
|
243
|
-
const owned = this.stages.get(staging);
|
|
413
|
+
const owned = this.stages.get(staging.directory);
|
|
244
414
|
if (!owned)
|
|
245
415
|
throw new FsError("ENOTSUP", { path: staging.directory.path });
|
|
246
|
-
|
|
416
|
+
if (retained && owned.cleanup)
|
|
417
|
+
await owned.cleanup.remove(options);
|
|
418
|
+
else {
|
|
419
|
+
await this.upper.removeStagedFile(owned, options);
|
|
420
|
+
await owned.cleanup?.close();
|
|
421
|
+
}
|
|
247
422
|
this.hidden.delete(owned.directory.path);
|
|
248
|
-
this.stages.delete(staging);
|
|
423
|
+
this.stages.delete(staging.directory);
|
|
424
|
+
this.stagingAncestors.delete(staging.directory);
|
|
249
425
|
}
|
|
250
|
-
confine(roots, options, run, fs) {
|
|
426
|
+
confine(roots, options, run, fs, maxBytes) {
|
|
251
427
|
options.signal?.throwIfAborted();
|
|
252
428
|
const retained = new Map();
|
|
253
429
|
for (const root of roots) {
|
|
@@ -259,10 +435,60 @@ export class OverlayMemoryPublication {
|
|
|
259
435
|
retained.set(entry.path, entry.stat);
|
|
260
436
|
}
|
|
261
437
|
}
|
|
438
|
+
const admittedStages = new WeakSet();
|
|
439
|
+
const guard = (path) => {
|
|
440
|
+
this.path(path);
|
|
441
|
+
if (!roots.some(root => isPathWithin(root, path)))
|
|
442
|
+
throw new FsError("EPERM", { path });
|
|
443
|
+
this.check([...retained].filter(([root]) => isPathWithin(root, path)).map(([path, stat]) => ({ path, stat })));
|
|
444
|
+
};
|
|
262
445
|
return new Proxy(fs, {
|
|
263
446
|
get: (target, property) => {
|
|
264
447
|
if (property === "confineExtraction" || property === "objects")
|
|
265
448
|
return undefined;
|
|
449
|
+
if (property === "prepareDirectory")
|
|
450
|
+
return (path, mutation) => run(mutation, () => this.prepareDirectory(path, { ...mutation }, () => guard(path)));
|
|
451
|
+
if (property === "createStagedFile")
|
|
452
|
+
return (path, name, content, mutation) => run(mutation, async () => {
|
|
453
|
+
guard(path);
|
|
454
|
+
if (content.type === "file" && content.data.byteLength > maxBytes)
|
|
455
|
+
throw new FsError("EFBIG", { path });
|
|
456
|
+
const stage = await this.create(path, name, content, mutation, () => guard(path));
|
|
457
|
+
admittedStages.add(stage.directory);
|
|
458
|
+
return stage;
|
|
459
|
+
});
|
|
460
|
+
if (property === "publishStagedFile")
|
|
461
|
+
return (stage, path, mutation) => run(mutation, async () => {
|
|
462
|
+
guard(path);
|
|
463
|
+
if (!admittedStages.has(stage.directory))
|
|
464
|
+
throw new FsError("ENOTSUP", { path });
|
|
465
|
+
await this.publish(stage, path, mutation);
|
|
466
|
+
});
|
|
467
|
+
if (property === "removeStagedFile")
|
|
468
|
+
return (stage, mutation = {}) => run(mutation, async () => {
|
|
469
|
+
if (!admittedStages.has(stage.directory))
|
|
470
|
+
throw new FsError("ENOTSUP", { path: stage.directory.path });
|
|
471
|
+
await this.cleanup(stage, mutation);
|
|
472
|
+
});
|
|
473
|
+
if (property === "writeFileConditional")
|
|
474
|
+
return async (path, data, mutation) => {
|
|
475
|
+
mutation.signal?.throwIfAborted();
|
|
476
|
+
if (!(data instanceof Uint8Array))
|
|
477
|
+
throw new TypeError("Overlay files require Uint8Array data");
|
|
478
|
+
if (data.byteLength > maxBytes)
|
|
479
|
+
throw new FsError("EFBIG", { path });
|
|
480
|
+
const bytes = new Uint8Array(data), captured = { ...mutation };
|
|
481
|
+
return run(captured, async () => {
|
|
482
|
+
const guard = () => {
|
|
483
|
+
this.path(path);
|
|
484
|
+
if (!roots.some(root => isPathWithin(root, path)))
|
|
485
|
+
throw new FsError("EPERM", { path });
|
|
486
|
+
this.check([...retained].filter(([root]) => isPathWithin(root, path)).map(([path, stat]) => ({ path, stat })));
|
|
487
|
+
};
|
|
488
|
+
guard();
|
|
489
|
+
return this.writeFile(path, bytes, captured, maxBytes, guard);
|
|
490
|
+
});
|
|
491
|
+
};
|
|
266
492
|
if (["mkdir", "rm", "rmdir"].includes(String(property)))
|
|
267
493
|
return (path, mutation = {}) => run(mutation, async () => {
|
|
268
494
|
mutation.signal?.throwIfAborted();
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { RequestOptions } from "node:http";
|
|
2
|
+
import type { RequestScope, WireResponse } from "./request.js";
|
|
3
|
+
import type { S3HttpRequestFactory } from "./types.js";
|
|
4
|
+
export declare function sendRequest(options: RequestOptions, body: Uint8Array, scope: RequestScope, factory?: S3HttpRequestFactory): Promise<WireResponse>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { S3ServiceError } from "../transport.js";
|
|
2
|
+
import { abortable } from "./request.js";
|
|
3
|
+
export async function sendRequest(options, body, scope, factory) {
|
|
4
|
+
const { signal } = scope;
|
|
5
|
+
signal.throwIfAborted();
|
|
6
|
+
if (factory)
|
|
7
|
+
throw new S3ServiceError("InvalidArgument", 400, "Node request factories are unavailable in fetch runtimes");
|
|
8
|
+
const hostname = String(options.hostname);
|
|
9
|
+
const authority = `${hostname.includes(":") ? `[${hostname}]` : hostname}${options.port ? `:${options.port}` : ""}`;
|
|
10
|
+
const target = `${options.protocol}//${authority}${options.path}`;
|
|
11
|
+
const url = new URL(target);
|
|
12
|
+
// Fetch normalizes dot segments. Never sign one key and send another.
|
|
13
|
+
if (`${url.pathname}${url.search}` !== options.path)
|
|
14
|
+
throw new S3ServiceError("InvalidArgument", 400, "object key cannot be represented by fetch without normalization");
|
|
15
|
+
const response = await abortable(fetch(target, {
|
|
16
|
+
method: options.method, headers: options.headers,
|
|
17
|
+
...(options.method === "PUT" ? { body: new Uint8Array(body) } : {}),
|
|
18
|
+
signal, redirect: "manual",
|
|
19
|
+
}), signal);
|
|
20
|
+
const reader = response.body?.getReader();
|
|
21
|
+
let closed = false;
|
|
22
|
+
const close = () => {
|
|
23
|
+
if (closed)
|
|
24
|
+
return;
|
|
25
|
+
closed = true;
|
|
26
|
+
signal.removeEventListener("abort", close);
|
|
27
|
+
scope.finish();
|
|
28
|
+
void reader?.cancel().catch(() => { });
|
|
29
|
+
};
|
|
30
|
+
signal.addEventListener("abort", close, { once: true });
|
|
31
|
+
if (signal.aborted) {
|
|
32
|
+
close();
|
|
33
|
+
signal.throwIfAborted();
|
|
34
|
+
}
|
|
35
|
+
const headersDistinct = Object.create(null);
|
|
36
|
+
response.headers.forEach((value, name) => { headersDistinct[name] = [value]; });
|
|
37
|
+
let claimed = false;
|
|
38
|
+
return {
|
|
39
|
+
message: { statusCode: response.status, headersDistinct }, close,
|
|
40
|
+
body: {
|
|
41
|
+
[Symbol.asyncIterator]() {
|
|
42
|
+
if (claimed)
|
|
43
|
+
throw new S3ServiceError("InvalidResponse", 502, "response body is single-use");
|
|
44
|
+
claimed = true;
|
|
45
|
+
return {
|
|
46
|
+
async next() {
|
|
47
|
+
try {
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
if (closed || !reader) {
|
|
50
|
+
close();
|
|
51
|
+
return { done: true, value: undefined };
|
|
52
|
+
}
|
|
53
|
+
const result = await abortable(reader.read(), signal);
|
|
54
|
+
signal.throwIfAborted();
|
|
55
|
+
if (result.done) {
|
|
56
|
+
close();
|
|
57
|
+
return { done: true, value: undefined };
|
|
58
|
+
}
|
|
59
|
+
return { done: false, value: result.value };
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
close();
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
async return() {
|
|
67
|
+
close();
|
|
68
|
+
return { done: true, value: undefined };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { RequestOptions } from "node:http";
|
|
2
|
+
import type { S3HttpRequestFactory } from "./types.js";
|
|
3
|
+
import type { RequestScope, WireResponse } from "./request.js";
|
|
4
|
+
export declare function sendRequest(options: RequestOptions, body: Uint8Array, scope: RequestScope, factory?: S3HttpRequestFactory): Promise<WireResponse>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import { request as httpRequest } from "node:http";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { S3ServiceError } from "../transport.js";
|
|
5
|
+
import { abortable } from "./request.js";
|
|
6
|
+
export async function sendRequest(options, body, scope, factory) {
|
|
7
|
+
const signal = scope.signal;
|
|
8
|
+
signal.throwIfAborted();
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
let request;
|
|
11
|
+
let response;
|
|
12
|
+
let headersReceived = false;
|
|
13
|
+
let finished = false;
|
|
14
|
+
const cleanup = () => {
|
|
15
|
+
signal.removeEventListener("abort", abort);
|
|
16
|
+
scope.finish();
|
|
17
|
+
};
|
|
18
|
+
const close = () => {
|
|
19
|
+
if (finished)
|
|
20
|
+
return;
|
|
21
|
+
finished = true;
|
|
22
|
+
cleanup();
|
|
23
|
+
response?.destroy();
|
|
24
|
+
request?.destroy();
|
|
25
|
+
};
|
|
26
|
+
const fail = (error) => {
|
|
27
|
+
reject(signal.aborted ? signal.reason : error);
|
|
28
|
+
close();
|
|
29
|
+
};
|
|
30
|
+
const abort = () => fail(signal.reason);
|
|
31
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
32
|
+
try {
|
|
33
|
+
const create = factory ?? (options.protocol === "https:" ? httpsRequest : httpRequest);
|
|
34
|
+
request = create(options, message => {
|
|
35
|
+
response = message;
|
|
36
|
+
message.on("error", () => { });
|
|
37
|
+
if (finished || signal.aborted) {
|
|
38
|
+
close();
|
|
39
|
+
message.destroy();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
headersReceived = true;
|
|
43
|
+
message.once("close", cleanup);
|
|
44
|
+
let claimed = false;
|
|
45
|
+
const source = {
|
|
46
|
+
[Symbol.asyncIterator]() {
|
|
47
|
+
if (claimed)
|
|
48
|
+
throw new S3ServiceError("InvalidResponse", 502, "response body is single-use");
|
|
49
|
+
claimed = true;
|
|
50
|
+
const iterator = message[Symbol.asyncIterator]();
|
|
51
|
+
let ended = false;
|
|
52
|
+
return {
|
|
53
|
+
async next() {
|
|
54
|
+
try {
|
|
55
|
+
signal.throwIfAborted();
|
|
56
|
+
if (ended)
|
|
57
|
+
return { done: true, value: undefined };
|
|
58
|
+
const result = await abortable(iterator.next(), signal);
|
|
59
|
+
signal.throwIfAborted();
|
|
60
|
+
if (result.done) {
|
|
61
|
+
ended = true;
|
|
62
|
+
if (!message.complete)
|
|
63
|
+
throw new S3ServiceError("InvalidResponse", 502, "incomplete HTTP response");
|
|
64
|
+
close();
|
|
65
|
+
return { done: true, value: undefined };
|
|
66
|
+
}
|
|
67
|
+
if (!(result.value instanceof Uint8Array))
|
|
68
|
+
throw new S3ServiceError("InvalidResponse", 502, "nonbinary HTTP body");
|
|
69
|
+
return { done: false, value: new Uint8Array(result.value) };
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
ended = true;
|
|
73
|
+
close();
|
|
74
|
+
throw signal.aborted ? signal.reason : error;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
async return() {
|
|
78
|
+
ended = true;
|
|
79
|
+
close();
|
|
80
|
+
return { done: true, value: undefined };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
resolve({ message, body: source, close });
|
|
86
|
+
});
|
|
87
|
+
request.on("error", fail);
|
|
88
|
+
request.once("close", () => {
|
|
89
|
+
if (!headersReceived)
|
|
90
|
+
fail(new S3ServiceError("InvalidResponse", 502, "connection closed before response headers"));
|
|
91
|
+
});
|
|
92
|
+
if (signal.aborted || finished) {
|
|
93
|
+
abort();
|
|
94
|
+
request.destroy();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
void (async () => {
|
|
98
|
+
for (let offset = 0; offset < body.length; offset += 64 * 1024) {
|
|
99
|
+
signal.throwIfAborted();
|
|
100
|
+
if (!request.write(body.subarray(offset, offset + 64 * 1024)))
|
|
101
|
+
await once(request, "drain", { signal });
|
|
102
|
+
}
|
|
103
|
+
signal.throwIfAborted();
|
|
104
|
+
request.end();
|
|
105
|
+
})().catch(fail);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
fail(error);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import type { S3HttpRequestFactory } from "./types.js";
|
|
1
|
+
export { sendRequest } from "#safe-fs-s3-request";
|
|
3
2
|
export interface RequestScope {
|
|
4
3
|
readonly signal: AbortSignal;
|
|
5
4
|
readonly finish: () => void;
|
|
@@ -7,10 +6,12 @@ export interface RequestScope {
|
|
|
7
6
|
export declare function scopeFor(signal: AbortSignal | undefined, timeout: number | undefined): RequestScope;
|
|
8
7
|
export declare function abortable<Value>(pending: Promise<Value>, signal: AbortSignal): Promise<Value>;
|
|
9
8
|
export interface WireResponse {
|
|
10
|
-
readonly message:
|
|
9
|
+
readonly message: {
|
|
10
|
+
readonly statusCode?: number | undefined;
|
|
11
|
+
readonly headersDistinct: Record<string, string[] | undefined>;
|
|
12
|
+
};
|
|
11
13
|
readonly body: AsyncIterable<Uint8Array>;
|
|
12
14
|
readonly close: () => void;
|
|
13
15
|
}
|
|
14
|
-
export declare function sendRequest(options: RequestOptions, body: Uint8Array, scope: RequestScope, factory?: S3HttpRequestFactory): Promise<WireResponse>;
|
|
15
16
|
export declare function limitedBody(response: WireResponse, maximum: number, expected?: number): AsyncIterable<Uint8Array>;
|
|
16
17
|
export declare function collect(response: WireResponse, maximum: number, expected?: number): Promise<Uint8Array>;
|