diff --git a/.changeset/empty-snails-allow.md b/.changeset/empty-snails-allow.md new file mode 100644 index 000000000000..261629367b57 --- /dev/null +++ b/.changeset/empty-snails-allow.md @@ -0,0 +1,7 @@ +--- +'astro': minor +--- + +Refactor dev server to use vite server internally. + +This should be an invisible change, and no breaking changes are expected from this change. However, it is a big enough refactor that some unexpected changes may occur. If you've experienced a regression in the dev server, it is most likely a bug! diff --git a/LICENSE b/LICENSE index 417b4ded941d..53be9db537f4 100644 --- a/LICENSE +++ b/LICENSE @@ -37,8 +37,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI """ -This license applies to parts of the `packages/create-astro` subdirectory originating from the -https://github.com/sveltejs/kit repository: +This license applies to parts of the `packages/create-astro` and `packages/astro` subdirectories originating from the https://github.com/sveltejs/kit repository: Copyright (c) 2020 [these people](https://github.com/sveltejs/kit/graphs/contributors) diff --git a/packages/astro/package.json b/packages/astro/package.json index a8d5deb45a78..bd21e98d183d 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -73,7 +73,6 @@ "astring": "^1.7.5", "ci-info": "^3.2.0", "common-ancestor-path": "^1.0.1", - "connect": "^3.7.0", "eol": "^0.9.1", "es-module-lexer": "^0.9.3", "esbuild": "0.13.7", diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index e764e96b7df2..ae8b0ac560de 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import type { AstroConfig } from '../@types/astro'; -import type { LogOptions } from '../core/logger'; +import type { LogOptions } from '../core/logger.js'; import * as colors from 'kleur/colors'; import fs from 'fs'; diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index c8b504ad6d03..61eb5edc7283 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -1,11 +1,11 @@ import type { AstroConfig } from '../@types/astro'; -import type { AstroDevServer } from './dev'; import type { LogOptions } from './logger'; import { builtinModules } from 'module'; import { fileURLToPath } from 'url'; import vite from './vite.js'; import astroVitePlugin from '../vite-plugin-astro/index.js'; +import astroViteServerPlugin from '../vite-plugin-astro-server/index.js'; import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.js'; import configAliasVitePlugin from '../vite-plugin-config-alias/index.js'; import markdownVitePlugin from '../vite-plugin-markdown/index.js'; @@ -34,12 +34,11 @@ export type ViteConfigWithSSR = vite.InlineConfig & { ssr?: { external?: string[ interface CreateViteOptions { astroConfig: AstroConfig; - devServer?: AstroDevServer; logging: LogOptions; } /** Return a common starting point for all Vite actions */ -export async function createVite(inlineConfig: ViteConfigWithSSR, { astroConfig, logging, devServer }: CreateViteOptions): Promise { +export async function createVite(inlineConfig: ViteConfigWithSSR, { astroConfig, logging }: CreateViteOptions): Promise { // First, start with the Vite configuration that Astro core needs let viteConfig: ViteConfigWithSSR = { cacheDir: fileURLToPath(new URL('./node_modules/.vite/', astroConfig.projectRoot)), // using local caches allows Astro to be used in monorepos, etc. @@ -50,10 +49,11 @@ export async function createVite(inlineConfig: ViteConfigWithSSR, { astroConfig, }, plugins: [ configAliasVitePlugin({ config: astroConfig }), - astroVitePlugin({ config: astroConfig, devServer, logging }), - markdownVitePlugin({ config: astroConfig, devServer }), + astroVitePlugin({ config: astroConfig, logging }), + astroViteServerPlugin({ config: astroConfig, logging }), + markdownVitePlugin({ config: astroConfig }), jsxVitePlugin({ config: astroConfig, logging }), - astroPostprocessVitePlugin({ config: astroConfig, devServer }), + astroPostprocessVitePlugin({ config: astroConfig }), ], publicDir: fileURLToPath(astroConfig.public), root: fileURLToPath(astroConfig.projectRoot), diff --git a/packages/astro/src/core/dev/index.ts b/packages/astro/src/core/dev/index.ts index 6fb0e3e3e1f6..ae1b579979af 100644 --- a/packages/astro/src/core/dev/index.ts +++ b/packages/astro/src/core/dev/index.ts @@ -1,398 +1,41 @@ -import type { NextFunction } from 'connect'; -import type http from 'http'; -import type { AstroConfig, ManifestData, RouteCache, RouteData } from '../../@types/astro'; -import type { LogOptions } from '../logger'; -import type { HmrContext, ModuleNode } from '../vite'; - -import path from 'path'; -import { fileURLToPath } from 'url'; -import { promisify } from 'util'; -import connect from 'connect'; -import mime from 'mime'; import { polyfill } from '@astropub/webapi'; +import type { AddressInfo } from 'net'; import { performance } from 'perf_hooks'; -import stripAnsi from 'strip-ansi'; -import vite from '../vite.js'; -import { defaultLogOptions, error, info } from '../logger.js'; -import { ssr } from '../ssr/index.js'; -import { STYLE_EXTENSIONS } from '../ssr/css.js'; -import { collectResources } from '../ssr/html.js'; -import { createRouteManifest, matchRoute } from '../ssr/routing.js'; +import type { AstroConfig } from '../../@types/astro'; import { createVite } from '../create-vite.js'; -import * as msg from './messages.js'; -import notFoundTemplate, { subpathNotUsedTemplate } from './template/4xx.js'; -import serverErrorTemplate from './template/5xx.js'; +import { defaultLogOptions, info, LogOptions } from '../logger.js'; +import vite from '../vite.js'; +import * as msg from '../messages.js'; export interface DevOptions { logging: LogOptions; } export interface DevServer { - hostname: string; - port: number; - server: connect.Server; + address: AddressInfo; stop(): Promise; } /** `astro dev` */ export default async function dev(config: AstroConfig, options: DevOptions = { logging: defaultLogOptions }): Promise { - // polyfill WebAPIs to globalThis for Node v12, Node v14, and Node v16 + const devStart = performance.now(); + // polyfill WebAPIs for Node.js runtime polyfill(globalThis, { exclude: 'window document', }); + // start the server + const viteUserConfig = vite.mergeConfig({ mode: 'development' }, config.vite || {}); + const viteConfig = await createVite(viteUserConfig, { astroConfig: config, logging: options.logging }); + const viteServer = await vite.createServer(viteConfig); + await viteServer.listen(config.devOptions.port); + const address = viteServer.httpServer!.address() as AddressInfo; + // Log to console + const site = config.buildOptions.site ? new URL(config.buildOptions.site) : undefined; + info(options.logging, 'astro', msg.devStart({ startupTime: performance.now() - devStart })); + info(options.logging, 'astro', msg.devHost({ address, site, https: !!viteUserConfig.server?.https })); - // start dev server - const server = new AstroDevServer(config, options); - await server.start(); - - // attempt shutdown - process.on('SIGTERM', () => server.stop()); return { - hostname: server.hostname, - port: server.port, - server: server.app, - stop: () => server.stop(), + address, + stop: () => viteServer.close(), }; } - -/** Dev server */ -export class AstroDevServer { - app: connect.Server = connect(); - config: AstroConfig; - devRoot: string; - hostname: string; - httpServer: http.Server | undefined; - logging: LogOptions; - manifest: ManifestData; - mostRecentRoute?: RouteData; - origin: string; - port: number; - routeCache: RouteCache = {}; - site: URL | undefined; - url: URL; - viteServer: vite.ViteDevServer | undefined; - - constructor(config: AstroConfig, options: DevOptions) { - this.config = config; - this.hostname = config.devOptions.hostname || 'localhost'; - this.logging = options.logging; - this.port = config.devOptions.port; - this.origin = `http://${this.hostname}:${this.port}`; - this.site = config.buildOptions.site ? new URL(config.buildOptions.site) : undefined; - this.devRoot = this.site ? this.site.pathname : '/'; - this.url = new URL(this.devRoot, this.origin); - this.manifest = createRouteManifest({ config }, this.logging); - } - - async start() { - const devStart = performance.now(); - - // Setup the dev server and connect it to Vite (via middleware) - this.viteServer = await this.createViteServer(); - this.app.use(this.viteServer.middlewares); - this.app.use((req, res, next) => this.handleRequest(req, res, next)); - this.app.use((req, res, next) => this.renderError(req, res, next)); - - // Listen on port (and retry if taken) - await this.listen(devStart); - } - - async stop() { - if (this.viteServer) { - await this.viteServer.close(); - } - if (this.httpServer) { - await promisify(this.httpServer.close.bind(this.httpServer))(); - } - } - - public async handleHotUpdate({ file, modules }: HmrContext): Promise { - const { viteServer } = this; - if (!viteServer) throw new Error(`AstroDevServer.start() not called`); - - for (const module of modules) { - viteServer.moduleGraph.invalidateModule(module); - } - - const route = this.mostRecentRoute; - const [pathname, search = undefined] = (route?.pathname ?? '/').split('?'); - - if (!route) { - viteServer.ws.send({ - type: 'full-reload', - }); - return []; - } - - try { - const filePath = new URL(`./${route.component}`, this.config.projectRoot); - // try to update the most recent route - const html = await ssr({ - astroConfig: this.config, - filePath, - logging: this.logging, - mode: 'development', - origin: this.origin, - pathname, - route, - routeCache: this.routeCache, - viteServer, - }); - - // collect style tags to be reloaded (needed for Tailwind HMR, etc.) - let invalidatedModules: vite.ModuleNode[] = []; - await Promise.all( - collectResources(html) - .filter(({ href }) => { - if (!href) return false; - const ext = path.extname(href); - return STYLE_EXTENSIONS.has(ext); - }) - .map(async ({ href }) => { - const viteModule = - viteServer.moduleGraph.getModuleById(`${href}?direct`) || - (await viteServer.moduleGraph.getModuleByUrl(`${href}?direct`)) || - viteServer.moduleGraph.getModuleById(href) || - (await viteServer.moduleGraph.getModuleByUrl(href)); - if (viteModule) { - invalidatedModules.push(viteModule); - viteServer.moduleGraph.invalidateModule(viteModule); - } - }) - ); - - // TODO: log update - viteServer.ws.send({ - type: 'custom', - event: 'astro:reload', - data: { html }, - }); - - for (const viteModule of invalidatedModules) { - // Note: from the time viteServer.moduleGraph.invalidateModule() is called above until now, CSS - // is building in the background. For things like Tailwind, this can take some time. If the - // CSS is still processing by the time HMR fires, we’ll end up with stale styles on the page. - // TODO: fix this hack by figuring out how to add these styles to the { modules } above - setTimeout(() => { - viteServer.ws.send({ - type: 'update', - updates: [ - { - type: viteModule.type === 'js' ? 'js-update' : 'css-update', - path: viteModule.id || viteModule.file || viteModule.url, - acceptedPath: viteModule.url, - timestamp: Date.now(), - }, - ], - }); - }, 150); - } - - return []; - } catch (e) { - const err = e as Error; - // eslint-disable-next-line - console.error(err.stack); - viteServer.ws.send({ - type: 'full-reload', - }); - return []; - } - } - - /** Expose dev server to this.port */ - public listen(devStart: number): Promise { - let showedPortTakenMsg = false; - return new Promise((resolve, reject) => { - const appListen = () => { - this.httpServer = this.app.listen(this.port, this.hostname, () => { - info(this.logging, 'astro', msg.devStart({ startupTime: performance.now() - devStart })); - info(this.logging, 'astro', msg.devHost({ host: `http://${this.hostname}:${this.port}${this.devRoot}` })); - resolve(); - }); - this.httpServer?.on('error', onError); - }; - - const onError = (err: NodeJS.ErrnoException) => { - if (err.code && err.code === 'EADDRINUSE') { - if (!showedPortTakenMsg) { - info(this.logging, 'astro', msg.portInUse({ port: this.port })); - showedPortTakenMsg = true; // only print this once - } - this.port++; - return appListen(); // retry - } else { - error(this.logging, 'astro', err.stack); - this.httpServer?.removeListener('error', onError); - reject(err); // reject - } - }; - - appListen(); - }); - } - - private async createViteServer() { - const viteConfig = await createVite( - vite.mergeConfig( - { - mode: 'development', - server: { - middlewareMode: 'ssr', - host: this.hostname, - }, - }, - this.config.vite || {} - ), - { astroConfig: this.config, logging: this.logging, devServer: this } - ); - const viteServer = await vite.createServer(viteConfig); - - const pagesDirectory = fileURLToPath(this.config.pages); - viteServer.watcher.on('add', (file) => { - // Only rebuild routes if new file is a page. - if (!file.startsWith(pagesDirectory)) { - return; - } - this.routeCache = {}; - this.manifest = createRouteManifest({ config: this.config }, this.logging); - }); - viteServer.watcher.on('unlink', (file) => { - // Only rebuild routes if deleted file is a page. - if (!file.startsWith(pagesDirectory)) { - return; - } - this.routeCache = {}; - this.manifest = createRouteManifest({ config: this.config }, this.logging); - }); - viteServer.watcher.on('change', () => { - // No need to rebuild routes on file content changes. - // However, we DO want to clear the cache in case - // the change caused a getStaticPaths() return to change. - this.routeCache = {}; - }); - - return viteServer; - } - - /** The primary router (runs before Vite, in case we need to modify or intercept anything) */ - private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction) { - if (!this.viteServer) throw new Error(`AstroDevServer.start() not called`); - - let [pathname, search = undefined] = (req.url || '/').split('?'); // original request - const reqStart = performance.now(); - let filePath: URL | undefined; - try { - let routePathname: string = pathname; - // If using a subpath, ensure that the user has included the pathname - // such as /blog in the URL. - if (this.devRoot !== '/') { - if (pathname.startsWith(this.devRoot)) { - // This includes the subpath, so strip off the subpath so that - // matchRoute finds this route. - routePathname = pathname.substr(this.devRoot.length) || ''; - if (!routePathname.startsWith('/')) { - routePathname = '/' + routePathname; - } - } else { - // Not using the subpath, so forward to Vite's middleware - next(); - return; - } - } - - const route = matchRoute(routePathname, this.manifest); - - // 404: continue to Vite - if (!route) { - // Send through, stripping off the `/blog/` part so that Vite matches it. - const newPathname = routePathname.startsWith('/') ? routePathname : '/' + routePathname; - req.url = newPathname; - next(); - return; - } - // handle .astro and .md pages - filePath = new URL(`./${route.component}`, this.config.projectRoot); - const html = await ssr({ - astroConfig: this.config, - filePath, - logging: this.logging, - mode: 'development', - origin: this.origin, - pathname: routePathname, - route, - routeCache: this.routeCache, - viteServer: this.viteServer, - }); - this.mostRecentRoute = route; - info(this.logging, 'astro', msg.req({ url: pathname, statusCode: 200, reqTime: performance.now() - reqStart })); - res.writeHead(200, { - 'Content-Type': mime.getType('.html') as string, - 'Content-Length': Buffer.byteLength(html, 'utf8'), - }); - res.write(html); - res.end(); - } catch (err: any) { - const statusCode = 500; - await this.viteServer.moduleGraph.invalidateAll(); - this.viteServer.ws.send({ type: 'error', err }); - let html = serverErrorTemplate({ - statusCode, - title: 'Internal Error', - tabTitle: '500: Error', - message: stripAnsi(err.message), - url: err.url || undefined, - stack: stripAnsi(err.stack), - }); - html = await this.viteServer.transformIndexHtml(pathname, html, pathname); - info(this.logging, 'astro', msg.req({ url: pathname, statusCode: 500, reqTime: performance.now() - reqStart })); - res.writeHead(statusCode, { - 'Content-Type': mime.getType('.html') as string, - 'Content-Length': Buffer.byteLength(html, 'utf8'), - }); - res.write(html); - res.end(); - } - } - - /** Render error page */ - private async renderError(req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction) { - if (!this.viteServer) throw new Error(`AstroDevServer.start() not called`); - - const pathname = req.url || '/'; - const reqStart = performance.now(); - let html = ''; - const statusCode = 404; - - // attempt to load user-given page - const relPages = this.config.pages.href.replace(this.config.projectRoot.href, ''); - const userDefined404 = this.manifest.routes.find((route) => route.component === relPages + '404.astro'); - if (userDefined404) { - html = await ssr({ - astroConfig: this.config, - filePath: new URL(`./${userDefined404.component}`, this.config.projectRoot), - logging: this.logging, - mode: 'development', - pathname: `/${userDefined404.component}`, - origin: this.origin, - routeCache: this.routeCache, - viteServer: this.viteServer, - }); - } - // if not found, fall back to default template - else { - if (pathname === '/' && !pathname.startsWith(this.devRoot)) { - html = subpathNotUsedTemplate(this.devRoot, pathname); - } else { - html = notFoundTemplate({ statusCode, title: 'Not found', tabTitle: '404: Not Found', pathname }); - } - } - info(this.logging, 'astro', msg.req({ url: pathname, statusCode, reqTime: performance.now() - reqStart })); - res.writeHead(statusCode, { - 'Content-Type': mime.getType('.html') as string, - 'Content-Length': Buffer.byteLength(html, 'utf8'), - }); - res.write(html); - res.end(); - } -} diff --git a/packages/astro/src/core/dev/messages.ts b/packages/astro/src/core/messages.ts similarity index 64% rename from packages/astro/src/core/dev/messages.ts rename to packages/astro/src/core/messages.ts index b7247dec3d5e..e2272bb9bbee 100644 --- a/packages/astro/src/core/dev/messages.ts +++ b/packages/astro/src/core/messages.ts @@ -2,17 +2,18 @@ * Dev server messages (organized here to prevent clutter) */ +import type { AddressInfo } from 'net'; import { bold, dim, green, magenta, yellow } from 'kleur/colors'; -import { pad } from './util.js'; +import { pad } from './dev/util.js'; /** Display */ -export function req({ url, statusCode, reqTime }: { url: string; statusCode: number; reqTime: number }): string { +export function req({ url, statusCode, reqTime }: { url: string; statusCode: number; reqTime?: number }): string { let color = dim; if (statusCode >= 500) color = magenta; else if (statusCode >= 400) color = yellow; else if (statusCode >= 300) color = dim; else if (statusCode >= 200) color = green; - return `${color(statusCode)} ${pad(url, 40)} ${dim(Math.round(reqTime) + 'ms')}`; + return `${color(statusCode)} ${pad(url, 40)} ${reqTime ? dim(Math.round(reqTime) + 'ms') : ''}`; } /** Display */ @@ -27,8 +28,10 @@ export function devStart({ startupTime }: { startupTime: number }): string { } /** Display dev server host */ -export function devHost({ host }: { host: string }): string { - return `Local: ${bold(magenta(host))}`; +export function devHost({ address, https, site }: { address: AddressInfo; https: boolean; site: URL | undefined }): string { + const rootPath = site ? site.pathname : '/'; + const displayUrl = `${https ? 'https' : 'http'}://${address.address}:${address.port}${rootPath}`; + return `Local: ${bold(magenta(displayUrl))}`; } /** Display port in use */ diff --git a/packages/astro/src/core/preview/index.ts b/packages/astro/src/core/preview/index.ts index 60296bc7308a..5dff165e27d4 100644 --- a/packages/astro/src/core/preview/index.ts +++ b/packages/astro/src/core/preview/index.ts @@ -7,9 +7,9 @@ import { performance } from 'perf_hooks'; import send from 'send'; import { fileURLToPath } from 'url'; import fs from 'fs'; -import * as msg from '../dev/messages.js'; +import * as msg from '../messages.js'; import { error, info } from '../logger.js'; -import { subpathNotUsedTemplate, notFoundTemplate, default as template } from '../dev/template/4xx.js'; +import { subpathNotUsedTemplate, notFoundTemplate, default as template } from '../../template/4xx.js'; import { appendForwardSlash, trimSlashes } from '../path.js'; interface PreviewOptions { @@ -43,7 +43,7 @@ export default async function preview(config: AstroConfig, { logging }: PreviewO }; /** Base request URL. */ - let baseURL = new URL(appendForwardSlash(config.buildOptions.site || ''), defaultOrigin); + let baseURL = new URL(config.buildOptions.site || '/', defaultOrigin); // Create the preview server, send static files out of the `dist/` directory. const server = http.createServer((req, res) => { @@ -126,7 +126,7 @@ export default async function preview(config: AstroConfig, { logging }: PreviewO httpServer = server.listen(port, hostname, () => { if (!showedListenMsg) { info(logging, 'astro', msg.devStart({ startupTime: performance.now() - timerStart })); - info(logging, 'astro', msg.devHost({ host: `http://${hostname}:${port}${baseURL.pathname}` })); + info(logging, 'astro', msg.devHost({ address: { family: 'ipv4', address: hostname, port }, https: false, site: baseURL })); } showedListenMsg = true; resolve(); diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index 8f7939abd179..58c7fc9d0088 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -48,6 +48,11 @@ export function parseNpmName(spec: string): { scope?: string; name: string; subp }; } +/** Coalesce any throw variable to an Error instance. */ +export function createSafeError(err: any): Error { + return err instanceof Error || (err && err.name && err.message) ? err : new Error(JSON.stringify(err)); +} + /** generate code frame from esbuild error */ export function codeFrame(src: string, loc: ErrorPayload['err']['loc']): string { if (!loc) return ''; diff --git a/packages/astro/src/core/dev/template/4xx.ts b/packages/astro/src/template/4xx.ts similarity index 100% rename from packages/astro/src/core/dev/template/4xx.ts rename to packages/astro/src/template/4xx.ts diff --git a/packages/astro/src/core/dev/template/5xx.ts b/packages/astro/src/template/5xx.ts similarity index 100% rename from packages/astro/src/core/dev/template/5xx.ts rename to packages/astro/src/template/5xx.ts diff --git a/packages/astro/src/core/dev/template/css.ts b/packages/astro/src/template/css.ts similarity index 100% rename from packages/astro/src/core/dev/template/css.ts rename to packages/astro/src/template/css.ts diff --git a/packages/astro/src/vite-plugin-astro-postprocess/index.ts b/packages/astro/src/vite-plugin-astro-postprocess/index.ts index 96087966f169..807e68c70d48 100644 --- a/packages/astro/src/vite-plugin-astro-postprocess/index.ts +++ b/packages/astro/src/vite-plugin-astro-postprocess/index.ts @@ -1,22 +1,20 @@ import type * as t from '@babel/types'; import type { Plugin } from '../core/vite'; import type { AstroConfig } from '../@types/astro'; -import type { AstroDevServer } from '../core/dev/index'; import * as babelTraverse from '@babel/traverse'; import * as babel from '@babel/core'; interface AstroPluginOptions { config: AstroConfig; - devServer?: AstroDevServer; } // esbuild transforms the component-scoped Astro into Astro2, so need to check both. const validAstroGlobalNames = new Set(['Astro', 'Astro2']); -export default function astro({ config, devServer }: AstroPluginOptions): Plugin { +export default function astro({ config }: AstroPluginOptions): Plugin { return { - name: '@astrojs/vite-plugin-astro-postprocess', + name: 'astro:postprocess', async transform(code, id) { // Currently only supported in ".astro" & ".md" files if (!id.endsWith('.astro') && !id.endsWith('.md')) { diff --git a/packages/astro/src/vite-plugin-astro-server/index.ts b/packages/astro/src/vite-plugin-astro-server/index.ts new file mode 100644 index 000000000000..701457f9376d --- /dev/null +++ b/packages/astro/src/vite-plugin-astro-server/index.ts @@ -0,0 +1,156 @@ +import type vite from '../core/vite'; +import type http from 'http'; +import type { AstroConfig, ManifestData, RouteCache, RouteData } from '../@types/astro'; +import { info, LogOptions } from '../core/logger.js'; +import { fileURLToPath } from 'url'; +import { createRouteManifest, matchRoute } from '../core/ssr/routing.js'; +import mime from 'mime'; +import stripAnsi from 'strip-ansi'; +import { createSafeError } from '../core/util.js'; +import { ssr } from '../core/ssr/index.js'; +import * as msg from '../core/messages.js'; + +import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js'; +import serverErrorTemplate from '../template/5xx.js'; + +interface AstroPluginOptions { + config: AstroConfig; + logging: LogOptions; +} + +const BAD_VITE_MIDDLEWARE = ['viteIndexHtmlMiddleware', 'vite404Middleware', 'viteSpaFallbackMiddleware']; +function removeViteHttpMiddleware(server: vite.Connect.Server) { + for (let i = server.stack.length - 1; i > 0; i--) { + // @ts-expect-error using internals until https://github.com/vitejs/vite/pull/4640 is merged + if (BAD_VITE_MIDDLEWARE.includes(server.stack[i].handle.name)) { + server.stack.splice(i, 1); + } + } +} + +function writeHtmlResponse(res: http.ServerResponse, statusCode: number, html: string) { + res.writeHead(statusCode, { + 'Content-Type': mime.getType('.html') as string, + 'Content-Length': Buffer.byteLength(html, 'utf8'), + }); + res.write(html); + res.end(); +} + +async function handle404Response(origin: string, config: AstroConfig, req: http.IncomingMessage, res: http.ServerResponse) { + const site = config.buildOptions.site ? new URL(config.buildOptions.site) : undefined; + const devRoot = site ? site.pathname : '/'; + const pathname = decodeURI(new URL(origin + req.url).pathname); + let html = ''; + if (pathname === '/' && !pathname.startsWith(devRoot)) { + html = subpathNotUsedTemplate(devRoot, pathname); + } else { + 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: any) { + const pathname = decodeURI(new URL(origin + req.url).pathname); + const html = serverErrorTemplate({ + statusCode: 500, + title: 'Internal Error', + tabTitle: '500: Error', + message: stripAnsi(err.message), + url: err.url || undefined, + stack: stripAnsi(err.stack), + }); + const transformedHtml = await viteServer.transformIndexHtml(pathname, html, pathname); + writeHtmlResponse(res, 500, transformedHtml); +} + +/** The main logic to route dev server requests to pages in Astro. */ +async function handleRequest( + routeCache: RouteCache, + viteServer: vite.ViteDevServer, + logging: LogOptions, + manifest: ManifestData, + config: AstroConfig, + req: http.IncomingMessage, + res: http.ServerResponse +) { + const reqStart = performance.now(); + const site = config.buildOptions.site ? new URL(config.buildOptions.site) : undefined; + const devRoot = site ? site.pathname : '/'; + const origin = `${viteServer.config.server.https ? 'https' : 'http'}://${req.headers.host}`; + const pathname = decodeURI(new URL(origin + req.url).pathname); + const rootRelativeUrl = pathname.substring(devRoot.length - 1); + try { + if (!pathname.startsWith(devRoot)) { + info(logging, 'astro', msg.req({ url: pathname, statusCode: 404 })); + return handle404Response(origin, config, req, res); + } + // Attempt to match the URL to a valid page route. + // If that fails, switch the response to a 404 response. + let route = matchRoute(rootRelativeUrl, manifest); + const statusCode = route ? 200 : 404; + // If no match found, lookup a custom 404 page to render, if one exists. + if (!route) { + const relPages = config.pages.href.replace(config.projectRoot.href, ''); + route = manifest.routes.find((r) => r.component === relPages + '404.astro'); + } + // If still no match is found, respond with a generic 404 page. + if (!route) { + info(logging, 'astro', msg.req({ url: pathname, statusCode: 404 })); + handle404Response(origin, config, req, res); + return; + } + // Route successfully matched! Render it. + const html = await ssr({ + astroConfig: config, + filePath: new URL(`./${route.component}`, config.projectRoot), + logging, + mode: 'development', + origin, + pathname: rootRelativeUrl, + route, + routeCache: routeCache, + viteServer: viteServer, + }); + info(logging, 'astro', msg.req({ url: pathname, statusCode, reqTime: performance.now() - reqStart })); + writeHtmlResponse(res, statusCode, html); + } catch (_err: any) { + info(logging, 'astro', msg.req({ url: pathname, statusCode: 500 })); + const err = createSafeError(_err); + handle500Response(viteServer, origin, req, res, err); + } +} + +export default function createPlugin({ config, logging }: AstroPluginOptions): vite.Plugin { + return { + name: 'astro:server', + configureServer(viteServer) { + const pagesDirectory = fileURLToPath(config.pages); + let routeCache: RouteCache = {}; + let manifest: ManifestData = createRouteManifest({ config: config }, logging); + /** rebuild the route cache + manifest if the changed file impacts routing. */ + function rebuildManifestIfNeeded(file: string) { + if (file.startsWith(pagesDirectory)) { + routeCache = {}; + manifest = createRouteManifest({ config: config }, logging); + } + } + // Rebuild route manifest on file change, if needed. + viteServer.watcher.on('add', rebuildManifestIfNeeded); + viteServer.watcher.on('unlink', rebuildManifestIfNeeded); + // No need to rebuild routes on content-only changes. + // However, we DO want to clear the cache in case + // the change caused a getStaticPaths() return to change. + viteServer.watcher.on('change', () => (routeCache = {})); + return () => { + removeViteHttpMiddleware(viteServer.middlewares); + viteServer.middlewares.use(async (req, res) => { + if (!req.url || !req.method) { + throw new Error('Incomplete request'); + } + handleRequest(routeCache, viteServer, logging, manifest, config, req, res); + }); + }; + }, + }; +} diff --git a/packages/astro/src/vite-plugin-astro/index.ts b/packages/astro/src/vite-plugin-astro/index.ts index a252a8e30979..c19f79842e84 100644 --- a/packages/astro/src/vite-plugin-astro/index.ts +++ b/packages/astro/src/vite-plugin-astro/index.ts @@ -1,11 +1,10 @@ import type vite from '../core/vite'; import type { AstroConfig } from '../@types/astro'; -import type { LogOptions } from '../core/logger'; +import type { LogOptions } from '../core/logger.js'; import esbuild from 'esbuild'; import npath from 'path'; import { fileURLToPath } from 'url'; -import { AstroDevServer } from '../core/dev/index.js'; import { getViteTransform, TransformHook } from './styles.js'; import { parseAstroRequest } from './query.js'; import { cachedCompilation, invalidateCompilation } from './compile.js'; @@ -15,7 +14,6 @@ const FRONTMATTER_PARSE_REGEXP = /^\-\-\-(.*)^\-\-\-/ms; interface AstroPluginOptions { config: AstroConfig; logging: LogOptions; - devServer?: AstroDevServer; } /** Transform .astro files for Vite */ @@ -36,7 +34,7 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu const isBrowserPath = (path: string) => path.startsWith(srcRootWeb); return { - name: '@astrojs/vite-plugin-astro', + name: 'astro:build', enforce: 'pre', // run transforms before other plugins can configResolved(resolvedConfig) { viteTransform = getViteTransform(resolvedConfig); diff --git a/packages/astro/src/vite-plugin-build-html/index.ts b/packages/astro/src/vite-plugin-build-html/index.ts index 22bad8073b1f..9a0bc6957b08 100644 --- a/packages/astro/src/vite-plugin-build-html/index.ts +++ b/packages/astro/src/vite-plugin-build-html/index.ts @@ -1,5 +1,5 @@ import type { AstroConfig, RouteCache } from '../@types/astro'; -import type { LogOptions } from '../core/logger'; +import type { LogOptions } from '../core/logger.js'; import type { ViteDevServer, Plugin as VitePlugin } from '../core/vite'; import type { OutputChunk, PreRenderedChunk } from 'rollup'; import type { AllPagesData } from '../core/build/types'; diff --git a/packages/astro/src/vite-plugin-config-alias/index.ts b/packages/astro/src/vite-plugin-config-alias/index.ts index 15ae0bffc317..c6fa6fd816b6 100644 --- a/packages/astro/src/vite-plugin-config-alias/index.ts +++ b/packages/astro/src/vite-plugin-config-alias/index.ts @@ -78,7 +78,7 @@ export default function configAliasVitePlugin(astroConfig: { projectRoot?: URL; if (!configAlias) return {} as vite.PluginOption; return { - name: '@astrojs/vite-plugin-config-alias', + name: 'astro:tsconfig-alias', enforce: 'pre', async resolveId(sourceId: string, importer, options) { /** Resolved ID conditionally handled by any other resolver. (this gives priority to all other resolvers) */ diff --git a/packages/astro/src/vite-plugin-jsx/index.ts b/packages/astro/src/vite-plugin-jsx/index.ts index a1efb6ffdc96..642aa865d2f5 100644 --- a/packages/astro/src/vite-plugin-jsx/index.ts +++ b/packages/astro/src/vite-plugin-jsx/index.ts @@ -1,7 +1,7 @@ import type { TransformResult } from 'rollup'; import type { Plugin, ResolvedConfig } from '../core/vite'; import type { AstroConfig, Renderer } from '../@types/astro'; -import type { LogOptions } from '../core/logger'; +import type { LogOptions } from '../core/logger.js'; import babel from '@babel/core'; import esbuild from 'esbuild'; @@ -98,7 +98,7 @@ export default function jsx({ config, logging }: AstroPluginJSXOptions): Plugin let viteConfig: ResolvedConfig; return { - name: '@astrojs/vite-plugin-jsx', + name: 'astro:jsx', enforce: 'pre', // run transforms before other plugins configResolved(resolvedConfig) { viteConfig = resolvedConfig; diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index 9b3ccaca82ec..99888e8b9b67 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -4,17 +4,15 @@ import type { AstroConfig } from '../@types/astro'; import esbuild from 'esbuild'; import fs from 'fs'; import { transform } from '@astrojs/compiler'; -import { AstroDevServer } from '../core/dev/index.js'; interface AstroPluginOptions { config: AstroConfig; - devServer?: AstroDevServer; } /** Transform .astro files for Vite */ export default function markdown({ config }: AstroPluginOptions): Plugin { return { - name: '@astrojs/vite-plugin-markdown', + name: 'astro:markdown', enforce: 'pre', // run transforms before other plugins can async load(id) { if (id.endsWith('.md')) { diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index 88b46908d54b..f14d7b311cc8 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -73,10 +73,10 @@ export async function loadFixture(inlineConfig) { return { build: (opts = {}) => build(config, { mode: 'development', logging: 'error', ...opts }), startDevServer: async (opts = {}) => { - const devServer = await dev(config, { logging: 'error', ...opts }); - config.devOptions.port = devServer.port; // update port - inlineConfig.devOptions.port = devServer.port; - return devServer; + const devResult = await dev(config, { logging: 'error', ...opts }); + config.devOptions.port = devResult.address.port; // update port + inlineConfig.devOptions.port = devResult.address.port; + return devResult; }, config, fetch: (url, init) => fetch(`http://${config.devOptions.hostname}:${config.devOptions.port}${url.replace(/^\/?/, '/')}`, init), diff --git a/yarn.lock b/yarn.lock index 53b001b37ffb..f46009e71fca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3185,16 +3185,6 @@ condense-whitespace@^1.0.0: resolved "https://registry.yarnpkg.com/condense-whitespace/-/condense-whitespace-1.0.0.tgz#8376d98ef028e6cb2cd2468e28ce42c5c65ab1a9" integrity sha1-g3bZjvAo5sss0kaOKM5CxcZasak= -connect@^3.7.0: - version "3.7.0" - resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8" - integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== - dependencies: - debug "2.6.9" - finalhandler "1.1.2" - parseurl "~1.3.3" - utils-merge "1.0.1" - consola@^2.15.3: version "2.15.3" resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550" @@ -4479,19 +4469,6 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -finalhandler@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" - integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== - dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "~2.3.0" - parseurl "~1.3.3" - statuses "~1.5.0" - unpipe "~1.0.0" - find-babel-config@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/find-babel-config/-/find-babel-config-1.2.0.tgz#a9b7b317eb5b9860cda9d54740a8c8337a2283a2" @@ -7168,11 +7145,6 @@ parse5@^6.0.0, parse5@^6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== -parseurl@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - path-browserify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" @@ -9329,7 +9301,7 @@ unocss@^0.15.5: "@unocss/reset" "0.15.6" "@unocss/vite" "0.15.6" -unpipe@1.0.0, unpipe@~1.0.0: +unpipe@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= @@ -9387,11 +9359,6 @@ util@^0.12.0: safe-buffer "^5.1.2" which-typed-array "^1.1.2" -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= - uuid@^2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a"