@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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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) =>
|
|
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
|
-
/**
|
|
136
|
-
|
|
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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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) =>
|
|
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
|
-
/**
|
|
136
|
-
|
|
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
|
|
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 +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};
|