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): void; }; caches?: CacheStorage & { default: Cache }; cf?: IncomingRequestCfProperties; env?: Record; } } } export type Environment = { variables?: Record; kv?: string[]; kvPersist?: boolean; d1?: string[]; d1Persist?: boolean; durableObjects?: Record; 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: * * - `` to exclude build artifacts (files generated by Vite) * - `` for the contents of your `static` directory * - `` for prerendered routes * - `` to exclude all of the above * * @default [""] */ 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; this.adapt = this.adapt.bind(this); } 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 { return new CloudflareEmulator(this.options.env); } } class CloudflareEmulator implements Emulator { private miniflare: Miniflare; private bindings: Record; 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 = {}; 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 { 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 = [""] }: 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 === "" ? ["", "", ""] : rule ) .flatMap((rule) => { if (rule === "") { return `/${builder.getAppPath()}/*`; } if (rule === "") { return assets .filter( (file) => !( file.startsWith( `${builder.config.kit.appDir}/` ) || file === "_headers" || file === "_redirects" ) ) .map((file) => `/${file}`); } if (rule === "") { 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(); }