next-evolve 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -2
  3. package/dist/_virtual/_rolldown/runtime.js +12 -0
  4. package/dist/aggregate/attribution.d.ts +40 -0
  5. package/dist/aggregate/attribution.js +115 -0
  6. package/dist/aggregate/run.d.ts +21 -0
  7. package/dist/aggregate/run.js +139 -0
  8. package/dist/cli/aggregate.d.ts +9 -0
  9. package/dist/cli/aggregate.js +104 -0
  10. package/dist/cli/args.d.ts +12 -0
  11. package/dist/cli/args.js +23 -0
  12. package/dist/cli/cloud.d.ts +41 -0
  13. package/dist/cli/cloud.js +74 -0
  14. package/dist/cli/connect.d.ts +8 -0
  15. package/dist/cli/connect.js +46 -0
  16. package/dist/cli/detect.d.ts +26 -0
  17. package/dist/cli/detect.js +97 -0
  18. package/dist/cli/doctor.d.ts +34 -0
  19. package/dist/cli/doctor.js +230 -0
  20. package/dist/cli/env-file.d.ts +4 -0
  21. package/dist/cli/env-file.js +15 -0
  22. package/dist/cli/files.d.ts +25 -0
  23. package/dist/cli/files.js +173 -0
  24. package/dist/cli/index.d.ts +1 -0
  25. package/dist/cli/index.js +81 -0
  26. package/dist/cli/init.d.ts +26 -0
  27. package/dist/cli/init.js +240 -0
  28. package/dist/cli/load-app.d.ts +19 -0
  29. package/dist/cli/load-app.js +67 -0
  30. package/dist/cli/migrate.d.ts +7 -0
  31. package/dist/cli/migrate.js +30 -0
  32. package/dist/cli/pages.d.ts +17 -0
  33. package/dist/cli/pages.js +304 -0
  34. package/dist/cli/plan.d.ts +27 -0
  35. package/dist/cli/plan.js +129 -0
  36. package/dist/cli/pull.d.ts +13 -0
  37. package/dist/cli/pull.js +62 -0
  38. package/dist/cli/push.d.ts +14 -0
  39. package/dist/cli/push.js +33 -0
  40. package/dist/client/beacon.d.ts +11 -0
  41. package/dist/client/beacon.js +51 -0
  42. package/dist/client/evolve-script.d.ts +6 -0
  43. package/dist/client/evolve-script.js +60 -0
  44. package/dist/client/index.d.ts +3 -0
  45. package/dist/client/index.js +3 -0
  46. package/dist/cloud/manifest.d.ts +14 -0
  47. package/dist/cloud/manifest.js +46 -0
  48. package/dist/cloud/push.d.ts +21 -0
  49. package/dist/cloud/push.js +56 -0
  50. package/dist/cloud/regions.d.ts +8 -0
  51. package/dist/cloud/regions.js +25 -0
  52. package/dist/config/config.d.ts +51 -0
  53. package/dist/config/config.js +47 -0
  54. package/dist/config/goals.d.ts +50 -0
  55. package/dist/config/goals.js +85 -0
  56. package/dist/config/index.d.ts +3 -0
  57. package/dist/config/index.js +3 -0
  58. package/dist/core/assign.d.ts +35 -0
  59. package/dist/core/assign.js +104 -0
  60. package/dist/core/bots.d.ts +5 -0
  61. package/dist/core/bots.js +25 -0
  62. package/dist/core/constants.d.ts +3 -0
  63. package/dist/core/constants.js +5 -0
  64. package/dist/core/cookies.d.ts +12 -0
  65. package/dist/core/cookies.js +25 -0
  66. package/dist/core/keys.d.ts +14 -0
  67. package/dist/core/keys.js +38 -0
  68. package/dist/core/log.d.ts +2 -0
  69. package/dist/core/log.js +10 -0
  70. package/dist/db/client.d.ts +22 -0
  71. package/dist/db/client.js +50 -0
  72. package/dist/db/migrate.d.ts +18 -0
  73. package/dist/db/migrate.js +43 -0
  74. package/dist/db/writer.d.ts +53 -0
  75. package/dist/db/writer.js +182 -0
  76. package/dist/index.d.ts +5 -0
  77. package/dist/index.js +5 -0
  78. package/dist/instrumentation/index.d.ts +24 -0
  79. package/dist/instrumentation/index.js +54 -0
  80. package/dist/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/typescript.d.ts +11169 -0
  81. package/dist/proxy/index.d.ts +2 -0
  82. package/dist/proxy/index.js +1 -0
  83. package/dist/proxy/proxy.d.ts +24 -0
  84. package/dist/proxy/proxy.js +120 -0
  85. package/dist/react/context.d.ts +6 -0
  86. package/dist/react/context.js +8 -0
  87. package/dist/react/optimizable.d.ts +37 -0
  88. package/dist/react/optimizable.js +38 -0
  89. package/dist/react/region.d.ts +17 -0
  90. package/dist/react/region.js +127 -0
  91. package/dist/react/render-bundle.d.ts +15 -0
  92. package/dist/react/render-bundle.js +43 -0
  93. package/dist/react/with-evolve.d.ts +29 -0
  94. package/dist/react/with-evolve.js +57 -0
  95. package/dist/server/aggregate-route.d.ts +16 -0
  96. package/dist/server/aggregate-route.js +68 -0
  97. package/dist/server/collect.d.ts +62 -0
  98. package/dist/server/collect.js +157 -0
  99. package/dist/server/events.d.ts +18 -0
  100. package/dist/server/events.js +50 -0
  101. package/dist/server/evolve.d.ts +20 -0
  102. package/dist/server/evolve.js +57 -0
  103. package/dist/server/handler.d.ts +17 -0
  104. package/dist/server/handler.js +105 -0
  105. package/dist/server/index.d.ts +4 -0
  106. package/dist/server/index.js +4 -0
  107. package/dist/server/runtime.d.ts +26 -0
  108. package/dist/server/runtime.js +45 -0
  109. package/dist/server/sync-route.d.ts +28 -0
  110. package/dist/server/sync-route.js +100 -0
  111. package/dist/server/track-route.d.ts +25 -0
  112. package/dist/server/track-route.js +49 -0
  113. package/dist/store/disk.d.ts +6 -0
  114. package/dist/store/disk.js +23 -0
  115. package/dist/store/fetch.d.ts +26 -0
  116. package/dist/store/fetch.js +74 -0
  117. package/dist/store/global-config.d.ts +39 -0
  118. package/dist/store/global-config.js +122 -0
  119. package/dist/store/memory.d.ts +39 -0
  120. package/dist/store/memory.js +72 -0
  121. package/dist/store/runtime.d.ts +44 -0
  122. package/dist/store/runtime.js +101 -0
  123. package/migrations/0001_init.sql +56 -0
  124. package/package.json +73 -3
@@ -0,0 +1,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,25 @@
1
+ import { Goals } from "../config/goals.js";
2
+ import { Writer } from "../db/writer.js";
3
+ import { z } from "zod";
4
+ export declare const MAX_TRACK_EVENTS = 500;
5
+ export declare const trackBodySchema: z.ZodObject<{
6
+ events: z.ZodArray<z.ZodObject<{
7
+ name: z.ZodString;
8
+ userId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
9
+ anonId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
10
+ value: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
11
+ props: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
12
+ source: z.ZodOptional<z.ZodString>;
13
+ ts: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
14
+ dedupeKey: z.ZodOptional<z.ZodNullable<z.ZodString>>;
15
+ }, z.core.$strip>>;
16
+ }, z.core.$strip>;
17
+ /**
18
+ * `POST /api/evolve/track`: server-to-server events (Segment, a CRM, a
19
+ * webhook relay) with `Authorization: Bearer $EVOLVE_TRACK_SECRET`.
20
+ */
21
+ export declare function handleTrack(req: Request, deps: {
22
+ secret: string | undefined;
23
+ goals: Goals | undefined;
24
+ writer: Writer;
25
+ }): Promise<Response>;
@@ -0,0 +1,49 @@
1
+ import { eventRow } from "./events.js";
2
+ import { z } from "zod";
3
+ import { timingSafeEqual } from "node:crypto";
4
+ // src/server/track-route.ts
5
+ const MAX_TRACK_EVENTS = 500;
6
+ const trackEvent = z.object({
7
+ name: z.string().min(1).max(100),
8
+ userId: z.string().max(256).nullish(),
9
+ anonId: z.string().max(64).nullish(),
10
+ value: z.number().finite().nullish(),
11
+ props: z.record(z.string(), z.unknown()).nullish(),
12
+ source: z.string().max(64).optional(),
13
+ ts: z.union([z.string(), z.number()]).optional(),
14
+ dedupeKey: z.string().max(200).nullish()
15
+ });
16
+ const trackBodySchema = z.object({ events: z.array(trackEvent).min(1).max(500) });
17
+ function bearerOk(req, secret) {
18
+ const h = req.headers.get("authorization") ?? "";
19
+ const got = Buffer.from(h.startsWith("Bearer ") ? h.slice(7) : "");
20
+ const want = Buffer.from(secret);
21
+ return got.length === want.length && timingSafeEqual(got, want);
22
+ }
23
+ /**
24
+ * `POST /api/evolve/track`: server-to-server events (Segment, a CRM, a
25
+ * webhook relay) with `Authorization: Bearer $EVOLVE_TRACK_SECRET`.
26
+ */
27
+ async function handleTrack(req, deps) {
28
+ if (!deps.secret) return Response.json({ error: "track_disabled" }, { status: 404 });
29
+ if (!bearerOk(req, deps.secret)) return Response.json({ error: "unauthorized" }, { status: 401 });
30
+ let body;
31
+ try {
32
+ const r = trackBodySchema.safeParse(await req.json());
33
+ if (!r.success) return Response.json({
34
+ error: "invalid_body",
35
+ issues: r.error.issues.slice(0, 5)
36
+ }, { status: 400 });
37
+ body = r.data;
38
+ } catch {
39
+ return Response.json({ error: "invalid_json" }, { status: 400 });
40
+ }
41
+ const rows = body.events.flatMap((e) => {
42
+ const row = eventRow(e.name, e, deps.goals, "webhook");
43
+ return row ? [row] : [];
44
+ });
45
+ deps.writer.events(rows);
46
+ await deps.writer.flush();
47
+ return Response.json({ accepted: rows.length });
48
+ }
49
+ export { MAX_TRACK_EVENTS, handleTrack, trackBodySchema };
@@ -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 };
@@ -0,0 +1,26 @@
1
+ export type FetchBundleResult = {
2
+ status: "ok";
3
+ token: string;
4
+ etag: string | null;
5
+ } | {
6
+ status: "not_modified";
7
+ } | {
8
+ status: "error";
9
+ code: number | "network";
10
+ message?: string;
11
+ };
12
+ /**
13
+ * `GET {cloud}/v1/bundle` with the secret key and `If-None-Match` (spec §7.5).
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.
19
+ */
20
+ export declare function fetchBundle(opts: {
21
+ cloudUrl: string;
22
+ secretKey: string;
23
+ etag?: string | null;
24
+ timeoutMs?: number;
25
+ fetch?: typeof fetch;
26
+ }): Promise<FetchBundleResult>;
@@ -0,0 +1,74 @@
1
+ import { request } from "node:http";
2
+ import { request as request$1 } from "node:https";
3
+ // src/store/fetch.ts
4
+ /**
5
+ * `GET {cloud}/v1/bundle` with the secret key and `If-None-Match` (spec §7.5).
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.
11
+ */
12
+ async function fetchBundle(opts) {
13
+ const f = opts.fetch ?? httpFetch;
14
+ try {
15
+ const res = await f(new URL("/v1/bundle", opts.cloudUrl), {
16
+ headers: {
17
+ authorization: `Bearer ${opts.secretKey}`,
18
+ ...opts.etag ? { "if-none-match": opts.etag } : {}
19
+ },
20
+ cache: "no-store",
21
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 5e3)
22
+ });
23
+ if (res.status === 304) return { status: "not_modified" };
24
+ if (!res.ok) {
25
+ const body = await res.text().catch(() => "");
26
+ return {
27
+ status: "error",
28
+ code: res.status,
29
+ message: body.slice(0, 200)
30
+ };
31
+ }
32
+ return {
33
+ status: "ok",
34
+ token: (await res.text()).trim(),
35
+ etag: res.headers.get("etag")
36
+ };
37
+ } catch (e) {
38
+ return {
39
+ status: "error",
40
+ code: "network",
41
+ message: String(e)
42
+ };
43
+ }
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
+ }
74
+ export { fetchBundle };
@@ -0,0 +1,39 @@
1
+ import { FetchBundleResult } from "./fetch.js";
2
+ type Env = Record<string, string | undefined>;
3
+ /** How often instances re-read Global Config. */
4
+ export declare const GLOBAL_CONFIG_TTL_MS = 10000;
5
+ /** Max wait for the first read on a cold start; then the build bundle serves. */
6
+ export declare const COLD_READ_CAP_MS = 20;
7
+ export declare const globalConfigItemKey: (site: string) => string;
8
+ export type GlobalConfigSettings = {
9
+ connectionString: string;
10
+ /** Write access (sync route only). */
11
+ id?: string;
12
+ apiToken?: string;
13
+ teamId?: string;
14
+ };
15
+ /**
16
+ * Global Config settings when the app opted in: `store: "global-config"`, or
17
+ * `"auto"` with `EVOLVE_GLOBAL_CONFIG` (connection string) set.
18
+ */
19
+ export declare function globalConfigSettings(store: "auto" | "memory" | "global-config" | undefined, env: Env): GlobalConfigSettings | null;
20
+ export type GlobalConfigReader = (key: string) => Promise<unknown>;
21
+ export declare function defaultReader(connectionString: string): GlobalConfigReader;
22
+ /**
23
+ * Fetcher for the memory store: reads Global Config every call; also asks the
24
+ * cloud when Global Config is empty or every `cloudEveryMs` (so a missed
25
+ * webhook or a stale item can't freeze the app), and returns the newer token.
26
+ */
27
+ export declare function createGlobalConfigFetcher(opts: {
28
+ site: string;
29
+ read: GlobalConfigReader;
30
+ cloud?: (etag: string | null) => Promise<FetchBundleResult>;
31
+ cloudEveryMs: number;
32
+ now?: () => number;
33
+ }): (etag: string | null) => Promise<FetchBundleResult>;
34
+ /**
35
+ * Writes the token to the site's item via the Vercel REST API (sync route).
36
+ * Never throws; returns whether the write succeeded.
37
+ */
38
+ export declare function writeGlobalConfig(settings: GlobalConfigSettings, site: string, token: string, f?: typeof fetch): Promise<boolean>;
39
+ export {};
@@ -0,0 +1,122 @@
1
+ // src/store/global-config.ts
2
+ /** How often instances re-read Global Config. */
3
+ const GLOBAL_CONFIG_TTL_MS = 1e4;
4
+ /** Max wait for the first read on a cold start; then the build bundle serves. */
5
+ const COLD_READ_CAP_MS = 20;
6
+ const globalConfigItemKey = (site) => `evolve_bundle_${site}`;
7
+ /**
8
+ * Global Config settings when the app opted in: `store: "global-config"`, or
9
+ * `"auto"` with `EVOLVE_GLOBAL_CONFIG` (connection string) set.
10
+ */
11
+ function globalConfigSettings(store, env) {
12
+ if (store === "memory") return null;
13
+ const connectionString = env.EVOLVE_GLOBAL_CONFIG;
14
+ if (!connectionString) {
15
+ if (store === "global-config") console.error("[evolve] store: \"global-config\" needs EVOLVE_GLOBAL_CONFIG; using memory");
16
+ return null;
17
+ }
18
+ return {
19
+ connectionString,
20
+ id: env.EVOLVE_GLOBAL_CONFIG_ID,
21
+ apiToken: env.EVOLVE_VERCEL_API_TOKEN,
22
+ teamId: env.EVOLVE_VERCEL_TEAM_ID
23
+ };
24
+ }
25
+ function defaultReader(connectionString) {
26
+ let client = null;
27
+ return async (key) => {
28
+ client ??= import("@vercel/global-config").then((m) => m.createClient(connectionString));
29
+ return (await client).get(key);
30
+ };
31
+ }
32
+ /** `"<ver>.<iat>"` of a token, without verifying it (verification is the store's job). */
33
+ function versionOf(token) {
34
+ try {
35
+ const p = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
36
+ return typeof p.ver === "number" && typeof p.iat === "number" ? p : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+ const newer = (a, b) => {
42
+ const x = versionOf(a);
43
+ const y = versionOf(b);
44
+ if (!x) return b;
45
+ if (!y) return a;
46
+ return x.ver > y.ver || x.ver === y.ver && x.iat >= y.iat ? a : b;
47
+ };
48
+ /**
49
+ * Fetcher for the memory store: reads Global Config every call; also asks the
50
+ * cloud when Global Config is empty or every `cloudEveryMs` (so a missed
51
+ * webhook or a stale item can't freeze the app), and returns the newer token.
52
+ */
53
+ function createGlobalConfigFetcher(opts) {
54
+ const now = opts.now ?? Date.now;
55
+ const key = globalConfigItemKey(opts.site);
56
+ let lastCloud = -Infinity;
57
+ return async (etag) => {
58
+ let fromConfig = null;
59
+ try {
60
+ const v = await opts.read(key);
61
+ fromConfig = typeof v === "string" && v ? v : null;
62
+ } catch (e) {
63
+ if (!opts.cloud) return {
64
+ status: "error",
65
+ code: "network",
66
+ message: String(e)
67
+ };
68
+ }
69
+ let fromCloud = null;
70
+ if (opts.cloud && (!fromConfig || now() - lastCloud >= opts.cloudEveryMs)) {
71
+ lastCloud = now();
72
+ const r = await opts.cloud(fromConfig ? null : etag);
73
+ if (r.status === "ok") fromCloud = r.token;
74
+ else if (!fromConfig) return r;
75
+ }
76
+ const best = fromConfig && fromCloud ? newer(fromConfig, fromCloud) : fromConfig ?? fromCloud;
77
+ if (!best) return { status: "not_modified" };
78
+ const v = versionOf(best);
79
+ if (v && etag === `"${v.ver}.${v.iat}"`) return { status: "not_modified" };
80
+ return {
81
+ status: "ok",
82
+ token: best,
83
+ etag: null
84
+ };
85
+ };
86
+ }
87
+ /**
88
+ * Writes the token to the site's item via the Vercel REST API (sync route).
89
+ * Never throws; returns whether the write succeeded.
90
+ */
91
+ async function writeGlobalConfig(settings, site, token, f = fetch) {
92
+ if (!settings.id || !settings.apiToken) {
93
+ console.error("[evolve] Global Config is read-only here: set EVOLVE_GLOBAL_CONFIG_ID and EVOLVE_VERCEL_API_TOKEN");
94
+ return false;
95
+ }
96
+ const url = new URL(`https://api.vercel.com/v1/edge-config/${settings.id}/items`);
97
+ if (settings.teamId) url.searchParams.set("teamId", settings.teamId);
98
+ try {
99
+ const res = await f(url, {
100
+ method: "PATCH",
101
+ headers: {
102
+ authorization: `Bearer ${settings.apiToken}`,
103
+ "content-type": "application/json"
104
+ },
105
+ body: JSON.stringify({ items: [{
106
+ operation: "upsert",
107
+ key: globalConfigItemKey(site),
108
+ value: token
109
+ }] }),
110
+ signal: AbortSignal.timeout(1e4)
111
+ });
112
+ if (!res.ok) {
113
+ const body = await res.text().catch(() => "");
114
+ console.error(`[evolve] Global Config write failed (${res.status})`, body.slice(0, 200));
115
+ }
116
+ return res.ok;
117
+ } catch (e) {
118
+ console.error("[evolve] Global Config write failed", e);
119
+ return false;
120
+ }
121
+ }
122
+ export { COLD_READ_CAP_MS, GLOBAL_CONFIG_TTL_MS, createGlobalConfigFetcher, defaultReader, globalConfigItemKey, globalConfigSettings, writeGlobalConfig };
@@ -0,0 +1,39 @@
1
+ import { FetchBundleResult } from "./fetch.js";
2
+ import { BundlePayload, KeyRing } from "@next-evolve/protocol";
3
+ export type BundleStore = {
4
+ /**
5
+ * Synchronous read for the request path: the current verified, unexpired
6
+ * bundle or null. May start a background refresh, handed to `waitUntil`.
7
+ */
8
+ peek(waitUntil?: (p: Promise<unknown>) => void): BundlePayload | null;
9
+ /** Verifies and installs a token (build bundle, disk, sync route). Returns whether it was accepted. */
10
+ load(token: string): boolean;
11
+ /**
12
+ * Fetches now (one in flight). Resolves once done. `force`: a fetch already
13
+ * in flight may predate a publish, so wait for it and fetch again.
14
+ */
15
+ refresh(opts?: {
16
+ force?: boolean;
17
+ }): Promise<void>;
18
+ /** The current bundle without side effects (no refresh; expiry not applied). */
19
+ current(): BundlePayload | null;
20
+ /** Raw token of the current bundle (for persistence). */
21
+ token(): string | null;
22
+ };
23
+ export type MemoryStoreOptions = {
24
+ site: string;
25
+ keys: KeyRing;
26
+ /** Fetches from the cloud. Omit for a static store (build bundle only). */
27
+ fetcher?: (etag: string | null) => Promise<FetchBundleResult>;
28
+ pollMs: number;
29
+ initialToken?: string | null;
30
+ now?: () => number;
31
+ random?: () => number;
32
+ /** Called with each token accepted after creation (persistence). */
33
+ onAccept?: (token: string) => void;
34
+ };
35
+ /**
36
+ * In-memory bundle store with stale-while-revalidate polling (spec §7.5):
37
+ * never blocks a request, one fetch in flight, ±10% jitter.
38
+ */
39
+ export declare function createMemoryStore(opts: MemoryStoreOptions): BundleStore;
@@ -0,0 +1,72 @@
1
+ import { logRateLimited } from "../core/log.js";
2
+ import { verifyBundle } from "@next-evolve/protocol";
3
+ // src/store/memory.ts
4
+ /**
5
+ * In-memory bundle store with stale-while-revalidate polling (spec §7.5):
6
+ * never blocks a request, one fetch in flight, ±10% jitter.
7
+ */
8
+ function createMemoryStore(opts) {
9
+ const now = opts.now ?? Date.now;
10
+ const random = opts.random ?? Math.random;
11
+ let current = null;
12
+ let currentToken = null;
13
+ let etag = null;
14
+ let nextCheck = 0;
15
+ let inflight = null;
16
+ const schedule = () => {
17
+ nextCheck = now() + opts.pollMs * (.9 + .2 * random());
18
+ };
19
+ let ready = false;
20
+ function load(token) {
21
+ const r = verifyBundle(token, {
22
+ keys: opts.keys,
23
+ site: opts.site,
24
+ current: current ? {
25
+ ver: current.ver,
26
+ exp: current.exp
27
+ } : null,
28
+ now: Math.floor(now() / 1e3)
29
+ });
30
+ if (!r.ok) {
31
+ if (r.reason !== "rollback") logRateLimited(`bundle:${r.reason}`, `bundle rejected: ${r.reason}`);
32
+ return false;
33
+ }
34
+ current = r.payload;
35
+ currentToken = token;
36
+ etag = `"${r.payload.ver}.${r.payload.iat}"`;
37
+ if (ready) opts.onAccept?.(token);
38
+ return true;
39
+ }
40
+ function refresh(o = {}) {
41
+ if (!opts.fetcher) return Promise.resolve();
42
+ if (inflight) return o.force ? inflight.then(() => refresh()) : inflight;
43
+ schedule();
44
+ inflight = opts.fetcher(etag).then((res) => {
45
+ if (res.status === "ok") load(res.token);
46
+ else if (res.status === "error") logRateLimited(`fetch:${res.code}`, `bundle fetch failed (${res.code})`, res.message);
47
+ }).catch((e) => logRateLimited("fetch:throw", "bundle fetch failed", e)).finally(() => {
48
+ inflight = null;
49
+ });
50
+ return inflight;
51
+ }
52
+ if (opts.initialToken) load(opts.initialToken);
53
+ ready = true;
54
+ return {
55
+ peek(waitUntil) {
56
+ if (current && now() >= current.exp * 1e3) {
57
+ current = null;
58
+ currentToken = null;
59
+ }
60
+ if (opts.fetcher && !inflight && now() >= nextCheck) {
61
+ const p = refresh();
62
+ waitUntil?.(p);
63
+ }
64
+ return current;
65
+ },
66
+ load,
67
+ refresh,
68
+ current: () => current,
69
+ token: () => currentToken
70
+ };
71
+ }
72
+ export { createMemoryStore };
@@ -0,0 +1,44 @@
1
+ import { GlobalConfigReader } from "./global-config.js";
2
+ import { BundleStore } from "./memory.js";
3
+ import { KeyRing } from "@next-evolve/protocol";
4
+ type Env = Record<string, string | undefined>;
5
+ export type RuntimeOptions = {
6
+ /** Signed bundle written at build time by `evolve pull` (cold-start fallback). */
7
+ buildBundle?: string | null;
8
+ /** Inject a store (tests). */
9
+ store?: BundleStore;
10
+ env?: Env;
11
+ /** Global Config reader (tests). */
12
+ globalConfigReader?: GlobalConfigReader;
13
+ /** Disk cache directory on long-running Node (default: $EVOLVE_CACHE_DIR, else os.tmpdir()). */
14
+ diskDir?: string;
15
+ };
16
+ /** True off serverless hosts: a process that lives across requests (next start, Docker). */
17
+ export declare function isLongRunningNode(env?: Env): boolean;
18
+ /** Default cloud URL (decision D29). Override with EVOLVE_CLOUD_URL. */
19
+ export declare const DEFAULT_CLOUD_URL: string | undefined;
20
+ /**
21
+ * The process-wide bundle store for this site, shared by the proxy and the
22
+ * render path when they run in the same process. Null when the app has no
23
+ * valid secret key (then everything serves control).
24
+ */
25
+ export declare function getBundleStore(config: {
26
+ pollSeconds?: number;
27
+ store?: "auto" | "memory" | "global-config";
28
+ }, opts?: RuntimeOptions): BundleStore | null;
29
+ /**
30
+ * Global Config hosts, first request of an instance: a promise that resolves
31
+ * after one Global Config read or {@link COLD_READ_CAP_MS}, whichever comes
32
+ * first (spec §7.8). Null when there's nothing to wait for.
33
+ */
34
+ export declare function coldStart(store: BundleStore): Promise<void> | null;
35
+ /** This app's site id (from EVOLVE_SECRET_KEY) and trusted public keys. Cached; null without a valid key. */
36
+ export declare function getSiteIdentity(opts?: {
37
+ env?: Env;
38
+ }): {
39
+ site: string;
40
+ keys: KeyRing;
41
+ } | null;
42
+ /** Test helper: forget all stores. */
43
+ export declare function resetBundleStores(): void;
44
+ export {};