diff options
| author | 2022-11-01 08:57:23 -0400 | |
|---|---|---|
| committer | 2022-11-01 08:57:23 -0400 | |
| commit | c77a6cbe345facbf72c453e2fddc00f20c98983f (patch) | |
| tree | 928a4adcd4dd04ea8b0d5975ec95bad1328c7c1c | |
| parent | 06c5d51b37071b39f77e77dd6c0391c0c7c4fc1b (diff) | |
| download | astro-c77a6cbe345facbf72c453e2fddc00f20c98983f.tar.gz astro-c77a6cbe345facbf72c453e2fddc00f20c98983f.tar.zst astro-c77a6cbe345facbf72c453e2fddc00f20c98983f.zip | |
Graceful error recovery in the dev server (#5198)
* Graceful error recovery in the dev server
Move dev-container to dev
Update for the lockfile
Invalidate modules in an error state
Test invalidation of broken modules
Remove unused error state
Normalize for windows
try a larger timeout
Fixes build
just for testing
more testing
Keep it posix
fully posix
* Fix up Windows path for testing
* some debugging
* use posix join
* finally fixed
* Remove leftover debugging
* Reset the timeout
* Adding a changeset
39 files changed, 1630 insertions, 643 deletions
| diff --git a/.changeset/thin-trains-run.md b/.changeset/thin-trains-run.md new file mode 100644 index 000000000..ef58f3b23 --- /dev/null +++ b/.changeset/thin-trains-run.md @@ -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.
\ No newline at end of file diff --git a/packages/astro/package.json b/packages/astro/package.json index 4595d403d..d3f830a96 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -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", @@ -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", diff --git a/packages/astro/src/@types/typed-emitter.ts b/packages/astro/src/@types/typed-emitter.ts new file mode 100644 index 000000000..62ed3522d --- /dev/null +++ b/packages/astro/src/@types/typed-emitter.ts @@ -0,0 +1,49 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2018 Andy Wermke + * https://github.com/andywer/typed-emitter/blob/9a139b6fa0ec6b0db6141b5b756b784e4f7ef4e4/LICENSE + */ + +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 diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts index 4164dfda7..6e9092bf3 100644 --- a/packages/astro/src/core/config/config.ts +++ b/packages/astro/src/core/config/config.ts @@ -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 @@ -195,8 +194,7 @@ export async function openConfig(configOptions: LoadConfigOptions): Promise<Open  		userConfig,  		root,  		flags, -		configOptions.cmd, -		configOptions.logging +		configOptions.cmd  	);  	return { @@ -302,7 +300,7 @@ 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. */ @@ -310,15 +308,21 @@ 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>, diff --git a/packages/astro/src/core/config/index.ts b/packages/astro/src/core/config/index.ts index 195ab1430..4cb79a713 100644 --- a/packages/astro/src/core/config/index.ts +++ b/packages/astro/src/core/config/index.ts @@ -1,4 +1,5 @@  export { +	createDefaultDevConfig,  	openConfig,  	resolveConfigPath,  	resolveFlags, @@ -6,5 +7,5 @@ export {  	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'; diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts index 3b562697e..54be8bb71 100644 --- a/packages/astro/src/core/config/settings.ts +++ b/packages/astro/src/core/config/settings.ts @@ -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); +} + diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 62dc46eb3..9dce95680 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -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'; @@ -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([ @@ -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), @@ -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 }) @@ -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), diff --git a/packages/astro/src/core/dev/container.ts b/packages/astro/src/core/dev/container.ts new file mode 100644 index 000000000..da99f998f --- /dev/null +++ b/packages/astro/src/core/dev/container.ts @@ -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(); +	} +} diff --git a/packages/astro/src/core/dev/dev.ts b/packages/astro/src/core/dev/dev.ts new file mode 100644 index 000000000..78d25e9a7 --- /dev/null +++ b/packages/astro/src/core/dev/dev.ts @@ -0,0 +1,74 @@ +import type { AstroTelemetry } from '@astrojs/telemetry'; +import type { AddressInfo } from 'net'; +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'; + +export interface DevOptions { +	logging: LogOptions; +	telemetry: AstroTelemetry; +	isRestart?: boolean; +} + +export interface DevServer { +	address: AddressInfo; +	watcher: vite.FSWatcher; +	stop(): Promise<void>; +} + +/** `astro dev` */ +export default async function dev( +	settings: AstroSettings, +	options: DevOptions +): Promise<DevServer> { +	const devStart = performance.now(); +	await options.telemetry.record([]); + +	// Create a container which sets up the Vite server. +	const container = await createContainer({ +		settings, +		logging: options.logging, +		isRestart: options.isRestart, +	}); + +	// Start listening to the port +	const devServerAddressInfo = await startContainer(container); + +	const site = settings.config.site +		? new URL(settings.config.base, settings.config.site) +		: undefined; +	info( +		options.logging, +		null, +		msg.serverStart({ +			startupTime: performance.now() - devStart, +			resolvedUrls: container.viteServer.resolvedUrls || { local: [], network: [] }, +			host: settings.config.server.host, +			site, +			isRestart: options.isRestart, +		}) +	); + +	const currentVersion = process.env.PACKAGE_VERSION ?? '0.0.0'; +	if (currentVersion.includes('-')) { +		warn(options.logging, null, msg.prerelease({ currentVersion })); +	} +	if (container.viteConfig.server?.fs?.strict === false) { +		warn(options.logging, null, msg.fsStrictWarning()); +	} + +	return { +		address: devServerAddressInfo, +		get watcher() { +			return container.viteServer.watcher; +		}, +		stop: async () => { +			await container.close(); +			await runHookServerDone({ config: settings.config, logging: options.logging }); +		}, +	}; +} diff --git a/packages/astro/src/core/dev/index.ts b/packages/astro/src/core/dev/index.ts index bd3659671..53b67502c 100644 --- a/packages/astro/src/core/dev/index.ts +++ b/packages/astro/src/core/dev/index.ts @@ -1,113 +1,9 @@ -import type { AstroTelemetry } from '@astrojs/telemetry'; -import type { AddressInfo } from 'net'; -import { performance } from 'perf_hooks'; -import * as vite from 'vite'; -import type { AstroSettings } from '../../@types/astro'; -import { -	runHookConfigDone, -	runHookConfigSetup, -	runHookServerDone, -	runHookServerSetup, -	runHookServerStart, -} from '../../integrations/index.js'; -import { createVite } from '../create-vite.js'; -import { info, LogOptions, warn } from '../logger/core.js'; -import * as msg from '../messages.js'; -import { apply as applyPolyfill } from '../polyfill.js'; - -export interface DevOptions { -	logging: LogOptions; -	telemetry: AstroTelemetry; -	isRestart?: boolean; -} - -export interface DevServer { -	address: AddressInfo; -	watcher: vite.FSWatcher; -	stop(): Promise<void>; -} - -/** `astro dev` */ -export default async function dev( -	settings: AstroSettings, -	options: DevOptions -): Promise<DevServer> { -	const devStart = performance.now(); -	applyPolyfill(); -	await options.telemetry.record([]); -	settings = await runHookConfigSetup({ -		settings, -		command: 'dev', -		logging: options.logging, -		isRestart: options.isRestart, -	}); -	const { host, port } = settings.config.server; -	const { isRestart = false } = options; - -	// 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: options.logging, mode: 'dev' } -	); -	await runHookConfigDone({ settings, logging: options.logging }); -	const viteServer = await vite.createServer(viteConfig); -	runHookServerSetup({ config: settings.config, server: viteServer, logging: options.logging }); -	await viteServer.listen(port); - -	const site = settings.config.site -		? new URL(settings.config.base, settings.config.site) -		: undefined; -	info( -		options.logging, -		null, -		msg.serverStart({ -			startupTime: performance.now() - devStart, -			resolvedUrls: viteServer.resolvedUrls || { local: [], network: [] }, -			host: settings.config.server.host, -			site, -			isRestart, -		}) -	); - -	const currentVersion = process.env.PACKAGE_VERSION ?? '0.0.0'; -	if (currentVersion.includes('-')) { -		warn(options.logging, null, msg.prerelease({ currentVersion })); -	} -	if (viteConfig.server?.fs?.strict === false) { -		warn(options.logging, null, msg.fsStrictWarning()); -	} - -	const devServerAddressInfo = viteServer.httpServer!.address() as AddressInfo; -	await runHookServerStart({ -		config: settings.config, -		address: devServerAddressInfo, -		logging: options.logging, -	}); - -	return { -		address: devServerAddressInfo, -		get watcher() { -			return viteServer.watcher; -		}, -		stop: async () => { -			await viteServer.close(); -			await runHookServerDone({ config: settings.config, logging: options.logging }); -		}, -	}; -} +export { +	createContainer, +	startContainer, +	runInContainer +} from './container.js'; + +export { +	default +} from './dev.js'; diff --git a/packages/astro/src/core/errors/dev/vite.ts b/packages/astro/src/core/errors/dev/vite.ts index 4a187c4f8..9feed2ab0 100644 --- a/packages/astro/src/core/errors/dev/vite.ts +++ b/packages/astro/src/core/errors/dev/vite.ts @@ -1,3 +1,4 @@ +import type { ModuleLoader } from '../../module-loader/index.js';  import * as fs from 'fs';  import { fileURLToPath } from 'url';  import { @@ -5,7 +6,6 @@ import {  	type ErrorPayload,  	type Logger,  	type LogLevel, -	type ViteDevServer,  } from 'vite';  import { AstroErrorCodes } from '../codes.js';  import { AstroError, type ErrorWithMetadata } from '../errors.js'; @@ -30,12 +30,12 @@ export function createCustomViteLogger(logLevel: LogLevel): Logger {  export function enhanceViteSSRError(  	error: Error,  	filePath?: URL, -	viteServer?: ViteDevServer +	loader?: ModuleLoader,  ): AstroError {  	// Vite will give you better stacktraces, using sourcemaps. -	if (viteServer) { +	if (loader) {  		try { -			viteServer.ssrFixStacktrace(error); +			loader.fixStacktrace(error);  		} catch {}  	} diff --git a/packages/astro/src/core/module-loader/index.ts b/packages/astro/src/core/module-loader/index.ts new file mode 100644 index 000000000..fd2c2a303 --- /dev/null +++ b/packages/astro/src/core/module-loader/index.ts @@ -0,0 +1,14 @@ +export type { +	ModuleInfo, +	ModuleLoader, +	ModuleNode, +	LoaderEvents +} from './loader.js'; + +export { +	createLoader +} from './loader.js'; + +export { +	createViteLoader +} from './vite.js'; diff --git a/packages/astro/src/core/module-loader/loader.ts b/packages/astro/src/core/module-loader/loader.ts new file mode 100644 index 000000000..6185e5d12 --- /dev/null +++ b/packages/astro/src/core/module-loader/loader.ts @@ -0,0 +1,71 @@ +import type TypedEmitter from '../../@types/typed-emitter'; +import type * as fs from 'fs'; +import { EventEmitter } from 'events'; + +// This is a generic interface for a module loader. In the astro cli this is +// fulfilled by Vite, see vite.ts + +export type LoaderEvents = { +	'file-add': (msg: [path: string, stats?: fs.Stats | undefined]) => void; +	'file-change': (msg: [path: string, stats?: fs.Stats | undefined]) => void; +	'file-unlink': (msg: [path: string, stats?: fs.Stats | undefined]) => void; +	'hmr-error': (msg: { +		type: 'error', +		err: { +			message: string; +			stack: string +		}; +	}) => void; +}; + +export type ModuleLoaderEventEmitter = TypedEmitter<LoaderEvents>; + +export interface ModuleLoader { +	import: (src: string) => Promise<Record<string, any>>; +	resolveId: (specifier: string, parentId: string | undefined) => Promise<string | undefined>; +	getModuleById: (id: string) => ModuleNode | undefined; +	getModulesByFile: (file: string) => Set<ModuleNode> | undefined; +	getModuleInfo: (id: string) => ModuleInfo | null; + +	eachModule(callbackfn: (value: ModuleNode, key: string) => void): void; +	invalidateModule(mod: ModuleNode): void; + +	fixStacktrace: (error: Error) => void; + +	clientReload: () => void; +	webSocketSend: (msg: any) => void; +	isHttps: () => boolean; +	events: TypedEmitter<LoaderEvents>; +} + +export interface ModuleNode { +	id: string | null; +	url: string; +	ssrModule: Record<string, any> | null; +	ssrError: Error | null; +	importedModules: Set<ModuleNode>; +} + +export interface ModuleInfo { +	id: string; +	meta?: Record<string, any>; +} + +export function createLoader(overrides: Partial<ModuleLoader>): ModuleLoader { +	return { +		import() { throw new Error(`Not implemented`); }, +		resolveId(id) { return Promise.resolve(id); }, +		getModuleById() {return undefined }, +		getModulesByFile() { return undefined }, +		getModuleInfo() { return null; }, +		eachModule() { throw new Error(`Not implemented`); }, +		invalidateModule() {}, +		fixStacktrace() {}, +		clientReload() {}, +		webSocketSend() {}, +		isHttps() { return true; }, +		events: new EventEmitter() as ModuleLoaderEventEmitter, + +		...overrides +	}; +} diff --git a/packages/astro/src/core/module-loader/vite.ts b/packages/astro/src/core/module-loader/vite.ts new file mode 100644 index 000000000..9e4d58208 --- /dev/null +++ b/packages/astro/src/core/module-loader/vite.ts @@ -0,0 +1,67 @@ +import type * as vite from 'vite'; +import type { ModuleLoader, ModuleLoaderEventEmitter } from './loader'; +import { EventEmitter } from 'events'; + +export function createViteLoader(viteServer: vite.ViteDevServer): ModuleLoader { +	const events = new EventEmitter() as ModuleLoaderEventEmitter; + +	viteServer.watcher.on('add', (...args) => events.emit('file-add', args)); +	viteServer.watcher.on('unlink', (...args) => events.emit('file-unlink', args)); +	viteServer.watcher.on('change', (...args) => events.emit('file-change', args)); + +	wrapMethod(viteServer.ws, 'send', msg => { +		if(msg?.type === 'error') { +			events.emit('hmr-error', msg); +		} +	}); + +	return { +		import(src) { +			return viteServer.ssrLoadModule(src); +		}, +		async resolveId(spec, parent) { +			const ret = await viteServer.pluginContainer.resolveId(spec, parent); +			return ret?.id; +		}, +		getModuleById(id) { +			return viteServer.moduleGraph.getModuleById(id); +		}, +		getModulesByFile(file) { +			return viteServer.moduleGraph.getModulesByFile(file); +		}, +		getModuleInfo(id) { +			return viteServer.pluginContainer.getModuleInfo(id); +		}, +		eachModule(cb) { +      return viteServer.moduleGraph.idToModuleMap.forEach(cb); +    }, +    invalidateModule(mod) { +      viteServer.moduleGraph.invalidateModule(mod as vite.ModuleNode); +    }, +		fixStacktrace(err) { +			return viteServer.ssrFixStacktrace(err); +		}, +		clientReload() { +			viteServer.ws.send({ +				type: 'full-reload', +				path: '*' +			}); +		}, +		webSocketSend(msg) { +			return viteServer.ws.send(msg); +		}, +		isHttps() { +			return !!viteServer.config.server.https; +		}, +		events +	}; +} + + +function wrapMethod(object: any, method: string, newFn: (...args: any[]) => void) { +	const orig = object[method]; +	object[method] = function(...args: any[]) { +		newFn.apply(this, args); +		return orig.apply(this, args); +	}; +} diff --git a/packages/astro/src/core/render/dev/css.ts b/packages/astro/src/core/render/dev/css.ts index 9c10cb03c..811be70b9 100644 --- a/packages/astro/src/core/render/dev/css.ts +++ b/packages/astro/src/core/render/dev/css.ts @@ -1,4 +1,4 @@ -import type * as vite from 'vite'; +import type { ModuleLoader } from '../../module-loader/index';  import path from 'path';  import { RuntimeMode } from '../../../@types/astro.js'; @@ -9,18 +9,18 @@ import { crawlGraph } from './vite.js';  /** Given a filePath URL, crawl Vite’s module graph to find all style imports. */  export async function getStylesForURL(  	filePath: URL, -	viteServer: vite.ViteDevServer, +	loader: ModuleLoader,  	mode: RuntimeMode  ): Promise<{ urls: Set<string>; stylesMap: Map<string, string> }> {  	const importedCssUrls = new Set<string>();  	const importedStylesMap = new Map<string, string>(); -	for await (const importedModule of crawlGraph(viteServer, viteID(filePath), true)) { +	for await (const importedModule of crawlGraph(loader, viteID(filePath), true)) {  		const ext = path.extname(importedModule.url).toLowerCase();  		if (STYLE_EXTENSIONS.has(ext)) {  			// The SSR module is possibly not loaded. Load it if it's null.  			const ssrModule = -				importedModule.ssrModule ?? (await viteServer.ssrLoadModule(importedModule.url)); +				importedModule.ssrModule ?? (await loader.import(importedModule.url));  			if (  				mode === 'development' && // only inline in development  				typeof ssrModule?.default === 'string' // ignore JS module styles diff --git a/packages/astro/src/core/render/dev/environment.ts b/packages/astro/src/core/render/dev/environment.ts index 5a8009eac..bf7a44fb5 100644 --- a/packages/astro/src/core/render/dev/environment.ts +++ b/packages/astro/src/core/render/dev/environment.ts @@ -2,19 +2,21 @@ import type { ViteDevServer } from 'vite';  import type { AstroSettings, RuntimeMode } from '../../../@types/astro';  import type { LogOptions } from '../../logger/core.js';  import type { Environment } from '../index'; +import type { ModuleLoader } from '../../module-loader/index'; +  import { createEnvironment } from '../index.js';  import { RouteCache } from '../route-cache.js';  import { createResolve } from './resolve.js';  export type DevelopmentEnvironment = Environment & { +	loader: ModuleLoader;  	settings: AstroSettings; -	viteServer: ViteDevServer;  };  export function createDevelopmentEnvironment(  	settings: AstroSettings,  	logging: LogOptions, -	viteServer: ViteDevServer +	loader: ModuleLoader  ): DevelopmentEnvironment {  	const mode: RuntimeMode = 'development';  	let env = createEnvironment({ @@ -27,7 +29,7 @@ export function createDevelopmentEnvironment(  		mode,  		// This will be overridden in the dev server  		renderers: [], -		resolve: createResolve(viteServer), +		resolve: createResolve(loader),  		routeCache: new RouteCache(logging, mode),  		site: settings.config.site,  		ssr: settings.config.output === 'server', @@ -36,7 +38,7 @@ export function createDevelopmentEnvironment(  	return {  		...env, -		viteServer, +		loader,  		settings,  	};  } diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index 57c436bf6..e5e651903 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -1,5 +1,4 @@  import { fileURLToPath } from 'url'; -import type { ViteDevServer } from 'vite';  import type {  	AstroSettings,  	ComponentInstance, @@ -8,6 +7,7 @@ import type {  	SSRElement,  	SSRLoadedRenderer,  } from '../../../@types/astro'; +import type { ModuleLoader } from '../../module-loader/index';  import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js';  import { enhanceViteSSRError } from '../../errors/dev/index.js';  import { AggregateError, CSSError, MarkdownError } from '../../errors/index.js'; @@ -39,26 +39,12 @@ export interface SSROptionsOld {  	route?: RouteData;  	/** pass in route cache because SSR can’t manage cache-busting */  	routeCache: RouteCache; -	/** Vite instance */ -	viteServer: ViteDevServer; +	/** Module loader (Vite) */ +	loader: ModuleLoader;  	/** Request */  	request: Request;  } -/* -		filePath: options.filePath -	}); - -	const ctx = createRenderContext({ -		request: options.request, -		origin: options.origin, -		pathname: options.pathname, -		scripts, -		links, -		styles, -		route: options.route -		*/ -  export interface SSROptions {  	/** The environment instance */  	env: DevelopmentEnvironment; @@ -79,10 +65,10 @@ export interface SSROptions {  export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance];  export async function loadRenderers( -	viteServer: ViteDevServer, +	moduleLoader: ModuleLoader,  	settings: AstroSettings  ): Promise<SSRLoadedRenderer[]> { -	const loader = (entry: string) => viteServer.ssrLoadModule(entry); +	const loader = (entry: string) => moduleLoader.import(entry);  	const renderers = await Promise.all(settings.renderers.map((r) => loadRenderer(r, loader)));  	return filterFoundRenderers(renderers);  } @@ -92,11 +78,11 @@ export async function preload({  	filePath,  }: Pick<SSROptions, 'env' | 'filePath'>): Promise<ComponentPreload> {  	// Important: This needs to happen first, in case a renderer provides polyfills. -	const renderers = await loadRenderers(env.viteServer, env.settings); +	const renderers = await loadRenderers(env.loader, env.settings);  	try {  		// Load the module from the Vite SSR Runtime. -		const mod = (await env.viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; +		const mod = (await env.loader.import(fileURLToPath(filePath))) as ComponentInstance;  		return [renderers, mod];  	} catch (err) {  		// If the error came from Markdown or CSS, we already handled it and there's no need to enhance it @@ -104,7 +90,7 @@ export async function preload({  			throw err;  		} -		throw enhanceViteSSRError(err as Error, filePath, env.viteServer); +		throw enhanceViteSSRError(err as Error, filePath, env.loader);  	}  } @@ -115,7 +101,7 @@ interface GetScriptsAndStylesParams {  async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams) {  	// Add hoisted script tags -	const scripts = await getScriptsForURL(filePath, env.viteServer); +	const scripts = await getScriptsForURL(filePath, env.loader);  	// Inject HMR scripts  	if (isPage(filePath, env.settings) && env.mode === 'development') { @@ -126,7 +112,7 @@ async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams)  		scripts.add({  			props: {  				type: 'module', -				src: await resolveIdToUrl(env.viteServer, 'astro/runtime/client/hmr.js'), +				src: await resolveIdToUrl(env.loader, 'astro/runtime/client/hmr.js'),  			},  			children: '',  		}); @@ -148,7 +134,7 @@ async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams)  	}  	// Pass framework CSS in as style tags to be appended to the page. -	const { urls: styleUrls, stylesMap } = await getStylesForURL(filePath, env.viteServer, env.mode); +	const { urls: styleUrls, stylesMap } = await getStylesForURL(filePath, env.loader, env.mode);  	let links = new Set<SSRElement>();  	[...styleUrls].forEach((href) => {  		links.add({ diff --git a/packages/astro/src/core/render/dev/resolve.ts b/packages/astro/src/core/render/dev/resolve.ts index baf18b4e6..b51e577fc 100644 --- a/packages/astro/src/core/render/dev/resolve.ts +++ b/packages/astro/src/core/render/dev/resolve.ts @@ -1,14 +1,14 @@ -import type { ViteDevServer } from 'vite'; +import type { ModuleLoader } from '../../module-loader/index';  import { resolveIdToUrl } from '../../util.js'; -export function createResolve(viteServer: ViteDevServer) { +export function createResolve(loader: ModuleLoader) {  	// Resolves specifiers in the inline hydrated scripts, such as:  	// - @astrojs/preact/client.js  	// - @/components/Foo.vue  	// - /Users/macos/project/src/Foo.vue  	// - C:/Windows/project/src/Foo.vue (normalized slash)  	return async function (s: string) { -		const url = await resolveIdToUrl(viteServer, s); +		const url = await resolveIdToUrl(loader, s);  		// Vite does not resolve .jsx -> .tsx when coming from hydration script import,  		// clip it so Vite is able to resolve implicitly.  		if (url.startsWith('/@fs') && url.endsWith('.jsx')) { diff --git a/packages/astro/src/core/render/dev/scripts.ts b/packages/astro/src/core/render/dev/scripts.ts index fc8967f40..14f8616ee 100644 --- a/packages/astro/src/core/render/dev/scripts.ts +++ b/packages/astro/src/core/render/dev/scripts.ts @@ -1,23 +1,23 @@ -import type { ModuleInfo } from 'rollup'; -import vite from 'vite';  import type { SSRElement } from '../../../@types/astro';  import type { PluginMetadata as AstroPluginMetadata } from '../../../vite-plugin-astro/types'; +import type { ModuleInfo, ModuleLoader } from '../../module-loader/index'; +  import { viteID } from '../../util.js';  import { createModuleScriptElementWithSrc } from '../ssr-element.js';  import { crawlGraph } from './vite.js';  export async function getScriptsForURL(  	filePath: URL, -	viteServer: vite.ViteDevServer +	loader: ModuleLoader  ): Promise<Set<SSRElement>> {  	const elements = new Set<SSRElement>();  	const rootID = viteID(filePath); -	const modInfo = viteServer.pluginContainer.getModuleInfo(rootID); +	const modInfo = loader.getModuleInfo(rootID);  	addHoistedScripts(elements, modInfo); -	for await (const moduleNode of crawlGraph(viteServer, rootID, true)) { +	for await (const moduleNode of crawlGraph(loader, rootID, true)) {  		const id = moduleNode.id;  		if (id) { -			const info = viteServer.pluginContainer.getModuleInfo(id); +			const info = loader.getModuleInfo(id);  			addHoistedScripts(elements, info);  		}  	} diff --git a/packages/astro/src/core/render/dev/vite.ts b/packages/astro/src/core/render/dev/vite.ts index e98fc87d1..ce864c6b4 100644 --- a/packages/astro/src/core/render/dev/vite.ts +++ b/packages/astro/src/core/render/dev/vite.ts @@ -1,5 +1,6 @@ +import type { ModuleLoader, ModuleNode } from '../../module-loader/index'; +  import npath from 'path'; -import vite from 'vite';  import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js';  import { unwrapId } from '../../util.js';  import { STYLE_EXTENSIONS } from '../util.js'; @@ -14,21 +15,21 @@ const STRIP_QUERY_PARAMS_REGEX = /\?.*$/;  /** recursively crawl the module graph to get all style files imported by parent id */  export async function* crawlGraph( -	viteServer: vite.ViteDevServer, +	loader: ModuleLoader,  	_id: string,  	isRootFile: boolean,  	scanned = new Set<string>() -): AsyncGenerator<vite.ModuleNode, void, unknown> { +): AsyncGenerator<ModuleNode, void, unknown> {  	const id = unwrapId(_id); -	const importedModules = new Set<vite.ModuleNode>(); +	const importedModules = new Set<ModuleNode>();  	const moduleEntriesForId = isRootFile  		? // "getModulesByFile" pulls from a delayed module cache (fun implementation detail),  		  // So we can get up-to-date info on initial server load.  		  // Needed for slower CSS preprocessing like Tailwind -		  viteServer.moduleGraph.getModulesByFile(id) ?? new Set() +		  loader.getModulesByFile(id) ?? new Set()  		: // For non-root files, we're safe to pull from "getModuleById" based on testing.  		  // TODO: Find better invalidation strat to use "getModuleById" in all cases! -		  new Set([viteServer.moduleGraph.getModuleById(id)]); +		  new Set([loader.getModuleById(id)]);  	// Collect all imported modules for the module(s).  	for (const entry of moduleEntriesForId) { @@ -57,10 +58,10 @@ export async function* crawlGraph(  						continue;  					}  					if (fileExtensionsToSSR.has(npath.extname(importedModulePathname))) { -						const mod = viteServer.moduleGraph.getModuleById(importedModule.id); +						const mod = loader.getModuleById(importedModule.id);  						if (!mod?.ssrModule) {  							try { -								await viteServer.ssrLoadModule(importedModule.id); +								await loader.import(importedModule.id);  							} catch {  								/** Likely an out-of-date module entry! Silently continue. */  							} @@ -80,6 +81,6 @@ export async function* crawlGraph(  		}  		yield importedModule; -		yield* crawlGraph(viteServer, importedModule.id, false, scanned); +		yield* crawlGraph(loader, importedModule.id, false, scanned);  	}  } diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 4cb2cb141..c983d2a0d 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -8,7 +8,7 @@ import type {  } from '../../../@types/astro';  import type { LogOptions } from '../../logger/core'; -import fs from 'fs'; +import nodeFs from 'fs';  import { createRequire } from 'module';  import path from 'path';  import slash from 'slash'; @@ -200,9 +200,18 @@ function injectedRouteToItem(  	};  } +export interface CreateRouteManifestParams { +	/** Astro Settings object */ +	settings: AstroSettings; +	/** Current working directory */ +	cwd?: string; +	/** fs module, for testing */ +	fsMod?: typeof nodeFs; +} +  /** Create manifest of all static routes */  export function createRouteManifest( -	{ settings, cwd }: { settings: AstroSettings; cwd?: string }, +	{ settings, cwd, fsMod }: CreateRouteManifestParams,  	logging: LogOptions  ): ManifestData {  	const components: string[] = []; @@ -213,8 +222,9 @@ export function createRouteManifest(  		...settings.pageExtensions,  	]);  	const validEndpointExtensions: Set<string> = new Set(['.js', '.ts']); +	const localFs = fsMod ?? nodeFs; -	function walk(dir: string, parentSegments: RoutePart[][], parentParams: string[]) { +	function walk(fs: typeof nodeFs, dir: string, parentSegments: RoutePart[][], parentParams: string[]) {  		let items: Item[] = [];  		fs.readdirSync(dir).forEach((basename) => {  			const resolved = path.join(dir, basename); @@ -291,7 +301,7 @@ export function createRouteManifest(  			params.push(...item.parts.filter((p) => p.dynamic).map((p) => p.content));  			if (item.isDir) { -				walk(path.join(dir, item.basename), segments, params); +				walk(fsMod ?? fs, path.join(dir, item.basename), segments, params);  			} else {  				components.push(item.file);  				const component = item.file; @@ -322,8 +332,8 @@ export function createRouteManifest(  	const { config } = settings;  	const pages = resolvePages(config); -	if (fs.existsSync(pages)) { -		walk(fileURLToPath(pages), [], []); +	if (localFs.existsSync(pages)) { +		walk(localFs, fileURLToPath(pages), [], []);  	} else if (settings.injectedRoutes.length === 0) {  		const pagesDirRootRelative = pages.href.slice(settings.config.root.href.length); diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index dbfe1ad35..e99c849ac 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -1,9 +1,11 @@ +import type { ModuleLoader } from './module-loader'; +import eol from 'eol';  import fs from 'fs';  import path from 'path';  import resolve from 'resolve';  import slash from 'slash';  import { fileURLToPath, pathToFileURL } from 'url'; -import { normalizePath, ViteDevServer } from 'vite'; +import { normalizePath } from 'vite';  import type { AstroConfig, AstroSettings, RouteType } from '../@types/astro';  import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './constants.js';  import { prependForwardSlash, removeTrailingForwardSlash } from './path.js'; @@ -180,19 +182,19 @@ export function getLocalAddress(serverAddress: string, host: string | boolean):   */  // NOTE: `/@id/` should only be used when the id is fully resolved  // TODO: Export a helper util from Vite -export async function resolveIdToUrl(viteServer: ViteDevServer, id: string) { -	let result = await viteServer.pluginContainer.resolveId(id, undefined); +export async function resolveIdToUrl(loader: ModuleLoader, id: string) { +	let resultId = await loader.resolveId(id, undefined);  	// Try resolve jsx to tsx -	if (!result && id.endsWith('.jsx')) { -		result = await viteServer.pluginContainer.resolveId(id.slice(0, -4), undefined); +	if (!resultId && id.endsWith('.jsx')) { +		resultId = await loader.resolveId(id.slice(0, -4), undefined);  	} -	if (!result) { +	if (!resultId) {  		return VALID_ID_PREFIX + id;  	} -	if (path.isAbsolute(result.id)) { -		return '/@fs' + prependForwardSlash(result.id); +	if (path.isAbsolute(resultId)) { +		return '/@fs' + prependForwardSlash(resultId);  	} -	return VALID_ID_PREFIX + result.id; +	return VALID_ID_PREFIX + resultId;  }  export function resolveJsToTs(filePath: string) { diff --git a/packages/astro/src/vite-plugin-astro-server/base.ts b/packages/astro/src/vite-plugin-astro-server/base.ts new file mode 100644 index 000000000..2618749db --- /dev/null +++ b/packages/astro/src/vite-plugin-astro-server/base.ts @@ -0,0 +1,46 @@ +import type * as vite from 'vite'; +import type { AstroSettings } from '../@types/astro'; + +import { LogOptions } from '../core/logger/core.js'; +import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js'; +import { log404 } from './common.js'; +import { writeHtmlResponse } from './response.js'; + +export function baseMiddleware( +	settings: AstroSettings, +	logging: LogOptions +): vite.Connect.NextHandleFunction { +	const { config } = settings; +	const site = config.site ? new URL(config.base, config.site) : undefined; +	const devRoot = site ? site.pathname : '/'; + +	return function devBaseMiddleware(req, res, next) { +		const url = req.url!; + +		const pathname = decodeURI(new URL(url, 'http://vitejs.dev').pathname); + +		if (pathname.startsWith(devRoot)) { +			req.url = url.replace(devRoot, '/'); +			return next(); +		} + +		if (pathname === '/' || pathname === '/index.html') { +			log404(logging, pathname); +			const html = subpathNotUsedTemplate(devRoot, pathname); +			return writeHtmlResponse(res, 404, html); +		} + +		if (req.headers.accept?.includes('text/html')) { +			log404(logging, pathname); +			const html = notFoundTemplate({ +				statusCode: 404, +				title: 'Not found', +				tabTitle: '404: Not Found', +				pathname, +			}); +			return writeHtmlResponse(res, 404, html); +		} + +		next(); +	}; +} diff --git a/packages/astro/src/vite-plugin-astro-server/common.ts b/packages/astro/src/vite-plugin-astro-server/common.ts new file mode 100644 index 000000000..dc0176980 --- /dev/null +++ b/packages/astro/src/vite-plugin-astro-server/common.ts @@ -0,0 +1,6 @@ +import { info, LogOptions } from '../core/logger/core.js'; +import * as msg from '../core/messages.js'; + +export function log404(logging: LogOptions, pathname: string) { +	info(logging, 'serve', msg.req({ url: pathname, statusCode: 404 })); +} diff --git a/packages/astro/src/vite-plugin-astro-server/controller.ts b/packages/astro/src/vite-plugin-astro-server/controller.ts new file mode 100644 index 000000000..bbbf87c04 --- /dev/null +++ b/packages/astro/src/vite-plugin-astro-server/controller.ts @@ -0,0 +1,100 @@ +import type { ServerState } from './server-state'; +import type { LoaderEvents, ModuleLoader } from '../core/module-loader/index'; + +import { createServerState, setRouteError, setServerError, clearRouteError } from './server-state.js'; + +type ReloadFn = () => void; + +export interface DevServerController { +	state: ServerState; +	onFileChange: LoaderEvents['file-change']; +	onHMRError: LoaderEvents['hmr-error']; +} + +export type CreateControllerParams = { +	loader: ModuleLoader; +} | { +	reload: ReloadFn; +}; + +export function createController(params: CreateControllerParams): DevServerController { +	if('loader' in params) { +		return createLoaderController(params.loader); +	} else { +		return createBaseController(params); +	} +} + +export function createBaseController({ reload }: { reload: ReloadFn }): DevServerController { +	const serverState = createServerState(); + +	const onFileChange: LoaderEvents['file-change'] = () => { +		if(serverState.state === 'error') { +			reload(); +		} +	}; + +	const onHMRError: LoaderEvents['hmr-error'] = (payload) => { +		let msg = payload?.err?.message ?? 'Unknown error'; +		let stack = payload?.err?.stack ?? 'Unknown stack'; +		let error = new Error(msg); +		Object.defineProperty(error, 'stack', { +			value: stack +		}); +		setServerError(serverState, error); +	}; + +	return { +		state: serverState, +		onFileChange, +		onHMRError +	}; +} + +export function createLoaderController(loader: ModuleLoader): DevServerController { +	const controller = createBaseController({ +		reload() { +			loader.clientReload(); +		} +	}); +	const baseOnFileChange = controller.onFileChange; +	controller.onFileChange = (...args) => { +		if(controller.state.state === 'error') { +			// If we are in an error state, check if there are any modules with errors +			// and if so invalidate them so that they will be updated on refresh. +			loader.eachModule(mod => { +				if(mod.ssrError) { +					loader.invalidateModule(mod); +				} +			}); +		} +		baseOnFileChange(...args); +	} + +	loader.events.on('file-change', controller.onFileChange); +	loader.events.on('hmr-error', controller.onHMRError); + +	return controller; +} + +export interface RunWithErrorHandlingParams { +	controller: DevServerController; +	pathname: string; +	run: () => Promise<any>; +	onError: (error: unknown) => Error; +} + +export async function runWithErrorHandling({ +	controller: { state }, +	pathname, +	run, +	onError +}: RunWithErrorHandlingParams) { +	try { +		await run(); +		clearRouteError(state, pathname); +	} catch(err) { +		const error = onError(err); +		setRouteError(state, pathname, error); +	} +} diff --git a/packages/astro/src/vite-plugin-astro-server/index.ts b/packages/astro/src/vite-plugin-astro-server/index.ts index 0e09c9b96..a6baa6c2c 100644 --- a/packages/astro/src/vite-plugin-astro-server/index.ts +++ b/packages/astro/src/vite-plugin-astro-server/index.ts @@ -1,439 +1,10 @@ -import type http from 'http'; -import mime from 'mime'; -import type * as vite from 'vite'; -import type { AstroSettings, ManifestData } from '../@types/astro'; -import { DevelopmentEnvironment, SSROptions } from '../core/render/dev/index'; - -import { Readable } from 'stream'; -import { attachToResponse, getSetCookiesFromResponse } from '../core/cookies/index.js'; -import { call as callEndpoint } from '../core/endpoint/dev/index.js'; -import { throwIfRedirectNotAllowed } from '../core/endpoint/index.js'; -import { collectErrorMetadata, getViteErrorPayload } from '../core/errors/dev/index.js'; -import type { ErrorWithMetadata } from '../core/errors/index.js'; -import { createSafeError } from '../core/errors/index.js'; -import { error, info, LogOptions, warn } from '../core/logger/core.js'; -import * as msg from '../core/messages.js'; -import { appendForwardSlash } from '../core/path.js'; -import { createDevelopmentEnvironment, preload, renderPage } from '../core/render/dev/index.js'; -import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js'; -import { createRequest } from '../core/request.js'; -import { createRouteManifest, matchAllRoutes } from '../core/routing/index.js'; -import { resolvePages } from '../core/util.js'; -import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js'; - -interface AstroPluginOptions { -	settings: AstroSettings; -	logging: LogOptions; -} - -type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends ( -	...args: any -) => Promise<infer R> -	? R -	: any; - -function writeHtmlResponse(res: http.ServerResponse, statusCode: number, html: string) { -	res.writeHead(statusCode, { -		'Content-Type': 'text/html; charset=utf-8', -		'Content-Length': Buffer.byteLength(html, 'utf-8'), -	}); -	res.write(html); -	res.end(); -} - -async function writeWebResponse(res: http.ServerResponse, webResponse: Response) { -	const { status, headers, body } = webResponse; - -	let _headers = {}; -	if ('raw' in headers) { -		// Node fetch allows you to get the raw headers, which includes multiples of the same type. -		// This is needed because Set-Cookie *must* be called for each cookie, and can't be -		// concatenated together. -		type HeadersWithRaw = Headers & { -			raw: () => Record<string, string[]>; -		}; - -		for (const [key, value] of Object.entries((headers as HeadersWithRaw).raw())) { -			res.setHeader(key, value); -		} -	} else { -		_headers = Object.fromEntries(headers.entries()); -	} - -	// Attach any set-cookie headers added via Astro.cookies.set() -	const setCookieHeaders = Array.from(getSetCookiesFromResponse(webResponse)); -	if (setCookieHeaders.length) { -		res.setHeader('Set-Cookie', setCookieHeaders); -	} -	res.writeHead(status, _headers); -	if (body) { -		if (Symbol.for('astro.responseBody') in webResponse) { -			let stream = (webResponse as any)[Symbol.for('astro.responseBody')]; -			for await (const chunk of stream) { -				res.write(chunk.toString()); -			} -		} else if (body instanceof Readable) { -			body.pipe(res); -			return; -		} else if (typeof body === 'string') { -			res.write(body); -		} else { -			const reader = body.getReader(); -			while (true) { -				const { done, value } = await reader.read(); -				if (done) break; -				if (value) { -					res.write(value); -				} -			} -		} -	} -	res.end(); -} - -async function writeSSRResult(webResponse: Response, res: http.ServerResponse) { -	return writeWebResponse(res, webResponse); -} - -async function handle404Response( -	origin: string, -	req: http.IncomingMessage, -	res: http.ServerResponse -) { -	const pathname = decodeURI(new URL(origin + req.url).pathname); - -	const html = notFoundTemplate({ -		statusCode: 404, -		title: 'Not found', -		tabTitle: '404: Not Found', -		pathname, -	}); -	writeHtmlResponse(res, 404, html); -} - -async function handle500Response( -	viteServer: vite.ViteDevServer, -	origin: string, -	req: http.IncomingMessage, -	res: http.ServerResponse, -	err: ErrorWithMetadata -) { -	res.on('close', () => setTimeout(() => viteServer.ws.send(getViteErrorPayload(err)), 200)); -	if (res.headersSent) { -		res.write(`<script type="module" src="/@vite/client"></script>`); -		res.end(); -	} else { -		writeHtmlResponse( -			res, -			500, -			`<title>${err.name}</title><script type="module" src="/@vite/client"></script>` -		); -	} -} - -function getCustom404Route({ config }: AstroSettings, manifest: ManifestData) { -	// For Windows compat, use relative page paths to match the 404 route -	const relPages = resolvePages(config).href.replace(config.root.href, ''); -	const pattern = new RegExp(`${appendForwardSlash(relPages)}404.(astro|md)`); -	return manifest.routes.find((r) => r.component.match(pattern)); -} - -function log404(logging: LogOptions, pathname: string) { -	info(logging, 'serve', msg.req({ url: pathname, statusCode: 404 })); -} - -export function baseMiddleware( -	settings: AstroSettings, -	logging: LogOptions -): vite.Connect.NextHandleFunction { -	const { config } = settings; -	const site = config.site ? new URL(config.base, config.site) : undefined; -	const devRoot = site ? site.pathname : '/'; - -	return function devBaseMiddleware(req, res, next) { -		const url = req.url!; - -		const pathname = decodeURI(new URL(url, 'http://vitejs.dev').pathname); - -		if (pathname.startsWith(devRoot)) { -			req.url = url.replace(devRoot, '/'); -			return next(); -		} - -		if (pathname === '/' || pathname === '/index.html') { -			log404(logging, pathname); -			const html = subpathNotUsedTemplate(devRoot, pathname); -			return writeHtmlResponse(res, 404, html); -		} - -		if (req.headers.accept?.includes('text/html')) { -			log404(logging, pathname); -			const html = notFoundTemplate({ -				statusCode: 404, -				title: 'Not found', -				tabTitle: '404: Not Found', -				pathname, -			}); -			return writeHtmlResponse(res, 404, html); -		} - -		next(); -	}; -} - -async function matchRoute(pathname: string, env: DevelopmentEnvironment, manifest: ManifestData) { -	const { logging, settings, routeCache } = env; -	const matches = matchAllRoutes(pathname, manifest); - -	for await (const maybeRoute of matches) { -		const filePath = new URL(`./${maybeRoute.component}`, settings.config.root); -		const preloadedComponent = await preload({ env, filePath }); -		const [, mod] = preloadedComponent; -		// attempt to get static paths -		// if this fails, we have a bad URL match! -		const paramsAndPropsRes = await getParamsAndProps({ -			mod, -			route: maybeRoute, -			routeCache, -			pathname: pathname, -			logging, -			ssr: settings.config.output === 'server', -		}); - -		if (paramsAndPropsRes !== GetParamsAndPropsError.NoMatchingStaticPath) { -			return { -				route: maybeRoute, -				filePath, -				preloadedComponent, -				mod, -			}; -		} -	} - -	if (matches.length) { -		warn( -			logging, -			'getStaticPaths', -			`Route pattern matched, but no matching static path found. (${pathname})` -		); -	} - -	log404(logging, pathname); -	const custom404 = getCustom404Route(settings, manifest); - -	if (custom404) { -		const filePath = new URL(`./${custom404.component}`, settings.config.root); -		const preloadedComponent = await preload({ env, filePath }); -		const [, mod] = preloadedComponent; - -		return { -			route: custom404, -			filePath, -			preloadedComponent, -			mod, -		}; -	} - -	return undefined; -} - -/** The main logic to route dev server requests to pages in Astro. */ -async function handleRequest( -	env: DevelopmentEnvironment, -	manifest: ManifestData, -	req: http.IncomingMessage, -	res: http.ServerResponse -) { -	const { settings, viteServer } = env; -	const { config } = settings; -	const origin = `${viteServer.config.server.https ? 'https' : 'http'}://${req.headers.host}`; -	const buildingToSSR = config.output === 'server'; -	// Ignore `.html` extensions and `index.html` in request URLS to ensure that -	// routing behavior matches production builds. This supports both file and directory -	// build formats, and is necessary based on how the manifest tracks build targets. -	const url = new URL(origin + req.url?.replace(/(index)?\.html$/, '')); -	const pathname = decodeURI(url.pathname); - -	// Add config.base back to url before passing it to SSR -	url.pathname = config.base.substring(0, config.base.length - 1) + url.pathname; - -	// HACK! @astrojs/image uses query params for the injected route in `dev` -	if (!buildingToSSR && pathname !== '/_image') { -		// Prevent user from depending on search params when not doing SSR. -		// NOTE: Create an array copy here because deleting-while-iterating -		// creates bugs where not all search params are removed. -		const allSearchParams = Array.from(url.searchParams); -		for (const [key] of allSearchParams) { -			url.searchParams.delete(key); -		} -	} - -	let body: ArrayBuffer | undefined = undefined; -	if (!(req.method === 'GET' || req.method === 'HEAD')) { -		let bytes: Uint8Array[] = []; -		await new Promise((resolve) => { -			req.on('data', (part) => { -				bytes.push(part); -			}); -			req.on('end', resolve); -		}); -		body = Buffer.concat(bytes); -	} - -	try { -		const matchedRoute = await matchRoute(pathname, env, manifest); -		return await handleRoute(matchedRoute, url, pathname, body, origin, env, manifest, req, res); -	} catch (_err) { -		// This is our last line of defense regarding errors where we still might have some information about the request -		// Our error should already be complete, but let's try to add a bit more through some guesswork -		const err = createSafeError(_err); -		const errorWithMetadata = collectErrorMetadata(err); - -		error(env.logging, null, msg.formatErrorMessage(errorWithMetadata)); -		handle500Response(viteServer, origin, req, res, errorWithMetadata); -	} -} - -async function handleRoute( -	matchedRoute: AsyncReturnType<typeof matchRoute>, -	url: URL, -	pathname: string, -	body: ArrayBuffer | undefined, -	origin: string, -	env: DevelopmentEnvironment, -	manifest: ManifestData, -	req: http.IncomingMessage, -	res: http.ServerResponse -): Promise<void> { -	const { logging, settings } = env; -	if (!matchedRoute) { -		return handle404Response(origin, req, res); -	} - -	const { config } = settings; -	const filePath: URL | undefined = matchedRoute.filePath; -	const { route, preloadedComponent, mod } = matchedRoute; -	const buildingToSSR = config.output === 'server'; - -	// Headers are only available when using SSR. -	const request = createRequest({ -		url, -		headers: buildingToSSR ? req.headers : new Headers(), -		method: req.method, -		body, -		logging, -		ssr: buildingToSSR, -		clientAddress: buildingToSSR ? req.socket.remoteAddress : undefined, -	}); - -	// attempt to get static paths -	// if this fails, we have a bad URL match! -	const paramsAndPropsRes = await getParamsAndProps({ -		mod, -		route, -		routeCache: env.routeCache, -		pathname: pathname, -		logging, -		ssr: config.output === 'server', -	}); - -	const options: SSROptions = { -		env, -		filePath, -		origin, -		preload: preloadedComponent, -		pathname, -		request, -		route, -	}; - -	// Route successfully matched! Render it. -	if (route.type === 'endpoint') { -		const result = await callEndpoint(options); -		if (result.type === 'response') { -			if (result.response.headers.get('X-Astro-Response') === 'Not-Found') { -				const fourOhFourRoute = await matchRoute('/404', env, manifest); -				return handleRoute( -					fourOhFourRoute, -					new URL('/404', url), -					'/404', -					body, -					origin, -					env, -					manifest, -					req, -					res -				); -			} -			throwIfRedirectNotAllowed(result.response, config); -			await writeWebResponse(res, result.response); -		} else { -			let contentType = 'text/plain'; -			// Dynamic routes don’t include `route.pathname`, so synthesize a path for these (e.g. 'src/pages/[slug].svg') -			const filepath = -				route.pathname || -				route.segments.map((segment) => segment.map((p) => p.content).join('')).join('/'); -			const computedMimeType = mime.getType(filepath); -			if (computedMimeType) { -				contentType = computedMimeType; -			} -			const response = new Response(result.body, { -				status: 200, -				headers: { -					'Content-Type': `${contentType};charset=utf-8`, -				}, -			}); -			attachToResponse(response, result.cookies); -			await writeWebResponse(res, response); -		} -	} else { -		const result = await renderPage(options); -		throwIfRedirectNotAllowed(result, config); -		return await writeSSRResult(result, res); -	} -} - -export default function createPlugin({ settings, logging }: AstroPluginOptions): vite.Plugin { -	return { -		name: 'astro:server', -		configureServer(viteServer) { -			let env = createDevelopmentEnvironment(settings, logging, viteServer); -			let manifest: ManifestData = createRouteManifest({ settings }, logging); - -			/** rebuild the route cache + manifest, as needed. */ -			function rebuildManifest(needsManifestRebuild: boolean, file: string) { -				env.routeCache.clearAll(); -				if (needsManifestRebuild) { -					manifest = createRouteManifest({ settings }, logging); -				} -			} -			// Rebuild route manifest on file change, if needed. -			viteServer.watcher.on('add', rebuildManifest.bind(null, true)); -			viteServer.watcher.on('unlink', rebuildManifest.bind(null, true)); -			viteServer.watcher.on('change', rebuildManifest.bind(null, false)); -			return () => { -				// Push this middleware to the front of the stack so that it can intercept responses. -				if (settings.config.base !== '/') { -					viteServer.middlewares.stack.unshift({ -						route: '', -						handle: baseMiddleware(settings, logging), -					}); -				} -				viteServer.middlewares.use(async (req, res) => { -					if (!req.url || !req.method) { -						throw new Error('Incomplete request'); -					} -					handleRequest(env, manifest, req, res); -				}); -			}; -		}, -		// HACK: hide `.tip` in Vite's ErrorOverlay and replace [vite] messages with [astro] -		transform(code, id, opts = {}) { -			if (opts.ssr) return; -			if (!id.includes('vite/dist/client/client.mjs')) return; -			return code -				.replace(/\.tip \{[^}]*\}/gm, '.tip {\n  display: none;\n}') -				.replace(/\[vite\]/g, '[astro]'); -		}, -	}; -} +export { +	createController, +	runWithErrorHandling +} from './controller.js'; +export { +	default as vitePluginAstroServer +} from './plugin.js'; +export { +	handleRequest +} from './request.js'; diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts new file mode 100644 index 000000000..434b220a3 --- /dev/null +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -0,0 +1,66 @@ + +import type * as vite from 'vite'; +import type { AstroSettings, ManifestData } from '../@types/astro'; + +import { LogOptions } from '../core/logger/core.js'; +import { createDevelopmentEnvironment } from '../core/render/dev/index.js'; +import { createRouteManifest } from '../core/routing/index.js'; +import { createViteLoader } from '../core/module-loader/index.js'; +import { baseMiddleware } from './base.js'; +import { handleRequest } from './request.js'; +import { createController } from './controller.js'; +import type fs from 'fs'; + +export interface AstroPluginOptions { +	settings: AstroSettings; +	logging: LogOptions; +	fs: typeof fs; +} + +export default function createVitePluginAstroServer({ settings, logging, fs: fsMod }: AstroPluginOptions): vite.Plugin { +	return { +		name: 'astro:server', +		configureServer(viteServer) { +			const loader = createViteLoader(viteServer); +			let env = createDevelopmentEnvironment(settings, logging, loader); +			let manifest: ManifestData = createRouteManifest({ settings, fsMod }, logging); +			const serverController = createController({ loader }); + +			/** rebuild the route cache + manifest, as needed. */ +			function rebuildManifest(needsManifestRebuild: boolean, _file: string) { +				env.routeCache.clearAll(); +				if (needsManifestRebuild) { +					manifest = createRouteManifest({ settings }, logging); +				} +			} +			// Rebuild route manifest on file change, if needed. +			viteServer.watcher.on('add', rebuildManifest.bind(null, true)); +			viteServer.watcher.on('unlink', rebuildManifest.bind(null, true)); +			viteServer.watcher.on('change', rebuildManifest.bind(null, false)); + +			return () => { +				// Push this middleware to the front of the stack so that it can intercept responses. +				if (settings.config.base !== '/') { +					viteServer.middlewares.stack.unshift({ +						route: '', +						handle: baseMiddleware(settings, logging), +					}); +				} +				viteServer.middlewares.use(async (req, res) => { +					if (!req.url || !req.method) { +						throw new Error('Incomplete request'); +					} +					handleRequest(env, manifest, serverController, req, res); +				}); +			}; +		}, +		// HACK: hide `.tip` in Vite's ErrorOverlay and replace [vite] messages with [astro] +		transform(code, id, opts = {}) { +			if (opts.ssr) return; +			if (!id.includes('vite/dist/client/client.mjs')) return; +			return code +				.replace(/\.tip \{[^}]*\}/gm, '.tip {\n  display: none;\n}') +				.replace(/\[vite\]/g, '[astro]'); +		}, +	}; +} diff --git a/packages/astro/src/vite-plugin-astro-server/request.ts b/packages/astro/src/vite-plugin-astro-server/request.ts new file mode 100644 index 000000000..4b0c1563e --- /dev/null +++ b/packages/astro/src/vite-plugin-astro-server/request.ts @@ -0,0 +1,78 @@ +import type http from 'http'; +import type { ManifestData, RouteData } from '../@types/astro'; +import type { DevServerController } from './controller'; +import type { DevelopmentEnvironment } from '../core/render/dev/index'; + +import { collectErrorMetadata } from '../core/errors/dev/index.js'; +import { error } from '../core/logger/core.js'; +import * as msg from '../core/messages.js'; +import { handleRoute, matchRoute } from './route.js'; +import { handle500Response } from './response.js'; +import { runWithErrorHandling } from './controller.js'; +import { createSafeError } from '../core/errors/index.js'; + +/** The main logic to route dev server requests to pages in Astro. */ +export async function handleRequest( +	env: DevelopmentEnvironment, +	manifest: ManifestData, +	controller: DevServerController, +	req: http.IncomingMessage, +	res: http.ServerResponse +) { +	const { settings, loader: moduleLoader } = env; +	const { config } = settings; +	const origin = `${moduleLoader.isHttps() ? 'https' : 'http'}://${req.headers.host}`; +	const buildingToSSR = config.output === 'server'; +	// Ignore `.html` extensions and `index.html` in request URLS to ensure that +	// routing behavior matches production builds. This supports both file and directory +	// build formats, and is necessary based on how the manifest tracks build targets. +	const url = new URL(origin + req.url?.replace(/(index)?\.html$/, '')); +	const pathname = decodeURI(url.pathname); + +	// Add config.base back to url before passing it to SSR +	url.pathname = config.base.substring(0, config.base.length - 1) + url.pathname; + +	// HACK! @astrojs/image uses query params for the injected route in `dev` +	if (!buildingToSSR && pathname !== '/_image') { +		// Prevent user from depending on search params when not doing SSR. +		// NOTE: Create an array copy here because deleting-while-iterating +		// creates bugs where not all search params are removed. +		const allSearchParams = Array.from(url.searchParams); +		for (const [key] of allSearchParams) { +			url.searchParams.delete(key); +		} +	} + +	let body: ArrayBuffer | undefined = undefined; +	if (!(req.method === 'GET' || req.method === 'HEAD')) { +		let bytes: Uint8Array[] = []; +		await new Promise((resolve) => { +			req.on('data', (part) => { +				bytes.push(part); +			}); +			req.on('end', resolve); +		}); +		body = Buffer.concat(bytes); +	} + +	await runWithErrorHandling({ +		controller, +		pathname, +		async run() { +			const matchedRoute = await matchRoute(pathname, env, manifest); +	 +			return await handleRoute(matchedRoute, url, pathname, body, origin, env, manifest, req, res); +		}, +		onError(_err) { +			const err = createSafeError(_err); +			// This is our last line of defense regarding errors where we still might have some information about the request +			// Our error should already be complete, but let's try to add a bit more through some guesswork +			const errorWithMetadata = collectErrorMetadata(err); + +			error(env.logging, null, msg.formatErrorMessage(errorWithMetadata)); +			handle500Response(moduleLoader, res, errorWithMetadata); + +			return err; +		} +	}); +} diff --git a/packages/astro/src/vite-plugin-astro-server/response.ts b/packages/astro/src/vite-plugin-astro-server/response.ts new file mode 100644 index 000000000..60142e0d6 --- /dev/null +++ b/packages/astro/src/vite-plugin-astro-server/response.ts @@ -0,0 +1,106 @@ +import type http from 'http'; +import type { ModuleLoader } from '../core/module-loader/index'; +import type { ErrorWithMetadata } from '../core/errors/index.js'; + +import { Readable } from 'stream'; +import { getSetCookiesFromResponse } from '../core/cookies/index.js'; +import { getViteErrorPayload } from '../core/errors/dev/index.js'; +import notFoundTemplate from '../template/4xx.js'; + + +export async function handle404Response( +	origin: string, +	req: http.IncomingMessage, +	res: http.ServerResponse +) { +	const pathname = decodeURI(new URL(origin + req.url).pathname); + +	const html = notFoundTemplate({ +		statusCode: 404, +		title: 'Not found', +		tabTitle: '404: Not Found', +		pathname, +	}); +	writeHtmlResponse(res, 404, html); +} + +export async function handle500Response( +	loader: ModuleLoader, +	res: http.ServerResponse, +	err: ErrorWithMetadata +) { +	res.on('close', () => setTimeout(() => loader.webSocketSend(getViteErrorPayload(err)), 200)); +	if (res.headersSent) { +		res.write(`<script type="module" src="/@vite/client"></script>`); +		res.end(); +	} else { +		writeHtmlResponse( +			res, +			500, +			`<title>${err.name}</title><script type="module" src="/@vite/client"></script>` +		); +	} +} + +export function writeHtmlResponse(res: http.ServerResponse, statusCode: number, html: string) { +	res.writeHead(statusCode, { +		'Content-Type': 'text/html; charset=utf-8', +		'Content-Length': Buffer.byteLength(html, 'utf-8'), +	}); +	res.write(html); +	res.end(); +} + +export async function writeWebResponse(res: http.ServerResponse, webResponse: Response) { +	const { status, headers, body } = webResponse; + +	let _headers = {}; +	if ('raw' in headers) { +		// Node fetch allows you to get the raw headers, which includes multiples of the same type. +		// This is needed because Set-Cookie *must* be called for each cookie, and can't be +		// concatenated together. +		type HeadersWithRaw = Headers & { +			raw: () => Record<string, string[]>; +		}; + +		for (const [key, value] of Object.entries((headers as HeadersWithRaw).raw())) { +			res.setHeader(key, value); +		} +	} else { +		_headers = Object.fromEntries(headers.entries()); +	} + +	// Attach any set-cookie headers added via Astro.cookies.set() +	const setCookieHeaders = Array.from(getSetCookiesFromResponse(webResponse)); +	if (setCookieHeaders.length) { +		res.setHeader('Set-Cookie', setCookieHeaders); +	} +	res.writeHead(status, _headers); +	if (body) { +		if (Symbol.for('astro.responseBody') in webResponse) { +			let stream = (webResponse as any)[Symbol.for('astro.responseBody')]; +			for await (const chunk of stream) { +				res.write(chunk.toString()); +			} +		} else if (body instanceof Readable) { +			body.pipe(res); +			return; +		} else if (typeof body === 'string') { +			res.write(body); +		} else { +			const reader = body.getReader(); +			while (true) { +				const { done, value } = await reader.read(); +				if (done) break; +				if (value) { +					res.write(value); +				} +			} +		} +	} +	res.end(); +} + +export async function writeSSRResult(webResponse: Response, res: http.ServerResponse) { +	return writeWebResponse(res, webResponse); +} diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts new file mode 100644 index 000000000..7015aaba8 --- /dev/null +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -0,0 +1,185 @@ +import type http from 'http'; +import mime from 'mime'; +import type { AstroConfig, AstroSettings, ManifestData } from '../@types/astro'; +import { DevelopmentEnvironment, SSROptions } from '../core/render/dev/index'; + +import { attachToResponse } from '../core/cookies/index.js'; +import { call as callEndpoint } from '../core/endpoint/dev/index.js'; +import { warn } from '../core/logger/core.js'; +import { appendForwardSlash } from '../core/path.js'; +import { preload, renderPage } from '../core/render/dev/index.js'; +import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js'; +import { createRequest } from '../core/request.js'; +import { matchAllRoutes } from '../core/routing/index.js'; +import { resolvePages } from '../core/util.js'; +import { log404 } from './common.js'; +import { handle404Response, writeWebResponse, writeSSRResult } from './response.js'; +import { throwIfRedirectNotAllowed } from '../core/endpoint/index.js'; + +type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends ( +	...args: any +) => Promise<infer R> +	? R +	: any; + +function getCustom404Route({ config }: AstroSettings, manifest: ManifestData) { +	// For Windows compat, use relative page paths to match the 404 route +	const relPages = resolvePages(config).href.replace(config.root.href, ''); +	const pattern = new RegExp(`${appendForwardSlash(relPages)}404.(astro|md)`); +	return manifest.routes.find((r) => r.component.match(pattern)); +} + +export async function matchRoute(pathname: string, env: DevelopmentEnvironment, manifest: ManifestData) { +	const { logging, settings, routeCache } = env; +	const matches = matchAllRoutes(pathname, manifest); + +	for await (const maybeRoute of matches) { +		const filePath = new URL(`./${maybeRoute.component}`, settings.config.root); +		const preloadedComponent = await preload({ env, filePath }); +		const [, mod] = preloadedComponent; +		// attempt to get static paths +		// if this fails, we have a bad URL match! +		const paramsAndPropsRes = await getParamsAndProps({ +			mod, +			route: maybeRoute, +			routeCache, +			pathname: pathname, +			logging, +			ssr: settings.config.output === 'server', +		}); + +		if (paramsAndPropsRes !== GetParamsAndPropsError.NoMatchingStaticPath) { +			return { +				route: maybeRoute, +				filePath, +				preloadedComponent, +				mod, +			}; +		} +	} + +	if (matches.length) { +		warn( +			logging, +			'getStaticPaths', +			`Route pattern matched, but no matching static path found. (${pathname})` +		); +	} + +	log404(logging, pathname); +	const custom404 = getCustom404Route(settings, manifest); + +	if (custom404) { +		const filePath = new URL(`./${custom404.component}`, settings.config.root); +		const preloadedComponent = await preload({ env, filePath }); +		const [, mod] = preloadedComponent; + +		return { +			route: custom404, +			filePath, +			preloadedComponent, +			mod, +		}; +	} + +	return undefined; +} + +export async function handleRoute( +	matchedRoute: AsyncReturnType<typeof matchRoute>, +	url: URL, +	pathname: string, +	body: ArrayBuffer | undefined, +	origin: string, +	env: DevelopmentEnvironment, +	manifest: ManifestData, +	req: http.IncomingMessage, +	res: http.ServerResponse +): Promise<void> { +	const { logging, settings } = env; +	if (!matchedRoute) { +		return handle404Response(origin, req, res); +	} + +	const { config } = settings; +	const filePath: URL | undefined = matchedRoute.filePath; +	const { route, preloadedComponent, mod } = matchedRoute; +	const buildingToSSR = config.output === 'server'; + +	// Headers are only available when using SSR. +	const request = createRequest({ +		url, +		headers: buildingToSSR ? req.headers : new Headers(), +		method: req.method, +		body, +		logging, +		ssr: buildingToSSR, +		clientAddress: buildingToSSR ? req.socket.remoteAddress : undefined, +	}); + +	// attempt to get static paths +	// if this fails, we have a bad URL match! +	const paramsAndPropsRes = await getParamsAndProps({ +		mod, +		route, +		routeCache: env.routeCache, +		pathname: pathname, +		logging, +		ssr: config.output === 'server', +	}); + +	const options: SSROptions = { +		env, +		filePath, +		origin, +		preload: preloadedComponent, +		pathname, +		request, +		route, +	}; + +	// Route successfully matched! Render it. +	if (route.type === 'endpoint') { +		const result = await callEndpoint(options); +		if (result.type === 'response') { +			if (result.response.headers.get('X-Astro-Response') === 'Not-Found') { +				const fourOhFourRoute = await matchRoute('/404', env, manifest); +				return handleRoute( +					fourOhFourRoute, +					new URL('/404', url), +					'/404', +					body, +					origin, +					env, +					manifest, +					req, +					res +				); +			} +			throwIfRedirectNotAllowed(result.response, config); +			await writeWebResponse(res, result.response); +		} else { +			let contentType = 'text/plain'; +			// Dynamic routes don’t include `route.pathname`, so synthesize a path for these (e.g. 'src/pages/[slug].svg') +			const filepath = +				route.pathname || +				route.segments.map((segment) => segment.map((p) => p.content).join('')).join('/'); +			const computedMimeType = mime.getType(filepath); +			if (computedMimeType) { +				contentType = computedMimeType; +			} +			const response = new Response(result.body, { +				status: 200, +				headers: { +					'Content-Type': `${contentType};charset=utf-8`, +				}, +			}); +			attachToResponse(response, result.cookies); +			await writeWebResponse(res, response); +		} +	} else { +		const result = await renderPage(options); +		throwIfRedirectNotAllowed(result, config); +		return await writeSSRResult(result, res); +	} +} diff --git a/packages/astro/src/vite-plugin-astro-server/server-state.ts b/packages/astro/src/vite-plugin-astro-server/server-state.ts new file mode 100644 index 000000000..16dec7d5a --- /dev/null +++ b/packages/astro/src/vite-plugin-astro-server/server-state.ts @@ -0,0 +1,52 @@ +export type ErrorState = 'fresh' | 'error'; + +export interface RouteState { +	state: ErrorState; +	error?: Error; +} + +export interface ServerState { +	routes: Map<string, RouteState>; +	state: ErrorState; +	error?: Error; +} + +export function createServerState(): ServerState { +	return { +		routes: new Map(), +		state: 'fresh' +	}; +} + +export function hasAnyFailureState(serverState: ServerState) { +	return serverState.state !== 'fresh'; +} + +export function setRouteError(serverState: ServerState, pathname: string, error: Error) { +	if(serverState.routes.has(pathname)) { +		const routeState = serverState.routes.get(pathname)!; +		routeState.state = 'error'; +		routeState.error = error; +	} else { +		const routeState: RouteState = { +			state: 'error', +			error: error +		}; +		serverState.routes.set(pathname, routeState); +	} +	serverState.state = 'error'; +	serverState.error = error; +} + +export function setServerError(serverState: ServerState, error: Error) { +	serverState.state = 'error'; +	serverState.error = error; +} + +export function clearRouteError(serverState: ServerState, pathname: string) { +	if(serverState.routes.has(pathname)) { +		serverState.routes.delete(pathname); +	} +	serverState.state = 'fresh'; +	serverState.error = undefined; +} diff --git a/packages/astro/src/vite-plugin-load-fallback/index.ts b/packages/astro/src/vite-plugin-load-fallback/index.ts new file mode 100644 index 000000000..6a6af9142 --- /dev/null +++ b/packages/astro/src/vite-plugin-load-fallback/index.ts @@ -0,0 +1,38 @@ +import type * as vite from 'vite'; +import nodeFs from 'fs'; + +type NodeFileSystemModule = typeof nodeFs; + +export interface LoadFallbackPluginParams { +	fs?: NodeFileSystemModule; +} + +export default function loadFallbackPlugin({ fs }: LoadFallbackPluginParams): vite.Plugin | false { +	// Only add this plugin if a custom fs implementation is provided. +	if(!fs || fs === nodeFs) { +		return false; +	} + +  return { +    name: 'astro:load-fallback', +	enforce: 'post', +    async load(id) { +      try { +		// await is necessary for the catch +        return await fs.promises.readFile(cleanUrl(id), 'utf-8') +      } catch (e) { +        try { +			return await fs.promises.readFile(id, 'utf-8'); +		} catch(e2) { +			// Let fall through to the next +		} +      } +    } +  } +} + +const queryRE = /\?.*$/s; +const hashRE = /#.*$/s; + +const cleanUrl = (url: string): string => +	url.replace(hashRE, '').replace(queryRE, ''); diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index a4f9191f9..45ecabd52 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -19,7 +19,7 @@ polyfill(globalThis, {  /**   * @typedef {import('node-fetch').Response} Response - * @typedef {import('../src/core/dev/index').DedvServer} DevServer + * @typedef {import('../src/core/dev/dev').DedvServer} DevServer   * @typedef {import('../src/@types/astro').AstroConfig} AstroConfig   * @typedef {import('../src/core/preview/index').PreviewServer} PreviewServer   * @typedef {import('../src/core/app/index').App} App diff --git a/packages/astro/test/units/correct-path.js b/packages/astro/test/units/correct-path.js new file mode 100644 index 000000000..3d0681623 --- /dev/null +++ b/packages/astro/test/units/correct-path.js @@ -0,0 +1,70 @@ +/** + * correctPath.js <https://github.com/streamich/fs-monkey/blob/af36a890d8070b25b9eae7178824f653bad5621f/src/correctPath.js> + * Taken from: + * https://github.com/streamich/fs-monkeys + */ + +const isWin = process.platform === 'win32'; + +/*! + * removeTrailingSeparator <https://github.com/darsain/remove-trailing-separator> + * + * Inlined from: + * Copyright (c) darsain. + * Released under the ISC License. + */ +function removeTrailingSeparator(str) { +	let i = str.length - 1; +	if (i < 2) { +		return str; +	} +	while (isSeparator(str, i)) { +		i--; +	} +	return str.substr(0, i + 1); +} + +function isSeparator(str, i) { +    let char = str[i]; +    return i > 0 && (char === '/' || (isWin && char === '\\')); +} + +/*! + * normalize-path <https://github.com/jonschlinkert/normalize-path> + * + * Inlined from: + * Copyright (c) 2014-2017, Jon Schlinkert. + * Released under the MIT License. + */ +function normalizePath(str, stripTrailing) { +  if (typeof str !== 'string') { +    throw new TypeError('expected a string'); +  } +  str = str.replace(/[\\\/]+/g, '/'); +  if (stripTrailing !== false) { +    str = removeTrailingSeparator(str); +  } +  return str; +} + +/*! + * unixify <https://github.com/jonschlinkert/unixify> + *  + * Inlined from: + * Copyright (c) 2014, 2017, Jon Schlinkert. + * Released under the MIT License. + */ +export function unixify(filepath, stripTrailing = true) { +  if(isWin) { +    filepath = normalizePath(filepath, stripTrailing); +    return filepath.replace(/^([a-zA-Z]+:|\.\/)/, ''); +  } +  return filepath; +} + +/* +* Corrects a windows path to unix format (including \\?\c:...) +*/ +export function correctPath(filepath) { +    return unixify(filepath.replace(/^\\\\\?\\.:\\/,'\\')); +}
\ No newline at end of file diff --git a/packages/astro/test/units/dev/dev.test.js b/packages/astro/test/units/dev/dev.test.js new file mode 100644 index 000000000..4b9f6382a --- /dev/null +++ b/packages/astro/test/units/dev/dev.test.js @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; + +import { 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', () => { +	it('can render requests', async () => { +		 +		const fs = createFs({ +			'/src/pages/index.astro': ` +				--- +				const name = 'Testing'; +				--- +				<html> +					<head><title>{name}</title></head> +					<body> +						<h1>{name}</h1> +					</body> +				</html> +			` +		}, root); + +		await runInContainer({ fs, root }, async container => { +			const { req, res, text } = createRequestAndResponse({ +				method: 'GET', +				url: '/' +			}); +			container.handle(req, res); +			const html = await text(); +			const $ = cheerio.load(html); +			expect(res.statusCode).to.equal(200); +			expect($('h1')).to.have.a.lengthOf(1); +		}); +	}); +}); diff --git a/packages/astro/test/units/test-utils.js b/packages/astro/test/units/test-utils.js new file mode 100644 index 000000000..0f9567868 --- /dev/null +++ b/packages/astro/test/units/test-utils.js @@ -0,0 +1,87 @@ +import httpMocks from 'node-mocks-http'; +import { EventEmitter } from 'events'; +import { Volume } from 'memfs'; +import { fileURLToPath } from 'url'; +import npath from 'path'; +import { unixify } from './correct-path.js'; + +class MyVolume extends Volume { +	existsSync(p) { +		if(p instanceof URL) { +			p = fileURLToPath(p); +		} +		return super.existsSync(p); +	} +} + +export function createFs(json, root) { +	if(typeof root !== 'string') { +		root = unixify(fileURLToPath(root)); +	} + +	const structure = {}; +	for(const [key, value] of Object.entries(json)) { +		const fullpath = npath.posix.join(root, key); +		structure[fullpath] = value; +	} + +	const fs = new MyVolume(); +	fs.fromJSON(structure); +	return fs; +} + +export function createRequestAndResponse(reqOptions = {}) { +	const req = httpMocks.createRequest(reqOptions); + +	const res = httpMocks.createResponse({ +		eventEmitter: EventEmitter, +		req, +	}); + +	// When the response is complete. +	const done = toPromise(res); + +	// Get the response as text +	const text = async () => { +		let chunks = await done; +		return buffersToString(chunks); +	}; + +	// Get the response as json +	const json = async () => { +		const raw = await text(); +		return JSON.parse(raw); +	}; + +	return { req, res, done, json, text }; +} + +export function toPromise(res) { +	return new Promise((resolve) => { +		// node-mocks-http doesn't correctly handle non-Buffer typed arrays, +		// so override the write method to fix it. +		const write = res.write; +		res.write = function(data, encoding) { +			if(ArrayBuffer.isView(data) && !Buffer.isBuffer(data)) { +				data = Buffer.from(data); +			} +			return write.call(this, data, encoding); +		}; +		res.on('end', () => { +			let chunks = res._getChunks(); +			resolve(chunks); +		}); +	}); +} + +export function buffersToString(buffers) { +	let decoder = new TextDecoder(); +	let str = ''; +	for(const buffer of buffers) { +		str += decoder.decode(buffer); +	} +	return str; +} + +// A convenience method for creating an astro module from a component +export const createAstroModule = (AstroComponent) => ({ default: AstroComponent }); diff --git a/packages/astro/test/units/vite-plugin-astro-server/controller.test.js b/packages/astro/test/units/vite-plugin-astro-server/controller.test.js new file mode 100644 index 000000000..5f8f3e869 --- /dev/null +++ b/packages/astro/test/units/vite-plugin-astro-server/controller.test.js @@ -0,0 +1,131 @@ +import { expect } from 'chai'; +import { createLoader } from '../../../dist/core/module-loader/index.js'; +import { createController, runWithErrorHandling } from '../../../dist/vite-plugin-astro-server/index.js'; + +describe('vite-plugin-astro-server', () => { +	describe('controller', () => { +		it('calls the onError method when an error occurs in the handler', async () => { +			const controller = createController({ loader: createLoader() }); +			let error = undefined; +			await runWithErrorHandling({ +				controller, +				pathname: '/', +				run() { +					throw new Error('oh no'); +				}, +				onError(err) { +					error = err; +				} +			}); +			expect(error).to.not.be.an('undefined'); +			expect(error).to.be.an.instanceOf(Error); +		}); + +		it('sets the state to error when an error occurs in the handler', async () => { +			const controller = createController({ loader: createLoader() }); +			await runWithErrorHandling({ +				controller, +				pathname: '/', +				run() { +					throw new Error('oh no'); +				}, +				onError(){} +			}); +			expect(controller.state.state).to.equal('error'); +		}); + +		it('calls reload when a file change occurs when in an error state', async () => { +			let reloads = 0; +			const loader = createLoader({ +				eachModule() {}, +				clientReload() { +					reloads++; +				} +			}); +			const controller = createController({ loader }); +			loader.events.emit('file-change'); +			expect(reloads).to.equal(0); +			await runWithErrorHandling({ +				controller, +				pathname: '/', +				run() { +					throw new Error('oh no'); +				}, +				onError(){} +			}); +			expect(reloads).to.equal(0); +			loader.events.emit('file-change'); +			expect(reloads).to.equal(1); +		}); + +		it('does not call reload on file change if not in an error state', async () => { +			let reloads = 0; +			const loader = createLoader({ +				eachModule() {}, +				clientReload() { +					reloads++; +				} +			}); +			const controller = createController({ loader }); +			loader.events.emit('file-change'); +			expect(reloads).to.equal(0); +			await runWithErrorHandling({ +				controller, +				pathname: '/', +				run() { +					throw new Error('oh no'); +				}, +				onError(){} +			}); +			expect(reloads).to.equal(0); +			loader.events.emit('file-change'); +			expect(reloads).to.equal(1); +			loader.events.emit('file-change'); +			expect(reloads).to.equal(2); + +			await runWithErrorHandling({ +				controller, +				pathname: '/', +				// No error here +				run() {} +			}); +			loader.events.emit('file-change'); +			expect(reloads).to.equal(2); +		}); + +		it('Invalidates broken modules when a change occurs in an error state', async () => { +			const mods = [ +				{ id: 'one', ssrError: new Error('one') }, +				{ id: 'two', ssrError: null }, +				{ id: 'three', ssrError: new Error('three') }, +			]; + +			const loader = createLoader({ +				eachModule(cb) { +					return mods.forEach(cb); +				}, +				invalidateModule(mod) { +					mod.ssrError = null; +				} +			}); +			const controller = createController({ loader }); + +			await runWithErrorHandling({ +				controller, +				pathname: '/', +				run() { +					throw new Error('oh no'); +				}, +				onError(){} +			}); +			 +			loader.events.emit('file-change'); + +			expect(mods).to.deep.equal([ +				{ id: 'one', ssrError: null }, +				{ id: 'two', ssrError: null }, +				{ id: 'three', ssrError: null }, +			]); +		}); +	}); +}); diff --git a/packages/astro/test/units/vite-plugin-astro-server/request.test.js b/packages/astro/test/units/vite-plugin-astro-server/request.test.js new file mode 100644 index 000000000..8ec5d402b --- /dev/null +++ b/packages/astro/test/units/vite-plugin-astro-server/request.test.js @@ -0,0 +1,63 @@ +import { expect } from 'chai'; + +import { createLoader } from '../../../dist/core/module-loader/index.js'; +import { createController, handleRequest } from '../../../dist/vite-plugin-astro-server/index.js'; +import { createDefaultDevSettings } from '../../../dist/core/config/index.js'; +import { createBasicEnvironment } from '../../../dist/core/render/index.js'; +import { createRouteManifest } from '../../../dist/core/routing/index.js'; +import { defaultLogging as logging } from '../../test-utils.js'; +import { createComponent, render } from '../../../dist/runtime/server/index.js'; +import { createRequestAndResponse, createFs, createAstroModule } from '../test-utils.js'; + +async function createDevEnvironment(overrides = {}) { +	const env = createBasicEnvironment({ +		logging, +		renderers: [] +	}); +	env.settings = await createDefaultDevSettings({}, '/'); +	env.settings.renderers = []; +	env.loader = createLoader(); +	Object.assign(env, overrides); +	return env; +} + +describe('vite-plugin-astro-server', () => { +	describe('request', () => { +		it('renders a request', async () => { +			const env = await createDevEnvironment({ +				loader: createLoader({ +					import(id) { +						const Page = createComponent(() => { +							return render`<div id="test">testing</div>`; +						}); +						return createAstroModule(Page); +					} +				}) +			}); +			const controller = createController({ loader: env.loader }); +			const { req, res, text } = createRequestAndResponse(); +			const fs = createFs({ +				// Note that the content doesn't matter here because we are using a custom loader. +				'/src/pages/index.astro': '' +			}, '/'); +			const manifest = createRouteManifest({ +				fsMod: fs, +				settings: env.settings +			}, logging); + +			try { +				await handleRequest( +					env, +					manifest, +					controller, +					req, +					res +				); +				const html = await text(); +				expect(html).to.include('<div id="test">'); +			} catch(err) { +				expect(err).to.be.undefined(); +			} +		}); +	}); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42ccf88dd..fa4760019 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -422,9 +422,11 @@ importers:        import-meta-resolve: ^2.1.0        kleur: ^4.1.4        magic-string: ^0.25.9 +      memfs: ^3.4.7        mime: ^3.0.0        mocha: ^9.2.2        node-fetch: ^3.2.5 +      node-mocks-http: ^1.11.0        ora: ^6.1.0        path-browserify: ^1.0.1        path-to-regexp: ^6.2.1 @@ -547,8 +549,10 @@ importers:        astro-scripts: link:../../scripts        chai: 4.3.6        cheerio: 1.0.0-rc.12 +      memfs: 3.4.7        mocha: 9.2.2        node-fetch: 3.2.10 +      node-mocks-http: 1.11.0        rehype-autolink-headings: 6.1.1        rehype-slug: 5.1.0        rehype-toc: 3.0.2 @@ -12761,6 +12765,10 @@ packages:        minipass: 3.3.4      dev: false +  /fs-monkey/1.0.3: +    resolution: {integrity: sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==} +    dev: true +    /fs.realpath/1.0.0:      resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -14270,6 +14278,13 @@ packages:      engines: {node: '>= 0.6'}      dev: true +  /memfs/3.4.7: +    resolution: {integrity: sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw==} +    engines: {node: '>= 4.0.0'} +    dependencies: +      fs-monkey: 1.0.3 +    dev: true +    /meow/6.1.1:      resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==}      engines: {node: '>=8'} | 
