Skip to main content

Adapter And Setup

createAdapter(...) is the entrypoint for this package.

It creates:

  • a configured Axios instance
  • factory methods for model and data services
  • generic wrapGet / wrapPost / wrapPut / wrapPatch / wrapDelete helpers
  • group(...) for root-router batching

Basic Setup​

import { createAdapter } from '@web-ts-toolkit/access-router-client';

const adapter = createAdapter(
{
baseURL: 'http://localhost:3000/api',
withCredentials: true,
headers: {
Authorization: 'Bearer token',
},
},
{
rootRouterPath: 'root',
throwOnError: false,
cacheTTL: 30_000,
},
);

Default Axios config applied by the adapter:

  • baseURL: '/api'
  • timeout: 0
  • withCredentials: true
  • Cache-Control: no-cache
  • Pragma: no-cache
  • Expires: 0

Your axiosConfig is merged on top of those defaults.

Adapter Options​

createAdapter(axiosConfig?, adapterOptions?)

Supported adapter options:

  • rootRouterPath?: string
  • onSuccess?: (res) => void
  • onFailure?: (res) => void
  • throwOnError?: boolean
  • cacheTTL?: number — milliseconds a cached GET response is reused before revalidation
  • cachePartition?: CachePartitioner — required to cache credentialed requests safely (see Cache Controls); returning undefined bypasses the cache for that credentialed request so one identity cannot receive a response created under another
  • cacheCapacity?: number — bounds the number of cached entries per adapter (defaults to 100 with LRU eviction)
  • modelDefaults?: Defaults — adapter-level defaults inherited by every createModelService<T>(...) unless overridden per-service
  • dataDefaults?: DataDefaults — adapter-level defaults inherited by every createDataService<T>(...) unless overridden per-service

Behavior notes:

  • rootRouterPath must match the path used by your server-side root router
  • throwOnError becomes the default for services created by this adapter
  • onSuccess and onFailure run after the client normalizes the response
  • cacheTTL > 0 installs in-memory Axios interceptors for cacheable requests; the value is measured in milliseconds; credentialed caching is off unless cachePartition returns a stable, non-secret identity token

Cache Controls​

When cacheTTL > 0, createAdapter(...) returns an adapter exposing clearCache() and disposeCache():

  • clearCache() — drop every cached entry. Call on credential transitions: login, logout, token refresh, tenant change.
  • disposeCache() — drop entries AND release cache timers. Call when the adapter is torn down so they do not keep a Node process alive.

Credentialed requests are NEVER cached unless cachePartition is configured, to prevent one identity from receiving a response created under another. The partition token must be a stable, non-secret value (for example a user id or tenant id); sensitive headers (authorization, cookie, proxy-authorization, x-api-key, x-auth-token, x-access-token, set-cookie, www-authenticate) are excluded from cache keys regardless of the partition token.

The token is captured as the request enters the cache and framed separately from the typed body, so delimiter-like text cannot merge partition/body boundaries. Choose a token for every response-affecting identity/tenant context; redacting credential headers does not infer that identity.

Eligible concurrent callers share one transformed JSON/text body, including HTTP error bodies, regardless of which caller starts first. Response copies and config belong to each caller, which applies its own validateStatus. Strict JSON parse failures and transport failures reject attached Axios callers and release the slot for a later request; service calls retain their normal failure/throwOnError policy.

Browser cookie credentials controlled by withCredentials/CORS/cookie policy, explicit Authorization/proxy authorization headers, API-key style headers, and Node Cookie headers supplied on the Axios config are all treated as credentialed for cache partitioning. withCredentials does not create Authorization headers or a Node cookie jar.

Only GET requests using supported JSON or text response modes are eligible. Stream/blob/array-buffer/document responses, custom request or response transforms, custom parameter serializers, cancellation-sensitive requests, and values that cannot be serialized stably bypass both storage and in-flight deduplication. Successful POST/PUT/PATCH/DELETE requests invalidate cached reads even when made directly through adapter.axios without package headers.

Browser And Node Runtime​

The package supports browsers and Node (maintainer decision, ARC-19). The bundle targets es2022 and package.json declares engines.node: ">=22" and browserslist: ["chrome >= 94", "edge >= 94", "firefox >= 93", "safari >= 16"]; the same dist/index.mjs and dist/index.js run in either environment because the source imports no Node built-ins.

  • withCredentials: true (the adapter default) permits browser cookie credentials when CORS and cookie policy allow them. Authorization, proxy authorization, API-key style headers, and Node Cookie headers are explicit Axios config values; withCredentials does not create them. The cachePartition policy above applies identically to both runtimes: one identity cannot receive another's cached response.
  • The cache timers use setTimeout/clearTimeout, which are browser-native. The optional Node unref() optimization is feature-detected and is a no-op in browsers, so clearCache() and disposeCache() are safe to call in either runtime.
  • pnpm --filter @web-ts-toolkit/access-router-client test:browser-smoke runs a jsdom + Vite smoke test against the built bundle. It catches Node-only built-in leaks and basic ESM browser-bundling regressions, but it is not a real-browser engine/version gate. It also runs as part of the package's default pnpm test.

Matching Server Paths​

The adapter itself only knows the API root. Individual services provide the router-relative paths.

Example:

// server routes
// /api/users
// /api/users/__query
// /api/users/__mutation
// /api/root

const adapter = createAdapter(
{ baseURL: 'http://localhost:3000/api' },
{ rootRouterPath: 'root' },
);

const userService = adapter.createModelService({
modelName: 'User',
basePath: 'users',
queryPath: '__query',
mutationPath: '__mutation',
});

If those paths are out of sync with the server, the client will fail in ways that look like missing-route or invalid-body errors.

Creating Services​

Model services​

interface User {
_id?: string;
name: string;
role: string;
public: boolean;
}

const userService = adapter.createModelService<User>({
modelName: 'User',
basePath: 'users',
});

Model service options:

  • modelName: string
  • basePath: string
  • queryPath?: string defaults to __query
  • mutationPath?: string defaults to __mutation
  • onSuccess?: ResponseCallback
  • onFailure?: ResponseCallback
  • throwOnError?: boolean

Use custom queryPath or mutationPath only when your server uses non-default route segments.

Data services​

interface Fruit {
id: string;
name: string;
public: boolean;
}

const fruitService = adapter.createDataService<Fruit>({
dataName: 'fruit',
basePath: 'fruit',
});

Data service options:

  • dataName: string
  • basePath: string
  • queryPath?: string defaults to __query
  • onSuccess?: ResponseCallback
  • onFailure?: ResponseCallback
  • throwOnError?: boolean

Service Defaults​

Both service factories accept a second defaults argument.

That lets you centralize common args and options instead of repeating them on every call.

const userService = adapter.createModelService<User>(
{
modelName: 'User',
basePath: 'users',
},
{
listAdvancedArgs: {
select: ['name', 'role'],
limit: 25,
},
listAdvancedOptions: {
includeCount: true,
skim: true,
},
readOptions: {
includePermissions: true,
},
},
);

The service method call still wins if you pass explicit values later.

Defaults accept null, strings, booleans, finite numbers, valid Dates, plain objects and arrays; Dates and containers are detached per request. Unsupported values or cycles throw synchronous UnsupportedServiceDefaultValueError (not a package-root export).

Normalization allows 64 edges from the entire defaults bag (depth 0) and 10,000 expanded value visits across all operations together. Count the bag, args/options containers, primitive/Date leaves, array slots including holes, and repeated shared-object occurrences. Direct service constructors validate supplied defaults; adapter factories validate the effective shallow-merged adapter/service bag when creating each service. createAdapter itself does not normalize defaults. Overridden fields do not count; two valid bags can exceed the merged budget. Generated empty option bags are added after validation. Each per-request default clone checks its selected value as a fresh root with the same limits. Frozen inputs and request-shaped values have no exemption.

Limit errors use Service defaults depth limit 64 exceeded at <path> or Service defaults node limit 10000 exceeded at <path>; cycle errors use Service defaults do not support circular value at <path>. Paths start at defaults with .key and [index]. Reduce the effective defaults bag when migrating from unbounded inputs. These are structural work limits, not byte limits or bounds on arbitrary getters/proxies/exotic behavior. Correlated conversion has its own aggregate budget.

Defaults are most useful when:

  • every list should include counts
  • every read should include permissions
  • most advanced reads share the same default projection
  • you want one service instance tuned for admin flows and another for public flows

Root Batching With group(...)​

adapter.group(...) batches multiple lazy requests into one root-router request.

const grouped = await adapter.group(
userService.readAdvanced('user-1', { select: ['name'] }),
userService.countAdvanced({ public: true }),
fruitService.list({ limit: 5 }),
);

Important rules:

  • only pass lazy requests returned from this client package
  • only pass lazy requests created by this adapter's services — group(...) rejects requests owned by a different adapter before any network activity begins (each adapter stamps its services with a private identity token so cross-adapter grouping is caught locally)
  • never pass a lazy request that has already been awaited (or .then/.catch/.finally/.exec'd) — group(...) rejects already-started requests before any network activity begins, so an executed mutation cannot be replayed through the root router
  • every grouped request must share the same Axios request config
  • every grouped request must resolve to the same effective throwOnError policy
  • the requests are serialized into root-router query metadata and sent to rootRouterPath

Practical consequences:

  • if one grouped request uses headers: { user: 'admin' }, every grouped request should use that same config
  • mixing different auth headers or different request-scoped permission headers in one batch will throw before the request is sent
  • grouped requests preserve order, so group(a, b, c) returns results for a, b, then c

The grouped result is an array of normalized response objects in the same order as the input requests.

Config comparison and ordered params​

Grouping compares normalized configs: plain-object key order is ignored and undefined object properties are omitted; arrays remain ordered. Valid Dates compare by timestamp and AxiosHeaders by JSON form. URLSearchParams compare with distinct keys sorted, preserving each key's repeated-value order: mode=first&mode=second differs from mode=second&mode=first. Tag-shaped plain objects are not equal to actual Dates or URLSearchParams. Functions, cancellation controls, unsupported instances, and cycles reject before claims or dispatch. Original requests remain directly executable after failed config validation. Migrate groups that relied on these former collisions by aligning configs or executing separately.

Basic model list/read/create/upsert/update, data list, and basic subdocument calls clone caller URLSearchParams before merging generated service params. Non-generated entries retain order, duplicates and blanks. Generated keys replace every caller duplicate; nullish generated values omit the key (including undefined pagination defaults), while false/zero are retained. Ordinary object params retain Axios merge behavior. Grouped transport sends caller config once, with generated operation options in each body entry.

throwOnError batch policy​

Effective policy follows the same precedence as direct execution: a per-call value overrides the service default, which overrides the adapter default. When that effective policy is true, every executed entry is normalized and receives its success/failure callback exactly once. The entire batch then rejects with the first failed entry's ServiceError.

When throwOnError is omitted (or false) on every member, each entry returns its normalized { success: false, message, status, data: null, ... } payload inside the array — the caller is responsible for inspecting result[i].success per entry. This is the default behavior and matches how partial failures have historically been surfaced.

A batch is uniform: mixed effective throwOnError policies reject during preflight, before requests are claimed or network activity begins.

Each grouped operation failure retains the root router's structured problem result in raw, including code and errors when supplied. Group entry headers are {}: the current root protocol provides only headers for the outer batch HTTP response, and those are not operation-specific. A transport-level rejection of the outer root request produces one normalized failure per requested entry and invokes every failure callback once before the uniform throwOnError policy is applied.

Lazy request semantics​

Service methods return a LazyRequest<T> — a promise-like object that delays execution until first interaction.

  • the request does not execute until you await, .then(), .catch(), .finally(), or call .exec()
  • a single underlying promise is shared across all of those entry points, so repeated chaining (req.then(...); req.then(...); req.exec();) invokes the executor once rather than re-issuing the request
  • a synchronous throw from the executor is converted into a promise rejection, so it reaches await and .catch() as a normal rejection rather than escaping synchronously from .then()/.exec()
  • batching metadata (__op, __query, __requestConfig, __service) is installed non-enumerably, non-writable, and non-configurably, so it cannot accidentally leak through JSON.stringify, Object.keys, spread iteration, or consumer assignment/deletion/redefinition; direct property reads inside the client package (e.g. by group(...)) still work

Wrapped Endpoints​

The adapter can also wrap arbitrary endpoints that are not part of a model or data service.

const getApple = adapter.wrapGet<{ name: string }>('/apple/{{name}}');

const result = await getApple({
pathParams: { name: 'green' },
queryParams: { includeSeeds: true },
});

Supported methods:

  • wrapGet(url, defaultAxiosRequestConfig?)
  • wrapPost(url, defaultAxiosRequestConfig?)
  • wrapPut(url, defaultAxiosRequestConfig?)
  • wrapPatch(url, defaultAxiosRequestConfig?)
  • wrapDelete(url, defaultAxiosRequestConfig?)

Path and query behavior:

  • {{token}} placeholders in the URL are replaced from pathParams
  • queryParams become Axios params
  • per-call Axios config is merged with the wrapper default config

Dynamic path segment encoding​

Each value interpolated into a URL — whether a wrapper pathParams value, a model/data service identifier, a distinct field, or subdocument id/sub/subId segments — is encoded exactly once with encodeURIComponent before being placed in the path.

Behavior to keep in mind:

  • Static route separators (/) and server route names (distinct, count, new, __query, __mutation, __filter, your configured queryPath/mutationPath) are inserted by the client verbatim and are never encoded.
  • Values containing /, ?, #, space, or any other URL-significant character are percent-encoded, so an identifier such as a/b becomes the single path segment a%2Fb. The server receives and decodes it as one literal segment rather than splitting on the slash.
  • Already-encoded values are re-encoded (% becomes %25). An input of %2F is sent as %252F so that, after a single server-side decode, the route sees the literal %2F string — not a /.
  • Raw identifiers are retained in any JSON-RPC metadata sent to the root router; encoding applies only to HTTP path construction.

This is useful when:

  • your API mostly uses access-router, but still exposes a few custom endpoints
  • you want to keep one shared Axios instance and auth setup
  • you want cache handling and base URL behavior to stay consistent across all requests

Cache Behavior​

When cacheTTL > 0, the adapter installs a bounded in-memory cache for eligible GET requests. The package cache header can force a request to bypass caching, but it cannot make a mutation or unsupported response configuration cacheable.

Practical behavior:

  • GET wrappers default to cacheable requests
  • mutation-style wrappers default to cache-disabled requests
  • service methods can opt out of cache by passing ignoreCache: true
  • cache keys include URL, method, params, body, non-sensitive headers, and supported response semantics
  • the default capacity is 100 entries with deterministic LRU eviction

The cache is scoped to the Axios instance created by that adapter. Two adapters do not share a cache.

Headers matter intentionally here. Response-affecting non-sensitive headers participate in the cache key. Sensitive authentication headers are never serialized; use cachePartition to isolate credentialed identities.

Adapter-Level vs Service-Level Wrap Helpers​

You can wrap endpoints from the adapter or from a service.

Use adapter-level wrap helpers when the path is already rooted from the adapter base URL:

adapter.wrapGet('reports/{{id}}');

Use service-level wrap helpers when the endpoint should be relative to a service base path:

userService.wrapPost('chairman');

For service-level wrappers, the service base path is prepended automatically.

Configuration immutability​

The adapter never mutates caller-owned configuration objects. Three internal sites historically mutated their inputs; each has been replaced with a non-mutating equivalent:

  • Service.updateHeaders(headers, { ignoreCache }) returns a fresh headers object that includes the package-owned CACHE_HEADER ("true" for cache-eligible, "false" for bypass). An AxiosHeaders instance is cloned via new AxiosHeaders(headers.toJSON()) before any value is set; a plain-object headers input is shallow-copied. Caller-supplied CACHE_HEADER wins over the ignoreCache default.
  • getWrapContext(url, options, config) no longer writes queryParams into the passed config.params. It returns { ...config, params: queryParams } (or a fresh { params } when only query params are supplied), leaving the caller's config untouched.
  • prepareConfig(defaultConfig, cacheValue, requestConfig) no longer stamps CACHE_HEADER onto the captured defaultConfig. It clones defaultConfig.headers into a new object, sets the cache header on the clone, and feeds a spread copy of defaultConfig into mergeConfig, so repeated wrapper invocations against the same captured default are order-independent and leave the default equal to its original shape.

Practical implications:

  • the same caller axiosRequestConfig object can be reused across many requests (success and failure) without acquiring hidden cache controls or params.
  • the same AxiosHeaders instance can be passed to multiple service methods without being mutated.
  • wrapper default configs captured at adapter/service construction remain stable across the adapter's lifetime.