@prisma-next/cli-telemetry 0.10.0-dev.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Best-effort identification of AI coding-agent sessions from an
3
+ * env-var allowlist. Detector property: false positives are negligible
4
+ * (a marker present ⇒ confidently an agent); false negatives are
5
+ * expected and documented in the user-facing telemetry docs. New
6
+ * entries should land here, not in per-CLI hand-rolls.
7
+ *
8
+ * Each entry is a `(envVar, agent)` pair with uniform comparison shape:
9
+ * the marker counts as "present" when `process.env[envVar]` is set to a
10
+ * truthy string. Truthy = anything other than the empty string, `'0'`,
11
+ * or `'false'` (case-insensitive); see `gating.isTruthyOptOut` for the
12
+ * same convention applied to opt-out env vars.
13
+ *
14
+ * The detector runs in the **child** sender process, never the parent;
15
+ * the parent does not probe env at command start.
16
+ *
17
+ * Codex CLI note: `CODEX_SANDBOX` is the only clear marker available here.
18
+ * Non-sandboxed Codex sessions may be false negatives.
19
+ *
20
+ * TODO: a ci-info-for-agents would be nice — this allowlist drifts the
21
+ * moment a new agent ships its env marker, and consolidating with the
22
+ * other ecosystems that need the same lookup (rate-limited LLM
23
+ * gateways, agent-aware metrics, etc.) would let one library carry the
24
+ * matrix instead of every consumer re-doing it.
25
+ */
26
+ export interface AgentMarker {
27
+ /** The env-var name to read. Exact-match; no prefix or fuzzy logic. */
28
+ readonly envVar: string;
29
+ /** The agent label written to the `agent` field of the telemetry event. */
30
+ readonly agent: string;
31
+ }
32
+
33
+ export const AGENT_MARKERS: readonly AgentMarker[] = [
34
+ { envVar: 'CLAUDECODE', agent: 'Claude Code' },
35
+ { envVar: 'CURSOR_AGENT', agent: 'Cursor' },
36
+ { envVar: 'CODEX_SANDBOX', agent: 'Codex CLI' },
37
+ { envVar: 'GEMINI_CLI', agent: 'Gemini CLI' },
38
+ { envVar: 'WINDSURF', agent: 'Windsurf' },
39
+ { envVar: 'AIDER', agent: 'Aider' },
40
+ { envVar: 'CODY', agent: 'Cody' },
41
+ { envVar: 'CONTINUE', agent: 'Continue' },
42
+ ];
43
+
44
+ function isTruthyMarker(raw: string | undefined): boolean {
45
+ if (raw === undefined) return false;
46
+ const normalised = raw.trim().toLowerCase();
47
+ if (normalised === '') return false;
48
+ if (normalised === '0') return false;
49
+ if (normalised === 'false') return false;
50
+ return true;
51
+ }
52
+
53
+ /**
54
+ * Resolve the agent label from an env snapshot, or `null` if no marker
55
+ * is set. Returns the **first** matching marker in `AGENT_MARKERS`
56
+ * order, so when multiple markers are set the agent label is
57
+ * deterministic and the allowlist's first entry wins.
58
+ *
59
+ * Pure: takes an env record, returns a string or null. No I/O.
60
+ */
61
+ export function detectAgent(env: Readonly<Record<string, string | undefined>>): string | null {
62
+ for (const marker of AGENT_MARKERS) {
63
+ if (isTruthyMarker(env[marker.envVar])) {
64
+ return marker.agent;
65
+ }
66
+ }
67
+ return null;
68
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Production endpoint pinned to the deployed Prisma Compute backend.
3
+ * Compiled as a build-time constant; not user-configurable.
4
+ */
5
+ export const TELEMETRY_BACKEND_URL = 'https://cmpbfbsdp09hr3jf7pojjs5qs.ewr.prisma.build';
6
+
7
+ /**
8
+ * Path within the backend that accepts telemetry POSTs.
9
+ */
10
+ export const TELEMETRY_ENDPOINT_PATH = '/events';
11
+
12
+ /**
13
+ * Resolve the full POST URL the sender targets. The
14
+ * `PRISMA_NEXT_TELEMETRY_ENDPOINT` env var is an integration-testing
15
+ * affordance only — it lets the test suite spin up a mock HTTP server
16
+ * on an ephemeral port and point the spawned sender at it. The override
17
+ * is intentionally undocumented in user-facing material.
18
+ *
19
+ * Fail-open: a malformed override (typo in a dev shell, bad CI config)
20
+ * silently falls back to the production backend rather than throwing,
21
+ * matching the telemetry layer's broader silent-on-failure contract.
22
+ */
23
+ export function resolveTelemetryEndpoint(
24
+ env: Readonly<Record<string, string | undefined>> = process.env,
25
+ ): string {
26
+ const override = env['PRISMA_NEXT_TELEMETRY_ENDPOINT'];
27
+ const base = override !== undefined && override.length > 0 ? override : TELEMETRY_BACKEND_URL;
28
+ try {
29
+ return new URL(TELEMETRY_ENDPOINT_PATH, base).toString();
30
+ } catch {
31
+ return new URL(TELEMETRY_ENDPOINT_PATH, TELEMETRY_BACKEND_URL).toString();
32
+ }
33
+ }
package/src/enrich.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'pathe';
3
+ import { detectAgent } from './detect-agent';
4
+ import type { ParentToSenderPayload, TelemetryEvent } from './payload';
5
+
6
+ /**
7
+ * Versions surface the enrichment cares about. Modelled as a structural
8
+ * record with a required `node` field so tests can pass a literal object
9
+ * without faking every field of `NodeJS.ProcessVersions` (which adds
10
+ * properties between Node versions and includes a long tail the
11
+ * enrichment never touches). Both `bun` and `deno` are read on the
12
+ * runtime-resolution path; everything else is ignored.
13
+ */
14
+ export interface VersionsSnapshot {
15
+ readonly node: string;
16
+ readonly bun?: string;
17
+ readonly deno?: string;
18
+ }
19
+
20
+ /**
21
+ * Snapshot of process-level inputs the enrichment reads. Tests pass an
22
+ * explicit snapshot so the enrichment is deterministic per case; the
23
+ * sender entry point passes a fresh snapshot from `process`.
24
+ */
25
+ export interface EnrichEnvironment {
26
+ readonly platform: NodeJS.Platform;
27
+ readonly arch: string;
28
+ readonly versions: VersionsSnapshot;
29
+ /**
30
+ * Included because package-manager and agent detection intentionally read
31
+ * environment variables from the same process snapshot as platform/versions.
32
+ */
33
+ readonly env: Readonly<Record<string, string | undefined>>;
34
+ /**
35
+ * Best-effort reader for the project's `package.json`, used only to derive
36
+ * the optional `tsVersion` telemetry field. Returning `null` means unknown.
37
+ */
38
+ readonly readProjectPackageJson: () => string | null;
39
+ }
40
+
41
+ /**
42
+ * Identify the runtime the sender is running in. Same-runtime as the
43
+ * parent is a correctness requirement: the parent forked us via
44
+ * `child_process.fork`, which inherits the parent's runtime. Detection
45
+ * keys on the runtime-specific version field rather than env vars so a
46
+ * spoofed env can't lie about the actual interpreter.
47
+ */
48
+ function resolveRuntime(versions: VersionsSnapshot): {
49
+ readonly name: 'node' | 'bun' | 'deno';
50
+ readonly version: string;
51
+ } {
52
+ if (versions.bun !== undefined) {
53
+ return { name: 'bun', version: versions.bun };
54
+ }
55
+ if (versions.deno !== undefined) {
56
+ return { name: 'deno', version: versions.deno };
57
+ }
58
+ return { name: 'node', version: versions.node };
59
+ }
60
+
61
+ /**
62
+ * Parse `npm_config_user_agent` into a `<pm>/<version>` token. The
63
+ * value, when present, looks like
64
+ * `"pnpm/10.27.0 npm/? node/v24.13.0 darwin arm64"` — we take the first
65
+ * whitespace-separated token. Any failure → `null`.
66
+ */
67
+ export function parsePackageManager(userAgent: string | undefined): string | null {
68
+ if (userAgent === undefined) return null;
69
+ const first = userAgent.split(/\s+/)[0];
70
+ if (first === undefined || first.length === 0) return null;
71
+ if (!first.includes('/')) return null;
72
+ return first;
73
+ }
74
+
75
+ /**
76
+ * Read the user's project `package.json` and resolve a TypeScript
77
+ * version from `devDependencies.typescript` (preferred) or
78
+ * `dependencies.typescript`. Strips a leading `^` or `~` semver
79
+ * prefix. Returns `null` on any failure mode — file missing,
80
+ * unreadable, malformed JSON, key absent, not a string.
81
+ */
82
+ export function readTsVersionFromPackageJson(raw: string | null): string | null {
83
+ if (raw === null) return null;
84
+ let parsed: Record<string, unknown>;
85
+ try {
86
+ parsed = JSON.parse(raw) as Record<string, unknown>;
87
+ } catch {
88
+ return null;
89
+ }
90
+ const candidate =
91
+ pickStringDep(parsed['devDependencies']) ?? pickStringDep(parsed['dependencies']);
92
+ if (candidate === null) return null;
93
+ return candidate.replace(/^[\^~]/, '');
94
+ }
95
+
96
+ function pickStringDep(deps: unknown): string | null {
97
+ if (deps === null || typeof deps !== 'object' || Array.isArray(deps)) return null;
98
+ const value = (deps as Record<string, unknown>)['typescript'];
99
+ return typeof value === 'string' ? value : null;
100
+ }
101
+
102
+ /**
103
+ * Build the full backend event from the parent's payload and the
104
+ * child's per-process snapshot. Pure given an `EnrichEnvironment`.
105
+ */
106
+ export function buildTelemetryEvent(
107
+ payload: ParentToSenderPayload,
108
+ env: EnrichEnvironment,
109
+ ): TelemetryEvent {
110
+ const runtime = resolveRuntime(env.versions);
111
+ return {
112
+ installationId: payload.installationId,
113
+ version: payload.version,
114
+ command: payload.command,
115
+ flags: payload.flags,
116
+ runtimeName: runtime.name,
117
+ runtimeVersion: runtime.version,
118
+ os: env.platform,
119
+ arch: env.arch,
120
+ packageManager: parsePackageManager(env.env['npm_config_user_agent']),
121
+ databaseTarget: payload.databaseTarget,
122
+ tsVersion: readTsVersionFromPackageJson(env.readProjectPackageJson()),
123
+ agent: detectAgent(env.env),
124
+ extensions: payload.extensions,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Convenience for the sender entry: build the event from the live
130
+ * `process` plus a real project-package.json reader, swallowing any
131
+ * I/O errors in the file read.
132
+ */
133
+ export function buildTelemetryEventFromProcess(payload: ParentToSenderPayload): TelemetryEvent {
134
+ return buildTelemetryEvent(payload, {
135
+ platform: process.platform,
136
+ arch: process.arch,
137
+ versions: process.versions,
138
+ env: process.env,
139
+ readProjectPackageJson: () => {
140
+ try {
141
+ return readFileSync(join(payload.projectRoot, 'package.json'), 'utf-8');
142
+ } catch {
143
+ return null;
144
+ }
145
+ },
146
+ });
147
+ }
@@ -0,0 +1,14 @@
1
+ export {
2
+ resolveTelemetryEndpoint,
3
+ TELEMETRY_BACKEND_URL,
4
+ TELEMETRY_ENDPOINT_PATH,
5
+ } from '../endpoint';
6
+ export type { GatingDisabledReason, GatingInputs, GatingResolution } from '../gating';
7
+ export { resolveGating } from '../gating';
8
+ export type { ParentToSenderPayload, TelemetryEvent } from '../payload';
9
+ export type { CommanderOptionShape, CommanderResultShape, SanitisedCommand } from '../sanitize';
10
+ export { sanitizeCommanderResult } from '../sanitize';
11
+ export type { RunTelemetryInputs, TelemetryRunOutcome } from '../spawn';
12
+ export { runTelemetry, senderModuleUrl } from '../spawn';
13
+ export type { UserConfig } from '../user-config';
14
+ export { readUserConfig, userConfigPath, writeUserConfig } from '../user-config';
package/src/gating.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { UserConfig } from './user-config';
2
+
3
+ /**
4
+ * Why telemetry was disabled. Useful for debug-mode logging in the
5
+ * parent; never surfaces to users.
6
+ */
7
+ export type GatingDisabledReason = 'env-override' | 'stored-opt-out' | 'default-off';
8
+
9
+ export type GatingResolution =
10
+ | { readonly enabled: true }
11
+ | { readonly enabled: false; readonly reason: GatingDisabledReason };
12
+
13
+ export interface GatingInputs {
14
+ /**
15
+ * Environment-variable lookups the resolver consults. Tests pass a
16
+ * literal record; production passes `process.env`. The two opt-out
17
+ * signals are `PRISMA_NEXT_DISABLE_TELEMETRY` (Prisma-specific) and
18
+ * `DO_NOT_TRACK` (community convention).
19
+ */
20
+ readonly env: Readonly<Record<string, string | undefined>>;
21
+ /** Result of `readUserConfig()` — file-missing tolerated as `{}`. */
22
+ readonly config: UserConfig;
23
+ }
24
+
25
+ /**
26
+ * A `PRISMA_NEXT_DISABLE_TELEMETRY` value counts as an opt-out only if
27
+ * it parses as a truthy string. The set-but-falsy spellings (`''`,
28
+ * `'0'`, `'false'`) are intentionally treated as not-set so a parent
29
+ * shell that exports the variable to a benign value doesn't accidentally
30
+ * disable telemetry for child processes.
31
+ */
32
+ function isTruthyOptOut(raw: string | undefined): boolean {
33
+ if (raw === undefined) return false;
34
+ const normalised = raw.trim().toLowerCase();
35
+ if (normalised === '') return false;
36
+ if (normalised === '0') return false;
37
+ if (normalised === 'false') return false;
38
+ return true;
39
+ }
40
+
41
+ /**
42
+ * Pure-function resolution of the gating decision. Same input → same
43
+ * output; no I/O. The caller is responsible for reading the env and the
44
+ * user config.
45
+ *
46
+ * Decision order:
47
+ * 1. Env-var override (`PRISMA_NEXT_DISABLE_TELEMETRY` truthy, or
48
+ * `DO_NOT_TRACK=1`) → disabled.
49
+ * 2. Stored `enableTelemetry === true` → enabled.
50
+ * 3. Stored `enableTelemetry === false` → disabled (`stored-opt-out`).
51
+ * 4. Stored `enableTelemetry === undefined` (file missing, or field
52
+ * not set) → disabled (`default-off`).
53
+ *
54
+ * Telemetry is enabled only when no env override is active **and**
55
+ * `enableTelemetry` is explicitly `true`.
56
+ */
57
+ export function resolveGating(inputs: GatingInputs): GatingResolution {
58
+ if (
59
+ isTruthyOptOut(inputs.env['PRISMA_NEXT_DISABLE_TELEMETRY']) ||
60
+ inputs.env['DO_NOT_TRACK'] === '1'
61
+ ) {
62
+ return { enabled: false, reason: 'env-override' };
63
+ }
64
+ if (inputs.config.enableTelemetry === true) {
65
+ return { enabled: true };
66
+ }
67
+ if (inputs.config.enableTelemetry === false) {
68
+ return { enabled: false, reason: 'stored-opt-out' };
69
+ }
70
+ return { enabled: false, reason: 'default-off' };
71
+ }
package/src/payload.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { type } from 'arktype';
2
+
3
+ /**
4
+ * Wire-shape payload the parent IPC-sends to the forked child sender.
5
+ * Mirrors the fields the parent has naturally in hand at command start
6
+ * (installation id, sanitised command + flags, CLI version, db target,
7
+ * extension-pack ids, project root for TS-version lookup). The child
8
+ * fills in the rest (runtime/os/arch, package manager, ts version,
9
+ * agent) on its side.
10
+ *
11
+ * Both sides version-couple on this shape because the IPC carrier is
12
+ * structured-cloned by Node and there's no on-wire compat to maintain.
13
+ */
14
+ export interface ParentToSenderPayload {
15
+ readonly installationId: string;
16
+ readonly version: string;
17
+ readonly command: string;
18
+ readonly flags: readonly string[];
19
+ readonly databaseTarget: string | null;
20
+ readonly extensions: readonly string[];
21
+ /** Absolute path of the user's project. The child reads `<projectRoot>/package.json` for `tsVersion`. */
22
+ readonly projectRoot: string;
23
+ /** Resolved endpoint URL (already includes the `/events` path). */
24
+ readonly endpoint: string;
25
+ }
26
+
27
+ /**
28
+ * Runtime validator for {@link ParentToSenderPayload}. The child sender
29
+ * uses this to gate `postEvent` so a payload missing a required field
30
+ * cannot silently produce a degraded telemetry event downstream.
31
+ *
32
+ * Mirrors the backend's own arktype schema in spirit: required scalars
33
+ * must be non-empty strings; `databaseTarget` is `string | null`; the
34
+ * two string arrays are validated element-by-element. Size caps are
35
+ * enforced by the backend, not here — IPC is structured-cloned and
36
+ * the parent/child agree on the schema by version-coupling.
37
+ */
38
+ const requiredString = type.string.moreThanLength(0);
39
+ const stringArray = type.string.array();
40
+
41
+ export const parentToSenderPayloadSchema = type({
42
+ installationId: requiredString,
43
+ version: requiredString,
44
+ command: requiredString,
45
+ flags: stringArray,
46
+ databaseTarget: type.string.or('null'),
47
+ extensions: stringArray,
48
+ projectRoot: requiredString,
49
+ endpoint: requiredString,
50
+ });
51
+
52
+ export function isParentToSenderPayload(value: unknown): value is ParentToSenderPayload {
53
+ return !(parentToSenderPayloadSchema(value) instanceof type.errors);
54
+ }
55
+
56
+ /**
57
+ * The full event the child POSTs to the backend. Shape matches the
58
+ * backend's arktype schema (`apps/telemetry-backend/src/schema.ts`).
59
+ */
60
+ export interface TelemetryEvent {
61
+ readonly installationId: string;
62
+ readonly version: string;
63
+ readonly command: string;
64
+ readonly flags: readonly string[];
65
+ readonly runtimeName: string;
66
+ readonly runtimeVersion: string;
67
+ readonly os: string;
68
+ readonly arch: string;
69
+ readonly packageManager: string | null;
70
+ readonly databaseTarget: string | null;
71
+ readonly tsVersion: string | null;
72
+ readonly agent: string | null;
73
+ readonly extensions: readonly string[];
74
+ }
@@ -0,0 +1,78 @@
1
+ export interface CommanderOptionShape {
2
+ /** Commander's option attribute name, e.g. `dryRun` for `--dry-run`. */
3
+ readonly attributeName: string;
4
+ /** Commander's long, user-facing flag spelling, e.g. `--dry-run` or `--no-install`. */
5
+ readonly longName: string | null;
6
+ /** Commander's value source for this option. Only `cli` is user-supplied. */
7
+ readonly source: string | null;
8
+ }
9
+
10
+ /**
11
+ * Input shape: a thin projection of commander's parsed-result surface.
12
+ * The parent extracts the command path, positional args, and per-option
13
+ * metadata from the leaf command. The sanitiser never consumes raw
14
+ * argv, never reads `process.argv`, and never sees flag values.
15
+ */
16
+ export interface CommanderResultShape {
17
+ /**
18
+ * The full command path from the root program to the leaf, including
19
+ * the root program name as the first element (the sanitiser drops it).
20
+ * Example: `['prisma-next', 'migration', 'new']`.
21
+ */
22
+ readonly commandPath: readonly string[];
23
+ /**
24
+ * Positional arguments commander parsed for the leaf command.
25
+ * **Intentionally never read.** Accepted so the call site doesn't have
26
+ * to think about whether to pass it; the sanitiser's contract is that
27
+ * positionals never leave the parent process.
28
+ */
29
+ readonly positionalArgs: readonly string[];
30
+ /**
31
+ * Per-option Commander metadata. The sanitiser emits only options whose
32
+ * source is `cli`, and uses `longName` so telemetry sees user-facing
33
+ * names (`dry-run`, `connection-string`, `no-install`) rather than
34
+ * Commander's internal camelCase attribute names or defaulted options.
35
+ */
36
+ readonly options: readonly CommanderOptionShape[];
37
+ }
38
+
39
+ /**
40
+ * Output shape: the sanitised projection that flows into the telemetry
41
+ * payload. Two fields only — command name (space-delimited subcommand
42
+ * path) and flag names (in commander's option declaration order).
43
+ */
44
+ export interface SanitisedCommand {
45
+ readonly command: string;
46
+ readonly flags: readonly string[];
47
+ }
48
+
49
+ function flagNameFromLongName(longName: string | null): string | null {
50
+ if (longName === null || !longName.startsWith('--')) return null;
51
+ const withoutPrefix = longName.slice(2);
52
+ return withoutPrefix.length > 0 ? withoutPrefix : null;
53
+ }
54
+
55
+ /**
56
+ * Project commander's parsed result into the wire-shape command and
57
+ * flag-name list. Pure; the only allowed inputs are the fields of
58
+ * `CommanderResultShape`.
59
+ *
60
+ * Sanitiser contract — no flag values, no positionals, no raw argv:
61
+ * - Drop the root program name (`commandPath[0]`); the wire ships
62
+ * `migration new`, not `prisma-next migration new`.
63
+ * - Emit only options whose Commander source is `cli`.
64
+ * - Emit the long user-facing flag spelling without the `--` prefix;
65
+ * never emit Commander's camelCase attribute names.
66
+ * - `positionalArgs` is accepted but never consumed; the field exists
67
+ * in the input type to make it obvious at the call site that
68
+ * positionals were deliberately excluded.
69
+ */
70
+ export function sanitizeCommanderResult(input: CommanderResultShape): SanitisedCommand {
71
+ const command = input.commandPath.slice(1).join(' ');
72
+ const flags = input.options.flatMap((option) => {
73
+ if (option.source !== 'cli') return [];
74
+ const flagName = flagNameFromLongName(option.longName);
75
+ return flagName === null ? [] : [flagName];
76
+ });
77
+ return { command, flags };
78
+ }
package/src/sender.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Sender script entry — forked into a detached child by the parent CLI via
3
+ * `child_process.fork(senderPath, [], { detached: true, ... })`.
4
+ *
5
+ * Lifecycle:
6
+ * 1. Wait for the parent's IPC `message` event carrying a
7
+ * `ParentToSenderPayload`.
8
+ * 2. Enrich with the local-process probes (runtime, os, arch, agent,
9
+ * package manager, tsVersion).
10
+ * 3. POST the event to the endpoint URL with a hard 1.5 s timeout.
11
+ * 4. Exit 0 unconditionally — successful POST, network failure, server
12
+ * error, parse error of the response, anything else: same outcome.
13
+ *
14
+ * Every error is swallowed; the only escape valve for visibility is
15
+ * `PRISMA_NEXT_DEBUG=1`, which routes diagnostics to stderr. In normal
16
+ * operation no telemetry-originating output ever reaches the user — the
17
+ * parent's stdio map ignores our streams anyway, but we also gate
18
+ * stderr writes behind the debug flag so the same binary is safe to
19
+ * invoke directly outside the spawn flow.
20
+ */
21
+ import { buildTelemetryEventFromProcess } from './enrich';
22
+ import { isParentToSenderPayload, type ParentToSenderPayload } from './payload';
23
+
24
+ const REQUEST_TIMEOUT_MS = 1500;
25
+
26
+ function debugLog(message: string, error?: unknown): void {
27
+ if (process.env['PRISMA_NEXT_DEBUG'] !== '1') return;
28
+ if (error !== undefined) {
29
+ process.stderr.write(`[cli-telemetry] ${message}: ${String(error)}\n`);
30
+ } else {
31
+ process.stderr.write(`[cli-telemetry] ${message}\n`);
32
+ }
33
+ }
34
+
35
+ async function postEvent(payload: ParentToSenderPayload): Promise<void> {
36
+ const event = buildTelemetryEventFromProcess(payload);
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
39
+ try {
40
+ const response = await fetch(payload.endpoint, {
41
+ method: 'POST',
42
+ headers: { 'content-type': 'application/json' },
43
+ body: JSON.stringify(event),
44
+ signal: controller.signal,
45
+ });
46
+ debugLog(`sent event: status=${response.status}`);
47
+ } catch (err) {
48
+ debugLog('send failed', err);
49
+ } finally {
50
+ clearTimeout(timer);
51
+ }
52
+ }
53
+
54
+ function exitClean(): void {
55
+ // `process.disconnect()` lets the parent's `.disconnect()` complete
56
+ // without lingering IPC handles when the parent is fast.
57
+ try {
58
+ process.disconnect?.();
59
+ } catch {
60
+ // ignore
61
+ }
62
+ process.exit(0);
63
+ }
64
+
65
+ process.once('message', (message: unknown) => {
66
+ if (!isParentToSenderPayload(message)) {
67
+ debugLog('received malformed payload; exiting');
68
+ exitClean();
69
+ return;
70
+ }
71
+ postEvent(message)
72
+ .catch((err) => debugLog('post threw', err))
73
+ .finally(exitClean);
74
+ });
75
+
76
+ // Defensive: if the parent never sends a payload (or the IPC channel
77
+ // closes before `message` arrives), exit after a generous grace period
78
+ // so the child process is not stuck holding a handle.
79
+ const SENDER_IDLE_EXIT_MS = REQUEST_TIMEOUT_MS * 2;
80
+ setTimeout(exitClean, SENDER_IDLE_EXIT_MS).unref();