diff --git a/app/components/VersionNotificationBanner.tsx b/app/components/VersionNotificationBanner.tsx new file mode 100644 index 000000000..a54121329 --- /dev/null +++ b/app/components/VersionNotificationBanner.tsx @@ -0,0 +1,57 @@ +import { toast } from 'sonner'; +import { Button } from '@ui/Button'; +import { SymbolIcon } from '@radix-ui/react-icons'; +import { captureMessage } from '@sentry/remix'; +import useSWR from 'swr'; + +export default function useVersionNotificationBanner() { + // eslint-disable-next-line local/no-direct-process-env + const currentSha = process.env.VERCEL_GIT_COMMIT_SHA; + const { data, error } = useSWR<{ sha?: string | null }>('/api/version', { + // Refresh every hour. + refreshInterval: 1000 * 60 * 60, + // Refresh on focus at most every 10 minutes. + focusThrottleInterval: 1000 * 60 * 10, + shouldRetryOnError: false, + fetcher: versionFetcher, + }); + + if (!error && data?.sha && currentSha && data.sha !== currentSha) { + toast.info( +
+ A new version of Chef is available! Refresh this page to update. + +
, + { + id: 'chefVersion', + duration: Number.POSITIVE_INFINITY, + }, + ); + } +} + +const versionFetcher = async (url: string) => { + const res = await fetch(url, { + method: 'POST', + }); + + if (!res.ok) { + try { + const { error } = await res.json(); + captureMessage(error); + } catch (_e) { + captureMessage('Failed to fetch dashboard version information.'); + } + throw new Error('Failed to fetch dashboard version information.'); + } + return res.json(); +}; diff --git a/app/entry.client.tsx b/app/entry.client.tsx index bdffc1c6b..af00e976e 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -3,6 +3,7 @@ import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; import { startTransition, useEffect } from 'react'; import { hydrateRoot } from 'react-dom/client'; +// eslint-disable-next-line local/no-direct-process-env const environment = process.env.VERCEL_ENV === 'production' ? 'production' : 'development'; Sentry.init({ diff --git a/app/root.tsx b/app/root.tsx index 822806d3a..a528049e2 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -21,9 +21,11 @@ import posthog from 'posthog-js'; import 'allotment/dist/style.css'; import { ErrorDisplay } from './components/ErrorComponent'; +import useVersionNotificationBanner from './components/VersionNotificationBanner'; export async function loader() { // These environment variables are available in the client (they aren't secret). + // eslint-disable-next-line local/no-direct-process-env const CONVEX_URL = process.env.VITE_CONVEX_URL || globalThis.process.env.CONVEX_URL!; const CONVEX_OAUTH_CLIENT_ID = globalThis.process.env.CONVEX_OAUTH_CLIENT_ID!; return json({ @@ -130,6 +132,8 @@ export function Layout({ children }: { children: React.ReactNode }) { }); }, []); + useVersionNotificationBanner(); + return ( <> diff --git a/app/routes/api.enhance-prompt.ts b/app/routes/api.enhance-prompt.ts index 57a0365c0..e4f2ee017 100644 --- a/app/routes/api.enhance-prompt.ts +++ b/app/routes/api.enhance-prompt.ts @@ -127,7 +127,7 @@ export async function action({ request }: ActionFunctionArgs) { } const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, + apiKey: globalThis.process.env.OPENAI_API_KEY, }); const completion = await openai.chat.completions.create({ diff --git a/app/routes/api.version.ts b/app/routes/api.version.ts new file mode 100644 index 000000000..68aa80128 --- /dev/null +++ b/app/routes/api.version.ts @@ -0,0 +1,80 @@ +import { json } from '@vercel/remix'; +import type { ActionFunctionArgs } from '@vercel/remix'; + +export async function action({ request }: ActionFunctionArgs) { + const globalEnv = globalThis.process.env; + const projectId = globalEnv.VERCEL_PROJECT_ID; + const teamId = globalEnv.VERCEL_TEAM_ID; + const productionBranchUrl = globalEnv.VERCEL_PRODUCTION_BRANCH_URL || 'chef.convex.dev'; + + if (request.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (!globalEnv.VERCEL_TOKEN) { + return json({ error: 'Failed to fetch version information' }, { status: 500 }); + } + + const requestOptions = { + headers: { + Authorization: `Bearer ${globalThis.process.env.VERCEL_TOKEN}`, + }, + method: 'get', + }; + + if (globalThis.process.env.VERCEL_ENV !== 'preview') { + // If we're not in a preview deployment, fetch the production deployment from Vercel's undocumented + // production-deployment API. + // This response includes a boolean indicating if the production deployment is stale (i.e. rolled back) + // We accept the risk that this API might change because it is not documented, meaning the version + // notification feature might silently fail. + const prodResponse = await fetch( + `https://vercel.com/api/v1/projects/${projectId}/production-deployment?teamId=${teamId}`, + requestOptions, + ); + if (!prodResponse.ok) { + return json({ error: 'Failed to fetch production version information' }, { status: 500 }); + } + + const prodData = await prodResponse.json(); + + // Since we retrieved data from an undocumented API + // let's defensively check that the data we need is present + // and return an opaque error if it isn't. + if (!prodData || typeof prodData.deploymentIsStale !== 'boolean') { + return json({ error: 'Failed to fetch production deployment' }, { status: 500 }); + } + + // If the production deployment is rolled back, + // we should not show a version notification. + if (prodData.deploymentIsStale) { + return json({ sha: null }, { status: 200 }); + } + } + + // Even though we retrieved the production data, we might be on a preview branch deployment. + // So, fetch the data specific to the latest branch deployment. + const branchUrl = globalThis.process.env.VERCEL_BRANCH_URL || productionBranchUrl; + if (!branchUrl) { + throw new Error('VERCEL_BRANCH_URL or VERCEL_PRODUCTION_BRANCH_URL not set'); + } + const branchResponse = await fetch( + `https://api.vercel.com/v13/deployments/${branchUrl}?teamId=${teamId}`, + requestOptions, + ); + if (!branchResponse.ok) { + throw new Error('Failed to fetch branch version information'); + } + + const branchData = await branchResponse.json(); + + return json( + { + sha: branchData.gitSource.sha, + }, + { status: 200 }, + ); +} diff --git a/eslint.config.mjs b/eslint.config.mjs index ee69eab06..054e966e3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,6 +6,34 @@ import reactPlugin from 'eslint-plugin-react'; import reactHooksPlugin from 'eslint-plugin-react-hooks'; import noGlobalFetchRule from './eslint-rules/no-global-fetch.js'; +const noDirectProcessEnv = { + meta: { + type: 'problem', + docs: { + description: 'Disallow direct process.env usage', + category: 'Best Practices', + }, + fixable: null, + schema: [], + messages: { + noDirectProcessEnv: + 'Direct process.env usage is not allowed. Use globalThis.process.env instead because process.env is shimmed in for both the browser and the server.', + }, + }, + create(context) { + return { + MemberExpression(node) { + if (node.object.name === 'process' && node.property.name === 'env') { + context.report({ + node, + messageId: 'noDirectProcessEnv', + }); + } + }, + }; + }, +}; + export default [ { ignores: [ @@ -24,6 +52,11 @@ export default [ plugins: { react: reactPlugin, 'react-hooks': reactHooksPlugin, + local: { + rules: { + 'no-direct-process-env': noDirectProcessEnv, + }, + }, }, rules: { ...reactPlugin.configs.flat.recommended.rules, @@ -84,6 +117,8 @@ export default [ selector: 'Literal[value=/bottom-4(?:\\D|$)/i]', }, ], + // Don't allow direct process.env usage + 'local/no-direct-process-env': 'error', }, settings: { react: { diff --git a/package.json b/package.json index 30d69c1a8..63762a2cc 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "dependencies": { "@ai-sdk/amazon-bedrock": "^2.2.9", "@ai-sdk/anthropic": "^1.2.12", - "@convex-dev/ai-sdk-google": "1.2.17", "@ai-sdk/google": "^1.2.11", "@ai-sdk/openai": "^1.3.6", "@ai-sdk/react": "^1.2.5", @@ -57,6 +56,7 @@ "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.35.0", + "@convex-dev/ai-sdk-google": "1.2.17", "@convex-dev/design-system": "0.1.11", "@convex-dev/eslint-plugin": "0.0.1-alpha.4", "@convex-dev/migrations": "^0.2.8", @@ -135,6 +135,7 @@ "remix-utils": "^7.7.0", "shiki": "^1.24.0", "sonner": "^2.0.3", + "swr": "^2.3.4", "ua-parser-js": "^1.0.40", "undici": "^7.7.0", "unist-util-visit": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c432d121..c6f70f8b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -325,6 +325,9 @@ importers: sonner: specifier: ^2.0.3 version: 2.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + swr: + specifier: ^2.3.4 + version: 2.3.4(react@18.3.1) ua-parser-js: specifier: ^1.0.40 version: 1.0.40 @@ -8657,6 +8660,11 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + swr@2.3.4: + resolution: {integrity: sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + swrev@4.0.0: resolution: {integrity: sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==} @@ -9620,7 +9628,7 @@ snapshots: '@ai-sdk/provider-utils': 2.2.4(zod@3.24.1) '@ai-sdk/ui-utils': 1.2.5(zod@3.24.1) react: 18.3.1 - swr: 2.3.2(react@18.3.1) + swr: 2.3.4(react@18.3.1) throttleit: 2.1.0 optionalDependencies: zod: 3.24.1 @@ -18982,6 +18990,12 @@ snapshots: react: 18.3.1 use-sync-external-store: 1.4.0(react@18.3.1) + swr@2.3.4(react@18.3.1): + dependencies: + dequal: 2.0.3 + react: 18.3.1 + use-sync-external-store: 1.4.0(react@18.3.1) + swrev@4.0.0: {} swrv@1.1.0(vue@3.5.13(typescript@5.8.3)): diff --git a/template/eslint.config.js b/template/eslint.config.js deleted file mode 100644 index dc2e21955..000000000 --- a/template/eslint.config.js +++ /dev/null @@ -1,77 +0,0 @@ -import js from "@eslint/js"; -import globals from "globals"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; -import tseslint from "typescript-eslint"; - -export default tseslint.config( - { - ignores: [ - "dist", - "eslint.config.js", - "convex/_generated", - "postcss.config.js", - "tailwind.config.js", - "vite.config.ts", - ], - }, - { - extends: [ - js.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - ], - files: ["**/*.{ts,tsx}"], - languageOptions: { - ecmaVersion: 2020, - globals: { - ...globals.browser, - ...globals.node, - }, - parserOptions: { - project: [ - "./tsconfig.node.json", - "./tsconfig.app.json", - "./convex/tsconfig.json", - ], - }, - }, - plugins: { - "react-hooks": reactHooks, - "react-refresh": reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - "react-refresh/only-export-components": [ - "warn", - { allowConstantExport: true }, - ], - // All of these overrides ease getting into - // TypeScript, and can be removed for stricter - // linting down the line. - - // Only warn on unused variables, and ignore variables starting with `_` - "@typescript-eslint/no-unused-vars": [ - "warn", - { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }, - ], - - // Allow escaping the compiler - "@typescript-eslint/ban-ts-comment": "error", - - // Allow explicit `any`s - "@typescript-eslint/no-explicit-any": "off", - - // START: Allow implicit `any`s - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-return": "off", - // END: Allow implicit `any`s - - // Allow async functions without await - // for consistency (esp. Convex `handler`s) - "@typescript-eslint/require-await": "off", - }, - }, -); diff --git a/vite.config.ts b/vite.config.ts index 775c3bc18..9f6babd3b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig((config) => { return { define: { 'process.env.VERCEL_ENV': JSON.stringify(process.env.VERCEL_ENV), + 'process.env.VERCEL_GIT_COMMIT_SHA': JSON.stringify(process.env.VERCEL_GIT_COMMIT_SHA), }, /*