mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 23:07:01 +10:00
Project quality audit (#2758)
* Harden security, health checks, and dependency hygiene Co-authored-by: Amruth Pillai <im.amruth@gmail.com> * Finalize health and storage hardening adjustments Co-authored-by: Amruth Pillai <im.amruth@gmail.com> * remove use of [REDACTED] * update dependencies --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
@@ -43,7 +43,7 @@ description: "Learn how to create API keys and authenticate requests to the Reac
|
||||
</Info>
|
||||
|
||||
```bash
|
||||
curl "https://rxresu.me/api/openapi/resume/list" \
|
||||
curl "https://rxresu.me/api/openapi/resumes" \
|
||||
-H "x-api-key: YOUR_API_KEY"
|
||||
```
|
||||
</Step>
|
||||
|
||||
+21
-16
@@ -31,11 +31,11 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.47",
|
||||
"@ai-sdk/anthropic": "^3.0.49",
|
||||
"@ai-sdk/google": "^3.0.33",
|
||||
"@ai-sdk/openai": "^3.0.36",
|
||||
"@ai-sdk/react": "^3.0.105",
|
||||
"@aws-sdk/client-s3": "^3.999.0",
|
||||
"@ai-sdk/react": "^3.0.106",
|
||||
"@aws-sdk/client-s3": "^3.1000.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -56,17 +56,17 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@t3-oss/env-core": "^0.13.10",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-router": "^1.163.2",
|
||||
"@tanstack/react-router-ssr-query": "^1.163.2",
|
||||
"@tanstack/react-start": "^1.163.2",
|
||||
"@tanstack/zod-adapter": "^1.163.2",
|
||||
"@tanstack/react-router": "^1.163.3",
|
||||
"@tanstack/react-router-ssr-query": "^1.163.3",
|
||||
"@tanstack/react-start": "^1.163.3",
|
||||
"@tanstack/zod-adapter": "^1.163.3",
|
||||
"@tiptap/extension-highlight": "^3.20.0",
|
||||
"@tiptap/extension-table": "^3.20.0",
|
||||
"@tiptap/extension-text-align": "^3.20.0",
|
||||
"@tiptap/pm": "^3.20.0",
|
||||
"@tiptap/react": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
"ai": "^6.0.103",
|
||||
"ai": "^6.0.104",
|
||||
"ai-sdk-ollama": "^3.8.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "v1.4.19",
|
||||
@@ -97,7 +97,6 @@
|
||||
"react-resizable-panels": "^4.6.5",
|
||||
"react-window": "^2.2.7",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"shadcn": "^3.8.5",
|
||||
"sharp": "^0.34.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -130,24 +129,30 @@
|
||||
"drizzle-kit": "^1.0.0-beta.15-859cf75",
|
||||
"knip": "^5.85.0",
|
||||
"nitro": "npm:nitro-nightly@latest",
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-addon-api": "^8.6.0",
|
||||
"node-gyp": "^12.2.0",
|
||||
"npm-check-updates": "^19.6.2",
|
||||
"npm-check-updates": "^19.6.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.0-beta.15",
|
||||
"vite": "^8.0.0-beta.16",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "^8.0.0-beta.15"
|
||||
"vite": "^8.0.0-beta.16",
|
||||
"hono": "^4.11.10",
|
||||
"minimatch": "^10.2.3",
|
||||
"@isaacs/brace-expansion": "^5.0.1",
|
||||
"basic-ftp": "^5.2.0",
|
||||
"lodash": "^4.17.23",
|
||||
"markdown-it": "^14.1.1",
|
||||
"ajv": "^8.18.0",
|
||||
"qs": "^6.14.2",
|
||||
"fast-xml-parser": "^5.3.8"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@prisma/engines",
|
||||
"bcrypt",
|
||||
"esbuild",
|
||||
"msw",
|
||||
"prisma",
|
||||
"sharp"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ async function migrateDatabase() {
|
||||
console.log("✅ Database migrations completed");
|
||||
} catch (error) {
|
||||
console.error("🚨 Database migrations failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
Generated
+1010
-3245
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
import { env } from "@/utils/env";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
type SendEmailOptions = {
|
||||
to: string | string[];
|
||||
@@ -44,18 +45,24 @@ export const sendEmail = async (options: SendEmailOptions) => {
|
||||
text: options.text,
|
||||
};
|
||||
|
||||
if (!transport) return console.log("[EMAIL] SMTP not configured; logging email:", payload);
|
||||
if (!transport) {
|
||||
logger.info("SMTP not configured; skipping email send", {
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.sendMail({ ...options, from });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[EMAIL] Failed to send via SMTP; logging email payload instead:",
|
||||
{
|
||||
smtp: { host: env.SMTP_HOST, port: env.SMTP_PORT, secure: env.SMTP_SECURE },
|
||||
email: payload,
|
||||
},
|
||||
logger.error("SMTP send failed", {
|
||||
smtpHost: env.SMTP_HOST,
|
||||
smtpPort: env.SMTP_PORT,
|
||||
smtpSecure: env.SMTP_SECURE,
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,13 +7,17 @@ import { createIsomorphicFn } from "@tanstack/react-start";
|
||||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import router from "@/integrations/orpc/router";
|
||||
import { getLocale } from "@/utils/locale";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
export const getORPCClient = createIsomorphicFn()
|
||||
.server((): RouterClient<typeof router> => {
|
||||
return createRouterClient(router, {
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error(`ERROR [oRPC]: ${error}`);
|
||||
logger.error("oRPC server client error", {
|
||||
scope: "server",
|
||||
error,
|
||||
});
|
||||
}),
|
||||
],
|
||||
context: async () => {
|
||||
@@ -40,7 +44,10 @@ export const getORPCClient = createIsomorphicFn()
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
console.error(`ERROR [oRPC]: ${error}`);
|
||||
logger.error("oRPC browser client error", {
|
||||
scope: "client",
|
||||
error,
|
||||
});
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -70,10 +70,11 @@ export const protectedProcedure = publicProcedure.use(async ({ context, next })
|
||||
*/
|
||||
export const serverOnlyProcedure = publicProcedure.use(async ({ context, next }) => {
|
||||
const headers = context.reqHeaders ?? new Headers();
|
||||
const isDebugBypassEnabled = env.FLAG_DEBUG_PRINTER && process.env.NODE_ENV === "development";
|
||||
|
||||
// Check for the custom header that indicates this is a server-side call
|
||||
// Server-side calls using createRouterClient have this header set
|
||||
const isServerSideCall = env.FLAG_DEBUG_PRINTER || headers.get("x-server-side-call") === "true";
|
||||
const isServerSideCall = isDebugBypassEnabled || headers.get("x-server-side-call") === "true";
|
||||
|
||||
// If the header is not present, this is a client-side HTTP request - reject it
|
||||
if (!isServerSideCall) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const printerRouter = {
|
||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume to export.") }))
|
||||
.output(z.object({ url: z.string().describe("The URL to download the generated PDF file.") }))
|
||||
.handler(async ({ input, context }) => {
|
||||
const { id, data, userId } = await resumeService.getByIdForPrinter({ id: input.id });
|
||||
const { id, data, userId } = await resumeService.getByIdForPrinter({ id: input.id, userId: context.user?.id });
|
||||
const url = await printerService.printResumeAsPDF({ id, data, userId });
|
||||
|
||||
if (!context.user) {
|
||||
@@ -41,9 +41,13 @@ export const printerRouter = {
|
||||
})
|
||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
||||
.output(z.object({ url: z.string().nullable().describe("The URL to the screenshot image, or null.") }))
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter({ id: input.id });
|
||||
const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
});
|
||||
|
||||
const url = await printerService.getResumeScreenshot({ id, data, userId, updatedAt });
|
||||
|
||||
return { url };
|
||||
|
||||
@@ -11,6 +11,14 @@ const filenameSchema = z.object({
|
||||
filename: z.string().min(1).describe("The path or filename of the file to delete."),
|
||||
});
|
||||
|
||||
function normalizeKey(input: string): string {
|
||||
return input.trim().replace(/^\/+/, "").split("/").filter(Boolean).join("/");
|
||||
}
|
||||
|
||||
function isUnsafeStorageKey(key: string): boolean {
|
||||
return key.split("/").some((segment) => segment === "." || segment === "..");
|
||||
}
|
||||
|
||||
export const storageRouter = {
|
||||
uploadFile: protectedProcedure
|
||||
.route({
|
||||
@@ -76,13 +84,21 @@ export const storageRouter = {
|
||||
message: "The specified file was not found in storage.",
|
||||
status: 404,
|
||||
},
|
||||
FORBIDDEN: {
|
||||
message: "You do not have permission to delete this file.",
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }): Promise<void> => {
|
||||
// The filename is now the full path from the URL (e.g., "uploads/userId/pictures/timestamp.ext")
|
||||
// We need to extract just the path portion that matches the storage key
|
||||
const key = input.filename.startsWith("uploads/")
|
||||
? input.filename
|
||||
: `uploads/${context.user.id}/pictures/${input.filename}`;
|
||||
const requestedKey = normalizeKey(input.filename);
|
||||
const key = requestedKey.startsWith("uploads/")
|
||||
? requestedKey
|
||||
: normalizeKey(`uploads/${context.user.id}/pictures/${requestedKey}`);
|
||||
const userPrefix = `uploads/${context.user.id}/`;
|
||||
|
||||
if (isUnsafeStorageKey(key) || !key.startsWith(userPrefix)) {
|
||||
throw new ORPCError("FORBIDDEN");
|
||||
}
|
||||
|
||||
const deleted = await storageService.delete(key);
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ type GetModelInput = {
|
||||
baseURL: string;
|
||||
};
|
||||
|
||||
const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4;
|
||||
|
||||
function getModel(input: GetModelInput) {
|
||||
const { provider, model, apiKey } = input;
|
||||
const baseURL = input.baseURL || undefined;
|
||||
@@ -62,7 +65,7 @@ export const aiCredentialsSchema = z.object({
|
||||
|
||||
export const fileInputSchema = z.object({
|
||||
name: z.string(),
|
||||
data: z.string(), // base64 encoded
|
||||
data: z.string().max(MAX_AI_FILE_BASE64_CHARS, "File is too large. Maximum size is 10MB."), // base64 encoded
|
||||
});
|
||||
|
||||
type TestConnectionInput = z.infer<typeof aiCredentialsSchema>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AuthProvider } from "@/integrations/auth/types";
|
||||
import { schema } from "@/integrations/drizzle";
|
||||
import { db } from "@/integrations/drizzle/client";
|
||||
import { env } from "@/utils/env";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { getStorageService } from "./storage";
|
||||
|
||||
export type ProviderList = Partial<Record<AuthProvider, string>>;
|
||||
@@ -39,7 +40,10 @@ export const authService = {
|
||||
try {
|
||||
await db.delete(schema.user).where(eq(schema.user.id, input.userId));
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete user record for userId=${input.userId}:`, err);
|
||||
logger.error("Failed to delete user record", {
|
||||
userId: input.userId,
|
||||
error: err,
|
||||
});
|
||||
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import puppeteer, { type Browser, type ConnectOptions } from "puppeteer-core";
|
||||
import puppeteer, { type Browser, type ConnectOptions, type Page } from "puppeteer-core";
|
||||
import type { schema } from "@/integrations/drizzle";
|
||||
import { pageDimensionsAsPixels } from "@/schema/page";
|
||||
import { printMarginTemplates } from "@/schema/templates";
|
||||
@@ -111,6 +111,7 @@ export const printerService = {
|
||||
}
|
||||
|
||||
let browser: Browser | null = null;
|
||||
let page: Page | null = null;
|
||||
|
||||
try {
|
||||
// Step 4: Connect to the browser and navigate to the printer route
|
||||
@@ -119,7 +120,7 @@ export const printerService = {
|
||||
// Set locale cookie so the resume renders in the correct language
|
||||
await browser.setCookie({ name: "locale", value: locale, domain });
|
||||
|
||||
const page = await browser.newPage();
|
||||
page = await browser.newPage();
|
||||
|
||||
// Wait for the page to fully load (network idle + custom loaded attribute)
|
||||
await page.emulateMediaType("print");
|
||||
@@ -213,8 +214,6 @@ export const printerService = {
|
||||
},
|
||||
});
|
||||
|
||||
await page.close();
|
||||
|
||||
// Step 7: Upload the generated PDF to storage
|
||||
const result = await uploadFile({
|
||||
userId,
|
||||
@@ -227,6 +226,8 @@ export const printerService = {
|
||||
return result.url;
|
||||
} catch (error) {
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
|
||||
} finally {
|
||||
if (page) await page.close().catch(() => null);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -280,13 +281,14 @@ export const printerService = {
|
||||
const url = `${baseUrl}/printer/${id}?token=${token}`;
|
||||
|
||||
let browser: Browser | null = null;
|
||||
let page: Page | null = null;
|
||||
|
||||
try {
|
||||
browser = await getBrowser();
|
||||
|
||||
await browser.setCookie({ name: "locale", value: locale, domain });
|
||||
|
||||
const page = await browser.newPage();
|
||||
page = await browser.newPage();
|
||||
|
||||
await page.setViewport(pageDimensionsAsPixels.a4);
|
||||
await page.goto(url, { waitUntil: "networkidle0" });
|
||||
@@ -294,8 +296,6 @@ export const printerService = {
|
||||
|
||||
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
|
||||
|
||||
await page.close();
|
||||
|
||||
const result = await uploadFile({
|
||||
userId,
|
||||
resumeId: id,
|
||||
@@ -307,6 +307,8 @@ export const printerService = {
|
||||
return result.url;
|
||||
} catch (error) {
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
|
||||
} finally {
|
||||
if (page) await page.close().catch(() => null);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -133,7 +133,7 @@ export const resumeService = {
|
||||
return resume;
|
||||
},
|
||||
|
||||
getByIdForPrinter: async (input: { id: string }) => {
|
||||
getByIdForPrinter: async (input: { id: string; userId?: string }) => {
|
||||
const [resume] = await db
|
||||
.select({
|
||||
id: schema.resume.id,
|
||||
@@ -146,7 +146,11 @@ export const resumeService = {
|
||||
updatedAt: schema.resume.updatedAt,
|
||||
})
|
||||
.from(schema.resume)
|
||||
.where(eq(schema.resume.id, input.id));
|
||||
.where(
|
||||
input.userId
|
||||
? and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId))
|
||||
: eq(schema.resume.id, input.id),
|
||||
);
|
||||
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { db } from "@/integrations/drizzle/client";
|
||||
|
||||
const CACHE_DURATION_MS = 6 * 60 * 60 * 1000; // 6 hours
|
||||
const GITHUB_API_URL = "https://api.github.com/repos/amruthpillai/reactive-resume";
|
||||
const GITHUB_REQUEST_TIMEOUT_MS = 5_000;
|
||||
const GITHUB_REQUEST_MAX_ATTEMPTS = 2;
|
||||
|
||||
const LAST_KNOWN = {
|
||||
users: 978_528,
|
||||
@@ -65,13 +67,36 @@ const getCountFromDatabase = async (table: typeof schema.user | typeof schema.re
|
||||
return result.count;
|
||||
};
|
||||
|
||||
const getGitHubStars = async (): Promise<number | null> => {
|
||||
const response = await fetch(GITHUB_API_URL, { headers: { Accept: "application/vnd.github+json" } });
|
||||
if (!response.ok) return null;
|
||||
const fetchGitHubStarsOnce = async (): Promise<number | null> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), GITHUB_REQUEST_TIMEOUT_MS);
|
||||
|
||||
const data = await response.json();
|
||||
const stars = Number(data.stargazers_count);
|
||||
return Number.isFinite(stars) && stars > 0 ? stars : null;
|
||||
try {
|
||||
const response = await fetch(GITHUB_API_URL, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = (await response.json()) as { stargazers_count?: unknown };
|
||||
const stars = Number(data.stargazers_count);
|
||||
return Number.isFinite(stars) && stars > 0 ? stars : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
const getGitHubStars = async (): Promise<number | null> => {
|
||||
for (let attempt = 0; attempt < GITHUB_REQUEST_MAX_ATTEMPTS; attempt++) {
|
||||
const stars = await fetchGitHubStarsOnce();
|
||||
if (stars !== null) return stars;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const statisticsService = {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { env } from "@/utils/env";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
interface StorageWriteInput {
|
||||
key: string;
|
||||
@@ -89,7 +90,9 @@ interface ProcessedImage {
|
||||
export async function processImageForUpload(file: File): Promise<ProcessedImage> {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
console.log("FLAG_DISABLE_IMAGE_PROCESSING", env.FLAG_DISABLE_IMAGE_PROCESSING);
|
||||
logger.debug("Image processing feature flag resolved", {
|
||||
flagDisableImageProcessing: env.FLAG_DISABLE_IMAGE_PROCESSING,
|
||||
});
|
||||
if (env.FLAG_DISABLE_IMAGE_PROCESSING) {
|
||||
return {
|
||||
data: new Uint8Array(fileBuffer),
|
||||
@@ -143,15 +146,23 @@ class LocalStorageService implements StorageService {
|
||||
|
||||
async read(key: string): Promise<StorageReadResult | null> {
|
||||
const fullPath = this.resolvePath(key);
|
||||
const [arrayBuffer, stats] = await Promise.all([fs.readFile(fullPath), fs.stat(fullPath)]);
|
||||
try {
|
||||
const [arrayBuffer, stats] = await Promise.all([fs.readFile(fullPath), fs.stat(fullPath)]);
|
||||
|
||||
return {
|
||||
data: arrayBuffer,
|
||||
size: stats.size,
|
||||
etag: `"${stats.size}-${stats.mtime.getTime()}"`,
|
||||
lastModified: stats.mtime,
|
||||
contentType: inferContentType(key),
|
||||
};
|
||||
return {
|
||||
data: arrayBuffer,
|
||||
size: stats.size,
|
||||
etag: `"${stats.size}-${stats.mtime.getTime()}"`,
|
||||
lastModified: stats.mtime,
|
||||
contentType: inferContentType(key),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
|
||||
+67
-17
@@ -3,30 +3,80 @@ import { sql } from "drizzle-orm";
|
||||
import { db } from "@/integrations/drizzle/client";
|
||||
import { printerService } from "@/integrations/orpc/services/printer";
|
||||
import { getStorageService } from "@/integrations/orpc/services/storage";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
function isUnhealthy(check: unknown): boolean {
|
||||
return (
|
||||
!!check &&
|
||||
typeof check === "object" &&
|
||||
"status" in check &&
|
||||
typeof check.status === "string" &&
|
||||
check.status === "unhealthy"
|
||||
);
|
||||
const HEALTHCHECK_TIMEOUT_MS = 1_500;
|
||||
|
||||
type CheckResult = {
|
||||
status: "healthy" | "unhealthy";
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
async function handler() {
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
||||
const startedAt = performance.now();
|
||||
|
||||
try {
|
||||
const data = await withTimeout(check(), HEALTHCHECK_TIMEOUT_MS);
|
||||
const latencyMs = Math.round(performance.now() - startedAt);
|
||||
const result = data as { status?: string };
|
||||
if (result.status === "unhealthy") return { ...(data as object), status: "unhealthy", latencyMs };
|
||||
return { ...(data as object), status: "healthy", latencyMs };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: getErrorMessage(error),
|
||||
latencyMs: Math.round(performance.now() - startedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function healthHandler() {
|
||||
const [database, printer, storage] = await Promise.all([
|
||||
runCheck(checkDatabase),
|
||||
runCheck(checkPrinter),
|
||||
runCheck(checkStorage),
|
||||
]);
|
||||
const status = [database, printer, storage].some((check) => check.status === "unhealthy") ? "unhealthy" : "healthy";
|
||||
|
||||
const checks = {
|
||||
service: "reactive-resume",
|
||||
version: process.env.npm_package_version,
|
||||
status: "healthy",
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: `${process.uptime().toFixed(2)}s`,
|
||||
database: await checkDatabase(),
|
||||
printer: await checkPrinter(),
|
||||
storage: await checkStorage(),
|
||||
database,
|
||||
printer,
|
||||
storage,
|
||||
};
|
||||
|
||||
if (checks.status === "unhealthy" || Object.values(checks).some(isUnhealthy)) {
|
||||
checks.status = "unhealthy";
|
||||
if (status === "unhealthy") {
|
||||
logger.warn("Healthcheck failed", {
|
||||
route: "/api/health",
|
||||
database,
|
||||
printer,
|
||||
storage,
|
||||
});
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
@@ -36,7 +86,7 @@ async function handler() {
|
||||
|
||||
return new Response(body, {
|
||||
headers,
|
||||
status: checks.status === "unhealthy" ? 500 : 200,
|
||||
status: checks.status === "unhealthy" ? 503 : 200,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,7 +130,7 @@ async function checkStorage() {
|
||||
export const Route = createFileRoute("/api/health")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: handler,
|
||||
GET: healthHandler,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import router from "@/integrations/orpc/router";
|
||||
import { resumeDataSchema } from "@/schema/resume/data";
|
||||
import { env } from "@/utils/env";
|
||||
import { getLocale } from "@/utils/locale";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
const openAPIHandler = new OpenAPIHandler(router, {
|
||||
plugins: [
|
||||
@@ -21,7 +22,10 @@ const openAPIHandler = new OpenAPIHandler(router, {
|
||||
],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error(`ERROR [OpenAPI]: ${error}`);
|
||||
logger.error("OpenAPI handler error", {
|
||||
route: "/api/openapi",
|
||||
error,
|
||||
});
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -4,12 +4,16 @@ import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import router from "@/integrations/orpc/router";
|
||||
import { getLocale } from "@/utils/locale";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
const rpcHandler = new RPCHandler(router, {
|
||||
plugins: [new BatchHandlerPlugin(), new RequestHeadersPlugin(), new StrictGetMethodPlugin()],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error(`ERROR [oRPC]: ${error}`);
|
||||
logger.error("oRPC server error", {
|
||||
route: "/api/rpc",
|
||||
error,
|
||||
});
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { registerPrompts } from "./-helpers/prompts";
|
||||
import { registerResources } from "./-helpers/resources";
|
||||
import { registerTools } from "./-helpers/tools";
|
||||
@@ -51,7 +52,10 @@ export const Route = createFileRoute("/mcp/")({
|
||||
|
||||
return await transport.handleRequest(request);
|
||||
} catch (error) {
|
||||
console.error(`Error handling request: ${error instanceof Error ? error.message : String(error)}`);
|
||||
logger.error("MCP request failed", {
|
||||
route: "/mcp",
|
||||
error,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
id: null,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
type LogContext = Record<string, unknown> & {
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
type SerializedError = {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
function serializeError(error: unknown): SerializedError | undefined {
|
||||
if (!error) return undefined;
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return {
|
||||
name: "Error",
|
||||
message: error,
|
||||
};
|
||||
}
|
||||
if (typeof error === "object") {
|
||||
return {
|
||||
name: "Error",
|
||||
message: JSON.stringify(error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: "Error",
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
function log(level: LogLevel, message: string, context: LogContext = {}) {
|
||||
const { error, ...rest } = context;
|
||||
const payload = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
...rest,
|
||||
error: serializeError(error),
|
||||
};
|
||||
const json = JSON.stringify(payload);
|
||||
|
||||
if (level === "error") {
|
||||
console.error(json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (level === "warn") {
|
||||
console.warn(json);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(json);
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (message: string, context?: LogContext) => log("debug", message, context),
|
||||
info: (message: string, context?: LogContext) => log("info", message, context),
|
||||
warn: (message: string, context?: LogContext) => log("warn", message, context),
|
||||
error: (message: string, context?: LogContext) => log("error", message, context),
|
||||
};
|
||||
Reference in New Issue
Block a user