@apex-inc/sdk 0.1.0 → 0.5.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/README.md +63 -125
- package/dist/esm/index.js +165 -2
- package/dist/esm/management.js +392 -0
- package/dist/esm/plg-events.js +45 -0
- package/dist/index.d.ts +242 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +172 -3
- package/dist/index.js.map +1 -1
- package/dist/management.d.ts +283 -0
- package/dist/management.d.ts.map +1 -1
- package/dist/management.js +392 -0
- package/dist/management.js.map +1 -1
- package/dist/plg-events.d.ts +131 -0
- package/dist/plg-events.d.ts.map +1 -0
- package/dist/plg-events.js +50 -0
- package/dist/plg-events.js.map +1 -0
- package/package.json +8 -7
- package/skills/apex-communications/SKILL.md +216 -0
- package/skills/apex-growth-tracking/SKILL.md +92 -1
- package/skills/apex-integration-cookbook/SKILL.md +140 -25
- package/skills/apex-partner-network/SKILL.md +191 -0
package/README.md
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# @apex-inc/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Event tracking and identity SDK for Apex — currently in **private beta**.
|
|
4
|
+
|
|
5
|
+
Works in Node.js 18+ and browser environments. Zero runtime dependencies.
|
|
6
|
+
|
|
7
|
+
## Access
|
|
8
|
+
|
|
9
|
+
Apex is invite-only during the private-beta period. If you received an invite, your onboarding email contains your `projectKey` and `apiUrl`. Full API documentation is available in-app once your account is active.
|
|
10
|
+
|
|
11
|
+
Not in the beta yet? Request access at [apex.inc](https://apex.inc) (coming soon).
|
|
4
12
|
|
|
5
13
|
## Install
|
|
6
14
|
|
|
@@ -13,152 +21,82 @@ npm install @apex-inc/sdk
|
|
|
13
21
|
```typescript
|
|
14
22
|
import { init, track, identify } from "@apex-inc/sdk";
|
|
15
23
|
|
|
16
|
-
// Initialize (once, at app startup)
|
|
17
24
|
init({
|
|
18
25
|
projectKey: "your-project-key",
|
|
19
26
|
apiUrl: "https://your-apex-instance.com",
|
|
20
27
|
});
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
identify("user_123", {
|
|
24
|
-
email: "jane@example.com",
|
|
25
|
-
plan: "starter",
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
// Track events
|
|
29
|
+
identify("user_123", { email: "jane@example.com" });
|
|
29
30
|
track("page_viewed", { path: "/pricing" });
|
|
30
|
-
track("button_clicked", { label: "Start Trial", section: "hero" });
|
|
31
|
-
track("form_submitted", { formId: "signup", fields: 3 });
|
|
32
31
|
```
|
|
33
32
|
|
|
34
|
-
##
|
|
35
|
-
|
|
36
|
-
### `init(config)`
|
|
33
|
+
## PLG primitives
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
| `apiUrl` | string | `window.location.origin` | Apex API URL |
|
|
42
|
-
| `flushInterval` | number | `5000` | Auto-flush interval (ms) |
|
|
43
|
-
| `flushAt` | number | `20` | Max queued events before flush |
|
|
44
|
-
| `debug` | boolean | `false` | Log SDK activity to console |
|
|
45
|
-
|
|
46
|
-
### `track(event, properties?)`
|
|
47
|
-
|
|
48
|
-
Send a named event with optional properties.
|
|
35
|
+
In addition to `track` / `identify` the SDK exposes three PLG-specific
|
|
36
|
+
primitives. Use them when you want Apex to derive activation,
|
|
37
|
+
expansion, and retention rollups automatically:
|
|
49
38
|
|
|
50
39
|
```typescript
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
### `identify(userId, traits?)`
|
|
40
|
+
import { init, identify, group, feature, track } from "@apex-inc/sdk";
|
|
41
|
+
import { EVENTS, type ActivatedEvent } from "@apex-inc/sdk/plg-events";
|
|
55
42
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
```typescript
|
|
59
|
-
identify("jane@example.com", {
|
|
60
|
-
name: "Jane Smith",
|
|
61
|
-
plan: "growth",
|
|
62
|
-
company: "Acme Inc",
|
|
63
|
-
});
|
|
64
|
-
```
|
|
43
|
+
init({ projectKey: "your-project-key", apiUrl: "https://app.apex.inc" });
|
|
65
44
|
|
|
66
|
-
|
|
45
|
+
// Identify the authenticated user.
|
|
46
|
+
identify("chris@acme.com", { visitorId: "apex_vid_from_cookie" });
|
|
67
47
|
|
|
68
|
-
|
|
48
|
+
// Group them into their account / team (B2B).
|
|
49
|
+
group("acct_acme", { plan: "growth", arr: 120_000 });
|
|
69
50
|
|
|
70
|
-
|
|
51
|
+
// Track feature usage. Include experiment variant if you have one.
|
|
52
|
+
feature("cohort-builder", { variant: "new-editor", enabled: true });
|
|
71
53
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
});
|
|
54
|
+
// Emit curated PLG lifecycle events with compile-time-typed payloads.
|
|
55
|
+
track(EVENTS.ACTIVATED, {
|
|
56
|
+
daysToActivation: 3,
|
|
57
|
+
milestone: "first-experiment-shipped",
|
|
58
|
+
} satisfies ActivatedEvent);
|
|
77
59
|
```
|
|
78
60
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
61
|
+
The PLG catalog ships eight curated events: `signup`, `activated`,
|
|
62
|
+
`feature_first_use`, `aha_moment`, `expansion`, `contraction`,
|
|
63
|
+
`reactivation`, `churn`. Each has a typed interface so your IDE
|
|
64
|
+
catches missing fields at build time. See the [PLG event catalog
|
|
65
|
+
docs](https://app.apex.inc/docs/plg-event-catalog) for the full
|
|
66
|
+
schema.
|
|
67
|
+
|
|
68
|
+
## Server Events API (server-to-server events)
|
|
69
|
+
|
|
70
|
+
For backend workflows that don't have a browser session — Stripe webhooks,
|
|
71
|
+
CRM events, scheduled jobs — use `sendServerEvent` / `sendServerEvents`.
|
|
72
|
+
These hit the `/api/v1/events` endpoint with API-key auth and
|
|
73
|
+
idempotency-key support.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { sendServerEvent, newIdempotencyKey } from "@apex-inc/sdk";
|
|
77
|
+
|
|
78
|
+
await sendServerEvent(
|
|
79
|
+
{ apiKey: process.env.APEX_API_KEY! }, // apex_sk_…
|
|
80
|
+
{
|
|
81
|
+
type: "purchase_completed",
|
|
82
|
+
email: customer.email,
|
|
83
|
+
data: { value: invoice.amount_paid / 100, currency: "USD", order_id: invoice.id },
|
|
84
|
+
},
|
|
85
|
+
{ idempotencyKey: stripeEvent.id }, // or newIdempotencyKey()
|
|
86
|
+
);
|
|
92
87
|
```
|
|
93
88
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
| `formId` | string | No | Form identifier |
|
|
98
|
-
| `action` | string | No | Form action URL |
|
|
99
|
-
| `fields` | object | No | Additional form field data |
|
|
100
|
-
|
|
101
|
-
### `flush()`
|
|
102
|
-
|
|
103
|
-
Immediately send all queued events.
|
|
104
|
-
|
|
105
|
-
### `shutdown()`
|
|
106
|
-
|
|
107
|
-
Flush remaining events and stop the background timer.
|
|
108
|
-
|
|
109
|
-
## Node.js Usage
|
|
110
|
-
|
|
111
|
-
The SDK works in Node.js 18+ (which includes global `fetch`). For older Node.js versions, install `node-fetch`:
|
|
112
|
-
|
|
113
|
-
```bash
|
|
114
|
-
npm install node-fetch
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
```typescript
|
|
118
|
-
import { init, track, identify, shutdown } from "@apex-inc/sdk";
|
|
119
|
-
|
|
120
|
-
init({
|
|
121
|
-
projectKey: "your-project-key",
|
|
122
|
-
apiUrl: "https://your-apex-instance.com",
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// In your webhook handler or API route:
|
|
126
|
-
identify("new-signup@example.com", { source: "stripe_webhook" });
|
|
127
|
-
track("subscription_created", { plan: "growth", mrr: 99 });
|
|
128
|
-
|
|
129
|
-
// On process exit:
|
|
130
|
-
shutdown();
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
## Management API (API key)
|
|
134
|
-
|
|
135
|
-
For **server-side** access to experiments (same auth model as the Apex MCP server), use `createManagementClient` with your **`APEX_API_KEY`** and project key. The client sends **`x-api-key`** and **`x-apex-project`** so routes resolve the tenant without a browser session.
|
|
136
|
-
|
|
137
|
-
```typescript
|
|
138
|
-
import { createManagementClient, init, track } from "@apex-inc/sdk";
|
|
139
|
-
|
|
140
|
-
const apex = createManagementClient({
|
|
141
|
-
projectKey: "your-project-key",
|
|
142
|
-
apiKey: process.env.APEX_API_KEY!,
|
|
143
|
-
apiUrl: "https://your-apex-instance.com",
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
const experiments = await apex.listExperiments();
|
|
147
|
-
const { variant } = await apex.assignVariant("experiment-id-123", "stable-visitor-id-per-user");
|
|
148
|
-
|
|
149
|
-
init({ projectKey: "your-project-key", apiUrl: "https://your-apex-instance.com" });
|
|
150
|
-
track("notification_sent", { experimentId: "experiment-id-123", variant });
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
`assignVariant` uses a stable **visitor id** per end user (e.g. hashed internal user id) so variant assignment stays deterministic. Pass the same id on every call for that user and experiment.
|
|
154
|
-
|
|
155
|
-
## Shipped skills (AI assistants)
|
|
156
|
-
|
|
157
|
-
Cursor-style skills live in **`skills/apex-growth-tracking/SKILL.md`**. Copy that folder into `.cursor/skills/` so your AI assistant gets Apex tracking conventions in context.
|
|
89
|
+
Batches of up to 100 events go through `sendServerEvents`. Use the same
|
|
90
|
+
visitorId / email you'd use in the browser snippet so cross-channel
|
|
91
|
+
stitching just works.
|
|
158
92
|
|
|
159
|
-
##
|
|
93
|
+
## Multi-workspace orgs
|
|
160
94
|
|
|
161
|
-
|
|
95
|
+
If your organization runs more than one workspace (e.g. a SaaS product
|
|
96
|
+
plus a partner marketplace), each workspace has its own `projectKey`.
|
|
97
|
+
Initialize one SDK instance per workspace; event identity stitches at
|
|
98
|
+
the org level via `OrgPerson` so a single human tracked in both shows
|
|
99
|
+
up as one person in cross-workspace cohorts and reports.
|
|
162
100
|
|
|
163
101
|
## License
|
|
164
102
|
|
package/dist/esm/index.js
CHANGED
|
@@ -17,6 +17,11 @@ async function sendBatch(events) {
|
|
|
17
17
|
projectKey: config.projectKey,
|
|
18
18
|
userId,
|
|
19
19
|
events,
|
|
20
|
+
// MMP-070 — propagate the SDK-level environment override (when
|
|
21
|
+
// set by `apex.init({ environment })`). The server prefers this
|
|
22
|
+
// over auto-detection from the Referer/Origin host. Mobile SDKs
|
|
23
|
+
// don't send this field; they set `mobile.releaseChannel` instead.
|
|
24
|
+
...(config.environment ? { webEnvironment: config.environment } : {}),
|
|
20
25
|
});
|
|
21
26
|
try {
|
|
22
27
|
const fetchFn = typeof fetch !== "undefined" ? fetch : await loadNodeFetch();
|
|
@@ -89,6 +94,9 @@ export function track(event, properties) {
|
|
|
89
94
|
* Identify a user with traits (email, name, plan, etc.).
|
|
90
95
|
* Also performs identity stitching if the userId looks like an email.
|
|
91
96
|
*
|
|
97
|
+
* Prefer canonical snake_case trait names (see `CanonicalUserTraits`) so
|
|
98
|
+
* templates resolve them cleanly.
|
|
99
|
+
*
|
|
92
100
|
* Pass `traits.visitorId` (the browser's `apex_vid` cookie value) to stitch
|
|
93
101
|
* server-side identify calls to the real browser session. Without it, the SDK
|
|
94
102
|
* creates a synthetic visitor ID that is disconnected from marketing touchpoints.
|
|
@@ -111,10 +119,16 @@ export function identify(userId_, traits) {
|
|
|
111
119
|
const email = userId_.includes("@") ? userId_ : traits?.email;
|
|
112
120
|
if (email) {
|
|
113
121
|
const browserVisitorId = traits?.visitorId;
|
|
114
|
-
|
|
122
|
+
// Forward the caller's stable user id as `userId` whenever it is NOT
|
|
123
|
+
// itself an email — this becomes the highest-precedence
|
|
124
|
+
// `internal_user_id` identifier server-side, which is what makes
|
|
125
|
+
// anonymous→known reconciliation deterministic. Always identify with
|
|
126
|
+
// your own user id (e.g. Cognito sub / DB id), not just an email.
|
|
127
|
+
const stableUserId = userId_.includes("@") ? undefined : userId_;
|
|
128
|
+
stitchIdentity(email, browserVisitorId, traits, stableUserId);
|
|
115
129
|
}
|
|
116
130
|
}
|
|
117
|
-
async function stitchIdentity(email, browserVisitorId, metadata) {
|
|
131
|
+
async function stitchIdentity(email, browserVisitorId, metadata, stableUserId) {
|
|
118
132
|
if (!config)
|
|
119
133
|
return;
|
|
120
134
|
const apiUrl = config.apiUrl ?? DEFAULT_API_URL;
|
|
@@ -129,6 +143,7 @@ async function stitchIdentity(email, browserVisitorId, metadata) {
|
|
|
129
143
|
visitorId: effectiveVisitorId,
|
|
130
144
|
email,
|
|
131
145
|
projectKey: config.projectKey,
|
|
146
|
+
...(stableUserId ? { userId: stableUserId } : {}),
|
|
132
147
|
metadata: metadata ?? {},
|
|
133
148
|
}),
|
|
134
149
|
});
|
|
@@ -138,6 +153,56 @@ async function stitchIdentity(email, browserVisitorId, metadata) {
|
|
|
138
153
|
log("Identity stitch failed:", err);
|
|
139
154
|
}
|
|
140
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* MWO-009 — Group a user into an account / company / team so B2B
|
|
158
|
+
* PQA rollups can be derived. Calling `group("acme-inc")` stamps
|
|
159
|
+
* every subsequent `track`/`feature` event with `groupId`.
|
|
160
|
+
*/
|
|
161
|
+
let groupId = null;
|
|
162
|
+
export function group(groupId_, traits) {
|
|
163
|
+
if (!config) {
|
|
164
|
+
console.warn("[apex-sdk] Call init() before group()");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
groupId = groupId_;
|
|
168
|
+
log("Group:", groupId_, traits);
|
|
169
|
+
enqueue({
|
|
170
|
+
type: "group",
|
|
171
|
+
payload: { groupId: groupId_, traits },
|
|
172
|
+
timestamp: new Date().toISOString(),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* MWO-009 — Feature usage. Fire from your app whenever a user
|
|
177
|
+
* touches a named feature. Variant + enabled support wiring to
|
|
178
|
+
* Apex experiment payloads — if Phase 8's experiment engine served
|
|
179
|
+
* a variant, pass it through here to close the loop between
|
|
180
|
+
* assignment and adoption.
|
|
181
|
+
*/
|
|
182
|
+
export function feature(featureKey, opts = {}) {
|
|
183
|
+
if (!config) {
|
|
184
|
+
console.warn("[apex-sdk] Call init() before feature()");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
log("Feature:", featureKey, opts);
|
|
188
|
+
enqueue({
|
|
189
|
+
type: "feature",
|
|
190
|
+
payload: {
|
|
191
|
+
featureKey,
|
|
192
|
+
enabled: opts.enabled,
|
|
193
|
+
variant: opts.variant,
|
|
194
|
+
properties: opts.properties,
|
|
195
|
+
},
|
|
196
|
+
timestamp: new Date().toISOString(),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Returns the most recently set groupId (if any). Callers can use
|
|
201
|
+
* this to decorate their own payloads or assert in tests.
|
|
202
|
+
*/
|
|
203
|
+
export function currentGroupId() {
|
|
204
|
+
return groupId;
|
|
205
|
+
}
|
|
141
206
|
/**
|
|
142
207
|
* Track a form submission event with the email for identity stitching.
|
|
143
208
|
*/
|
|
@@ -165,6 +230,34 @@ export function flush() {
|
|
|
165
230
|
const batch = queue.splice(0, queue.length);
|
|
166
231
|
sendBatch(batch);
|
|
167
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Helpers grouped under `trackCart.*` so call sites read naturally
|
|
235
|
+
* (`apex.trackCart.add({...})`). The implementation just delegates to
|
|
236
|
+
* `track()` with the canonical event names.
|
|
237
|
+
*/
|
|
238
|
+
export const trackCart = {
|
|
239
|
+
/** Fired when a user adds an item to their cart. */
|
|
240
|
+
add(line) {
|
|
241
|
+
track("add_to_cart", { ...line });
|
|
242
|
+
},
|
|
243
|
+
/** Fired when a user removes an item from their cart. */
|
|
244
|
+
remove(opts) {
|
|
245
|
+
track("remove_from_cart", { ...opts });
|
|
246
|
+
},
|
|
247
|
+
/**
|
|
248
|
+
* Full-cart reconciliation snapshot. Use whenever the canonical
|
|
249
|
+
* cart state is loaded (login restore, app foreground, cart screen
|
|
250
|
+
* mount). The server-side rollup replaces all stored lines wholesale
|
|
251
|
+
* — this is the "source of truth" event when deltas drift.
|
|
252
|
+
*/
|
|
253
|
+
snapshot(opts) {
|
|
254
|
+
track("cart_snapshot", { ...opts });
|
|
255
|
+
},
|
|
256
|
+
/** Fired on checkout success; clears the rolled-up cart. */
|
|
257
|
+
checkout(opts) {
|
|
258
|
+
track("checkout_completed", { ...opts });
|
|
259
|
+
},
|
|
260
|
+
};
|
|
168
261
|
/**
|
|
169
262
|
* Shut down the SDK — flush remaining events and stop the timer.
|
|
170
263
|
*/
|
|
@@ -178,3 +271,73 @@ export function shutdown() {
|
|
|
178
271
|
log("Shut down");
|
|
179
272
|
}
|
|
180
273
|
export { createManagementClient, } from "./management";
|
|
274
|
+
/**
|
|
275
|
+
* Generate a stable idempotency key. Prefers `crypto.randomUUID()`
|
|
276
|
+
* when available (Node 19+, modern browsers). Falls back to a
|
|
277
|
+
* timestamp + random suffix that's good enough to avoid collisions
|
|
278
|
+
* within a single process.
|
|
279
|
+
*/
|
|
280
|
+
export function newIdempotencyKey() {
|
|
281
|
+
const c = globalThis.crypto;
|
|
282
|
+
if (c?.randomUUID)
|
|
283
|
+
return c.randomUUID();
|
|
284
|
+
return `idk_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Send a single event server-to-server. Useful for Stripe webhooks,
|
|
288
|
+
* CRM-driven conversions, and any backend signal that should land in
|
|
289
|
+
* Apex without going through the browser snippet.
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* await sendServerEvent(
|
|
293
|
+
* { apiKey: process.env.APEX_API_KEY! },
|
|
294
|
+
* {
|
|
295
|
+
* type: "purchase_completed",
|
|
296
|
+
* email: "user@example.com",
|
|
297
|
+
* data: { value: 99, currency: "USD", order_id: "order_42" },
|
|
298
|
+
* },
|
|
299
|
+
* { idempotencyKey: stripeEvent.id }
|
|
300
|
+
* );
|
|
301
|
+
*/
|
|
302
|
+
export async function sendServerEvent(config, event, options = {}) {
|
|
303
|
+
return sendServerEvents(config, [event], options);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Send up to 100 events server-to-server in one request. Larger
|
|
307
|
+
* batches must be sharded by the caller (the API returns 400
|
|
308
|
+
* `batch_too_large` otherwise).
|
|
309
|
+
*/
|
|
310
|
+
export async function sendServerEvents(config, events, options = {}) {
|
|
311
|
+
if (!config.apiKey) {
|
|
312
|
+
throw new Error("apex-sdk: sendServerEvent requires apiKey");
|
|
313
|
+
}
|
|
314
|
+
if (events.length === 0) {
|
|
315
|
+
throw new Error("apex-sdk: sendServerEvent requires at least one event");
|
|
316
|
+
}
|
|
317
|
+
if (events.length > 100) {
|
|
318
|
+
throw new Error("apex-sdk: sendServerEvents batch_too_large — max 100 events; shard yourself");
|
|
319
|
+
}
|
|
320
|
+
const apiUrl = (config.apiUrl ?? "https://app.apex.inc").replace(/\/$/, "");
|
|
321
|
+
const url = `${apiUrl}/api/v1/events`;
|
|
322
|
+
const idempotencyKey = options.idempotencyKey ?? newIdempotencyKey();
|
|
323
|
+
const headers = {
|
|
324
|
+
"content-type": "application/json",
|
|
325
|
+
"x-api-key": config.apiKey,
|
|
326
|
+
"idempotency-key": idempotencyKey,
|
|
327
|
+
};
|
|
328
|
+
if (config.projectKey)
|
|
329
|
+
headers["x-apex-project"] = config.projectKey;
|
|
330
|
+
const fetchFn = typeof fetch !== "undefined" ? fetch : await loadNodeFetch();
|
|
331
|
+
const res = await fetchFn(url, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers,
|
|
334
|
+
body: JSON.stringify({ events }),
|
|
335
|
+
});
|
|
336
|
+
const json = (await res.json().catch(() => ({})));
|
|
337
|
+
if (!res.ok) {
|
|
338
|
+
const code = json.error?.code ?? `http_${res.status}`;
|
|
339
|
+
const message = json.error?.message ?? `HTTP ${res.status}`;
|
|
340
|
+
throw new Error(`apex-sdk: sendServerEvent failed (${code}): ${message}`);
|
|
341
|
+
}
|
|
342
|
+
return json;
|
|
343
|
+
}
|