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
5 changes: 5 additions & 0 deletions .changeset/cold-cache-ssr-deps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Pre-bundle runtime-reached SSR deps (middleware chain, lazy Cloudflare adapter entrypoints, `emdash/ui`, `emdash/runtime`, `astro/zod`) in the Cloudflare branch of `ssr.optimizeDeps.include`. Without these the cold Vite dev cache discovered them at request time and could cascade a `deps_ssr` re-optimize race that crashed the first SSR requests.
35 changes: 35 additions & 0 deletions packages/core/src/astro/integration/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,41 @@ export function createViteConfig(
"astro/assets/fonts/runtime.js",
"astro/assets/services/noop",
"@astrojs/cloudflare/image-service",
// Runtime-reached SSR deps the startup scan can't see: the
// middleware chain sits behind the excluded `virtual:emdash`
// boundary, adapter entrypoints load lazily on first query, and
// ui/runtime/portabletext are imported during first render.
// Without these, the cold dev cache discovers them at request
// time and cascades a deps_ssr re-optimize race. Guarded by
// tests/integration/smoke/dep-optimizer-cold-cache.test.ts.
"emdash/ui",
"emdash/runtime",
"emdash/media/local-runtime",
"emdash/middleware",
"emdash/middleware/redirect",
"emdash/middleware/setup",
"emdash/middleware/auth",
"emdash/middleware/request-context",
// Astro's public re-export -- a different specifier than the
// `astro > zod/v4` entry above, so it must be listed separately.
"astro/zod",
// Cloudflare adapter entrypoints, loaded lazily on first
// content query / media op.
"@emdash-cms/cloudflare/db/d1",
"@emdash-cms/cloudflare/storage/r2",
// Bare specifiers for deps already listed above in their
// `emdash > ...` / `emdash > @emdash-cms/admin > ...` chained
// form. The chained form pre-bundles the copy resolved through
// the emdash package graph; at request time these resolve as
// top-level specifiers instead (a distinct Vite optimize key,
// same as `astro/zod` vs `astro > zod/v4` above). The cold-cache
// guard test re-optimized on exactly these until both forms were
// listed, so do NOT remove them as duplicates -- they are not.
"@lingui/react",
"@oslojs/crypto/hmac",
"@oslojs/crypto/subtle",
"@oslojs/crypto/rsa",
"@cloudflare/kumo/primitives",
],
},
}
Expand Down
169 changes: 169 additions & 0 deletions packages/core/tests/integration/smoke/dep-optimizer-cold-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { spawn } from "node:child_process";
import { rmSync } from "node:fs";
import { join, resolve } from "node:path";

import { describe, expect, it } from "vitest";

import { ensureBuilt } from "../server.js";

// A Cloudflare-adapter site that is proven to boot in CI (it is part of the
// site-matrix smoke suite). We reuse it rather than an isolated site because
// the only CF sites NOT in the matrix are unusable here: demos/cloudflare
// needs Cloudflare Access secrets, and demos/preview uses a Durable-Object
// preview DB that dev-bypass cannot migrate. fileParallelism:false in
// vitest.smoke.config.ts guarantees this never boots concurrently with the
// matrix, so sharing the site (and its node_modules/.vite) is safe.
const WORKSPACE_ROOT = resolve(import.meta.dirname, "../../../../..");
const SITE_DIR = resolve(WORKSPACE_ROOT, "templates/starter-cloudflare");
const PORT = 4620; // unused by the matrix (4603, 4612-4618)
const BASE_URL = `http://localhost:${PORT}`;

// Deterministic signal of a missing force-include: on a cold cache, any SSR
// dep not in optimizeDeps.include is discovered at request time and triggers
// a post-startup re-optimize. (The downstream deps_ssr "file does not exist"
// crash is a race between overlapping re-optimizes and is NOT reliable to
// assert on -- a single missing dep usually only re-optimizes, not crashes.)
const REOPTIMIZE_RE = /new dependencies optimized|optimized dependencies changed|re-optimizing/i;
// Completeness: if the race does manifest, catch it too. These two phrases are
// genuine Vite dev-server log lines emitted only on a real deps_ssr cascade --
// not transient cold-start traces that fetchWithRetry recovers from -- so this
// stays a deterministic signal and not a CI flake source. (The primary guard is
// REOPTIMIZE_RE above; a real cascade always emits re-optimize lines too.)
const CRASH_RE = /does not exist at|An error happened during full reload/i;

async function waitForServer(url: string, timeoutMs: number): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(3000) });
if (res.status > 0) return;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`);
}

async function fetchWithRetry(url: string, retries = 8, delayMs = 1500): Promise<void> {
Comment thread
MA2153 marked this conversation as resolved.
// Mid-cascade requests can 500; retry so a transient 5xx doesn't mask the
// log-based assertion. We don't care about the body, only that we drove the
// route so its imports are reached. If every attempt fails we throw -- a
// silent return would let the test pass without exercising the route, hiding
// a missing force-include behind a false green.
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(15_000) });
if (res.status < 500) return;
lastError = new Error(`${url} returned ${res.status}`);
} catch (error) {
lastError = error;
}
if (attempt < retries) await new Promise((r) => setTimeout(r, delayMs));
}
throw lastError instanceof Error ? lastError : new Error(`Request failed for ${url}`);
}

Comment thread
MA2153 marked this conversation as resolved.
/**
* Resolve once server output stops growing for `quietMs`, capped at `capMs`.
Comment thread
MA2153 marked this conversation as resolved.
* Defaults are generous so a slow CI runner that pauses mid-re-optimize-burst
* doesn't settle early and produce a false green.
*/
async function waitForOutputToSettle(
getOutput: () => string,
quietMs = 3000,
capMs = 15_000,
): Promise<void> {
const start = Date.now();
let last = getOutput().length;
let lastChange = Date.now();
while (Date.now() - start < capMs) {
await new Promise((r) => setTimeout(r, 500));
const now = getOutput().length;
if (now !== last) {
last = now;
lastChange = Date.now();
} else if (Date.now() - lastChange >= quietMs) {
return;
}
}
}

describe.sequential("cold-cache SSR dep optimizer (cloudflare)", () => {
it(
"force-includes all runtime-reached SSR deps (zero post-startup re-optimizations)",
{ timeout: 240_000 },
async () => {
// ensureBuilt() skips the build if the CLI binary already exists, so it
// does NOT rebuild after a source edit. The dev server loads emdash's
// compiled dist (the integration vite config comes from dist, not src),
// so after editing vite-config.ts locally run `pnpm build` before this
// test or it will measure the stale build. CI always builds first, so
// it's correct there.
await ensureBuilt();

// Cold cache: wipe Vite's dep cache and any stale DB so the optimizer
// runs from scratch. This is what reproduces the bug -- a warm .vite
// hides it.
rmSync(join(SITE_DIR, "node_modules", ".vite"), { recursive: true, force: true });
for (const f of ["data.db", "data.db-wal", "data.db-shm"]) {
rmSync(join(SITE_DIR, f), { force: true });
}

const server = spawn("pnpm", ["exec", "astro", "dev", "--port", String(PORT)], {
cwd: SITE_DIR,
env: { ...process.env, CI: "true" },
stdio: "pipe",
});

let output = "";
server.stdout?.on("data", (d: Buffer) => (output += d.toString()));
server.stderr?.on("data", (d: Buffer) => (output += d.toString()));

try {
await waitForServer(`${BASE_URL}/_emdash/admin/`, 180_000);

// Drive the routes that reach the runtime-only deps:
// - dev-bypass: middleware chain, auth, request-context, D1 entrypoint
// - frontend: emdash/ui, emdash/runtime, portabletext render path
// - admin: admin shell SSR (lingui, kumo)
// NOTE: R2 / media (emdash/media/local-runtime,
// @emdash-cms/cloudflare/storage/r2) need an actual media op to be
// exercised and are not covered here; this guard covers the
// dev-bypass + frontend + admin import graph.
await fetchWithRetry(`${BASE_URL}/_emdash/api/setup/dev-bypass?redirect=/`);
await fetchWithRetry(`${BASE_URL}/`);
await fetchWithRetry(`${BASE_URL}/_emdash/admin/`);

// Let any async re-optimize logs land before asserting.
await waitForOutputToSettle(() => output);

const reoptimizes = output.split("\n").filter((l) => REOPTIMIZE_RE.test(l));
const crashes = output.split("\n").filter((l) => CRASH_RE.test(l));

expect(
reoptimizes,
`Vite re-optimized SSR deps after startup -- a dep reached at request time ` +
`is missing from the cloudflare branch of ssr.optimizeDeps.include in ` +
`packages/core/src/astro/integration/vite-config.ts. Offending log lines:\n` +
reoptimizes.join("\n"),
).toEqual([]);
expect(crashes, `deps_ssr cascade crash detected:\n${crashes.join("\n")}`).toEqual([]);
} catch (error) {
throw new Error(
`cold-cache dep-optimizer guard failed: ${error instanceof Error ? error.message : String(error)}\n\n` +
output.slice(-3000),
{ cause: error },
);
} finally {
server.kill("SIGTERM");
await new Promise((r) => setTimeout(r, 1200));
Comment thread
MA2153 marked this conversation as resolved.
// `killed` flips true the moment kill() is called, not when the process
// exits -- check exitCode to know if SIGTERM was actually honored.
if (server.exitCode === null) server.kill("SIGKILL");
await new Promise((r) => setTimeout(r, 500));
Comment thread
MA2153 marked this conversation as resolved.
}
},
);
});
5 changes: 5 additions & 0 deletions packages/core/vitest.smoke.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,10 @@ export default defineConfig({
// when pnpm build hasn't been cached.
testTimeout: 30_000,
hookTimeout: 120_000,
// Smoke files boot real dev servers against shared template/demo
// sites. Two files booting the same site concurrently would race on
// that site's node_modules/.vite cache (the cold-cache dep-optimizer
// guard wipes it). Run smoke files one at a time.
fileParallelism: false,
},
});
Loading