Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion worker/mcp-entry.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { handleMcp } from "./src/mcp";
import { logTrafficWithRequest } from "./src/shared/traffic-logger";
import type { McpEnv } from "./src/env";

export default {
fetch(request: Request, env: McpEnv, ctx: ExecutionContext) {
return handleMcp(request, env, ctx);
return logTrafficWithRequest("mcp-server", env, ctx, request, async () => {
return handleMcp(request, env, ctx);
});
},
} satisfies ExportedHandler<McpEnv>;
1 change: 1 addition & 0 deletions worker/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface McpEnv {
TRAFFIC_LOGS: R2Bucket;
OPENAPI_JSON?: string;
MCP_API_BASE?: string;
MCP_ALLOWED_HOSTS?: string;
Expand Down
61 changes: 32 additions & 29 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Env } from "./env";
import { logTrafficWithRequest } from "./shared/traffic-logger";

export type { Env } from "./env";

Expand All @@ -17,39 +18,41 @@ const CORS = {
};

export default {
async fetch(req: Request, env: Env): Promise<Response> {
console.log(JSON.stringify({
method: req.method,
path: new URL(req.url).pathname,
country: req.cf?.country ?? "unknown",
colo: req.cf?.colo ?? "unknown",
userAgent: req.headers.get("User-Agent") ?? "",
referer: req.headers.get("Referer") ?? "",
accept: req.headers.get("Accept") ?? "",
timestamp: new Date().toISOString(),
}));
if (req.method === "OPTIONS") return new Response(null, { headers: CORS });
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return logTrafficWithRequest("rest-api", env, ctx, req, async () => {
console.log(JSON.stringify({
method: req.method,
path: new URL(req.url).pathname,
country: req.cf?.country ?? "unknown",
colo: req.cf?.colo ?? "unknown",
userAgent: req.headers.get("User-Agent") ?? "",
referer: req.headers.get("Referer") ?? "",
accept: req.headers.get("Accept") ?? "",
timestamp: new Date().toISOString(),
}));
if (req.method === "OPTIONS") return new Response(null, { headers: CORS });

const u = new URL(req.url);
const p = u.pathname.replace(/\/+$/, "");
const u = new URL(req.url);
const p = u.pathname.replace(/\/+$/, "");

try {
if (p === "/" || p === "") return text("PS-Wiki API v1");
if (p === "/openapi.json") return json(env.OPENAPI_JSON ?? DEFAULT_OPENAPI, 200);
try {
if (p === "/" || p === "") return text("PS-Wiki API v1");
if (p === "/openapi.json") return json(env.OPENAPI_JSON ?? DEFAULT_OPENAPI, 200);

if (p === "/v1/terms") return listTerms(u, env);
if (p.startsWith("/v1/terms/")) {
const id = decodeURIComponent(p.split("/").pop() || "");
return getTerm(id, env);
}
if (p === "/v1/tags") return listTags(env);
if (p === "/v1/changes") return changes(u, env);
if (p === "/v1/terms") return listTerms(u, env);
if (p.startsWith("/v1/terms/")) {
const id = decodeURIComponent(p.split("/").pop() || "");
return getTerm(id, env);
}
if (p === "/v1/tags") return listTags(env);
if (p === "/v1/changes") return changes(u, env);

return json({ error: "not_found" }, 404);
} catch (e: unknown) {
console.error("Unhandled request error", e);
return json({ error: "internal_error" }, 500);
}
return json({ error: "not_found" }, 404);
} catch (e: unknown) {
console.error("Unhandled request error", e);
return json({ error: "internal_error" }, 500);
}
});
}
};

Expand Down
143 changes: 143 additions & 0 deletions worker/src/shared/traffic-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Traffic Logger — Drop-in module for writing request logs to R2.
*
* Works on the Workers Free plan. No Logpush or Tail Worker required.
* Each request writes one JSON object to your R2 bucket via an R2 binding.
*
* Usage in your Worker:
*
* import { logTraffic } from "./traffic-logger";
*
* export default {
* async fetch(request, env, ctx) {
* return logTraffic("rest-api", env, ctx, async () => {
* // ...your existing handler logic...
* return new Response("hello");
* });
* },
* };
*
* R2 binding required in wrangler.jsonc:
*
* "r2_buckets": [
* { "binding": "TRAFFIC_LOGS", "bucket_name": "<your-bucket-name>" }
* ]
*/

export interface TrafficLogEntry {
timestamp: string;
worker: string;
method: string;
url: string;
pathname: string;
status: number;
durationMs: number;
requestId: string;
cf?: {
colo?: string;
country?: string;
asn?: number;
};
error?: string;
}

/**
* Wraps a request handler and logs traffic to R2.
*
* @param workerName - Identifier for this Worker (e.g. "rest-api", "mcp-server")
* @param env - Worker env (must contain TRAFFIC_LOGS R2 binding)
* @param ctx - ExecutionContext (for waitUntil)
* @param handler - Your actual request handler
* @returns - The response from your handler
*/
export function logTraffic(
workerName: string,
env: { TRAFFIC_LOGS: R2Bucket },
ctx: { waitUntil: (p: Promise<unknown>) => void },
handler: () => Promise<Response>,
): Promise<Response> {
return logTrafficWithRequest(workerName, env, ctx, null, handler);
}

/**
* Full version — pass the original Request if you want cf metadata captured.
*/
export function logTrafficWithRequest(
workerName: string,
env: { TRAFFIC_LOGS: R2Bucket },
ctx: { waitUntil: (p: Promise<unknown>) => void },
request: Request | null,
handler: () => Promise<Response>,
): Promise<Response> {
const start = Date.now();
const requestId = crypto.randomUUID();
const timestamp = new Date().toISOString();

// Run the handler first, then log — logging is fire-and-forget
return handler().then(
(response) => {
writeLogEntry(
env, ctx, workerName, request, response.status, start,
requestId, timestamp, undefined,
);
return response;
},
(error) => {
writeLogEntry(
env, ctx, workerName, request, 500, start,
requestId, timestamp, String(error),
);
throw error;
},
);
}

function writeLogEntry(
env: { TRAFFIC_LOGS: R2Bucket },
ctx: { waitUntil: (p: Promise<unknown>) => void },
workerName: string,
request: Request | null,
status: number,
startMs: number,
requestId: string,
timestamp: string,
error: string | undefined,
): void {
const durationMs = Date.now() - startMs;

const entry: TrafficLogEntry = {
timestamp,
worker: workerName,
method: request?.method ?? "UNKNOWN",
url: request?.url ?? "",
pathname: request ? new URL(request.url).pathname : "",
status,
durationMs,
requestId,
};

// Capture Cloudflare cf metadata if available
const cf = (request as any)?.cf as Record<string, unknown> | undefined;
if (cf) {
entry.cf = {
colo: cf.colo as string | undefined,
country: cf.country as string | undefined,
asn: cf.asn as number | undefined,
};
}

if (error) {
entry.error = error;
}

// R2 key format: <worker>/<YYYY-MM-DD>/<requestId>.json
const datePart = timestamp.slice(0, 10); // YYYY-MM-DD
const key = `${workerName}/${datePart}/${requestId}.json`;

// Fire-and-forget — don't block the response
ctx.waitUntil(
env.TRAFFIC_LOGS.put(key, JSON.stringify(entry), {
httpMetadata: { contentType: "application/json" },
}),
);
}
1 change: 1 addition & 0 deletions worker/tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import worker from "../mcp-entry";
import type { McpEnv } from "../src/env";

const env: McpEnv = {
TRAFFIC_LOGS: { put: vi.fn(async () => undefined) } as unknown as R2Bucket,
MCP_API_BASE: "https://api.ps-wiki.ning.guru",
MCP_ALLOWED_HOSTS: "mcp.ps-wiki.ning.guru",
MCP_ALLOWED_ORIGINS: "mcp.ps-wiki.ning.guru",
Expand Down
4 changes: 4 additions & 0 deletions worker/wrangler-mcp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ MCP_ALLOWED_HOSTS = "mcp.ps-wiki.ning.guru,pswiki-mcp.jinning.workers.dev"
MCP_ALLOWED_ORIGINS = "mcp.ps-wiki.ning.guru,pswiki-mcp.jinning.workers.dev"
MCP_TIMEOUT_MS = "5000"
MCP_MAX_RESPONSE_BYTES = "1000000"

[[r2_buckets]]
binding = "TRAFFIC_LOGS"
bucket_name = "ps-wiki"
4 changes: 4 additions & 0 deletions worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ ORIGIN_BASE = "https://raw.githubusercontent.com/ps-wiki/ps-wiki.github.io/main/
INDEX_URL = "https://ps-wiki.github.io/api/index.json"
TAGS_URL = "https://ps-wiki.github.io/api/tags.json"
SITE_BASE = "https://ps-wiki.ning.guru"

[[r2_buckets]]
binding = "TRAFFIC_LOGS"
bucket_name = "ps-wiki"