Read the entrypoint configuration from data attributes

Instead of injecting configuration through inline <script> tags setting
window globals, server-rendered templates now carry the configuration
as data attributes on the entrypoint mount node, validated client-side
with a valibot schema over element.dataset. This removes all inline
scripts from the templates, which is friendlier to CSP, and gives the
frontend runtime-validated, typed configuration.

The DOM read and the wiring now happen in the entrypoints themselves:
main.tsx parses the mount node dataset, owns the QueryClient, and
injects the basepath and GraphQL endpoint into the router and request
layer instead of those being module-scope singletons.
This commit is contained in:
Quentin Gliech
2026-07-30 20:51:01 +02:00
parent 7abe147ba0
commit a1bfbb6cdd
9 changed files with 73 additions and 97 deletions
+2 -6
View File
@@ -1,4 +1,5 @@
<!--
Copyright 2025, 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2022-2024 The Matrix.org Foundation C.I.C.
@@ -14,15 +15,10 @@ Please see LICENSE files in the repository root for full details.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>matrix-authentication-service</title>
<script type="application/javascript">
window.APP_CONFIG = JSON.parse(
'{"root": "/account/", "graphqlEndpoint": "/graphql"}',
);
</script>
</head>
<body>
<div id="root"></div>
<div id="root" data-root="/account/" data-graphql-endpoint="/graphql"></div>
<script type="module" src="/src/entrypoints/main.tsx"></script>
</body>
</html>
-22
View File
@@ -1,22 +0,0 @@
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
type AppConfig = {
root: string;
graphqlEndpoint: string;
};
interface IWindow {
APP_CONFIG?: AppConfig;
}
const config: AppConfig = (typeof window !== "undefined" &&
(window as IWindow).APP_CONFIG) || {
root: "/",
graphqlEndpoint: "/graphql",
};
export default config;
+27 -5
View File
@@ -1,33 +1,55 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
import { QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { TooltipProvider } from "@vector-im/compound-web";
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import * as v from "valibot";
import ErrorBoundary from "../components/ErrorBoundary";
import LoadingScreen from "../components/LoadingScreen";
import { queryClient } from "../graphql";
import { setGraphqlEndpoint } from "../graphql";
import i18n, { setupI18n } from "../i18n";
import { router } from "../router";
import { makeRouter } from "../router";
import "./vendor.css";
import "./shared.css";
setupI18n();
createRoot(document.getElementById("root") as HTMLElement).render(
const configSchema = v.object({
root: v.optional(v.string(), "/"),
graphqlEndpoint: v.optional(v.string(), "/graphql"),
});
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
throwOnError: true,
},
},
});
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("#root element not found");
const config = v.parse(configSchema, rootElement.dataset);
setGraphqlEndpoint(config.graphqlEndpoint);
const router = makeRouter(config.root, queryClient);
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<TooltipProvider>
<Suspense fallback={<LoadingScreen />}>
<I18nextProvider i18n={i18n}>
<RouterProvider router={router} context={{ queryClient }} />
<RouterProvider router={router} />
</I18nextProvider>
</Suspense>
</TooltipProvider>
+8 -10
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
@@ -6,21 +7,18 @@
import { SwaggerUIBundle } from "swagger-ui-dist";
import "swagger-ui-dist/swagger-ui.css";
type ApiConfig = {
openapiUrl: string;
callbackUrl: string;
};
import * as v from "valibot";
interface IWindow {
API_CONFIG?: ApiConfig;
ui?: SwaggerUIBundle;
}
const config = typeof window !== "undefined" && (window as IWindow).API_CONFIG;
if (!config) {
throw new Error("API_CONFIG is not defined");
}
const el = document.getElementById("swagger-ui");
if (!el) throw new Error("swagger-ui element not found");
const config = v.parse(
v.object({ openapiUrl: v.string(), callbackUrl: v.string() }),
el.dataset,
);
(window as IWindow).ui = SwaggerUIBundle({
url: config.openapiUrl,
+19 -26
View File
@@ -1,26 +1,22 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
import { QueryClient } from "@tanstack/react-query";
import type { ExecutionResult } from "graphql";
import appConfig from "./config";
import type { TypedDocumentString } from "./gql/graphql";
let graphqlEndpoint: string;
if (import.meta.env.TEST && typeof window === "undefined") {
graphqlEndpoint = new URL(
appConfig.graphqlEndpoint,
"http:://localhost/",
).toString();
} else {
graphqlEndpoint = new URL(
appConfig.graphqlEndpoint,
window.location.toString(),
).toString();
}
let graphqlEndpoint = "/graphql";
/**
* Sets the GraphQL endpoint to use for requests.
* This is called during initialization, once config has been loaded.
*/
export const setGraphqlEndpoint = (endpoint: string): void => {
graphqlEndpoint = endpoint;
};
type RequestOptions<TData, TVariables> = {
query: TypedDocumentString<TData, TVariables>;
@@ -35,9 +31,14 @@ export const graphqlRequest = async <TData, TVariables>({
variables,
signal,
}: RequestOptions<TData, TVariables>): Promise<TData> => {
const endpoint =
import.meta.env.TEST && typeof window === "undefined"
? new URL(graphqlEndpoint, "http://localhost/").toString()
: new URL(graphqlEndpoint, window.location.toString()).toString();
let response: Response;
try {
response = await fetch(graphqlEndpoint, {
response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -49,14 +50,14 @@ export const graphqlRequest = async <TData, TVariables>({
signal,
});
} catch (cause) {
throw new Error(`GraphQL request to ${graphqlEndpoint} request failed`, {
throw new Error(`GraphQL request to ${endpoint} request failed`, {
cause,
});
}
if (!response.ok) {
throw new Error(
`GraphQL request to ${graphqlEndpoint} failed: ${response.status}`,
`GraphQL request to ${endpoint} failed: ${response.status}`,
);
}
@@ -66,16 +67,8 @@ export const graphqlRequest = async <TData, TVariables>({
}
if (!json.data) {
throw new Error(`GraphQL request to ${graphqlEndpoint} returned no data`);
throw new Error(`GraphQL request to ${endpoint} returned no data`);
}
return json.data;
};
export const queryClient = new QueryClient({
defaultOptions: {
mutations: {
throwOnError: true,
},
},
});
+12 -12
View File
@@ -1,28 +1,28 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
import type { QueryClient } from "@tanstack/react-query";
import { createRouter } from "@tanstack/react-router";
import LoadingScreen from "./components/LoadingScreen";
import config from "./config";
import { queryClient } from "./graphql";
import { routeTree } from "./routeTree.gen";
// Create a new router instance
export const router = createRouter({
routeTree,
scrollRestoration: true,
basepath: config.root,
defaultPendingComponent: LoadingScreen,
defaultPreload: "intent",
context: { queryClient },
});
export const makeRouter = (basepath: string, queryClient: QueryClient) =>
createRouter({
routeTree,
scrollRestoration: true,
basepath,
defaultPendingComponent: LoadingScreen,
defaultPreload: "intent",
context: { queryClient },
});
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
router: ReturnType<typeof makeRouter>;
}
}
+2 -8
View File
@@ -1,4 +1,5 @@
{#
Copyright 2025, 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
@@ -15,19 +16,12 @@ Please see LICENSE files in the repository root for full details.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ _("app.name") }}</title>
{% set config = {
'graphqlEndpoint': app_config.graphqlEndpoint,
'root': app_config.root,
} -%}
<script>
window.APP_CONFIG = JSON.parse("{{ config | tojson | add_slashes | safe }}");
</script>
{{ include_asset('src/entrypoints/main.tsx') | indent(4) | safe }}
{# Pre-load the locale data for the current language #}
{{ include_asset('locales/' ~ lang ~ '.json') | indent(4) | safe }}
</head>
<body>
<div id="root"></div>
<div id="root" data-root="{{ app_config.root }}" data-graphql-endpoint="{{ app_config.graphqlEndpoint }}"></div>
</body>
</html>
+2 -7
View File
@@ -1,4 +1,5 @@
{#
Copyright 2025, 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
@@ -12,16 +13,10 @@ Please see LICENSE files in the repository root for full details.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API documentation</title>
<script>
window.API_CONFIG = {
openapiUrl: "{{ openapi_url | add_slashes | safe }}",
callbackUrl: "{{ callback_url | add_slashes | safe }}",
};
</script>
{{ include_asset('src/entrypoints/swagger.ts') | indent(4) | safe }}
</head>
<body>
<div id="swagger-ui"></div>
<div id="swagger-ui" data-openapi-url="{{ openapi_url }}" data-callback-url="{{ callback_url }}"></div>
</body>
</html>
+1 -1
View File
@@ -41,7 +41,7 @@
},
"name": "matrix-authentication-service",
"@name": {
"context": "app.html:17:14-27, base.html:24:31-44",
"context": "app.html:18:14-27, base.html:24:31-44",
"description": "Name of the application"
},
"technical_description": "OpenID Connect discovery document: <a class=\"cpd-link\" data-kind=\"primary\" href=\"%(discovery_url)s\">%(discovery_url)s</a>",