@porulle/adapter-s3 0.1.0
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 +50 -0
- package/dist/src/index.d.ts +21 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +112 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +51 -0
- package/src/index.ts +158 -0
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# @porulle/adapter-s3
|
|
2
|
+
|
|
3
|
+
`StorageAdapter` for AWS S3 (or any S3-compatible bucket — DigitalOcean Spaces, MinIO, Backblaze B2 with the right `endpoint`).
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { defineConfig } from "@porulle/core";
|
|
9
|
+
import { s3StorageAdapter } from "@porulle/adapter-s3";
|
|
10
|
+
|
|
11
|
+
export default defineConfig({
|
|
12
|
+
storage: s3StorageAdapter({
|
|
13
|
+
bucket: "acme-media",
|
|
14
|
+
region: "us-east-1",
|
|
15
|
+
publicBaseUrl: "https://media.acme.com", // CDN or static.acme.com
|
|
16
|
+
signedUrlExpiresIn: 3600, // 1 hour
|
|
17
|
+
credentials: { // optional — falls back to AWS credential chain
|
|
18
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
19
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
// …
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For S3-compatible providers, set `endpoint` and `forcePathStyle`:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
s3StorageAdapter({
|
|
30
|
+
bucket: "acme-media",
|
|
31
|
+
region: "us-east-1",
|
|
32
|
+
endpoint: "https://nyc3.digitaloceanspaces.com",
|
|
33
|
+
forcePathStyle: true,
|
|
34
|
+
// …
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## What it implements
|
|
39
|
+
|
|
40
|
+
`upload`, `getUrl` (returns `publicBaseUrl/<key>`), `getSignedUrl` (presigned GET via `@aws-sdk/s3-request-presigner`), `delete`, `list` (paginated via `ListObjectsV2`).
|
|
41
|
+
|
|
42
|
+
## Notes
|
|
43
|
+
|
|
44
|
+
- Bring your own AWS SDK config — the adapter doesn't override credential resolution; it uses `@aws-sdk/client-s3` defaults if you omit `credentials`.
|
|
45
|
+
- The `client` and `signUrl` options exist for unit tests; production code should leave them unset.
|
|
46
|
+
|
|
47
|
+
## See also
|
|
48
|
+
|
|
49
|
+
- `@porulle/adapter-r2` — Cloudflare R2 (no AWS SDK, lighter)
|
|
50
|
+
- [`SECURITY.md`](../../../SECURITY.md) — media-upload MIME validation is enforced in `@porulle/core`
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type S3ClientConfig } from "@aws-sdk/client-s3";
|
|
2
|
+
import { type StorageAdapter } from "@porulle/core";
|
|
3
|
+
interface S3ClientLike {
|
|
4
|
+
send(command: unknown): Promise<any>;
|
|
5
|
+
}
|
|
6
|
+
export interface S3StorageAdapterOptions {
|
|
7
|
+
bucket: string;
|
|
8
|
+
region: string;
|
|
9
|
+
endpoint?: string;
|
|
10
|
+
forcePathStyle?: boolean;
|
|
11
|
+
publicBaseUrl?: string;
|
|
12
|
+
signedUrlExpiresIn?: number;
|
|
13
|
+
credentials?: S3ClientConfig["credentials"];
|
|
14
|
+
client?: S3ClientLike;
|
|
15
|
+
signUrl?: (client: S3ClientLike, command: unknown, options: {
|
|
16
|
+
expiresIn: number;
|
|
17
|
+
}) => Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
export declare function s3StorageAdapter(options: S3StorageAdapterOptions): StorageAdapter;
|
|
20
|
+
export {};
|
|
21
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,cAAc,EACpB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAAwB,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAE1E,UAAU,YAAY;IACpB,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,WAAW,CAAC,EAAE,cAAc,CAAC,aAAa,CAAC,CAAC;IAC5C,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACvG;AAuBD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,cAAc,CA6GjF"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { DeleteObjectCommand, GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
|
|
2
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
3
|
+
import { Err, Ok } from "@porulle/core";
|
|
4
|
+
async function toArrayBuffer(data) {
|
|
5
|
+
if (data instanceof ArrayBuffer)
|
|
6
|
+
return data;
|
|
7
|
+
return new Response(data).arrayBuffer();
|
|
8
|
+
}
|
|
9
|
+
function objectUrl(options, key) {
|
|
10
|
+
if (options.publicBaseUrl) {
|
|
11
|
+
return `${options.publicBaseUrl.replace(/\/$/, "")}/${key}`;
|
|
12
|
+
}
|
|
13
|
+
if (options.endpoint) {
|
|
14
|
+
const endpoint = options.endpoint.replace(/\/$/, "");
|
|
15
|
+
if (options.forcePathStyle) {
|
|
16
|
+
return `${endpoint}/${options.bucket}/${key}`;
|
|
17
|
+
}
|
|
18
|
+
return `${endpoint}/${key}`;
|
|
19
|
+
}
|
|
20
|
+
return `https://${options.bucket}.s3.${options.region}.amazonaws.com/${key}`;
|
|
21
|
+
}
|
|
22
|
+
export function s3StorageAdapter(options) {
|
|
23
|
+
const client = options.client ??
|
|
24
|
+
new S3Client({
|
|
25
|
+
region: options.region,
|
|
26
|
+
...(options.endpoint ? { endpoint: options.endpoint } : {}),
|
|
27
|
+
...(options.forcePathStyle !== undefined ? { forcePathStyle: options.forcePathStyle } : {}),
|
|
28
|
+
...(options.credentials ? { credentials: options.credentials } : {}),
|
|
29
|
+
});
|
|
30
|
+
const signUrl = options.signUrl ?? (async (currentClient, command, config) => getSignedUrl(currentClient, command, config));
|
|
31
|
+
return {
|
|
32
|
+
providerId: "s3",
|
|
33
|
+
async upload(key, data, contentType) {
|
|
34
|
+
try {
|
|
35
|
+
const body = await toArrayBuffer(data);
|
|
36
|
+
await client.send(new PutObjectCommand({
|
|
37
|
+
Bucket: options.bucket,
|
|
38
|
+
Key: key,
|
|
39
|
+
Body: new Uint8Array(body),
|
|
40
|
+
ContentType: contentType,
|
|
41
|
+
}));
|
|
42
|
+
return Ok({
|
|
43
|
+
key,
|
|
44
|
+
url: objectUrl(options, key),
|
|
45
|
+
contentType,
|
|
46
|
+
size: body.byteLength,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return Err({
|
|
51
|
+
code: "S3_UPLOAD_FAILED",
|
|
52
|
+
message: error instanceof Error ? error.message : "Failed to upload to S3.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
async getUrl(key) {
|
|
57
|
+
return Ok(objectUrl(options, key));
|
|
58
|
+
},
|
|
59
|
+
async getSignedUrl(key, expiresIn) {
|
|
60
|
+
try {
|
|
61
|
+
const ttl = expiresIn > 0 ? expiresIn : options.signedUrlExpiresIn ?? 900;
|
|
62
|
+
const url = await signUrl(client, new GetObjectCommand({
|
|
63
|
+
Bucket: options.bucket,
|
|
64
|
+
Key: key,
|
|
65
|
+
}), { expiresIn: ttl });
|
|
66
|
+
return Ok(url);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return Err({
|
|
70
|
+
code: "S3_SIGNED_URL_FAILED",
|
|
71
|
+
message: error instanceof Error ? error.message : "Failed to generate signed URL.",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
async delete(key) {
|
|
76
|
+
try {
|
|
77
|
+
await client.send(new DeleteObjectCommand({
|
|
78
|
+
Bucket: options.bucket,
|
|
79
|
+
Key: key,
|
|
80
|
+
}));
|
|
81
|
+
return Ok(undefined);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return Err({
|
|
85
|
+
code: "S3_DELETE_FAILED",
|
|
86
|
+
message: error instanceof Error ? error.message : "Failed to delete object from S3.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
async list(prefix) {
|
|
91
|
+
try {
|
|
92
|
+
const listed = await client.send(new ListObjectsV2Command({
|
|
93
|
+
Bucket: options.bucket,
|
|
94
|
+
Prefix: prefix,
|
|
95
|
+
}));
|
|
96
|
+
const items = (listed.Contents ?? []).map((item) => ({
|
|
97
|
+
key: String(item.Key ?? ""),
|
|
98
|
+
url: objectUrl(options, String(item.Key ?? "")),
|
|
99
|
+
contentType: "application/octet-stream",
|
|
100
|
+
size: typeof item.Size === "number" ? item.Size : undefined,
|
|
101
|
+
}));
|
|
102
|
+
return Ok(items.filter((item) => item.key.length > 0));
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return Err({
|
|
106
|
+
code: "S3_LIST_FAILED",
|
|
107
|
+
message: error instanceof Error ? error.message : "Failed to list S3 objects.",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|