···44import { db } from "./libs/db";
55import { version, name } from "../package.json";
66import { preloadCaches, invalidateAndRefreshCaches } from "./libs/cacheWarming";
77-import { QueryCache, queryCache, createCacheHeaders, compressResponse, createCachedEndpoint } from "./libs/cache";
77+import {
88+ QueryCache,
99+ queryCache,
1010+ compressResponse,
1111+ createCachedEndpoint,
1212+} from "./libs/cache";
1313+import { handleCORS } from "./libs/cors";
814import root from "../public/index.html";
99-import { count, sql } from "drizzle-orm";
1515+import { count } from "drizzle-orm";
1016import { stories } from "./libs/schema";
11171218const environment = process.env.NODE_ENV;
···7076 reusePort: true,
7177 routes: {
7278 "/": root,
7373- "/api/stories": createCachedEndpoint("leaderboard_stories", async () => {
7474- // Only select the specific columns we need for better performance
7575- const storyAlerts = await db.query.stories.findMany({
7676- columns: {
7777- id: true,
7878- title: true,
7979- url: true,
8080- position: true,
8181- peakPosition: true,
8282- score: true,
8383- peakScore: true,
8484- descendants: true,
8585- enteredLeaderboardAt: true,
8686- firstSeenAt: true,
8787- by: true,
8888- isFromMonitoredUser: true,
7979+ // Apply CORS to all API routes
8080+ "/api/stories": handleCORS(
8181+ createCachedEndpoint(
8282+ "leaderboard_stories",
8383+ async () => {
8484+ // Only select the specific columns we need for better performance
8585+ const storyAlerts = await db.query.stories.findMany({
8686+ columns: {
8787+ id: true,
8888+ title: true,
8989+ url: true,
9090+ position: true,
9191+ peakPosition: true,
9292+ score: true,
9393+ peakScore: true,
9494+ descendants: true,
9595+ enteredLeaderboardAt: true,
9696+ firstSeenAt: true,
9797+ by: true,
9898+ isFromMonitoredUser: true,
9999+ },
100100+ where: (stories, { eq }) => eq(stories.isOnLeaderboard, true),
101101+ orderBy: (stories, { asc }) => [asc(stories.position)],
102102+ limit: 30, // Reduced from 100 to 30 for better performance
103103+ });
104104+105105+ // Pre-calculate the time multiplier to optimize date transformations
106106+ const timeMultiplier = 1000;
107107+108108+ // Transform story data to match the format expected by the frontend
109109+ return storyAlerts.map((story) => {
110110+ // Calculate timestamp only once per story
111111+ const timestamp = story.enteredLeaderboardAt
112112+ ? new Date(
113113+ story.enteredLeaderboardAt * timeMultiplier,
114114+ ).toISOString()
115115+ : new Date(story.firstSeenAt * timeMultiplier).toISOString();
116116+117117+ return {
118118+ id: story.id,
119119+ title: story.title,
120120+ url:
121121+ story.url || `https://news.ycombinator.com/item?id=${story.id}`,
122122+ rank: story.position,
123123+ peakRank: story.peakPosition,
124124+ points: story.score,
125125+ peakPoints: story.peakScore,
126126+ comments: story.descendants,
127127+ timestamp,
128128+ by: story.by,
129129+ isFromMonitoredUser: story.isFromMonitoredUser,
130130+ };
131131+ });
89132 },
9090- where: (stories, { eq }) => eq(stories.isOnLeaderboard, true),
9191- orderBy: (stories, { asc }) => [asc(stories.position)],
9292- limit: 30, // Reduced from 100 to 30 for better performance
9393- });
133133+ 300,
134134+ ),
135135+ ),
941369595- // Pre-calculate the time multiplier to optimize date transformations
9696- const timeMultiplier = 1000;
9797-9898- // Transform story data to match the format expected by the frontend
9999- return storyAlerts.map((story) => {
100100- // Calculate timestamp only once per story
101101- const timestamp = story.enteredLeaderboardAt
102102- ? new Date(story.enteredLeaderboardAt * timeMultiplier).toISOString()
103103- : new Date(story.firstSeenAt * timeMultiplier).toISOString();
104104-105105- return {
106106- id: story.id,
107107- title: story.title,
108108- url:
109109- story.url || `https://news.ycombinator.com/item?id=${story.id}`,
110110- rank: story.position,
111111- peakRank: story.peakPosition,
112112- points: story.score,
113113- peakPoints: story.peakScore,
114114- comments: story.descendants,
115115- timestamp,
116116- by: story.by,
117117- isFromMonitoredUser: story.isFromMonitoredUser,
118118- };
119119- });
120120- }, 300),
121121- "/api/stats/total-stories": createCachedEndpoint("total_stories_count", async () => {
122122- const result = await db.select({ count: count() }).from(stories);
123123- return {
124124- count: Number(result[0]?.count),
125125- timestamp: Math.floor(Date.now() / 1000),
126126- };
127127- }, 300),
128128- "/api/stats/verified-users": createCachedEndpoint("verified_users_stats", async () => {
129129- // Get stats for verified user stories
130130- const verifiedStories = await db.query.stories.findMany({
131131- where: (stories, { eq }) => eq(stories.isFromMonitoredUser, true),
132132- });
133133- // Get count of verified users in the system
134134- const verifiedUsersCount = await db.query.users
135135- .findMany({
136136- where: (users, { eq }) => eq(users.verified, true),
137137- })
138138- .then((users) => users.length);
137137+ "/api/stats/total-stories": handleCORS(
138138+ createCachedEndpoint(
139139+ "total_stories_count",
140140+ async () => {
141141+ const result = await db.select({ count: count() }).from(stories);
142142+ return {
143143+ count: Number(result[0]?.count),
144144+ timestamp: Math.floor(Date.now() / 1000),
145145+ };
146146+ },
147147+ 300,
148148+ ),
149149+ ),
150150+151151+ "/api/stats/verified-users": handleCORS(
152152+ createCachedEndpoint(
153153+ "verified_users_stats",
154154+ async () => {
155155+ // Get stats for verified user stories
156156+ const verifiedStories = await db.query.stories.findMany({
157157+ where: (stories, { eq }) => eq(stories.isFromMonitoredUser, true),
158158+ });
159159+ // Get count of verified users in the system
160160+ const verifiedUsersCount = await db.query.users
161161+ .findMany({
162162+ where: (users, { eq }) => eq(users.verified, true),
163163+ })
164164+ .then((users) => users.length);
165165+166166+ // Count stories on front page (rank <= 30)
167167+ const frontPageCount = verifiedStories.filter(
168168+ (s) => s.isOnLeaderboard,
169169+ ).length;
139170140140- // Count stories on front page (rank <= 30)
141141- const frontPageCount = verifiedStories.filter(
142142- (s) => s.isOnLeaderboard,
143143- ).length;
171171+ // Calculate average peak points for verified users
172172+ let totalPeakPoints = 0;
173173+ for (const s of verifiedStories) {
174174+ if (s.peakScore) totalPeakPoints += s.peakScore;
175175+ }
176176+ const avgPeakPoints = verifiedStories.length
177177+ ? Math.round(totalPeakPoints / verifiedStories.length)
178178+ : 0;
144179145145- // Calculate average peak points for verified users
146146- let totalPeakPoints = 0;
147147- for (const s of verifiedStories) {
148148- if (s.peakScore) totalPeakPoints += s.peakScore;
149149- }
150150- const avgPeakPoints = verifiedStories.length
151151- ? Math.round(totalPeakPoints / verifiedStories.length)
152152- : 0;
180180+ return {
181181+ totalCount: verifiedUsersCount,
182182+ frontPageCount: frontPageCount,
183183+ avgPeakPoints: avgPeakPoints,
184184+ timestamp: Math.floor(Date.now() / 1000),
185185+ };
186186+ },
187187+ 300,
188188+ ),
189189+ ),
153190154154- return {
155155- totalCount: verifiedUsersCount,
156156- frontPageCount: frontPageCount,
157157- avgPeakPoints: avgPeakPoints,
158158- timestamp: Math.floor(Date.now() / 1000),
159159- };
160160- }, 300),
161161- "/api/story/:id/snapshots": async (req) => {
191191+ "/api/story/:id/snapshots": handleCORS(async (req) => {
162192 try {
163163- const storyId = Number.parseInt(req.params.id as string);
193193+ // Extract the story ID from the URL path
194194+ const url = new URL(req.url);
195195+ const match = url.pathname.match(/\/api\/story\/(\d+)\/snapshots/);
196196+ const storyId = Number.parseInt(match?.[1] ?? "") || Number.NaN;
164197 if (Number.isNaN(storyId)) {
165198 return new Response(JSON.stringify({ error: "Invalid story ID" }), {
166199 status: 400,
···186219 date: new Date(snapshot.timestamp * 1000).toISOString(),
187220 }));
188221 },
189189- 3600 // Cache story snapshots for 1 hour as they change less frequently
222222+ 3600, // Cache story snapshots for 1 hour as they change less frequently
190223 );
191224192225 // Execute the cached handler
···201234 },
202235 );
203236 }
204204- },
205205- "/health": (req) => {
237237+ }),
238238+239239+ "/health": handleCORS(async (req) => {
206240 const response = new Response(JSON.stringify({ status: "ok" }), {
207241 headers: {
208242 "Content-Type": "application/json",
···213247 },
214248 });
215249 return compressResponse(req, response);
216216- },
250250+ }),
251251+217252 "/slack": (res: Request) => {
253253+ // No CORS needed for Slack endpoints
218254 return slackApp.run(res);
219255 },
220256 },
+97
src/libs/cors.ts
···11+/**
22+ * CORS configuration for the application
33+ * This adds support for Cloudflare Insights specifically
44+ */
55+66+/**
77+ * Adds CORS headers to allow Cloudflare Insights
88+ * @param response The response to add CORS headers to
99+ * @param origin The request origin to use for Access-Control-Allow-Origin
1010+ * @returns A new response with added CORS headers
1111+ */
1212+function addCloudflareInsightsCors(
1313+ response: Response,
1414+ origin: string,
1515+): Response {
1616+ // Get existing headers
1717+ const headers = new Headers(response.headers);
1818+1919+ // Add CORS headers specifically for Cloudflare Insights
2020+ // Use the request's origin if it matches allowed origins
2121+ headers.set("Access-Control-Allow-Origin", origin);
2222+ headers.set("Access-Control-Allow-Methods", "GET, OPTIONS, POST");
2323+ headers.set("Access-Control-Allow-Headers", "Content-Type");
2424+ headers.set("Access-Control-Max-Age", "86400"); // Cache preflight for 24 hours
2525+2626+ // For browser caching
2727+ headers.append("Vary", "Origin");
2828+2929+ // Create a new response with the original body and updated headers
3030+ return new Response(response.body, {
3131+ status: response.status,
3232+ statusText: response.statusText,
3333+ headers,
3434+ });
3535+}
3636+3737+/**
3838+ * Handles OPTIONS preflight requests for CORS
3939+ * @param req The original request
4040+ * @returns A response for preflight requests
4141+ */
4242+function handleCorsPreflightRequest(req: Request): Response {
4343+ const headers = new Headers();
4444+ const origin = req.headers.get("Origin");
4545+4646+ // List of allowed origins
4747+ const allowedOrigins: string[] = [];
4848+4949+ // Only set the Access-Control-Allow-Origin if the origin is in our allowed list
5050+ if (origin && allowedOrigins.includes(origin)) {
5151+ headers.set("Access-Control-Allow-Origin", origin);
5252+ }
5353+5454+ headers.set("Access-Control-Allow-Methods", "GET, OPTIONS, POST");
5555+ headers.set("Access-Control-Allow-Headers", "Content-Type");
5656+ headers.set("Access-Control-Max-Age", "86400"); // Cache preflight for 24 hours
5757+ headers.set("Vary", "Origin");
5858+5959+ // Return 204 No Content for preflight requests
6060+ return new Response(null, {
6161+ status: 204,
6262+ headers,
6363+ });
6464+}
6565+6666+/**
6767+ * Higher-order function that adds CORS support to a request handler
6868+ * Specifically configured for Cloudflare Insights requests
6969+ * @param handler The original request handler function
7070+ * @returns A new handler function with CORS support added
7171+ */
7272+export function handleCORS(
7373+ handler: (req: Request) => Response | Promise<Response>,
7474+): (req: Request) => Promise<Response> {
7575+ return async (req: Request) => {
7676+ const origin = req.headers.get("Origin");
7777+ const allowedOrigins = [
7878+ "https://static.cloudflareinsights.com",
7979+ "https://cloudflareinsights.com",
8080+ ];
8181+8282+ // Handle OPTIONS preflight requests
8383+ if (req.method === "OPTIONS") {
8484+ return handleCorsPreflightRequest(req);
8585+ }
8686+8787+ // Process the request normally then add CORS headers
8888+ const response = await handler(req);
8989+9090+ // Only add CORS headers if the origin is in our allowed list
9191+ if (origin && allowedOrigins.includes(origin)) {
9292+ return addCloudflareInsightsCors(response, origin);
9393+ }
9494+9595+ return response;
9696+ };
9797+}