diff --git a/src/lib/auto-fix/client/auto-fix-worker-client.ts b/src/lib/auto-fix/client/auto-fix-worker-client.ts index e092247931..5f543a63b7 100644 --- a/src/lib/auto-fix/client/auto-fix-worker-client.ts +++ b/src/lib/auto-fix/client/auto-fix-worker-client.ts @@ -44,16 +44,27 @@ export interface DispatchFixResponse { * Handles all communication with the Cloudflare Worker for auto fix */ class AutoFixWorkerClient { - private readonly baseUrl: string; - private readonly authToken: string; + private _baseUrl: string | null = null; + private _authToken: string | null = null; - constructor() { - if (!AUTO_FIX_URL || !AUTO_FIX_AUTH_TOKEN) { - throw new Error('AUTO_FIX_URL or AUTO_FIX_AUTH_TOKEN not configured'); + private get baseUrl(): string { + if (!this._baseUrl) { + if (!AUTO_FIX_URL) { + throw new Error('AUTO_FIX_URL not configured'); + } + this._baseUrl = AUTO_FIX_URL; } + return this._baseUrl; + } - this.baseUrl = AUTO_FIX_URL; - this.authToken = AUTO_FIX_AUTH_TOKEN; + private get authToken(): string { + if (!this._authToken) { + if (!AUTO_FIX_AUTH_TOKEN) { + throw new Error('AUTO_FIX_AUTH_TOKEN not configured'); + } + this._authToken = AUTO_FIX_AUTH_TOKEN; + } + return this._authToken; } /** diff --git a/src/lib/auto-triage/client/triage-worker-client.ts b/src/lib/auto-triage/client/triage-worker-client.ts index 1c0752d29b..be0edfcba4 100644 --- a/src/lib/auto-triage/client/triage-worker-client.ts +++ b/src/lib/auto-triage/client/triage-worker-client.ts @@ -45,16 +45,27 @@ export interface DispatchTriageResponse { * Handles all communication with the Cloudflare Worker for auto triage */ class TriageWorkerClient { - private readonly baseUrl: string; - private readonly authToken: string; + private _baseUrl: string | null = null; + private _authToken: string | null = null; - constructor() { - if (!AUTO_TRIAGE_URL || !AUTO_TRIAGE_AUTH_TOKEN) { - throw new Error('AUTO_TRIAGE_URL or AUTO_TRIAGE_AUTH_TOKEN not configured'); + private get baseUrl(): string { + if (!this._baseUrl) { + if (!AUTO_TRIAGE_URL) { + throw new Error('AUTO_TRIAGE_URL not configured'); + } + this._baseUrl = AUTO_TRIAGE_URL; } + return this._baseUrl; + } - this.baseUrl = AUTO_TRIAGE_URL; - this.authToken = AUTO_TRIAGE_AUTH_TOKEN; + private get authToken(): string { + if (!this._authToken) { + if (!AUTO_TRIAGE_AUTH_TOKEN) { + throw new Error('AUTO_TRIAGE_AUTH_TOKEN not configured'); + } + this._authToken = AUTO_TRIAGE_AUTH_TOKEN; + } + return this._authToken; } /** diff --git a/src/lib/code-reviews/client/code-review-worker-client.ts b/src/lib/code-reviews/client/code-review-worker-client.ts index 99b13ac3fe..bd7a013aa2 100644 --- a/src/lib/code-reviews/client/code-review-worker-client.ts +++ b/src/lib/code-reviews/client/code-review-worker-client.ts @@ -67,16 +67,27 @@ export interface CancelReviewResponse { * Handles all communication with the Cloudflare Worker for code reviews */ class CodeReviewWorkerClient { - private readonly baseUrl: string; - private readonly authToken: string; - - constructor() { - if (!CODE_REVIEW_WORKER_URL || !CODE_REVIEW_WORKER_AUTH_TOKEN) { - throw new Error('CODE_REVIEW_WORKER_URL or CODE_REVIEW_WORKER_AUTH_TOKEN not configured'); + private _baseUrl: string | null = null; + private _authToken: string | null = null; + + private get baseUrl(): string { + if (!this._baseUrl) { + if (!CODE_REVIEW_WORKER_URL) { + throw new Error('CODE_REVIEW_WORKER_URL not configured'); + } + this._baseUrl = CODE_REVIEW_WORKER_URL; } + return this._baseUrl; + } - this.baseUrl = CODE_REVIEW_WORKER_URL; - this.authToken = CODE_REVIEW_WORKER_AUTH_TOKEN; + private get authToken(): string { + if (!this._authToken) { + if (!CODE_REVIEW_WORKER_AUTH_TOKEN) { + throw new Error('CODE_REVIEW_WORKER_AUTH_TOKEN not configured'); + } + this._authToken = CODE_REVIEW_WORKER_AUTH_TOKEN; + } + return this._authToken; } /** diff --git a/src/lib/r2/cli-sessions.test.ts b/src/lib/r2/cli-sessions.test.ts index fccfbe21b7..3ca6d39763 100644 --- a/src/lib/r2/cli-sessions.test.ts +++ b/src/lib/r2/cli-sessions.test.ts @@ -3,7 +3,7 @@ import { Readable } from 'node:stream'; jest.mock('./client', () => ({ r2Client: { send: jest.fn() }, - r2CliSessionsBucketName: 'test-bucket', + getR2CliSessionsBucketName: () => 'test-bucket', })); jest.mock('@aws-sdk/s3-request-presigner', () => ({ diff --git a/src/lib/r2/cli-sessions.ts b/src/lib/r2/cli-sessions.ts index 0f8b584dce..ca39ee1ea3 100644 --- a/src/lib/r2/cli-sessions.ts +++ b/src/lib/r2/cli-sessions.ts @@ -7,7 +7,7 @@ import { } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import type { Readable } from 'node:stream'; -import { r2Client, r2CliSessionsBucketName } from './client'; +import { r2Client, getR2CliSessionsBucketName } from './client'; import type { CliSession } from '@/db/schema'; export type FolderName = 'sessions' | 'shared-sessions'; @@ -41,7 +41,7 @@ export async function uploadBlob( const key = getBlobKey(sessionId, folderName, filename); const command = { - Bucket: r2CliSessionsBucketName, + Bucket: getR2CliSessionsBucketName(), Key: key, Body: rawContent, ContentType: 'application/json', @@ -70,7 +70,7 @@ export async function generateSignedUploadUrl( const key = getBlobKey(sessionId, folderName, filename); const command = new PutObjectCommand({ - Bucket: r2CliSessionsBucketName, + Bucket: getR2CliSessionsBucketName(), Key: key, ContentType: 'application/json', ContentLength: contentLength, @@ -100,7 +100,7 @@ export async function generateSignedUrls( const urlPromises = filenames.map(async filename => { const key = getBlobKey(sessionId, folderName, filename); const command = new GetObjectCommand({ - Bucket: r2CliSessionsBucketName, + Bucket: getR2CliSessionsBucketName(), Key: key, }); @@ -129,7 +129,7 @@ export async function deleteBlobs( await r2Client.send( new DeleteObjectsCommand({ - Bucket: r2CliSessionsBucketName, + Bucket: getR2CliSessionsBucketName(), Delete: { Objects: objects, Quiet: true, @@ -141,7 +141,7 @@ export async function deleteBlobs( export async function getBlobContent(blobKey: string): Promise { const response = await r2Client.send( new GetObjectCommand({ - Bucket: r2CliSessionsBucketName, + Bucket: getR2CliSessionsBucketName(), Key: blobKey, }) ); @@ -169,7 +169,7 @@ export async function copyBlobs( try { await r2Client.send( new HeadObjectCommand({ - Bucket: r2CliSessionsBucketName, + Bucket: getR2CliSessionsBucketName(), Key: sourceKey, }) ); @@ -177,10 +177,11 @@ export async function copyBlobs( return null; } + const bucketName = getR2CliSessionsBucketName(); await r2Client.send( new CopyObjectCommand({ - Bucket: r2CliSessionsBucketName, - CopySource: `${r2CliSessionsBucketName}/${sourceKey}`, + Bucket: bucketName, + CopySource: `${bucketName}/${sourceKey}`, Key: destinationKey, }) ); diff --git a/src/lib/r2/client.ts b/src/lib/r2/client.ts index 2dcdade71f..306ef7c43f 100644 --- a/src/lib/r2/client.ts +++ b/src/lib/r2/client.ts @@ -1,29 +1,61 @@ import { S3Client } from '@aws-sdk/client-s3'; import { getEnvVariable } from '@/lib/dotenvx'; -// R2 configuration from environment variables -const R2_ACCOUNT_ID = getEnvVariable('R2_ACCOUNT_ID'); -const R2_ACCESS_KEY_ID = getEnvVariable('R2_ACCESS_KEY_ID'); -const R2_SECRET_ACCESS_KEY = getEnvVariable('R2_SECRET_ACCESS_KEY'); -const R2_CLI_SESSIONS_BUCKET_NAME = getEnvVariable('R2_CLI_SESSIONS_BUCKET_NAME'); -const CLOUD_AGENT_R2_ATTACHMENTS_BUCKET_NAME = getEnvVariable( - 'CLOUD_AGENT_R2_ATTACHMENTS_BUCKET_NAME' -); - -if (!R2_ACCOUNT_ID) { - throw new Error('R2_ACCOUNT_ID environment variable is required'); -} +// R2 configuration from environment variables (lazy-loaded to avoid breaking the app at startup) +function getR2Config() { + const R2_ACCOUNT_ID = getEnvVariable('R2_ACCOUNT_ID'); + const R2_ACCESS_KEY_ID = getEnvVariable('R2_ACCESS_KEY_ID'); + const R2_SECRET_ACCESS_KEY = getEnvVariable('R2_SECRET_ACCESS_KEY'); + const R2_CLI_SESSIONS_BUCKET_NAME = getEnvVariable('R2_CLI_SESSIONS_BUCKET_NAME'); + const CLOUD_AGENT_R2_ATTACHMENTS_BUCKET_NAME = getEnvVariable( + 'CLOUD_AGENT_R2_ATTACHMENTS_BUCKET_NAME' + ); -if (!R2_ACCESS_KEY_ID) { - throw new Error('R2_ACCESS_KEY_ID environment variable is required'); -} + if (!R2_ACCOUNT_ID) { + throw new Error('R2_ACCOUNT_ID environment variable is required'); + } + + if (!R2_ACCESS_KEY_ID) { + throw new Error('R2_ACCESS_KEY_ID environment variable is required'); + } + + if (!R2_SECRET_ACCESS_KEY) { + throw new Error('R2_SECRET_ACCESS_KEY environment variable is required'); + } -if (!R2_SECRET_ACCESS_KEY) { - throw new Error('R2_SECRET_ACCESS_KEY environment variable is required'); + if (!R2_CLI_SESSIONS_BUCKET_NAME) { + throw new Error('R2_CLI_SESSIONS_BUCKET_NAME environment variable is required'); + } + + return { + R2_ACCOUNT_ID, + R2_ACCESS_KEY_ID, + R2_SECRET_ACCESS_KEY, + R2_CLI_SESSIONS_BUCKET_NAME, + CLOUD_AGENT_R2_ATTACHMENTS_BUCKET_NAME, + }; } -if (!R2_CLI_SESSIONS_BUCKET_NAME) { - throw new Error('R2_CLI_SESSIONS_BUCKET_NAME environment variable is required'); +let _r2Client: S3Client | null = null; +let _r2CliSessionsBucketName: string | null = null; +let _r2CloudAgentAttachmentsBucketName: string | undefined = undefined; + +function initR2Client() { + if (_r2Client) return; + + const config = getR2Config(); + + _r2Client = new S3Client({ + region: 'auto', + endpoint: `https://${config.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: config.R2_ACCESS_KEY_ID, + secretAccessKey: config.R2_SECRET_ACCESS_KEY, + }, + }); + + _r2CliSessionsBucketName = config.R2_CLI_SESSIONS_BUCKET_NAME; + _r2CloudAgentAttachmentsBucketName = config.CLOUD_AGENT_R2_ATTACHMENTS_BUCKET_NAME; } /** @@ -31,15 +63,29 @@ if (!R2_CLI_SESSIONS_BUCKET_NAME) { * * R2 is Cloudflare's S3-compatible object storage service. * The client is configured with R2-specific endpoint and credentials. + * + * Note: This is lazy-initialized to avoid breaking the app at startup + * when R2 environment variables are not configured. */ -export const r2Client = new S3Client({ - region: 'auto', - endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, - credentials: { - accessKeyId: R2_ACCESS_KEY_ID, - secretAccessKey: R2_SECRET_ACCESS_KEY, +export const r2Client = { + send: (...args: Parameters) => { + initR2Client(); + if (!_r2Client) { + throw new Error('R2 client failed to initialize'); + } + return _r2Client.send(...args); }, -}); +} as S3Client; -export const r2CliSessionsBucketName = R2_CLI_SESSIONS_BUCKET_NAME; -export const r2CloudAgentAttachmentsBucketName = CLOUD_AGENT_R2_ATTACHMENTS_BUCKET_NAME; +export function getR2CliSessionsBucketName(): string { + initR2Client(); + if (!_r2CliSessionsBucketName) { + throw new Error('R2 CLI sessions bucket name not configured'); + } + return _r2CliSessionsBucketName; +} + +export function getR2CloudAgentAttachmentsBucketName(): string | undefined { + initR2Client(); + return _r2CloudAgentAttachmentsBucketName; +} diff --git a/src/lib/r2/cloud-agent-attachments.ts b/src/lib/r2/cloud-agent-attachments.ts index f71c2f096d..506e4f017d 100644 --- a/src/lib/r2/cloud-agent-attachments.ts +++ b/src/lib/r2/cloud-agent-attachments.ts @@ -5,7 +5,7 @@ import { CLOUD_AGENT_IMAGE_PRESIGNED_URL_EXPIRY_SECONDS, } from '@/lib/cloud-agent/constants'; import type { CloudAgentImageAllowedType } from '@/lib/cloud-agent/constants'; -import { r2Client, r2CloudAgentAttachmentsBucketName } from '@/lib/r2/client'; +import { r2Client, getR2CloudAgentAttachmentsBucketName } from '@/lib/r2/client'; type Service = 'app-builder'; @@ -50,7 +50,7 @@ export async function generateImageUploadUrl({ const key = getImageKey(service, userId, messageUuid, imageId, contentType); const command = new PutObjectCommand({ - Bucket: r2CloudAgentAttachmentsBucketName, + Bucket: getR2CloudAgentAttachmentsBucketName(), Key: key, ContentType: contentType, ContentLength: contentLength,