@sera4/essentia
v3.0.23
Published
A library of utilities for Teleporte Web Services
Readme
ESSENTIA 3.0
Shared TypeScript library for TWS/sera4 microservices. ESM, strict mode, full type declarations.
Requires Node.js ≥ 22.
What's new in 3.0
Breaking changes
- Node.js ≥ 22 required (was ≥ 18 in 3.x)
- TypeScript 6 (up from 5.3) — stricter inference rules may surface new type errors in consuming projects
- All test files are now
.tsonly and use the*.test.tsconvention; the.jstest glob has been removed from the test runner
New
- Biome replaces ad-hoc linting — single tool for lint, format, and import sorting (
npm run check) - Husky pre-commit hook — Biome auto-fixes staged files before every commit
tsconfig.test.json— separate typecheck pass covering bothts/andtest/.nvmrcpinned to Node 22
Improvements
- Strict-mode cleanup across all modules —
noImplicitAny, unused imports/variables enforced as errors - All source files use
node:protocol for built-in imports (node:fs,node:path, etc.) - All file names normalised to kebab-case (e.g.
dns-cache.ts,cycle-deployment-watcher.test.ts) - New test suites added for
cache,hal,last-commit,paper-trail,safe-proxy,serializer,utils,s4-formatter,s4-pagination— 190 tests total
Setup
npm install
npm run build
npm testScripts
| Script | What it does |
| ----------------------- | ---------------------------------------------- |
| npm run build | Compile TypeScript → dist/ts/ |
| npm test | Run all tests (Mocha + Chai, JUnit XML output) |
| npm run test:coverage | Tests with c8 coverage HTML report |
| npm run typecheck | Type-check without emitting |
| npm run lint | Biome lint across ts/ and test/ |
| npm run lint:fix | Biome lint with auto-fix |
| npm run format | Biome format write pass |
| npm run format:check | Biome format check (CI-safe) |
| npm run check | Biome lint + format + import sort |
| npm run check:fix | check with auto-fix |
Pre-commit hook
Husky runs Biome on staged files:
npx biome check --write --staged --no-errors-on-unmatchedThe hook is wired via npm run prepare (runs automatically after npm install).
Modules
All source lives under ts/. Each module exports from its index.ts.
| Module | Export | Description |
| ------------------- | ------------------------------------ | --------------------------------------------------------------------------- |
| ts/logger | s4logger, S4Logger | Winston logger with trace/debug/info/warn/error levels |
| ts/utils | utils | UUID validation, integer array parsing, route type detection, JSONB helpers |
| ts/dns | DnsCache | Singleton DNS lookup cache with configurable TTL |
| ts/health | HealthCheck | Health check endpoint utilities |
| ts/cycle | CycleDeploymentWatcher | Watches Cycle environment file for deployment tag changes |
| ts/constants | TWS_ROUTE_TYPES | Shared route type constants |
| ts/helpers | helpers, TestServerWrapper | Misc helpers, test HTTP server wrapper |
| ts/paginator | sqlPaginator, paginator | SQL and array pagination |
| ts/cache | cache, Cache, CacheConfigError | Redis cache wrapper (fakeredis supported for tests) |
| ts/prompts | setupCLIParser, cliSleep, etc. | CLI argument parsing and prompt utilities |
| ts/dns-lb | DnsLbPool, dnsLbPool | DNS-aware round-robin load balancer across all instances of a Cycle deployment |
| ts/safe-proxy | safeProxy, SafeProxy | Axios wrapper that forwards auth headers between services (supports LB) |
| ts/queue | queue | AMQP publisher/subscriber with fanout support |
| ts/last-commit | lastCommit | Read/write git branch + commit hash to a JSON file |
| ts/hal | halDecorator | Decorates Sequelize models with HAL-style _links / _resource |
| ts/formatter | S4Formatter | Error response formatting |
| ts/serializer | serializer | Sequelize model serialization |
| ts/paper-trail | paperTrail | Sequelize hook-based audit trail (diff + revision storage) |
dns-lb — DNS-aware round-robin load balancer
Background
Go's net/http resolves DNS on every request and distributes across all returned A records automatically. Node's http.Agent does not — it connection-pools by hostname, meaning once it resolves lockdepot.current-tag to one IP it keeps hitting that same container for the lifetime of the connection.
On Cycle.io there are two hostname forms:
| Form | Mechanism | Returns |
|------|-----------|---------|
| hostname.current-tag | Cycle discovery service (getaddrinfo) | 1 IP — lowest latency instance |
| _hostname.current-tag | Cycle random selection (getaddrinfo) | 1 IP — random instance |
| dig hostname.current-tag | Direct DNS query | All IPs — every instance |
The key is that dns.resolve4() (unlike dns.lookup()) bypasses getaddrinfo and queries DNS directly — the Node equivalent of dig. It returns the full A-record set for a hostname, which is all the IPs of every live container in the deployment.
DnsLbPool uses dns.resolve4() on the plain hostname (no underscore prefix needed) to get all IPs, feeds them into undici's BalancedPool (weighted round-robin), and diffs the upstream list in-place on each refresh so in-flight requests are never dropped when the deployment scales.
Node.js compatibility
Works on Node 22. undici 7.x is installed as an explicit npm dependency — it does not require Node 24. Node 24 is only significant because that's when Node will bundle undici 7 internally; until then it installs like any other package and works identically on Node 22.
Configuration
import { DnsLbPool, logger } from "@sera4/essentia";
// Singleton, configured once at startup. Pass your app logger so debug messages
// respect the same log level the rest of the app uses (S4_LOG_LEVEL=debug).
const lbPool = DnsLbPool.getInstance({
logger, // share the app logger — see "Logger injection" below
debug: true, // log pool state + timing on every request
refreshIntervalMs: 5000, // how often to re-resolve DNS (default: 5000ms)
port: 80, // target port for all upstreams (default: 80)
protocol: "http:", // "http:" | "https:" (default: "http:")
onStats: (stats) => {
// called after each upstream diff — hook for Node 24 / undici 7 introspection
// stats: { hostname, ips, connected, pending, running, queued }
metrics.gauge("dns_lb.pool_size", stats.ips.length, { hostname: stats.hostname });
},
});Making HTTP requests directly
Use request() or requestUrl() when your service needs to call another service and get the full response body back (not proxied).
// request(hostname, options) — hostname + path style
const response = await lbPool.request("lockdepot.current-tag", {
method: "GET",
path: "/v3/locks",
headers: { "tws-account-id": locals.account.id },
});
const locks = await response.body.json();
// requestUrl(url, options) — full URL style, compatible with SafeProxy call patterns
const response = await lbPool.requestUrl("http://lockdepot.current-tag/v3/locks", {
method: "POST",
headers: { "tws-account-id": locals.account.id },
body: JSON.stringify({ serialNumber: "ABC123" }),
});
const created = await response.body.json();response is Dispatcher.ResponseData from undici:
response.statusCode // number
response.headers // IncomingHttpHeaders
await response.body.json() // parse JSON body
await response.body.text() // parse text body
response.body // Readable stream for streaming responsesSafeProxy with load balancing
SafeProxy can optionally resolve hostnames via DnsLbPool before making its Axios calls. This keeps the same .data response API that callers already use.
import { SafeProxy, DnsLbPool, logger } from "@sera4/essentia";
// Create one load-balanced SafeProxy instance at startup
const lbPool = DnsLbPool.getInstance({ logger });
const lbProxy = new SafeProxy({ lbPool });
// Use exactly like safeProxy, but requests are now distributed
// across all instances of the target service
await lbProxy.post(res.locals, "http://identity-service.current-tag/v3/sessions", {
data: { device, domain },
});
await lbProxy.delete(res.locals, "http://identity-service.current-tag/v3/sessions/abc123");Under the hood, SafeProxy calls lbPool.resolveRoundRobin(hostname) to get a concrete IP, rewrites the URL to that IP, and sets a host header to the original hostname so the backend can route correctly.
The default safeProxy singleton (imported without new) is unchanged and backward compatible — it still uses Axios without load balancing.
Integration with http-proxy (streaming proxy / gateway pattern)
When using node:http-proxy or http-proxy-middleware as a transparent proxy, the library needs a concrete target URL — you can't plug in undici's BalancedPool directly. Use resolveRoundRobin() to pick one IP per request in round-robin order, then pass that IP as the proxy target.
resolveRoundRobin(hostname) returns a single IP from the pool on each call, cycling through all known IPs. The DNS pool refreshes on its own timer; the counter wraps by the current pool size so scale-up/down is handled automatically.
Replacing the gateway's server/helpers/dns.js:
// server/helpers/dns.js — replace the entire file with this
import { logger, DnsLbPool } from "@sera4/essentia";
import configs from "../../configurations/server-config.js";
const lbPool = DnsLbPool.getInstance({
logger,
debug: configs.debug_dns, // respects the existing S4_DEBUG_DNS env flag
refreshIntervalMs: 5000,
});
// resolveTarget: same API as before — returns http://[ip]:port/path
// The only difference is dns.resolve4() now selects from ALL instance IPs in round-robin.
async function resolveTarget(urlOrHostname, keepPath = false) {
const parsed = new URL(urlOrHostname);
try {
if (process.env.NODE_ENV === "test") {
return `${parsed.protocol}//localhost:${parsed.port || 80}${keepPath ? parsed.pathname : ""}`;
}
const ip = await lbPool.resolveRoundRobin(parsed.hostname);
const hostPart = ip.includes(":") ? `[${ip}]` : ip; // wrap IPv6
return `${parsed.protocol}//${hostPart}:${parsed.port || 80}${keepPath ? parsed.pathname : ""}`;
} catch (e) {
logger.warn(`DNS resolution failed for ${urlOrHostname}, falling back: ${e.message}`);
return urlOrHostname;
}
}
// resolveIdentityEndpoint: unchanged API, now load-balanced
const resolveIdentityEndpoint = async () => {
try {
return await resolveTarget(configs.s4_identity_endpoint, true);
} catch (error) {
logger.error(`Failed to resolve identity endpoint:`, error.message);
return configs.s4_identity_endpoint;
}
};
// forceCachePurge: triggers an immediate DNS re-resolution.
// Called when a Cycle deployment-change event arrives via the queue.
export function forceCachePurge(hostname) {
lbPool.forceRefresh(hostname).catch((err) => {
logger.warn(`Force DNS refresh failed for ${hostname ?? "all"}:`, err.message);
});
}
// purgeExpiredCache: DnsLbPool self-manages TTL internally — this is a no-op kept
// for backward compatibility with background-tasks.js.
export function purgeExpiredCache() {}
export const dnsCache = new Map(); // kept for backward compat; no longer populated
export const PURGE_INTERVAL_MS = 60 * 2 * 1000; // kept for backward compat
export default {
resolveTarget,
resolveIdentityEndpoint,
forceCachePurge,
dnsCache,
purgeExpiredCache,
PURGE_INTERVAL_MS,
};Nothing else in the gateway changes — all callers of dns.resolveTarget(), dns.resolveIdentityEndpoint(), and dns.forceCachePurge() continue to work without modification. The background-tasks.js purge interval can be left as-is (calling a no-op is harmless) or removed.
forceRefresh — responding to deployment change events
When the queue delivers a cache.dns.* message signalling that a deployment has changed, call forceRefresh to re-resolve DNS immediately rather than waiting for the next TTL tick:
// Force re-resolution of one hostname
await lbPool.forceRefresh("lockdepot.current-tag");
// Force re-resolution of all tracked hostnames
await lbPool.forceRefresh();Logger injection
Pass your application's configured S4Logger instance so DnsLbPool emits at the same level the rest of the service uses. Without injection, a new logger is created with service="dns-lb-pool".
// In server startup, before any requests are handled:
import { DnsLbPool, logger } from "@sera4/essentia";
logger.configure({ service: "gateway", level: "debug" });
DnsLbPool.getInstance({ logger, debug: true });
// Now dns-lb debug messages appear when S4_LOG_LEVEL=debug,
// and are suppressed at info/warn just like every other log line.When debug: true is set, every request logs:
- Before:
dns-lb: GET lockdepot.current-tag/v3/locks → pool [10.0.0.1, 10.0.0.2, 10.0.0.3] - After:
dns-lb: lockdepot.current-tag/v3/locks ← 200 (12ms) - Scale events:
dns-lb: +upstream 10.0.0.4 for lockdepot.current-tag
Inspecting the pool
lbPool.getIps("lockdepot.current-tag") // → ["10.0.0.1", "10.0.0.2", "10.0.0.3"]Cleanup
// On process shutdown, drain in-flight requests and close connections:
await lbPool.destroy();Logger environment policy
S4Logger.resolveLogLevel() reads S4_LOG_LEVEL; when it is set to a valid level, that value always wins. When it is unset or invalid, logging defaults to info.
S4_LOG_LEVEL=debug: emit debug and aboveS4_LOG_LEVEL=info: emit info and above- unset or invalid: default to
info
Usage
import { logger, utils, DnsCache, DnsLbPool, SafeProxy, queue, safeProxy } from "@sera4/essentia";
// Logger
logger.configure({ level: "info", service: "my-service", format: "json" });
logger.info("Starting");
// Utils
const isValid = utils.isValidUuidV4("26d61a82-3587-4875-98a8-e950e1bf2350");
// DnsCache — single-IP lookup with TTL cache (uses dns.lookup internally)
const dnsCache = DnsCache.getInstance();
const ip = await dnsCache.resolveTarget("https://example.com");
// DnsLbPool — full A-record round-robin across all instances (uses dns.resolve4 internally)
const lbPool = DnsLbPool.getInstance({ logger, debug: true });
// Direct HTTP call — load balanced
const response = await lbPool.requestUrl("http://lockdepot.current-tag/v3/locks");
const locks = await response.body.json();
// SafeProxy with load balancing
const lbProxy = new SafeProxy({ lbPool });
await lbProxy.post(res.locals, "http://identity-service.current-tag/v3/sessions", { data: {} });
// SafeProxy without load balancing (original behaviour)
await safeProxy.post(res.locals, "http://other-service/endpoint", { data: {} });
// Queue
await queue.openConnection({ connectionUrl: "amqp://localhost" });
await queue.publishMessage("my-exchange", "routing.key", { payload: true });Testing
Tests run manually — no CI pipeline yet.
npm test # all tests
npm run test:coverage # HTML coverage report in coverage/
npm test -- --grep "DnsCache" # run a specific suiteTest files live in test/, mirroring the ts/ structure. Files use the .test.ts naming convention.
License
Proprietary. All rights reserved.
