@lenso/storage 0.0.0-stage → 0.1.1
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 +144 -2
- package/dist/fetch.d.ts +18 -0
- package/dist/fetch.js +98 -0
- package/dist/files.d.ts +68 -0
- package/dist/files.js +237 -0
- package/dist/index-0fz1t930.js +64 -0
- package/dist/index-fa6z0g4w.js +89 -0
- package/dist/index.d.ts +116 -0
- package/dist/index.js +20 -0
- package/dist/local.d.ts +11 -0
- package/dist/local.js +457 -0
- package/dist/postgres.d.ts +567 -0
- package/dist/postgres.js +45 -0
- package/dist/r2.d.ts +52 -0
- package/dist/r2.js +188 -0
- package/dist/s3.d.ts +16 -0
- package/dist/s3.js +386 -0
- package/dist/sqlite.d.ts +602 -0
- package/dist/sqlite.js +45 -0
- package/dist/stream.d.ts +8 -0
- package/migrations/postgres/0001_files.sql +19 -0
- package/migrations/sqlite/0001_files.sql +19 -0
- package/package.json +84 -4
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
StorageError2,
|
|
3
|
+
validateContentType2
|
|
4
|
+
} from "./index-0fz1t930.js";
|
|
5
|
+
|
|
6
|
+
// src/stream.ts
|
|
7
|
+
function validateUpload(input) {
|
|
8
|
+
if (input.contentType !== undefined)
|
|
9
|
+
validateContentType2(input.contentType);
|
|
10
|
+
for (const [name, value] of [
|
|
11
|
+
["size", input.size],
|
|
12
|
+
["maxBytes", input.maxBytes]
|
|
13
|
+
]) {
|
|
14
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
|
|
15
|
+
throw new StorageError2("invalid-input", `${name} must be a nonnegative safe integer`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (input.size !== undefined && input.maxBytes !== undefined && input.size > input.maxBytes) {
|
|
19
|
+
throw new StorageError2("too-large", "Upload exceeds the size limit");
|
|
20
|
+
}
|
|
21
|
+
input.signal?.throwIfAborted();
|
|
22
|
+
}
|
|
23
|
+
function checkedUpload(input) {
|
|
24
|
+
validateUpload(input);
|
|
25
|
+
const reader = input.body.getReader();
|
|
26
|
+
let bytes = 0;
|
|
27
|
+
let ended = false;
|
|
28
|
+
let cancellation;
|
|
29
|
+
let controller;
|
|
30
|
+
const detach = () => input.signal?.removeEventListener("abort", abort);
|
|
31
|
+
function cancel(reason) {
|
|
32
|
+
if (cancellation)
|
|
33
|
+
return cancellation;
|
|
34
|
+
if (ended)
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
ended = true;
|
|
37
|
+
detach();
|
|
38
|
+
cancellation = reader.cancel(reason).catch((error) => {
|
|
39
|
+
if (error !== reason)
|
|
40
|
+
throw error;
|
|
41
|
+
}).finally(() => reader.releaseLock());
|
|
42
|
+
return cancellation;
|
|
43
|
+
}
|
|
44
|
+
function abort() {
|
|
45
|
+
const error = new StorageError2("aborted", "Upload cancelled");
|
|
46
|
+
controller.error(error);
|
|
47
|
+
cancel(error).catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
const body = new ReadableStream({
|
|
50
|
+
start(value) {
|
|
51
|
+
controller = value;
|
|
52
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
53
|
+
if (input.signal?.aborted)
|
|
54
|
+
abort();
|
|
55
|
+
},
|
|
56
|
+
async pull(value) {
|
|
57
|
+
try {
|
|
58
|
+
const chunk = await reader.read();
|
|
59
|
+
if (ended)
|
|
60
|
+
return;
|
|
61
|
+
if (chunk.done) {
|
|
62
|
+
if (input.size !== undefined && bytes !== input.size) {
|
|
63
|
+
throw new StorageError2("invalid-input", "Upload size differs from declared size");
|
|
64
|
+
}
|
|
65
|
+
ended = true;
|
|
66
|
+
detach();
|
|
67
|
+
reader.releaseLock();
|
|
68
|
+
value.close();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
bytes += chunk.value.byteLength;
|
|
72
|
+
if (input.maxBytes !== undefined && bytes > input.maxBytes) {
|
|
73
|
+
throw new StorageError2("too-large", "Upload exceeds the size limit");
|
|
74
|
+
}
|
|
75
|
+
if (input.size !== undefined && bytes > input.size) {
|
|
76
|
+
throw new StorageError2("invalid-input", "Upload exceeds its declared size");
|
|
77
|
+
}
|
|
78
|
+
value.enqueue(chunk.value);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
value.error(error);
|
|
81
|
+
await cancel(error).catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
cancel
|
|
85
|
+
}, { highWaterMark: 0 });
|
|
86
|
+
return { body, size: () => bytes, cancel };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { validateUpload, checkedUpload };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type Plugin, type PluginContext } from "@lenso/core/plugin";
|
|
2
|
+
export type StorageErrorCode = "invalid-key" | "invalid-input" | "not-found" | "forbidden" | "unsupported" | "conflict" | "too-large" | "aborted" | "provider";
|
|
3
|
+
export declare class StorageError extends Error {
|
|
4
|
+
readonly code: StorageErrorCode;
|
|
5
|
+
constructor(code: StorageErrorCode, message: string, options?: ErrorOptions);
|
|
6
|
+
}
|
|
7
|
+
export interface StorageCapabilities {
|
|
8
|
+
readonly provider: "local" | "s3" | "r2-binding";
|
|
9
|
+
readonly signedUpload: boolean;
|
|
10
|
+
readonly signedDownload: boolean;
|
|
11
|
+
readonly rangeRead: boolean;
|
|
12
|
+
readonly conditionalRead: boolean;
|
|
13
|
+
readonly pagination: "key" | "opaque";
|
|
14
|
+
readonly uploadCancellation: "abort" | "stream-only";
|
|
15
|
+
readonly uploadRequiresSize: boolean;
|
|
16
|
+
readonly maxUploadBytes?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ObjectMetadata {
|
|
19
|
+
key: string;
|
|
20
|
+
size: number;
|
|
21
|
+
contentType: string;
|
|
22
|
+
etag?: string;
|
|
23
|
+
lastModified?: Date;
|
|
24
|
+
versionId?: string;
|
|
25
|
+
customMetadata?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
/** Uploads create a new key, never replace an existing object. */
|
|
28
|
+
export interface PutObjectInput {
|
|
29
|
+
key: string;
|
|
30
|
+
body: ReadableStream<Uint8Array>;
|
|
31
|
+
contentType?: string;
|
|
32
|
+
size?: number;
|
|
33
|
+
maxBytes?: number;
|
|
34
|
+
customMetadata?: Record<string, string>;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
export interface ReadObjectOptions {
|
|
38
|
+
range?: {
|
|
39
|
+
offset: number;
|
|
40
|
+
length?: number;
|
|
41
|
+
};
|
|
42
|
+
ifMatch?: string;
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
}
|
|
45
|
+
export interface ObjectDownload {
|
|
46
|
+
metadata: ObjectMetadata;
|
|
47
|
+
body: ReadableStream<Uint8Array>;
|
|
48
|
+
range?: {
|
|
49
|
+
offset: number;
|
|
50
|
+
length: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export interface ListObjectsInput {
|
|
54
|
+
prefix?: string;
|
|
55
|
+
cursor?: string;
|
|
56
|
+
limit?: number;
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
}
|
|
59
|
+
export interface ObjectPage {
|
|
60
|
+
objects: ObjectMetadata[];
|
|
61
|
+
cursor?: string;
|
|
62
|
+
}
|
|
63
|
+
/** URLs and headers are temporary credentials. Never log this result. */
|
|
64
|
+
export interface SignedObjectLink {
|
|
65
|
+
url: string;
|
|
66
|
+
method: "GET" | "PUT";
|
|
67
|
+
headers: Record<string, string>;
|
|
68
|
+
expiresAt: Date;
|
|
69
|
+
conditions: {
|
|
70
|
+
contentType?: string;
|
|
71
|
+
ifMatch?: string;
|
|
72
|
+
createOnly?: boolean;
|
|
73
|
+
maxBytes?: number;
|
|
74
|
+
sizeEnforcement: "completion-check" | "none";
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export interface SignUploadInput {
|
|
78
|
+
key: string;
|
|
79
|
+
expiresIn: number;
|
|
80
|
+
contentType: string;
|
|
81
|
+
maxBytes?: number;
|
|
82
|
+
}
|
|
83
|
+
export interface SignDownloadInput {
|
|
84
|
+
key: string;
|
|
85
|
+
expiresIn: number;
|
|
86
|
+
ifMatch?: string;
|
|
87
|
+
}
|
|
88
|
+
export interface ObjectStorage {
|
|
89
|
+
readonly id: string;
|
|
90
|
+
readonly capabilities: StorageCapabilities;
|
|
91
|
+
put(input: PutObjectInput): Promise<ObjectMetadata>;
|
|
92
|
+
get(key: string, options?: ReadObjectOptions): Promise<ObjectDownload>;
|
|
93
|
+
head(key: string, options?: {
|
|
94
|
+
signal?: AbortSignal;
|
|
95
|
+
}): Promise<ObjectMetadata | null>;
|
|
96
|
+
/** Idempotent. "absent-or-deleted" backends cannot atomically report prior existence. */
|
|
97
|
+
delete(key: string, options?: {
|
|
98
|
+
signal?: AbortSignal;
|
|
99
|
+
}): Promise<{
|
|
100
|
+
outcome: "deleted" | "not-found" | "absent-or-deleted";
|
|
101
|
+
}>;
|
|
102
|
+
list(input?: ListObjectsInput): Promise<ObjectPage>;
|
|
103
|
+
signUpload(input: SignUploadInput): Promise<SignedObjectLink>;
|
|
104
|
+
signDownload(input: SignDownloadInput): Promise<SignedObjectLink>;
|
|
105
|
+
}
|
|
106
|
+
export declare function createStoragePlugin(options: {
|
|
107
|
+
id: string;
|
|
108
|
+
requires?: readonly Plugin<unknown>[];
|
|
109
|
+
setup(context: PluginContext): Omit<ObjectStorage, "id"> | Promise<Omit<ObjectStorage, "id">>;
|
|
110
|
+
}): Plugin<ObjectStorage>;
|
|
111
|
+
export declare function validateKey(key: string): void;
|
|
112
|
+
export declare function validateContentType(contentType: string): void;
|
|
113
|
+
export declare function validateList(input: ListObjectsInput): number;
|
|
114
|
+
export declare function validateRange(options: ReadObjectOptions, supported: boolean): void;
|
|
115
|
+
export declare function validateExpiry(expiresIn: number): void;
|
|
116
|
+
export declare function unsupportedSigning(): never;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
StorageError2,
|
|
3
|
+
createStoragePlugin2,
|
|
4
|
+
validateKey2,
|
|
5
|
+
validateContentType2,
|
|
6
|
+
validateList2,
|
|
7
|
+
validateRange2,
|
|
8
|
+
validateExpiry2,
|
|
9
|
+
unsupportedSigning2
|
|
10
|
+
} from "./index-0fz1t930.js";
|
|
11
|
+
export {
|
|
12
|
+
StorageError2 as StorageError,
|
|
13
|
+
createStoragePlugin2 as createStoragePlugin,
|
|
14
|
+
unsupportedSigning2 as unsupportedSigning,
|
|
15
|
+
validateContentType2 as validateContentType,
|
|
16
|
+
validateExpiry2 as validateExpiry,
|
|
17
|
+
validateKey2 as validateKey,
|
|
18
|
+
validateList2 as validateList,
|
|
19
|
+
validateRange2 as validateRange
|
|
20
|
+
};
|
package/dist/local.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use a trusted, dedicated directory, not a user-writable shared tree.
|
|
3
|
+
* O_NOFOLLOW protects opened files; portable path APIs cannot prevent concurrent
|
|
4
|
+
* hostile writers from swapping ancestor directories between checks and use.
|
|
5
|
+
* Each SHA-256 key directory contains payload and metadata.json. A nonempty
|
|
6
|
+
* directory rename publishes both atomically and cannot replace another object.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createLocalStoragePlugin(options: {
|
|
9
|
+
id: string;
|
|
10
|
+
root: string;
|
|
11
|
+
}): import("@lenso/core/plugin").Plugin<import("./index").ObjectStorage>;
|
package/dist/local.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import {
|
|
2
|
+
StorageError2,
|
|
3
|
+
createStoragePlugin2,
|
|
4
|
+
validateKey2,
|
|
5
|
+
validateList2,
|
|
6
|
+
validateRange2,
|
|
7
|
+
unsupportedSigning2
|
|
8
|
+
} from "./index-0fz1t930.js";
|
|
9
|
+
import {
|
|
10
|
+
checkedUpload
|
|
11
|
+
} from "./index-fa6z0g4w.js";
|
|
12
|
+
|
|
13
|
+
// src/local.ts
|
|
14
|
+
import { constants } from "node:fs";
|
|
15
|
+
import {
|
|
16
|
+
lstat,
|
|
17
|
+
mkdir,
|
|
18
|
+
mkdtemp,
|
|
19
|
+
open,
|
|
20
|
+
readdir,
|
|
21
|
+
rename,
|
|
22
|
+
rm
|
|
23
|
+
} from "node:fs/promises";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { dirname, join, parse, resolve } from "node:path";
|
|
26
|
+
var objectName = (key) => createHash("sha256").update(key).digest("hex");
|
|
27
|
+
var temporary = /^\.tmp-[A-Za-z0-9]+$/;
|
|
28
|
+
var metadataLimit = 64 * 1024;
|
|
29
|
+
function code(error) {
|
|
30
|
+
return error?.code;
|
|
31
|
+
}
|
|
32
|
+
function storageError(error) {
|
|
33
|
+
if (error instanceof StorageError2)
|
|
34
|
+
return error;
|
|
35
|
+
const value = code(error);
|
|
36
|
+
return new StorageError2(value === "ENOENT" ? "not-found" : value === "ELOOP" || value === "EACCES" || value === "EPERM" ? "forbidden" : error?.name === "AbortError" ? "aborted" : "provider", "Local storage operation failed", { cause: error });
|
|
37
|
+
}
|
|
38
|
+
async function directory(path) {
|
|
39
|
+
let stat;
|
|
40
|
+
try {
|
|
41
|
+
stat = await lstat(path);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw storageError(error);
|
|
44
|
+
}
|
|
45
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
46
|
+
throw new StorageError2("forbidden", "Storage directories must not be symlinks");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function ancestry(root) {
|
|
50
|
+
const paths = [];
|
|
51
|
+
for (let path = root;; path = dirname(path)) {
|
|
52
|
+
paths.unshift(path);
|
|
53
|
+
if (path === parse(path).root)
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
for (const path of paths)
|
|
57
|
+
await directory(path);
|
|
58
|
+
}
|
|
59
|
+
function createLocalStoragePlugin(options) {
|
|
60
|
+
return createStoragePlugin2({
|
|
61
|
+
id: options.id,
|
|
62
|
+
async setup(context) {
|
|
63
|
+
const root = resolve(options.root);
|
|
64
|
+
const lifetime = new AbortController;
|
|
65
|
+
const handles = new Set;
|
|
66
|
+
const operations = new Set;
|
|
67
|
+
context.onCleanup(async () => {
|
|
68
|
+
lifetime.abort();
|
|
69
|
+
const results = await Promise.allSettled(operations);
|
|
70
|
+
const closed = await Promise.allSettled([...handles].map((handle) => handle.close()));
|
|
71
|
+
handles.clear();
|
|
72
|
+
const failures = [...results, ...closed].filter((result) => result.status === "rejected" && !(result.reason instanceof StorageError2 && result.reason.code === "aborted" && !(result.reason.cause instanceof AggregateError)));
|
|
73
|
+
if (failures.length)
|
|
74
|
+
throw new StorageError2("provider", "Local storage cleanup failed", {
|
|
75
|
+
cause: new AggregateError(failures.map((failure) => failure.reason))
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
const paths = [];
|
|
79
|
+
for (let path = root;path !== parse(path).root; path = dirname(path))
|
|
80
|
+
paths.unshift(path);
|
|
81
|
+
await directory(parse(root).root);
|
|
82
|
+
for (const path of paths) {
|
|
83
|
+
try {
|
|
84
|
+
await mkdir(path, { mode: 448 });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (code(error) !== "EEXIST")
|
|
87
|
+
throw storageError(error);
|
|
88
|
+
}
|
|
89
|
+
await directory(path);
|
|
90
|
+
}
|
|
91
|
+
const signalFor = (signal) => signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
|
|
92
|
+
function run(signal, action) {
|
|
93
|
+
const task = (async () => {
|
|
94
|
+
try {
|
|
95
|
+
const combined = signalFor(signal);
|
|
96
|
+
combined.throwIfAborted();
|
|
97
|
+
await ancestry(root);
|
|
98
|
+
return await action(combined);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw storageError(error);
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
operations.add(task);
|
|
104
|
+
task.finally(() => operations.delete(task)).catch(() => {});
|
|
105
|
+
return task;
|
|
106
|
+
}
|
|
107
|
+
async function safeOpen(path) {
|
|
108
|
+
let handle;
|
|
109
|
+
try {
|
|
110
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (code(error) === "ENOENT")
|
|
113
|
+
throw new StorageError2("provider", "Incomplete stored object", { cause: error });
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
handles.add(handle);
|
|
117
|
+
try {
|
|
118
|
+
const stat = await handle.stat();
|
|
119
|
+
if (!stat.isFile() || stat.nlink !== 1) {
|
|
120
|
+
throw new StorageError2("forbidden", "Storage files must be regular files without additional hard links");
|
|
121
|
+
}
|
|
122
|
+
return handle;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
await close(handle, error);
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function close(handle, failure) {
|
|
129
|
+
try {
|
|
130
|
+
await handle.close();
|
|
131
|
+
handles.delete(handle);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new StorageError2(failure instanceof StorageError2 ? failure.code : "provider", "Storage file cleanup failed", {
|
|
134
|
+
cause: new AggregateError([...failure === undefined ? [] : [failure], error])
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function load(name, expectedKey) {
|
|
139
|
+
const path = join(root, name);
|
|
140
|
+
await directory(path);
|
|
141
|
+
const identity = await lstat(path);
|
|
142
|
+
const entries = await readdir(path);
|
|
143
|
+
if (entries.length !== 2 || !entries.includes("payload") || !entries.includes("metadata.json")) {
|
|
144
|
+
throw new StorageError2("forbidden", "Unexpected object directory entries");
|
|
145
|
+
}
|
|
146
|
+
const metadataFile = await safeOpen(join(path, "metadata.json"));
|
|
147
|
+
let metadata;
|
|
148
|
+
try {
|
|
149
|
+
if ((await metadataFile.stat()).size > metadataLimit) {
|
|
150
|
+
throw new StorageError2("provider", "Object metadata exceeds its representation limit");
|
|
151
|
+
}
|
|
152
|
+
const value = JSON.parse(await metadataFile.readFile("utf8"));
|
|
153
|
+
if (typeof value.key !== "string")
|
|
154
|
+
throw new StorageError2("provider", "Invalid stored key");
|
|
155
|
+
validateKey2(value.key);
|
|
156
|
+
if (objectName(value.key) !== name || expectedKey !== undefined && value.key !== expectedKey || !Number.isSafeInteger(value.size) || value.size < 0 || typeof value.contentType !== "string" || !/^"[a-f0-9]{64}"$/.test(value.etag) || typeof value.lastModified !== "string" || !Number.isFinite(Date.parse(value.lastModified)) || value.customMetadata !== undefined && (value.customMetadata === null || typeof value.customMetadata !== "object" || Array.isArray(value.customMetadata) || Object.values(value.customMetadata).some((entry) => typeof entry !== "string"))) {
|
|
157
|
+
throw new StorageError2("provider", "Invalid stored metadata");
|
|
158
|
+
}
|
|
159
|
+
metadata = { ...value, lastModified: new Date(value.lastModified) };
|
|
160
|
+
} finally {
|
|
161
|
+
await close(metadataFile);
|
|
162
|
+
}
|
|
163
|
+
const payload = await safeOpen(join(path, "payload"));
|
|
164
|
+
try {
|
|
165
|
+
if ((await payload.stat()).size !== metadata.size) {
|
|
166
|
+
throw new StorageError2("provider", "Stored payload size differs from metadata");
|
|
167
|
+
}
|
|
168
|
+
const current = await lstat(path);
|
|
169
|
+
if (current.dev !== identity.dev || current.ino !== identity.ino || current.isSymbolicLink()) {
|
|
170
|
+
throw new StorageError2("conflict", "Object changed while opening its snapshot");
|
|
171
|
+
}
|
|
172
|
+
return { metadata, payload };
|
|
173
|
+
} catch (error) {
|
|
174
|
+
await close(payload, error);
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function put(input, signal) {
|
|
179
|
+
validateKey2(input.key);
|
|
180
|
+
const contentType = input.contentType ?? "application/octet-stream";
|
|
181
|
+
if (typeof contentType !== "string" || input.customMetadata !== undefined && (input.customMetadata === null || typeof input.customMetadata !== "object" || Array.isArray(input.customMetadata) || Object.values(input.customMetadata).some((value) => typeof value !== "string"))) {
|
|
182
|
+
throw new StorageError2("invalid-input", "Invalid upload metadata");
|
|
183
|
+
}
|
|
184
|
+
const upload = checkedUpload({ ...input, signal });
|
|
185
|
+
let temp;
|
|
186
|
+
let failure;
|
|
187
|
+
try {
|
|
188
|
+
temp = await mkdtemp(join(root, ".tmp-"));
|
|
189
|
+
const payload = await open(join(temp, "payload"), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
190
|
+
handles.add(payload);
|
|
191
|
+
const hash = createHash("sha256");
|
|
192
|
+
const reader = upload.body.getReader();
|
|
193
|
+
try {
|
|
194
|
+
for (;; ) {
|
|
195
|
+
const chunk = await reader.read();
|
|
196
|
+
if (chunk.done)
|
|
197
|
+
break;
|
|
198
|
+
hash.update(chunk.value);
|
|
199
|
+
let offset = 0;
|
|
200
|
+
while (offset < chunk.value.byteLength) {
|
|
201
|
+
signal.throwIfAborted();
|
|
202
|
+
const { bytesWritten } = await payload.write(chunk.value, offset, chunk.value.byteLength - offset);
|
|
203
|
+
if (!bytesWritten)
|
|
204
|
+
throw new StorageError2("provider", "Upload write made no progress");
|
|
205
|
+
offset += bytesWritten;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
await payload.sync();
|
|
209
|
+
} catch (error) {
|
|
210
|
+
failure = storageError(error);
|
|
211
|
+
throw failure;
|
|
212
|
+
} finally {
|
|
213
|
+
reader.releaseLock();
|
|
214
|
+
await close(payload, failure);
|
|
215
|
+
}
|
|
216
|
+
const metadata = {
|
|
217
|
+
key: input.key,
|
|
218
|
+
size: upload.size(),
|
|
219
|
+
contentType,
|
|
220
|
+
etag: `"${hash.digest("hex")}"`,
|
|
221
|
+
lastModified: new Date,
|
|
222
|
+
...input.customMetadata === undefined ? {} : { customMetadata: { ...input.customMetadata } }
|
|
223
|
+
};
|
|
224
|
+
const serialized = JSON.stringify(metadata);
|
|
225
|
+
if (Buffer.byteLength(serialized) > metadataLimit)
|
|
226
|
+
throw new StorageError2("invalid-input", "Upload metadata is too large");
|
|
227
|
+
const file = await open(join(temp, "metadata.json"), "wx", 384);
|
|
228
|
+
handles.add(file);
|
|
229
|
+
try {
|
|
230
|
+
await file.writeFile(serialized);
|
|
231
|
+
await file.sync();
|
|
232
|
+
} finally {
|
|
233
|
+
await close(file);
|
|
234
|
+
}
|
|
235
|
+
signal.throwIfAborted();
|
|
236
|
+
await ancestry(root);
|
|
237
|
+
const destination = join(root, objectName(input.key));
|
|
238
|
+
try {
|
|
239
|
+
await lstat(destination);
|
|
240
|
+
await directory(destination);
|
|
241
|
+
throw new StorageError2("conflict", "Object already exists");
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (code(error) !== "ENOENT")
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
await rename(temp, destination);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (["EEXIST", "ENOTEMPTY"].includes(code(error) ?? ""))
|
|
250
|
+
throw new StorageError2("conflict", "Object already exists", { cause: error });
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
temp = undefined;
|
|
254
|
+
return metadata;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
failure = storageError(error);
|
|
257
|
+
throw failure;
|
|
258
|
+
} finally {
|
|
259
|
+
const cleanup = await Promise.allSettled([
|
|
260
|
+
upload.cancel(failure),
|
|
261
|
+
...temp ? [rm(temp, { recursive: true })] : []
|
|
262
|
+
]);
|
|
263
|
+
const errors = cleanup.filter((result) => result.status === "rejected");
|
|
264
|
+
if (errors.length) {
|
|
265
|
+
throw new StorageError2(failure instanceof StorageError2 ? failure.code : "provider", "Upload cleanup failed", {
|
|
266
|
+
cause: new AggregateError([
|
|
267
|
+
...failure ? [failure] : [],
|
|
268
|
+
...errors.map((error) => error.reason)
|
|
269
|
+
])
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
capabilities: {
|
|
276
|
+
provider: "local",
|
|
277
|
+
signedUpload: false,
|
|
278
|
+
signedDownload: false,
|
|
279
|
+
rangeRead: true,
|
|
280
|
+
conditionalRead: true,
|
|
281
|
+
pagination: "key",
|
|
282
|
+
uploadCancellation: "abort",
|
|
283
|
+
uploadRequiresSize: false
|
|
284
|
+
},
|
|
285
|
+
put: (input) => run(input.signal, (signal) => put(input, signal)),
|
|
286
|
+
get: (key, readOptions = {}) => run(readOptions.signal, async (signal) => {
|
|
287
|
+
validateKey2(key);
|
|
288
|
+
validateRange2(readOptions, true);
|
|
289
|
+
const { metadata, payload } = await load(objectName(key), key);
|
|
290
|
+
try {
|
|
291
|
+
if (readOptions.ifMatch !== undefined && readOptions.ifMatch !== metadata.etag) {
|
|
292
|
+
throw new StorageError2("conflict", "Object etag does not match");
|
|
293
|
+
}
|
|
294
|
+
const offset = readOptions.range?.offset ?? 0;
|
|
295
|
+
if (readOptions.range && offset >= metadata.size)
|
|
296
|
+
throw new StorageError2("invalid-input", "Range starts outside the object");
|
|
297
|
+
const length = Math.min(readOptions.range?.length ?? metadata.size - offset, metadata.size - offset);
|
|
298
|
+
let position = offset;
|
|
299
|
+
let ended = false;
|
|
300
|
+
let controller;
|
|
301
|
+
const finish = async () => {
|
|
302
|
+
if (ended)
|
|
303
|
+
return;
|
|
304
|
+
ended = true;
|
|
305
|
+
signal.removeEventListener("abort", abort);
|
|
306
|
+
await close(payload);
|
|
307
|
+
};
|
|
308
|
+
const abort = () => {
|
|
309
|
+
controller.error(new StorageError2("aborted", "Download cancelled"));
|
|
310
|
+
finish().catch(() => {});
|
|
311
|
+
};
|
|
312
|
+
const body = new ReadableStream({
|
|
313
|
+
start(value) {
|
|
314
|
+
controller = value;
|
|
315
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
316
|
+
if (signal.aborted)
|
|
317
|
+
abort();
|
|
318
|
+
},
|
|
319
|
+
async pull(value) {
|
|
320
|
+
try {
|
|
321
|
+
if (position === offset + length) {
|
|
322
|
+
await finish();
|
|
323
|
+
value.close();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const buffer = new Uint8Array(Math.min(64 * 1024, offset + length - position));
|
|
327
|
+
const { bytesRead } = await payload.read(buffer, 0, buffer.length, position);
|
|
328
|
+
if (ended)
|
|
329
|
+
return;
|
|
330
|
+
if (!bytesRead)
|
|
331
|
+
throw new StorageError2("provider", "Unexpected end of stored payload");
|
|
332
|
+
position += bytesRead;
|
|
333
|
+
value.enqueue(buffer.subarray(0, bytesRead));
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (!ended)
|
|
336
|
+
value.error(storageError(error));
|
|
337
|
+
await finish();
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
cancel: finish
|
|
341
|
+
}, { highWaterMark: 0 });
|
|
342
|
+
return {
|
|
343
|
+
metadata,
|
|
344
|
+
body,
|
|
345
|
+
...readOptions.range ? { range: { offset, length } } : {}
|
|
346
|
+
};
|
|
347
|
+
} catch (error) {
|
|
348
|
+
await close(payload, error);
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}),
|
|
352
|
+
head: (key, headOptions = {}) => run(headOptions.signal, async () => {
|
|
353
|
+
validateKey2(key);
|
|
354
|
+
try {
|
|
355
|
+
const { metadata, payload } = await load(objectName(key), key);
|
|
356
|
+
await close(payload);
|
|
357
|
+
return metadata;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error instanceof StorageError2 && error.code === "not-found")
|
|
360
|
+
return null;
|
|
361
|
+
if (code(error) === "ENOENT")
|
|
362
|
+
throw new StorageError2("provider", "Incomplete stored object", { cause: error });
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
}),
|
|
366
|
+
delete: (key, deleteOptions = {}) => run(deleteOptions.signal, async (signal) => {
|
|
367
|
+
validateKey2(key);
|
|
368
|
+
const tomb = join(root, `.delete-${objectName(key)}`);
|
|
369
|
+
async function removeTomb() {
|
|
370
|
+
try {
|
|
371
|
+
await directory(tomb);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error instanceof StorageError2 && error.code === "not-found")
|
|
374
|
+
return false;
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
for (const entry of await readdir(tomb)) {
|
|
378
|
+
if (entry !== "payload" && entry !== "metadata.json") {
|
|
379
|
+
throw new StorageError2("forbidden", "Unexpected deletion directory entry");
|
|
380
|
+
}
|
|
381
|
+
let stat;
|
|
382
|
+
try {
|
|
383
|
+
stat = await lstat(join(tomb, entry));
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (code(error) === "ENOENT")
|
|
386
|
+
continue;
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
|
|
390
|
+
throw new StorageError2("forbidden", "Unsafe deletion directory entry");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
signal.throwIfAborted();
|
|
394
|
+
try {
|
|
395
|
+
await rm(tomb, { recursive: true });
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (code(error) !== "ENOENT")
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
const recovered = await removeTomb();
|
|
403
|
+
try {
|
|
404
|
+
const { payload } = await load(objectName(key), key);
|
|
405
|
+
await close(payload);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (error instanceof StorageError2 && error.code === "not-found") {
|
|
408
|
+
return { outcome: recovered ? "deleted" : "not-found" };
|
|
409
|
+
}
|
|
410
|
+
if (code(error) === "ENOENT")
|
|
411
|
+
throw new StorageError2("provider", "Incomplete stored object", { cause: error });
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
signal.throwIfAborted();
|
|
415
|
+
try {
|
|
416
|
+
await rename(join(root, objectName(key)), tomb);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if (code(error) !== "ENOENT")
|
|
419
|
+
throw error;
|
|
420
|
+
const removed = await removeTomb();
|
|
421
|
+
return { outcome: recovered || removed ? "deleted" : "not-found" };
|
|
422
|
+
}
|
|
423
|
+
await removeTomb();
|
|
424
|
+
return { outcome: "deleted" };
|
|
425
|
+
}),
|
|
426
|
+
list: (input = {}) => run(input.signal, async (signal) => {
|
|
427
|
+
const limit = validateList2(input);
|
|
428
|
+
if (input.cursor !== undefined)
|
|
429
|
+
validateKey2(input.cursor);
|
|
430
|
+
const objects = [];
|
|
431
|
+
for (const name of await readdir(root)) {
|
|
432
|
+
signal.throwIfAborted();
|
|
433
|
+
await directory(join(root, name));
|
|
434
|
+
if (temporary.test(name) || /^\.delete-[a-f0-9]{64}$/.test(name))
|
|
435
|
+
continue;
|
|
436
|
+
if (!/^[a-f0-9]{64}$/.test(name))
|
|
437
|
+
throw new StorageError2("forbidden", "Unexpected storage root entry");
|
|
438
|
+
const { metadata, payload } = await load(name);
|
|
439
|
+
await close(payload);
|
|
440
|
+
if (metadata.key.startsWith(input.prefix ?? "") && (input.cursor === undefined || metadata.key > input.cursor))
|
|
441
|
+
objects.push(metadata);
|
|
442
|
+
}
|
|
443
|
+
objects.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
444
|
+
return {
|
|
445
|
+
objects: objects.slice(0, limit),
|
|
446
|
+
...objects.length > limit ? { cursor: objects[limit - 1].key } : {}
|
|
447
|
+
};
|
|
448
|
+
}),
|
|
449
|
+
signUpload: async () => unsupportedSigning2(),
|
|
450
|
+
signDownload: async () => unsupportedSigning2()
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
export {
|
|
456
|
+
createLocalStoragePlugin
|
|
457
|
+
};
|