@lunora/storage 1.0.0-alpha.103 → 1.0.0-alpha.105

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{LunoraError as T}from"@lunora/errors";import{File as S}from"@visulima/storage";import{Rest as g,Multipart as y,Tus as E}from"@visulima/storage/handler/http/fetch";import{AwsLightStorage as b}from"@visulima/storage/provider/aws-light";const w=100*1024*1024,N="1.0.0",m=(t,e,r)=>{const o={"content-type":"application/json"};return t==="tus"&&(o["Tus-Resumable"]=N),Response.json({error:r},{headers:o,status:e})},d=t=>m(t,403,{code:"FORBIDDEN",message:"Upload denied by authorization policy",name:"ForbiddenError"}),f=t=>m(t,413,{code:"REQUEST_ENTITY_TOO_LARGE",message:"Upload exceeds the configured maxFileSize",name:"RequestEntityTooLargeError"}),v=t=>{const e=m(t,405,{code:"METHOD_NOT_ALLOWED",message:"DELETE is not allowed on this upload route",name:"MethodNotAllowedError"});return e.headers.set("Allow",t==="multipart"?"GET, OPTIONS, POST":"GET, HEAD, OPTIONS, PATCH, POST, PUT"),e},O=/^\d+$/u,h=(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 s=O.test(n.trim())?Number(n):Number.NaN,i=Number.isSafeInteger(s)?s:Number.POSITIVE_INFINITY;(r===void 0||i>r)&&(r=i)}return r},R=t=>{const e={};for(const[r,o]of t.split(",").map(n=>n.split(" ")))r!==void 0&&r!==""&&(e[r]=o===void 0||o===""?"":Buffer.from(o,"base64").toString());return e},z=t=>{try{const e=JSON.parse(t??"{}");return e!==null&&typeof e=="object"&&!Array.isArray(e)?e:{}}catch{return{}}},A=(t,e)=>{const r=e==="tus"?R(t.headers.get("Upload-Metadata")??""):z(t.headers.get("X-File-Metadata")),o=t.headers.get("Content-Type"),s=e==="tus"?void 0:o===null||o===""?"application/octet-stream":o,i=new S({contentType:s,metadata:r}),l={};for(const[a,c]of Object.entries(r))l[a]=typeof c=="string"?c:JSON.stringify(c);return{contentType:i.contentType,metadata:l}},F=(t,e)=>e!=="multipart"&&(t.method==="POST"||t.method==="PUT"),I=async(t,e,r)=>{const o=h(e.request,e.protocol);let n;try{n=await t({...e,...A(e.request,e.protocol),declaredSize:o})}catch{return d(e.protocol)}if(n!==void 0)return typeof n!="number"||!Number.isFinite(n)||n<0?d(e.protocol):o===void 0||o>Math.min(n,r)?f(e.protocol):void 0},L=(t,e)=>t==="chunked-rest"?new g(e):t==="multipart"?new y(e):new E(e),M=t=>{const e=t.protocol??"tus",r=t.maxFileSize??w;if(!Number.isFinite(r)||r<0)throw new T("VALIDATION_ERROR",`@lunora/storage: maxFileSize must be a finite, non-negative number (received ${String(r)})`);const o={disableTerminationForFinishedUploads:!0,maxFileSize:r,storage:t.storage},n=L(e,o),{authorize:s,maxFileSizeFor:i}=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 a=>{if(e!=="tus"&&a.method==="DELETE")return v(e);const c=h(a,e);if(c!==void 0&&c>r)return f(e);const p={method:a.method,protocol:e,request:a,url:new URL(a.url)};if(s!==void 0)try{if(await s(p)!==!0)return d(e)}catch{return d(e)}if(i!==void 0&&F(a,e)){const u=await I(i,p,r);if(u!==void 0)return u}return n.fetch(a)},protocol:e}},k=t=>new b({accessKeyId:t.accessKeyId,bucket:t.bucket,endpoint:t.endpoint??`https://${t.accountId}.r2.cloudflarestorage.com`,path:t.path??"/",region:"auto",secretAccessKey:t.secretAccessKey,...t.partSize===void 0?{}:{partSize:t.partSize}});export{w as DEFAULT_MAX_UPLOAD_BYTES,k as createR2UploadStorage,M as createUploadHandler};
@@ -0,0 +1 @@
1
+ import{a as R,R as o}from"./r2-upload-part-writer-BE4HJRLY.mjs";export{R as R2UploadPartWriter,o as R2_PART_SIZE};
@@ -0,0 +1 @@
1
+ import{Readable as v}from"node:stream";import{MetaStorage as O,throwErrorCode as c,ERRORS as o,AbstractBaseStorage as g,File as y,UploadError as _}from"@visulima/storage";import{r as E,a as N}from"./r2-upload-part-writer-BE4HJRLY.mjs";import{R as W}from"./r2-upload-part-writer-BE4HJRLY.mjs";const A=5,T=["bytesWritten","ETag","id","name","size","status"];class x extends O{bucket;constructor(t,e){super({prefix:e,suffix:".json"}),this.bucket=t}segmentPrefix(t){return`${this.prefix}${t}/`}async read(t){const e=await this.bucket.get(this.getMetaName(t));if(e===null)return;const a=await e.text();return{etag:e.etag,state:JSON.parse(a)}}async create(t,e){return await this.bucket.put(this.getMetaName(t),JSON.stringify(e),{onlyIf:{etagDoesNotMatch:"*"}})!==null}async write(t,e,a){return(await this.bucket.put(this.getMetaName(t),JSON.stringify(e),{onlyIf:{etagMatches:a}}))?.etag}async swap(t,e){for(let a=0;a<A;a+=1){const s=await this.read(t);if(s===void 0)return c(o.FILE_NOT_FOUND);const r=e(s.state);if(await this.write(t,r,s.etag)!==void 0)return r}return c(o.FILE_CONFLICT,"The upload kept changing while it was being written; resume from the current offset")}async get(t){const e=await this.read(t);if(e===void 0)throw new Error(`Upload state not found for id: ${t}`);return e.state.file}async save(t,e){return(await this.swap(t,s=>{const i={...e};for(const n of T)i[n]=s.file[n];return{...s,file:i}})).file}async delete(t){await this.bucket.delete(this.getMetaName(t))}}const I="_lunora/uploads/",f=6e4,k=new Set(["checksum","concatenation","creation-defer-length"]),D=new Set([...T,"expiredAt"]),m=u=>c(o.FILE_CONFLICT,u),U=u=>u!==void 0&&u.expiresAt>Date.now(),b=32*1024*1024;class R extends g{meta;bucket;constructor(t,e={}){super(e),this.bucket=t,this.meta=new x(t,e.statePrefix??I)}get tusExtension(){return super.tusExtension.filter(t=>!k.has(t))}async create(t){return this.instrumentOperation("create",async()=>{const e=t.size===void 0||t.size===""?Number.NaN:Number(t.size);if(!Number.isSafeInteger(e)||e<0)return c(o.INVALID_FILE_SIZE,"An upload must declare its size as a non-negative integer");const a=new y(t);if(a.size=e,a.name=this.namingFunction(a),g.assertSafeId(a.id),a.name.includes(this.meta.prefix))return c(o.INVALID_FILE_NAME,"An upload cannot be stored under the upload state prefix");await this.validate(a);const s=await this.meta.read(a.id);return s!==void 0?s.state.file:(a.bytesWritten=0,a.status="created",this.updateTimestamps(a),e===0&&(await this.bucket.put(a.name,new Uint8Array(0),{httpMetadata:{contentType:a.contentType}}),a.status="completed"),await this.meta.create(a.id,{file:a,upload:{parts:[],segments:[]}})?(await this.onCreate(a),a):(await this.meta.read(a.id))?.state.file??m("The upload is being created by another request"))})}async write(t){return this.instrumentOperation("write",async()=>{const e=await this.meta.read(t.id);if(e===void 0)return c(o.FILE_NOT_FOUND);const a=await this.checkIfExpired(e.state.file);if(a.status==="completed"||!("body"in t)||!("start"in t))return a;this.checkPart(a,t);const s=await this.acquireLease(a.id,e.state,e.etag);try{return await this.writeLeased(a,e.state.upload,t.body,s)}catch(r){throw await this.releaseLease(a.id,s).catch(()=>{}),r}})}async get({id:t}){return this.instrumentOperation("get",async()=>{const{file:e,object:a}=await this.completedObject(t);if(a.size>b)return await a.body?.cancel(),c(o.REQUEST_ENTITY_TOO_LARGE,`get() buffers the whole file and refuses one over ${String(b)} bytes; use getStream(), ctx.storage.download() or a signed URL`);const s=Buffer.from(await a.arrayBuffer());return{content:s,contentType:e.contentType,ETag:a.etag,expiredAt:e.expiredAt,id:t,metadata:e.metadata,modifiedAt:e.modifiedAt,name:e.name,originalName:e.originalName,size:s.byteLength}})}async getStream({id:t}){return this.instrumentOperation("getStream",async()=>{const{file:e,object:a}=await this.completedObject(t),s=v.from(E(a.body));return{headers:{"Content-Length":String(a.size),"Content-Type":e.contentType,ETag:a.etag},size:a.size,stream:s}})}async delete({id:t}){return this.instrumentOperation("delete",async()=>{const e=await this.meta.read(t);if(e===void 0)return c(o.FILE_NOT_FOUND);const{file:a,upload:s}=e.state;a.status!=="completed"&&(await this.acquireLease(t,e.state,e.etag),s.uploadId!==void 0&&await this.bucket.resumeMultipartUpload(a.name,s.uploadId).abort().catch(()=>{}),await this.deleteSegments(t)),await this.deleteMeta(t);const r={...a,status:"deleted"};return await this.onDelete(r),r})}async update(t,e){const a=Object.fromEntries(Object.entries(e).filter(([s])=>!D.has(s)));return super.update(t,a)}async list(){return this.instrumentOperation("list",async()=>{const{prefix:t,suffix:e}=this.meta,a=[];let s;do{const r=await this.bucket.list({delimiter:"/",prefix:t,...s===void 0?{}:{cursor:s}});for(const i of r.objects)if(i.key.endsWith(e)){const n=new y({id:this.meta.getIdFromMetaName(i.key),metadata:{}});n.createdAt=(i.uploaded??new Date).toISOString(),n.modifiedAt=n.createdAt,a.push(n)}s=r.truncated===!0?r.cursor:void 0}while(s!==void 0);return a})}copy(){return this.unsupported("copy")}move(){return this.unsupported("move")}async completedObject(t){const e=await this.checkIfExpired(await this.getMeta(t)),a=e.status==="completed"?await this.bucket.get(e.name):void 0;return a==null?c(o.FILE_NOT_FOUND):{file:e,object:a}}unsupported(t){return Promise.reject(new _(o.METHOD_NOT_ALLOWED,`${this.constructor.name} does not implement ${t}()`))}checkPart(t,e){e.start!==t.bytesWritten&&m(`Upload-Offset ${String(e.start)} does not match the upload's offset ${String(t.bytesWritten)}`),e.checksumAlgorithm!==void 0&&e.checksumAlgorithm!==""&&c(o.UNSUPPORTED_CHECKSUM_ALGORITHM),e.contentLength!==void 0&&e.start+e.contentLength>(t.size??0)&&c(o.REQUEST_ENTITY_TOO_LARGE,"The chunk runs past the declared upload length")}async acquireLease(t,e,a){if(U(e.upload.lock))return m("Another request is writing to this upload");const s=crypto.randomUUID(),r={...e.upload,lock:{expiresAt:Date.now()+f,token:s}};return await this.meta.write(t,{...e,upload:r},a)===void 0?m("Another request is writing to this upload"):s}async changeLeased(t,e,a){return this.meta.swap(t,s=>s.upload.lock?.token!==e?m("This request's lease on the upload expired; resume from the current offset"):a(s))}async releaseLease(t,e){await this.changeLeased(t,e,a=>{const s={...a.upload};return delete s.lock,{...a,upload:s}})}async renewLease(t,e){await this.changeLeased(t,e,a=>({...a,upload:{...a.upload,lock:{expiresAt:Date.now()+f,token:e}}}))}async deleteSegments(t){const e=[];let a;do{const s=await this.bucket.list({prefix:this.meta.segmentPrefix(t),...a===void 0?{}:{cursor:a}});e.push(...s.objects.map(r=>r.key)),a=s.truncated===!0?s.cursor:void 0}while(a!==void 0);for(let s=0;s<e.length;s+=1e3)await this.bucket.delete(e.slice(s,s+1e3))}async pump(t,e,a,s,r){const i=E(e);let n=0,d=Date.now();try{for(;;){let h;try{h=await i.next()}catch(l){return l instanceof Error?l:new Error(String(l))}if(h.done===!0)return;if(n+=h.value.byteLength,n>a)return c(o.REQUEST_ENTITY_TOO_LARGE,"The chunk runs past the declared upload length");Date.now()-d>=f/3&&(d=Date.now(),await this.renewLease(s,r)),await t.push(h.value)}}finally{await i.return(void 0).catch(()=>{})}}async writeLeased(t,e,a,s){const r=t.size??0,i=new N(this.bucket,t,e,this.meta.segmentPrefix(t.id),{confirm:async()=>this.renewLease(t.id,s),save:async(p,L)=>{await this.changeLeased(t.id,s,w=>({file:{...w.file,bytesWritten:L,status:"part"},upload:{...p,lock:w.upload.lock}}))},token:s}),n=await this.pump(i,a,r-t.bytesWritten,t.id,s),d=i.offset===r,h=d?await i.finish():void 0;if(!d)try{await i.keepTail()}catch(p){if(n===void 0)throw p}const l={bytesWritten:d?r:i.stored,modifiedAt:new Date().toISOString(),status:d?"completed":"part",...h===void 0?{}:{ETag:h}},S=d?{parts:[],segments:[]}:i.progress();if(await this.changeLeased(t.id,s,p=>({file:{...p.file,...l},upload:S})),await(d?this.deleteSegments(t.id).catch(()=>{}):i.dropConsumed()),n!==void 0)throw n;return{...t,...l}}}const z=(u,t={})=>new R(u,t);export{W as R2_PART_SIZE,z as createR2BindingUploadStorage};
@@ -0,0 +1 @@
1
+ import{throwErrorCode as d,ERRORS as u}from"@visulima/storage";const p=i=>{if(i instanceof Uint8Array)return i;if(typeof i=="string")return new TextEncoder().encode(i);if(ArrayBuffer.isView(i))return new Uint8Array(i.buffer,i.byteOffset,i.byteLength);if(i instanceof ArrayBuffer)return new Uint8Array(i);throw new TypeError("Unsupported upload body chunk")},c=i=>typeof i.getReader=="function",m=async function*(t){if(t!=null){if(t instanceof Uint8Array){yield t;return}if(typeof t=="object"&&c(t)){const e=t.getReader();try{for(;;){const{done:s,value:n}=await e.read();if(s)return;yield p(n)}}finally{e.releaseLock()}}if(typeof t=="object"&&Symbol.asyncIterator in t){for await(const e of t)yield p(e);return}throw new TypeError("Unsupported upload body")}};class l{size=0;chunks=[];push(t){t.byteLength>0&&(this.chunks.push(t),this.size+=t.byteLength)}copyFront(t,e,s){let n=0;for(const a of this.chunks){if(n>=s)break;const r=Math.min(a.byteLength,s-n);t.set(a.subarray(0,r),e+n),n+=r}}drop(t){let e=t;for(;e>0;){const s=this.chunks[0];if(s===void 0)break;s.byteLength<=e?(this.chunks.shift(),e-=s.byteLength):(this.chunks[0]=s.subarray(e),e=0)}this.size-=t-e}}const o=5*1024*1024,f=8,h=i=>i.reduce((t,e)=>t+e.size,0);class y{parts;segments;uploadId;bucket;consumed=[];file;lease;pending=new l;segmentPrefix;constructor(t,e,s,n,a){this.bucket=t,this.file=e,this.parts=[...s.parts],this.segments=[...s.segments],this.uploadId=s.uploadId,this.segmentPrefix=n,this.lease=a}get stored(){return this.parts.length*o+h(this.segments)}get offset(){return this.stored+this.pending.size}progress(){return{parts:[...this.parts],segments:[...this.segments],...this.uploadId===void 0?{}:{uploadId:this.uploadId}}}async push(t){for(this.pending.push(t);h(this.segments)+this.pending.size>=o;)await this.flushPart()}async keepTail(){if(this.pending.size===0)return;const t=this.segments.length+1>f,e=t?this.parts.length*o:this.stored,s=t?h(this.segments)+this.pending.size:this.pending.size,n=t?await this.assemble(s):this.front(s),a=`${this.segmentPrefix}${String(e).padStart(16,"0")}-${this.lease.token}`;await this.bucket.put(a,n),t&&this.consumeSegments(),this.segments=[...this.segments,{key:a,size:s}],this.pending.drop(this.pending.size)}async finish(){const t=await this.assemble(h(this.segments)+this.pending.size);await this.lease.confirm();let e;if(this.uploadId===void 0)e=(await this.bucket.put(this.file.name,t,{httpMetadata:{contentType:this.file.contentType}})).etag;else{const s=this.multipart(this.uploadId);if(t.byteLength>0){const a=this.parts.length+1,r=await s.uploadPart(a,t);this.parts.push({etag:r.etag,partNumber:a})}e=(await s.complete(this.parts)).etag}return this.consumeSegments(),this.pending.drop(this.pending.size),e}async dropConsumed(){const t=this.consumed;this.consumed=[],t.length>0&&await this.bucket.delete(t).catch(()=>{})}async assemble(t){const e=new Uint8Array(t);let s=0;for(const n of this.segments){const a=await this.bucket.get(n.key);if(a===null)return d(u.STORAGE_ERROR,`Buffered upload segment ${n.key} is missing`);const r=new Uint8Array(await a.arrayBuffer());e.set(r,s),s+=r.byteLength}return this.pending.copyFront(e,s,t-s),e}front(t){const e=new Uint8Array(t);return this.pending.copyFront(e,0,t),e}consumeSegments(){this.consumed.push(...this.segments.map(t=>t.key)),this.segments=[]}multipart(t){return this.bucket.resumeMultipartUpload(this.file.name,t)}async flushPart(){const t=h(this.segments),e=await this.assemble(o);if(await this.lease.confirm(),this.uploadId===void 0){const a=await this.bucket.createMultipartUpload(this.file.name,{httpMetadata:{contentType:this.file.contentType}});this.uploadId=a.uploadId}const s=this.parts.length+1,n=await this.multipart(this.uploadId).uploadPart(s,e);this.parts.push({etag:n.etag,partNumber:s}),this.pending.drop(o-t),this.consumeSegments(),await this.lease.save(this.progress(),this.stored),await this.dropConsumed()}}export{o as R,y as a,m as r};
package/dist/upload.d.mts CHANGED
@@ -1,5 +1,13 @@
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";
5
13
  /**
@@ -27,6 +35,29 @@ interface UploadAuthzContext {
27
35
  /** The parsed request URL (query params, upload-id path segment). */
28
36
  url: URL;
29
37
  }
38
+ /**
39
+ * The context handed to {@link CreateUploadHandlerOptions.maxFileSizeFor}: the
40
+ * authorization context plus what the create request declares about the file.
41
+ * Everything here comes from the client, so it caps an upload by what the
42
+ * client SAYS it is; `allowMIME` on the provider is what checks the type.
43
+ */
44
+ interface UploadSizeContext extends UploadAuthzContext {
45
+ /**
46
+ * The declared MIME type, resolved the way the stored file's type is: on
47
+ * chunked REST the request's `Content-Type`; on TUS the `Upload-Metadata`
48
+ * `mimeType`, else `type`, else `filetype`; else
49
+ * `application/octet-stream`.
50
+ */
51
+ contentType: string;
52
+ /**
53
+ * The largest size the request declares (`Upload-Length`, `X-Total-Size`,
54
+ * `Content-Length`). A declared size that is not a non-negative integer
55
+ * reads as `Infinity`.
56
+ */
57
+ declaredSize: number | undefined;
58
+ /** The decoded TUS `Upload-Metadata`, or the chunked-REST `X-File-Metadata` JSON, as strings. */
59
+ metadata: Record<string, string>;
60
+ }
30
61
  /** Options for {@link createUploadHandler}. */
31
62
  interface CreateUploadHandlerOptions {
32
63
  /**
@@ -52,6 +83,22 @@ interface CreateUploadHandlerOptions {
52
83
  * construction rather than disabling the cap.
53
84
  */
54
85
  maxFileSize?: number;
86
+ /**
87
+ * A per-upload size cap, below `maxFileSize`: return the most bytes this
88
+ * upload may hold (say 10 MiB for `image/*`, 2 GiB for `video/*`), or
89
+ * `undefined` to leave `maxFileSize` as the only cap. It can only lower the
90
+ * cap, never raise it past `maxFileSize`.
91
+ *
92
+ * Runs after `authorize`, on the requests that create an upload (`POST`,
93
+ * and the chunked-REST `PUT`) of the `"tus"` and `"chunked-rest"`
94
+ * protocols; later chunks are bounded by the size declared at creation.
95
+ * Not applied to `"multipart"`, whose size is only known once the form is
96
+ * parsed. A create that declares no size is refused (`413`) when this
97
+ * returns a cap, since there is nothing to check it against. Throwing, or
98
+ * returning something that is not a finite, non-negative number, denies the
99
+ * request (`403`), fail-closed like `authorize`.
100
+ */
101
+ maxFileSizeFor?: (context: UploadSizeContext) => number | undefined | Promise<number | undefined>;
55
102
  /** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
56
103
  protocol?: UploadProtocol;
57
104
  /**
@@ -118,43 +165,37 @@ declare const createUploadHandler: (options: CreateUploadHandlerOptions) => Uplo
118
165
  declare const createR2UploadStorage: (options: R2UploadStorageOptions & {
119
166
  secretAccessKey: string;
120
167
  }) => AwsLightStorage;
121
- export {
122
- /**
123
- * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
124
- *
125
- * A separate subpath so the base `@lunora/storage` entry stays lean: the
126
- * `@visulima/storage` handler dependency only loads for apps that mount an
127
- * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
128
- * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
129
- */
130
- type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
131
- /**
132
- * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
133
- *
134
- * A separate subpath so the base `@lunora/storage` entry stays lean: the
135
- * `@visulima/storage` handler dependency only loads for apps that mount an
136
- * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
137
- * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
138
- */
139
- type R2UploadStorageOptions,
168
+ /** Every part of an R2 multipart upload but the last is exactly this size: R2's 5 MiB minimum. */
169
+ declare const R2_PART_SIZE: number;
170
+ /** Options for {@link createR2BindingUploadStorage}. */
171
+ interface R2BindingUploadStorageOptions extends Omit<BaseStorageOptions, "metaStorage"> {
172
+ /**
173
+ * Key prefix the upload state objects and buffered segments are stored
174
+ * under, in the same bucket. Default `"_lunora/uploads/"`. No uploaded
175
+ * object may be named under it.
176
+ *
177
+ * The state of a finished upload stays (a few hundred bytes, so a `HEAD`
178
+ * can still answer that the upload is complete), and so does the state of
179
+ * one that was abandoned. Both accumulate: add an R2 object lifecycle rule
180
+ * that deletes objects under this prefix after a few days, longer than
181
+ * any upload you expect to take.
182
+ */
183
+ statePrefix?: string;
184
+ }
140
185
  /**
141
- * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
186
+ * Build a resumable-upload provider for `createUploadHandler` over a Worker's
187
+ * R2 binding: no S3 credentials, and the same behaviour under `wrangler dev` /
188
+ * miniflare as in production.
142
189
  *
143
- * A separate subpath so the base `@lunora/storage` entry stays lean: the
144
- * `@visulima/storage` handler dependency only loads for apps that mount an
145
- * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
146
- * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
147
- */
148
- type UploadAuthzContext,
149
- /**
150
- * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
190
+ * Speaks TUS (without the `checksum`, `concatenation` and
191
+ * `creation-defer-length` extensions), chunked REST with chunks sent in order
192
+ * (a chunk at any other offset is a `409`), and multipart forms.
151
193
  *
152
- * A separate subpath so the base `@lunora/storage` entry stays lean: the
153
- * `@visulima/storage` handler dependency only loads for apps that mount an
154
- * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
155
- * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
194
+ * Chunked REST is broken upstream, see visulima/visulima#884 and the
195
+ * `@lunora/storage` docs ("Chunked REST is broken upstream").
156
196
  */
157
- type UploadHandler,
197
+ declare const createR2BindingUploadStorage: (bucket: R2UploadBucket, options?: R2BindingUploadStorageOptions) => UploadStorage;
198
+ export { type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
158
199
  /**
159
200
  * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
160
201
  *
@@ -163,7 +204,7 @@ type UploadHandler,
163
204
  * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
164
205
  * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
165
206
  */
166
- type UploadProtocol,
207
+ type R2BindingUploadStorageOptions,
167
208
  /**
168
209
  * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
169
210
  *
@@ -172,4 +213,4 @@ type UploadProtocol,
172
213
  * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
173
214
  * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
174
215
  */
175
- type UploadStorage, createR2UploadStorage, createUploadHandler };
216
+ type R2UploadBucket, type R2UploadStorageOptions, R2_PART_SIZE, type UploadAuthzContext, type UploadHandler, type UploadProtocol, type UploadSizeContext, type UploadStorage, createR2BindingUploadStorage, createR2UploadStorage, createUploadHandler };
package/dist/upload.d.ts CHANGED
@@ -1,5 +1,13 @@
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";
5
13
  /**
@@ -27,6 +35,29 @@ interface UploadAuthzContext {
27
35
  /** The parsed request URL (query params, upload-id path segment). */
28
36
  url: URL;
29
37
  }
38
+ /**
39
+ * The context handed to {@link CreateUploadHandlerOptions.maxFileSizeFor}: the
40
+ * authorization context plus what the create request declares about the file.
41
+ * Everything here comes from the client, so it caps an upload by what the
42
+ * client SAYS it is; `allowMIME` on the provider is what checks the type.
43
+ */
44
+ interface UploadSizeContext extends UploadAuthzContext {
45
+ /**
46
+ * The declared MIME type, resolved the way the stored file's type is: on
47
+ * chunked REST the request's `Content-Type`; on TUS the `Upload-Metadata`
48
+ * `mimeType`, else `type`, else `filetype`; else
49
+ * `application/octet-stream`.
50
+ */
51
+ contentType: string;
52
+ /**
53
+ * The largest size the request declares (`Upload-Length`, `X-Total-Size`,
54
+ * `Content-Length`). A declared size that is not a non-negative integer
55
+ * reads as `Infinity`.
56
+ */
57
+ declaredSize: number | undefined;
58
+ /** The decoded TUS `Upload-Metadata`, or the chunked-REST `X-File-Metadata` JSON, as strings. */
59
+ metadata: Record<string, string>;
60
+ }
30
61
  /** Options for {@link createUploadHandler}. */
31
62
  interface CreateUploadHandlerOptions {
32
63
  /**
@@ -52,6 +83,22 @@ interface CreateUploadHandlerOptions {
52
83
  * construction rather than disabling the cap.
53
84
  */
54
85
  maxFileSize?: number;
86
+ /**
87
+ * A per-upload size cap, below `maxFileSize`: return the most bytes this
88
+ * upload may hold (say 10 MiB for `image/*`, 2 GiB for `video/*`), or
89
+ * `undefined` to leave `maxFileSize` as the only cap. It can only lower the
90
+ * cap, never raise it past `maxFileSize`.
91
+ *
92
+ * Runs after `authorize`, on the requests that create an upload (`POST`,
93
+ * and the chunked-REST `PUT`) of the `"tus"` and `"chunked-rest"`
94
+ * protocols; later chunks are bounded by the size declared at creation.
95
+ * Not applied to `"multipart"`, whose size is only known once the form is
96
+ * parsed. A create that declares no size is refused (`413`) when this
97
+ * returns a cap, since there is nothing to check it against. Throwing, or
98
+ * returning something that is not a finite, non-negative number, denies the
99
+ * request (`403`), fail-closed like `authorize`.
100
+ */
101
+ maxFileSizeFor?: (context: UploadSizeContext) => number | undefined | Promise<number | undefined>;
55
102
  /** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
56
103
  protocol?: UploadProtocol;
57
104
  /**
@@ -118,43 +165,37 @@ declare const createUploadHandler: (options: CreateUploadHandlerOptions) => Uplo
118
165
  declare const createR2UploadStorage: (options: R2UploadStorageOptions & {
119
166
  secretAccessKey: string;
120
167
  }) => AwsLightStorage;
121
- export {
122
- /**
123
- * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
124
- *
125
- * A separate subpath so the base `@lunora/storage` entry stays lean: the
126
- * `@visulima/storage` handler dependency only loads for apps that mount an
127
- * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
128
- * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
129
- */
130
- type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
131
- /**
132
- * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
133
- *
134
- * A separate subpath so the base `@lunora/storage` entry stays lean: the
135
- * `@visulima/storage` handler dependency only loads for apps that mount an
136
- * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
137
- * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
138
- */
139
- type R2UploadStorageOptions,
168
+ /** Every part of an R2 multipart upload but the last is exactly this size: R2's 5 MiB minimum. */
169
+ declare const R2_PART_SIZE: number;
170
+ /** Options for {@link createR2BindingUploadStorage}. */
171
+ interface R2BindingUploadStorageOptions extends Omit<BaseStorageOptions, "metaStorage"> {
172
+ /**
173
+ * Key prefix the upload state objects and buffered segments are stored
174
+ * under, in the same bucket. Default `"_lunora/uploads/"`. No uploaded
175
+ * object may be named under it.
176
+ *
177
+ * The state of a finished upload stays (a few hundred bytes, so a `HEAD`
178
+ * can still answer that the upload is complete), and so does the state of
179
+ * one that was abandoned. Both accumulate: add an R2 object lifecycle rule
180
+ * that deletes objects under this prefix after a few days, longer than
181
+ * any upload you expect to take.
182
+ */
183
+ statePrefix?: string;
184
+ }
140
185
  /**
141
- * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
186
+ * Build a resumable-upload provider for `createUploadHandler` over a Worker's
187
+ * R2 binding: no S3 credentials, and the same behaviour under `wrangler dev` /
188
+ * miniflare as in production.
142
189
  *
143
- * A separate subpath so the base `@lunora/storage` entry stays lean: the
144
- * `@visulima/storage` handler dependency only loads for apps that mount an
145
- * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
146
- * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
147
- */
148
- type UploadAuthzContext,
149
- /**
150
- * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
190
+ * Speaks TUS (without the `checksum`, `concatenation` and
191
+ * `creation-defer-length` extensions), chunked REST with chunks sent in order
192
+ * (a chunk at any other offset is a `409`), and multipart forms.
151
193
  *
152
- * A separate subpath so the base `@lunora/storage` entry stays lean: the
153
- * `@visulima/storage` handler dependency only loads for apps that mount an
154
- * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
155
- * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
194
+ * Chunked REST is broken upstream, see visulima/visulima#884 and the
195
+ * `@lunora/storage` docs ("Chunked REST is broken upstream").
156
196
  */
157
- type UploadHandler,
197
+ declare const createR2BindingUploadStorage: (bucket: R2UploadBucket, options?: R2BindingUploadStorageOptions) => UploadStorage;
198
+ export { type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
158
199
  /**
159
200
  * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
160
201
  *
@@ -163,7 +204,7 @@ type UploadHandler,
163
204
  * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
164
205
  * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
165
206
  */
166
- type UploadProtocol,
207
+ type R2BindingUploadStorageOptions,
167
208
  /**
168
209
  * `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
169
210
  *
@@ -172,4 +213,4 @@ type UploadProtocol,
172
213
  * end-user upload endpoint. Pair with `@visulima/storage-client` (or the
173
214
  * `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
174
215
  */
175
- type UploadStorage, createR2UploadStorage, createUploadHandler };
216
+ type R2UploadBucket, type R2UploadStorageOptions, R2_PART_SIZE, type UploadAuthzContext, type UploadHandler, type UploadProtocol, type UploadSizeContext, type UploadStorage, createR2BindingUploadStorage, createR2UploadStorage, createUploadHandler };
package/dist/upload.mjs CHANGED
@@ -1 +1 @@
1
- import{DEFAULT_MAX_UPLOAD_BYTES as r,createR2UploadStorage as o,createUploadHandler as t}from"./packem_shared/DEFAULT_MAX_UPLOAD_BYTES-DOQ-yvKa.mjs";export{r as DEFAULT_MAX_UPLOAD_BYTES,o as createR2UploadStorage,t as createUploadHandler};
1
+ import{createR2BindingUploadStorage as o}from"./packem_shared/createR2BindingUploadStorage-BQE2iEz6.mjs";import{DEFAULT_MAX_UPLOAD_BYTES as t,createR2UploadStorage as p,createUploadHandler as d}from"./packem_shared/DEFAULT_MAX_UPLOAD_BYTES-BlLcQ6Uc.mjs";import{R as U}from"./packem_shared/r2-upload-part-writer-BE4HJRLY.mjs";export{t as DEFAULT_MAX_UPLOAD_BYTES,U as R2_PART_SIZE,o as createR2BindingUploadStorage,p as createR2UploadStorage,d as createUploadHandler};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/storage",
3
- "version": "1.0.0-alpha.103",
3
+ "version": "1.0.0-alpha.105",
4
4
  "description": "R2-backed storage for Lunora: typed buckets and signed URLs",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -51,8 +51,8 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@lunora/errors": "1.0.0-alpha.50",
54
- "@lunora/platform": "1.0.0-alpha.53",
55
- "@visulima/storage": "2.0.8",
54
+ "@lunora/platform": "1.0.0-alpha.54",
55
+ "@visulima/storage": "2.0.22",
56
56
  "aws4fetch": "1.0.20"
57
57
  },
58
58
  "engines": {
@@ -1 +0,0 @@
1
- import{LunoraError as l}from"@lunora/errors";import{Rest as u,Multipart as m,Tus as h}from"@visulima/storage/handler/http/fetch";import{AwsLightStorage as p}from"@visulima/storage/provider/aws-light";const E=100*1024*1024,T="1.0.0",i=(e,t,r)=>{const o={"content-type":"application/json"};return e==="tus"&&(o["Tus-Resumable"]=T),Response.json({error:r},{headers:o,status:t})},d=e=>i(e,403,{code:"FORBIDDEN",message:"Upload denied by authorization policy",name:"ForbiddenError"}),f=e=>i(e,413,{code:"REQUEST_ENTITY_TOO_LARGE",message:"Upload exceeds the configured maxFileSize",name:"RequestEntityTooLargeError"}),g=e=>{const t=i(e,405,{code:"METHOD_NOT_ALLOWED",message:"DELETE is not allowed on this upload route",name:"MethodNotAllowedError"});return t.headers.set("Allow",e==="multipart"?"GET, OPTIONS, POST":"GET, HEAD, OPTIONS, PATCH, POST, PUT"),t},S=(e,t)=>{if(t==="multipart")return;let r;for(const o of["Upload-Length","X-Total-Size","Content-Length"]){const s=e.headers.get(o);if(s===null)continue;const n=Number(s);Number.isFinite(n)&&(r===void 0||n>r)&&(r=n)}return r},w=(e,t)=>e==="chunked-rest"?new u(t):e==="multipart"?new m(t):new h(t),z=e=>{const t=e.protocol??"tus",r=e.maxFileSize??E;if(!Number.isFinite(r)||r<0)throw new l("VALIDATION_ERROR",`@lunora/storage: maxFileSize must be a finite, non-negative number (received ${String(r)})`);const o={disableTerminationForFinishedUploads:!0,maxFileSize:r,storage:e.storage},s=w(t,o),{authorize:n}=e;return n===void 0&&!e.silent&&!e.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 a=>{if(t!=="tus"&&a.method==="DELETE")return g(t);const c=S(a,t);if(c!==void 0&&c>r)return f(t);if(n!==void 0)try{if(await n({method:a.method,protocol:t,request:a,url:new URL(a.url)})!==!0)return d(t)}catch{return d(t)}return s.fetch(a)},protocol:t}},O=e=>new p({accessKeyId:e.accessKeyId,bucket:e.bucket,endpoint:e.endpoint??`https://${e.accountId}.r2.cloudflarestorage.com`,path:e.path??"/",region:"auto",secretAccessKey:e.secretAccessKey,...e.partSize===void 0?{}:{partSize:e.partSize}});export{E as DEFAULT_MAX_UPLOAD_BYTES,O as createR2UploadStorage,z as createUploadHandler};