@gohcltech/edge-print-client 2.0.31-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/README.md +50 -6
- package/dist/edge-print.d.ts +88 -3
- package/dist/edge-print.js +203 -22
- package/package.json +5 -3
- package/src/edge-print.test.ts +680 -0
- package/src/edge-print.ts +224 -23
- package/tsconfig.json +2 -1
- package/tsconfig.test.json +16 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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.
|
|
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
|
-
|
|
425
|
-
|
|
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.
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
//
|
|
450
|
-
|
|
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);
|
|
453
|
-
reject: (e) => { clearTimeout(timeout);
|
|
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
|
@@ -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
|
+
}
|