next-evolve 0.1.0 → 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.
- package/README.md +55 -1
- package/dist/_virtual/_rolldown/runtime.js +12 -0
- package/dist/aggregate/attribution.d.ts +40 -0
- package/dist/aggregate/attribution.js +115 -0
- package/dist/aggregate/run.d.ts +21 -0
- package/dist/aggregate/run.js +139 -0
- package/dist/cli/aggregate.d.ts +9 -0
- package/dist/cli/aggregate.js +104 -0
- package/dist/cli/args.d.ts +12 -0
- package/dist/cli/args.js +23 -0
- package/dist/cli/cloud.d.ts +41 -0
- package/dist/cli/cloud.js +74 -0
- package/dist/cli/connect.d.ts +8 -0
- package/dist/cli/connect.js +46 -0
- package/dist/cli/detect.d.ts +26 -0
- package/dist/cli/detect.js +97 -0
- package/dist/cli/doctor.d.ts +34 -0
- package/dist/cli/doctor.js +230 -0
- package/dist/cli/env-file.d.ts +4 -0
- package/dist/cli/env-file.js +15 -0
- package/dist/cli/files.d.ts +25 -0
- package/dist/cli/files.js +173 -0
- package/dist/cli/index.js +73 -32
- package/dist/cli/init.d.ts +26 -0
- package/dist/cli/init.js +240 -0
- package/dist/cli/load-app.d.ts +19 -0
- package/dist/cli/load-app.js +67 -0
- package/dist/cli/migrate.d.ts +7 -0
- package/dist/cli/migrate.js +30 -0
- package/dist/cli/pages.d.ts +17 -0
- package/dist/cli/pages.js +304 -0
- package/dist/cli/plan.d.ts +27 -0
- package/dist/cli/plan.js +129 -0
- package/dist/cli/pull.d.ts +13 -0
- package/dist/cli/pull.js +62 -0
- package/dist/cli/push.d.ts +14 -0
- package/dist/cli/push.js +33 -0
- package/dist/cloud/manifest.d.ts +2 -0
- package/dist/cloud/manifest.js +6 -2
- package/dist/core/assign.d.ts +13 -1
- package/dist/core/assign.js +25 -3
- package/dist/db/writer.d.ts +2 -0
- package/dist/db/writer.js +1 -1
- package/dist/instrumentation/index.d.ts +3 -2
- package/dist/instrumentation/index.js +17 -2
- package/dist/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/typescript.d.ts +11169 -0
- package/dist/proxy/index.d.ts +2 -2
- package/dist/proxy/index.js +1 -1
- package/dist/proxy/proxy.d.ts +7 -1
- package/dist/proxy/proxy.js +23 -6
- package/dist/react/render-bundle.d.ts +10 -4
- package/dist/react/render-bundle.js +34 -5
- package/dist/server/aggregate-route.d.ts +16 -0
- package/dist/server/aggregate-route.js +68 -0
- package/dist/server/collect.d.ts +16 -0
- package/dist/server/collect.js +34 -8
- package/dist/server/handler.d.ts +3 -2
- package/dist/server/handler.js +37 -4
- package/dist/server/sync-route.d.ts +28 -0
- package/dist/server/sync-route.js +100 -0
- package/dist/store/disk.d.ts +6 -0
- package/dist/store/disk.js +23 -0
- package/dist/store/fetch.d.ts +4 -0
- package/dist/store/fetch.js +36 -1
- package/dist/store/global-config.d.ts +39 -0
- package/dist/store/global-config.js +122 -0
- package/dist/store/memory.d.ts +11 -2
- package/dist/store/memory.js +6 -2
- package/dist/store/runtime.d.ts +14 -0
- package/dist/store/runtime.js +39 -7
- package/package.json +2 -1
package/dist/proxy/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { EvolveProxyOptions, applyEvolve, createEvolveProxy } from "./proxy.js";
|
|
2
|
-
export { type EvolveProxyOptions, applyEvolve, createEvolveProxy };
|
|
1
|
+
import { EvolveProxyOptions, applyEvolve, createEvolveProxy, evolveReady } from "./proxy.js";
|
|
2
|
+
export { type EvolveProxyOptions, applyEvolve, createEvolveProxy, evolveReady };
|
package/dist/proxy/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { applyEvolve, createEvolveProxy } from "./proxy.js";
|
|
1
|
+
export { applyEvolve, createEvolveProxy, evolveReady } from "./proxy.js";
|
package/dist/proxy/proxy.d.ts
CHANGED
|
@@ -15,4 +15,10 @@ export declare const PREVIEW_PARAM = "evolve_preview";
|
|
|
15
15
|
*/
|
|
16
16
|
export declare function applyEvolve(config: EvolveConfig, req: NextRequest, event?: Pick<NextFetchEvent, "waitUntil">, opts?: EvolveProxyOptions): NextResponse | null;
|
|
17
17
|
/** Builds the `proxy` export for `proxy.ts` (Next 16) or `middleware.ts` (Next 15.5, Node runtime). */
|
|
18
|
-
export declare function createEvolveProxy(config: EvolveConfig, opts?: EvolveProxyOptions): (req: NextRequest, event: NextFetchEvent) => NextResponse
|
|
18
|
+
export declare function createEvolveProxy(config: EvolveConfig, opts?: EvolveProxyOptions): (req: NextRequest, event: NextFetchEvent) => NextResponse | Promise<NextResponse>;
|
|
19
|
+
/**
|
|
20
|
+
* With Global Config, an instance's first request waits ≤ 20 ms for one read
|
|
21
|
+
* (spec §7.8). Returns null when there's nothing to wait for. With an existing
|
|
22
|
+
* proxy: `await evolveReady(config)` before `applyEvolve(...)`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function evolveReady(config: EvolveConfig, opts?: RuntimeOptions): Promise<void> | null;
|
package/dist/proxy/proxy.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { normalizePath, resolveConfig } from "../config/config.js";
|
|
2
|
-
import { assign } from "../core/assign.js";
|
|
2
|
+
import { assign, visitorDraw } from "../core/assign.js";
|
|
3
3
|
import { createBotDetector } from "../core/bots.js";
|
|
4
4
|
import { HEADER_CODE, HEADER_PATH } from "../core/constants.js";
|
|
5
5
|
import { ANON_COOKIE, ASSIGN_COOKIE, PREVIEW_COOKIE, parseAssignments, serializeAssignments } from "../core/cookies.js";
|
|
6
6
|
import { logRateLimited } from "../core/log.js";
|
|
7
|
-
import { getBundleStore, getSiteIdentity } from "../store/runtime.js";
|
|
7
|
+
import { coldStart, getBundleStore, getSiteIdentity } from "../store/runtime.js";
|
|
8
8
|
import { CONTROL_CODE, encodeCode, newAnonId, verifyPreview } from "@next-evolve/protocol";
|
|
9
9
|
import { NextResponse } from "next/server";
|
|
10
10
|
// src/proxy/proxy.ts
|
|
@@ -55,10 +55,12 @@ function applyEvolve(config, req, event, opts = {}) {
|
|
|
55
55
|
]);
|
|
56
56
|
} else if (bundle && !isBot(req.headers.get("user-agent")) && cfg.consent?.(req) !== false) {
|
|
57
57
|
const cookie = parseAssignments(req.cookies.get(ASSIGN_COOKIE)?.value);
|
|
58
|
-
const
|
|
58
|
+
const known = req.cookies.get(ANON_COOKIE)?.value;
|
|
59
|
+
const anon = known || newAnonId();
|
|
60
|
+
const a = assign(bundle, path, cookie, opts.random ?? visitorDraw(anon));
|
|
59
61
|
code = encodeCode(a.pairs);
|
|
60
62
|
if ((bundle.routing.routes[path] ?? []).some((e) => bundle.routing.experiments[e]?.status === "running")) {
|
|
61
|
-
if (!
|
|
63
|
+
if (!known) setCookies.push([ANON_COOKIE, anon]);
|
|
62
64
|
if (a.changed) setCookies.push([ASSIGN_COOKIE, serializeAssignments(a.assigned)]);
|
|
63
65
|
}
|
|
64
66
|
}
|
|
@@ -97,7 +99,22 @@ function passThrough(req, code, path) {
|
|
|
97
99
|
function createEvolveProxy(config, opts = {}) {
|
|
98
100
|
resolveConfig(config);
|
|
99
101
|
return function proxy(req, event) {
|
|
100
|
-
|
|
102
|
+
const ready = evolveReady(config, opts);
|
|
103
|
+
const run = () => applyEvolve(config, req, event, opts) ?? NextResponse.next();
|
|
104
|
+
return ready ? ready.then(run) : run();
|
|
101
105
|
};
|
|
102
106
|
}
|
|
103
|
-
|
|
107
|
+
/**
|
|
108
|
+
* With Global Config, an instance's first request waits ≤ 20 ms for one read
|
|
109
|
+
* (spec §7.8). Returns null when there's nothing to wait for. With an existing
|
|
110
|
+
* proxy: `await evolveReady(config)` before `applyEvolve(...)`.
|
|
111
|
+
*/
|
|
112
|
+
function evolveReady(config, opts = {}) {
|
|
113
|
+
try {
|
|
114
|
+
const store = getBundleStore(resolveConfig(config), opts);
|
|
115
|
+
return store ? coldStart(store) : null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export { PREVIEW_PARAM, applyEvolve, createEvolveProxy, evolveReady };
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { BundlePayload } from "@next-evolve/protocol";
|
|
2
|
+
type Env = Record<string, string | undefined>;
|
|
2
3
|
/**
|
|
3
|
-
* Bundle for rendering
|
|
4
|
-
* proxy
|
|
5
|
-
*
|
|
4
|
+
* Bundle for rendering (spec §7.7). Warm: the process-wide store (shared with
|
|
5
|
+
* the proxy where they run in one process). Cold (empty store, e.g. a fresh
|
|
6
|
+
* serverless render function): `GET /v1/bundle` through Next's data cache,
|
|
7
|
+
* tagged `evolve-bundle`, so instances share it and the sync route's
|
|
8
|
+
* `revalidateTag` drops it. `fresh` pulls uncached, for a variant code the
|
|
9
|
+
* cached bundle doesn't know yet (a new or preview-only variant).
|
|
6
10
|
*/
|
|
7
11
|
export declare function getRenderBundle(opts?: {
|
|
8
12
|
fresh?: boolean;
|
|
9
|
-
|
|
13
|
+
env?: Env;
|
|
14
|
+
}): Promise<BundlePayload | null>;
|
|
15
|
+
export {};
|
|
@@ -1,14 +1,43 @@
|
|
|
1
|
+
import { BUNDLE_TAG } from "../server/sync-route.js";
|
|
1
2
|
import { getBundleStore } from "../store/runtime.js";
|
|
2
3
|
// src/react/render-bundle.ts
|
|
3
4
|
/**
|
|
4
|
-
* Bundle for rendering
|
|
5
|
-
* proxy
|
|
6
|
-
*
|
|
5
|
+
* Bundle for rendering (spec §7.7). Warm: the process-wide store (shared with
|
|
6
|
+
* the proxy where they run in one process). Cold (empty store, e.g. a fresh
|
|
7
|
+
* serverless render function): `GET /v1/bundle` through Next's data cache,
|
|
8
|
+
* tagged `evolve-bundle`, so instances share it and the sync route's
|
|
9
|
+
* `revalidateTag` drops it. `fresh` pulls uncached, for a variant code the
|
|
10
|
+
* cached bundle doesn't know yet (a new or preview-only variant).
|
|
7
11
|
*/
|
|
8
12
|
async function getRenderBundle(opts = {}) {
|
|
9
|
-
const
|
|
13
|
+
const env = opts.env ?? process.env;
|
|
14
|
+
const store = getBundleStore({}, { env });
|
|
10
15
|
if (!store) return null;
|
|
11
|
-
if (opts.fresh)
|
|
16
|
+
if (opts.fresh) {
|
|
17
|
+
await store.refresh({ force: true });
|
|
18
|
+
return store.peek();
|
|
19
|
+
}
|
|
20
|
+
const warm = store.peek();
|
|
21
|
+
if (warm) return warm;
|
|
22
|
+
const token = await taggedFetch(env);
|
|
23
|
+
if (token) store.load(token);
|
|
12
24
|
return store.peek();
|
|
13
25
|
}
|
|
26
|
+
async function taggedFetch(env) {
|
|
27
|
+
const cloudUrl = env.EVOLVE_CLOUD_URL ?? "https://api.evolveab.com";
|
|
28
|
+
const key = env.EVOLVE_SECRET_KEY;
|
|
29
|
+
if (!cloudUrl || !key) return null;
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(new URL("/v1/bundle", cloudUrl), {
|
|
32
|
+
headers: { authorization: `Bearer ${key}` },
|
|
33
|
+
cache: "force-cache",
|
|
34
|
+
next: { tags: [BUNDLE_TAG] },
|
|
35
|
+
signal: AbortSignal.timeout(5e3)
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) return null;
|
|
38
|
+
return (await res.text()).trim() || null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
14
43
|
export { getRenderBundle };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ResolvedConfig } from "../config/config.js";
|
|
2
|
+
import { Sql } from "../db/client.js";
|
|
3
|
+
import { ReplayGuard } from "@next-evolve/protocol";
|
|
4
|
+
type Env = Record<string, string | undefined>;
|
|
5
|
+
export type AggregateRouteDeps = {
|
|
6
|
+
config: ResolvedConfig;
|
|
7
|
+
env: Env;
|
|
8
|
+
sql: () => Sql | null;
|
|
9
|
+
sdkVersion: string;
|
|
10
|
+
replay?: ReplayGuard;
|
|
11
|
+
now?: () => number;
|
|
12
|
+
};
|
|
13
|
+
/** Delivery ids seen in the last 10 minutes, shared by every signed cloud → app route. */
|
|
14
|
+
export declare function sharedReplayGuard(): ReplayGuard;
|
|
15
|
+
export declare function handleAggregate(req: Request, deps: AggregateRouteDeps): Promise<Response>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { runAggregate } from "../aggregate/run.js";
|
|
2
|
+
import { DELIVERY_HEADER, SIGNATURE_HEADER, aggregateRequestSchema, createReplayGuard, isSupportedProtocol, parseSecretKey, signBody, unsupportedProtocolBody, verifyBody } from "@next-evolve/protocol";
|
|
3
|
+
// src/server/aggregate-route.ts
|
|
4
|
+
const MAX_BODY_BYTES = 262144;
|
|
5
|
+
const KEY = Symbol.for("next-evolve.replay");
|
|
6
|
+
/** Delivery ids seen in the last 10 minutes, shared by every signed cloud → app route. */
|
|
7
|
+
function sharedReplayGuard() {
|
|
8
|
+
const g = globalThis;
|
|
9
|
+
g[KEY] ??= createReplayGuard();
|
|
10
|
+
return g[KEY];
|
|
11
|
+
}
|
|
12
|
+
const json = (status, body, headers = {}) => Response.json(body, {
|
|
13
|
+
status,
|
|
14
|
+
headers
|
|
15
|
+
});
|
|
16
|
+
async function handleAggregate(req, deps) {
|
|
17
|
+
const secret = deps.env.EVOLVE_WEBHOOK_SECRET;
|
|
18
|
+
const site = parseSecretKey(deps.env.EVOLVE_SECRET_KEY ?? "")?.siteId;
|
|
19
|
+
if (!secret || !site) return json(503, { error: "not_configured" });
|
|
20
|
+
const body = await req.text();
|
|
21
|
+
if (Buffer.byteLength(body) > MAX_BODY_BYTES) return json(413, { error: "too_large" });
|
|
22
|
+
const now = deps.now ?? Date.now;
|
|
23
|
+
const sig = verifyBody(req.headers.get(SIGNATURE_HEADER), body, secret, { now: Math.floor(now() / 1e3) });
|
|
24
|
+
if (!sig.ok) return json(401, {
|
|
25
|
+
error: "invalid_signature",
|
|
26
|
+
reason: sig.reason
|
|
27
|
+
});
|
|
28
|
+
const delivery = req.headers.get(DELIVERY_HEADER) ?? "";
|
|
29
|
+
if (!(deps.replay ?? sharedReplayGuard()).check(delivery)) return json(409, { error: "replayed" });
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(body);
|
|
33
|
+
} catch {
|
|
34
|
+
return json(400, { error: "invalid_json" });
|
|
35
|
+
}
|
|
36
|
+
if (!isSupportedProtocol(parsed?.pv)) return json(400, unsupportedProtocolBody());
|
|
37
|
+
const r = aggregateRequestSchema.safeParse(parsed);
|
|
38
|
+
if (!r.success) return json(400, { error: "invalid" });
|
|
39
|
+
if (r.data.site !== site) return json(403, { error: "site_mismatch" });
|
|
40
|
+
const sql = deps.sql();
|
|
41
|
+
if (!sql) return json(503, { error: "no_database" });
|
|
42
|
+
let result;
|
|
43
|
+
try {
|
|
44
|
+
result = await runAggregate(sql, r.data, {
|
|
45
|
+
maxMs: deps.config.aggregate.maxMs,
|
|
46
|
+
statementTimeoutMs: deps.config.aggregate.statementTimeoutMs,
|
|
47
|
+
sdkVersion: deps.sdkVersion,
|
|
48
|
+
now
|
|
49
|
+
});
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.error("[evolve] aggregate failed", e);
|
|
52
|
+
return json(503, { error: "database_error" });
|
|
53
|
+
}
|
|
54
|
+
if (result.kind === "busy") return json(429, {
|
|
55
|
+
busy: true,
|
|
56
|
+
retryAfter: result.retryAfter
|
|
57
|
+
}, { "retry-after": String(result.retryAfter) });
|
|
58
|
+
const out = JSON.stringify(result.response);
|
|
59
|
+
return new Response(out, {
|
|
60
|
+
status: 200,
|
|
61
|
+
headers: {
|
|
62
|
+
"content-type": "application/json",
|
|
63
|
+
"cache-control": "no-store",
|
|
64
|
+
[SIGNATURE_HEADER]: signBody(secret, out, Math.floor(now() / 1e3))
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export { handleAggregate, sharedReplayGuard };
|
package/dist/server/collect.d.ts
CHANGED
|
@@ -32,10 +32,26 @@ export type CollectDeps = {
|
|
|
32
32
|
config: ResolvedConfig;
|
|
33
33
|
goals: Goals | undefined;
|
|
34
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>;
|
|
35
41
|
isBot: (ua: string | null) => boolean;
|
|
36
42
|
writer: Writer;
|
|
37
43
|
now?: () => Date;
|
|
38
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>;
|
|
39
55
|
/** Same-origin check: `Origin` must match the host; without it, `Sec-Fetch-Site` must say so. */
|
|
40
56
|
export declare function isSameOrigin(req: Request): boolean;
|
|
41
57
|
/**
|
package/dist/server/collect.js
CHANGED
|
@@ -35,6 +35,26 @@ const collectSchema = z.object({
|
|
|
35
35
|
v: z.literal(1),
|
|
36
36
|
events: z.array(z.unknown()).max(50)
|
|
37
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
|
+
}
|
|
38
58
|
/** Same-origin check: `Origin` must match the host; without it, `Sec-Fetch-Site` must say so. */
|
|
39
59
|
function isSameOrigin(req) {
|
|
40
60
|
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
|
|
@@ -81,22 +101,28 @@ async function handleCollect(req, deps) {
|
|
|
81
101
|
return r.success ? [r.data] : [];
|
|
82
102
|
});
|
|
83
103
|
const now = deps.now?.() ?? /* @__PURE__ */ new Date();
|
|
84
|
-
|
|
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
|
+
}
|
|
85
113
|
const exposures = [];
|
|
86
114
|
const rows = [];
|
|
87
115
|
let testedPageviews = 0;
|
|
88
116
|
for (const e of events) switch (e.t) {
|
|
89
|
-
case "exp":
|
|
90
|
-
|
|
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({
|
|
117
|
+
case "exp":
|
|
118
|
+
if (known(e.e, e.v)) exposures.push({
|
|
93
119
|
experiment_id: e.e,
|
|
94
120
|
anon_id: anon,
|
|
95
121
|
variant_id: e.v,
|
|
96
|
-
path: normalizePath(e.p)
|
|
122
|
+
path: normalizePath(e.p),
|
|
123
|
+
first_at: now
|
|
97
124
|
});
|
|
98
125
|
break;
|
|
99
|
-
}
|
|
100
126
|
case "pv": {
|
|
101
127
|
const p = normalizePath(e.p);
|
|
102
128
|
if ((bundle?.routing.routes[p] ?? []).some((id) => bundle?.routing.experiments[id]?.status === "running") && deps.config.routes.has(p)) testedPageviews++;
|
|
@@ -128,4 +154,4 @@ async function handleCollect(req, deps) {
|
|
|
128
154
|
deps.writer.usage(utcDay(now), testedPageviews);
|
|
129
155
|
return 204;
|
|
130
156
|
}
|
|
131
|
-
export { MAX_BATCH_BYTES, MAX_BATCH_EVENTS, collectSchema, handleCollect, isSameOrigin };
|
|
157
|
+
export { MAX_BATCH_BYTES, MAX_BATCH_EVENTS, collectSchema, createBundleRefresher, handleCollect, isSameOrigin };
|
package/dist/server/handler.d.ts
CHANGED
|
@@ -7,8 +7,9 @@ export type EvolveHandlerOptions = {
|
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
9
|
* Route handlers for `app/api/evolve/[...evolve]/route.ts`:
|
|
10
|
-
* `POST collect` (beacon), `POST track` (bearer webhook), `
|
|
11
|
-
*
|
|
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`.
|
|
12
13
|
*/
|
|
13
14
|
export declare function createEvolveHandler(opts: EvolveHandlerOptions): {
|
|
14
15
|
GET: (req: Request) => Promise<Response>;
|
package/dist/server/handler.js
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
|
+
import { sdkVersion } from "../cloud/manifest.js";
|
|
1
2
|
import { resolveConfig } from "../config/config.js";
|
|
2
3
|
import { serializeGoals } from "../config/goals.js";
|
|
3
4
|
import { createBotDetector } from "../core/bots.js";
|
|
4
5
|
import { logRateLimited } from "../core/log.js";
|
|
6
|
+
import { getSql } from "../db/client.js";
|
|
7
|
+
import { globalConfigSettings, writeGlobalConfig } from "../store/global-config.js";
|
|
5
8
|
import { getBundleStore } from "../store/runtime.js";
|
|
6
|
-
import {
|
|
9
|
+
import { handleAggregate } from "./aggregate-route.js";
|
|
10
|
+
import { createBundleRefresher, handleCollect } from "./collect.js";
|
|
7
11
|
import { runtimeWriter, scheduleFlush, setRuntime } from "./runtime.js";
|
|
12
|
+
import { handleSync, revalidateBundle } from "./sync-route.js";
|
|
8
13
|
import { handleTrack } from "./track-route.js";
|
|
9
|
-
import { SUPPORTED_PROTOCOLS } from "@next-evolve/protocol";
|
|
14
|
+
import { SUPPORTED_PROTOCOLS, parseSecretKey } from "@next-evolve/protocol";
|
|
10
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
|
+
}
|
|
11
26
|
const notFound = () => Response.json({ error: "not_found" }, { status: 404 });
|
|
12
27
|
/**
|
|
13
28
|
* Route handlers for `app/api/evolve/[...evolve]/route.ts`:
|
|
14
|
-
* `POST collect` (beacon), `POST track` (bearer webhook), `
|
|
15
|
-
*
|
|
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`.
|
|
16
32
|
*/
|
|
17
33
|
function createEvolveHandler(opts) {
|
|
18
34
|
const cfg = resolveConfig(opts.config);
|
|
@@ -38,6 +54,7 @@ function createEvolveHandler(opts) {
|
|
|
38
54
|
config: cfg,
|
|
39
55
|
goals: opts.goals,
|
|
40
56
|
bundle: () => store?.peek() ?? null,
|
|
57
|
+
...store ? { refreshBundle: collectRefresher(store) } : {},
|
|
41
58
|
isBot,
|
|
42
59
|
writer
|
|
43
60
|
});
|
|
@@ -49,6 +66,22 @@ function createEvolveHandler(opts) {
|
|
|
49
66
|
goals: opts.goals,
|
|
50
67
|
writer: runtimeWriter()
|
|
51
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
|
+
});
|
|
52
85
|
default: return notFound();
|
|
53
86
|
}
|
|
54
87
|
} catch (e) {
|
|
@@ -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 {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { logRateLimited } from "../core/log.js";
|
|
2
|
+
import { sharedReplayGuard } from "./aggregate-route.js";
|
|
3
|
+
import { DELIVERY_HEADER, SIGNATURE_HEADER, SUPPORTED_PROTOCOLS, isSupportedProtocol, parseSecretKey, syncWebhookSchema, unsupportedProtocolBody, verifyBody } from "@next-evolve/protocol";
|
|
4
|
+
// src/server/sync-route.ts
|
|
5
|
+
/** Cache tag on the render-side bundle fetch (spec §7.7). */
|
|
6
|
+
const BUNDLE_TAG = "evolve-bundle";
|
|
7
|
+
/** At most one refresh per window. */
|
|
8
|
+
const SYNC_MIN_INTERVAL_MS = 5e3;
|
|
9
|
+
const MAX_BODY_BYTES = 16384;
|
|
10
|
+
const createSyncThrottle = () => ({
|
|
11
|
+
last: -Infinity,
|
|
12
|
+
waiting: null
|
|
13
|
+
});
|
|
14
|
+
const THROTTLE = Symbol.for("next-evolve.sync-throttle");
|
|
15
|
+
function sharedThrottle() {
|
|
16
|
+
const g = globalThis;
|
|
17
|
+
g[THROTTLE] ??= createSyncThrottle();
|
|
18
|
+
return g[THROTTLE];
|
|
19
|
+
}
|
|
20
|
+
const json = (status, body) => Response.json(body, { status });
|
|
21
|
+
async function handleSync(req, deps) {
|
|
22
|
+
const secret = deps.env.EVOLVE_WEBHOOK_SECRET;
|
|
23
|
+
const site = parseSecretKey(deps.env.EVOLVE_SECRET_KEY ?? "")?.siteId;
|
|
24
|
+
const store = deps.store();
|
|
25
|
+
if (!secret || !site || !store) return json(503, { error: "not_configured" });
|
|
26
|
+
const body = await req.text();
|
|
27
|
+
if (Buffer.byteLength(body) > MAX_BODY_BYTES) return json(413, { error: "too_large" });
|
|
28
|
+
const now = deps.now ?? Date.now;
|
|
29
|
+
const sig = verifyBody(req.headers.get(SIGNATURE_HEADER), body, secret, { now: Math.floor(now() / 1e3) });
|
|
30
|
+
if (!sig.ok) return json(401, {
|
|
31
|
+
error: "invalid_signature",
|
|
32
|
+
reason: sig.reason
|
|
33
|
+
});
|
|
34
|
+
if (!(deps.replay ?? sharedReplayGuard()).check(req.headers.get(DELIVERY_HEADER) ?? "")) return json(409, { error: "replayed" });
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(body);
|
|
38
|
+
} catch {
|
|
39
|
+
return json(400, { error: "invalid_json" });
|
|
40
|
+
}
|
|
41
|
+
if (!isSupportedProtocol(parsed?.pv)) return json(400, unsupportedProtocolBody());
|
|
42
|
+
const r = syncWebhookSchema.safeParse(parsed);
|
|
43
|
+
if (!r.success) return json(400, { error: "invalid" });
|
|
44
|
+
const current = store.current();
|
|
45
|
+
if (r.data.type === "ping") return json(202, {
|
|
46
|
+
ok: true,
|
|
47
|
+
ver: current?.ver ?? null,
|
|
48
|
+
protocol: { supported: SUPPORTED_PROTOCOLS }
|
|
49
|
+
});
|
|
50
|
+
if (r.data.site !== site) return json(403, { error: "site_mismatch" });
|
|
51
|
+
if (!(!current || r.data.ver > current.ver || r.data.ver === current.ver && r.data.iat > current.iat)) return json(202, {
|
|
52
|
+
accepted: false,
|
|
53
|
+
reason: "current"
|
|
54
|
+
});
|
|
55
|
+
const work = () => throttledRefresh(store, deps);
|
|
56
|
+
(deps.defer ?? deferAfter)(work);
|
|
57
|
+
return json(202, { accepted: true });
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Refreshes at most once per {@link SYNC_MIN_INTERVAL_MS}. A call inside the
|
|
61
|
+
* window waits for its end (calls that arrive meanwhile share that refresh),
|
|
62
|
+
* so the last change is always pulled.
|
|
63
|
+
*/
|
|
64
|
+
function throttledRefresh(store, deps) {
|
|
65
|
+
const t = deps.throttle ?? sharedThrottle();
|
|
66
|
+
if (t.waiting) return t.waiting;
|
|
67
|
+
const now = deps.now ?? Date.now;
|
|
68
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
69
|
+
const wait = Math.max(0, t.last + SYNC_MIN_INTERVAL_MS - now());
|
|
70
|
+
const run = async () => {
|
|
71
|
+
if (wait > 0) await sleep(wait);
|
|
72
|
+
t.waiting = null;
|
|
73
|
+
t.last = now();
|
|
74
|
+
const before = store.token();
|
|
75
|
+
await store.refresh({ force: true });
|
|
76
|
+
const after = store.token();
|
|
77
|
+
if (after && after !== before) await (deps.onSynced ?? revalidateBundle)(after);
|
|
78
|
+
};
|
|
79
|
+
const p = run().catch((e) => logRateLimited("sync", "sync refresh failed", e));
|
|
80
|
+
t.waiting = wait > 0 ? p : null;
|
|
81
|
+
return p;
|
|
82
|
+
}
|
|
83
|
+
async function deferAfter(task) {
|
|
84
|
+
try {
|
|
85
|
+
const { after } = await import("next/server");
|
|
86
|
+
after(task);
|
|
87
|
+
} catch {
|
|
88
|
+
task();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Drops cached render-side bundle fetches (and pages that used them). */
|
|
92
|
+
async function revalidateBundle() {
|
|
93
|
+
try {
|
|
94
|
+
const { revalidateTag } = await import("next/cache");
|
|
95
|
+
revalidateTag(BUNDLE_TAG, { expire: 0 });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
logRateLimited("sync:revalidate", "revalidateTag failed", e);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export { BUNDLE_TAG, SYNC_MIN_INTERVAL_MS, createSyncThrottle, handleSync, revalidateBundle };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const defaultDiskDir: () => string;
|
|
2
|
+
export declare const diskPath: (dir: string, site: string) => string;
|
|
3
|
+
/** The cached token, or null (missing or unreadable). */
|
|
4
|
+
export declare function readDisk(path: string): string | null;
|
|
5
|
+
/** Atomic write (temp file + rename). Never throws. */
|
|
6
|
+
export declare function writeDisk(path: string, token: string): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
// src/store/disk.ts
|
|
5
|
+
const defaultDiskDir = () => tmpdir();
|
|
6
|
+
const diskPath = (dir, site) => join(dir, `evolve-${site}.jws`);
|
|
7
|
+
/** The cached token, or null (missing or unreadable). */
|
|
8
|
+
function readDisk(path) {
|
|
9
|
+
try {
|
|
10
|
+
return readFileSync(path, "utf8").trim() || null;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Atomic write (temp file + rename). Never throws. */
|
|
16
|
+
function writeDisk(path, token) {
|
|
17
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
18
|
+
try {
|
|
19
|
+
writeFileSync(tmp, token, { mode: 384 });
|
|
20
|
+
renameSync(tmp, path);
|
|
21
|
+
} catch {}
|
|
22
|
+
}
|
|
23
|
+
export { defaultDiskDir, diskPath, readDisk, writeDisk };
|
package/dist/store/fetch.d.ts
CHANGED
|
@@ -12,6 +12,10 @@ export type FetchBundleResult = {
|
|
|
12
12
|
/**
|
|
13
13
|
* `GET {cloud}/v1/bundle` with the secret key and `If-None-Match` (spec §7.5).
|
|
14
14
|
* Never throws. Verification is the caller's job.
|
|
15
|
+
*
|
|
16
|
+
* Uses node:http(s) unless a `fetch` is injected (tests): the render path
|
|
17
|
+
* refreshes through this too, and inside a static (ISR) render Next's patched
|
|
18
|
+
* `fetch` turns an uncached call into "dynamic usage" and fails the page.
|
|
15
19
|
*/
|
|
16
20
|
export declare function fetchBundle(opts: {
|
|
17
21
|
cloudUrl: string;
|
package/dist/store/fetch.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
import { request } from "node:http";
|
|
2
|
+
import { request as request$1 } from "node:https";
|
|
1
3
|
// src/store/fetch.ts
|
|
2
4
|
/**
|
|
3
5
|
* `GET {cloud}/v1/bundle` with the secret key and `If-None-Match` (spec §7.5).
|
|
4
6
|
* Never throws. Verification is the caller's job.
|
|
7
|
+
*
|
|
8
|
+
* Uses node:http(s) unless a `fetch` is injected (tests): the render path
|
|
9
|
+
* refreshes through this too, and inside a static (ISR) render Next's patched
|
|
10
|
+
* `fetch` turns an uncached call into "dynamic usage" and fails the page.
|
|
5
11
|
*/
|
|
6
12
|
async function fetchBundle(opts) {
|
|
7
|
-
const f = opts.fetch ??
|
|
13
|
+
const f = opts.fetch ?? httpFetch;
|
|
8
14
|
try {
|
|
9
15
|
const res = await f(new URL("/v1/bundle", opts.cloudUrl), {
|
|
10
16
|
headers: {
|
|
@@ -36,4 +42,33 @@ async function fetchBundle(opts) {
|
|
|
36
42
|
};
|
|
37
43
|
}
|
|
38
44
|
}
|
|
45
|
+
/** A tiny GET over node:http(s) with fetch's shape (the parts fetchBundle uses). */
|
|
46
|
+
function httpFetch(url, init) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const req = (url.protocol === "https:" ? request$1 : request)(url, {
|
|
49
|
+
method: "GET",
|
|
50
|
+
headers: init.headers,
|
|
51
|
+
signal: init.signal
|
|
52
|
+
}, (res) => {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
res.on("data", (c) => chunks.push(c));
|
|
55
|
+
res.on("error", reject);
|
|
56
|
+
res.on("end", () => {
|
|
57
|
+
const status = res.statusCode ?? 0;
|
|
58
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
59
|
+
resolve({
|
|
60
|
+
status,
|
|
61
|
+
ok: status >= 200 && status < 300,
|
|
62
|
+
text: async () => body,
|
|
63
|
+
headers: { get: (n) => {
|
|
64
|
+
const v = res.headers[n.toLowerCase()];
|
|
65
|
+
return Array.isArray(v) ? v[0] ?? null : v ?? null;
|
|
66
|
+
} }
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
req.on("error", reject);
|
|
71
|
+
req.end();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
39
74
|
export { fetchBundle };
|