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.
- package/LICENSE +21 -0
- package/README.md +82 -2
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +40 -0
- package/dist/client/beacon.d.ts +11 -0
- package/dist/client/beacon.js +51 -0
- package/dist/client/evolve-script.d.ts +6 -0
- package/dist/client/evolve-script.js +60 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +3 -0
- package/dist/cloud/manifest.d.ts +12 -0
- package/dist/cloud/manifest.js +42 -0
- package/dist/cloud/push.d.ts +21 -0
- package/dist/cloud/push.js +56 -0
- package/dist/cloud/regions.d.ts +8 -0
- package/dist/cloud/regions.js +25 -0
- package/dist/config/config.d.ts +51 -0
- package/dist/config/config.js +47 -0
- package/dist/config/goals.d.ts +50 -0
- package/dist/config/goals.js +85 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.js +3 -0
- package/dist/core/assign.d.ts +23 -0
- package/dist/core/assign.js +82 -0
- package/dist/core/bots.d.ts +5 -0
- package/dist/core/bots.js +25 -0
- package/dist/core/constants.d.ts +3 -0
- package/dist/core/constants.js +5 -0
- package/dist/core/cookies.d.ts +12 -0
- package/dist/core/cookies.js +25 -0
- package/dist/core/keys.d.ts +14 -0
- package/dist/core/keys.js +38 -0
- package/dist/core/log.d.ts +2 -0
- package/dist/core/log.js +10 -0
- package/dist/db/client.d.ts +22 -0
- package/dist/db/client.js +50 -0
- package/dist/db/migrate.d.ts +18 -0
- package/dist/db/migrate.js +43 -0
- package/dist/db/writer.d.ts +51 -0
- package/dist/db/writer.js +182 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/instrumentation/index.d.ts +23 -0
- package/dist/instrumentation/index.js +39 -0
- package/dist/proxy/index.d.ts +2 -0
- package/dist/proxy/index.js +1 -0
- package/dist/proxy/proxy.d.ts +18 -0
- package/dist/proxy/proxy.js +103 -0
- package/dist/react/context.d.ts +6 -0
- package/dist/react/context.js +8 -0
- package/dist/react/optimizable.d.ts +37 -0
- package/dist/react/optimizable.js +38 -0
- package/dist/react/region.d.ts +17 -0
- package/dist/react/region.js +127 -0
- package/dist/react/render-bundle.d.ts +9 -0
- package/dist/react/render-bundle.js +14 -0
- package/dist/react/with-evolve.d.ts +29 -0
- package/dist/react/with-evolve.js +57 -0
- package/dist/server/collect.d.ts +46 -0
- package/dist/server/collect.js +131 -0
- package/dist/server/events.d.ts +18 -0
- package/dist/server/events.js +50 -0
- package/dist/server/evolve.d.ts +20 -0
- package/dist/server/evolve.js +57 -0
- package/dist/server/handler.d.ts +16 -0
- package/dist/server/handler.js +72 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/runtime.d.ts +26 -0
- package/dist/server/runtime.js +45 -0
- package/dist/server/track-route.d.ts +25 -0
- package/dist/server/track-route.js +49 -0
- package/dist/store/fetch.d.ts +22 -0
- package/dist/store/fetch.js +39 -0
- package/dist/store/memory.d.ts +30 -0
- package/dist/store/memory.js +68 -0
- package/dist/store/runtime.d.ts +30 -0
- package/dist/store/runtime.js +69 -0
- package/migrations/0001_init.sql +56 -0
- package/package.json +72 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Velocinator
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,83 @@
|
|
|
1
|
-
#
|
|
1
|
+
# next-evolve
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Self-improving Next.js sites: server-side variants with no flicker, measured in your own Postgres. Needs an Evolve Cloud account.
|
|
4
|
+
|
|
5
|
+
> Alpha. The setup below is what `npx next-evolve init` will write (E11).
|
|
6
|
+
|
|
7
|
+
## Database
|
|
8
|
+
|
|
9
|
+
Raw events (exposures, goal events, identities) stay in **your** Postgres (≥ 14), in a separate `evolve` schema. Evolve Cloud only ever receives aggregate counts.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
EVOLVE_DATABASE_URL=postgres://… npx next-evolve migrate
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Use the pooled URL on serverless hosts (e.g. Neon's `-pooler` host).
|
|
16
|
+
|
|
17
|
+
## Goals
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// evolve.goals.ts
|
|
21
|
+
import { defineGoals } from "next-evolve/config";
|
|
22
|
+
|
|
23
|
+
export default defineGoals({
|
|
24
|
+
cta_click: { event: "cta_click", window: "1d" },
|
|
25
|
+
signup_page: { pageview: "/signup", window: "1d" },
|
|
26
|
+
paid: {
|
|
27
|
+
event: "subscription_started",
|
|
28
|
+
source: "stripe",
|
|
29
|
+
window: "30d",
|
|
30
|
+
value: (e) => e.props.amount as number,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Function values run in your app when the event is written; only `{ kind: "local-fn" }` is sent to the cloud.
|
|
36
|
+
|
|
37
|
+
## Routes and beacon
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// app/api/evolve/[...evolve]/route.ts
|
|
41
|
+
import { createEvolveHandler } from "next-evolve/server";
|
|
42
|
+
import evolveConfig from "@/evolve.config";
|
|
43
|
+
import goals from "@/evolve.goals";
|
|
44
|
+
|
|
45
|
+
export const { GET, POST } = createEvolveHandler({ config: evolveConfig, goals });
|
|
46
|
+
export const maxDuration = 60;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Put `<EvolveScript />` (from `next-evolve/client`) in the root layout. It sends page views, exposures and clicks on `[data-evolve-goal]` elements (forms: on submit) to `/api/evolve/collect` on your own origin.
|
|
50
|
+
|
|
51
|
+
## Tracking
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { evolve } from "next-evolve/server";
|
|
55
|
+
|
|
56
|
+
await evolve.track("project_created", { userId: user.id });
|
|
57
|
+
await evolve.identify(user.id); // links the visitor's evolve_id cookie; first link wins
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
In the browser: `import { track, identify } from "next-evolve/client"`.
|
|
61
|
+
|
|
62
|
+
### Server-to-server events
|
|
63
|
+
|
|
64
|
+
`POST /api/evolve/track` with `Authorization: Bearer $EVOLVE_TRACK_SECRET` and `{ "events": [{ "name", "userId"?, "anonId"?, "value"?, "props"?, "source"?, "ts"?, "dedupeKey"? }] }` (≤ 500 events). Unset secret → 404; wrong bearer → 401.
|
|
65
|
+
|
|
66
|
+
### Stripe recipe
|
|
67
|
+
|
|
68
|
+
Call `evolve.track` from your own Stripe webhook handler, after you verify the signature:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// app/api/stripe/route.ts
|
|
72
|
+
import { evolve } from "next-evolve/server";
|
|
73
|
+
|
|
74
|
+
if (event.type === "invoice.paid") {
|
|
75
|
+
const invoice = event.data.object;
|
|
76
|
+
await evolve.track("subscription_started", {
|
|
77
|
+
userId: invoice.metadata.userId,
|
|
78
|
+
source: "stripe",
|
|
79
|
+
props: { amount: invoice.amount_paid / 100 },
|
|
80
|
+
dedupeKey: event.id, // Stripe retries: stored once
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createSql } from "../db/client.js";
|
|
3
|
+
import { migrate } from "../db/migrate.js";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
// src/cli/index.ts
|
|
6
|
+
const HELP = `Usage: next-evolve <command>
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
migrate [--url <postgres-url>] Create or update the evolve schema in your database
|
|
10
|
+
(default: $EVOLVE_DATABASE_URL, read from .env.local / .env)
|
|
11
|
+
`;
|
|
12
|
+
function loadEnvFiles() {
|
|
13
|
+
for (const f of [".env.local", ".env"]) if (existsSync(f)) process.loadEnvFile(f);
|
|
14
|
+
}
|
|
15
|
+
async function runMigrate(args) {
|
|
16
|
+
loadEnvFiles();
|
|
17
|
+
const i = args.indexOf("--url");
|
|
18
|
+
const url = i >= 0 ? args[i + 1] : process.env.EVOLVE_DATABASE_URL;
|
|
19
|
+
if (!url) {
|
|
20
|
+
console.error("next-evolve migrate: set EVOLVE_DATABASE_URL or pass --url");
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
const sql = createSql(url, { max: 1 });
|
|
24
|
+
try {
|
|
25
|
+
const r = await migrate(sql);
|
|
26
|
+
console.warn(r.applied.length ? `Applied ${r.applied.join(", ")}` : `Up to date (${r.skipped.join(", ") || "nothing to apply"})`);
|
|
27
|
+
return 0;
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.error("next-evolve migrate failed:", e instanceof Error ? e.message : e);
|
|
30
|
+
return 1;
|
|
31
|
+
} finally {
|
|
32
|
+
await sql.end({ timeout: 5 });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
36
|
+
if (cmd === "migrate") process.exitCode = await runMigrate(rest);
|
|
37
|
+
else {
|
|
38
|
+
process.stdout.write(HELP);
|
|
39
|
+
process.exitCode = cmd && cmd !== "help" && cmd !== "--help" ? 1 : 0;
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type BeaconEvent = Record<string, unknown>;
|
|
2
|
+
export declare const COLLECT_URL = "/api/evolve/collect";
|
|
3
|
+
export declare function push(e: BeaconEvent): void;
|
|
4
|
+
export declare function flush(): void;
|
|
5
|
+
/** Records a browser event (e.g. `track("cta_click", { plan: "pro" })`). */
|
|
6
|
+
export declare function track(name: string, props?: Record<string, unknown>): void;
|
|
7
|
+
/** Links this visitor to your user id. Prefer `evolve.identify()` on the server. */
|
|
8
|
+
export declare function identify(userId: string): void;
|
|
9
|
+
/** Test helper. */
|
|
10
|
+
export declare function _reset(): void;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/client/beacon.ts
|
|
2
|
+
const COLLECT_URL = "/api/evolve/collect";
|
|
3
|
+
const MAX = 50;
|
|
4
|
+
let queue = [];
|
|
5
|
+
let timer;
|
|
6
|
+
/** Off on the server and under automation (`navigator.webdriver`). */
|
|
7
|
+
const off = () => typeof window === "undefined" || !!navigator.webdriver;
|
|
8
|
+
function push(e) {
|
|
9
|
+
if (off()) return;
|
|
10
|
+
queue.push(e);
|
|
11
|
+
if (queue.length >= MAX) flush();
|
|
12
|
+
else timer ??= setTimeout(flush, 2e3);
|
|
13
|
+
}
|
|
14
|
+
function flush() {
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
timer = void 0;
|
|
17
|
+
while (queue.length) {
|
|
18
|
+
const body = JSON.stringify({
|
|
19
|
+
v: 1,
|
|
20
|
+
events: queue.splice(0, MAX)
|
|
21
|
+
});
|
|
22
|
+
if (!navigator.sendBeacon?.("/api/evolve/collect", body)) fetch(COLLECT_URL, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
body,
|
|
25
|
+
keepalive: true
|
|
26
|
+
}).catch(() => {});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Records a browser event (e.g. `track("cta_click", { plan: "pro" })`). */
|
|
30
|
+
function track(name, props) {
|
|
31
|
+
push({
|
|
32
|
+
t: "ev",
|
|
33
|
+
n: name,
|
|
34
|
+
p: location.pathname,
|
|
35
|
+
...props ? { props } : {}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** Links this visitor to your user id. Prefer `evolve.identify()` on the server. */
|
|
39
|
+
function identify(userId) {
|
|
40
|
+
push({
|
|
41
|
+
t: "id",
|
|
42
|
+
u: String(userId)
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Test helper. */
|
|
46
|
+
function _reset() {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
timer = void 0;
|
|
49
|
+
queue = [];
|
|
50
|
+
}
|
|
51
|
+
export { COLLECT_URL, _reset, flush, identify, push, track };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evolve beacon. Place once in the root layout. Not needed to show a variant.
|
|
3
|
+
* Queues a page view and one exposure per counted region on every route, and
|
|
4
|
+
* clicks/submits on `[data-evolve-goal]`; flushes every 2 s and on page hide.
|
|
5
|
+
*/
|
|
6
|
+
export declare function EvolveScript(): null;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { flush, push } from "./beacon.js";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
// src/client/evolve-script.tsx
|
|
6
|
+
/**
|
|
7
|
+
* Evolve beacon. Place once in the root layout. Not needed to show a variant.
|
|
8
|
+
* Queues a page view and one exposure per counted region on every route, and
|
|
9
|
+
* clicks/submits on `[data-evolve-goal]`; flushes every 2 s and on page hide.
|
|
10
|
+
*/
|
|
11
|
+
function EvolveScript() {
|
|
12
|
+
const path = usePathname();
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
document.documentElement.dataset.evolve = "ready";
|
|
15
|
+
const onGoal = (ev) => {
|
|
16
|
+
const el = ev.target?.closest?.("[data-evolve-goal]");
|
|
17
|
+
const n = el?.getAttribute("data-evolve-goal");
|
|
18
|
+
if (!n || ev.type === "submit" !== (el?.tagName === "FORM")) return;
|
|
19
|
+
push({
|
|
20
|
+
t: "ev",
|
|
21
|
+
n,
|
|
22
|
+
p: location.pathname
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
const onHide = () => document.visibilityState === "hidden" && flush();
|
|
26
|
+
document.addEventListener("click", onGoal, true);
|
|
27
|
+
document.addEventListener("submit", onGoal, true);
|
|
28
|
+
document.addEventListener("visibilitychange", onHide);
|
|
29
|
+
addEventListener("pagehide", flush);
|
|
30
|
+
return () => {
|
|
31
|
+
document.removeEventListener("click", onGoal, true);
|
|
32
|
+
document.removeEventListener("submit", onGoal, true);
|
|
33
|
+
document.removeEventListener("visibilitychange", onHide);
|
|
34
|
+
removeEventListener("pagehide", flush);
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const p = location.pathname;
|
|
39
|
+
push({
|
|
40
|
+
t: "pv",
|
|
41
|
+
p
|
|
42
|
+
});
|
|
43
|
+
const seen = /* @__PURE__ */ new Set();
|
|
44
|
+
for (const el of document.querySelectorAll("[data-evolve-e]:not([data-evolve-x])")) {
|
|
45
|
+
const e = el.getAttribute("data-evolve-e");
|
|
46
|
+
const v = el.getAttribute("data-evolve-v");
|
|
47
|
+
if (e && v && !seen.has(e)) {
|
|
48
|
+
seen.add(e);
|
|
49
|
+
push({
|
|
50
|
+
t: "exp",
|
|
51
|
+
e,
|
|
52
|
+
v,
|
|
53
|
+
p
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}, [path]);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
export { EvolveScript };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { EvolveConfig } from "../config/config.js";
|
|
2
|
+
import { Goals } from "../config/goals.js";
|
|
3
|
+
import { Manifest } from "@next-evolve/protocol";
|
|
4
|
+
/** The manifest this app sends to the cloud (spec §8.4). Configuration only. */
|
|
5
|
+
export declare function buildManifest(config: EvolveConfig, goals: Goals | undefined, opts?: {
|
|
6
|
+
appUrl?: string | null;
|
|
7
|
+
}): Manifest;
|
|
8
|
+
/**
|
|
9
|
+
* The app's public base URL: EVOLVE_APP_URL, else Vercel's production URL.
|
|
10
|
+
* Null when unknown (the dashboard asks for it).
|
|
11
|
+
*/
|
|
12
|
+
export declare function detectAppUrl(env?: Record<string, string | undefined>): string | null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { resolveConfig } from "../config/config.js";
|
|
2
|
+
import { serializeGoals } from "../config/goals.js";
|
|
3
|
+
import { PROTOCOL_VERSION, SUPPORTED_PROTOCOLS, computeManifestHash } from "@next-evolve/protocol";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
// src/cloud/manifest.ts
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const versionOf = (load) => {
|
|
8
|
+
try {
|
|
9
|
+
return load().version;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
/** The manifest this app sends to the cloud (spec §8.4). Configuration only. */
|
|
15
|
+
function buildManifest(config, goals, opts = {}) {
|
|
16
|
+
const routes = {};
|
|
17
|
+
for (const [path, r] of resolveConfig(config).routes) routes[path] = { mode: r.mode };
|
|
18
|
+
const serialized = goals ? serializeGoals(goals) : {};
|
|
19
|
+
return {
|
|
20
|
+
pv: PROTOCOL_VERSION,
|
|
21
|
+
sdkVersion: versionOf(() => require("next-evolve/package.json")) ?? "0.0.0",
|
|
22
|
+
protocol: { supported: [...SUPPORTED_PROTOCOLS] },
|
|
23
|
+
nextVersion: versionOf(() => require("next/package.json")),
|
|
24
|
+
routes,
|
|
25
|
+
goals: serialized,
|
|
26
|
+
hash: computeManifestHash({
|
|
27
|
+
routes,
|
|
28
|
+
goals: serialized
|
|
29
|
+
}),
|
|
30
|
+
appUrl: opts.appUrl ?? null
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The app's public base URL: EVOLVE_APP_URL, else Vercel's production URL.
|
|
35
|
+
* Null when unknown (the dashboard asks for it).
|
|
36
|
+
*/
|
|
37
|
+
function detectAppUrl(env = process.env) {
|
|
38
|
+
if (env.EVOLVE_APP_URL) return env.EVOLVE_APP_URL.replace(/\/$/, "");
|
|
39
|
+
if (env.VERCEL_ENV === "production" && env.VERCEL_PROJECT_PRODUCTION_URL) return `https://${env.VERCEL_PROJECT_PRODUCTION_URL}`;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
export { buildManifest, detectAppUrl };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type Env = Record<string, string | undefined>;
|
|
2
|
+
export type PushResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
status: number | "network" | "no_key";
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* POSTs configuration (manifest, regions) to the cloud with the site's secret
|
|
10
|
+
* key. Never throws and never blocks a request: callers run it in `after()`
|
|
11
|
+
* or at startup. Without a secret key it's a no-op.
|
|
12
|
+
*
|
|
13
|
+
* Uses node:http, not `fetch`: Next patches `fetch` during prerender, and an
|
|
14
|
+
* uncached fetch there counts as dynamic usage (the push would fail at build,
|
|
15
|
+
* where static-mode regions are rendered).
|
|
16
|
+
*/
|
|
17
|
+
export declare function pushToCloud(path: "/v1/manifest" | "/v1/regions", body: unknown, opts?: {
|
|
18
|
+
env?: Env;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}): Promise<PushResult>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import "../store/runtime.js";
|
|
2
|
+
import { request } from "node:http";
|
|
3
|
+
import { request as request$1 } from "node:https";
|
|
4
|
+
// src/cloud/push.ts
|
|
5
|
+
/**
|
|
6
|
+
* POSTs configuration (manifest, regions) to the cloud with the site's secret
|
|
7
|
+
* key. Never throws and never blocks a request: callers run it in `after()`
|
|
8
|
+
* or at startup. Without a secret key it's a no-op.
|
|
9
|
+
*
|
|
10
|
+
* Uses node:http, not `fetch`: Next patches `fetch` during prerender, and an
|
|
11
|
+
* uncached fetch there counts as dynamic usage (the push would fail at build,
|
|
12
|
+
* where static-mode regions are rendered).
|
|
13
|
+
*/
|
|
14
|
+
function pushToCloud(path, body, opts = {}) {
|
|
15
|
+
const env = opts.env ?? process.env;
|
|
16
|
+
const key = env.EVOLVE_SECRET_KEY;
|
|
17
|
+
const cloud = env.EVOLVE_CLOUD_URL ?? "https://api.evolveab.com";
|
|
18
|
+
if (!key || !cloud) return Promise.resolve({
|
|
19
|
+
ok: false,
|
|
20
|
+
status: "no_key"
|
|
21
|
+
});
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(path, cloud);
|
|
25
|
+
const data = Buffer.from(JSON.stringify(body));
|
|
26
|
+
const req = (url.protocol === "https:" ? request$1 : request)(url, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
authorization: `Bearer ${key}`,
|
|
30
|
+
"content-type": "application/json",
|
|
31
|
+
"content-length": data.length
|
|
32
|
+
},
|
|
33
|
+
timeout: opts.timeoutMs ?? 3e3
|
|
34
|
+
}, (res) => {
|
|
35
|
+
res.resume();
|
|
36
|
+
const status = res.statusCode ?? 0;
|
|
37
|
+
resolve(status >= 200 && status < 300 ? { ok: true } : {
|
|
38
|
+
ok: false,
|
|
39
|
+
status
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
req.on("timeout", () => req.destroy(/* @__PURE__ */ new Error("timeout")));
|
|
43
|
+
req.on("error", () => resolve({
|
|
44
|
+
ok: false,
|
|
45
|
+
status: "network"
|
|
46
|
+
}));
|
|
47
|
+
req.end(data);
|
|
48
|
+
} catch {
|
|
49
|
+
resolve({
|
|
50
|
+
ok: false,
|
|
51
|
+
status: "network"
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export { pushToCloud };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { RegionRegistration } from "@next-evolve/protocol";
|
|
2
|
+
/**
|
|
3
|
+
* Registers a region with the cloud (spec §5.2), once per process per
|
|
4
|
+
* (id, schema, path). Errors are swallowed; a failed push is retried on a later render.
|
|
5
|
+
*/
|
|
6
|
+
export declare function registerRegion(r: Omit<RegionRegistration, "pv">): Promise<void>;
|
|
7
|
+
/** Test helper. */
|
|
8
|
+
export declare function resetRegionRegistry(): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { pushToCloud } from "./push.js";
|
|
2
|
+
import { PROTOCOL_VERSION } from "@next-evolve/protocol";
|
|
3
|
+
// src/cloud/regions.ts
|
|
4
|
+
const sent = /* @__PURE__ */ new Set();
|
|
5
|
+
const MAX_SENT = 1e3;
|
|
6
|
+
/**
|
|
7
|
+
* Registers a region with the cloud (spec §5.2), once per process per
|
|
8
|
+
* (id, schema, path). Errors are swallowed; a failed push is retried on a later render.
|
|
9
|
+
*/
|
|
10
|
+
async function registerRegion(r) {
|
|
11
|
+
const key = `${r.id}|${r.schemaHash}|${r.path}`;
|
|
12
|
+
if (sent.has(key)) return;
|
|
13
|
+
sent.add(key);
|
|
14
|
+
if (sent.size > MAX_SENT) sent.delete(sent.values().next().value);
|
|
15
|
+
const res = await pushToCloud("/v1/regions", {
|
|
16
|
+
pv: PROTOCOL_VERSION,
|
|
17
|
+
...r
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok && res.status !== "no_key") sent.delete(key);
|
|
20
|
+
}
|
|
21
|
+
/** Test helper. */
|
|
22
|
+
function resetRegionRegistry() {
|
|
23
|
+
sent.clear();
|
|
24
|
+
}
|
|
25
|
+
export { registerRegion, resetRegionRegistry };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type RouteConfig = {
|
|
2
|
+
/** static (default): ISR per variant under `app/[evolve]/`. dynamic: page reads `x-evolve-a`. */
|
|
3
|
+
mode: "static" | "dynamic";
|
|
4
|
+
};
|
|
5
|
+
export type EvolveConfig = {
|
|
6
|
+
/** Routes Evolve may test, keyed by exact pathname (e.g. "/", "/pricing"). */
|
|
7
|
+
routes: Record<string, RouteConfig>;
|
|
8
|
+
store?: "auto" | "memory" | "global-config";
|
|
9
|
+
sync?: {
|
|
10
|
+
pollSeconds?: number;
|
|
11
|
+
};
|
|
12
|
+
database?: {
|
|
13
|
+
urlEnv?: string;
|
|
14
|
+
};
|
|
15
|
+
aggregate?: {
|
|
16
|
+
maxMs?: number;
|
|
17
|
+
statementTimeoutMs?: number;
|
|
18
|
+
};
|
|
19
|
+
cookies?: {
|
|
20
|
+
maxAgeDays?: number;
|
|
21
|
+
domain?: string;
|
|
22
|
+
};
|
|
23
|
+
/** Return false to serve control and set no cookies (e.g. before consent). */
|
|
24
|
+
consent?: (req: Request) => boolean;
|
|
25
|
+
bots?: {
|
|
26
|
+
extraPatterns?: string[];
|
|
27
|
+
};
|
|
28
|
+
debug?: boolean;
|
|
29
|
+
};
|
|
30
|
+
export type ResolvedConfig = {
|
|
31
|
+
routes: ReadonlyMap<string, RouteConfig>;
|
|
32
|
+
store: "auto" | "memory" | "global-config";
|
|
33
|
+
pollSeconds: number;
|
|
34
|
+
databaseUrlEnv: string;
|
|
35
|
+
aggregate: {
|
|
36
|
+
maxMs: number;
|
|
37
|
+
statementTimeoutMs: number;
|
|
38
|
+
};
|
|
39
|
+
cookies: {
|
|
40
|
+
maxAgeDays: number;
|
|
41
|
+
domain: string | undefined;
|
|
42
|
+
};
|
|
43
|
+
consent: ((req: Request) => boolean) | undefined;
|
|
44
|
+
botPatterns: readonly string[];
|
|
45
|
+
debug: boolean;
|
|
46
|
+
};
|
|
47
|
+
/** Normalizes a pathname: no trailing slash (except "/"), no empty segments. */
|
|
48
|
+
export declare function normalizePath(p: string): string;
|
|
49
|
+
/** Identity helper for `evolve.config.ts` with types and validation. */
|
|
50
|
+
export declare function defineConfig<const C extends EvolveConfig>(config: C): C;
|
|
51
|
+
export declare function resolveConfig(config: EvolveConfig): ResolvedConfig;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/config/config.ts
|
|
2
|
+
/** Normalizes a pathname: no trailing slash (except "/"), no empty segments. */
|
|
3
|
+
function normalizePath(p) {
|
|
4
|
+
if (p === "/" || p === "") return "/";
|
|
5
|
+
const s = p.replace(/\/{2,}/g, "/");
|
|
6
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
7
|
+
}
|
|
8
|
+
/** Identity helper for `evolve.config.ts` with types and validation. */
|
|
9
|
+
function defineConfig(config) {
|
|
10
|
+
resolveConfig(config);
|
|
11
|
+
return config;
|
|
12
|
+
}
|
|
13
|
+
const resolved = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
function resolveConfig(config) {
|
|
15
|
+
const hit = resolved.get(config);
|
|
16
|
+
if (hit) return hit;
|
|
17
|
+
const routes = /* @__PURE__ */ new Map();
|
|
18
|
+
for (const [raw, route] of Object.entries(config.routes)) {
|
|
19
|
+
if (!raw.startsWith("/")) throw new Error(`[evolve] route "${raw}" must start with "/"`);
|
|
20
|
+
const path = normalizePath(raw);
|
|
21
|
+
if (path.split("/")[1]?.startsWith("ev_")) throw new Error(`[evolve] route "${raw}" can't start with ev_`);
|
|
22
|
+
if (/[[\]*?#]/.test(path)) throw new Error(`[evolve] route "${raw}" must be a concrete path`);
|
|
23
|
+
if (route.mode !== "static" && route.mode !== "dynamic") throw new Error(`[evolve] route "${raw}": mode must be "static" or "dynamic"`);
|
|
24
|
+
routes.set(path, route);
|
|
25
|
+
}
|
|
26
|
+
const dev = process.env.NODE_ENV !== "production";
|
|
27
|
+
const r = {
|
|
28
|
+
routes,
|
|
29
|
+
store: config.store ?? "auto",
|
|
30
|
+
pollSeconds: config.sync?.pollSeconds ?? (dev ? 5 : 60),
|
|
31
|
+
databaseUrlEnv: config.database?.urlEnv ?? "EVOLVE_DATABASE_URL",
|
|
32
|
+
aggregate: {
|
|
33
|
+
maxMs: config.aggregate?.maxMs ?? 25e3,
|
|
34
|
+
statementTimeoutMs: config.aggregate?.statementTimeoutMs ?? 2e4
|
|
35
|
+
},
|
|
36
|
+
cookies: {
|
|
37
|
+
maxAgeDays: config.cookies?.maxAgeDays ?? 365,
|
|
38
|
+
domain: config.cookies?.domain
|
|
39
|
+
},
|
|
40
|
+
consent: config.consent,
|
|
41
|
+
botPatterns: config.bots?.extraPatterns ?? [],
|
|
42
|
+
debug: config.debug ?? dev
|
|
43
|
+
};
|
|
44
|
+
resolved.set(config, r);
|
|
45
|
+
return r;
|
|
46
|
+
}
|
|
47
|
+
export { defineConfig, normalizePath, resolveConfig };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { SerializedGoal } from "@next-evolve/protocol";
|
|
2
|
+
/** `<n>m`, `<n>h` or `<n>d`, at most 365 days. */
|
|
3
|
+
export type GoalWindow = `${number}${"m" | "h" | "d"}`;
|
|
4
|
+
type Common = {
|
|
5
|
+
/** Attribution window after first exposure. Default `7d`. */
|
|
6
|
+
window?: GoalWindow;
|
|
7
|
+
/** Count per visitor (default) or per identified user. */
|
|
8
|
+
unit?: "visitor" | "user";
|
|
9
|
+
/** Whether higher is better. Default `increase`. */
|
|
10
|
+
direction?: "increase" | "decrease";
|
|
11
|
+
};
|
|
12
|
+
/** An event as a goal's `value` function sees it (at write time, in your app). */
|
|
13
|
+
export type GoalEvent = {
|
|
14
|
+
name: string;
|
|
15
|
+
props: Record<string, unknown>;
|
|
16
|
+
value: number | null;
|
|
17
|
+
source: string;
|
|
18
|
+
userId: string | null;
|
|
19
|
+
anonId: string | null;
|
|
20
|
+
};
|
|
21
|
+
export type EventGoal = Common & {
|
|
22
|
+
event: string;
|
|
23
|
+
/** Only events from this source (e.g. `stripe`). */
|
|
24
|
+
source?: string;
|
|
25
|
+
/** Only events whose props equal these values. */
|
|
26
|
+
where?: Record<string, string | number | boolean>;
|
|
27
|
+
/** A numeric prop path (`"amount"`, `"order.total"`) or a function run locally at write time. */
|
|
28
|
+
value?: string | ((e: GoalEvent) => number | null | undefined);
|
|
29
|
+
};
|
|
30
|
+
export type PageviewGoal = Common & {
|
|
31
|
+
/** Exact path, or a prefix ending in `*` (e.g. `/docs/*`). */
|
|
32
|
+
pageview: string;
|
|
33
|
+
};
|
|
34
|
+
export type GoalDefinition = EventGoal | PageviewGoal;
|
|
35
|
+
export type Goals = Record<string, GoalDefinition>;
|
|
36
|
+
/** Identity helper for `evolve.goals.ts`: types plus validation (throws on mistakes). */
|
|
37
|
+
export declare function defineGoals<const G extends Goals>(goals: G): G;
|
|
38
|
+
/**
|
|
39
|
+
* Goal definitions as sent to the cloud. Function values become
|
|
40
|
+
* `{ kind: "local-fn" }`; the function itself never leaves the app.
|
|
41
|
+
*/
|
|
42
|
+
export declare function serializeGoals(goals: Goals): Record<string, SerializedGoal>;
|
|
43
|
+
/** True if a stored `$pageview` at `path` would count for some pageview goal. */
|
|
44
|
+
export declare function matchesPageviewGoal(goals: Goals | undefined, path: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Runs the `value` functions of matching event goals. Returns `{ goalKey: n }`
|
|
47
|
+
* or null. A function that throws or returns a non-finite number is skipped.
|
|
48
|
+
*/
|
|
49
|
+
export declare function computeGoalValues(goals: Goals | undefined, e: GoalEvent): Record<string, number> | null;
|
|
50
|
+
export {};
|