s3.ts
3.5 kB · typescript · 118 lines
1import { S3Client } from "bun";23/* WHERE */45export const REGION = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-2";67/* CREDENTIALS */89export type Creds = {10 accessKeyId: string;11 secretAccessKey: string;12 sessionToken?: string;13 region: string;14};1516let held: Creds | null = null;1718export function credentials(): Creds {19 if (held) return held;20 const id = process.env.AWS_ACCESS_KEY_ID;21 const secret = process.env.AWS_SECRET_ACCESS_KEY;22 if (id && secret) {23 held = { accessKeyId: id, secretAccessKey: secret, sessionToken: process.env.AWS_SESSION_TOKEN, region: REGION };24 return held;25 }26 const run = Bun.spawnSync(["aws", "configure", "export-credentials", "--format", "process"]);27 if (run.exitCode !== 0) throw new Error("s3: no AWS_* env credentials and aws configure export-credentials failed");28 const data = JSON.parse(run.stdout.toString()) as { AccessKeyId: string; SecretAccessKey: string; SessionToken?: string };29 held = {30 accessKeyId: data.AccessKeyId,31 secretAccessKey: data.SecretAccessKey,32 sessionToken: data.SessionToken,33 region: REGION,34 };35 return held;36}3738/* CLIENT */3940export function client(bucket: string): S3Client {41 return new S3Client({ bucket, ...credentials() });42}4344/* READ */4546const gone = (error: unknown) => {47 const it = error as { code?: string; name?: string };48 return it?.code === "NoSuchKey" || it?.code === "ERR_S3_FILE_NOT_FOUND" || it?.name === "NoSuchKey";49};5051export async function getText(s3: S3Client, key: string): Promise<string | null> {52 try {53 return await s3.file(key).text();54 } catch (error) {55 if (gone(error)) return null;56 throw error;57 }58}5960/* WRITE */6162export async function putBytes(63 s3: S3Client,64 key: string,65 body: Uint8Array | string,66 opts: { type: string; cacheControl?: string },67): Promise<void> {68 const url = s3.presign(key, { method: "PUT", expiresIn: 900, type: opts.type });69 const headers: Record<string, string> = { "Content-Type": opts.type };70 if (opts.cacheControl) headers["Cache-Control"] = opts.cacheControl;71 await retry(async () => {72 const res = await fetch(url, { method: "PUT", body, headers });73 if (!res.ok) throw new Error(`s3: put ${key} failed ${res.status} ${(await res.text()).slice(0, 200)}`);74 });75}7677/* RETRY */7879export async function retry<T>(work: () => Promise<T>, tries = 4): Promise<T> {80 let wait = 500;81 for (let n = 1; ; n++) {82 try {83 return await work();84 } catch (error) {85 if (n >= tries) throw error;86 await Bun.sleep(wait);87 wait *= 3;88 }89 }90}9192/* DELETE */9394export async function del(s3: S3Client, keys: string[], batch = 16): Promise<number> {95 for (let i = 0; i < keys.length; i += batch) {96 await Promise.all(keys.slice(i, i + batch).map((key) => retry(() => drop(s3, key))));97 }98 return keys.length;99}100101async function drop(s3: S3Client, key: string): Promise<void> {102 const url = s3.presign(key, { method: "DELETE", expiresIn: 900 });103 const res = await fetch(url, { method: "DELETE" });104 if (!res.ok && res.status !== 404) throw new Error(`s3: delete ${key} failed ${res.status} ${(await res.text()).slice(0, 200)}`);105}106107/* LIST */108109export async function list(s3: S3Client, prefix = ""): Promise<string[]> {110 const keys: string[] = [];111 let token: string | undefined;112 do {113 const page = await s3.list({ prefix, maxKeys: 1000, continuationToken: token });114 for (const item of page?.contents ?? []) keys.push(item.key);115 token = page?.isTruncated ? (page.nextContinuationToken ?? undefined) : undefined;116 } while (token);117 return keys;118}