#!/usr/bin/env node import { readPositiveIntegerEnv } from './env-utils.mjs'; import { validateSpaceUrl } from './hf-space-doctor-utils.mjs'; const DEFAULT_SPACE_URL = 'https://misonl-visual-journal.hf.space'; const KEEPALIVE_USER_AGENT = 'visual-journal-keepalive/1.0'; const DEFAULT_KEEPALIVE_PATH = '/api/auth-status'; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_ATTEMPTS = 1; const DEFAULT_RETRY_DELAY_MS = 5_000; const DEFAULT_RETRY_MAX_DELAY_MS = 30_000; function normalizeUrl(rawUrl, path) { const urlError = validateSpaceUrl(rawUrl); if (urlError) { throw new Error(urlError.replace('HF Space URL', 'HF_SPACE_KEEPALIVE_URL')); } const baseUrl = new URL(rawUrl); const normalizedPath = path.startsWith('/') ? path : `/${path}`; return new URL(normalizedPath, baseUrl).toString(); } function readExpectedPasswordRequired() { const rawValue = process.env.HF_SPACE_KEEPALIVE_EXPECT_PASSWORD_REQUIRED?.trim(); if (!rawValue) return undefined; if (rawValue === 'true') return true; if (rawValue === 'false') return false; throw new Error('HF_SPACE_KEEPALIVE_EXPECT_PASSWORD_REQUIRED must be true or false'); } function readResponseContentType(response) { return response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() || 'unknown'; } async function readJsonResponse(response) { const contentType = readResponseContentType(response); const text = await response.text(); if (!text) return { body: undefined, contentType, bodyType: 'empty' }; try { return { body: JSON.parse(text), contentType, bodyType: 'json' }; } catch { return { body: undefined, contentType, bodyType: 'non-json' }; } } function readKeepaliveConfig() { const spaceUrl = process.env.HF_SPACE_KEEPALIVE_URL?.trim() || DEFAULT_SPACE_URL; const path = process.env.HF_SPACE_KEEPALIVE_PATH?.trim() || DEFAULT_KEEPALIVE_PATH; const timeoutMs = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS, 1_000); const maxAttempts = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_MAX_ATTEMPTS', DEFAULT_MAX_ATTEMPTS); const retryDelayMs = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_RETRY_DELAY_MS', DEFAULT_RETRY_DELAY_MS); const retryMaxDelayMs = readPositiveIntegerEnv( 'HF_SPACE_KEEPALIVE_RETRY_MAX_DELAY_MS', Math.max(DEFAULT_RETRY_MAX_DELAY_MS, retryDelayMs), retryDelayMs ); const expectedPasswordRequired = readExpectedPasswordRequired(); const url = normalizeUrl(spaceUrl, path); return { url, timeoutMs, maxAttempts, retryDelayMs, retryMaxDelayMs, expectedPasswordRequired }; } function formatKeepaliveError(error, timeoutMs) { if (error?.name === 'AbortError') { return `Keepalive request timed out after ${timeoutMs}ms`; } return error instanceof Error ? error.message : String(error); } async function waitBeforeRetry(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } function getRetryDelayMs(config, completedAttempts) { const multiplier = 2 ** Math.min(completedAttempts - 1, 30); return Math.min(config.retryDelayMs * multiplier, config.retryMaxDelayMs); } async function pingKeepaliveEndpointOnce(config, attemptLabel) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.timeoutMs); const startedAt = Date.now(); try { const response = await fetch(config.url, { headers: { 'User-Agent': KEEPALIVE_USER_AGENT }, signal: controller.signal }); const elapsedMs = Date.now() - startedAt; const result = await readJsonResponse(response); if (!response.ok) { throw new Error( `Keepalive endpoint failed with HTTP ${response.status} (${result.contentType}; ${result.bodyType} body)` ); } if (result.bodyType === 'non-json') { throw new Error(`Keepalive endpoint returned non-JSON body (${result.contentType})`); } if ( config.expectedPasswordRequired !== undefined && result.body?.passwordRequired !== config.expectedPasswordRequired ) { throw new Error( `passwordRequired expected ${config.expectedPasswordRequired}, got ${result.body?.passwordRequired}` ); } console.log( JSON.stringify( { ok: true, url: config.url, status: response.status, elapsedMs, contentType: result.contentType, passwordRequired: result.body?.passwordRequired, attempt: attemptLabel }, null, 2 ) ); } finally { clearTimeout(timeout); } } async function pingKeepaliveEndpoint() { const config = readKeepaliveConfig(); let lastError; for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) { try { const attemptLabel = `${attempt}/${config.maxAttempts}`; await pingKeepaliveEndpointOnce(config, attemptLabel); return; } catch (error) { lastError = error; const nextRetryDelayMs = attempt < config.maxAttempts ? getRetryDelayMs(config, attempt) : undefined; console.error( JSON.stringify( { ok: false, attempt: `${attempt}/${config.maxAttempts}`, error: formatKeepaliveError(error, config.timeoutMs), ...(nextRetryDelayMs === undefined ? {} : { nextRetryDelayMs }) }, null, 2 ) ); if (nextRetryDelayMs !== undefined) { await waitBeforeRetry(nextRetryDelayMs); } } } throw new Error(`Keepalive attempt already reported: ${formatKeepaliveError(lastError, config.timeoutMs)}`); } pingKeepaliveEndpoint().catch((error) => { const message = error instanceof Error ? error.message : String(error); if (!message.startsWith('Keepalive attempt already reported: ')) { console.error(JSON.stringify({ ok: false, error: message }, null, 2)); } process.exit(1); });