@stowage/adapter-fs 0.0.0 → 0.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/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +1111 -0
- package/package.json +41 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Kaufmann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# @stowage/adapter-fs
|
|
2
|
+
|
|
3
|
+
A storage rooted in one directory of the local file system.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @stowage/adapter-fs
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Example
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { fsStorage } from "@stowage/adapter-fs";
|
|
15
|
+
|
|
16
|
+
const storage = fsStorage({ root: "/var/lib/my-app/storage" });
|
|
17
|
+
|
|
18
|
+
await storage.put("reports/2026/q3.csv", "region,revenue\n");
|
|
19
|
+
|
|
20
|
+
for await (const entry of storage.list({ prefix: "reports/" })) {
|
|
21
|
+
console.log(entry.key, entry.size, entry.lastModified);
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Runtimes
|
|
26
|
+
|
|
27
|
+
Node 24 and later, Bun and Deno, on Linux and macOS. `workerd` and Windows are not promised. CI last ran green on Bun 1.4.2 and Deno 2.9.6.
|
|
28
|
+
|
|
29
|
+
The bundle measures 5.7 kB minified and gzipped, `@stowage/core` included.
|
|
30
|
+
|
|
31
|
+
## Limits
|
|
32
|
+
|
|
33
|
+
- `keyBytesPreserved` is not declared: a key comes back Unicode-equivalent to what was written
|
|
34
|
+
([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#49-capabilities)).
|
|
35
|
+
- `presignedUrls` is not declared: `FsStorage` has neither `presignGet` nor `presignPut`
|
|
36
|
+
([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#49-capabilities)).
|
|
37
|
+
- `userMetadata` is not declared: `put` with a non-empty `userMetadata` is `Unsupported`, and reads
|
|
38
|
+
return `{}`
|
|
39
|
+
([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#49-capabilities)).
|
|
40
|
+
- A key segment longer than 255 bytes is `InvalidKey`, and so is a key whose whole path is longer
|
|
41
|
+
than the file system holds. macOS bounds one path at 1024 bytes with the root counted in
|
|
42
|
+
([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#6-stowageadapter-fs)).
|
|
43
|
+
- APFS keeps a name in the Unicode form it was written in, and folds the forms when it looks a name
|
|
44
|
+
up, so a key in NFD reaches the object its NFC form wrote. A case-insensitive file system collides
|
|
45
|
+
keys that differ in case alone. Nothing repairs either
|
|
46
|
+
([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#6-stowageadapter-fs)).
|
|
47
|
+
- The content type is derived from the key's extension, and is `application/octet-stream` where the
|
|
48
|
+
extension is unknown or absent. The `contentType` handed to `put` is not stored, so `stat` may
|
|
49
|
+
report another one
|
|
50
|
+
([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#6-stowageadapter-fs)).
|
|
51
|
+
- No `etag` is set
|
|
52
|
+
([spec 4.4](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#44-object-descriptions)).
|
|
53
|
+
|
|
54
|
+
## Notes
|
|
55
|
+
|
|
56
|
+
`put` takes no `Blob`. A caller holding one passes its stream
|
|
57
|
+
([spec 4.2](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#42-bodies)):
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { fsStorage } from "@stowage/adapter-fs";
|
|
61
|
+
|
|
62
|
+
const storage = fsStorage({ root: "/var/lib/my-app/storage" });
|
|
63
|
+
const blob = new Blob(["region,revenue\n"], { type: "text/csv" });
|
|
64
|
+
|
|
65
|
+
await storage.put("reports/2026/q4.csv", blob.stream());
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
stowage reports no progress
|
|
69
|
+
([spec 11](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#11-non-goals)).
|
|
70
|
+
A caller who wants it counts the bytes on their way into `put`:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { fsStorage } from "@stowage/adapter-fs";
|
|
74
|
+
|
|
75
|
+
const storage = fsStorage({ root: "/var/lib/my-app/storage" });
|
|
76
|
+
|
|
77
|
+
function countBytes(report: (bytes: number) => void): TransformStream<Uint8Array, Uint8Array> {
|
|
78
|
+
let bytes = 0;
|
|
79
|
+
|
|
80
|
+
return new TransformStream({
|
|
81
|
+
transform(chunk, controller) {
|
|
82
|
+
bytes += chunk.byteLength;
|
|
83
|
+
report(bytes);
|
|
84
|
+
controller.enqueue(chunk);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const response = await fetch("https://example.com/video.mp4");
|
|
90
|
+
|
|
91
|
+
if (response.body === null) throw new Error("The response carries no body");
|
|
92
|
+
|
|
93
|
+
await storage.put("videos/intro.mp4", response.body.pipeThrough(countBytes(console.log)));
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Specification
|
|
97
|
+
|
|
98
|
+
[`docs/spec.md` at `@stowage/adapter-fs@0.1.0`](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#6-stowageadapter-fs)
|
|
99
|
+
is the contract: a caller may rely on what it states and on nothing else this package happens to
|
|
100
|
+
export. The [terms it uses](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/CONTEXT.md)
|
|
101
|
+
and the [decisions behind it](https://github.com/stowage-js/stowage/tree/@stowage/adapter-fs@0.1.0/docs/adr)
|
|
102
|
+
are at the same tag.
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Storage } from "@stowage/core";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
export interface FsAdapterOptions {
|
|
4
|
+
/**
|
|
5
|
+
* An absolute path to an existing directory. Construction performs no I/O, so an operation
|
|
6
|
+
* against a root that does not exist rejects with `NotFound`.
|
|
7
|
+
*/
|
|
8
|
+
root: string;
|
|
9
|
+
}
|
|
10
|
+
export interface FsStorage extends Storage {
|
|
11
|
+
readonly provider: "fs";
|
|
12
|
+
}
|
|
13
|
+
export declare function fsStorage(options: FsAdapterOptions): FsStorage;
|
|
14
|
+
//#endregion
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
import { copyFile, lstat, mkdir, open, readdir, realpath, rename, rmdir, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, sep } from "node:path";
|
|
3
|
+
import { StorageError, invalidKeyReason, isStorageError } from "@stowage/core";
|
|
4
|
+
//#region src/body.ts
|
|
5
|
+
const utf8$1 = new TextEncoder();
|
|
6
|
+
/**
|
|
7
|
+
* Writes the body into the open file. A stream goes chunk by chunk, so that an upload of
|
|
8
|
+
* any size costs one chunk of memory rather than the object.
|
|
9
|
+
*/
|
|
10
|
+
async function writeBody(handle, body, signal) {
|
|
11
|
+
if (isStream(body)) {
|
|
12
|
+
await body.pipeTo(new WritableStream({ async write(chunk) {
|
|
13
|
+
await writeAll(handle, chunk);
|
|
14
|
+
} }), { signal });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
signal?.throwIfAborted();
|
|
18
|
+
await writeAll(handle, typeof body === "string" ? utf8$1.encode(body) : body);
|
|
19
|
+
}
|
|
20
|
+
/** Spec 4.2 leaves a stream at its end or canceled once `put` settled, read or not. */
|
|
21
|
+
async function cancelBody(body, reason) {
|
|
22
|
+
if (!isStream(body) || body.locked) return;
|
|
23
|
+
await body.cancel(reason).catch(() => {});
|
|
24
|
+
}
|
|
25
|
+
function isStream(body) {
|
|
26
|
+
return typeof body !== "string" && !(body instanceof Uint8Array);
|
|
27
|
+
}
|
|
28
|
+
async function writeAll(handle, bytes) {
|
|
29
|
+
for (let written = 0; written < bytes.byteLength;) {
|
|
30
|
+
const { bytesWritten } = await handle.write(bytes, written, bytes.byteLength - written);
|
|
31
|
+
written += bytesWritten;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/content-type.ts
|
|
36
|
+
const defaultContentType = "application/octet-stream";
|
|
37
|
+
/**
|
|
38
|
+
* The table spec 6 has the adapter derive a content type from, kept to the types a file
|
|
39
|
+
* below a root is likely to carry: nothing here reads the bytes, and no dependency comes
|
|
40
|
+
* into a package that promises a runtime dependency outside `@stowage` for nothing.
|
|
41
|
+
*/
|
|
42
|
+
const contentTypes = /* @__PURE__ */ new Map([
|
|
43
|
+
["avif", "image/avif"],
|
|
44
|
+
["css", "text/css"],
|
|
45
|
+
["csv", "text/csv"],
|
|
46
|
+
["gif", "image/gif"],
|
|
47
|
+
["gz", "application/gzip"],
|
|
48
|
+
["htm", "text/html"],
|
|
49
|
+
["html", "text/html"],
|
|
50
|
+
["ico", "image/vnd.microsoft.icon"],
|
|
51
|
+
["jpeg", "image/jpeg"],
|
|
52
|
+
["jpg", "image/jpeg"],
|
|
53
|
+
["js", "text/javascript"],
|
|
54
|
+
["json", "application/json"],
|
|
55
|
+
["md", "text/markdown"],
|
|
56
|
+
["mjs", "text/javascript"],
|
|
57
|
+
["mp3", "audio/mpeg"],
|
|
58
|
+
["mp4", "video/mp4"],
|
|
59
|
+
["otf", "font/otf"],
|
|
60
|
+
["pdf", "application/pdf"],
|
|
61
|
+
["png", "image/png"],
|
|
62
|
+
["svg", "image/svg+xml"],
|
|
63
|
+
["tar", "application/x-tar"],
|
|
64
|
+
["ttf", "font/ttf"],
|
|
65
|
+
["txt", "text/plain"],
|
|
66
|
+
["wasm", "application/wasm"],
|
|
67
|
+
["wav", "audio/wav"],
|
|
68
|
+
["webm", "video/webm"],
|
|
69
|
+
["webp", "image/webp"],
|
|
70
|
+
["woff", "font/woff"],
|
|
71
|
+
["woff2", "font/woff2"],
|
|
72
|
+
["xml", "application/xml"],
|
|
73
|
+
["yaml", "application/yaml"],
|
|
74
|
+
["yml", "application/yaml"],
|
|
75
|
+
["zip", "application/zip"]
|
|
76
|
+
]);
|
|
77
|
+
/** The type the key's extension names, and `application/octet-stream` for every other key. */
|
|
78
|
+
function contentTypeOf(key) {
|
|
79
|
+
const name = key.slice(key.lastIndexOf("/") + 1);
|
|
80
|
+
const dot = name.lastIndexOf(".");
|
|
81
|
+
if (dot < 1) return defaultContentType;
|
|
82
|
+
return contentTypes.get(name.slice(dot + 1).toLowerCase()) ?? "application/octet-stream";
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/cursor.ts
|
|
86
|
+
const cursorTag = "stowage-fs-1:";
|
|
87
|
+
const unitDigits = 4;
|
|
88
|
+
const hexPosition = new RegExp(`^(?:[\\da-f]{${unitDigits}})*$`);
|
|
89
|
+
/** The key a page ended on, as the opaque string that continues from there. */
|
|
90
|
+
function encodeCursor(after) {
|
|
91
|
+
return btoa(`${cursorTag}${hexOf(after)}`);
|
|
92
|
+
}
|
|
93
|
+
/** The key the cursor continues from, or `undefined` for one this adapter did not produce. */
|
|
94
|
+
function decodeCursor(cursor) {
|
|
95
|
+
let decoded;
|
|
96
|
+
try {
|
|
97
|
+
decoded = atob(cursor);
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!decoded.startsWith(cursorTag)) return void 0;
|
|
102
|
+
const position = decoded.slice(13);
|
|
103
|
+
return position.length > 0 && hexPosition.test(position) ? keyOf(position) : void 0;
|
|
104
|
+
}
|
|
105
|
+
function hexOf(key) {
|
|
106
|
+
const units = [];
|
|
107
|
+
for (let index = 0; index < key.length; index += 1) units.push(key.charCodeAt(index).toString(16).padStart(unitDigits, "0"));
|
|
108
|
+
return units.join("");
|
|
109
|
+
}
|
|
110
|
+
function keyOf(position) {
|
|
111
|
+
const units = [];
|
|
112
|
+
for (let index = 0; index < position.length; index += unitDigits) units.push(String.fromCharCode(Number.parseInt(position.slice(index, index + unitDigits), 16)));
|
|
113
|
+
return units.join("");
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/storage-error.ts
|
|
117
|
+
/** `bucket` is the root as the storage was given it, which spec 6 names as the bucket. */
|
|
118
|
+
function fsError(root, fields) {
|
|
119
|
+
return new StorageError({
|
|
120
|
+
...fields,
|
|
121
|
+
bucket: root,
|
|
122
|
+
provider: "fs"
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/temporary.ts
|
|
127
|
+
const temporaryName = /^\.stowage-[\da-f-]{36}\.tmp$/;
|
|
128
|
+
/** Where the bytes of a write land before the rename that puts them under the key. */
|
|
129
|
+
function temporaryPathIn(directory) {
|
|
130
|
+
return join(directory, `.stowage-${crypto.randomUUID()}.tmp`);
|
|
131
|
+
}
|
|
132
|
+
function isTemporaryName(name) {
|
|
133
|
+
return temporaryName.test(name);
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/key.ts
|
|
137
|
+
/** What a file system takes as one name, which spec 6 has the adapter refuse beyond. */
|
|
138
|
+
const segmentByteLimit = 255;
|
|
139
|
+
const utf8 = new TextEncoder();
|
|
140
|
+
function requireKey(root, key, rule, operation) {
|
|
141
|
+
const reason = invalidKeyReason(key, rule) ?? temporaryNameReason(key, rule) ?? longSegmentReason(key);
|
|
142
|
+
if (reason === void 0) return;
|
|
143
|
+
throw fsError(root, {
|
|
144
|
+
code: "InvalidKey",
|
|
145
|
+
message: `The key ${JSON.stringify(key)} ${reason}`,
|
|
146
|
+
operation,
|
|
147
|
+
key,
|
|
148
|
+
attempts: 0
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function temporaryNameReason(key, rule) {
|
|
152
|
+
if (rule !== "writable" || !isTemporaryName(key.slice(key.lastIndexOf("/") + 1))) return;
|
|
153
|
+
return "uses the reserved .stowage temporary-file name";
|
|
154
|
+
}
|
|
155
|
+
function longSegmentReason(key) {
|
|
156
|
+
for (const segment of key.split("/")) {
|
|
157
|
+
const bytes = utf8.encode(segment).length;
|
|
158
|
+
if (bytes > segmentByteLimit) return `holds a segment of ${bytes} UTF-8 bytes, above the limit of ${segmentByteLimit}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/options.ts
|
|
163
|
+
const operationOptionKeys = ["signal"];
|
|
164
|
+
const putOptionKeys = [
|
|
165
|
+
...operationOptionKeys,
|
|
166
|
+
"contentType",
|
|
167
|
+
"userMetadata"
|
|
168
|
+
];
|
|
169
|
+
const getOptionKeys = [...operationOptionKeys, "range"];
|
|
170
|
+
const listOptionKeys = [
|
|
171
|
+
...operationOptionKeys,
|
|
172
|
+
"prefix",
|
|
173
|
+
"delimiter",
|
|
174
|
+
"pageSize",
|
|
175
|
+
"cursor"
|
|
176
|
+
];
|
|
177
|
+
const adapterOptionKeys = ["root"];
|
|
178
|
+
/**
|
|
179
|
+
* Refuses an option key the spec does not list (spec 4.3), in a call as well as in the
|
|
180
|
+
* configuration a storage is constructed from. TypeScript catches one at the call site;
|
|
181
|
+
* this catches the rest.
|
|
182
|
+
*/
|
|
183
|
+
function requireKnownOptions(root, options, known, operation) {
|
|
184
|
+
if (options === void 0) return;
|
|
185
|
+
for (const key of Object.keys(options)) {
|
|
186
|
+
if (known.includes(key)) continue;
|
|
187
|
+
throw optionError(root, key, "is not one this storage takes", operation);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function optionError(root, option, expectation, operation) {
|
|
191
|
+
return fsError(root, {
|
|
192
|
+
code: "InvalidOption",
|
|
193
|
+
message: `The option \`${option}\` ${expectation}`,
|
|
194
|
+
operation,
|
|
195
|
+
attempts: 0
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/listing.ts
|
|
200
|
+
const defaultPageSize = 1e3;
|
|
201
|
+
const maxPageSize = 1e3;
|
|
202
|
+
/** `objectsBelow` walks the tree below the prefix and hands it over sorted by key. */
|
|
203
|
+
function createListing(root, objectsBelow, options) {
|
|
204
|
+
return {
|
|
205
|
+
async page() {
|
|
206
|
+
const request = read(root, options);
|
|
207
|
+
options?.signal?.throwIfAborted();
|
|
208
|
+
const slice = take(await objectsBelow(request.prefix), request);
|
|
209
|
+
return {
|
|
210
|
+
objects: slice.objects,
|
|
211
|
+
prefixes: slice.prefixes,
|
|
212
|
+
cursor: slice.after === void 0 ? void 0 : encodeCursor(slice.after)
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
async *[Symbol.asyncIterator]() {
|
|
216
|
+
const request = read(root, options);
|
|
217
|
+
options?.signal?.throwIfAborted();
|
|
218
|
+
const entries = await objectsBelow(request.prefix);
|
|
219
|
+
for (let after = request.after;;) {
|
|
220
|
+
options?.signal?.throwIfAborted();
|
|
221
|
+
const slice = take(entries, {
|
|
222
|
+
...request,
|
|
223
|
+
after
|
|
224
|
+
});
|
|
225
|
+
yield* slice.objects;
|
|
226
|
+
if (slice.after === void 0) return;
|
|
227
|
+
after = slice.after;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function read(root, options) {
|
|
233
|
+
requireKnownOptions(root, options, listOptionKeys, "list");
|
|
234
|
+
const prefix = options?.prefix ?? "";
|
|
235
|
+
requireKey(root, prefix, "prefix", "list");
|
|
236
|
+
const pageSize = options?.pageSize ?? defaultPageSize;
|
|
237
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > maxPageSize) throw optionError(root, "pageSize", `takes a whole number from 1 to ${maxPageSize}`, "list");
|
|
238
|
+
if (options?.delimiter === "") throw optionError(root, "delimiter", "takes at least one character", "list");
|
|
239
|
+
const after = options?.cursor === void 0 ? void 0 : decodeCursor(options.cursor);
|
|
240
|
+
if (options?.cursor !== void 0 && after === void 0) throw optionError(root, "cursor", "takes a cursor this storage handed out", "list");
|
|
241
|
+
return {
|
|
242
|
+
prefix,
|
|
243
|
+
delimiter: options?.delimiter,
|
|
244
|
+
pageSize,
|
|
245
|
+
after
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function take(entries, request) {
|
|
249
|
+
const objects = [];
|
|
250
|
+
const prefixes = [];
|
|
251
|
+
let after;
|
|
252
|
+
for (const entry of entries) {
|
|
253
|
+
if (request.after !== void 0 && entry.key <= request.after) continue;
|
|
254
|
+
const pseudoDirectory = pseudoDirectoryOf(entry.key, request);
|
|
255
|
+
if (pseudoDirectory !== void 0 && prefixes.at(-1) === pseudoDirectory) {
|
|
256
|
+
after = entry.key;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (objects.length + prefixes.length === request.pageSize) return {
|
|
260
|
+
objects,
|
|
261
|
+
prefixes,
|
|
262
|
+
after
|
|
263
|
+
};
|
|
264
|
+
if (pseudoDirectory === void 0) objects.push(entry);
|
|
265
|
+
else prefixes.push(pseudoDirectory);
|
|
266
|
+
after = entry.key;
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
objects,
|
|
270
|
+
prefixes
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function pseudoDirectoryOf(key, { prefix, delimiter }) {
|
|
274
|
+
if (delimiter === void 0) return void 0;
|
|
275
|
+
const end = key.indexOf(delimiter, prefix.length);
|
|
276
|
+
return end === -1 ? void 0 : key.slice(0, end + delimiter.length);
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region src/errno.ts
|
|
280
|
+
const errnoMappings = /* @__PURE__ */ new Map([
|
|
281
|
+
["ENOENT", { code: "NotFound" }],
|
|
282
|
+
["EISDIR", {
|
|
283
|
+
code: "NotFound",
|
|
284
|
+
onWrite: "InvalidRequest"
|
|
285
|
+
}],
|
|
286
|
+
["ENOTDIR", {
|
|
287
|
+
code: "NotFound",
|
|
288
|
+
onWrite: "InvalidRequest"
|
|
289
|
+
}],
|
|
290
|
+
["EACCES", { code: "AccessDenied" }],
|
|
291
|
+
["EPERM", { code: "AccessDenied" }],
|
|
292
|
+
["ENAMETOOLONG", { code: "InvalidKey" }],
|
|
293
|
+
["EMFILE", {
|
|
294
|
+
code: "ProviderError",
|
|
295
|
+
retryable: true
|
|
296
|
+
}],
|
|
297
|
+
["EBUSY", {
|
|
298
|
+
code: "ProviderError",
|
|
299
|
+
retryable: true
|
|
300
|
+
}],
|
|
301
|
+
["EAGAIN", {
|
|
302
|
+
code: "ProviderError",
|
|
303
|
+
retryable: true
|
|
304
|
+
}]
|
|
305
|
+
]);
|
|
306
|
+
/**
|
|
307
|
+
* What the file system threw, as the error spec 6 names for it. The adapter repeats
|
|
308
|
+
* nothing, so the count is the one attempt the syscall made, and `retryable` says that
|
|
309
|
+
* the condition may be gone rather than that anything was sent twice (spec 4.10).
|
|
310
|
+
*/
|
|
311
|
+
function fsErrorFrom(thrown, failure) {
|
|
312
|
+
const errno = errnoOf(thrown);
|
|
313
|
+
const mapping = errno === void 0 ? void 0 : errnoMappings.get(errno);
|
|
314
|
+
const onWrite = failure.access === "write" ? mapping?.onWrite : void 0;
|
|
315
|
+
return fsError(failure.root, {
|
|
316
|
+
code: onWrite ?? mapping?.code ?? "ProviderError",
|
|
317
|
+
message: messageOf(thrown),
|
|
318
|
+
operation: failure.operation,
|
|
319
|
+
key: failure.key,
|
|
320
|
+
attempts: 1,
|
|
321
|
+
retryable: mapping?.retryable ?? false,
|
|
322
|
+
providerCode: errno,
|
|
323
|
+
cause: thrown
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
/** The `errno` string of a failed syscall, which travels on as the provider code. */
|
|
327
|
+
function errnoOf(thrown) {
|
|
328
|
+
if (typeof thrown !== "object" || thrown === null) return void 0;
|
|
329
|
+
const code = Reflect.get(thrown, "code");
|
|
330
|
+
return typeof code === "string" ? code : void 0;
|
|
331
|
+
}
|
|
332
|
+
/** The key names nothing this storage holds, which is what an absent file amounts to. */
|
|
333
|
+
function isAbsence(thrown) {
|
|
334
|
+
const errno = errnoOf(thrown);
|
|
335
|
+
return errno === "ENOENT" || errno === "ENOTDIR";
|
|
336
|
+
}
|
|
337
|
+
function messageOf(thrown) {
|
|
338
|
+
return thrown instanceof Error ? thrown.message : String(thrown);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* What a failed step reaches the caller as: the runtime's `AbortError` and a refusal this
|
|
342
|
+
* adapter already shaped travel as they are, and everything else is read off the table
|
|
343
|
+
* above (spec 4.10).
|
|
344
|
+
*/
|
|
345
|
+
function asFailure(thrown, failure) {
|
|
346
|
+
if (isStorageError(thrown) || nameOf(thrown) === "AbortError") return thrown;
|
|
347
|
+
return fsErrorFrom(thrown, failure);
|
|
348
|
+
}
|
|
349
|
+
function nameOf(thrown) {
|
|
350
|
+
if (typeof thrown !== "object" || thrown === null) return void 0;
|
|
351
|
+
const name = Reflect.get(thrown, "name");
|
|
352
|
+
return typeof name === "string" ? name : void 0;
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/paths.ts
|
|
356
|
+
/**
|
|
357
|
+
* The root as the file system resolves it, which every path an operation reaches is held
|
|
358
|
+
* against. It is read afresh per operation: construction performs no I/O, and a root
|
|
359
|
+
* that is gone by the time it is used is `NotFound` rather than a path of its own (spec 6).
|
|
360
|
+
*/
|
|
361
|
+
async function resolveRoot(root, operation, access) {
|
|
362
|
+
try {
|
|
363
|
+
return await realpath(root);
|
|
364
|
+
} catch (thrown) {
|
|
365
|
+
throw fsErrorFrom(thrown, {
|
|
366
|
+
root,
|
|
367
|
+
operation,
|
|
368
|
+
access
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** The path the key names below the root, or `undefined` where no file can carry it. */
|
|
373
|
+
function pathOf(realRoot, key) {
|
|
374
|
+
const segments = key.split("/");
|
|
375
|
+
if (segments.at(-1) === "") return void 0;
|
|
376
|
+
return join(realRoot, ...segments);
|
|
377
|
+
}
|
|
378
|
+
function within(realRoot, path) {
|
|
379
|
+
return path === realRoot || path.startsWith(`${realRoot}${sep}`);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* The key names nothing this storage holds, which is what a read of it answers. The count
|
|
383
|
+
* is what it cost to find that out: one lookup, or none where the key names no file at
|
|
384
|
+
* all (spec 4.10).
|
|
385
|
+
*/
|
|
386
|
+
function absent(context, attempts) {
|
|
387
|
+
return fsError(context.root, {
|
|
388
|
+
code: "NotFound",
|
|
389
|
+
message: `No object under the key ${JSON.stringify(context.key)}`,
|
|
390
|
+
operation: context.operation,
|
|
391
|
+
key: context.key,
|
|
392
|
+
attempts
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The real path of the file the key names. Spec 6 has every access resolve it and answer
|
|
397
|
+
* `NotFound` where it lies outside the root, so a symbolic link leaving the root behaves
|
|
398
|
+
* as an absent object rather than as a way out of the storage.
|
|
399
|
+
*/
|
|
400
|
+
async function resolveObject(context) {
|
|
401
|
+
const path = pathOf(context.realRoot, context.key);
|
|
402
|
+
if (path === void 0) throw absent(context, 0);
|
|
403
|
+
let resolved;
|
|
404
|
+
try {
|
|
405
|
+
resolved = await realpath(path);
|
|
406
|
+
} catch (thrown) {
|
|
407
|
+
throw fsErrorFrom(thrown, {
|
|
408
|
+
...context,
|
|
409
|
+
access: "read"
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (!within(context.realRoot, resolved)) throw absent(context, 1);
|
|
413
|
+
return resolved;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The path a write lands under, with the directories above it in place. The nearest
|
|
417
|
+
* existing directory is resolved before anything is created, so a link leaving the root
|
|
418
|
+
* cannot cause this storage to create directories outside it.
|
|
419
|
+
*/
|
|
420
|
+
async function prepareWrite(context) {
|
|
421
|
+
const path = pathOf(context.realRoot, context.key);
|
|
422
|
+
if (path === void 0) throw absent(context, 0);
|
|
423
|
+
const directory = await makeDirectory(dirname(path), context);
|
|
424
|
+
const target = join(directory, basename(path));
|
|
425
|
+
if (!await resolvesWithin(context, target)) throw absent(context, 1);
|
|
426
|
+
return target;
|
|
427
|
+
}
|
|
428
|
+
async function resolvesWithin(context, path) {
|
|
429
|
+
try {
|
|
430
|
+
return within(context.realRoot, await realpath(path));
|
|
431
|
+
} catch (thrown) {
|
|
432
|
+
if (isAbsence(thrown)) return true;
|
|
433
|
+
throw fsErrorFrom(thrown, {
|
|
434
|
+
...context,
|
|
435
|
+
access: "write"
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async function makeDirectory(directory, context) {
|
|
440
|
+
const missing = [];
|
|
441
|
+
let existing = directory;
|
|
442
|
+
for (;;) try {
|
|
443
|
+
existing = await realpath(existing);
|
|
444
|
+
break;
|
|
445
|
+
} catch (thrown) {
|
|
446
|
+
if (!isAbsence(thrown)) throw fsErrorFrom(thrown, {
|
|
447
|
+
...context,
|
|
448
|
+
access: "write"
|
|
449
|
+
});
|
|
450
|
+
missing.unshift(basename(existing));
|
|
451
|
+
existing = dirname(existing);
|
|
452
|
+
}
|
|
453
|
+
if (!within(context.realRoot, existing)) throw absent(context, 1);
|
|
454
|
+
for (const segment of missing) {
|
|
455
|
+
const path = join(existing, segment);
|
|
456
|
+
try {
|
|
457
|
+
await mkdir(path);
|
|
458
|
+
} catch (thrown) {
|
|
459
|
+
if (errnoOf(thrown) !== "EEXIST") throw fsErrorFrom(thrown, {
|
|
460
|
+
...context,
|
|
461
|
+
access: "write"
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
existing = await realpath(path);
|
|
466
|
+
} catch (thrown) {
|
|
467
|
+
throw fsErrorFrom(thrown, {
|
|
468
|
+
...context,
|
|
469
|
+
access: "write"
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (!within(context.realRoot, existing)) throw absent(context, 1);
|
|
473
|
+
}
|
|
474
|
+
return existing;
|
|
475
|
+
}
|
|
476
|
+
//#endregion
|
|
477
|
+
//#region src/object-file.ts
|
|
478
|
+
const directoryMutationLocks = /* @__PURE__ */ new Map();
|
|
479
|
+
const doNothing = () => {};
|
|
480
|
+
/**
|
|
481
|
+
* The file below the key, or `undefined` where the key names no object of this storage:
|
|
482
|
+
* nothing at all, a directory, or a path that leaves the root (spec 6). What a listing
|
|
483
|
+
* passes over is what these operations find nothing under.
|
|
484
|
+
*/
|
|
485
|
+
async function findObjectFile(context) {
|
|
486
|
+
const release = await holdDirectoryMutations(context.realRoot);
|
|
487
|
+
try {
|
|
488
|
+
const path = pathOf(context.realRoot, context.key);
|
|
489
|
+
if (path === void 0) {
|
|
490
|
+
release();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const directory = await resolveWithin(context, dirname(path));
|
|
494
|
+
if (directory === void 0) {
|
|
495
|
+
release();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const target = join(directory, basename(path));
|
|
499
|
+
const held = await describe$2(context, target);
|
|
500
|
+
if (held?.isFile() !== true) {
|
|
501
|
+
release();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
path: target,
|
|
506
|
+
stats: held,
|
|
507
|
+
release
|
|
508
|
+
};
|
|
509
|
+
} catch (thrown) {
|
|
510
|
+
release();
|
|
511
|
+
throw thrown;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/** Releases the parent-directory lease after a read-only use of the found object. */
|
|
515
|
+
function releaseObjectFile(file) {
|
|
516
|
+
file.release();
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Removes the file and the directories it leaves empty behind it, so that a listing with
|
|
520
|
+
* a delimiter names no pseudo-directory that holds nothing (spec 6).
|
|
521
|
+
*/
|
|
522
|
+
async function removeObjectFile(context, file) {
|
|
523
|
+
try {
|
|
524
|
+
try {
|
|
525
|
+
await unlink(file.path);
|
|
526
|
+
} catch (thrown) {
|
|
527
|
+
if (!isAbsence(thrown)) throw fsErrorFrom(thrown, {
|
|
528
|
+
...context,
|
|
529
|
+
access: "write"
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
await pruneEmptyDirectories(context.realRoot, dirname(file.path));
|
|
533
|
+
} finally {
|
|
534
|
+
file.release();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Moves the file to the path, which is the copy and the delete of spec 4.11 as one file
|
|
539
|
+
* system performs them, and removes what the source left empty behind it.
|
|
540
|
+
*/
|
|
541
|
+
async function renameObjectFile(context, file, path) {
|
|
542
|
+
try {
|
|
543
|
+
try {
|
|
544
|
+
await rename(file.path, path);
|
|
545
|
+
} catch (thrown) {
|
|
546
|
+
throw fsErrorFrom(thrown, {
|
|
547
|
+
...context,
|
|
548
|
+
access: "write"
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
await pruneEmptyDirectories(context.realRoot, dirname(file.path));
|
|
552
|
+
} finally {
|
|
553
|
+
file.release();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async function holdDirectoryMutations(realRoot) {
|
|
557
|
+
const lock = directoryMutationLocks.get(realRoot) ?? {
|
|
558
|
+
tail: Promise.resolve(),
|
|
559
|
+
leases: 0
|
|
560
|
+
};
|
|
561
|
+
const previous = lock.tail;
|
|
562
|
+
let releaseNext = doNothing;
|
|
563
|
+
lock.tail = new Promise((resolve) => {
|
|
564
|
+
releaseNext = resolve;
|
|
565
|
+
});
|
|
566
|
+
lock.leases += 1;
|
|
567
|
+
directoryMutationLocks.set(realRoot, lock);
|
|
568
|
+
await previous;
|
|
569
|
+
let held = true;
|
|
570
|
+
return () => {
|
|
571
|
+
if (!held) return;
|
|
572
|
+
held = false;
|
|
573
|
+
lock.leases -= 1;
|
|
574
|
+
releaseNext();
|
|
575
|
+
if (lock.leases === 0 && directoryMutationLocks.get(realRoot) === lock) directoryMutationLocks.delete(realRoot);
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
async function pruneEmptyDirectories(realRoot, directory) {
|
|
579
|
+
for (let current = directory; current !== realRoot && within(realRoot, current); current = dirname(current)) try {
|
|
580
|
+
await rmdir(current);
|
|
581
|
+
} catch {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/** What the path holds, or `undefined` where nothing is there or a link leaves the root. */
|
|
586
|
+
async function describe$2(context, path) {
|
|
587
|
+
const held = await lookUp(context, async () => await lstat(path));
|
|
588
|
+
if (held === void 0 || !held.isSymbolicLink()) return held;
|
|
589
|
+
const resolved = await resolveWithin(context, path);
|
|
590
|
+
return resolved === void 0 ? void 0 : await lookUp(context, async () => await stat(resolved));
|
|
591
|
+
}
|
|
592
|
+
/** The path as the file system resolves it, where that lies below the root (spec 6). */
|
|
593
|
+
async function resolveWithin(context, path) {
|
|
594
|
+
const resolved = await lookUp(context, async () => await realpath(path));
|
|
595
|
+
return resolved !== void 0 && within(context.realRoot, resolved) ? resolved : void 0;
|
|
596
|
+
}
|
|
597
|
+
/** A lookup whose answer for a path that is not there is that nothing is there. */
|
|
598
|
+
async function lookUp(context, read) {
|
|
599
|
+
try {
|
|
600
|
+
return await read();
|
|
601
|
+
} catch (thrown) {
|
|
602
|
+
if (isAbsence(thrown)) return void 0;
|
|
603
|
+
throw fsErrorFrom(thrown, {
|
|
604
|
+
...context,
|
|
605
|
+
access: "write"
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
//#endregion
|
|
610
|
+
//#region src/range.ts
|
|
611
|
+
/** Refuses bounds the spec does not allow, before the file is opened (spec 4.3). */
|
|
612
|
+
function requireRange(root, range) {
|
|
613
|
+
if (range === void 0) return;
|
|
614
|
+
const { start, end } = range;
|
|
615
|
+
if (!isOffset(start) || end !== void 0 && (!isOffset(end) || end < start)) throw optionError(root, "range", "takes two whole numbers from zero up, `start` at most `end`", "get");
|
|
616
|
+
}
|
|
617
|
+
/** The last byte the range names, both ends inclusive, clipped to what the file holds. */
|
|
618
|
+
function lastByteOf(root, range, size, key) {
|
|
619
|
+
if (range === void 0) return size - 1;
|
|
620
|
+
if (range.start >= size) throw fsError(root, {
|
|
621
|
+
code: "InvalidRequest",
|
|
622
|
+
message: `The range starts beyond the ${size} bytes under the key ${JSON.stringify(key)}`,
|
|
623
|
+
operation: "get",
|
|
624
|
+
key,
|
|
625
|
+
attempts: 1
|
|
626
|
+
});
|
|
627
|
+
return Math.min(range.end ?? size - 1, size - 1);
|
|
628
|
+
}
|
|
629
|
+
function isOffset(value) {
|
|
630
|
+
return Number.isInteger(value) && value >= 0;
|
|
631
|
+
}
|
|
632
|
+
//#endregion
|
|
633
|
+
//#region src/stored-object.ts
|
|
634
|
+
const chunkSize = 65536;
|
|
635
|
+
/**
|
|
636
|
+
* The object `get` hands back. The file is opened where a reader is taken rather than
|
|
637
|
+
* where the object was described, so a caller reading the description alone holds no
|
|
638
|
+
* open file — the rename `put` lands through is what keeps that reader off a half-written
|
|
639
|
+
* object (spec 6).
|
|
640
|
+
*/
|
|
641
|
+
function createStoredObject(context, stat, body) {
|
|
642
|
+
let read = false;
|
|
643
|
+
const take = () => {
|
|
644
|
+
if (read) throw fsError(context.root, {
|
|
645
|
+
code: "InvalidRequest",
|
|
646
|
+
message: "The body of this stored object has already been read",
|
|
647
|
+
operation: context.operation,
|
|
648
|
+
key: context.key,
|
|
649
|
+
attempts: 0
|
|
650
|
+
});
|
|
651
|
+
read = true;
|
|
652
|
+
return body;
|
|
653
|
+
};
|
|
654
|
+
const fail = (thrown) => asFailure(thrown, {
|
|
655
|
+
...context,
|
|
656
|
+
access: "read"
|
|
657
|
+
});
|
|
658
|
+
const decoder = new TextDecoder();
|
|
659
|
+
return {
|
|
660
|
+
stat,
|
|
661
|
+
stream() {
|
|
662
|
+
let taken;
|
|
663
|
+
try {
|
|
664
|
+
taken = take();
|
|
665
|
+
} catch (error) {
|
|
666
|
+
return new ReadableStream({ start(controller) {
|
|
667
|
+
controller.error(error);
|
|
668
|
+
} });
|
|
669
|
+
}
|
|
670
|
+
return readStream(taken, fail);
|
|
671
|
+
},
|
|
672
|
+
async bytes() {
|
|
673
|
+
return await readAll(take(), fail);
|
|
674
|
+
},
|
|
675
|
+
async text() {
|
|
676
|
+
return decoder.decode(await readAll(take(), fail));
|
|
677
|
+
},
|
|
678
|
+
async json() {
|
|
679
|
+
return JSON.parse(decoder.decode(await readAll(take(), fail)));
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function readStream(body, fail) {
|
|
684
|
+
let handle;
|
|
685
|
+
let position = body.start;
|
|
686
|
+
return new ReadableStream({
|
|
687
|
+
async pull(controller) {
|
|
688
|
+
const left = body.end - position + 1;
|
|
689
|
+
if (left <= 0) {
|
|
690
|
+
await close(handle);
|
|
691
|
+
controller.close();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const buffer = new Uint8Array(Math.min(left, chunkSize));
|
|
695
|
+
try {
|
|
696
|
+
handle ??= await openFile(body.path, fail);
|
|
697
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
|
|
698
|
+
if (bytesRead === 0) {
|
|
699
|
+
await close(handle);
|
|
700
|
+
controller.close();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
position += bytesRead;
|
|
704
|
+
controller.enqueue(buffer.subarray(0, bytesRead));
|
|
705
|
+
} catch (thrown) {
|
|
706
|
+
await close(handle);
|
|
707
|
+
throw fail(thrown);
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
async cancel() {
|
|
711
|
+
await close(handle);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
async function readAll(body, fail) {
|
|
716
|
+
const size = Math.max(body.end - body.start + 1, 0);
|
|
717
|
+
const bytes = new Uint8Array(size);
|
|
718
|
+
const handle = await openFile(body.path, fail);
|
|
719
|
+
let read = 0;
|
|
720
|
+
try {
|
|
721
|
+
while (read < size) {
|
|
722
|
+
const { bytesRead } = await handle.read(bytes, read, size - read, body.start + read);
|
|
723
|
+
if (bytesRead === 0) break;
|
|
724
|
+
read += bytesRead;
|
|
725
|
+
}
|
|
726
|
+
} catch (thrown) {
|
|
727
|
+
throw fail(thrown);
|
|
728
|
+
} finally {
|
|
729
|
+
await close(handle);
|
|
730
|
+
}
|
|
731
|
+
return read === size ? bytes : bytes.subarray(0, read);
|
|
732
|
+
}
|
|
733
|
+
async function openFile(path, fail) {
|
|
734
|
+
try {
|
|
735
|
+
return await open(path, "r");
|
|
736
|
+
} catch (thrown) {
|
|
737
|
+
throw fail(thrown);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
async function close(handle) {
|
|
741
|
+
await handle?.close().catch(() => {});
|
|
742
|
+
}
|
|
743
|
+
//#endregion
|
|
744
|
+
//#region src/walk.ts
|
|
745
|
+
/**
|
|
746
|
+
* Every object below the prefix, sorted by key. A listing pages through one order, so
|
|
747
|
+
* the snapshot it reads is sorted; the order itself is not promised (spec 4.6).
|
|
748
|
+
*/
|
|
749
|
+
async function walkObjects(context, prefix) {
|
|
750
|
+
const entries = [];
|
|
751
|
+
const directory = prefix.slice(0, prefix.lastIndexOf("/") + 1);
|
|
752
|
+
await collect(context, entries, join(context.realRoot, ...directory.split("/")), directory);
|
|
753
|
+
return entries.filter((entry) => entry.key.startsWith(prefix)).toSorted(byKey);
|
|
754
|
+
}
|
|
755
|
+
async function collect(context, entries, directory, keyPrefix) {
|
|
756
|
+
let resolved;
|
|
757
|
+
let held;
|
|
758
|
+
try {
|
|
759
|
+
resolved = await realpath(directory);
|
|
760
|
+
if (!within(context.realRoot, resolved)) return;
|
|
761
|
+
held = await readdir(resolved, { withFileTypes: true });
|
|
762
|
+
} catch (thrown) {
|
|
763
|
+
if (isAbsence(thrown)) return;
|
|
764
|
+
throw failure(context, thrown);
|
|
765
|
+
}
|
|
766
|
+
for (const entry of held) {
|
|
767
|
+
const path = join(resolved, entry.name);
|
|
768
|
+
const key = `${keyPrefix}${entry.name}`;
|
|
769
|
+
if (entry.isDirectory()) {
|
|
770
|
+
await collect(context, entries, path, `${key}/`);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (isTemporaryName(entry.name)) continue;
|
|
774
|
+
const described = await describe$1(context, path);
|
|
775
|
+
if (described !== void 0) entries.push({
|
|
776
|
+
key,
|
|
777
|
+
size: described.size,
|
|
778
|
+
lastModified: described.mtime
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* What the entry holds, or `undefined` for everything that is no object: a link leaving
|
|
784
|
+
* the root, a link to a directory, a socket, and a file another writer removed between
|
|
785
|
+
* the listing of the level and the reading of it.
|
|
786
|
+
*/
|
|
787
|
+
async function describe$1(context, path) {
|
|
788
|
+
try {
|
|
789
|
+
const resolved = await realpath(path);
|
|
790
|
+
if (!within(context.realRoot, resolved)) return void 0;
|
|
791
|
+
const described = await stat(resolved);
|
|
792
|
+
return described.isFile() ? described : void 0;
|
|
793
|
+
} catch (thrown) {
|
|
794
|
+
if (isAbsence(thrown)) return void 0;
|
|
795
|
+
throw failure(context, thrown);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function failure(context, thrown) {
|
|
799
|
+
return fsErrorFrom(thrown, {
|
|
800
|
+
root: context.root,
|
|
801
|
+
operation: context.operation,
|
|
802
|
+
access: "read"
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
function byKey(one, other) {
|
|
806
|
+
return one.key < other.key ? -1 : one.key > other.key ? 1 : 0;
|
|
807
|
+
}
|
|
808
|
+
//#endregion
|
|
809
|
+
//#region src/index.ts
|
|
810
|
+
function fsStorage(options) {
|
|
811
|
+
return new FileSystemStorage(options);
|
|
812
|
+
}
|
|
813
|
+
const fsCapabilities = Object.freeze(["rangeReads"]);
|
|
814
|
+
/** A storage that does not declare `userMetadata` reads back none of it (spec 4.9). */
|
|
815
|
+
const noUserMetadata = Object.freeze({});
|
|
816
|
+
var FileSystemStorage = class {
|
|
817
|
+
provider = "fs";
|
|
818
|
+
bucket;
|
|
819
|
+
capabilities = fsCapabilities;
|
|
820
|
+
#root;
|
|
821
|
+
constructor(options) {
|
|
822
|
+
this.#root = readRoot(options);
|
|
823
|
+
this.bucket = this.#root;
|
|
824
|
+
}
|
|
825
|
+
async put(key, body, options) {
|
|
826
|
+
try {
|
|
827
|
+
return await this.#put(key, body, options);
|
|
828
|
+
} catch (failure) {
|
|
829
|
+
await cancelBody(body, failure);
|
|
830
|
+
throw failure;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
async #put(key, body, options) {
|
|
834
|
+
requireKey(this.#root, key, "writable", "put");
|
|
835
|
+
requireKnownOptions(this.#root, options, putOptionKeys, "put");
|
|
836
|
+
this.#requireContentType(options?.contentType);
|
|
837
|
+
this.#requireNoUserMetadata(options?.userMetadata, key);
|
|
838
|
+
options?.signal?.throwIfAborted();
|
|
839
|
+
const context = await this.#context(key, "put", "write");
|
|
840
|
+
const path = await prepareWrite(context);
|
|
841
|
+
return await this.#write(context, path, body, options?.signal);
|
|
842
|
+
}
|
|
843
|
+
async #write(context, path, body, signal) {
|
|
844
|
+
return await this.#land(context, path, async (temporary) => {
|
|
845
|
+
const handle = await this.#openTemporary(context, temporary);
|
|
846
|
+
try {
|
|
847
|
+
await writeBody(handle, body, signal);
|
|
848
|
+
signal?.throwIfAborted();
|
|
849
|
+
const written = await handle.stat();
|
|
850
|
+
await handle.close();
|
|
851
|
+
return written;
|
|
852
|
+
} catch (thrown) {
|
|
853
|
+
await handle.close().catch(() => {});
|
|
854
|
+
throw thrown;
|
|
855
|
+
}
|
|
856
|
+
}, signal);
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Spec 6: the bytes land in a file beside the object and are renamed into place, so a
|
|
860
|
+
* reader sees the object as it was or as it now is and never half of a write. A write
|
|
861
|
+
* that broke takes its file with it, so that no listing names what never became one.
|
|
862
|
+
*/
|
|
863
|
+
async #land(context, path, fill, signal) {
|
|
864
|
+
const temporary = temporaryPathIn(dirname(path));
|
|
865
|
+
try {
|
|
866
|
+
const written = await fill(temporary);
|
|
867
|
+
signal?.throwIfAborted();
|
|
868
|
+
await rename(temporary, path);
|
|
869
|
+
return describe(context.key, written.size, written.mtime);
|
|
870
|
+
} catch (thrown) {
|
|
871
|
+
await unlink(temporary).catch(() => {});
|
|
872
|
+
throw asFailure(thrown, {
|
|
873
|
+
...context,
|
|
874
|
+
access: "write"
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
async get(key, options) {
|
|
879
|
+
requireKey(this.#root, key, "addressable", "get");
|
|
880
|
+
requireKnownOptions(this.#root, options, getOptionKeys, "get");
|
|
881
|
+
requireRange(this.#root, options?.range);
|
|
882
|
+
options?.signal?.throwIfAborted();
|
|
883
|
+
const context = await this.#context(key, "get", "read");
|
|
884
|
+
const found = await this.#find(context);
|
|
885
|
+
return createStoredObject(context, found.description, {
|
|
886
|
+
path: found.path,
|
|
887
|
+
start: options?.range?.start ?? 0,
|
|
888
|
+
end: lastByteOf(this.#root, options?.range, found.description.size, key)
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
async stat(key, options) {
|
|
892
|
+
requireKey(this.#root, key, "addressable", "stat");
|
|
893
|
+
requireKnownOptions(this.#root, options, operationOptionKeys, "stat");
|
|
894
|
+
options?.signal?.throwIfAborted();
|
|
895
|
+
return (await this.#find(await this.#context(key, "stat", "read"))).description;
|
|
896
|
+
}
|
|
897
|
+
async exists(key, options) {
|
|
898
|
+
requireKey(this.#root, key, "addressable", "exists");
|
|
899
|
+
requireKnownOptions(this.#root, options, operationOptionKeys, "exists");
|
|
900
|
+
options?.signal?.throwIfAborted();
|
|
901
|
+
const context = await this.#context(key, "exists", "read");
|
|
902
|
+
try {
|
|
903
|
+
await this.#find(context);
|
|
904
|
+
return true;
|
|
905
|
+
} catch (thrown) {
|
|
906
|
+
if (isStorageError(thrown) && thrown.code === "NotFound") return false;
|
|
907
|
+
throw thrown;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
list(options) {
|
|
911
|
+
return createListing(this.#root, async (prefix) => await walkObjects({
|
|
912
|
+
root: this.#root,
|
|
913
|
+
realRoot: await resolveRoot(this.#root, "list", "read"),
|
|
914
|
+
operation: "list"
|
|
915
|
+
}, prefix), options);
|
|
916
|
+
}
|
|
917
|
+
async delete(...keys) {
|
|
918
|
+
const context = {
|
|
919
|
+
root: this.#root,
|
|
920
|
+
realRoot: await resolveRoot(this.#root, "delete", "write"),
|
|
921
|
+
operation: "delete"
|
|
922
|
+
};
|
|
923
|
+
const failed = [];
|
|
924
|
+
for (const key of keys) {
|
|
925
|
+
const failure = await this.#remove({
|
|
926
|
+
...context,
|
|
927
|
+
key
|
|
928
|
+
});
|
|
929
|
+
if (failure !== void 0) failed.push(failure);
|
|
930
|
+
}
|
|
931
|
+
return {
|
|
932
|
+
requested: keys.length,
|
|
933
|
+
failed
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
async deleteAll(prefix, options) {
|
|
937
|
+
requireKey(this.#root, prefix, "prefix", "deleteAll");
|
|
938
|
+
requireKnownOptions(this.#root, options, operationOptionKeys, "deleteAll");
|
|
939
|
+
options?.signal?.throwIfAborted();
|
|
940
|
+
const context = {
|
|
941
|
+
root: this.#root,
|
|
942
|
+
realRoot: await resolveRoot(this.#root, "deleteAll", "write"),
|
|
943
|
+
operation: "deleteAll"
|
|
944
|
+
};
|
|
945
|
+
const entries = await walkObjects(context, prefix);
|
|
946
|
+
const failed = [];
|
|
947
|
+
for (const entry of entries) {
|
|
948
|
+
options?.signal?.throwIfAborted();
|
|
949
|
+
const failure = await this.#remove({
|
|
950
|
+
...context,
|
|
951
|
+
key: entry.key
|
|
952
|
+
});
|
|
953
|
+
if (failure !== void 0) failed.push(failure);
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
requested: entries.length,
|
|
957
|
+
failed
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
async #remove(context) {
|
|
961
|
+
try {
|
|
962
|
+
requireKey(this.#root, context.key, "addressable", context.operation);
|
|
963
|
+
const file = await findObjectFile(context);
|
|
964
|
+
if (file !== void 0) await removeObjectFile(context, file);
|
|
965
|
+
return;
|
|
966
|
+
} catch (thrown) {
|
|
967
|
+
if (isStorageError(thrown)) return thrown;
|
|
968
|
+
throw thrown;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
async copy(from, to, options) {
|
|
972
|
+
const ends = await this.#endpoints(from, to, "copy", options);
|
|
973
|
+
const source = await this.#requireFile(ends.from);
|
|
974
|
+
try {
|
|
975
|
+
const path = await prepareWrite(ends.to);
|
|
976
|
+
return await this.#land(ends.to, path, async (temporary) => {
|
|
977
|
+
await this.#read(ends.from, source.path, temporary);
|
|
978
|
+
return await stat(temporary);
|
|
979
|
+
}, options?.signal);
|
|
980
|
+
} finally {
|
|
981
|
+
releaseObjectFile(source);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/** Reads the source into the file the copy lands through, and names it in a failure. */
|
|
985
|
+
async #read(context, source, temporary) {
|
|
986
|
+
try {
|
|
987
|
+
await copyFile(source, temporary);
|
|
988
|
+
} catch (thrown) {
|
|
989
|
+
throw asFailure(thrown, {
|
|
990
|
+
...context,
|
|
991
|
+
access: "read"
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
async move(from, to, options) {
|
|
996
|
+
const ends = await this.#endpoints(from, to, "move", options);
|
|
997
|
+
const source = await this.#requireFile(ends.from);
|
|
998
|
+
try {
|
|
999
|
+
const path = await prepareWrite(ends.to);
|
|
1000
|
+
options?.signal?.throwIfAborted();
|
|
1001
|
+
await renameObjectFile(ends.from, source, path);
|
|
1002
|
+
return describe(to, source.stats.size, source.stats.mtime);
|
|
1003
|
+
} finally {
|
|
1004
|
+
releaseObjectFile(source);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/** Both ends of a `copy` or a `move`, once every rule of spec 4.11 has passed. */
|
|
1008
|
+
async #endpoints(from, to, operation, options) {
|
|
1009
|
+
requireKey(this.#root, from, "addressable", operation);
|
|
1010
|
+
requireKey(this.#root, to, "writable", operation);
|
|
1011
|
+
requireKnownOptions(this.#root, options, operationOptionKeys, operation);
|
|
1012
|
+
options?.signal?.throwIfAborted();
|
|
1013
|
+
if (from === to) throw fsError(this.#root, {
|
|
1014
|
+
code: "InvalidRequest",
|
|
1015
|
+
message: `The key ${JSON.stringify(from)} is both the source and the destination`,
|
|
1016
|
+
operation,
|
|
1017
|
+
key: from,
|
|
1018
|
+
attempts: 0
|
|
1019
|
+
});
|
|
1020
|
+
const realRoot = await resolveRoot(this.#root, operation, "write");
|
|
1021
|
+
return {
|
|
1022
|
+
from: {
|
|
1023
|
+
root: this.#root,
|
|
1024
|
+
realRoot,
|
|
1025
|
+
key: from,
|
|
1026
|
+
operation
|
|
1027
|
+
},
|
|
1028
|
+
to: {
|
|
1029
|
+
root: this.#root,
|
|
1030
|
+
realRoot,
|
|
1031
|
+
key: to,
|
|
1032
|
+
operation
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
/** The file the source key names, which `copy` and `move` owe a `NotFound` for. */
|
|
1037
|
+
async #requireFile(context) {
|
|
1038
|
+
const file = await findObjectFile(context);
|
|
1039
|
+
if (file !== void 0) return file;
|
|
1040
|
+
throw absent(context, context.key.endsWith("/") ? 0 : 1);
|
|
1041
|
+
}
|
|
1042
|
+
/** The file the key names, described as spec 4.4 has a read of one describe it. */
|
|
1043
|
+
async #find(context) {
|
|
1044
|
+
const path = await resolveObject(context);
|
|
1045
|
+
try {
|
|
1046
|
+
const described = await stat(path);
|
|
1047
|
+
if (!described.isFile()) throw absent(context, 1);
|
|
1048
|
+
return {
|
|
1049
|
+
path,
|
|
1050
|
+
description: describe(context.key, described.size, described.mtime)
|
|
1051
|
+
};
|
|
1052
|
+
} catch (thrown) {
|
|
1053
|
+
throw asFailure(thrown, {
|
|
1054
|
+
...context,
|
|
1055
|
+
access: "read"
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
async #context(key, operation, access) {
|
|
1060
|
+
return {
|
|
1061
|
+
root: this.#root,
|
|
1062
|
+
realRoot: await resolveRoot(this.#root, operation, access),
|
|
1063
|
+
key,
|
|
1064
|
+
operation
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
async #openTemporary(context, path) {
|
|
1068
|
+
try {
|
|
1069
|
+
return await open(path, "wx");
|
|
1070
|
+
} catch (thrown) {
|
|
1071
|
+
throw asFailure(thrown, {
|
|
1072
|
+
...context,
|
|
1073
|
+
access: "write"
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
/** Spec 6 validates the type `put` was given and derives the stored one from the key. */
|
|
1078
|
+
#requireContentType(contentType) {
|
|
1079
|
+
if (contentType === void 0 || typeof contentType === "string") return;
|
|
1080
|
+
throw optionError(this.#root, "contentType", "takes a string", "put");
|
|
1081
|
+
}
|
|
1082
|
+
#requireNoUserMetadata(userMetadata, key) {
|
|
1083
|
+
if (userMetadata === void 0 || Object.keys(userMetadata).length === 0) return;
|
|
1084
|
+
throw fsError(this.#root, {
|
|
1085
|
+
code: "Unsupported",
|
|
1086
|
+
message: "This storage holds no user metadata",
|
|
1087
|
+
operation: "put",
|
|
1088
|
+
key,
|
|
1089
|
+
attempts: 0,
|
|
1090
|
+
capability: "userMetadata"
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
function readRoot(options) {
|
|
1095
|
+
const root = options.root;
|
|
1096
|
+
const named = typeof root === "string" ? root : "";
|
|
1097
|
+
requireKnownOptions(named, options, adapterOptionKeys, "fsStorage");
|
|
1098
|
+
if (typeof root !== "string" || !isAbsolute(root)) throw optionError(named, "root", "takes an absolute path to a directory", "fsStorage");
|
|
1099
|
+
return root;
|
|
1100
|
+
}
|
|
1101
|
+
function describe(key, size, lastModified) {
|
|
1102
|
+
return {
|
|
1103
|
+
key,
|
|
1104
|
+
size,
|
|
1105
|
+
lastModified,
|
|
1106
|
+
contentType: contentTypeOf(key),
|
|
1107
|
+
userMetadata: noUserMetadata
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
//#endregion
|
|
1111
|
+
export { fsStorage };
|
package/package.json
CHANGED
|
@@ -1 +1,41 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@stowage/adapter-fs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "stowage adapter rooting a storage in one directory of the local file system.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"adapter",
|
|
7
|
+
"filesystem",
|
|
8
|
+
"fs",
|
|
9
|
+
"object-storage",
|
|
10
|
+
"storage",
|
|
11
|
+
"stowage"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/stowage-js/stowage/tree/main/packages/adapter-fs#readme",
|
|
14
|
+
"bugs": "https://github.com/stowage-js/stowage/issues",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Alexander Kaufmann",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/stowage-js/stowage.git",
|
|
20
|
+
"directory": "packages/adapter-fs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./dist/index.js",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"provenance": true
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@stowage/core": "^0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=24"
|
|
40
|
+
}
|
|
41
|
+
}
|