@love-moon/app-sdk 0.3.2 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @love-moon/app-sdk
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 4ecc359: Publish the chat-web browser runtime and wire it into the CLI and AI SDK for
8
+ ChatGPT and Gemini web sessions, including provider error handling and local
9
+ development installation support.
10
+
11
+ Ship app SDK realtime history catch-up and the CLI/AI SDK goal-mode and custom
12
+ command runtime updates included in this release.
13
+
3
14
  ## 0.3.2
4
15
 
5
16
  ### Changed
@@ -26,10 +37,13 @@
26
37
  silent for minutes) can opt out by passing `idleTimeoutMs: 0`:
27
38
 
28
39
  ```ts
29
- for await (const delta of client.tasks.streamReply(taskId, { idleTimeoutMs: 0 })) {
40
+ for await (const delta of client.tasks.streamReply(taskId, {
41
+ idleTimeoutMs: 0,
42
+ })) {
30
43
  // never time out — caller is responsible for cancelling via signal
31
44
  }
32
45
  ```
46
+
33
47
  - **Locked down the public `.d.ts` surface.** Internal transport types —
34
48
  `Fetcher`, `FetcherOptions`, `RequestOptions`, `AppWebSocket`,
35
49
  `AppWebSocketOptions`, `TasksRestApi` — are now tagged
@@ -89,7 +103,7 @@ Initial implementation, RFC 0027 milestones M0–M3.
89
103
  subscribe; yields `text` deltas from `reply_preview` plus a terminal `done`
90
104
  on the assistant message (or `error` on `task_status_update=failed`).
91
105
  - Unified `ConductorAppError` with named error codes mapped from HTTP status
92
- + backend error strings.
106
+ - backend error strings.
93
107
  - Custom fetch / WebSocket / bearerToken providers for SSR + test injection.
94
108
 
95
109
  ### Added — `/react` (M2)
@@ -106,7 +120,7 @@ Initial implementation, RFC 0027 milestones M0–M3.
106
120
  - Optimistic send → server confirm flow with pending-message replacement.
107
121
  - Pre-compiled CSS at `@love-moon/app-sdk/react/styles.css`; CSS-variable
108
122
  theming via the `theme` prop; mobile/desktop layout via responsive CSS
109
- + an explicit `layout` prop override.
123
+ - an explicit `layout` prop override.
110
124
  - jsdom integration tests covering hydration, live events, runtime status,
111
125
  interrupt button visibility, and optimistic send.
112
126
 
@@ -43,6 +43,35 @@ interface SendMessageInput {
43
43
  role?: MessageRole;
44
44
  }
45
45
 
46
+ /**
47
+ * Custom content renderer. Receives the full `Message` so the renderer can
48
+ * branch on `role` / `metadata` / `attachments` if it wants to. Return any
49
+ * ReactNode; the SDK still wraps it in `<div class="conductor-bubble">` and
50
+ * applies role-based alignment / pending styling.
51
+ *
52
+ * Returning `null` (or `undefined`) renders an empty bubble — equivalent to
53
+ * a message with empty content. If you want to suppress the bubble entirely,
54
+ * filter the message upstream (e.g. via your own MessageList).
55
+ */
56
+ type RenderMessageContent = (message: Message) => ReactNode;
57
+ interface MessageListProps {
58
+ labels: ChatViewLabels;
59
+ /**
60
+ * Optional override for how each message's content is rendered inside its
61
+ * bubble. Defaults to plain text (`message.content`). Pass e.g. a
62
+ * `react-markdown` renderer here to enable markdown formatting:
63
+ *
64
+ * <MessageList
65
+ * labels={...}
66
+ * renderMessageContent={(m) => (
67
+ * <ReactMarkdown remarkPlugins={[remarkGfm]}>{m.content}</ReactMarkdown>
68
+ * )}
69
+ * />
70
+ */
71
+ renderMessageContent?: RenderMessageContent;
72
+ }
73
+ declare function MessageList({ labels, renderMessageContent }: MessageListProps): react_jsx_runtime.JSX.Element;
74
+
46
75
  /**
47
76
  * Runtime status pushed by the daemon while a task is in progress.
48
77
  * Mirrors `task_runtime_status` envelope on /ws/app.
@@ -169,6 +198,25 @@ interface ChatViewProps {
169
198
  * "via <self>" self-reference loop.
170
199
  */
171
200
  showAppOriginChip?: boolean;
201
+ /**
202
+ * Override how each message's content is rendered inside its bubble.
203
+ * Defaults to plain text. Common use: wire in a markdown renderer.
204
+ *
205
+ * import ReactMarkdown from 'react-markdown';
206
+ * import remarkGfm from 'remark-gfm';
207
+ *
208
+ * <ChatView
209
+ * taskId={taskId}
210
+ * adapter={adapter}
211
+ * renderMessageContent={(m) => (
212
+ * <ReactMarkdown remarkPlugins={[remarkGfm]}>{m.content}</ReactMarkdown>
213
+ * )}
214
+ * />
215
+ *
216
+ * The SDK still owns the bubble container (role-based alignment, pending
217
+ * state, data attributes). Only the content inside is replaced.
218
+ */
219
+ renderMessageContent?: RenderMessageContent;
172
220
  onError?: (error: unknown) => void;
173
221
  /** Extra className applied to the root container. */
174
222
  className?: string;
@@ -196,11 +244,6 @@ interface ChatViewTheme {
196
244
  }
197
245
  declare function ChatView(props: ChatViewProps): react_jsx_runtime.JSX.Element;
198
246
 
199
- interface MessageListProps {
200
- labels: ChatViewLabels;
201
- }
202
- declare function MessageList({ labels }: MessageListProps): react_jsx_runtime.JSX.Element;
203
-
204
247
  interface MessageInputProps {
205
248
  labels: ChatViewLabels;
206
249
  /** Disable the send button (useful while a turn is in progress). */
@@ -361,4 +404,4 @@ type ConductorErrorCode = 'unauthorized' | 'forbidden' | 'token_revoked' | 'inva
361
404
  /** Helper: type-guard for callers that don't want `instanceof`. */
362
405
  declare function isConductorAppError(error: unknown): error is ConductorAppError;
363
406
 
364
- export { type Attachment, type ChatAdapter, type ChatEvent, ChatProvider, type ChatState, ChatView, type ChatViewLabels, type ChatViewProps, type ChatViewTheme, ConductorAppError, type Message, MessageInput, MessageList, type MessageRole, type RestAdapterOptions, type RuntimeState, type RuntimeStatus, RuntimeStatusBar, type SendMessageInput, type Task, type TaskStatus, createRestAdapter, isConductorAppError, useChat };
407
+ export { type Attachment, type ChatAdapter, type ChatEvent, ChatProvider, type ChatState, ChatView, type ChatViewLabels, type ChatViewProps, type ChatViewTheme, ConductorAppError, type Message, MessageInput, MessageList, type MessageListProps, type MessageRole, type RenderMessageContent, type RestAdapterOptions, type RuntimeState, type RuntimeStatus, RuntimeStatusBar, type SendMessageInput, type Task, type TaskStatus, createRestAdapter, isConductorAppError, useChat };
@@ -152,6 +152,51 @@ function ChatProvider(props) {
152
152
  const activeAdapter = adapterRef.current;
153
153
  let cancelled = false;
154
154
  const abort = new AbortController();
155
+ let catchUpTimer = null;
156
+ let catchUpInFlight = false;
157
+ let needAnotherCatchUp = false;
158
+ let catchUpAbort = null;
159
+ let prevReplyInProgress;
160
+ let prevConnectionState;
161
+ const runCatchUp = async () => {
162
+ if (cancelled) return;
163
+ catchUpInFlight = true;
164
+ catchUpAbort = new AbortController();
165
+ try {
166
+ const page = await activeAdapter.fetchHistory(taskId, {
167
+ limit: 20,
168
+ signal: catchUpAbort.signal
169
+ });
170
+ if (cancelled) return;
171
+ for (const m of page.messages) {
172
+ if (!m.id) continue;
173
+ dispatch({ type: "APPEND_MESSAGE", message: m });
174
+ }
175
+ } catch (err) {
176
+ const name = err?.name;
177
+ const code = err?.code;
178
+ if (name === "AbortError" || code === "stream_aborted") return;
179
+ console.warn("[app-sdk] history catch-up failed", err);
180
+ } finally {
181
+ catchUpInFlight = false;
182
+ if (needAnotherCatchUp && !cancelled) {
183
+ needAnotherCatchUp = false;
184
+ void runCatchUp();
185
+ }
186
+ }
187
+ };
188
+ const scheduleCatchUp = (delayMs) => {
189
+ if (cancelled) return;
190
+ if (catchUpInFlight) {
191
+ needAnotherCatchUp = true;
192
+ return;
193
+ }
194
+ if (catchUpTimer) clearTimeout(catchUpTimer);
195
+ catchUpTimer = setTimeout(() => {
196
+ catchUpTimer = null;
197
+ void runCatchUp();
198
+ }, delayMs);
199
+ };
155
200
  dispatch({ type: "LOADING_HISTORY", loading: true });
156
201
  activeAdapter.fetchHistory(taskId, { signal: abort.signal }).then((page) => {
157
202
  if (cancelled) return;
@@ -180,11 +225,18 @@ function ChatProvider(props) {
180
225
  case "message_updated":
181
226
  dispatch({ type: "UPDATE_MESSAGE", message: event.message });
182
227
  break;
183
- case "runtime_status":
228
+ case "runtime_status": {
184
229
  dispatch({ type: "SET_RUNTIME", status: event.status });
230
+ const now = event.status.replyInProgress;
231
+ if (prevReplyInProgress === true && now === false) {
232
+ scheduleCatchUp(500);
233
+ }
234
+ prevReplyInProgress = now;
185
235
  break;
236
+ }
186
237
  case "task_finished":
187
238
  dispatch({ type: "TASK_FINISHED" });
239
+ scheduleCatchUp(500);
188
240
  break;
189
241
  case "task_failed": {
190
242
  const taskErr = {
@@ -194,17 +246,28 @@ function ChatProvider(props) {
194
246
  if (event.error.details !== void 0) taskErr.details = event.error.details;
195
247
  if (event.error.cause !== void 0) taskErr.cause = event.error.cause;
196
248
  dispatch({ type: "TASK_FAILED", error: taskErr });
249
+ scheduleCatchUp(500);
197
250
  break;
198
251
  }
199
- case "connection_state":
252
+ case "connection_state": {
200
253
  dispatch({ type: "SET_CONNECTION", state: event.state });
254
+ if (event.state === "connected" && prevConnectionState === "reconnecting") {
255
+ scheduleCatchUp(0);
256
+ }
257
+ prevConnectionState = event.state;
201
258
  break;
259
+ }
202
260
  }
203
261
  });
204
262
  return () => {
205
263
  cancelled = true;
206
264
  abort.abort();
207
265
  sub.unsubscribe();
266
+ if (catchUpTimer) {
267
+ clearTimeout(catchUpTimer);
268
+ catchUpTimer = null;
269
+ }
270
+ catchUpAbort?.abort();
208
271
  };
209
272
  }, [taskId]);
210
273
  const value = useMemo(() => {
@@ -299,7 +362,7 @@ function extractError(err) {
299
362
  // src/react/components/MessageList.tsx
300
363
  import { useEffect as useEffect2, useLayoutEffect, useRef as useRef2 } from "react";
301
364
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
302
- function MessageList({ labels }) {
365
+ function MessageList({ labels, renderMessageContent }) {
303
366
  const { state, loadEarlier } = useChat();
304
367
  const containerRef = useRef2(null);
305
368
  const lastMessageCountRef = useRef2(state.messages.length);
@@ -335,13 +398,14 @@ function MessageList({ labels }) {
335
398
  state.messages.map((m) => {
336
399
  const isUser = m.role === "user" || m.role === "sdk";
337
400
  const isPending = m.id.startsWith("pending:");
401
+ const content = renderMessageContent ? renderMessageContent(m) : m.content;
338
402
  return /* @__PURE__ */ jsx2(
339
403
  "div",
340
404
  {
341
405
  className: "conductor-message " + (isUser ? "conductor-message--user" : "conductor-message--assistant") + (isPending ? " conductor-message--pending" : ""),
342
406
  "data-role": m.role,
343
407
  "data-message-id": m.id,
344
- children: /* @__PURE__ */ jsx2("div", { className: "conductor-bubble", children: m.content })
408
+ children: /* @__PURE__ */ jsx2("div", { className: "conductor-bubble", children: content })
345
409
  },
346
410
  m.id
347
411
  );
@@ -487,7 +551,13 @@ function ChatView(props) {
487
551
  style: themeStyle,
488
552
  children: /* @__PURE__ */ jsxs4(ChatProvider, { taskId: props.taskId, adapter: props.adapter, onError: props.onError, children: [
489
553
  /* @__PURE__ */ jsx5(RuntimeStatusBar, { labels }),
490
- /* @__PURE__ */ jsx5(MessageList, { labels }),
554
+ /* @__PURE__ */ jsx5(
555
+ MessageList,
556
+ {
557
+ labels,
558
+ renderMessageContent: props.renderMessageContent
559
+ }
560
+ ),
491
561
  /* @__PURE__ */ jsx5(MessageInput, { labels, disabled: props.readOnly })
492
562
  ] })
493
563
  }
@@ -329,10 +329,28 @@ declare class TasksApi {
329
329
  *
330
330
  * The first call lazily opens a /ws/app connection; subsequent calls share
331
331
  * the same connection.
332
+ *
333
+ * History catch-up (enabled by default): when a terminal event arrives
334
+ * (`task_finished`, `task_failed`, `runtime_status` flipping
335
+ * `replyInProgress` true→false, or `connection_state` recovering from
336
+ * `reconnecting → connected`), the wrapper pulls a window of recent
337
+ * history via REST and injects any missing entries as synthetic
338
+ * `message_appended` events. This compensates for Conductor deployments
339
+ * where the realtime broadcast doesn't always reach the WS connection
340
+ * (multi-instance fan-out without a backplane, idempotent commit retries
341
+ * that bypass projection, momentary WS drops). Disable with
342
+ * `disableHistoryCatchUp: true` for callers that prefer the raw stream.
332
343
  */
333
344
  subscribe(taskId: string, opts?: {
334
345
  signal?: AbortSignal;
335
346
  bufferCap?: number;
347
+ /**
348
+ * Disable the post-terminal history catch-up. Default: catch-up
349
+ * enabled. Set to `true` to receive only events that traveled the
350
+ * realtime path (no synthetic `message_appended` injected from
351
+ * REST).
352
+ */
353
+ disableHistoryCatchUp?: boolean;
336
354
  }): AsyncIterable<ChatEvent>;
337
355
  /**
338
356
  * Higher-level convenience: yield only AI reply deltas. Internally consumes
@@ -1151,6 +1151,8 @@ function streamReplyForTask(socket, taskId, opts) {
1151
1151
  }
1152
1152
 
1153
1153
  // src/server/client.ts
1154
+ var CATCH_UP_DELAY_MS = 500;
1155
+ var CATCH_UP_LIMIT = 20;
1154
1156
  var AppClient = class {
1155
1157
  projects;
1156
1158
  tasks;
@@ -1252,6 +1254,17 @@ var TasksApi = class {
1252
1254
  *
1253
1255
  * The first call lazily opens a /ws/app connection; subsequent calls share
1254
1256
  * the same connection.
1257
+ *
1258
+ * History catch-up (enabled by default): when a terminal event arrives
1259
+ * (`task_finished`, `task_failed`, `runtime_status` flipping
1260
+ * `replyInProgress` true→false, or `connection_state` recovering from
1261
+ * `reconnecting → connected`), the wrapper pulls a window of recent
1262
+ * history via REST and injects any missing entries as synthetic
1263
+ * `message_appended` events. This compensates for Conductor deployments
1264
+ * where the realtime broadcast doesn't always reach the WS connection
1265
+ * (multi-instance fan-out without a backplane, idempotent commit retries
1266
+ * that bypass projection, momentary WS drops). Disable with
1267
+ * `disableHistoryCatchUp: true` for callers that prefer the raw stream.
1255
1268
  */
1256
1269
  subscribe(taskId, opts) {
1257
1270
  if (!taskId) {
@@ -1262,37 +1275,185 @@ var TasksApi = class {
1262
1275
  }
1263
1276
  this.assertOpen();
1264
1277
  const socket = this.getSocket();
1278
+ const rest = this.rest;
1265
1279
  const inner = subscribeToTask(socket, taskId, opts);
1280
+ const catchUpEnabled = opts?.disableHistoryCatchUp !== true;
1266
1281
  return {
1282
+ // Each `for await` consumer gets its own iterator with its own queue,
1283
+ // pump task, knownIds set, and catch-up state. Multiple concurrent
1284
+ // iterations over the same returned AsyncIterable do not share state
1285
+ // beyond the underlying socket (which is shared by design).
1267
1286
  [Symbol.asyncIterator]() {
1268
1287
  const innerIter = inner[Symbol.asyncIterator]();
1269
- const connectPromise = socket.connect().then(
1270
- () => null,
1271
- (err) => err
1272
- );
1273
- let connectChecked = false;
1274
- return {
1275
- async next() {
1276
- if (!connectChecked) {
1277
- connectChecked = true;
1288
+ const queue = [];
1289
+ let pendingResolve = null;
1290
+ let upstreamDone = false;
1291
+ let consumerReturned = false;
1292
+ const knownIds = /* @__PURE__ */ new Set();
1293
+ let prevReplyInProgress;
1294
+ let prevConnectionState;
1295
+ let catchUpInFlight = 0;
1296
+ let needAnotherCatchUp = false;
1297
+ let activeCatchUpAbort = null;
1298
+ const wakePending = (r) => {
1299
+ const resolver = pendingResolve;
1300
+ pendingResolve = null;
1301
+ resolver?.(r);
1302
+ };
1303
+ const drain = () => {
1304
+ if (!pendingResolve) return;
1305
+ if (queue.length > 0) {
1306
+ wakePending({ value: queue.shift(), done: false });
1307
+ return;
1308
+ }
1309
+ if (upstreamDone && catchUpInFlight === 0) {
1310
+ wakePending({ value: void 0, done: true });
1311
+ }
1312
+ };
1313
+ const enqueue = (ev) => {
1314
+ if (consumerReturned) return;
1315
+ queue.push(ev);
1316
+ drain();
1317
+ };
1318
+ const triggerCatchUp = (delayMs) => {
1319
+ if (consumerReturned || !catchUpEnabled) return;
1320
+ if (catchUpInFlight > 0) {
1321
+ needAnotherCatchUp = true;
1322
+ return;
1323
+ }
1324
+ catchUpInFlight += 1;
1325
+ const abort = new AbortController();
1326
+ activeCatchUpAbort = abort;
1327
+ void (async () => {
1328
+ try {
1329
+ if (delayMs > 0) {
1330
+ await new Promise((r) => setTimeout(r, delayMs));
1331
+ }
1332
+ if (consumerReturned || abort.signal.aborted) return;
1333
+ const page = await rest.history(
1334
+ taskId,
1335
+ { limit: CATCH_UP_LIMIT },
1336
+ { signal: abort.signal }
1337
+ );
1338
+ if (consumerReturned || abort.signal.aborted) return;
1339
+ for (const m of page.messages) {
1340
+ if (!m.id) continue;
1341
+ if (knownIds.has(m.id)) continue;
1342
+ knownIds.add(m.id);
1343
+ enqueue({ type: "message_appended", message: m });
1344
+ }
1345
+ } catch (err) {
1346
+ const name = err?.name;
1347
+ const code = err?.code;
1348
+ if (name === "AbortError" || code === "stream_aborted") return;
1349
+ console.warn("[app-sdk] subscribe history catch-up failed", err);
1350
+ } finally {
1351
+ if (activeCatchUpAbort === abort) {
1352
+ activeCatchUpAbort = null;
1353
+ }
1354
+ catchUpInFlight -= 1;
1355
+ if (needAnotherCatchUp && !consumerReturned) {
1356
+ needAnotherCatchUp = false;
1357
+ triggerCatchUp(0);
1358
+ }
1359
+ drain();
1360
+ }
1361
+ })();
1362
+ };
1363
+ let pumpStarted = false;
1364
+ const startPump = () => {
1365
+ if (pumpStarted) return;
1366
+ pumpStarted = true;
1367
+ const connectPromise = socket.connect().then(
1368
+ () => null,
1369
+ (err) => err
1370
+ );
1371
+ void (async () => {
1372
+ try {
1278
1373
  const err = await connectPromise;
1279
1374
  if (err) {
1280
1375
  try {
1281
1376
  await innerIter.return?.();
1282
1377
  } catch {
1283
1378
  }
1284
- const event = {
1379
+ enqueue({
1285
1380
  type: "task_failed",
1286
1381
  taskId,
1287
1382
  error: errorToChatError(err, "subscribe_failed")
1288
- };
1289
- return { value: event, done: false };
1383
+ });
1384
+ upstreamDone = true;
1385
+ drain();
1386
+ return;
1387
+ }
1388
+ while (!consumerReturned) {
1389
+ const r = await innerIter.next();
1390
+ if (r.done) {
1391
+ upstreamDone = true;
1392
+ drain();
1393
+ return;
1394
+ }
1395
+ const ev = r.value;
1396
+ if (catchUpEnabled) {
1397
+ if (ev.type === "message_appended") {
1398
+ if (ev.message.id) knownIds.add(ev.message.id);
1399
+ } else if (ev.type === "task_finished" || ev.type === "task_failed") {
1400
+ triggerCatchUp(CATCH_UP_DELAY_MS);
1401
+ } else if (ev.type === "runtime_status") {
1402
+ const now = ev.status.replyInProgress;
1403
+ if (prevReplyInProgress === true && now === false) {
1404
+ triggerCatchUp(CATCH_UP_DELAY_MS);
1405
+ }
1406
+ prevReplyInProgress = now;
1407
+ } else if (ev.type === "connection_state") {
1408
+ if (ev.state === "connected" && prevConnectionState === "reconnecting") {
1409
+ triggerCatchUp(0);
1410
+ }
1411
+ prevConnectionState = ev.state;
1412
+ }
1413
+ }
1414
+ enqueue(ev);
1415
+ }
1416
+ } catch (err) {
1417
+ if (!consumerReturned) {
1418
+ enqueue({
1419
+ type: "task_failed",
1420
+ taskId,
1421
+ error: errorToChatError(err, "subscribe_failed")
1422
+ });
1423
+ upstreamDone = true;
1424
+ drain();
1290
1425
  }
1291
1426
  }
1292
- return innerIter.next();
1427
+ })();
1428
+ };
1429
+ return {
1430
+ async next() {
1431
+ if (consumerReturned) {
1432
+ return { value: void 0, done: true };
1433
+ }
1434
+ startPump();
1435
+ if (queue.length > 0) {
1436
+ return { value: queue.shift(), done: false };
1437
+ }
1438
+ if (upstreamDone && catchUpInFlight === 0) {
1439
+ return { value: void 0, done: true };
1440
+ }
1441
+ return new Promise((resolve) => {
1442
+ pendingResolve = resolve;
1443
+ });
1293
1444
  },
1294
1445
  async return() {
1295
- return await innerIter.return?.() ?? { value: void 0, done: true };
1446
+ consumerReturned = true;
1447
+ activeCatchUpAbort?.abort();
1448
+ activeCatchUpAbort = null;
1449
+ try {
1450
+ await innerIter.return?.();
1451
+ } catch {
1452
+ }
1453
+ if (pendingResolve) {
1454
+ wakePending({ value: void 0, done: true });
1455
+ }
1456
+ return { value: void 0, done: true };
1296
1457
  }
1297
1458
  };
1298
1459
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/app-sdk",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Conductor App SDK: third-party backend SDK + React chat widget for embedding Conductor AI tools",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,8 +60,12 @@
60
60
  "react-dom": ">=18.0.0"
61
61
  },
62
62
  "peerDependenciesMeta": {
63
- "react": { "optional": true },
64
- "react-dom": { "optional": true }
63
+ "react": {
64
+ "optional": true
65
+ },
66
+ "react-dom": {
67
+ "optional": true
68
+ }
65
69
  },
66
70
  "devDependencies": {
67
71
  "@types/node": "^22.10.2",