0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-01-20 22:12:38 -05:00

Use GET and preload links on Server Islands (#11732)

* 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
This commit is contained in:
Matthew Phillips 2024-08-20 13:53:04 -04:00 committed by GitHub
parent 9a2aaa01ea
commit 4cd6c43e22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 138 additions and 20 deletions

View file

@ -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.

View file

@ -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>

View file

@ -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');
}

View file

@ -2,6 +2,9 @@
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>
@ -9,11 +12,18 @@ import HTMLError from '../components/HTMLError.astro';
<!-- 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">

View file

@ -17,7 +17,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');
@ -25,7 +25,7 @@ test.describe('Server islands', () => {
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');
@ -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');
});
@ -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,
@ -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');
@ -99,7 +107,7 @@ 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');
@ -107,7 +115,7 @@ test.describe('Server islands', () => {
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');
});
});

View file

@ -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,
@ -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, {

View file

@ -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,
@ -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' ? '/' : ''}`;
const slash = result.base.endsWith('/') ? '' : '/';
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)},
};
@ -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();