Skip to content

Commit 1ed5202

Browse files
feat(deploy): add deployment domain warmup utility with retry logic
Add warmup script that verifies domain availability after gh-pages deployment with configurable retry attempts and delay. Includes comprehensive test coverage for success, failure, and retry scenarios. Co-Authored-By: Hagicode <noreply@hagicode.com> Signed-off-by: newbe36524 <newbe36524@qq.com>
1 parent 9ce0641 commit 1ed5202

2 files changed

Lines changed: 402 additions & 0 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import { mkdir, writeFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import { pathToFileURL } from 'node:url';
4+
5+
export const DOCS_WARMUP_CONFIG = Object.freeze({
6+
domains: Object.freeze(['docs.472158246.workers.dev', 'docs.hagicode.com']),
7+
maxAttempts: 4,
8+
retryDelayMs: 3000,
9+
timeoutMs: 10000,
10+
});
11+
12+
export class WarmupRunError extends Error {
13+
constructor(message, result) {
14+
super(message);
15+
this.name = 'WarmupRunError';
16+
this.result = result;
17+
}
18+
}
19+
20+
function assert(condition, message) {
21+
if (!condition) {
22+
throw new Error(message);
23+
}
24+
}
25+
26+
function normalizePositiveInteger(value, fieldName) {
27+
assert(Number.isInteger(value) && value > 0, `Invalid warmup config: ${fieldName} must be a positive integer`);
28+
return value;
29+
}
30+
31+
function normalizeDomain(value) {
32+
assert(typeof value === 'string' && value.trim().length > 0, 'Invalid warmup config: domain must be a non-empty string');
33+
return value.trim().replace(/^https?:\/\//i, '').replace(/\/+$/u, '');
34+
}
35+
36+
export function normalizeWarmupConfig(config = DOCS_WARMUP_CONFIG) {
37+
assert(typeof config === 'object' && config !== null, 'Invalid warmup config: config must be an object');
38+
39+
const domains = Array.from(config.domains ?? [], (domain) => normalizeDomain(domain));
40+
assert(domains.length > 0, 'Invalid warmup config: at least one domain is required');
41+
42+
return {
43+
domains,
44+
maxAttempts: normalizePositiveInteger(config.maxAttempts, 'maxAttempts'),
45+
retryDelayMs: normalizePositiveInteger(config.retryDelayMs, 'retryDelayMs'),
46+
timeoutMs: normalizePositiveInteger(config.timeoutMs, 'timeoutMs'),
47+
};
48+
}
49+
50+
function createLogger(logger) {
51+
return {
52+
log: typeof logger?.log === 'function' ? logger.log.bind(logger) : () => {},
53+
warn: typeof logger?.warn === 'function' ? logger.warn.bind(logger) : (typeof logger?.log === 'function' ? logger.log.bind(logger) : () => {}),
54+
error: typeof logger?.error === 'function' ? logger.error.bind(logger) : (typeof logger?.warn === 'function' ? logger.warn.bind(logger) : () => {}),
55+
};
56+
}
57+
58+
function sleep(milliseconds) {
59+
return new Promise((resolve) => {
60+
setTimeout(resolve, milliseconds);
61+
});
62+
}
63+
64+
async function safeReadText(response) {
65+
try {
66+
return (await response.text()).replace(/\s+/gu, ' ').trim().slice(0, 240);
67+
} catch {
68+
return '';
69+
}
70+
}
71+
72+
function createFailureDetail(error, timeoutMs) {
73+
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
74+
return `Request timed out after ${timeoutMs}ms`;
75+
}
76+
77+
return error instanceof Error ? error.message : String(error);
78+
}
79+
80+
function formatHttpDetail(status, body = '') {
81+
return `HTTP ${status}${body ? ` - ${body}` : ''}`;
82+
}
83+
84+
function createDomainUrl(domain) {
85+
return `https://${domain}/`;
86+
}
87+
88+
function describeWarmupResult(result) {
89+
if (result.ok) {
90+
return result.retriesUsed > 0 ? 'warmed after retry' : 'warmed';
91+
}
92+
93+
return 'failed';
94+
}
95+
96+
export async function warmDomain(domain, { config = DOCS_WARMUP_CONFIG, fetchImpl = globalThis.fetch, wait = sleep, logger = console } = {}) {
97+
assert(typeof fetchImpl === 'function', 'Warmup requires a fetch implementation');
98+
assert(typeof wait === 'function', 'Warmup requires a wait implementation');
99+
100+
const normalizedConfig = normalizeWarmupConfig({
101+
...config,
102+
domains: [domain],
103+
});
104+
const normalizedDomain = normalizedConfig.domains[0];
105+
const url = createDomainUrl(normalizedDomain);
106+
const log = createLogger(logger);
107+
const attempts = [];
108+
109+
for (let attempt = 1; attempt <= normalizedConfig.maxAttempts; attempt += 1) {
110+
log.log(`[warmup] ${normalizedDomain}: attempt ${attempt}/${normalizedConfig.maxAttempts}`);
111+
112+
try {
113+
const response = await fetchImpl(url, {
114+
method: 'GET',
115+
redirect: 'manual',
116+
headers: {
117+
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
118+
'user-agent': 'hagicode-deployment-warmup/1.0',
119+
},
120+
signal: AbortSignal.timeout(normalizedConfig.timeoutMs),
121+
});
122+
const body = response.status >= 400 ? await safeReadText(response) : '';
123+
const detail = formatHttpDetail(response.status, body);
124+
const ok = response.status >= 200 && response.status < 400;
125+
126+
attempts.push({
127+
attempt,
128+
ok,
129+
status: response.status,
130+
detail,
131+
});
132+
133+
if (ok) {
134+
log.log(`[warmup] ${normalizedDomain}: success (${detail})`);
135+
return {
136+
domain: normalizedDomain,
137+
url,
138+
maxAttempts: normalizedConfig.maxAttempts,
139+
attempts,
140+
retriesUsed: attempt - 1,
141+
ok: true,
142+
finalDetail: detail,
143+
};
144+
}
145+
146+
log.warn(`[warmup] ${normalizedDomain}: ${detail}`);
147+
} catch (error) {
148+
const detail = createFailureDetail(error, normalizedConfig.timeoutMs);
149+
150+
attempts.push({
151+
attempt,
152+
ok: false,
153+
detail,
154+
error: detail,
155+
});
156+
157+
log.warn(`[warmup] ${normalizedDomain}: ${detail}`);
158+
}
159+
160+
if (attempt < normalizedConfig.maxAttempts) {
161+
log.log(`[warmup] ${normalizedDomain}: retrying in ${normalizedConfig.retryDelayMs}ms`);
162+
await wait(normalizedConfig.retryDelayMs);
163+
}
164+
}
165+
166+
const finalAttempt = attempts.at(-1);
167+
return {
168+
domain: normalizedDomain,
169+
url,
170+
maxAttempts: normalizedConfig.maxAttempts,
171+
attempts,
172+
retriesUsed: normalizedConfig.maxAttempts - 1,
173+
ok: false,
174+
finalDetail: `${finalAttempt?.detail ?? 'Unknown failure'}; retries exhausted after ${attempts.length}/${normalizedConfig.maxAttempts} attempts`,
175+
};
176+
}
177+
178+
export async function runWarmup({ config = DOCS_WARMUP_CONFIG, fetchImpl = globalThis.fetch, wait = sleep, logger = console } = {}) {
179+
const normalizedConfig = normalizeWarmupConfig(config);
180+
const domainResults = [];
181+
182+
for (const domain of normalizedConfig.domains) {
183+
domainResults.push(await warmDomain(domain, { config: normalizedConfig, fetchImpl, wait, logger }));
184+
}
185+
186+
const result = {
187+
config: normalizedConfig,
188+
domainResults,
189+
successCount: domainResults.filter((domainResult) => domainResult.ok).length,
190+
failureCount: domainResults.filter((domainResult) => !domainResult.ok).length,
191+
};
192+
193+
if (result.failureCount > 0) {
194+
const failedDomains = result.domainResults
195+
.filter((domainResult) => !domainResult.ok)
196+
.map((domainResult) => `${domainResult.domain} (${domainResult.finalDetail})`)
197+
.join('; ');
198+
throw new WarmupRunError(`Deployment warmup failed: ${failedDomains}`, result);
199+
}
200+
201+
return result;
202+
}
203+
204+
function escapeTableValue(value) {
205+
return String(value).replace(/\|/gu, '\\|').replace(/\s+/gu, ' ').trim();
206+
}
207+
208+
export function renderWarmupSummary(result) {
209+
const lines = [
210+
'## Deployment domain warmup',
211+
'- Warmup ran after gh-pages publication.',
212+
`- Successful domains: ${result.successCount}/${result.domainResults.length}`,
213+
'',
214+
'| Domain | Result | Attempts | Final detail |',
215+
'| --- | --- | --- | --- |',
216+
];
217+
218+
for (const domainResult of result.domainResults) {
219+
lines.push(
220+
`| \`${domainResult.domain}\` | ${describeWarmupResult(domainResult)} | ${domainResult.attempts.length}/${domainResult.maxAttempts} | ${escapeTableValue(domainResult.finalDetail)} |`,
221+
);
222+
}
223+
224+
if (result.failureCount > 0) {
225+
lines.push('', '- Warmup failure does not roll back the published `gh-pages` snapshot.');
226+
}
227+
228+
return `${lines.join('\n')}\n`;
229+
}
230+
231+
export async function writeWarmupSummary(summaryMarkdownPath, result) {
232+
if (!summaryMarkdownPath) {
233+
return null;
234+
}
235+
236+
await mkdir(path.dirname(summaryMarkdownPath), { recursive: true });
237+
await writeFile(summaryMarkdownPath, renderWarmupSummary(result), 'utf8');
238+
return summaryMarkdownPath;
239+
}
240+
241+
export function parseArgs(argv) {
242+
const args = Array.isArray(argv) ? [...argv] : [];
243+
let summaryMarkdownPath = null;
244+
245+
while (args.length > 0) {
246+
const current = args.shift();
247+
248+
if (current === '--summary-markdown') {
249+
const value = args.shift();
250+
assert(typeof value === 'string' && value.trim().length > 0, 'Missing value for --summary-markdown');
251+
summaryMarkdownPath = value;
252+
continue;
253+
}
254+
255+
throw new Error(`Unknown argument: ${current}`);
256+
}
257+
258+
return {
259+
summaryMarkdownPath,
260+
};
261+
}
262+
263+
export async function main(argv = process.argv.slice(2), runtime = {}) {
264+
const { summaryMarkdownPath } = parseArgs(argv);
265+
266+
try {
267+
const result = await runWarmup(runtime);
268+
await writeWarmupSummary(summaryMarkdownPath, result);
269+
return result;
270+
} catch (error) {
271+
if (error instanceof WarmupRunError) {
272+
await writeWarmupSummary(summaryMarkdownPath, error.result);
273+
}
274+
275+
throw error;
276+
}
277+
}
278+
279+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
280+
main().catch((error) => {
281+
console.error(error instanceof Error ? error.message : String(error));
282+
process.exitCode = 1;
283+
});
284+
}

0 commit comments

Comments
 (0)