Allow the config to be supplied by an embedder

Config.init() derives the location of config.json from window.location,
which only makes sense while Element Call owns the page. When it is
embedded in a host application the host owns the configuration, so add
Config.initWith() to accept it directly.

Share the defaulting and validation between both paths via resolveConfig(),
so that an injected config behaves identically to a fetched one, and mark
initialization as complete so that the init() calls already on the startup
path resolve immediately instead of fetching over the top of it.

initDefault() becomes initWith({}), which also stops it handing out a
shallow copy of DEFAULT_CONFIG whose nested objects were shared with the
module-level default.
This commit is contained in:
Valere
2026-09-01 18:40:53 +02:00
parent 7485b3d71f
commit 0a8c24bca8
2 changed files with 93 additions and 9 deletions
+51 -2
View File
@@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, it, vi, afterEach } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { validateConfig } from "./Config";
import { MatrixRTCMode } from "./ConfigOptions";
import { Config, validateConfig } from "./Config";
import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions";
describe("validateConfig", () => {
afterEach(() => {
@@ -52,3 +52,52 @@ describe("validateConfig", () => {
expect(result.ssla).toBe("https://example.invalid/ssla");
});
});
describe("Config.initWith", () => {
// vitest.setup.ts has already called initDefault(), so every test here is
// free to re-initialize; the last call wins.
afterEach(() => {
vi.restoreAllMocks();
Config.initDefault();
});
it("makes the supplied config readable", () => {
Config.initWith({ ssla: "https://example.invalid/ssla" });
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
});
it("fills in defaults for keys the embedder did not supply", () => {
Config.initWith({ ssla: "https://example.invalid/ssla" });
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
});
it("validates the supplied config just as a fetched one would be", () => {
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
Config.initWith({
matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode,
});
expect(Config.get().matrix_rtc_mode).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it("does not share nested state with DEFAULT_CONFIG", () => {
Config.initWith({});
expect(Config.get().media_quality).not.toBe(DEFAULT_CONFIG.media_quality);
});
it("stops a later init() from fetching over the top of it", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
Config.initWith({ ssla: "https://example.invalid/ssla" });
await Config.init();
expect(fetchSpy).not.toHaveBeenCalled();
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
});
it("replaces a config initialized earlier", () => {
Config.initWith({ ssla: "https://first.invalid/ssla" });
Config.initWith({ ssla: "https://second.invalid/ssla" });
expect(Config.get().ssla).toBe("https://second.invalid/ssla");
});
});
+42 -7
View File
@@ -30,6 +30,14 @@ export class Config {
return this.internalInstance.config;
}
/**
* Initializes the config by fetching `config.json`, locating it relative to
* the current page.
*
* Does nothing if the config has already been initialized, including by
* {@link Config.initWith}, so that the regular startup path can run unchanged
* when an embedder has already supplied the config.
*/
public static async init(): Promise<void> {
if (!Config.internalInstance?.initPromise) {
const internalInstance = new Config();
@@ -50,17 +58,36 @@ export class Config {
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
(config) => {
internalInstance.config = merge(
{},
DEFAULT_CONFIG,
validateConfig(config),
);
internalInstance.config = resolveConfig(config);
},
);
}
return Config.internalInstance.initPromise;
}
/**
* Initializes the config from an object supplied by the embedder, instead of
* fetching `config.json`.
*
* {@link Config.init} derives the location of `config.json` from
* `window.location`, which only makes sense while Element Call owns the page.
* When it is embedded in a host application the host owns the configuration
* and passes it in here.
*
* The config goes through the same validation and defaulting as a fetched
* one, so that an injected config behaves identically to a hosted one.
*
* Replaces any config initialized earlier.
*/
public static initWith(config: ConfigOptions): void {
const internalInstance = new Config();
internalInstance.config = resolveConfig(config);
// Mark initialization as already done, so that a later init() resolves
// immediately rather than fetching config.json over the top of this.
internalInstance.initPromise = Promise.resolve();
Config.internalInstance = internalInstance;
}
/**
* This is a alternative initializer that does not load anything
* from a hosted config file but instead just initializes the config using the
@@ -69,8 +96,7 @@ export class Config {
* It is supposed to only be used in tests. (It is executed in `vite.setup.js`)
*/
public static initDefault(): void {
Config.internalInstance = new Config();
Config.internalInstance.config = { ...DEFAULT_CONFIG };
Config.initWith({});
}
// Convenience accessors
@@ -94,6 +120,15 @@ export class Config {
private initPromise?: Promise<void>;
}
/**
* Applies validation and the built-in defaults to a config, however it was
* obtained. Deep-merges onto a fresh object so that the result never shares
* nested state with {@link DEFAULT_CONFIG}.
*/
function resolveConfig(config: ConfigOptions): ResolvedConfigOptions {
return merge({}, DEFAULT_CONFIG, validateConfig(config));
}
export function validateConfig(config: ConfigOptions): ConfigOptions {
const mode = config.matrix_rtc_mode;
if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) {