@vinext/cloudflare 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/deploy.js ADDED
@@ -0,0 +1,272 @@
1
+ import { runTPR } from "./tpr.js";
2
+ import { formatImageOptimizationHint, formatMissingCacheAdapterError, viteConfigHasCacheAdapter, viteConfigHasCloudflarePlugin, viteConfigHasImageAdapter, workerEntryHasCacheHandler } from "./deploy-config.js";
3
+ import { createRequire } from "node:module";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { execFileSync } from "node:child_process";
7
+ import { parseArgs } from "node:util";
8
+ import { pathToFileURL } from "node:url";
9
+ import { runPrerender } from "vinext/internal/build/run-prerender";
10
+ import { loadDotenv } from "vinext/internal/config/dotenv";
11
+ import { loadNextConfig, resolveNextConfig } from "vinext/internal/config/next-config";
12
+ import { formatVinextPrerenderLabel, loadVinextPrerenderConfigFromViteConfig, resolveVinextPrerenderDecision } from "vinext/internal/config/prerender";
13
+ import { detectProject, findInNodeModules, formatMissingCloudflarePluginError, getMissingDeps } from "vinext/internal/utils/project";
14
+ //#region src/deploy.ts
15
+ /**
16
+ * vinext-cloudflare deploy — one-command Cloudflare Workers deployment.
17
+ *
18
+ * Takes any Next.js app and deploys it to Cloudflare Workers:
19
+ *
20
+ * 1. Validates the project was prepared by `vinext init --platform=cloudflare`
21
+ * 2. Runs the Vite build
22
+ * 3. Deploys to Cloudflare Workers via Wrangler
23
+ */
24
+ function parsePositiveIntegerArg(raw, flag) {
25
+ if (raw === "") throw new Error(`${flag} requires a value, but none was provided.`);
26
+ const parsed = Number(raw);
27
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} expects a positive integer, but got "${raw}".`);
28
+ return parsed;
29
+ }
30
+ /** Deploy command flag definitions for util.parseArgs. */
31
+ const deployArgOptions = {
32
+ help: {
33
+ type: "boolean",
34
+ short: "h",
35
+ default: false
36
+ },
37
+ preview: {
38
+ type: "boolean",
39
+ default: false
40
+ },
41
+ env: { type: "string" },
42
+ name: { type: "string" },
43
+ "skip-build": {
44
+ type: "boolean",
45
+ default: false
46
+ },
47
+ "dry-run": {
48
+ type: "boolean",
49
+ default: false
50
+ },
51
+ "prerender-all": {
52
+ type: "boolean",
53
+ default: false
54
+ },
55
+ "prerender-concurrency": { type: "string" },
56
+ "experimental-tpr": {
57
+ type: "boolean",
58
+ default: false
59
+ },
60
+ "tpr-coverage": { type: "string" },
61
+ "tpr-limit": { type: "string" },
62
+ "tpr-window": { type: "string" }
63
+ };
64
+ function parseDeployArgs(args) {
65
+ const { values } = parseArgs({
66
+ args,
67
+ options: deployArgOptions,
68
+ strict: true
69
+ });
70
+ function parseIntArg(name, raw) {
71
+ if (!raw) return void 0;
72
+ const n = parseInt(raw, 10);
73
+ if (isNaN(n)) {
74
+ console.error(` --${name} must be a number (got: ${raw})`);
75
+ process.exit(1);
76
+ }
77
+ return n;
78
+ }
79
+ return {
80
+ help: values.help,
81
+ preview: values.preview,
82
+ env: values.env?.trim() || void 0,
83
+ name: values.name?.trim() || void 0,
84
+ skipBuild: values["skip-build"],
85
+ dryRun: values["dry-run"],
86
+ prerenderAll: values["prerender-all"],
87
+ prerenderConcurrency: values["prerender-concurrency"] === void 0 ? void 0 : parsePositiveIntegerArg(values["prerender-concurrency"], "--prerender-concurrency"),
88
+ experimentalTPR: values["experimental-tpr"],
89
+ tprCoverage: parseIntArg("tpr-coverage", values["tpr-coverage"]),
90
+ tprLimit: parseIntArg("tpr-limit", values["tpr-limit"]),
91
+ tprWindow: parseIntArg("tpr-window", values["tpr-window"])
92
+ };
93
+ }
94
+ /**
95
+ * Run a function with `process.env.CLOUDFLARE_ENV` set to the given value,
96
+ * restoring the previous state (whether set or absent) after the function
97
+ * resolves or throws.
98
+ *
99
+ * The `@cloudflare/vite-plugin` reads `CLOUDFLARE_ENV` from `process.env` to
100
+ * drive the multi-environment merge applied to the emitted `wrangler.json`.
101
+ * Without this propagation the `--env <name>` CLI flag is silently ignored at
102
+ * build time and the top-level config is emitted regardless. See issue #1210.
103
+ *
104
+ * Passing `undefined` is a no-op; the callback runs with `process.env` untouched.
105
+ */
106
+ async function withCloudflareEnv(env, fn) {
107
+ if (env === void 0 || env === "") return fn();
108
+ const hadPrev = "CLOUDFLARE_ENV" in process.env;
109
+ const prev = process.env.CLOUDFLARE_ENV;
110
+ process.env.CLOUDFLARE_ENV = env;
111
+ try {
112
+ return await fn();
113
+ } finally {
114
+ if (hadPrev) process.env.CLOUDFLARE_ENV = prev;
115
+ else delete process.env.CLOUDFLARE_ENV;
116
+ }
117
+ }
118
+ async function loadProjectViteApi(root) {
119
+ let vitePath;
120
+ try {
121
+ vitePath = createRequire(path.join(root, "package.json")).resolve("vite");
122
+ } catch {
123
+ vitePath = "vite";
124
+ }
125
+ return await (vitePath === "vite" ? import(vitePath) : import(pathToFileURL(vitePath).href));
126
+ }
127
+ async function runBuild(info, env) {
128
+ console.log("\n Building for Cloudflare Workers...\n");
129
+ const { createBuilder } = await loadProjectViteApi(info.root);
130
+ await withCloudflareEnv(env, async () => {
131
+ await (await createBuilder({ root: info.root })).buildApp();
132
+ });
133
+ }
134
+ function validateWranglerEnvName(env) {
135
+ if (env.includes("\0")) throw new Error("Wrangler environment names cannot contain null bytes.");
136
+ return env;
137
+ }
138
+ function buildWranglerDeployArgs(options) {
139
+ const args = ["deploy"];
140
+ const env = options.env || (options.preview ? "preview" : void 0);
141
+ if (env) args.push("--env", validateWranglerEnvName(env));
142
+ return {
143
+ args,
144
+ env
145
+ };
146
+ }
147
+ /**
148
+ * Resolve Wrangler's JavaScript CLI entrypoint in node_modules.
149
+ *
150
+ * Invoking the JavaScript file through `process.execPath` avoids the `.cmd`
151
+ * shim and command shell that package managers create on Windows.
152
+ */
153
+ function resolveWranglerBin(root, resolvePackageJson = (projectRoot) => {
154
+ try {
155
+ return createRequire(path.join(projectRoot, "package.json")).resolve("wrangler/package.json");
156
+ } catch {
157
+ return findInNodeModules(projectRoot, "wrangler/package.json");
158
+ }
159
+ }) {
160
+ const packageJsonPath = resolvePackageJson(root);
161
+ if (packageJsonPath) {
162
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
163
+ const bin = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.wrangler;
164
+ if (bin) return path.resolve(path.dirname(packageJsonPath), bin);
165
+ }
166
+ return path.join(root, "node_modules", "wrangler", "bin", "wrangler.js");
167
+ }
168
+ function buildNodeCliInvocation(scriptPath, args, nodeExecutable = process.execPath) {
169
+ return {
170
+ file: nodeExecutable,
171
+ args: [scriptPath, ...args]
172
+ };
173
+ }
174
+ function buildWranglerInvocation(root, options, nodeExecutable = process.execPath) {
175
+ const wranglerBin = resolveWranglerBin(root);
176
+ const { args, env } = buildWranglerDeployArgs(options);
177
+ return {
178
+ ...buildNodeCliInvocation(wranglerBin, args, nodeExecutable),
179
+ env
180
+ };
181
+ }
182
+ function runWranglerDeploy(root, options, execute = execFileSync) {
183
+ const execOpts = {
184
+ cwd: root,
185
+ stdio: "pipe",
186
+ encoding: "utf-8",
187
+ shell: false
188
+ };
189
+ const { file, args, env } = buildWranglerInvocation(root, options);
190
+ if (env) console.log(`\n Deploying to env: ${env}...`);
191
+ else console.log("\n Deploying to production...");
192
+ const output = execute(file, args, execOpts);
193
+ const urlMatch = output.match(/https:\/\/[^\s]+\.workers\.dev[^\s]*/);
194
+ const deployedUrl = urlMatch ? urlMatch[0] : null;
195
+ if (output.trim()) for (const line of output.trim().split("\n")) console.log(` ${line}`);
196
+ return deployedUrl ?? "(URL not detected in wrangler output)";
197
+ }
198
+ async function deploy(options) {
199
+ const deployEnv = validateWranglerEnvName(options.env || (options.preview ? "preview" : "production"));
200
+ const root = path.resolve(options.root);
201
+ loadDotenv({
202
+ root,
203
+ mode: "production"
204
+ });
205
+ console.log("\n vinext-cloudflare deploy\n");
206
+ const info = detectProject(root);
207
+ if (!info.isAppRouter && !info.isPagesRouter) {
208
+ console.error(" Error: No app/ or pages/ directory found.");
209
+ console.error(" vinext-cloudflare deploy requires a Next.js project with an app/ or pages/ directory");
210
+ console.error(" (also checks src/app/ and src/pages/).\n");
211
+ process.exit(1);
212
+ }
213
+ if (options.name) info.projectName = options.name;
214
+ console.log(` Project: ${info.projectName}`);
215
+ console.log(` Router: ${info.isAppRouter ? "App Router" : "Pages Router"}`);
216
+ console.log(` ISR: ${info.hasISR ? "detected" : "none"}`);
217
+ const missingScaffolding = [!info.hasViteConfig && "Vite config", !info.hasWranglerConfig && "Wrangler config"].filter((value) => Boolean(value));
218
+ if (missingScaffolding.length > 0) throw new Error(`Missing Cloudflare deployment setup: ${missingScaffolding.join(", ")}. Run \`vinext init --platform=cloudflare\` first.`);
219
+ const missingDeps = getMissingDeps(info);
220
+ if (missingDeps.length > 0) throw new Error(`Missing deployment dependencies: ${missingDeps.map((dependency) => dependency.name).join(", ")}. Run \`vinext init --platform=cloudflare\` first.`);
221
+ if (info.hasViteConfig && !viteConfigHasCloudflarePlugin(root)) throw new Error(formatMissingCloudflarePluginError({ isAppRouter: info.isAppRouter }));
222
+ if (info.hasISR && !viteConfigHasCacheAdapter(root) && !workerEntryHasCacheHandler(root)) throw new Error(formatMissingCacheAdapterError({}));
223
+ if (!viteConfigHasImageAdapter(root)) {
224
+ console.log();
225
+ console.log(formatImageOptimizationHint());
226
+ }
227
+ if (options.dryRun) {
228
+ console.log("\n Dry run complete. No build or deploy performed.\n");
229
+ return;
230
+ }
231
+ const buildEnv = deployEnv === "production" && !options.env ? void 0 : deployEnv;
232
+ if (!options.skipBuild) await runBuild(info, buildEnv);
233
+ else console.log("\n Skipping build (--skip-build)");
234
+ {
235
+ const nextConfig = await resolveNextConfig(await loadNextConfig(info.root), info.root);
236
+ const vinextPrerenderConfig = !options.prerenderAll && nextConfig.output !== "export" ? await withCloudflareEnv(buildEnv, async () => {
237
+ return loadVinextPrerenderConfigFromViteConfig(await loadProjectViteApi(info.root), info.root);
238
+ }) : null;
239
+ const prerenderDecision = resolveVinextPrerenderDecision({
240
+ prerenderAllFlag: options.prerenderAll,
241
+ vinextPrerenderConfig,
242
+ nextOutput: nextConfig.output
243
+ });
244
+ if (prerenderDecision) {
245
+ console.log(`\n ${formatVinextPrerenderLabel(prerenderDecision)}`);
246
+ if (nextConfig.enablePrerenderSourceMaps) {
247
+ process.setSourceMapsEnabled(true);
248
+ Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 50);
249
+ }
250
+ await runPrerender({
251
+ root: info.root,
252
+ concurrency: options.prerenderConcurrency
253
+ });
254
+ }
255
+ }
256
+ if (options.experimentalTPR) {
257
+ console.log();
258
+ const tprResult = await runTPR({
259
+ root,
260
+ coverage: Math.max(1, Math.min(100, options.tprCoverage ?? 90)),
261
+ limit: Math.max(1, options.tprLimit ?? 1e3),
262
+ window: Math.max(1, options.tprWindow ?? 24)
263
+ });
264
+ if (tprResult.skipped) console.log(` TPR: Skipped (${tprResult.skipped})`);
265
+ }
266
+ const url = runWranglerDeploy(root, { env: deployEnv === "production" && !options.env ? void 0 : deployEnv });
267
+ console.log("\n ─────────────────────────────────────────");
268
+ console.log(` Deployed to: ${url}`);
269
+ console.log(" ─────────────────────────────────────────\n");
270
+ }
271
+ //#endregion
272
+ export { buildNodeCliInvocation, buildWranglerDeployArgs, buildWranglerInvocation, deploy, parseDeployArgs, resolveWranglerBin, runWranglerDeploy, validateWranglerEnvName, withCloudflareEnv };
@@ -0,0 +1,28 @@
1
+ //#region src/images/images-optimizer.d.ts
2
+ /** Options accepted by {@link imagesOptimizer}, forwarded to the runtime factory. */
3
+ type ImagesOptimizerOptions = {
4
+ /** Cloudflare Images binding name on the Worker `env`. @default "IMAGES" */binding?: string;
5
+ };
6
+ /**
7
+ * Config-time builder: returns a serializable descriptor whose `adapter` is the
8
+ * absolute path to the runtime factory. Safe to call from vite.config — it
9
+ * never instantiates the optimizer or reads a binding.
10
+ *
11
+ * Wires `next/image` optimization (`/_next/image`) to the Cloudflare Images
12
+ * binding for on-the-fly resize, format negotiation (AVIF/WebP), and quality
13
+ * transforms at the edge — no custom worker entry required.
14
+ *
15
+ * @example
16
+ * import { vinext } from "vinext";
17
+ * import { imagesOptimizer } from "@vinext/cloudflare/images/images-optimizer";
18
+ *
19
+ * export default defineConfig({
20
+ * plugins: [vinext({ images: { optimizer: imagesOptimizer() } })],
21
+ * });
22
+ */
23
+ declare function imagesOptimizer(options?: ImagesOptimizerOptions): {
24
+ adapter: string;
25
+ options: ImagesOptimizerOptions | undefined;
26
+ };
27
+ //#endregion
28
+ export { ImagesOptimizerOptions, imagesOptimizer };
@@ -0,0 +1,28 @@
1
+ import { fileURLToPath } from "node:url";
2
+ //#region src/images/images-optimizer.ts
3
+ /**
4
+ * Config-time builder: returns a serializable descriptor whose `adapter` is the
5
+ * absolute path to the runtime factory. Safe to call from vite.config — it
6
+ * never instantiates the optimizer or reads a binding.
7
+ *
8
+ * Wires `next/image` optimization (`/_next/image`) to the Cloudflare Images
9
+ * binding for on-the-fly resize, format negotiation (AVIF/WebP), and quality
10
+ * transforms at the edge — no custom worker entry required.
11
+ *
12
+ * @example
13
+ * import { vinext } from "vinext";
14
+ * import { imagesOptimizer } from "@vinext/cloudflare/images/images-optimizer";
15
+ *
16
+ * export default defineConfig({
17
+ * plugins: [vinext({ images: { optimizer: imagesOptimizer() } })],
18
+ * });
19
+ */
20
+ function imagesOptimizer(options) {
21
+ if (options?.binding !== void 0 && typeof options.binding !== "string") throw new TypeError("[vinext] imagesOptimizer({ binding }) must be a string Images binding name.");
22
+ return {
23
+ adapter: fileURLToPath(import.meta.resolve("./images-optimizer.runtime.js")),
24
+ options
25
+ };
26
+ }
27
+ //#endregion
28
+ export { imagesOptimizer };
@@ -0,0 +1,16 @@
1
+ import { ImageOptimizer } from "vinext/server/image-optimization";
2
+
3
+ //#region src/images/images-optimizer.runtime.d.ts
4
+ /** Options accepted by the optimizer factory (mirrors `ImagesOptimizerOptions`). */
5
+ type ImagesOptimizerOptions = {
6
+ binding?: string;
7
+ };
8
+ declare const createCloudflareImageOptimizer: ({
9
+ env,
10
+ options
11
+ }: {
12
+ env?: Record<string, unknown>;
13
+ options?: ImagesOptimizerOptions;
14
+ }) => ImageOptimizer;
15
+ //#endregion
16
+ export { createCloudflareImageOptimizer as default };
@@ -0,0 +1,16 @@
1
+ //#region src/images/images-optimizer.runtime.ts
2
+ /** Default Cloudflare Images binding name on the Worker `env`. */
3
+ const DEFAULT_BINDING = "IMAGES";
4
+ const createCloudflareImageOptimizer = ({ env, options }) => {
5
+ const binding = options?.binding ?? DEFAULT_BINDING;
6
+ const images = env?.[binding];
7
+ if (!images || typeof images.input !== "function") throw new Error(`[vinext] The Cloudflare image optimizer requires an \`${binding}\` Images binding.\n Add it to wrangler.jsonc:\n "images": { "binding": "${binding}" }`);
8
+ return { async transformImage(body, { width, format, quality }) {
9
+ return (await images.input(body).transform(width > 0 ? { width } : {}).output({
10
+ format,
11
+ quality
12
+ })).response();
13
+ } };
14
+ };
15
+ //#endregion
16
+ export { createCloudflareImageOptimizer as default };
package/dist/tpr.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ //#region src/tpr.d.ts
2
+ /**
3
+ * TPR: Traffic-aware Pre-Rendering
4
+ *
5
+ * Uses Cloudflare zone analytics to determine which pages actually get
6
+ * traffic, and pre-renders only those during deploy. The pre-rendered
7
+ * HTML is uploaded to KV in the same format ISR uses at runtime — no
8
+ * runtime changes needed.
9
+ *
10
+ * Flow:
11
+ * 1. Parse wrangler config to find custom domain and KV namespace
12
+ * 2. Resolve the Cloudflare zone for the custom domain
13
+ * 3. Query zone analytics (GraphQL) for top pages by request count
14
+ * 4. Walk ranked list until coverage threshold is met
15
+ * 5. Start the built production server locally
16
+ * 6. Fetch each hot route to produce HTML
17
+ * 7. Upload pre-rendered HTML to KV (same KVCacheEntry format ISR reads)
18
+ *
19
+ * TPR is an experimental feature enabled via --experimental-tpr. It
20
+ * gracefully skips when no custom domain, no API token, no traffic data,
21
+ * or no KV namespace is configured.
22
+ */
23
+ type TPROptions = {
24
+ /** Project root directory. */root: string; /** Traffic coverage percentage (0–100). Default: 90. */
25
+ coverage: number; /** Hard cap on number of pages to pre-render. Default: 1000. */
26
+ limit: number; /** Analytics lookback window in hours. Default: 24. */
27
+ window: number;
28
+ };
29
+ type TPRResult = {
30
+ /** Total unique page paths found in analytics. */totalPaths: number; /** Number of pages successfully pre-rendered and uploaded. */
31
+ prerenderedCount: number; /** Actual traffic coverage achieved (percentage). */
32
+ coverageAchieved: number; /** Wall-clock duration of the TPR step in milliseconds. */
33
+ durationMs: number; /** If TPR was skipped, the reason. */
34
+ skipped?: string;
35
+ };
36
+ type PrerenderResult = {
37
+ html: string;
38
+ status: number;
39
+ headers: Record<string, string>;
40
+ };
41
+ type WranglerConfig = {
42
+ accountId?: string;
43
+ kvNamespaceId?: string;
44
+ customDomain?: string;
45
+ };
46
+ /**
47
+ * Parse wrangler config (JSONC or TOML) to extract the fields TPR needs:
48
+ * account_id, VINEXT_KV_CACHE KV namespace ID, and custom domain.
49
+ */
50
+ declare function parseWranglerConfig(root: string): WranglerConfig | null;
51
+ /**
52
+ * Generate zone lookup candidates from shortest (2-part) to longest.
53
+ * Tries the most common case first (e.g., "example.com") and progressively
54
+ * adds labels for multi-part TLDs (e.g., "co.uk" → "example.co.uk").
55
+ *
56
+ * "shop.example.com" → ["example.com", "shop.example.com"]
57
+ * "shop.example.co.uk" → ["co.uk", "example.co.uk", "shop.example.co.uk"]
58
+ * "example.com" → ["example.com"]
59
+ */
60
+ declare function domainCandidates(domain: string): string[];
61
+ declare function resolveVinextProdServerPath(root: string): string;
62
+ /**
63
+ * Build KV bulk API pairs from pre-rendered entries.
64
+ *
65
+ * Key format matches the runtime KVCacheHandler exactly:
66
+ * ENTRY_PREFIX + isrCacheKey("app", pathname, buildId) + ":html"
67
+ * → "cache:app:<buildId>:<pathname>:html"
68
+ */
69
+ declare function buildTprKVPairs(entries: Map<string, PrerenderResult>, buildId: string | undefined, defaultRevalidateSeconds: number): Array<{
70
+ key: string;
71
+ value: string;
72
+ expiration_ttl: number;
73
+ }>;
74
+ /**
75
+ * Run the TPR pipeline: query traffic, select routes, pre-render, upload.
76
+ *
77
+ * Designed to be called between the build step and wrangler deploy in the
78
+ * `vinext-cloudflare deploy` pipeline. Gracefully skips (never errors) when
79
+ * the prerequisites aren't met.
80
+ */
81
+ declare function runTPR(options: TPROptions): Promise<TPRResult>;
82
+ //#endregion
83
+ export { TPROptions, TPRResult, buildTprKVPairs, domainCandidates, parseWranglerConfig, resolveVinextProdServerPath, runTPR };