Repository navigation
Expand file tree
/
Copy pathlaunchClient.ts
More file actions
106 lines (95 loc) · 4.14 KB
/
Copy pathlaunchClient.ts
File metadata and controls
106 lines (95 loc) · 4.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import type { Config } from './config.js';
import { ApiError, AppError, ExitCode, parseErrorBody } from './errors.js';
import { silentLogger, type Logger } from './logger.js';
/** A Launch domain, as returned by the list endpoint. */
export interface LaunchDomain {
uid: string;
url: string;
isCustomDomain?: boolean;
sslMethod?: string | null;
customCertificateExpiresAt?: string | null;
domainInfo?: { status?: string; domainStatusDescription?: string; sslStatus?: string } | null;
}
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** Client for the Launch domains API: …/projects/{project}/environments/{env}/domains */
export class LaunchClient {
private readonly base: string;
constructor(
private readonly config: Config,
private readonly logger: Logger = silentLogger,
private readonly fetchImpl: FetchLike = (url, init) => fetch(url, init),
) {
this.base = `${config.host}/projects/${encodeURIComponent(config.projectUid)}/environments/${encodeURIComponent(config.environmentUid)}/domains`;
logger.addSecret(config.authtoken);
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {
accept: 'application/json',
authtoken: this.config.authtoken,
organization_uid: this.config.orgUid,
};
if (body !== undefined) headers['content-type'] = 'application/json';
this.logger.debug(`→ ${method} ${path}`, body === undefined ? { headers } : { headers, body });
// GET is safe to retry on network errors and 429/5xx; uploads are sent once.
const attempts = method === 'PUT' ? 1 : 3;
for (let attempt = 1; ; attempt++) {
let res: Response;
try {
res = await this.fetchImpl(`${this.base}${path}`, {
method,
headers,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
if (attempt < attempts) {
await sleep(1000 * attempt);
continue;
}
throw new AppError(`${method} ${path} failed: ${(err as Error).message}`, ExitCode.API_ERROR, 'Check LAUNCH_HOST and your network.');
}
const text = await res.text();
this.logger.debug(`← ${res.status} ${method} ${path}`, text ? safeJson(text) : undefined);
if (res.ok) return (text ? JSON.parse(text) : undefined) as T;
if ((res.status === 429 || res.status >= 500) && attempt < attempts) {
await sleep(1000 * attempt);
continue;
}
const { code, message } = parseErrorBody(text);
throw new ApiError(res.status, code, message && this.logger.redactString(message), `${method} ${path}`);
}
}
/** All domains in the environment (the API returns at most 100 per page). */
async listDomains(): Promise<LaunchDomain[]> {
const all: LaunchDomain[] = [];
for (let skip = 0; ; skip += 100) {
const page = await this.request<{ domains?: LaunchDomain[] }>('GET', `?limit=100&skip=${skip}`);
const domains = page.domains ?? [];
all.push(...domains);
if (domains.length < 100 || skip > 10_000) return all;
}
}
async findDomain(url: string): Promise<LaunchDomain | undefined> {
const wanted = url.toLowerCase();
return (await this.listDomains()).find((d) => d.url.toLowerCase() === wanted);
}
/** Upload a certificate. The domain's existing url is sent unchanged: changing it would rename the domain. */
async uploadCertificate(domain: LaunchDomain, cert: { leaf: string; chain: string; key: string }): Promise<void> {
this.logger.addSecret(cert.key);
await this.request('PUT', `/${encodeURIComponent(domain.uid)}`, {
url: domain.url,
sslMethod: 'CUSTOM', // without it Launch silently ignores the certificate
customCertificate: cert.leaf,
customPrivateKey: cert.key,
...(cert.chain ? { intermediateCertificates: cert.chain } : {}),
});
}
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text.slice(0, 500);
}
}