@lunora/storage 1.0.0-alpha.12 → 1.0.0-alpha.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,134 +1,5 @@
1
- /**
2
- * A single-range read against R2: an `{ offset, length }` window (at least one
3
- * bound required, mirroring R2's own `R2Range`) or a `{ suffix }` tail. The
4
- * subset of `R2Range` that {@link Storage.download} forwards so a caller can
5
- * stream just the bytes it needs instead of the whole object.
6
- */
7
- type R2RangeLike = {
8
- length: number;
9
- offset?: number;
10
- } | {
11
- length?: number;
12
- offset: number;
13
- } | {
14
- suffix: number;
15
- };
16
- /**
17
- * Minimal projection of `R2Bucket`. Declared structurally so unit tests can
18
- * pass a plain object double; the real binding satisfies the same shape.
19
- */
20
- interface R2BucketLike {
21
- /**
22
- * Begin a multipart upload (R2 `createMultipartUpload`). Optional so existing
23
- * test doubles still satisfy the type; {@link Storage.createMultipartUpload}
24
- * throws a clear error when the binding lacks it.
25
- */
26
- createMultipartUpload?: (key: string, options?: {
27
- customMetadata?: Record<string, string>;
28
- httpMetadata?: {
29
- contentType?: string;
30
- };
31
- }) => Promise<R2MultipartUploadLike>;
32
- delete: (key: string) => Promise<void>;
33
- get: (key: string, options?: {
34
- range?: R2RangeLike;
35
- }) => Promise<R2ObjectBodyLike | null>;
36
- /**
37
- * Fetch an object's metadata without its body (R2 HEAD). Returns `null` when
38
- * the object is absent. Declared optional so existing test doubles that only
39
- * implement `get`/`put`/`list`/`delete` still satisfy the type; callers that
40
- * need metadata fall back to a 0-length ranged `get()` when `head` is absent.
41
- */
42
- head?: (key: string) => Promise<R2ObjectLike | null>;
43
- list: (options?: {
44
- cursor?: string;
45
- delimiter?: string;
46
- limit?: number;
47
- prefix?: string;
48
- }) => Promise<{
49
- cursor?: string;
50
- objects: R2ObjectLike[];
51
- truncated?: boolean;
52
- }>;
53
- put: (key: string, body: ReadableStream | ArrayBuffer | Blob | string | null, options?: {
54
- customMetadata?: Record<string, string>;
55
- httpMetadata?: {
56
- contentType?: string;
57
- };
58
- }) => Promise<R2ObjectLike>;
59
- /** Resume an in-progress multipart upload by id (R2 `resumeMultipartUpload`). Optional; see {@link Storage.resumeMultipartUpload}. */
60
- resumeMultipartUpload?: (key: string, uploadId: string) => R2MultipartUploadLike;
61
- }
62
- /** One uploaded multipart part — returned by `uploadPart`, required to `complete`. Mirrors R2's `R2UploadedPart`. */
63
- interface R2UploadedPartLike {
64
- etag: string;
65
- partNumber: number;
66
- }
67
- /**
68
- * An in-progress multipart upload, mirroring R2's `R2MultipartUpload`. Each part
69
- * (except the last) must be uniform in size. The object does not guarantee the
70
- * underlying upload still exists — a parallel `complete`/`abort` can invalidate
71
- * it — so wrap each call in error handling.
72
- */
73
- interface R2MultipartUploadLike {
74
- /** Abort the upload, discarding any uploaded parts. */
75
- abort: () => Promise<void>;
76
- /** Finish the upload from the collected parts; resolves to the stored object. */
77
- complete: (uploadedParts: R2UploadedPartLike[]) => Promise<R2ObjectLike>;
78
- /** The object key being assembled. */
79
- readonly key: string;
80
- /** The R2 upload id (persist it to resume across requests). */
81
- readonly uploadId: string;
82
- /** Upload one part (1-indexed); returns the `{ partNumber, etag }` to pass to `complete`. */
83
- uploadPart: (partNumber: number, value: ArrayBuffer | ArrayBufferView | Blob | ReadableStream | string) => Promise<R2UploadedPartLike>;
84
- }
85
- interface R2ObjectLike {
86
- /**
87
- * R2-computed checksums. The real binding exposes `sha256` as an
88
- * `ArrayBuffer` (present only when R2 stored a SHA-256 for the object);
89
- * declared optional so fakes and non-checksummed objects type-check.
90
- */
91
- checksums?: {
92
- sha256?: ArrayBuffer;
93
- };
94
- customMetadata?: Record<string, string>;
95
- etag: string;
96
- /**
97
- * The quoted form of {@link R2ObjectLike.etag} (e.g. `"abc123"`), suitable
98
- * for emitting directly as an HTTP `ETag` header. The real binding always
99
- * provides it; declared optional so existing doubles that only set `etag`
100
- * still type-check (callers fall back to quoting `etag`).
101
- */
102
- httpEtag?: string;
103
- httpMetadata?: {
104
- contentType?: string;
105
- };
106
- key: string;
107
- /**
108
- * Hex-encoded SHA-256 of the object body, surfaced by `download()`/`list()`
109
- * when R2 carries a checksum (derived from {@link R2ObjectLike.checksums}).
110
- */
111
- sha256?: string;
112
- /**
113
- * Base64-encoded SHA-256 of the object body, surfaced alongside
114
- * {@link R2ObjectLike.sha256} from the same checksum. Base64 is the encoding
115
- * RFC 9530 digest headers (`Repr-Digest`/`Content-Digest`) require, so HTTP
116
- * layers can emit a spec-compliant digest without re-deriving it.
117
- */
118
- sha256Base64?: string;
119
- size: number;
120
- /**
121
- * When the object was written. The real binding exposes this as a `Date`;
122
- * declared optional so fakes that omit it still type-check.
123
- * {@link Storage.getMetadata} normalises it to epoch ms.
124
- */
125
- uploaded?: Date;
126
- }
127
- interface R2ObjectBodyLike extends R2ObjectLike {
128
- arrayBuffer: () => Promise<ArrayBuffer>;
129
- body: ReadableStream | null;
130
- text: () => Promise<string>;
131
- }
1
+ import { R2MultipartUploadLike, R2RangeLike, R2ObjectBodyLike, R2ObjectLike, R2BucketLike } from '@lunora/platform';
2
+ export type { R2BucketLike, R2MultipartUploadLike, R2ObjectBodyLike, R2ObjectLike, R2RangeLike, R2UploadedPartLike } from '@lunora/platform';
132
3
  /**
133
4
  * R2 S3-API credentials for {@link Storage.getPresignedUrl}. These are an R2 API
134
5
  * token's Access Key ID / Secret Access Key (NOT a Cloudflare API token), plus
@@ -403,4 +274,4 @@ interface VerifyResult {
403
274
  declare const verifySignedUrl: (input: string | URL, secret: string, options?: {
404
275
  expectedHost?: string;
405
276
  }) => Promise<VerifyResult>;
406
- export { type BucketStorage, type ListOptions, type LunoraStorageOptions, type ObjectMetadata, type PresignedUrlOptions, type PresignedUrlParams, type R2BucketLike, type R2MultipartUploadLike, type R2ObjectBodyLike, type R2ObjectLike, type R2RangeLike, type R2S3Credentials, type R2UploadedPartLike, type SignedUrlOptions, type Storage, type UploadOptions, type VerifyResult, buildPresignedUrl, buildSignedUrl, createBucketStorage, createStorage, scopeKey, verifySignedUrl };
277
+ export { type BucketStorage, type ListOptions, type LunoraStorageOptions, type ObjectMetadata, type PresignedUrlOptions, type PresignedUrlParams, type R2S3Credentials, type SignedUrlOptions, type Storage, type UploadOptions, type VerifyResult, buildPresignedUrl, buildSignedUrl, createBucketStorage, createStorage, scopeKey, verifySignedUrl };
package/dist/index.d.ts CHANGED
@@ -1,134 +1,5 @@
1
- /**
2
- * A single-range read against R2: an `{ offset, length }` window (at least one
3
- * bound required, mirroring R2's own `R2Range`) or a `{ suffix }` tail. The
4
- * subset of `R2Range` that {@link Storage.download} forwards so a caller can
5
- * stream just the bytes it needs instead of the whole object.
6
- */
7
- type R2RangeLike = {
8
- length: number;
9
- offset?: number;
10
- } | {
11
- length?: number;
12
- offset: number;
13
- } | {
14
- suffix: number;
15
- };
16
- /**
17
- * Minimal projection of `R2Bucket`. Declared structurally so unit tests can
18
- * pass a plain object double; the real binding satisfies the same shape.
19
- */
20
- interface R2BucketLike {
21
- /**
22
- * Begin a multipart upload (R2 `createMultipartUpload`). Optional so existing
23
- * test doubles still satisfy the type; {@link Storage.createMultipartUpload}
24
- * throws a clear error when the binding lacks it.
25
- */
26
- createMultipartUpload?: (key: string, options?: {
27
- customMetadata?: Record<string, string>;
28
- httpMetadata?: {
29
- contentType?: string;
30
- };
31
- }) => Promise<R2MultipartUploadLike>;
32
- delete: (key: string) => Promise<void>;
33
- get: (key: string, options?: {
34
- range?: R2RangeLike;
35
- }) => Promise<R2ObjectBodyLike | null>;
36
- /**
37
- * Fetch an object's metadata without its body (R2 HEAD). Returns `null` when
38
- * the object is absent. Declared optional so existing test doubles that only
39
- * implement `get`/`put`/`list`/`delete` still satisfy the type; callers that
40
- * need metadata fall back to a 0-length ranged `get()` when `head` is absent.
41
- */
42
- head?: (key: string) => Promise<R2ObjectLike | null>;
43
- list: (options?: {
44
- cursor?: string;
45
- delimiter?: string;
46
- limit?: number;
47
- prefix?: string;
48
- }) => Promise<{
49
- cursor?: string;
50
- objects: R2ObjectLike[];
51
- truncated?: boolean;
52
- }>;
53
- put: (key: string, body: ReadableStream | ArrayBuffer | Blob | string | null, options?: {
54
- customMetadata?: Record<string, string>;
55
- httpMetadata?: {
56
- contentType?: string;
57
- };
58
- }) => Promise<R2ObjectLike>;
59
- /** Resume an in-progress multipart upload by id (R2 `resumeMultipartUpload`). Optional; see {@link Storage.resumeMultipartUpload}. */
60
- resumeMultipartUpload?: (key: string, uploadId: string) => R2MultipartUploadLike;
61
- }
62
- /** One uploaded multipart part — returned by `uploadPart`, required to `complete`. Mirrors R2's `R2UploadedPart`. */
63
- interface R2UploadedPartLike {
64
- etag: string;
65
- partNumber: number;
66
- }
67
- /**
68
- * An in-progress multipart upload, mirroring R2's `R2MultipartUpload`. Each part
69
- * (except the last) must be uniform in size. The object does not guarantee the
70
- * underlying upload still exists — a parallel `complete`/`abort` can invalidate
71
- * it — so wrap each call in error handling.
72
- */
73
- interface R2MultipartUploadLike {
74
- /** Abort the upload, discarding any uploaded parts. */
75
- abort: () => Promise<void>;
76
- /** Finish the upload from the collected parts; resolves to the stored object. */
77
- complete: (uploadedParts: R2UploadedPartLike[]) => Promise<R2ObjectLike>;
78
- /** The object key being assembled. */
79
- readonly key: string;
80
- /** The R2 upload id (persist it to resume across requests). */
81
- readonly uploadId: string;
82
- /** Upload one part (1-indexed); returns the `{ partNumber, etag }` to pass to `complete`. */
83
- uploadPart: (partNumber: number, value: ArrayBuffer | ArrayBufferView | Blob | ReadableStream | string) => Promise<R2UploadedPartLike>;
84
- }
85
- interface R2ObjectLike {
86
- /**
87
- * R2-computed checksums. The real binding exposes `sha256` as an
88
- * `ArrayBuffer` (present only when R2 stored a SHA-256 for the object);
89
- * declared optional so fakes and non-checksummed objects type-check.
90
- */
91
- checksums?: {
92
- sha256?: ArrayBuffer;
93
- };
94
- customMetadata?: Record<string, string>;
95
- etag: string;
96
- /**
97
- * The quoted form of {@link R2ObjectLike.etag} (e.g. `"abc123"`), suitable
98
- * for emitting directly as an HTTP `ETag` header. The real binding always
99
- * provides it; declared optional so existing doubles that only set `etag`
100
- * still type-check (callers fall back to quoting `etag`).
101
- */
102
- httpEtag?: string;
103
- httpMetadata?: {
104
- contentType?: string;
105
- };
106
- key: string;
107
- /**
108
- * Hex-encoded SHA-256 of the object body, surfaced by `download()`/`list()`
109
- * when R2 carries a checksum (derived from {@link R2ObjectLike.checksums}).
110
- */
111
- sha256?: string;
112
- /**
113
- * Base64-encoded SHA-256 of the object body, surfaced alongside
114
- * {@link R2ObjectLike.sha256} from the same checksum. Base64 is the encoding
115
- * RFC 9530 digest headers (`Repr-Digest`/`Content-Digest`) require, so HTTP
116
- * layers can emit a spec-compliant digest without re-deriving it.
117
- */
118
- sha256Base64?: string;
119
- size: number;
120
- /**
121
- * When the object was written. The real binding exposes this as a `Date`;
122
- * declared optional so fakes that omit it still type-check.
123
- * {@link Storage.getMetadata} normalises it to epoch ms.
124
- */
125
- uploaded?: Date;
126
- }
127
- interface R2ObjectBodyLike extends R2ObjectLike {
128
- arrayBuffer: () => Promise<ArrayBuffer>;
129
- body: ReadableStream | null;
130
- text: () => Promise<string>;
131
- }
1
+ import { R2MultipartUploadLike, R2RangeLike, R2ObjectBodyLike, R2ObjectLike, R2BucketLike } from '@lunora/platform';
2
+ export type { R2BucketLike, R2MultipartUploadLike, R2ObjectBodyLike, R2ObjectLike, R2RangeLike, R2UploadedPartLike } from '@lunora/platform';
132
3
  /**
133
4
  * R2 S3-API credentials for {@link Storage.getPresignedUrl}. These are an R2 API
134
5
  * token's Access Key ID / Secret Access Key (NOT a Cloudflare API token), plus
@@ -403,4 +274,4 @@ interface VerifyResult {
403
274
  declare const verifySignedUrl: (input: string | URL, secret: string, options?: {
404
275
  expectedHost?: string;
405
276
  }) => Promise<VerifyResult>;
406
- export { type BucketStorage, type ListOptions, type LunoraStorageOptions, type ObjectMetadata, type PresignedUrlOptions, type PresignedUrlParams, type R2BucketLike, type R2MultipartUploadLike, type R2ObjectBodyLike, type R2ObjectLike, type R2RangeLike, type R2S3Credentials, type R2UploadedPartLike, type SignedUrlOptions, type Storage, type UploadOptions, type VerifyResult, buildPresignedUrl, buildSignedUrl, createBucketStorage, createStorage, scopeKey, verifySignedUrl };
277
+ export { type BucketStorage, type ListOptions, type LunoraStorageOptions, type ObjectMetadata, type PresignedUrlOptions, type PresignedUrlParams, type R2S3Credentials, type SignedUrlOptions, type Storage, type UploadOptions, type VerifyResult, buildPresignedUrl, buildSignedUrl, createBucketStorage, createStorage, scopeKey, verifySignedUrl };
package/dist/index.mjs CHANGED
@@ -1,4 +1 @@
1
- export { createBucketStorage } from './packem_shared/createBucketStorage-JYdrS4e5.mjs';
2
- export { createStorage, scopeKey } from './packem_shared/createStorage-Bu6GJB25.mjs';
3
- export { buildPresignedUrl } from './packem_shared/buildPresignedUrl-B5f1wvz6.mjs';
4
- export { buildSignedUrl, verifySignedUrl } from './packem_shared/buildSignedUrl-rt-6azia.mjs';
1
+ import{createBucketStorage as o}from"./packem_shared/createBucketStorage-DAip6cEw.mjs";import{createStorage as i,scopeKey as d}from"./packem_shared/createStorage-CWKguFEG.mjs";import{buildPresignedUrl as g}from"./packem_shared/buildPresignedUrl-TCv-5TAT.mjs";import{buildSignedUrl as p,verifySignedUrl as a}from"./packem_shared/buildSignedUrl-DocLLDGy.mjs";export{g as buildPresignedUrl,p as buildSignedUrl,o as createBucketStorage,i as createStorage,d as scopeKey,a as verifySignedUrl};
@@ -0,0 +1,4 @@
1
+ import{a as g}from"./internal-DpjlEJPE.mjs";const h="auto",y="s3",S="AWS4-HMAC-SHA256",M=1,X=10080*60,w=900,d=new TextEncoder,j=(e,t)=>e[0]<t[0]?-1:e[0]>t[0]?1:0,s=e=>encodeURIComponent(e).replaceAll(/[!'()*]/gu,t=>`%${t.codePointAt(0)?.toString(16).toUpperCase()??""}`),f=e=>e.split("/").map(t=>s(t)).join("/"),U=async e=>g(await crypto.subtle.digest("SHA-256",d.encode(e))),n=async(e,t)=>{const a=await crypto.subtle.importKey("raw",e,{hash:"SHA-256",name:"HMAC"},!1,["sign"]);return crypto.subtle.sign("HMAC",a,d.encode(t))},E=async(e,t)=>{const a=await n(d.encode(`AWS4${e}`),t),r=await n(a,h),o=await n(r,y);return n(o,"aws4_request")},K=e=>{const t=e.jurisdiction===void 0?"":`${e.jurisdiction}.`;return`${e.accountId}.${t}r2.cloudflarestorage.com`},P=e=>{const t=`${e.toISOString().replaceAll(/[:-]/gu,"").slice(0,15)}Z`;return{amzDate:t,dateStamp:t.slice(0,8)}},k=async e=>{const{credentials:t,key:a}=e,r=e.method??"GET",o=e.expiresInSeconds??w,z=Number.isFinite(o)?o:w,H=Math.min(Math.max(M,Math.floor(z)),X),m=K(t),D=new Date(e.now?.()??Date.now()),{amzDate:l,dateStamp:u}=P(D),A=`${u}/${h}/${y}/aws4_request`,p=`/${s(t.bucket)}/${f(a)}`,$=[["X-Amz-Algorithm",S],["X-Amz-Credential",`${t.accessKeyId}/${A}`],["X-Amz-Date",l],["X-Amz-Expires",H.toString()],["X-Amz-SignedHeaders","host"]].map(([i,c])=>[s(i),s(c)]).toSorted(j).map(([i,c])=>`${i}=${c}`).join("&"),b=[r,p,$,`host:${m}
2
+ `,"host","UNSIGNED-PAYLOAD"].join(`
3
+ `),x=[S,l,A,await U(b)].join(`
4
+ `),C=await E(t.secretAccessKey,u),I=g(await n(C,x));return`https://${m}${p}?${$}&X-Amz-Signature=${I}`};export{k as buildPresignedUrl};
@@ -0,0 +1,5 @@
1
+ import{LunoraError as p}from"@lunora/errors";import{t as A}from"./internal-DpjlEJPE.mjs";const $=(e,r)=>{if(e.size<r)return;const t=e.keys().next().value;t!==void 0&&e.delete(t)},u=new TextEncoder,f=10080*60,b=/^[a-z][a-z0-9+\-.]*:\/\//i,U=e=>{const r=String.fromCodePoint(...e);return btoa(r).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")},x=e=>{const r=e.replaceAll("-","+").replaceAll("_","/")+"===".slice((e.length+3)%4),t=atob(r),n=new Uint8Array(t.length);for(let a=0;a<t.length;a+=1)n[a]=t.codePointAt(a)??0;return n},R=64,m=new Map,g=async e=>{const r=m.get(e);if(r)return r;$(m,R);const t=crypto.subtle.importKey("raw",u.encode(e),{hash:"SHA-256",name:"HMAC"},!1,["sign","verify"]);return m.set(e,t),t},y=e=>{try{return new URL(e).host}catch{return e.replace(b,"").split("/")[0]??""}},S=async(e,r)=>{const t=await g(e),n=await crypto.subtle.sign("HMAC",t,u.encode(r));return U(new Uint8Array(n))},I=async(e,r,t)=>{const n=await g(e);return crypto.subtle.verify("HMAC",n,t,u.encode(r))},T=/^\//,h=(e,r,t,n,a)=>{const o=`${e}
2
+ ${r.toLowerCase()}
3
+ ${t}
4
+ ${String(n)}`;return a===void 0?o:`${o}
5
+ ${a}`},P=async e=>{const r=e.method??"GET",t=e.expiresInSeconds??3600;if(!Number.isFinite(t)||t<=0)throw new p("VALIDATION_ERROR","@lunora/storage: expiresInSeconds must be a positive finite number");if(t>f)throw new p("VALIDATION_ERROR",`@lunora/storage: expiresInSeconds must not exceed ${String(f)} (7 days)`);const n=r==="PUT"?e.contentType:void 0,a=Math.floor(Date.now()/1e3)+t,o=y(e.baseUrl),c=await S(e.secret,h(r,o,e.key,a,n)),s=A(e.baseUrl),l=e.key.split("/").map(d=>encodeURIComponent(d)).join("/"),i=n===void 0?"":`&ct=${encodeURIComponent(n)}`;return`${s}/${l}?exp=${String(a)}&method=${r}&sig=${c}${i}`},E=async(e,r,t)=>{let n;try{n=e instanceof URL?e:new URL(e)}catch{return{reason:"malformed",valid:!1}}const a=n.searchParams.get("exp"),o=a===null?Number.NaN:Number(a),c=n.searchParams.get("sig"),s=n.searchParams.get("method")??"GET",l=n.searchParams.get("ct")??void 0;if(!c||!Number.isInteger(o))return{reason:"malformed",valid:!1};if(o<Math.floor(Date.now()/1e3))return{reason:"expired",valid:!1};if(s!=="GET"&&s!=="PUT")return{reason:"malformed",valid:!1};let i,d;try{i=n.pathname.replace(T,"").split("/").map(v=>decodeURIComponent(v)).join("/"),d=x(c)}catch{return{reason:"malformed",valid:!1}}const w=t?.expectedHost===void 0?n.host:y(t.expectedHost);return await I(r,h(s,w,i,o,l),d)?{contentType:l,key:i,method:s,valid:!0}:{reason:"bad_signature",valid:!1}};export{P as buildSignedUrl,E as verifySignedUrl};
@@ -0,0 +1 @@
1
+ import{LunoraError as u}from"@lunora/errors";const d=(t,o={})=>{const r=Object.keys(t),[n]=r;if(n===void 0)throw new u("INTERNAL","@lunora/storage: createBucketStorage requires at least one bucket");if(o.default!==void 0&&!t[o.default])throw new u("INTERNAL",`@lunora/storage: default bucket "${o.default}" is not in the bucket map (have: ${r.join(", ")})`);const e=o.default??"default",c=t[e]??t[n];if(c===void 0)throw new u("INTERNAL",`@lunora/storage: default bucket "${e}" is not in the bucket map (have: ${r.join(", ")})`);const f=[...new Set([e,...r])],i=a=>{const s=a===e?c:t[a];if(!s)throw new u("INTERNAL",`@lunora/storage: no bucket registered for "${a}". Known buckets: ${f.join(", ")}`);return{...s,bucket:k=>i(k),bucketName:a}};return i(e)};export{d as createBucketStorage};
@@ -0,0 +1 @@
1
+ import{Rest as n,Multipart as i,Tus as u}from"@visulima/storage/handler/http/fetch";import{AwsLightStorage as p}from"@visulima/storage/provider/aws-light";const d="1.0.0",a=e=>{const t={"content-type":"application/json"};return e==="tus"&&(t["Tus-Resumable"]=d),Response.json({error:{code:"FORBIDDEN",message:"Upload denied by authorization policy",name:"ForbiddenError"}},{headers:t,status:403})},l=(e,t)=>e==="chunked-rest"?new n(t):e==="multipart"?new i(t):new u(t),g=e=>{const t=e.protocol??"tus",s={storage:e.storage,...e.maxFileSize===void 0?{}:{maxFileSize:e.maxFileSize}},c=l(t,s),{authorize:o}=e;return{fetch:async r=>{if(o!==void 0)try{if(!await o({method:r.method,protocol:t,request:r,url:new URL(r.url)}))return a(t)}catch{return a(t)}return c.fetch(r)},protocol:t}},y=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{y as createR2UploadStorage,g as createUploadHandler};
@@ -0,0 +1 @@
1
+ import{LunoraError as a}from"@lunora/errors";import{t as T,a as f}from"./internal-DpjlEJPE.mjs";import{buildPresignedUrl as R}from"./buildPresignedUrl-TCv-5TAT.mjs";import{buildSignedUrl as k}from"./buildSignedUrl-DocLLDGy.mjs";const m=1024,I=1e3,A=100,b=e=>{const o=new Uint8Array(e);let i="";for(const p of o)i+=String.fromCodePoint(p);return btoa(i)},U=e=>{const o=e.checksums?.sha256;if(o===void 0)return e;const i=f(o),p=b(o);return new Proxy(e,{get(u,s){if(s==="sha256")return i;if(s==="sha256Base64")return p;const l=Reflect.get(u,s,u);return typeof l=="function"?l.bind(u):l},has(u,s){return s==="sha256"||s==="sha256Base64"||Reflect.has(u,s)}})},w=e=>{const o=e.checksums?.sha256;return{contentType:e.httpMetadata?.contentType,customMetadata:e.customMetadata,key:e.key,sha256:o===void 0?void 0:f(o),size:e.size,uploaded:e.uploaded===void 0?void 0:e.uploaded.getTime()}},S=e=>{const o=e.checksums?.sha256;return{checksums:e.checksums,customMetadata:e.customMetadata,etag:e.etag,httpEtag:e.httpEtag,httpMetadata:e.httpMetadata,key:e.key,sha256:o===void 0?void 0:f(o),sha256Base64:o===void 0?void 0:b(o),size:e.size,uploaded:e.uploaded}},M=(e,o)=>{let i=0;const p=s=>s instanceof ArrayBuffer||ArrayBuffer.isView(s)?s.byteLength:void 0,u=new TransformStream({transform(s,l){const h=p(s);if(h===void 0){l.error(new Error("@lunora/storage: stream chunk is not a byte chunk; cannot enforce maxSize"));return}if(i+=h,i>o){l.error(new Error(`@lunora/storage: stream body exceeds maxSize (> ${String(o)} bytes)`));return}l.enqueue(s)}});return e.pipeThrough(u)},c=e=>{if(typeof e!="string"||e.length===0)throw new a("VALIDATION_ERROR","@lunora/storage: key must be a non-empty string");if(e.length>m)throw new a("VALIDATION_ERROR",`@lunora/storage: key exceeds ${String(m)}-byte limit`);if(e.includes("\0"))throw new a("VALIDATION_ERROR","@lunora/storage: key contains NUL byte");if(e.startsWith("/"))throw new a("VALIDATION_ERROR","@lunora/storage: key must not start with `/`");const o=e.split("/");for(const i of o)if(i==="..")throw new a("VALIDATION_ERROR","@lunora/storage: key contains a `..` path component")},O=(e,o)=>{c(e),c(o);const i=`${e.endsWith("/")?e.slice(0,-1):e}/${o}`;if(i.length>m)throw new a("VALIDATION_ERROR",`@lunora/storage: scoped key exceeds ${String(m)}-byte limit`);return i},v=e=>{if(!e.bucket)throw new a("INTERNAL","@lunora/storage: `bucket` is required");const o=async(r,t,n={})=>{if(c(r),n.allowedContentTypes!==void 0){if(n.contentType===void 0)throw new a("VALIDATION_ERROR","@lunora/storage: contentType is required when allowedContentTypes is set");if(!n.allowedContentTypes.includes(n.contentType))throw new a("VALIDATION_ERROR",`@lunora/storage: contentType "${n.contentType}" not in allowedContentTypes`)}let y=t;if(typeof n.maxSize=="number"){let g;if(t instanceof ArrayBuffer?g=t.byteLength:t instanceof Blob&&(g=t.size),g!==void 0&&g>n.maxSize)throw new a("PAYLOAD_TOO_LARGE",`@lunora/storage: body exceeds maxSize (${String(g)} > ${String(n.maxSize)})`);t instanceof ReadableStream&&(y=M(t,n.maxSize))}const d=await e.bucket.put(r,y,{customMetadata:n.customMetadata,httpMetadata:n.contentType?{contentType:n.contentType}:void 0});return{etag:d.etag,httpEtag:d.httpEtag??`"${d.etag}"`,key:d.key}},i=async(r,t={})=>{c(r);const n=await(t.range?e.bucket.get(r,{range:t.range}):e.bucket.get(r));return n&&U(n)},p=async r=>{c(r),await e.bucket.delete(r)},u=async r=>{if(c(r),e.bucket.head){const n=await e.bucket.head(r);return n&&w(n)}const t=await e.bucket.get(r,{range:{length:0}});return t&&w(t)},s=async(r,t={})=>{if(r?.includes("\0"))throw new a("VALIDATION_ERROR","@lunora/storage: prefix contains NUL byte");const n=t.limit??A,y=Math.min(Math.max(1,Math.floor(n)),I),d=await e.bucket.list({cursor:t.cursor,delimiter:t.delimiter,limit:y,prefix:r});return{cursor:d.cursor,objects:d.objects.map(g=>S(g)),truncated:d.truncated}},l=r=>{if(!e.publicBaseUrl)throw new a("INTERNAL","@lunora/storage: `publicBaseUrl` is required for getUrl()");c(r);const t=r.split("/").map(n=>encodeURIComponent(n)).join("/");return`${T(e.publicBaseUrl)}/${t}`},h=async(r,t={})=>{if(!e.publicBaseUrl)throw new a("INTERNAL","@lunora/storage: `publicBaseUrl` is required for getSignedUrl()");if(!e.signingSecret)throw new a("INTERNAL","@lunora/storage: `signingSecret` is required for getSignedUrl()");return c(r),k({baseUrl:e.publicBaseUrl,contentType:t.contentType,expiresInSeconds:t.expiresInSeconds,key:r,method:t.method,secret:e.signingSecret})};return{createMultipartUpload:async(r,t={})=>{if(c(r),!e.bucket.createMultipartUpload)throw new a("INTERNAL","@lunora/storage: bucket binding does not support multipart uploads (createMultipartUpload)");return e.bucket.createMultipartUpload(r,{customMetadata:t.customMetadata,httpMetadata:t.contentType?{contentType:t.contentType}:void 0})},delete:p,download:i,generateUploadUrl:async(r,t={})=>h(r,{contentType:t.contentType,expiresInSeconds:t.expiresInSeconds,method:"PUT"}),getMetadata:u,getPresignedUrl:async(r,t={})=>{if(!e.s3)throw new a("INTERNAL","@lunora/storage: `s3` credentials are required for getPresignedUrl() — pass { accountId, accessKeyId, secretAccessKey, bucket }");return c(r),R({credentials:e.s3,expiresInSeconds:t.expiresInSeconds,key:r,method:t.method})},getSignedUrl:h,getUrl:l,list:s,resumeMultipartUpload:(r,t)=>{if(c(r),typeof t!="string"||t.length===0)throw new a("VALIDATION_ERROR","@lunora/storage: resumeMultipartUpload requires a non-empty uploadId");if(!e.bucket.resumeMultipartUpload)throw new a("INTERNAL","@lunora/storage: bucket binding does not support multipart uploads (resumeMultipartUpload)");return e.bucket.resumeMultipartUpload(r,t)},store:async(r,t,n={})=>o(r,t,n),upload:o}};export{v as createStorage,O as scopeKey};
@@ -0,0 +1 @@
1
+ const o=e=>{const t=new Uint8Array(e);let n="";for(const r of t)n+=r.toString(16).padStart(2,"0");return n},a=e=>{let t=e.length;for(;t>0&&e[t-1]==="/";)t-=1;return e.slice(0,t)};export{o as a,a as t};
package/dist/upload.mjs CHANGED
@@ -1 +1 @@
1
- export { createR2UploadStorage, createUploadHandler } from './packem_shared/createR2UploadStorage-BfLIpWdf.mjs';
1
+ import{createR2UploadStorage as r,createUploadHandler as o}from"./packem_shared/createR2UploadStorage-BSgOjRvv.mjs";export{r as createR2UploadStorage,o as createUploadHandler};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/storage",
3
- "version": "1.0.0-alpha.12",
3
+ "version": "1.0.0-alpha.14",
4
4
  "description": "R2-backed storage for Lunora: typed buckets and signed URLs",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -50,7 +50,8 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@lunora/errors": "1.0.0-alpha.8",
53
+ "@lunora/errors": "1.0.0-alpha.9",
54
+ "@lunora/platform": "1.0.0-alpha.1",
54
55
  "@visulima/storage": "1.0.5",
55
56
  "aws4fetch": "1.0.20"
56
57
  },
@@ -1,64 +0,0 @@
1
- import { a as toHex } from './internal-Dqk0MrAj.mjs';
2
-
3
- const REGION = "auto";
4
- const SERVICE = "s3";
5
- const ALGORITHM = "AWS4-HMAC-SHA256";
6
- const MIN_EXPIRES_SECONDS = 1;
7
- const MAX_EXPIRES_SECONDS = 7 * 24 * 60 * 60;
8
- const DEFAULT_EXPIRES_SECONDS = 900;
9
- const textEncoder = new TextEncoder();
10
- const compareEntries = (a, b) => {
11
- if (a[0] < b[0]) {
12
- return -1;
13
- }
14
- return a[0] > b[0] ? 1 : 0;
15
- };
16
- const encodeRfc3986 = (value) => encodeURIComponent(value).replaceAll(/[!'()*]/gu, (char) => `%${char.codePointAt(0)?.toString(16).toUpperCase() ?? ""}`);
17
- const encodeKey = (key) => key.split("/").map((segment) => encodeRfc3986(segment)).join("/");
18
- const sha256Hex = async (input) => toHex(await crypto.subtle.digest("SHA-256", textEncoder.encode(input)));
19
- const hmac = async (key, message) => {
20
- const cryptoKey = await crypto.subtle.importKey("raw", key, { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
21
- return crypto.subtle.sign("HMAC", cryptoKey, textEncoder.encode(message));
22
- };
23
- const deriveSigningKey = async (secretAccessKey, dateStamp) => {
24
- const dateKey = await hmac(textEncoder.encode(`AWS4${secretAccessKey}`), dateStamp);
25
- const regionKey = await hmac(dateKey, REGION);
26
- const serviceKey = await hmac(regionKey, SERVICE);
27
- return hmac(serviceKey, "aws4_request");
28
- };
29
- const endpointHost = (credentials) => {
30
- const infix = credentials.jurisdiction === void 0 ? "" : `${credentials.jurisdiction}.`;
31
- return `${credentials.accountId}.${infix}r2.cloudflarestorage.com`;
32
- };
33
- const formatAmzDate = (date) => {
34
- const amzDate = `${date.toISOString().replaceAll(/[:-]/gu, "").slice(0, 15)}Z`;
35
- return { amzDate, dateStamp: amzDate.slice(0, 8) };
36
- };
37
- const buildPresignedUrl = async (parameters) => {
38
- const { credentials, key } = parameters;
39
- const method = parameters.method ?? "GET";
40
- const requested = parameters.expiresInSeconds ?? DEFAULT_EXPIRES_SECONDS;
41
- const normalised = Number.isFinite(requested) ? requested : DEFAULT_EXPIRES_SECONDS;
42
- const expires = Math.min(Math.max(MIN_EXPIRES_SECONDS, Math.floor(normalised)), MAX_EXPIRES_SECONDS);
43
- const host = endpointHost(credentials);
44
- const date = new Date(parameters.now?.() ?? Date.now());
45
- const { amzDate, dateStamp } = formatAmzDate(date);
46
- const credentialScope = `${dateStamp}/${REGION}/${SERVICE}/aws4_request`;
47
- const canonicalUri = `/${encodeRfc3986(credentials.bucket)}/${encodeKey(key)}`;
48
- const query = [
49
- ["X-Amz-Algorithm", ALGORITHM],
50
- ["X-Amz-Credential", `${credentials.accessKeyId}/${credentialScope}`],
51
- ["X-Amz-Date", amzDate],
52
- ["X-Amz-Expires", expires.toString()],
53
- ["X-Amz-SignedHeaders", "host"]
54
- ];
55
- const canonicalQuery = query.map(([name, value]) => [encodeRfc3986(name), encodeRfc3986(value)]).toSorted(compareEntries).map(([name, value]) => `${name}=${value}`).join("&");
56
- const canonicalRequest = [method, canonicalUri, canonicalQuery, `host:${host}
57
- `, "host", "UNSIGNED-PAYLOAD"].join("\n");
58
- const stringToSign = [ALGORITHM, amzDate, credentialScope, await sha256Hex(canonicalRequest)].join("\n");
59
- const signingKey = await deriveSigningKey(credentials.secretAccessKey, dateStamp);
60
- const signature = toHex(await hmac(signingKey, stringToSign));
61
- return `https://${host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
62
- };
63
-
64
- export { buildPresignedUrl };
@@ -1,124 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
- import { t as trimTrailingSlashes } from './internal-Dqk0MrAj.mjs';
3
-
4
- const evictOldestEntry = (map, capacity) => {
5
- if (map.size < capacity) {
6
- return;
7
- }
8
- const oldest = map.keys().next().value;
9
- if (oldest !== void 0) {
10
- map.delete(oldest);
11
- }
12
- };
13
-
14
- const textEncoder = new TextEncoder();
15
- const MAX_SIGNED_URL_TTL_SECONDS = 7 * 24 * 60 * 60;
16
- const SCHEME_PREFIX_RE = /^[a-z][a-z0-9+\-.]*:\/\//i;
17
- const toBase64Url = (bytes) => {
18
- const binary = String.fromCodePoint(...bytes);
19
- return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
20
- };
21
- const fromBase64Url = (input) => {
22
- const padded = input.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((input.length + 3) % 4);
23
- const binary = atob(padded);
24
- const bytes = new Uint8Array(binary.length);
25
- for (let index = 0; index < binary.length; index += 1) {
26
- bytes[index] = binary.codePointAt(index) ?? 0;
27
- }
28
- return bytes;
29
- };
30
- const KEY_CACHE_MAX = 64;
31
- const keyCache = /* @__PURE__ */ new Map();
32
- const importHmacKey = async (secret) => {
33
- const cached = keyCache.get(secret);
34
- if (cached) {
35
- return cached;
36
- }
37
- evictOldestEntry(keyCache, KEY_CACHE_MAX);
38
- const keyPromise = crypto.subtle.importKey("raw", textEncoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign", "verify"]);
39
- keyCache.set(secret, keyPromise);
40
- return keyPromise;
41
- };
42
- const extractHost = (input) => {
43
- try {
44
- return new URL(input).host;
45
- } catch {
46
- const noScheme = input.replace(SCHEME_PREFIX_RE, "");
47
- return noScheme.split("/")[0] ?? "";
48
- }
49
- };
50
- const signCanonical = async (secret, canonical) => {
51
- const cryptoKey = await importHmacKey(secret);
52
- const signature = await crypto.subtle.sign("HMAC", cryptoKey, textEncoder.encode(canonical));
53
- return toBase64Url(new Uint8Array(signature));
54
- };
55
- const verifyCanonical = async (secret, canonical, sigBytes) => {
56
- const cryptoKey = await importHmacKey(secret);
57
- return crypto.subtle.verify("HMAC", cryptoKey, sigBytes, textEncoder.encode(canonical));
58
- };
59
-
60
- const LEADING_SLASH_RE = /^\//;
61
- const canonicalize = (method, host, key, exp, contentType) => {
62
- const base = `${method}
63
- ${host.toLowerCase()}
64
- ${key}
65
- ${String(exp)}`;
66
- return contentType === void 0 ? base : `${base}
67
- ${contentType}`;
68
- };
69
- const buildSignedUrl = async (args) => {
70
- const method = args.method ?? "GET";
71
- const expiresInSeconds = args.expiresInSeconds ?? 60 * 60;
72
- if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) {
73
- throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: expiresInSeconds must be a positive finite number");
74
- }
75
- if (expiresInSeconds > MAX_SIGNED_URL_TTL_SECONDS) {
76
- throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: expiresInSeconds must not exceed ${String(MAX_SIGNED_URL_TTL_SECONDS)} (7 days)`);
77
- }
78
- const contentType = method === "PUT" ? args.contentType : void 0;
79
- const exp = Math.floor(Date.now() / 1e3) + expiresInSeconds;
80
- const host = extractHost(args.baseUrl);
81
- const sig = await signCanonical(args.secret, canonicalize(method, host, args.key, exp, contentType));
82
- const base = trimTrailingSlashes(args.baseUrl);
83
- const safeKey = args.key.split("/").map((segment) => encodeURIComponent(segment)).join("/");
84
- const ctParameter = contentType === void 0 ? "" : `&ct=${encodeURIComponent(contentType)}`;
85
- return `${base}/${safeKey}?exp=${String(exp)}&method=${method}&sig=${sig}${ctParameter}`;
86
- };
87
- const verifySignedUrl = async (input, secret, options) => {
88
- let url;
89
- try {
90
- url = input instanceof URL ? input : new URL(input);
91
- } catch {
92
- return { reason: "malformed", valid: false };
93
- }
94
- const expRaw = url.searchParams.get("exp");
95
- const exp = expRaw === null ? Number.NaN : Number(expRaw);
96
- const sig = url.searchParams.get("sig");
97
- const method = url.searchParams.get("method") ?? "GET";
98
- const contentType = url.searchParams.get("ct") ?? void 0;
99
- if (!sig || !Number.isInteger(exp)) {
100
- return { reason: "malformed", valid: false };
101
- }
102
- if (exp < Math.floor(Date.now() / 1e3)) {
103
- return { reason: "expired", valid: false };
104
- }
105
- if (method !== "GET" && method !== "PUT") {
106
- return { reason: "malformed", valid: false };
107
- }
108
- let key;
109
- let sigBytes;
110
- try {
111
- key = url.pathname.replace(LEADING_SLASH_RE, "").split("/").map((segment) => decodeURIComponent(segment)).join("/");
112
- sigBytes = fromBase64Url(sig);
113
- } catch {
114
- return { reason: "malformed", valid: false };
115
- }
116
- const host = options?.expectedHost === void 0 ? url.host : extractHost(options.expectedHost);
117
- const valid = await verifyCanonical(secret, canonicalize(method, host, key, exp, contentType), sigBytes);
118
- if (!valid) {
119
- return { reason: "bad_signature", valid: false };
120
- }
121
- return { contentType, key, method, valid: true };
122
- };
123
-
124
- export { buildSignedUrl, verifySignedUrl };
@@ -1,28 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
-
3
- const createBucketStorage = (buckets, options = {}) => {
4
- const names = Object.keys(buckets);
5
- const [firstName] = names;
6
- if (firstName === void 0) {
7
- throw new LunoraError("INTERNAL", "@lunora/storage: createBucketStorage requires at least one bucket");
8
- }
9
- if (options.default !== void 0 && !buckets[options.default]) {
10
- throw new LunoraError("INTERNAL", `@lunora/storage: default bucket "${options.default}" is not in the bucket map (have: ${names.join(", ")})`);
11
- }
12
- const defaultTag = options.default ?? "default";
13
- const defaultBinding = buckets[defaultTag] ?? buckets[firstName];
14
- if (defaultBinding === void 0) {
15
- throw new LunoraError("INTERNAL", `@lunora/storage: default bucket "${defaultTag}" is not in the bucket map (have: ${names.join(", ")})`);
16
- }
17
- const addressable = [.../* @__PURE__ */ new Set([defaultTag, ...names])];
18
- const make = (name) => {
19
- const target = name === defaultTag ? defaultBinding : buckets[name];
20
- if (!target) {
21
- throw new LunoraError("INTERNAL", `@lunora/storage: no bucket registered for "${name}". Known buckets: ${addressable.join(", ")}`);
22
- }
23
- return { ...target, bucket: (next) => make(next), bucketName: name };
24
- };
25
- return make(defaultTag);
26
- };
27
-
28
- export { createBucketStorage };
@@ -1,54 +0,0 @@
1
- import { Rest, Multipart, Tus } from '@visulima/storage/handler/http/fetch';
2
- import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
3
-
4
- const TUS_RESUMABLE = "1.0.0";
5
- const denyResponse = (protocol) => {
6
- const headers = { "content-type": "application/json" };
7
- if (protocol === "tus") {
8
- headers["Tus-Resumable"] = TUS_RESUMABLE;
9
- }
10
- return Response.json({ error: { code: "FORBIDDEN", message: "Upload denied by authorization policy", name: "ForbiddenError" } }, { headers, status: 403 });
11
- };
12
- const instantiateHandler = (protocol, handlerOptions) => {
13
- if (protocol === "chunked-rest") {
14
- return new Rest(handlerOptions);
15
- }
16
- if (protocol === "multipart") {
17
- return new Multipart(handlerOptions);
18
- }
19
- return new Tus(handlerOptions);
20
- };
21
- const createUploadHandler = (options) => {
22
- const protocol = options.protocol ?? "tus";
23
- const handlerOptions = {
24
- storage: options.storage,
25
- ...options.maxFileSize === void 0 ? {} : { maxFileSize: options.maxFileSize }
26
- };
27
- const handler = instantiateHandler(protocol, handlerOptions);
28
- const { authorize } = options;
29
- const fetch = async (request) => {
30
- if (authorize !== void 0) {
31
- try {
32
- const allowed = await authorize({ method: request.method, protocol, request, url: new URL(request.url) });
33
- if (!allowed) {
34
- return denyResponse(protocol);
35
- }
36
- } catch {
37
- return denyResponse(protocol);
38
- }
39
- }
40
- return handler.fetch(request);
41
- };
42
- return { fetch, protocol };
43
- };
44
- const createR2UploadStorage = (options) => new AwsLightStorage({
45
- accessKeyId: options.accessKeyId,
46
- bucket: options.bucket,
47
- endpoint: options.endpoint ?? `https://${options.accountId}.r2.cloudflarestorage.com`,
48
- path: options.path ?? "/",
49
- region: "auto",
50
- secretAccessKey: options.secretAccessKey,
51
- ...options.partSize === void 0 ? {} : { partSize: options.partSize }
52
- });
53
-
54
- export { createR2UploadStorage, createUploadHandler };
@@ -1,261 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
- import { t as trimTrailingSlashes, a as toHex } from './internal-Dqk0MrAj.mjs';
3
- import { buildPresignedUrl } from './buildPresignedUrl-B5f1wvz6.mjs';
4
- import { buildSignedUrl } from './buildSignedUrl-rt-6azia.mjs';
5
-
6
- const MAX_KEY_LENGTH = 1024;
7
- const MAX_LIST_LIMIT = 1e3;
8
- const DEFAULT_LIST_LIMIT = 100;
9
- const toBase64 = (buffer) => {
10
- const bytes = new Uint8Array(buffer);
11
- let binary = "";
12
- for (const byte of bytes) {
13
- binary += String.fromCodePoint(byte);
14
- }
15
- return btoa(binary);
16
- };
17
- const withSha256 = (object) => {
18
- const raw = object.checksums?.sha256;
19
- if (raw === void 0) {
20
- return object;
21
- }
22
- const sha256 = toHex(raw);
23
- const sha256Base64 = toBase64(raw);
24
- return /* @__PURE__ */ new Proxy(object, {
25
- get(target, property) {
26
- if (property === "sha256") {
27
- return sha256;
28
- }
29
- if (property === "sha256Base64") {
30
- return sha256Base64;
31
- }
32
- const value = Reflect.get(target, property, target);
33
- return typeof value === "function" ? value.bind(target) : value;
34
- },
35
- has(target, property) {
36
- return property === "sha256" || property === "sha256Base64" || Reflect.has(target, property);
37
- }
38
- });
39
- };
40
- const toMetadata = (object) => {
41
- const raw = object.checksums?.sha256;
42
- return {
43
- contentType: object.httpMetadata?.contentType,
44
- customMetadata: object.customMetadata,
45
- key: object.key,
46
- sha256: raw === void 0 ? void 0 : toHex(raw),
47
- size: object.size,
48
- uploaded: object.uploaded === void 0 ? void 0 : object.uploaded.getTime()
49
- };
50
- };
51
- const toListObject = (object) => {
52
- const raw = object.checksums?.sha256;
53
- return {
54
- checksums: object.checksums,
55
- customMetadata: object.customMetadata,
56
- etag: object.etag,
57
- httpEtag: object.httpEtag,
58
- httpMetadata: object.httpMetadata,
59
- key: object.key,
60
- sha256: raw === void 0 ? void 0 : toHex(raw),
61
- sha256Base64: raw === void 0 ? void 0 : toBase64(raw),
62
- size: object.size,
63
- uploaded: object.uploaded
64
- };
65
- };
66
- const enforceStreamMaxSize = (stream, maxSize) => {
67
- let seen = 0;
68
- const byteLengthOf = (chunk) => {
69
- if (chunk instanceof ArrayBuffer) {
70
- return chunk.byteLength;
71
- }
72
- return ArrayBuffer.isView(chunk) ? chunk.byteLength : void 0;
73
- };
74
- const counter = new TransformStream({
75
- transform(chunk, controller) {
76
- const length = byteLengthOf(chunk);
77
- if (length === void 0) {
78
- controller.error(new Error("@lunora/storage: stream chunk is not a byte chunk; cannot enforce maxSize"));
79
- return;
80
- }
81
- seen += length;
82
- if (seen > maxSize) {
83
- controller.error(new Error(`@lunora/storage: stream body exceeds maxSize (> ${String(maxSize)} bytes)`));
84
- return;
85
- }
86
- controller.enqueue(chunk);
87
- }
88
- });
89
- return stream.pipeThrough(counter);
90
- };
91
- const validateKey = (key) => {
92
- if (typeof key !== "string" || key.length === 0) {
93
- throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key must be a non-empty string");
94
- }
95
- if (key.length > MAX_KEY_LENGTH) {
96
- throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: key exceeds ${String(MAX_KEY_LENGTH)}-byte limit`);
97
- }
98
- if (key.includes("\0")) {
99
- throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key contains NUL byte");
100
- }
101
- if (key.startsWith("/")) {
102
- throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key must not start with `/`");
103
- }
104
- const segments = key.split("/");
105
- for (const segment of segments) {
106
- if (segment === "..") {
107
- throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: key contains a `..` path component");
108
- }
109
- }
110
- };
111
- const scopeKey = (prefix, key) => {
112
- validateKey(prefix);
113
- validateKey(key);
114
- const trimmedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
115
- const composed = `${trimmedPrefix}/${key}`;
116
- if (composed.length > MAX_KEY_LENGTH) {
117
- throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: scoped key exceeds ${String(MAX_KEY_LENGTH)}-byte limit`);
118
- }
119
- return composed;
120
- };
121
- const createStorage = (options) => {
122
- if (!options.bucket) {
123
- throw new LunoraError("INTERNAL", "@lunora/storage: `bucket` is required");
124
- }
125
- const upload = async (key, body, uploadOptions = {}) => {
126
- validateKey(key);
127
- if (uploadOptions.allowedContentTypes !== void 0) {
128
- if (uploadOptions.contentType === void 0) {
129
- throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: contentType is required when allowedContentTypes is set");
130
- }
131
- if (!uploadOptions.allowedContentTypes.includes(uploadOptions.contentType)) {
132
- throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: contentType "${uploadOptions.contentType}" not in allowedContentTypes`);
133
- }
134
- }
135
- let putBody = body;
136
- if (typeof uploadOptions.maxSize === "number") {
137
- let size;
138
- if (body instanceof ArrayBuffer) {
139
- size = body.byteLength;
140
- } else if (body instanceof Blob) {
141
- size = body.size;
142
- }
143
- if (size !== void 0 && size > uploadOptions.maxSize) {
144
- throw new LunoraError("PAYLOAD_TOO_LARGE", `@lunora/storage: body exceeds maxSize (${String(size)} > ${String(uploadOptions.maxSize)})`);
145
- }
146
- if (body instanceof ReadableStream) {
147
- putBody = enforceStreamMaxSize(body, uploadOptions.maxSize);
148
- }
149
- }
150
- const object = await options.bucket.put(key, putBody, {
151
- customMetadata: uploadOptions.customMetadata,
152
- httpMetadata: uploadOptions.contentType ? { contentType: uploadOptions.contentType } : void 0
153
- });
154
- return { etag: object.etag, httpEtag: object.httpEtag ?? `"${object.etag}"`, key: object.key };
155
- };
156
- const download = async (key, downloadOptions = {}) => {
157
- validateKey(key);
158
- const object = await (downloadOptions.range ? options.bucket.get(key, { range: downloadOptions.range }) : options.bucket.get(key));
159
- return object && withSha256(object);
160
- };
161
- const deleteObject = async (key) => {
162
- validateKey(key);
163
- await options.bucket.delete(key);
164
- };
165
- const getMetadata = async (key) => {
166
- validateKey(key);
167
- if (options.bucket.head) {
168
- const head = await options.bucket.head(key);
169
- return head && toMetadata(head);
170
- }
171
- const object = await options.bucket.get(key, { range: { length: 0 } });
172
- return object && toMetadata(object);
173
- };
174
- const list = async (prefix, listOptions = {}) => {
175
- if (prefix?.includes("\0")) {
176
- throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: prefix contains NUL byte");
177
- }
178
- const requested = listOptions.limit ?? DEFAULT_LIST_LIMIT;
179
- const limit = Math.min(Math.max(1, Math.floor(requested)), MAX_LIST_LIMIT);
180
- const result = await options.bucket.list({ cursor: listOptions.cursor, delimiter: listOptions.delimiter, limit, prefix });
181
- return { cursor: result.cursor, objects: result.objects.map((object) => toListObject(object)), truncated: result.truncated };
182
- };
183
- const getUrl = (key) => {
184
- if (!options.publicBaseUrl) {
185
- throw new LunoraError("INTERNAL", "@lunora/storage: `publicBaseUrl` is required for getUrl()");
186
- }
187
- validateKey(key);
188
- const safeKey = key.split("/").map((segment) => encodeURIComponent(segment)).join("/");
189
- return `${trimTrailingSlashes(options.publicBaseUrl)}/${safeKey}`;
190
- };
191
- const getSignedUrl = async (key, signedOptions = {}) => {
192
- if (!options.publicBaseUrl) {
193
- throw new LunoraError("INTERNAL", "@lunora/storage: `publicBaseUrl` is required for getSignedUrl()");
194
- }
195
- if (!options.signingSecret) {
196
- throw new LunoraError("INTERNAL", "@lunora/storage: `signingSecret` is required for getSignedUrl()");
197
- }
198
- validateKey(key);
199
- return buildSignedUrl({
200
- baseUrl: options.publicBaseUrl,
201
- contentType: signedOptions.contentType,
202
- expiresInSeconds: signedOptions.expiresInSeconds,
203
- key,
204
- method: signedOptions.method,
205
- secret: options.signingSecret
206
- });
207
- };
208
- const createMultipartUpload = async (key, multipartOptions = {}) => {
209
- validateKey(key);
210
- if (!options.bucket.createMultipartUpload) {
211
- throw new LunoraError("INTERNAL", "@lunora/storage: bucket binding does not support multipart uploads (createMultipartUpload)");
212
- }
213
- return options.bucket.createMultipartUpload(key, {
214
- customMetadata: multipartOptions.customMetadata,
215
- httpMetadata: multipartOptions.contentType ? { contentType: multipartOptions.contentType } : void 0
216
- });
217
- };
218
- const resumeMultipartUpload = (key, uploadId) => {
219
- validateKey(key);
220
- if (typeof uploadId !== "string" || uploadId.length === 0) {
221
- throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: resumeMultipartUpload requires a non-empty uploadId");
222
- }
223
- if (!options.bucket.resumeMultipartUpload) {
224
- throw new LunoraError("INTERNAL", "@lunora/storage: bucket binding does not support multipart uploads (resumeMultipartUpload)");
225
- }
226
- return options.bucket.resumeMultipartUpload(key, uploadId);
227
- };
228
- const getPresignedUrl = async (key, presignedOptions = {}) => {
229
- if (!options.s3) {
230
- throw new LunoraError(
231
- "INTERNAL",
232
- "@lunora/storage: `s3` credentials are required for getPresignedUrl() — pass { accountId, accessKeyId, secretAccessKey, bucket }"
233
- );
234
- }
235
- validateKey(key);
236
- return buildPresignedUrl({
237
- credentials: options.s3,
238
- expiresInSeconds: presignedOptions.expiresInSeconds,
239
- key,
240
- method: presignedOptions.method
241
- });
242
- };
243
- const generateUploadUrl = async (key, uploadUrlOptions = {}) => getSignedUrl(key, { contentType: uploadUrlOptions.contentType, expiresInSeconds: uploadUrlOptions.expiresInSeconds, method: "PUT" });
244
- const store = async (key, body, storeOptions = {}) => upload(key, body, storeOptions);
245
- return {
246
- createMultipartUpload,
247
- delete: deleteObject,
248
- download,
249
- generateUploadUrl,
250
- getMetadata,
251
- getPresignedUrl,
252
- getSignedUrl,
253
- getUrl,
254
- list,
255
- resumeMultipartUpload,
256
- store,
257
- upload
258
- };
259
- };
260
-
261
- export { createStorage, scopeKey };
@@ -1,17 +0,0 @@
1
- const toHex = (buffer) => {
2
- const bytes = new Uint8Array(buffer);
3
- let out = "";
4
- for (const byte of bytes) {
5
- out += byte.toString(16).padStart(2, "0");
6
- }
7
- return out;
8
- };
9
- const trimTrailingSlashes = (value) => {
10
- let end = value.length;
11
- while (end > 0 && value[end - 1] === "/") {
12
- end -= 1;
13
- }
14
- return value.slice(0, end);
15
- };
16
-
17
- export { toHex as a, trimTrailingSlashes as t };