Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 4 additions & 1 deletion packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import type { AstroSettings, RoutesList } from '../types/astro.js';
import { vitePluginAdapterConfig } from '../vite-plugin-adapter-config/index.js';
import { vitePluginApp } from '../vite-plugin-app/index.js';
import astroVitePlugin from '../vite-plugin-astro/index.js';
import { vitePluginAstroServer, vitePluginAstroServerClient } from '../vite-plugin-astro-server/index.js';
import {
vitePluginAstroServer,
vitePluginAstroServerClient,
} from '../vite-plugin-astro-server/index.js';
import configAliasVitePlugin from '../vite-plugin-config-alias/index.js';
import { astroDevCssPlugin } from '../vite-plugin-css/index.js';
import vitePluginFileURL from '../vite-plugin-fileurl/index.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP

const astro = info ? (info.meta.astro as AstroPluginMetadata['astro']) : undefined;

let hasAddedIsland = false;

if (astro) {
for (const comp of astro.serverComponents) {
if (!serverIslandNameMap.has(comp.resolvedPath)) {
Expand Down Expand Up @@ -81,12 +79,11 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP
});
referenceIdMap.set(comp.resolvedPath, referenceId);
}
hasAddedIsland = true;
}
}
}

if (hasAddedIsland && ssrEnvironment) {
if (serverIslandNameMap.size > 0 && serverIslandMap.size > 0 && ssrEnvironment) {
// In dev, we need to clear the module graph so that Vite knows to re-transform
// the module with the new island information.
const mod = ssrEnvironment.moduleGraph.getModuleById(RESOLVED_SERVER_ISLAND_MANIFEST);
Expand All @@ -100,22 +97,24 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP
const hasServerIslands = serverIslandNameMap.size > 0;
// Error if there are server islands but no adapter provided.
if (hasServerIslands && settings.buildOutput !== 'server') {
// TODO: re-enable once we fix the build
// throw new AstroError(AstroErrorData.NoAdapterInstalledServerIslands);
throw new AstroError(AstroErrorData.NoAdapterInstalledServerIslands);
}
}
let mapSource = 'new Map([\n\t';
for (let [name, path] of serverIslandMap) {
mapSource += `\n\t['${name}', () => import('${path}')],`;
}
mapSource += ']);';

return {
code: `
if (serverIslandNameMap.size > 0 && serverIslandMap.size > 0) {
let mapSource = 'new Map([\n\t';
for (let [name, path] of serverIslandMap) {
mapSource += `\n\t['${name}', () => import('${path}')],`;
}
mapSource += ']);';

return {
code: `
export const serverIslandMap = ${mapSource};
\n\nexport const serverIslandNameMap = new Map(${JSON.stringify(Array.from(serverIslandNameMap.entries()), null, 2)});
`,
};
};
}
}
},

Expand All @@ -125,7 +124,9 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP
// If there's no reference, we can fast-path to an empty map replacement
// without sourcemaps as it doesn't shift rows
return {
code: code.replace(serverIslandPlaceholderMap, 'new Map();'),
code: code
.replace(serverIslandPlaceholderMap, 'new Map();')
.replace(serverIslandPlaceholderNameMap, 'new Map()'),
map: null,
};
}
Expand Down
250 changes: 123 additions & 127 deletions packages/astro/test/csp-server-islands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,154 +4,150 @@ import * as cheerio from 'cheerio';
import testAdapter from './test-adapter.js';
import { loadFixture } from './test-utils.js';

describe('Server islands', () => {
describe('SSR', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/server-islands/ssr',
adapter: testAdapter(),
experimental: {
csp: true,
},
});
describe('Server Islands SSR prod', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/server-islands/ssr',
adapter: testAdapter(),
experimental: {
csp: true,
},
});

describe('prod', () => {
before(async () => {
process.env.ASTRO_KEY = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=';
await fixture.build();
});
process.env.ASTRO_KEY = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=';
await fixture.build();
});

after(async () => {
delete process.env.ASTRO_KEY;
});
after(async () => {
delete process.env.ASTRO_KEY;
});

it('omits the islands HTML', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
it('omits the islands HTML', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();

const $ = cheerio.load(html);
const serverIslandEl = $('h2#island');
assert.equal(serverIslandEl.length, 0);
const $ = cheerio.load(html);
const serverIslandEl = $('h2#island');
assert.equal(serverIslandEl.length, 0);

const serverIslandScript = $('script[data-island-id]');
assert.equal(serverIslandScript.length, 1, 'has the island script');
});
const serverIslandScript = $('script[data-island-id]');
assert.equal(serverIslandScript.length, 1, 'has the island script');
});

it('island is not indexed', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/_server-islands/Island', {
method: 'POST',
body: JSON.stringify({
componentExport: 'default',
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
encryptedSlots: '',
}),
headers: {
origin: 'http://example.com',
},
});
const response = await app.render(request);
assert.equal(response.headers.get('x-robots-tag'), 'noindex');
});
it('omits empty props from the query string', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/empty-props');
const response = await app.render(request);
assert.equal(response.status, 200);
const html = await response.text();
const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/);
assert.equal(fetchMatch.length, 2, 'should include props in the query string');
assert.equal(fetchMatch[1], '', 'should not include encrypted empty props');
});
it('re-encrypts props on each request', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/includeComponentWithProps/');
const response = await app.render(request);
assert.equal(response.status, 200);
const html = await response.text();
const fetchMatch = html.match(
/fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/,
);
assert.equal(fetchMatch.length, 2, 'should include props in the query string');
const firstProps = fetchMatch[1];
const secondRequest = new Request('http://example.com/includeComponentWithProps/');
const secondResponse = await app.render(secondRequest);
assert.equal(secondResponse.status, 200);
const secondHtml = await secondResponse.text();
const secondFetchMatch = secondHtml.match(
/fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/,
);
assert.equal(secondFetchMatch.length, 2, 'should include props in the query string');
assert.notEqual(
secondFetchMatch[1],
firstProps,
'should re-encrypt props on each request with a different IV',
);
it(
'island is not indexed',
{ skip: "The endpoint doesn't respond to POST because it wants a get" },
async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/_server-islands/Island', {
method: 'POST',
body: JSON.stringify({
componentExport: 'default',
encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
encryptedSlots: '',
}),
headers: {
origin: 'http://example.com',
},
});
const response = await app.render(request);
assert.equal(response.headers.get('x-robots-tag'), 'noindex');
},
);
it('omits empty props from the query string', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/empty-props');
const response = await app.render(request);
assert.equal(response.status, 200);
const html = await response.text();
const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/);
assert.equal(fetchMatch.length, 2, 'should include props in the query string');
assert.equal(fetchMatch[1], '', 'should not include encrypted empty props');
});
it('re-encrypts props on each request', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/includeComponentWithProps/');
const response = await app.render(request);
assert.equal(response.status, 200);
const html = await response.text();
const fetchMatch = html.match(/fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/);
assert.equal(fetchMatch.length, 2, 'should include props in the query string');
const firstProps = fetchMatch[1];
const secondRequest = new Request('http://example.com/includeComponentWithProps/');
const secondResponse = await app.render(secondRequest);
assert.equal(secondResponse.status, 200);
const secondHtml = await secondResponse.text();
const secondFetchMatch = secondHtml.match(
/fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/,
);
assert.equal(secondFetchMatch.length, 2, 'should include props in the query string');
assert.notEqual(
secondFetchMatch[1],
firstProps,
'should re-encrypt props on each request with a different IV',
);
});
});

describe('Server islands Hybrid mode', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/server-islands/hybrid',
experimental: {
csp: true,
},
});
});

describe('Hybrid mode', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
describe('build', () => {
before(async () => {
fixture = await loadFixture({
root: './fixtures/server-islands/hybrid',
experimental: {
csp: true,
},
await fixture.build({
adapter: testAdapter(),
});
});

describe('build', () => {
before(async () => {
await fixture.build({
adapter: testAdapter(),
});
});

it('Omits the island HTML from the static HTML', async () => {
let html = await fixture.readFile('/client/index.html');
it('Omits the island HTML from the static HTML', async () => {
let html = await fixture.readFile('/client/index.html');

const $ = cheerio.load(html);
const serverIslandEl = $('h2#island');
assert.equal(serverIslandEl.length, 0);
const $ = cheerio.load(html);
const serverIslandEl = $('h2#island');
assert.equal(serverIslandEl.length, 0);

const serverIslandScript = $('script[data-island-id]');
assert.equal(serverIslandScript.length, 2, 'has the island script');
});
const serverIslandScript = $('script[data-island-id]');
assert.equal(serverIslandScript.length, 2, 'has the island script');
});

it('includes the server island runtime script once', async () => {
let html = await fixture.readFile('/client/index.html');
it('includes the server island runtime script once', async () => {
let html = await fixture.readFile('/client/index.html');

const $ = cheerio.load(html);
const serverIslandScript = $('script').filter((_, el) =>
$(el).html().trim().startsWith('async function replaceServerIsland'),
);
assert.equal(
serverIslandScript.length,
1,
'should include the server island runtime script once',
);
});
const $ = cheerio.load(html);
const serverIslandScript = $('script').filter((_, el) =>
$(el).html().trim().startsWith('async function replaceServerIsland'),
);
assert.equal(
serverIslandScript.length,
1,
'should include the server island runtime script once',
);
});
});

describe('build (no adapter)', () => {
it('Errors during the build', async () => {
try {
await fixture.build({
adapter: undefined,
});
assert.equal(true, false, 'should not have succeeded');
} catch (err) {
assert.equal(err.title, 'Cannot use Server Islands without an adapter.');
}
});
describe('build (no adapter)', () => {
it('Errors during the build', async () => {
try {
await fixture.build({
adapter: undefined,
});
assert.equal(true, false, 'should not have succeeded');
} catch (err) {
assert.equal(err.title, 'Cannot use Server Islands without an adapter.');
}
});
});
});
Loading
Loading