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}