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