vinext 0.0.0 → 0.0.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 (272) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/dist/build/static-export.d.ts +78 -0
  4. package/dist/build/static-export.d.ts.map +1 -0
  5. package/dist/build/static-export.js +553 -0
  6. package/dist/build/static-export.js.map +1 -0
  7. package/dist/check.d.ts +52 -0
  8. package/dist/check.d.ts.map +1 -0
  9. package/dist/check.js +483 -0
  10. package/dist/check.js.map +1 -0
  11. package/dist/cli.d.ts +15 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +565 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/client/entry.d.ts +2 -0
  16. package/dist/client/entry.d.ts.map +1 -0
  17. package/dist/client/entry.js +85 -0
  18. package/dist/client/entry.js.map +1 -0
  19. package/dist/cloudflare/index.d.ts +8 -0
  20. package/dist/cloudflare/index.d.ts.map +1 -0
  21. package/dist/cloudflare/index.js +8 -0
  22. package/dist/cloudflare/index.js.map +1 -0
  23. package/dist/cloudflare/kv-cache-handler.d.ts +68 -0
  24. package/dist/cloudflare/kv-cache-handler.d.ts.map +1 -0
  25. package/dist/cloudflare/kv-cache-handler.js +304 -0
  26. package/dist/cloudflare/kv-cache-handler.js.map +1 -0
  27. package/dist/cloudflare/tpr.d.ts +78 -0
  28. package/dist/cloudflare/tpr.d.ts.map +1 -0
  29. package/dist/cloudflare/tpr.js +672 -0
  30. package/dist/cloudflare/tpr.js.map +1 -0
  31. package/dist/config/config-matchers.d.ts +106 -0
  32. package/dist/config/config-matchers.d.ts.map +1 -0
  33. package/dist/config/config-matchers.js +499 -0
  34. package/dist/config/config-matchers.js.map +1 -0
  35. package/dist/config/next-config.d.ts +153 -0
  36. package/dist/config/next-config.d.ts.map +1 -0
  37. package/dist/config/next-config.js +274 -0
  38. package/dist/config/next-config.js.map +1 -0
  39. package/dist/deploy.d.ts +87 -0
  40. package/dist/deploy.d.ts.map +1 -0
  41. package/dist/deploy.js +644 -0
  42. package/dist/deploy.js.map +1 -0
  43. package/dist/index.d.ts +156 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +3287 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/init.d.ts +55 -0
  48. package/dist/init.d.ts.map +1 -0
  49. package/dist/init.js +201 -0
  50. package/dist/init.js.map +1 -0
  51. package/dist/routing/app-router.d.ts +96 -0
  52. package/dist/routing/app-router.d.ts.map +1 -0
  53. package/dist/routing/app-router.js +815 -0
  54. package/dist/routing/app-router.js.map +1 -0
  55. package/dist/routing/pages-router.d.ts +52 -0
  56. package/dist/routing/pages-router.d.ts.map +1 -0
  57. package/dist/routing/pages-router.js +239 -0
  58. package/dist/routing/pages-router.js.map +1 -0
  59. package/dist/server/api-handler.d.ts +18 -0
  60. package/dist/server/api-handler.d.ts.map +1 -0
  61. package/dist/server/api-handler.js +169 -0
  62. package/dist/server/api-handler.js.map +1 -0
  63. package/dist/server/app-dev-server.d.ts +42 -0
  64. package/dist/server/app-dev-server.d.ts.map +1 -0
  65. package/dist/server/app-dev-server.js +2718 -0
  66. package/dist/server/app-dev-server.js.map +1 -0
  67. package/dist/server/app-router-entry.d.ts +18 -0
  68. package/dist/server/app-router-entry.d.ts.map +1 -0
  69. package/dist/server/app-router-entry.js +34 -0
  70. package/dist/server/app-router-entry.js.map +1 -0
  71. package/dist/server/dev-server.d.ts +40 -0
  72. package/dist/server/dev-server.d.ts.map +1 -0
  73. package/dist/server/dev-server.js +758 -0
  74. package/dist/server/dev-server.js.map +1 -0
  75. package/dist/server/html.d.ts +22 -0
  76. package/dist/server/html.d.ts.map +1 -0
  77. package/dist/server/html.js +29 -0
  78. package/dist/server/html.js.map +1 -0
  79. package/dist/server/image-optimization.d.ts +56 -0
  80. package/dist/server/image-optimization.d.ts.map +1 -0
  81. package/dist/server/image-optimization.js +103 -0
  82. package/dist/server/image-optimization.js.map +1 -0
  83. package/dist/server/instrumentation.d.ts +68 -0
  84. package/dist/server/instrumentation.d.ts.map +1 -0
  85. package/dist/server/instrumentation.js +90 -0
  86. package/dist/server/instrumentation.js.map +1 -0
  87. package/dist/server/isr-cache.d.ts +61 -0
  88. package/dist/server/isr-cache.d.ts.map +1 -0
  89. package/dist/server/isr-cache.js +134 -0
  90. package/dist/server/isr-cache.js.map +1 -0
  91. package/dist/server/metadata-routes.d.ts +103 -0
  92. package/dist/server/metadata-routes.d.ts.map +1 -0
  93. package/dist/server/metadata-routes.js +270 -0
  94. package/dist/server/metadata-routes.js.map +1 -0
  95. package/dist/server/middleware.d.ts +77 -0
  96. package/dist/server/middleware.d.ts.map +1 -0
  97. package/dist/server/middleware.js +228 -0
  98. package/dist/server/middleware.js.map +1 -0
  99. package/dist/server/prod-server.d.ts +78 -0
  100. package/dist/server/prod-server.d.ts.map +1 -0
  101. package/dist/server/prod-server.js +712 -0
  102. package/dist/server/prod-server.js.map +1 -0
  103. package/dist/shims/amp.d.ts +17 -0
  104. package/dist/shims/amp.d.ts.map +1 -0
  105. package/dist/shims/amp.js +21 -0
  106. package/dist/shims/amp.js.map +1 -0
  107. package/dist/shims/app.d.ts +12 -0
  108. package/dist/shims/app.d.ts.map +1 -0
  109. package/dist/shims/app.js +2 -0
  110. package/dist/shims/app.js.map +1 -0
  111. package/dist/shims/cache-runtime.d.ts +68 -0
  112. package/dist/shims/cache-runtime.d.ts.map +1 -0
  113. package/dist/shims/cache-runtime.js +437 -0
  114. package/dist/shims/cache-runtime.js.map +1 -0
  115. package/dist/shims/cache.d.ts +243 -0
  116. package/dist/shims/cache.d.ts.map +1 -0
  117. package/dist/shims/cache.js +415 -0
  118. package/dist/shims/cache.js.map +1 -0
  119. package/dist/shims/client-only.d.ts +18 -0
  120. package/dist/shims/client-only.d.ts.map +1 -0
  121. package/dist/shims/client-only.js +18 -0
  122. package/dist/shims/client-only.js.map +1 -0
  123. package/dist/shims/config.d.ts +27 -0
  124. package/dist/shims/config.d.ts.map +1 -0
  125. package/dist/shims/config.js +30 -0
  126. package/dist/shims/config.js.map +1 -0
  127. package/dist/shims/constants.d.ts +13 -0
  128. package/dist/shims/constants.d.ts.map +1 -0
  129. package/dist/shims/constants.js +13 -0
  130. package/dist/shims/constants.js.map +1 -0
  131. package/dist/shims/document.d.ts +33 -0
  132. package/dist/shims/document.d.ts.map +1 -0
  133. package/dist/shims/document.js +32 -0
  134. package/dist/shims/document.js.map +1 -0
  135. package/dist/shims/dynamic.d.ts +33 -0
  136. package/dist/shims/dynamic.d.ts.map +1 -0
  137. package/dist/shims/dynamic.js +148 -0
  138. package/dist/shims/dynamic.js.map +1 -0
  139. package/dist/shims/error-boundary.d.ts +33 -0
  140. package/dist/shims/error-boundary.d.ts.map +1 -0
  141. package/dist/shims/error-boundary.js +88 -0
  142. package/dist/shims/error-boundary.js.map +1 -0
  143. package/dist/shims/error.d.ts +16 -0
  144. package/dist/shims/error.d.ts.map +1 -0
  145. package/dist/shims/error.js +45 -0
  146. package/dist/shims/error.js.map +1 -0
  147. package/dist/shims/fetch-cache.d.ts +61 -0
  148. package/dist/shims/fetch-cache.d.ts.map +1 -0
  149. package/dist/shims/fetch-cache.js +307 -0
  150. package/dist/shims/fetch-cache.js.map +1 -0
  151. package/dist/shims/font-google.d.ts +122 -0
  152. package/dist/shims/font-google.d.ts.map +1 -0
  153. package/dist/shims/font-google.js +387 -0
  154. package/dist/shims/font-google.js.map +1 -0
  155. package/dist/shims/font-local.d.ts +61 -0
  156. package/dist/shims/font-local.d.ts.map +1 -0
  157. package/dist/shims/font-local.js +303 -0
  158. package/dist/shims/font-local.js.map +1 -0
  159. package/dist/shims/form.d.ts +30 -0
  160. package/dist/shims/form.d.ts.map +1 -0
  161. package/dist/shims/form.js +78 -0
  162. package/dist/shims/form.js.map +1 -0
  163. package/dist/shims/head-state.d.ts +11 -0
  164. package/dist/shims/head-state.d.ts.map +1 -0
  165. package/dist/shims/head-state.js +47 -0
  166. package/dist/shims/head-state.js.map +1 -0
  167. package/dist/shims/head.d.ts +28 -0
  168. package/dist/shims/head.d.ts.map +1 -0
  169. package/dist/shims/head.js +148 -0
  170. package/dist/shims/head.js.map +1 -0
  171. package/dist/shims/headers.d.ts +150 -0
  172. package/dist/shims/headers.d.ts.map +1 -0
  173. package/dist/shims/headers.js +412 -0
  174. package/dist/shims/headers.js.map +1 -0
  175. package/dist/shims/image-config.d.ts +30 -0
  176. package/dist/shims/image-config.d.ts.map +1 -0
  177. package/dist/shims/image-config.js +91 -0
  178. package/dist/shims/image-config.js.map +1 -0
  179. package/dist/shims/image.d.ts +63 -0
  180. package/dist/shims/image.d.ts.map +1 -0
  181. package/dist/shims/image.js +284 -0
  182. package/dist/shims/image.js.map +1 -0
  183. package/dist/shims/internal/api-utils.d.ts +12 -0
  184. package/dist/shims/internal/api-utils.d.ts.map +1 -0
  185. package/dist/shims/internal/api-utils.js +7 -0
  186. package/dist/shims/internal/api-utils.js.map +1 -0
  187. package/dist/shims/internal/app-router-context.d.ts +21 -0
  188. package/dist/shims/internal/app-router-context.d.ts.map +1 -0
  189. package/dist/shims/internal/app-router-context.js +15 -0
  190. package/dist/shims/internal/app-router-context.js.map +1 -0
  191. package/dist/shims/internal/cookies.d.ts +9 -0
  192. package/dist/shims/internal/cookies.d.ts.map +1 -0
  193. package/dist/shims/internal/cookies.js +9 -0
  194. package/dist/shims/internal/cookies.js.map +1 -0
  195. package/dist/shims/internal/router-context.d.ts +2 -0
  196. package/dist/shims/internal/router-context.d.ts.map +1 -0
  197. package/dist/shims/internal/router-context.js +9 -0
  198. package/dist/shims/internal/router-context.js.map +1 -0
  199. package/dist/shims/internal/utils.d.ts +48 -0
  200. package/dist/shims/internal/utils.d.ts.map +1 -0
  201. package/dist/shims/internal/utils.js +35 -0
  202. package/dist/shims/internal/utils.js.map +1 -0
  203. package/dist/shims/internal/work-unit-async-storage.d.ts +12 -0
  204. package/dist/shims/internal/work-unit-async-storage.d.ts.map +1 -0
  205. package/dist/shims/internal/work-unit-async-storage.js +13 -0
  206. package/dist/shims/internal/work-unit-async-storage.js.map +1 -0
  207. package/dist/shims/layout-segment-context.d.ts +21 -0
  208. package/dist/shims/layout-segment-context.d.ts.map +1 -0
  209. package/dist/shims/layout-segment-context.js +27 -0
  210. package/dist/shims/layout-segment-context.js.map +1 -0
  211. package/dist/shims/legacy-image.d.ts +52 -0
  212. package/dist/shims/legacy-image.d.ts.map +1 -0
  213. package/dist/shims/legacy-image.js +46 -0
  214. package/dist/shims/legacy-image.js.map +1 -0
  215. package/dist/shims/link.d.ts +48 -0
  216. package/dist/shims/link.d.ts.map +1 -0
  217. package/dist/shims/link.js +395 -0
  218. package/dist/shims/link.js.map +1 -0
  219. package/dist/shims/metadata.d.ts +184 -0
  220. package/dist/shims/metadata.d.ts.map +1 -0
  221. package/dist/shims/metadata.js +472 -0
  222. package/dist/shims/metadata.js.map +1 -0
  223. package/dist/shims/navigation-state.d.ts +14 -0
  224. package/dist/shims/navigation-state.d.ts.map +1 -0
  225. package/dist/shims/navigation-state.js +77 -0
  226. package/dist/shims/navigation-state.js.map +1 -0
  227. package/dist/shims/navigation.d.ts +201 -0
  228. package/dist/shims/navigation.d.ts.map +1 -0
  229. package/dist/shims/navigation.js +672 -0
  230. package/dist/shims/navigation.js.map +1 -0
  231. package/dist/shims/og.d.ts +20 -0
  232. package/dist/shims/og.d.ts.map +1 -0
  233. package/dist/shims/og.js +19 -0
  234. package/dist/shims/og.js.map +1 -0
  235. package/dist/shims/router-state.d.ts +11 -0
  236. package/dist/shims/router-state.d.ts.map +1 -0
  237. package/dist/shims/router-state.js +56 -0
  238. package/dist/shims/router-state.js.map +1 -0
  239. package/dist/shims/router.d.ts +103 -0
  240. package/dist/shims/router.d.ts.map +1 -0
  241. package/dist/shims/router.js +536 -0
  242. package/dist/shims/router.js.map +1 -0
  243. package/dist/shims/script.d.ts +58 -0
  244. package/dist/shims/script.d.ts.map +1 -0
  245. package/dist/shims/script.js +163 -0
  246. package/dist/shims/script.js.map +1 -0
  247. package/dist/shims/server-only.d.ts +19 -0
  248. package/dist/shims/server-only.d.ts.map +1 -0
  249. package/dist/shims/server-only.js +19 -0
  250. package/dist/shims/server-only.js.map +1 -0
  251. package/dist/shims/server.d.ts +178 -0
  252. package/dist/shims/server.d.ts.map +1 -0
  253. package/dist/shims/server.js +377 -0
  254. package/dist/shims/server.js.map +1 -0
  255. package/dist/shims/web-vitals.d.ts +24 -0
  256. package/dist/shims/web-vitals.d.ts.map +1 -0
  257. package/dist/shims/web-vitals.js +17 -0
  258. package/dist/shims/web-vitals.js.map +1 -0
  259. package/dist/utils/hash.d.ts +6 -0
  260. package/dist/utils/hash.d.ts.map +1 -0
  261. package/dist/utils/hash.js +20 -0
  262. package/dist/utils/hash.js.map +1 -0
  263. package/dist/utils/project.d.ts +36 -0
  264. package/dist/utils/project.d.ts.map +1 -0
  265. package/dist/utils/project.js +112 -0
  266. package/dist/utils/project.js.map +1 -0
  267. package/dist/utils/query.d.ts +10 -0
  268. package/dist/utils/query.d.ts.map +1 -0
  269. package/dist/utils/query.js +27 -0
  270. package/dist/utils/query.js.map +1 -0
  271. package/package.json +65 -7
  272. package/index.js +0 -1
@@ -0,0 +1,672 @@
1
+ /**
2
+ * TPR: Traffic-aware Pre-Rendering
3
+ *
4
+ * Uses Cloudflare zone analytics to determine which pages actually get
5
+ * traffic, and pre-renders only those during deploy. The pre-rendered
6
+ * HTML is uploaded to KV in the same format ISR uses at runtime — no
7
+ * runtime changes needed.
8
+ *
9
+ * Flow:
10
+ * 1. Parse wrangler config to find custom domain and KV namespace
11
+ * 2. Resolve the Cloudflare zone for the custom domain
12
+ * 3. Query zone analytics (GraphQL) for top pages by request count
13
+ * 4. Walk ranked list until coverage threshold is met
14
+ * 5. Start the built production server locally
15
+ * 6. Fetch each hot route to produce HTML
16
+ * 7. Upload pre-rendered HTML to KV (same KVCacheEntry format ISR reads)
17
+ *
18
+ * TPR is an experimental feature enabled via --experimental-tpr. It
19
+ * gracefully skips when no custom domain, no API token, no traffic data,
20
+ * or no KV namespace is configured.
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { spawn } from "node:child_process";
26
+ // ─── Wrangler Config Parsing ─────────────────────────────────────────────────
27
+ /**
28
+ * Parse wrangler config (JSONC or TOML) to extract the fields TPR needs:
29
+ * account_id, VINEXT_CACHE KV namespace ID, and custom domain.
30
+ */
31
+ export function parseWranglerConfig(root) {
32
+ // Try JSONC / JSON first
33
+ for (const filename of ["wrangler.jsonc", "wrangler.json"]) {
34
+ const filepath = path.join(root, filename);
35
+ if (fs.existsSync(filepath)) {
36
+ const content = fs.readFileSync(filepath, "utf-8");
37
+ try {
38
+ const json = JSON.parse(stripJsonComments(content));
39
+ return extractFromJSON(json);
40
+ }
41
+ catch {
42
+ continue;
43
+ }
44
+ }
45
+ }
46
+ // Try TOML
47
+ const tomlPath = path.join(root, "wrangler.toml");
48
+ if (fs.existsSync(tomlPath)) {
49
+ const content = fs.readFileSync(tomlPath, "utf-8");
50
+ return extractFromTOML(content);
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * Strip single-line (//) and multi-line comments from JSONC while
56
+ * preserving strings that contain slashes.
57
+ */
58
+ function stripJsonComments(str) {
59
+ let result = "";
60
+ let inString = false;
61
+ let inSingleLine = false;
62
+ let inMultiLine = false;
63
+ let escapeNext = false;
64
+ for (let i = 0; i < str.length; i++) {
65
+ const ch = str[i];
66
+ const next = str[i + 1];
67
+ if (escapeNext) {
68
+ if (!inSingleLine && !inMultiLine)
69
+ result += ch;
70
+ escapeNext = false;
71
+ continue;
72
+ }
73
+ if (ch === "\\" && inString) {
74
+ result += ch;
75
+ escapeNext = true;
76
+ continue;
77
+ }
78
+ if (inSingleLine) {
79
+ if (ch === "\n") {
80
+ inSingleLine = false;
81
+ result += ch;
82
+ }
83
+ continue;
84
+ }
85
+ if (inMultiLine) {
86
+ if (ch === "*" && next === "/") {
87
+ inMultiLine = false;
88
+ i++;
89
+ }
90
+ continue;
91
+ }
92
+ if (ch === '"' && !inString) {
93
+ inString = true;
94
+ result += ch;
95
+ continue;
96
+ }
97
+ if (ch === '"' && inString) {
98
+ inString = false;
99
+ result += ch;
100
+ continue;
101
+ }
102
+ if (!inString && ch === "/" && next === "/") {
103
+ inSingleLine = true;
104
+ i++;
105
+ continue;
106
+ }
107
+ if (!inString && ch === "/" && next === "*") {
108
+ inMultiLine = true;
109
+ i++;
110
+ continue;
111
+ }
112
+ result += ch;
113
+ }
114
+ return result;
115
+ }
116
+ function extractFromJSON(config) {
117
+ const result = {};
118
+ // account_id
119
+ if (typeof config.account_id === "string") {
120
+ result.accountId = config.account_id;
121
+ }
122
+ // KV namespace ID for VINEXT_CACHE
123
+ if (Array.isArray(config.kv_namespaces)) {
124
+ const vinextKV = config.kv_namespaces.find((ns) => ns && typeof ns === "object" && ns.binding === "VINEXT_CACHE");
125
+ if (vinextKV &&
126
+ typeof vinextKV.id === "string" &&
127
+ vinextKV.id !== "<your-kv-namespace-id>") {
128
+ result.kvNamespaceId = vinextKV.id;
129
+ }
130
+ }
131
+ // Custom domain — check routes[] and custom_domains[]
132
+ const domain = extractDomainFromRoutes(config.routes) ?? extractDomainFromCustomDomains(config);
133
+ if (domain)
134
+ result.customDomain = domain;
135
+ return result;
136
+ }
137
+ function extractDomainFromRoutes(routes) {
138
+ if (!Array.isArray(routes))
139
+ return null;
140
+ for (const route of routes) {
141
+ if (typeof route === "string") {
142
+ const domain = cleanDomain(route);
143
+ if (domain && !domain.includes("workers.dev"))
144
+ return domain;
145
+ }
146
+ else if (route && typeof route === "object") {
147
+ const r = route;
148
+ const pattern = typeof r.zone_name === "string"
149
+ ? r.zone_name
150
+ : typeof r.pattern === "string"
151
+ ? r.pattern
152
+ : null;
153
+ if (pattern) {
154
+ const domain = cleanDomain(pattern);
155
+ if (domain && !domain.includes("workers.dev"))
156
+ return domain;
157
+ }
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+ function extractDomainFromCustomDomains(config) {
163
+ // Workers Custom Domains: "custom_domains": ["example.com"]
164
+ if (Array.isArray(config.custom_domains)) {
165
+ for (const d of config.custom_domains) {
166
+ if (typeof d === "string" && !d.includes("workers.dev")) {
167
+ return cleanDomain(d);
168
+ }
169
+ }
170
+ }
171
+ return null;
172
+ }
173
+ /** Strip protocol and trailing wildcards from a route pattern to get a bare domain. */
174
+ function cleanDomain(raw) {
175
+ const cleaned = raw
176
+ .replace(/^https?:\/\//, "")
177
+ .replace(/\/\*$/, "")
178
+ .replace(/\/+$/, "")
179
+ .split("/")[0]; // Take only the host part
180
+ return cleaned || null;
181
+ }
182
+ /**
183
+ * Simple extraction of specific fields from wrangler.toml content.
184
+ * Not a full TOML parser — just enough for the fields we need.
185
+ */
186
+ function extractFromTOML(content) {
187
+ const result = {};
188
+ // account_id = "..."
189
+ const accountMatch = content.match(/^account_id\s*=\s*"([^"]+)"/m);
190
+ if (accountMatch)
191
+ result.accountId = accountMatch[1];
192
+ // KV namespace with binding = "VINEXT_CACHE"
193
+ // Look for [[kv_namespaces]] blocks
194
+ const kvBlocks = content.split(/\[\[kv_namespaces\]\]/);
195
+ for (let i = 1; i < kvBlocks.length; i++) {
196
+ const block = kvBlocks[i].split(/\[\[/)[0]; // Take until next section
197
+ const bindingMatch = block.match(/binding\s*=\s*"([^"]+)"/);
198
+ const idMatch = block.match(/\bid\s*=\s*"([^"]+)"/);
199
+ if (bindingMatch?.[1] === "VINEXT_CACHE" &&
200
+ idMatch?.[1] &&
201
+ idMatch[1] !== "<your-kv-namespace-id>") {
202
+ result.kvNamespaceId = idMatch[1];
203
+ }
204
+ }
205
+ // routes — both string and table forms
206
+ // route = "example.com/*"
207
+ const routeMatch = content.match(/^route\s*=\s*"([^"]+)"/m);
208
+ if (routeMatch) {
209
+ const domain = cleanDomain(routeMatch[1]);
210
+ if (domain && !domain.includes("workers.dev")) {
211
+ result.customDomain = domain;
212
+ }
213
+ }
214
+ // [[routes]] blocks
215
+ if (!result.customDomain) {
216
+ const routeBlocks = content.split(/\[\[routes\]\]/);
217
+ for (let i = 1; i < routeBlocks.length; i++) {
218
+ const block = routeBlocks[i].split(/\[\[/)[0];
219
+ const patternMatch = block.match(/pattern\s*=\s*"([^"]+)"/);
220
+ if (patternMatch) {
221
+ const domain = cleanDomain(patternMatch[1]);
222
+ if (domain && !domain.includes("workers.dev")) {
223
+ result.customDomain = domain;
224
+ break;
225
+ }
226
+ }
227
+ }
228
+ }
229
+ return result;
230
+ }
231
+ // ─── Cloudflare API ──────────────────────────────────────────────────────────
232
+ /** Resolve zone ID from a domain name via the Cloudflare API. */
233
+ async function resolveZoneId(domain, apiToken) {
234
+ // Extract the registrable domain (e.g., "shop.example.com" → "example.com").
235
+ // TODO: This doesn't handle multi-part TLDs like .co.uk, .com.br, .com.au.
236
+ // For those, we'd need a public suffix list. For now, we try the simple
237
+ // two-part extraction first, then fall back to the full domain if the zone
238
+ // lookup fails. Cloudflare's zone API will match on the correct registrable
239
+ // domain regardless.
240
+ const parts = domain.split(".");
241
+ const MULTI_PART_TLDS = ["co.uk", "com.br", "com.au", "co.jp", "co.kr", "co.nz", "co.za", "com.mx", "com.ar", "com.cn", "org.uk", "net.au"];
242
+ const lastTwo = parts.slice(-2).join(".");
243
+ let rootDomain;
244
+ if (MULTI_PART_TLDS.includes(lastTwo) && parts.length > 2) {
245
+ rootDomain = parts.slice(-3).join(".");
246
+ }
247
+ else {
248
+ rootDomain = parts.length > 2 ? parts.slice(-2).join(".") : domain;
249
+ }
250
+ const response = await fetch(`https://api.cloudflare.com/client/v4/zones?name=${encodeURIComponent(rootDomain)}`, {
251
+ headers: {
252
+ Authorization: `Bearer ${apiToken}`,
253
+ "Content-Type": "application/json",
254
+ },
255
+ });
256
+ if (!response.ok)
257
+ return null;
258
+ const data = (await response.json());
259
+ if (!data.success || !data.result?.length)
260
+ return null;
261
+ return data.result[0].id;
262
+ }
263
+ /** Resolve the account ID associated with the API token. */
264
+ async function resolveAccountId(apiToken) {
265
+ const response = await fetch("https://api.cloudflare.com/client/v4/accounts?per_page=1", {
266
+ headers: {
267
+ Authorization: `Bearer ${apiToken}`,
268
+ "Content-Type": "application/json",
269
+ },
270
+ });
271
+ if (!response.ok)
272
+ return null;
273
+ const data = (await response.json());
274
+ if (!data.success || !data.result?.length)
275
+ return null;
276
+ return data.result[0].id;
277
+ }
278
+ // ─── Traffic Querying ────────────────────────────────────────────────────────
279
+ /**
280
+ * Query Cloudflare zone analytics for top page paths by request count
281
+ * over the given time window.
282
+ */
283
+ async function queryTraffic(zoneTag, apiToken, windowHours) {
284
+ const now = new Date();
285
+ const start = new Date(now.getTime() - windowHours * 60 * 60 * 1000);
286
+ const query = `{
287
+ viewer {
288
+ zones(filter: { zoneTag: "${zoneTag}" }) {
289
+ httpRequestsAdaptiveGroups(
290
+ limit: 10000
291
+ orderBy: [sum_requests_DESC]
292
+ filter: {
293
+ datetime_geq: "${start.toISOString()}"
294
+ datetime_lt: "${now.toISOString()}"
295
+ requestSource: "eyeball"
296
+ }
297
+ ) {
298
+ sum { requests }
299
+ dimensions { clientRequestPath }
300
+ }
301
+ }
302
+ }
303
+ }`;
304
+ const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
305
+ method: "POST",
306
+ headers: {
307
+ Authorization: `Bearer ${apiToken}`,
308
+ "Content-Type": "application/json",
309
+ },
310
+ body: JSON.stringify({ query }),
311
+ });
312
+ if (!response.ok) {
313
+ throw new Error(`Zone analytics query failed: ${response.status} ${response.statusText}`);
314
+ }
315
+ const data = (await response.json());
316
+ if (data.errors?.length) {
317
+ throw new Error(`Zone analytics error: ${data.errors[0].message}`);
318
+ }
319
+ const groups = data.data?.viewer?.zones?.[0]?.httpRequestsAdaptiveGroups;
320
+ if (!groups || groups.length === 0)
321
+ return [];
322
+ return filterTrafficPaths(groups.map((g) => ({
323
+ path: g.dimensions.clientRequestPath,
324
+ requests: g.sum.requests,
325
+ })));
326
+ }
327
+ /** Filter out non-page requests (static assets, API routes, internal routes). */
328
+ function filterTrafficPaths(entries) {
329
+ return entries.filter((e) => {
330
+ if (!e.path.startsWith("/"))
331
+ return false;
332
+ // Static assets
333
+ if (/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|webp|avif)$/i.test(e.path))
334
+ return false;
335
+ // API routes
336
+ if (e.path.startsWith("/api/"))
337
+ return false;
338
+ // Internal routes
339
+ if (e.path.startsWith("/_vinext/") || e.path.startsWith("/_next/"))
340
+ return false;
341
+ // RSC requests
342
+ if (e.path.endsWith(".rsc"))
343
+ return false;
344
+ return true;
345
+ });
346
+ }
347
+ // ─── Route Selection ─────────────────────────────────────────────────────────
348
+ /**
349
+ * Walk the ranked traffic list, accumulating request counts until the
350
+ * coverage target is met or the hard cap is reached.
351
+ */
352
+ export function selectRoutes(traffic, coverageTarget, limit) {
353
+ const totalRequests = traffic.reduce((sum, e) => sum + e.requests, 0);
354
+ if (totalRequests === 0) {
355
+ return { routes: [], totalRequests: 0, coveredRequests: 0, coveragePercent: 0 };
356
+ }
357
+ const target = totalRequests * (coverageTarget / 100);
358
+ const selected = [];
359
+ let accumulated = 0;
360
+ // Traffic is already sorted DESC by requests from the GraphQL query
361
+ for (const entry of traffic) {
362
+ if (accumulated >= target || selected.length >= limit)
363
+ break;
364
+ selected.push(entry);
365
+ accumulated += entry.requests;
366
+ }
367
+ return {
368
+ routes: selected,
369
+ totalRequests,
370
+ coveredRequests: accumulated,
371
+ coveragePercent: (accumulated / totalRequests) * 100,
372
+ };
373
+ }
374
+ // ─── Pre-rendering ───────────────────────────────────────────────────────────
375
+ /** Pre-render port — high number to avoid collisions with dev servers. */
376
+ const PRERENDER_PORT = 19384;
377
+ /** Max time to wait for the local server to start (ms). */
378
+ const SERVER_STARTUP_TIMEOUT = 30_000;
379
+ /** Max concurrent fetch requests during pre-rendering. */
380
+ const FETCH_CONCURRENCY = 10;
381
+ /**
382
+ * Start a local production server, fetch each route to produce HTML,
383
+ * and return the results. Pages that fail to render are skipped.
384
+ */
385
+ async function prerenderRoutes(routes, root, hostDomain) {
386
+ const results = new Map();
387
+ let failedCount = 0;
388
+ const port = PRERENDER_PORT;
389
+ // Verify dist/ exists
390
+ const distDir = path.join(root, "dist");
391
+ if (!fs.existsSync(distDir)) {
392
+ console.log(" TPR: Skipping pre-render — dist/ directory not found");
393
+ return results;
394
+ }
395
+ // Start the local production server as a subprocess
396
+ const serverProcess = startLocalServer(root, port);
397
+ try {
398
+ await waitForServer(port, SERVER_STARTUP_TIMEOUT);
399
+ // Fetch routes in batches to limit concurrency
400
+ for (let i = 0; i < routes.length; i += FETCH_CONCURRENCY) {
401
+ const batch = routes.slice(i, i + FETCH_CONCURRENCY);
402
+ const promises = batch.map(async (routePath) => {
403
+ try {
404
+ const response = await fetch(`http://127.0.0.1:${port}${routePath}`, {
405
+ headers: {
406
+ "User-Agent": "vinext-tpr/1.0",
407
+ ...(hostDomain ? { Host: hostDomain } : {}),
408
+ },
409
+ redirect: "manual", // Don't follow redirects — cache the redirect itself
410
+ });
411
+ // Only cache successful responses (2xx and 3xx)
412
+ if (response.status < 400) {
413
+ const html = await response.text();
414
+ const headers = {};
415
+ response.headers.forEach((value, key) => {
416
+ // Only keep relevant headers
417
+ if (key === "content-type" ||
418
+ key === "cache-control" ||
419
+ key === "x-vinext-revalidate" ||
420
+ key === "location") {
421
+ headers[key] = value;
422
+ }
423
+ });
424
+ results.set(routePath, {
425
+ html,
426
+ status: response.status,
427
+ headers,
428
+ });
429
+ }
430
+ }
431
+ catch {
432
+ // Skip pages that fail to render — they may depend on
433
+ // request-specific data (cookies, headers, auth) that
434
+ // isn't available during pre-rendering.
435
+ failedCount++;
436
+ }
437
+ });
438
+ await Promise.all(promises);
439
+ }
440
+ if (failedCount > 0) {
441
+ console.log(` TPR: ${failedCount} page(s) failed to pre-render (skipped)`);
442
+ }
443
+ }
444
+ finally {
445
+ serverProcess.kill("SIGTERM");
446
+ // Give it a moment to clean up
447
+ await new Promise((resolve) => {
448
+ serverProcess.on("exit", resolve);
449
+ setTimeout(resolve, 2000);
450
+ });
451
+ }
452
+ return results;
453
+ }
454
+ /**
455
+ * Spawn a subprocess running the vinext production server.
456
+ * Uses the same Node.js binary and resolves prod-server.js relative
457
+ * to the current module (works whether vinext is installed or linked).
458
+ */
459
+ function startLocalServer(root, port) {
460
+ const thisDir = fileURLToPath(new URL(".", import.meta.url));
461
+ const prodServerPath = path.resolve(thisDir, "..", "server", "prod-server.js");
462
+ const outDir = path.join(root, "dist");
463
+ // Escape backslashes for Windows paths inside the JS string
464
+ const escapedProdServer = prodServerPath.replace(/\\/g, "\\\\");
465
+ const escapedOutDir = outDir.replace(/\\/g, "\\\\");
466
+ const script = [
467
+ `import("file://${escapedProdServer}")`,
468
+ `.then(m => m.startProdServer({ port: ${port}, host: "127.0.0.1", outDir: "${escapedOutDir}" }))`,
469
+ `.catch(e => { console.error("[vinext-tpr] Server failed to start:", e); process.exit(1); });`,
470
+ ].join("");
471
+ const proc = spawn(process.execPath, ["--input-type=module", "-e", script], {
472
+ cwd: root,
473
+ stdio: "pipe",
474
+ env: { ...process.env, NODE_ENV: "production" },
475
+ });
476
+ // Forward server errors to the parent's stderr for debugging
477
+ proc.stderr?.on("data", (chunk) => {
478
+ const msg = chunk.toString().trim();
479
+ if (msg)
480
+ console.error(` [tpr-server] ${msg}`);
481
+ });
482
+ return proc;
483
+ }
484
+ /** Poll the local server until it responds or the timeout is reached. */
485
+ async function waitForServer(port, timeoutMs) {
486
+ const start = Date.now();
487
+ while (Date.now() - start < timeoutMs) {
488
+ try {
489
+ const controller = new AbortController();
490
+ const timer = setTimeout(() => controller.abort(), 2000);
491
+ const response = await fetch(`http://127.0.0.1:${port}/`, {
492
+ redirect: "manual",
493
+ signal: controller.signal,
494
+ });
495
+ clearTimeout(timer);
496
+ // Any response means the server is up
497
+ await response.text(); // consume body
498
+ return;
499
+ }
500
+ catch {
501
+ await new Promise((r) => setTimeout(r, 300));
502
+ }
503
+ }
504
+ throw new Error(`Local production server failed to start within ${timeoutMs / 1000}s`);
505
+ }
506
+ // ─── KV Upload ───────────────────────────────────────────────────────────────
507
+ /**
508
+ * Upload pre-rendered pages to KV using the Cloudflare REST API.
509
+ * Writes in the same KVCacheEntry format that KVCacheHandler reads
510
+ * at runtime, so ISR serves these entries without any code changes.
511
+ */
512
+ async function uploadToKV(entries, namespaceId, accountId, apiToken, defaultRevalidateSeconds) {
513
+ const now = Date.now();
514
+ // Build the bulk write payload
515
+ const pairs = [];
516
+ for (const [routePath, result] of entries) {
517
+ // Determine revalidation window — use the page's revalidate header
518
+ // if present, otherwise fall back to the default
519
+ const revalidateHeader = result.headers["x-vinext-revalidate"];
520
+ const revalidateSeconds = revalidateHeader && !isNaN(Number(revalidateHeader))
521
+ ? Number(revalidateHeader)
522
+ : defaultRevalidateSeconds;
523
+ const revalidateAt = revalidateSeconds > 0 ? now + revalidateSeconds * 1000 : null;
524
+ // KV TTL: 10x the revalidation period, clamped to [60s, 30d]
525
+ // (matches the logic in KVCacheHandler.set)
526
+ const kvTtl = revalidateSeconds > 0
527
+ ? Math.max(Math.min(revalidateSeconds * 10, 30 * 24 * 3600), 60)
528
+ : 24 * 3600; // 24h fallback if no revalidation
529
+ const entry = {
530
+ value: {
531
+ kind: "APP_PAGE",
532
+ html: result.html,
533
+ headers: result.headers,
534
+ status: result.status,
535
+ },
536
+ tags: [],
537
+ lastModified: now,
538
+ revalidateAt,
539
+ };
540
+ pairs.push({
541
+ key: `cache:${routePath}`,
542
+ value: JSON.stringify(entry),
543
+ expiration_ttl: kvTtl,
544
+ });
545
+ }
546
+ // Upload in batches (KV bulk API accepts up to 10,000 per request)
547
+ const BATCH_SIZE = 10_000;
548
+ for (let i = 0; i < pairs.length; i += BATCH_SIZE) {
549
+ const batch = pairs.slice(i, i + BATCH_SIZE);
550
+ const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/bulk`, {
551
+ method: "PUT",
552
+ headers: {
553
+ Authorization: `Bearer ${apiToken}`,
554
+ "Content-Type": "application/json",
555
+ },
556
+ body: JSON.stringify(batch),
557
+ });
558
+ if (!response.ok) {
559
+ const text = await response.text();
560
+ throw new Error(`KV bulk upload failed (batch ${Math.floor(i / BATCH_SIZE) + 1}): ${response.status} — ${text}`);
561
+ }
562
+ }
563
+ }
564
+ // ─── Main Entry ──────────────────────────────────────────────────────────────
565
+ /** Default revalidation TTL for pre-rendered pages (1 hour). */
566
+ const DEFAULT_REVALIDATE_SECONDS = 3600;
567
+ /**
568
+ * Run the TPR pipeline: query traffic, select routes, pre-render, upload.
569
+ *
570
+ * Designed to be called between the build step and wrangler deploy in
571
+ * the `vinext deploy` pipeline. Gracefully skips (never errors) when
572
+ * the prerequisites aren't met.
573
+ */
574
+ export async function runTPR(options) {
575
+ const startTime = Date.now();
576
+ const { root, coverage, limit, window: windowHours } = options;
577
+ const skip = (reason) => ({
578
+ totalPaths: 0,
579
+ prerenderedCount: 0,
580
+ coverageAchieved: 0,
581
+ durationMs: Date.now() - startTime,
582
+ skipped: reason,
583
+ });
584
+ // ── 1. Check for API token ────────────────────────────────────
585
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN;
586
+ if (!apiToken) {
587
+ return skip("no CLOUDFLARE_API_TOKEN set");
588
+ }
589
+ // ── 2. Parse wrangler config ──────────────────────────────────
590
+ const wranglerConfig = parseWranglerConfig(root);
591
+ if (!wranglerConfig) {
592
+ return skip("could not parse wrangler config");
593
+ }
594
+ // ── 3. Check for custom domain ────────────────────────────────
595
+ if (!wranglerConfig.customDomain) {
596
+ return skip("no custom domain — zone analytics unavailable");
597
+ }
598
+ // ── 4. Check for KV namespace ─────────────────────────────────
599
+ if (!wranglerConfig.kvNamespaceId) {
600
+ return skip("no VINEXT_CACHE KV namespace configured");
601
+ }
602
+ // ── 5. Resolve account ID ─────────────────────────────────────
603
+ const accountId = wranglerConfig.accountId ?? (await resolveAccountId(apiToken));
604
+ if (!accountId) {
605
+ return skip("could not resolve Cloudflare account ID");
606
+ }
607
+ // ── 6. Resolve zone ID ────────────────────────────────────────
608
+ console.log(` TPR: Analyzing traffic for ${wranglerConfig.customDomain} (last ${windowHours}h)`);
609
+ const zoneId = await resolveZoneId(wranglerConfig.customDomain, apiToken);
610
+ if (!zoneId) {
611
+ return skip(`could not resolve zone for ${wranglerConfig.customDomain}`);
612
+ }
613
+ // ── 7. Query traffic data ─────────────────────────────────────
614
+ let traffic;
615
+ try {
616
+ traffic = await queryTraffic(zoneId, apiToken, windowHours);
617
+ }
618
+ catch (err) {
619
+ return skip(`analytics query failed: ${err instanceof Error ? err.message : String(err)}`);
620
+ }
621
+ if (traffic.length === 0) {
622
+ return skip("no traffic data available (first deploy?)");
623
+ }
624
+ // ── 8. Select routes by coverage ──────────────────────────────
625
+ const selection = selectRoutes(traffic, coverage, limit);
626
+ console.log(` TPR: ${traffic.length.toLocaleString()} unique paths — ` +
627
+ `${selection.routes.length} pages cover ${Math.round(selection.coveragePercent)}% of traffic`);
628
+ if (selection.routes.length === 0) {
629
+ return {
630
+ totalPaths: traffic.length,
631
+ prerenderedCount: 0,
632
+ coverageAchieved: 0,
633
+ durationMs: Date.now() - startTime,
634
+ skipped: "no pre-renderable routes after filtering",
635
+ };
636
+ }
637
+ // ── 9. Pre-render selected routes ─────────────────────────────
638
+ console.log(` TPR: Pre-rendering ${selection.routes.length} pages...`);
639
+ const routePaths = selection.routes.map((r) => r.path);
640
+ let rendered;
641
+ try {
642
+ rendered = await prerenderRoutes(routePaths, root, wranglerConfig.customDomain);
643
+ }
644
+ catch (err) {
645
+ return skip(`pre-rendering failed: ${err instanceof Error ? err.message : String(err)}`);
646
+ }
647
+ if (rendered.size === 0) {
648
+ return {
649
+ totalPaths: traffic.length,
650
+ prerenderedCount: 0,
651
+ coverageAchieved: selection.coveragePercent,
652
+ durationMs: Date.now() - startTime,
653
+ skipped: "all pages failed to pre-render (request-dependent?)",
654
+ };
655
+ }
656
+ // ── 10. Upload to KV ──────────────────────────────────────────
657
+ try {
658
+ await uploadToKV(rendered, wranglerConfig.kvNamespaceId, accountId, apiToken, DEFAULT_REVALIDATE_SECONDS);
659
+ }
660
+ catch (err) {
661
+ return skip(`KV upload failed: ${err instanceof Error ? err.message : String(err)}`);
662
+ }
663
+ const durationMs = Date.now() - startTime;
664
+ console.log(` TPR: Pre-rendered ${rendered.size} pages in ${(durationMs / 1000).toFixed(1)}s → KV cache`);
665
+ return {
666
+ totalPaths: traffic.length,
667
+ prerenderedCount: rendered.size,
668
+ coverageAchieved: selection.coveragePercent,
669
+ durationMs,
670
+ };
671
+ }
672
+ //# sourceMappingURL=tpr.js.map