返回 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 // No `runtime = "edge"`: @opennextjs/cloudflare does not support the edge
23 // runtime. Its `migrate` command says to remove the declaration, and its
24 // server bundle replaces Next's edge runtime with an empty shim. How the
25 // deployed route behaved while it was declared has not been checked. The
26 // default runtime already has fetch, Request, and TextDecoder.
27
28 export const CANONICAL_INGEST_URL = "https://telemetry.codewhale.net/v1/telemetry";
29 const FORWARD_TIMEOUT_MS = 1500;
30
31 export function ingestUrl(env: Record<string, string | undefined> = process.env): string | null {
32 const configured = env.CODEWHALE_TELEMETRY_INGEST_URL?.trim();
33 return configured === CANONICAL_INGEST_URL ? configured : null;
34 }
35
36 type Forward = (url: string, body: string, signal: AbortSignal) => Promise<{ ok: boolean }>;
37
38 const defaultForward: Forward = (url, body, signal) =>
39 fetch(url, {
40 method: "POST",
41 headers: { "content-type": "application/json" },
42 body,
43 signal,
44 redirect: "error",
45 });
46
47 export async function handleProductTelemetry(
48 request: Request,
49 deps: { ingestUrl?: string | null; forward?: Forward } = {},
50 ): Promise<Response> {
51 const target = deps.ingestUrl === undefined ? ingestUrl() : deps.ingestUrl;
52 const reply = (status: number, accepted: boolean, reason?: string) =>
53 NextResponse.json(reason ? { accepted, reason } : { accepted }, {
54 status,
55 headers: { "cache-control": "no-store" },
56 });
57
58 if (!target) return reply(200, false, "disabled");
59
60 let text: string;
61 try {
62 const bytes = await readBoundedBody(request, MAX_ENVELOPE_BYTES);
63 text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
64 } catch (cause) {
65 if (cause instanceof BodyReadError) {
66 return reply(cause.status, false, cause.status === 413 ? "too_large" : "invalid_body");
67 }
68 return reply(422, false, "invalid_json");
69 }
70
71 let parsed: unknown;
72 try {
73 parsed = JSON.parse(text);
74 } catch {
75 return reply(422, false, "invalid_json");
76 }
77 const validated = validateEnvelope(parsed, { surfaces: ["website"] });
78 if (!validated.ok) return reply(422, false, "schema");
79
80 const controller = new AbortController();
81 const timer = setTimeout(() => controller.abort(), FORWARD_TIMEOUT_MS);
82 try {
83 // Re-serialise the validated envelope so only known fields travel.
84 const response = await (deps.forward ?? defaultForward)(
85 target,
86 JSON.stringify(validated.envelope),
87 controller.signal,
88 );
89 return reply(200, response.ok, response.ok ? undefined : "unavailable");
90 } catch {
91 return reply(200, false, "unavailable");
92 } finally {
93 clearTimeout(timer);
94 }
95 }
96
97 export async function POST(request: Request): Promise<Response> {
98 return handleProductTelemetry(request);
99 }
100
100 lines TYPESCRIPT