@dooboostore/simple-boot-http-server
v1.0.31
Published
back end http server frameworks
Maintainers
Readme
@dooboostore/simple-boot-http-server
@dooboostore/simple-boot-http-server is a lightweight, high-performance HTTP web server framework for Node.js, built on top of @dooboostore/simple-boot. It provides a decorator-driven, declarative programming model that allows you to design complex backend architectures intuitively and robustly.
🚀 Key Features
- Declarative Routing: Map URL paths and HTTP methods directly to your controller methods using decorators like
@Router,@Route,@GET,@POST,@PUT,@DELETE,@PATCH. - Dependency Injection (DI): Manage dependencies between services automatically using the
@Simdecorator — the exact same DI/AOP container as@dooboostore/simple-boot. - Middleware & Filters: Systematically separate pre/post-processing logic (CORS, caching, auth, logging, OpenAPI generation, etc.) through the
Filterinterface. - Powerful Exception Handling: Leverage AOP-based
GlobalAdviceto isolate business logic from error handling logic. - Generator Streaming: Return a (async) generator and each yielded value is written to the body immediately after the headers — SSE with no plumbing.
- Content-Type Streams:
ContentTypeGeneratoradds the wire format for SSE, MJPEG/multipart, NDJSON, JSON Text Sequences, streamed JSON arrays, CSV, and text lines — pull from your generator orpush()into a channel. - Manual Response Streaming: Opt out of the automatic response pipeline with
res: 'manual'to stream Server-Sent Events, NDJSON, or any long-lived response directly from the injectedServerResponse. - Real-time WebSocket Protocol: A topic-based pub/sub protocol over a single socket — subscribe/unsubscribe, request/response, and server-initiated events the server can
awaita client reply for — with automatic reconnection and re-subscription. - Binary File Transfer over WebSocket:
Buffer/Filevalues anywhere in a message are detected automatically and shipped as raw bytes in a compact binary frame (length header + JSON metadata + concatenated buffers) instead of being bloated with base64 — no separate upload endpoint needed. - Resource Resolvers:
ResourceResolver(set via@GET({ resolver: ResourceResolver })) writes a file/resource as the response;ResourceFilterserves static paths.
📦 Installation
pnpm add @dooboostore/simple-boot-http-server reflect-metadata💻 Usage Example
1. Define a Service (Dependency Injection)
Create a service that contains business logic using @Sim.
import { Sim } from '@dooboostore/simple-boot';
@Sim
export class UserService {
private users = [{ id: 1, name: 'Dooboo' }];
getAllUsers() {
return this.users;
}
}2. Configure Router and Controller
Define endpoints using decorators. Inject services through the constructor.
import { Sim, Router, Route } from '@dooboostore/simple-boot';
import { GET, POST } from '@dooboostore/simple-boot-http-server';
@Sim
@Router({ path: '/api' })
export class ApiRouter {
constructor(private userService: UserService) {}
@Route({ path: '/users' })
@GET({ res: { contentType: 'application/json' } })
getUsers() {
return this.userService.getAllUsers();
}
}3. Run the Server
Configure HttpServerOption and launch the server.
import 'reflect-metadata';
import { SimpleBootHttpServer, HttpServerOption } from '@dooboostore/simple-boot-http-server';
const option = new HttpServerOption(
{ listen: { port: 8080 } },
{ rootRouter: ApiRouter }
);
const server = new SimpleBootHttpServer(option).run();
console.log('🚀 Server running at http://localhost:8080');🛠️ Advanced Features
Filters
Implement the Filter interface to perform common request processing. A filter has two hooks: proceedBefore runs before the route handler (return false to stop the chain right there), and proceedAfter runs after it (receives before, the boolean proceedBefore returned).
import { Sim } from '@dooboostore/simple-boot';
import { Filter, RequestResponse, SimpleBootHttpServer } from '@dooboostore/simple-boot-http-server';
@Sim
export class MyAuthFilter implements Filter {
async onInit(app: SimpleBootHttpServer) {}
async proceedBefore({ rr }: { rr: RequestResponse; app: SimpleBootHttpServer; carrier: Map<string, any> }): Promise<boolean> {
// Returning false stops the request from proceeding to the next filter/route handler.
return true;
}
async proceedAfter({ rr, before }: { rr: RequestResponse; app: SimpleBootHttpServer; before: boolean; carrier: Map<string, any> }): Promise<boolean> {
return true;
}
}Built-in filters and end points (all exported from the package root):
| Class | Use |
|---|---|
| ResourceFilter(distPath, [regex \| { request, dist }]) / ResourceFilter([{ request, response }]) | Serve static files or generated data for matching requests |
| CacheFilter({ config: { cacheDir?, lifeTime, startUpClean? }, key, filter? }) | Response cache keyed per request |
| OpenApi3Filter({ path, excludePath? }, openApiConfig?) | Serve an OpenAPI 3 document generated from routes |
| TopicProtocolFilter | Handle the topic protocol over HTTP multipart (X-Simple-Boot-Http-Topic-Protocol: topic) |
| IntentSchemeFilter | Route requests to IntentManager |
| HttpStatusFilter(status) / ThrowFilter(error) | Respond with a fixed status / throw a fixed error |
| HeaderEndPoint(headers) / CrossDomainHeaderEndPoint(config?) | requestEndPoints that add response headers / CORS headers |
Other HttpServerOption fields: serverOption (HTTP/HTTPS), listen, filters, requestEndPoints, closeEndPoints, errorEndPoints, webSocketEndPoints, sessionOption (key, expiredTime, provider), globalAdvice, fileUploadTempPath, noSuchRouteEndPointMappingThrow, transactionManagerFactory.
Generator Streaming (SSE)
Return a generator (function* or async function*) and the server streams it: headers (status, header, contentType from res) are sent immediately, then every yielded value is written to the body right away (strings/Buffers as-is, anything else as JSON), and the response ends when the generator finishes. If the client disconnects, the generator is stopped with return(), so its finally block runs. If the request's Accept-Encoding includes gzip, the stream is gzip-compressed and flushed (Z_SYNC_FLUSH) after every chunk, so events still arrive immediately. Headers are sent before the first chunk, so set them in res or in a before-filter — setHeader after that throws ERR_HTTP_HEADERS_SENT.
@Route({ path: '/events' })
@GET({ res: { contentType: 'text/event-stream', header: { 'Cache-Control': 'no-cache' } } })
async *events(signal: AbortSignal) {
try {
while (!signal.aborted) {
const msg = await nextMessage(signal); // rejects as soon as the client disconnects
yield `data: ${JSON.stringify(msg)}\n\n`; // SSE framing is up to you — or let ContentTypeGenerator do it (below)
}
} finally {
// runs when the client disconnects — release subscriptions here
}
}Every handler can take an AbortSignal parameter (injected by type, like ReqJsonBody). It aborts when the client disconnects before the response finishes. Pass it to anything that waits (fetch, timers/promises, events.once, ...) — a generator suspended on an await isn't stopped by return() until its next yield, so the signal is what releases an event wait. Errors thrown after the client disconnected (e.g. that AbortError) are swallowed.
Content-Type Streams (ContentTypeGenerator)
Yielding raw chunks means writing the wire format yourself — SSE data: lines, multipart boundaries + Content-Length, NDJSON newlines, CSV escaping. ContentTypeGenerator does that part: you hand it the content and get back a stream that carries its own contentType (boundary included) and compress flag. Return it from a handler — no contentType needed in the decorator (if one is set, the stream's wins; other res.headers still apply).
Two ways to feed it:
- pull — wrap a generator you loop in yourself:
CTG.ndjson(rows()). Client disconnect callsreturn()on your generator, so itsfinallyruns. - push —
CTG.ndjson.channel({ signal })gives a stream youpush()into from callbacks/events. It queues values and yields them;close()ends the response after the queue drains, andonClose(fn)runs once on close, abort, or client disconnect (even while idle — the place to unsubscribe).
import { ContentTypeGenerator as CTG } from '@dooboostore/simple-boot-http-server';
// pull: MJPEG from a generator of frames (Content-Type / Content-Length per part are filled in)
@Route({ path: '/camera.mjpeg' })
@GET
camera(signal: AbortSignal) {
return CTG.multipartReplace(this.frames(signal), { boundary: 'frame' });
}
// push: job progress as SSE, released when the client leaves
@Route({ path: '/jobs/progress' })
@GET
progress(signal: AbortSignal) {
const out = CTG.sse.channel({ signal, heartbeatMs: 15000 });
const onProgress = (p: number) => out.push({ event: 'progress', data: { p } });
job.on('progress', onProgress);
job.once('done', () => out.close());
out.onClose(() => job.off('progress', onProgress));
return out;
}
// CSV download that opens in Excel without breaking Korean text
@Route({ path: '/rows.csv' })
@GET({ res: { header: { 'Content-Disposition': 'attachment; filename="rows.csv"' } } })
rows() {
return CTG.csv(db.cursor(), { columns: ['name', 'memo'], bom: true });
}| pull / push | Content-Type | You give | Framing added |
|---|---|---|---|
| sse(src, { heartbeatMs? }) / sse.channel() | text/event-stream | string or { data, event, id, retry, comment } | data: per line (multi-line safe), field lines, blank line; : ping heartbeat while idle |
| multipartReplace(src, { boundary?, type? }) / .channel() | multipart/x-mixed-replace; boundary=… | Buffer / Uint8Array / string / { body, type?, name?, headers? } | --boundary, Content-Type, Content-Length (bytes), CRLFs. Not gzipped (frames are already compressed) |
| multipartMixed(src, { boundary?, type? }) / .channel() | multipart/mixed; boundary=… | same as above | same, plus --boundary-- at the end |
| ndjson(src) / .channel() | application/x-ndjson | any JSON value | JSON + \n |
| jsonSeq(src) / .channel() | application/json-seq (RFC 7464) | any JSON value | \x1e JSON \n |
| jsonArray(src) / .channel() | application/json | any JSON value | [, , between items, ] — the client just calls res.json() |
| csv(src, { columns?, bom? }) / .channel() | text/csv; charset=utf-8 | row arrays, or objects mapped by columns | RFC 4180 quoting, CRLF, header row from columns, optional UTF-8 BOM |
| text(src) / .channel() | text/plain; charset=utf-8 | strings (others as JSON) | \n per item |
srcis any iterable or async iterable (array,function*,async function*).- A part's MIME type is resolved as the part's
type>mime-typeslookup of itsname> then for string bodies the stream'stypeoption ortext/plain; charset=utf-8, for bytes sniffing (JPEG/PNG/GIF/WEBP) > thetypeoption >application/octet-stream. - A missing
boundaryis randomized; an invalid one (RFC 2046) throws when the stream is created. - Channel options:
signal(closes on abort),keep: 'queue'(default, every value in order, up tomax= 1000, oldest dropped) orkeep: 'latest'(only the newest pending value — video frames). jsonArrayis valid JSON only once it finishes; for streams that may be cut off, preferndjson/jsonSeq.- The per-item formatters are exported too:
sseChunk,ssePing,multipartPart,multipartEnd,partType,ndjsonLine,jsonSeqRecord,csvCell,csvRow,textLine,boundary, andisStream.
Manual Response Streaming (SSE / NDJSON)
By default, a route's return value is automatically serialized and written as a single response. Set res: 'manual' to skip that entirely and take direct control of the injected ServerResponse — the request handler's promise simply stays pending until you're done, so nothing else writes to the response underneath you.
import { ServerResponse } from 'http';
import { Route } from '@dooboostore/simple-boot';
import { GET } from '@dooboostore/simple-boot-http-server';
@Route({ path: '/stream/time' })
@GET({ res: 'manual' }) // status/header/body auto-write is skipped
streamTime(res: ServerResponse) {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
return new Promise<void>(resolve => {
const timer = setInterval(() => {
res.write(`data: ${JSON.stringify({ time: new Date().toISOString() })}\n\n`);
}, 1000);
res.on('close', () => { clearInterval(timer); resolve(); }); // clean up when the client disconnects
});
}MappingConfig is a union: either res: { status, header, contentType } (+ resolver) or res: 'manual', so TypeScript won't let you combine them — a route is either auto-written or fully manual.
WebSocket: Topic Protocol + Binary File Transfer
Register WebSocketManager as a webSocketEndPoints entry to get a topic-based pub/sub protocol over a single connection — subscribe/unsubscribe, request/response, and server-initiated events the server can await a client reply for.
import { WebSocketManager } from '@dooboostore/simple-boot-http-server/websocket/WebSocketManager';
const option = new HttpServerOption({
webSocketEndPoints: [WebSocketManager],
listen: { port: 8080 }
}, { rootRouter: AppRouter });Any @Sim({ symbol })-tagged service's public methods become callable by name over the socket via an intent target (Symbol.for(ClassName)://methodName) — no extra decorator needed:
@Sim({ symbol: Symbol.for('UserService') })
export class UserService {
say(message: any) {
return { echo: message };
}
}WebSocketClient (the browser-side counterpart) is not exported from the package root. Import it through the ./* subpath with a bundler (@dooboostore/simple-boot-http-server/websocket/WebSocketClient), or load the standalone UMD bundle dist/umd-bundle/websocket-client.umd.js (built by pnpm run build:client) with a plain <script> tag:
<script src="./node_modules/@dooboostore/simple-boot-http-server/dist/umd-bundle/websocket-client.umd.js"></script>
<script>
const { WebSocketClient } = dooboostoreSimpleBootHttpServerWebSocketClient;
const ws = new WebSocketClient('http://localhost:8080', { retryConnectionCount: Infinity, retryConnectionDelay: 1000 });
const subject = ws.subject('Symbol.for(UserService)://say', { type: 'intent' });
subject.observable.subscribe({ next: data => console.log('received:', data) });
subject.send({ hello: 'world' });
// subject.observable.subscribe(...) returns a *local* RxJS-style subscription —
// unsubscribing it only stops local delivery. To actually tell the server to
// drop the topic, call the outer object's own unsubscribe:
subject.unsubscribe();
</script>Drop a Buffer (Node) or File/Blob (browser) anywhere inside a message body and it's detected automatically and shipped as raw bytes in a compact binary frame (4-byte length header + JSON metadata with $file references + concatenated buffers) instead of being base64-inflated inside JSON — reconstructed transparently as a real File/{name, mime, size, buffer} on the other side:
say(message: any) {
const buffer = Buffer.from('hello binary world', 'utf8');
return { echo: message, file: { name: 'greeting.txt', mime: 'text/plain', buffer } };
}See the WebSocket Detailed Guide for the full wire protocol.
📖 Learn More
Check out the detailed guides and tutorials in the document folder.
