# tsreport-sdk [English](./README.md) | [日本語](./README.ja.md) | [简体中文](./README.zh-CN.md) | [繁體中文](./README.zh-TW.md) | [한국어](./README.ko.md) | [Tiếng Việt](./README.vi.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 รับผิดชอบการสื่อสารทั้งหมดกับเซิร์ฟเวอร์ API การพิมพ์ภายนอกที่ tsreport-editor จัดเตรียมไว้ ตั้งแต่การยืนยันตัวตนด้วย OAuth 2.0, การส่งงานพิมพ์, การรอจนกว่าจะเสร็จสิ้น, การดาวน์โหลด PDF ไปจนถึงการดึงวัสดุสำหรับแสดงตัวอย่างในเบราว์เซอร์ ทั้งหมดนี้ถูกจัดการไว้ในคลาสเดียวที่มีการกำหนดชนิดข้อมูล (type) การขอรับโทเคน การจัดการวันหมดอายุ และการขอรับใหม่เมื่อหมดอายุ ล้วนเสร็จสิ้นภายในไลบรารีเอง ผู้ใช้งานจึงต้องเขียนโค้ดเพียงแค่ "จะส่งข้อมูลใดไปยังเทมเพลตใด" เท่านั้น ## สถาปัตยกรรม: ขอบเขตของการยืนยันตัวตนมี 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 เพียงส่งเซสชัน Cookie เป็นต้น โดยไม่มีข้อมูลรับรองใดๆ เลย | | เซิร์ฟเวอร์แอปของตนเอง | `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 การพิมพ์, การ polling สถานะงาน, การรับ PDF, การรับวัสดุสำหรับแสดงตัวอย่าง, การจัดเตรียมเอ็นด์พอยต์ตัวกลางที่วางไว้บนเซิร์ฟเวอร์แอป - **สภาพแวดล้อมที่ทำงานได้** — `TsreportClient` และ `tsreport-sdk/server` ใช้งานได้เฉพาะฝั่งเซิร์ฟเวอร์เท่านั้น สิ่งที่ใช้ในเบราว์เซอร์ได้มีเพียง `createEndpointConnector()` ที่ไม่มีข้อมูลรับรอง - **สิ่งที่ไม่ทำ** — เลย์เอาต์ของรายงานหรือการสร้าง PDF (หน้าที่ของ `tsreport-core`), การแสดงผลหน้าจอตัวอย่าง (หน้าที่ของ `tsreport-react`) และ **การยืนยันตัวตน・การตรวจสอบสิทธิ์ของผู้ใช้งาน** (ขอบเขต A ซึ่งเป็นหน้าที่ของแอปฝั่งผู้ใช้งาน) มีข้อสัญญาในการออกแบบอีก 2 ข้อ นั่นคือ **ไม่มีแพ็กเกจที่ต้องพึ่งพาในขณะรันไทม์เลย** (ไม่มี `dependencies` ใน `package.json`) และ **ไม่มีการอ่านตัวแปรสภาพแวดล้อม (environment variable) เลย** ทั้งปลายทางการเชื่อมต่อและข้อมูลรับรองล้วนรับเป็นอาร์กิวเมนต์ที่ระบุอย่างชัดเจนทั้งหมด ทำให้พฤติกรรมไม่เปลี่ยนแปลงไม่ว่าจะนำไปวางในสภาพแวดล้อมใด ## การติดตั้ง ```sh npm install tsreport-sdk ``` ทำงานได้บน Node.js 18 ขึ้นไป ภายในใช้เพียง `fetch` และ Web Streams เท่านั้น ไม่ได้พึ่งพา API เฉพาะของ Node.js แต่อย่างใด **โปรดใช้ไลบรารีนี้ในฝั่งเซิร์ฟเวอร์** เนื่องจาก `TsreportClient` ซึ่งเป็นแกนหลักต้องการ `clientSecret` หากนำไปรันในเบราว์เซอร์ กุญแจลับจะถูกแจกจ่ายไปยังผู้ใช้งานทุกคน หากต้องการจัดการรายงานจากเบราว์เซอร์ ให้ใช้โครงสร้าง 3 ชั้นที่อธิบายไว้ในหัวข้อ "แสดงตัวอย่างจากเบราว์เซอร์" ด้านล่าง โดยฝั่งเบราว์เซอร์ใช้เพียง `createEndpointConnector()` ที่ไม่มีข้อมูลรับรองเท่านั้น ## สิ่งที่ต้องเตรียมล่วงหน้า ไลบรารีนี้ทำงานเพียงลำพังไม่ได้ ต้องมี**เซิร์ฟเวอร์ API การพิมพ์ภายนอกของ tsreport-editor ทำงานอยู่**เป็นเงื่อนไข โปรดขอรับสิ่งต่อไปนี้ทั้ง 4 อย่างจากเซิร์ฟเวอร์นั้น | สิ่งที่จำเป็น | คำอธิบาย | | --- | --- | | Base URL | URL ของเซิร์ฟเวอร์ API (เช่น `https://reports.example.com`) มีเครื่องหมายทับปิดท้ายหรือไม่ก็ได้ | | Client ID | client_id ของ OAuth 2.0 | | Client Secret | client_secret ของ OAuth 2.0 **ต้องเก็บไว้ฝั่งเซิร์ฟเวอร์เท่านั้น ห้ามส่งไปยังเบราว์เซอร์เด็ดขาด** | | Workspace Key | ตัวระบุ (identifier) ของ workspace ที่วางรายงานไว้ (รูปแบบ UUID) | ไคลเอนต์จะได้รับ scope ตามการใช้งาน มีทั้งหมด 4 ประเภท ได้แก่ `report:print` (การส่งงานพิมพ์), `report:status` (การตรวจสอบสถานะ), `report:download` (การรับ PDF), และ `report:preview` (การรับวัสดุสำหรับแสดงตัวอย่าง) ## ก้าวแรก: รับรายงานเป็น 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 ขั้นตอน "ส่ง → รอจนเสร็จ → ดาวน์โหลด" ที่จะอธิบายต่อไปนี้ทั้งหมดในคราวเดียว ## กระบวนการของงานพิมพ์ การพิมพ์เป็นแบบ**อะซิงโครนัส (asynchronous)** ณ เวลาที่ส่งคำขอ PDF ยังไม่ถูกสร้างขึ้น การประมวลผลแบบ batch ฝั่งเซิร์ฟเวอร์จะสร้างตามลำดับ ดังนั้นการส่งคำขอและการรับผลลัพธ์จึงแยกออกเป็น 2 ขั้นตอน ``` print() downloadPdf() │ │ ▼ ▼ ┌────────┐ ┌────────────┐ ┌───────────┐ ┌─────────┐ │ queued │ → │ processing │ → │ completed │ → │ PDF │ └────────┘ └────────────┘ └───────────┘ └─────────┘ │ ▼ ┌───────┐ │ error │ → PrintJobError จะถูก throw └───────┘ ``` - `print()` จะคืนค่า**คีย์ของงาน (job key)** (string) ณ จุดนี้ PDF ยังไม่ถูกสร้างขึ้น - สถานะของงานจะเปลี่ยนจาก `queued` (รอคิว) → `processing` (กำลังสร้าง) → `completed` (เสร็จสิ้น) หากล้มเหลวจะกลายเป็น `error` - `waitForCompletion()` จะตรวจสอบสถานะซ้ำๆ จนกว่าจะเป็น `completed` และหากกลายเป็น `error` จะ throw exception - ต่อเมื่อกลายเป็น `completed` แล้วเท่านั้นจึงจะสามารถรับ PDF ได้ด้วย `downloadPdf()` ## วิธีใช้งานตามวัตถุประสงค์ ### ต้องการรับ PDF ด้วยการเรียก 1 ครั้ง — `printAndDownload()` ทำการส่ง・รอ・ดาวน์โหลดในคราวเดียว **โดยปกติให้ใช้วิธีนี้** ```ts const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data) ``` หากต้องการเปลี่ยนช่วงเวลารอและขีดจำกัด ให้ระบุในอาร์กิวเมนต์ที่ 5 ```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()` เพียงคืนค่าสถานะ ไม่ throw exception แม้จะเป็น `error` (ที่ throw exception คือ `waitForCompletion()`) ### ต้องการจัดการ PDF ขนาดใหญ่โดยไม่โหลดลงในหน่วยความจำ — `getPdfStream()` `downloadPdf()` จะกางเนื้อหา PDF ทั้งหมดลงในหน่วยความจำ สำหรับรายงานที่มีหลายร้อยหน้า การรับเป็นสตรีมแล้วส่งตรงไปยังไฟล์หรือ HTTP response จะปลอดภัยกว่า ```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` หยุดการ polling เมื่อผู้ใช้งานออกจากหน้าจอ เป็นต้น ```ts const controller = new AbortController() // เมื่อผู้ใช้กด「ยกเลิก」ให้เรียก controller.abort(new Error('ถูกยกเลิกแล้ว')) const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data, { signal: controller.signal, }) ``` เมื่อยกเลิก Promise จะถูก reject ด้วยค่าที่ส่งไปยัง `signal.reason` ## แสดงตัวอย่างจากเบราว์เซอร์ การแสดงตัวอย่างรายงานในเบราว์เซอร์จำเป็นต้องส่งวัสดุ เช่น เทมเพลต ฟอนต์ รูปภาพ ไปยังฝั่งเบราว์เซอร์ ในขณะนี้เนื่องจาก**ห้ามวาง client secret ไว้ในเบราว์เซอร์** จึงต้องใช้โครงสร้าง 3 ชั้นโดยแทรกเซิร์ฟเวอร์แอปของตนเองไว้ตรงกลาง ``` ┌──────────────┐ ①ขอแอสเซ็ต ┌────────────────┐ ②ยืนยันตัวตนแล้วส่งต่อ ┌──────────────┐ │ เบราว์เซอร์ │ ───────────────→ │ แอปของคุณ │ ───────────────→ │ เซิร์ฟเวอร์ API พิมพ์ │ │ │ │ เซิร์ฟเวอร์ │ │ │ │ createEndpoint│ ←─────────────── │ createPreview │ ←─────────────── │ │ │ Connector │ ④แอสเซ็ตมาถึง │ Endpoint │ ③แอสเซ็ตถูกส่งกลับ │ │ └──────────────┘ └────────────────┘ └──────────────┘ ไม่มีข้อมูลรับรอง clientSecret อยู่ที่นี่เท่านั้น ``` - ฝั่งเบราว์เซอร์เพียงแค่รู้ **URL ของแอปตนเอง** ก็เพียงพอด้วย `createEndpointConnector()` - ฝั่งเซิร์ฟเวอร์แอปเพียงวาง `createPreviewEndpoint()` ก็จะแนบการยืนยันตัวตนแล้วส่งต่อไปยัง API การพิมพ์ - เอ็นด์พอยต์ตัวกลางนี้เป็นไปตามสัญญา (contract) ของ `PreviewConnector` ใน `tsreport-react` โดยตรง จึงสามารถส่งให้กับคอมโพเนนต์แสดงตัวอย่างได้เลย ในแผนภาพนี้ ระหว่าง "เบราว์เซอร์→แอป" คือ**ขอบเขต A** ที่กล่าวถึงตอนต้น (การยืนยันตัวตนเซสชันของแอป・พัฒนาเอง) ส่วน "แอป→API การพิมพ์" คือ**ขอบเขต B** (OAuth・SDK จัดการให้) ### ต้องการวางเอ็นด์พอยต์ตัวกลางบนเซิร์ฟเวอร์แอป — `createPreviewEndpoint()` โหลดจาก `tsreport-sdk/server` (เป็น subpath **สำหรับเซิร์ฟเวอร์เท่านั้น**) ```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 ให้ห่อ (wrap) แฮนเดลอร์ตัวกลางด้วย**การยืนยันตัวตนของตนเองก่อน** แล้วจึง export ออกมาเป็น 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' // ←อิมพลีเมนต์ในแอปเอง(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 โดยตรงแบบ `export const GET = createPreviewEndpoint(...)` ก็ทำได้ในทางเทคนิค แต่**ไม่แนะนำเนื่องจากจะไม่มีที่ให้แทรกการยืนยันตัวตน** โปรดส่งต่อผ่านการยืนยันตัวตนของตนเองก่อนเสมอ ตามรูปแบบด้านบน การอ่านตัวแปรสภาพแวดล้อมเป็น**โค้ดฝั่งผู้ใช้งาน** ตัวไลบรารีเองไม่อ่านตัวแปรสภาพแวดล้อม ### ต้องการนำไปใช้กับ 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()` จะส่งต่อไปยัง `next(error)` เมื่อการส่งต่อล้มเหลว (เช่น ไม่สามารถเข้าถึงเซิร์ฟเวอร์ API ได้) ทำให้สามารถประมวลผลด้วย error handler ของแอปได้ นอกจากนี้ **express ไม่ใช่ dependency ของแพ็กเกจนี้** เป็นเพียงการรับสิ่งที่มีรูปแบบชนิดข้อมูลตรงกันเท่านั้น ### ต้องการนำไปใช้กับ `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) ``` Promise ที่คืนค่าโดย `toNodeHandler()` จะ 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 ให้ส่งอาร์กิวเมนต์ชนิดข้อมูล (type argument) ```ts import type { ReportTemplate } from 'tsreport-core' const connector = createEndpointConnector({ endpoint: '/api/report-preview' }) ``` มี**พฤติกรรมหนึ่งที่ควรจดจำ**ในคอนเนกเตอร์ นั่นคือเมื่อ `fetchTemplate()` สำเร็จ จะจดจำ workspace และ directory ของเทมเพลตนั้นไว้ แล้วนำไปแนบให้กับ `resolveImage()` ในครั้งต่อๆ ไปโดยอัตโนมัติ ดังนั้นก่อนแก้ไข (resolve) รูปภาพ โปรดเรียก `fetchTemplate()` เสมอ (หากเป็นเอ็นด์พอยต์ที่ตรึง `target` ไว้ตายตัว ฝั่งเซิร์ฟเวอร์จะเติมให้เองจึงไม่จำเป็น) ## ต้องการจัดการวัสดุสำหรับแสดงตัวอย่างโดยตรงในฝั่งเซิร์ฟเวอร์ สามารถรับวัสดุจาก Node.js ได้โดยไม่ต้องผ่านเบราว์เซอร์เช่นกัน ### ต้องการรับเทมเพลต — `getPreviewTemplate()` ```ts const { template, fontIds } = await client.getPreviewTemplate(workspaceKey, 'invoice.report', 'v1') ``` `fontIds` คือรายการฟอนต์ที่เทมเพลตนี้ต้องการ รวมถึงฟอนต์ค่าเริ่มต้นและฟอนต์สำหรับสูตรคณิตศาสตร์ด้วย เนื่องจาก**เซิร์ฟเวอร์คำนวณด้วยตรรกะเดียวกันกับ pipeline การพิมพ์** หากโหลดตามรายการนี้ ผลลัพธ์ของการแสดงตัวอย่างและการพิมพ์จริงจะตรงกัน ### ต้องการรับเทมเพลตของ subreport — `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') ``` ### ต้องการเขียนการส่งต่อ (relay) เอง — `fetchPreviewResource()` เมื่อวิธีการข้างต้นไม่เพียงพอ สามารถทำ GET แบบแนบการยืนยันตัวตนไปยังพาธการแสดงตัวอย่างใดก็ได้ ```ts const response = await client.fetchPreviewResource('/api/report/preview/fonts') ``` **มีเพียงเมธอดนี้เท่านั้นที่จะคืนค่า `Response` ตามเดิมโดยไม่ throw exception แม้จะเกิดข้อผิดพลาด** เป็นทางเข้าระดับต่ำสำหรับกรณีที่ต้องการส่งต่อสถานะโค้ดหรือ header ตามเดิม (`createPreviewEndpoint()` ก็ใช้วิธีนี้อยู่) ## กลไกการยืนยันตัวตน ผู้ใช้งานไม่จำเป็นต้องคำนึงถึง แต่ภายในทำงานดังนี้ 1. เมื่อมีการเรียกเมธอดที่ต้องการการยืนยันตัวตน จะตรวจสอบว่ามีโทเคนที่ยังใช้ได้หรือไม่ 2. หากไม่มี จะขอไปยัง `POST /api/oauth/token` ด้วย `grant_type=client_credentials` 3. โทเคนที่ได้รับจะถูกเก็บไว้**ในหน่วยความจำของอินสแตนซ์เท่านั้น** และนำกลับมาใช้ซ้ำจนถึง 10 วินาทีก่อนวันหมดอายุ 4. หากคำขอถูกปฏิเสธด้วย 401 หรือ 403 จะทิ้งโทเคนและขอรับใหม่ แล้ว**ส่งคำขอเดิมซ้ำอีกครั้งเดียว** จะไม่มีการบันทึกโทเคนลงในดิสก์หรือที่เก็บข้อมูลภายนอกเลย หากต้องการโทเคนอย่างชัดเจน ให้ใช้ `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 status), `errorCode`, `errorDescription` | | `ApiError` | API คืนค่า 4xx/5xx (ไม่มีเทมเพลต, สิทธิ์ไม่เพียงพอ เป็นต้น) | `status`, `errorMessage` (ข้อความที่เซิร์ฟเวอร์ส่งกลับมา) | | `PrintJobError` | สถานะของงานกลายเป็น `error` | `key`, `errorReason` (เหตุผลความล้มเหลวที่เซิร์ฟเวอร์ส่งกลับมา) | | `PollTimeoutError` | `waitForCompletion()` ไม่สามารถยืนยันความสำเร็จภายในเวลาที่กำหนด | `key`, `timeoutMs` | | `TsreportClientError` | นอกเหนือจากข้างต้น เป็นคลาสฐาน (base class) ของข้อผิดพลาดทั้งหมด | — | `PollTimeoutError` หมายถึงเพียง "ไคลเอนต์หยุดรอ" เท่านั้น **ไม่ได้หมายความว่างานฝั่งเซิร์ฟเวอร์ล้มเหลว** เนื่องจากคีย์ยังคงใช้ได้ จึงสามารถตรวจสอบทีหลังได้ด้วย `getStatus()` นอกจากนี้ มีเพียงคอนเนกเตอร์ที่คืนค่าโดย `createEndpointConnector()` เท่านั้นที่เป็นข้อยกเว้น จะคืนค่า `null` แทนการ throw exception เมื่อไม่พบวัสดุ (404) เนื่องจากการแสดงตัวอย่างควรสามารถแสดงผลต่อไปได้แม้วัสดุบางส่วนจะขาดหายไป อย่างไรก็ตาม `fetchTemplate()` เป็นข้อยกเว้น หากไม่สามารถรับเทมเพลตเองได้ จะ throw `ApiError` (เนื่องจากไม่มีสิ่งใดที่ควรแสดงผลเลย) ## เอกสารอ้างอิง API ### `new TsreportClient(options)` | พร็อพเพอร์ตี้ | ชนิด | จำเป็น | คำอธิบาย | | --- | --- | --- | --- | | `baseUrl` | string | ✓ | Base URL ของเซิร์ฟเวอร์ API เครื่องหมายทับปิดท้ายจะถูกลบออกโดยอัตโนมัติ | | `clientId` | string | ✓ | client_id ของ OAuth 2.0 | | `clientSecret` | string | ✓ | client_secret ของ OAuth 2.0 | | `scope` | string | | จำกัด scope ที่ต้องการโดยคั่นด้วยช่องว่าง หากละเว้นจะได้รับ scope ทั้งหมดที่ลงทะเบียนไว้กับไคลเอนต์ | ### เมธอด | เมธอด | ค่าที่คืน | คำอธิบาย | | --- | --- | --- | | `print(workspace, templatePath, tag, data)` | `Promise` | ส่งคำขอพิมพ์ และคืนค่าคีย์ของงาน | | `getStatus(key)` | `Promise` | คืนค่าสถานะปัจจุบันของงาน (ไม่ throw exception) | | `waitForCompletion(key, options?)` | `Promise` | รอจนกว่าจะเสร็จสิ้น หากล้มเหลวจะเป็น `PrintJobError`, หมดเวลาจะเป็น `PollTimeoutError` | | `downloadPdf(key)` | `Promise` | รับไบต์ทั้งหมดของ PDF | | `getPdfStream(key)` | `Promise>` | รับ PDF แบบสตรีม | | `printAndDownload(workspace, templatePath, tag, data, options?)` | `Promise` | ทำการส่ง・รอ・ดาวน์โหลดในคราวเดียว | | `getAccessToken()` | `Promise` | คืนค่า access token ที่ใช้ได้ (ขอรับหากจำเป็น) | | `getPreviewTemplate(workspace, templatePath, tag)` | `Promise` | รับการกำหนดค่าเทมเพลตและ ID ฟอนต์ที่จำเป็น | | `getPreviewSubreport(workspace, templatePath)` | `Promise` | รับเทมเพลตของ subreport | | `getPreviewFile(workspace, filePath)` | `Promise` | รับไฟล์ (เช่นรูปภาพ) ภายใน workspace | | `listPreviewFonts()` | `Promise` | รับรายการฟอนต์ที่ใช้ได้ | | `getPreviewFont(id)` | `Promise` | รับตัวฟอนต์จริง | | `fetchPreviewResource(resourcePath)` | `Promise` | ทำ GET แบบแนบการยืนยันตัวตนไปยังพาธการแสดงตัวอย่างใดก็ได้ และคืนค่า `Response` ดิบ (ไม่ throw exception) | ### `WaitForCompletionOptions` | พร็อพเพอร์ตี้ | ชนิด | จำเป็น | คำอธิบาย | | --- | --- | --- | --- | | `intervalMs` | number | | ช่วงเวลาในการตรวจสอบสถานะ (มิลลิวินาที) ค่าเริ่มต้น: 1000 | | `timeoutMs` | number | | ขีดจำกัดของการรอ (มิลลิวินาที) หากเกินจะเป็น `PollTimeoutError` ค่าเริ่มต้น: 120000 | | `signal` | AbortSignal | | สัญญาณสำหรับยกเลิกการรอ เมื่อยกเลิกจะ reject ด้วย `signal.reason` | ### ชนิดข้อมูลของค่าที่คืน | ชนิด | โครงสร้าง | | --- | --- | | `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 ที่ mount เอ็นด์พอยต์ตัวกลางไว้ (เช่น `/api/report-preview`) | | `fetchInit` | RequestInit | | การตั้งค่าที่แนบไปกับทุกคำขอ หากต้องการส่งเซสชัน cookie ให้ใช้ `{ credentials: 'include' }` | เมธอดของคอนเนกเตอร์ที่คืนค่ามีทั้งหมด 4 เมธอดต่อไปนี้ ทุกเมธอดจะคืนค่า `null` หากไม่พบวัสดุ (ยกเว้น `fetchTemplate()`) | เมธอด | ค่าที่คืน | คำอธิบาย | | --- | --- | --- | | `fetchTemplate(source)` | `Promise` | รับเทมเพลต `source` คือ `{ workspace, path, tag }` | | `fetchFont(fontId)` | `Promise` | รับตัวฟอนต์จริง | | `resolveImage(ref)` | `Promise` | แก้ไข (resolve) รูปภาพที่เทมเพลตอ้างอิงถึง | | `fetchSubreportTemplate(ref, context)` | `Promise` | รับเทมเพลตของ subreport `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 }` | | เทมเพลตที่ตรึงไว้ตายตัว หากระบุ จะละเว้นการระบุจากฝั่งคำขอ และคืนเทมเพลตนี้เสมอ ไดเรกทอรีอ้างอิงสำหรับรูปภาพและ subreport จะถูกเติมให้ด้วย | ## ตัวอย่างโค้ด มีตัวอย่างการนำไปใช้งานจริงที่รันได้ทันทีอยู่ในไดเรกทอรี `examples/` (รวมอยู่ใน npm package ด้วย) | ไฟล์ | เนื้อหา | | --- | --- | | `examples/nextjs-route.ts` | Route handler ของ Next.js App Router (พร้อม gate การยืนยันตัวตนเซสชัน) | | `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 และข้อมูล seed ที่กำลังทำงานอยู่** | ## สภาพแวดล้อมการทำงาน - 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`)