@avsbhq/node 1.6.2 → 1.7.1

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/dist/index.d.ts CHANGED
@@ -1,54 +1,172 @@
1
- import { FlagDatafile, StickyBucketService, Logger, EvaluationSource, StickyAssignment, DecideOption, Flag, TrackPayload, DecisionLog, InitResult, EvalContext } from '@avsbhq/core';
1
+ import { LogLevel, Logger, FlagDatafile, StickyBucketService, DecideOption, DecisionLog, Flag, ExposureEvent, StickyAssignment, TrackPayload, EvalContext, InitResult } from '@avsbhq/core';
2
2
  export { DatafileDataset, DatafileDatasetType, DecideOption, DecisionLog, DecisionLogEntry, EvalContext, EvaluationSource, ExposureEvent, Flag, FlagDatafile, FlagEvaluationDetails, InitResult, Logger, MetricEvent, MultiContext, PurchaseHistoryAttribute, RuleType, SingleContext, StickyAssignment, StickyBucketService, TrackPayload, consoleLogger, consoleTransport, createLogger, noopLogger } from '@avsbhq/core';
3
3
  import { AvsbUtils } from '@avsbhq/utils';
4
4
  export { AvsbUtils } from '@avsbhq/utils';
5
+ export { ExpressAvsbServer, ExpressMiddlewareOptions, expressMiddleware } from '@avsbhq/utils/middleware/express';
6
+ export { FastifyAvsbServer, FastifyPluginOptions, fastifyPlugin } from '@avsbhq/utils/middleware/fastify';
5
7
  export { StreamingClient, StreamingOptions, createStreamingClient } from '@avsbhq/utils/streaming';
6
8
 
9
+ /**
10
+ * Server-side datasets read client (e-commerce suite Phase 2).
11
+ *
12
+ * Resolves the live version of a dataset from the SDK's datafile
13
+ * (`datasets` field) and fetches a single key from the edge worker:
14
+ *
15
+ * GET {ingestionHost}/v1/ds/{sdkKey}/{slug}/{versionShortId}/{key}
16
+ *
17
+ * The ingestion host is derived from the datafile's `collectEndpoint`,
18
+ * exactly like the server tracker derives `/v1/collect/*`. The worker's
19
+ * key cache resolves SDK keys as well as snippet keys in the first path
20
+ * segment.
21
+ *
22
+ * Error posture matches flag evaluation: this client NEVER throws.
23
+ * Unknown slug, missing live version, SDK not ready, HTTP errors and
24
+ * network failures all degrade to `{ found: false }`.
25
+ */
26
+ interface DatasetGetResult {
27
+ found: boolean;
28
+ /** Parsed row for TABLE/FEED datasets; `true` for LIST membership hits. */
29
+ value?: unknown;
30
+ meta: {
31
+ slug: string;
32
+ /** Live version short id used for the lookup; null when unresolvable. */
33
+ version: number | null;
34
+ };
35
+ }
36
+ interface DatasetsClient {
37
+ /**
38
+ * Look up `key` in the live version of dataset `slug`.
39
+ * Resolves the version from the current datafile and never throws.
40
+ */
41
+ get(slug: string, key: string): Promise<DatasetGetResult>;
42
+ }
43
+
44
+ /** `logLevel` option values. 'silent' selects the noop logger. */
45
+ type ServerLogLevel = LogLevel | 'silent';
46
+ /**
47
+ * True when this process looks like a development environment. Exported so
48
+ * the behaviour is directly testable and so a caller can mirror the decision.
49
+ */
50
+ declare function isDevEnvironment(): boolean;
51
+ /**
52
+ * The logger used when the caller passed neither `logger` nor `logLevel`:
53
+ * console at warn level in development, noop otherwise.
54
+ */
55
+ declare function defaultLogger(): Logger;
56
+ /** Resolve the constructor's logging options into one Logger. */
57
+ declare function resolveServerLogger(options: {
58
+ logger?: Logger;
59
+ logLevel?: ServerLogLevel;
60
+ }): Logger;
61
+
7
62
  interface AvsbServerOptions {
8
- /** SDK key for the flag environment (e.g., "env_abc123") */
63
+ /**
64
+ * SDK key for the environment, shaped `sdk_<environment>_<id>`, for example
65
+ * `sdk_production_ttqm0eaj4vth1krcb2xn`. Copy it from app.avsb.cloud, then
66
+ * Settings, then Environments. The shape is checked at construction and an
67
+ * unrecognisable value is logged (never thrown) with what looked wrong.
68
+ */
9
69
  sdkKey: string;
10
70
  /** Datafile polling interval in ms. Default: 60_000 (60s). Min: 5_000. */
11
71
  pollingInterval?: number;
12
72
  /** CDN host for datafile fetches. Default: "https://cdn.avsb.cloud" */
13
73
  cdnHost?: string;
14
- /** Max ms to wait for initial datafile fetch in onReady(). Default: 10_000. */
74
+ /** Max ms to wait for the initial datafile fetch in onReady(). Default: 10_000. */
15
75
  initTimeout?: number;
16
- /** Pre-fetched datafile to skip initial network request. */
76
+ /** Pre-fetched datafile, so onReady() resolves without a network request. */
17
77
  bootstrap?: FlagDatafile;
18
- /** Enable SSE streaming for real-time datafile updates. Default: false. */
78
+ /**
79
+ * Real-time datafile updates over SSE. Default: TRUE on the server
80
+ * (breaking change B12): server connection counts are low, one SSE stream
81
+ * per process is cheaper than polling, and flag changes then land instantly.
82
+ *
83
+ * Falling back is invisible: if the datafile carries no `streamEndpoint` and
84
+ * no `streamingEndpoint` option was passed, or the stream cannot be opened,
85
+ * the SDK keeps polling and says so at debug level. Flags still update, just
86
+ * on the polling interval. Pass `false` to force polling only.
87
+ */
19
88
  streaming?: boolean;
20
- /** Custom SSE endpoint URL. Default: derived from app URL. */
89
+ /**
90
+ * Stream TICKET endpoint. Overrides the datafile's `streamEndpoint` field.
91
+ * Only needed for a self-hosted or proxied deployment whose publisher does
92
+ * not set the field; nothing is ever guessed from another host.
93
+ */
21
94
  streamingEndpoint?: string;
22
95
  /** Event tracking configuration. */
23
96
  events?: EventOptions;
24
97
  /** Pluggable sticky bucketing service. */
25
98
  stickyBucketService?: StickyBucketService;
26
- /** Custom logger. Default: silent (no output). */
99
+ /**
100
+ * Custom logger. Default: console at warn level when NODE_ENV is neither
101
+ * 'production' nor 'test', noop otherwise.
102
+ */
27
103
  logger?: Logger;
104
+ /**
105
+ * Shorthand for the default logger's level. `'silent'` restores the old
106
+ * no-output behaviour. Ignored when `logger` is passed.
107
+ */
108
+ logLevel?: ServerLogLevel;
28
109
  }
29
110
  interface EventOptions {
30
111
  /** Ms between automatic event flushes. Default: 2000. */
31
112
  flushInterval?: number;
32
113
  /** Max events per HTTP request. Default: 10. */
33
114
  batchSize?: number;
34
- /** Max events in queue. Oldest dropped when exceeded. Default: 10_000. */
115
+ /**
116
+ * Max events held in a queue. Oldest dropped when exceeded, with a warning.
117
+ * Applies to the tracker's queues AND to the pre-ready queue that holds
118
+ * events tracked before the first datafile arrives. Default: 10_000.
119
+ */
35
120
  maxQueueSize?: number;
36
121
  /** Retry attempts on flush failure. Default: 3. */
37
122
  retryAttempts?: number;
38
123
  }
124
+
125
+ /**
126
+ * Options for a single evaluation. The CONTEXT is not in here: it is the third
127
+ * positional argument of every evaluation call (breaking change B3), matching
128
+ * `@avsbhq/edge`, so one shape reads the same in every server SDK.
129
+ */
130
+ interface ServerGetFlagOptions {
131
+ /** Per-call decide options. See ./decideOptions.ts for the two members. */
132
+ decideOptions?: DecideOption[];
133
+ /** Append this evaluation to a decision log. */
134
+ decisionLog?: DecisionLog;
135
+ }
136
+ interface GetAllFlagsOptions {
137
+ /**
138
+ * Record an exposure for every flag evaluated. Default FALSE: reading the
139
+ * whole flag set is a diagnostic or an SSR bootstrap, not a decision a
140
+ * visitor saw, and firing hundreds of exposures per request would corrupt
141
+ * every experiment in the project.
142
+ */
143
+ fireExposures?: boolean;
144
+ decideOptions?: DecideOption[];
145
+ /** Append every evaluation to a decision log. */
146
+ decisionLog?: DecisionLog;
147
+ }
39
148
  type ServerEventMap = {
40
149
  ready: () => void;
150
+ /** A newly published datafile was applied, and how it arrived. */
41
151
  configUpdate: (data: {
42
152
  publishedAt: string;
153
+ reason: 'poll' | 'stream' | 'manual';
43
154
  }) => void;
155
+ /** One flag was evaluated. Fires whether or not an exposure was recorded. */
44
156
  evaluation: (data: {
45
157
  flagKey: string;
46
- value: unknown;
47
- userId: string;
48
- source: EvaluationSource;
158
+ flag: Flag;
159
+ contextKeys: string[];
49
160
  }) => void;
161
+ /**
162
+ * An exposure was recorded and queued for delivery. This is the server-side
163
+ * seam for forwarding decisions to your own analytics: it carries the SAME
164
+ * `eventId` the ingestion pipeline receives, so rows reconcile across
165
+ * systems.
166
+ */
167
+ exposure: (event: ExposureEvent) => void;
50
168
  error: (error: Error) => void;
51
- /** Fired when a sticky assignment references a rule that no longer matches the datafile. */
169
+ /** A sticky assignment references a rule that no longer matches the datafile. */
52
170
  stickyOrphan: (data: {
53
171
  flagKey: string;
54
172
  userId: string;
@@ -56,12 +174,6 @@ type ServerEventMap = {
56
174
  }) => void;
57
175
  };
58
176
  type ServerEventName = keyof ServerEventMap;
59
- interface MiddlewareConfig {
60
- /** Extract userId from the request. Return undefined to skip AvsB for this request. */
61
- userIdFrom: (req: unknown) => string | undefined;
62
- /** Extract targeting attributes from the request. */
63
- attributesFrom?: (req: unknown) => Record<string, unknown>;
64
- }
65
177
 
66
178
  /**
67
179
  * Purchase order shape for server-side purchase tracking.
@@ -100,41 +212,6 @@ interface PurchaseOrder {
100
212
  }>;
101
213
  }
102
214
 
103
- /**
104
- * Server-side datasets read client (e-commerce suite Phase 2).
105
- *
106
- * Resolves the live version of a dataset from the SDK's datafile
107
- * (`datasets` field) and fetches a single key from the edge worker:
108
- *
109
- * GET {ingestionHost}/v1/ds/{sdkKey}/{slug}/{versionShortId}/{key}
110
- *
111
- * The ingestion host is derived from the datafile's `collectEndpoint`,
112
- * exactly like the server tracker derives `/v1/collect/*`. The worker's
113
- * key cache resolves SDK keys as well as snippet keys in the first path
114
- * segment.
115
- *
116
- * Error posture matches flag evaluation: this client NEVER throws.
117
- * Unknown slug, missing live version, SDK not ready, HTTP errors and
118
- * network failures all degrade to `{ found: false }`.
119
- */
120
- interface DatasetGetResult {
121
- found: boolean;
122
- /** Parsed row for TABLE/FEED datasets; `true` for LIST membership hits. */
123
- value?: unknown;
124
- meta: {
125
- slug: string;
126
- /** Live version short id used for the lookup; null when unresolvable. */
127
- version: number | null;
128
- };
129
- }
130
- interface DatasetsClient {
131
- /**
132
- * Look up `key` in the live version of dataset `slug`.
133
- * Resolves the version from the current datafile and never throws.
134
- */
135
- get(slug: string, key: string): Promise<DatasetGetResult>;
136
- }
137
-
138
215
  /**
139
216
  * Server-side recommendations client (e-commerce suite overhaul, contract C3).
140
217
  *
@@ -225,156 +302,289 @@ interface DatafileRecipe {
225
302
  }
226
303
 
227
304
  /**
228
- * A per-request scoped client with a bound EvalContext.
305
+ * A per-request client with a bound `EvalContext`. THE primary server pattern:
306
+ * bind the context once at the edge of the request, then read flags anywhere
307
+ * inside it without threading identity through every signature.
229
308
  *
230
309
  * Created via `server.forUser(ctx)` or the `server.createUserContext(ctx)`
231
- * alias. All flag evaluations and track calls use the bound context.
310
+ * alias. Every flag and track call uses the bound context, and the instance
311
+ * carries its own decision log.
232
312
  *
233
- * Forced decisions set on a UserBoundClient survive only for the lifetime
234
- * of the instance; they do not affect the shared AvsbServer override state.
235
- * This makes them safe for test middleware and per-request debugging.
236
- *
237
- * Spec §5.8.
313
+ * Forced decisions set here live only for the lifetime of this instance; they
314
+ * never touch the shared `AvsbServer` override state, which is what makes them
315
+ * safe in test middleware and per-request debugging.
238
316
  */
239
317
  interface UserBoundClient {
240
- getFlag<T>(flagKey: string, defaultValue: T, options?: {
241
- decideOptions?: DecideOption[];
242
- }): Flag<T>;
243
- getBoolFlag(flagKey: string, defaultValue: boolean, options?: {
244
- decideOptions?: DecideOption[];
245
- }): Flag<boolean>;
246
- getStringFlag(flagKey: string, defaultValue: string, options?: {
247
- decideOptions?: DecideOption[];
248
- }): Flag<string>;
249
- getNumberFlag(flagKey: string, defaultValue: number, options?: {
250
- decideOptions?: DecideOption[];
251
- }): Flag<number>;
252
- getJsonFlag<T>(flagKey: string, defaultValue: T, options?: {
253
- decideOptions?: DecideOption[];
254
- }): Flag<T>;
318
+ /** Evaluate against the bound context. Returns `Flag<T>`, never a raw value. */
319
+ getFlag<T>(flagKey: string, defaultValue: T, options?: ServerGetFlagOptions): Flag<T>;
320
+ /** Type-checked against the datafile's declared flag type; falls back to the default on a mismatch. */
321
+ getBoolFlag(flagKey: string, defaultValue: boolean, options?: ServerGetFlagOptions): Flag<boolean>;
322
+ getStringFlag(flagKey: string, defaultValue: string, options?: ServerGetFlagOptions): Flag<string>;
323
+ getNumberFlag(flagKey: string, defaultValue: number, options?: ServerGetFlagOptions): Flag<number>;
324
+ getJsonFlag<T>(flagKey: string, defaultValue: T, options?: ServerGetFlagOptions): Flag<T>;
325
+ /** Every flag in the environment for the bound context. Exposures off by default. */
255
326
  getAllFlags(options?: GetAllFlagsOptions): Record<string, Flag>;
327
+ /** Queue a metric event attributed to the bound context. */
256
328
  track(eventKey: string, payload?: Omit<TrackPayload, 'context'>): void;
329
+ /** Send a purchase attributed to the bound context's bucketing key. */
330
+ trackPurchase(order: PurchaseOrder): Promise<void>;
331
+ /** Record an exposure for a decision the visitor has now actually seen. */
332
+ manualExposure(flagKey: string): void;
333
+ /** The context this client is bound to. */
334
+ getContext(): EvalContext;
257
335
  setForcedDecision(flagKey: string, opts: {
258
336
  ruleId?: string;
259
337
  variationKey: string;
260
338
  }): void;
261
339
  clearForcedDecision(flagKey: string): void;
340
+ /** Every decision this request made, redacted per the context's private attributes. */
262
341
  getDecisionLog(): DecisionLog;
263
342
  }
264
343
 
265
- interface GetFlagOptions {
266
- context?: EvalContext;
267
- decideOptions?: DecideOption[];
268
- decisionLog?: DecisionLog;
269
- /** Internal: UserBoundClient forced decision override. */
344
+ /**
345
+ * @internal Superset of the public per-call options, carrying the two things
346
+ * only this package may set. Not exported from the package entry.
347
+ */
348
+ interface InternalGetFlagOptions extends ServerGetFlagOptions {
349
+ /** Forced decision from a UserBoundClient, applied before evaluation. */
270
350
  forcedVariationKey?: string;
271
- }
272
- interface GetAllFlagsOptions {
273
- fireExposures?: boolean;
274
- decideOptions?: DecideOption[];
351
+ /** Runtime type check to apply, from a typed getter. */
352
+ expectedType?: 'boolean' | 'string' | 'number' | 'json';
275
353
  }
276
354
  declare class AvsbServer {
277
- private sdkKey;
278
- private pollingInterval;
279
- private cdnHost;
280
- private initTimeout;
281
- private logger;
282
- private stickyBucketService;
283
- private eventOptions;
284
- private streamingEnabled;
285
- private streamingEndpoint;
355
+ private readonly sdkKey;
356
+ private readonly cdnHost;
357
+ private readonly pollingInterval;
358
+ private readonly initTimeout;
359
+ private readonly logger;
360
+ private readonly stickyBucketService;
361
+ private readonly eventOptions;
362
+ private readonly streamingEnabled;
363
+ private readonly streamingEndpointOption;
364
+ private readonly overrides;
365
+ private readonly resolveDecideOptions;
286
366
  private datafile;
367
+ private datafileEtag;
287
368
  private ready;
288
369
  private destroyed;
289
370
  private tracker;
290
371
  private streamingClient;
291
- private pollingId;
372
+ private pollingTimer;
292
373
  private abortController;
293
- private overrides;
294
374
  private listeners;
295
375
  private readyPromise;
296
376
  private pendingEvents;
377
+ private pendingPurchases;
297
378
  /**
298
- * §6: `client.utils.*` surface. Constructed lazily on first access so
299
- * the utils package is only imported when actually consumed.
300
- *
301
- * The server's event surface (`ServerEventMap`) is a subset of the
302
- * shared `ClientEventMap`; the utils-adapter `on` here ignores events
303
- * the server doesn't emit (e.g. `flagChange`). Workstream 5 (CoW 6)
304
- * harmonises the two maps when the streaming evaluator lands.
379
+ * Exposures produced before the tracker existed. Third member of the
380
+ * pre-ready family alongside `pendingEvents` and `pendingPurchases`; it used
381
+ * to be missing, and exposures were dropped on the floor instead.
382
+ */
383
+ private pendingExposures;
384
+ /** ms-epoch of the last liveness heartbeat, for the throttle. */
385
+ private lastHeartbeatAt;
386
+ /** Keys of one-time warnings, so a request loop cannot flood a log. */
387
+ private readonly warnedOnce;
388
+ /**
389
+ * `client.utils.*` surface, constructed lazily on first access so the utils
390
+ * package is only imported when actually consumed.
305
391
  */
306
392
  private _utils;
307
393
  get utils(): AvsbUtils;
308
394
  /**
309
- * Server-side datasets read surface (e-commerce suite Phase 2).
310
- * Constructed lazily on first access, mirroring the `utils` getter.
311
- * `client.datasets.get(slug, key)` resolves the live version from the
312
- * current datafile and never throws; see ./datasets.ts.
395
+ * Server-side datasets read surface. `client.datasets.get(slug, key)`
396
+ * resolves the live version from the current datafile and never throws.
313
397
  */
314
398
  private _datasets;
315
399
  get datasets(): DatasetsClient;
316
400
  /**
317
- * Server-side recommendations surface (e-commerce suite overhaul, C3).
318
- * Constructed lazily on first access, mirroring the `datasets` getter.
319
- * `client.recs.get({ recipe, … })` resolves the recipe registry from the
320
- * current datafile and never throws; impressions/clicks ride the server
321
- * tracker's queue and revenue attaches via the existing `trackPurchase`.
401
+ * Server-side recommendations surface. `client.recs.get({ recipe, … })`
402
+ * resolves the recipe registry from the current datafile and never throws;
403
+ * impressions and clicks ride the server tracker's queue and revenue
404
+ * attaches via `trackPurchase`.
322
405
  */
323
406
  private _recs;
324
407
  get recs(): RecsClient;
325
408
  constructor(options: AvsbServerOptions);
409
+ /**
410
+ * Await the init attempt. Never rejects; always resolves to an InitResult.
411
+ * Calling it twice returns the same promise.
412
+ */
326
413
  onReady(): Promise<InitResult>;
414
+ /** True when a datafile is loaded and flags are answerable right now. */
415
+ isReady(): boolean;
327
416
  private initializeAndReady;
417
+ /** Wiring that runs once a datafile is readable, however it arrived. */
418
+ private onDatafileReadable;
328
419
  close(): Promise<void>;
329
420
  flush(): Promise<void>;
421
+ /**
422
+ * Fetch the datafile now and apply it if it changed.
423
+ *
424
+ * A 304 is not a problem and is not warned about: it means the datafile in
425
+ * memory IS the published one.
426
+ */
330
427
  refreshDatafile(): Promise<void>;
331
- getFlag<T>(flagKey: string, defaultValue: NoInfer<T> | T, options?: GetFlagOptions): Flag<T>;
332
- getBoolFlag(flagKey: string, defaultValue: NoInfer<boolean> | boolean, options?: GetFlagOptions): Flag<boolean>;
333
- getStringFlag(flagKey: string, defaultValue: NoInfer<string> | string, options?: GetFlagOptions): Flag<string>;
334
- getNumberFlag(flagKey: string, defaultValue: NoInfer<number> | number, options?: GetFlagOptions): Flag<number>;
335
- getJsonFlag<T>(flagKey: string, defaultValue: NoInfer<T> | T, options?: GetFlagOptions): Flag<T>;
336
- getAllFlags(context: EvalContext, options?: GetAllFlagsOptions): Record<string, Flag>;
337
- trackPurchase(visitorId: string, order: PurchaseOrder): Promise<void>;
428
+ /**
429
+ * Adopt a datafile received out of band: a bootstrap blob from
430
+ * `client.utils.hydrateBootstrap`, a file on disk, a KV read at cold start.
431
+ *
432
+ * Marks the SDK ready, drains anything queued before it, and starts polling
433
+ * and streaming exactly as a network arrival would. This used to be a no-op
434
+ * comment inside the utils adapter, which made the documented bootstrap
435
+ * handoff silently do nothing.
436
+ */
437
+ applyDatafileBootstrap(datafile: FlagDatafile): void;
438
+ /** The datafile currently in memory, or null before init completes. */
439
+ getDatafile(): FlagDatafile | null;
440
+ getFlag<T>(flagKey: string, defaultValue: NoInfer<T> | T, context?: EvalContext, options?: ServerGetFlagOptions): Flag<T>;
441
+ getBoolFlag(flagKey: string, defaultValue: NoInfer<boolean> | boolean, context?: EvalContext, options?: ServerGetFlagOptions): Flag<boolean>;
442
+ getStringFlag(flagKey: string, defaultValue: NoInfer<string> | string, context?: EvalContext, options?: ServerGetFlagOptions): Flag<string>;
443
+ getNumberFlag(flagKey: string, defaultValue: NoInfer<number> | number, context?: EvalContext, options?: ServerGetFlagOptions): Flag<number>;
444
+ getJsonFlag<T>(flagKey: string, defaultValue: NoInfer<T> | T, context?: EvalContext, options?: ServerGetFlagOptions): Flag<T>;
445
+ /**
446
+ * @internal Single evaluation entry point for this package. `UserBoundClient`
447
+ * calls it with a bound context and forced decisions. Not part of the
448
+ * published API: its options bag carries package-internal fields.
449
+ */
450
+ evaluateInternal<T>(flagKey: string, defaultValue: NoInfer<T> | T, context?: EvalContext, options?: InternalGetFlagOptions): Flag<T>;
451
+ private evaluateAndBuild;
452
+ private checkType;
453
+ /**
454
+ * Every flag in the environment, evaluated against one context.
455
+ *
456
+ * Positional context (B3). Note the deliberate difference from
457
+ * `@avsbhq/browser`, which keeps an options bag (`getAllFlags({context})`)
458
+ * because it is context-bound and the bag disambiguates its optional
459
+ * arguments.
460
+ */
461
+ getAllFlags(context?: EvalContext, options?: GetAllFlagsOptions): Record<string, Flag>;
462
+ /**
463
+ * Queue a metric (conversion) event. Synchronous: it returns as soon as the
464
+ * event is queued, and the tracker batches it.
465
+ *
466
+ * The context rides in the payload rather than positionally because it is
467
+ * OPTIONAL vocabulary on the shared `TrackPayload` type and required only on
468
+ * the server; see the README for why the two runtimes read differently here.
469
+ */
338
470
  track(eventKey: string, payload: TrackPayload & {
339
471
  context: EvalContext;
340
472
  }): void;
473
+ /**
474
+ * Send a purchase to `/v1/collect/orders`.
475
+ *
476
+ * Queued before the tracker exists, exactly like `track` (they used to
477
+ * disagree: one queued, the other warned and dropped the order).
478
+ */
479
+ trackPurchase(visitorId: string, order: PurchaseOrder): Promise<void>;
480
+ /**
481
+ * Mark the moment a signup or login claimed an anonymous session. Same shape
482
+ * and same honesty as the browser client's `alias`.
483
+ *
484
+ * WHAT IT DOES: queues one `avsb_alias` event attributed to the NEW
485
+ * identity's key.
486
+ *
487
+ * WHAT IT DOES NOT DO, precisely:
488
+ * - It does not rewrite sticky assignments or move past exposures. Nothing
489
+ * already decided for either identity changes.
490
+ * - It does not store the PREVIOUS key. The conversion wire
491
+ * (`/v1/collect/metric-batch`) carries no properties column, so a
492
+ * retroactive session join is not available yet.
493
+ *
494
+ * To keep one visitor's bucketing across a login, evaluate with the SAME
495
+ * context key on both sides. See "Sharing identity with the web snippet" in
496
+ * the README for the cross-surface version of the same rule.
497
+ */
498
+ alias(previousContext: EvalContext, newContext: EvalContext): void;
499
+ /**
500
+ * Record an exposure for a flag without re-serving its value. Use it when
501
+ * the decision was read earlier with exposures suppressed and the visitor has
502
+ * now actually seen the result.
503
+ */
341
504
  manualExposure(flagKey: string, context: EvalContext): void;
505
+ /** A client with the context bound. The primary server pattern. */
342
506
  forUser(context: EvalContext): UserBoundClient;
507
+ /** Ergonomic alias for `forUser`. */
343
508
  createUserContext(context: EvalContext): UserBoundClient;
344
509
  setOverrideForUser(flagKey: string, contextKey: string, variationKey: string, contextKind?: string): void;
345
510
  setGlobalOverride(flagKey: string, variationKey: string): void;
346
511
  clearOverrideForUser(flagKey: string, contextKey: string, contextKind?: string): void;
347
512
  clearGlobalOverride(flagKey: string): void;
348
513
  clearAllOverrides(): void;
514
+ /** A fresh decision log to pass per call. `forUser` carries its own. */
349
515
  createDecisionLog(): DecisionLog;
350
516
  on<E extends ServerEventName>(event: E, cb: ServerEventMap[E]): () => void;
351
517
  off<E extends ServerEventName>(event: E, cb: ServerEventMap[E]): void;
518
+ /**
519
+ * `on` for the shared `ClientEventMap` names the utils layer speaks. The
520
+ * server emits every one of them except `flagChange`, which needs per-flag
521
+ * diffing the server does not do; subscribing to it warns and hands back a
522
+ * no-op disposer instead of pretending.
523
+ */
524
+ private onSharedEvent;
525
+ private emit;
526
+ private warnOnce;
352
527
  private evaluate;
353
- private getTypedFlag;
528
+ /**
529
+ * One exposure, delivered everywhere: queued for ingestion AND emitted on
530
+ * the event bus with the SAME `eventId`, so a customer forwarding decisions
531
+ * to their own analytics can reconcile rows with A vs B results.
532
+ */
533
+ private deliverExposure;
354
534
  private toFlag;
355
- /** toFlag variant that never writes a decision log (used by getAllFlags suppressed path). */
356
- private toFlagNoLog;
357
- /** Build a Flag<T> from a forced variation key (per-request override). */
358
- private buildFlagFromVariationKey;
535
+ /** Build a Flag from a forced variation key (per-request forced decision). */
536
+ private buildForcedFlag;
537
+ /** Emit the evaluation event and append to a decision log if one was passed. */
538
+ private recordDecision;
359
539
  private buildLegacyContext;
360
- private resolveRuntimeOverride;
361
540
  private resolveStickyBucket;
362
541
  private findVariation;
363
542
  private fireExposureForStickyAssignment;
364
- private emit;
365
- private emitEvaluation;
366
543
  private initTracker;
367
- private startStreamingIfEnabled;
368
- private fetchDatafile;
544
+ private enqueuePendingExposure;
545
+ private enqueuePending;
546
+ /**
547
+ * Fetch and apply the datafile.
548
+ *
549
+ * Three outcomes, not two, because a conditional request has three:
550
+ * 'applied' a datafile came back and is now in memory
551
+ * 'unchanged' the CDN answered 304: what we hold IS current
552
+ * 'failed' nothing usable came back; the error is already logged
553
+ *
554
+ * 'unchanged' is a success. Collapsing it into 'failed' made
555
+ * `refreshDatafile()` warn that a refresh "did not apply a new datafile"
556
+ * every time the datafile was simply up to date.
557
+ */
558
+ private loadDatafile;
559
+ /**
560
+ * Poll on a JITTERED interval. Without the jitter every process that
561
+ * deployed together polls the CDN on the same tick forever.
562
+ */
369
563
  private startPolling;
564
+ /**
565
+ * Open the SSE stream when one is available, and fall back to polling in
566
+ * silence when it is not (B12). The endpoint comes from the datafile's
567
+ * `streamEndpoint` field, or an explicit option; it is never guessed from
568
+ * another host, which is what gave one port an infinite reconnect loop
569
+ * against DNS that does not resolve.
570
+ */
571
+ private startStreamingIfEnabled;
572
+ /**
573
+ * Report liveness, throttled.
574
+ *
575
+ * Called on every successful datafile load, not just at init: the dashboard
576
+ * marks an environment Stale after five minutes without a heartbeat, so an
577
+ * init-only heartbeat made every long-running process look dead a few
578
+ * minutes after it started. The throttle keeps a fast polling interval (or a
579
+ * burst of stream-triggered refreshes) from turning liveness into traffic.
580
+ *
581
+ * Fire and forget: a failed heartbeat never affects evaluation, and the
582
+ * throttle clock is stamped by the synchronous send below, so a hanging
583
+ * request cannot queue a second one.
584
+ */
370
585
  private sendHeartbeat;
371
586
  }
372
587
 
373
- declare function expressMiddleware(client: AvsbServer, config: MiddlewareConfig): (req: unknown, res: unknown, next: () => void) => void;
374
- declare function fastifyPlugin(instance: unknown, opts: {
375
- client: AvsbServer;
376
- } & MiddlewareConfig, done: () => void): void;
377
-
378
588
  declare class InMemoryStickyBucketService implements StickyBucketService {
379
589
  private cache;
380
590
  private key;
@@ -412,6 +622,60 @@ declare class RedisStickyBucketService implements StickyBucketService {
412
622
 
413
623
  declare function createDecisionLog(): DecisionLog;
414
624
 
415
- declare const SDK_VERSION = "1.6.2";
625
+ /** Default replay window: a delivery more than 5 minutes old is rejected. */
626
+ declare const DEFAULT_TOLERANCE_SECONDS = 300;
627
+ /**
628
+ * Header bag shapes this accepts, so it works with Node's `IncomingHttpHeaders`,
629
+ * a plain object, or a `Headers` instance (via `Object.fromEntries(headers)`).
630
+ */
631
+ type WebhookHeaders = Record<string, string | string[] | undefined> | {
632
+ get(name: string): string | null;
633
+ };
634
+ interface VerifyWebhookOptions {
635
+ /**
636
+ * How old a delivery may be, in seconds. Defaults to 300 (5 minutes). Set to 0 to
637
+ * skip the age check entirely — only do that when you dedupe on the delivery id and
638
+ * accept replays.
639
+ */
640
+ toleranceSeconds?: number;
641
+ /** Injectable clock, in ms since the epoch. Exists for tests. */
642
+ now?: () => number;
643
+ }
644
+ /** Why verification failed. `ok: false` always carries one of these. */
645
+ type WebhookVerifyFailure = 'missing_headers' | 'malformed_signature' | 'malformed_timestamp' | 'timestamp_out_of_tolerance' | 'signature_mismatch';
646
+ type WebhookVerifyResult = {
647
+ ok: true;
648
+ deliveryId: string;
649
+ timestamp: string;
650
+ } | {
651
+ ok: false;
652
+ reason: WebhookVerifyFailure;
653
+ message: string;
654
+ };
655
+ /**
656
+ * Verify one webhook delivery.
657
+ *
658
+ * @param payload The RAW request body, exactly as received (string or Buffer). Never
659
+ * a parsed-and-re-serialised object.
660
+ * @param headers The request headers.
661
+ * @param secret The webhook's signing secret, from the create or rotate response.
662
+ *
663
+ * @example
664
+ * ```ts
665
+ * import express from 'express'
666
+ * import { verifyWebhookSignature } from '@avsbhq/node'
667
+ *
668
+ * app.post('/avsb-webhook', express.raw({ type: 'application/json' }), (req, res) => {
669
+ * const result = verifyWebhookSignature(req.body, req.headers, process.env.AVSB_WEBHOOK_SECRET!)
670
+ * if (!result.ok) return res.status(401).send(result.reason)
671
+ * const event = JSON.parse(req.body.toString('utf8'))
672
+ * // ... handle event, deduped on result.deliveryId
673
+ * res.sendStatus(200)
674
+ * })
675
+ * ```
676
+ */
677
+ declare function verifyWebhookSignature(payload: string | Buffer, headers: WebhookHeaders, secret: string, options?: VerifyWebhookOptions): WebhookVerifyResult;
678
+
679
+ declare const SDK_VERSION = "1.7.1";
416
680
 
417
- export { AvsbServer, type AvsbServerOptions, type DatafileRecipe, type DatasetGetResult, type DatasetsClient, type EventOptions, type GetAllFlagsOptions, type GetFlagOptions, InMemoryStickyBucketService, type MiddlewareConfig, type PurchaseOrder, type RecProduct, type RecsAttribution, type RecsClient, type RecsGetRequest, type RecsResult, type RedisLike, type RedisStickyBucketOptions, RedisStickyBucketService, SDK_VERSION, type UserBoundClient, createDecisionLog, expressMiddleware, fastifyPlugin };
681
+ export { AvsbServer, type AvsbServerOptions, DEFAULT_TOLERANCE_SECONDS, type DatafileRecipe, type DatasetGetResult, type DatasetsClient, type EventOptions, type GetAllFlagsOptions, InMemoryStickyBucketService, type PurchaseOrder, type RecProduct, type RecsAttribution, type RecsClient, type RecsGetRequest, type RecsResult, type RedisLike, type RedisStickyBucketOptions, RedisStickyBucketService, SDK_VERSION, type ServerEventMap, type ServerEventName, type ServerGetFlagOptions, type ServerLogLevel, type UserBoundClient, type VerifyWebhookOptions, type WebhookHeaders, type WebhookVerifyFailure, type WebhookVerifyResult, createDecisionLog, defaultLogger, isDevEnvironment, resolveServerLogger, verifyWebhookSignature };