next-evolve 0.0.0-stage → 0.1.0

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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -2
  3. package/dist/cli/index.d.ts +1 -0
  4. package/dist/cli/index.js +40 -0
  5. package/dist/client/beacon.d.ts +11 -0
  6. package/dist/client/beacon.js +51 -0
  7. package/dist/client/evolve-script.d.ts +6 -0
  8. package/dist/client/evolve-script.js +60 -0
  9. package/dist/client/index.d.ts +3 -0
  10. package/dist/client/index.js +3 -0
  11. package/dist/cloud/manifest.d.ts +12 -0
  12. package/dist/cloud/manifest.js +42 -0
  13. package/dist/cloud/push.d.ts +21 -0
  14. package/dist/cloud/push.js +56 -0
  15. package/dist/cloud/regions.d.ts +8 -0
  16. package/dist/cloud/regions.js +25 -0
  17. package/dist/config/config.d.ts +51 -0
  18. package/dist/config/config.js +47 -0
  19. package/dist/config/goals.d.ts +50 -0
  20. package/dist/config/goals.js +85 -0
  21. package/dist/config/index.d.ts +3 -0
  22. package/dist/config/index.js +3 -0
  23. package/dist/core/assign.d.ts +23 -0
  24. package/dist/core/assign.js +82 -0
  25. package/dist/core/bots.d.ts +5 -0
  26. package/dist/core/bots.js +25 -0
  27. package/dist/core/constants.d.ts +3 -0
  28. package/dist/core/constants.js +5 -0
  29. package/dist/core/cookies.d.ts +12 -0
  30. package/dist/core/cookies.js +25 -0
  31. package/dist/core/keys.d.ts +14 -0
  32. package/dist/core/keys.js +38 -0
  33. package/dist/core/log.d.ts +2 -0
  34. package/dist/core/log.js +10 -0
  35. package/dist/db/client.d.ts +22 -0
  36. package/dist/db/client.js +50 -0
  37. package/dist/db/migrate.d.ts +18 -0
  38. package/dist/db/migrate.js +43 -0
  39. package/dist/db/writer.d.ts +51 -0
  40. package/dist/db/writer.js +182 -0
  41. package/dist/index.d.ts +5 -0
  42. package/dist/index.js +5 -0
  43. package/dist/instrumentation/index.d.ts +23 -0
  44. package/dist/instrumentation/index.js +39 -0
  45. package/dist/proxy/index.d.ts +2 -0
  46. package/dist/proxy/index.js +1 -0
  47. package/dist/proxy/proxy.d.ts +18 -0
  48. package/dist/proxy/proxy.js +103 -0
  49. package/dist/react/context.d.ts +6 -0
  50. package/dist/react/context.js +8 -0
  51. package/dist/react/optimizable.d.ts +37 -0
  52. package/dist/react/optimizable.js +38 -0
  53. package/dist/react/region.d.ts +17 -0
  54. package/dist/react/region.js +127 -0
  55. package/dist/react/render-bundle.d.ts +9 -0
  56. package/dist/react/render-bundle.js +14 -0
  57. package/dist/react/with-evolve.d.ts +29 -0
  58. package/dist/react/with-evolve.js +57 -0
  59. package/dist/server/collect.d.ts +46 -0
  60. package/dist/server/collect.js +131 -0
  61. package/dist/server/events.d.ts +18 -0
  62. package/dist/server/events.js +50 -0
  63. package/dist/server/evolve.d.ts +20 -0
  64. package/dist/server/evolve.js +57 -0
  65. package/dist/server/handler.d.ts +16 -0
  66. package/dist/server/handler.js +72 -0
  67. package/dist/server/index.d.ts +4 -0
  68. package/dist/server/index.js +4 -0
  69. package/dist/server/runtime.d.ts +26 -0
  70. package/dist/server/runtime.js +45 -0
  71. package/dist/server/track-route.d.ts +25 -0
  72. package/dist/server/track-route.js +49 -0
  73. package/dist/store/fetch.d.ts +22 -0
  74. package/dist/store/fetch.js +39 -0
  75. package/dist/store/memory.d.ts +30 -0
  76. package/dist/store/memory.js +68 -0
  77. package/dist/store/runtime.d.ts +30 -0
  78. package/dist/store/runtime.js +69 -0
  79. package/migrations/0001_init.sql +56 -0
  80. package/package.json +72 -3
@@ -0,0 +1,29 @@
1
+ import { ComponentType, ReactElement } from "react";
2
+ import { Metadata } from "next";
3
+ /** Props Next passes to a page under `app/[evolve]/`. */
4
+ export type EvolvePageProps = {
5
+ params: Promise<{
6
+ evolve: string;
7
+ } & Record<string, string | string[] | undefined>>;
8
+ searchParams?: Promise<Record<string, string | string[] | undefined>>;
9
+ };
10
+ /**
11
+ * Wraps a static-mode page under `app/[evolve]/<route>/page.tsx`. Decodes the
12
+ * variant code from `params.evolve` and makes it available to every
13
+ * `<Optimizable>` on the page before the body renders.
14
+ */
15
+ export declare function withEvolve<P extends object>(Page: ComponentType<P>, opts: {
16
+ path: string;
17
+ }): (props: P & EvolvePageProps) => Promise<ReactElement>;
18
+ /** `generateStaticParams` for `app/[evolve]/`: prerenders control only, so builds need no network. */
19
+ export declare function evolveStaticParams(): {
20
+ evolve: string;
21
+ }[];
22
+ type MetadataInput = Metadata | ((props: EvolvePageProps) => Metadata | Promise<Metadata>);
23
+ /**
24
+ * `generateMetadata` for a static-mode page: canonical is the original path
25
+ * for every variant, and variant renders are `noindex` (bots only ever get
26
+ * control anyway).
27
+ */
28
+ export declare function evolveMetadata(path: string, base?: MetadataInput): (props: EvolvePageProps) => Promise<Metadata>;
29
+ export {};
@@ -0,0 +1,57 @@
1
+ import { normalizePath } from "../config/config.js";
2
+ import { requestContext } from "./context.js";
3
+ import { CODE_RE, CONTROL_CODE } from "@next-evolve/protocol";
4
+ import { createElement } from "react";
5
+ import { notFound } from "next/navigation";
6
+ // src/react/with-evolve.ts
7
+ /** Validates the `[evolve]` segment; malformed codes are a 404. */
8
+ async function readCode(params) {
9
+ const { evolve } = await params;
10
+ if (typeof evolve !== "string" || !CODE_RE.test(evolve)) notFound();
11
+ return evolve;
12
+ }
13
+ /**
14
+ * Wraps a static-mode page under `app/[evolve]/<route>/page.tsx`. Decodes the
15
+ * variant code from `params.evolve` and makes it available to every
16
+ * `<Optimizable>` on the page before the body renders.
17
+ */
18
+ function withEvolve(Page, opts) {
19
+ const path = normalizePath(opts.path);
20
+ async function EvolvePage(props) {
21
+ const code = await readCode(props.params);
22
+ const ctx = requestContext();
23
+ ctx.code = code;
24
+ ctx.path = path;
25
+ return createElement(Page, props);
26
+ }
27
+ Object.defineProperty(EvolvePage, "name", { value: `withEvolve(${Page.displayName ?? Page.name ?? "Page"})` });
28
+ return EvolvePage;
29
+ }
30
+ /** `generateStaticParams` for `app/[evolve]/`: prerenders control only, so builds need no network. */
31
+ function evolveStaticParams() {
32
+ return [{ evolve: CONTROL_CODE }];
33
+ }
34
+ /**
35
+ * `generateMetadata` for a static-mode page: canonical is the original path
36
+ * for every variant, and variant renders are `noindex` (bots only ever get
37
+ * control anyway).
38
+ */
39
+ function evolveMetadata(path, base = {}) {
40
+ const canonical = normalizePath(path);
41
+ return async function generateMetadata(props) {
42
+ const code = await readCode(props.params);
43
+ const m = typeof base === "function" ? await base(props) : base;
44
+ return {
45
+ ...m,
46
+ alternates: {
47
+ ...m.alternates,
48
+ canonical
49
+ },
50
+ ...code === CONTROL_CODE ? {} : { robots: {
51
+ index: false,
52
+ follow: true
53
+ } }
54
+ };
55
+ };
56
+ }
57
+ export { evolveMetadata, evolveStaticParams, withEvolve };
@@ -0,0 +1,46 @@
1
+ import { ResolvedConfig } from "../config/config.js";
2
+ import { Goals } from "../config/goals.js";
3
+ import { Writer } from "../db/writer.js";
4
+ import { BundlePayload } from "@next-evolve/protocol";
5
+ import { z } from "zod";
6
+ export declare const MAX_BATCH_BYTES: number;
7
+ export declare const MAX_BATCH_EVENTS = 50;
8
+ declare const beaconEvent: z.ZodDiscriminatedUnion<[z.ZodObject<{
9
+ t: z.ZodLiteral<"pv">;
10
+ p: z.ZodString;
11
+ }, z.core.$strip>, z.ZodObject<{
12
+ t: z.ZodLiteral<"exp">;
13
+ e: z.ZodString;
14
+ v: z.ZodString;
15
+ p: z.ZodString;
16
+ }, z.core.$strip>, z.ZodObject<{
17
+ t: z.ZodLiteral<"ev">;
18
+ n: z.ZodString;
19
+ p: z.ZodOptional<z.ZodString>;
20
+ props: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
21
+ }, z.core.$strip>, z.ZodObject<{
22
+ t: z.ZodLiteral<"id">;
23
+ u: z.ZodString;
24
+ }, z.core.$strip>], "t">;
25
+ /** Beacon payload (spec §8.1). Unknown event types are skipped, not fatal. */
26
+ export declare const collectSchema: z.ZodObject<{
27
+ v: z.ZodLiteral<1>;
28
+ events: z.ZodArray<z.ZodUnknown>;
29
+ }, z.core.$strip>;
30
+ export type BeaconEvent = z.infer<typeof beaconEvent>;
31
+ export type CollectDeps = {
32
+ config: ResolvedConfig;
33
+ goals: Goals | undefined;
34
+ bundle: () => BundlePayload | null;
35
+ isBot: (ua: string | null) => boolean;
36
+ writer: Writer;
37
+ now?: () => Date;
38
+ };
39
+ /** Same-origin check: `Origin` must match the host; without it, `Sec-Fetch-Site` must say so. */
40
+ export declare function isSameOrigin(req: Request): boolean;
41
+ /**
42
+ * `POST /api/evolve/collect`. Validates synchronously and queues rows; the
43
+ * caller flushes after the response. Returns the status code to send.
44
+ */
45
+ export declare function handleCollect(req: Request, deps: CollectDeps): Promise<number>;
46
+ export {};
@@ -0,0 +1,131 @@
1
+ import { normalizePath } from "../config/config.js";
2
+ import { matchesPageviewGoal } from "../config/goals.js";
3
+ import { ANON_COOKIE } from "../core/cookies.js";
4
+ import { eventRow, utcDay } from "./events.js";
5
+ import { ANON_ID_RE, ID_RE } from "@next-evolve/protocol";
6
+ import { z } from "zod";
7
+ // src/server/collect.ts
8
+ const MAX_BATCH_BYTES = 32768;
9
+ const MAX_BATCH_EVENTS = 50;
10
+ const path = z.string().min(1).max(512).startsWith("/");
11
+ const beaconEvent = z.discriminatedUnion("t", [
12
+ z.object({
13
+ t: z.literal("pv"),
14
+ p: path
15
+ }),
16
+ z.object({
17
+ t: z.literal("exp"),
18
+ e: z.string().regex(ID_RE),
19
+ v: z.string().regex(ID_RE),
20
+ p: path
21
+ }),
22
+ z.object({
23
+ t: z.literal("ev"),
24
+ n: z.string().min(1).max(100),
25
+ p: path.optional(),
26
+ props: z.record(z.string(), z.unknown()).optional()
27
+ }),
28
+ z.object({
29
+ t: z.literal("id"),
30
+ u: z.string().min(1).max(256)
31
+ })
32
+ ]);
33
+ /** Beacon payload (spec §8.1). Unknown event types are skipped, not fatal. */
34
+ const collectSchema = z.object({
35
+ v: z.literal(1),
36
+ events: z.array(z.unknown()).max(50)
37
+ });
38
+ /** Same-origin check: `Origin` must match the host; without it, `Sec-Fetch-Site` must say so. */
39
+ function isSameOrigin(req) {
40
+ const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
41
+ const origin = req.headers.get("origin");
42
+ if (origin) try {
43
+ return new URL(origin).host === (host ?? new URL(req.url).host);
44
+ } catch {
45
+ return false;
46
+ }
47
+ return req.headers.get("sec-fetch-site") === "same-origin";
48
+ }
49
+ function cookie(req, name) {
50
+ const raw = req.headers.get("cookie");
51
+ if (!raw) return void 0;
52
+ for (const part of raw.split(";")) {
53
+ const i = part.indexOf("=");
54
+ if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim());
55
+ }
56
+ }
57
+ /**
58
+ * `POST /api/evolve/collect`. Validates synchronously and queues rows; the
59
+ * caller flushes after the response. Returns the status code to send.
60
+ */
61
+ async function handleCollect(req, deps) {
62
+ if (Number(req.headers.get("content-length") ?? 0) > 32768) return 413;
63
+ if (!isSameOrigin(req)) return 403;
64
+ const text = await req.text();
65
+ if (text.length > 32768) return 413;
66
+ let parsed;
67
+ try {
68
+ const r = collectSchema.safeParse(JSON.parse(text));
69
+ if (!r.success) return 400;
70
+ parsed = r.data;
71
+ } catch {
72
+ return 400;
73
+ }
74
+ const anon = cookie(req, ANON_COOKIE);
75
+ if (deps.isBot(req.headers.get("user-agent"))) return 204;
76
+ if (cookie(req, "evolve_pv")) return 204;
77
+ if (!anon || !ANON_ID_RE.test(anon)) return 204;
78
+ if (deps.config.consent?.(req) === false) return 204;
79
+ const events = parsed.events.flatMap((e) => {
80
+ const r = beaconEvent.safeParse(e);
81
+ return r.success ? [r.data] : [];
82
+ });
83
+ const now = deps.now?.() ?? /* @__PURE__ */ new Date();
84
+ const bundle = deps.bundle();
85
+ const exposures = [];
86
+ const rows = [];
87
+ let testedPageviews = 0;
88
+ for (const e of events) switch (e.t) {
89
+ case "exp": {
90
+ const v = bundle?.variants[e.v];
91
+ const exp = bundle?.routing.experiments[e.e];
92
+ if (v && exp && v.exp === e.e && exp.variants.some((x) => x.id === e.v)) exposures.push({
93
+ experiment_id: e.e,
94
+ anon_id: anon,
95
+ variant_id: e.v,
96
+ path: normalizePath(e.p)
97
+ });
98
+ break;
99
+ }
100
+ case "pv": {
101
+ const p = normalizePath(e.p);
102
+ if ((bundle?.routing.routes[p] ?? []).some((id) => bundle?.routing.experiments[id]?.status === "running") && deps.config.routes.has(p)) testedPageviews++;
103
+ if (matchesPageviewGoal(deps.goals, p)) {
104
+ const row = eventRow("$pageview", {
105
+ anonId: anon,
106
+ path: p
107
+ }, void 0, "browser", now);
108
+ if (row) rows.push(row);
109
+ }
110
+ break;
111
+ }
112
+ case "ev": {
113
+ const row = eventRow(e.n, {
114
+ anonId: anon,
115
+ props: e.props ?? null,
116
+ path: e.p ? normalizePath(e.p) : null
117
+ }, deps.goals, "browser", now);
118
+ if (row) rows.push(row);
119
+ break;
120
+ }
121
+ case "id": deps.writer.identities([{
122
+ anon_id: anon,
123
+ user_id: e.u
124
+ }]);
125
+ }
126
+ deps.writer.exposures(exposures);
127
+ deps.writer.events(rows);
128
+ deps.writer.usage(utcDay(now), testedPageviews);
129
+ return 204;
130
+ }
131
+ export { MAX_BATCH_BYTES, MAX_BATCH_EVENTS, collectSchema, handleCollect, isSameOrigin };
@@ -0,0 +1,18 @@
1
+ import { Goals } from "../config/goals.js";
2
+ import { EventRow } from "../db/writer.js";
3
+ /** Props larger than this (as JSON) are dropped from the event. */
4
+ export declare const MAX_PROPS_BYTES = 2048;
5
+ export type TrackInput = {
6
+ userId?: string | null;
7
+ anonId?: string | null;
8
+ value?: number | null;
9
+ props?: Record<string, unknown> | null;
10
+ source?: string;
11
+ ts?: Date | string | number;
12
+ dedupeKey?: string | null;
13
+ path?: string | null;
14
+ };
15
+ /** Normalizes one event and runs goal `value` functions (locally, at write time). */
16
+ export declare function eventRow(name: string, input: TrackInput, goals: Goals | undefined, defaultSource: string, now?: Date): EventRow | null;
17
+ /** `YYYY-MM-DD` in UTC. */
18
+ export declare const utcDay: (d?: Date) => string;
@@ -0,0 +1,50 @@
1
+ import { computeGoalValues } from "../config/goals.js";
2
+ // src/server/events.ts
3
+ /** Props larger than this (as JSON) are dropped from the event. */
4
+ const MAX_PROPS_BYTES = 2048;
5
+ const clean = (s, max) => typeof s === "string" && s.length > 0 ? s.slice(0, max) : null;
6
+ function capProps(p) {
7
+ if (!p || typeof p !== "object" || Array.isArray(p)) return null;
8
+ try {
9
+ return JSON.stringify(p).length <= 2048 ? p : null;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ function toDate(ts, now) {
15
+ if (ts === void 0) return now;
16
+ const d = ts instanceof Date ? ts : new Date(ts);
17
+ return Number.isNaN(d.getTime()) ? now : d;
18
+ }
19
+ /** Normalizes one event and runs goal `value` functions (locally, at write time). */
20
+ function eventRow(name, input, goals, defaultSource, now = /* @__PURE__ */ new Date()) {
21
+ const n = clean(name, 100);
22
+ if (!n) return null;
23
+ const props = capProps(input.props);
24
+ const value = typeof input.value === "number" && Number.isFinite(input.value) ? input.value : null;
25
+ const source = clean(input.source, 64) ?? defaultSource;
26
+ const userId = clean(input.userId, 256);
27
+ const anonId = clean(input.anonId, 64);
28
+ return {
29
+ ts: toDate(input.ts, now),
30
+ name: n,
31
+ anon_id: anonId,
32
+ user_id: userId,
33
+ value,
34
+ goal_values: computeGoalValues(goals, {
35
+ name: n,
36
+ props: props ?? {},
37
+ value,
38
+ source,
39
+ userId,
40
+ anonId
41
+ }),
42
+ props,
43
+ source,
44
+ path: clean(input.path, 512),
45
+ dedupe_key: clean(input.dedupeKey, 200)
46
+ };
47
+ }
48
+ /** `YYYY-MM-DD` in UTC. */
49
+ const utcDay = (d = /* @__PURE__ */ new Date()) => d.toISOString().slice(0, 10);
50
+ export { MAX_PROPS_BYTES, eventRow, utcDay };
@@ -0,0 +1,20 @@
1
+ import { TrackInput } from "./events.js";
2
+ export type TrackOptions = Omit<TrackInput, "path">;
3
+ /**
4
+ * Records a server-side event in the customer DB. Never throws. Without
5
+ * `anonId` it uses the visitor's `evolve_id` cookie when called in a request.
6
+ */
7
+ declare function track(name: string, opts?: TrackOptions): Promise<void>;
8
+ /**
9
+ * Links a visitor (the `evolve_id` cookie, or `anonId`) to your user id, so
10
+ * events on other devices or from your server count for their experiments.
11
+ * The first link for a visitor wins. Never throws.
12
+ */
13
+ declare function identify(userId: string, opts?: {
14
+ anonId?: string;
15
+ }): Promise<void>;
16
+ export declare const evolve: {
17
+ track: typeof track;
18
+ identify: typeof identify;
19
+ };
20
+ export {};
@@ -0,0 +1,57 @@
1
+ import "../core/cookies.js";
2
+ import { logRateLimited } from "../core/log.js";
3
+ import { eventRow } from "./events.js";
4
+ import { getRuntime, runtimeWriter, scheduleFlush } from "./runtime.js";
5
+ // src/server/evolve.ts
6
+ async function cookieAnonId() {
7
+ try {
8
+ const { cookies } = await import("next/headers");
9
+ return (await cookies()).get("evolve_id")?.value ?? null;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ /**
15
+ * Records a server-side event in the customer DB. Never throws. Without
16
+ * `anonId` it uses the visitor's `evolve_id` cookie when called in a request.
17
+ */
18
+ async function track(name, opts = {}) {
19
+ try {
20
+ const rt = getRuntime();
21
+ const anonId = opts.anonId !== void 0 ? opts.anonId : await cookieAnonId();
22
+ const row = eventRow(name, {
23
+ ...opts,
24
+ anonId
25
+ }, rt.goals, "server");
26
+ if (!row) return;
27
+ const w = runtimeWriter(rt);
28
+ w.events([row]);
29
+ await scheduleFlush(w);
30
+ } catch (e) {
31
+ logRateLimited("track", "evolve.track failed", e);
32
+ }
33
+ }
34
+ /**
35
+ * Links a visitor (the `evolve_id` cookie, or `anonId`) to your user id, so
36
+ * events on other devices or from your server count for their experiments.
37
+ * The first link for a visitor wins. Never throws.
38
+ */
39
+ async function identify(userId, opts = {}) {
40
+ try {
41
+ const anonId = opts.anonId ?? await cookieAnonId();
42
+ if (!anonId || !userId) return;
43
+ const w = runtimeWriter();
44
+ w.identities([{
45
+ anon_id: anonId.slice(0, 64),
46
+ user_id: String(userId).slice(0, 256)
47
+ }]);
48
+ await scheduleFlush(w);
49
+ } catch (e) {
50
+ logRateLimited("identify", "evolve.identify failed", e);
51
+ }
52
+ }
53
+ const evolve = {
54
+ track,
55
+ identify
56
+ };
57
+ export { evolve };
@@ -0,0 +1,16 @@
1
+ import { EvolveConfig } from "../config/config.js";
2
+ import { Goals } from "../config/goals.js";
3
+ export type EvolveHandlerOptions = {
4
+ config: EvolveConfig;
5
+ goals?: Goals;
6
+ env?: Record<string, string | undefined>;
7
+ };
8
+ /**
9
+ * Route handlers for `app/api/evolve/[...evolve]/route.ts`:
10
+ * `POST collect` (beacon), `POST track` (bearer webhook), `GET health`.
11
+ * Sync and aggregate arrive with M3/M4.
12
+ */
13
+ export declare function createEvolveHandler(opts: EvolveHandlerOptions): {
14
+ GET: (req: Request) => Promise<Response>;
15
+ POST: (req: Request) => Promise<Response>;
16
+ };
@@ -0,0 +1,72 @@
1
+ import { resolveConfig } from "../config/config.js";
2
+ import { serializeGoals } from "../config/goals.js";
3
+ import { createBotDetector } from "../core/bots.js";
4
+ import { logRateLimited } from "../core/log.js";
5
+ import { getBundleStore } from "../store/runtime.js";
6
+ import { handleCollect } from "./collect.js";
7
+ import { runtimeWriter, scheduleFlush, setRuntime } from "./runtime.js";
8
+ import { handleTrack } from "./track-route.js";
9
+ import { SUPPORTED_PROTOCOLS } from "@next-evolve/protocol";
10
+ // src/server/handler.ts
11
+ const notFound = () => Response.json({ error: "not_found" }, { status: 404 });
12
+ /**
13
+ * Route handlers for `app/api/evolve/[...evolve]/route.ts`:
14
+ * `POST collect` (beacon), `POST track` (bearer webhook), `GET health`.
15
+ * Sync and aggregate arrive with M3/M4.
16
+ */
17
+ function createEvolveHandler(opts) {
18
+ const cfg = resolveConfig(opts.config);
19
+ if (opts.goals) serializeGoals(opts.goals);
20
+ setRuntime({
21
+ config: opts.config,
22
+ goals: opts.goals,
23
+ ...opts.env ? { env: opts.env } : {}
24
+ });
25
+ const isBot = createBotDetector(cfg.botPatterns);
26
+ const env = () => opts.env ?? process.env;
27
+ const action = (req) => {
28
+ const parts = new URL(req.url).pathname.split("/").filter(Boolean);
29
+ return parts[parts.length - 1] ?? "";
30
+ };
31
+ async function post(req) {
32
+ try {
33
+ switch (action(req)) {
34
+ case "collect": {
35
+ const writer = runtimeWriter();
36
+ const store = getBundleStore(cfg, { env: env() });
37
+ const status = await handleCollect(req, {
38
+ config: cfg,
39
+ goals: opts.goals,
40
+ bundle: () => store?.peek() ?? null,
41
+ isBot,
42
+ writer
43
+ });
44
+ if (status === 204) await scheduleFlush(writer);
45
+ return new Response(null, { status });
46
+ }
47
+ case "track": return await handleTrack(req, {
48
+ secret: env().EVOLVE_TRACK_SECRET,
49
+ goals: opts.goals,
50
+ writer: runtimeWriter()
51
+ });
52
+ default: return notFound();
53
+ }
54
+ } catch (e) {
55
+ logRateLimited("handler", "evolve route failed", e);
56
+ return Response.json({ error: "internal" }, { status: 500 });
57
+ }
58
+ }
59
+ async function get(req) {
60
+ if (action(req) !== "health") return notFound();
61
+ return Response.json({
62
+ ok: true,
63
+ protocol: { supported: SUPPORTED_PROTOCOLS },
64
+ db: Boolean(env()[cfg.databaseUrlEnv])
65
+ });
66
+ }
67
+ return {
68
+ GET: get,
69
+ POST: post
70
+ };
71
+ }
72
+ export { createEvolveHandler };
@@ -0,0 +1,4 @@
1
+ import { TrackOptions, evolve } from "./evolve.js";
2
+ import { EvolveHandlerOptions, createEvolveHandler } from "./handler.js";
3
+ import "server-only";
4
+ export { type EvolveHandlerOptions, type TrackOptions, createEvolveHandler, evolve };
@@ -0,0 +1,4 @@
1
+ import { evolve } from "./evolve.js";
2
+ import { createEvolveHandler } from "./handler.js";
3
+ import "server-only";
4
+ export { createEvolveHandler, evolve };
@@ -0,0 +1,26 @@
1
+ import { EvolveConfig } from "../config/config.js";
2
+ import { Goals } from "../config/goals.js";
3
+ import { Writer } from "../db/writer.js";
4
+ type Env = Record<string, string | undefined>;
5
+ export type EvolveRuntime = {
6
+ config?: EvolveConfig;
7
+ goals?: Goals;
8
+ /** Inject a writer (tests). */
9
+ writer?: Writer;
10
+ env?: Env;
11
+ };
12
+ /**
13
+ * Makes the app's config and goals known to `evolve.track()` and friends.
14
+ * Called by `createEvolveHandler()` and `registerEvolve()`.
15
+ */
16
+ export declare function setRuntime(r: EvolveRuntime): void;
17
+ export declare function getRuntime(): EvolveRuntime;
18
+ /** Test helper. */
19
+ export declare function resetRuntime(): void;
20
+ export declare function runtimeWriter(r?: EvolveRuntime): Writer;
21
+ /**
22
+ * Flushes after the response on serverless hosts (`after()`), or right away
23
+ * outside a request. Long-running Node flushes on its own timer.
24
+ */
25
+ export declare function scheduleFlush(writer: Writer): Promise<void>;
26
+ export {};
@@ -0,0 +1,45 @@
1
+ import { resolveConfig } from "../config/config.js";
2
+ import { getSql } from "../db/client.js";
3
+ import { getWriter } from "../db/writer.js";
4
+ // src/server/runtime.ts
5
+ const KEY = Symbol.for("next-evolve.runtime");
6
+ const state = () => {
7
+ const g = globalThis;
8
+ g[KEY] ??= {};
9
+ return g[KEY];
10
+ };
11
+ /**
12
+ * Makes the app's config and goals known to `evolve.track()` and friends.
13
+ * Called by `createEvolveHandler()` and `registerEvolve()`.
14
+ */
15
+ function setRuntime(r) {
16
+ Object.assign(state(), r);
17
+ }
18
+ function getRuntime() {
19
+ return state();
20
+ }
21
+ /** Test helper. */
22
+ function resetRuntime() {
23
+ const s = state();
24
+ for (const k of Object.keys(s)) delete s[k];
25
+ }
26
+ function runtimeWriter(r = state()) {
27
+ if (r.writer) return r.writer;
28
+ const urlEnv = r.config ? resolveConfig(r.config).databaseUrlEnv : "EVOLVE_DATABASE_URL";
29
+ const env = r.env ?? process.env;
30
+ return getWriter(() => getSql(urlEnv, env), urlEnv);
31
+ }
32
+ /**
33
+ * Flushes after the response on serverless hosts (`after()`), or right away
34
+ * outside a request. Long-running Node flushes on its own timer.
35
+ */
36
+ async function scheduleFlush(writer) {
37
+ if (writer.mode === "node") return;
38
+ try {
39
+ const { after } = await import("next/server");
40
+ after(() => writer.flush());
41
+ } catch {
42
+ await writer.flush();
43
+ }
44
+ }
45
+ export { getRuntime, resetRuntime, runtimeWriter, scheduleFlush, setRuntime };
@@ -0,0 +1,25 @@
1
+ import { Goals } from "../config/goals.js";
2
+ import { Writer } from "../db/writer.js";
3
+ import { z } from "zod";
4
+ export declare const MAX_TRACK_EVENTS = 500;
5
+ export declare const trackBodySchema: z.ZodObject<{
6
+ events: z.ZodArray<z.ZodObject<{
7
+ name: z.ZodString;
8
+ userId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
9
+ anonId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
10
+ value: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
11
+ props: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
12
+ source: z.ZodOptional<z.ZodString>;
13
+ ts: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
14
+ dedupeKey: z.ZodOptional<z.ZodNullable<z.ZodString>>;
15
+ }, z.core.$strip>>;
16
+ }, z.core.$strip>;
17
+ /**
18
+ * `POST /api/evolve/track`: server-to-server events (Segment, a CRM, a
19
+ * webhook relay) with `Authorization: Bearer $EVOLVE_TRACK_SECRET`.
20
+ */
21
+ export declare function handleTrack(req: Request, deps: {
22
+ secret: string | undefined;
23
+ goals: Goals | undefined;
24
+ writer: Writer;
25
+ }): Promise<Response>;
@@ -0,0 +1,49 @@
1
+ import { eventRow } from "./events.js";
2
+ import { z } from "zod";
3
+ import { timingSafeEqual } from "node:crypto";
4
+ // src/server/track-route.ts
5
+ const MAX_TRACK_EVENTS = 500;
6
+ const trackEvent = z.object({
7
+ name: z.string().min(1).max(100),
8
+ userId: z.string().max(256).nullish(),
9
+ anonId: z.string().max(64).nullish(),
10
+ value: z.number().finite().nullish(),
11
+ props: z.record(z.string(), z.unknown()).nullish(),
12
+ source: z.string().max(64).optional(),
13
+ ts: z.union([z.string(), z.number()]).optional(),
14
+ dedupeKey: z.string().max(200).nullish()
15
+ });
16
+ const trackBodySchema = z.object({ events: z.array(trackEvent).min(1).max(500) });
17
+ function bearerOk(req, secret) {
18
+ const h = req.headers.get("authorization") ?? "";
19
+ const got = Buffer.from(h.startsWith("Bearer ") ? h.slice(7) : "");
20
+ const want = Buffer.from(secret);
21
+ return got.length === want.length && timingSafeEqual(got, want);
22
+ }
23
+ /**
24
+ * `POST /api/evolve/track`: server-to-server events (Segment, a CRM, a
25
+ * webhook relay) with `Authorization: Bearer $EVOLVE_TRACK_SECRET`.
26
+ */
27
+ async function handleTrack(req, deps) {
28
+ if (!deps.secret) return Response.json({ error: "track_disabled" }, { status: 404 });
29
+ if (!bearerOk(req, deps.secret)) return Response.json({ error: "unauthorized" }, { status: 401 });
30
+ let body;
31
+ try {
32
+ const r = trackBodySchema.safeParse(await req.json());
33
+ if (!r.success) return Response.json({
34
+ error: "invalid_body",
35
+ issues: r.error.issues.slice(0, 5)
36
+ }, { status: 400 });
37
+ body = r.data;
38
+ } catch {
39
+ return Response.json({ error: "invalid_json" }, { status: 400 });
40
+ }
41
+ const rows = body.events.flatMap((e) => {
42
+ const row = eventRow(e.name, e, deps.goals, "webhook");
43
+ return row ? [row] : [];
44
+ });
45
+ deps.writer.events(rows);
46
+ await deps.writer.flush();
47
+ return Response.json({ accepted: rows.length });
48
+ }
49
+ export { MAX_TRACK_EVENTS, handleTrack, trackBodySchema };