Express, NestJS, Fastify
The panel does not care what serves your HTTP. It needs two things — the CNS
instance and the registry that names its neurons — and one decision: does the
DevTools server run inside your process, or beside it.
- Inside your process (
startCNSDevTools) — one call, no extra deployment. Right for local development and a single-process app. - Beside it (a standalone
@cnstra/devtools-server, apps dial out to it) — the only option once you run more than one replica, because an in-process server would exist once per replica and each would see only itself. See Deployments.
Everything below is the first shape. The second is the same in every framework:
construct CNSDevTools with a WebSocket transport where you would have called
startCNSDevTools.
The examples assume an app with these two modules:
import { CNS, cnsCollateral, cnsNeuron } from '@cnstra/core';
export type TOrder = { id: string };
export const orderCreated = cnsCollateral<TOrder>();
export const orderAccepted = cnsCollateral<TOrder>();
export const orders = cnsNeuron({ orderAccepted }).dendrite({
collateral: orderCreated,
response: (order, axon) => axon.orderAccepted.createSignal(order),
});
export const cns = new CNS([orders]);
export type TOrdersCNS = typeof cns;
import { CNSPersistOptionsRegistryFactory } from '@cnstra/persist';
import { orders } from '../cns';
export const registry = CNSPersistOptionsRegistryFactory.create({ orders });
Setup shared by the examples on this page
import type { CNS } from '@cnstra/core';
import type { TCNSNeuron } from '@cnstra/types';
import type { CNSPersistOptionsRegistry } from '@cnstra/persist';
import type { ICNSBullActionQueueLike } from '@cnstra/bullmq';
import { startCNSDevTools } from '@cnstra/devtools-server';
import { CNSDevTools } from '@cnstra/devtools';
declare const cns: CNS<TCNSNeuron>;
declare const registry: CNSPersistOptionsRegistry;
declare const ordersQueue: ICNSBullActionQueueLike;
declare function Fastify(): { listen(options: { port: number }): Promise<string> };
Plain Express
Keep the call out of the module that defines your routes, so nothing about DevTools is reachable from the request path:
// src/server.ts
import express from 'express';
import { cns, orderCreated } from './cns';
import { registry } from './neurons/registry';
const app = express();
app.post('/orders', async (req, res) => {
await cns.stimulate(orderCreated.createSignal(req.body)).waitUntilComplete();
res.sendStatus(202);
});
const server = app.listen(3000);
// DevTools is a development tool: it shows payloads and offers Retry to anyone
// who can open it. Gate it, do not ship it on by accident.
if (process.env.CNSTRA_DEVTOOLS === '1') {
const { startCNSDevTools } = await import('@cnstra/devtools-server');
const devtools = await startCNSDevTools(cns, registry, {
appId: 'orders-api',
});
console.log(`DevTools: ${devtools.url}`);
process.on('SIGTERM', () => {
devtools.stop();
server.close();
});
}
Two details worth knowing:
opendefaults to whether stdout is a TTY, so a container already getsfalseand a localnpm run devgetstrue. Set it explicitly only when you want the opposite of that.- The dynamic
importis the point of the flag. With a static import the package sits in your production bundle and its dependencies are installed in production, even when the flag is off.
NestJS
Nest owns the lifecycle, so the panel goes in a module that starts it on init and
stops it on shutdown. The CNS and the registry become ordinary providers:
// cns.module.ts
import { Module, Inject } from '@nestjs/common';
import type { TCNSDevToolsHandle } from '@cnstra/devtools-server';
import type { CNSPersistOptionsRegistry } from '@cnstra/persist';
import { cns, type TOrdersCNS } from './cns';
import { registry } from './neurons/registry';
export const CNS = Symbol('CNS');
export const REGISTRY = Symbol('CNS_REGISTRY');
@Module({
providers: [
{ provide: CNS, useValue: cns },
{ provide: REGISTRY, useValue: registry },
],
exports: [CNS, REGISTRY],
})
export class CnsModule {
private devtools?: TCNSDevToolsHandle;
constructor(
@Inject(CNS) private readonly cns: TOrdersCNS,
@Inject(REGISTRY) private readonly registry: CNSPersistOptionsRegistry,
) {}
async onModuleInit(): Promise<void> {
if (process.env.CNSTRA_DEVTOOLS !== '1') return;
const { startCNSDevTools } = await import('@cnstra/devtools-server');
this.devtools = await startCNSDevTools(this.cns, this.registry, {
appId: 'orders-api',
});
}
onApplicationShutdown(): void {
this.devtools?.stop();
}
}
onApplicationShutdown only fires if you called app.enableShutdownHooks() —
without it the panel's port stays held until the process is killed, which is
exactly the kind of thing that makes a redeploy hang.
Inject CNS wherever you stimulate:
import { Inject, Injectable } from '@nestjs/common';
import { orderCreated, type TOrder, type TOrdersCNS } from './cns';
import { CNS } from './cns.module';
@Injectable()
export class OrdersService {
constructor(@Inject(CNS) private readonly cns: TOrdersCNS) {}
async create(order: TOrder): Promise<void> {
await this.cns
.stimulate(orderCreated.createSignal(order))
.waitUntilComplete();
}
}
Fastify, Koa, anything else
Identical to Express — the panel runs its own HTTP listener on its own port and shares nothing with yours:
const fastify = Fastify();
await fastify.listen({ port: 3000 });
if (process.env.CNSTRA_DEVTOOLS === '1') {
const { startCNSDevTools } = await import('@cnstra/devtools-server');
await startCNSDevTools(cns, registry, { appId: 'orders-api' });
}
There is no plugin or middleware to register: nothing about DevTools sits on your request path, so it cannot slow a route down or leak into a response.
Keeping run history across restarts
By default the panel's store is in-memory and dies with the process — fine for
development, useless the moment you restart while reading an incident. Pass a
Postgres store, and the same instance you give a queue worker as its observe
store, so jobs and HTTP-triggered runs land in one place:
import { Pool } from 'pg';
import { CNSPostgresStimulationRepository, CNSPostgresStimulationSchema } from '@cnstra/persist-postgres';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await CNSPostgresStimulationSchema.migrate(pool); // once, from a place you control
const runs = new CNSPostgresStimulationRepository({ pool });
await startCNSDevTools(cns, registry, { stimulationRepository: runs });
See Stimulations for what the store holds and Deployments for running the server separately.
Retry through a queue, and hiding an action
startCNSDevTools takes the same two knobs as the server it builds, so you do not
have to drop to assembling one by hand as soon as you need them:
import { createCNSBullActions } from '@cnstra/bullmq';
await startCNSDevTools(cns, registry, {
appId: 'orders-api',
// Retry goes through the broker instead of the app socket — which is what
// stops one click running on every replica.
actions: [createCNSBullActions(ordersQueue)],
// This deployment never overwrites a run's record in place.
ui: { actions: { retry: false } },
});
Both are explained in Deployments and Retry & clone.
What the panel needs from your network
A WebSocket, in both directions, by default. The panel opens a WebSocket to
the server, and the app connects with @cnstra/devtools-transport-ws. All
protocol traffic is on the socket; the HTTP port otherwise serves the panel's
assets.
That matters when something between you and the server does not pass WebSocket upgrades. Typical culprits: an ingress or load balancer with upgrades off, a corporate proxy, a serverless platform that terminates long-lived connections. Without a fallback the panel would sit reconnecting and show nothing.
There is an HTTP fallback for both sides. The server always exposes it at
/__cnstra/poll on the same port; the socket stays the default and polling is
what you switch to when an upgrade will not get through.
For the panel, add ?transport=polling to its URL — that is a link you can hand
to whoever is stuck behind the proxy, and the header shows · polling so nobody
wonders why the view is a poll behind.
For the app, swap the transport:
import { CNSDevToolsTransportHttp } from '@cnstra/devtools-transport-http';
const transport = new CNSDevToolsTransportHttp({ url: 'http://cnstra-devtools:3141' });
const devtools = new CNSDevTools('orders-worker', transport);
Both sides speak the same protocol over three endpoints — a POST per message, a
long GET that parks until something is queued, a DELETE on shutdown. Expect
the panel to lag by up to one poll, and a failed request to back off rather than
retry tightly.
Before you turn it on outside development
- The panel shows payloads. Whatever your signals carry — a customer address, a token — is visible to anyone who can open it. Redact at the write path if that matters: Middleware chains.
- The panel offers Retry and Clone. Both re-run real work. A server can turn either off — see Retry & clone.
- Bind it to an internal address. Nothing in DevTools authenticates anybody.