Skip to content

About

Learn the patterns JavaScript and TypeScript frameworks are built from, by reading real framework source.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Framework Patterns in JavaScript and TypeScript

A study of the patterns that JavaScript and TypeScript frameworks are built from, learned by reading real framework source.

What this is

Frameworks look complex because they combine many small patterns. Each pattern on its own is simple: a kernel that boots the app, a container that builds objects, a chain that runs middleware, a plugin that hooks into a build. Once you can name the patterns, framework code becomes much easier to read.

This repository collects those patterns. Each one is described in general terms first, then shown in the source of real frameworks: NestJS, AdonisJS, H3 and Nuxt, React Router, TanStack Start, and Vinxi. The frameworks are examples, not recommendations. They are studied side by side because they solve the same problems in different ways.

Who it is for

  • Developers who use a framework and want to understand what it does under the hood.
  • Developers who want to read, debug, or contribute to framework source code.
  • Developers who are designing their own library or framework and want to see how others solved the same problems.

You should be comfortable with TypeScript, Node.js, and the basics of HTTP. Some sections assume you know what Vite is.

How to use it

  1. Read the pattern sections in this README in order. Each one explains the idea, then shows examples.
  2. When a pattern interests you, open the matching framework analysis below to see it in context.
  3. Do the exercises. They ask you to find the same pattern in another codebase.
  4. Read SYNTHESIS.md for a side-by-side comparison of the studied frameworks.
  5. Follow the study order if you want a structured plan.

File paths and line numbers in the analyses point to the framework source as it was when the analysis was written. They may have moved since.


Contents

Pattern index

Core framework patterns (Part 1)

Reading a codebase (Part 2)

Classic design patterns in frameworks (Part 3)

  • Creational: Factory Method, Abstract Factory, Singleton, Builder
  • Structural: Proxy, Adapter, Decorator, Composite, Facade
  • Behavioral: Chain of Responsibility, Observer, Strategy, Template Method, Command, Mediator
  • Concurrency: Barrier, Settlement Signal, AsyncLocalStorage

Advanced topics (Part 4)

Cross-framework patterns from the analyses — see the pattern summary in SYNTHESIS.md: distance-based module ordering, WeakMap request context, three-phase lifecycle, Vite middleware mode, dynamic handler swap, invalidation graph walking, child compilers, server function RPC, import protection, composed plugin arrays, multi-router builds, and more.

Framework analyses

Analysis What it covers
NestJS DI container (InstanceWrapper, Injector), module scanner and distance ordering, lifecycle hooks, full-app reload through Webpack HMR.
AdonisJS IoC container (@adonisjs/fold), three-phase provider lifecycle, Vite running in middleware mode, config providers.
AdonisJS Inertia Request-scoped Inertia instance, Symbol-branded props, SSR through Vite's module runner, protocol handling in middleware.
H3 and Nuxt H3's event model, recursive middleware, hot-swappable handlers; Nuxt's Vite plugin stack, vite-node IPC, invalidation-based server HMR.
React Router A large Vite plugin, virtual modules, ssrLoadModule() on every request, a child compiler for analysis, the Express to Fetch API bridge, dual builds.
TanStack Router / Start A Vite plugin composed of single-purpose plugins, compile-time server function RPC, file-based route generation, import protection, Nitro packaging.
Vinxi One Vite instance per "router" (build target), Nitro/H3 as coordinator, router modes, worker threads.
Synthesis A comparison matrix of the studied frameworks, three dev-server architectures, server reload techniques, and a pattern summary.

Part 1: Core Framework Patterns

Each section describes the pattern in general terms, then shows how the studied frameworks implement it.


1.1 The kernel

The pattern. Every framework has a central object that starts everything else. You can call it the kernel. If you understand the kernel, you understand the shape of the framework.

The kernel always does three things, in order:

  1. Collect — gather all modules, providers, or plugins.
  2. Wire — resolve dependencies and connect the pieces.
  3. Start — begin accepting requests.

Examples.

NestJS:       NestFactory.create() → NestApplication (packages/core/nest-factory.ts)
AdonisJS:     Ignitor.createApp() → Application (src/ignitor/http.ts)
H3:           createH3() → H3 instance (src/h3.ts)
Vinxi:        createApp() → App object (packages/vinxi/lib/app.js)
React Router: reactRouterVitePlugin() → Vite plugin array (vite/plugin.ts)

How each one maps to the three phases:

  • Collect: the NestJS scanner, AdonisJS providers, Vinxi routers.
  • Wire: the NestJS injector, the AdonisJS container, Vinxi plugin composition.
  • Start: Express listen, H3 fetch, the Nitro dev server.

Exercise: For any framework, find the kernel and trace these three phases. Draw a box diagram.


1.2 Inversion of control

The pattern. The framework calls your code. Your code does not call the framework's main loop. This is inversion of control (IoC), and every framework is built on it. It comes in three levels, from simple to complex.

Level 1: Callback-based

You pass a function. The framework calls it.

// H3
app.use('/api', (event) => {
  return { hello: 'world' }
})

Where: H3 handlers, Express middleware, Vite plugin hooks.

Level 2: Convention-based

The framework finds your code by its file location or name.

// AdonisJS — app/controllers/users_controller.ts is auto-loaded
export default class UsersController {
  async index() { return 'users list' }
}

Where: AdonisJS controllers, Nuxt pages/, TanStack routes/, file-based routing in general.

Level 3: Container-based (dependency injection)

The framework creates your objects and passes in what they need.

// NestJS — the framework creates and wires these objects
@Controller('/users')
class UserController {
  constructor(
    private userService: UserService,  // created and injected by the framework
    private logger: Logger,            // this too
  ) {}
}

Where: NestJS, AdonisJS (@adonisjs/fold).

Reading tip: If you see a class that is never created with new, a container manages it. To understand the wiring, find the container's resolve() (or equivalent) method.


1.3 The DI container

The pattern. A dependency injection container is a map from a token to a way of building a value, plus a resolver that builds dependencies recursively and caches the results. It works in three steps.

Step 1: Registration

A decorator or an explicit call records the class and how to build it.

@Service() decorator fires at class definition time
  → Reflect.defineMetadata(INJECTABLE_KEY, true, UserService)
  → Container.register('UserService', { metatype: UserService, scope: SINGLETON })

NestJS (packages/core/injector/module.ts:244):

addProvider(provider) {
  // Creates InstanceWrapper — the core unit that tracks each injectable
  const instanceWrapper = new InstanceWrapper({
    name: provider.name,
    metatype: provider,
    scope: getScope(provider),
    instance: null,        // Not created yet
    isResolved: false,     // Will be resolved later
  })
  this._providers.set(provider, instanceWrapper)
}

Step 2: Resolution

The container builds the object and everything it depends on.

Container.resolve('UserService')
  → Already resolved? Return the cached instance.
  → No: what does UserService need?
    → Read constructor params via Reflect.getMetadata('design:paramtypes', UserService)
    → Returns [Logger, DatabaseService]
    → Recursively resolve Logger, DatabaseService
    → Create new UserService(resolvedLogger, resolvedDB)
    → Cache the instance
    → Return it

NestJS (packages/core/injector/injector.ts:128-205):

async loadInstance(wrapper, collection, moduleRef) {
  // 1. Already pending? Wait for it (prevents double-instantiation)
  if (wrapper.isPending) {
    return wrapper.donePromise
  }

  // 2. Mark as pending
  const signal = this.applySettlementSignal(wrapper)

  // 3. Resolve all constructor dependencies
  const dependencies = await this.resolveConstructorParams(wrapper, moduleRef)

  // 4. Create the actual instance
  const instance = await this.instantiateClass(dependencies, wrapper)

  // 5. Mark as resolved
  wrapper.instance = instance
  wrapper.isResolved = true
  signal.complete()
}

AdonisJS resolves through async resolvers registered on the container. See Factory Method and the AdonisJS analysis.

Step 3: Scope

The scope decides how long an instance lives.

SINGLETON:  One instance per container (the usual default)
REQUEST:    One instance per HTTP request (often via AsyncLocalStorage)
TRANSIENT:  A new instance every time it is resolved

NestJS stores request-scoped instances in a WeakMap (instance-wrapper.ts:78):

private readonly values = new WeakMap<ContextId, InstancePerContext<T>>()
// When the request context is garbage-collected, so is the instance

Takeaway: A DI container is a Map<token, factory> with a recursive resolver and instance caching. The rest is ergonomics.


1.4 Module systems

The pattern. A module system answers one question: what code belongs together, and what does each group need from the others? The answer is usually a graph of modules that the kernel walks at startup.

NestJS: declarative modules

@Module({
  imports: [DatabaseModule],        // Dependencies from other modules
  controllers: [UserController],    // HTTP endpoints
  providers: [UserService],         // Business logic
  exports: [UserService],           // What other modules can use
})
class UserModule {}

How it works (packages/core/scanner.ts):

  1. The scanner reads @Module() metadata via Reflect.getMetadata().
  2. It follows imports recursively, building a graph.
  3. It calculates module distance (depth from the root) to order lifecycle hooks.
  4. Each module gets its own provider map, which isolates names.

AdonisJS: provider-based modules

export default class AppProvider {
  register() {
    // Phase 1: bind to container (no cross-provider access)
    this.app.container.singleton(UserService, () => new UserService())
  }
  boot() {
    // Phase 2: cross-provider wiring (all bindings available)
  }
  ready() {
    // Phase 3: app is running (start background tasks)
  }
  shutdown() {
    // Phase 4: cleanup
  }
}

Vinxi: router-based modules

createApp({
  routers: [
    { name: 'api', type: 'http', handler: './api.ts', base: '/api' },
    { name: 'web', type: 'client', handler: './app.tsx', base: '/' },
  ]
})
// Each "router" = a separate Vite instance and build target

1.5 Plugins, hooks, and adapters

The pattern. The framework decides when extension code runs. The extension decides what it does. This is the Strategy pattern applied to extensibility. It shows up in three common forms.

Hooks

Named events at fixed points in the lifecycle. Hooks run in registration order. Some are "waterfall" hooks, where each one receives the previous result.

// H3, Nuxt, and Vinxi use the Hookable library
app.hooks.hook('request', (event) => { /* before each request */ })
app.hooks.hook('error', (error) => { /* on error */ })

Under the hood this is an event emitter with ordered execution.

Plugin objects

An object with optional methods. The host calls each method at a known stage.

// Vite plugin
export function myPlugin(): Plugin {
  return {
    name: 'my-plugin',
    configureServer(server) { /* modify dev server */ },
    transform(code, id) { /* modify source code */ },
    resolveId(id) { /* resolve import paths */ },
    load(id) { /* generate virtual modules */ },
  }
}

Vite hook order:

config → configResolved → configureServer → buildStart
→ [per module: resolveId → load → transform]
→ handleHotUpdate (dev) / generateBundle (build) → writeBundle

Lifecycle interfaces and adapters

A class implements a fixed set of lifecycle methods, or wraps a lower-level library behind a common interface.

  • AdonisJS providers implement register, boot, ready, and shutdown (see 1.4).
  • NestJS lifecycle hooks (OnModuleInit, OnApplicationBootstrap, OnModuleDestroy, BeforeApplicationShutdown, OnApplicationShutdown) are interfaces that the framework calls in module-distance order.
  • NestJS platform adapters wrap Express or Fastify behind one interface (see Abstract Factory).

1.6 The middleware pipeline

The pattern. An HTTP framework passes each request through a chain of functions before the route handler runs. The frameworks differ in how they compose the chain. The model matters when you debug ordering problems.

Linear chain (Express)

Middleware is a flat array. Each function calls next() to pass control on.

req → helmet → cors → requestId → bodyParser → routeHandler → res
         ↓        ↓         ↓           ↓             ↓
       next()   next()    next()      next()       send()

Recursive chain (H3)

Each next() call recurses into the next middleware.

// H3's callMiddleware (src/middleware.ts:60-91)
function callMiddleware(event, middleware, handler, index = 0) {
  if (index === middleware.length) return handler(event)
  const next = () => callMiddleware(event, middleware, handler, index + 1)
  return middleware[index](event, next)
}

Promise-based chain (AdonisJS)

Middleware awaits next(), so it can run code both before and after the handler.

async handle(ctx: HttpContext, next: NextFn) {
  // Before handler
  await next()
  // After handler (response is ready)
}

Ordered by module (NestJS)

NestJS collects middleware per module and sorts it by module distance, with global middleware first.


1.7 The build pipeline and virtual modules

The pattern. Source code goes through several stages before it runs. Meta-frameworks add their own stages, most often by generating modules that do not exist on disk.

Source Code (.ts)
    ↓
[Decorator Metadata] → Reflect.defineMetadata() at import time
    ↓
[Bundler Transform] → Vite/esbuild/tsc compiles TS → JS
    ↓
[Tree Shaking] → Remove unused exports (React Router removes server code from client)
    ↓
[Code Splitting] → Split into chunks (route-based, ?pick= based)
    ↓
[Virtual Modules] → Generate modules that don't exist on disk
    ↓
[Output] → JS bundles ready for runtime

Virtual modules connect build-time analysis to runtime behavior. The plugin scans files at build time, then exposes the result as an importable module.

// Vite virtual module pattern
export function myPlugin(): Plugin {
  return {
    name: 'my-plugin',
    resolveId(id) {
      if (id === 'virtual:my-module') return '\0virtual:my-module'
    },
    load(id) {
      if (id === '\0virtual:my-module') {
        // Generate code dynamically
        return `export const modules = [${discovered.map(m => `() => import('${m}')`)}]`
      }
    }
  }
}

Examples. React Router generates three virtual modules (server build, server manifest, browser manifest). TanStack Start generates several (router entry, start entry, manifest). Vinxi generates per-router manifests.


1.8 Hot module replacement on the server

The pattern. When server code changes in development, the framework must run the new code. There are a few ways to do it, each with different trade-offs.

Strategy 1: Full restart (NestJS, AdonisJS)

File change → tear down the app (or kill the process) → re-bootstrap everything
Pros: Simple, always correct
Cons: Slower, loses all in-memory state

Strategy 2: Module graph invalidation (React Router, Nuxt, Vinxi, TanStack Start)

File change → Vite detects → invalidate module + importers
→ Next ssrLoadModule() returns fresh code → no restart needed
Pros: Fast, Vite handles the complexity
Cons: Stale closures are possible; memory leaks if not careful

Strategy 3: Dynamic handler swap (H3 / Nuxt)

// H3's dynamicEventHandler — atomic swap
const handler = dynamicEventHandler(initialHandler)
// Later, when code changes:
handler.set(newHandler) // Instant swap, no restart

An unexplored option: reactive container registrations

None of the studied frameworks use reactive proxies for backend objects. The idea is to wrap container registrations so that a changed module re-creates only the affected instances and notifies subscribers. Section 4.4 sketches it as an exercise.


Part 2: Reading a Codebase

These techniques work on any framework repository.

2.1 The package graph

In a monorepo, first learn what depends on what.

# For any pnpm monorepo, list workspace dependencies:
pnpm ls -r --depth 0 --json | jq '.[].dependencies'

# Or, in a Turbo repo:
npx turbo graph

The studied frameworks' package graphs:

NestJS:
  @nestjs/common → (standalone, decorators + interfaces)
  @nestjs/core → @nestjs/common (DI, scanner, lifecycle)
  @nestjs/platform-express → @nestjs/core + express

Nuxt:
  h3 → (standalone HTTP framework)
  nitro → h3 (server framework + deployment)
  @nuxt/vite → vite + nuxt (Vite integration)
  nuxt → @nuxt/vite + nitro + h3 (meta-framework)

Vinxi:
  vinxi → vite + nitropack + h3 + hookable (all-in-one)

React Router:
  react-router → (core routing)
  react-router-dev → react-router + vite (Vite plugin)
  react-router-express → react-router + express (adapter)
  react-router-node → react-router (Node.js runtime)

Exercise: Draw the package graph for a framework monorepo you use. Find the package that everything else depends on. Start reading there.

2.2 The import graph

Inside one package, learn which files import which.

# What a file imports:
grep -n "^import" packages/core/injector/injector.ts

# What imports a file:
grep -rn "from.*injector" packages/core/

# The most-imported files (the "hubs"):
grep -rn "^import.*from" packages/core/src/ | \
  sed "s/.*from ['\"]//;s/['\"].*//" | sort | uniq -c | sort -rn | head -20

The most-imported files are usually the core interfaces. Read them first.

2.3 The call graph

Technique: trace one request from start to finish.

NestJS:

1. Express receives request
2. NestApplication.use() registered middleware runs
3. RoutesResolver finds matching controller
4. GuardsConsumer checks guards
5. InterceptorsConsumer wraps execution
6. PipesConsumer validates/transforms params
7. Controller method executes
8. ExceptionFiltersConsumer catches errors
9. Response sent

H3:

1. H3Core.fetch(request) called
2. H3Event created from request
3. config.onRequest hook called
4. H3.handler(event) called
5. ~findRoute() matches URL
6. Global middleware collected
7. Route middleware collected
8. callMiddleware() recursive chain
9. Route handler executes
10. toResponse() converts result
11. config.onResponse hook called

How to trace it yourself:

# Method 1: grep for the entry function and follow the calls
grep -n "async handler" packages/core/nest-application.ts

# Method 2: add console.trace() at a known point and read the stack
# (for example, in an example app's controller)

# Method 3: use the Node.js inspector
node --inspect-brk ./dist/main.js
# Then open chrome://inspect and set breakpoints

2.4 The lifecycle graph

Every framework has a boot sequence. Write it down.

NestJS boot sequence (from nest-factory.ts):

1. Create container (empty)
2. Create scanner
3. scanner.scan(AppModule)
   3a. Recursively discover all modules
   3b. For each: extract providers, controllers, injectables
   3c. Calculate module distance (depth from root)
   3d. Bind global modules
4. instanceLoader.createInstances()
   4a. Create prototypes (empty shells)
   4b. Resolve and inject dependencies (topological order)
5. applyApplicationProviders() — global guards, pipes, filters
6. registerModules() — middleware, WebSockets
7. registerRouter() — mount routes on Express
8. callInitHook() — OnModuleInit (ascending distance)
9. callBootstrapHook() — OnApplicationBootstrap
10. listen() — start accepting requests

Exercise: Write the same list for another framework's startup path, for example AdonisJS's Ignitor (see the AdonisJS analysis). List every step.


Part 3: Classic Design Patterns in Frameworks

3.1 Creational patterns

Factory Method

What: Create objects without naming the exact class at the call site.

// NestJS: FactoryProvider
{ provide: 'CONFIG', useFactory: (configService) => configService.get('db'), inject: [ConfigService] }

// AdonisJS: container.singleton with a resolver
this.app.container.singleton(Server, async (resolver) => {
  const encryption = await resolver.make(Encryption)
  return new Server(this.app, encryption)
})

// Vite: plugin factory functions
export function reactRouterVitePlugin(): Plugin[] { return [...] }

Abstract Factory

What: Create families of related objects behind one interface.

// NestJS platform adapters — same interface, different implementations
NestFactory.create(AppModule, new ExpressAdapter())   // Express family
NestFactory.create(AppModule, new FastifyAdapter())   // Fastify family
// Both provide: listen(), reply(), registerMiddleware(), etc.

Singleton

What: Exactly one instance.

Where: The default scope in DI containers. The container itself acts as a registry of singletons.

// NestJS — providers are singletons unless a scope is set
@Injectable()
class DatabaseConnection {}

// AdonisJS — explicit singleton binding
this.app.container.singleton(Application, () => this.app)

Builder

What: Build a complex object step by step.

// NestJS MiddlewareBuilder
const builder = new MiddlewareBuilder(routesMapper, httpAdapter)
module.configure(builder)
builder
  .apply(CorsMiddleware, LoggerMiddleware)
  .exclude('/health')
  .forRoutes('*')
const config = builder.build()

3.2 Structural patterns

Proxy

What: A stand-in that controls access to another object.

// NestJS: NestApplication is wrapped in a Proxy (nest-factory.ts:262-279)
return new Proxy(app, {
  get: (target, property) => {
    // Catch exceptions during method calls
    // Handle async errors
    // Auto-flush logs on errors
  }
})

// Vinxi: the dev manifest is a lazy proxy
const manifest = new Proxy({}, {
  get(target, routerName) {
    return loadRouterManifest(routerName) // Lazy-loaded
  }
})

Adapter

What: Make incompatible interfaces work together.

// React Router: Express ↔ Fetch API adapter
function createRemixRequest(req: express.Request): Request {
  // Convert Express request to Web Fetch Request
  const url = `${protocol}://${host}${req.url}`
  return new Request(url, { method: req.method, headers: req.headers, body: ... })
}

function sendRemixResponse(res: express.Response, response: Response) {
  // Convert Web Fetch Response back to Express response
  res.status(response.status)
  response.headers.forEach((value, key) => res.setHeader(key, value))
  // Stream body...
}

Decorator (the structural pattern, not TypeScript decorators)

What: Add behavior to an object by wrapping it.

// NestJS: InstanceWrapper wraps every provider
class InstanceWrapper<T> {
  metatype: Type<T>        // The original class
  instance: T              // The created instance
  scope: Scope             // Added behavior: lifecycle management
  isResolved: boolean      // Added behavior: resolution tracking
  donePromise: Promise     // Added behavior: async coordination
}

Composite

What: A tree where single objects and groups are treated the same way.

// NestJS module tree — each module can import other modules
@Module({ imports: [UserModule, AuthModule] })  // AuthModule also imports UserModule
class AppModule {}
// The scanner walks this tree and deduplicates shared imports

// Vinxi: an App is a composite of routers; each router is a composite of plugins

Facade

What: A simple interface in front of a complex subsystem.

// NestJS: NestFactory.create() is a facade over:
//   - Container creation
//   - Scanner initialization
//   - Instance loading
//   - Application configuration
// You just call: const app = await NestFactory.create(AppModule)

// AdonisJS: Ignitor is a facade over the entire boot sequence

3.3 Behavioral patterns

Chain of Responsibility (middleware)

What: Pass a request along a chain of handlers.

This is the main pattern in HTTP frameworks. See 1.6.

Observer / Pub-Sub

What: An object notifies its subscribers when something changes.

// Vite HMR
server.hot.send({ type: 'custom', event: 'react-router:hmr', data })

// H3 hooks (via Hookable)
app.hooks.hook('request', handler)
app.hooks.callHook('request', event)

Strategy

What: Interchangeable algorithms behind one interface.

// Vinxi router modes — same interface, different strategies
const routerModes = {
  static: { dev: { handler(), plugins() }, build: { ... } },
  client: { dev: { handler(), plugins() }, build: { ... } },
  http:   { dev: { handler(), plugins() }, build: { ... } },
  spa:    { dev: { handler(), plugins() }, build: { ... } },
}
// resolveRouterConfig picks the right strategy by type

Template Method

What: A base class defines the steps of an algorithm. Subclasses fill in some steps.

// Vinxi BaseFileSystemRouter (lib/fs-router.js)
class BaseFileSystemRouter {
  async buildRoutes() {          // Template: defined
    const files = glob(this.config.dir)
    return files.map(f => this.toRoute(f))
  }
  toRoute(file) { /* defined */ }
  toPath(file) { /* ABSTRACT — subclass must implement */ }
}

// SolidStart extends this with its own toPath() logic

Command

What: Wrap a request as an object.

// AdonisJS Ace commands
export default class MakeController extends BaseCommand {
  static commandName = 'make:controller'
  static args = [{ name: 'name', required: true }]
  async run() { /* execute */ }
}

Most framework CLIs (generators, dev servers, build commands) follow this shape.

Mediator

What: A central object coordinates communication between other objects.

// NestJS container — mediates between all modules
// Modules do not talk to each other directly; they go through the container
container.resolve('UserService') // The container knows where it lives

// Vinxi App — mediates between all routers
app.getRouter('api') // The app knows all routers

3.4 Concurrency patterns

Barrier

What: Wait for several async operations to finish before going on.

// NestJS: Barrier in resolveConstructorParams (packages/core/helpers/barrier.ts)
// Ensures ALL constructor dependencies resolve before instantiation
const barrier = new Barrier()
barrier.add(dep1Promise, dep2Promise, dep3Promise)
await barrier.wait() // Only proceeds when all are ready

Settlement Signal

What: Track whether an async resolution is pending, done, or failed.

// NestJS: SettlementSignal in instance-wrapper.ts
// Prevents duplicate instantiation attempts
// Detects circular dependencies
const signal = new SettlementSignal()
wrapper.donePromise = signal.asPromise()
// ... resolve dependencies ...
signal.complete() // or signal.error(err)

AsyncLocalStorage

What: Storage that follows an async call chain, like thread-local storage in other languages.

// Used for request context and request-scoped DI
import { AsyncLocalStorage } from 'node:async_hooks'

const requestStore = new AsyncLocalStorage<RequestContext>()

// In middleware:
requestStore.run({ requestId, user, ... }, () => next())

// Anywhere in the call stack:
const ctx = requestStore.getStore() // Gets the current request's context

Part 4: Advanced Topics

4.1 Metaprogramming with reflect-metadata

This is how TypeScript decorators store type information and how containers read it back.

import 'reflect-metadata'

// STORING metadata (what decorators do)
function Service(): ClassDecorator {
  return (target) => {
    Reflect.defineMetadata('injectable', true, target)
  }
}

function Inject(token: string): ParameterDecorator {
  return (target, propertyKey, parameterIndex) => {
    const existing = Reflect.getMetadata('inject:params', target) || []
    existing[parameterIndex] = token
    Reflect.defineMetadata('inject:params', existing, target)
  }
}

// READING metadata (what the container does)
function resolve(target: any) {
  // Get constructor parameter types (TypeScript emits this)
  const paramTypes = Reflect.getMetadata('design:paramtypes', target) || []
  // Get custom injection tokens
  const injectTokens = Reflect.getMetadata('inject:params', target) || []

  // Merge: custom tokens override auto-detected types
  const deps = paramTypes.map((type, i) => injectTokens[i] || type)

  // Recursively resolve each dependency
  const resolved = deps.map(dep => resolve(dep))

  // Create the instance
  return new target(...resolved)
}

Reflect.getMetadata('design:paramtypes', MyClass) only works if:

  1. emitDecoratorMetadata: true is set in tsconfig.
  2. The class has at least one decorator.
  3. You use legacy (experimental) decorators. Stage 3 decorators do not emit this metadata.

4.2 Module graph theory

A module graph is a directed graph:

  • Nodes are files or modules.
  • Edges are imports.

Key operations:

Topological Sort:
  Given: A imports B, B imports C
  Order: C → B → A (dependencies first)
  Used by: NestJS instance loader, Vite build order

Cycle Detection:
  If A imports B and B imports A → circular dependency
  NestJS uses forwardRef() to handle this
  Vite uses lazy evaluation

Invalidation Walk:
  File C changed → find all importers of C (B, A) → mark all as stale
  Nuxt does this for HMR (vite-node.ts:157-180)

Reachability:
  Can module X reach module Y through imports?
  Used for: tree shaking, import protection (TanStack)

Exercise: Draw the module graph for an example app of a framework you use. Which module is the root? Which one has the most importers?

4.3 AST transformation

Frameworks that rewrite code (TanStack Start, React Router, Vinxi) parse it into an abstract syntax tree (AST), change the tree, and print it back.

// What TanStack Start does with createServerFn:

// Input:
const getUser = createServerFn().handler(async (ctx) => {
  return db.user.findUnique({ where: { id: ctx.data.id } })
})

// Output for the CLIENT:
const getUser = createClientRpc('getUser_abc123')
// Server code removed, replaced with an HTTP fetch stub

// Output for the SERVER:
const getUser = createServerRpc({ functionId: 'getUser_abc123' }, async (ctx) => {
  return db.user.findUnique({ where: { id: ctx.data.id } })
})

Tools:

  • Babel — mature; used by TanStack and Vinxi for transforms.
  • SWC — faster; used by NestJS for dev builds.
  • esbuild — used by Vite for transforms.
  • es-module-lexer — only parses imports and exports (used by Vinxi).

4.4 The proxy and reactive pattern

A JavaScript Proxy can intercept reads and writes. Reactive libraries use this to track who reads a value and to notify them when it changes. Vue's ref() is a well-known example. A simplified version:

// Vue's ref() — simplified
function ref<T>(value: T) {
  const subscribers = new Set<() => void>()

  return new Proxy({ value }, {
    get(target, key) {
      if (key === 'value') {
        // Track: who is reading this?
        if (activeEffect) subscribers.add(activeEffect)
        return target.value
      }
    },
    set(target, key, newValue) {
      if (key === 'value') {
        target.value = newValue
        // Trigger: notify everyone who read this
        subscribers.forEach(fn => fn())
        return true
      }
      return false
    }
  })
}

// Usage
const count = ref(0)
effect(() => {
  console.log(count.value) // Automatically subscribes
})
count.value = 1 // Automatically triggers the effect

Exercise: a reactive container registration. None of the studied frameworks apply this to backend objects. As an exercise, wrap a DI registration so it can be re-created and can notify subscribers:

function reactiveRegistration(token, factory) {
  const subscribers = new Set<(newInstance: any) => void>()
  let instance = null

  return {
    resolve() {
      if (!instance) instance = factory()
      return instance
    },
    invalidate() {
      instance = null              // Clear cached instance
      instance = factory()         // Re-create
      subscribers.forEach(fn => fn(instance))  // Notify
    },
    subscribe(fn) {
      subscribers.add(fn)
      return () => subscribers.delete(fn)
    }
  }
}

Think about what breaks: other objects that already hold the old instance, open connections, and timers.


Part 5: Algorithms and Data Structures

Used constantly in frameworks

Concept Where it is used Learn it by
Hash Map DI container (token → instance), module registry Implementing a simple DI container
DAG (directed acyclic graph) Module dependencies, import graph Implementing topological sort
Tree traversal (BFS/DFS) Module scanning, middleware chain, component tree Tracing NestJS's scanner
Topological sort Build order, lifecycle hook order, dependency resolution Computing NestJS-style module distance
Trie / radix tree URL routing (H3's rou3, Express's path-to-regexp) Building a simple URL router
Observer pattern Event emitters, HMR, reactive state Building a simple EventEmitter
WeakMap / WeakRef Request-scoped instances, caches with automatic GC Reading NestJS's context management
Proxy / Reflect Reactive state, lazy loading, validation Building Vue's ref() from scratch

Common in build tools

Concept Where it is used
AST parsing Code transformation (Babel, SWC), tree shaking
Regular expressions Route matching, file pattern matching
Streaming / backpressure SSR rendering, file upload, response streaming
Concurrent queue Build orchestration, parallel test execution
LRU cache Module caching, template caching
Binary protocol Nuxt's vite-node IPC (4-byte length + JSON)
Glob matching File discovery, route generation
Semver Version resolution, dependency management

Capstone exercise: build a small framework

Build these pieces, in this order:

1. HTTP server that matches routes (Trie/Map)
2. Middleware chain (Chain of Responsibility)
3. DI container with @Service/@Inject (reflect-metadata + Map + recursive resolve)
4. Module system with @Module (DAG + topological sort)
5. Lifecycle hooks (Observer pattern)
6. Vite plugin that generates a virtual module from decorators
7. HMR via ssrLoadModule() in the Vite plugin
8. Reactive proxy on the container that notifies on change (see 4.4)

Steps 1 to 7 cover the same core parts as the frameworks studied here. Step 8 goes beyond them.


Part 6: Study Order

Phase 1: Foundations (2–4 weeks)

  • Read the H3 source (small — about 1000 lines in total)
  • Read the Vite Plugin API docs end to end
  • Build steps 1–3 of the capstone exercise
  • Open two documentation PRs to any project

Phase 2: DI and modules (2–4 weeks)

  • Read NestJS packages/core/injector/ (the whole directory)
  • Read AdonisJS providers/app_provider.ts
  • Build steps 4–5
  • Fix two "good first issue" bugs

Phase 3: Build tools and HMR (2–4 weeks)

  • Read React Router vite/plugin.ts (all ~3900 lines)
  • Read Vinxi lib/dev-server.js and lib/router-modes.js
  • Build steps 6–7
  • Propose a small feature to one project

Phase 4: Advanced (ongoing)

  • Read Nuxt packages/vite/src/vite-node.ts (the IPC protocol)
  • Read TanStack start-compiler-plugin/ (AST transformation)
  • Build step 8 (reactive container)
  • Take ownership of one area in one project

Part 7: Contributing to Framework Projects

Reading framework source is the first step toward contributing to it. This section collects practical advice for that path.

7.1 The contribution ladder

Level 0: USER
  → Use the framework, file issues, answer questions on Discord/GitHub

Level 1: CONTRIBUTOR (3-6 months)
  → Fix typos, improve docs, add tests
  → Fix bugs from "good first issue" labels
  → Review other people's PRs (even without merge access)

Level 2: REGULAR CONTRIBUTOR (6-12 months)
  → Own a specific area (e.g., "the CLI", "the Vite plugin", "the auth module")
  → Propose and implement features with RFCs
  → Triage issues (label, reproduce, close duplicates)
  → Mentor new contributors

Level 3: COLLABORATOR (12-18 months)
  → Get write access to the repo
  → Review and merge PRs
  → Participate in architecture decisions
  → Release management

Level 4: MAINTAINER (18+ months)
  → Set project direction
  → Make breaking change decisions
  → Manage the community
  → Onboard new collaborators

7.2 Practical steps

Weeks 1–2: learn the project as a user

# Build the project from source
git clone <repo> && pnpm install && pnpm build && pnpm test

# Read CONTRIBUTING.md, CODE_OF_CONDUCT.md, ARCHITECTURE.md
# Read the last 50 merged PRs to see what is happening

# Join the community Discord/Slack
# Read without posting for a week to understand the culture

Weeks 3–4: first contributions

Target: 3-5 small PRs that get merged

Good targets:
1. Fix a typo in docs
2. Add a missing test case
3. Improve an error message
4. Fix a "good first issue" bug
5. Add type annotations to untyped code

Too early:
- "I refactored the whole module system"
- "I added a feature nobody asked for"
- "I changed the code style to my preference"

Months 2–3: build trust

- Respond to issues consistently (reproduce bugs, suggest fixes)
- Review PRs carefully (point out edge cases, not just "LGTM")
- Write about something you learned from the codebase
- Propose a small feature in an issue BEFORE writing code

Months 4–6: own an area

Pick ONE area of the codebase and learn it deeply:
- "I know every line of the CLI"
- "I maintain the Prisma adapter"
- "I wrote the test infrastructure"

This is what usually leads to collaborator access.

7.3 What maintainers look for

Do:
  - Write tests for everything you touch
  - Follow existing patterns, even if you disagree
  - Communicate before coding (issue → discussion → PR)
  - Be patient with review cycles
  - Help other contributors (answer questions, review PRs)
  - Accept feedback calmly

Don't:
  - Send large unsolicited refactoring PRs
  - Argue about code style
  - Demand your PR be merged quickly
  - Break existing tests without explanation
  - Add dependencies without discussion
  - Disappear after starting something

7.4 A typical PR workflow

# 1. Fork and clone
gh repo fork <org>/<repo> --clone

# 2. Create a branch from the latest main
git checkout main && git pull upstream main
git checkout -b fix/issue-123-description

# 3. Make changes and tests
# (run the full test suite before pushing)
pnpm test

# 4. Commit with a conventional message
git commit -m "fix(cli): handle spaces in module names (#123)"

# 5. Push and open a PR
git push -u origin fix/issue-123-description
gh pr create --base main

# 6. Address review feedback
# (push new commits; don't force-push during review)
git commit -m "address review: add edge case test"
git push

# 7. After merge, clean up
git checkout main && git pull upstream main
git branch -d fix/issue-123-description

7.5 Where to start in each studied project

Project Good first area Why Community
NestJS packages/common/decorators/ Small, self-contained files discord.gg/nestjs
Nuxt packages/nuxt/src/core/ Well documented, typed discord.nuxt.dev
React Router packages/react-router/lib/ Pure logic, no build tool dependencies discord.gg/reactrouter
AdonisJS Any addon package Isolated, clear interfaces discord.gg/adonisjs
TanStack packages/router-generator/ Code generation, easy to test tlinz.com/discord
Vinxi Test coverage (currently low) High impact; you learn by testing —
H3 src/utils/ Small utility functions discord.gg/unjs

About

Learn the patterns JavaScript and TypeScript frameworks are built from, by reading real framework source.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors