feat(map): add cache-first Cesium gateway and AMD egress
This commit is contained in:
@@ -0,0 +1,987 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { link, mkdir, open, readFile, unlink } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import https from "node:https";
|
||||
import { connect as connectNet } from "node:net";
|
||||
import { dirname } from "node:path";
|
||||
import { connect as connectTls } from "node:tls";
|
||||
|
||||
const allowedHosts = new Set([
|
||||
"api.cesium.com",
|
||||
"assets.ion.cesium.com",
|
||||
"dev.virtualearth.net",
|
||||
"ecn.t0.tiles.virtualearth.net",
|
||||
"ecn.t1.tiles.virtualearth.net",
|
||||
"ecn.t2.tiles.virtualearth.net",
|
||||
"ecn.t3.tiles.virtualearth.net",
|
||||
]);
|
||||
const connectionTimingSymbol = Symbol("nodedc.connection-timing");
|
||||
const config = await readConfig();
|
||||
const transport = createTransport(config);
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url || "/", "http://localhost");
|
||||
try {
|
||||
if (request.method === "GET" && ["/healthz", "/status"].includes(url.pathname)) return writeJson(response, 200, await statusBody());
|
||||
if (request.method === "POST" && url.pathname === "/api/pair") return await pairConnector(request, response);
|
||||
if (["GET", "HEAD"].includes(request.method || "") && url.pathname === "/proxy/cesium/fetch") return await forwardCesiumRequest(request, response, url);
|
||||
return writeJson(response, 404, { ok: false, error: "not_found" });
|
||||
} catch (error) {
|
||||
if (response.destroyed || response.headersSent || response.writableEnded) {
|
||||
if (!response.writableEnded) response.destroy();
|
||||
return;
|
||||
}
|
||||
const errorCode = safeError(error);
|
||||
const status = Number(error?.statusCode || 502);
|
||||
console.warn(JSON.stringify({ event: "cesium_egress_request_failed", error: errorCode, status, ...telemetryLogFields(error?.telemetry) }));
|
||||
return writeJson(response, status, { ok: false, error: errorCode }, { "x-nodedc-egress-error": errorCode });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(config.port, config.bindAddress, () => console.log(JSON.stringify({ event: "dc_amd_proxy_started", bindAddress: config.bindAddress, port: config.port, connector: `${config.connectorHost}:${config.connectorPort}`, directEgress: false })));
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => server.close(() => transport.close(() => process.exit(0))));
|
||||
|
||||
async function readConfig() {
|
||||
const nodeEnv = String(process.env.NODE_ENV || "production").trim();
|
||||
return {
|
||||
port: parsePort(process.env.PORT, 8790),
|
||||
bindAddress: parseIpv4(process.env.DC_AMD_PROXY_BIND_ADDRESS || "172.22.0.222", "bind_address"),
|
||||
connectorHost: parseIpv4(process.env.DC_AMD_CONNECTOR_HOST || "172.22.0.183", "connector_host"),
|
||||
connectorPort: parsePort(process.env.DC_AMD_CONNECTOR_PORT, 8791),
|
||||
pairAllowedSource: parseIpv4(process.env.DC_AMD_PAIR_ALLOWED_SOURCE || "172.22.0.183", "pair_allowed_source"),
|
||||
connectorTokenFile: String(process.env.DC_AMD_CONNECTOR_TOKEN_FILE || "/var/lib/dc-amd-proxy/connector-access").trim(),
|
||||
mapToken: Buffer.from(await readSecretFile(process.env.DC_AMD_MAP_EGRESS_TOKEN_FILE || "/run/nodedc-secrets/map-egress-proxy-token", "map_egress_token"), "utf8"),
|
||||
connectTimeoutMs: parseDuration(process.env.DC_AMD_CONNECT_TIMEOUT_SECONDS, 20),
|
||||
upstreamTimeoutMs: parseDuration(process.env.DC_AMD_UPSTREAM_TIMEOUT_SECONDS, 30),
|
||||
bodyIdleTimeoutMs: parseDuration(process.env.DC_AMD_BODY_IDLE_TIMEOUT_SECONDS, 30),
|
||||
poolMaxSockets: parsePoolSize(process.env.DC_AMD_POOL_MAX_SOCKETS, 8),
|
||||
poolMaxFreeSockets: parsePoolSize(process.env.DC_AMD_POOL_MAX_FREE_SOCKETS, 4),
|
||||
slowRequestMs: parseDuration(process.env.DC_AMD_SLOW_REQUEST_SECONDS, 2),
|
||||
allowInsecureTls: nodeEnv === "test" && parseBoolean(process.env.DC_AMD_PROXY_TEST_ALLOW_INSECURE_TLS, false),
|
||||
};
|
||||
}
|
||||
|
||||
async function statusBody() {
|
||||
const paired = Boolean(await readConnectorToken());
|
||||
return {
|
||||
ok: true,
|
||||
service: "dc-amd-proxy",
|
||||
state: paired ? "paired" : "awaiting_pair",
|
||||
forwarding: paired ? "amd_connector_only" : "disabled",
|
||||
directEgress: false,
|
||||
connector: `${config.connectorHost}:${config.connectorPort}`,
|
||||
transport: transport.status(),
|
||||
};
|
||||
}
|
||||
|
||||
async function pairConnector(request, response) {
|
||||
if (normalizeAddress(request.socket.remoteAddress) !== config.pairAllowedSource) return writeJson(response, 403, { ok: false, error: "pair_source_not_allowed" });
|
||||
if (await readConnectorToken()) return writeJson(response, 409, { ok: false, error: "already_paired" });
|
||||
const body = await readJsonBody(request, 1024);
|
||||
const token = String(body?.connectorAccessToken || "").trim();
|
||||
if (!isSecret(token)) return writeJson(response, 400, { ok: false, error: "connector_access_token_invalid" });
|
||||
await persistConnectorToken(token);
|
||||
console.log(JSON.stringify({ event: "amd_connector_paired" }));
|
||||
return writeJson(response, 201, { ok: true, state: "paired" });
|
||||
}
|
||||
|
||||
async function forwardCesiumRequest(request, response, requestUrl) {
|
||||
if (!isMapAuthorized(request.headers["x-proxy-token"])) return writeJson(response, 401, { ok: false, error: "map_egress_unauthorized" });
|
||||
const target = parseTarget(requestUrl.searchParams.get("url") || "");
|
||||
const startedAt = Date.now();
|
||||
const terminal = transport.beginRequest(target.hostname);
|
||||
const controller = new AbortController();
|
||||
let telemetry = emptyTelemetry();
|
||||
let upstream;
|
||||
let responseBytes = 0;
|
||||
let upstreamEnded = false;
|
||||
let downstreamFinished = false;
|
||||
let clientAborted = false;
|
||||
let terminalFinished = false;
|
||||
let bodyIdleTimer;
|
||||
let abortUpstream;
|
||||
const clearBodyIdleTimer = () => {
|
||||
if (bodyIdleTimer) clearTimeout(bodyIdleTimer);
|
||||
bodyIdleTimer = undefined;
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearBodyIdleTimer();
|
||||
request.off("aborted", onClientAbort);
|
||||
response.off("close", onClientAbort);
|
||||
if (abortUpstream) controller.signal.removeEventListener("abort", abortUpstream);
|
||||
};
|
||||
const finish = (outcome) => {
|
||||
if (terminalFinished) return false;
|
||||
terminalFinished = terminal.complete({
|
||||
...outcome,
|
||||
durationMs: Date.now() - startedAt,
|
||||
bytes: responseBytes,
|
||||
telemetry,
|
||||
});
|
||||
if (terminalFinished) cleanup();
|
||||
return terminalFinished;
|
||||
};
|
||||
function onClientAbort() {
|
||||
if (downstreamFinished || terminalFinished) return;
|
||||
clientAborted = true;
|
||||
if (!controller.signal.aborted) controller.abort(abortError());
|
||||
// Before headers, requestOnce owns the active/queued ClientRequest and its
|
||||
// signal. The await/catch below records its complete attempt telemetry.
|
||||
if (upstream) finish({ type: "client-abort" });
|
||||
}
|
||||
const armBodyIdleTimer = () => {
|
||||
clearBodyIdleTimer();
|
||||
bodyIdleTimer = setTimeout(() => {
|
||||
const error = typedError("amd_upstream_body_idle_timeout", 502);
|
||||
error.code = "ETIMEDOUT";
|
||||
upstream.destroy(error);
|
||||
}, config.bodyIdleTimeoutMs);
|
||||
bodyIdleTimer.unref?.();
|
||||
};
|
||||
request.once("aborted", onClientAbort);
|
||||
response.once("close", onClientAbort);
|
||||
|
||||
try {
|
||||
const connectorToken = await readConnectorToken();
|
||||
if (clientAborted || controller.signal.aborted) {
|
||||
finish({ type: "client-abort" });
|
||||
return;
|
||||
}
|
||||
if (!connectorToken) {
|
||||
finish({ type: "request-failure", errorCode: "amd_connector_not_paired" });
|
||||
return writeJson(response, 503, { ok: false, error: "amd_connector_not_paired" }, { "x-nodedc-egress-error": "amd_connector_not_paired" });
|
||||
}
|
||||
const result = await requestThroughAmd(target, request.method || "GET", upstreamHeaders(request), controller.signal);
|
||||
upstream = result.upstream;
|
||||
telemetry = result.telemetry;
|
||||
if (clientAborted || controller.signal.aborted) {
|
||||
upstream.destroy();
|
||||
finish({ type: "client-abort" });
|
||||
return;
|
||||
}
|
||||
abortUpstream = () => {
|
||||
if (!upstreamEnded && !upstream.destroyed) upstream.destroy(abortError());
|
||||
};
|
||||
controller.signal.addEventListener("abort", abortUpstream, { once: true });
|
||||
if (controller.signal.aborted) abortUpstream();
|
||||
|
||||
upstream.on("data", (chunk) => {
|
||||
responseBytes += chunk.length;
|
||||
armBodyIdleTimer();
|
||||
});
|
||||
upstream.once("end", () => {
|
||||
upstreamEnded = true;
|
||||
clearBodyIdleTimer();
|
||||
});
|
||||
upstream.once("error", (error) => {
|
||||
upstreamEnded = true;
|
||||
clearBodyIdleTimer();
|
||||
if (!finish({ type: clientAborted ? "client-abort" : "stream-failure", errorCode: safeTransportError(error) })) return;
|
||||
if (!clientAborted) {
|
||||
console.warn(JSON.stringify({ event: "cesium_egress_stream_failed", host: target.hostname, error: safeTransportError(error), bytes: responseBytes }));
|
||||
}
|
||||
if (!response.destroyed) response.destroy();
|
||||
});
|
||||
armBodyIdleTimer();
|
||||
response.writeHead(upstream.statusCode || 502, safeResponseHeaders(upstream.headers));
|
||||
response.once("finish", () => {
|
||||
downstreamFinished = true;
|
||||
clearBodyIdleTimer();
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const status = Number(upstream.statusCode || 502);
|
||||
if (!finish({ type: "response", status })) return;
|
||||
// Normal tile traffic is intentionally not logged per object. Slow or
|
||||
// failing responses are sufficient to diagnose VPN/transport regressions
|
||||
// without creating a noisy, credential-bearing request log.
|
||||
if (durationMs >= config.slowRequestMs || status >= 400) {
|
||||
console.warn(JSON.stringify({
|
||||
event: "cesium_egress_slow_or_failed",
|
||||
host: target.hostname,
|
||||
status,
|
||||
durationMs,
|
||||
queueMs: telemetry.queueMs,
|
||||
connectionMs: telemetry.connectionMs,
|
||||
ttfbMs: telemetry.ttfbMs,
|
||||
retryMs: telemetry.retryMs,
|
||||
attempts: telemetry.attempts,
|
||||
bytes: responseBytes,
|
||||
reusedSocket: telemetry.reusedSocket,
|
||||
retries: telemetry.retries,
|
||||
}));
|
||||
}
|
||||
});
|
||||
upstream.pipe(response);
|
||||
} catch (error) {
|
||||
telemetry = mergeTelemetry(telemetry, error?.telemetry);
|
||||
if (clientAborted || controller.signal.aborted) {
|
||||
finish({ type: "client-abort" });
|
||||
return;
|
||||
}
|
||||
if (upstream && !upstream.destroyed) upstream.destroy();
|
||||
finish({ type: "request-failure", errorCode: safeTransportError(error) });
|
||||
error.telemetry = telemetry;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestThroughAmd(initialTarget, method, headers, signal) {
|
||||
let target = initialTarget;
|
||||
let requestHeaders = { ...headers };
|
||||
let aggregate = emptyTelemetry();
|
||||
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
||||
let result;
|
||||
try {
|
||||
result = await requestWithSafeRetry(target, method, requestHeaders, signal);
|
||||
} catch (error) {
|
||||
error.telemetry = mergeTelemetry(aggregate, error?.telemetry);
|
||||
throw error;
|
||||
}
|
||||
const { upstream } = result;
|
||||
aggregate = mergeTelemetry(aggregate, result.telemetry);
|
||||
const statusCode = Number(upstream.statusCode || 502);
|
||||
const location = Array.isArray(upstream.headers.location) ? upstream.headers.location[0] : upstream.headers.location;
|
||||
if ([301, 302, 303, 307, 308].includes(statusCode) && location) {
|
||||
try {
|
||||
aggregate.attemptMs += await drainRedirect(upstream, signal);
|
||||
const redirectedTarget = parseTarget(new URL(location, target).toString());
|
||||
if (redirectedTarget.origin !== target.origin) requestHeaders = withoutAuthorization(requestHeaders);
|
||||
target = redirectedTarget;
|
||||
} catch (error) {
|
||||
aggregate.attemptMs += Math.max(0, Number(error?.redirectDrainMs || 0));
|
||||
error.telemetry = aggregate;
|
||||
throw error;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return { upstream, telemetry: aggregate };
|
||||
}
|
||||
const error = typedError("amd_upstream_redirect_limit", 502);
|
||||
error.telemetry = aggregate;
|
||||
throw error;
|
||||
}
|
||||
|
||||
function withoutAuthorization(headers) {
|
||||
const output = { ...headers };
|
||||
delete output.authorization;
|
||||
return output;
|
||||
}
|
||||
|
||||
function drainRedirect(upstream, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
let finished = false;
|
||||
let idleTimer;
|
||||
const cleanup = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
upstream.off("data", armIdleTimer);
|
||||
upstream.off("end", onEnd);
|
||||
upstream.off("aborted", onUpstreamAbort);
|
||||
upstream.off("error", onError);
|
||||
};
|
||||
const complete = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
cleanup();
|
||||
resolve(Date.now() - startedAt);
|
||||
};
|
||||
const fail = (error) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
cleanup();
|
||||
error.redirectDrainMs = Date.now() - startedAt;
|
||||
upstream.destroy();
|
||||
reject(error);
|
||||
};
|
||||
const armIdleTimer = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
const error = typedError("amd_upstream_redirect_body_idle_timeout", 502);
|
||||
error.code = "ETIMEDOUT";
|
||||
fail(error);
|
||||
}, config.bodyIdleTimeoutMs);
|
||||
idleTimer.unref?.();
|
||||
};
|
||||
const onAbort = () => fail(abortReason(signal));
|
||||
const onEnd = () => complete();
|
||||
const onUpstreamAbort = () => {
|
||||
const error = typedError("amd_upstream_redirect_aborted", 502);
|
||||
error.code = "ECONNRESET";
|
||||
fail(error);
|
||||
};
|
||||
const onError = (error) => fail(error);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
upstream.on("data", armIdleTimer);
|
||||
upstream.once("end", onEnd);
|
||||
upstream.once("aborted", onUpstreamAbort);
|
||||
upstream.once("error", onError);
|
||||
if (signal.aborted) onAbort();
|
||||
else {
|
||||
armIdleTimer();
|
||||
upstream.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function requestWithSafeRetry(target, method, headers, signal) {
|
||||
let retries = 0;
|
||||
let aggregate = emptyTelemetry();
|
||||
while (true) {
|
||||
try {
|
||||
const result = await requestOnce(target, method, headers, signal);
|
||||
aggregate = mergeTelemetry(aggregate, result.telemetry);
|
||||
aggregate.retries = retries;
|
||||
if (retries > 0) {
|
||||
console.warn(JSON.stringify({
|
||||
event: "cesium_egress_retry_completed",
|
||||
host: target.hostname,
|
||||
attempt: retries + 1,
|
||||
status: Number(result.upstream.statusCode || 502),
|
||||
...telemetryLogFields(result.telemetry),
|
||||
totalAttemptMs: aggregate.attemptMs,
|
||||
}));
|
||||
}
|
||||
return { upstream: result.upstream, telemetry: aggregate };
|
||||
} catch (error) {
|
||||
const failedAttemptTelemetry = error?.telemetry;
|
||||
aggregate = mergeTelemetry(aggregate, failedAttemptTelemetry);
|
||||
if (signal.aborted || retries >= 1 || !["GET", "HEAD"].includes(method) || error?.reusedSocket !== true || !isRetryableSocketError(error)) {
|
||||
if (retries > 0) {
|
||||
console.warn(JSON.stringify({
|
||||
event: "cesium_egress_retry_failed",
|
||||
host: target.hostname,
|
||||
attempt: retries + 1,
|
||||
error: safeTransportError(error),
|
||||
...telemetryLogFields(failedAttemptTelemetry),
|
||||
totalAttemptMs: aggregate.attemptMs,
|
||||
}));
|
||||
}
|
||||
error.telemetry = aggregate;
|
||||
throw error;
|
||||
}
|
||||
retries += 1;
|
||||
aggregate.retryMs += Number(error?.telemetry?.attemptMs || 0);
|
||||
aggregate.retries = retries;
|
||||
const errorCode = safeTransportError(error);
|
||||
transport.recordRetry(target.hostname, errorCode);
|
||||
console.warn(JSON.stringify({
|
||||
event: "cesium_egress_reused_socket_retry",
|
||||
host: target.hostname,
|
||||
attempt: retries,
|
||||
error: errorCode,
|
||||
...telemetryLogFields(failedAttemptTelemetry),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requestOnce(target, method, headers, externalSignal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
let socketAssignedAt = null;
|
||||
let socketTiming = null;
|
||||
let reusedSocket = false;
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
const pendingConnectionTiming = { startedAt: null, readyAt: null };
|
||||
const attemptController = new AbortController();
|
||||
const abortAttempt = () => {
|
||||
if (!attemptController.signal.aborted) attemptController.abort(abortReason(externalSignal));
|
||||
};
|
||||
if (externalSignal.aborted) abortAttempt();
|
||||
else externalSignal.addEventListener("abort", abortAttempt, { once: true });
|
||||
const client = https.request({
|
||||
protocol: "https:",
|
||||
hostname: target.hostname,
|
||||
port: 443,
|
||||
method,
|
||||
path: `${target.pathname || "/"}${target.search || ""}`,
|
||||
headers,
|
||||
agent: transport.agent,
|
||||
signal: attemptController.signal,
|
||||
nodedcAbortSignal: attemptController.signal,
|
||||
nodedcConnectionTiming: pendingConnectionTiming,
|
||||
});
|
||||
transport.observeQueuePeak();
|
||||
queueMicrotask(() => transport.observeQueuePeak());
|
||||
const timeoutError = typedError("amd_upstream_timeout", 502);
|
||||
timeoutError.code = "ETIMEDOUT";
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (!attemptController.signal.aborted) attemptController.abort(timeoutError);
|
||||
}, config.upstreamTimeoutMs);
|
||||
timeout.unref?.();
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
externalSignal.removeEventListener("abort", abortAttempt);
|
||||
};
|
||||
const attemptTelemetry = (endedAt, receivedHeaders, error) => {
|
||||
const failedConnectionTiming = error?.[connectionTimingSymbol];
|
||||
const timing = socketTiming || failedConnectionTiming || (pendingConnectionTiming.startedAt ? pendingConnectionTiming : null);
|
||||
const newConnection = !reusedSocket && timing;
|
||||
const queueEnd = newConnection?.startedAt || socketAssignedAt || endedAt;
|
||||
const connectionMs = newConnection ? Math.max(0, Number(newConnection.readyAt || endedAt) - Number(newConnection.startedAt || endedAt)) : 0;
|
||||
return {
|
||||
queueMs: Math.max(0, Number(queueEnd) - startedAt),
|
||||
connectionMs,
|
||||
ttfbMs: receivedHeaders && socketAssignedAt ? Math.max(0, endedAt - socketAssignedAt) : 0,
|
||||
attemptMs: Math.max(0, endedAt - startedAt),
|
||||
retryMs: 0,
|
||||
attempts: 1,
|
||||
retries: 0,
|
||||
reusedSocket,
|
||||
};
|
||||
};
|
||||
client.once("socket", (socket) => {
|
||||
socketAssignedAt = Date.now();
|
||||
socketTiming = socket[connectionTimingSymbol] || null;
|
||||
const priorAssignments = Number(socketTiming?.assignments || 0);
|
||||
reusedSocket = client.reusedSocket === true || priorAssignments > 0;
|
||||
if (socketTiming) socketTiming.assignments = priorAssignments + 1;
|
||||
});
|
||||
client.once("error", (rawError) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
const error = timedOut && !externalSignal.aborted ? timeoutError : externalSignal.aborted ? abortReason(externalSignal) : rawError;
|
||||
if (rawError?.[connectionTimingSymbol] && !error[connectionTimingSymbol]) error[connectionTimingSymbol] = rawError[connectionTimingSymbol];
|
||||
error.reusedSocket = reusedSocket || client.reusedSocket === true;
|
||||
error.telemetry = attemptTelemetry(Date.now(), false, error);
|
||||
reject(error);
|
||||
});
|
||||
client.once("response", (upstream) => {
|
||||
if (settled) {
|
||||
upstream.destroy();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
const endedAt = Date.now();
|
||||
resolve({
|
||||
upstream,
|
||||
telemetry: attemptTelemetry(endedAt, true),
|
||||
});
|
||||
});
|
||||
client.end();
|
||||
transport.observeQueuePeak();
|
||||
});
|
||||
}
|
||||
|
||||
function emptyTelemetry() {
|
||||
return { queueMs: 0, connectionMs: 0, ttfbMs: 0, attemptMs: 0, retryMs: 0, attempts: 0, retries: 0, reusedSocket: false };
|
||||
}
|
||||
|
||||
function telemetryLogFields(telemetry) {
|
||||
if (!telemetry) return {};
|
||||
return {
|
||||
queueMs: Math.max(0, Number(telemetry.queueMs || 0)),
|
||||
connectionMs: Math.max(0, Number(telemetry.connectionMs || 0)),
|
||||
ttfbMs: Math.max(0, Number(telemetry.ttfbMs || 0)),
|
||||
attemptMs: Math.max(0, Number(telemetry.attemptMs || 0)),
|
||||
retryMs: Math.max(0, Number(telemetry.retryMs || 0)),
|
||||
attempts: Math.max(0, Number(telemetry.attempts || 0)),
|
||||
retries: Math.max(0, Number(telemetry.retries || 0)),
|
||||
reusedSocket: telemetry.reusedSocket === true,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeTelemetry(left, right) {
|
||||
const merged = { ...emptyTelemetry(), ...(left || {}) };
|
||||
if (!right) return merged;
|
||||
for (const name of ["queueMs", "connectionMs", "ttfbMs", "attemptMs", "retryMs", "attempts", "retries"]) {
|
||||
if (name === "retries") merged[name] = Math.max(Number(merged[name] || 0), Number(right[name] || 0));
|
||||
else merged[name] += Math.max(0, Number(right[name] || 0));
|
||||
}
|
||||
merged.reusedSocket ||= right.reusedSocket === true;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function createTransport(runtimeConfig) {
|
||||
const metrics = {
|
||||
openedTunnels: 0,
|
||||
tunnelFailures: 0,
|
||||
requests: 0,
|
||||
terminalRequests: 0,
|
||||
inFlightRequests: 0,
|
||||
completedResponses: 0,
|
||||
failedRequests: 0,
|
||||
slowRequests: 0,
|
||||
responseBytes: 0,
|
||||
partialBytes: 0,
|
||||
reusedSocketRequests: 0,
|
||||
retries: 0,
|
||||
streamFailures: 0,
|
||||
clientAborts: 0,
|
||||
maxQueuedRequests: 0,
|
||||
timing: {
|
||||
queueMs: { total: 0, max: 0 },
|
||||
connectionMs: { total: 0, max: 0 },
|
||||
ttfbMs: { total: 0, max: 0 },
|
||||
attemptMs: { total: 0, max: 0 },
|
||||
retryMs: { total: 0, max: 0 },
|
||||
durationMs: { total: 0, max: 0 },
|
||||
},
|
||||
byHost: {},
|
||||
lastFailure: null,
|
||||
lastFailureAt: null,
|
||||
};
|
||||
const agent = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30_000,
|
||||
maxSockets: runtimeConfig.poolMaxSockets,
|
||||
maxFreeSockets: runtimeConfig.poolMaxFreeSockets,
|
||||
scheduling: "lifo",
|
||||
});
|
||||
// https.Agent maintains a separate pool per origin. A Cesium/Bing tile
|
||||
// therefore reuses an already authenticated NAS -> AMD -> VPN -> TLS path
|
||||
// for its host instead of paying a fresh CONNECT and TLS handshake.
|
||||
agent.createConnection = (options, callback) => {
|
||||
const hostname = String(options.servername || options.hostname || options.host || "").toLowerCase();
|
||||
const signal = options.nodedcAbortSignal;
|
||||
const pendingConnectionTiming = options.nodedcConnectionTiming;
|
||||
void (async () => {
|
||||
if (!allowedHosts.has(hostname)) throw typedError("cesium_target_not_allowed", 403);
|
||||
const connectorToken = await readConnectorToken();
|
||||
if (!connectorToken) throw typedError("amd_connector_not_paired", 503);
|
||||
const startedAt = Date.now();
|
||||
if (pendingConnectionTiming) pendingConnectionTiming.startedAt = startedAt;
|
||||
let connectedAt = startedAt;
|
||||
try {
|
||||
const tunnel = await openConnectorTunnel({ hostname }, connectorToken, signal);
|
||||
connectedAt = Date.now();
|
||||
const secureSocket = await openTlsTunnel(tunnel, hostname, signal);
|
||||
const readyAt = Date.now();
|
||||
if (pendingConnectionTiming) pendingConnectionTiming.readyAt = readyAt;
|
||||
secureSocket[connectionTimingSymbol] = { startedAt, readyAt, assignments: 0 };
|
||||
metrics.openedTunnels += 1;
|
||||
console.log(JSON.stringify({
|
||||
event: "cesium_egress_tunnel_opened",
|
||||
host: hostname,
|
||||
connectorMs: connectedAt - startedAt,
|
||||
tlsMs: readyAt - connectedAt,
|
||||
}));
|
||||
callback(null, secureSocket);
|
||||
} catch (error) {
|
||||
const readyAt = Date.now();
|
||||
if (pendingConnectionTiming) pendingConnectionTiming.readyAt = readyAt;
|
||||
error[connectionTimingSymbol] = { startedAt, readyAt, assignments: 0 };
|
||||
throw error;
|
||||
}
|
||||
})().catch((error) => {
|
||||
const errorCode = safeError(error);
|
||||
if (error?.code !== "ABORT_ERR") {
|
||||
metrics.tunnelFailures += 1;
|
||||
metrics.lastFailure = errorCode;
|
||||
metrics.lastFailureAt = new Date().toISOString();
|
||||
}
|
||||
callback(error);
|
||||
});
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
agent,
|
||||
close(callback) { agent.destroy(); callback(); },
|
||||
beginRequest(host) {
|
||||
const hostMetrics = perHostMetrics(metrics, host);
|
||||
metrics.requests += 1;
|
||||
hostMetrics.requests += 1;
|
||||
metrics.inFlightRequests += 1;
|
||||
hostMetrics.inFlightRequests += 1;
|
||||
let finished = false;
|
||||
return {
|
||||
complete({ type, status = 0, durationMs = 0, bytes = 0, telemetry = emptyTelemetry(), errorCode = "" }) {
|
||||
if (finished) return false;
|
||||
finished = true;
|
||||
metrics.terminalRequests += 1;
|
||||
metrics.inFlightRequests = Math.max(0, metrics.inFlightRequests - 1);
|
||||
hostMetrics.terminalRequests += 1;
|
||||
hostMetrics.inFlightRequests = Math.max(0, hostMetrics.inFlightRequests - 1);
|
||||
const safeBytes = Math.max(0, Number(bytes || 0));
|
||||
if (telemetry.reusedSocket) {
|
||||
metrics.reusedSocketRequests += 1;
|
||||
hostMetrics.reusedSocketRequests += 1;
|
||||
}
|
||||
observe(metrics.timing.queueMs, telemetry.queueMs);
|
||||
observe(metrics.timing.connectionMs, telemetry.connectionMs);
|
||||
observe(metrics.timing.ttfbMs, telemetry.ttfbMs);
|
||||
observe(metrics.timing.attemptMs, telemetry.attemptMs);
|
||||
observe(metrics.timing.retryMs, telemetry.retryMs);
|
||||
observe(metrics.timing.durationMs, durationMs);
|
||||
if (type === "response") {
|
||||
metrics.completedResponses += 1;
|
||||
hostMetrics.completedResponses += 1;
|
||||
metrics.responseBytes += safeBytes;
|
||||
hostMetrics.bytes += safeBytes;
|
||||
} else {
|
||||
metrics.partialBytes += safeBytes;
|
||||
hostMetrics.partialBytes += safeBytes;
|
||||
}
|
||||
if (type === "client-abort") {
|
||||
metrics.clientAborts += 1;
|
||||
hostMetrics.clientAborts += 1;
|
||||
}
|
||||
const failure = type === "stream-failure" || type === "request-failure" || type === "response" && Number(status) >= 400;
|
||||
if (failure) {
|
||||
metrics.failedRequests += 1;
|
||||
hostMetrics.failedRequests += 1;
|
||||
if (type === "stream-failure") {
|
||||
metrics.streamFailures += 1;
|
||||
hostMetrics.streamFailures += 1;
|
||||
}
|
||||
metrics.lastFailure = errorCode || `upstream_http_${status}`;
|
||||
metrics.lastFailureAt = new Date().toISOString();
|
||||
hostMetrics.lastTransportError = errorCode || `upstream_http_${status}`;
|
||||
}
|
||||
if (type !== "client-abort" && durationMs >= runtimeConfig.slowRequestMs) metrics.slowRequests += 1;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
recordRetry(host, errorCode) {
|
||||
metrics.retries += 1;
|
||||
const hostMetrics = perHostMetrics(metrics, host);
|
||||
hostMetrics.retries += 1;
|
||||
hostMetrics.lastTransportError = errorCode;
|
||||
},
|
||||
observeQueuePeak() {
|
||||
const queuedRequests = socketCount(agent.requests);
|
||||
metrics.maxQueuedRequests = Math.max(metrics.maxQueuedRequests, queuedRequests);
|
||||
},
|
||||
status() {
|
||||
const queuedRequests = socketCount(agent.requests);
|
||||
metrics.maxQueuedRequests = Math.max(metrics.maxQueuedRequests, queuedRequests);
|
||||
return {
|
||||
pool: {
|
||||
activeSockets: socketCount(agent.sockets),
|
||||
idleSockets: socketCount(agent.freeSockets),
|
||||
queuedRequests,
|
||||
maxQueuedRequests: metrics.maxQueuedRequests,
|
||||
maxSockets: runtimeConfig.poolMaxSockets,
|
||||
maxFreeSockets: runtimeConfig.poolMaxFreeSockets,
|
||||
byOrigin: poolByOrigin(agent),
|
||||
},
|
||||
metrics: publicTransportMetrics(metrics),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function perHostMetrics(metrics, host) {
|
||||
const safeHost = allowedHosts.has(String(host || "").toLowerCase()) ? String(host).toLowerCase() : "unknown";
|
||||
metrics.byHost[safeHost] ||= {
|
||||
requests: 0,
|
||||
terminalRequests: 0,
|
||||
inFlightRequests: 0,
|
||||
completedResponses: 0,
|
||||
failedRequests: 0,
|
||||
reusedSocketRequests: 0,
|
||||
retries: 0,
|
||||
streamFailures: 0,
|
||||
clientAborts: 0,
|
||||
bytes: 0,
|
||||
partialBytes: 0,
|
||||
lastTransportError: null,
|
||||
};
|
||||
return metrics.byHost[safeHost];
|
||||
}
|
||||
|
||||
function observe(bucket, rawValue) {
|
||||
const value = Math.max(0, Number(rawValue || 0));
|
||||
bucket.total += value;
|
||||
bucket.max = Math.max(bucket.max, value);
|
||||
}
|
||||
|
||||
function publicTransportMetrics(metrics) {
|
||||
const average = (bucket) => metrics.terminalRequests ? Math.round(bucket.total / metrics.terminalRequests) : 0;
|
||||
return {
|
||||
openedTunnels: metrics.openedTunnels,
|
||||
tunnelFailures: metrics.tunnelFailures,
|
||||
requests: metrics.requests,
|
||||
terminalRequests: metrics.terminalRequests,
|
||||
inFlightRequests: metrics.inFlightRequests,
|
||||
completedResponses: metrics.completedResponses,
|
||||
failedRequests: metrics.failedRequests,
|
||||
slowRequests: metrics.slowRequests,
|
||||
responseBytes: metrics.responseBytes,
|
||||
partialBytes: metrics.partialBytes,
|
||||
reusedSocketRequests: metrics.reusedSocketRequests,
|
||||
retries: metrics.retries,
|
||||
streamFailures: metrics.streamFailures,
|
||||
clientAborts: metrics.clientAborts,
|
||||
timing: {
|
||||
queueMs: { average: average(metrics.timing.queueMs), max: metrics.timing.queueMs.max },
|
||||
connectionMs: { average: average(metrics.timing.connectionMs), max: metrics.timing.connectionMs.max },
|
||||
ttfbMs: { average: average(metrics.timing.ttfbMs), max: metrics.timing.ttfbMs.max },
|
||||
attemptMs: { average: average(metrics.timing.attemptMs), max: metrics.timing.attemptMs.max },
|
||||
retryMs: { average: average(metrics.timing.retryMs), max: metrics.timing.retryMs.max },
|
||||
durationMs: { average: average(metrics.timing.durationMs), max: metrics.timing.durationMs.max },
|
||||
},
|
||||
byHost: metrics.byHost,
|
||||
lastFailure: metrics.lastFailure,
|
||||
lastFailureAt: metrics.lastFailureAt,
|
||||
};
|
||||
}
|
||||
|
||||
function poolByOrigin(agent) {
|
||||
const origins = {};
|
||||
for (const [key, sockets] of Object.entries(agent.sockets)) poolOrigin(origins, key).active += sockets.length;
|
||||
for (const [key, sockets] of Object.entries(agent.freeSockets)) poolOrigin(origins, key).idle += sockets.length;
|
||||
for (const [key, requests] of Object.entries(agent.requests)) poolOrigin(origins, key).queued += requests.length;
|
||||
return origins;
|
||||
}
|
||||
|
||||
function poolOrigin(origins, agentKey) {
|
||||
const host = String(agentKey || "").split(":")[0].toLowerCase();
|
||||
const safeHost = allowedHosts.has(host) ? host : "unknown";
|
||||
origins[safeHost] ||= { active: 0, idle: 0, queued: 0 };
|
||||
return origins[safeHost];
|
||||
}
|
||||
|
||||
function openConnectorTunnel(target, connectorToken, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(abortReason(signal));
|
||||
return;
|
||||
}
|
||||
const socket = connectNet({ host: config.connectorHost, port: config.connectorPort });
|
||||
let buffer = Buffer.alloc(0);
|
||||
let finished = false;
|
||||
const timeoutError = typedError("amd_connector_timeout", 502);
|
||||
timeoutError.code = "ETIMEDOUT";
|
||||
const timeout = setTimeout(() => fail(timeoutError), config.connectTimeoutMs);
|
||||
timeout.unref?.();
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
socket.off("connect", onConnect);
|
||||
socket.off("error", fail);
|
||||
socket.off("data", onData);
|
||||
};
|
||||
const finish = (value) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
const fail = (error) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
cleanup();
|
||||
socket.destroy();
|
||||
reject(error);
|
||||
};
|
||||
const onAbort = () => fail(abortReason(signal));
|
||||
const onConnect = () => socket.write(`CONNECT ${target.hostname}:443 HTTP/1.1\r\nHost: ${target.hostname}:443\r\nProxy-Authorization: Bearer ${connectorToken}\r\n\r\n`);
|
||||
const onData = (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
if (buffer.length > 16 * 1024) return fail(typedError("amd_connector_response_invalid", 502));
|
||||
const end = buffer.indexOf("\r\n\r\n");
|
||||
if (end < 0) return;
|
||||
if (!/^HTTP\/1\.[01] 200\b/.test(buffer.subarray(0, end).toString("ascii"))) return fail(typedError("amd_connector_rejected", 502));
|
||||
const remainder = buffer.subarray(end + 4);
|
||||
if (remainder.length) socket.unshift(remainder);
|
||||
finish(socket);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
socket.once("connect", onConnect);
|
||||
socket.on("error", fail);
|
||||
socket.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
function openTlsTunnel(socket, hostname, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
socket.destroy();
|
||||
reject(abortReason(signal));
|
||||
return;
|
||||
}
|
||||
const secureSocket = connectTls({ socket, servername: hostname, ALPNProtocols: ["http/1.1"], rejectUnauthorized: !config.allowInsecureTls });
|
||||
let finished = false;
|
||||
const timeoutError = typedError("amd_upstream_tls_timeout", 502);
|
||||
timeoutError.code = "ETIMEDOUT";
|
||||
const timeout = setTimeout(() => fail(timeoutError), config.connectTimeoutMs);
|
||||
timeout.unref?.();
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
secureSocket.off("secureConnect", finish);
|
||||
secureSocket.off("error", fail);
|
||||
};
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
cleanup();
|
||||
resolve(secureSocket);
|
||||
};
|
||||
const fail = (error) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
cleanup();
|
||||
secureSocket.destroy();
|
||||
reject(error);
|
||||
};
|
||||
const onAbort = () => fail(abortReason(signal));
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
secureSocket.once("secureConnect", finish);
|
||||
secureSocket.once("error", fail);
|
||||
});
|
||||
}
|
||||
|
||||
function upstreamHeaders(request) {
|
||||
// Do not force `Connection: close`: the shared Agent owns a deliberately
|
||||
// bounded keep-alive pool for the approved upstream hosts.
|
||||
const headers = { accept: safeHeader(request.headers.accept, "*/*"), "accept-encoding": "identity" };
|
||||
for (const name of ["range", "if-none-match", "if-modified-since"]) {
|
||||
const value = safeHeader(request.headers[name], "");
|
||||
if (value) headers[name] = value;
|
||||
}
|
||||
const authorization = safeHeader(request.headers["x-nodedc-cesium-authorization"], "");
|
||||
if (authorization) headers.authorization = authorization;
|
||||
const referer = safeHeader(request.headers["x-nodedc-map-referer"], "");
|
||||
if (referer) headers.referer = referer;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function safeResponseHeaders(headers) {
|
||||
const output = {};
|
||||
const skipped = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
|
||||
for (const [name, raw] of Object.entries(headers)) {
|
||||
if (skipped.has(name.toLowerCase()) || raw === undefined) continue;
|
||||
const value = Array.isArray(raw) ? raw.join(", ") : String(raw);
|
||||
if (value.length <= 8192 && !/[\r\n\u0000]/.test(value)) output[name] = value;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseTarget(value) {
|
||||
let target;
|
||||
try { target = new URL(String(value)); } catch { throw typedError("cesium_target_invalid", 400); }
|
||||
if (target.protocol !== "https:" || target.username || target.password || target.port && target.port !== "443" || !allowedHosts.has(target.hostname.toLowerCase())) throw typedError("cesium_target_not_allowed", 403);
|
||||
target.hostname = target.hostname.toLowerCase();
|
||||
return target;
|
||||
}
|
||||
|
||||
async function readConnectorToken() {
|
||||
try {
|
||||
const token = (await readFile(config.connectorTokenFile, "utf8")).trim();
|
||||
return isSecret(token) ? token : null;
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw typedError("connector_pair_state_unreadable", 500);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistConnectorToken(token) {
|
||||
await mkdir(dirname(config.connectorTokenFile), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${config.connectorTokenFile}.${process.pid}.${Date.now()}.tmp`;
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(temporary, "wx", 0o600);
|
||||
await handle.writeFile(`${token}\n`, "ascii");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = null;
|
||||
// link(2) is the compare-and-set: unlike rename(), it cannot overwrite an
|
||||
// existing pair state. Two concurrent pair attempts therefore produce one
|
||||
// durable secret and one harmless 409 response.
|
||||
try {
|
||||
await link(temporary, config.connectorTokenFile);
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") throw typedError("already_paired", 409);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
if (handle) await handle.close();
|
||||
await unlink(temporary).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function readSecretFile(path, label) {
|
||||
let value;
|
||||
try { value = (await readFile(String(path), "utf8")).trim(); } catch { throw new Error(`${label}_unreadable`); }
|
||||
if (!value || value.length > 4096 || /[\u0000-\u001f\u007f\s]/.test(value)) throw new Error(`${label}_invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function isMapAuthorized(raw) {
|
||||
if (Array.isArray(raw)) return false;
|
||||
const candidate = Buffer.from(String(raw || ""), "utf8");
|
||||
return candidate.length === config.mapToken.length && timingSafeEqual(candidate, config.mapToken);
|
||||
}
|
||||
|
||||
function readJsonBody(request, maxBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let bytes = 0;
|
||||
request.on("data", (chunk) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > maxBytes) return reject(typedError("pair_payload_too_large", 413));
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.once("error", reject);
|
||||
request.once("end", () => {
|
||||
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch { reject(typedError("pair_payload_invalid", 400)); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function writeJson(response, status, body, extraHeaders = {}) {
|
||||
const payload = JSON.stringify(body);
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(payload),
|
||||
"cache-control": "no-store",
|
||||
...extraHeaders,
|
||||
});
|
||||
response.end(payload);
|
||||
}
|
||||
|
||||
function parsePort(raw, fallback) {
|
||||
const value = Number(String(raw || fallback).trim());
|
||||
if (!Number.isInteger(value) || value < 1024 || value > 65535) throw new Error("invalid_port");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseDuration(raw, fallbackSeconds) {
|
||||
const value = Number(String(raw || fallbackSeconds).trim());
|
||||
if (!Number.isInteger(value) || value < 1 || value > 120) throw new Error("invalid_duration");
|
||||
return value * 1000;
|
||||
}
|
||||
|
||||
function parsePoolSize(raw, fallback) {
|
||||
const value = Number(String(raw || fallback).trim());
|
||||
if (!Number.isInteger(value) || value < 1 || value > 32) throw new Error("invalid_pool_size");
|
||||
return value;
|
||||
}
|
||||
|
||||
function socketCount(table) {
|
||||
return Object.values(table).reduce((total, sockets) => total + (Array.isArray(sockets) ? sockets.length : 0), 0);
|
||||
}
|
||||
|
||||
function parseIpv4(raw, label) {
|
||||
const value = String(raw || "").trim();
|
||||
const octets = value.split(".").map(Number);
|
||||
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) throw new Error(`${label}_invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeAddress(value) { return String(value || "").replace(/^::ffff:/, ""); }
|
||||
function safeHeader(raw, fallback) {
|
||||
if (Array.isArray(raw)) return fallback;
|
||||
const value = String(raw || "").trim();
|
||||
return value.length <= 4096 && !/[\r\n\u0000]/.test(value) ? value : fallback;
|
||||
}
|
||||
function parseBoolean(value, fallback) { return value === undefined || value === "" ? fallback : ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); }
|
||||
function isSecret(value) { return /^[A-Za-z0-9_-]{48,256}$/.test(value); }
|
||||
function typedError(message, statusCode) { const error = new Error(message); error.statusCode = statusCode; return error; }
|
||||
function abortError() { const error = typedError("amd_client_aborted", 499); error.code = "ABORT_ERR"; return error; }
|
||||
function abortReason(signal) { return signal?.reason instanceof Error ? signal.reason : abortError(); }
|
||||
function safeError(error) { return String(error?.message || "dc_amd_proxy_error").replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120); }
|
||||
function isRetryableSocketError(error) { return new Set(["ECONNRESET", "EPIPE", "ETIMEDOUT", "ECONNABORTED"]).has(String(error?.code || "").toUpperCase()); }
|
||||
function safeTransportError(error) {
|
||||
const code = String(error?.code || "").trim().toLowerCase();
|
||||
if (/^[a-z0-9_]{1,48}$/.test(code)) return `amd_upstream_${code}`;
|
||||
return safeError(error);
|
||||
}
|
||||
Reference in New Issue
Block a user