@procedures/s3 0.47.2 → 0.48.1

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/src/module.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  s3MultipartAbort,
22
22
  } from "./procedures/s3/index.js";
23
23
  import {
24
- S3UploadInputSchema,
24
+ S3UploadCallSchema,
25
25
  S3DownloadInputSchema,
26
26
  S3ListInputSchema,
27
27
  S3ListAllInputSchema,
@@ -30,7 +30,7 @@ import {
30
30
  S3MultipartUploadInputSchema,
31
31
  S3MultipartCompleteInputSchema,
32
32
  S3MultipartAbortInputSchema,
33
- type S3UploadInput,
33
+ type S3UploadCall,
34
34
  type S3DownloadInput,
35
35
  type S3ListInput,
36
36
  type S3ListAllInput,
@@ -76,30 +76,35 @@ import {
76
76
  type S3PresignOutput,
77
77
  } from "./types.js";
78
78
 
79
+ // =============================================================================
80
+ // The rules under a root of the files (roadmap R33, tracker item O36)
81
+ // =============================================================================
82
+
83
+ /** `s3.upload`: under a root, the file of `sourcePath` is in the root. A `body` names no file. */
84
+ const UPLOAD_IN_A_ROOT: FsRootRules = { paths: ["sourcePath"] };
85
+
79
86
  // =============================================================================
80
87
  // Procedures
81
88
  // =============================================================================
82
89
 
83
90
  const s3UploadProcedure = createProcedure()
84
91
  .path(["s3", "upload"])
85
- .input(S3UploadInputSchema)
92
+ .input(S3UploadCallSchema)
86
93
  .output(S3UploadOutputSchema)
87
94
  .meta({
88
95
  description: "Upload content to S3 bucket",
89
- // A put replaces the object of an existing key
90
- effects: { reads: ["s3", "network"], writes: ["s3", "network"] },
96
+ // A put replaces the object of an existing key. With sourcePath, it reads that file.
97
+ effects: { reads: ["s3", "network", "fs"], writes: ["s3", "network"] },
91
98
  shorts: { bucket: "b", key: "k" },
92
99
  output: "json",
100
+ fsRoot: UPLOAD_IN_A_ROOT,
93
101
  })
94
- .handler(async (input: S3UploadInput): Promise<S3UploadOutput> => {
95
- return s3Upload(input);
102
+ .handler(async (input): Promise<S3UploadOutput> => {
103
+ // The two refinements of the schema give this type: a body or a sourcePath, and not the two
104
+ return s3Upload(input as S3UploadCall);
96
105
  })
97
106
  .build();
98
107
 
99
- // =============================================================================
100
- // The rules under a root of the files (roadmap R33, tracker item O36)
101
- // =============================================================================
102
-
103
108
  /** `s3.download`: under a root, the file of `destPath` is in the root. Without it, the object stays in memory. */
104
109
  const DOWNLOAD_IN_A_ROOT: FsRootRules = { paths: ["destPath"] };
105
110
 
@@ -1,12 +1,126 @@
1
1
  /**
2
2
  * s3.upload procedure
3
3
  *
4
- * Upload content to S3 bucket.
4
+ * Upload content to S3 bucket, from a string in memory or from a file.
5
5
  */
6
6
 
7
- import { PutObjectCommand } from "@aws-sdk/client-s3";
7
+ import { open, type FileHandle } from "node:fs/promises";
8
+ import type { Stats } from "node:fs";
9
+ import {
10
+ AbortMultipartUploadCommand,
11
+ CompleteMultipartUploadCommand,
12
+ CreateMultipartUploadCommand,
13
+ PutObjectCommand,
14
+ UploadPartCommand,
15
+ type CompletedPart,
16
+ } from "@aws-sdk/client-s3";
8
17
  import { getS3Client } from "../../s3-client.js";
9
- import type { S3UploadInput, S3UploadOutput } from "../../types.js";
18
+ import type { S3UploadCall, S3UploadInput, S3UploadOutput } from "../../types.js";
19
+
20
+ /** The fields of an upload that do not depend on where its content comes from. */
21
+ type UploadTarget = Pick<S3UploadInput, "bucket" | "key" | "contentType" | "metadata">;
22
+
23
+ /** The size of one part of a file, in bytes. A file of this size or less goes in one put. */
24
+ export const PART_BYTES: number = 8 * 1024 * 1024;
25
+
26
+ /** The largest number of parts that S3 takes in one multipart upload. */
27
+ export const MOST_PARTS = 10_000;
28
+
29
+ /**
30
+ * This function gives the size of the parts of a file. A very large file has larger parts, because
31
+ * S3 takes at most `MOST_PARTS` parts. The peak memory of an upload is one part of this size.
32
+ */
33
+ export function partBytesOf(size: number): number {
34
+ return Math.max(PART_BYTES, Math.ceil(size / MOST_PARTS));
35
+ }
36
+
37
+ /** This function gives the error of a file that changed after the upload started. */
38
+ function changed(sourcePath: string): Error {
39
+ return new Error(`The file ${sourcePath} changed during the upload, so the object is not complete. Upload the file again.`);
40
+ }
41
+
42
+ /** This function fills the start of `buffer` from the file. A short file fails, because a short part is not the file. */
43
+ async function readPart(file: FileHandle, buffer: Buffer, length: number, position: number, sourcePath: string): Promise<void> {
44
+ let done = 0;
45
+ while (done < length) {
46
+ const { bytesRead } = await file.read(buffer, done, length - done, position + done);
47
+ if (bytesRead === 0) throw changed(sourcePath);
48
+ done += bytesRead;
49
+ }
50
+ }
51
+
52
+ /** This function fails when the size or the time of the file is not the same as at the start. */
53
+ async function assertSame(file: FileHandle, start: Stats, sourcePath: string): Promise<void> {
54
+ const now = await file.stat();
55
+ if (now.size !== start.size || now.mtimeMs !== start.mtimeMs) throw changed(sourcePath);
56
+ }
57
+
58
+ /**
59
+ * This function sends the file part by part, one part at a time, with one buffer. On a failure it
60
+ * aborts the upload, so S3 keeps no object and no parts. The first error stays the error of the
61
+ * call, also when the abort fails.
62
+ */
63
+ async function uploadParts(file: FileHandle, start: Stats, partBytes: number, input: UploadTarget, sourcePath: string): Promise<S3UploadOutput> {
64
+ const client = getS3Client();
65
+ const target = { Bucket: input.bucket, Key: input.key };
66
+ const created = await client.send(
67
+ new CreateMultipartUploadCommand({ ...target, ContentType: input.contentType, Metadata: input.metadata }),
68
+ );
69
+ const UploadId = created.UploadId;
70
+ if (UploadId === undefined) throw new Error(`S3 gave no upload id for ${input.key}`);
71
+ try {
72
+ const buffer = Buffer.allocUnsafeSlow(partBytes);
73
+ const parts: CompletedPart[] = [];
74
+ for (let position = 0; position < start.size; position += partBytes) {
75
+ const length = Math.min(partBytes, start.size - position);
76
+ await readPart(file, buffer, length, position, sourcePath);
77
+ const PartNumber = parts.length + 1;
78
+ const sent = await client.send(new UploadPartCommand({ ...target, UploadId, PartNumber, Body: buffer.subarray(0, length) }));
79
+ parts.push({ ETag: sent.ETag, PartNumber });
80
+ }
81
+ await assertSame(file, start, sourcePath);
82
+ const done = await client.send(new CompleteMultipartUploadCommand({ ...target, UploadId, MultipartUpload: { Parts: parts } }));
83
+ return { etag: done.ETag?.replace(/"/g, "") || "", versionId: done.VersionId, location: `s3://${input.bucket}/${input.key}` };
84
+ } catch (error) {
85
+ await client.send(new AbortMultipartUploadCommand({ ...target, UploadId })).catch(() => undefined);
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ /** This function uploads a file: in one put up to the size of one part, and in parts above it. */
91
+ async function uploadFile(input: UploadTarget, sourcePath: string): Promise<S3UploadOutput> {
92
+ const file = await open(sourcePath, "r");
93
+ try {
94
+ const start = await file.stat();
95
+ if (!start.isFile()) throw new Error(`${sourcePath} is not a file`);
96
+ const partBytes = partBytesOf(start.size);
97
+ if (start.size > partBytes) return await uploadParts(file, start, partBytes, input, sourcePath);
98
+ const body = Buffer.allocUnsafeSlow(start.size);
99
+ await readPart(file, body, start.size, 0, sourcePath);
100
+ await assertSame(file, start, sourcePath);
101
+ return await put(input, body);
102
+ } finally {
103
+ await file.close();
104
+ }
105
+ }
106
+
107
+ /** This function sends one put. */
108
+ async function put(input: UploadTarget, body: Buffer | string): Promise<S3UploadOutput> {
109
+ const response = await getS3Client().send(
110
+ new PutObjectCommand({
111
+ Bucket: input.bucket,
112
+ Key: input.key,
113
+ Body: body,
114
+ ContentType: input.contentType,
115
+ Metadata: input.metadata,
116
+ }),
117
+ );
118
+ return {
119
+ etag: response.ETag?.replace(/"/g, "") || "",
120
+ versionId: response.VersionId,
121
+ location: `s3://${input.bucket}/${input.key}`,
122
+ };
123
+ }
10
124
 
11
125
  /**
12
126
  * Upload content to S3
@@ -29,28 +143,20 @@ import type { S3UploadInput, S3UploadOutput } from "../../types.js";
29
143
  * base64: true,
30
144
  * contentType: "image/png",
31
145
  * });
146
+ *
147
+ * @example
148
+ * // Upload a large file in bounded memory: the object is whole or absent
149
+ * await client.call(["s3", "upload"], {
150
+ * bucket: "my-bucket",
151
+ * key: "backups/archive.tar.gz",
152
+ * sourcePath: "/tmp/archive.tar.gz",
153
+ * });
32
154
  */
33
- export async function s3Upload(input: S3UploadInput): Promise<S3UploadOutput> {
34
- const client = getS3Client();
35
-
36
- // Convert body to Buffer if base64
37
- const body = input.base64
38
- ? Buffer.from(input.body, "base64")
39
- : input.body;
40
-
41
- const command = new PutObjectCommand({
42
- Bucket: input.bucket,
43
- Key: input.key,
44
- Body: body,
45
- ContentType: input.contentType,
46
- Metadata: input.metadata,
47
- });
48
-
49
- const response = await client.send(command);
50
-
51
- return {
52
- etag: response.ETag?.replace(/"/g, "") || "",
53
- versionId: response.VersionId,
54
- location: `s3://${input.bucket}/${input.key}`,
55
- };
155
+ export async function s3Upload(input: S3UploadCall): Promise<S3UploadOutput> {
156
+ if (input.sourcePath !== undefined) {
157
+ if (input.body !== undefined) throw new Error("Give body or sourcePath, and not the two");
158
+ return uploadFile(input, input.sourcePath);
159
+ }
160
+ if (input.body === undefined) throw new Error("Give body or sourcePath, and not the two");
161
+ return put(input, input.base64 ? Buffer.from(input.body, "base64") : input.body);
56
162
  }
package/src/types.ts CHANGED
@@ -59,6 +59,58 @@ export const S3UploadInputSchema: z.ZodObject<{
59
59
 
60
60
  export type S3UploadInput = z.infer<typeof S3UploadInputSchema>;
61
61
 
62
+ /** The input of an upload from a file. It refuses `body` and `base64`. */
63
+ export const S3UploadFileInputSchema: z.ZodObject<{
64
+ bucket: z.ZodString;
65
+ key: z.ZodString;
66
+ sourcePath: z.ZodString;
67
+ contentType: z.ZodOptional<z.ZodString>;
68
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
69
+ body: z.ZodOptional<z.ZodUndefined>;
70
+ base64: z.ZodOptional<z.ZodUndefined>;
71
+ }> = z.object({
72
+ bucket: S3UploadInputSchema.shape.bucket,
73
+ key: S3UploadInputSchema.shape.key,
74
+ /** The path of a file to upload. The upload reads one part at a time, and the object is whole or absent. */
75
+ sourcePath: z.string().min(1).describe("The path of a file to upload. The upload reads one part at a time, and the object is whole or absent."),
76
+ contentType: S3UploadInputSchema.shape.contentType,
77
+ metadata: S3UploadInputSchema.shape.metadata,
78
+ body: z.undefined().optional(),
79
+ base64: z.undefined().optional(),
80
+ });
81
+
82
+ export type S3UploadFileInput = z.infer<typeof S3UploadFileInputSchema>;
83
+
84
+ /** A call of `s3.upload`: a call with both `body` and `sourcePath`, or with neither, fails at the type. */
85
+ export type S3UploadCall = (S3UploadInput & { sourcePath?: undefined }) | S3UploadFileInput;
86
+
87
+ /**
88
+ * The schema of the input of the procedure `s3.upload`: one object with two refinements.
89
+ *
90
+ * This schema is not a `z.union`, because a schema of an object removes unknown keys. A plain union
91
+ * reads a call with both inputs as a call with `body`, and that call sends the wrong content without an error.
92
+ */
93
+ export const S3UploadCallSchema: z.ZodObject<{
94
+ bucket: z.ZodString;
95
+ key: z.ZodString;
96
+ body: z.ZodOptional<z.ZodString>;
97
+ sourcePath: z.ZodOptional<z.ZodString>;
98
+ contentType: z.ZodOptional<z.ZodString>;
99
+ base64: z.ZodOptional<z.ZodBoolean>;
100
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
101
+ }> = z
102
+ .object({
103
+ bucket: S3UploadInputSchema.shape.bucket,
104
+ key: S3UploadInputSchema.shape.key,
105
+ body: z.string().optional().describe("Content to upload - string or base64 encoded binary. Give body or sourcePath, and not the two."),
106
+ sourcePath: z.string().min(1).optional().describe("The path of a file to upload. The upload reads one part at a time, and the object is whole or absent."),
107
+ contentType: S3UploadInputSchema.shape.contentType,
108
+ base64: S3UploadInputSchema.shape.base64,
109
+ metadata: S3UploadInputSchema.shape.metadata,
110
+ })
111
+ .refine((input) => (input.body !== undefined) !== (input.sourcePath !== undefined), { message: "Give body or sourcePath, and not the two" })
112
+ .refine((input) => input.base64 !== true || input.body !== undefined, { message: "base64 applies only to body" });
113
+
62
114
  export interface S3UploadOutput {
63
115
  /** ETag of uploaded object */
64
116
  etag: string;