Files
multi-simadmin/apps/api/src/infrastructure/transport/pinned-http-requester.ts
T
chick f9186bd851 feat(api): absorb the Hub control plane into the local instance model
Add central notification channels, rules, queue and delivery logs, fleet
organization groups and tags, device discovery, the device action catalog,
instance module reads, the log centre, connection settings and system
maintenance as native /api/v1 routes backed by the existing secret store,
audit trail and pinned upstream transport.
2026-09-05 18:53:04 +08:00

111 lines
3.9 KiB
TypeScript

import * as http from 'node:http';
import * as https from 'node:https';
import type { LookupFunction } from 'node:net';
import type {
PinnedRequest,
ResolvedAddress,
TransportResponse,
} from './safe-instance-transport.js';
import { asUpstreamError, UpstreamError } from './upstream-error.js';
export interface PinnedDispatchOptions {
readonly url: URL;
readonly method: 'GET' | 'POST' | 'DELETE';
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
readonly lookup: LookupFunction;
readonly followRedirects: false;
readonly timeoutMs: number;
readonly maxBodyBytes: number;
}
export interface PinnedHttpRequesterOptions {
readonly dispatch?: (options: PinnedDispatchOptions) => Promise<TransportResponse>;
readonly timeoutMs?: number;
readonly maxBodyBytes?: number;
}
const DEFAULT_TIMEOUT_MS = 5_000;
const DEFAULT_MAX_BODY_BYTES = 65_536;
const lookupFor = (addresses: readonly ResolvedAddress[]): LookupFunction => {
const first = addresses[0];
if (!first) throw new Error('No validated address');
return (_hostname, _options, callback) => callback(null, first.address, first.family);
};
export class PinnedHttpRequester {
private readonly dispatch: (options: PinnedDispatchOptions) => Promise<TransportResponse>;
private readonly timeoutMs: number;
private readonly maxBodyBytes: number;
constructor(options: PinnedHttpRequesterOptions = {}) {
this.dispatch = options.dispatch ?? dispatchNative;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
}
async request(
request: PinnedRequest,
addresses: readonly ResolvedAddress[],
): Promise<TransportResponse> {
try {
return await this.dispatch({
url: new URL(request.url),
method: request.method,
headers: request.headers,
...(request.body === undefined ? {} : { body: request.body }),
lookup: lookupFor(addresses),
followRedirects: false,
timeoutMs: this.timeoutMs,
maxBodyBytes: this.maxBodyBytes,
});
} catch (error) {
throw asUpstreamError(error);
}
}
}
const dispatchNative = (options: PinnedDispatchOptions): Promise<TransportResponse> =>
new Promise((resolve, reject) => {
const client = options.url.protocol === 'https:' ? https : http;
const request = client.request({
protocol: options.url.protocol,
hostname: options.url.hostname,
port: options.url.port || undefined,
path: `${options.url.pathname}${options.url.search}`,
method: options.method,
headers: options.headers,
lookup: options.lookup,
agent: false,
timeout: options.timeoutMs,
maxHeaderSize: 16_384,
...(options.url.protocol === 'https:' ? { servername: options.url.hostname } : {}),
});
let settled = false;
const fail = (error: Error): void => {
if (settled) return;
settled = true;
request.destroy(error);
reject(error);
};
request.once('timeout', () => fail(new UpstreamError('UPSTREAM_TIMEOUT')));
request.once('error', fail);
request.once('response', (response) => {
const chunks: Buffer[] = [];
let bytes = 0;
response.on('data', (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > options.maxBodyBytes) fail(new UpstreamError('UPSTREAM_RESPONSE_TOO_LARGE'));
else chunks.push(chunk);
});
response.once('error', fail);
response.once('end', () => {
if (settled) return;
settled = true;
const headers: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(response.headers))
headers[key] = Array.isArray(value) ? value.join(', ') : value;
resolve({
status: response.statusCode ?? 0,
headers,
body: Buffer.concat(chunks).toString('utf8'),
});
});
});
request.end(options.body);
});