# tsreport-sdk [English](./README.md) | [日本語](./README.ja.md) | [简体中文](./README.zh-CN.md) | [繁體中文](./README.zh-TW.md) | [한국어](./README.ko.md) | Tiếng Việt | [ไทย](./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) **Đây là client dùng để yêu cầu máy chủ báo cáo in PDF và nhận kết quả trả về. Không có phụ thuộc gói nào ở thời gian chạy. Được thiết kế để sử dụng ở phía server (Node.js).** tsreport-sdk đảm nhận toàn bộ việc giao tiếp với máy chủ API in ấn bên ngoài do tsreport-editor cung cấp. Bạn có thể xử lý xác thực bằng OAuth 2.0, gửi công việc in, chờ cho đến khi hoàn tất, tải xuống PDF, và thậm chí lấy tài nguyên để xem trước trên trình duyệt — tất cả thông qua một class duy nhất có kiểu dữ liệu rõ ràng. Việc lấy token, quản lý thời hạn, và lấy lại token khi hết hạn đều được xử lý hoàn toàn bên trong thư viện, nên phía sử dụng chỉ cần viết "gửi dữ liệu nào vào template nào". ## Kiến trúc: có 2 ranh giới xác thực Hệ thống tích hợp SDK này được cấu thành từ 3 tầng. **Trước tiên hãy nắm bắt toàn cảnh.** Hầu hết các hiểu lầm về SDK này đều bắt nguồn từ việc nhầm lẫn giữa 2 ranh giới xác thực. ```mermaid flowchart LR browser["Trình duyệt\ncreateEndpointConnector\n(không giữ thông tin xác thực)"] app["Máy chủ ứng dụng của bạn\ncreatePreviewEndpoint + TsreportClient\n(clientSecret chỉ tồn tại ở đây)"] editor["Máy chủ API in ấn\n(tsreport-editor)"] browser -->|"Ranh giới xác thực A: Xác thực phiên (session)\n(bạn tự triển khai)"| app app -->|"Ranh giới xác thực B: OAuth 2.0\n(SDK tự động xử lý)"| editor ``` | Tầng | Thành phần SDK sử dụng | Vai trò trong xác thực | | --- | --- | --- | | Trình duyệt | `createEndpointConnector()` | **Bên được xác thực** ở ranh giới A. Chỉ gửi session cookie v.v., hoàn toàn không giữ thông tin xác thực | | Máy chủ ứng dụng của bạn | `createPreviewEndpoint()`+`TsreportClient` | **Bên kiểm tra** ranh giới A (việc kiểm tra này do ứng dụng tự triển khai). Ranh giới B giao cho SDK. `clientSecret` chỉ tồn tại ở tầng này | | Máy chủ API in ấn | (do tsreport-editor cung cấp) | Kiểm tra ranh giới B | **SDK chỉ lo liệu cho ranh giới B.** `TsreportClient` xử lý toàn bộ việc lấy token, quản lý thời hạn, và gửi lại bằng `clientId`/`clientSecret` ở bên trong. Mặt khác, **cơ chế phán đoán ranh giới A — "người đang thao tác hiện tại là ai, và người đó có được phép xem báo cáo này hay không" — hoàn toàn không tồn tại ở bất kỳ đâu trong SDK này.** `createPreviewEndpoint()` chỉ là một trung gian chuyển tiếp, không thực hiện bất kỳ sự cấp phép nào. Đăng nhập, quản lý phiên, phán đoán quyền hạn là cơ chế mà ứng dụng đã có sẵn — **bạn nhất định phải tự đặt nó ở phía trước endpoint**. Nếu bỏ qua điều này, bất kỳ ai truy cập được URL cũng có thể đọc được báo cáo và tài nguyên. "Xác nhận phiên" và `requireAppUser` xuất hiện trong các ví dụ sau đây không phải là trang trí. Đó là **mã tự viết cho ranh giới A, bắt buộc phải triển khai ở phía ứng dụng**. ## Gói này làm gì, và không làm gì Gói này chỉ đảm nhận **việc giao tiếp**. - **Việc làm** — Xác thực (OAuth 2.0 client credentials), gọi API in ấn, polling trạng thái công việc, lấy PDF, lấy tài nguyên xem trước, cung cấp endpoint trung gian đặt ở máy chủ ứng dụng - **Nơi hoạt động** — `TsreportClient` và `tsreport-sdk/server` chỉ dành riêng cho phía server. Thứ duy nhất có thể dùng ở trình duyệt là `createEndpointConnector()` — thứ không giữ thông tin xác thực - **Việc không làm** — Bố cục báo cáo hay tạo PDF (do `tsreport-core` đảm nhận), vẽ màn hình xem trước (do `tsreport-react` đảm nhận), và **xác thực/cấp phép người dùng** (ranh giới A — do ứng dụng phía sử dụng đảm nhận) Cũng có 2 cam kết về mặt thiết kế. **Không có phụ thuộc gói nào ở thời gian chạy** (không có `dependencies` trong `package.json`), và **hoàn toàn không đọc biến môi trường**. Điểm kết nối và thông tin xác thực đều được nhận dưới dạng tham số tường minh, nên hành vi sẽ không thay đổi dù đặt ở môi trường nào. ## Cài đặt ```sh npm install tsreport-sdk ``` Hoạt động trên Node.js 18 trở lên. Bên trong chỉ sử dụng `fetch` và Web Streams, không phụ thuộc vào API riêng của Node.js. **Hãy sử dụng thư viện này ở phía server.** `TsreportClient` — thành phần trung tâm — yêu cầu `clientSecret`, nên nếu chạy trên trình duyệt thì khóa bí mật sẽ bị phân phối cho tất cả người dùng. Nếu muốn xử lý báo cáo từ trình duyệt, hãy áp dụng cấu trúc 3 tầng được mô tả trong phần "Xem trước từ trình duyệt" bên dưới, và ở phía trình duyệt chỉ sử dụng `createEndpointConnector()` — thứ không giữ thông tin xác thực. ## Những thứ cần chuẩn bị trước Thư viện này không thể hoạt động một mình. Điều kiện tiên quyết là **máy chủ API in ấn bên ngoài của tsreport-editor phải đang hoạt động**. Hãy nhận 4 thứ sau đây từ máy chủ đó. | Cần thiết | Mô tả | | --- | --- | | Base URL | URL của máy chủ API (ví dụ: `https://reports.example.com`). Có dấu gạch chéo ở cuối cũng không sao | | Client ID | client_id của OAuth 2.0 | | Client Secret | client_secret của OAuth 2.0. **Chỉ giữ ở phía server, tuyệt đối không được truyền cho trình duyệt** | | Workspace Key | định danh (dạng UUID) của workspace nơi đặt báo cáo | Client sẽ được gán phạm vi (scope) tùy theo mục đích sử dụng. Có 4 loại: `report:print` (gửi lệnh in), `report:status` (kiểm tra trạng thái), `report:download` (lấy PDF), `report:preview` (lấy tài nguyên xem trước). ## Bước đầu tiên: nhận báo cáo dưới dạng PDF Đây là đoạn mã ngắn nhất để truyền template và dữ liệu rồi nhận về chuỗi byte 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', // khóa workspace 'invoice.report', // đường dẫn của mẫu trong workspace 'v1', // thẻ (phiên bản) của mẫu { rows: [{ item: 'Linh kiện A', amount: 12000 }] }, // dữ liệu đổ vào báo cáo ) writeFileSync('./invoice.pdf', pdf) ``` `printAndDownload()` thực hiện gộp 3 bước "gửi lệnh → chờ hoàn tất → tải xuống" sẽ được giải thích sau đây. ## Luồng công việc in ấn Việc in ấn là **bất đồng bộ (asynchronous)**. Tại thời điểm gửi yêu cầu, PDF vẫn chưa tồn tại — quá trình xử lý theo lô ở phía máy chủ sẽ tạo ra lần lượt. Vì vậy, việc gửi yêu cầu và việc nhận kết quả được tách thành 2 bước. ``` print() downloadPdf() │ │ ▼ ▼ ┌────────┐ ┌────────────┐ ┌───────────┐ ┌─────────┐ │ queued │ → │ processing │ → │ completed │ → │ PDF │ └────────┘ └────────────┘ └───────────┘ └─────────┘ │ ▼ ┌───────┐ │ error │ → PrintJobError sẽ được throw └───────┘ ``` - `print()` trả về **key của công việc** (chuỗi ký tự). Tại thời điểm này PDF vẫn chưa được tạo - Trạng thái công việc chuyển đổi theo thứ tự `queued` (đang chờ) → `processing` (đang tạo) → `completed` (hoàn tất). Nếu thất bại thì sẽ chuyển thành `error` - `waitForCompletion()` sẽ lặp lại việc kiểm tra trạng thái cho đến khi thành `completed`, và ném ra exception nếu thành `error` - Chỉ khi thành `completed` mới có thể lấy PDF bằng `downloadPdf()` ## Cách sử dụng theo từng mục đích ### Muốn nhận PDF chỉ với 1 lần gọi — `printAndDownload()` Thực hiện gộp việc gửi lệnh, chờ, và tải xuống. **Thông thường hãy dùng cái này.** ```ts const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data) ``` Nếu muốn thay đổi khoảng thời gian chờ hay giới hạn, hãy chỉ định ở tham số thứ 5. ```ts const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data, { intervalMs: 2000, // khoảng thời gian giữa các lần kiểm tra trạng thái timeoutMs: 90000, // vượt quá thời gian này thì PollTimeoutError }) ``` ### Muốn tách riêng việc gửi lệnh và việc nhận kết quả — `print()` và `waitForCompletion()` Sử dụng trong cấu trúc lưu key của công việc vào cơ sở dữ liệu, rồi sau đó mới đi lấy kết quả. ```ts // Chỉ gửi yêu cầu, rồi lưu lại khóa const key = await client.print(workspaceKey, 'invoice.report', 'v1', data) await db.jobs.insert({ key, requestedAt: new Date() }) // ── Trong một request khác hoặc process khác ── await client.waitForCompletion(key) const pdf = await client.downloadPdf(key) ``` ### Muốn tự quản lý tiến độ — `getStatus()` Không chờ đợi, chỉ lấy trạng thái tại thời điểm đó. Dùng khi hiển thị tiến độ trên màn hình v.v. ```ts const status = await client.getStatus(key) if (status.status === 'completed') { const pdf = await client.downloadPdf(key) } else if (status.status === 'error') { console.error('In thất bại:', status.errorReason) } else { console.log('Đang xử lý:', status.status) // 'queued' hoặc 'processing' } ``` `getStatus()` chỉ trả về trạng thái, kể cả khi là `error` cũng không ném ra exception (chỉ có `waitForCompletion()` mới ném exception). ### Muốn xử lý PDF lớn mà không tải hết vào bộ nhớ — `getPdfStream()` `downloadPdf()` sẽ giải nén toàn bộ PDF vào bộ nhớ. Với báo cáo hàng trăm trang, việc nhận dưới dạng stream rồi truyền trực tiếp vào file hoặc HTTP response sẽ an toàn hơn. ```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'))) ``` ### Muốn hủy giữa chừng khi đang chờ — `signal` Dừng việc polling khi người dùng rời khỏi màn hình v.v. ```ts const controller = new AbortController() // Khi người dùng nhấn "Hủy" thì gọi controller.abort(new Error('Đã bị hủy')) const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data, { signal: controller.signal, }) ``` Khi hủy, Promise sẽ bị reject với giá trị được truyền vào `signal.reason`. ## Xem trước từ trình duyệt Để hiển thị bản xem trước báo cáo trên trình duyệt, cần phải gửi các tài nguyên như template, font, hình ảnh đến phía trình duyệt. Vì lúc này **không được đặt client secret trên trình duyệt**, nên cấu trúc 3 tầng có chèn máy chủ ứng dụng của bạn ở giữa là cần thiết. ``` ┌──────────────┐ ①yêu cầu tài nguyên ┌────────────────┐ ②xác thực và chuyển tiếp ┌──────────────┐ │ Trình duyệt │ ───────────────→ │ Máy chủ ứng │ ───────────────→ │ Máy chủ API in │ │ │ │ dụng của bạn │ │ │ │ createEndpoint│ ←─────────────── │ createPreview │ ←─────────────── │ │ │ Connector │ ④tài nguyên đến │ Endpoint │ ③tài nguyên trả về │ │ └──────────────┘ └────────────────┘ └──────────────┘ không giữ thông tin xác thực clientSecret chỉ nằm ở đây ``` - Phía trình duyệt chỉ cần biết **URL của ứng dụng của bạn** thông qua `createEndpointConnector()` là đủ - Phía máy chủ ứng dụng chỉ cần đặt `createPreviewEndpoint()` để đính kèm xác thực rồi chuyển tiếp đến API in ấn - Endpoint trung gian này thỏa mãn nguyên vẹn hợp đồng `PreviewConnector` của `tsreport-react`, nên có thể truyền trực tiếp cho component xem trước Đoạn giữa "Trình duyệt→Ứng dụng" trong sơ đồ trên chính là **ranh giới A** (xác thực phiên của ứng dụng, tự triển khai) đã đề cập ở đầu, còn đoạn "Ứng dụng→API in ấn" là **ranh giới B** (OAuth, SDK xử lý). ### Muốn đặt endpoint trung gian ở máy chủ ứng dụng — `createPreviewEndpoint()` Import từ `tsreport-sdk/server` (đây là subpath **chỉ dành cho 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` là một hàm tiêu chuẩn có kiểu `(request: Request) => Promise`. Khi chỉ định `target`, **bất kể trình duyệt yêu cầu gì thì cũng luôn trả về template này**. Vì có thể ngăn chặn sự cố trình duyệt chỉ định tùy ý template khác, nên khuyến nghị chỉ định `target` ở màn hình công khai (nếu bỏ qua thì sẽ theo chỉ định từ trình duyệt). > **Quan trọng**: `createPreviewEndpoint()` hoàn toàn không thực hiện cấp phép. Việc phán đoán "người dùng này có được phép xem báo cáo này hay không" là trách nhiệm của phía sử dụng. Nhất định phải mount nó ở **bên trong** cơ chế xác thực/cấp phép của ứng dụng. ### Muốn tích hợp vào Next.js App Router Bọc handler trung gian bằng **xác thực tự viết trước**, rồi export dưới dạng route handler. ```ts // app/api/report-preview/route.ts import { TsreportClient } from 'tsreport-sdk' import { createPreviewEndpoint } from 'tsreport-sdk/server' import { getSessionUser } from '@/lib/auth' // ←tự triển khai trong ứng dụng (NextAuth, iron-session, phiên tự xây dựng, v.v.) 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: 'khóa workspace', path: 'reports/invoice.report', tag: 'v1' }, }) export async function GET(request: Request): Promise { // Đây là xác thực phiên riêng của ứng dụng (ranh giới A). Vì SDK không kiểm tra gì cả, // nếu bỏ qua bước kiểm tra này, bất kỳ ai truy cập được URL đều có thể xem báo cáo 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) } ``` Về mặt kỹ thuật cũng có thể export trực tiếp bằng `export const GET = createPreviewEndpoint(...)`, nhưng **không khuyến nghị vì sẽ không còn chỗ để chèn xác thực**. Nhất định hãy chuyển tiếp sau khi thông qua xác thực tự viết như hình thức phía trên. Việc đọc biến môi trường là **mã của phía sử dụng**. Bản thân thư viện không đọc biến môi trường. ### Muốn tích hợp vào 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 là middleware xác thực phiên riêng của ứng dụng (ranh giới A, tự triển khai). // Việc mount phía sau vị trí này chính là ranh giới ủy quyền app.get('/api/report-preview', requireAppUser, toExpressHandler(handler)) ``` `toExpressHandler()` sẽ chuyển tiếp đến `next(error)` khi bản thân việc chuyển tiếp thất bại (như không thể kết nối đến máy chủ API), nên có thể xử lý bằng error handler của ứng dụng. Lưu ý, **express không phải là phụ thuộc của gói này**. Nó chỉ nhận vào thứ có hình dạng kiểu phù hợp. ### Muốn tích hợp vào `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')) { // Xác thực phiên riêng của ứng dụng (ranh giới A, tự triển khai). Không được bỏ qua if (!isAuthorizedAppUser(req)) { // ←tự triển khai trong ứng dụng 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) ``` Promise mà `toNodeHandler()` trả về sẽ reject khi bản thân việc chuyển tiếp thất bại. Hãy đón nhận như ví dụ trên và trả về phản hồi phù hợp với chính sách của ứng dụng. ### Muốn lấy tài nguyên từ phía trình duyệt — `createEndpointConnector()` Chỉ cần truyền vào URL của endpoint trung gian đã đặt ở máy chủ ứng dụng. ```ts import { createEndpointConnector } from 'tsreport-sdk' const connector = createEndpointConnector({ endpoint: '/api/report-preview', fetchInit: { credentials: 'include' }, // Gửi cookie phiên của ứng dụng (để được xác thực ở ranh giới A) }) const payload = await connector.fetchTemplate({ workspace: 'khóa workspace', path: 'reports/invoice.report', tag: 'v1', }) // payload.template … định nghĩa mẫu // payload.fontIds … mảng các ID phông chữ mà mẫu này cần for (const fontId of payload.fontIds) { const bytes = await connector.fetchFont(fontId) // null nếu không tìm thấy } ``` Nếu muốn gắn kiểu cho template bằng TypeScript, hãy truyền tham số kiểu. ```ts import type { ReportTemplate } from 'tsreport-core' const connector = createEndpointConnector({ endpoint: '/api/report-preview' }) ``` Connector có **một hành vi cần ghi nhớ**. Khi `fetchTemplate()` thành công, nó sẽ ghi nhớ workspace và directory của template đó, và tự động gắn vào `resolveImage()` sau này. Vì vậy, hãy luôn gọi `fetchTemplate()` trước khi resolve hình ảnh (không cần thiết nếu là endpoint có `target` cố định, vì phía máy chủ sẽ tự bổ sung). ## Muốn xử lý trực tiếp tài nguyên xem trước ở phía server Cũng có thể lấy tài nguyên từ Node.js mà không thông qua trình duyệt. ### Muốn lấy template — `getPreviewTemplate()` ```ts const { template, fontIds } = await client.getPreviewTemplate(workspaceKey, 'invoice.report', 'v1') ``` `fontIds` là danh sách font mà template đó cần. Bao gồm cả font mặc định và font công thức toán học, vì **máy chủ tính toán bằng cùng logic với pipeline in ấn**, nên chỉ cần tải theo đúng danh sách này thì kết quả xem trước và kết quả in ấn sẽ khớp nhau. ### Muốn lấy template của subreport — `getPreviewSubreport()` ```ts const { template, fontIds } = await client.getPreviewSubreport(workspaceKey, 'reports/sub.report') ``` Điểm khác biệt với `getPreviewTemplate()` là không nhận tag. ### Muốn lấy file như hình ảnh — `getPreviewFile()` ```ts const bytes = await client.getPreviewFile(workspaceKey, 'assets/logo.png') ``` ### Muốn liệt kê các font có thể sử dụng — `listPreviewFonts()` ```ts const fonts = await client.listPreviewFonts() // [{ id: 'NotoSansJP', fileName: 'NotoSansJP-VariableFont_wght.ttf' }, ...] ``` ### Muốn lấy thực thể của font — `getPreviewFont()` ```ts const fontBytes = await client.getPreviewFont('NotoSansJP') ``` ### Muốn viết trung gian riêng — `fetchPreviewResource()` Khi các phương thức trên không đủ, thực hiện GET có xác thực đến bất kỳ đường dẫn xem trước nào. ```ts const response = await client.fetchPreviewResource('/api/report/preview/fonts') ``` **Chỉ có phương thức này là trả về nguyên vẹn `Response` mà không ném ra exception ngay cả khi có lỗi.** Đây là lối vào cấp thấp cho mục đích muốn chuyển tiếp nguyên vẹn mã trạng thái hay header (chính `createPreviewEndpoint()` cũng đang sử dụng cái này). ## Cơ chế xác thực Phía sử dụng không cần phải để ý, nhưng bên trong hoạt động như sau. 1. Khi phương thức cần xác thực được gọi, sẽ kiểm tra xem có token hợp lệ hay không 2. Nếu không có, sẽ yêu cầu đến `POST /api/oauth/token` với `grant_type=client_credentials` 3. Token đã lấy được sẽ **chỉ được giữ trong bộ nhớ của instance**, và sẽ tái sử dụng cho đến 10 giây trước khi hết hạn 4. Nếu request bị từ chối với mã 401 hoặc 403, sẽ hủy token và lấy lại, rồi **gửi lại đúng request đó chỉ 1 lần** Token không bao giờ được lưu vào đĩa hay kho lưu trữ bên ngoài. Chỉ khi cần token một cách tường minh thì mới dùng `getAccessToken()`. ```ts const token = await client.getAccessToken() ``` ## Xử lý lỗi Tất cả các lỗi đều kế thừa từ `TsreportClientError`, nên có thể bắt gộp lại, hoặc phân nhánh theo từng loại. ```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) { // Thông tin xác thực sai, client đã bị vô hiệu hóa, v.v. console.error('Xác thực thất bại:', error.status, error.errorCode) } else if (error instanceof PrintJobError) { // Việc tạo ở phía máy chủ thất bại do mẫu hoặc dữ liệu console.error('Job in thất bại:', error.key, error.errorReason) } else if (error instanceof PollTimeoutError) { // Không hoàn thành trong thời gian cho phép (bản thân job có thể vẫn còn tồn tại) console.error('Chờ đã hết thời gian:', error.key, error.timeoutMs) } else if (error instanceof ApiError) { // API trả về 4xx/5xx (mẫu không tồn tại, thiếu quyền, v.v.) console.error('Lỗi API:', error.status, error.errorMessage) } else if (error instanceof TsreportClientError) { // Các trường hợp khác console.error(error.message) } } ``` | Class lỗi | Trường hợp xảy ra | Thông tin được giữ | | --- | --- | --- | | `TokenError` | Việc lấy token thất bại (thông tin xác thực sai, client bị vô hiệu hóa v.v.) | `status` (HTTP status), `errorCode`, `errorDescription` | | `ApiError` | API trả về 4xx/5xx (template không tồn tại, thiếu quyền hạn v.v.) | `status`, `errorMessage` (thông báo do máy chủ trả về) | | `PrintJobError` | Trạng thái công việc trở thành `error` | `key`, `errorReason` (lý do thất bại do máy chủ trả về) | | `PollTimeoutError` | `waitForCompletion()` không xác nhận được sự hoàn tất trong giới hạn thời gian | `key`, `timeoutMs` | | `TsreportClientError` | Ngoài các trường hợp trên. Class cơ sở của tất cả các lỗi | — | `PollTimeoutError` chỉ có nghĩa là "client đã ngừng chờ", **không có nghĩa là công việc ở phía máy chủ đã thất bại**. Vì key vẫn còn hợp lệ, nên có thể xác nhận sau bằng `getStatus()`. Ngoài ra, chỉ riêng connector do `createEndpointConnector()` trả về là một ngoại lệ, khi tài nguyên không tìm thấy (404) thì sẽ trả về `null` thay vì exception. Vì bản xem trước nên tiếp tục được vẽ ngay cả khi thiếu một phần tài nguyên. Tuy nhiên `fetchTemplate()` là ngoại lệ của ngoại lệ này, nếu không thể lấy được bản thân template thì `ApiError` sẽ được ném ra (vì không có gì để vẽ cả). ## Tham chiếu API ### `new TsreportClient(options)` | Thuộc tính | Kiểu | Bắt buộc | Mô tả | | --- | --- | --- | --- | | `baseUrl` | string | ✓ | Base URL của máy chủ API. Dấu gạch chéo ở cuối sẽ tự động bị loại bỏ | | `clientId` | string | ✓ | client_id của OAuth 2.0 | | `clientSecret` | string | ✓ | client_secret của OAuth 2.0 | | `scope` | string | | Thu hẹp bằng cách chỉ định scope cần yêu cầu, phân tách bằng khoảng trắng. Nếu bỏ qua, tất cả scope đã đăng ký cho client sẽ được gán | ### Các phương thức | Phương thức | Giá trị trả về | Mô tả | | --- | --- | --- | | `print(workspace, templatePath, tag, data)` | `Promise` | Yêu cầu in ấn, trả về key của công việc | | `getStatus(key)` | `Promise` | Trả về trạng thái hiện tại của công việc (không ném exception) | | `waitForCompletion(key, options?)` | `Promise` | Chờ cho đến khi hoàn tất. Khi thất bại là `PrintJobError`, khi hết thời gian là `PollTimeoutError` | | `downloadPdf(key)` | `Promise` | Lấy toàn bộ byte của PDF | | `getPdfStream(key)` | `Promise>` | Lấy PDF dưới dạng stream | | `printAndDownload(workspace, templatePath, tag, data, options?)` | `Promise` | Thực hiện gộp gửi lệnh, chờ, và tải xuống | | `getAccessToken()` | `Promise` | Trả về access token hợp lệ (lấy mới nếu cần) | | `getPreviewTemplate(workspace, templatePath, tag)` | `Promise` | Lấy định nghĩa template và font ID cần thiết | | `getPreviewSubreport(workspace, templatePath)` | `Promise` | Lấy template của subreport | | `getPreviewFile(workspace, filePath)` | `Promise` | Lấy file (hình ảnh v.v.) trong workspace | | `listPreviewFonts()` | `Promise` | Lấy danh sách font có thể sử dụng | | `getPreviewFont(id)` | `Promise` | Lấy thực thể của font | | `fetchPreviewResource(resourcePath)` | `Promise` | Thực hiện GET có xác thực đến bất kỳ đường dẫn xem trước nào, trả về `Response` nguyên bản (không ném exception) | ### `WaitForCompletionOptions` | Thuộc tính | Kiểu | Bắt buộc | Mô tả | | --- | --- | --- | --- | | `intervalMs` | number | | Khoảng thời gian kiểm tra trạng thái (mili giây). Mặc định: 1000 | | `timeoutMs` | number | | Giới hạn thời gian chờ (mili giây). Vượt quá sẽ là `PollTimeoutError`. Mặc định: 120000 | | `signal` | AbortSignal | | Signal dùng để hủy việc chờ. Khi hủy sẽ bị reject bằng `signal.reason` | ### Kiểu của giá trị trả về | Kiểu | Cấu trúc | | --- | --- | | `PrintStatusResult` | `{ key: string, status: PrintJobState, errorReason?: string }` | | `PrintJobState` | `'queued'`=đang chờ / `'processing'`=đang tạo / `'completed'`=hoàn tất / `'error'`=thất bại | | `PreviewTemplateResult` | `{ template: unknown, fontIds: string[] }` | | `PreviewFontInfo` | `{ id: string, fileName: string }` | ### `createEndpointConnector(options)` | Thuộc tính | Kiểu | Bắt buộc | Mô tả | | --- | --- | --- | --- | | `endpoint` | string | ✓ | URL đã mount endpoint trung gian (ví dụ: `/api/report-preview`) | | `fetchInit` | RequestInit | | Cấu hình gắn cho tất cả request. Nếu muốn gửi session cookie thì dùng `{ credentials: 'include' }` | Connector được trả về có 4 phương thức sau. Tất cả đều trả về `null` khi không tìm thấy tài nguyên (ngoại trừ `fetchTemplate()`). | Phương thức | Giá trị trả về | Mô tả | | --- | --- | --- | | `fetchTemplate(source)` | `Promise` | Lấy template. `source` là `{ workspace, path, tag }` | | `fetchFont(fontId)` | `Promise` | Lấy thực thể của font | | `resolveImage(ref)` | `Promise` | Resolve hình ảnh mà template tham chiếu đến | | `fetchSubreportTemplate(ref, context)` | `Promise` | Lấy template của subreport. `context` là `{ workingDirectory }` | ### `tsreport-sdk/server` | Hàm | Giá trị trả về | Mô tả | | --- | --- | --- | | `createPreviewEndpoint(options)` | `(request: Request) => Promise` | Tạo handler trung gian cho tài nguyên xem trước | | `toNodeHandler(handler)` | `(req, res) => Promise` | Adapter cho `node:http`. Reject khi bản thân việc chuyển tiếp thất bại | | `toExpressHandler(handler)` | `(req, res, next) => Promise` | Adapter cho Express. Chuyển tiếp thất bại của bản thân việc trung gian đến `next(error)` | Tùy chọn của `createPreviewEndpoint`: | Thuộc tính | Kiểu | Bắt buộc | Mô tả | | --- | --- | --- | --- | | `client` | TsreportClient | ✓ | Client đã xác thực. Điểm kết nối và thông tin xác thực chỉ được truyền từ đây | | `target` | `{ workspace, path, tag }` | | Template được cố định. Khi chỉ định, sẽ bỏ qua chỉ định từ phía request, luôn trả về template này. Thư mục cơ sở cho hình ảnh và subreport cũng sẽ được bổ sung | ## Mã ví dụ Trong thư mục `examples/` có kèm theo các ví dụ triển khai hoạt động ngay (cũng được bao gồm trong gói npm). | File | Nội dung | | --- | --- | | `examples/nextjs-route.ts` | Route handler của Next.js App Router (kèm cổng xác thực phiên) | | `examples/express-server.ts` | Tích hợp vào Express và vị trí của middleware cấp phép | | `examples/node-server.ts` | Tích hợp vào `node:http` | | `examples/browser-connector.ts` | Lấy tài nguyên xem trước từ trình duyệt | Vì các file này **thực sự được khởi động máy chủ để kiểm chứng** trong `npm test`, nên sẽ không xảy ra việc lẫn vào mã không hoạt động. ## Kiểm thử | Lệnh | Nội dung | | --- | --- | | `npm test` | Kiểm thử đơn vị và tích hợp. Bao gồm việc kiểm chứng bằng cách thực sự khởi động `examples/` | | `npm run test:live` | Kiểm thử liên kết máy chủ thực, với điều kiện tiên quyết là **tsreport-editor đang hoạt động và dữ liệu seed** | ## Môi trường thực thi - Node.js 18 trở lên (môi trường hoạt động của `TsreportClient` và `tsreport-sdk/server`) - Trình duyệt hiện đại (chỉ dành cho `createEndpointConnector()`. Vì không giữ thông tin xác thực nên có thể đặt an toàn) - Không có phụ thuộc gói ở thời gian chạy - Hỗ trợ cả ESM / CommonJS ## Các dự án liên quan - [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 có thể được sử dụng theo lựa chọn của người dùng dưới [MIT License](./LICENSE-MIT) hoặc [Apache License 2.0](./LICENSE-APACHE) (SPDX: `MIT OR Apache-2.0`).