next-evolve 0.0.0-stage → 0.3.1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -2
  3. package/dist/_virtual/_rolldown/runtime.js +12 -0
  4. package/dist/aggregate/attribution.d.ts +40 -0
  5. package/dist/aggregate/attribution.js +115 -0
  6. package/dist/aggregate/run.d.ts +21 -0
  7. package/dist/aggregate/run.js +139 -0
  8. package/dist/cli/aggregate.d.ts +9 -0
  9. package/dist/cli/aggregate.js +104 -0
  10. package/dist/cli/args.d.ts +12 -0
  11. package/dist/cli/args.js +23 -0
  12. package/dist/cli/cloud.d.ts +41 -0
  13. package/dist/cli/cloud.js +74 -0
  14. package/dist/cli/connect.d.ts +8 -0
  15. package/dist/cli/connect.js +46 -0
  16. package/dist/cli/detect.d.ts +26 -0
  17. package/dist/cli/detect.js +97 -0
  18. package/dist/cli/doctor.d.ts +34 -0
  19. package/dist/cli/doctor.js +230 -0
  20. package/dist/cli/env-file.d.ts +4 -0
  21. package/dist/cli/env-file.js +15 -0
  22. package/dist/cli/files.d.ts +25 -0
  23. package/dist/cli/files.js +173 -0
  24. package/dist/cli/index.d.ts +1 -0
  25. package/dist/cli/index.js +81 -0
  26. package/dist/cli/init.d.ts +26 -0
  27. package/dist/cli/init.js +240 -0
  28. package/dist/cli/load-app.d.ts +19 -0
  29. package/dist/cli/load-app.js +67 -0
  30. package/dist/cli/migrate.d.ts +7 -0
  31. package/dist/cli/migrate.js +30 -0
  32. package/dist/cli/pages.d.ts +17 -0
  33. package/dist/cli/pages.js +304 -0
  34. package/dist/cli/plan.d.ts +27 -0
  35. package/dist/cli/plan.js +129 -0
  36. package/dist/cli/pull.d.ts +13 -0
  37. package/dist/cli/pull.js +62 -0
  38. package/dist/cli/push.d.ts +14 -0
  39. package/dist/cli/push.js +33 -0
  40. package/dist/client/beacon.d.ts +11 -0
  41. package/dist/client/beacon.js +51 -0
  42. package/dist/client/evolve-script.d.ts +6 -0
  43. package/dist/client/evolve-script.js +60 -0
  44. package/dist/client/index.d.ts +3 -0
  45. package/dist/client/index.js +3 -0
  46. package/dist/cloud/manifest.d.ts +14 -0
  47. package/dist/cloud/manifest.js +46 -0
  48. package/dist/cloud/push.d.ts +21 -0
  49. package/dist/cloud/push.js +56 -0
  50. package/dist/cloud/regions.d.ts +8 -0
  51. package/dist/cloud/regions.js +25 -0
  52. package/dist/config/config.d.ts +51 -0
  53. package/dist/config/config.js +47 -0
  54. package/dist/config/goals.d.ts +50 -0
  55. package/dist/config/goals.js +85 -0
  56. package/dist/config/index.d.ts +3 -0
  57. package/dist/config/index.js +3 -0
  58. package/dist/core/assign.d.ts +35 -0
  59. package/dist/core/assign.js +104 -0
  60. package/dist/core/bots.d.ts +5 -0
  61. package/dist/core/bots.js +25 -0
  62. package/dist/core/constants.d.ts +3 -0
  63. package/dist/core/constants.js +5 -0
  64. package/dist/core/cookies.d.ts +12 -0
  65. package/dist/core/cookies.js +25 -0
  66. package/dist/core/keys.d.ts +14 -0
  67. package/dist/core/keys.js +38 -0
  68. package/dist/core/log.d.ts +2 -0
  69. package/dist/core/log.js +10 -0
  70. package/dist/db/client.d.ts +22 -0
  71. package/dist/db/client.js +50 -0
  72. package/dist/db/migrate.d.ts +18 -0
  73. package/dist/db/migrate.js +43 -0
  74. package/dist/db/writer.d.ts +53 -0
  75. package/dist/db/writer.js +182 -0
  76. package/dist/index.d.ts +5 -0
  77. package/dist/index.js +5 -0
  78. package/dist/instrumentation/index.d.ts +24 -0
  79. package/dist/instrumentation/index.js +54 -0
  80. package/dist/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/typescript.d.ts +11169 -0
  81. package/dist/proxy/index.d.ts +2 -0
  82. package/dist/proxy/index.js +1 -0
  83. package/dist/proxy/proxy.d.ts +24 -0
  84. package/dist/proxy/proxy.js +120 -0
  85. package/dist/react/context.d.ts +6 -0
  86. package/dist/react/context.js +8 -0
  87. package/dist/react/optimizable.d.ts +37 -0
  88. package/dist/react/optimizable.js +38 -0
  89. package/dist/react/region.d.ts +17 -0
  90. package/dist/react/region.js +127 -0
  91. package/dist/react/render-bundle.d.ts +15 -0
  92. package/dist/react/render-bundle.js +43 -0
  93. package/dist/react/with-evolve.d.ts +29 -0
  94. package/dist/react/with-evolve.js +57 -0
  95. package/dist/server/aggregate-route.d.ts +16 -0
  96. package/dist/server/aggregate-route.js +68 -0
  97. package/dist/server/collect.d.ts +62 -0
  98. package/dist/server/collect.js +157 -0
  99. package/dist/server/events.d.ts +18 -0
  100. package/dist/server/events.js +50 -0
  101. package/dist/server/evolve.d.ts +20 -0
  102. package/dist/server/evolve.js +57 -0
  103. package/dist/server/handler.d.ts +17 -0
  104. package/dist/server/handler.js +105 -0
  105. package/dist/server/index.d.ts +4 -0
  106. package/dist/server/index.js +4 -0
  107. package/dist/server/runtime.d.ts +26 -0
  108. package/dist/server/runtime.js +45 -0
  109. package/dist/server/sync-route.d.ts +28 -0
  110. package/dist/server/sync-route.js +100 -0
  111. package/dist/server/track-route.d.ts +25 -0
  112. package/dist/server/track-route.js +49 -0
  113. package/dist/store/disk.d.ts +6 -0
  114. package/dist/store/disk.js +23 -0
  115. package/dist/store/fetch.d.ts +26 -0
  116. package/dist/store/fetch.js +74 -0
  117. package/dist/store/global-config.d.ts +39 -0
  118. package/dist/store/global-config.js +122 -0
  119. package/dist/store/memory.d.ts +39 -0
  120. package/dist/store/memory.js +72 -0
  121. package/dist/store/runtime.d.ts +44 -0
  122. package/dist/store/runtime.js +101 -0
  123. package/migrations/0001_init.sql +56 -0
  124. package/package.json +73 -3
@@ -0,0 +1,62 @@
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
+ /**
36
+ * Fetches the bundle now (see {@link createBundleRefresher}). Called when a
37
+ * beacon names an experiment/variant the current bundle doesn't know, so a
38
+ * cold or stale instance doesn't drop an exposure the proxy just assigned.
39
+ */
40
+ refreshBundle?: () => Promise<void>;
41
+ isBot: (ua: string | null) => boolean;
42
+ writer: Writer;
43
+ now?: () => Date;
44
+ };
45
+ /**
46
+ * Wraps a store refresh for collect: at most one fetch per `minIntervalMs` per
47
+ * instance (forged beacons with made-up ids can't turn into a cloud fetch each),
48
+ * and never waits longer than `capMs`.
49
+ */
50
+ export declare function createBundleRefresher(refresh: () => Promise<void>, opts?: {
51
+ minIntervalMs?: number;
52
+ capMs?: number;
53
+ now?: () => number;
54
+ }): () => Promise<void>;
55
+ /** Same-origin check: `Origin` must match the host; without it, `Sec-Fetch-Site` must say so. */
56
+ export declare function isSameOrigin(req: Request): boolean;
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
+ export declare function handleCollect(req: Request, deps: CollectDeps): Promise<number>;
62
+ export {};
@@ -0,0 +1,157 @@
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
+ /**
39
+ * Wraps a store refresh for collect: at most one fetch per `minIntervalMs` per
40
+ * instance (forged beacons with made-up ids can't turn into a cloud fetch each),
41
+ * and never waits longer than `capMs`.
42
+ */
43
+ function createBundleRefresher(refresh, opts = {}) {
44
+ const minIntervalMs = opts.minIntervalMs ?? 5e3;
45
+ const capMs = opts.capMs ?? 2e3;
46
+ const now = opts.now ?? Date.now;
47
+ let last = -Infinity;
48
+ return async () => {
49
+ if (now() - last < minIntervalMs) return;
50
+ last = now();
51
+ let timer;
52
+ await Promise.race([refresh().catch(() => {}), new Promise((r) => {
53
+ timer = setTimeout(r, capMs);
54
+ })]);
55
+ clearTimeout(timer);
56
+ };
57
+ }
58
+ /** Same-origin check: `Origin` must match the host; without it, `Sec-Fetch-Site` must say so. */
59
+ function isSameOrigin(req) {
60
+ const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
61
+ const origin = req.headers.get("origin");
62
+ if (origin) try {
63
+ return new URL(origin).host === (host ?? new URL(req.url).host);
64
+ } catch {
65
+ return false;
66
+ }
67
+ return req.headers.get("sec-fetch-site") === "same-origin";
68
+ }
69
+ function cookie(req, name) {
70
+ const raw = req.headers.get("cookie");
71
+ if (!raw) return void 0;
72
+ for (const part of raw.split(";")) {
73
+ const i = part.indexOf("=");
74
+ if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim());
75
+ }
76
+ }
77
+ /**
78
+ * `POST /api/evolve/collect`. Validates synchronously and queues rows; the
79
+ * caller flushes after the response. Returns the status code to send.
80
+ */
81
+ async function handleCollect(req, deps) {
82
+ if (Number(req.headers.get("content-length") ?? 0) > 32768) return 413;
83
+ if (!isSameOrigin(req)) return 403;
84
+ const text = await req.text();
85
+ if (text.length > 32768) return 413;
86
+ let parsed;
87
+ try {
88
+ const r = collectSchema.safeParse(JSON.parse(text));
89
+ if (!r.success) return 400;
90
+ parsed = r.data;
91
+ } catch {
92
+ return 400;
93
+ }
94
+ const anon = cookie(req, ANON_COOKIE);
95
+ if (deps.isBot(req.headers.get("user-agent"))) return 204;
96
+ if (cookie(req, "evolve_pv")) return 204;
97
+ if (!anon || !ANON_ID_RE.test(anon)) return 204;
98
+ if (deps.config.consent?.(req) === false) return 204;
99
+ const events = parsed.events.flatMap((e) => {
100
+ const r = beaconEvent.safeParse(e);
101
+ return r.success ? [r.data] : [];
102
+ });
103
+ const now = deps.now?.() ?? /* @__PURE__ */ new Date();
104
+ let bundle = deps.bundle();
105
+ const known = (e, v) => {
106
+ const exp = bundle?.routing.experiments[e];
107
+ return bundle?.variants[v]?.exp === e && Boolean(exp?.variants.some((x) => x.id === v));
108
+ };
109
+ if (deps.refreshBundle && events.some((e) => e.t === "exp" && !known(e.e, e.v))) {
110
+ await deps.refreshBundle();
111
+ bundle = deps.bundle();
112
+ }
113
+ const exposures = [];
114
+ const rows = [];
115
+ let testedPageviews = 0;
116
+ for (const e of events) switch (e.t) {
117
+ case "exp":
118
+ if (known(e.e, e.v)) exposures.push({
119
+ experiment_id: e.e,
120
+ anon_id: anon,
121
+ variant_id: e.v,
122
+ path: normalizePath(e.p),
123
+ first_at: now
124
+ });
125
+ break;
126
+ case "pv": {
127
+ const p = normalizePath(e.p);
128
+ if ((bundle?.routing.routes[p] ?? []).some((id) => bundle?.routing.experiments[id]?.status === "running") && deps.config.routes.has(p)) testedPageviews++;
129
+ if (matchesPageviewGoal(deps.goals, p)) {
130
+ const row = eventRow("$pageview", {
131
+ anonId: anon,
132
+ path: p
133
+ }, void 0, "browser", now);
134
+ if (row) rows.push(row);
135
+ }
136
+ break;
137
+ }
138
+ case "ev": {
139
+ const row = eventRow(e.n, {
140
+ anonId: anon,
141
+ props: e.props ?? null,
142
+ path: e.p ? normalizePath(e.p) : null
143
+ }, deps.goals, "browser", now);
144
+ if (row) rows.push(row);
145
+ break;
146
+ }
147
+ case "id": deps.writer.identities([{
148
+ anon_id: anon,
149
+ user_id: e.u
150
+ }]);
151
+ }
152
+ deps.writer.exposures(exposures);
153
+ deps.writer.events(rows);
154
+ deps.writer.usage(utcDay(now), testedPageviews);
155
+ return 204;
156
+ }
157
+ export { MAX_BATCH_BYTES, MAX_BATCH_EVENTS, collectSchema, createBundleRefresher, 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,17 @@
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), `POST aggregate`
11
+ * (signed cloud request), `POST sync` (signed cloud webhook → pull the
12
+ * bundle), `GET health`.
13
+ */
14
+ export declare function createEvolveHandler(opts: EvolveHandlerOptions): {
15
+ GET: (req: Request) => Promise<Response>;
16
+ POST: (req: Request) => Promise<Response>;
17
+ };
@@ -0,0 +1,105 @@
1
+ import { sdkVersion } from "../cloud/manifest.js";
2
+ import { resolveConfig } from "../config/config.js";
3
+ import { serializeGoals } from "../config/goals.js";
4
+ import { createBotDetector } from "../core/bots.js";
5
+ import { logRateLimited } from "../core/log.js";
6
+ import { getSql } from "../db/client.js";
7
+ import { globalConfigSettings, writeGlobalConfig } from "../store/global-config.js";
8
+ import { getBundleStore } from "../store/runtime.js";
9
+ import { handleAggregate } from "./aggregate-route.js";
10
+ import { createBundleRefresher, handleCollect } from "./collect.js";
11
+ import { runtimeWriter, scheduleFlush, setRuntime } from "./runtime.js";
12
+ import { handleSync, revalidateBundle } from "./sync-route.js";
13
+ import { handleTrack } from "./track-route.js";
14
+ import { SUPPORTED_PROTOCOLS, parseSecretKey } from "@next-evolve/protocol";
15
+ // src/server/handler.ts
16
+ const refreshers = /* @__PURE__ */ new WeakMap();
17
+ /** One rate-limited refresher per store (collect, unknown exposures). */
18
+ function collectRefresher(store) {
19
+ let r = refreshers.get(store);
20
+ if (!r) {
21
+ r = createBundleRefresher(() => store.refresh());
22
+ refreshers.set(store, r);
23
+ }
24
+ return r;
25
+ }
26
+ const notFound = () => Response.json({ error: "not_found" }, { status: 404 });
27
+ /**
28
+ * Route handlers for `app/api/evolve/[...evolve]/route.ts`:
29
+ * `POST collect` (beacon), `POST track` (bearer webhook), `POST aggregate`
30
+ * (signed cloud request), `POST sync` (signed cloud webhook → pull the
31
+ * bundle), `GET health`.
32
+ */
33
+ function createEvolveHandler(opts) {
34
+ const cfg = resolveConfig(opts.config);
35
+ if (opts.goals) serializeGoals(opts.goals);
36
+ setRuntime({
37
+ config: opts.config,
38
+ goals: opts.goals,
39
+ ...opts.env ? { env: opts.env } : {}
40
+ });
41
+ const isBot = createBotDetector(cfg.botPatterns);
42
+ const env = () => opts.env ?? process.env;
43
+ const action = (req) => {
44
+ const parts = new URL(req.url).pathname.split("/").filter(Boolean);
45
+ return parts[parts.length - 1] ?? "";
46
+ };
47
+ async function post(req) {
48
+ try {
49
+ switch (action(req)) {
50
+ case "collect": {
51
+ const writer = runtimeWriter();
52
+ const store = getBundleStore(cfg, { env: env() });
53
+ const status = await handleCollect(req, {
54
+ config: cfg,
55
+ goals: opts.goals,
56
+ bundle: () => store?.peek() ?? null,
57
+ ...store ? { refreshBundle: collectRefresher(store) } : {},
58
+ isBot,
59
+ writer
60
+ });
61
+ if (status === 204) await scheduleFlush(writer);
62
+ return new Response(null, { status });
63
+ }
64
+ case "track": return await handleTrack(req, {
65
+ secret: env().EVOLVE_TRACK_SECRET,
66
+ goals: opts.goals,
67
+ writer: runtimeWriter()
68
+ });
69
+ case "sync": return await handleSync(req, {
70
+ env: env(),
71
+ store: () => getBundleStore(cfg, { env: env() }),
72
+ onSynced: async (token) => {
73
+ const gc = globalConfigSettings(cfg.store, env());
74
+ const site = parseSecretKey(env().EVOLVE_SECRET_KEY ?? "")?.siteId;
75
+ if (gc && site) await writeGlobalConfig(gc, site, token);
76
+ await revalidateBundle();
77
+ }
78
+ });
79
+ case "aggregate": return await handleAggregate(req, {
80
+ config: cfg,
81
+ env: env(),
82
+ sql: () => getSql(cfg.databaseUrlEnv, env()),
83
+ sdkVersion: sdkVersion()
84
+ });
85
+ default: return notFound();
86
+ }
87
+ } catch (e) {
88
+ logRateLimited("handler", "evolve route failed", e);
89
+ return Response.json({ error: "internal" }, { status: 500 });
90
+ }
91
+ }
92
+ async function get(req) {
93
+ if (action(req) !== "health") return notFound();
94
+ return Response.json({
95
+ ok: true,
96
+ protocol: { supported: SUPPORTED_PROTOCOLS },
97
+ db: Boolean(env()[cfg.databaseUrlEnv])
98
+ });
99
+ }
100
+ return {
101
+ GET: get,
102
+ POST: post
103
+ };
104
+ }
105
+ 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,28 @@
1
+ import { BundleStore } from "../store/memory.js";
2
+ import { ReplayGuard } from "@next-evolve/protocol";
3
+ type Env = Record<string, string | undefined>;
4
+ /** Cache tag on the render-side bundle fetch (spec §7.7). */
5
+ export declare const BUNDLE_TAG = "evolve-bundle";
6
+ /** At most one refresh per window. */
7
+ export declare const SYNC_MIN_INTERVAL_MS = 5000;
8
+ export type SyncThrottle = {
9
+ last: number;
10
+ waiting: Promise<void> | null;
11
+ };
12
+ export declare const createSyncThrottle: () => SyncThrottle;
13
+ export type SyncDeps = {
14
+ env: Env;
15
+ store: () => BundleStore | null;
16
+ /** Runs after the response (`after()` by default). */
17
+ defer?: (task: () => Promise<void>) => void;
18
+ /** Called with the new token once a refresh installed one (persist + revalidate). */
19
+ onSynced?: (token: string) => Promise<void>;
20
+ replay?: ReplayGuard;
21
+ throttle?: SyncThrottle;
22
+ now?: () => number;
23
+ sleep?: (ms: number) => Promise<void>;
24
+ };
25
+ export declare function handleSync(req: Request, deps: SyncDeps): Promise<Response>;
26
+ /** Drops cached render-side bundle fetches (and pages that used them). */
27
+ export declare function revalidateBundle(): Promise<void>;
28
+ export {};