Shared API-client core for the OlivierZal SDKs (melcloud-api,
heatzy-api): the session lifecycle and request pipeline
(SessionAPI, which both SDKs subclass), the redaction-seated HTTP
client, the observability shells, and the resilience primitives — the
MECHANISM the SDKs used to carry as byte-identical twins. The protocol vocabularies (sensitive-key
sets, wire types, status semantics) stay in each consumer and are
INJECTED here, never owned.
The package lives on GitHub Packages:
@olivierzal:registry=https://npm.pkg.github.com
npm install @olivierzal/api-core
Pin it exactly — adoption of a new version is a reviewed PR, never a range.
| Import | Contents |
|---|---|
@olivierzal/api-core |
Everything: HttpClient/HttpError/HttpStatus and readHeaders, the redaction engine (createRedaction, BASE_SENSITIVE_KEYS, REDACTED), the observability shells (APICallRequestData, APICallResponseData, createAPICallErrorData, LifecycleEmitter), the resilience primitives, SessionAPI + SyncManager, the errors APIError/AuthenticationError/AuthenticationThrottledError/RateLimitError/RegistrySyncError/ValidationError and the guards isAPIError/isHttpError, the setting accessor decorator and the syncDevices method decorator factory, LoginCredentials, the lifecycle types — plus a re-export of the three flat subpath modules below (never ./testing) |
@olivierzal/api-core/fire-and-forget |
fireAndForget — the one sanctioned detach-and-log seam |
@olivierzal/api-core/temporal |
Temporal + Intl — the single temporal-polyfill entry point |
@olivierzal/api-core/testing |
The vitest-backed helpers the SDK suites share — cast, defined, mock, createLogger, createSettingStore, createMockHttpClient, mockFetchResponse, mockHttpResponse, the HttpError factories, the Temporal clock spies. Imports vitest from YOUR devDependencies; never re-exported by the root barrel |
@olivierzal/api-core/time-units |
MS_PER_SECOND, MS_PER_MINUTE, MS_PER_DAY, SESSION_REFRESH_AHEAD_MS |
setting persists a decorated accessor through your host's
SettingManager, under a key that IS the accessor's name — renaming
the accessor renames the stored key and strands the value (the rule
and its probe). syncDevices(params?) is the method
decorator factory that awaits the decorated method, then calls the
host's notifySync(params) — @syncDevices({ type }) forwards the
payload, @syncDevices() notifies without one; the host's notifySync
is a structural contract.
Every redaction seat takes a Redaction engine built from YOUR wire's
credential keys. The base vocabulary (authorization, cookie,
set-cookie, password, username, email, token) always applies;
createRedaction unions your keys on top, so extending can only ever
redact MORE. A second, separately declared tier names the user-entered
or identifying fields your wire carries (personalDataKeys: a device's
display name, an account holder's name) — blanked exactly like a
credential, in every dump and snapshot, and empty unless you fill it:
the core cannot know your field names, and the request/response dumps
print bodies up to MAX_LOGGED_BODY_BYTES (4 KiB; a larger body is
omitted whole and counted, so a hundred one-line records fit a Homey
diagnostic report and its upload), so a name stays out of a pasted
report only where you declare its key.
import {
AuthRetryPolicy,
createRedaction,
HttpClient,
HttpStatus,
RetryGuard,
} from '@olivierzal/api-core'
// One engine per SDK, seeded with its protocol's credential keys and
// the user-entered fields its payloads carry.
const redaction = createRedaction(['x-mitscontextkey', 'contextkey'], {
personalDataKeys: ['givenDisplayName'],
})
// Every HttpError this client throws carries a snapshot redacted
// through that vocabulary — request headers/body/params/url-query and
// response headers/body alike, at construction, not at log time.
const client = new HttpClient({
baseURL: 'https://api.example.com',
redaction,
timeout: 30_000,
})
// A wire that reports an expired token as 400 injects its statuses.
const authRetry = new AuthRetryPolicy(
new RetryGuard(60_000),
async () => resumeSession(),
[HttpStatus.Unauthorized, HttpStatus.BadRequest],
)
Constructing an HttpError, APICallRequestData,
APICallResponseData or createAPICallErrorData directly? Pass the
same engine. Without one, the base vocabulary applies — generic
carriers are always covered, protocol keys only where injected.
SessionAPI is the abstract session lifecycle and request pipeline:
persisted credentials, the login-backoff gate, single-flight session
refresh, the resilience pipeline around every request, and the
sync-cycle template. Extend it, hand it what your protocol knows, and
implement the twelve hooks (doAuthenticate, getAuthHeaders,
isAuthenticated, syncRegistry, enforceRegistrySync, …):
class MyAPI extends SessionAPI<MySyncParams> {
public constructor(config: MyConfig = {}) {
super(config, {
// YOUR resolver, YOUR HttpClient subclass — the core takes the
// transport already built, so a host-supplied client is judged
// against the class that seats your redaction vocabulary.
transport: buildTransport(config.transport),
defaultSyncIntervalMinutes: 5,
// Omit `rateLimitHours` for a wire that has never sent a 429;
// omit `logLabel` when one client per host needs no prefix.
authFailureStatuses: [HttpStatus.Unauthorized, HttpStatus.BadRequest],
})
}
protected override async doAuthenticate(
credentials: LoginCredentials,
): Promise<void> {
try {
await this.login(credentials)
} catch (error) {
// The same statuses, spelled once: a rejection on them becomes
// the shared AuthenticationError (cause preserved); anything
// else is rethrown verbatim — as a BARE `throw error`, the
// catch-clause rethrow `only-throw-error` admits (a `?? error`
// one-liner is typed `unknown` there and refused).
const authError = this.toAuthFailure(
error,
'Vendor rejected the credentials',
)
if (authError !== null) {
throw authError
}
throw error
}
}
}
The four settings it persists are named by their accessors — expiry,
loginBackoffUntil, password, username — so a host that already
holds those keys keeps its stored values.
A failure that repeats is ONE event in your log. Whatever cadence your
client polls on, a call that keeps failing and a registry cycle that
keeps failing are each reported when the streak opens, when its reason
changes, and at most every five minutes while it stands; the recovery
is one line counting the whole streak, the failure that opened it
included: GET /devices answered again after 42 failed attempts. Override the
protected logError to silence a dialect's expected failure — its error
ENTRY, not its recovery line, which the pipeline writes on its own
verdict: any HttpError opens a streak. The class is exported for a
dialect's own subjects — FailureStreaks, with failureReason for the
identity and FAILURE_REMINDER_INTERVAL_MS for the window — so a
failure the pipeline cannot key (a payload refusing a schema) is
streaked the same way rather than re-implemented; has() says whether
a subject's streak is open.
A failure the core has a class for — an HttpError, one of its own
errors, a transport failure (undici's fetch failed, the client's
TimeoutError, a caller's AbortError), anywhere in its cause chain
— is written as ONE line naming that chain, with no stack: Failed to fetch devices: TypeError: fetch failed — caused by ConnectTimeoutError: Connect Timeout Error (UND_ERR_CONNECT_TIMEOUT). A programming error
keeps its stack: it is logged as the label and the error object — under
a RegistrySyncError too, which is judged by what it wraps. The
same split is yours through logFailure(logger, label, error), with
isKnownFailure and failureCauseChain beside it for a line of your
own.
The helpers every SDK suite used to copy come from the ./testing
subpath — it imports vitest from your devDependencies and is never
re-exported by the root barrel:
import {
createMockHttpClient,
createSettingStore,
mockTemporalNowInstant,
} from '@olivierzal/api-core/testing'
// YOUR HttpClient subclass, so the spy-wrapped transport is the one
// your resolver accepts.
const { client, requestSpy } = createMockHttpClient(HttpClient, baseURL)
Full API reference: https://olivierzal.github.io/api-core/.
Maintainer doctrine lives in CLAUDE.md — including why
this package exists (the 2026-08-21 twin-divergence leak) and what may
enter it.