@yougrowai/node 0.1.0 → 0.3.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 +264 -55
- package/dist/cjs/index.d.ts +189 -0
- package/dist/cjs/index.js +246 -0
- package/dist/cjs/jwt.d.ts +54 -0
- package/dist/cjs/jwt.js +80 -0
- package/dist/cjs/origin.d.ts +11 -0
- package/dist/cjs/origin.js +20 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/server.d.ts +121 -0
- package/dist/cjs/server.js +140 -0
- package/dist/index.d.ts +164 -64
- package/dist/index.js +205 -121
- package/dist/jwt.d.ts +3 -2
- package/dist/jwt.js +2 -1
- package/dist/origin.d.ts +11 -0
- package/dist/origin.js +15 -0
- package/dist/server.d.ts +17 -4
- package/dist/server.js +19 -5
- package/package.json +18 -9
- package/test/vectors.json +1 -24
- package/dist/signing.d.ts +0 -19
- package/dist/signing.js +0 -10
package/dist/index.d.ts
CHANGED
|
@@ -1,89 +1,189 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @yougrowai/node —
|
|
2
|
+
* @yougrowai/node — keep your users' state in YouGrow lifecycle journeys (API v2).
|
|
3
3
|
*
|
|
4
4
|
* const yg = new YouGrow({ keyId: process.env.YOUGROW_KEY_ID!, secret: process.env.YOUGROW_SECRET! });
|
|
5
|
-
* yg.
|
|
6
|
-
* yg.track({ userId: user.id, event: "user.signed_up" });
|
|
7
|
-
* await yg.flush();
|
|
5
|
+
* await yg.users.update(user.id, { email: user.email, signedUpAt: user.createdAt.toISOString(), consent: "soft_opt_in" });
|
|
8
6
|
*
|
|
9
|
-
* Server-side only
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* Server-side only: the secret authenticates every request (HTTP Basic). Each
|
|
8
|
+
* method resolves once its request is done (a batch's, one per 100 users), with
|
|
9
|
+
* nothing queued or sent in the background, so it's safe in serverless
|
|
10
|
+
* functions. A request is abandoned after `timeoutMs` and retried with capped,
|
|
11
|
+
* jittered backoff on network errors, timeouts, 429 and 5xx; any other error
|
|
12
|
+
* status throws a YouGrowError straight away.
|
|
12
13
|
*/
|
|
13
|
-
export { HEADERS, sign, type Direction } from "./signing.js";
|
|
14
14
|
export type ConsentBasis = "consent" | "soft_opt_in" | "corporate_subscriber" | "none";
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Any subset of a user's state, as a JSON Merge Patch (RFC 7396): fields sent
|
|
17
|
+
* replace YouGrow's, fields left out stay, `null` clears, and `steps`, `facts`
|
|
18
|
+
* and `traits` merge key by key. Timestamps are ISO 8601 with a zone, e.g.
|
|
19
|
+
* `new Date().toISOString()`.
|
|
20
|
+
*/
|
|
21
|
+
export interface UserPatch {
|
|
22
|
+
email?: string | null;
|
|
23
|
+
firstName?: string | null;
|
|
24
|
+
lastName?: string | null;
|
|
25
|
+
/** An IANA time zone, e.g. "Europe/London". */
|
|
26
|
+
timezone?: string | null;
|
|
27
|
+
/** A BCP 47 locale, e.g. "en-GB". */
|
|
28
|
+
locale?: string | null;
|
|
29
|
+
/** When the account was created. Starts sign-up journeys while inside their window. */
|
|
30
|
+
signedUpAt?: string;
|
|
31
|
+
/** The legal basis for marketing email. */
|
|
32
|
+
consent?: ConsentBasis | null;
|
|
33
|
+
/** false = the person opted out of lifecycle email in your product. */
|
|
34
|
+
subscribed?: boolean;
|
|
35
|
+
/** Never email this person, in any journey (staff, test accounts, invited teammates). */
|
|
36
|
+
excluded?: {
|
|
37
|
+
reason: string;
|
|
38
|
+
} | null;
|
|
39
|
+
/** Onboarding step id → when it was done (null: not done). */
|
|
40
|
+
steps?: Record<string, string | null>;
|
|
41
|
+
/** Fact id → its latest value (null removes it). */
|
|
42
|
+
facts?: Record<string, number | string | boolean | null>;
|
|
43
|
+
/** Anything else journeys branch on (null removes a key). */
|
|
44
|
+
traits?: Record<string, string | number | boolean | null>;
|
|
45
|
+
/** When you read this state. A write older than the stored one is ignored. */
|
|
46
|
+
updatedAt?: string;
|
|
47
|
+
}
|
|
48
|
+
/** A user's state as YouGrow holds it. */
|
|
49
|
+
export interface UserState {
|
|
50
|
+
userId: string;
|
|
51
|
+
email: string | null;
|
|
52
|
+
firstName: string | null;
|
|
53
|
+
lastName: string | null;
|
|
54
|
+
timezone: string | null;
|
|
55
|
+
locale: string | null;
|
|
56
|
+
signedUpAt: string | null;
|
|
57
|
+
consent: ConsentBasis | null;
|
|
58
|
+
subscribed: boolean;
|
|
59
|
+
excluded: {
|
|
60
|
+
reason: string;
|
|
61
|
+
} | null;
|
|
62
|
+
steps: Record<string, string>;
|
|
63
|
+
facts: Record<string, string | number | boolean>;
|
|
64
|
+
traits: Record<string, string | number | boolean>;
|
|
65
|
+
updatedAt: string | null;
|
|
66
|
+
}
|
|
67
|
+
/** `users.get` adds what YouGrow decided: journeys and opt-outs. */
|
|
68
|
+
export interface UserView extends UserState {
|
|
69
|
+
enrolments: Array<{
|
|
70
|
+
journeyId: string;
|
|
71
|
+
status: string;
|
|
72
|
+
mode: string;
|
|
73
|
+
enrolledAt: string;
|
|
74
|
+
}>;
|
|
75
|
+
/** Unsubscribes made in YouGrow's emails. They hold until the person lifts them; the API can't. */
|
|
76
|
+
optOuts: Array<{
|
|
77
|
+
scope: "all" | "category";
|
|
78
|
+
category: string | null;
|
|
79
|
+
at: string | null;
|
|
80
|
+
}>;
|
|
81
|
+
}
|
|
82
|
+
/** Why a write was ignored: older than the stored state, or than the user's deletion. */
|
|
83
|
+
export type SkipReason = "stale_write" | "deleted_later";
|
|
84
|
+
export type PatchResponse = {
|
|
85
|
+
applied: true;
|
|
86
|
+
user: UserState;
|
|
87
|
+
} | {
|
|
88
|
+
applied: false;
|
|
89
|
+
reason: SkipReason;
|
|
90
|
+
storedUpdatedAt?: string | null;
|
|
91
|
+
user?: UserState;
|
|
92
|
+
};
|
|
93
|
+
/** What was wrong with one field, e.g. `{ path: "traits.plan", message: "…" }`. */
|
|
94
|
+
export interface FieldError {
|
|
95
|
+
path: string;
|
|
96
|
+
message: string;
|
|
97
|
+
}
|
|
98
|
+
/** One user in `users.batch`: their id plus a patch. */
|
|
99
|
+
export interface BatchItem extends UserPatch {
|
|
100
|
+
userId: string;
|
|
101
|
+
}
|
|
102
|
+
export interface BatchResponse {
|
|
103
|
+
applied: number;
|
|
104
|
+
ignored: number;
|
|
105
|
+
failed: number;
|
|
106
|
+
/** Only the ignored and failed items; applied ones are counted. `index` is the item's position in your array. */
|
|
107
|
+
results: Array<{
|
|
108
|
+
index: number;
|
|
109
|
+
userId: string | null;
|
|
110
|
+
status: "ignored" | "failed";
|
|
111
|
+
reason: string;
|
|
112
|
+
fields?: FieldError[];
|
|
113
|
+
}>;
|
|
114
|
+
}
|
|
115
|
+
export interface EventResult {
|
|
116
|
+
recorded: boolean;
|
|
117
|
+
duplicate: boolean;
|
|
118
|
+
}
|
|
16
119
|
export interface YouGrowOptions {
|
|
120
|
+
/** Your connection's key id (`ygk_…`). */
|
|
17
121
|
keyId: string;
|
|
122
|
+
/** Its secret (`ygs_…`). Server-side only. */
|
|
18
123
|
secret: string;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
124
|
+
/**
|
|
125
|
+
* YouGrow's origin, e.g. from YOUGROW_ORIGIN. Defaults to https://yougrow.ai;
|
|
126
|
+
* set it when you're connected to another YouGrow instance (staging, self-hosted).
|
|
127
|
+
*/
|
|
128
|
+
origin?: string;
|
|
129
|
+
/** Abandon a request after this long (default 10000); it's retried like a network error. */
|
|
130
|
+
timeoutMs?: number;
|
|
131
|
+
/** Retries per request for network errors, timeouts, 429 and 5xx (default 3). */
|
|
26
132
|
maxRetries?: number;
|
|
133
|
+
/** Longest wait between retries (default 5000), even when Retry-After asks for more. */
|
|
134
|
+
maxRetryWaitMs?: number;
|
|
27
135
|
/** Custom fetch (tests, proxies). */
|
|
28
136
|
fetch?: typeof fetch;
|
|
29
|
-
/** Called when a background flush fails. */
|
|
30
|
-
onError?: (err: Error) => void;
|
|
31
137
|
}
|
|
32
|
-
export interface
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
source?: string;
|
|
38
|
-
};
|
|
39
|
-
timestamp?: Date | string;
|
|
40
|
-
messageId?: string;
|
|
138
|
+
export interface BatchOptions {
|
|
139
|
+
/** Don't log the console.warn line about failed items. */
|
|
140
|
+
quiet?: boolean;
|
|
141
|
+
/** Throw a YouGrowBatchError, carrying the result, if any item failed. Every item is still sent. */
|
|
142
|
+
throwOnItemError?: boolean;
|
|
41
143
|
}
|
|
42
|
-
export interface
|
|
43
|
-
userId: string;
|
|
44
|
-
event: string;
|
|
144
|
+
export interface TrackOptions {
|
|
45
145
|
properties?: Record<string, unknown>;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
146
|
+
/** When it happened (ISO 8601 with a zone). */
|
|
147
|
+
occurredAt?: string;
|
|
148
|
+
/** The same key is recorded once. Defaults to a random key per call, so the SDK's own retries never count twice. */
|
|
149
|
+
idempotencyKey?: string;
|
|
49
150
|
}
|
|
50
|
-
export interface
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
151
|
+
export interface UsersApi {
|
|
152
|
+
/** Merge a patch into one user's state; creates the user if YouGrow hasn't seen them. */
|
|
153
|
+
update(userId: string, patch: UserPatch): Promise<PatchResponse>;
|
|
154
|
+
/**
|
|
155
|
+
* Patch any number of users, 100 per request, one request after another. One
|
|
156
|
+
* bad item never fails the rest: the result lists the ignored and failed ones,
|
|
157
|
+
* and failures are logged in one console.warn line (see BatchOptions).
|
|
158
|
+
*/
|
|
159
|
+
batch(items: readonly BatchItem[], opts?: BatchOptions): Promise<BatchResponse>;
|
|
160
|
+
/** The user's state, journeys and opt-outs, or null if YouGrow doesn't know them. */
|
|
161
|
+
get(userId: string): Promise<UserView | null>;
|
|
162
|
+
/** Erase the user and their history. Safe to repeat, and for users YouGrow never saw. */
|
|
163
|
+
delete(userId: string): Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
export interface EventsApi {
|
|
166
|
+
/** Record a milestone, e.g. "report.exported". Optional: journeys run on state. */
|
|
167
|
+
track(userId: string, event: string, opts?: TrackOptions): Promise<EventResult>;
|
|
58
168
|
}
|
|
169
|
+
/** The API refused a request, or still failed (429, 5xx) after the retries. */
|
|
59
170
|
export declare class YouGrowError extends Error {
|
|
60
171
|
readonly status: number;
|
|
61
172
|
readonly body?: unknown | undefined;
|
|
173
|
+
/** The API's error code (`invalid`, `unauthorized`, `rate_limited`…), or `http_<status>` when the body has none. */
|
|
174
|
+
readonly code: string;
|
|
175
|
+
/** For a 400: which fields were wrong, and why. */
|
|
176
|
+
readonly fields?: FieldError[];
|
|
62
177
|
constructor(message: string, status: number, body?: unknown | undefined);
|
|
63
178
|
}
|
|
179
|
+
/** From `users.batch` with `throwOnItemError`: some items failed. The rest were applied; `result` says which. */
|
|
180
|
+
export declare class YouGrowBatchError extends Error {
|
|
181
|
+
readonly result: BatchResponse;
|
|
182
|
+
constructor(message: string, result: BatchResponse);
|
|
183
|
+
}
|
|
64
184
|
export declare class YouGrow {
|
|
65
|
-
private
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
private readonly flushAt;
|
|
69
|
-
private readonly flushIntervalMs;
|
|
70
|
-
private readonly maxRetries;
|
|
71
|
-
private readonly fetchImpl;
|
|
72
|
-
private readonly onError?;
|
|
73
|
-
private queue;
|
|
74
|
-
private timer;
|
|
185
|
+
#private;
|
|
186
|
+
readonly users: UsersApi;
|
|
187
|
+
readonly events: EventsApi;
|
|
75
188
|
constructor(opts: YouGrowOptions);
|
|
76
|
-
/** Who the user is: email, name, timezone, plan… plus the consent basis. Returns the messageId. */
|
|
77
|
-
identify(input: IdentifyInput): string;
|
|
78
|
-
/** Something the user did. Returns the messageId. */
|
|
79
|
-
track(input: TrackInput): string;
|
|
80
|
-
/** Shorthand for the reserved `onboarding.step_completed` event. */
|
|
81
|
-
stepCompleted(userId: string, step: string, timestamp?: Date | string): string;
|
|
82
|
-
/** Send everything queued. Resolves with one result per request. */
|
|
83
|
-
flush(): Promise<IngestResult[]>;
|
|
84
|
-
/** Flush and stop the background timer (call on shutdown). */
|
|
85
|
-
close(): Promise<IngestResult[]>;
|
|
86
|
-
private enqueue;
|
|
87
|
-
private clearTimer;
|
|
88
|
-
private send;
|
|
89
189
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,156 +1,240 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* const yg = new YouGrow({ keyId: process.env.YOUGROW_KEY_ID!, secret: process.env.YOUGROW_SECRET! });
|
|
7
|
-
* yg.identify({ userId: user.id, traits: { email: user.email }, consent: { basis: "soft_opt_in" } });
|
|
8
|
-
* yg.track({ userId: user.id, event: "user.signed_up" });
|
|
9
|
-
* await yg.flush();
|
|
10
|
-
*
|
|
11
|
-
* Server-side only (the secret signs every request). Messages are batched (≤100
|
|
12
|
-
* per request) and retried with backoff on network errors, 429 and 5xx. Every
|
|
13
|
-
* message carries a messageId, so a retried or duplicated send is harmless.
|
|
14
|
-
*/
|
|
15
|
-
export { HEADERS, sign } from "./signing.js";
|
|
2
|
+
import { isSecureOrigin, originOf } from "./origin.js";
|
|
3
|
+
const USERS_PATH = "/api/v2/users";
|
|
4
|
+
/** The API's limits: users per batch request, and bytes per request body. */
|
|
16
5
|
const MAX_BATCH = 100;
|
|
6
|
+
const MAX_BODY_BYTES = 512 * 1024;
|
|
7
|
+
const BATCH_ENVELOPE_BYTES = '{"users":[]}'.length;
|
|
8
|
+
const MAX_USER_ID = 256;
|
|
9
|
+
/** The longest delay a Node timer accepts. */
|
|
10
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
11
|
+
/** The API refused a request, or still failed (429, 5xx) after the retries. */
|
|
17
12
|
export class YouGrowError extends Error {
|
|
18
13
|
status;
|
|
19
14
|
body;
|
|
15
|
+
/** The API's error code (`invalid`, `unauthorized`, `rate_limited`…), or `http_<status>` when the body has none. */
|
|
16
|
+
code;
|
|
17
|
+
/** For a 400: which fields were wrong, and why. */
|
|
18
|
+
fields;
|
|
20
19
|
constructor(message, status, body) {
|
|
21
20
|
super(message);
|
|
22
21
|
this.status = status;
|
|
23
22
|
this.body = body;
|
|
24
23
|
this.name = "YouGrowError";
|
|
24
|
+
const b = asRecord(body);
|
|
25
|
+
this.code = typeof b?.error === "string" ? b.error : `http_${status}`;
|
|
26
|
+
if (Array.isArray(b?.fields))
|
|
27
|
+
this.fields = b.fields;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** From `users.batch` with `throwOnItemError`: some items failed. The rest were applied; `result` says which. */
|
|
31
|
+
export class YouGrowBatchError extends Error {
|
|
32
|
+
result;
|
|
33
|
+
constructor(message, result) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.result = result;
|
|
36
|
+
this.name = "YouGrowBatchError";
|
|
25
37
|
}
|
|
26
38
|
}
|
|
27
39
|
export class YouGrow {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
timer = null;
|
|
40
|
+
users;
|
|
41
|
+
events;
|
|
42
|
+
#origin;
|
|
43
|
+
/** Private (#), so logging the client never prints the credentials. */
|
|
44
|
+
#authorization;
|
|
45
|
+
#timeoutMs;
|
|
46
|
+
#maxRetries;
|
|
47
|
+
#maxRetryWaitMs;
|
|
48
|
+
#fetch;
|
|
38
49
|
constructor(opts) {
|
|
39
50
|
if (!opts.keyId || !opts.secret)
|
|
40
51
|
throw new Error("YouGrow: keyId and secret are required");
|
|
41
|
-
this
|
|
42
|
-
this
|
|
43
|
-
|
|
44
|
-
this
|
|
45
|
-
this
|
|
46
|
-
this
|
|
47
|
-
this
|
|
48
|
-
this
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
/** Send everything queued. Resolves with one result per request. */
|
|
78
|
-
async flush() {
|
|
79
|
-
this.clearTimer();
|
|
80
|
-
const results = [];
|
|
81
|
-
while (this.queue.length > 0) {
|
|
82
|
-
const batch = this.queue.splice(0, MAX_BATCH);
|
|
83
|
-
results.push(await this.send(batch));
|
|
84
|
-
}
|
|
85
|
-
return results;
|
|
86
|
-
}
|
|
87
|
-
/** Flush and stop the background timer (call on shutdown). */
|
|
88
|
-
async close() {
|
|
89
|
-
return this.flush();
|
|
52
|
+
this.#origin = originOf(opts.origin);
|
|
53
|
+
if (!isSecureOrigin(this.#origin))
|
|
54
|
+
throw new Error("YouGrow: origin must be https, e.g. https://yougrow.ai");
|
|
55
|
+
this.#authorization = `Basic ${Buffer.from(`${opts.keyId}:${opts.secret}`, "utf8").toString("base64")}`;
|
|
56
|
+
this.#timeoutMs = whole(opts.timeoutMs, 10_000, 1, MAX_TIMER_MS);
|
|
57
|
+
this.#maxRetries = whole(opts.maxRetries, 3, 0, Number.MAX_SAFE_INTEGER);
|
|
58
|
+
this.#maxRetryWaitMs = whole(opts.maxRetryWaitMs, 5_000, 0, MAX_TIMER_MS);
|
|
59
|
+
this.#fetch = opts.fetch ?? globalThis.fetch;
|
|
60
|
+
this.users = {
|
|
61
|
+
update: async (userId, patch) => (await this.#send("PATCH", userPath(userId), JSON.stringify(patch))),
|
|
62
|
+
batch: (items, batchOpts) => this.#patchMany(items, batchOpts),
|
|
63
|
+
get: async (userId) => {
|
|
64
|
+
try {
|
|
65
|
+
return (await this.#send("GET", userPath(userId)));
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
// Only the API's own "no such user": a 404 from anything else (e.g. a wrong origin) still throws.
|
|
69
|
+
if (err instanceof YouGrowError && err.status === 404 && err.code === "not_found")
|
|
70
|
+
return null;
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
delete: async (userId) => {
|
|
75
|
+
await this.#send("DELETE", userPath(userId));
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
this.events = {
|
|
79
|
+
track: async (userId, event, options = {}) => {
|
|
80
|
+
const path = `${userPath(userId)}/events`;
|
|
81
|
+
const { properties, occurredAt } = options;
|
|
82
|
+
// One key for every attempt, so a retry is never recorded twice.
|
|
83
|
+
const body = { event, properties, occurredAt, idempotencyKey: options.idempotencyKey ?? randomUUID() };
|
|
84
|
+
return (await this.#send("POST", path, JSON.stringify(body)));
|
|
85
|
+
},
|
|
86
|
+
};
|
|
90
87
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
async #patchMany(items, opts = {}) {
|
|
89
|
+
if (!Array.isArray(items))
|
|
90
|
+
throw new TypeError("YouGrow: users.batch takes an array of { userId, ...patch }");
|
|
91
|
+
const total = { applied: 0, ignored: 0, failed: 0, results: [] };
|
|
92
|
+
for (const chunk of chunks(items)) {
|
|
93
|
+
if (chunk.bytes > MAX_BODY_BYTES) {
|
|
94
|
+
// One item bigger than any request may be: it can't be valid, so it fails here.
|
|
95
|
+
const userId = asRecord(items[chunk.start])?.userId;
|
|
96
|
+
total.failed += 1;
|
|
97
|
+
total.results.push({ index: chunk.start, userId: typeof userId === "string" ? userId : null, status: "failed", reason: "body_too_large" });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const r = (await this.#send("POST", `${USERS_PATH}/batch`, `{"users":[${chunk.json.join(",")}]}`));
|
|
101
|
+
total.applied += r.applied;
|
|
102
|
+
total.ignored += r.ignored;
|
|
103
|
+
total.failed += r.failed;
|
|
104
|
+
for (const item of r.results)
|
|
105
|
+
total.results.push({ ...item, index: chunk.start + item.index });
|
|
95
106
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
if (total.failed > 0) {
|
|
108
|
+
const summary = failureSummary(total, items.length);
|
|
109
|
+
if (opts.throwOnItemError)
|
|
110
|
+
throw new YouGrowBatchError(`YouGrow ${summary}`, total);
|
|
111
|
+
if (!opts.quiet)
|
|
112
|
+
console.warn(`[@yougrowai/node] ${summary}`);
|
|
102
113
|
}
|
|
103
|
-
return
|
|
114
|
+
return total;
|
|
104
115
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
116
|
+
/**
|
|
117
|
+
* One request, retried on network errors, timeouts, 429 and 5xx. Resolves
|
|
118
|
+
* with the JSON body (none for DELETE); an error status throws a YouGrowError.
|
|
119
|
+
*/
|
|
120
|
+
async #send(method, path, body) {
|
|
121
|
+
const headers = { authorization: this.#authorization, accept: "application/json" };
|
|
122
|
+
if (body !== undefined)
|
|
123
|
+
headers["content-type"] = "application/json";
|
|
124
|
+
const doFetch = this.#fetch; // called unbound: some fetch implementations refuse another `this`
|
|
112
125
|
for (let attempt = 0;; attempt += 1) {
|
|
113
|
-
// Sign every attempt afresh: the timestamp must stay inside the 5-minute window.
|
|
114
|
-
const ts = Math.floor(Date.now() / 1000);
|
|
115
126
|
let res;
|
|
127
|
+
let text;
|
|
116
128
|
try {
|
|
117
|
-
res = await this.
|
|
118
|
-
|
|
119
|
-
headers: {
|
|
120
|
-
"content-type": "application/json",
|
|
121
|
-
[HEADERS.keyId]: this.keyId,
|
|
122
|
-
[HEADERS.timestamp]: String(ts),
|
|
123
|
-
[HEADERS.signature]: sign(this.secret, "events", ts, body),
|
|
124
|
-
},
|
|
125
|
-
body,
|
|
126
|
-
});
|
|
129
|
+
res = await doFetch(`${this.#origin}${path}`, { method, headers, body, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
130
|
+
text = await res.text(); // under the same deadline
|
|
127
131
|
}
|
|
128
132
|
catch (err) {
|
|
129
|
-
|
|
133
|
+
// Network error or timeout.
|
|
134
|
+
if (attempt >= this.#maxRetries)
|
|
130
135
|
throw err;
|
|
131
|
-
await
|
|
136
|
+
await sleep(this.#retryWait(attempt));
|
|
132
137
|
continue;
|
|
133
138
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
throw new YouGrowError(`YouGrow ingest failed: ${code}`, res.status, data);
|
|
139
|
+
const data = parseJson(text);
|
|
140
|
+
if (res.ok) {
|
|
141
|
+
if (method !== "DELETE" && !asRecord(data)) {
|
|
142
|
+
throw new YouGrowError(`YouGrow API ${res.status}: the response isn't JSON (is origin right?)`, res.status);
|
|
143
|
+
}
|
|
144
|
+
return data;
|
|
141
145
|
}
|
|
142
|
-
|
|
146
|
+
if ((res.status === 429 || res.status >= 500) && attempt < this.#maxRetries) {
|
|
147
|
+
await sleep(this.#retryWait(attempt, res.headers.get("retry-after")));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
throw apiError(res.status, data);
|
|
143
151
|
}
|
|
144
152
|
}
|
|
153
|
+
/** Retry-After (seconds) if given, else exponential backoff with full jitter; never over maxRetryWaitMs. */
|
|
154
|
+
#retryWait(attempt, retryAfter) {
|
|
155
|
+
const seconds = retryAfter?.trim();
|
|
156
|
+
if (seconds && /^\d+$/.test(seconds))
|
|
157
|
+
return Math.min(Number(seconds) * 1000, this.#maxRetryWaitMs);
|
|
158
|
+
return Math.random() * Math.min(500 * 2 ** attempt, this.#maxRetryWaitMs);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** `/api/v2/users/{userId}`, refusing an id the API can't take before anything is sent. */
|
|
162
|
+
function userPath(userId) {
|
|
163
|
+
const problem = userIdProblem(userId);
|
|
164
|
+
if (problem)
|
|
165
|
+
throw new TypeError(`YouGrow: userId ${problem}`);
|
|
166
|
+
return `${USERS_PATH}/${encodeURIComponent(userId)}`;
|
|
167
|
+
}
|
|
168
|
+
function userIdProblem(userId) {
|
|
169
|
+
if (typeof userId !== "string")
|
|
170
|
+
return "must be a string";
|
|
171
|
+
if (userId.length === 0)
|
|
172
|
+
return "is empty";
|
|
173
|
+
if (userId.length > MAX_USER_ID)
|
|
174
|
+
return `is over ${MAX_USER_ID} characters`;
|
|
175
|
+
// "batch" is a route; "." and ".." vanish from a URL path.
|
|
176
|
+
if (userId === "batch" || userId === "." || userId === "..")
|
|
177
|
+
return `can't be "${userId}"`;
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Consecutive runs of batch items, one request each: at most 100 users and
|
|
182
|
+
* 512 KB of body. A run over the limit is a single item too big to send.
|
|
183
|
+
*/
|
|
184
|
+
function* chunks(items) {
|
|
185
|
+
let chunk = { start: 0, json: [], bytes: BATCH_ENVELOPE_BYTES };
|
|
186
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
187
|
+
const json = JSON.stringify(items[i]) ?? "null";
|
|
188
|
+
const size = Buffer.byteLength(json, "utf8");
|
|
189
|
+
// + 1 for the comma before it.
|
|
190
|
+
if (chunk.json.length === MAX_BATCH || (chunk.json.length > 0 && chunk.bytes + 1 + size > MAX_BODY_BYTES)) {
|
|
191
|
+
yield chunk;
|
|
192
|
+
chunk = { start: i, json: [], bytes: BATCH_ENVELOPE_BYTES };
|
|
193
|
+
}
|
|
194
|
+
chunk.bytes += (chunk.json.length > 0 ? 1 : 0) + size;
|
|
195
|
+
chunk.json.push(json);
|
|
196
|
+
}
|
|
197
|
+
if (chunk.json.length > 0)
|
|
198
|
+
yield chunk;
|
|
199
|
+
}
|
|
200
|
+
/** e.g. "users.batch: 2 of 250 users failed (u3: invalid, u17: invalid)". Ids and reasons only, never the data. */
|
|
201
|
+
function failureSummary(r, count) {
|
|
202
|
+
const failed = r.results.filter((x) => x.status === "failed");
|
|
203
|
+
const shown = failed.slice(0, 3).map((x) => `${x.userId ?? `item ${x.index}`}: ${x.reason}`);
|
|
204
|
+
if (failed.length > 3)
|
|
205
|
+
shown.push(`+${failed.length - 3} more`);
|
|
206
|
+
return `users.batch: ${r.failed} of ${count} users failed${shown.length > 0 ? ` (${shown.join(", ")})` : ""}`;
|
|
207
|
+
}
|
|
208
|
+
/** e.g. "YouGrow API 400 invalid: traits.plan: …". */
|
|
209
|
+
function apiError(status, body) {
|
|
210
|
+
const b = asRecord(body);
|
|
211
|
+
const fields = Array.isArray(b?.fields) ? b.fields : [];
|
|
212
|
+
let detail = fields
|
|
213
|
+
.slice(0, 3)
|
|
214
|
+
.map((f) => `${f.path}: ${f.message}`)
|
|
215
|
+
.join("; ");
|
|
216
|
+
if (fields.length > 3)
|
|
217
|
+
detail += `; +${fields.length - 3} more`;
|
|
218
|
+
if (!detail && typeof b?.message === "string")
|
|
219
|
+
detail = b.message;
|
|
220
|
+
const code = typeof b?.error === "string" ? ` ${b.error}` : "";
|
|
221
|
+
return new YouGrowError(`YouGrow API ${status}${code}${detail ? `: ${detail}` : ""}`, status, body);
|
|
222
|
+
}
|
|
223
|
+
function asRecord(v) {
|
|
224
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
225
|
+
}
|
|
226
|
+
function parseJson(text) {
|
|
227
|
+
try {
|
|
228
|
+
return text ? JSON.parse(text) : undefined;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
145
233
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return typeof t === "string" ? t : t.toISOString();
|
|
234
|
+
/** A whole number in [min, max] from an option; unset or not a number means the default. */
|
|
235
|
+
function whole(value, fallback, min, max) {
|
|
236
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(Math.floor(value), min), max) : fallback;
|
|
150
237
|
}
|
|
151
|
-
|
|
152
|
-
function backoff(attempt, retryAfter) {
|
|
153
|
-
const hinted = retryAfter && /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 : 0;
|
|
154
|
-
const ms = hinted || Math.random() * Math.min(500 * 2 ** attempt, 10_000);
|
|
238
|
+
function sleep(ms) {
|
|
155
239
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
156
240
|
}
|
package/dist/jwt.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* dir "context" | "webhook"
|
|
9
9
|
* iat / exp at most 5 minutes apart
|
|
10
10
|
* jti unique per request
|
|
11
|
-
* body_sha256 base64url SHA-256 of the exact raw body
|
|
11
|
+
* body_sha256 base64url SHA-256 of the exact raw body (the bytes as received)
|
|
12
12
|
*
|
|
13
13
|
* Signature first, then every claim. Pinned by test/vectors.json.
|
|
14
14
|
*/
|
|
@@ -48,6 +48,7 @@ export declare function verifyJwt(input: {
|
|
|
48
48
|
issuer: string;
|
|
49
49
|
audience: string;
|
|
50
50
|
direction: RequestDirection;
|
|
51
|
-
|
|
51
|
+
/** The bytes as received; a string is hashed as UTF-8. */
|
|
52
|
+
rawBody: string | Uint8Array;
|
|
52
53
|
nowMs?: number;
|
|
53
54
|
}): JwtResult;
|
package/dist/jwt.js
CHANGED
|
@@ -67,7 +67,8 @@ export function verifyJwt(input) {
|
|
|
67
67
|
return { ok: false, reason: "expired" };
|
|
68
68
|
if (nowSec < c.iat - LEEWAY_SEC)
|
|
69
69
|
return { ok: false, reason: "not_yet_valid" };
|
|
70
|
-
const
|
|
70
|
+
const bytes = typeof input.rawBody === "string" ? Buffer.from(input.rawBody, "utf8") : input.rawBody;
|
|
71
|
+
const hash = createHash("sha256").update(bytes).digest("base64url");
|
|
71
72
|
if (c.body_sha256 !== hash)
|
|
72
73
|
return { ok: false, reason: "body_mismatch" };
|
|
73
74
|
return { ok: true, claims: c };
|