diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index c55ad0d4..37dc15a1 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -2,29 +2,104 @@ import { error, fail, redirect } from "@sveltejs/kit"; import { validateData } from "$lib/utils"; import { loginUserSchema, registerUserSchema } from "$lib/schemas"; + +// Constants +const CACHE_DURATION = 60 * 1000; // 1 minute in milliseconds +const REQUEST_TIMEOUT = 5000; + +// LRU Cache implementation with automatic cleanup +class LRUCache { + constructor(maxSize = 100) { + this.cache = new Map(); + this.maxSize = maxSize; + } + + get(key) { + const item = this.cache.get(key); + if (!item) return null; + if (Date.now() - item.timestamp >= CACHE_DURATION) { + this.cache.delete(key); + return null; + } + return item.data; + } + + set(key, data) { + if (this.cache.size >= this.maxSize) { + const oldestKey = this.cache.keys().next().value; + this.cache.delete(oldestKey); + } + this.cache.set(key, { data, timestamp: Date.now() }); + } +} + +const dashboardCache = new LRUCache(); + +// Optimized fetch function with AbortController and timeout +const fetchWithTimeout = async (url, options, timeout) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal + }); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return await response.json(); + } finally { + clearTimeout(timeoutId); + } +}; + +// Dashboard data fetching function with caching +const getDashboard = async (apiURL, apiKey) => { + const cacheKey = 'dashboard-info'; + const cachedData = dashboardCache.get(cacheKey); + if (cachedData) return cachedData; + + const options = { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-API-KEY": apiKey + } + }; + + try { + const data = await fetchWithTimeout( + `${apiURL}/dashboard-info`, + options, + REQUEST_TIMEOUT + ); + dashboardCache.set(cacheKey, data); + return data; + } catch (error) { + if (error.name === 'AbortError') { + throw new Error('Dashboard request timeout'); + } + console.error('Error fetching dashboard:', error); + return {}; + } +}; + +// Main load function export const load = async ({ locals }) => { const { apiKey, apiURL } = locals; - const getDashboard = async () => { - const response = await fetch(apiURL + "/dashboard-info", { - method: "GET", - headers: { - "Content-Type": "application/json", - "X-API-KEY": apiKey, - }, - }); - - const output = await response?.json(); - - return output; - }; - - // Make sure to return a promise - return { - getDashboard: await getDashboard(), - }; + try { + return { + getDashboard: await getDashboard(apiURL, apiKey) + }; + } catch (error) { + console.error('Error in dashboard load:', error); + return { + getDashboard: {} + }; + } }; + async function checkDisposableEmail(email) { const url = `https://disposable.debounce.io/?email=${encodeURIComponent(email)}`; const response = await fetch(url, { diff --git a/src/routes/index/[tickerID]/+layout.server.ts b/src/routes/index/[tickerID]/+layout.server.ts index a6f51fdc..0253d7ac 100644 --- a/src/routes/index/[tickerID]/+layout.server.ts +++ b/src/routes/index/[tickerID]/+layout.server.ts @@ -1,5 +1,6 @@ -const cleanString = (input) => { - const substringsToRemove = [ +// Pre-compile regex pattern and substrings for cleaning +const REMOVE_PATTERNS = { + pattern: new RegExp(`\\b(${[ "Depositary", "Inc.", "Incorporated", @@ -11,72 +12,166 @@ const cleanString = (input) => { "Oyj", "Company", "The", - "plc", - ]; - const pattern = new RegExp(`\\b(${substringsToRemove.join("|")})\\b|,`, "gi"); - return input?.replace(pattern, "").trim(); + "plc" + ].join("|")})\\b|,`, "gi") }; -const fetchData = async (apiURL, apiKey, endpoint, ticker) => { - try { - const response = await fetch(`${apiURL}${endpoint}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-API-KEY": apiKey, - }, - body: JSON.stringify({ ticker }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); +// Constants +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes +const REQUEST_TIMEOUT = 5000; // 5 seconds +const ENDPOINTS = Object.freeze([ + "/index-profile", + "/etf-holdings", + "/etf-sector-weighting", + "/stock-quote", + "/pre-post-quote", + "/wiim", + "/one-day-price", + "/stock-news" +]); + +const SPY_PROXY_ENDPOINTS = Object.freeze([ + "/etf-holdings", + "/etf-sector-weighting", + "/wiim", + "/stock-news" +]); + +// Memoized string cleaning function +const cleanString = (() => { + const cache = new Map(); + return (input) => { + if (!input) return ''; + if (cache.has(input)) return cache.get(input); + const cleaned = input.replace(REMOVE_PATTERNS.pattern, '').trim(); + cache.set(input, cleaned); + return cleaned; + }; +})(); + +// LRU Cache implementation with automatic cleanup +class LRUCache { + constructor(maxSize = 100) { + this.cache = new Map(); + this.maxSize = maxSize; + } + + get(key) { + const item = this.cache.get(key); + if (!item) return null; + if (Date.now() - item.timestamp >= CACHE_DURATION) { + this.cache.delete(key); + return null; } - - const data = await response.json(); + return item.data; + } + + set(key, data) { + if (this.cache.size >= this.maxSize) { + const oldestKey = this.cache.keys().next().value; + this.cache.delete(oldestKey); + } + this.cache.set(key, { data, timestamp: Date.now() }); + } +} + +const dataCache = new LRUCache(); + +// Optimized fetch function with AbortController and timeout +const fetchWithTimeout = async (url, options, timeout) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal + }); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return await response.json(); + } finally { + clearTimeout(timeoutId); + } +}; + +// Main data fetching function with SPX/SPY handling +const fetchData = async (apiURL, apiKey, endpoint, ticker) => { + const useSpyTicker = ticker?.toLowerCase() === "^spx" && + SPY_PROXY_ENDPOINTS.includes(endpoint); + const effectiveTicker = useSpyTicker ? "SPY" : ticker; + + const cacheKey = `${endpoint}-${effectiveTicker}`; + const cachedData = dataCache.get(cacheKey); + if (cachedData) return cachedData; + + const options = { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-KEY": apiKey + }, + body: JSON.stringify({ ticker: effectiveTicker }) + }; + + try { + const data = await fetchWithTimeout( + `${apiURL}${endpoint}`, + options, + REQUEST_TIMEOUT + ); + dataCache.set(cacheKey, data); return data; } catch (error) { - console.error(`Error fetching ${endpoint}:`, error); + if (error.name === 'AbortError') { + console.error(`Request timeout for ${endpoint}`); + } else { + console.error(`Error fetching ${endpoint}:`, error); + } return []; } }; +// Optimized watchlist fetching with error boundary const fetchWatchlist = async (pb, userId) => { - let output; + if (!userId) return []; try { - output = await pb.collection("watchlist").getFullList({ - filter: `user="${userId}"`, + return await pb.collection("watchlist").getFullList({ + filter: `user="${userId}"` }); - } catch (e) { - //console.log(e) - output = []; + } catch (error) { + console.error('Error fetching watchlist:', error); + return []; } - return output; }; +// Helper function to generate default response +const getDefaultResponse = (tickerID) => ({ + getIndexProfile: [], + getIndexHolding: [], + getIndexSectorWeighting: [], + getStockQuote: [], + getPrePostQuote: [], + getWhyPriceMoved: [], + getOneDayPrice: [], + getNews: [], + getUserWatchlist: [], + companyName: '', + getParams: tickerID +}); + +// Main load function with parallel fetching export const load = async ({ params, locals }) => { const { apiURL, apiKey, pb, user } = locals; const { tickerID } = params; + if (!tickerID) { + return getDefaultResponse(tickerID); + } + try { - const endpoints = [ - "/index-profile", - "/etf-holdings", - "/etf-sector-weighting", - "/stock-quote", - "/pre-post-quote", - "/wiim", - "/one-day-price", - "/stock-news", - ]; - - const promises = endpoints.map((endpoint) => { - // Use SPY for specific endpoints when tickerID is ^SPC or ^spc - const useSpyTicker = tickerID?.toLowerCase() === "^spx" && - ["/etf-holdings", "/etf-sector-weighting", "/wiim", "/stock-news"]?.includes(endpoint); - return fetchData(apiURL, apiKey, endpoint, useSpyTicker ? "SPY" : tickerID); - }); - - // Add watchlist promise + const promises = ENDPOINTS.map(endpoint => + fetchData(apiURL, apiKey, endpoint, tickerID) + ); promises.push(fetchWatchlist(pb, user?.id)); const [ @@ -102,22 +197,10 @@ export const load = async ({ params, locals }) => { getNews: getNews || [], getUserWatchlist: getUserWatchlist || [], companyName: cleanString(getIndexProfile?.at(0)?.name), - getParams: params.tickerID, + getParams: tickerID }; } catch (error) { console.error('Error in load function:', error); - return { - getIndexProfile: [], - getIndexHolding: [], - getIndexSectorWeighting: [], - getStockQuote: [], - getPrePostQuote: [], - getWhyPriceMoved: [], - getOneDayPrice: [], - getNews: [], - getUserWatchlist: [], - companyName: '', - getParams: params.tickerID, - }; + return getDefaultResponse(tickerID); } }; \ No newline at end of file diff --git a/src/routes/stocks/[tickerID]/+layout.server.ts b/src/routes/stocks/[tickerID]/+layout.server.ts index 33bdfd61..1b555314 100644 --- a/src/routes/stocks/[tickerID]/+layout.server.ts +++ b/src/routes/stocks/[tickerID]/+layout.server.ts @@ -32,14 +32,20 @@ const cleanString = (() => { const CACHE_DURATION = 5 * 60 * 1000; const REQUEST_TIMEOUT = 5000; const ENDPOINTS = Object.freeze([ - "/stockdeck", - "/analyst-summary-rating", + "/index-profile", + "/etf-holdings", + "/etf-sector-weighting", "/stock-quote", "/pre-post-quote", "/wiim", "/one-day-price", - "/next-earnings", - "/earnings-surprise", + "/stock-news" +]); + +const SPY_PROXY_ENDPOINTS = Object.freeze([ + "/etf-holdings", + "/etf-sector-weighting", + "/wiim", "/stock-news" ]); @@ -88,9 +94,13 @@ const fetchWithTimeout = async (url, options, timeout) => { } }; -// Main data fetching function +// Main data fetching function with SPX/SPY handling const fetchData = async (apiURL, apiKey, endpoint, ticker) => { - const cacheKey = `${endpoint}-${ticker}`; + const useSpyTicker = ticker?.toLowerCase() === "^spx" && + SPY_PROXY_ENDPOINTS.some(proxyEndpoint => ENDPOINTS.includes(proxyEndpoint)); + const effectiveTicker = useSpyTicker ? "SPY" : ticker; + + const cacheKey = `${endpoint}-${effectiveTicker}`; const cachedData = dataCache.get(cacheKey); if (cachedData) return cachedData; @@ -100,7 +110,10 @@ const fetchData = async (apiURL, apiKey, endpoint, ticker) => { "Content-Type": "application/json", "X-API-KEY": apiKey }, - body: JSON.stringify({ ticker, endpoints: ENDPOINTS }) + body: JSON.stringify({ + ticker: effectiveTicker, + endpoints: ENDPOINTS + }) }; try { @@ -131,50 +144,64 @@ const fetchWatchlist = async (pb, userId) => { } }; +// Helper function to generate default response +const getDefaultResponse = (tickerID) => ({ + getIndexProfile: [], + getIndexHolding: [], + getIndexSectorWeighting: [], + getStockQuote: [], + getPrePostQuote: [], + getWhyPriceMoved: [], + getOneDayPrice: [], + getNews: [], + getUserWatchlist: [], + companyName: '', + getParams: tickerID +}); + // Main load function with parallel fetching export const load = async ({ params, locals }) => { const { apiURL, apiKey, pb, user } = locals; const { tickerID } = params; if (!tickerID) { - return { error: 'Invalid ticker ID' }; + return getDefaultResponse(tickerID); } try { // Fetch data in parallel - const [stockData, userWatchlist] = await Promise.all([ + const [indexData, userWatchlist] = await Promise.all([ fetchData(apiURL, apiKey, "/bulk-data", tickerID), fetchWatchlist(pb, user?.id) ]); - // Destructure with default empty object to prevent undefined errors + // Destructure with default empty arrays to prevent undefined errors const { - '/stockdeck': getStockDeck = {}, - '/analyst-summary-rating': getAnalystSummary = {}, - '/stock-quote': getStockQuote = {}, - '/pre-post-quote': getPrePostQuote = {}, - '/wiim': getWhyPriceMoved = {}, - '/one-day-price': getOneDayPrice = {}, - '/next-earnings': getNextEarnings = {}, - '/earnings-surprise': getEarningsSurprise = {}, - '/stock-news': getNews = {} - } = stockData; + '/index-profile': getIndexProfile = [], + '/etf-holdings': getIndexHolding = [], + '/etf-sector-weighting': getIndexSectorWeighting = [], + '/stock-quote': getStockQuote = [], + '/pre-post-quote': getPrePostQuote = [], + '/wiim': getWhyPriceMoved = [], + '/one-day-price': getOneDayPrice = [], + '/stock-news': getNews = [] + } = indexData; return { - getStockDeck, - getAnalystSummary, + getIndexProfile, + getIndexHolding, + getIndexSectorWeighting, getStockQuote, getPrePostQuote, getWhyPriceMoved, getOneDayPrice, - getNextEarnings, - getEarningsSurprise, getNews, getUserWatchlist: userWatchlist, - companyName: cleanString(getStockDeck?.companyName), + companyName: cleanString(getIndexProfile?.at(0)?.name), getParams: tickerID }; } catch (error) { - return { error: 'Failed to load stock data' }; + console.error('Error in load function:', error); + return getDefaultResponse(tickerID); } }; \ No newline at end of file