Skip to content
UniBox

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.

@unibox-cam/integration-sdk · services.ts
// 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.

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.

@unibox-cam/integration-sdk · fetch-client.ts
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)
}
}
}
}

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.