@technestinnovations/print-gateway-client 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ export type PrintDocumentBlock = {
2
+ type: 'text';
3
+ value: string;
4
+ align?: 'left' | 'center' | 'right';
5
+ style?: {
6
+ bold?: boolean;
7
+ width?: number;
8
+ height?: number;
9
+ };
10
+ } | {
11
+ type: 'rule';
12
+ char?: string;
13
+ style?: string;
14
+ } | {
15
+ type: 'row';
16
+ columns: Array<{
17
+ text: string;
18
+ width: number;
19
+ align?: string;
20
+ style?: {
21
+ bold?: boolean;
22
+ };
23
+ }>;
24
+ } | {
25
+ type: 'table';
26
+ headers?: string[];
27
+ rows: string[][];
28
+ } | {
29
+ type: 'image';
30
+ data: string;
31
+ align?: string;
32
+ max_width?: number;
33
+ } | {
34
+ type: 'qr';
35
+ data: string;
36
+ size?: number;
37
+ align?: string;
38
+ } | {
39
+ type: 'barcode';
40
+ data: string;
41
+ format?: string;
42
+ align?: string;
43
+ height?: number;
44
+ } | {
45
+ type: 'feed';
46
+ lines?: number;
47
+ } | {
48
+ type: 'cut';
49
+ mode?: 'full' | 'partial';
50
+ } | {
51
+ type: 'cash_drawer';
52
+ pin?: number;
53
+ };
54
+ export type PrintDocument = {
55
+ schema_version?: string;
56
+ document_type?: string;
57
+ paper_width_mm?: number;
58
+ locale?: string;
59
+ metadata?: Record<string, unknown>;
60
+ blocks: PrintDocumentBlock[];
61
+ };
62
+ export type PrintOptions = {
63
+ copies?: number;
64
+ cut?: boolean;
65
+ cut_mode?: 'full' | 'partial' | 'none';
66
+ open_cash_drawer?: boolean;
67
+ cash_drawer_pin?: number;
68
+ feed_lines_before_cut?: number;
69
+ };
70
+ export type PrintRequest = {
71
+ document: PrintDocument;
72
+ printer_id?: string;
73
+ role?: string;
74
+ options?: PrintOptions;
75
+ };
76
+ export type PrintJobResponse = {
77
+ job_id: string;
78
+ status: string;
79
+ printer_id: string;
80
+ created_at: string;
81
+ };
82
+ export type GatewayDestination = {
83
+ destination: string;
84
+ printer_id: string;
85
+ name: string;
86
+ };
87
+ export type GatewayPrinter = {
88
+ printer_id: string;
89
+ name: string;
90
+ last_known_ip: string;
91
+ port: number;
92
+ protocol: string;
93
+ roles: string[];
94
+ is_default: boolean;
95
+ connection_status: string;
96
+ paper_width_mm: number;
97
+ cut_mode: string;
98
+ open_cash_drawer: boolean;
99
+ };
100
+ export type GatewayHealth = {
101
+ status: string;
102
+ service: string;
103
+ version: string;
104
+ api_version: string;
105
+ schema_version: string;
106
+ requires_auth: boolean;
107
+ transport_mode: string;
108
+ uptime_seconds?: number;
109
+ host?: string;
110
+ port?: number;
111
+ printer_count?: number;
112
+ default_printer_id?: string;
113
+ destinations?: GatewayDestination[];
114
+ pos_origins_configured?: boolean;
115
+ pos_origin_count?: number;
116
+ };
117
+ export type SetupBundle = {
118
+ base_url: string;
119
+ pos_origin: string;
120
+ printers: Array<{
121
+ printer_id: string;
122
+ name: string;
123
+ destinations: string[];
124
+ is_default: boolean;
125
+ }>;
126
+ };
package/dist/schema.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { PrintGatewayClient } from './print.js';
2
+ import type { GatewayDestination, GatewayHealth, PrintDocument, PrintJobResponse, SetupBundle } from './schema.js';
3
+ export type SetupValidation = {
4
+ ok: boolean;
5
+ printerCount: number;
6
+ defaultPrinterId?: string;
7
+ destinations: GatewayDestination[];
8
+ };
9
+ export declare function validateSetup(client: PrintGatewayClient): Promise<SetupValidation>;
10
+ export declare function parseSetupBundle(raw: string): SetupBundle;
11
+ export type PrintRouterOptions = {
12
+ client: PrintGatewayClient;
13
+ routes: Record<string, string>;
14
+ };
15
+ export type PrintRouteOptions = {
16
+ idempotencyKey?: string;
17
+ waitForCompletion?: boolean;
18
+ };
19
+ export declare function createPrintRouter(options: PrintRouterOptions): {
20
+ routes: Record<string, string>;
21
+ getPrinterId(routeKey: string): string | undefined;
22
+ print(routeKey: string, document: PrintDocument, printOptions?: PrintRouteOptions): Promise<PrintJobResponse>;
23
+ };
24
+ export declare function resolvePrintTarget(client: PrintGatewayClient, options: {
25
+ printerId?: string;
26
+ role?: string;
27
+ health?: GatewayHealth;
28
+ }): Promise<{
29
+ printerId?: string;
30
+ role?: string;
31
+ }>;
package/dist/setup.js ADDED
@@ -0,0 +1,49 @@
1
+ export async function validateSetup(client) {
2
+ const health = await client.getHealth();
3
+ return {
4
+ ok: health.status === 'ok',
5
+ printerCount: health.printer_count ?? 0,
6
+ defaultPrinterId: health.default_printer_id,
7
+ destinations: health.destinations ?? [],
8
+ };
9
+ }
10
+ export function parseSetupBundle(raw) {
11
+ const parsed = JSON.parse(raw);
12
+ if (!parsed.base_url || !parsed.pos_origin || !Array.isArray(parsed.printers)) {
13
+ throw new Error('Invalid setup bundle');
14
+ }
15
+ return parsed;
16
+ }
17
+ export function createPrintRouter(options) {
18
+ const { client, routes } = options;
19
+ return {
20
+ routes,
21
+ getPrinterId(routeKey) {
22
+ return routes[routeKey];
23
+ },
24
+ async print(routeKey, document, printOptions = {}) {
25
+ const printerId = routes[routeKey];
26
+ if (!printerId) {
27
+ throw new Error(`No printer mapped for route "${routeKey}"`);
28
+ }
29
+ const job = await client.print({ document, printer_id: printerId }, printOptions.idempotencyKey);
30
+ if (printOptions.waitForCompletion) {
31
+ return client.waitForJob(job.job_id);
32
+ }
33
+ return job;
34
+ },
35
+ };
36
+ }
37
+ export async function resolvePrintTarget(client, options) {
38
+ if (options.printerId) {
39
+ return { printerId: options.printerId };
40
+ }
41
+ const health = options.health ?? await client.getHealth();
42
+ if (!options.role && health.printer_count === 1 && health.default_printer_id) {
43
+ return { printerId: health.default_printer_id };
44
+ }
45
+ if (options.role) {
46
+ return { role: options.role };
47
+ }
48
+ return { role: 'receipt' };
49
+ }
@@ -0,0 +1,27 @@
1
+ import type { PrintDocument } from '../schema';
2
+ export type ReceiptLineItem = {
3
+ name: string;
4
+ quantity: number;
5
+ unitPrice: number;
6
+ total: number;
7
+ };
8
+ export type ReceiptData = {
9
+ storeName: string;
10
+ storeAddress?: string;
11
+ storePhone?: string;
12
+ receiptNumber: string;
13
+ orderId?: string;
14
+ tableName?: string;
15
+ timestamp: string;
16
+ items: ReceiptLineItem[];
17
+ subtotal?: number;
18
+ discount?: number;
19
+ tax?: number;
20
+ total: number;
21
+ paymentMethod?: string;
22
+ customerName?: string;
23
+ footer?: string;
24
+ qrUrl?: string;
25
+ paperWidthMm?: number;
26
+ };
27
+ export declare function buildReceiptDocument(data: ReceiptData): PrintDocument;
@@ -0,0 +1,100 @@
1
+ export function buildReceiptDocument(data) {
2
+ const paperWidth = data.paperWidthMm ?? 80;
3
+ const blocks = [
4
+ { type: 'text', value: data.storeName, align: 'center', style: { bold: true, width: 2, height: 2 } },
5
+ ];
6
+ if (data.storeAddress) {
7
+ blocks.push({ type: 'text', value: data.storeAddress, align: 'center' });
8
+ }
9
+ if (data.storePhone) {
10
+ blocks.push({ type: 'text', value: data.storePhone, align: 'center' });
11
+ }
12
+ const header = [
13
+ `Bill #${data.receiptNumber}`,
14
+ data.tableName ? `Table ${data.tableName}` : null,
15
+ ].filter(Boolean).join(' · ');
16
+ blocks.push({ type: 'text', value: header, align: 'center' });
17
+ blocks.push({ type: 'text', value: data.timestamp, align: 'center' });
18
+ blocks.push({ type: 'rule' });
19
+ for (const item of data.items) {
20
+ blocks.push({
21
+ type: 'row',
22
+ columns: [
23
+ { text: item.name, width: 60, align: 'left' },
24
+ { text: item.total.toFixed(2), width: 40, align: 'right' },
25
+ ],
26
+ });
27
+ blocks.push({
28
+ type: 'row',
29
+ columns: [
30
+ { text: ` ${item.quantity} x ${item.unitPrice.toFixed(2)}`, width: 60, align: 'left' },
31
+ { text: '', width: 40, align: 'right' },
32
+ ],
33
+ });
34
+ }
35
+ blocks.push({ type: 'rule' });
36
+ if (data.subtotal != null) {
37
+ blocks.push({
38
+ type: 'row',
39
+ columns: [
40
+ { text: 'Subtotal', width: 60, align: 'left' },
41
+ { text: data.subtotal.toFixed(2), width: 40, align: 'right' },
42
+ ],
43
+ });
44
+ }
45
+ if (data.discount != null) {
46
+ blocks.push({
47
+ type: 'row',
48
+ columns: [
49
+ { text: 'Discount', width: 60, align: 'left' },
50
+ { text: data.discount.toFixed(2), width: 40, align: 'right' },
51
+ ],
52
+ });
53
+ }
54
+ if (data.tax != null) {
55
+ blocks.push({
56
+ type: 'row',
57
+ columns: [
58
+ { text: 'Tax', width: 60, align: 'left' },
59
+ { text: data.tax.toFixed(2), width: 40, align: 'right' },
60
+ ],
61
+ });
62
+ }
63
+ blocks.push({
64
+ type: 'row',
65
+ columns: [
66
+ { text: 'GRAND TOTAL', width: 60, align: 'left', style: { bold: true } },
67
+ { text: data.total.toFixed(2), width: 40, align: 'right', style: { bold: true } },
68
+ ],
69
+ });
70
+ if (data.paymentMethod) {
71
+ blocks.push({ type: 'text', value: `PAID · ${data.paymentMethod}`, align: 'left' });
72
+ }
73
+ if (data.customerName) {
74
+ blocks.push({ type: 'text', value: data.customerName, align: 'left' });
75
+ }
76
+ if (data.footer) {
77
+ blocks.push({ type: 'text', value: data.footer, align: 'center' });
78
+ }
79
+ if (data.qrUrl) {
80
+ blocks.push({ type: 'qr', data: data.qrUrl, size: 6, align: 'center' });
81
+ }
82
+ blocks.push({ type: 'feed', lines: 3 });
83
+ blocks.push({ type: 'cut', mode: 'full' });
84
+ return {
85
+ schema_version: '1.0',
86
+ document_type: 'receipt',
87
+ paper_width_mm: paperWidth,
88
+ metadata: {
89
+ receipt_number: data.receiptNumber,
90
+ order_id: data.orderId,
91
+ timestamp: data.timestamp,
92
+ store: {
93
+ name: data.storeName,
94
+ address: data.storeAddress,
95
+ phone: data.storePhone,
96
+ },
97
+ },
98
+ blocks,
99
+ };
100
+ }
@@ -0,0 +1,12 @@
1
+ import type { GatewayHealth } from './schema';
2
+ export type DetectOptions = {
3
+ baseUrl?: string;
4
+ timeoutMs?: number;
5
+ };
6
+ export type DetectResult = {
7
+ available: boolean;
8
+ health?: GatewayHealth;
9
+ baseUrl: string;
10
+ };
11
+ export declare function detectPrintGateway(options?: DetectOptions): Promise<DetectResult>;
12
+ export declare function isAndroidDevice(): boolean;
@@ -0,0 +1,24 @@
1
+ const DEFAULT_BASE_URL = 'http://127.0.0.1:19643/api/v1';
2
+ export async function detectPrintGateway(options = {}) {
3
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
4
+ const timeoutMs = options.timeoutMs ?? 500;
5
+ try {
6
+ const controller = new AbortController();
7
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
8
+ const response = await fetch(`${baseUrl}/health`, { signal: controller.signal });
9
+ clearTimeout(timer);
10
+ if (!response.ok) {
11
+ return { available: false, baseUrl };
12
+ }
13
+ const health = (await response.json());
14
+ return { available: health.status === 'ok', health, baseUrl };
15
+ }
16
+ catch {
17
+ return { available: false, baseUrl };
18
+ }
19
+ }
20
+ export function isAndroidDevice() {
21
+ if (typeof navigator === 'undefined')
22
+ return false;
23
+ return /Android/i.test(navigator.userAgent);
24
+ }
@@ -0,0 +1,4 @@
1
+ export type FallbackPrintOptions = {
2
+ title?: string;
3
+ };
4
+ export declare function fallbackWindowPrint(options?: FallbackPrintOptions): Promise<void>;
@@ -0,0 +1,13 @@
1
+ export function fallbackWindowPrint(options = {}) {
2
+ return new Promise((resolve, reject) => {
3
+ if (typeof window === 'undefined' || typeof window.print !== 'function') {
4
+ reject(new Error('window.print is not available'));
5
+ return;
6
+ }
7
+ if (options.title && typeof document !== 'undefined') {
8
+ document.title = options.title;
9
+ }
10
+ window.print();
11
+ resolve();
12
+ });
13
+ }
@@ -0,0 +1,24 @@
1
+ import type { PrintDocument } from './schema';
2
+ export { buildReceiptDocument } from './adapters/receiptAdapter';
3
+ export type { ReceiptData, ReceiptLineItem } from './adapters/receiptAdapter';
4
+ export { detectPrintGateway, isAndroidDevice } from './detect';
5
+ export { fallbackWindowPrint } from './fallback';
6
+ export { PrintGatewayClient } from './print';
7
+ export type { GatewayHealth, PrintDocument, PrintDocumentBlock, PrintJobResponse, PrintOptions, PrintRequest, } from './schema';
8
+ export type PrintWithFallbackOptions = {
9
+ baseUrl?: string;
10
+ authToken?: string;
11
+ printerId?: string;
12
+ role?: string;
13
+ idempotencyKey?: string;
14
+ waitForCompletion?: boolean;
15
+ onlyOnAndroid?: boolean;
16
+ };
17
+ export type PrintResult = {
18
+ method: 'gateway';
19
+ jobId: string;
20
+ status: string;
21
+ } | {
22
+ method: 'window.print';
23
+ };
24
+ export declare function printWithFallback(document: PrintDocument, options?: PrintWithFallbackOptions): Promise<PrintResult>;
@@ -0,0 +1,35 @@
1
+ import { detectPrintGateway, isAndroidDevice } from './detect';
2
+ import { fallbackWindowPrint } from './fallback';
3
+ import { PrintGatewayClient } from './print';
4
+ export { buildReceiptDocument } from './adapters/receiptAdapter';
5
+ export { detectPrintGateway, isAndroidDevice } from './detect';
6
+ export { fallbackWindowPrint } from './fallback';
7
+ export { PrintGatewayClient } from './print';
8
+ export async function printWithFallback(document, options = {}) {
9
+ const onlyOnAndroid = options.onlyOnAndroid ?? true;
10
+ if (onlyOnAndroid && !isAndroidDevice()) {
11
+ await fallbackWindowPrint();
12
+ return { method: 'window.print' };
13
+ }
14
+ const detection = await detectPrintGateway({ baseUrl: options.baseUrl });
15
+ if (!detection.available) {
16
+ await fallbackWindowPrint();
17
+ return { method: 'window.print' };
18
+ }
19
+ const client = new PrintGatewayClient({
20
+ baseUrl: detection.baseUrl,
21
+ authToken: options.authToken,
22
+ });
23
+ const request = {
24
+ document,
25
+ printer_id: options.printerId,
26
+ role: options.role ?? 'receipt',
27
+ options: { cut: true },
28
+ };
29
+ const job = await client.print(request, options.idempotencyKey);
30
+ if (options.waitForCompletion) {
31
+ const finalJob = await client.waitForJob(job.job_id);
32
+ return { method: 'gateway', jobId: finalJob.job_id, status: finalJob.status };
33
+ }
34
+ return { method: 'gateway', jobId: job.job_id, status: job.status };
35
+ }
@@ -0,0 +1,18 @@
1
+ import type { PrintJobResponse, PrintRequest } from './schema';
2
+ export type PrintGatewayClientOptions = {
3
+ baseUrl?: string;
4
+ authToken?: string;
5
+ };
6
+ export declare class PrintGatewayClient {
7
+ private baseUrl;
8
+ private authToken?;
9
+ constructor(options?: PrintGatewayClientOptions);
10
+ setAuthToken(token: string): void;
11
+ private headers;
12
+ print(request: PrintRequest, idempotencyKey?: string): Promise<PrintJobResponse>;
13
+ getJob(jobId: string): Promise<PrintJobResponse & {
14
+ attempts?: number;
15
+ last_error?: string;
16
+ }>;
17
+ waitForJob(jobId: string, timeoutMs?: number, pollMs?: number): Promise<PrintJobResponse>;
18
+ }
@@ -0,0 +1,53 @@
1
+ export class PrintGatewayClient {
2
+ constructor(options = {}) {
3
+ this.baseUrl = options.baseUrl ?? 'http://127.0.0.1:19643/api/v1';
4
+ this.authToken = options.authToken;
5
+ }
6
+ setAuthToken(token) {
7
+ this.authToken = token;
8
+ }
9
+ headers(idempotencyKey) {
10
+ const headers = {
11
+ 'Content-Type': 'application/json',
12
+ };
13
+ if (this.authToken) {
14
+ headers.Authorization = `Bearer ${this.authToken}`;
15
+ }
16
+ if (idempotencyKey) {
17
+ headers['X-Idempotency-Key'] = idempotencyKey;
18
+ }
19
+ return headers;
20
+ }
21
+ async print(request, idempotencyKey) {
22
+ const response = await fetch(`${this.baseUrl}/print`, {
23
+ method: 'POST',
24
+ headers: this.headers(idempotencyKey),
25
+ body: JSON.stringify(request),
26
+ });
27
+ if (!response.ok) {
28
+ const body = await response.text();
29
+ throw new Error(body || `Print request failed with status ${response.status}`);
30
+ }
31
+ return (await response.json());
32
+ }
33
+ async getJob(jobId) {
34
+ const response = await fetch(`${this.baseUrl}/jobs/${jobId}`, {
35
+ headers: this.headers(),
36
+ });
37
+ if (!response.ok) {
38
+ throw new Error(`Failed to fetch job ${jobId}`);
39
+ }
40
+ return (await response.json());
41
+ }
42
+ async waitForJob(jobId, timeoutMs = 30000, pollMs = 500) {
43
+ const deadline = Date.now() + timeoutMs;
44
+ while (Date.now() < deadline) {
45
+ const job = await this.getJob(jobId);
46
+ if (job.status === 'completed' || job.status === 'failed') {
47
+ return job;
48
+ }
49
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
50
+ }
51
+ throw new Error(`Timed out waiting for job ${jobId}`);
52
+ }
53
+ }
@@ -0,0 +1,88 @@
1
+ export type PrintDocumentBlock = {
2
+ type: 'text';
3
+ value: string;
4
+ align?: 'left' | 'center' | 'right';
5
+ style?: {
6
+ bold?: boolean;
7
+ width?: number;
8
+ height?: number;
9
+ };
10
+ } | {
11
+ type: 'rule';
12
+ char?: string;
13
+ style?: string;
14
+ } | {
15
+ type: 'row';
16
+ columns: Array<{
17
+ text: string;
18
+ width: number;
19
+ align?: string;
20
+ style?: {
21
+ bold?: boolean;
22
+ };
23
+ }>;
24
+ } | {
25
+ type: 'table';
26
+ headers?: string[];
27
+ rows: string[][];
28
+ } | {
29
+ type: 'image';
30
+ data: string;
31
+ align?: string;
32
+ max_width?: number;
33
+ } | {
34
+ type: 'qr';
35
+ data: string;
36
+ size?: number;
37
+ align?: string;
38
+ } | {
39
+ type: 'barcode';
40
+ data: string;
41
+ format?: string;
42
+ align?: string;
43
+ height?: number;
44
+ } | {
45
+ type: 'feed';
46
+ lines?: number;
47
+ } | {
48
+ type: 'cut';
49
+ mode?: 'full' | 'partial';
50
+ } | {
51
+ type: 'cash_drawer';
52
+ pin?: number;
53
+ };
54
+ export type PrintDocument = {
55
+ schema_version: string;
56
+ document_type: string;
57
+ paper_width_mm: number;
58
+ locale?: string;
59
+ metadata?: Record<string, unknown>;
60
+ blocks: PrintDocumentBlock[];
61
+ };
62
+ export type PrintOptions = {
63
+ copies?: number;
64
+ cut?: boolean;
65
+ open_cash_drawer?: boolean;
66
+ };
67
+ export type PrintRequest = {
68
+ document: PrintDocument;
69
+ printer_id?: string;
70
+ role?: string;
71
+ options?: PrintOptions;
72
+ };
73
+ export type PrintJobResponse = {
74
+ job_id: string;
75
+ status: string;
76
+ printer_id: string;
77
+ created_at: string;
78
+ };
79
+ export type GatewayHealth = {
80
+ status: string;
81
+ service: string;
82
+ version: string;
83
+ api_version: string;
84
+ schema_version: string;
85
+ requires_auth: boolean;
86
+ transport_mode: string;
87
+ default_printer_id?: string;
88
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { PrintDocument } from './schema.js';
2
+ export declare function textToDocument(text: string, documentType?: string): PrintDocument;
@@ -0,0 +1,12 @@
1
+ export function textToDocument(text, documentType = 'receipt') {
2
+ const blocks = text.replace(/\r\n/g, '\n').split('\n').map((line) => ({
3
+ type: 'text',
4
+ value: line,
5
+ align: 'left',
6
+ }));
7
+ return {
8
+ schema_version: '1.0',
9
+ document_type: documentType,
10
+ blocks,
11
+ };
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { textToDocument } from './textToDocument';
4
+ import { buildPrintIntentUrl } from './intent';
5
+ test('textToDocument splits lines without hardware fields', () => {
6
+ const document = textToDocument('Line 1\nLine 2');
7
+ assert.equal(document.blocks.length, 2);
8
+ assert.equal(document.paper_width_mm, undefined);
9
+ assert.equal(document.blocks[0]?.type, 'text');
10
+ });
11
+ test('buildPrintIntentUrl encodes payload', () => {
12
+ const url = buildPrintIntentUrl({
13
+ token: 'abc',
14
+ role: 'receipt',
15
+ document: textToDocument('Test'),
16
+ });
17
+ assert.match(url, /^printgateway:\/\/print\?payload=/);
18
+ });