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, 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:
import {
AuthRetryPolicy,
createRedaction,
HttpClient,
HttpStatus,
RetryGuard,
} from '@olivierzal/api-core'
// One engine per SDK, seeded with its protocol's credential keys.
const redaction = createRedaction(['x-mitscontextkey', 'contextkey'])
// 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,
syncCallback: async () => this.fetch(),
// 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.
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.