s3.ts
3.7 kB · typescript · 126 lines
1import { S3Client } from "bun";23/* ENV */45export function need(key: string): string {6 const value = process.env[key];7 if (!value) throw new Error(`${key} is not set`);8 return value;9}1011/* WHERE */1213const REGION = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-2";1415/* CREDENTIALS */1617type Creds = {18 accessKeyId: string;19 secretAccessKey: string;20 sessionToken?: string;21 region: string;22};2324let held: Creds | null = null;2526function credentials(): Creds {27 if (held) return held;28 const id = process.env.AWS_ACCESS_KEY_ID;29 const secret = process.env.AWS_SECRET_ACCESS_KEY;30 if (id && secret) {31 held = { accessKeyId: id, secretAccessKey: secret, sessionToken: process.env.AWS_SESSION_TOKEN, region: REGION };32 return held;33 }34 const run = Bun.spawnSync(["aws", "configure", "export-credentials", "--format", "process"]);35 if (run.exitCode !== 0) throw new Error("s3: no AWS_* env credentials and aws configure export-credentials failed");36 const data = JSON.parse(run.stdout.toString()) as { AccessKeyId: string; SecretAccessKey: string; SessionToken?: string };37 held = {38 accessKeyId: data.AccessKeyId,39 secretAccessKey: data.SecretAccessKey,40 sessionToken: data.SessionToken,41 region: REGION,42 };43 return held;44}4546/* CLIENT */4748export function client(bucket: string): S3Client {49 return new S3Client({ bucket, ...credentials() });50}5152/* READ */5354const gone = (error: unknown) => {55 const it = error as { code?: string; name?: string };56 return it?.code === "NoSuchKey" || it?.code === "ERR_S3_FILE_NOT_FOUND" || it?.name === "NoSuchKey";57};5859export async function getText(s3: S3Client, key: string): Promise<string | null> {60 try {61 return await s3.file(key).text();62 } catch (error) {63 if (gone(error)) return null;64 throw error;65 }66}6768/* WRITE */6970export async function putBytes(71 s3: S3Client,72 key: string,73 body: Uint8Array | string,74 opts: { type: string; cacheControl?: string },75): Promise<void> {76 const url = s3.presign(key, { method: "PUT", expiresIn: 900, type: opts.type });77 const headers: Record<string, string> = { "Content-Type": opts.type };78 if (opts.cacheControl) headers["Cache-Control"] = opts.cacheControl;79 await retry(async () => {80 const res = await fetch(url, { method: "PUT", body, headers });81 if (!res.ok) throw new Error(`s3: put ${key} failed ${res.status} ${(await res.text()).slice(0, 200)}`);82 });83}8485/* RETRY */8687async function retry<T>(work: () => Promise<T>, tries = 4): Promise<T> {88 let wait = 500;89 for (let n = 1; ; n++) {90 try {91 return await work();92 } catch (error) {93 if (n >= tries) throw error;94 await Bun.sleep(wait);95 wait *= 3;96 }97 }98}99100/* DELETE */101102export async function del(s3: S3Client, keys: string[], batch = 16): Promise<number> {103 for (let i = 0; i < keys.length; i += batch) {104 await Promise.all(keys.slice(i, i + batch).map((key) => retry(() => drop(s3, key))));105 }106 return keys.length;107}108109async function drop(s3: S3Client, key: string): Promise<void> {110 const url = s3.presign(key, { method: "DELETE", expiresIn: 900 });111 const res = await fetch(url, { method: "DELETE" });112 if (!res.ok && res.status !== 404) throw new Error(`s3: delete ${key} failed ${res.status} ${(await res.text()).slice(0, 200)}`);113}114115/* LIST */116117export async function list(s3: S3Client, prefix = ""): Promise<string[]> {118 const keys: string[] = [];119 let token: string | undefined;120 do {121 const page = await s3.list({ prefix, maxKeys: 1000, continuationToken: token });122 for (const item of page?.contents ?? []) keys.push(item.key);123 token = page?.isTruncated ? (page.nextContinuationToken ?? undefined) : undefined;124 } while (token);125 return keys;126}