Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/aws

src/s3.ts

249 lines · 6.3 KB · TypeScript
import { awsSignedFetch } from './client.ts'
import { mutationPreview } from './helpers.ts'
import {
	assertBucket,
	assertObjectKey,
	boundedPageSize,
	inputRecord,
	optionalString,
	xmlTag,
	xmlTags,
	type AwsAuthOptions,
} from './validation.ts'

export type S3ObjectMetadata = {
	key: string
	size: number | null
	lastModified: string | null
	etag: string | null
	storageClass: string | null
}

function header(headers: Record<string, string>, name: string): string | null {
	const found = Object.entries(headers).find(
		([key]) => key.toLowerCase() === name.toLowerCase(),
	)
	return found?.[1] ?? null
}

/**
 * List buckets visible to the caller. Names only — no account-specific defaults.
 *
 * @example
 * import { listBuckets } from 'kody:@kody/aws/s3'
 * const { items } = await listBuckets()
 */
export async function listBuckets(params: AwsAuthOptions = {}) {
	const result = await awsSignedFetch({
		...params,
		service: 's3',
		method: 'GET',
		path: '/',
	})
	const names = xmlTags(result.text, 'Name')
	return {
		ok: true,
		region: result.region,
		items: names.map((name) => ({ name })),
	}
}

/**
 * List object keys and sizes in a bucket. Does not download object bodies.
 *
 * @example
 * import { listObjects } from 'kody:@kody/aws/s3'
 * const { items } = await listObjects({ bucket: 'example-bucket', prefix: 'logs/' })
 */
export async function listObjects(
	params: AwsAuthOptions & {
		bucket: string
		prefix?: string
		pageSize?: number
		continuationToken?: string
	},
) {
	const input = inputRecord(params)
	const bucket = assertBucket(requiredBucket(input))
	const result = await awsSignedFetch({
		...params,
		service: 's3',
		method: 'GET',
		path: '/' + encodeURIComponent(bucket),
		query: {
			'list-type': '2',
			'max-keys': String(boundedPageSize(input.pageSize, 20, 1000)),
			prefix: optionalString(input, 'prefix'),
			'continuation-token': optionalString(input, 'continuationToken'),
		},
	})

	const contents = result.text.match(/<Contents>[\s\S]*?<\/Contents>/g) ?? []
	const items: Array<S3ObjectMetadata> = contents.map((block) => ({
		key: xmlTag(block, 'Key') ?? '',
		size: xmlTag(block, 'Size') ? Number(xmlTag(block, 'Size')) : null,
		lastModified: xmlTag(block, 'LastModified'),
		etag: xmlTag(block, 'ETag'),
		storageClass: xmlTag(block, 'StorageClass'),
	}))

	return {
		ok: true,
		bucket,
		region: result.region,
		prefix: optionalString(input, 'prefix') ?? null,
		truncated: xmlTag(result.text, 'IsTruncated') === 'true',
		nextContinuationToken: xmlTag(result.text, 'NextContinuationToken'),
		items,
	}
}

/**
 * Read S3 object metadata (HEAD). Never returns the object body.
 *
 * @example
 * import { getObjectMetadata } from 'kody:@kody/aws/s3'
 * const meta = await getObjectMetadata({ bucket: 'example-bucket', key: 'readme.txt' })
 */
export async function getObjectMetadata(
	params: AwsAuthOptions & { bucket: string; key: string },
) {
	const input = inputRecord(params)
	const bucket = assertBucket(requiredBucket(input))
	const key = assertObjectKey(requiredStringLocal(input, 'key'))
	const result = await awsSignedFetch({
		...params,
		service: 's3',
		method: 'HEAD',
		path:
			'/' +
			encodeURIComponent(bucket) +
			'/' +
			key.split('/').map(encodeURIComponent).join('/'),
	})

	const contentLength = header(result.headers, 'content-length')
	return {
		ok: true,
		bucket,
		key,
		region: result.region,
		contentType: header(result.headers, 'content-type'),
		contentLength: contentLength ? Number(contentLength) : null,
		etag: header(result.headers, 'etag'),
		lastModified: header(result.headers, 'last-modified'),
		storageClass: header(result.headers, 'x-amz-storage-class'),
		versionId: header(result.headers, 'x-amz-version-id'),
	}
}

/**
 * Preview or upload an S3 object. Defaults to dry-run. Live put needs
 * `confirm: true`. Do not put secret files; this helper never echoes the body.
 */
export async function putObject(
	params: AwsAuthOptions & {
		bucket: string
		key: string
		body: string
		contentType?: string
		dryRun?: boolean
		confirm?: boolean
	},
) {
	const input = inputRecord(params)
	const bucket = assertBucket(requiredBucket(input))
	const key = assertObjectKey(requiredStringLocal(input, 'key'))
	const body = requiredStringLocal(input, 'body')
	const contentType = optionalString(input, 'contentType') ?? 'text/plain'
	const preview = mutationPreview(input, {
		method: 'PUT',
		service: 's3',
		region: optionalString(input, 'region') ?? 'us-east-1',
		host: 's3.amazonaws.com',
		path: '/' + bucket + '/' + key,
		body: { contentType, bodyLength: body.length },
	})
	if (preview) {
		return { ...preview, bucket, key, bodyLength: body.length }
	}

	const result = await awsSignedFetch({
		...params,
		service: 's3',
		method: 'PUT',
		path:
			'/' +
			encodeURIComponent(bucket) +
			'/' +
			key.split('/').map(encodeURIComponent).join('/'),
		headers: { 'content-type': contentType },
		body,
		unsignedPayload: false,
	})
	return {
		ok: true,
		bucket,
		key,
		region: result.region,
		etag: header(result.headers, 'etag'),
	}
}

/**
 * Preview or delete an S3 object. Defaults to dry-run. Live delete needs
 * `confirm: true`.
 */
export async function deleteObject(
	params: AwsAuthOptions & {
		bucket: string
		key: string
		dryRun?: boolean
		confirm?: boolean
	},
) {
	const input = inputRecord(params)
	const bucket = assertBucket(requiredBucket(input))
	const key = assertObjectKey(requiredStringLocal(input, 'key'))
	const preview = mutationPreview(input, {
		method: 'DELETE',
		service: 's3',
		region: optionalString(input, 'region') ?? 'us-east-1',
		host: 's3.amazonaws.com',
		path: '/' + bucket + '/' + key,
	})
	if (preview) return { ...preview, bucket, key }

	await awsSignedFetch({
		...params,
		service: 's3',
		method: 'DELETE',
		path:
			'/' +
			encodeURIComponent(bucket) +
			'/' +
			key.split('/').map(encodeURIComponent).join('/'),
	})
	return { ok: true, deleted: true, bucket, key }
}

function requiredBucket(input: Record<string, unknown>): string {
	const value = optionalString(input, 'bucket')
	if (!value) {
		throw new Error(
			'bucket is required. Pass the caller bucket at call time — this package has no default bucket.',
		)
	}
	return value
}

function requiredStringLocal(
	input: Record<string, unknown>,
	key: string,
): string {
	const value = optionalString(input, key)
	if (!value) throw new Error(key + ' is required.')
	return value
}

export default listBuckets