s3.ts

3.7 kB · typescript · 121 lines

1import { S3Client } from "bun";23/* WHERE */45export const REGION = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-2";6export const NET_BUCKET = process.env.MRLYNET_BUCKET || "mrlynet";7export const DEV_BUCKET = process.env.MRLYDEV_BUCKET || "mrlydev";8export const PROD_BUCKET = process.env.MRLYPROD_BUCKET || "mrlyprod";910/* CREDENTIALS */1112export type Creds = {13  accessKeyId: string;14  secretAccessKey: string;15  sessionToken?: string;16  region: string;17};1819let held: Creds | null = null;2021export function credentials(): Creds {22  if (held) return held;23  const id = process.env.AWS_ACCESS_KEY_ID;24  const secret = process.env.AWS_SECRET_ACCESS_KEY;25  if (id && secret) {26    held = { accessKeyId: id, secretAccessKey: secret, sessionToken: process.env.AWS_SESSION_TOKEN, region: REGION };27    return held;28  }29  const run = Bun.spawnSync(["aws", "configure", "export-credentials", "--format", "process"]);30  if (run.exitCode !== 0) throw new Error("s3: no AWS_* env credentials and aws configure export-credentials failed");31  const data = JSON.parse(run.stdout.toString()) as { AccessKeyId: string; SecretAccessKey: string; SessionToken?: string };32  held = {33    accessKeyId: data.AccessKeyId,34    secretAccessKey: data.SecretAccessKey,35    sessionToken: data.SessionToken,36    region: REGION,37  };38  return held;39}4041/* CLIENT */4243export function client(bucket: string): S3Client {44  return new S3Client({ bucket, ...credentials() });45}4647/* READ */4849const gone = (error: unknown) => {50  const it = error as { code?: string; name?: string };51  return it?.code === "NoSuchKey" || it?.code === "ERR_S3_FILE_NOT_FOUND" || it?.name === "NoSuchKey";52};5354export async function getText(s3: S3Client, key: string): Promise<string | null> {55  try {56    return await s3.file(key).text();57  } catch (error) {58    if (gone(error)) return null;59    throw error;60  }61}6263/* WRITE */6465export async function putBytes(66  s3: S3Client,67  key: string,68  body: Uint8Array | string,69  opts: { type: string; cacheControl?: string },70): Promise<void> {71  const url = s3.presign(key, { method: "PUT", expiresIn: 900, type: opts.type });72  const headers: Record<string, string> = { "Content-Type": opts.type };73  if (opts.cacheControl) headers["Cache-Control"] = opts.cacheControl;74  await retry(async () => {75    const res = await fetch(url, { method: "PUT", body, headers });76    if (!res.ok) throw new Error(`s3: put ${key} failed ${res.status} ${(await res.text()).slice(0, 200)}`);77  });78}7980/* RETRY */8182export async function retry<T>(work: () => Promise<T>, tries = 4): Promise<T> {83  let wait = 500;84  for (let n = 1; ; n++) {85    try {86      return await work();87    } catch (error) {88      if (n >= tries) throw error;89      await Bun.sleep(wait);90      wait *= 3;91    }92  }93}9495/* DELETE */9697export async function del(s3: S3Client, keys: string[], batch = 16): Promise<number> {98  for (let i = 0; i < keys.length; i += batch) {99    await Promise.all(keys.slice(i, i + batch).map((key) => retry(() => drop(s3, key))));100  }101  return keys.length;102}103104async function drop(s3: S3Client, key: string): Promise<void> {105  const url = s3.presign(key, { method: "DELETE", expiresIn: 900 });106  const res = await fetch(url, { method: "DELETE" });107  if (!res.ok && res.status !== 404) throw new Error(`s3: delete ${key} failed ${res.status} ${(await res.text()).slice(0, 200)}`);108}109110/* LIST */111112export async function list(s3: S3Client, prefix = ""): Promise<string[]> {113  const keys: string[] = [];114  let token: string | undefined;115  do {116    const page = await s3.list({ prefix, maxKeys: 1000, continuationToken: token });117    for (const item of page?.contents ?? []) keys.push(item.key);118    token = page?.isTruncated ? (page.nextContinuationToken ?? undefined) : undefined;119  } while (token);120  return keys;121}