fake-s3.ts

2.9 KB · raw

1/**2 * A bucket in memory, served over HTTP for the joint public-steal test: koh's own S3 backend and3 * Bun's S3Client both talk to it, so Kepr and the two koh machines share one bucket.4 *5 * It runs as its own process because the test drives koh with `Bun.spawnSync`, which would block6 * the event loop this server needs. It prints its endpoint on the first line of stdout.7 *8 * Only what the two clients use is implemented: GET, HEAD, PUT (with `If-None-Match: *` for the9 * one conditional write that matters, `head`), DELETE, and ListObjectsV2. Signatures are ignored10 * rather than verified — the one way this is easier than a real bucket.11 */12const BUCKET = "kohbucket";13const objects = new Map<string, Uint8Array>();1415async function handle(request: Request): Promise<Response> {16	const url = new URL(request.url);17	const [, bucket = "", ...rest] = url.pathname.split("/");18	if (bucket !== BUCKET) return new Response("", { status: 404 });19	if (url.searchParams.get("list-type") === "2") {20		const prefix = url.searchParams.get("prefix") ?? "";21		const delimiter = url.searchParams.get("delimiter");22		const keys = [...objects.keys()].filter((key) => key.startsWith(prefix)).sort();23		// With a delimiter, keys sharing a folder after the prefix roll up into one CommonPrefixes.24		const folders = new Set<string>();25		const listed = keys.filter((key) => {26			const at = delimiter ? key.indexOf(delimiter, prefix.length) : -1;27			if (at >= 0) folders.add(key.slice(0, at + 1));28			return at < 0;29		});30		const contents = listed31			.map(32				(key) =>33					`<Contents><Key>${key}</Key><Size>${objects.get(key)?.length ?? 0}</Size></Contents>`,34			)35			.join("");36		const common = [...folders]37			.map((folder) => `<CommonPrefixes><Prefix>${folder}</Prefix></CommonPrefixes>`)38			.join("");39		return new Response(40			`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><IsTruncated>false</IsTruncated>${contents}${common}</ListBucketResult>`,41			{ headers: { "content-type": "application/xml" } },42		);43	}44	const key = rest.join("/");45	if (request.method === "PUT") {46		const body = new Uint8Array(await request.arrayBuffer());47		if (request.headers.get("if-none-match") === "*" && objects.has(key)) {48			return new Response("", { status: 412 });49		}50		objects.set(key, body);51		return new Response("", { status: 200, headers: { etag: `"${body.length}"` } });52	}53	if (request.method === "GET" || request.method === "HEAD") {54		const body = objects.get(key);55		if (!body) return new Response("", { status: 404 });56		return new Response(request.method === "HEAD" ? null : body, {57			status: 200,58			headers: { etag: `"${body.length}"`, "content-length": String(body.length) },59		});60	}61	if (request.method === "DELETE") {62		objects.delete(key);63		return new Response("", { status: 204 });64	}65	return new Response("", { status: 404 });66}6768const server = Bun.serve({ port: 0, fetch: handle });69console.log(`http://127.0.0.1:${server.port}`);