UNPKG

@directus/api

Version:

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

261 lines (260 loc) 10.6 kB
import { InvalidCredentialsError, ServiceUnavailableError } from '@directus/errors'; import { NetlifyAPI } from '@netlify/api'; import { isNumber } from 'lodash-es'; import { DeploymentDriver } from '../deployment.js'; const WS_CONNECTIONS = new Map(); const WS_IDLE_TIMEOUT = 60_000; // 60 seconds const WS_CONNECTION_TIMEOUT = 10_000; // 10 seconds // eslint-disable-next-line no-control-regex const ANSI_REGEX = /[\x1b]\[[0-9;]*m/g; const WS_URL = 'wss://socketeer.services.netlify.com/build/logs'; export class NetlifyDriver extends DeploymentDriver { api; constructor(credentials, options = {}) { super(credentials, options); this.api = new NetlifyAPI(this.credentials.access_token); } async handleApiError(cb) { try { return await cb(this.api); } catch (error) { if (error instanceof Error && 'status' in error && isNumber(error.status) && error.status >= 400) { if (error.status === 401 || error.status === 403) { throw new InvalidCredentialsError(); } throw new ServiceUnavailableError({ service: 'netlify', reason: 'Netlify API error: ' + error.message }); } throw error; } } mapStatus(netlifyState) { const normalized = netlifyState?.toLowerCase(); switch (normalized) { case 'ready': return 'ready'; case 'error': return 'error'; case 'canceled': return 'canceled'; default: return 'building'; } } async testConnection() { await this.handleApiError((api) => api.listSites({ per_page: 1 })); } mapSiteBase(site) { const result = { id: site.id, name: site.name, deployable: Boolean(site.build_settings?.provider && site.build_settings?.repo_url), }; // Use custom domain if available, otherwise ssl_url or url if (site.custom_domain) { result.url = `https://${site.custom_domain}`; } else if (site.ssl_url) { result.url = site.ssl_url; } else if (site.url) { result.url = site.url; } return result; } async listProjects() { const params = { per_page: '100' }; const response = await this.handleApiError((api) => { return this.options.account_slug ? api.listSitesForAccount({ account_slug: this.options.account_slug, ...params, }) : api.listSites(params); }); return response.map((site) => this.mapSiteBase(site)); } async getProject(projectId) { const site = await this.handleApiError((api) => api.getSite({ siteId: projectId })); const result = this.mapSiteBase(site); // Add published deploy info if available if (site.published_deploy) { const deploy = site.published_deploy; if (deploy.state && deploy.created_at) { result.latest_deployment = { status: this.mapStatus(deploy.state), created_at: new Date(deploy.created_at), ...(deploy.published_at && { finished_at: new Date(deploy.published_at) }), }; } } if (site.created_at) { result.created_at = new Date(site.created_at); } if (site.updated_at) { result.updated_at = new Date(site.updated_at); } return result; } mapDeployUrl(deploy) { return deploy['ssl_url'] ?? deploy['deploy_ssl_url'] ?? deploy['deploy_url'] ?? deploy['url']; } async listDeployments(projectId, limit = 20) { const response = await this.handleApiError((api) => api.listSiteDeploys({ site_id: projectId, per_page: limit })); return response.map((deploy) => { const result = { id: deploy.id, project_id: deploy.site_id, status: this.mapStatus(deploy.state), created_at: new Date(deploy.created_at), }; const url = this.mapDeployUrl(deploy); if (url) result.url = url; if (deploy.published_at) { result.finished_at = new Date(deploy.published_at); } if (deploy.error_message) { result.error_message = deploy.error_message; } return result; }); } async getDeployment(deploymentId) { const deploy = await this.handleApiError((api) => api.getDeploy({ deployId: deploymentId })); const result = { id: deploy.id, project_id: deploy.site_id, status: this.mapStatus(deploy.state), created_at: new Date(deploy.created_at), }; const url = this.mapDeployUrl(deploy); if (url) result.url = url; if (deploy.published_at) { result.finished_at = new Date(deploy.published_at); } if (deploy.error_message) { result.error_message = deploy.error_message; } return result; } async triggerDeployment(projectId, options) { // Netlify builds endpoint returns a Build object with deploy_id and deploy_state const buildResponse = await this.handleApiError((api) => api.createSiteBuild({ site_id: projectId, clear_cache: options?.clearCache || false, })); const deployState = await this.handleApiError((api) => api.getDeploy({ deployId: buildResponse.deploy_id })); const triggerResult = { deployment_id: buildResponse.deploy_id, status: this.mapStatus(deployState.state), }; return triggerResult; } async cancelDeployment(deploymentId) { await this.handleApiError((api) => api.cancelSiteDeploy({ deployId: deploymentId })); this.closeWsConnection(deploymentId); } closeWsConnection(deploymentId, remove = true) { const connection = WS_CONNECTIONS.get(deploymentId); if (!connection) return; connection.ws.close(); if (remove) { WS_CONNECTIONS.delete(deploymentId); } } setupWsIdleTimeout(connection) { if (connection.idleTimeout) { clearTimeout(connection.idleTimeout); } connection.idleTimeout = setTimeout(() => { this.closeWsConnection(connection.deploymentId); }, WS_IDLE_TIMEOUT); } setupWsConnectionTimeout(connection, reject) { if (connection.connectionTimeout) { clearTimeout(connection.connectionTimeout); } connection.connectionTimeout = setTimeout(() => { this.closeWsConnection(connection.deploymentId); reject(new ServiceUnavailableError({ service: 'netlify', reason: 'WebSocket connection timeout' })); }, WS_CONNECTION_TIMEOUT); } getWsConnection(deploymentId) { return new Promise((resolve, reject) => { const existingConnection = WS_CONNECTIONS.get(deploymentId); if (existingConnection) { this.setupWsIdleTimeout(existingConnection); return resolve(existingConnection); } let resolveCompleted; const completed = new Promise((res) => { resolveCompleted = res; }); const connection = { ws: new WebSocket(WS_URL), logs: [], deploymentId, completed, resolveCompleted: resolveCompleted, }; this.setupWsConnectionTimeout(connection, reject); connection.ws.addEventListener('open', () => { if (connection.connectionTimeout) { clearTimeout(connection.connectionTimeout); connection.connectionTimeout = undefined; } this.setupWsIdleTimeout(connection); const payload = JSON.stringify({ deploy_id: deploymentId, access_token: this.credentials.access_token, }); connection.ws.send(payload); resolve(connection); WS_CONNECTIONS.set(deploymentId, connection); }); connection.ws.addEventListener('message', (event) => { const data = JSON.parse(event.data); const cleanMessage = data.message.replace(/\r/g, '').replace(ANSI_REGEX, ''); let logType = 'stdout'; if (data.type === 'report') { logType = cleanMessage.includes('Failing build') ? 'stderr' : 'info'; } connection.logs.push({ timestamp: new Date(data.ts), type: logType, message: cleanMessage, }); // If we receive a "report" type message, the build is complete. // Close the WebSocket connection but don't yet remove the logs, allowing the client to fetch them until the idle timeout expires. if (data.type === 'report') { connection.resolveCompleted(); this.closeWsConnection(deploymentId, false); } }); connection.ws.addEventListener('error', () => { this.closeWsConnection(deploymentId); reject(new ServiceUnavailableError({ service: 'netlify', reason: 'WebSocket connection error' })); }); connection.ws.addEventListener('close', () => { if (connection.connectionTimeout) { clearTimeout(connection.connectionTimeout); } }); }); } async getDeploymentLogs(deploymentId, options) { const deploy = await this.handleApiError((api) => api.getDeploy({ deployId: deploymentId })); const connection = await this.getWsConnection(deploymentId); // Build already finished — WS is replaying logs, wait for all of them if (this.mapStatus(deploy.state) !== 'building') { await connection.completed; } if (options?.since) { return connection.logs.filter((log) => log.timestamp >= options.since); } return connection.logs; } }