Skip to content

Conversation

@anteeek
Copy link

@anteeek anteeek commented Dec 8, 2025

About Me

superfly.tv

Type of Contribution

This is a:

Feature

Current Behavior

no S3 support

New Behavior

Added AWS S3 support

Testing Instructions

Add working S3 key credentials to expectedPackages.json, then everything

Other Information

Status

  • PR is ready to be reviewed.
  • The functionality has been tested by the author.
  • Relevant unit tests has been added / updated.
  • Relevant documentation (code comments, system documentation) has been added / updated.

@anteeek anteeek requested a review from a team as a code owner December 8, 2025 09:31
@anteeek anteeek marked this pull request as draft December 8, 2025 10:14
@anteeek anteeek force-pushed the feat/s3 branch 4 times, most recently from bcf864a to 1b65234 Compare December 8, 2025 10:38
@anteeek anteeek marked this pull request as ready for review December 8, 2025 22:35
Copy link
Member

@nytamin nytamin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

I added a few comments regarding docs and style that would be good to address before merging.

Comment on lines 599 to 600
/** S3 endpoint (obligatory for non-AWS S3 deployments) */
endpoint?: string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add docs explaining what does undefined imply

Comment on lines 602 to 603
/** If true, forces path-style URLs (required for some S3-compatible storage solutions) */
forcePathStyle?: boolean
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know any solutions that require this? if so, maybe name them in the doc?

Comment on lines 34 to 41
constructor(
private readonly bucketId: string,
private readonly region: string,
private readonly accessKey: string,
private readonly secretAccessKey: string,
private readonly endpoint?: string,
private readonly forcePathStyle: boolean = false
) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor note: To me, passing the S3Options object would be cleaner

Suggested change
constructor(
private readonly bucketId: string,
private readonly region: string,
private readonly accessKey: string,
private readonly secretAccessKey: string,
private readonly endpoint?: string,
private readonly forcePathStyle: boolean = false
) {
constructor(
private readonly options: S3Options
) {

user: 'The requested file does not exist in the S3 storage bucket',
}
: {
tech: `S3 Error: ${reason} err: ${err?.message || JSON.stringify(err)}`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the function stringifyerror(error) to stringify errors (it ensures that stack traces etc are included)

(this also applies to other places in the code)

Comment on lines 356 to 358
? fullPath.replace(/^\//, '').endsWith('/')
? fullPath.replace(/^\//, '')
: `${fullPath.replace(/^\//, '')}/`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readability: can fullPath.replace(/^\//, '') be DRY'ed up here?

async putPackageStream(sourceStream: NodeJS.ReadableStream): Promise<PutPackageHandler> {
const s3 = await this.getS3Client()

const fullPath = this.workOptions.useTemporaryFilePath ? this.temporaryFilePath : this.fullPath
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized that useTemporaryFilePath might not be needed for S3 storage.
It is used when writing to files on disk to ensure atomic operations, but since S3 operations are atomic by definition, I think we can omit any uses of useTemporaryFilePath completely.

Comment on lines 409 to 505
private async getS3Client(): Promise<S3BucketClient> {
let client = this.getS3ClientFromCache(
this.s3Options.region,
this.s3Options.accessKey,
this.s3Options.secretAccessKey,
this.s3Options.s3PublicBaseUrl,
this.s3Options.endpoint,
this.s3Options.forcePathStyle
)

if (!client) {
client = this.createNewS3Client()

this.cacheS3Client(
client,
this.s3Options.region,
this.s3Options.accessKey,
this.s3Options.secretAccessKey,
this.s3Options.s3PublicBaseUrl,
this.s3Options.endpoint,
this.s3Options.forcePathStyle
)
}

return client
}

private getS3ClientFromCache(
region: string,
accessKey: string,
secretAccessKey: string,
publicBaseUrl: string,
endpoint?: string,
forcePathStyle?: boolean
): S3BucketClient | undefined {
const accessorCache = this.worker.accessorCache as {
[accessorType: string]: S3BucketClient | undefined
}

return accessorCache[
S3AccessorHandle.getCacheKeyForS3Client(
accessKey,
secretAccessKey,
region,
publicBaseUrl,
endpoint,
forcePathStyle
)
]
}

private cacheS3Client(
client: S3BucketClient,
region: string,
accessKey: string,
secretAccessKey: string,
publicBaseUrl: string,
endpoint?: string,
forcePathStyle?: boolean
): void {
const accessorCache = this.worker.accessorCache as {
[accessorType: string]: S3BucketClient | undefined
}

accessorCache[
S3AccessorHandle.getCacheKeyForS3Client(
accessKey,
secretAccessKey,
region,
publicBaseUrl,
endpoint,
forcePathStyle
)
] = client
}

static getCacheKeyForS3Client(
accessKey: string,
secretAccessKey: string,
region: string,
publicBaseUrl: string,
endpoint?: string,
forcePathStyle?: boolean
): string {
return JSON.stringify([accessKey, secretAccessKey, region, publicBaseUrl, endpoint, forcePathStyle])
}

private createNewS3Client(): S3BucketClient {
return new S3BucketClient(
this.s3Options.bucketId,
this.s3Options.region,
this.s3Options.accessKey,
this.s3Options.secretAccessKey,
this.s3Options.endpoint,
this.s3Options.forcePathStyle
)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified using worker.cacheData().

Suggested change
private async getS3Client(): Promise<S3BucketClient> {
let client = this.getS3ClientFromCache(
this.s3Options.region,
this.s3Options.accessKey,
this.s3Options.secretAccessKey,
this.s3Options.s3PublicBaseUrl,
this.s3Options.endpoint,
this.s3Options.forcePathStyle
)
if (!client) {
client = this.createNewS3Client()
this.cacheS3Client(
client,
this.s3Options.region,
this.s3Options.accessKey,
this.s3Options.secretAccessKey,
this.s3Options.s3PublicBaseUrl,
this.s3Options.endpoint,
this.s3Options.forcePathStyle
)
}
return client
}
private getS3ClientFromCache(
region: string,
accessKey: string,
secretAccessKey: string,
publicBaseUrl: string,
endpoint?: string,
forcePathStyle?: boolean
): S3BucketClient | undefined {
const accessorCache = this.worker.accessorCache as {
[accessorType: string]: S3BucketClient | undefined
}
return accessorCache[
S3AccessorHandle.getCacheKeyForS3Client(
accessKey,
secretAccessKey,
region,
publicBaseUrl,
endpoint,
forcePathStyle
)
]
}
private cacheS3Client(
client: S3BucketClient,
region: string,
accessKey: string,
secretAccessKey: string,
publicBaseUrl: string,
endpoint?: string,
forcePathStyle?: boolean
): void {
const accessorCache = this.worker.accessorCache as {
[accessorType: string]: S3BucketClient | undefined
}
accessorCache[
S3AccessorHandle.getCacheKeyForS3Client(
accessKey,
secretAccessKey,
region,
publicBaseUrl,
endpoint,
forcePathStyle
)
] = client
}
static getCacheKeyForS3Client(
accessKey: string,
secretAccessKey: string,
region: string,
publicBaseUrl: string,
endpoint?: string,
forcePathStyle?: boolean
): string {
return JSON.stringify([accessKey, secretAccessKey, region, publicBaseUrl, endpoint, forcePathStyle])
}
private createNewS3Client(): S3BucketClient {
return new S3BucketClient(
this.s3Options.bucketId,
this.s3Options.region,
this.s3Options.accessKey,
this.s3Options.secretAccessKey,
this.s3Options.endpoint,
this.s3Options.forcePathStyle
)
}
private async getS3Client(): Promise<S3BucketClient> {
const cacheKey = JSON.stringify(this.s3Options)
return this.worker.cacheData(
this.type,
`s3Client${cacheKey}`,
() => {
return this.createNewS3Client()
},
10 * 60 * 1000
) // 10 min cache time
}
private createNewS3Client(): S3BucketClient {
return new S3BucketClient(this.s3Options)
}

@jstarpl jstarpl changed the title Add support for S3 feat: Add support for S3 Dec 10, 2025
@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
11.7% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@Julusian
Copy link
Member

As a side comment, I was hoping that it would be possible to do this without propagating the credentials through to each worker (to minimise the spread and risk of leaking them). At least any keys which can write to the bucket, instead having the workforce or something provide presigned urls for uploading.
I may well have overlooked something which means that isnt viable though, and this could be something added later by someone who is worried enough about security to do it.

Is there an opensource s3 server we should recommend for use during development? (either as a docker image, or cross platform binary)

@anteeek
Copy link
Author

anteeek commented Dec 18, 2025

@Julusian after investigation - it is of course possible to pass it through presigned URLS and it's feasible for this use case, but architecturally it seems to be a big intervention in terms of data flow direction
as for S3 server locally used for testing & development it was minio in a docker container, should I add it to README or somewhere?

@Julusian
Copy link
Member

as for S3 server locally used for testing & development it was minio in a docker container, should I add it to README or somewhere?

hmm Im hesistant to put minio as a recommendation as it is a little dead these days https://github.com/minio/minio#maintenance-mode. That said for my personal usage I havent figured out what to move to, so havent migrated away.

@anteeek anteeek merged commit 8bcaaa4 into develop Jan 6, 2026
9 of 10 checks passed
@anteeek anteeek deleted the feat/s3 branch January 6, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants