Skip to main content

Creating a client

import { createFrappeClient, tokenAuth, consoleLogger } from 'frappe-js-client'
import { withExtended } from 'frappe-js-client/extended'
import { retry } from 'frappe-js-client/middleware'

const frappe = createFrappeClient({
url: 'https://frappe.example.com',
auth: tokenAuth({ apiKey: '...', apiSecret: '...' }),
headers: { 'X-App': 'desk' },
timeout: 30_000,
logger: consoleLogger(),
middleware: [retry()],
})

createFrappeClient returns an immutable client: frozen config plus modules. Core modules are properties, not factories: frappe.db, frappe.auth, frappe.file, frappe.call, frappe.search.

The optional Docs generic defaults to object. Pass GeneratedDocTypes from frappe-codegen so db.getDoc('ToDo', name) infers the row.

const typed = createFrappeClient<GeneratedDocTypes>({ url, auth })

Options

OptionDefaultMeaning
urlrequiredSite base URL (http or https). Trailing / is stripped. Stored as config.baseUrl.
apiVersion21 = classic /api/method + /api/resource. 2 = /api/v2 (Frappe 15+). Only 1 or 2.
frappeVersionunset14 | 15 | 16. Optional hint for release-specific capabilities. Unset = conservative defaults, except Frappe 16 sites must pass 16 so validateLink does not call the removed validate_link RPC.
timeout30_000Milliseconds, per attempt. Must be finite and > 0.
authanonymousAuth()See Authentication.
headers{}Lowest precedence; per-request headers win. Frozen on config.headers.
siteNameunsetWhen the URL host is not the Frappe site (e.g. 127.0.0.1 vs frappe16.localhost). Sets X-Frappe-Site-Name, and Host outside browsers (browsers forbid Host). In a browser without siteName, X-Frappe-Site-Name defaults to window.location.hostname.
loggeroffReceives pathname + method + status + duration + requestId only — never query, headers, or bodies. See Logging.
middleware[]First entry is outermost. See Middleware.
fetchglobalThis.fetchIgnored if transport is set. For polyfills, instrumentation, or SSR runtimes without a global fetch.
transportFetchTransportReplace the network layer. createTestClient() injects MemoryTransport. FetchTransport is not a public export.
credentialssee belowRequestInit.credentials for the default transport.

Credentials default: 'include' when auth.name === 'cookie' and the runtime is a browser; otherwise 'same-origin'. XHR withCredentials follows the same rule.

Invalid url, apiVersion, frappeVersion, or timing values throw ConfigurationError before authentication or network activity — including when using an injected transport.

url must parse as http: or https:. Timeouts must be finite and positive. Per-request deadline must be a finite Unix timestamp in milliseconds.

Frozen config

frappe.config is FrappeClientConfig (not the input options):

FieldSource
baseUrlNormalized url (no trailing slash)
apiVersionDefault 2
frappeVersionOptional
timeoutDefault 30_000
siteNameOptional
headersFrozen copy
authStrategy instance
loggerOptional
middlewareFrozen array
fetchOptional override
transportOptional override
credentialsOptional override

There is no config.url. Use config.baseUrl.

Derived clients

withAuth, withMiddleware, and withHeaders return a new client. The original is unchanged.

MethodBehavior
withAuth(auth)Replaces the strategy
withMiddleware(...mw)Appends to the existing middleware list
withHeaders(headers)Merges over existing client headers (per-request headers still win)

Do not share one cookie-authenticated client across users in a Node server — derive per session:

import { cookieAuth } from 'frappe-js-client'

const perUser = frappe.withAuth(cookieAuth())
await perUser.auth.login({ username, password })

Extended modules stay attached when you derive from an extended client:

const app = withExtended(frappe)
const authed = app.withAuth(tokenAuth({ apiKey: 'k', apiSecret: 's' }))
await authed.report.run('Sales Analytics')

logger, transport, fetch, timeout, apiVersion, frappeVersion, siteName, and credentials are copied onto the derived client. There is no withLogger / withTimeout helper — create a new client if those must change.

Modules

PropertyPackageRole
authcorelogin, getLoggedUser, logout, forgetPassword, ping
dbcoreDocument CRUD, lists, bulk, passwords, link validation
callcoreArbitrary method RPC
filecoreMultipart upload and blob download
searchcoreLink search / titles
permissionextendedhas / getForDoc
workflowextendedTransitions, apply, bulk approval
deskextendedComments, assignments, tags, share
reportextendedReport view, query reports, prepared reports
siteextendedgetTimeZone

withExtended(client) shares the same transport, auth, and config — it does not open a second HTTP client.

Per-request options

Every public method (except file.upload, which uses UploadOptions) accepts a trailing RequestOptions:

interface RequestOptions {
signal?: AbortSignal
timeout?: number // per attempt; overrides client timeout
deadline?: number // wall-clock ms (`Date.now() + budget`) across retries
headers?: Record<string, string>
requestId?: string // generated if omitted; appears on `FrappeError.request.requestId`
}

timeout resets on each retry() attempt. deadline does not. The deadline covers authentication, token refresh, retry backoff, response parsing, and upload stream preparation as well as the network request.

Header overrides are case-insensitive: a per-request authorization replaces a configured Authorization entry instead of creating a duplicate.

Invalid per-request timeout / deadline throw ConfigurationError before the request is sent.

Logging

Pass logger: consoleLogger() or any { debug(event: FrappeLogEvent): void }.

FrappeLogEvent:

FieldMeaning
methodHTTP method
pathPathname only (query stripped)
statusHTTP status, or the mapped FrappeError.status on failure
durationMsElapsed time
requestIdCorrelation id
errorError.name only (never message) when the request threw

consoleLogger() writes a single console.debug line:

[frappe-js-client] GET /api/v2/document/User/Administrator -> 200 (12.4ms) [uuid]

A throwing logger is ignored — it cannot fail the request. There is no logging() middleware.

Custom transport

Implement Transport and pass it as transport:

import type { Transport, TransportRequest, TransportResponse } from 'frappe-js-client'

const tracing: Transport = {
async request<T>(req: TransportRequest): Promise<TransportResponse<T>> {
// issue the HTTP call yourself, then return { data, status, statusText, headers }
},
}

const frappe = createFrappeClient({ url, transport: tracing })

TransportRequest includes method, url, params, data, headers, responseType, signal, timeout, deadline, requestId, and onUploadProgress.

For tests, use createTestClient (MemoryTransport) instead of a hand-rolled fake. The default FetchTransport is not exported.

When you write a custom transport and still want retry / timing, compose them with composeMiddleware from frappe-js-client/middleware. After the pipeline, non-2xx responses are still mapped to FrappeError — see Fail closed.

Fail closed

If middleware returns a FrappeResponse whose status is not 2xx, the client throws mapServerError(...). Middleware cannot swallow HTTP errors by returning a non-2xx response. Throw or return 2xx.

Dates in filters

Filter values are string | number | boolean | null. Do not pass Date objects (JSON.stringify would emit a UTC ISO instant, which Frappe does not accept).

import { formatFrappeDate, formatFrappeDatetime } from 'frappe-js-client'

formatFrappeDate(new Date()) // YYYY-MM-DD, local time
formatFrappeDatetime(new Date()) // YYYY-MM-DD HH:mm:ss, local time
formatFrappeDatetime(new Date(), 'Asia/Riyadh') // explicit Frappe site timezone

Pass an IANA timezone from frappe.site.getTimeZone() when the runtime and Frappe site use different local timezones.

Transport notes

  • Default transport is fetch. It is not a public export.
  • onUploadProgress (file uploads: onProgress) cannot be combined with client middleware — XHR cannot run the middleware pipeline (ConfigurationError).
  • Without XMLHttpRequest, upload progress is best-effort (0% then 100%) around fetch.
  • Query booleans encode as '1' / '0'.
  • Auth onUnauthorized returning true replays once per logical request, even when retry middleware is also present.
  • There is no unsafeTransport() helper.