@cequrebackends/plugin-s3 0.12.2
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/README.md +23 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +159 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @cequrebackends/plugin-s3
|
|
2
|
+
|
|
3
|
+
Amazon S3 and S3-compatible cloud storage plugin for Cequre backends.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @cequrebackends/plugin-s3 @aws-sdk/client-s3
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { CequreRuntime } from "@cequrebackends/cequre-ts";
|
|
15
|
+
import { s3Storage } from "@cequrebackends/plugin-s3";
|
|
16
|
+
|
|
17
|
+
const runtime = new CequreRuntime(schema, {
|
|
18
|
+
adapter,
|
|
19
|
+
plugins: [
|
|
20
|
+
s3Storage({ bucket: "my-bucket", region: "us-east-1" })
|
|
21
|
+
]
|
|
22
|
+
});
|
|
23
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type StorageProvider, type UploadedFile } from "@cequrebackends/cequre-ts";
|
|
2
|
+
export interface S3Config {
|
|
3
|
+
/** S3 bucket name */
|
|
4
|
+
bucket: string;
|
|
5
|
+
/** AWS region (e.g., "us-east-1") */
|
|
6
|
+
region: string;
|
|
7
|
+
/** S3 endpoint URL (for S3-compatible services like MinIO, DigitalOcean Spaces) */
|
|
8
|
+
endpoint?: string;
|
|
9
|
+
/** Public URL prefix for serving uploaded files (e.g., "https://cdn.example.com") */
|
|
10
|
+
publicUrl?: string;
|
|
11
|
+
/** AWS access key ID */
|
|
12
|
+
accessKeyId: string;
|
|
13
|
+
/** AWS secret access key */
|
|
14
|
+
secretAccessKey: string;
|
|
15
|
+
/** Force path-style URLs (required for MinIO and some S3-compatible services) */
|
|
16
|
+
forcePathStyle?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare class CequreFileS3 implements StorageProvider {
|
|
19
|
+
private readonly s3Cfg;
|
|
20
|
+
private readonly s3;
|
|
21
|
+
private readonly awsClient;
|
|
22
|
+
private readonly baseUrl;
|
|
23
|
+
constructor(config: S3Config);
|
|
24
|
+
saveFile(file: File, options?: {
|
|
25
|
+
filename?: string;
|
|
26
|
+
}): Promise<UploadedFile>;
|
|
27
|
+
initMultipartUpload(options: {
|
|
28
|
+
filename: string;
|
|
29
|
+
mimetype: string;
|
|
30
|
+
size: number;
|
|
31
|
+
}): Promise<{
|
|
32
|
+
uploadId: string;
|
|
33
|
+
filename: string;
|
|
34
|
+
parts?: {
|
|
35
|
+
partNumber: number;
|
|
36
|
+
url: string;
|
|
37
|
+
}[];
|
|
38
|
+
}>;
|
|
39
|
+
completeMultipartUpload(uploadId: string, filename: string, parts: {
|
|
40
|
+
partNumber: number;
|
|
41
|
+
etag?: string;
|
|
42
|
+
}[]): Promise<UploadedFile>;
|
|
43
|
+
deleteFile(filename: string): Promise<void>;
|
|
44
|
+
getFileUrl(filename: string): Promise<string | undefined>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Creates an S3 storage provider for Cequre uploads.
|
|
48
|
+
*/
|
|
49
|
+
export declare function s3Storage(config: S3Config): StorageProvider;
|
|
50
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAGpF,MAAM,WAAW,QAAQ;IACvB,qBAAqB;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qFAAqF;IACrF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wBAAwB;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,eAAe,EAAE,MAAM,CAAC;IACxB,iFAAiF;IACjF,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAyBD,qBAAa,YAAa,YAAW,eAAe;IAClD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAW;IACjC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAc;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;gBAErB,MAAM,EAAE,QAAQ;IAwCtB,QAAQ,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,YAAY,CAAC;IAqB5E,mBAAmB,CAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE;YAAE,UAAU,EAAE,MAAM,CAAC;YAAC,GAAG,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC;IAuClL,uBAAuB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,GAAG,OAAO,CAAC,YAAY,CAAC;IA0ClI,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQ3C,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;CAahE;AAED;;GAEG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,QAAQ,GAAG,eAAe,CAE3D"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import { ulid } from "@cequrebackends/cequre-ts";
|
|
4
|
+
import { AwsClient } from "aws4fetch";
|
|
5
|
+
|
|
6
|
+
class CequreFileS3 {
|
|
7
|
+
s3Cfg;
|
|
8
|
+
s3;
|
|
9
|
+
awsClient;
|
|
10
|
+
baseUrl;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.s3Cfg = config;
|
|
13
|
+
const BunS3Client = globalThis.Bun?.S3Client;
|
|
14
|
+
if (!BunS3Client) {
|
|
15
|
+
throw new Error("Bun.S3Client is not available. Ensure you are running on Bun >= 1.1.44.");
|
|
16
|
+
}
|
|
17
|
+
this.s3 = new BunS3Client({
|
|
18
|
+
bucket: this.s3Cfg.bucket,
|
|
19
|
+
region: this.s3Cfg.region,
|
|
20
|
+
endpoint: this.s3Cfg.endpoint,
|
|
21
|
+
accessKeyId: this.s3Cfg.accessKeyId,
|
|
22
|
+
secretAccessKey: this.s3Cfg.secretAccessKey,
|
|
23
|
+
forcePathStyle: this.s3Cfg.forcePathStyle
|
|
24
|
+
});
|
|
25
|
+
this.awsClient = new AwsClient({
|
|
26
|
+
accessKeyId: this.s3Cfg.accessKeyId,
|
|
27
|
+
secretAccessKey: this.s3Cfg.secretAccessKey,
|
|
28
|
+
region: this.s3Cfg.region,
|
|
29
|
+
service: "s3"
|
|
30
|
+
});
|
|
31
|
+
let baseUrl;
|
|
32
|
+
if (this.s3Cfg.endpoint) {
|
|
33
|
+
const ep = new URL(this.s3Cfg.endpoint);
|
|
34
|
+
if (this.s3Cfg.forcePathStyle) {
|
|
35
|
+
baseUrl = `${ep.origin}/${this.s3Cfg.bucket}`;
|
|
36
|
+
} else {
|
|
37
|
+
baseUrl = `${ep.protocol}//${this.s3Cfg.bucket}.${ep.host}`;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
baseUrl = `https://${this.s3Cfg.bucket}.s3.${this.s3Cfg.region}.amazonaws.com`;
|
|
41
|
+
}
|
|
42
|
+
this.baseUrl = baseUrl;
|
|
43
|
+
}
|
|
44
|
+
async saveFile(file, options) {
|
|
45
|
+
const ext = file.name ? file.name.substring(file.name.lastIndexOf(".")) : "";
|
|
46
|
+
const filename = options?.filename ?? `${ulid()}${ext}`;
|
|
47
|
+
try {
|
|
48
|
+
await this.s3.write(filename, file);
|
|
49
|
+
const publicUrl = this.s3Cfg.publicUrl ? `${this.s3Cfg.publicUrl.replace(/\/$/, "")}/${filename}` : `/${filename}`;
|
|
50
|
+
return {
|
|
51
|
+
filename,
|
|
52
|
+
originalName: file.name || "upload",
|
|
53
|
+
mimetype: file.type || "application/octet-stream",
|
|
54
|
+
size: file.size,
|
|
55
|
+
url: publicUrl
|
|
56
|
+
};
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error(`S3 upload failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async initMultipartUpload(options) {
|
|
62
|
+
const ext = options.filename.includes(".") ? options.filename.substring(options.filename.lastIndexOf(".")) : "";
|
|
63
|
+
const filename = `${ulid()}${ext}`;
|
|
64
|
+
const createUrl = `${this.baseUrl}/${filename}?uploads`;
|
|
65
|
+
const createReq = await this.awsClient.fetch(createUrl, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
"Content-Type": options.mimetype
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
const createText = await createReq.text();
|
|
72
|
+
if (!createReq.ok) {
|
|
73
|
+
throw new Error(`Failed to create multipart upload: ${createText}`);
|
|
74
|
+
}
|
|
75
|
+
const uploadIdMatch = createText.match(/<UploadId>([\s\S]*?)<\/UploadId>/);
|
|
76
|
+
if (!uploadIdMatch || !uploadIdMatch[1])
|
|
77
|
+
throw new Error("Missing UploadId in S3 response");
|
|
78
|
+
const uploadId = uploadIdMatch[1].trim();
|
|
79
|
+
const chunkSize = 5 * 1024 * 1024;
|
|
80
|
+
const totalParts = Math.ceil(options.size / chunkSize);
|
|
81
|
+
const parts = [];
|
|
82
|
+
for (let i = 1;i <= totalParts; i++) {
|
|
83
|
+
const partUrl = `${this.baseUrl}/${filename}?partNumber=${i}&uploadId=${encodeURIComponent(uploadId)}`;
|
|
84
|
+
const signedUrlReq = await this.awsClient.sign(partUrl, { method: "PUT", aws: { signQuery: true } });
|
|
85
|
+
parts.push({
|
|
86
|
+
partNumber: i,
|
|
87
|
+
url: signedUrlReq.url
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
console.log(">>> INIT SUCCESS. UPLOAD_ID:", uploadId);
|
|
91
|
+
return { uploadId, filename, parts };
|
|
92
|
+
}
|
|
93
|
+
async completeMultipartUpload(uploadId, filename, parts) {
|
|
94
|
+
console.log(">>> COMPLETE STARTED. UPLOAD_ID:", uploadId);
|
|
95
|
+
const completeUrl = `${this.baseUrl}/${filename}?uploadId=${encodeURIComponent(uploadId)}`;
|
|
96
|
+
let xml = `<CompleteMultipartUpload>
|
|
97
|
+
`;
|
|
98
|
+
for (const part of parts.sort((a, b) => a.partNumber - b.partNumber)) {
|
|
99
|
+
if (!part.etag)
|
|
100
|
+
throw new Error(`Missing ETag for part ${part.partNumber}`);
|
|
101
|
+
xml += ` <Part>
|
|
102
|
+
`;
|
|
103
|
+
xml += ` <PartNumber>${part.partNumber}</PartNumber>
|
|
104
|
+
`;
|
|
105
|
+
xml += ` <ETag>${part.etag}</ETag>
|
|
106
|
+
`;
|
|
107
|
+
xml += ` </Part>
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
xml += `</CompleteMultipartUpload>`;
|
|
111
|
+
const completeReq = await this.awsClient.fetch(completeUrl, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: {
|
|
114
|
+
"Content-Type": "application/xml"
|
|
115
|
+
},
|
|
116
|
+
body: xml
|
|
117
|
+
});
|
|
118
|
+
const completeText = await completeReq.text();
|
|
119
|
+
if (!completeReq.ok) {
|
|
120
|
+
throw new Error(`Failed to complete multipart upload: ${completeText}`);
|
|
121
|
+
}
|
|
122
|
+
const headReq = await this.awsClient.fetch(`${this.baseUrl}/${filename}`, { method: "HEAD" });
|
|
123
|
+
const size = parseInt(headReq.headers.get("Content-Length") || "0", 10);
|
|
124
|
+
const mimetype = headReq.headers.get("Content-Type") || "application/octet-stream";
|
|
125
|
+
const publicUrl = this.s3Cfg.publicUrl ? `${this.s3Cfg.publicUrl.replace(/\/$/, "")}/${filename}` : `/${filename}`;
|
|
126
|
+
return {
|
|
127
|
+
filename,
|
|
128
|
+
originalName: filename,
|
|
129
|
+
mimetype,
|
|
130
|
+
size,
|
|
131
|
+
url: publicUrl
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async deleteFile(filename) {
|
|
135
|
+
try {
|
|
136
|
+
await this.s3.delete(filename);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
throw new Error(`S3 delete failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async getFileUrl(filename) {
|
|
142
|
+
try {
|
|
143
|
+
return this.s3.file(filename).presign({ expiresIn: 3600 });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error("Failed to presign S3 URL", err);
|
|
146
|
+
if (this.s3Cfg.publicUrl) {
|
|
147
|
+
return `${this.s3Cfg.publicUrl.replace(/\/$/, "")}/${filename}`;
|
|
148
|
+
}
|
|
149
|
+
return `${this.baseUrl}/${filename}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function s3Storage(config) {
|
|
154
|
+
return new CequreFileS3(config);
|
|
155
|
+
}
|
|
156
|
+
export {
|
|
157
|
+
s3Storage,
|
|
158
|
+
CequreFileS3
|
|
159
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cequrebackends/plugin-s3",
|
|
3
|
+
"version": "0.12.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "rm -rf dist && bun build ./src/index.ts --outdir ./dist --target bun --packages external && tsc --emitDeclarationOnly"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"aws4fetch": "^1.0.20"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^6.0.3",
|
|
19
|
+
"@cequrebackends/cequre-ts": "0.12.2"
|
|
20
|
+
},
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/cequrebackends/cequre.git"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@cequrebackends/cequre-ts": "0.12.2"
|
|
39
|
+
}
|
|
40
|
+
}
|