271 lines
7.9 KiB
JavaScript
271 lines
7.9 KiB
JavaScript
|
|
import { destr } from "destr";
|
||
|
|
import { createError, defineEventHandler, getQuery, getRequestHeader, getRequestWebStream, setResponseHeader, setResponseHeaders, setResponseStatus } from "h3";
|
||
|
|
import { resolveUnrefHeadInput } from "@unhead/vue";
|
||
|
|
import { getRequestDependencies } from "vue-bundle-renderer/runtime";
|
||
|
|
import { getQuery as getURLQuery } from "ufo";
|
||
|
|
import { computeIslandHash } from "#app/island-hash";
|
||
|
|
// @ts-expect-error virtual file
|
||
|
|
import { runtimeCompiler } from "#internal/nuxt.config.mjs";
|
||
|
|
import { findUnsafeIslandPropKey } from "#app/island-props";
|
||
|
|
import { MAX_ISLAND_BODY_BYTES, exceedsMaxBytes, exceedsMaxDepth } from "../utils/island-props.mjs";
|
||
|
|
import { useNitroApp } from "nitropack/runtime/app";
|
||
|
|
import { islandCache, islandPropCache } from "../utils/cache.mjs";
|
||
|
|
import { createSSRContext } from "../utils/renderer/app.mjs";
|
||
|
|
import { getSSRRenderer } from "../utils/renderer/build-files.mjs";
|
||
|
|
import { renderInlineStyles } from "../utils/renderer/inline-styles.mjs";
|
||
|
|
import { getClientIslandResponse, getServerComponentHTML, getSlotIslandResponse } from "../utils/renderer/islands.mjs";
|
||
|
|
const ISLAND_SUFFIX_RE = /\.json(?:\?.*)?$/;
|
||
|
|
const handler = defineEventHandler(async (event) => {
|
||
|
|
const nitroApp = useNitroApp();
|
||
|
|
setResponseHeaders(event, {
|
||
|
|
"content-type": "application/json;charset=utf-8",
|
||
|
|
"x-powered-by": "Nuxt"
|
||
|
|
});
|
||
|
|
if (import.meta.prerender && event.path && await islandCache.hasItem(event.path)) {
|
||
|
|
return islandCache.getItem(event.path);
|
||
|
|
}
|
||
|
|
const islandContext = await getIslandContext(event);
|
||
|
|
const ssrContext = {
|
||
|
|
...createSSRContext(event),
|
||
|
|
islandContext,
|
||
|
|
noSSR: false,
|
||
|
|
url: islandContext.url
|
||
|
|
};
|
||
|
|
|
||
|
|
const renderer = await getSSRRenderer();
|
||
|
|
const renderResult = await renderer.renderToString(ssrContext).catch(async (err) => {
|
||
|
|
if (ssrContext["~renderResponse"] && err?.message === "skipping render") {
|
||
|
|
return {};
|
||
|
|
}
|
||
|
|
await ssrContext.nuxt?.hooks.callHook("app:error", err);
|
||
|
|
throw err;
|
||
|
|
});
|
||
|
|
|
||
|
|
|
||
|
|
await ssrContext.nuxt?.hooks.callHook("app:rendered", {
|
||
|
|
ssrContext,
|
||
|
|
renderResult
|
||
|
|
});
|
||
|
|
if (ssrContext["~renderResponse"]) {
|
||
|
|
const response = ssrContext["~renderResponse"];
|
||
|
|
if (response.statusCode && response.statusCode >= 400) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: response.statusCode,
|
||
|
|
statusMessage: response.statusMessage
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return returnIslandResponse(event, response);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (ssrContext.payload?.error) {
|
||
|
|
throw ssrContext.payload.error;
|
||
|
|
}
|
||
|
|
const inlinedStyles = await renderInlineStyles(ssrContext.modules ?? []);
|
||
|
|
if (inlinedStyles.length) {
|
||
|
|
ssrContext.head.push({ style: inlinedStyles });
|
||
|
|
}
|
||
|
|
if (import.meta.dev) {
|
||
|
|
const { styles } = getRequestDependencies(ssrContext, renderer.rendererContext);
|
||
|
|
const link = [];
|
||
|
|
for (const resource of Object.values(styles)) {
|
||
|
|
|
||
|
|
if ("inline" in getURLQuery(resource.file)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
if (resource.file.includes("scoped") && !resource.file.includes("pages/")) {
|
||
|
|
link.push({
|
||
|
|
rel: "stylesheet",
|
||
|
|
href: renderer.rendererContext.buildAssetsURL(resource.file),
|
||
|
|
crossorigin: ""
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (link.length) {
|
||
|
|
ssrContext.head.push({ link }, { mode: "server" });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const islandHead = {};
|
||
|
|
for (const entry of ssrContext.head.entries.values()) {
|
||
|
|
|
||
|
|
for (const [key, value] of Object.entries(resolveUnrefHeadInput(entry.input))) {
|
||
|
|
const currentValue = islandHead[key];
|
||
|
|
if (Array.isArray(currentValue)) {
|
||
|
|
currentValue.push(...value);
|
||
|
|
} else {
|
||
|
|
islandHead[key] = value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
islandHead.link ||= [];
|
||
|
|
islandHead.style ||= [];
|
||
|
|
const islandResponse = {
|
||
|
|
id: islandContext.id,
|
||
|
|
head: islandHead,
|
||
|
|
html: getServerComponentHTML(renderResult.html),
|
||
|
|
components: getClientIslandResponse(ssrContext),
|
||
|
|
slots: getSlotIslandResponse(ssrContext)
|
||
|
|
};
|
||
|
|
await nitroApp.hooks.callHook("render:island", islandResponse, {
|
||
|
|
event,
|
||
|
|
islandContext
|
||
|
|
});
|
||
|
|
if (import.meta.prerender) {
|
||
|
|
await islandCache.setItem(`/__nuxt_island/${islandContext.name}_${islandContext.id}.json`, islandResponse);
|
||
|
|
await islandPropCache.setItem(`/__nuxt_island/${islandContext.name}_${islandContext.id}.json`, event.path);
|
||
|
|
}
|
||
|
|
return islandResponse;
|
||
|
|
});
|
||
|
|
export default handler;
|
||
|
|
function returnIslandResponse(event, response) {
|
||
|
|
for (const header in response.headers || {}) {
|
||
|
|
setResponseHeader(event, header, response.headers[header]);
|
||
|
|
}
|
||
|
|
if (response.statusCode) {
|
||
|
|
setResponseStatus(event, response.statusCode, response.statusMessage);
|
||
|
|
}
|
||
|
|
return response.body;
|
||
|
|
}
|
||
|
|
const ISLAND_PATH_PREFIX = "/__nuxt_island/";
|
||
|
|
const VALID_COMPONENT_NAME_RE = /^[a-z][\w.-]*$/i;
|
||
|
|
|
||
|
|
|
||
|
|
async function readGuardedIslandBody(event) {
|
||
|
|
const contentLength = Number(getRequestHeader(event, "content-length"));
|
||
|
|
if (contentLength > MAX_ISLAND_BODY_BYTES) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 413,
|
||
|
|
statusMessage: "Island request body too large"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
let received = 0;
|
||
|
|
let raw = "";
|
||
|
|
let overflowed = false;
|
||
|
|
const stream = getRequestWebStream(event);
|
||
|
|
if (stream) {
|
||
|
|
const decoder = new TextDecoder();
|
||
|
|
const reader = stream.getReader();
|
||
|
|
try {
|
||
|
|
for (;;) {
|
||
|
|
const { done, value } = await reader.read();
|
||
|
|
if (done) {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
received += value.byteLength;
|
||
|
|
if (received > MAX_ISLAND_BODY_BYTES) {
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
overflowed = true;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
raw += decoder.decode(value, { stream: true });
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
reader.releaseLock();
|
||
|
|
}
|
||
|
|
raw += decoder.decode();
|
||
|
|
}
|
||
|
|
if (overflowed) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 413,
|
||
|
|
statusMessage: "Island request body too large"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (!raw) {
|
||
|
|
return {};
|
||
|
|
}
|
||
|
|
if (exceedsMaxDepth(raw)) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 400,
|
||
|
|
statusMessage: "Island request body too deeply nested"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return destr(raw) || {};
|
||
|
|
}
|
||
|
|
async function getIslandContext(event) {
|
||
|
|
let url = event.path || "";
|
||
|
|
const islandPath = url.replace(/\?.*$/, "");
|
||
|
|
if (import.meta.prerender && event.path && await islandPropCache.hasItem(islandPath)) {
|
||
|
|
|
||
|
|
|
||
|
|
url = await islandPropCache.getItem(islandPath);
|
||
|
|
}
|
||
|
|
if (!url.startsWith(ISLAND_PATH_PREFIX)) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 400,
|
||
|
|
statusMessage: "Invalid island request path"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const componentParts = url.substring(ISLAND_PATH_PREFIX.length).replace(ISLAND_SUFFIX_RE, "").split("_");
|
||
|
|
const hashId = componentParts.length > 1 ? componentParts.pop() : undefined;
|
||
|
|
const componentName = componentParts.join("_");
|
||
|
|
if (!componentName || !VALID_COMPONENT_NAME_RE.test(componentName)) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 400,
|
||
|
|
statusMessage: "Invalid island component name"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const rawContext = event.method === "GET" ? getQuery(event) : await readGuardedIslandBody(event);
|
||
|
|
const serializedProps = typeof rawContext?.props === "string" ? rawContext.props : "{}";
|
||
|
|
|
||
|
|
|
||
|
|
if (exceedsMaxBytes(serializedProps)) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 413,
|
||
|
|
statusMessage: "Island request props too large"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (exceedsMaxDepth(serializedProps)) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 400,
|
||
|
|
statusMessage: "Island request props too deeply nested"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
const clientContext = {};
|
||
|
|
if (rawContext && typeof rawContext === "object") {
|
||
|
|
for (const key in rawContext) {
|
||
|
|
if (key !== "props") {
|
||
|
|
clientContext[key] = rawContext[key];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const parsedProps = destr(serializedProps) || {};
|
||
|
|
|
||
|
|
|
||
|
|
const expectedHash = computeIslandHash(componentName, serializedProps, clientContext, undefined);
|
||
|
|
if (!hashId || hashId !== expectedHash) {
|
||
|
|
throw createError({
|
||
|
|
statusCode: 400,
|
||
|
|
statusMessage: "Invalid island request hash"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
if (runtimeCompiler && findUnsafeIslandPropKey(parsedProps)) {
|
||
|
|
|
||
|
|
|
||
|
|
if (import.meta.dev) {
|
||
|
|
console.warn("Island props cannot contain a `template` key, which the Vue runtime compiler would compile and execute. Rename the prop, or disable `vue.runtimeCompiler`.");
|
||
|
|
}
|
||
|
|
throw createError({
|
||
|
|
statusCode: 400,
|
||
|
|
statusMessage: "Invalid island request props"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
url: typeof rawContext?.url === "string" ? rawContext.url : "/",
|
||
|
|
id: hashId,
|
||
|
|
name: componentName,
|
||
|
|
props: parsedProps,
|
||
|
|
slots: {},
|
||
|
|
components: {}
|
||
|
|
};
|
||
|
|
}
|