@astrojs/cloudflare
Version:
Deploy your site to Cloudflare Workers/Pages
296 lines (295 loc) • 11.2 kB
JavaScript
import { createReadStream } from "node:fs";
import { appendFile, stat } from "node:fs/promises";
import { createRequire } from "node:module";
import { createInterface } from "node:readline/promises";
import { pathToFileURL } from "node:url";
import {
appendForwardSlash,
prependForwardSlash,
removeLeadingForwardSlash
} from "@astrojs/internal-helpers/path";
import { createRedirectsFromAstroRoutes, printAsRedirects } from "@astrojs/underscore-redirects";
import { AstroError } from "astro/errors";
import { defaultClientConditions } from "vite";
import { getPlatformProxy } from "wrangler";
import {
cloudflareModuleLoader
} from "./utils/cloudflare-module-loader.js";
import { createGetEnv } from "./utils/env.js";
import { createRoutesFile, getParts } from "./utils/generate-routes-json.js";
import { setImageConfig } from "./utils/image-config.js";
function wrapWithSlashes(path) {
return prependForwardSlash(appendForwardSlash(path));
}
function setProcessEnv(config, env) {
const getEnv = createGetEnv(env);
if (config.env?.schema) {
for (const key of Object.keys(config.env.schema)) {
const value = getEnv(key);
if (value !== void 0) {
process.env[key] = value;
}
}
}
}
function createIntegration(args) {
let _config;
let finalBuildOutput;
const cloudflareModulePlugin = cloudflareModuleLoader(
args?.cloudflareModules ?? true
);
let _routes;
const SESSION_KV_BINDING_NAME = args?.sessionKVBindingName ?? "SESSION";
return {
name: "@astrojs/cloudflare",
hooks: {
"astro:config:setup": ({
command,
config,
updateConfig,
logger,
addWatchFile,
addMiddleware
}) => {
let session = config.session;
if (!session?.driver) {
logger.info(
`Enabling sessions with Cloudflare KV with the "${SESSION_KV_BINDING_NAME}" KV binding.`
);
logger.info(
`If you see the error "Invalid binding \`${SESSION_KV_BINDING_NAME}\`" in your build output, you need to add the binding to your wrangler config file.`
);
session = {
...session,
driver: "cloudflare-kv-binding",
options: {
binding: SESSION_KV_BINDING_NAME,
...session?.options
}
};
}
updateConfig({
build: {
client: new URL(`.${wrapWithSlashes(config.base)}`, config.outDir),
server: new URL("./_worker.js/", config.outDir),
serverEntry: "index.js",
redirects: false
},
session,
vite: {
plugins: [
// https://developers.cloudflare.com/pages/functions/module-support/
// Allows imports of '.wasm', '.bin', and '.txt' file types
cloudflareModulePlugin,
{
name: "vite:cf-imports",
enforce: "pre",
resolveId(source) {
if (source.startsWith("cloudflare:")) {
return { id: source, external: true };
}
return null;
}
}
]
},
image: setImageConfig(args?.imageService ?? "compile", config.image, command, logger)
});
if (args?.platformProxy?.configPath) {
addWatchFile(new URL(args.platformProxy.configPath, config.root));
} else {
addWatchFile(new URL("./wrangler.toml", config.root));
addWatchFile(new URL("./wrangler.json", config.root));
addWatchFile(new URL("./wrangler.jsonc", config.root));
}
addMiddleware({
entrypoint: "@astrojs/cloudflare/entrypoints/middleware.js",
order: "pre"
});
},
"astro:routes:resolved": ({ routes }) => {
_routes = routes;
},
"astro:config:done": ({ setAdapter, config, buildOutput, logger }) => {
if (buildOutput === "static") {
logger.warn(
"[@astrojs/cloudflare] This adapter is intended to be used with server rendered pages, which this project does not contain any of. As such, this adapter is unnecessary."
);
}
_config = config;
finalBuildOutput = buildOutput;
let customWorkerEntryPoint;
if (args?.workerEntryPoint && typeof args.workerEntryPoint.path === "string") {
const require2 = createRequire(config.root);
try {
customWorkerEntryPoint = pathToFileURL(require2.resolve(args.workerEntryPoint.path));
} catch {
customWorkerEntryPoint = new URL(args.workerEntryPoint.path, config.root);
}
}
setAdapter({
name: "@astrojs/cloudflare",
serverEntrypoint: customWorkerEntryPoint ?? "@astrojs/cloudflare/entrypoints/server.js",
exports: args?.workerEntryPoint?.namedExports ? ["default", ...args.workerEntryPoint.namedExports] : ["default"],
adapterFeatures: {
edgeMiddleware: false,
buildOutput: "server"
},
supportedAstroFeatures: {
serverOutput: "stable",
hybridOutput: "stable",
staticOutput: "unsupported",
i18nDomains: "experimental",
sharpImageService: {
support: "limited",
message: 'Cloudflare does not support sharp at runtime. However, you can configure `imageService: "compile"` to optimize images with sharp on prerendered pages during build time.',
// For explicitly set image services, we suppress the warning about sharp not being supported at runtime,
// inferring the user is aware of the limitations.
suppress: args?.imageService ? "all" : "default"
},
envGetSecret: "stable"
}
});
},
"astro:server:setup": async ({ server }) => {
if ((args?.platformProxy?.enabled ?? true) === true) {
const platformProxy = await getPlatformProxy(args?.platformProxy);
server.httpServer?.on("close", async () => {
await platformProxy.dispose();
});
setProcessEnv(_config, platformProxy.env);
globalThis.__env__ ??= {};
globalThis.__env__[SESSION_KV_BINDING_NAME] = platformProxy.env[SESSION_KV_BINDING_NAME];
const clientLocalsSymbol = Symbol.for("astro.locals");
server.middlewares.use(async function middleware(req, _res, next) {
Reflect.set(req, clientLocalsSymbol, {
runtime: {
env: platformProxy.env,
cf: platformProxy.cf,
caches: platformProxy.caches,
ctx: {
waitUntil: (promise) => platformProxy.ctx.waitUntil(promise),
// Currently not available: https://developers.cloudflare.com/pages/platform/known-issues/#pages-functions
passThroughOnException: () => {
throw new AstroError(
"`passThroughOnException` is currently not available in Cloudflare Pages. See https://developers.cloudflare.com/pages/platform/known-issues/#pages-functions."
);
}
}
}
});
next();
});
}
},
"astro:build:setup": ({ vite, target }) => {
if (target === "server") {
vite.resolve ||= {};
vite.resolve.alias ||= {};
const aliases = [
{
find: "react-dom/server",
replacement: "react-dom/server.browser"
}
];
if (Array.isArray(vite.resolve.alias)) {
vite.resolve.alias = [...vite.resolve.alias, ...aliases];
} else {
for (const alias of aliases) {
vite.resolve.alias[alias.find] = alias.replacement;
}
}
vite.ssr ||= {};
vite.ssr.resolve ||= {};
vite.ssr.resolve.conditions ||= [...defaultClientConditions];
vite.ssr.resolve.conditions.push("workerd", "worker");
vite.ssr.target = "webworker";
vite.ssr.noExternal = true;
vite.build ||= {};
vite.build.rollupOptions ||= {};
vite.build.rollupOptions.output ||= {};
vite.build.rollupOptions.output.banner ||= "globalThis.process ??= {}; globalThis.process.env ??= {};";
vite.define = {
"process.env": "process.env",
// Allows the request handler to know what the binding name is
"globalThis.__ASTRO_SESSION_BINDING_NAME": JSON.stringify(SESSION_KV_BINDING_NAME),
...vite.define
};
}
},
"astro:build:done": async ({ pages, dir, logger, assets }) => {
await cloudflareModulePlugin.afterBuildCompleted(_config);
let redirectsExists = false;
try {
const redirectsStat = await stat(new URL("./_redirects", _config.outDir));
if (redirectsStat.isFile()) {
redirectsExists = true;
}
} catch (_error) {
redirectsExists = false;
}
const redirects = [];
if (redirectsExists) {
const rl = createInterface({
input: createReadStream(new URL("./_redirects", _config.outDir)),
crlfDelay: Number.POSITIVE_INFINITY
});
for await (const line of rl) {
const parts = line.split(" ");
if (parts.length >= 2) {
const p = removeLeadingForwardSlash(parts[0]).split("/").filter(Boolean).map((s) => {
const syntax = s.replace(/\/:.*?(?=\/|$)/g, "/*").replace(/\?.*$/, "");
return getParts(syntax);
});
redirects.push(p);
}
}
}
let routesExists = false;
try {
const routesStat = await stat(new URL("./_routes.json", _config.outDir));
if (routesStat.isFile()) {
routesExists = true;
}
} catch (_error) {
routesExists = false;
}
if (!routesExists) {
await createRoutesFile(
_config,
logger,
_routes,
pages,
redirects,
args?.routes?.extend?.include,
args?.routes?.extend?.exclude
);
}
const trueRedirects = createRedirectsFromAstroRoutes({
config: _config,
routeToDynamicTargetMap: new Map(
Array.from(
_routes.filter((route) => route.type === "redirect").map((route) => [route, ""])
)
),
dir,
buildOutput: finalBuildOutput,
assets
});
if (!trueRedirects.empty()) {
try {
await appendFile(
new URL("./_redirects", _config.outDir),
printAsRedirects(trueRedirects)
);
} catch (_error) {
logger.error("Failed to write _redirects file");
}
}
}
}
};
}
export {
createIntegration as default
};