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