@hot-updater/plugin-remote-config 0.0.0-stage → 1.0.0-rc.41

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.
@@ -0,0 +1,396 @@
1
+ const require_ownEntry = require("../shared/ownEntry.cjs");
2
+ let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
3
+ let _hot_updater_protocol = require("@hot-updater/protocol");
4
+ let verkit = require("verkit");
5
+ //#region src/server/template.ts
6
+ const EMPTY_REMOTE_CONFIG_TEMPLATE = Object.freeze({
7
+ conditions: Object.freeze([]),
8
+ parameters: Object.freeze({})
9
+ });
10
+ /**
11
+ * A template's JSON stays under this many characters, so a device's values
12
+ * always fit the 64 KB a client plugin may store under one key.
13
+ */
14
+ const REMOTE_CONFIG_MAX_TEMPLATE_LENGTH = 6e4;
15
+ const REMOTE_CONFIG_MAX_PARAMETERS = 2e3;
16
+ const REMOTE_CONFIG_MAX_CONDITIONS = 500;
17
+ const MAX_RULE_VALUES = 100;
18
+ const MAX_DESCRIPTION_LENGTH = 256;
19
+ const MAX_CONDITION_NAME_LENGTH = 100;
20
+ const MAX_RULE_TEXT_LENGTH = 128;
21
+ const PARAMETER_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,255}$/u;
22
+ const SEED_PATTERN = /^[A-Za-z0-9_-]{1,64}$/u;
23
+ /** A date-time with its time zone, so it names one instant. */
24
+ const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/u;
25
+ const VALUE_TYPES = /* @__PURE__ */ new Set([
26
+ "STRING",
27
+ "NUMBER",
28
+ "BOOLEAN",
29
+ "JSON"
30
+ ]);
31
+ const PLATFORMS = /* @__PURE__ */ new Set(["ios", "android"]);
32
+ /** A template that cannot be published; `issues` lists every problem found. */
33
+ var RemoteConfigValidationError = class extends Error {
34
+ issues;
35
+ name = "RemoteConfigValidationError";
36
+ constructor(issues) {
37
+ super(`The Remote Config template is invalid: ${issues.slice(0, 3).map(({ path, message }) => path ? `${path}: ${message}` : message).join("; ")}${issues.length > 3 ? ` (and ${issues.length - 3} more)` : ""}`);
38
+ this.issues = issues;
39
+ }
40
+ };
41
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
42
+ const visibleText = (value, max) => typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\p{Cc}]/u.test(value);
43
+ /** Whether a value's text suits its type; the problem otherwise. */
44
+ const checkValueText = (valueType, text) => {
45
+ switch (valueType) {
46
+ case "NUMBER": return text.trim().length > 0 && Number.isFinite(Number(text)) ? null : "must be a number.";
47
+ case "BOOLEAN": return text === "true" || text === "false" ? null : "must be \"true\" or \"false\".";
48
+ case "JSON": try {
49
+ JSON.parse(text);
50
+ return null;
51
+ } catch {
52
+ return "must be valid JSON.";
53
+ }
54
+ default: return null;
55
+ }
56
+ };
57
+ /**
58
+ * Checks a template and returns it with only its declared fields, or throws
59
+ * `RemoteConfigValidationError` with every problem it finds.
60
+ */
61
+ const validateRemoteConfigTemplate = (input) => {
62
+ const issues = [];
63
+ const issue = (path, message) => {
64
+ issues.push({
65
+ path,
66
+ message
67
+ });
68
+ };
69
+ const unknownKeys = (value, allowed, path) => {
70
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) issue(path ? `${path}.${key}` : key, "is not a template field.");
71
+ };
72
+ if (!isRecord(input)) throw new RemoteConfigValidationError([{
73
+ path: "",
74
+ message: "A template is an object."
75
+ }]);
76
+ unknownKeys(input, ["conditions", "parameters"], "");
77
+ const conditions = [];
78
+ const conditionNames = /* @__PURE__ */ new Set();
79
+ const rawConditions = input.conditions ?? [];
80
+ if (!Array.isArray(rawConditions)) issue("conditions", "must be a list.");
81
+ else {
82
+ if (rawConditions.length > 500) issue("conditions", `holds at most 500 conditions.`);
83
+ rawConditions.forEach((raw, index) => {
84
+ const path = `conditions[${index}]`;
85
+ if (!isRecord(raw)) {
86
+ issue(path, "must be an object.");
87
+ return;
88
+ }
89
+ unknownKeys(raw, ["name", "rules"], path);
90
+ const { name } = raw;
91
+ if (!visibleText(name, MAX_CONDITION_NAME_LENGTH)) issue(`${path}.name`, `must be 1-${MAX_CONDITION_NAME_LENGTH} visible characters.`);
92
+ else if (conditionNames.has(name)) issue(`${path}.name`, `"${name}" names another condition too.`);
93
+ else conditionNames.add(name);
94
+ const rules = validateRules(raw.rules, `${path}.rules`, issue);
95
+ if (typeof name === "string") conditions.push({
96
+ name,
97
+ rules
98
+ });
99
+ });
100
+ }
101
+ const parameters = [];
102
+ const rawParameters = input.parameters ?? {};
103
+ if (!isRecord(rawParameters)) issue("parameters", "must be an object keyed by parameter.");
104
+ else {
105
+ const keys = Object.keys(rawParameters);
106
+ if (keys.length > 2e3) issue("parameters", `holds at most ${REMOTE_CONFIG_MAX_PARAMETERS} parameters.`);
107
+ for (const key of keys) {
108
+ const path = `parameters.${key}`;
109
+ if (!PARAMETER_KEY_PATTERN.test(key)) issue(path, "Parameter keys start with a letter or _, and hold up to 256 letters, digits, and _.");
110
+ const raw = rawParameters[key];
111
+ if (!isRecord(raw)) {
112
+ issue(path, "must be an object.");
113
+ continue;
114
+ }
115
+ unknownKeys(raw, [
116
+ "valueType",
117
+ "defaultValue",
118
+ "conditionalValues",
119
+ "description"
120
+ ], path);
121
+ const valueType = raw.valueType;
122
+ if (typeof valueType !== "string" || !VALUE_TYPES.has(valueType)) {
123
+ issue(`${path}.valueType`, "must be STRING, NUMBER, BOOLEAN, or JSON.");
124
+ continue;
125
+ }
126
+ const type = valueType;
127
+ const readValue = (value, valuePath) => {
128
+ if (!isRecord(value)) {
129
+ issue(valuePath, "must be { value } or { useInAppDefault: true }.");
130
+ return null;
131
+ }
132
+ if ("useInAppDefault" in value) {
133
+ unknownKeys(value, ["useInAppDefault"], valuePath);
134
+ if (value.useInAppDefault !== true) {
135
+ issue(`${valuePath}.useInAppDefault`, "must be true.");
136
+ return null;
137
+ }
138
+ return { useInAppDefault: true };
139
+ }
140
+ unknownKeys(value, ["value"], valuePath);
141
+ if (typeof value.value !== "string") {
142
+ issue(`${valuePath}.value`, "must be text.");
143
+ return null;
144
+ }
145
+ const problem = checkValueText(type, value.value);
146
+ if (problem !== null) {
147
+ issue(`${valuePath}.value`, problem);
148
+ return null;
149
+ }
150
+ return { value: value.value };
151
+ };
152
+ const defaultValue = readValue(raw.defaultValue, `${path}.defaultValue`);
153
+ const conditionalValues = [];
154
+ if (raw.conditionalValues !== void 0) {
155
+ if (!isRecord(raw.conditionalValues)) issue(`${path}.conditionalValues`, "must be an object keyed by condition.");
156
+ else for (const [conditionName, value] of Object.entries(raw.conditionalValues)) {
157
+ const valuePath = `${path}.conditionalValues.${conditionName}`;
158
+ if (!conditionNames.has(conditionName)) {
159
+ issue(valuePath, `No condition is named "${conditionName}".`);
160
+ continue;
161
+ }
162
+ const read = readValue(value, valuePath);
163
+ if (read !== null) conditionalValues.push([conditionName, read]);
164
+ }
165
+ }
166
+ let description;
167
+ if (raw.description !== void 0) {
168
+ const text = typeof raw.description === "string" ? raw.description.trim() : null;
169
+ if (text === null || text.length > MAX_DESCRIPTION_LENGTH) issue(`${path}.description`, `must be text of up to ${MAX_DESCRIPTION_LENGTH} characters.`);
170
+ else if (text.length > 0) description = text;
171
+ }
172
+ if (defaultValue !== null) parameters.push([key, {
173
+ valueType: type,
174
+ defaultValue,
175
+ ...conditionalValues.length === 0 ? {} : { conditionalValues: Object.fromEntries(conditionalValues) },
176
+ ...description === void 0 ? {} : { description }
177
+ }]);
178
+ }
179
+ }
180
+ if (issues.length > 0) throw new RemoteConfigValidationError(issues);
181
+ const template = {
182
+ conditions,
183
+ parameters: Object.fromEntries(parameters)
184
+ };
185
+ const length = JSON.stringify(template).length;
186
+ if (length > 6e4) throw new RemoteConfigValidationError([{
187
+ path: "",
188
+ message: `The template takes ${length.toLocaleString("en-US")} characters of JSON; the limit is ${REMOTE_CONFIG_MAX_TEMPLATE_LENGTH.toLocaleString("en-US")}.`
189
+ }]);
190
+ return template;
191
+ };
192
+ const textList = (value, path, issue, check, describe) => {
193
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_RULE_VALUES) {
194
+ issue(path, `must list 1-${MAX_RULE_VALUES} values.`);
195
+ return [];
196
+ }
197
+ const list = [];
198
+ value.forEach((entry, index) => {
199
+ if (typeof entry !== "string" || !check(entry)) issue(`${path}[${index}]`, describe);
200
+ else if (!list.includes(entry)) list.push(entry);
201
+ });
202
+ return list;
203
+ };
204
+ const RULE_FIELDS = {
205
+ platform: ["type", "platforms"],
206
+ channel: ["type", "channels"],
207
+ appVersion: ["type", "range"],
208
+ cohort: ["type", "cohorts"],
209
+ percent: [
210
+ "type",
211
+ "seed",
212
+ "from",
213
+ "to"
214
+ ],
215
+ fingerprint: ["type", "hashes"],
216
+ dateTime: [
217
+ "type",
218
+ "from",
219
+ "to"
220
+ ]
221
+ };
222
+ const validateRules = (value, path, issue) => {
223
+ if (!Array.isArray(value) || value.length === 0) {
224
+ issue(path, "must list at least one rule.");
225
+ return [];
226
+ }
227
+ const rules = [];
228
+ value.forEach((raw, index) => {
229
+ const rulePath = `${path}[${index}]`;
230
+ if (!isRecord(raw) || typeof raw.type !== "string") {
231
+ issue(rulePath, "must be a rule object with a type.");
232
+ return;
233
+ }
234
+ const type = raw.type;
235
+ const fields = require_ownEntry.ownEntry(RULE_FIELDS, type);
236
+ if (fields === void 0) {
237
+ issue(`${rulePath}.type`, "must be platform, channel, appVersion, cohort, percent, fingerprint, or dateTime.");
238
+ return;
239
+ }
240
+ for (const key of Object.keys(raw)) if (!fields.includes(key)) issue(`${rulePath}.${key}`, `is not a field of a ${type} rule.`);
241
+ switch (type) {
242
+ case "platform": {
243
+ const platforms = textList(raw.platforms, `${rulePath}.platforms`, issue, (text) => PLATFORMS.has(text), "must be \"ios\" or \"android\".");
244
+ rules.push({
245
+ type,
246
+ platforms
247
+ });
248
+ return;
249
+ }
250
+ case "channel": {
251
+ const channels = textList(raw.channels, `${rulePath}.channels`, issue, (text) => visibleText(text, MAX_RULE_TEXT_LENGTH), `must be a channel name of 1-${MAX_RULE_TEXT_LENGTH} characters.`);
252
+ rules.push({
253
+ type,
254
+ channels
255
+ });
256
+ return;
257
+ }
258
+ case "appVersion": {
259
+ const range = raw.range;
260
+ if (!visibleText(range, MAX_RULE_TEXT_LENGTH) || !(0, verkit.isValidRange)(range.trim())) {
261
+ issue(`${rulePath}.range`, "must be a semver range, such as >=1.4.0 or 2.x.");
262
+ return;
263
+ }
264
+ rules.push({
265
+ type,
266
+ range: range.trim()
267
+ });
268
+ return;
269
+ }
270
+ case "cohort": {
271
+ const cohorts = textList(raw.cohorts, `${rulePath}.cohorts`, issue, _hot_updater_protocol.isValidCohort, "must be a cohort from 1 to 1000, or a lowercase slug.").map(_hot_updater_protocol.normalizeCohortValue);
272
+ rules.push({
273
+ type,
274
+ cohorts: [...new Set(cohorts)]
275
+ });
276
+ return;
277
+ }
278
+ case "percent": {
279
+ const { seed, from, to } = raw;
280
+ if (typeof seed !== "string" || !SEED_PATTERN.test(seed)) {
281
+ issue(`${rulePath}.seed`, "must be 1-64 letters, digits, _, and -.");
282
+ return;
283
+ }
284
+ const isPercent = (value) => typeof value === "number" && value >= 0 && value <= 100 && Math.abs(value * 10 - Math.round(value * 10)) < 1e-9;
285
+ if (!isPercent(from) || !isPercent(to) || from >= to) {
286
+ issue(rulePath, "takes from < to, each a percentage from 0 to 100 in steps of 0.1.");
287
+ return;
288
+ }
289
+ rules.push({
290
+ type,
291
+ seed,
292
+ from,
293
+ to
294
+ });
295
+ return;
296
+ }
297
+ case "fingerprint": {
298
+ const hashes = textList(raw.hashes, `${rulePath}.hashes`, issue, (text) => visibleText(text, MAX_RULE_TEXT_LENGTH), `must be a fingerprint of 1-${MAX_RULE_TEXT_LENGTH} characters.`);
299
+ rules.push({
300
+ type,
301
+ hashes
302
+ });
303
+ return;
304
+ }
305
+ case "dateTime": {
306
+ const instant = (field) => {
307
+ const value = raw[field];
308
+ if (value === void 0) return void 0;
309
+ const ms = typeof value === "string" ? Date.parse(value) : NaN;
310
+ if (typeof value !== "string" || !DATE_TIME_PATTERN.test(value) || !Number.isFinite(ms)) {
311
+ issue(`${rulePath}.${field}`, "must be an ISO 8601 date-time with a time zone, such as 2026-10-08T10:00:00Z.");
312
+ return null;
313
+ }
314
+ return new Date(ms).toISOString();
315
+ };
316
+ const from = instant("from");
317
+ const to = instant("to");
318
+ if (from === null || to === null) return;
319
+ if (from === void 0 && to === void 0) {
320
+ issue(rulePath, "takes from, to, or both.");
321
+ return;
322
+ }
323
+ if (from !== void 0 && to !== void 0 && from >= to) {
324
+ issue(rulePath, "takes a from before its to.");
325
+ return;
326
+ }
327
+ rules.push({
328
+ type,
329
+ ...from === void 0 ? {} : { from },
330
+ ...to === void 0 ? {} : { to }
331
+ });
332
+ return;
333
+ }
334
+ }
335
+ });
336
+ return rules;
337
+ };
338
+ /** Whether one rule matches a device. */
339
+ const matchesRemoteConfigRule = (rule, context) => {
340
+ switch (rule.type) {
341
+ case "platform": return context.platform !== void 0 && context.platform !== null && rule.platforms.includes(context.platform);
342
+ case "channel": return typeof context.channel === "string" && rule.channels.includes(context.channel);
343
+ case "appVersion": return typeof context.appVersion === "string" && (0, _hot_updater_plugin_core.semverSatisfies)(rule.range, context.appVersion);
344
+ case "cohort": return typeof context.cohort === "string" && rule.cohorts.includes((0, _hot_updater_protocol.normalizeCohortValue)(context.cohort));
345
+ case "percent": {
346
+ if (typeof context.cohort !== "string") return false;
347
+ const cohort = (0, _hot_updater_protocol.getNumericCohortValue)(context.cohort);
348
+ if (cohort === null) return false;
349
+ const position = (0, _hot_updater_protocol.getNumericCohortRolloutPosition)(rule.seed, cohort);
350
+ return position >= Math.round(rule.from * 10) && position < Math.round(rule.to * 10);
351
+ }
352
+ case "fingerprint": return typeof context.fingerprintHash === "string" && rule.hashes.includes(context.fingerprintHash);
353
+ case "dateTime": return typeof context.now === "number" && (rule.from === void 0 || context.now >= Date.parse(rule.from)) && (rule.to === void 0 || context.now < Date.parse(rule.to));
354
+ }
355
+ };
356
+ /** Whether every rule of a condition matches a device. */
357
+ const matchesRemoteConfigCondition = (condition, context) => condition.rules.every((rule) => matchesRemoteConfigRule(rule, context));
358
+ /**
359
+ * Every parameter's value for one device: the value of the first condition,
360
+ * in the template's order, that matches and that the parameter has a value
361
+ * for; otherwise its default value.
362
+ */
363
+ const evaluateRemoteConfig = (template, context) => {
364
+ const matched = template.conditions.filter((condition) => matchesRemoteConfigCondition(condition, context)).map(({ name }) => name);
365
+ return Object.fromEntries(Object.entries(template.parameters).map(([key, parameter]) => {
366
+ let condition = null;
367
+ let chosen = parameter.defaultValue;
368
+ for (const name of matched) {
369
+ const value = require_ownEntry.ownEntry(parameter.conditionalValues, name);
370
+ if (value !== void 0) {
371
+ condition = name;
372
+ chosen = value;
373
+ break;
374
+ }
375
+ }
376
+ return [key, {
377
+ value: "value" in chosen ? chosen.value : null,
378
+ condition
379
+ }];
380
+ }));
381
+ };
382
+ /** The values a device receives: each parameter's text, without in-app defaults. */
383
+ const resolveRemoteConfigValues = (template, context) => {
384
+ return Object.fromEntries(Object.entries(evaluateRemoteConfig(template, context)).flatMap(([key, { value }]) => value === null ? [] : [[key, value]]));
385
+ };
386
+ //#endregion
387
+ exports.EMPTY_REMOTE_CONFIG_TEMPLATE = EMPTY_REMOTE_CONFIG_TEMPLATE;
388
+ exports.REMOTE_CONFIG_MAX_CONDITIONS = REMOTE_CONFIG_MAX_CONDITIONS;
389
+ exports.REMOTE_CONFIG_MAX_PARAMETERS = REMOTE_CONFIG_MAX_PARAMETERS;
390
+ exports.REMOTE_CONFIG_MAX_TEMPLATE_LENGTH = REMOTE_CONFIG_MAX_TEMPLATE_LENGTH;
391
+ exports.RemoteConfigValidationError = RemoteConfigValidationError;
392
+ exports.evaluateRemoteConfig = evaluateRemoteConfig;
393
+ exports.matchesRemoteConfigCondition = matchesRemoteConfigCondition;
394
+ exports.matchesRemoteConfigRule = matchesRemoteConfigRule;
395
+ exports.resolveRemoteConfigValues = resolveRemoteConfigValues;
396
+ exports.validateRemoteConfigTemplate = validateRemoteConfigTemplate;
@@ -0,0 +1,134 @@
1
+ import { RemoteConfigDeviceContext } from "../shared/wire.cjs";
2
+ //#region src/server/template.d.ts
3
+ /**
4
+ * How the Console edits a parameter's value and checks it before a publish.
5
+ * Devices receive every value as text.
6
+ */
7
+ type RemoteConfigValueType = "STRING" | "NUMBER" | "BOOLEAN" | "JSON";
8
+ /** A value a parameter takes: text, or the app's own default. */
9
+ type RemoteConfigParameterValue = {
10
+ readonly value: string;
11
+ } | {
12
+ readonly useInAppDefault: true;
13
+ };
14
+ interface RemoteConfigParameter {
15
+ readonly valueType: RemoteConfigValueType;
16
+ /** The value when none of the parameter's conditions match. */
17
+ readonly defaultValue: RemoteConfigParameterValue;
18
+ /**
19
+ * Values by condition name. When several of these conditions match, the
20
+ * one listed first in the template's `conditions` wins.
21
+ */
22
+ readonly conditionalValues?: Readonly<Record<string, RemoteConfigParameterValue>>;
23
+ readonly description?: string;
24
+ }
25
+ /** A test on a device's context; a condition matches when all of its rules do. */
26
+ type RemoteConfigRule = {
27
+ readonly type: "platform";
28
+ readonly platforms: readonly ("ios" | "android")[];
29
+ } | {
30
+ readonly type: "channel";
31
+ readonly channels: readonly string[];
32
+ } |
33
+ /** A semver range, such as `>=1.4.0` or `2.x`, as release targets use. */
34
+ {
35
+ readonly type: "appVersion";
36
+ readonly range: string;
37
+ } |
38
+ /** Devices whose cohort is one of these: numbers from 1 to 1000 or slugs. */
39
+ {
40
+ readonly type: "cohort";
41
+ readonly cohorts: readonly string[];
42
+ } |
43
+ /**
44
+ * A share of numeric cohorts: the devices whose cohort lands in
45
+ * `[from, to)` percent once `seed` shuffles the 1,000 cohorts. Rules with
46
+ * one seed pick from one order, so `0–10` and `10–20` never overlap; a new
47
+ * seed picks other devices. Custom cohorts never match.
48
+ */
49
+ {
50
+ readonly type: "percent";
51
+ readonly seed: string;
52
+ readonly from: number;
53
+ readonly to: number;
54
+ } | {
55
+ readonly type: "fingerprint";
56
+ readonly hashes: readonly string[];
57
+ } |
58
+ /**
59
+ * While the server's clock is in `[from, to)`: ISO 8601 date-times in UTC,
60
+ * either of them open. A device gets the change at its next fetch.
61
+ */
62
+ {
63
+ readonly type: "dateTime";
64
+ readonly from?: string;
65
+ readonly to?: string;
66
+ };
67
+ type RemoteConfigRuleType = RemoteConfigRule["type"];
68
+ interface RemoteConfigCondition {
69
+ /** Unique in the template; parameters name it in `conditionalValues`. */
70
+ readonly name: string;
71
+ /** Every rule must match. */
72
+ readonly rules: readonly RemoteConfigRule[];
73
+ }
74
+ /**
75
+ * Everything Remote Config serves, published as a whole and versioned.
76
+ */
77
+ interface RemoteConfigTemplate {
78
+ /** In priority order: the first one that matches decides a parameter's value. */
79
+ readonly conditions: readonly RemoteConfigCondition[];
80
+ readonly parameters: Readonly<Record<string, RemoteConfigParameter>>;
81
+ }
82
+ declare const EMPTY_REMOTE_CONFIG_TEMPLATE: RemoteConfigTemplate;
83
+ /**
84
+ * A template's JSON stays under this many characters, so a device's values
85
+ * always fit the 64 KB a client plugin may store under one key.
86
+ */
87
+ declare const REMOTE_CONFIG_MAX_TEMPLATE_LENGTH = 60000;
88
+ declare const REMOTE_CONFIG_MAX_PARAMETERS = 2000;
89
+ declare const REMOTE_CONFIG_MAX_CONDITIONS = 500;
90
+ /** One problem with a template, at a JSON path such as `parameters.welcome.defaultValue`. */
91
+ interface RemoteConfigTemplateIssue {
92
+ readonly path: string;
93
+ readonly message: string;
94
+ }
95
+ /** A template that cannot be published; `issues` lists every problem found. */
96
+ declare class RemoteConfigValidationError extends Error {
97
+ readonly issues: readonly RemoteConfigTemplateIssue[];
98
+ readonly name = "RemoteConfigValidationError";
99
+ constructor(issues: readonly RemoteConfigTemplateIssue[]);
100
+ }
101
+ /**
102
+ * Checks a template and returns it with only its declared fields, or throws
103
+ * `RemoteConfigValidationError` with every problem it finds.
104
+ */
105
+ declare const validateRemoteConfigTemplate: (input: unknown) => RemoteConfigTemplate;
106
+ /** What the server knows of a device; a rule on a missing field never matches. */
107
+ type RemoteConfigEvaluationContext = Partial<Readonly<{ [K in keyof RemoteConfigDeviceContext]: RemoteConfigDeviceContext[K] | null; }>> & {
108
+ /**
109
+ * When to evaluate, in ms since the epoch. The server uses its own clock;
110
+ * a `dateTime` rule never matches without it.
111
+ */
112
+ readonly now?: number;
113
+ };
114
+ /** Whether one rule matches a device. */
115
+ declare const matchesRemoteConfigRule: (rule: RemoteConfigRule, context: RemoteConfigEvaluationContext) => boolean;
116
+ /** Whether every rule of a condition matches a device. */
117
+ declare const matchesRemoteConfigCondition: (condition: RemoteConfigCondition, context: RemoteConfigEvaluationContext) => boolean;
118
+ /** A parameter's value for one device, and the condition that chose it. */
119
+ interface RemoteConfigEvaluatedParameter {
120
+ /** The text the device receives, or null when it keeps its in-app default. */
121
+ readonly value: string | null;
122
+ /** The condition whose value applies, or null for the default value. */
123
+ readonly condition: string | null;
124
+ }
125
+ /**
126
+ * Every parameter's value for one device: the value of the first condition,
127
+ * in the template's order, that matches and that the parameter has a value
128
+ * for; otherwise its default value.
129
+ */
130
+ declare const evaluateRemoteConfig: (template: RemoteConfigTemplate, context: RemoteConfigEvaluationContext) => Record<string, RemoteConfigEvaluatedParameter>;
131
+ /** The values a device receives: each parameter's text, without in-app defaults. */
132
+ declare const resolveRemoteConfigValues: (template: RemoteConfigTemplate, context: RemoteConfigEvaluationContext) => Record<string, string>;
133
+ //#endregion
134
+ export { EMPTY_REMOTE_CONFIG_TEMPLATE, REMOTE_CONFIG_MAX_CONDITIONS, REMOTE_CONFIG_MAX_PARAMETERS, REMOTE_CONFIG_MAX_TEMPLATE_LENGTH, RemoteConfigCondition, RemoteConfigEvaluatedParameter, RemoteConfigEvaluationContext, RemoteConfigParameter, RemoteConfigParameterValue, RemoteConfigRule, RemoteConfigRuleType, RemoteConfigTemplate, RemoteConfigTemplateIssue, RemoteConfigValidationError, RemoteConfigValueType, evaluateRemoteConfig, matchesRemoteConfigCondition, matchesRemoteConfigRule, resolveRemoteConfigValues, validateRemoteConfigTemplate };
@@ -0,0 +1,134 @@
1
+ import { RemoteConfigDeviceContext } from "../shared/wire.mjs";
2
+ //#region src/server/template.d.ts
3
+ /**
4
+ * How the Console edits a parameter's value and checks it before a publish.
5
+ * Devices receive every value as text.
6
+ */
7
+ type RemoteConfigValueType = "STRING" | "NUMBER" | "BOOLEAN" | "JSON";
8
+ /** A value a parameter takes: text, or the app's own default. */
9
+ type RemoteConfigParameterValue = {
10
+ readonly value: string;
11
+ } | {
12
+ readonly useInAppDefault: true;
13
+ };
14
+ interface RemoteConfigParameter {
15
+ readonly valueType: RemoteConfigValueType;
16
+ /** The value when none of the parameter's conditions match. */
17
+ readonly defaultValue: RemoteConfigParameterValue;
18
+ /**
19
+ * Values by condition name. When several of these conditions match, the
20
+ * one listed first in the template's `conditions` wins.
21
+ */
22
+ readonly conditionalValues?: Readonly<Record<string, RemoteConfigParameterValue>>;
23
+ readonly description?: string;
24
+ }
25
+ /** A test on a device's context; a condition matches when all of its rules do. */
26
+ type RemoteConfigRule = {
27
+ readonly type: "platform";
28
+ readonly platforms: readonly ("ios" | "android")[];
29
+ } | {
30
+ readonly type: "channel";
31
+ readonly channels: readonly string[];
32
+ } |
33
+ /** A semver range, such as `>=1.4.0` or `2.x`, as release targets use. */
34
+ {
35
+ readonly type: "appVersion";
36
+ readonly range: string;
37
+ } |
38
+ /** Devices whose cohort is one of these: numbers from 1 to 1000 or slugs. */
39
+ {
40
+ readonly type: "cohort";
41
+ readonly cohorts: readonly string[];
42
+ } |
43
+ /**
44
+ * A share of numeric cohorts: the devices whose cohort lands in
45
+ * `[from, to)` percent once `seed` shuffles the 1,000 cohorts. Rules with
46
+ * one seed pick from one order, so `0–10` and `10–20` never overlap; a new
47
+ * seed picks other devices. Custom cohorts never match.
48
+ */
49
+ {
50
+ readonly type: "percent";
51
+ readonly seed: string;
52
+ readonly from: number;
53
+ readonly to: number;
54
+ } | {
55
+ readonly type: "fingerprint";
56
+ readonly hashes: readonly string[];
57
+ } |
58
+ /**
59
+ * While the server's clock is in `[from, to)`: ISO 8601 date-times in UTC,
60
+ * either of them open. A device gets the change at its next fetch.
61
+ */
62
+ {
63
+ readonly type: "dateTime";
64
+ readonly from?: string;
65
+ readonly to?: string;
66
+ };
67
+ type RemoteConfigRuleType = RemoteConfigRule["type"];
68
+ interface RemoteConfigCondition {
69
+ /** Unique in the template; parameters name it in `conditionalValues`. */
70
+ readonly name: string;
71
+ /** Every rule must match. */
72
+ readonly rules: readonly RemoteConfigRule[];
73
+ }
74
+ /**
75
+ * Everything Remote Config serves, published as a whole and versioned.
76
+ */
77
+ interface RemoteConfigTemplate {
78
+ /** In priority order: the first one that matches decides a parameter's value. */
79
+ readonly conditions: readonly RemoteConfigCondition[];
80
+ readonly parameters: Readonly<Record<string, RemoteConfigParameter>>;
81
+ }
82
+ declare const EMPTY_REMOTE_CONFIG_TEMPLATE: RemoteConfigTemplate;
83
+ /**
84
+ * A template's JSON stays under this many characters, so a device's values
85
+ * always fit the 64 KB a client plugin may store under one key.
86
+ */
87
+ declare const REMOTE_CONFIG_MAX_TEMPLATE_LENGTH = 60000;
88
+ declare const REMOTE_CONFIG_MAX_PARAMETERS = 2000;
89
+ declare const REMOTE_CONFIG_MAX_CONDITIONS = 500;
90
+ /** One problem with a template, at a JSON path such as `parameters.welcome.defaultValue`. */
91
+ interface RemoteConfigTemplateIssue {
92
+ readonly path: string;
93
+ readonly message: string;
94
+ }
95
+ /** A template that cannot be published; `issues` lists every problem found. */
96
+ declare class RemoteConfigValidationError extends Error {
97
+ readonly issues: readonly RemoteConfigTemplateIssue[];
98
+ readonly name = "RemoteConfigValidationError";
99
+ constructor(issues: readonly RemoteConfigTemplateIssue[]);
100
+ }
101
+ /**
102
+ * Checks a template and returns it with only its declared fields, or throws
103
+ * `RemoteConfigValidationError` with every problem it finds.
104
+ */
105
+ declare const validateRemoteConfigTemplate: (input: unknown) => RemoteConfigTemplate;
106
+ /** What the server knows of a device; a rule on a missing field never matches. */
107
+ type RemoteConfigEvaluationContext = Partial<Readonly<{ [K in keyof RemoteConfigDeviceContext]: RemoteConfigDeviceContext[K] | null; }>> & {
108
+ /**
109
+ * When to evaluate, in ms since the epoch. The server uses its own clock;
110
+ * a `dateTime` rule never matches without it.
111
+ */
112
+ readonly now?: number;
113
+ };
114
+ /** Whether one rule matches a device. */
115
+ declare const matchesRemoteConfigRule: (rule: RemoteConfigRule, context: RemoteConfigEvaluationContext) => boolean;
116
+ /** Whether every rule of a condition matches a device. */
117
+ declare const matchesRemoteConfigCondition: (condition: RemoteConfigCondition, context: RemoteConfigEvaluationContext) => boolean;
118
+ /** A parameter's value for one device, and the condition that chose it. */
119
+ interface RemoteConfigEvaluatedParameter {
120
+ /** The text the device receives, or null when it keeps its in-app default. */
121
+ readonly value: string | null;
122
+ /** The condition whose value applies, or null for the default value. */
123
+ readonly condition: string | null;
124
+ }
125
+ /**
126
+ * Every parameter's value for one device: the value of the first condition,
127
+ * in the template's order, that matches and that the parameter has a value
128
+ * for; otherwise its default value.
129
+ */
130
+ declare const evaluateRemoteConfig: (template: RemoteConfigTemplate, context: RemoteConfigEvaluationContext) => Record<string, RemoteConfigEvaluatedParameter>;
131
+ /** The values a device receives: each parameter's text, without in-app defaults. */
132
+ declare const resolveRemoteConfigValues: (template: RemoteConfigTemplate, context: RemoteConfigEvaluationContext) => Record<string, string>;
133
+ //#endregion
134
+ export { EMPTY_REMOTE_CONFIG_TEMPLATE, REMOTE_CONFIG_MAX_CONDITIONS, REMOTE_CONFIG_MAX_PARAMETERS, REMOTE_CONFIG_MAX_TEMPLATE_LENGTH, RemoteConfigCondition, RemoteConfigEvaluatedParameter, RemoteConfigEvaluationContext, RemoteConfigParameter, RemoteConfigParameterValue, RemoteConfigRule, RemoteConfigRuleType, RemoteConfigTemplate, RemoteConfigTemplateIssue, RemoteConfigValidationError, RemoteConfigValueType, evaluateRemoteConfig, matchesRemoteConfigCondition, matchesRemoteConfigRule, resolveRemoteConfigValues, validateRemoteConfigTemplate };