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 your production build resolves the production export condition, createScenarist() resolves to a stub that returns undefined without loading any test code, so no Scenarist or MSW code ends up in your production bundle. Next.js production builds resolve this condition automatically; Express builds must enable it (see Express below).
Independently of the condition, createScenarist() also returns undefined when enabled is false, and the Express adapter returns undefined when NODE_ENV is production (see Runtime Guards).
import { createScenarist } from "@scenarist/express-adapter";import { scenarios } from "./scenarios";
// In production builds (`production` export condition), or when enabled is false: returns undefined// In development/test with enabled: true: returns working Scenarist instanceexport const scenarist = createScenarist({ enabled: process.env.NODE_ENV === "test", scenarios,});The Production Wrapper Pattern
Section titled “The Production Wrapper Pattern”All Scenarist adapters use conditional package.json exports to swap in a production entry point that imports nothing:
// production.js - resolved via the `production` export conditionexport const createScenarist = (_options) => { return undefined; // ← Returns immediately, no test code loaded};
// Development/test (default condition): the full implementation is exportedThis pattern ensures:
- ✅ Zero runtime impact - Production code paths never execute test logic
- ✅ Nothing to tree-shake - The production entry point has zero imports
- ✅ Automatic in Next.js - Next.js production builds resolve the
productioncondition; Express builds need it enabled - ✅ Type-safe - TypeScript enforces null checks via
| undefinedreturn type
Runtime Guards
Section titled “Runtime Guards”enabled: false: every adapter returnsundefinedfromcreateScenarist(): no endpoints, no middleware, no interception.NODE_ENV=production(Express): the Express adapter also returnsundefinedwhenNODE_ENVisproduction, even withenabled: true. This protects unbundled Express apps started without--conditions=production.
A runtime guard makes Scenarist inert, but it does not remove code: without the production condition, the adapter and MSW are still loaded or bundled.
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)# and Scenarist's shared server global, which survives minificationOUT_DIR=dist # your build output directory, e.g. dist, .next or buildtest -d "$OUT_DIR" && { grep -rqE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' "$OUT_DIR" test $? -eq 1}Expected result: exit code 0. grep exits 1 when nothing matches, so the command succeeds only when the output directory exists and contains no matches. A non-zero exit means tree-shaking failed or the directory is missing.
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 (Requires the production Condition):
Unbundled Deployments:
Run Node with the production condition:
# Deploy directlyNODE_ENV=production node --conditions=production src/server.js
# Without the condition: Scenarist is inert (/__scenario__ returns 404),# but its code and msw are still loadedNODE_ENV=production node src/server.jsHow it works:
- Node resolves
@scenarist/express-adapterto its production entry point createScenarist()returnsundefinedat runtime- MSW code never loads into memory
Verify Node resolves the production entry point, using the same Node flags as your start command (Node.js 20.6+, run from your app’s root):
# Succeeds only when @scenarist/express-adapter resolves to its zero-import stubnode --conditions=production --input-type=module \ -e "console.log(import.meta.resolve('@scenarist/express-adapter'))" \ | grep -q '/dist/setup/production.js$'Checking open files with lsof does not work for this: Node closes module files after loading them, so MSW never shows up even when it is loaded.
Bundled Deployments (esbuild, webpack, Vite, rollup):
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 src/server.ts --bundle --platform=node --outfile=dist/server.js --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"], }), ],};Verify bundled Express app:
# Build production bundle (with --conditions=production)NODE_ENV=production npm run build
# Search for MSW code (succeeds only if dist/ exists and has no matches)test -d dist && { grep -rqE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' dist/ test $? -eq 1}Minifiers rename MSW’s internal identifiers, so setupWorker or HttpResponse.json can miss MSW code in a minified bundle. __scenarist_shared_msw_server is a global property name that survives minification.
Example verification script:
{ "scripts": { "build:production": "esbuild src/server.ts --bundle --platform=node --format=esm --outfile=dist/server.js --external:express --define:process.env.NODE_ENV='\"production\"' --minify --conditions=production", "verify:treeshaking": "npm run build:production && test -d dist && { grep -rqE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\\.(get|post|put|delete|patch)|HttpResponse\\.json)' dist/; test $? -eq 1; }" }}Run: npm run verify:treeshaking
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 and server bundles for Scenarist/MSW runtime code.# grep exits 1 when nothing matches, so this succeeds (exit 0) only when the# bundles exist and contain no matches.test -d .next/static && test -d .next/server && { grep -rqE --include='*.js' '(__scenarist_shared_msw_server|setupWorker|HttpResponse\.json)' .next/static .next/server test $? -eq 1}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: | OUT_DIR=dist # your build output directory, e.g. dist, .next or build status=0 grep -rE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' "$OUT_DIR" || status=$? if [ "$status" -eq 0 ]; then echo "❌ ERROR: MSW runtime code found in production bundle!" echo "Tree-shaking failed. Check the production condition and bundler config." exit 1 fi if [ "$status" -ne 1 ]; then echo "❌ ERROR: could not search $OUT_DIR" 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 && test -d dist && { grep -rqE '(__scenarist_shared_msw_server|setupWorker|HttpResponse\\.json)' dist/; test $? -eq 1; }" }}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. production export condition not resolved:
# ❌ Wrong - resolves the full implementationesbuild src/server.ts --bundle --platform=node --outfile=dist/server.js
# ✅ Correct - resolves Scenarist's production entry pointesbuild src/server.ts --bundle --platform=node --outfile=dist/server.js --conditions=production2. 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. Express bundle built without the production condition:
Code splitting and NODE_ENV=production do not remove Scenarist from an Express bundle. The adapter imports its implementation statically, so only the production export condition swaps it for the zero-import stub:
# ❌ Adapter and MSW code stay in the bundleesbuild src/server.ts --bundle --platform=node --format=esm --splitting --outdir=dist --define:process.env.NODE_ENV='"production"'
# ✅ Resolves the stub: no Scenarist or MSW code in the bundleesbuild src/server.ts --bundle --platform=node --format=esm --splitting --outdir=dist --define:process.env.NODE_ENV='"production"' --conditions=productionSee Express bundled deployments for webpack, Vite, and rollup configuration.
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 the production entry point is used
- 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", }, cache: "no-store", });}// 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), cache: "no-store", });}// 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?”Not if enabled is tied to your test environment. In Express, gating enabled on process.env.NODE_ENV === "test" means an unset NODE_ENV makes createScenarist() return undefined: no endpoints, no interception. (In Next.js use enabled: true: Next.js inlines NODE_ENV, so a "test" check is never true, and production builds are excluded by the production export condition.) enabled is the switch, not start(): MSW only intercepts requests after scenarist.start(), but the scenario endpoints are active wherever you mount the middleware (Express) or export the route handlers (Next.js).
You should still set NODE_ENV=production in production for:
- The Express runtime guard (returns
undefinedeven withenabled: true) - Optimal performance
- Correct framework behavior
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 builds (
productionexport condition): Returns undefined, so no test code is bundled
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 build verification
- 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 the
productioncondition: Ensure your bundler (ornode --conditions=production) resolves it - 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 | No endpoints, no request interception |
| Bundle size | Zero Scenarist code after tree-shaking |
| Configuration | Next.js: none; Express: enable the production condition |
| Type safety | TypeScript enforces null checks |
| Performance | No runtime overhead in production |
The bottom line: the production export condition removes Scenarist entirely, and the runtime guards (enabled: false, and NODE_ENV=production in Express) keep it inert when the condition is not applied.