feat!: migrate to TypeScript and implement emulate using Miniflare
Signed-off-by: Pascal Vorwerk <info@fossores.de>
This commit is contained in:
454
index.ts
Normal file
454
index.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user