@procedures/s3 0.0.0-stage → 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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +886 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/module.d.ts +16 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +151 -0
- package/dist/module.js.map +1 -0
- package/dist/procedures/s3/delete.d.ts +26 -0
- package/dist/procedures/s3/delete.d.ts.map +1 -0
- package/dist/procedures/s3/delete.js +40 -0
- package/dist/procedures/s3/delete.js.map +1 -0
- package/dist/procedures/s3/download.d.ts +36 -0
- package/dist/procedures/s3/download.d.ts.map +1 -0
- package/dist/procedures/s3/download.js +143 -0
- package/dist/procedures/s3/download.js.map +1 -0
- package/dist/procedures/s3/index.d.ts +13 -0
- package/dist/procedures/s3/index.d.ts.map +1 -0
- package/dist/procedures/s3/index.js +13 -0
- package/dist/procedures/s3/index.js.map +1 -0
- package/dist/procedures/s3/list-all.d.ts +24 -0
- package/dist/procedures/s3/list-all.d.ts.map +1 -0
- package/dist/procedures/s3/list-all.js +57 -0
- package/dist/procedures/s3/list-all.js.map +1 -0
- package/dist/procedures/s3/list.d.ts +28 -0
- package/dist/procedures/s3/list.d.ts.map +1 -0
- package/dist/procedures/s3/list.js +56 -0
- package/dist/procedures/s3/list.js.map +1 -0
- package/dist/procedures/s3/multipart-abort.d.ts +21 -0
- package/dist/procedures/s3/multipart-abort.d.ts.map +1 -0
- package/dist/procedures/s3/multipart-abort.js +33 -0
- package/dist/procedures/s3/multipart-abort.js.map +1 -0
- package/dist/procedures/s3/multipart-complete.d.ts +26 -0
- package/dist/procedures/s3/multipart-complete.d.ts.map +1 -0
- package/dist/procedures/s3/multipart-complete.js +51 -0
- package/dist/procedures/s3/multipart-complete.js.map +1 -0
- package/dist/procedures/s3/multipart-init.d.ts +24 -0
- package/dist/procedures/s3/multipart-init.d.ts.map +1 -0
- package/dist/procedures/s3/multipart-init.js +42 -0
- package/dist/procedures/s3/multipart-init.js.map +1 -0
- package/dist/procedures/s3/multipart-upload.d.ts +24 -0
- package/dist/procedures/s3/multipart-upload.d.ts.map +1 -0
- package/dist/procedures/s3/multipart-upload.js +44 -0
- package/dist/procedures/s3/multipart-upload.js.map +1 -0
- package/dist/procedures/s3/upload.d.ts +30 -0
- package/dist/procedures/s3/upload.d.ts.map +1 -0
- package/dist/procedures/s3/upload.js +50 -0
- package/dist/procedures/s3/upload.js.map +1 -0
- package/dist/register.d.ts +8 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +14 -0
- package/dist/register.js.map +1 -0
- package/dist/s3-client.d.ts +38 -0
- package/dist/s3-client.d.ts.map +1 -0
- package/dist/s3-client.js +83 -0
- package/dist/s3-client.js.map +1 -0
- package/dist/types.d.ts +198 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +197 -0
- package/dist/types.js.map +1 -0
- package/package.json +77 -3
- package/src/index.ts +73 -0
- package/src/module.ts +211 -0
- package/src/procedures/s3/delete.ts +45 -0
- package/src/procedures/s3/download.ts +155 -0
- package/src/procedures/s3/index.ts +13 -0
- package/src/procedures/s3/list-all.ts +63 -0
- package/src/procedures/s3/list.ts +63 -0
- package/src/procedures/s3/multipart-abort.ts +40 -0
- package/src/procedures/s3/multipart-complete.ts +58 -0
- package/src/procedures/s3/multipart-init.ts +50 -0
- package/src/procedures/s3/multipart-upload.ts +53 -0
- package/src/procedures/s3/upload.ts +56 -0
- package/src/register.ts +17 -0
- package/src/s3-client.ts +94 -0
- package/src/types.ts +425 -0
package/src/module.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module (no side effects): the procedures of this package as data, for `createRegistry()`.
|
|
3
|
+
*
|
|
4
|
+
* Procedure Registration for S3 operations
|
|
5
|
+
*
|
|
6
|
+
* Provides s3.upload, s3.download, s3.list, s3.delete, and s3.multipart.* procedures.
|
|
7
|
+
* Credentials are loaded from environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createProcedure, zodAdapter, defineModule, type ProcedureModule } from "@procedures/client";
|
|
11
|
+
import {
|
|
12
|
+
s3Upload,
|
|
13
|
+
s3Download,
|
|
14
|
+
s3List,
|
|
15
|
+
s3ListAll,
|
|
16
|
+
s3Delete,
|
|
17
|
+
s3MultipartInit,
|
|
18
|
+
s3MultipartUpload,
|
|
19
|
+
s3MultipartComplete,
|
|
20
|
+
s3MultipartAbort,
|
|
21
|
+
} from "./procedures/s3/index.js";
|
|
22
|
+
import {
|
|
23
|
+
S3UploadInputSchema,
|
|
24
|
+
S3DownloadInputSchema,
|
|
25
|
+
S3ListInputSchema,
|
|
26
|
+
S3ListAllInputSchema,
|
|
27
|
+
S3DeleteInputSchema,
|
|
28
|
+
S3MultipartInitInputSchema,
|
|
29
|
+
S3MultipartUploadInputSchema,
|
|
30
|
+
S3MultipartCompleteInputSchema,
|
|
31
|
+
S3MultipartAbortInputSchema,
|
|
32
|
+
type S3UploadInput,
|
|
33
|
+
type S3DownloadInput,
|
|
34
|
+
type S3ListInput,
|
|
35
|
+
type S3ListAllInput,
|
|
36
|
+
type S3DeleteInput,
|
|
37
|
+
type S3MultipartInitInput,
|
|
38
|
+
type S3MultipartUploadInput,
|
|
39
|
+
type S3MultipartCompleteInput,
|
|
40
|
+
type S3MultipartAbortInput,
|
|
41
|
+
type S3UploadOutput,
|
|
42
|
+
type S3DownloadOutput,
|
|
43
|
+
type S3ListOutput,
|
|
44
|
+
type S3ListAllOutput,
|
|
45
|
+
type S3DeleteOutput,
|
|
46
|
+
type S3MultipartInitOutput,
|
|
47
|
+
type S3MultipartUploadOutput,
|
|
48
|
+
type S3MultipartCompleteOutput,
|
|
49
|
+
type S3MultipartAbortOutput,
|
|
50
|
+
S3UploadOutputSchema,
|
|
51
|
+
S3DownloadOutputSchema,
|
|
52
|
+
S3ListOutputSchema,
|
|
53
|
+
S3ListAllOutputSchema,
|
|
54
|
+
S3DeleteOutputSchema,
|
|
55
|
+
S3MultipartInitOutputSchema,
|
|
56
|
+
S3MultipartUploadOutputSchema,
|
|
57
|
+
S3MultipartCompleteOutputSchema,
|
|
58
|
+
S3MultipartAbortOutputSchema,
|
|
59
|
+
} from "./types.js";
|
|
60
|
+
|
|
61
|
+
// =============================================================================
|
|
62
|
+
// Procedures
|
|
63
|
+
// =============================================================================
|
|
64
|
+
|
|
65
|
+
const s3UploadProcedure = createProcedure()
|
|
66
|
+
.path(["s3", "upload"])
|
|
67
|
+
.input(zodAdapter<S3UploadInput>(S3UploadInputSchema))
|
|
68
|
+
.output(zodAdapter<S3UploadOutput>(S3UploadOutputSchema))
|
|
69
|
+
.meta({
|
|
70
|
+
description: "Upload content to S3 bucket",
|
|
71
|
+
shorts: { bucket: "b", key: "k" },
|
|
72
|
+
output: "json",
|
|
73
|
+
})
|
|
74
|
+
.handler(async (input: S3UploadInput): Promise<S3UploadOutput> => {
|
|
75
|
+
return s3Upload(input);
|
|
76
|
+
})
|
|
77
|
+
.build();
|
|
78
|
+
|
|
79
|
+
const s3DownloadProcedure = createProcedure()
|
|
80
|
+
.path(["s3", "download"])
|
|
81
|
+
.input(zodAdapter<S3DownloadInput>(S3DownloadInputSchema))
|
|
82
|
+
.output(zodAdapter<S3DownloadOutput>(S3DownloadOutputSchema))
|
|
83
|
+
.meta({
|
|
84
|
+
description: "Download content from S3 bucket",
|
|
85
|
+
shorts: { bucket: "b", key: "k", encoding: "e" },
|
|
86
|
+
output: "json",
|
|
87
|
+
})
|
|
88
|
+
.handler(async (input: S3DownloadInput): Promise<S3DownloadOutput> => {
|
|
89
|
+
return s3Download(input);
|
|
90
|
+
})
|
|
91
|
+
.build();
|
|
92
|
+
|
|
93
|
+
const s3ListProcedure = createProcedure()
|
|
94
|
+
.path(["s3", "list"])
|
|
95
|
+
.input(zodAdapter<S3ListInput>(S3ListInputSchema))
|
|
96
|
+
.output(zodAdapter<S3ListOutput>(S3ListOutputSchema))
|
|
97
|
+
.meta({
|
|
98
|
+
description: "List objects in S3 bucket",
|
|
99
|
+
shorts: { bucket: "b", prefix: "p", maxKeys: "n" },
|
|
100
|
+
output: "json",
|
|
101
|
+
})
|
|
102
|
+
.handler(async (input: S3ListInput): Promise<S3ListOutput> => {
|
|
103
|
+
return s3List(input);
|
|
104
|
+
})
|
|
105
|
+
.build();
|
|
106
|
+
|
|
107
|
+
const s3ListAllProcedure = createProcedure()
|
|
108
|
+
.path(["s3", "listAll"])
|
|
109
|
+
.input(zodAdapter<S3ListAllInput>(S3ListAllInputSchema))
|
|
110
|
+
.output(zodAdapter<S3ListAllOutput>(S3ListAllOutputSchema))
|
|
111
|
+
.meta({
|
|
112
|
+
description: "List ALL objects in S3 bucket, following continuation tokens (unbounded by 1000)",
|
|
113
|
+
shorts: { bucket: "b", prefix: "p", maxKeys: "n" },
|
|
114
|
+
output: "json",
|
|
115
|
+
})
|
|
116
|
+
.handler(async (input: S3ListAllInput): Promise<S3ListAllOutput> => {
|
|
117
|
+
return s3ListAll(input);
|
|
118
|
+
})
|
|
119
|
+
.build();
|
|
120
|
+
|
|
121
|
+
const s3DeleteProcedure = createProcedure()
|
|
122
|
+
.path(["s3", "delete"])
|
|
123
|
+
.input(zodAdapter<S3DeleteInput>(S3DeleteInputSchema))
|
|
124
|
+
.output(zodAdapter<S3DeleteOutput>(S3DeleteOutputSchema))
|
|
125
|
+
.meta({
|
|
126
|
+
description: "Delete object from S3 bucket",
|
|
127
|
+
shorts: { bucket: "b", key: "k" },
|
|
128
|
+
output: "json",
|
|
129
|
+
})
|
|
130
|
+
.handler(async (input: S3DeleteInput): Promise<S3DeleteOutput> => {
|
|
131
|
+
return s3Delete(input);
|
|
132
|
+
})
|
|
133
|
+
.build();
|
|
134
|
+
|
|
135
|
+
const s3MultipartInitProcedure = createProcedure()
|
|
136
|
+
.path(["s3", "multipart", "init"])
|
|
137
|
+
.input(zodAdapter<S3MultipartInitInput>(S3MultipartInitInputSchema))
|
|
138
|
+
.output(zodAdapter<S3MultipartInitOutput>(S3MultipartInitOutputSchema))
|
|
139
|
+
.meta({
|
|
140
|
+
description: "Initialize multipart upload for large files",
|
|
141
|
+
shorts: { bucket: "b", key: "k" },
|
|
142
|
+
output: "json",
|
|
143
|
+
})
|
|
144
|
+
.handler(async (input: S3MultipartInitInput): Promise<S3MultipartInitOutput> => {
|
|
145
|
+
return s3MultipartInit(input);
|
|
146
|
+
})
|
|
147
|
+
.build();
|
|
148
|
+
|
|
149
|
+
const s3MultipartUploadProcedure = createProcedure()
|
|
150
|
+
.path(["s3", "multipart", "upload"])
|
|
151
|
+
.input(zodAdapter<S3MultipartUploadInput>(S3MultipartUploadInputSchema))
|
|
152
|
+
.output(zodAdapter<S3MultipartUploadOutput>(S3MultipartUploadOutputSchema))
|
|
153
|
+
.meta({
|
|
154
|
+
description: "Upload a part in multipart upload",
|
|
155
|
+
shorts: { bucket: "b", key: "k", partNumber: "n" },
|
|
156
|
+
output: "json",
|
|
157
|
+
})
|
|
158
|
+
.handler(async (input: S3MultipartUploadInput): Promise<S3MultipartUploadOutput> => {
|
|
159
|
+
return s3MultipartUpload(input);
|
|
160
|
+
})
|
|
161
|
+
.build();
|
|
162
|
+
|
|
163
|
+
const s3MultipartCompleteProcedure = createProcedure()
|
|
164
|
+
.path(["s3", "multipart", "complete"])
|
|
165
|
+
.input(zodAdapter<S3MultipartCompleteInput>(S3MultipartCompleteInputSchema))
|
|
166
|
+
.output(zodAdapter<S3MultipartCompleteOutput>(S3MultipartCompleteOutputSchema))
|
|
167
|
+
.meta({
|
|
168
|
+
description: "Complete multipart upload by assembling parts",
|
|
169
|
+
shorts: { bucket: "b", key: "k" },
|
|
170
|
+
output: "json",
|
|
171
|
+
})
|
|
172
|
+
.handler(async (input: S3MultipartCompleteInput): Promise<S3MultipartCompleteOutput> => {
|
|
173
|
+
return s3MultipartComplete(input);
|
|
174
|
+
})
|
|
175
|
+
.build();
|
|
176
|
+
|
|
177
|
+
const s3MultipartAbortProcedure = createProcedure()
|
|
178
|
+
.path(["s3", "multipart", "abort"])
|
|
179
|
+
.input(zodAdapter<S3MultipartAbortInput>(S3MultipartAbortInputSchema))
|
|
180
|
+
.output(zodAdapter<S3MultipartAbortOutput>(S3MultipartAbortOutputSchema))
|
|
181
|
+
.meta({
|
|
182
|
+
description: "Abort multipart upload and clean up parts",
|
|
183
|
+
shorts: { bucket: "b", key: "k" },
|
|
184
|
+
output: "json",
|
|
185
|
+
})
|
|
186
|
+
.handler(async (input: S3MultipartAbortInput): Promise<S3MultipartAbortOutput> => {
|
|
187
|
+
return s3MultipartAbort(input);
|
|
188
|
+
})
|
|
189
|
+
.build();
|
|
190
|
+
|
|
191
|
+
// =============================================================================
|
|
192
|
+
// Registration
|
|
193
|
+
// =============================================================================
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The module of this package, for `createRegistry()`: its procedures as data. Importing this
|
|
197
|
+
* file registers nothing. (`register.ts` registers the same procedures in the global registry.)
|
|
198
|
+
*/
|
|
199
|
+
export const s3Module: ProcedureModule = defineModule("@procedures/s3", [
|
|
200
|
+
s3UploadProcedure,
|
|
201
|
+
s3DownloadProcedure,
|
|
202
|
+
s3ListProcedure,
|
|
203
|
+
s3ListAllProcedure,
|
|
204
|
+
s3DeleteProcedure,
|
|
205
|
+
s3MultipartInitProcedure,
|
|
206
|
+
s3MultipartUploadProcedure,
|
|
207
|
+
s3MultipartCompleteProcedure,
|
|
208
|
+
s3MultipartAbortProcedure,
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
export default s3Module;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* s3.delete procedure
|
|
3
|
+
*
|
|
4
|
+
* Delete object from S3 bucket.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
|
|
8
|
+
import { getS3Client } from "../../s3-client.js";
|
|
9
|
+
import type { S3DeleteInput, S3DeleteOutput } from "../../types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Delete object from S3
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // Delete an object
|
|
16
|
+
* await client.call(["s3", "delete"], {
|
|
17
|
+
* bucket: "my-bucket",
|
|
18
|
+
* key: "path/to/file.json",
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* // Delete specific version
|
|
23
|
+
* await client.call(["s3", "delete"], {
|
|
24
|
+
* bucket: "my-bucket",
|
|
25
|
+
* key: "path/to/file.json",
|
|
26
|
+
* versionId: "abc123",
|
|
27
|
+
* });
|
|
28
|
+
*/
|
|
29
|
+
export async function s3Delete(input: S3DeleteInput): Promise<S3DeleteOutput> {
|
|
30
|
+
const client = getS3Client();
|
|
31
|
+
|
|
32
|
+
const command = new DeleteObjectCommand({
|
|
33
|
+
Bucket: input.bucket,
|
|
34
|
+
Key: input.key,
|
|
35
|
+
VersionId: input.versionId,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const response = await client.send(command);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
deleted: true,
|
|
42
|
+
deleteMarker: response.DeleteMarker,
|
|
43
|
+
versionId: response.VersionId,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* s3.download procedure
|
|
3
|
+
*
|
|
4
|
+
* Download content from S3 bucket.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { createWriteStream } from "node:fs";
|
|
9
|
+
import { rename, rm } from "node:fs/promises";
|
|
10
|
+
import { Transform, type Readable } from "node:stream";
|
|
11
|
+
import { pipeline } from "node:stream/promises";
|
|
12
|
+
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
13
|
+
import { getS3Client } from "../../s3-client.js";
|
|
14
|
+
import type { S3DownloadInput, S3DownloadOutput } from "../../types.js";
|
|
15
|
+
|
|
16
|
+
/** Largest object loaded into memory by default (the base64 body is a third larger again) */
|
|
17
|
+
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
function tooLarge(bytes: number | string, maxBytes: number): Error {
|
|
20
|
+
return new Error(
|
|
21
|
+
`Object is ${bytes} bytes, more than maxBytes (${maxBytes}). Use destPath to download it to a file.`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A stream step that counts the bytes and fails past `maxBytes`. */
|
|
26
|
+
function byteLimit(maxBytes: number): Transform {
|
|
27
|
+
let total = 0;
|
|
28
|
+
return new Transform({
|
|
29
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
30
|
+
total += chunk.length;
|
|
31
|
+
if (total > maxBytes) {
|
|
32
|
+
callback(tooLarge(`more than ${maxBytes}`, maxBytes));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
callback(null, chunk);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read a body into memory, counting the bytes. `Content-Length` can be missing or wrong, so the
|
|
42
|
+
* count is the limit (deep dive DATA-14).
|
|
43
|
+
*/
|
|
44
|
+
async function readLimited(body: Readable, maxBytes: number): Promise<Buffer> {
|
|
45
|
+
const chunks: Buffer[] = [];
|
|
46
|
+
let total = 0;
|
|
47
|
+
for await (const chunk of body) {
|
|
48
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
|
49
|
+
total += buffer.length;
|
|
50
|
+
if (total > maxBytes) {
|
|
51
|
+
body.destroy();
|
|
52
|
+
throw tooLarge(`more than ${maxBytes}`, maxBytes);
|
|
53
|
+
}
|
|
54
|
+
chunks.push(buffer);
|
|
55
|
+
}
|
|
56
|
+
return Buffer.concat(chunks);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Download content from S3
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* // Download text file
|
|
64
|
+
* const result = await client.call(["s3", "download"], {
|
|
65
|
+
* bucket: "my-bucket",
|
|
66
|
+
* key: "path/to/file.json",
|
|
67
|
+
* encoding: "utf8",
|
|
68
|
+
* });
|
|
69
|
+
* console.log(result.body); // JSON string
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* // Download binary as base64
|
|
73
|
+
* const result = await client.call(["s3", "download"], {
|
|
74
|
+
* bucket: "my-bucket",
|
|
75
|
+
* key: "path/to/image.png",
|
|
76
|
+
* });
|
|
77
|
+
* console.log(result.body); // base64 encoded
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* // Download a large object straight to a file
|
|
81
|
+
* await client.call(["s3", "download"], {
|
|
82
|
+
* bucket: "my-bucket",
|
|
83
|
+
* key: "backups/archive.tar.gz",
|
|
84
|
+
* destPath: "/tmp/archive.tar.gz",
|
|
85
|
+
* });
|
|
86
|
+
*/
|
|
87
|
+
export async function s3Download(input: S3DownloadInput): Promise<S3DownloadOutput> {
|
|
88
|
+
const client = getS3Client();
|
|
89
|
+
|
|
90
|
+
const command = new GetObjectCommand({
|
|
91
|
+
Bucket: input.bucket,
|
|
92
|
+
Key: input.key,
|
|
93
|
+
VersionId: input.versionId,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const response = await client.send(command);
|
|
97
|
+
|
|
98
|
+
// To a file: stream the body, nothing is held in memory (BUGS-2026-07 M22)
|
|
99
|
+
if (input.destPath) {
|
|
100
|
+
if (!response.Body) {
|
|
101
|
+
throw new Error("Empty response body");
|
|
102
|
+
}
|
|
103
|
+
// Write a part file, then rename it. A failed transfer leaves the previous file
|
|
104
|
+
// (createWriteStream on destPath truncated it first: deep dive DATA-14).
|
|
105
|
+
const partPath = `${input.destPath}.${randomUUID().slice(0, 8)}.part`;
|
|
106
|
+
const source = response.Body as Readable;
|
|
107
|
+
try {
|
|
108
|
+
if (input.maxBytes !== undefined) {
|
|
109
|
+
await pipeline(source, byteLimit(input.maxBytes), createWriteStream(partPath));
|
|
110
|
+
} else {
|
|
111
|
+
await pipeline(source, createWriteStream(partPath));
|
|
112
|
+
}
|
|
113
|
+
await rename(partPath, input.destPath);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await rm(partPath, { force: true });
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
body: "",
|
|
120
|
+
path: input.destPath,
|
|
121
|
+
contentLength: response.ContentLength ?? 0,
|
|
122
|
+
contentType: response.ContentType,
|
|
123
|
+
etag: response.ETag?.replace(/"/g, ""),
|
|
124
|
+
lastModified: response.LastModified?.toISOString(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Into memory: refuse an object that is too large before reading it (BUGS-2026-07 M22)
|
|
129
|
+
const maxBytes = input.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
130
|
+
if (response.ContentLength !== undefined && response.ContentLength > maxBytes) {
|
|
131
|
+
(response.Body as Readable | undefined)?.destroy?.();
|
|
132
|
+
throw tooLarge(response.ContentLength, maxBytes);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!response.Body) {
|
|
136
|
+
throw new Error("Empty response body");
|
|
137
|
+
}
|
|
138
|
+
const bodyBytes = await readLimited(response.Body as Readable, maxBytes);
|
|
139
|
+
|
|
140
|
+
// Convert to string based on encoding
|
|
141
|
+
let body: string;
|
|
142
|
+
if (input.encoding) {
|
|
143
|
+
body = bodyBytes.toString(input.encoding as BufferEncoding);
|
|
144
|
+
} else {
|
|
145
|
+
body = bodyBytes.toString("base64");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
body,
|
|
150
|
+
contentLength: bodyBytes.length,
|
|
151
|
+
contentType: response.ContentType,
|
|
152
|
+
etag: response.ETag?.replace(/"/g, ""),
|
|
153
|
+
lastModified: response.LastModified?.toISOString(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3 Procedures Index
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { s3Upload } from "./upload.js";
|
|
6
|
+
export { s3Download } from "./download.js";
|
|
7
|
+
export { s3List } from "./list.js";
|
|
8
|
+
export { s3ListAll } from "./list-all.js";
|
|
9
|
+
export { s3Delete } from "./delete.js";
|
|
10
|
+
export { s3MultipartInit } from "./multipart-init.js";
|
|
11
|
+
export { s3MultipartUpload } from "./multipart-upload.js";
|
|
12
|
+
export { s3MultipartComplete } from "./multipart-complete.js";
|
|
13
|
+
export { s3MultipartAbort } from "./multipart-abort.js";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* s3.listAll procedure
|
|
3
|
+
*
|
|
4
|
+
* List ALL objects in an S3 bucket, transparently following the
|
|
5
|
+
* ListObjectsV2 continuation token so that buckets/prefixes with more than
|
|
6
|
+
* 1000 keys are fully reachable in a single call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { s3List } from "./list.js";
|
|
10
|
+
import type { S3ListAllInput, S3ListAllOutput, S3ListObject } from "../../types.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* List every object under a bucket/prefix, following pagination automatically.
|
|
14
|
+
*
|
|
15
|
+
* Unlike `s3.list` (which returns at most one page — up to 1000 keys — and
|
|
16
|
+
* exposes `nextContinuationToken` for manual paging), `s3.listAll` loops until
|
|
17
|
+
* the result is no longer truncated and returns the aggregated contents.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const { contents, keyCount } = await client.call(["s3", "listAll"], {
|
|
21
|
+
* bucket: "my-bucket",
|
|
22
|
+
* prefix: "snapshots/",
|
|
23
|
+
* });
|
|
24
|
+
* console.log(keyCount); // e.g. 4213, not clamped to 1000
|
|
25
|
+
*/
|
|
26
|
+
export async function s3ListAll(input: S3ListAllInput): Promise<S3ListAllOutput> {
|
|
27
|
+
const contents: S3ListObject[] = [];
|
|
28
|
+
const commonPrefixes: string[] = [];
|
|
29
|
+
const seenPrefixes = new Set<string>();
|
|
30
|
+
let continuationToken: string | undefined = undefined;
|
|
31
|
+
let pageCount = 0;
|
|
32
|
+
|
|
33
|
+
do {
|
|
34
|
+
const page = await s3List({
|
|
35
|
+
bucket: input.bucket,
|
|
36
|
+
prefix: input.prefix,
|
|
37
|
+
delimiter: input.delimiter,
|
|
38
|
+
maxKeys: input.maxKeys,
|
|
39
|
+
continuationToken,
|
|
40
|
+
});
|
|
41
|
+
pageCount += 1;
|
|
42
|
+
|
|
43
|
+
for (const object of page.contents) {
|
|
44
|
+
contents.push(object);
|
|
45
|
+
}
|
|
46
|
+
for (const prefix of page.commonPrefixes) {
|
|
47
|
+
if (!seenPrefixes.has(prefix)) {
|
|
48
|
+
seenPrefixes.add(prefix);
|
|
49
|
+
commonPrefixes.push(prefix);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Only continue while S3 reports more results AND hands back a token.
|
|
54
|
+
continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;
|
|
55
|
+
} while (continuationToken);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
contents,
|
|
59
|
+
commonPrefixes,
|
|
60
|
+
keyCount: contents.length,
|
|
61
|
+
pageCount,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* s3.list procedure
|
|
3
|
+
*
|
|
4
|
+
* List objects in S3 bucket.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ListObjectsV2Command } from "@aws-sdk/client-s3";
|
|
8
|
+
import { getS3Client } from "../../s3-client.js";
|
|
9
|
+
import type { S3ListInput, S3ListOutput, S3ListObject } from "../../types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* List objects in S3 bucket
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // List all objects with prefix
|
|
16
|
+
* const result = await client.call(["s3", "list"], {
|
|
17
|
+
* bucket: "my-bucket",
|
|
18
|
+
* prefix: "snapshots/",
|
|
19
|
+
* maxKeys: 100,
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* // Hierarchical listing with delimiter
|
|
24
|
+
* const result = await client.call(["s3", "list"], {
|
|
25
|
+
* bucket: "my-bucket",
|
|
26
|
+
* prefix: "data/",
|
|
27
|
+
* delimiter: "/",
|
|
28
|
+
* });
|
|
29
|
+
* console.log(result.commonPrefixes); // ["data/2024/", "data/2025/"]
|
|
30
|
+
*/
|
|
31
|
+
export async function s3List(input: S3ListInput): Promise<S3ListOutput> {
|
|
32
|
+
const client = getS3Client();
|
|
33
|
+
|
|
34
|
+
const command = new ListObjectsV2Command({
|
|
35
|
+
Bucket: input.bucket,
|
|
36
|
+
Prefix: input.prefix,
|
|
37
|
+
MaxKeys: input.maxKeys || 1000,
|
|
38
|
+
ContinuationToken: input.continuationToken,
|
|
39
|
+
Delimiter: input.delimiter,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const response = await client.send(command);
|
|
43
|
+
|
|
44
|
+
const contents: S3ListObject[] = (response.Contents || []).map((obj) => ({
|
|
45
|
+
key: obj.Key || "",
|
|
46
|
+
size: obj.Size || 0,
|
|
47
|
+
lastModified: obj.LastModified?.toISOString() || "",
|
|
48
|
+
etag: obj.ETag?.replace(/"/g, "") || "",
|
|
49
|
+
storageClass: obj.StorageClass,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const commonPrefixes = (response.CommonPrefixes || [])
|
|
53
|
+
.map((cp) => cp.Prefix || "")
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
contents,
|
|
58
|
+
commonPrefixes,
|
|
59
|
+
isTruncated: response.IsTruncated || false,
|
|
60
|
+
nextContinuationToken: response.NextContinuationToken,
|
|
61
|
+
keyCount: response.KeyCount || contents.length,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* s3.multipart.abort procedure
|
|
3
|
+
*
|
|
4
|
+
* Abort a multipart upload and clean up parts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { AbortMultipartUploadCommand } from "@aws-sdk/client-s3";
|
|
8
|
+
import { getS3Client } from "../../s3-client.js";
|
|
9
|
+
import type { S3MultipartAbortInput, S3MultipartAbortOutput } from "../../types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Abort multipart upload
|
|
13
|
+
*
|
|
14
|
+
* Use to cancel an in-progress multipart upload.
|
|
15
|
+
* This cleans up any parts that were already uploaded.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* await client.call(["s3", "multipart", "abort"], {
|
|
19
|
+
* bucket: "my-bucket",
|
|
20
|
+
* key: "large-file.tar.gz",
|
|
21
|
+
* uploadId: "abc123",
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
export async function s3MultipartAbort(
|
|
25
|
+
input: S3MultipartAbortInput
|
|
26
|
+
): Promise<S3MultipartAbortOutput> {
|
|
27
|
+
const client = getS3Client();
|
|
28
|
+
|
|
29
|
+
const command = new AbortMultipartUploadCommand({
|
|
30
|
+
Bucket: input.bucket,
|
|
31
|
+
Key: input.key,
|
|
32
|
+
UploadId: input.uploadId,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await client.send(command);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
aborted: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* s3.multipart.complete procedure
|
|
3
|
+
*
|
|
4
|
+
* Complete a multipart upload by assembling parts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CompleteMultipartUploadCommand } from "@aws-sdk/client-s3";
|
|
8
|
+
import { getS3Client } from "../../s3-client.js";
|
|
9
|
+
import type { S3MultipartCompleteInput, S3MultipartCompleteOutput } from "../../types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Complete multipart upload
|
|
13
|
+
*
|
|
14
|
+
* Call after all parts have been uploaded. Parts must be provided
|
|
15
|
+
* in order with their ETags from the upload responses.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const result = await client.call(["s3", "multipart", "complete"], {
|
|
19
|
+
* bucket: "my-bucket",
|
|
20
|
+
* key: "large-file.tar.gz",
|
|
21
|
+
* uploadId: "abc123",
|
|
22
|
+
* parts: [
|
|
23
|
+
* { partNumber: 1, etag: "etag1" },
|
|
24
|
+
* { partNumber: 2, etag: "etag2" },
|
|
25
|
+
* { partNumber: 3, etag: "etag3" },
|
|
26
|
+
* ],
|
|
27
|
+
* });
|
|
28
|
+
*/
|
|
29
|
+
export async function s3MultipartComplete(
|
|
30
|
+
input: S3MultipartCompleteInput
|
|
31
|
+
): Promise<S3MultipartCompleteOutput> {
|
|
32
|
+
const client = getS3Client();
|
|
33
|
+
|
|
34
|
+
const command = new CompleteMultipartUploadCommand({
|
|
35
|
+
Bucket: input.bucket,
|
|
36
|
+
Key: input.key,
|
|
37
|
+
UploadId: input.uploadId,
|
|
38
|
+
MultipartUpload: {
|
|
39
|
+
// S3 refuses parts out of order. Parts that were uploaded in parallel can arrive in any
|
|
40
|
+
// order, so the procedure sorts them by number.
|
|
41
|
+
Parts: [...input.parts]
|
|
42
|
+
.sort((a, b) => a.partNumber - b.partNumber)
|
|
43
|
+
.map((part) => ({
|
|
44
|
+
ETag: `"${part.etag}"`,
|
|
45
|
+
PartNumber: part.partNumber,
|
|
46
|
+
})),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const response = await client.send(command);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
location: response.Location || `s3://${input.bucket}/${input.key}`,
|
|
54
|
+
bucket: response.Bucket || input.bucket,
|
|
55
|
+
key: response.Key || input.key,
|
|
56
|
+
etag: response.ETag?.replace(/"/g, "") || "",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* s3.multipart.init procedure
|
|
3
|
+
*
|
|
4
|
+
* Initialize a multipart upload for large files.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CreateMultipartUploadCommand } from "@aws-sdk/client-s3";
|
|
8
|
+
import { getS3Client } from "../../s3-client.js";
|
|
9
|
+
import type { S3MultipartInitInput, S3MultipartInitOutput } from "../../types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Initialize multipart upload
|
|
13
|
+
*
|
|
14
|
+
* Use this for files larger than 5MB. Workflow:
|
|
15
|
+
* 1. s3.multipart.init - get uploadId
|
|
16
|
+
* 2. s3.multipart.upload - upload each part (5MB min, except last)
|
|
17
|
+
* 3. s3.multipart.complete - finalize with part ETags
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const init = await client.call(["s3", "multipart", "init"], {
|
|
21
|
+
* bucket: "my-bucket",
|
|
22
|
+
* key: "large-file.tar.gz",
|
|
23
|
+
* contentType: "application/gzip",
|
|
24
|
+
* });
|
|
25
|
+
* console.log(init.uploadId); // Use for subsequent part uploads
|
|
26
|
+
*/
|
|
27
|
+
export async function s3MultipartInit(
|
|
28
|
+
input: S3MultipartInitInput
|
|
29
|
+
): Promise<S3MultipartInitOutput> {
|
|
30
|
+
const client = getS3Client();
|
|
31
|
+
|
|
32
|
+
const command = new CreateMultipartUploadCommand({
|
|
33
|
+
Bucket: input.bucket,
|
|
34
|
+
Key: input.key,
|
|
35
|
+
ContentType: input.contentType,
|
|
36
|
+
Metadata: input.metadata,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const response = await client.send(command);
|
|
40
|
+
|
|
41
|
+
if (!response.UploadId) {
|
|
42
|
+
throw new Error("Failed to initialize multipart upload: no UploadId returned");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
uploadId: response.UploadId,
|
|
47
|
+
bucket: input.bucket,
|
|
48
|
+
key: input.key,
|
|
49
|
+
};
|
|
50
|
+
}
|