← vibesdk
worker/services/deployer/api/cloudflare-api.ts
10.1 KB
Download file
import { AssetManifest, UploadAssetSession, WorkerMetadata } from '../types';
import { getMimeType } from '../utils/index';
/**
* Cloudflare API client for Worker deployment operations
*/
export class CloudflareAPI {
private readonly accountId: string;
private readonly apiToken: string;
private readonly baseUrl = 'https://api.cloudflare.com/client/v4';
constructor(accountId: string, apiToken: string) {
this.accountId = accountId;
this.apiToken = apiToken;
}
/**
* Generate request headers with authorization
*/
private getHeaders(contentType?: string): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiToken}`,
};
if (contentType) {
headers['Content-Type'] = contentType;
}
return headers;
}
/**
* Create an asset upload session with Cloudflare
* Returns JWT token and list of files that need uploading
*/
async createAssetUploadSession(
scriptName: string,
manifest: AssetManifest,
dispatchNamespace?: string,
): Promise<UploadAssetSession> {
const url = dispatchNamespace
? `${this.baseUrl}/accounts/${this.accountId}/workers/dispatch/namespaces/${dispatchNamespace}/scripts/${scriptName}/assets-upload-session`
: `${this.baseUrl}/accounts/${this.accountId}/workers/scripts/${scriptName}/assets-upload-session`;
const response = await fetch(url, {
method: 'POST',
headers: this.getHeaders('application/json'),
body: JSON.stringify({ manifest }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(
`Failed to create asset upload session: ${response.status} - ${error}`,
);
}
const data = (await response.json()) as any;
return data.result;
}
/**
* Upload a batch of assets to Cloudflare
* Returns completion token if this is the last batch
*/
async uploadAssetBatch(
uploadToken: string,
fileHashesToUpload: string[],
fileContents: Map<string, Buffer>,
hashToPath: Map<string, string>,
): Promise<string | null> {
const url = `${this.baseUrl}/accounts/${this.accountId}/workers/assets/upload?base64=true`;
const formData = new FormData();
// Add each file as base64 string with proper MIME type
for (const hash of fileHashesToUpload) {
const content = fileContents.get(hash);
if (!content) {
throw new Error(`Content not found for hash: ${hash}`);
}
const base64Content = content.toString('base64');
// Get MIME type based on file path
const filePath = hashToPath.get(hash);
const mimeType = filePath
? getMimeType(filePath)
: 'application/octet-stream';
// Create a Blob with the base64 string and proper MIME type
// This ensures Content-Type is preserved when serving assets
const blob = new Blob([base64Content], { type: mimeType });
formData.append(hash, blob, hash);
}
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${uploadToken}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.text();
throw new Error(
`Failed to upload assets: ${response.status} - ${error}`,
);
}
// Status 201 indicates all files uploaded, returns completion token
if (response.status === 201) {
const data = (await response.json()) as any;
return data.result?.jwt || null;
}
return null;
}
/**
* Deploy a Worker script to Cloudflare
* Includes metadata, bindings, and assets configuration
*/
async deployWorker(
scriptName: string,
metadata: WorkerMetadata,
workerContent: string,
dispatchNamespace?: string,
additionalModules?: Map<string, string>,
durableObjectClasses?: string[],
): Promise<void> {
const url = dispatchNamespace
? `${this.baseUrl}/accounts/${this.accountId}/workers/dispatch/namespaces/${dispatchNamespace}/scripts/${scriptName}`
: `${this.baseUrl}/accounts/${this.accountId}/workers/scripts/${scriptName}`;
const formData = new FormData();
// Build complete metadata with Durable Object exports
const metadataWithExports = { ...metadata };
if (durableObjectClasses && durableObjectClasses.length > 0) {
metadataWithExports.exported_handlers = durableObjectClasses;
}
// Don't modify worker content - Vite builds already include exports
// The exported_handlers in metadata is sufficient for the API
const finalWorkerContent = workerContent;
formData.append('metadata', JSON.stringify(metadataWithExports));
// Add main worker script as ES module with DO exports
const workerBlob = new Blob([finalWorkerContent], {
type: 'application/javascript+module',
});
formData.append('index.js', workerBlob, 'index.js');
// Add any additional modules (e.g., from Vite build)
if (additionalModules) {
for (const [
moduleName,
moduleContent,
] of additionalModules.entries()) {
const moduleBlob = new Blob([moduleContent], {
type: 'application/javascript+module',
});
formData.append(moduleName, moduleBlob, moduleName);
}
}
const response = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${this.apiToken}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.text();
const errorObj = JSON.parse(error);
// Check if error is about migrations for existing DO classes
if (errorObj.errors?.[0]?.code === 10074) {
const errorMessage = errorObj.errors[0].message;
const existingClassMatch =
errorMessage.match(/class '([^']+)'/);
const existingClass = existingClassMatch
? existingClassMatch[1]
: null;
console.log(
`\n⚠️ Durable Object class '${existingClass}' already exists`,
);
// Filter out the existing class from migrations
if (metadataWithExports.migrations && existingClass) {
const migrations = metadataWithExports.migrations;
// Remove the existing class from new_classes and new_sqlite_classes
if (migrations.new_classes) {
migrations.new_classes = migrations.new_classes.filter(
(c: string) => c !== existingClass,
);
if (migrations.new_classes.length === 0)
delete migrations.new_classes;
}
if (migrations.new_sqlite_classes) {
migrations.new_sqlite_classes =
migrations.new_sqlite_classes.filter(
(c: string) => c !== existingClass,
);
if (migrations.new_sqlite_classes.length === 0)
delete migrations.new_sqlite_classes;
}
// If no migrations left, remove the field entirely
if (
!migrations.new_classes &&
!migrations.new_sqlite_classes &&
!migrations.renamed_classes &&
!migrations.deleted_classes
) {
delete metadataWithExports.migrations;
console.log(
'📝 All Durable Objects already exist, deploying without migrations',
);
} else {
console.log(
`📝 Retrying with migrations for new classes only`,
);
}
}
// Retry deployment with filtered migrations
const retryFormData = new FormData();
retryFormData.append(
'metadata',
JSON.stringify(metadataWithExports),
);
retryFormData.append('index.js', workerBlob, 'index.js');
if (additionalModules) {
for (const [
moduleName,
moduleContent,
] of additionalModules.entries()) {
const moduleBlob = new Blob([moduleContent], {
type: 'application/javascript+module',
});
retryFormData.append(
moduleName,
moduleBlob,
moduleName,
);
}
}
const retryResponse = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${this.apiToken}`,
},
body: retryFormData,
});
if (!retryResponse.ok) {
const retryError = await retryResponse.text();
const retryErrorObj = JSON.parse(retryError);
// If still failing with same error, recursively handle it
if (retryErrorObj.errors?.[0]?.code === 10074) {
// Recursive call to handle multiple existing classes
return this.deployWorker(
scriptName,
metadataWithExports,
finalWorkerContent,
dispatchNamespace,
additionalModules,
durableObjectClasses,
);
}
throw new Error(
`Failed to deploy worker: ${retryResponse.status} - ${retryError}`,
);
}
console.log('✅ Successfully deployed');
return;
}
throw new Error(
`Failed to deploy worker: ${response.status} - ${error}`,
);
}
console.log(`✅ Worker deployed successfully: ${scriptName}`);
}
async enableWorkersDev(scriptName: string): Promise<void> {
const response = await fetch(
`${this.baseUrl}/accounts/${this.accountId}/workers/scripts/${encodeURIComponent(scriptName)}/subdomain`,
{
method: 'POST',
headers: this.getHeaders('application/json'),
body: JSON.stringify({ enabled: true }),
},
);
if (!response.ok) {
throw new Error(`Failed to enable public workers.dev URL: ${response.status} - ${await response.text()}`);
}
}
async getWorkersDevSubdomain(): Promise<string> {
const response = await fetch(
`${this.baseUrl}/accounts/${this.accountId}/workers/subdomain`,
{ headers: this.getHeaders() },
);
if (!response.ok) {
throw new Error(`Failed to resolve workers.dev subdomain: ${response.status} - ${await response.text()}`);
}
const data = (await response.json()) as {
success: boolean;
result?: { subdomain?: string };
};
const subdomain = data.result?.subdomain;
if (!data.success || !subdomain) {
throw new Error('workers.dev is not enabled for the selected Cloudflare account');
}
return subdomain;
}
/**
* Test a deployed Worker by making a request to its endpoint
*/
async testWorkerEndpoint(workerUrl: string): Promise<void> {
try {
const response = await fetch(workerUrl);
const text = await response.text();
console.log(`\n📡 Worker Response (${response.status}):`);
console.log(
text.substring(0, 200) + (text.length > 200 ? '...' : ''),
);
} catch (error) {
console.error('❌ Failed to test worker endpoint:', error);
}
}
}