Skip to content
Merged
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/layout-prefetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Speeds up public page loads on remote databases (D1, Durable Objects) by eagerly warming site-global layout data (menus, widget areas, taxonomy term lists, settings) at the start of the request, so the layout's per-component reads overlap into roughly one round trip instead of executing serially. Transparent to site code; no template changes needed.
22 changes: 22 additions & 0 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { isMissingTableError } from "../utils/db-errors.js";
import { createInitLock, type InitLock, initWithLock } from "../utils/init-lock.js";
import type { EmDashConfig } from "./integration/runtime.js";
import { wrapBodyForStreamMetrics } from "./middleware/stream-end-metrics.js";
import { prefetchLayoutData } from "./prefetch.js";
import { createPublicPluginApiRouteHandler } from "./public-plugin-api-routes.js";
import type { EmDashHandlers } from "./types.js";

Expand Down Expand Up @@ -512,7 +513,28 @@ export const onRequest = defineMiddleware(async (context, next) => {
const ctx = parent
? { ...parent, db: anonScoped.db }
: { editMode: false, db: anonScoped.db, metrics };
// Eagerly warm site-global layout data (menus, widget areas,
// taxonomy terms, settings) concurrently so the layout's
// per-component reads overlap into ~one wall-clock round trip and
// hit a warm cache instead of serializing. Three guards:
// - request-scoped (remote) backend only -- this branch implies it;
// pointless on synchronous local SQLite.
// - HTML navigations only -- feeds/sitemaps/JSON don't render the
// layout, so prefetching their chrome is pure waste.
// - via after(): it runs immediately (still warms the render) but
// hands the promise to waitUntil, so the surplus warm-up (chrome a
// given page doesn't render) is kept alive past the response rather
// than erroring on workerd as orphaned request I/O.
// Gate on the CLIENT'S PREFERRED type (leading media range), not a
// substring -- browser navigations lead with `text/html`, while feed
// readers lead with `application/rss+xml` etc. and only list
// `text/html;q=0.8` later, so a substring match would leak onto feeds.
const acceptsHtml = (request.headers.get("accept") ?? "")
.split(",", 1)[0]!
.trim()
.startsWith("text/html");
return runWithContext(ctx, async () => {
if (acceptsHtml) after(() => prefetchLayoutData());
// commit() in finally: the write reached the primary independently
// of render, so the bookmark cookie must be persisted even if
// render throws -- otherwise a write-then-failed-render leaves the
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/astro/prefetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Eager, transparent prefetch of site-global "chrome" data.
*
* On a public page render, the shared layout pulls the same site-global data on
* every request -- menus, widget areas, taxonomy term lists, site settings --
* but each is awaited inside a separately-rendered Astro component, so they
* execute as serial DB round trips. This fires them all CONCURRENTLY at the
* very start of the request, before `next()`:
*
* - On remote backends (D1, Durable Objects) the round trips overlap instead
* of serializing, collapsing ~N sequential RTTs into ~1 wall-clock RTT. On
* a coalescing backend they additionally batch into a single round trip.
* - The results land in the per-request `requestCached` store under the exact
* keys the layout helpers use, so when the components render they hit a warm
* (in-flight or resolved) cache entry instead of issuing their own query.
*
* Nothing here changes what templates call -- it warms the real helpers, so the
* cache keys and value shapes are guaranteed identical. The caller gates this to
* the public-page path on a request-scoped (remote) backend; it is a no-op-ish
* waste on synchronous local SQLite, so don't call it there.
*
* Fire-and-forget: never awaited by middleware, never throws (a prefetch failure
* must not affect the request -- the helpers will simply run on demand).
*/

import { getDb } from "../loader.js";
import { getMenu } from "../menus/index.js";
import { setRequestCacheEntry } from "../request-cache.js";
import { getSiteSettings } from "../settings/index.js";
import { getTaxonomyDefs, getTaxonomyTerms } from "../taxonomies/index.js";
import { getWidgetAreas } from "../widgets/index.js";

/** Warm widget areas: one bulk load, primed under each per-area cache key. */
async function prefetchWidgetAreas(): Promise<void> {
const areas = await getWidgetAreas();
// getWidgetArea(name) caches under `widget-area:${name}` and returns the same
// WidgetArea shape getWidgetAreas yields, so priming here makes those calls hit.
for (const area of areas) {
setRequestCacheEntry(`widget-area:${area.name}`, area);
}
}

/** Warm every taxonomy's term list via the real helper (primes per-name keys). */
async function prefetchTaxonomyTerms(): Promise<void> {
const defs = await getTaxonomyDefs();
await Promise.allSettled(defs.map((def) => getTaxonomyTerms(def.name)));
}

/** Warm every menu via the real helper (primes `menu:${name}:${locale}`). */
async function prefetchMenus(): Promise<void> {
const db = await getDb();
// The layout calls getMenu(name) with hardcoded names; we can't know them, so
// discover every menu name and warm them all (small, bounded chrome table).
const rows = await db.selectFrom("_emdash_menus").select("name").distinct().execute();

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.

[suggestion] This SELECT DISTINCT name FROM _emdash_menus runs on every anonymous HTML request and is awaited before the per-menu getMenu(name) calls, so on a non-coalescing D1 session it adds a serial round trip that partially offsets the overlap win this PR is after (on a coalescing backend it batches for free, which is the better case). Menu names change only when an admin creates/renames/deletes a menu, so this is a good candidate for an isolate-scoped cache mirroring getSiteSettings()'s SITE_SETTINGS_CACHE_KEY / invalidateSiteSettingsCache() (settings/index.ts), invalidated from the menu write paths. After the first request per isolate the discovery would be a free cache hit instead of a per-request query. Not a correctness issue — the prefetch is correct as written — just the one per-request cost worth eliminating if the measurement shows the discovery RTT matters.

const names = [...new Set(rows.map((r) => r.name))];
await Promise.allSettled(names.map((name) => getMenu(name)));
}

/**
* Concurrently warm the site-global layout data for the current request.
* Safe to call only inside the request ALS frame that owns the (remote)
* request-scoped db. Never throws.
*/
export async function prefetchLayoutData(): Promise<void> {
try {
await Promise.allSettled([
getSiteSettings(),
prefetchMenus(),
prefetchWidgetAreas(),
prefetchTaxonomyTerms(),
]);
} catch (error) {
// Defensive: Promise.allSettled shouldn't reject, but never let a prefetch
// failure surface to the request.
console.error("[emdash] layout prefetch failed (non-fatal):", error);
}
}
18 changes: 18 additions & 0 deletions packages/core/tests/unit/menus/menu-request-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ vi.mock("../../../src/loader.js", () => ({
getDb: vi.fn(),
}));

import { prefetchLayoutData } from "../../../src/astro/prefetch.js";
import { getDb } from "../../../src/loader.js";
import { getMenu } from "../../../src/menus/index.js";
import { runWithContext } from "../../../src/request-context.js";
Expand Down Expand Up @@ -118,4 +119,21 @@ describe("getMenu collection-pattern request cache", () => {

expect(collectionPatternQueries()).toHaveLength(1);
});

it("prefetchLayoutData warms menus so layout getMenu calls hit the cache", async () => {
await runWithContext({ editMode: false }, async () => {
// Eager prefetch discovers every menu name and warms them up front.
await prefetchLayoutData();

// Anything the layout reads afterwards must be served from the
// request cache the prefetch populated -- zero further queries.
queries = [];
const primary = await getMenu("primary");
const footer = await getMenu("footer");

expect(primary?.items.map((i) => i.url)).toEqual(["/blog/hello"]);
expect(footer?.items.map((i) => i.url)).toEqual(["/blog/hello"]);
expect(queries).toHaveLength(0);
});
});
});
Loading