From 803dd8061df02138b4928442bcb76e77dcf6f5e7 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 5 Jun 2024 11:39:42 +0100 Subject: [PATCH] feat(container): provide a virtual module to load renderers (#11144) * feat(container): provide a virtual module to load renderers * address feedback * chore: restore some default to allow to have PHP prototype working * Thread through renderers and manifest * Pass manifest too * update changeset * add diff * Apply suggestions from code review Co-authored-by: Sarah Rainsberger * fix diff * rebase and update lock --------- Co-authored-by: Matthew Phillips Co-authored-by: Sarah Rainsberger --- .changeset/fair-singers-reflect.md | 30 ++++++++ .changeset/gold-mayflies-beam.md | 36 +++++++++ examples/container-with-vitest/package.json | 4 +- .../test/ReactWrapper.test.ts | 16 ++-- packages/astro/client.d.ts | 4 + packages/astro/src/@types/astro.ts | 16 ++++ packages/astro/src/container/index.ts | 76 +++++++++---------- .../astro/src/virtual-modules/container.ts | 32 ++++++++ packages/integrations/lit/src/index.ts | 9 ++- packages/integrations/mdx/src/index.ts | 9 ++- packages/integrations/preact/src/index.ts | 9 ++- packages/integrations/react/src/index.ts | 15 +++- packages/integrations/solid/src/index.ts | 14 +++- packages/integrations/svelte/src/index.ts | 9 ++- packages/integrations/vue/src/index.ts | 9 ++- pnpm-lock.yaml | 4 +- 16 files changed, 233 insertions(+), 59 deletions(-) create mode 100644 .changeset/fair-singers-reflect.md create mode 100644 .changeset/gold-mayflies-beam.md create mode 100644 packages/astro/src/virtual-modules/container.ts diff --git a/.changeset/fair-singers-reflect.md b/.changeset/fair-singers-reflect.md new file mode 100644 index 000000000000..e18ec42f9cf8 --- /dev/null +++ b/.changeset/fair-singers-reflect.md @@ -0,0 +1,30 @@ +--- +"@astrojs/preact": minor +"@astrojs/svelte": minor +"@astrojs/react": minor +"@astrojs/solid-js": minor +"@astrojs/lit": minor +"@astrojs/mdx": minor +"@astrojs/vue": minor +"astro": patch +--- + +The integration now exposes a function called `getContainerRenderer`, that can be used inside the Container APIs to load the relative renderer. + +```js +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import ReactWrapper from '../src/components/ReactWrapper.astro'; +import { loadRenderers } from "astro:container"; +import { getContainerRenderer } from "@astrojs/react"; + +test('ReactWrapper with react renderer', async () => { + const renderers = await loadRenderers([getContainerRenderer()]) + const container = await AstroContainer.create({ + renderers, + }); + const result = await container.renderToString(ReactWrapper); + + expect(result).toContain('Counter'); + expect(result).toContain('Count: 5'); +}); +``` diff --git a/.changeset/gold-mayflies-beam.md b/.changeset/gold-mayflies-beam.md new file mode 100644 index 000000000000..d500b30b41c6 --- /dev/null +++ b/.changeset/gold-mayflies-beam.md @@ -0,0 +1,36 @@ +--- +"astro": patch +--- + +**BREAKING CHANGE to the experimental Container API only** + +Changes the **type** of the `renderers` option of the `AstroContainer::create` function and adds a dedicated function `loadRenderers()` to load the rendering scripts from renderer integration packages (`@astrojs/react`, `@astrojs/preact`, `@astrojs/solid-js`, `@astrojs/svelte`, `@astrojs/vue`, `@astrojs/lit`, and `@astrojs/mdx`). + +You no longer need to know the individual, direct file paths to the client and server rendering scripts for each renderer integration package. Now, there is a dedicated function to load the renderer from each package, which is available from `getContainerRenderer()`: + +```diff +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import ReactWrapper from '../src/components/ReactWrapper.astro'; +import { loadRenderers } from "astro:container"; +import { getContainerRenderer } from "@astrojs/react"; + +test('ReactWrapper with react renderer', async () => { ++ const renderers = await loadRenderers([getContainerRenderer()]) +- const renderers = [ +- { +- name: '@astrojs/react', +- clientEntrypoint: '@astrojs/react/client.js', +- serverEntrypoint: '@astrojs/react/server.js', +- }, +- ]; + const container = await AstroContainer.create({ + renderers, + }); + const result = await container.renderToString(ReactWrapper); + + expect(result).toContain('Counter'); + expect(result).toContain('Count: 5'); +}); +``` + +The new `loadRenderers()` helper function is available from `astro:container`, a virtual module that can be used when running the Astro container inside `vite`. diff --git a/examples/container-with-vitest/package.json b/examples/container-with-vitest/package.json index 8f07d620c044..e8cb1536a96c 100644 --- a/examples/container-with-vitest/package.json +++ b/examples/container-with-vitest/package.json @@ -12,8 +12,8 @@ "test": "vitest run" }, "dependencies": { - "astro": "^4.9.3", - "@astrojs/react": "^3.4.0", + "astro": "experimental--container", + "@astrojs/react": "experimental--container", "react": "^18.3.1", "react-dom": "^18.3.1", "vitest": "^1.6.0" diff --git a/examples/container-with-vitest/test/ReactWrapper.test.ts b/examples/container-with-vitest/test/ReactWrapper.test.ts index 91e3dd09d994..70b938708332 100644 --- a/examples/container-with-vitest/test/ReactWrapper.test.ts +++ b/examples/container-with-vitest/test/ReactWrapper.test.ts @@ -1,17 +1,15 @@ import { experimental_AstroContainer as AstroContainer } from 'astro/container'; import { expect, test } from 'vitest'; import ReactWrapper from '../src/components/ReactWrapper.astro'; +import { loadRenderers } from 'astro:container'; +import { getContainerRenderer } from '@astrojs/react'; + +const renderers = await loadRenderers([getContainerRenderer()]); +const container = await AstroContainer.create({ + renderers, +}); test('ReactWrapper with react renderer', async () => { - const container = await AstroContainer.create({ - renderers: [ - { - name: '@astrojs/react', - clientEntrypoint: '@astrojs/react/client.js', - serverEntrypoint: '@astrojs/react/server.js', - }, - ], - }); const result = await container.renderToString(ReactWrapper); expect(result).toContain('Counter'); diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index 3083247cfc0f..0870d3dcc566 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -152,6 +152,10 @@ declare module 'astro:i18n' { export * from 'astro/virtual-modules/i18n.js'; } +declare module 'astro:container' { + export * from 'astro/virtual-modules/container.js'; +} + declare module 'astro:middleware' { export * from 'astro/virtual-modules/middleware.js'; } diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 877b77bd9d25..ab1af4048750 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -3290,3 +3290,19 @@ declare global { 'astro:page-load': Event; } } + +// Container types +export type ContainerImportRendererFn = ( + containerRenderer: ContainerRenderer +) => Promise; + +export type ContainerRenderer = { + /** + * The name of the renderer. + */ + name: string; + /** + * The entrypoint that is used to render a component on the server + */ + serverEntrypoint: string; +}; diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index 724c426f39a6..5a260685374b 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -1,8 +1,11 @@ import { posix } from 'node:path'; import type { + AstroConfig, AstroRenderer, AstroUserConfig, ComponentInstance, + ContainerImportRendererFn, + ContainerRenderer, MiddlewareHandler, Props, RouteData, @@ -83,8 +86,8 @@ export type ContainerRenderOptions = { }; function createManifest( - renderers: SSRLoadedRenderer[], manifest?: AstroContainerManifest, + renderers?: SSRLoadedRenderer[], middleware?: MiddlewareHandler ): SSRManifest { const defaultMiddleware: MiddlewareHandler = (_, next) => { @@ -102,7 +105,7 @@ function createManifest( routes: manifest?.routes ?? [], adapterName: '', clientDirectives: manifest?.clientDirectives ?? new Map(), - renderers: manifest?.renderers ?? renderers, + renderers: renderers ?? manifest?.renderers ?? [], base: manifest?.base ?? ASTRO_CONFIG_DEFAULTS.base, componentMetadata: manifest?.componentMetadata ?? new Map(), inlinedScripts: manifest?.inlinedScripts ?? new Map(), @@ -138,21 +141,9 @@ export type AstroContainerOptions = { * @default [] * @description * - * List or renderers to use when rendering components. Usually they are entry points - * - * ## Example - * - * ```js - * const container = await AstroContainer.create({ - * renderers: [{ - * name: "@astrojs/react" - * client: "@astrojs/react/client.js" - * server: "@astrojs/react/server.js" - * }] - * }); - * ``` + * List or renderers to use when rendering components. Usually, you want to pass these in an SSR context. */ - renderers?: AstroRenderer[]; + renderers?: SSRLoadedRenderer[]; /** * @default {} * @description @@ -170,6 +161,17 @@ export type AstroContainerOptions = { * ``` */ astroConfig?: AstroContainerUserConfig; + + // TODO: document out of experimental + resolve?: SSRResult['resolve']; + + /** + * @default {} + * @description + * + * The raw manifest from the build output. + */ + manifest?: SSRManifest; }; type AstroContainerManifest = Pick< @@ -195,6 +197,7 @@ type AstroContainerConstructor = { renderers?: SSRLoadedRenderer[]; manifest?: AstroContainerManifest; resolve?: SSRResult['resolve']; + astroConfig: AstroConfig; }; export class experimental_AstroContainer { @@ -206,24 +209,31 @@ export class experimental_AstroContainer { */ #withManifest = false; + /** + * Internal function responsible for importing a renderer + * @private + */ + #getRenderer: ContainerImportRendererFn | undefined; + private constructor({ streaming = false, - renderers = [], manifest, + renderers, resolve, + astroConfig, }: AstroContainerConstructor) { this.#pipeline = ContainerPipeline.create({ logger: new Logger({ level: 'info', dest: nodeLogDestination, }), - manifest: createManifest(renderers, manifest), + manifest: createManifest(manifest, renderers), streaming, serverLike: true, - renderers, + renderers: renderers ?? manifest?.renderers ?? [], resolve: async (specifier: string) => { if (this.#withManifest) { - return this.#containerResolve(specifier); + return this.#containerResolve(specifier, astroConfig); } else if (resolve) { return resolve(specifier); } @@ -232,10 +242,10 @@ export class experimental_AstroContainer { }); } - async #containerResolve(specifier: string): Promise { + async #containerResolve(specifier: string, astroConfig: AstroConfig): Promise { const found = this.#pipeline.manifest.entryModules[specifier]; if (found) { - return new URL(found, ASTRO_CONFIG_DEFAULTS.build.client).toString(); + return new URL(found, astroConfig.build.client).toString(); } return found; } @@ -248,22 +258,9 @@ export class experimental_AstroContainer { public static async create( containerOptions: AstroContainerOptions = {} ): Promise { - const { streaming = false, renderers = [] } = containerOptions; - const loadedRenderers = await Promise.all( - renderers.map(async (renderer) => { - const mod = await import(renderer.serverEntrypoint); - if (typeof mod.default !== 'undefined') { - return { - ...renderer, - ssr: mod.default, - } as SSRLoadedRenderer; - } - return undefined; - }) - ); - const finalRenderers = loadedRenderers.filter((r): r is SSRLoadedRenderer => Boolean(r)); - - return new experimental_AstroContainer({ streaming, renderers: finalRenderers }); + const { streaming = false, manifest, renderers = [], resolve } = containerOptions; + const astroConfig = await validateConfig(ASTRO_CONFIG_DEFAULTS, process.cwd(), 'container'); + return new experimental_AstroContainer({ streaming, manifest, renderers, astroConfig, resolve }); } // NOTE: we keep this private via TS instead via `#` so it's still available on the surface, so we can play with it. @@ -271,9 +268,10 @@ export class experimental_AstroContainer { private static async createFromManifest( manifest: SSRManifest ): Promise { - const config = await validateConfig(ASTRO_CONFIG_DEFAULTS, process.cwd(), 'container'); + const astroConfig = await validateConfig(ASTRO_CONFIG_DEFAULTS, process.cwd(), 'container'); const container = new experimental_AstroContainer({ manifest, + astroConfig, }); container.#withManifest = true; return container; diff --git a/packages/astro/src/virtual-modules/container.ts b/packages/astro/src/virtual-modules/container.ts new file mode 100644 index 000000000000..a4da62972d62 --- /dev/null +++ b/packages/astro/src/virtual-modules/container.ts @@ -0,0 +1,32 @@ +import type { AstroRenderer, SSRLoadedRenderer } from '../@types/astro.js'; + +/** + * Use this function to provide renderers to the `AstroContainer`: + * + * ```js + * import { getContainerRenderer } from "@astrojs/react"; + * import { experimental_AstroContainer as AstroContainer } from "astro/container"; + * import { loadRenderers } from "astro:container"; // use this only when using vite/vitest + * + * const renderers = await loadRenderers([ getContainerRenderer ]); + * const container = await AstroContainer.create({ renderers }); + * + * ``` + * @param renderers + */ +export async function loadRenderers(renderers: AstroRenderer[]) { + const loadedRenderers = await Promise.all( + renderers.map(async (renderer) => { + const mod = await import(renderer.serverEntrypoint); + if (typeof mod.default !== 'undefined') { + return { + ...renderer, + ssr: mod.default, + } as SSRLoadedRenderer; + } + return undefined; + }) + ); + + return loadedRenderers.filter((r): r is SSRLoadedRenderer => Boolean(r)); +} diff --git a/packages/integrations/lit/src/index.ts b/packages/integrations/lit/src/index.ts index 6c86bd740d2b..33aaf727d918 100644 --- a/packages/integrations/lit/src/index.ts +++ b/packages/integrations/lit/src/index.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import type { AstroIntegration } from 'astro'; +import type { AstroIntegration, ContainerRenderer } from 'astro'; function getViteConfiguration() { return { @@ -19,6 +19,13 @@ function getViteConfiguration() { }; } +export function getContainerRenderer(): ContainerRenderer { + return { + name: '@astrojs/lit', + serverEntrypoint: '@astrojs/lit/server.js', + }; +} + export default function (): AstroIntegration { return { name: '@astrojs/lit', diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index 3aaed8787585..bd0278933aea 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { markdownConfigDefaults } from '@astrojs/markdown-remark'; -import type { AstroIntegration, ContentEntryType, HookParameters } from 'astro'; +import type { AstroIntegration, ContainerRenderer, ContentEntryType, HookParameters } from 'astro'; import astroJSXRenderer from 'astro/jsx/renderer.js'; import type { Options as RemarkRehypeOptions } from 'remark-rehype'; import type { PluggableList } from 'unified'; @@ -28,6 +28,13 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & { addContentEntryType: (contentEntryType: ContentEntryType) => void; }; +export function getContainerRenderer(): ContainerRenderer { + return { + name: 'astro:jsx', + serverEntrypoint: 'astro/jsx/server.js', + }; +} + export default function mdx(partialMdxOptions: Partial = {}): AstroIntegration { // @ts-expect-error Temporarily assign an empty object here, which will be re-assigned by the // `astro:config:done` hook later. This is so that `vitePluginMdx` can get hold of a reference earlier. diff --git a/packages/integrations/preact/src/index.ts b/packages/integrations/preact/src/index.ts index bcca01dd0b33..aab4f9925fbc 100644 --- a/packages/integrations/preact/src/index.ts +++ b/packages/integrations/preact/src/index.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url'; import { type PreactPluginOptions as VitePreactPluginOptions, preact } from '@preact/preset-vite'; -import type { AstroIntegration, AstroRenderer, ViteUserConfig } from 'astro'; +import type { AstroIntegration, AstroRenderer, ContainerRenderer, ViteUserConfig } from 'astro'; const babelCwd = new URL('../', import.meta.url); @@ -12,6 +12,13 @@ function getRenderer(development: boolean): AstroRenderer { }; } +export function getContainerRenderer(): ContainerRenderer { + return { + name: '@astrojs/preact', + serverEntrypoint: '@astrojs/preact/server.js', + }; +} + export interface Options extends Pick { compat?: boolean; devtools?: boolean; diff --git a/packages/integrations/react/src/index.ts b/packages/integrations/react/src/index.ts index 838640239ee3..85d79eef88a6 100644 --- a/packages/integrations/react/src/index.ts +++ b/packages/integrations/react/src/index.ts @@ -1,5 +1,5 @@ import react, { type Options as ViteReactPluginOptions } from '@vitejs/plugin-react'; -import type { AstroIntegration } from 'astro'; +import type { AstroIntegration, ContainerRenderer } from 'astro'; import { version as ReactVersion } from 'react-dom'; import type * as vite from 'vite'; @@ -53,6 +53,19 @@ function getRenderer(reactConfig: ReactVersionConfig) { }; } +export function getContainerRenderer(): ContainerRenderer { + const majorVersion = getReactMajorVersion(); + if (isUnsupportedVersion(majorVersion)) { + throw new Error(`Unsupported React version: ${majorVersion}.`); + } + const versionConfig = versionsConfig[majorVersion as SupportedReactVersion]; + + return { + name: '@astrojs/react', + serverEntrypoint: versionConfig.server, + }; +} + function optionsPlugin(experimentalReactChildren: boolean): vite.Plugin { const virtualModule = 'astro:react:opts'; const virtualModuleId = '\0' + virtualModule; diff --git a/packages/integrations/solid/src/index.ts b/packages/integrations/solid/src/index.ts index a779dea6083e..1bbfa741f078 100644 --- a/packages/integrations/solid/src/index.ts +++ b/packages/integrations/solid/src/index.ts @@ -1,4 +1,9 @@ -import type { AstroIntegration, AstroIntegrationLogger, AstroRenderer } from 'astro'; +import type { + AstroIntegration, + AstroIntegrationLogger, + AstroRenderer, + ContainerRenderer, +} from 'astro'; import type { PluginOption, UserConfig } from 'vite'; import solid, { type Options as ViteSolidPluginOptions } from 'vite-plugin-solid'; @@ -94,6 +99,13 @@ function getRenderer(): AstroRenderer { }; } +export function getContainerRenderer(): ContainerRenderer { + return { + name: '@astrojs/solid', + serverEntrypoint: '@astrojs/solid-js/server.js', + }; +} + export interface Options extends Pick { devtools?: boolean; } diff --git a/packages/integrations/svelte/src/index.ts b/packages/integrations/svelte/src/index.ts index 9c38b9d0518f..b0db3505c83a 100644 --- a/packages/integrations/svelte/src/index.ts +++ b/packages/integrations/svelte/src/index.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from 'node:url'; import type { Options } from '@sveltejs/vite-plugin-svelte'; import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte'; -import type { AstroIntegration, AstroRenderer } from 'astro'; +import type { AstroIntegration, AstroRenderer, ContainerRenderer } from 'astro'; import { VERSION } from 'svelte/compiler'; import type { UserConfig } from 'vite'; @@ -15,6 +15,13 @@ function getRenderer(): AstroRenderer { }; } +export function getContainerRenderer(): ContainerRenderer { + return { + name: '@astrojs/svelte', + serverEntrypoint: isSvelte5 ? '@astrojs/svelte/server-v5.js' : '@astrojs/svelte/server.js', + }; +} + async function svelteConfigHasPreprocess(root: URL) { const svelteConfigFiles = ['./svelte.config.js', './svelte.config.cjs', './svelte.config.mjs']; for (const file of svelteConfigFiles) { diff --git a/packages/integrations/vue/src/index.ts b/packages/integrations/vue/src/index.ts index 6edb82526fa4..81afe3a2eb5d 100644 --- a/packages/integrations/vue/src/index.ts +++ b/packages/integrations/vue/src/index.ts @@ -3,7 +3,7 @@ import type { Options as VueOptions } from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue'; import type { Options as VueJsxOptions } from '@vitejs/plugin-vue-jsx'; import { MagicString } from '@vue/compiler-sfc'; -import type { AstroIntegration, AstroRenderer, HookParameters } from 'astro'; +import type { AstroIntegration, AstroRenderer, ContainerRenderer, HookParameters } from 'astro'; import type { Plugin, UserConfig } from 'vite'; import type { VitePluginVueDevToolsOptions } from 'vite-plugin-vue-devtools'; @@ -32,6 +32,13 @@ function getJsxRenderer(): AstroRenderer { }; } +export function getContainerRenderer(): ContainerRenderer { + return { + name: '@astrojs/vue', + serverEntrypoint: '@astrojs/vue/server.js', + }; +} + function virtualAppEntrypoint(options?: Options): Plugin { let isBuild: boolean; let root: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d24ea4f9487..72df592a74e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,10 +155,10 @@ importers: examples/container-with-vitest: dependencies: '@astrojs/react': - specifier: ^3.4.0 + specifier: experimental--container version: link:../../packages/integrations/react astro: - specifier: ^4.9.3 + specifier: experimental--container version: link:../../packages/astro react: specifier: ^18.3.1