@lunora/storage 1.0.0-alpha.119 → 1.0.0-alpha.120

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.
@@ -0,0 +1 @@
1
+ import{AsyncLocalStorage as A}from"node:async_hooks";import{LunoraError as y}from"@lunora/errors";import{ERRORS as R,parseTusMetadata as I,File as L}from"@visulima/storage";import{Rest as F,Multipart as P,Tus as _}from"@visulima/storage/handler/http/fetch";const f="1.0.0",g=(t,e)=>Response.json({error:e},{headers:{"content-type":"application/json","Tus-Resumable":f},status:t}),M=t=>{try{return{metadata:Object.fromEntries(Object.entries(I(t)))}}catch(e){return{error:e instanceof Error?e.message:"Invalid Upload-Metadata header"}}},k=t=>{const e=new Headers(t.headers),r=e.get("Tus-Extension");return e.delete("Tus-Checksum-Algorithm"),r!==null&&e.set("Tus-Extension",r.split(",").filter(n=>n.trim()!=="checksum").join(",")),new Response(t.body,{headers:e,status:t.status,statusText:t.statusText})},C={declared:{contentType:void 0,metadata:{}}},D=t=>({check:e=>{if(e.method!=="POST"&&e.method!=="PATCH")return C;const r=M(e.headers.get("Upload-Metadata")??"");return"error"in r?{refusal:g(400,{code:"BadRequestError",message:r.error,name:"BadRequestError"})}:!t&&e.headers.has("Upload-Checksum")?{refusal:g(400,{code:R.UNSUPPORTED_CHECKSUM_ALGORITHM,message:"Upload-Checksum is not supported on this upload route: its storage verifies no checksums",name:"BadRequestError"})}:{declared:{contentType:void 0,metadata:r.metadata}}},finish:(e,r)=>e.method==="OPTIONS"&&!t?k(r):r}),z=100*1024*1024,H=16*1024*1024,S=new WeakMap,O=new A,ce=()=>{const t={get:()=>{const e=O.getStore();if(e?.issuer!==t)throw new y("INTERNAL","@lunora/storage: no upload grant from this UploadContext is active. Read it from a storage callback of a request `authorize` admitted with its grant()");return e.context},grant:e=>{const r=Object.freeze({});return S.set(r,{context:e,issuer:t}),r}};return t},m=(t,e,r)=>{const n={"content-type":"application/json"};return t==="tus"&&(n["Tus-Resumable"]=f),Response.json({error:r},{headers:n,status:e})},h=t=>m(t,403,{code:"FORBIDDEN",message:"Upload denied by authorization policy",name:"ForbiddenError"}),w=t=>m(t,413,{code:"REQUEST_ENTITY_TOO_LARGE",message:"Upload exceeds the configured maxFileSize",name:"RequestEntityTooLargeError"}),N={"chunked-rest":new Set(["HEAD","OPTIONS","PATCH","POST","PUT"]),multipart:new Set(["OPTIONS","POST"]),tus:new Set(["DELETE","HEAD","OPTIONS","PATCH","POST"])},E=(t,e)=>{const r=m(t,405,{code:"METHOD_NOT_ALLOWED",message:e,name:"MethodNotAllowedError"});return r.headers.set("Allow",[...N[t]].join(", ")),r},x=["X-HTTP-Method-Override","X-HTTP-Method","X-Method-Override"],v=/^\d+$/u,j=/\.[^.]+$/u,B=/^[\w-]{1,255}$/u,X=(t,e)=>{const r=(new URL(t.url).pathname.split("/").findLast(Boolean)??"").replace(j,""),n=t.headers.get("Content-Length")?.trim()??"",o=v.test(n)?Number(n):Number.NaN;return B.test(r)&&Number.isSafeInteger(o)&&o<=e.maxUploadSize?r:void 0},G=t=>typeof t=="object"&&t!==null&&t.UploadErrorCode===R.FILE_NOT_FOUND,$=async(t,e)=>{try{return await e.getMeta(t),!0}catch(r){return!G(r)}},b=(t,e)=>{if(e==="multipart")return;let r;for(const n of["Upload-Length","X-Total-Size","Content-Length"]){const o=t.headers.get(n);if(o===null)continue;const s=v.test(o.trim())?Number(o):Number.NaN,a=Number.isSafeInteger(s)?s:Number.POSITIVE_INFINITY;(r===void 0||a>r)&&(r=a)}return r},Y=(t,e,r,n)=>{const{method:o}=t;if(!N[e].has(o))return{refusal:E(e,`${o} is not allowed on this upload route: it is write-only. Serve stored files with ctx.storage.download() or a signed URL`)};const s=x.find(u=>t.headers.has(u));if(s!==void 0)return{refusal:E(e,`${s} is not allowed on this upload route: send the request with the method itself`)};const a=b(t,e);return a!==void 0&&a>r?{refusal:w(e)}:n.check(t)},V=t=>{try{const e=JSON.parse(t??"{}");return e!==null&&typeof e=="object"&&!Array.isArray(e)?e:{}}catch{return{}}},W={check:t=>{const e=t.headers.get("Content-Type");return{declared:{contentType:e===null||e===""?"application/octet-stream":e,metadata:V(t.headers.get("X-File-Metadata"))}}},finish:(t,e)=>e},J={check:()=>({declared:{contentType:void 0,metadata:{}}}),finish:(t,e)=>e},K=t=>{const e=new L({contentType:t.contentType,metadata:t.metadata}),r={};for(const[n,o]of Object.entries(t.metadata))r[n]=typeof o=="string"?o:JSON.stringify(o);return{contentType:e.contentType,metadata:r}},Q=(t,e)=>e!=="multipart"&&(t.method==="POST"||t.method==="PUT"),Z=async(t,e,r,n,o)=>{const s=b(e.request,e.protocol);let a;try{a=await t({...e,...K(n),declaredSize:s,granted:r})}catch{return h(e.protocol)}if(a!==void 0)return typeof a!="number"||!Number.isFinite(a)||a<0?h(e.protocol):s===void 0||s>Math.min(a,o)?w(e.protocol):void 0},q=(t,e)=>{if(t!=="tus"||e.headers.has("Tus-Resumable"))return e;const r=new Response(e.body,e);return r.headers.set("Tus-Resumable",f),r},ee=async(t,e)=>{let r;try{r=await t(e)}catch{return h(e.protocol)}if(r instanceof Response)return q(e.protocol,r);if(r===!0)return{granted:void 0};const n=typeof r=="object"&&r!==null?S.get(r):void 0;return n===void 0?h(e.protocol):{granted:n}},te=(t,e)=>t==="chunked-rest"?new F(e):t==="multipart"?new P(e):new _(e),re=(t,e)=>t==="tus"?D(e.checksumTypes.length>0):t==="chunked-rest"?W:J,de=t=>{const e=t.protocol??"tus",r=t.maxFileSize??z;if(!Number.isFinite(r)||r<0)throw new y("VALIDATION_ERROR",`@lunora/storage: maxFileSize must be a finite, non-negative number (received ${String(r)})`);const n={allowMethodOverride:!1,disableTerminationForFinishedUploads:!0,maxChecksumBufferSize:H,maxFileSize:r,storage:t.storage},o=te(e,n),s=re(e,t.storage),{authorize:a,maxFileSizeFor:u}=t;a===void 0&&!t.silent&&!t.public&&console.warn("@lunora/storage: createUploadHandler() has no `authorize` — this mounts an unauthenticated, unbounded-write endpoint. Pass an RLS `authorize` gate, or set `public: true` (or `silent: true`) to confirm this bucket is intentionally open.");const U=async(i,d,l,c)=>{if(u!==void 0&&Q(i,e)){const T=await Z(u,d,l?.context,c,r);if(T!==void 0)return T}const p=e==="chunked-rest"&&i.method==="PUT"?X(i,t.storage):void 0;return p!==void 0&&await $(p,t.storage)?m(e,409,{code:"FileConflict",message:"A file already exists under this name",name:"ConflictError"}):s.finish(i,await o.fetch(i))};return{fetch:async i=>{const d=Y(i,e,r,s);if("refusal"in d)return d.refusal;const l={method:i.method,protocol:e,request:i,url:new URL(i.url)},c=a===void 0?{granted:void 0}:await ee(a,l);return c instanceof Response?c:O.run(c.granted,async()=>U(i,l,c.granted,d.declared))},protocol:e}};export{z as DEFAULT_MAX_UPLOAD_BYTES,ce as createUploadContext,de as createUploadHandler};
package/dist/upload.d.mts CHANGED
@@ -35,13 +35,52 @@ interface UploadAuthzContext {
35
35
  /** The parsed request URL (query params, upload-id path segment). */
36
36
  url: URL;
37
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>;
38
77
  /**
39
78
  * The context handed to {@link CreateUploadHandlerOptions.maxFileSizeFor}: the
40
79
  * authorization context plus what the create request declares about the file.
41
80
  * Everything here comes from the client, so it caps an upload by what the
42
81
  * client SAYS it is; `allowMIME` on the provider is what checks the type.
43
82
  */
44
- interface UploadSizeContext extends UploadAuthzContext {
83
+ interface UploadSizeContext<Context = unknown> extends UploadAuthzContext {
45
84
  /**
46
85
  * The declared MIME type, resolved the way the stored file's type is: on
47
86
  * chunked REST the request's `Content-Type`; on TUS the `Upload-Metadata`
@@ -55,22 +94,30 @@ interface UploadSizeContext extends UploadAuthzContext {
55
94
  * reads as `Infinity`.
56
95
  */
57
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;
58
99
  /** The decoded TUS `Upload-Metadata`, or the chunked-REST `X-File-Metadata` JSON, as strings. */
59
100
  metadata: Record<string, string>;
60
101
  }
61
102
  /** Options for {@link createUploadHandler}. */
62
- interface CreateUploadHandlerOptions {
103
+ interface CreateUploadHandlerOptions<Context = unknown> {
63
104
  /**
64
105
  * The RLS gate. Runs before every upload request and denies fail-closed:
65
- * returning `false` **or throwing** yields a `403`. Omit only for a fully
66
- * public bucket — the whole point of this handler over the admin path is
67
- * 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.
68
115
  *
69
116
  * Omitting it mounts an unauthenticated, unbounded-write endpoint, so
70
117
  * doing so logs a one-time warning (per handler) unless `silent`/`public`
71
118
  * says the omission is intentional.
72
119
  */
73
- authorize?: (context: UploadAuthzContext) => boolean | Promise<boolean>;
120
+ authorize?: (context: UploadAuthzContext) => UploadAuthorizeResult<Context> | Promise<UploadAuthorizeResult<Context>>;
74
121
  /**
75
122
  * Maximum accepted file size in bytes. Forwarded to the multipart parser
76
123
  * (protocol `"multipart"`) and, for `"tus"`/`"chunked-rest"`, enforced by
@@ -98,7 +145,7 @@ interface CreateUploadHandlerOptions {
98
145
  * returning something that is not a finite, non-negative number, denies the
99
146
  * request (`403`), fail-closed like `authorize`.
100
147
  */
101
- maxFileSizeFor?: (context: UploadSizeContext) => number | undefined | Promise<number | undefined>;
148
+ maxFileSizeFor?: (context: UploadSizeContext<Context>) => number | undefined | Promise<number | undefined>;
102
149
  /** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
103
150
  protocol?: UploadProtocol;
104
151
  /**
@@ -129,11 +176,21 @@ interface UploadHandler {
129
176
  * provider. Mount its {@link UploadHandler.fetch} on the route your client
130
177
  * uploads to and drive it with `@visulima/storage-client`.
131
178
  */
132
- declare const createUploadHandler: (options: CreateUploadHandlerOptions) => UploadHandler;
179
+ declare const createUploadHandler: <Context = unknown>(options: CreateUploadHandlerOptions<Context>) => UploadHandler;
133
180
  /** Every part of an R2 multipart upload but the last is exactly this size: R2's 5 MiB minimum. */
134
181
  declare const R2_PART_SIZE: number;
135
- /** Options for {@link createR2BindingUploadStorage}. */
136
- interface R2BindingUploadStorageOptions extends Omit<BaseStorageOptions, "metaStorage"> {
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"> & {
137
194
  /**
138
195
  * Key prefix the upload state objects and buffered segments are stored
139
196
  * under, in the same bucket. Default `"_lunora/uploads/"`. No uploaded
@@ -146,7 +203,7 @@ interface R2BindingUploadStorageOptions extends Omit<BaseStorageOptions, "metaSt
146
203
  * any upload you expect to take.
147
204
  */
148
205
  statePrefix?: string;
149
- }
206
+ };
150
207
  /**
151
208
  * Build a resumable-upload provider for `createUploadHandler` over a Worker's
152
209
  * R2 binding: no S3 credentials, and the same behaviour under `wrangler dev` /
@@ -219,6 +276,15 @@ export { type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
219
276
  * `@visulima/storage` handler dependency only loads for apps that mount an
220
277
  * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
221
278
  * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
279
+ *
280
+ * On Workers this subpath statically imports `node:async_hooks` (the grant
281
+ * scope) and, through the R2 providers, `node:stream`, so it needs Node.js
282
+ * compatibility to load at all: the import fails at load time otherwise, and
283
+ * the subpath never reaches `createUploadHandler` / `createUploadContext`. A
284
+ * `compatibility_date` of `2026-08-04` or later enables `nodejs_compat` by
285
+ * default; earlier dates need `nodejs_compat` in `compatibility_flags`.
286
+ * `nodejs_als` alone satisfies the `AsyncLocalStorage` import but not
287
+ * `node:stream`, so it is not enough for this subpath. Node.js is unaffected.
222
288
  */
223
289
  type R2BindingUploadStorageOptions,
224
290
  /**
@@ -228,5 +294,14 @@ type R2BindingUploadStorageOptions,
228
294
  * `@visulima/storage` handler dependency only loads for apps that mount an
229
295
  * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
230
296
  * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
297
+ *
298
+ * On Workers this subpath statically imports `node:async_hooks` (the grant
299
+ * scope) and, through the R2 providers, `node:stream`, so it needs Node.js
300
+ * compatibility to load at all: the import fails at load time otherwise, and
301
+ * the subpath never reaches `createUploadHandler` / `createUploadContext`. A
302
+ * `compatibility_date` of `2026-08-04` or later enables `nodejs_compat` by
303
+ * default; earlier dates need `nodejs_compat` in `compatibility_flags`.
304
+ * `nodejs_als` alone satisfies the `AsyncLocalStorage` import but not
305
+ * `node:stream`, so it is not enough for this subpath. Node.js is unaffected.
231
306
  */
232
- type R2UploadBucket, type R2UploadStorageOptions, R2_PART_SIZE, type UploadAuthzContext, type UploadHandler, type UploadProtocol, type UploadSizeContext, type UploadStorage, createR2BindingUploadStorage, createR2UploadStorage, createUploadHandler };
307
+ 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
@@ -35,13 +35,52 @@ interface UploadAuthzContext {
35
35
  /** The parsed request URL (query params, upload-id path segment). */
36
36
  url: URL;
37
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>;
38
77
  /**
39
78
  * The context handed to {@link CreateUploadHandlerOptions.maxFileSizeFor}: the
40
79
  * authorization context plus what the create request declares about the file.
41
80
  * Everything here comes from the client, so it caps an upload by what the
42
81
  * client SAYS it is; `allowMIME` on the provider is what checks the type.
43
82
  */
44
- interface UploadSizeContext extends UploadAuthzContext {
83
+ interface UploadSizeContext<Context = unknown> extends UploadAuthzContext {
45
84
  /**
46
85
  * The declared MIME type, resolved the way the stored file's type is: on
47
86
  * chunked REST the request's `Content-Type`; on TUS the `Upload-Metadata`
@@ -55,22 +94,30 @@ interface UploadSizeContext extends UploadAuthzContext {
55
94
  * reads as `Infinity`.
56
95
  */
57
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;
58
99
  /** The decoded TUS `Upload-Metadata`, or the chunked-REST `X-File-Metadata` JSON, as strings. */
59
100
  metadata: Record<string, string>;
60
101
  }
61
102
  /** Options for {@link createUploadHandler}. */
62
- interface CreateUploadHandlerOptions {
103
+ interface CreateUploadHandlerOptions<Context = unknown> {
63
104
  /**
64
105
  * The RLS gate. Runs before every upload request and denies fail-closed:
65
- * returning `false` **or throwing** yields a `403`. Omit only for a fully
66
- * public bucket — the whole point of this handler over the admin path is
67
- * 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.
68
115
  *
69
116
  * Omitting it mounts an unauthenticated, unbounded-write endpoint, so
70
117
  * doing so logs a one-time warning (per handler) unless `silent`/`public`
71
118
  * says the omission is intentional.
72
119
  */
73
- authorize?: (context: UploadAuthzContext) => boolean | Promise<boolean>;
120
+ authorize?: (context: UploadAuthzContext) => UploadAuthorizeResult<Context> | Promise<UploadAuthorizeResult<Context>>;
74
121
  /**
75
122
  * Maximum accepted file size in bytes. Forwarded to the multipart parser
76
123
  * (protocol `"multipart"`) and, for `"tus"`/`"chunked-rest"`, enforced by
@@ -98,7 +145,7 @@ interface CreateUploadHandlerOptions {
98
145
  * returning something that is not a finite, non-negative number, denies the
99
146
  * request (`403`), fail-closed like `authorize`.
100
147
  */
101
- maxFileSizeFor?: (context: UploadSizeContext) => number | undefined | Promise<number | undefined>;
148
+ maxFileSizeFor?: (context: UploadSizeContext<Context>) => number | undefined | Promise<number | undefined>;
102
149
  /** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
103
150
  protocol?: UploadProtocol;
104
151
  /**
@@ -129,11 +176,21 @@ interface UploadHandler {
129
176
  * provider. Mount its {@link UploadHandler.fetch} on the route your client
130
177
  * uploads to and drive it with `@visulima/storage-client`.
131
178
  */
132
- declare const createUploadHandler: (options: CreateUploadHandlerOptions) => UploadHandler;
179
+ declare const createUploadHandler: <Context = unknown>(options: CreateUploadHandlerOptions<Context>) => UploadHandler;
133
180
  /** Every part of an R2 multipart upload but the last is exactly this size: R2's 5 MiB minimum. */
134
181
  declare const R2_PART_SIZE: number;
135
- /** Options for {@link createR2BindingUploadStorage}. */
136
- interface R2BindingUploadStorageOptions extends Omit<BaseStorageOptions, "metaStorage"> {
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"> & {
137
194
  /**
138
195
  * Key prefix the upload state objects and buffered segments are stored
139
196
  * under, in the same bucket. Default `"_lunora/uploads/"`. No uploaded
@@ -146,7 +203,7 @@ interface R2BindingUploadStorageOptions extends Omit<BaseStorageOptions, "metaSt
146
203
  * any upload you expect to take.
147
204
  */
148
205
  statePrefix?: string;
149
- }
206
+ };
150
207
  /**
151
208
  * Build a resumable-upload provider for `createUploadHandler` over a Worker's
152
209
  * R2 binding: no S3 credentials, and the same behaviour under `wrangler dev` /
@@ -219,6 +276,15 @@ export { type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
219
276
  * `@visulima/storage` handler dependency only loads for apps that mount an
220
277
  * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
221
278
  * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
279
+ *
280
+ * On Workers this subpath statically imports `node:async_hooks` (the grant
281
+ * scope) and, through the R2 providers, `node:stream`, so it needs Node.js
282
+ * compatibility to load at all: the import fails at load time otherwise, and
283
+ * the subpath never reaches `createUploadHandler` / `createUploadContext`. A
284
+ * `compatibility_date` of `2026-08-04` or later enables `nodejs_compat` by
285
+ * default; earlier dates need `nodejs_compat` in `compatibility_flags`.
286
+ * `nodejs_als` alone satisfies the `AsyncLocalStorage` import but not
287
+ * `node:stream`, so it is not enough for this subpath. Node.js is unaffected.
222
288
  */
223
289
  type R2BindingUploadStorageOptions,
224
290
  /**
@@ -228,5 +294,14 @@ type R2BindingUploadStorageOptions,
228
294
  * `@visulima/storage` handler dependency only loads for apps that mount an
229
295
  * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
230
296
  * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
297
+ *
298
+ * On Workers this subpath statically imports `node:async_hooks` (the grant
299
+ * scope) and, through the R2 providers, `node:stream`, so it needs Node.js
300
+ * compatibility to load at all: the import fails at load time otherwise, and
301
+ * the subpath never reaches `createUploadHandler` / `createUploadContext`. A
302
+ * `compatibility_date` of `2026-08-04` or later enables `nodejs_compat` by
303
+ * default; earlier dates need `nodejs_compat` in `compatibility_flags`.
304
+ * `nodejs_als` alone satisfies the `AsyncLocalStorage` import but not
305
+ * `node:stream`, so it is not enough for this subpath. Node.js is unaffected.
231
306
  */
232
- type R2UploadBucket, type R2UploadStorageOptions, R2_PART_SIZE, type UploadAuthzContext, type UploadHandler, type UploadProtocol, type UploadSizeContext, type UploadStorage, createR2BindingUploadStorage, createR2UploadStorage, createUploadHandler };
307
+ 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{createR2BindingUploadStorage as o}from"./packem_shared/createR2BindingUploadStorage-DuD9VXLN.mjs";import{createR2UploadStorage as t}from"./packem_shared/createR2UploadStorage-DGB1xJiD.mjs";import{DEFAULT_MAX_UPLOAD_BYTES as d,createUploadHandler as R}from"./packem_shared/DEFAULT_MAX_UPLOAD_BYTES-COgL1Ipz.mjs";import{R as _}from"./packem_shared/r2-upload-part-writer-SdQlWaAu.mjs";export{d as DEFAULT_MAX_UPLOAD_BYTES,_ as R2_PART_SIZE,o as createR2BindingUploadStorage,t as createR2UploadStorage,R as createUploadHandler};
1
+ import{createR2BindingUploadStorage as r}from"./packem_shared/createR2BindingUploadStorage-DuD9VXLN.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.119",
3
+ "version": "1.0.0-alpha.120",
4
4
  "description": "R2-backed storage for Lunora: typed buckets and signed URLs",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -1 +0,0 @@
1
- import{LunoraError as w}from"@lunora/errors";import{ERRORS as E,parseTusMetadata as N,File as U}from"@visulima/storage";import{Rest as b,Multipart as A,Tus as v}from"@visulima/storage/handler/http/fetch";const S="1.0.0",T=(t,e)=>Response.json({error:e},{headers:{"content-type":"application/json","Tus-Resumable":S},status:t}),I=t=>{try{return{metadata:Object.fromEntries(Object.entries(N(t)))}}catch(e){return{error:e instanceof Error?e.message:"Invalid Upload-Metadata header"}}},F=t=>{const e=new Headers(t.headers),r=e.get("Tus-Extension");return e.delete("Tus-Checksum-Algorithm"),r!==null&&e.set("Tus-Extension",r.split(",").filter(o=>o.trim()!=="checksum").join(",")),new Response(t.body,{headers:e,status:t.status,statusText:t.statusText})},P={declared:{contentType:void 0,metadata:{}}},_=t=>({check:e=>{if(e.method!=="POST"&&e.method!=="PATCH")return P;const r=I(e.headers.get("Upload-Metadata")??"");return"error"in r?{refusal:T(400,{code:"BadRequestError",message:r.error,name:"BadRequestError"})}:!t&&e.headers.has("Upload-Checksum")?{refusal:T(400,{code:E.UNSUPPORTED_CHECKSUM_ALGORITHM,message:"Upload-Checksum is not supported on this upload route: its storage verifies no checksums",name:"BadRequestError"})}:{declared:{contentType:void 0,metadata:r.metadata}}},finish:(e,r)=>e.method==="OPTIONS"&&!t?F(r):r}),L=100*1024*1024,M=16*1024*1024,u=(t,e,r)=>{const o={"content-type":"application/json"};return t==="tus"&&(o["Tus-Resumable"]=S),Response.json({error:r},{headers:o,status:e})},c=t=>u(t,403,{code:"FORBIDDEN",message:"Upload denied by authorization policy",name:"ForbiddenError"}),g=t=>u(t,413,{code:"REQUEST_ENTITY_TOO_LARGE",message:"Upload exceeds the configured maxFileSize",name:"RequestEntityTooLargeError"}),y={"chunked-rest":new Set(["HEAD","OPTIONS","PATCH","POST","PUT"]),multipart:new Set(["OPTIONS","POST"]),tus:new Set(["DELETE","HEAD","OPTIONS","PATCH","POST"])},p=(t,e)=>{const r=u(t,405,{code:"METHOD_NOT_ALLOWED",message:e,name:"MethodNotAllowedError"});return r.headers.set("Allow",[...y[t]].join(", ")),r},k=["X-HTTP-Method-Override","X-HTTP-Method","X-Method-Override"],O=/^\d+$/u,C=/\.[^.]+$/u,D=/^[\w-]{1,255}$/u,H=(t,e)=>{const r=(new URL(t.url).pathname.split("/").findLast(Boolean)??"").replace(C,""),o=t.headers.get("Content-Length")?.trim()??"",n=O.test(o)?Number(o):Number.NaN;return D.test(r)&&Number.isSafeInteger(n)&&n<=e.maxUploadSize?r:void 0},z=t=>typeof t=="object"&&t!==null&&t.UploadErrorCode===E.FILE_NOT_FOUND,x=async(t,e)=>{try{return await e.getMeta(t),!0}catch(r){return!z(r)}},R=(t,e)=>{if(e==="multipart")return;let r;for(const o of["Upload-Length","X-Total-Size","Content-Length"]){const n=t.headers.get(o);if(n===null)continue;const a=O.test(n.trim())?Number(n):Number.NaN,s=Number.isSafeInteger(a)?a:Number.POSITIVE_INFINITY;(r===void 0||s>r)&&(r=s)}return r},j=(t,e,r,o)=>{const{method:n}=t;if(!y[e].has(n))return{refusal:p(e,`${n} is not allowed on this upload route: it is write-only. Serve stored files with ctx.storage.download() or a signed URL`)};const a=k.find(d=>t.headers.has(d));if(a!==void 0)return{refusal:p(e,`${a} is not allowed on this upload route: send the request with the method itself`)};const s=R(t,e);return s!==void 0&&s>r?{refusal:g(e)}:o.check(t)},B=t=>{try{const e=JSON.parse(t??"{}");return e!==null&&typeof e=="object"&&!Array.isArray(e)?e:{}}catch{return{}}},X={check:t=>{const e=t.headers.get("Content-Type");return{declared:{contentType:e===null||e===""?"application/octet-stream":e,metadata:B(t.headers.get("X-File-Metadata"))}}},finish:(t,e)=>e},$={check:()=>({declared:{contentType:void 0,metadata:{}}}),finish:(t,e)=>e},G=t=>{const e=new U({contentType:t.contentType,metadata:t.metadata}),r={};for(const[o,n]of Object.entries(t.metadata))r[o]=typeof n=="string"?n:JSON.stringify(n);return{contentType:e.contentType,metadata:r}},Y=(t,e)=>e!=="multipart"&&(t.method==="POST"||t.method==="PUT"),V=async(t,e,r,o)=>{const n=R(e.request,e.protocol);let a;try{a=await t({...e,...G(r),declaredSize:n})}catch{return c(e.protocol)}if(a!==void 0)return typeof a!="number"||!Number.isFinite(a)||a<0?c(e.protocol):n===void 0||n>Math.min(a,o)?g(e.protocol):void 0},J=(t,e)=>t==="chunked-rest"?new b(e):t==="multipart"?new A(e):new v(e),K=(t,e)=>t==="tus"?_(e.checksumTypes.length>0):t==="chunked-rest"?X:$,ee=t=>{const e=t.protocol??"tus",r=t.maxFileSize??L;if(!Number.isFinite(r)||r<0)throw new w("VALIDATION_ERROR",`@lunora/storage: maxFileSize must be a finite, non-negative number (received ${String(r)})`);const o={allowMethodOverride:!1,disableTerminationForFinishedUploads:!0,maxChecksumBufferSize:M,maxFileSize:r,storage:t.storage},n=J(e,o),a=K(e,t.storage),{authorize:s,maxFileSizeFor:d}=t;return s===void 0&&!t.silent&&!t.public&&console.warn("@lunora/storage: createUploadHandler() has no `authorize` — this mounts an unauthenticated, unbounded-write endpoint. Pass an RLS `authorize` gate, or set `public: true` (or `silent: true`) to confirm this bucket is intentionally open."),{fetch:async i=>{const l=j(i,e,r,a);if("refusal"in l)return l.refusal;const m={method:i.method,protocol:e,request:i,url:new URL(i.url)};if(s!==void 0)try{if(await s(m)!==!0)return c(e)}catch{return c(e)}if(d!==void 0&&Y(i,e)){const h=await V(d,m,l.declared,r);if(h!==void 0)return h}const f=e==="chunked-rest"&&i.method==="PUT"?H(i,t.storage):void 0;return f!==void 0&&await x(f,t.storage)?u(e,409,{code:"FileConflict",message:"A file already exists under this name",name:"ConflictError"}):a.finish(i,await n.fetch(i))},protocol:e}};export{L as DEFAULT_MAX_UPLOAD_BYTES,ee as createUploadHandler};