Architecture Internals
How Rangka boots, resolves metadata, serves APIs, and renders the frontend shell.
Boot Sequence
The framework starts from the CLI and follows this path:
CLI start/dev command
→ ProjectScanner.scan(appRoot)
→ boot(options)
→ dependencySort(apps)
→ loadSchemas() + mergeSchemas()
→ SchemaRegistry (immutable)
→ HookRegistry, JobRegistry, ServiceRegistry, PermissionRegistry, ScopeRegistry
→ DatabaseClient → autoSync() → seedCoreData()
→ createServer() → generateRoutes()
→ PluginLifecycleManager → loadPlugins()
→ BootResult (registries + Fastify server)1. Project Scanning
ProjectScanner (packages/core/src/boot/) performs filesystem discovery from the app root:
| Path pattern | What it loads |
|---|---|
rangka.config.ts | Database and server settings |
modules/*/module.ts | Module configs |
modules/*/models/*.ts | Model definitions |
modules/*/hooks/*.ts | Lifecycle hooks |
modules/*/roles.ts | Permission roles |
modules/*/services/*.ts | Injectable services |
modules/*/jobs/*.ts | Background jobs |
modules/*/fixtures/*.ts | Seed data |
modules/*/pages/*.ts | Page definitions |
extensions/*.ts | Field extensions |
For multi-app setups, NodeModulesDiscoverySource finds dependent Rangka apps in node_modules. MemoryDiscoverySource is used in tests.
2. Schema Resolution
After scanning, boot() calls:
dependencySort()— resolves app load order fromdepends[]declarationsloadSchemas()— imports all model schema filesmergeSchemas()— applies extensions and overrides across apps
The result is an immutable SchemaRegistry with methods:
getModel(qualifiedName)— returnsResolvedModelwith fields + relationshipsgetAllModels()— all models across all appsgetRelationships()/getRelationshipsForModel()— relationship graph
3. Registry Initialization
After the schema is resolved, boot creates:
| Registry | Source | Purpose |
|---|---|---|
HookRegistry | hooks/*.ts per module | validate/before/after CRUD lifecycle |
ServiceRegistry | services/*.ts | Injectable business logic with DI |
JobRegistry | jobs/*.ts | Background jobs |
EventBus | Created at boot | Transaction-scoped pub/sub |
PermissionRegistry | roles.ts per module | Role-based model/field permissions |
ScopeRegistry | Scope definitions | Row-level tenant isolation |
FixtureRegistry | fixtures/*.ts | Seed data |
AdapterRegistry | Plugin definitions | External data source adapters |
4. Database Initialization
DatabaseClient wraps Kysely with the configured dialect (PostgreSQL or SQLite):
SchemaToDesired()builds target DDL from resolved modelsintrospect()reads current database stateDiffEnginecompares actual vs desired, produces migration operationsautoSync()applies non-destructive migrations in developmentseedCoreData()initializescore.user,core.role,core.sessiontables
5. API Server
createServer() returns a Fastify instance with OpenAPI 3.1:
generateRoutes()mounts CRUD endpoints for every model- Request pipeline: auth hook → permission guard → scope hook → field write guard → handler → field strip hook
- Handlers use
withHooksCreate/Update/Deletefromhooks/middleware.tsfor mutations listHandler/getHandlerfromapi/handlers.tsfor reads
6. Meta Handler
GET /api/meta/boot serves the boot payload to the client:
- Validates session token
- Loads user permissions
- Filters pages by access
- Builds navigation tree
- Collects model metadata (fields, relationships)
- Returns
BootPayload:{ user, permissions, navigation, pages, models }
FrameworkContext
The single context object passed to all hooks, services, and jobs:
interface FrameworkContext {
db: Kysely<unknown>; // transaction-scoped connection
schema: SchemaRegistryInterface; // model/field/relationship lookups
scope: unknown; // current tenant/scope value
models: ModelAccessInterface; // CRUD with scopes + permissions
events: { emit; on }; // transaction-scoped pub/sub
auth: { user; roles }; // current user identity
config: Record<string, unknown>; // app configuration
service: (name) => ServiceInstance; // call other services
enqueue: (job, data, opts?) => Promise<void>; // background jobs
notify: (channel, message) => void;
email: { send: (template, options) => Promise<void> };
}Built by createHookContext() in hooks/context.ts. All data access in hooks/services goes through ctx.models.
Model Access Layer
createModelAccess() in model-api/index.ts provides the CRUD API:
models.get(model, id)— fetch single recordmodels.query(model)— fluent query with filter/sort/paginate/includemodels.create(model, data)— insert with scope enforcementmodels.update(model, id, data)— update with scope enforcementmodels.delete(model, id)— delete with scope enforcement
The query builder (ModelQueryBuilder) handles scope enforcement, field-level access, filter translation, and relationship includes automatically.
Hook Pipeline
Hooks run inside a transaction via hooks/executor.ts:
validate— can throw to reject the operationbeforeCreate/beforeUpdate/beforeSave— mutate data before write- (database write)
afterCreate/afterUpdate/afterSave— side effectsbeforeDelete→ (delete) →afterDelete
hooks/middleware.ts wraps this pipeline into Fastify request handlers (withHooksCreate, withHooksUpdate, withHooksDelete).
Frontend Shell
The client (packages/client/) consumes the boot payload and builds the UI:
App
→ BootProvider
→ useBoot() state machine: checking → login → loading → ready → error
→ BootGate (renders only when ready)
→ ShellProviders (MetaContext, UserContext, PermissionsContext)
→ QueryProvider (TanStack React Query)
→ RouterProvider (TanStack Router)
→ PageOutlet → WidgetRendererWidget System
The client renders pages as widget trees. Each WidgetNode (from the server page definition) is rendered by WidgetRenderer:
- Resolve visibility conditions (
useCondition) - Resolve binding (
useBind) — connects widget to data via field/expression/model - Resolve triggers (
useTriggerHandlers) — maps actions to handler functions - Resolve props (expression interpolation, route params)
- Render the widget component with
WidgetProps
All widgets receive the same WidgetProps interface. Input widgets read from bind.value and write through bind.setValue. The FormContext integrates transparently through useBind.
Data Layer
useModelRecord(model, id)— fetch single record (TanStack Query)useModelQuery(model, options)— fetch list with magic variables ($filter, $sort, $page)useSource()/useRecord()/useMutation()— shell-level data hooks- All backed by TanStack React Query with cache invalidation
Studio
Rangka Studio is an AI-powered development environment:
studio-local (React UI)
↕ WebSocket
studio-core (Node.js server)
→ RuntimeManager (boots @rangka/core, introspects state)
→ AgentEngine (AI agent sessions)
→ FileWatcher (project file changes → runtime reboot)Protocol messages are typed in studio-core/src/protocol.ts. The runtime manager provides introspection without duplicating core logic.
Cross-Package Rules
These rules prevent architectural drift:
- All data access in hooks/services goes through
ctx.models(never raw Kysely for CRUD) - All widgets receive
WidgetPropsunchanged (never add custom React props) - All widgets read data through
useBind(never bypass with direct context access) - All shared types live in
@rangka/shared(never define cross-package types locally) - All registries are singletons created at boot (never duplicate their purpose)
- All CRUD routes are auto-generated (never manually duplicate the pattern)
- All protocol messages are typed in
studio-core/protocol.ts(never redefine in studio-local)
When modifying a shared interface:
- Update
@rangka/sharedfirst - Rebuild:
pnpm --filter @rangka/shared build - Fix all downstream consumers
- Run
pnpm buildto verify the full monorepo compiles