Files
sveltekit-adapter-cloudflare/index.ts

455 lines
11 KiB
TypeScript

import { writeFileSync } from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { build, buildSync, formatMessages } from "esbuild";
import type { BuildFailure } from "esbuild";
import type {
Adapter,
Builder,
Emulator,
PrerenderOption,
} from "@sveltejs/kit";
import { Miniflare } from "miniflare";
import {
Cache,
CacheStorage,
IncomingRequestCfProperties,
} from "@cloudflare/workers-types";
declare global {
namespace App {
export interface Platform {
context?: {
waitUntil(promise: Promise<any>): void;
};
caches?: CacheStorage & { default: Cache };
cf?: IncomingRequestCfProperties;
env?: Record<string, any>;
}
}
}
export type Environment = {
variables?: Record<string, string>;
kv?: string[];
kvPersist?: boolean;
d1?: string[];
d1Persist?: boolean;
durableObjects?: Record<string, { class: string; src: string }>;
durableObjectsPersist?: boolean;
queueConsumers?: string[];
queueProducers?: string[];
queuesPersist?: boolean;
r2?: string[];
r2Persist?: boolean;
};
export type AdapterOptions = {
/**
* Whether to render a plaintext 404.html page, or a rendered SPA fallback page. This page will
* only be served when a request that matches an entry in `routes.exclude` fails to match an asset.
*
* Most of the time `plaintext` is sufficient, but if you are using `routes.exclude` to manually
* exclude a set of prerendered pages without exceeding the 100 route limit, you may wish to
* use `spa` instead to avoid showing an unstyled 404 page to users.
*
* @default 'plaintext'
*/
fallback?: "plaintext" | "spa";
/**
* Customize the automatically-generated `_routes.json` file
* https://developers.cloudflare.com/pages/platform/functions/routing/#create-a-_routesjson-file
*/
routes?: {
/**
* Routes that will be invoked by functions. Accepts wildcards.
* @default ["/*"]
*/
include?: string[];
/**
* Routes that will not be invoked by functions. Accepts wildcards.
* `exclude` takes priority over `include`.
*
* To have the adapter automatically exclude certain things, you can use these placeholders:
*
* - `<build>` to exclude build artifacts (files generated by Vite)
* - `<files>` for the contents of your `static` directory
* - `<prerendered>` for prerendered routes
* - `<all>` to exclude all of the above
*
* @default ["<all>"]
*/
exclude?: string[];
};
env?: Environment;
};
export type RoutesJSONSpec = {
version: 1;
description: string;
include: string[];
exclude: string[];
};
// list from https://developers.cloudflare.com/workers/runtime-apis/nodejs/
const compatible_node_modules = [
"assert",
"async_hooks",
"buffer",
"crypto",
"diagnostics_channel",
"events",
"path",
"process",
"stream",
"string_decoder",
"util",
];
class CloudflareAdapter implements Adapter {
public name = "@sveltejs/adapter-cloudflare";
private options: AdapterOptions;
constructor(options: AdapterOptions) {
this.options = options;
}
async adapt(builder: Builder) {
const files = fileURLToPath(new URL("./files", import.meta.url).href);
const dest = builder.getBuildDirectory("cloudflare");
const tmp = builder.getBuildDirectory("cloudflare-tmp");
builder.rimraf(dest);
builder.rimraf(tmp);
builder.mkdirp(dest);
builder.mkdirp(tmp);
// generate plaintext 404.html first which can then be overridden by prerendering, if the user defined such a page
const fallback = path.join(dest, "404.html");
if (this.options.fallback === "spa") {
await builder.generateFallback(fallback);
} else {
writeFileSync(fallback, "Not Found");
}
const dest_dir = `${dest}${builder.config.kit.paths.base}`;
const written_files = builder.writeClient(dest_dir);
builder.writePrerendered(dest_dir);
const relativePath = path.posix.relative(
tmp,
builder.getServerDirectory()
);
writeFileSync(
`${tmp}/manifest.js`,
`export const manifest = ${builder.generateManifest({
relativePath,
})};\n\n` +
`export const prerendered = new Set(${JSON.stringify(
builder.prerendered.paths
)});\n\n` +
`export const app_path = ${JSON.stringify(
builder.getAppPath()
)};\n`
);
writeFileSync(
`${dest}/_routes.json`,
JSON.stringify(
get_routes_json(
builder,
written_files,
this.options.routes ?? {}
),
null,
"\t"
)
);
writeFileSync(
`${dest}/_headers`,
generate_headers(builder.getAppPath()),
{ flag: "a" }
);
builder.copy(`${files}/worker.js`, `${tmp}/_worker.js`, {
replace: {
SERVER: `${relativePath}/index.js`,
MANIFEST: "./manifest.js",
},
});
const external = [
"cloudflare:*",
...compatible_node_modules.map((id) => `node:${id}`),
];
try {
const result = await build({
platform: "browser",
conditions: ["worker", "browser"],
sourcemap: "linked",
target: "es2022",
entryPoints: [`${tmp}/_worker.js`],
outfile: `${dest}/_worker.js`,
allowOverwrite: true,
format: "esm",
bundle: true,
loader: {
".wasm": "copy",
},
external,
alias: Object.fromEntries(
compatible_node_modules.map((id) => [id, `node:${id}`])
),
logLevel: "silent",
});
if (result.warnings.length > 0) {
const formatted = await formatMessages(result.warnings, {
kind: "warning",
color: true,
});
console.error(formatted.join("\n"));
}
} catch (ex) {
const error = ex as BuildFailure;
for (const e of error.errors) {
for (const node of e.notes) {
const match =
/The package "(.+)" wasn't found on the file system but is built into node/.exec(
node.text
);
if (match) {
node.text = `Cannot use "${match[1]}" when deploying to Cloudflare.`;
}
}
}
const formatted = await formatMessages(error.errors, {
kind: "error",
color: true,
});
console.error(formatted.join("\n"));
throw new Error(
`Bundling with esbuild failed with ${error.errors.length} ${
error.errors.length === 1 ? "error" : "errors"
}`
);
}
}
async emulate(): Promise<Emulator> {
return new CloudflareEmulator(this.options.env);
}
}
class CloudflareEmulator implements Emulator {
private miniflare: Miniflare;
private bindings: Record<string, any>;
private caches: CacheStorage & { default: Cache };
private cf: IncomingRequestCfProperties = {
clientTcpRtt: 36,
longitude: "13.42720",
latitude: "52.49410",
tlsCipher: "AEAD-AES128-GCM-SHA256",
continent: "EU",
asn: 64496,
clientAcceptEncoding: "gzip, deflate, br",
country: "DE",
isEUCountry: "1",
tlsClientAuth: {
certIssuerDNLegacy: "",
certIssuerSKI: "",
certSubjectDNRFC2253: "",
certSubjectDNLegacy: "",
certFingerprintSHA256: "",
certNotBefore: "",
certSKI: "",
certSerial: "",
certIssuerDN: "",
certVerified: "NONE",
certNotAfter: "",
certSubjectDN: "",
certPresented: "0",
certRevoked: "0",
certIssuerSerial: "",
certIssuerDNRFC2253: "",
certFingerprintSHA1: "",
},
tlsExportedAuthenticator: {
clientFinished:
"a0a93610f5034d0ab72d1f258cffaf85dd8a2a7297d1d38e74552419d1865a54",
clientHandshake:
"eecba94dbc48ea43ed63bb082e9498e60d0458d986311119600dcb48ecb20647",
serverHandshake:
"320728f3fa8514c9233f38bdb730c858b24043e3af5beddc723c4fb6d38152d3",
serverFinished:
"3503a40dd2267ac11ce57993016b007215454b6c935f91e9d2cac56d4b29d580",
},
tlsVersion: "TLSv1.3",
colo: "LHR",
timezone: "Europe/Berlin",
city: "Berlin",
verifiedBotCategory: "",
edgeRequestKeepAliveStatus: 1,
requestPriority: "weight=256;exclusive=1",
httpProtocol: "HTTP/2",
region: "Land Berlin",
regionCode: "BE",
asOrganization: "Example AS Organization",
postalCode: "10999",
botManagement: {
ja3Hash: "",
score: 0,
corporateProxy: false,
detectionIds: [],
staticResource: false,
verifiedBot: false,
},
clientTrustScore: 0,
hostMetadata: {},
};
constructor(env: Environment = {}) {
let durableObjects: Record<string, { className: string }> = {};
let scripts = [];
for (const [name, obj] of Object.entries(env.durableObjects ?? {})) {
durableObjects[name] = { className: obj.class };
scripts.push(obj.src);
}
const script = buildSync({
bundle: true,
format: "esm",
target: "esnext",
write: false,
entryPoints: scripts,
});
this.miniflare = new Miniflare({
script:
`export default {async fetch() {return new Response("Hello world!");}};` +
script.outputFiles[0].text,
modules: true,
cache: true,
kvNamespaces: env.kv,
kvPersist: env.kvPersist,
durableObjects: env.durableObjects ? durableObjects : undefined,
durableObjectsPersist: env.durableObjectsPersist,
d1Databases: env.d1,
d1Persist: env.d1Persist,
queueConsumers: env.queueConsumers,
queueProducers: env.queueProducers,
r2Buckets: env.r2,
r2Persist: env.r2Persist,
bindings: env.variables,
});
this.bindings = this.miniflare.getBindings();
// @ts-ignore
this.caches = this.miniflare.getCaches();
}
async platform(details: {
config: any;
prerender: PrerenderOption;
}): Promise<App.Platform> {
return {
env: await this.bindings,
caches: await this.caches,
cf: this.cf,
};
}
}
export default function cloudflare(options: AdapterOptions = {}): Adapter {
return new CloudflareAdapter(options);
}
function get_routes_json(
builder: Builder,
assets: string[],
{ include = ["/*"], exclude = ["<all>"] }: AdapterOptions["routes"] = {}
): RoutesJSONSpec {
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()}/*`;
}
if (rule === "<files>") {
return assets
.filter(
(file) =>
!(
file.startsWith(
`${builder.config.kit.appDir}/`
) ||
file === "_headers" ||
file === "_redirects"
)
)
.map((file) => `/${file}`);
}
if (rule === "<prerendered>") {
const prerendered = [];
for (const path of builder.prerendered.paths) {
if (!builder.prerendered.redirects.has(path)) {
prerendered.push(path);
}
}
return prerendered;
}
return rule;
});
const excess = include.length + exclude.length - 100;
if (excess > 0) {
const message = `Function 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,
};
}
function generate_headers(app_dir: string) {
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();
}