Services & Utilities
Services & Utilities
JSails ships a cluster of small, focused services you can opt into: caching and rate limiting, feature flags, mail, filesystem blobs, notifications, database sessions, validation rules, an HTTP client, and a dependency-free translator. Each is a narrow contract with an in-memory default and, where it makes sense, a first-party plugin that exposes it under a typed service token. Construction is inert — nothing connects until you call a method.
Cache
jsails/cache is a narrow string-keyed CacheStore contract with an in-memory
default (createMemoryCacheStore) and an optional Valkey/Redis backend
(createValkeyCacheStore). A fixed-window createRateLimiter runs over the same
store — guard for the check and rateLimitResponse for a value-free 429.
cachePlugin exposes a CacheStore under cacheToken. The API-facing throttle
helper builds on this same limiter.
import { createMemoryCacheStore, createRateLimiter } from 'jsails/cache';
const store = createMemoryCacheStore();
const limiter = createRateLimiter({ store, limit: 60, windowMs: 60_000 });
Feature flags
jsails/flags is a boolean gate the app flips without a deploy. A flag is a
non-empty key plus an optional scope; the global scope and each scoped
namespace are independent, so the same key can be active in one scope and
inactive elsewhere. createMemoryFeatureStore is the zero-config default, and
createDatabaseFeatureStore({ dataSource }) backs the jsails_feature_flag
table. resolveFeature(flags, key, scope?) returns { active, inactive } to
compose with ??, and flagsPlugin({ store? }) exposes a FeatureFlags service
under flagsToken.
import { createMemoryFeatureStore, resolveFeature } from 'jsails/flags';
const flags = createMemoryFeatureStore();
const { active, inactive } = resolveFeature(flags, 'new-dashboard', 'user:42');
const view = active ?? inactive;
jsails/mail is a minimal, transport-agnostic mail seam: the Mailer contract
plus three transports and a first-party plugin. Use createMemoryTransport
(in-memory capture for tests), createCallbackTransport (an injected callback),
or createSmtpTransport (lazy SMTP over nodemailer, loaded at first send).
mailPlugin({ transport, transportOptions }) exposes the mailer under
mailToken with lazy, fail-closed transport resolution. The subpath is
server-only; nodemailer is pulled in only when SMTP is actually used.
import { createMemoryTransport, mailPlugin } from 'jsails/mail';
const mail = mailPlugin({ transport: createMemoryTransport() });
Filesystem
jsails/filesystem is a keyed blob-store Disk contract with two
implementations and a plugin. createLocalDisk(options) backs a disk with the
local filesystem; createMemoryDisk(options) is an in-memory disk for tests and
demos; filesystemPlugin({ disks }) exposes a named FileSystem service over
those disks under filesystemToken. On top of it, defineVariant /
createVariantResolver lazily cache transformations of a stored source file
(the caller supplies the transform — no image library dependency), and
createRichText is a sanitized HTML value object with plain-text extraction.
import { createLocalDisk, filesystemPlugin } from 'jsails/filesystem';
const filesystem = filesystemPlugin({
disks: { uploads: createLocalDisk({ root: 'storage/uploads' }) },
});
Notifications
jsails/notifications is a narrow, multi-channel delivery seam. A
NotificationMessage is smaller than a mail message — to, subject, and a
required plain-text text body — so any channel (mail, SMS, push) can consume
it. Two channels ship: createMemoryChannel captures messages in-process, and
createMailChannel({ mailer }) adapts the jsails/mail Mailer.
notificationsPlugin({ channels?, mail? }) exposes a NotificationsService
under notificationsToken; notify(message, { channels? }) sends through every
selected channel and aggregates failures into a value-free NotificationError.
There is no durable queue, retry policy, or database — enqueue with the jobs
plugin if you need retries.
import { createMemoryChannel, notificationsPlugin } from 'jsails/notifications';
const notifications = notificationsPlugin({
channels: { memory: createMemoryChannel() },
});
Sessions
jsails/sessions carries the framework-owned session entity and a
database-backed SessionStore. JsailsSession maps the jsails_session table
(SESSION_TABLE, SESSION_ID_COLUMN_LENGTH); add sessionEntities to a
JsailsDataSource entities and run the normal makemigrations/migrate
history. createDatabaseSessionStore({ dataSource }) builds the store through
the data source's repository — no raw SQL, no runtime DDL; a missing table fails
with a value-free SessionStoreError.
import { createDatabaseSessionStore, sessionEntities } from 'jsails/sessions';
const sessions = createDatabaseSessionStore({ dataSource });
Validation
jsails/validation provides composable, value-free Zod-backed validation rules
and a bulk validation helper. Each rule returns a Zod schema (for direct
composition into z.object({...})) or a ValidationRule predicate for
cross-field checks — required, email, url, minLength / maxLength,
min / max, regex, and inList are schema rules, while confirmed(field)
and when(condition, rule) are predicates. validateFields(schema, values)
runs a Zod object schema and returns a flat array of value-free FieldError
objects. Messages never echo input values.
import { confirmed, validateFields, when } from 'jsails/validation';
const errors = validateFields(schema, values);
// confirmed('password') requires password_confirmation to match
HTTP client
jsails/http is a thin fetch-based HTTP client seam with typed
get/post/put/patch/delete methods. Every method encodes/decodes JSON,
enforces a bounded timeout via AbortController, and surfaces failures as
value-free HttpClientErrors (status + message; never the request URL or
response body). Pass an injectable fetch for testing — the client uses
globalThis.fetch by default.
import { createHttpClient } from 'jsails/http';
const http = createHttpClient({ baseUrl: 'https://api.example.com' });
const user = await http.get('/users/42');
Internationalization
jsails/i18n is a dependency-free message translator over nested string maps
the caller loads from plain JSON/objects — this slice performs no file-system or
environment resolution. createTranslator({ messages, locale?, fallbackLocale? })
returns a Translator with t(key, params?) for dot-path keys and {name}
interpolation, and tChoice(key, count, params?) for plural selection driven by
the built-in Intl.PluralRules. locale() / fallbackLocale() / withLocale(next)
read or derive the active locale. The translator holds the passed messages
reference and never mutates it.
import { createTranslator } from 'jsails/i18n';
const t = createTranslator({ messages, locale: 'en', fallbackLocale: 'en' });
t('nav.home');
tChoice('cart.items', 3);
Signals
jsails/signals is a service-layer signal bus over the shared
interceptor/observer registry. createSignalBus(registry) wraps an
InterceptorRegistry as a SignalBus (observe/emit), and signalsPlugin()
exposes that bus under the typed signalsToken service. The bus adds no
buffering, durability, or removal API — it delegates to the registry, whose seal
guard applies (registration closes after setup; emit stays callable). emit
isolates observer errors: it never throws and resolves to a readonly unknown[]
of the errors thrown by observers.
Because the bus is the shared registry, an observer registered by one plugin
fires for events emitted by another. SetupContext/PluginContext expose
interceptorRegistry directly, so a plugin can run its own operations through
the shared graph (runBefore/runAfter/emit) instead of a private registry.
import { defineEvent, definePlugin } from 'jsails/extensions';
import { signalsPlugin, signalsToken } from 'jsails/signals';
const postCreated = defineEvent('post.created');
const observer = definePlugin({
name: 'audit',
setup({ observe }) {
observe(postCreated, (payload) => console.log('created', payload));
},
});
// A handler reaches the bus through the request context.
const emitFromHandler = (context) => {
const bus = context.services.get(signalsToken);
return bus.emit(postCreated, { id: 1 });
};
requestStarted / requestFinished / requestFailed are built-in lifecycle
events emitted by the API pipeline when the signals plugin is enabled. Their
RequestSignalPayload is value-free: it carries the Request, URL, params,
method, route, and session (server-only), but never the body, headers, or
cookies. Emission is fire-and-forget and opt-in — without the plugin the pipeline
emits nothing and behaves identically.
Logging
jsails/logging is a record-first structured logging service. The record is
the API and the string is a rendering: logger.info(msg, context?) builds a
frozen LogRecord { level, message, context, at }, and each channel receives the
record. createLogger(options?) is the standalone factory; loggerPlugin()
provides a Logger under the typed loggerToken service. Construction is inert —
no I/O until a log method is called.
import { createLogger, consoleChannel, jsonFormatter, memoryChannel } from 'jsails/logging';
const logger = createLogger({
channels: [consoleChannel({ formatter: jsonFormatter() }), memoryChannel()],
});
logger.info('server started', { port: 3000 });
const requestLog = logger.child({ requestId: 'abc' });
requestLog.warn('slow query', { ms: 812 }); // requestId is merged into every record
- Levels —
debug|info|warn|error, ordered; each channel has its ownminLevelthreshold. - Channels are the pluggable seam (
{ name, minLevel, write(record) }):consoleChannel()(default),memoryChannel()(tests; rawrecords()), andnullChannel(). - Formatters are pluggable:
jsonFormatter()(stable key order) andlineFormatter(). Both never throw — a circular ortoJSON-throwing value becomes"[unserializable]". child(context)merges bound context into every record (child overrides parent).- Composition — an optional
diagnosticsrecorder gets a value-free{ type: 'log', data: { level, message } }entry, and an optionalsignalsbus emits the sharedlogErrorevent forerror-level records. Both sinks are failure-isolated.
Value-free by default: there is no automatic secret redaction — context is
caller-owned, so never place credentials in it. The built-in formatters serialize
only the four record fields and never stack traces or cause chains.
v1 limits: no file/daily/stack channels (the channel contract makes them
additive); no async channel flush; no runtime level mutation; no global logger
singleton.
Encryption
jsails/encryption is a symmetric AES-256-GCM encrypter built on
node:crypto (no new dependency). createEncrypter({ key, previousKeys? })
returns an inert Encrypter with encrypt(plaintext, { aad? }) and
decrypt(token, { aad? }); encryptionPlugin(options) provides it under the
typed encryptionToken service.
import { createEncrypter } from 'jsails/encryption';
const encrypter = createEncrypter({ key: process.env.APP_ENCRYPTION_KEY });
const token = encrypter.encrypt('card number', { aad: 'user:42' });
encrypter.decrypt(token, { aad: 'user:42' }); // 'card number'
encrypter.decrypt(token, { aad: 'user:43' }); // throws decryption_failed
- Envelope —
v1.<iv>.<tag>.<ciphertext>, each part base64url. Thev1version tag is mandatory so a future format is rejected withunsupported_versionrather than misparsed. The IV is 12 random bytes per call; the tag is 16 bytes. - Key model — a
stringkey is a high-entropy secret of at least 32 bytes from which a 32-byte AES key is derived with HKDF-SHA256; aUint8Arraykey is used verbatim and must be exactly 32 bytes. Truncation is deliberately avoided so two secrets sharing a 32-byte prefix cannot collide. - Key rotation —
previousKeysis decrypt-only:encryptalways useskey, whiledecrypttrieskeyfirst, then eachpreviousKeysentry in order. - AAD — an optional
aadstring is bound into the GCM tag; a mismatch fails authentication withdecryption_failed. - No global secret — the key is always passed explicitly; there is no
JSAILS_ENCRYPTION_KEYenvironment fallback. - Value-free errors —
EncryptionErrornever echoes the key, plaintext, ciphertext, token, or AAD.
v1 limits: no passphrase KDF (a string key is a secret, not a password); no
encrypts-style entity attribute hook yet — GCM is non-deterministic, so an
encrypted column cannot be queried by equality (deferred to T1.6b). The module is
server-only and must never enter the browser-safe jsails/api/jsails/client
graphs.
Job middleware, chaining, and batching
jsails/jobs provides three built-in composition patterns — middleware,
chaining, and batching — that run on top of the neutral job runtime. All three
are worker-side only: they compose around the handler after payload
validation, and dispatch options carry the control descriptors.
Per-job middleware
composeMiddleware wraps the handler with JobMiddleware functions registered
per job name via createJobsRuntime({ middleware: { [jobName]: [mw, ...] } }).
Middleware runs in declared order outermost-first on the worker; next() is
callable at most once, and returning without next() short-circuits the chain.
Errors propagate as JobMiddlewareError (codes invalid_middleware /
middleware_threw).
import { createJobsRuntime, composeMiddleware } from 'jsails/jobs';
const loggingMiddleware = async (ctx, next) => {
console.log(`starting ${ctx.name} (${ctx.jobId})`);
await next();
console.log(`finished ${ctx.name}`);
};
const runtime = createJobsRuntime({
registry,
adapter,
middleware: { sendEmail: [loggingMiddleware] },
});
Chaining
createJobChain(runtime, steps) builds a sequential pipeline: the
chainMiddleware, registered on every chained job, enqueues step N+1 only after
step N's handler succeeds. The chain descriptor rides in dispatch options under
the reserved CHAIN_OPTION_KEY. Chaining is at-least-once: a retried step
re-runs the handler and continues on success — handlers must be idempotent.
JobChainError (codes empty_chain / invalid_step) rejects a malformed chain.
import { createJobChain, chainMiddleware, CHAIN_OPTION_KEY } from 'jsails/jobs';
const runtime = createJobsRuntime({
registry,
adapter,
middleware: { stepA: [chainMiddleware], stepB: [chainMiddleware], stepC: [chainMiddleware] },
});
const chain = createJobChain(runtime, [
{ job: 'stepA', data: { file: 'report.csv' } },
{ job: 'stepB', data: {} },
{ job: 'stepC', data: {} },
]);
await chain.dispatch();
Batching
createJobBatch(runtime, coordinator, items, callbacks?) fans out N jobs, each
carrying a BatchDescriptor under the reserved BATCH_OPTION_KEY. The per-job
createBatchMiddleware(coordinator) records every item's outcome into an
injectable BatchCoordinator; when all items settle, then (all ok) /
catch (any failed) / finally (always) fire once, in that order. Recording
is idempotent per item index; settlement is single-fire; callback errors are
swallowed. JobBatchError (codes empty_batch / invalid_item) rejects a
malformed batch.
import {
createJobBatch,
createBatchCoordinator,
createBatchMiddleware,
} from 'jsails/jobs';
const coordinator = createBatchCoordinator();
const runtime = createJobsRuntime({
registry,
adapter,
middleware: { processItem: [createBatchMiddleware(coordinator)] },
});
const batch = await createJobBatch(runtime, coordinator, [
{ job: 'processItem', data: { id: 1 } },
{ job: 'processItem', data: { id: 2 } },
], {
then: (summary) => console.log('all done', summary),
catch: (failed) => console.error('some failed', failed),
});
Limitation: the built-in coordinator is in-process. A deployment with separate producer and worker processes must supply a coordinator backed by shared storage, or callbacks will not fire.
Task scheduling and overlap control
jsails/jobs provides a ScheduleDefinition.overlap option to prevent
concurrent runs of the same scheduled job. The overlap descriptor rides in the
scheduled job's dispatch options under the reserved OVERLAP_OPTION_KEY, and a
per-job middleware — createOverlapMiddleware(mutex) — gates execution behind
a mutex store.
The mutex store itself lives in jsails/cache. It is a separate contract from
CacheStore: a cache is pure data whose set always overwrites, while a mutex
needs an atomic "acquire only when absent" operation. Two stores ship:
createMemoryMutexStore() (single-process; in-memory Map with expiry) and
createValkeyMutexStore({ valkeyUrl? }) (multi-process via atomic SET NX PX).
import { createJobsRuntime, createOverlapMiddleware } from 'jsails/jobs';
import { createMemoryMutexStore } from 'jsails/cache';
const mutex = createMemoryMutexStore();
const runtime = createJobsRuntime({
registry,
adapter,
middleware: {
digest: [createOverlapMiddleware(mutex)],
},
schedules: [
{ id: 'digest', job: 'sendDigest', cron: '0 3 * * *', overlap: true },
],
});
// Read-only schedule list — names and metadata only, never payloads or data.
const schedules = await runtime.listSchedules();
// Pause the named schedules (removes their repeatable jobs from the provider).
await runtime.pauseSchedules(['digest']);
Limits:
- Pause is non-durable —
pauseSchedulesremoves the repeatable job; the nextworkstart re-registers every schedule from config viaupsertSchedules, so a paused schedule resumes automatically on process restart. There is no separateresumeSchedulescall — resume by re-callingupsertSchedules. - The memory mutex is single-process only — competing worker processes each
have their own in-memory map, so they cannot see each other's locks. Use
createValkeyMutexStorefor multi-process deployments.
Next steps
- Testing — the in-process
createTestAppsurface and the nativenode:testsuite.