SDK

Resolvers

Last updated on

Load file-based layers and templates for rendering

Resolvers provide a unified interface for loading file-based content (templates, assets) when rendering forms. They abstract away the underlying storage mechanism, whether in-memory, filesystem, or remote.

The @paradoc/resolvers package provides filesystem, HTTP, and memory resolvers. Install it separately from the SDK with npm install @paradoc/resolvers, and import the entrypoint for your environment.

Resolver Interface

All resolvers implement this simple interface:

interface Resolver {
  read(path: string): Promise<Uint8Array>
}

A resolver is bound once, when an artifact is constructed — p.form(schema, { resolver }), a builder's .build({ resolver }), or p.load(content, { resolver }). Every instance you derive from it afterward — every fill, every mutator, every render call — carries that same resolver, so you never pass it again.

ArtifactInstanceOptions

The options object every construction entry point accepts:

interface ArtifactInstanceOptions {
  resolver?: Resolver
}

It's accepted by:

  • p.form(input, options?), form.from(input, options?), form.safeFrom(input, options?), and a form builder's .build(options?) — and the same four entry points for document and checklist.
  • load, loadFromObject, safeLoad, and safeLoadFromObject, when reconstructing a form, document, or checklist. (Loading a bundle takes no resolver: a bundle has no file-backed layers of its own, only content entries that carry their own.)
  • runtimeFormFromJSON, runtimeDocumentFromJSON, and runtimeChecklistFromJSON, when rebuilding a runtime (filled) instance from its JSON.

resolver is the only field, and it's optional: pass it only when the artifact has file-backed layers. It's bound once, at construction, and every instance you derive afterward — every fill, mutator, and clone — carries it. A render call never takes one.

Render a file-backed layer with no resolver bound, and Paradoc throws UnboundResolverError, naming the layer, the path it wanted, and where to bind one.

Memory Resolver (Browser-Compatible)

The createMemoryResolver function is exported from @paradoc/resolvers/memory and works in any environment, including browsers.

import { p } from '@paradoc/core'
import { createMemoryResolver } from '@paradoc/resolvers/memory'

const resolver = createMemoryResolver({
  contents: {
    '/templates/receipt.html': '<h1>Receipt for {{fields.customer}}</h1>',
    '/templates/contract.docx': docxTemplateBytes,
    '/assets/logo.png': logoBytes,
  }
})

// Bind the resolver when the form is built — the form has file-based layers
const form = p.form(formSchema, { resolver })

const filled = form.fill({
  fields: { customer: 'Acme Corp' }
})

const output = await filled.render()

createMemoryResolver

function createMemoryResolver(options: MemoryResolverOptions): Resolver

MemoryResolverOptions

contents: Record<string, Uint8Array | string>
Map of paths to content. Keys match the read path exactly. Strings are UTF-8 encoded automatically.

Binary content, including a Node.js Buffer, is copied when the resolver is created, and every read returns a new copy. A path with no entry rejects with ERR_RESOLVER_NOT_FOUND. Missing contents, or a value that is not a string or Uint8Array, throws ERR_RESOLVER_INVALID_OPTIONS.

Filesystem Resolver (Node.js Only)

The @paradoc/resolvers/fs entrypoint requires Node.js.

import { createFsResolver } from '@paradoc/resolvers/fs'

const resolver = createFsResolver({
  root: process.cwd()
})

// Reads files relative to the root directory
const bytes = await resolver.read('/templates/form.docx')

createFsResolver

function createFsResolver(options: FsResolverOptions): Resolver

FsResolverOptions

root: string
Absolute or current-working-directory-relative path to the root directory. Relative roots are anchored when the resolver is created.

The filesystem resolver validates that paths and symlinks stay within the root directory. Policy failures expose ERR_RESOLVER_OUTSIDE_ROOT or ERR_RESOLVER_INVALID_PATH; missing files retain native Node.js error codes. An empty or non-string root throws ERR_RESOLVER_INVALID_OPTIONS. The root is checked on each read until it is found, so a root created after a failed read becomes readable.

HTTP Resolver

The createHttpResolver function is exported from @paradoc/resolvers/http and works in any environment with the Fetch API. It reads each path beneath a base URL.

import { createHttpResolver } from '@paradoc/resolvers/http'

const resolver = createHttpResolver({
  baseUrl: 'https://example.com/forms/w-9/'
})

// Fetches https://example.com/forms/w-9/templates/form.pdf
const bytes = await resolver.read('/templates/form.pdf')

createHttpResolver

function createHttpResolver(options: HttpResolverOptions): Resolver

HttpResolverOptions

baseUrl: string
Absolute http or https URL of the root directory. Its query and fragment are ignored.
fetch: (url: string) => Promise<Response>
Optional. The fetch used for each read. Defaults to the global fetch. Supply one to add allowed hosts, timeouts, redirect handling, or body size limits.

Paths follow the filesystem resolver's rules: forward slashes only, and a single leading slash means the base URL, not the origin root. Each path segment is percent-encoded. A path that resolves outside the base URL rejects with ERR_RESOLVER_OUTSIDE_ROOT, and one that names the base URL itself with ERR_RESOLVER_INVALID_PATH, before any request. Redirects follow the fetch function's own policy. A 404 rejects with ERR_RESOLVER_NOT_FOUND, and any other failed status rejects with ERR_RESOLVER_FETCH_FAILED. An error thrown by a supplied fetch passes through unchanged.

Usage with File-Based Layers

Resolvers are needed when forms define file-based layers:

import { p } from '@paradoc/core'
import { createFsResolver } from '@paradoc/resolvers/fs'

const resolver = createFsResolver({ root: process.cwd() })

const form = p.form({
  name: 'w9',
  fields: {
    name: { type: 'text' },
    taxId: { type: 'text' }
  },
  layers: {
    pdf: {
      kind: 'file',                    // File-based layer
      mimeType: 'application/pdf',
      path: '/templates/w9-form.pdf',  // Resolved via resolver
      bindings: { /* ... */ }
    }
  },
  defaultLayer: 'pdf'
}, { resolver })

const filled = form.fill({
  fields: { name: 'John Smith', taxId: '12-3456789' }
})

const output = await filled.render()

Render a file layer with no resolver bound and Paradoc throws UnboundResolverError, naming the layer, the path it wanted, and where to bind one.

Inline vs File Layers

  • Inline layers (kind: 'inline') embed content directly in the form definition. No resolver needed.
  • File layers (kind: 'file') reference external files by path. A resolver must be bound when the artifact is constructed.
// Inline layer - no resolver needed
layers: {
  html: {
    kind: 'inline',
    mimeType: 'text/html',
    text: '<h1>{{fields.title}}</h1>'
  }
}

// File layer - resolver required
layers: {
  pdf: {
    kind: 'file',
    mimeType: 'application/pdf',
    path: '/templates/form.pdf'
  }
}

Custom Resolvers

Implement the Resolver interface for custom storage backends:

import type { Resolver } from '@paradoc/types'

// Example: S3 resolver
function createS3Resolver(bucket: string, s3Client: S3Client): Resolver {
  return {
    async read(path: string): Promise<Uint8Array> {
      const response = await s3Client.getObject({ Bucket: bucket, Key: path })
      return new Uint8Array(await response.Body.transformToByteArray())
    }
  }
}

On this page