A study of the patterns that JavaScript and TypeScript frameworks are built from, learned by reading real framework source.
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.
- 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.
- Read the pattern sections in this README in order. Each one explains the idea, then shows examples.
- When a pattern interests you, open the matching framework analysis below to see it in context.
- Do the exercises. They ask you to find the same pattern in another codebase.
- Read SYNTHESIS.md for a side-by-side comparison of the studied frameworks.
- 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.
Core framework patterns (Part 1)
- 1.1 The kernel
- 1.2 Inversion of control
- 1.3 The DI container
- 1.4 Module systems
- 1.5 Plugins, hooks, and adapters
- 1.6 The middleware pipeline
- 1.7 The build pipeline and virtual modules
- 1.8 Hot module replacement on the server
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)
- 4.1 Metaprogramming with reflect-metadata
- 4.2 Module graph theory
- 4.3 AST transformation
- 4.4 The proxy and reactive pattern
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.
| 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. |
Each section describes the pattern in general terms, then shows how the studied frameworks implement it.
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:
- Collect — gather all modules, providers, or plugins.
- Wire — resolve dependencies and connect the pieces.
- 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, H3fetch, the Nitro dev server.
Exercise: For any framework, find the kernel and trace these three phases. Draw a box diagram.
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.
You pass a function. The framework calls it.
// H3
app.use('/api', (event) => {
return { hello: 'world' }
})Where: H3 handlers, Express middleware, Vite plugin hooks.
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.
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.
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.
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)
}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.
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 instanceTakeaway: A DI container is a Map<token, factory> with a recursive resolver and instance caching. The rest is ergonomics.
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.
@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):
- The scanner reads
@Module()metadata viaReflect.getMetadata(). - It follows
importsrecursively, building a graph. - It calculates module distance (depth from the root) to order lifecycle hooks.
- Each module gets its own provider map, which isolates names.
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
}
}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 targetThe 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.
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.
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
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, andshutdown(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).
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.
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()
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)
}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)
}NestJS collects middleware per module and sorts it by module distance, with global middleware first.
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.
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.
File change → tear down the app (or kill the process) → re-bootstrap everything
Pros: Simple, always correct
Cons: Slower, loses all in-memory state
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
// H3's dynamicEventHandler — atomic swap
const handler = dynamicEventHandler(initialHandler)
// Later, when code changes:
handler.set(newHandler) // Instant swap, no restartNone 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.
These techniques work on any framework repository.
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 graphThe 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.
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 -20The most-imported files are usually the core interfaces. Read them first.
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 breakpointsEvery 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.
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 [...] }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.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)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()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
}
})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...
}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
}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 pluginsWhat: 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 sequenceWhat: Pass a request along a chain of handlers.
This is the main pattern in HTTP frameworks. See 1.6.
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)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 typeWhat: 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() logicWhat: 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.
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 routersWhat: 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 readyWhat: 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)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 contextThis 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:
emitDecoratorMetadata: trueis set intsconfig.- The class has at least one decorator.
- You use legacy (experimental) decorators. Stage 3 decorators do not emit this metadata.
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?
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).
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 effectExercise: 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.
| 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 |
| 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 |
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.
- 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
- Read NestJS
packages/core/injector/(the whole directory) - Read AdonisJS
providers/app_provider.ts - Build steps 4–5
- Fix two "good first issue" bugs
- Read React Router
vite/plugin.ts(all ~3900 lines) - Read Vinxi
lib/dev-server.jsandlib/router-modes.js - Build steps 6–7
- Propose a small feature to one project
- 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
Reading framework source is the first step toward contributing to it. This section collects practical advice for that path.
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
# 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 cultureTarget: 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"
- 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
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.
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
# 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| 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 |