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 runs —
TsreportClientandtsreport-sdk/serverare server-side only. The only thing usable in a browser iscreateEndpointConnector(), which holds no credentials - What it doesn't do — Report layout or PDF generation (handled by
tsreport-core), rendering the preview screen (handled bytsreport-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 becomeserror waitForCompletion()repeatedly checks the status until it becomescompleted, and throws an exception if it becomeserror- Only once it becomes
completedcan you fetch the PDF withdownloadPdf()
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'sPreviewConnectorcontract 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:http — toNodeHandler()
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.
- When a method requiring authentication is called, it checks whether there is a valid token
- If not, it requests one from
POST /api/oauth/tokenwithgrant_type=client_credentials - The obtained token is kept only in the instance's memory, and reused until 10 seconds before its expiration
- 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
TsreportClientandtsreport-sdk/serverrun in) - A modern browser (
createEndpointConnector()only. Since it holds no credentials, it can be safely deployed) - No runtime dependencies
- Supports both ESM and CommonJS
Related projects
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).