mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
- `stanza init --dry-run` no longer creates the project directory, monorepo shell, or `stanza.json`; the dry-run manifest is built with `emptyManifest` and package.json existence checks are skipped throughout `applyModule`
- Direct-fs codemods (`append-to-file`, `add-package-dep`, `set-tsconfig-paths`) now claim their region before writing so a failed `add` rolls back to true pre-apply bytes and `RegionConflictError` fires before any disk change
- `stanza remove` reads the `consumesPackages` snapshot persisted on each installed record (falling back to a live registry fetch only for legacy records), so package-dir protection holds offline and after upstream renames
- `stanza remove` only applies the legacy bare-id owner fallback when no sibling install of the same module remains, preventing one app's removal from sweeping another app's files on pre-composite-owner manifests
- `stanza doctor` no longer reports false drift for package-home modules installed with an `--app` restriction; `expectedConsumerApps` now intersects targeted apps from the manifest records
- `wrap-root-layout` resolves the framework per dispatched app, fixing multi-app projects with differing frameworks
- The codemod render context now carries `packageManager` so `{{pm}}`/`{{run …}}` templates render bun/npm/pnpm correctly instead of always defaulting to pnpm
104 lines
3.4 KiB
TypeScript
104 lines
3.4 KiB
TypeScript
import { createFileRoute } from "@tanstack/react-router";
|
|
import { waitUntil } from "@vercel/functions";
|
|
import { PostHog } from "posthog-node";
|
|
|
|
/**
|
|
* `POST /api/events` — analytics ingest for the `stanza-cli`. The CLI sends
|
|
* plain `fetch` batches here so it never has to bundle `posthog-node`; this
|
|
* route holds the PostHog project key server-side and forwards each event via
|
|
* the `posthog-node` client.
|
|
*/
|
|
|
|
const MAX_EVENTS = 20;
|
|
|
|
// Module-level singleton so successive cold-start invocations reuse the client.
|
|
let client: PostHog | null = null;
|
|
function getClient(): PostHog | null {
|
|
const apiKey = process.env.VITE_PUBLIC_POSTHOG_KEY;
|
|
if (!apiKey) return null;
|
|
if (!client) {
|
|
client = new PostHog(apiKey, {
|
|
host: process.env.VITE_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com",
|
|
flushAt: 1,
|
|
flushInterval: 0,
|
|
});
|
|
}
|
|
return client;
|
|
}
|
|
|
|
type IncomingEvent = {
|
|
event: string;
|
|
properties?: Record<string, unknown>;
|
|
timestamp?: string;
|
|
};
|
|
|
|
type Payload = {
|
|
distinctId: string;
|
|
events: IncomingEvent[];
|
|
};
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function parsePayload(body: unknown): Payload | null {
|
|
if (!isObject(body)) return null;
|
|
const { distinctId, events } = body;
|
|
if (typeof distinctId !== "string" || distinctId.length === 0) return null;
|
|
if (!Array.isArray(events) || events.length === 0 || events.length > MAX_EVENTS) return null;
|
|
const parsed: IncomingEvent[] = [];
|
|
for (const e of events) {
|
|
if (!isObject(e)) return null;
|
|
if (typeof e.event !== "string") return null;
|
|
parsed.push({
|
|
event: e.event,
|
|
properties: isObject(e.properties) ? e.properties : undefined,
|
|
timestamp: typeof e.timestamp === "string" ? e.timestamp : undefined,
|
|
});
|
|
}
|
|
return { distinctId, events: parsed };
|
|
}
|
|
|
|
export const Route = createFileRoute("/api/events")({
|
|
server: {
|
|
handlers: {
|
|
POST: async ({ request }) => {
|
|
let body: unknown;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return new Response("Invalid JSON", { status: 400 });
|
|
}
|
|
|
|
const payload = parsePayload(body);
|
|
if (!payload) return new Response("Invalid payload", { status: 400 });
|
|
|
|
const posthog = getClient();
|
|
// No key configured (local dev / self-host without analytics): accept
|
|
// and discard so the CLI never sees an error.
|
|
if (!posthog) return new Response(null, { status: 204 });
|
|
|
|
for (const e of payload.events) {
|
|
// Drop unparseable timestamps — an Invalid Date would throw inside
|
|
// posthog-node's serialization, turning bad input into a 500.
|
|
const ts = e.timestamp ? new Date(e.timestamp) : undefined;
|
|
posthog.capture({
|
|
distinctId: payload.distinctId,
|
|
event: e.event,
|
|
properties: e.properties ?? {},
|
|
timestamp: ts && !Number.isNaN(ts.getTime()) ? ts : undefined,
|
|
});
|
|
}
|
|
|
|
// Serverless functions can freeze before in-flight requests settle, so
|
|
// hand the flush to `waitUntil` to keep it alive past the response
|
|
// without blocking it. Telemetry must never surface as a user-facing
|
|
// error, so swallow failures.
|
|
waitUntil(posthog.flush().catch(() => {}));
|
|
|
|
return new Response(null, { status: 202 });
|
|
},
|
|
},
|
|
},
|
|
});
|