diff --git a/.changeset/brave-colts-cover.md b/.changeset/brave-colts-cover.md
new file mode 100644
index 0000000000..1819588021
--- /dev/null
+++ b/.changeset/brave-colts-cover.md
@@ -0,0 +1,35 @@
+---
+"astro": minor
+---
+
+Introduces an experimental Container API to render `.astro` components in isolation.
+
+This API introduces three new functions to allow you to create a new container and render an Astro component returning either a string or a Response:
+
+- `create()`: creates a new instance of the container.
+- `renderToString()`: renders a component and return a string.
+- `renderToResponse()`: renders a component and returns the `Response` emitted by the rendering phase.
+
+The first supported use of this new API is to enable unit testing. For example, with `vitest`, you can create a container to render your component with test data and check the result:
+
+```js
+import { experimental_AstroContainer as AstroContainer } from 'astro/container';
+import { expect, test } from 'vitest';
+import Card from '../src/components/Card.astro';
+
+test('Card with slots', async () => {
+ const container = await AstroContainer.create();
+ const result = await container.renderToString(Card, {
+ slots: {
+ default: 'Card content',
+ },
+ });
+
+ expect(result).toContain('This is a card');
+ expect(result).toContain('Card content');
+});
+```
+
+For a complete reference, see the [Container API docs](/en/reference/container-reference/).
+
+For a feature overview, and to give feedback on this experimental API, see the [Container API roadmap discussion](https://github.com/withastro/roadmap/pull/916).
diff --git a/examples/container-with-vitest/.codesandbox/Dockerfile b/examples/container-with-vitest/.codesandbox/Dockerfile
new file mode 100644
index 0000000000..c3b5c81a12
--- /dev/null
+++ b/examples/container-with-vitest/.codesandbox/Dockerfile
@@ -0,0 +1 @@
+FROM node:18-bullseye
diff --git a/examples/container-with-vitest/.gitignore b/examples/container-with-vitest/.gitignore
new file mode 100644
index 0000000000..16d54bb13c
--- /dev/null
+++ b/examples/container-with-vitest/.gitignore
@@ -0,0 +1,24 @@
+# build output
+dist/
+# generated types
+.astro/
+
+# dependencies
+node_modules/
+
+# logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+
+# environment variables
+.env
+.env.production
+
+# macOS-specific files
+.DS_Store
+
+# jetbrains setting folder
+.idea/
diff --git a/examples/container-with-vitest/README.md b/examples/container-with-vitest/README.md
new file mode 100644
index 0000000000..116268944c
--- /dev/null
+++ b/examples/container-with-vitest/README.md
@@ -0,0 +1,11 @@
+# Astro + [Vitest](https://vitest.dev/) + Container API Example
+
+```sh
+npm create astro@latest -- --template container-with-vitest
+```
+
+[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/with-vitest)
+[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/with-vitest)
+[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/with-vitest/devcontainer.json)
+
+This example showcases Astro working with [Vitest](https://vitest.dev/) and how to test components using the Container API.
diff --git a/examples/container-with-vitest/astro.config.ts b/examples/container-with-vitest/astro.config.ts
new file mode 100644
index 0000000000..17257d4f19
--- /dev/null
+++ b/examples/container-with-vitest/astro.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'astro/config';
+import react from "@astrojs/react"
+
+// https://astro.build/config
+export default defineConfig({
+ integrations: [react()]
+});
diff --git a/examples/container-with-vitest/package.json b/examples/container-with-vitest/package.json
new file mode 100644
index 0000000000..9885ba7182
--- /dev/null
+++ b/examples/container-with-vitest/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@example/container-with-vitest",
+ "type": "module",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "astro dev",
+ "start": "astro dev",
+ "build": "astro build",
+ "preview": "astro preview",
+ "astro": "astro",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "astro": "experimental--container",
+ "@astrojs/react": "^3.3.4",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "vitest": "^1.6.0"
+ },
+ "devDependencies": {
+ "@types/react-dom": "^18.3.0",
+ "@types/react": "^18.3.2"
+ }
+}
diff --git a/examples/container-with-vitest/public/favicon.svg b/examples/container-with-vitest/public/favicon.svg
new file mode 100644
index 0000000000..f157bd1c5e
--- /dev/null
+++ b/examples/container-with-vitest/public/favicon.svg
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/examples/container-with-vitest/src/components/Card.astro b/examples/container-with-vitest/src/components/Card.astro
new file mode 100644
index 0000000000..776c823292
--- /dev/null
+++ b/examples/container-with-vitest/src/components/Card.astro
@@ -0,0 +1,7 @@
+---
+
+---
+
+ This is a card
+
+
diff --git a/examples/container-with-vitest/src/components/Counter.jsx b/examples/container-with-vitest/src/components/Counter.jsx
new file mode 100644
index 0000000000..2148bf3d8f
--- /dev/null
+++ b/examples/container-with-vitest/src/components/Counter.jsx
@@ -0,0 +1,14 @@
+import { useState } from 'react';
+
+export default function({ initialCount }) {
+ const [count, setCount] = useState(initialCount || 0);
+ return (
+
+
Counter
+ Count: {count}
+ setCount(count + 1)}>Increment
+
+ )
+}
diff --git a/examples/container-with-vitest/src/components/ReactWrapper.astro b/examples/container-with-vitest/src/components/ReactWrapper.astro
new file mode 100644
index 0000000000..73ac6baebd
--- /dev/null
+++ b/examples/container-with-vitest/src/components/ReactWrapper.astro
@@ -0,0 +1,5 @@
+---
+import Counter from './Counter.jsx';
+---
+
+
diff --git a/examples/container-with-vitest/src/pages/[locale].astro b/examples/container-with-vitest/src/pages/[locale].astro
new file mode 100644
index 0000000000..55e5c186a6
--- /dev/null
+++ b/examples/container-with-vitest/src/pages/[locale].astro
@@ -0,0 +1,22 @@
+---
+export function getStaticPaths() {
+ return [
+ {params: {locale: 'en'}},
+ ];
+}
+const { locale } = Astro.params
+---
+
+
+
+
+
+
+
+ Astro
+
+
+ Astro
+ Locale: {locale}
+
+
diff --git a/examples/container-with-vitest/src/pages/api.ts b/examples/container-with-vitest/src/pages/api.ts
new file mode 100644
index 0000000000..c30def5bba
--- /dev/null
+++ b/examples/container-with-vitest/src/pages/api.ts
@@ -0,0 +1,11 @@
+export function GET() {
+ const json = {
+ foo: 'bar',
+ number: 1,
+ };
+ return new Response(JSON.stringify(json), {
+ headers: {
+ 'content-type': 'application/json',
+ },
+ });
+}
diff --git a/examples/container-with-vitest/src/pages/index.astro b/examples/container-with-vitest/src/pages/index.astro
new file mode 100644
index 0000000000..2d14107362
--- /dev/null
+++ b/examples/container-with-vitest/src/pages/index.astro
@@ -0,0 +1,16 @@
+---
+
+---
+
+
+
+
+
+
+
+ Astro
+
+
+ Astro
+
+
diff --git a/examples/container-with-vitest/test/Card.test.ts b/examples/container-with-vitest/test/Card.test.ts
new file mode 100644
index 0000000000..26d766d1a8
--- /dev/null
+++ b/examples/container-with-vitest/test/Card.test.ts
@@ -0,0 +1,15 @@
+import { experimental_AstroContainer as AstroContainer } from 'astro/container';
+import { expect, test } from 'vitest';
+import Card from '../src/components/Card.astro';
+
+test('Card with slots', async () => {
+ const container = await AstroContainer.create();
+ const result = await container.renderToString(Card, {
+ slots: {
+ default: 'Card content',
+ },
+ });
+
+ expect(result).toContain('This is a card');
+ expect(result).toContain('Card content');
+});
diff --git a/examples/container-with-vitest/test/ReactWrapper.test.ts b/examples/container-with-vitest/test/ReactWrapper.test.ts
new file mode 100644
index 0000000000..2f21d85966
--- /dev/null
+++ b/examples/container-with-vitest/test/ReactWrapper.test.ts
@@ -0,0 +1,19 @@
+import { experimental_AstroContainer as AstroContainer } from 'astro/container';
+import { expect, test } from 'vitest';
+import ReactWrapper from '../src/components/ReactWrapper.astro';
+
+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');
+ expect(result).toContain('Count: 5');
+});
diff --git a/examples/container-with-vitest/test/[locale].test.ts b/examples/container-with-vitest/test/[locale].test.ts
new file mode 100644
index 0000000000..f58a26c49b
--- /dev/null
+++ b/examples/container-with-vitest/test/[locale].test.ts
@@ -0,0 +1,16 @@
+import { experimental_AstroContainer as AstroContainer } from 'astro/container';
+import { expect, test } from 'vitest';
+import Locale from '../src/pages/[locale].astro';
+
+test('Dynamic route', async () => {
+ const container = await AstroContainer.create();
+ // @ts-ignore
+ const result = await container.renderToString(Locale, {
+ params: {
+ "locale": 'en'
+ },
+ request: new Request('http://example.com/en'),
+ });
+
+ expect(result).toContain('Locale: en');
+});
diff --git a/examples/container-with-vitest/tsconfig.json b/examples/container-with-vitest/tsconfig.json
new file mode 100644
index 0000000000..d78f81ec4e
--- /dev/null
+++ b/examples/container-with-vitest/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "astro/tsconfigs/base"
+}
diff --git a/examples/container-with-vitest/vitest.config.ts b/examples/container-with-vitest/vitest.config.ts
new file mode 100644
index 0000000000..a34f19bb10
--- /dev/null
+++ b/examples/container-with-vitest/vitest.config.ts
@@ -0,0 +1,9 @@
+///
+import { getViteConfig } from 'astro/config';
+
+export default getViteConfig({
+ test: {
+ /* for example, use global to avoid globals imports (describe, test, expect): */
+ // globals: true,
+ },
+});
diff --git a/packages/astro/package.json b/packages/astro/package.json
index ce0967346a..8d276aa8a2 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -48,6 +48,10 @@
"types": "./config.d.ts",
"default": "./config.mjs"
},
+ "./container": {
+ "types": "./dist/container/index.d.ts",
+ "default": "./dist/container/index.js"
+ },
"./app": "./dist/core/app/index.js",
"./app/node": "./dist/core/app/node.js",
"./client/*": "./dist/runtime/client/*",
diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts
new file mode 100644
index 0000000000..50277dbf04
--- /dev/null
+++ b/packages/astro/src/container/index.ts
@@ -0,0 +1,416 @@
+import type {
+ ComponentInstance,
+ MiddlewareHandler,
+ RouteData,
+ RouteType,
+ SSRLoadedRenderer,
+ SSRManifest,
+ SSRResult,
+ AstroUserConfig,
+ AstroRenderer,
+} from '../@types/astro.js';
+import { ContainerPipeline } from './pipeline.js';
+import { Logger } from '../core/logger/core.js';
+import { nodeLogDestination } from '../core/logger/node.js';
+import { validateConfig } from '../core/config/config.js';
+import { ASTRO_CONFIG_DEFAULTS } from '../core/config/schema.js';
+import { RenderContext } from '../core/render-context.js';
+import { posix } from 'node:path';
+import { getParts, getPattern, validateSegment } from '../core/routing/manifest/create.js';
+import { removeLeadingForwardSlash } from '../core/path.js';
+import type {AstroComponentFactory} from "../runtime/server/index.js";
+
+/**
+ * Options to be passed when rendering a route
+ */
+export type ContainerRenderOptions = {
+ /**
+ * If your component renders slots, that's where you want to fill the slots.
+ * A single slot should have the `default` field:
+ *
+ * ## Examples
+ *
+ * **Default slot**
+ *
+ * ```js
+ * container.renderToString(Component, { slots: { default: "Some value"}});
+ * ```
+ *
+ * **Named slots**
+ *
+ * ```js
+ * container.renderToString(Component, { slots: { "foo": "Some value", "bar": "Lorem Ipsum" }});
+ * ```
+ */
+ slots?: Record;
+ /**
+ * The request is used to understand which path/URL the component is about to render.
+ *
+ * Use this option in case your component or middleware needs to read information like `Astro.url` or `Astro.request`.
+ */
+ request?: Request;
+ /**
+ * Useful for dynamic routes. If your component is something like `src/pages/blog/[id]/[...slug]`, you'll want to provide:
+ * ```js
+ * container.renderToString(Component, { params: ["id", "...slug"] });
+ * ```
+ */
+ params?: Record;
+ /**
+ * Useful if your component needs to access some locals without the use a middleware.
+ * ```js
+ * container.renderToString(Component, { locals: { getSomeValue() {} } });
+ * ```
+ */
+ locals?: App.Locals;
+ /**
+ * Useful in case you're attempting to render an endpoint:
+ * ```js
+ * container.renderToString(Endpoint, { routeType: "endpoint" });
+ * ```
+ */
+ routeType?: RouteType;
+};
+
+function createManifest(
+ renderers: SSRLoadedRenderer[],
+ manifest?: AstroContainerManifest,
+ middleware?: MiddlewareHandler
+): SSRManifest {
+ const defaultMiddleware: MiddlewareHandler = (_, next) => {
+ return next();
+ };
+
+ return {
+ rewritingEnabled: false,
+ trailingSlash: manifest?.trailingSlash ?? ASTRO_CONFIG_DEFAULTS.trailingSlash ,
+ buildFormat: manifest?.buildFormat ?? ASTRO_CONFIG_DEFAULTS.build.format,
+ compressHTML: manifest?.compressHTML ?? ASTRO_CONFIG_DEFAULTS.compressHTML,
+ assets: manifest?.assets ?? new Set(),
+ assetsPrefix: manifest?.assetsPrefix ?? undefined,
+ entryModules: manifest?.entryModules ?? {},
+ routes: manifest?.routes ?? [],
+ adapterName: '',
+ clientDirectives: manifest?.clientDirectives ?? new Map(),
+ renderers: manifest?.renderers ?? renderers,
+ base: manifest?.base ?? ASTRO_CONFIG_DEFAULTS.base,
+ componentMetadata: manifest?.componentMetadata ?? new Map(),
+ inlinedScripts: manifest?.inlinedScripts ?? new Map(),
+ i18n: manifest?.i18n,
+ checkOrigin: false,
+ middleware: manifest?.middleware ?? middleware ?? defaultMiddleware,
+ };
+}
+
+export type AstroContainerUserConfig = Omit
+
+/**
+ * Options that are used for the entire lifecycle of the current instance of the container.
+ */
+export type AstroContainerOptions = {
+ /**
+ * @default false
+ *
+ * @description
+ *
+ * Enables streaming during rendering
+ *
+ * ## Example
+ *
+ * ```js
+ * const container = await AstroContainer.create({
+ * streaming: true
+ * });
+ * ```
+ */
+ streaming?: boolean;
+ /**
+ * @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"
+ * }]
+ * });
+ * ```
+ */
+ renderers?: AstroRenderer[];
+ /**
+ * @default {}
+ * @description
+ *
+ * A subset of the astro configuration object.
+ *
+ * ## Example
+ *
+ * ```js
+ * const container = await AstroContainer.create({
+ * astroConfig: {
+ * trailingSlash: "never"
+ * }
+ * });
+ * ```
+ */
+ astroConfig?: AstroContainerUserConfig;
+};
+
+type AstroContainerManifest = Pick<
+ SSRManifest,
+ | 'middleware'
+ | 'clientDirectives'
+ | 'inlinedScripts'
+ | 'componentMetadata'
+ | 'renderers'
+ | 'assetsPrefix'
+ | 'base'
+ | 'routes'
+ | 'assets'
+ | 'entryModules'
+ | 'compressHTML'
+ | 'trailingSlash'
+ | 'buildFormat'
+ | 'i18n'
+>;
+
+type AstroContainerConstructor = {
+ streaming?: boolean;
+ renderers?: SSRLoadedRenderer[];
+ manifest?: AstroContainerManifest;
+ resolve?: SSRResult['resolve'];
+};
+
+export class experimental_AstroContainer {
+ #pipeline: ContainerPipeline;
+
+ /**
+ * Internally used to check if the container was created with a manifest.
+ * @private
+ */
+ #withManifest = false;
+
+ private constructor({
+ streaming = false,
+ renderers = [],
+ manifest,
+ resolve,
+ }: AstroContainerConstructor) {
+ this.#pipeline = ContainerPipeline.create({
+ logger: new Logger({
+ level: 'info',
+ dest: nodeLogDestination,
+ }),
+ manifest: createManifest(renderers, manifest),
+ streaming,
+ serverLike: true,
+ renderers,
+ resolve: async (specifier: string) => {
+ if (this.#withManifest) {
+ return this.#containerResolve(specifier);
+ } else if (resolve) {
+ return resolve(specifier);
+ }
+ return specifier;
+ },
+ });
+ }
+
+ async #containerResolve(specifier: string): Promise {
+ const found = this.#pipeline.manifest.entryModules[specifier];
+ if (found) {
+ return new URL(found, ASTRO_CONFIG_DEFAULTS.build.client).toString();
+ }
+ return found;
+ }
+
+ /**
+ * Creates a new instance of a container.
+ *
+ * @param {AstroContainerOptions=} containerOptions
+ */
+ 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 });
+ }
+
+ // NOTE: we keep this private via TS instead via `#` so it's still available on the surface, so we can play with it.
+ // @ematipico: I plan to use it for a possible integration that could help people
+ private static async createFromManifest(manifest: SSRManifest): Promise {
+ const config = await validateConfig(ASTRO_CONFIG_DEFAULTS, process.cwd(), 'container');
+ const container = new experimental_AstroContainer({
+ manifest,
+ });
+ container.#withManifest = true;
+ return container;
+ }
+
+ #insertRoute({
+ path,
+ componentInstance,
+ params = {},
+ type = 'page',
+ }: {
+ path: string;
+ componentInstance: ComponentInstance;
+ route?: string,
+ params?: Record;
+ type?: RouteType;
+ }): RouteData {
+ const pathUrl = new URL(path, 'https://example.com');
+ const routeData: RouteData = this.#createRoute(pathUrl,
+ params, type);
+ this.#pipeline.manifest.routes.push({
+ routeData,
+ file: '',
+ links: [],
+ styles: [],
+ scripts: [],
+ });
+ this.#pipeline.insertRoute(routeData, componentInstance);
+ return routeData;
+ }
+
+ /**
+ * @description
+ * It renders a component and returns the result as a string.
+ *
+ * ## Example
+ *
+ * ```js
+ * import Card from "../src/components/Card.astro";
+ *
+ * const container = await AstroContainer.create();
+ * const result = await container.renderToString(Card);
+ *
+ * console.log(result); // it's a string
+ * ```
+ *
+ *
+ * @param {AstroComponentFactory} component The instance of the component.
+ * @param {ContainerRenderOptions=} options Possible options to pass when rendering the component.
+ */
+ public async renderToString(
+ component: AstroComponentFactory,
+ options: ContainerRenderOptions = {}
+ ): Promise {
+ const response = await this.renderToResponse(component, options);
+ return await response.text();
+ }
+
+ /**
+ * @description
+ * It renders a component and returns the `Response` as result of the rendering phase.
+ *
+ * ## Example
+ *
+ * ```js
+ * import Card from "../src/components/Card.astro";
+ *
+ * const container = await AstroContainer.create();
+ * const response = await container.renderToResponse(Card);
+ *
+ * console.log(response.status); // it's a number
+ * ```
+ *
+ *
+ * @param {AstroComponentFactory} component The instance of the component.
+ * @param {ContainerRenderOptions=} options Possible options to pass when rendering the component.
+ */
+ public async renderToResponse(
+ component: AstroComponentFactory,
+ options: ContainerRenderOptions = {}
+ ): Promise {
+ const { routeType = 'page', slots } = options;
+ const request = options?.request ?? new Request('https://example.com/');
+ const url = new URL(request.url);
+ const componentInstance = routeType === "endpoint" ? component as unknown as ComponentInstance : this.#wrapComponent(component, options.params);
+ const routeData = this.#insertRoute({
+ path: request.url,
+ componentInstance,
+ params: options.params,
+ type: routeType,
+ });
+ const renderContext = RenderContext.create({
+ pipeline: this.#pipeline,
+ routeData,
+ status: 200,
+ middleware: this.#pipeline.middleware,
+ request,
+ pathname: url.pathname,
+ locals: options?.locals ?? {},
+ });
+ if (options.params) {
+ renderContext.params = options.params;
+ }
+
+ return renderContext.render(componentInstance, slots);
+ }
+
+ #createRoute(url: URL, params: Record, type: RouteType): RouteData {
+ const segments = removeLeadingForwardSlash(url.pathname)
+ .split(posix.sep)
+ .filter(Boolean)
+ .map((s: string) => {
+ validateSegment(s);
+ return getParts(s, url.pathname);
+ });
+ return {
+ route: url.pathname,
+ component: '',
+ generate(_data: any): string {
+ return '';
+ },
+ params: Object.keys(params),
+ pattern: getPattern(segments, ASTRO_CONFIG_DEFAULTS.base, ASTRO_CONFIG_DEFAULTS.trailingSlash),
+ prerender: false,
+ segments,
+ type,
+ fallbackRoutes: [],
+ isIndex: false,
+ };
+ }
+
+ /**
+ * If the provided component isn't a default export, the function wraps it in an object `{default: Component }` to mimic the default export.
+ * @param componentFactory
+ * @param params
+ * @private
+ */
+ #wrapComponent(componentFactory: AstroComponentFactory, params?: Record): ComponentInstance {
+ if (params) {
+ return {
+ default: componentFactory,
+ getStaticPaths() {
+ return [{ params }];
+ }
+ }
+ }
+ return ({ default: componentFactory })
+ }
+}
diff --git a/packages/astro/src/container/pipeline.ts b/packages/astro/src/container/pipeline.ts
new file mode 100644
index 0000000000..5e76fad21d
--- /dev/null
+++ b/packages/astro/src/container/pipeline.ts
@@ -0,0 +1,115 @@
+import { type HeadElements, Pipeline } from '../core/base-pipeline.js';
+import type {
+ ComponentInstance,
+ RewritePayload,
+ RouteData,
+ SSRElement,
+ SSRResult,
+} from '../@types/astro.js';
+import {
+ createModuleScriptElement,
+ createStylesheetElementSet,
+} from '../core/render/ssr-element.js';
+import { AstroError } from '../core/errors/index.js';
+import { RouteNotFound } from '../core/errors/errors-data.js';
+import type { SinglePageBuiltModule } from '../core/build/types.js';
+
+export class ContainerPipeline extends Pipeline {
+ /**
+ * Internal cache to store components instances by `RouteData`.
+ * @private
+ */
+ #componentsInterner: WeakMap = new WeakMap<
+ RouteData,
+ SinglePageBuiltModule
+ >();
+
+ static create({
+ logger,
+ manifest,
+ renderers,
+ resolve,
+ serverLike,
+ streaming,
+ }: Pick<
+ ContainerPipeline,
+ 'logger' | 'manifest' | 'renderers' | 'resolve' | 'serverLike' | 'streaming'
+ >) {
+ return new ContainerPipeline(
+ logger,
+ manifest,
+ 'development',
+ renderers,
+ resolve,
+ serverLike,
+ streaming
+ );
+ }
+
+ componentMetadata(_routeData: RouteData): Promise | void {}
+
+ headElements(routeData: RouteData): Promise | HeadElements {
+ const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData);
+ const links = new Set();
+ const scripts = new Set();
+ const styles = createStylesheetElementSet(routeInfo?.styles ?? []);
+
+ for (const script of routeInfo?.scripts ?? []) {
+ if ('stage' in script) {
+ if (script.stage === 'head-inline') {
+ scripts.add({
+ props: {},
+ children: script.children,
+ });
+ }
+ } else {
+ scripts.add(createModuleScriptElement(script));
+ }
+ }
+ return { links, styles, scripts };
+ }
+
+ async tryRewrite(rewritePayload: RewritePayload): Promise<[RouteData, ComponentInstance]> {
+ let foundRoute: RouteData | undefined;
+ // options.manifest is the actual type that contains the information
+ for (const route of this.manifest.routes) {
+ const routeData = route.routeData;
+ if (rewritePayload instanceof URL) {
+ if (routeData.pattern.test(rewritePayload.pathname)) {
+ foundRoute = routeData;
+ break;
+ }
+ } else if (rewritePayload instanceof Request) {
+ const url = new URL(rewritePayload.url);
+ if (routeData.pattern.test(url.pathname)) {
+ foundRoute = routeData;
+ break;
+ }
+ } else if (routeData.pattern.test(decodeURI(rewritePayload))) {
+ foundRoute = routeData;
+ break;
+ }
+ }
+ if (foundRoute) {
+ const componentInstance = await this.getComponentByRoute(foundRoute);
+ return [foundRoute, componentInstance];
+ } else {
+ throw new AstroError(RouteNotFound);
+ }
+ }
+
+ insertRoute(route: RouteData, componentInstance: ComponentInstance): void {
+ this.#componentsInterner.set(route, {
+ page() {
+ return Promise.resolve(componentInstance);
+ },
+ renderers: this.manifest.renderers,
+ onRequest: this.manifest.middleware,
+ });
+ }
+
+ // At the moment it's not used by the container via any public API
+ // @ts-expect-error It needs to be implemented.
+ async getComponentByRoute(_routeData: RouteData): Promise {
+ }
+}
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index ca795b91da..a6f0a46ec3 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -45,7 +45,7 @@ type RehypePlugin = ComplexifyWithUnion<_RehypePlugin>;
type RemarkPlugin = ComplexifyWithUnion<_RemarkPlugin>;
type RemarkRehype = ComplexifyWithOmit<_RemarkRehype>;
-const ASTRO_CONFIG_DEFAULTS = {
+export const ASTRO_CONFIG_DEFAULTS = {
root: '.',
srcDir: './src',
publicDir: './public',
diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts
index 4a5cad1da5..d1abb8a160 100644
--- a/packages/astro/src/core/render-context.ts
+++ b/packages/astro/src/core/render-context.ts
@@ -91,7 +91,10 @@ export class RenderContext {
* - endpoint
* - fallback
*/
- async render(componentInstance: ComponentInstance | undefined): Promise {
+ async render(
+ componentInstance: ComponentInstance | undefined,
+ slots: Record = {}
+ ): Promise {
const { cookies, middleware, pathname, pipeline } = this;
const { logger, routeCache, serverLike, streaming } = pipeline;
const props = await getProps({
@@ -148,7 +151,7 @@ export class RenderContext {
result,
componentInstance?.default as any,
props,
- {},
+ slots,
streaming,
this.routeData
);
diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts
index 6c3010ad6f..4a36c8536d 100644
--- a/packages/astro/src/core/routing/manifest/create.ts
+++ b/packages/astro/src/core/routing/manifest/create.ts
@@ -48,7 +48,7 @@ function countOccurrences(needle: string, haystack: string) {
const ROUTE_DYNAMIC_SPLIT = /\[(.+?\(.+?\)|.+?)\]/;
const ROUTE_SPREAD = /^\.{3}.+$/;
-function getParts(part: string, file: string) {
+export function getParts(part: string, file: string) {
const result: RoutePart[] = [];
part.split(ROUTE_DYNAMIC_SPLIT).map((str, i) => {
if (!str) return;
@@ -70,12 +70,11 @@ function getParts(part: string, file: string) {
return result;
}
-function getPattern(
+export function getPattern(
segments: RoutePart[][],
- config: AstroConfig,
+ base: AstroConfig['base'],
addTrailingSlash: AstroConfig['trailingSlash']
) {
- const base = config.base;
const pathname = segments
.map((segment) => {
if (segment.length === 1 && segment[0].spread) {
@@ -124,7 +123,7 @@ function getTrailingSlashPattern(addTrailingSlash: AstroConfig['trailingSlash'])
return '\\/?$';
}
-function validateSegment(segment: string, file = '') {
+export function validateSegment(segment: string, file = '') {
if (!file) file = segment;
if (/\]\[/.test(segment)) {
@@ -292,7 +291,7 @@ function createFileBasedRoutes(
components.push(item.file);
const component = item.file;
const { trailingSlash } = settings.config;
- const pattern = getPattern(segments, settings.config, trailingSlash);
+ const pattern = getPattern(segments, settings.config.base, trailingSlash);
const generate = getRouteGenerator(segments, trailingSlash);
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
? `/${segments.map((segment) => segment[0].content).join('/')}`
@@ -363,7 +362,7 @@ function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): Pri
const isPage = type === 'page';
const trailingSlash = isPage ? config.trailingSlash : 'never';
- const pattern = getPattern(segments, settings.config, trailingSlash);
+ const pattern = getPattern(segments, settings.config.base, trailingSlash);
const generate = getRouteGenerator(segments, trailingSlash);
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
? `/${segments.map((segment) => segment[0].content).join('/')}`
@@ -419,7 +418,7 @@ function createRedirectRoutes(
return getParts(s, from);
});
- const pattern = getPattern(segments, settings.config, trailingSlash);
+ const pattern = getPattern(segments, settings.config.base, trailingSlash);
const generate = getRouteGenerator(segments, trailingSlash);
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
? `/${segments.map((segment) => segment[0].content).join('/')}`
@@ -687,7 +686,7 @@ export function createRouteManifest(
pathname,
route,
segments,
- pattern: getPattern(segments, config, config.trailingSlash),
+ pattern: getPattern(segments, config.base, config.trailingSlash),
type: 'fallback',
});
}
@@ -764,7 +763,7 @@ export function createRouteManifest(
route,
segments,
generate,
- pattern: getPattern(segments, config, config.trailingSlash),
+ pattern: getPattern(segments, config.base, config.trailingSlash),
type: 'fallback',
fallbackRoutes: [],
};
diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts
index 8d65940bf8..154944494f 100644
--- a/packages/astro/src/vite-plugin-astro-server/plugin.ts
+++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts
@@ -115,7 +115,6 @@ export default function createVitePluginAstroServer({
*
* Renderers needs to be pulled out from the page module emitted during the build.
* @param settings
- * @param renderers
*/
export function createDevelopmentManifest(settings: AstroSettings): SSRManifest {
let i18nManifest: SSRManifestI18n | undefined = undefined;
diff --git a/packages/astro/test/container.test.js b/packages/astro/test/container.test.js
new file mode 100644
index 0000000000..ab64efa9ff
--- /dev/null
+++ b/packages/astro/test/container.test.js
@@ -0,0 +1,142 @@
+import { describe, it } from 'node:test';
+import {
+ Fragment,
+ createComponent,
+ maybeRenderHead,
+ render,
+ renderComponent,
+ renderHead,
+ renderSlot,
+} from '../dist/runtime/server/index.js';
+import { experimental_AstroContainer } from '../dist/container/index.js';
+import assert from 'node:assert/strict';
+
+const BaseLayout = createComponent((result, _props, slots) => {
+ return render`
+
+ ${renderSlot(result, slots['head'])}
+ ${renderHead(result)}
+
+ ${maybeRenderHead(result)}
+
+ ${renderSlot(result, slots['default'])}
+
+`;
+});
+
+describe('Container', () => {
+ it('Renders a div with hello world text', async () => {
+ const Page = createComponent((result) => {
+ return render`${renderComponent(
+ result,
+ 'BaseLayout',
+ BaseLayout,
+ {},
+ {
+ default: () => render`${maybeRenderHead(result)}hello world
`,
+ head: () => render`
+ ${renderComponent(
+ result,
+ 'Fragment',
+ Fragment,
+ { slot: 'head' },
+ {
+ default: () => render` `,
+ }
+ )}
+ `,
+ }
+ )}`;
+ });
+
+ const container = await experimental_AstroContainer.create();
+ const response = await container.renderToString(Page);
+
+ assert.match(response, /hello world/);
+ });
+
+ it('Renders a slot', async () => {
+ const Page = createComponent(
+ (result, _props, slots) => {
+ return render`${renderComponent(
+ result,
+ 'BaseLayout',
+ BaseLayout,
+ {},
+ {
+ default: () => render`
+ ${maybeRenderHead(result)}
+ ${renderSlot(result, slots['default'])}
+ `,
+ head: () => render`
+ ${renderComponent(
+ result,
+ 'Fragment',
+ Fragment,
+ { slot: 'head' },
+ {
+ default: () => render` `,
+ }
+ )}
+ `,
+ }
+ )}`;
+ },
+ 'Component2.astro',
+ undefined
+ );
+
+ const container = await experimental_AstroContainer.create();
+ const result = await container.renderToString(Page, {
+ slots: {
+ default: 'some slot',
+ },
+ });
+
+ assert.match(result, /some slot/);
+ });
+
+ it('Renders multiple named slots', async () => {
+ const Page = createComponent(
+ (result, _props, slots) => {
+ return render`${renderComponent(
+ result,
+ 'BaseLayout',
+ BaseLayout,
+ {},
+ {
+ default: () => render`
+ ${maybeRenderHead(result)}
+ ${renderSlot(result, slots['custom-name'])}
+ ${renderSlot(result, slots['foo-name'])}
+ `,
+ head: () => render`
+ ${renderComponent(
+ result,
+ 'Fragment',
+ Fragment,
+ { slot: 'head' },
+ {
+ default: () => render` `,
+ }
+ )}
+ `,
+ }
+ )}`;
+ },
+ 'Component2.astro',
+ undefined
+ );
+
+ const container = await experimental_AstroContainer.create();
+ const result = await container.renderToString(Page, {
+ slots: {
+ 'custom-name': 'Custom name',
+ 'foo-name': 'Bar name',
+ },
+ });
+
+ assert.match(result, /Custom name/);
+ assert.match(result, /Bar name/);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 829ec7013e..4676f88cd7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -152,6 +152,31 @@ importers:
specifier: ^4.8.7
version: link:../../packages/astro
+ examples/container-with-vitest:
+ dependencies:
+ '@astrojs/react':
+ specifier: ^3.3.4
+ version: link:../../packages/integrations/react
+ astro:
+ specifier: experimental--container
+ version: link:../../packages/astro
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ vitest:
+ specifier: ^1.6.0
+ version: 1.6.0(@types/node@18.19.31)
+ devDependencies:
+ '@types/react':
+ specifier: ^18.3.2
+ version: 18.3.2
+ '@types/react-dom':
+ specifier: ^18.3.0
+ version: 18.3.0
+
examples/framework-alpine:
dependencies:
'@astrojs/alpinejs':
@@ -10149,6 +10174,7 @@ packages:
/color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
+ requiresBuild: true
dependencies:
color-name: 1.1.4
@@ -15431,7 +15457,7 @@ packages:
dependencies:
color: 4.2.3
detect-libc: 2.0.3
- semver: 7.6.0
+ semver: 7.6.2
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.3
'@img/sharp-darwin-x64': 0.33.3
@@ -16246,6 +16272,7 @@ packages:
/tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
+ requiresBuild: true
/tty-table@4.2.3:
resolution: {integrity: sha512-Fs15mu0vGzCrj8fmJNP7Ynxt5J7praPXqFN0leZeZBXJwkMxv9cb2D454k1ltrtUSJbZ4yH4e0CynsHLxmUfFA==}