@sveltejs/adapter-cloudflare
Version:
Adapter for building SvelteKit applications on Cloudflare Pages with Workers integration
332 lines (290 loc) • 11 kB
JavaScript
import { VERSION } from '@sveltejs/kit';
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';
import { is_building_for_cloudflare_pages, validate_worker_settings } from './utils.js';
const name = '@sveltejs/adapter-cloudflare';
const [kit_major, kit_minor] = VERSION.split('.');
/**
* @template T
* @template {keyof T} K
* @typedef {Partial<Omit<T, K>> & Required<Pick<T, K>>} PartialExcept
*/
/**
* We use a custom `Builder` type here to support the minimum version of SvelteKit.
* @typedef {PartialExcept<import('@sveltejs/kit').Builder, 'log' | 'rimraf' | 'mkdirp' | 'config' | 'prerendered' | 'routes' | 'createEntries' | 'generateFallback' | 'generateEnvModule' | 'generateManifest' | 'getBuildDirectory' | 'getClientDirectory' | 'getServerDirectory' | 'getAppPath' | 'writeClient' | 'writePrerendered' | 'writePrerendered' | 'writeServer' | 'copy' | 'compress'>} Builder2_0_0
*/
/** @type {import('./index.js').default} */
export default function (options = {}) {
return {
name,
/** @param {Builder2_0_0} builder */
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_wrangler_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) {
// wrangler doesn't resolve `assets.directory` to an absolute path unlike
// `main` and `pages_build_output_dir` so we need to do it ourselves here
const parent_dir = wrangler_config.configPath
? path.dirname(path.resolve(wrangler_config.configPath))
: process.cwd();
dest = path.resolve(parent_dir, 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}`;
builder.mkdirp(assets_dest);
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
}
});
if (builder.hasServerInstrumentationFile?.()) {
builder.instrument?.({
entrypoint: worker_dest,
instrumentation: `${builder.getServerDirectory()}/instrumentation.server.js`
});
}
// _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,
ctx: proxy.ctx,
context: proxy.ctx, // deprecated in favor of 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;
}
};
},
supports: {
read: ({ route }) => {
// TODO bump peer dep in next adapter major to simplify this
if (kit_major === '2' && kit_minor < '25') {
throw new Error(
`${name}: Cannot use \`read\` from \`$app/server\` in route \`${route.id}\` when using SvelteKit < 2.25.0`
);
}
return true;
},
instrumentation: () => true
}
};
}
/**
* @param {Builder2_0_0} builder
* @param {string[]} assets
* @param {import('./index.js').AdapterOptions['routes']} routes
* @returns {import('./index.js').RoutesJSONSpec}
*/
function get_routes_json(builder, assets, routes) {
let { include = ['/*'], exclude = ['<all>'] } = routes || {};
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 | undefined} config_file
* @returns {import('wrangler').Unstable_Config}
*/
function validate_wrangler_config(config_file = undefined) {
const wrangler_config = unstable_readConfig({ config: config_file });
if (!is_building_for_cloudflare_pages(wrangler_config)) {
// probably deploying to Cloudflare Workers
validate_worker_settings(wrangler_config);
}
return wrangler_config;
}
/** @param {string} str */
function posixify(str) {
return str.replace(/\\/g, '/');
}