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.
Files changed (71) hide show
  1. package/README.md +55 -1
  2. package/dist/_virtual/_rolldown/runtime.js +12 -0
  3. package/dist/aggregate/attribution.d.ts +40 -0
  4. package/dist/aggregate/attribution.js +115 -0
  5. package/dist/aggregate/run.d.ts +21 -0
  6. package/dist/aggregate/run.js +139 -0
  7. package/dist/cli/aggregate.d.ts +9 -0
  8. package/dist/cli/aggregate.js +104 -0
  9. package/dist/cli/args.d.ts +12 -0
  10. package/dist/cli/args.js +23 -0
  11. package/dist/cli/cloud.d.ts +41 -0
  12. package/dist/cli/cloud.js +74 -0
  13. package/dist/cli/connect.d.ts +8 -0
  14. package/dist/cli/connect.js +46 -0
  15. package/dist/cli/detect.d.ts +26 -0
  16. package/dist/cli/detect.js +97 -0
  17. package/dist/cli/doctor.d.ts +34 -0
  18. package/dist/cli/doctor.js +230 -0
  19. package/dist/cli/env-file.d.ts +4 -0
  20. package/dist/cli/env-file.js +15 -0
  21. package/dist/cli/files.d.ts +25 -0
  22. package/dist/cli/files.js +173 -0
  23. package/dist/cli/index.js +73 -32
  24. package/dist/cli/init.d.ts +26 -0
  25. package/dist/cli/init.js +240 -0
  26. package/dist/cli/load-app.d.ts +19 -0
  27. package/dist/cli/load-app.js +67 -0
  28. package/dist/cli/migrate.d.ts +7 -0
  29. package/dist/cli/migrate.js +30 -0
  30. package/dist/cli/pages.d.ts +17 -0
  31. package/dist/cli/pages.js +304 -0
  32. package/dist/cli/plan.d.ts +27 -0
  33. package/dist/cli/plan.js +129 -0
  34. package/dist/cli/pull.d.ts +13 -0
  35. package/dist/cli/pull.js +62 -0
  36. package/dist/cli/push.d.ts +14 -0
  37. package/dist/cli/push.js +33 -0
  38. package/dist/cloud/manifest.d.ts +2 -0
  39. package/dist/cloud/manifest.js +6 -2
  40. package/dist/core/assign.d.ts +13 -1
  41. package/dist/core/assign.js +25 -3
  42. package/dist/db/writer.d.ts +2 -0
  43. package/dist/db/writer.js +1 -1
  44. package/dist/instrumentation/index.d.ts +3 -2
  45. package/dist/instrumentation/index.js +17 -2
  46. package/dist/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/typescript.d.ts +11169 -0
  47. package/dist/proxy/index.d.ts +2 -2
  48. package/dist/proxy/index.js +1 -1
  49. package/dist/proxy/proxy.d.ts +7 -1
  50. package/dist/proxy/proxy.js +23 -6
  51. package/dist/react/render-bundle.d.ts +10 -4
  52. package/dist/react/render-bundle.js +34 -5
  53. package/dist/server/aggregate-route.d.ts +16 -0
  54. package/dist/server/aggregate-route.js +68 -0
  55. package/dist/server/collect.d.ts +16 -0
  56. package/dist/server/collect.js +34 -8
  57. package/dist/server/handler.d.ts +3 -2
  58. package/dist/server/handler.js +37 -4
  59. package/dist/server/sync-route.d.ts +28 -0
  60. package/dist/server/sync-route.js +100 -0
  61. package/dist/store/disk.d.ts +6 -0
  62. package/dist/store/disk.js +23 -0
  63. package/dist/store/fetch.d.ts +4 -0
  64. package/dist/store/fetch.js +36 -1
  65. package/dist/store/global-config.d.ts +39 -0
  66. package/dist/store/global-config.js +122 -0
  67. package/dist/store/memory.d.ts +11 -2
  68. package/dist/store/memory.js +6 -2
  69. package/dist/store/runtime.d.ts +14 -0
  70. package/dist/store/runtime.js +39 -7
  71. package/package.json +2 -1
@@ -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 };
@@ -1 +1 @@
1
- export { applyEvolve, createEvolveProxy } from "./proxy.js";
1
+ export { applyEvolve, createEvolveProxy, evolveReady } from "./proxy.js";
@@ -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;
@@ -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 a = assign(bundle, path, cookie, opts.random ?? Math.random);
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 (!req.cookies.get("evolve_id")?.value) setCookies.push([ANON_COOKIE, newAnonId()]);
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
- return applyEvolve(config, req, event, opts) ?? NextResponse.next();
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
- export { PREVIEW_PARAM, applyEvolve, createEvolveProxy };
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. Phase 1 (M1): the process-wide store shared with the
4
- * proxy; `fresh` awaits a refetch first. T7.6 adds the tagged fetch for hosts
5
- * where render and proxy run in different processes.
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
- }): Promise<BundlePayload | null>;
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. Phase 1 (M1): the process-wide store shared with the
5
- * proxy; `fresh` awaits a refetch first. T7.6 adds the tagged fetch for hosts
6
- * where render and proxy run in different processes.
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 store = getBundleStore({});
13
+ const env = opts.env ?? process.env;
14
+ const store = getBundleStore({}, { env });
10
15
  if (!store) return null;
11
- if (opts.fresh) await store.refresh();
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 };
@@ -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
  /**
@@ -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
- const bundle = deps.bundle();
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
- 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({
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 };
@@ -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), `GET health`.
11
- * Sync and aggregate arrive with M3/M4.
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>;
@@ -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 { handleCollect } from "./collect.js";
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), `GET health`.
15
- * Sync and aggregate arrive with M3/M4.
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 };
@@ -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;
@@ -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 ?? 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 };