@ubean/server 0.1.12 → 0.2.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/dist/analytics-entry.d.ts +2 -0
- package/dist/analytics-entry.js +2 -0
- package/dist/cache-C84ix1Vq.js +173 -0
- package/dist/cache-b-MZlyv0.d.ts +48 -0
- package/dist/cache-directive-C1Nekkza.js +304 -0
- package/dist/cache-directive-CAxJAQyE.d.ts +175 -0
- package/dist/cache-directive.d.ts +2 -0
- package/dist/cache-directive.js +2 -0
- package/dist/cache-entry.d.ts +3 -0
- package/dist/cache-entry.js +3 -0
- package/dist/cron-entry.d.ts +2 -0
- package/dist/cron-entry.js +2 -0
- package/dist/cron-scheduler-BF33PPn4.d.ts +77 -0
- package/dist/cron-scheduler-BVuXv7nn.js +258 -0
- package/dist/database-CfpFznl-.d.ts +67 -0
- package/dist/database-DNrY44SQ.js +352 -0
- package/dist/database.d.ts +2 -0
- package/dist/database.js +2 -0
- package/dist/email-BjfRiR9b.js +354 -0
- package/dist/email-BvpEuNn_.d.ts +226 -0
- package/dist/email.d.ts +2 -0
- package/dist/email.js +2 -0
- package/dist/feature-flags-CdLwsMD2.js +657 -0
- package/dist/feature-flags-DWkS6p0D.d.ts +386 -0
- package/dist/fetch-memo-rbkxxnW4.js +338 -0
- package/dist/index.d.ts +183 -488
- package/dist/index.js +352 -2023
- package/dist/middleware.d.ts +2 -0
- package/dist/middleware.js +3 -0
- package/dist/observability-Cio6Qq1H.js +339 -0
- package/dist/observability-DUNUEjj3.d.ts +70 -0
- package/dist/observability.d.ts +2 -0
- package/dist/observability.js +2 -0
- package/dist/queue-Bwzi3mhK.js +210 -0
- package/dist/queue-GOfTAWlz.d.ts +55 -0
- package/dist/queue.d.ts +2 -0
- package/dist/queue.js +2 -0
- package/dist/realtime.d.ts +2 -0
- package/dist/realtime.js +2 -0
- package/dist/security.d.ts +2 -0
- package/dist/security.js +2 -0
- package/dist/sessions-BLqFFQTL.d.ts +217 -0
- package/dist/sessions-BsBsyFAG.js +450 -0
- package/dist/single-flight-BJyhDLdU.d.ts +422 -0
- package/dist/single-flight-mJ4ZKbx1.js +715 -0
- package/dist/sse-Ct72zhic.d.ts +95 -0
- package/dist/sse-a6Ky9Vcl.js +310 -0
- package/dist/static-DPHaovQe.js +90 -0
- package/dist/static-K2dRvjpS.d.ts +11 -0
- package/dist/static.d.ts +2 -0
- package/dist/static.js +2 -0
- package/dist/storage-BZLMaqHr.js +162 -0
- package/dist/storage-QdlPtPtR.d.ts +48 -0
- package/dist/storage.d.ts +2 -0
- package/dist/storage.js +2 -0
- package/package.json +68 -6
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
//#region src/analytics.ts
|
|
2
|
+
/** log provider:输出到 console */
|
|
3
|
+
function createLogAnalyticsProvider() {
|
|
4
|
+
return {
|
|
5
|
+
name: "log",
|
|
6
|
+
track(event, properties, context) {
|
|
7
|
+
const summary = {
|
|
8
|
+
event,
|
|
9
|
+
...properties
|
|
10
|
+
};
|
|
11
|
+
if (Object.keys(context).length > 0) summary.context = context;
|
|
12
|
+
console.log("[analytics]", JSON.stringify(summary));
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** memory provider:存储在内存,用于测试 */
|
|
17
|
+
function createMemoryAnalyticsProvider() {
|
|
18
|
+
const records = [];
|
|
19
|
+
return {
|
|
20
|
+
name: "memory",
|
|
21
|
+
records,
|
|
22
|
+
track(event, properties, context) {
|
|
23
|
+
records.push({
|
|
24
|
+
event,
|
|
25
|
+
type: event === "page_view" ? "page_view" : "event",
|
|
26
|
+
timestamp: Date.now(),
|
|
27
|
+
properties: { ...properties },
|
|
28
|
+
context: { ...context }
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
flush: async () => {},
|
|
32
|
+
destroy() {
|
|
33
|
+
records.length = 0;
|
|
34
|
+
},
|
|
35
|
+
clear() {
|
|
36
|
+
records.length = 0;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** mock provider:记录调用次数,用于断言 */
|
|
41
|
+
function createMockAnalyticsProvider(impl) {
|
|
42
|
+
const calls = [];
|
|
43
|
+
return {
|
|
44
|
+
name: impl?.name || "mock",
|
|
45
|
+
calls,
|
|
46
|
+
get callCount() {
|
|
47
|
+
return calls.length;
|
|
48
|
+
},
|
|
49
|
+
track(event, properties, context) {
|
|
50
|
+
calls.push({
|
|
51
|
+
event,
|
|
52
|
+
properties: { ...properties },
|
|
53
|
+
context: { ...context }
|
|
54
|
+
});
|
|
55
|
+
impl?.track?.(event, properties, context);
|
|
56
|
+
},
|
|
57
|
+
flush: impl?.flush,
|
|
58
|
+
destroy: impl?.destroy,
|
|
59
|
+
reset() {
|
|
60
|
+
calls.length = 0;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const providerRegistry = /* @__PURE__ */ new Map();
|
|
65
|
+
let globalProvider = null;
|
|
66
|
+
/** 注册一个 provider 到全局 registry */
|
|
67
|
+
function registerAnalyticsProvider(provider) {
|
|
68
|
+
providerRegistry.set(provider.name, provider);
|
|
69
|
+
}
|
|
70
|
+
/** 取消注册 */
|
|
71
|
+
function unregisterAnalyticsProvider(name) {
|
|
72
|
+
providerRegistry.delete(name);
|
|
73
|
+
}
|
|
74
|
+
/** 从 registry 获取指定名称的 provider */
|
|
75
|
+
function getAnalyticsProvider(name) {
|
|
76
|
+
return providerRegistry.get(name);
|
|
77
|
+
}
|
|
78
|
+
/** 获取所有已注册的 provider */
|
|
79
|
+
function listAnalyticsProviders() {
|
|
80
|
+
return Array.from(providerRegistry.values());
|
|
81
|
+
}
|
|
82
|
+
/** 清空 registry(主要供测试使用) */
|
|
83
|
+
function clearAnalyticsProviders() {
|
|
84
|
+
providerRegistry.clear();
|
|
85
|
+
globalProvider = null;
|
|
86
|
+
}
|
|
87
|
+
/** 设置全局默认 provider */
|
|
88
|
+
function setGlobalAnalyticsProvider(provider) {
|
|
89
|
+
globalProvider = provider;
|
|
90
|
+
}
|
|
91
|
+
/** 获取全局默认 provider(若未设置,返回空 provider) */
|
|
92
|
+
function getGlobalAnalyticsProvider() {
|
|
93
|
+
if (globalProvider) return globalProvider;
|
|
94
|
+
const fallback = createLogAnalyticsProvider();
|
|
95
|
+
globalProvider = fallback;
|
|
96
|
+
return fallback;
|
|
97
|
+
}
|
|
98
|
+
/** 从 Hono 上下文中自动抽取 analytics context */
|
|
99
|
+
function extractAnalyticsContext(c) {
|
|
100
|
+
const path = c.req.path;
|
|
101
|
+
const method = c.req.method;
|
|
102
|
+
const referrer = c.req.header("referer") || c.req.header("referrer") || void 0;
|
|
103
|
+
const userAgent = c.req.header("user-agent") || void 0;
|
|
104
|
+
const locale = c.req.header("accept-language") || void 0;
|
|
105
|
+
const requestId = c.get("requestId") || void 0;
|
|
106
|
+
const ip = c.req.header("x-forwarded-for")?.split(",")[0].trim() || c.req.header("x-real-ip") || void 0;
|
|
107
|
+
const ctxLocale = c.get("locale") || locale;
|
|
108
|
+
return {
|
|
109
|
+
path,
|
|
110
|
+
method,
|
|
111
|
+
status: c.res?.status,
|
|
112
|
+
referrer,
|
|
113
|
+
userAgent,
|
|
114
|
+
locale: ctxLocale,
|
|
115
|
+
requestId,
|
|
116
|
+
ip
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function resolveProviders(provider) {
|
|
120
|
+
const source = provider ?? getGlobalAnalyticsProvider();
|
|
121
|
+
if (Array.isArray(source)) return source;
|
|
122
|
+
return [source];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 跟踪一次页面浏览
|
|
126
|
+
*/
|
|
127
|
+
async function trackPageView(c, options = {}) {
|
|
128
|
+
const { autoContext = true, properties = {}, eventName = "page_view", awaitFlush = false } = options;
|
|
129
|
+
const context = autoContext ? extractAnalyticsContext(c) : {};
|
|
130
|
+
const providers = resolveProviders();
|
|
131
|
+
const tasks = providers.map((p) => p.track(eventName, properties, context));
|
|
132
|
+
if (awaitFlush) {
|
|
133
|
+
await Promise.all(tasks);
|
|
134
|
+
await Promise.all(providers.map((p) => p.flush?.()));
|
|
135
|
+
} else Promise.all(tasks).catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 跟踪一次自定义事件
|
|
139
|
+
*/
|
|
140
|
+
async function trackEvent(c, name, properties = {}) {
|
|
141
|
+
const context = extractAnalyticsContext(c);
|
|
142
|
+
const tasks = resolveProviders().map((p) => p.track(name, properties, context));
|
|
143
|
+
Promise.all(tasks).catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
/** 直接调用 provider 跟踪事件(无 Hono 上下文) */
|
|
146
|
+
async function trackRaw(event, properties = {}, context = {}, provider) {
|
|
147
|
+
const providers = resolveProviders(provider);
|
|
148
|
+
await Promise.all(providers.map((p) => p.track(event, properties, context)));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 定义一个 analytics provider,并注册到全局 registry
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```ts
|
|
155
|
+
* const provider = defineAnalyticsProvider({
|
|
156
|
+
* name: 'posthog',
|
|
157
|
+
* track(event, properties, context) {
|
|
158
|
+
* await fetch('https://app.posthog.com/capture/', { ... });
|
|
159
|
+
* }
|
|
160
|
+
* });
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
function defineAnalyticsProvider(options) {
|
|
164
|
+
if (!options.name) throw new Error("[ubean/analytics] AnalyticsProvider requires a `name`");
|
|
165
|
+
const provider = {
|
|
166
|
+
name: options.name,
|
|
167
|
+
track: options.track,
|
|
168
|
+
flush: options.flush,
|
|
169
|
+
destroy: options.destroy
|
|
170
|
+
};
|
|
171
|
+
registerAnalyticsProvider(provider);
|
|
172
|
+
return provider;
|
|
173
|
+
}
|
|
174
|
+
function isExcluded(path, exclude) {
|
|
175
|
+
if (!exclude || exclude.length === 0) return false;
|
|
176
|
+
return exclude.some((pattern) => {
|
|
177
|
+
if (pattern.endsWith("/**")) return path.startsWith(pattern.slice(0, -3));
|
|
178
|
+
if (pattern.endsWith("/*")) {
|
|
179
|
+
const prefix = pattern.slice(0, -2);
|
|
180
|
+
return path.startsWith(prefix) && !path.slice(prefix.length).includes("/");
|
|
181
|
+
}
|
|
182
|
+
return path === pattern;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 创建 analytics 中间件,自动跟踪页面浏览
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```ts
|
|
190
|
+
* const memory = createMemoryAnalyticsProvider();
|
|
191
|
+
* app.use('*', createAnalyticsMiddleware({ provider: memory }));
|
|
192
|
+
* ```
|
|
193
|
+
*/
|
|
194
|
+
function createAnalyticsMiddleware(options = {}) {
|
|
195
|
+
const { provider, methods = ["GET"], exclude = [], trackAllStatus = false, shouldTrack, getProperties, eventName = "page_view" } = options;
|
|
196
|
+
const normalizedMethods = new Set(methods.map((m) => m.toUpperCase()));
|
|
197
|
+
return async function analyticsMiddleware(c, next) {
|
|
198
|
+
if (isExcluded(c.req.path, exclude)) {
|
|
199
|
+
await next();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!normalizedMethods.has(c.req.method.toUpperCase())) {
|
|
203
|
+
await next();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (shouldTrack && !await shouldTrack(c)) {
|
|
207
|
+
await next();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const start = Date.now();
|
|
211
|
+
let error = void 0;
|
|
212
|
+
try {
|
|
213
|
+
await next();
|
|
214
|
+
} catch (err) {
|
|
215
|
+
error = err;
|
|
216
|
+
}
|
|
217
|
+
const duration = Date.now() - start;
|
|
218
|
+
const status = c.res?.status ?? 200;
|
|
219
|
+
if (trackAllStatus || status >= 200 && status < 400) {
|
|
220
|
+
const context = extractAnalyticsContext(c);
|
|
221
|
+
context.status = status;
|
|
222
|
+
context.duration = duration;
|
|
223
|
+
const extraProperties = getProperties ? await getProperties(c) : {};
|
|
224
|
+
const tasks = resolveProviders(provider).map((p) => p.track(eventName, extraProperties, context));
|
|
225
|
+
Promise.all(tasks).catch(() => {});
|
|
226
|
+
}
|
|
227
|
+
if (error !== void 0) throw error;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** 定义 analytics 中间件(等同于 createAnalyticsMiddleware) */
|
|
231
|
+
function defineAnalytics(options) {
|
|
232
|
+
return createAnalyticsMiddleware(options);
|
|
233
|
+
}
|
|
234
|
+
/** 从上下文获取 analytics provider(便于在 handler 中手动 track) */
|
|
235
|
+
function useAnalytics(c, provider) {
|
|
236
|
+
const buildContext = () => c ? extractAnalyticsContext(c) : {};
|
|
237
|
+
return {
|
|
238
|
+
trackPageView(properties = {}) {
|
|
239
|
+
const ctx = buildContext();
|
|
240
|
+
const providers = resolveProviders(provider);
|
|
241
|
+
Promise.all(providers.map((p) => p.track("page_view", properties, ctx))).catch(() => {});
|
|
242
|
+
},
|
|
243
|
+
trackEvent(name, properties = {}) {
|
|
244
|
+
const ctx = buildContext();
|
|
245
|
+
const providers = resolveProviders(provider);
|
|
246
|
+
Promise.all(providers.map((p) => p.track(name, properties, ctx))).catch(() => {});
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/feature-flags.ts
|
|
252
|
+
function createMemoryFeatureFlagStore() {
|
|
253
|
+
const flags = /* @__PURE__ */ new Map();
|
|
254
|
+
const experiments = /* @__PURE__ */ new Map();
|
|
255
|
+
return {
|
|
256
|
+
async getFlag(name) {
|
|
257
|
+
return flags.get(name);
|
|
258
|
+
},
|
|
259
|
+
async setFlag(name, flag) {
|
|
260
|
+
flags.set(name, flag);
|
|
261
|
+
},
|
|
262
|
+
async deleteFlag(name) {
|
|
263
|
+
flags.delete(name);
|
|
264
|
+
},
|
|
265
|
+
async listFlags() {
|
|
266
|
+
return Array.from(flags.values());
|
|
267
|
+
},
|
|
268
|
+
async getExperiment(name) {
|
|
269
|
+
return experiments.get(name);
|
|
270
|
+
},
|
|
271
|
+
async setExperiment(name, exp) {
|
|
272
|
+
experiments.set(name, exp);
|
|
273
|
+
},
|
|
274
|
+
async deleteExperiment(name) {
|
|
275
|
+
experiments.delete(name);
|
|
276
|
+
},
|
|
277
|
+
async listExperiments() {
|
|
278
|
+
return Array.from(experiments.values());
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const flagRegistry = /* @__PURE__ */ new Map();
|
|
283
|
+
const experimentRegistry = /* @__PURE__ */ new Map();
|
|
284
|
+
let globalStore = null;
|
|
285
|
+
/** 设置全局 feature flag store */
|
|
286
|
+
function setGlobalFeatureFlagStore(store) {
|
|
287
|
+
globalStore = store;
|
|
288
|
+
}
|
|
289
|
+
/** 获取全局 store(默认内存 store) */
|
|
290
|
+
function getGlobalFeatureFlagStore() {
|
|
291
|
+
if (!globalStore) globalStore = createMemoryFeatureFlagStore();
|
|
292
|
+
return globalStore;
|
|
293
|
+
}
|
|
294
|
+
/** 清空全局 store 和 registry(供测试使用) */
|
|
295
|
+
function clearFeatureFlags() {
|
|
296
|
+
flagRegistry.clear();
|
|
297
|
+
experimentRegistry.clear();
|
|
298
|
+
globalStore = null;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* 简单的字符串哈希(FNV-1a 32-bit),用于一致性分配
|
|
302
|
+
* 同样的输入始终产生同样的输出,无副作用
|
|
303
|
+
*/
|
|
304
|
+
function hashString(input) {
|
|
305
|
+
let hash = 2166136261;
|
|
306
|
+
for (let i = 0; i < input.length; i++) {
|
|
307
|
+
hash ^= input.charCodeAt(i);
|
|
308
|
+
hash = Math.imul(hash, 16777619);
|
|
309
|
+
}
|
|
310
|
+
return hash >>> 0;
|
|
311
|
+
}
|
|
312
|
+
/** 返回 0-99 的整数(基于 hash 输入的稳定百分比) */
|
|
313
|
+
function hashToBucket(hashInput) {
|
|
314
|
+
return hashString(hashInput) % 100;
|
|
315
|
+
}
|
|
316
|
+
/** 生成一致性哈希的输入 key */
|
|
317
|
+
function buildHashKey(name, identity, salt) {
|
|
318
|
+
return `${name}:${salt || ""}:${identity}`;
|
|
319
|
+
}
|
|
320
|
+
function resolveIdentity(context) {
|
|
321
|
+
if (!context) return "anonymous";
|
|
322
|
+
return context.userId || context.anonymousId || context.sessionId || "anonymous";
|
|
323
|
+
}
|
|
324
|
+
function matchRule(rule, context) {
|
|
325
|
+
const attrValue = context[rule.attribute];
|
|
326
|
+
switch (rule.operator) {
|
|
327
|
+
case "eq": return attrValue === rule.value;
|
|
328
|
+
case "neq": return attrValue !== rule.value;
|
|
329
|
+
case "in": return Array.isArray(rule.values) && rule.values.includes(attrValue);
|
|
330
|
+
case "not_in": return !Array.isArray(rule.values) || !rule.values.includes(attrValue);
|
|
331
|
+
case "contains":
|
|
332
|
+
if (typeof attrValue !== "string" || typeof rule.value !== "string") return false;
|
|
333
|
+
return attrValue.includes(rule.value);
|
|
334
|
+
case "gt": return typeof attrValue === "number" && typeof rule.value === "number" && attrValue > rule.value;
|
|
335
|
+
case "lt": return typeof attrValue === "number" && typeof rule.value === "number" && attrValue < rule.value;
|
|
336
|
+
case "gte": return typeof attrValue === "number" && typeof rule.value === "number" && attrValue >= rule.value;
|
|
337
|
+
case "lte": return typeof attrValue === "number" && typeof rule.value === "number" && attrValue <= rule.value;
|
|
338
|
+
case "exists": return attrValue !== void 0 && attrValue !== null;
|
|
339
|
+
case "not_exists": return attrValue === void 0 || attrValue === null;
|
|
340
|
+
default: return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function matchSegment(segment, context) {
|
|
344
|
+
if (segment.enabled === false) return false;
|
|
345
|
+
if (!segment.rules || segment.rules.length === 0) return true;
|
|
346
|
+
return segment.rules.every((rule) => matchRule(rule, context));
|
|
347
|
+
}
|
|
348
|
+
function matchAnySegment(segments, context) {
|
|
349
|
+
if (!segments || segments.length === 0) return true;
|
|
350
|
+
return segments.some((seg) => matchSegment(seg, context));
|
|
351
|
+
}
|
|
352
|
+
function pickVariant(variants, hashInput) {
|
|
353
|
+
if (variants.length === 0) return void 0;
|
|
354
|
+
const totalWeight = variants.reduce((sum, v) => sum + Math.max(0, v.weight), 0);
|
|
355
|
+
if (totalWeight <= 0) return variants[0];
|
|
356
|
+
const bucket = hashString(hashInput) % totalWeight;
|
|
357
|
+
let acc = 0;
|
|
358
|
+
for (const v of variants) {
|
|
359
|
+
acc += Math.max(0, v.weight);
|
|
360
|
+
if (bucket < acc) return v;
|
|
361
|
+
}
|
|
362
|
+
return variants[variants.length - 1];
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* 定义一个 feature flag,注册到全局 registry 和 store
|
|
366
|
+
*
|
|
367
|
+
* @example
|
|
368
|
+
* ```ts
|
|
369
|
+
* defineFeatureFlag('new_dashboard', {
|
|
370
|
+
* kind: 'percentage',
|
|
371
|
+
* percentage: 50,
|
|
372
|
+
* defaultValue: false
|
|
373
|
+
* });
|
|
374
|
+
*
|
|
375
|
+
* defineFeatureFlag('checkout_flow', {
|
|
376
|
+
* kind: 'multivariate',
|
|
377
|
+
* variants: [
|
|
378
|
+
* { key: 'control', weight: 1, value: 'v1' },
|
|
379
|
+
* { key: 'treatment', weight: 1, value: 'v2' }
|
|
380
|
+
* ],
|
|
381
|
+
* defaultValue: 'v1'
|
|
382
|
+
* });
|
|
383
|
+
* ```
|
|
384
|
+
*/
|
|
385
|
+
function defineFeatureFlag(name, options = {}) {
|
|
386
|
+
const def = {
|
|
387
|
+
name,
|
|
388
|
+
kind: options.kind || "boolean",
|
|
389
|
+
defaultValue: options.defaultValue ?? (options.kind === "multivariate" ? "" : false),
|
|
390
|
+
variants: options.variants,
|
|
391
|
+
percentage: options.percentage,
|
|
392
|
+
segments: options.segments,
|
|
393
|
+
enabled: options.enabled ?? true,
|
|
394
|
+
description: options.description,
|
|
395
|
+
salt: options.salt
|
|
396
|
+
};
|
|
397
|
+
flagRegistry.set(name, def);
|
|
398
|
+
getGlobalFeatureFlagStore().setFlag(name, def);
|
|
399
|
+
return def;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* 定义一个 A/B 测试实验,注册到全局 registry 和 store
|
|
403
|
+
*
|
|
404
|
+
* @example
|
|
405
|
+
* ```ts
|
|
406
|
+
* defineExperiment('checkout_v2_test', {
|
|
407
|
+
* variants: [
|
|
408
|
+
* { key: 'control', weight: 1, value: 'A' },
|
|
409
|
+
* { key: 'treatment', weight: 1, value: 'B' }
|
|
410
|
+
* ],
|
|
411
|
+
* traffic: 50
|
|
412
|
+
* });
|
|
413
|
+
* ```
|
|
414
|
+
*/
|
|
415
|
+
function defineExperiment(name, options) {
|
|
416
|
+
const def = {
|
|
417
|
+
name,
|
|
418
|
+
description: options.description,
|
|
419
|
+
variants: options.variants,
|
|
420
|
+
traffic: options.traffic ?? 100,
|
|
421
|
+
segments: options.segments,
|
|
422
|
+
enabled: options.enabled ?? true,
|
|
423
|
+
salt: options.salt
|
|
424
|
+
};
|
|
425
|
+
experimentRegistry.set(name, def);
|
|
426
|
+
getGlobalFeatureFlagStore().setExperiment(name, def);
|
|
427
|
+
return def;
|
|
428
|
+
}
|
|
429
|
+
/** 解析 flag 定义(优先 registry,次选 store) */
|
|
430
|
+
async function resolveFlag(name, store) {
|
|
431
|
+
if (flagRegistry.has(name)) return flagRegistry.get(name);
|
|
432
|
+
return (store || getGlobalFeatureFlagStore()).getFlag(name);
|
|
433
|
+
}
|
|
434
|
+
async function resolveExperiment(name, store) {
|
|
435
|
+
if (experimentRegistry.has(name)) return experimentRegistry.get(name);
|
|
436
|
+
return (store || getGlobalFeatureFlagStore()).getExperiment(name);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* 评估一个 flag,返回 boolean 或 variant value
|
|
440
|
+
*
|
|
441
|
+
* - boolean flag:返回 true / false
|
|
442
|
+
* - percentage flag:返回 true / false(基于用户身份的稳定百分比)
|
|
443
|
+
* - multivariate flag:返回选中的 variant value
|
|
444
|
+
*/
|
|
445
|
+
async function evaluateFlag(name, context = {}, store) {
|
|
446
|
+
return (await evaluateFlagWithReason(name, context, store)).value;
|
|
447
|
+
}
|
|
448
|
+
/** 评估 flag 并返回原因(用于调试 / debug) */
|
|
449
|
+
async function evaluateFlagWithReason(name, context = {}, store) {
|
|
450
|
+
const flag = await resolveFlag(name, store);
|
|
451
|
+
if (!flag) return {
|
|
452
|
+
name,
|
|
453
|
+
kind: "boolean",
|
|
454
|
+
value: false,
|
|
455
|
+
reason: "default"
|
|
456
|
+
};
|
|
457
|
+
if (flag.enabled === false) return {
|
|
458
|
+
name,
|
|
459
|
+
kind: flag.kind,
|
|
460
|
+
value: flag.defaultValue,
|
|
461
|
+
reason: "disabled"
|
|
462
|
+
};
|
|
463
|
+
if (flag.segments && flag.segments.length > 0 && !matchAnySegment(flag.segments, context)) return {
|
|
464
|
+
name,
|
|
465
|
+
kind: flag.kind,
|
|
466
|
+
value: flag.defaultValue,
|
|
467
|
+
reason: "segment"
|
|
468
|
+
};
|
|
469
|
+
const hashKey = buildHashKey(name, resolveIdentity(context), flag.salt);
|
|
470
|
+
switch (flag.kind) {
|
|
471
|
+
case "boolean": return {
|
|
472
|
+
name,
|
|
473
|
+
kind: "boolean",
|
|
474
|
+
value: true,
|
|
475
|
+
reason: "enabled"
|
|
476
|
+
};
|
|
477
|
+
case "percentage": {
|
|
478
|
+
const pct = typeof flag.percentage === "number" ? Math.max(0, Math.min(100, flag.percentage)) : 0;
|
|
479
|
+
if (hashToBucket(hashKey) < pct) return {
|
|
480
|
+
name,
|
|
481
|
+
kind: "percentage",
|
|
482
|
+
value: true,
|
|
483
|
+
reason: "percentage"
|
|
484
|
+
};
|
|
485
|
+
return {
|
|
486
|
+
name,
|
|
487
|
+
kind: "percentage",
|
|
488
|
+
value: false,
|
|
489
|
+
reason: "percentage"
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
case "multivariate": {
|
|
493
|
+
if (!flag.variants || flag.variants.length === 0) return {
|
|
494
|
+
name,
|
|
495
|
+
kind: "multivariate",
|
|
496
|
+
value: flag.defaultValue,
|
|
497
|
+
reason: "default"
|
|
498
|
+
};
|
|
499
|
+
const variant = pickVariant(flag.variants, hashKey);
|
|
500
|
+
return {
|
|
501
|
+
name,
|
|
502
|
+
kind: "multivariate",
|
|
503
|
+
value: variant?.value ?? variant?.key ?? flag.defaultValue,
|
|
504
|
+
reason: "variant",
|
|
505
|
+
variant: variant?.key
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
default: return {
|
|
509
|
+
name,
|
|
510
|
+
kind: "boolean",
|
|
511
|
+
value: flag.defaultValue,
|
|
512
|
+
reason: "default"
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* 获取一个实验分配给当前用户的 variant key
|
|
518
|
+
*/
|
|
519
|
+
async function getVariant(name, context = {}, store) {
|
|
520
|
+
return (await getVariantAssignment(name, context, store)).variant;
|
|
521
|
+
}
|
|
522
|
+
/** 获取实验分配详情(用于调试) */
|
|
523
|
+
async function getVariantAssignment(name, context = {}, store) {
|
|
524
|
+
const exp = await resolveExperiment(name, store);
|
|
525
|
+
if (!exp) return {
|
|
526
|
+
name,
|
|
527
|
+
variant: "",
|
|
528
|
+
inExperiment: false,
|
|
529
|
+
reason: "no_variants"
|
|
530
|
+
};
|
|
531
|
+
if (exp.enabled === false) return {
|
|
532
|
+
name,
|
|
533
|
+
variant: "",
|
|
534
|
+
inExperiment: false,
|
|
535
|
+
reason: "disabled"
|
|
536
|
+
};
|
|
537
|
+
if (!exp.variants || exp.variants.length === 0) return {
|
|
538
|
+
name,
|
|
539
|
+
variant: "",
|
|
540
|
+
inExperiment: false,
|
|
541
|
+
reason: "no_variants"
|
|
542
|
+
};
|
|
543
|
+
if (exp.segments && exp.segments.length > 0 && !matchAnySegment(exp.segments, context)) return {
|
|
544
|
+
name,
|
|
545
|
+
variant: "",
|
|
546
|
+
inExperiment: false,
|
|
547
|
+
reason: "segment"
|
|
548
|
+
};
|
|
549
|
+
const traffic = typeof exp.traffic === "number" ? Math.max(0, Math.min(100, exp.traffic)) : 100;
|
|
550
|
+
const hashKey = buildHashKey(name, resolveIdentity(context), exp.salt);
|
|
551
|
+
if (hashToBucket(hashKey) >= traffic) return {
|
|
552
|
+
name,
|
|
553
|
+
variant: "",
|
|
554
|
+
inExperiment: false,
|
|
555
|
+
reason: "traffic"
|
|
556
|
+
};
|
|
557
|
+
return {
|
|
558
|
+
name,
|
|
559
|
+
variant: pickVariant(exp.variants, hashKey)?.key || "",
|
|
560
|
+
inExperiment: true,
|
|
561
|
+
reason: "enabled"
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
/** 默认从 Hono 上下文提取 flag context */
|
|
565
|
+
function extractFlagContext(c) {
|
|
566
|
+
const userId = c.get("userId") || void 0;
|
|
567
|
+
const sessionId = c.get("sessionId") || void 0;
|
|
568
|
+
const locale = c.get("locale") || void 0;
|
|
569
|
+
const ip = c.req.header("x-forwarded-for")?.split(",")[0].trim() || c.req.header("x-real-ip") || void 0;
|
|
570
|
+
const cookieHeader = c.req.header("cookie") || "";
|
|
571
|
+
let anonymousId;
|
|
572
|
+
const match = cookieHeader.match(/(?:^|;\s*)ubean_aid=([^;]+)/);
|
|
573
|
+
if (match) anonymousId = match[1];
|
|
574
|
+
return {
|
|
575
|
+
userId,
|
|
576
|
+
sessionId,
|
|
577
|
+
locale,
|
|
578
|
+
ip,
|
|
579
|
+
anonymousId
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* 创建 feature flags 中间件,把 flag/experiment 评估附加到 context
|
|
584
|
+
*
|
|
585
|
+
* @example
|
|
586
|
+
* ```ts
|
|
587
|
+
* app.use('*', createFeatureFlagsMiddleware());
|
|
588
|
+
*
|
|
589
|
+
* app.get('/', (c) => {
|
|
590
|
+
* const flags = useFlags(c);
|
|
591
|
+
* if (flags.new_dashboard) {
|
|
592
|
+
* return c.render('dashboard-v2');
|
|
593
|
+
* }
|
|
594
|
+
* return c.render('dashboard');
|
|
595
|
+
* });
|
|
596
|
+
* ```
|
|
597
|
+
*/
|
|
598
|
+
function createFeatureFlagsMiddleware(options = {}) {
|
|
599
|
+
const { store, getContext, flagsVarKey = "flags", experimentsVarKey = "experiments" } = options;
|
|
600
|
+
return async function featureFlagsMiddleware(c, next) {
|
|
601
|
+
const flagContext = getContext ? await getContext(c) : extractFlagContext(c);
|
|
602
|
+
const s = store || getGlobalFeatureFlagStore();
|
|
603
|
+
const allFlags = await s.listFlags();
|
|
604
|
+
const flags = {};
|
|
605
|
+
for (const flag of allFlags) flags[flag.name] = await evaluateFlag(flag.name, flagContext, s);
|
|
606
|
+
c.set(flagsVarKey, flags);
|
|
607
|
+
const allExperiments = await s.listExperiments();
|
|
608
|
+
const experiments = {};
|
|
609
|
+
for (const exp of allExperiments) {
|
|
610
|
+
const assignment = await getVariantAssignment(exp.name, flagContext, s);
|
|
611
|
+
experiments[exp.name] = assignment.variant;
|
|
612
|
+
}
|
|
613
|
+
c.set(experimentsVarKey, experiments);
|
|
614
|
+
c.set("flagContext", flagContext);
|
|
615
|
+
await next();
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/** 从 Hono 上下文中获取所有 flag(由中间件写入) */
|
|
619
|
+
function useFlags(c) {
|
|
620
|
+
return c.get("flags") || {};
|
|
621
|
+
}
|
|
622
|
+
/** 从 Hono 上下文中获取所有 experiment 分配 */
|
|
623
|
+
function useExperiments(c) {
|
|
624
|
+
return c.get("experiments") || {};
|
|
625
|
+
}
|
|
626
|
+
/** 获取当前请求的 flag context(由中间件写入) */
|
|
627
|
+
function useFlagContext(c) {
|
|
628
|
+
return c.get("flagContext") || {};
|
|
629
|
+
}
|
|
630
|
+
/** 在 handler 中即时评估一个 flag(如果未启用中间件也可用) */
|
|
631
|
+
async function evaluateFlagFromContext(c, name, store) {
|
|
632
|
+
return evaluateFlag(name, c.get("flagContext") || extractFlagContext(c), store);
|
|
633
|
+
}
|
|
634
|
+
/** 在 handler 中即时获取一个实验的 variant(如果未启用中间件也可用) */
|
|
635
|
+
async function getVariantFromContext(c, name, store) {
|
|
636
|
+
return getVariant(name, c.get("flagContext") || extractFlagContext(c), store);
|
|
637
|
+
}
|
|
638
|
+
/** 列出所有已注册的 flag 名称 */
|
|
639
|
+
function listFlagNames() {
|
|
640
|
+
return Array.from(flagRegistry.keys());
|
|
641
|
+
}
|
|
642
|
+
/** 列出所有已注册的 experiment 名称 */
|
|
643
|
+
function listExperimentNames() {
|
|
644
|
+
return Array.from(experimentRegistry.keys());
|
|
645
|
+
}
|
|
646
|
+
/** 从 registry 中删除一个 flag */
|
|
647
|
+
function removeFeatureFlag(name) {
|
|
648
|
+
flagRegistry.delete(name);
|
|
649
|
+
getGlobalFeatureFlagStore().deleteFlag(name);
|
|
650
|
+
}
|
|
651
|
+
/** 从 registry 中删除一个实验 */
|
|
652
|
+
function removeExperiment(name) {
|
|
653
|
+
experimentRegistry.delete(name);
|
|
654
|
+
getGlobalFeatureFlagStore().deleteExperiment(name);
|
|
655
|
+
}
|
|
656
|
+
//#endregion
|
|
657
|
+
export { getAnalyticsProvider as A, createAnalyticsMiddleware as C, defineAnalytics as D, createMockAnalyticsProvider as E, trackEvent as F, trackPageView as I, trackRaw as L, listAnalyticsProviders as M, registerAnalyticsProvider as N, defineAnalyticsProvider as O, setGlobalAnalyticsProvider as P, unregisterAnalyticsProvider as R, clearAnalyticsProviders as S, createMemoryAnalyticsProvider as T, removeFeatureFlag as _, defineFeatureFlag as a, useFlagContext as b, evaluateFlagWithReason as c, getVariant as d, getVariantAssignment as f, removeExperiment as g, listFlagNames as h, defineExperiment as i, getGlobalAnalyticsProvider as j, extractAnalyticsContext as k, extractFlagContext as l, listExperimentNames as m, createFeatureFlagsMiddleware as n, evaluateFlag as o, getVariantFromContext as p, createMemoryFeatureFlagStore as r, evaluateFlagFromContext as s, clearFeatureFlags as t, getGlobalFeatureFlagStore as u, setGlobalFeatureFlagStore as v, createLogAnalyticsProvider as w, useFlags as x, useExperiments as y, useAnalytics as z };
|