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

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,387 @@
1
+ import { ownEntry } from "../shared/ownEntry.mjs";
2
+ import { semverSatisfies } from "@hot-updater/plugin-core";
3
+ import { getNumericCohortRolloutPosition, getNumericCohortValue, isValidCohort, normalizeCohortValue } from "@hot-updater/protocol";
4
+ import { isValidRange } from "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 = 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) || !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, isValidCohort, "must be a cohort from 1 to 1000, or a lowercase slug.").map(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" && semverSatisfies(rule.range, context.appVersion);
344
+ case "cohort": return typeof context.cohort === "string" && rule.cohorts.includes(normalizeCohortValue(context.cohort));
345
+ case "percent": {
346
+ if (typeof context.cohort !== "string") return false;
347
+ const cohort = getNumericCohortValue(context.cohort);
348
+ if (cohort === null) return false;
349
+ const position = 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 = 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
+ export { EMPTY_REMOTE_CONFIG_TEMPLATE, REMOTE_CONFIG_MAX_CONDITIONS, REMOTE_CONFIG_MAX_PARAMETERS, REMOTE_CONFIG_MAX_TEMPLATE_LENGTH, RemoteConfigValidationError, evaluateRemoteConfig, matchesRemoteConfigCondition, matchesRemoteConfigRule, resolveRemoteConfigValues, validateRemoteConfigTemplate };
@@ -0,0 +1,8 @@
1
+ //#region src/shared/ownEntry.ts
2
+ /**
3
+ * A record's own entry. Parameter keys and condition names come from users,
4
+ * so `constructor` or `__proto__` must name theirs, never Object.prototype's.
5
+ */
6
+ const ownEntry = (record, key) => record !== void 0 && Object.prototype.hasOwnProperty.call(record, key) ? record[key] : void 0;
7
+ //#endregion
8
+ exports.ownEntry = ownEntry;
@@ -0,0 +1,8 @@
1
+ //#region src/shared/ownEntry.ts
2
+ /**
3
+ * A record's own entry. Parameter keys and condition names come from users,
4
+ * so `constructor` or `__proto__` must name theirs, never Object.prototype's.
5
+ */
6
+ const ownEntry = (record, key) => record !== void 0 && Object.prototype.hasOwnProperty.call(record, key) ? record[key] : void 0;
7
+ //#endregion
8
+ export { ownEntry };
@@ -0,0 +1,48 @@
1
+ //#region src/shared/wire.ts
2
+ /**
3
+ * The exchange between the app's `remoteConfig()` client plugin and the
4
+ * `remoteConfig()` server plugin: `GET /remote-config`, relative to the
5
+ * `baseURL` the app passes to `HotUpdater.init`. The app
6
+ * sends what conditions read as query parameters, and the server answers the
7
+ * values the published template gives them. Both sides import this module,
8
+ * which imports nothing, so the app's bundle never reaches server code.
9
+ */
10
+ /** The path both sides use, relative to the app's `baseURL`. */
11
+ const REMOTE_CONFIG_PATH = "remote-config";
12
+ /** The query parameter each context field travels in. */
13
+ const REMOTE_CONFIG_QUERY = {
14
+ platform: "platform",
15
+ appVersion: "appVersion",
16
+ channel: "channel",
17
+ cohort: "cohort",
18
+ fingerprintHash: "fingerprintHash"
19
+ };
20
+ /**
21
+ * `GET /remote-config?…`, with a field left out when it is null. It encodes
22
+ * the query itself, since React Native's `URLSearchParams` throws on `set`.
23
+ */
24
+ const remoteConfigRequestPath = (context) => {
25
+ const query = Object.keys(REMOTE_CONFIG_QUERY).flatMap((field) => {
26
+ const value = context[field];
27
+ return value === null ? [] : [`${encodeURIComponent(REMOTE_CONFIG_QUERY[field])}=${encodeURIComponent(value)}`];
28
+ });
29
+ return `${REMOTE_CONFIG_PATH}?${query.join("&")}`;
30
+ };
31
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
32
+ /** A `200` body, checked; null when it is not one. */
33
+ const parseRemoteConfigFetchResponse = (value) => {
34
+ if (!isRecord(value)) return null;
35
+ const { version, values } = value;
36
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 0 || !isRecord(values)) return null;
37
+ const entries = Object.entries(values);
38
+ if (entries.some(([, text]) => typeof text !== "string")) return null;
39
+ return {
40
+ version,
41
+ values: Object.fromEntries(entries)
42
+ };
43
+ };
44
+ //#endregion
45
+ exports.REMOTE_CONFIG_PATH = REMOTE_CONFIG_PATH;
46
+ exports.REMOTE_CONFIG_QUERY = REMOTE_CONFIG_QUERY;
47
+ exports.parseRemoteConfigFetchResponse = parseRemoteConfigFetchResponse;
48
+ exports.remoteConfigRequestPath = remoteConfigRequestPath;
@@ -0,0 +1,24 @@
1
+ //#region src/shared/wire.d.ts
2
+ /** What a device tells the server, so the server can evaluate conditions. */
3
+ interface RemoteConfigDeviceContext {
4
+ readonly platform: "ios" | "android";
5
+ /** The native app version, or null when the build has none. */
6
+ readonly appVersion: string | null;
7
+ readonly channel: string;
8
+ /** A numeric cohort from 1 to 1000, or a custom cohort's slug. */
9
+ readonly cohort: string;
10
+ /** The native build's fingerprint, or null without one. */
11
+ readonly fingerprintHash: string | null;
12
+ }
13
+ /** The body of a `200` from `GET /remote-config`. */
14
+ interface RemoteConfigFetchResponse {
15
+ /** The published template version the values come from; 0 before the first publish. */
16
+ readonly version: number;
17
+ /**
18
+ * Each parameter's value for this device, as text. A parameter whose
19
+ * value is the app's own default is left out.
20
+ */
21
+ readonly values: Readonly<Record<string, string>>;
22
+ }
23
+ //#endregion
24
+ export { RemoteConfigDeviceContext, RemoteConfigFetchResponse };
@@ -0,0 +1,24 @@
1
+ //#region src/shared/wire.d.ts
2
+ /** What a device tells the server, so the server can evaluate conditions. */
3
+ interface RemoteConfigDeviceContext {
4
+ readonly platform: "ios" | "android";
5
+ /** The native app version, or null when the build has none. */
6
+ readonly appVersion: string | null;
7
+ readonly channel: string;
8
+ /** A numeric cohort from 1 to 1000, or a custom cohort's slug. */
9
+ readonly cohort: string;
10
+ /** The native build's fingerprint, or null without one. */
11
+ readonly fingerprintHash: string | null;
12
+ }
13
+ /** The body of a `200` from `GET /remote-config`. */
14
+ interface RemoteConfigFetchResponse {
15
+ /** The published template version the values come from; 0 before the first publish. */
16
+ readonly version: number;
17
+ /**
18
+ * Each parameter's value for this device, as text. A parameter whose
19
+ * value is the app's own default is left out.
20
+ */
21
+ readonly values: Readonly<Record<string, string>>;
22
+ }
23
+ //#endregion
24
+ export { RemoteConfigDeviceContext, RemoteConfigFetchResponse };
@@ -0,0 +1,45 @@
1
+ //#region src/shared/wire.ts
2
+ /**
3
+ * The exchange between the app's `remoteConfig()` client plugin and the
4
+ * `remoteConfig()` server plugin: `GET /remote-config`, relative to the
5
+ * `baseURL` the app passes to `HotUpdater.init`. The app
6
+ * sends what conditions read as query parameters, and the server answers the
7
+ * values the published template gives them. Both sides import this module,
8
+ * which imports nothing, so the app's bundle never reaches server code.
9
+ */
10
+ /** The path both sides use, relative to the app's `baseURL`. */
11
+ const REMOTE_CONFIG_PATH = "remote-config";
12
+ /** The query parameter each context field travels in. */
13
+ const REMOTE_CONFIG_QUERY = {
14
+ platform: "platform",
15
+ appVersion: "appVersion",
16
+ channel: "channel",
17
+ cohort: "cohort",
18
+ fingerprintHash: "fingerprintHash"
19
+ };
20
+ /**
21
+ * `GET /remote-config?…`, with a field left out when it is null. It encodes
22
+ * the query itself, since React Native's `URLSearchParams` throws on `set`.
23
+ */
24
+ const remoteConfigRequestPath = (context) => {
25
+ const query = Object.keys(REMOTE_CONFIG_QUERY).flatMap((field) => {
26
+ const value = context[field];
27
+ return value === null ? [] : [`${encodeURIComponent(REMOTE_CONFIG_QUERY[field])}=${encodeURIComponent(value)}`];
28
+ });
29
+ return `${REMOTE_CONFIG_PATH}?${query.join("&")}`;
30
+ };
31
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
32
+ /** A `200` body, checked; null when it is not one. */
33
+ const parseRemoteConfigFetchResponse = (value) => {
34
+ if (!isRecord(value)) return null;
35
+ const { version, values } = value;
36
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 0 || !isRecord(values)) return null;
37
+ const entries = Object.entries(values);
38
+ if (entries.some(([, text]) => typeof text !== "string")) return null;
39
+ return {
40
+ version,
41
+ values: Object.fromEntries(entries)
42
+ };
43
+ };
44
+ //#endregion
45
+ export { REMOTE_CONFIG_PATH, REMOTE_CONFIG_QUERY, parseRemoteConfigFetchResponse, remoteConfigRequestPath };
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@hot-updater/plugin-remote-config",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "type": "module",
4
+ "version": "1.0.0-rc.42",
5
+ "description": "Hot Updater's Remote Config plugin: parameters, conditions, and versioned templates on the server, and values the React Native app reads",
6
+ "exports": {
7
+ "./client": {
8
+ "import": "./dist/client/index.mjs",
9
+ "require": "./dist/client/index.cjs"
10
+ },
11
+ "./server": {
12
+ "import": "./dist/server/index.mjs",
13
+ "require": "./dist/server/index.cjs"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/gronxb/hot-updater"
21
+ },
22
+ "author": "gronxb <gron1gh1@gmail.com> (https://github.com/gronxb)",
23
+ "bugs": {
24
+ "url": "https://github.com/gronxb/hot-updater/issues"
25
+ },
26
+ "homepage": "https://github.com/gronxb/hot-updater#readme",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "client",
33
+ "package.json"
34
+ ],
35
+ "dependencies": {
36
+ "verkit": "0.4.0",
37
+ "@hot-updater/protocol": "1.0.0-rc.42"
38
+ },
39
+ "peerDependencies": {
40
+ "@hot-updater/plugin-core": "^1.0.0-rc.42"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@hot-updater/plugin-core": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^20",
49
+ "vitest": "4.1.4",
50
+ "@hot-updater/plugin-core": "1.0.0-rc.42",
51
+ "@hot-updater/test-utils": "1.0.0-rc.42"
52
+ },
53
+ "nx": {
54
+ "implicitDependencies": [
55
+ "!@hot-updater/test-utils"
56
+ ]
57
+ },
58
+ "scripts": {
59
+ "build": "tsdown",
60
+ "test:type": "tsc --noEmit"
61
+ }
6
62
  }