diff --git a/.env.example b/.env.example
deleted file mode 100644
index c5daeb2..0000000
--- a/.env.example
+++ /dev/null
@@ -1,29 +0,0 @@
-LITEFS_DIR="/litefs/data"
-DATABASE_PATH="./prisma/data.db"
-DATABASE_URL="file:./data.db?connection_limit=1"
-CACHE_DATABASE_PATH="./other/cache.db"
-SESSION_SECRET="super-duper-s3cret"
-HONEYPOT_SECRET="super-duper-s3cret"
-RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh"
-SENTRY_DSN="your-dsn"
-
-# this is set to a random value in the Dockerfile
-INTERNAL_COMMAND_TOKEN="some-made-up-token"
-
-# the mocks and some code rely on these two being prefixed with "MOCK_"
-# if they aren't then the real github api will be attempted
-GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID"
-GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET"
-GITHUB_TOKEN="MOCK_GITHUB_TOKEN"
-GITHUB_REDIRECT_URI="https://example.com/auth/github/callback"
-
-# set this to false to prevent search engines from indexing the website
-# default to allow indexing for seo safety
-ALLOW_INDEXING="true"
-
-# Tigris Object Storage (S3-compatible) Configuration
-AWS_ACCESS_KEY_ID="mock-access-key"
-AWS_SECRET_ACCESS_KEY="mock-secret-key"
-AWS_REGION="auto"
-AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev"
-BUCKET_NAME="mock-bucket"
diff --git a/.env.schema b/.env.schema
new file mode 100644
index 0000000..5f242dc
--- /dev/null
+++ b/.env.schema
@@ -0,0 +1,102 @@
+# This env file uses @env-spec - see https://varlock.dev/env-spec for more info
+#
+# @defaultRequired=true @defaultSensitive=false
+# @currentEnv=$NODE_ENV
+# @generateTypes(lang=ts, path=types/env-vars.d.ts)
+# ----------
+
+# @type=enum(development, production, test)
+NODE_ENV=development
+# @type=enum(development, production, test)
+MODE=$NODE_ENV
+
+# @type=port
+PORT=3000
+
+LITEFS_DIR="/litefs/data"
+DATABASE_PATH="./prisma/data.db"
+DATABASE_URL="file:./data.db?connection_limit=1"
+CACHE_DATABASE_PATH="./other/cache.db"
+
+# used to secure sessions
+# @sensitive
+# @docs(https://stack-staging.epicweb.dev/topic/deployment)
+SESSION_SECRET="super-duper-s3cret"
+
+# encryption seed for honeypot server
+# @sensitive
+# @docs(https://stack-staging.epicweb.dev/topic/deployment)
+HONEYPOT_SECRET="super-duper-s3cret"
+
+# this is set to a random value in the Dockerfile
+# @sensitive
+INTERNAL_COMMAND_TOKEN="some-made-up-token"
+
+# set to false to prevent search engines from indexing the website (defaults to allow)
+ALLOW_INDEXING=true
+
+# enables mocks for external services
+MOCKS=forEnv(development, test)
+
+# will be set to curent commit sha in deployments
+# @optional
+COMMIT_SHA=
+
+# API key for Resend (email service)
+# @type=string(startsWith=re_)
+# @sensitive
+# @optional # remove this if using resend
+# @docs(https://resend.com/docs/dashboard/api-keys/introduction#what-is-an-api-key)
+RESEND_API_KEY=
+
+# will be set to true when running in CI
+CI=false
+
+# Sentry settings (error tracking)
+# note that SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT are optional
+# but enable @sentry/react-router integration and release tagging
+# ---
+# @type=url
+# @optional # remove this if using sentry
+# @example=https://examplePublicKey@o0.ingest.sentry.io/0
+# @docs(https://docs.sentry.io/concepts/key-terms/dsn-explainer/)
+SENTRY_DSN=
+# @optional @sensitive
+SENTRY_AUTH_TOKEN=
+# @required=if($SENTRY_AUTH_TOKEN)
+SENTRY_ORG=
+# @required=if($SENTRY_AUTH_TOKEN)
+SENTRY_PROJECT=
+
+# GitHub settings
+#
+# the mocks and some code rely on these being prefixed with "MOCK_"
+# if they aren't then the real github api will be attempted
+# ---
+GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID"
+# @sensitive
+GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET"
+# @sensitive
+GITHUB_TOKEN="MOCK_GITHUB_TOKEN"
+# @type=url
+GITHUB_REDIRECT_URI="https://example.com/auth/github/callback"
+
+
+# Tigris Object Storage (S3-compatible) Configuration
+# ---
+AWS_ACCESS_KEY_ID="mock-access-key"
+# @sensitive
+AWS_SECRET_ACCESS_KEY="mock-secret-key"
+AWS_REGION="auto"
+# @type=url
+AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev"
+BUCKET_NAME="mock-bucket"
+
+# Populated by fly.io
+# ---
+# current fly.io region
+# @optional
+FLY_REGION=
+# app name as set in fly.io
+# @optional
+FLY_APP_NAME=
\ No newline at end of file
diff --git a/.env.test b/.env.test
new file mode 100644
index 0000000..6b5776f
--- /dev/null
+++ b/.env.test
@@ -0,0 +1,2 @@
+DATABASE_PATH="./tests/prisma/base.db"
+DATABASE_URL=file:../tests/prisma/base.db
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 49cbbad..1c7d48b 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -30,9 +30,6 @@ jobs:
- name: 📥 Download deps
uses: bahmutov/npm-install@v1
- - name: 🏄 Copy test env vars
- run: cp .env.example .env
-
- name: 🛠 Setup Database
run: npx prisma migrate deploy && npx prisma generate --sql
@@ -57,9 +54,6 @@ jobs:
- name: 🏗 Build
run: npm run build
- - name: 🏄 Copy test env vars
- run: cp .env.example .env
-
- name: 🛠 Setup Database
run: npx prisma migrate deploy && npx prisma generate --sql
@@ -81,9 +75,6 @@ jobs:
- name: 📥 Download deps
uses: bahmutov/npm-install@v1
- - name: 🏄 Copy test env vars
- run: cp .env.example .env
-
- name: 🛠 Setup Database
run: npx prisma migrate deploy && npx prisma generate --sql
@@ -98,9 +89,6 @@ jobs:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
- - name: 🏄 Copy test env vars
- run: cp .env.example .env
-
- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
diff --git a/.gitignore b/.gitignore
index 2345034..ef74da5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,8 @@ node_modules
/build
/server-build
.env
+.env.local
+.env.*.local
.cache
/prisma/data.db
@@ -26,3 +28,4 @@ node_modules
# generated files
/app/components/ui/icons
.react-router/
+/types/env-vars.d.ts
diff --git a/.prettierignore b/.prettierignore
index f022d02..81afeda 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,6 +4,7 @@ node_modules
/public/build
/server-build
.env
+.env.*
/test-results/
/playwright-report/
@@ -11,5 +12,6 @@ node_modules
/tests/fixtures/email/*.json
/coverage
/prisma/migrations
+/types/env-vars.d.ts
package-lock.json
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 3c0a690..7eb65d6 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -6,6 +6,7 @@
"prisma.prisma",
"qwtel.sqlite-viewer",
"yoavbls.pretty-ts-errors",
- "github.vscode-github-actions"
+ "github.vscode-github-actions",
+ "varlock.env-spec-language"
]
}
diff --git a/README.md b/README.md
index b9d163e..432bd75 100644
--- a/README.md
+++ b/README.md
@@ -1,54 +1,35 @@
-
-
-
- Ditch analysis paralysis and start shipping Epic Web apps.
-
-
- This is an opinionated project starter and reference that allows teams to
- ship their ideas to production faster and on a more stable foundation based
- on the experience of Kent C. Dodds and
- contributors.
-
-
+# Epic Stack + Varlock
-```sh
-npx epicli
-```
+An example repo using [Varlock](https://varlock.dev/) within the Epic Stack to help manage configuration and secrets.
-[](https://www.epicweb.dev/epic-stack)
+With Varlock, we convert the `.env.example` file into a `.env.schema` which contains additional schema information about all configuration in the system. This will improve developer onboarding into the epic stack, as well as ongoing DX as devs add more config into their apps. It adds additional guardrails around configuration in general, and notably adds additional protection for sensitive secrets.
-[The Epic Stack](https://www.epicweb.dev/epic-stack)
+## Why do this?
+- validations, default values, and documentation are all now in one source of truth (`.env.schema`)
+- no more duplication between `.env.example` and `.env`, which means it will never get out of sync
+- only overrides must be added by user
+- clear env validation, decoupled from the application booting
+- improved TS types / IntelliSense
+- allows more flexible validation and composition of values based on other items
+- easy to now pull secrets from secure backends like 1pass, etc
+- leak prevention! log redaction!
+- clear error messages when accessing bad env vars, or using them in wrong place
-
+## Screenshots
-## Watch Kent's Introduction to The Epic Stack
+Some screenshots of varlock in action:
-[](https://www.epicweb.dev/talks/the-epic-stack)
+_`varlock load` showing loaded and validated env_
+
-["The Epic Stack" by Kent C. Dodds](https://www.epicweb.dev/talks/the-epic-stack)
+_Improved IntelliSense_
+
-## Docs
+_Leak detection example_
+
-[Read the docs](https://github.com/epicweb-dev/epic-stack/blob/main/docs)
-(please 🙏).
+_Log redaction example_
+
-## Support
-
-- 🆘 Join the
- [discussion on GitHub](https://github.com/epicweb-dev/epic-stack/discussions)
- and the [KCD Community on Discord](https://kcd.im/discord).
-- 💡 Create an
- [idea discussion](https://github.com/epicweb-dev/epic-stack/discussions/new?category=ideas)
- for suggestions.
-- 🐛 Open a [GitHub issue](https://github.com/epicweb-dev/epic-stack/issues) to
- report a bug.
-
-## Branding
-
-Want to talk about the Epic Stack in a blog post or talk? Great! Here are some
-assets you can use in your material:
-[EpicWeb.dev/brand](https://epicweb.dev/brand)
-
-## Thanks
-
-You rock 🪨
+_Example of failing env validation_
+
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
index 9b7749f..6998451 100644
--- a/app/entry.client.tsx
+++ b/app/entry.client.tsx
@@ -1,6 +1,7 @@
import { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { HydratedRouter } from 'react-router/dom'
+import { ENV } from 'varlock/env'
if (ENV.MODE === 'production' && ENV.SENTRY_DSN) {
void import('./utils/monitoring.client.tsx').then(({ init }) => init())
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
index 99fdd4b..f1c7cbd 100644
--- a/app/entry.server.tsx
+++ b/app/entry.server.tsx
@@ -12,17 +12,13 @@ import {
type ActionFunctionArgs,
type HandleDocumentRequestFunction,
} from 'react-router'
-import { getEnv, init } from './utils/env.server.ts'
+import { ENV } from 'varlock/env'
import { getInstanceInfo } from './utils/litefs.server.ts'
import { NonceProvider } from './utils/nonce-provider.ts'
import { makeTimings } from './utils/timing.server.ts'
export const streamTimeout = 5000
-init()
-global.ENV = getEnv()
-
-const MODE = process.env.NODE_ENV ?? 'development'
type DocRequestArgs = Parameters
@@ -30,12 +26,12 @@ export default async function handleRequest(...args: DocRequestArgs) {
const [request, responseStatusCode, responseHeaders, reactRouterContext] =
args
const { currentInstance, primaryInstance } = await getInstanceInfo()
- responseHeaders.set('fly-region', process.env.FLY_REGION ?? 'unknown')
- responseHeaders.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown')
+ responseHeaders.set('fly-region', ENV.FLY_REGION ?? 'unknown')
+ responseHeaders.set('fly-app', ENV.FLY_APP_NAME ?? 'unknown')
responseHeaders.set('fly-primary-instance', primaryInstance)
responseHeaders.set('fly-instance', currentInstance)
- if (process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) {
+ if (ENV.NODE_ENV === 'production' && ENV.SENTRY_DSN) {
responseHeaders.append('Document-Policy', 'js-profiling')
}
@@ -72,8 +68,8 @@ export default async function handleRequest(...args: DocRequestArgs) {
directives: {
fetch: {
'connect-src': [
- MODE === 'development' ? 'ws:' : undefined,
- process.env.SENTRY_DSN ? '*.sentry.io' : undefined,
+ ENV.MODE === 'development' ? 'ws:' : undefined,
+ ENV.SENTRY_DSN ? '*.sentry.io' : undefined,
"'self'",
],
'font-src': ["'self'"],
@@ -114,8 +110,8 @@ export default async function handleRequest(...args: DocRequestArgs) {
export async function handleDataRequest(response: Response) {
const { currentInstance, primaryInstance } = await getInstanceInfo()
- response.headers.set('fly-region', process.env.FLY_REGION ?? 'unknown')
- response.headers.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown')
+ response.headers.set('fly-region', ENV.FLY_REGION ?? 'unknown')
+ response.headers.set('fly-app', ENV.FLY_APP_NAME ?? 'unknown')
response.headers.set('fly-primary-instance', primaryInstance)
response.headers.set('fly-instance', currentInstance)
diff --git a/app/root.tsx b/app/root.tsx
index 3b19435..93038c1 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -11,6 +11,7 @@ import {
useMatches,
} from 'react-router'
import { HoneypotProvider } from 'remix-utils/honeypot/react'
+import { ENV } from 'varlock/env'
import { type Route } from './+types/root.ts'
import appleTouchIconAssetUrl from './assets/favicons/apple-touch-icon.png'
import faviconAssetUrl from './assets/favicons/favicon.svg'
@@ -31,7 +32,6 @@ import tailwindStyleSheetUrl from './styles/tailwind.css?url'
import { getUserId, logout } from './utils/auth.server.ts'
import { ClientHintCheck, getHints } from './utils/client-hints.tsx'
import { prisma } from './utils/db.server.ts'
-import { getEnv } from './utils/env.server.ts'
import { pipeHeaders } from './utils/headers.server.ts'
import { honeypot } from './utils/honeypot.server.ts'
import { combineHeaders, getDomainUrl, getImgSrc } from './utils/misc.tsx'
@@ -119,7 +119,6 @@ export async function loader({ request }: Route.LoaderArgs) {
theme: getTheme(request),
},
},
- ENV: getEnv(),
toast,
honeyProps,
},
@@ -138,14 +137,11 @@ function Document({
children,
nonce,
theme = 'light',
- env = {},
}: {
children: React.ReactNode
nonce: string
theme?: Theme
- env?: Record
}) {
- const allowIndexing = ENV.ALLOW_INDEXING !== 'false'
return (
@@ -153,19 +149,13 @@ function Document({
- {allowIndexing ? null : (
+ {ENV.ALLOW_INDEXING ? null : (
)}
{children}
-
@@ -174,12 +164,10 @@ function Document({
}
export function Layout({ children }: { children: React.ReactNode }) {
- // if there was an error running the loader, data could be missing
- const data = useLoaderData()
const nonce = useNonce()
const theme = useOptionalTheme()
return (
-
+
{children}
)
diff --git a/app/routes/_auth/auth.$provider/callback.test.ts b/app/routes/_auth/auth.$provider/callback.test.ts
index 10346eb..c07b334 100644
--- a/app/routes/_auth/auth.$provider/callback.test.ts
+++ b/app/routes/_auth/auth.$provider/callback.test.ts
@@ -2,6 +2,7 @@ import { invariant } from '@epic-web/invariant'
import { faker } from '@faker-js/faker'
import { SetCookie } from '@mjackson/headers'
import { http } from 'msw'
+import { ENV } from 'varlock/env'
import { afterEach, expect, test } from 'vitest'
import { twoFAVerificationType } from '#app/routes/settings/profile/two-factor/_layout.tsx'
import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts'
@@ -231,7 +232,7 @@ async function setupRequest({
sameSite: 'Lax',
httpOnly: true,
maxAge: 60 * 10,
- secure: process.env.NODE_ENV === 'production' || undefined,
+ secure: ENV.NODE_ENV === 'production' || undefined,
})
const request = new Request(url.toString(), {
method: 'GET',
diff --git a/app/routes/_auth/webauthn/utils.server.ts b/app/routes/_auth/webauthn/utils.server.ts
index aaaf5fc..cebe699 100644
--- a/app/routes/_auth/webauthn/utils.server.ts
+++ b/app/routes/_auth/webauthn/utils.server.ts
@@ -3,6 +3,7 @@ import {
type RegistrationResponseJSON,
} from '@simplewebauthn/server'
import { createCookie } from 'react-router'
+import { ENV } from 'varlock/env'
import { z } from 'zod'
import { getDomainUrl } from '#app/utils/misc.tsx'
@@ -11,8 +12,8 @@ export const passkeyCookie = createCookie('webauthn-challenge', {
sameSite: 'lax',
httpOnly: true,
maxAge: 60 * 60 * 2,
- secure: process.env.NODE_ENV === 'production',
- secrets: [process.env.SESSION_SECRET],
+ secure: ENV.NODE_ENV === 'production',
+ secrets: [ENV.SESSION_SECRET],
})
export const PasskeyCookieSchema = z.object({
diff --git a/app/routes/admin/cache/sqlite.server.ts b/app/routes/admin/cache/sqlite.server.ts
index 721ef8d..14a85bf 100644
--- a/app/routes/admin/cache/sqlite.server.ts
+++ b/app/routes/admin/cache/sqlite.server.ts
@@ -1,4 +1,5 @@
import { redirect } from 'react-router'
+import { ENV } from 'varlock/env'
import { z } from 'zod'
import { cache } from '#app/utils/cache.server.ts'
import {
@@ -21,7 +22,7 @@ export async function updatePrimaryCacheValue({
)
}
const domain = getInternalInstanceDomain(primaryInstance)
- const token = process.env.INTERNAL_COMMAND_TOKEN
+ const token = ENV.INTERNAL_COMMAND_TOKEN
return fetch(`${domain}/admin/cache/sqlite`, {
method: 'POST',
headers: {
@@ -39,7 +40,7 @@ export async function action({ request }: Route.ActionArgs) {
`${request.url} should only be called on the primary instance (${primaryInstance})}`,
)
}
- const token = process.env.INTERNAL_COMMAND_TOKEN
+ const token = ENV.INTERNAL_COMMAND_TOKEN
const isAuthorized =
request.headers.get('Authorization') === `Bearer ${token}`
if (!isAuthorized) {
diff --git a/app/routes/resources/images.tsx b/app/routes/resources/images.tsx
index 766e925..ac0cc7a 100644
--- a/app/routes/resources/images.tsx
+++ b/app/routes/resources/images.tsx
@@ -1,6 +1,7 @@
import { promises as fs, constants } from 'node:fs'
import { invariantResponse } from '@epic-web/invariant'
import { getImgResponse } from 'openimg/node'
+import { ENV } from 'varlock/env'
import { getDomainUrl } from '#app/utils/misc.tsx'
import { getSignedGetRequestInfo } from '#app/utils/storage.server.ts'
import { type Route } from './+types/images'
@@ -11,7 +12,7 @@ async function getCacheDir() {
if (cacheDir) return cacheDir
let dir = './tests/fixtures/openimg'
- if (process.env.NODE_ENV === 'production') {
+ if (ENV.NODE_ENV === 'production') {
const isAccessible = await fs
.access('/data', constants.W_OK)
.then(() => true)
@@ -38,7 +39,7 @@ export async function loader({ request }: Route.LoaderArgs) {
headers,
allowlistedOrigins: [
getDomainUrl(request),
- process.env.AWS_ENDPOINT_URL_S3,
+ ENV.AWS_ENDPOINT_URL_S3,
].filter(Boolean),
cacheFolder: await getCacheDir(),
getImgSource: () => {
diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts
index 6e26fb9..85e2246 100644
--- a/app/utils/cache.server.ts
+++ b/app/utils/cache.server.ts
@@ -14,12 +14,13 @@ import {
} from '@epic-web/cachified'
import { remember } from '@epic-web/remember'
import { LRUCache } from 'lru-cache'
+import { ENV } from 'varlock/env'
import { z } from 'zod'
import { updatePrimaryCacheValue } from '#app/routes/admin/cache/sqlite.server.ts'
import { getInstanceInfo, getInstanceInfoSync } from './litefs.server.ts'
import { cachifiedTimingReporter, type Timings } from './timing.server.ts'
-const CACHE_DATABASE_PATH = process.env.CACHE_DATABASE_PATH
+const CACHE_DATABASE_PATH = ENV.CACHE_DATABASE_PATH
const cacheDb = remember('cacheDb', createDatabase)
diff --git a/app/utils/email.server.ts b/app/utils/email.server.ts
index 5ca9264..fdf1188 100644
--- a/app/utils/email.server.ts
+++ b/app/utils/email.server.ts
@@ -1,5 +1,6 @@
import { render } from '@react-email/components'
import { type ReactElement } from 'react'
+import { ENV } from 'varlock/env'
import { z } from 'zod'
const resendErrorSchema = z.union([
@@ -40,7 +41,7 @@ export async function sendEmail({
}
// feel free to remove this condition once you've set up resend
- if (!process.env.RESEND_API_KEY && !process.env.MOCKS) {
+ if (!ENV.RESEND_API_KEY && !ENV.MOCKS) {
console.error(`RESEND_API_KEY not set and we're not in mocks mode.`)
console.error(
`To send emails, set the RESEND_API_KEY environment variable.`,
@@ -56,7 +57,7 @@ export async function sendEmail({
method: 'POST',
body: JSON.stringify(email),
headers: {
- Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
+ Authorization: `Bearer ${ENV.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
})
diff --git a/app/utils/env.server.ts b/app/utils/env.server.ts
deleted file mode 100644
index e762b1e..0000000
--- a/app/utils/env.server.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { z } from 'zod'
-
-const schema = z.object({
- NODE_ENV: z.enum(['production', 'development', 'test'] as const),
- DATABASE_PATH: z.string(),
- DATABASE_URL: z.string(),
- SESSION_SECRET: z.string(),
- INTERNAL_COMMAND_TOKEN: z.string(),
- HONEYPOT_SECRET: z.string(),
- CACHE_DATABASE_PATH: z.string(),
- // If you plan on using Sentry, remove the .optional()
- SENTRY_DSN: z.string().optional(),
- // If you plan to use Resend, remove the .optional()
- RESEND_API_KEY: z.string().optional(),
- // If you plan to use GitHub auth, remove the .optional()
- GITHUB_CLIENT_ID: z.string().optional(),
- GITHUB_CLIENT_SECRET: z.string().optional(),
- GITHUB_REDIRECT_URI: z.string().optional(),
- GITHUB_TOKEN: z.string().optional(),
-
- ALLOW_INDEXING: z.enum(['true', 'false']).optional(),
-
- // Tigris Object Storage Configuration
- AWS_ACCESS_KEY_ID: z.string(),
- AWS_SECRET_ACCESS_KEY: z.string(),
- AWS_REGION: z.string(),
- AWS_ENDPOINT_URL_S3: z.string().url(),
- BUCKET_NAME: z.string(),
-})
-
-declare global {
- namespace NodeJS {
- interface ProcessEnv extends z.infer {}
- }
-}
-
-export function init() {
- const parsed = schema.safeParse(process.env)
-
- if (parsed.success === false) {
- console.error(
- '❌ Invalid environment variables:',
- parsed.error.flatten().fieldErrors,
- )
-
- throw new Error('Invalid environment variables')
- }
-}
-
-/**
- * This is used in both `entry.server.ts` and `root.tsx` to ensure that
- * the environment variables are set and globally available before the app is
- * started.
- *
- * NOTE: Do *not* add any environment variables in here that you do not wish to
- * be included in the client.
- * @returns all public ENV variables
- */
-export function getEnv() {
- return {
- MODE: process.env.NODE_ENV,
- SENTRY_DSN: process.env.SENTRY_DSN,
- ALLOW_INDEXING: process.env.ALLOW_INDEXING,
- }
-}
-
-type ENV = ReturnType
-
-declare global {
- var ENV: ENV
- interface Window {
- ENV: ENV
- }
-}
diff --git a/app/utils/honeypot.server.ts b/app/utils/honeypot.server.ts
index 55b18a5..d1d0ded 100644
--- a/app/utils/honeypot.server.ts
+++ b/app/utils/honeypot.server.ts
@@ -1,8 +1,9 @@
import { Honeypot, SpamError } from 'remix-utils/honeypot/server'
+import { ENV } from 'varlock/env'
export const honeypot = new Honeypot({
- validFromFieldName: process.env.NODE_ENV === 'test' ? null : undefined,
- encryptionSeed: process.env.HONEYPOT_SECRET,
+ validFromFieldName: ENV.NODE_ENV === 'test' ? null : undefined,
+ encryptionSeed: ENV.HONEYPOT_SECRET,
})
export async function checkHoneypot(formData: FormData) {
diff --git a/app/utils/monitoring.client.tsx b/app/utils/monitoring.client.tsx
index f559b6c..fdb99b1 100644
--- a/app/utils/monitoring.client.tsx
+++ b/app/utils/monitoring.client.tsx
@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/react-router'
+import { ENV } from 'varlock/env'
export function init() {
Sentry.init({
diff --git a/app/utils/providers/github.server.ts b/app/utils/providers/github.server.ts
index 1d2cf38..25015b8 100644
--- a/app/utils/providers/github.server.ts
+++ b/app/utils/providers/github.server.ts
@@ -2,6 +2,7 @@ import { SetCookie } from '@mjackson/headers'
import { createId as cuid } from '@paralleldrive/cuid2'
import { redirect } from 'react-router'
import { GitHubStrategy } from 'remix-auth-github'
+import { ENV } from 'varlock/env'
import { z } from 'zod'
import { cache, cachified } from '../cache.server.ts'
import { type Timings } from '../timing.server.ts'
@@ -21,8 +22,8 @@ const GitHubUserParseResult = z
)
const shouldMock =
- process.env.GITHUB_CLIENT_ID?.startsWith('MOCK_') ||
- process.env.NODE_ENV === 'test'
+ ENV.GITHUB_CLIENT_ID?.startsWith('MOCK_') ||
+ ENV.MODE === 'test'
const GitHubEmailSchema = z.object({
email: z.string(),
@@ -43,9 +44,9 @@ const GitHubUserResponseSchema = z.object({
export class GitHubProvider implements AuthProvider {
getAuthStrategy() {
if (
- !process.env.GITHUB_CLIENT_ID ||
- !process.env.GITHUB_CLIENT_SECRET ||
- !process.env.GITHUB_REDIRECT_URI
+ !ENV.GITHUB_CLIENT_ID ||
+ !ENV.GITHUB_CLIENT_SECRET ||
+ !ENV.GITHUB_REDIRECT_URI
) {
console.log(
'GitHub OAuth strategy not available because environment variables are not set',
@@ -54,9 +55,9 @@ export class GitHubProvider implements AuthProvider {
}
return new GitHubStrategy(
{
- clientId: process.env.GITHUB_CLIENT_ID,
- clientSecret: process.env.GITHUB_CLIENT_SECRET,
- redirectURI: process.env.GITHUB_REDIRECT_URI,
+ clientId: ENV.GITHUB_CLIENT_ID,
+ clientSecret: ENV.GITHUB_CLIENT_SECRET,
+ redirectURI: ENV.GITHUB_REDIRECT_URI,
},
async ({ tokens }) => {
// we need to fetch the user and the emails separately, this is a change in remix-auth-github
@@ -112,7 +113,7 @@ export class GitHubProvider implements AuthProvider {
async getFreshValue(context) {
const response = await fetch(
`https://api.github.com/user/${providerId}`,
- { headers: { Authorization: `token ${process.env.GITHUB_TOKEN}` } },
+ { headers: { Authorization: `token ${ENV.GITHUB_TOKEN}` } },
)
const rawJson = await response.json()
const result = GitHubUserSchema.safeParse(rawJson)
@@ -147,7 +148,7 @@ export class GitHubProvider implements AuthProvider {
sameSite: 'Lax',
httpOnly: true,
maxAge: 60 * 10,
- secure: process.env.NODE_ENV === 'production' || undefined,
+ secure: ENV.MODE === 'production' || undefined,
})
throw redirect(`/auth/github/callback?${searchParams}`, {
headers: {
diff --git a/app/utils/session.server.ts b/app/utils/session.server.ts
index 5d9fd32..4160c02 100644
--- a/app/utils/session.server.ts
+++ b/app/utils/session.server.ts
@@ -1,4 +1,5 @@
import { createCookieSessionStorage } from 'react-router'
+import { ENV } from 'varlock/env'
export const authSessionStorage = createCookieSessionStorage({
cookie: {
@@ -6,8 +7,8 @@ export const authSessionStorage = createCookieSessionStorage({
sameSite: 'lax', // CSRF protection is advised if changing to 'none'
path: '/',
httpOnly: true,
- secrets: process.env.SESSION_SECRET.split(','),
- secure: process.env.NODE_ENV === 'production',
+ secrets: [ENV.SESSION_SECRET],
+ secure: ENV.NODE_ENV === 'production',
},
})
diff --git a/app/utils/storage.server.ts b/app/utils/storage.server.ts
index 2fd7360..cf4160e 100644
--- a/app/utils/storage.server.ts
+++ b/app/utils/storage.server.ts
@@ -1,12 +1,7 @@
import { createHash, createHmac } from 'crypto'
import { type FileUpload } from '@mjackson/form-data-parser'
import { createId } from '@paralleldrive/cuid2'
-
-const STORAGE_ENDPOINT = process.env.AWS_ENDPOINT_URL_S3
-const STORAGE_BUCKET = process.env.BUCKET_NAME
-const STORAGE_ACCESS_KEY = process.env.AWS_ACCESS_KEY_ID
-const STORAGE_SECRET_KEY = process.env.AWS_SECRET_ACCESS_KEY
-const STORAGE_REGION = process.env.AWS_REGION
+import { ENV } from 'varlock/env'
async function uploadToStorage(file: File | FileUpload, key: string) {
const { url, headers } = getSignedPutRequestInfo(file, key)
@@ -85,7 +80,7 @@ function getBaseSignedRequestInfo({
contentType?: string
uploadDate?: string
}) {
- const url = `${STORAGE_ENDPOINT}/${STORAGE_BUCKET}/${key}`
+ const url = `${ENV.AWS_ENDPOINT_URL_S3}/${ENV.BUCKET_NAME}/${key}`
const endpoint = new URL(url)
// Prepare date strings
@@ -106,7 +101,7 @@ function getBaseSignedRequestInfo({
const canonicalRequest = [
method,
- `/${STORAGE_BUCKET}/${key}`,
+ `/${ENV.BUCKET_NAME}/${key}`,
'', // canonicalQueryString
canonicalHeaders,
signedHeaders,
@@ -115,7 +110,7 @@ function getBaseSignedRequestInfo({
// Prepare string to sign
const algorithm = 'AWS4-HMAC-SHA256'
- const credentialScope = `${dateStamp}/${STORAGE_REGION}/s3/aws4_request`
+ const credentialScope = `${dateStamp}/${ENV.AWS_REGION}/s3/aws4_request`
const stringToSign = [
algorithm,
amzDate,
@@ -125,9 +120,9 @@ function getBaseSignedRequestInfo({
// Calculate signature
const signingKey = getSignatureKey(
- STORAGE_SECRET_KEY,
+ ENV.AWS_SECRET_ACCESS_KEY,
dateStamp,
- STORAGE_REGION,
+ ENV.AWS_REGION,
's3',
)
const signature = createHmac('sha256', signingKey)
@@ -138,7 +133,7 @@ function getBaseSignedRequestInfo({
'X-Amz-Date': amzDate,
'X-Amz-Content-SHA256': 'UNSIGNED-PAYLOAD',
Authorization: [
- `${algorithm} Credential=${STORAGE_ACCESS_KEY}/${credentialScope}`,
+ `${algorithm} Credential=${ENV.AWS_ACCESS_KEY_ID}/${credentialScope}`,
`SignedHeaders=${signedHeaders}`,
`Signature=${signature}`,
].join(', '),
diff --git a/app/utils/toast.server.ts b/app/utils/toast.server.ts
index b46fefa..899c2bd 100644
--- a/app/utils/toast.server.ts
+++ b/app/utils/toast.server.ts
@@ -1,5 +1,6 @@
import { createId as cuid } from '@paralleldrive/cuid2'
import { createCookieSessionStorage, redirect } from 'react-router'
+import { ENV } from 'varlock/env'
import { z } from 'zod'
import { combineHeaders } from './misc.tsx'
@@ -21,8 +22,8 @@ export const toastSessionStorage = createCookieSessionStorage({
sameSite: 'lax',
path: '/',
httpOnly: true,
- secrets: process.env.SESSION_SECRET.split(','),
- secure: process.env.NODE_ENV === 'production',
+ secrets: [ENV.SESSION_SECRET],
+ secure: ENV.NODE_ENV === 'production',
},
})
diff --git a/app/utils/verification.server.ts b/app/utils/verification.server.ts
index 1099f7c..856cf70 100644
--- a/app/utils/verification.server.ts
+++ b/app/utils/verification.server.ts
@@ -1,4 +1,5 @@
import { createCookieSessionStorage } from 'react-router'
+import { ENV } from 'varlock/env'
export const verifySessionStorage = createCookieSessionStorage({
cookie: {
@@ -7,7 +8,7 @@ export const verifySessionStorage = createCookieSessionStorage({
path: '/',
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
- secrets: process.env.SESSION_SECRET.split(','),
- secure: process.env.NODE_ENV === 'production',
+ secrets: [ENV.SESSION_SECRET],
+ secure: ENV.NODE_ENV === 'production',
},
})
diff --git a/eslint.config.js b/eslint.config.js
index eede0cf..df24093 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -9,6 +9,9 @@ export default [
rules: { 'react-hooks/rules-of-hooks': 'off' },
},
{
- ignores: ['.react-router/*'],
+ ignores: [
+ '.react-router/*',
+ 'types/env-vars.d.ts'
+ ],
},
]
diff --git a/index.js b/index.js
index 082cd60..2f51b6d 100644
--- a/index.js
+++ b/index.js
@@ -1,6 +1,7 @@
-import 'dotenv/config'
+import 'varlock/auto-load'
import * as fs from 'node:fs'
import sourceMapSupport from 'source-map-support'
+import { ENV } from 'varlock/env'
sourceMapSupport.install({
retrieveSourceMap: function (source) {
@@ -16,11 +17,11 @@ sourceMapSupport.install({
},
})
-if (process.env.MOCKS === 'true') {
+if (ENV.MOCKS) {
await import('./tests/mocks/index.ts')
}
-if (process.env.NODE_ENV === 'production') {
+if (ENV.MODE === 'production') {
await import('./server-build/index.js')
} else {
await import('./server/index.ts')
diff --git a/other/Dockerfile b/other/Dockerfile
index a801036..4fbdc04 100644
--- a/other/Dockerfile
+++ b/other/Dockerfile
@@ -74,7 +74,7 @@ RUN echo "#!/bin/sh\nset -x\nsqlite3 \$CACHE_DATABASE_URL" > /usr/local/bin/cach
WORKDIR /myapp
-# Generate random value and save it to .env file which will be loaded by dotenv
+# Generate random value and save it to .env file which will be loaded by varlock
RUN INTERNAL_COMMAND_TOKEN=$(openssl rand -hex 32) && \
echo "INTERNAL_COMMAND_TOKEN=$INTERNAL_COMMAND_TOKEN" > .env
diff --git a/package-lock.json b/package-lock.json
index af07aec..51dd9e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,10 @@
{
- "name": "epic-stack-with-varlock",
+ "name": "epic-stack-with-varlock-a3b4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "epic-stack-with-varlock",
- "license": "MIT",
+ "name": "epic-stack-with-varlock-a3b4",
"dependencies": {
"@conform-to/react": "^1.5.0",
"@conform-to/zod": "^1.5.0",
@@ -39,6 +38,7 @@
"@simplewebauthn/server": "^13.1.1",
"@tailwindcss/vite": "^4.1.5",
"@tusbar/cache-control": "1.0.2",
+ "@varlock/vite-integration": "^0.1.0",
"address": "^2.0.3",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
@@ -48,7 +48,6 @@
"cookie": "^1.0.2",
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
- "dotenv": "^16.5.0",
"execa": "^9.5.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
@@ -77,6 +76,7 @@
"spin-delay": "^2.0.1",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",
+ "varlock": "^0.1.2",
"vite-env-only": "^3.0.3",
"zod": "^3.24.4"
},
@@ -1117,6 +1117,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@env-spec/parser": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/@env-spec/parser/-/parser-0.0.7.tgz",
+ "integrity": "sha512-PK4jVwAZA+5aa3PzVF/We48wA9r1S6GRoZH6oSf/zWvNLc9LErynLbnEmmfLBo67KVqRvaur2a7aBj3H1Mpj2w==",
+ "license": "MIT"
+ },
"node_modules/@epic-web/cachified": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/@epic-web/cachified/-/cachified-5.5.2.tgz",
@@ -7396,6 +7402,22 @@
"win32"
]
},
+ "node_modules/@varlock/vite-integration": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@varlock/vite-integration/-/vite-integration-0.1.0.tgz",
+ "integrity": "sha512-WXFXDpyV2aUtylxZnVxWRe7pvve1LQbS0BD73Gdio/7D24VK0EVpRXFt6RSeKyzxwLwzvumKVwV6VVIgnkKCjA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "varlock": "^0.1.0",
+ "vite": ">=5"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz",
@@ -9406,9 +9428,9 @@
}
},
"node_modules/debug": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
- "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -16281,9 +16303,9 @@
}
},
"node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -17949,6 +17971,49 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/varlock": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/varlock/-/varlock-0.1.2.tgz",
+ "integrity": "sha512-f3FuScvIIlcF0t6HZUxtSCwxuc96C2eDx/KYdIv648NdvNg+jCQc0l70782snSPT8Mc49J3GiCXh6aZcy/OIeg==",
+ "license": "MIT",
+ "dependencies": {
+ "@env-spec/parser": "^0.0.7",
+ "debug": "^4.4.3",
+ "execa": "^9.6.0",
+ "semver": "^7.7.3",
+ "which": "^5.0.0"
+ },
+ "bin": {
+ "varlock": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/varlock/node_modules/isexe": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+ "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/varlock/node_modules/which": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
+ "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^3.1.1"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
diff --git a/package.json b/package.json
index 6a1f4c8..18ca640 100644
--- a/package.json
+++ b/package.json
@@ -11,8 +11,8 @@
"build": "run-s build:*",
"build:remix": "react-router build",
"build:server": "tsx ./other/build-server.ts",
- "dev": "cross-env NODE_ENV=development MOCKS=true node ./server/dev-server.js",
- "dev:no-mocks": "cross-env NODE_ENV=development node ./server/dev-server.js",
+ "dev": "cross-env node ./server/dev-server.js",
+ "dev:no-mocks": "cross-env MOCKS=false node ./server/dev-server.js",
"format": "prettier --write .",
"lint": "eslint .",
"setup": "npm run build && prisma migrate deploy && prisma generate --sql && playwright install",
@@ -69,6 +69,7 @@
"@simplewebauthn/server": "^13.1.1",
"@tailwindcss/vite": "^4.1.5",
"@tusbar/cache-control": "1.0.2",
+ "@varlock/vite-integration": "^0.1.0",
"address": "^2.0.3",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
@@ -78,7 +79,6 @@
"cookie": "^1.0.2",
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
- "dotenv": "^16.5.0",
"execa": "^9.5.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
@@ -107,6 +107,7 @@
"spin-delay": "^2.0.1",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",
+ "varlock": "^0.1.2",
"vite-env-only": "^3.0.3",
"zod": "^3.24.4"
},
diff --git a/playwright.config.ts b/playwright.config.ts
index e5d832d..fed00fb 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,5 +1,5 @@
+import 'varlock/auto-load'
import { defineConfig, devices } from '@playwright/test'
-import 'dotenv/config'
const PORT = process.env.PORT || '3000'
diff --git a/prisma.config.ts b/prisma.config.ts
new file mode 100644
index 0000000..4fcbd13
--- /dev/null
+++ b/prisma.config.ts
@@ -0,0 +1,8 @@
+// This helps load DATABASE_URL into prisma
+
+import 'varlock/auto-load' // this loads DATABASE_URL
+import { defineConfig } from 'prisma/config'
+
+export default defineConfig({
+ earlyAccess: true, // TS type in this prisma version requires this, but should go away in future
+})
\ No newline at end of file
diff --git a/server/dev-server.js b/server/dev-server.js
index 0261319..b8618c7 100644
--- a/server/dev-server.js
+++ b/server/dev-server.js
@@ -1,5 +1,6 @@
import { execa } from 'execa'
+// using process.env because we have not loaded varlock yet
if (process.env.NODE_ENV === 'production') {
await import('../server-build/index.js')
} else {
diff --git a/server/index.ts b/server/index.ts
index 3dbe167..2290128 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -10,12 +10,11 @@ import rateLimit from 'express-rate-limit'
import getPort, { portNumbers } from 'get-port'
import morgan from 'morgan'
import { type ServerBuild } from 'react-router'
+import { ENV } from 'varlock/env'
-const MODE = process.env.NODE_ENV ?? 'development'
-const IS_PROD = MODE === 'production'
-const IS_DEV = MODE === 'development'
-const ALLOW_INDEXING = process.env.ALLOW_INDEXING !== 'false'
-const SENTRY_ENABLED = IS_PROD && process.env.SENTRY_DSN
+const IS_PROD = ENV.MODE === 'production'
+const IS_DEV = ENV.MODE === 'development'
+const SENTRY_ENABLED = IS_PROD && ENV.SENTRY_DSN
if (SENTRY_ENABLED) {
void import('./utils/monitoring.js').then(({ init }) => init())
@@ -28,7 +27,7 @@ const viteDevServer = IS_PROD
server: {
middlewareMode: true,
},
- // We tell Vite we are running a custom app instead of
+ // We tell Vite we are running a custom app instead of
// the SPA default so it doesn't run HTML middleware
appType: 'custom',
}),
@@ -84,11 +83,7 @@ if (viteDevServer) {
// Remix fingerprints its assets so we can cache forever.
app.use(
'/assets',
- express.static('build/client/assets', {
- immutable: true,
- maxAge: '1y',
- fallthrough: false,
- }),
+ express.static('build/client/assets', { immutable: true, maxAge: '1y', fallthrough: false }),
)
// Everything else (like favicon.ico) is cached for an hour. You may want to be
@@ -194,7 +189,7 @@ async function getBuild() {
}
}
-if (!ALLOW_INDEXING) {
+if (!ENV.ALLOW_INDEXING) {
app.use((_, res, next) => {
res.set('X-Robots-Tag', 'noindex, nofollow')
next()
@@ -205,7 +200,7 @@ app.all(
'*',
createRequestHandler({
getLoadContext: () => ({ serverBuild: getBuild() }),
- mode: MODE,
+ mode: ENV.MODE,
build: async () => {
const { error, build } = await getBuild()
// gracefully "catch" the error
@@ -217,7 +212,7 @@ app.all(
}),
)
-const desiredPort = Number(process.env.PORT || 3000)
+const desiredPort = ENV.PORT
const portToUse = await getPort({
port: portNumbers(desiredPort, desiredPort + 100),
})
diff --git a/server/utils/monitoring.ts b/server/utils/monitoring.ts
index 107521a..c3bd993 100644
--- a/server/utils/monitoring.ts
+++ b/server/utils/monitoring.ts
@@ -1,11 +1,12 @@
import { PrismaInstrumentation } from '@prisma/instrumentation'
import { nodeProfilingIntegration } from '@sentry/profiling-node'
import * as Sentry from '@sentry/react-router'
+import { ENV } from 'varlock/env'
export function init() {
Sentry.init({
- dsn: process.env.SENTRY_DSN,
- environment: process.env.NODE_ENV,
+ dsn: ENV.SENTRY_DSN,
+ environment: ENV.NODE_ENV,
denyUrls: [
/\/resources\/healthcheck/,
// TODO: be smarter about the public assets...
@@ -28,7 +29,7 @@ export function init() {
if (samplingContext.request?.url?.includes('/resources/healthcheck')) {
return 0
}
- return process.env.NODE_ENV === 'production' ? 1 : 0
+ return ENV.NODE_ENV === 'production' ? 1 : 0
},
beforeSendTransaction(event) {
// ignore all healthcheck related transactions
diff --git a/tests/e2e/notes.test.ts b/tests/e2e/notes.test.ts
new file mode 100644
index 0000000..44af6cf
--- /dev/null
+++ b/tests/e2e/notes.test.ts
@@ -0,0 +1,79 @@
+import { faker } from '@faker-js/faker'
+import { prisma } from '#app/utils/db.server.ts'
+import { expect, test } from '#tests/playwright-utils.ts'
+
+test('Users can create notes', async ({ page, navigate, login }) => {
+ const user = await login()
+ await navigate('/users/:username/notes', { username: user.username })
+
+ const newNote = createNote()
+ await page.getByRole('link', { name: /New Note/i }).click()
+
+ // fill in form and submit
+ await page.getByRole('textbox', { name: /title/i }).fill(newNote.title)
+ await page.getByRole('textbox', { name: /content/i }).fill(newNote.content)
+
+ await page.getByRole('button', { name: /submit/i }).click()
+ await expect(page).toHaveURL(new RegExp(`/users/${user.username}/notes/.*`))
+})
+
+test('Users can edit notes', async ({ page, navigate, login }) => {
+ const user = await login()
+
+ const note = await prisma.note.create({
+ select: { id: true },
+ data: { ...createNote(), ownerId: user.id },
+ })
+ await navigate('/users/:username/notes/:noteId', {
+ username: user.username,
+ noteId: note.id,
+ })
+
+ // edit the note
+ await page.getByRole('link', { name: 'Edit', exact: true }).click()
+ const updatedNote = createNote()
+ await page.getByRole('textbox', { name: /title/i }).fill(updatedNote.title)
+ await page
+ .getByRole('textbox', { name: /content/i })
+ .fill(updatedNote.content)
+ await page.getByRole('button', { name: /submit/i }).click()
+
+ await expect(page).toHaveURL(`/users/${user.username}/notes/${note.id}`)
+ await expect(
+ page.getByRole('heading', { name: updatedNote.title }),
+ ).toBeVisible()
+})
+
+test('Users can delete notes', async ({ page, navigate, login }) => {
+ const user = await login()
+
+ const note = await prisma.note.create({
+ select: { id: true },
+ data: { ...createNote(), ownerId: user.id },
+ })
+ await navigate('/users/:username/notes/:noteId', {
+ username: user.username,
+ noteId: note.id,
+ })
+
+ // find links with href prefix
+ const noteLinks = page
+ .getByRole('main')
+ .getByRole('list')
+ .getByRole('listitem')
+ .getByRole('link')
+ const countBefore = await noteLinks.count()
+ await page.getByRole('button', { name: /delete/i }).click()
+ await expect(
+ page.getByText('Your note has been deleted.', { exact: true }),
+ ).toBeVisible()
+ await expect(page).toHaveURL(`/users/${user.username}/notes`)
+ await expect(noteLinks).toHaveCount(countBefore - 1)
+})
+
+function createNote() {
+ return {
+ title: faker.lorem.words(3),
+ content: faker.lorem.paragraphs(3),
+ }
+}
diff --git a/tests/e2e/search.test.ts b/tests/e2e/search.test.ts
new file mode 100644
index 0000000..35b23c9
--- /dev/null
+++ b/tests/e2e/search.test.ts
@@ -0,0 +1,29 @@
+import { expect, test } from '#tests/playwright-utils.ts'
+
+test('Search from home page', async ({ page, navigate, insertNewUser }) => {
+ const newUser = await insertNewUser()
+ await navigate('/')
+
+ // Search for an existing user.
+ await page.getByRole('searchbox', { name: /search/i }).fill(newUser.username)
+ await page.getByRole('button', { name: /search/i }).click()
+
+ await expect(page.getByText('Epic Notes Users')).toBeVisible()
+ const userList = page.getByRole('main').getByRole('list')
+ await expect(userList.getByRole('listitem')).toHaveCount(1)
+ await expect(
+ userList
+ .getByRole('listitem')
+ .getByRole('link', {
+ name: `${newUser.name || newUser.username} profile`,
+ }),
+ ).toBeVisible()
+
+ // Search for a non-existing user.
+ await page.getByRole('searchbox', { name: /search/i }).fill('__nonexistent__')
+ await page.getByRole('button', { name: /search/i }).click()
+ await page.waitForURL(`/users?search=__nonexistent__`)
+
+ await expect(userList.getByRole('listitem')).not.toBeVisible()
+ await expect(page.getByText(/no users found/i)).toBeVisible()
+})
diff --git a/tests/mocks/github.ts b/tests/mocks/github.ts
index 936aeec..9ccf0db 100644
--- a/tests/mocks/github.ts
+++ b/tests/mocks/github.ts
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
import { faker } from '@faker-js/faker'
import fsExtra from 'fs-extra'
import { HttpResponse, passthrough, http, type HttpHandler } from 'msw'
+import { ENV } from 'varlock/env'
import { USERNAME_MAX_LENGTH } from '#app/utils/user-validation.ts'
const { json } = HttpResponse
@@ -129,8 +130,8 @@ async function getUser(request: Request) {
}
const passthroughGitHub =
- !process.env.GITHUB_CLIENT_ID?.startsWith('MOCK_') &&
- process.env.NODE_ENV !== 'test'
+ !ENV.GITHUB_CLIENT_ID?.startsWith('MOCK_') &&
+ ENV.NODE_ENV !== 'test'
export const handlers: Array = [
http.post(
diff --git a/tests/mocks/index.ts b/tests/mocks/index.ts
index 441f3f0..a626110 100644
--- a/tests/mocks/index.ts
+++ b/tests/mocks/index.ts
@@ -1,5 +1,6 @@
import closeWithGrace from 'close-with-grace'
import { setupServer } from 'msw/node'
+import { ENV } from 'varlock/env';
import { handlers as githubHandlers } from './github.ts'
import { handlers as pwnedPasswordApiHandlers } from './pwned-passwords.ts'
import { handlers as resendHandlers } from './resend.ts'
@@ -30,7 +31,7 @@ server.listen({
},
})
-if (process.env.NODE_ENV !== 'test') {
+if (ENV.NODE_ENV !== 'test') {
console.info('🔶 Mock server installed')
closeWithGrace(() => {
diff --git a/tests/mocks/tigris.ts b/tests/mocks/tigris.ts
index b8f2d17..3c45229 100644
--- a/tests/mocks/tigris.ts
+++ b/tests/mocks/tigris.ts
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
import { invariantResponse } from '@epic-web/invariant'
import { lookup as getMimeType } from 'mime-types'
import { http, HttpResponse } from 'msw'
+import { ENV } from 'varlock/env'
// Ensure we have a valid URL by explicitly creating it from the import.meta.url
const __filename = fileURLToPath(import.meta.url)
@@ -12,9 +13,9 @@ const __dirname = path.dirname(__filename)
const FIXTURES_DIR = path.join(__dirname, '..', 'fixtures')
const MOCK_STORAGE_DIR = path.join(FIXTURES_DIR, 'uploaded')
const FIXTURES_IMAGES_DIR = path.join(FIXTURES_DIR, 'images')
-const STORAGE_ENDPOINT = process.env.AWS_ENDPOINT_URL_S3
-const STORAGE_BUCKET = process.env.BUCKET_NAME
-const STORAGE_ACCESS_KEY = process.env.AWS_ACCESS_KEY_ID
+const STORAGE_ENDPOINT = ENV.AWS_ENDPOINT_URL_S3
+const STORAGE_BUCKET = ENV.BUCKET_NAME
+const STORAGE_ACCESS_KEY = ENV.AWS_ACCESS_KEY_ID
function validateAuth(headers: Headers) {
const authHeader = headers.get('Authorization')
diff --git a/tests/setup/db-setup.ts b/tests/setup/db-setup.ts
index 2cbc646..234b3d6 100644
--- a/tests/setup/db-setup.ts
+++ b/tests/setup/db-setup.ts
@@ -1,14 +1,14 @@
import path from 'node:path'
import fsExtra from 'fs-extra'
+import { ENV } from 'varlock/env'
import { afterAll, beforeEach } from 'vitest'
-import { BASE_DATABASE_PATH } from './global-setup.ts'
const databaseFile = `./tests/prisma/data.${process.env.VITEST_POOL_ID || 0}.db`
const databasePath = path.join(process.cwd(), databaseFile)
process.env.DATABASE_URL = `file:${databasePath}`
beforeEach(async () => {
- await fsExtra.copyFile(BASE_DATABASE_PATH, databasePath)
+ await fsExtra.copyFile(ENV.DATABASE_PATH, databasePath)
})
afterAll(async () => {
diff --git a/tests/setup/global-setup.ts b/tests/setup/global-setup.ts
index a81d74d..fc0181f 100644
--- a/tests/setup/global-setup.ts
+++ b/tests/setup/global-setup.ts
@@ -1,20 +1,18 @@
-import path from 'node:path'
+import 'varlock/auto-load'
+import { fileURLToPath } from 'node:url'
import { execaCommand } from 'execa'
import fsExtra from 'fs-extra'
-import 'dotenv/config'
-import '#app/utils/env.server.ts'
+import { ENV } from 'varlock/env'
import '#app/utils/cache.server.ts'
-export const BASE_DATABASE_PATH = path.join(
- process.cwd(),
- `./tests/prisma/base.db`,
-)
+
export async function setup() {
- const databaseExists = await fsExtra.pathExists(BASE_DATABASE_PATH)
+ const dbPath = fileURLToPath(ENV.DATABASE_URL);
+ const databaseExists = await fsExtra.pathExists(dbPath)
if (databaseExists) {
- const databaseLastModifiedAt = (await fsExtra.stat(BASE_DATABASE_PATH))
+ const databaseLastModifiedAt = (await fsExtra.stat(dbPath))
.mtime
const prismaSchemaLastModifiedAt = (
await fsExtra.stat('./prisma/schema.prisma')
@@ -29,10 +27,6 @@ export async function setup() {
'npx prisma migrate reset --force --skip-seed --skip-generate',
{
stdio: 'inherit',
- env: {
- ...process.env,
- DATABASE_URL: `file:${BASE_DATABASE_PATH}`,
- },
},
)
}
diff --git a/tests/setup/setup-test-env.ts b/tests/setup/setup-test-env.ts
index 8987c43..4a3f3e4 100644
--- a/tests/setup/setup-test-env.ts
+++ b/tests/setup/setup-test-env.ts
@@ -1,6 +1,5 @@
-import 'dotenv/config'
+import 'varlock/auto-load'
import './db-setup.ts'
-import '#app/utils/env.server.ts'
// we need these to be imported first 👆
import { cleanup } from '@testing-library/react'
diff --git a/vite.config.ts b/vite.config.ts
index 1f10a67..6d56c3e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,20 +1,22 @@
+
import { reactRouter } from '@react-router/dev/vite'
import {
type SentryReactRouterBuildOptions,
sentryReactRouter,
} from '@sentry/react-router'
import tailwindcss from '@tailwindcss/vite'
+import { varlockVitePlugin } from '@varlock/vite-integration'
import { reactRouterDevTools } from 'react-router-devtools'
+import { ENV } from 'varlock/env'
import { defineConfig } from 'vite'
import { envOnlyMacros } from 'vite-env-only'
import { iconsSpritesheet } from 'vite-plugin-icons-spritesheet'
-const MODE = process.env.NODE_ENV
export default defineConfig((config) => ({
build: {
target: 'es2022',
- cssMinify: MODE === 'production',
+ cssMinify: ENV.MODE === 'production',
rollupOptions: {
external: [/node:.*/, 'fsevents'],
@@ -38,6 +40,7 @@ export default defineConfig((config) => ({
},
sentryConfig,
plugins: [
+ varlockVitePlugin(),
envOnlyMacros(),
tailwindcss(),
reactRouterDevTools(),
@@ -51,8 +54,8 @@ export default defineConfig((config) => ({
}),
// it would be really nice to have this enabled in tests, but we'll have to
// wait until https://github.com/remix-run/remix/issues/9871 is fixed
- MODE === 'test' ? null : reactRouter(),
- MODE === 'production' && process.env.SENTRY_AUTH_TOKEN
+ ENV.MODE === 'test' ? null : reactRouter(),
+ ENV.MODE === 'production' && ENV.SENTRY_AUTH_TOKEN
? sentryReactRouter(sentryConfig, config)
: null,
],
@@ -69,13 +72,13 @@ export default defineConfig((config) => ({
}))
const sentryConfig: SentryReactRouterBuildOptions = {
- authToken: process.env.SENTRY_AUTH_TOKEN,
- org: process.env.SENTRY_ORG,
- project: process.env.SENTRY_PROJECT,
+ authToken: ENV.SENTRY_AUTH_TOKEN,
+ org: ENV.SENTRY_ORG,
+ project: ENV.SENTRY_PROJECT,
unstable_sentryVitePluginOptions: {
release: {
- name: process.env.COMMIT_SHA,
+ name: ENV.COMMIT_SHA,
setCommits: {
auto: true,
},