diff options
author | 2023-09-11 20:04:44 +0200 | |
---|---|---|
committer | 2023-09-11 23:34:44 +0530 | |
commit | 772da934e8324bf6d5b2be76766b131931772f8f (patch) | |
tree | f5a7a9fe1ff9c2008aea52d31f8f608835eebb82 /packages/integrations/cloudflare/src | |
parent | cd4cc6b33114ae87dc66f11165f9ea95965a0b6b (diff) | |
download | astro-772da934e8324bf6d5b2be76766b131931772f8f.tar.gz astro-772da934e8324bf6d5b2be76766b131931772f8f.tar.zst astro-772da934e8324bf6d5b2be76766b131931772f8f.zip |
feat(@astrojs/cloudflare): add runtime support to `astro dev` (#8426)
* add necessary libs
* cleanup stale code
* add base feature-set of runtime to `astro dev`
* fix lockfile
* remove future code
Co-authored-by: Arsh <69170106+lilnasy@users.noreply.github.com>
* remove future code
Co-authored-by: Arsh <69170106+lilnasy@users.noreply.github.com>
* remove future code
Co-authored-by: Arsh <69170106+lilnasy@users.noreply.github.com>
* remove future code
Co-authored-by: Arsh <69170106+lilnasy@users.noreply.github.com>
* remove future code
Co-authored-by: Arsh <69170106+lilnasy@users.noreply.github.com>
* address review comments
* fix linting issue
* add docs & tests
* fix test paths
* add changeset
* update README.md
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
* fix docs & make adapter options optional
* fix package resolve mode
* fix pnpm-lock
---------
Co-authored-by: Arsh <69170106+lilnasy@users.noreply.github.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Diffstat (limited to 'packages/integrations/cloudflare/src')
-rw-r--r-- | packages/integrations/cloudflare/src/index.ts | 149 | ||||
-rw-r--r-- | packages/integrations/cloudflare/src/parser.ts | 134 | ||||
-rw-r--r-- | packages/integrations/cloudflare/src/server.advanced.ts | 13 | ||||
-rw-r--r-- | packages/integrations/cloudflare/src/server.directory.ts | 15 |
4 files changed, 276 insertions, 35 deletions
diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 718b1efa8..c70c9c5aa 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -1,18 +1,31 @@ -import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects'; +import type { IncomingRequestCfProperties } from '@cloudflare/workers-types/experimental'; import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro'; + +import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects'; +import { CacheStorage } from '@miniflare/cache'; +import { NoOpLog } from '@miniflare/shared'; +import { MemoryStorage } from '@miniflare/storage-memory'; +import { AstroError } from 'astro/errors'; import esbuild from 'esbuild'; import * as fs from 'node:fs'; import * as os from 'node:os'; import { sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import glob from 'tiny-glob'; +import { getEnvVars } from './parser.js'; export type { AdvancedRuntime } from './server.advanced'; export type { DirectoryRuntime } from './server.directory'; type Options = { - mode: 'directory' | 'advanced'; + mode?: 'directory' | 'advanced'; functionPerRoute?: boolean; + /** + * 'off': current behaviour (wrangler is needed) + * 'local': use a static req.cf object, and env vars defined in wrangler.toml & .dev.vars (astro dev is enough) + * 'remote': use a dynamic real-live req.cf object, and env vars defined in wrangler.toml & .dev.vars (astro dev is enough) + */ + runtime?: 'off' | 'local' | 'remote'; }; interface BuildConfig { @@ -22,6 +35,17 @@ interface BuildConfig { split?: boolean; } +class StorageFactory { + storages = new Map(); + + storage(namespace: string) { + let storage = this.storages.get(namespace); + if (storage) return storage; + this.storages.set(namespace, (storage = new MemoryStorage())); + return storage; + } +} + export function getAdapter({ isModeDirectory, functionPerRoute, @@ -66,6 +90,73 @@ export function getAdapter({ }; } +async function getCFObject(runtimeMode: string): Promise<IncomingRequestCfProperties | void> { + const CF_ENDPOINT = 'https://workers.cloudflare.com/cf.json'; + const CF_FALLBACK: IncomingRequestCfProperties = { + asOrganization: '', + asn: 395747, + colo: 'DFW', + city: 'Austin', + region: 'Texas', + regionCode: 'TX', + metroCode: '635', + postalCode: '78701', + country: 'US', + continent: 'NA', + timezone: 'America/Chicago', + latitude: '30.27130', + longitude: '-97.74260', + clientTcpRtt: 0, + httpProtocol: 'HTTP/1.1', + requestPriority: 'weight=192;exclusive=0', + tlsCipher: 'AEAD-AES128-GCM-SHA256', + tlsVersion: 'TLSv1.3', + tlsClientAuth: { + certPresented: '0', + certVerified: 'NONE', + certRevoked: '0', + certIssuerDN: '', + certSubjectDN: '', + certIssuerDNRFC2253: '', + certSubjectDNRFC2253: '', + certIssuerDNLegacy: '', + certSubjectDNLegacy: '', + certSerial: '', + certIssuerSerial: '', + certSKI: '', + certIssuerSKI: '', + certFingerprintSHA1: '', + certFingerprintSHA256: '', + certNotBefore: '', + certNotAfter: '', + }, + edgeRequestKeepAliveStatus: 0, + hostMetadata: undefined, + clientTrustScore: 99, + botManagement: { + corporateProxy: false, + verifiedBot: false, + ja3Hash: '25b4882c2bcb50cd6b469ff28c596742', + staticResource: false, + detectionIds: [], + score: 99, + }, + }; + + if (runtimeMode === 'local') { + return CF_FALLBACK; + } else if (runtimeMode === 'remote') { + try { + const res = await fetch(CF_ENDPOINT); + const cfText = await res.text(); + const storedCf = JSON.parse(cfText); + return storedCf; + } catch (e: any) { + return CF_FALLBACK; + } + } +} + const SHIM = `globalThis.process = { argv: [], env: {}, @@ -85,6 +176,7 @@ export default function createIntegration(args?: Options): AstroIntegration { const isModeDirectory = args?.mode === 'directory'; const functionPerRoute = args?.functionPerRoute ?? false; + const runtimeMode = args?.runtime ?? 'off'; return { name: '@astrojs/cloudflare', @@ -105,15 +197,56 @@ export default function createIntegration(args?: Options): AstroIntegration { _buildConfig = config.build; if (config.output === 'static') { - throw new Error(` - [@astrojs/cloudflare] \`output: "server"\` or \`output: "hybrid"\` is required to use this adapter. Otherwise, this adapter is not necessary to deploy a static site to Cloudflare. - -`); + throw new AstroError( + '[@astrojs/cloudflare] `output: "server"` or `output: "hybrid"` is required to use this adapter. Otherwise, this adapter is not necessary to deploy a static site to Cloudflare.' + ); } if (config.base === SERVER_BUILD_FOLDER) { - throw new Error(` - [@astrojs/cloudflare] \`base: "${SERVER_BUILD_FOLDER}"\` is not allowed. Please change your \`base\` config to something else.`); + throw new AstroError( + '[@astrojs/cloudflare] `base: "${SERVER_BUILD_FOLDER}"` is not allowed. Please change your `base` config to something else.' + ); + } + }, + 'astro:server:setup': ({ server }) => { + if (runtimeMode !== 'off') { + server.middlewares.use(async function middleware(req, res, next) { + try { + const cf = await getCFObject(runtimeMode); + const vars = await getEnvVars(); + + const clientLocalsSymbol = Symbol.for('astro.locals'); + Reflect.set(req, clientLocalsSymbol, { + runtime: { + env: { + // default binding for static assets will be dynamic once we support mocking of bindings + ASSETS: {}, + // this is just a VAR for CF to change build behavior, on dev it should be 0 + CF_PAGES: '0', + // will be fetched from git dynamically once we support mocking of bindings + CF_PAGES_BRANCH: 'TBA', + // will be fetched from git dynamically once we support mocking of bindings + CF_PAGES_COMMIT_SHA: 'TBA', + CF_PAGES_URL: `http://${req.headers.host}`, + ...vars, + }, + cf: cf, + waitUntil: (_promise: Promise<any>) => { + return; + }, + caches: new CacheStorage( + { cache: true, cachePersist: false }, + new NoOpLog(), + new StorageFactory(), + {} + ), + }, + }); + next(); + } catch { + next(); + } + }); } }, 'astro:build:setup': ({ vite, target }) => { diff --git a/packages/integrations/cloudflare/src/parser.ts b/packages/integrations/cloudflare/src/parser.ts new file mode 100644 index 000000000..d7130ff9d --- /dev/null +++ b/packages/integrations/cloudflare/src/parser.ts @@ -0,0 +1,134 @@ +/** + * This file is a derivative work of wrangler by Cloudflare + * An upstream request for exposing this API was made here: + * https://github.com/cloudflare/workers-sdk/issues/3897 + * + * Until further notice, we will be using this file as a workaround + * TODO: Tackle this file, once their is an decision on the upstream request + */ + +import * as fs from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { findUpSync } from 'find-up'; +import TOML from '@iarna/toml'; +import dotenv from 'dotenv'; + +function findWranglerToml( + referencePath: string = process.cwd(), + preferJson = false +): string | undefined { + if (preferJson) { + return ( + findUpSync(`wrangler.json`, { cwd: referencePath }) ?? + findUpSync(`wrangler.toml`, { cwd: referencePath }) + ); + } + return findUpSync(`wrangler.toml`, { cwd: referencePath }); +} +type File = { + file?: string; + fileText?: string; +}; +type Location = File & { + line: number; + column: number; + length?: number; + lineText?: string; + suggestion?: string; +}; +type Message = { + text: string; + location?: Location; + notes?: Message[]; + kind?: 'warning' | 'error'; +}; +class ParseError extends Error implements Message { + readonly text: string; + readonly notes: Message[]; + readonly location?: Location; + readonly kind: 'warning' | 'error'; + + constructor({ text, notes, location, kind }: Message) { + super(text); + this.name = this.constructor.name; + this.text = text; + this.notes = notes ?? []; + this.location = location; + this.kind = kind ?? 'error'; + } +} +const TOML_ERROR_NAME = 'TomlError'; +const TOML_ERROR_SUFFIX = ' at row '; +type TomlError = Error & { + line: number; + col: number; +}; +function parseTOML(input: string, file?: string): TOML.JsonMap | never { + try { + // Normalize CRLF to LF to avoid hitting https://github.com/iarna/iarna-toml/issues/33. + const normalizedInput = input.replace(/\r\n/g, '\n'); + return TOML.parse(normalizedInput); + } catch (err) { + const { name, message, line, col } = err as TomlError; + if (name !== TOML_ERROR_NAME) { + throw err; + } + const text = message.substring(0, message.lastIndexOf(TOML_ERROR_SUFFIX)); + const lineText = input.split('\n')[line]; + const location = { + lineText, + line: line + 1, + column: col - 1, + file, + fileText: input, + }; + throw new ParseError({ text, location }); + } +} + +export interface DotEnv { + path: string; + parsed: dotenv.DotenvParseOutput; +} +function tryLoadDotEnv(path: string): DotEnv | undefined { + try { + const parsed = dotenv.parse(fs.readFileSync(path)); + return { path, parsed }; + } catch (e) { + // logger.debug(`Failed to load .env file "${path}":`, e); + } +} +/** + * Loads a dotenv file from <path>, preferring to read <path>.<environment> if + * <environment> is defined and that file exists. + */ + +export function loadDotEnv(path: string): DotEnv | undefined { + return tryLoadDotEnv(path); +} +function getVarsForDev(config: any, configPath: string | undefined): any { + const configDir = resolve(dirname(configPath ?? '.')); + const devVarsPath = resolve(configDir, '.dev.vars'); + const loaded = loadDotEnv(devVarsPath); + if (loaded !== undefined) { + return { + ...config.vars, + ...loaded.parsed, + }; + } else { + return config.vars; + } +} +export async function getEnvVars() { + let rawConfig; + const configPath = findWranglerToml(process.cwd(), false); // false = args.experimentalJsonConfig + if (!configPath) { + throw new Error('Could not find wrangler.toml'); + } + // Load the configuration from disk if available + if (configPath?.endsWith('toml')) { + rawConfig = parseTOML(fs.readFileSync(configPath).toString(), configPath); + } + const vars = getVarsForDev(rawConfig, configPath); + return vars; +} diff --git a/packages/integrations/cloudflare/src/server.advanced.ts b/packages/integrations/cloudflare/src/server.advanced.ts index 24358a5e0..6e305b1b9 100644 --- a/packages/integrations/cloudflare/src/server.advanced.ts +++ b/packages/integrations/cloudflare/src/server.advanced.ts @@ -44,19 +44,6 @@ export function createExports(manifest: SSRManifest) { request.headers.get('cf-connecting-ip') ); - // `getRuntime()` is deprecated, currently available additionally to new Astro.locals.runtime - // TODO: remove `getRuntime()` in Astro 3.0 - Reflect.set(request, Symbol.for('runtime'), { - env, - name: 'cloudflare', - caches, - cf: request.cf, - ...context, - waitUntil: (promise: Promise<any>) => { - context.waitUntil(promise); - }, - }); - const locals: AdvancedRuntime = { runtime: { waitUntil: (promise: Promise<any>) => { diff --git a/packages/integrations/cloudflare/src/server.directory.ts b/packages/integrations/cloudflare/src/server.directory.ts index 64d820d99..48c97392c 100644 --- a/packages/integrations/cloudflare/src/server.directory.ts +++ b/packages/integrations/cloudflare/src/server.directory.ts @@ -21,7 +21,7 @@ export function createExports(manifest: SSRManifest) { const onRequest = async (context: EventContext<unknown, string, unknown>) => { const request = context.request as CFRequest & Request; - const { next, env } = context; + const { env } = context; // TODO: remove this any cast in the future // REF: the type cast to any is needed because the Cloudflare Env Type is not assignable to type 'ProcessEnv' @@ -41,19 +41,6 @@ export function createExports(manifest: SSRManifest) { request.headers.get('cf-connecting-ip') ); - // `getRuntime()` is deprecated, currently available additionally to new Astro.locals.runtime - // TODO: remove `getRuntime()` in Astro 3.0 - Reflect.set(request, Symbol.for('runtime'), { - ...context, - waitUntil: (promise: Promise<any>) => { - context.waitUntil(promise); - }, - name: 'cloudflare', - next, - caches, - cf: request.cf, - }); - const locals: DirectoryRuntime = { runtime: { waitUntil: (promise: Promise<any>) => { |