td-feature-node 0.0.0-stage → 0.0.2

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.
Files changed (49) hide show
  1. package/README.md +174 -2
  2. package/dist/client.d.ts +29 -0
  3. package/dist/client.js +206 -0
  4. package/dist/common/coercion.d.ts +4 -0
  5. package/dist/common/coercion.js +32 -0
  6. package/dist/common/config.d.ts +8 -0
  7. package/dist/common/config.js +172 -0
  8. package/dist/common/json.d.ts +7 -0
  9. package/dist/common/json.js +59 -0
  10. package/dist/core/config-snapshot.d.ts +15 -0
  11. package/dist/core/config-snapshot.js +58 -0
  12. package/dist/core/http-transport.d.ts +13 -0
  13. package/dist/core/http-transport.js +181 -0
  14. package/dist/core/request-operation.d.ts +16 -0
  15. package/dist/core/request-operation.js +73 -0
  16. package/dist/core/request-scheduler.d.ts +16 -0
  17. package/dist/core/request-scheduler.js +111 -0
  18. package/dist/core/request.d.ts +10 -0
  19. package/dist/core/request.js +72 -0
  20. package/dist/core/response.d.ts +5 -0
  21. package/dist/core/response.js +198 -0
  22. package/dist/core/result-cache.d.ts +14 -0
  23. package/dist/core/result-cache.js +70 -0
  24. package/dist/core/retry.d.ts +6 -0
  25. package/dist/core/retry.js +49 -0
  26. package/dist/core/stream-decoder.d.ts +10 -0
  27. package/dist/core/stream-decoder.js +29 -0
  28. package/dist/errors.d.ts +17 -0
  29. package/dist/errors.js +16 -0
  30. package/dist/evaluation.d.ts +10 -0
  31. package/dist/evaluation.js +9 -0
  32. package/dist/feature.d.ts +31 -0
  33. package/dist/feature.js +57 -0
  34. package/dist/index.d.ts +9 -0
  35. package/dist/index.js +11 -0
  36. package/dist/report/event.d.ts +3 -0
  37. package/dist/report/event.js +86 -0
  38. package/dist/report/expose-reporter.d.ts +40 -0
  39. package/dist/report/expose-reporter.js +285 -0
  40. package/dist/report/exposure-dedup.d.ts +16 -0
  41. package/dist/report/exposure-dedup.js +45 -0
  42. package/dist/report/owned-collection-adapter.d.ts +22 -0
  43. package/dist/report/owned-collection-adapter.js +243 -0
  44. package/dist/report/thinking-data-adapter.d.ts +11 -0
  45. package/dist/report/thinking-data-adapter.js +54 -0
  46. package/dist/types.d.ts +134 -0
  47. package/dist/types.js +9 -0
  48. package/npm-shrinkwrap.json +665 -0
  49. package/package.json +46 -4
package/README.md CHANGED
@@ -1,3 +1,175 @@
1
- # Temporary Holding Version
1
+ # TD Feature Node.js SDK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ThinkingData 实验平台的 Node.js 服务端 SDK,分阶段实现一期远端分流。
4
+
5
+ **当前版本:0.0.2。远端分流、曝光、完整服务 Demo、HTTPS、资源压力及发布检查已补齐;自动曝光默认关闭。0.0.2 待发布,0.0.1 已发布;未实现本地分流。真实项目与跨操作系统验收范围另行列明。**
6
+
7
+ ## 分阶段交付
8
+
9
+ | 阶段 | SDK | Demo | 状态 |
10
+ | --- | --- | --- | --- |
11
+ | S1 | npm 包、类型声明、初始化与关闭 | 初始化示例 | 已完成 |
12
+ | S2 | RCC 请求、签名、响应解码、Feature 取值 | Mock RCC / 真实 RCC 两种运行方式 | 已完成本地验收 |
13
+ | S3 | 单次超时、可选重试、有界并发/排队、结果缓存 | 重试恢复、缓存、过载和超时场景 | 已完成本地验收 |
14
+ | S4 | Java 对齐的手动/自动曝光、去重、队列、采集适配器 | 实际使用结果后曝光 | 已完成本地验收 |
15
+ | S5 | 完整兼容矩阵、负载、内部候选包验收 | 完整 API、进程关闭、显式业务回退 | 本机自动化验收;外部项保留 |
16
+
17
+ 详见 [接入指南](docs/INTEGRATION.md)、[任务清单](docs/TASKS.md)、[Java 对齐基准](docs/JAVA_CONTRACT.md)、[兼容性记录](docs/COMPATIBILITY.md)、[发布验收与保留项](docs/RELEASE.md)。
18
+
19
+ ## 使用
20
+
21
+ 当前待发布版本为 `td-feature-node@0.0.2`。npm 安装包只包含 `dist/`、README、包信息和纯生产依赖发布锁;完整文档与 Demo 保留在源码仓库,不随安装包分发。本文的构建、Demo 和验收命令均在源码仓库根目录执行。
22
+
23
+ 先构建并验证本地包:
24
+
25
+ ```sh
26
+ npm ci
27
+ npm test
28
+ npm run verify:package
29
+ ```
30
+
31
+ 0.0.2 发布后,在消费项目从 registry 安装;默认安装无需 TypeScript、Prettier 或开发依赖:
32
+
33
+ ```sh
34
+ npm install td-feature-node@0.0.2
35
+ ```
36
+
37
+ ```js
38
+ const { createClient, SplitMode } = require('td-feature-node');
39
+ const client = createClient({
40
+ splitMode: SplitMode.REMOTE,
41
+ appId: 'your_app_id',
42
+ serverUrl: 'https://your-rcc-server.example.com',
43
+ timeout: 1000,
44
+ retryTimes: 0,
45
+ maxConcurrentRequests: 20,
46
+ maxQueueSize: 0,
47
+ enableCache: false,
48
+ autoExposure: false
49
+ });
50
+
51
+ client.fetch({
52
+ accountId: 'user_123',
53
+ distinctId: 'device_456',
54
+ featureKeys: ['checkout_button'],
55
+ customParams: { country: 'CN' }
56
+ }).then(function (config) {
57
+ const feature = config.features['checkout_button'];
58
+ const text = feature ? feature.asString('购买') : '购买';
59
+ console.log(text, feature && feature.experiment && feature.experiment.experimentId);
60
+ if (feature) client.expose(feature, { scene: 'checkout' }); // 在实际使用值后曝光。
61
+ }, function (error) {
62
+ // 网络或协议错误由业务显式处理,不会伪装成成功的空配置。
63
+ console.error(error.code, error.message);
64
+ }).then(function () {
65
+ return client.close(); // 单次脚本示例;服务端应复用 Client,在进程退出时关闭。
66
+ }, function (error) {
67
+ console.error(error.code, error.message);
68
+ return client.close();
69
+ });
70
+ ```
71
+
72
+ createClient() 不访问网络。`splitMode` 默认 `SplitMode.REMOTE`,也可直接传 `'REMOTE'`。`SplitMode.LOCAL` 仅作后续扩展预留,当前传入会同步抛出 `INVALID_CONFIG`。`fetch()` 在缓存未命中时请求 RCC,默认不排队、不自动重试;需要时可显式开启。身份必须至少包含一个非空字符串。不指定 Feature 列表或列表为空时请求全部 Feature。`customParams` 只接受 JSON 数据,保留身份、语言和 Feature 字段由 SDK 覆盖;使用原生 `JSON.stringify`,对象和数组中的 null 均保留,未提供的账号/设备字段省略。
73
+
74
+ ## 配置
75
+
76
+ | 配置 | 默认值 | 说明 |
77
+ | --- | --- | --- |
78
+ | `appId` | 必填 | 平台项目 ID |
79
+ | `serverUrl` | 必填 | 远端决策与采集共用的 HTTP(S) 基础地址 |
80
+ | `splitMode` | `SplitMode.REMOTE` | 初始化时选择分流模式;当前仅实现 REMOTE,LOCAL 为预留值且会报错 |
81
+ | `timeout` | 5000 ms | 单次 HTTP 阶段超时,含连接池等待、连接和响应读取;到期中止请求 |
82
+ | `maxHttpConnections` | 20 | 同一 RCC 的连接池最大连接数 |
83
+ | `maxConcurrentRequests` | 与 `maxHttpConnections` 相同 | 活跃 fetch 上限,不能大于连接数;解码和退避期间保留槽位 |
84
+ | `maxQueueSize` | 0 | 默认不排队;可设 0–1000000,并发及等待位置满时返回 `QUEUE_FULL` |
85
+ | `retryTimes` | 0 | 默认不自动重试;首次请求后的最大重试次数,范围 0–10 |
86
+ | `retryInterval` | 500 ms | 首次退避间隔;允许 0 |
87
+ | `retryStrategy` | `EXPONENTIAL_BACKOFF` | 可选 `FIXED_INTERVAL` |
88
+ | `retryJitter` | true | 在退避区间内作 full jitter 随机延迟 |
89
+ | `maxRetryInterval` | 30000 ms | 重试等待上限;`Retry-After` 超过此值时停止重试,返回原 HTTP 错误 |
90
+ | `enableCache` | false | 显式启用成功结果缓存 |
91
+ | `cacheExpireTime` | 86400 秒 | RCC TTL 非正数时的回退;正数 RCC TTL 优先 |
92
+ | `maxCacheSize` | 10000 | LRU 缓存条目上限;0 不存储 |
93
+ | `maxDecompressedResponseBytes` | 10 MiB | `data` 字段 Base64/GZIP 解压后的上限,与 Java 默认值一致 |
94
+ | `autoExposure` | false | 开启后首次读取原始/类型化值时自动曝光 |
95
+ | `exposureDedupMaxSize` | 100000 | 曝光去重条目上限,独立于结果缓存 |
96
+ | `exposureDedupExpireSeconds` | 86400 秒 | 去重 TTL,读取不续期 |
97
+ | `exposureQueueCapacity` | 10000 | 等待曝光容量,范围 1–1000000;另有一个活跃任务 |
98
+ | `exposureBatchSize` | 100 | 自建采集器每批事件数,范围 1–10000 |
99
+ | `exposureFlushIntervalMillis` | 1000 ms | 自建采集器不足一批时的最长缓冲时间 |
100
+ | `exposureFlushTimeoutMillis` | 5000 ms | flush/close 等待预算;自建采集请求的单次 I/O 预算 |
101
+ | `tdAnalytics` | 未指定 | 复用业务已有 Node 采集实例,外部实例由业务关闭 |
102
+ | `exposureAdapter` | 未指定 | 自定义异步采集适配器,与 `tdAnalytics` 互斥,外部实例由业务关闭 |
103
+ | `tlsCa` | 运行时默认信任根 | 显式 PEM CA 字符串/数组会替换默认 CA 列表,作用于 RCC/自建采集 HTTPS,仍检查主机名 |
104
+
105
+ `timeout` 只控制每次 HTTP 尝试,重试会重新计时;排队、重试等待和 RCC 字段解码不计入该 HTTP 超时。整次 fetch 的耗时会随尝试次数与退避等待增加,可用 AbortSignal 提前取消。完整语义和示例见 [S3 请求可靠性与缓存](docs/RELIABILITY.md)。
106
+
107
+ 默认最多 20 个活跃 fetch,没有等待队列,失败只尝试一次。显式开启排队或重试后,整次 fetch 仍没有 SDK 内置的总期限;有业务截止时间时传入带期限的 signal。较早版本默认重试 3 次、排队 1000 项,接入升级时需留意默认行为变化。
108
+
109
+ `serverUrl` 不接受认证信息、查询参数或 fragment,末尾 `/` 会归一化。未知或未实现的配置会报错,当前不能设置本地模式。
110
+
111
+ ## Feature 读取
112
+
113
+ `fetch()` 返回只读的普通对象:
114
+
115
+ ```js
116
+ {
117
+ features: { checkout_button: feature },
118
+ ttl: 60 // RCC 原始 TTL,单位秒,不是缓存剩余寿命
119
+ }
120
+ ```
121
+
122
+ 通过 `result.features[key]` 查找,缺失时为 `undefined`;`Object.keys(result.features)` 枚举 Feature。字典没有原型,`__proto__`、`constructor` 等服务端 Key 也能安全读取,`experiment_detail` 不进入字典。
123
+
124
+ Feature 提供 `key`、`value`、`accountId`、`distinctId`、`experiment` 和 `hasValue` 属性;转换方法为 `asString(fallback)`、`asNumber(fallback)`、`asBoolean(fallback)`、`asJson(fallback)`。省略 fallback 时默认为 null;false、0、空字符串不会被业务默认值覆盖。
125
+
126
+ JSON 数字使用原生 `number`:`asString()` 按 `String(number)` 输出,对象和数组按 `JSON.stringify` 输出。例如 `1.0` 读取为 `1`、字符串为 `"1"`,`1e23` 的字符串为 `"1e+23"`。超出安全整数范围的数字遵循 JS 精度规则,`9007199254740993` 会读取为 `9007199254740992`;需要精确保留的长 ID 应由服务端作为字符串返回。
127
+
128
+ `asNumber()` 接受数字或非空数字字符串,按 `Number()` 解析;例如 `"0x10"` 为 16,`"1f"`、`"0x1.8p1"`、NaN、空白字符串和其他类型返回 fallback。`asBoolean()` 接受布尔值或大小写不限的 `"true"` / `"false"` 字符串。`asJson()` 接受对象或解析为对象的 JSON 字符串;空字符串、`"null"`、数组和无效 JSON 返回 fallback。
129
+
130
+ `experiment` 为 null 或只读实验信息对象;实验 ID、分组、控制组和自定义分桶直接从对象读取。不再提供重复元数据 getter,也不为远端模式构造版本/分流来源占位值。
131
+
132
+ 读取 `value` 或调用转换方法会触发可选自动曝光;读取元数据和 hasValue 不会。自动曝光默认关闭。展开、序列化 Feature 也会读取 value,调试元数据时只选取所需字段。
133
+
134
+ 未发布 API 在 rc.2 直接调整,不提供旧构造器或 getter 别名。旧候选迁移及维护约定见 [Node API 与编码约定](docs/NODE_STYLE.md)。
135
+
136
+ ## 曝光
137
+
138
+ `client.expose(feature, properties?)` 同步返回是否入队;未匹配、重复、队列满或已关闭返回 false。返回 true 不是入库确认。入队前复制属性,所有 `#` 开头的自定义属性被忽略。远端曝光只发送 Java 已提供的元数据,不从结果的 Feature Key 推造远端元数据。
139
+
140
+ 自动曝光默认关闭;显式开启后,`value` 和所有类型化读取首次触发,包括返回业务默认值的情况。元数据读取与 `hasValue` 不触发;每次 fetch(包括缓存命中)的结果各自有一次触发状态,再经过 Client 级去重。队列拒绝或提交失败撤销去重标记,新结果或手动曝光可重试,同一个自动结果不会再次触发。
141
+
142
+ 默认采集器按 100 条一批发送,不足一批最多缓冲 1 秒;同一批共用一次 GZIP/HTTP 请求。一个批次发送期间,后续曝光留在有容量限制的队列中。`client.flush()` 立即刷新余量,`client.close()` 在关闭预算内排空。复用外部采集实例时,保留该实例的批量和定时配置;官方 `track()` 同步返回,不逐条等待网络回调或触发 flush。
143
+
144
+ 默认自建采集器直接依赖官方 npm 包 `thinkingdata-node@1.5.0`,首次曝光时惰性加载;也可通过 `tdAnalytics` 复用已有官方实例。SDK 只为自建实例配置传输资源、TLS、超时与关闭,不修改官方包源码。依赖与适配边界见 [曝光与采集说明](docs/EXPOSURE.md)。
145
+
146
+ ## 错误与关闭
147
+
148
+ `fetch(request, options?)` 对参数、网络和协议错误均返回 Promise rejection。`FeatureError.code` 区分 `INVALID_CONFIG`、`INVALID_REQUEST`、`NETWORK_ERROR`、`HTTP_ERROR`、`REMOTE_ERROR`、`INVALID_RESPONSE`、`RESPONSE_TOO_LARGE`、`TIMEOUT`、`ABORTED`、`QUEUE_FULL`、`CLIENT_CLOSED`、`EXPOSURE_ERROR`。
149
+
150
+ HTTP 错误附 `httpStatus`;RCC 业务错误附原始 `remoteCode` 和 Java 映射后的 `errorCode`。未知 RCC code 映射到 5000,但保留原始 code。符合 Java 兼容契约的空数据/缺失模板仍是成功的空配置,也可缓存。
151
+
152
+ `clearCache()` 只清结果缓存,不清曝光去重。曝光入队后由 SDK 后台发送,业务请求无需调用或等待 `flush()`;它是测试、批处理等需要等待此前曝光处理完成时使用的可选接口。超时或适配器 flush 失败分别拒绝为 `TIMEOUT`、`EXPOSURE_ERROR`。单个曝光提交失败会撤销去重标记。
153
+
154
+ `close()` 停收、取消 fetch 和退避,有限等待曝光排空并关闭自建采集实例;超时丢弃待处理曝光并取消自有连接,外部采集实例仍由业务管理。方法可重复调用,关闭后不能再次 fetch。没有本地规则轮询或采集周期定时器。flush 控制槽和关闭的具体边界见 [曝光说明](docs/EXPOSURE.md)。
155
+
156
+
157
+ ## Demo 与兼容验证
158
+
159
+ ```sh
160
+ npm run demo
161
+ npm run demo:s3
162
+ npm run demo:s4
163
+ npm run demo:s5
164
+ npm run demo:service
165
+ npm run verify:package -- /path/to/node-v12.0.0/bin/node /path/to/another/node
166
+ npm run verify:production-install -- /path/to/node-v12.0.0/bin/node
167
+ ```
168
+
169
+ 基础 Demo 展示缓存,S3 展示可靠性,S4 展示曝光;S5 自动跑 API→RCC→缓存取值→曝光→采集→回退→关闭,service 提供常驻本机 API 和 SIGTERM 关闭。默认都只使用本机 Mock。完整接入和真实模式见 [指南](docs/INTEGRATION.md)、[Demo 说明](examples/node-demo/README.md)。真实 RCC、曝光入库及四进程生命周期的执行结果见 [真实联调](docs/LIVE_RCC_VERIFICATION.md)和 [PM2 验收](docs/PM2.md),记录保留实际验收时的版本号。
170
+
171
+ 验收从同一 tarball 安装 SDK,执行支持版本矩阵、最低 Node 版本的 engine-strict 生产安装、JS/TS/ESM 消费、八个压力场景及官方采集实例验证。测试和 Demo 从仓库运行,通过 `TD_FEATURE_SDK_PATH` 指向已安装的 SDK,生成绑定 SHA-256 的报告。命令、指标边界和公网发布保留项见 [压力验收](docs/PERFORMANCE.md)、[发布验收](docs/RELEASE.md)。没有自动发布步骤。
172
+
173
+ 当前版本为 `0.0.2`,最低 Node.js **12.0.0**,发布 ES2019 CommonJS 与 `.d.ts`。`thinkingdata-node@1.5.0` 保留为生产依赖;仓库使用完整的 `package-lock.json` 安装开发工具;打包时自动生成只含生产依赖的 `npm-shrinkwrap.json`,固定支持 Node 12 的依赖树,包括 `winston@3.17.0`、`@dabh/diagnostics@2.0.3`、`triple-beam@1.3.0`、`color@3.2.1` 和 `color-string@1.9.1`。打包结束会移除临时发布锁,保留正常的仓库 npm ci 流程。仓库和发布包均不使用 overrides,不包含 vendor 或修改过的官方源码。构建工具属于开发依赖,维护构建使用现代 Node;TLS 功能测试另需 OpenSSL CLI,消费 SDK 无需该命令。Node 12.0–12.16 的 ESM 导入需启用 `--experimental-modules`,CommonJS 无需该参数。
174
+
175
+ Java 固定样本已纳入测试;维护者可用 `npm run fixtures:java` 从同级 Java SDK 当前源码重新生成。该命令需要 JDK 17+、Python 3 和脚本列出的本地 Maven 缓存依赖,不修改 Java 项目,不访问真实 RCC。
@@ -0,0 +1,29 @@
1
+ import type { ClientOptions, ResolvedClientOptions, FeatureRequest, FetchOptions, ExposureProperties } from './types';
2
+ import { Evaluation } from './evaluation';
3
+ import { Feature } from './feature';
4
+ /** REMOTE evaluation client. Construction performs no network I/O. */
5
+ export declare class Client {
6
+ private readonly config;
7
+ private readonly transport;
8
+ private readonly pending;
9
+ private readonly operations;
10
+ private readonly scheduler;
11
+ private readonly cache;
12
+ private readonly reporter;
13
+ private readonly automaticExposure;
14
+ private cacheGeneration;
15
+ private closing;
16
+ private closePromise;
17
+ constructor(options: ClientOptions);
18
+ get options(): Readonly<ResolvedClientOptions>;
19
+ get closed(): boolean;
20
+ fetch(request: FeatureRequest, options?: FetchOptions): Promise<Evaluation>;
21
+ private evaluate;
22
+ clearCache(): void;
23
+ /** Returns queue acceptance only; duplicates, unmatched results and overload return false. */
24
+ expose(feature: Feature | null | undefined, properties?: ExposureProperties | null): boolean;
25
+ flush(): Promise<void>;
26
+ close(): Promise<void>;
27
+ }
28
+ /** Create one long-lived client per project; close it during application shutdown. */
29
+ export declare function createClient(options: ClientOptions): Client;
package/dist/client.js ADDED
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Client = void 0;
4
+ exports.createClient = createClient;
5
+ const config_1 = require("./common/config");
6
+ const errors_1 = require("./errors");
7
+ const http_transport_1 = require("./core/http-transport");
8
+ const request_1 = require("./core/request");
9
+ const response_1 = require("./core/response");
10
+ const request_operation_1 = require("./core/request-operation");
11
+ const request_scheduler_1 = require("./core/request-scheduler");
12
+ const config_snapshot_1 = require("./core/config-snapshot");
13
+ const result_cache_1 = require("./core/result-cache");
14
+ const retry_1 = require("./core/retry");
15
+ const feature_1 = require("./feature");
16
+ const expose_reporter_1 = require("./report/expose-reporter");
17
+ const thinking_data_adapter_1 = require("./report/thinking-data-adapter");
18
+ const owned_collection_adapter_1 = require("./report/owned-collection-adapter");
19
+ /** REMOTE evaluation client. Construction performs no network I/O. */
20
+ class Client {
21
+ constructor(options) {
22
+ this.pending = new Set();
23
+ this.operations = new Set();
24
+ this.cacheGeneration = 0;
25
+ this.closing = false;
26
+ this.config = (0, config_1.normalizeOptions)(options);
27
+ this.transport = new http_transport_1.HttpTransport(this.config);
28
+ this.scheduler = new request_scheduler_1.Scheduler(this.config.maxConcurrentRequests, this.config.maxQueueSize);
29
+ this.cache = this.config.enableCache
30
+ ? new result_cache_1.ResultCache(this.config.maxCacheSize, this.config.cacheExpireTime)
31
+ : null;
32
+ const adapter = this.config.exposureAdapter ||
33
+ (this.config.tdAnalytics
34
+ ? new thinking_data_adapter_1.ThinkingDataAdapter(this.config.tdAnalytics)
35
+ : new owned_collection_adapter_1.OwnedCollectionAdapter(this.config));
36
+ this.reporter = new expose_reporter_1.ExposeReporter(adapter, !this.config.exposureAdapter && !this.config.tdAnalytics, this.config);
37
+ this.automaticExposure = this.config.autoExposure
38
+ ? (feature) => {
39
+ this.reporter.report(feature);
40
+ }
41
+ : undefined;
42
+ }
43
+ get options() {
44
+ return this.config;
45
+ }
46
+ get closed() {
47
+ return this.closing;
48
+ }
49
+ fetch(request, options = {}) {
50
+ if (this.closing)
51
+ return Promise.reject(new errors_1.FeatureError('Client is closed', 'CLIENT_CLOSED'));
52
+ let operation;
53
+ try {
54
+ validateFetchOptions(options);
55
+ const op = (operation = new request_operation_1.Operation(options.signal));
56
+ this.operations.add(op);
57
+ op.check();
58
+ const built = (0, request_1.buildRequest)(this.config.appId, request);
59
+ op.check();
60
+ const key = this.cache ? (0, result_cache_1.configCacheKey)(built.body) : '';
61
+ const cached = this.cache ? this.cache.get(key) : null;
62
+ op.check();
63
+ if (cached) {
64
+ const result = (0, config_snapshot_1.bindSnapshot)(cached, built.accountId, built.distinctId, this.automaticExposure);
65
+ op.check();
66
+ op.dispose();
67
+ this.operations.delete(op);
68
+ return Promise.resolve(result);
69
+ }
70
+ const generation = this.cacheGeneration;
71
+ let completed;
72
+ const work = this.scheduler
73
+ .submit(op, () => this.evaluate(built, op))
74
+ .then((snapshot) => {
75
+ completed = snapshot;
76
+ op.check();
77
+ const result = (0, config_snapshot_1.bindSnapshot)(snapshot, built.accountId, built.distinctId, this.automaticExposure);
78
+ op.check();
79
+ if (this.cache && generation === this.cacheGeneration) {
80
+ this.cache.set(key, snapshot);
81
+ try {
82
+ op.check();
83
+ }
84
+ catch (error) {
85
+ this.cache.removeIfSame(key, snapshot);
86
+ throw error;
87
+ }
88
+ }
89
+ return result;
90
+ });
91
+ const promise = work.then((result) => {
92
+ try {
93
+ op.check();
94
+ return result;
95
+ }
96
+ catch (error) {
97
+ if (this.cache && completed)
98
+ this.cache.removeIfSame(key, completed);
99
+ throw error;
100
+ }
101
+ finally {
102
+ op.dispose();
103
+ this.operations.delete(op);
104
+ }
105
+ }, (error) => {
106
+ op.dispose();
107
+ this.operations.delete(op);
108
+ throw error;
109
+ });
110
+ this.pending.add(promise);
111
+ const remove = () => {
112
+ this.pending.delete(promise);
113
+ };
114
+ promise.then(remove, remove);
115
+ return promise;
116
+ }
117
+ catch (error) {
118
+ if (operation) {
119
+ operation.dispose();
120
+ this.operations.delete(operation);
121
+ }
122
+ return Promise.reject(error);
123
+ }
124
+ }
125
+ evaluate(built, operation) {
126
+ let retries = 0;
127
+ const attempt = () => {
128
+ operation.check();
129
+ return this.transport
130
+ .send(built, operation)
131
+ .then((response) => {
132
+ operation.check();
133
+ return (0, response_1.parseSnapshot)(response, this.config.maxDecompressedResponseBytes, operation);
134
+ })
135
+ .then((snapshot) => {
136
+ operation.check();
137
+ return snapshot;
138
+ }, (error) => {
139
+ operation.check();
140
+ if (retries >= this.config.retryTimes || !(0, retry_1.retryable)(error))
141
+ throw error;
142
+ const delay = (0, retry_1.retryDelay)(retries, error, this.config);
143
+ if (delay === null)
144
+ throw error;
145
+ retries++;
146
+ return operation.sleep(delay).then(attempt);
147
+ });
148
+ };
149
+ return attempt();
150
+ }
151
+ clearCache() {
152
+ this.cacheGeneration++;
153
+ if (this.cache)
154
+ this.cache.clear();
155
+ }
156
+ /** Returns queue acceptance only; duplicates, unmatched results and overload return false. */
157
+ expose(feature, properties) {
158
+ if (feature !== null && feature !== undefined && !(0, feature_1.isFeature)(feature))
159
+ throw new errors_1.FeatureError('expose requires a feature returned by fetch', 'INVALID_REQUEST');
160
+ return this.reporter.report(feature, properties);
161
+ }
162
+ flush() {
163
+ return this.reporter.flush();
164
+ }
165
+ close() {
166
+ if (!this.closePromise) {
167
+ this.closing = true;
168
+ this.scheduler.close();
169
+ Array.from(this.operations).forEach(function (operation) {
170
+ operation.cancel(new errors_1.FeatureError('Client is closed', 'CLIENT_CLOSED'));
171
+ });
172
+ this.transport.close();
173
+ this.clearCache();
174
+ const exposures = this.reporter.close();
175
+ this.closePromise = Promise.all(Array.from(this.pending)
176
+ .map(function (promise) {
177
+ return promise.then(function () { }, function () { });
178
+ })
179
+ .concat([exposures])).then(function () { });
180
+ }
181
+ return this.closePromise;
182
+ }
183
+ }
184
+ exports.Client = Client;
185
+ function validateFetchOptions(options) {
186
+ if (!options ||
187
+ typeof options !== 'object' ||
188
+ Array.isArray(options) ||
189
+ Object.keys(options).some(function (key) {
190
+ return key !== 'signal';
191
+ })) {
192
+ throw new errors_1.FeatureError('Invalid fetch options', 'INVALID_REQUEST');
193
+ }
194
+ const signal = options.signal;
195
+ if (signal !== undefined &&
196
+ (!signal ||
197
+ typeof signal.aborted !== 'boolean' ||
198
+ typeof signal.addEventListener !== 'function' ||
199
+ typeof signal.removeEventListener !== 'function')) {
200
+ throw new errors_1.FeatureError('signal must implement AbortSignalLike', 'INVALID_REQUEST');
201
+ }
202
+ }
203
+ /** Create one long-lived client per project; close it during application shutdown. */
204
+ function createClient(options) {
205
+ return new Client(options);
206
+ }
@@ -0,0 +1,4 @@
1
+ import type { JsonObject, JsonValue } from '../types';
2
+ export declare function toNumber(value: JsonValue, fallback: number | null): number | null;
3
+ export declare function toBoolean(value: JsonValue, fallback: boolean | null): boolean | null;
4
+ export declare function toJson(value: JsonValue, fallback: JsonObject | null): JsonObject | null;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toNumber = toNumber;
4
+ exports.toBoolean = toBoolean;
5
+ exports.toJson = toJson;
6
+ const json_1 = require("./json");
7
+ function toNumber(value, fallback) {
8
+ if (typeof value !== 'number' && (typeof value !== 'string' || value.trim() === ''))
9
+ return fallback;
10
+ const number = Number(value);
11
+ return Number.isNaN(number) ? fallback : number;
12
+ }
13
+ function toBoolean(value, fallback) {
14
+ if (typeof value === 'boolean')
15
+ return value;
16
+ return typeof value === 'string' && /^(true|false)$/i.test(value)
17
+ ? value.toLowerCase() === 'true'
18
+ : fallback;
19
+ }
20
+ function toJson(value, fallback) {
21
+ if ((0, json_1.isObject)(value))
22
+ return (0, json_1.freezeJson)(value);
23
+ if (typeof value !== 'string')
24
+ return fallback;
25
+ try {
26
+ const parsed = JSON.parse(value);
27
+ return (0, json_1.isObject)(parsed) ? (0, json_1.freezeJson)(parsed) : fallback;
28
+ }
29
+ catch (_) {
30
+ return fallback;
31
+ }
32
+ }
@@ -0,0 +1,8 @@
1
+ import { type ClientOptions, type ResolvedClientOptions } from '../types';
2
+ export declare function normalizeOptions(options: ClientOptions): Readonly<ResolvedClientOptions>;
3
+ export declare function requestLocation(baseUrl: string): {
4
+ protocol: string;
5
+ hostname: string;
6
+ port: string | null;
7
+ path: string;
8
+ };
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeOptions = normalizeOptions;
4
+ exports.requestLocation = requestLocation;
5
+ const net_1 = require("net");
6
+ const url_1 = require("url");
7
+ const types_1 = require("../types");
8
+ const errors_1 = require("../errors");
9
+ function normalizeServerUrl(value) {
10
+ if (typeof value !== 'string' || value.length === 0 || /[\s\\]/.test(value)) {
11
+ throw new errors_1.FeatureError('serverUrl must be a non-empty HTTP(S) base URL');
12
+ }
13
+ const authority = /^https?:\/\/([^/?#]+)/i.exec(value);
14
+ const address = authority && /^(\[[^\]]+\]|[^:@\[\]]+)(?::([0-9]+))?$/.exec(authority[1]);
15
+ if (!address) {
16
+ throw new errors_1.FeatureError('serverUrl must include a valid HTTP(S) host and optional port');
17
+ }
18
+ if (/[?#]/.test(value) || /[%|^`<>"'{}]/.test(address[1])) {
19
+ throw new errors_1.FeatureError('serverUrl must be a base URL with a valid host and no query or fragment');
20
+ }
21
+ if (address[1].charAt(0) === '[' && (0, net_1.isIP)(address[1].slice(1, -1)) !== 6) {
22
+ throw new errors_1.FeatureError('serverUrl contains an invalid IPv6 host');
23
+ }
24
+ if (address[2] !== undefined && (Number(address[2]) < 1 || Number(address[2]) > 65535)) {
25
+ throw new errors_1.FeatureError('serverUrl port must be between 1 and 65535');
26
+ }
27
+ try {
28
+ const parsed = new url_1.URL(value);
29
+ if (!parsed.hostname || parsed.username || parsed.password)
30
+ throw new Error('invalid base URL');
31
+ }
32
+ catch (_) {
33
+ throw new errors_1.FeatureError('serverUrl must be a base URL without credentials, query or fragment');
34
+ }
35
+ return value.replace(/\/+$/, '');
36
+ }
37
+ function normalizeOptions(options) {
38
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
39
+ throw new errors_1.FeatureError('client options are required');
40
+ }
41
+ Object.keys(options).forEach(function (key) {
42
+ if ([
43
+ 'appId',
44
+ 'serverUrl',
45
+ 'splitMode',
46
+ 'timeout',
47
+ 'maxHttpConnections',
48
+ 'maxDecompressedResponseBytes',
49
+ 'retryTimes',
50
+ 'retryInterval',
51
+ 'retryStrategy',
52
+ 'retryJitter',
53
+ 'maxRetryInterval',
54
+ 'maxConcurrentRequests',
55
+ 'maxQueueSize',
56
+ 'enableCache',
57
+ 'cacheExpireTime',
58
+ 'maxCacheSize',
59
+ 'autoExposure',
60
+ 'exposureDedupMaxSize',
61
+ 'exposureDedupExpireSeconds',
62
+ 'exposureQueueCapacity',
63
+ 'exposureBatchSize',
64
+ 'exposureFlushIntervalMillis',
65
+ 'exposureFlushTimeoutMillis',
66
+ 'exposureAdapter',
67
+ 'tdAnalytics',
68
+ 'tlsCa',
69
+ ].indexOf(key) < 0) {
70
+ throw new errors_1.FeatureError('Unsupported client option: ' + key);
71
+ }
72
+ });
73
+ if (typeof options.appId !== 'string' ||
74
+ options.appId.trim().length === 0 ||
75
+ /[\x00-\x1f\x7f]/.test(options.appId)) {
76
+ throw new errors_1.FeatureError('appId must be a non-empty string');
77
+ }
78
+ const splitMode = options.splitMode === undefined ? types_1.SplitMode.REMOTE : options.splitMode;
79
+ if (splitMode !== types_1.SplitMode.REMOTE)
80
+ throw new errors_1.FeatureError('Only splitMode REMOTE is currently supported');
81
+ const timeout = positiveInteger(options.timeout, 5000, 'timeout');
82
+ const connections = positiveInteger(options.maxHttpConnections, 20, 'maxHttpConnections');
83
+ const concurrent = positiveInteger(options.maxConcurrentRequests, connections, 'maxConcurrentRequests');
84
+ if (concurrent > connections)
85
+ throw new errors_1.FeatureError('maxConcurrentRequests must not exceed maxHttpConnections');
86
+ const strategy = options.retryStrategy === undefined ? 'EXPONENTIAL_BACKOFF' : options.retryStrategy;
87
+ if (strategy !== 'FIXED_INTERVAL' && strategy !== 'EXPONENTIAL_BACKOFF')
88
+ throw new errors_1.FeatureError('Invalid retryStrategy');
89
+ if (options.exposureAdapter !== undefined && options.tdAnalytics !== undefined)
90
+ throw new errors_1.FeatureError('exposureAdapter and tdAnalytics are mutually exclusive');
91
+ [options.exposureAdapter, options.tdAnalytics].forEach(function (adapter) {
92
+ if (adapter !== undefined &&
93
+ (!adapter || typeof adapter.track !== 'function' || typeof adapter.flush !== 'function')) {
94
+ throw new errors_1.FeatureError('Collection adapter must implement track and flush');
95
+ }
96
+ });
97
+ if (options.tdAnalytics !== undefined && typeof options.tdAnalytics.close !== 'function')
98
+ throw new errors_1.FeatureError('tdAnalytics must implement close');
99
+ return Object.freeze({
100
+ appId: options.appId,
101
+ serverUrl: normalizeServerUrl(options.serverUrl),
102
+ splitMode,
103
+ timeout: timeout,
104
+ maxHttpConnections: connections,
105
+ maxDecompressedResponseBytes: positiveInteger(options.maxDecompressedResponseBytes, 10 * 1024 * 1024, 'maxDecompressedResponseBytes'),
106
+ retryTimes: boundedInteger(options.retryTimes, 0, 'retryTimes', 0, 10),
107
+ retryInterval: boundedInteger(options.retryInterval, 500, 'retryInterval', 0),
108
+ retryStrategy: strategy,
109
+ retryJitter: booleanOption(options.retryJitter, true, 'retryJitter'),
110
+ maxRetryInterval: boundedInteger(options.maxRetryInterval, 30000, 'maxRetryInterval', 0),
111
+ maxConcurrentRequests: concurrent,
112
+ maxQueueSize: boundedInteger(options.maxQueueSize, 0, 'maxQueueSize', 0, 1000000),
113
+ enableCache: booleanOption(options.enableCache, false, 'enableCache'),
114
+ cacheExpireTime: boundedInteger(options.cacheExpireTime, 86400, 'cacheExpireTime', 0),
115
+ maxCacheSize: boundedInteger(options.maxCacheSize, 10000, 'maxCacheSize', 0),
116
+ autoExposure: booleanOption(options.autoExposure, false, 'autoExposure'),
117
+ exposureDedupMaxSize: positiveInteger(options.exposureDedupMaxSize, 100000, 'exposureDedupMaxSize'),
118
+ exposureDedupExpireSeconds: positiveInteger(options.exposureDedupExpireSeconds, 86400, 'exposureDedupExpireSeconds'),
119
+ exposureQueueCapacity: boundedInteger(options.exposureQueueCapacity, 10000, 'exposureQueueCapacity', 1, 1000000),
120
+ exposureBatchSize: boundedInteger(options.exposureBatchSize, 100, 'exposureBatchSize', 1, 10000),
121
+ exposureFlushIntervalMillis: positiveInteger(options.exposureFlushIntervalMillis, 1000, 'exposureFlushIntervalMillis'),
122
+ exposureFlushTimeoutMillis: positiveInteger(options.exposureFlushTimeoutMillis, 5000, 'exposureFlushTimeoutMillis'),
123
+ exposureAdapter: options.exposureAdapter,
124
+ tdAnalytics: options.tdAnalytics,
125
+ tlsCa: normalizeCa(options.tlsCa),
126
+ });
127
+ }
128
+ function normalizeCa(ca) {
129
+ if (ca === undefined)
130
+ return undefined;
131
+ const entries = Array.isArray(ca) ? ca : [ca];
132
+ if (entries.length === 0 ||
133
+ entries.some(function (entry) {
134
+ return (typeof entry !== 'string' ||
135
+ entry.trim().length === 0 ||
136
+ entry.indexOf('-----BEGIN CERTIFICATE-----') < 0);
137
+ }))
138
+ throw new errors_1.FeatureError('tlsCa must contain PEM CA certificates');
139
+ return Array.isArray(ca) ? Object.freeze(entries.slice()) : ca;
140
+ }
141
+ function positiveInteger(value, fallback, name) {
142
+ return boundedInteger(value, fallback, name, 1);
143
+ }
144
+ function boundedInteger(value, fallback, name, minimum, maximum = 2147483647) {
145
+ if (value === undefined)
146
+ return fallback;
147
+ if (typeof value !== 'number' ||
148
+ !isFinite(value) ||
149
+ Math.floor(value) !== value ||
150
+ value < minimum ||
151
+ value > maximum) {
152
+ throw new errors_1.FeatureError(name + ' must be an integer between ' + minimum + ' and ' + maximum);
153
+ }
154
+ return value;
155
+ }
156
+ function booleanOption(value, fallback, name) {
157
+ if (value === undefined)
158
+ return fallback;
159
+ if (typeof value !== 'boolean')
160
+ throw new errors_1.FeatureError(name + ' must be a boolean');
161
+ return value;
162
+ }
163
+ function requestLocation(baseUrl) {
164
+ const endpoint = baseUrl + '/v1/remote-config/get';
165
+ const parsed = new url_1.URL(endpoint);
166
+ return {
167
+ protocol: parsed.protocol,
168
+ hostname: parsed.hostname.replace(/^\[|\]$/g, ''),
169
+ port: parsed.port || null,
170
+ path: parsed.pathname,
171
+ };
172
+ }
@@ -0,0 +1,7 @@
1
+ import type { JsonValue, JsonObject } from '../types';
2
+ export declare function isObject(value: JsonValue | undefined): value is JsonObject;
3
+ export declare function stringValue(value: JsonValue | undefined): string | null;
4
+ /** Freeze a detached JSON copy once, then reuse SDK-owned immutable values. */
5
+ export declare function freezeJson(value: JsonValue): JsonValue;
6
+ /** Snapshot a caller's JSON data without invoking toJSON hooks or losing reserved keys. */
7
+ export declare function snapshotJson(value: JsonValue, ancestors?: object[]): JsonValue;