# tsreport-sdk [English](./README.md) | [日本語](./README.ja.md) | [简体中文](./README.zh-CN.md) | [繁體中文](./README.zh-TW.md) | 한국어 | [Tiếng Việt](./README.vi.md) | [ไทย](./README.th.md) | [Bahasa Indonesia](./README.id.md) | [Deutsch](./README.de.md) | [Français](./README.fr.md) | [Español](./README.es.md) | [Português](./README.pt.md) | [العربية](./README.ar.md) | [עברית](./README.he.md) **帳票 서버에 PDF 인쇄를 요청하고 결과를 받기 위한 클라이언트입니다. 런타임 의존 패키지는 제로입니다. 서버 사이드(Node.js)에서의 사용을 전제로 합니다.** tsreport-sdk는 tsreport-editor가 제공하는 외부 인쇄 API 서버와의 통신을 모두 담당합니다. OAuth 2.0을 통한 인증, 인쇄 작업 투입, 완료까지 대기, PDF 다운로드, 그리고 브라우저 미리보기용 소재 취득까지를 타입이 지정된 하나의 클래스로 다룰 수 있습니다. 토큰의 취득・기한 관리・실효 시 재취득은 라이브러리 내부에서 완결되므로, 이용하는 쪽에서 작성하는 것은 "어떤 템플릿에, 어떤 데이터를 흘려보낼 것인가" 뿐입니다. ## 아키텍처: 인증의 경계는 2개 있다 이 SDK를 도입한 시스템은 3개의 계층으로 구성됩니다. **먼저 전체 그림을 파악해 주세요.** 이 SDK에 관한 오해의 대부분은 2개 있는 인증 경계를 혼동하는 데서 생겨납니다. ```mermaid flowchart LR browser["브라우저\ncreateEndpointConnector\n(자격 증명을 가지지 않음)"] app["자체 앱 서버\ncreatePreviewEndpoint + TsreportClient\n(clientSecret은 여기에만 존재)"] editor["인쇄 API 서버\n(tsreport-editor)"] browser -->|"인증 경계 A: 세션 인증\n(직접 구현해야 함)"| app app -->|"인증 경계 B: OAuth 2.0\n(SDK가 자동 처리)"| editor ``` | 계층 | 사용하는 SDK 구성 요소 | 인증에서의 역할 | | --- | --- | --- | | 브라우저 | `createEndpointConnector()` | 경계 A의 **인증을 받는 쪽**. 세션 쿠키 등을 보낼 뿐, 자격 증명은 전혀 가지지 않음 | | 자체 앱 서버 | `createPreviewEndpoint()`+`TsreportClient` | 경계 A를 **검사하는 쪽**(이 검사는 앱이 직접 구현). 경계 B는 SDK에 맡김. `clientSecret`은 이 계층만이 보유 | | 인쇄 API 서버 | (tsreport-editor가 제공) | 경계 B를 검사함 | **SDK가 처리하는 것은 경계 B뿐입니다.** `clientId`/`clientSecret`에 의한 토큰 취득・기한 관리・재전송은 `TsreportClient`가 모두 내부에서 처리합니다. 한편, **경계 A——"지금 조작하고 있는 것은 누구이며, 그 사람이 이 帳票를 봐도 되는가"——를 판단하는 구조는 이 SDK 어디에도 존재하지 않습니다.** `createPreviewEndpoint()`는 인가를 전혀 수행하지 않는 통과형 중계입니다. 로그인・세션 관리・권한 판정은 앱이 가지고 있는 구조를 **엔드포인트 앞단에 반드시 직접 배치해 주세요**. 이를 생략하면 URL에 도달할 수 있는 모든 사람이 帳票와 소재를 읽을 수 있게 됩니다. 이후 예시에 등장하는 "세션 확인"이나 `requireAppUser`는 장식이 아닙니다. **앱 측에서 반드시 구현해야 하는, 경계 A의 자체 코드**입니다. ## 어떤 작업을 하는 패키지이고, 어떤 작업은 하지 않는가 이 패키지는 **통신만**을 담당합니다. - **하는 일** — 인증(OAuth 2.0 client credentials), 인쇄 API 호출, 작업 상태 폴링, PDF 취득, 미리보기 소재 취득, 앱 서버에 두는 중계 엔드포인트 제공 - **동작하는 곳** — `TsreportClient`와 `tsreport-sdk/server`는 서버 사이드 전용입니다. 브라우저에서 사용할 수 있는 것은 자격 증명을 가지지 않는 `createEndpointConnector()`뿐입니다 - **하지 않는 일** — 帳票의 레이아웃이나 PDF 생성(`tsreport-core`의 담당), 미리보기의 화면 렌더링(`tsreport-react`의 담당), 그리고 **이용자 인증・인가**(경계 A. 이용 측 앱의 담당) 설계상의 약속도 2가지 있습니다. **런타임 의존 패키지는 제로**(`package.json`에 `dependencies`가 없음)이며, **환경 변수를 전혀 읽지 않습니다**. 접속 대상도 자격 증명도 모두 명시적인 인수로 받으므로, 어떤 환경에 두어도 동작이 바뀌지 않습니다. ## 설치 ```sh npm install tsreport-sdk ``` Node.js 18 이상에서 동작합니다. 내부에서 사용하는 것은 `fetch`와 Web Streams뿐이며, Node.js 고유의 API에는 의존하지 않습니다. **이 라이브러리는 서버 사이드에서 사용해 주세요.** 중심이 되는 `TsreportClient`는 `clientSecret`을 필요로 하므로, 브라우저에서 동작시키면 비밀 키가 모든 이용자에게 배포되게 됩니다. 브라우저에서 帳票를 다루고 싶은 경우에는, 후술하는 "브라우저에서 미리보기하기"에서 설명하는 3계층 구성을 취하고, 브라우저 측에서는 자격 증명을 가지지 않는 `createEndpointConnector()`만을 사용합니다. ## 사전에 필요한 것 이 라이브러리는 단독으로는 동작하지 않습니다. **tsreport-editor의 외부 인쇄 API 서버가 가동 중일 것**이 전제입니다. 그 서버로부터 다음 4가지를 받아 주세요. | 필요한 것 | 설명 | | --- | --- | | 베이스 URL | API 서버의 URL(예: `https://reports.example.com`). 끝에 슬래시가 붙어 있어도 상관없습니다 | | 클라이언트 ID | OAuth 2.0의 client_id | | 클라이언트 시크릿 | OAuth 2.0의 client_secret. **서버 측에서만 보유하고, 브라우저에는 절대 전달하지 마세요** | | 워크스페이스 키 | 帳票가 배치되어 있는 워크스페이스의 식별자(UUID 형식) | 클라이언트에는 용도에 따른 스코프가 할당됩니다. `report:print`(인쇄 투입), `report:status`(상태 확인), `report:download`(PDF 취득), `report:preview`(미리보기 소재 취득)의 4종류입니다. ## 첫걸음: 帳票를 PDF로 받기 템플릿과 데이터를 전달하여 PDF의 바이트 열을 얻기까지의, 가장 짧은 코드입니다. ```ts 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', // 워크스페이스 키 'invoice.report', // 워크스페이스 내 템플릿의 경로 'v1', // 템플릿의 태그(판) { rows: [{ item: '부품A', amount: 12000 }] }, // 장표에 흘려 넣을 데이터 ) writeFileSync('./invoice.pdf', pdf) ``` `printAndDownload()`는 이후 설명할 "투입 → 완료 대기 → 다운로드"의 3단계를 한번에 실행합니다. ## 인쇄 작업의 흐름 인쇄는 **비동기**입니다. 요청을 한 시점에서는 PDF가 아직 존재하지 않고, 서버 측의 배치 처리가 순서대로 생성합니다. 그 때문에 요청과 수신이 2개의 절차로 나뉘어 있습니다. ``` print() downloadPdf() │ │ ▼ ▼ ┌────────┐ ┌────────────┐ ┌───────────┐ ┌─────────┐ │ queued │ → │ processing │ → │ completed │ → │ PDF │ └────────┘ └────────────┘ └───────────┘ └─────────┘ │ ▼ ┌───────┐ │ error │ → PrintJobError가 throw된다 └───────┘ ``` - `print()`는 **작업의 키**(문자열)를 반환합니다. 이 시점에서는 아직 PDF가 완성되지 않았습니다 - 작업의 상태는 `queued`(순서 대기)→ `processing`(생성 중)→ `completed`(완료)로 전이합니다. 실패한 경우에는 `error`가 됩니다 - `waitForCompletion()`은 `completed`가 될 때까지 상태를 반복 확인하며, `error`가 되면 예외를 던집니다 - `completed`가 되어야 비로소 `downloadPdf()`로 PDF를 취득할 수 있습니다 ## 목적별 사용법 ### PDF를 한 번의 호출로 받고 싶다 — `printAndDownload()` 투입・대기・다운로드를 한번에 수행합니다. **일반적으로는 이것을 사용해 주세요.** ```ts const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data) ``` 대기 간격이나 상한을 변경하고 싶은 경우에는 다섯 번째 인수로 지정합니다. ```ts const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data, { intervalMs: 2000, // 상태를 확인하는 간격 timeoutMs: 90000, // 이것을 초과하면 PollTimeoutError }) ``` ### 투입과 수신을 분리하고 싶다 — `print()` 와 `waitForCompletion()` 작업의 키를 데이터베이스에 저장해 두고, 나중에 결과를 가지러 가는 구성에서 사용합니다. ```ts // 요청만 하고, 키를 기록해 둔다 const key = await client.print(workspaceKey, 'invoice.report', 'v1', data) await db.jobs.insert({ key, requestedAt: new Date() }) // ── 다른 요청이나 다른 프로세스에서 ── await client.waitForCompletion(key) const pdf = await client.downloadPdf(key) ``` ### 진행 상황을 직접 관리하고 싶다 — `getStatus()` 대기하지 않고, 그 순간의 상태만을 취득합니다. 화면에 진행 상황을 표시하는 경우 등에 사용합니다. ```ts const status = await client.getStatus(key) if (status.status === 'completed') { const pdf = await client.downloadPdf(key) } else if (status.status === 'error') { console.error('인쇄에 실패했습니다:', status.errorReason) } else { console.log('처리 중입니다:', status.status) // 'queued' 또는 'processing' } ``` `getStatus()`는 상태를 반환할 뿐, `error`여도 예외를 던지지 않습니다(예외를 던지는 것은 `waitForCompletion()`입니다). ### 큰 PDF를 메모리에 올리지 않고 다루고 싶다 — `getPdfStream()` `downloadPdf()`는 PDF 전체를 메모리에 전개합니다. 수백 페이지의 帳票 등에서는 스트림으로 받아서 직접 파일이나 HTTP 응답으로 흘려보내는 것이 안전합니다. ```ts import { Writable } from 'node:stream' import { createWriteStream } from 'node:fs' const stream = await client.getPdfStream(key) await stream.pipeTo(Writable.toWeb(createWriteStream('./invoice.pdf'))) ``` ### 대기를 도중에 중단하고 싶다 — `signal` 이용자가 화면을 벗어났을 때 등에, 폴링을 멈춥니다. ```ts const controller = new AbortController() // 사용자가 '취소'를 누르면 controller.abort(new Error('취소되었습니다')) const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data, { signal: controller.signal, }) ``` 중단하면 `signal.reason`에 전달한 값으로 Promise가 reject됩니다. ## 브라우저에서 미리보기하기 브라우저에서 帳票 미리보기를 표시하려면, 템플릿・폰트・이미지와 같은 소재를 브라우저 측으로 전달할 필요가 있습니다. 이때 **클라이언트 시크릿을 브라우저에 두어서는 안 되기** 때문에, 중간에 자체 앱 서버를 끼우는 3계층 구성이 됩니다. ``` ┌──────────────┐ ①자료를 요청 ┌────────────────┐ ②인증하여 전송 ┌──────────────┐ │ 브라우저 │ ───────────────→ │ 자신의 앱 │ ───────────────→ │ 인쇄 API 서버 │ │ │ │ 서버 │ │ │ │ createEndpoint│ ←─────────────── │ createPreview │ ←─────────────── │ │ │ Connector │ ④자료가 도착함 │ Endpoint │ ③자료가 반환됨 │ │ └──────────────┘ └────────────────┘ └──────────────┘ 자격 증명을 갖지 않음 clientSecret은 여기에만 ``` - 브라우저 측은 `createEndpointConnector()`로, **자체 앱의 URL**만 알고 있으면 됩니다 - 앱 서버 측은 `createPreviewEndpoint()`를 두는 것만으로, 인증을 붙여서 인쇄 API로 중계합니다 - 이 중계 엔드포인트는 `tsreport-react`의 `PreviewConnector` 계약을 그대로 만족하므로, 미리보기 컴포넌트에 직접 전달할 수 있습니다 이 그림의 "브라우저→앱" 사이가 서두의 **경계 A**(앱의 세션 인증・자체 구현), "앱→인쇄 API" 사이가 **경계 B**(OAuth・SDK가 처리)입니다. ### 앱 서버에 중계 엔드포인트를 두고 싶다 — `createPreviewEndpoint()` `tsreport-sdk/server`에서 불러옵니다(**서버 전용** 서브패스입니다). ```ts 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`는 `(request: Request) => Promise`라는 표준적인 함수입니다. `target`을 지정하면, **브라우저에서 무엇을 요청하든 반드시 이 템플릿을 반환합니다**. 브라우저 측이 임의의 템플릿을 지정할 수 있게 되는 사고를 막을 수 있으므로, 공개 화면에서는 `target`의 지정을 권장합니다(생략하면 브라우저로부터의 지정을 따릅니다). > **중요**: `createPreviewEndpoint()`는 인가를 전혀 수행하지 않습니다. "이 사용자가 이 帳票를 봐도 되는가"의 판단은 이용 측의 책임입니다. 반드시 앱의 인증・인가의 **내측**에 마운트해 주세요. ### Next.js App Router에 통합하고 싶다 중계 핸들러를 **자체 인증으로 감싼 후에** 라우트 핸들러로 export합니다. ```ts // app/api/report-preview/route.ts import { TsreportClient } from 'tsreport-sdk' import { createPreviewEndpoint } from 'tsreport-sdk/server' import { getSessionUser } from '@/lib/auth' // ←앱에서 구현한다(NextAuth・iron-session・자체 세션 등) 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: '워크스페이스 키', path: 'reports/invoice.report', tag: 'v1' }, }) export async function GET(request: Request): Promise { // 여기가 앱 고유의 세션 인증(경계A). SDK는 아무것도 검사하지 않으므로, // 이 검사를 생략하면, URL에 도달할 수 있는 모든 사람이 장표를 열람할 수 있게 된다 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) } ``` `export const GET = createPreviewEndpoint(...)`와 같이 직접 export하는 것도 기술적으로는 가능하지만, **인증을 끼울 곳이 없어지므로 권장하지 않습니다**. 반드시 위의 형태와 같이, 자체 인증을 거친 후에 중계해 주세요. 환경 변수의 읽기는 **이용 측의 코드**입니다. 라이브러리 자체는 환경 변수를 읽지 않습니다. ### Express에 통합하고 싶다 — `toExpressHandler()` ```ts 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는 앱 고유의 세션 인증 미들웨어(경계A・자체 구현). // 이 위치보다 뒤에 마운트하는 것이 인가의 경계가 된다 app.get('/api/report-preview', requireAppUser, toExpressHandler(handler)) ``` `toExpressHandler()`는 중계 자체가 실패한 경우(API 서버에 도달할 수 없는 등)에 `next(error)`로 전달하므로, 앱의 에러 핸들러에서 처리할 수 있습니다. 또한, **express는 이 패키지의 의존성이 아닙니다**. 타입 형태가 맞는 것을 받을 뿐입니다. ### `node:http`에 통합하고 싶다 — `toNodeHandler()` ```ts 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')) { // 앱 고유의 세션 인증(경계A・자체 구현). 생략해서는 안 된다 if (!isAuthorizedAppUser(req)) { // ←앱에서 구현한다 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) ``` `toNodeHandler()`가 반환하는 Promise는, 중계 자체가 실패했을 때 reject됩니다. 위의 예시처럼 받아서, 앱의 방침에 맞는 응답을 반환해 주세요. ### 브라우저 측에서 소재를 취득하고 싶다 — `createEndpointConnector()` 앱 서버에 둔 중계 엔드포인트의 URL을 전달하기만 하면 됩니다. ```ts import { createEndpointConnector } from 'tsreport-sdk' const connector = createEndpointConnector({ endpoint: '/api/report-preview', fetchInit: { credentials: 'include' }, // 앱의 세션 Cookie를 보낸다(경계A의 인증을 받기 위해) }) const payload = await connector.fetchTemplate({ workspace: '워크스페이스 키', path: 'reports/invoice.report', tag: 'v1', }) // payload.template … 템플릿 정의 // payload.fontIds … 이 템플릿이 필요로 하는 폰트 ID의 배열 for (const fontId of payload.fontIds) { const bytes = await connector.fetchFont(fontId) // 찾을 수 없으면 null } ``` TypeScript에서 템플릿의 타입을 지정하고 싶은 경우에는, 타입 인자를 전달합니다. ```ts import type { ReportTemplate } from 'tsreport-core' const connector = createEndpointConnector({ endpoint: '/api/report-preview' }) ``` 커넥터에는 **기억해 두어야 할 동작**이 하나 있습니다. `fetchTemplate()`이 성공하면, 그 템플릿의 워크스페이스와 디렉터리를 기억하여, 이후의 `resolveImage()`에 자동으로 부여합니다. 그 때문에, 이미지를 해석하기 전에 반드시 `fetchTemplate()`을 호출해 주세요(`target`을 고정한 엔드포인트라면 서버 측이 보완하므로 불필요합니다). ## 미리보기 소재를 서버 측에서 직접 다루고 싶다 브라우저를 거치지 않고, Node.js에서 소재를 취득할 수도 있습니다. ### 템플릿을 취득하고 싶다 — `getPreviewTemplate()` ```ts const { template, fontIds } = await client.getPreviewTemplate(workspaceKey, 'invoice.report', 'v1') ``` `fontIds`는 그 템플릿이 필요로 하는 폰트의 목록입니다. 기본 폰트나 수식용 폰트도 포함하여, **인쇄 파이프라인과 동일한 로직으로 서버가 산출**하고 있으므로, 이 목록대로 읽어들이면 미리보기와 인쇄 결과가 일치합니다. ### 서브리포트의 템플릿을 취득하고 싶다 — `getPreviewSubreport()` ```ts const { template, fontIds } = await client.getPreviewSubreport(workspaceKey, 'reports/sub.report') ``` 태그를 받지 않는다는 점이 `getPreviewTemplate()`과 다릅니다. ### 이미지 등의 파일을 취득하고 싶다 — `getPreviewFile()` ```ts const bytes = await client.getPreviewFile(workspaceKey, 'assets/logo.png') ``` ### 이용할 수 있는 폰트를 목록으로 보고 싶다 — `listPreviewFonts()` ```ts const fonts = await client.listPreviewFonts() // [{ id: 'NotoSansJP', fileName: 'NotoSansJP-VariableFont_wght.ttf' }, ...] ``` ### 폰트의 실체를 취득하고 싶다 — `getPreviewFont()` ```ts const fontBytes = await client.getPreviewFont('NotoSansJP') ``` ### 독자적인 중계를 작성하고 싶다 — `fetchPreviewResource()` 위의 메서드로 부족한 경우에, 임의의 미리보기 경로로 인증을 붙여서 GET합니다. ```ts const response = await client.fetchPreviewResource('/api/report/preview/fonts') ``` **이 메서드만은, 에러 시에도 예외를 던지지 않고 `Response`를 그대로 반환합니다.** 상태 코드나 헤더를 그대로 중계하고 싶은 용도(바로 `createPreviewEndpoint()`가 이것을 사용하고 있습니다)를 위한, 저수준 진입점입니다. ## 인증의 구조 이용하는 쪽에서 의식할 필요는 없지만, 내부에서는 다음과 같이 동작하고 있습니다. 1. 인증이 필요한 메서드가 호출되면, 유효한 토큰이 있는지 확인합니다 2. 없으면 `POST /api/oauth/token`으로 `grant_type=client_credentials`로 요청합니다 3. 취득한 토큰은 **인스턴스의 메모리 내에만** 보유하며, 유효 기한 10초 전까지를 재사용합니다 4. 요청이 401 또는 403으로 거부된 경우, 토큰을 파기하고 다시 취득하여, **동일한 요청을 1회만 재전송**합니다 토큰을 디스크나 외부 저장소에 저장하는 일은 없습니다. 명시적으로 토큰이 필요한 경우에만 `getAccessToken()`을 사용합니다. ```ts const token = await client.getAccessToken() ``` ## 에러 처리 모든 에러는 `TsreportClientError`를 상속하고 있으므로, 한꺼번에 포착하는 것도, 종류별로 분기하는 것도 가능합니다. ```ts 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) { // 자격 증명이 잘못되었거나, 클라이언트가 비활성화된 경우 등 console.error('인증에 실패했습니다:', error.status, error.errorCode) } else if (error instanceof PrintJobError) { // 템플릿이나 데이터에 기인하여, 서버 측 생성이 실패했다 console.error('인쇄 작업이 실패했습니다:', error.key, error.errorReason) } else if (error instanceof PollTimeoutError) { // 시간 내에 완료되지 않았다(작업 자체는 아직 살아 있을 가능성이 있다) console.error('대기가 타임아웃되었습니다:', error.key, error.timeoutMs) } else if (error instanceof ApiError) { // API가 4xx/5xx를 반환했다(존재하지 않는 템플릿, 권한 부족 등) console.error('API 오류:', error.status, error.errorMessage) } else if (error instanceof TsreportClientError) { // 그 외 console.error(error.message) } } ``` | 에러 클래스 | 발생하는 경우 | 보유하고 있는 정보 | | --- | --- | --- | | `TokenError` | 토큰의 취득에 실패함(자격 증명의 오류, 클라이언트의 무효화 등) | `status`(HTTP 상태), `errorCode`, `errorDescription` | | `ApiError` | API가 4xx/5xx를 반환함(템플릿이 존재하지 않음, 권한이 부족함 등) | `status`, `errorMessage`(서버가 반환한 메시지) | | `PrintJobError` | 작업의 상태가 `error`가 됨 | `key`, `errorReason`(서버가 반환한 실패 이유) | | `PollTimeoutError` | `waitForCompletion()`이 제한 시간 내에 완료를 확인할 수 없었음 | `key`, `timeoutMs` | | `TsreportClientError` | 위 이외. 모든 에러의 기저 클래스 | — | `PollTimeoutError`는 "클라이언트가 기다리는 것을 그만두었다"는 것일 뿐, **서버 측의 작업이 실패했음을 의미하지 않습니다**. 키는 유효한 상태로 남아 있으므로, 나중에 `getStatus()`로 확인할 수 있습니다. 또한, `createEndpointConnector()`가 반환하는 커넥터만은 예외적으로, 소재를 찾을 수 없는 경우(404)에 예외가 아닌 `null`을 반환합니다. 미리보기는 소재의 일부가 누락되어도 렌더링을 계속할 수 있어야 하기 때문입니다. 다만 `fetchTemplate()`은 대상 외이며, 템플릿 자체를 취득할 수 없는 경우에는 `ApiError`가 던져집니다(렌더링해야 할 것이 아무것도 없기 때문입니다). ## API 레퍼런스 ### `new TsreportClient(options)` | 프로퍼티 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `baseUrl` | string | ✓ | API 서버의 베이스 URL. 끝의 슬래시는 자동으로 제거됨 | | `clientId` | string | ✓ | OAuth 2.0의 client_id | | `clientSecret` | string | ✓ | OAuth 2.0의 client_secret | | `scope` | string | | 요청할 스코프를 공백 구분으로 지정하여 좁힘. 생략하면, 클라이언트에 등록되어 있는 모든 스코프가 부여됨 | ### 메서드 | 메서드 | 반환값 | 설명 | | --- | --- | --- | | `print(workspace, templatePath, tag, data)` | `Promise` | 인쇄를 요청하고, 작업의 키를 반환함 | | `getStatus(key)` | `Promise` | 작업의 현재 상태를 반환함(예외는 던지지 않음) | | `waitForCompletion(key, options?)` | `Promise` | 완료될 때까지 대기함. 실패 시는 `PrintJobError`, 시간 초과는 `PollTimeoutError` | | `downloadPdf(key)` | `Promise` | PDF의 전체 바이트를 취득함 | | `getPdfStream(key)` | `Promise>` | PDF를 스트림으로 취득함 | | `printAndDownload(workspace, templatePath, tag, data, options?)` | `Promise` | 투입・대기・다운로드를 한번에 수행함 | | `getAccessToken()` | `Promise` | 유효한 액세스 토큰을 반환함(필요하면 취득함) | | `getPreviewTemplate(workspace, templatePath, tag)` | `Promise` | 템플릿 정의와 필요 폰트 ID를 취득함 | | `getPreviewSubreport(workspace, templatePath)` | `Promise` | 서브리포트의 템플릿을 취득함 | | `getPreviewFile(workspace, filePath)` | `Promise` | 워크스페이스 내 파일(이미지 등)을 취득함 | | `listPreviewFonts()` | `Promise` | 이용할 수 있는 폰트의 목록을 취득함 | | `getPreviewFont(id)` | `Promise` | 폰트의 실체를 취득함 | | `fetchPreviewResource(resourcePath)` | `Promise` | 임의의 미리보기 경로로 인증을 붙여 GET을 수행하고, 원본 `Response`를 반환함(예외를 던지지 않음) | ### `WaitForCompletionOptions` | 프로퍼티 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `intervalMs` | number | | 상태를 확인하는 간격(밀리초). 기본값: 1000 | | `timeoutMs` | number | | 대기의 상한(밀리초). 초과하면 `PollTimeoutError`. 기본값: 120000 | | `signal` | AbortSignal | | 대기를 중단하기 위한 시그널. 중단 시에는 `signal.reason`으로 reject됨 | ### 반환값의 타입 | 타입 | 구조 | | --- | --- | | `PrintStatusResult` | `{ key: string, status: PrintJobState, errorReason?: string }` | | `PrintJobState` | `'queued'`=순서 대기 / `'processing'`=생성 중 / `'completed'`=완료 / `'error'`=실패 | | `PreviewTemplateResult` | `{ template: unknown, fontIds: string[] }` | | `PreviewFontInfo` | `{ id: string, fileName: string }` | ### `createEndpointConnector(options)` | 프로퍼티 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `endpoint` | string | ✓ | 중계 엔드포인트를 마운트한 URL(예: `/api/report-preview`) | | `fetchInit` | RequestInit | | 모든 요청에 부여할 설정. 세션 cookie를 전송하려면 `{ credentials: 'include' }` | 반환되는 커넥터의 메서드는 다음 4가지입니다. 모두 소재를 찾을 수 없는 경우 `null`을 반환합니다(`fetchTemplate()`은 제외). | 메서드 | 반환값 | 설명 | | --- | --- | --- | | `fetchTemplate(source)` | `Promise` | 템플릿을 취득함. `source`는 `{ workspace, path, tag }` | | `fetchFont(fontId)` | `Promise` | 폰트의 실체를 취득함 | | `resolveImage(ref)` | `Promise` | 템플릿이 참조하는 이미지를 해석함 | | `fetchSubreportTemplate(ref, context)` | `Promise` | 서브리포트의 템플릿을 취득함. `context`는 `{ workingDirectory }` | ### `tsreport-sdk/server` | 함수 | 반환값 | 설명 | | --- | --- | --- | | `createPreviewEndpoint(options)` | `(request: Request) => Promise` | 미리보기 소재의 중계 핸들러를 만듦 | | `toNodeHandler(handler)` | `(req, res) => Promise` | `node:http`용 어댑터. 중계 자체의 실패 시에는 reject됨 | | `toExpressHandler(handler)` | `(req, res, next) => Promise` | Express용 어댑터. 중계 자체의 실패는 `next(error)`로 전달됨 | `createPreviewEndpoint`의 옵션: | 프로퍼티 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `client` | TsreportClient | ✓ | 인증이 완료된 클라이언트. 접속 대상과 자격 증명은 여기서만 전달됨 | | `target` | `{ workspace, path, tag }` | | 고정할 템플릿. 지정하면 요청 측의 지정을 무시하고, 항상 이 템플릿을 반환함. 이미지나 서브리포트의 기준 디렉터리도 보완됨 | ## 예제 코드 `examples/` 디렉터리에, 그대로 동작하는 구현 예시를 동봉하고 있습니다(npm 패키지에도 포함됩니다). | 파일 | 내용 | | --- | --- | | `examples/nextjs-route.ts` | Next.js App Router의 라우트 핸들러(세션 인증 게이트 포함) | | `examples/express-server.ts` | Express로의 통합과 인가 미들웨어의 위치 | | `examples/node-server.ts` | `node:http`로의 통합 | | `examples/browser-connector.ts` | 브라우저에서의 미리보기 소재 취득 | 이것들은 `npm test` 안에서 **실제로 서버를 기동하여 검증**되고 있으므로, 동작하지 않는 코드가 섞이는 일은 없습니다. ## 테스트 | 명령어 | 내용 | | --- | --- | | `npm test` | 단위・통합 테스트. `examples/`를 실제로 기동하는 검증을 포함함 | | `npm run test:live` | **가동 중인 tsreport-editor와 시드 데이터를 전제**로 한 실서버 연동 테스트 | ## 실행 환경 - Node.js 18 이상(`TsreportClient`와 `tsreport-sdk/server`의 동작 환경) - 모던 브라우저(`createEndpointConnector()`만. 자격 증명을 가지지 않으므로 안전하게 배치할 수 있습니다) - 런타임 의존 패키지 없음 - ESM / CommonJS 양쪽 지원 ## 관련 프로젝트 - [tsreport-core](https://github.com/pontasan/tsreport-core) - [tsreport-editor](https://github.com/pontasan/tsreport-editor) - [tsreport-sdk](https://github.com/pontasan/tsreport-sdk) - [tsreport-react](https://github.com/pontasan/tsreport-react) ## License tsreport-sdk는 이용자의 선택에 따라 [MIT License](./LICENSE-MIT) 또는 [Apache License 2.0](./LICENSE-APACHE)으로 이용할 수 있습니다(SPDX: `MIT OR Apache-2.0`).