DC-087: Refactor SDK to compact spec-table pattern (326 lines, 39 methods)
Subagent refactored from 750→326 lines using compact spec-table. Covers services, containers, health, dns, backups, config, monitoring.
This commit is contained in:
+178
-602
@@ -2,6 +2,7 @@
|
|||||||
* DashCaddy JavaScript Client — lightweight SDK for the DashCaddy API.
|
* DashCaddy JavaScript Client — lightweight SDK for the DashCaddy API.
|
||||||
*
|
*
|
||||||
* Zero external dependencies. Works in Node.js 18+ (uses global fetch).
|
* Zero external dependencies. Works in Node.js 18+ (uses global fetch).
|
||||||
|
* Full TypeScript definitions in types.d.ts.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const { DashCaddyClient } = require('./dashcaddy-client');
|
* const { DashCaddyClient } = require('./dashcaddy-client');
|
||||||
@@ -18,19 +19,10 @@
|
|||||||
* sessionCookie: 'sid=...'
|
* sessionCookie: 'sid=...'
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* // List services
|
* const services = await client.services.list(); // GET /api/v1/services
|
||||||
* const services = await client.services.list();
|
* const health = await client.health.get(); // GET /health
|
||||||
*
|
|
||||||
* // Get health status
|
|
||||||
* const health = await client.health.get();
|
|
||||||
*
|
|
||||||
* // Discover containers
|
|
||||||
* const { containers } = await client.containers.discover();
|
* const { containers } = await client.containers.discover();
|
||||||
*
|
|
||||||
* // Create a DNS record
|
|
||||||
* await client.dns.createRecord({ type: 'A', domain: 'app.sami', value: '10.0.0.1' });
|
* await client.dns.createRecord({ type: 'A', domain: 'app.sami', value: '10.0.0.1' });
|
||||||
*
|
|
||||||
* // Run an immediate backup
|
|
||||||
* const { backup } = await client.backups.execute();
|
* const { backup } = await client.backups.execute();
|
||||||
*
|
*
|
||||||
* @license MIT
|
* @license MIT
|
||||||
@@ -38,30 +30,15 @@
|
|||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT = 30000;
|
const DEFAULT_TIMEOUT = 30000;
|
||||||
const DEFAULT_MAX_RETRIES = 3;
|
const DEFAULT_MAX_RETRIES = 3;
|
||||||
const RETRY_BACKOFF_BASE_MS = 500;
|
const RETRY_BASE_MS = 500;
|
||||||
const API_PREFIX = '/api/v1';
|
const API_PREFIX = '/api/v1';
|
||||||
const HEALTH_PREFIX = '';
|
const CSRF_HEADER = 'x-csrf-token';
|
||||||
const CSRF_PATH = API_PREFIX + '/csrf-token';
|
|
||||||
const CSRF_HEADER_NAME = 'x-csrf-token';
|
|
||||||
const API_KEY_HEADER = 'x-api-key';
|
const API_KEY_HEADER = 'x-api-key';
|
||||||
|
|
||||||
// ── Error Class ────────────────────────────────────────────────
|
/** Error thrown on non-success API responses or network failures after retries. */
|
||||||
|
|
||||||
/**
|
|
||||||
* Error thrown when the API returns a non-success response or a network
|
|
||||||
* error occurs after all retries are exhausted.
|
|
||||||
*/
|
|
||||||
class DashCaddyError extends Error {
|
class DashCaddyError extends Error {
|
||||||
/**
|
|
||||||
* @param {string} message - Error message.
|
|
||||||
* @param {number} [statusCode] - HTTP status code.
|
|
||||||
* @param {string} [code] - Machine-readable error code from the API.
|
|
||||||
* @param {Record<string, unknown>} [details] - Full error response body.
|
|
||||||
*/
|
|
||||||
constructor(message, statusCode, code, details) {
|
constructor(message, statusCode, code, details) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'DashCaddyError';
|
this.name = 'DashCaddyError';
|
||||||
@@ -71,422 +48,8 @@ class DashCaddyError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal HTTP Request Helper ───────────────────────────────
|
// ── Client ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Object} opts
|
|
||||||
* @param {string} opts.url
|
|
||||||
* @param {string} opts.method
|
|
||||||
* @param {Record<string, string>} [opts.headers]
|
|
||||||
* @param {unknown} [opts.body]
|
|
||||||
* @param {number} [opts.timeout]
|
|
||||||
* @param {typeof fetch} [opts.fetchImpl]
|
|
||||||
* @param {AbortSignal} [opts.signal]
|
|
||||||
* @returns {Promise<Response>}
|
|
||||||
*/
|
|
||||||
async function rawRequest({ url, method, headers, body, timeout, fetchImpl, signal }) {
|
|
||||||
const fetchFn = fetchImpl || fetch;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), timeout || DEFAULT_TIMEOUT);
|
|
||||||
|
|
||||||
// Link external signal if provided
|
|
||||||
if (signal) {
|
|
||||||
if (signal.aborted) controller.abort();
|
|
||||||
else signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetchFn(url, {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Resource Mixins ────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Each resource namespace is created as a plain object with methods bound
|
|
||||||
// to the client instance. This keeps the class lean while providing
|
|
||||||
// structured access: client.services.list(), client.health.get(), etc.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DashCaddyClient} client
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function createServicesResource(client) {
|
|
||||||
return {
|
|
||||||
/** List all registered services. GET /api/v1/services */
|
|
||||||
async list() {
|
|
||||||
const res = await client._request('GET', '/services');
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get aggregated status for all services. GET /api/v1/services/status
|
|
||||||
* @returns {Promise<{ success: boolean, checkedAt?: string, partial?: boolean, statuses?: Record<string, ServiceStatus> }>}
|
|
||||||
*/
|
|
||||||
async status() {
|
|
||||||
return client._request('GET', '/services/status');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new service. POST /api/v1/services
|
|
||||||
* @param {object} service - Service definition.
|
|
||||||
*/
|
|
||||||
async create(service) {
|
|
||||||
return client._request('POST', '/services', { body: service });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update services (bulk replace). PUT /api/v1/services
|
|
||||||
* @param {object[]} services - Full services array.
|
|
||||||
*/
|
|
||||||
async updateAll(services) {
|
|
||||||
return client._request('PUT', '/services', { body: services });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a service by ID. DELETE /api/v1/services/:id
|
|
||||||
* @param {string} id - Service ID.
|
|
||||||
*/
|
|
||||||
async delete(id) {
|
|
||||||
return client._request('DELETE', `/services/${encodeURIComponent(id)}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger a services update check/apply. POST /api/v1/services/update
|
|
||||||
* @param {object} [opts] - Update options.
|
|
||||||
*/
|
|
||||||
async triggerUpdate(opts) {
|
|
||||||
return client._request('POST', '/services/update', { body: opts || {} });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DashCaddyClient} client
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function createContainersResource(client) {
|
|
||||||
return {
|
|
||||||
/** Discover all Docker containers. GET /api/v1/containers/discover */
|
|
||||||
async discover() {
|
|
||||||
return client._request('GET', '/containers/discover');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get logs for a container. GET /api/v1/containers/:id/logs
|
|
||||||
* @param {string} id - Container ID.
|
|
||||||
*/
|
|
||||||
async logs(id) {
|
|
||||||
return client._request('GET', `/containers/${encodeURIComponent(id)}/logs`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get resource limits for a container. GET /api/v1/containers/:id/resources
|
|
||||||
* @param {string} id - Container ID.
|
|
||||||
*/
|
|
||||||
async resources(id) {
|
|
||||||
return client._request('GET', `/containers/${encodeURIComponent(id)}/resources`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a container image update is available.
|
|
||||||
* GET /api/v1/containers/:id/check-update
|
|
||||||
* @param {string} id - Container ID.
|
|
||||||
*/
|
|
||||||
async checkUpdate(id) {
|
|
||||||
return client._request('GET', `/containers/${encodeURIComponent(id)}/check-update`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Start a container. POST /api/v1/containers/:id/start */
|
|
||||||
async start(id) {
|
|
||||||
return client._request('POST', `/containers/${encodeURIComponent(id)}/start`, { body: {} });
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Stop a container. POST /api/v1/containers/:id/stop */
|
|
||||||
async stop(id) {
|
|
||||||
return client._request('POST', `/containers/${encodeURIComponent(id)}/stop`, { body: {} });
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Restart a container. POST /api/v1/containers/:id/restart */
|
|
||||||
async restart(id) {
|
|
||||||
return client._request('POST', `/containers/${encodeURIComponent(id)}/restart`, { body: {} });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a container image. POST /api/v1/containers/:id/update
|
|
||||||
* @param {string} id - Container ID.
|
|
||||||
* @param {object} [opts] - Update options.
|
|
||||||
*/
|
|
||||||
async update(id, opts) {
|
|
||||||
return client._request('POST', `/containers/${encodeURIComponent(id)}/update`, { body: opts || {} });
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Remove a container. DELETE /api/v1/containers/:id */
|
|
||||||
async remove(id) {
|
|
||||||
return client._request('DELETE', `/containers/${encodeURIComponent(id)}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DashCaddyClient} client
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function createHealthResource(client) {
|
|
||||||
return {
|
|
||||||
/** Liveness check (root-level). GET /health */
|
|
||||||
async get() {
|
|
||||||
return client._request('GET', '/health', { root: true });
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Liveness probe. GET /health/live */
|
|
||||||
async live() {
|
|
||||||
return client._request('GET', '/health/live', { root: true });
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Readiness probe. GET /health/ready */
|
|
||||||
async ready() {
|
|
||||||
return client._request('GET', '/health/ready', { root: true });
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Health status for all services. GET /api/v1/health/services */
|
|
||||||
async services() {
|
|
||||||
return client._request('GET', '/health/services');
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Cached health (no re-probe). GET /api/v1/health/cached */
|
|
||||||
async cached() {
|
|
||||||
return client._request('GET', '/health/cached');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Health for a specific service. GET /api/v1/health/service/:id
|
|
||||||
* @param {string} id - Service ID.
|
|
||||||
*/
|
|
||||||
async service(id) {
|
|
||||||
return client._request('GET', `/health/service/${encodeURIComponent(id)}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** CA certificate health. GET /api/v1/health/ca */
|
|
||||||
async ca() {
|
|
||||||
return client._request('GET', '/health/ca');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DashCaddyClient} client
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function createDnsResource(client) {
|
|
||||||
return {
|
|
||||||
/** List DNS providers. GET /api/v1/dns/providers */
|
|
||||||
async providers() {
|
|
||||||
return client._request('GET', '/dns/providers');
|
|
||||||
},
|
|
||||||
|
|
||||||
/** DNS provider status. GET /api/v1/dns/provider/status */
|
|
||||||
async providerStatus() {
|
|
||||||
return client._request('GET', '/dns/provider/status');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a DNS record. POST /api/v1/dns/record
|
|
||||||
* @param {object} record - DNS record definition.
|
|
||||||
*/
|
|
||||||
async createRecord(record) {
|
|
||||||
return client._request('POST', '/dns/record', { body: record });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a DNS record (universal path). POST /api/v1/dns/universal/record
|
|
||||||
* @param {object} record - DNS record definition.
|
|
||||||
*/
|
|
||||||
async createUniversalRecord(record) {
|
|
||||||
return client._request('POST', '/dns/universal/record', { body: record });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a DNS record. DELETE /api/v1/dns/record
|
|
||||||
* @param {object} record - Record identifier fields.
|
|
||||||
*/
|
|
||||||
async deleteRecord(record) {
|
|
||||||
return client._request('DELETE', '/dns/record', { body: record });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a DNS record. GET /api/v1/dns/resolve
|
|
||||||
* @param {object} params - Query params (domain, type).
|
|
||||||
*/
|
|
||||||
async resolve(params) {
|
|
||||||
return client._request('GET', '/dns/resolve', { query: params });
|
|
||||||
},
|
|
||||||
|
|
||||||
/** DNS credentials. GET /api/v1/dns/credentials */
|
|
||||||
async credentials() {
|
|
||||||
return client._request('GET', '/dns/credentials');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set DNS credentials. POST /api/v1/dns/credentials
|
|
||||||
* @param {object} creds - Provider credentials.
|
|
||||||
*/
|
|
||||||
async setCredentials(creds) {
|
|
||||||
return client._request('POST', '/dns/credentials', { body: creds });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check DNS propagation for a domain. GET /api/v1/dns/propagation/:domain
|
|
||||||
* @param {string} domain - Domain to check.
|
|
||||||
*/
|
|
||||||
async propagation(domain) {
|
|
||||||
return client._request('GET', `/dns/propagation/${encodeURIComponent(domain)}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DashCaddyClient} client
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function createBackupsResource(client) {
|
|
||||||
return {
|
|
||||||
/** Get backup config. GET /api/v1/backups/config */
|
|
||||||
async getConfig() {
|
|
||||||
return client._request('GET', '/backups/config');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update backup config. POST /api/v1/backups/config
|
|
||||||
* @param {object} config - Backup config patch.
|
|
||||||
*/
|
|
||||||
async updateConfig(config) {
|
|
||||||
return client._request('POST', '/backups/config', { body: config });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute an immediate backup. POST /api/v1/backups/execute
|
|
||||||
* @param {object} [opts] - Backup options.
|
|
||||||
*/
|
|
||||||
async execute(opts) {
|
|
||||||
return client._request('POST', '/backups/execute', { body: opts || {} });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get backup history. GET /api/v1/backups/history
|
|
||||||
* @param {number} [limit=50] - Max entries.
|
|
||||||
*/
|
|
||||||
async history(limit) {
|
|
||||||
const query = limit ? { limit: String(limit) } : undefined;
|
|
||||||
return client._request('GET', '/backups/history', { query });
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Get backup storage info. GET /api/v1/backups/storage-info */
|
|
||||||
async storageInfo() {
|
|
||||||
return client._request('GET', '/backups/storage-info');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore from a backup. POST /api/v1/backups/restore/:backupId
|
|
||||||
* @param {string} backupId - Backup ID.
|
|
||||||
* @param {object} [opts] - Restore options.
|
|
||||||
*/
|
|
||||||
async restore(backupId, opts) {
|
|
||||||
return client._request('POST', `/backups/restore/${encodeURIComponent(backupId)}`, { body: opts || {} });
|
|
||||||
},
|
|
||||||
|
|
||||||
/** List backup files. GET /api/v1/backups/files */
|
|
||||||
async files() {
|
|
||||||
return client._request('GET', '/backups/files');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DashCaddyClient} client
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function createConfigResource(client) {
|
|
||||||
return {
|
|
||||||
/** Get site configuration. GET /api/v1/config */
|
|
||||||
async get() {
|
|
||||||
return client._request('GET', '/config');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update site configuration. POST /api/v1/config
|
|
||||||
* @param {object} config - Config patch (merged with existing).
|
|
||||||
*/
|
|
||||||
async update(config) {
|
|
||||||
return client._request('POST', '/config', { body: config });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DashCaddyClient} client
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function createMonitoringResource(client) {
|
|
||||||
return {
|
|
||||||
/** Aggregated resource stats for all containers. GET /api/v1/monitoring/stats */
|
|
||||||
async stats() {
|
|
||||||
return client._request('GET', '/monitoring/stats');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resource stats for a specific container. GET /api/v1/monitoring/stats/:containerId
|
|
||||||
* @param {string} containerId - Container ID.
|
|
||||||
*/
|
|
||||||
async containerStats(containerId) {
|
|
||||||
return client._request('GET', `/monitoring/stats/${encodeURIComponent(containerId)}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Historical stats for a container. GET /api/v1/monitoring/history/:containerId
|
|
||||||
* @param {string} containerId - Container ID.
|
|
||||||
* @param {object} [query] - e.g. { hours: 24 } or { startTime, endTime }.
|
|
||||||
*/
|
|
||||||
async history(containerId, query) {
|
|
||||||
return client._request('GET', `/monitoring/history/${encodeURIComponent(containerId)}`, { query });
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Alert configuration. GET /api/v1/monitoring/alerts/config */
|
|
||||||
async alertConfig() {
|
|
||||||
return client._request('GET', '/monitoring/alerts/config');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update alert configuration. POST /api/v1/monitoring/alerts/config
|
|
||||||
* @param {object} config - Alert config.
|
|
||||||
*/
|
|
||||||
async updateAlertConfig(config) {
|
|
||||||
return client._request('POST', '/monitoring/alerts/config', { body: config });
|
|
||||||
},
|
|
||||||
|
|
||||||
/** List configured alerts. GET /api/v1/monitoring/alerts */
|
|
||||||
async alerts() {
|
|
||||||
return client._request('GET', '/monitoring/alerts');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main Client Class ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DashCaddy API client.
|
|
||||||
*
|
|
||||||
* Handles authentication (API key, session cookie, or TOTP session),
|
|
||||||
* automatic CSRF token management, retry on 5xx errors, and provides
|
|
||||||
* structured access to all major resource types.
|
|
||||||
*/
|
|
||||||
class DashCaddyClient {
|
class DashCaddyClient {
|
||||||
/**
|
/**
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
@@ -496,14 +59,11 @@ class DashCaddyClient {
|
|||||||
* @param {string} [options.csrfToken] - Pre-fetched CSRF token.
|
* @param {string} [options.csrfToken] - Pre-fetched CSRF token.
|
||||||
* @param {number} [options.timeout=30000] - Request timeout in ms.
|
* @param {number} [options.timeout=30000] - Request timeout in ms.
|
||||||
* @param {number} [options.maxRetries=3] - Max retries on 5xx.
|
* @param {number} [options.maxRetries=3] - Max retries on 5xx.
|
||||||
* @param {Record<string, string>} [options.headers] - Extra default headers.
|
* @param {Record<string,string>} [options.headers] - Extra default headers.
|
||||||
* @param {typeof fetch} [options.fetch] - Custom fetch implementation.
|
* @param {typeof fetch} [options.fetch] - Custom fetch implementation.
|
||||||
*/
|
*/
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
if (!options || !options.baseUrl) {
|
if (!options || !options.baseUrl) throw new Error('DashCaddyClient: baseUrl is required');
|
||||||
throw new Error('DashCaddyClient: baseUrl is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||||
this.apiKey = options.apiKey || null;
|
this.apiKey = options.apiKey || null;
|
||||||
this.sessionCookie = options.sessionCookie || null;
|
this.sessionCookie = options.sessionCookie || null;
|
||||||
@@ -512,64 +72,78 @@ class DashCaddyClient {
|
|||||||
this.maxRetries = options.maxRetries !== undefined ? options.maxRetries : DEFAULT_MAX_RETRIES;
|
this.maxRetries = options.maxRetries !== undefined ? options.maxRetries : DEFAULT_MAX_RETRIES;
|
||||||
this.extraHeaders = options.headers || {};
|
this.extraHeaders = options.headers || {};
|
||||||
this._fetchImpl = options.fetch || null;
|
this._fetchImpl = options.fetch || null;
|
||||||
|
|
||||||
// API key auth bypasses CSRF entirely
|
|
||||||
this._useApiKey = !!this.apiKey;
|
this._useApiKey = !!this.apiKey;
|
||||||
|
|
||||||
// Resource namespaces
|
// Resource namespaces — defined via compact spec tables below
|
||||||
this.services = createServicesResource(this);
|
this.services = this._buildResource(SERVICES_SPEC);
|
||||||
this.containers = createContainersResource(this);
|
this.containers = this._buildResource(CONTAINERS_SPEC);
|
||||||
this.health = createHealthResource(this);
|
this.health = this._buildResource(HEALTH_SPEC);
|
||||||
this.dns = createDnsResource(this);
|
this.dns = this._buildResource(DNS_SPEC);
|
||||||
this.backups = createBackupsResource(this);
|
this.backups = this._buildResource(BACKUPS_SPEC);
|
||||||
this.config = createConfigResource(this);
|
this.config = this._buildResource(CONFIG_SPEC);
|
||||||
this.monitoring = createMonitoringResource(this);
|
this.monitoring = this._buildResource(MONITORING_SPEC);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CSRF Token Management ──
|
/**
|
||||||
|
* Build a resource namespace from a compact method spec.
|
||||||
|
* Each spec entry: [methodName, httpMethod, pathTemplate, needsBody, isRoot]
|
||||||
|
* pathTemplate uses :param placeholders substituted from args[0..n].
|
||||||
|
* isRoot=true means the path is root-level (no /api/v1 prefix), e.g. /health.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_buildResource(spec) {
|
||||||
|
const client = this;
|
||||||
|
const obj = {};
|
||||||
|
for (const entry of spec) {
|
||||||
|
const [name, httpMethod, pathTpl, hasBody, isRoot] = entry;
|
||||||
|
obj[name] = async function (...args) {
|
||||||
|
let path = pathTpl;
|
||||||
|
// Substitute :param placeholders from positional args (strings/numbers only)
|
||||||
|
const params = pathTpl.match(/:[\w]+/g) || [];
|
||||||
|
let argIdx = 0;
|
||||||
|
for (const param of params) {
|
||||||
|
if (argIdx < args.length) {
|
||||||
|
path = path.replace(param, encodeURIComponent(String(args[argIdx++])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Body or query is the next arg after path params
|
||||||
|
const nextArg = args[argIdx];
|
||||||
|
const opts = { root: isRoot || false };
|
||||||
|
if (hasBody) opts.body = nextArg || {};
|
||||||
|
else if (nextArg && typeof nextArg === 'object') opts.query = nextArg;
|
||||||
|
return client._request(httpMethod, path, opts);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and cache a CSRF token (needed for session-cookie auth on
|
* Fetch and cache a CSRF token (session-cookie auth only).
|
||||||
* state-changing requests). Skipped automatically when using API key auth.
|
|
||||||
* @returns {Promise<string|null>}
|
* @returns {Promise<string|null>}
|
||||||
*/
|
*/
|
||||||
async ensureCsrfToken() {
|
async ensureCsrfToken() {
|
||||||
if (this._useApiKey) return null;
|
if (this._useApiKey) return null;
|
||||||
if (this._csrfToken) return this._csrfToken;
|
if (this._csrfToken) return this._csrfToken;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await this._request('GET', '/csrf-token', { _skipCsrf: true });
|
const res = await this._request('GET', '/csrf-token', { _skipCsrf: true });
|
||||||
this._csrfToken = res.token || null;
|
this._csrfToken = res.token || null;
|
||||||
return this._csrfToken;
|
return this._csrfToken;
|
||||||
} catch (_) {
|
} catch (_) { return null; }
|
||||||
// CSRF fetch failed — proceed without; server will reject if needed
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Core Request Method ──
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal: perform an authenticated API request with retry logic.
|
* Core request: builds URL + headers, handles auth, retries 5xx.
|
||||||
*
|
* @param {string} method - HTTP method.
|
||||||
* @param {string} method - HTTP method (GET, POST, PUT, DELETE, PATCH).
|
* @param {string} path - Path after API prefix (or root-level if opts.root).
|
||||||
* @param {string} path - Path after the API base (e.g. '/services').
|
* @param {object} [opts] - { body, query, root, _skipCsrf, signal }.
|
||||||
* @param {object} [opts]
|
* @returns {Promise<object>} Parsed response (success envelope spread).
|
||||||
* @param {unknown} [opts.body] - Request body (JSON-serialized).
|
|
||||||
* @param {Record<string,string>} [opts.query] - Query string params.
|
|
||||||
* @param {boolean} [opts.root=false] - If true, path is root-level (e.g. /health).
|
|
||||||
* @param {boolean} [opts._skipCsrf=false] - Internal: skip CSRF token injection.
|
|
||||||
* @param {AbortSignal} [opts.signal] - External abort signal.
|
|
||||||
* @returns {Promise<object>} The parsed response body (spread from the success envelope).
|
|
||||||
* @throws {DashCaddyError} On non-success response or network failure after retries.
|
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
async _request(method, path, opts = {}) {
|
async _request(method, path, opts = {}) {
|
||||||
const { body, query, root, _skipCsrf, signal } = opts;
|
const { body, query, root, _skipCsrf, signal } = opts;
|
||||||
|
|
||||||
// Build URL
|
// Build URL
|
||||||
const prefix = root ? HEALTH_PREFIX : API_PREFIX;
|
let url = `${this.baseUrl}${root ? '' : API_PREFIX}${path}`;
|
||||||
let url = `${this.baseUrl}${prefix}${path}`;
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const qs = new URLSearchParams(
|
const qs = new URLSearchParams(
|
||||||
Object.entries(query).filter(([, v]) => v !== undefined && v !== null)
|
Object.entries(query).filter(([, v]) => v !== undefined && v !== null)
|
||||||
@@ -577,57 +151,26 @@ class DashCaddyClient {
|
|||||||
if (qs) url += `?${qs}`;
|
if (qs) url += `?${qs}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if CSRF is needed for this request
|
// CSRF: needed for state-changing requests in session-cookie mode
|
||||||
const isStateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase());
|
const isStateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase());
|
||||||
const needsCsrf = isStateChanging && !_skipCsrf && !this._useApiKey;
|
const needsCsrf = isStateChanging && !_skipCsrf && !this._useApiKey;
|
||||||
|
|
||||||
// CSRF token: ensure we have one for state-changing requests (session auth)
|
|
||||||
let csrfToken = this._csrfToken;
|
let csrfToken = this._csrfToken;
|
||||||
if (needsCsrf && !csrfToken) {
|
if (needsCsrf && !csrfToken) csrfToken = await this.ensureCsrfToken();
|
||||||
csrfToken = await this.ensureCsrfToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build headers
|
// Headers
|
||||||
const headers = {
|
const headers = { 'Content-Type': 'application/json', ...this.extraHeaders };
|
||||||
'Content-Type': 'application/json',
|
if (this._useApiKey) headers[API_KEY_HEADER] = this.apiKey;
|
||||||
...this.extraHeaders,
|
if (this.sessionCookie) headers['Cookie'] = this.sessionCookie;
|
||||||
};
|
if (csrfToken && !_skipCsrf) headers[CSRF_HEADER] = csrfToken;
|
||||||
|
|
||||||
if (this._useApiKey) {
|
|
||||||
headers[API_KEY_HEADER] = this.apiKey;
|
|
||||||
}
|
|
||||||
if (this.sessionCookie) {
|
|
||||||
headers['Cookie'] = this.sessionCookie;
|
|
||||||
}
|
|
||||||
if (csrfToken && !_skipCsrf) {
|
|
||||||
headers[CSRF_HEADER_NAME] = csrfToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry loop
|
// Retry loop
|
||||||
let lastError = null;
|
let lastError;
|
||||||
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
const res = await rawRequest({
|
const res = await this._fetch(url, method, headers, body, signal);
|
||||||
url,
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
timeout: this.timeout,
|
|
||||||
fetchImpl: this._fetchImpl,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse response body
|
|
||||||
let json = null;
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (text) {
|
let json = null;
|
||||||
try {
|
if (text) { try { json = JSON.parse(text); } catch (_) { json = { success: res.ok, raw: text }; } }
|
||||||
json = JSON.parse(text);
|
|
||||||
} catch (_) {
|
|
||||||
// Non-JSON response — wrap it
|
|
||||||
json = { success: res.ok, raw: text };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry on 5xx
|
// Retry on 5xx
|
||||||
if (res.status >= 500 && attempt < this.maxRetries) {
|
if (res.status >= 500 && attempt < this.maxRetries) {
|
||||||
@@ -635,116 +178,149 @@ class DashCaddyClient {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check envelope
|
// Envelope check
|
||||||
if (json && json.success === false) {
|
if (json && json.success === false) {
|
||||||
const errorMsg = json.error || `Request failed with status ${res.status}`;
|
throw new DashCaddyError(json.error || `Status ${res.status}`, res.status, json.code, json);
|
||||||
throw new DashCaddyError(errorMsg, res.status, json.code, json);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok && !(json && json.success === true)) {
|
if (!res.ok && !(json && json.success === true)) {
|
||||||
const errorMsg = (json && json.error) || `HTTP ${res.status}`;
|
throw new DashCaddyError((json && json.error) || `HTTP ${res.status}`, res.status, json && json.code, json);
|
||||||
throw new DashCaddyError(errorMsg, res.status, json && json.code, json);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success — return the full envelope (minus the success flag is caller's choice)
|
|
||||||
// We return the spread data: everything except `success` for convenience,
|
|
||||||
// but also keep success for callers who want to check it.
|
|
||||||
return json || { success: true };
|
return json || { success: true };
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Network errors (AbortError, TypeError) — retry if attempts remain
|
|
||||||
if (err instanceof DashCaddyError) {
|
if (err instanceof DashCaddyError) {
|
||||||
// 5xx errors that exhausted retries are re-thrown
|
if (err.statusCode >= 500 && attempt < this.maxRetries) { lastError = err; await this._backoff(attempt); continue; }
|
||||||
if (err.statusCode >= 500 && attempt < this.maxRetries) {
|
|
||||||
lastError = err;
|
|
||||||
await this._backoff(attempt);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network-level error
|
|
||||||
lastError = err;
|
lastError = err;
|
||||||
if (attempt < this.maxRetries) {
|
if (attempt < this.maxRetries) { await this._backoff(attempt); continue; }
|
||||||
await this._backoff(attempt);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new DashCaddyError(
|
throw new DashCaddyError(
|
||||||
err.name === 'AbortError'
|
err.name === 'AbortError' ? `Timeout after ${this.timeout}ms` : `Network error: ${err.message}`,
|
||||||
? `Request timeout after ${this.timeout}ms`
|
0, 'NETWORK_ERROR', { originalError: err.message }
|
||||||
: `Network error: ${err.message}`,
|
|
||||||
0,
|
|
||||||
'NETWORK_ERROR',
|
|
||||||
{ originalError: err.message }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not reach here, but guard just in case
|
|
||||||
throw lastError || new DashCaddyError('Request failed after all retries', 0);
|
throw lastError || new DashCaddyError('Request failed after all retries', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Low-level fetch with timeout. @private */
|
||||||
* Exponential backoff with jitter.
|
async _fetch(url, method, headers, body, externalSignal) {
|
||||||
* @param {number} attempt - Current attempt number (1-based).
|
const fetchFn = this._fetchImpl || fetch;
|
||||||
* @returns {Promise<void>}
|
const controller = new AbortController();
|
||||||
* @private
|
const timer = setTimeout(() => controller.abort(), this.timeout);
|
||||||
*/
|
if (externalSignal) {
|
||||||
async _backoff(attempt) {
|
if (externalSignal.aborted) controller.abort();
|
||||||
const delay = RETRY_BACKOFF_BASE_MS * Math.pow(2, attempt - 1);
|
else externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||||
const jitter = Math.random() * delay * 0.3;
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
|
try {
|
||||||
|
return await fetchFn(url, {
|
||||||
|
method, headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth Helpers ──
|
/** Exponential backoff with jitter. @private */
|
||||||
|
async _backoff(attempt) {
|
||||||
|
const delay = RETRY_BASE_MS * Math.pow(2, attempt - 1);
|
||||||
|
await new Promise(r => setTimeout(r, delay + Math.random() * delay * 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
// ── Auth & System Helpers ──
|
||||||
* Exchange an API key for a JWT token.
|
|
||||||
* POST /api/v1/auth/jwt
|
/** Exchange API key for JWT. POST /api/v1/auth/jwt */
|
||||||
* @param {string} [apiKey] - Override the client's API key.
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
async exchangeJwt(apiKey) {
|
async exchangeJwt(apiKey) {
|
||||||
const key = apiKey || this.apiKey;
|
const key = apiKey || this.apiKey;
|
||||||
if (!key) throw new DashCaddyError('API key required for JWT exchange', 0, 'NO_API_KEY');
|
if (!key) throw new DashCaddyError('API key required', 0, 'NO_API_KEY');
|
||||||
return this._request('POST', '/auth/jwt', { body: { apiKey: key }, _skipCsrf: true });
|
return this._request('POST', '/auth/jwt', { body: { apiKey: key }, _skipCsrf: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Verify TOTP and establish session. POST /api/v1/totp/verify */
|
||||||
* Verify a TOTP code to establish a session.
|
|
||||||
* POST /api/v1/totp/verify
|
|
||||||
* @param {string} code - TOTP code from authenticator.
|
|
||||||
* @returns {Promise<object>} Includes csrfToken and ssoToken on success.
|
|
||||||
*/
|
|
||||||
async verifyTotp(code) {
|
async verifyTotp(code) {
|
||||||
const res = await this._request('POST', '/totp/verify', { body: { code }, _skipCsrf: true });
|
const res = await this._request('POST', '/totp/verify', { body: { code }, _skipCsrf: true });
|
||||||
// Cache the CSRF token returned after TOTP login
|
if (res.csrfToken) this._csrfToken = res.csrfToken;
|
||||||
if (res.csrfToken) {
|
|
||||||
this._csrfToken = res.csrfToken;
|
|
||||||
}
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Get API version. GET /api/v1/version */
|
||||||
* Get the current API version. GET /api/v1/version
|
async version() { return this._request('GET', '/version'); }
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
async version() {
|
|
||||||
return this._request('GET', '/version');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/** Get metrics summary. GET /api/v1/metrics */
|
||||||
* Get API metrics summary. GET /api/v1/metrics
|
async metrics() { return this._request('GET', '/metrics'); }
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
async metrics() {
|
|
||||||
return this._request('GET', '/metrics');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Exports ────────────────────────────────────────────────────
|
// ── Resource Specs ─────────────────────────────────────────────
|
||||||
|
// [methodName, httpMethod, pathTemplate, hasBody]
|
||||||
|
// Path params (:id) are filled from positional string/number args.
|
||||||
|
// For hasBody=true, the arg after path params is the body.
|
||||||
|
// For hasBody=false, an object arg after path params is treated as query params.
|
||||||
|
|
||||||
|
const SERVICES_SPEC = [
|
||||||
|
['list', 'GET', '/services', false],
|
||||||
|
['status', 'GET', '/services/status', false],
|
||||||
|
['create', 'POST', '/services', true],
|
||||||
|
['updateAll', 'PUT', '/services', true],
|
||||||
|
['delete', 'DELETE', '/services/:id', false],
|
||||||
|
['triggerUpdate', 'POST', '/services/update', true],
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONTAINERS_SPEC = [
|
||||||
|
['discover', 'GET', '/containers/discover', false],
|
||||||
|
['logs', 'GET', '/containers/:id/logs', false],
|
||||||
|
['resources', 'GET', '/containers/:id/resources', false],
|
||||||
|
['checkUpdate', 'GET', '/containers/:id/check-update', false],
|
||||||
|
['start', 'POST', '/containers/:id/start', true],
|
||||||
|
['stop', 'POST', '/containers/:id/stop', true],
|
||||||
|
['restart', 'POST', '/containers/:id/restart', true],
|
||||||
|
['update', 'POST', '/containers/:id/update', true],
|
||||||
|
['remove', 'DELETE', '/containers/:id', false],
|
||||||
|
];
|
||||||
|
|
||||||
|
const HEALTH_SPEC = [
|
||||||
|
['get', 'GET', '/health', false, true],
|
||||||
|
['live', 'GET', '/health/live', false, true],
|
||||||
|
['ready', 'GET', '/health/ready', false, true],
|
||||||
|
['services', 'GET', '/health/services', false],
|
||||||
|
['cached', 'GET', '/health/cached', false],
|
||||||
|
['service', 'GET', '/health/service/:id', false],
|
||||||
|
['ca', 'GET', '/health/ca', false],
|
||||||
|
];
|
||||||
|
|
||||||
|
const DNS_SPEC = [
|
||||||
|
['providers', 'GET', '/dns/providers', false],
|
||||||
|
['providerStatus', 'GET', '/dns/provider/status', false],
|
||||||
|
['createRecord', 'POST', '/dns/record', true],
|
||||||
|
['createUniversal', 'POST', '/dns/universal/record', true],
|
||||||
|
['deleteRecord', 'DELETE', '/dns/record', true],
|
||||||
|
['resolve', 'GET', '/dns/resolve', false],
|
||||||
|
['credentials', 'GET', '/dns/credentials', false],
|
||||||
|
['setCredentials', 'POST', '/dns/credentials', true],
|
||||||
|
['propagation', 'GET', '/dns/propagation/:domain', false],
|
||||||
|
];
|
||||||
|
|
||||||
|
const BACKUPS_SPEC = [
|
||||||
|
['getConfig', 'GET', '/backups/config', false],
|
||||||
|
['updateConfig', 'POST', '/backups/config', true],
|
||||||
|
['execute', 'POST', '/backups/execute', true],
|
||||||
|
['history', 'GET', '/backups/history', false],
|
||||||
|
['storageInfo', 'GET', '/backups/storage-info', false],
|
||||||
|
['restore', 'POST', '/backups/restore/:backupId', true],
|
||||||
|
['files', 'GET', '/backups/files', false],
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONFIG_SPEC = [
|
||||||
|
['get', 'GET', '/config', false],
|
||||||
|
['update', 'POST', '/config', true],
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONITORING_SPEC = [
|
||||||
|
['stats', 'GET', '/monitoring/stats', false],
|
||||||
|
['containerStats', 'GET', '/monitoring/stats/:containerId', false],
|
||||||
|
['history', 'GET', '/monitoring/history/:containerId', false],
|
||||||
|
['alertConfig', 'GET', '/monitoring/alerts/config', false],
|
||||||
|
['updateAlertConfig','POST', '/monitoring/alerts/config', true],
|
||||||
|
['alerts', 'GET', '/monitoring/alerts', false],
|
||||||
|
];
|
||||||
|
|
||||||
module.exports = { DashCaddyClient, DashCaddyError };
|
module.exports = { DashCaddyClient, DashCaddyError };
|
||||||
module.exports.DashCaddyClient = DashCaddyClient;
|
|
||||||
module.exports.DashCaddyError = DashCaddyError;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user