From 9412afb1af8a9b11019abde60954cd652a3be89f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 23 Jan 2026 18:49:34 -0500 Subject: [PATCH] Add generic S3 client loader and update tests --- js/services/storageService.js | 70 ++++++++++++++++++++- js/storage/r2-s3.js | 110 ++++++--------------------------- js/storage/s3-client.js | 112 ++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 95 deletions(-) create mode 100644 js/storage/s3-client.js diff --git a/js/services/storageService.js b/js/services/storageService.js index 967f4299..a3351653 100644 --- a/js/services/storageService.js +++ b/js/services/storageService.js @@ -34,10 +34,74 @@ export const PROVIDERS = Object.freeze({ GENERIC: "generic_s3", }); +function resolveConnectionMeta(config) { + return config?.meta || {}; +} + +function resolveBucket(config) { + return config.bucket || resolveConnectionMeta(config).bucket; +} + +function resolveRegion(config) { + return config.region || resolveConnectionMeta(config).region; +} + +function resolveEndpoint(config) { + return config.endpoint || resolveConnectionMeta(config).endpoint; +} + +function resolveR2Endpoint(config) { + const accountId = + config.accountId || resolveConnectionMeta(config).accountId || ""; + if (!accountId) { + return resolveEndpoint(config); + } + return `https://${accountId}.r2.cloudflarestorage.com`; +} + +function resolveForcePathStyle(config, fallback = false) { + const meta = resolveConnectionMeta(config); + if (typeof config.forcePathStyle === "boolean") { + return config.forcePathStyle; + } + if (typeof meta.forcePathStyle === "boolean") { + return meta.forcePathStyle; + } + return fallback; +} + +function buildS3TestConfig(config, overrides = {}) { + return { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + bucket: resolveBucket(config), + region: resolveRegion(config), + endpoint: resolveEndpoint(config), + forcePathStyle: resolveForcePathStyle(config), + ...overrides, + }; +} + const PROVIDER_TESTS = { - [PROVIDERS.R2]: testS3Connection, - [PROVIDERS.S3]: testS3Connection, - [PROVIDERS.GENERIC]: testS3Connection, + [PROVIDERS.R2]: (config) => + testS3Connection( + buildS3TestConfig(config, { + endpoint: resolveR2Endpoint(config), + forcePathStyle: true, + }) + ), + [PROVIDERS.S3]: (config) => + testS3Connection( + buildS3TestConfig(config, { + forcePathStyle: resolveForcePathStyle(config, false), + }) + ), + [PROVIDERS.GENERIC]: (config) => + testS3Connection( + buildS3TestConfig(config, { + forcePathStyle: resolveForcePathStyle(config, true), + }) + ), }; function hexToBytes(hex) { diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js index cf73b2c5..5777e6f9 100644 --- a/js/storage/r2-s3.js +++ b/js/storage/r2-s3.js @@ -1,81 +1,28 @@ // js/storage/r2-s3.js -const AWS_SDK_SPECIFIER = "https://esm.sh/@aws-sdk/client-s3@3.637.0?bundle"; - -const disableNetworkImports = Boolean( - globalThis?.__BITVID_DISABLE_NETWORK_IMPORTS__ -); - -let awsSdk = null; -let awsSdkLoadError = null; -let awsSdkLoadPromise = null; - -function assignAwsSdk(module) { - if (!module) { - return; - } - - awsSdk = { - S3Client: module.S3Client, - CreateBucketCommand: module.CreateBucketCommand, - CreateMultipartUploadCommand: module.CreateMultipartUploadCommand, - UploadPartCommand: module.UploadPartCommand, - CompleteMultipartUploadCommand: module.CompleteMultipartUploadCommand, - AbortMultipartUploadCommand: module.AbortMultipartUploadCommand, - ListBucketsCommand: module.ListBucketsCommand, - HeadBucketCommand: module.HeadBucketCommand, - PutBucketCorsCommand: module.PutBucketCorsCommand, - DeleteObjectCommand: module.DeleteObjectCommand, - }; -} - -async function loadAwsSdkModule() { - if (awsSdk || awsSdkLoadError) { - return awsSdk; - } - - if (!awsSdkLoadPromise) { - if (disableNetworkImports) { - awsSdkLoadError = new Error( - "Cloudflare R2 SDK network import has been disabled." - ); - return null; - } - - awsSdkLoadPromise = import(AWS_SDK_SPECIFIER) - .then((module) => { - assignAwsSdk(module); - return awsSdk; - }) - .catch((error) => { - awsSdkLoadError = error || new Error("Failed to load R2 SDK module"); - throw awsSdkLoadError; - }); - } - - try { - return await awsSdkLoadPromise; - } catch (error) { - awsSdkLoadError = error; - throw error; - } -} +import { + ensureS3SdkLoaded, + getS3SdkLoadError, + isS3SdkAvailable, + makeS3Client, + requireAwsSdk, +} from "./s3-client.js"; export async function testS3Connection(config) { if (!config) { throw new Error("Configuration required for testing S3 connection."); } - await ensureR2SdkLoaded(); + await ensureS3SdkLoaded(); const { ListBucketsCommand, HeadBucketCommand } = requireAwsSdk(); - const s3 = makeR2Client({ - accountId: config.accountId, - accessKeyId: config.accessKeyId, - secretAccessKey: config.secretAccessKey, + const s3 = makeS3Client({ endpoint: config.endpoint, region: config.region, + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + forcePathStyle: config.forcePathStyle, }); // If bucket is specified, we check that specific bucket (permissions + existence). @@ -108,37 +55,16 @@ export async function testS3Connection(config) { } } -function requireAwsSdk() { - if (awsSdk) { - return awsSdk; - } - - if (awsSdkLoadError) { - throw awsSdkLoadError; - } - - throw new Error( - "Cloudflare R2 SDK has not finished loading. Call ensureR2SdkLoaded() first." - ); -} - export function ensureR2SdkLoaded() { - return loadAwsSdkModule(); + return ensureS3SdkLoaded(); } export function getR2SdkLoadError() { - return awsSdkLoadError; + return getS3SdkLoadError(); } export function isR2SdkAvailable() { - return Boolean(awsSdk); -} - -if (!disableNetworkImports) { - // Begin loading immediately in environments that support network imports. - loadAwsSdkModule().catch(() => { - // Errors are captured for later inspection by getR2SdkLoadError(). - }); + return isS3SdkAvailable(); } function computeCacheControl(key) { @@ -173,14 +99,14 @@ export function makeR2Client({ throw new Error("Missing S3/R2 credentials"); } - const { S3Client } = requireAwsSdk(); const finalEndpoint = endpoint || `https://${accountId}.r2.cloudflarestorage.com`; - return new S3Client({ + return makeS3Client({ region: region || "auto", endpoint: finalEndpoint, - credentials: { accessKeyId, secretAccessKey }, + accessKeyId, + secretAccessKey, forcePathStyle: true, }); } diff --git a/js/storage/s3-client.js b/js/storage/s3-client.js new file mode 100644 index 00000000..701eea68 --- /dev/null +++ b/js/storage/s3-client.js @@ -0,0 +1,112 @@ +// js/storage/s3-client.js + +const AWS_SDK_SPECIFIER = "https://esm.sh/@aws-sdk/client-s3@3.637.0?bundle"; + +const disableNetworkImports = Boolean( + globalThis?.__BITVID_DISABLE_NETWORK_IMPORTS__ +); + +let awsSdk = null; +let awsSdkLoadError = null; +let awsSdkLoadPromise = null; + +function assignAwsSdk(module) { + if (!module) { + return; + } + + awsSdk = { + S3Client: module.S3Client, + CreateBucketCommand: module.CreateBucketCommand, + CreateMultipartUploadCommand: module.CreateMultipartUploadCommand, + UploadPartCommand: module.UploadPartCommand, + CompleteMultipartUploadCommand: module.CompleteMultipartUploadCommand, + AbortMultipartUploadCommand: module.AbortMultipartUploadCommand, + ListBucketsCommand: module.ListBucketsCommand, + HeadBucketCommand: module.HeadBucketCommand, + PutBucketCorsCommand: module.PutBucketCorsCommand, + DeleteObjectCommand: module.DeleteObjectCommand, + }; +} + +async function loadAwsSdkModule() { + if (awsSdk || awsSdkLoadError) { + return awsSdk; + } + + if (!awsSdkLoadPromise) { + if (disableNetworkImports) { + awsSdkLoadError = new Error("S3 SDK network import has been disabled."); + return null; + } + + awsSdkLoadPromise = import(AWS_SDK_SPECIFIER) + .then((module) => { + assignAwsSdk(module); + return awsSdk; + }) + .catch((error) => { + awsSdkLoadError = error || new Error("Failed to load S3 SDK module"); + throw awsSdkLoadError; + }); + } + + try { + return await awsSdkLoadPromise; + } catch (error) { + awsSdkLoadError = error; + throw error; + } +} + +export function requireAwsSdk() { + if (awsSdk) { + return awsSdk; + } + + if (awsSdkLoadError) { + throw awsSdkLoadError; + } + + throw new Error("S3 SDK has not finished loading. Call ensureS3SdkLoaded() first."); +} + +export function ensureS3SdkLoaded() { + return loadAwsSdkModule(); +} + +export function getS3SdkLoadError() { + return awsSdkLoadError; +} + +export function isS3SdkAvailable() { + return Boolean(awsSdk); +} + +export function makeS3Client({ + endpoint, + region, + accessKeyId, + secretAccessKey, + forcePathStyle, +}) { + if (!accessKeyId || !secretAccessKey) { + throw new Error("Missing S3 credentials"); + } + + const { S3Client } = requireAwsSdk(); + + return new S3Client({ + region: region || "auto", + endpoint, + credentials: { accessKeyId, secretAccessKey }, + forcePathStyle: Boolean(forcePathStyle), + }); +} + +if (!disableNetworkImports) { + // Begin loading immediately in environments that support network imports. + loadAwsSdkModule().catch(() => { + // Errors are captured for later inspection by getS3SdkLoadError(). + }); +}