Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Graceful error recovery in the dev server #5198

Merged
merged 8 commits into from
Nov 1, 2022
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
7 changes: 7 additions & 0 deletions .changeset/thin-trains-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'astro': patch
---

HMR - Improved error recovery

This improves error recovery for HMR. Now when the dev server finds itself in an error state (because a route contained an error), it will recover from that state and refresh the page when the user has corrected the mistake.
4 changes: 3 additions & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
"dev": "astro-scripts dev --prebuild \"src/runtime/server/astro-island.ts\" --prebuild \"src/runtime/client/{idle,load,media,only,visible}.ts\" \"src/**/*.ts\"",
"postbuild": "astro-scripts copy \"src/**/*.astro\"",
"benchmark": "node test/benchmark/dev.bench.js && node test/benchmark/build.bench.js",
"test:unit": "mocha --exit --timeout 2000 ./test/units/**/*.test.js",
"test:unit": "mocha --exit --timeout 30000 ./test/units/**/*.test.js",
"test": "pnpm run test:unit && mocha --exit --timeout 20000 --ignore **/lit-element.test.js && mocha --timeout 20000 **/lit-element.test.js",
"test:match": "mocha --timeout 20000 -g",
"test:e2e": "playwright test",
Expand Down Expand Up @@ -189,8 +189,10 @@
"astro-scripts": "workspace:*",
"chai": "^4.3.6",
"cheerio": "^1.0.0-rc.11",
"memfs": "^3.4.7",
"mocha": "^9.2.2",
"node-fetch": "^3.2.5",
"node-mocks-http": "^1.11.0",
"rehype-autolink-headings": "^6.1.1",
"rehype-slug": "^5.0.1",
"rehype-toc": "^3.0.2",
Expand Down
49 changes: 49 additions & 0 deletions packages/astro/src/@types/typed-emitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* The MIT License (MIT)
* Copyright (c) 2018 Andy Wermke
* https://github.com/andywer/typed-emitter/blob/9a139b6fa0ec6b0db6141b5b756b784e4f7ef4e4/LICENSE
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that I brought this package internally because when Vite crawls through package.jsons, this one has a main but the main doesn't export anything so Vite warns about it not being a real ESM module. This is only a (small) types package, so brought it in.

*/

export type EventMap = {
[key: string]: (...args: any[]) => void
}

/**
* Type-safe event emitter.
*
* Use it like this:
*
* ```typescript
* type MyEvents = {
* error: (error: Error) => void;
* message: (from: string, content: string) => void;
* }
*
* const myEmitter = new EventEmitter() as TypedEmitter<MyEvents>;
*
* myEmitter.emit("error", "x") // <- Will catch this type error;
* ```
*/
interface TypedEventEmitter<Events extends EventMap> {
addListener<E extends keyof Events> (event: E, listener: Events[E]): this
on<E extends keyof Events> (event: E, listener: Events[E]): this
once<E extends keyof Events> (event: E, listener: Events[E]): this
prependListener<E extends keyof Events> (event: E, listener: Events[E]): this
prependOnceListener<E extends keyof Events> (event: E, listener: Events[E]): this

off<E extends keyof Events>(event: E, listener: Events[E]): this
removeAllListeners<E extends keyof Events> (event?: E): this
removeListener<E extends keyof Events> (event: E, listener: Events[E]): this

emit<E extends keyof Events> (event: E, ...args: Parameters<Events[E]>): boolean
// The sloppy `eventNames()` return type is to mitigate type incompatibilities - see #5
eventNames (): (keyof Events | string | symbol)[]
rawListeners<E extends keyof Events> (event: E): Events[E][]
listeners<E extends keyof Events> (event: E): Events[E][]
listenerCount<E extends keyof Events> (event: E): number

getMaxListeners (): number
setMaxListeners (maxListeners: number): this
}

export default TypedEventEmitter
20 changes: 12 additions & 8 deletions packages/astro/src/core/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ export const LEGACY_ASTRO_CONFIG_KEYS = new Set([
export async function validateConfig(
userConfig: any,
root: string,
cmd: string,
logging: LogOptions
cmd: string
): Promise<AstroConfig> {
const fileProtocolRoot = pathToFileURL(root + path.sep);
// Manual deprecation checks
Expand Down Expand Up @@ -195,8 +194,7 @@ export async function openConfig(configOptions: LoadConfigOptions): Promise<Open
userConfig,
root,
flags,
configOptions.cmd,
configOptions.logging
configOptions.cmd
);

return {
Expand Down Expand Up @@ -302,23 +300,29 @@ export async function loadConfig(configOptions: LoadConfigOptions): Promise<Astr
if (config) {
userConfig = config.value;
}
return resolveConfig(userConfig, root, flags, configOptions.cmd, configOptions.logging);
return resolveConfig(userConfig, root, flags, configOptions.cmd);
}

/** Attempt to resolve an Astro configuration object. Normalize, validate, and return. */
export async function resolveConfig(
userConfig: AstroUserConfig,
root: string,
flags: CLIFlags = {},
cmd: string,
logging: LogOptions
cmd: string
): Promise<AstroConfig> {
const mergedConfig = mergeCLIFlags(userConfig, flags, cmd);
const validatedConfig = await validateConfig(mergedConfig, root, cmd, logging);
const validatedConfig = await validateConfig(mergedConfig, root, cmd);

return validatedConfig;
}

export function createDefaultDevConfig(
userConfig: AstroUserConfig = {},
root: string = process.cwd(),
) {
return resolveConfig(userConfig, root, undefined, 'dev');
}

function mergeConfigRecursively(
defaults: Record<string, any>,
overrides: Record<string, any>,
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/core/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
export {
createDefaultDevConfig,
openConfig,
resolveConfigPath,
resolveFlags,
resolveRoot,
validateConfig,
} from './config.js';
export type { AstroConfigSchema } from './schema';
export { createSettings } from './settings.js';
export { createSettings, createDefaultDevSettings } from './settings.js';
export { loadTSConfig, updateTSConfigForFramework } from './tsconfig.js';
35 changes: 28 additions & 7 deletions packages/astro/src/core/config/settings.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,43 @@
import type { AstroConfig, AstroSettings } from '../../@types/astro';
import type { AstroConfig, AstroSettings, AstroUserConfig } from '../../@types/astro';
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../constants.js';

import { fileURLToPath } from 'url';
import { createDefaultDevConfig } from './config.js';
import jsxRenderer from '../../jsx/renderer.js';
import { loadTSConfig } from './tsconfig.js';

export function createSettings(config: AstroConfig, cwd?: string): AstroSettings {
const tsconfig = loadTSConfig(cwd);

export function createBaseSettings(config: AstroConfig): AstroSettings {
return {
config,
tsConfig: tsconfig?.config,
tsConfigPath: tsconfig?.path,
tsConfig: undefined,
tsConfigPath: undefined,

adapter: undefined,
injectedRoutes: [],
pageExtensions: ['.astro', '.html', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS],
renderers: [jsxRenderer],
scripts: [],
watchFiles: tsconfig?.exists ? [tsconfig.path, ...tsconfig.extendedPaths] : [],
watchFiles: [],
};
}

export function createSettings(config: AstroConfig, cwd?: string): AstroSettings {
const tsconfig = loadTSConfig(cwd);
const settings = createBaseSettings(config);
settings.tsConfig = tsconfig?.config;
settings.tsConfigPath = tsconfig?.path;
settings.watchFiles = tsconfig?.exists ? [tsconfig.path, ...tsconfig.extendedPaths] : [];
return settings;
}

export async function createDefaultDevSettings(
userConfig: AstroUserConfig = {},
root?: string | URL
): Promise<AstroSettings> {
if(root && typeof root !== 'string') {
root = fileURLToPath(root);
}
const config = await createDefaultDevConfig(userConfig, root);
return createBaseSettings(config);
}

10 changes: 7 additions & 3 deletions packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { AstroSettings } from '../@types/astro';
import type { LogOptions } from './logger/core';

import nodeFs from 'fs';
import { fileURLToPath } from 'url';
import * as vite from 'vite';
import { crawlFrameworkPkgs } from 'vitefu';
import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.js';
import astroViteServerPlugin from '../vite-plugin-astro-server/index.js';
import { vitePluginAstroServer } from '../vite-plugin-astro-server/index.js';
import astroVitePlugin from '../vite-plugin-astro/index.js';
import configAliasVitePlugin from '../vite-plugin-config-alias/index.js';
import envVitePlugin from '../vite-plugin-env/index.js';
Expand All @@ -17,12 +18,14 @@ import markdownVitePlugin from '../vite-plugin-markdown/index.js';
import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
import { createCustomViteLogger } from './errors/dev/index.js';
import astroLoadFallbackPlugin from '../vite-plugin-load-fallback/index.js';
import { resolveDependency } from './util.js';

interface CreateViteOptions {
settings: AstroSettings;
logging: LogOptions;
mode: 'dev' | 'build' | string;
fs?: typeof nodeFs;
}

const ALWAYS_NOEXTERNAL = new Set([
Expand Down Expand Up @@ -54,7 +57,7 @@ function getSsrNoExternalDeps(projectRoot: URL): string[] {
/** Return a common starting point for all Vite actions */
export async function createVite(
commandConfig: vite.InlineConfig,
{ settings, logging, mode }: CreateViteOptions
{ settings, logging, mode, fs = nodeFs }: CreateViteOptions
): Promise<vite.InlineConfig> {
const astroPkgsConfig = await crawlFrameworkPkgs({
root: fileURLToPath(settings.config.root),
Expand Down Expand Up @@ -97,7 +100,7 @@ export async function createVite(
astroScriptsPlugin({ settings }),
// The server plugin is for dev only and having it run during the build causes
// the build to run very slow as the filewatcher is triggered often.
mode !== 'build' && astroViteServerPlugin({ settings, logging }),
mode !== 'build' && vitePluginAstroServer({ settings, logging, fs }),
envVitePlugin({ settings }),
settings.config.legacy.astroFlavoredMarkdown
? legacyMarkdownVitePlugin({ settings, logging })
Expand All @@ -107,6 +110,7 @@ export async function createVite(
astroPostprocessVitePlugin({ settings }),
astroIntegrationsContainerPlugin({ settings, logging }),
astroScriptsPageSSRPlugin({ settings }),
astroLoadFallbackPlugin({ fs })
],
publicDir: fileURLToPath(settings.config.publicDir),
root: fileURLToPath(settings.config.root),
Expand Down
124 changes: 124 additions & 0 deletions packages/astro/src/core/dev/container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@

import type { AddressInfo } from 'net';
import type { AstroSettings, AstroUserConfig } from '../../@types/astro';
import * as http from 'http';

import {
runHookConfigDone,
runHookConfigSetup,
runHookServerSetup,
runHookServerStart,
} from '../../integrations/index.js';
import { createVite } from '../create-vite.js';
import { LogOptions } from '../logger/core.js';
import { nodeLogDestination } from '../logger/node.js';
import nodeFs from 'fs';
import * as vite from 'vite';
import { createDefaultDevSettings } from '../config/index.js';
import { apply as applyPolyfill } from '../polyfill.js';


const defaultLogging: LogOptions = {
dest: nodeLogDestination,
level: 'error',
};

export interface Container {
fs: typeof nodeFs;
logging: LogOptions;
settings: AstroSettings;
viteConfig: vite.InlineConfig;
viteServer: vite.ViteDevServer;
handle: (req: http.IncomingMessage, res: http.ServerResponse) => void;
close: () => Promise<void>;
}

export interface CreateContainerParams {
isRestart?: boolean;
logging?: LogOptions;
userConfig?: AstroUserConfig;
settings?: AstroSettings;
fs?: typeof nodeFs;
root?: string | URL;
}

export async function createContainer(params: CreateContainerParams = {}): Promise<Container> {
let {
isRestart = false,
logging = defaultLogging,
settings = await createDefaultDevSettings(params.userConfig, params.root),
fs = nodeFs
} = params;

// Initialize
applyPolyfill();
settings = await runHookConfigSetup({
settings,
command: 'dev',
logging,
isRestart,
});
const { host } = settings.config.server;

// The client entrypoint for renderers. Since these are imported dynamically
// we need to tell Vite to preoptimize them.
const rendererClientEntries = settings.renderers
.map((r) => r.clientEntrypoint)
.filter(Boolean) as string[];

const viteConfig = await createVite(
{
mode: 'development',
server: { host },
optimizeDeps: {
include: rendererClientEntries,
},
define: {
'import.meta.env.BASE_URL': settings.config.base
? `'${settings.config.base}'`
: 'undefined',
},
},
{ settings, logging, mode: 'dev', fs }
);
await runHookConfigDone({ settings, logging });
const viteServer = await vite.createServer(viteConfig);
runHookServerSetup({ config: settings.config, server: viteServer, logging });

return {
fs,
logging,
settings,
viteConfig,
viteServer,

handle(req, res) {
viteServer.middlewares.handle(req, res, Function.prototype);
},
close() {
return viteServer.close();
}
};
}

export async function startContainer({ settings, viteServer, logging }: Container): Promise<AddressInfo> {
const { port } = settings.config.server;
await viteServer.listen(port);
const devServerAddressInfo = viteServer.httpServer!.address() as AddressInfo;
await runHookServerStart({
config: settings.config,
address: devServerAddressInfo,
logging,
});

return devServerAddressInfo;
}

export async function runInContainer(params: CreateContainerParams, callback: (container: Container) => Promise<void> | void) {
const container = await createContainer(params);
try {
await callback(container);
} finally {
await container.close();
}
}
Loading