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/quick-pandas-dev.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Improves dev server startup time. The content config and dev server app module graphs now begin compiling during server creation without blocking the server from listening. Request handling waits for the shared setup result when needed, cutting `astro dev` ready time by roughly a third on projects with a content config.
52 changes: 52 additions & 0 deletions packages/astro/src/content/config-prewarm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type fsMod from 'node:fs';
import type { RunnableDevEnvironment } from 'vite';
import type { AstroLogger } from '../core/logger/core.js';
import type { AstroSettings } from '../types/astro.js';
import { getContentPaths, reloadContentConfigObserver } from './utils.js';

/**
* Starts the content config load as early as possible during dev startup so its
* on-demand Vite compilation overlaps other startup work instead of blocking it.
*
* The types generator (`createContentTypesGenerator#init`) and the dev server
* app compile both dedupe on the same in-flight promise, so the config is
* compiled and evaluated exactly once per dev server instance.
*/
let inFlight: Promise<void> | undefined;

export function getContentConfigLoadPromise(): Promise<void> | undefined {
return inFlight;
}

export function kickOffContentConfigLoad({
settings,
fs,
logger,
environment,
}: {
settings: AstroSettings;
fs: typeof fsMod;
logger: AstroLogger;
environment: RunnableDevEnvironment;
}): Promise<void> {
if (inFlight) {
return inFlight;
}
const contentPaths = getContentPaths(
settings.config,
fs,
settings.config.legacy?.collectionsBackwardsCompat,
);
if (!contentPaths.config.exists) {
return Promise.resolve();
}
inFlight = reloadContentConfigObserver({
fs,
settings,
environment,
logger,
}).finally(() => {
inFlight = undefined;
});
return inFlight;
}
26 changes: 18 additions & 8 deletions packages/astro/src/content/types-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
getEntryType,
reloadContentConfigObserver,
} from './utils.js';
import { getContentConfigLoadPromise } from './config-prewarm.js';
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js';

type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
Expand Down Expand Up @@ -137,14 +138,23 @@ export async function createContentTypesGenerator({
return { shouldGenerateTypes: false };
}
if (fileType === 'config') {
await reloadContentConfigObserver({
fs,
settings,
environment: viteServer.environments[
ASTRO_VITE_ENVIRONMENT_NAMES.astro
] as RunnableDevEnvironment,
logger,
});
// Dev server creation may already be loading this config. Wait for that
// exact load when present; otherwise this path owns the reload. Do not
// infer ownership from the global observer status because `astro sync`
// can retain a settled status from an earlier invocation in the process.
const prewarm = getContentConfigLoadPromise();
if (prewarm) {
await prewarm;
} else {
await reloadContentConfigObserver({
fs,
settings,
environment: viteServer.environments[
ASTRO_VITE_ENVIRONMENT_NAMES.astro
] as RunnableDevEnvironment,
logger,
});
}
return { shouldGenerateTypes: true };
}

Expand Down
6 changes: 6 additions & 0 deletions packages/astro/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ export const fetchStateSymbol = Symbol.for('astro.fetchState');
*/
export const devPrerenderMiddlewareSymbol = Symbol.for('astro.devPrerenderMiddleware');

/**
* A promise for background dev server app setup that must settle before Vite's
* runnable environments are closed.
*/
export const devServerAppReadySymbol = Symbol.for('astro.devServerAppReady');

/**
* The symbol used as a field on the request object to store a cleanup callback associated with aborting the request when the underlying socket closes.
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/core/dev/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '../../integrations/hooks.js';
import type { AstroSettings } from '../../types/astro.js';
import type { AstroInlineConfig } from '../../types/public/config.js';
import { devServerAppReadySymbol } from '../constants.js';
import { createVite } from '../create-vite.js';
import type { AstroLogger } from '../logger/core.js';
import { createRoutesList } from '../routing/create-manifest.js';
Expand Down Expand Up @@ -152,6 +153,10 @@ export async function createContainer({
}

async function closeContainer({ viteServer, settings, logger }: Container) {
// Background app setup uses the runnable environment's module runner. Let it
// settle before Vite disconnects that runner so shutdown cannot strand an
// in-flight module import.
await (viteServer as any)[devServerAppReadySymbol];
await viteServer.close();
await runHookServerDone({
config: settings.config,
Expand Down
65 changes: 54 additions & 11 deletions packages/astro/src/vite-plugin-astro-server/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import nodeFs from 'node:fs';
import { IncomingMessage } from 'node:http';
import type * as vite from 'vite';
import { isRunnableDevEnvironment, type RunnableDevEnvironment } from 'vite';
import type { RouteInfo, SSRManifest } from '../core/app/types.js';
import { ASTRO_VITE_ENVIRONMENT_NAMES, devPrerenderMiddlewareSymbol } from '../core/constants.js';
import {
ASTRO_VITE_ENVIRONMENT_NAMES,
devPrerenderMiddlewareSymbol,
devServerAppReadySymbol,
} from '../core/constants.js';
import { getViteErrorPayload } from '../core/errors/dev/index.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import type { AstroLogger } from '../core/logger/core.js';
import { createViteLoader } from '../core/module-loader/index.js';
import { matchAllRoutes } from '../core/routing/match.js';
import { SERIALIZED_MANIFEST_ID } from '../manifest/serialized.js';
import type { AstroSettings } from '../types/astro.js';
import { kickOffContentConfigLoad } from '../content/config-prewarm.js';
import { ASTRO_DEV_SERVER_APP_ID } from '../vite-plugin-app/index.js';
import { baseMiddleware } from './base.js';
import { createController } from './controller.js';
Expand Down Expand Up @@ -67,15 +73,48 @@ export default function createVitePluginAstroServer({
return { controller, handler, loader, manifest, environment };
}

const ssrHandler = runnableSsrEnvironment
? await createHandler(runnableSsrEnvironment)
// Kick off the content config load and dev server app compile as early
// as possible. Neither is needed until the first request, so install the
// middleware synchronously and let request handlers await the in-flight
// results without blocking server creation. Serialize the app compile
// after the config load so they do not contend for the main thread.
const astroEnvironment = viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.astro];
const runnableAstroEnvironment = isRunnableDevEnvironment(astroEnvironment)
? (astroEnvironment as RunnableDevEnvironment)
: undefined;
const prerenderHandler = runnablePrerenderEnvironment
? await createHandler(runnablePrerenderEnvironment)
const contentConfigLoad = runnableAstroEnvironment
? kickOffContentConfigLoad({
settings,
fs: nodeFs,
logger,
environment: runnableAstroEnvironment,
})
: Promise.resolve();

const ssrHandlerPromise = runnableSsrEnvironment
? contentConfigLoad.then(() => createHandler(runnableSsrEnvironment))
: undefined;
const prerenderHandlerPromise = runnablePrerenderEnvironment
? contentConfigLoad.then(() => createHandler(runnablePrerenderEnvironment))
: undefined;

// Background setup uses the runnable environments' module runners. Keep
// shutdown from disconnecting those runners while an import is in flight.
(viteServer as any)[devServerAppReadySymbol] = Promise.allSettled(
[contentConfigLoad, ssrHandlerPromise, prerenderHandlerPromise].filter(Boolean),
).then(() => undefined);

// Compile failures surface here so startup still reports them, and also
// as a rejected lazy await in the request handlers below.
ssrHandlerPromise?.catch((error) => {
logger.error(null, `Failed to create the dev server app: ${error?.message ?? error}`);
});
prerenderHandlerPromise?.catch((error) => {
logger.error(null, `Failed to create the prerender server app: ${error?.message ?? error}`);
});
const localStorage = new AsyncLocalStorage();

function handleUnhandledRejection(rejection: any) {
async function handleUnhandledRejection(rejection: any) {
const error = AstroError.is(rejection)
? rejection
: new AstroError({
Expand All @@ -84,9 +123,11 @@ export default function createVitePluginAstroServer({
});
const store = localStorage.getStore();
const handlers = [];
if (ssrHandler) handlers.push(ssrHandler);
if (prerenderHandler) handlers.push(prerenderHandler);
if (ssrHandlerPromise) handlers.push(await ssrHandlerPromise.catch(() => undefined));
if (prerenderHandlerPromise)
handlers.push(await prerenderHandlerPromise.catch(() => undefined));
for (const currentHandler of handlers) {
if (!currentHandler) continue;
if (store instanceof IncomingMessage) {
setRouteError(currentHandler.controller.state, store.url!, error);
}
Expand All @@ -104,7 +145,7 @@ export default function createVitePluginAstroServer({
}
}

if (ssrHandler || prerenderHandler) {
if (runnableSsrEnvironment || runnablePrerenderEnvironment) {
process.on('unhandledRejection', handleUnhandledRejection);
viteServer.httpServer?.on('close', () => {
process.off('unhandledRejection', handleUnhandledRejection);
Expand Down Expand Up @@ -137,7 +178,7 @@ export default function createVitePluginAstroServer({
handle: secFetchMiddleware(logger, settings.config.security?.allowedDomains),
});

if (prerenderHandler && shouldHandlePrerenderInCore) {
if (runnablePrerenderEnvironment && shouldHandlePrerenderInCore) {
viteServer.middlewares.use(
async function astroDevPrerenderHandler(request, response, next) {
if (request.url === undefined || !request.method) {
Expand All @@ -155,6 +196,7 @@ export default function createVitePluginAstroServer({
}

try {
const prerenderHandler = await prerenderHandlerPromise!;
const pathname = decodeURI(new URL(request.url, 'http://localhost').pathname);
const { routes } = (await prerenderHandler.environment.runner.import(
'virtual:astro:routes',
Expand Down Expand Up @@ -184,7 +226,7 @@ export default function createVitePluginAstroServer({
);
}

if (ssrHandler) {
if (runnableSsrEnvironment) {
// Note that this function has a name so other middleware can find it.
viteServer.middlewares.use(async function astroDevHandler(request, response) {
if (request.url === undefined || !request.method) {
Expand All @@ -193,6 +235,7 @@ export default function createVitePluginAstroServer({
return;
}

const ssrHandler = await ssrHandlerPromise!;
localStorage.run(request, () => {
ssrHandler.handler(request, response);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/test/content-frontmatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('frontmatter (loadFixture)', () => {
fs.writeFileSync(blogPath, originalContent);
});

it('errors in content/ does not crash server', { timeout: 2000 }, async () => {
it('errors in content/ does not crash server', { timeout: 5000 }, async () => {
// Verify server is alive
const res1 = await fixture.fetch('/');
assert.equal(res1.status, 200);
Expand Down
Loading