@avsbhq/node 1.7.1 → 1.8.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 +25 -25
- package/dist/index.cjs +48 -20
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +51 -22
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -35,8 +35,8 @@ if (!result.success) {
|
|
|
35
35
|
// Bind the context once per request.
|
|
36
36
|
const user = server.forUser({ kind: 'user', key: 'u_123', plan: 'pro' });
|
|
37
37
|
|
|
38
|
-
const flag = user.getBoolFlag('
|
|
39
|
-
const checkoutTemplate = flag.isEnabled() ? '
|
|
38
|
+
const flag = user.getBoolFlag('new_checkout_flow', false);
|
|
39
|
+
const checkoutTemplate = flag.isEnabled() ? 'checkout_v2' : 'checkout_v1';
|
|
40
40
|
|
|
41
41
|
user.track('checkout_started', { revenue: 99.0 });
|
|
42
42
|
```
|
|
@@ -104,7 +104,7 @@ interface EventOptions {
|
|
|
104
104
|
|
|
105
105
|
### SDK keys
|
|
106
106
|
|
|
107
|
-
Copy the key for the environment you want from app.avsb.cloud, then
|
|
107
|
+
Copy the key for the environment you want from app.avsb.cloud, then Environments in the project sidebar. The format is `sdk_<environment>_<id>`.
|
|
108
108
|
|
|
109
109
|
```bash
|
|
110
110
|
AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xn
|
|
@@ -139,7 +139,7 @@ const user = server.forUser({
|
|
|
139
139
|
country: req.user.country,
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
-
const flag = user.getBoolFlag('
|
|
142
|
+
const flag = user.getBoolFlag('checkout_v2', false);
|
|
143
143
|
user.track('page_viewed');
|
|
144
144
|
```
|
|
145
145
|
|
|
@@ -150,7 +150,7 @@ user.track('page_viewed');
|
|
|
150
150
|
Context is the THIRD POSITIONAL argument on every evaluation call, matching `@avsbhq/edge`:
|
|
151
151
|
|
|
152
152
|
```ts
|
|
153
|
-
const flag = server.getBoolFlag('
|
|
153
|
+
const flag = server.getBoolFlag('checkout_v2', false, {
|
|
154
154
|
kind: 'user',
|
|
155
155
|
key: 'u_123',
|
|
156
156
|
plan: 'pro',
|
|
@@ -175,7 +175,7 @@ const user = server.forUser({
|
|
|
175
175
|
Audiences built with the commerce condition types (cart value, products viewed, purchase history) read fixed attribute names off the context:
|
|
176
176
|
|
|
177
177
|
```ts
|
|
178
|
-
const flag = server.getBoolFlag('
|
|
178
|
+
const flag = server.getBoolFlag('big_cart_banner', false, {
|
|
179
179
|
kind: 'user',
|
|
180
180
|
key: 'u_123',
|
|
181
181
|
cartValue: 120.5, // decimal MAJOR units of the project currency
|
|
@@ -290,7 +290,7 @@ interface Flag<T = unknown> {
|
|
|
290
290
|
```ts
|
|
291
291
|
import { DecideOption } from '@avsbhq/node';
|
|
292
292
|
|
|
293
|
-
user.getBoolFlag('
|
|
293
|
+
user.getBoolFlag('checkout_v2', false, {
|
|
294
294
|
decideOptions: [DecideOption.DISABLE_EXPOSURE],
|
|
295
295
|
});
|
|
296
296
|
```
|
|
@@ -314,12 +314,12 @@ Called without a context, or before the datafile loads, it returns `{}` AND warn
|
|
|
314
314
|
### Overrides, for QA and testing
|
|
315
315
|
|
|
316
316
|
```ts
|
|
317
|
-
server.setOverrideForUser('
|
|
318
|
-
server.setOverrideForUser('
|
|
319
|
-
server.setGlobalOverride('
|
|
317
|
+
server.setOverrideForUser('checkout_v2', 'u_qa', 'on');
|
|
318
|
+
server.setOverrideForUser('checkout_v2', 'acme', 'on', 'organization'); // explicit kind
|
|
319
|
+
server.setGlobalOverride('checkout_v2', 'on');
|
|
320
320
|
|
|
321
|
-
server.clearOverrideForUser('
|
|
322
|
-
server.clearGlobalOverride('
|
|
321
|
+
server.clearOverrideForUser('checkout_v2', 'u_qa');
|
|
322
|
+
server.clearGlobalOverride('checkout_v2');
|
|
323
323
|
server.clearAllOverrides();
|
|
324
324
|
```
|
|
325
325
|
|
|
@@ -327,8 +327,8 @@ Per-request forced decisions, which never touch the shared state above:
|
|
|
327
327
|
|
|
328
328
|
```ts
|
|
329
329
|
const user = server.forUser({ kind: 'user', key: 'u_qa' });
|
|
330
|
-
user.setForcedDecision('
|
|
331
|
-
user.clearForcedDecision('
|
|
330
|
+
user.setForcedDecision('checkout_v2', { variationKey: 'on' });
|
|
331
|
+
user.clearForcedDecision('checkout_v2');
|
|
332
332
|
```
|
|
333
333
|
|
|
334
334
|
---
|
|
@@ -380,12 +380,12 @@ Purchases are queued before ready exactly like `track`. They used to be dropped
|
|
|
380
380
|
### Manual exposure
|
|
381
381
|
|
|
382
382
|
```ts
|
|
383
|
-
const flag = user.getBoolFlag('
|
|
383
|
+
const flag = user.getBoolFlag('checkout_v2', false, {
|
|
384
384
|
decideOptions: [DecideOption.DISABLE_EXPOSURE],
|
|
385
385
|
});
|
|
386
386
|
|
|
387
387
|
// Later, at the point the visitor actually sees the variation:
|
|
388
|
-
user.manualExposure('
|
|
388
|
+
user.manualExposure('checkout_v2');
|
|
389
389
|
```
|
|
390
390
|
|
|
391
391
|
---
|
|
@@ -471,10 +471,10 @@ actually being served.
|
|
|
471
471
|
exactly the steady state of a healthy process.
|
|
472
472
|
- **Fire and forget**: a failed heartbeat is dropped, never retried, and never
|
|
473
473
|
affects evaluation.
|
|
474
|
-
- **Where it shows up**:
|
|
474
|
+
- **Where it shows up**: Environments in the project sidebar, in the dashboard. Each
|
|
475
475
|
environment shows Connected, Stale, or Not connected, plus the `sdkType` and
|
|
476
476
|
`sdkVersion` that last reported, which is how you see "production is running
|
|
477
|
-
@avsbhq/node 1.
|
|
477
|
+
@avsbhq/node 1.8.0". An environment goes Stale after five minutes without a
|
|
478
478
|
heartbeat.
|
|
479
479
|
- **Turning it off**: publish a datafile with no `heartbeatEndpoint`. The SDK
|
|
480
480
|
never guesses a host, so an absent field simply means no heartbeats.
|
|
@@ -587,7 +587,7 @@ import { createMockServer, flagsFromTestData, TestData } from '@avsbhq/test';
|
|
|
587
587
|
|
|
588
588
|
const server = createMockServer(
|
|
589
589
|
flagsFromTestData([
|
|
590
|
-
TestData.flag('
|
|
590
|
+
TestData.flag('checkout_v2')
|
|
591
591
|
.booleanFlag()
|
|
592
592
|
.variationForUser('u_paying', true)
|
|
593
593
|
.fallthroughVariation(false)
|
|
@@ -596,7 +596,7 @@ const server = createMockServer(
|
|
|
596
596
|
);
|
|
597
597
|
|
|
598
598
|
const user = server.forUser({ kind: 'user', key: 'u_paying' });
|
|
599
|
-
expect(user.getBoolFlag('
|
|
599
|
+
expect(user.getBoolFlag('checkout_v2', false).value).toBe(true);
|
|
600
600
|
expect(server._evaluations()).toHaveLength(1);
|
|
601
601
|
```
|
|
602
602
|
|
|
@@ -630,7 +630,7 @@ app.use(
|
|
|
630
630
|
|
|
631
631
|
// Deep inside a service, with no `req` in scope:
|
|
632
632
|
function pricingService() {
|
|
633
|
-
return getRequestClient()?.getBoolFlag('
|
|
633
|
+
return getRequestClient()?.getBoolFlag('new_pricing', false).value ?? false;
|
|
634
634
|
}
|
|
635
635
|
```
|
|
636
636
|
|
|
@@ -640,8 +640,8 @@ Every route then reads flags off `req.avsb`:
|
|
|
640
640
|
// docs-example: not typechecked here, because express is not a dependency of the A vs B repository, so a route handler's Request and Response types cannot be resolved
|
|
641
641
|
|
|
642
642
|
app.get('/checkout', (req, res) => {
|
|
643
|
-
const flag = req.avsb.getBoolFlag('
|
|
644
|
-
res.render(flag.isEnabled() ? '
|
|
643
|
+
const flag = req.avsb.getBoolFlag('new_checkout_flow', false);
|
|
644
|
+
res.render(flag.isEnabled() ? 'checkout_v2' : 'checkout_v1');
|
|
645
645
|
});
|
|
646
646
|
```
|
|
647
647
|
|
|
@@ -682,7 +682,7 @@ await app.register(fastifyPlugin, {
|
|
|
682
682
|
});
|
|
683
683
|
|
|
684
684
|
app.get('/features', (req, reply) => {
|
|
685
|
-
reply.send({ enabled: req.avsb.getBoolFlag('
|
|
685
|
+
reply.send({ enabled: req.avsb.getBoolFlag('new_feature', false).isEnabled() });
|
|
686
686
|
});
|
|
687
687
|
```
|
|
688
688
|
|
|
@@ -716,7 +716,7 @@ The Redis service keeps evaluation synchronous: a lookup is a cache hit or null,
|
|
|
716
716
|
|
|
717
717
|
```ts
|
|
718
718
|
const user = server.forUser({ kind: 'user', key: 'u_123' });
|
|
719
|
-
user.getBoolFlag('
|
|
719
|
+
user.getBoolFlag('checkout_v2', false);
|
|
720
720
|
|
|
721
721
|
const entries = user.getDecisionLog().entries;
|
|
722
722
|
// [{ flagKey, value, variationKey, source, ruleId, ruleType, reasons,
|
package/dist/index.cjs
CHANGED
|
@@ -478,12 +478,19 @@ async function fetchDatafile(input) {
|
|
|
478
478
|
if (response.status === 304) return { status: "not-modified" };
|
|
479
479
|
if (!response.ok) {
|
|
480
480
|
input.logger.error(
|
|
481
|
-
`AvsB datafile request failed with HTTP ${response.status} for ${url}. ` + (response.status === 404 ? "That SDK key has no published datafile: check the key belongs to this environment and that the environment has been published at least once (app.avsb.cloud, then
|
|
481
|
+
`AvsB datafile request failed with HTTP ${response.status} for ${url}. ` + (response.status === 404 ? "That SDK key has no published datafile: check the key belongs to this environment and that the environment has been published at least once (app.avsb.cloud, then Environments in the project sidebar)." : "The SDK keeps serving whatever datafile it already has and retries on the polling interval.")
|
|
482
482
|
);
|
|
483
483
|
return { status: "failed" };
|
|
484
484
|
}
|
|
485
|
-
const
|
|
486
|
-
|
|
485
|
+
const body = await response.json();
|
|
486
|
+
const problem = (0, import_core3.datafileShapeProblem)(body);
|
|
487
|
+
if (problem) {
|
|
488
|
+
input.logger.error(
|
|
489
|
+
`AvsB datafile from ${url} is not a datafile the SDK can read: ${problem}. The SDK keeps serving whatever datafile it already has and retries on the polling interval.`
|
|
490
|
+
);
|
|
491
|
+
return { status: "failed" };
|
|
492
|
+
}
|
|
493
|
+
return { status: "ok", datafile: body, etag: response.headers.get("etag") };
|
|
487
494
|
} catch (error) {
|
|
488
495
|
if (input.signal.aborted) return { status: "aborted" };
|
|
489
496
|
input.logger.error(
|
|
@@ -492,6 +499,14 @@ async function fetchDatafile(input) {
|
|
|
492
499
|
return { status: "failed" };
|
|
493
500
|
}
|
|
494
501
|
}
|
|
502
|
+
function acceptDatafile(candidate, origin, logger) {
|
|
503
|
+
const problem = (0, import_core3.datafileShapeProblem)(candidate);
|
|
504
|
+
if (problem === null) return candidate;
|
|
505
|
+
logger.error(
|
|
506
|
+
origin === "bootstrap" ? `AvsB ignored the bootstrap datafile passed to new AvsbServer(): ${problem}. Flags return the default you passed, with source "not_ready", until a datafile loads from the CDN. Pass the datafile JSON exactly as the CDN serves it for this SDK key.` : `AvsB ignored the datafile passed to applyDatafileBootstrap(): ${problem}. The SDK keeps serving the datafile it already has, if any. Pass the datafile JSON exactly as the CDN serves it for this SDK key.`
|
|
507
|
+
);
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
495
510
|
function sendHeartbeat(input) {
|
|
496
511
|
const endpoint = (0, import_core3.resolveHeartbeatEndpoint)(input.datafileHeartbeatEndpoint);
|
|
497
512
|
if (!endpoint) return null;
|
|
@@ -701,7 +716,8 @@ function createServerTracker(options) {
|
|
|
701
716
|
var import_streaming = require("@avsbhq/utils/streaming");
|
|
702
717
|
|
|
703
718
|
// src/version.ts
|
|
704
|
-
var SDK_VERSION = "1.
|
|
719
|
+
var SDK_VERSION = "1.8.0";
|
|
720
|
+
var SDK_TYPE = "@avsbhq/node";
|
|
705
721
|
|
|
706
722
|
// src/UserBoundClient.ts
|
|
707
723
|
var import_core6 = require("@avsbhq/core");
|
|
@@ -811,6 +827,23 @@ function buildServerUtils(server) {
|
|
|
811
827
|
return (0, import_utils.createUtils)(adapter);
|
|
812
828
|
}
|
|
813
829
|
|
|
830
|
+
// src/initTimeout.ts
|
|
831
|
+
async function raceInitTimeout(work, timeoutMs) {
|
|
832
|
+
let timer;
|
|
833
|
+
const timeout = new Promise((resolve) => {
|
|
834
|
+
timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
|
|
835
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
|
|
836
|
+
});
|
|
837
|
+
try {
|
|
838
|
+
return await Promise.race([
|
|
839
|
+
work.then((value) => ({ timedOut: false, value })),
|
|
840
|
+
timeout
|
|
841
|
+
]);
|
|
842
|
+
} finally {
|
|
843
|
+
clearTimeout(timer);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
814
847
|
// src/AvsbServer.ts
|
|
815
848
|
var DEFAULT_EVENTS = {
|
|
816
849
|
flushInterval: 2e3,
|
|
@@ -818,7 +851,6 @@ var DEFAULT_EVENTS = {
|
|
|
818
851
|
maxQueueSize: 1e4,
|
|
819
852
|
retryAttempts: 3
|
|
820
853
|
};
|
|
821
|
-
var SDK_TYPE = "@avsbhq/node";
|
|
822
854
|
var HEARTBEAT_MIN_INTERVAL_MS = 12e4;
|
|
823
855
|
var AvsbServer = class {
|
|
824
856
|
sdkKey;
|
|
@@ -915,8 +947,8 @@ var AvsbServer = class {
|
|
|
915
947
|
this.eventOptions = { ...DEFAULT_EVENTS, ...options.events };
|
|
916
948
|
this.streamingEnabled = options.streaming !== false;
|
|
917
949
|
this.streamingEndpointOption = options.streamingEndpoint ?? null;
|
|
918
|
-
this.datafile = options.bootstrap
|
|
919
|
-
this.ready =
|
|
950
|
+
this.datafile = options.bootstrap ? acceptDatafile(options.bootstrap, "bootstrap", this.logger) : null;
|
|
951
|
+
this.ready = this.datafile !== null;
|
|
920
952
|
this.destroyed = false;
|
|
921
953
|
this.tracker = null;
|
|
922
954
|
this.streamingClient = null;
|
|
@@ -950,21 +982,13 @@ var AvsbServer = class {
|
|
|
950
982
|
return { success: true, source: "bootstrap" };
|
|
951
983
|
}
|
|
952
984
|
try {
|
|
953
|
-
const outcome = await
|
|
985
|
+
const outcome = await raceInitTimeout(
|
|
954
986
|
// At init there is no stored etag, so 'unchanged' cannot happen here;
|
|
955
987
|
// it is folded in anyway so a future caching layer cannot make init
|
|
956
988
|
// silently report failure.
|
|
957
|
-
this.loadDatafile().then((result) =>
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
})),
|
|
961
|
-
new Promise(
|
|
962
|
-
(resolve) => setTimeout(
|
|
963
|
-
() => resolve({ applied: false, timedOut: true }),
|
|
964
|
-
this.initTimeout
|
|
965
|
-
)
|
|
966
|
-
)
|
|
967
|
-
]);
|
|
989
|
+
this.loadDatafile().then((result) => result !== "failed"),
|
|
990
|
+
this.initTimeout
|
|
991
|
+
);
|
|
968
992
|
if (outcome.timedOut) {
|
|
969
993
|
this.startPolling();
|
|
970
994
|
const error2 = new Error(
|
|
@@ -973,7 +997,7 @@ var AvsbServer = class {
|
|
|
973
997
|
this.emit("error", error2);
|
|
974
998
|
return { success: false, source: "timeout", error: error2 };
|
|
975
999
|
}
|
|
976
|
-
if (outcome.
|
|
1000
|
+
if (outcome.value) {
|
|
977
1001
|
this.onDatafileReadable();
|
|
978
1002
|
return { success: true, source: "network" };
|
|
979
1003
|
}
|
|
@@ -1051,6 +1075,7 @@ var AvsbServer = class {
|
|
|
1051
1075
|
*/
|
|
1052
1076
|
applyDatafileBootstrap(datafile) {
|
|
1053
1077
|
if (this.destroyed) return;
|
|
1078
|
+
if (!acceptDatafile(datafile, "applyDatafileBootstrap", this.logger)) return;
|
|
1054
1079
|
const changed = this.datafile?.publishedAt !== datafile.publishedAt;
|
|
1055
1080
|
this.datafile = datafile;
|
|
1056
1081
|
this.datafileEtag = null;
|
|
@@ -1146,6 +1171,9 @@ var AvsbServer = class {
|
|
|
1146
1171
|
evaluateAndBuild(flagKey, defaultValue, context, decide, options) {
|
|
1147
1172
|
const legacyCtx = this.buildLegacyContext(context, decide.suppressExposure);
|
|
1148
1173
|
const result = this.evaluate(flagKey, legacyCtx, defaultValue);
|
|
1174
|
+
if (result.source === "not_found") {
|
|
1175
|
+
this.warnOnce(`missing:${flagKey}`, (0, import_core8.flagNotFoundMessage)(flagKey));
|
|
1176
|
+
}
|
|
1149
1177
|
const typeMismatch = this.checkType(flagKey, options?.expectedType, result);
|
|
1150
1178
|
if (typeMismatch) {
|
|
1151
1179
|
this.logger.warn(
|
package/dist/index.d.cts
CHANGED
|
@@ -63,7 +63,7 @@ interface AvsbServerOptions {
|
|
|
63
63
|
/**
|
|
64
64
|
* SDK key for the environment, shaped `sdk_<environment>_<id>`, for example
|
|
65
65
|
* `sdk_production_ttqm0eaj4vth1krcb2xn`. Copy it from app.avsb.cloud, then
|
|
66
|
-
*
|
|
66
|
+
* Environments in the project sidebar. The shape is checked at construction and an
|
|
67
67
|
* unrecognisable value is logged (never thrown) with what looked wrong.
|
|
68
68
|
*/
|
|
69
69
|
sdkKey: string;
|
|
@@ -676,6 +676,7 @@ type WebhookVerifyResult = {
|
|
|
676
676
|
*/
|
|
677
677
|
declare function verifyWebhookSignature(payload: string | Buffer, headers: WebhookHeaders, secret: string, options?: VerifyWebhookOptions): WebhookVerifyResult;
|
|
678
678
|
|
|
679
|
-
|
|
679
|
+
/** This package's version, reported on every liveness heartbeat as `sdkVersion`. */
|
|
680
|
+
declare const SDK_VERSION = "1.8.0";
|
|
680
681
|
|
|
681
682
|
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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -63,7 +63,7 @@ interface AvsbServerOptions {
|
|
|
63
63
|
/**
|
|
64
64
|
* SDK key for the environment, shaped `sdk_<environment>_<id>`, for example
|
|
65
65
|
* `sdk_production_ttqm0eaj4vth1krcb2xn`. Copy it from app.avsb.cloud, then
|
|
66
|
-
*
|
|
66
|
+
* Environments in the project sidebar. The shape is checked at construction and an
|
|
67
67
|
* unrecognisable value is logged (never thrown) with what looked wrong.
|
|
68
68
|
*/
|
|
69
69
|
sdkKey: string;
|
|
@@ -676,6 +676,7 @@ type WebhookVerifyResult = {
|
|
|
676
676
|
*/
|
|
677
677
|
declare function verifyWebhookSignature(payload: string | Buffer, headers: WebhookHeaders, secret: string, options?: VerifyWebhookOptions): WebhookVerifyResult;
|
|
678
678
|
|
|
679
|
-
|
|
679
|
+
/** This package's version, reported on every liveness heartbeat as `sdkVersion`. */
|
|
680
|
+
declare const SDK_VERSION = "1.8.0";
|
|
680
681
|
|
|
681
682
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
contextSummary,
|
|
13
13
|
privateAttributesFrom,
|
|
14
14
|
bucketingKey as bucketingKey2,
|
|
15
|
-
resolveStreamEndpoint
|
|
15
|
+
resolveStreamEndpoint,
|
|
16
|
+
flagNotFoundMessage
|
|
16
17
|
} from "@avsbhq/core";
|
|
17
18
|
import { validateSdkKeyShape } from "@avsbhq/utils/sdkKey";
|
|
18
19
|
import { jitteredInterval } from "@avsbhq/utils/backoff";
|
|
@@ -427,7 +428,7 @@ function createOverrideStore() {
|
|
|
427
428
|
}
|
|
428
429
|
|
|
429
430
|
// src/datafileSource.ts
|
|
430
|
-
import { datafileUrl, resolveHeartbeatEndpoint } from "@avsbhq/core";
|
|
431
|
+
import { datafileShapeProblem, datafileUrl, resolveHeartbeatEndpoint } from "@avsbhq/core";
|
|
431
432
|
async function fetchDatafile(input) {
|
|
432
433
|
const url = datafileUrl(input.sdkKey, input.cdnHost);
|
|
433
434
|
const headers = {};
|
|
@@ -437,12 +438,19 @@ async function fetchDatafile(input) {
|
|
|
437
438
|
if (response.status === 304) return { status: "not-modified" };
|
|
438
439
|
if (!response.ok) {
|
|
439
440
|
input.logger.error(
|
|
440
|
-
`AvsB datafile request failed with HTTP ${response.status} for ${url}. ` + (response.status === 404 ? "That SDK key has no published datafile: check the key belongs to this environment and that the environment has been published at least once (app.avsb.cloud, then
|
|
441
|
+
`AvsB datafile request failed with HTTP ${response.status} for ${url}. ` + (response.status === 404 ? "That SDK key has no published datafile: check the key belongs to this environment and that the environment has been published at least once (app.avsb.cloud, then Environments in the project sidebar)." : "The SDK keeps serving whatever datafile it already has and retries on the polling interval.")
|
|
441
442
|
);
|
|
442
443
|
return { status: "failed" };
|
|
443
444
|
}
|
|
444
|
-
const
|
|
445
|
-
|
|
445
|
+
const body = await response.json();
|
|
446
|
+
const problem = datafileShapeProblem(body);
|
|
447
|
+
if (problem) {
|
|
448
|
+
input.logger.error(
|
|
449
|
+
`AvsB datafile from ${url} is not a datafile the SDK can read: ${problem}. The SDK keeps serving whatever datafile it already has and retries on the polling interval.`
|
|
450
|
+
);
|
|
451
|
+
return { status: "failed" };
|
|
452
|
+
}
|
|
453
|
+
return { status: "ok", datafile: body, etag: response.headers.get("etag") };
|
|
446
454
|
} catch (error) {
|
|
447
455
|
if (input.signal.aborted) return { status: "aborted" };
|
|
448
456
|
input.logger.error(
|
|
@@ -451,6 +459,14 @@ async function fetchDatafile(input) {
|
|
|
451
459
|
return { status: "failed" };
|
|
452
460
|
}
|
|
453
461
|
}
|
|
462
|
+
function acceptDatafile(candidate, origin, logger) {
|
|
463
|
+
const problem = datafileShapeProblem(candidate);
|
|
464
|
+
if (problem === null) return candidate;
|
|
465
|
+
logger.error(
|
|
466
|
+
origin === "bootstrap" ? `AvsB ignored the bootstrap datafile passed to new AvsbServer(): ${problem}. Flags return the default you passed, with source "not_ready", until a datafile loads from the CDN. Pass the datafile JSON exactly as the CDN serves it for this SDK key.` : `AvsB ignored the datafile passed to applyDatafileBootstrap(): ${problem}. The SDK keeps serving the datafile it already has, if any. Pass the datafile JSON exactly as the CDN serves it for this SDK key.`
|
|
467
|
+
);
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
454
470
|
function sendHeartbeat(input) {
|
|
455
471
|
const endpoint = resolveHeartbeatEndpoint(input.datafileHeartbeatEndpoint);
|
|
456
472
|
if (!endpoint) return null;
|
|
@@ -667,7 +683,8 @@ import {
|
|
|
667
683
|
} from "@avsbhq/utils/streaming";
|
|
668
684
|
|
|
669
685
|
// src/version.ts
|
|
670
|
-
var SDK_VERSION = "1.
|
|
686
|
+
var SDK_VERSION = "1.8.0";
|
|
687
|
+
var SDK_TYPE = "@avsbhq/node";
|
|
671
688
|
|
|
672
689
|
// src/UserBoundClient.ts
|
|
673
690
|
import { bucketingKey } from "@avsbhq/core";
|
|
@@ -777,6 +794,23 @@ function buildServerUtils(server) {
|
|
|
777
794
|
return createUtils(adapter);
|
|
778
795
|
}
|
|
779
796
|
|
|
797
|
+
// src/initTimeout.ts
|
|
798
|
+
async function raceInitTimeout(work, timeoutMs) {
|
|
799
|
+
let timer;
|
|
800
|
+
const timeout = new Promise((resolve) => {
|
|
801
|
+
timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
|
|
802
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
|
|
803
|
+
});
|
|
804
|
+
try {
|
|
805
|
+
return await Promise.race([
|
|
806
|
+
work.then((value) => ({ timedOut: false, value })),
|
|
807
|
+
timeout
|
|
808
|
+
]);
|
|
809
|
+
} finally {
|
|
810
|
+
clearTimeout(timer);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
780
814
|
// src/AvsbServer.ts
|
|
781
815
|
var DEFAULT_EVENTS = {
|
|
782
816
|
flushInterval: 2e3,
|
|
@@ -784,7 +818,6 @@ var DEFAULT_EVENTS = {
|
|
|
784
818
|
maxQueueSize: 1e4,
|
|
785
819
|
retryAttempts: 3
|
|
786
820
|
};
|
|
787
|
-
var SDK_TYPE = "@avsbhq/node";
|
|
788
821
|
var HEARTBEAT_MIN_INTERVAL_MS = 12e4;
|
|
789
822
|
var AvsbServer = class {
|
|
790
823
|
sdkKey;
|
|
@@ -881,8 +914,8 @@ var AvsbServer = class {
|
|
|
881
914
|
this.eventOptions = { ...DEFAULT_EVENTS, ...options.events };
|
|
882
915
|
this.streamingEnabled = options.streaming !== false;
|
|
883
916
|
this.streamingEndpointOption = options.streamingEndpoint ?? null;
|
|
884
|
-
this.datafile = options.bootstrap
|
|
885
|
-
this.ready =
|
|
917
|
+
this.datafile = options.bootstrap ? acceptDatafile(options.bootstrap, "bootstrap", this.logger) : null;
|
|
918
|
+
this.ready = this.datafile !== null;
|
|
886
919
|
this.destroyed = false;
|
|
887
920
|
this.tracker = null;
|
|
888
921
|
this.streamingClient = null;
|
|
@@ -916,21 +949,13 @@ var AvsbServer = class {
|
|
|
916
949
|
return { success: true, source: "bootstrap" };
|
|
917
950
|
}
|
|
918
951
|
try {
|
|
919
|
-
const outcome = await
|
|
952
|
+
const outcome = await raceInitTimeout(
|
|
920
953
|
// At init there is no stored etag, so 'unchanged' cannot happen here;
|
|
921
954
|
// it is folded in anyway so a future caching layer cannot make init
|
|
922
955
|
// silently report failure.
|
|
923
|
-
this.loadDatafile().then((result) =>
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
})),
|
|
927
|
-
new Promise(
|
|
928
|
-
(resolve) => setTimeout(
|
|
929
|
-
() => resolve({ applied: false, timedOut: true }),
|
|
930
|
-
this.initTimeout
|
|
931
|
-
)
|
|
932
|
-
)
|
|
933
|
-
]);
|
|
956
|
+
this.loadDatafile().then((result) => result !== "failed"),
|
|
957
|
+
this.initTimeout
|
|
958
|
+
);
|
|
934
959
|
if (outcome.timedOut) {
|
|
935
960
|
this.startPolling();
|
|
936
961
|
const error2 = new Error(
|
|
@@ -939,7 +964,7 @@ var AvsbServer = class {
|
|
|
939
964
|
this.emit("error", error2);
|
|
940
965
|
return { success: false, source: "timeout", error: error2 };
|
|
941
966
|
}
|
|
942
|
-
if (outcome.
|
|
967
|
+
if (outcome.value) {
|
|
943
968
|
this.onDatafileReadable();
|
|
944
969
|
return { success: true, source: "network" };
|
|
945
970
|
}
|
|
@@ -1017,6 +1042,7 @@ var AvsbServer = class {
|
|
|
1017
1042
|
*/
|
|
1018
1043
|
applyDatafileBootstrap(datafile) {
|
|
1019
1044
|
if (this.destroyed) return;
|
|
1045
|
+
if (!acceptDatafile(datafile, "applyDatafileBootstrap", this.logger)) return;
|
|
1020
1046
|
const changed = this.datafile?.publishedAt !== datafile.publishedAt;
|
|
1021
1047
|
this.datafile = datafile;
|
|
1022
1048
|
this.datafileEtag = null;
|
|
@@ -1112,6 +1138,9 @@ var AvsbServer = class {
|
|
|
1112
1138
|
evaluateAndBuild(flagKey, defaultValue, context, decide, options) {
|
|
1113
1139
|
const legacyCtx = this.buildLegacyContext(context, decide.suppressExposure);
|
|
1114
1140
|
const result = this.evaluate(flagKey, legacyCtx, defaultValue);
|
|
1141
|
+
if (result.source === "not_found") {
|
|
1142
|
+
this.warnOnce(`missing:${flagKey}`, flagNotFoundMessage(flagKey));
|
|
1143
|
+
}
|
|
1115
1144
|
const typeMismatch = this.checkType(flagKey, options?.expectedType, result);
|
|
1116
1145
|
if (typeMismatch) {
|
|
1117
1146
|
this.logger.warn(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avsbhq/node",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Node.js server SDK for A vs B feature flags and experiments: per-request contexts, sticky bucketing, decision logs, and batched event delivery.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"avsb",
|
|
@@ -49,14 +49,15 @@
|
|
|
49
49
|
"url": "https://github.com/avsbhq/a-vs-b/issues"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
|
+
"prebuild": "node ../avsb-core/scripts/writeSdkVersion.mjs",
|
|
52
53
|
"build": "tsup",
|
|
53
54
|
"dev": "tsup --watch",
|
|
54
55
|
"test": "vitest run",
|
|
55
56
|
"test:watch": "vitest"
|
|
56
57
|
},
|
|
57
58
|
"dependencies": {
|
|
58
|
-
"@avsbhq/core": "^1.
|
|
59
|
-
"@avsbhq/utils": "^1.
|
|
59
|
+
"@avsbhq/core": "^1.6.0",
|
|
60
|
+
"@avsbhq/utils": "^1.2.1"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@types/node": "^20.0.0",
|