-
Notifications
You must be signed in to change notification settings - Fork 30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Container APIs #916
Open
ematipico
wants to merge
11
commits into
main
Choose a base branch
from
rfc/container-api
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+231
−0
Open
Container APIs #916
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
d1f4cbe
Container APIs
ematipico 8af8582
Update proposals/0048-container-api.md
ematipico c29b1d1
fix typo
ematipico 7859759
udpate the RFC
ematipico 0b11432
Update proposals/0048-container-api.md
ematipico cac3f42
Update proposals/0048-container-api.md
ematipico e5b02d2
address feedback
ematipico 013ca1d
Apply suggestions from code review
ematipico 390afac
remove status
ematipico 155bfce
add props to the RFC
ematipico a5e85d0
add chapter for renderers
ematipico File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,231 @@ | ||
- Start Date: 2024-04-26 | ||
- Reference Issues: https://github.com/withastro/roadmap/issues/533 | ||
- Implementation PR: | ||
|
||
# Summary | ||
|
||
Astro components are tightly coupled to `Astro` (the metaframework). This proposal introduces a server-side API for rendering `.astro` files in isolation, outside the full `astro` build pipeline. | ||
|
||
# Example | ||
|
||
```js | ||
import { AstroContainer } from 'astro/container'; | ||
import Component from './components/Component.astro'; | ||
|
||
const container = await AstroContainer.create(); | ||
console.log(await container.renderToString(Component, { props, slots })) | ||
``` | ||
|
||
The container can be optionally constructed with some settings that are typically reserved for Astro configuration. | ||
```js | ||
const container = await AstroContainer.create({ | ||
streaming: true, | ||
astroConfig: { | ||
site: "https://example.com", | ||
trailingSlash: false | ||
} | ||
}) | ||
``` | ||
|
||
The second argument to `renderToString` optionally provides render-specific data that may be exposed through the Astro global, like `props`, `slots`, `request`, and/or `params`. | ||
```js | ||
await astro.renderToString(Component, { props, slots, request, params }) | ||
``` | ||
|
||
# Background & Motivation | ||
|
||
Some of our own proposals, as well as many third-party tool integrations, are blocked until we expose a proper server-side rendering API for `.astro` components. Other frameworks have tools like `preact-render-to-string` or `react-dom/server` that make rendering components on the server straight-forward. | ||
|
||
We've avoided doing this because... it's very hard! Part of the appeal of `.astro` components is that they have immediate access to a number of powerful APIs, context about your site, and a rich set of framework renderers. In the full `astro` build process, we are able to wire up all this context invisibly. Wiring up the context manually as a third-party is prohibitively complex. | ||
|
||
|
||
# Goals | ||
|
||
- Provide a **low-level** API for rendering `.astro` components in isolation | ||
- Surface enough control for full `astro` parity, but abstract away internal APIs | ||
- Enable third-party tools to consume `.astro` files on the server | ||
- Enable unit testing of `.astro` component output | ||
- Support Astro framework renderers (`@astrojs/*`) | ||
|
||
# Non-Goals | ||
|
||
- Provide a way to **import** `.astro` components in a non-vite environment. Users will be responsible to provide a compiled component. | ||
- Provide a way to **import** `astro.config.(mjs|mts)` out of the box. | ||
|
||
# Detailed Design | ||
|
||
## Preface | ||
|
||
**This** RFC will have a smaller scoped compared to what users have envisioned. The reason why the scope shrunk is that | ||
|
||
We want to ship a smaller feature on the surface, which will allow us, later to enhance it based on user's feedback and use cases. | ||
|
||
That's why the first iteration of the APIs won't provide a built-in way to compile Astro components. | ||
|
||
## Internal concepts | ||
|
||
The API took a lot of time to land because it required some internal refactors. Thanks to these refactors, we will be able | ||
to land this API by using the **very same engine** that Astro uses under the hood. | ||
|
||
Eventually, we landed to a common and abstract concept called **Pipeline**. A pipeline is an abstract | ||
class that is responsible to render any kind of Astro route: page, endpoint, redirect, virtual. | ||
|
||
Each pipeline inside the Astro codebase (dev, build, SSR and now test) is responsible to collect the important information in different way, | ||
but eventually each route must be rendered using the same data, which are: | ||
- **Manifest**: this is a serializable/deserializable version of our "configuration", however it also contains more information, such as renderers, client directives, styles. | ||
- **Request**: a standard https://developer.mozilla.org/en-US/docs/Web/API/Request object. | ||
- **RouteData**: a type that identifies a generic Astro route. It contains information such as the type of route, the associated component, etc. | ||
- **ComponentInstance**: The instance of an Astro component. This is the **compiled** component. Internally, an Astro component undergoes various transformations via Vite, and it eventually gets consumed by our rendering engine (if it's a page). | ||
|
||
## Public APIs | ||
|
||
- `AstroContainer::create`: creates a new instance of the container. | ||
- `AstroContainer.renderToString`: renders a component and return a string. | ||
- `AstroContainer.renderToResponse`: renders a component and returns the `Response` emitted by the rendering phase. | ||
- `AstroContainer.addServerRenderer`: to programmatically store a renderer inside the container. | ||
|
||
### `create` function | ||
|
||
A container is class exposed via the `astro/container` specifier. Users **shouldn't** create a container using the `new` instance, but they should use the static function `create`: | ||
|
||
```js | ||
const container = await AstroContainer.create() | ||
``` | ||
|
||
The reason why `create` returns a promise is because internally we will validate and parse the user configuration. As a developer, you will be able to pass the same configuration that Astro uses: | ||
|
||
```js | ||
import astroConfig from "../src/astro.config.mjs"; | ||
|
||
const container = await AstroContainer.create({ | ||
astroConfig | ||
}) | ||
``` | ||
|
||
The function `create` will throw an error if there's a validation error. | ||
|
||
> [!NOTE] | ||
> If you use a TypeScript file for your configuration, you are responsible for loading and transforming it to JavaScript. | ||
|
||
|
||
The `container` binding will expose a `renderToString` that accepts a Astro component and will return a `string`: | ||
|
||
```js | ||
import astroConfig from "../src/astro.config.mjs"; | ||
|
||
const container = await AstroContainer.create({ | ||
astroConfig | ||
}) | ||
|
||
const content = await container.renderToString(AstroComponent); | ||
``` | ||
|
||
#### Options | ||
|
||
It will be possible to tweak the container APIs with options and such. The `.create` function will accept the following interface: | ||
|
||
```ts | ||
type AstroContainerOptions = { | ||
streaming: boolean; | ||
renderers: AddServerRenderer[]; | ||
astroConfig: AstroUserConfig; | ||
} | ||
``` | ||
|
||
The `astroConfig` object is literally the same object exposed by the `defineConfig`, inside the `astro.config.mjs` file. This very configuration | ||
will go under the same schema validation that Astro uses internally. This means that an invalid schema will result in an error of the `create` function. | ||
|
||
#### Add a renderer | ||
|
||
Adding a renderer can be done manually or automatically. | ||
|
||
The automatic way is meant for static applications, or cases where the container isn't called at runtime. The renderers maintained by the Astro org will expose | ||
a method called `getContainerRenderer` that will return the correct information that will tell the container **how to import the renderer**. The importing of the renderer is done via a function called `loadRenderers`, exported by a new virtual module called `astro:container`: | ||
|
||
```js | ||
import { getContainerRenderer as reactRenderer } from "@astrojs/react"; | ||
import { getContainerRenderer as vueRenderer } from "@astrojs/vue"; | ||
import { laodRenderers } from "astro:container"; | ||
import { AstroContainer } from "astro/container"; | ||
|
||
const renderers = loadRenderers([ | ||
reactRenderer(), | ||
vueRenderer() | ||
]); | ||
const container = await AstroContainer.create({ renderers }) | ||
``` | ||
|
||
The manual way is meant for on-demand applications, or cases where the container is called at runtime or inside other "shells" (PHP, Ruby, Java, etc.). | ||
The developer is in charge of **importing** the server renderer and store it inside the container via `AstroContainer.addServerRenderer`: | ||
|
||
The renderers maintained by the Astro org will ship proper types for importing server renderers. | ||
|
||
```js | ||
import reactRenderer from "@astrojs/react/server.js"; | ||
import vueRenderer from "@astrojs/vue/server.js"; | ||
|
||
const container = await AstroContainer.create(); | ||
container.addServerRenderer({renderer: reactRenderer}); | ||
container.addServerRenderer({renderer: vueRenderer}); | ||
``` | ||
|
||
### `renderToString` and `renderToResponse` options | ||
|
||
This function can accept an optional object as a second parameter: | ||
|
||
```js | ||
import { onRequest } from "../src/middleware.js" | ||
|
||
const response = await container.renderToString(Card, { | ||
slots: { | ||
default: await container.renderToString(CardItem) | ||
}, | ||
request: new Request("https://example.com/blog/blog-slug", { | ||
headers: {} | ||
}), | ||
params: { | ||
"slug": "blog-slug" | ||
}, // in case your route is `pages/blog/[slug].astro` | ||
locals: { | ||
someFn() { | ||
|
||
}, | ||
someString: "string", | ||
someNumber: 100 | ||
}, | ||
props: { | ||
"someState": true | ||
} | ||
}); | ||
``` | ||
|
||
- `slots`: required in case your component is designed to render some slots inside of it. It supposed named slots too. | ||
- `request`: required in case your component/page access to some information such as `Astro.url` or `Astro.request`. | ||
- `params`: the `Astro.params` to provide to the components | ||
- `props`: the `Astro.props` to provide to the components | ||
- `locals`: initial value of the `Astro.locals`. | ||
- `routeType`: useful to test endpoints. | ||
|
||
# Testing Strategy | ||
|
||
- Integration tests to cover the majority of the options. | ||
- Example folders to show how to integrate inside an application | ||
|
||
# Drawbacks | ||
|
||
I have considered a more high-level API, however with a high-level API it gets more difficult to integrate it inside other testing frameworks. | ||
|
||
While a low-level API requires more maintenance and work to provide more work, the main idea is to provide a bare-metal API so users can tweak it as they see fit. | ||
|
||
# Alternatives | ||
|
||
I have considered the idea of exposing the compiler itself, with a thin layer of APIs, however users won't be able to compile the majority of formats like Markdown, MDX, etc. | ||
|
||
# Adoption strategy | ||
|
||
Considering the fact that this API doesn't involve any configuration or virtual module, the API | ||
will be released with a `experimental_` prefix, e.g. `experimental_AstroContainer`. | ||
ematipico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
This implies that the public APIs of the class will be deemed unstable, and they can change anytime, in `patch` releases too. | ||
|
||
Once the API is deemed stable, the `experimental_` prefix will be removed. |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have you found use-case for passing the astroConfig yet? I'm worried about getting bug reports when people try to pass through
vite
config and other non-supported stuff.What do you think about instead raising the relevant config values up to the
AstroContainerOptions
level. That would be stuff liketrailingSlash
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that would work easily. These options, internally, are used to create a manifest. If we pass these options when rendering a component, it would mean generating a new manifest every time we attempt to render a component, and then discard that manifest. We have to be careful not to override the existing manifest, because the manifest is tight to the lifetime of the instance of the container.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not when rendering,
AstroContainerOptions
is the name of the options passed toAstroContainer.create()
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I misunderstood what you meant. Yeah we should be able to provide the individual options.