diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/.eslintrc.cjs b/dev-packages/e2e-tests/test-applications/cloudflare-remix/.eslintrc.cjs new file mode 100644 index 000000000000..4f6f59eee1e8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/.eslintrc.cjs @@ -0,0 +1,84 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + ignorePatterns: ["!**/.server", "!**/.client"], + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + }, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + }, + + // Node + { + files: [".eslintrc.cjs"], + env: { + node: true, + }, + }, + ], +}; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-remix/.gitignore new file mode 100644 index 000000000000..464354906a64 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/.gitignore @@ -0,0 +1,8 @@ +node_modules + +/.cache +/build +.env +.dev.vars + +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/.npmrc b/dev-packages/e2e-tests/test-applications/cloudflare-remix/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/README.md b/dev-packages/e2e-tests/test-applications/cloudflare-remix/README.md new file mode 100644 index 000000000000..dec7f30b37ac --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/README.md @@ -0,0 +1,47 @@ +# Welcome to Remix + Cloudflare! + +- 📖 [Remix docs](https://remix.run/docs) +- 📖 [Remix Cloudflare docs](https://remix.run/guides/vite#cloudflare) + +## Development + +Run the dev server: + +```sh +npm run dev +``` + +To run Wrangler: + +```sh +npm run build +npm run start +``` + +## Typegen + +Generate types for your Cloudflare bindings in `wrangler.toml`: + +```sh +npm run typegen +``` + +You will need to rerun typegen whenever you make changes to `wrangler.toml`. + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then, deploy your app to Cloudflare Pages: + +```sh +npm run deploy +``` + +## Styling + +This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever css framework you prefer. See the [Vite docs on css](https://vitejs.dev/guide/features.html#css) for more information. diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/entry.client.tsx new file mode 100644 index 000000000000..94d5dc0de0fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/entry.client.tsx @@ -0,0 +1,18 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/entry.server.tsx new file mode 100644 index 000000000000..0d5c40a755e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/entry.server.tsx @@ -0,0 +1,43 @@ +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import type { AppLoadContext, EntryContext } from "@remix-run/cloudflare"; +import { RemixServer } from "@remix-run/react"; +import { isbot } from "isbot"; +import { renderToReadableStream } from "react-dom/server"; + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + // This is ignored so we can keep it in the template for visibility. Feel + // free to delete this parameter in your app if you're not using it! + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loadContext: AppLoadContext +) { + const body = await renderToReadableStream( + , + { + signal: request.signal, + onError(error: unknown) { + // Log streaming rendering errors from inside the shell + console.error(error); + responseStatusCode = 500; + }, + } + ); + + if (isbot(request.headers.get("user-agent") || "")) { + await body.allReady; + } + + responseHeaders.set("Content-Type", "text/html"); + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/root.tsx b/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/root.tsx new file mode 100644 index 000000000000..3d3d73320719 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/root.tsx @@ -0,0 +1,30 @@ +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; +import "./tailwind.css"; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/routes/_index.tsx new file mode 100644 index 000000000000..5c68e88b6e2f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/routes/_index.tsx @@ -0,0 +1,41 @@ +import type { MetaFunction } from "@remix-run/cloudflare"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { + name: "description", + content: "Welcome to Remix on Cloudflare!", + }, + ]; +}; + +export default function Index() { + return ( +
+

Welcome to Remix on Cloudflare

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/tailwind.css b/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/tailwind.css new file mode 100644 index 000000000000..b5c61c956711 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/app/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/functions/[[path]].ts b/dev-packages/e2e-tests/test-applications/cloudflare-remix/functions/[[path]].ts new file mode 100644 index 000000000000..08576b33f177 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/functions/[[path]].ts @@ -0,0 +1,13 @@ +import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages'; +import { sentryPagesPlugin } from '@sentry/cloudflare'; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore - the server build file is generated by `remix vite:build` +// eslint-disable-next-line import/no-unresolved +import * as build from '../build/server'; + +export const onRequest = [ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context: any) => sentryPagesPlugin({ dsn: context.env.E2E_TEST_DSN })(context), + createPagesFunctionHandler({ build }), +]; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/load-context.ts b/dev-packages/e2e-tests/test-applications/cloudflare-remix/load-context.ts new file mode 100644 index 000000000000..2777ca18c051 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/load-context.ts @@ -0,0 +1,17 @@ +import { type PlatformProxy } from "wrangler"; + +// When using `wrangler.toml` to configure bindings, +// `wrangler types` will generate types for those bindings +// into the global `Env` interface. +// Need this empty interface so that typechecking passes +// even if no `wrangler.toml` exists. +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface Env {} + +type Cloudflare = Omit, "dispose">; + +declare module "@remix-run/cloudflare" { + interface AppLoadContext { + cloudflare: Cloudflare; + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-remix/package.json new file mode 100644 index 000000000000..cefbf34c9f98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/package.json @@ -0,0 +1,52 @@ +{ + "name": "cloudflare-remix", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "remix vite:build", + "deploy": "wrangler pages deploy ./build/client", + "dev": "remix vite:dev", + "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", + "start": "wrangler pages dev ./build/client --binding=E2E_TEST_DSN=$E2E_TEST_DSN --port=$PORT", + "typecheck": "tsc", + "typegen": "wrangler types", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "pnpm playwright test" + }, + "dependencies": { + "@sentry/cloudflare": "latest || *", + "@remix-run/cloudflare": "^2.10.3", + "@remix-run/cloudflare-pages": "^2.10.3", + "@remix-run/react": "^2.10.3", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240512.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@playwright/test": "^1.44.1", + "@remix-run/dev": "^2.10.3", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.7.4", + "@typescript-eslint/parser": "^6.7.4", + "autoprefixer": "^10.4.19", + "eslint": "^8.38.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.1.6", + "vite": "^5.1.0", + "vite-tsconfig-paths": "^4.2.1", + "wrangler": "3.57.1" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-remix/playwright.config.mjs new file mode 100644 index 000000000000..fc1939297a31 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + // startCommand: 'pnpm start', +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/postcss.config.js b/dev-packages/e2e-tests/test-applications/cloudflare-remix/postcss.config.js new file mode 100644 index 000000000000..2aa7205d4b40 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/public/_headers b/dev-packages/e2e-tests/test-applications/cloudflare-remix/public/_headers new file mode 100644 index 000000000000..f9e277752020 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/public/_headers @@ -0,0 +1,4 @@ +/favicon.ico + Cache-Control: public, max-age=3600, s-maxage=3600 +/assets/* + Cache-Control: public, max-age=31536000, immutable diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/public/_routes.json b/dev-packages/e2e-tests/test-applications/cloudflare-remix/public/_routes.json new file mode 100644 index 000000000000..b042b3ec08ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/public/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/*"], + "exclude": ["/favicon.ico", "/assets/*"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/public/favicon.ico b/dev-packages/e2e-tests/test-applications/cloudflare-remix/public/favicon.ico new file mode 100644 index 000000000000..8830cf6821b3 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/cloudflare-remix/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-remix/start-event-proxy.mjs new file mode 100644 index 000000000000..05c02137ae73 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-remix', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/tailwind.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-remix/tailwind.config.ts new file mode 100644 index 000000000000..34d03da55f6e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/tailwind.config.ts @@ -0,0 +1,9 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: {}, + }, + plugins: [], +} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-remix/tests/transaction.test.ts new file mode 100644 index 000000000000..1c30b2a23927 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/tests/transaction.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; + +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should send a transaction', async ({ page }) => { + const transactionPromise = waitForTransaction('cloudflare-remix', async transactionEvent => { + return transactionEvent?.transaction === 'GET /'; + }); + + await page.goto(`/`); + + await expect(transactionPromise).resolves.toBeDefined(); + + const transactionEvent = await transactionPromise; + + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'http.method': 'GET', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.node_fetch', + }), + description: 'GET http://example.com/', + }), + ); + + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'http.method': 'GET', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + }), + description: 'GET http://example.com/', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-remix/tsconfig.json new file mode 100644 index 000000000000..a61e663a7651 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/tsconfig.json @@ -0,0 +1,32 @@ +{ + "include": [ + "**/*.ts", + "**/*.tsx", + "**/.server/**/*.ts", + "**/.server/**/*.tsx", + "**/.client/**/*.ts", + "**/.client/**/*.tsx" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["@remix-run/cloudflare", "vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Vite takes care of building everything, not tsc. + "noEmit": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-remix/vite.config.ts new file mode 100644 index 000000000000..8c8a052d9af3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/vite.config.ts @@ -0,0 +1,20 @@ +import { + vitePlugin as remix, + cloudflareDevProxyVitePlugin as remixCloudflareDevProxy, +} from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + remixCloudflareDevProxy(), + remix({ + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + }, + }), + tsconfigPaths(), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/worker-configuration.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-remix/worker-configuration.d.ts new file mode 100644 index 000000000000..5b2319b3f29f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/worker-configuration.d.ts @@ -0,0 +1,4 @@ +// Generated by Wrangler +// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen` +interface Env { +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-remix/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-remix/wrangler.toml new file mode 100644 index 000000000000..396cc92a62da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-remix/wrangler.toml @@ -0,0 +1,87 @@ +#:schema node_modules/wrangler/config-schema.json +name = "cloudflare-remix" +compatibility_date = "2024-07-25" +pages_build_output_dir = "./build/client" + +compatibility_flags = ["nodejs_als"] + +# Automatically place your workloads in an optimal location to minimize latency. +# If you are running back-end logic in a Pages Function, running it closer to your back-end infrastructure +# rather than the end user may result in better performance. +# Docs: https://developers.cloudflare.com/pages/functions/smart-placement/#smart-placement +# [placement] +# mode = "smart" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Docs: +# - https://developers.cloudflare.com/pages/functions/bindings/#environment-variables +# Note: Use secrets to store sensitive data. +# - https://developers.cloudflare.com/pages/functions/bindings/#secrets +# [vars] +# MY_VARIABLE = "production_value" + +# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network +# Docs: https://developers.cloudflare.com/pages/functions/bindings/#workers-ai +# [ai] +# binding = "AI" + +# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. +# Docs: https://developers.cloudflare.com/pages/functions/bindings/#d1-databases +# [[d1_databases]] +# binding = "MY_DB" +# database_name = "my-database" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects +# [[durable_objects.bindings]] +# name = "MY_DURABLE_OBJECT" +# class_name = "MyDurableObject" +# script_name = 'my-durable-object' + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/pages/functions/bindings/#kv-namespaces +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/pages/functions/bindings/#queue-producers +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/pages/functions/bindings/#r2-buckets +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/pages/functions/bindings/#service-bindings +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# To use different bindings for preview and production environments, follow the examples below. +# When using environment-specific overrides for bindings, ALL bindings must be specified on a per-environment basis. +# Docs: https://developers.cloudflare.com/pages/functions/wrangler-configuration#environment-specific-overrides + +######## PREVIEW environment config ######## + +# [env.preview.vars] +# API_KEY = "xyz789" + +# [[env.preview.kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "" + +######## PRODUCTION environment config ######## + +# [env.production.vars] +# API_KEY = "abc123" + +# [[env.production.kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = ""