@lunora/storage 1.0.0-alpha.1 → 1.0.0-alpha.11
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 +6 -0
- package/__assets__/package-og.svg +1 -1
- package/dist/index.d.mts +163 -163
- package/dist/index.d.ts +163 -163
- package/dist/index.mjs +4 -4
- package/dist/packem_shared/{buildPresignedUrl-DzPwi1bY.mjs → buildPresignedUrl-B5f1wvz6.mjs} +2 -8
- package/dist/packem_shared/{buildSignedUrl-ZzB16yPl.mjs → buildSignedUrl-rt-6azia.mjs} +41 -24
- package/dist/packem_shared/{createBucketStorage-4Xk7-5CN.mjs → createBucketStorage-JYdrS4e5.mjs} +6 -4
- package/dist/packem_shared/createR2UploadStorage-BfLIpWdf.mjs +54 -0
- package/dist/packem_shared/{scopeKey-Bs_iJ1Mx.mjs → createStorage-Bu6GJB25.mjs} +48 -38
- package/dist/packem_shared/internal-Dqk0MrAj.mjs +17 -0
- package/dist/upload.d.mts +91 -0
- package/dist/upload.d.ts +91 -0
- package/dist/upload.mjs +1 -0
- package/package.json +11 -2
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Rest, Multipart, Tus } from '@visulima/storage/handler/http/fetch';
|
|
2
|
+
import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
|
|
3
|
+
|
|
4
|
+
const TUS_RESUMABLE = "1.0.0";
|
|
5
|
+
const denyResponse = (protocol) => {
|
|
6
|
+
const headers = { "content-type": "application/json" };
|
|
7
|
+
if (protocol === "tus") {
|
|
8
|
+
headers["Tus-Resumable"] = TUS_RESUMABLE;
|
|
9
|
+
}
|
|
10
|
+
return Response.json({ error: { code: "FORBIDDEN", message: "Upload denied by authorization policy", name: "ForbiddenError" } }, { headers, status: 403 });
|
|
11
|
+
};
|
|
12
|
+
const instantiateHandler = (protocol, handlerOptions) => {
|
|
13
|
+
if (protocol === "chunked-rest") {
|
|
14
|
+
return new Rest(handlerOptions);
|
|
15
|
+
}
|
|
16
|
+
if (protocol === "multipart") {
|
|
17
|
+
return new Multipart(handlerOptions);
|
|
18
|
+
}
|
|
19
|
+
return new Tus(handlerOptions);
|
|
20
|
+
};
|
|
21
|
+
const createUploadHandler = (options) => {
|
|
22
|
+
const protocol = options.protocol ?? "tus";
|
|
23
|
+
const handlerOptions = {
|
|
24
|
+
storage: options.storage,
|
|
25
|
+
...options.maxFileSize === void 0 ? {} : { maxFileSize: options.maxFileSize }
|
|
26
|
+
};
|
|
27
|
+
const handler = instantiateHandler(protocol, handlerOptions);
|
|
28
|
+
const { authorize } = options;
|
|
29
|
+
const fetch = async (request) => {
|
|
30
|
+
if (authorize !== void 0) {
|
|
31
|
+
try {
|
|
32
|
+
const allowed = await authorize({ method: request.method, protocol, request, url: new URL(request.url) });
|
|
33
|
+
if (!allowed) {
|
|
34
|
+
return denyResponse(protocol);
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
return denyResponse(protocol);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return handler.fetch(request);
|
|
41
|
+
};
|
|
42
|
+
return { fetch, protocol };
|
|
43
|
+
};
|
|
44
|
+
const createR2UploadStorage = (options) => new AwsLightStorage({
|
|
45
|
+
accessKeyId: options.accessKeyId,
|
|
46
|
+
bucket: options.bucket,
|
|
47
|
+
endpoint: options.endpoint ?? `https://${options.accountId}.r2.cloudflarestorage.com`,
|
|
48
|
+
path: options.path ?? "/",
|
|
49
|
+
region: "auto",
|
|
50
|
+
secretAccessKey: options.secretAccessKey,
|
|
51
|
+
...options.partSize === void 0 ? {} : { partSize: options.partSize }
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export { createR2UploadStorage, createUploadHandler };
|
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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';
|
|
3
5
|
|
|
4
6
|
const MAX_KEY_LENGTH = 1024;
|
|
5
7
|
const MAX_LIST_LIMIT = 1e3;
|
|
6
8
|
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
9
|
const toBase64 = (buffer) => {
|
|
16
10
|
const bytes = new Uint8Array(buffer);
|
|
17
11
|
let binary = "";
|
|
@@ -54,17 +48,37 @@ const toMetadata = (object) => {
|
|
|
54
48
|
uploaded: object.uploaded === void 0 ? void 0 : object.uploaded.getTime()
|
|
55
49
|
};
|
|
56
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
|
+
};
|
|
57
66
|
const enforceStreamMaxSize = (stream, maxSize) => {
|
|
58
67
|
let seen = 0;
|
|
59
68
|
const byteLengthOf = (chunk) => {
|
|
60
69
|
if (chunk instanceof ArrayBuffer) {
|
|
61
70
|
return chunk.byteLength;
|
|
62
71
|
}
|
|
63
|
-
return ArrayBuffer.isView(chunk) ? chunk.byteLength : 0;
|
|
72
|
+
return ArrayBuffer.isView(chunk) ? chunk.byteLength : void 0;
|
|
64
73
|
};
|
|
65
74
|
const counter = new TransformStream({
|
|
66
75
|
transform(chunk, controller) {
|
|
67
|
-
|
|
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;
|
|
68
82
|
if (seen > maxSize) {
|
|
69
83
|
controller.error(new Error(`@lunora/storage: stream body exceeds maxSize (> ${String(maxSize)} bytes)`));
|
|
70
84
|
return;
|
|
@@ -74,30 +88,23 @@ const enforceStreamMaxSize = (stream, maxSize) => {
|
|
|
74
88
|
});
|
|
75
89
|
return stream.pipeThrough(counter);
|
|
76
90
|
};
|
|
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
91
|
const validateKey = (key) => {
|
|
85
92
|
if (typeof key !== "string" || key.length === 0) {
|
|
86
|
-
throw new
|
|
93
|
+
throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key must be a non-empty string");
|
|
87
94
|
}
|
|
88
95
|
if (key.length > MAX_KEY_LENGTH) {
|
|
89
|
-
throw new
|
|
96
|
+
throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: key exceeds ${String(MAX_KEY_LENGTH)}-byte limit`);
|
|
90
97
|
}
|
|
91
98
|
if (key.includes("\0")) {
|
|
92
|
-
throw new
|
|
99
|
+
throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key contains NUL byte");
|
|
93
100
|
}
|
|
94
101
|
if (key.startsWith("/")) {
|
|
95
|
-
throw new
|
|
102
|
+
throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key must not start with `/`");
|
|
96
103
|
}
|
|
97
104
|
const segments = key.split("/");
|
|
98
105
|
for (const segment of segments) {
|
|
99
106
|
if (segment === "..") {
|
|
100
|
-
throw new
|
|
107
|
+
throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key contains a `..` path component");
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
};
|
|
@@ -107,22 +114,22 @@ const scopeKey = (prefix, key) => {
|
|
|
107
114
|
const trimmedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
108
115
|
const composed = `${trimmedPrefix}/${key}`;
|
|
109
116
|
if (composed.length > MAX_KEY_LENGTH) {
|
|
110
|
-
throw new
|
|
117
|
+
throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: scoped key exceeds ${String(MAX_KEY_LENGTH)}-byte limit`);
|
|
111
118
|
}
|
|
112
119
|
return composed;
|
|
113
120
|
};
|
|
114
121
|
const createStorage = (options) => {
|
|
115
122
|
if (!options.bucket) {
|
|
116
|
-
throw new
|
|
123
|
+
throw new LunoraError("INTERNAL", "@lunora/storage: `bucket` is required");
|
|
117
124
|
}
|
|
118
125
|
const upload = async (key, body, uploadOptions = {}) => {
|
|
119
126
|
validateKey(key);
|
|
120
127
|
if (uploadOptions.allowedContentTypes !== void 0) {
|
|
121
128
|
if (uploadOptions.contentType === void 0) {
|
|
122
|
-
throw new
|
|
129
|
+
throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: contentType is required when allowedContentTypes is set");
|
|
123
130
|
}
|
|
124
131
|
if (!uploadOptions.allowedContentTypes.includes(uploadOptions.contentType)) {
|
|
125
|
-
throw new
|
|
132
|
+
throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: contentType "${uploadOptions.contentType}" not in allowedContentTypes`);
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
135
|
let putBody = body;
|
|
@@ -134,7 +141,7 @@ const createStorage = (options) => {
|
|
|
134
141
|
size = body.size;
|
|
135
142
|
}
|
|
136
143
|
if (size !== void 0 && size > uploadOptions.maxSize) {
|
|
137
|
-
throw new
|
|
144
|
+
throw new LunoraError("PAYLOAD_TOO_LARGE", `@lunora/storage: body exceeds maxSize (${String(size)} > ${String(uploadOptions.maxSize)})`);
|
|
138
145
|
}
|
|
139
146
|
if (body instanceof ReadableStream) {
|
|
140
147
|
putBody = enforceStreamMaxSize(body, uploadOptions.maxSize);
|
|
@@ -166,16 +173,16 @@ const createStorage = (options) => {
|
|
|
166
173
|
};
|
|
167
174
|
const list = async (prefix, listOptions = {}) => {
|
|
168
175
|
if (prefix?.includes("\0")) {
|
|
169
|
-
throw new
|
|
176
|
+
throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: prefix contains NUL byte");
|
|
170
177
|
}
|
|
171
178
|
const requested = listOptions.limit ?? DEFAULT_LIST_LIMIT;
|
|
172
179
|
const limit = Math.min(Math.max(1, Math.floor(requested)), MAX_LIST_LIMIT);
|
|
173
180
|
const result = await options.bucket.list({ cursor: listOptions.cursor, delimiter: listOptions.delimiter, limit, prefix });
|
|
174
|
-
return { cursor: result.cursor, objects: result.objects.map((object) =>
|
|
181
|
+
return { cursor: result.cursor, objects: result.objects.map((object) => toListObject(object)), truncated: result.truncated };
|
|
175
182
|
};
|
|
176
183
|
const getUrl = (key) => {
|
|
177
184
|
if (!options.publicBaseUrl) {
|
|
178
|
-
throw new
|
|
185
|
+
throw new LunoraError("INTERNAL", "@lunora/storage: `publicBaseUrl` is required for getUrl()");
|
|
179
186
|
}
|
|
180
187
|
validateKey(key);
|
|
181
188
|
const safeKey = key.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
@@ -183,10 +190,10 @@ const createStorage = (options) => {
|
|
|
183
190
|
};
|
|
184
191
|
const getSignedUrl = async (key, signedOptions = {}) => {
|
|
185
192
|
if (!options.publicBaseUrl) {
|
|
186
|
-
throw new
|
|
193
|
+
throw new LunoraError("INTERNAL", "@lunora/storage: `publicBaseUrl` is required for getSignedUrl()");
|
|
187
194
|
}
|
|
188
195
|
if (!options.signingSecret) {
|
|
189
|
-
throw new
|
|
196
|
+
throw new LunoraError("INTERNAL", "@lunora/storage: `signingSecret` is required for getSignedUrl()");
|
|
190
197
|
}
|
|
191
198
|
validateKey(key);
|
|
192
199
|
return buildSignedUrl({
|
|
@@ -201,7 +208,7 @@ const createStorage = (options) => {
|
|
|
201
208
|
const createMultipartUpload = async (key, multipartOptions = {}) => {
|
|
202
209
|
validateKey(key);
|
|
203
210
|
if (!options.bucket.createMultipartUpload) {
|
|
204
|
-
throw new
|
|
211
|
+
throw new LunoraError("INTERNAL", "@lunora/storage: bucket binding does not support multipart uploads (createMultipartUpload)");
|
|
205
212
|
}
|
|
206
213
|
return options.bucket.createMultipartUpload(key, {
|
|
207
214
|
customMetadata: multipartOptions.customMetadata,
|
|
@@ -211,16 +218,19 @@ const createStorage = (options) => {
|
|
|
211
218
|
const resumeMultipartUpload = (key, uploadId) => {
|
|
212
219
|
validateKey(key);
|
|
213
220
|
if (typeof uploadId !== "string" || uploadId.length === 0) {
|
|
214
|
-
throw new
|
|
221
|
+
throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: resumeMultipartUpload requires a non-empty uploadId");
|
|
215
222
|
}
|
|
216
223
|
if (!options.bucket.resumeMultipartUpload) {
|
|
217
|
-
throw new
|
|
224
|
+
throw new LunoraError("INTERNAL", "@lunora/storage: bucket binding does not support multipart uploads (resumeMultipartUpload)");
|
|
218
225
|
}
|
|
219
226
|
return options.bucket.resumeMultipartUpload(key, uploadId);
|
|
220
227
|
};
|
|
221
228
|
const getPresignedUrl = async (key, presignedOptions = {}) => {
|
|
222
229
|
if (!options.s3) {
|
|
223
|
-
throw new
|
|
230
|
+
throw new LunoraError(
|
|
231
|
+
"INTERNAL",
|
|
232
|
+
"@lunora/storage: `s3` credentials are required for getPresignedUrl() — pass { accountId, accessKeyId, secretAccessKey, bucket }"
|
|
233
|
+
);
|
|
224
234
|
}
|
|
225
235
|
validateKey(key);
|
|
226
236
|
return buildPresignedUrl({
|
|
@@ -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 };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Tus } from '@visulima/storage/handler/http/fetch';
|
|
2
|
+
import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
|
|
3
|
+
/** Resumable upload wire protocols the handler can speak. */
|
|
4
|
+
type UploadProtocol = "chunked-rest" | "multipart" | "tus";
|
|
5
|
+
type UploadHandlerOptions = ConstructorParameters<typeof Tus>[0];
|
|
6
|
+
/** A `@visulima/storage` storage provider (e.g. {@link createR2UploadStorage} or a memory provider in tests). */
|
|
7
|
+
type UploadStorage = UploadHandlerOptions["storage"];
|
|
8
|
+
/**
|
|
9
|
+
* The context handed to {@link CreateUploadHandlerOptions.authorize}. Everything
|
|
10
|
+
* needed to make an RLS decision: the raw `request` (headers/cookies/auth), the
|
|
11
|
+
* `method`, the parsed `url`, and which `protocol` the handler speaks.
|
|
12
|
+
*/
|
|
13
|
+
interface UploadAuthzContext {
|
|
14
|
+
/** The upload method being invoked (`POST` create, `PATCH` chunk, `HEAD` resume, `DELETE`). */
|
|
15
|
+
method: string;
|
|
16
|
+
/** The protocol this handler is mounted for. */
|
|
17
|
+
protocol: UploadProtocol;
|
|
18
|
+
/** The inbound request — inspect headers/cookies to resolve the caller's identity. */
|
|
19
|
+
request: Request;
|
|
20
|
+
/** The parsed request URL (query params, upload-id path segment). */
|
|
21
|
+
url: URL;
|
|
22
|
+
}
|
|
23
|
+
/** Options for {@link createUploadHandler}. */
|
|
24
|
+
interface CreateUploadHandlerOptions {
|
|
25
|
+
/**
|
|
26
|
+
* The RLS gate. Runs before every upload request and denies fail-closed:
|
|
27
|
+
* returning `false` **or throwing** yields a `403`. Omit only for a fully
|
|
28
|
+
* public bucket — the whole point of this handler over the admin path is
|
|
29
|
+
* that uploads are gated by *your* per-user policy, not an admin token.
|
|
30
|
+
*/
|
|
31
|
+
authorize?: (context: UploadAuthzContext) => boolean | Promise<boolean>;
|
|
32
|
+
/** Maximum accepted file size in bytes (forwarded to the multipart parser). */
|
|
33
|
+
maxFileSize?: number;
|
|
34
|
+
/** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
|
|
35
|
+
protocol?: UploadProtocol;
|
|
36
|
+
/** The storage provider the bytes land in (R2 in prod, memory in tests). */
|
|
37
|
+
storage: UploadStorage;
|
|
38
|
+
}
|
|
39
|
+
/** The object returned by {@link createUploadHandler}. */
|
|
40
|
+
interface UploadHandler {
|
|
41
|
+
/**
|
|
42
|
+
* Handle one upload request. Runs the RLS gate, then delegates to the
|
|
43
|
+
* `@visulima/storage` protocol handler. Wire this into your Worker's routing
|
|
44
|
+
* for the path the client uploads to.
|
|
45
|
+
*/
|
|
46
|
+
fetch: (request: Request) => Promise<Response>;
|
|
47
|
+
/** The protocol this handler speaks. */
|
|
48
|
+
protocol: UploadProtocol;
|
|
49
|
+
}
|
|
50
|
+
/** R2 (S3-compatible) credentials + bucket for {@link createR2UploadStorage}. */
|
|
51
|
+
interface R2UploadStorageOptions {
|
|
52
|
+
/** R2 S3 API Access Key ID (from an R2 API token). */
|
|
53
|
+
accessKeyId: string;
|
|
54
|
+
/** Cloudflare account id — used to derive the R2 S3 endpoint host. */
|
|
55
|
+
accountId: string;
|
|
56
|
+
/** Target R2 bucket name. */
|
|
57
|
+
bucket: string;
|
|
58
|
+
/**
|
|
59
|
+
* Explicit R2 S3 endpoint. Defaults to
|
|
60
|
+
* `https://<accountId>.r2.cloudflarestorage.com`. Pass this to pin a
|
|
61
|
+
* jurisdiction (e.g. `<accountId>.eu.r2.cloudflarestorage.com`).
|
|
62
|
+
*/
|
|
63
|
+
endpoint?: string;
|
|
64
|
+
/** Client-side multipart part size (bytes or a size string like `"16MB"`). */
|
|
65
|
+
partSize?: number | string;
|
|
66
|
+
/**
|
|
67
|
+
* Path prefix the handler is mounted on (must match the client endpoint's
|
|
68
|
+
* path). Default `"/"`.
|
|
69
|
+
*/
|
|
70
|
+
path?: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Build an RLS-gated resumable upload handler over a `@visulima/storage`
|
|
74
|
+
* provider. Mount its {@link UploadHandler.fetch} on the route your client
|
|
75
|
+
* uploads to and drive it with `@visulima/storage-client`.
|
|
76
|
+
*/
|
|
77
|
+
declare const createUploadHandler: (options: CreateUploadHandlerOptions) => UploadHandler;
|
|
78
|
+
/**
|
|
79
|
+
* Build an R2-backed storage provider for {@link createUploadHandler} using
|
|
80
|
+
* `@visulima/storage`'s dependency-light `aws-light` provider (`aws4fetch`, no
|
|
81
|
+
* AWS SDK). R2's S3 region alias is always `auto`.
|
|
82
|
+
*
|
|
83
|
+
* Requires an R2 **S3 API** token's Access Key ID / Secret Access Key — the
|
|
84
|
+
* same credential shape `@lunora/storage`'s presigned-URL helpers take. In a
|
|
85
|
+
* Worker the `aws-light` provider needs `nodejs_compat` (it imports
|
|
86
|
+
* `node:stream`).
|
|
87
|
+
*/
|
|
88
|
+
declare const createR2UploadStorage: (options: R2UploadStorageOptions & {
|
|
89
|
+
secretAccessKey: string;
|
|
90
|
+
}) => AwsLightStorage;
|
|
91
|
+
export { type CreateUploadHandlerOptions, type R2UploadStorageOptions, type UploadAuthzContext, type UploadHandler, type UploadProtocol, type UploadStorage, createR2UploadStorage, createUploadHandler };
|
package/dist/upload.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Tus } from '@visulima/storage/handler/http/fetch';
|
|
2
|
+
import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
|
|
3
|
+
/** Resumable upload wire protocols the handler can speak. */
|
|
4
|
+
type UploadProtocol = "chunked-rest" | "multipart" | "tus";
|
|
5
|
+
type UploadHandlerOptions = ConstructorParameters<typeof Tus>[0];
|
|
6
|
+
/** A `@visulima/storage` storage provider (e.g. {@link createR2UploadStorage} or a memory provider in tests). */
|
|
7
|
+
type UploadStorage = UploadHandlerOptions["storage"];
|
|
8
|
+
/**
|
|
9
|
+
* The context handed to {@link CreateUploadHandlerOptions.authorize}. Everything
|
|
10
|
+
* needed to make an RLS decision: the raw `request` (headers/cookies/auth), the
|
|
11
|
+
* `method`, the parsed `url`, and which `protocol` the handler speaks.
|
|
12
|
+
*/
|
|
13
|
+
interface UploadAuthzContext {
|
|
14
|
+
/** The upload method being invoked (`POST` create, `PATCH` chunk, `HEAD` resume, `DELETE`). */
|
|
15
|
+
method: string;
|
|
16
|
+
/** The protocol this handler is mounted for. */
|
|
17
|
+
protocol: UploadProtocol;
|
|
18
|
+
/** The inbound request — inspect headers/cookies to resolve the caller's identity. */
|
|
19
|
+
request: Request;
|
|
20
|
+
/** The parsed request URL (query params, upload-id path segment). */
|
|
21
|
+
url: URL;
|
|
22
|
+
}
|
|
23
|
+
/** Options for {@link createUploadHandler}. */
|
|
24
|
+
interface CreateUploadHandlerOptions {
|
|
25
|
+
/**
|
|
26
|
+
* The RLS gate. Runs before every upload request and denies fail-closed:
|
|
27
|
+
* returning `false` **or throwing** yields a `403`. Omit only for a fully
|
|
28
|
+
* public bucket — the whole point of this handler over the admin path is
|
|
29
|
+
* that uploads are gated by *your* per-user policy, not an admin token.
|
|
30
|
+
*/
|
|
31
|
+
authorize?: (context: UploadAuthzContext) => boolean | Promise<boolean>;
|
|
32
|
+
/** Maximum accepted file size in bytes (forwarded to the multipart parser). */
|
|
33
|
+
maxFileSize?: number;
|
|
34
|
+
/** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
|
|
35
|
+
protocol?: UploadProtocol;
|
|
36
|
+
/** The storage provider the bytes land in (R2 in prod, memory in tests). */
|
|
37
|
+
storage: UploadStorage;
|
|
38
|
+
}
|
|
39
|
+
/** The object returned by {@link createUploadHandler}. */
|
|
40
|
+
interface UploadHandler {
|
|
41
|
+
/**
|
|
42
|
+
* Handle one upload request. Runs the RLS gate, then delegates to the
|
|
43
|
+
* `@visulima/storage` protocol handler. Wire this into your Worker's routing
|
|
44
|
+
* for the path the client uploads to.
|
|
45
|
+
*/
|
|
46
|
+
fetch: (request: Request) => Promise<Response>;
|
|
47
|
+
/** The protocol this handler speaks. */
|
|
48
|
+
protocol: UploadProtocol;
|
|
49
|
+
}
|
|
50
|
+
/** R2 (S3-compatible) credentials + bucket for {@link createR2UploadStorage}. */
|
|
51
|
+
interface R2UploadStorageOptions {
|
|
52
|
+
/** R2 S3 API Access Key ID (from an R2 API token). */
|
|
53
|
+
accessKeyId: string;
|
|
54
|
+
/** Cloudflare account id — used to derive the R2 S3 endpoint host. */
|
|
55
|
+
accountId: string;
|
|
56
|
+
/** Target R2 bucket name. */
|
|
57
|
+
bucket: string;
|
|
58
|
+
/**
|
|
59
|
+
* Explicit R2 S3 endpoint. Defaults to
|
|
60
|
+
* `https://<accountId>.r2.cloudflarestorage.com`. Pass this to pin a
|
|
61
|
+
* jurisdiction (e.g. `<accountId>.eu.r2.cloudflarestorage.com`).
|
|
62
|
+
*/
|
|
63
|
+
endpoint?: string;
|
|
64
|
+
/** Client-side multipart part size (bytes or a size string like `"16MB"`). */
|
|
65
|
+
partSize?: number | string;
|
|
66
|
+
/**
|
|
67
|
+
* Path prefix the handler is mounted on (must match the client endpoint's
|
|
68
|
+
* path). Default `"/"`.
|
|
69
|
+
*/
|
|
70
|
+
path?: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Build an RLS-gated resumable upload handler over a `@visulima/storage`
|
|
74
|
+
* provider. Mount its {@link UploadHandler.fetch} on the route your client
|
|
75
|
+
* uploads to and drive it with `@visulima/storage-client`.
|
|
76
|
+
*/
|
|
77
|
+
declare const createUploadHandler: (options: CreateUploadHandlerOptions) => UploadHandler;
|
|
78
|
+
/**
|
|
79
|
+
* Build an R2-backed storage provider for {@link createUploadHandler} using
|
|
80
|
+
* `@visulima/storage`'s dependency-light `aws-light` provider (`aws4fetch`, no
|
|
81
|
+
* AWS SDK). R2's S3 region alias is always `auto`.
|
|
82
|
+
*
|
|
83
|
+
* Requires an R2 **S3 API** token's Access Key ID / Secret Access Key — the
|
|
84
|
+
* same credential shape `@lunora/storage`'s presigned-URL helpers take. In a
|
|
85
|
+
* Worker the `aws-light` provider needs `nodejs_compat` (it imports
|
|
86
|
+
* `node:stream`).
|
|
87
|
+
*/
|
|
88
|
+
declare const createR2UploadStorage: (options: R2UploadStorageOptions & {
|
|
89
|
+
secretAccessKey: string;
|
|
90
|
+
}) => AwsLightStorage;
|
|
91
|
+
export { type CreateUploadHandlerOptions, type R2UploadStorageOptions, type UploadAuthzContext, type UploadHandler, type UploadProtocol, type UploadStorage, createR2UploadStorage, createUploadHandler };
|
package/dist/upload.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createR2UploadStorage, createUploadHandler } from './packem_shared/createR2UploadStorage-BfLIpWdf.mjs';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/storage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.11",
|
|
4
4
|
"description": "R2-backed storage for Lunora: typed buckets and signed URLs",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/storage"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
|
-
"dist",
|
|
28
|
+
"./dist",
|
|
29
29
|
"__assets__",
|
|
30
30
|
"README.md",
|
|
31
31
|
"LICENSE.md"
|
|
@@ -40,11 +40,20 @@
|
|
|
40
40
|
"types": "./dist/index.d.ts",
|
|
41
41
|
"import": "./dist/index.mjs"
|
|
42
42
|
},
|
|
43
|
+
"./upload": {
|
|
44
|
+
"types": "./dist/upload.d.ts",
|
|
45
|
+
"import": "./dist/upload.mjs"
|
|
46
|
+
},
|
|
43
47
|
"./package.json": "./package.json"
|
|
44
48
|
},
|
|
45
49
|
"publishConfig": {
|
|
46
50
|
"access": "public"
|
|
47
51
|
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@lunora/errors": "1.0.0-alpha.7",
|
|
54
|
+
"@visulima/storage": "1.0.5",
|
|
55
|
+
"aws4fetch": "1.0.20"
|
|
56
|
+
},
|
|
48
57
|
"engines": {
|
|
49
58
|
"node": "^22.15.0 || >=24.11.0"
|
|
50
59
|
}
|