this repo has no description
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add cors

+224 -91
+127 -91
src/index.ts
··· 4 4 import { db } from "./libs/db"; 5 5 import { version, name } from "../package.json"; 6 6 import { preloadCaches, invalidateAndRefreshCaches } from "./libs/cacheWarming"; 7 - import { QueryCache, queryCache, createCacheHeaders, compressResponse, createCachedEndpoint } from "./libs/cache"; 7 + import { 8 + QueryCache, 9 + queryCache, 10 + compressResponse, 11 + createCachedEndpoint, 12 + } from "./libs/cache"; 13 + import { handleCORS } from "./libs/cors"; 8 14 import root from "../public/index.html"; 9 - import { count, sql } from "drizzle-orm"; 15 + import { count } from "drizzle-orm"; 10 16 import { stories } from "./libs/schema"; 11 17 12 18 const environment = process.env.NODE_ENV; ··· 70 76 reusePort: true, 71 77 routes: { 72 78 "/": root, 73 - "/api/stories": createCachedEndpoint("leaderboard_stories", async () => { 74 - // Only select the specific columns we need for better performance 75 - const storyAlerts = await db.query.stories.findMany({ 76 - columns: { 77 - id: true, 78 - title: true, 79 - url: true, 80 - position: true, 81 - peakPosition: true, 82 - score: true, 83 - peakScore: true, 84 - descendants: true, 85 - enteredLeaderboardAt: true, 86 - firstSeenAt: true, 87 - by: true, 88 - isFromMonitoredUser: true, 79 + // Apply CORS to all API routes 80 + "/api/stories": handleCORS( 81 + createCachedEndpoint( 82 + "leaderboard_stories", 83 + async () => { 84 + // Only select the specific columns we need for better performance 85 + const storyAlerts = await db.query.stories.findMany({ 86 + columns: { 87 + id: true, 88 + title: true, 89 + url: true, 90 + position: true, 91 + peakPosition: true, 92 + score: true, 93 + peakScore: true, 94 + descendants: true, 95 + enteredLeaderboardAt: true, 96 + firstSeenAt: true, 97 + by: true, 98 + isFromMonitoredUser: true, 99 + }, 100 + where: (stories, { eq }) => eq(stories.isOnLeaderboard, true), 101 + orderBy: (stories, { asc }) => [asc(stories.position)], 102 + limit: 30, // Reduced from 100 to 30 for better performance 103 + }); 104 + 105 + // Pre-calculate the time multiplier to optimize date transformations 106 + const timeMultiplier = 1000; 107 + 108 + // Transform story data to match the format expected by the frontend 109 + return storyAlerts.map((story) => { 110 + // Calculate timestamp only once per story 111 + const timestamp = story.enteredLeaderboardAt 112 + ? new Date( 113 + story.enteredLeaderboardAt * timeMultiplier, 114 + ).toISOString() 115 + : new Date(story.firstSeenAt * timeMultiplier).toISOString(); 116 + 117 + return { 118 + id: story.id, 119 + title: story.title, 120 + url: 121 + story.url || `https://news.ycombinator.com/item?id=${story.id}`, 122 + rank: story.position, 123 + peakRank: story.peakPosition, 124 + points: story.score, 125 + peakPoints: story.peakScore, 126 + comments: story.descendants, 127 + timestamp, 128 + by: story.by, 129 + isFromMonitoredUser: story.isFromMonitoredUser, 130 + }; 131 + }); 89 132 }, 90 - where: (stories, { eq }) => eq(stories.isOnLeaderboard, true), 91 - orderBy: (stories, { asc }) => [asc(stories.position)], 92 - limit: 30, // Reduced from 100 to 30 for better performance 93 - }); 133 + 300, 134 + ), 135 + ), 94 136 95 - // Pre-calculate the time multiplier to optimize date transformations 96 - const timeMultiplier = 1000; 97 - 98 - // Transform story data to match the format expected by the frontend 99 - return storyAlerts.map((story) => { 100 - // Calculate timestamp only once per story 101 - const timestamp = story.enteredLeaderboardAt 102 - ? new Date(story.enteredLeaderboardAt * timeMultiplier).toISOString() 103 - : new Date(story.firstSeenAt * timeMultiplier).toISOString(); 104 - 105 - return { 106 - id: story.id, 107 - title: story.title, 108 - url: 109 - story.url || `https://news.ycombinator.com/item?id=${story.id}`, 110 - rank: story.position, 111 - peakRank: story.peakPosition, 112 - points: story.score, 113 - peakPoints: story.peakScore, 114 - comments: story.descendants, 115 - timestamp, 116 - by: story.by, 117 - isFromMonitoredUser: story.isFromMonitoredUser, 118 - }; 119 - }); 120 - }, 300), 121 - "/api/stats/total-stories": createCachedEndpoint("total_stories_count", async () => { 122 - const result = await db.select({ count: count() }).from(stories); 123 - return { 124 - count: Number(result[0]?.count), 125 - timestamp: Math.floor(Date.now() / 1000), 126 - }; 127 - }, 300), 128 - "/api/stats/verified-users": createCachedEndpoint("verified_users_stats", async () => { 129 - // Get stats for verified user stories 130 - const verifiedStories = await db.query.stories.findMany({ 131 - where: (stories, { eq }) => eq(stories.isFromMonitoredUser, true), 132 - }); 133 - // Get count of verified users in the system 134 - const verifiedUsersCount = await db.query.users 135 - .findMany({ 136 - where: (users, { eq }) => eq(users.verified, true), 137 - }) 138 - .then((users) => users.length); 137 + "/api/stats/total-stories": handleCORS( 138 + createCachedEndpoint( 139 + "total_stories_count", 140 + async () => { 141 + const result = await db.select({ count: count() }).from(stories); 142 + return { 143 + count: Number(result[0]?.count), 144 + timestamp: Math.floor(Date.now() / 1000), 145 + }; 146 + }, 147 + 300, 148 + ), 149 + ), 150 + 151 + "/api/stats/verified-users": handleCORS( 152 + createCachedEndpoint( 153 + "verified_users_stats", 154 + async () => { 155 + // Get stats for verified user stories 156 + const verifiedStories = await db.query.stories.findMany({ 157 + where: (stories, { eq }) => eq(stories.isFromMonitoredUser, true), 158 + }); 159 + // Get count of verified users in the system 160 + const verifiedUsersCount = await db.query.users 161 + .findMany({ 162 + where: (users, { eq }) => eq(users.verified, true), 163 + }) 164 + .then((users) => users.length); 165 + 166 + // Count stories on front page (rank <= 30) 167 + const frontPageCount = verifiedStories.filter( 168 + (s) => s.isOnLeaderboard, 169 + ).length; 139 170 140 - // Count stories on front page (rank <= 30) 141 - const frontPageCount = verifiedStories.filter( 142 - (s) => s.isOnLeaderboard, 143 - ).length; 171 + // Calculate average peak points for verified users 172 + let totalPeakPoints = 0; 173 + for (const s of verifiedStories) { 174 + if (s.peakScore) totalPeakPoints += s.peakScore; 175 + } 176 + const avgPeakPoints = verifiedStories.length 177 + ? Math.round(totalPeakPoints / verifiedStories.length) 178 + : 0; 144 179 145 - // Calculate average peak points for verified users 146 - let totalPeakPoints = 0; 147 - for (const s of verifiedStories) { 148 - if (s.peakScore) totalPeakPoints += s.peakScore; 149 - } 150 - const avgPeakPoints = verifiedStories.length 151 - ? Math.round(totalPeakPoints / verifiedStories.length) 152 - : 0; 180 + return { 181 + totalCount: verifiedUsersCount, 182 + frontPageCount: frontPageCount, 183 + avgPeakPoints: avgPeakPoints, 184 + timestamp: Math.floor(Date.now() / 1000), 185 + }; 186 + }, 187 + 300, 188 + ), 189 + ), 153 190 154 - return { 155 - totalCount: verifiedUsersCount, 156 - frontPageCount: frontPageCount, 157 - avgPeakPoints: avgPeakPoints, 158 - timestamp: Math.floor(Date.now() / 1000), 159 - }; 160 - }, 300), 161 - "/api/story/:id/snapshots": async (req) => { 191 + "/api/story/:id/snapshots": handleCORS(async (req) => { 162 192 try { 163 - const storyId = Number.parseInt(req.params.id as string); 193 + // Extract the story ID from the URL path 194 + const url = new URL(req.url); 195 + const match = url.pathname.match(/\/api\/story\/(\d+)\/snapshots/); 196 + const storyId = Number.parseInt(match?.[1] ?? "") || Number.NaN; 164 197 if (Number.isNaN(storyId)) { 165 198 return new Response(JSON.stringify({ error: "Invalid story ID" }), { 166 199 status: 400, ··· 186 219 date: new Date(snapshot.timestamp * 1000).toISOString(), 187 220 })); 188 221 }, 189 - 3600 // Cache story snapshots for 1 hour as they change less frequently 222 + 3600, // Cache story snapshots for 1 hour as they change less frequently 190 223 ); 191 224 192 225 // Execute the cached handler ··· 201 234 }, 202 235 ); 203 236 } 204 - }, 205 - "/health": (req) => { 237 + }), 238 + 239 + "/health": handleCORS(async (req) => { 206 240 const response = new Response(JSON.stringify({ status: "ok" }), { 207 241 headers: { 208 242 "Content-Type": "application/json", ··· 213 247 }, 214 248 }); 215 249 return compressResponse(req, response); 216 - }, 250 + }), 251 + 217 252 "/slack": (res: Request) => { 253 + // No CORS needed for Slack endpoints 218 254 return slackApp.run(res); 219 255 }, 220 256 },
+97
src/libs/cors.ts
··· 1 + /** 2 + * CORS configuration for the application 3 + * This adds support for Cloudflare Insights specifically 4 + */ 5 + 6 + /** 7 + * Adds CORS headers to allow Cloudflare Insights 8 + * @param response The response to add CORS headers to 9 + * @param origin The request origin to use for Access-Control-Allow-Origin 10 + * @returns A new response with added CORS headers 11 + */ 12 + function addCloudflareInsightsCors( 13 + response: Response, 14 + origin: string, 15 + ): Response { 16 + // Get existing headers 17 + const headers = new Headers(response.headers); 18 + 19 + // Add CORS headers specifically for Cloudflare Insights 20 + // Use the request's origin if it matches allowed origins 21 + headers.set("Access-Control-Allow-Origin", origin); 22 + headers.set("Access-Control-Allow-Methods", "GET, OPTIONS, POST"); 23 + headers.set("Access-Control-Allow-Headers", "Content-Type"); 24 + headers.set("Access-Control-Max-Age", "86400"); // Cache preflight for 24 hours 25 + 26 + // For browser caching 27 + headers.append("Vary", "Origin"); 28 + 29 + // Create a new response with the original body and updated headers 30 + return new Response(response.body, { 31 + status: response.status, 32 + statusText: response.statusText, 33 + headers, 34 + }); 35 + } 36 + 37 + /** 38 + * Handles OPTIONS preflight requests for CORS 39 + * @param req The original request 40 + * @returns A response for preflight requests 41 + */ 42 + function handleCorsPreflightRequest(req: Request): Response { 43 + const headers = new Headers(); 44 + const origin = req.headers.get("Origin"); 45 + 46 + // List of allowed origins 47 + const allowedOrigins: string[] = []; 48 + 49 + // Only set the Access-Control-Allow-Origin if the origin is in our allowed list 50 + if (origin && allowedOrigins.includes(origin)) { 51 + headers.set("Access-Control-Allow-Origin", origin); 52 + } 53 + 54 + headers.set("Access-Control-Allow-Methods", "GET, OPTIONS, POST"); 55 + headers.set("Access-Control-Allow-Headers", "Content-Type"); 56 + headers.set("Access-Control-Max-Age", "86400"); // Cache preflight for 24 hours 57 + headers.set("Vary", "Origin"); 58 + 59 + // Return 204 No Content for preflight requests 60 + return new Response(null, { 61 + status: 204, 62 + headers, 63 + }); 64 + } 65 + 66 + /** 67 + * Higher-order function that adds CORS support to a request handler 68 + * Specifically configured for Cloudflare Insights requests 69 + * @param handler The original request handler function 70 + * @returns A new handler function with CORS support added 71 + */ 72 + export function handleCORS( 73 + handler: (req: Request) => Response | Promise<Response>, 74 + ): (req: Request) => Promise<Response> { 75 + return async (req: Request) => { 76 + const origin = req.headers.get("Origin"); 77 + const allowedOrigins = [ 78 + "https://static.cloudflareinsights.com", 79 + "https://cloudflareinsights.com", 80 + ]; 81 + 82 + // Handle OPTIONS preflight requests 83 + if (req.method === "OPTIONS") { 84 + return handleCorsPreflightRequest(req); 85 + } 86 + 87 + // Process the request normally then add CORS headers 88 + const response = await handler(req); 89 + 90 + // Only add CORS headers if the origin is in our allowed list 91 + if (origin && allowedOrigins.includes(origin)) { 92 + return addCloudflareInsightsCors(response, origin); 93 + } 94 + 95 + return response; 96 + }; 97 + }