@blocklet/payment-react-headless 1.27.2 → 1.29.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.
@@ -30,6 +30,9 @@ export interface UseSubmitReturn {
30
30
  stripeConfirm: () => Promise<void>;
31
31
  stripeCancel: () => Promise<void>;
32
32
  stripeSkip: () => Promise<void>;
33
+ /** Signal that DID Connect completed successfully — triggers completion polling.
34
+ * Use this in CF Workers (no WebSocket) to poll session status after connect.close(). */
35
+ didConnectComplete: () => Promise<void>;
33
36
  vendorStatus: VendorOrderStatus | null;
34
37
  /** Whether the checkout config is locked (user clicked "Connect and Pay", pending login/submit) */
35
38
  locked: boolean;
@@ -116,13 +116,15 @@ export function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit
116
116
  try {
117
117
  const { data } = await api.get(API.VENDOR_ORDER_STATUS(sessionId));
118
118
  if (!mountedRef.current) return;
119
+ const vendors = Array.isArray(data?.vendors) ? data.vendors : [];
119
120
  const needCheckError = Date.now() - startTime > 6e3;
120
- const allCompleted = data?.vendors?.every((v) => v.progress >= 100);
121
- const hasFailed = data?.vendors?.some(
121
+ const allCompleted = vendors.every((v) => v.progress >= 100);
122
+ const hasFailed = vendors.some(
122
123
  (v) => v.status === "failed" || needCheckError && !!v.error && !!v.error_message
123
124
  );
124
125
  setVendorStatus({
125
126
  ...data,
127
+ vendors,
126
128
  isAllCompleted: !hasFailed && allCompleted,
127
129
  hasFailed
128
130
  });
@@ -503,6 +505,13 @@ export function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit
503
505
  setContext({ type: "error", message: err.message || "Failed to skip payment method", code: "SKIP_FAILED" });
504
506
  }
505
507
  });
508
+ const didConnectComplete = useMemoizedFn(async () => {
509
+ if (status === "submitting" || status === "completed") return;
510
+ pollingAbortRef.current = false;
511
+ setStatus("submitting");
512
+ setContext(null);
513
+ await handleCompletion();
514
+ });
506
515
  return {
507
516
  status,
508
517
  context,
@@ -515,6 +524,7 @@ export function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit
515
524
  stripeConfirm,
516
525
  stripeCancel,
517
526
  stripeSkip,
527
+ didConnectComplete,
518
528
  vendorStatus,
519
529
  locked,
520
530
  lock
@@ -21,6 +21,7 @@ export default api;
21
21
  export declare const API: {
22
22
  readonly START_PAYMENT_LINK: (id: string) => string;
23
23
  readonly RETRIEVE_SESSION: (id: string) => string;
24
+ readonly STATUS_SESSION: (id: string) => string;
24
25
  readonly SWITCH_CURRENCY: (id: string) => string;
25
26
  readonly SUBMIT: (id: string) => string;
26
27
  readonly DONATE_SUBMIT: (id: string) => string;
package/es/shared/api.js CHANGED
@@ -61,6 +61,7 @@ export default api;
61
61
  export const API = {
62
62
  START_PAYMENT_LINK: (id) => `/api/checkout-sessions/start/${id}`,
63
63
  RETRIEVE_SESSION: (id) => `/api/checkout-sessions/retrieve/${id}`,
64
+ STATUS_SESSION: (id) => `/api/checkout-sessions/status/${id}`,
64
65
  SWITCH_CURRENCY: (id) => `/api/checkout-sessions/${id}/switch-currency`,
65
66
  SUBMIT: (id) => `/api/checkout-sessions/${id}/submit`,
66
67
  DONATE_SUBMIT: (id) => `/api/checkout-sessions/${id}/donate-submit`,
@@ -1,11 +1,12 @@
1
1
  import type { CheckoutResult } from '../checkout/types';
2
2
  /**
3
3
  * Poll for checkout session completion.
4
- * Extracted from react/src/payment/form/index.tsx:72-99
5
4
  *
6
- * Uses p-wait-for with 2s interval and 3 minute timeout.
7
- * Completion condition: session.status === 'complete' && payment_status in ['paid', 'no_payment_required']
8
- * Throws if paymentIntent has last_payment_error.
5
+ * Uses lightweight /status endpoint during polling (single D1 query, ~50ms)
6
+ * instead of full /retrieve (multiple includes, ~850ms). Only fetches full
7
+ * session data on the final successful poll for the success page.
8
+ *
9
+ * This reduces D1 load during payment processing and speeds up detection.
9
10
  */
10
11
  export declare function waitForCheckoutComplete(sessionId: string): Promise<CheckoutResult>;
11
12
  /**
@@ -1,19 +1,28 @@
1
- import pWaitFor from "p-wait-for";
2
1
  import api, { API } from "./api.js";
2
+ const POLL_TIMEOUT_MS = 5 * 60 * 1e3;
3
+ function nextPollInterval(elapsedMs) {
4
+ if (elapsedMs < 5e3) return 500;
5
+ if (elapsedMs < 3e4) return 1500;
6
+ return 3e3;
7
+ }
8
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3
9
  export async function waitForCheckoutComplete(sessionId) {
4
- let result;
5
- await pWaitFor(
6
- async () => {
7
- const { data } = await api.get(API.RETRIEVE_SESSION(sessionId));
8
- if (data.paymentIntent && data.paymentIntent.status === "requires_action" && data.paymentIntent.last_payment_error) {
9
- throw new Error(data.paymentIntent.last_payment_error.message);
10
- }
11
- result = data;
12
- return data.checkoutSession?.status === "complete" && ["paid", "no_payment_required"].includes(data.checkoutSession?.payment_status);
13
- },
14
- { interval: 2e3, timeout: 3 * 60 * 1e3 }
15
- );
16
- return result;
10
+ const startedAt = Date.now();
11
+ while (true) {
12
+ const { data: data2 } = await api.get(API.STATUS_SESSION(sessionId));
13
+ if (data2.paymentIntent && data2.paymentIntent.status === "requires_action" && data2.paymentIntent.last_payment_error) {
14
+ throw new Error(data2.paymentIntent.last_payment_error.message);
15
+ }
16
+ const done = data2.checkoutSession?.status === "complete" && ["paid", "no_payment_required"].includes(data2.checkoutSession?.payment_status);
17
+ if (done) break;
18
+ const elapsed = Date.now() - startedAt;
19
+ if (elapsed >= POLL_TIMEOUT_MS) {
20
+ throw new Error("waitForCheckoutComplete: timed out");
21
+ }
22
+ await sleep(nextPollInterval(elapsed));
23
+ }
24
+ const { data } = await api.get(API.RETRIEVE_SESSION(sessionId));
25
+ return data;
17
26
  }
18
27
  export function generateIdempotencyKey(sessionId, currencyId) {
19
28
  return `${sessionId}-${currencyId}-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
@@ -30,6 +30,9 @@ export interface UseSubmitReturn {
30
30
  stripeConfirm: () => Promise<void>;
31
31
  stripeCancel: () => Promise<void>;
32
32
  stripeSkip: () => Promise<void>;
33
+ /** Signal that DID Connect completed successfully — triggers completion polling.
34
+ * Use this in CF Workers (no WebSocket) to poll session status after connect.close(). */
35
+ didConnectComplete: () => Promise<void>;
33
36
  vendorStatus: VendorOrderStatus | null;
34
37
  /** Whether the checkout config is locked (user clicked "Connect and Pay", pending login/submit) */
35
38
  locked: boolean;
@@ -115,11 +115,13 @@ function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit, isDon
115
115
  data
116
116
  } = await _api.default.get(_api.API.VENDOR_ORDER_STATUS(sessionId));
117
117
  if (!mountedRef.current) return;
118
+ const vendors = Array.isArray(data?.vendors) ? data.vendors : [];
118
119
  const needCheckError = Date.now() - startTime > 6e3;
119
- const allCompleted = data?.vendors?.every(v => v.progress >= 100);
120
- const hasFailed = data?.vendors?.some(v => v.status === "failed" || needCheckError && !!v.error && !!v.error_message);
120
+ const allCompleted = vendors.every(v => v.progress >= 100);
121
+ const hasFailed = vendors.some(v => v.status === "failed" || needCheckError && !!v.error && !!v.error_message);
121
122
  setVendorStatus({
122
123
  ...data,
124
+ vendors,
123
125
  isAllCompleted: !hasFailed && allCompleted,
124
126
  hasFailed
125
127
  });
@@ -528,6 +530,13 @@ function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit, isDon
528
530
  });
529
531
  }
530
532
  });
533
+ const didConnectComplete = (0, _ahooks.useMemoizedFn)(async () => {
534
+ if (status === "submitting" || status === "completed") return;
535
+ pollingAbortRef.current = false;
536
+ setStatus("submitting");
537
+ setContext(null);
538
+ await handleCompletion();
539
+ });
531
540
  return {
532
541
  status,
533
542
  context,
@@ -540,6 +549,7 @@ function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit, isDon
540
549
  stripeConfirm,
541
550
  stripeCancel,
542
551
  stripeSkip,
552
+ didConnectComplete,
543
553
  vendorStatus,
544
554
  locked,
545
555
  lock
@@ -21,6 +21,7 @@ export default api;
21
21
  export declare const API: {
22
22
  readonly START_PAYMENT_LINK: (id: string) => string;
23
23
  readonly RETRIEVE_SESSION: (id: string) => string;
24
+ readonly STATUS_SESSION: (id: string) => string;
24
25
  readonly SWITCH_CURRENCY: (id: string) => string;
25
26
  readonly SUBMIT: (id: string) => string;
26
27
  readonly DONATE_SUBMIT: (id: string) => string;
package/lib/shared/api.js CHANGED
@@ -68,6 +68,7 @@ module.exports = api;
68
68
  const API = exports.API = {
69
69
  START_PAYMENT_LINK: id => `/api/checkout-sessions/start/${id}`,
70
70
  RETRIEVE_SESSION: id => `/api/checkout-sessions/retrieve/${id}`,
71
+ STATUS_SESSION: id => `/api/checkout-sessions/status/${id}`,
71
72
  SWITCH_CURRENCY: id => `/api/checkout-sessions/${id}/switch-currency`,
72
73
  SUBMIT: id => `/api/checkout-sessions/${id}/submit`,
73
74
  DONATE_SUBMIT: id => `/api/checkout-sessions/${id}/donate-submit`,
@@ -1,11 +1,12 @@
1
1
  import type { CheckoutResult } from '../checkout/types';
2
2
  /**
3
3
  * Poll for checkout session completion.
4
- * Extracted from react/src/payment/form/index.tsx:72-99
5
4
  *
6
- * Uses p-wait-for with 2s interval and 3 minute timeout.
7
- * Completion condition: session.status === 'complete' && payment_status in ['paid', 'no_payment_required']
8
- * Throws if paymentIntent has last_payment_error.
5
+ * Uses lightweight /status endpoint during polling (single D1 query, ~50ms)
6
+ * instead of full /retrieve (multiple includes, ~850ms). Only fetches full
7
+ * session data on the final successful poll for the success page.
8
+ *
9
+ * This reduces D1 load during payment processing and speeds up detection.
9
10
  */
10
11
  export declare function waitForCheckoutComplete(sessionId: string): Promise<CheckoutResult>;
11
12
  /**
@@ -5,27 +5,37 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.generateIdempotencyKey = generateIdempotencyKey;
7
7
  exports.waitForCheckoutComplete = waitForCheckoutComplete;
8
- var _pWaitFor = _interopRequireDefault(require("p-wait-for"));
9
8
  var _api = _interopRequireWildcard(require("./api"));
10
9
  function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
11
10
  function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
12
- function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
11
+ const POLL_TIMEOUT_MS = 5 * 60 * 1e3;
12
+ function nextPollInterval(elapsedMs) {
13
+ if (elapsedMs < 5e3) return 500;
14
+ if (elapsedMs < 3e4) return 1500;
15
+ return 3e3;
16
+ }
17
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
13
18
  async function waitForCheckoutComplete(sessionId) {
14
- let result;
15
- await (0, _pWaitFor.default)(async () => {
19
+ const startedAt = Date.now();
20
+ while (true) {
16
21
  const {
17
- data
18
- } = await _api.default.get(_api.API.RETRIEVE_SESSION(sessionId));
19
- if (data.paymentIntent && data.paymentIntent.status === "requires_action" && data.paymentIntent.last_payment_error) {
20
- throw new Error(data.paymentIntent.last_payment_error.message);
22
+ data: data2
23
+ } = await _api.default.get(_api.API.STATUS_SESSION(sessionId));
24
+ if (data2.paymentIntent && data2.paymentIntent.status === "requires_action" && data2.paymentIntent.last_payment_error) {
25
+ throw new Error(data2.paymentIntent.last_payment_error.message);
26
+ }
27
+ const done = data2.checkoutSession?.status === "complete" && ["paid", "no_payment_required"].includes(data2.checkoutSession?.payment_status);
28
+ if (done) break;
29
+ const elapsed = Date.now() - startedAt;
30
+ if (elapsed >= POLL_TIMEOUT_MS) {
31
+ throw new Error("waitForCheckoutComplete: timed out");
21
32
  }
22
- result = data;
23
- return data.checkoutSession?.status === "complete" && ["paid", "no_payment_required"].includes(data.checkoutSession?.payment_status);
24
- }, {
25
- interval: 2e3,
26
- timeout: 3 * 60 * 1e3
27
- });
28
- return result;
33
+ await sleep(nextPollInterval(elapsed));
34
+ }
35
+ const {
36
+ data
37
+ } = await _api.default.get(_api.API.RETRIEVE_SESSION(sessionId));
38
+ return data;
29
39
  }
30
40
  function generateIdempotencyKey(sessionId, currencyId) {
31
41
  return `${sessionId}-${currencyId}-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocklet/payment-react-headless",
3
- "version": "1.27.2",
3
+ "version": "1.29.0",
4
4
  "description": "Headless React hooks for payment-kit checkout",
5
5
  "keywords": [
6
6
  "react",
@@ -33,10 +33,10 @@
33
33
  }
34
34
  },
35
35
  "dependencies": {
36
- "@arcblock/ws": "^1.28.5",
36
+ "@arcblock/ws": "^1.30.9",
37
37
  "@blocklet/js-sdk": "workspace:*",
38
- "@blocklet/payment-types": "1.27.2",
39
- "@ocap/util": "^1.28.5",
38
+ "@blocklet/payment-types": "1.29.0",
39
+ "@ocap/util": "^1.30.9",
40
40
  "ahooks": "^3.8.5",
41
41
  "google-libphonenumber": "^3.2.42",
42
42
  "lodash": "^4.18.1",
@@ -60,5 +60,5 @@
60
60
  "publishConfig": {
61
61
  "access": "public"
62
62
  },
63
- "gitHead": "ba98e644bcbf88924039ba8990bba673198e6a61"
63
+ "gitHead": "02334964fbf505ea2fd27081039542d1f9868d57"
64
64
  }
@@ -57,6 +57,9 @@ export interface UseSubmitReturn {
57
57
  stripeConfirm: () => Promise<void>;
58
58
  stripeCancel: () => Promise<void>;
59
59
  stripeSkip: () => Promise<void>;
60
+ /** Signal that DID Connect completed successfully — triggers completion polling.
61
+ * Use this in CF Workers (no WebSocket) to poll session status after connect.close(). */
62
+ didConnectComplete: () => Promise<void>;
60
63
  vendorStatus: VendorOrderStatus | null;
61
64
  /** Whether the checkout config is locked (user clicked "Connect and Pay", pending login/submit) */
62
65
  locked: boolean;
@@ -210,15 +213,17 @@ export function useSubmit(
210
213
  const { data } = await api.get(API.VENDOR_ORDER_STATUS(sessionId));
211
214
  if (!mountedRef.current) return;
212
215
 
216
+ const vendors = Array.isArray(data?.vendors) ? data.vendors : [];
213
217
  const needCheckError = Date.now() - startTime > 6000;
214
- const allCompleted = data?.vendors?.every((v: VendorStatus) => v.progress >= 100);
215
- const hasFailed = data?.vendors?.some(
218
+ const allCompleted = vendors.every((v: VendorStatus) => v.progress >= 100);
219
+ const hasFailed = vendors.some(
216
220
  (v: VendorStatus & { error?: string; error_message?: string }) =>
217
221
  v.status === 'failed' || (needCheckError && !!v.error && !!v.error_message)
218
222
  );
219
223
 
220
224
  setVendorStatus({
221
225
  ...data,
226
+ vendors,
222
227
  isAllCompleted: !hasFailed && allCompleted,
223
228
  hasFailed,
224
229
  });
@@ -704,6 +709,23 @@ export function useSubmit(
704
709
  }
705
710
  });
706
711
 
712
+ // DID Connect complete — mirrors V1 handleConnected() / stripeConfirm() pattern.
713
+ // In CF Workers (no WebSocket relay), the frontend has no real-time notification
714
+ // when DID Connect payment succeeds. Call this from onSuccess to poll session status.
715
+ //
716
+ // Recovers from in-modal retry: if the first sign attempt failed (onError fired,
717
+ // status was reset to 'idle' / 'failed' and pollingAbortRef was set), the user can
718
+ // still retry inside the same DID Connect modal. When that retry finally fires
719
+ // onSuccess we must resume polling from any non-terminal status — only short-circuit
720
+ // when completion is already in flight or finalized.
721
+ const didConnectComplete = useMemoizedFn(async () => {
722
+ if (status === 'submitting' || status === 'completed') return;
723
+ pollingAbortRef.current = false;
724
+ setStatus('submitting');
725
+ setContext(null);
726
+ await handleCompletion();
727
+ });
728
+
707
729
  return {
708
730
  status,
709
731
  context,
@@ -716,6 +738,7 @@ export function useSubmit(
716
738
  stripeConfirm,
717
739
  stripeCancel,
718
740
  stripeSkip,
741
+ didConnectComplete,
719
742
  vendorStatus,
720
743
  locked,
721
744
  lock,
package/src/shared/api.ts CHANGED
@@ -98,6 +98,7 @@ export default api;
98
98
  export const API = {
99
99
  START_PAYMENT_LINK: (id: string) => `/api/checkout-sessions/start/${id}`,
100
100
  RETRIEVE_SESSION: (id: string) => `/api/checkout-sessions/retrieve/${id}`,
101
+ STATUS_SESSION: (id: string) => `/api/checkout-sessions/status/${id}`,
101
102
  SWITCH_CURRENCY: (id: string) => `/api/checkout-sessions/${id}/switch-currency`,
102
103
  SUBMIT: (id: string) => `/api/checkout-sessions/${id}/submit`,
103
104
  DONATE_SUBMIT: (id: string) => `/api/checkout-sessions/${id}/donate-submit`,
@@ -1,43 +1,63 @@
1
- import pWaitFor from 'p-wait-for';
2
-
3
1
  import api, { API } from './api';
4
2
  import type { CheckoutResult } from '../checkout/types';
5
3
 
4
+ const POLL_TIMEOUT_MS = 5 * 60 * 1000;
5
+
6
+ /**
7
+ * Progressive polling interval — typical DID Connect + on-chain confirm + DB
8
+ * write completes in 1–3s, so a short cadence in that window matters most for
9
+ * perceived latency. Backoff after that to keep D1 load bounded for slow paths
10
+ * (chain congestion, async vendor processing, etc.).
11
+ */
12
+ function nextPollInterval(elapsedMs: number): number {
13
+ if (elapsedMs < 5_000) return 500;
14
+ if (elapsedMs < 30_000) return 1_500;
15
+ return 3_000;
16
+ }
17
+
18
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
19
+
6
20
  /**
7
21
  * Poll for checkout session completion.
8
- * Extracted from react/src/payment/form/index.tsx:72-99
9
22
  *
10
- * Uses p-wait-for with 2s interval and 3 minute timeout.
11
- * Completion condition: session.status === 'complete' && payment_status in ['paid', 'no_payment_required']
12
- * Throws if paymentIntent has last_payment_error.
23
+ * Uses lightweight /status endpoint during polling (single D1 query, ~50ms)
24
+ * instead of full /retrieve (multiple includes, ~850ms). Only fetches full
25
+ * session data on the final successful poll for the success page.
26
+ *
27
+ * This reduces D1 load during payment processing and speeds up detection.
13
28
  */
14
29
  export async function waitForCheckoutComplete(sessionId: string): Promise<CheckoutResult> {
15
- let result: CheckoutResult;
16
-
17
- await pWaitFor(
18
- async () => {
19
- const { data } = await api.get(API.RETRIEVE_SESSION(sessionId));
20
-
21
- if (
22
- data.paymentIntent &&
23
- data.paymentIntent.status === 'requires_action' &&
24
- data.paymentIntent.last_payment_error
25
- ) {
26
- throw new Error(data.paymentIntent.last_payment_error.message);
27
- }
28
-
29
- result = data;
30
-
31
- return (
32
- data.checkoutSession?.status === 'complete' &&
33
- ['paid', 'no_payment_required'].includes(data.checkoutSession?.payment_status)
34
- );
35
- },
36
- { interval: 2000, timeout: 3 * 60 * 1000 }
37
- );
38
-
39
- // @ts-expect-error result is assigned inside the polling callback
40
- return result;
30
+ const startedAt = Date.now();
31
+
32
+ // Phase 1: Lightweight status polling with progressive backoff.
33
+ // First check fires immediately — happy-path DID flows where the backend
34
+ // already finalized before the polling kicks in must not wait an interval.
35
+ while (true) {
36
+ const { data } = await api.get(API.STATUS_SESSION(sessionId));
37
+
38
+ if (
39
+ data.paymentIntent &&
40
+ data.paymentIntent.status === 'requires_action' &&
41
+ data.paymentIntent.last_payment_error
42
+ ) {
43
+ throw new Error(data.paymentIntent.last_payment_error.message);
44
+ }
45
+
46
+ const done =
47
+ data.checkoutSession?.status === 'complete' &&
48
+ ['paid', 'no_payment_required'].includes(data.checkoutSession?.payment_status);
49
+ if (done) break;
50
+
51
+ const elapsed = Date.now() - startedAt;
52
+ if (elapsed >= POLL_TIMEOUT_MS) {
53
+ throw new Error('waitForCheckoutComplete: timed out');
54
+ }
55
+ await sleep(nextPollInterval(elapsed));
56
+ }
57
+
58
+ // Phase 2: Full retrieve for success page data
59
+ const { data } = await api.get(API.RETRIEVE_SESSION(sessionId));
60
+ return data;
41
61
  }
42
62
 
43
63
  /**
package/tsconfig.json CHANGED
@@ -13,6 +13,7 @@
13
13
  "resolveJsonModule": true,
14
14
  "skipLibCheck": true,
15
15
  "skipDefaultLibCheck": true,
16
- "jsx": "react-jsx"
16
+ "jsx": "react-jsx",
17
+ "types": ["node"]
17
18
  }
18
19
  }