@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/README.md CHANGED
@@ -56,11 +56,12 @@ Opens a WebSocket connection to the agent and authenticates. Retries automatical
56
56
 
57
57
  ```ts
58
58
  await ep.connect({
59
- host: '127.0.0.1', // default
60
- port: 8181, // default
61
- token: 'abc123', // required — from the agent settings window
62
- retries: 3, // defaultadditional attempts after the first failure
63
- retryDelay: 1000, // default — ms between retries
59
+ host: '127.0.0.1', // default
60
+ port: 8181, // default
61
+ token: 'abc123', // required — from the agent settings window
62
+ clientName: 'Warehouse', // recommendedshown in the agent's approval prompt
63
+ retries: 3, // default — additional attempts after the first failure
64
+ retryDelay: 1000, // default — ms between retries
64
65
  })
65
66
  ```
66
67
 
@@ -69,11 +70,22 @@ await ep.connect({
69
70
  | `host` | `string` | `'127.0.0.1'` | Hostname or IP of the agent machine |
70
71
  | `port` | `number` | `8181` | Port the agent listens on |
71
72
  | `token` | `string` | _(required)_ | API token from the Edge Printing settings window |
73
+ | `clientName` | `string?` | _(none)_ | Name shown in the agent's Clients tab and approval prompt |
72
74
  | `retries` | `number` | `3` | Max additional connection attempts after the first failure |
73
75
  | `retryDelay` | `number` | `1000` | Ms to wait between retry attempts |
74
76
 
77
+ Pass `clientName` unless you have a reason not to. Connecting with an unapproved
78
+ token prompts the user to approve the application by name; without it they are
79
+ asked to trust an "Unknown client".
80
+
75
81
  Throws if the agent is unreachable or the token is rejected after all retries.
76
82
 
83
+ Calling `connect()` on an already-connected client **replaces** the connection —
84
+ the existing socket is torn down before the new one is attempted, and its
85
+ in-flight requests reject. If the new attempt then fails there is no connection
86
+ left. Guard a connect-on-demand helper with `isConnected()` rather than
87
+ reconnecting unconditionally.
88
+
77
89
  ---
78
90
 
79
91
  ### `printers()`
@@ -132,6 +144,29 @@ Submits a print job. Returns the job ID assigned by the agent.
132
144
  const jobId = await ep.print(config, data)
133
145
  ```
134
146
 
147
+ Rejects if the job is refused, and also if it fails on the way to the spooler —
148
+ where it previously resolved with a job id regardless. Always handle the
149
+ rejection:
150
+
151
+ ```ts
152
+ try {
153
+ const jobId = await ep.print(config, data)
154
+ } catch (err) {
155
+ // printer not found, decode failure, spooler error …
156
+ }
157
+ ```
158
+
159
+ Two limits worth knowing before you build on this:
160
+
161
+ - **Resolved means spooled, not printed.** The agent resolves once the OS print
162
+ spooler accepts the job. A printer that is offline, jammed, or out of paper
163
+ after that point still resolves, so keep whatever confirmation step your
164
+ workflow needs.
165
+ - **Rejected does not prove nothing printed.** Requests time out after 30
166
+ seconds, and a large PDF can take longer than that to spool — the rejection
167
+ arrives while the agent is still working. Never resubmit a print
168
+ automatically on rejection; surface it and let a person decide.
169
+
135
170
  **`config`** — `PrintConfig`
136
171
 
137
172
  | Field | Type | Description |
@@ -207,13 +242,22 @@ if (!ep.isConnected()) {
207
242
 
208
243
  Registers a callback that fires whenever the connection closes — network drop, agent restart, or an explicit `disconnect()` call. Multiple listeners are supported.
209
244
 
245
+ Returns a function that removes the listener:
246
+
210
247
  ```ts
211
- ep.onClose(() => {
248
+ const stop = ep.onClose(() => {
212
249
  console.warn('Connection lost — reconnecting…')
213
250
  reconnect()
214
251
  })
252
+
253
+ // later, e.g. when the component unmounts
254
+ stop()
215
255
  ```
216
256
 
257
+ Register once for the life of the client where you can. Registering inside a
258
+ reconnect path without unsubscribing stacks a duplicate listener on every
259
+ attempt, and they all fire on the next close.
260
+
217
261
  ---
218
262
 
219
263
  ## Using a custom instance
@@ -209,6 +209,14 @@ export interface ConnectOptions {
209
209
  * any other requests are accepted.
210
210
  */
211
211
  token: string;
212
+ /**
213
+ * Name shown for this application in the agent's Clients tab and in the
214
+ * approval prompt the user sees when connecting with an unapproved token.
215
+ *
216
+ * Strongly recommended: without it the user is asked to trust an
217
+ * "Unknown client", which is not much of a decision.
218
+ */
219
+ clientName?: string;
212
220
  /**
213
221
  * Maximum number of additional connection attempts after the first failure.
214
222
  * @default 3
@@ -243,6 +251,21 @@ export interface ConnectOptions {
243
251
  export declare class EdgePrintClient {
244
252
  private ws;
245
253
  private pending;
254
+ /** Rejects the connect currently waiting on `onopen`, if there is one. */
255
+ private pendingOpen;
256
+ /** Bumped per connect, so a superseded one can tell it is no longer current. */
257
+ private connectGeneration;
258
+ /**
259
+ * Whether a connection currently exists from the application's point of view:
260
+ * a socket that opened *and* authenticated, and has not since gone away.
261
+ *
262
+ * Distinct from `authenticated`, which tracks one socket. This tracks whether
263
+ * there is anything for `onClose` to report the loss of, and is the single
264
+ * rule every teardown path consults — the alternative was each path deciding
265
+ * for itself, which is how the same event came to be announced in one place
266
+ * and swallowed in another.
267
+ */
268
+ private established;
246
269
  private authenticated;
247
270
  private closeListeners;
248
271
  /**
@@ -252,6 +275,13 @@ export declare class EdgePrintClient {
252
275
  * waiting `options.retryDelay` ms (default 1 000) between attempts. If all
253
276
  * attempts fail the last error is re-thrown.
254
277
  *
278
+ * Calling this on an already-connected client **replaces** the connection:
279
+ * the existing socket is torn down before the new one is attempted, and its
280
+ * in-flight requests reject. If the new attempt then fails there is no
281
+ * connection left, and close listeners are notified. Guard a
282
+ * connect-on-demand helper with {@link EdgePrintClient.isConnected} rather
283
+ * than reconnecting unconditionally.
284
+ *
255
285
  * @throws {Error} If the agent is unreachable or the token is rejected after
256
286
  * all retries are exhausted.
257
287
  *
@@ -295,7 +325,28 @@ export declare class EdgePrintClient {
295
325
  * @param data - One or more content items to print (pages, labels, …).
296
326
  * @returns The job ID assigned by the agent.
297
327
  *
298
- * @throws {Error} If not connected, or if the agent rejects the job.
328
+ * @throws {Error} If not connected, if the agent rejects the job, or if the
329
+ * job fails on the way to the spooler.
330
+ *
331
+ * A resolved promise means the job was handed to the OS print spooler — not
332
+ * that paper came out. A printer that is offline, jammed or out of paper
333
+ * after the spooler accepts the job still resolves.
334
+ *
335
+ * A rejection is also not proof that nothing printed: a request that exceeds
336
+ * the client's 30 s timeout rejects while the agent may still be spooling it.
337
+ * Do not resubmit a print automatically on rejection.
338
+ *
339
+ * When the agent had already created a job before it failed, the thrown error
340
+ * carries a `jobId` property matching the entry in the agent's job history —
341
+ * useful when surfacing a failure someone has to chase:
342
+ *
343
+ * ```ts
344
+ * try {
345
+ * await ep.print(config, data)
346
+ * } catch (err) {
347
+ * const jobId = (err as Error & { jobId?: string }).jobId
348
+ * }
349
+ * ```
299
350
  *
300
351
  * @example Print a PDF
301
352
  * ```ts
@@ -334,15 +385,49 @@ export declare class EdgePrintClient {
334
385
  *
335
386
  * Multiple listeners can be registered; all are called in registration order.
336
387
  *
388
+ * @returns A function that removes this listener. Registering inside a
389
+ * reconnect path without unsubscribing stacks a duplicate listener on every
390
+ * attempt, so hold onto this if the caller can register more than once.
391
+ *
337
392
  * @example
338
393
  * ```ts
339
- * ep.onClose(() => {
394
+ * const stop = ep.onClose(() => {
340
395
  * console.warn('Lost connection to Edge Printing agent — reconnecting…')
341
396
  * reconnect()
342
397
  * })
398
+ *
399
+ * // later, e.g. when the component unmounts
400
+ * stop()
343
401
  * ```
344
402
  */
345
- onClose(fn: () => void): void;
403
+ onClose(fn: () => void): () => void;
404
+ /**
405
+ * Announces the loss of an established connection, exactly once.
406
+ *
407
+ * A socket that never authenticated was never a connection, so its going
408
+ * away is not something `onClose` reports — announcing it would have an
409
+ * onClose-driven reconnect racing the retry loop already running.
410
+ */
411
+ private markClosed;
412
+ /**
413
+ * Tears down the current socket so it cannot reach this client again.
414
+ *
415
+ * Handlers are detached before closing, because the close event arrives in a
416
+ * later task — by which time the socket may have been replaced, and its
417
+ * `onclose` would otherwise report the *replacement* as disconnected.
418
+ *
419
+ * Detaching means the teardown that handler would have done has to happen
420
+ * here instead: clearing `authenticated`, settling in-flight requests, and
421
+ * settling a connect still waiting on `onopen`. That last one is easy to
422
+ * miss — a connect whose handlers are removed before either fires has
423
+ * nothing left to settle it, and `request()`'s timeout does not cover it, so
424
+ * it would wait forever.
425
+ *
426
+ * Close listeners are not fired from here. Whether a caller hears about a
427
+ * lost connection is `markClosed`'s decision, because only it knows whether
428
+ * there was an established connection to lose.
429
+ */
430
+ private discardSocket;
346
431
  private openSocket;
347
432
  private handleMessage;
348
433
  private request;
@@ -40,6 +40,21 @@ export class EdgePrintClient {
40
40
  constructor() {
41
41
  this.ws = null;
42
42
  this.pending = new Map();
43
+ /** Rejects the connect currently waiting on `onopen`, if there is one. */
44
+ this.pendingOpen = null;
45
+ /** Bumped per connect, so a superseded one can tell it is no longer current. */
46
+ this.connectGeneration = 0;
47
+ /**
48
+ * Whether a connection currently exists from the application's point of view:
49
+ * a socket that opened *and* authenticated, and has not since gone away.
50
+ *
51
+ * Distinct from `authenticated`, which tracks one socket. This tracks whether
52
+ * there is anything for `onClose` to report the loss of, and is the single
53
+ * rule every teardown path consults — the alternative was each path deciding
54
+ * for itself, which is how the same event came to be announced in one place
55
+ * and swallowed in another.
56
+ */
57
+ this.established = false;
43
58
  this.authenticated = false;
44
59
  this.closeListeners = [];
45
60
  }
@@ -50,6 +65,13 @@ export class EdgePrintClient {
50
65
  * waiting `options.retryDelay` ms (default 1 000) between attempts. If all
51
66
  * attempts fail the last error is re-thrown.
52
67
  *
68
+ * Calling this on an already-connected client **replaces** the connection:
69
+ * the existing socket is torn down before the new one is attempted, and its
70
+ * in-flight requests reject. If the new attempt then fails there is no
71
+ * connection left, and close listeners are notified. Guard a
72
+ * connect-on-demand helper with {@link EdgePrintClient.isConnected} rather
73
+ * than reconnecting unconditionally.
74
+ *
53
75
  * @throws {Error} If the agent is unreachable or the token is rejected after
54
76
  * all retries are exhausted.
55
77
  *
@@ -65,17 +87,44 @@ export class EdgePrintClient {
65
87
  * ```
66
88
  */
67
89
  async connect(options) {
68
- const { host = '127.0.0.1', port = 8181, token, retries = 3, retryDelay = 1000, } = options;
69
- for (let attempt = 0; attempt <= retries; attempt++) {
90
+ const { host = '127.0.0.1', port = 8181, token, clientName, retries = 3, retryDelay = 1000, } = options;
91
+ // A negative count would skip the loop altogether and resolve without ever
92
+ // opening a socket, leaving the caller believing it is connected while
93
+ // every later call rejects with "Not connected".
94
+ const attempts = Math.max(0, retries);
95
+ const generation = ++this.connectGeneration;
96
+ for (let attempt = 0; attempt <= attempts; attempt++) {
97
+ // Checked before every attempt, not only after a failed one. A connect
98
+ // sleeping between retries would otherwise wake and open a socket —
99
+ // discarding whatever connection was established while it slept, and
100
+ // reconnecting after an explicit disconnect.
101
+ if (this.connectGeneration !== generation) {
102
+ throw new Error('Connection superseded');
103
+ }
70
104
  try {
71
105
  await this.openSocket(`wss://${host}:${port}`);
72
- await this.request('auth', { token });
106
+ // Omitted entirely when unset, rather than sent as undefined/null —
107
+ // the agent treats a missing key as "no name given".
108
+ await this.request('auth', clientName ? { token, clientName } : { token });
73
109
  this.authenticated = true;
110
+ this.established = true;
74
111
  return;
75
112
  }
76
113
  catch (err) {
77
- if (attempt === retries)
114
+ // A newer connect has taken over since this attempt began. Its socket
115
+ // is not this attempt's to tear down.
116
+ if (this.connectGeneration !== generation)
117
+ throw err;
118
+ this.discardSocket(new Error('Connection closed'));
119
+ // Every failure retries, including a refused token. The agent answers a
120
+ // token still awaiting approval with the same "Invalid token" it gives
121
+ // a bad one, so the client cannot tell them apart — and retrying while
122
+ // someone clicks Approve is the documented flow. Repeat attempts only
123
+ // bump a counter on the pending entry; the notification fires once.
124
+ if (attempt === attempts) {
125
+ this.markClosed();
78
126
  throw err;
127
+ }
79
128
  await sleep(retryDelay);
80
129
  }
81
130
  }
@@ -114,7 +163,28 @@ export class EdgePrintClient {
114
163
  * @param data - One or more content items to print (pages, labels, …).
115
164
  * @returns The job ID assigned by the agent.
116
165
  *
117
- * @throws {Error} If not connected, or if the agent rejects the job.
166
+ * @throws {Error} If not connected, if the agent rejects the job, or if the
167
+ * job fails on the way to the spooler.
168
+ *
169
+ * A resolved promise means the job was handed to the OS print spooler — not
170
+ * that paper came out. A printer that is offline, jammed or out of paper
171
+ * after the spooler accepts the job still resolves.
172
+ *
173
+ * A rejection is also not proof that nothing printed: a request that exceeds
174
+ * the client's 30 s timeout rejects while the agent may still be spooling it.
175
+ * Do not resubmit a print automatically on rejection.
176
+ *
177
+ * When the agent had already created a job before it failed, the thrown error
178
+ * carries a `jobId` property matching the entry in the agent's job history —
179
+ * useful when surfacing a failure someone has to chase:
180
+ *
181
+ * ```ts
182
+ * try {
183
+ * await ep.print(config, data)
184
+ * } catch (err) {
185
+ * const jobId = (err as Error & { jobId?: string }).jobId
186
+ * }
187
+ * ```
118
188
  *
119
189
  * @example Print a PDF
120
190
  * ```ts
@@ -143,9 +213,16 @@ export class EdgePrintClient {
143
213
  * disconnected.
144
214
  */
145
215
  disconnect() {
146
- this.ws?.close();
147
- this.ws = null;
148
- this.authenticated = false;
216
+ // Routed through the same teardown as a replacement: nulling the reference
217
+ // alone leaves the socket's handlers attached, and its close then arrives
218
+ // later and tears down whatever connection has taken its place.
219
+ // Invalidates any connect still running, so a retry cannot wake up after
220
+ // this and quietly reconnect.
221
+ this.connectGeneration++;
222
+ this.discardSocket(new Error('Disconnected'));
223
+ // Detaching the handlers means the socket's own close never arrives, so
224
+ // without this the loss would go unannounced.
225
+ this.markClosed();
149
226
  }
150
227
  /**
151
228
  * `true` when the WebSocket is open and the session is authenticated.
@@ -162,29 +239,107 @@ export class EdgePrintClient {
162
239
  *
163
240
  * Multiple listeners can be registered; all are called in registration order.
164
241
  *
242
+ * @returns A function that removes this listener. Registering inside a
243
+ * reconnect path without unsubscribing stacks a duplicate listener on every
244
+ * attempt, so hold onto this if the caller can register more than once.
245
+ *
165
246
  * @example
166
247
  * ```ts
167
- * ep.onClose(() => {
248
+ * const stop = ep.onClose(() => {
168
249
  * console.warn('Lost connection to Edge Printing agent — reconnecting…')
169
250
  * reconnect()
170
251
  * })
252
+ *
253
+ * // later, e.g. when the component unmounts
254
+ * stop()
171
255
  * ```
172
256
  */
173
257
  onClose(fn) {
174
258
  this.closeListeners.push(fn);
259
+ return () => {
260
+ const i = this.closeListeners.indexOf(fn);
261
+ if (i !== -1)
262
+ this.closeListeners.splice(i, 1);
263
+ };
175
264
  }
176
265
  // ── internals ────────────────────────────────────────────────────────────
266
+ /**
267
+ * Announces the loss of an established connection, exactly once.
268
+ *
269
+ * A socket that never authenticated was never a connection, so its going
270
+ * away is not something `onClose` reports — announcing it would have an
271
+ * onClose-driven reconnect racing the retry loop already running.
272
+ */
273
+ markClosed() {
274
+ if (!this.established)
275
+ return;
276
+ this.established = false;
277
+ [...this.closeListeners].forEach(fn => fn());
278
+ }
279
+ /**
280
+ * Tears down the current socket so it cannot reach this client again.
281
+ *
282
+ * Handlers are detached before closing, because the close event arrives in a
283
+ * later task — by which time the socket may have been replaced, and its
284
+ * `onclose` would otherwise report the *replacement* as disconnected.
285
+ *
286
+ * Detaching means the teardown that handler would have done has to happen
287
+ * here instead: clearing `authenticated`, settling in-flight requests, and
288
+ * settling a connect still waiting on `onopen`. That last one is easy to
289
+ * miss — a connect whose handlers are removed before either fires has
290
+ * nothing left to settle it, and `request()`'s timeout does not cover it, so
291
+ * it would wait forever.
292
+ *
293
+ * Close listeners are not fired from here. Whether a caller hears about a
294
+ * lost connection is `markClosed`'s decision, because only it knows whether
295
+ * there was an established connection to lose.
296
+ */
297
+ discardSocket(reason) {
298
+ const ws = this.ws;
299
+ const settleOpen = this.pendingOpen;
300
+ this.ws = null;
301
+ this.pendingOpen = null;
302
+ this.authenticated = false;
303
+ if (ws) {
304
+ ws.onopen = null;
305
+ ws.onmessage = null;
306
+ ws.onclose = null;
307
+ ws.onerror = null;
308
+ try {
309
+ ws.close();
310
+ }
311
+ catch { /* already closing or closed */ }
312
+ }
313
+ settleOpen?.(reason);
314
+ this.rejectPending(reason);
315
+ }
177
316
  openSocket(url) {
178
317
  return new Promise((resolve, reject) => {
318
+ // Never hold two sockets. This covers a retry, a reconnect on a live
319
+ // client, and a second connect racing the first.
320
+ this.discardSocket(new Error('Connection superseded'));
179
321
  const ws = new WebSocket(url);
180
- ws.onopen = () => { this.ws = ws; resolve(); };
181
- ws.onerror = () => reject(new Error(`Cannot reach Edge Printing agent at ${url}`));
322
+ this.ws = ws;
323
+ this.pendingOpen = reject;
324
+ const settled = () => { this.pendingOpen = null; };
325
+ ws.onopen = () => { settled(); resolve(); };
326
+ ws.onerror = () => {
327
+ settled();
328
+ reject(new Error(`Cannot reach Edge Printing agent at ${url}`));
329
+ };
182
330
  ws.onmessage = (ev) => this.handleMessage(String(ev.data));
183
331
  ws.onclose = () => {
332
+ // A socket can close during the handshake, before either onopen or
333
+ // onerror fires. Clearing the pending open without settling it would
334
+ // leave this connect waiting forever — request()'s timeout does not
335
+ // cover the open.
336
+ const openWaiting = this.pendingOpen;
337
+ settled();
338
+ openWaiting?.(new Error(`Cannot reach Edge Printing agent at ${url}`));
184
339
  this.ws = null;
185
340
  this.authenticated = false;
186
341
  this.rejectPending(new Error('Connection closed'));
187
- this.closeListeners.forEach(fn => fn());
342
+ this.markClosed();
188
343
  };
189
344
  });
190
345
  }
@@ -201,8 +356,24 @@ export class EdgePrintClient {
201
356
  return;
202
357
  const { resolve, reject } = this.pending.get(id);
203
358
  this.pending.delete(id);
204
- if (msg['type'] === 'error') {
205
- reject(new Error(msg['message'] ?? 'Unknown error'));
359
+ // `error` means the request never became a job. `print_error` means a job
360
+ // was created and then failed — it carries a real jobId, which is why
361
+ // matching only on `error` let failed prints resolve as successes.
362
+ // Matched by suffix so error types added later reject by default rather
363
+ // than silently resolving.
364
+ const type = String(msg['type'] ?? '');
365
+ if (type === 'error' || type.endsWith('_error')) {
366
+ const failure = new Error(msg['message'] ?? 'Unknown error');
367
+ // `print_error` carries the id of the job the agent created and then
368
+ // failed, which is the handle a caller needs to find it in the agent's
369
+ // job history. Attached rather than given an exported error type: the
370
+ // typed client error that formalises this arrives later, and a second
371
+ // error shape now would only have to be reconciled with it.
372
+ const jobId = msg['jobId'];
373
+ if (typeof jobId === 'string' && jobId.length > 0) {
374
+ Object.assign(failure, { jobId });
375
+ }
376
+ reject(failure);
206
377
  }
207
378
  else {
208
379
  resolve(msg);
@@ -215,20 +386,30 @@ export class EdgePrintClient {
215
386
  return;
216
387
  }
217
388
  const id = crypto.randomUUID();
218
- this.pending.set(id, { resolve, reject });
219
389
  const timeout = setTimeout(() => {
220
- if (this.pending.has(id)) {
221
- this.pending.delete(id);
390
+ if (this.pending.delete(id)) {
222
391
  reject(new Error(`Request "${type}" timed out`));
223
392
  }
224
393
  }, 30000);
225
- this.ws.send(JSON.stringify({ type, id, ...payload }));
226
- // Wrap resolve/reject to also clear the timeout.
227
- const original = this.pending.get(id);
394
+ // Registered once, already wrapped. The previous version inserted the
395
+ // raw handlers, sent, then replaced the entry with wrapped ones that
396
+ // closed over what it read back out of the map. A close in that window
397
+ // made rejectPending clear the map first, so the replacement re-inserted
398
+ // an entry whose captured handlers were undefined — poisoning the map for
399
+ // the next rejectPending, which then threw mid-loop and left every later
400
+ // request unsettled.
228
401
  this.pending.set(id, {
229
- resolve: (v) => { clearTimeout(timeout); original.resolve(v); },
230
- reject: (e) => { clearTimeout(timeout); original.reject(e); },
402
+ resolve: (v) => { clearTimeout(timeout); resolve(v); },
403
+ reject: (e) => { clearTimeout(timeout); reject(e); },
231
404
  });
405
+ try {
406
+ this.ws.send(JSON.stringify({ type, id, ...payload }));
407
+ }
408
+ catch (err) {
409
+ clearTimeout(timeout);
410
+ this.pending.delete(id);
411
+ reject(err instanceof Error ? err : new Error(String(err)));
412
+ }
232
413
  });
233
414
  }
234
415
  rejectPending(err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gohcltech/edge-print-client",
3
- "version": "1.0.29-develop",
3
+ "version": "2.0.40-develop",
4
4
  "description": "Browser client for the Edge Printing WebSocket agent",
5
5
  "type": "module",
6
6
  "main": "./dist/edge-print.cjs",
@@ -14,9 +14,11 @@
14
14
  }
15
15
  },
16
16
  "scripts": {
17
- "build": "tsc"
17
+ "build": "tsc",
18
+ "test": "tsc -p tsconfig.test.json && vitest run"
18
19
  },
19
20
  "devDependencies": {
20
- "typescript": "^5.5.0"
21
+ "typescript": "^5.5.0",
22
+ "vitest": "^3.0.0"
21
23
  }
22
24
  }