UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

209 lines (208 loc) 8.34 kB
import { HitRateLimitError, InvalidCredentialsError, ServiceUnavailableError } from '@directus/errors'; import pLimit from 'p-limit'; import { DeploymentDriver } from '../deployment.js'; export class VercelDriver extends DeploymentDriver { static API_URL = 'https://api.vercel.com'; requestLimit = pLimit(5); constructor(credentials, options = {}) { super(credentials, options); } /** * Make authenticated request with retry on rate limit and concurrency control */ async request(endpoint, options = {}, retryCount = 0) { return this.requestLimit(async () => { const response = await this.axiosRequest(VercelDriver.API_URL, endpoint, { ...options, headers: { Authorization: `Bearer ${this.credentials.access_token}`, 'Content-Type': 'application/json', ...(options.headers ?? {}), }, params: { ...(this.options.team_id ? { teamId: this.options.team_id } : {}), ...(options.params ?? {}), }, }); // Handle rate limiting with retry (max 3 retries) if (response.status === 429) { const resetAt = parseInt(response.headers['x-ratelimit-reset'] || '0'); const limit = parseInt(response.headers['x-ratelimit-limit'] || '0'); if (retryCount < 3) { const waitTime = resetAt > 0 ? Math.max(resetAt * 1000 - Date.now(), 1000) : 1000 * (retryCount + 1); await new Promise((resolve) => setTimeout(resolve, waitTime)); return this.request(endpoint, options, retryCount + 1); } // Max retries exceeded throw new HitRateLimitError({ limit, reset: new Date(resetAt > 0 ? resetAt * 1000 : Date.now()), }); } const body = response.data; if (response.status >= 400) { const message = typeof body === 'object' && body !== null && 'error' in body ? body.error?.message || `Vercel API error: ${response.status}` : `Vercel API error: ${response.status}`; if (response.status === 401 || response.status === 403) { throw new InvalidCredentialsError(); } throw new ServiceUnavailableError({ service: 'vercel', reason: message }); } return body; }); } mapStatus(vercelStatus) { const normalized = vercelStatus?.toLowerCase(); switch (normalized) { case 'building': case 'error': case 'canceled': case 'ready': return normalized; case 'queued': case 'initializing': case 'analyzing': case 'deploying': default: return 'building'; } } async testConnection() { return await this.request('/v9/projects', { params: { limit: '1' } }); } mapProjectBase(project) { const result = { id: project.id, name: project.name, deployable: Boolean(project.link?.type), }; if (project.framework) { result.framework = project.framework; } return result; } async listProjects() { const response = await this.request('/v9/projects'); return response.projects.map((project) => this.mapProjectBase(project)); } async getProject(projectId) { const project = await this.request(`/v9/projects/${projectId}`); const result = this.mapProjectBase(project); const production = project.targets?.production; if (production?.alias?.[0]) { result.url = `https://${production.alias[0]}`; } // Latest deployment info from detail endpoint if (production?.readyState && production.createdAt) { result.latest_deployment = { status: this.mapStatus(production.readyState), created_at: new Date(production.createdAt), ...(production.readyAt && { finished_at: new Date(production.readyAt) }), }; } if (project.createdAt) { result.created_at = new Date(project.createdAt); } if (project.updatedAt) { result.updated_at = new Date(project.updatedAt); } return result; } async listDeployments(projectId, limit = 20) { const url = `/v6/deployments?projectId=${encodeURIComponent(projectId)}&limit=${limit}`; const response = await this.request(url); return response.deployments.map((deployment) => { const result = { id: deployment.uid, project_id: deployment.projectId ?? projectId, status: this.mapStatus(deployment.state), created_at: new Date(deployment.createdAt), }; if (deployment.url) { result.url = `https://${deployment.url}`; } if (deployment.ready) { result.finished_at = new Date(deployment.ready); } return result; }); } async getDeployment(deploymentId) { const deployment = await this.request(`/v13/deployments/${encodeURIComponent(deploymentId)}`); const result = { id: deployment.id, project_id: deployment.projectId ?? '', status: this.mapStatus(deployment.status || deployment.state), created_at: new Date(deployment.createdAt), }; if (deployment.url) { result.url = `https://${deployment.url}`; } if (deployment.ready) { result.finished_at = new Date(deployment.ready); } return result; } async triggerDeployment(projectId, options) { // Fetch project to get realtime required name needed for the vercel request const project = await this.request(`/v9/projects/${projectId}`); const body = { name: project.name, project: projectId, }; if (!options?.preview) { body['target'] = 'production'; } // Add required gitSource if (project.link?.type) { body['gitSource'] = { type: project.link.type, ref: project.link.productionBranch, repoId: project.link.repoId, }; } // forceNew=1 skips build cache when clearCache is true const response = await this.request('/v13/deployments', { method: 'POST', body: JSON.stringify(body), ...(options?.clearCache && { params: { forceNew: '1' } }), }); const triggerResult = { deployment_id: response.id, status: this.mapStatus(response.status), }; if (response.url) { triggerResult.url = `https://${response.url}`; } return triggerResult; } async cancelDeployment(deploymentId) { await this.request(`/v12/deployments/${encodeURIComponent(deploymentId)}/cancel`, { method: 'PATCH', }); } async getDeploymentLogs(deploymentId, options) { let url = `/v3/deployments/${encodeURIComponent(deploymentId)}/events`; // Vercel's since parameter uses milliseconds timestamp if (options?.since) { const sinceMs = options.since.getTime(); url += `?since=${sinceMs}`; } const response = await this.request(url); const mapEventType = (type) => { if (type === 'stderr') return 'stderr'; if (type === 'command') return 'info'; return 'stdout'; }; return response .filter((event) => event.type === 'stdout' || event.type === 'stderr' || event.type === 'command') .map((event) => ({ timestamp: new Date(event.created), type: mapEventType(event.type), message: event.text || event.payload?.text || '', })); } }