## Summary - Fixes periodic production crashes (undici ECONNREFUSED ::1) by bounding server cache size/lifetime and hardening server HTTP client. ### Root cause - React server cache (cache(...)) held axios responses indefinitely across many parameter combinations, causing slow memory growth until the Next.js app router worker was OOM-killed. The main server then failed IPC to the worker (ECONNREFUSED ::1:<port>). ### Changes - `app/lib/data.ts`: Replace unbounded cache(...) with unstable_cache and explicit keys; TTLs: 60s for teams/detail/favorites/user, 300s for meta (jobs/skills/accessories/raids/version). - `app/lib/api-utils.ts`: Add shared Axios instance with 15s timeout and keepAlive http/https agents; apply to GET/POST/PUT/DELETE helpers. - `pages/api/preview/[shortcode].ts`: Remove duplicate handler to dedupe route; retain the .tsx variant using `NEXT_PUBLIC_SIERO_API_URL`. ### Notes - Build currently has pre-existing app/pages route duplication errors; out of scope here but unrelated to this fix. - Ensure `NEXT_PUBLIC_SIERO_API_URL` and `NEXT_PUBLIC_SIERO_OAUTH_URL` are set on Railway. ### Risk/impact - Low risk; behavior is unchanged aside from bounded caching and resilient HTTP. - Cache TTLs can be tuned later if needed. ### Test plan - Verify saved/teams/user pages load and revalidate after TTL. - Validate API routes still proxy correctly; timeouts occur after ~15s for hung upstreams. - Monitor memory over several days; expect stable usage without steady growth.
173 lines
4.4 KiB
TypeScript
173 lines
4.4 KiB
TypeScript
import axios, { AxiosRequestConfig } from "axios";
|
|
import http from "http";
|
|
import https from "https";
|
|
import { cookies } from "next/headers";
|
|
import { revalidatePath } from "next/cache";
|
|
import { z } from "zod";
|
|
|
|
// Base URL from environment variable
|
|
const baseUrl = process.env.NEXT_PUBLIC_SIERO_API_URL || 'https://localhost:3000/v1';
|
|
const oauthUrl = process.env.NEXT_PUBLIC_SIERO_OAUTH_URL || 'https://localhost:3000/oauth';
|
|
|
|
// Shared Axios instance with sane defaults for server-side calls
|
|
const httpClient = axios.create({
|
|
baseURL: baseUrl,
|
|
timeout: 15000,
|
|
// Keep connections alive to reduce socket churn
|
|
httpAgent: new http.Agent({ keepAlive: true, maxSockets: 50 }),
|
|
httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 50 }),
|
|
// Do not throw on HTTP status by default; let callers handle
|
|
validateStatus: () => true,
|
|
});
|
|
|
|
// Utility to get auth token from cookies on the server
|
|
export function getAuthToken() {
|
|
const cookieStore = cookies();
|
|
const accountCookie = cookieStore.get('account');
|
|
|
|
if (accountCookie) {
|
|
try {
|
|
const accountData = JSON.parse(accountCookie.value);
|
|
return accountData.token;
|
|
} catch (e) {
|
|
console.error('Failed to parse account cookie', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Create headers with auth token
|
|
export function createHeaders() {
|
|
const token = getAuthToken();
|
|
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
|
};
|
|
}
|
|
|
|
// Helper for GET requests
|
|
export async function fetchFromApi(endpoint: string, config?: AxiosRequestConfig) {
|
|
const headers = createHeaders();
|
|
|
|
try {
|
|
const response = await httpClient.get(`${endpoint}`, {
|
|
...config,
|
|
headers: {
|
|
...headers,
|
|
...(config?.headers || {})
|
|
}
|
|
});
|
|
|
|
return response.data;
|
|
} catch (error) {
|
|
console.error(`API fetch error: ${endpoint}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Helper for POST requests
|
|
export async function postToApi(endpoint: string, data: any, config?: AxiosRequestConfig) {
|
|
const headers = createHeaders();
|
|
|
|
try {
|
|
const response = await httpClient.post(`${endpoint}`, data, {
|
|
...config,
|
|
headers: {
|
|
...headers,
|
|
...(config?.headers || {})
|
|
}
|
|
});
|
|
|
|
return response.data;
|
|
} catch (error) {
|
|
console.error(`API post error: ${endpoint}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Helper for PUT requests
|
|
export async function putToApi(endpoint: string, data: any, config?: AxiosRequestConfig) {
|
|
const headers = createHeaders();
|
|
|
|
try {
|
|
const response = await httpClient.put(`${endpoint}`, data, {
|
|
...config,
|
|
headers: {
|
|
...headers,
|
|
...(config?.headers || {})
|
|
}
|
|
});
|
|
|
|
return response.data;
|
|
} catch (error) {
|
|
console.error(`API put error: ${endpoint}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Helper for DELETE requests
|
|
export async function deleteFromApi(endpoint: string, data?: any, config?: AxiosRequestConfig) {
|
|
const headers = createHeaders();
|
|
|
|
try {
|
|
const response = await httpClient.delete(`${endpoint}`, {
|
|
...config,
|
|
headers: {
|
|
...headers,
|
|
...(config?.headers || {})
|
|
},
|
|
data
|
|
});
|
|
|
|
return response.data;
|
|
} catch (error) {
|
|
console.error(`API delete error: ${endpoint}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Helper for login endpoint
|
|
export async function login(credentials: { email: string; password: string }) {
|
|
try {
|
|
const response = await axios.post(`${oauthUrl}/token`, credentials);
|
|
return response.data;
|
|
} catch (error) {
|
|
console.error('Login error', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Helper to revalidate cache for a path
|
|
export function revalidate(path: string) {
|
|
try {
|
|
revalidatePath(path);
|
|
} catch (error) {
|
|
console.error(`Failed to revalidate ${path}`, error);
|
|
}
|
|
}
|
|
|
|
// Schemas for validation
|
|
export const UserSchema = z.object({
|
|
username: z.string().min(3).max(20),
|
|
email: z.string().email(),
|
|
password: z.string().min(8),
|
|
});
|
|
|
|
export const PartySchema = z.object({
|
|
name: z.string().optional(),
|
|
description: z.string().optional(),
|
|
visibility: z.enum(['public', 'unlisted', 'private']),
|
|
raid_id: z.string().optional(),
|
|
element: z.number().optional(),
|
|
});
|
|
|
|
export const SearchSchema = z.object({
|
|
query: z.string(),
|
|
filters: z.record(z.array(z.number())).optional(),
|
|
job: z.string().optional(),
|
|
locale: z.string().default('en'),
|
|
page: z.number().default(0),
|
|
});
|