Serve the SPA by the server

This commit is contained in:
Quentin Gliech
2022-11-18 13:09:10 +01:00
parent 5a806bf8de
commit 28a9d54072
18 changed files with 581 additions and 45 deletions

View File

@@ -1,6 +1,8 @@
target/
crates/*/target
crates/*/node_modules
frontend/node_modules
frontend/dist
docs/
.devcontainer/
.git/

26
Cargo.lock generated
View File

@@ -850,6 +850,15 @@ dependencies = [
"either",
]
[[package]]
name = "camino"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88ad0e1e3e88dd237a156ab9f571021b8a158caa0ae44b1968a241efb5144c1e"
dependencies = [
"serde",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -2504,6 +2513,7 @@ dependencies = [
"mas-listener",
"mas-policy",
"mas-router",
"mas-spa",
"mas-static-files",
"mas-storage",
"mas-tasks",
@@ -2523,6 +2533,7 @@ dependencies = [
"serde_yaml",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-appender",
"tracing-opentelemetry",
@@ -2830,6 +2841,21 @@ dependencies = [
"url",
]
[[package]]
name = "mas-spa"
version = "0.1.0"
dependencies = [
"camino",
"headers",
"http",
"serde",
"serde_json",
"thiserror",
"tokio",
"tower-http",
"tower-service",
]
[[package]]
name = "mas-static-files"
version = "0.1.0"

View File

@@ -17,9 +17,9 @@ ARG ZIG_VERSION=0.9.1
ARG NODEJS_VERSION=18
ARG OPA_VERSION=0.45.0
#######################################################
## Build stage that builds the static files/frontend ##
#######################################################
##############################################
## Build stage that builds the static files ##
##############################################
FROM --platform=${BUILDPLATFORM} docker.io/library/node:${NODEJS_VERSION}-${DEBIAN_VERSION_NAME}-slim AS static-files
@@ -34,6 +34,30 @@ RUN npm run build
# Change the timestamp of built files for better caching
RUN find public -type f -exec touch -t 197001010000.00 {} +
##########################################
## Build stage that builds the frontend ##
##########################################
FROM --platform=${BUILDPLATFORM} docker.io/library/node:${NODEJS_VERSION}-${DEBIAN_VERSION_NAME}-slim AS frontend
WORKDIR /app/frontend
COPY ./frontend/package.json ./frontend/package-lock.json /app/frontend/
RUN npm ci
COPY ./frontend/ /app/frontend/
RUN npm run build
# Move the built files
RUN \
mkdir -p /usr/local/share/mas-cli/frontend-assets && \
cp ./dist/manifest.json /usr/local/share/mas-cli/frontend-manifest.json && \
rm -f ./dist/index.html* ./dist/manifest.json* && \
cp ./dist/* /usr/local/share/mas-cli/frontend-assets/
# Change the timestamp of built files for better caching
RUN find /usr/local/share/mas-cli -exec touch -t 197001010000.00 {} +
##############################################
## Build stage that builds the OPA policies ##
##############################################
@@ -143,6 +167,7 @@ RUN mv target/$(/docker-arch-to-rust-target.sh "${TARGETPLATFORM}")/release/mas-
FROM --platform=${TARGETPLATFORM} gcr.io/distroless/cc-debian${DEBIAN_VERSION}:debug-nonroot AS debug
COPY --from=builder /usr/local/bin/mas-cli /usr/local/bin/mas-cli
COPY --from=frontend /usr/local/share/mas-cli /usr/local/share/mas-cli
WORKDIR /
ENTRYPOINT ["/usr/local/bin/mas-cli"]
@@ -152,5 +177,6 @@ ENTRYPOINT ["/usr/local/bin/mas-cli"]
FROM --platform=${TARGETPLATFORM} gcr.io/distroless/cc-debian${DEBIAN_VERSION}:nonroot
COPY --from=builder /usr/local/bin/mas-cli /usr/local/bin/mas-cli
COPY --from=frontend /usr/local/share/mas-cli /usr/local/share/mas-cli
WORKDIR /
ENTRYPOINT ["/usr/local/bin/mas-cli"]

View File

@@ -23,6 +23,7 @@ serde_json = "1.0.87"
serde_yaml = "0.9.14"
tokio = { version = "1.21.2", features = ["full"] }
tower = { version = "0.4.13", features = ["full"] }
tower-http = { version = "0.3.4", features = ["fs"] }
url = "2.3.1"
watchman_client = "0.8.0"
@@ -43,13 +44,14 @@ mas-config = { path = "../config" }
mas-email = { path = "../email" }
mas-handlers = { path = "../handlers", default-features = false }
mas-http = { path = "../http", default-features = false, features = ["axum", "client"] }
mas-listener = { path = "../listener" }
mas-policy = { path = "../policy" }
mas-router = { path = "../router" }
mas-spa = { path = "../spa" }
mas-static-files = { path = "../static-files" }
mas-storage = { path = "../storage" }
mas-tasks = { path = "../tasks" }
mas-templates = { path = "../templates" }
mas-listener = { path = "../listener" }
[dev-dependencies]
indoc = "1.0.7"
@@ -58,7 +60,7 @@ indoc = "1.0.7"
default = ["otlp", "jaeger", "zipkin", "prometheus", "webpki-roots", "policy-cache"]
# Features used in the Docker image
docker = ["otlp", "jaeger", "zipkin", "prometheus", "native-roots"]
docker = ["otlp", "jaeger", "zipkin", "prometheus", "native-roots", "mas-config/docker"]
# Enable wasmtime compilation cache
policy-cache = ["mas-policy/cache"]

View File

@@ -13,19 +13,24 @@
// limitations under the License.
use std::{
future::ready,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, ToSocketAddrs},
os::unix::net::UnixListener,
sync::Arc,
};
use anyhow::Context;
use axum::{body::HttpBody, Extension, Router};
use axum::{body::HttpBody, error_handling::HandleErrorLayer, Extension, Router};
use hyper::StatusCode;
use listenfd::ListenFd;
use mas_config::{HttpBindConfig, HttpResource, HttpTlsConfig, UnixOrTcp};
use mas_handlers::AppState;
use mas_listener::{unix_or_tcp::UnixOrTcpListener, ConnectionInfo};
use mas_router::Route;
use mas_spa::ViteManifestService;
use rustls::ServerConfig;
use tower::Layer;
use tower_http::services::ServeDir;
#[allow(clippy::trait_duplication_in_bounds)]
pub fn build_router<B>(state: &Arc<AppState>, resources: &[HttpResource]) -> Router<AppState, B>
@@ -70,6 +75,33 @@ where
format!("{connection:?}")
}),
),
mas_config::HttpResource::Spa { assets, manifest } => {
let error_layer =
HandleErrorLayer::new(|_e| ready(StatusCode::INTERNAL_SERVER_ERROR));
// TODO: split the assets service and the index service, and make those paths
// configurable
let assets_base = "/app-assets/";
let app_base = "/app/";
// TODO: make that config typed and configurable
let config = serde_json::json!({
"root": app_base,
});
let index_service = ViteManifestService::new(
manifest.clone().try_into().unwrap(),
assets_base.into(),
config,
);
let static_service = ServeDir::new(assets).append_index_html_on_directories(false);
router
.nest(app_base, error_layer.layer(index_service))
.nest(assets_base, error_layer.layer(static_service))
}
}
}

View File

@@ -40,6 +40,7 @@ mas-email = { path = "../email" }
[features]
native-roots = ["mas-email/native-roots"]
webpki-roots = ["mas-email/webpki-roots"]
docker = []
[[bin]]
name = "schema"

View File

@@ -259,6 +259,15 @@ pub enum Resource {
/// the upstream connection
#[serde(rename = "connection-info")]
ConnectionInfo,
/// Mount the single page app
Spa {
/// Path to the vite manifest
manifest: PathBuf,
/// Path to the assets to server
assets: PathBuf,
},
}
/// Configuration of a listener
@@ -309,6 +318,18 @@ impl Default for HttpConfig {
Resource::Compat,
Resource::GraphQL { playground: true },
Resource::Static { web_root: None },
#[cfg(not(feature = "docker"))]
Resource::Spa {
manifest: "./frontend/dist/manifest.json".into(),
assets: "./frontend/dist/".into(),
},
#[cfg(feature = "docker")]
Resource::Spa {
// This is where the frontend files are mounted in the docker image by
// default
manifest: "/usr/local/share/mas-cli/frontend-manifest.json".into(),
assets: "/usr/local/share/mas-cli/frontend-assets/".into(),
},
],
tls: None,
proxy_protocol: false,

20
crates/spa/Cargo.toml Normal file
View File

@@ -0,0 +1,20 @@
[package]
name = "mas-spa"
version = "0.1.0"
authors = ["Quentin Gliech <quenting@element.io>"]
edition = "2021"
license = "Apache-2.0"
[dependencies]
serde = { version = "1.0.147", features = ["derive"] }
serde_json = "1.0.87"
thiserror = "1.0.37"
camino = { version = "1.1.1", features = ["serde1"] }
headers = "0.3.2"
http = "0.2.8"
tower-service = "0.3.2"
tower-http = { version = "0.3.4", features = ["fs"] }
tokio = { version = "1.21.2", features = ["fs"] }
[[bin]]
name = "render"

View File

@@ -0,0 +1,32 @@
// Copyright 2022 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use camino::Utf8Path;
use mas_spa::ViteManifest;
fn main() {
let mut stdin = std::io::stdin();
let manifest: ViteManifest =
serde_json::from_reader(&mut stdin).expect("failed to read manifest from stdin");
let assets_base = Utf8Path::new("/assets/");
let config = serde_json::json!({
"root": "/app/",
});
let html = manifest
.render(assets_base, &config)
.expect("failed to render");
println!("{html}");
}

95
crates/spa/src/lib.rs Normal file
View File

@@ -0,0 +1,95 @@
// Copyright 2022 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#![forbid(unsafe_code)]
#![deny(
clippy::all,
clippy::str_to_string,
rustdoc::missing_crate_level_docs,
rustdoc::broken_intra_doc_links
)]
#![warn(clippy::pedantic)]
mod vite;
use std::{future::Future, pin::Pin};
use camino::Utf8PathBuf;
use headers::{ContentType, HeaderMapExt};
use http::Response;
use serde::Serialize;
use tower_service::Service;
pub use self::vite::Manifest as ViteManifest;
/// Service which renders an `index.html` based on the files in the manifest
#[derive(Debug, Clone)]
pub struct ViteManifestService<T> {
manifest: Utf8PathBuf,
assets_base: Utf8PathBuf,
config: T,
}
impl<T> ViteManifestService<T> {
#[must_use]
pub const fn new(manifest: Utf8PathBuf, assets_base: Utf8PathBuf, config: T) -> Self {
Self {
manifest,
assets_base,
config,
}
}
}
impl<T, R> Service<R> for ViteManifestService<T>
where
T: Clone + Serialize + Send + Sync + 'static,
{
type Error = std::io::Error;
type Response = Response<String>;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + Sync + 'static>>;
fn poll_ready(
&mut self,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn call(&mut self, _req: R) -> Self::Future {
let manifest = self.manifest.clone();
let assets_base = self.assets_base.clone();
let config = self.config.clone();
Box::pin(async move {
// Read the manifest from disk
let manifest = tokio::fs::read(manifest).await?;
// Parse it
let manifest: ViteManifest = serde_json::from_slice(&manifest)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?;
// Render the HTML out of the manifest
let html = manifest
.render(&assets_base, &config)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?;
let mut response = Response::new(html);
response.headers_mut().typed_insert(ContentType::html());
Ok(response)
})
}
}

218
crates/spa/src/vite.rs Normal file
View File

@@ -0,0 +1,218 @@
use std::collections::{BTreeSet, HashMap};
use camino::{Utf8Path, Utf8PathBuf};
use thiserror::Error;
#[derive(serde::Deserialize, Debug)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ManifestEntry {
#[allow(dead_code)]
src: Option<Utf8PathBuf>,
file: Utf8PathBuf,
css: Option<Vec<Utf8PathBuf>>,
#[allow(dead_code)]
assets: Option<Vec<Utf8PathBuf>>,
#[allow(dead_code)]
is_entry: Option<bool>,
#[allow(dead_code)]
is_dynamic_entry: Option<bool>,
#[allow(dead_code)]
imports: Option<Vec<Utf8PathBuf>>,
dynamic_imports: Option<Vec<Utf8PathBuf>>,
}
/// Render the HTML template
fn template(head: impl Iterator<Item = String>, config: &impl serde::Serialize) -> String {
// This should be kept in sync with `../../../frontend/index.html`
// Render the items to insert in the <head>
let head: String = head.map(|f| format!(" {f}\n")).collect();
// Serialize the config
let config = serde_json::to_string(config).expect("failed to serialize config");
// Script in the <head> which manages the dark mode class on the <html> element
let dark_mode_script = r#"
(function () {
const query = window.matchMedia("(prefers-color-scheme: dark)");
function handleChange(e) {
if (e.matches) {
document.documentElement.classList.add("dark")
} else {
document.documentElement.classList.remove("dark")
}
}
query.addListener(handleChange);
handleChange(query);
})();
"#;
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>matrix-authentication-service</title>
<script>window.APP_CONFIG = {config};</script>
<script>{dark_mode_script}</script>
{head}</head>
<body>
<div id="root"></div>
</body>
</html>"#
)
}
impl ManifestEntry {
/// Get a list of items to insert in the `<head>`
fn head<'a>(&'a self, assets_base: &'a Utf8Path) -> impl Iterator<Item = String> + 'a {
let css = self.css.iter().flat_map(|css| {
css.iter().map(|href| {
let href = assets_base.join(href);
format!(r#"<link rel="stylesheet" href="{href}" />"#)
})
});
let script = assets_base.join(&self.file);
let script = format!(r#"<script type="module" crossorigin src="{script}"></script>"#);
css.chain(std::iter::once(script))
}
}
#[derive(serde::Deserialize, Debug)]
pub struct Manifest {
#[serde(flatten)]
inner: HashMap<Utf8PathBuf, ManifestEntry>,
}
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
enum FileType {
Script,
Stylesheet,
}
impl FileType {
fn from_name(name: &Utf8Path) -> Option<Self> {
match name.extension() {
Some("css") => Some(Self::Stylesheet),
Some("js") => Some(Self::Script),
_ => None,
}
}
}
#[derive(Debug, Error)]
#[error("Invalid Vite manifest")]
pub enum InvalidManifest {
#[error("No index.html")]
NoIndex,
#[error("Can't find preloaded entry")]
CantFindPreload,
#[error("Invalid file type")]
InvalidFileType,
}
/// Represents an entry which should be preloaded
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
struct Preload<'name> {
name: &'name Utf8Path,
file_type: FileType,
}
impl<'a> Preload<'a> {
/// Generate a `<link>` tag for this entry
fn link(&self, assets_base: &Utf8Path) -> String {
let href = assets_base.join(self.name);
match self.file_type {
FileType::Stylesheet => {
format!(r#"<link rel="preload" href="{href}" as="style" />"#)
}
FileType::Script => format!(
r#"<link rel="preload" href="{href}" as="script" crossorigin="anonymous" />"#
),
}
}
}
impl Manifest {
/// Render an `index.html` page
///
/// # Errors
///
/// Returns an error if the manifest is invalid.
pub fn render(
&self,
assets_base: &Utf8Path,
config: &impl serde::Serialize,
) -> Result<String, InvalidManifest> {
let entrypoint = Utf8Path::new("index.html");
let entry = self.inner.get(entrypoint).ok_or(InvalidManifest::NoIndex)?;
// Find the items that should be pre-loaded
let preload = self.find_preload(entrypoint)?;
let head = preload
.iter()
.map(|p| p.link(assets_base))
.chain(entry.head(assets_base));
let html = template(head, config);
Ok(html)
}
/// Find entries to preload
fn find_preload<'a>(
&'a self,
entrypoint: &Utf8Path,
) -> Result<BTreeSet<Preload<'a>>, InvalidManifest> {
// TODO: we're preoading the whole tree. We should instead guess which component
// should be loaded based on the route.
let mut entries = BTreeSet::new();
self.find_preload_rec(entrypoint, &mut entries)?;
Ok(entries)
}
fn find_preload_rec<'a>(
&'a self,
entrypoint: &Utf8Path,
entries: &mut BTreeSet<Preload<'a>>,
) -> Result<(), InvalidManifest> {
let entry = self
.inner
.get(entrypoint)
.ok_or(InvalidManifest::CantFindPreload)?;
let name = &entry.file;
let file_type = FileType::from_name(name).ok_or(InvalidManifest::InvalidFileType)?;
let preload = Preload { name, file_type };
let inserted = entries.insert(preload);
if inserted {
if let Some(css) = &entry.css {
let file_type = FileType::Stylesheet;
for name in css {
let preload = Preload { name, file_type };
entries.insert(preload);
}
}
if let Some(dynamic_imports) = &entry.dynamic_imports {
for import in dynamic_imports {
self.find_preload_rec(import, entries)?;
}
}
}
Ok(())
}
}

View File

@@ -86,6 +86,11 @@
},
{
"name": "static"
},
{
"assets": "./frontend/dist/",
"manifest": "./frontend/dist/manifest.json",
"name": "spa"
}
]
},
@@ -1420,6 +1425,31 @@
]
}
}
},
{
"description": "Mount the single page app",
"type": "object",
"required": [
"assets",
"manifest",
"name"
],
"properties": {
"assets": {
"description": "Path to the assets to server",
"type": "string"
},
"manifest": {
"description": "Path to the vite manifest",
"type": "string"
},
"name": {
"type": "string",
"enum": [
"spa"
]
}
}
}
]
},

View File

@@ -24,17 +24,18 @@ limitations under the License.
<title>matrix-authentication-service</title>
<script>
window.APP_CONFIG = {root: "/app/"};
(function () {
const query = window.matchMedia("(prefers-color-scheme: dark)");
function handleChange(e) {
if (e.matches) {
document.documentElement.classList.add("dark")
function handleChange(list) {
if (list.matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark")
document.documentElement.classList.remove("dark");
}
}
query.addListener(handleChange);
query.addEventListener("change", handleChange);
handleChange(query);
})();
</script>

View File

@@ -8,7 +8,7 @@
"generate": "relay-compiler && eslint --fix .",
"lint": "relay-compiler --validate && eslint . && tsc",
"relay": "relay-compiler",
"build": "npm run lint && vite build",
"build": "npm run lint && vite build --base=./",
"preview": "vite preview",
"test": "jest",
"storybook": "storybook dev -p 6006",

View File

@@ -13,7 +13,7 @@
// limitations under the License.
import { lazy, Suspense } from "react";
import { createHashRouter, Outlet, RouterProvider } from "react-router-dom";
import { createBrowserRouter, Outlet, RouterProvider } from "react-router-dom";
import Layout from "./components/Layout";
import LoadingSpinner from "./components/LoadingSpinner";
@@ -22,36 +22,41 @@ const Home = lazy(() => import("./pages/Home"));
const OAuth2Client = lazy(() => import("./pages/OAuth2Client"));
const BrowserSession = lazy(() => import("./pages/BrowserSession"));
export const router = createHashRouter([
export const router = createBrowserRouter(
[
{
path: "/",
element: (
<Layout>
<Suspense fallback={<LoadingSpinner />}>
<Outlet />
</Suspense>
</Layout>
),
children: [
{
index: true,
element: <Home />,
},
{
path: "dumb",
element: <>Hello from another dumb page.</>,
},
{
path: "client/:id",
element: <OAuth2Client />,
},
{
path: "session/:id",
element: <BrowserSession />,
},
],
},
],
{
path: "/",
element: (
<Layout>
<Suspense fallback={<LoadingSpinner />}>
<Outlet />
</Suspense>
</Layout>
),
children: [
{
index: true,
element: <Home />,
},
{
path: "dumb",
element: <>Hello from another dumb page.</>,
},
{
path: "client/:id",
element: <OAuth2Client />,
},
{
path: "session/:id",
element: <BrowserSession />,
},
],
},
]);
basename: window.APP_CONFIG.root,
}
);
const Router = () => <RouterProvider router={router} />;

21
frontend/src/config.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
// Copyright 2022 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
type AppConfig = {
root: string;
};
interface Window {
APP_CONFIG: AppConfig;
}

View File

@@ -12,14 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import React, { lazy } from "react";
import React from "react";
import ReactDOM from "react-dom/client";
import { RelayEnvironmentProvider } from "react-relay";
import LoadingScreen from "./components/LoadingScreen";
import RelayEnvironment from "./RelayEnvironment";
const Router = lazy(() => import("./Router"));
import Router from "./Router";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>

View File

@@ -19,6 +19,11 @@ import relay from "vite-plugin-relay";
export default defineConfig({
base: "/app/",
build: {
manifest: true,
assetsDir: "",
sourcemap: true,
},
plugins: [
react(),
eslint({