455 lines
11 KiB
TypeScript
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();
|
|
}
|