@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.
- 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/admin.cjs +96 -0
- package/dist/server/admin.d.cts +21 -0
- package/dist/server/admin.d.mts +21 -0
- package/dist/server/admin.mjs +96 -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 +50 -0
- package/dist/server/index.d.cts +64 -0
- package/dist/server/index.d.mts +64 -0
- package/dist/server/index.mjs +36 -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,207 @@
|
|
|
1
|
+
import { ownEntry } from "../shared/ownEntry.mjs";
|
|
2
|
+
import { parseRemoteConfigFetchResponse, remoteConfigRequestPath } from "../shared/wire.mjs";
|
|
3
|
+
import { defineClientPlugin } from "@hot-updater/protocol";
|
|
4
|
+
//#region src/client/index.ts
|
|
5
|
+
const DEFAULT_MINIMUM_FETCH_INTERVAL_MS = 432e5;
|
|
6
|
+
const BOOLEAN_TRUTHY_VALUES = /* @__PURE__ */ new Set([
|
|
7
|
+
"1",
|
|
8
|
+
"true",
|
|
9
|
+
"t",
|
|
10
|
+
"yes",
|
|
11
|
+
"y",
|
|
12
|
+
"on"
|
|
13
|
+
]);
|
|
14
|
+
const FETCHED_KEY = "fetched";
|
|
15
|
+
const ACTIVE_KEY = "active";
|
|
16
|
+
const LAST_FETCH_STATUS_KEY = "lastFetchStatus";
|
|
17
|
+
const createValue = (text, source) => Object.freeze({
|
|
18
|
+
asString: () => text,
|
|
19
|
+
asNumber: () => {
|
|
20
|
+
const number = Number(text);
|
|
21
|
+
return Number.isNaN(number) ? 0 : number;
|
|
22
|
+
},
|
|
23
|
+
asBoolean: () => BOOLEAN_TRUTHY_VALUES.has(text.toLowerCase()),
|
|
24
|
+
getSource: () => source
|
|
25
|
+
});
|
|
26
|
+
const parseStored = (text, extra) => {
|
|
27
|
+
if (text === null) return null;
|
|
28
|
+
try {
|
|
29
|
+
const value = JSON.parse(text);
|
|
30
|
+
if (typeof value !== "object" || value === null) return null;
|
|
31
|
+
const record = value;
|
|
32
|
+
if (parseRemoteConfigFetchResponse(record) === null || record.etag !== null && typeof record.etag !== "string" || !extra(record)) return null;
|
|
33
|
+
return record;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const isFetchStatus = (value) => value === "success" || value === "failure";
|
|
39
|
+
const deviceContext = (context) => ({
|
|
40
|
+
platform: context.platform,
|
|
41
|
+
appVersion: context.appVersion,
|
|
42
|
+
channel: context.getChannel(),
|
|
43
|
+
cohort: context.getCohort(),
|
|
44
|
+
fingerprintHash: context.getFingerprintHash()
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* Remote Config for the app: in-app defaults, values a server running the
|
|
48
|
+
* `remoteConfig()` plugin picks for this device, and a fetch and activate
|
|
49
|
+
* step between them. Add it to `HotUpdater.init`'s `plugins`, and read it
|
|
50
|
+
* from the instance `init` returns, as `hotUpdater.remoteConfig`. It fetches
|
|
51
|
+
* from the `baseURL`, request headers, and timeout configured there.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* import { HotUpdater, remoteConfig } from "@hot-updater/react-native";
|
|
56
|
+
*
|
|
57
|
+
* export const hotUpdater = HotUpdater.init({
|
|
58
|
+
* baseURL,
|
|
59
|
+
* plugins: [
|
|
60
|
+
* remoteConfig({ defaults: { welcome_message: "Welcome", max_items: 20 } }),
|
|
61
|
+
* ],
|
|
62
|
+
* });
|
|
63
|
+
*
|
|
64
|
+
* hotUpdater.remoteConfig.fetchAndActivate().catch(() => {});
|
|
65
|
+
* hotUpdater.remoteConfig.getString("welcome_message");
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
const remoteConfig = (options = {}) => {
|
|
69
|
+
const minimumFetchIntervalMs = options.minimumFetchIntervalMs ?? DEFAULT_MINIMUM_FETCH_INTERVAL_MS;
|
|
70
|
+
if (typeof minimumFetchIntervalMs !== "number" || !Number.isFinite(minimumFetchIntervalMs) || minimumFetchIntervalMs < 0) throw new TypeError("[HotUpdater] remoteConfig({ minimumFetchIntervalMs }) must be a non-negative number.");
|
|
71
|
+
const defaults = Object.fromEntries(Object.entries(options.defaults ?? {}).map(([key, value]) => [key, String(value)]));
|
|
72
|
+
return defineClientPlugin({
|
|
73
|
+
id: "remoteConfig",
|
|
74
|
+
setup: (context) => ({ api: createRemoteConfigClient(context, {
|
|
75
|
+
defaults,
|
|
76
|
+
minimumFetchIntervalMs
|
|
77
|
+
}) })
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
/** The client a plugin's `setup` creates, over the values stored on the device. */
|
|
81
|
+
const createRemoteConfigClient = (context, { defaults, minimumFetchIntervalMs }) => {
|
|
82
|
+
let active = parseStored(context.storage.get(ACTIVE_KEY), () => true);
|
|
83
|
+
let fetched = parseStored(context.storage.get(FETCHED_KEY), (record) => typeof record.context === "string" && typeof record.fetchedAtMs === "number");
|
|
84
|
+
const storedStatus = context.storage.get(LAST_FETCH_STATUS_KEY);
|
|
85
|
+
let lastFetchStatus = isFetchStatus(storedStatus) ? storedStatus : fetched === null ? "no-fetch-yet" : "success";
|
|
86
|
+
let inFlight = null;
|
|
87
|
+
let snapshot = null;
|
|
88
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
89
|
+
const notify = () => {
|
|
90
|
+
snapshot = null;
|
|
91
|
+
for (const listener of listeners) try {
|
|
92
|
+
listener();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.warn("[HotUpdater] A remoteConfig listener threw.", error);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const store = (key, value) => {
|
|
98
|
+
try {
|
|
99
|
+
context.storage.set(key, value);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.warn(`[HotUpdater] remoteConfig could not store its ${key} values.`, error);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const setStatus = (status) => {
|
|
105
|
+
lastFetchStatus = status;
|
|
106
|
+
store(LAST_FETCH_STATUS_KEY, status);
|
|
107
|
+
};
|
|
108
|
+
const getValue = (key) => {
|
|
109
|
+
const remote = ownEntry(active?.values, key);
|
|
110
|
+
if (remote !== void 0) return createValue(remote, "remote");
|
|
111
|
+
const fallback = ownEntry(defaults, key);
|
|
112
|
+
return fallback === void 0 ? null : createValue(fallback, "default");
|
|
113
|
+
};
|
|
114
|
+
const fetchFromServer = async (device) => {
|
|
115
|
+
const contextKey = JSON.stringify(device);
|
|
116
|
+
let response;
|
|
117
|
+
try {
|
|
118
|
+
response = await context.fetch(remoteConfigRequestPath(device), { headers: fetched === null || fetched.etag === null ? {} : { "if-none-match": fetched.etag } });
|
|
119
|
+
} catch (error) {
|
|
120
|
+
setStatus("failure");
|
|
121
|
+
throw new Error("[HotUpdater] Remote Config could not be fetched.", { cause: error });
|
|
122
|
+
}
|
|
123
|
+
const fetchedAtMs = context.now();
|
|
124
|
+
if (response.status === 304 && fetched !== null) {
|
|
125
|
+
fetched = {
|
|
126
|
+
...fetched,
|
|
127
|
+
context: contextKey,
|
|
128
|
+
fetchedAtMs
|
|
129
|
+
};
|
|
130
|
+
store(FETCHED_KEY, JSON.stringify(fetched));
|
|
131
|
+
setStatus("success");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
setStatus("failure");
|
|
136
|
+
throw new Error(response.status === 404 ? "[HotUpdater] The server answered 404 to GET /remote-config: it runs without the remoteConfig() plugin." : `[HotUpdater] Remote Config fetch failed with HTTP ${response.status}.`);
|
|
137
|
+
}
|
|
138
|
+
const body = parseRemoteConfigFetchResponse(await response.json().catch(() => null));
|
|
139
|
+
if (body === null) {
|
|
140
|
+
setStatus("failure");
|
|
141
|
+
throw new Error("[HotUpdater] The server's GET /remote-config answer is not Remote Config values.");
|
|
142
|
+
}
|
|
143
|
+
fetched = {
|
|
144
|
+
version: body.version,
|
|
145
|
+
values: body.values,
|
|
146
|
+
etag: response.headers.get("etag"),
|
|
147
|
+
context: contextKey,
|
|
148
|
+
fetchedAtMs
|
|
149
|
+
};
|
|
150
|
+
store(FETCHED_KEY, JSON.stringify(fetched));
|
|
151
|
+
setStatus("success");
|
|
152
|
+
};
|
|
153
|
+
const fetchValues = (options) => {
|
|
154
|
+
const device = deviceContext(context);
|
|
155
|
+
if (options?.force !== true && inFlight === null && fetched !== null && fetched.context === JSON.stringify(device) && context.now() - fetched.fetchedAtMs < minimumFetchIntervalMs) return Promise.resolve();
|
|
156
|
+
inFlight ??= fetchFromServer(device).finally(() => {
|
|
157
|
+
inFlight = null;
|
|
158
|
+
});
|
|
159
|
+
return inFlight;
|
|
160
|
+
};
|
|
161
|
+
const activate = async () => {
|
|
162
|
+
if (fetched === null) return false;
|
|
163
|
+
const next = {
|
|
164
|
+
version: fetched.version,
|
|
165
|
+
values: fetched.values,
|
|
166
|
+
etag: fetched.etag
|
|
167
|
+
};
|
|
168
|
+
if (active !== null && active.version === next.version && JSON.stringify(active.values) === JSON.stringify(next.values)) return false;
|
|
169
|
+
active = next;
|
|
170
|
+
store(ACTIVE_KEY, JSON.stringify(active));
|
|
171
|
+
notify();
|
|
172
|
+
return true;
|
|
173
|
+
};
|
|
174
|
+
return Object.freeze({
|
|
175
|
+
getValue,
|
|
176
|
+
getString: (key) => getValue(key)?.asString() ?? null,
|
|
177
|
+
getNumber: (key) => getValue(key)?.asNumber() ?? null,
|
|
178
|
+
getBoolean: (key) => getValue(key)?.asBoolean() ?? null,
|
|
179
|
+
getAll: () => {
|
|
180
|
+
snapshot ??= Object.freeze(Object.fromEntries([.../* @__PURE__ */ new Set([...Object.keys(defaults), ...Object.keys(active?.values ?? {})])].map((key) => [key, getValue(key)])));
|
|
181
|
+
return snapshot;
|
|
182
|
+
},
|
|
183
|
+
fetch: (options) => fetchValues(options),
|
|
184
|
+
activate,
|
|
185
|
+
fetchAndActivate: async (options) => {
|
|
186
|
+
await fetchValues(options);
|
|
187
|
+
return activate();
|
|
188
|
+
},
|
|
189
|
+
get lastFetchStatus() {
|
|
190
|
+
return lastFetchStatus;
|
|
191
|
+
},
|
|
192
|
+
get fetchedAtMs() {
|
|
193
|
+
return fetched?.fetchedAtMs ?? null;
|
|
194
|
+
},
|
|
195
|
+
get activeVersion() {
|
|
196
|
+
return active?.version ?? 0;
|
|
197
|
+
},
|
|
198
|
+
subscribe: (listener) => {
|
|
199
|
+
listeners.add(listener);
|
|
200
|
+
return () => {
|
|
201
|
+
listeners.delete(listener);
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
//#endregion
|
|
207
|
+
export { remoteConfig };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const require_template = require("./template.cjs");
|
|
2
|
+
//#region src/server/admin.ts
|
|
3
|
+
const readBody = async (response) => response.json().catch(() => null);
|
|
4
|
+
const field = (body, name) => typeof body === "object" && body !== null ? Reflect.get(body, name) : void 0;
|
|
5
|
+
/** A published version's record, as a publish or a rollback answers it. */
|
|
6
|
+
const isPublishedVersion = (body) => {
|
|
7
|
+
const version = field(body, "version");
|
|
8
|
+
const updateType = field(body, "updateType");
|
|
9
|
+
return typeof version === "number" && Number.isSafeInteger(version) && version > 0 && (updateType === "PUBLISH" || updateType === "ROLLBACK") && typeof field(body, "createdAtMs") === "number";
|
|
10
|
+
};
|
|
11
|
+
const errorText = (body) => {
|
|
12
|
+
const error = field(body, "error");
|
|
13
|
+
return typeof error === "string" ? error : void 0;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Remote Config over a self-hosted server's admin routes, for tooling that
|
|
17
|
+
* reaches it through `standaloneRepository`. It answers as the API does: an
|
|
18
|
+
* invalid template throws `RemoteConfigValidationError` with the server's
|
|
19
|
+
* issues.
|
|
20
|
+
*/
|
|
21
|
+
const createRemoteConfigAdminApi = (fetchAdmin, { unavailable = () => /* @__PURE__ */ new Error("The server runs without remoteConfig(): its admin API serves no Remote Config routes.") } = {}) => {
|
|
22
|
+
const request = async (path, init) => {
|
|
23
|
+
const response = await fetchAdmin(path, init === void 0 ? void 0 : {
|
|
24
|
+
method: init.method,
|
|
25
|
+
headers: { "content-type": "application/json" },
|
|
26
|
+
body: JSON.stringify(init.body)
|
|
27
|
+
});
|
|
28
|
+
const body = await readBody(response);
|
|
29
|
+
if (response.status === 404 && errorText(body) === "Not found") throw unavailable();
|
|
30
|
+
return {
|
|
31
|
+
status: response.status,
|
|
32
|
+
body
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
const expectOk = (path, { status, body }) => {
|
|
36
|
+
if (status >= 200 && status < 300) return body;
|
|
37
|
+
throw new Error(errorText(body) ?? `The server answered ${path.split("?")[0]} with ${status}.`);
|
|
38
|
+
};
|
|
39
|
+
const published = (path, result) => {
|
|
40
|
+
const currentVersion = field(result.body, "currentVersion");
|
|
41
|
+
if (result.status === 409 && typeof currentVersion === "number" && Number.isSafeInteger(currentVersion)) return {
|
|
42
|
+
status: "conflict",
|
|
43
|
+
currentVersion
|
|
44
|
+
};
|
|
45
|
+
const issues = field(result.body, "issues");
|
|
46
|
+
if (result.status === 400 && Array.isArray(issues)) throw new require_template.RemoteConfigValidationError(issues);
|
|
47
|
+
const version = expectOk(path, result);
|
|
48
|
+
if (!isPublishedVersion(version)) throw new Error(`The server answered ${path} without the version it published.`);
|
|
49
|
+
return {
|
|
50
|
+
status: "published",
|
|
51
|
+
version
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
getActive: async () => {
|
|
56
|
+
const path = "/remote-config/template";
|
|
57
|
+
return expectOk(path, await request(path));
|
|
58
|
+
},
|
|
59
|
+
listVersions: async ({ limit, cursor } = {}) => {
|
|
60
|
+
const query = new URLSearchParams();
|
|
61
|
+
if (limit !== void 0) query.set("limit", String(limit));
|
|
62
|
+
if (cursor !== void 0) query.set("cursor", cursor);
|
|
63
|
+
const path = `/remote-config/versions${query.size === 0 ? "" : `?${query}`}`;
|
|
64
|
+
return expectOk(path, await request(path));
|
|
65
|
+
},
|
|
66
|
+
getVersion: async (version) => {
|
|
67
|
+
const path = `/remote-config/versions/${version}`;
|
|
68
|
+
const result = await request(path);
|
|
69
|
+
return result.status === 404 ? null : expectOk(path, result);
|
|
70
|
+
},
|
|
71
|
+
publish: async ({ template, baseVersion, description }) => {
|
|
72
|
+
const path = "/remote-config/template";
|
|
73
|
+
return published(path, await request(path, {
|
|
74
|
+
method: "PUT",
|
|
75
|
+
body: {
|
|
76
|
+
template,
|
|
77
|
+
baseVersion,
|
|
78
|
+
description
|
|
79
|
+
}
|
|
80
|
+
}));
|
|
81
|
+
},
|
|
82
|
+
rollback: async ({ version, baseVersion, description }) => {
|
|
83
|
+
const path = `/remote-config/versions/${version}/rollback`;
|
|
84
|
+
const result = await request(path, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: {
|
|
87
|
+
baseVersion,
|
|
88
|
+
description
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return result.status === 404 ? { status: "not_found" } : published(path, result);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
//#endregion
|
|
96
|
+
exports.createRemoteConfigAdminApi = createRemoteConfigAdminApi;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { RemoteConfigApi } from "./api.cjs";
|
|
2
|
+
import { RemoteDatabase } from "@hot-updater/plugin-core";
|
|
3
|
+
//#region src/server/admin.d.ts
|
|
4
|
+
/** What tooling edits templates with: `RemoteConfigApi` without the devices' `resolve`. */
|
|
5
|
+
type RemoteConfigAdminApi = Omit<RemoteConfigApi, "resolve">;
|
|
6
|
+
interface RemoteConfigAdminApiOptions {
|
|
7
|
+
/**
|
|
8
|
+
* The error for a server that runs without `remoteConfig()`, whose admin
|
|
9
|
+
* API answers its routes with core's 404.
|
|
10
|
+
*/
|
|
11
|
+
readonly unavailable?: () => Error;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Remote Config over a self-hosted server's admin routes, for tooling that
|
|
15
|
+
* reaches it through `standaloneRepository`. It answers as the API does: an
|
|
16
|
+
* invalid template throws `RemoteConfigValidationError` with the server's
|
|
17
|
+
* issues.
|
|
18
|
+
*/
|
|
19
|
+
declare const createRemoteConfigAdminApi: (fetchAdmin: RemoteDatabase["fetchAdmin"], { unavailable }?: RemoteConfigAdminApiOptions) => RemoteConfigAdminApi;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { RemoteConfigAdminApi, RemoteConfigAdminApiOptions, createRemoteConfigAdminApi };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { RemoteConfigApi } from "./api.mjs";
|
|
2
|
+
import { RemoteDatabase } from "@hot-updater/plugin-core";
|
|
3
|
+
//#region src/server/admin.d.ts
|
|
4
|
+
/** What tooling edits templates with: `RemoteConfigApi` without the devices' `resolve`. */
|
|
5
|
+
type RemoteConfigAdminApi = Omit<RemoteConfigApi, "resolve">;
|
|
6
|
+
interface RemoteConfigAdminApiOptions {
|
|
7
|
+
/**
|
|
8
|
+
* The error for a server that runs without `remoteConfig()`, whose admin
|
|
9
|
+
* API answers its routes with core's 404.
|
|
10
|
+
*/
|
|
11
|
+
readonly unavailable?: () => Error;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Remote Config over a self-hosted server's admin routes, for tooling that
|
|
15
|
+
* reaches it through `standaloneRepository`. It answers as the API does: an
|
|
16
|
+
* invalid template throws `RemoteConfigValidationError` with the server's
|
|
17
|
+
* issues.
|
|
18
|
+
*/
|
|
19
|
+
declare const createRemoteConfigAdminApi: (fetchAdmin: RemoteDatabase["fetchAdmin"], { unavailable }?: RemoteConfigAdminApiOptions) => RemoteConfigAdminApi;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { RemoteConfigAdminApi, RemoteConfigAdminApiOptions, createRemoteConfigAdminApi };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { RemoteConfigValidationError } from "./template.mjs";
|
|
2
|
+
//#region src/server/admin.ts
|
|
3
|
+
const readBody = async (response) => response.json().catch(() => null);
|
|
4
|
+
const field = (body, name) => typeof body === "object" && body !== null ? Reflect.get(body, name) : void 0;
|
|
5
|
+
/** A published version's record, as a publish or a rollback answers it. */
|
|
6
|
+
const isPublishedVersion = (body) => {
|
|
7
|
+
const version = field(body, "version");
|
|
8
|
+
const updateType = field(body, "updateType");
|
|
9
|
+
return typeof version === "number" && Number.isSafeInteger(version) && version > 0 && (updateType === "PUBLISH" || updateType === "ROLLBACK") && typeof field(body, "createdAtMs") === "number";
|
|
10
|
+
};
|
|
11
|
+
const errorText = (body) => {
|
|
12
|
+
const error = field(body, "error");
|
|
13
|
+
return typeof error === "string" ? error : void 0;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Remote Config over a self-hosted server's admin routes, for tooling that
|
|
17
|
+
* reaches it through `standaloneRepository`. It answers as the API does: an
|
|
18
|
+
* invalid template throws `RemoteConfigValidationError` with the server's
|
|
19
|
+
* issues.
|
|
20
|
+
*/
|
|
21
|
+
const createRemoteConfigAdminApi = (fetchAdmin, { unavailable = () => /* @__PURE__ */ new Error("The server runs without remoteConfig(): its admin API serves no Remote Config routes.") } = {}) => {
|
|
22
|
+
const request = async (path, init) => {
|
|
23
|
+
const response = await fetchAdmin(path, init === void 0 ? void 0 : {
|
|
24
|
+
method: init.method,
|
|
25
|
+
headers: { "content-type": "application/json" },
|
|
26
|
+
body: JSON.stringify(init.body)
|
|
27
|
+
});
|
|
28
|
+
const body = await readBody(response);
|
|
29
|
+
if (response.status === 404 && errorText(body) === "Not found") throw unavailable();
|
|
30
|
+
return {
|
|
31
|
+
status: response.status,
|
|
32
|
+
body
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
const expectOk = (path, { status, body }) => {
|
|
36
|
+
if (status >= 200 && status < 300) return body;
|
|
37
|
+
throw new Error(errorText(body) ?? `The server answered ${path.split("?")[0]} with ${status}.`);
|
|
38
|
+
};
|
|
39
|
+
const published = (path, result) => {
|
|
40
|
+
const currentVersion = field(result.body, "currentVersion");
|
|
41
|
+
if (result.status === 409 && typeof currentVersion === "number" && Number.isSafeInteger(currentVersion)) return {
|
|
42
|
+
status: "conflict",
|
|
43
|
+
currentVersion
|
|
44
|
+
};
|
|
45
|
+
const issues = field(result.body, "issues");
|
|
46
|
+
if (result.status === 400 && Array.isArray(issues)) throw new RemoteConfigValidationError(issues);
|
|
47
|
+
const version = expectOk(path, result);
|
|
48
|
+
if (!isPublishedVersion(version)) throw new Error(`The server answered ${path} without the version it published.`);
|
|
49
|
+
return {
|
|
50
|
+
status: "published",
|
|
51
|
+
version
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
getActive: async () => {
|
|
56
|
+
const path = "/remote-config/template";
|
|
57
|
+
return expectOk(path, await request(path));
|
|
58
|
+
},
|
|
59
|
+
listVersions: async ({ limit, cursor } = {}) => {
|
|
60
|
+
const query = new URLSearchParams();
|
|
61
|
+
if (limit !== void 0) query.set("limit", String(limit));
|
|
62
|
+
if (cursor !== void 0) query.set("cursor", cursor);
|
|
63
|
+
const path = `/remote-config/versions${query.size === 0 ? "" : `?${query}`}`;
|
|
64
|
+
return expectOk(path, await request(path));
|
|
65
|
+
},
|
|
66
|
+
getVersion: async (version) => {
|
|
67
|
+
const path = `/remote-config/versions/${version}`;
|
|
68
|
+
const result = await request(path);
|
|
69
|
+
return result.status === 404 ? null : expectOk(path, result);
|
|
70
|
+
},
|
|
71
|
+
publish: async ({ template, baseVersion, description }) => {
|
|
72
|
+
const path = "/remote-config/template";
|
|
73
|
+
return published(path, await request(path, {
|
|
74
|
+
method: "PUT",
|
|
75
|
+
body: {
|
|
76
|
+
template,
|
|
77
|
+
baseVersion,
|
|
78
|
+
description
|
|
79
|
+
}
|
|
80
|
+
}));
|
|
81
|
+
},
|
|
82
|
+
rollback: async ({ version, baseVersion, description }) => {
|
|
83
|
+
const path = `/remote-config/versions/${version}/rollback`;
|
|
84
|
+
const result = await request(path, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: {
|
|
87
|
+
baseVersion,
|
|
88
|
+
description
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return result.status === 404 ? { status: "not_found" } : published(path, result);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
//#endregion
|
|
96
|
+
export { createRemoteConfigAdminApi };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const require_template = require("./template.cjs");
|
|
2
|
+
let _hot_updater_plugin_core = require("@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: require_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 _hot_updater_plugin_core.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: require_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: require_template.resolveRemoteConfigValues(active.template, {
|
|
166
|
+
...context,
|
|
167
|
+
now: context.now ?? now()
|
|
168
|
+
})
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
//#endregion
|
|
174
|
+
exports.RemoteConfigInputError = RemoteConfigInputError;
|
|
175
|
+
exports.createRemoteConfigApi = createRemoteConfigApi;
|