mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
+9
-6
@@ -9,6 +9,8 @@ import { handle as handleFiles } from "./routes/files";
|
|||||||
import { hubRouter } from "./routes/hub";
|
import { hubRouter } from "./routes/hub";
|
||||||
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
||||||
import { Env } from "./types";
|
import { Env } from "./types";
|
||||||
|
import { logger } from "./lib/logger";
|
||||||
|
import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
|
|
||||||
@@ -71,16 +73,17 @@ async function getForwardEmailIps(): Promise<string[]> {
|
|||||||
)
|
)
|
||||||
.flatMap((entry) => entry.ipv4);
|
.flatMap((entry) => entry.ipv4);
|
||||||
|
|
||||||
// Store in cache for 24 hours
|
|
||||||
forwardEmailIpsCache = {
|
forwardEmailIpsCache = {
|
||||||
ips: mxIps,
|
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;
|
return mxIps;
|
||||||
} catch (error) {
|
} 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 IPs if fetch fails
|
||||||
return FALLBACK_FORWARD_EMAIL_IPS;
|
return FALLBACK_FORWARD_EMAIL_IPS;
|
||||||
}
|
}
|
||||||
@@ -118,11 +121,11 @@ api.use("/inbound", async (c, next) => {
|
|||||||
|
|
||||||
// Check if the request is coming from ForwardEmail.net
|
// Check if the request is coming from ForwardEmail.net
|
||||||
if (!allowedIps.includes(clientIP)) {
|
if (!allowedIps.includes(clientIP)) {
|
||||||
console.error(`Unauthorized webhook request from IP: ${clientIP}`);
|
logger.warn("Unauthorized webhook request", { clientIP });
|
||||||
return c.text("Unauthorized", 401);
|
return c.text("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Authorized webhook request from ForwardEmail.net (${clientIP})`);
|
logger.info("Authorized webhook request", { clientIP });
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
FeedMetadata,
|
FeedMetadata,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { notifySubscribers } from "../utils/websub";
|
import { notifySubscribers } from "../utils/websub";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
import { FEED_MAX_BYTES } from "../config/constants";
|
||||||
|
|
||||||
export interface RawAttachment {
|
export interface RawAttachment {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -81,7 +83,9 @@ export async function validateEmail(
|
|||||||
): Promise<ValidationResult> {
|
): Promise<ValidationResult> {
|
||||||
const feedId = EmailParser.extractFeedId(input.toAddress);
|
const feedId = EmailParser.extractFeedId(input.toAddress);
|
||||||
if (!feedId) {
|
if (!feedId) {
|
||||||
console.error(`Invalid email address format: ${input.toAddress}`);
|
logger.error("Invalid email address format", {
|
||||||
|
toAddress: input.toAddress,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
response: new Response("Invalid email address format", { status: 400 }),
|
response: new Response("Invalid email address format", { status: 400 }),
|
||||||
@@ -93,7 +97,7 @@ export async function validateEmail(
|
|||||||
"json",
|
"json",
|
||||||
)) as FeedConfig | null;
|
)) as FeedConfig | null;
|
||||||
if (!feedConfig) {
|
if (!feedConfig) {
|
||||||
console.error(`Feed with ID ${feedId} does not exist or has been deleted`);
|
logger.error("Feed not found", { feedId });
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
response: new Response("Feed does not exist", { status: 404 }),
|
response: new Response("Feed does not exist", { status: 404 }),
|
||||||
@@ -110,10 +114,11 @@ export async function validateEmail(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!senderAllowed) {
|
if (!senderAllowed) {
|
||||||
console.warn(
|
logger.warn("Rejected email: sender not in allowlist", {
|
||||||
`Rejected email for feed ${feedId}; sender not in allowlist`,
|
feedId,
|
||||||
{ senders: input.senders, allowedSenders },
|
senders: input.senders,
|
||||||
);
|
allowedSenders,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
response: new Response("Sender not allowed for this feed", {
|
response: new Response("Sender not allowed for this feed", {
|
||||||
@@ -164,9 +169,8 @@ export async function storeEmail(
|
|||||||
emails: [],
|
emails: [],
|
||||||
}) as FeedMetadata;
|
}) as FeedMetadata;
|
||||||
|
|
||||||
const DEFAULT_MAX_BYTES = 524288; // 512 KB
|
|
||||||
const maxBytes =
|
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 serialised = JSON.stringify(emailData);
|
||||||
const serialisedSize = new TextEncoder().encode(serialised).byteLength;
|
const serialisedSize = new TextEncoder().encode(serialised).byteLength;
|
||||||
@@ -205,7 +209,7 @@ export async function storeEmail(
|
|||||||
...r2Deletions,
|
...r2Deletions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log(`Successfully processed email for feed ${feedId}`);
|
logger.info("Email processed", { feedId });
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.waitUntil(notifySubscribers(feedId, env));
|
ctx.waitUntil(notifySubscribers(feedId, env));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
type LogLevel = "info" | "warn" | "error" | "debug";
|
||||||
|
|
||||||
|
function log(
|
||||||
|
level: LogLevel,
|
||||||
|
message: string,
|
||||||
|
data?: Record<string, unknown>,
|
||||||
|
): 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<string, unknown>) =>
|
||||||
|
log("info", message, data),
|
||||||
|
warn: (message: string, data?: Record<string, unknown>) =>
|
||||||
|
log("warn", message, data),
|
||||||
|
error: (message: string, data?: Record<string, unknown>) =>
|
||||||
|
log("error", message, data),
|
||||||
|
debug: (message: string, data?: Record<string, unknown>) =>
|
||||||
|
log("debug", message, data),
|
||||||
|
};
|
||||||
+4
-14
@@ -1,23 +1,13 @@
|
|||||||
import { Hono, type Context } from "hono";
|
import { Hono } from "hono";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
|
||||||
import {
|
import {
|
||||||
verifyAndStoreSubscription,
|
verifyAndStoreSubscription,
|
||||||
verifyAndDeleteSubscription,
|
verifyAndDeleteSubscription,
|
||||||
} from "../utils/websub";
|
} from "../utils/websub";
|
||||||
|
import { waitUntilSafe } from "../utils/worker";
|
||||||
|
import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants";
|
||||||
|
|
||||||
function waitUntilSafe(c: Context<AppEnv>, promise: Promise<unknown>) {
|
type AppEnv = { Bindings: Env };
|
||||||
// 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
|
|
||||||
|
|
||||||
export const hubRouter = new Hono<AppEnv>();
|
export const hubRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
|||||||
@@ -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<unknown>): void {
|
||||||
|
try {
|
||||||
|
c.executionCtx.waitUntil(promise);
|
||||||
|
} catch {
|
||||||
|
// ExecutionContext unavailable in Node test environment — ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user