Skip to content

Commit

Permalink
Use GET and preload links on Server Islands (#11732)
Browse files Browse the repository at this point in the history
* Use GET and preload links on Server Islands

Use origin/next

Remove since

* Add test to verify large islands work

* Update based on feedback

* Merge conflict fixed

* Update test
  • Loading branch information
matthewp authored Aug 20, 2024
1 parent 9a2aaa0 commit 4cd6c43
Show file tree
Hide file tree
Showing 7 changed files with 137 additions and 19 deletions.
13 changes: 13 additions & 0 deletions .changeset/healthy-ads-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'astro': patch
---

Use GET requests with preloading for Server Islands

Server Island requests include the props used to render the island as well as any slots passed in (excluding the fallback slot). Since browsers have a max 4mb URL length we default to using a POST request to avoid overflowing this length.

However in reality most usage of Server Islands are fairly isolated and won't exceed this limit, so a GET request is possible by passing this same information via search parameters.

Using GET means we can also include a `<link rel="preload">` tag to speed up the request.

This change implements this, with safe fallback to POST.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
const { secret } = Astro.props;
---
<h2 id="island">I am an island</h2>

<h2 class="island">I am an island</h2>
<slot />
<h3 id="secret">{secret}</h3>
<h3 class="secret">{secret}</h3>
9 changes: 9 additions & 0 deletions packages/astro/e2e/fixtures/server-islands/src/lorem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const content = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`;

export function generateLongText(paragraphs = 5) {
let arr = new Array(paragraphs);
for(let i = 0; i < paragraphs; i++) {
arr[i] = content;
}
return arr.join('\n');
}
16 changes: 13 additions & 3 deletions packages/astro/e2e/fixtures/server-islands/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,28 @@
import Island from '../components/Island.astro';
import Self from '../components/Self.astro';
import HTMLError from '../components/HTMLError.astro';
import { generateLongText } from '../lorem';
const content = generateLongText(5);
---

<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<Island server:defer secret="test">
<h3 id="children">children</h3>
</Island>
<div id="basics">
<Island server:defer secret="test">
<h3 id="children">children</h3>
</Island>
</div>

<Self server:defer />

<div id="big">
<Island server:defer secret="test" content={content} />
</div>

<div id="error-test">
<HTMLError server:defer>
<script is:inline slot="fallback">
Expand Down
20 changes: 14 additions & 6 deletions packages/astro/e2e/server-islands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ test.describe('Server islands', () => {

test('Load content from the server', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/base/'));
let el = page.locator('#island');
let el = page.locator('#basics .island');

await expect(el, 'element rendered').toBeVisible();
await expect(el, 'should have content').toHaveText('I am an island');
});

test('Can be in an MDX file', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/base/mdx/'));
let el = page.locator('#island');
let el = page.locator('.island');

await expect(el, 'element rendered').toBeVisible();
await expect(el, 'should have content').toHaveText('I am an island');
Expand All @@ -40,7 +40,7 @@ test.describe('Server islands', () => {

test('Props are encrypted', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/base/'));
let el = page.locator('#secret');
let el = page.locator('#basics .secret');
await expect(el).toHaveText('test');
});

Expand All @@ -51,6 +51,14 @@ test.describe('Server islands', () => {
await expect(el).toHaveCount(2);
});

test('Large islands that exceed URL length still work through POST', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/base/'));
let el = page.locator('#basics .island');

await expect(el, 'element rendered').toBeVisible();
await expect(el, 'should have content').toHaveText('I am an island');
});

test("Missing server island start comment doesn't cause browser to lock up", async ({
page,
astro,
Expand All @@ -75,7 +83,7 @@ test.describe('Server islands', () => {

test('Load content from the server', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/base/'));
let el = page.locator('#island');
let el = page.locator('#basics .island');

await expect(el, 'element rendered').toBeVisible();
await expect(el, 'should have content').toHaveText('I am an island');
Expand All @@ -99,15 +107,15 @@ test.describe('Server islands', () => {
test('Only one component in prod', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/base/'));

let el = page.locator('#island');
let el = page.locator('#basics .island');

await expect(el, 'element rendered').toBeVisible();
await expect(el, 'should have content').toHaveText('I am an island');
});

test('Props are encrypted', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
let el = page.locator('#secret');
let el = page.locator('#basics .secret');
await expect(el).toHaveText('test');
});
});
Expand Down
53 changes: 50 additions & 3 deletions packages/astro/src/core/server-islands/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,53 @@ type RenderOptions = {
slots: Record<string, string>;
};

function badRequest(reason: string) {
return new Response(null, {
status: 400,
statusText: 'Bad request: ' + reason,
});
}

async function getRequestData(request: Request): Promise<Response | RenderOptions> {
switch(request.method) {
case 'GET': {
const url = new URL(request.url);
const params = url.searchParams;

if(!params.has('s') || !params.has('e') || !params.has('p')) {
return badRequest('Missing required query parameters.');
}

const rawSlots = params.get('s')!;
try {
return {
componentExport: params.get('e')!,
encryptedProps: params.get('p')!,
slots: JSON.parse(rawSlots),
};
} catch {
return badRequest('Invalid slots format.');
}
}
case 'POST': {
try {
const raw = await request.text();
const data = JSON.parse(raw) as RenderOptions;
return data;
} catch {
return badRequest('Request format is invalid.');
}
}
default: {
// Method not allowed: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405
return new Response(null, { status: 405 });
}
}
}

export function createEndpoint(manifest: SSRManifest) {
const page: AstroComponentFactory = async (result) => {
const params = result.params;
const request = result.request;
const raw = await request.text();
const data = JSON.parse(raw) as RenderOptions;
if (!params.name) {
return new Response(null, {
status: 400,
Expand All @@ -63,6 +104,12 @@ export function createEndpoint(manifest: SSRManifest) {
}
const componentId = params.name;

// Get the request data from the body or search params
const data = await getRequestData(result.request);
if(data instanceof Response) {
return data;
}

const imp = manifest.serverIslandMap?.get(componentId);
if (!imp) {
return new Response(null, {
Expand Down
40 changes: 35 additions & 5 deletions packages/astro/src/runtime/server/render/server-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ function safeJsonStringify(obj: any) {
.replace(/\//g, '\\u002f');
}

function createSearchParams(componentExport: string, encryptedProps: string, slots: string) {
const params = new URLSearchParams();
params.set('e', componentExport);
params.set('p', encryptedProps);
params.set('s', slots);
return params;
}

function isWithinURLLimit(pathname: string, params: URLSearchParams) {
const url = pathname + '?' + params.toString();
const chars = url.length;
// https://chromium.googlesource.com/chromium/src/+/master/docs/security/url_display_guidelines/url_display_guidelines.md#url-length
return chars < 2048;
}

export function renderServerIsland(
result: SSRResult,
_displayName: string,
Expand Down Expand Up @@ -64,15 +79,29 @@ export function renderServerIsland(
const propsEncrypted = await encryptString(key, JSON.stringify(props));

const hostId = crypto.randomUUID();

const slash = result.base.endsWith('/') ? '' : '/';
const serverIslandUrl = `${result.base}${slash}_server-islands/${componentId}${result.trailingSlash === 'always' ? '/' : ''}`;
let serverIslandUrl = `${result.base}${slash}_server-islands/${componentId}${result.trailingSlash === 'always' ? '/' : ''}`;

// Determine if its safe to use a GET request
const potentialSearchParams = createSearchParams(componentExport, propsEncrypted, safeJsonStringify(renderedSlots));
const useGETRequest =isWithinURLLimit(serverIslandUrl, potentialSearchParams);

if(useGETRequest) {
serverIslandUrl += ('?' + potentialSearchParams.toString());
destination.write(`<link rel="preload" as="fetch" href="${serverIslandUrl}" crossorigin="anonymous">`);
}

destination.write(`<script async type="module" data-island-id="${hostId}">
let componentId = ${safeJsonStringify(componentId)};
let componentExport = ${safeJsonStringify(componentExport)};
let script = document.querySelector('script[data-island-id="${hostId}"]');
let data = {
componentExport,
${useGETRequest ?
// GET request
`let response = await fetch('${serverIslandUrl}');
`:
// POST request
`let data = {
componentExport: ${safeJsonStringify(componentExport)},
encryptedProps: ${safeJsonStringify(propsEncrypted)},
slots: ${safeJsonStringify(renderedSlots)},
};
Expand All @@ -81,6 +110,7 @@ let response = await fetch('${serverIslandUrl}', {
method: 'POST',
body: JSON.stringify(data),
});
`}
if(response.status === 200 && response.headers.get('content-type') === 'text/html') {
let html = await response.text();
Expand Down

0 comments on commit 4cd6c43

Please sign in to comment.