summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/thin-trains-run.md7
-rw-r--r--packages/astro/package.json4
-rw-r--r--packages/astro/src/@types/typed-emitter.ts49
-rw-r--r--packages/astro/src/core/config/config.ts20
-rw-r--r--packages/astro/src/core/config/index.ts3
-rw-r--r--packages/astro/src/core/config/settings.ts35
-rw-r--r--packages/astro/src/core/create-vite.ts10
-rw-r--r--packages/astro/src/core/dev/container.ts124
-rw-r--r--packages/astro/src/core/dev/dev.ts74
-rw-r--r--packages/astro/src/core/dev/index.ts122
-rw-r--r--packages/astro/src/core/errors/dev/vite.ts8
-rw-r--r--packages/astro/src/core/module-loader/index.ts14
-rw-r--r--packages/astro/src/core/module-loader/loader.ts71
-rw-r--r--packages/astro/src/core/module-loader/vite.ts67
-rw-r--r--packages/astro/src/core/render/dev/css.ts8
-rw-r--r--packages/astro/src/core/render/dev/environment.ts10
-rw-r--r--packages/astro/src/core/render/dev/index.ts36
-rw-r--r--packages/astro/src/core/render/dev/resolve.ts6
-rw-r--r--packages/astro/src/core/render/dev/scripts.ts12
-rw-r--r--packages/astro/src/core/render/dev/vite.ts19
-rw-r--r--packages/astro/src/core/routing/manifest/create.ts22
-rw-r--r--packages/astro/src/core/util.ts20
-rw-r--r--packages/astro/src/vite-plugin-astro-server/base.ts46
-rw-r--r--packages/astro/src/vite-plugin-astro-server/common.ts6
-rw-r--r--packages/astro/src/vite-plugin-astro-server/controller.ts100
-rw-r--r--packages/astro/src/vite-plugin-astro-server/index.ts449
-rw-r--r--packages/astro/src/vite-plugin-astro-server/plugin.ts66
-rw-r--r--packages/astro/src/vite-plugin-astro-server/request.ts78
-rw-r--r--packages/astro/src/vite-plugin-astro-server/response.ts106
-rw-r--r--packages/astro/src/vite-plugin-astro-server/route.ts185
-rw-r--r--packages/astro/src/vite-plugin-astro-server/server-state.ts52
-rw-r--r--packages/astro/src/vite-plugin-load-fallback/index.ts38
-rw-r--r--packages/astro/test/test-utils.js2
-rw-r--r--packages/astro/test/units/correct-path.js70
-rw-r--r--packages/astro/test/units/dev/dev.test.js38
-rw-r--r--packages/astro/test/units/test-utils.js87
-rw-r--r--packages/astro/test/units/vite-plugin-astro-server/controller.test.js131
-rw-r--r--packages/astro/test/units/vite-plugin-astro-server/request.test.js63
-rw-r--r--pnpm-lock.yaml15
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'}