@molecule/api-pdf-molecule

Provider bond · pdf · API (Node) · v1.1.1 · Apache-2.0

molecule.dev hosted PDF — HTML-to-PDF rendering, merge and watermark billed to your molecule project, no vendor account

npm install @molecule/api-pdf-molecule

npm · Source on GitHub · Implements @molecule/api-pdf

How it works

@molecule/api-pdf-molecule is a provider bond on the API (Node) side: it implements the pdf core interface (@molecule/api-pdf) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

Works with: @molecule/api-pdf, @molecule/api-secrets

Secrets: MOLECULE_API_KEY

Reference

molecule.dev hosted PDF — HTML-to-PDF rendering, merge and watermark billed to your molecule project, no vendor account

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Type

provider

Installation

npm install @molecule/api-pdf-molecule @molecule/api-pdf @molecule/api-secrets pdf-lib

API

Interfaces

PdfResult

The response the hosted PDF service returns for every operation.

interface PdfResult {
  /** The PDF document, base64-encoded — no `data:` URL prefix. */
  data: string
  /** MIME type of the returned document (`application/pdf`). */
  mimeType: string
  /** Number of pages in the returned document. */
  pageCount: number
  /** Size in bytes. */
  bytes: number
}

MoleculePdfConfig

Options for createProvider. Every field falls back to an env var, so the zero-argument provider export works from .env alone.

interface MoleculePdfConfig {
  /**
   * The molecule project API key (`mk_…`), or the sandbox token molecule.dev
   * writes for in-IDE apps (`mbk_…`). Defaults to `MOLECULE_API_KEY`.
   */
  apiKey?: string
  /**
   * The hosted services base URL, without a trailing slash. Defaults to
   * `MOLECULE_SERVICES_URL`, then `https://api.molecule.dev/api/v1/services`.
   */
  servicesUrl?: string
  /** Per-request timeout in milliseconds. Default 60000 — headless-Chrome rendering takes a moment. */
  timeoutMs?: number
}

ProcessEnv

Environment variables this provider reads.

interface ProcessEnv {
  MOLECULE_API_KEY?: string
  MOLECULE_SERVICES_URL?: string
}

Classes

MoleculeServiceError

Error thrown for a refused or failed hosted-service call.

class MoleculeServiceError extends Error {
  /** HTTP status from molecule.dev. */
  readonly status: number
  /** Stable error key from molecule.dev. */
  readonly errorKey: string | undefined

  /**
   * Create the error.
   *
   * @param message - Human-readable message.
   * @param status - HTTP status.
   * @param errorKey - Stable error key, when the service sent one.
   * @param cause - The underlying error, if any.
   */
  constructor(message: string, status: number, errorKey?: string, cause?: unknown) {
    super(message, cause === undefined ? undefined : { cause })
    this.name = 'MoleculeServiceError'
    this.status = status
    this.errorKey = errorKey
  }
}

MoleculePdfProvider

PDF provider backed by molecule.dev's hosted service.

fromTemplate is NOT hosted: templating is string interpolation the caller does in its own code (escape interpolated values, then call fromHTML) — the service renders the HTML it is given.

class MoleculePdfProvider implements PDFProvider {
  readonly name = 'molecule'

  private readonly apiKey: string
  private readonly servicesUrl: string
  private readonly timeoutMs: number

  /**
   * Create the provider.
   *
   * @param config - Options; each falls back to its env var.
   */
  constructor(config: MoleculePdfConfig = {}) {
    this.apiKey = config.apiKey ?? process.env.MOLECULE_API_KEY ?? ''
    this.servicesUrl = resolveServicesUrl(
      config.servicesUrl ?? process.env.MOLECULE_SERVICES_URL ?? DEFAULT_SERVICES_URL,
    )
    this.timeoutMs = config.timeoutMs ?? 60_000
  }

  /**
   * Renders HTML to PDF through the hosted service and returns the decoded
   * document plus its page count. Prefer this over `fromHTML` when the page
   * count matters — it is the same single round trip.
   *
   * @param html - The HTML to render.
   * @param options - Page size, margins, scale, header/footer templates.
   * @returns The rendered PDF plus its metadata.
   */
  async render(html: string, options?: PDFOptions): Promise<PdfResult> {
    if (typeof html !== 'string' || html.length === 0) {
      throw new MoleculeServiceError(
        '"html" must be non-empty text.',
        400,
        'hostedServices.error.invalidInput',
      )
    }
    if (html.length > PDF_SERVICE_LIMITS.maxHtmlChars) {
      throw new MoleculeServiceError(
        `The HTML is ${html.length} characters; the limit is ${PDF_SERVICE_LIMITS.maxHtmlChars}.`,
        413,
        'hostedServices.error.inputTooLarge',
      )
    }
    return this.request('pdf/from-html', { html, ...(options !== undefined ? { options } : {}) })
  }

  /** Renders HTML to PDF through the hosted service. */
  async fromHTML(html: string, options?: PDFOptions): Promise<Buffer> {
    const result = await this.render(html, options)
    return Buffer.from(result.data, 'base64')
  }

  /** Not hosted — interpolate the template yourself (escape the values), then call `fromHTML`. */
  fromTemplate(): Promise<Buffer> {
    return Promise.reject(
      new MoleculeServiceError(
        'The hosted PDF service does not serve template interpolation — build the HTML yourself (HTML-escape every interpolated value) and call fromHTML.',
        400,
        'hostedServices.error.invalidInput',
      ),
    )
  }

  /**
   * Merges PDFs on the hosted service.
   *
   * @param pdfs - The documents, in order.
   * @returns The merged PDF.
   */
  async merge(pdfs: Buffer[]): Promise<Buffer> {
    if (!Array.isArray(pdfs) || pdfs.length === 0) {
      throw new MoleculeServiceError(
        '"pdfs" must be a non-empty array of PDF buffers.',
        400,
        'hostedServices.error.invalidInput',
      )
    }
    if (pdfs.length > PDF_SERVICE_LIMITS.maxMergeInputs) {
      throw new MoleculeServiceError(
        `${pdfs.length} documents is over the limit of ${PDF_SERVICE_LIMITS.maxMergeInputs}.`,
        413,
        'hostedServices.error.inputTooLarge',
      )
    }
    for (const pdf of pdfs) {
      if (!Buffer.isBuffer(pdf) || pdf.length === 0) {
        throw new MoleculeServiceError(
          'Every entry of "pdfs" must be non-empty PDF bytes.',
          400,
          'hostedServices.error.invalidInput',
        )
      }
      if (pdf.length > PDF_SERVICE_LIMITS.maxDocumentBytes) {
        throw new MoleculeServiceError(
          `A document is ${pdf.length} bytes; the per-document limit is ${PDF_SERVICE_LIMITS.maxDocumentBytes}.`,
          413,
          'hostedServices.error.inputTooLarge',
        )
      }
    }
    const total = pdfs.reduce((sum, pdf) => sum + pdf.length, 0)
    if (total > PDF_SERVICE_LIMITS.maxMergeTotalBytes) {
      throw new MoleculeServiceError(
        `The merge totals ${total} bytes; the limit is ${PDF_SERVICE_LIMITS.maxMergeTotalBytes}.`,
        413,
        'hostedServices.error.inputTooLarge',
      )
    }
    const result = await this.request('pdf/merge', {
      pdfs: pdfs.map((pdf) => pdf.toString('base64')),
    })
    return Buffer.from(result.data, 'base64')
  }

  /**
   * Adds a text watermark to every page on the hosted service.
   *
   * @param pdf - The document to watermark.
   * @param text - The watermark text.
   * @param options - Font size, color, rotation, opacity.
   * @returns The watermarked PDF.
   */
  async addWatermark(pdf: Buffer, text: string, options?: WatermarkOptions): Promise<Buffer> {
    if (!Buffer.isBuffer(pdf) || pdf.length === 0) {
      throw new MoleculeServiceError(
        '"pdf" must be non-empty PDF bytes.',
        400,
        'hostedServices.error.invalidInput',
      )
    }
    if (pdf.length > PDF_SERVICE_LIMITS.maxDocumentBytes) {
      throw new MoleculeServiceError(
        `The document is ${pdf.length} bytes; the limit is ${PDF_SERVICE_LIMITS.maxDocumentBytes}.`,
        413,
        'hostedServices.error.inputTooLarge',
      )
    }
    if (
      typeof text !== 'string' ||
      text.trim().length === 0 ||
      text.length > PDF_SERVICE_LIMITS.maxWatermarkTextChars
    ) {
      throw new MoleculeServiceError(
        `"text" must be 1-${PDF_SERVICE_LIMITS.maxWatermarkTextChars} characters.`,
        400,
        'hostedServices.error.invalidInput',
      )
    }
    const result = await this.request('pdf/watermark', {
      pdf: pdf.toString('base64'),
      text,
      ...(options !== undefined ? { options } : {}),
    })
    return Buffer.from(result.data, 'base64')
  }

  /** Counts pages with a local pdf-lib parse — no network call. */
  async getPageCount(pdf: Buffer): Promise<number> {
    const doc = await PDFDocument.load(pdf, { updateMetadata: false })
    return doc.getPageCount()
  }

  /** Reads document metadata with a local pdf-lib parse — no network call. */
  async getMetadata(pdf: Buffer): Promise<PDFMetadata> {
    const doc = await PDFDocument.load(pdf, { updateMetadata: false })
    return {
      pageCount: doc.getPageCount(),
      title: doc.getTitle(),
      author: doc.getAuthor(),
      subject: doc.getSubject(),
      creator: doc.getCreator(),
      creationDate: doc.getCreationDate(),
      modificationDate: doc.getModificationDate(),
    }
  }

  /** One POST to the hosted service. */
  private async request(path: string, body: Record<string, unknown>): Promise<PdfResult> {
    if (!this.apiKey) {
      throw new MoleculeServiceError(
        'MOLECULE_API_KEY is not set. Create a project API key on molecule.dev (or with `mlcl apikey create`) and put it in the API .env.',
        401,
        'hostedServices.error.tokenInvalid',
      )
    }
    const response = await fetch(`${this.servicesUrl}/${path}`, {
      method: 'POST',
      headers: { authorization: `Bearer ${this.apiKey}`, 'content-type': 'application/json' },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(this.timeoutMs),
    })
    const text = await response.text()
    let data: unknown
    try {
      data = text ? JSON.parse(text) : {}
    } catch (error) {
      throw new MoleculeServiceError(
        `molecule.dev returned ${response.status} with a non-JSON body.`,
        response.status,
        undefined,
        error,
      )
    }
    if (!response.ok) {
      const err = data as { error?: string; errorKey?: string }
      const hint = HINTS[response.status]
      throw new MoleculeServiceError(
        `molecule.dev ${path} failed (${response.status}): ${err.error ?? 'error'}${hint ? ` ${hint}` : ''}`,
        response.status,
        err.errorKey,
      )
    }
    return data as PdfResult
  }
}

Functions

createProvider(config)

Create a hosted PDF provider.

function createProvider(config?: MoleculePdfConfig): PDFProvider
  • config — Options; each falls back to its env var.

Returns: A PDFProvider backed by molecule.dev.

Constants

provider

The provider implementation (wire with setProvider).

const provider: PDFProvider

DEFAULT_SERVICES_URL

Default hosted services base URL.

const DEFAULT_SERVICES_URL: 'https://api.molecule.dev/api/v1/services'

PDF_SERVICE_LIMITS

Per-request limits of the hosted service (mirrored for local refusal).

const PDF_SERVICE_LIMITS: {
  readonly maxHtmlChars: 2000000
  readonly maxDocumentBytes: number
  readonly maxMergeInputs: 20
  readonly maxMergeTotalBytes: number
  readonly maxWatermarkTextChars: 200
}

pdfMoleculeSecretDefinitions

Secret definitions required by the molecule.dev hosted PDF bond.

const pdfMoleculeSecretDefinitions: SecretDefinition[]

Core Interface

Implements @molecule/api-pdf interface.

Bond Wiring

Setup function to register this provider with the core interface:

import { setProvider } from '@molecule/api-pdf'
import { provider } from '@molecule/api-pdf-molecule'

export function setupPdfMolecule(): void {
  setProvider(provider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-pdf >=1.0.2
  • @molecule/api-secrets ^1.0.1

Environment Variables

  • MOLECULE_API_KEY (required) — molecule.dev project API key
    • Setup: Create one for your project with mlcl apikey create --project <id> --name <name> (keys start with mk_).
    • Get it here: https://www.molecule.dev
    • Example: mk_...

Runtime Dependencies

  • @molecule/api-pdf
  • @molecule/api-secrets
  • pdf-lib

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual documents (invoice, report, receipt, contract) and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • Every document the app generates has a working Download/Export control that returns a REAL PDF — not an HTML error page or a JSON-stringified Buffer. Inspect the actual response: Content-Type is application/pdf and the body's first bytes are the %PDF magic (hex 25 50 44 46). Fetch the endpoint and check both — a body that starts with < or { is a failure dressed up as a download.
  • Opening the downloaded PDF shows the record's real values (names, line items, dates, totals) — not placeholder/template text or a blank page.
  • Edit a record and re-export: the new PDF reflects the changed values, and two different records produce two visibly different PDFs (not the same cached bytes for every id).
  • If the app shows page previews or reads document info, it feature-detects (getProvider().toImages / .getMetadata) or bonds a provider that supports them — both are OPTIONAL and THROW on bonds that lack them (e.g. PDFKit), so a preview built on an unsupporting bond errors at runtime, not compile time.
  • Styled output (CSS layout, backgrounds, web fonts) actually renders — which requires a browser-engine bond (Puppeteer). On PDFKit the same HTML collapses to a plain-text approximation; if the design matters, that's the wrong bond.
  • Export is authorized: a signed-in user cannot fetch another user's document by guessing or incrementing an id — the endpoint scopes every PDF to its owner (a guessed id returns 403/404, never someone else's invoice).