npm.io
1.0.0 • Published 2d ago

tsreport-sdk

Licence
MIT OR Apache-2.0
Version
1.0.0
Deps
0
Size
642 kB
Vulns
0
Weekly
0

tsreport-sdk

English | 日本語 | 简体中文 | 繁體中文 | 한국어 | Tiếng Việt | ไทย | Bahasa Indonesia | Deutsch | Français | Español | Português | العربية | עברית

A client for requesting PDF printing from a report server and receiving the results. Zero runtime dependencies. Designed for use on the server side (Node.js).

tsreport-sdk handles all communication with the external print API server provided by tsreport-editor. OAuth 2.0 authentication, submitting print jobs, waiting for completion, downloading PDFs, and even fetching materials for browser previews — all of this is handled through a single typed class.

Token acquisition, expiration management, and reacquisition on expiry are all handled internally by the library, so all the caller needs to write is "which template to feed which data into."

Architecture: there are two authentication boundaries

A system that incorporates this SDK is composed of three layers. First, grasp the overall picture. Most misunderstandings about this SDK stem from confusing the two authentication boundaries.

flowchart LR
    browser["Browser\ncreateEndpointConnector\n(holds no credentials)"]
    app["Your app server\ncreatePreviewEndpoint + TsreportClient\n(clientSecret lives only here)"]
    editor["Print API server\n(tsreport-editor)"]
    browser -->|"Boundary A: session authentication\n(you implement this yourself)"| app
    app -->|"Boundary B: OAuth 2.0\n(handled automatically by the SDK)"| editor
Layer SDK parts used Role in authentication
Browser createEndpointConnector() The side being authenticated at boundary A. It just sends session cookies etc., and holds no credentials at all
Your app server createPreviewEndpoint() + TsreportClient The side that checks boundary A (this check is implemented by the app itself). Boundary B is left to the SDK. clientSecret is held only by this layer
Print API server (provided by tsreport-editor) Checks boundary B

The SDK only takes care of boundary B. TsreportClient internally handles all token acquisition, expiration management, and resending using clientId/clientSecret.

On the other hand, there is no mechanism anywhere in this SDK for judging boundary A — "who is currently operating this, and are they allowed to view this report?" createPreviewEndpoint() is a pass-through relay that performs no authorization whatsoever. Login, session management, and permission checks must always be placed by the app itself, in front of the endpoint, using whatever mechanism the app already has. If you omit this, anyone who can reach the URL can read the report and its materials.

The "session check" and requireAppUser that appear in the examples that follow are not decorative. They are the app's own code for boundary A, which must always be implemented on the app side.

What this package does, and what it doesn't

This package is responsible only for communication.

  • What it does — Authentication (OAuth 2.0 client credentials), calling the print API, polling job status, fetching PDFs, fetching preview materials, and providing a relay endpoint to place on your app server
  • Where it runsTsreportClient and tsreport-sdk/server are server-side only. The only thing usable in a browser is createEndpointConnector(), which holds no credentials
  • What it doesn't do — Report layout or PDF generation (handled by tsreport-core), rendering the preview screen (handled by tsreport-react), and authenticating/authorizing the end user (Boundary A, handled by the consuming application)

There are also two design promises. Zero runtime dependencies (no dependencies in package.json), and no environment variables are ever read. The connection destination and credentials are all received as explicit arguments, so behavior doesn't change depending on where it's deployed.

Installation

npm install tsreport-sdk

Works on Node.js 18 and above. Internally it only uses fetch and Web Streams, and does not depend on any Node.js-specific APIs.

Use this library on the server side. Since the central TsreportClient requires a clientSecret, running it in a browser would distribute the secret key to every user. If you want to handle reports from a browser, use the three-layer architecture described later in "Previewing from a browser," where the browser side only uses createEndpointConnector(), which holds no credentials.

Prerequisites

This library does not work by itself. It assumes that tsreport-editor's external print API server is running. Obtain the following four items from that server.

Requirement Description
Base URL The URL of the API server (e.g. https://reports.example.com). A trailing slash is fine either way
Client ID The OAuth 2.0 client_id
Client secret The OAuth 2.0 client_secret. Keep this only on the server side, and never pass it to the browser
Workspace key The identifier (UUID format) of the workspace where the reports are located

Clients are assigned scopes appropriate to their purpose. There are four kinds: report:print (submitting print jobs), report:status (checking status), report:download (fetching the PDF), and report:preview (fetching preview materials).

First step: receive a report as a PDF

Here is the shortest code to pass a template and data and get back a PDF byte array.

import { writeFileSync } from 'node:fs'
import { TsreportClient } from 'tsreport-sdk'

const client = new TsreportClient({
  baseUrl: 'https://reports.example.com',
  clientId: 'my-client-id',
  clientSecret: 'my-client-secret',
})

const pdf = await client.printAndDownload(
  '00000000-0000-0000-0000-000000000002', // workspace key
  'invoice.report',                        // path of the template within the workspace
  'v1',                                    // template tag (version)
  { rows: [{ item: 'Part A', amount: 12000 }] }, // data to feed into the report
)

writeFileSync('./invoice.pdf', pdf)

printAndDownload() performs all three steps described below — "submit → wait for completion → download" — in one call.

The flow of a print job

Printing is asynchronous. At the moment you make the request, the PDF does not yet exist; it is generated in order by server-side batch processing. Because of this, requesting and receiving are split into two steps.

  print()                                        downloadPdf()
     │                                                 │
     ▼                                                 ▼
 ┌────────┐    ┌────────────┐    ┌───────────┐    ┌─────────┐
 │ queued │ →  │ processing │ →  │ completed │ →  │   PDF   │
 └────────┘    └────────────┘    └───────────┘    └─────────┘
                      │
                      ▼
                 ┌───────┐
                 │ error │ → PrintJobError is thrown
                 └───────┘
  • print() returns the job's key (a string). At this point the PDF does not yet exist
  • The job's status transitions from queued (waiting in line) → processing (generating) → completed (finished). If it fails, it becomes error
  • waitForCompletion() repeatedly checks the status until it becomes completed, and throws an exception if it becomes error
  • Only once it becomes completed can you fetch the PDF with downloadPdf()

Usage by purpose

Want to receive the PDF in a single call — printAndDownload()

Performs submission, waiting, and download together. Normally, use this.

const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data)

If you want to change the wait interval or limit, specify them in the fifth argument.

const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data, {
  intervalMs: 2000,  // interval between status checks
  timeoutMs: 90000,  // PollTimeoutError once this is exceeded
})
Want to separate submission and receiving — print() and waitForCompletion()

Use this in a setup where you save the job's key to a database and go fetch the result later.

// Just submit the request and keep the key
const key = await client.print(workspaceKey, 'invoice.report', 'v1', data)
await db.jobs.insert({ key, requestedAt: new Date() })

// ── In another request or another process ──
await client.waitForCompletion(key)
const pdf = await client.downloadPdf(key)
Want to manage progress yourself — getStatus()

Fetches only the status at that instant, without waiting. Use this, for example, to display progress on screen.

const status = await client.getStatus(key)

if (status.status === 'completed') {
  const pdf = await client.downloadPdf(key)
} else if (status.status === 'error') {
  console.error('Printing failed:', status.errorReason)
} else {
  console.log('Still in progress:', status.status) // 'queued' or 'processing'
}

getStatus() just returns the status and does not throw an exception even for error (it is waitForCompletion() that throws exceptions).

Want to handle a large PDF without loading it into memory — getPdfStream()

downloadPdf() expands the entire PDF into memory. For reports of hundreds of pages, it is safer to receive it as a stream and pipe it directly to a file or HTTP response.

import { Writable } from 'node:stream'
import { createWriteStream } from 'node:fs'

const stream = await client.getPdfStream(key)
await stream.pipeTo(Writable.toWeb(createWriteStream('./invoice.pdf')))
Want to abort waiting midway — signal

Stops polling, for example, when the user navigates away from the screen.

const controller = new AbortController()
// When the user presses "Cancel", call controller.abort(new Error('Canceled'))

const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data, {
  signal: controller.signal,
})

When aborted, the Promise is rejected with the value passed to signal.reason.

Previewing from a browser

To display a report preview in a browser, materials such as templates, fonts, and images need to be delivered to the browser side. Since the client secret must never be placed in the browser in this case, this results in a three-layer architecture with your own app server in between.

 ┌──────────────┐  ①request assets ┌────────────────┐ ②authenticate+forward ┌──────────────┐
 │   Browser    │ ───────────────→ │   Your own app   │ ───────────────→ │ Print API server │
 │              │                  │     server       │                  │              │
 │ createEndpoint│ ←─────────────── │ createPreview   │ ←─────────────── │              │
 │  Connector   │  ④assets arrive   │   Endpoint      │ ③assets returned  │              │
 └──────────────┘                  └────────────────┘                  └──────────────┘
   holds no credentials              clientSecret lives only here
  • The browser side only needs to know its own app's URL, via createEndpointConnector()
  • The app server side just places createPreviewEndpoint(), which attaches authentication and relays to the print API
  • Since this relay endpoint satisfies tsreport-react's PreviewConnector contract as-is, it can be passed directly to the preview component

In this diagram, the "Browser → App" segment is Boundary A from the introduction (the app's session authentication, implemented by yourself), and the "App → Print API" segment is Boundary B (OAuth, handled by the SDK).

Want to place a relay endpoint on your app server — createPreviewEndpoint()

Import this from tsreport-sdk/server (a server-only subpath).

import { TsreportClient } from 'tsreport-sdk'
import { createPreviewEndpoint } from 'tsreport-sdk/server'

const client = new TsreportClient({
  baseUrl: 'https://reports.example.com',
  clientId: 'my-client-id',
  clientSecret: 'my-client-secret',
})

const handler = createPreviewEndpoint({
  client,
  target: {
    workspace: '00000000-0000-0000-0000-000000000002',
    path: 'reports/invoice.report',
    tag: 'v1',
  },
})

handler is a standard function of type (request: Request) => Promise<Response>.

If you specify target, it will always return this template no matter what is requested from the browser. Specifying target is recommended for public screens, since it prevents the accident of the browser side being able to specify an arbitrary template (if omitted, it follows what the browser specifies).

Important: createPreviewEndpoint() performs no authorization whatsoever. Judging "whether this user is allowed to view this report" is the caller's responsibility. Always mount it inside your app's authentication/authorization.

Want to incorporate this into a Next.js App Router

Wrap the relay handler with your own authentication before exporting it as a route handler.

// app/api/report-preview/route.ts
import { TsreportClient } from 'tsreport-sdk'
import { createPreviewEndpoint } from 'tsreport-sdk/server'
import { getSessionUser } from '@/lib/auth' // ← implement in your app (NextAuth, iron-session, your own sessions, etc.)

const client = new TsreportClient({
  baseUrl: process.env.REPORT_API_URL!,
  clientId: process.env.REPORT_CLIENT_ID!,
  clientSecret: process.env.REPORT_CLIENT_SECRET!,
})

const previewHandler = createPreviewEndpoint({
  client,
  target: { workspace: 'workspace key', path: 'reports/invoice.report', tag: 'v1' },
})

export async function GET(request: Request): Promise<Response> {
  // This is your app's own session authentication (boundary A). The SDK performs no checks,
  // so if you omit this check, anyone who can reach the URL can view the reports
  const user = await getSessionUser(request)
  if (user === null) {
    return new Response(
      JSON.stringify({ message: 'unauthorized', statusCode: 401 }),
      { status: 401, headers: { 'content-type': 'application/json' } },
    )
  }
  return previewHandler(request)
}

While it is technically possible to export it directly as export const GET = createPreviewEndpoint(...), this is not recommended, since there is no longer any place to insert authentication. Always relay through your own authentication, as in the form above.

Reading environment variables is code on the consuming side. The library itself never reads environment variables.

Want to incorporate this into Express — toExpressHandler()
import express from 'express'
import { TsreportClient } from 'tsreport-sdk'
import { createPreviewEndpoint, toExpressHandler } from 'tsreport-sdk/server'

const app = express()
const client = new TsreportClient({ baseUrl, clientId, clientSecret })
const handler = createPreviewEndpoint({ client, target })

// requireAppUser is your app's own session-authentication middleware (boundary A, implemented yourself).
// Mounting the handler after this position is what forms the authorization boundary
app.get('/api/report-preview', requireAppUser, toExpressHandler(handler))

toExpressHandler() forwards to next(error) if the relay itself fails (e.g. the API server is unreachable), so it can be handled by the app's error handler. Note that express is not a dependency of this package. It only receives something whose shape matches.

Want to incorporate this into node:httptoNodeHandler()
import { createServer } from 'node:http'
import { createPreviewEndpoint, toNodeHandler } from 'tsreport-sdk/server'

const previewHandler = toNodeHandler(createPreviewEndpoint({ client, target }))

createServer(async (req, res) => {
  if (req.url?.startsWith('/api/report-preview')) {
    // Your app's own session authentication (boundary A, implemented yourself). Must not be omitted
    if (!isAuthorizedAppUser(req)) { // ← implement in your app
      res.statusCode = 401
      res.setHeader('content-type', 'application/json')
      res.end(JSON.stringify({ message: 'unauthorized', statusCode: 401 }))
      return
    }
    try {
      await previewHandler(req, res)
    } catch (error) {
      res.statusCode = 500
      res.setHeader('content-type', 'application/json')
      res.end(JSON.stringify({ message: 'preview failed', statusCode: 500 }))
    }
    return
  }
  res.statusCode = 404
  res.end()
}).listen(3000)

The Promise returned by toNodeHandler() rejects when the relay itself fails. Catch it as in the example above, and return a response consistent with your app's policy.

Want to fetch materials from the browser side — createEndpointConnector()

Just pass the URL of the relay endpoint you placed on your app server.

import { createEndpointConnector } from 'tsreport-sdk'

const connector = createEndpointConnector({
  endpoint: '/api/report-preview',
  fetchInit: { credentials: 'include' }, // send the app's session cookie (to pass boundary A authentication)
})

const payload = await connector.fetchTemplate({
  workspace: 'workspace key',
  path: 'reports/invoice.report',
  tag: 'v1',
})
// payload.template … the template definition
// payload.fontIds  … array of font IDs this template requires

for (const fontId of payload.fontIds) {
  const bytes = await connector.fetchFont(fontId) // null if not found
}

If you want to attach a type to the template in TypeScript, pass a type argument.

import type { ReportTemplate } from 'tsreport-core'

const connector = createEndpointConnector<ReportTemplate>({ endpoint: '/api/report-preview' })

There is one behavior worth remembering about the connector. When fetchTemplate() succeeds, it remembers that template's workspace and directory, and automatically attaches them to subsequent calls to resolveImage(). Because of this, always call fetchTemplate() before resolving images (this is unnecessary if it's an endpoint with a fixed target, since the server side fills it in).

Handling preview materials directly on the server side

You can also fetch materials from Node.js without going through a browser.

Want to fetch a template — getPreviewTemplate()
const { template, fontIds } = await client.getPreviewTemplate(workspaceKey, 'invoice.report', 'v1')

fontIds is the list of fonts required by that template. Since the server computes this using the same logic as the print pipeline, including default fonts and math fonts, loading it according to this list will make the preview match the print result.

Want to fetch a subreport's template — getPreviewSubreport()
const { template, fontIds } = await client.getPreviewSubreport(workspaceKey, 'reports/sub.report')

This differs from getPreviewTemplate() in that it doesn't take a tag.

Want to fetch a file such as an image — getPreviewFile()
const bytes = await client.getPreviewFile(workspaceKey, 'assets/logo.png')
Want to list the available fonts — listPreviewFonts()
const fonts = await client.listPreviewFonts()
// [{ id: 'NotoSansJP', fileName: 'NotoSansJP-VariableFont_wght.ttf' }, ...]
Want to fetch the font body — getPreviewFont()
const fontBytes = await client.getPreviewFont('NotoSansJP')
Want to write your own relay — fetchPreviewResource()

For cases the methods above don't cover, this performs an authenticated GET to any preview path.

const response = await client.fetchPreviewResource('/api/report/preview/fonts')

This method alone returns the Response as-is even on error, without throwing an exception. It is a low-level entry point for use cases where you want to relay the status code and headers as-is (which is exactly what createPreviewEndpoint() uses this for).

How authentication works

The caller doesn't need to be aware of this, but internally it works as follows.

  1. When a method requiring authentication is called, it checks whether there is a valid token
  2. If not, it requests one from POST /api/oauth/token with grant_type=client_credentials
  3. The obtained token is kept only in the instance's memory, and reused until 10 seconds before its expiration
  4. If a request is rejected with 401 or 403, the token is discarded and reacquired, and the same request is resent exactly once

Tokens are never saved to disk or an external store. Use getAccessToken() only when you explicitly need the token.

const token = await client.getAccessToken()

Handling errors

All errors inherit from TsreportClientError, so you can catch them all together or branch by type.

import {
  ApiError, PollTimeoutError, PrintJobError, TokenError, TsreportClientError,
} from 'tsreport-sdk'

try {
  const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data)
} catch (error) {
  if (error instanceof TokenError) {
    // Wrong credentials, the client was disabled, etc.
    console.error('Authentication failed:', error.status, error.errorCode)
  } else if (error instanceof PrintJobError) {
    // Server-side generation failed due to the template or the data
    console.error('Print job failed:', error.key, error.errorReason)
  } else if (error instanceof PollTimeoutError) {
    // Did not complete in time (the job itself may still be alive)
    console.error('Waiting timed out:', error.key, error.timeoutMs)
  } else if (error instanceof ApiError) {
    // The API returned 4xx/5xx (nonexistent template, insufficient permissions, etc.)
    console.error('API error:', error.status, error.errorMessage)
  } else if (error instanceof TsreportClientError) {
    // Anything else
    console.error(error.message)
  }
}
Error class When it occurs Information it holds
TokenError Token acquisition failed (incorrect credentials, client disabled, etc.) status (HTTP status), errorCode, errorDescription
ApiError The API returned a 4xx/5xx (template doesn't exist, insufficient permissions, etc.) status, errorMessage (message returned by the server)
PrintJobError The job's status became error key, errorReason (failure reason returned by the server)
PollTimeoutError waitForCompletion() could not confirm completion within the time limit key, timeoutMs
TsreportClientError Anything else. The base class for all errors

PollTimeoutError only means "the client stopped waiting" — it does not mean the job failed on the server side. The key remains valid, so you can check it later with getStatus().

Note that only the connector returned by createEndpointConnector() returns null instead of throwing an exception, as an exception, when a material is not found (404). This is because a preview should be able to continue rendering even if some materials are missing. However, fetchTemplate() is excluded from this — if the template itself cannot be fetched, an ApiError is thrown (since there is nothing to render at all).

API reference

new TsreportClient(options)
Property Type Required Description
baseUrl string The base URL of the API server. A trailing slash is automatically removed
clientId string The OAuth 2.0 client_id
clientSecret string The OAuth 2.0 client_secret
scope string Narrows the requested scopes, specified space-separated. If omitted, all scopes registered to the client are granted
Methods
Method Return value Description
print(workspace, templatePath, tag, data) Promise<string> Requests printing and returns the job's key
getStatus(key) Promise<PrintStatusResult> Returns the job's current status (does not throw an exception)
waitForCompletion(key, options?) Promise<PrintStatusResult> Waits until completion. PrintJobError on failure, PollTimeoutError on timeout
downloadPdf(key) Promise<Uint8Array> Fetches all the PDF's bytes
getPdfStream(key) Promise<ReadableStream<Uint8Array>> Fetches the PDF as a stream
printAndDownload(workspace, templatePath, tag, data, options?) Promise<Uint8Array> Performs submission, waiting, and download together
getAccessToken() Promise<string> Returns a valid access token (acquiring one if necessary)
getPreviewTemplate(workspace, templatePath, tag) Promise<PreviewTemplateResult> Fetches the template definition and the required font IDs
getPreviewSubreport(workspace, templatePath) Promise<PreviewTemplateResult> Fetches a subreport's template
getPreviewFile(workspace, filePath) Promise<Uint8Array> Fetches a file (such as an image) within the workspace
listPreviewFonts() Promise<PreviewFontInfo[]> Fetches the list of available fonts
getPreviewFont(id) Promise<Uint8Array> Fetches the font body
fetchPreviewResource(resourcePath) Promise<Response> Performs an authenticated GET to any preview path, and returns the raw Response (does not throw an exception)
WaitForCompletionOptions
Property Type Required Description
intervalMs number The interval (in milliseconds) at which to check the status. Default: 1000
timeoutMs number The upper limit (in milliseconds) for waiting. PollTimeoutError if exceeded. Default: 120000
signal AbortSignal A signal for aborting the wait. On abort, it is rejected with signal.reason
Return value types
Type Structure
PrintStatusResult { key: string, status: PrintJobState, errorReason?: string }
PrintJobState 'queued' = waiting in line / 'processing' = generating / 'completed' = completed / 'error' = failed
PreviewTemplateResult { template: unknown, fontIds: string[] }
PreviewFontInfo { id: string, fileName: string }
createEndpointConnector(options)
Property Type Required Description
endpoint string The URL where the relay endpoint is mounted (e.g. /api/report-preview)
fetchInit RequestInit Settings attached to every request. Use { credentials: 'include' } to send the session cookie

The returned connector has the following four methods. All of them return null when a material is not found (except fetchTemplate()).

Method Return value Description
fetchTemplate(source) Promise<PreviewTemplatePayload> Fetches the template. source is { workspace, path, tag }
fetchFont(fontId) Promise<Uint8Array | null> Fetches the font body
resolveImage(ref) Promise<Uint8Array | string | null> Resolves an image referenced by the template
fetchSubreportTemplate(ref, context) Promise<PreviewTemplatePayload | null> Fetches a subreport's template. context is { workingDirectory }
tsreport-sdk/server
Function Return value Description
createPreviewEndpoint(options) (request: Request) => Promise<Response> Creates the relay handler for preview materials
toNodeHandler(handler) (req, res) => Promise<void> An adapter for node:http. Rejects if the relay itself fails
toExpressHandler(handler) (req, res, next) => Promise<void> An adapter for Express. Forwards to next(error) if the relay itself fails

Options for createPreviewEndpoint:

Property Type Required Description
client TsreportClient An authenticated client. Only this supplies the connection destination and credentials
target { workspace, path, tag } The template to fix. If specified, the request's specification is ignored and this template is always returned. The base directory for images and subreports is also filled in

Sample code

The examples/ directory bundles working sample implementations as-is (also included in the npm package).

File Content
examples/nextjs-route.ts A Next.js App Router route handler (with a session authentication gate)
examples/express-server.ts Integration with Express and the placement of authorization middleware
examples/node-server.ts Integration with node:http
examples/browser-connector.ts Fetching preview materials from a browser

Since these are actually verified by starting up a server as part of npm test, non-working code cannot slip in.

Testing

Command Content
npm test Unit and integration tests. Includes verification that actually starts examples/
npm run test:live Real-server integration tests that assume a running tsreport-editor and seed data

Runtime environment

  • Node.js 18 or above (the environment TsreportClient and tsreport-sdk/server run in)
  • A modern browser (createEndpointConnector() only. Since it holds no credentials, it can be safely deployed)
  • No runtime dependencies
  • Supports both ESM and CommonJS

License

tsreport-sdk is available under either the MIT License or the Apache License 2.0, at the user's choice (SPDX: MIT OR Apache-2.0).

Keywords