dunx live demo
reading status

A walkthrough of dunx, against a live process

This is examples/full from the dunx repository, running unmodified on a Raspberry Pi 5 behind a Cloudflare tunnel. Each step below explains one capability, shows the few lines that declare it, and has a button that calls the real route: the same one the example's tests and OpenAPI document are generated from.

It starts with a single request and a single class, and ends with work that leaves the request entirely: a queue, a broker and a process watching itself.

    Use the arrow keys, or the contents, to move around. The database is in-memory and reseeds on every restart, so anything created here is temporary.

    Dependency injection

    A class asks for what it needs in its constructor, and that is the whole declaration. No @Injectable(), no @Inject(), no reflect-metadata: @dunx/transform reads the parameter types at load time and the container resolves them.

    The source below is read off disk with Bun.file at /api/demo/source/:name, so it is the file this process was built from.

    
              

    Validation

    One zod schema per route validates the request and types the handler's input. A rejected body answers 400 with the issues zod raised, before the handler runs.

    export const createUser = {
      body: CreateUser,
      response: { 201: User },
    };
    
    @Post('/', createUser)
    create({ body }: Input<typeof createUser>) { ... }
    
            

    Two API explorers, one document

    The same schemas generate the OpenAPI document, so nothing is written twice. Swagger UI is at /api/docs and Scalar at /api/reference, both reading one openapi.json. Each is its own subpath and optional peer, and both self-host.

    
            

    API versioning

    Two controllers on one path, told apart by version. With versioning: { type: 'uri' } they serve /api/v1/swatches and /api/v2/swatches; header and media-type are the other two types. @Deprecated makes every v1 response say it is going.

    @Deprecated({
      since: '2026-09-01',
      sunset: '2027-03-01',
      link,
    })
    @Controller('swatches', { version: '1' })
    
    @Controller('swatches', { version: '2' })
    
            

    Conditional GET

    etag: true hashes every value a GET route returns into a weak ETag. A client that sends it back in If-None-Match gets a 304 with no body, and the handler did not have to know.

    
            

    Image pipeline

    Bun.Image at /api/images/render, returned as a Response the browser decodes. The source is generated at runtime from a 4x4 seed, so there is no image file in the repository.

    
            

    Guards and roles

    One controller, three answers. @Public opts a route out, a class-level @Roles covers the rest, and a method-level one overrides it. The guard reads route metadata, so no handler calls a session helper.

    @Roles('admin')
    @UseGuards(AuthGuard)
    @Controller('reports')
    
      // anyone
      @Public() @Get('/health')
      // any caller with credentials
      @Get('/')
      // admin only
      @UseGuards(RolesGuard) @Post('/')
    
            

    Session

    better-auth mounted by @dunx/auth, with the anonymous plugin so there is no form to fill in. Opening this step signed you in as a guest. The audit route wants a role a guest does not have.

    
            

    Signed cookies

    req.cookies is Bun's own CookieMap, and SignedCookies adds an HMAC so a value the visitor edits reads back as absent. The cookie is HttpOnly, so this page's script cannot see it; the theme below is applied from what the server reads back.

    this.signed.set(req.cookies, 'prefs', theme);
    
    // undefined if the visitor edited it
    this.signed.get(req.cookies, 'prefs');
    
            

    Rate limit

    @Throttle({ limit: 3, windowSeconds: 60 }) on /api/limits/burst. The counter is in Redis when Redis is up, and in memory when it is not, and every answer carries the IETF RateLimit headers.

    
            

    Security headers and CSRF

    securityHeaders puts a strict CSP, HSTS, nosniff and friends on every response, the 404 and the mapped errors included. csrf refuses a cross-site unsafe request by the Sec-Fetch-Site header a browser always sends, with no token to thread through forms.

    A page cannot forge that header, so the second button asks the server to post to /api/users as three callers.

    
            

    Transactions

    /api/ledger/transfer moves an amount between two rows inside one drizzle transaction. Failing it between the two writes answers 409, and the unchanged row count is the rollback.

    
            

    Keyset pagination

    @dunx/infra/pagination over a list that is not a database table. Each page carries a cursor rather than an offset, so a row inserted between two requests cannot shift the next page.

    
            

    Cache tiers

    @dunx/infra/cache with memory in front of Redis. The counter is the loader's rather than the cache's, so a second read moving it by zero is the cache working. DELETE evicts both tiers.

    
            

    Idempotency keys

    A client that timed out cannot know whether its POST ran. @Idempotent() lets it retry with the same Idempotency-Key: the first response is stored and replayed, and the handler runs once. The store is Redis when it is up, so a retry on another replica finds it.

    @Idempotent()
    @Post('/orders', placeOrder)
    
            

    Server-sent events

    @Sse over Bun's own ReadableStream. The handler is an async generator and every yield is a frame. Each frame carries an id, so a reconnect resumes rather than restarting.

    
            

    Websocket gateway

    @Gateway('/chat') on the same Bun.serve as every route, broadcasting over Bun's native pub/sub. Open this step in a second tab to see the other side.

    
            

    RPC on the same port

    @dunx/http/connect serves Connect and gRPC-Web as middleware rather than on a second port, so an RPC gets this app's CORS, rate limit and request log. A unary Connect call is a POST with a JSON body, which is all this button sends.

    
            

    Application events

    EventBus over EventTarget: one publisher, and @OnEvent subscribers in another module. The response is the dispatch, so a subscriber that throws is reported rather than swallowed, and the request still succeeds.

    
            

    Queue

    POST /api/jobs/thumbnails enqueues through bullmq over Bun.RedisClient, and the handler runs in a forked process. Watch it land in bull-board.

    
            

    Message broker

    @dunx/infra/amqp over RabbitMQ. POST /api/messaging/orders publishes to a topic exchange and the broker routes the key to its own queue, consumed by this same container. The traceId that comes back is the one this request carried: a trace that crossed the broker.

    
            

    Retry and backoff

    @dunx/http/client calling an upstream that answers 503 twice before it works. Every attempt the retry policy made comes back with the millisecond it happened at, so the jittered backoff is visible rather than described.

    
            

    Email templates

    @dunx/infra/email renders a React Email template and hands it to whichever EmailTransport is bound, so no service names a vendor. This demo sends nothing, but the rendered welcome email is at /api/email/preview.

    
            

    Trace context

    A W3C traceparent is adopted, so every log line the request writes carries its trace id and the response carries traceresponse. OtelModule binds the Tracer, so routes, queries, jobs and broker messages open real spans once an OpenTelemetry SDK is registered.

    
            

    What this process is doing

    RequestMetrics, QueryMetrics and EventLoopLag read out of the running container, refreshed every five seconds while this step is open. Every button you pressed on the way here is in the table.

    busiest route calls p50 p99