Skip to main content
Version: ๐Ÿšง Canary

๐Ÿšฆ Routes

A route bundles an HTTP method, path, validators and a business logic together. Nodeblocks provides the withRoute helper which stores metadata and plugs into Express automatically when the service router is generated. Routes support both HTTP and WebSocket protocols for real-time communication.


1๏ธโƒฃ Anatomyโ€‹

src/routes/user.ts
import {compose, flatMapAsync, withRoute, lift} from '../primitives';
import {createUser, getUserById, normalizeUserTerminator} from '../handlers';

export const createUserRoute = withRoute({
method: 'POST',
path: '/users',
validators: [verifyAuthentication(getBearerTokenInfo)], // optional array of validator functions
handler: compose(
createUser, // Handler: write to DB
flatMapAsync(getUserById), // Handler: read back the created entity
lift(normalizeUserTerminator) // Terminator handler: format final HTTP response
),
});

Below is the minimal shape returned by withRoute.

interface Route {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; // Required for HTTP routes
protocol?: 'http' | 'ws'; // Specify protocol (defaults to 'http')
path: string; // Express-style path, supports :params
validators?: Validator[]; // optional list executed **before** handler chain
handler: RouteHandler; // composable function chain
}

Protocolโ€‹

Routes support two protocols:

ProtocolPurposeHandler Return Type
httpTraditional REST API endpointsData object or Result
wsReal-time WebSocket connectionsRxJS Subject

Methodโ€‹

The HTTP verb that follows REST conventions (only for HTTP routes):

VerbPurpose
GETRead/retrieve resources
POSTCreate new resources
PATCHPartially update resources
DELETERemove resources

Pathโ€‹

An Express-style template. Use plural nouns for collections (/users) and sub-paths for actions (/users/:id/lock). Dynamic parts become requestParams.

Validatorsโ€‹

Synchronous or async hooks that run before any database work. They receive the same payload object.
The idiomatic way to reject a request is to throw new NodeblocksError(status, message):

import {NodeblocksError} from '../primitives/error';

export const validateEmail: Validator = ({ params }) => {
if (!params.requestBody.email?.includes('@')) {
throw new NodeblocksError(400, 'Invalid email');
}
};

Handlerโ€‹

Routes delegate their business logic to handler chainsโ€”composed functions that process requests and format responses. Every handler chain follows this pattern:

  1. Handlers - Perform business logic (database operations, validations, transformations)
  2. Terminator Handler - Final handler that formats the HTTP response
handler: compose(
handler1, // Returns Result<RouteHandlerPayload, Error>
flatMapAsync(handler2), // Returns Result<RouteHandlerPayload, Error>
lift(terminatorHandler) // Handles Result, returns formatted response
)

Handlers have their own dedicated explanation covering signatures, mergeData, Result patterns, and terminator requirements. โžœ Handler explanation


2๏ธโƒฃ Good Practicesโ€‹

  1. Path semantics โ€“ nouns for resources (/users), verbs for actions (/users/:id/lock).
  2. Complete handler chains โ€“ always end with a terminator handler that formats the final response.
  3. Keep handlers small โ€“ prefer multiple composables to a single monolith.
  4. Validators first โ€“ do lightweight checks before DB calls.
  5. Stateless โ€“ routes should not mutate global state; everything arrives via payload.

Handler Chain Patternโ€‹

handler: compose(
handler1, // Business logic: returns Result
flatMapAsync(handler2), // More business logic: returns Result
lift(terminatorHandler) // Response formatting: handles Result, returns data
)

3๏ธโƒฃ WebSocket Routesโ€‹

WebSocket routes enable real-time communication by returning RxJS Subjects instead of static data:

import { Subject } from 'rxjs';
import { withRoute } from '../primitives';

export const realtimeDataRoute = withRoute({
protocol: 'ws',
path: '/api/realtime',
handler: (payload) => {
const subject = new Subject();

// Setup real-time data source (e.g., database change stream)
const changeStream = payload.context.db.data.watch();

changeStream.on('change', (changeDoc) => {
subject.next({
type: 'data_change',
data: changeDoc.fullDocument,
timestamp: Date.now()
});
});

// Cleanup when connection closes
subject.subscribe({
complete: () => changeStream.close(),
error: () => changeStream.close()
});

return subject;
},
validators: [
// WebSocket routes can also use validators
(payload) => {
// Validate WebSocket connection headers, etc.
return Promise.resolve();
}
]
});

WebSocket vs HTTP Routesโ€‹

AspectHTTP RoutesWebSocket Routes
Protocol'http' (default)'ws'
MethodRequired (GET, POST, etc.)Not applicable
Handler ReturnData object or ResultRxJS Subject
CommunicationRequest-responseBidirectional streaming
Use CasesREST APIs, CRUD operationsReal-time updates, live data

4๏ธโƒฃ Adding Validatorsโ€‹

import {withRoute, compose} from '../primitives';
import {NodeblocksError} from '../primitives/error';
import {ok, err} from 'neverthrow';
import {mergeData} from '../handlers';

// Custom validator that always blocks access
const youShallNotPass = () => {
throw new NodeblocksError(400, 'YOU SHALL NOT PASS!');
};

// Handler for getting secret data
const getSecretData = async (payload) => {
return ok(mergeData(payload, { hiddenSecret: '๐Ÿงฑ๐Ÿงฑ๐ŸŽ‰๐Ÿงฑ๐Ÿงฑ' }));
};

// Terminator handler for formatting response
const secretTerminator = (result) => {
if (result.isErr()) {
throw result.error;
}
const payload = result.value;
const { context } = payload;

return { secret: context.data.secret };
};

export const getSecretRoute = withRoute({
method: 'GET',
path: '/secret',
validators: [youShallNotPass],
handler: compose(
getSecretData, // Handler: get the data
lift(secretTerminator) // Terminator: format final response
),
});

Validators throw NodeblocksError on failure which bubbles up to the error middleware.


โžก๏ธ Nextโ€‹

Learn about Feature ยป to see how to compose routes and schemas into reusable units, or explore WebSocket Services ยป for real-time functionality