Skip to content
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

Server Islands #963

Merged
merged 3 commits into from
Sep 6, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions proposals/server-islands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
- Start Date: 2024-06-25
- Reference Issues: https://github.com/withastro/roadmap/issues/945
- Implementation PR: https://github.com/withastro/astro/pull/11305

# Summary

Allow islands of server-rendered content that renders after the page load, allowing more cacheable pages.

# Example

A component, Astro or framework, can be deferred using the `server:defer` directive:

```astro
<Avatar server:defer>
<div slot="fallback">Guest</div>
</Avatar>
```

The page can also pass props like normal. These props are included in the request to fetch the server island:

```astro
---
import Like from "../components/Like";

export const prerender = true;

const post = await getPost(Astro.params.slug)
---
<Like server:defer post={post.id} />
```

# Background & Motivation

Personalized and dynamic content reduce the ability to cache pages. Forgoing that content in the initial page request allows more effective caching strategies. This allows a CDN to deliver an initial page, either from static content or server-rendered and cached content, closer to the user immediately in most cases. Personal and dynamic content can still be delivered after the initial HTML request.

## Definitions

- **Personalized content**: Content on a web page that is distinct for a user, usually one who is logged in. Examples include a logged in user's avatar and menu items.
- **Dynamic content**: Content delivered on a page that changes frequently. An example would be a carousel of *related products* on an ecommerce site.

# Goals

- Allow deferred content to be rendered asynchronously with the page request.
- Explicit opt-in to server islands; no magic discovery based on a heuristic.
- Host agnostic and simplicity are preferred. Ideally when prerendering the static pages can be deployed to any host.
- Individual server islands per usage, no global fetch of all islands, to allow parallel and async loading.
- Allow access to on-demand rendering features in deferred components, such as cookies and the response object.

# Non-Goals

- Prerendering of deferred components, only specified fallback content will be rendered.
- Static content inside of a server island; like with client islands once you are inside of a server island all components rendered within are also server rendered.
- Zero JS. For portability and simplicity, using a small client script to fetch the island is a better approach.

# Detailed Design

Server islands are declared with the `server:defer` directive. The compiler will:

- Scan components looking for this directive.
- When it finds one, traces the component to its associated import statement.
- Creates metadata, like with client islands, returned from compilation that gives a list of each island used in each component.

In Astro a route is created, `/_server-islands/[name]` that serves discovered islands. During the "server" phase of the build the islands are discovered and collected into a map.

## Naming algorithm

After the server build there is a secondary build for the discovered islands. Each island is given a distinct name using this algorithm:

- A component is by default named its usage. If `src/components/Avatar.astro` is imported as `Avatar` and used as a server island it is by default named `Avatar`.
- If the same component is used somewhere else, but renamed to another name, the first discovered usage serves as the name.
- If another component has already claimed the name `Avatar` then the name is appended a number, `Avatar1`. The name is recursively checked with the number incremented until it finds a distinct name.

## Rendering

### Island rendering

Server islands are rendered with the same rules as a `partial`; no `<doctype>` is appended to them, nor are scripts and styles included in the response. Since the islands are used within pages their scripts and styles are already collected, bundled, and injected as part of the page's own build process.

When a request for `/_server-islands/Avatar` comes through the runtime:

- Looks in the `serverComponents` field in the `SSRManifest`. This field is a `Map<string, () => Promise<ComponentInstance>>` where the key is the component's distinct name and the value is a function that will return a promise for the component. This is similar to the data structure used to lazy load pages.
- The server island calls the value of this map to retrieve the `ComponentInstance` which is then rendered inside of the endpoint.

### Page rendering

When the page renders, either at build time (`output: 'hybrid'`) or at runtime (`output: 'server'`), components with the `server:defer` directive are not rendered. Instead a script is injected (explained in next section).

Additionally the `slot="fallback"` is rendered and returned before the hydration script. The hydration script is injected along with stringified:

- Component `name` as described in the naming algorithm.
- `props` passed to the component. An island can be rendered multiple times; the props are representative of a particular usage.
- `slots` that are passed to the island component.

## Hydration

The hydration script performs the following steps:

- Creates an HTTP request to `/_server-islands/[name]`
- Consumes the body of the request into a string.
- Turns the string of HTML into a document fragment.
- Removes the fallback content, if there is any.
- Injects the new fragment.
- Removes the script.

# Testing Strategy

This feature spans multiple parts of Astro so it will be tested in layers:

## Compiler

- The compiler piece of this mostly deals with the metadata that is returned. So integration/wasm tests will be added to verify the right output.

## Rendering

- Fixture tests for server generated content, dev and build, to ensure the script is emitted for islands.

## E2E

- Playwright tests to verify the island hydrates, requests the server contents, and renders it properly on the client.

# Drawbacks

- There is overlap between this feature and client directives, particularly `client:only` which can include fallback content. It is hard to explain why you would use server islands over this feature. One reason is that client directives that fetch from an API cause a waterfall that is not included with server directives which only have a small inlined script.

# Alternatives

The major alternative implementation idea is to not fetch the islands via a script but rather to do so inside of an Edge function and then stitch together the HTML as the islands stream in. Such an approach would have these advantages:

- Prevention of a waterfall caused by the island only being fetched once the page loads.
- No fallback content needed. Fallback requires design considerations, using an Edge function would be more akin to SSR.

However this approach has some downsides:

- Eliminates the caching advantage gained by the script approach. Since the edge function injects personalized content the page cannot be cached globally.
- Only works will with Edge functions, so limited choice of hosts. Loss of portability.
Copy link

@kandros kandros Jun 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only works will with Edge functions,

typo

- The page's main content will often be delayed from being visible to the user as it is blocked by server islands being fetched further up in the page.

# Adoption strategy

- Experimental release while the stage 3 RFC goes through revisions.
- This is an opt-in feature that does not include any breaking changes to existing features. Only users who want to use it will.
- This feature requires compiler integration so there are no similar features in the Astro ecosystem.

# Unresolved Questions

- During stage 2 there was some discussion about `props` which get serialized to the island. It could make sense to encrypt them to prevent mistakening leaking secrets. How/if this can be done hasn't been determined yet.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative is signing the props, which would address the potential issue of untrusted props - though not the one of leaking secrets.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Encrypting should address both, and has ~ the same implementation complexity

Copy link
Member

@Fryuni Fryuni Jul 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be best to use a built-time-generated symmetric key with some algorithm available under the Web Crypto standard. That way it is available on basically every JS server runtime.