diff --git a/packages/astro/src/@types/astro-file.ts b/packages/astro/src/@types/astro-file.ts new file mode 100644 index 000000000..dc5c4e2b8 --- /dev/null +++ b/packages/astro/src/@types/astro-file.ts @@ -0,0 +1,37 @@ +type AstroRenderedHTML = string; + +export type FetchContentResultBase = { + astro: { + headers: string[]; + source: string; + html: AstroRenderedHTML; + }; + url: URL; +}; + +export type FetchContentResult = FetchContentResultBase & T; + +export type Params = Record; + +interface AstroPageRequest { + url: URL; + canonicalURL: URL; + params: Params; +} + +export interface AstroBuiltinProps { + 'client:load'?: boolean; + 'client:idle'?: boolean; + 'client:media'?: string; + 'client:visible'?: boolean; +} + +export interface Astro { + isPage: boolean; + fetchContent(globStr: string): Promise[]>; + props: Record; + request: AstroPageRequest; + resolve: (path: string) => string; + site: URL; + slots: Record; +} \ No newline at end of file diff --git a/packages/astro/src/@types/ssr.ts b/packages/astro/src/@types/ssr.ts new file mode 100644 index 000000000..3cb38cfbc --- /dev/null +++ b/packages/astro/src/@types/ssr.ts @@ -0,0 +1,14 @@ +import { Astro as AstroGlobal } from './astro-file'; +import { Renderer } from './astro'; + +export interface SSRMetadata { + importedModules: Record; + renderers: Renderer[]; +} + +export interface SSRResult { + styles: Set; + scripts: Set; + createAstro(props: Record, slots: Record | null): AstroGlobal; + _metadata: SSRMetadata; +} \ No newline at end of file diff --git a/packages/astro/src/internal/index.ts b/packages/astro/src/internal/index.ts index dad19f577..d7df3e37f 100644 --- a/packages/astro/src/internal/index.ts +++ b/packages/astro/src/internal/index.ts @@ -1,4 +1,5 @@ import type { AstroComponentMetadata } from '../@types/astro'; +import type { SSRResult } from '../@types/ssr'; import { valueToEstree } from 'estree-util-value-to-estree'; import * as astring from 'astring'; @@ -141,7 +142,7 @@ export async function renderSlot(result: any, slotted: string, fallback?: any) { return fallback; } -export async function renderComponent(result: any, displayName: string, Component: unknown, _props: Record, slots: any = {}) { +export async function renderComponent(result: SSRResult, displayName: string, Component: unknown, _props: Record, slots: any = {}) { Component = await Component; const children = await renderSlot(result, slots?.default); const { renderers } = result._metadata; @@ -167,9 +168,16 @@ export async function renderComponent(result: any, displayName: string, Componen metadata.hydrateArgs = hydrationDirective[1]; } + const isCustomElement = typeof Component === 'string'; for (const [url, exported] of Object.entries(result._metadata.importedModules)) { for (const [key, value] of Object.entries(exported as any)) { - if (Component === value) { + if(isCustomElement) { + if (key === 'tagName' && Component === value) { + metadata.componentExport = { value: key }; + metadata.componentUrl = url; + break; + } + } else if(Component === value) { metadata.componentExport = { value: key }; metadata.componentUrl = url; break; @@ -194,6 +202,11 @@ export async function renderComponent(result: any, displayName: string, Componen ({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children)); } + if (renderer?.polyfills?.length) { + let polyfillScripts = renderer.polyfills.map((src) => ``).join(''); + html = html + polyfillScripts; + } + if (!hydrationDirective) { return html.replace(/\<\/?astro-fragment\>/g, ''); } diff --git a/packages/astro/src/runtime/ssr.ts b/packages/astro/src/runtime/ssr.ts index cf7d92460..89cfdb3e5 100644 --- a/packages/astro/src/runtime/ssr.ts +++ b/packages/astro/src/runtime/ssr.ts @@ -1,6 +1,8 @@ import type { BuildResult } from 'esbuild'; import type { ViteDevServer } from 'vite'; -import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Props, RouteCache, RouteData, RuntimeMode, SSRError } from '../@types/astro'; +import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Props, Renderer, RouteCache, RouteData, RuntimeMode, SSRError } from '../@types/astro'; +import type { SSRResult } from '../@types/ssr'; +import type { FetchContentResultBase, FetchContentResult } from '../@types/astro-file'; import type { LogOptions } from '../logger'; import cheerio from 'cheerio'; @@ -40,32 +42,38 @@ interface SSROptions { // this prevents client-side errors such as the "double React bug" (https://reactjs.org/warnings/invalid-hook-call-warning.html#mismatching-versions-of-react-and-react-dom) let browserHash: string | undefined; -const cache = new Map(); +const cache = new Map>(); // TODO: improve validation and error handling here. -async function resolveRenderers(viteServer: ViteDevServer, ids: string[]) { +async function resolveRenderer(viteServer: ViteDevServer, renderer: string) { + 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(renderer); + + resolvedRenderer.name = name; + if (client) resolvedRenderer.source = path.posix.join(renderer, client); + 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(path.posix.join(renderer, server)); + const { default: rendererSSR } = await viteServer.ssrLoadModule(url); + resolvedRenderer.ssr = rendererSSR; + + const completedRenderer: Renderer = resolvedRenderer; + return completedRenderer; +} + +async function resolveRenderers(viteServer: ViteDevServer, ids: string[]): Promise { const renderers = await Promise.all( - ids.map(async (renderer) => { - if (cache.has(renderer)) return cache.get(renderer); - - 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(renderer); - - resolvedRenderer.name = name; - if (client) resolvedRenderer.source = path.posix.join(renderer, client); - 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(path.posix.join(renderer, server)); - const { default: rendererSSR } = await viteServer.ssrLoadModule(url); - resolvedRenderer.ssr = rendererSSR; - - cache.set(renderer, resolvedRenderer); - return resolvedRenderer; + ids.map(renderer => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (cache.has(renderer)) return cache.get(renderer)!; + let promise = resolveRenderer(viteServer, renderer); + cache.set(renderer, promise); + return promise; }) ); @@ -118,20 +126,26 @@ async function resolveImportedModules(viteServer: ViteDevServer, file: URL) { /** use Vite to SSR */ export async function ssr({ astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer }: SSROptions): Promise { try { - // 1. load module + // 1. resolve renderers + // Important this happens before load module in case a renderer provides polyfills. + const renderers = await resolveRenderers(viteServer, astroConfig.renderers); + + // 1.5. load module const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; - // 1.5. resolve renderers and imported modules. + // 1.75. resolve renderers // important that this happens _after_ ssrLoadModule, otherwise `importedModules` would be empty - const [renderers, importedModules] = await Promise.all([resolveRenderers(viteServer, astroConfig.renderers), resolveImportedModules(viteServer, filePath)]); + const importedModules = await resolveImportedModules(viteServer, filePath); // 2. handle dynamic routes let params: Params = {}; let pageProps: Props = {}; if (route && !route.pathname) { if (route.params.length) { - const paramsMatch = route.pattern.exec(pathname)!; - params = getParams(route.params)(paramsMatch); + const paramsMatch = route.pattern.exec(pathname); + if(paramsMatch) { + params = getParams(route.params)(paramsMatch); + } } validateGetStaticPathsModule(mod); routeCache[route.component] = @@ -163,11 +177,11 @@ export async function ssr({ astroConfig, filePath, logging, mode, origin, pathna if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`); - const result = { + const result: SSRResult = { styles: new Set(), scripts: new Set(), /** This function returns the `Astro` faux-global */ - createAstro: (props: any, slots: Record | null) => { + createAstro: (props: Record, slots: Record | null) => { const site = new URL(origin); const url = new URL('.' + pathname, site); const canonicalURL = getCanonicalURL(pathname, astroConfig.buildOptions.site || origin); @@ -175,30 +189,46 @@ export async function ssr({ astroConfig, filePath, logging, mode, origin, pathna return { isPage: true, site, - request: { url, canonicalURL }, + request: { + canonicalURL, + params: {}, + url + }, props, fetchContent, slots: Object.fromEntries( Object.entries(slots || {}).map(([slotName]) => [slotName, true]) - ) + ), + // Only temporary to get types working. + resolve(_s: string) { + throw new Error('Astro.resolve() is not currently supported in next.'); + } }; }, _metadata: { importedModules, renderers }, }; - const createFetchContent = (currentFilePath: string) => { + function createFetchContent(currentFilePath: string) { const fetchContentCache = new Map(); - return async (pattern: string) => { + return async function fetchContent(pattern: string): Promise[]> { const cwd = path.dirname(currentFilePath); const cacheKey = `${cwd}:${pattern}`; if (fetchContentCache.has(cacheKey)) { return fetchContentCache.get(cacheKey); } const files = await glob(pattern, { cwd, absolute: true }); - const contents = await Promise.all( + const contents: FetchContentResult[] = await Promise.all( files.map(async (file) => { - const { metadata: astro = {}, frontmatter = {} } = (await viteServer.ssrLoadModule(file)) as any; - return { ...frontmatter, astro }; + const loadedModule = await viteServer.ssrLoadModule(file); + const astro = (loadedModule.metadata || {}) as FetchContentResultBase['astro']; + const frontmatter = loadedModule.frontmatter || {}; + //eslint-disable-next-line no-shadow + const result: FetchContentResult = { + ...frontmatter, + astro, + url: new URL('http://example.com') // TODO fix + }; + return result; }) ); fetchContentCache.set(cacheKey, contents); diff --git a/packages/astro/test/custom-elements.test.js b/packages/astro/test/custom-elements.test.js index 07a57b3ed..2632ab5c8 100644 --- a/packages/astro/test/custom-elements.test.js +++ b/packages/astro/test/custom-elements.test.js @@ -1,8 +1,7 @@ -/** - * UNCOMMENT: add support for custom elements import { expect } from 'chai'; import cheerio from 'cheerio'; import { loadFixture } from './test-utils.js'; +import path from 'path'; let fixture; @@ -49,17 +48,8 @@ describe('Custom Elements', () => { expect($('my-element template[shadowroot=open]')).to.have.lengthOf(1); // Hydration - // test 3: Component URL is included - expect(html).to.include('/src/components/my-element.js'); - }); - - it('Polyfills are added before the hydration script', async () => { - const html = await fixture.readFile('/load/index.html'); - const $ = cheerio.load(html); - + // test 3: Component and polyfill scripts included expect($('script[type=module]')).to.have.lengthOf(2); - expect($('script[type=module]').attr('src')).to.equal('/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js'); - expect($($('script[type=module]').get(1)).html()).to.include('/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/hydration-polyfill.js'); }); it('Polyfills are added even if not hydrating', async () => { @@ -67,10 +57,6 @@ describe('Custom Elements', () => { const $ = cheerio.load(html); expect($('script[type=module]')).to.have.lengthOf(1); - expect($('script[type=module]').attr('src')).to.equal('/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js'); - expect($($('script[type=module]').get(1)).html()).not.to.include( - '/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/hydration-polyfill.js' - ); }); it('Custom elements not claimed by renderer are rendered as regular HTML', async () => { @@ -88,7 +74,4 @@ describe('Custom Elements', () => { // test 1: Element rendered expect($('client-only-element')).to.have.lengthOf(1); }); -}); -*/ - -it.skip('is skipped', () => {}); +}); \ No newline at end of file diff --git a/packages/astro/test/fixtures/custom-elements/my-component-lib/index.js b/packages/astro/test/fixtures/custom-elements/my-component-lib/index.js index 0d1ed17a5..e0c1442f2 100644 --- a/packages/astro/test/fixtures/custom-elements/my-component-lib/index.js +++ b/packages/astro/test/fixtures/custom-elements/my-component-lib/index.js @@ -1,7 +1,6 @@ - export default { name: '@astrojs/test-custom-element-renderer', - server: './server', + server: './server.js', polyfills: [ './polyfill.js' ], diff --git a/packages/astro/test/fixtures/custom-elements/my-component-lib/package.json b/packages/astro/test/fixtures/custom-elements/my-component-lib/package.json index f3f1fb194..bfa0bf3dc 100644 --- a/packages/astro/test/fixtures/custom-elements/my-component-lib/package.json +++ b/packages/astro/test/fixtures/custom-elements/my-component-lib/package.json @@ -3,5 +3,11 @@ "version": "0.0.1", "private": true, "main": "index.js", - "type": "module" + "type": "module", + "exports": { + ".": "./index.js", + "./server.js": "./server.js", + "./polyfill.js": "./polyfill.js", + "./hydration-polyfill.js": "./hydration-polyfill.js" + } } \ No newline at end of file diff --git a/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js b/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js index 0b0b71696..27fdef8d3 100644 --- a/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js +++ b/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js @@ -1 +1,2 @@ -console.log('this is a polyfill'); \ No newline at end of file +console.log('this is a polyfill'); +export default {}; \ No newline at end of file