@gohcltech/edge-print-client 2.0.57-develop → 2.0.70-develop

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.
package/README.md CHANGED
@@ -36,11 +36,12 @@ const printers = await ep.printers()
36
36
  console.log(printers.map(p => p.name))
37
37
 
38
38
  // 3. Print a PDF (base64-encoded)
39
- const jobId = await ep.print(
39
+ const { jobId, warnings } = await ep.print(
40
40
  { printer: 'Office Laser', copies: 1 },
41
41
  [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: pdfBase64 }],
42
42
  )
43
43
  console.log('Queued as', jobId)
44
+ for (const w of warnings) console.warn(w.message)
44
45
 
45
46
  // 4. Disconnect when done
46
47
  ep.disconnect()
@@ -118,10 +119,22 @@ Each `PrinterInfo` object includes:
118
119
  | `duplex` | `boolean?` | `true` if the printer supports double-sided printing |
119
120
  | `trays` | `TrayInfo[]?` | Input trays/bins (see below) |
120
121
  | `papers` | `string[]?` | All supported paper sizes (e.g. `["Letter", "A4"]`) |
122
+ | `mediaTypes` | `string[]?` | Stock types the driver reports (e.g. `["Plain", "Bond"]`). Absent for most printers — see below |
121
123
  | `copiesMax` | `number?` | Maximum copies the driver accepts in one job |
122
124
  | `orientations` | `string[]?` | Supported orientations (e.g. `["portrait", "landscape"]`) |
123
125
  | `virtualPrinter` | `boolean?` | `true` for Developer (virtual) printers injected by the agent |
124
126
 
127
+ **On `mediaTypes`.** The field is usually absent. Of eleven drivers surveyed,
128
+ eight listed no stock types at all, and a ninth listed them only under a
129
+ vendor-private key whose values are the driver's internal codes rather than
130
+ names you could choose from — the agent reports nothing in that case too. When
131
+ the field is absent the agent accepts whatever stock you ask for rather than
132
+ guessing. When it *is*
133
+ present the strings are the driver's own and are not always self-describing, so
134
+ read them off the target machine (the agent's Printers tab lists them) rather
135
+ than assuming a vocabulary. Passing a value the printer does not list rejects
136
+ with `unsupported_value`, and the error's `supported` array names the real ones.
137
+
125
138
  1.x agents sent `is_default`, `share_name`, `copies_max` and `virtual_printer`.
126
139
  Those spellings are still accepted on read and mapped onto the camelCase fields
127
140
  above, so nothing downstream sees the old names. **Removed in 3.0.**
@@ -152,24 +165,53 @@ const name = await ep.defaultPrinter() // "Office Laser"
152
165
 
153
166
  ### `print(config, data)`
154
167
 
155
- Submits a print job. Returns the job ID assigned by the agent.
168
+ Submits a print job. Resolves to a `PrintResult`.
156
169
 
157
170
  ```ts
158
- const jobId = await ep.print(config, data)
171
+ const { jobId, warnings } = await ep.print(config, data)
159
172
  ```
160
173
 
174
+ | Field | Type | Description |
175
+ |---|---|---|
176
+ | `jobId` | `string` | The agent's id for the job, matching its entry in the job history |
177
+ | `warnings` | `JobWarning[]` | Caveats the agent recorded about a job it printed anyway. Empty in the common case |
178
+
179
+ Each `JobWarning` is `{ code, message }`. Today the only code is
180
+ `capability_unverified` — the agent could not determine a capability, so it
181
+ skipped the check and passed the request through. Treat the set as open-ended:
182
+ log or surface `message` rather than branching on every code.
183
+
184
+ > **Breaking change.** `print()` used to resolve to the job id string. It now
185
+ > resolves to an object. Destructure it (`const { jobId } = await ep.print(…)`),
186
+ > or the value you pass on will be `[object Object]`. TypeScript callers get a
187
+ > compile error; JavaScript callers do not.
188
+
161
189
  Rejects if the job is refused, and also if it fails on the way to the spooler —
162
190
  where it previously resolved with a job id regardless. Always handle the
163
- rejection:
191
+ rejection, and branch on `err.code` rather than the message text:
164
192
 
165
193
  ```ts
194
+ import { EdgePrintError } from '@gohcltech/edge-print-client'
195
+
166
196
  try {
167
- const jobId = await ep.print(config, data)
197
+ const { jobId } = await ep.print(config, data)
168
198
  } catch (err) {
169
- // printer not found, decode failure, spooler error
199
+ if (err instanceof EdgePrintError && err.code === 'unsupported_capability') {
200
+ // err.field names the setting the printer cannot do — 'colorType', 'duplex'
201
+ }
170
202
  }
171
203
  ```
172
204
 
205
+ A job rejected by validation still gets a record in the agent's History, so an
206
+ `EdgePrintError` from `print()` usually carries a `jobId` you can quote to
207
+ support. A request refused before a job existed — one the agent could not parse
208
+ — has no `jobId`.
209
+
210
+ A rejection never carries warnings, even where the agent had already recorded
211
+ some before the rule that failed. They are dropped deliberately: a note saying
212
+ "we could not verify duplex" alongside a rejection for an unrelated reason is
213
+ noise, not help.
214
+
173
215
  Two limits worth knowing before you build on this:
174
216
 
175
217
  - **Resolved means spooled, not printed.** The agent resolves once the OS print
@@ -193,6 +235,7 @@ Two limits worth knowing before you build on this:
193
235
  | `colorType` | `'color' \| 'grayscale' \| 'black-white'?` | Color mode |
194
236
  | `paperSize` | `string?` | Paper size from `PrinterInfo.papers` (e.g. `"Letter"`) |
195
237
  | `tray` | `string?` | Tray name from `TrayInfo.name` (e.g. `"Tray 1"`) |
238
+ | `mediaType` | `string?` | Stock type from `PrinterInfo.mediaTypes` (e.g. `"Bond"`). Rejected for `raw`/`html` content and network targets |
196
239
 
197
240
  **`data`** — `PrintData[]`
198
241
 
@@ -309,6 +352,96 @@ attempt, and they all fire on the next close.
309
352
 
310
353
  ---
311
354
 
355
+ ## Errors
356
+
357
+ Every rejection from this library is an `EdgePrintError` — including the ones
358
+ raised locally, before a request ever reaches the agent. It extends `Error`, so
359
+ `err.message` and `String(err)` read exactly as they did before codes existed.
360
+
361
+ ```ts
362
+ import ep, { EdgePrintError } from '@gohcltech/edge-print-client'
363
+
364
+ try {
365
+ await ep.print(config, data)
366
+ } catch (err) {
367
+ if (!(err instanceof EdgePrintError)) throw err
368
+
369
+ if (err.retryable) {
370
+ // transport trouble — the same request may well work on a second attempt
371
+ } else if (err.field) {
372
+ console.error(`${err.field}: ${err.message}`)
373
+ } else {
374
+ console.error(err.message)
375
+ }
376
+ }
377
+ ```
378
+
379
+ | Property | Type | Description |
380
+ |---|---|---|
381
+ | `code` | `EdgePrintErrorCode` | The failure, from a closed set. `'unknown'` when the agent sent no code, or one this client predates |
382
+ | `message` | `string` | Human-readable. Do not match on it — that is what `code` is for |
383
+ | `field` | `string?` | The offending setting, when one setting is to blame |
384
+ | `supported` | `string[]?` | What the printer accepts, for the codes that can say |
385
+ | `requestId` | `string?` | The request this answers. Absent for failures raised before one existed |
386
+ | `jobId` | `string?` | Present when the agent created a job and then failed it |
387
+ | `retryable` | `boolean` | True only for transport failures — see below |
388
+
389
+ ### Codes
390
+
391
+ Sent by the agent:
392
+
393
+ | Code | Raised when |
394
+ |---|---|
395
+ | `not_authenticated` | A request was sent before `connect()` finished authenticating |
396
+ | `invalid_token` | The token is unknown, or still awaiting approval in the agent UI |
397
+ | `invalid_request` | The agent could not parse the request |
398
+ | `unsupported_capability` | A capability the printer explicitly reports it lacks |
399
+ | `invalid_geometry` | A scale or margin outside what the protocol accepts |
400
+ | `unsupported_for_path` | A setting that cannot be honoured on the requested path |
401
+ | `printer_not_found` | `defaultPrinter()` found no default configured on the machine |
402
+ | `print_failed` | The backend failed after the job was accepted and recorded |
403
+ | `internal_error` | The agent itself failed — not the caller's doing |
404
+
405
+ Raised locally by this library, never sent by the agent:
406
+
407
+ | Code | Raised when |
408
+ |---|---|
409
+ | `not_connected` | A call was made while disconnected |
410
+ | `unreachable` | The WebSocket could not be opened — agent not running, or wrong host/port |
411
+ | `protocol_mismatch` | The agent and this library speak different wire protocol versions |
412
+ | `connection_closed` | The connection dropped with a request in flight |
413
+ | `timeout` | The agent accepted the request and did not answer within 30 seconds |
414
+
415
+ `invalid_request` is raised by **both** sides: by the agent for a request it
416
+ cannot parse, and here for a `config` or `data` value that cannot be serialised
417
+ to JSON at all — a circular reference, say. Neither is retryable.
418
+
419
+ The four codes in the table above are never accepted *from* the agent. A frame
420
+ claiming `code: "timeout"` is read as `unknown`, so a failure the agent
421
+ considers final cannot arrive labelled retryable.
422
+
423
+ Three more are **reserved**: `unsupported_value`, `content_overflow` and
424
+ `encrypted_pdf` are part of the closed set and typed here, so code written
425
+ today handles them, but no agent sends them yet. `supported` is populated only
426
+ by `unsupported_value`, so it is always `undefined` against an agent of this
427
+ generation.
428
+
429
+ And `unknown`, the fallback for a code this client does not recognise — which
430
+ is what lets a client built today keep working against a future agent. Treat an
431
+ unfamiliar code as a plain failure rather than a bug.
432
+
433
+ ### `retryable`
434
+
435
+ `err.retryable` is true for `unreachable`, `connection_closed`, and `timeout`,
436
+ and false for everything else. A validation rejection is deterministic: retrying
437
+ it sends the same bad request again.
438
+
439
+ Note that `retryable` says the *request* may succeed — not that nothing
440
+ happened. See the `timeout` entry under Troubleshooting before you retry a
441
+ print.
442
+
443
+ ---
444
+
312
445
  ## Using a custom instance
313
446
 
314
447
  The default export `ep` is a module-level singleton. If you need to connect to multiple agents simultaneously, create separate instances:
@@ -374,16 +507,18 @@ To confirm the certificate is trusted, open `https://127.0.0.1:8181` in the
374
507
  browser. If the page loads (even with an empty body) the certificate is fine.
375
508
  If you see a security warning, the certificate is not yet trusted.
376
509
 
377
- ### `connect()` throws "Cannot reach Edge Printing agent"
510
+ ### `connect()` rejects with `code: 'unreachable'`
378
511
 
379
512
  - Confirm the Edge Printing agent is running (check the system tray / menu bar).
380
513
  - Confirm `host` and `port` match the agent's configured values (default `127.0.0.1:8181`).
381
514
 
382
- ### `connect()` throws after authentication
515
+ ### `connect()` rejects with `code: 'invalid_token'`
383
516
 
384
- The token was rejected. Generate a new one from the Edge Printing settings window.
517
+ The token was rejected, or it is new and still awaiting approval in the agent's
518
+ Clients tab. Approve it there, or generate a fresh one from the Edge Printing
519
+ settings window.
385
520
 
386
- ### `connect()` throws "Protocol version mismatch"
521
+ ### `connect()` rejects with `code: 'protocol_mismatch'`
387
522
 
388
523
  The agent and this library speak different wire protocol versions. The message
389
524
  names both and says which side to update:
@@ -399,10 +534,15 @@ A mismatch is deterministic, so `connect()`'s retries cannot clear it — it sti
399
534
  exhausts them (about 3 seconds by default) before throwing. Pass `retries: 0`
400
535
  when probing for a usable agent. One side has to be upgraded.
401
536
 
402
- ### Requests time out after 30 seconds
537
+ ### A request rejects with `code: 'timeout'`
538
+
539
+ The agent accepted the connection and did not answer within 30 seconds. Restart
540
+ the agent. If the problem persists, file an issue with the agent log attached.
403
541
 
404
- The agent accepted the connection but stopped responding. Restart the agent. If
405
- the problem persists, file an issue with the agent log attached.
542
+ For a `print()`, a timeout does **not** prove nothing printed a large PDF can
543
+ take longer than 30 seconds to spool, and the rejection arrives while the agent
544
+ is still working. Never resubmit a print automatically; surface it and let a
545
+ person decide.
406
546
 
407
547
  ---
408
548
 
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * await ep.connect({ token: 'your-api-token' })
12
12
  * const printers = await ep.printers()
13
- * const jobId = await ep.print(
13
+ * const { jobId, warnings } = await ep.print(
14
14
  * { printer: 'Office Laser' },
15
15
  * [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: pdfBase64 }],
16
16
  * )
@@ -66,6 +66,16 @@ export interface PrintConfig {
66
66
  * Omit to use the printer's current default.
67
67
  */
68
68
  tray?: string;
69
+ /**
70
+ * Media / stock type, exactly as returned by {@link PrinterInfo.mediaTypes}
71
+ * (e.g. `"Plain"`, `"Bond"`, `"Thermal"`). Changes how the printer feeds and
72
+ * fuses the page. Omit to use the printer's current default.
73
+ *
74
+ * Not supported for `raw` or `html` content, or a network target — those
75
+ * paths never reach the printer's driver. Requesting it there rejects with
76
+ * `unsupported_for_path`.
77
+ */
78
+ mediaType?: string;
69
79
  }
70
80
  /**
71
81
  * A single unit of content to be printed.
@@ -178,6 +188,18 @@ export interface PrinterInfo {
178
188
  * Pass one of these values to {@link PrintConfig.paperSize}.
179
189
  */
180
190
  papers?: string[];
191
+ /**
192
+ * Stock types the driver reports — card stock, labels, envelopes and so on.
193
+ * Pass one of these values to {@link PrintConfig.mediaType}.
194
+ *
195
+ * Absent for most printers — many drivers report no stock types, and some
196
+ * report them only under a vendor-private key whose values are internal
197
+ * codes rather than choosable names. The agent reports nothing in either
198
+ * case and then accepts whatever is asked rather than guessing. When the
199
+ * field *is* present, the strings are the driver's own and may not be
200
+ * self-describing.
201
+ */
202
+ mediaTypes?: string[];
181
203
  /** Maximum number of copies the driver accepts in a single job. */
182
204
  copiesMax?: number;
183
205
  /** Page orientations the driver supports (e.g. `["portrait", "landscape"]`). */
@@ -228,6 +250,65 @@ export interface ConnectOptions {
228
250
  */
229
251
  retryDelay?: number;
230
252
  }
253
+ declare const ERROR_CODES: readonly ["not_authenticated", "invalid_token", "protocol_mismatch", "invalid_request", "unsupported_value", "unsupported_capability", "invalid_geometry", "content_overflow", "unsupported_for_path", "encrypted_pdf", "printer_not_found", "print_failed", "internal_error", "not_connected", "unreachable", "connection_closed", "timeout", "unknown"];
254
+ export type EdgePrintErrorCode = typeof ERROR_CODES[number];
255
+ /** A warning the agent recorded about a job it nonetheless printed. */
256
+ export interface JobWarning {
257
+ /** e.g. `"capability_unverified"`. Open-ended: an agent may add codes. */
258
+ code: string;
259
+ message: string;
260
+ }
261
+ /** What {@link EdgePrintClient.print} resolves to. */
262
+ export interface PrintResult {
263
+ /** The agent's id for the job, matching its entry in the job history. */
264
+ jobId: string;
265
+ /** Empty when the agent had nothing to report, which is the common case. */
266
+ warnings: JobWarning[];
267
+ }
268
+ /**
269
+ * A failure from the Edge Printing agent or from this client.
270
+ *
271
+ * Extends `Error`, so `e.message` and `String(e)` keep working for code
272
+ * written before codes existed.
273
+ *
274
+ * **Do not set `name` on this class.** A class name does not set `.name` — it
275
+ * is inherited as `"Error"` — so `String(e)` stays `"Error: …"`. Setting it
276
+ * would change that string for every existing consumer, which is exactly what
277
+ * extending `Error` is here to avoid.
278
+ */
279
+ export declare class EdgePrintError extends Error {
280
+ /** `'unknown'` when the agent sent no code, or one this client predates. */
281
+ readonly code: EdgePrintErrorCode;
282
+ /** The offending setting, when one setting is to blame. */
283
+ readonly field?: string;
284
+ /** What the printer accepts, for the codes that can say. */
285
+ readonly supported?: string[];
286
+ /** The request this answers. Absent for failures raised before one existed. */
287
+ readonly requestId?: string;
288
+ /** Present when the agent created a job and then failed it. */
289
+ readonly jobId?: string;
290
+ constructor(code: EdgePrintErrorCode, message: string, detail?: {
291
+ field?: string;
292
+ supported?: string[];
293
+ requestId?: string;
294
+ jobId?: string;
295
+ });
296
+ /**
297
+ * Builds an error from an agent failure frame.
298
+ *
299
+ * Everything is read defensively: an older agent sends no `code` at all, and
300
+ * a newer one may send a code this client has never heard of. Both become
301
+ * `'unknown'` rather than reaching a caller as an invalid value.
302
+ */
303
+ static fromWire(msg: Record<string, unknown>): EdgePrintError;
304
+ /**
305
+ * Whether the same request might succeed on a retry.
306
+ *
307
+ * True only for transport failures. A validation rejection is deterministic:
308
+ * retrying it sends the same bad request again.
309
+ */
310
+ get retryable(): boolean;
311
+ }
231
312
  /**
232
313
  * WebSocket client for the Edge Printing agent.
233
314
  *
@@ -284,7 +365,7 @@ export declare class EdgePrintClient {
284
365
  * connect-on-demand helper with {@link EdgePrintClient.isConnected} rather
285
366
  * than reconnecting unconditionally.
286
367
  *
287
- * @throws {Error} If the agent is unreachable or the token is rejected after
368
+ * @throws {EdgePrintError} If the agent is unreachable or the token is rejected after
288
369
  * all retries are exhausted.
289
370
  *
290
371
  * @example
@@ -302,7 +383,7 @@ export declare class EdgePrintClient {
302
383
  /**
303
384
  * Return all printers available on the agent machine.
304
385
  *
305
- * @throws {Error} If not connected.
386
+ * @throws {EdgePrintError} If not connected.
306
387
  *
307
388
  * @example
308
389
  * ```ts
@@ -317,7 +398,7 @@ export declare class EdgePrintClient {
317
398
  * Cheaper than calling {@link printers} when you only need the default name
318
399
  * and no other printer metadata.
319
400
  *
320
- * @throws {Error} If not connected.
401
+ * @throws {EdgePrintError} If not connected.
321
402
  */
322
403
  defaultPrinter(): Promise<string>;
323
404
  /**
@@ -325,10 +406,13 @@ export declare class EdgePrintClient {
325
406
  *
326
407
  * @param config - Printer selection and job settings.
327
408
  * @param data - One or more content items to print (pages, labels, …).
328
- * @returns The job ID assigned by the agent.
409
+ * @returns A {@link PrintResult} the agent's `jobId`, and any `warnings`
410
+ * it recorded about a job it printed anyway. `warnings` is empty in the
411
+ * common case.
329
412
  *
330
- * @throws {Error} If not connected, if the agent rejects the job, or if the
331
- * job fails on the way to the spooler.
413
+ * @throws {EdgePrintError} If not connected, if the agent rejects the job, or
414
+ * if the job fails on the way to the spooler. Branch on `err.code`, never
415
+ * on the message text.
332
416
  *
333
417
  * A resolved promise means the job was handed to the OS print spooler — not
334
418
  * that paper came out. A printer that is offline, jammed or out of paper
@@ -338,24 +422,31 @@ export declare class EdgePrintClient {
338
422
  * the client's 30 s timeout rejects while the agent may still be spooling it.
339
423
  * Do not resubmit a print automatically on rejection.
340
424
  *
341
- * When the agent had already created a job before it failed, the thrown error
342
- * carries a `jobId` property matching the entry in the agent's job history —
343
- * useful when surfacing a failure someone has to chase:
425
+ * A job rejected by validation is still recorded, so the thrown error
426
+ * usually carries a `jobId` matching the entry in the agent's job history —
427
+ * useful when surfacing a failure someone has to chase. A request refused
428
+ * before a job existed at all, such as one the agent could not parse, has no
429
+ * `jobId` to carry.
430
+ *
431
+ * A rejection never carries warnings, even where the agent had accumulated
432
+ * some before the rule that failed: they are dropped deliberately rather
433
+ * than reported alongside an unrelated failure.
344
434
  *
345
435
  * ```ts
346
436
  * try {
347
437
  * await ep.print(config, data)
348
438
  * } catch (err) {
349
- * const jobId = (err as Error & { jobId?: string }).jobId
439
+ * if (err instanceof EdgePrintError) console.error(err.code, err.jobId)
350
440
  * }
351
441
  * ```
352
442
  *
353
443
  * @example Print a PDF
354
444
  * ```ts
355
- * const jobId = await ep.print(
445
+ * const { jobId, warnings } = await ep.print(
356
446
  * { printer: 'Office Laser', copies: 2, duplex: 'long-edge' },
357
447
  * [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: pdfBase64 }],
358
448
  * )
449
+ * for (const w of warnings) console.warn(w.message)
359
450
  * ```
360
451
  *
361
452
  * @example Print a ZPL label
@@ -366,7 +457,7 @@ export declare class EdgePrintClient {
366
457
  * )
367
458
  * ```
368
459
  */
369
- print(config: PrintConfig, data: PrintData[]): Promise<string>;
460
+ print(config: PrintConfig, data: PrintData[]): Promise<PrintResult>;
370
461
  /**
371
462
  * Close the WebSocket connection and reset client state.
372
463
  *
@@ -10,12 +10,108 @@
10
10
  *
11
11
  * await ep.connect({ token: 'your-api-token' })
12
12
  * const printers = await ep.printers()
13
- * const jobId = await ep.print(
13
+ * const { jobId, warnings } = await ep.print(
14
14
  * { printer: 'Office Laser' },
15
15
  * [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: pdfBase64 }],
16
16
  * )
17
17
  * ```
18
18
  */
19
+ /**
20
+ * Every failure code this client understands.
21
+ *
22
+ * Declared as a runtime array first, with the union derived from it — a
23
+ * hand-written union would be erased at compile time, leaving nothing to
24
+ * validate a wire value against, and the two would drift the first time the
25
+ * agent gained a code.
26
+ *
27
+ * The first group is the agent's closed set. The second is raised locally and
28
+ * never sent by the agent. `unknown` is the fallback for a code this client
29
+ * does not recognise, which is what lets a new client work against an old agent.
30
+ */
31
+ const AGENT_CODES = [
32
+ 'not_authenticated', 'invalid_token', 'protocol_mismatch', 'invalid_request',
33
+ 'unsupported_value', 'unsupported_capability', 'invalid_geometry',
34
+ 'content_overflow', 'unsupported_for_path', 'encrypted_pdf',
35
+ 'printer_not_found', 'print_failed', 'internal_error',
36
+ ];
37
+ /**
38
+ * Raised here, never accepted off the wire.
39
+ *
40
+ * Kept separate so `fromWire` can reject them. An agent — or anything between
41
+ * the client and it — sending `code: "timeout"` would otherwise be taken at
42
+ * face value, and `retryable` would report `true` for a failure the agent
43
+ * considers final. `invalid_request` is deliberately *not* in this list: both
44
+ * sides can raise it, and a request this client refuses to serialise is the
45
+ * same kind of failure as one the agent refuses to parse.
46
+ */
47
+ const CLIENT_ONLY_CODES = ['not_connected', 'unreachable', 'connection_closed', 'timeout'];
48
+ const ERROR_CODES = [...AGENT_CODES, ...CLIENT_ONLY_CODES, 'unknown'];
49
+ /** Codes where the same request may succeed if simply tried again. */
50
+ const RETRYABLE = ['unreachable', 'connection_closed', 'timeout'];
51
+ /**
52
+ * A failure from the Edge Printing agent or from this client.
53
+ *
54
+ * Extends `Error`, so `e.message` and `String(e)` keep working for code
55
+ * written before codes existed.
56
+ *
57
+ * **Do not set `name` on this class.** A class name does not set `.name` — it
58
+ * is inherited as `"Error"` — so `String(e)` stays `"Error: …"`. Setting it
59
+ * would change that string for every existing consumer, which is exactly what
60
+ * extending `Error` is here to avoid.
61
+ */
62
+ export class EdgePrintError extends Error {
63
+ constructor(code, message, detail = {}) {
64
+ super(message);
65
+ this.code = code;
66
+ this.field = detail.field;
67
+ this.supported = detail.supported;
68
+ this.requestId = detail.requestId;
69
+ this.jobId = detail.jobId;
70
+ }
71
+ /**
72
+ * Builds an error from an agent failure frame.
73
+ *
74
+ * Everything is read defensively: an older agent sends no `code` at all, and
75
+ * a newer one may send a code this client has never heard of. Both become
76
+ * `'unknown'` rather than reaching a caller as an invalid value.
77
+ */
78
+ static fromWire(msg) {
79
+ const raw = msg['code'];
80
+ // Checked against the agent's set, not the union: see CLIENT_ONLY_CODES.
81
+ const code = AGENT_CODES.includes(raw)
82
+ ? raw
83
+ : 'unknown';
84
+ const str = (v) => (typeof v === 'string' && v.length > 0 ? v : undefined);
85
+ const supported = Array.isArray(msg['supported'])
86
+ ? msg['supported'].filter((v) => typeof v === 'string')
87
+ : undefined;
88
+ return new EdgePrintError(code, str(msg['message']) ?? 'Unknown error', {
89
+ field: str(msg['field']),
90
+ supported,
91
+ requestId: str(msg['id']),
92
+ jobId: str(msg['jobId']),
93
+ });
94
+ }
95
+ /**
96
+ * Whether the same request might succeed on a retry.
97
+ *
98
+ * True only for transport failures. A validation rejection is deterministic:
99
+ * retrying it sends the same bad request again.
100
+ */
101
+ get retryable() {
102
+ return RETRYABLE.includes(this.code);
103
+ }
104
+ }
105
+ /**
106
+ * The message an `Error` carries, or the value itself stringified.
107
+ *
108
+ * `String(err)` on an `Error` yields `"TypeError: foo"`, not `"foo"`. Using it
109
+ * to build a replacement error would silently prefix every such message with a
110
+ * constructor name.
111
+ */
112
+ function messageOf(err) {
113
+ return err instanceof Error ? err.message : String(err);
114
+ }
19
115
  /**
20
116
  * The wire protocol this client speaks.
21
117
  *
@@ -121,7 +217,7 @@ export class EdgePrintClient {
121
217
  * connect-on-demand helper with {@link EdgePrintClient.isConnected} rather
122
218
  * than reconnecting unconditionally.
123
219
  *
124
- * @throws {Error} If the agent is unreachable or the token is rejected after
220
+ * @throws {EdgePrintError} If the agent is unreachable or the token is rejected after
125
221
  * all retries are exhausted.
126
222
  *
127
223
  * @example
@@ -148,7 +244,7 @@ export class EdgePrintClient {
148
244
  // discarding whatever connection was established while it slept, and
149
245
  // reconnecting after an explicit disconnect.
150
246
  if (this.connectGeneration !== generation) {
151
- throw new Error('Connection superseded');
247
+ throw new EdgePrintError('connection_closed', 'Connection superseded');
152
248
  }
153
249
  try {
154
250
  await this.openSocket(`wss://${host}:${port}`);
@@ -169,7 +265,7 @@ export class EdgePrintClient {
169
265
  ? ack['protocolVersion']
170
266
  : LEGACY_PROTOCOL_VERSION;
171
267
  if (agentProtocol !== PROTOCOL_VERSION) {
172
- throw new Error(`Protocol version mismatch: this client speaks protocol version ` +
268
+ throw new EdgePrintError('protocol_mismatch', `Protocol version mismatch: this client speaks protocol version ` +
173
269
  `${PROTOCOL_VERSION}, but the agent speaks ${agentProtocol}. ` +
174
270
  (agentProtocol < PROTOCOL_VERSION
175
271
  ? 'Update the Edge Printing agent.'
@@ -182,7 +278,7 @@ export class EdgePrintClient {
182
278
  // same check the loop makes before each attempt and in the catch, which
183
279
  // between them cover every path except this one.
184
280
  if (this.connectGeneration !== generation) {
185
- throw new Error('Connection superseded');
281
+ throw new EdgePrintError('connection_closed', 'Connection superseded');
186
282
  }
187
283
  // Assigned together so the getters and `isConnected()` agree: either
188
284
  // this attempt owns the connection and publishes all of it, or none.
@@ -199,7 +295,7 @@ export class EdgePrintClient {
199
295
  // is not this attempt's to tear down.
200
296
  if (this.connectGeneration !== generation)
201
297
  throw err;
202
- this.discardSocket(new Error('Connection closed'));
298
+ this.discardSocket(new EdgePrintError('connection_closed', 'Connection closed'));
203
299
  // Every failure retries, including a refused token. The agent answers a
204
300
  // token still awaiting approval with the same "Invalid token" it gives
205
301
  // a bad one, so the client cannot tell them apart — and retrying while
@@ -216,7 +312,7 @@ export class EdgePrintClient {
216
312
  /**
217
313
  * Return all printers available on the agent machine.
218
314
  *
219
- * @throws {Error} If not connected.
315
+ * @throws {EdgePrintError} If not connected.
220
316
  *
221
317
  * @example
222
318
  * ```ts
@@ -234,7 +330,7 @@ export class EdgePrintClient {
234
330
  * Cheaper than calling {@link printers} when you only need the default name
235
331
  * and no other printer metadata.
236
332
  *
237
- * @throws {Error} If not connected.
333
+ * @throws {EdgePrintError} If not connected.
238
334
  */
239
335
  async defaultPrinter() {
240
336
  const resp = await this.request('get_default_printer', {});
@@ -245,10 +341,13 @@ export class EdgePrintClient {
245
341
  *
246
342
  * @param config - Printer selection and job settings.
247
343
  * @param data - One or more content items to print (pages, labels, …).
248
- * @returns The job ID assigned by the agent.
344
+ * @returns A {@link PrintResult} the agent's `jobId`, and any `warnings`
345
+ * it recorded about a job it printed anyway. `warnings` is empty in the
346
+ * common case.
249
347
  *
250
- * @throws {Error} If not connected, if the agent rejects the job, or if the
251
- * job fails on the way to the spooler.
348
+ * @throws {EdgePrintError} If not connected, if the agent rejects the job, or
349
+ * if the job fails on the way to the spooler. Branch on `err.code`, never
350
+ * on the message text.
252
351
  *
253
352
  * A resolved promise means the job was handed to the OS print spooler — not
254
353
  * that paper came out. A printer that is offline, jammed or out of paper
@@ -258,24 +357,31 @@ export class EdgePrintClient {
258
357
  * the client's 30 s timeout rejects while the agent may still be spooling it.
259
358
  * Do not resubmit a print automatically on rejection.
260
359
  *
261
- * When the agent had already created a job before it failed, the thrown error
262
- * carries a `jobId` property matching the entry in the agent's job history —
263
- * useful when surfacing a failure someone has to chase:
360
+ * A job rejected by validation is still recorded, so the thrown error
361
+ * usually carries a `jobId` matching the entry in the agent's job history —
362
+ * useful when surfacing a failure someone has to chase. A request refused
363
+ * before a job existed at all, such as one the agent could not parse, has no
364
+ * `jobId` to carry.
365
+ *
366
+ * A rejection never carries warnings, even where the agent had accumulated
367
+ * some before the rule that failed: they are dropped deliberately rather
368
+ * than reported alongside an unrelated failure.
264
369
  *
265
370
  * ```ts
266
371
  * try {
267
372
  * await ep.print(config, data)
268
373
  * } catch (err) {
269
- * const jobId = (err as Error & { jobId?: string }).jobId
374
+ * if (err instanceof EdgePrintError) console.error(err.code, err.jobId)
270
375
  * }
271
376
  * ```
272
377
  *
273
378
  * @example Print a PDF
274
379
  * ```ts
275
- * const jobId = await ep.print(
380
+ * const { jobId, warnings } = await ep.print(
276
381
  * { printer: 'Office Laser', copies: 2, duplex: 'long-edge' },
277
382
  * [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: pdfBase64 }],
278
383
  * )
384
+ * for (const w of warnings) console.warn(w.message)
279
385
  * ```
280
386
  *
281
387
  * @example Print a ZPL label
@@ -288,7 +394,9 @@ export class EdgePrintClient {
288
394
  */
289
395
  async print(config, data) {
290
396
  const resp = await this.request('print', { config, data });
291
- return resp.jobId;
397
+ // Defaulted here rather than left optional: a caller destructuring
398
+ // `warnings` should get an array to iterate, not `undefined` to guard.
399
+ return { jobId: resp.jobId, warnings: resp.warnings ?? [] };
292
400
  }
293
401
  /**
294
402
  * Close the WebSocket connection and reset client state.
@@ -303,7 +411,7 @@ export class EdgePrintClient {
303
411
  // Invalidates any connect still running, so a retry cannot wake up after
304
412
  // this and quietly reconnect.
305
413
  this.connectGeneration++;
306
- this.discardSocket(new Error('Disconnected'));
414
+ this.discardSocket(new EdgePrintError('connection_closed', 'Disconnected'));
307
415
  // Detaching the handlers means the socket's own close never arrives, so
308
416
  // without this the loss would go unannounced.
309
417
  this.markClosed();
@@ -430,7 +538,7 @@ export class EdgePrintClient {
430
538
  return new Promise((resolve, reject) => {
431
539
  // Never hold two sockets. This covers a retry, a reconnect on a live
432
540
  // client, and a second connect racing the first.
433
- this.discardSocket(new Error('Connection superseded'));
541
+ this.discardSocket(new EdgePrintError('connection_closed', 'Connection superseded'));
434
542
  const ws = new WebSocket(url);
435
543
  this.ws = ws;
436
544
  this.pendingOpen = reject;
@@ -438,7 +546,7 @@ export class EdgePrintClient {
438
546
  ws.onopen = () => { settled(); resolve(); };
439
547
  ws.onerror = () => {
440
548
  settled();
441
- reject(new Error(`Cannot reach Edge Printing agent at ${url}`));
549
+ reject(new EdgePrintError('unreachable', `Cannot reach Edge Printing agent at ${url}`));
442
550
  };
443
551
  ws.onmessage = (ev) => this.handleMessage(String(ev.data));
444
552
  ws.onclose = () => {
@@ -448,10 +556,10 @@ export class EdgePrintClient {
448
556
  // cover the open.
449
557
  const openWaiting = this.pendingOpen;
450
558
  settled();
451
- openWaiting?.(new Error(`Cannot reach Edge Printing agent at ${url}`));
559
+ openWaiting?.(new EdgePrintError('unreachable', `Cannot reach Edge Printing agent at ${url}`));
452
560
  this.ws = null;
453
561
  this.clearSessionState();
454
- this.rejectPending(new Error('Connection closed'));
562
+ this.rejectPending(new EdgePrintError('connection_closed', 'Connection closed'));
455
563
  this.markClosed();
456
564
  };
457
565
  });
@@ -476,17 +584,11 @@ export class EdgePrintClient {
476
584
  // than silently resolving.
477
585
  const type = String(msg['type'] ?? '');
478
586
  if (type === 'error' || type.endsWith('_error')) {
479
- const failure = new Error(msg['message'] ?? 'Unknown error');
480
587
  // `print_error` carries the id of the job the agent created and then
481
588
  // failed, which is the handle a caller needs to find it in the agent's
482
- // job history. Attached rather than given an exported error type: the
483
- // typed client error that formalises this arrives later, and a second
484
- // error shape now would only have to be reconciled with it.
485
- const jobId = msg['jobId'];
486
- if (typeof jobId === 'string' && jobId.length > 0) {
487
- Object.assign(failure, { jobId });
488
- }
489
- reject(failure);
589
+ // job history. `fromWire` reads that alongside the code, field and
590
+ // supported values, so the whole failure arrives as one typed object.
591
+ reject(EdgePrintError.fromWire(msg));
490
592
  }
491
593
  else {
492
594
  resolve(msg);
@@ -495,13 +597,13 @@ export class EdgePrintClient {
495
597
  request(type, payload) {
496
598
  return new Promise((resolve, reject) => {
497
599
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
498
- reject(new Error('Not connected to Edge Printing agent'));
600
+ reject(new EdgePrintError('not_connected', 'Not connected to Edge Printing agent'));
499
601
  return;
500
602
  }
501
603
  const id = crypto.randomUUID();
502
604
  const timeout = setTimeout(() => {
503
605
  if (this.pending.delete(id)) {
504
- reject(new Error(`Request "${type}" timed out`));
606
+ reject(new EdgePrintError('timeout', `Request "${type}" timed out`, { requestId: id }));
505
607
  }
506
608
  }, 30000);
507
609
  // Registered once, already wrapped. The previous version inserted the
@@ -515,13 +617,32 @@ export class EdgePrintClient {
515
617
  resolve: (v) => { clearTimeout(timeout); resolve(v); },
516
618
  reject: (e) => { clearTimeout(timeout); reject(e); },
517
619
  });
620
+ // Serialised outside the try below: a payload that cannot be stringified
621
+ // — a circular reference, a BigInt — is a bad request, not a dead socket,
622
+ // and labelling it `connection_closed` would make `retryable` true for a
623
+ // failure that reproduces identically on every retry.
624
+ let frame;
625
+ try {
626
+ frame = JSON.stringify({ type, id, ...payload });
627
+ }
628
+ catch (err) {
629
+ clearTimeout(timeout);
630
+ this.pending.delete(id);
631
+ reject(new EdgePrintError('invalid_request', messageOf(err), { requestId: id }));
632
+ return;
633
+ }
518
634
  try {
519
- this.ws.send(JSON.stringify({ type, id, ...payload }));
635
+ this.ws.send(frame);
520
636
  }
521
637
  catch (err) {
522
638
  clearTimeout(timeout);
523
639
  this.pending.delete(id);
524
- reject(err instanceof Error ? err : new Error(String(err)));
640
+ // Coded like everything else this client raises, so a caller never has
641
+ // to ask whether `e.code` exists before reading it. `messageOf` rather
642
+ // than `String(err)`: the latter prepends the constructor name, so a
643
+ // caller's `e.message` would gain a "TypeError: " it never had before
644
+ // codes existed — the one thing extending `Error` is here to preserve.
645
+ reject(new EdgePrintError('connection_closed', messageOf(err), { requestId: id }));
525
646
  }
526
647
  });
527
648
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gohcltech/edge-print-client",
3
- "version": "2.0.57-develop",
3
+ "version": "2.0.70-develop",
4
4
  "description": "Browser client for the Edge Printing WebSocket agent",
5
5
  "license": "MIT",
6
6
  "repository": {