Surface configuration errors to the client (#5273)

* Surface configuration errors to the client

* Actually start the container on restart

* Add beforeRestart to clear the console

* Some minor changes, restarted() returns an Error maybe

* Refactor testing code

* Adding a changeset
This commit is contained in:
Matthew Phillips 2022-11-03 08:04:33 -04:00 committed by GitHub
parent a558cf317a
commit c7b9b14a1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 378 additions and 82 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Surface astro.config errors to the user

View file

@ -177,67 +177,21 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {
// by the end of this switch statement.
switch (cmd) {
case 'dev': {
async function startDevServer({ isRestart = false }: { isRestart?: boolean } = {}) {
const { watcher, stop } = await devServer(settings, { logging, telemetry, isRestart });
let restartInFlight = false;
const configFlag = resolveFlags(flags).config;
const configFlagPath = configFlag
? await resolveConfigPath({ cwd: root, flags })
: undefined;
const resolvedRoot = appendForwardSlash(resolveRoot(root));
const configFlag = resolveFlags(flags).config;
const configFlagPath = configFlag
? await resolveConfigPath({ cwd: root, flags })
: undefined;
const handleServerRestart = (logMsg: string) =>
async function (changedFile: string) {
if (restartInFlight) return;
let shouldRestart = false;
// If the config file changed, reload the config and restart the server.
shouldRestart = configFlag
? // If --config is specified, only watch changes for this file
!!configFlagPath && normalizePath(configFlagPath) === normalizePath(changedFile)
: // Otherwise, watch for any astro.config.* file changes in project root
new RegExp(
`${normalizePath(resolvedRoot)}.*astro\.config\.((mjs)|(cjs)|(js)|(ts))$`
).test(normalizePath(changedFile));
if (!shouldRestart && settings.watchFiles.length > 0) {
// If the config file didn't change, check if any of the watched files changed.
shouldRestart = settings.watchFiles.some(
(path) => normalizePath(path) === normalizePath(changedFile)
);
}
if (!shouldRestart) return;
restartInFlight = true;
console.clear();
try {
const newConfig = await openConfig({
cwd: root,
flags,
cmd,
logging,
isRestart: true,
});
info(logging, 'astro', logMsg + '\n');
let astroConfig = newConfig.astroConfig;
settings = createSettings(astroConfig, root);
await stop();
await startDevServer({ isRestart: true });
} catch (e) {
await handleConfigError(e, { cwd: root, flags, logging });
await stop();
info(logging, 'astro', 'Continuing with previous valid configuration\n');
await startDevServer({ isRestart: true });
}
};
watcher.on('change', handleServerRestart('Configuration updated. Restarting...'));
watcher.on('unlink', handleServerRestart('Configuration removed. Restarting...'));
watcher.on('add', handleServerRestart('Configuration added. Restarting...'));
}
await startDevServer({ isRestart: false });
await devServer(settings, {
configFlag,
configFlagPath,
logging,
telemetry,
handleConfigError(e) {
handleConfigError(e, { cwd: root, flags, logging });
info(logging, 'astro', 'Continuing with previous valid configuration\n');
}
});
return await new Promise(() => {}); // lives forever
}

View file

@ -105,7 +105,10 @@ export function resolveFlags(flags: Partial<Flags>): CLIFlags {
};
}
export function resolveRoot(cwd?: string): string {
export function resolveRoot(cwd?: string | URL): string {
if(cwd instanceof URL) {
cwd = fileURLToPath(cwd);
}
return cwd ? path.resolve(cwd) : process.cwd();
}
@ -137,6 +140,7 @@ interface LoadConfigOptions {
logging: LogOptions;
/** Invalidate when reloading a previously loaded config */
isRestart?: boolean;
fsMod?: typeof fs;
}
/**
@ -210,6 +214,7 @@ async function tryLoadConfig(
flags: CLIFlags,
root: string
): Promise<TryLoadConfigResult | undefined> {
const fsMod = configOptions.fsMod ?? fs;
let finallyCleanup = async () => {};
try {
let configPath = await resolveConfigPath({
@ -224,7 +229,9 @@ async function tryLoadConfig(
root,
`.temp.${Date.now()}.config${path.extname(configPath)}`
);
await fs.promises.writeFile(tempConfigPath, await fs.promises.readFile(configPath));
const currentConfigContent = await fsMod.promises.readFile(configPath, 'utf-8');
await fs.promises.writeFile(tempConfigPath, currentConfigContent);
finallyCleanup = async () => {
try {
await fs.promises.unlink(tempConfigPath);

View file

@ -9,10 +9,12 @@ import {
runHookConfigSetup,
runHookServerSetup,
runHookServerStart,
runHookServerDone
} from '../../integrations/index.js';
import { createDefaultDevSettings } from '../config/index.js';
import { createDefaultDevSettings, resolveRoot } from '../config/index.js';
import { createVite } from '../create-vite.js';
import { LogOptions } from '../logger/core.js';
import { appendForwardSlash } from '../path.js';
import { nodeLogDestination } from '../logger/node.js';
import { apply as applyPolyfill } from '../polyfill.js';
@ -27,6 +29,10 @@ export interface Container {
settings: AstroSettings;
viteConfig: vite.InlineConfig;
viteServer: vite.ViteDevServer;
resolvedRoot: string;
configFlag: string | undefined;
configFlagPath: string | undefined;
restartInFlight: boolean; // gross
handle: (req: http.IncomingMessage, res: http.ServerResponse) => void;
close: () => Promise<void>;
}
@ -38,6 +44,9 @@ export interface CreateContainerParams {
settings?: AstroSettings;
fs?: typeof nodeFs;
root?: string | URL;
// The string passed to --config and the resolved path
configFlag?: string;
configFlagPath?: string;
}
export async function createContainer(params: CreateContainerParams = {}): Promise<Container> {
@ -83,20 +92,38 @@ export async function createContainer(params: CreateContainerParams = {}): Promi
const viteServer = await vite.createServer(viteConfig);
runHookServerSetup({ config: settings.config, server: viteServer, logging });
return {
const container: Container = {
configFlag: params.configFlag,
configFlagPath: params.configFlagPath,
fs,
logging,
resolvedRoot: appendForwardSlash(resolveRoot(params.root)),
restartInFlight: false,
settings,
viteConfig,
viteServer,
handle(req, res) {
viteServer.middlewares.handle(req, res, Function.prototype);
},
// TODO deprecate and remove
close() {
return viteServer.close();
},
return closeContainer(container);
}
};
return container;
}
async function closeContainer({
viteServer,
settings,
logging
}: Container) {
await viteServer.close();
await runHookServerDone({
config: settings.config,
logging,
});
}
export async function startContainer({
@ -116,6 +143,10 @@ export async function startContainer({
return devServerAddressInfo;
}
export function isStarted(container: Container): boolean {
return !!container.viteServer.httpServer?.listening;
}
export async function runInContainer(
params: CreateContainerParams,
callback: (container: Container) => Promise<void> | void

View file

@ -1,21 +1,26 @@
import type { AstroTelemetry } from '@astrojs/telemetry';
import type { AddressInfo } from 'net';
import type http from 'http';
import { performance } from 'perf_hooks';
import * as vite from 'vite';
import type { AstroSettings } from '../../@types/astro';
import { runHookServerDone } from '../../integrations/index.js';
import { info, LogOptions, warn } from '../logger/core.js';
import * as msg from '../messages.js';
import { createContainer, startContainer } from './container.js';
import { startContainer } from './container.js';
import { createContainerWithAutomaticRestart } from './restart.js';
export interface DevOptions {
configFlag: string | undefined;
configFlagPath: string | undefined;
logging: LogOptions;
telemetry: AstroTelemetry;
handleConfigError: (error: Error) => void;
isRestart?: boolean;
}
export interface DevServer {
address: AddressInfo;
handle: (req: http.IncomingMessage, res: http.ServerResponse<http.IncomingMessage>) => void;
watcher: vite.FSWatcher;
stop(): Promise<void>;
}
@ -29,14 +34,20 @@ export default async function dev(
await options.telemetry.record([]);
// Create a container which sets up the Vite server.
const container = await createContainer({
settings,
logging: options.logging,
isRestart: options.isRestart,
const restart = await createContainerWithAutomaticRestart({
flags: {},
handleConfigError: options.handleConfigError,
// eslint-disable-next-line no-console
beforeRestart: () => console.clear(),
params: {
settings,
logging: options.logging,
isRestart: options.isRestart,
}
});
// Start listening to the port
const devServerAddressInfo = await startContainer(container);
const devServerAddressInfo = await startContainer(restart.container);
const site = settings.config.site
? new URL(settings.config.base, settings.config.site)
@ -46,7 +57,7 @@ export default async function dev(
null,
msg.serverStart({
startupTime: performance.now() - devStart,
resolvedUrls: container.viteServer.resolvedUrls || { local: [], network: [] },
resolvedUrls: restart.container.viteServer.resolvedUrls || { local: [], network: [] },
host: settings.config.server.host,
site,
isRestart: options.isRestart,
@ -57,18 +68,20 @@ export default async function dev(
if (currentVersion.includes('-')) {
warn(options.logging, null, msg.prerelease({ currentVersion }));
}
if (container.viteConfig.server?.fs?.strict === false) {
if (restart.container.viteConfig.server?.fs?.strict === false) {
warn(options.logging, null, msg.fsStrictWarning());
}
return {
address: devServerAddressInfo,
get watcher() {
return container.viteServer.watcher;
return restart.container.viteServer.watcher;
},
stop: async () => {
await container.close();
await runHookServerDone({ config: settings.config, logging: options.logging });
handle(req, res) {
return restart.container.handle(req, res);
},
async stop() {
await restart.container.close();
},
};
}

View file

@ -1,2 +1,9 @@
export { createContainer, runInContainer, startContainer } from './container.js';
export {
createContainer,
runInContainer,
startContainer
} from './container.js';
export {
createContainerWithAutomaticRestart
} from './restart.js';
export { default } from './dev.js';

View file

@ -0,0 +1,197 @@
import type { AstroSettings } from '../../@types/astro';
import type { Container, CreateContainerParams } from './container';
import * as vite from 'vite';
import { createSettings, openConfig } from '../config/index.js';
import { info } from '../logger/core.js';
import { createContainer, isStarted, startContainer } from './container.js';
import { createSafeError } from '../errors/index.js';
async function createRestartedContainer(container: Container, settings: AstroSettings): Promise<Container> {
const {
logging,
fs,
resolvedRoot,
configFlag,
configFlagPath
} = container;
const needsStart = isStarted(container);
const newContainer = await createContainer({
isRestart: true,
logging,
settings,
fs,
root: resolvedRoot,
configFlag,
configFlagPath,
});
if(needsStart) {
await startContainer(newContainer);
}
return newContainer;
}
export function shouldRestartContainer({
settings,
configFlag,
configFlagPath,
restartInFlight
}: Container, changedFile: string): boolean {
if(restartInFlight) return false;
let shouldRestart = false;
// If the config file changed, reload the config and restart the server.
if(configFlag) {
if(!!configFlagPath) {
shouldRestart = vite.normalizePath(configFlagPath) === vite.normalizePath(changedFile);
}
}
// Otherwise, watch for any astro.config.* file changes in project root
else {
const exp = new RegExp(`.*astro\.config\.((mjs)|(cjs)|(js)|(ts))$`);
const normalizedChangedFile = vite.normalizePath(changedFile);
shouldRestart = exp.test(normalizedChangedFile);
}
if (!shouldRestart && settings.watchFiles.length > 0) {
// If the config file didn't change, check if any of the watched files changed.
shouldRestart = settings.watchFiles.some(
(path) => vite.normalizePath(path) === vite.normalizePath(changedFile)
);
}
return shouldRestart;
}
interface RestartContainerParams {
container: Container;
flags: any;
logMsg: string;
handleConfigError: (err: Error) => Promise<void> | void;
beforeRestart?: () => void;
}
export async function restartContainer({
container,
flags,
logMsg,
handleConfigError,
beforeRestart
}: RestartContainerParams): Promise<{ container: Container, error: Error | null }> {
const {
logging,
close,
resolvedRoot,
settings: existingSettings
} = container;
container.restartInFlight = true;
//console.clear(); // TODO move this
if(beforeRestart) {
beforeRestart()
}
try {
const newConfig = await openConfig({
cwd: resolvedRoot,
flags,
cmd: 'dev',
logging,
isRestart: true,
fsMod: container.fs,
});
info(logging, 'astro', logMsg + '\n');
let astroConfig = newConfig.astroConfig;
const settings = createSettings(astroConfig, resolvedRoot);
await close();
return {
container: await createRestartedContainer(container, settings),
error: null
};
} catch (_err) {
const error = createSafeError(_err);
await handleConfigError(error);
await close();
info(logging, 'astro', 'Continuing with previous valid configuration\n');
return {
container: await createRestartedContainer(container, existingSettings),
error
};
}
}
export interface CreateContainerWithAutomaticRestart {
flags: any;
params: CreateContainerParams;
handleConfigError?: (error: Error) => void | Promise<void>;
beforeRestart?: () => void;
}
interface Restart {
container: Container;
restarted: () => Promise<Error | null>;
}
export async function createContainerWithAutomaticRestart({
flags,
handleConfigError = (_e: Error) => {},
beforeRestart,
params
}: CreateContainerWithAutomaticRestart): Promise<Restart> {
const initialContainer = await createContainer(params);
let resolveRestart: (value: Error | null) => void;
let restartComplete = new Promise<Error | null>(resolve => {
resolveRestart = resolve;
});
let restart: Restart = {
container: initialContainer,
restarted() {
return restartComplete;
}
};
function handleServerRestart(logMsg: string) {
// eslint-disable-next-line @typescript-eslint/no-shadow
const container = restart.container;
return async function(changedFile: string) {
if(shouldRestartContainer(container, changedFile)) {
const { container: newContainer, error } = await restartContainer({
beforeRestart,
container,
flags,
logMsg,
async handleConfigError(err) {
// Send an error message to the client if one is connected.
await handleConfigError(err);
container.viteServer.ws.send({
type: 'error',
err: {
message: err.message,
stack: err.stack || ''
}
});
}
});
restart.container = newContainer;
// Add new watches because this is a new container with a new Vite server
addWatches();
resolveRestart(error);
restartComplete = new Promise<Error | null>(resolve => {
resolveRestart = resolve;
});
}
}
}
// Set up watches
function addWatches() {
const watcher = restart.container.viteServer.watcher;
watcher.on('change', handleServerRestart('Configuration updated. Restarting...'));
watcher.on('unlink', handleServerRestart('Configuration removed. Restarting...'));
watcher.on('add', handleServerRestart('Configuration added. Restarting...'));
}
addWatches();
return restart;
}

View file

@ -0,0 +1,68 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { createContainerWithAutomaticRestart, runInContainer } from '../../../dist/core/dev/index.js';
import { createFs, createRequestAndResponse } from '../test-utils.js';
const root = new URL('../../fixtures/alias/', import.meta.url);
describe('dev container restarts', () => {
it('Surfaces config errors on restarts', async () => {
const fs = createFs(
{
'/src/pages/index.astro': `
<html>
<head><title>Test</title></head>
<body>
<h1>Test</h1>
</body>
</html>
`,
'/astro.config.mjs': `
`
},
root
);
let restart = await createContainerWithAutomaticRestart({
params: { fs, root }
});
try {
let r = createRequestAndResponse({
method: 'GET',
url: '/',
});
restart.container.handle(r.req, r.res);
let html = await r.text();
const $ = cheerio.load(html);
expect(r.res.statusCode).to.equal(200);
expect($('h1')).to.have.a.lengthOf(1);
// Create an error
let restartComplete = restart.restarted();
fs.writeFileFromRootSync('/astro.config.mjs', 'const foo = bar');
// Vite watches the real filesystem, so we have to mock this part. It's not so bad.
restart.container.viteServer.watcher.emit('change', fs.getFullyResolvedPath('/astro.config.mjs'));
// Wait for the restart to finish
let hmrError = await restartComplete;
expect(hmrError).to.not.be.a('undefined');
// Do it a second time to make sure we are still watching
restartComplete = restart.restarted();
fs.writeFileFromRootSync('/astro.config.mjs', 'const foo = bar2');
// Vite watches the real filesystem, so we have to mock this part. It's not so bad.
restart.container.viteServer.watcher.emit('change', fs.getFullyResolvedPath('/astro.config.mjs'));
hmrError = await restartComplete;
expect(hmrError).to.not.be.a('undefined');
} finally {
await restart.container.close();
}
});
});

View file

@ -6,12 +6,26 @@ import npath from 'path';
import { unixify } from './correct-path.js';
class MyVolume extends Volume {
#root = '';
constructor(root) {
super();
this.#root = root;
}
getFullyResolvedPath(pth) {
return npath.posix.join(this.#root, pth);
}
existsSync(p) {
if (p instanceof URL) {
p = fileURLToPath(p);
}
return super.existsSync(p);
}
writeFileFromRootSync(pth, ...rest) {
return super.writeFileSync(this.getFullyResolvedPath(pth), ...rest);
}
}
export function createFs(json, root) {
@ -25,7 +39,7 @@ export function createFs(json, root) {
structure[fullpath] = value;
}
const fs = new MyVolume();
const fs = new MyVolume(root);
fs.fromJSON(structure);
return fs;
}