+5
-1
apps/hosting-service/src/index.ts
+5
-1
apps/hosting-service/src/index.ts
···
16
17
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
18
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
19
20
// Parse CLI arguments
21
const args = process.argv.slice(2);
···
52
console.log('๐ Backfill requested, starting cache backfill...');
53
backfillCache({
54
skipExisting: true,
55
-
concurrency: 3,
56
}).then((stats) => {
57
console.log('โ
Cache backfill completed');
58
}).catch((err) => {
···
83
Cache: ${CACHE_DIR}
84
Firehose: Connected to Firehose
85
Cache-Only: ${CACHE_ONLY_MODE ? 'ENABLED (no DB writes)' : 'DISABLED'}
86
`);
87
88
// Graceful shutdown
···
16
17
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
18
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
19
+
const BACKFILL_CONCURRENCY = process.env.BACKFILL_CONCURRENCY
20
+
? parseInt(process.env.BACKFILL_CONCURRENCY)
21
+
: undefined; // Let backfill.ts default (10) apply
22
23
// Parse CLI arguments
24
const args = process.argv.slice(2);
···
55
console.log('๐ Backfill requested, starting cache backfill...');
56
backfillCache({
57
skipExisting: true,
58
+
concurrency: BACKFILL_CONCURRENCY,
59
}).then((stats) => {
60
console.log('โ
Cache backfill completed');
61
}).catch((err) => {
···
86
Cache: ${CACHE_DIR}
87
Firehose: Connected to Firehose
88
Cache-Only: ${CACHE_ONLY_MODE ? 'ENABLED (no DB writes)' : 'DISABLED'}
89
+
Backfill: ${backfillOnStartup ? `ENABLED (concurrency: ${BACKFILL_CONCURRENCY || 10})` : 'DISABLED'}
90
`);
91
92
// Graceful shutdown
+26
-2
apps/hosting-service/src/lib/utils.ts
+26
-2
apps/hosting-service/src/lib/utils.ts
···
90
export async function fetchSiteRecord(did: string, rkey: string): Promise<{ record: WispFsRecord; cid: string } | null> {
91
try {
92
const pdsEndpoint = await getPdsForDid(did);
93
-
if (!pdsEndpoint) return null;
94
95
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`;
96
const data = await safeFetchJson(url);
···
100
cid: data.cid || ''
101
};
102
} catch (err) {
103
-
console.error('Failed to fetch site record', did, rkey, err);
104
return null;
105
}
106
}
···
90
export async function fetchSiteRecord(did: string, rkey: string): Promise<{ record: WispFsRecord; cid: string } | null> {
91
try {
92
const pdsEndpoint = await getPdsForDid(did);
93
+
if (!pdsEndpoint) {
94
+
console.error('[hosting-service] Failed to get PDS endpoint for DID', { did, rkey });
95
+
return null;
96
+
}
97
98
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`;
99
const data = await safeFetchJson(url);
···
103
cid: data.cid || ''
104
};
105
} catch (err) {
106
+
const errorCode = (err as any)?.code;
107
+
const errorMsg = err instanceof Error ? err.message : String(err);
108
+
109
+
// Better error logging to distinguish between network errors and 404s
110
+
if (errorMsg.includes('HTTP 404') || errorMsg.includes('Not Found')) {
111
+
console.log('[hosting-service] Site record not found', { did, rkey });
112
+
} else if (errorCode && ['ECONNRESET', 'ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR', 'ETIMEDOUT'].includes(errorCode)) {
113
+
console.error('[hosting-service] Network/SSL error fetching site record (after retries)', {
114
+
did,
115
+
rkey,
116
+
error: errorMsg,
117
+
code: errorCode
118
+
});
119
+
} else {
120
+
console.error('[hosting-service] Failed to fetch site record', {
121
+
did,
122
+
rkey,
123
+
error: errorMsg,
124
+
code: errorCode
125
+
});
126
+
}
127
+
128
return null;
129
}
130
}
+128
-27
packages/@wisp/safe-fetch/src/index.ts
+128
-27
packages/@wisp/safe-fetch/src/index.ts
···
28
const MAX_BLOB_SIZE = 500 * 1024 * 1024; // 500MB
29
const MAX_REDIRECTS = 10;
30
31
function isBlockedHost(hostname: string): boolean {
32
const lowerHost = hostname.toLowerCase();
33
···
44
return false;
45
}
46
47
export async function safeFetch(
48
url: string,
49
-
options?: RequestInit & { maxSize?: number; timeout?: number }
50
): Promise<Response> {
51
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT;
52
const maxSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
53
54
-
// Parse and validate URL
55
let parsedUrl: URL;
56
try {
57
parsedUrl = new URL(url);
···
68
throw new Error(`Blocked host: ${hostname}`);
69
}
70
71
-
const controller = new AbortController();
72
-
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
73
74
-
try {
75
-
const response = await fetch(url, {
76
-
...options,
77
-
signal: controller.signal,
78
-
redirect: 'follow',
79
-
headers: {
80
-
'User-Agent': 'wisp-place hosting-service',
81
-
...(options?.headers || {}),
82
-
},
83
-
});
84
85
-
const contentLength = response.headers.get('content-length');
86
-
if (contentLength && parseInt(contentLength, 10) > maxSize) {
87
-
throw new Error(`Response too large: ${contentLength} bytes`);
88
}
89
90
-
return response;
91
-
} catch (err) {
92
-
if (err instanceof Error && err.name === 'AbortError') {
93
-
throw new Error(`Request timeout after ${timeoutMs}ms`);
94
-
}
95
-
throw err;
96
-
} finally {
97
-
clearTimeout(timeoutId);
98
}
99
}
100
101
export async function safeFetchJson<T = any>(
102
url: string,
103
-
options?: RequestInit & { maxSize?: number; timeout?: number }
104
): Promise<T> {
105
const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE;
106
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
···
146
147
export async function safeFetchBlob(
148
url: string,
149
-
options?: RequestInit & { maxSize?: number; timeout?: number }
150
): Promise<Uint8Array> {
151
const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE;
152
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB;
···
28
const MAX_BLOB_SIZE = 500 * 1024 * 1024; // 500MB
29
const MAX_REDIRECTS = 10;
30
31
+
// Retry configuration
32
+
const MAX_RETRIES = 3;
33
+
const INITIAL_RETRY_DELAY = 1000; // 1 second
34
+
const MAX_RETRY_DELAY = 10000; // 10 seconds
35
+
36
function isBlockedHost(hostname: string): boolean {
37
const lowerHost = hostname.toLowerCase();
38
···
49
return false;
50
}
51
52
+
/**
53
+
* Check if an error is retryable (network/SSL errors, not HTTP errors)
54
+
*/
55
+
function isRetryableError(err: unknown): boolean {
56
+
if (!(err instanceof Error)) return false;
57
+
58
+
// Network errors (ECONNRESET, ENOTFOUND, etc.)
59
+
const errorCode = (err as any).code;
60
+
if (errorCode) {
61
+
const retryableCodes = [
62
+
'ECONNRESET',
63
+
'ECONNREFUSED',
64
+
'ETIMEDOUT',
65
+
'ENOTFOUND',
66
+
'ENETUNREACH',
67
+
'EAI_AGAIN',
68
+
'EPIPE',
69
+
'ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR', // SSL/TLS handshake failures
70
+
'ERR_SSL_WRONG_VERSION_NUMBER',
71
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
72
+
];
73
+
if (retryableCodes.includes(errorCode)) {
74
+
return true;
75
+
}
76
+
}
77
+
78
+
// Timeout errors
79
+
if (err.name === 'AbortError' || err.message.includes('timeout')) {
80
+
return true;
81
+
}
82
+
83
+
// Fetch failures (generic network errors)
84
+
if (err.message.includes('fetch failed')) {
85
+
return true;
86
+
}
87
+
88
+
return false;
89
+
}
90
+
91
+
/**
92
+
* Sleep for a given number of milliseconds
93
+
*/
94
+
function sleep(ms: number): Promise<void> {
95
+
return new Promise(resolve => setTimeout(resolve, ms));
96
+
}
97
+
98
+
/**
99
+
* Retry a function with exponential backoff
100
+
*/
101
+
async function withRetry<T>(
102
+
fn: () => Promise<T>,
103
+
options: { maxRetries?: number; initialDelay?: number; maxDelay?: number; context?: string } = {}
104
+
): Promise<T> {
105
+
const maxRetries = options.maxRetries ?? MAX_RETRIES;
106
+
const initialDelay = options.initialDelay ?? INITIAL_RETRY_DELAY;
107
+
const maxDelay = options.maxDelay ?? MAX_RETRY_DELAY;
108
+
const context = options.context ?? 'Request';
109
+
110
+
let lastError: unknown;
111
+
112
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
113
+
try {
114
+
return await fn();
115
+
} catch (err) {
116
+
lastError = err;
117
+
118
+
// Don't retry if this is the last attempt or error is not retryable
119
+
if (attempt === maxRetries || !isRetryableError(err)) {
120
+
throw err;
121
+
}
122
+
123
+
// Calculate delay with exponential backoff
124
+
const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
125
+
126
+
const errorCode = (err as any)?.code;
127
+
const errorMsg = err instanceof Error ? err.message : String(err);
128
+
console.warn(
129
+
`${context} failed (attempt ${attempt + 1}/${maxRetries + 1}): ${errorMsg}${errorCode ? ` [${errorCode}]` : ''} - retrying in ${delay}ms`
130
+
);
131
+
132
+
await sleep(delay);
133
+
}
134
+
}
135
+
136
+
throw lastError;
137
+
}
138
+
139
export async function safeFetch(
140
url: string,
141
+
options?: RequestInit & { maxSize?: number; timeout?: number; retry?: boolean }
142
): Promise<Response> {
143
+
const shouldRetry = options?.retry !== false; // Default to true
144
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT;
145
const maxSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
146
147
+
// Parse and validate URL (done once, outside retry loop)
148
let parsedUrl: URL;
149
try {
150
parsedUrl = new URL(url);
···
161
throw new Error(`Blocked host: ${hostname}`);
162
}
163
164
+
const fetchFn = async () => {
165
+
const controller = new AbortController();
166
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
167
+
168
+
try {
169
+
const response = await fetch(url, {
170
+
...options,
171
+
signal: controller.signal,
172
+
redirect: 'follow',
173
+
headers: {
174
+
'User-Agent': 'wisp-place hosting-service',
175
+
...(options?.headers || {}),
176
+
},
177
+
});
178
179
+
const contentLength = response.headers.get('content-length');
180
+
if (contentLength && parseInt(contentLength, 10) > maxSize) {
181
+
throw new Error(`Response too large: ${contentLength} bytes`);
182
+
}
183
184
+
return response;
185
+
} catch (err) {
186
+
if (err instanceof Error && err.name === 'AbortError') {
187
+
throw new Error(`Request timeout after ${timeoutMs}ms`);
188
+
}
189
+
throw err;
190
+
} finally {
191
+
clearTimeout(timeoutId);
192
}
193
+
};
194
195
+
if (shouldRetry) {
196
+
return withRetry(fetchFn, { context: `Fetch ${parsedUrl.hostname}` });
197
+
} else {
198
+
return fetchFn();
199
}
200
}
201
202
export async function safeFetchJson<T = any>(
203
url: string,
204
+
options?: RequestInit & { maxSize?: number; timeout?: number; retry?: boolean }
205
): Promise<T> {
206
const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE;
207
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
···
247
248
export async function safeFetchBlob(
249
url: string,
250
+
options?: RequestInit & { maxSize?: number; timeout?: number; retry?: boolean }
251
): Promise<Uint8Array> {
252
const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE;
253
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB;