189 lines
6.7 KiB
JavaScript
189 lines
6.7 KiB
JavaScript
import { encryptString, generateCspDigest } from "../../../core/encryption.js";
|
|
import { markHTMLString } from "../escape.js";
|
|
import { renderChild } from "./any.js";
|
|
import { createThinHead } from "./astro/head-and-content.js";
|
|
import { createRenderInstruction } from "./instruction.js";
|
|
import { renderSlotToString } from "./slot.js";
|
|
const internalProps = /* @__PURE__ */ new Set([
|
|
"server:component-path",
|
|
"server:component-export",
|
|
"server:component-directive",
|
|
"server:defer"
|
|
]);
|
|
function containsServerDirective(props) {
|
|
return "server:component-directive" in props;
|
|
}
|
|
const SCRIPT_RE = /<\/script/giu;
|
|
const COMMENT_RE = /<!--/gu;
|
|
const SCRIPT_REPLACER = "<\\/script";
|
|
const COMMENT_REPLACER = "\\u003C!--";
|
|
function safeJsonStringify(obj) {
|
|
return JSON.stringify(obj).replace(SCRIPT_RE, SCRIPT_REPLACER).replace(COMMENT_RE, COMMENT_REPLACER);
|
|
}
|
|
function createSearchParams(componentExport, encryptedProps, slots) {
|
|
const params = new URLSearchParams();
|
|
params.set("e", componentExport);
|
|
params.set("p", encryptedProps);
|
|
params.set("s", slots);
|
|
return params;
|
|
}
|
|
function isWithinURLLimit(pathname, params) {
|
|
const url = pathname + "?" + params.toString();
|
|
const chars = url.length;
|
|
return chars < 2048;
|
|
}
|
|
class ServerIslandComponent {
|
|
result;
|
|
props;
|
|
slots;
|
|
displayName;
|
|
hostId;
|
|
islandContent;
|
|
componentPath;
|
|
componentExport;
|
|
componentId;
|
|
constructor(result, props, slots, displayName) {
|
|
this.result = result;
|
|
this.props = props;
|
|
this.slots = slots;
|
|
this.displayName = displayName;
|
|
}
|
|
async init() {
|
|
const content = await this.getIslandContent();
|
|
if (this.result.cspDestination) {
|
|
this.result._metadata.extraScriptHashes.push(
|
|
await generateCspDigest(SERVER_ISLAND_REPLACER, this.result.cspAlgorithm)
|
|
);
|
|
const contentDigest = await generateCspDigest(content, this.result.cspAlgorithm);
|
|
this.result._metadata.extraScriptHashes.push(contentDigest);
|
|
}
|
|
return createThinHead();
|
|
}
|
|
async render(destination) {
|
|
const hostId = await this.getHostId();
|
|
const islandContent = await this.getIslandContent();
|
|
destination.write(createRenderInstruction({ type: "server-island-runtime" }));
|
|
destination.write("<!--[if astro]>server-island-start<![endif]-->");
|
|
for (const name in this.slots) {
|
|
if (name === "fallback") {
|
|
await renderChild(destination, this.slots.fallback(this.result));
|
|
}
|
|
}
|
|
destination.write(
|
|
`<script type="module" data-astro-rerun data-island-id="${hostId}">${islandContent}</script>`
|
|
);
|
|
}
|
|
getComponentPath() {
|
|
if (this.componentPath) {
|
|
return this.componentPath;
|
|
}
|
|
const componentPath = this.props["server:component-path"];
|
|
if (!componentPath) {
|
|
throw new Error(`Could not find server component path`);
|
|
}
|
|
this.componentPath = componentPath;
|
|
return componentPath;
|
|
}
|
|
getComponentExport() {
|
|
if (this.componentExport) {
|
|
return this.componentExport;
|
|
}
|
|
const componentExport = this.props["server:component-export"];
|
|
if (!componentExport) {
|
|
throw new Error(`Could not find server component export`);
|
|
}
|
|
this.componentExport = componentExport;
|
|
return componentExport;
|
|
}
|
|
async getHostId() {
|
|
if (!this.hostId) {
|
|
this.hostId = await crypto.randomUUID();
|
|
}
|
|
return this.hostId;
|
|
}
|
|
async getIslandContent() {
|
|
if (this.islandContent) {
|
|
return this.islandContent;
|
|
}
|
|
const componentPath = this.getComponentPath();
|
|
const componentExport = this.getComponentExport();
|
|
const componentId = this.result.serverIslandNameMap.get(componentPath);
|
|
if (!componentId) {
|
|
throw new Error(`Could not find server component name`);
|
|
}
|
|
for (const key2 of Object.keys(this.props)) {
|
|
if (internalProps.has(key2)) {
|
|
delete this.props[key2];
|
|
}
|
|
}
|
|
const renderedSlots = {};
|
|
for (const name in this.slots) {
|
|
if (name !== "fallback") {
|
|
const content = await renderSlotToString(this.result, this.slots[name]);
|
|
renderedSlots[name] = content.toString();
|
|
}
|
|
}
|
|
const key = await this.result.key;
|
|
const propsEncrypted = Object.keys(this.props).length === 0 ? "" : await encryptString(key, JSON.stringify(this.props));
|
|
const hostId = await this.getHostId();
|
|
const slash = this.result.base.endsWith("/") ? "" : "/";
|
|
let serverIslandUrl = `${this.result.base}${slash}_server-islands/${componentId}${this.result.trailingSlash === "always" ? "/" : ""}`;
|
|
const potentialSearchParams = createSearchParams(
|
|
componentExport,
|
|
propsEncrypted,
|
|
safeJsonStringify(renderedSlots)
|
|
);
|
|
const useGETRequest = isWithinURLLimit(serverIslandUrl, potentialSearchParams);
|
|
if (useGETRequest) {
|
|
serverIslandUrl += "?" + potentialSearchParams.toString();
|
|
this.result._metadata.extraHead.push(
|
|
markHTMLString(
|
|
`<link rel="preload" as="fetch" href="${serverIslandUrl}" crossorigin="anonymous">`
|
|
)
|
|
);
|
|
}
|
|
const method = useGETRequest ? (
|
|
// GET request
|
|
`let response = await fetch('${serverIslandUrl}');`
|
|
) : (
|
|
// POST request
|
|
`let data = {
|
|
componentExport: ${safeJsonStringify(componentExport)},
|
|
encryptedProps: ${safeJsonStringify(propsEncrypted)},
|
|
slots: ${safeJsonStringify(renderedSlots)},
|
|
};
|
|
let response = await fetch('${serverIslandUrl}', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});`
|
|
);
|
|
this.islandContent = `${method}replaceServerIsland('${hostId}', response);`;
|
|
return this.islandContent;
|
|
}
|
|
}
|
|
const renderServerIslandRuntime = () => {
|
|
return `<script>${SERVER_ISLAND_REPLACER}</script>`;
|
|
};
|
|
const SERVER_ISLAND_REPLACER = markHTMLString(
|
|
`async function replaceServerIsland(id, r) {
|
|
let s = document.querySelector(\`script[data-island-id="\${id}"]\`);
|
|
// If there's no matching script, or the request fails then return
|
|
if (!s || r.status !== 200 || r.headers.get('content-type')?.split(';')[0].trim() !== 'text/html') return;
|
|
// Load the HTML before modifying the DOM in case of errors
|
|
let html = await r.text();
|
|
// Remove any placeholder content before the island script
|
|
while (s.previousSibling && s.previousSibling.nodeType !== 8 && s.previousSibling.data !== '[if astro]>server-island-start<![endif]')
|
|
s.previousSibling.remove();
|
|
s.previousSibling?.remove();
|
|
// Insert the new HTML
|
|
s.before(document.createRange().createContextualFragment(html));
|
|
// Remove the script. Prior to v5.4.2, this was the trick to force rerun of scripts. Keeping it to minimize change to the existing behavior.
|
|
s.remove();
|
|
}`.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("//")).join(" ")
|
|
);
|
|
export {
|
|
ServerIslandComponent,
|
|
containsServerDirective,
|
|
renderServerIslandRuntime
|
|
};
|