From a9501d6e445a86bb312c61cf4bc3ac122f0f0bb2 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Fri, 22 May 2026 10:55:15 +0200 Subject: [PATCH] feat: add structured JSON logger and worker utility (P1-4) Introduces src/lib/logger.ts emitting JSON lines (level, message, data) compatible with Cloudflare Logpush. Replaces all console.log/warn/error calls in email-processor.ts, index.ts, and hub.ts with structured logger calls. Extracts waitUntilSafe into src/utils/worker.ts to avoid duplicating the executionCtx guard across routes. Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 15 +++++++++------ src/lib/email-processor.ts | 22 +++++++++++++--------- src/lib/logger.ts | 26 ++++++++++++++++++++++++++ src/routes/hub.ts | 18 ++++-------------- src/utils/worker.ts | 10 ++++++++++ 5 files changed, 62 insertions(+), 29 deletions(-) create mode 100644 src/lib/logger.ts create mode 100644 src/utils/worker.ts diff --git a/src/index.ts b/src/index.ts index 4637805..e971c5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ import { handle as handleFiles } from "./routes/files"; import { hubRouter } from "./routes/hub"; import { handleCloudflareEmail } from "./lib/cloudflare-email"; import { Env } from "./types"; +import { logger } from "./lib/logger"; +import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants"; type AppEnv = { Bindings: Env }; @@ -71,16 +73,17 @@ async function getForwardEmailIps(): Promise { ) .flatMap((entry) => entry.ipv4); - // Store in cache for 24 hours forwardEmailIpsCache = { ips: mxIps, - expiresAt: Date.now() + 24 * 60 * 60 * 1000, // 24 hours + expiresAt: Date.now() + FORWARD_EMAIL_IPS_CACHE_TTL_MS, }; - console.log("Fetched ForwardEmail.net IPs:", mxIps); + logger.info("Fetched ForwardEmail.net IPs", { count: mxIps.length }); return mxIps; } catch (error) { - console.error("Error fetching ForwardEmail.net IPs:", error); + logger.error("Failed to fetch ForwardEmail.net IPs", { + error: String(error), + }); // Return fallback IPs if fetch fails return FALLBACK_FORWARD_EMAIL_IPS; } @@ -118,11 +121,11 @@ api.use("/inbound", async (c, next) => { // Check if the request is coming from ForwardEmail.net if (!allowedIps.includes(clientIP)) { - console.error(`Unauthorized webhook request from IP: ${clientIP}`); + logger.warn("Unauthorized webhook request", { clientIP }); return c.text("Unauthorized", 401); } - console.log(`Authorized webhook request from ForwardEmail.net (${clientIP})`); + logger.info("Authorized webhook request", { clientIP }); await next(); }); diff --git a/src/lib/email-processor.ts b/src/lib/email-processor.ts index 93dd245..5eeb129 100644 --- a/src/lib/email-processor.ts +++ b/src/lib/email-processor.ts @@ -7,6 +7,8 @@ import { FeedMetadata, } from "../types"; import { notifySubscribers } from "../utils/websub"; +import { logger } from "./logger"; +import { FEED_MAX_BYTES } from "../config/constants"; export interface RawAttachment { filename: string; @@ -81,7 +83,9 @@ export async function validateEmail( ): Promise { const feedId = EmailParser.extractFeedId(input.toAddress); if (!feedId) { - console.error(`Invalid email address format: ${input.toAddress}`); + logger.error("Invalid email address format", { + toAddress: input.toAddress, + }); return { ok: false, response: new Response("Invalid email address format", { status: 400 }), @@ -93,7 +97,7 @@ export async function validateEmail( "json", )) as FeedConfig | null; if (!feedConfig) { - console.error(`Feed with ID ${feedId} does not exist or has been deleted`); + logger.error("Feed not found", { feedId }); return { ok: false, response: new Response("Feed does not exist", { status: 404 }), @@ -110,10 +114,11 @@ export async function validateEmail( ), ); if (!senderAllowed) { - console.warn( - `Rejected email for feed ${feedId}; sender not in allowlist`, - { senders: input.senders, allowedSenders }, - ); + logger.warn("Rejected email: sender not in allowlist", { + feedId, + senders: input.senders, + allowedSenders, + }); return { ok: false, response: new Response("Sender not allowed for this feed", { @@ -164,9 +169,8 @@ export async function storeEmail( emails: [], }) as FeedMetadata; - const DEFAULT_MAX_BYTES = 524288; // 512 KB const maxBytes = - parseInt(env.FEED_MAX_SIZE_BYTES ?? "", 10) || DEFAULT_MAX_BYTES; + parseInt(env.FEED_MAX_SIZE_BYTES ?? "", 10) || FEED_MAX_BYTES; const serialised = JSON.stringify(emailData); const serialisedSize = new TextEncoder().encode(serialised).byteLength; @@ -205,7 +209,7 @@ export async function storeEmail( ...r2Deletions, ]); - console.log(`Successfully processed email for feed ${feedId}`); + logger.info("Email processed", { feedId }); if (ctx) { ctx.waitUntil(notifySubscribers(feedId, env)); } diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..ec8f093 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,26 @@ +type LogLevel = "info" | "warn" | "error" | "debug"; + +function log( + level: LogLevel, + message: string, + data?: Record, +): void { + const entry = data ? { level, message, ...data } : { level, message }; + const line = JSON.stringify(entry); + if (level === "error" || level === "warn") { + console.error(line); + } else { + console.log(line); + } +} + +export const logger = { + info: (message: string, data?: Record) => + log("info", message, data), + warn: (message: string, data?: Record) => + log("warn", message, data), + error: (message: string, data?: Record) => + log("error", message, data), + debug: (message: string, data?: Record) => + log("debug", message, data), +}; diff --git a/src/routes/hub.ts b/src/routes/hub.ts index 5dcb6cb..c8924d2 100644 --- a/src/routes/hub.ts +++ b/src/routes/hub.ts @@ -1,23 +1,13 @@ -import { Hono, type Context } from "hono"; +import { Hono } from "hono"; import { Env } from "../types"; - -type AppEnv = { Bindings: Env }; import { verifyAndStoreSubscription, verifyAndDeleteSubscription, } from "../utils/websub"; +import { waitUntilSafe } from "../utils/worker"; +import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants"; -function waitUntilSafe(c: Context, promise: Promise) { - // Hono throws when ExecutionContext isn't present (e.g. Node unit tests). - try { - c.executionCtx.waitUntil(promise); - } catch { - // ignore - } -} - -const DEFAULT_LEASE_SECONDS = 86400; -const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days +type AppEnv = { Bindings: Env }; export const hubRouter = new Hono(); diff --git a/src/utils/worker.ts b/src/utils/worker.ts new file mode 100644 index 0000000..cecd33d --- /dev/null +++ b/src/utils/worker.ts @@ -0,0 +1,10 @@ +import { Context } from "hono"; + +/** Calls ctx.waitUntil() without throwing when the ExecutionContext is absent (e.g. Node tests). */ +export function waitUntilSafe(c: Context, promise: Promise): void { + try { + c.executionCtx.waitUntil(promise); + } catch { + // ExecutionContext unavailable in Node test environment — ignore. + } +}