@gohcltech/edge-print-client 1.0.29-develop → 2.0.40-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/src/edge-print.ts CHANGED
@@ -212,6 +212,14 @@ export interface ConnectOptions {
212
212
  * any other requests are accepted.
213
213
  */
214
214
  token: string
215
+ /**
216
+ * Name shown for this application in the agent's Clients tab and in the
217
+ * approval prompt the user sees when connecting with an unapproved token.
218
+ *
219
+ * Strongly recommended: without it the user is asked to trust an
220
+ * "Unknown client", which is not much of a decision.
221
+ */
222
+ clientName?: string
215
223
  /**
216
224
  * Maximum number of additional connection attempts after the first failure.
217
225
  * @default 3
@@ -249,6 +257,21 @@ type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void }
249
257
  export class EdgePrintClient {
250
258
  private ws: WebSocket | null = null
251
259
  private pending = new Map<string, Pending>()
260
+ /** Rejects the connect currently waiting on `onopen`, if there is one. */
261
+ private pendingOpen: ((e: Error) => void) | null = null
262
+ /** Bumped per connect, so a superseded one can tell it is no longer current. */
263
+ private connectGeneration = 0
264
+ /**
265
+ * Whether a connection currently exists from the application's point of view:
266
+ * a socket that opened *and* authenticated, and has not since gone away.
267
+ *
268
+ * Distinct from `authenticated`, which tracks one socket. This tracks whether
269
+ * there is anything for `onClose` to report the loss of, and is the single
270
+ * rule every teardown path consults — the alternative was each path deciding
271
+ * for itself, which is how the same event came to be announced in one place
272
+ * and swallowed in another.
273
+ */
274
+ private established = false
252
275
  private authenticated = false
253
276
  private closeListeners: Array<() => void> = []
254
277
 
@@ -259,6 +282,13 @@ export class EdgePrintClient {
259
282
  * waiting `options.retryDelay` ms (default 1 000) between attempts. If all
260
283
  * attempts fail the last error is re-thrown.
261
284
  *
285
+ * Calling this on an already-connected client **replaces** the connection:
286
+ * the existing socket is torn down before the new one is attempted, and its
287
+ * in-flight requests reject. If the new attempt then fails there is no
288
+ * connection left, and close listeners are notified. Guard a
289
+ * connect-on-demand helper with {@link EdgePrintClient.isConnected} rather
290
+ * than reconnecting unconditionally.
291
+ *
262
292
  * @throws {Error} If the agent is unreachable or the token is rejected after
263
293
  * all retries are exhausted.
264
294
  *
@@ -278,18 +308,51 @@ export class EdgePrintClient {
278
308
  host = '127.0.0.1',
279
309
  port = 8181,
280
310
  token,
311
+ clientName,
281
312
  retries = 3,
282
313
  retryDelay = 1000,
283
314
  } = options
284
315
 
285
- for (let attempt = 0; attempt <= retries; attempt++) {
316
+ // A negative count would skip the loop altogether and resolve without ever
317
+ // opening a socket, leaving the caller believing it is connected while
318
+ // every later call rejects with "Not connected".
319
+ const attempts = Math.max(0, retries)
320
+
321
+ const generation = ++this.connectGeneration
322
+
323
+ for (let attempt = 0; attempt <= attempts; attempt++) {
324
+ // Checked before every attempt, not only after a failed one. A connect
325
+ // sleeping between retries would otherwise wake and open a socket —
326
+ // discarding whatever connection was established while it slept, and
327
+ // reconnecting after an explicit disconnect.
328
+ if (this.connectGeneration !== generation) {
329
+ throw new Error('Connection superseded')
330
+ }
331
+
286
332
  try {
287
333
  await this.openSocket(`wss://${host}:${port}`)
288
- await this.request('auth', { token })
334
+ // Omitted entirely when unset, rather than sent as undefined/null —
335
+ // the agent treats a missing key as "no name given".
336
+ await this.request('auth', clientName ? { token, clientName } : { token })
289
337
  this.authenticated = true
338
+ this.established = true
290
339
  return
291
340
  } catch (err) {
292
- if (attempt === retries) throw err
341
+ // A newer connect has taken over since this attempt began. Its socket
342
+ // is not this attempt's to tear down.
343
+ if (this.connectGeneration !== generation) throw err
344
+
345
+ this.discardSocket(new Error('Connection closed'))
346
+
347
+ // Every failure retries, including a refused token. The agent answers a
348
+ // token still awaiting approval with the same "Invalid token" it gives
349
+ // a bad one, so the client cannot tell them apart — and retrying while
350
+ // someone clicks Approve is the documented flow. Repeat attempts only
351
+ // bump a counter on the pending entry; the notification fires once.
352
+ if (attempt === attempts) {
353
+ this.markClosed()
354
+ throw err
355
+ }
293
356
  await sleep(retryDelay)
294
357
  }
295
358
  }
@@ -331,7 +394,28 @@ export class EdgePrintClient {
331
394
  * @param data - One or more content items to print (pages, labels, …).
332
395
  * @returns The job ID assigned by the agent.
333
396
  *
334
- * @throws {Error} If not connected, or if the agent rejects the job.
397
+ * @throws {Error} If not connected, if the agent rejects the job, or if the
398
+ * job fails on the way to the spooler.
399
+ *
400
+ * A resolved promise means the job was handed to the OS print spooler — not
401
+ * that paper came out. A printer that is offline, jammed or out of paper
402
+ * after the spooler accepts the job still resolves.
403
+ *
404
+ * A rejection is also not proof that nothing printed: a request that exceeds
405
+ * the client's 30 s timeout rejects while the agent may still be spooling it.
406
+ * Do not resubmit a print automatically on rejection.
407
+ *
408
+ * When the agent had already created a job before it failed, the thrown error
409
+ * carries a `jobId` property matching the entry in the agent's job history —
410
+ * useful when surfacing a failure someone has to chase:
411
+ *
412
+ * ```ts
413
+ * try {
414
+ * await ep.print(config, data)
415
+ * } catch (err) {
416
+ * const jobId = (err as Error & { jobId?: string }).jobId
417
+ * }
418
+ * ```
335
419
  *
336
420
  * @example Print a PDF
337
421
  * ```ts
@@ -361,9 +445,17 @@ export class EdgePrintClient {
361
445
  * disconnected.
362
446
  */
363
447
  disconnect(): void {
364
- this.ws?.close()
365
- this.ws = null
366
- this.authenticated = false
448
+ // Routed through the same teardown as a replacement: nulling the reference
449
+ // alone leaves the socket's handlers attached, and its close then arrives
450
+ // later and tears down whatever connection has taken its place.
451
+ // Invalidates any connect still running, so a retry cannot wake up after
452
+ // this and quietly reconnect.
453
+ this.connectGeneration++
454
+
455
+ this.discardSocket(new Error('Disconnected'))
456
+ // Detaching the handlers means the socket's own close never arrives, so
457
+ // without this the loss would go unannounced.
458
+ this.markClosed()
367
459
  }
368
460
 
369
461
  /**
@@ -382,31 +474,113 @@ export class EdgePrintClient {
382
474
  *
383
475
  * Multiple listeners can be registered; all are called in registration order.
384
476
  *
477
+ * @returns A function that removes this listener. Registering inside a
478
+ * reconnect path without unsubscribing stacks a duplicate listener on every
479
+ * attempt, so hold onto this if the caller can register more than once.
480
+ *
385
481
  * @example
386
482
  * ```ts
387
- * ep.onClose(() => {
483
+ * const stop = ep.onClose(() => {
388
484
  * console.warn('Lost connection to Edge Printing agent — reconnecting…')
389
485
  * reconnect()
390
486
  * })
487
+ *
488
+ * // later, e.g. when the component unmounts
489
+ * stop()
391
490
  * ```
392
491
  */
393
- onClose(fn: () => void): void {
492
+ onClose(fn: () => void): () => void {
394
493
  this.closeListeners.push(fn)
494
+ return () => {
495
+ const i = this.closeListeners.indexOf(fn)
496
+ if (i !== -1) this.closeListeners.splice(i, 1)
497
+ }
395
498
  }
396
499
 
397
500
  // ── internals ────────────────────────────────────────────────────────────
398
501
 
502
+ /**
503
+ * Announces the loss of an established connection, exactly once.
504
+ *
505
+ * A socket that never authenticated was never a connection, so its going
506
+ * away is not something `onClose` reports — announcing it would have an
507
+ * onClose-driven reconnect racing the retry loop already running.
508
+ */
509
+ private markClosed(): void {
510
+ if (!this.established) return
511
+ this.established = false
512
+ // Copied: a listener may remove itself via the function onClose returns,
513
+ // and splicing the live array mid-iteration skips the next one.
514
+ ;[...this.closeListeners].forEach(fn => fn())
515
+ }
516
+
517
+ /**
518
+ * Tears down the current socket so it cannot reach this client again.
519
+ *
520
+ * Handlers are detached before closing, because the close event arrives in a
521
+ * later task — by which time the socket may have been replaced, and its
522
+ * `onclose` would otherwise report the *replacement* as disconnected.
523
+ *
524
+ * Detaching means the teardown that handler would have done has to happen
525
+ * here instead: clearing `authenticated`, settling in-flight requests, and
526
+ * settling a connect still waiting on `onopen`. That last one is easy to
527
+ * miss — a connect whose handlers are removed before either fires has
528
+ * nothing left to settle it, and `request()`'s timeout does not cover it, so
529
+ * it would wait forever.
530
+ *
531
+ * Close listeners are not fired from here. Whether a caller hears about a
532
+ * lost connection is `markClosed`'s decision, because only it knows whether
533
+ * there was an established connection to lose.
534
+ */
535
+ private discardSocket(reason: Error): void {
536
+ const ws = this.ws
537
+ const settleOpen = this.pendingOpen
538
+ this.ws = null
539
+ this.pendingOpen = null
540
+ this.authenticated = false
541
+
542
+ if (ws) {
543
+ ws.onopen = null
544
+ ws.onmessage = null
545
+ ws.onclose = null
546
+ ws.onerror = null
547
+ try { ws.close() } catch { /* already closing or closed */ }
548
+ }
549
+
550
+ settleOpen?.(reason)
551
+ this.rejectPending(reason)
552
+ }
553
+
399
554
  private openSocket(url: string): Promise<void> {
400
555
  return new Promise((resolve, reject) => {
556
+ // Never hold two sockets. This covers a retry, a reconnect on a live
557
+ // client, and a second connect racing the first.
558
+ this.discardSocket(new Error('Connection superseded'))
559
+
401
560
  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}`))
561
+ this.ws = ws
562
+ this.pendingOpen = reject
563
+
564
+ const settled = () => { this.pendingOpen = null }
565
+ ws.onopen = () => { settled(); resolve() }
566
+ ws.onerror = () => {
567
+ settled()
568
+ reject(new Error(`Cannot reach Edge Printing agent at ${url}`))
569
+ }
404
570
  ws.onmessage = (ev) => this.handleMessage(String(ev.data))
405
571
  ws.onclose = () => {
572
+ // A socket can close during the handshake, before either onopen or
573
+ // onerror fires. Clearing the pending open without settling it would
574
+ // leave this connect waiting forever — request()'s timeout does not
575
+ // cover the open.
576
+ const openWaiting = this.pendingOpen
577
+ settled()
578
+ openWaiting?.(new Error(`Cannot reach Edge Printing agent at ${url}`))
579
+
406
580
  this.ws = null
407
581
  this.authenticated = false
408
582
  this.rejectPending(new Error('Connection closed'))
409
- this.closeListeners.forEach(fn => fn())
583
+ this.markClosed()
410
584
  }
411
585
  })
412
586
  }
@@ -421,8 +595,26 @@ export class EdgePrintClient {
421
595
  const { resolve, reject } = this.pending.get(id)!
422
596
  this.pending.delete(id)
423
597
 
424
- if (msg['type'] === 'error') {
425
- reject(new Error((msg['message'] as string) ?? 'Unknown error'))
598
+ // `error` means the request never became a job. `print_error` means a job
599
+ // was created and then failed — it carries a real jobId, which is why
600
+ // matching only on `error` let failed prints resolve as successes.
601
+ // Matched by suffix so error types added later reject by default rather
602
+ // than silently resolving.
603
+ const type = String(msg['type'] ?? '')
604
+ if (type === 'error' || type.endsWith('_error')) {
605
+ const failure = new Error((msg['message'] as string) ?? 'Unknown error')
606
+
607
+ // `print_error` carries the id of the job the agent created and then
608
+ // failed, which is the handle a caller needs to find it in the agent's
609
+ // job history. Attached rather than given an exported error type: the
610
+ // typed client error that formalises this arrives later, and a second
611
+ // error shape now would only have to be reconciled with it.
612
+ const jobId = msg['jobId']
613
+ if (typeof jobId === 'string' && jobId.length > 0) {
614
+ Object.assign(failure, { jobId })
615
+ }
616
+
617
+ reject(failure)
426
618
  } else {
427
619
  resolve(msg)
428
620
  }
@@ -435,23 +627,32 @@ export class EdgePrintClient {
435
627
  return
436
628
  }
437
629
  const id = crypto.randomUUID()
438
- this.pending.set(id, { resolve, reject })
439
630
 
440
631
  const timeout = setTimeout(() => {
441
- if (this.pending.has(id)) {
442
- this.pending.delete(id)
632
+ if (this.pending.delete(id)) {
443
633
  reject(new Error(`Request "${type}" timed out`))
444
634
  }
445
635
  }, 30_000)
446
636
 
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)!
637
+ // Registered once, already wrapped. The previous version inserted the
638
+ // raw handlers, sent, then replaced the entry with wrapped ones that
639
+ // closed over what it read back out of the map. A close in that window
640
+ // made rejectPending clear the map first, so the replacement re-inserted
641
+ // an entry whose captured handlers were undefined — poisoning the map for
642
+ // the next rejectPending, which then threw mid-loop and left every later
643
+ // request unsettled.
451
644
  this.pending.set(id, {
452
- resolve: (v) => { clearTimeout(timeout); original.resolve(v) },
453
- reject: (e) => { clearTimeout(timeout); original.reject(e) },
645
+ resolve: (v) => { clearTimeout(timeout); resolve(v) },
646
+ reject: (e) => { clearTimeout(timeout); reject(e) },
454
647
  })
648
+
649
+ try {
650
+ this.ws.send(JSON.stringify({ type, id, ...payload }))
651
+ } catch (err) {
652
+ clearTimeout(timeout)
653
+ this.pending.delete(id)
654
+ reject(err instanceof Error ? err : new Error(String(err)))
655
+ }
455
656
  })
456
657
  }
457
658
 
package/tsconfig.json CHANGED
@@ -9,5 +9,6 @@
9
9
  "strict": true,
10
10
  "lib": ["ES2020", "DOM"]
11
11
  },
12
- "include": ["src/**/*.ts"]
12
+ "include": ["src/**/*.ts"],
13
+ "exclude": ["src/**/*.test.ts"]
13
14
  }
@@ -0,0 +1,16 @@
1
+ // Typechecks the test suite, which the build config deliberately excludes so
2
+ // test files never reach dist/. `vitest run` strips types without checking
3
+ // them, so without this pass a signature change would neither fail the build
4
+ // nor fail the tests.
5
+ {
6
+ "extends": "./tsconfig.json",
7
+ "compilerOptions": {
8
+ "noEmit": true,
9
+ // Skips vitest's and vite's own .d.ts files, which expect Node types this
10
+ // browser-targeted package does not carry. Only declaration files are
11
+ // skipped — the test is still checked in full against edge-print.ts.
12
+ "skipLibCheck": true
13
+ },
14
+ "include": ["src/**/*.ts"],
15
+ "exclude": []
16
+ }