@lunora/storage 0.0.0 → 1.0.0-alpha.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.md +105 -0
- package/README.md +141 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/index.d.mts +406 -0
- package/dist/index.d.ts +406 -0
- package/dist/index.mjs +4 -0
- package/dist/packem_shared/buildPresignedUrl-DzPwi1bY.mjs +70 -0
- package/dist/packem_shared/buildSignedUrl-ZzB16yPl.mjs +107 -0
- package/dist/packem_shared/createBucketStorage-4Xk7-5CN.mjs +26 -0
- package/dist/packem_shared/scopeKey-Bs_iJ1Mx.mjs +251 -0
- package/package.json +37 -17
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { buildPresignedUrl } from './buildPresignedUrl-DzPwi1bY.mjs';
|
|
2
|
+
import { buildSignedUrl } from './buildSignedUrl-ZzB16yPl.mjs';
|
|
3
|
+
|
|
4
|
+
const MAX_KEY_LENGTH = 1024;
|
|
5
|
+
const MAX_LIST_LIMIT = 1e3;
|
|
6
|
+
const DEFAULT_LIST_LIMIT = 100;
|
|
7
|
+
const toHex = (buffer) => {
|
|
8
|
+
const bytes = new Uint8Array(buffer);
|
|
9
|
+
let out = "";
|
|
10
|
+
for (const byte of bytes) {
|
|
11
|
+
out += byte.toString(16).padStart(2, "0");
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
};
|
|
15
|
+
const toBase64 = (buffer) => {
|
|
16
|
+
const bytes = new Uint8Array(buffer);
|
|
17
|
+
let binary = "";
|
|
18
|
+
for (const byte of bytes) {
|
|
19
|
+
binary += String.fromCodePoint(byte);
|
|
20
|
+
}
|
|
21
|
+
return btoa(binary);
|
|
22
|
+
};
|
|
23
|
+
const withSha256 = (object) => {
|
|
24
|
+
const raw = object.checksums?.sha256;
|
|
25
|
+
if (raw === void 0) {
|
|
26
|
+
return object;
|
|
27
|
+
}
|
|
28
|
+
const sha256 = toHex(raw);
|
|
29
|
+
const sha256Base64 = toBase64(raw);
|
|
30
|
+
return /* @__PURE__ */ new Proxy(object, {
|
|
31
|
+
get(target, property) {
|
|
32
|
+
if (property === "sha256") {
|
|
33
|
+
return sha256;
|
|
34
|
+
}
|
|
35
|
+
if (property === "sha256Base64") {
|
|
36
|
+
return sha256Base64;
|
|
37
|
+
}
|
|
38
|
+
const value = Reflect.get(target, property, target);
|
|
39
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
40
|
+
},
|
|
41
|
+
has(target, property) {
|
|
42
|
+
return property === "sha256" || property === "sha256Base64" || Reflect.has(target, property);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
const toMetadata = (object) => {
|
|
47
|
+
const raw = object.checksums?.sha256;
|
|
48
|
+
return {
|
|
49
|
+
contentType: object.httpMetadata?.contentType,
|
|
50
|
+
customMetadata: object.customMetadata,
|
|
51
|
+
key: object.key,
|
|
52
|
+
sha256: raw === void 0 ? void 0 : toHex(raw),
|
|
53
|
+
size: object.size,
|
|
54
|
+
uploaded: object.uploaded === void 0 ? void 0 : object.uploaded.getTime()
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const enforceStreamMaxSize = (stream, maxSize) => {
|
|
58
|
+
let seen = 0;
|
|
59
|
+
const byteLengthOf = (chunk) => {
|
|
60
|
+
if (chunk instanceof ArrayBuffer) {
|
|
61
|
+
return chunk.byteLength;
|
|
62
|
+
}
|
|
63
|
+
return ArrayBuffer.isView(chunk) ? chunk.byteLength : 0;
|
|
64
|
+
};
|
|
65
|
+
const counter = new TransformStream({
|
|
66
|
+
transform(chunk, controller) {
|
|
67
|
+
seen += byteLengthOf(chunk);
|
|
68
|
+
if (seen > maxSize) {
|
|
69
|
+
controller.error(new Error(`@lunora/storage: stream body exceeds maxSize (> ${String(maxSize)} bytes)`));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
controller.enqueue(chunk);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return stream.pipeThrough(counter);
|
|
76
|
+
};
|
|
77
|
+
const trimTrailingSlashes = (value) => {
|
|
78
|
+
let end = value.length;
|
|
79
|
+
while (end > 0 && value[end - 1] === "/") {
|
|
80
|
+
end -= 1;
|
|
81
|
+
}
|
|
82
|
+
return value.slice(0, end);
|
|
83
|
+
};
|
|
84
|
+
const validateKey = (key) => {
|
|
85
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
86
|
+
throw new Error("@lunora/storage: key must be a non-empty string");
|
|
87
|
+
}
|
|
88
|
+
if (key.length > MAX_KEY_LENGTH) {
|
|
89
|
+
throw new Error(`@lunora/storage: key exceeds ${String(MAX_KEY_LENGTH)}-byte limit`);
|
|
90
|
+
}
|
|
91
|
+
if (key.includes("\0")) {
|
|
92
|
+
throw new Error("@lunora/storage: key contains NUL byte");
|
|
93
|
+
}
|
|
94
|
+
if (key.startsWith("/")) {
|
|
95
|
+
throw new Error("@lunora/storage: key must not start with `/`");
|
|
96
|
+
}
|
|
97
|
+
const segments = key.split("/");
|
|
98
|
+
for (const segment of segments) {
|
|
99
|
+
if (segment === "..") {
|
|
100
|
+
throw new Error("@lunora/storage: key contains a `..` path component");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const scopeKey = (prefix, key) => {
|
|
105
|
+
validateKey(prefix);
|
|
106
|
+
validateKey(key);
|
|
107
|
+
const trimmedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
108
|
+
const composed = `${trimmedPrefix}/${key}`;
|
|
109
|
+
if (composed.length > MAX_KEY_LENGTH) {
|
|
110
|
+
throw new Error(`@lunora/storage: scoped key exceeds ${String(MAX_KEY_LENGTH)}-byte limit`);
|
|
111
|
+
}
|
|
112
|
+
return composed;
|
|
113
|
+
};
|
|
114
|
+
const createStorage = (options) => {
|
|
115
|
+
if (!options.bucket) {
|
|
116
|
+
throw new Error("@lunora/storage: `bucket` is required");
|
|
117
|
+
}
|
|
118
|
+
const upload = async (key, body, uploadOptions = {}) => {
|
|
119
|
+
validateKey(key);
|
|
120
|
+
if (uploadOptions.allowedContentTypes !== void 0) {
|
|
121
|
+
if (uploadOptions.contentType === void 0) {
|
|
122
|
+
throw new Error("@lunora/storage: contentType is required when allowedContentTypes is set");
|
|
123
|
+
}
|
|
124
|
+
if (!uploadOptions.allowedContentTypes.includes(uploadOptions.contentType)) {
|
|
125
|
+
throw new Error(`@lunora/storage: contentType "${uploadOptions.contentType}" not in allowedContentTypes`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
let putBody = body;
|
|
129
|
+
if (typeof uploadOptions.maxSize === "number") {
|
|
130
|
+
let size;
|
|
131
|
+
if (body instanceof ArrayBuffer) {
|
|
132
|
+
size = body.byteLength;
|
|
133
|
+
} else if (body instanceof Blob) {
|
|
134
|
+
size = body.size;
|
|
135
|
+
}
|
|
136
|
+
if (size !== void 0 && size > uploadOptions.maxSize) {
|
|
137
|
+
throw new Error(`@lunora/storage: body exceeds maxSize (${String(size)} > ${String(uploadOptions.maxSize)})`);
|
|
138
|
+
}
|
|
139
|
+
if (body instanceof ReadableStream) {
|
|
140
|
+
putBody = enforceStreamMaxSize(body, uploadOptions.maxSize);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const object = await options.bucket.put(key, putBody, {
|
|
144
|
+
customMetadata: uploadOptions.customMetadata,
|
|
145
|
+
httpMetadata: uploadOptions.contentType ? { contentType: uploadOptions.contentType } : void 0
|
|
146
|
+
});
|
|
147
|
+
return { etag: object.etag, httpEtag: object.httpEtag ?? `"${object.etag}"`, key: object.key };
|
|
148
|
+
};
|
|
149
|
+
const download = async (key, downloadOptions = {}) => {
|
|
150
|
+
validateKey(key);
|
|
151
|
+
const object = await (downloadOptions.range ? options.bucket.get(key, { range: downloadOptions.range }) : options.bucket.get(key));
|
|
152
|
+
return object && withSha256(object);
|
|
153
|
+
};
|
|
154
|
+
const deleteObject = async (key) => {
|
|
155
|
+
validateKey(key);
|
|
156
|
+
await options.bucket.delete(key);
|
|
157
|
+
};
|
|
158
|
+
const getMetadata = async (key) => {
|
|
159
|
+
validateKey(key);
|
|
160
|
+
if (options.bucket.head) {
|
|
161
|
+
const head = await options.bucket.head(key);
|
|
162
|
+
return head && toMetadata(head);
|
|
163
|
+
}
|
|
164
|
+
const object = await options.bucket.get(key, { range: { length: 0 } });
|
|
165
|
+
return object && toMetadata(object);
|
|
166
|
+
};
|
|
167
|
+
const list = async (prefix, listOptions = {}) => {
|
|
168
|
+
if (prefix?.includes("\0")) {
|
|
169
|
+
throw new Error("@lunora/storage: prefix contains NUL byte");
|
|
170
|
+
}
|
|
171
|
+
const requested = listOptions.limit ?? DEFAULT_LIST_LIMIT;
|
|
172
|
+
const limit = Math.min(Math.max(1, Math.floor(requested)), MAX_LIST_LIMIT);
|
|
173
|
+
const result = await options.bucket.list({ cursor: listOptions.cursor, delimiter: listOptions.delimiter, limit, prefix });
|
|
174
|
+
return { cursor: result.cursor, objects: result.objects.map((object) => withSha256(object)), truncated: result.truncated };
|
|
175
|
+
};
|
|
176
|
+
const getUrl = (key) => {
|
|
177
|
+
if (!options.publicBaseUrl) {
|
|
178
|
+
throw new Error("@lunora/storage: `publicBaseUrl` is required for getUrl()");
|
|
179
|
+
}
|
|
180
|
+
validateKey(key);
|
|
181
|
+
const safeKey = key.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
182
|
+
return `${trimTrailingSlashes(options.publicBaseUrl)}/${safeKey}`;
|
|
183
|
+
};
|
|
184
|
+
const getSignedUrl = async (key, signedOptions = {}) => {
|
|
185
|
+
if (!options.publicBaseUrl) {
|
|
186
|
+
throw new Error("@lunora/storage: `publicBaseUrl` is required for getSignedUrl()");
|
|
187
|
+
}
|
|
188
|
+
if (!options.signingSecret) {
|
|
189
|
+
throw new Error("@lunora/storage: `signingSecret` is required for getSignedUrl()");
|
|
190
|
+
}
|
|
191
|
+
validateKey(key);
|
|
192
|
+
return buildSignedUrl({
|
|
193
|
+
baseUrl: options.publicBaseUrl,
|
|
194
|
+
contentType: signedOptions.contentType,
|
|
195
|
+
expiresInSeconds: signedOptions.expiresInSeconds,
|
|
196
|
+
key,
|
|
197
|
+
method: signedOptions.method,
|
|
198
|
+
secret: options.signingSecret
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
const createMultipartUpload = async (key, multipartOptions = {}) => {
|
|
202
|
+
validateKey(key);
|
|
203
|
+
if (!options.bucket.createMultipartUpload) {
|
|
204
|
+
throw new Error("@lunora/storage: bucket binding does not support multipart uploads (createMultipartUpload)");
|
|
205
|
+
}
|
|
206
|
+
return options.bucket.createMultipartUpload(key, {
|
|
207
|
+
customMetadata: multipartOptions.customMetadata,
|
|
208
|
+
httpMetadata: multipartOptions.contentType ? { contentType: multipartOptions.contentType } : void 0
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
const resumeMultipartUpload = (key, uploadId) => {
|
|
212
|
+
validateKey(key);
|
|
213
|
+
if (typeof uploadId !== "string" || uploadId.length === 0) {
|
|
214
|
+
throw new Error("@lunora/storage: resumeMultipartUpload requires a non-empty uploadId");
|
|
215
|
+
}
|
|
216
|
+
if (!options.bucket.resumeMultipartUpload) {
|
|
217
|
+
throw new Error("@lunora/storage: bucket binding does not support multipart uploads (resumeMultipartUpload)");
|
|
218
|
+
}
|
|
219
|
+
return options.bucket.resumeMultipartUpload(key, uploadId);
|
|
220
|
+
};
|
|
221
|
+
const getPresignedUrl = async (key, presignedOptions = {}) => {
|
|
222
|
+
if (!options.s3) {
|
|
223
|
+
throw new Error("@lunora/storage: `s3` credentials are required for getPresignedUrl() — pass { accountId, accessKeyId, secretAccessKey, bucket }");
|
|
224
|
+
}
|
|
225
|
+
validateKey(key);
|
|
226
|
+
return buildPresignedUrl({
|
|
227
|
+
credentials: options.s3,
|
|
228
|
+
expiresInSeconds: presignedOptions.expiresInSeconds,
|
|
229
|
+
key,
|
|
230
|
+
method: presignedOptions.method
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
const generateUploadUrl = async (key, uploadUrlOptions = {}) => getSignedUrl(key, { contentType: uploadUrlOptions.contentType, expiresInSeconds: uploadUrlOptions.expiresInSeconds, method: "PUT" });
|
|
234
|
+
const store = async (key, body, storeOptions = {}) => upload(key, body, storeOptions);
|
|
235
|
+
return {
|
|
236
|
+
createMultipartUpload,
|
|
237
|
+
delete: deleteObject,
|
|
238
|
+
download,
|
|
239
|
+
generateUploadUrl,
|
|
240
|
+
getMetadata,
|
|
241
|
+
getPresignedUrl,
|
|
242
|
+
getSignedUrl,
|
|
243
|
+
getUrl,
|
|
244
|
+
list,
|
|
245
|
+
resumeMultipartUpload,
|
|
246
|
+
store,
|
|
247
|
+
upload
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export { createStorage, scopeKey };
|
package/package.json
CHANGED
|
@@ -1,31 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/storage",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "1.0.0-alpha.1",
|
|
4
4
|
"description": "R2-backed storage for Lunora: typed buckets and signed URLs",
|
|
5
|
-
"
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cloudflare",
|
|
7
|
+
"durable-objects",
|
|
8
|
+
"lunora",
|
|
9
|
+
"presigned-url",
|
|
10
|
+
"r2",
|
|
11
|
+
"signed-url",
|
|
12
|
+
"storage",
|
|
13
|
+
"workers"
|
|
14
|
+
],
|
|
6
15
|
"homepage": "https://lunora.sh",
|
|
16
|
+
"bugs": "https://github.com/anolilab/lunora/issues",
|
|
17
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Daniel Bannert",
|
|
20
|
+
"email": "d.bannert@anolilab.de"
|
|
21
|
+
},
|
|
7
22
|
"repository": {
|
|
8
23
|
"type": "git",
|
|
9
24
|
"url": "git+https://github.com/anolilab/lunora.git",
|
|
10
25
|
"directory": "packages/storage"
|
|
11
26
|
},
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
"cloudflare",
|
|
18
|
-
"workers",
|
|
19
|
-
"durable-objects",
|
|
20
|
-
"r2",
|
|
21
|
-
"storage",
|
|
22
|
-
"signed-url",
|
|
23
|
-
"presigned-url"
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"__assets__",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE.md"
|
|
24
32
|
],
|
|
33
|
+
"type": "module",
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"main": "./dist/index.mjs",
|
|
36
|
+
"module": "./dist/index.mjs",
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"import": "./dist/index.mjs"
|
|
42
|
+
},
|
|
43
|
+
"./package.json": "./package.json"
|
|
44
|
+
},
|
|
25
45
|
"publishConfig": {
|
|
26
46
|
"access": "public"
|
|
27
47
|
},
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": "^22.15.0 || >=24.11.0"
|
|
50
|
+
}
|
|
31
51
|
}
|