返回 CodeWhale
route.ts
根目录 / web / app / api / product-telemetry / route.ts
1 import { BodyReadError, readBoundedBody } from "@/lib/bounded-body";
2 import { NextResponse } from "next/server";
3 import { MAX_ENVELOPE_BYTES, validateEnvelope } from "@/lib/telemetry/product-usage";
4
5 /**
6 * POST /api/product-telemetry — the website's same-origin forwarder.
7 *
8 * Inert without configuration: unless `CODEWHALE_TELEMETRY_INGEST_URL` is set
9 * to the exact canonical first-party ingest, the route accepts nothing and
10 * forwards nothing. With it set, a batch is forwarded only when it passes the
11 * closed-set validator for the `website` surface and fits in 4 KiB. The
12 * forward sets only a content-type header and the validated body; it does not
13 * copy client headers. Hosting infrastructure may add transport headers, so
14 * this is not a claim of network anonymity. Forwarding has a 1.5-second timeout
15 * with no retry. The response is
16 * `{ accepted, reason? }`, never the ingest's own body.
17 *
18 * The PostHog token, if the ingest has one, lives there. This route never
19 * holds it.
20 */
21
22 export const runtime = "edge";
23
24 export const CANONICAL_INGEST_URL = "https://telemetry.codewhale.net/v1/telemetry";
25 const FORWARD_TIMEOUT_MS = 1500;
26
27 export function ingestUrl(env: Record<string, string | undefined> = process.env): string | null {
28 const configured = env.CODEWHALE_TELEMETRY_INGEST_URL?.trim();
29 return configured === CANONICAL_INGEST_URL ? configured : null;
30 }
31
32 type Forward = (url: string, body: string, signal: AbortSignal) => Promise<{ ok: boolean }>;
33
34 const defaultForward: Forward = (url, body, signal) =>
35 fetch(url, {
36 method: "POST",
37 headers: { "content-type": "application/json" },
38 body,
39 signal,
40 redirect: "error",
41 });
42
43 export async function handleProductTelemetry(
44 request: Request,
45 deps: { ingestUrl?: string | null; forward?: Forward } = {},
46 ): Promise<Response> {
47 const target = deps.ingestUrl === undefined ? ingestUrl() : deps.ingestUrl;
48 const reply = (status: number, accepted: boolean, reason?: string) =>
49 NextResponse.json(reason ? { accepted, reason } : { accepted }, {
50 status,
51 headers: { "cache-control": "no-store" },
52 });
53
54 if (!target) return reply(200, false, "disabled");
55
56 let text: string;
57 try {
58 const bytes = await readBoundedBody(request, MAX_ENVELOPE_BYTES);
59 text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
60 } catch (cause) {
61 if (cause instanceof BodyReadError) {
62 return reply(cause.status, false, cause.status === 413 ? "too_large" : "invalid_body");
63 }
64 return reply(422, false, "invalid_json");
65 }
66
67 let parsed: unknown;
68 try {
69 parsed = JSON.parse(text);
70 } catch {
71 return reply(422, false, "invalid_json");
72 }
73 const validated = validateEnvelope(parsed, { surfaces: ["website"] });
74 if (!validated.ok) return reply(422, false, "schema");
75
76 const controller = new AbortController();
77 const timer = setTimeout(() => controller.abort(), FORWARD_TIMEOUT_MS);
78 try {
79 // Re-serialise the validated envelope so only known fields travel.
80 const response = await (deps.forward ?? defaultForward)(
81 target,
82 JSON.stringify(validated.envelope),
83 controller.signal,
84 );
85 return reply(200, response.ok, response.ok ? undefined : "unavailable");
86 } catch {
87 return reply(200, false, "unavailable");
88 } finally {
89 clearTimeout(timer);
90 }
91 }
92
93 export async function POST(request: Request): Promise<Response> {
94 return handleProductTelemetry(request);
95 }
96
96 lines TYPESCRIPT