@lunora/storage 1.0.0-alpha.13 → 1.0.0-alpha.131

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/dist/upload.d.mts CHANGED
@@ -1,9 +1,24 @@
1
+ import { BaseStorageOptions } from '@visulima/storage';
2
+ import { R2BucketLike } from '@lunora/platform';
1
3
  import { Tus } from '@visulima/storage/handler/http/fetch';
2
4
  import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
5
+ /**
6
+ * An R2 binding with the multipart API: a Worker's `env.BUCKET` (and
7
+ * miniflare's). The provider also relies on `put`'s `onlyIf` preconditions,
8
+ * which every R2 binding honors.
9
+ */
10
+ type R2UploadBucket = R2BucketLike & Required<Pick<R2BucketLike, "createMultipartUpload" | "resumeMultipartUpload">>;
3
11
  /** Resumable upload wire protocols the handler can speak. */
4
12
  type UploadProtocol = "chunked-rest" | "multipart" | "tus";
13
+ /**
14
+ * Default ceiling applied to `maxFileSize` when the caller doesn't supply one
15
+ * (100 MiB). Without SOME cap, an unauthenticated or loosely-authorized
16
+ * handler accepts an unbounded request body — pass an explicit `maxFileSize`
17
+ * to raise or lower this for your app.
18
+ */
19
+ declare const DEFAULT_MAX_UPLOAD_BYTES: number;
5
20
  type UploadHandlerOptions = ConstructorParameters<typeof Tus>[0];
6
- /** A `@visulima/storage` storage provider (e.g. {@link createR2UploadStorage} or a memory provider in tests). */
21
+ /** A `@visulima/storage` storage provider (e.g. `createR2UploadStorage` or a memory provider in tests). */
7
22
  type UploadStorage = UploadHandlerOptions["storage"];
8
23
  /**
9
24
  * The context handed to {@link CreateUploadHandlerOptions.authorize}. Everything
@@ -20,19 +35,128 @@ interface UploadAuthzContext {
20
35
  /** The parsed request URL (query params, upload-id path segment). */
21
36
  url: URL;
22
37
  }
38
+ declare const GRANTED: unique symbol;
39
+ /**
40
+ * A grant `authorize` answers to admit a request and hand the rest of it a
41
+ * `Context`. Opaque: only {@link UploadContext.grant} issues one, so a value
42
+ * the gate builds by hand, or picks up by mistake (`{ valid: false, context }`),
43
+ * is not a grant and is denied.
44
+ */
45
+ interface UploadGrant<Context = unknown> {
46
+ readonly [GRANTED]: Context;
47
+ }
48
+ /** What {@link CreateUploadHandlerOptions.authorize} answers: allow, deny, a grant, or the response to refuse with. */
49
+ type UploadAuthorizeResult<Context = unknown> = boolean | Response | UploadGrant<Context>;
50
+ /**
51
+ * A typed channel from `authorize` to the storage provider's callbacks
52
+ * (`filename`, `onCreate`, `onComplete`, …), which get only the file.
53
+ * `authorize` answers `grant(context)`, and the callbacks read the context back
54
+ * with `get()`, so one handler and one provider serve every caller.
55
+ */
56
+ interface UploadContext<Context> {
57
+ /**
58
+ * The context the current request was granted. Throws when there is none:
59
+ * outside an upload request, or when `authorize` answered `true` or a grant
60
+ * from another `UploadContext`.
61
+ */
62
+ get: () => Context;
63
+ /** A grant that admits the request and carries `context` to {@link UploadContext.get}. */
64
+ grant: (context: Context) => UploadGrant<Context>;
65
+ }
66
+ /**
67
+ * Create an {@link UploadContext}, typed by what `authorize` grants:
68
+ *
69
+ * ```ts
70
+ * const caller = createUploadContext<{ userId: string }>();
71
+ *
72
+ * filename: (file) => `uploads/${caller.get().userId}/${file.id}`,
73
+ * authorize: async ({ request }) => caller.grant({ userId: await userIdFrom(request) }),
74
+ * ```
75
+ */
76
+ declare const createUploadContext: <Context>() => UploadContext<Context>;
77
+ /**
78
+ * The context handed to {@link CreateUploadHandlerOptions.maxFileSizeFor}: the
79
+ * authorization context plus what the create request declares about the file.
80
+ * Everything here comes from the client, so it caps an upload by what the
81
+ * client SAYS it is; `allowMIME` on the provider is what checks the type.
82
+ */
83
+ interface UploadSizeContext<Context = unknown> extends UploadAuthzContext {
84
+ /**
85
+ * The declared MIME type, resolved the way the stored file's type is: on
86
+ * chunked REST the request's `Content-Type`; on TUS the `Upload-Metadata`
87
+ * `mimeType`, else `type`, else `filetype`; else
88
+ * `application/octet-stream`.
89
+ */
90
+ contentType: string;
91
+ /**
92
+ * The largest size the request declares (`Upload-Length`, `X-Total-Size`,
93
+ * `Content-Length`). A declared size that is not a non-negative integer
94
+ * reads as `Infinity`.
95
+ */
96
+ declaredSize: number | undefined;
97
+ /** The context `authorize` granted this request, or `undefined` when it answered `true` or there is no `authorize`. */
98
+ granted: Context | undefined;
99
+ /** The decoded TUS `Upload-Metadata`, or the chunked-REST `X-File-Metadata` JSON, as strings. */
100
+ metadata: Record<string, string>;
101
+ }
23
102
  /** Options for {@link createUploadHandler}. */
24
- interface CreateUploadHandlerOptions {
103
+ interface CreateUploadHandlerOptions<Context = unknown> {
25
104
  /**
26
105
  * 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.
106
+ * only `true` or an {@link UploadGrant} (from {@link UploadContext.grant})
107
+ * lets the request through; `false`, anything else, **or throwing** yields
108
+ * a `403`. A `Response` is answered instead of the `403`, for a refusal of
109
+ * your own (say a `429` with `Retry-After`): it is passed through as it
110
+ * is, except that a TUS route adds `Tus-Resumable` when it has none. It is
111
+ * meant for 4xx/5xx; a 2xx would tell the client an upload happened. Omit
112
+ * only for a fully public bucket — the whole point of this handler over
113
+ * the admin path is that uploads are gated by *your* per-user policy, not
114
+ * an admin token.
115
+ *
116
+ * Omitting it mounts an unauthenticated, unbounded-write endpoint, so
117
+ * doing so logs a one-time warning (per handler) unless `silent`/`public`
118
+ * says the omission is intentional.
119
+ */
120
+ authorize?: (context: UploadAuthzContext) => UploadAuthorizeResult<Context> | Promise<UploadAuthorizeResult<Context>>;
121
+ /**
122
+ * Maximum accepted file size in bytes. Forwarded to the multipart parser
123
+ * (protocol `"multipart"`) and, for `"tus"`/`"chunked-rest"`, enforced by
124
+ * this handler itself against the largest size the request declares across
125
+ * `Upload-Length` (TUS), `X-Total-Size` (chunked REST) and `Content-Length`
126
+ * — see {@link declaredUploadSize}. Defaults to
127
+ * {@link DEFAULT_MAX_UPLOAD_BYTES} (100 MiB) — pass this to raise or lower
128
+ * the ceiling; there is no unbounded option. Must be a finite, non-negative
129
+ * number: anything else (notably a `NaN` from an unset env var) throws at
130
+ * construction rather than disabling the cap.
30
131
  */
31
- authorize?: (context: UploadAuthzContext) => boolean | Promise<boolean>;
32
- /** Maximum accepted file size in bytes (forwarded to the multipart parser). */
33
132
  maxFileSize?: number;
133
+ /**
134
+ * A per-upload size cap, below `maxFileSize`: return the most bytes this
135
+ * upload may hold (say 10 MiB for `image/*`, 2 GiB for `video/*`), or
136
+ * `undefined` to leave `maxFileSize` as the only cap. It can only lower the
137
+ * cap, never raise it past `maxFileSize`.
138
+ *
139
+ * Runs after `authorize`, on the requests that create an upload (`POST`,
140
+ * and the chunked-REST `PUT`) of the `"tus"` and `"chunked-rest"`
141
+ * protocols; later chunks are bounded by the size declared at creation.
142
+ * Not applied to `"multipart"`, whose size is only known once the form is
143
+ * parsed. A create that declares no size is refused (`413`) when this
144
+ * returns a cap, since there is nothing to check it against. Throwing, or
145
+ * returning something that is not a finite, non-negative number, denies the
146
+ * request (`403`), fail-closed like `authorize`.
147
+ */
148
+ maxFileSizeFor?: (context: UploadSizeContext<Context>) => number | undefined | Promise<number | undefined>;
34
149
  /** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
35
150
  protocol?: UploadProtocol;
151
+ /**
152
+ * Set when omitting `authorize` is intentional (a fully public upload
153
+ * bucket) — suppresses the one-time "no authorize gate" warning that would
154
+ * otherwise print when the handler is constructed. Has no effect when
155
+ * `authorize` is provided.
156
+ */
157
+ public?: boolean;
158
+ /** Suppress the one-time default-open-authorize warning. Alias of `public`. */
159
+ silent?: boolean;
36
160
  /** The storage provider the bytes land in (R2 in prod, memory in tests). */
37
161
  storage: UploadStorage;
38
162
  }
@@ -47,6 +171,62 @@ interface UploadHandler {
47
171
  /** The protocol this handler speaks. */
48
172
  protocol: UploadProtocol;
49
173
  }
174
+ /**
175
+ * Build an RLS-gated resumable upload handler over a `@visulima/storage`
176
+ * provider. Mount its {@link UploadHandler.fetch} on the route your client
177
+ * uploads to and drive it with `@visulima/storage-client`.
178
+ */
179
+ declare const createUploadHandler: <Context = unknown>(options: CreateUploadHandlerOptions<Context>) => UploadHandler;
180
+ /** Every part of an R2 multipart upload but the last is exactly this size: R2's 5 MiB minimum. */
181
+ declare const R2_PART_SIZE: number;
182
+ /**
183
+ * `T` without its index signatures. `BaseStorageOptions` has a
184
+ * `[key: string]: unknown` one, and `Omit` over it maps over `string`, which
185
+ * erases every declared option (each would be `unknown`).
186
+ */
187
+ type DeclaredKeys<T> = { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; };
188
+ /**
189
+ * Options for {@link createR2BindingUploadStorage}. A type alias, not an
190
+ * interface: an interface gets no implicit index signature, so it would not
191
+ * pass to `AbstractBaseStorage`'s constructor.
192
+ */
193
+ type R2BindingUploadStorageOptions = Omit<DeclaredKeys<BaseStorageOptions>, "metaStorage"> & {
194
+ /**
195
+ * Key prefix the upload state objects and buffered segments are stored
196
+ * under, in the same bucket. Default `"_lunora/uploads/"`. No uploaded
197
+ * object may be named under it.
198
+ *
199
+ * The state of a finished upload stays (a few hundred bytes, so a `HEAD`
200
+ * can still answer that the upload is complete), and so does the state of
201
+ * one that was abandoned. Both accumulate: add an R2 object lifecycle rule
202
+ * that deletes objects under this prefix after a few days, longer than
203
+ * any upload you expect to take.
204
+ */
205
+ statePrefix?: string;
206
+ /**
207
+ * The scope a request's upload state is kept under, appended to `statePrefix`: the state of an upload
208
+ * is then readable only by requests in the same scope. Read it from the request's grant, e.g.
209
+ * `() => \`${caller.get().userId}/\``. Must return a string ending in `/`, or `""` for the unscoped root.
210
+ *
211
+ * `list()` reads only the unscoped root, so an upload kept under a scope is not listed.
212
+ */
213
+ stateScope?: () => string;
214
+ };
215
+ /**
216
+ * Build a resumable-upload provider for `createUploadHandler` over a Worker's
217
+ * R2 binding: no S3 credentials, and the same behaviour under `wrangler dev` /
218
+ * miniflare as in production.
219
+ *
220
+ * Speaks TUS (without the `checksum`, `concatenation` and
221
+ * `creation-defer-length` extensions), chunked REST, and multipart forms.
222
+ *
223
+ * Chunked-REST chunks have to arrive one at a time and in order. A chunk at any
224
+ * offset other than the upload's current one, or one sent while another is
225
+ * still streaming, is a `409` and stores nothing, and it never counts towards
226
+ * completion. The bundled `@visulima/storage-client` chunked-REST client
227
+ * sends one chunk at a time (since 1.0.8), so its uploads complete here.
228
+ */
229
+ declare const createR2BindingUploadStorage: (bucket: R2UploadBucket, options?: R2BindingUploadStorageOptions) => UploadStorage;
50
230
  /** R2 (S3-compatible) credentials + bucket for {@link createR2UploadStorage}. */
51
231
  interface R2UploadStorageOptions {
52
232
  /** R2 S3 API Access Key ID (from an R2 API token). */
@@ -56,9 +236,11 @@ interface R2UploadStorageOptions {
56
236
  /** Target R2 bucket name. */
57
237
  bucket: string;
58
238
  /**
59
- * Explicit R2 S3 endpoint. Defaults to
60
- * `https://&lt;accountId>.r2.cloudflarestorage.com`. Pass this to pin a
61
- * jurisdiction (e.g. `&lt;accountId>.eu.r2.cloudflarestorage.com`).
239
+ * Explicit R2 S3 account endpoint, an absolute `https://` URL. The bucket
240
+ * is appended as a path segment, unless the endpoint already names it
241
+ * (as its last path segment, or as the host's first label). Defaults to
242
+ * `https://<accountId>.r2.cloudflarestorage.com`. Pass this to pin a
243
+ * jurisdiction (e.g. `https://<accountId>.eu.r2.cloudflarestorage.com`).
62
244
  */
63
245
  endpoint?: string;
64
246
  /** Client-side multipart part size (bytes or a size string like `"16MB"`). */
@@ -70,13 +252,7 @@ interface R2UploadStorageOptions {
70
252
  path?: string;
71
253
  }
72
254
  /**
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
255
+ * Build an R2-backed storage provider for `createUploadHandler` using
80
256
  * `@visulima/storage`'s dependency-light `aws-light` provider (`aws4fetch`, no
81
257
  * AWS SDK). R2's S3 region alias is always `auto`.
82
258
  *
@@ -84,8 +260,56 @@ declare const createUploadHandler: (options: CreateUploadHandlerOptions) => Uplo
84
260
  * same credential shape `@lunora/storage`'s presigned-URL helpers take. In a
85
261
  * Worker the `aws-light` provider needs `nodejs_compat` (it imports
86
262
  * `node:stream`).
263
+ *
264
+ * The bucket is addressed path-style, `<endpoint>/<bucket>/<key>`.
265
+ *
266
+ * Each chunk becomes one multipart part, so every chunk but the last has to be
267
+ * the same size and at least 5 MiB, and chunks have to arrive in order (an
268
+ * out-of-order or repeated one is a `409`). The R2 binding provider
269
+ * (`./r2-binding-upload-storage`) has neither limit, needs no S3 credentials
270
+ * and runs under `wrangler dev` too.
271
+ *
272
+ * The provider takes no `@visulima/storage` limits of its own (its upload cap
273
+ * is the 5 TB default), so `maxFileSize` / `maxFileSizeFor` on the handler are
274
+ * the caps that apply.
87
275
  */
88
276
  declare const createR2UploadStorage: (options: R2UploadStorageOptions & {
89
277
  secretAccessKey: string;
90
278
  }) => AwsLightStorage;
91
- export { type CreateUploadHandlerOptions, type R2UploadStorageOptions, type UploadAuthzContext, type UploadHandler, type UploadProtocol, type UploadStorage, createR2UploadStorage, createUploadHandler };
279
+ export { type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
280
+ /**
281
+ * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
282
+ *
283
+ * A separate subpath so the base `@lunora/storage` entry stays lean: the
284
+ * `@visulima/storage` handler dependency only loads for apps that mount an
285
+ * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
286
+ * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
287
+ *
288
+ * On Workers this subpath statically imports `node:async_hooks` (the grant
289
+ * scope) and, through the R2 providers, `node:stream`, so it needs Node.js
290
+ * compatibility to load at all: the import fails at load time otherwise, and
291
+ * the subpath never reaches `createUploadHandler` / `createUploadContext`. A
292
+ * `compatibility_date` of `2026-08-04` or later enables `nodejs_compat` by
293
+ * default; earlier dates need `nodejs_compat` in `compatibility_flags`.
294
+ * `nodejs_als` alone satisfies the `AsyncLocalStorage` import but not
295
+ * `node:stream`, so it is not enough for this subpath. Node.js is unaffected.
296
+ */
297
+ type R2BindingUploadStorageOptions,
298
+ /**
299
+ * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
300
+ *
301
+ * A separate subpath so the base `@lunora/storage` entry stays lean: the
302
+ * `@visulima/storage` handler dependency only loads for apps that mount an
303
+ * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
304
+ * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
305
+ *
306
+ * On Workers this subpath statically imports `node:async_hooks` (the grant
307
+ * scope) and, through the R2 providers, `node:stream`, so it needs Node.js
308
+ * compatibility to load at all: the import fails at load time otherwise, and
309
+ * the subpath never reaches `createUploadHandler` / `createUploadContext`. A
310
+ * `compatibility_date` of `2026-08-04` or later enables `nodejs_compat` by
311
+ * default; earlier dates need `nodejs_compat` in `compatibility_flags`.
312
+ * `nodejs_als` alone satisfies the `AsyncLocalStorage` import but not
313
+ * `node:stream`, so it is not enough for this subpath. Node.js is unaffected.
314
+ */
315
+ type R2UploadBucket, type R2UploadStorageOptions, R2_PART_SIZE, type UploadAuthorizeResult, type UploadAuthzContext, type UploadContext, type UploadGrant, type UploadHandler, type UploadProtocol, type UploadSizeContext, type UploadStorage, createR2BindingUploadStorage, createR2UploadStorage, createUploadContext, createUploadHandler };
package/dist/upload.d.ts CHANGED
@@ -1,9 +1,24 @@
1
+ import { BaseStorageOptions } from '@visulima/storage';
2
+ import { R2BucketLike } from '@lunora/platform';
1
3
  import { Tus } from '@visulima/storage/handler/http/fetch';
2
4
  import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
5
+ /**
6
+ * An R2 binding with the multipart API: a Worker's `env.BUCKET` (and
7
+ * miniflare's). The provider also relies on `put`'s `onlyIf` preconditions,
8
+ * which every R2 binding honors.
9
+ */
10
+ type R2UploadBucket = R2BucketLike & Required<Pick<R2BucketLike, "createMultipartUpload" | "resumeMultipartUpload">>;
3
11
  /** Resumable upload wire protocols the handler can speak. */
4
12
  type UploadProtocol = "chunked-rest" | "multipart" | "tus";
13
+ /**
14
+ * Default ceiling applied to `maxFileSize` when the caller doesn't supply one
15
+ * (100 MiB). Without SOME cap, an unauthenticated or loosely-authorized
16
+ * handler accepts an unbounded request body — pass an explicit `maxFileSize`
17
+ * to raise or lower this for your app.
18
+ */
19
+ declare const DEFAULT_MAX_UPLOAD_BYTES: number;
5
20
  type UploadHandlerOptions = ConstructorParameters<typeof Tus>[0];
6
- /** A `@visulima/storage` storage provider (e.g. {@link createR2UploadStorage} or a memory provider in tests). */
21
+ /** A `@visulima/storage` storage provider (e.g. `createR2UploadStorage` or a memory provider in tests). */
7
22
  type UploadStorage = UploadHandlerOptions["storage"];
8
23
  /**
9
24
  * The context handed to {@link CreateUploadHandlerOptions.authorize}. Everything
@@ -20,19 +35,128 @@ interface UploadAuthzContext {
20
35
  /** The parsed request URL (query params, upload-id path segment). */
21
36
  url: URL;
22
37
  }
38
+ declare const GRANTED: unique symbol;
39
+ /**
40
+ * A grant `authorize` answers to admit a request and hand the rest of it a
41
+ * `Context`. Opaque: only {@link UploadContext.grant} issues one, so a value
42
+ * the gate builds by hand, or picks up by mistake (`{ valid: false, context }`),
43
+ * is not a grant and is denied.
44
+ */
45
+ interface UploadGrant<Context = unknown> {
46
+ readonly [GRANTED]: Context;
47
+ }
48
+ /** What {@link CreateUploadHandlerOptions.authorize} answers: allow, deny, a grant, or the response to refuse with. */
49
+ type UploadAuthorizeResult<Context = unknown> = boolean | Response | UploadGrant<Context>;
50
+ /**
51
+ * A typed channel from `authorize` to the storage provider's callbacks
52
+ * (`filename`, `onCreate`, `onComplete`, …), which get only the file.
53
+ * `authorize` answers `grant(context)`, and the callbacks read the context back
54
+ * with `get()`, so one handler and one provider serve every caller.
55
+ */
56
+ interface UploadContext<Context> {
57
+ /**
58
+ * The context the current request was granted. Throws when there is none:
59
+ * outside an upload request, or when `authorize` answered `true` or a grant
60
+ * from another `UploadContext`.
61
+ */
62
+ get: () => Context;
63
+ /** A grant that admits the request and carries `context` to {@link UploadContext.get}. */
64
+ grant: (context: Context) => UploadGrant<Context>;
65
+ }
66
+ /**
67
+ * Create an {@link UploadContext}, typed by what `authorize` grants:
68
+ *
69
+ * ```ts
70
+ * const caller = createUploadContext<{ userId: string }>();
71
+ *
72
+ * filename: (file) => `uploads/${caller.get().userId}/${file.id}`,
73
+ * authorize: async ({ request }) => caller.grant({ userId: await userIdFrom(request) }),
74
+ * ```
75
+ */
76
+ declare const createUploadContext: <Context>() => UploadContext<Context>;
77
+ /**
78
+ * The context handed to {@link CreateUploadHandlerOptions.maxFileSizeFor}: the
79
+ * authorization context plus what the create request declares about the file.
80
+ * Everything here comes from the client, so it caps an upload by what the
81
+ * client SAYS it is; `allowMIME` on the provider is what checks the type.
82
+ */
83
+ interface UploadSizeContext<Context = unknown> extends UploadAuthzContext {
84
+ /**
85
+ * The declared MIME type, resolved the way the stored file's type is: on
86
+ * chunked REST the request's `Content-Type`; on TUS the `Upload-Metadata`
87
+ * `mimeType`, else `type`, else `filetype`; else
88
+ * `application/octet-stream`.
89
+ */
90
+ contentType: string;
91
+ /**
92
+ * The largest size the request declares (`Upload-Length`, `X-Total-Size`,
93
+ * `Content-Length`). A declared size that is not a non-negative integer
94
+ * reads as `Infinity`.
95
+ */
96
+ declaredSize: number | undefined;
97
+ /** The context `authorize` granted this request, or `undefined` when it answered `true` or there is no `authorize`. */
98
+ granted: Context | undefined;
99
+ /** The decoded TUS `Upload-Metadata`, or the chunked-REST `X-File-Metadata` JSON, as strings. */
100
+ metadata: Record<string, string>;
101
+ }
23
102
  /** Options for {@link createUploadHandler}. */
24
- interface CreateUploadHandlerOptions {
103
+ interface CreateUploadHandlerOptions<Context = unknown> {
25
104
  /**
26
105
  * 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.
106
+ * only `true` or an {@link UploadGrant} (from {@link UploadContext.grant})
107
+ * lets the request through; `false`, anything else, **or throwing** yields
108
+ * a `403`. A `Response` is answered instead of the `403`, for a refusal of
109
+ * your own (say a `429` with `Retry-After`): it is passed through as it
110
+ * is, except that a TUS route adds `Tus-Resumable` when it has none. It is
111
+ * meant for 4xx/5xx; a 2xx would tell the client an upload happened. Omit
112
+ * only for a fully public bucket — the whole point of this handler over
113
+ * the admin path is that uploads are gated by *your* per-user policy, not
114
+ * an admin token.
115
+ *
116
+ * Omitting it mounts an unauthenticated, unbounded-write endpoint, so
117
+ * doing so logs a one-time warning (per handler) unless `silent`/`public`
118
+ * says the omission is intentional.
119
+ */
120
+ authorize?: (context: UploadAuthzContext) => UploadAuthorizeResult<Context> | Promise<UploadAuthorizeResult<Context>>;
121
+ /**
122
+ * Maximum accepted file size in bytes. Forwarded to the multipart parser
123
+ * (protocol `"multipart"`) and, for `"tus"`/`"chunked-rest"`, enforced by
124
+ * this handler itself against the largest size the request declares across
125
+ * `Upload-Length` (TUS), `X-Total-Size` (chunked REST) and `Content-Length`
126
+ * — see {@link declaredUploadSize}. Defaults to
127
+ * {@link DEFAULT_MAX_UPLOAD_BYTES} (100 MiB) — pass this to raise or lower
128
+ * the ceiling; there is no unbounded option. Must be a finite, non-negative
129
+ * number: anything else (notably a `NaN` from an unset env var) throws at
130
+ * construction rather than disabling the cap.
30
131
  */
31
- authorize?: (context: UploadAuthzContext) => boolean | Promise<boolean>;
32
- /** Maximum accepted file size in bytes (forwarded to the multipart parser). */
33
132
  maxFileSize?: number;
133
+ /**
134
+ * A per-upload size cap, below `maxFileSize`: return the most bytes this
135
+ * upload may hold (say 10 MiB for `image/*`, 2 GiB for `video/*`), or
136
+ * `undefined` to leave `maxFileSize` as the only cap. It can only lower the
137
+ * cap, never raise it past `maxFileSize`.
138
+ *
139
+ * Runs after `authorize`, on the requests that create an upload (`POST`,
140
+ * and the chunked-REST `PUT`) of the `"tus"` and `"chunked-rest"`
141
+ * protocols; later chunks are bounded by the size declared at creation.
142
+ * Not applied to `"multipart"`, whose size is only known once the form is
143
+ * parsed. A create that declares no size is refused (`413`) when this
144
+ * returns a cap, since there is nothing to check it against. Throwing, or
145
+ * returning something that is not a finite, non-negative number, denies the
146
+ * request (`403`), fail-closed like `authorize`.
147
+ */
148
+ maxFileSizeFor?: (context: UploadSizeContext<Context>) => number | undefined | Promise<number | undefined>;
34
149
  /** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
35
150
  protocol?: UploadProtocol;
151
+ /**
152
+ * Set when omitting `authorize` is intentional (a fully public upload
153
+ * bucket) — suppresses the one-time "no authorize gate" warning that would
154
+ * otherwise print when the handler is constructed. Has no effect when
155
+ * `authorize` is provided.
156
+ */
157
+ public?: boolean;
158
+ /** Suppress the one-time default-open-authorize warning. Alias of `public`. */
159
+ silent?: boolean;
36
160
  /** The storage provider the bytes land in (R2 in prod, memory in tests). */
37
161
  storage: UploadStorage;
38
162
  }
@@ -47,6 +171,62 @@ interface UploadHandler {
47
171
  /** The protocol this handler speaks. */
48
172
  protocol: UploadProtocol;
49
173
  }
174
+ /**
175
+ * Build an RLS-gated resumable upload handler over a `@visulima/storage`
176
+ * provider. Mount its {@link UploadHandler.fetch} on the route your client
177
+ * uploads to and drive it with `@visulima/storage-client`.
178
+ */
179
+ declare const createUploadHandler: <Context = unknown>(options: CreateUploadHandlerOptions<Context>) => UploadHandler;
180
+ /** Every part of an R2 multipart upload but the last is exactly this size: R2's 5 MiB minimum. */
181
+ declare const R2_PART_SIZE: number;
182
+ /**
183
+ * `T` without its index signatures. `BaseStorageOptions` has a
184
+ * `[key: string]: unknown` one, and `Omit` over it maps over `string`, which
185
+ * erases every declared option (each would be `unknown`).
186
+ */
187
+ type DeclaredKeys<T> = { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; };
188
+ /**
189
+ * Options for {@link createR2BindingUploadStorage}. A type alias, not an
190
+ * interface: an interface gets no implicit index signature, so it would not
191
+ * pass to `AbstractBaseStorage`'s constructor.
192
+ */
193
+ type R2BindingUploadStorageOptions = Omit<DeclaredKeys<BaseStorageOptions>, "metaStorage"> & {
194
+ /**
195
+ * Key prefix the upload state objects and buffered segments are stored
196
+ * under, in the same bucket. Default `"_lunora/uploads/"`. No uploaded
197
+ * object may be named under it.
198
+ *
199
+ * The state of a finished upload stays (a few hundred bytes, so a `HEAD`
200
+ * can still answer that the upload is complete), and so does the state of
201
+ * one that was abandoned. Both accumulate: add an R2 object lifecycle rule
202
+ * that deletes objects under this prefix after a few days, longer than
203
+ * any upload you expect to take.
204
+ */
205
+ statePrefix?: string;
206
+ /**
207
+ * The scope a request's upload state is kept under, appended to `statePrefix`: the state of an upload
208
+ * is then readable only by requests in the same scope. Read it from the request's grant, e.g.
209
+ * `() => \`${caller.get().userId}/\``. Must return a string ending in `/`, or `""` for the unscoped root.
210
+ *
211
+ * `list()` reads only the unscoped root, so an upload kept under a scope is not listed.
212
+ */
213
+ stateScope?: () => string;
214
+ };
215
+ /**
216
+ * Build a resumable-upload provider for `createUploadHandler` over a Worker's
217
+ * R2 binding: no S3 credentials, and the same behaviour under `wrangler dev` /
218
+ * miniflare as in production.
219
+ *
220
+ * Speaks TUS (without the `checksum`, `concatenation` and
221
+ * `creation-defer-length` extensions), chunked REST, and multipart forms.
222
+ *
223
+ * Chunked-REST chunks have to arrive one at a time and in order. A chunk at any
224
+ * offset other than the upload's current one, or one sent while another is
225
+ * still streaming, is a `409` and stores nothing, and it never counts towards
226
+ * completion. The bundled `@visulima/storage-client` chunked-REST client
227
+ * sends one chunk at a time (since 1.0.8), so its uploads complete here.
228
+ */
229
+ declare const createR2BindingUploadStorage: (bucket: R2UploadBucket, options?: R2BindingUploadStorageOptions) => UploadStorage;
50
230
  /** R2 (S3-compatible) credentials + bucket for {@link createR2UploadStorage}. */
51
231
  interface R2UploadStorageOptions {
52
232
  /** R2 S3 API Access Key ID (from an R2 API token). */
@@ -56,9 +236,11 @@ interface R2UploadStorageOptions {
56
236
  /** Target R2 bucket name. */
57
237
  bucket: string;
58
238
  /**
59
- * Explicit R2 S3 endpoint. Defaults to
60
- * `https://&lt;accountId>.r2.cloudflarestorage.com`. Pass this to pin a
61
- * jurisdiction (e.g. `&lt;accountId>.eu.r2.cloudflarestorage.com`).
239
+ * Explicit R2 S3 account endpoint, an absolute `https://` URL. The bucket
240
+ * is appended as a path segment, unless the endpoint already names it
241
+ * (as its last path segment, or as the host's first label). Defaults to
242
+ * `https://<accountId>.r2.cloudflarestorage.com`. Pass this to pin a
243
+ * jurisdiction (e.g. `https://<accountId>.eu.r2.cloudflarestorage.com`).
62
244
  */
63
245
  endpoint?: string;
64
246
  /** Client-side multipart part size (bytes or a size string like `"16MB"`). */
@@ -70,13 +252,7 @@ interface R2UploadStorageOptions {
70
252
  path?: string;
71
253
  }
72
254
  /**
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
255
+ * Build an R2-backed storage provider for `createUploadHandler` using
80
256
  * `@visulima/storage`'s dependency-light `aws-light` provider (`aws4fetch`, no
81
257
  * AWS SDK). R2's S3 region alias is always `auto`.
82
258
  *
@@ -84,8 +260,56 @@ declare const createUploadHandler: (options: CreateUploadHandlerOptions) => Uplo
84
260
  * same credential shape `@lunora/storage`'s presigned-URL helpers take. In a
85
261
  * Worker the `aws-light` provider needs `nodejs_compat` (it imports
86
262
  * `node:stream`).
263
+ *
264
+ * The bucket is addressed path-style, `<endpoint>/<bucket>/<key>`.
265
+ *
266
+ * Each chunk becomes one multipart part, so every chunk but the last has to be
267
+ * the same size and at least 5 MiB, and chunks have to arrive in order (an
268
+ * out-of-order or repeated one is a `409`). The R2 binding provider
269
+ * (`./r2-binding-upload-storage`) has neither limit, needs no S3 credentials
270
+ * and runs under `wrangler dev` too.
271
+ *
272
+ * The provider takes no `@visulima/storage` limits of its own (its upload cap
273
+ * is the 5 TB default), so `maxFileSize` / `maxFileSizeFor` on the handler are
274
+ * the caps that apply.
87
275
  */
88
276
  declare const createR2UploadStorage: (options: R2UploadStorageOptions & {
89
277
  secretAccessKey: string;
90
278
  }) => AwsLightStorage;
91
- export { type CreateUploadHandlerOptions, type R2UploadStorageOptions, type UploadAuthzContext, type UploadHandler, type UploadProtocol, type UploadStorage, createR2UploadStorage, createUploadHandler };
279
+ export { type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
280
+ /**
281
+ * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
282
+ *
283
+ * A separate subpath so the base `@lunora/storage` entry stays lean: the
284
+ * `@visulima/storage` handler dependency only loads for apps that mount an
285
+ * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
286
+ * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
287
+ *
288
+ * On Workers this subpath statically imports `node:async_hooks` (the grant
289
+ * scope) and, through the R2 providers, `node:stream`, so it needs Node.js
290
+ * compatibility to load at all: the import fails at load time otherwise, and
291
+ * the subpath never reaches `createUploadHandler` / `createUploadContext`. A
292
+ * `compatibility_date` of `2026-08-04` or later enables `nodejs_compat` by
293
+ * default; earlier dates need `nodejs_compat` in `compatibility_flags`.
294
+ * `nodejs_als` alone satisfies the `AsyncLocalStorage` import but not
295
+ * `node:stream`, so it is not enough for this subpath. Node.js is unaffected.
296
+ */
297
+ type R2BindingUploadStorageOptions,
298
+ /**
299
+ * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
300
+ *
301
+ * A separate subpath so the base `@lunora/storage` entry stays lean: the
302
+ * `@visulima/storage` handler dependency only loads for apps that mount an
303
+ * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
304
+ * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
305
+ *
306
+ * On Workers this subpath statically imports `node:async_hooks` (the grant
307
+ * scope) and, through the R2 providers, `node:stream`, so it needs Node.js
308
+ * compatibility to load at all: the import fails at load time otherwise, and
309
+ * the subpath never reaches `createUploadHandler` / `createUploadContext`. A
310
+ * `compatibility_date` of `2026-08-04` or later enables `nodejs_compat` by
311
+ * default; earlier dates need `nodejs_compat` in `compatibility_flags`.
312
+ * `nodejs_als` alone satisfies the `AsyncLocalStorage` import but not
313
+ * `node:stream`, so it is not enough for this subpath. Node.js is unaffected.
314
+ */
315
+ type R2UploadBucket, type R2UploadStorageOptions, R2_PART_SIZE, type UploadAuthorizeResult, type UploadAuthzContext, type UploadContext, type UploadGrant, type UploadHandler, type UploadProtocol, type UploadSizeContext, type UploadStorage, createR2BindingUploadStorage, createR2UploadStorage, createUploadContext, createUploadHandler };
package/dist/upload.mjs CHANGED
@@ -1 +1 @@
1
- import{createR2UploadStorage as r,createUploadHandler as o}from"./packem_shared/createR2UploadStorage-BSgOjRvv.mjs";export{r as createR2UploadStorage,o as createUploadHandler};
1
+ import{createR2BindingUploadStorage as r}from"./packem_shared/createR2BindingUploadStorage-Vjs0vyJJ.mjs";import{createR2UploadStorage as t}from"./packem_shared/createR2UploadStorage-DGB1xJiD.mjs";import{DEFAULT_MAX_UPLOAD_BYTES as d,createUploadContext as U,createUploadHandler as l}from"./packem_shared/DEFAULT_MAX_UPLOAD_BYTES-BOaXqwP5.mjs";import{R}from"./packem_shared/r2-upload-part-writer-SdQlWaAu.mjs";export{d as DEFAULT_MAX_UPLOAD_BYTES,R as R2_PART_SIZE,r as createR2BindingUploadStorage,t as createR2UploadStorage,U as createUploadContext,l as createUploadHandler};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/storage",
3
- "version": "1.0.0-alpha.13",
3
+ "version": "1.0.0-alpha.131",
4
4
  "description": "R2-backed storage for Lunora: typed buckets and signed URLs",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -50,8 +50,9 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@lunora/errors": "1.0.0-alpha.9",
54
- "@visulima/storage": "1.0.5",
53
+ "@lunora/errors": ">=1.0.0-alpha.51 <2.0.0-0",
54
+ "@lunora/platform": "1.0.0-alpha.63",
55
+ "@visulima/storage": "2.0.37",
55
56
  "aws4fetch": "1.0.20"
56
57
  },
57
58
  "engines": {
@@ -1,4 +0,0 @@
1
- import{a as g}from"./internal-DpjlEJPE.mjs";const h="auto",y="s3",S="AWS4-HMAC-SHA256",M=1,X=10080*60,w=900,d=new TextEncoder,j=(e,t)=>e[0]<t[0]?-1:e[0]>t[0]?1:0,s=e=>encodeURIComponent(e).replaceAll(/[!'()*]/gu,t=>`%${t.codePointAt(0)?.toString(16).toUpperCase()??""}`),f=e=>e.split("/").map(t=>s(t)).join("/"),U=async e=>g(await crypto.subtle.digest("SHA-256",d.encode(e))),n=async(e,t)=>{const a=await crypto.subtle.importKey("raw",e,{hash:"SHA-256",name:"HMAC"},!1,["sign"]);return crypto.subtle.sign("HMAC",a,d.encode(t))},E=async(e,t)=>{const a=await n(d.encode(`AWS4${e}`),t),r=await n(a,h),o=await n(r,y);return n(o,"aws4_request")},K=e=>{const t=e.jurisdiction===void 0?"":`${e.jurisdiction}.`;return`${e.accountId}.${t}r2.cloudflarestorage.com`},P=e=>{const t=`${e.toISOString().replaceAll(/[:-]/gu,"").slice(0,15)}Z`;return{amzDate:t,dateStamp:t.slice(0,8)}},k=async e=>{const{credentials:t,key:a}=e,r=e.method??"GET",o=e.expiresInSeconds??w,z=Number.isFinite(o)?o:w,H=Math.min(Math.max(M,Math.floor(z)),X),m=K(t),D=new Date(e.now?.()??Date.now()),{amzDate:l,dateStamp:u}=P(D),A=`${u}/${h}/${y}/aws4_request`,p=`/${s(t.bucket)}/${f(a)}`,$=[["X-Amz-Algorithm",S],["X-Amz-Credential",`${t.accessKeyId}/${A}`],["X-Amz-Date",l],["X-Amz-Expires",H.toString()],["X-Amz-SignedHeaders","host"]].map(([i,c])=>[s(i),s(c)]).toSorted(j).map(([i,c])=>`${i}=${c}`).join("&"),b=[r,p,$,`host:${m}
2
- `,"host","UNSIGNED-PAYLOAD"].join(`
3
- `),x=[S,l,A,await U(b)].join(`
4
- `),C=await E(t.secretAccessKey,u),I=g(await n(C,x));return`https://${m}${p}?${$}&X-Amz-Signature=${I}`};export{k as buildPresignedUrl};