mirror of
https://github.com/withastro/astro.git
synced 2024-12-16 21:46:22 -05:00
feat: container APIs (#11051)
* feat: container APIs * chore: handle runtime mode * chore: render slots * more prototyping * Adding a changeset * fix some weirdness around types * feat: allow to inject the manifest * feat: accept a manifest * more APIs * add `route` to the options * chore * fix component instance * chore: document stuff * remove commented code * chore: add test for renderers and fixed its types * fix: update name of the example * fix regression inside tests * use `experimental_` * remove errors * need to understand the types here * remove some options that I don't deem necessary for this phase * remove superfluous comments * chore: remove useless `@ts-ignore` directive * chore: address feedback * fix regression and remove astro config * chore: fix regression * Apply suggestions from code review Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * ooops * restore changes --------- Co-authored-by: Matthew Phillips <matthew@skypack.dev> Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
parent
a6916e4402
commit
12a1bccc81
27 changed files with 969 additions and 15 deletions
35
.changeset/brave-colts-cover.md
Normal file
35
.changeset/brave-colts-cover.md
Normal file
|
@ -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).
|
1
examples/container-with-vitest/.codesandbox/Dockerfile
Normal file
1
examples/container-with-vitest/.codesandbox/Dockerfile
Normal file
|
@ -0,0 +1 @@
|
|||
FROM node:18-bullseye
|
24
examples/container-with-vitest/.gitignore
vendored
Normal file
24
examples/container-with-vitest/.gitignore
vendored
Normal file
|
@ -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/
|
11
examples/container-with-vitest/README.md
Normal file
11
examples/container-with-vitest/README.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
# Astro + [Vitest](https://vitest.dev/) + Container API Example
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template container-with-vitest
|
||||
```
|
||||
|
||||
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/with-vitest)
|
||||
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/with-vitest)
|
||||
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](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.
|
7
examples/container-with-vitest/astro.config.ts
Normal file
7
examples/container-with-vitest/astro.config.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import react from "@astrojs/react"
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [react()]
|
||||
});
|
25
examples/container-with-vitest/package.json
Normal file
25
examples/container-with-vitest/package.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
9
examples/container-with-vitest/public/favicon.svg
Normal file
9
examples/container-with-vitest/public/favicon.svg
Normal file
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
After Width: | Height: | Size: 749 B |
7
examples/container-with-vitest/src/components/Card.astro
Normal file
7
examples/container-with-vitest/src/components/Card.astro
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
|
||||
---
|
||||
<div>
|
||||
This is a card
|
||||
<slot />
|
||||
</div>
|
14
examples/container-with-vitest/src/components/Counter.jsx
Normal file
14
examples/container-with-vitest/src/components/Counter.jsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
export default function({ initialCount }) {
|
||||
const [count, setCount] = useState(initialCount || 0);
|
||||
return (
|
||||
<div className="rounded-t-lg overflow-hidden border-t border-l border-r border-gray-400 text-center p-4">
|
||||
<h2 className="font-semibold text-lg">Counter</h2>
|
||||
<h3 className="font-medium text-lg">Count: {count}</h3>
|
||||
<button
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
onClick={() => setCount(count + 1)}>Increment</button>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import Counter from './Counter.jsx';
|
||||
---
|
||||
|
||||
<Counter initialCount={5} />
|
22
examples/container-with-vitest/src/pages/[locale].astro
Normal file
22
examples/container-with-vitest/src/pages/[locale].astro
Normal file
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
export function getStaticPaths() {
|
||||
return [
|
||||
{params: {locale: 'en'}},
|
||||
];
|
||||
}
|
||||
const { locale } = Astro.params
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Astro</h1>
|
||||
<p>Locale: {locale}</p>
|
||||
</body>
|
||||
</html>
|
11
examples/container-with-vitest/src/pages/api.ts
Normal file
11
examples/container-with-vitest/src/pages/api.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export function GET() {
|
||||
const json = {
|
||||
foo: 'bar',
|
||||
number: 1,
|
||||
};
|
||||
return new Response(JSON.stringify(json), {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
16
examples/container-with-vitest/src/pages/index.astro
Normal file
16
examples/container-with-vitest/src/pages/index.astro
Normal file
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Astro</h1>
|
||||
</body>
|
||||
</html>
|
15
examples/container-with-vitest/test/Card.test.ts
Normal file
15
examples/container-with-vitest/test/Card.test.ts
Normal file
|
@ -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');
|
||||
});
|
19
examples/container-with-vitest/test/ReactWrapper.test.ts
Normal file
19
examples/container-with-vitest/test/ReactWrapper.test.ts
Normal file
|
@ -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');
|
||||
});
|
16
examples/container-with-vitest/test/[locale].test.ts
Normal file
16
examples/container-with-vitest/test/[locale].test.ts
Normal file
|
@ -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');
|
||||
});
|
3
examples/container-with-vitest/tsconfig.json
Normal file
3
examples/container-with-vitest/tsconfig.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/base"
|
||||
}
|
9
examples/container-with-vitest/vitest.config.ts
Normal file
9
examples/container-with-vitest/vitest.config.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/// <reference types="vitest" />
|
||||
import { getViteConfig } from 'astro/config';
|
||||
|
||||
export default getViteConfig({
|
||||
test: {
|
||||
/* for example, use global to avoid globals imports (describe, test, expect): */
|
||||
// globals: true,
|
||||
},
|
||||
});
|
|
@ -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/*",
|
||||
|
|
416
packages/astro/src/container/index.ts
Normal file
416
packages/astro/src/container/index.ts
Normal file
|
@ -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<string, any>;
|
||||
/**
|
||||
* 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<string, string | undefined>;
|
||||
/**
|
||||
* 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<AstroUserConfig, 'integrations' | 'adapter' >
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
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<experimental_AstroContainer> {
|
||||
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<experimental_AstroContainer> {
|
||||
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<string, string | undefined>;
|
||||
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<string> {
|
||||
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<Response> {
|
||||
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<string, string | undefined>, 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<string, string | undefined>): ComponentInstance {
|
||||
if (params) {
|
||||
return {
|
||||
default: componentFactory,
|
||||
getStaticPaths() {
|
||||
return [{ params }];
|
||||
}
|
||||
}
|
||||
}
|
||||
return ({ default: componentFactory })
|
||||
}
|
||||
}
|
115
packages/astro/src/container/pipeline.ts
Normal file
115
packages/astro/src/container/pipeline.ts
Normal file
|
@ -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<RouteData, SinglePageBuiltModule> = 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<SSRResult['componentMetadata']> | void {}
|
||||
|
||||
headElements(routeData: RouteData): Promise<HeadElements> | HeadElements {
|
||||
const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData);
|
||||
const links = new Set<never>();
|
||||
const scripts = new Set<SSRElement>();
|
||||
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<ComponentInstance> {
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -91,7 +91,10 @@ export class RenderContext {
|
|||
* - endpoint
|
||||
* - fallback
|
||||
*/
|
||||
async render(componentInstance: ComponentInstance | undefined): Promise<Response> {
|
||||
async render(
|
||||
componentInstance: ComponentInstance | undefined,
|
||||
slots: Record<string, any> = {}
|
||||
): Promise<Response> {
|
||||
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
|
||||
);
|
||||
|
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
142
packages/astro/test/container.test.js
Normal file
142
packages/astro/test/container.test.js
Normal file
|
@ -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`<html>
|
||||
<head>
|
||||
${renderSlot(result, slots['head'])}
|
||||
${renderHead(result)}
|
||||
</head>
|
||||
${maybeRenderHead(result)}
|
||||
<body>
|
||||
${renderSlot(result, slots['default'])}
|
||||
</body>
|
||||
</html>`;
|
||||
});
|
||||
|
||||
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)}<div>hello world</div>`,
|
||||
head: () => render`
|
||||
${renderComponent(
|
||||
result,
|
||||
'Fragment',
|
||||
Fragment,
|
||||
{ slot: 'head' },
|
||||
{
|
||||
default: () => render`<meta charset="utf-8">`,
|
||||
}
|
||||
)}
|
||||
`,
|
||||
}
|
||||
)}`;
|
||||
});
|
||||
|
||||
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`<meta charset="utf-8">`,
|
||||
}
|
||||
)}
|
||||
`,
|
||||
}
|
||||
)}`;
|
||||
},
|
||||
'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`<meta charset="utf-8">`,
|
||||
}
|
||||
)}
|
||||
`,
|
||||
}
|
||||
)}`;
|
||||
},
|
||||
'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/);
|
||||
});
|
||||
});
|
|
@ -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==}
|
||||
|
|
Loading…
Reference in a new issue