Graceful error recovery in the dev server (#5198)

* Graceful error recovery in the dev server

Move dev-container to dev

Update for the lockfile

Invalidate modules in an error state

Test invalidation of broken modules

Remove unused error state

Normalize for windows

try a larger timeout

Fixes build

just for testing

more testing

Keep it posix

fully posix

* Fix up Windows path for testing

* some debugging

* use posix join

* finally fixed

* Remove leftover debugging

* Reset the timeout

* Adding a changeset
This commit is contained in:
Matthew Phillips 2022-11-01 08:57:23 -04:00 committed by GitHub
parent 06c5d51b37
commit c77a6cbe34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1629 additions and 642 deletions

View file

@ -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.

View file

@ -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",

View file

@ -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

View file

@ -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>,

View file

@ -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';

View file

@ -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);
}

View file

@ -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),

View file

@ -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();
}
}

View file

@ -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 });
},
};
}

View file

@ -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 {
createContainer,
startContainer,
runInContainer
} 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();
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 {
default
} from './dev.js';

View file

@ -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 {}
}

View file

@ -0,0 +1,14 @@
export type {
ModuleInfo,
ModuleLoader,
ModuleNode,
LoaderEvents
} from './loader.js';
export {
createLoader
} from './loader.js';
export {
createViteLoader
} from './vite.js';

View file

@ -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
};
}

View file

@ -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);
};
}

View file

@ -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 Vites 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

View file

@ -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,
};
}

View file

@ -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 cant 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({

View file

@ -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')) {

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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) {

View file

@ -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();
};
}

View file

@ -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 }));
}

View file

@ -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);
}
}

View file

@ -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 dont 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';

View file

@ -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]');
},
};
}

View file

@ -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;
}
});
}

View file

@ -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);
}

View file

@ -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 dont 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);
}
}

View file

@ -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;
}

View file

@ -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, '');

View file

@ -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

View file

@ -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(/^\\\\\?\\.:\\/,'\\'));
}

View file

@ -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);
});
});
});

View file

@ -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 });

View file

@ -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 },
]);
});
});
});

View file

@ -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();
}
});
});
});

View file

@ -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'}