Skip to content

Commit

Permalink
feat(@astrojs/cloudflare): add runtime support to astro dev (#8426)
Browse files Browse the repository at this point in the history
* add necessary libs

* cleanup stale code

* add base feature-set of runtime to `astro dev`

* fix lockfile

* remove future code

Co-authored-by: Arsh <[email protected]>

* remove future code

Co-authored-by: Arsh <[email protected]>

* remove future code

Co-authored-by: Arsh <[email protected]>

* remove future code

Co-authored-by: Arsh <[email protected]>

* remove future code

Co-authored-by: Arsh <[email protected]>

* address review comments

* fix linting issue

* add docs & tests

* fix test paths

* add changeset

* update README.md

Co-authored-by: Sarah Rainsberger <[email protected]>

* fix docs & make adapter options optional

* fix package resolve mode

* fix pnpm-lock

---------

Co-authored-by: Arsh <[email protected]>
Co-authored-by: Sarah Rainsberger <[email protected]>
  • Loading branch information
3 people authored Sep 11, 2023
1 parent b384cf4 commit 2c96144
Show file tree
Hide file tree
Showing 13 changed files with 535 additions and 52 deletions.
5 changes: 5 additions & 0 deletions .changeset/smart-dragons-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': minor
---

Add support for Cloudflare Runtime (env vars, caches and req object), using `astro dev`
26 changes: 25 additions & 1 deletion packages/integrations/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ declare namespace App {
}
```

## Environment Variables
### Environment Variables

See Cloudflare's documentation for [working with environment variables](https://developers.cloudflare.com/pages/platform/functions/bindings/#environment-variables).

Expand All @@ -159,6 +159,30 @@ export function GET({ params }) {
}
```

### `cloudflare.runtime`

`runtime: "off" | "local" | "remote"`
default `"off"`

This optional flag enables the Astro dev server to populate environment variables and the Cloudflare Request Object, avoiding the need for Wrangler.

- `local`: environment variables are available, but the request object is populated from a static placeholder value.
- `remote`: environment variables and the live, fetched request object are available.
- `off`: the Astro dev server will populate neither environment variables nor the request object. Use Wrangler to access Cloudflare bindings and environment variables.

```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
output: 'server',
adapter: cloudflare({
runtime: 'off' | 'local' | 'remote',
}),
});
```

## Headers, Redirects and function invocation routes

Cloudflare has support for adding custom [headers](https://developers.cloudflare.com/pages/platform/headers/), configuring static [redirects](https://developers.cloudflare.com/pages/platform/redirects/) and defining which routes should [invoke functions](https://developers.cloudflare.com/pages/platform/functions/routing/#function-invocation-routes). Cloudflare looks for `_headers`, `_redirects`, and `_routes.json` files in your build output directory to configure these features. This means they should be placed in your Astro project’s `public/` directory.
Expand Down
8 changes: 7 additions & 1 deletion packages/integrations/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@
"@astrojs/underscore-redirects": "workspace:*",
"@cloudflare/workers-types": "^4.20230821.0",
"esbuild": "^0.19.2",
"tiny-glob": "^0.2.9"
"tiny-glob": "^0.2.9",
"find-up": "^6.3.0",
"@iarna/toml": "^2.2.5",
"dotenv": "^16.3.1",
"@miniflare/cache": "^2.14.1",
"@miniflare/shared": "^2.14.1",
"@miniflare/storage-memory": "^2.14.1"
},
"peerDependencies": {
"astro": "workspace:^3.0.12"
Expand Down
3 changes: 0 additions & 3 deletions packages/integrations/cloudflare/runtime.d.ts

This file was deleted.

149 changes: 141 additions & 8 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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: {},
Expand All @@ -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',
Expand All @@ -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 }) => {
Expand Down
134 changes: 134 additions & 0 deletions packages/integrations/cloudflare/src/parser.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 2c96144

Please sign in to comment.