メインコンテンツまでスキップ
バージョン: 🚧 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