a cache for slack profile pictures and emojis

feat: move to a typed routes system

dunkirk.sh f44b7d71 ec4ce6eb

verified
+24 -2
src/cache.ts
···
}
/**
* Records a request for analytics
* @param endpoint The endpoint that was accessed
* @param method HTTP method
···
// Clean up old cache entries (keep only last 5)
if (this.analyticsCache.size > 5) {
-
const oldestKey = Array.from(this.analyticsCache.keys())[0];
-
this.analyticsCache.delete(oldestKey);
}
return result;
···
}
/**
+
* Get all emojis from the cache
+
* @returns Array of all non-expired emojis
+
*/
+
async getAllEmojis(): Promise<Emoji[]> {
+
const results = this.db
+
.query("SELECT * FROM emojis WHERE expiration > ?")
+
.all(Date.now()) as Emoji[];
+
+
return results.map(result => ({
+
type: "emoji",
+
id: result.id,
+
name: result.name,
+
alias: result.alias || null,
+
imageUrl: result.imageUrl,
+
expiration: new Date(result.expiration),
+
}));
+
}
+
+
/**
* Records a request for analytics
* @param endpoint The endpoint that was accessed
* @param method HTTP method
···
// Clean up old cache entries (keep only last 5)
if (this.analyticsCache.size > 5) {
+
const keys = Array.from(this.analyticsCache.keys());
+
const oldestKey = keys[0];
+
if (oldestKey) {
+
this.analyticsCache.delete(oldestKey);
+
}
}
return result;
+295
src/handlers/index.ts
···
···
+
/**
+
* All route handler functions extracted for reuse
+
*/
+
+
import * as Sentry from "@sentry/bun";
+
import type { SlackUser } from "../slack";
+
import type { RouteHandlerWithAnalytics } from "../lib/analytics-wrapper";
+
+
// These will be injected by the route system
+
let cache: any;
+
let slackApp: any;
+
+
export function injectDependencies(cacheInstance: any, slackInstance: any) {
+
cache = cacheInstance;
+
slackApp = slackInstance;
+
}
+
+
export const handleHealthCheck: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const isHealthy = await cache.healthCheck();
+
if (isHealthy) {
+
await recordAnalytics(200);
+
return Response.json({
+
status: "healthy",
+
cache: true,
+
uptime: process.uptime(),
+
});
+
} else {
+
await recordAnalytics(503);
+
return Response.json(
+
{ status: "unhealthy", error: "Cache connection failed" },
+
{ status: 503 },
+
);
+
}
+
};
+
+
export const handleGetUser: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const url = new URL(request.url);
+
const userId = url.pathname.split("/").pop() || "";
+
const user = await cache.getUser(userId);
+
+
if (!user || !user.imageUrl) {
+
let slackUser: SlackUser;
+
try {
+
slackUser = await slackApp.getUserInfo(userId);
+
} catch (e) {
+
if (e instanceof Error && e.message === "user_not_found") {
+
await recordAnalytics(404);
+
return Response.json({ message: "User not found" }, { status: 404 });
+
}
+
+
Sentry.withScope((scope) => {
+
scope.setExtra("url", request.url);
+
scope.setExtra("user", userId);
+
Sentry.captureException(e);
+
});
+
+
await recordAnalytics(500);
+
return Response.json(
+
{ message: "Internal server error" },
+
{ status: 500 },
+
);
+
}
+
+
await cache.insertUser(
+
slackUser.id,
+
slackUser.real_name || slackUser.name || "Unknown",
+
slackUser.profile?.pronouns || "",
+
slackUser.profile?.image_512 || slackUser.profile?.image_192 || "",
+
);
+
+
await recordAnalytics(200);
+
return Response.json({
+
id: slackUser.id,
+
userId: slackUser.id,
+
displayName: slackUser.real_name || slackUser.name || "Unknown",
+
pronouns: slackUser.profile?.pronouns || "",
+
imageUrl: slackUser.profile?.image_512 || slackUser.profile?.image_192 || "",
+
});
+
}
+
+
await recordAnalytics(200);
+
return Response.json(user);
+
};
+
+
export const handleUserRedirect: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const url = new URL(request.url);
+
const parts = url.pathname.split("/");
+
const userId = parts[2] || "";
+
const user = await cache.getUser(userId);
+
+
if (!user || !user.imageUrl) {
+
let slackUser: SlackUser;
+
try {
+
slackUser = await slackApp.getUserInfo(userId.toUpperCase());
+
} catch (e) {
+
if (e instanceof Error && e.message === "user_not_found") {
+
console.warn(`⚠️ WARN user not found: ${userId}`);
+
+
await recordAnalytics(307);
+
return new Response(null, {
+
status: 307,
+
headers: {
+
Location: "https://ca.slack-edge.com/T0266FRGM-U0266FRGP-g28a1f281330-512",
+
},
+
});
+
}
+
+
Sentry.withScope((scope) => {
+
scope.setExtra("url", request.url);
+
scope.setExtra("user", userId);
+
Sentry.captureException(e);
+
});
+
+
await recordAnalytics(500);
+
return Response.json(
+
{ message: "Internal server error" },
+
{ status: 500 },
+
);
+
}
+
+
await cache.insertUser(
+
slackUser.id,
+
slackUser.real_name || slackUser.name || "Unknown",
+
slackUser.profile?.pronouns || "",
+
slackUser.profile?.image_512 || slackUser.profile?.image_192 || "",
+
);
+
+
await recordAnalytics(302);
+
return new Response(null, {
+
status: 302,
+
headers: {
+
Location: slackUser.profile?.image_512 || slackUser.profile?.image_192 || "",
+
},
+
});
+
}
+
+
await recordAnalytics(302);
+
return new Response(null, {
+
status: 302,
+
headers: { Location: user.imageUrl },
+
});
+
};
+
+
export const handlePurgeUser: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const authHeader = request.headers.get("authorization") || "";
+
if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
+
await recordAnalytics(401);
+
return new Response("Unauthorized", { status: 401 });
+
}
+
+
const url = new URL(request.url);
+
const userId = url.pathname.split("/")[2] || "";
+
const result = await cache.purgeUserCache(userId);
+
+
await recordAnalytics(200);
+
return Response.json({
+
message: "User cache purged",
+
userId,
+
success: result,
+
});
+
};
+
+
export const handleListEmojis: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const emojis = await cache.getAllEmojis();
+
await recordAnalytics(200);
+
return Response.json(emojis);
+
};
+
+
export const handleGetEmoji: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const url = new URL(request.url);
+
const emojiName = url.pathname.split("/").pop() || "";
+
const emoji = await cache.getEmoji(emojiName);
+
+
if (!emoji) {
+
await recordAnalytics(404);
+
return Response.json({ message: "Emoji not found" }, { status: 404 });
+
}
+
+
await recordAnalytics(200);
+
return Response.json(emoji);
+
};
+
+
export const handleEmojiRedirect: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const url = new URL(request.url);
+
const parts = url.pathname.split("/");
+
const emojiName = parts[2] || "";
+
const emoji = await cache.getEmoji(emojiName);
+
+
if (!emoji) {
+
await recordAnalytics(404);
+
return Response.json({ message: "Emoji not found" }, { status: 404 });
+
}
+
+
await recordAnalytics(302);
+
return new Response(null, {
+
status: 302,
+
headers: { Location: emoji.imageUrl },
+
});
+
};
+
+
export const handleResetCache: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const authHeader = request.headers.get("authorization") || "";
+
if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
+
await recordAnalytics(401);
+
return new Response("Unauthorized", { status: 401 });
+
}
+
const result = await cache.purgeAll();
+
await recordAnalytics(200);
+
return Response.json(result);
+
};
+
+
export const handleGetEssentialStats: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const url = new URL(request.url);
+
const params = new URLSearchParams(url.search);
+
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
+
+
const stats = await cache.getEssentialStats(days);
+
await recordAnalytics(200);
+
return Response.json(stats);
+
};
+
+
export const handleGetChartData: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const url = new URL(request.url);
+
const params = new URLSearchParams(url.search);
+
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
+
+
const chartData = await cache.getChartData(days);
+
await recordAnalytics(200);
+
return Response.json(chartData);
+
};
+
+
export const handleGetUserAgents: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const url = new URL(request.url);
+
const params = new URLSearchParams(url.search);
+
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
+
+
const userAgents = await cache.getUserAgents(days);
+
await recordAnalytics(200);
+
return Response.json(userAgents);
+
};
+
+
export const handleGetStats: RouteHandlerWithAnalytics = async (
+
request,
+
recordAnalytics,
+
) => {
+
const url = new URL(request.url);
+
const params = new URLSearchParams(url.search);
+
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
+
+
const [essentialStats, chartData, userAgents] = await Promise.all([
+
cache.getEssentialStats(days),
+
cache.getChartData(days),
+
cache.getUserAgents(days),
+
]);
+
+
await recordAnalytics(200);
+
return Response.json({
+
...essentialStats,
+
chartData,
+
userAgents,
+
});
+
};
+44 -765
src/index.ts
···
import { SlackCache } from "./cache";
import { SlackWrapper } from "./slackWrapper";
import { getEmojiUrl } from "../utils/emojiHelper";
-
import type { SlackUser } from "./slack";
-
import swaggerSpec from "./swagger";
import dashboard from "./dashboard.html";
import swagger from "./swagger.html";
···
environment: process.env.NODE_ENV,
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.5,
-
ignoreErrors: [
-
// Ignore all 404-related errors
-
"Not Found",
-
"404",
-
"user_not_found",
-
"emoji_not_found",
-
],
});
} else {
console.warn("Sentry DSN not provided, error monitoring is disabled");
···
const emojiEntries = Object.entries(emojis)
.map(([name, url]) => {
if (typeof url === "string" && url.startsWith("alias:")) {
-
const aliasName = url.substring(6); // Remove 'alias:' prefix
const aliasUrl = emojis[aliasName] ?? getEmojiUrl(aliasName) ?? null;
if (aliasUrl === null) {
···
// Inject SlackWrapper into cache for background user updates
cache.setSlackWrapper(slackApp);
-
// Cache maintenance is now handled automatically by cache.ts scheduled tasks
-
-
// Start the server
-
const server = serve({
-
routes: {
-
// HTML routes
-
"/dashboard": dashboard,
-
"/swagger": swagger,
-
"/swagger.json": async (request) => {
-
return Response.json(swaggerSpec);
-
},
-
"/favicon.ico": async (request) => {
-
return new Response(Bun.file("./favicon.ico"));
-
},
-
-
// Root route - redirect to dashboard for browsers
-
"/": async (request) => {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
"/",
-
request.method,
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
const userAgent = request.headers.get("user-agent") || "";
-
if (
-
userAgent.toLowerCase().includes("mozilla") ||
-
userAgent.toLowerCase().includes("chrome") ||
-
userAgent.toLowerCase().includes("safari")
-
) {
-
recordAnalytics(302);
-
return new Response(null, {
-
status: 302,
-
headers: { Location: "/dashboard" },
-
});
-
}
-
recordAnalytics(200);
-
return new Response(
-
"Hello World from Cachet 😊\n\n---\nSee /swagger for docs\nSee /dashboard for analytics\n---",
-
);
-
},
-
// Health check endpoint
-
"/health": {
-
async GET(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
"/health",
-
"GET",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handleHealthCheck(request, recordAnalytics);
-
},
-
},
-
-
// User endpoints
-
"/users/:id": {
-
async GET(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
request.url,
-
"GET",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handleGetUser(request, recordAnalytics);
-
},
-
},
-
-
"/users/:id/r": {
-
async GET(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
request.url,
-
"GET",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handleUserRedirect(request, recordAnalytics);
-
},
-
},
-
-
"/users/:id/purge": {
-
async POST(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
request.url,
-
"POST",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handlePurgeUser(request, recordAnalytics);
-
},
-
},
-
-
// Emoji endpoints
-
"/emojis": {
-
async GET(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
"/emojis",
-
"GET",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handleListEmojis(request, recordAnalytics);
-
},
-
},
-
-
"/emojis/:name": {
-
async GET(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
request.url,
-
"GET",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handleGetEmoji(request, recordAnalytics);
-
},
-
},
-
-
"/emojis/:name/r": {
-
async GET(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
request.url,
-
"GET",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handleEmojiRedirect(request, recordAnalytics);
-
},
-
},
-
-
// Reset cache endpoint
-
"/reset": {
-
async POST(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
"/reset",
-
"POST",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handleResetCache(request, recordAnalytics);
-
},
-
},
-
-
// Fast essential stats endpoint - loads immediately
-
"/api/stats/essential": {
-
async GET(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
"/api/stats/essential",
-
"GET",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handleGetEssentialStats(request, recordAnalytics);
-
},
-
},
-
-
// Chart data endpoint - loads after essential stats
-
"/api/stats/charts": {
-
async GET(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
"/api/stats/charts",
-
"GET",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handleGetChartData(request, recordAnalytics);
-
},
-
},
-
-
// User agents endpoint - loads last
-
"/api/stats/useragents": {
-
async GET(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
"/api/stats/useragents",
-
"GET",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handleGetUserAgents(request, recordAnalytics);
-
},
-
},
-
-
// Original stats endpoint (for backwards compatibility)
-
"/stats": {
-
async GET(request) {
-
const startTime = Date.now();
-
const recordAnalytics = async (statusCode: number) => {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
"/stats",
-
"GET",
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
};
-
-
return handleGetStats(request, recordAnalytics);
-
},
-
},
},
-
-
// Enable development mode for hot reloading
-
development: {
-
hmr: true,
-
console: true,
},
-
// Fallback fetch handler for unmatched routes and error handling
-
async fetch(request) {
-
const url = new URL(request.url);
-
const path = url.pathname;
-
const method = request.method;
-
const startTime = Date.now();
-
// Record request analytics (except for favicon and swagger)
-
const recordAnalytics = async (statusCode: number) => {
-
if (path !== "/favicon.ico" && !path.startsWith("/swagger")) {
-
const userAgent = request.headers.get("user-agent") || "";
-
const ipAddress =
-
request.headers.get("x-forwarded-for") ||
-
request.headers.get("x-real-ip") ||
-
"unknown";
-
-
await cache.recordRequest(
-
path,
-
method,
-
statusCode,
-
userAgent,
-
ipAddress,
-
Date.now() - startTime,
-
);
-
}
-
};
-
-
try {
-
// Not found
-
recordAnalytics(404);
-
return new Response("Not Found", { status: 404 });
-
} catch (error) {
-
console.error(
-
`\x1b[31m x\x1b[0m unhandled error: \x1b[31m${error instanceof Error ? error.message : String(error)}\x1b[0m`,
-
);
-
-
// Don't send 404 errors to Sentry
-
const is404 =
-
error instanceof Error &&
-
(error.message === "Not Found" ||
-
error.message === "user_not_found" ||
-
error.message === "emoji_not_found");
-
-
if (!is404 && error instanceof Error) {
-
Sentry.withScope((scope) => {
-
scope.setExtra("url", request.url);
-
Sentry.captureException(error);
-
});
-
}
-
-
recordAnalytics(500);
-
return new Response("Internal Server Error", { status: 500 });
-
}
-
},
-
-
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
-
});
-
-
console.log(
-
`\n---\n\n🐰 Bun server is running at ${server.url} on ${process.env.NODE_ENV}\n\n---\n`,
-
);
-
-
// Handler functions
-
async function handleHealthCheck(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const slackConnection = await slackApp.testAuth();
-
const databaseConnection = await cache.healthCheck();
-
-
if (!slackConnection || !databaseConnection) {
-
await recordAnalytics(500);
-
return Response.json(
-
{
-
http: false,
-
slack: slackConnection,
-
database: databaseConnection,
-
},
-
{ status: 500 },
-
);
-
}
-
-
await recordAnalytics(200);
-
return Response.json({
-
http: true,
-
slack: true,
-
database: true,
-
});
-
}
-
-
async function handleGetUser(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const url = new URL(request.url);
-
const userId = url.pathname.split("/").pop() || "";
-
const user = await cache.getUser(userId);
-
-
// If not found then check slack first
-
if (!user || !user.imageUrl) {
-
let slackUser: SlackUser;
-
try {
-
slackUser = await slackApp.getUserInfo(userId);
-
} catch (e) {
-
if (e instanceof Error && e.message === "user_not_found") {
-
await recordAnalytics(404);
-
return Response.json({ message: "User not found" }, { status: 404 });
-
}
-
-
Sentry.withScope((scope) => {
-
scope.setExtra("url", request.url);
-
scope.setExtra("user", userId);
-
Sentry.captureException(e);
});
-
-
if (e instanceof Error)
-
console.warn(
-
`\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`,
-
);
-
-
await recordAnalytics(500);
-
return Response.json(
-
{ message: `Error fetching user from Slack: ${e}` },
-
{ status: 500 },
-
);
}
-
const displayName =
-
slackUser.profile.display_name_normalized ||
-
slackUser.profile.real_name_normalized;
-
-
await cache.insertUser(
-
slackUser.id,
-
displayName,
-
slackUser.profile.pronouns,
-
slackUser.profile.image_512,
);
-
-
await recordAnalytics(200);
-
return Response.json({
-
id: slackUser.id,
-
expiration: new Date().toISOString(),
-
user: slackUser.id,
-
displayName: displayName,
-
pronouns: slackUser.profile.pronouns || null,
-
image: slackUser.profile.image_512,
-
});
-
}
-
-
await recordAnalytics(200);
-
return Response.json({
-
id: user.id,
-
expiration: user.expiration.toISOString(),
-
user: user.userId,
-
displayName: user.displayName,
-
pronouns: user.pronouns,
-
image: user.imageUrl,
-
});
-
}
-
-
async function handleUserRedirect(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const url = new URL(request.url);
-
const parts = url.pathname.split("/");
-
const userId = parts[2] || "";
-
const user = await cache.getUser(userId);
-
-
// If not found then check slack first
-
if (!user || !user.imageUrl) {
-
let slackUser: SlackUser;
-
try {
-
slackUser = await slackApp.getUserInfo(userId.toUpperCase());
-
} catch (e) {
-
if (e instanceof Error && e.message === "user_not_found") {
-
console.warn(
-
`\x1b[38;5;214m ⚠️ WARN\x1b[0m user not found: \x1b[38;5;208m${userId}\x1b[0m`,
-
);
-
-
await recordAnalytics(307);
-
return new Response(null, {
-
status: 307,
-
headers: {
-
Location:
-
"https://api.dicebear.com/9.x/thumbs/svg?seed={username_hash}",
-
},
-
});
-
}
-
-
Sentry.withScope((scope) => {
-
scope.setExtra("url", request.url);
-
scope.setExtra("user", userId);
-
Sentry.captureException(e);
-
});
-
-
if (e instanceof Error)
-
console.warn(
-
`\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`,
-
);
-
-
await recordAnalytics(500);
-
return Response.json(
-
{ message: `Error fetching user from Slack: ${e}` },
-
{ status: 500 },
-
);
-
}
-
-
await cache.insertUser(
-
slackUser.id,
-
slackUser.profile.display_name_normalized ||
-
slackUser.profile.real_name_normalized,
-
slackUser.profile.pronouns,
-
slackUser.profile.image_512,
-
);
-
-
await recordAnalytics(302);
-
return new Response(null, {
-
status: 302,
-
headers: { Location: slackUser.profile.image_512 },
-
});
-
}
-
-
await recordAnalytics(302);
-
return new Response(null, {
-
status: 302,
-
headers: { Location: user.imageUrl },
-
});
-
}
-
-
async function handleListEmojis(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const emojis = await cache.listEmojis();
-
-
await recordAnalytics(200);
-
return Response.json(
-
emojis.map((emoji) => ({
-
id: emoji.id,
-
expiration: emoji.expiration.toISOString(),
-
name: emoji.name,
-
...(emoji.alias ? { alias: emoji.alias } : {}),
-
image: emoji.imageUrl,
-
})),
-
);
-
}
-
-
async function handleGetEmoji(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const url = new URL(request.url);
-
const emojiName = url.pathname.split("/").pop() || "";
-
const emoji = await cache.getEmoji(emojiName);
-
-
if (!emoji) {
-
const fallbackUrl = getEmojiUrl(emojiName);
-
if (!fallbackUrl) {
-
await recordAnalytics(404);
-
return Response.json({ message: "Emoji not found" }, { status: 404 });
-
}
-
-
await recordAnalytics(200);
-
return Response.json({
-
id: null,
-
expiration: new Date().toISOString(),
-
name: emojiName,
-
image: fallbackUrl,
-
});
-
}
-
-
await recordAnalytics(200);
-
return Response.json({
-
id: emoji.id,
-
expiration: emoji.expiration.toISOString(),
-
name: emoji.name,
-
...(emoji.alias ? { alias: emoji.alias } : {}),
-
image: emoji.imageUrl,
-
});
-
}
-
-
async function handleEmojiRedirect(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const url = new URL(request.url);
-
const parts = url.pathname.split("/");
-
const emojiName = parts[2] || "";
-
const emoji = await cache.getEmoji(emojiName);
-
-
if (!emoji) {
-
const fallbackUrl = getEmojiUrl(emojiName);
-
if (!fallbackUrl) {
-
await recordAnalytics(404);
-
return Response.json({ message: "Emoji not found" }, { status: 404 });
-
}
-
-
await recordAnalytics(302);
-
return new Response(null, {
-
status: 302,
-
headers: { Location: fallbackUrl },
-
});
-
}
-
-
await recordAnalytics(302);
-
return new Response(null, {
-
status: 302,
-
headers: { Location: emoji.imageUrl },
-
});
-
}
-
-
async function handleResetCache(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const authHeader = request.headers.get("authorization") || "";
-
-
if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
-
await recordAnalytics(401);
-
return new Response("Unauthorized", { status: 401 });
-
}
-
const result = await cache.purgeAll();
-
await recordAnalytics(200);
-
return Response.json(result);
-
}
-
async function handlePurgeUser(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const authHeader = request.headers.get("authorization") || "";
-
if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
-
await recordAnalytics(401);
-
return new Response("Unauthorized", { status: 401 });
-
}
-
const url = new URL(request.url);
-
const parts = url.pathname.split("/");
-
const userId = parts[2] || "";
-
const success = await cache.purgeUserCache(userId);
-
-
await recordAnalytics(200);
-
return Response.json({
-
message: success ? "User cache purged" : "User not found in cache",
-
userId: userId,
-
success,
-
});
-
}
-
-
async function handleGetStats(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const url = new URL(request.url);
-
const params = new URLSearchParams(url.search);
-
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
-
const analytics = await cache.getAnalytics(days);
-
-
await recordAnalytics(200);
-
return Response.json(analytics);
-
}
-
-
// Fast essential stats - just the 3 key metrics
-
async function handleGetEssentialStats(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const url = new URL(request.url);
-
const params = new URLSearchParams(url.search);
-
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
-
-
const essentialStats = await cache.getEssentialStats(days);
-
-
await recordAnalytics(200);
-
return Response.json(essentialStats);
-
}
-
-
// Chart data - requests and latency over time
-
async function handleGetChartData(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const url = new URL(request.url);
-
const params = new URLSearchParams(url.search);
-
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
-
-
const chartData = await cache.getChartData(days);
-
-
await recordAnalytics(200);
-
return Response.json(chartData);
-
}
-
-
// User agents data - slowest loading part
-
async function handleGetUserAgents(
-
request: Request,
-
recordAnalytics: (statusCode: number) => Promise<void>,
-
) {
-
const url = new URL(request.url);
-
const params = new URLSearchParams(url.search);
-
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
-
-
const userAgents = await cache.getUserAgents(days);
-
-
await recordAnalytics(200);
-
return Response.json(userAgents);
-
}
-
-
// Cache maintenance is now handled by scheduled tasks in cache.ts
-
// No aggressive daily purge needed - users will lazy load with longer TTL
···
import { SlackCache } from "./cache";
import { SlackWrapper } from "./slackWrapper";
import { getEmojiUrl } from "../utils/emojiHelper";
+
import { createApiRoutes } from "./routes/api-routes";
+
import { buildRoutes, getSwaggerSpec } from "./lib/route-builder";
import dashboard from "./dashboard.html";
import swagger from "./swagger.html";
···
environment: process.env.NODE_ENV,
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.5,
+
ignoreErrors: ["Not Found", "404", "user_not_found", "emoji_not_found"],
});
} else {
console.warn("Sentry DSN not provided, error monitoring is disabled");
···
const emojiEntries = Object.entries(emojis)
.map(([name, url]) => {
if (typeof url === "string" && url.startsWith("alias:")) {
+
const aliasName = url.substring(6);
const aliasUrl = emojis[aliasName] ?? getEmojiUrl(aliasName) ?? null;
if (aliasUrl === null) {
···
// Inject SlackWrapper into cache for background user updates
cache.setSlackWrapper(slackApp);
+
// Create the typed API routes with injected dependencies
+
const apiRoutes = createApiRoutes(cache, slackApp);
+
// Build Bun-compatible routes and generate Swagger
+
const typedRoutes = buildRoutes(apiRoutes);
+
const generatedSwagger = getSwaggerSpec();
+
// Legacy routes (non-API)
+
const legacyRoutes = {
+
"/dashboard": dashboard,
+
"/swagger": swagger,
+
"/swagger.json": async (request: Request) => {
+
return Response.json(generatedSwagger);
},
+
"/favicon.ico": async (request: Request) => {
+
return new Response(Bun.file("./favicon.ico"));
},
+
// Root route - redirect to dashboard for browsers
+
"/": async (request: Request) => {
+
const userAgent = request.headers.get("user-agent") || "";
+
if (
+
userAgent.toLowerCase().includes("mozilla") ||
+
userAgent.toLowerCase().includes("chrome") ||
+
userAgent.toLowerCase().includes("safari")
+
) {
+
return new Response(null, {
+
status: 302,
+
headers: { Location: "/dashboard" },
});
}
+
return new Response(
+
"Hello World from Cachet 😊\n\n---\nSee /swagger for docs\nSee /dashboard for analytics\n---",
);
+
},
+
};
+
// Merge all routes
+
const allRoutes = {
+
...legacyRoutes,
+
...typedRoutes,
+
};
+
// Start the server
+
const server = serve({
+
routes: allRoutes,
+
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
+
});
+
console.log(`🚀 Server running on http://localhost:${server.port}`);
+
export { cache, slackApp };
+56
src/lib/analytics-wrapper.ts
···
···
+
/**
+
* Analytics wrapper utility to eliminate boilerplate in route handlers
+
*/
+
+
// Cache will be injected by the route system
+
+
export type AnalyticsRecorder = (statusCode: number) => Promise<void>;
+
export type RouteHandlerWithAnalytics = (request: Request, recordAnalytics: AnalyticsRecorder) => Promise<Response> | Response;
+
+
/**
+
* Creates analytics wrapper with injected cache
+
*/
+
export function createAnalyticsWrapper(cache: any) {
+
return function withAnalytics(
+
path: string,
+
method: string,
+
handler: RouteHandlerWithAnalytics
+
) {
+
return async (request: Request): Promise<Response> => {
+
const startTime = Date.now();
+
+
const recordAnalytics: AnalyticsRecorder = async (statusCode: number) => {
+
const userAgent = request.headers.get("user-agent") || "";
+
const ipAddress =
+
request.headers.get("x-forwarded-for") ||
+
request.headers.get("x-real-ip") ||
+
"unknown";
+
+
// Use the actual request URL for dynamic paths, fallback to provided path
+
const analyticsPath = path.includes(":") ? request.url : path;
+
+
await cache.recordRequest(
+
analyticsPath,
+
method,
+
statusCode,
+
userAgent,
+
ipAddress,
+
Date.now() - startTime,
+
);
+
};
+
+
return handler(request, recordAnalytics);
+
};
+
};
+
}
+
+
/**
+
* Type-safe analytics wrapper that automatically infers path and method
+
*/
+
export function createAnalyticsHandler(
+
path: string,
+
method: string
+
) {
+
return (handler: RouteHandlerWithAnalytics) =>
+
withAnalytics(path, method, handler);
+
}
+56
src/lib/route-builder.ts
···
···
+
/**
+
* Utility to build Bun-compatible routes from typed route definitions
+
* and generate Swagger documentation
+
*/
+
+
import type { RouteDefinition } from "../types/routes";
+
import { swaggerGenerator } from "./swagger-generator";
+
+
/**
+
* Convert typed routes to Bun server format and generate Swagger
+
*/
+
export function buildRoutes(typedRoutes: Record<string, RouteDefinition>) {
+
// Generate Swagger from typed routes
+
swaggerGenerator.addRoutes(typedRoutes);
+
+
// Convert to Bun server format
+
const bunRoutes: Record<string, any> = {};
+
+
Object.entries(typedRoutes).forEach(([path, routeConfig]) => {
+
const bunRoute: Record<string, any> = {};
+
+
// Convert each HTTP method
+
Object.entries(routeConfig).forEach(([method, typedRoute]) => {
+
if (typedRoute && 'handler' in typedRoute) {
+
bunRoute[method] = typedRoute.handler;
+
}
+
});
+
+
bunRoutes[path] = bunRoute;
+
});
+
+
return bunRoutes;
+
}
+
+
/**
+
* Get the generated Swagger specification
+
*/
+
export function getSwaggerSpec() {
+
return swaggerGenerator.getSpec();
+
}
+
+
/**
+
* Merge typed routes with existing legacy routes
+
* This allows gradual migration
+
*/
+
export function mergeRoutes(
+
typedRoutes: Record<string, RouteDefinition>,
+
legacyRoutes: Record<string, any>
+
) {
+
const builtRoutes = buildRoutes(typedRoutes);
+
+
return {
+
...legacyRoutes,
+
...builtRoutes,
+
};
+
}
+203
src/lib/swagger-generator.ts
···
···
+
/**
+
* Generates Swagger/OpenAPI specifications from typed route definitions
+
*/
+
+
import { version } from "../../package.json";
+
import type { RouteDefinition, RouteMetadata, RouteParam, HttpMethod } from "../types/routes";
+
+
interface SwaggerSpec {
+
openapi: string;
+
info: {
+
title: string;
+
version: string;
+
description: string;
+
contact: {
+
name: string;
+
email: string;
+
};
+
license: {
+
name: string;
+
url: string;
+
};
+
};
+
paths: Record<string, any>;
+
components?: {
+
securitySchemes?: any;
+
};
+
}
+
+
export class SwaggerGenerator {
+
private spec: SwaggerSpec;
+
+
constructor() {
+
this.spec = {
+
openapi: "3.0.0",
+
info: {
+
title: "Cachet",
+
version: version,
+
description: "A high-performance cache and proxy for Slack profile pictures and emojis with comprehensive analytics.",
+
contact: {
+
name: "Kieran Klukas",
+
email: "me@dunkirk.sh",
+
},
+
license: {
+
name: "AGPL 3.0",
+
url: "https://github.com/taciturnaxolotl/cachet/blob/main/LICENSE.md",
+
},
+
},
+
paths: {},
+
components: {
+
securitySchemes: {
+
bearerAuth: {
+
type: "http",
+
scheme: "bearer",
+
},
+
},
+
},
+
};
+
}
+
+
/**
+
* Add routes to the Swagger specification
+
*/
+
addRoutes(routes: Record<string, RouteDefinition | any>) {
+
Object.entries(routes).forEach(([path, routeConfig]) => {
+
// Skip non-API routes
+
if (typeof routeConfig === 'function' ||
+
path.includes('dashboard') ||
+
path.includes('swagger') ||
+
path.includes('favicon')) {
+
return;
+
}
+
+
this.addRoute(path, routeConfig);
+
});
+
}
+
+
/**
+
* Add a single route to the specification
+
*/
+
private addRoute(path: string, routeConfig: RouteDefinition) {
+
const swaggerPath = this.convertPathToSwagger(path);
+
+
if (!this.spec.paths[swaggerPath]) {
+
this.spec.paths[swaggerPath] = {};
+
}
+
+
// Process each HTTP method
+
Object.entries(routeConfig).forEach(([method, typedRoute]) => {
+
if (typeof typedRoute === 'object' && 'handler' in typedRoute && 'metadata' in typedRoute) {
+
const swaggerMethod = method.toLowerCase();
+
this.spec.paths[swaggerPath][swaggerMethod] = this.buildMethodSpec(
+
method as HttpMethod,
+
typedRoute.metadata
+
);
+
}
+
});
+
}
+
+
/**
+
* Convert Express-style path to Swagger format
+
* /users/:id -> /users/{id}
+
*/
+
private convertPathToSwagger(path: string): string {
+
return path.replace(/:([^/]+)/g, '{$1}');
+
}
+
+
/**
+
* Build Swagger specification for a single method
+
*/
+
private buildMethodSpec(method: HttpMethod, metadata: RouteMetadata) {
+
const spec: any = {
+
summary: metadata.summary,
+
description: metadata.description,
+
tags: metadata.tags || ['API'],
+
responses: {},
+
};
+
+
// Add parameters
+
if (metadata.parameters) {
+
spec.parameters = [];
+
+
// Path parameters
+
if (metadata.parameters.path) {
+
metadata.parameters.path.forEach(param => {
+
spec.parameters.push(this.buildParameterSpec(param, 'path'));
+
});
+
}
+
+
// Query parameters
+
if (metadata.parameters.query) {
+
metadata.parameters.query.forEach(param => {
+
spec.parameters.push(this.buildParameterSpec(param, 'query'));
+
});
+
}
+
+
// Request body
+
if (metadata.parameters.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
+
spec.requestBody = {
+
required: true,
+
content: {
+
'application/json': {
+
schema: metadata.parameters.body,
+
},
+
},
+
};
+
}
+
}
+
+
// Add responses
+
Object.entries(metadata.responses).forEach(([status, response]) => {
+
spec.responses[status] = {
+
description: response.description,
+
...(response.schema && {
+
content: {
+
'application/json': {
+
schema: response.schema,
+
},
+
},
+
}),
+
};
+
});
+
+
// Add security if required
+
if (metadata.requiresAuth) {
+
spec.security = [{ bearerAuth: [] }];
+
}
+
+
return spec;
+
}
+
+
/**
+
* Build parameter specification
+
*/
+
private buildParameterSpec(param: RouteParam, location: 'path' | 'query') {
+
return {
+
name: param.name,
+
in: location,
+
required: param.required,
+
description: param.description,
+
schema: {
+
type: param.type,
+
...(param.example && { example: param.example }),
+
},
+
};
+
}
+
+
/**
+
* Get the complete Swagger specification
+
*/
+
getSpec(): SwaggerSpec {
+
return this.spec;
+
}
+
+
/**
+
* Generate JSON string of the specification
+
*/
+
toJSON(): string {
+
return JSON.stringify(this.spec, null, 2);
+
}
+
}
+
+
// Export singleton instance
+
export const swaggerGenerator = new SwaggerGenerator();
+310
src/routes/api-routes.ts
···
···
+
/**
+
* Complete typed route definitions for all Cachet API endpoints
+
*/
+
+
import {
+
createRoute,
+
pathParam,
+
queryParam,
+
apiResponse,
+
type RouteDefinition
+
} from "../types/routes";
+
import { createAnalyticsWrapper } from "../lib/analytics-wrapper";
+
import * as handlers from "../handlers";
+
+
// Factory function to create all routes with injected dependencies
+
export function createApiRoutes(cache: any, slackApp: any) {
+
// Inject dependencies into handlers
+
handlers.injectDependencies(cache, slackApp);
+
+
const withAnalytics = createAnalyticsWrapper(cache);
+
+
return {
+
"/health": {
+
GET: createRoute(
+
withAnalytics("/health", "GET", handlers.handleHealthCheck),
+
{
+
summary: "Health check",
+
description: "Check if the service is healthy and operational",
+
tags: ["Health"],
+
responses: Object.fromEntries([
+
apiResponse(200, "Service is healthy", {
+
type: "object",
+
properties: {
+
status: { type: "string", example: "healthy" },
+
cache: { type: "boolean", example: true },
+
uptime: { type: "number", example: 123456 }
+
}
+
}),
+
apiResponse(503, "Service is unhealthy")
+
])
+
}
+
)
+
},
+
+
"/users/:id": {
+
GET: createRoute(
+
withAnalytics("/users/:id", "GET", handlers.handleGetUser),
+
{
+
summary: "Get user information",
+
description: "Retrieve cached user profile information from Slack",
+
tags: ["Users"],
+
parameters: {
+
path: [pathParam("id", "string", "Slack user ID", "U062UG485EE")]
+
},
+
responses: Object.fromEntries([
+
apiResponse(200, "User information retrieved successfully", {
+
type: "object",
+
properties: {
+
id: { type: "string", example: "U062UG485EE" },
+
userId: { type: "string", example: "U062UG485EE" },
+
displayName: { type: "string", example: "Kieran Klukas" },
+
pronouns: { type: "string", example: "he/him" },
+
imageUrl: { type: "string", example: "https://avatars.slack-edge.com/..." }
+
}
+
}),
+
apiResponse(404, "User not found")
+
])
+
}
+
)
+
},
+
+
"/users/:id/r": {
+
GET: createRoute(
+
withAnalytics("/users/:id/r", "GET", handlers.handleUserRedirect),
+
{
+
summary: "Redirect to user profile image",
+
description: "Direct redirect to the user's cached profile image URL",
+
tags: ["Users"],
+
parameters: {
+
path: [pathParam("id", "string", "Slack user ID", "U062UG485EE")]
+
},
+
responses: Object.fromEntries([
+
apiResponse(302, "Redirect to user image"),
+
apiResponse(307, "Temporary redirect to default avatar"),
+
apiResponse(404, "User not found")
+
])
+
}
+
)
+
},
+
+
"/users/:id/purge": {
+
POST: createRoute(
+
withAnalytics("/users/:id/purge", "POST", handlers.handlePurgeUser),
+
{
+
summary: "Purge user cache",
+
description: "Remove a specific user from the cache (requires authentication)",
+
tags: ["Users", "Admin"],
+
requiresAuth: true,
+
parameters: {
+
path: [pathParam("id", "string", "Slack user ID to purge", "U062UG485EE")]
+
},
+
responses: Object.fromEntries([
+
apiResponse(200, "User cache purged successfully", {
+
type: "object",
+
properties: {
+
message: { type: "string", example: "User cache purged" },
+
userId: { type: "string", example: "U062UG485EE" },
+
success: { type: "boolean", example: true }
+
}
+
}),
+
apiResponse(401, "Unauthorized")
+
])
+
}
+
)
+
},
+
+
"/emojis": {
+
GET: createRoute(
+
withAnalytics("/emojis", "GET", handlers.handleListEmojis),
+
{
+
summary: "List all emojis",
+
description: "Get a list of all cached custom emojis from the Slack workspace",
+
tags: ["Emojis"],
+
responses: Object.fromEntries([
+
apiResponse(200, "List of emojis retrieved successfully", {
+
type: "array",
+
items: {
+
type: "object",
+
properties: {
+
name: { type: "string", example: "hackshark" },
+
imageUrl: { type: "string", example: "https://emoji.slack-edge.com/..." },
+
alias: { type: "string", nullable: true, example: null }
+
}
+
}
+
})
+
])
+
}
+
)
+
},
+
+
"/emojis/:name": {
+
GET: createRoute(
+
withAnalytics("/emojis/:name", "GET", handlers.handleGetEmoji),
+
{
+
summary: "Get emoji information",
+
description: "Retrieve information about a specific custom emoji",
+
tags: ["Emojis"],
+
parameters: {
+
path: [pathParam("name", "string", "Emoji name (without colons)", "hackshark")]
+
},
+
responses: Object.fromEntries([
+
apiResponse(200, "Emoji information retrieved successfully", {
+
type: "object",
+
properties: {
+
name: { type: "string", example: "hackshark" },
+
imageUrl: { type: "string", example: "https://emoji.slack-edge.com/..." },
+
alias: { type: "string", nullable: true, example: null }
+
}
+
}),
+
apiResponse(404, "Emoji not found")
+
])
+
}
+
)
+
},
+
+
"/emojis/:name/r": {
+
GET: createRoute(
+
withAnalytics("/emojis/:name/r", "GET", handlers.handleEmojiRedirect),
+
{
+
summary: "Redirect to emoji image",
+
description: "Direct redirect to the emoji's cached image URL",
+
tags: ["Emojis"],
+
parameters: {
+
path: [pathParam("name", "string", "Emoji name (without colons)", "hackshark")]
+
},
+
responses: Object.fromEntries([
+
apiResponse(302, "Redirect to emoji image"),
+
apiResponse(404, "Emoji not found")
+
])
+
}
+
)
+
},
+
+
"/reset": {
+
POST: createRoute(
+
withAnalytics("/reset", "POST", handlers.handleResetCache),
+
{
+
summary: "Reset entire cache",
+
description: "Clear all cached data (requires authentication)",
+
tags: ["Admin"],
+
requiresAuth: true,
+
responses: Object.fromEntries([
+
apiResponse(200, "Cache reset successfully", {
+
type: "object",
+
properties: {
+
message: { type: "string", example: "Cache has been reset" },
+
users: { type: "number", example: 42 },
+
emojis: { type: "number", example: 1337 }
+
}
+
}),
+
apiResponse(401, "Unauthorized")
+
])
+
}
+
)
+
},
+
+
"/api/stats/essential": {
+
GET: createRoute(
+
withAnalytics("/api/stats/essential", "GET", handlers.handleGetEssentialStats),
+
{
+
summary: "Get essential analytics",
+
description: "Fast-loading essential statistics for the dashboard",
+
tags: ["Analytics"],
+
parameters: {
+
query: [queryParam("days", "number", "Number of days to analyze", false, 7)]
+
},
+
responses: Object.fromEntries([
+
apiResponse(200, "Essential stats retrieved successfully", {
+
type: "object",
+
properties: {
+
totalRequests: { type: "number", example: 12345 },
+
averageResponseTime: { type: "number", example: 23.5 },
+
uptime: { type: "number", example: 99.9 },
+
period: { type: "string", example: "7 days" }
+
}
+
})
+
])
+
}
+
)
+
},
+
+
"/api/stats/charts": {
+
GET: createRoute(
+
withAnalytics("/api/stats/charts", "GET", handlers.handleGetChartData),
+
{
+
summary: "Get chart data",
+
description: "Time-series data for request and latency charts",
+
tags: ["Analytics"],
+
parameters: {
+
query: [queryParam("days", "number", "Number of days to analyze", false, 7)]
+
},
+
responses: Object.fromEntries([
+
apiResponse(200, "Chart data retrieved successfully", {
+
type: "array",
+
items: {
+
type: "object",
+
properties: {
+
time: { type: "string", example: "2024-01-01T12:00:00Z" },
+
count: { type: "number", example: 42 },
+
averageResponseTime: { type: "number", example: 25.3 }
+
}
+
}
+
})
+
])
+
}
+
)
+
},
+
+
"/api/stats/useragents": {
+
GET: createRoute(
+
withAnalytics("/api/stats/useragents", "GET", handlers.handleGetUserAgents),
+
{
+
summary: "Get user agents statistics",
+
description: "List of user agents accessing the service with request counts",
+
tags: ["Analytics"],
+
parameters: {
+
query: [queryParam("days", "number", "Number of days to analyze", false, 7)]
+
},
+
responses: Object.fromEntries([
+
apiResponse(200, "User agents data retrieved successfully", {
+
type: "array",
+
items: {
+
type: "object",
+
properties: {
+
userAgent: { type: "string", example: "Mozilla/5.0..." },
+
count: { type: "number", example: 123 }
+
}
+
}
+
})
+
])
+
}
+
)
+
},
+
+
"/stats": {
+
GET: createRoute(
+
withAnalytics("/stats", "GET", handlers.handleGetStats),
+
{
+
summary: "Get complete analytics (legacy)",
+
description: "Legacy endpoint returning all analytics data in one response",
+
tags: ["Analytics", "Legacy"],
+
parameters: {
+
query: [queryParam("days", "number", "Number of days to analyze", false, 7)]
+
},
+
responses: Object.fromEntries([
+
apiResponse(200, "Complete analytics data retrieved", {
+
type: "object",
+
properties: {
+
totalRequests: { type: "number" },
+
averageResponseTime: { type: "number" },
+
chartData: { type: "array" },
+
userAgents: { type: "array" }
+
}
+
})
+
])
+
}
+
)
+
}
+
};
+
}
-553
src/swagger.ts
···
-
import { version } from "../package.json";
-
-
// Define the Swagger specification
-
const swaggerSpec = {
-
openapi: "3.0.0",
-
info: {
-
title: "Cachet",
-
version: version,
-
description:
-
"Hi 👋\n\nThis is a pretty simple API that acts as a middleman caching layer between slack and the outside world. There may be authentication in the future, but for now, it's just a simple cache.\n\nThe `/r` endpoints are redirects to the actual image URLs, so you can use them as direct image links.",
-
contact: {
-
name: "Kieran Klukas",
-
email: "me@dunkirk.sh",
-
},
-
license: {
-
name: "AGPL 3.0",
-
url: "https://github.com/taciturnaxolotl/cachet/blob/main/LICENSE.md",
-
},
-
},
-
tags: [
-
{
-
name: "The Cache!",
-
description: "*must be read in an ominous voice*",
-
},
-
{
-
name: "Status",
-
description: "*Rather boring status endpoints :(*",
-
},
-
],
-
paths: {
-
"/users/{user}": {
-
get: {
-
tags: ["The Cache!"],
-
summary: "Get user information",
-
description:
-
"Retrieves user information from the cache or from Slack if not cached",
-
parameters: [
-
{
-
name: "user",
-
in: "path",
-
required: true,
-
schema: {
-
type: "string",
-
},
-
description: "Slack user ID",
-
},
-
],
-
responses: {
-
"200": {
-
description: "User information",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
id: {
-
type: "string",
-
example: "90750e24-c2f0-4c52-8681-e6176da6e7ab",
-
},
-
expiration: {
-
type: "string",
-
format: "date-time",
-
example: new Date().toISOString(),
-
},
-
user: {
-
type: "string",
-
example: "U12345678",
-
},
-
displayName: {
-
type: "string",
-
example: "krn",
-
},
-
pronouns: {
-
type: "string",
-
nullable: true,
-
example: "possibly/blank",
-
},
-
image: {
-
type: "string",
-
example:
-
"https://avatars.slack-edge.com/2024-11-30/8105375749571_53898493372773a01a1f_original.jpg",
-
},
-
},
-
},
-
},
-
},
-
},
-
"404": {
-
description: "User not found",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
message: {
-
type: "string",
-
example: "User not found",
-
},
-
},
-
},
-
},
-
},
-
},
-
"500": {
-
description: "Error fetching user from Slack",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
message: {
-
type: "string",
-
example: "Error fetching user from Slack",
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
"/users/{user}/r": {
-
get: {
-
tags: ["The Cache!"],
-
summary: "Redirect to user profile image",
-
description: "Redirects to the user's profile image URL",
-
parameters: [
-
{
-
name: "user",
-
in: "path",
-
required: true,
-
schema: {
-
type: "string",
-
},
-
description: "Slack user ID",
-
},
-
],
-
responses: {
-
"302": {
-
description: "Redirect to user profile image",
-
},
-
"307": {
-
description: "Redirect to default image when user not found",
-
},
-
"500": {
-
description: "Error fetching user from Slack",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
message: {
-
type: "string",
-
example: "Error fetching user from Slack",
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
"/users/{user}/purge": {
-
post: {
-
tags: ["The Cache!"],
-
summary: "Purge user cache",
-
description: "Purges a specific user's cache",
-
parameters: [
-
{
-
name: "user",
-
in: "path",
-
required: true,
-
schema: {
-
type: "string",
-
},
-
description: "Slack user ID",
-
},
-
{
-
name: "authorization",
-
in: "header",
-
required: true,
-
schema: {
-
type: "string",
-
example: "Bearer <token>",
-
},
-
description: "Bearer token for authentication",
-
},
-
],
-
responses: {
-
"200": {
-
description: "User cache purged",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
message: {
-
type: "string",
-
example: "User cache purged",
-
},
-
userId: {
-
type: "string",
-
example: "U12345678",
-
},
-
success: {
-
type: "boolean",
-
example: true,
-
},
-
},
-
},
-
},
-
},
-
},
-
"401": {
-
description: "Unauthorized",
-
content: {
-
"text/plain": {
-
schema: {
-
type: "string",
-
example: "Unauthorized",
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
"/emojis": {
-
get: {
-
tags: ["The Cache!"],
-
summary: "Get all emojis",
-
description: "Retrieves all emojis from the cache",
-
responses: {
-
"200": {
-
description: "List of emojis",
-
content: {
-
"application/json": {
-
schema: {
-
type: "array",
-
items: {
-
type: "object",
-
properties: {
-
id: {
-
type: "string",
-
example: "5427fe70-686f-4684-9da5-95d9ef4c1090",
-
},
-
expiration: {
-
type: "string",
-
format: "date-time",
-
example: new Date().toISOString(),
-
},
-
name: {
-
type: "string",
-
example: "blahaj-heart",
-
},
-
alias: {
-
type: "string",
-
nullable: true,
-
example: "blobhaj-heart",
-
},
-
image: {
-
type: "string",
-
example:
-
"https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png",
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
"/emojis/{emoji}": {
-
get: {
-
tags: ["The Cache!"],
-
summary: "Get emoji information",
-
description: "Retrieves information about a specific emoji",
-
parameters: [
-
{
-
name: "emoji",
-
in: "path",
-
required: true,
-
schema: {
-
type: "string",
-
},
-
description: "Emoji name",
-
},
-
],
-
responses: {
-
"200": {
-
description: "Emoji information",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
id: {
-
type: "string",
-
example: "9ed0a560-928d-409c-89fc-10fe156299da",
-
},
-
expiration: {
-
type: "string",
-
format: "date-time",
-
example: new Date().toISOString(),
-
},
-
name: {
-
type: "string",
-
example: "orphmoji-yay",
-
},
-
image: {
-
type: "string",
-
example:
-
"https://emoji.slack-edge.com/T0266FRGM/orphmoji-yay/23a37f4af47092d3.png",
-
},
-
},
-
},
-
},
-
},
-
},
-
"404": {
-
description: "Emoji not found",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
message: {
-
type: "string",
-
example: "Emoji not found",
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
"/emojis/{emoji}/r": {
-
get: {
-
tags: ["The Cache!"],
-
summary: "Redirect to emoji image",
-
description: "Redirects to the emoji image URL",
-
parameters: [
-
{
-
name: "emoji",
-
in: "path",
-
required: true,
-
schema: {
-
type: "string",
-
},
-
description: "Emoji name",
-
},
-
],
-
responses: {
-
"302": {
-
description: "Redirect to emoji image",
-
},
-
"404": {
-
description: "Emoji not found",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
message: {
-
type: "string",
-
example: "Emoji not found",
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
"/reset": {
-
post: {
-
tags: ["The Cache!"],
-
summary: "Reset cache",
-
description: "Purges all items from the cache",
-
parameters: [
-
{
-
name: "authorization",
-
in: "header",
-
required: true,
-
schema: {
-
type: "string",
-
example: "Bearer <token>",
-
},
-
description: "Bearer token for authentication",
-
},
-
],
-
responses: {
-
"200": {
-
description: "Cache purged",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
message: {
-
type: "string",
-
example: "Cache purged",
-
},
-
users: {
-
type: "number",
-
example: 10,
-
},
-
emojis: {
-
type: "number",
-
example: 100,
-
},
-
},
-
},
-
},
-
},
-
},
-
"401": {
-
description: "Unauthorized",
-
content: {
-
"text/plain": {
-
schema: {
-
type: "string",
-
example: "Unauthorized",
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
"/health": {
-
get: {
-
tags: ["Status"],
-
summary: "Health check",
-
description:
-
"Checks the health of the API, Slack connection, and database",
-
responses: {
-
"200": {
-
description: "Health check passed",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
http: {
-
type: "boolean",
-
example: true,
-
},
-
slack: {
-
type: "boolean",
-
example: true,
-
},
-
database: {
-
type: "boolean",
-
example: true,
-
},
-
},
-
},
-
},
-
},
-
},
-
"500": {
-
description: "Health check failed",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
http: {
-
type: "boolean",
-
example: false,
-
},
-
slack: {
-
type: "boolean",
-
example: false,
-
},
-
database: {
-
type: "boolean",
-
example: false,
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
"/stats": {
-
get: {
-
tags: ["Status"],
-
summary: "Get analytics statistics",
-
description: "Retrieves analytics statistics for the API",
-
parameters: [
-
{
-
name: "days",
-
in: "query",
-
required: false,
-
schema: {
-
type: "string",
-
},
-
description: "Number of days to look back (default: 7)",
-
},
-
],
-
responses: {
-
"200": {
-
description: "Analytics statistics",
-
content: {
-
"application/json": {
-
schema: {
-
type: "object",
-
properties: {
-
totalRequests: {
-
type: "number",
-
},
-
requestsByEndpoint: {
-
type: "array",
-
items: {
-
type: "object",
-
properties: {
-
endpoint: {
-
type: "string",
-
},
-
count: {
-
type: "number",
-
},
-
averageResponseTime: {
-
type: "number",
-
},
-
},
-
},
-
},
-
// Additional properties omitted for brevity
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
},
-
};
-
-
// Export the Swagger specification for use in other files
-
export default swaggerSpec;
···
+93
src/types/routes.ts
···
···
+
/**
+
* Type-safe route system that generates Swagger documentation from route definitions
+
* This ensures the Swagger docs stay in sync with the actual API implementation
+
*/
+
+
// Base types for HTTP methods
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
+
+
// Parameter types
+
export interface RouteParam {
+
name: string;
+
type: 'string' | 'number' | 'boolean';
+
required: boolean;
+
description: string;
+
example?: any;
+
}
+
+
// Response types
+
export interface ApiResponse {
+
status: number;
+
description: string;
+
schema?: any; // JSON Schema or example object
+
}
+
+
// Route metadata for Swagger generation
+
export interface RouteMetadata {
+
summary: string;
+
description?: string;
+
tags?: string[];
+
parameters?: {
+
path?: RouteParam[];
+
query?: RouteParam[];
+
body?: any; // JSON Schema for request body
+
};
+
responses: Record<number, ApiResponse>;
+
requiresAuth?: boolean;
+
}
+
+
// Handler function type
+
export type RouteHandler = (request: Request) => Promise<Response> | Response;
+
+
// Enhanced route definition that includes metadata
+
export interface TypedRoute {
+
handler: RouteHandler;
+
metadata: RouteMetadata;
+
}
+
+
// Method-specific route definitions (matching Bun's pattern)
+
export interface RouteDefinition {
+
GET?: TypedRoute;
+
POST?: TypedRoute;
+
PUT?: TypedRoute;
+
DELETE?: TypedRoute;
+
PATCH?: TypedRoute;
+
}
+
+
// Type helper to create routes with metadata
+
export function createRoute(
+
handler: RouteHandler,
+
metadata: RouteMetadata
+
): TypedRoute {
+
return { handler, metadata };
+
}
+
+
// Type helper for path parameters
+
export function pathParam(
+
name: string,
+
type: RouteParam['type'] = 'string',
+
description: string,
+
example?: any
+
): RouteParam {
+
return { name, type, required: true, description, example };
+
}
+
+
// Type helper for query parameters
+
export function queryParam(
+
name: string,
+
type: RouteParam['type'] = 'string',
+
description: string,
+
required = false,
+
example?: any
+
): RouteParam {
+
return { name, type, required, description, example };
+
}
+
+
// Type helper for API responses
+
export function apiResponse(
+
status: number,
+
description: string,
+
schema?: any
+
): [number, ApiResponse] {
+
return [status, { status, description, schema }];
+
}