@gohcltech/edge-print-client 0.2.0-dev.6

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,484 @@
1
+ /**
2
+ * @module edge-print
3
+ *
4
+ * Browser-side WebSocket client for the Edge Printing agent.
5
+ * Drop-in replacement for qz-tray.js with a simpler token-based auth model.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import ep from '@gohcltech/edge-print-client'
10
+ *
11
+ * await ep.connect({ token: 'your-api-token' })
12
+ * const printers = await ep.printers()
13
+ * const jobId = await ep.print(
14
+ * { printer: 'Office Laser' },
15
+ * [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: pdfBase64 }],
16
+ * )
17
+ * ```
18
+ */
19
+
20
+ /**
21
+ * Identifies the target printer.
22
+ *
23
+ * - Pass a `string` to use a printer by its OS name (e.g. `"Office Laser"`).
24
+ * - Pass `{ host, port }` to route to a network printer by address.
25
+ */
26
+ export type PrinterTarget = string | { host: string; port: number }
27
+
28
+ /**
29
+ * Configuration options for a print job.
30
+ *
31
+ * All fields except `printer` are optional; omitted fields use the printer's
32
+ * own defaults.
33
+ */
34
+ export interface PrintConfig {
35
+ /** Target printer — OS name string or remote `{ host, port }` object. */
36
+ printer: PrinterTarget
37
+ /** Number of copies to print (default: 1). */
38
+ copies?: number
39
+ /**
40
+ * Duplex (double-sided) mode.
41
+ * - `'none'` — single-sided
42
+ * - `'long-edge'` — flip on the long edge (standard book binding)
43
+ * - `'short-edge'` — flip on the short edge (calendar binding)
44
+ */
45
+ duplex?: 'none' | 'long-edge' | 'short-edge'
46
+ /** Page orientation (default: determined by document content). */
47
+ orientation?: 'portrait' | 'landscape'
48
+ /** Human-readable name shown in the OS print queue. */
49
+ jobName?: string
50
+ /**
51
+ * Color output mode.
52
+ * - `'color'` — full color
53
+ * - `'grayscale'` — color converted to gray
54
+ * - `'black-white'` — pure monochrome (fastest on mono printers)
55
+ */
56
+ colorType?: 'color' | 'grayscale' | 'black-white'
57
+ /**
58
+ * Paper size, exactly as returned by {@link PrinterInfo.papers}
59
+ * (e.g. `"Letter"`, `"A4"`). Omit to use the printer's current default.
60
+ */
61
+ paperSize?: string
62
+ /**
63
+ * Input tray, exactly as returned by {@link TrayInfo.name} inside
64
+ * {@link PrinterInfo.trays} (e.g. `"Tray 1"`, `"Auto"`).
65
+ * Omit to use the printer's current default.
66
+ */
67
+ tray?: string
68
+ }
69
+
70
+ /**
71
+ * A single unit of content to be printed.
72
+ *
73
+ * Pass an array of `PrintData` to {@link EdgePrintClient.print} — each item
74
+ * maps to one page or label in the job.
75
+ *
76
+ * @example PDF from a base64 string
77
+ * ```ts
78
+ * const page: PrintData = {
79
+ * type: 'pixel',
80
+ * format: 'pdf',
81
+ * flavor: 'base64',
82
+ * data: '<base64-encoded PDF>',
83
+ * }
84
+ * ```
85
+ *
86
+ * @example ZPL label (raw command)
87
+ * ```ts
88
+ * const label: PrintData = {
89
+ * type: 'raw',
90
+ * format: 'command',
91
+ * flavor: 'plain',
92
+ * data: '^XA^FO50,50^ADN,36,20^FDHello^FS^XZ',
93
+ * }
94
+ * ```
95
+ */
96
+ export interface PrintData {
97
+ /**
98
+ * Rendering pipeline.
99
+ * - `'raw'` — bytes are forwarded to the printer verbatim (ESC/POS, ZPL, EPL, …)
100
+ * - `'pixel'` — the agent rasterises the content before printing (PDF, HTML, image)
101
+ */
102
+ type: 'raw' | 'pixel'
103
+ /**
104
+ * Content format.
105
+ * - `'command'` — raw printer command language
106
+ * - `'pdf'` — Portable Document Format
107
+ * - `'html'` — HTML markup (agent renders to pixels)
108
+ * - `'image'` — raster image (PNG, JPEG, …)
109
+ */
110
+ format: 'command' | 'pdf' | 'html' | 'image'
111
+ /**
112
+ * How `data` is encoded.
113
+ * - `'plain'` — UTF-8 text
114
+ * - `'base64'` — Base64-encoded binary
115
+ * - `'hex'` — hex-encoded binary
116
+ * - `'file'` — absolute file path on the agent machine (agent reads the file)
117
+ */
118
+ flavor: 'plain' | 'base64' | 'hex' | 'file'
119
+ /** The content, encoded according to `flavor`. */
120
+ data: string
121
+ /** Format-specific extra options forwarded to the agent renderer. */
122
+ options?: Record<string, unknown>
123
+ }
124
+
125
+ /**
126
+ * An input tray/bin reported by the printer driver.
127
+ *
128
+ * Returned inside {@link PrinterInfo.trays}.
129
+ */
130
+ export interface TrayInfo {
131
+ /** Tray name exactly as the driver reports it (e.g. `"Tray 1"`, `"Auto"`). */
132
+ name: string
133
+ /**
134
+ * Paper sizes this tray can hold, when the driver reports per-tray
135
+ * capability. Empty when the driver does not distinguish by tray —
136
+ * fall back to {@link PrinterInfo.papers} in that case.
137
+ */
138
+ papers?: string[]
139
+ }
140
+
141
+ /**
142
+ * Metadata about a printer available on the agent machine.
143
+ *
144
+ * Returned by {@link EdgePrintClient.printers}.
145
+ * Use {@link name} as the value for {@link PrintConfig.printer}.
146
+ */
147
+ export interface PrinterInfo {
148
+ /** OS-assigned printer name. Pass this to {@link PrintConfig.printer}. */
149
+ name: string
150
+ /** `true` if this is the system default printer. */
151
+ is_default: boolean
152
+ /**
153
+ * Current printer status reported by the OS.
154
+ *
155
+ * Possible values: `"idle"` | `"printing"` | `"offline"` | `"error"` |
156
+ * `"paused"` | `"paper_jam"` | `"paper_out"` | `"disabled"` | `"unknown"`
157
+ */
158
+ status: string
159
+ /** Driver name as reported by the OS (present when available). */
160
+ driver?: string
161
+ /** Port or URI the printer is connected on (e.g. `"USB001"`, `"ipp://…"`). */
162
+ port?: string
163
+ /** Physical location string set in the printer properties. */
164
+ location?: string
165
+ /** Freeform comment from the printer properties. */
166
+ comment?: string
167
+ /** Windows share name, if the printer is shared on the network. */
168
+ share_name?: string
169
+ /** `true` if the printer supports color output. */
170
+ color?: boolean
171
+ /** `true` if the printer supports duplex (double-sided) printing. */
172
+ duplex?: boolean
173
+ /**
174
+ * Input trays available on this printer.
175
+ * Pass a {@link TrayInfo.name} value to {@link PrintConfig.tray}.
176
+ */
177
+ trays?: TrayInfo[]
178
+ /**
179
+ * All paper sizes the printer supports, regardless of tray.
180
+ * Pass one of these values to {@link PrintConfig.paperSize}.
181
+ */
182
+ papers?: string[]
183
+ /** Maximum number of copies the driver accepts in a single job. */
184
+ copies_max?: number
185
+ /** Page orientations the driver supports (e.g. `["portrait", "landscape"]`). */
186
+ orientations?: string[]
187
+ /**
188
+ * `true` for Developer (virtual) printers — injected by the agent when
189
+ * Developer Printers mode is enabled in Settings. These printers capture
190
+ * jobs to disk or render a ZPL preview rather than sending to physical hardware.
191
+ */
192
+ virtual?: boolean
193
+ }
194
+
195
+ /**
196
+ * Options passed to {@link EdgePrintClient.connect}.
197
+ */
198
+ export interface ConnectOptions {
199
+ /**
200
+ * Hostname or IP of the machine running the Edge Printing agent.
201
+ * @default '127.0.0.1'
202
+ */
203
+ host?: string
204
+ /**
205
+ * Port the agent is listening on.
206
+ * @default 8181
207
+ */
208
+ port?: number
209
+ /**
210
+ * API token shown in the Edge Printing settings window.
211
+ * Every WebSocket session must authenticate with this token before
212
+ * any other requests are accepted.
213
+ */
214
+ token: string
215
+ /**
216
+ * Maximum number of additional connection attempts after the first failure.
217
+ * @default 3
218
+ */
219
+ retries?: number
220
+ /**
221
+ * Milliseconds to wait between retry attempts.
222
+ * @default 1000
223
+ */
224
+ retryDelay?: number
225
+ }
226
+
227
+ type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void }
228
+
229
+ /**
230
+ * WebSocket client for the Edge Printing agent.
231
+ *
232
+ * Each instance manages a single persistent connection. For most applications
233
+ * the exported {@link ep} singleton is sufficient; create additional instances
234
+ * only when you need concurrent connections to different agents.
235
+ *
236
+ * ### Lifecycle
237
+ * ```
238
+ * connect() → printers() / print() / … → disconnect()
239
+ * ```
240
+ *
241
+ * @example
242
+ * ```ts
243
+ * import { EdgePrintClient } from '@gohcltech/edge-print-client'
244
+ *
245
+ * const client = new EdgePrintClient()
246
+ * await client.connect({ token: 'abc123' })
247
+ * ```
248
+ */
249
+ export class EdgePrintClient {
250
+ private ws: WebSocket | null = null
251
+ private pending = new Map<string, Pending>()
252
+ private authenticated = false
253
+ private closeListeners: Array<() => void> = []
254
+
255
+ /**
256
+ * Open a WebSocket connection to the Edge Printing agent and authenticate.
257
+ *
258
+ * On failure the client retries up to `options.retries` times (default 3),
259
+ * waiting `options.retryDelay` ms (default 1 000) between attempts. If all
260
+ * attempts fail the last error is re-thrown.
261
+ *
262
+ * @throws {Error} If the agent is unreachable or the token is rejected after
263
+ * all retries are exhausted.
264
+ *
265
+ * @example
266
+ * ```ts
267
+ * await ep.connect({
268
+ * host: '127.0.0.1',
269
+ * port: 8181,
270
+ * token: 'abc123',
271
+ * retries: 5,
272
+ * retryDelay: 2000,
273
+ * })
274
+ * ```
275
+ */
276
+ async connect(options: ConnectOptions): Promise<void> {
277
+ const {
278
+ host = '127.0.0.1',
279
+ port = 8181,
280
+ token,
281
+ retries = 3,
282
+ retryDelay = 1000,
283
+ } = options
284
+
285
+ for (let attempt = 0; attempt <= retries; attempt++) {
286
+ try {
287
+ await this.openSocket(`wss://${host}:${port}`)
288
+ await this.request('auth', { token })
289
+ this.authenticated = true
290
+ return
291
+ } catch (err) {
292
+ if (attempt === retries) throw err
293
+ await sleep(retryDelay)
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Return all printers available on the agent machine.
300
+ *
301
+ * @throws {Error} If not connected.
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * const printers = await ep.printers()
306
+ * const colorPrinters = printers.filter(p => p.color)
307
+ * ```
308
+ */
309
+ async printers(): Promise<PrinterInfo[]> {
310
+ const resp = await this.request('get_printers', {}) as { printers: PrinterInfo[] }
311
+ return resp.printers
312
+ }
313
+
314
+ /**
315
+ * Return the name of the OS default printer.
316
+ *
317
+ * Cheaper than calling {@link printers} when you only need the default name
318
+ * and no other printer metadata.
319
+ *
320
+ * @throws {Error} If not connected.
321
+ */
322
+ async defaultPrinter(): Promise<string> {
323
+ const resp = await this.request('get_default_printer', {}) as { name: string }
324
+ return resp.name
325
+ }
326
+
327
+ /**
328
+ * Submit a print job to the agent.
329
+ *
330
+ * @param config - Printer selection and job settings.
331
+ * @param data - One or more content items to print (pages, labels, …).
332
+ * @returns The job ID assigned by the agent.
333
+ *
334
+ * @throws {Error} If not connected, or if the agent rejects the job.
335
+ *
336
+ * @example Print a PDF
337
+ * ```ts
338
+ * const jobId = await ep.print(
339
+ * { printer: 'Office Laser', copies: 2, duplex: 'long-edge' },
340
+ * [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: pdfBase64 }],
341
+ * )
342
+ * ```
343
+ *
344
+ * @example Print a ZPL label
345
+ * ```ts
346
+ * await ep.print(
347
+ * { printer: 'Zebra ZT410' },
348
+ * [{ type: 'raw', format: 'command', flavor: 'plain', data: zplString }],
349
+ * )
350
+ * ```
351
+ */
352
+ async print(config: PrintConfig, data: PrintData[]): Promise<string> {
353
+ const resp = await this.request('print', { config, data }) as { job_id: string }
354
+ return resp.job_id
355
+ }
356
+
357
+ /**
358
+ * Close the WebSocket connection and reset client state.
359
+ *
360
+ * Any in-flight requests are rejected. Safe to call when already
361
+ * disconnected.
362
+ */
363
+ disconnect(): void {
364
+ this.ws?.close()
365
+ this.ws = null
366
+ this.authenticated = false
367
+ }
368
+
369
+ /**
370
+ * `true` when the WebSocket is open and the session is authenticated.
371
+ *
372
+ * Use this to guard print calls in components that may render before the
373
+ * connection is established.
374
+ */
375
+ isConnected(): boolean {
376
+ return this.ws?.readyState === WebSocket.OPEN && this.authenticated
377
+ }
378
+
379
+ /**
380
+ * Register a callback invoked whenever the connection closes — whether from
381
+ * a network drop, an agent restart, or an explicit {@link disconnect} call.
382
+ *
383
+ * Multiple listeners can be registered; all are called in registration order.
384
+ *
385
+ * @example
386
+ * ```ts
387
+ * ep.onClose(() => {
388
+ * console.warn('Lost connection to Edge Printing agent — reconnecting…')
389
+ * reconnect()
390
+ * })
391
+ * ```
392
+ */
393
+ onClose(fn: () => void): void {
394
+ this.closeListeners.push(fn)
395
+ }
396
+
397
+ // ── internals ────────────────────────────────────────────────────────────
398
+
399
+ private openSocket(url: string): Promise<void> {
400
+ return new Promise((resolve, reject) => {
401
+ const ws = new WebSocket(url)
402
+ ws.onopen = () => { this.ws = ws; resolve() }
403
+ ws.onerror = () => reject(new Error(`Cannot reach Edge Printing agent at ${url}`))
404
+ ws.onmessage = (ev) => this.handleMessage(String(ev.data))
405
+ ws.onclose = () => {
406
+ this.ws = null
407
+ this.authenticated = false
408
+ this.rejectPending(new Error('Connection closed'))
409
+ this.closeListeners.forEach(fn => fn())
410
+ }
411
+ })
412
+ }
413
+
414
+ private handleMessage(raw: string): void {
415
+ let msg: Record<string, unknown>
416
+ try { msg = JSON.parse(raw) } catch { return }
417
+
418
+ const id = msg['id'] as string | undefined
419
+ if (!id || !this.pending.has(id)) return
420
+
421
+ const { resolve, reject } = this.pending.get(id)!
422
+ this.pending.delete(id)
423
+
424
+ if (msg['type'] === 'error') {
425
+ reject(new Error((msg['message'] as string) ?? 'Unknown error'))
426
+ } else {
427
+ resolve(msg)
428
+ }
429
+ }
430
+
431
+ private request(type: string, payload: Record<string, unknown>): Promise<unknown> {
432
+ return new Promise((resolve, reject) => {
433
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
434
+ reject(new Error('Not connected to Edge Printing agent'))
435
+ return
436
+ }
437
+ const id = crypto.randomUUID()
438
+ this.pending.set(id, { resolve, reject })
439
+
440
+ const timeout = setTimeout(() => {
441
+ if (this.pending.has(id)) {
442
+ this.pending.delete(id)
443
+ reject(new Error(`Request "${type}" timed out`))
444
+ }
445
+ }, 30_000)
446
+
447
+ this.ws.send(JSON.stringify({ type, id, ...payload }))
448
+
449
+ // Wrap resolve/reject to also clear the timeout.
450
+ const original = this.pending.get(id)!
451
+ this.pending.set(id, {
452
+ resolve: (v) => { clearTimeout(timeout); original.resolve(v) },
453
+ reject: (e) => { clearTimeout(timeout); original.reject(e) },
454
+ })
455
+ })
456
+ }
457
+
458
+ private rejectPending(err: Error): void {
459
+ for (const { reject } of this.pending.values()) reject(err)
460
+ this.pending.clear()
461
+ }
462
+ }
463
+
464
+ function sleep(ms: number): Promise<void> {
465
+ return new Promise(resolve => setTimeout(resolve, ms))
466
+ }
467
+
468
+ /**
469
+ * Shared singleton `EdgePrintClient` instance.
470
+ *
471
+ * Suitable for most single-page applications. Call {@link EdgePrintClient.connect}
472
+ * once at app startup, then use `ep` from any module without passing the
473
+ * client around.
474
+ *
475
+ * @example
476
+ * ```ts
477
+ * import ep from '@gohcltech/edge-print-client'
478
+ *
479
+ * await ep.connect({ token: 'abc123' })
480
+ * await ep.print({ printer: 'Office Laser' }, [pdfData])
481
+ * ```
482
+ */
483
+ export const ep = new EdgePrintClient()
484
+ export default ep
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "declarationDir": "./dist",
8
+ "outDir": "./dist",
9
+ "strict": true,
10
+ "lib": ["ES2020", "DOM"]
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }