/** * @mandujs/core/storage/s3 * * Thin, S3-compatible object storage helper backed by **native `Bun.S3Client`** * (no AWS SDK, no external deps). The same client works against AWS S3, * Cloudflare R2, MinIO, DigitalOcean Spaces, and any other S3-compatible * service — the only thing that changes is the `endpoint` URL. * * ## Credentials * * Bun reads `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (and optionally * `AWS_SESSION_TOKEN`) — plus the `S3_*` variants — from the environment at * initialization time. You can also pass them explicitly to the underlying * `Bun.S3Client`, but this helper intentionally does **not** surface that * option: routing secrets through env vars (or a `.env` file loaded by Bun) * keeps credentials out of application code. See * https://bun.com/docs/runtime/s3#credentials for the full matrix. * * ## Endpoint * * Pass `endpoint` on the client config for R2/MinIO/GCS/Supabase/etc. Omit it * for AWS (Bun infers the endpoint from `region` + `bucket`). * * @example * ```ts * import { createS3Client } from "@mandujs/core/storage/s3"; * * const storage = createS3Client({ * bucket: "uploads", * endpoint: "https://.r2.cloudflarestorage.com", * region: "auto", * }); * * const url = await storage.upload(file, { key: `u/${id}.png` }); * const presigned = await storage.presign({ key: "u/next.png", method: "PUT", contentType: "image/png" }); * ``` * * @module storage/s3 */ // ─── Public types ─────────────────────────────────────────────────────────── /** * S3 client configuration. Credentials are **not** part of this interface — * they come from `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` env vars that * Bun auto-reads. See module docs. */ export interface S3Config { /** Bucket name. Required. */ bucket: string; /** * Custom endpoint URL. Required for R2/MinIO/GCS/etc.; omit for AWS (Bun * infers from `region`). */ endpoint?: string; /** * Force path-style URLs (`endpoint/bucket/key`) instead of virtual-hosted * (`bucket.endpoint/key`). MinIO usually needs this set to `true`. AWS and * R2 work with either but default to virtual-hosted. Default: `false`. */ forcePathStyle?: boolean; /** * Region. Bun defaults to `us-east-1` for AWS; set `"auto"` for Cloudflare * R2. No default applied here — we pass through as-is so Bun's own * defaulting logic stays authoritative. */ region?: string; } /** Options for uploading a single object. */ export interface S3UploadOptions { /** Target object key (path inside the bucket). Required. */ key: string; /** * Content-Type header. Inferred from the key's file extension when omitted * — see `getContentType` below for the covered extensions. */ contentType?: string; /** * User metadata. Keys are lowercased and prefixed with `x-amz-meta-` by S3. * Values must be ASCII — non-ASCII characters will be rejected by most * providers; we do not validate here. */ metadata?: Record; /** * Canned ACL. Applied to this upload only. Omit to inherit the bucket * default (recommended for private buckets). */ acl?: "private" | "public-read"; } /** Options for generating a presigned URL. */ export interface S3PresignOptions { /** Object key. Required. */ key: string; /** HTTP method. Default: `"PUT"` (client-direct upload is the common case). */ method?: "GET" | "PUT"; /** URL lifetime in **seconds**. Default: 900 (15 minutes). */ expiresIn?: number; /** * `Content-Type` baked into the signature. Only meaningful for `PUT`; the * uploading client MUST send a matching header or the signature will fail. */ contentType?: string; } /** Opaque client instance. */ export interface S3Client { /** * Uploads a blob/buffer and resolves to the canonical object URL * (endpoint + bucket + key). */ upload( body: Blob | ArrayBuffer | Uint8Array, options: S3UploadOptions, ): Promise; /** Generates a presigned URL for client-direct upload (PUT) or download (GET). */ presign(options: S3PresignOptions): Promise; /** Deletes an object. Resolves silently if the object does not exist. */ delete(key: string): Promise; /** Returns a readable stream for the object body. Throws if not found. */ getReadable(key: string): Promise; /** * Head check — `true` if the object exists, `false` on 404. Any other error * (network failure, 403, malformed response) is re-thrown. */ exists(key: string): Promise; } // ─── Bun runtime surface (structural; no `any`) ───────────────────────────── /** Options accepted by `S3File.write`. Mirrors Bun's BlobPropertyBag extensions. */ interface BunS3WriteOptions { type?: string; acl?: "private" | "public-read"; } /** Options accepted by `S3File.presign`. */ interface BunS3PresignOptions { method?: "GET" | "PUT"; expiresIn?: number; type?: string; } /** Minimal shape of the `S3File` handle returned by `client.file(key)`. */ interface BunS3File { write( body: Blob | ArrayBuffer | Uint8Array, options?: BunS3WriteOptions, ): Promise; presign(options?: BunS3PresignOptions): string; delete(): Promise; exists(): Promise; stream(): ReadableStream; } /** Options accepted by the `Bun.S3Client` constructor. */ interface BunS3ClientConfig { bucket: string; endpoint?: string; region?: string; virtualHostedStyle?: boolean; } /** Minimal `Bun.S3Client` instance shape used by this module. */ export interface BunS3ClientInstance { file(key: string): BunS3File; } /** * Constructor surface — `Bun.S3Client` is a class. We only need the `new` * signature structurally, so we type it as a callable returning an instance. */ export type BunS3ClientCtor = new (config: BunS3ClientConfig) => BunS3ClientInstance; // ─── Content-type inference ───────────────────────────────────────────────── const EXTENSION_MAP: ReadonlyMap = new Map([ ["jpg", "image/jpeg"], ["jpeg", "image/jpeg"], ["png", "image/png"], ["webp", "image/webp"], ["gif", "image/gif"], ["pdf", "application/pdf"], ["txt", "text/plain"], ["json", "application/json"], ["csv", "text/csv"], ["zip", "application/zip"], ["mp4", "video/mp4"], ["webm", "video/webm"], ]); /** * Maps a key's trailing file extension to a MIME type. Returns * `application/octet-stream` for unknown/missing extensions. * * Exported for tests and for callers that want to inspect the mapping without * uploading (e.g., for building a `Content-Type` header on a presigned PUT). */ export function getContentType(key: string): string { const lastDot = key.lastIndexOf("."); if (lastDot === -1 || lastDot === key.length - 1) { return "application/octet-stream"; } const ext = key.slice(lastDot + 1).toLowerCase(); return EXTENSION_MAP.get(ext) ?? "application/octet-stream"; } // ─── URL construction ─────────────────────────────────────────────────────── /** * Builds the canonical object URL that `upload()` returns. Uses the same * path-vs-virtual-host logic Bun itself applies, so the URL matches what * `fetch()` would resolve. * * For AWS (no endpoint set), we return an `s3:///` URI — the * caller may not know the region and we don't want to guess at a public URL. */ function buildObjectUrl( config: S3Config, key: string, ): string { const encodedKey = encodeKey(key); if (!config.endpoint) { // Bun infers the AWS endpoint; returning s3:// keeps this provider-agnostic // without pretending to know if the bucket is public-read. return `s3://${config.bucket}/${encodedKey}`; } const base = config.endpoint.replace(/\/+$/, ""); if (config.forcePathStyle) { return `${base}/${config.bucket}/${encodedKey}`; } // Virtual-hosted style: splice bucket into the hostname. try { const url = new URL(base); url.hostname = `${config.bucket}.${url.hostname}`; return `${url.toString().replace(/\/+$/, "")}/${encodedKey}`; } catch { // Malformed endpoint — fall back to path style so we still return a // deterministic, debuggable URL. return `${base}/${config.bucket}/${encodedKey}`; } } /** URL-encodes every path segment of an S3 key while preserving `/` separators. */ function encodeKey(key: string): string { return key .split("/") .map((segment) => encodeURIComponent(segment)) .join("/"); } // ─── Bun runtime probe ────────────────────────────────────────────────────── function getBunS3ClientCtor(): BunS3ClientCtor { const g = globalThis as unknown as { Bun?: { S3Client?: BunS3ClientCtor } }; if (!g.Bun || !g.Bun.S3Client) { throw new Error( "[@mandujs/core/storage/s3] Bun.S3Client is unavailable — this module requires the Bun runtime (>= 1.3).", ); } return g.Bun.S3Client; } // ─── Factory ──────────────────────────────────────────────────────────────── /** * Internal factory accepting an injectable `S3Client` constructor — used by * unit tests to swap in a fake implementation. Production callers use * {@link createS3Client}, which binds this to `Bun.S3Client`. */ export function _createS3ClientWith( Ctor: BunS3ClientCtor, config: S3Config, ): S3Client { if (!config.bucket || typeof config.bucket !== "string") { throw new TypeError( "[@mandujs/core/storage/s3] createS3Client: 'bucket' is required.", ); } const bunClient = new Ctor({ bucket: config.bucket, endpoint: config.endpoint, region: config.region, // forcePathStyle === true ⇒ virtualHostedStyle: false (the Bun default). // forcePathStyle === false ⇒ leave Bun's default untouched. // Only set virtualHostedStyle explicitly if the user *opts out* of path // style for a service that normally defaults to it. The map here is 1:1. virtualHostedStyle: config.forcePathStyle === true ? false : undefined, }); async function upload( body: Blob | ArrayBuffer | Uint8Array, options: S3UploadOptions, ): Promise { if (!options.key) { throw new TypeError( "[@mandujs/core/storage/s3] upload: 'key' is required.", ); } const file = bunClient.file(options.key); const contentType = options.contentType ?? getContentType(options.key); await file.write(body, { type: contentType, ...(options.acl !== undefined ? { acl: options.acl } : {}), }); // NOTE: metadata is accepted in our public type for forward-compat, but // Bun 1.3's `S3File.write` does not surface a `metadata` option. Dropping // it silently would be a bug waiting to happen, so we throw at call time. if (options.metadata && Object.keys(options.metadata).length > 0) { throw new Error( "[@mandujs/core/storage/s3] upload: 'metadata' is not yet supported by Bun.S3Client.write. Use a presigned URL with custom headers, or file an issue upstream.", ); } return buildObjectUrl(config, options.key); } async function presign(options: S3PresignOptions): Promise { if (!options.key) { throw new TypeError( "[@mandujs/core/storage/s3] presign: 'key' is required.", ); } const method = options.method ?? "PUT"; const expiresIn = options.expiresIn ?? 900; const file = bunClient.file(options.key); // Bun.S3File.presign is synchronous; we wrap in a Promise so the public // API stays consistent with the rest of the helper surface. const url = file.presign({ method, expiresIn, ...(options.contentType !== undefined ? { type: options.contentType } : {}), }); return url; } async function deleteObject(key: string): Promise { const file = bunClient.file(key); try { await file.delete(); } catch (err) { // S3 returns success for delete-of-missing-key; but some S3-compatible // providers (and network hiccups) can surface a 404-shaped error. We // swallow "not found" and re-throw everything else. if (isNotFoundError(err)) return; throw err; } } async function getReadable(key: string): Promise { const file = bunClient.file(key); // `file.stream()` is synchronous and returns a ReadableStream that lazily // issues the GET when you start consuming. We preserve that laziness and // let errors surface on first read rather than proactively HEAD-ing here. return file.stream(); } async function exists(key: string): Promise { const file = bunClient.file(key); try { return await file.exists(); } catch (err) { if (isNotFoundError(err)) return false; throw err; } } return { upload, presign, delete: deleteObject, getReadable, exists, }; } /** * Creates an S3-compatible storage client. * * @throws if `config.bucket` is missing, or if called outside the Bun runtime. */ export function createS3Client(config: S3Config): S3Client { return _createS3ClientWith(getBunS3ClientCtor(), config); } // ─── Error helpers ────────────────────────────────────────────────────────── /** * Structural check for "object not found" errors raised by Bun's S3 layer. * Bun emits an `S3Error` instance with `code` set; we also handle the * HTTP-status-bearing variants some providers surface. */ function isNotFoundError(err: unknown): boolean { if (!err || typeof err !== "object") return false; const e = err as { name?: unknown; code?: unknown; status?: unknown }; if (e.code === "NoSuchKey") return true; if (e.code === "NotFound") return true; if (e.status === 404) return true; return false; }