Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/vinext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@
"./server/worker-utils": {
"types": "./dist/server/worker-utils.d.ts",
"import": "./dist/server/worker-utils.js"
},
"./server/seed-cache-workers": {
"types": "./dist/server/seed-cache-workers.d.ts",
"import": "./dist/server/seed-cache-workers.js"
}
},
"scripts": {
Expand Down
74 changes: 61 additions & 13 deletions packages/vinext/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ import { setCacheHandler } from "vinext/shims/cache";
*/
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import type { ImageConfig } from "vinext/server/image-optimization";
import { seedRouteFromAssets } from "vinext/server/seed-cache-workers";
import handler from "vinext/server/app-router-entry";
${isrImports}
interface Env {
Expand Down Expand Up @@ -495,6 +496,11 @@ ${isrSetup} const url = new URL(request.url);
}, allowedWidths);
}

// Seed the memory cache from pre-rendered assets (lazy, per-route).
// Blocking (not waitUntil) so the RSC handler sees the seeded cache.
// seedRouteFromAssets handles errors internally — never throws.
await seedRouteFromAssets(url.pathname, (p) => env.ASSETS.fetch(new Request(new URL(p, request.url))));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This await is on the critical path of every request — it blocks the response until seeding completes for pre-rendered routes. For the first cold request this adds the latency of two env.ASSETS.fetch() calls (HTML + RSC) plus two handler.set() calls.

This is probably acceptable since env.ASSETS.fetch() is local to the colo and fast, but worth a comment noting that this is intentionally blocking (to guarantee the cache is populated before the RSC handler runs). An alternative would be to let the RSC handler serve the request normally while seeding in the background via ctx.waitUntil(), but that would mean the first request always re-renders — so blocking is the right call here.

Also: should this be wrapped in try/catch as defense-in-depth? If seedRouteFromAssets throws (see other comment), the request dies.

Suggested change
await seedRouteFromAssets(url.pathname, (p) => env.ASSETS.fetch(new Request(new URL(p, request.url))));
// Seed the memory cache from pre-rendered assets (lazy, per-route).
// On first request for a pre-rendered page, fetches HTML/RSC from the
// assets binding and inserts into the cache. Subsequent requests in the
// same isolate get cache HITs. No-op for non-prerendered routes.
// Blocking (not waitUntil) so the RSC handler sees the seeded cache.
try {
await seedRouteFromAssets(url.pathname, (p) => env.ASSETS.fetch(new Request(new URL(p, request.url))));
} catch { /* seeding is best-effort — fall through to normal render */ }


Comment on lines 498 to +503
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated App Router worker entry calls seedRouteFromAssets() unconditionally, even when ISR is enabled and the entry swaps the cache handler to KVCacheHandler. That means prerender seeding will write HTML/RSC into KV on first request, which is potentially expensive and contradicts the stated scope of “Workers memory stub support only”. Consider guarding the call (e.g., only seed when using the default MemoryCacheHandler, or skip when hasISR/KV is configured).

Suggested change
// Seed the memory cache from pre-rendered assets (lazy, per-route).
// Blocking (not waitUntil) so the RSC handler sees the seeded cache.
// seedRouteFromAssets handles errors internally — never throws.
await seedRouteFromAssets(url.pathname, (p) => env.ASSETS.fetch(new Request(new URL(p, request.url))));
${hasISR ? "" : `
// Seed the memory cache from pre-rendered assets (lazy, per-route).
// Blocking (not waitUntil) so the RSC handler sees the seeded cache.
// seedRouteFromAssets handles errors internally — never throws.
await seedRouteFromAssets(url.pathname, (p) => env.ASSETS.fetch(new Request(new URL(p, request.url))));
`}

Copilot uses AI. Check for mistakes.
// Delegate everything else to vinext, forwarding ctx so that
// ctx.waitUntil() is available to background cache writes and
// other deferred work via getRequestExecutionContext().
Expand Down Expand Up @@ -1199,6 +1205,44 @@ export function buildWranglerDeployArgs(
return { args, env };
}

/**
* Copy pre-rendered files to dist/client/__prerender/ so they're deployed
* as static assets and accessible via env.ASSETS.fetch() at runtime.
* No-op if no prerender manifest exists.
*/
export function copyPrerenderToAssets(root: string, basePath = ""): void {
const serverDir = path.join(root, "dist", "server");
const manifestPath = path.join(serverDir, "vinext-prerender.json");
if (!fs.existsSync(manifestPath)) return;

const targetDir = path.join(root, "dist", "client", "__prerender");
fs.mkdirSync(targetDir, { recursive: true });
const targetManifestPath = path.join(targetDir, "vinext-prerender.json");

// Copy manifest, enriching with basePath for Workers-side cache lookup.
if (!basePath) {
fs.copyFileSync(manifestPath, targetManifestPath);
} else {
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as Record<
string,
unknown
>;
manifest.basePath = basePath;
fs.writeFileSync(targetManifestPath, JSON.stringify(manifest, null, 2), "utf-8");
} catch {
// Best-effort enrichment only. If parse fails, preserve the source bytes.
fs.copyFileSync(manifestPath, targetManifestPath);
}
}

// Copy prerendered HTML/RSC files
const sourceDir = path.join(serverDir, "prerendered-routes");
if (!fs.existsSync(sourceDir)) return;

fs.cpSync(sourceDir, targetDir, { recursive: true });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: fs.cpSync(sourceDir, targetDir, { recursive: true }) copies the contents of sourceDir into targetDir where targetDir already exists. This means vinext-prerender.json (copied on line 1219) and route files (e.g., about.html) all live at the same level under __prerender/. This works, but means a pre-rendered route literally named /vinext-prerender (producing vinext-prerender.html) would coexist fine — just calling it out.

A more robust layout would be __prerender/manifest.json + __prerender/routes/..., but that's a future cleanup, not a blocker.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observation: fs.cpSync(sourceDir, targetDir) merges the contents of prerendered-routes/ into __prerender/, which already contains vinext-prerender.json. Since route files are .html/.rsc and the manifest is .json, there's no collision risk. But if a future route produces a .json output file, it could theoretically overwrite the manifest. Very unlikely, but a __prerender/manifest/ vs __prerender/routes/ split would make this impossible. Low priority.

}

function runWranglerDeploy(root: string, options: Pick<DeployOptions, "preview" | "env">): string {
// Walk up ancestor directories so the binary is found even when node_modules
// is hoisted to the workspace root in a monorepo.
Expand Down Expand Up @@ -1327,22 +1371,20 @@ export async function deploy(options: DeployOptions): Promise<void> {
console.log("\n Skipping build (--skip-build)");
}

const rawNextConfig = await loadNextConfig(info.root);
const nextConfig = await resolveNextConfig(rawNextConfig, info.root);

// Step 6a: prerender — render every discovered route into dist.
// Triggered by --prerender-all, or automatically when next.config.js
// sets `output: 'export'` (every route must be statically exportable).
{
const rawNextConfig = await loadNextConfig(info.root);
const nextConfig = await resolveNextConfig(rawNextConfig, info.root);
const isStaticExport = nextConfig.output === "export";

if (options.prerenderAll || isStaticExport) {
const label =
isStaticExport && !options.prerenderAll
? "Pre-rendering all routes (output: 'export')..."
: "Pre-rendering all routes...";
console.log(`\n ${label}`);
await runPrerender({ root: info.root });
}
const isStaticExport = nextConfig.output === "export";
if (options.prerenderAll || isStaticExport) {
const label =
isStaticExport && !options.prerenderAll
? "Pre-rendering all routes (output: 'export')..."
: "Pre-rendering all routes...";
console.log(`\n ${label}`);
await runPrerender({ root: info.root });
}

// Step 6b: TPR — pre-render hot pages into KV cache (experimental, opt-in)
Expand All @@ -1360,6 +1402,12 @@ export async function deploy(options: DeployOptions): Promise<void> {
}
}

// Step 6c: Copy prerender data to dist/client/__prerender/ so the Worker
// can access it via env.ASSETS.fetch() for lazy per-route cache seeding.
// With not_found_handling: "none", these files are never auto-served —
// only accessible programmatically through the assets binding.
copyPrerenderToAssets(root, nextConfig.basePath);

// Step 7: Deploy via wrangler
const url = runWranglerDeploy(root, {
preview: options.preview ?? false,
Expand Down
60 changes: 60 additions & 0 deletions packages/vinext/src/server/seed-cache-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Shared types and helpers for seed-cache modules (Node.js and Workers).
*
* Both `seed-cache.ts` (eager, fs-based) and `seed-cache-workers.ts`
* (lazy, fetch-based) read the same vinext-prerender.json manifest and
* produce identical cache entries. This module holds the shared contract.
*/

import type { CachedAppPageValue } from "../shims/cache.js";

// ─── Manifest types ──────────────────────────────────────────────────────────

export type PrerenderManifest = {
buildId: string;
basePath?: string;
trailingSlash?: boolean;
routes: PrerenderManifestRoute[];
};

export type PrerenderManifestRoute = {
route: string;
status: string;
revalidate?: number | false;
path?: string;
router?: "app" | "pages";
};

// ─── Cache value construction ────────────────────────────────────────────────

/**
* Build the CacheHandler context object from a revalidate value.
* `revalidate: undefined` (static routes) → empty context → no expiry.
*/
export function revalidateCtx(seconds: number | undefined): Record<string, unknown> {
return seconds !== undefined ? { revalidate: seconds } : {};
}

/** Build an APP_PAGE cache value for an HTML entry. */
export function makeHtmlCacheValue(html: string): CachedAppPageValue {
return {
kind: "APP_PAGE",
html,
rscData: undefined,
headers: undefined,
postponed: undefined,
status: undefined,
};
}

/** Build an APP_PAGE cache value for an RSC entry. */
export function makeRscCacheValue(rscData: ArrayBuffer): CachedAppPageValue {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rscData parameter is typed as ArrayBuffer, which matches the CachedAppPageValue field type. But in seed-cache.ts:142-145, the Node.js codepath does rscBuffer.buffer.slice(byteOffset, byteOffset + byteLength) to extract a clean ArrayBuffer from the Node Buffer. Worth verifying that the ArrayBuffer from Response.arrayBuffer() in the Workers path (line 179) doesn't need similar treatment — in practice Workers' Response.arrayBuffer() returns a standalone ArrayBuffer with byteOffset === 0, so this should be fine. Just flagging the asymmetry.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting for future readers: rscData here accepts an ArrayBuffer directly. In the Workers codepath (seed-cache-workers.ts:187), Response.arrayBuffer() returns a standalone ArrayBuffer with byteOffset === 0, so no slicing is needed. In the Node.js codepath (seed-cache.ts:142-145), fs.readFileSync() returns a Buffer whose underlying ArrayBuffer may have a non-zero byteOffset, so the slice is required there. The asymmetry is correct but easy to miss.

return {
kind: "APP_PAGE",
html: "",
rscData,
headers: undefined,
postponed: undefined,
status: undefined,
};
}
Loading
Loading