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/wrapDeletehelpers 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: 0withCredentials: trueCache-Control: no-cachePragma: no-cacheExpires: 0
Your axiosConfig is merged on top of those defaults.
Adapter Options
createAdapter(axiosConfig?, adapterOptions?)
Supported adapter options:
rootRouterPath?: stringonSuccess?: (res) => voidonFailure?: (res) => voidthrowOnError?: booleancacheTTL?: number— milliseconds a cached GET response is reused before revalidationcachePartition?: CachePartitioner— required to cache credentialed requests safely (see Cache Controls); returningundefinedbypasses the cache for that credentialed request so one identity cannot receive a response created under anothercacheCapacity?: number— bounds the number of cached entries per adapter (defaults to 100 with LRU eviction)modelDefaults?: Defaults— adapter-level defaults inherited by everycreateModelService<T>(...)unless overridden per-servicedataDefaults?: DataDefaults— adapter-level defaults inherited by everycreateDataService<T>(...)unless overridden per-service
Behavior notes:
rootRouterPathmust match the path used by your server-side root routerthrowOnErrorbecomes the default for services created by this adapteronSuccessandonFailurerun after the client normalizes the responsecacheTTL > 0installs in-memory Axios interceptors for cacheable requests; the value is measured in milliseconds; credentialed caching is off unlesscachePartitionreturns 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, set-cookie,
proxy-authorization, www-authenticate) are excluded from cache keys
regardless of the partition token.
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 NodeCookieheaders are explicit Axios config values;withCredentialsdoes not create them. ThecachePartitionpolicy 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 Nodeunref()optimization is feature-detected and is a no-op in browsers, soclearCache()anddisposeCache()are safe to call in either runtime. pnpm --filter @web-ts-toolkit/access-router-client test:browser-smokeruns 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 defaultpnpm 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: stringbasePath: stringqueryPath?: stringdefaults to__querymutationPath?: stringdefaults to__mutationonSuccess?: ResponseCallbackonFailure?: ResponseCallbackthrowOnError?: 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: stringbasePath: stringqueryPath?: stringdefaults to__queryonSuccess?: ResponseCallbackonFailure?: ResponseCallbackthrowOnError?: 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 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
throwOnErrorpolicy - 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 fora,b, thenc
The grouped result is an array of normalized response objects in the same order as the input requests.
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
awaitand.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 throughJSON.stringify,Object.keys, spread iteration, or consumer assignment/deletion/redefinition; direct property reads inside the client package (e.g. bygroup(...)) 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 frompathParamsqueryParamsbecome Axiosparams- 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 configuredqueryPath/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 asa/bbecomes the single path segmenta%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%2Fis sent as%252Fso that, after a single server-side decode, the route sees the literal%2Fstring — 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-ownedCACHE_HEADER("true"for cache-eligible,"false"for bypass). AnAxiosHeadersinstance is cloned vianew AxiosHeaders(headers.toJSON())before any value is set; a plain-object headers input is shallow-copied. Caller-suppliedCACHE_HEADERwins over theignoreCachedefault.getWrapContext(url, options, config)no longer writesqueryParamsinto the passedconfig.params. It returns{ ...config, params: queryParams }(or a fresh{ params }when only query params are supplied), leaving the caller'sconfiguntouched.prepareConfig(defaultConfig, cacheValue, requestConfig)no longer stampsCACHE_HEADERonto the captureddefaultConfig. It clonesdefaultConfig.headersinto a new object, sets the cache header on the clone, and feeds a spread copy ofdefaultConfigintomergeConfig, 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
axiosRequestConfigobject can be reused across many requests (success and failure) without acquiring hidden cache controls orparams. - the same
AxiosHeadersinstance 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.