reader.ts

1.9 KB · raw

1import { readdir } from "node:fs/promises";2import { join, relative } from "node:path";3import type { BucketSource } from "../server/config.ts";45/** Read-only view of a Koh 3 remote (§9.1): Kepr needs only these two of its five operations. */6export type BucketReader = {7	get(key: string): Promise<Uint8Array | null>;8	list(prefix: string): Promise<string[]>;9};1011export function openBucket(source: BucketSource): BucketReader {12	return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);13}1415function s3Reader(source: Extract<BucketSource, { kind: "s3" }>): BucketReader {16	const client = new Bun.S3Client({17		endpoint: source.endpoint,18		bucket: source.name,19		region: source.region,20		accessKeyId: source.accessKeyId,21		secretAccessKey: source.secretAccessKey,22	});23	return {24		async get(key) {25			try {26				return await client.file(key).bytes();27			} catch (error) {28				if ((error as { code?: string }).code === "NoSuchKey") return null;29				throw error;30			}31		},32		async list(prefix) {33			const keys: string[] = [];34			let continuationToken: string | undefined;35			do {36				const page = await client.list({ prefix, continuationToken });37				for (const object of page.contents ?? []) keys.push(object.key);38				continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;39			} while (continuationToken);40			return keys;41		},42	};43}4445function folderReader(root: string): BucketReader {46	return {47		async get(key) {48			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);49			const file = Bun.file(join(root, key));50			return (await file.exists()) ? await file.bytes() : null;51		},52		async list(prefix) {53			const entries = await readdir(root, { recursive: true, withFileTypes: true });54			return entries55				.filter((entry) => entry.isFile())56				.map((entry) => relative(root, join(entry.parentPath, entry.name)))57				.filter((key) => key.startsWith(prefix))58				.sort();59		},60	};61}