Skip to content

Dev startup triggers heavy internal typegen + SSR dep optimizer race on Cloudflare, causing chunk errors. #484

@hayatosc

Description

@hayatosc

Description

On astro dev with the Cloudflare adapter and emdash() integration, EmDash performs an internal POST /_emdash/api/typegen immediately after the dev server starts listening.

That request is not lightweight. It goes through the full EmDash middleware/runtime path, initializes the runtime, builds the admin manifest, and triggers additional SSR dependency optimization on first access.

There are two interrelated issues at play.

  1. The typegen request fires immediately on server.httpServer.on('listening', ...) inside astro:server:setup , but at that point Vite's SSR dependency optimization may not have completed yet. Vite lazily optimizes SSR dependencies on first access, so the typegen → middleware → runtime chain pulls in a large number of SSR dependencies all at once, causing a race between optimization and request handling. This is what produces the chunk-*.js not found errors.
  2. The typegen call takes the full HTTP round-trip path: HTTP request → entire middleware stack → runtime init → manifest construction → typegen schema generation. Typegen itself shouldn't need the auth, redirect, or request-context middleware, so the request is doing significantly more work than necessary.

This makes the dev server feel very slow and sometimes temporarily unusable right after startup.

Current behavior

In astro:server:setup, EmDash fetches /_emdash/api/typegen as soon as the server is listening:

"astro:server:setup": ({ server, logger }) => {
// Generate types once the server is listening.
// The endpoint returns the types content; we write the file here
// (in Node) because workerd has no real filesystem access.
server.httpServer?.once("listening", async () => {
const { writeFile, readFile } = await import("node:fs/promises");
const { resolve } = await import("node:path");
const address = server.httpServer?.address();
if (!address || typeof address === "string") return;
const port = address.port;
const typegenUrl = `http://localhost:${port}/_emdash/api/typegen`;
const outputPath = resolve(process.cwd(), "emdash-env.d.ts");
try {
const response = await fetch(typegenUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
});

The typegen endpoint requires locals.emdash.db , so it goes through EmDash middleware first:

const registry = new SchemaRegistry(emdash.db);

The middleware does getRuntime and getManifest on src/astro/middleware.ts

/**
* Get or create the runtime instance
*/
async function getRuntime(config: EmDashConfig): Promise<EmDashRuntime> {
// Return cached instance if available
if (runtimeInstance) {
return runtimeInstance;
}
// If another request is already initializing, wait and retry.
// We don't share the promise across requests because workerd flags
// cross-request promise resolution (causes warnings + potential hangs).
if (runtimeInitializing) {
// Poll until the initializing request finishes
await new Promise((resolve) => setTimeout(resolve, 50));
return getRuntime(config);
}
runtimeInitializing = true;
try {
const deps = buildDependencies(config);
const runtime = await EmDashRuntime.create(deps);
runtimeInstance = runtime;
return runtime;
} finally {
runtimeInitializing = false;
}
}

After runtime init, getManifest() reads collections and fields again from the DB:

async getManifest(): Promise<EmDashManifest> {
// Build collections from database.
// Use this.db (ALS-aware getter) so playground mode picks up the
// per-session DO database instead of the hardcoded singleton.
const manifestCollections: Record<string, ManifestCollection> = {};
try {
const registry = new SchemaRegistry(this.db);
const dbCollections = await registry.listCollections();
for (const collection of dbCollections) {
const collectionWithFields = await registry.getCollectionWithFields(collection.slug);
const fields: Record<
string,
{
kind: string;
label?: string;
required?: boolean;
widget?: string;
// Two shapes: legacy enum-style `[{ value, label }]` for select widgets,
// or arbitrary `Record<string, unknown>` for plugin field widgets that
// need per-field config (e.g. a checkbox grid receiving its column defs).
options?: Array<{ value: string; label: string }> | Record<string, unknown>;
}
> = {};
if (collectionWithFields?.fields) {
for (const field of collectionWithFields.fields) {
const entry: (typeof fields)[string] = {
kind: FIELD_TYPE_TO_KIND[field.type] ?? "string",
label: field.label,
required: field.required,
};
if (field.widget) entry.widget = field.widget;
// Plugin field widgets read their per-field config from `field.options`,
// which the seed schema types as `Record<string, unknown>`. Pass it
// through to the manifest so plugin widgets in the admin SPA receive it.
if (field.options) {
entry.options = field.options;
}
// Legacy: select/multiSelect enum options live on `field.validation.options`.
// Wins over `field.options` to preserve existing behavior for enum widgets.
if (field.validation?.options) {
entry.options = field.validation.options.map((v) => ({
value: v,
label: v.charAt(0).toUpperCase() + v.slice(1),
}));
}
// Include full validation for repeater fields (subFields, minItems, maxItems)
if (field.type === "repeater" && field.validation) {
(entry as Record<string, unknown>).validation = field.validation;
}
fields[field.slug] = entry;
}
}
manifestCollections[collection.slug] = {
label: collection.label,
labelSingular: collection.labelSingular || collection.label,
supports: collection.supports || [],
hasSeo: collection.hasSeo,
urlPattern: collection.urlPattern,
fields,
};
}
} catch (error) {
console.debug("EmDash: Could not load database collections:", error);
}
// Build plugins manifest
const manifestPlugins: Record<
string,
{
version?: string;
enabled?: boolean;
sandboxed?: boolean;
adminMode?: "react" | "blocks" | "none";
adminPages?: Array<{ path: string; label?: string; icon?: string }>;
dashboardWidgets?: Array<{
id: string;
title?: string;
size?: string;
}>;
portableTextBlocks?: Array<{
type: string;
label: string;
icon?: string;
description?: string;
placeholder?: string;
fields?: Element[];
}>;
fieldWidgets?: Array<{
name: string;
label: string;
fieldTypes: string[];
elements?: Element[];
}>;
}
> = {};
for (const plugin of this.configuredPlugins) {
const status = this.pluginStates.get(plugin.id);
const enabled = status === undefined || status === "active";
// Determine admin mode: has admin entry → react, has pages/widgets → blocks, else none
const hasAdminEntry = !!plugin.admin?.entry;
const hasAdminPages = (plugin.admin?.pages?.length ?? 0) > 0;
const hasWidgets = (plugin.admin?.widgets?.length ?? 0) > 0;
let adminMode: "react" | "blocks" | "none" = "none";
if (hasAdminEntry) {
adminMode = "react";
} else if (hasAdminPages || hasWidgets) {
adminMode = "blocks";
}
manifestPlugins[plugin.id] = {
version: plugin.version,
enabled,
adminMode,
adminPages: plugin.admin?.pages ?? [],
dashboardWidgets: plugin.admin?.widgets ?? [],
portableTextBlocks: plugin.admin?.portableTextBlocks,
fieldWidgets: plugin.admin?.fieldWidgets,
};
}
// Add sandboxed plugins (use entries for admin config)
// TODO: sandboxed plugins need fieldWidgets extracted from their manifest
// to support Block Kit field widgets. Currently only trusted plugins carry
// fieldWidgets through the admin.fieldWidgets path.
for (const entry of this.sandboxedPluginEntries) {
const status = this.pluginStates.get(entry.id);
const enabled = status === undefined || status === "active";
const hasAdminPages = (entry.adminPages?.length ?? 0) > 0;
const hasWidgets = (entry.adminWidgets?.length ?? 0) > 0;
manifestPlugins[entry.id] = {
version: entry.version,
enabled,
sandboxed: true,
adminMode: hasAdminPages || hasWidgets ? "blocks" : "none",
adminPages: entry.adminPages ?? [],
dashboardWidgets: entry.adminWidgets ?? [],
};
}
// Add marketplace-installed plugins (dynamically loaded from R2)
for (const [pluginId, meta] of marketplaceManifestCache) {
// Skip if already included from build-time config
if (manifestPlugins[pluginId]) continue;
const status = this.pluginStates.get(pluginId);
const enabled = status === "active";
const pages = meta.admin?.pages;
const widgets = meta.admin?.widgets;
const hasAdminPages = (pages?.length ?? 0) > 0;
const hasWidgets = (widgets?.length ?? 0) > 0;
manifestPlugins[pluginId] = {
version: meta.version,
enabled,
sandboxed: true,
adminMode: hasAdminPages || hasWidgets ? "blocks" : "none",
adminPages: pages ?? [],
dashboardWidgets: widgets ?? [],
};
}
// Build taxonomies from database
let manifestTaxonomies: Array<{
name: string;
label: string;
labelSingular?: string;
hierarchical: boolean;
collections: string[];
}> = [];
try {
const rows = await this.db
.selectFrom("_emdash_taxonomy_defs")
.selectAll()
.orderBy("name")
.execute();
manifestTaxonomies = rows.map((row) => ({
name: row.name,
label: row.label,
labelSingular: row.label_singular ?? undefined,
hierarchical: row.hierarchical === 1,
collections: row.collections ? (JSON.parse(row.collections) as string[]).toSorted() : [],
}));
} catch (error) {
console.debug("EmDash: Could not load taxonomy definitions:", error);
}
// Build manifest hash
const manifestHash = await hashString(
JSON.stringify(manifestCollections) +
JSON.stringify(manifestPlugins) +
JSON.stringify(manifestTaxonomies),
);
// Determine auth mode
const authMode = getAuthMode(this.config);
const authModeValue = authMode.type === "external" ? authMode.providerType : "passkey";
// Include i18n config if enabled (read from virtual module to avoid SSR module singleton mismatch)
const i18nConfig = virtualConfig?.i18n;
const i18n =
i18nConfig && i18nConfig.locales && i18nConfig.locales.length > 1
? { defaultLocale: i18nConfig.defaultLocale, locales: i18nConfig.locales }
: undefined;
return {
version: "0.1.0",
hash: manifestHash,
collections: manifestCollections,
plugins: manifestPlugins,
taxonomies: manifestTaxonomies,
authMode: authModeValue,
i18n,
marketplace: !!this.config.marketplace,
};
}

Then the typegen endpoint reads collections/fields again to generate TS types:

/**
* Generate types content and metadata from the current schema.
*/
async function generateTypes(registry: SchemaRegistry) {
const { generateTypesFile, generateSchemaHash } = await import("#schema/zod-generator.js");
const collections = await safeListCollections(registry);
const collectionsWithFields = await Promise.all(
collections.map(async (c) => {
const fields = await registry.listFields(c.id);
return { ...c, fields };
}),
);
const types = generateTypesFile(collectionsWithFields);
const hash: string = await generateSchemaHash(collectionsWithFields);
return { types, hash, collections: collections.length };
}

So the same internal request does both manifest schema loading and typegen schema loading.

Also, even though EmDash configures ssr.optimizeDeps.include, in my cold-start logs Vite still optimized these later, one by one:

  • emdash/middleware
  • emdash/middleware/redirect
  • emdash/middleware/setup
  • emdash/middleware/auth
  • emdash/middleware/request-context
  • @emdash-cms/cloudflare/db/d1
  • emdash/media/local-runtime
  • @emdash-cms/cloudflare/storage/r2
  • emdash/ui
  • astro/zod
  • emdash/runtime

This seems to be the unstable part that causes the deps_ssr missing-file failures during reload.

Steps to reproduce

just start astro dev

Environment

  • Astro: 6.1.5
  • EmDash: 0.1.1
  • @astrojs/cloudflare: 13.1.8
  • @emdash-cms/cloudflare: 0.1.1

Logs / error output

The file does not exist at ".../node_modules/.vite/deps_ssr/chunk-*.js"
The dependency might be incompatible with the dep optimizer. Try adding it to optimizeDeps.exclude.

Suggested Change

Instead of POST /_emdash/api/typegen through the full middleware stack, call the typegen logic directly in astro:server:setup:

// In the integration's astro:server:setup hook
import { generateTypes } from '../routes/api/typegen';

server.httpServer?.on('listening', async () => {
  // Initialize only what typegen needs (DB + schema),
  // skip auth/redirect/request-context middleware
  const runtime = await getRuntime(config);
  await generateTypes(runtime);
});

This eliminates the middleware overhead and avoids triggering SSR dep optimization for middleware-related modules (emdash/middleware/*, etc.) at startup.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions