Production Safety
Scenarist is safe to use in production - your test mocking code will never reach production users.
How It Works
Section titled “How It Works”When you deploy to production with NODE_ENV=production, Scenarist automatically returns undefined without loading any test code. Your bundler (Webpack, Vite, esbuild, etc.) then performs tree-shaking to eliminate all Scenarist code from your production bundle.
import { createScenarist } from "@scenarist/express-adapter";import { scenarios } from "./scenarios";
// In production: returns undefined// In development/test: returns working Scenarist instanceexport const scenarist = createScenarist({ enabled: true, scenarios,});The Production Wrapper Pattern
Section titled “The Production Wrapper Pattern”All Scenarist adapters use a production wrapper that checks NODE_ENV before loading any test code:
// Simplified view of what happens inside createScenarist()export const createScenarist = (options) => { if (process.env.NODE_ENV === "production") { return undefined; // ← Returns immediately, no test code loaded }
// Development/test: load implementation return createScenaristImpl(options);};This pattern ensures:
- ✅ Zero runtime impact - Production code paths never execute test logic
- ✅ Automatic tree-shaking - Bundlers eliminate dead code
- ✅ No configuration needed - Works automatically based on NODE_ENV
- ✅ Type-safe - TypeScript enforces null checks via
| undefinedreturn type
Bundle Size Impact
Section titled “Bundle Size Impact”Production bundles contain zero Scenarist code - the production wrapper pattern combined with tree-shaking completely eliminates all test code from your production builds.
Verification
Section titled “Verification”Critical: Always verify tree-shaking is working in your production builds. This section provides multiple methods to confirm Scenarist code is eliminated.
Quick Verification (30 seconds)
Section titled “Quick Verification (30 seconds)”The fastest way to verify tree-shaking by searching for MSW runtime functions (not just the word “msw”):
# Build for productionNODE_ENV=production npm run build
# Search for MSW runtime code (setupWorker, HttpResponse, handler functions)! grep -rE '(setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' dist/ .next/static/ build/Expected result: No matches found (exit code 0 from the command due to ! negation). If matches are found, tree-shaking failed.
Why this pattern: Searching for the literal strings “scenarist” or “msw” gives false positives (your own variable names, Zod code, comments). This pattern searches for actual MSW runtime functions like http.get(), HttpResponse.json(), and worker setup - code that should NEVER appear in production bundles.
Visual Bundle Analysis
Section titled “Visual Bundle Analysis”Visual analysis tools provide the most comprehensive verification. Choose the tool matching your bundler:
Next.js (Webpack):
# Install analyzernpm install --save-dev @next/bundle-analyzer
# Add to next.config.jsconst withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true',});
module.exports = withBundleAnalyzer({ // ... your config});
# Build and analyzeANALYZE=true NODE_ENV=production npm run buildOpens interactive visualization in browser. Search for “scenarist” or “msw” - should find nothing.
Vite:
# Install visualizernpm install --save-dev rollup-plugin-visualizer
# Add to vite.config.tsimport { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({ plugins: [ visualizer({ open: true, gzipSize: true, brotliSize: true, }), ],});
# Build and analyzeNODE_ENV=production npm run buildOpens stats.html showing bundle composition. Scenarist/MSW should be absent.
Webpack (standalone):
# Install analyzernpm install --save-dev webpack-bundle-analyzer
# Generate stats during buildwebpack --mode production --profile --json > stats.json
# Analyzenpx webpack-bundle-analyzer stats.jsonRollup:
# Install visualizernpm install --save-dev rollup-plugin-visualizer
# Add to rollup.config.jsimport { visualizer } from 'rollup-plugin-visualizer';
export default { plugins: [ visualizer({ filename: 'bundle-stats.html', open: true, }), ],};
# BuildNODE_ENV=production npm run buildSource Map Analysis (Framework-Agnostic)
Section titled “Source Map Analysis (Framework-Agnostic)”Works with any bundler:
# Install source-map-explorernpm install --save-dev source-map-explorer
# Build with source mapsNODE_ENV=production npm run build
# Analyze (adjust paths to your build output)npx source-map-explorer 'dist/**/*.js' 'dist/**/*.js.map'Generates treemap showing code origins. Scenarist/MSW should not appear.
Size Comparison (Before/After)
Section titled “Size Comparison (Before/After)”Measure actual bundle size impact:
# Baseline: Build without Scenarist# (Comment out scenarist imports temporarily)NODE_ENV=production npm run builddu -sh dist/ .next/static/ build/# Note the size
# With Scenarist: Build normally# (Uncomment scenarist imports)NODE_ENV=production npm run builddu -sh dist/ .next/static/ build/# Compare sizes - should be identical or negligible differenceExpected: ≤ 1KB difference (rounding/metadata only)
Framework-Specific Verification
Section titled “Framework-Specific Verification”Express (Automatic for Unbundled, Configuration for Bundled):
Unbundled Deployments (Most Common):
Standard Express deployment pattern - no bundler involved:
# Deploy directlyNODE_ENV=production node src/server.jsHow it works:
createScenarist()returnsundefinedat runtime- Dynamic imports never execute
- MSW code never loads into memory
- ✅ Zero configuration required
Verify with memory inspection (optional):
# Run production server and check memoryNODE_ENV=production node --trace-warnings src/server.js &PID=$!
# MSW should not appear in loaded moduleslsof -p $PID | grep -i msw# Should output nothing
kill $PIDBundled Deployments (esbuild, webpack, Vite, rollup):
Default Approach (Zero Config):
Most bundlers enable code splitting by default for dynamic imports:
# esbuild with code splittingesbuild --bundle --splitting --outdir=dist --define:process.env.NODE_ENV='"production"'
# webpack (code splitting automatic for dynamic imports)webpack --mode production
# Vite (code splitting automatic)vite build
# rollup (code splitting automatic for dynamic imports)rollup -cHow it works:
- Dynamic import creates code-splitting boundary
- Impl code split into separate chunk (e.g.,
impl-ABC123.js) - DefinePlugin makes
if (process.env.NODE_ENV === 'production')unreachable - Import statement eliminated from entry point
- Chunk file exists on disk but never loads into memory
Verification:
# Build with code splittingNODE_ENV=production npm run build
# Entry point should be small (~27kb)ls -lh dist/server.js
# Impl chunk should exist but never be loadedls -lh dist/impl-*.js # Exists (~242kb)
# Verify chunk never loads into memory (optional)NODE_ENV=production node dist/server.js &PID=$!lsof -p $PID | grep 'impl-.*\.js'# Should output nothing (chunk not loaded)kill $PIDResult:
- Entry point: ~27kb (94% smaller than single bundle)
- Impl chunk: ~242kb (exists but never loaded)
- Effective delivery: 27kb (same as conditional exports!)
- Zero custom configuration required
Optional: Minimal Build Artifacts
Scenarist uses conditional package.json exports to provide different entry points:
{ "exports": { ".": { "production": "./dist/setup/production.js", // Zero dependencies "default": "./dist/index.js" // Full implementation } }}The "production" condition is custom (not a Node.js built-in). Bundlers must be configured to recognize it:
esbuild:
esbuild --bundle --splitting --outdir=dist --define:process.env.NODE_ENV='"production"' --conditions=productionwebpack:
module.exports = { mode: "production", resolve: { conditionNames: ["production", "import", "require"], },};Vite:
export default { resolve: { conditions: ["production"], },};rollup:
import resolve from "@rollup/plugin-node-resolve";
export default { plugins: [ resolve({ exportConditions: ["production"], }), ],};Trade-off:
- ✅ Smallest possible build artifacts (~298kb total vs ~27kb + ~242kb)
- ⚠️ GLOBAL configuration affects all dependencies
- ⚠️ May break other packages using conditional exports
- ⚠️ Requires auditing dependency tree
When to use conditional exports:
- Build artifact size is critical (containers, CI/CD storage)
- Disk space constrained environments
- You’ve audited your dependency tree
- You understand the global nature of
--conditions
Default recommendation: Use code splitting (zero config). Only use conditional exports if build artifact size is critical and you understand the trade-offs
Verify bundled Express app with code splitting:
# Build production bundle with code splitting (default approach)NODE_ENV=production npm run build
# Entry point should be smallls -lh dist/server.js
# Search for MSW code (should not be in entry point)grep -rE '(setupWorker|startWorker|HttpResponse\.json)' dist/server.js# Should output nothing
# Verify impl chunk exists but is never loadedls -lh dist/impl-*.js # Chunk exists on diskNODE_ENV=production node dist/server.js &PID=$!lsof -p $PID | grep 'impl-.*\.js' # Not loaded into memorykill $PIDExample verification script (code splitting):
{ "scripts": { "build:production": "esbuild src/server.ts --bundle --splitting --outdir=dist --platform=node --format=esm --external:express --define:process.env.NODE_ENV='\"production\"' --minify", "verify:treeshaking": "npm run build:production && ! grep -rE '(setupWorker|HttpResponse\\.json)' dist/server.js" }}Example verification script (conditional exports):
{ "scripts": { "build:production": "esbuild src/server.ts --bundle --splitting --outdir=dist --platform=node --format=esm --external:express --define:process.env.NODE_ENV='\"production\"' --minify --conditions=production", "verify:minimal": "npm run build:production && ! grep -rE 'impl-.*\\.js' dist/" }}Run: npm run verify:treeshaking (code splitting) or npm run verify:minimal (conditional exports)
For detailed bundler configuration examples, see the Express Adapter README - Production Tree-Shaking.
Next.js (Detailed):
Next.js has separate client and server bundles. Verify both:
# BuildNODE_ENV=production npm run build
# Check client bundles for MSW runtime functions! find .next/static -name "*.js" -exec grep -E '(setupWorker|HttpResponse\.json)' {} \;# Should output nothing (exit code 0)
# Check server bundles for MSW runtime functions! find .next/server -name "*.js" -exec grep -E '(http\.(get|post|put|delete|patch)|HttpResponse\.json)' {} \;# Should output nothing (exit code 0)Vercel Deployment:
If deployed to Vercel, check production bundle size in deployment logs:
Build Output:├ ƒ / 1.2 kB 123 B├ ○ /404 2.1 kB 456 B└ ƒ /api/products 450 B 78 BCompare with pre-Scenarist deployment - sizes should be nearly identical.
Automated CI/CD Verification
Section titled “Automated CI/CD Verification”Prevent tree-shaking regressions by adding checks to CI:
GitHub Actions Example:
name: Verify Production Safety
on: pull_request: paths: - "package.json" - "src/**"
jobs: verify-bundle: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3
- name: Install dependencies run: npm ci
- name: Build production bundle run: NODE_ENV=production npm run build env: NODE_ENV: production
- name: Verify no MSW runtime code in bundle run: | if grep -rE '(setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' dist/ .next/static/ build/ 2>/dev/null; then echo "❌ ERROR: MSW runtime code found in production bundle!" echo "Tree-shaking failed. Check NODE_ENV and bundler config." exit 1 fi echo "✅ Verified: No MSW runtime code in production bundle"
- name: Compare bundle sizes run: | # Store baseline size (first run) or compare BUNDLE_SIZE=$(du -sb dist/ .next/ build/ 2>/dev/null | awk '{sum+=$1} END {print sum}') echo "Bundle size: $BUNDLE_SIZE bytes" # Add budget check if needednpm script:
Add to package.json for manual verification:
{ "scripts": { "verify:production": "NODE_ENV=production npm run build && ! grep -rE '(setupWorker|HttpResponse\\.json)' dist/ .next/static/ build/" }}Run: npm run verify:production
What Success Looks Like
Section titled “What Success Looks Like”✅ Passing verification:
- Bundle analyzer shows NO
scenaristormswpackages grepsearch returns no matches (exit code 1)- Bundle size same as before adding Scenarist (±1KB)
- Source maps show NO Scenarist source files
- Production deployment works normally
❌ Failed verification (tree-shaking didn’t work):
- Bundle contains
scenaristormswstrings - Bundle size significantly larger than expected
createScenaristfunction visible in bundle- MSW handlers appear in production code
Troubleshooting Failed Verification
Section titled “Troubleshooting Failed Verification”If verification fails, check these common issues:
1. NODE_ENV not set to ‘production’:
# ❌ Wrong - tree-shaking won't worknpm run build
# ✅ Correct - enables tree-shakingNODE_ENV=production npm run build2. Bundler not configured for tree-shaking:
Most bundlers enable tree-shaking by default in production mode. Verify config:
Webpack:
module.exports = { mode: "production", // ← Required optimization: { usedExports: true, // ← Should be true (default in production) sideEffects: true, // ← Should be true (default) },};Vite:
// vite.config.js - tree-shaking automatic in productionexport default defineConfig({ build: { minify: "terser", // Or 'esbuild' - both tree-shake },});3. Dynamic imports preventing tree-shaking:
// ❌ Wrong - prevents tree-shakingconst scenarist = require("@scenarist/express-adapter");
// ✅ Correct - enables tree-shakingimport { createScenarist } from "@scenarist/express-adapter";4. package.json sideEffects field:
Check if your bundler respects sideEffects: false in Scenarist’s package.json:
cat node_modules/@scenarist/*/package.json | grep sideEffects# Should show: "sideEffects": falseIf this is missing, file an issue - this is a bug.
5. TypeScript compilation settings:
Ensure TypeScript preserves ES modules:
{ "compilerOptions": { "module": "ES2020", // ← Or "ESNext", not "CommonJS" "moduleResolution": "bundler" // ← Or "node16" }}CommonJS modules prevent tree-shaking in most bundlers.
Still Having Issues?
Section titled “Still Having Issues?”If tree-shaking still fails after checking the above:
- Check your bundler version - Update to latest version
- Review bundler logs - Look for warnings about side effects
- Minimal reproduction - Test in fresh project to isolate issue
- Open an issue - Report it with:
- Bundler name and version
- Framework name and version
- Bundler config
- Output of
npm ls @scenarist/*
For additional help, see:
- Production Tree-Shaking Verification - Verify code splitting works correctly
- Header Propagation in Parallel Tests - Troubleshoot test isolation issues
Type Safety
Section titled “Type Safety”The return type ExpressScenarist<T> | undefined (or equivalent for your adapter) forces you to handle the production case:
// TypeScript enforces null checksif (!scenarist) { // Production mode - scenarist is undefined return;}
// Development/test mode - scenarist is definedscenarist.start();This prevents accidentally calling test methods in production code.
Framework-Specific Notes
Section titled “Framework-Specific Notes”Express
Section titled “Express”import { createScenarist } from "@scenarist/express-adapter";
export const scenarist = createScenarist({ enabled: true, scenarios,});
// Type-safe null check requiredif (scenarist) { scenarist.start(); // Only runs in development/test}Next.js (App Router & Pages Router)
Section titled “Next.js (App Router & Pages Router)”Both Next.js adapters use the synchronous pattern with conditional exports for production safety:
import { createScenarist } from "@scenarist/nextjs-adapter/app";// or: import { createScenarist } from '@scenarist/nextjs-adapter/pages';
// Synchronous - no async/await neededexport const scenarist = createScenarist({ enabled: true, scenarios,});
// MSW auto-starts in development/test (when scenarist is defined)if (typeof window === "undefined" && scenarist) { scenarist.start();}Production behavior:
- ✅ Conditional exports resolve to
production.jswhich returnsundefinedimmediately - ✅ Zero imports in production.js guarantees tree-shaking
- ✅ Zero configuration: Works automatically based on
NODE_ENV
Safe Helper Functions:
To avoid scenarist?.method() ?? fallback patterns everywhere, both adapters export safe helper functions:
// App Router API Routeimport { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";
export async function GET(request: Request) { const response = await fetch("http://localhost:3001/products", { headers: { ...getScenaristHeaders(request), // Always safe, no guards needed "x-user-tier": "premium", }, });}// App Router Server Componentimport { headers } from "next/headers";import { getScenaristHeadersFromReadonlyHeaders } from "@scenarist/nextjs-adapter/app";
export default async function ProductsPage() { const headersList = await headers();
const response = await fetch("http://localhost:3001/products", { headers: getScenaristHeadersFromReadonlyHeaders(headersList), });}// Pages Router API Routeimport { getScenaristHeaders } from "@scenarist/nextjs-adapter/pages";
export default async function handler(req, res) { const response = await fetch("http://localhost:3001/products", { headers: getScenaristHeaders(req), });}Available helpers:
- App Router:
getScenaristHeaders(request)- Extract Scenarist headers from RequestgetScenaristHeadersFromReadonlyHeaders(headers)- Extract Scenarist headers from ReadonlyHeadersgetScenaristTestId(request)- Extract test ID string from RequestgetScenaristTestIdFromReadonlyHeaders(headers)- Extract test ID string from ReadonlyHeaders
- Pages Router:
getScenaristHeaders(req)- Extract Scenarist headers from IncomingMessage
These helpers access global singletons and return safe defaults in production (empty objects for headers, 'default-test' for test IDs), eliminating the need for manual undefined checks.
Common Questions
Section titled “Common Questions”Does Scenarist run in production if I forget NODE_ENV?
Section titled “Does Scenarist run in production if I forget NODE_ENV?”No. Even without NODE_ENV=production, Scenarist only activates when explicitly started. The scenarist?.start() call is typically in development server setup, not production runtime.
However, you should always set NODE_ENV=production for:
- Optimal performance
- Correct framework behavior
- Automatic tree-shaking
What about CI/CD environments?
Section titled “What about CI/CD environments?”CI/CD environments should use:
NODE_ENV=testfor running testsNODE_ENV=productionfor building production bundles
Scenarist works in both modes:
- Test mode: Full functionality for scenario-based tests
- Production mode: Returns undefined, enabling tree-shaking
Can I verify tree-shaking before deploying?
Section titled “Can I verify tree-shaking before deploying?”Yes! See the Production Tree-Shaking Verification section for:
- Step-by-step verification with lsof (proves chunk never loads)
- Bundle size analysis
- Build artifact inspection
- Framework-specific verification commands
- Red flags and troubleshooting
What if tree-shaking fails?
Section titled “What if tree-shaking fails?”If your bundler doesn’t tree-shake Scenarist:
- Check NODE_ENV: Ensure
NODE_ENV=productionduring build - Check bundler config: Verify tree-shaking is enabled (it usually is by default)
- Check sideEffects: Scenarist marks itself as side-effect-free in package.json
- File an issue: If tree-shaking still fails, open an issue with your bundler details
Best Practices
Section titled “Best Practices”-
Always use
NODE_ENV=productionfor production buildsTerminal window NODE_ENV=production npm run build -
Add null checks where you use Scenarist
if (scenarist) {scenarist.start();} -
Verify bundle size in CI/CD
- name: Check bundle sizerun: npm run build && npm run analyze -
Monitor production bundles
- Set up bundle size budgets
- Alert on unexpected size increases
- Review bundle composition regularly
Supply Chain Security
Section titled “Supply Chain Security”Beyond production safety, Scenarist provides cryptographic verification of package authenticity:
- npm Provenance: Packages include npm provenance attestations
- GitHub Attestations: Build provenance attestations verifiable via GitHub CLI
- SBOM: Software Bill of Materials signed with Sigstore
Verifying Packages
Section titled “Verifying Packages”Verify any Scenarist package was built from this repository:
# Download and verify a packagenpm pack @scenarist/coregh attestation verify scenarist-core-*.tgz -R citypaul/scenaristView all attestations at github.com/citypaul/scenarist/attestations.
For complete security documentation, see SECURITY.md.
Summary
Section titled “Summary”Scenarist is designed for production safety:
| Aspect | Guarantee |
|---|---|
| Production runtime | Zero test code execution |
| Bundle size | Zero Scenarist code after tree-shaking |
| Configuration | No environment-specific config needed |
| Type safety | TypeScript enforces null checks |
| Performance | No runtime overhead in production |
The bottom line: Scenarist’s production wrapper + tree-shaking = zero production impact.