-
Notifications
You must be signed in to change notification settings - Fork 2
Rapid Modules
How to build an app out of decorated classes: the decorators and binders,
the RapidModule tier with its events, invoke and lifecycle, and how
app.modules() boots it all. Everything here is metadata plus one
registration core: a decorated method is still a plain method you can call
in a test with new.
-
Two tiers. Any class with
@GET/@SOCKET/@JOBmethods mounts withapp.module(new Users()). A class extendingRapidModuleadditionally getsname/namespaceidentity, declaredevents,this.log,this.config,this.emit(),this.invoke(),init()/dispose()hooks, and boots withapp.modules({ modules: [namespaces] }). -
Decorators never wrap. They record metadata by method name in the
class's
Symbol.metadata; the method is untouched. -
Binders type the signature.
@GET('/:id:', { bind: [param('id'), payload(Schema)] })givesfind(id: string, body: Body). Without a validatorparamisstringandpayloadisunknown. -
Boot once, before start.
app.modules()constructs zero-argument modules (or dispenses ones doctor knows), mounts every decorated instance, wires@Onsubscriptions, runsinit()in mount order;stop()disposes in reverse. -
Modules never import each other. They
emitdeclared events and, when a synchronous answer is needed,invokea target through the runtime.
import { Application } from '@tundralibs/rapid';
import {
GET,
Module,
param,
payload,
POST,
} from '@tundralibs/rapid/decorators';
import type { RapidContextResponse } from '@tundralibs/rapid';
@Module('Users', { prefix: '/users' })
class Users {
@GET('/:id:', { bind: [param('id')] })
find(id: string): RapidContextResponse {
return { content: { id } };
}
@POST('/', { bind: [payload()] })
create(body: unknown): RapidContextResponse {
return { status: 201, content: { created: body } };
}
}
const app = await Application.initialize({ name: 'plain-tier' });
app.module(new Users()); // an instance you built — DI or not, rapid does not careapp.module(...instances) walks each instance's prototype chain, reads the
decorations, and registers them through the same core as app.get() /
app.socket() / app.job(). Nothing is global: two apps never see each
other's classes, and a second import() of the same file is a cache hit.
| Decorator | Records |
|---|---|
@GET/@POST/@PUT/@PATCH/@DELETE(path, options?) |
An HTTP route. options: bind, version, summary, description, tags, operationId, security, response, paging, apiOnly, uiOnly, template, layout, middleware. |
@SOCKET(command, { bind?, middleware? }) |
A websocket command. The name is joined with the module namespace: ns.command. |
@JOB(name, schedule, { bind?, args? }) |
A cron job (5-field schedule, validated at decoration). Name joined as ns.name; args are the registration defaults for ctx.args.params. |
@Module(name?, options?) |
Class metadata: prefix (HTTP paths only), namespace (sockets and jobs), version, description, tags, security, layout, middleware. |
@On(...events) |
Subscribe a method to declared events ('ns:Module:Event'), RapidModule only. |
@Use(...middleware) |
Guard module-to-module invoke() of this method, RapidModule only. Never runs for a transport request. |
Decorators stack: one method may be @GET and @JOB at once. Ordering of
rapid decorators relative to third-party wrapping decorators does not
matter — the route binds whatever function ends up installed under the
method name. A subclass that overrides a decorated method must re-decorate
it, or mounting fails loudly rather than binding a route to a method the
instance no longer runs.
response deserves a note: give it a schema that can parse and
DEVELOPMENT mode enforces it — a success reply whose content fails the
declared shape is a loud RAPID_RESPONSE_INVALID instead of a response the
docs lie about. PRODUCTION never runs the check.
A decorated route takes the same route-scoped chain a plain
app.get(path, ...middleware, handler) does — middleware on the route
decorator for one route, middleware on @Module for every HTTP route and
socket command in the class. Order inside the app onion: app-wide use()
first, then the module's chain, then the route's, then the handler.
import { GET, Module, param } from '@tundralibs/rapid/decorators';
import type { RapidContextResponse, RapidMiddleware } from '@tundralibs/rapid';
// e.g. from pactAuth(pact) — the catalog's middlewares are all universal
declare const authenticate: RapidMiddleware;
declare const authorize: (
module: string,
permission: string,
) => RapidMiddleware;
@Module('Admin', {
prefix: '/admin',
middleware: [authenticate], // every route and command in the class
})
class Admin {
@GET('/users/:id:', {
bind: [param('id')],
middleware: [authorize('Admin', 'READ')], // this route only
security: ['bearerAuth'], // documents what the guard enforces
})
user(id: string): RapidContextResponse {
return { content: { id } };
}
}Entries are checked at decoration time — a non-function (a factory you forgot
to call) fails at import as RAPID_CONFIG naming the decorator and index.
The chain is HTTP-typed on @GET/…, socket-typed on @SOCKET, and universal
on @Module — a universal middleware (every catalog middleware, anything
from pactAuth) fits all three. Jobs are
not covered: @JOB has no per-registration chain, only the app-wide
use() scoped with onlyJOB. Middleware here is behaviour; the OpenAPI
security option is documentation — declare both.
bind is a tuple in method-parameter order; its element types become the
parameter types.
| Binder | Yields | Notes |
|---|---|---|
param(name, validate?) |
ctx.args.params[name] — string without a validator |
A present, non-string value with no validator is a 400 at invocation. |
payload(schemaOrValidate?) |
await ctx.payload — unknown without a validator |
A schema object (anything with .parse) also documents the request body in OpenAPI. |
query(validate?) |
ctx.args.query ({ filters, sorting }) |
Untrusted as-is; the validator is where the allow-list lives. Not emitted into OpenAPI. |
paging() |
ctx.args.paging |
Always valid (clamped). |
header(name, validate?) |
the header on HTTP, the upgrade header on SOCKET, null on JOB |
Emitted as an optional header parameter in OpenAPI. |
cookie(name, validate?) |
the cookie on HTTP, null elsewhere |
|
auth(validate?) |
ctx.auth |
undefined until an auth middleware set it. |
session() |
await getSession(ctx) — HTTP with session() installed, else undefined
|
Exported from @tundralibs/rapid/decorators only (the root's session is the middleware). |
connection() |
ctx.connection |
@SOCKET methods only — anything else is RAPID_CONFIG at mount. |
config('set.key') |
ctx.config.get(path) on any transport |
Set = lowercased file name, keys case-sensitive; a missing path binds undefined. |
Validation rule: a @tundralibs/guardian failure is automatically a 400
with per-field messages; any other validator's throw is a 500 unless wrapped
in validated(). A RapidError you throw is used as-is.
import { event, RapidModule, reply } from '@tundralibs/rapid/modules';
import {
GET,
On,
param,
payload,
POST,
Use,
} from '@tundralibs/rapid/decorators';
import type {
EventContext,
RapidModuleInvokeMiddleware,
} from '@tundralibs/rapid/modules';
const EVENTS = { PostCreated: event<{ id: string }>() };
function mustBeInternal(
...[ctx, next]: Parameters<RapidModuleInvokeMiddleware>
): ReturnType<RapidModuleInvokeMiddleware> {
if (ctx.auth === undefined) {
ctx.response = reply(403, { reason: 'internal callers only' });
return;
}
return next(); // ALWAYS return next() — a bare `next();` detaches
}
export class Posts extends RapidModule<typeof EVENTS> {
readonly name = 'Posts';
readonly namespace = 'blog';
protected readonly events = EVENTS;
@GET('/posts/:id:', { bind: [param('id')] })
get(id: string) {
return { content: { id, title: 'hello' } };
}
@POST('/posts', { bind: [payload()] })
async create(body: unknown) {
const id = 'p1';
await this.emit('PostCreated', { id }); // awaited: subscribers settled before we reply
return reply(201, { id, body });
}
@Use(mustBeInternal)
count() {
return { content: { total: 1 } };
}
init() {
this.log.info('posts ready');
}
}
export class Audit extends RapidModule {
readonly name = 'Audit';
readonly namespace = 'blog';
protected readonly events = {};
@On('blog:Posts:PostCreated')
onCreated(payload: { id: string }, ctx: EventContext) {
this.log.info('post created', { id: payload.id, requestId: ctx.requestId });
}
}-
Identity.
nameis PascalCase and unique within itsnamespace(kebab-case). Events are fully qualified asnamespace:Name:Eventand every subscription is checked against the declared set when the runtime finalizes — a typo fails the boot, not silently never fires. -
eventsis a const map ofevent<T>()markers (pure type carriers). The class generic (RapidModule<typeof EVENTS>) is what typesemit; a module that emits nothing omits both. -
emit(name, payload)resolves when every subscriber has settled. Await it for in-request consistency, or fire and forget — either way a throwing subscriber is isolated and logged, and never affects the emitter or the other subscribers. Subscribers get correlation only (requestId,action,event) — no state and no auth (an event carries no authority). -
invoke(Target, 'method', args)calls another module through the runtime: the target's@Useguards run, a copy of the caller's state and the caller's auth flow, and the outcome is an envelope — a denied guard is a 403 reply, not a throw. Prefer events; reach forinvokewhen you need an answer. -
reply(status, content)is the explicit envelope. A plain return is 200 with that value as content;undefinedis 204; a domain object that happens to have acontentkey stays content — the runtime never guesses. -
init()/dispose()are optional (duck-typed, nooverrideneeded):initruns once after every module is mounted, in mount order;disposeruns on shutdown in reverse. Aninitthat throws rolls back the ones already initialised. -
this.logcarriesmodule: 'ns:Name'and the request id;this.configis the app's other config sets.
All of these throw RAPID_CONFIG if used before the module is mounted; a
unit test that needs them boots a harness() (see
Testing).
import { Application } from '@tundralibs/rapid';
import * as blog from './modules/mod.ts'; // the barrel `rapid modules` generates
const app = await Application.initialize('./configs');
const { modules, runtime } = await app.modules({ modules: [blog] });
modules.Posts.count(); // typed by export name
await app.start();- Pass module namespaces (the result of
import * as); every export whose prototype descends fromRapidModuleis constructed with zero arguments, or dispensed from the app's doctor container when the class is registered there. A class with required constructor parameters fails loudly — hand it over pre-built underinstances: { Posts: new Posts(db) }instead. A field initialiserinject(Label)in an unregistered module resolves against the app's container. - Call it once, before
start()or the firstfetch(). A second call isRAPID_CONFIG. Mounting failures dispose what was built before rethrowing. -
app.moduleRuntimeexposes theModuleRuntime(invoke,emit,drain,modules,declaredEvents);stop()disposes it. - The
rapid modulesCLI command regeneratesmodules/mod.tsfrom the files in the directory (sorted,export abstract classskipped, duplicate class names refused);--checkfails CI when the barrel is stale.
initModules(context, sources, container?) from @tundralibs/rapid/modules
is the same boot without an Application — for a worker, a CLI or a test
that only needs the module system. buildModuleContext(options) builds the
standalone context it takes.
-
@Module({ prefix: '/users' })joins onto HTTP paths only; slashes are normalised. A prefix may carry params (/tenants/:tid:) that any method binds withparam('tid'). -
Several prefixes mount the class's whole route table once per entry. The tenant-optional shape is one declaration, not a second class and not a repeated decorator:
@Module('Users', { prefix: ['', '/:orgCode:'] }) class Users { @GET('/users') list() {/* … */} } // GET /users and GET /:orgCode:/users
A route path takes a list on the same terms, and the two multiply — two prefixes and two paths mount four routes:
@GET(['/users/:id:', '/u/:id:'], { bind: [param('id')] }) one(id: string) { /* … */ }
The handler is shared, so read the tenant defensively: on the unprefixed path
params.orgCodeis simply absent. An empty list, a repeated entry, or a prefix not starting with/fails at decoration time. -
Multi-path methods get path-derived operation ids in the OpenAPI document (
Users_list_users,Users_list_orgCode_users), because the spec requires operation ids to be unique. A method serving ONE route keeps the plainModule_methodid. The suffix comes from the path rather than a counter, so reordering the list never renames an operation. -
namespace(theRapidModulefield, or@Module('Name', { namespace })on a plain class) dots onto socket command and job names:blog.posts.get. -
@Module({ version })is the default version for the class's routes;@GET(path, { version })overrides;server.versioningdecides where a request declares its version.
A module's name is its routes' default tag, its namespace the tag group,
its description the tag's; a route's summary, description, tags,
operationId (default <Module>_<method>), security (['bearerAuth'];
[] marks it deliberately public) and response describe the operation;
payload(Schema) with a schema object documents the request body. Serve the
document with the openapi() endpoint and the reference page with docs() —
see OpenAPI and the API reference.
-
Registering a module after
start()throws. Boot everything first. -
@Useis not route middleware. It guards module-to-moduleinvoke()and never runs for a request. Guard a decorated route with themiddlewareoption on the route decorator or on@Module. -
Constructor autobinding (
this.find = this.find.bind(this)) installs an own property that shadows the decorated prototype method — mounting refuses it with a message naming the method. -
A bare
next();in an invoke middleware detaches the call: the invocation finishes before the method does. Alwaysreturn next(). -
Returning without
next()and without a response from an invoke guard is a 204 success envelope, not a denial. Setctx.response = reply(403, …)to deny. -
Harness config is empty. Under
harness()a module'sthis.configis an emptyConfig— stub the dependency that needs configuration instead.