diff --git a/README.md b/README.md index 0fb857be7..45b8f7c0f 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,37 @@ pnpm install pnpm dev ``` -This starts your app in development mode, rebuilding assets on file changes. +Requires Node.js 22.12 or newer and pnpm 11. This starts the site at http://localhost:3000 and rebuilds on file changes. No environment file, Cloudflare account, database, or sibling repository is required. Internet access is needed to download dependencies and read remote documentation. + +### Optional services + +Add only the credentials you need to `.env.local`, then restart `pnpm dev`. Environment files are read from this checkout, never from another checkout. Missing credentials disable the relevant local service, they do not prevent the site from starting. + +| Service | Optional configuration | Without it | +| ------------------------------ | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Public docs | Sibling repositories or `TANSTACK_LOCAL_REPOS_DIR` | Reads public docs from GitHub. Local files take precedence. `TANSTACK_DOCS_USE_REMOTE=true` skips local files. | +| GitHub data | `GITHUB_AUTH_TOKEN` | Public REST and raw-file requests remain anonymous, with GitHub's rate limits. Private sponsor data is omitted. | +| Database features | `DATABASE_URL`, with the repository migrations applied | Showcase and Intent registry lists, plus the feedback leaderboard, are empty. Accounts and writes are unavailable. | +| GitHub sign-in | Database plus `GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET` | GitHub sign-in is hidden. | +| Google sign-in | Database plus `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` | Google sign-in is hidden. | +| Persistent sessions | `SESSION_SECRET` | Development generates a random worker-local secret. Restarting or reloading the worker signs you out. Production requires a configured secret. | +| Shopify | `SHOPIFY_PRIVATE_STOREFRONT_TOKEN` | The shop shows an unconfigured state with a link to the live shop. | +| Workers AI | `CLOUDFLARE_API_TOKEN`, plus `CLOUDFLARE_ACCOUNT_ID` to use your own account | Remote bindings are disabled. The included chat model is unavailable. Local R2, Durable Objects, and Workflows still use the local emulator. | +| TanChat and connected accounts | Database, sign-in, chat access, and `ENCRYPTION_KEY` of at least 32 characters | Private chat and encrypted connected-account storage are unavailable. Provider API keys can be added in chat settings once chat is configured. | +| AI Gateway | `AI_GATEWAY_ACCOUNT_ID`, `AI_GATEWAY_ID`, `AI_GATEWAY_TOKEN` | Configured providers connect directly. | +| Kody | `KODY_ORIGIN` and `ENCRYPTION_KEY`, then connect an account | Kody actions require a connected account. | +| Notifications | `DISCORD_WEBHOOK_URL` and/or `RESEND_API_KEY` | The corresponding notification transport is disabled. | +| Server error reporting | `SENTRY_DSN` | Local error reporting is disabled. Browser reporting and analytics only run in production. | +| Kapa docs assistant | `VITE_KAPA_INTEGRATION_ID` and optional `VITE_KAPA_SOURCE_GROUP_IDS` | The hosted docs assistant is unavailable. | +| WebContainer runtime | `VITE_WEBCONTAINER_API_KEY` where required by the runtime | Other Builder runtimes remain available. | + +For a database you own, set `DATABASE_URL` and run `pnpm db:migrate`. OAuth apps must permit the local callback URL, `http://localhost:3000/api/auth/callback/github` or `/google`. Use a persistent session secret when testing saved sessions or encrypted site API keys. + +Cloudflare's included model makes real remote requests and can incur usage charges once a Cloudflare API token is supplied. Local storage stays in `.wrangler/state`. Production bindings and deployment configuration remain in `wrangler.jsonc`. ### Local Setup -The documentation for all TanStack projects (except `React Charts`) is hosted on [tanstack.com](https://tanstack.com). In production, doc pages are fetched from GitHub. In development, they're read from your local file system. +The documentation for all TanStack projects (except `React Charts`) is hosted on [tanstack.com](https://tanstack.com). Doc pages are fetched from GitHub. In development, a local copy takes precedence when available. You only need the additional repositories if you're editing their docs. Create a `tanstack` parent directory and clone this repo alongside the projects: @@ -91,4 +117,4 @@ TanChat is invite-only, independent of Builder access. Signing in to TanStack do Other users unlock TanChat by redeeming a single-use invite and receive three invites to share. Issuing a link consumes one invite, even if the link expires unused. Links expire after seven days. Account settings links to `/chat-access`, where users can create and copy invites. The main site menu links to TanChat, and accounts without access see the invite page. -Migration `0039_tanchat_invites` adds grants and hashed invite tokens. Server functions and chat HTTP endpoints enforce access independently of the UI. Quota checks lock the issuer account, and redemption locks the invite while granting access and consuming it in one transaction. The local app uses the shared configured database, so invite grants also persist for future production releases. +Migration `0039_tanchat_invites` adds grants and hashed invite tokens. Server functions and chat HTTP endpoints enforce access independently of the UI. Quota checks lock the issuer account, and redemption locks the invite while granting access and consuming it in one transaction. Invite grants are stored in whichever database you configure. Local development without a database has no invite or account state. diff --git a/harness-tests/core/local-services.test.ts b/harness-tests/core/local-services.test.ts new file mode 100644 index 000000000..fa1ce9eb5 --- /dev/null +++ b/harness-tests/core/local-services.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('~/db/client', () => ({ + db: new Proxy( + {}, + { + get() { + throw new Error('Unconfigured database was accessed') + }, + }, + ), + isDatabaseConfigured: vi.fn(async () => false), +})) +import { isDatabaseConfigured } from '~/db/client' +import { getAuthService, getSessionService } from '~/auth/context.server' +import { + getAllVerifiedPackages, + getIntentRegistryStats, + getLatestIntentVersionSkillSummaries, + getPackageByName, + searchSkills, +} from '~/utils/intent-db.server' +import { searchShowcasesCore } from '~/utils/showcase.server' + +afterEach(() => vi.unstubAllEnvs()) + +describe('local services without credentials', () => { + it('does not require a session secret for local anonymous browsing', async () => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('SESSION_SECRET', undefined) + expect(getSessionService()).toBeDefined() + expect( + await getAuthService().getCurrentUser( + new Request('http://localhost:3000'), + ), + ).toBeNull() + }) + it('keeps public database lists usable without a database', async () => { + vi.stubEnv('NODE_ENV', 'development') + expect(await getAllVerifiedPackages()).toEqual([]) + expect(await getPackageByName('@tanstack/query')).toBeUndefined() + expect(await getLatestIntentVersionSkillSummaries()).toEqual([]) + expect(await searchSkills('query')).toEqual([]) + expect(await getIntentRegistryStats()).toEqual({ + packageCount: 0, + skillCount: 0, + versionCount: 0, + }) + expect(await searchShowcasesCore()).toEqual({ + showcases: [], + pagination: { page: 1, pageSize: 24, total: 0, totalPages: 0 }, + }) + }) + it('uses the database when it is supplied locally', async () => { + vi.stubEnv('NODE_ENV', 'development') + vi.mocked(isDatabaseConfigured).mockResolvedValueOnce(true) + await expect(getAllVerifiedPackages()).rejects.toThrow( + 'Unconfigured database was accessed', + ) + }) + it('does not hide a missing production database', async () => { + vi.stubEnv('NODE_ENV', 'production') + await expect(getAllVerifiedPackages()).rejects.toThrow( + 'Unconfigured database was accessed', + ) + }) +}) diff --git a/package.json b/package.json index 4d4f76564..7c0298a20 100644 --- a/package.json +++ b/package.json @@ -227,6 +227,6 @@ "vitest": "^4.1.11" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.12.0" } } diff --git a/src/auth/auth.server.ts b/src/auth/auth.server.ts index 74794c930..07f7c175a 100644 --- a/src/auth/auth.server.ts +++ b/src/auth/auth.server.ts @@ -5,6 +5,8 @@ * and user retrieval. Uses inversion of control for all dependencies. */ +import { isDatabaseConfigured } from '~/db/client' + import type { AuthUser, Capability, @@ -33,6 +35,11 @@ export class AuthService implements IAuthService { * Returns null if not authenticated */ async getCurrentUser(request: Request): Promise { + if ( + process.env.NODE_ENV === 'development' && + !(await isDatabaseConfigured()) + ) + return null const signedCookie = this.sessionService.getSessionCookie(request) if (!signedCookie) { diff --git a/src/auth/context.server.ts b/src/auth/context.server.ts index 23d26db36..1d9d49b79 100644 --- a/src/auth/context.server.ts +++ b/src/auth/context.server.ts @@ -20,6 +20,8 @@ import { // Environment Configuration // ============================================================================ +const developmentSessionSecret = crypto.randomUUID() + function getSessionSecret(): string { const secret = process.env.SESSION_SECRET if (!secret) { @@ -28,17 +30,8 @@ function getSessionSecret(): string { 'SESSION_SECRET environment variable is required in production', ) } - // In development, require explicit opt-in to use insecure default - if (process.env.ALLOW_INSECURE_SESSION_SECRET !== 'true') { - throw new Error( - 'SESSION_SECRET environment variable is required. ' + - 'Set ALLOW_INSECURE_SESSION_SECRET=true to use insecure default in development.', - ) - } - console.warn( - '[Auth] WARNING: Using insecure session secret for development. Do NOT use in production.', - ) - return 'dev-secret-key-change-in-production' + // Sessions are local to this worker unless a persistent secret is supplied. + return developmentSessionSecret } return secret } diff --git a/src/chat/server/providers.ts b/src/chat/server/providers.ts index 4bab58aec..c3e26b214 100644 --- a/src/chat/server/providers.ts +++ b/src/chat/server/providers.ts @@ -43,6 +43,10 @@ export function adapterFor( const observedFetch = (protocol: ProviderProtocol) => observer ? { fetch: observeProviderFetch(protocol, observer) } : undefined if (c.provider === 'included') { + if (!env.AI) + throw new Error( + 'The included model is unavailable on this server. Add a provider API key in settings.', + ) const binding = env.AI_GATEWAY_ID ? metadataOnlyBinding(env.AI) : env.AI return createCloudflareText(c.model || env.INCLUDED_MODEL, { binding: observer ? observeProviderBinding(binding, observer) : binding, diff --git a/src/components/LoginModal.tsx b/src/components/LoginModal.tsx index c50f9b4e5..0bdc1080f 100644 --- a/src/components/LoginModal.tsx +++ b/src/components/LoginModal.tsx @@ -1,3 +1,5 @@ +import { useQuery } from '@tanstack/react-query' +import { getSignInProviders } from '~/utils/auth.functions' import { GithubIcon } from '~/components/icons/GithubIcon' import { GoogleIcon } from '~/components/icons/GoogleIcon' import type { OAuthProvider } from '~/auth/types' @@ -21,28 +23,42 @@ export function LoginModal({ onOpenChange, onSocialSignIn, }: LoginModalProps) { + const providers = useQuery({ + queryKey: ['sign-in-providers'], + queryFn: () => getSignInProviders(), + staleTime: Infinity, + }) return (
- - + {providers.data && + !providers.data.github && + !providers.data.google && ( +

Sign-in isn't configured for this server.

+ )} + {providers.data?.github && ( + + )} + {providers.data?.google && ( + + )}
diff --git a/src/db/client.ts b/src/db/client.ts index 0bf30a4bc..a9c165e24 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -48,6 +48,10 @@ function getDatabaseUrl() { return connectionString } +export async function isDatabaseConfigured() { + return Boolean(await getDatabaseConnectionString()) +} + function getRequestDb(context: DatabaseContext) { if (!context.db) { const database = createDatabase(context.connectionString) diff --git a/src/instrument.server.mjs b/src/instrument.server.mjs index a6fc64551..b1092aa76 100644 --- a/src/instrument.server.mjs +++ b/src/instrument.server.mjs @@ -3,7 +3,11 @@ import { redactByokRequestHeaders } from './utils/sentry-redaction' if (!Sentry.getClient()) Sentry.init({ - dsn: 'https://ac4bfc43ff4a892f8dc7053c4a50d92f@o4507236158537728.ingest.us.sentry.io/4507236163649536', + dsn: + process.env.SENTRY_DSN ?? + 'https://ac4bfc43ff4a892f8dc7053c4a50d92f@o4507236158537728.ingest.us.sentry.io/4507236163649536', + enabled: + process.env.NODE_ENV === 'production' || Boolean(process.env.SENTRY_DSN), integrations: [], sendDefaultPii: true, // Performance Monitoring diff --git a/src/router.tsx b/src/router.tsx index 7bcb6a98d..c84e08414 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -13,6 +13,7 @@ if (typeof document !== 'undefined') { Sentry.init({ dsn: 'https://ac4bfc43ff4a892f8dc7053c4a50d92f@o4507236158537728.ingest.us.sentry.io/4507236163649536', + enabled: import.meta.env.PROD, sendDefaultPii: true, // Performance Monitoring tracesSampleRate: 1.0, // Capture 100% of the transactions diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 47981c1e9..c3d782a5a 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -274,7 +274,9 @@ export const Route = createRootRouteWithContext<{ }, { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, ], - scripts: [{ children: GOOGLE_ANALYTICS_BOOTSTRAP }], + scripts: import.meta.env.PROD + ? [{ children: GOOGLE_ANALYTICS_BOOTSTRAP }] + : [], } }, headers: () => DOCUMENT_CACHE_HEADERS, diff --git a/src/routes/auth/$provider/start.tsx b/src/routes/auth/$provider/start.tsx index ba3fb2943..599dce392 100644 --- a/src/routes/auth/$provider/start.tsx +++ b/src/routes/auth/$provider/start.tsx @@ -1,3 +1,4 @@ +import { getSignInProviders } from '~/utils/auth.functions' import { oauthPopupChannelSchema } from '~/auth/oauth-popup' import { createFileRoute } from '@tanstack/react-router' import { env } from '~/utils/env' @@ -39,6 +40,12 @@ export const Route = createFileRoute('/auth/$provider/start')({ return Response.redirect(new URL('/login', request.url), 302) } + const providers = await getSignInProviders() + if (!providers[provider]) + return new Response('Sign-in is not configured for this server.', { + status: 503, + }) + // Generate random state token for CSRF protection const state = generateOAuthState() diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 59bf00cdc..392df8213 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -1,3 +1,5 @@ +import { useQuery } from '@tanstack/react-query' +import { getSignInProviders } from '~/utils/auth.functions' import * as React from 'react' import { authClient } from '~/auth/client' // Using public asset URLs for splash images @@ -64,36 +66,50 @@ function SplashImage() { } export function SignInForm({ returnTo }: { returnTo?: string } = {}) { + const providers = useQuery({ + queryKey: ['sign-in-providers'], + queryFn: () => getSignInProviders(), + staleTime: Infinity, + }) return (

Sign into TanStack

- - + {providers.data && !providers.data.github && !providers.data.google && ( +

Sign-in isn't configured for this server.

+ )} + {providers.data?.github && ( + + )} + {providers.data?.google && ( + + )}
) } diff --git a/src/routes/shop.tsx b/src/routes/shop.tsx index a10c587f0..3f2be08a7 100644 --- a/src/routes/shop.tsx +++ b/src/routes/shop.tsx @@ -2,17 +2,19 @@ import { Outlet, createFileRoute } from '@tanstack/react-router' import shopCss from '~/styles/shop.css?url' import { ShopLayout } from '~/components/shop/ShopLayout' import { CART_QUERY_KEY } from '~/hooks/useCart' -import { getCart } from '~/utils/shop.functions' +import { getCart, isShopConfigured } from '~/utils/shop.functions' const SHOP_FONTS_HREF = 'https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&family=JetBrains+Mono:wght@400;500&display=swap' export const Route = createFileRoute('/shop')({ loader: async ({ context }) => { + const configured = await isShopConfigured() await context.queryClient.prefetchQuery({ queryKey: CART_QUERY_KEY, queryFn: () => getCart(), }) + return { configured } }, head: () => ({ links: [ @@ -26,9 +28,19 @@ export const Route = createFileRoute('/shop')({ { rel: 'stylesheet', href: SHOP_FONTS_HREF }, ], }), - component: () => ( + component: Shop, +}) + +function Shop() { + const { configured } = Route.useLoaderData() + return configured ? ( - ), -}) + ) : ( +
+

The shop isn't configured for this server.

+ Browse the live shop +
+ ) +} diff --git a/src/server/sponsors.ts b/src/server/sponsors.ts index f65dffbb9..6cfe2de99 100644 --- a/src/server/sponsors.ts +++ b/src/server/sponsors.ts @@ -1,3 +1,4 @@ +import { env } from '~/utils/env' import { fetchCached } from '~/utils/cache.server' import { graphqlWithAuth } from '~/server/github' import { createServerFn } from '@tanstack/react-start' @@ -98,6 +99,11 @@ export async function getSponsors() { } async function getGithubSponsors() { + if ( + !env.GITHUB_AUTH_TOKEN || + env.GITHUB_AUTH_TOKEN === 'USE_A_REAL_KEY_IN_PRODUCTION' + ) + return [] let sponsors: Sponsor[] = [] try { const fetchPage = async (cursor = '') => { @@ -211,29 +217,10 @@ async function getGithubSponsors() { } catch (err) { const error = err as { status?: number } if (error.status === 401) { - console.error( - 'Invalid or missing GitHub credentials, returning mock data.', - ) - return [ - 'tannerlinsley', - 'tkdodo', - 'crutchcorn', - 'kevinvandy', - 'jherr', - 'seancassiere', - 'schiller-manuel', - ].flatMap((d) => - new Array(20).fill(d).map((_, i2) => ({ - login: d, - name: d, - amount: (20 - i2) / 20 + Math.random(), - createdAt: new Date().toISOString(), - private: false, - linkUrl: `https://github.com/${d}`, - imageUrl: `https://github.com/${d}.png`, - })), - ) + console.error('GitHub sponsor credentials were rejected.') + return [] } + if (error.status === 403) { console.error('GitHub rate limit exceeded, returning empty sponsors.') return [] diff --git a/src/utils/auth.functions.ts b/src/utils/auth.functions.ts index 6581f3709..124782eee 100644 --- a/src/utils/auth.functions.ts +++ b/src/utils/auth.functions.ts @@ -1,3 +1,5 @@ +import { isDatabaseConfigured } from '~/db/client' +import { env } from '~/utils/env' import { createServerFn } from '@tanstack/react-start' import { getRequest } from '@tanstack/react-start/server' import { @@ -83,3 +85,17 @@ export const requireAnyAdminCapability = createServerFn({ return user }) + +export const getSignInProviders = createServerFn({ method: 'GET' }).handler( + async () => { + const database = await isDatabaseConfigured() + return { + github: + database && + Boolean(env.GITHUB_OAUTH_CLIENT_ID && env.GITHUB_OAUTH_CLIENT_SECRET), + google: + database && + Boolean(env.GOOGLE_OAUTH_CLIENT_ID && env.GOOGLE_OAUTH_CLIENT_SECRET), + } + }, +) diff --git a/src/utils/docFeedback.functions.ts b/src/utils/docFeedback.functions.ts index 714fce036..445a20a47 100644 --- a/src/utils/docFeedback.functions.ts +++ b/src/utils/docFeedback.functions.ts @@ -1,6 +1,6 @@ import { createServerFn } from '@tanstack/react-start' import * as v from 'valibot' -import { db } from '~/db/client' +import { db, isDatabaseConfigured } from '~/db/client' import { docFeedback, users, @@ -497,6 +497,14 @@ export const getDocFeedbackLeaderboard = createServerFn({ method: 'POST' }) ) .handler(async ({ data }) => { const { page, pageSize } = data.pagination + if ( + process.env.NODE_ENV === 'development' && + !(await isDatabaseConfigured()) + ) + return { + leaderboard: [], + pagination: { page, pageSize, total: 0, totalPages: 0 }, + } // Get all approved feedback to calculate leaderboard in JS const allFeedback = await db diff --git a/src/utils/documents.server.ts b/src/utils/documents.server.ts index f02f2cea6..d409da703 100644 --- a/src/utils/documents.server.ts +++ b/src/utils/documents.server.ts @@ -536,7 +536,9 @@ async function fetchRepoFileFromOrigin( let text: string | null if (shouldUseLocalDocsFiles()) { - text = await fetchFs(repo, filepath) + text = + (await fetchFs(repo, filepath)) ?? + (await fetchRemote(owner, repo, ref, filepath)) } else { text = await fetchRemote(owner, repo, ref, filepath) } @@ -576,7 +578,9 @@ async function fetchRepoRawFileFromOrigin( ) { const [owner, repo] = repoPair.split('/') return shouldUseLocalDocsFiles() - ? fetchFs(repo, filepath) + ? fetchFs(repo, filepath).then( + (text) => text ?? fetchRemote(owner, repo, ref, filepath), + ) : fetchRemote(owner, repo, ref, filepath) } @@ -1405,7 +1409,9 @@ export function fetchApiContents( return fetchCached({ key: `${repoPair}:${branch}:${startingPath}`, ttl: 1, - fn: () => fetchApiContentsFs(repoPair, startingPath), + fn: async () => + (await fetchApiContentsFs(repoPair, startingPath)) ?? + fetchApiContentsRemote(repoPair, branch, startingPath), }) } diff --git a/src/utils/intent-db.server.ts b/src/utils/intent-db.server.ts index b109e7cf6..afbee287c 100644 --- a/src/utils/intent-db.server.ts +++ b/src/utils/intent-db.server.ts @@ -3,7 +3,7 @@ * Server-only. Import only inside server function handlers or scheduled functions. */ -import { db } from '~/db/client' +import { db, isDatabaseConfigured } from '~/db/client' import { intentPackages, intentPackageVersions, @@ -42,6 +42,9 @@ export interface IntentLatestVersionSkillSummary { // --------------------------------------------------------------------------- export async function getAllVerifiedPackages(): Promise> { + if (process.env.NODE_ENV === 'development' && !(await isDatabaseConfigured())) + return [] + return db.query.intentPackages.findMany({ where: eq(intentPackages.verified, true), orderBy: [desc(intentPackages.lastSyncedAt)], @@ -51,6 +54,9 @@ export async function getAllVerifiedPackages(): Promise> { export async function getPackageByName( name: string, ): Promise { + if (process.env.NODE_ENV === 'development' && !(await isDatabaseConfigured())) + return undefined + return db.query.intentPackages.findFirst({ where: eq(intentPackages.name, name), }) @@ -60,6 +66,9 @@ export async function getPackageByName( export async function getPackageVersions( packageName: string, ): Promise> { + if (process.env.NODE_ENV === 'development' && !(await isDatabaseConfigured())) + return [] + return db.query.intentPackageVersions.findMany({ where: and( eq(intentPackageVersions.packageName, packageName), @@ -141,6 +150,9 @@ export async function getVersionForProcessing( export async function getSkillsForVersion( packageVersionId: number, ): Promise> { + if (process.env.NODE_ENV === 'development' && !(await isDatabaseConfigured())) + return [] + const rows = await db .select({ id: intentSkills.id, @@ -167,6 +179,9 @@ export async function getSkillsForVersion( export async function getLatestIntentVersionSkillSummaries(): Promise< Array > { + if (process.env.NODE_ENV === 'development' && !(await isDatabaseConfigured())) + return [] + const latestVersions = db .selectDistinctOn([intentPackageVersions.packageName], { id: intentPackageVersions.id, @@ -228,6 +243,9 @@ export async function getLatestIntentVersionSkillSummaries(): Promise< export async function getSkillFingerprintsForVersion( packageVersionId: number, ): Promise> { + if (process.env.NODE_ENV === 'development' && !(await isDatabaseConfigured())) + return [] + return db .select({ name: intentSkills.name, @@ -243,6 +261,9 @@ export async function getIntentRegistryStats(): Promise<{ skillCount: number versionCount: number }> { + if (process.env.NODE_ENV === 'development' && !(await isDatabaseConfigured())) + return { packageCount: 0, skillCount: 0, versionCount: 0 } + const [pkgCount, skillCount, versionCount] = await Promise.all([ db .select({ count: sql`count(*)::int` }) @@ -303,6 +324,9 @@ export async function getIntentRegistryStats(): Promise<{ export async function searchPackagesByName( query: string, ): Promise> { + if (process.env.NODE_ENV === 'development' && !(await isDatabaseConfigured())) + return [] + return db.query.intentPackages.findMany({ where: and( eq(intentPackages.verified, true), @@ -330,6 +354,9 @@ export async function searchSkills( query: string, limit = 50, ): Promise> { + if (process.env.NODE_ENV === 'development' && !(await isDatabaseConfigured())) + return [] + const pattern = `%${query}%` const rows = await db .select({ diff --git a/src/utils/intent.functions.ts b/src/utils/intent.functions.ts index a1ab1fe6f..39bbd5819 100644 --- a/src/utils/intent.functions.ts +++ b/src/utils/intent.functions.ts @@ -1,3 +1,4 @@ +import { isDatabaseConfigured } from '~/db/client' import { createServerFn } from '@tanstack/react-start' import * as v from 'valibot' import { @@ -464,6 +465,11 @@ export const getIntentPackageDetail = createServerFn({ method: 'GET' }) .validator(v.object({ name: npmPackageNameSchema })) .handler(async ({ data }) => { const { name } = data + if ( + process.env.NODE_ENV === 'development' && + !(await isDatabaseConfigured()) + ) + return null // Quick rejection cache check (packages confirmed to have no skills) if (isRejected(name)) return null diff --git a/src/utils/sentry.server.ts b/src/utils/sentry.server.ts index f5e4c9a4d..014a6f49c 100644 --- a/src/utils/sentry.server.ts +++ b/src/utils/sentry.server.ts @@ -9,6 +9,7 @@ export function initSentryServer(): void { Sentry.init({ dsn: env.SENTRY_DSN ?? SENTRY_DSN, + enabled: process.env.NODE_ENV === 'production' || Boolean(env.SENTRY_DSN), tracesSampleRate: 1.0, environment: process.env.NODE_ENV ?? 'development', }) diff --git a/src/utils/shop.functions.ts b/src/utils/shop.functions.ts index b9f637731..c1a6482bf 100644 --- a/src/utils/shop.functions.ts +++ b/src/utils/shop.functions.ts @@ -1,3 +1,4 @@ +import { env } from '~/utils/env' import { createServerFn } from '@tanstack/react-start' import { deleteCookie, @@ -146,6 +147,11 @@ export const getProducts = createServerFn({ method: 'POST' }) ) .handler(async ({ data }): Promise => { setBrowseCacheHeaders() + if ( + process.env.NODE_ENV === 'development' && + !env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN + ) + return { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } const result = await shopifyServerFetch< ProductsQueryResult, ProductsQueryVariables @@ -163,6 +169,11 @@ export const getProducts = createServerFn({ method: 'POST' }) export const getCollections = createServerFn({ method: 'GET' }).handler( async (): Promise> => { + if ( + process.env.NODE_ENV === 'development' && + !env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN + ) + return [] setBrowseCacheHeaders() const result = await shopifyServerFetch< CollectionsQueryResult, @@ -178,6 +189,11 @@ export const getCollections = createServerFn({ method: 'GET' }).handler( export const getProduct = createServerFn({ method: 'POST' }) .validator(v.object({ handle: shopifyHandleSchema })) .handler(async ({ data }): Promise => { + if ( + process.env.NODE_ENV === 'development' && + !env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN + ) + return null setBrowseCacheHeaders() const result = await shopifyServerFetch< ProductQueryResult, @@ -211,6 +227,11 @@ export const getCollection = createServerFn({ method: 'POST' }) }), ) .handler(async ({ data }): Promise => { + if ( + process.env.NODE_ENV === 'development' && + !env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN + ) + return null setBrowseCacheHeaders() const result = await shopifyServerFetch< CollectionQueryResult, @@ -237,6 +258,11 @@ export const getCollection = createServerFn({ method: 'POST' }) export const getPage = createServerFn({ method: 'POST' }) .validator(v.object({ handle: shopifyHandleSchema })) .handler(async ({ data }): Promise => { + if ( + process.env.NODE_ENV === 'development' && + !env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN + ) + return null setBrowseCacheHeaders() const result = await shopifyServerFetch< PageQueryResult, @@ -250,6 +276,11 @@ export const getPage = createServerFn({ method: 'POST' }) export const getShopPolicies = createServerFn({ method: 'GET' }).handler( async (): Promise> => { + if ( + process.env.NODE_ENV === 'development' && + !env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN + ) + return [] setBrowseCacheHeaders() const result = await shopifyServerFetch({ query: SHOP_POLICIES_QUERY, @@ -264,6 +295,11 @@ export const getShopPolicy = createServerFn({ method: 'POST' }) async ({ data, }): Promise<{ title: string; body: string; handle: string } | null> => { + if ( + process.env.NODE_ENV === 'development' && + !env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN + ) + return null setBrowseCacheHeaders() const result = await shopifyServerFetch({ query: SHOP_POLICIES_QUERY, @@ -295,6 +331,15 @@ export const searchProducts = createServerFn({ method: 'POST' }) products: ProductListPage['nodes'] }> => { setBrowseCacheHeaders() + if ( + process.env.NODE_ENV === 'development' && + !env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN + ) + return { + totalCount: 0, + products: [], + pageInfo: { hasNextPage: false, endCursor: null }, + } const result = await shopifyServerFetch< SearchQueryResult, { query: string; first: number; after: string | null } @@ -341,6 +386,11 @@ async function fetchCartById(cartId: string): Promise { export const getCart = createServerFn({ method: 'GET' }).handler( async (): Promise => { setCartResponseHeaders() + if ( + process.env.NODE_ENV === 'development' && + !env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN + ) + return null const cartId = getCookie(CART_COOKIE_NAME) if (!cartId) return null @@ -501,3 +551,7 @@ export const removeDiscountCode = createServerFn({ method: 'POST' }).handler( return cart }, ) + +export const isShopConfigured = createServerFn({ method: 'GET' }).handler(() => + Boolean(env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN), +) diff --git a/src/utils/showcase.server.ts b/src/utils/showcase.server.ts index 1d473501f..9f1c62e3a 100644 --- a/src/utils/showcase.server.ts +++ b/src/utils/showcase.server.ts @@ -1,4 +1,4 @@ -import { db } from '~/db/client' +import { db, isDatabaseConfigured } from '~/db/client' import { showcases, users, @@ -544,6 +544,16 @@ export async function searchShowcasesCore( const page = pagination.page ?? 1 const pageSize = pagination.pageSize ?? 24 + if ( + process.env.NODE_ENV === 'development' && + !(await isDatabaseConfigured()) + ) { + return { + showcases: [], + pagination: { page, pageSize, total: 0, totalPages: 0 }, + } + } + // Build where conditions const conditions = [ eq(showcases.status, 'approved'), @@ -643,6 +653,8 @@ export async function getShowcaseCore( options: GetShowcaseOptions = {}, ) { const { userId } = options + if (process.env.NODE_ENV === 'development' && !(await isDatabaseConfigured())) + throw notFound() const [result] = await db .select({ diff --git a/src/utils/sponsors.functions.ts b/src/utils/sponsors.functions.ts index f7d2be19c..b339d108f 100644 --- a/src/utils/sponsors.functions.ts +++ b/src/utils/sponsors.functions.ts @@ -1,3 +1,4 @@ +import { env } from '~/utils/env' import { createServerFn } from '@tanstack/react-start' import { setResponseHeaders } from '@tanstack/react-start/server' import { extent, scaleLinear } from 'd3' @@ -108,6 +109,11 @@ async function getSponsors() { } async function getGithubSponsors() { + if ( + !env.GITHUB_AUTH_TOKEN || + env.GITHUB_AUTH_TOKEN === 'USE_A_REAL_KEY_IN_PRODUCTION' + ) + return [] let sponsors: Array = [] try { @@ -221,28 +227,8 @@ async function getGithubSponsors() { const error = err as { status?: number } if (error.status === 401) { - console.error( - 'Invalid or missing GitHub credentials, returning mock data.', - ) - return [ - 'tannerlinsley', - 'tkdodo', - 'crutchcorn', - 'kevinvandy', - 'jherr', - 'seancassiere', - 'schiller-manuel', - ].flatMap((d) => - new Array(20).fill(d).map((_, i2) => ({ - login: d, - name: d, - amount: (20 - i2) / 20 + Math.random(), - createdAt: new Date().toISOString(), - private: false, - linkUrl: `https://github.com/${d}`, - imageUrl: `https://github.com/${d}.png`, - })), - ) + console.error('GitHub sponsor credentials were rejected.') + return [] } if (error.status === 403) { diff --git a/vite.config.ts b/vite.config.ts index c0a31d66c..a831e80f6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,5 @@ import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite' -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import type { PluginOption, UserConfig } from 'vite' import { redact } from '@tanstack/redact/vite' import contentCollections from '@content-collections/vite' @@ -33,14 +33,6 @@ const shouldBuildSourcemaps = const SITE_URL = 'https://tanstack.com' const localDocsDevToken = isDev ? randomUUID() : '' -const localEnvPath = path.resolve(__dirname, '.env.local') -const defaultCheckoutEnvDir = path.join(os.homedir(), 'GitHub/tanstack.com') -const envDir = - !fs.existsSync(localEnvPath) && - fs.existsSync(path.join(defaultCheckoutEnvDir, '.env.local')) - ? defaultCheckoutEnvDir - : __dirname - function localDocsDevFiles(): PluginOption { return { name: 'tanstack-local-docs-files', @@ -208,10 +200,12 @@ function chatBuildId() { return digest.digest('hex') } -export default defineConfig(async ({ command }) => { +export default defineConfig(async ({ command, mode }) => { + const localEnv = { ...loadEnv(mode, __dirname, ''), ...process.env } + const useRemoteAi = Boolean(localEnv.CLOUDFLARE_API_TOKEN) const egress = command === 'serve' ? await startLocalMcpEgress() : undefined const config: UserConfig = { - envDir, + envDir: __dirname, define: { __GUM_LOCAL_DEVELOPMENT__: JSON.stringify(command === 'serve'), __GUM_BUILD_ID__: JSON.stringify(isDev ? 'development' : chatBuildId()), @@ -358,8 +352,18 @@ export default defineConfig(async ({ command }) => { : []), cloudflare({ viteEnvironment: { name: 'ssr' }, - config: (config) => - egress ? { vars: { ...config.vars, ...egress.vars } } : {}, + remoteBindings: useRemoteAi, + config: (config) => { + if (command !== 'serve') return + if (!useRemoteAi) delete config.ai + if (localEnv.CLOUDFLARE_ACCOUNT_ID) + config.account_id = localEnv.CLOUDFLARE_ACCOUNT_ID + config.vars = { + ...config.vars, + ...egress?.vars, + APP_MODE: 'development', + } + }, }), ...(shouldUseRedact ? [