Files
dashcaddy/sdks/js/dashcaddy-client.js
T
Hermes 2595b6a456
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
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.
2026-08-12 12:49:10 -07:00

327 lines
13 KiB
JavaScript

/**
* DashCaddy JavaScript Client — lightweight SDK for the DashCaddy API.
*
* Zero external dependencies. Works in Node.js 18+ (uses global fetch).
* Full TypeScript definitions in types.d.ts.
*
* @example
* const { DashCaddyClient } = require('./dashcaddy-client');
*
* // API key auth (simplest — no CSRF needed)
* const client = new DashCaddyClient({
* baseUrl: 'https://status.sami',
* apiKey: 'dk_abc123_xyz'
* });
*
* // Session cookie auth (CSRF handled automatically)
* const client2 = new DashCaddyClient({
* baseUrl: 'https://status.sami',
* sessionCookie: 'sid=...'
* });
*
* const services = await client.services.list(); // GET /api/v1/services
* const health = await client.health.get(); // GET /health
* const { containers } = await client.containers.discover();
* await client.dns.createRecord({ type: 'A', domain: 'app.sami', value: '10.0.0.1' });
* const { backup } = await client.backups.execute();
*
* @license MIT
*/
'use strict';
const DEFAULT_TIMEOUT = 30000;
const DEFAULT_MAX_RETRIES = 3;
const RETRY_BASE_MS = 500;
const API_PREFIX = '/api/v1';
const CSRF_HEADER = 'x-csrf-token';
const API_KEY_HEADER = 'x-api-key';
/** Error thrown on non-success API responses or network failures after retries. */
class DashCaddyError extends Error {
constructor(message, statusCode, code, details) {
super(message);
this.name = 'DashCaddyError';
this.statusCode = statusCode || 0;
this.code = code;
this.details = details;
}
}
// ── Client ─────────────────────────────────────────────────────
class DashCaddyClient {
/**
* @param {object} options
* @param {string} options.baseUrl - Base URL, e.g. 'https://status.sami'.
* @param {string} [options.apiKey] - API key (dk_<id>_<secret>). Bypasses CSRF.
* @param {string} [options.sessionCookie] - Session cookie value for cookie auth.
* @param {string} [options.csrfToken] - Pre-fetched CSRF token.
* @param {number} [options.timeout=30000] - Request timeout in ms.
* @param {number} [options.maxRetries=3] - Max retries on 5xx.
* @param {Record<string,string>} [options.headers] - Extra default headers.
* @param {typeof fetch} [options.fetch] - Custom fetch implementation.
*/
constructor(options) {
if (!options || !options.baseUrl) throw new Error('DashCaddyClient: baseUrl is required');
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.apiKey = options.apiKey || null;
this.sessionCookie = options.sessionCookie || null;
this._csrfToken = options.csrfToken || null;
this.timeout = options.timeout || DEFAULT_TIMEOUT;
this.maxRetries = options.maxRetries !== undefined ? options.maxRetries : DEFAULT_MAX_RETRIES;
this.extraHeaders = options.headers || {};
this._fetchImpl = options.fetch || null;
this._useApiKey = !!this.apiKey;
// Resource namespaces — defined via compact spec tables below
this.services = this._buildResource(SERVICES_SPEC);
this.containers = this._buildResource(CONTAINERS_SPEC);
this.health = this._buildResource(HEALTH_SPEC);
this.dns = this._buildResource(DNS_SPEC);
this.backups = this._buildResource(BACKUPS_SPEC);
this.config = this._buildResource(CONFIG_SPEC);
this.monitoring = this._buildResource(MONITORING_SPEC);
}
/**
* 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 (session-cookie auth only).
* @returns {Promise<string|null>}
*/
async ensureCsrfToken() {
if (this._useApiKey) return null;
if (this._csrfToken) return this._csrfToken;
try {
const res = await this._request('GET', '/csrf-token', { _skipCsrf: true });
this._csrfToken = res.token || null;
return this._csrfToken;
} catch (_) { return null; }
}
/**
* Core request: builds URL + headers, handles auth, retries 5xx.
* @param {string} method - HTTP method.
* @param {string} path - Path after API prefix (or root-level if opts.root).
* @param {object} [opts] - { body, query, root, _skipCsrf, signal }.
* @returns {Promise<object>} Parsed response (success envelope spread).
* @private
*/
async _request(method, path, opts = {}) {
const { body, query, root, _skipCsrf, signal } = opts;
// Build URL
let url = `${this.baseUrl}${root ? '' : API_PREFIX}${path}`;
if (query) {
const qs = new URLSearchParams(
Object.entries(query).filter(([, v]) => v !== undefined && v !== null)
).toString();
if (qs) url += `?${qs}`;
}
// CSRF: needed for state-changing requests in session-cookie mode
const isStateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase());
const needsCsrf = isStateChanging && !_skipCsrf && !this._useApiKey;
let csrfToken = this._csrfToken;
if (needsCsrf && !csrfToken) csrfToken = await this.ensureCsrfToken();
// Headers
const headers = { 'Content-Type': 'application/json', ...this.extraHeaders };
if (this._useApiKey) headers[API_KEY_HEADER] = this.apiKey;
if (this.sessionCookie) headers['Cookie'] = this.sessionCookie;
if (csrfToken && !_skipCsrf) headers[CSRF_HEADER] = csrfToken;
// Retry loop
let lastError;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const res = await this._fetch(url, method, headers, body, signal);
const text = await res.text();
let json = null;
if (text) { try { json = JSON.parse(text); } catch (_) { json = { success: res.ok, raw: text }; } }
// Retry on 5xx
if (res.status >= 500 && attempt < this.maxRetries) {
await this._backoff(attempt);
continue;
}
// Envelope check
if (json && json.success === false) {
throw new DashCaddyError(json.error || `Status ${res.status}`, res.status, json.code, json);
}
if (!res.ok && !(json && json.success === true)) {
throw new DashCaddyError((json && json.error) || `HTTP ${res.status}`, res.status, json && json.code, json);
}
return json || { success: true };
} catch (err) {
if (err instanceof DashCaddyError) {
if (err.statusCode >= 500 && attempt < this.maxRetries) { lastError = err; await this._backoff(attempt); continue; }
throw err;
}
lastError = err;
if (attempt < this.maxRetries) { await this._backoff(attempt); continue; }
throw new DashCaddyError(
err.name === 'AbortError' ? `Timeout after ${this.timeout}ms` : `Network error: ${err.message}`,
0, 'NETWORK_ERROR', { originalError: err.message }
);
}
}
throw lastError || new DashCaddyError('Request failed after all retries', 0);
}
/** Low-level fetch with timeout. @private */
async _fetch(url, method, headers, body, externalSignal) {
const fetchFn = this._fetchImpl || fetch;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeout);
if (externalSignal) {
if (externalSignal.aborted) controller.abort();
else externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
try {
return await fetchFn(url, {
method, headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
} finally { clearTimeout(timer); }
}
/** 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 API key for JWT. POST /api/v1/auth/jwt */
async exchangeJwt(apiKey) {
const key = apiKey || this.apiKey;
if (!key) throw new DashCaddyError('API key required', 0, 'NO_API_KEY');
return this._request('POST', '/auth/jwt', { body: { apiKey: key }, _skipCsrf: true });
}
/** Verify TOTP and establish session. POST /api/v1/totp/verify */
async verifyTotp(code) {
const res = await this._request('POST', '/totp/verify', { body: { code }, _skipCsrf: true });
if (res.csrfToken) this._csrfToken = res.csrfToken;
return res;
}
/** Get API version. GET /api/v1/version */
async version() { return this._request('GET', '/version'); }
/** Get metrics summary. GET /api/v1/metrics */
async metrics() { return this._request('GET', '/metrics'); }
}
// ── 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 };