Skip to content

Production Safety

Scenarist is safe to use in production - your test mocking code will never reach production users.

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 instance
export const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios,
});

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 condition
export const createScenarist = (_options) => {
return undefined; // ← Returns immediately, no test code loaded
};
// Development/test (default condition): the full implementation is exported

This 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 production condition; Express builds need it enabled
  • ✅ Type-safe - TypeScript enforces null checks via | undefined return type
  • enabled: false: every adapter returns undefined from createScenarist(): no endpoints, no middleware, no interception.
  • NODE_ENV=production (Express): the Express adapter also returns undefined when NODE_ENV is production, even with enabled: 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.

Production bundles contain zero Scenarist code - the production wrapper pattern combined with tree-shaking completely eliminates all test code from your production builds.

Critical: Always verify tree-shaking is working in your production builds. This section provides multiple methods to confirm Scenarist code is eliminated.

The fastest way to verify tree-shaking by searching for MSW runtime functions (not just the word “msw”):

Terminal window
# Build for production
NODE_ENV=production npm run build
# Search for MSW runtime code (setupWorker, HttpResponse, handler functions)
# and Scenarist's shared server global, which survives minification
OUT_DIR=dist # your build output directory, e.g. dist, .next or build
test -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 analysis tools provide the most comprehensive verification. Choose the tool matching your bundler:

Next.js (Webpack):

Terminal window
# Install analyzer
npm install --save-dev @next/bundle-analyzer
# Add to next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// ... your config
});
# Build and analyze
ANALYZE=true NODE_ENV=production npm run build

Opens interactive visualization in browser. Search for “scenarist” or “msw” - should find nothing.

Vite:

Terminal window
# Install visualizer
npm install --save-dev rollup-plugin-visualizer
# Add to vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
}),
],
});
# Build and analyze
NODE_ENV=production npm run build

Opens stats.html showing bundle composition. Scenarist/MSW should be absent.

Webpack (standalone):

Terminal window
# Install analyzer
npm install --save-dev webpack-bundle-analyzer
# Generate stats during build
webpack --mode production --profile --json > stats.json
# Analyze
npx webpack-bundle-analyzer stats.json

Rollup:

Terminal window
# Install visualizer
npm install --save-dev rollup-plugin-visualizer
# Add to rollup.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
filename: 'bundle-stats.html',
open: true,
}),
],
};
# Build
NODE_ENV=production npm run build

Works with any bundler:

Terminal window
# Install source-map-explorer
npm install --save-dev source-map-explorer
# Build with source maps
NODE_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.

Measure actual bundle size impact:

Terminal window
# Baseline: Build without Scenarist
# (Comment out scenarist imports temporarily)
NODE_ENV=production npm run build
du -sh dist/ .next/static/ build/
# Note the size
# With Scenarist: Build normally
# (Uncomment scenarist imports)
NODE_ENV=production npm run build
du -sh dist/ .next/static/ build/
# Compare sizes - should be identical or negligible difference

Expected: ≤ 1KB difference (rounding/metadata only)

Express (Requires the production Condition):

Unbundled Deployments:

Run Node with the production condition:

Terminal window
# Deploy directly
NODE_ENV=production node --conditions=production src/server.js
# Without the condition: Scenarist is inert (/__scenario__ returns 404),
# but its code and msw are still loaded
NODE_ENV=production node src/server.js

How it works:

  • Node resolves @scenarist/express-adapter to its production entry point
  • createScenarist() returns undefined at 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):

Terminal window
# Succeeds only when @scenarist/express-adapter resolves to its zero-import stub
node --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:

Terminal window
esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js --define:process.env.NODE_ENV='"production"' --conditions=production

webpack:

webpack.config.js
module.exports = {
mode: "production",
resolve: {
conditionNames: ["production", "import", "require"],
},
};

Vite:

vite.config.js
export default {
resolve: {
conditions: ["production"],
},
};

rollup:

import resolve from "@rollup/plugin-node-resolve";
export default {
plugins: [
resolve({
exportConditions: ["production"],
}),
],
};

Verify bundled Express app:

Terminal window
# 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:

Terminal window
# Build
NODE_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 B

Compare with pre-Scenarist deployment - sizes should be nearly identical.

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 needed

npm 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

✅ Passing verification:

  • Bundle analyzer shows NO scenarist or msw packages
  • grep search 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 scenarist or msw strings
  • Bundle size significantly larger than expected
  • createScenarist function visible in bundle
  • MSW handlers appear in production code

If verification fails, check these common issues:

1. production export condition not resolved:

Terminal window
# ❌ Wrong - resolves the full implementation
esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js
# ✅ Correct - resolves Scenarist's production entry point
esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js --conditions=production

2. Bundler not configured for tree-shaking:

Most bundlers enable tree-shaking by default in production mode. Verify config:

Webpack:

webpack.config.js
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 production
export 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:

Terminal window
# ❌ Adapter and MSW code stay in the bundle
esbuild 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 bundle
esbuild src/server.ts --bundle --platform=node --format=esm --splitting --outdir=dist --define:process.env.NODE_ENV='"production"' --conditions=production

See 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:

Terminal window
cat node_modules/@scenarist/*/package.json | grep sideEffects
# Should show: "sideEffects": false

If this is missing, file an issue - this is a bug.

5. TypeScript compilation settings:

Ensure TypeScript preserves ES modules:

tsconfig.json
{
"compilerOptions": {
"module": "ES2020", // ← Or "ESNext", not "CommonJS"
"moduleResolution": "bundler" // ← Or "node16"
}
}

CommonJS modules prevent tree-shaking in most bundlers.

If tree-shaking still fails after checking the above:

  1. Check your bundler version - Update to latest version
  2. Review bundler logs - Look for warnings about side effects
  3. Minimal reproduction - Test in fresh project to isolate issue
  4. 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:

The return type ExpressScenarist<T> | undefined (or equivalent for your adapter) forces you to handle the production case:

// TypeScript enforces null checks
if (!scenarist) {
// Production mode - scenarist is undefined
return;
}
// Development/test mode - scenarist is defined
scenarist.start();

This prevents accidentally calling test methods in production code.

import { createScenarist } from "@scenarist/express-adapter";
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Type-safe null check required
if (scenarist) {
scenarist.start(); // Only runs in development/test
}

Both Next.js adapters use the synchronous pattern with conditional exports for production safety:

lib/scenarist.ts
import { createScenarist } from "@scenarist/nextjs-adapter/app";
// or: import { createScenarist } from '@scenarist/nextjs-adapter/pages';
// Synchronous - no async/await needed
export 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.js which returns undefined immediately
  • ✅ 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 Route
import { 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 Component
import { 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 Route
import { 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 Request
    • getScenaristHeadersFromReadonlyHeaders(headers) - Extract Scenarist headers from ReadonlyHeaders
    • getScenaristTestId(request) - Extract test ID string from Request
    • getScenaristTestIdFromReadonlyHeaders(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.

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 undefined even with enabled: true)
  • Optimal performance
  • Correct framework behavior

CI/CD environments should use:

  • NODE_ENV=test for running tests
  • NODE_ENV=production for building production bundles

Scenarist works in both modes:

  • Test mode: Full functionality for scenario-based tests
  • Production builds (production export 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

If your bundler doesn’t tree-shake Scenarist:

  1. Check the production condition: Ensure your bundler (or node --conditions=production) resolves it
  2. Check bundler config: Verify tree-shaking is enabled (it usually is by default)
  3. Check sideEffects: Scenarist marks itself as side-effect-free in package.json
  4. File an issue: If tree-shaking still fails, open an issue with your bundler details
  1. Always use NODE_ENV=production for production builds

    Terminal window
    NODE_ENV=production npm run build
  2. Add null checks where you use Scenarist

    if (scenarist) {
    scenarist.start();
    }
  3. Verify bundle size in CI/CD

    - name: Check bundle size
    run: npm run build && npm run analyze
  4. Monitor production bundles

    • Set up bundle size budgets
    • Alert on unexpected size increases
    • Review bundle composition regularly

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

Verify any Scenarist package was built from this repository:

Terminal window
# Download and verify a package
npm pack @scenarist/core
gh attestation verify scenarist-core-*.tgz -R citypaul/scenarist

View all attestations at github.com/citypaul/scenarist/attestations.

For complete security documentation, see SECURITY.md.

Scenarist is designed for production safety:

AspectGuarantee
Production runtimeNo endpoints, no request interception
Bundle sizeZero Scenarist code after tree-shaking
ConfigurationNext.js: none; Express: enable the production condition
Type safetyTypeScript enforces null checks
PerformanceNo 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.