From 483217bf56da182caaaaf4a07f414a770ff98e5c Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 17 Mar 2025 21:22:05 -0700 Subject: [PATCH] Switch to using t3-env for env-var management (#230) --- .dockerignore | 12 ++- Dockerfile | 82 ++++++++------ entrypoint.sh | 96 ----------------- packages/backend/.env | 1 - packages/backend/package.json | 4 +- packages/backend/src/env.ts | 41 +++++++ packages/backend/src/environment.ts | 48 --------- packages/backend/src/gitea.ts | 4 +- packages/backend/src/github.ts | 4 +- packages/backend/src/gitlab.ts | 4 +- packages/backend/src/instrument.ts | 8 +- packages/backend/src/logger.ts | 10 +- packages/backend/src/main.ts | 8 +- packages/backend/src/posthog.ts | 14 +-- packages/web/.env | 3 - packages/web/next.config.mjs | 79 +++++++------- packages/web/package.json | 3 +- packages/web/src/actions.ts | 102 ++++++++++++------ .../[domain]/components/errorNavIndicator.tsx | 6 +- .../components/progressNavIndicator.tsx | 4 +- .../components/repositorySnapshot.tsx | 4 +- .../[domain]/components/settingsDropdown.tsx | 5 +- .../components/warningNavIndicator.tsx | 4 +- .../connections/[id]/components/overview.tsx | 4 +- .../connections/[id]/components/repoList.tsx | 4 +- .../components/connectionList/index.tsx | 4 +- .../app/[domain]/repos/repositoryTable.tsx | 4 +- packages/web/src/app/[domain]/search/page.tsx | 2 - .../app/[domain]/settings/(general)/page.tsx | 4 +- .../members/components/invitesList.tsx | 4 +- packages/web/src/app/api/(client)/client.ts | 23 +--- .../web/src/app/api/(server)/stripe/route.ts | 17 ++- .../web/src/app/api/(server)/version/route.ts | 4 +- packages/web/src/app/layout.tsx | 3 +- packages/web/src/app/onboard/page.tsx | 4 +- packages/web/src/app/posthogProvider.tsx | 41 ++++--- packages/web/src/auth.ts | 42 +++----- packages/web/src/env.mjs | 60 +++++++++++ packages/web/src/hooks/useCaptureEvent.ts | 4 +- packages/web/src/lib/environment.client.ts | 14 --- packages/web/src/lib/environment.ts | 29 ----- packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/lib/posthogEvents.ts | 1 - packages/web/src/lib/server/searchService.ts | 6 +- packages/web/src/lib/server/zoektClient.ts | 5 +- packages/web/src/lib/stripe.ts | 16 ++- packages/web/src/lib/utils.ts | 24 ----- packages/web/tsconfig.json | 2 +- yarn.lock | 20 +++- 49 files changed, 404 insertions(+), 484 deletions(-) delete mode 100644 packages/backend/.env create mode 100644 packages/backend/src/env.ts delete mode 100644 packages/backend/src/environment.ts delete mode 100644 packages/web/.env create mode 100644 packages/web/src/env.mjs delete mode 100644 packages/web/src/lib/environment.client.ts delete mode 100644 packages/web/src/lib/environment.ts diff --git a/.dockerignore b/.dockerignore index a589c140..65c74346 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,13 @@ Dockerfile .dockerignore -node_modules npm-debug.log README.md -.next -!.next/static -!.next/standalone .git .sourcebot -.env.local \ No newline at end of file +packages/web/.next +!packages/web/.next/static +!packages/web/.next/standalone +**/node_modules +**/.env.local +**/.sentryclirc +**/.env.sentry-build-plugin \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d5886578..dad1c27d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,18 @@ +# ------ Global scope variables ------ +# Set of global build arguments. +# @see: https://docs.docker.com/build/building/variables/#scoping + +ARG SOURCEBOT_VERSION +# PAPIK = Project API Key +# Note that this key does not need to be kept secret, so it's not +# necessary to use Docker build secrets here. +# @see: https://posthog.com/tutorials/api-capture-events#authenticating-with-the-project-api-key +ARG POSTHOG_PAPIK +ARG SENTRY_ENVIRONMENT + FROM node:20-alpine3.19 AS node-alpine FROM golang:1.23.4-alpine3.19 AS go-alpine +# ---------------------------------- # ------ Build Zoekt ------ FROM go-alpine AS zoekt-builder @@ -9,6 +22,7 @@ COPY vendor/zoekt/go.mod vendor/zoekt/go.sum ./ RUN go mod download COPY vendor/zoekt ./ RUN CGO_ENABLED=0 GOOS=linux go build -o /cmd/ ./cmd/... +# ------------------------- # ------ Build shared libraries ------ FROM node-alpine AS shared-libs-builder @@ -23,9 +37,24 @@ RUN yarn workspace @sourcebot/db install --frozen-lockfile RUN yarn workspace @sourcebot/schemas install --frozen-lockfile RUN yarn workspace @sourcebot/crypto install --frozen-lockfile RUN yarn workspace @sourcebot/error install --frozen-lockfile +# ------------------------------------ # ------ Build Web ------ FROM node-alpine AS web-builder +ENV DOCKER_BUILD=1 +# ----------- +# Global args +ARG SOURCEBOT_VERSION +ENV NEXT_PUBLIC_SOURCEBOT_VERSION=$SOURCEBOT_VERSION +ARG POSTHOG_PAPIK +ENV NEXT_PUBLIC_POSTHOG_PAPIK=$POSTHOG_PAPIK +ARG SENTRY_ENVIRONMENT +ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT +# Local args +ARG SENTRY_WEBAPP_DSN +ENV NEXT_PUBLIC_SENTRY_WEBAPP_DSN=$SENTRY_WEBAPP_DSN +# ----------- + RUN apk add --no-cache libc6-compat WORKDIR /app @@ -43,26 +72,13 @@ RUN yarn config set network-timeout 1200000 RUN yarn workspace @sourcebot/web install --frozen-lockfile ENV NEXT_TELEMETRY_DISABLED=1 -# @see: https://phase.dev/blog/nextjs-public-runtime-variables/ -ARG NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED=BAKED_NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED -ARG NEXT_PUBLIC_SOURCEBOT_VERSION=BAKED_NEXT_PUBLIC_SOURCEBOT_VERSION -ENV NEXT_PUBLIC_PUBLIC_SEARCH_DEMO=BAKED_NEXT_PUBLIC_PUBLIC_SEARCH_DEMO -ENV NEXT_PUBLIC_POSTHOG_PAPIK=BAKED_NEXT_PUBLIC_POSTHOG_PAPIK -ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=BAKED_NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY -ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=BAKED_NEXT_PUBLIC_SENTRY_ENVIRONMENT -ENV NEXT_PUBLIC_SENTRY_WEBAPP_DSN=BAKED_NEXT_PUBLIC_SENTRY_WEBAPP_DSN - -# @nocheckin: This was interfering with the the `matcher` regex in middleware.ts, -# causing regular expressions parsing errors when making a request. It's unclear -# why exactly this was happening, but it's likely due to a bad replacement happening -# in the `sed` command. -# @note: leading "/" is required for the basePath property. @see: https://nextjs.org/docs/app/api-reference/next-config-js/basePath -# ARG NEXT_PUBLIC_DOMAIN_SUB_PATH=/BAKED_NEXT_PUBLIC_DOMAIN_SUB_PATH - RUN yarn workspace @sourcebot/web build +ENV DOCKER_BUILD=0 +# ------------------------------ # ------ Build Backend ------ FROM node-alpine AS backend-builder +ENV DOCKER_BUILD=1 WORKDIR /app COPY package.json yarn.lock* ./ @@ -75,10 +91,22 @@ COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto COPY --from=shared-libs-builder /app/packages/error ./packages/error RUN yarn workspace @sourcebot/backend install --frozen-lockfile RUN yarn workspace @sourcebot/backend build - +ENV DOCKER_BUILD=0 +# ------------------------------ # ------ Runner ------ FROM node-alpine AS runner +# ----------- +# Global args +ARG SOURCEBOT_VERSION +ENV SOURCEBOT_VERSION=$SOURCEBOT_VERSION +ARG POSTHOG_PAPIK +ENV POSTHOG_PAPIK=$POSTHOG_PAPIK +# Local args +# ----------- + +RUN echo "Sourcebot Version: $SOURCEBOT_VERSION" + WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 @@ -90,14 +118,6 @@ ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot" ENV REDIS_URL="redis://localhost:6379" ENV SRC_TENANT_ENFORCEMENT_MODE=strict -ARG SOURCEBOT_VERSION=unknown -ENV SOURCEBOT_VERSION=$SOURCEBOT_VERSION -RUN echo "Sourcebot Version: $SOURCEBOT_VERSION" - -ARG PUBLIC_SEARCH_DEMO=false -ENV PUBLIC_SEARCH_DEMO=$PUBLIC_SEARCH_DEMO -RUN echo "Public Search Demo: $PUBLIC_SEARCH_DEMO" - # Valid values are: debug, info, warn, error ENV SOURCEBOT_LOG_LEVEL=info @@ -106,18 +126,9 @@ ENV SOURCEBOT_LOG_LEVEL=info # will serve from http(s)://example.com/sb ENV DOMAIN_SUB_PATH=/ -# PAPIK = Project API Key -# Note that this key does not need to be kept secret, so it's not -# necessary to use Docker build secrets here. -# @see: https://posthog.com/tutorials/api-capture-events#authenticating-with-the-project-api-key -ARG POSTHOG_PAPIK= -ENV POSTHOG_PAPIK=$POSTHOG_PAPIK - # Sourcebot collects anonymous usage data using [PostHog](https://posthog.com/). Uncomment this line to disable. # ENV SOURCEBOT_TELEMETRY_DISABLED=1 -ENV STRIPE_PUBLISHABLE_KEY="" - # Configure zoekt COPY vendor/zoekt/install-ctags-alpine.sh . RUN ./install-ctags-alpine.sh && rm install-ctags-alpine.sh @@ -178,4 +189,5 @@ COPY default-config.json . EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -ENTRYPOINT ["/sbin/tini", "--", "./entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/sbin/tini", "--", "./entrypoint.sh"] +# ------------------------------ \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh index 9cb6e416..f151fe70 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -108,102 +108,6 @@ fi echo "{\"version\": \"$SOURCEBOT_VERSION\", \"install_id\": \"$SOURCEBOT_INSTALL_ID\"}" > "$FIRST_RUN_FILE" -# Update NextJs public env variables w/o requiring a rebuild. -# @see: https://phase.dev/blog/nextjs-public-runtime-variables/ -{ - # Infer NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED if it is not set - if [ -z "$NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED" ] && [ ! -z "$SOURCEBOT_TELEMETRY_DISABLED" ]; then - export NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED="$SOURCEBOT_TELEMETRY_DISABLED" - fi - - # Infer NEXT_PUBLIC_SOURCEBOT_VERSION if it is not set - if [ -z "$NEXT_PUBLIC_SOURCEBOT_VERSION" ] && [ ! -z "$SOURCEBOT_VERSION" ]; then - export NEXT_PUBLIC_SOURCEBOT_VERSION="$SOURCEBOT_VERSION" - fi - - # Infer NEXT_PUBLIC_PUBLIC_SEARCH_DEMO if it is not set - if [ -z "$NEXT_PUBLIC_PUBLIC_SEARCH_DEMO" ] && [ ! -z "$PUBLIC_SEARCH_DEMO" ]; then - export NEXT_PUBLIC_PUBLIC_SEARCH_DEMO="$PUBLIC_SEARCH_DEMO" - fi - - # Always infer NEXT_PUBLIC_POSTHOG_PAPIK - export NEXT_PUBLIC_POSTHOG_PAPIK="$POSTHOG_PAPIK" - - # Always infer NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY - export NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="$STRIPE_PUBLISHABLE_KEY" - - # Always infer NEXT_PUBLIC_SENTRY_ENVIRONMENT - export NEXT_PUBLIC_SENTRY_ENVIRONMENT="$SENTRY_ENVIRONMENT" - - # Always infer NEXT_PUBLIC_SENTRY_WEBAPP_DSN - export NEXT_PUBLIC_SENTRY_WEBAPP_DSN="$SENTRY_WEBAPP_DSN" - - # Iterate over all .js files in .next & public, making substitutions for the `BAKED_` sentinal values - # with their actual desired runtime value. - find /app/packages/web/public /app/packages/web/.next -type f -name "*.js" | - while read file; do - sed -i "s|BAKED_NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED|${NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED}|g" "$file" - sed -i "s|BAKED_NEXT_PUBLIC_SOURCEBOT_VERSION|${NEXT_PUBLIC_SOURCEBOT_VERSION}|g" "$file" - sed -i "s|BAKED_NEXT_PUBLIC_POSTHOG_PAPIK|${NEXT_PUBLIC_POSTHOG_PAPIK}|g" "$file" - sed -i "s|BAKED_NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY|${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}|g" "$file" - sed -i "s|BAKED_NEXT_PUBLIC_SENTRY_ENVIRONMENT|${NEXT_PUBLIC_SENTRY_ENVIRONMENT}|g" "$file" - sed -i "s|BAKED_NEXT_PUBLIC_SENTRY_WEBAPP_DSN|${NEXT_PUBLIC_SENTRY_WEBAPP_DSN}|g" "$file" - sed -i "s|BAKED_NEXT_PUBLIC_PUBLIC_SEARCH_DEMO|${NEXT_PUBLIC_PUBLIC_SEARCH_DEMO}|g" "$file" - done -} - -# @nocheckin: This was interfering with the the `matcher` regex in middleware.ts, -# causing regular expressions parsing errors when making a request. It's unclear -# why exactly this was happening, but it's likely due to a bad replacement happening -# in the `sed` command. -# -# # Update specifically NEXT_PUBLIC_DOMAIN_SUB_PATH w/o requiring a rebuild. -# # Ultimately, the DOMAIN_SUB_PATH sets the `basePath` param in the next.config.mjs. -# # Similar to above, we pass in a `BAKED_` sentinal value into next.config.mjs at build -# # time. Unlike above, the `basePath` configuration is set in files other than just javascript -# # code (e.g., manifest files, css files, etc.), so this section has subtle differences. -# # -# # @see: https://nextjs.org/docs/app/api-reference/next-config-js/basePath -# # @see: https://phase.dev/blog/nextjs-public-runtime-variables/ -# { -# if [ ! -z "$DOMAIN_SUB_PATH" ]; then -# # If the sub-path is "/", this creates problems with certain replacements. For example: -# # /BAKED_NEXT_PUBLIC_DOMAIN_SUB_PATH/_next/image -> //_next/image (notice the double slash...) -# # To get around this, we default to an empty sub-path, which is the default when no sub-path is defined. -# if [ "$DOMAIN_SUB_PATH" = "/" ]; then -# DOMAIN_SUB_PATH="" - -# # Otherwise, we need to ensure that the sub-path starts with a slash, since this is a requirement -# # for the basePath property. For example, assume DOMAIN_SUB_PATH=/bot, then: -# # /BAKED_NEXT_PUBLIC_DOMAIN_SUB_PATH/_next/image -> /bot/_next/image -# elif [[ ! "$DOMAIN_SUB_PATH" =~ ^/ ]]; then -# DOMAIN_SUB_PATH="/$DOMAIN_SUB_PATH" -# fi -# fi - -# if [ ! -z "$DOMAIN_SUB_PATH" ]; then -# echo -e "\e[34m[Info] DOMAIN_SUB_PATH was set to "$DOMAIN_SUB_PATH". Overriding default path.\e[0m" -# fi - -# # Always set NEXT_PUBLIC_DOMAIN_SUB_PATH to DOMAIN_SUB_PATH (even if it is empty!!) -# export NEXT_PUBLIC_DOMAIN_SUB_PATH="$DOMAIN_SUB_PATH" - -# # Iterate over _all_ files in the web directory, making substitutions for the `BAKED_` sentinal values -# # with their actual desired runtime value. -# find /app/packages/web -type f | -# while read file; do -# # @note: the leading "/" is required here as it is included at build time. See Dockerfile. -# sed -i "s|/BAKED_NEXT_PUBLIC_DOMAIN_SUB_PATH|${NEXT_PUBLIC_DOMAIN_SUB_PATH}|g" "$file" -# done -# } - -# Upload sourcemaps to Sentry -# @nocheckin -su -c "sentry-cli login --auth-token $SENTRY_AUTH_TOKEN" -su -c "sentry-cli sourcemaps inject --org sourcebot --project backend /app/packages/backend/dist" -su -c "sentry-cli sourcemaps upload --org sourcebot --project backend /app/packages/backend/dist" - - # Start the database and wait for it to be ready before starting any other service if [ "$DATABASE_URL" = "postgresql://postgres@localhost:5432/sourcebot" ]; then su postgres -c "postgres -D $DB_DATA_DIR" & diff --git a/packages/backend/.env b/packages/backend/.env deleted file mode 100644 index baa6a2c1..00000000 --- a/packages/backend/.env +++ /dev/null @@ -1 +0,0 @@ -POSTHOG_HOST=https://us.i.posthog.com diff --git a/packages/backend/package.json b/packages/backend/package.json index bf6b130d..0c91fbdb 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -32,6 +32,7 @@ "@sourcebot/db": "^0.1.0", "@sourcebot/error": "^0.1.0", "@sourcebot/schemas": "^0.1.0", + "@t3-oss/env-core": "^0.12.0", "@types/express": "^5.0.0", "argparse": "^2.0.1", "bullmq": "^5.34.10", @@ -47,6 +48,7 @@ "prom-client": "^15.1.3", "simple-git": "^3.27.0", "strip-json-comments": "^5.0.1", - "winston": "^3.15.0" + "winston": "^3.15.0", + "zod": "^3.24.2" } } diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts new file mode 100644 index 00000000..7815f48c --- /dev/null +++ b/packages/backend/src/env.ts @@ -0,0 +1,41 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod"; +import dotenv from 'dotenv'; + +dotenv.config({ + path: './.env', +}); + +dotenv.config({ + path: './.env.local', + override: true +}); + +export const env = createEnv({ + server: { + SOURCEBOT_ENCRYPTION_KEY: z.string(), + SOURCEBOT_LOG_LEVEL: z.enum(["info", "debug", "warn", "error"]).default("info"), + SOURCEBOT_TELEMETRY_DISABLED: z.enum(["true", "false"]).default("false"), + SOURCEBOT_INSTALL_ID: z.string().default("unknown"), + SOURCEBOT_VERSION: z.string().default("unknown"), + + POSTHOG_PAPIK: z.string().optional(), + POSTHOG_HOST: z.string().url().default('https://us.i.posthog.com'), + + FALLBACK_GITHUB_TOKEN: z.string().optional(), + FALLBACK_GITLAB_TOKEN: z.string().optional(), + FALLBACK_GITEA_TOKEN: z.string().optional(), + + REDIS_URL: z.string().url().optional().default("redis://localhost:6379"), + + SENTRY_BACKEND_DSN: z.string().optional(), + SENTRY_ENVIRONMENT: z.string().optional(), + + LOGTAIL_TOKEN: z.string().optional(), + LOGTAIL_HOST: z.string().url().optional(), + }, + runtimeEnv: process.env, + emptyStringAsUndefined: true, + // Skip environment variable validation in Docker builds. + skipValidation: process.env.DOCKER_BUILD === "1", +}); \ No newline at end of file diff --git a/packages/backend/src/environment.ts b/packages/backend/src/environment.ts deleted file mode 100644 index d35dbdb5..00000000 --- a/packages/backend/src/environment.ts +++ /dev/null @@ -1,48 +0,0 @@ -import dotenv from 'dotenv'; -import * as Sentry from "@sentry/node"; - -export const getEnv = (env: string | undefined, defaultValue?: string, required?: boolean) => { - if (required && !env && !defaultValue) { - const e = new Error(`Missing required environment variable: ${env}`); - Sentry.captureException(e); - throw e; - } - - return env ?? defaultValue; -} - -export const getEnvBoolean = (env: string | undefined, defaultValue: boolean) => { - if (!env) { - return defaultValue; - } - return env === 'true' || env === '1'; -} - -dotenv.config({ - path: './.env', -}); -dotenv.config({ - path: './.env.local', - override: true -}); - - -export const SOURCEBOT_LOG_LEVEL = getEnv(process.env.SOURCEBOT_LOG_LEVEL, 'info')!; -export const SOURCEBOT_TELEMETRY_DISABLED = getEnvBoolean(process.env.SOURCEBOT_TELEMETRY_DISABLED, false)!; -export const SOURCEBOT_INSTALL_ID = getEnv(process.env.SOURCEBOT_INSTALL_ID, 'unknown')!; -export const SOURCEBOT_VERSION = getEnv(process.env.SOURCEBOT_VERSION, 'unknown')!; -export const POSTHOG_PAPIK = getEnv(process.env.POSTHOG_PAPIK); -export const POSTHOG_HOST = getEnv(process.env.POSTHOG_HOST); - -export const FALLBACK_GITHUB_TOKEN = getEnv(process.env.FALLBACK_GITHUB_TOKEN); -export const FALLBACK_GITLAB_TOKEN = getEnv(process.env.FALLBACK_GITLAB_TOKEN); -export const FALLBACK_GITEA_TOKEN = getEnv(process.env.FALLBACK_GITEA_TOKEN); - -export const INDEX_CONCURRENCY_MULTIPLE = getEnv(process.env.INDEX_CONCURRENCY_MULTIPLE); -export const REDIS_URL = getEnv(process.env.REDIS_URL, 'redis://localhost:6379')!; - -export const SENTRY_BACKEND_DSN = getEnv(process.env.SENTRY_BACKEND_DSN); -export const SENTRY_ENVIRONMENT = getEnv(process.env.SENTRY_ENVIRONMENT, 'unknown')!; - -export const LOGTAIL_TOKEN = getEnv(process.env.LOGTAIL_TOKEN); -export const LOGTAIL_HOST = getEnv(process.env.LOGTAIL_HOST); \ No newline at end of file diff --git a/packages/backend/src/gitea.ts b/packages/backend/src/gitea.ts index eca46d3d..e43ae5de 100644 --- a/packages/backend/src/gitea.ts +++ b/packages/backend/src/gitea.ts @@ -5,15 +5,15 @@ import fetch from 'cross-fetch'; import { createLogger } from './logger.js'; import micromatch from 'micromatch'; import { PrismaClient } from '@sourcebot/db'; -import { FALLBACK_GITEA_TOKEN } from './environment.js'; import { processPromiseResults, throwIfAnyFailed } from './connectionUtils.js'; import * as Sentry from "@sentry/node"; +import { env } from './env.js'; const logger = createLogger('Gitea'); export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, orgId: number, db: PrismaClient) => { const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined; - const token = tokenResult?.token ?? FALLBACK_GITEA_TOKEN; + const token = tokenResult?.token ?? env.FALLBACK_GITEA_TOKEN; const api = giteaApi(config.url ?? 'https://gitea.com', { token: token, diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index d65c0679..cdf6fa02 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -4,10 +4,10 @@ import { createLogger } from "./logger.js"; import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; import micromatch from "micromatch"; import { PrismaClient } from "@sourcebot/db"; -import { FALLBACK_GITHUB_TOKEN } from "./environment.js"; import { BackendException, BackendError } from "@sourcebot/error"; import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; import * as Sentry from "@sentry/node"; +import { env } from "./env.js"; const logger = createLogger("GitHub"); @@ -45,7 +45,7 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o const secretKey = tokenResult?.secretKey; const octokit = new Octokit({ - auth: token ?? FALLBACK_GITHUB_TOKEN, + auth: token ?? env.FALLBACK_GITHUB_TOKEN, ...(config.url ? { baseUrl: `${config.url}/api/v3` } : {}), diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts index bca14133..bffccfa1 100644 --- a/packages/backend/src/gitlab.ts +++ b/packages/backend/src/gitlab.ts @@ -4,16 +4,16 @@ import { createLogger } from "./logger.js"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type" import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; import { PrismaClient } from "@sourcebot/db"; -import { FALLBACK_GITLAB_TOKEN } from "./environment.js"; import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; import * as Sentry from "@sentry/node"; +import { env } from "./env.js"; const logger = createLogger("GitLab"); export const GITLAB_CLOUD_HOSTNAME = "gitlab.com"; export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => { const tokenResult = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined; - const token = tokenResult?.token ?? FALLBACK_GITLAB_TOKEN; + const token = tokenResult?.token ?? env.FALLBACK_GITLAB_TOKEN; const api = new Gitlab({ ...(token ? { diff --git a/packages/backend/src/instrument.ts b/packages/backend/src/instrument.ts index bf0fd33d..179ad41c 100644 --- a/packages/backend/src/instrument.ts +++ b/packages/backend/src/instrument.ts @@ -1,8 +1,8 @@ import * as Sentry from "@sentry/node"; -import { SOURCEBOT_VERSION, SENTRY_BACKEND_DSN, SENTRY_ENVIRONMENT } from "./environment.js"; +import { env } from "./env.js"; Sentry.init({ - dsn: SENTRY_BACKEND_DSN, - release: SOURCEBOT_VERSION, - environment: SENTRY_ENVIRONMENT, + dsn: env.SENTRY_BACKEND_DSN, + release: env.SOURCEBOT_VERSION, + environment: env.SENTRY_ENVIRONMENT, }); diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index a6631ce4..1701d7e6 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -1,14 +1,14 @@ import winston, { format } from 'winston'; -import { SOURCEBOT_LOG_LEVEL, LOGTAIL_TOKEN, LOGTAIL_HOST } from './environment.js'; import { Logtail } from '@logtail/node'; import { LogtailTransport } from '@logtail/winston'; +import { env } from './env.js'; const { combine, colorize, timestamp, prettyPrint, errors, printf, label: labelFn } = format; const createLogger = (label: string) => { return winston.createLogger({ - level: SOURCEBOT_LOG_LEVEL, + level: env.SOURCEBOT_LOG_LEVEL, format: combine( errors({ stack: true }), timestamp(), @@ -31,10 +31,10 @@ const createLogger = (label: string) => { }), ), }), - ...(LOGTAIL_TOKEN && LOGTAIL_HOST ? [ + ...(env.LOGTAIL_TOKEN && env.LOGTAIL_HOST ? [ new LogtailTransport( - new Logtail(LOGTAIL_TOKEN, { - endpoint: LOGTAIL_HOST, + new Logtail(env.LOGTAIL_TOKEN, { + endpoint: env.LOGTAIL_HOST, }) ) ] : []), diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 4080880d..01d9a29b 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -5,13 +5,13 @@ import { DEFAULT_SETTINGS } from './constants.js'; import { Redis } from 'ioredis'; import { ConnectionManager } from './connectionManager.js'; import { RepoManager } from './repoManager.js'; -import { INDEX_CONCURRENCY_MULTIPLE, REDIS_URL } from './environment.js'; +import { env } from './env.js'; import { PromClient } from './promClient.js'; const logger = createLogger('main'); export const main = async (db: PrismaClient, context: AppContext) => { - const redis = new Redis(REDIS_URL, { + const redis = new Redis(env.REDIS_URL, { maxRetriesPerRequest: null }); redis.ping().then(() => { @@ -23,8 +23,8 @@ export const main = async (db: PrismaClient, context: AppContext) => { }); const settings = DEFAULT_SETTINGS; - if (INDEX_CONCURRENCY_MULTIPLE) { - settings.indexConcurrencyMultiple = parseInt(INDEX_CONCURRENCY_MULTIPLE); + if (env.INDEX_CONCURRENCY_MULTIPLE) { + settings.indexConcurrencyMultiple = env.INDEX_CONCURRENCY_MULTIPLE; } const promClient = new PromClient(); diff --git a/packages/backend/src/posthog.ts b/packages/backend/src/posthog.ts index 58287424..1ef64755 100644 --- a/packages/backend/src/posthog.ts +++ b/packages/backend/src/posthog.ts @@ -1,29 +1,29 @@ import { PostHog } from 'posthog-node'; import { PosthogEvent, PosthogEventMap } from './posthogEvents.js'; -import { POSTHOG_HOST, POSTHOG_PAPIK, SOURCEBOT_INSTALL_ID, SOURCEBOT_TELEMETRY_DISABLED, SOURCEBOT_VERSION } from './environment.js'; +import { env } from './env.js'; let posthog: PostHog | undefined = undefined; -if (POSTHOG_PAPIK) { +if (env.POSTHOG_PAPIK) { posthog = new PostHog( - POSTHOG_PAPIK, + env.POSTHOG_PAPIK, { - host: POSTHOG_HOST, + host: env.POSTHOG_HOST, } ); } export function captureEvent(event: E, properties: PosthogEventMap[E]) { - if (SOURCEBOT_TELEMETRY_DISABLED) { + if (env.SOURCEBOT_TELEMETRY_DISABLED) { return; } posthog?.capture({ - distinctId: SOURCEBOT_INSTALL_ID, + distinctId: env.SOURCEBOT_INSTALL_ID, event: event, properties: { ...properties, - sourcebot_version: SOURCEBOT_VERSION, + sourcebot_version: env.SOURCEBOT_VERSION, }, }); } diff --git a/packages/web/.env b/packages/web/.env deleted file mode 100644 index c0fc3865..00000000 --- a/packages/web/.env +++ /dev/null @@ -1,3 +0,0 @@ -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -NEXT_PUBLIC_POSTHOG_ASSET_HOST=https://us-assets.i.posthog.com -NEXT_PUBLIC_POSTHOG_UI_HOST=https://us.posthog.com diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs index 2fde9895..a85c24d4 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -1,22 +1,30 @@ -import {withSentryConfig} from "@sentry/nextjs"; +await import("./src/env.mjs"); +import { withSentryConfig } from "@sentry/nextjs"; +import { env } from "./src/env.mjs"; + + /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", + // This is required when using standalone builds. + // @see: https://env.t3.gg/docs/nextjs#create-your-schema + transpilePackages: ["@t3-oss/env-nextjs", "@t3-oss/env-core"], + // @see : https://posthog.com/docs/advanced/proxy/nextjs async rewrites() { return [ { source: "/ingest/static/:path*", - destination: `${process.env.NEXT_PUBLIC_POSTHOG_ASSET_HOST}/static/:path*`, + destination: `${env.NEXT_PUBLIC_POSTHOG_ASSET_HOST}/static/:path*`, }, { source: "/ingest/:path*", - destination: `${process.env.NEXT_PUBLIC_POSTHOG_HOST}/:path*`, + destination: `${env.NEXT_PUBLIC_POSTHOG_HOST}/:path*`, }, { source: "/ingest/decide", - destination: `${process.env.NEXT_PUBLIC_POSTHOG_HOST}/decide`, + destination: `${env.NEXT_PUBLIC_POSTHOG_HOST}/decide`, }, ]; }, @@ -30,51 +38,42 @@ const nextConfig = { hostname: '**', }, ] - } - - // @nocheckin: This was interfering with the the `matcher` regex in middleware.ts, - // causing regular expressions parsing errors when making a request. It's unclear - // why exactly this was happening, but it's likely due to a bad replacement happening - // in the `sed` command. - // @note: this is evaluated at build time. - // ...(process.env.NEXT_PUBLIC_DOMAIN_SUB_PATH ? { - // basePath: process.env.NEXT_PUBLIC_DOMAIN_SUB_PATH, - // } : {}) + }, }; export default withSentryConfig(nextConfig, { -// For all available options, see: -// https://www.npmjs.com/package/@sentry/webpack-plugin#options + // For all available options, see: + // https://www.npmjs.com/package/@sentry/webpack-plugin#options -org: "sourcebot", -project: "webapp", + org: "sourcebot", + project: "webapp", -// Only print logs for uploading source maps in CI -silent: !process.env.CI, + // Only print logs for uploading source maps in CI + silent: !process.env.CI, -// For all available options, see: -// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ -// Upload a larger set of source maps for prettier stack traces (increases build time) -widenClientFileUpload: true, + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, -// Automatically annotate React components to show their full name in breadcrumbs and session replay -reactComponentAnnotation: { -enabled: true, -}, + // Automatically annotate React components to show their full name in breadcrumbs and session replay + reactComponentAnnotation: { + enabled: true, + }, -// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. -// This can increase your server load as well as your hosting bill. -// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- -// side errors will fail. -tunnelRoute: "/monitoring", + // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + tunnelRoute: "/monitoring", -// Automatically tree-shake Sentry logger statements to reduce bundle size -disableLogger: true, + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, -// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) -// See the following for more information: -// https://docs.sentry.io/product/crons/ -// https://vercel.com/docs/cron-jobs -automaticVercelMonitors: true, + // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, }); \ No newline at end of file diff --git a/packages/web/package.json b/packages/web/package.json index 10816c84..9664a83d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -75,6 +75,7 @@ "@ssddanbrown/codemirror-lang-twig": "^1.0.0", "@stripe/react-stripe-js": "^3.1.1", "@stripe/stripe-js": "^5.6.0", + "@t3-oss/env-nextjs": "^0.12.0", "@tanstack/react-query": "^5.53.3", "@tanstack/react-table": "^8.20.5", "@tanstack/react-virtual": "^3.10.8", @@ -133,7 +134,7 @@ "tailwind-scrollbar-hide": "^1.1.7", "tailwindcss-animate": "^1.0.7", "usehooks-ts": "^3.1.0", - "zod": "^3.23.8" + "zod": "^3.24.2" }, "devDependencies": { "@types/bcrypt": "^5.0.2", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 00e149ee..3f747390 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -16,10 +16,9 @@ import { decrypt, encrypt } from "@sourcebot/crypto" import { getConnection } from "./data/connection"; import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; import { cookies, headers } from "next/headers" -import { getStripe } from "@/lib/stripe" import { getUser } from "@/data/user"; import { Session } from "next-auth"; -import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN, EMAIL_FROM, SMTP_CONNECTION_URL, AUTH_URL } from "@/lib/environment"; +import { env } from "@/env.mjs"; import Stripe from "stripe"; import { render } from "@react-email/components"; import InviteUserEmail from "./emails/inviteUserEmail"; @@ -27,6 +26,7 @@ import { createTransport } from "nodemailer"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { RepositoryQuery } from "./lib/types"; import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants"; +import { stripeClient } from "./lib/stripe"; const ajv = new Ajv({ validateFormats: false, @@ -594,7 +594,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ }); // Send invites to recipients - if (SMTP_CONNECTION_URL && EMAIL_FROM) { + if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM) { const origin = (await headers()).get('origin')!; await Promise.all(emails.map(async (email) => { const invite = await prisma.invite.findUnique({ @@ -619,9 +619,9 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ }, }); const inviteLink = `${origin}/redeem?invite_id=${invite.id}`; - const transport = createTransport(SMTP_CONNECTION_URL); + const transport = createTransport(env.SMTP_CONNECTION_URL); const html = await render(InviteUserEmail({ - baseUrl: AUTH_URL, + baseUrl: env.AUTH_URL, host: { name: session.user.name ?? undefined, email: session.user.email!, @@ -637,7 +637,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ const result = await transport.sendMail({ to: email, - from: EMAIL_FROM, + from: env.EMAIL_FROM, subject: `Join ${invite.org.name} on Sourcebot`, html, text: `Join ${invite.org.name} on Sourcebot by clicking here: ${inviteLink}`, @@ -718,8 +718,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean const existingSeatCount = subscription.items.data[0].quantity; const newSeatCount = (existingSeatCount || 1) + 1 - const stripe = getStripe(); - await stripe.subscriptionItems.update( + await stripeClient?.subscriptionItems.update( subscription.items.data[0].id, { quantity: newSeatCount, @@ -873,10 +872,16 @@ export const createOnboardingSubscription = async (domain: string) => return notFound(); } - const stripe = getStripe(); + if (!stripeClient) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED, + message: "Stripe client is not initialized.", + } satisfies ServiceError; + } // @nocheckin - const test_clock = AUTH_URL !== "https://app.sourcebot.dev" ? await stripe.testHelpers.testClocks.create({ + const test_clock = env.AUTH_URL !== "https://app.sourcebot.dev" ? await stripeClient.testHelpers.testClocks.create({ frozen_time: Math.floor(Date.now() / 1000) }) : null; @@ -886,7 +891,7 @@ export const createOnboardingSubscription = async (domain: string) => return org.stripeCustomerId; } - const customer = await stripe.customers.create({ + const customer = await stripeClient.customers.create({ name: org.name, email: user.email ?? undefined, test_clock: test_clock?.id, @@ -915,13 +920,13 @@ export const createOnboardingSubscription = async (domain: string) => } - const prices = await stripe.prices.list({ - product: STRIPE_PRODUCT_ID, + const prices = await stripeClient.prices.list({ + product: env.STRIPE_PRODUCT_ID, expand: ['data.product'], }); try { - const subscription = await stripe.subscriptions.create({ + const subscription = await stripeClient.subscriptions.create({ customer: customerId, items: [{ price: prices.data[0].id, @@ -974,6 +979,14 @@ export const createStripeCheckoutSession = async (domain: string) => return notFound(); } + if (!stripeClient) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.STRIPE_CLIENT_NOT_INITIALIZED, + message: "Stripe client is not initialized.", + } satisfies ServiceError; + } + const orgMembers = await prisma.userToOrg.findMany({ where: { orgId, @@ -984,14 +997,13 @@ export const createStripeCheckoutSession = async (domain: string) => }); const numOrgMembers = orgMembers.length; - const stripe = getStripe(); const origin = (await headers()).get('origin') - const prices = await stripe.prices.list({ - product: STRIPE_PRODUCT_ID, + const prices = await stripeClient.prices.list({ + product: env.STRIPE_PRODUCT_ID, expand: ['data.product'], }); - const stripeSession = await stripe.checkout.sessions.create({ + const stripeSession = await stripeClient.checkout.sessions.create({ customer: org.stripeCustomerId as string, payment_method_types: ['card'], line_items: [ @@ -1033,9 +1045,16 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise { } })(); - if (!hasToken && numRepos && numRepos > CONFIG_MAX_REPOS_NO_TOKEN) { + if (!hasToken && numRepos && numRepos > env.CONFIG_MAX_REPOS_NO_TOKEN) { return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: `You must provide a token to sync more than ${CONFIG_MAX_REPOS_NO_TOKEN} repositories.`, + message: `You must provide a token to sync more than ${env.CONFIG_MAX_REPOS_NO_TOKEN} repositories.`, } satisfies ServiceError; } diff --git a/packages/web/src/app/[domain]/components/errorNavIndicator.tsx b/packages/web/src/app/[domain]/components/errorNavIndicator.tsx index e18c03a3..cf26ba56 100644 --- a/packages/web/src/app/[domain]/components/errorNavIndicator.tsx +++ b/packages/web/src/app/[domain]/components/errorNavIndicator.tsx @@ -6,7 +6,7 @@ import { CircleXIcon } from "lucide-react"; import { useDomain } from "@/hooks/useDomain"; import { unwrapServiceError } from "@/lib/utils"; import useCaptureEvent from "@/hooks/useCaptureEvent"; -import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client"; +import { env } from "@/env.mjs"; import { useQuery } from "@tanstack/react-query"; import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db"; import { getConnections } from "@/actions"; @@ -20,14 +20,14 @@ export const ErrorNavIndicator = () => { queryKey: ['repos', domain], queryFn: () => unwrapServiceError(getRepos(domain)), select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.FAILED), - refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS, + refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS, }); const { data: connections, isPending: isPendingConnections, isError: isErrorConnections } = useQuery({ queryKey: ['connections', domain], queryFn: () => unwrapServiceError(getConnections(domain)), select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.FAILED), - refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS, + refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS, }); if (isPendingRepos || isErrorRepos || isPendingConnections || isErrorConnections) { diff --git a/packages/web/src/app/[domain]/components/progressNavIndicator.tsx b/packages/web/src/app/[domain]/components/progressNavIndicator.tsx index e114559d..3bb40d6a 100644 --- a/packages/web/src/app/[domain]/components/progressNavIndicator.tsx +++ b/packages/web/src/app/[domain]/components/progressNavIndicator.tsx @@ -4,7 +4,7 @@ import { getRepos } from "@/actions"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useDomain } from "@/hooks/useDomain"; -import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client"; +import { env } from "@/env.mjs"; import { unwrapServiceError } from "@/lib/utils"; import { RepoIndexingStatus } from "@prisma/client"; import { useQuery } from "@tanstack/react-query"; @@ -19,7 +19,7 @@ export const ProgressNavIndicator = () => { queryKey: ['repos', domain], queryFn: () => unwrapServiceError(getRepos(domain)), select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repoIndexingStatus === RepoIndexingStatus.INDEXING), - refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS, + refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS, }); if (isPending || isError || inProgressRepos.length === 0) { diff --git a/packages/web/src/app/[domain]/components/repositorySnapshot.tsx b/packages/web/src/app/[domain]/components/repositorySnapshot.tsx index 80dab162..1b2c3470 100644 --- a/packages/web/src/app/[domain]/components/repositorySnapshot.tsx +++ b/packages/web/src/app/[domain]/components/repositorySnapshot.tsx @@ -6,7 +6,7 @@ import { useDomain } from "@/hooks/useDomain"; import { useQuery } from "@tanstack/react-query"; import { unwrapServiceError } from "@/lib/utils"; import { getRepos } from "@/actions"; -import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client"; +import { env } from "@/env.mjs"; import { Skeleton } from "@/components/ui/skeleton"; import { Carousel, @@ -22,7 +22,7 @@ export function RepositorySnapshot() { const { data: repos, isPending, isError } = useQuery({ queryKey: ['repos', domain], queryFn: () => unwrapServiceError(getRepos(domain)), - refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS, + refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS, }); if (isPending || isError || !repos) { diff --git a/packages/web/src/app/[domain]/components/settingsDropdown.tsx b/packages/web/src/app/[domain]/components/settingsDropdown.tsx index 94ca83d7..e6b84ead 100644 --- a/packages/web/src/app/[domain]/components/settingsDropdown.tsx +++ b/packages/web/src/app/[domain]/components/settingsDropdown.tsx @@ -28,11 +28,10 @@ import { useMemo } from "react" import { KeymapType } from "@/lib/types" import { cn } from "@/lib/utils" import { useKeymapType } from "@/hooks/useKeymapType" -import { NEXT_PUBLIC_SOURCEBOT_VERSION } from "@/lib/environment.client"; import { useSession } from "next-auth/react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { signOut } from "next-auth/react" - +import { env } from "@/env.mjs"; interface SettingsDropdownProps { menuButtonClassName?: string; @@ -147,7 +146,7 @@ export const SettingsDropdown = ({
- version: {NEXT_PUBLIC_SOURCEBOT_VERSION} + version: {env.NEXT_PUBLIC_SOURCEBOT_VERSION}
diff --git a/packages/web/src/app/[domain]/components/warningNavIndicator.tsx b/packages/web/src/app/[domain]/components/warningNavIndicator.tsx index 26363f1a..496c8f0a 100644 --- a/packages/web/src/app/[domain]/components/warningNavIndicator.tsx +++ b/packages/web/src/app/[domain]/components/warningNavIndicator.tsx @@ -7,7 +7,7 @@ import { useDomain } from "@/hooks/useDomain"; import { getConnections } from "@/actions"; import { unwrapServiceError } from "@/lib/utils"; import useCaptureEvent from "@/hooks/useCaptureEvent"; -import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client"; +import { env } from "@/env.mjs"; import { useQuery } from "@tanstack/react-query"; import { ConnectionSyncStatus } from "@prisma/client"; @@ -19,7 +19,7 @@ export const WarningNavIndicator = () => { queryKey: ['connections', domain], queryFn: () => unwrapServiceError(getConnections(domain)), select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.SYNCED_WITH_WARNINGS), - refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS, + refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS, }); if (isPending || isError || connections.length === 0) { diff --git a/packages/web/src/app/[domain]/connections/[id]/components/overview.tsx b/packages/web/src/app/[domain]/connections/[id]/components/overview.tsx index 8836baed..6b7dbbaf 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/overview.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/overview.tsx @@ -10,7 +10,7 @@ import { useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { flagConnectionForSync, getConnectionInfo } from "@/actions"; import { isServiceError, unwrapServiceError } from "@/lib/utils"; -import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client"; +import { env } from "@/env.mjs"; import { ConnectionSyncStatus } from "@sourcebot/db"; import { FiLoader } from "react-icons/fi"; import { CircleCheckIcon, AlertTriangle, CircleXIcon } from "lucide-react"; @@ -31,7 +31,7 @@ export const Overview = ({ connectionId }: OverviewProps) => { const { data: connection, isPending, error, refetch } = useQuery({ queryKey: ['connection', domain, connectionId], queryFn: () => unwrapServiceError(getConnectionInfo(connectionId, domain)), - refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS, + refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS, }); const handleSecretsNavigation = useCallback(() => { diff --git a/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx b/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx index df60ca38..4f43ba3e 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx @@ -11,7 +11,7 @@ import { Search, Loader2 } from "lucide-react"; import { Input } from "@/components/ui/input"; import { useCallback, useMemo, useState } from "react"; import { RepoListItemSkeleton } from "./repoListItemSkeleton"; -import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client"; +import { env } from "@/env.mjs"; import { Button } from "@/components/ui/button"; import { useRouter } from "next/navigation"; import { MultiSelect } from "@/components/ui/multi-select"; @@ -78,7 +78,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => { return new Date(a.indexedAt ?? new Date()).getTime() - new Date(b.indexedAt ?? new Date()).getTime(); }); }, - refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS, + refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS, }); const { data: connection, isPending: isConnectionPending, error: isConnectionError } = useQuery({ diff --git a/packages/web/src/app/[domain]/connections/components/connectionList/index.tsx b/packages/web/src/app/[domain]/connections/components/connectionList/index.tsx index ef3502ae..b3d62b0a 100644 --- a/packages/web/src/app/[domain]/connections/components/connectionList/index.tsx +++ b/packages/web/src/app/[domain]/connections/components/connectionList/index.tsx @@ -6,7 +6,7 @@ import { InfoCircledIcon } from "@radix-ui/react-icons"; import { getConnections } from "@/actions"; import { Skeleton } from "@/components/ui/skeleton"; import { useQuery } from "@tanstack/react-query"; -import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client"; +import { env } from "@/env.mjs"; import { RepoIndexingStatus, ConnectionSyncStatus } from "@sourcebot/db"; import { Search } from "lucide-react"; import { Input } from "@/components/ui/input"; @@ -43,7 +43,7 @@ export const ConnectionList = ({ const { data: unfilteredConnections, isPending, error } = useQuery({ queryKey: ['connections', domain], queryFn: () => unwrapServiceError(getConnections(domain)), - refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS, + refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS, }); const connections = useMemo(() => { diff --git a/packages/web/src/app/[domain]/repos/repositoryTable.tsx b/packages/web/src/app/[domain]/repos/repositoryTable.tsx index ef94bc53..59ee672b 100644 --- a/packages/web/src/app/[domain]/repos/repositoryTable.tsx +++ b/packages/web/src/app/[domain]/repos/repositoryTable.tsx @@ -5,11 +5,11 @@ import { columns, RepositoryColumnInfo } from "./columns"; import { unwrapServiceError } from "@/lib/utils"; import { getRepos } from "@/actions"; import { useQuery } from "@tanstack/react-query"; -import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client"; import { useDomain } from "@/hooks/useDomain"; import { RepoIndexingStatus } from "@sourcebot/db"; import { useMemo } from "react"; import { Skeleton } from "@/components/ui/skeleton"; +import { env } from "@/env.mjs"; export const RepositoryTable = () => { const domain = useDomain(); @@ -19,7 +19,7 @@ export const RepositoryTable = () => { queryFn: async () => { return await unwrapServiceError(getRepos(domain)); }, - refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS, + refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS, refetchIntervalInBackground: true, }); diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index d6e9d10f..0b587903 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -22,7 +22,6 @@ import { CodePreviewPanel } from "./components/codePreviewPanel"; import { FilterPanel } from "./components/filterPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel"; import { useDomain } from "@/hooks/useDomain"; -import { NEXT_PUBLIC_PUBLIC_SEARCH_DEMO } from "@/lib/environment.client"; const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000; @@ -105,7 +104,6 @@ const SearchPageInternal = () => { const fileLanguages = searchResponse.Result.Files?.map(file => file.Language) || []; captureEvent("search_finished", { - query: NEXT_PUBLIC_PUBLIC_SEARCH_DEMO ? searchQuery : null, // @nocheckin contentBytesLoaded: searchResponse.Result.ContentBytesLoaded, indexBytesLoaded: searchResponse.Result.IndexBytesLoaded, crashes: searchResponse.Result.Crashes, diff --git a/packages/web/src/app/[domain]/settings/(general)/page.tsx b/packages/web/src/app/[domain]/settings/(general)/page.tsx index 1e322ee9..45ddfb73 100644 --- a/packages/web/src/app/[domain]/settings/(general)/page.tsx +++ b/packages/web/src/app/[domain]/settings/(general)/page.tsx @@ -4,7 +4,7 @@ import { isServiceError } from "@/lib/utils"; import { getCurrentUserRole } from "@/actions"; import { getOrgFromDomain } from "@/data/org"; import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard"; -import { SOURCEBOT_ROOT_DOMAIN } from "@/lib/environment"; +import { env } from "@/env.mjs"; interface GeneralSettingsPageProps { params: { @@ -42,7 +42,7 @@ export default async function GeneralSettingsPage({ params: { domain } }: Genera ) diff --git a/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx b/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx index 3302ee13..81f56862 100644 --- a/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx @@ -1,7 +1,6 @@ 'use client'; import { OrgRole } from "@sourcebot/db"; -import { resolveServerPath } from "@/app/api/(client)/client"; import { useToast } from "@/components/hooks/use-toast"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; @@ -124,8 +123,7 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => { className="gap-2" title="Copy invite link" onClick={() => { - const basePath = `${window.location.origin}${resolveServerPath('/')}`; - const url = createPathWithQueryParams(`${basePath}redeem?invite_id=${invite.id}`); + const url = createPathWithQueryParams(`${window.location.origin}/redeem?invite_id=${invite.id}`); navigator.clipboard.writeText(url) .then(() => { toast({ diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 4754dc4b..0131da2e 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -1,13 +1,11 @@ 'use client'; -import { NEXT_PUBLIC_DOMAIN_SUB_PATH } from "@/lib/environment.client"; import { fileSourceResponseSchema, getVersionResponseSchema, listRepositoriesResponseSchema, searchResponseSchema } from "@/lib/schemas"; import { FileSourceRequest, FileSourceResponse, GetVersionResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types"; import assert from "assert"; export const search = async (body: SearchRequest, domain: string): Promise => { - const path = resolveServerPath("/api/search"); - const result = await fetch(path, { + const result = await fetch("/api/search", { method: "POST", headers: { "Content-Type": "application/json", @@ -20,8 +18,7 @@ export const search = async (body: SearchRequest, domain: string): Promise => { - const path = resolveServerPath("/api/source"); - const result = await fetch(path, { + const result = await fetch("/api/source", { method: "POST", headers: { "Content-Type": "application/json", @@ -34,8 +31,7 @@ export const fetchFileSource = async (body: FileSourceRequest, domain: string): } export const getRepos = async (domain: string): Promise => { - const path = resolveServerPath("/api/repos"); - const result = await fetch(path, { + const result = await fetch("/api/repos", { method: "GET", headers: { "Content-Type": "application/json", @@ -47,8 +43,7 @@ export const getRepos = async (domain: string): Promise => { - const path = resolveServerPath("/api/version"); - const result = await fetch(path, { + const result = await fetch("/api/version", { method: "GET", headers: { "Content-Type": "application/json", @@ -56,13 +51,3 @@ export const getVersion = async (): Promise => { }).then(response => response.json()); return getVersionResponseSchema.parse(result); } - -/** - * Given a subpath to a api route on the server (e.g., /api/search), - * returns the full path to that route on the server, taking into account - * the base path (if any). - */ -export const resolveServerPath = (path: string) => { - assert(path.startsWith("/")); - return `${NEXT_PUBLIC_DOMAIN_SUB_PATH}${path}`; -} \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/stripe/route.ts b/packages/web/src/app/api/(server)/stripe/route.ts index 77c1a15e..9219c463 100644 --- a/packages/web/src/app/api/(server)/stripe/route.ts +++ b/packages/web/src/app/api/(server)/stripe/route.ts @@ -2,9 +2,9 @@ import { headers } from 'next/headers'; import { NextRequest } from 'next/server'; import Stripe from 'stripe'; import { prisma } from '@/prisma'; -import { STRIPE_WEBHOOK_SECRET } from '@/lib/environment'; -import { getStripe } from '@/lib/stripe'; import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db'; +import { stripeClient } from '@/lib/stripe'; +import { env } from '@/env.mjs'; export async function POST(req: NextRequest) { const body = await req.text(); @@ -14,12 +14,19 @@ export async function POST(req: NextRequest) { return new Response('No signature', { status: 400 }); } + if (!stripeClient) { + return new Response('Stripe client not initialized', { status: 500 }); + } + + if (!env.STRIPE_WEBHOOK_SECRET) { + return new Response('Stripe webhook secret not set', { status: 500 }); + } + try { - const stripe = getStripe(); - const event = stripe.webhooks.constructEvent( + const event = stripeClient.webhooks.constructEvent( body, signature, - STRIPE_WEBHOOK_SECRET! + env.STRIPE_WEBHOOK_SECRET ); if (event.type === 'customer.subscription.deleted') { diff --git a/packages/web/src/app/api/(server)/version/route.ts b/packages/web/src/app/api/(server)/version/route.ts index 309ffa19..750955de 100644 --- a/packages/web/src/app/api/(server)/version/route.ts +++ b/packages/web/src/app/api/(server)/version/route.ts @@ -1,4 +1,4 @@ -import { SOURCEBOT_VERSION } from "@/lib/environment"; +import { env } from "@/env.mjs"; import { GetVersionResponse } from "@/lib/types"; // Note: In Next.JS 14, GET methods with no params are cached by default at build time. @@ -10,6 +10,6 @@ export const dynamic = "force-dynamic"; export const GET = async () => { return Response.json({ - version: SOURCEBOT_VERSION, + version: env.NEXT_PUBLIC_SOURCEBOT_VERSION, } satisfies GetVersionResponse); } \ No newline at end of file diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 63fadd47..859cd8b6 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -6,6 +6,7 @@ import { PostHogProvider } from "./posthogProvider"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; import { SessionProvider } from "next-auth/react"; +import { env } from "@/env.mjs"; export const metadata: Metadata = { title: "Sourcebot", @@ -26,7 +27,7 @@ export default function RootLayout({ - + - + ); diff --git a/packages/web/src/app/posthogProvider.tsx b/packages/web/src/app/posthogProvider.tsx index b78b1f17..e574769c 100644 --- a/packages/web/src/app/posthogProvider.tsx +++ b/packages/web/src/app/posthogProvider.tsx @@ -1,14 +1,10 @@ 'use client' -import { NEXT_PUBLIC_POSTHOG_PAPIK, NEXT_PUBLIC_POSTHOG_UI_HOST, NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED, NEXT_PUBLIC_PUBLIC_SEARCH_DEMO } from '@/lib/environment.client' import posthog from 'posthog-js' import { usePostHog } from 'posthog-js/react' import { PostHogProvider as PHProvider } from 'posthog-js/react' -import { resolveServerPath } from './api/(client)/client' -import { isDefined } from '@/lib/utils' import { usePathname, useSearchParams } from "next/navigation" -import { useEffect, Suspense } from "react" - -const POSTHOG_ENABLED = isDefined(NEXT_PUBLIC_POSTHOG_PAPIK) && !NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED; +import { Suspense, useEffect } from "react" +import { env } from '@/env.mjs' function PostHogPageView() { const pathname = usePathname() @@ -30,26 +26,23 @@ function PostHogPageView() { return null } -export default function SuspendedPostHogPageView() { - return - - +interface PostHogProviderProps { + children: React.ReactNode + disabled: boolean } -export function PostHogProvider({ children }: { children: React.ReactNode }) { +export function PostHogProvider({ children, disabled }: PostHogProviderProps) { useEffect(() => { - if (POSTHOG_ENABLED) { - // @see next.config.mjs for path rewrites to the "/ingest" route. - const posthogHostPath = resolveServerPath('/ingest'); - - posthog.init(NEXT_PUBLIC_POSTHOG_PAPIK!, { - api_host: posthogHostPath, - ui_host: NEXT_PUBLIC_POSTHOG_UI_HOST, + if (!disabled && env.NEXT_PUBLIC_POSTHOG_PAPIK) { + posthog.init(env.NEXT_PUBLIC_POSTHOG_PAPIK, { + // @see next.config.mjs for path rewrites to the "/ingest" route. + api_host: "/ingest", + ui_host: env.NEXT_PUBLIC_POSTHOG_UI_HOST, person_profiles: 'identified_only', - capture_pageview: NEXT_PUBLIC_PUBLIC_SEARCH_DEMO, // @nocheckin Disable automatic pageview capture if we're not in public demo mode + capture_pageview: false, // @nocheckin Disable automatic pageview capture if we're not in public demo mode autocapture: false, // Disable automatic event capture // eslint-disable-next-line @typescript-eslint/no-explicit-any - sanitize_properties: !NEXT_PUBLIC_PUBLIC_SEARCH_DEMO ? (properties: Record, _event: string) => { + sanitize_properties: (properties: Record, _event: string) => { // https://posthog.com/docs/libraries/js#config if (properties['$current_url']) { properties['$current_url'] = null; @@ -59,16 +52,18 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { } return properties; - } : undefined + } }); } else { console.log("PostHog telemetry disabled"); } - }, []) + }, [disabled]) return ( - + + + {children} ) diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 326b8fd6..8fb369a2 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -6,17 +6,7 @@ import Credentials from "next-auth/providers/credentials" import EmailProvider from "next-auth/providers/nodemailer"; import { PrismaAdapter } from "@auth/prisma-adapter" import { prisma } from "@/prisma"; -import { - AUTH_GITHUB_CLIENT_ID, - AUTH_GITHUB_CLIENT_SECRET, - AUTH_GOOGLE_CLIENT_ID, - AUTH_GOOGLE_CLIENT_SECRET, - AUTH_SECRET, - AUTH_URL, - AUTH_CREDENTIALS_LOGIN_ENABLED, - EMAIL_FROM, - SMTP_CONNECTION_URL -} from "./lib/environment"; +import { env } from "@/env.mjs"; import { User } from '@sourcebot/db'; import 'next-auth/jwt'; import type { Provider } from "next-auth/providers"; @@ -44,24 +34,24 @@ declare module 'next-auth/jwt' { export const getProviders = () => { const providers: Provider[] = []; - if (AUTH_GITHUB_CLIENT_ID && AUTH_GITHUB_CLIENT_SECRET) { + if (env.AUTH_GITHUB_CLIENT_ID && env.AUTH_GITHUB_CLIENT_SECRET) { providers.push(GitHub({ - clientId: AUTH_GITHUB_CLIENT_ID, - clientSecret: AUTH_GITHUB_CLIENT_SECRET, + clientId: env.AUTH_GITHUB_CLIENT_ID, + clientSecret: env.AUTH_GITHUB_CLIENT_SECRET, })); } - if (AUTH_GOOGLE_CLIENT_ID && AUTH_GOOGLE_CLIENT_SECRET) { + if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) { providers.push(Google({ - clientId: AUTH_GOOGLE_CLIENT_ID, - clientSecret: AUTH_GOOGLE_CLIENT_SECRET, + clientId: env.AUTH_GOOGLE_CLIENT_ID, + clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET, })); } - if (SMTP_CONNECTION_URL && EMAIL_FROM) { + if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM) { providers.push(EmailProvider({ - server: SMTP_CONNECTION_URL, - from: EMAIL_FROM, + server: env.SMTP_CONNECTION_URL, + from: env.EMAIL_FROM, maxAge: 60 * 10, generateVerificationToken: async () => { const token = String(Math.floor(100000 + Math.random() * 900000)); @@ -69,7 +59,7 @@ export const getProviders = () => { }, sendVerificationRequest: async ({ identifier, provider, token }) => { const transport = createTransport(provider.server); - const html = await render(MagicLinkEmail({ baseUrl: AUTH_URL, token: token })); + const html = await render(MagicLinkEmail({ baseUrl: env.AUTH_URL, token: token })); const result = await transport.sendMail({ to: identifier, from: provider.from, @@ -86,7 +76,7 @@ export const getProviders = () => { })); } - if (AUTH_CREDENTIALS_LOGIN_ENABLED) { + if (env.AUTH_CREDENTIALS_LOGIN_ENABLED) { providers.push(Credentials({ credentials: { email: {}, @@ -102,7 +92,7 @@ export const getProviders = () => { // authorize runs in the edge runtime (where we cannot make DB calls / access environment variables), // so we need to make a request to the server to verify the credentials. - const response = await fetch(new URL('/api/auth/verifyCredentials', AUTH_URL), { + const response = await fetch(new URL('/api/auth/verifyCredentials', env.AUTH_URL), { method: 'POST', body: JSON.stringify({ email, password }), }); @@ -125,11 +115,11 @@ export const getProviders = () => { return providers; } -const useSecureCookies = AUTH_URL?.startsWith("https://") ?? false; -const hostName = AUTH_URL ? new URL(AUTH_URL).hostname : "localhost"; +const useSecureCookies = env.AUTH_URL?.startsWith("https://") ?? false; +const hostName = env.AUTH_URL ? new URL(env.AUTH_URL).hostname : "localhost"; export const { handlers, signIn, signOut, auth } = NextAuth({ - secret: AUTH_SECRET, + secret: env.AUTH_SECRET, adapter: PrismaAdapter(prisma), session: { strategy: "jwt", diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs new file mode 100644 index 00000000..1570c582 --- /dev/null +++ b/packages/web/src/env.mjs @@ -0,0 +1,60 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + // Zoekt + ZOEKT_WEBSERVER_URL: z.string().url().default('http://localhost:6070'), + SHARD_MAX_MATCH_COUNT: z.number().default(10000), + TOTAL_MAX_MATCH_COUNT: z.number().default(100000), + + // Auth + AUTH_SECRET: z.string(), + AUTH_GITHUB_CLIENT_ID: z.string().optional(), + AUTH_GITHUB_CLIENT_SECRET: z.string().optional(), + AUTH_GOOGLE_CLIENT_ID: z.string().optional(), + AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(), + AUTH_URL: z.string().url(), + AUTH_CREDENTIALS_LOGIN_ENABLED: z.boolean().default(true), + + // Email + SMTP_CONNECTION_URL: z.string().url().optional(), + EMAIL_FROM: z.string().email().optional(), + + // Stripe + STRIPE_SECRET_KEY: z.string().optional(), + STRIPE_PRODUCT_ID: z.string().optional(), + STRIPE_WEBHOOK_SECRET: z.string().optional(), + + // Misc + CONFIG_MAX_REPOS_NO_TOKEN: z.number().default(500), + SOURCEBOT_ROOT_DOMAIN: z.string().default("localhost:3000"), + NODE_ENV: z.enum(["development", "test", "production"]), + SOURCEBOT_TELEMETRY_DISABLED: z.enum(["true", "false"]).default("false"), + }, + // @NOTE: Make sure you destructure all client variables in the + // `experimental__runtimeEnv` block below. + client: { + // PostHog + NEXT_PUBLIC_POSTHOG_PAPIK: z.string().optional(), + NEXT_PUBLIC_POSTHOG_HOST: z.string().url().default('https://us.i.posthog.com'), + NEXT_PUBLIC_POSTHOG_ASSET_HOST: z.string().url().default('https://us-assets.i.posthog.com'), + NEXT_PUBLIC_POSTHOG_UI_HOST: z.string().url().default('https://us.posthog.com'), + + // Misc + NEXT_PUBLIC_SOURCEBOT_VERSION: z.string().default('unknown'), + NEXT_PUBLIC_POLLING_INTERVAL_MS: z.number().default(5000), + }, + // For Next.js >= 13.4.4, you only need to destructure client variables: + experimental__runtimeEnv: { + NEXT_PUBLIC_POSTHOG_PAPIK: process.env.NEXT_PUBLIC_POSTHOG_PAPIK, + NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, + NEXT_PUBLIC_POSTHOG_ASSET_HOST: process.env.NEXT_PUBLIC_POSTHOG_ASSET_HOST, + NEXT_PUBLIC_POSTHOG_UI_HOST: process.env.NEXT_PUBLIC_POSTHOG_UI_HOST, + NEXT_PUBLIC_SOURCEBOT_VERSION: process.env.NEXT_PUBLIC_SOURCEBOT_VERSION, + NEXT_PUBLIC_POLLING_INTERVAL_MS: process.env.NEXT_PUBLIC_POLLING_INTERVAL_MS, + }, + // Skip environment variable validation in Docker builds. + skipValidation: process.env.DOCKER_BUILD === "1", + emptyStringAsUndefined: true, +}); \ No newline at end of file diff --git a/packages/web/src/hooks/useCaptureEvent.ts b/packages/web/src/hooks/useCaptureEvent.ts index ad7d66db..8dd03ffe 100644 --- a/packages/web/src/hooks/useCaptureEvent.ts +++ b/packages/web/src/hooks/useCaptureEvent.ts @@ -3,7 +3,7 @@ import { CaptureOptions } from "posthog-js"; import posthog from "posthog-js"; import { PosthogEvent, PosthogEventMap } from "../lib/posthogEvents"; -import { NEXT_PUBLIC_SOURCEBOT_VERSION } from "@/lib/environment.client"; +import { env } from "@/env.mjs"; export function captureEvent(event: E, properties: PosthogEventMap[E], options?: CaptureOptions) { if(!options) { @@ -12,7 +12,7 @@ export function captureEvent(event: E, properties: Posth options.send_instantly = true; posthog.capture(event, { ...properties, - sourcebot_version: NEXT_PUBLIC_SOURCEBOT_VERSION, + sourcebot_version: env.NEXT_PUBLIC_SOURCEBOT_VERSION, }, options); } diff --git a/packages/web/src/lib/environment.client.ts b/packages/web/src/lib/environment.client.ts deleted file mode 100644 index e08d1e6a..00000000 --- a/packages/web/src/lib/environment.client.ts +++ /dev/null @@ -1,14 +0,0 @@ -import 'client-only'; - -import { getEnv, getEnvBoolean, getEnvNumber } from "./utils"; - -export const NEXT_PUBLIC_POSTHOG_PAPIK = getEnv(process.env.NEXT_PUBLIC_POSTHOG_PAPIK); -export const NEXT_PUBLIC_POSTHOG_HOST = getEnv(process.env.NEXT_PUBLIC_POSTHOG_HOST); -export const NEXT_PUBLIC_POSTHOG_UI_HOST = getEnv(process.env.NEXT_PUBLIC_POSTHOG_UI_HOST); -export const NEXT_PUBLIC_POSTHOG_ASSET_HOST = getEnv(process.env.NEXT_PUBLIC_POSTHOG_ASSET_HOST); -export const NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED = getEnvBoolean(process.env.NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED, false); -export const NEXT_PUBLIC_SOURCEBOT_VERSION = getEnv(process.env.NEXT_PUBLIC_SOURCEBOT_VERSION, "unknown")!; -export const NEXT_PUBLIC_DOMAIN_SUB_PATH = getEnv(process.env.NEXT_PUBLIC_DOMAIN_SUB_PATH, "")!; -export const NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = getEnv(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); -export const NEXT_PUBLIC_POLLING_INTERVAL_MS = getEnvNumber(process.env.NEXT_PUBLIC_POLLING_INTERVAL_MS, 5000); -export const NEXT_PUBLIC_PUBLIC_SEARCH_DEMO = getEnvBoolean(process.env.NEXT_PUBLIC_PUBLIC_SEARCH_DEMO, false); diff --git a/packages/web/src/lib/environment.ts b/packages/web/src/lib/environment.ts deleted file mode 100644 index f6293fb7..00000000 --- a/packages/web/src/lib/environment.ts +++ /dev/null @@ -1,29 +0,0 @@ -import 'server-only'; - -import { getEnv, getEnvBoolean, getEnvNumber } from "./utils"; - -export const ZOEKT_WEBSERVER_URL = getEnv(process.env.ZOEKT_WEBSERVER_URL, "http://localhost:6070")!; -export const SHARD_MAX_MATCH_COUNT = getEnvNumber(process.env.SHARD_MAX_MATCH_COUNT, 10000); -export const TOTAL_MAX_MATCH_COUNT = getEnvNumber(process.env.TOTAL_MAX_MATCH_COUNT, 100000); -export const SOURCEBOT_VERSION = getEnv(process.env.SOURCEBOT_VERSION, 'unknown')!; -export const NODE_ENV = process.env.NODE_ENV; - -export const AUTH_SECRET = getEnv(process.env.AUTH_SECRET); // Generate using `npx auth secret` -export const AUTH_GITHUB_CLIENT_ID = getEnv(process.env.AUTH_GITHUB_CLIENT_ID); -export const AUTH_GITHUB_CLIENT_SECRET = getEnv(process.env.AUTH_GITHUB_CLIENT_SECRET); -export const AUTH_GOOGLE_CLIENT_ID = getEnv(process.env.AUTH_GOOGLE_CLIENT_ID); -export const AUTH_GOOGLE_CLIENT_SECRET = getEnv(process.env.AUTH_GOOGLE_CLIENT_SECRET); -export const AUTH_URL = getEnv(process.env.AUTH_URL)!; -export const AUTH_CREDENTIALS_LOGIN_ENABLED = getEnvBoolean(process.env.AUTH_CREDENTIALS_LOGIN_ENABLED, true); - -export const STRIPE_SECRET_KEY = getEnv(process.env.STRIPE_SECRET_KEY); -export const STRIPE_PRODUCT_ID = getEnv(process.env.STRIPE_PRODUCT_ID); -export const STRIPE_WEBHOOK_SECRET = getEnv(process.env.STRIPE_WEBHOOK_SECRET); - -export const CONFIG_MAX_REPOS_NO_TOKEN = getEnvNumber(process.env.CONFIG_MAX_REPOS_NO_TOKEN, 500); - -export const SMTP_CONNECTION_URL = getEnv(process.env.SMTP_CONNECTION_URL); -export const EMAIL_FROM = getEnv(process.env.EMAIL_FROM); - -export const SOURCEBOT_ROOT_DOMAIN = getEnv(process.env.SOURCEBOT_ROOT_DOMAIN, "localhost:3000")!; -export const PUBLIC_SEARCH_DEMO = getEnvBoolean(process.env.PUBLIC_SEARCH_DEMO, false); diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 8adff8a4..e978a175 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -19,4 +19,5 @@ export enum ErrorCode { STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR', SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS', SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS', + STRIPE_CLIENT_NOT_INITIALIZED = 'STRIPE_CLIENT_NOT_INITIALIZED', } diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index a75d33f7..2f36712d 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -2,7 +2,6 @@ export type PosthogEventMap = { search_finished: { - query: string | null, contentBytesLoaded: number, indexBytesLoaded: number, crashes: number, diff --git a/packages/web/src/lib/server/searchService.ts b/packages/web/src/lib/server/searchService.ts index e120f1c7..783872bc 100644 --- a/packages/web/src/lib/server/searchService.ts +++ b/packages/web/src/lib/server/searchService.ts @@ -1,5 +1,5 @@ import escapeStringRegexp from "escape-string-regexp"; -import { SHARD_MAX_MATCH_COUNT, TOTAL_MAX_MATCH_COUNT } from "../environment"; +import { env } from "@/env.mjs"; import { listRepositoriesResponseSchema, zoektSearchResponseSchema } from "../schemas"; import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "../types"; import { fileNotFound, invalidZoektResponse, ServiceError, unexpectedError } from "../serviceError"; @@ -59,8 +59,8 @@ export const search = async ({ query, maxMatchDisplayCount, whole}: SearchReques ChunkMatches: true, MaxMatchDisplayCount: maxMatchDisplayCount, Whole: !!whole, - ShardMaxMatchCount: SHARD_MAX_MATCH_COUNT, - TotalMaxMatchCount: TOTAL_MAX_MATCH_COUNT, + ShardMaxMatchCount: env.SHARD_MAX_MATCH_COUNT, + TotalMaxMatchCount: env.TOTAL_MAX_MATCH_COUNT, } }); diff --git a/packages/web/src/lib/server/zoektClient.ts b/packages/web/src/lib/server/zoektClient.ts index ac14f3e2..3379d2ce 100644 --- a/packages/web/src/lib/server/zoektClient.ts +++ b/packages/web/src/lib/server/zoektClient.ts @@ -1,5 +1,4 @@ -import { ZOEKT_WEBSERVER_URL } from "../environment" - +import { env } from "@/env.mjs"; interface ZoektRequest { path: string, @@ -17,7 +16,7 @@ export const zoektFetch = async ({ cache, }: ZoektRequest) => { const response = await fetch( - new URL(path, ZOEKT_WEBSERVER_URL), + new URL(path, env.ZOEKT_WEBSERVER_URL), { method, headers: { diff --git a/packages/web/src/lib/stripe.ts b/packages/web/src/lib/stripe.ts index 16e2fe0e..3588e958 100644 --- a/packages/web/src/lib/stripe.ts +++ b/packages/web/src/lib/stripe.ts @@ -1,12 +1,8 @@ import 'server-only'; +import { env } from '@/env.mjs' +import Stripe from "stripe"; -import Stripe from 'stripe' -import { STRIPE_SECRET_KEY } from './environment' - -let stripeInstance: Stripe | null = null; -export const getStripe = () => { - if (!stripeInstance) { - stripeInstance = new Stripe(STRIPE_SECRET_KEY!); - } - return stripeInstance; -} \ No newline at end of file +export const stripeClient = + env.STRIPE_SECRET_KEY + ? new Stripe(env.STRIPE_SECRET_KEY) + : undefined; \ No newline at end of file diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 44dad9b5..8f6e38c4 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -158,30 +158,6 @@ export const isServiceError = (data: unknown): data is ServiceError => { 'message' in data; } -export const getEnv = (env: string | undefined, defaultValue?: string) => { - return env ?? defaultValue; -} - -export const getEnvNumber = (env: string | undefined, defaultValue: number = 0) => { - if (!env) { - return defaultValue; - } - - const num = Number(env); - if (isNaN(num)) { - return defaultValue; - } - - return num; -} - -export const getEnvBoolean = (env: string | undefined, defaultValue: boolean) => { - if (!env) { - return defaultValue; - } - return env === 'true' || env === '1'; -} - // From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem export const base64Decode = (base64: string): string => { const binString = atob(base64); diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index 6b2f2e65..35ee5810 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -22,6 +22,6 @@ "@/public/*": ["./public/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "src/env.mjs"], "exclude": ["node_modules"] } diff --git a/yarn.lock b/yarn.lock index 6ed21193..f2c71041 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3555,6 +3555,18 @@ "@swc/counter" "^0.1.3" tslib "^2.4.0" +"@t3-oss/env-core@0.12.0", "@t3-oss/env-core@^0.12.0": + version "0.12.0" + resolved "https://registry.npmjs.org/@t3-oss/env-core/-/env-core-0.12.0.tgz#d5b6d92bf07d2f3ccdf59cc428f1faf114350d35" + integrity sha512-lOPj8d9nJJTt81mMuN9GMk8x5veOt7q9m11OSnCBJhwp1QrL/qR+M8Y467ULBSm9SunosryWNbmQQbgoiMgcdw== + +"@t3-oss/env-nextjs@^0.12.0": + version "0.12.0" + resolved "https://registry.npmjs.org/@t3-oss/env-nextjs/-/env-nextjs-0.12.0.tgz#a3a89c5d2eca35c96e8ffd5fe97922873a39c7a0" + integrity sha512-rFnvYk1049RnNVUPvY8iQ55AuQh1Rr+qZzQBh3t++RttCGK4COpXGNxS4+45afuQq02lu+QAOy/5955aU8hRKw== + dependencies: + "@t3-oss/env-core" "0.12.0" + "@tanstack/query-core@5.59.0": version "5.59.0" resolved "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.0.tgz" @@ -10474,10 +10486,10 @@ yocto-queue@^0.1.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^3.23.8: - version "3.23.8" - resolved "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz" - integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== +zod@^3.24.2: + version "3.24.2" + resolved "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3" + integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ== zwitch@^2.0.4: version "2.0.4"