Services and context
A plugin does all input and output through the IntegrationContext it is given. It never imports fetch, touches the file
system, or reads the clock directly. That is what lets the app swap in the native HTTP stack and secure storage on a phone, and
lets tests swap in fakes.
// Platform services handed to plugins through IntegrationContext.export type QueryValue = string | number | boolean
export interface HttpRequest { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' url: string headers?: Record<string, string> /** Array values are sent as repeated parameters (`k=a&k=b`). */ query?: Record<string, QueryValue | QueryValue[] | undefined> body?: unknown timeoutMs?: number}
export interface HttpResponse<T = unknown> { status: number headers: Record<string, string> data: T}
/** Resolves for any HTTP status; rejects (TypeError-like) only on transport failure. */export interface HttpClient { request<T = unknown>(req: HttpRequest): Promise<HttpResponse<T>>}
export interface SecureStorage { get<T>(key: string): Promise<T | null> set<T>(key: string, value: T): Promise<void> remove(key: string): Promise<void>}
export interface IntegrationCache { get<T>(namespace: string, key: string): Promise<T | null> set<T>(namespace: string, key: string, value: T): Promise<void> remove(namespace: string, key: string): Promise<void> clearNamespace(namespace: string): Promise<void>}
export interface Logger { debug(message: string, data?: Record<string, unknown>): void info(message: string, data?: Record<string, unknown>): void warn(message: string, data?: Record<string, unknown>): void error(message: string, data?: Record<string, unknown>): void}
export interface Clock { /** ISO-8601 timestamp. */ now(): string}
export interface IntegrationContext { http: HttpClient secureStorage: SecureStorage cache: IntegrationCache logger: Logger clock: Clock}
/** Cache namespace for one instance, so plugins cannot touch each other's state. */export function instanceCacheNamespace(instanceId: string): string { return `integration-instance:${instanceId}`}| Service | Use it for | Notes |
|---|---|---|
http |
Every request to the source. | Resolves for any HTTP status, so check status yourself. It rejects only on transport failure, with a TypeError; pass that to toIntegrationError. Array query values become repeated parameters. |
secureStorage |
Secrets such as a session token you obtained after signing in. | Backed by the Keychain (iOS) or Keystore (Android). Do not put secrets anywhere else. |
cache |
Small, non-secret data worth keeping between runs, for example a computed index. | Namespaced per source with instanceCacheNamespace(instance.id), so plugins cannot touch each other’s data. |
logger |
Diagnostics. | Never log credentials or tokens. |
clock |
Timestamps such as syncedAt. |
Use it, not Date.now(), so tests are deterministic. |
Outside the app
Section titled “Outside the app”createFetchHttpClient() builds an HttpClient on the platform fetch, with the same contract (resolve on any status, reject
only on transport failure). Use it in scripts and manual experiments.
import type { HttpClient, HttpRequest, HttpResponse } from './services'
/** HttpClient over the platform `fetch`. Resolves for any HTTP status; rejects only on transport failure. */export function createFetchHttpClient(fetchImpl: typeof fetch = (...a) => fetch(...a)): HttpClient { return { async request<T>(req: HttpRequest): Promise<HttpResponse<T>> { const url = new URL(req.url) for (const [k, v] of Object.entries(req.query ?? {})) { if (v === undefined) continue for (const one of Array.isArray(v) ? v : [v]) url.searchParams.append(k, String(one)) } const headers: Record<string, string> = { ...req.headers } let body: string | undefined if (req.body !== undefined) { body = JSON.stringify(req.body) headers['Content-Type'] ??= 'application/json' } const controller = req.timeoutMs ? new AbortController() : undefined const timer = controller && setTimeout(() => controller.abort(), req.timeoutMs) try { const res = await fetchImpl(url.toString(), { method: req.method, headers, body, signal: controller?.signal }) const text = await res.text() let data: unknown = text if (text && (res.headers.get('content-type') ?? '').includes('json')) { try { data = JSON.parse(text) } catch { data = text } } const outHeaders: Record<string, string> = {} res.headers.forEach((v, k) => { outHeaders[k] = v }) return { status: res.status, headers: outHeaders, data: data as T } } catch (e) { // Aborts and DNS/TLS failures surface as TypeError so toIntegrationError maps them to network-unreachable. throw e instanceof TypeError ? e : new TypeError(e instanceof Error ? e.message : String(e)) } finally { if (timer) clearTimeout(timer) } } }}In tests
Section titled “In tests”createFakeContext({ http: myFakeServer }) from the /testing entry point gives you a complete context with in-memory secure
storage and cache, a silent logger and a fixed clock. Override only what a test cares about, usually http. See
Testing.