@sveltejs/adapter-cloudflare
Version:
Adapter for building SvelteKit applications on Cloudflare Pages with Workers integration
321 lines (281 loc) • 10.3 kB
JavaScript
import { copyFileSync, existsSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { getPlatformProxy, unstable_readConfig } from 'wrangler';
/** @type {import('./index.js').default} */
export default function (options = {}) {
return {
name: '@sveltejs/adapter-cloudflare',
async adapt(builder) {
if (existsSync('_routes.json')) {
throw new Error(
"Cloudflare Pages' _routes.json should be configured in svelte.config.js. See https://svelte.dev/docs/kit/adapter-cloudflare#Options-routes"
);
}
if (existsSync(`${builder.config.kit.files.assets}/_headers`)) {
throw new Error(
`The _headers file should be placed in the project root rather than the ${builder.config.kit.files.assets} directory`
);
}
if (existsSync(`${builder.config.kit.files.assets}/_redirects`)) {
throw new Error(
`The _redirects file should be placed in the project root rather than the ${builder.config.kit.files.assets} directory`
);
}
const wrangler_config = validate_config(options.config);
const building_for_cloudflare_pages = is_building_for_cloudflare_pages(wrangler_config);
let dest = builder.getBuildDirectory('cloudflare');
let worker_dest = `${dest}/_worker.js`;
let assets_binding = 'ASSETS';
if (building_for_cloudflare_pages) {
if (wrangler_config.pages_build_output_dir) {
dest = wrangler_config.pages_build_output_dir;
worker_dest = `${dest}/_worker.js`;
}
} else {
if (wrangler_config.main) {
worker_dest = wrangler_config.main;
}
if (wrangler_config.assets?.directory) {
dest = wrangler_config.assets.directory;
}
if (wrangler_config.assets?.binding) {
assets_binding = wrangler_config.assets.binding;
}
}
const files = fileURLToPath(new URL('./files', import.meta.url).href);
const tmp = builder.getBuildDirectory('cloudflare-tmp');
builder.rimraf(dest);
builder.rimraf(worker_dest);
builder.mkdirp(dest);
builder.mkdirp(tmp);
// client assets and prerendered pages
const assets_dest = `${dest}${builder.config.kit.paths.base}`;
if (
building_for_cloudflare_pages ||
wrangler_config.assets?.not_found_handling === '404-page'
) {
// generate plaintext 404.html first which can then be overridden by prerendering, if the user defined such a page
const fallback = path.join(assets_dest, '404.html');
if (options.fallback === 'spa') {
await builder.generateFallback(fallback);
} else {
writeFileSync(fallback, 'Not Found');
}
}
const client_assets = builder.writeClient(assets_dest);
builder.writePrerendered(assets_dest);
if (
!building_for_cloudflare_pages &&
wrangler_config.assets?.not_found_handling === 'single-page-application'
) {
await builder.generateFallback(path.join(assets_dest, 'index.html'));
}
// worker
const worker_dest_dir = path.dirname(worker_dest);
writeFileSync(
`${tmp}/manifest.js`,
`export const manifest = ${builder.generateManifest({ relativePath: path.posix.relative(tmp, builder.getServerDirectory()) })};\n\n` +
`export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});\n\n` +
`export const base_path = ${JSON.stringify(builder.config.kit.paths.base)};\n`
);
builder.copy(`${files}/worker.js`, worker_dest, {
replace: {
// the paths returned by the Wrangler config might be Windows paths,
// so we need to convert them to POSIX paths or else the backslashes
// will be interpreted as escape characters and create an incorrect import path
SERVER: `${posixify(path.relative(worker_dest_dir, builder.getServerDirectory()))}/index.js`,
MANIFEST: `${posixify(path.relative(worker_dest_dir, tmp))}/manifest.js`,
ASSETS: assets_binding
}
});
// _headers
if (existsSync('_headers')) {
copyFileSync('_headers', `${dest}/_headers`);
}
writeFileSync(`${dest}/_headers`, generate_headers(builder.getAppPath()), { flag: 'a' });
// _redirects
if (existsSync('_redirects')) {
copyFileSync('_redirects', `${dest}/_redirects`);
}
if (builder.prerendered.redirects.size > 0) {
writeFileSync(`${dest}/_redirects`, generate_redirects(builder.prerendered.redirects), {
flag: 'a'
});
}
writeFileSync(`${dest}/.assetsignore`, generate_assetsignore(), { flag: 'a' });
if (building_for_cloudflare_pages) {
writeFileSync(
`${dest}/_routes.json`,
JSON.stringify(get_routes_json(builder, client_assets, options.routes ?? {}), null, '\t')
);
}
},
emulate() {
// we want to invoke `getPlatformProxy` only once, but await it only when it is accessed.
// If we would await it here, it would hang indefinitely because the platform proxy only resolves once a request happens
const get_emulated = async () => {
const proxy = await getPlatformProxy(options.platformProxy);
const platform = /** @type {App.Platform} */ ({
env: proxy.env,
context: proxy.ctx,
caches: proxy.caches,
cf: proxy.cf
});
/** @type {Record<string, any>} */
const env = {};
const prerender_platform = /** @type {App.Platform} */ (/** @type {unknown} */ ({ env }));
for (const key in proxy.env) {
Object.defineProperty(env, key, {
get: () => {
throw new Error(`Cannot access platform.env.${key} in a prerenderable route`);
}
});
}
return { platform, prerender_platform };
};
/** @type {{ platform: App.Platform, prerender_platform: App.Platform }} */
let emulated;
return {
platform: async ({ prerender }) => {
emulated ??= await get_emulated();
return prerender ? emulated.prerender_platform : emulated.platform;
}
};
}
};
}
/**
* @param {import('@sveltejs/kit').Builder} builder
* @param {string[]} assets
* @param {import('./index.js').AdapterOptions['routes']} routes
* @returns {import('./index.js').RoutesJSONSpec}
*/
function get_routes_json(builder, assets, { include = ['/*'], exclude = ['<all>'] }) {
if (!Array.isArray(include) || !Array.isArray(exclude)) {
throw new Error('routes.include and routes.exclude must be arrays');
}
if (include.length === 0) {
throw new Error('routes.include must contain at least one route');
}
if (include.length > 100) {
throw new Error('routes.include must contain 100 or fewer routes');
}
exclude = exclude
.flatMap((rule) => (rule === '<all>' ? ['<build>', '<files>', '<prerendered>'] : rule))
.flatMap((rule) => {
if (rule === '<build>') {
return [`/${builder.getAppPath()}/immutable/*`, `/${builder.getAppPath()}/version.json`];
}
if (rule === '<files>') {
return assets
.filter(
(file) =>
!(
file.startsWith(`${builder.config.kit.appDir}/`) ||
file === '_headers' ||
file === '_redirects'
)
)
.map((file) => `${builder.config.kit.paths.base}/${file}`);
}
if (rule === '<prerendered>') {
return builder.prerendered.paths;
}
return rule;
});
const excess = include.length + exclude.length - 100;
if (excess > 0) {
const message = `Cloudflare Pages Functions' includes/excludes exceeds _routes.json limits (see https://developers.cloudflare.com/pages/platform/functions/routing/#limits). Dropping ${excess} exclude rules — this will cause unnecessary function invocations.`;
builder.log.warn(message);
exclude.length -= excess;
}
return {
version: 1,
description: 'Generated by @sveltejs/adapter-cloudflare',
include,
exclude
};
}
/** @param {string} app_dir */
function generate_headers(app_dir) {
return `
# === START AUTOGENERATED SVELTE IMMUTABLE HEADERS ===
/${app_dir}/*
X-Robots-Tag: noindex
Cache-Control: no-cache
/${app_dir}/immutable/*
! Cache-Control
Cache-Control: public, immutable, max-age=31536000
# === END AUTOGENERATED SVELTE IMMUTABLE HEADERS ===
`.trimEnd();
}
/** @param {Map<string, { status: number; location: string }>} redirects */
function generate_redirects(redirects) {
const rules = Array.from(
redirects.entries(),
([path, redirect]) => `${path} ${redirect.location} ${redirect.status}`
).join('\n');
return `
# === START AUTOGENERATED SVELTE PRERENDERED REDIRECTS ===
${rules}
# === END AUTOGENERATED SVELTE PRERENDERED REDIRECTS ===
`.trimEnd();
}
function generate_assetsignore() {
// this comes from https://github.com/cloudflare/workers-sdk/blob/main/packages/create-cloudflare/templates-experimental/svelte/templates/static/.assetsignore
return `
_worker.js
_routes.json
_headers
_redirects
`;
}
/**
* @param {string} config_file
* @returns {import('wrangler').Unstable_Config}
*/
function validate_config(config_file = undefined) {
const wrangler_config = unstable_readConfig({ config: config_file });
// we don't support workers sites
if (wrangler_config.site) {
throw new Error(
`You must remove all \`site\` keys in ${wrangler_config.configPath}. Consult https://svelte.dev/docs/kit/adapter-cloudflare#Migrating-from-Workers-Sites-to-Workers-Static-Assets`
);
}
if (is_building_for_cloudflare_pages(wrangler_config)) {
return wrangler_config;
}
// probably deploying to Cloudflare Workers
if (wrangler_config.main || wrangler_config.assets) {
if (!wrangler_config.assets?.directory) {
throw new Error(
`You must specify the \`assets.directory\` key in ${wrangler_config.configPath}. Consult https://developers.cloudflare.com/workers/static-assets/binding/#directory`
);
}
if (!wrangler_config.assets?.binding) {
throw new Error(
`You must specify the \`assets.binding\` key in ${wrangler_config.configPath}. Consult https://developers.cloudflare.com/workers/static-assets/binding/#binding`
);
}
}
return wrangler_config;
}
/**
* @param {import('wrangler').Unstable_Config} wrangler_config
* @returns {boolean}
*/
function is_building_for_cloudflare_pages(wrangler_config) {
return (
!!process.env.CF_PAGES ||
!wrangler_config.configPath ||
!!wrangler_config.pages_build_output_dir ||
!wrangler_config.main ||
!wrangler_config.assets
);
}
/** @param {string} str */
function posixify(str) {
return str.replace(/\\/g, '/');
}