Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2219,6 +2219,49 @@ declare module "bun" {
},
): Promise<number>;

/**
* Upload `input` to an {@link S3File}.
*
* `options` applies to this write only: `destination` keeps its own options.
* An option that is set replaces the same option of `destination`, with one
* exception: `partSize`, `queueSize` and `retry` are applied to a stream
* `input`. For a `BunFile` or an `S3File` `input`, those of `destination`
* are used.
*
* `options` takes no `type`. The content type of the upload comes from
* `destination`: its `type` when one is set, or else the extension of its
* key. To set it, use {@link S3File.write} or the `type` option of `file()`.
*
* @param destination The S3 file to write to
* @param input The data to upload
* @param options S3 options for this write
*
* @returns A promise that resolves with the number of bytes written.
*
* @example
* ```ts
* const file = new Bun.S3Client({ bucket: "my-bucket" }).file("report.pdf");
* await Bun.write(file, pdf, { storageClass: "STANDARD_IA", contentDisposition: "attachment" });
* ```
*/
function write(
destination: S3File,
input:
Blob | ArrayBufferView | ArrayBufferLike | string | BlobPart[] | Archive | ReadableStream | Response | Request,
options?: Omit<S3Options, "type"> & {
// Spelled like the file signatures, without `| undefined`: with it, a function typed `typeof Bun.write`
// that takes `options?: { createPath?: boolean }` does not compile under exactOptionalPropertyTypes.
/**
* Has no effect on an S3 upload. The value must still be a file mode from 0 to 0o777.
*/
mode?: number;
/**
* Has no effect on an S3 upload.
*/
createPath?: boolean;
},
): Promise<number>;

/**
* Use the fastest syscalls available to copy from `input` into `destination`.
*
Expand Down
10 changes: 7 additions & 3 deletions packages/bun-types/s3.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,9 @@ declare module "bun" {
| BunFile
| S3File
| Blob
| Archive,
| File
| Archive
| ReadableStream,
options?: S3Options,
): Promise<number>;

Expand Down Expand Up @@ -1058,7 +1060,8 @@ declare module "bun" {
| S3File
| Blob
| File
| Archive,
| Archive
| ReadableStream,
options?: S3Options,
): Promise<number>;

Expand Down Expand Up @@ -1111,7 +1114,8 @@ declare module "bun" {
| S3File
| Blob
| File
| Archive,
| Archive
| ReadableStream,
options?: S3Options,
): Promise<number>;

Expand Down
22 changes: 22 additions & 0 deletions test/integration/bun-types/bun-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,28 @@ describe("@types/bun integration test", () => {
});
});

describe("Bun.write options", () => {
// Each signature of Bun.write() spells `mode` and `createPath` the same way, so that the options of
// each one fit the file options. One that adds `| undefined` fails here.
typeTest("a function typed as Bun.write takes the file options with exactOptionalPropertyTypes", {
options: { exactOptionalPropertyTypes: true },
files: {
"write-options.ts": `
const write: typeof Bun.write = async (
destination: unknown,
input: unknown,
options?: { mode?: number; createPath?: boolean },
) => (options?.createPath === false ? 0 : 1);
write("a.txt", "x", { mode: 0o600 });
`,
},
emptyInterfaces: expectedEmptyInterfacesWhenNoDOM,
diagnostics: diagnostics => {
expect(diagnostics.filter(d => d.line?.startsWith("write-options.ts"))).toEqual([]);
},
});
});

describe("Bunland reaching for JSX", () => {
typeTest("Bun.markdown.react() returns type compatible with React.ReactElement", {
packages: ["@types/react", "@types/react-dom"],
Expand Down
112 changes: 112 additions & 0 deletions test/integration/bun-types/fixture/s3.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { s3 } from "bun";
import { expectType } from "./utilities";

async function doFileOps(file: Bun.S3File) {
console.log(file.bucket);
Expand Down Expand Up @@ -29,3 +30,114 @@ doFileOps(
type: "application/octet-stream",
}),
);

// The input of the first signature of Bun.write(), the file write. TypeScript fills the `infer`
// slots from the last signature backwards and a spare slot repeats the first one, so this reads
// the first signature for up to ten of them.
type FileWriteInput = typeof Bun.write extends {
(...args: infer A0): any;
(...args: infer A1): any;
(...args: infer A2): any;
(...args: infer A3): any;
(...args: infer A4): any;
(...args: infer A5): any;
(...args: infer A6): any;
(...args: infer A7): any;
(...args: infer A8): any;
(...args: infer A9): any;
}
? A0[1]
: never;

async function doWrites(
client: Bun.S3Client,
s3file: Bun.S3File,
bunfile: Bun.BunFile,
either: Bun.BunFile | Bun.S3File,
options: Bun.S3Options,
response: Response,
request: Request,
stream: ReadableStream,
fileInput: FileWriteInput,
) {
// Bun.write() to an S3File takes S3 options, with each kind of input.
expectType(Bun.write(s3file, "x", { contentDisposition: "inline", acl: "private" })).is<Promise<number>>();
expectType(Bun.write(s3file, bunfile, { partSize: 5 * 1024 * 1024, queueSize: 2 })).is<Promise<number>>();
await Bun.write(s3file, "x", options);
await Bun.write(s3file, response);
await Bun.write(s3file, response, { storageClass: "STANDARD_IA" });
await Bun.write(s3file, request);
await Bun.write(s3file, request, { requestPayer: true });
await Bun.write(s3file, new Uint8Array(1), { acl: "private" });
await Bun.write(s3file, new DataView(new ArrayBuffer(1)), { acl: "private" });
await Bun.write(s3file, new ArrayBuffer(1), { acl: "private" });
await Bun.write(s3file, new Blob(["x"]), { acl: "private" });
await Bun.write(s3file, s3file, { acl: "private" });
await Bun.write(s3file, ["x", new Uint8Array(1)], { acl: "private" });
await Bun.write(s3file, new Bun.Archive({ "a.txt": "a" }), { retry: 1 });
await Bun.write(s3file, stream, { queueSize: 2 });
await Bun.write(s3file.slice(0, 1), "x", { bucket: "other" });
// Each input of a file write is an input of an S3File write with S3 options.
await Bun.write(s3file, fileInput, { acl: "private" });

// The file options still compile for an S3File, alone and beside S3 options.
await Bun.write(s3file, "x", { mode: 0o600, createPath: false });
await Bun.write(s3file, "x", { mode: 0o600, acl: "private" });
await Bun.write(s3file, "x", {});
await Bun.write(s3file, "x", undefined);

// An S3File destination still checks its options and its data.
// @ts-expect-error - not an ACL: the runtime throws ERR_INVALID_ARG_TYPE
await Bun.write(s3file, "x", { acl: "publik" });
// @ts-expect-error - not an option of an S3 write or of a file write
await Bun.write(s3file, "x", { bogus: true });
// @ts-expect-error - the runtime does not apply a type of the call: the content type comes from the S3File
await Bun.write(s3file, "x", { type: "text/plain" });
// @ts-expect-error - a number is not data
await Bun.write(s3file, 123);
// @ts-expect-error - null is not data: the runtime throws ERR_INVALID_ARG_TYPE
await Bun.write(s3file, null);

// A destination whose type is not S3File takes the file options only.
await Bun.write(either, "x");
await Bun.write(either, "x", { createPath: true });
// @ts-expect-error - S3 options need a destination whose type is S3File
await Bun.write("a.txt", "x", { acl: "private" });

// A function typed as Bun.write, for example a mock, can take its options as the file options:
// the options of the S3File signature extend them.
const inferred: typeof Bun.write = async (destination, input, options) => (options?.createPath ? 1 : 0);
const declared: typeof Bun.write = async (
destination: unknown,
input: unknown,
options?: { createPath?: boolean },
) => (options?.createPath ? 1 : 0);

// Each S3 write method takes a stream.
await s3file.write(stream);
await client.write("key", stream);
await Bun.S3Client.write("key", stream, options);
// An array of parts is not declared on them: the runtime drops a part that is a BunFile or an S3File.
// @ts-expect-error
await s3file.write(["x", bunfile]);
// @ts-expect-error
await client.write("key", ["x", bunfile]);

return [inferred, declared];
}

doWrites(
s3,
s3.file("key"),
Bun.file("file"),
Bun.file("file"),
{ acl: "private" },
new Response("x"),
new Request("https://example.com", { method: "POST", body: "x" }),
new ReadableStream(),
"x",
);

// The three S3 write methods take one data type.
expectType<Parameters<Bun.S3Client["write"]>[1]>().is<Parameters<Bun.S3File["write"]>[0]>();
expectType<Parameters<typeof Bun.S3Client.write>[1]>().is<Parameters<Bun.S3File["write"]>[0]>();
87 changes: 84 additions & 3 deletions test/js/bun/s3/s3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,6 +1515,88 @@ describe("s3-server", () => {
});
});

// What the JSDoc of the S3File signature of Bun.write() in bun-types says about `options`.
describe.concurrent("Bun.write(S3File, input, options)", () => {
const client = new S3Client(localCredentials);
const MiB = 1024 * 1024;

it("applies an option of the call to that write only", async () => {
const key = `${randomUUIDv7()}.txt`;
const url = `${localServer.url}/${localCredentials.bucket}/${key}`;
const file = client.file(key, { acl: "private" });

expect(await Bun.write(file, "first", { acl: "public-read", contentDisposition: "attachment" })).toBe(5);
const first = await fetch(url);
expect({
status: first.status,
disposition: first.headers.get("content-disposition"),
body: await first.text(),
}).toEqual({ status: 200, disposition: "attachment", body: "first" });

// The file keeps its own options: the next write is private again and has no disposition.
expect(await Bun.write(file, "second")).toBe(6);
const denied = await fetch(url);
await denied.text();
const second = await fetch(file.presign());
expect({
anonymous: denied.status,
status: second.status,
disposition: second.headers.get("content-disposition"),
body: await second.text(),
}).toEqual({ anonymous: 403, status: 200, disposition: null, body: "second" });
});

it("does not apply a type of the call", async () => {
// The signature takes no `type` for this reason, so the type comes in through a variable.
const options: S3Options = { type: "text/x-call" };
const typed = client.file(randomUUIDv7(), { type: "text/x-file" });
await Bun.write(typed, "x", options);
const byExtension = client.file(`${randomUUIDv7()}.json`);
await Bun.write(byExtension, "{}", options);
// S3File.write() does apply it.
const byMethod = client.file(randomUUIDv7());
await byMethod.write("x", { type: "text/x-call" });

expect({
typed: (await typed.stat()).type,
byExtension: (await byExtension.stat()).type,
byMethod: (await byMethod.stat()).type,
}).toEqual({
typed: "text/x-file",
byExtension: "application/json;charset=utf-8",
byMethod: "text/x-call",
});
});

it("applies partSize of the call to a stream input, not to a BunFile input", async () => {
using dir = tempDir("s3-write-part-size", {});
// One byte more than the default part size of 5 MiB: two parts, or one with a part size of 6 MiB.
const bytes = Buffer.alloc(5 * MiB + 1, "a");
const input = path.join(String(dir), "input.bin");
await Bun.write(input, bytes);

const fromStream = client.file(randomUUIDv7());
await Bun.write(fromStream, new Blob([bytes]).stream(), { partSize: 6 * MiB });
const fromFile = client.file(randomUUIDv7());
await Bun.write(fromFile, Bun.file(input), { partSize: 6 * MiB });
const ofDestination = client.file(randomUUIDv7(), { partSize: 6 * MiB });
await Bun.write(ofDestination, Bun.file(input));

// The ETag of one PUT is an MD5. The ETag of a multipart upload ends with the number of its parts.
const onePut = expect.stringMatching(/^"[0-9a-f]{32}"$/);
const twoParts = expect.stringMatching(/^"[0-9a-f]{32}-2"$/);
expect({
fromStream: (await fromStream.stat()).etag,
fromFile: (await fromFile.stat()).etag,
ofDestination: (await ofDestination.stat()).etag,
}).toEqual({
fromStream: onePut,
fromFile: twoParts,
ofDestination: onePut,
});
});
});
Comment thread
robobun marked this conversation as resolved.

describe.concurrent("s3 missing credentials", () => {
async function assertMissingCredentials(fn: () => Promise<any>) {
try {
Expand Down Expand Up @@ -2102,9 +2184,8 @@ describe("s3 upload stream body error", () => {
virtualHostedStyle: false,
});
const upstream = await fetch(\`http://127.0.0.1:\${server.port}/big\`);
// S3Client.write() does not accept a bare ReadableStream; passing the Response
// routes BodyValue::Locked -> upload_stream(), which matches the native
// ByteStream -> NetworkSink fast-path this test exercises.
// Passing the Response routes BodyValue::Locked -> upload_stream(), which is
// the native ByteStream -> NetworkSink fast-path this test exercises.
await client.write("obj", upstream);
server.stop(true);
console.log(JSON.stringify({ received, totalBytes }));
Expand Down
4 changes: 2 additions & 2 deletions test/js/web/fetch/fetch-backpressure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1698,7 +1698,7 @@ describe.concurrent("S3 receive backpressure", () => {
// partSize 5 MiB × queueSize 1: with the first part held, the sink fills and the origin has
// to stop long before its 1 GiB is out.
const written = Bun.write(
bucket.s3.file("up", { partSize: 5 * 1024 * 1024, queueSize: 1 }) as any,
bucket.s3.file("up", { partSize: 5 * 1024 * 1024, queueSize: 1 }),
await fetch(origin.url, { signal: abort.signal }),
);
await bucket.firstPart;
Expand All @@ -1720,7 +1720,7 @@ describe.concurrent("S3 receive backpressure", () => {
test("Bun.write(s3file, res) resolves with the byte count", async () => {
await using origin = await serve("h1");
await using bucket = await fakeUploadBucket();
expect(await Bun.write(bucket.s3.file("up") as any, await fetch(origin.url))).toBe(TOTAL);
expect(await Bun.write(bucket.s3.file("up"), await fetch(origin.url))).toBe(TOTAL);
expect({ uploaded: bucket.uploaded(), foreign: bucket.foreign(), completed: bucket.completed() }).toEqual({
uploaded: TOTAL,
foreign: 0,
Expand Down
Loading