@epistola.app/epistola-client

中文文档
2.0.0发布于 2026/11/12EUPL-1.2TypeScriptES Module零依赖构建来源已验证近 30 天下载 3038

Node.js client for the Epistola document generation API, on the platform's own fetch. Generated from the Epistola OpenAPI contract, with identity headers, RFC 9457 problem-detail error handling, API-key and self-signed JWT auth, NDJSON result collection,

原始使用文档
文档目录11 节
  1. Install
  2. Quick start
  3. Partial updates
  4. Binary downloads
  5. Uploading files
  6. Newer servers
  7. Error handling
  8. Result collection
  9. Template data validation
  10. Validating in-process instead
  11. Development

Epistola Node.js Client

Node.js client library for the Epistola document generation API, generated from the Epistola OpenAPI contract with OpenAPI Generator (typescript-fetch, on the platform's own fetch) and hand-written glue for identity, authentication, error handling, result collection, and client-side validation. TypeScript declarations are included.

It adds, on top of the stock generated client:

  • Client identity headers (User-Agent + X-EP-Node-Id) required on every request.
  • RFC 9457 problem-detail error handling — a typed ProblemDetailException with a typeSlug discriminator and generated KnownProblemSlugs constants.
  • Static API-key authentication via Authorization: ApiKey <key>, the supported method.
  • Self-signed JWT authentication (JwtSigner, experimental), minting a fresh short-lived token per request on node:crypto alone.
  • The Accept header each operation is declared with. The generated classes never set one, and Node's fetch sends */* by default — so without this a request never says it accepts application/problem+json, the document every error handler here is built to parse.
  • NDJSON result collection (ResultCollector) with adaptive polling, compression, and partition-aware routing helpers.
  • Template data validation (TemplateSchemaValidator), asking the server, with a pluggable TemplateDataValidator for consumers who want the check in-process; and generated validate<Model> helpers for the contract's own constraints.

The package version tracks the Epistola contract version (info.version) and releases in lockstep with the Kotlin, Jakarta EE, .NET and Python clients. It needs Node.js 22.12 or later and ships as an ES module (which Node's require() can load as well).

It has no runtime dependencies and no optional ones: HTTP is the platform's fetch, JWT signing is node:crypto, decompression is node:zlib. Template data validation asks the server rather than compiling schemas here, so there is no longer a JSON Schema compiler to install (see Template data validation).

Release history: CHANGELOG.md.

Install

npm install @epistola.app/epistola-client

Quick start

import { ClientIdentity, EpistolaClient, TemplatesApi } from '@epistola.app/epistola-client'

const identity = ClientIdentity.builder()
  .nodeId('my-pod-123')                       // defaults to the hostname
  .product('my-app', '1.0.0')                 // appended to User-Agent
  .build()

const client = EpistolaClient.builder('https://epistola.example.com/api')
  .identity(identity)                         // User-Agent + X-EP-Node-Id
  .apiKey('epk_...')                          // Authorization: ApiKey <key>
  .build()

const templates = new TemplatesApi(client)
const template = await templates.getTemplate({ tenantKey: 'acme', registry: '~', namespace: 'acme', catalogKey: 'invoices', release: '1.4.0', templateKey: 'invoice' })

A catalog is addressed by its registry, namespace and catalogKey. registry: '~' addresses the server's default registry; any other registry is named in full (registry: 'acme.com').

EpistolaClient.builder(baseUrl, apiKey) is a shorthand for the same thing. Some Epistola Suite deployments disable API-key authentication entirely; switch on e.typeSlug === KnownProblemSlugs.API_KEY_AUTH_DISABLED and guide the caller to JWT auth.

Self-signed JWT authentication is experimental: Epistola Suite may not implement it yet, and the flow may still change. To try it, pass .jwtSigner(signer) instead of .apiKey(...):

import { JwtSigner } from '@epistola.app/epistola-client'

const signer = JwtSigner.builder()
  .consumerId('invoice-service')
  .privateKey(JwtSigner.loadPrivateKey('private.pem'))
  .build()

EpistolaClient is a generated Configuration, so every generated API class takes it directly. One builder can produce more than one client: call build() again after changing requestTimeoutMs(...) for the two profiles a long-running consumer typically needs against the same backend — no timeout (the default) for polling, rendering and large transfers, a bounded one for everything else.

Partial updates

The generated models are plain TypeScript interfaces, and the generated serializers drop a property you never set while sending one you set to null as null. On the API's PATCH operations an omitted or null member keeps its value, because most generated servers cannot tell the two apart: updateConsumer({ ..., updateConsumerRequest: { name: 'Billing' } }) renames the consumer and touches nothing else, and so does { name: 'Billing', description: null }. To remove a value, send an empty string for an optional text member ({ description: '' }), an empty array or object to replace a list or a map with nothing, or the member's explicit flag ({ clearExpiresAt: true }, clearDefaultTheme, clearCodeListBinding).

Binary downloads

Every operation the contract declares as format: binary — downloadDocument, previewDocument, downloadImageContent — resolves to a Blob; Buffer.from(await blob.arrayBuffer()) gives you the bytes.

Uploading files

Multipart operations such as uploadImage and importCatalog take a Blob. Pass a File, which carries a name and a type:

import { readFile } from 'node:fs/promises'

const file = new File([await readFile('logo.png')], 'logo.png', { type: 'image/png' })
const image = await new ImagesApi(client).uploadImage({ tenantKey: 'acme-corp', registry: '~', namespace: 'acme-corp', catalogKey: 'main', release: 'k7m2x9qa', file, name: 'Logo' })

A plain Blob is always sent with the filename blob, and as application/octet-stream unless it was given a type. The server then names the image blob.

Newer servers

A minor version of the contract may add response members and enum values ("Compatibility within 2.x" in the API description), so this client keeps working against a later 2.x server: a response member it does not know is ignored, and an enum value it does not know is kept as the string received, though the generated type does not list it. Keep a default branch when switching on an enum.

What a client ignores it cannot send back. A request built from a response carries only the members this client knows. That matters where a write replaces what was read: a template document read with getVariantContent and written back with updateVariantContent loses every member a newer catalog wire version added. Edit template documents with a client at least as new as the server, and change a consumer's permissions by sending only the resources you change, never the object you read.

Error handling

Catch ProblemDetailException and switch on typeSlug against KnownProblemSlugs. The slug list is open — the API can introduce new problem types without a client release — so always keep a default branch and fall back to the HTTP status for unrecognized types.

import { KnownProblemSlugs, ProblemDetailException, ResponseError, TenantsApi } from '@epistola.app/epistola-client'

try {
  await new TenantsApi(client).getTenant({ tenantKey: 'acme' })
} catch (e) {
  if (e instanceof ProblemDetailException) {
    switch (e.typeSlug) {
      case KnownProblemSlugs.NOT_FOUND:
        log.warn(`tenant not found: ${e.detail}`)
        break
      case KnownProblemSlugs.VALIDATION_ERROR:
        for (const err of e.errors) log.warn(`${err.field}: ${err.message}`)     // field-level ValidationProblemDetail
        break
      case KnownProblemSlugs.DATA_MODEL_VALIDATION_ERROR:
        for (const [example, failures] of Object.entries(e.validationErrors)) {  // 422 data-model failures
          for (const f of failures) log.warn(`${example} ${f.path}: ${f.message}`)
        }
        break
      case KnownProblemSlugs.RELEASE_DEPENDENCIES_MISSING:
        for (const d of e.missingDependencies) {                                 // 409 from deployCatalogRelease
          log.warn(`${d.registry}/${d.namespace}/${d.catalogKey}@${d.version}: ${d.reason}`)
        }
        break
      case KnownProblemSlugs.RELEASE_DEPENDENCIES_UNRELEASED:
        for (const d of e.unreleasedDependencies) {                              // 409 from releaseCatalogDraft
          log.warn(`${d.registry}/${d.namespace}/${d.catalogKey}: draft ${d.releaseName} (${d.releaseId})`)
        }
        break
      case KnownProblemSlugs.CATALOG_READ_ONLY:
        // any write to a catalog that cannot be changed here
        log.warn(`catalog cannot be changed here: ${e.readOnlyReason}`)
        break
      case KnownProblemSlugs.MERGE_CONFLICTS_UNRESOLVED:
        for (const conflict of e.unresolvedConflicts) log.warn(`needs ours or theirs: ${conflict}`)  // mergeIntoCatalogDraft (alpha)
        break
      case KnownProblemSlugs.MERGE_PREVIEW_OUTDATED:
        log.warn('the draft or the release changed since the preview: preview again')  // expectedToken (alpha)
        break
      case KnownProblemSlugs.MERGE_BASE_MISSING:
        // the merge base is not installed here: install it first, then merge again
        log.warn(`install ${e.missingBaseVersion} (${e.missingBaseFingerprint}) first`)
        break
      case KnownProblemSlugs.CATALOG_VERSION_BEHIND:
        log.warn(`the draft is behind ${e.behind.join(', ')}`)                  // releaseCatalogDraft: name them in acknowledgedBehind
        break
      case KnownProblemSlugs.CATALOG_DRAFT_IN_USE:
        // deleteCatalogRelease: drafts of other catalogs name the draft, or it alone holds resources
        // something outside it uses (resourceType/resourceKey); change those first
        log.warn(`named by ${e.dependents.map((d) => `${d.catalogKey}: ${d.releaseName}`).join(', ')}; holds ${e.heldResources.join(', ')}`)
        break
      case KnownProblemSlugs.AMBIGUOUS_VARIANT:
        log.warn(`several variants match: ${e.tiedVariants.join(', ')}`)        // generateDocument, previewDocument: name one by variantKey
        break
      case KnownProblemSlugs.FORBIDDEN:
        // any refused authorization: a permission in a tenant, access to a tenant, a platform role,
        // or a policy (refusalReason), each read from this problem only
        log.warn(`refused: missing ${e.requiredPermission} in ${e.refusedTenantKey}`)
        break
      case KnownProblemSlugs.CATALOG_RENDERING_DEFAULTS_TOO_NEW:
        // importCatalog / installCatalogRelease: the release renders with rendering defaults this
        // installation does not have
        log.warn(`upgrade Epistola: the release needs ${e.extensions.renderingDefaultsVersion}, this has up to ${e.extensions.supportedVersion}`)
        break
      case KnownProblemSlugs.RELEASE_NUMBER_TAKEN:
        // publishReleaseToRegistry (alpha): the registry has this version with other content;
        // importCatalog, installCatalogRelease, installRegistryRelease: the tenant has this version
        // with another fingerprint, or this release id as another release
        log.warn('release the changes under a new version')
        break
      case KnownProblemSlugs.CATALOG_SOURCE_UNAVAILABLE:
        // installCatalogRelease / listAvailableCatalogReleases: 503 when the catalog's source could
        // not be reached (Retry-After may say when), 502 when it answered badly
        log.warn(`catalog source unavailable (${e.problemStatus}): ${e.detail}`)
        break
      default:
        throw e                                                                  // a type this client does not know: handle it by e.problemStatus
    }
  } else if (e instanceof ResponseError) {
    // an error response without a problem document
  }
}

Each field error's code says why the field failed (min-length, stencil-recursion, catalog-resources-not-ready, ...), or is undefined when the server sent none. Compare it against KnownValidationCodes, generated from the contract's registry, and keep a default branch: new codes may come in a minor version.

ProblemDetailException extends the generated ResponseError, so existing instanceof ResponseError sites keep working. It exposes type, typeSlug, title, problemStatus, detail, instance, help (the type's page with this request's context, such as ?operation=getTemplate, or undefined: switch on the type, not on this, and open it for hints), errors, validationErrors, missingFields, invalidFields, missingDependencies, unreleasedDependencies, readOnlyReason, unresolvedConflicts, isMergeConflictsUnresolvedProblem, missingBaseVersion, missingBaseFingerprint, isMergeBaseMissingProblem, behind, isCatalogVersionBehindProblem, environments, pinnedBy, isReleaseInUseProblem, dependents, heldResources, isCatalogDraftInUseProblem, tiedVariants, isAmbiguousVariantProblem, requiredPermission, refusedTenantKey, requiredPlatformRole, refusalReason, isForbiddenProblem, extensions (every member outside the base members, RFC 9457's five and help), statusCode, rawBody, isValidationProblem, isDataModelValidationProblem, isTemplateDataProblem, isReleaseDependenciesMissingProblem, isReleaseDependenciesUnreleasedProblem, and isCatalogReadOnlyProblem.

A release-dependencies-unreleased problem (409) means releaseCatalogDraft was refused because the draft's dependency declarations name drafts of other catalogs, and a release pins only releases. unreleasedDependencies lists each such draft (registry, namespace, catalogKey, releaseId, releaseName); release them first, or send releaseDependencies: true to release them along with the draft.

A catalog-draft-in-use problem (409) means deleteCatalogRelease was refused because something outside the draft still relies on it. dependents lists the drafts of other catalogs whose dependency declarations name it (registry, namespace, catalogKey, releaseId, releaseName), and heldResources the resources only it holds that something outside it still uses, as resourceType/resourceKey (theme/house-style); change those first.

A release-dependencies-missing problem (409) means deployCatalogRelease was refused because releases of other catalogs the release pins cannot be had: the tenant does not have them and they could not be fetched or verified. missingDependencies lists each, with a reason kept as the string the server sent (compare it against MissingReleaseDependencyReason). The dependency releases are used implicitly; pass includeDependencies: true to deploy them explicitly as well:

await new EnvironmentsApi(client).deployCatalogRelease({
  tenantKey: 'acme', environmentKey: 'production',
  registry: '~', namespace: 'acme', catalogKey: 'invoices', release: '1.4.0',
  includeDependencies: true,
})

Result collection

import { ResultCollector } from '@epistola.app/epistola-client'

const collector = ResultCollector.builder()
  .client(client)
  .tenantKey('acme')
  .handler(async (result) => console.log(result.requestId, result.status))
  .build()

await collector.start()   // adaptive polling; resolves after collector.stop()

Results stream in one at a time and the batch is acknowledged only after every result in it was handled: a handler that throws leaves the batch unacknowledged, so it is redelivered. gzip is always decoded; zstd is offered and decoded where Node's zlib has it (22.15+). partitionFor, isMyPartition and routingKeyToMe compute, from the assignment the server reports, a routing key whose result comes back to this node.

Template data validation

Template data is checked by Epistola, through validateTemplateData. Nothing is compiled here, so there is no schema compiler to install, and the verdict cannot disagree with what generation will do — the server validates the same data when a job is submitted, and it also knows which optional fields the release being rendered actually reads.

import { ServerTemplateDataValidator, TemplateSchemaValidator, ValidatingGenerationApi, validateCreateTenantRequest } from '@epistola.app/epistola-client'

const validator = new TemplateSchemaValidator(templatesApi)
// tenant, catalog registry (`~` for the server's default, or in full: 'acme.com'), namespace, catalog key, template
await validator.validate('acme', '~', 'acme', 'invoices', 'invoice', data)   // throws TemplateDataValidationException

// Against a particular release: a version range, an environment, or both together
const pinned = new TemplateSchemaValidator(
  new ServerTemplateDataValidator(templatesApi, { versionRange: '^1.4.0', environmentKey: 'production' }),
)

// Or around generation, which turns the server's `template-data-invalid` problem into the same error:
const generation = new ValidatingGenerationApi(new GenerationApi(client), templatesApi)

// A request model against the constraints the contract declares on it (key patterns, lengths, ranges):
validateCreateTenantRequest({ key: 'Acme Corp', name: 'Acme' })  // throws ModelValidationException

Each failure carries a JSON Pointer into the data (/customer/email), the JSON Schema keyword that failed, and a human-readable message — so a form can mark the field that is wrong.

Validating in-process instead

To avoid a request per check — pre-flighting a large batch, say — implement TemplateDataValidator over the compiler of your choice and pass it instead of the API:

const validator = new TemplateSchemaValidator(new MyAjvValidator(templatesApi, '1.4.0'))
const generation = new ValidatingGenerationApi(new GenerationApi(client), new MyAjvValidator(templatesApi, '1.4.0'))

A validator that answers in-process declares preflightsGeneration = true (the default), so ValidatingGenerationApi checks before submitting and reports every item of a batch at once, each path prefixed items[<index>]. TtlSchemaCache is still exported for caching the fetched schema; without it a "local" validator costs a round trip per call anyway. Templates live in releases of their catalog, so a local validator fetches its schema from a release you name (1.4.0 above): name the release you will render, since the server validates against that release, not against a draft.

Two things an implementation owes its callers, both described on TemplateDataValidator: an empty array means the data is acceptable, and path is a JSON Pointer. Compilers do not agree on that last point — Ajv's instancePath is already a pointer but names a missing property separately, while other engines emit a JSONPath — so converting is the adapter's job. A worked Ajv adapter, roughly forty lines, is in test/validation/local/ajvTemplateDataValidator.ts; copy it.

Be aware that a local verdict is its own: format assertion, dialect coverage and unknown-keyword handling are the compiler's choices, so it can accept data Epistola will refuse, or refuse data Epistola would render.

Development

The client is generated from the bundled spec. From the repository root:

make bundle                       # produce openapi.yaml
cd contracts/api/clients/nodejs-fetch
pnpm install --frozen-lockfile
./generate.sh                     # stock client + derived sources into src/generated/
pnpm build                        # dist/
pnpm test                         # node:test, against the compiled tree

src/generated/ (the stock client under api/, and the derived contractVersion, contractIdentity, contractMediaTypes, contractOperations, knownProblemSlugs and modelValidation) is gitignored and rebuilt from the spec each time. The cross-client conformance suite drives this client with make conformance-node.

版本与发布记录

@epistola.app/epistola-client 共发布 17 个版本,首次发布于 2026/09/09;过去 12 个月发布了 17 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
2.0.0latest2026/11/121 天前
2.0.0-rc.2next2026/11/103 天前
2.0.0-rc.12026/11/094 天前
2.0.0-beta.42026/11/077 天前
2.0.0-beta.32026/11/049 天前
2.0.0-beta.22026/11/0410 天前

关于 @epistola.app/epistola-client 的常见问题

@epistola.app/epistola-client 是什么?

Node.js client for the Epistola document generation API, on the platform's own fetch. Generated from the Epistola OpenAPI contract, with identity headers, RFC 9457 problem-detail error handling, API-key and self-signed JWT auth, NDJSON result collection,

如何安装 @epistola.app/epistola-client?

在项目目录运行 npm install @epistola.app/epistola-client,或使用 pnpm add / yarn add / bun add。

@epistola.app/epistola-client 支持 TypeScript 吗?

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

@epistola.app/epistola-client 支持 ESM 还是 CommonJS?

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

@epistola.app/epistola-client 需要什么版本的 Node.js?

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

@epistola.app/epistola-client 可以商用吗?

@epistola.app/epistola-client 使用 EUPL-1.2 许可。声明的许可为 EUPL-1.2,请阅读许可原文确认使用条件。以上为概括,不构成法律意见,请以许可原文为准。

@epistola.app/epistola-client 的下载量有多少?

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

@epistola.app/epistola-client 还在维护吗?

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