INTEGRITY Документация

Оболочка одностраничного приложения (SPA) с данными начальной загрузки

В этом примере используются Worker и HTMLRewriter чтобы внедрять заранее полученные данные API в оболочку одностраничного приложения (SPA). Worker параллельно загружает начальные данные и оболочку HTML и передает результат в браузер потоком, поэтому к моменту запуска JavaScript у SPA уже есть всё необходимое.

Показаны два варианта:

  1. Static Assets : SPA развёртывается с помощью Workers Static Assets
  2. Внешний источник : SPA размещено вне Cloudflare, а Worker стоит перед ним в роли обратного прокси, что повышает производительность

Оба варианта используют одну и ту же технику внедрения через HTMLRewriter и одинаковый способ использования на стороне клиента. Выберите вариант, который соответствует вашему развёртыванию.

Этот подход подходит для любого SPA фреймворка: React, Vue, Svelte и других. Инструкции по развертыванию для конкретных фреймворков см. Веб-приложения.


Вариант 1: одностраничное приложение (SPA), полностью построенное на Workers

Используйте этот вариант, если результат сборки SPA развёрнут как часть вашего Worker с помощью Static Assets.

Настройка статических ресурсов

Задайте not_found_handling к "single-page-application" чтобы каждый маршрут возвращал index.html. Используйте run_worker_first чтобы направлять все запросы через Worker, кроме хешированных ресурсов в /assets/*, которые обслуживаются напрямую.

{
	"name": "my-spa",
	"main": "src/worker.ts",
	// Set this to today's date
	"compatibility_date": "2026-08-28",
	"compatibility_flags": ["nodejs_compat"],
	"assets": {
		"directory": "./dist",
		"binding": "ASSETS",
		"not_found_handling": "single-page-application",
		"run_worker_first": ["/*", "!/assets/*"],
	},
}
name = "my-spa"
main = "src/worker.ts"
# Set this to today's date
compatibility_date = "2026-08-28"
compatibility_flags = [ "nodejs_compat" ]

[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
run_worker_first = [ "/*", "!/assets/*" ]

Подробнее об этих параметрах см. в Маршрутизация Static Assets и run_worker_first справочник.

Внедрение bootstrap-данных с помощью HTMLRewriter

Worker сразу начинает загружать данные API, а затем загружает оболочку SPA из статических ресурсов. HTMLRewriter передает потоком <head> в браузер сразу же. Когда <body> обработчик выполняется, он ожидает ответ API и добавляет в начало <script> тег, содержащий сериализованные данные.

Если вызов API завершается ошибкой, оболочка всё равно загружается, а SPA переключается на получение данных на стороне клиента.

// Env is generated by `wrangler types` — run it whenever you change your config.
// Do not manually define Env — it drifts from your actual bindings.

export default {
	async fetch(request, env) {
		const url = new URL(request.url);

		// Serve root-level static files (favicon.ico, robots.txt) directly.
		// Hashed assets under /assets/* skip the Worker entirely via run_worker_first.
		if (url.pathname.match(/\.\w+$/) && !url.pathname.endsWith(".html")) {
			return env.ASSETS.fetch(request);
		}

		// Start fetching bootstrap data immediately — do not await yet.
		const dataPromise = fetchBootstrapData(env, url.pathname, request.headers);

		// Fetch the SPA shell from static assets (co-located, sub-millisecond).
		const shell = await env.ASSETS.fetch(
			new Request(new URL("/index.html", request.url)),
		);

		// Use HTMLRewriter to stream the shell and inject data into <body>.
		return new HTMLRewriter()
			.on("body", {
				async element(el) {
					const data = await dataPromise;
					if (data) {
						el.prepend(
							`<script>window.__BOOTSTRAP_DATA__=${JSON.stringify(data)}</script>`,
							{ html: true },
						);
					}
				},
			})
			.transform(shell);
	},
};

async function fetchBootstrapData(env, pathname, headers) {
	try {
		const res = await fetch(`${env.API_BASE_URL}/api/bootstrap`, {
			headers: {
				Cookie: headers.get("Cookie") || "",
				"X-Request-Path": pathname,
			},
		});
		if (!res.ok) return null;
		return await res.json();
	} catch {
		// If the API is down, the shell still loads and the SPA
		// falls back to client-side data fetching.
		return null;
	}
}
// Env is generated by `wrangler types` — run it whenever you change your config.
// Do not manually define Env — it drifts from your actual bindings.

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const url = new URL(request.url);

		// Serve root-level static files (favicon.ico, robots.txt) directly.
		// Hashed assets under /assets/* skip the Worker entirely via run_worker_first.
		if (url.pathname.match(/\.\w+$/) && !url.pathname.endsWith(".html")) {
			return env.ASSETS.fetch(request);
		}

		// Start fetching bootstrap data immediately — do not await yet.
		const dataPromise = fetchBootstrapData(env, url.pathname, request.headers);

		// Fetch the SPA shell from static assets (co-located, sub-millisecond).
		const shell = await env.ASSETS.fetch(
			new Request(new URL("/index.html", request.url)),
		);

		// Use HTMLRewriter to stream the shell and inject data into <body>.
		return new HTMLRewriter()
			.on("body", {
				async element(el) {
					const data = await dataPromise;
					if (data) {
						el.prepend(
							`<script>window.__BOOTSTRAP_DATA__=${JSON.stringify(data)}</script>`,
							{ html: true },
						);
					}
				},
			})
			.transform(shell);
	},
} satisfies ExportedHandler<Env>;

async function fetchBootstrapData(
	env: Env,
	pathname: string,
	headers: Headers,
): Promise<unknown | null> {
	try {
		const res = await fetch(`${env.API_BASE_URL}/api/bootstrap`, {
			headers: {
				Cookie: headers.get("Cookie") || "",
				"X-Request-Path": pathname,
			},
		});
		if (!res.ok) return null;
		return await res.json();
	} catch {
		// If the API is down, the shell still loads and the SPA
		// falls back to client-side data fetching.
		return null;
	}
}

Вариант 2: SPA, размещенное на внешнем origin-сервере

Используйте этот вариант, если ваши HTML, CSS и JavaScript развёрнуты вне Cloudflare. Worker получает оболочку SPA с внешнего источника, с помощью HTMLRewriter внедряет данные инициализации и передаёт изменённый ответ в браузер потоком.

Настройка Worker

Поскольку SPA не находится в Workers Static Assets, вам не нужен assets блок. Вместо этого сохраните URL внешнего источника в переменной окружения. Подключите Worker к своему домену с помощью Custom Domain или Маршрут.

{
	"name": "my-spa-proxy",
	"main": "src/worker.ts",
	// Set this to today's date
	"compatibility_date": "2026-08-28",
	"compatibility_flags": ["nodejs_compat"],
	"vars": {
		"SPA_ORIGIN": "https://my-spa.example-hosting.com",
		"API_BASE_URL": "https://api.example.com",
	},
}
name = "my-spa-proxy"
main = "src/worker.ts"
# Set this to today's date
compatibility_date = "2026-08-28"
compatibility_flags = [ "nodejs_compat" ]

[vars]
SPA_ORIGIN = "https://my-spa.example-hosting.com"
API_BASE_URL = "https://api.example.com"

Внедрение bootstrap-данных с помощью HTMLRewriter

Worker параллельно загружает и оболочку SPA, и данные API. Когда источник SPA отвечает, HTMLRewriter передает HTML потоком, одновременно внедряя начальные данные (bootstrap data) в <body>. Статические ресурсы (CSS, JS, изображения) передаются на внешний источник без изменений.

// Env is generated by `wrangler types` — run it whenever you change your config.
// Do not manually define Env — it drifts from your actual bindings.

export default {
	async fetch(request, env) {
		const url = new URL(request.url);

		// Pass static asset requests through to the external origin unmodified.
		if (url.pathname.match(/\.\w+$/) && !url.pathname.endsWith(".html")) {
			return fetch(new Request(`${env.SPA_ORIGIN}${url.pathname}`, request));
		}

		// Start fetching bootstrap data immediately — do not await yet.
		const dataPromise = fetchBootstrapData(env, url.pathname, request.headers);

		// Fetch the SPA shell from the external origin.
		// SPA routers serve index.html for all routes.
		const shell = await fetch(`${env.SPA_ORIGIN}/index.html`);

		if (!shell.ok) {
			return new Response("Origin returned an error", { status: 502 });
		}

		// Use HTMLRewriter to stream the shell and inject data into <body>.
		return new HTMLRewriter()
			.on("body", {
				async element(el) {
					const data = await dataPromise;
					if (data) {
						el.prepend(
							`<script>window.__BOOTSTRAP_DATA__=${JSON.stringify(data)}</script>`,
							{ html: true },
						);
					}
				},
			})
			.transform(shell);
	},
};

async function fetchBootstrapData(env, pathname, headers) {
	try {
		const res = await fetch(`${env.API_BASE_URL}/api/bootstrap`, {
			headers: {
				Cookie: headers.get("Cookie") || "",
				"X-Request-Path": pathname,
			},
		});
		if (!res.ok) return null;
		return await res.json();
	} catch {
		// If the API is down, the shell still loads and the SPA
		// falls back to client-side data fetching.
		return null;
	}
}
// Env is generated by `wrangler types` — run it whenever you change your config.
// Do not manually define Env — it drifts from your actual bindings.

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const url = new URL(request.url);

		// Pass static asset requests through to the external origin unmodified.
		if (url.pathname.match(/\.\w+$/) && !url.pathname.endsWith(".html")) {
			return fetch(new Request(`${env.SPA_ORIGIN}${url.pathname}`, request));
		}

		// Start fetching bootstrap data immediately — do not await yet.
		const dataPromise = fetchBootstrapData(env, url.pathname, request.headers);

		// Fetch the SPA shell from the external origin.
		// SPA routers serve index.html for all routes.
		const shell = await fetch(`${env.SPA_ORIGIN}/index.html`);

		if (!shell.ok) {
			return new Response("Origin returned an error", { status: 502 });
		}

		// Use HTMLRewriter to stream the shell and inject data into <body>.
		return new HTMLRewriter()
			.on("body", {
				async element(el) {
					const data = await dataPromise;
					if (data) {
						el.prepend(
							`<script>window.__BOOTSTRAP_DATA__=${JSON.stringify(data)}</script>`,
							{ html: true },
						);
					}
				},
			})
			.transform(shell);
	},
} satisfies ExportedHandler<Env>;

async function fetchBootstrapData(
	env: Env,
	pathname: string,
	headers: Headers,
): Promise<unknown | null> {
	try {
		const res = await fetch(`${env.API_BASE_URL}/api/bootstrap`, {
			headers: {
				Cookie: headers.get("Cookie") || "",
				"X-Request-Path": pathname,
			},
		});
		if (!res.ok) return null;
		return await res.json();
	} catch {
		// If the API is down, the shell still loads and the SPA
		// falls back to client-side data fetching.
		return null;
	}
}

Используйте предварительно загруженные данные в вашем SPA

На клиенте считайте window.__BOOTSTRAP_DATA__ перед выполнением любых вызовов API. Если данные существуют, используйте их напрямую. В противном случае выполните обычный fetch.

src/App.tsx
// React example — works the same way in Vue, Svelte, or any other framework.
import { useEffect, useState } from "react";

function App() {
	const [data, setData] = useState(window.__BOOTSTRAP_DATA__ || null);
	const [loading, setLoading] = useState(!data);

	useEffect(() => {
		if (data) return; // Already have prefetched data — skip the API call.

		fetch("/api/bootstrap")
			.then((res) => res.json())
			.then((result) => {
				setData(result);
				setLoading(false);
			});
	}, []);

	if (loading) return <LoadingSpinner />;
	return <Dashboard data={data} />;
}

Добавьте объявление типа, чтобы TypeScript распознавал глобальное свойство:

global.d.ts
declare global {
	interface Window {
		__BOOTSTRAP_DATA__?: unknown;
	}
}

Дополнительные методы внедрения

Можно выстраивать цепочку из нескольких обработчиков HTMLRewriter, чтобы внедрять не только данные bootstrap.

Настройка мета-тегов

Внедрите Open Graph или другие <meta> теги в зависимости от пути запроса. Это позволяет краулерам социальных сетей получать корректные превью без полноценного фреймворка серверного рендеринга.

new HTMLRewriter()
	.on("head", {
		element(el) {
			el.append(`<meta property="og:title" content="${title}" />`, {
				html: true,
			});
		},
	})
	.transform(shell);

Добавление CSP-nonce

Генерируйте nonce для каждого запроса и добавляйте его как в заголовок Content-Security-Policy, так и в каждый встроенный <script> тег.

const nonce = crypto.randomUUID();

const response = new HTMLRewriter()
	.on("script", {
		element(el) {
			el.setAttribute("nonce", nonce);
		},
	})
	.transform(shell);

response.headers.set(
	"Content-Security-Policy",
	`script-src 'nonce-${nonce}' 'strict-dynamic';`,
);

return response;

Внедрение пользовательской конфигурации

Передавайте флаги функций или настройки, специфичные для окружения, в SPA без дополнительного обращения к API.

new HTMLRewriter()
	.on("body", {
		element(el) {
			el.prepend(
				`<script>window.__APP_CONFIG__=${JSON.stringify({
					apiBase: env.API_BASE_URL,
					featureFlags: { darkMode: true },
				})}</script>`,
				{ html: true },
			);
		},
	})
	.transform(shell);