@baukit/api-runtime

中文文档
0.18.3发布于 2026/11/07MITTypeScriptES Module构建来源已验证近 30 天下载 8247

Shared client-side behavior for product APIs: environment selection, auth and request-ID headers, trace propagation, normalized errors, and safe retries.

原始使用文档
文档目录8 节
  1. Generated client usage
  2. Unauthorized recovery
  3. Unverified display identity hints
  4. Error handling
  5. Retry semantics
  6. Idempotent mutations
  7. Tests
  8. Profile and sign-in account deletion

@baukit/api-runtime

Shared client-side behavior for Baukit product APIs: explicit environment selection, bearer-token and request-ID headers, optional W3C trace propagation, normalized errors, safe retries, and a test fetch transport. It works in browsers and React Native and never reads process.env.

Generated client usage

The package wraps openapi-fetch; each product continues to own its generated paths type.

import { createApiClient, resolveApiEnvironment } from '@baukit/api-runtime';
import type { paths } from './generated/api.js';

const environment = resolveApiEnvironment('production', {
  development: 'http://localhost:3000',
  production: 'https://api.example.com',
});

export const api = createApiClient<paths>({
  ...environment,
  tokenProvider: async () => session.accessToken ?? null,
  onUnauthorized: async ({ canRetry }) => {
    if (!canRetry) return 'handled';
    const token = await session.accessToken({ forceRefresh: true });
    return token === undefined ? 'handled' : 'retry-once';
  },
});

const { data } = await api.GET('/widgets');

If a generated wrapper already creates the client, pass it a configured runtime instead:

import createClient from 'openapi-fetch';
import { createApiRuntime } from '@baukit/api-runtime';

const runtime = createApiRuntime({
  baseUrl: 'https://api.example.com',
  environment: 'production',
  tokenProvider: getAccessToken,
});

const api = createClient<paths>({ baseUrl: runtime.baseUrl, fetch: runtime.fetch });

tokenProvider runs for every logical request; the runtime does not cache its result. Every request receives a new UUID in x-request-id. Configure traceparentProvider only when the product has a tracing implementation.

Unauthorized recovery

onUnauthorized remains a notification hook when it returns void or 'handled': the normalized 401 is thrown as before. Returning 'retry-once' explicitly asks the runtime to reacquire credentials through tokenProvider and replay a preflighted request clone. The hook receives canRetry: false when the body cannot be cloned; returning 'retry-once' then safely falls back to the original 401. Recovery runs at most once, a second 401 stops, and the original AbortSignal remains effective during refresh and replay.

Set onUnauthorizedExhausted when the product must stop schedulers or move to a signed-out state after that replay also returns 401. It receives the final normalized error with canRetry: false; observer failures never replace the API failure.

A 401 whose error code is profile_erased means the backend fenced an erased profile, so new credentials cannot help. The runtime then skips onUnauthorized and onUnauthorizedExhausted and calls onProfileErased once. It awaits the handler and ignores its return value. The request still rejects with the ApiError whose code is profile_erased, and a handler failure never replaces it.

This handshake does not make arbitrary mutations safe. Replaying POST, PATCH, or any write whose outcome may already have committed requires an Idempotency-Key the server honors; see idempotent mutations and the replay-safe mutations protocol. Follow the repository's integration reliability recipe, offline replay contract, and add-endpoint replay/idempotency guidance.

Unverified display identity hints

unverifiedDisplayIdentityHintsFromJwt decodes local JWT claims for display only. It prefers name, then joined given_name and family_name, preferred_username, and email. It derives at most two initials. Invalid tokens and missing claims use product-supplied fallback text.

import { unverifiedDisplayIdentityHintsFromJwt } from '@baukit/api-runtime';

const hints = unverifiedDisplayIdentityHintsFromJwt(session.accessToken, {
  displayName: 'Local account',
  initials: 'LA',
});

The claims have not been verified. Never use these hints for authorization, a storage partition, analytics identity, cache ownership, or synchronization ownership. Those decisions need the server-validated subject. Existing web and native helpers can be replaced directly, but products must keep their fallback copy at the call site.

Error handling

Non-success responses throw one of three typed errors. Raw fetch/CORS errors are always wrapped.

import { isApiError, isHttpError, isNetworkError } from '@baukit/api-runtime';

try {
  await api.POST('/widgets', { body: widget });
} catch (error) {
  if (isApiError(error, 'validation_failed')) {
    showValidation(error.details, error.requestId);
  } else if (isNetworkError(error)) {
    showOfflineMessage();
  } else if (isHttpError(error)) {
    reportUnexpectedResponse(error.status, error.requestId);
  } else {
    throw error;
  }
}
  • ApiError: a valid Baukit { error: { code, message, requestId, details } } envelope.
  • HttpError: an HTTP failure with a missing or malformed envelope, including non-JSON bodies.
  • NetworkError: no HTTP response, such as an offline, DNS, fetch, or CORS failure. Aborts set aborted to true.

The backend message is public, safe fallback text. Localized clients should resolve ApiError.code plus structured ApiError.details through their product catalog and use ApiError.message only when that resolution is unavailable. Do not parse the message or use it as a stable localization key.

Retry semantics

Retries use exponential backoff with full jitter, capped by maxDelayMs. The same request ID is retained across attempts.

Request/resultDefaultConfigurable
GET, HEAD + network errorRetrymaxRetries, delays, or disable
GET, HEAD + 502/503/504RetrymaxRetries, delays, or disable
OPTIONS, PUT, DELETENo retryMay be explicitly opted in through methods
Keyed POST, PATCHNo retryMay be explicitly opted in through methods
Unkeyed POST, PATCHNeverNo
Any 4xxNeverNo
AbortStop immediatelyNo

Defaults are two retries, a 100 ms initial ceiling, and a 2,000 ms maximum ceiling. Use retry: false to disable retries. A POST or PATCH counts as keyed when the request carries the Idempotency-Key header (IDEMPOTENCY_KEY_HEADER). Every attempt resends the same headers and body, so the server sees one key.

The delay formula is public as fullJitterBackoffMs(retryIndex, { baseDelayMs, maxDelayMs, random }) in @baukit/api-runtime/backoff. It returns a delay between 0 and min(maxDelayMs, baseDelayMs * 2 ** retryIndex) and clamps the random sample to [0, 1]. That entry imports nothing, so a sync loop or job runner can share the policy without loading the fetch runtime. @baukit/sync-client's scheduler takes it as its retry delay.

Idempotent mutations

@baukit/api-runtime/idempotency keeps one Idempotency-Key per account, operation, and body until the outcome is definite or the key expires. The server half is baukit-http's IdempotencyKeyRule, and the protocol is replay-safe mutations.

import { createApiFetch, IDEMPOTENCY_KEY_HEADER } from '@baukit/api-runtime';
import { createIdempotencyKeyStore, sendIdempotentMutation } from '@baukit/api-runtime/idempotency';

const keys = createIdempotencyKeyStore({ ttlMs: 12 * 60 * 60 * 1000 });
const apiFetch = createApiFetch({
  baseUrl: 'https://api.example.com',
  environment: 'production',
  retry: { methods: ['GET', 'HEAD', 'POST'] },
});

const note = { title: 'Standup' };
await sendIdempotentMutation(
  keys,
  { account: 'user-1', operation: 'createNote', body: note },
  (key) =>
    apiFetch('/notes', {
      method: 'POST',
      headers: { [IDEMPOTENCY_KEY_HEADER]: key, 'content-type': 'application/json' },
      body: JSON.stringify(note),
    }),
);

sendIdempotentMutation asks the store for the intent's key, calls send, and settles the key with the outcome. The store compares intents by canonical JSON, so member order in the body does not matter. classifyMutationError and classifyMutationStatus decide the outcome:

ResultOutcomeKey
2xxcommittedDropped
Network error, abort, 408, 429, 5xx, 409 idempotency_key_in_progress, any other throwpossibly-committedKept
Any other 4xxnot-committedDropped

A kept key is reused for the same intent until ttlMs passes; keep ttlMs below the server's replay retention. After that the store mints a new key, so a request never carries a key the server may already have purged. Keys default to crypto.randomUUID() and live in memory, so a reload forgets them. Pass storage, an IdempotencyKeyStorage with get, set, and delete (sync or async), to keep keys in sessionStorage, AsyncStorage, or SQLite. Its slot argument is the intent's canonical JSON; store a digest of it when bodies are sensitive.

Calls for the same intent share one key from the first keyFor until the intent settles. A double submit therefore sends two requests with one key, and the server replays the first result or answers 409 idempotency_key_in_progress, which keeps the key. This holds while storage is still reading and when storage drops writes, as a full sessionStorage does. The store does not merge the two requests into one; each caller gets its own response. A lookup that fails does not block the next one.

body takes any value that JSON carries unchanged, checked against its own type through JsonCompatible<Body>. Interfaces without an index signature pass, and so do optional members typed T | undefined. A Date, a function, or an unknown member fails to compile. Members set to undefined are left out of the key, as JSON.stringify leaves them out of the request.

The Baukit classifier knows ApiError, HttpError, and NetworkError. A product that throws its own API error passes classifyError to the store. Return an outcome for the errors the product knows and undefined for the rest, which falls through to classifyMutationError. classifyMutationStatus(status, code) applies the table above, including the in-progress code:

const keys = createIdempotencyKeyStore({
  ttlMs: 12 * 60 * 60 * 1000,
  classifyError: (error) =>
    error instanceof ProductApiError ? classifyMutationStatus(error.status, error.code) : undefined,
});

sendIdempotentMutation settles a failed send with store.classifyError(error). Code that calls keyFor and settle directly can call the same method.

@baukit/data-contracts/revisioned-writes decides when a document write is resent; this store decides which key it carries. A revisioned PATCH with If-Match needs no key. A keyed create sent from the queue's write callback puts any expected revision in body, so a resend after an unknown outcome maps to the same key.

Tests

MockFetch queues responses, errors, or handlers and records cloned requests:

const mock = new MockFetch().enqueueJson({ widgets: [] });
const runtime = createApiRuntime({
  baseUrl: 'https://api.example.test',
  environment: 'test',
  fetch: mock.fetch,
});

await runtime.fetch('/widgets');
mock.assertRequest(0, { method: 'GET', url: 'https://api.example.test/widgets' });
mock.assertQueueEmpty();

Profile and sign-in account deletion

Import createProfileErasureClient from @baukit/api-runtime/erasure. Pass the account's immutable subject, an authenticated fetch, and durable IdempotencyKeyStorage. Persist keys separately from tokens. The template adapters hash the storage slot so it contains no raw subject.

const erasure = createProfileErasureClient({
  account: subject,
  fetch: runtime.fetch,
  storage: persistedKeys,
});
const receipt = await erasure.erase(signal);
if (receipt.status === 'pending' && receipt.operationId !== null) {
  const operation = await erasure.poll(receipt.operationId, { signal });
}

erase sends DELETE /me with Idempotency-Key, and poll reads GET /me/erasures/{operationId}. Pass routes: { eraseMethod, erasePath, statusPath } for other routes, for example { eraseMethod: 'POST', erasePath: '/v1/me/erasure', statusPath: '/v1/me/erasure/{operationId}' }; templateProfileErasureRoutes holds the defaults. It validates the 200 completed and 202 pending bodies. A network drop, abort after sending, malformed success body, or uncertain server error throws AmbiguousProfileErasureError. Its code is compatible with eraseProductProfile. Keep the same key for a later retry, even after a reload. A completed or pending receipt, including 401 profile_erased, settles the key as committed and removes it from durable storage. An uncertain key does not expire on a timer. If key removal fails, ProfileErasureKeyCleanupError carries the committed receipt so callers can still erase local data and sign out, then report the local cleanup failure. The client releases a key on 409 erasure_idempotency_conflict. Other rejections keep it because an earlier attempt could have committed before a response was lost.

A 401 profile_erased confirms that server profile erasure committed. It returns pending with a null operation ID because the fence does not prove the sign-in account is already gone. An ordinary 401 still rejects.

poll reads GET /me/erasures/{operationId} immediately, then waits 500 ms, 1 second, 2 seconds, and at most 5 seconds between reads. It stops at completed, failed, or eight reads and returns the last pending state at that limit. maxAttempts, initialDelayMs, and maxDelayMs can change those bounds. An abort cancels both the fetch and the backoff timer. A failed operation needs operator help; clients must not send another deletion to repair it.

The auth templates capture a short-lived access token for this operation before clearing local authentication. Status checks use that token while the screen stays open, without refreshing or restoring a signed-out session. A failed status check does not undo deletion; the server worker continues.

版本与发布记录

@baukit/api-runtime 共发布 67 个版本,首次发布于 2026/08/26;过去 12 个月发布了 67 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.18.3latest2026/11/07今天
0.18.22026/11/061 天前
0.18.12026/11/062 天前
0.18.02026/11/052 天前
0.17.12026/11/034 天前
0.17.02026/11/025 天前

关于 @baukit/api-runtime 的常见问题

@baukit/api-runtime 是什么?

Shared client-side behavior for product APIs: environment selection, auth and request-ID headers, trace propagation, normalized errors, and safe retries.

如何安装 @baukit/api-runtime?

在项目目录运行 npm install @baukit/api-runtime,或使用 pnpm add / yarn add / bun add。

@baukit/api-runtime 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@baukit/api-runtime 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@baukit/api-runtime 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@baukit/api-runtime 可以商用吗?

@baukit/api-runtime 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@baukit/api-runtime 的下载量有多少?

根据 npm 官方下载统计,@baukit/api-runtime 在 2026/10/08 – 2026/11/06 这 30 天内被下载 8,247 次,日均约 275 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@baukit/api-runtime 还在维护吗?

最新版本 0.18.3 发布于 2026/11/07(今天);至今共发布 67 个版本,其中过去 12 个月发布了 67 个;首次发布于 2026/08/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。