@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.
- package/LICENSE +33 -0
- package/README.md +23 -3
- package/client/package.json +7 -0
- package/dist/client/index.cjs +208 -0
- package/dist/client/index.d.cts +123 -0
- package/dist/client/index.d.mts +123 -0
- package/dist/client/index.mjs +207 -0
- package/dist/server/api.cjs +175 -0
- package/dist/server/api.d.cts +85 -0
- package/dist/server/api.d.mts +85 -0
- package/dist/server/api.mjs +174 -0
- package/dist/server/index.cjs +48 -0
- package/dist/server/index.d.cts +63 -0
- package/dist/server/index.d.mts +63 -0
- package/dist/server/index.mjs +35 -0
- package/dist/server/official.cjs +12 -0
- package/dist/server/official.mjs +12 -0
- package/dist/server/routes.cjs +157 -0
- package/dist/server/routes.mjs +157 -0
- package/dist/server/schema.cjs +46 -0
- package/dist/server/schema.d.cts +57 -0
- package/dist/server/schema.d.mts +57 -0
- package/dist/server/schema.mjs +46 -0
- package/dist/server/template.cjs +396 -0
- package/dist/server/template.d.cts +134 -0
- package/dist/server/template.d.mts +134 -0
- package/dist/server/template.mjs +387 -0
- package/dist/shared/ownEntry.cjs +8 -0
- package/dist/shared/ownEntry.mjs +8 -0
- package/dist/shared/wire.cjs +48 -0
- package/dist/shared/wire.d.cts +24 -0
- package/dist/shared/wire.d.mts +24 -0
- package/dist/shared/wire.mjs +45 -0
- package/package.json +59 -3
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { EMPTY_REMOTE_CONFIG_TEMPLATE, resolveRemoteConfigValues, validateRemoteConfigTemplate } from "./template.mjs";
|
|
2
|
+
import { DatabaseConstraintError } from "@hot-updater/plugin-core";
|
|
3
|
+
//#region src/server/api.ts
|
|
4
|
+
const ACTIVE_ID = "active";
|
|
5
|
+
/** How long a server answers fetches from the active template it read last. */
|
|
6
|
+
const ACTIVE_CACHE_TTL_MS = 5e3;
|
|
7
|
+
const MAX_DESCRIPTION_LENGTH = 256;
|
|
8
|
+
const MAX_VERSIONS_PAGE = 100;
|
|
9
|
+
/**
|
|
10
|
+
* An argument the API refuses, such as a negative `baseVersion`; the admin
|
|
11
|
+
* routes answer it with `400`.
|
|
12
|
+
*/
|
|
13
|
+
var RemoteConfigInputError = class extends TypeError {
|
|
14
|
+
name = "RemoteConfigInputError";
|
|
15
|
+
};
|
|
16
|
+
const normalizeDescription = (description) => {
|
|
17
|
+
if (description === void 0 || description === null) return null;
|
|
18
|
+
if (typeof description !== "string") throw new RemoteConfigInputError("A version description is text.");
|
|
19
|
+
const trimmed = description.trim();
|
|
20
|
+
if (trimmed.length > MAX_DESCRIPTION_LENGTH) throw new RemoteConfigInputError(`A version description holds up to ${MAX_DESCRIPTION_LENGTH} characters.`);
|
|
21
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
22
|
+
};
|
|
23
|
+
const requireVersionNumber = (value, name) => {
|
|
24
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new RemoteConfigInputError(`${name} must be a non-negative integer.`);
|
|
25
|
+
return value;
|
|
26
|
+
};
|
|
27
|
+
/** A template as its `json` column stores it; validation made it plain JSON. */
|
|
28
|
+
const toColumn = (template) => template;
|
|
29
|
+
const fromColumn = (value) => value;
|
|
30
|
+
const toVersion = (row) => Object.freeze({
|
|
31
|
+
version: row.version,
|
|
32
|
+
description: row.description,
|
|
33
|
+
updateType: row.update_type === "ROLLBACK" ? "ROLLBACK" : "PUBLISH",
|
|
34
|
+
rollbackSource: row.rollback_source,
|
|
35
|
+
createdAtMs: row.created_at_ms
|
|
36
|
+
});
|
|
37
|
+
/** Remote Config's API over its tables. */
|
|
38
|
+
const createRemoteConfigApi = ({ db, now }) => {
|
|
39
|
+
let cached = null;
|
|
40
|
+
let loading = null;
|
|
41
|
+
let generation = 0;
|
|
42
|
+
const readActive = async () => {
|
|
43
|
+
const row = await db.findOne("remote_config_active", { id: ACTIVE_ID });
|
|
44
|
+
return row === null ? {
|
|
45
|
+
version: 0,
|
|
46
|
+
template: EMPTY_REMOTE_CONFIG_TEMPLATE,
|
|
47
|
+
updatedAtMs: null
|
|
48
|
+
} : {
|
|
49
|
+
version: row.version,
|
|
50
|
+
template: fromColumn(row.template),
|
|
51
|
+
updatedAtMs: row.updated_at_ms
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
/** The active template for fetches: one read per server every few seconds at most. */
|
|
55
|
+
const servedActive = () => {
|
|
56
|
+
if (cached !== null && cached.expiresAt > now()) return Promise.resolve(cached.active);
|
|
57
|
+
if (loading !== null) return loading;
|
|
58
|
+
const started = generation;
|
|
59
|
+
const read = readActive().then((active) => {
|
|
60
|
+
if (generation === started) cached = {
|
|
61
|
+
active,
|
|
62
|
+
expiresAt: now() + ACTIVE_CACHE_TTL_MS
|
|
63
|
+
};
|
|
64
|
+
return active;
|
|
65
|
+
}).finally(() => {
|
|
66
|
+
if (loading === read) loading = null;
|
|
67
|
+
});
|
|
68
|
+
loading = read;
|
|
69
|
+
return read;
|
|
70
|
+
};
|
|
71
|
+
const commit = async (input) => {
|
|
72
|
+
const attempt = () => db.transaction(async (tx) => {
|
|
73
|
+
const active = await tx.findOne("remote_config_active", { id: ACTIVE_ID });
|
|
74
|
+
const currentVersion = active?.version ?? 0;
|
|
75
|
+
if (currentVersion !== input.baseVersion) return {
|
|
76
|
+
status: "conflict",
|
|
77
|
+
currentVersion
|
|
78
|
+
};
|
|
79
|
+
const version = currentVersion + 1;
|
|
80
|
+
const at = now();
|
|
81
|
+
const row = {
|
|
82
|
+
version,
|
|
83
|
+
template: toColumn(input.template),
|
|
84
|
+
description: input.description,
|
|
85
|
+
update_type: input.rollbackSource === null ? "PUBLISH" : "ROLLBACK",
|
|
86
|
+
rollback_source: input.rollbackSource,
|
|
87
|
+
created_at_ms: at
|
|
88
|
+
};
|
|
89
|
+
tx.create("remote_config_versions", row);
|
|
90
|
+
if (active === null) tx.create("remote_config_active", {
|
|
91
|
+
id: ACTIVE_ID,
|
|
92
|
+
version,
|
|
93
|
+
template: toColumn(input.template),
|
|
94
|
+
updated_at_ms: at
|
|
95
|
+
});
|
|
96
|
+
else tx.update("remote_config_active", active, {
|
|
97
|
+
version,
|
|
98
|
+
template: toColumn(input.template),
|
|
99
|
+
updated_at_ms: at
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
status: "published",
|
|
103
|
+
version: toVersion(row)
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
const result = await attempt();
|
|
108
|
+
if (result.status === "published") {
|
|
109
|
+
generation += 1;
|
|
110
|
+
cached = null;
|
|
111
|
+
loading = null;
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error instanceof DatabaseConstraintError && (error.reason === "exists" || error.reason === "unique")) return {
|
|
116
|
+
status: "conflict",
|
|
117
|
+
currentVersion: (await readActive()).version
|
|
118
|
+
};
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
return Object.freeze({
|
|
123
|
+
getActive: readActive,
|
|
124
|
+
publish: async ({ template, baseVersion, description }) => commit({
|
|
125
|
+
template: validateRemoteConfigTemplate(template),
|
|
126
|
+
baseVersion: requireVersionNumber(baseVersion, "baseVersion"),
|
|
127
|
+
description: normalizeDescription(description),
|
|
128
|
+
rollbackSource: null
|
|
129
|
+
}),
|
|
130
|
+
rollback: async ({ version, baseVersion, description }) => {
|
|
131
|
+
const source = await db.findOne("remote_config_versions", { version: requireVersionNumber(version, "version") });
|
|
132
|
+
if (source === null) return { status: "not_found" };
|
|
133
|
+
return commit({
|
|
134
|
+
template: fromColumn(source.template),
|
|
135
|
+
baseVersion: requireVersionNumber(baseVersion, "baseVersion"),
|
|
136
|
+
description: normalizeDescription(description) ?? `Rollback to version ${source.version}`,
|
|
137
|
+
rollbackSource: source.version
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
listVersions: async ({ limit = 20, cursor } = {}) => {
|
|
141
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_VERSIONS_PAGE) throw new RemoteConfigInputError(`limit must be an integer from 1 to ${MAX_VERSIONS_PAGE}.`);
|
|
142
|
+
const page = await db.findMany("remote_config_versions", {
|
|
143
|
+
index: "byVersion",
|
|
144
|
+
where: {},
|
|
145
|
+
order: "desc",
|
|
146
|
+
limit,
|
|
147
|
+
...cursor === void 0 ? {} : { cursor }
|
|
148
|
+
});
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
versions: Object.freeze(page.rows.map(toVersion)),
|
|
151
|
+
...page.next === void 0 ? {} : { next: page.next }
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
getVersion: async (version) => {
|
|
155
|
+
const row = await db.findOne("remote_config_versions", { version: requireVersionNumber(version, "version") });
|
|
156
|
+
return row === null ? null : Object.freeze({
|
|
157
|
+
...toVersion(row),
|
|
158
|
+
template: fromColumn(row.template)
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
resolve: async (context) => {
|
|
162
|
+
const active = await servedActive();
|
|
163
|
+
return {
|
|
164
|
+
version: active.version,
|
|
165
|
+
values: resolveRemoteConfigValues(active.template, {
|
|
166
|
+
...context,
|
|
167
|
+
now: context.now ?? now()
|
|
168
|
+
})
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
//#endregion
|
|
174
|
+
export { RemoteConfigInputError, createRemoteConfigApi };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_template = require("./template.cjs");
|
|
3
|
+
const require_api = require("./api.cjs");
|
|
4
|
+
const require_official = require("./official.cjs");
|
|
5
|
+
const require_routes = require("./routes.cjs");
|
|
6
|
+
const require_schema = require("./schema.cjs");
|
|
7
|
+
let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
|
|
8
|
+
//#region src/server/index.ts
|
|
9
|
+
/**
|
|
10
|
+
* Remote Config: parameters whose values the server picks per device by
|
|
11
|
+
* conditions, published as versioned templates. Devices fetch their values
|
|
12
|
+
* from `GET /remote-config` with the `remoteConfig()` client plugin; the
|
|
13
|
+
* Console and `hotUpdater.api.remoteConfig` edit, publish, and roll back
|
|
14
|
+
* templates.
|
|
15
|
+
*/
|
|
16
|
+
const remoteConfig = () => {
|
|
17
|
+
const plugin = (0, _hot_updater_plugin_core.definePlugin)({
|
|
18
|
+
id: "remoteConfig",
|
|
19
|
+
namespace: false,
|
|
20
|
+
schemaVersion: "1.0.0",
|
|
21
|
+
schema: require_schema.remoteConfigSchema,
|
|
22
|
+
init: ({ db, now }) => {
|
|
23
|
+
const api = require_api.createRemoteConfigApi({
|
|
24
|
+
db,
|
|
25
|
+
now
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
api,
|
|
29
|
+
endpoints: require_routes.createRemoteConfigEndpoints(api)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return require_official.markOfficial(plugin);
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
exports.EMPTY_REMOTE_CONFIG_TEMPLATE = require_template.EMPTY_REMOTE_CONFIG_TEMPLATE;
|
|
37
|
+
exports.REMOTE_CONFIG_MAX_CONDITIONS = require_template.REMOTE_CONFIG_MAX_CONDITIONS;
|
|
38
|
+
exports.REMOTE_CONFIG_MAX_PARAMETERS = require_template.REMOTE_CONFIG_MAX_PARAMETERS;
|
|
39
|
+
exports.REMOTE_CONFIG_MAX_TEMPLATE_LENGTH = require_template.REMOTE_CONFIG_MAX_TEMPLATE_LENGTH;
|
|
40
|
+
exports.RemoteConfigInputError = require_api.RemoteConfigInputError;
|
|
41
|
+
exports.RemoteConfigValidationError = require_template.RemoteConfigValidationError;
|
|
42
|
+
exports.evaluateRemoteConfig = require_template.evaluateRemoteConfig;
|
|
43
|
+
exports.matchesRemoteConfigCondition = require_template.matchesRemoteConfigCondition;
|
|
44
|
+
exports.matchesRemoteConfigRule = require_template.matchesRemoteConfigRule;
|
|
45
|
+
exports.remoteConfig = remoteConfig;
|
|
46
|
+
exports.remoteConfigSchema = require_schema.remoteConfigSchema;
|
|
47
|
+
exports.resolveRemoteConfigValues = require_template.resolveRemoteConfigValues;
|
|
48
|
+
exports.validateRemoteConfigTemplate = require_template.validateRemoteConfigTemplate;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { RemoteConfigDeviceContext, RemoteConfigFetchResponse } from "../shared/wire.cjs";
|
|
2
|
+
import { RemoteConfigSchema, remoteConfigSchema } from "./schema.cjs";
|
|
3
|
+
import { 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 } from "./template.cjs";
|
|
4
|
+
import { RemoteConfigActive, RemoteConfigApi, RemoteConfigInputError, RemoteConfigPublishResult, RemoteConfigVersion, RemoteConfigVersionDetail, RemoteConfigVersionsPage } from "./api.cjs";
|
|
5
|
+
//#region src/server/index.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Remote Config: parameters whose values the server picks per device by
|
|
8
|
+
* conditions, published as versioned templates. Devices fetch their values
|
|
9
|
+
* from `GET /remote-config` with the `remoteConfig()` client plugin; the
|
|
10
|
+
* Console and `hotUpdater.api.remoteConfig` edit, publish, and roll back
|
|
11
|
+
* templates.
|
|
12
|
+
*/
|
|
13
|
+
declare const remoteConfig: () => import("@hot-updater/plugin-core").HotUpdaterPlugin<"remoteConfig", {
|
|
14
|
+
readonly remote_config_active: import("@hot-updater/plugin-core").TableDefinition<{
|
|
15
|
+
readonly id: {
|
|
16
|
+
readonly type: "string";
|
|
17
|
+
readonly maxLength: 16;
|
|
18
|
+
};
|
|
19
|
+
readonly version: {
|
|
20
|
+
readonly type: "integer";
|
|
21
|
+
};
|
|
22
|
+
readonly template: {
|
|
23
|
+
readonly type: "json";
|
|
24
|
+
};
|
|
25
|
+
readonly updated_at_ms: {
|
|
26
|
+
readonly type: "integer";
|
|
27
|
+
};
|
|
28
|
+
}, {}, {}, readonly ["id"]>;
|
|
29
|
+
readonly remote_config_versions: import("@hot-updater/plugin-core").TableDefinition<{
|
|
30
|
+
readonly version: {
|
|
31
|
+
readonly type: "integer";
|
|
32
|
+
};
|
|
33
|
+
readonly template: {
|
|
34
|
+
readonly type: "json";
|
|
35
|
+
};
|
|
36
|
+
readonly description: {
|
|
37
|
+
readonly type: "string";
|
|
38
|
+
readonly maxLength: 256;
|
|
39
|
+
readonly required: false;
|
|
40
|
+
};
|
|
41
|
+
readonly update_type: {
|
|
42
|
+
readonly type: "string";
|
|
43
|
+
readonly maxLength: 16;
|
|
44
|
+
};
|
|
45
|
+
readonly rollback_source: {
|
|
46
|
+
readonly type: "integer";
|
|
47
|
+
readonly required: false;
|
|
48
|
+
};
|
|
49
|
+
readonly created_at_ms: {
|
|
50
|
+
readonly type: "integer";
|
|
51
|
+
};
|
|
52
|
+
}, {}, {
|
|
53
|
+
readonly byVersion: {
|
|
54
|
+
readonly eq: readonly [];
|
|
55
|
+
readonly sort: readonly ["version"];
|
|
56
|
+
};
|
|
57
|
+
}, readonly ["version"]>;
|
|
58
|
+
}, {
|
|
59
|
+
api: RemoteConfigApi;
|
|
60
|
+
endpoints: readonly import("@hot-updater/plugin-core").PluginEndpoint[];
|
|
61
|
+
}, {}>;
|
|
62
|
+
//#endregion
|
|
63
|
+
export { EMPTY_REMOTE_CONFIG_TEMPLATE, REMOTE_CONFIG_MAX_CONDITIONS, REMOTE_CONFIG_MAX_PARAMETERS, REMOTE_CONFIG_MAX_TEMPLATE_LENGTH, type RemoteConfigActive, type RemoteConfigApi, type RemoteConfigCondition, type RemoteConfigDeviceContext, type RemoteConfigEvaluatedParameter, type RemoteConfigEvaluationContext, type RemoteConfigFetchResponse, RemoteConfigInputError, type RemoteConfigParameter, type RemoteConfigParameterValue, type RemoteConfigPublishResult, type RemoteConfigRule, type RemoteConfigRuleType, type RemoteConfigSchema, type RemoteConfigTemplate, type RemoteConfigTemplateIssue, RemoteConfigValidationError, type RemoteConfigValueType, type RemoteConfigVersion, type RemoteConfigVersionDetail, type RemoteConfigVersionsPage, evaluateRemoteConfig, matchesRemoteConfigCondition, matchesRemoteConfigRule, remoteConfig, remoteConfigSchema, resolveRemoteConfigValues, validateRemoteConfigTemplate };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { RemoteConfigDeviceContext, RemoteConfigFetchResponse } from "../shared/wire.mjs";
|
|
2
|
+
import { RemoteConfigSchema, remoteConfigSchema } from "./schema.mjs";
|
|
3
|
+
import { 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 } from "./template.mjs";
|
|
4
|
+
import { RemoteConfigActive, RemoteConfigApi, RemoteConfigInputError, RemoteConfigPublishResult, RemoteConfigVersion, RemoteConfigVersionDetail, RemoteConfigVersionsPage } from "./api.mjs";
|
|
5
|
+
//#region src/server/index.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Remote Config: parameters whose values the server picks per device by
|
|
8
|
+
* conditions, published as versioned templates. Devices fetch their values
|
|
9
|
+
* from `GET /remote-config` with the `remoteConfig()` client plugin; the
|
|
10
|
+
* Console and `hotUpdater.api.remoteConfig` edit, publish, and roll back
|
|
11
|
+
* templates.
|
|
12
|
+
*/
|
|
13
|
+
declare const remoteConfig: () => import("@hot-updater/plugin-core").HotUpdaterPlugin<"remoteConfig", {
|
|
14
|
+
readonly remote_config_active: import("@hot-updater/plugin-core").TableDefinition<{
|
|
15
|
+
readonly id: {
|
|
16
|
+
readonly type: "string";
|
|
17
|
+
readonly maxLength: 16;
|
|
18
|
+
};
|
|
19
|
+
readonly version: {
|
|
20
|
+
readonly type: "integer";
|
|
21
|
+
};
|
|
22
|
+
readonly template: {
|
|
23
|
+
readonly type: "json";
|
|
24
|
+
};
|
|
25
|
+
readonly updated_at_ms: {
|
|
26
|
+
readonly type: "integer";
|
|
27
|
+
};
|
|
28
|
+
}, {}, {}, readonly ["id"]>;
|
|
29
|
+
readonly remote_config_versions: import("@hot-updater/plugin-core").TableDefinition<{
|
|
30
|
+
readonly version: {
|
|
31
|
+
readonly type: "integer";
|
|
32
|
+
};
|
|
33
|
+
readonly template: {
|
|
34
|
+
readonly type: "json";
|
|
35
|
+
};
|
|
36
|
+
readonly description: {
|
|
37
|
+
readonly type: "string";
|
|
38
|
+
readonly maxLength: 256;
|
|
39
|
+
readonly required: false;
|
|
40
|
+
};
|
|
41
|
+
readonly update_type: {
|
|
42
|
+
readonly type: "string";
|
|
43
|
+
readonly maxLength: 16;
|
|
44
|
+
};
|
|
45
|
+
readonly rollback_source: {
|
|
46
|
+
readonly type: "integer";
|
|
47
|
+
readonly required: false;
|
|
48
|
+
};
|
|
49
|
+
readonly created_at_ms: {
|
|
50
|
+
readonly type: "integer";
|
|
51
|
+
};
|
|
52
|
+
}, {}, {
|
|
53
|
+
readonly byVersion: {
|
|
54
|
+
readonly eq: readonly [];
|
|
55
|
+
readonly sort: readonly ["version"];
|
|
56
|
+
};
|
|
57
|
+
}, readonly ["version"]>;
|
|
58
|
+
}, {
|
|
59
|
+
api: RemoteConfigApi;
|
|
60
|
+
endpoints: readonly import("@hot-updater/plugin-core").PluginEndpoint[];
|
|
61
|
+
}, {}>;
|
|
62
|
+
//#endregion
|
|
63
|
+
export { EMPTY_REMOTE_CONFIG_TEMPLATE, REMOTE_CONFIG_MAX_CONDITIONS, REMOTE_CONFIG_MAX_PARAMETERS, REMOTE_CONFIG_MAX_TEMPLATE_LENGTH, type RemoteConfigActive, type RemoteConfigApi, type RemoteConfigCondition, type RemoteConfigDeviceContext, type RemoteConfigEvaluatedParameter, type RemoteConfigEvaluationContext, type RemoteConfigFetchResponse, RemoteConfigInputError, type RemoteConfigParameter, type RemoteConfigParameterValue, type RemoteConfigPublishResult, type RemoteConfigRule, type RemoteConfigRuleType, type RemoteConfigSchema, type RemoteConfigTemplate, type RemoteConfigTemplateIssue, RemoteConfigValidationError, type RemoteConfigValueType, type RemoteConfigVersion, type RemoteConfigVersionDetail, type RemoteConfigVersionsPage, evaluateRemoteConfig, matchesRemoteConfigCondition, matchesRemoteConfigRule, remoteConfig, remoteConfigSchema, resolveRemoteConfigValues, validateRemoteConfigTemplate };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { EMPTY_REMOTE_CONFIG_TEMPLATE, REMOTE_CONFIG_MAX_CONDITIONS, REMOTE_CONFIG_MAX_PARAMETERS, REMOTE_CONFIG_MAX_TEMPLATE_LENGTH, RemoteConfigValidationError, evaluateRemoteConfig, matchesRemoteConfigCondition, matchesRemoteConfigRule, resolveRemoteConfigValues, validateRemoteConfigTemplate } from "./template.mjs";
|
|
2
|
+
import { RemoteConfigInputError, createRemoteConfigApi } from "./api.mjs";
|
|
3
|
+
import { markOfficial } from "./official.mjs";
|
|
4
|
+
import { createRemoteConfigEndpoints } from "./routes.mjs";
|
|
5
|
+
import { remoteConfigSchema } from "./schema.mjs";
|
|
6
|
+
import { definePlugin } from "@hot-updater/plugin-core";
|
|
7
|
+
//#region src/server/index.ts
|
|
8
|
+
/**
|
|
9
|
+
* Remote Config: parameters whose values the server picks per device by
|
|
10
|
+
* conditions, published as versioned templates. Devices fetch their values
|
|
11
|
+
* from `GET /remote-config` with the `remoteConfig()` client plugin; the
|
|
12
|
+
* Console and `hotUpdater.api.remoteConfig` edit, publish, and roll back
|
|
13
|
+
* templates.
|
|
14
|
+
*/
|
|
15
|
+
const remoteConfig = () => {
|
|
16
|
+
const plugin = definePlugin({
|
|
17
|
+
id: "remoteConfig",
|
|
18
|
+
namespace: false,
|
|
19
|
+
schemaVersion: "1.0.0",
|
|
20
|
+
schema: remoteConfigSchema,
|
|
21
|
+
init: ({ db, now }) => {
|
|
22
|
+
const api = createRemoteConfigApi({
|
|
23
|
+
db,
|
|
24
|
+
now
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
api,
|
|
28
|
+
endpoints: createRemoteConfigEndpoints(api)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return markOfficial(plugin);
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { EMPTY_REMOTE_CONFIG_TEMPLATE, REMOTE_CONFIG_MAX_CONDITIONS, REMOTE_CONFIG_MAX_PARAMETERS, REMOTE_CONFIG_MAX_TEMPLATE_LENGTH, RemoteConfigInputError, RemoteConfigValidationError, evaluateRemoteConfig, matchesRemoteConfigCondition, matchesRemoteConfigRule, remoteConfig, remoteConfigSchema, resolveRemoteConfigValues, validateRemoteConfigTemplate };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/server/official.ts
|
|
2
|
+
/**
|
|
3
|
+
* The brand of Hot Updater's own plugins, a registered symbol that
|
|
4
|
+
* `@hot-updater/server` reads to reserve the id "remoteConfig" for this plugin.
|
|
5
|
+
* Each official plugin package keeps this copy, so none imports another's
|
|
6
|
+
* internals for it.
|
|
7
|
+
*/
|
|
8
|
+
const OFFICIAL = Symbol.for("@hot-updater/server/official-plugin");
|
|
9
|
+
/** Marks a plugin one of Hot Updater's own factories made. */
|
|
10
|
+
const markOfficial = (plugin) => Object.defineProperty(plugin, OFFICIAL, { value: true });
|
|
11
|
+
//#endregion
|
|
12
|
+
exports.markOfficial = markOfficial;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/server/official.ts
|
|
2
|
+
/**
|
|
3
|
+
* The brand of Hot Updater's own plugins, a registered symbol that
|
|
4
|
+
* `@hot-updater/server` reads to reserve the id "remoteConfig" for this plugin.
|
|
5
|
+
* Each official plugin package keeps this copy, so none imports another's
|
|
6
|
+
* internals for it.
|
|
7
|
+
*/
|
|
8
|
+
const OFFICIAL = Symbol.for("@hot-updater/server/official-plugin");
|
|
9
|
+
/** Marks a plugin one of Hot Updater's own factories made. */
|
|
10
|
+
const markOfficial = (plugin) => Object.defineProperty(plugin, OFFICIAL, { value: true });
|
|
11
|
+
//#endregion
|
|
12
|
+
export { markOfficial };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const require_template = require("./template.cjs");
|
|
2
|
+
const require_api = require("./api.cjs");
|
|
3
|
+
const require_wire = require("../shared/wire.cjs");
|
|
4
|
+
let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
|
|
5
|
+
//#region src/server/routes.ts
|
|
6
|
+
/** Seconds a client waits before it resends a request the busy database refused. */
|
|
7
|
+
const RETRY_AFTER_SECONDS = 5;
|
|
8
|
+
/** A shared cache keeps a device's values five seconds, as it keeps a Release Catalog. */
|
|
9
|
+
const FETCH_CACHE_CONTROL = "public, max-age=0, s-maxage=5";
|
|
10
|
+
const MAX_CONTEXT_LENGTH = 128;
|
|
11
|
+
var BadRequestError = class extends Error {};
|
|
12
|
+
const json = (body, status, headers = {}) => Response.json(body, {
|
|
13
|
+
headers: {
|
|
14
|
+
"cache-control": "private, no-store",
|
|
15
|
+
...headers
|
|
16
|
+
},
|
|
17
|
+
status
|
|
18
|
+
});
|
|
19
|
+
const run = async (operation) => {
|
|
20
|
+
try {
|
|
21
|
+
return await operation();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof require_template.RemoteConfigValidationError) return json({
|
|
24
|
+
error: error.message,
|
|
25
|
+
issues: error.issues
|
|
26
|
+
}, 400);
|
|
27
|
+
if (error instanceof BadRequestError || error instanceof require_api.RemoteConfigInputError || error instanceof _hot_updater_plugin_core.DatabaseCursorError) return json({ error: error.message }, 400);
|
|
28
|
+
if ((0, _hot_updater_plugin_core.isDatabaseBusyError)(error)) {
|
|
29
|
+
console.warn("[hot-updater] Remote Config answered 503: the database is busy.", error);
|
|
30
|
+
return json({ error: "Service unavailable" }, 503, { "retry-after": String(RETRY_AFTER_SECONDS) });
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const readContext = (request) => {
|
|
36
|
+
const query = new URL(request.url).searchParams;
|
|
37
|
+
const read = (field) => {
|
|
38
|
+
const value = query.get(require_wire.REMOTE_CONFIG_QUERY[field]);
|
|
39
|
+
if (value === null || value.length === 0) return null;
|
|
40
|
+
if (value.length > MAX_CONTEXT_LENGTH) throw new BadRequestError(`${field} is too long.`);
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
43
|
+
const platform = read("platform");
|
|
44
|
+
if (platform !== null && platform !== "ios" && platform !== "android") throw new BadRequestError("platform must be \"ios\" or \"android\".");
|
|
45
|
+
return {
|
|
46
|
+
platform,
|
|
47
|
+
appVersion: read("appVersion"),
|
|
48
|
+
channel: read("channel"),
|
|
49
|
+
cohort: read("cohort"),
|
|
50
|
+
fingerprintHash: read("fingerprintHash")
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
const bodyHash = async (body) => {
|
|
54
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
|
|
55
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
56
|
+
};
|
|
57
|
+
const readJsonBody = async (request) => {
|
|
58
|
+
const body = await request.json().catch(() => null);
|
|
59
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new BadRequestError("The request body must be a JSON object.");
|
|
60
|
+
return body;
|
|
61
|
+
};
|
|
62
|
+
const versionParam = (params) => {
|
|
63
|
+
const text = params.version ?? "";
|
|
64
|
+
if (!/^\d{1,15}$/u.test(text)) throw new BadRequestError("The version must be a positive integer.");
|
|
65
|
+
return Number(text);
|
|
66
|
+
};
|
|
67
|
+
const publishResponse = (result) => result.status === "published" ? json(result.version, 200) : json({
|
|
68
|
+
error: `Version ${result.currentVersion} was published since; reload the template and publish again.`,
|
|
69
|
+
currentVersion: result.currentVersion
|
|
70
|
+
}, 409);
|
|
71
|
+
/**
|
|
72
|
+
* Remote Config's routes: the devices' `GET /remote-config` on
|
|
73
|
+
* `handlers.client`, and template management on `handlers.admin`.
|
|
74
|
+
*/
|
|
75
|
+
const createRemoteConfigEndpoints = (api) => [
|
|
76
|
+
{
|
|
77
|
+
method: "GET",
|
|
78
|
+
path: `/${require_wire.REMOTE_CONFIG_PATH}`,
|
|
79
|
+
access: "client",
|
|
80
|
+
handler: (request) => run(async () => {
|
|
81
|
+
const body = JSON.stringify(await api.resolve(readContext(request)));
|
|
82
|
+
const etag = `"sha256:${await bodyHash(body)}"`;
|
|
83
|
+
const headers = {
|
|
84
|
+
"cache-control": FETCH_CACHE_CONTROL,
|
|
85
|
+
"content-type": "application/json",
|
|
86
|
+
etag,
|
|
87
|
+
vary: "Accept-Encoding"
|
|
88
|
+
};
|
|
89
|
+
if (request.headers.get("if-none-match") === etag) return new Response(null, {
|
|
90
|
+
headers,
|
|
91
|
+
status: 304
|
|
92
|
+
});
|
|
93
|
+
return new Response(body, {
|
|
94
|
+
headers,
|
|
95
|
+
status: 200
|
|
96
|
+
});
|
|
97
|
+
})
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
method: "GET",
|
|
101
|
+
path: `/${require_wire.REMOTE_CONFIG_PATH}/template`,
|
|
102
|
+
access: "admin",
|
|
103
|
+
handler: () => run(async () => json(await api.getActive(), 200))
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
method: "PUT",
|
|
107
|
+
path: `/${require_wire.REMOTE_CONFIG_PATH}/template`,
|
|
108
|
+
access: "admin",
|
|
109
|
+
handler: (request) => run(async () => {
|
|
110
|
+
const body = await readJsonBody(request);
|
|
111
|
+
return publishResponse(await api.publish({
|
|
112
|
+
template: body.template,
|
|
113
|
+
baseVersion: body.baseVersion,
|
|
114
|
+
...body.description === void 0 ? {} : { description: body.description }
|
|
115
|
+
}));
|
|
116
|
+
})
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
method: "GET",
|
|
120
|
+
path: `/${require_wire.REMOTE_CONFIG_PATH}/versions`,
|
|
121
|
+
access: "admin",
|
|
122
|
+
handler: (request) => run(async () => {
|
|
123
|
+
const query = new URL(request.url).searchParams;
|
|
124
|
+
const limit = query.get("limit");
|
|
125
|
+
const cursor = query.get("cursor");
|
|
126
|
+
return json(await api.listVersions({
|
|
127
|
+
...limit === null ? {} : { limit: Number(limit) },
|
|
128
|
+
...cursor === null ? {} : { cursor }
|
|
129
|
+
}), 200);
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
method: "GET",
|
|
134
|
+
path: `/${require_wire.REMOTE_CONFIG_PATH}/versions/:version`,
|
|
135
|
+
access: "admin",
|
|
136
|
+
handler: (_request, params) => run(async () => {
|
|
137
|
+
const version = await api.getVersion(versionParam(params));
|
|
138
|
+
return version === null ? json({ error: "Version not found" }, 404) : json(version, 200);
|
|
139
|
+
})
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
method: "POST",
|
|
143
|
+
path: `/${require_wire.REMOTE_CONFIG_PATH}/versions/:version/rollback`,
|
|
144
|
+
access: "admin",
|
|
145
|
+
handler: (request, params) => run(async () => {
|
|
146
|
+
const body = await readJsonBody(request);
|
|
147
|
+
const result = await api.rollback({
|
|
148
|
+
version: versionParam(params),
|
|
149
|
+
baseVersion: body.baseVersion,
|
|
150
|
+
...body.description === void 0 ? {} : { description: body.description }
|
|
151
|
+
});
|
|
152
|
+
return result.status === "not_found" ? json({ error: "Version not found" }, 404) : publishResponse(result);
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
];
|
|
156
|
+
//#endregion
|
|
157
|
+
exports.createRemoteConfigEndpoints = createRemoteConfigEndpoints;
|