diff options
Diffstat (limited to 'packages/astro/src')
39 files changed, 1124 insertions, 619 deletions
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index ea1dc6f4d..598530836 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -28,6 +28,7 @@ export interface CLIFlags { port?: number; config?: string; experimentalStaticBuild?: boolean; + experimentalSsr?: boolean; drafts?: boolean; } @@ -102,7 +103,7 @@ export interface AstroUserConfig { renderers?: string[]; /** Options for rendering markdown content */ markdownOptions?: { - render?: [string | MarkdownParser, Record<string, any>]; + render?: MarkdownRenderOptions; }; /** Options specific to `astro build` */ buildOptions?: { @@ -132,6 +133,10 @@ export interface AstroUserConfig { * Default: false */ experimentalStaticBuild?: boolean; + /** + * Enable a build for SSR support. + */ + experimentalSsr?: boolean; }; /** Options for the development server run with `astro dev`. */ devOptions?: { @@ -224,6 +229,7 @@ export interface ManifestData { routes: RouteData[]; } +export type MarkdownRenderOptions = [string | MarkdownParser, Record<string, any>]; export type MarkdownParser = (contents: string, options?: Record<string, any>) => MarkdownParserResponse | PromiseLike<MarkdownParserResponse>; export interface MarkdownParserResponse { @@ -341,6 +347,11 @@ export interface RouteData { type: 'page'; } +export type SerializedRouteData = Omit<RouteData, 'generate' | 'pattern'> & { + generate: undefined; + pattern: string; +}; + export type RuntimeMode = 'development' | 'production'; /** diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index b37edec1c..ce3be2efb 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -31,6 +31,7 @@ function printHelp() { --project-root <path> Specify the path to the project root folder. --no-sitemap Disable sitemap generation (build only). --experimental-static-build A more performant build that expects assets to be define statically. + --experimental-ssr Enable SSR compilation. --drafts Include markdown draft pages in the build. --verbose Enable verbose logging --silent Disable logging diff --git a/packages/astro/src/core/app/common.ts b/packages/astro/src/core/app/common.ts new file mode 100644 index 000000000..ef6d1ae74 --- /dev/null +++ b/packages/astro/src/core/app/common.ts @@ -0,0 +1,20 @@ +import type { SSRManifest, SerializedSSRManifest, RouteInfo } from './types'; +import { deserializeRouteData } from '../routing/manifest/serialization.js'; + +export function deserializeManifest(serializedManifest: SerializedSSRManifest): SSRManifest { + const routes: RouteInfo[] = []; + for(const serializedRoute of serializedManifest.routes) { + routes.push({ + ...serializedRoute, + routeData: deserializeRouteData(serializedRoute.routeData) + }); + + const route = serializedRoute as unknown as RouteInfo; + route.routeData = deserializeRouteData(serializedRoute.routeData); + } + + return { + ...serializedManifest, + routes + }; +} diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts new file mode 100644 index 000000000..38e5c3d6f --- /dev/null +++ b/packages/astro/src/core/app/index.ts @@ -0,0 +1,100 @@ +import type { ComponentInstance, ManifestData, RouteData, Renderer } from '../../@types/astro'; +import type { + SSRManifest as Manifest, RouteInfo +} from './types'; + +import { defaultLogOptions } from '../logger.js'; +import { matchRoute } from '../routing/match.js'; +import { render } from '../render/core.js'; +import { RouteCache } from '../render/route-cache.js'; +import { createLinkStylesheetElementSet, createModuleScriptElementWithSrcSet } from '../render/ssr-element.js'; +import { createRenderer } from '../render/renderer.js'; +import { prependForwardSlash } from '../path.js'; + +export class App { + #manifest: Manifest; + #manifestData: ManifestData; + #rootFolder: URL; + #routeDataToRouteInfo: Map<RouteData, RouteInfo>; + #routeCache: RouteCache; + #renderersPromise: Promise<Renderer[]>; + + constructor(manifest: Manifest, rootFolder: URL) { + this.#manifest = manifest; + this.#manifestData = { + routes: manifest.routes.map(route => route.routeData) + }; + this.#rootFolder = rootFolder; + this.#routeDataToRouteInfo = new Map( + manifest.routes.map(route => [route.routeData, route]) + ); + this.#routeCache = new RouteCache(defaultLogOptions); + this.#renderersPromise = this.#loadRenderers(); + } + match({ pathname }: URL): RouteData | undefined { + return matchRoute(pathname, this.#manifestData); + } + async render(url: URL, routeData?: RouteData): Promise<string> { + if(!routeData) { + routeData = this.match(url); + if(!routeData) { + return 'Not found'; + } + } + + const manifest = this.#manifest; + const info = this.#routeDataToRouteInfo.get(routeData!)!; + const [mod, renderers] = await Promise.all([ + this.#loadModule(info.file), + this.#renderersPromise + ]); + + const links = createLinkStylesheetElementSet(info.links, manifest.site); + const scripts = createModuleScriptElementWithSrcSet(info.scripts, manifest.site); + + return render({ + experimentalStaticBuild: true, + links, + logging: defaultLogOptions, + markdownRender: manifest.markdown.render, + mod, + origin: url.origin, + pathname: url.pathname, + scripts, + renderers, + async resolve(specifier: string) { + if(!(specifier in manifest.entryModules)) { + throw new Error(`Unable to resolve [${specifier}]`); + } + const bundlePath = manifest.entryModules[specifier]; + return prependForwardSlash(bundlePath); + }, + route: routeData, + routeCache: this.#routeCache, + site: this.#manifest.site + }) + } + async #loadRenderers(): Promise<Renderer[]> { + const rendererNames = this.#manifest.renderers; + return await Promise.all(rendererNames.map(async (rendererName) => { + return createRenderer(rendererName, { + renderer(name) { + return import(name); + }, + server(entry) { + return import(entry); + } + }) + })); + } + async #loadModule(rootRelativePath: string): Promise<ComponentInstance> { + let modUrl = new URL(rootRelativePath, this.#rootFolder).toString(); + let mod: ComponentInstance; + try { + mod = await import(modUrl); + return mod; + } catch(err) { + throw new Error(`Unable to import ${modUrl}. Does this file exist?`); + } + } +} diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts new file mode 100644 index 000000000..d1bcbf46b --- /dev/null +++ b/packages/astro/src/core/app/node.ts @@ -0,0 +1,31 @@ +import type { SSRManifest, SerializedSSRManifest } from './types'; + +import * as fs from 'fs'; +import { App } from './index.js'; +import { deserializeManifest } from './common.js'; +import { IncomingMessage } from 'http'; + +function createURLFromRequest(req: IncomingMessage): URL { + return new URL(`http://${req.headers.host}${req.url}`); +} + +class NodeApp extends App { + match(req: IncomingMessage | URL) { + return super.match(req instanceof URL ? req : createURLFromRequest(req)); + } + render(req: IncomingMessage | URL) { + return super.render(req instanceof URL ? req : createURLFromRequest(req)); + } +} + +export async function loadManifest(rootFolder: URL): Promise<SSRManifest> { + const manifestFile = new URL('./manifest.json', rootFolder); + const rawManifest = await fs.promises.readFile(manifestFile, 'utf-8'); + const serializedManifest: SerializedSSRManifest = JSON.parse(rawManifest); + return deserializeManifest(serializedManifest); +} + +export async function loadApp(rootFolder: URL): Promise<NodeApp> { + const manifest = await loadManifest(rootFolder); + return new NodeApp(manifest, rootFolder); +} diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts new file mode 100644 index 000000000..8799ef1c9 --- /dev/null +++ b/packages/astro/src/core/app/types.ts @@ -0,0 +1,26 @@ +import type { RouteData, SerializedRouteData, MarkdownRenderOptions } from '../../@types/astro'; + +export interface RouteInfo { + routeData: RouteData + file: string; + links: string[]; + scripts: string[]; +} + +export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & { + routeData: SerializedRouteData; +} + +export interface SSRManifest { + routes: RouteInfo[]; + site?: string; + markdown: { + render: MarkdownRenderOptions + }, + renderers: string[]; + entryModules: Record<string, string>; +} + +export type SerializedSSRManifest = Omit<SSRManifest, 'routes'> & { + routes: SerializedRouteInfo[]; +} diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index b54e68622..68b12603d 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -8,12 +8,12 @@ import { performance } from 'perf_hooks'; import vite, { ViteDevServer } from '../vite.js'; import { createVite, ViteConfigWithSSR } from '../create-vite.js'; import { debug, defaultLogOptions, info, levels, timerMessage, warn } from '../logger.js'; -import { createRouteManifest } from '../ssr/routing.js'; -import { generateSitemap } from '../ssr/sitemap.js'; +import { createRouteManifest } from '../routing/index.js'; +import { generateSitemap } from '../render/sitemap.js'; import { collectPagesData } from './page-data.js'; import { build as scanBasedBuild } from './scan-based-build.js'; import { staticBuild } from './static-build.js'; -import { RouteCache } from '../ssr/route-cache.js'; +import { RouteCache } from '../render/route-cache.js'; export interface BuildOptions { mode?: string; @@ -115,6 +115,7 @@ class AstroBuilder { allPages, astroConfig: this.config, logging: this.logging, + manifest: this.manifest, origin: this.origin, pageNames, routeCache: this.routeCache, diff --git a/packages/astro/src/core/build/page-data.ts b/packages/astro/src/core/build/page-data.ts index 106e09a05..945423080 100644 --- a/packages/astro/src/core/build/page-data.ts +++ b/packages/astro/src/core/build/page-data.ts @@ -1,4 +1,4 @@ -import type { AstroConfig, ComponentInstance, ManifestData, RouteData, RSSResult } from '../../@types/astro'; +import type { AstroConfig, ComponentInstance, ManifestData, RouteData } from '../../@types/astro'; import type { AllPagesData } from './types'; import type { LogOptions } from '../logger'; import type { ViteDevServer } from '../vite.js'; @@ -6,9 +6,9 @@ import type { ViteDevServer } from '../vite.js'; import { fileURLToPath } from 'url'; import * as colors from 'kleur/colors'; import { debug } from '../logger.js'; -import { preload as ssrPreload } from '../ssr/index.js'; -import { generateRssFunction } from '../ssr/rss.js'; -import { callGetStaticPaths, RouteCache, RouteCacheEntry } from '../ssr/route-cache.js'; +import { preload as ssrPreload } from '../render/dev/index.js'; +import { generateRssFunction } from '../render/rss.js'; +import { callGetStaticPaths, RouteCache, RouteCacheEntry } from '../render/route-cache.js'; export interface CollectPagesDataOptions { astroConfig: AstroConfig; diff --git a/packages/astro/src/core/build/scan-based-build.ts b/packages/astro/src/core/build/scan-based-build.ts index c11795fd8..e6d380b61 100644 --- a/packages/astro/src/core/build/scan-based-build.ts +++ b/packages/astro/src/core/build/scan-based-build.ts @@ -9,7 +9,7 @@ import vite from '../vite.js'; import { createBuildInternals } from '../../core/build/internal.js'; import { rollupPluginAstroBuildHTML } from '../../vite-plugin-build-html/index.js'; import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js'; -import { RouteCache } from '../ssr/route-cache.js'; +import { RouteCache } from '../render/route-cache.js'; export interface ScanBasedBuildOptions { allPages: AllPagesData; diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 22255148d..3ab3e0cb4 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -1,12 +1,12 @@ -import type { OutputChunk, OutputAsset, PreRenderedChunk, RollupOutput } from 'rollup'; -import type { Plugin as VitePlugin, UserConfig } from '../vite'; -import type { AstroConfig, Renderer, SSRElement } from '../../@types/astro'; +import type { OutputChunk, OutputAsset, RollupOutput } from 'rollup'; +import type { Plugin as VitePlugin, UserConfig, Manifest as ViteManifest } from '../vite'; +import type { AstroConfig, ComponentInstance, ManifestData, Renderer } from '../../@types/astro'; import type { AllPagesData } from './types'; import type { LogOptions } from '../logger'; import type { ViteConfigWithSSR } from '../create-vite'; import type { PageBuildData } from './types'; import type { BuildInternals } from '../../core/build/internal.js'; -import type { AstroComponentFactory } from '../../runtime/server'; +import type { SerializedSSRManifest, SerializedRouteInfo } from '../app/types'; import fs from 'fs'; import npath from 'path'; @@ -17,17 +17,18 @@ import { debug, error } from '../../core/logger.js'; import { prependForwardSlash, appendForwardSlash } from '../../core/path.js'; import { createBuildInternals } from '../../core/build/internal.js'; import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js'; -import { getParamsAndProps } from '../ssr/index.js'; -import { createResult } from '../ssr/result.js'; -import { renderPage } from '../../runtime/server/index.js'; -import { prepareOutDir } from './fs.js'; +import { emptyDir, prepareOutDir } from './fs.js'; import { vitePluginHoistedScripts } from './vite-plugin-hoisted-scripts.js'; -import { RouteCache } from '../ssr/route-cache.js'; +import { RouteCache } from '../render/route-cache.js'; +import { serializeRouteData } from '../routing/index.js'; +import { render } from '../render/core.js'; +import { createLinkStylesheetElementSet, createModuleScriptElementWithSrcSet } from '../render/ssr-element.js'; export interface StaticBuildOptions { allPages: AllPagesData; astroConfig: AstroConfig; logging: LogOptions; + manifest: ManifestData; origin: string; pageNames: string[]; routeCache: RouteCache; @@ -41,6 +42,12 @@ function addPageName(pathname: string, opts: StaticBuildOptions): void { opts.pageNames.push(pathname.replace(/\/?$/, pathrepl).replace(/^\//, '')); } +// Gives back a facadeId that is relative to the root. +// ie, src/pages/index.astro instead of /Users/name..../src/pages/index.astro +function rootRelativeFacadeId(facadeId: string, astroConfig: AstroConfig): string { + return facadeId.slice(fileURLToPath(astroConfig.projectRoot).length); +} + // Determines of a Rollup chunk is an entrypoint page. function chunkIsPage(astroConfig: AstroConfig, output: OutputAsset | OutputChunk, internals: BuildInternals) { if (output.type !== 'chunk') { @@ -48,7 +55,7 @@ function chunkIsPage(astroConfig: AstroConfig, output: OutputAsset | OutputChunk } const chunk = output as OutputChunk; if (chunk.facadeModuleId) { - const facadeToEntryId = prependForwardSlash(chunk.facadeModuleId.slice(fileURLToPath(astroConfig.projectRoot).length)); + const facadeToEntryId = prependForwardSlash(rootRelativeFacadeId(chunk.facadeModuleId, astroConfig)); return internals.entrySpecifierToBundleMap.has(facadeToEntryId); } return false; @@ -88,6 +95,9 @@ function getByFacadeId<T>(facadeId: string, map: Map<string, T>): T | undefined export async function staticBuild(opts: StaticBuildOptions) { const { allPages, astroConfig } = opts; + // Basic options + const staticMode = !astroConfig.buildOptions.experimentalSsr; + // The pages to be built for rendering purposes. const pageInput = new Set<string>(); @@ -148,26 +158,38 @@ export async function staticBuild(opts: StaticBuildOptions) { // Run the SSR build and client build in parallel const [ssrResult] = (await Promise.all([ssrBuild(opts, internals, pageInput), clientBuild(opts, internals, jsInput)])) as RollupOutput[]; - // Generate each of the pages. - await generatePages(ssrResult, opts, internals, facadeIdToPageDataMap); - await cleanSsrOutput(opts); + // SSG mode, generate pages. + if(staticMode) { + // Generate each of the pages. + await generatePages(ssrResult, opts, internals, facadeIdToPageDataMap); + await cleanSsrOutput(opts); + } else { + await generateManifest(ssrResult, opts, internals); + await ssrMoveAssets(opts); + } } async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, input: Set<string>) { const { astroConfig, viteConfig } = opts; + const ssr = astroConfig.buildOptions.experimentalSsr; + const out = ssr ? getServerRoot(astroConfig) : getOutRoot(astroConfig); return await vite.build({ logLevel: 'error', mode: 'production', build: { emptyOutDir: false, + manifest: ssr, minify: false, - outDir: fileURLToPath(getOutRoot(astroConfig)), + outDir: fileURLToPath(out), ssr: true, rollupOptions: { input: Array.from(input), output: { format: 'esm', + entryFileNames: '[name].[hash].mjs', + chunkFileNames: 'chunks/[name].[hash].mjs', + assetFileNames: 'assets/[name].[hash][extname]' }, }, target: 'esnext', // must match an esbuild target @@ -179,7 +201,7 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp }), ...(viteConfig.plugins || []), ], - publicDir: viteConfig.publicDir, + publicDir: ssr ? false : viteConfig.publicDir, root: viteConfig.root, envPrefix: 'PUBLIC_', server: viteConfig.server, @@ -196,17 +218,23 @@ async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals, return null; } + const out = astroConfig.buildOptions.experimentalSsr ? getClientRoot(astroConfig) : getOutRoot(astroConfig); + return await vite.build({ logLevel: 'error', mode: 'production', build: { emptyOutDir: false, minify: 'esbuild', - outDir: fileURLToPath(getOutRoot(astroConfig)), + outDir: fileURLToPath(out), rollupOptions: { input: Array.from(input), output: { format: 'esm', + entryFileNames: '[name].[hash].js', + chunkFileNames: 'chunks/[name].[hash].js', + assetFileNames: 'assets/[name].[hash][extname]' + }, preserveEntrySignatures: 'exports-only', }, @@ -285,14 +313,13 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter const hoistedId = getByFacadeId<string>(facadeId, internals.facadeIdToHoistedEntryMap) || null; let compiledModule = await import(url.toString()); - let Component = compiledModule.default; const generationOptions: Readonly<GeneratePathOptions> = { pageData, internals, linkIds, hoistedId, - Component, + mod: compiledModule, renderers, }; @@ -314,65 +341,48 @@ interface GeneratePathOptions { internals: BuildInternals; linkIds: string[]; hoistedId: string | null; - Component: AstroComponentFactory; + mod: ComponentInstance; renderers: Renderer[]; } async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) { const { astroConfig, logging, origin, routeCache } = opts; - const { Component, internals, linkIds, hoistedId, pageData, renderers } = gopts; + const { mod, internals, linkIds, hoistedId, pageData, renderers } = gopts; // This adds the page name to the array so it can be shown as part of stats. addPageName(pathname, opts); - const [, mod] = pageData.preload; + debug('build', `Generating: ${pathname}`); + + const site = astroConfig.buildOptions.site; + const links = createLinkStylesheetElementSet(linkIds, site); + const scripts = createModuleScriptElementWithSrcSet(hoistedId ? [hoistedId] : [], site); try { - const [params, pageProps] = await getParamsAndProps({ + const html = await render({ + experimentalStaticBuild: true, + links, + logging, + markdownRender: astroConfig.markdownOptions.render, + mod, + origin, + pathname, + scripts, + renderers, + async resolve(specifier: string) { + const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier); + if (typeof hashedFilePath !== 'string') { + throw new Error(`Cannot find the built path for ${specifier}`); + } + const relPath = npath.posix.relative(pathname, '/' + hashedFilePath); + const fullyRelativePath = relPath[0] === '.' ? relPath : './' + relPath; + return fullyRelativePath; + }, route: pageData.route, routeCache, - pathname, + site: astroConfig.buildOptions.site, }); - debug('build', `Generating: ${pathname}`); - - const rootpath = appendForwardSlash(new URL(astroConfig.buildOptions.site || 'http://localhost/').pathname); - const links = new Set<SSRElement>( - linkIds.map((href) => ({ - props: { - rel: 'stylesheet', - href: npath.posix.join(rootpath, href), - }, - children: '', - })) - ); - const scripts = hoistedId - ? new Set<SSRElement>([ - { - props: { - type: 'module', - src: npath.posix.join(rootpath, hoistedId), - }, - children: '', - }, - ]) - : new Set<SSRElement>(); - const result = createResult({ astroConfig, logging, origin, params, pathname, renderers, links, scripts }); - - // Override the `resolve` method so that hydrated components are given the - // hashed filepath to the component. - result.resolve = async (specifier: string) => { - const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier); - if (typeof hashedFilePath !== 'string') { - throw new Error(`Cannot find the built path for ${specifier}`); - } - const relPath = npath.posix.relative(pathname, '/' + hashedFilePath); - const fullyRelativePath = relPath[0] === '.' ? relPath : './' + relPath; - return fullyRelativePath; - }; - - let html = await renderPage(result, Component, pageProps, null); - const outFolder = getOutFolder(astroConfig, pathname); const outFile = getOutFile(astroConfig, outFolder, pathname); await fs.promises.mkdir(outFolder, { recursive: true }); @@ -382,11 +392,79 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G } } +async function generateManifest(result: RollupOutput, opts: StaticBuildOptions, internals: BuildInternals) { + const { astroConfig, manifest } = opts; + const manifestFile = new URL('./manifest.json', getServerRoot(astroConfig)); + + const inputManifestJSON = await fs.promises.readFile(manifestFile, 'utf-8'); + const data: ViteManifest = JSON.parse(inputManifestJSON); + + const rootRelativeIdToChunkMap = new Map<string, OutputChunk>(); + for(const output of result.output) { + if(chunkIsPage(astroConfig, output, internals)) { + const chunk = output as OutputChunk; + if(chunk.facadeModuleId) { + const id = rootRelativeFacadeId(chunk.facadeModuleId, astroConfig); + rootRelativeIdToChunkMap.set(id, chunk); + } + } + } + + const routes: SerializedRouteInfo[] = []; + + for(const routeData of manifest.routes) { + const componentPath = routeData.component; + const entry = data[componentPath]; + + if(!rootRelativeIdToChunkMap.has(componentPath)) { + throw new Error('Unable to find chunk for ' + componentPath); + } + + const chunk = rootRelativeIdToChunkMap.get(componentPath)!; + const facadeId = chunk.facadeModuleId!; + const links = getByFacadeId<string[]>(facadeId, internals.facadeIdToAssetsMap) || []; + const hoistedScript = getByFacadeId<string>(facadeId, internals.facadeIdToHoistedEntryMap); + const scripts = hoistedScript ? [hoistedScript] : []; + + routes.push({ + file: entry?.file, + links, + scripts, + routeData: serializeRouteData(routeData) + }); + } + + const ssrManifest: SerializedSSRManifest = { + routes, + site: astroConfig.buildOptions.site, + markdown: { + render: astroConfig.markdownOptions.render + }, + renderers: astroConfig.renderers, + entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()) + }; + + const outputManifestJSON = JSON.stringify(ssrManifest, null, ' '); + await fs.promises.writeFile(manifestFile, outputManifestJSON, 'utf-8'); +} + function getOutRoot(astroConfig: AstroConfig): URL { const rootPathname = appendForwardSlash(astroConfig.buildOptions.site ? new URL(astroConfig.buildOptions.site).pathname : '/'); return new URL('.' + rootPathname, astroConfig.dist); } +function getServerRoot(astroConfig: AstroConfig): URL { + const rootFolder = getOutRoot(astroConfig); + const serverFolder = new URL('./server/', rootFolder); + return serverFolder; +} + +function getClientRoot(astroConfig: AstroConfig): URL { + const rootFolder = getOutRoot(astroConfig); + const serverFolder = new URL('./client/', rootFolder); + return serverFolder; +} + function getOutFolder(astroConfig: AstroConfig, pathname: string): URL { const outRoot = getOutRoot(astroConfig); @@ -421,6 +499,34 @@ async function cleanSsrOutput(opts: StaticBuildOptions) { ); } +async function ssrMoveAssets(opts: StaticBuildOptions) { + const { astroConfig } = opts; + const serverRoot = getServerRoot(astroConfig); + const clientRoot = getClientRoot(astroConfig); + const serverAssets = new URL('./assets/', serverRoot); + const clientAssets = new URL('./assets/', clientRoot); + const files = await glob('assets/**/*', { + cwd: fileURLToPath(serverRoot), + }); + + // Make the directory + await fs.promises.mkdir(clientAssets, { recursive: true }); + + await Promise.all( + files.map(async (filename) => { + const currentUrl = new URL(filename, serverRoot); + const clientUrl = new URL(filename, clientRoot); + return fs.promises.rename(currentUrl, clientUrl); + }) + ); + + await emptyDir(fileURLToPath(serverAssets)); + + if(fs.existsSync(serverAssets)) { + await fs.promises.rmdir(serverAssets); + } +} + export function vitePluginNewBuild(input: Set<string>, internals: BuildInternals, ext: 'js' | 'mjs'): VitePlugin { return { name: '@astro/rollup-plugin-new-build', @@ -451,18 +557,6 @@ export function vitePluginNewBuild(input: Set<string>, internals: BuildInternals } }, - outputOptions(outputOptions) { - Object.assign(outputOptions, { - entryFileNames(_chunk: PreRenderedChunk) { - return 'assets/[name].[hash].' + ext; - }, - chunkFileNames(_chunk: PreRenderedChunk) { - return 'assets/[name].[hash].' + ext; - }, - }); - return outputOptions; - }, - async generateBundle(_options, bundle) { const promises = []; const mapping = new Map<string, string>(); diff --git a/packages/astro/src/core/build/types.d.ts b/packages/astro/src/core/build/types.d.ts index 2606075e2..fa37ff888 100644 --- a/packages/astro/src/core/build/types.d.ts +++ b/packages/astro/src/core/build/types.d.ts @@ -1,4 +1,4 @@ -import type { ComponentPreload } from '../ssr/index'; +import type { ComponentPreload } from '../render/dev/index'; import type { RouteData } from '../../@types/astro'; export interface PageBuildData { diff --git a/packages/astro/src/core/config.ts b/packages/astro/src/core/config.ts index 186677802..a8ddd5b79 100644 --- a/packages/astro/src/core/config.ts +++ b/packages/astro/src/core/config.ts @@ -63,6 +63,7 @@ export const AstroConfigSchema = z.object({ .optional() .default('directory'), experimentalStaticBuild: z.boolean().optional().default(false), + experimentalSsr: z.boolean().optional().default(false), drafts: z.boolean().optional().default(false), }) .optional() @@ -130,6 +131,7 @@ function resolveFlags(flags: Partial<Flags>): CLIFlags { config: typeof flags.config === 'string' ? flags.config : undefined, hostname: typeof flags.hostname === 'string' ? flags.hostname : undefined, experimentalStaticBuild: typeof flags.experimentalStaticBuild === 'boolean' ? flags.experimentalStaticBuild : false, + experimentalSsr: typeof flags.experimentalSsr === 'boolean' ? flags.experimentalSsr : false, drafts: typeof flags.drafts === 'boolean' ? flags.drafts : false, }; } @@ -143,6 +145,12 @@ function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags) { if (typeof flags.port === 'number') astroConfig.devOptions.port = flags.port; if (typeof flags.hostname === 'string') astroConfig.devOptions.hostname = flags.hostname; if (typeof flags.experimentalStaticBuild === 'boolean') astroConfig.buildOptions.experimentalStaticBuild = flags.experimentalStaticBuild; + if (typeof flags.experimentalSsr === 'boolean') { + astroConfig.buildOptions.experimentalSsr = flags.experimentalSsr; + if(flags.experimentalSsr) { + astroConfig.buildOptions.experimentalStaticBuild = true; + } + } if (typeof flags.drafts === 'boolean') astroConfig.buildOptions.drafts = flags.drafts; return astroConfig; } diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts new file mode 100644 index 000000000..eea5afa33 --- /dev/null +++ b/packages/astro/src/core/render/core.ts @@ -0,0 +1,119 @@ +import type { ComponentInstance, MarkdownRenderOptions, Params, Props, Renderer, RouteData, SSRElement } from '../../@types/astro'; +import type { LogOptions } from '../logger.js'; + +import { renderPage } from '../../runtime/server/index.js'; +import { getParams } from '../routing/index.js'; +import { createResult } from './result.js'; +import { findPathItemByKey, RouteCache, callGetStaticPaths } from './route-cache.js'; +import { warn } from '../logger.js'; + +interface GetParamsAndPropsOptions { + mod: ComponentInstance; + route: RouteData | undefined; + routeCache: RouteCache; + pathname: string; + logging: LogOptions; +} + +async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise<[Params, Props]> { + const { logging, mod, route, routeCache, pathname } = opts; + // Handle dynamic routes + let params: Params = {}; + let pageProps: Props; + if (route && !route.pathname) { + if (route.params.length) { + const paramsMatch = route.pattern.exec(pathname); + if (paramsMatch) { + params = getParams(route.params)(paramsMatch); + } + } + let routeCacheEntry = routeCache.get(route); + if (!routeCacheEntry) { + warn(logging, 'routeCache', `Internal Warning: getStaticPaths() called twice during the build. (${route.component})`); + routeCacheEntry = await callGetStaticPaths(mod, route, true, logging); + routeCache.set(route, routeCacheEntry); + } + const paramsKey = JSON.stringify(params); + const matchedStaticPath = findPathItemByKey(routeCacheEntry.staticPaths, paramsKey); + if (!matchedStaticPath) { + throw new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${pathname})`); + } + // This is written this way for performance; instead of spreading the props + // which is O(n), create a new object that extends props. + pageProps = Object.create(matchedStaticPath.props || Object.prototype); + } else { + pageProps = {}; + } + return [params, pageProps]; +} + +interface RenderOptions { + experimentalStaticBuild: boolean; + logging: LogOptions, + links: Set<SSRElement>; + markdownRender: MarkdownRenderOptions, + mod: ComponentInstance; + origin: string; + pathname: string; + scripts: Set<SSRElement>; + resolve: (s: string) => Promise<string>; + renderers: Renderer[]; + route?: RouteData; + routeCache: RouteCache; + site?: string; +} + +export async function render(opts: RenderOptions): Promise<string> { + const { + experimentalStaticBuild, + links, + logging, + origin, + markdownRender, + mod, + pathname, + scripts, + renderers, + resolve, + route, + routeCache, + site + } = opts; + + const [params, pageProps] = await getParamsAndProps({ + logging, + mod, + route, + routeCache, + pathname, + }); + + // Validate the page component before rendering the page + const Component = await mod.default; + if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); + if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`); + + + const result = createResult({ + experimentalStaticBuild, + links, + logging, + markdownRender, + origin, + params, + pathname, + resolve, + renderers, + site, + scripts + }); + + let html = await renderPage(result, Component, pageProps, null); + + // inject <!doctype html> if missing (TODO: is a more robust check needed for comments, etc.?) + if (experimentalStaticBuild && !/<!doctype html/i.test(html)) { + html = '<!DOCTYPE html>\n' + html; + } + + return html; +} diff --git a/packages/astro/src/core/ssr/css.ts b/packages/astro/src/core/render/dev/css.ts index 4ee0e80d8..196fdafd4 100644 --- a/packages/astro/src/core/ssr/css.ts +++ b/packages/astro/src/core/render/dev/css.ts @@ -1,7 +1,7 @@ -import type vite from '../vite'; +import type vite from '../../vite'; import path from 'path'; -import { viteID } from '../util.js'; +import { viteID } from '../../util.js'; // https://vitejs.dev/guide/features.html#css-pre-processors export const STYLE_EXTENSIONS = new Set(['.css', '.pcss', '.postcss', '.scss', '.sass', '.styl', '.stylus', '.less']); diff --git a/packages/astro/src/core/render/dev/error.ts b/packages/astro/src/core/render/dev/error.ts new file mode 100644 index 000000000..aa5a18083 --- /dev/null +++ b/packages/astro/src/core/render/dev/error.ts @@ -0,0 +1,44 @@ +import type { BuildResult } from 'esbuild'; +import type vite from '../../vite'; +import type { SSRError } from '../../../@types/astro'; + +import eol from 'eol'; +import fs from 'fs'; +import { codeFrame } from '../../util.js'; + +interface ErrorHandlerOptions { + filePath: URL; + viteServer: vite.ViteDevServer; +} + +export async function errorHandler(e: unknown, { viteServer, filePath }: ErrorHandlerOptions) { + // normalize error stack line-endings to \n + if ((e as any).stack) { + (e as any).stack = eol.lf((e as any).stack); + } + + // fix stack trace with Vite (this searches its module graph for matches) + if (e instanceof Error) { + viteServer.ssrFixStacktrace(e); + } + + // Astro error (thrown by esbuild so it needs to be formatted for Vite) + if (Array.isArray((e as any).errors)) { + const { location, pluginName, text } = (e as BuildResult).errors[0]; + const err = e as SSRError; + if (location) err.loc = { file: location.file, line: location.line, column: location.column }; + let src = err.pluginCode; + if (!src && err.id && fs.existsSync(err.id)) src = await fs.promises.readFile(err.id, 'utf8'); + if (!src) src = await fs.promises.readFile(filePath, 'utf8'); + err.frame = codeFrame(src, err.loc); + err.id = location?.file; + err.message = `${location?.file}: ${text} +${err.frame} +`; + if (pluginName) err.plugin = pluginName; + throw err; + } + + // Generic error (probably from Vite, and already formatted) + throw e; +} diff --git a/packages/astro/src/core/render/dev/hmr.ts b/packages/astro/src/core/render/dev/hmr.ts new file mode 100644 index 000000000..3c795fdb1 --- /dev/null +++ b/packages/astro/src/core/render/dev/hmr.ts @@ -0,0 +1,11 @@ +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +let hmrScript: string; +export async function getHmrScript() { + if (hmrScript) return hmrScript; + const filePath = fileURLToPath(new URL('../../../runtime/client/hmr.js', import.meta.url)); + const content = await fs.promises.readFile(filePath); + hmrScript = content.toString(); + return hmrScript; +} diff --git a/packages/astro/src/core/ssr/html.ts b/packages/astro/src/core/render/dev/html.ts index eb429b927..2ae147ade 100644 --- a/packages/astro/src/core/ssr/html.ts +++ b/packages/astro/src/core/render/dev/html.ts @@ -1,4 +1,4 @@ -import type vite from '../vite'; +import type vite from '../../vite'; import htmlparser2 from 'htmlparser2'; diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts new file mode 100644 index 000000000..70f142c33 --- /dev/null +++ b/packages/astro/src/core/render/dev/index.ts @@ -0,0 +1,158 @@ +import type vite from '../../vite'; +import type { AstroConfig, ComponentInstance, Renderer, RouteData, RuntimeMode } from '../../../@types/astro'; +import { LogOptions } from '../../logger.js'; +import { fileURLToPath } from 'url'; +import { getStylesForURL } from './css.js'; +import { injectTags } from './html.js'; +import { RouteCache } from '../route-cache.js'; +import { resolveRenderers } from './renderers.js'; +import { errorHandler } from './error.js'; +import { getHmrScript } from './hmr.js'; +import { render as coreRender } from '../core.js'; +import { createModuleScriptElementWithSrcSet } from '../ssr-element.js'; + +interface SSROptions { + /** an instance of the AstroConfig */ + astroConfig: AstroConfig; + /** location of file on disk */ + filePath: URL; + /** logging options */ + logging: LogOptions; + /** "development" or "production" */ + mode: RuntimeMode; + /** production website, needed for some RSS & Sitemap functions */ + origin: string; + /** the web request (needed for dynamic routes) */ + pathname: string; + /** optional, in case we need to render something outside of a dev server */ + route?: RouteData; + /** pass in route cache because SSR can’t manage cache-busting */ + routeCache: RouteCache; + /** Vite instance */ + viteServer: vite.ViteDevServer; +} + +export type ComponentPreload = [Renderer[], ComponentInstance]; + +const svelteStylesRE = /svelte\?svelte&type=style/; + +export async function preload({ astroConfig, filePath, viteServer }: SSROptions): Promise<ComponentPreload> { + // Important: This needs to happen first, in case a renderer provides polyfills. + const renderers = await resolveRenderers(viteServer, astroConfig); + // Load the module from the Vite SSR Runtime. + const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; + + return [renderers, mod]; +} + +/** use Vite to SSR */ +export async function render(renderers: Renderer[], mod: ComponentInstance, ssrOpts: SSROptions): Promise<string> { + const { astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer } = ssrOpts; + + // Add hoisted script tags + const scripts = createModuleScriptElementWithSrcSet(astroConfig.buildOptions.experimentalStaticBuild ? + Array.from(mod.$$metadata.hoistedScriptPaths()) : + [] + ); + + // Inject HMR scripts + if (mode === 'development' && astroConfig.buildOptions.experimentalStaticBuild) { + scripts.add({ + props: { type: 'module', src: '/@vite/client' }, + children: '', + }); + scripts.add({ + props: { type: 'module', src: new URL('../../runtime/client/hmr.js', import.meta.url).pathname }, + children: '', + }); + } + + let html = await coreRender({ + experimentalStaticBuild: astroConfig.buildOptions.experimentalStaticBuild, + links: new Set(), + logging, + markdownRender: astroConfig.markdownOptions.render, + mod, + origin, + pathname, + scripts, + // Resolves specifiers in the inline hydrated scripts, such as "@astrojs/renderer-preact/client.js" + async resolve(s: string) { + // The legacy build needs these to remain unresolved so that vite HTML + // Can do the resolution. Without this condition the build output will be + // broken in the legacy build. This can be removed once the legacy build is removed. + if (astroConfig.buildOptions.experimentalStaticBuild) { + const [, resolvedPath] = await viteServer.moduleGraph.resolveUrl(s); + return resolvedPath; + } else { + return s; + } + }, + renderers, + route, + routeCache, + site: astroConfig.buildOptions.site, + }); + + // inject tags + const tags: vite.HtmlTagDescriptor[] = []; + + // dev only: inject Astro HMR client + if (mode === 'development' && !astroConfig.buildOptions.experimentalStaticBuild) { + tags.push({ + tag: 'script', + attrs: { type: 'module' }, + // HACK: inject the direct contents of our `astro/runtime/client/hmr.js` to ensure + // `import.meta.hot` is properly handled by Vite + children: await getHmrScript(), + injectTo: 'head', + }); + } + + // inject CSS + [...getStylesForURL(filePath, viteServer)].forEach((href) => { + if (mode === 'development' && svelteStylesRE.test(href)) { + tags.push({ + tag: 'script', + attrs: { type: 'module', src: href }, + injectTo: 'head', + }); + } else { + tags.push({ + tag: 'link', + attrs: { + rel: 'stylesheet', + href, + 'data-astro-injected': true, + }, + injectTo: 'head', + }); + } + }); + + // add injected tags + html = injectTags(html, tags); + + // run transformIndexHtml() in dev to run Vite dev transformations + if (mode === 'development' && !astroConfig.buildOptions.experimentalStaticBuild) { + const relativeURL = filePath.href.replace(astroConfig.projectRoot.href, '/'); + html = await viteServer.transformIndexHtml(relativeURL, html, pathname); + } + + // inject <!doctype html> if missing (TODO: is a more robust check needed for comments, etc.?) + if (!/<!doctype html/i.test(html)) { + html = '<!DOCTYPE html>\n' + html; + } + + return html; +} + +export async function ssr(ssrOpts: SSROptions): Promise<string> { + try { + const [renderers, mod] = await preload(ssrOpts); + return await render(renderers, mod, ssrOpts); // note(drew): without "await", errors won’t get caught by errorHandler() + } catch (e: unknown) { + await errorHandler(e, { viteServer: ssrOpts.viteServer, filePath: ssrOpts.filePath }); + throw e; + } +} diff --git a/packages/astro/src/core/render/dev/renderers.ts b/packages/astro/src/core/render/dev/renderers.ts new file mode 100644 index 000000000..abe22b3ca --- /dev/null +++ b/packages/astro/src/core/render/dev/renderers.ts @@ -0,0 +1,36 @@ +import type vite from '../../vite'; +import type { AstroConfig, Renderer } from '../../../@types/astro'; + +import { resolveDependency } from '../../util.js'; +import { createRenderer } from '../renderer.js'; + +const cache = new Map<string, Promise<Renderer>>(); + +async function resolveRenderer(viteServer: vite.ViteDevServer, renderer: string, astroConfig: AstroConfig): Promise<Renderer> { + const resolvedRenderer: Renderer = await createRenderer(renderer, { + renderer(name) { + return import(resolveDependency(name, astroConfig)); + }, + async server(entry) { + const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(entry); + const mod = await viteServer.ssrLoadModule(url); + return mod; + } + }); + + return resolvedRenderer; +} + +export async function resolveRenderers(viteServer: vite.ViteDevServer, astroConfig: AstroConfig): Promise<Renderer[]> { + const ids: string[] = astroConfig.renderers; + const renderers = await Promise.all( + ids.map((renderer) => { + if (cache.has(renderer)) return cache.get(renderer)!; + let promise = resolveRenderer(viteServer, renderer, astroConfig); + cache.set(renderer, promise); + return promise; + }) + ); + + return renderers; +} diff --git a/packages/astro/src/core/ssr/paginate.ts b/packages/astro/src/core/render/paginate.ts index 96d8a435a..96d8a435a 100644 --- a/packages/astro/src/core/ssr/paginate.ts +++ b/packages/astro/src/core/render/paginate.ts diff --git a/packages/astro/src/core/render/renderer.ts b/packages/astro/src/core/render/renderer.ts new file mode 100644 index 000000000..42025cfc0 --- /dev/null +++ b/packages/astro/src/core/render/renderer.ts @@ -0,0 +1,30 @@ +import type { Renderer } from '../../@types/astro'; + +import npath from 'path'; + +interface RendererResolverImplementation { + renderer: (name: string) => Promise<any>; + server: (entry: string) => Promise<any>; +} + +export async function createRenderer(renderer: string, impl: RendererResolverImplementation) { + const resolvedRenderer: any = {}; + // We can dynamically import the renderer by itself because it shouldn't have + // any non-standard imports, the index is just meta info. + // The other entrypoints need to be loaded through Vite. + const { + default: { name, client, polyfills, hydrationPolyfills, server }, + } = await impl.renderer(renderer) //await import(resolveDependency(renderer, astroConfig)); + + resolvedRenderer.name = name; + if (client) resolvedRenderer.source = npath.posix.join(renderer, client); + resolvedRenderer.serverEntry = npath.posix.join(renderer, server); + if (Array.isArray(hydrationPolyfills)) resolvedRenderer.hydrationPolyfills = hydrationPolyfills.map((src: string) => npath.posix.join(renderer, src)); + if (Array.isArray(polyfills)) resolvedRenderer.polyfills = polyfills.map((src: string) => npath.posix.join(renderer, src)); + + const { default: rendererSSR } = await impl.server(resolvedRenderer.serverEntry); + resolvedRenderer.ssr = rendererSSR; + + const completedRenderer: Renderer = resolvedRenderer; + return completedRenderer; +} diff --git a/packages/astro/src/core/ssr/result.ts b/packages/astro/src/core/render/result.ts index 5a03ab769..9775a0949 100644 --- a/packages/astro/src/core/ssr/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -1,25 +1,37 @@ -import type { AstroConfig, AstroGlobal, AstroGlobalPartial, Params, Renderer, SSRElement, SSRResult } from '../../@types/astro'; +import type { AstroGlobal, AstroGlobalPartial, MarkdownParser, MarkdownRenderOptions, Params, Renderer, SSRElement, SSRResult } from '../../@types/astro'; import { bold } from 'kleur/colors'; import { canonicalURL as getCanonicalURL } from '../util.js'; -import { isCSSRequest } from './css.js'; +import { isCSSRequest } from './dev/css.js'; import { isScriptRequest } from './script.js'; import { renderSlot } from '../../runtime/server/index.js'; import { warn, LogOptions } from '../logger.js'; export interface CreateResultArgs { - astroConfig: AstroConfig; + experimentalStaticBuild: boolean; logging: LogOptions; origin: string; + markdownRender: MarkdownRenderOptions; params: Params; pathname: string; renderers: Renderer[]; + resolve: (s: string) => Promise<string>; + site: string | undefined; links?: Set<SSRElement>; scripts?: Set<SSRElement>; } export function createResult(args: CreateResultArgs): SSRResult { - const { astroConfig, origin, params, pathname, renderers } = args; + const { + experimentalStaticBuild, + origin, + markdownRender, + params, + pathname, + renderers, + resolve, + site: buildOptionsSite + } = args; // Create the result object that will be passed into the render function. // This object starts here as an empty shell (not yet the result) but then @@ -32,7 +44,7 @@ export function createResult(args: CreateResultArgs): SSRResult { createAstro(astroGlobal: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null) { const site = new URL(origin); const url = new URL('.' + pathname, site); - const canonicalURL = getCanonicalURL('.' + pathname, astroConfig.buildOptions.site || origin); + const canonicalURL = getCanonicalURL('.' + pathname, buildOptionsSite || origin); return { __proto__: astroGlobal, props, @@ -42,7 +54,7 @@ export function createResult(args: CreateResultArgs): SSRResult { url, }, resolve(path: string) { - if (astroConfig.buildOptions.experimentalStaticBuild) { + if (experimentalStaticBuild) { let extra = `This can be replaced with a dynamic import like so: await import("${path}")`; if (isCSSRequest(path)) { extra = `It looks like you are resolving styles. If you are adding a link tag, replace with this: @@ -83,33 +95,37 @@ ${extra}` }, // <Markdown> also needs the same `astroConfig.markdownOptions.render` as `.md` pages async privateRenderMarkdownDoNotUse(content: string, opts: any) { - let mdRender = astroConfig.markdownOptions.render; - let renderOpts = {}; + let [mdRender, renderOpts] = markdownRender; + let parser: MarkdownParser | null = null; + //let renderOpts = {}; if (Array.isArray(mdRender)) { renderOpts = mdRender[1]; mdRender = mdRender[0]; } // ['rehype-toc', opts] if (typeof mdRender === 'string') { - ({ default: mdRender } = await import(mdRender)); + const mod: { default: MarkdownParser } = await import(mdRender); + parser = mod.default; } // [import('rehype-toc'), opts] else if (mdRender instanceof Promise) { - ({ default: mdRender } = await mdRender); + const mod: { default: MarkdownParser } = await mdRender; + parser = mod.default; + } else if(typeof mdRender === 'function') { + parser = mdRender; + } else { + throw new Error('No Markdown parser found.'); } - const { code } = await mdRender(content, { ...renderOpts, ...(opts ?? {}) }); + const { code } = await parser(content, { ...renderOpts, ...(opts ?? {}) }); return code; }, } as unknown as AstroGlobal; }, - // This is a stub and will be implemented by dev and build. - async resolve(s: string): Promise<string> { - return ''; - }, + resolve, _metadata: { renderers, pathname, - experimentalStaticBuild: astroConfig.buildOptions.experimentalStaticBuild, + experimentalStaticBuild, }, }; diff --git a/packages/astro/src/core/ssr/route-cache.ts b/packages/astro/src/core/render/route-cache.ts index 11988d36b..889c64a48 100644 --- a/packages/astro/src/core/ssr/route-cache.ts +++ b/packages/astro/src/core/render/route-cache.ts @@ -1,8 +1,8 @@ import type { ComponentInstance, GetStaticPathsItem, GetStaticPathsResult, GetStaticPathsResultKeyed, RouteData, RSS } from '../../@types/astro'; import { LogOptions, warn, debug } from '../logger.js'; -import { generatePaginateFunction } from '../ssr/paginate.js'; -import { validateGetStaticPathsModule, validateGetStaticPathsResult } from './routing.js'; +import { generatePaginateFunction } from './paginate.js'; +import { validateGetStaticPathsModule, validateGetStaticPathsResult } from '../routing/index.js'; type RSSFn = (...args: any[]) => any; diff --git a/packages/astro/src/core/ssr/rss.ts b/packages/astro/src/core/render/rss.ts index 18cce36a1..1e77dff35 100644 --- a/packages/astro/src/core/ssr/rss.ts +++ b/packages/astro/src/core/render/rss.ts @@ -1,4 +1,4 @@ -import type { RSSFunction, RSS, RSSResult, FeedResult, RouteData } from '../../@types/astro'; +import type { RSSFunction, RSS, RSSResult, RouteData } from '../../@types/astro'; import { XMLValidator } from 'fast-xml-parser'; import { canonicalURL, isValidURL, PRETTY_FEED_V3 } from '../util.js'; diff --git a/packages/astro/src/core/ssr/script.ts b/packages/astro/src/core/render/script.ts index a91391963..a91391963 100644 --- a/packages/astro/src/core/ssr/script.ts +++ b/packages/astro/src/core/render/script.ts diff --git a/packages/astro/src/core/ssr/sitemap.ts b/packages/astro/src/core/render/sitemap.ts index a5ef54f6a..a5ef54f6a 100644 --- a/packages/astro/src/core/ssr/sitemap.ts +++ b/packages/astro/src/core/render/sitemap.ts diff --git a/packages/astro/src/core/render/ssr-element.ts b/packages/astro/src/core/render/ssr-element.ts new file mode 100644 index 000000000..5fbd3b115 --- /dev/null +++ b/packages/astro/src/core/render/ssr-element.ts @@ -0,0 +1,40 @@ +import type { SSRElement } from '../../@types/astro'; + +import npath from 'path'; +import { appendForwardSlash } from '../../core/path.js'; + +function getRootPath(site?: string): string { + return appendForwardSlash(new URL(site || 'http://localhost/').pathname) +} + +function joinToRoot(href: string, site?: string): string { + return npath.posix.join(getRootPath(site), href); +} + +export function createLinkStylesheetElement(href: string, site?: string): SSRElement { + return { + props: { + rel: 'stylesheet', + href: joinToRoot(href, site) + }, + children: '', + }; +} + +export function createLinkStylesheetElementSet(hrefs: string[], site?: string) { + return new Set<SSRElement>(hrefs.map(href => createLinkStylesheetElement(href, site))); +} + +export function createModuleScriptElementWithSrc(src: string, site?: string): SSRElement { + return { + props: { + type: 'module', + src: joinToRoot(src, site), + }, + children: '', + } +} + +export function createModuleScriptElementWithSrcSet(srces: string[], site?: string): Set<SSRElement> { + return new Set<SSRElement>(srces.map(src => createModuleScriptElementWithSrc(src, site))); +} diff --git a/packages/astro/src/core/routing/index.ts b/packages/astro/src/core/routing/index.ts new file mode 100644 index 000000000..2bc9be954 --- /dev/null +++ b/packages/astro/src/core/routing/index.ts @@ -0,0 +1,11 @@ +export { createRouteManifest } from './manifest/create.js'; +export { + serializeRouteData, + deserializeRouteData +} from './manifest/serialization.js'; +export { matchRoute } from './match.js'; +export { getParams } from './params.js'; +export { + validateGetStaticPathsModule, + validateGetStaticPathsResult +} from './validation.js'; diff --git a/packages/astro/src/core/ssr/routing.ts b/packages/astro/src/core/routing/manifest/create.ts index b6a2cf1a4..5456938ee 100644 --- a/packages/astro/src/core/ssr/routing.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -1,69 +1,16 @@ -import type { AstroConfig, ComponentInstance, GetStaticPathsResult, ManifestData, Params, RouteData } from '../../@types/astro'; -import type { LogOptions } from '../logger'; +import type { + AstroConfig, + ManifestData, + RouteData +} from '../../../@types/astro'; +import type { LogOptions } from '../../logger'; import fs from 'fs'; import path from 'path'; import { compile } from 'path-to-regexp'; import slash from 'slash'; import { fileURLToPath } from 'url'; -import { warn } from '../logger.js'; - -/** - * given an array of params like `['x', 'y', 'z']` for - * src/routes/[x]/[y]/[z]/svelte, create a function - * that turns a RegExpExecArray into ({ x, y, z }) - */ -export function getParams(array: string[]) { - const fn = (match: RegExpExecArray) => { - const params: Params = {}; - array.forEach((key, i) => { - if (key.startsWith('...')) { - params[key.slice(3)] = match[i + 1] ? decodeURIComponent(match[i + 1]) : undefined; - } else { - params[key] = decodeURIComponent(match[i + 1]); - } - }); - return params; - }; - - return fn; -} - -/** Find matching route from pathname */ -export function matchRoute(pathname: string, manifest: ManifestData): RouteData | undefined { - return manifest.routes.find((route) => route.pattern.test(pathname)); -} - -/** Throw error for deprecated/malformed APIs */ -export function validateGetStaticPathsModule(mod: ComponentInstance) { - if ((mod as any).createCollection) { - throw new Error(`[createCollection] deprecated. Please use getStaticPaths() instead.`); - } - if (!mod.getStaticPaths) { - throw new Error(`[getStaticPaths] getStaticPaths() function is required. Make sure that you \`export\` the function from your component.`); - } -} - -/** Throw error for malformed getStaticPaths() response */ -export function validateGetStaticPathsResult(result: GetStaticPathsResult, logging: LogOptions) { - if (!Array.isArray(result)) { - throw new Error(`[getStaticPaths] invalid return value. Expected an array of path objects, but got \`${JSON.stringify(result)}\`.`); - } - result.forEach((pathObject) => { - if (!pathObject.params) { - warn(logging, 'getStaticPaths', `invalid path object. Expected an object with key \`params\`, but got \`${JSON.stringify(pathObject)}\`. Skipped.`); - return; - } - for (const [key, val] of Object.entries(pathObject.params)) { - if (!(typeof val === 'undefined' || typeof val === 'string')) { - warn(logging, 'getStaticPaths', `invalid path param: ${key}. A string value was expected, but got \`${JSON.stringify(val)}\`.`); - } - if (val === '') { - warn(logging, 'getStaticPaths', `invalid path param: ${key}. \`undefined\` expected for an optional param, but got empty string.`); - } - } - }); -} +import { warn } from '../../logger.js'; interface Part { content: string; @@ -82,6 +29,148 @@ interface Item { routeSuffix: string; } +function countOccurrences(needle: string, haystack: string) { + let count = 0; + for (let i = 0; i < haystack.length; i += 1) { + if (haystack[i] === needle) count += 1; + } + return count; +} + +function getParts(part: string, file: string) { + const result: Part[] = []; + part.split(/\[(.+?\(.+?\)|.+?)\]/).map((str, i) => { + if (!str) return; + const dynamic = i % 2 === 1; + + const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str]; + + if (!content || (dynamic && !/^(\.\.\.)?[a-zA-Z0-9_$]+$/.test(content))) { + throw new Error(`Invalid route ${file} — parameter name must match /^[a-zA-Z0-9_$]+$/`); + } + + result.push({ + content, + dynamic, + spread: dynamic && /^\.{3}.+$/.test(content), + }); + }); + + return result; +} + +function getPattern(segments: Part[][], addTrailingSlash: AstroConfig['devOptions']['trailingSlash']) { + const pathname = segments + .map((segment) => { + return segment[0].spread + ? '(?:\\/(.*?))?' + : '\\/' + + segment + .map((part) => { + if (part) + return part.dynamic + ? '([^/]+?)' + : part.content + .normalize() + .replace(/\?/g, '%3F') + .replace(/#/g, '%23') + .replace(/%5B/g, '[') + .replace(/%5D/g, ']') + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }) + .join(''); + }) + .join(''); + + const trailing = addTrailingSlash && segments.length ? getTrailingSlashPattern(addTrailingSlash) : '$'; + return new RegExp(`^${pathname || '\\/'}${trailing}`); +} + + +function getTrailingSlashPattern(addTrailingSlash: AstroConfig['devOptions']['trailingSlash']): string { + if (addTrailingSlash === 'always') { + return '\\/$'; + } + if (addTrailingSlash === 'never') { + return '$'; + } + return '\\/?$'; +} + +function getGenerator(segments: Part[][], addTrailingSlash: AstroConfig['devOptions']['trailingSlash']) { + const template = segments + .map((segment) => { + return segment[0].spread + ? `/:${segment[0].content.substr(3)}(.*)?` + : '/' + + segment + .map((part) => { + if (part) + return part.dynamic + ? `:${part.content}` + : part.content + .normalize() + .replace(/\?/g, '%3F') + .replace(/#/g, '%23') + .replace(/%5B/g, '[') + .replace(/%5D/g, ']') + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }) + .join(''); + }) + .join(''); + + const trailing = addTrailingSlash !== 'never' && segments.length ? '/' : ''; + const toPath = compile(template + trailing); + return toPath; +} + +function isSpread(str: string) { + const spreadPattern = /\[\.{3}/g; + return spreadPattern.test(str); +} + +function comparator(a: Item, b: Item) { + if (a.isIndex !== b.isIndex) { + if (a.isIndex) return isSpread(a.file) ? 1 : -1; + + return isSpread(b.file) ? -1 : 1; + } + + const max = Math.max(a.parts.length, b.parts.length); + + for (let i = 0; i < max; i += 1) { + const aSubPart = a.parts[i]; + const bSubPart = b.parts[i]; + + if (!aSubPart) return 1; // b is more specific, so goes first + if (!bSubPart) return -1; + + // if spread && index, order later + if (aSubPart.spread && bSubPart.spread) { + return a.isIndex ? 1 : -1; + } + + // If one is ...spread order it later + if (aSubPart.spread !== bSubPart.spread) return aSubPart.spread ? 1 : -1; + + if (aSubPart.dynamic !== bSubPart.dynamic) { + return aSubPart.dynamic ? 1 : -1; + } + + if (!aSubPart.dynamic && aSubPart.content !== bSubPart.content) { + return bSubPart.content.length - aSubPart.content.length || (aSubPart.content < bSubPart.content ? -1 : 1); + } + } + + if (a.isPage !== b.isPage) { + return a.isPage ? 1 : -1; + } + + // otherwise sort alphabetically + return a.file < b.file ? -1 : 1; +} + /** Create manifest of all static routes */ export function createRouteManifest({ config, cwd }: { config: AstroConfig; cwd?: string }, logging: LogOptions): ManifestData { const components: string[] = []; @@ -207,144 +296,3 @@ export function createRouteManifest({ config, cwd }: { config: AstroConfig; cwd? routes, }; } - -function countOccurrences(needle: string, haystack: string) { - let count = 0; - for (let i = 0; i < haystack.length; i += 1) { - if (haystack[i] === needle) count += 1; - } - return count; -} - -function isSpread(str: string) { - const spreadPattern = /\[\.{3}/g; - return spreadPattern.test(str); -} - -function comparator(a: Item, b: Item) { - if (a.isIndex !== b.isIndex) { - if (a.isIndex) return isSpread(a.file) ? 1 : -1; - - return isSpread(b.file) ? -1 : 1; - } - - const max = Math.max(a.parts.length, b.parts.length); - - for (let i = 0; i < max; i += 1) { - const aSubPart = a.parts[i]; - const bSubPart = b.parts[i]; - - if (!aSubPart) return 1; // b is more specific, so goes first - if (!bSubPart) return -1; - - // if spread && index, order later - if (aSubPart.spread && bSubPart.spread) { - return a.isIndex ? 1 : -1; - } - - // If one is ...spread order it later - if (aSubPart.spread !== bSubPart.spread) return aSubPart.spread ? 1 : -1; - - if (aSubPart.dynamic !== bSubPart.dynamic) { - return aSubPart.dynamic ? 1 : -1; - } - - if (!aSubPart.dynamic && aSubPart.content !== bSubPart.content) { - return bSubPart.content.length - aSubPart.content.length || (aSubPart.content < bSubPart.content ? -1 : 1); - } - } - - if (a.isPage !== b.isPage) { - return a.isPage ? 1 : -1; - } - - // otherwise sort alphabetically - return a.file < b.file ? -1 : 1; -} - -function getParts(part: string, file: string) { - const result: Part[] = []; - part.split(/\[(.+?\(.+?\)|.+?)\]/).map((str, i) => { - if (!str) return; - const dynamic = i % 2 === 1; - - const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str]; - - if (!content || (dynamic && !/^(\.\.\.)?[a-zA-Z0-9_$]+$/.test(content))) { - throw new Error(`Invalid route ${file} — parameter name must match /^[a-zA-Z0-9_$]+$/`); - } - - result.push({ - content, - dynamic, - spread: dynamic && /^\.{3}.+$/.test(content), - }); - }); - - return result; -} - -function getTrailingSlashPattern(addTrailingSlash: AstroConfig['devOptions']['trailingSlash']): string { - if (addTrailingSlash === 'always') { - return '\\/$'; - } - if (addTrailingSlash === 'never') { - return '$'; - } - return '\\/?$'; -} - -function getPattern(segments: Part[][], addTrailingSlash: AstroConfig['devOptions']['trailingSlash']) { - const pathname = segments - .map((segment) => { - return segment[0].spread - ? '(?:\\/(.*?))?' - : '\\/' + - segment - .map((part) => { - if (part) - return part.dynamic - ? '([^/]+?)' - : part.content - .normalize() - .replace(/\?/g, '%3F') - .replace(/#/g, '%23') - .replace(/%5B/g, '[') - .replace(/%5D/g, ']') - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - }) - .join(''); - }) - .join(''); - - const trailing = addTrailingSlash && segments.length ? getTrailingSlashPattern(addTrailingSlash) : '$'; - return new RegExp(`^${pathname || '\\/'}${trailing}`); -} - -function getGenerator(segments: Part[][], addTrailingSlash: AstroConfig['devOptions']['trailingSlash']) { - const template = segments - .map((segment) => { - return segment[0].spread - ? `/:${segment[0].content.substr(3)}(.*)?` - : '/' + - segment - .map((part) => { - if (part) - return part.dynamic - ? `:${part.content}` - : part.content - .normalize() - .replace(/\?/g, '%3F') - .replace(/#/g, '%23') - .replace(/%5B/g, '[') - .replace(/%5D/g, ']') - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - }) - .join(''); - }) - .join(''); - - const trailing = addTrailingSlash !== 'never' && segments.length ? '/' : ''; - const toPath = compile(template + trailing); - return toPath; -} diff --git a/packages/astro/src/core/routing/manifest/serialization.ts b/packages/astro/src/core/routing/manifest/serialization.ts new file mode 100644 index 000000000..e751cc517 --- /dev/null +++ b/packages/astro/src/core/routing/manifest/serialization.ts @@ -0,0 +1,29 @@ +import type { + RouteData, + SerializedRouteData +} from '../../../@types/astro'; + +function createRouteData(pattern: RegExp, params: string[], component: string, pathname: string | undefined): RouteData { + return { + type: 'page', + pattern, + params, + component, + // TODO bring back + generate: () => '', + pathname: pathname || undefined, + } +} + +export function serializeRouteData(routeData: RouteData): SerializedRouteData { + // Is there a better way to do this in TypeScript? + const outRouteData = routeData as unknown as SerializedRouteData; + outRouteData.pattern = routeData.pattern.source; + return outRouteData; +} + +export function deserializeRouteData(rawRouteData: SerializedRouteData) { + const { component, params, pathname } = rawRouteData; + const pattern = new RegExp(rawRouteData.pattern); + return createRouteData(pattern, params, component, pathname); +} diff --git a/packages/astro/src/core/routing/match.ts b/packages/astro/src/core/routing/match.ts new file mode 100644 index 000000000..d5cf4e860 --- /dev/null +++ b/packages/astro/src/core/routing/match.ts @@ -0,0 +1,10 @@ +import type { + ManifestData, + RouteData +} from '../../@types/astro'; + +/** Find matching route from pathname */ +export function matchRoute(pathname: string, manifest: ManifestData): RouteData | undefined { + return manifest.routes.find((route) => route.pattern.test(pathname)); +} + diff --git a/packages/astro/src/core/routing/params.ts b/packages/astro/src/core/routing/params.ts new file mode 100644 index 000000000..739a99afd --- /dev/null +++ b/packages/astro/src/core/routing/params.ts @@ -0,0 +1,23 @@ +import type { Params } from '../../@types/astro'; + +/** + * given an array of params like `['x', 'y', 'z']` for + * src/routes/[x]/[y]/[z]/svelte, create a function + * that turns a RegExpExecArray into ({ x, y, z }) + */ + export function getParams(array: string[]) { + const fn = (match: RegExpExecArray) => { + const params: Params = {}; + array.forEach((key, i) => { + if (key.startsWith('...')) { + params[key.slice(3)] = match[i + 1] ? decodeURIComponent(match[i + 1]) : undefined; + } else { + params[key] = decodeURIComponent(match[i + 1]); + } + }); + return params; + }; + + return fn; +} + diff --git a/packages/astro/src/core/routing/validation.ts b/packages/astro/src/core/routing/validation.ts new file mode 100644 index 000000000..db47f6089 --- /dev/null +++ b/packages/astro/src/core/routing/validation.ts @@ -0,0 +1,37 @@ +import type { + ComponentInstance, + GetStaticPathsResult +} from '../../@types/astro'; +import type { LogOptions } from '../logger'; +import { warn } from '../logger.js'; + +/** Throw error for deprecated/malformed APIs */ +export function validateGetStaticPathsModule(mod: ComponentInstance) { + if ((mod as any).createCollection) { + throw new Error(`[createCollection] deprecated. Please use getStaticPaths() instead.`); + } + if (!mod.getStaticPaths) { + throw new Error(`[getStaticPaths] getStaticPaths() function is required. Make sure that you \`export\` the function from your component.`); + } +} + +/** Throw error for malformed getStaticPaths() response */ +export function validateGetStaticPathsResult(result: GetStaticPathsResult, logging: LogOptions) { + if (!Array.isArray(result)) { + throw new Error(`[getStaticPaths] invalid return value. Expected an array of path objects, but got \`${JSON.stringify(result)}\`.`); + } + result.forEach((pathObject) => { + if (!pathObject.params) { + warn(logging, 'getStaticPaths', `invalid path object. Expected an object with key \`params\`, but got \`${JSON.stringify(pathObject)}\`. Skipped.`); + return; + } + for (const [key, val] of Object.entries(pathObject.params)) { + if (!(typeof val === 'undefined' || typeof val === 'string')) { + warn(logging, 'getStaticPaths', `invalid path param: ${key}. A string value was expected, but got \`${JSON.stringify(val)}\`.`); + } + if (val === '') { + warn(logging, 'getStaticPaths', `invalid path param: ${key}. \`undefined\` expected for an optional param, but got empty string.`); + } + } + }); +} diff --git a/packages/astro/src/core/ssr/index.ts b/packages/astro/src/core/ssr/index.ts deleted file mode 100644 index c4d214a72..000000000 --- a/packages/astro/src/core/ssr/index.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { BuildResult } from 'esbuild'; -import type vite from '../vite'; -import type { AstroConfig, ComponentInstance, Params, Props, Renderer, RouteData, RuntimeMode, SSRElement, SSRError } from '../../@types/astro'; -import { LogOptions, warn } from '../logger.js'; - -import eol from 'eol'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { renderPage } from '../../runtime/server/index.js'; -import { codeFrame, resolveDependency } from '../util.js'; -import { getStylesForURL } from './css.js'; -import { injectTags } from './html.js'; -import { getParams, validateGetStaticPathsResult } from './routing.js'; -import { createResult } from './result.js'; -import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js'; - -const svelteStylesRE = /svelte\?svelte&type=style/; - -interface SSROptions { - /** an instance of the AstroConfig */ - astroConfig: AstroConfig; - /** location of file on disk */ - filePath: URL; - /** logging options */ - logging: LogOptions; - /** "development" or "production" */ - mode: RuntimeMode; - /** production website, needed for some RSS & Sitemap functions */ - origin: string; - /** the web request (needed for dynamic routes) */ - pathname: string; - /** optional, in case we need to render something outside of a dev server */ - route?: RouteData; - /** pass in route cache because SSR can’t manage cache-busting */ - routeCache: RouteCache; - /** Vite instance */ - viteServer: vite.ViteDevServer; -} - -const cache = new Map<string, Promise<Renderer>>(); - -// TODO: improve validation and error handling here. -async function resolveRenderer(viteServer: vite.ViteDevServer, renderer: string, astroConfig: AstroConfig) { - const resolvedRenderer: any = {}; - // We can dynamically import the renderer by itself because it shouldn't have - // any non-standard imports, the index is just meta info. - // The other entrypoints need to be loaded through Vite. - const { - default: { name, client, polyfills, hydrationPolyfills, server }, - } = await import(resolveDependency(renderer, astroConfig)); - - resolvedRenderer.name = name; - if (client) resolvedRenderer.source = path.posix.join(renderer, client); - resolvedRenderer.serverEntry = path.posix.join(renderer, server); - if (Array.isArray(hydrationPolyfills)) resolvedRenderer.hydrationPolyfills = hydrationPolyfills.map((src: string) => path.posix.join(renderer, src)); - if (Array.isArray(polyfills)) resolvedRenderer.polyfills = polyfills.map((src: string) => path.posix.join(renderer, src)); - const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(resolvedRenderer.serverEntry); - const { default: rendererSSR } = await viteServer.ssrLoadModule(url); - resolvedRenderer.ssr = rendererSSR; - - const completedRenderer: Renderer = resolvedRenderer; - return completedRenderer; -} - -async function resolveRenderers(viteServer: vite.ViteDevServer, astroConfig: AstroConfig): Promise<Renderer[]> { - const ids: string[] = astroConfig.renderers; - const renderers = await Promise.all( - ids.map((renderer) => { - if (cache.has(renderer)) return cache.get(renderer)!; - let promise = resolveRenderer(viteServer, renderer, astroConfig); - cache.set(renderer, promise); - return promise; - }) - ); - - return renderers; -} - -interface ErrorHandlerOptions { - filePath: URL; - viteServer: vite.ViteDevServer; -} - -async function errorHandler(e: unknown, { viteServer, filePath }: ErrorHandlerOptions) { - // normalize error stack line-endings to \n - if ((e as any).stack) { - (e as any).stack = eol.lf((e as any).stack); - } - - // fix stack trace with Vite (this searches its module graph for matches) - if (e instanceof Error) { - viteServer.ssrFixStacktrace(e); - } - - // Astro error (thrown by esbuild so it needs to be formatted for Vite) - if (Array.isArray((e as any).errors)) { - const { location, pluginName, text } = (e as BuildResult).errors[0]; - const err = e as SSRError; - if (location) err.loc = { file: location.file, line: location.line, column: location.column }; - let src = err.pluginCode; - if (!src && err.id && fs.existsSync(err.id)) src = await fs.promises.readFile(err.id, 'utf8'); - if (!src) src = await fs.promises.readFile(filePath, 'utf8'); - err.frame = codeFrame(src, err.loc); - err.id = location?.file; - err.message = `${location?.file}: ${text} -${err.frame} -`; - if (pluginName) err.plugin = pluginName; - throw err; - } - - // Generic error (probably from Vite, and already formatted) - throw e; -} - -export type ComponentPreload = [Renderer[], ComponentInstance]; - -export async function preload({ astroConfig, filePath, viteServer }: SSROptions): Promise<ComponentPreload> { - // Important: This needs to happen first, in case a renderer provides polyfills. - const renderers = await resolveRenderers(viteServer, astroConfig); - // Load the module from the Vite SSR Runtime. - const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; - - return [renderers, mod]; -} - -export async function getParamsAndProps({ route, routeCache, pathname }: { route: RouteData | undefined; routeCache: RouteCache; pathname: string }): Promise<[Params, Props]> { - // Handle dynamic routes - let params: Params = {}; - let pageProps: Props; - if (route && !route.pathname) { - if (route.params.length) { - const paramsMatch = route.pattern.exec(pathname); - if (paramsMatch) { - params = getParams(route.params)(paramsMatch); - } - } - const routeCacheEntry = routeCache.get(route); - if (!routeCacheEntry) { - throw new Error(`[${route.component}] Internal error: route cache was empty, but expected to be full.`); - } - const paramsKey = JSON.stringify(params); - const matchedStaticPath = findPathItemByKey(routeCacheEntry.staticPaths, paramsKey); - if (!matchedStaticPath) { - throw new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${pathname})`); - } - // This is written this way for performance; instead of spreading the props - // which is O(n), create a new object that extends props. - pageProps = Object.create(matchedStaticPath.props || Object.prototype); - } else { - pageProps = {}; - } - return [params, pageProps]; -} - -/** use Vite to SSR */ -export async function render(renderers: Renderer[], mod: ComponentInstance, ssrOpts: SSROptions): Promise<string> { - const { astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer } = ssrOpts; - - // Handle dynamic routes - let params: Params = {}; - let pageProps: Props = {}; - if (route && !route.pathname) { - if (route.params.length) { - const paramsMatch = route.pattern.exec(pathname); - if (paramsMatch) { - params = getParams(route.params)(paramsMatch); - } - } - let routeCacheEntry = routeCache.get(route); - // TODO(fks): All of our getStaticPaths logic should live in a single place, - // to prevent duplicate runs during the build. This is not expected to run - // anymore and we should change this check to thrown an internal error. - if (!routeCacheEntry) { - warn(logging, 'routeCache', `Internal Warning: getStaticPaths() called twice during the build. (${route.component})`); - routeCacheEntry = await callGetStaticPaths(mod, route, true, logging); - routeCache.set(route, routeCacheEntry); - } - const matchedStaticPath = routeCacheEntry.staticPaths.find(({ params: _params }) => JSON.stringify(_params) === JSON.stringify(params)); - if (!matchedStaticPath) { - throw new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${pathname})`); - } - pageProps = { ...matchedStaticPath.props } || {}; - } - - // Validate the page component before rendering the page - const Component = await mod.default; - if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); - if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`); - - // Add hoisted script tags - const scripts = astroConfig.buildOptions.experimentalStaticBuild - ? new Set<SSRElement>( - Array.from(mod.$$metadata.hoistedScriptPaths()).map((src) => ({ - props: { type: 'module', src }, - children: '', - })) - ) - : new Set<SSRElement>(); - - // Inject HMR scripts - if (mode === 'development' && astroConfig.buildOptions.experimentalStaticBuild) { - scripts.add({ - props: { type: 'module', src: '/@vite/client' }, - children: '', - }); - scripts.add({ - props: { type: 'module', src: new URL('../../runtime/client/hmr.js', import.meta.url).pathname }, - children: '', - }); - } - - const result = createResult({ astroConfig, logging, origin, params, pathname, renderers, scripts }); - // Resolves specifiers in the inline hydrated scripts, such as "@astrojs/renderer-preact/client.js" - result.resolve = async (s: string) => { - // The legacy build needs these to remain unresolved so that vite HTML - // Can do the resolution. Without this condition the build output will be - // broken in the legacy build. This can be removed once the legacy build is removed. - if (astroConfig.buildOptions.experimentalStaticBuild) { - const [, resolvedPath] = await viteServer.moduleGraph.resolveUrl(s); - return resolvedPath; - } else { - return s; - } - }; - - let html = await renderPage(result, Component, pageProps, null); - - // inject tags - const tags: vite.HtmlTagDescriptor[] = []; - - // dev only: inject Astro HMR client - if (mode === 'development' && !astroConfig.buildOptions.experimentalStaticBuild) { - tags.push({ - tag: 'script', - attrs: { type: 'module' }, - // HACK: inject the direct contents of our `astro/runtime/client/hmr.js` to ensure - // `import.meta.hot` is properly handled by Vite - children: await getHmrScript(), - injectTo: 'head', - }); - } - - // inject CSS - [...getStylesForURL(filePath, viteServer)].forEach((href) => { - if (mode === 'development' && svelteStylesRE.test(href)) { - tags.push({ - tag: 'script', - attrs: { type: 'module', src: href }, - injectTo: 'head', - }); - } else { - tags.push({ - tag: 'link', - attrs: { - rel: 'stylesheet', - href, - 'data-astro-injected': true, - }, - injectTo: 'head', - }); - } - }); - - // add injected tags - html = injectTags(html, tags); - - // run transformIndexHtml() in dev to run Vite dev transformations - if (mode === 'development' && !astroConfig.buildOptions.experimentalStaticBuild) { - const relativeURL = filePath.href.replace(astroConfig.projectRoot.href, '/'); - html = await viteServer.transformIndexHtml(relativeURL, html, pathname); - } - - // inject <!doctype html> if missing (TODO: is a more robust check needed for comments, etc.?) - if (!/<!doctype html/i.test(html)) { - html = '<!DOCTYPE html>\n' + html; - } - - return html; -} - -let hmrScript: string; -async function getHmrScript() { - if (hmrScript) return hmrScript; - const filePath = fileURLToPath(new URL('../../runtime/client/hmr.js', import.meta.url)); - const content = await fs.promises.readFile(filePath); - hmrScript = content.toString(); - return hmrScript; -} - -export async function ssr(ssrOpts: SSROptions): Promise<string> { - try { - const [renderers, mod] = await preload(ssrOpts); - return await render(renderers, mod, ssrOpts); // note(drew): without "await", errors won’t get caught by errorHandler() - } catch (e: unknown) { - await errorHandler(e, { viteServer: ssrOpts.viteServer, filePath: ssrOpts.filePath }); - throw e; - } -} diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 93c908416..e987db5f6 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -157,7 +157,7 @@ export async function renderComponent(result: SSRResult, displayName: string, Co } const probableRendererNames = guessRenderers(metadata.componentUrl); - if (Array.isArray(renderers) && renderers.length === 0 && typeof Component !== 'string' && !HTMLElement.isPrototypeOf(Component as object)) { + if (Array.isArray(renderers) && renderers.length === 0 && typeof Component !== 'string' && !componentIsHTMLElement(Component)) { const message = `Unable to render ${metadata.displayName}! There are no \`renderers\` set in your \`astro.config.mjs\` file. @@ -175,7 +175,7 @@ Did you mean to enable ${formatList(probableRendererNames.map((r) => '`' + r + ' } } - if (!renderer && HTMLElement.isPrototypeOf(Component as object)) { + if (!renderer && typeof HTMLElement === 'function' && componentIsHTMLElement(Component)) { const output = renderHTMLElement(result, Component as typeof HTMLElement, _props, slots); return output; @@ -465,6 +465,10 @@ export async function renderAstroComponent(component: InstanceType<typeof AstroC return unescapeHTML(await _render(template)); } +function componentIsHTMLElement(Component: unknown) { + return typeof HTMLElement !== 'undefined' && HTMLElement.isPrototypeOf(Component as object); +} + export async function renderHTMLElement(result: SSRResult, constructor: typeof HTMLElement, props: any, slots: any) { const name = getHTMLElementName(constructor); diff --git a/packages/astro/src/vite-plugin-astro-server/index.ts b/packages/astro/src/vite-plugin-astro-server/index.ts index de57e1593..eb08ac8a0 100644 --- a/packages/astro/src/vite-plugin-astro-server/index.ts +++ b/packages/astro/src/vite-plugin-astro-server/index.ts @@ -2,17 +2,16 @@ import type vite from '../core/vite'; import type http from 'http'; import type { AstroConfig, ManifestData, RouteData } from '../@types/astro'; import { info, LogOptions } from '../core/logger.js'; -import { fileURLToPath } from 'url'; -import { createRouteManifest, matchRoute } from '../core/ssr/routing.js'; +import { createRouteManifest, matchRoute } from '../core/routing/index.js'; import mime from 'mime'; import stripAnsi from 'strip-ansi'; import { createSafeError } from '../core/util.js'; -import { ssr } from '../core/ssr/index.js'; +import { ssr } from '../core/render/dev/index.js'; import * as msg from '../core/messages.js'; import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js'; import serverErrorTemplate from '../template/5xx.js'; -import { RouteCache } from '../core/ssr/route-cache.js'; +import { RouteCache } from '../core/render/route-cache.js'; interface AstroPluginOptions { config: AstroConfig; @@ -126,7 +125,6 @@ export default function createPlugin({ config, logging }: AstroPluginOptions): v return { name: 'astro:server', configureServer(viteServer) { - const pagesDirectory = fileURLToPath(config.pages); let routeCache = new RouteCache(logging); let manifest: ManifestData = createRouteManifest({ config: config }, logging); /** rebuild the route cache + manifest, as needed. */ diff --git a/packages/astro/src/vite-plugin-astro/styles.ts b/packages/astro/src/vite-plugin-astro/styles.ts index 6ebcd0e0d..b49ce6e9b 100644 --- a/packages/astro/src/vite-plugin-astro/styles.ts +++ b/packages/astro/src/vite-plugin-astro/styles.ts @@ -1,6 +1,6 @@ import type vite from '../core/vite'; -import { STYLE_EXTENSIONS } from '../core/ssr/css.js'; +import { STYLE_EXTENSIONS } from '../core/render/dev/css.js'; export type TransformHook = (code: string, id: string, ssr?: boolean) => Promise<vite.TransformResult>; diff --git a/packages/astro/src/vite-plugin-build-css/index.ts b/packages/astro/src/vite-plugin-build-css/index.ts index 155fdc8ed..de7933f7d 100644 --- a/packages/astro/src/vite-plugin-build-css/index.ts +++ b/packages/astro/src/vite-plugin-build-css/index.ts @@ -1,10 +1,9 @@ -import type { RenderedChunk } from 'rollup'; import type { BuildInternals } from '../core/build/internal'; import * as path from 'path'; import esbuild from 'esbuild'; import { Plugin as VitePlugin } from '../core/vite'; -import { isCSSRequest } from '../core/ssr/css.js'; +import { isCSSRequest } from '../core/render/dev/css.js'; const PLUGIN_NAME = '@astrojs/rollup-plugin-build-css'; diff --git a/packages/astro/src/vite-plugin-build-html/index.ts b/packages/astro/src/vite-plugin-build-html/index.ts index 48bb617c9..87cc46779 100644 --- a/packages/astro/src/vite-plugin-build-html/index.ts +++ b/packages/astro/src/vite-plugin-build-html/index.ts @@ -12,10 +12,10 @@ import { getAttribute, hasAttribute, insertBefore, remove, createScript, createE import { addRollupInput } from './add-rollup-input.js'; import { findAssets, findExternalScripts, findInlineScripts, findInlineStyles, getTextContent, getAttributes } from './extract-assets.js'; import { isBuildableImage, isBuildableLink, isHoistedScript, isInSrcDirectory, hasSrcSet } from './util.js'; -import { render as ssrRender } from '../core/ssr/index.js'; +import { render as ssrRender } from '../core/render/dev/index.js'; import { getAstroStyleId, getAstroPageStyleId } from '../vite-plugin-build-css/index.js'; import { prependDotSlash, removeEndingForwardSlash } from '../core/path.js'; -import { RouteCache } from '../core/ssr/route-cache.js'; +import { RouteCache } from '../core/render/route-cache.js'; // This package isn't real ESM, so have to coerce it const matchSrcset: typeof srcsetParse = (srcsetParse as any).default; |