summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Matthew Phillips <matthew@skypack.dev> 2024-08-20 13:53:04 -0400
committerGravatar GitHub <noreply@github.com> 2024-08-20 13:53:04 -0400
commit4cd6c43e221e40345dfb433f9c63395f886091fd (patch)
tree3a186f65997606ac7771365614f1ef42dac569e6
parent9a2aaa01ea427df3844bce8595207809a8d2cb94 (diff)
downloadastro-4cd6c43e221e40345dfb433f9c63395f886091fd.tar.gz
astro-4cd6c43e221e40345dfb433f9c63395f886091fd.tar.zst
astro-4cd6c43e221e40345dfb433f9c63395f886091fd.zip
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
-rw-r--r--.changeset/healthy-ads-scream.md13
-rw-r--r--packages/astro/e2e/fixtures/server-islands/src/components/Island.astro5
-rw-r--r--packages/astro/e2e/fixtures/server-islands/src/lorem.ts9
-rw-r--r--packages/astro/e2e/fixtures/server-islands/src/pages/index.astro16
-rw-r--r--packages/astro/e2e/server-islands.test.js20
-rw-r--r--packages/astro/src/core/server-islands/endpoint.ts53
-rw-r--r--packages/astro/src/runtime/server/render/server-islands.ts40
7 files changed, 137 insertions, 19 deletions
diff --git a/.changeset/healthy-ads-scream.md b/.changeset/healthy-ads-scream.md
new file mode 100644
index 000000000..f78bd6d52
--- /dev/null
+++ b/.changeset/healthy-ads-scream.md
@@ -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.
diff --git a/packages/astro/e2e/fixtures/server-islands/src/components/Island.astro b/packages/astro/e2e/fixtures/server-islands/src/components/Island.astro
index 5eab0dc4d..3daa55615 100644
--- a/packages/astro/e2e/fixtures/server-islands/src/components/Island.astro
+++ b/packages/astro/e2e/fixtures/server-islands/src/components/Island.astro
@@ -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>
diff --git a/packages/astro/e2e/fixtures/server-islands/src/lorem.ts b/packages/astro/e2e/fixtures/server-islands/src/lorem.ts
new file mode 100644
index 000000000..74210474c
--- /dev/null
+++ b/packages/astro/e2e/fixtures/server-islands/src/lorem.ts
@@ -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');
+}
diff --git a/packages/astro/e2e/fixtures/server-islands/src/pages/index.astro b/packages/astro/e2e/fixtures/server-islands/src/pages/index.astro
index eff5df25e..70a4fcabc 100644
--- a/packages/astro/e2e/fixtures/server-islands/src/pages/index.astro
+++ b/packages/astro/e2e/fixtures/server-islands/src/pages/index.astro
@@ -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">
diff --git a/packages/astro/e2e/server-islands.test.js b/packages/astro/e2e/server-islands.test.js
index 496cf229c..6fa92f40a 100644
--- a/packages/astro/e2e/server-islands.test.js
+++ b/packages/astro/e2e/server-islands.test.js
@@ -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');
});
});
diff --git a/packages/astro/src/core/server-islands/endpoint.ts b/packages/astro/src/core/server-islands/endpoint.ts
index 73ed57177..153b6837f 100644
--- a/packages/astro/src/core/server-islands/endpoint.ts
+++ b/packages/astro/src/core/server-islands/endpoint.ts
@@ -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, {
diff --git a/packages/astro/src/runtime/server/render/server-islands.ts b/packages/astro/src/runtime/server/render/server-islands.ts
index fce702364..7474c891a 100644
--- a/packages/astro/src/runtime/server/render/server-islands.ts
+++ b/packages/astro/src/runtime/server/render/server-islands.ts
@@ -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' ? '/' : ''}`;
+ 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();