@littlemissrobot/highfive
v0.3.1
Published
A contract-first TypeScript library for building applications with interchangeable services and shared conventions.
Readme
Highfive
A contract-first TypeScript library for building applications with interchangeable services and shared conventions.
Installation
npm install @littlemissrobot/highfiveThe package ships ESM and CommonJS builds with TypeScript declarations.
Contracts
Highfive is a collection of small TypeScript contracts for application
infrastructure. Application code can depend on these interfaces instead of a
particular framework, vendor, or runtime implementation. Normative requirements
are in specs/. Those documents use the key words defined by
RFC 2119.
- Analytics
- Cache
- Clock
- Config
- Container
- ServiceProvider
- Tracer
- ErrorReporter
- EventBus
- Deduplicator
- RetryPolicy
- Timeout
- Filesystem
- HttpAuth
- HttpClient
- IdGenerator
- Logger
- UrlBuilder
- Notification
- Notifier
- Serializer
- SerializationCodec
- CodecRegistry
- Validator
- Transformer
Analytics
Analytics records a named tracking event with optional data.
LoggerAnalytics(logger) is the included adapter. It writes events through a
Logger.
Cache
Cache asynchronously reads, writes, and deletes string values, with optional
time-to-live support.
Included adapters:
InMemoryCache(clock)stores values for the lifetime of the instance.LocalStorageCache(namespace, clock)stores values in the browser'swindow.localStorage, under keys prefixed withhighfive:<namespace>:.
Both adapters expire entries lazily when they are read. A TTL must be a positive, finite number.
import {
InMemoryCache,
SystemClock,
} from '@littlemissrobot/highfive';
const cache = new InMemoryCache(new SystemClock());
await cache.set('session', 'active', { ttlMs: 60_000 });
const session = await cache.get('session');Clock
Clock returns the current Date, so application code can receive time from
outside the process clock.
SystemClock reads the system time. AdvanceableClock is a deterministic fake
whose time starts at the Unix epoch and can be moved forward with
advance(durationMs).
Config
Config<Values> reads a typed configuration value by key.
Highfive defines this contract and does not include a config implementation.
Container
Container<B> binds factories, binds lazy singletons, registers providers, and
resolves services with make. Its type includes dependencyTypes, the binding
map. Implementations declare that property and do not assign it. Callers do
not read it.
DefaultContainer is the included implementation. Create one and register
providers in dependency order. Container usage is optional; all other
implementations can be constructed directly.
ServiceProvider
ServiceProvider<Provides, Requires> describes the services a provider adds
and the services that provider needs before it can register them.
Tracer
Tracer observes an asynchronous operation while preserving its result or
error.
LogTracer(logger) measures an operation with performance.now() and logs its
duration and success or failure through a Logger.
ErrorReporter
ErrorReporter reports an error with optional context.
ConsoleErrorReporter writes the error and the optional context to
console.error.
EventBus
EventBus<E> publishes typed events and subscribes typed listeners.
InMemoryEventBus<E> invokes listeners synchronously in registration order.
on returns an unsubscribe function. Listener errors propagate from emit.
import { InMemoryEventBus } from '@littlemissrobot/highfive';
interface AppEvents {
signedIn: { userId: string };
signedOut: undefined;
}
const events = new InMemoryEventBus<AppEvents>();
const unsubscribe = events.on('signedIn', ({ userId }) => {
console.info(userId);
});
events.emit('signedIn', { userId: 'user-123' });
unsubscribe();Deduplicator
Deduplicator coalesces concurrent asynchronous work by key.
SingleFlightDeduplicator shares one pending promise per key and forgets it
after the operation settles.
RetryPolicy
RetryPolicy runs an asynchronous operation again after failure, according to
configurable retry rules.
DefaultRetryPolicy makes three attempts by default, with no delay. It
passes a one-based attempt number to the operation.
Timeout
Timeout applies a deadline and supplies an AbortSignal to an asynchronous
operation.
DefaultTimeout rejects with TimeoutError when its deadline is reached and
propagates cancellation from an optional parent signal. The operation should
observe its signal. A timeout cannot stop arbitrary JavaScript work that
ignores cancellation.
import {
DefaultRetryPolicy,
DefaultTimeout,
SingleFlightDeduplicator,
} from '@littlemissrobot/highfive';
const retry = new DefaultRetryPolicy();
const timeout = new DefaultTimeout();
const deduplicator = new SingleFlightDeduplicator();
const response = await deduplicator.run('current-user', () =>
retry.run(() =>
timeout.run(
signal => fetch('/api/me', { signal }),
5_000,
),
),
);Filesystem
Filesystem reads, writes, deletes, checks, and moves binary files.
Highfive defines this contract and does not include a filesystem implementation.
HttpAuth
HttpAuth transforms an HTTP request before it is sent.
BearerHttpAuth(getToken) obtains a token asynchronously and adds an
Authorization: Bearer <token> header. getToken receives the request's
AbortSignal.
HttpClient
HttpClient sends GET, POST, PUT, PATCH, and DELETE requests.
FetchHttpClient(auth?) uses the global fetch. It resolves with the native
Response for successful responses and rejects with HttpStatusError for
non-2xx responses. HttpStatusError exposes the response status.
import {
BearerHttpAuth,
FetchHttpClient,
HttpStatusError,
} from '@littlemissrobot/highfive';
const auth = new BearerHttpAuth(signal =>
tokenStore.getAccessToken({ signal }),
);
const http = new FetchHttpClient(auth);
try {
const response = await http.post('/api/tasks', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Ship it' }),
});
const task = await response.json();
}
catch (error) {
if (error instanceof HttpStatusError && error.status === 401) {
// Handle an expired or invalid session.
}
else {
throw error;
}
}IdGenerator
IdGenerator generates identifiers that are unique for the application's
lifetime.
CryptoIdGenerator delegates to globalThis.crypto.randomUUID().
Logger
Logger writes info, warning, and error messages.
ConsoleLogger(clock) writes timestamped messages to the corresponding
console method. NothingLogger implements Logger and discards every message.
UrlBuilder
UrlBuilder<Routes> builds a URL from a typed route table.
A route is either a string or a function that returns a string. RouteArgs
is the function's argument list, or an empty list for a string route.
DefaultUrlBuilder(routes) throws if the key is missing, the definition is
neither a string nor a function, or the function does not return a string.
import { DefaultUrlBuilder } from '@littlemissrobot/highfive';
const urls = new DefaultUrlBuilder({
home: '/',
profile: (id: string) => `/users/${id}`,
});
urls.make('home');
urls.make('profile', 'user-123');Notification
Notification describes a user-facing message, its level, and how long it
stays visible.
Omit durationMs to use an adapter's default duration. Set it to null to
keep a notification visible.
Notifier
Notifier shows a notification and dismisses one by the identifier notify
returned.
Highfive defines this contract and does not include a notifier implementation.
Serializer
Serializer<Serialized> converts a value to a serialized form and back.
Serialized defaults to string. T on deserialize is the static type of
the restored value. Check the result with a Validator when the caller needs
that value rejected or narrowed.
JsonSerializer serializes to a JSON string and implements CodecRegistry.
The constructor registers each codec it is given, in order. A plain object,
array, string, boolean, null, or finite number other than -0 round-trips.
undefined, a bigint, a symbol, a function, NaN, an infinity, -0, a
circular reference, or an object that is not a plain object throws a
TypeError unless a registered codec supports that value.
import { DateCodec, JsonSerializer, MapCodec } from '@littlemissrobot/highfive';
const serializer = new JsonSerializer([
new DateCodec(),
new MapCodec(),
]);
const json = serializer.serialize({
name: 'Ada',
joined: new Date('2020-01-02T03:04:05.000Z'),
scores: new Map([['a', 1]]),
});
const user = serializer.deserialize<{
name: string;
joined: Date;
scores: Map<string, number>;
}>(json);serializerProvider registers that serializer, including DateCodec,
BigIntCodec, URLCodec, URLSearchParamsCodec, RegExpCodec, MapCodec,
SetCodec, Uint8ArrayCodec, and ArrayBufferCodec, as the serializer
singleton.
SerializationCodec
SerializationCodec<T> preserves a value that the serialized form cannot
represent on its own. type identifies the codec inside a serialized value.
supports reports whether the codec claims a value. encode returns the
representation to store. decode restores T from that representation.
Each built-in codec supports every value of its type. encode stores the
representation below. decode restores the value from that representation
and throws a TypeError for any other value. DateCodec also throws a
TypeError when encoding an invalid Date. Uint8ArrayCodec and
ArrayBufferCodec throw a TypeError when the buffer is detached.
| Codec | Representation |
| --- | --- |
| DateCodec | toISOString() |
| BigIntCodec | Canonical decimal string, such as "0", "1", or "-42" |
| URLCodec | href |
| URLSearchParamsCodec | toString() |
| RegExpCodec | { source, flags } using the canonical flags string. lastIndex is omitted |
| MapCodec | Array of [key, value] pairs in insertion order |
| SetCodec | Array of values in insertion order |
| Uint8ArrayCodec | Array of integers from 0 through 255, covering the view |
| ArrayBufferCodec | Array of integers from 0 through 255 |
CodecRegistry
CodecRegistry makes serialization codecs available to a serializer.
register adds a codec. An empty type, or a type that is already
registered, throws a TypeError. When several codecs support a value, the
first one registered serializes it. The JSON records that codec's type.
Validator
Validator<T> checks an unknown value and returns a typed result, or throws
when the value is rejected. ValidationIssue describes one reason a value was
rejected.
A rejected value throws an Error named ValidationError.
ValidationError is the included error for that shape. issues lists what
was rejected. An empty path applies to the whole value. A string addresses
an object property, and a number addresses an array index. A custom adapter
implements Validator<T> and throws ValidationError when it rejects a
value.
Included adapters:
StringValidatoraccepts a string.minLengthandmaxLengthare inclusive. Length counts UTF-16 code units.nonEmptyrejects''.NumberValidatoraccepts a finite number.minandmaxare inclusive.IntegerValidatoraccepts a safe integer.minandmaxare inclusive.BooleanValidatoracceptstrueorfalse.EmailValidatoraccepts an address with one@, an ASCII local part, and a domain of at least two dot-separated ASCII labels. The final label is at least two characters.URLValidatoraccepts a string that theURLconstructor can parse, and returns that string.DateValidatoraccepts a validDate, or an ISO 8601 string, and returns aDate.YYYY-MM-DDis midnight UTC. A date-time usesTand aZor±HH:mmoffset.ArrayValidatorvalidates each item with another validator. A rejected item keeps its message, prefixed with the index.ObjectValidatorvalidates each declared field. A missing field is passed asundefined. Unknown fields are omitted. A rejected field keeps its message, prefixed with the field name.OptionalValidatoracceptsundefined, or a value accepted by another validator.OneOfValidatoraccepts a value that matches an allowed value withObject.is.
import {
EmailValidator,
ObjectValidator,
OptionalValidator,
StringValidator,
} from '@littlemissrobot/highfive';
const user = new ObjectValidator({
name: new StringValidator({ nonEmpty: true }),
email: new EmailValidator(),
nickname: new OptionalValidator(new StringValidator()),
});
user.validate({
name: 'Ada',
email: '[email protected]',
});Transformer
Transformer<In, Out> maps a value of type In to a value of type Out.
transform returns the Out defined for that input. The result may be the
same reference, a copy, or a value with a different shape. The same input
produces a deeply equal result on a later call. A value that cannot be
represented throws, and the implementation chooses the exception. There is no
included adapter.
interface ApiUser {
user_id: string;
}
interface User {
id: string;
}
const toUser: Transformer<ApiUser, User> = {
transform(api) {
return { id: api.user_id };
},
};Testing custom implementations
Reusable Vitest contract suites are exported from the package's
/testsuite entry point:
testAnalyticsContracttestCacheContracttestClockContracttestConfigContracttestContainerContracttestTracerContracttestErrorReporterContracttestEventBusContracttestDeduplicatorContracttestRetryPolicyContracttestTimeoutContracttestFilesystemContracttestHttpAuthContracttestHttpClientContracttestIdGeneratorContracttestLoggerContracttestUrlBuilderContracttestNotifierContracttestSerializerContracttestSerializationCodecContracttestCodecRegistryContracttestValidatorContracttestTransformerContract
import { InMemoryCache } from '@littlemissrobot/highfive';
import { testCacheContract } from '@littlemissrobot/highfive/testsuite';
let now = 0;
const clock = {
now: () => new Date(now),
};
testCacheContract(
'InMemoryCache',
() => new InMemoryCache(clock),
(durationMs) => {
now += durationMs;
},
);The test suites require Vitest 5 or newer. Keep adapter-specific tests for behavior outside the shared contract.
Optional: container and providers
new DefaultContainer() creates an empty typed container. Register only the
providers you need, in dependency order. Registering a provider before the
services it requires is a type error.
import {
cacheProvider,
clockProvider,
DefaultContainer,
loggerProvider,
} from '@littlemissrobot/highfive';
const container = new DefaultContainer()
.register(clockProvider)
.register(loggerProvider)
.register(cacheProvider);
container.make('logger').info('Application started');
await container.make('cache').set('ready', 'yes');Available providers are:
identifiersProviderclockProviderloggerProviderdiagnosticsProviderexecutionProvidercacheProviderhttpProvideranalyticsProviderserializerProvider
http reads httpAuth when http is first resolved. Replace the httpAuth
singleton before that resolution when requests need authentication.
InMemoryEventBus has no provider. Bind it, or any other application service,
on the container. Pass the extra bindings as the container's type argument so
singleton accepts them:
import type { EventBus } from '@littlemissrobot/highfive';
import {
DefaultContainer,
InMemoryEventBus,
} from '@littlemissrobot/highfive';
interface AppEvents {
signedIn: { userId: string };
}
const container = new DefaultContainer<{
events: () => EventBus<AppEvents>;
}>().singleton('events', () => new InMemoryEventBus<AppEvents>());Use bind for a new instance on every resolution and singleton for one lazy
instance. The first resolution of a singleton supplies the arguments that
create it; later resolutions return that instance. Rebinding a service
invalidates its cached singleton.
Optional: decorators
The decorators use TypeScript's standard decorator proposal and support asynchronous methods. Each decorator receives the dependency it calls. Pass that dependency when the class is defined.
@cached(cache, key, ttlMs?, options?)caches a fulfilled result. Withoutoptions.serializer, the value is stored withJSON.stringifyand read withJSON.parse.keymay be a string or a function of the method arguments. A TTL must be a positive, finite number when the cache enforces that rule.@retry(retryPolicy, options?)applies the givenRetryPolicy.@timeout(timeout, durationMs)applies the givenTimeout.@singleFlight(deduplicator, idGenerator, keyFor?)coalesces concurrent calls to the same method and instance.keyForcan add the method arguments to that identity.@trace(tracer, name?)uses the givenTracer. The name defaults to the method name.@log(logger, formatResult?, formatReceiver?)logs fulfilled results through the givenLogger.@emit(eventBus, event)emits fulfilled results to the supplied bus. The method's result type must match the event payload.@validate(validator)checks a fulfilled result with the givenValidator. The method's result type must match the validator's result type.
import {
cached,
DefaultRetryPolicy,
DefaultTimeout,
FetchHttpClient,
InMemoryCache,
retry,
SystemClock,
timeout,
} from '@littlemissrobot/highfive';
const cache = new InMemoryCache(new SystemClock());
const retryPolicy = new DefaultRetryPolicy();
const deadlines = new DefaultTimeout();
const http = new FetchHttpClient();
class UserService {
@cached(cache, (id: string) => `user:${id}`, 60_000)
@retry(retryPolicy, { attempts: 3, delayMs: 250 })
@timeout(deadlines, 5_000)
async find(id: string) {
const response = await http.get(`/api/users/${id}`);
return response.json();
}
}Decorators are applied from the method outward. Here, timeout wraps the
method first, then retry, then cached. Ordering changes behavior.
@timeout enforces a deadline but cannot pass its generated signal into the
decorated method; use the Timeout contract directly when the operation must
receive that signal. @cached stores JSON.stringify output and returns
JSON.parse output unless options.serializer is set. That serializer's
serialize result is what gets stored, and deserialize is what a hit
returns.
The same wrappers exist for a function. Pass the dependencies first, then the operation:
withCache(cache, key, ttlMs?, options?)withRetry(retryPolicy, options?)withTimeout(timeout, durationMs)withSingleFlight(deduplicator, idGenerator, keyFor?)coalesces concurrent calls to that function.keyFordistinguishes arguments.withTrace(tracer, name?)uses the function's name whennameis omitted.withLog(logger, formatResult?, name?)logs a fulfilled result.namedefaults to the function's name.withEmit(eventBus, event)withValidate(validator)withTransform(transformer)
Using the cache, retry policy, timeout, and HTTP client from the example above:
import {
withCache,
withRetry,
withTimeout,
} from '@littlemissrobot/highfive';
const find = withCache(cache, (id: string) => `user:${id}`, 60_000)(
withRetry(retryPolicy, { attempts: 3, delayMs: 250 })(
withTimeout(deadlines, 5_000)(async (id: string) => {
const response = await http.get(`/api/users/${id}`);
return response.json();
}),
),
);createFacades(container) returns one function per container binding. Calling
facades.clock() resolves that binding through container.make. The returned
object is not a promise, so awaiting it does not resolve the container.
createFlow(operation) returns that async function with a use method.
use takes a wrapper such as withRetry, withValidate, or withTransform
and returns the next flow. Wrappers apply from the inside out: the first
use is the innermost layer. The flow itself is the function you call.
import {
createFlow,
withRetry,
withTransform,
withValidate,
} from '@littlemissrobot/highfive';
const getUser = createFlow(async (id: string) => {
const response = await http.get(`/api/users/${id}`);
return response.json();
})
.use(withRetry(retryPolicy, { attempts: 3, delayMs: 250 }))
.use(withValidate(userValidator))
.use(withTransform(toUser));
const user = await getUser('ada');installBrowserErrorReporting(reporter) listens for error and
unhandledrejection on window and sends both to an ErrorReporter. An
error event is reported with source: 'uncaught-exception'. An
unhandledrejection event is reported with source: 'unhandled-rejection'.
The returned function removes those listeners. This helper needs a browser
window.
import { installBrowserErrorReporting } from '@littlemissrobot/highfive';
const uninstall = installBrowserErrorReporting(reporter);
uninstall();Runtime requirements
- Node.js 18 or newer, or a modern browser, for the web APIs used by the included implementations.
- TypeScript 5 or newer when using decorators.
fetch,Headers,Response, andAbortControllerforFetchHttpClientandBearerHttpAuth.crypto.randomUUIDforCryptoIdGenerator.performance.nowforLogTracer.window.localStorageforLocalStorageCache.
Projects that depend only on the contracts do not need these implementation APIs.
Development
git clone https://github.com/Little-Miss-Robot/highfive.git
cd highfive
npm install
npm test
npm run buildLicense
MIT © Little Miss Robot
