From c77a6cbe345facbf72c453e2fddc00f20c98983f Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 1 Nov 2022 08:57:23 -0400 Subject: [PATCH] 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 --- .changeset/thin-trains-run.md | 7 + packages/astro/package.json | 4 +- packages/astro/src/@types/typed-emitter.ts | 49 ++ packages/astro/src/core/config/config.ts | 20 +- packages/astro/src/core/config/index.ts | 3 +- packages/astro/src/core/config/settings.ts | 35 +- packages/astro/src/core/create-vite.ts | 10 +- packages/astro/src/core/dev/container.ts | 124 +++++ packages/astro/src/core/dev/dev.ts | 74 +++ packages/astro/src/core/dev/index.ts | 120 +---- packages/astro/src/core/errors/dev/vite.ts | 8 +- .../astro/src/core/module-loader/index.ts | 14 + .../astro/src/core/module-loader/loader.ts | 71 +++ packages/astro/src/core/module-loader/vite.ts | 67 +++ packages/astro/src/core/render/dev/css.ts | 8 +- .../astro/src/core/render/dev/environment.ts | 10 +- packages/astro/src/core/render/dev/index.ts | 36 +- packages/astro/src/core/render/dev/resolve.ts | 6 +- packages/astro/src/core/render/dev/scripts.ts | 12 +- packages/astro/src/core/render/dev/vite.ts | 19 +- .../astro/src/core/routing/manifest/create.ts | 22 +- packages/astro/src/core/util.ts | 20 +- .../src/vite-plugin-astro-server/base.ts | 46 ++ .../src/vite-plugin-astro-server/common.ts | 6 + .../vite-plugin-astro-server/controller.ts | 100 ++++ .../src/vite-plugin-astro-server/index.ts | 449 +----------------- .../src/vite-plugin-astro-server/plugin.ts | 66 +++ .../src/vite-plugin-astro-server/request.ts | 78 +++ .../src/vite-plugin-astro-server/response.ts | 106 +++++ .../src/vite-plugin-astro-server/route.ts | 185 ++++++++ .../vite-plugin-astro-server/server-state.ts | 52 ++ .../src/vite-plugin-load-fallback/index.ts | 38 ++ packages/astro/test/test-utils.js | 2 +- packages/astro/test/units/correct-path.js | 70 +++ packages/astro/test/units/dev/dev.test.js | 38 ++ packages/astro/test/units/test-utils.js | 87 ++++ .../controller.test.js | 131 +++++ .../vite-plugin-astro-server/request.test.js | 63 +++ pnpm-lock.yaml | 15 + 39 files changed, 1629 insertions(+), 642 deletions(-) create mode 100644 .changeset/thin-trains-run.md create mode 100644 packages/astro/src/@types/typed-emitter.ts create mode 100644 packages/astro/src/core/dev/container.ts create mode 100644 packages/astro/src/core/dev/dev.ts create mode 100644 packages/astro/src/core/module-loader/index.ts create mode 100644 packages/astro/src/core/module-loader/loader.ts create mode 100644 packages/astro/src/core/module-loader/vite.ts create mode 100644 packages/astro/src/vite-plugin-astro-server/base.ts create mode 100644 packages/astro/src/vite-plugin-astro-server/common.ts create mode 100644 packages/astro/src/vite-plugin-astro-server/controller.ts create mode 100644 packages/astro/src/vite-plugin-astro-server/plugin.ts create mode 100644 packages/astro/src/vite-plugin-astro-server/request.ts create mode 100644 packages/astro/src/vite-plugin-astro-server/response.ts create mode 100644 packages/astro/src/vite-plugin-astro-server/route.ts create mode 100644 packages/astro/src/vite-plugin-astro-server/server-state.ts create mode 100644 packages/astro/src/vite-plugin-load-fallback/index.ts create mode 100644 packages/astro/test/units/correct-path.js create mode 100644 packages/astro/test/units/dev/dev.test.js create mode 100644 packages/astro/test/units/test-utils.js create mode 100644 packages/astro/test/units/vite-plugin-astro-server/controller.test.js create mode 100644 packages/astro/test/units/vite-plugin-astro-server/request.test.js 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; + * + * myEmitter.emit("error", "x") // <- Will catch this type error; + * ``` + */ +interface TypedEventEmitter { + addListener (event: E, listener: Events[E]): this + on (event: E, listener: Events[E]): this + once (event: E, listener: Events[E]): this + prependListener (event: E, listener: Events[E]): this + prependOnceListener (event: E, listener: Events[E]): this + + off(event: E, listener: Events[E]): this + removeAllListeners (event?: E): this + removeListener (event: E, listener: Events[E]): this + + emit (event: E, ...args: Parameters): boolean + // The sloppy `eventNames()` return type is to mitigate type incompatibilities - see #5 + eventNames (): (keyof Events | string | symbol)[] + rawListeners (event: E): Events[E][] + listeners (event: E): Events[E][] + listenerCount (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 { const fileProtocolRoot = pathToFileURL(root + path.sep); // Manual deprecation checks @@ -195,8 +194,7 @@ export async function openConfig(configOptions: LoadConfigOptions): Promise { 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, overrides: Record, 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 { + 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 { 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; +} + +export interface CreateContainerParams { + isRestart?: boolean; + logging?: LogOptions; + userConfig?: AstroUserConfig; + settings?: AstroSettings; + fs?: typeof nodeFs; + root?: string | URL; +} + +export async function createContainer(params: CreateContainerParams = {}): Promise { + 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 { + 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) { + 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; +} + +/** `astro dev` */ +export default async function dev( + settings: AstroSettings, + options: DevOptions +): Promise { + 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 { + 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; -} - -/** `astro dev` */ -export default async function dev( - settings: AstroSettings, - options: DevOptions -): Promise { - 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'; 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; + +export interface ModuleLoader { + import: (src: string) => Promise>; + resolveId: (specifier: string, parentId: string | undefined) => Promise; + getModuleById: (id: string) => ModuleNode | undefined; + getModulesByFile: (file: string) => Set | 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; +} + +export interface ModuleNode { + id: string | null; + url: string; + ssrModule: Record | null; + ssrError: Error | null; + importedModules: Set; +} + +export interface ModuleInfo { + id: string; + meta?: Record; +} + +export function createLoader(overrides: Partial): 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; stylesMap: Map }> { const importedCssUrls = new Set(); const importedStylesMap = new Map(); - 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 { - 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): Promise { // 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(); [...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> { const elements = new Set(); 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() -): AsyncGenerator { +): AsyncGenerator { const id = unwrapId(_id); - const importedModules = new Set(); + const importedModules = new Set(); 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 = 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; + 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 Promise> = T extends ( - ...args: any -) => Promise - ? 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; - }; - - 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(``); - res.end(); - } else { - writeHtmlResponse( - res, - 500, - `${err.name}` - ); - } -} - -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, - url: URL, - pathname: string, - body: ArrayBuffer | undefined, - origin: string, - env: DevelopmentEnvironment, - manifest: ManifestData, - req: http.IncomingMessage, - res: http.ServerResponse -): Promise { - 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(``); + res.end(); + } else { + writeHtmlResponse( + res, + 500, + `${err.name}` + ); + } +} + +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; + }; + + 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 Promise> = T extends ( + ...args: any +) => Promise + ? 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, + url: URL, + pathname: string, + body: ArrayBuffer | undefined, + origin: string, + env: DevelopmentEnvironment, + manifest: ManifestData, + req: http.IncomingMessage, + res: http.ServerResponse +): Promise { + 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; + 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 + * Taken from: + * https://github.com/streamich/fs-monkeys + */ + +const isWin = process.platform === 'win32'; + +/*! + * removeTrailingSeparator + * + * 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 + * + * 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 + * + * 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'; + --- + + {name} + +

{name}

+ + + ` + }, 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`
testing
`; + }); + 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('
'); + } 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'}