Files
dashcaddy/dashcaddy-api/src/utils/http.js
T
Hermes 7bc2a207f3 DC-005: Fix all 138 broken test paths after src/ refactor
After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:

1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths

Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.

Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)

Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
2026-06-13 12:16:56 -07:00

183 lines
5.8 KiB
JavaScript

/**
* HTTP utilities - Fetch helpers and HTTP operations
*/
const http = require('http');
const https = require('https');
const { TIMEOUTS } = require('../utilities/constants');
// HTTPS agent that trusts internal CA certs (self-signed .sami TLD etc.)
// Lazy-initialized singleton to avoid creating a new agent per request.
let _internalHttpsAgent;
function getInternalHttpsAgent() {
if (!_internalHttpsAgent) {
_internalHttpsAgent = new https.Agent({ rejectUnauthorized: false });
}
return _internalHttpsAgent;
}
/**
* Fetch with automatic timeout
* Drop-in replacement for fetch() with AbortSignal timeout
*
* Handles two cases where native fetch() doesn't work:
* 1. Caddy admin API (:2019) - rejects undici fetch
* 2. HTTPS with self-signed certs (.sami TLD) - undici can't use Node's tls agent
*/
function fetchT(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
// Caddy admin API rejects Node.js undici fetch - use raw http.request
if (url.includes(':2019')) {
return _httpFetch(url, opts, timeoutMs);
}
// HTTPS with self-signed certs: use raw https.request with rejectUnauthorized:false
// Node.js native fetch() (undici) ignores the `agent` option and always validates certs.
if (url.startsWith('https://')) {
return _httpsFetch(url, opts, timeoutMs);
}
if (!opts.signal) {
opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) };
}
// The `timeout` key in fetch() opts is silently ignored by undici. Callers
// should use the third arg of fetchT() (timeoutMs) instead. If a caller
// passes `timeout: N` here, it's almost certainly a bug — we used to silently
// strip it, which masked the issue. Now we surface it in logs and strip it.
if ('timeout' in opts) {
console.warn(`[fetchT] opts.timeout=${opts.timeout} is ignored — pass timeoutMs as the 3rd arg of fetchT() instead. Called from: ${new Error().stack.split('\n').slice(2, 4).join(' <- ')}`);
const { timeout: _timeout, ...rest } = opts;
opts = rest;
}
return fetch(url, opts);
}
/**
* Raw https.request wrapper for self-signed cert support
* Node.js native fetch() (undici) cannot be configured with rejectUnauthorized:false,
* so we use the standard https module for internal HTTPS endpoints.
*/
function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const options = {
hostname: parsed.hostname,
port: parsed.port || 443,
path: parsed.pathname + parsed.search,
method: (opts.method || 'GET').toUpperCase(),
headers: { ...opts.headers },
timeout: timeoutMs,
agent: getInternalHttpsAgent(),
};
if (opts.body && !options.headers['Content-Length']) {
options.headers['Content-Length'] = Buffer.byteLength(opts.body);
}
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const req = https.request(options, (res) => {
let data = '';
let size = 0;
res.on('data', chunk => {
size += chunk.length;
if (size > MAX_RESPONSE_SIZE) {
res.destroy();
reject(new Error(`Response from ${url} exceeded ${MAX_RESPONSE_SIZE} bytes`));
return;
}
data += chunk;
});
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
statusText: res.statusMessage,
json: () => Promise.resolve(JSON.parse(data)),
text: () => Promise.resolve(data),
headers: {
get: (k) => res.headers[k.toLowerCase()],
getSetCookie: () => {
const sc = res.headers['set-cookie'];
if (!sc) return [];
return Array.isArray(sc) ? sc : [sc];
}
},
});
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`));
});
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
/**
* Raw http.request wrapper for Caddy admin API
*/
function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const options = {
hostname: parsed.hostname,
port: parsed.port || 2019,
path: parsed.pathname + parsed.search,
method: (opts.method || 'GET').toUpperCase(),
headers: { ...opts.headers },
timeout: timeoutMs,
};
if (opts.body) {
options.headers['Content-Length'] = Buffer.byteLength(opts.body);
}
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const req = http.request(options, (res) => {
let data = '';
let size = 0;
res.on('data', chunk => {
size += chunk.length;
if (size > MAX_RESPONSE_SIZE) {
res.destroy();
reject(new Error(`Response from ${url} exceeded ${MAX_RESPONSE_SIZE} bytes`));
return;
}
data += chunk;
});
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
statusText: res.statusMessage,
json: () => Promise.resolve(JSON.parse(data)),
text: () => Promise.resolve(data),
headers: {
get: (k) => res.headers[k.toLowerCase()],
getSetCookie: () => {
const sc = res.headers['set-cookie'];
if (!sc) return [];
return Array.isArray(sc) ? sc : [sc];
}
},
});
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`));
});
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
module.exports = { fetchT };