diff --git a/Taskfile.yml b/Taskfile.yml index 6b2c1863..e77de2b7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -60,6 +60,12 @@ tasks: - task: client:check - task: themes:check + fix:ts: + cmds: + - task: docs:fix + - task: client:fix + - task: themes:fix + check:rs: deps: [base:grammar] cmds: diff --git a/compiler-core/src/plugin/builtin/export_livesplit.rs b/compiler-core/src/plugin/builtin/export_livesplit.rs index c8c2c375..6d1f2675 100644 --- a/compiler-core/src/plugin/builtin/export_livesplit.rs +++ b/compiler-core/src/plugin/builtin/export_livesplit.rs @@ -81,22 +81,26 @@ impl PluginRuntime for ExportLiveSplitPlugin { let mut segments_xml = String::new(); for section in &doc.route { - let length = section.lines.len(); - for (i, line) in section.lines.iter().enumerate() { + let mut split_lines = vec![]; + for line in §ion.lines { if should_split_on(line, &split_types) { - let mut name = match &line.split_name { - Some(name) => name.to_string(), - None => line.text.to_string(), - }; - if subsplit { - if i == length - 1 { - name = format!("{{{}}}{name}", section.name); - } else { - name = format!("-{name}"); - } + split_lines.push(line); + } + } + let length = split_lines.len(); + for (i, line) in split_lines.iter().enumerate() { + let mut name = match &line.split_name { + Some(name) => name.to_string(), + None => line.text.to_string(), + }; + if subsplit { + if i == length - 1 { + name = format!("{{{}}}{name}", section.name); + } else { + name = format!("-{name}"); } - append_segment(&mut segments_xml, &name, line); } + append_segment(&mut segments_xml, &name, line); } } diff --git a/compiler-wasm/src/compiler/export.rs b/compiler-wasm/src/compiler/export.rs index 53037ef0..3a2fe00c 100644 --- a/compiler-wasm/src/compiler/export.rs +++ b/compiler-wasm/src/compiler/export.rs @@ -1,6 +1,5 @@ use instant::Instant; use log::{error, info}; -use wasm_bindgen::prelude::*; use celerc::pack::PackError; use celerc::{Compiler, ExpoDoc, ExportRequest, PluginOptions, PreparedContext}; @@ -14,14 +13,14 @@ pub async fn export_document( entry_path: Option, use_cache: bool, req: ExportRequest, -) -> Result { +) -> ExpoDoc { info!("exporting document"); let plugin_options = match plugin::get_plugin_options() { Ok(x) => x, Err(message) => { let message = format!("Failed to load user plugin options: {message}"); error!("{message}"); - return Ok(ExpoDoc::Error(message)); + return ExpoDoc::Error(message); } }; @@ -39,7 +38,7 @@ pub async fn export_document( let prep_ctx = match super::new_context(entry_path).await { Ok(x) => x, Err(e) => { - return Ok(ExpoDoc::Error(e.to_string())); + return ExpoDoc::Error(e.to_string()); } }; let guard = CachedContextGuard::new(prep_ctx); @@ -51,7 +50,7 @@ async fn export_in_context( start_time: Option, plugin_options: Option, req: ExportRequest, -) -> Result { +) -> ExpoDoc { let mut comp_ctx = prep_ctx.new_compilation(start_time).await; match comp_ctx.configure_plugins(plugin_options).await { Err(e) => export_with_pack_error(e), @@ -62,18 +61,15 @@ async fn export_in_context( } } -fn export_with_pack_error(error: PackError) -> Result { - Ok(ExpoDoc::Error(error.to_string())) +fn export_with_pack_error(error: PackError) -> ExpoDoc { + ExpoDoc::Error(error.to_string()) } -async fn export_with_compiler( - compiler: Compiler<'_>, - req: ExportRequest, -) -> Result { +async fn export_with_compiler(compiler: Compiler<'_>, req: ExportRequest) -> ExpoDoc { let mut comp_doc = compiler.compile().await; if let Some(expo_doc) = comp_doc.run_exporter(&req) { - return Ok(expo_doc); + return expo_doc; } let exec_ctx = comp_doc.execute().await; - Ok(exec_ctx.run_exporter(req)) + exec_ctx.run_exporter(req) } diff --git a/compiler-wasm/src/lib.rs b/compiler-wasm/src/lib.rs index 2407d352..8fea0680 100644 --- a/compiler-wasm/src/lib.rs +++ b/compiler-wasm/src/lib.rs @@ -65,7 +65,7 @@ pub async fn export_document( use_cache: bool, req: ExportRequest, ) -> Result { - compiler::export_document(entry_path, use_cache, req).await + Ok(compiler::export_document(entry_path, use_cache, req).await) } /// Set user plugin options diff --git a/docs/src/api/server.md b/docs/src/api/server.md index b0b73e74..b3166f94 100644 --- a/docs/src/api/server.md +++ b/docs/src/api/server.md @@ -57,3 +57,29 @@ Otherwise, it will return "data": "error message here" } ``` + +## `GET /export/{owner}/{repo}/{ref}[/{path}]` +Export the document +### Parameters +Same as the `/compile` endpoint. + +### Headers +|Name|Description| +|-|-| +|`Celer-Export-Request`|(Required) Base64 encoded JSON ExportRequest object| +|`Celer-Plugin-Options`|(Optional) Base64 encoded JSON PluginOptionsRaw object used to specify extra plugin options| + +### Returns +It should always return status `200 OK`. + +Returns an ExpoDoc, which could be success or error +```json +{ + "success": { ... } +} +``` +```json +{ + "error": "message here", +} +``` diff --git a/server/src/api/compile.rs b/server/src/api/compile.rs index adc9ac6a..1d4e61ef 100644 --- a/server/src/api/compile.rs +++ b/server/src/api/compile.rs @@ -4,16 +4,16 @@ use axum::extract::Path; use axum::http::HeaderMap; use axum::routing; use axum::{Json, Router}; -use base64::Engine; use instant::Instant; use serde::{Deserialize, Serialize}; use serde_json::Value; use tower::ServiceBuilder; use tower_http::compression::CompressionLayer; -use tracing::error; use crate::compiler; +use super::header; + pub fn init_api() -> Router { Router::new() .route( @@ -38,11 +38,11 @@ async fn compile_owner_repo_ref( Path((owner, repo, reference)): Path<(String, String, String)>, headers: HeaderMap, ) -> Json { - let plugin_options = match get_plugin_options_from_headers(&headers) { + let plugin_options = match header::get_plugin_options(&headers) { Ok(v) => v, Err(e) => return Json(CompileResponse::Failure(e)), }; - let response = compile_internal(&owner, &repo, None, &reference, plugin_options).await; + let response = compile_internal(&owner, &repo, None, &reference, &plugin_options).await; Json(response) } @@ -50,55 +50,20 @@ async fn compile_owner_repo_ref_path( Path((owner, repo, reference, path)): Path<(String, String, String, String)>, headers: HeaderMap, ) -> Json { - let plugin_options = match get_plugin_options_from_headers(&headers) { + let plugin_options = match header::get_plugin_options(&headers) { Ok(v) => v, Err(e) => return Json(CompileResponse::Failure(e)), }; - let response = compile_internal(&owner, &repo, Some(&path), &reference, plugin_options).await; + let response = compile_internal(&owner, &repo, Some(&path), &reference, &plugin_options).await; Json(response) } -fn get_plugin_options_from_headers(headers: &HeaderMap) -> Result, String> { - let header_value = match headers.get("Celer-Plugin-Options") { - None => return Ok(None), - Some(v) => v, - }; - let header_value = match header_value.to_str() { - Ok(s) => s, - Err(e) => { - error!("Invalid Celer-Plugin-Options header: {e}"); - return Err("Invalid Celer-Plugin-Options header".to_string()); - } - }; - if header_value.is_empty() { - return Ok(None); - } - - let header_decoded = match base64::engine::general_purpose::STANDARD.decode(header_value) { - Ok(v) => v, - Err(e) => { - error!("Failed to decode Celer-Plugin-Options header: {e}"); - return Err("Invalid Celer-Plugin-Options header".to_string()); - } - }; - - let header_str = match String::from_utf8(header_decoded) { - Ok(s) => s, - Err(e) => { - error!("Celer-Plugin-Options header is not valid UTF-8: {e}"); - return Err("Invalid Celer-Plugin-Options header".to_string()); - } - }; - - Ok(Some(header_str)) -} - async fn compile_internal( owner: &str, repo: &str, path: Option<&str>, reference: &str, - plugin_options_json: Option, + plugin_options_json: &str, ) -> CompileResponse { let start_time = Instant::now(); let prep_ctx = match compiler::get_context(owner, repo, path, reference).await { @@ -106,12 +71,13 @@ async fn compile_internal( Err(e) => return CompileResponse::Failure(e.to_string()), }; - let plugin_options = match plugin_options_json { - None => None, - Some(s) => match compiler::parse_plugin_options(&s, &prep_ctx.project_res).await { + let plugin_options = if plugin_options_json.is_empty() { + None + } else { + match compiler::parse_plugin_options(plugin_options_json, &prep_ctx.project_res).await { Ok(options) => Some(options), Err(e) => return CompileResponse::Failure(e), - }, + } }; let expo_ctx = compiler::compile(&prep_ctx, Some(start_time), plugin_options).await; @@ -119,5 +85,6 @@ async fn compile_internal( Ok(v) => v, Err(e) => return CompileResponse::Failure(e.to_string()), }; + CompileResponse::Success(expo_ctx_json) } diff --git a/server/src/api/export.rs b/server/src/api/export.rs new file mode 100644 index 00000000..5ac3ba0d --- /dev/null +++ b/server/src/api/export.rs @@ -0,0 +1,83 @@ +use axum::extract::Path; +use axum::http::HeaderMap; +use axum::routing; +use axum::{Json, Router}; +use celerc::{ExpoDoc, ExportRequest}; +use instant::Instant; +use tower::ServiceBuilder; +use tower_http::compression::CompressionLayer; + +use crate::compiler; + +use super::header; + +pub fn init_api() -> Router { + Router::new() + .route( + "/:owner/:repo/:reference", + routing::get(export_owner_repo_ref), + ) + .route( + "/:owner/:repo/:reference/*path", + routing::get(export_owner_repo_ref_path), + ) + .layer(ServiceBuilder::new().layer(CompressionLayer::new())) +} + +async fn export_owner_repo_ref( + Path((owner, repo, reference)): Path<(String, String, String)>, + headers: HeaderMap, +) -> Json { + let plugin_options = match header::get_plugin_options(&headers) { + Ok(v) => v, + Err(e) => return Json(ExpoDoc::Error(e)), + }; + let req = match header::get_export_request(&headers) { + Ok(v) => v, + Err(e) => return Json(ExpoDoc::Error(e)), + }; + let response = export_internal(&owner, &repo, None, &reference, &plugin_options, req).await; + Json(response) +} + +async fn export_owner_repo_ref_path( + Path((owner, repo, reference, path)): Path<(String, String, String, String)>, + headers: HeaderMap, +) -> Json { + let plugin_options = match header::get_plugin_options(&headers) { + Ok(v) => v, + Err(e) => return Json(ExpoDoc::Error(e)), + }; + let req = match header::get_export_request(&headers) { + Ok(v) => v, + Err(e) => return Json(ExpoDoc::Error(e)), + }; + let response = + export_internal(&owner, &repo, Some(&path), &reference, &plugin_options, req).await; + Json(response) +} +async fn export_internal( + owner: &str, + repo: &str, + path: Option<&str>, + reference: &str, + plugin_options_json: &str, + req: ExportRequest, +) -> ExpoDoc { + let start_time = Instant::now(); + let prep_ctx = match compiler::get_context(owner, repo, path, reference).await { + Ok(ctx) => ctx, + Err(e) => return ExpoDoc::Error(e.to_string()), + }; + + let plugin_options = if plugin_options_json.is_empty() { + None + } else { + match compiler::parse_plugin_options(plugin_options_json, &prep_ctx.project_res).await { + Ok(options) => Some(options), + Err(e) => return ExpoDoc::Error(e), + } + }; + + compiler::export(&prep_ctx, Some(start_time), plugin_options, req).await +} diff --git a/server/src/api/header.rs b/server/src/api/header.rs new file mode 100644 index 00000000..2e814a5f --- /dev/null +++ b/server/src/api/header.rs @@ -0,0 +1,70 @@ +//! Utilities for parsing headers + +use axum::http::{HeaderMap, HeaderValue}; +use base64::Engine; +use tracing::error; + +use celerc::ExportRequest; + +/// Get the Celer-Plugin-Options header as a string +/// Returns empty string if header is not present or empty +pub fn get_plugin_options(headers: &HeaderMap) -> Result { + let header_value = match headers.get("Celer-Plugin-Options") { + None => return Ok(String::new()), + Some(v) => v, + }; + + let header_str = decode_base64_header_utf8(header_value)?; + + Ok(header_str) +} + +pub fn get_export_request(headers: &HeaderMap) -> Result { + let header_value = match headers.get("Celer-Export-Request") { + None => { + error!("Missing required header"); + return Err("Missing required header".to_string()); + } + Some(v) => v, + }; + + let header_str = decode_base64_header_utf8(header_value)?; + let req: ExportRequest = match serde_json::from_str(&header_str) { + Ok(v) => v, + Err(e) => { + error!("Failed to parse header value as JSON: {e}"); + return Err("Invalid header value".to_string()); + } + }; + + Ok(req) +} + +/// Decode a base64 encoded header value as UTF-8. Returns error message if decoding fails. +fn decode_base64_header_utf8(header_value: &HeaderValue) -> Result { + let value = match header_value.to_str() { + Ok(s) => s, + Err(e) => { + error!("Raw header value is not valid UTF-8: {e}"); + return Err("Invalid header encoding".to_string()); + } + }; + if value.is_empty() { + return Ok(String::new()); + } + let decoded = match base64::engine::general_purpose::STANDARD.decode(value) { + Ok(v) => v, + Err(e) => { + error!("Failed to decode header value from base64: {e}"); + return Err("Invalid header encoding".to_string()); + } + }; + let header_str = match String::from_utf8(decoded) { + Ok(s) => s, + Err(e) => { + error!("header value is not valid UTF-8: {e}"); + return Err("Invalid header encoding".to_string()); + } + }; + Ok(header_str) +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 0781f3cc..8a9fdf32 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -6,6 +6,8 @@ use tracing::info; use crate::env; mod compile; +mod export; +mod header; mod view; pub fn init_api(router: Router, app_dir: &str) -> Result { @@ -20,7 +22,8 @@ pub fn init_api(router: Router, app_dir: &str) -> Result { pub fn init_api_v1() -> Result { let router = Router::new() .route("/version", routing::get(|| async { env::version() })) - .nest("/compile", compile::init_api()); + .nest("/compile", compile::init_api()) + .nest("/export", export::init_api()); Ok(router) } diff --git a/server/src/compiler/export.rs b/server/src/compiler/export.rs new file mode 100644 index 00000000..efc17929 --- /dev/null +++ b/server/src/compiler/export.rs @@ -0,0 +1,35 @@ +use instant::Instant; + +use celerc::pack::PackError; +use celerc::{Compiler, ExpoDoc, ExportRequest, PluginOptions, PreparedContext}; + +use super::ServerResourceLoader; + +pub async fn export( + prep_ctx: &PreparedContext, + start_time: Option, + plugin_options: Option, + req: ExportRequest, +) -> ExpoDoc { + let mut comp_ctx = prep_ctx.new_compilation(start_time).await; + match comp_ctx.configure_plugins(plugin_options).await { + Err(e) => export_with_pack_error(e), + Ok(_) => match prep_ctx.create_compiler(comp_ctx).await { + Ok(x) => export_with_compiler(x, req).await, + Err((e, _)) => export_with_pack_error(e), + }, + } +} + +fn export_with_pack_error(error: PackError) -> ExpoDoc { + ExpoDoc::Error(error.to_string()) +} + +async fn export_with_compiler(compiler: Compiler<'_>, req: ExportRequest) -> ExpoDoc { + let mut comp_doc = compiler.compile().await; + if let Some(expo_doc) = comp_doc.run_exporter(&req) { + return expo_doc; + } + let exec_ctx = comp_doc.execute().await; + exec_ctx.run_exporter(req) +} diff --git a/server/src/compiler/mod.rs b/server/src/compiler/mod.rs index b0cbd74e..631d940d 100644 --- a/server/src/compiler/mod.rs +++ b/server/src/compiler/mod.rs @@ -10,6 +10,8 @@ mod loader; pub use loader::*; mod cache; pub use cache::*; +mod export; +pub use export::*; mod plugin; pub use plugin::*; diff --git a/web-client/index.html b/web-client/index.html index 617416ce..fbcc6a51 100644 --- a/web-client/index.html +++ b/web-client/index.html @@ -60,6 +60,6 @@
Celer
- + diff --git a/web-client/src/core/doc/export.ts b/web-client/src/core/doc/export.ts index 72734f14..af213995 100644 --- a/web-client/src/core/doc/export.ts +++ b/web-client/src/core/doc/export.ts @@ -2,9 +2,12 @@ import YAML from "js-yaml"; import { Result, tryCatch } from "pure/result"; +import { AppState, documentSelector, settingsSelector } from "core/store"; import { ExportMetadata, ExportRequest } from "low/celerc"; +import { consoleDoc as console } from "low/utils"; import { DocSettingsState } from "./state"; +import { getDefaultSplitTypes } from "./utils"; /// Get a unique identifier for the export metadata /// @@ -60,3 +63,39 @@ export function createExportRequest( export function getSplitExportPluginConfigs() { return [{ use: "export-livesplit" }]; } + +export function injectSplitTypesIntoRequest( + request: ExportRequest, + state: AppState, +) { + const splitExportConfigs = getSplitExportPluginConfigs(); + if (!splitExportConfigs.find((c) => c.use === request.pluginId)) { + // not a split export plugin, don't inject splits + return; + } + if (!request.payload || typeof request.payload !== "object") { + // no payload to inject into + return; + } + const payload = request.payload as Record; + if (payload["split-types"]) { + // already has data, don't override + return; + } + const { splitTypes } = settingsSelector(state); + let injected: string[]; + if (splitTypes) { + injected = splitTypes; + } else { + const { document } = documentSelector(state); + if (document) { + injected = getDefaultSplitTypes(document); + } else { + injected = []; + } + } + payload["split-types"] = injected; + console.info( + `injected ${injected.length} split types into export request payload.`, + ); +} diff --git a/web-client/src/core/doc/index.ts b/web-client/src/core/doc/index.ts index 312423c4..1dad2fbf 100644 --- a/web-client/src/core/doc/index.ts +++ b/web-client/src/core/doc/index.ts @@ -3,7 +3,6 @@ //! Document viewer system export * from "./export"; -export * from "./loader"; export * from "./state"; export * from "./utils"; export * as documentReducers from "./docReducers"; diff --git a/web-client/src/core/doc/loader.ts b/web-client/src/core/doc/loader.ts deleted file mode 100644 index 468d672c..00000000 --- a/web-client/src/core/doc/loader.ts +++ /dev/null @@ -1,175 +0,0 @@ -//! Utilities for loading/requesting document from server - -import { Buffer } from "buffer/"; - -import { tryAsync } from "pure/result"; - -import type { ExpoContext, PluginOptionsRaw } from "low/celerc"; -import { fetchAsJson, getApiUrl } from "low/fetch"; -import { consoleDoc as console } from "low/utils"; - -export type LoadDocumentResult = - | { - type: "success"; - data: ExpoContext; - } - | { - type: "failure"; - data: string; - help?: string; - }; - -const HELP_URL = "/docs/route/publish#viewing-the-route-on-celer"; - -/// Load the document based on the current URL (window.location.pathname) -/// -/// The path should be /view/{owner}/{repo}/{path}:{reference} -export async function loadDocumentFromCurrentUrl( - pluginOptions: PluginOptionsRaw | undefined, -): Promise { - const pathname = window.location.pathname; - if (!pathname.startsWith("/view")) { - return createLoadError( - "Invalid document URL. Please double check you have the correct URL.", - HELP_URL, - ); - } - const parts = pathname.substring(6).split("/").filter(Boolean); - // parts[0] is owner - // parts[1] is repo - // parts[2:] is path - // last is path:reference - if (parts.length < 2) { - return createLoadError( - "Invalid document reference. Please double check you have the correct URL.", - HELP_URL, - ); - } - - const [owner, repo, ...rest] = parts; - if (!owner || !repo) { - return createLoadError( - "Invalid document reference. Please double check you have the correct URL.", - HELP_URL, - ); - } - let reference = "main"; - let realRepo = repo; - if (rest.length > 0) { - const [last, ref] = rest[rest.length - 1].split(":", 2); - rest[rest.length - 1] = last; - if (ref) { - reference = ref; - } - } else { - // :reference might be in repo - const [last, ref] = repo.split(":", 2); - realRepo = last; - if (ref) { - reference = ref; - } - } - const path = rest.join("/"); - return await loadDocument(owner, realRepo, reference, path, pluginOptions); -} - -function createLoadError( - message: string, - help: string | undefined, -): LoadDocumentResult { - return { - type: "failure", - data: message, - help, - }; -} - -export async function loadDocument( - owner: string, - repo: string, - reference: string, - path: string | undefined, - pluginOptions: PluginOptionsRaw | undefined, -): Promise { - console.info(`loading document: ${owner}/${repo}/${reference} ${path}`); - const startTime = performance.now(); - let url = `/compile/${owner}/${repo}/${reference}`; - if (path) { - url += `/${path}`; - } - const headers: Record = {}; - if (pluginOptions) { - try { - const optionsJson = JSON.stringify(pluginOptions); - const optionsBytes = new TextEncoder().encode(optionsJson); - const optionsBase64 = Buffer.from(optionsBytes).toString("base64"); - headers["Celer-Plugin-Options"] = optionsBase64; - } catch (e) { - console.error(e); - console.error("failed to encode plugin options"); - return createLoadError( - "Failed to encode plugin options", - undefined, - ); - } - } - const result = await tryAsync(() => - fetchAsJson(getApiUrl(url), { headers }), - ); - if ("err" in result) { - const err = result.err; - console.error(err); - return createLoadError( - "There was an error loading the document from the server.", - undefined, - ); - } - const response = result.val; - const elapsed = Math.round(performance.now() - startTime); - console.info(`received resposne in ${elapsed}ms`); - if (response.type === "success") { - injectLoadTime(response.data, elapsed); - } else { - if (!response.help) { - response.help = HELP_URL; - } - } - - return response; -} - -function injectLoadTime(doc: ExpoContext, ms: number) { - // in case the response from server is invalid, we don't want to crash the app - try { - doc.execDoc.project.stats["Loaded In"] = `${ms}ms`; - } catch (e) { - console.info("failed to inject load time"); - console.error(e); - } -} - -export const preloadedDocumentTitle = getPreloadedDocumentTitle(); - -/// Get the server provided document title, for configuring the load request -function getPreloadedDocumentTitle(): string | undefined { - const meta = document.querySelector("meta[name='preload-title']"); - if (!meta) { - if (window.location.hash) { - const title = decodeURIComponent(window.location.hash.substring(1)); - console.info(`using preloaded title from URL hash: ${title}`); - return title || undefined; - } else { - console.warn( - "cannot find preloaded document title. This is a bug if you are in production env", - ); - console.info( - "for dev environment, append the URL encoded title after # in the URL", - ); - return undefined; - } - } - - const title = meta.getAttribute("content") || undefined; - console.info(`using preloaded title from server: ${title}`); - return title; -} diff --git a/web-client/src/core/editor/initEditor.ts b/web-client/src/core/editor/initEditor.ts index 615be7bd..ba591f92 100644 --- a/web-client/src/core/editor/initEditor.ts +++ b/web-client/src/core/editor/initEditor.ts @@ -7,15 +7,15 @@ import { EditorKernel } from "./EditorKernel"; declare global { interface Window { - __theEditorKernel: EditorKernel; + __theEditor: EditorKernel; } } -export const initEditor = async ( +export async function initEditor( kernel: EditorKernelAccess, fs: FsFileSystem, store: AppStore, -): Promise => { +): Promise { deleteEditor(); const { editorMode } = settingsSelector(store.getState()); let editor; @@ -27,12 +27,12 @@ export const initEditor = async ( editor = initExternalEditor(kernel, fs); } - window.__theEditorKernel = editor; + window.__theEditor = editor; return editor; -}; +} -export const deleteEditor = (): void => { - if (window.__theEditorKernel) { - window.__theEditorKernel.delete(); +export function deleteEditor(): void { + if (window.__theEditor) { + window.__theEditor.delete(); } -}; +} diff --git a/web-client/src/core/kernel/AlertMgr.ts b/web-client/src/core/kernel/AlertMgr.ts index 208c0b68..cf665ae7 100644 --- a/web-client/src/core/kernel/AlertMgr.ts +++ b/web-client/src/core/kernel/AlertMgr.ts @@ -106,7 +106,7 @@ export class AlertMgrImpl implements AlertMgr { setTimeout(async () => { const result = await tryAsync(f); if (!cancelled) { - resolve(result); + this.clearAlertAndThen(() => resolve(result)); } }, ALERT_TIMEOUT); }); diff --git a/web-client/src/core/kernel/Kernel.ts b/web-client/src/core/kernel/Kernel.ts index cb1ab5fa..df7ee7c6 100644 --- a/web-client/src/core/kernel/Kernel.ts +++ b/web-client/src/core/kernel/Kernel.ts @@ -1,371 +1,43 @@ -import reduxWatch from "redux-watch"; - -import { FsFileSystem } from "pure/fs"; - -import { - AppState, - AppStore, - SettingsState, - documentActions, - initStore, - saveSettings, - settingsActions, - settingsSelector, - viewSelector, - viewActions, - documentSelector, -} from "core/store"; -import { - getDefaultSplitTypes, - getRawPluginOptionsForTitle, - getSplitExportPluginConfigs, - isRecompileNeeded, - loadDocumentFromCurrentUrl, - preloadedDocumentTitle, -} from "core/doc"; +import type { FsFileSystem } from "pure/fs"; import type { CompilerKernel } from "core/compiler"; -import type { EditorKernel, EditorKernelAccess } from "core/editor"; -import { ExpoDoc, ExportRequest } from "low/celerc"; -import { - consoleKernel as console, - isInDarkMode, - sleep, - AlertMgr, -} from "low/utils"; - -import { KeyMgr } from "./KeyMgr"; -import { WindowMgr } from "./WindowMgr"; -import { AlertMgrImpl } from "./AlertMgr"; - -type InitUiFunction = ( - kernel: Kernel, - store: AppStore, - isDarkMode: boolean, -) => () => void; - -/// The kernel class -/// -/// The kernel owns all global resources like the redux store. -/// It is also responsible for mounting react to the DOM and -/// handles the routing. -export class Kernel implements EditorKernelAccess { - /// The store - /// - /// The kernel owns the store. The store is shared - /// between app boots (i.e. when switching routes) - private store: AppStore; - /// The function to initialize react - private initReact: InitUiFunction; - /// The function to unmount react - private cleanupUi: (() => void) | null = null; - - // Alert API - private alertMgr: AlertMgr; - - // Editor API - // The editor is owned by the kernel because the toolbar needs access - private editor: EditorKernel | null = null; - - // Compiler API - private compiler: CompilerKernel | null = null; - - constructor(initReact: InitUiFunction) { - this.initReact = initReact; - console.info("starting application"); - this.store = this.initStore(); - this.alertMgr = new AlertMgrImpl(this.store); - } - - /// Initialize the store - private initStore(): AppStore { - console.info("initializing store..."); - const store = initStore(); - - const watchSettings = reduxWatch(() => - settingsSelector(store.getState()), - ); - - store.subscribe( - watchSettings((newVal: SettingsState, _oldVal: SettingsState) => { - // save settings to local storage - console.info("saving settings..."); - saveSettings(newVal); - }), - ); - - const watchAll = reduxWatch(() => store.getState()); - store.subscribe( - watchAll(async (newVal: AppState, oldVal: AppState) => { - if (await isRecompileNeeded(newVal, oldVal)) { - console.info("reloading document due to state change..."); - await this.reloadDocument(); - } - }), - ); - - return store; - } - - /// Start the application. Cleans up previous application if needed - public init() { - this.initStage(); - this.initUi(); - - window.addEventListener("beforeunload", (e) => { - if (this.editor && this.editor.hasUnsavedChangesSync()) { - e.preventDefault(); - return (e.returnValue = - "There are unsaved changes in the editor which will be lost. Are you sure you want to leave?"); - } - }); - } - - /// Initialize stage info based on window.location - private async initStage() { - console.info("initializing stage..."); - const path = window.location.pathname; - if (path === "/edit") { - document.title = "Celer Editor"; - this.store.dispatch(viewActions.setStageMode("edit")); - } else { - if (preloadedDocumentTitle) { - document.title = preloadedDocumentTitle; - } - setTimeout(() => { - this.reloadDocument(); - }, 0); - this.store.dispatch(viewActions.setStageMode("view")); - } - } - - /// Initialize UI related stuff - private initUi() { - console.info("initializing ui..."); - if (this.cleanupUi) { - console.info("unmounting previous ui"); - this.cleanupUi(); - } - const isDarkMode = isInDarkMode(); - const unmountReact = this.initReact(this, this.store, isDarkMode); - - // key binding handler - const keyMgr = new KeyMgr(this.store); - const unlistenKeyMgr = keyMgr.listen(); - - // window handlers - const windowMgr = new WindowMgr(this.store); - const unlistenWindowMgr = windowMgr.listen(); - - this.cleanupUi = () => { - unmountReact(); - unlistenKeyMgr(); - unlistenWindowMgr(); - }; - } - - public getAlertMgr(): AlertMgr { - return this.alertMgr; - } +import type { EditorKernel } from "core/editor"; +import type { ExpoDoc, ExportRequest } from "low/celerc"; +import type { AlertMgr } from "low/utils"; + +/// Kernel is the global interface for the application +/// It also owns global state and resources such as the redux store +export interface Kernel { + /// Initialize the kernel + init(): void; + /// Delete the kernel + /// May be called in dev environment when hot reloading + delete(): void; + + /// Get the alert manager + readonly alertMgr: AlertMgr; + + /// Get access to APIs only in EDIT mode + /// Will throw if called in VIEW mode + asEdit(): KernelEdit; + + /// Reload the document + reloadDocument(): Promise; + + /// Execute an export request + exportDocument(request: ExportRequest): Promise; +} - public getEditor(): EditorKernel | null { - return this.editor; - } +export interface KernelEdit { + /// Get the editor, will be undefined if the editor is + /// not initialized (project not opened) + getEditor(): EditorKernel | undefined; - /// Get or load the compiler - public async getCompiler(): Promise { - const state = this.store.getState(); - const stageMode = viewSelector(state).stageMode; - if (stageMode !== "edit") { - console.error( - "compiler is not available in view mode. This is a bug!", - ); - throw new Error("compiler is not available in view mode"); - } - if (!this.compiler) { - const { initCompiler } = await import("core/compiler"); - const compiler = initCompiler(this.store); - this.compiler = compiler; - } - return this.compiler; - } + /// Get or initialized the compiler. + ensureCompiler(): Promise; /// Open a project file system - /// - /// This function eats the error because alerts will be shown to the user - public async openProjectFileSystem(fs: FsFileSystem): Promise { - console.info("opening file system..."); - - const { editorMode } = settingsSelector(this.store.getState()); - const { write, live } = fs.capabilities; - if (editorMode === "web") { - // must be able to save to use web editor - if (!write) { - const yes = await this.getAlertMgr().show({ - title: "Save not supported", - message: - "The web editor cannot be used because your browser does not support saving changes to the file system. If you wish to edit the project, you can use the External Editor workflow and have Celer load changes directly from your file system.", - okButton: "Use external editor", - cancelButton: "Cancel", - learnMoreLink: "/docs/route/editor/web#browser-os-support", - }); - if (!yes) { - return; - } - this.store.dispatch(settingsActions.setEditorMode("external")); - } - } - - if (!live) { - const yes = await this.getAlertMgr().show({ - title: "Heads up!", - message: - "Your browser has limited support for file system access when opening a project from a dialog. Celer will not be able to detect new, renamed or deleted files! Please see the learn more link below for more information.", - okButton: "Continue anyway", - cancelButton: "Cancel", - learnMoreLink: "/docs/route/editor/external#open-a-project", - }); - if (!yes) { - return; - } - } - - const { initEditor } = await import("core/editor"); - const editor = await initEditor(this, fs, this.store); - this.editor = editor; - this.updateRootPathInStore(fs); - const compiler = await this.getCompiler(); - await compiler.init(editor.getFileAccess()); - - // trigger a first run when loading new project - compiler.compile(); - console.info("project opened."); - } - - public async closeProjectFileSystem() { - console.info("closing file system..."); - this.store.dispatch(documentActions.setDocument(undefined)); - this.updateRootPathInStore(undefined); - this.editor = null; - const { deleteEditor } = await import("core/editor"); - deleteEditor(); - const compiler = await this.getCompiler(); - compiler.uninit(); - } - - private updateRootPathInStore(fs: FsFileSystem | undefined) { - this.store.dispatch(viewActions.updateFileSys(fs?.root ?? undefined)); - } - - public async reloadDocument() { - if (viewSelector(this.store.getState()).stageMode === "edit") { - const compiler = await this.getCompiler(); - await compiler.compile(); - return; - } - await this.reloadDocumentFromServer(); - } - - public async export(request: ExportRequest): Promise { - const splitExportConfigs = getSplitExportPluginConfigs(); - if (splitExportConfigs.find((c) => c.use === request.pluginId)) { - if (request.payload && typeof request.payload === "object") { - const payload = request.payload as Record; - if (!payload["split-types"]) { - const { splitTypes } = settingsSelector( - this.store.getState(), - ); - let injected: string[]; - if (splitTypes) { - injected = splitTypes; - } else { - const { document } = documentSelector( - this.store.getState(), - ); - if (document) { - injected = getDefaultSplitTypes(document); - } else { - injected = []; - } - } - payload["split-types"] = injected; - console.info( - `injected ${injected.length} split types into export request payload.`, - ); - } - } - } - const { stageMode } = viewSelector(this.store.getState()); - if (stageMode === "edit") { - const compiler = await this.getCompiler(); - return await compiler.export(request); - } else { - // TODO #184: export from server - return { - error: "Export from server is not available yet. This is tracked by issue 184 on GitHub", - }; - } - } - - /// Reload the document from the server based on the current URL - private async reloadDocumentFromServer() { - this.store.dispatch(documentActions.setDocument(undefined)); - this.store.dispatch(viewActions.setCompileInProgress(true)); - // let UI update - await sleep(0); + openProjectFileSystem(fs: FsFileSystem): Promise; - let retry = true; - while (retry) { - console.info("reloading document from server"); - const settings = settingsSelector(this.store.getState()); - const pluginOptions = getRawPluginOptionsForTitle( - settings, - preloadedDocumentTitle, - ); - const result = await loadDocumentFromCurrentUrl(pluginOptions); - if (result.type === "failure") { - this.store.dispatch(documentActions.setDocument(undefined)); - console.info("failed to load document from server"); - console.error(result.data); - retry = await this.getAlertMgr().show({ - title: "Failed to load route", - message: result.data, - learnMoreLink: result.help, - okButton: "Retry", - cancelButton: "Cancel", - }); - if (!retry) { - await this.alertMgr.show({ - title: "Load cancelled", - message: - 'You can retry at any time by refreshing the page, or by clicking "Reload Document" from the toolbar.', - okButton: "Got it", - cancelButton: "", - }); - break; - } - console.warn("retrying in 1s..."); - await sleep(1000); - continue; - } - console.info("received document from server"); - const doc = result.data; - try { - const { title, version } = doc.execDoc.project; - if (!title) { - document.title = "Celer Viewer"; - } else if (!version) { - document.title = title; - } else { - document.title = `${title} - ${version}`; - } - } catch (e) { - console.warn("failed to set document title"); - console.error(e); - document.title = "Celer Viewer"; - } - this.store.dispatch(documentActions.setDocument(doc)); - break; - } - this.store.dispatch(viewActions.setCompileInProgress(false)); - } + /// Close the opened project file system + closeProjectFileSystem(): Promise; } diff --git a/web-client/src/core/kernel/KernelEditImpl.ts b/web-client/src/core/kernel/KernelEditImpl.ts new file mode 100644 index 00000000..a6b1754d --- /dev/null +++ b/web-client/src/core/kernel/KernelEditImpl.ts @@ -0,0 +1,165 @@ +//! Kernel implementation for the EDIT mode + +import { FsFileSystem } from "pure/fs"; + +import { + AppStore, + documentActions, + settingsActions, + settingsSelector, + viewActions, +} from "core/store"; +import { injectSplitTypesIntoRequest } from "core/doc"; +import { EditorKernel, EditorKernelAccess } from "core/editor"; +import { CompilerKernel } from "core/compiler"; +import { AlertMgr, consoleKernel as console } from "low/utils"; +import { ExpoDoc, ExportRequest } from "low/celerc"; + +import { Kernel, KernelEdit } from "./Kernel"; +import { UiMgr, UiMgrInitFn } from "./UiMgr"; +import { KeyMgr } from "./KeyMgr"; +import { createAndBindStore } from "./store"; +import { AlertMgrImpl } from "./AlertMgr"; + +/// The kernel class +/// +/// The kernel owns all global resources like the redux store. +/// It is also responsible for mounting react to the DOM and +/// handles the routing. +export class KernelEditImpl implements Kernel, KernelEdit, EditorKernelAccess { + private store: AppStore; + private uiMgr: UiMgr; + private keyMgr: KeyMgr; + + public readonly alertMgr: AlertMgr; + + // Editor API + private editor: EditorKernel | undefined = undefined; + + // Compiler API + private compiler: CompilerKernel | undefined = undefined; + + constructor(initUiMgr: UiMgrInitFn) { + this.store = createAndBindStore(this); + this.uiMgr = new UiMgr(this, this.store, initUiMgr); + this.keyMgr = new KeyMgr(this.store); + this.alertMgr = new AlertMgrImpl(this.store); + } + + public asEdit() { + return this; + } + + public init() { + console.info("initializing edit mode kernel..."); + this.uiMgr.init(); + this.keyMgr.init(); + + document.title = "Celer Editor"; + this.store.dispatch(viewActions.setStageMode("edit")); + window.addEventListener("beforeunload", (e) => { + if (this.editor && this.editor.hasUnsavedChangesSync()) { + e.preventDefault(); + return (e.returnValue = + "There are unsaved changes in the editor which will be lost. Are you sure you want to leave?"); + } + }); + } + public delete() { + this.uiMgr.delete(); + this.keyMgr.delete(); + } + + public async reloadDocument() { + const compiler = await this.ensureCompiler(); + await compiler.compile(); + } + + public async exportDocument(request: ExportRequest): Promise { + injectSplitTypesIntoRequest(request, this.store.getState()); + const compiler = await this.ensureCompiler(); + return await compiler.export(request); + } + + public getEditor(): EditorKernel | undefined { + return this.editor; + } + + /// Get or load the compiler + public async ensureCompiler(): Promise { + if (!this.compiler) { + const { initCompiler } = await import("core/compiler"); + const compiler = initCompiler(this.store); + this.compiler = compiler; + } + return this.compiler; + } + + /// Open a project file system + /// + /// This function eats the error because alerts will be shown to the user + public async openProjectFileSystem(fs: FsFileSystem): Promise { + console.info("opening file system..."); + + const { editorMode } = settingsSelector(this.store.getState()); + const { write, live } = fs.capabilities; + if (editorMode === "web") { + // must be able to save to use web editor + if (!write) { + const yes = await this.alertMgr.show({ + title: "Save not supported", + message: + "The web editor cannot be used because your browser does not support saving changes to the file system. If you wish to edit the project, you can use the External Editor workflow and have Celer load changes directly from your file system.", + okButton: "Use external editor", + cancelButton: "Cancel", + learnMoreLink: "/docs/route/editor/web#browser-os-support", + }); + if (!yes) { + return; + } + this.store.dispatch(settingsActions.setEditorMode("external")); + } + } + + if (!live) { + const yes = await this.alertMgr.show({ + title: "Heads up!", + message: + "Your browser has limited support for file system access when opening a project from a dialog. Celer will not be able to detect new, renamed or deleted files! Please see the learn more link below for more information.", + okButton: "Continue anyway", + cancelButton: "Cancel", + learnMoreLink: "/docs/route/editor/external#open-a-project", + }); + if (!yes) { + return; + } + } + + const { initEditor } = await import("core/editor"); + const editor = await initEditor(this, fs, this.store); + this.editor = editor; + this.updateRootPathInStore(fs); + const compiler = await this.ensureCompiler(); + await compiler.init(editor.getFileAccess()); + + // trigger a first run when loading new project + compiler.compile(); + console.info("project opened."); + } + + public async closeProjectFileSystem() { + console.info("closing file system..."); + this.store.dispatch(documentActions.setDocument(undefined)); + this.updateRootPathInStore(undefined); + this.editor = undefined; + const { deleteEditor } = await import("core/editor"); + deleteEditor(); + if (this.compiler) { + this.compiler.uninit(); + } + } + + private updateRootPathInStore(fs: FsFileSystem | undefined) { + this.store.dispatch(viewActions.updateFileSys(fs?.root ?? undefined)); + } +} diff --git a/web-client/src/core/kernel/KernelViewImpl.ts b/web-client/src/core/kernel/KernelViewImpl.ts new file mode 100644 index 00000000..0cd529cf --- /dev/null +++ b/web-client/src/core/kernel/KernelViewImpl.ts @@ -0,0 +1,139 @@ +/// Implementation of the Kernel in VIEW mode + +import { + getRawPluginOptionsForTitle, + injectSplitTypesIntoRequest, +} from "core/doc"; +import { + AppStore, + documentActions, + settingsSelector, + viewActions, +} from "core/store"; +import { AlertMgr, consoleKernel as console, sleep } from "low/utils"; +import { ExpoDoc, ExportRequest } from "low/celerc"; + +import { Kernel } from "./Kernel"; +import { UiMgr, UiMgrInitFn } from "./UiMgr"; +import { createAndBindStore } from "./store"; +import { KeyMgr } from "./KeyMgr"; +import { AlertMgrImpl } from "./AlertMgr"; +import { + getPreloadedDocumentTitle, + loadDocument, + sendExportRequest, +} from "./server"; + +export class KernelViewImpl implements Kernel { + private store: AppStore; + private uiMgr: UiMgr; + private keyMgr: KeyMgr; + public readonly alertMgr: AlertMgr; + + private preloadedDocumentTitle: string | undefined; + + constructor(initUiMgr: UiMgrInitFn) { + this.store = createAndBindStore(this); + this.uiMgr = new UiMgr(this, this.store, initUiMgr); + this.keyMgr = new KeyMgr(this.store); + this.alertMgr = new AlertMgrImpl(this.store); + this.preloadedDocumentTitle = getPreloadedDocumentTitle(); + } + + public asEdit(): never { + throw new Error("Cannot switch to edit mode from view mode"); + } + + public init() { + console.info("initializing view mode kernel..."); + this.uiMgr.init(); + this.keyMgr.init(); + + if (this.preloadedDocumentTitle) { + document.title = this.preloadedDocumentTitle; + } + setTimeout(() => { + this.reloadDocument(); + }, 0); + this.store.dispatch(viewActions.setStageMode("view")); + } + + public delete() { + this.uiMgr.delete(); + this.keyMgr.delete(); + } + + /// Reload the document from the server based on the current URL + public async reloadDocument() { + this.store.dispatch(documentActions.setDocument(undefined)); + this.store.dispatch(viewActions.setCompileInProgress(true)); + // let UI update + await sleep(0); + + let retry = true; + while (retry) { + console.info("reloading document from server"); + const settings = settingsSelector(this.store.getState()); + const pluginOptions = getRawPluginOptionsForTitle( + settings, + this.preloadedDocumentTitle, + ); + const result = await loadDocument(pluginOptions); + if (result.type === "failure") { + this.store.dispatch(documentActions.setDocument(undefined)); + console.info("failed to load document from server"); + console.error(result.data); + retry = await this.alertMgr.show({ + title: "Failed to load route", + message: result.data, + learnMoreLink: + "/docs/route/publish#viewing-the-route-on-celer", + okButton: "Retry", + cancelButton: "Cancel", + }); + if (!retry) { + await this.alertMgr.show({ + title: "Load cancelled", + message: + 'You can retry at any time by refreshing the page, or by clicking "Reload Document" from the toolbar.', + okButton: "Got it", + cancelButton: "", + }); + break; + } + console.warn("retrying in 1s..."); + await sleep(1000); + continue; + } + console.info("received document from server"); + const doc = result.data; + try { + const { title, version } = doc.execDoc.project; + if (!title) { + document.title = "Celer Viewer"; + } else if (!version) { + document.title = title; + } else { + document.title = `${title} - ${version}`; + } + } catch (e) { + console.warn("failed to set document title"); + console.error(e); + document.title = "Celer Viewer"; + } + this.store.dispatch(documentActions.setDocument(doc)); + break; + } + this.store.dispatch(viewActions.setCompileInProgress(false)); + } + + public exportDocument(request: ExportRequest): Promise { + injectSplitTypesIntoRequest(request, this.store.getState()); + const settings = settingsSelector(this.store.getState()); + const pluginOptions = getRawPluginOptionsForTitle( + settings, + this.preloadedDocumentTitle, + ); + return sendExportRequest(pluginOptions, request); + } +} diff --git a/web-client/src/core/kernel/KeyMgr.ts b/web-client/src/core/kernel/KeyMgr.ts index 3e71efb9..86435631 100644 --- a/web-client/src/core/kernel/KeyMgr.ts +++ b/web-client/src/core/kernel/KeyMgr.ts @@ -29,14 +29,14 @@ export class KeyMgr { /// for it to be released. private lastDetected: string[] = []; + private cleanupFn: (() => void) | undefined = undefined; + constructor(store: AppStore) { this.store = store; } /// Add listeners to the window - /// - /// Returns a function to unlisten - public listen(): () => void { + public init() { const onKeyDown = (e: KeyboardEvent) => { this.onKeyDown(e.key); }; @@ -45,12 +45,19 @@ export class KeyMgr { }; window.addEventListener("keydown", onKeyDown); window.addEventListener("keyup", onKeyUp); - return () => { + this.cleanupFn = () => { window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); }; } + /// Remove all listeners + public delete() { + if (this.cleanupFn) { + this.cleanupFn(); + } + } + /// Handle when a key is pressed /// /// This will add to the current pressed strokes. diff --git a/web-client/src/core/kernel/WindowMgr.ts b/web-client/src/core/kernel/UiMgr.ts similarity index 51% rename from web-client/src/core/kernel/WindowMgr.ts rename to web-client/src/core/kernel/UiMgr.ts index bafb0e83..37c9b9e3 100644 --- a/web-client/src/core/kernel/WindowMgr.ts +++ b/web-client/src/core/kernel/UiMgr.ts @@ -1,19 +1,31 @@ import { AppStore, viewActions } from "core/store"; +import { consoleKernel as console } from "low/utils"; -/// Manager for various global window events -export class WindowMgr { +import { Kernel } from "./Kernel"; + +export type UiMgrInitFn = (kernel: Kernel, store: AppStore) => () => void; + +/// Manager for React and various global window events +export class UiMgr { + private kernel: Kernel; private store: AppStore; + private initFn: UiMgrInitFn; + private cleanupFn: (() => void) | undefined = undefined; + private resizeHandle: number | undefined = undefined; - constructor(store: AppStore) { + constructor(kernel: Kernel, store: AppStore, initFn: UiMgrInitFn) { + this.kernel = kernel; this.store = store; + this.initFn = initFn; } /// Register the window handlers /// /// Returns a function to unregister - public listen(): () => void { + public init() { + console.info("initializing ui..."); const onResize = () => { if (this.resizeHandle) { // already resizing @@ -26,9 +38,18 @@ export class WindowMgr { this.store.dispatch(viewActions.setIsResizingWindow(false)); }, 200); }; + const cleanUpUi = this.initFn(this.kernel, this.store); + window.addEventListener("resize", onResize); - return () => { + this.cleanupFn = () => { + cleanUpUi(); window.removeEventListener("resize", onResize); }; } + + public delete() { + console.info("deleting ui..."); + this.cleanupFn?.(); + window.clearTimeout(this.resizeHandle); + } } diff --git a/web-client/src/core/kernel/index.ts b/web-client/src/core/kernel/index.ts index e8a8086b..a553e713 100644 --- a/web-client/src/core/kernel/index.ts +++ b/web-client/src/core/kernel/index.ts @@ -4,5 +4,5 @@ //! the react ui, redux store, file system, and others export * from "./Kernel"; -export * from "./AlertMgr"; +export * from "./initKernel"; export * from "./context"; diff --git a/web-client/src/core/kernel/initKernel.ts b/web-client/src/core/kernel/initKernel.ts new file mode 100644 index 00000000..3fa0ee35 --- /dev/null +++ b/web-client/src/core/kernel/initKernel.ts @@ -0,0 +1,30 @@ +import { consoleKernel as console } from "low/utils"; + +import { UiMgrInitFn } from "./UiMgr"; +import { Kernel } from "./Kernel"; + +declare global { + interface Window { + __theKernel: Kernel; + } +} + +export async function initKernel(initUi: UiMgrInitFn): Promise { + if (window.__theKernel) { + console.warn("deleting old kernel..."); + window.__theKernel.delete(); + } + console.info("initializing kernel..."); + let kernel; + if (window.location.pathname === "/edit") { + const { KernelEditImpl } = await import("./KernelEditImpl"); + kernel = new KernelEditImpl(initUi); + } else { + const { KernelViewImpl } = await import("./KernelViewImpl"); + kernel = new KernelViewImpl(initUi); + } + + window.__theKernel = kernel; + kernel.init(); + console.info("kernel initialized"); +} diff --git a/web-client/src/core/kernel/server/compile.ts b/web-client/src/core/kernel/server/compile.ts new file mode 100644 index 00000000..60dcc68b --- /dev/null +++ b/web-client/src/core/kernel/server/compile.ts @@ -0,0 +1,88 @@ +//! Calls the /compile endpoint + +import type { ExpoContext, PluginOptionsRaw } from "low/celerc"; +import { consoleKernel as console } from "low/utils"; +import { fetchAsJson, getApiUrl } from "low/fetch"; + +import { DocRef, encodeObjectAsBase64, parseDocRef } from "./utils.ts"; + +export type LoadDocumentResult = + | { + type: "success"; + data: ExpoContext; + } + | { + type: "failure"; + data: string; + }; + +function createLoadError(data: string): LoadDocumentResult { + return { type: "failure", data }; +} + +/// Load the document based on the current URL (window.location.pathname) +/// +/// The path should be /view/{owner}/{repo}/{path}:{reference} +export async function loadDocument( + pluginOptions: PluginOptionsRaw | undefined, +): Promise { + const docRef = parseDocRef(window.location.pathname); + if (!docRef) { + return createLoadError( + "Invalid document reference. Please double check you have the correct URL.", + ); + } + return await loadDocumentForRef(docRef, pluginOptions); +} + +async function loadDocumentForRef( + docRef: DocRef, + pluginOptions: PluginOptionsRaw | undefined, +): Promise { + const { owner, repo, ref, path } = docRef; + console.info(`loading document: ${owner}/${repo}/${ref} ${path}`); + const startTime = performance.now(); + let url = `/compile/${owner}/${repo}/${ref}`; + if (path) { + url += `/${path}`; + } + + const headers: Record = {}; + if (pluginOptions) { + const optionsValue = encodeObjectAsBase64(pluginOptions); + if ("err" in optionsValue) { + console.error(optionsValue.err); + return createLoadError("Failed to encode plugin options"); + } + headers["Celer-Plugin-Options"] = optionsValue.val; + } + const result = await fetchAsJson(getApiUrl(url), { + headers, + }); + if ("err" in result) { + const err = result.err; + console.error(err); + return createLoadError( + "There was an error loading the document from the server.", + ); + } + const response = result.val; + + const elapsed = Math.round(performance.now() - startTime); + console.info(`received resposne in ${elapsed}ms`); + if (response.type === "success") { + injectLoadTime(response.data, elapsed); + } + + return response; +} + +function injectLoadTime(doc: ExpoContext, ms: number) { + // in case the response from server is invalid, we don't want to crash the app + try { + doc.execDoc.project.stats["Loaded In"] = `${ms}ms`; + } catch (e) { + console.info("failed to inject load time"); + console.error(e); + } +} diff --git a/web-client/src/core/kernel/server/export.ts b/web-client/src/core/kernel/server/export.ts new file mode 100644 index 00000000..5cebbccf --- /dev/null +++ b/web-client/src/core/kernel/server/export.ts @@ -0,0 +1,65 @@ +//! Calls the /export endpoint + +import type { ExpoDoc, ExportRequest, PluginOptionsRaw } from "low/celerc"; +import { fetchAsJson, getApiUrl } from "low/fetch"; +import { consoleKernel as console } from "low/utils"; + +import { DocRef, encodeObjectAsBase64, parseDocRef } from "./utils.ts"; + +function createExportError(error: string): ExpoDoc { + return { error }; +} + +export async function sendExportRequest( + pluginOptions: PluginOptionsRaw | undefined, + request: ExportRequest, +): Promise { + const docRef = parseDocRef(window.location.pathname); + if (!docRef) { + return createExportError("Invalid document reference for export."); + } + return await sendExportRequestForRef(docRef, pluginOptions, request); +} + +async function sendExportRequestForRef( + docRef: DocRef, + pluginOptions: PluginOptionsRaw | undefined, + request: ExportRequest, +): Promise { + const { owner, repo, ref, path } = docRef; + console.info(`export document: ${owner}/${repo}/${ref} ${path}`); + const startTime = performance.now(); + let url = `/export/${owner}/${repo}/${ref}`; + if (path) { + url += `/${path}`; + } + const headers: Record = {}; + if (pluginOptions) { + const optionsValue = encodeObjectAsBase64(pluginOptions); + if ("err" in optionsValue) { + console.error(optionsValue.err); + return createExportError("Failed to encode plugin options"); + } + headers["Celer-Plugin-Options"] = optionsValue.val; + } + const requestValue = encodeObjectAsBase64(request); + if ("err" in requestValue) { + console.error(requestValue.err); + return createExportError("Failed to encode export request"); + } + headers["Celer-Export-Request"] = requestValue.val; + + const result = await fetchAsJson(getApiUrl(url), { headers }); + if ("err" in result) { + const err = result.err; + console.error(err); + return createExportError( + "There was an error sending export request to the server.", + ); + } + const doc = result.val; + + const elapsed = Math.round(performance.now() - startTime); + console.info(`received resposne in ${elapsed}ms`); + return doc; +} diff --git a/web-client/src/core/kernel/server/index.ts b/web-client/src/core/kernel/server/index.ts new file mode 100644 index 00000000..92b85faa --- /dev/null +++ b/web-client/src/core/kernel/server/index.ts @@ -0,0 +1,5 @@ +//! Functions for calling server endpoints + +export * from "./compile.ts"; +export * from "./export.ts"; +export { getPreloadedDocumentTitle } from "./utils.ts"; diff --git a/web-client/src/core/kernel/server/utils.ts b/web-client/src/core/kernel/server/utils.ts new file mode 100644 index 00000000..10f6e082 --- /dev/null +++ b/web-client/src/core/kernel/server/utils.ts @@ -0,0 +1,85 @@ +import { Buffer } from "buffer/"; + +import { Result, tryCatch } from "pure/result"; + +import { consoleKernel as console } from "low/utils"; + +export type DocRef = { + owner: string; + repo: string; + ref: string; + path: string; +}; + +/// Parse document ref from a path like /view/{owner}/{repo}[/{path}]:{ref} +/// Return undefined if parse fail +export function parseDocRef(pathname: string): DocRef | undefined { + if (!pathname.startsWith("/view")) { + return undefined; + } + const parts = pathname.substring(6).split("/").filter(Boolean); + // parts[0] is owner + // parts[1] is repo + // parts[2:] is path + // last is path:reference + if (parts.length < 2) { + return undefined; + } + + const [owner, repoTemp, ...rest] = parts; + if (!owner || !repoTemp) { + return undefined; + } + let ref = "main"; + let repo = repoTemp; + if (rest.length > 0) { + const [last, refTemp] = rest[rest.length - 1].split(":", 2); + rest[rest.length - 1] = last; + if (refTemp) { + ref = refTemp; + } + } else { + // :reference might be in repo + const [last, refTemp] = repo.split(":", 2); + repo = last; + if (refTemp) { + ref = refTemp; + } + } + const path = rest.join("/"); + + return { owner, repo, ref, path }; +} + +/// Encode a JSON object as base64 +export function encodeObjectAsBase64(obj: unknown): Result { + return tryCatch(() => { + const json = JSON.stringify(obj); + const bytes = new TextEncoder().encode(json); + return Buffer.from(bytes).toString("base64"); + }); +} + +/// Get the server provided document title, for configuring the load request +export function getPreloadedDocumentTitle(): string | undefined { + const meta = document.querySelector("meta[name='preload-title']"); + if (!meta) { + if (window.location.hash) { + const title = decodeURIComponent(window.location.hash.substring(1)); + console.info(`using preloaded title from URL hash: ${title}`); + return title || undefined; + } else { + console.warn( + "cannot find preloaded document title. This is a bug if you are in production env", + ); + console.info( + "for dev environment, append the URL encoded title after # in the URL", + ); + return undefined; + } + } + + const title = meta.getAttribute("content") || undefined; + console.info(`using preloaded title from server: ${title}`); + return title; +} diff --git a/web-client/src/core/kernel/store.ts b/web-client/src/core/kernel/store.ts new file mode 100644 index 00000000..67355cf7 --- /dev/null +++ b/web-client/src/core/kernel/store.ts @@ -0,0 +1,42 @@ +import reduxWatch from "redux-watch"; + +import { isRecompileNeeded } from "core/doc"; +import { + AppState, + AppStore, + SettingsState, + initStore, + saveSettings, + settingsSelector, +} from "core/store"; +import { consoleKernel as console } from "low/utils"; + +import { Kernel } from "./Kernel"; + +/// Create the store and bind listeners to the kernel +export const createAndBindStore = (kernel: Kernel): AppStore => { + console.info("initializing store..."); + const store = initStore(); + + const watchSettings = reduxWatch(() => settingsSelector(store.getState())); + + store.subscribe( + watchSettings((newVal: SettingsState, _oldVal: SettingsState) => { + // save settings to local storage + console.info("saving settings..."); + saveSettings(newVal); + }), + ); + + const watchAll = reduxWatch(() => store.getState()); + store.subscribe( + watchAll(async (newVal: AppState, oldVal: AppState) => { + if (await isRecompileNeeded(newVal, oldVal)) { + console.info("reloading document due to state change..."); + await kernel.reloadDocument(); + } + }), + ); + + return store; +}; diff --git a/web-client/src/low/fetch.ts b/web-client/src/low/fetch.ts index 1309e031..17dcc114 100644 --- a/web-client/src/low/fetch.ts +++ b/web-client/src/low/fetch.ts @@ -1,9 +1,11 @@ -import { console, sleep } from "./utils"; +import { Result } from "pure/result"; + +import { sleep } from "./utils"; export function fetchAsBytes( url: string, options?: RequestInit, -): Promise { +): Promise> { return doFetch(url, options, async (response) => { const buffer = await response.arrayBuffer(); return new Uint8Array(buffer); @@ -13,7 +15,7 @@ export function fetchAsBytes( export function fetchAsString( url: string, options?: RequestInit, -): Promise { +): Promise> { return doFetch(url, options, (response) => { return response.text(); }); @@ -22,7 +24,7 @@ export function fetchAsString( export const fetchAsJson = ( url: string, options?: RequestInit, -): Promise => { +): Promise> => { return doFetch(url, options, (response) => { return response.json(); }); @@ -33,27 +35,27 @@ export const getApiUrl = (path: string) => { return API_PREFIX + path; }; -const doFetch = async ( +async function doFetch( url: string, options: RequestInit | undefined, handler: (response: Response) => Promise, -): Promise => { +): Promise> { const RETRY_COUNT = 3; let error: unknown; for (let i = 0; i < RETRY_COUNT; i++) { try { const response = await fetch(url, options); if (response.ok) { - return await handler(response); + const val = await handler(response); + return { val }; } } catch (e) { - console.error(e); error = e; await sleep(50); } } if (error) { - throw error; + return { err: error }; } - throw new Error("unknown error"); -}; + return { err: new Error("unknown error") }; +} diff --git a/web-client/src/main.ts b/web-client/src/main.ts new file mode 100644 index 00000000..208637b0 --- /dev/null +++ b/web-client/src/main.ts @@ -0,0 +1,5 @@ +import "./main.css"; +import { initAppRoot } from "ui/app"; +import { initKernel } from "core/kernel"; + +initKernel(initAppRoot); diff --git a/web-client/src/main.tsx b/web-client/src/main.tsx deleted file mode 100644 index 3888d2f3..00000000 --- a/web-client/src/main.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import "./main.css"; -import { initAppRoot } from "ui/app"; -import { Kernel } from "core/kernel"; - -const kernel = new Kernel(initAppRoot); -kernel.init(); diff --git a/web-client/src/ui/app/AppAlert.tsx b/web-client/src/ui/app/AppAlert.tsx index 9a2e2a52..1e40f670 100644 --- a/web-client/src/ui/app/AppAlert.tsx +++ b/web-client/src/ui/app/AppAlert.tsx @@ -27,7 +27,7 @@ export const AppAlert: React.FC = () => { alertCancelButton, alertExtraActions, } = useSelector(viewSelector); - const alertMgr = useKernel().getAlertMgr(); + const alertMgr = useKernel().alertMgr; const responseRef = useRef(false); const { RichAlertComponent } = alertMgr; if (!alertText && !RichAlertComponent) { diff --git a/web-client/src/ui/app/FluentProviderWrapper.tsx b/web-client/src/ui/app/FluentProviderWrapper.tsx index fd0b5895..3ef0ac82 100644 --- a/web-client/src/ui/app/FluentProviderWrapper.tsx +++ b/web-client/src/ui/app/FluentProviderWrapper.tsx @@ -5,16 +5,14 @@ import { } from "@fluentui/react-components"; import { PropsWithChildren } from "react"; -export type FluentProviderWrapperProps = PropsWithChildren<{ - isDarkMode: boolean; -}>; +import { isInDarkMode } from "low/utils"; -export const FluentProviderWrapper: React.FC = ({ - isDarkMode, +export const FluentProviderWrapper: React.FC = ({ children, }) => { + const dark = isInDarkMode(); return ( - + {children} ); diff --git a/web-client/src/ui/app/index.tsx b/web-client/src/ui/app/index.tsx index 96507d42..1112b58a 100644 --- a/web-client/src/ui/app/index.tsx +++ b/web-client/src/ui/app/index.tsx @@ -24,8 +24,6 @@ export const initAppRoot = ( kernel: Kernel, /// The redux store store: AppStore, - /// Whether the ui should render in dark mode - isDarkMode: boolean, ) => { const rootDiv = ReactRootDiv.get(); if (!rootDiv) { @@ -38,7 +36,7 @@ export const initAppRoot = ( - + diff --git a/web-client/src/ui/editor/EditorDropZone.tsx b/web-client/src/ui/editor/EditorDropZone.tsx index 37bd5b16..2e6b280c 100644 --- a/web-client/src/ui/editor/EditorDropZone.tsx +++ b/web-client/src/ui/editor/EditorDropZone.tsx @@ -41,7 +41,7 @@ export const EditorDropZone: React.FC = () => { e.preventDefault(); setIsDragging(false); setIsOpening(true); - const alertMgr = kernel.getAlertMgr(); + const { alertMgr } = kernel; const item = e.dataTransfer?.items[0]; if (!item) { @@ -63,7 +63,7 @@ export const EditorDropZone: React.FC = () => { return; } - await kernel.openProjectFileSystem(fs.val); + await kernel.asEdit().openProjectFileSystem(fs.val); setIsOpening(false); }} > diff --git a/web-client/src/ui/editor/EditorRoot.tsx b/web-client/src/ui/editor/EditorRoot.tsx index 9ad0949a..aa33cc65 100644 --- a/web-client/src/ui/editor/EditorRoot.tsx +++ b/web-client/src/ui/editor/EditorRoot.tsx @@ -8,9 +8,22 @@ import { EditorDropZone } from "./EditorDropZone"; import { EditorPanel } from "./EditorPanel"; export const EditorRoot: React.FC = () => { - const { rootPath } = useSelector(viewSelector); + const { rootPath, stageMode } = useSelector(viewSelector); const { editorMode } = useSelector(settingsSelector); + // TODO #207: split layout settings between view and edit mode + if (stageMode !== "edit") { + return ( + +

Web editor is not available because you are in view mode

+

+ Switch to the default layout or a layout without the editor + to hide this widget. +

+
+ ); + } + if (rootPath === undefined) { return ; } diff --git a/web-client/src/ui/editor/EditorTree.tsx b/web-client/src/ui/editor/EditorTree.tsx index ad414b90..66089362 100644 --- a/web-client/src/ui/editor/EditorTree.tsx +++ b/web-client/src/ui/editor/EditorTree.tsx @@ -24,7 +24,10 @@ export const EditorTree: React.FC = () => { /* eslint-disable react-hooks/exhaustive-deps*/ const listDir = useCallback( (path: string) => { - return kernel.getEditor()?.listDir(path) || Promise.resolve([]); + return ( + kernel.asEdit().getEditor()?.listDir(path) || + Promise.resolve([]) + ); }, [serial], ); @@ -67,7 +70,7 @@ export const EditorTree: React.FC = () => { level={0} listDir={listDir} onClickFile={async (path) => { - const editor = kernel.getEditor(); + const editor = kernel.asEdit().getEditor(); if (!editor) { return; } diff --git a/web-client/src/ui/map/MapVisualMgr.ts b/web-client/src/ui/map/MapVisualMgr.ts index a3626146..0e5e5ba3 100644 --- a/web-client/src/ui/map/MapVisualMgr.ts +++ b/web-client/src/ui/map/MapVisualMgr.ts @@ -426,7 +426,6 @@ class MapVisualGroup { (i === undefined || i < 0 || i >= this.sectionLayers.length) ) { // Index is invalid, we will keep the map empty - console.warn("Invalid section index: " + i); return; } if (mode === SectionMode.None) { diff --git a/web-client/src/ui/toolbar/Export.tsx b/web-client/src/ui/toolbar/Export.tsx index 80864cb2..29714b99 100644 --- a/web-client/src/ui/toolbar/Export.tsx +++ b/web-client/src/ui/toolbar/Export.tsx @@ -205,7 +205,7 @@ const runExportWizard = async ( while (true) { // show extra config dialog if needed if (enableConfig) { - const ok = await kernel.getAlertMgr().showRich({ + const ok = await kernel.alertMgr.showRich({ title: "Export", component: () => { return ( @@ -349,7 +349,7 @@ async function runExportAndShowDialog( config: string, ): Promise { let cancelled = false; - const result = await kernel.getAlertMgr().showBlocking( + const result = await kernel.alertMgr.showBlocking( { title: "Export", component: () => { @@ -371,7 +371,7 @@ async function runExportAndShowDialog( if ("err" in request) { return errstr(request.err); } - const expoDoc = await kernel.export(request.val); + const expoDoc = await kernel.exportDocument(request.val); if (cancelled) { return ""; } diff --git a/web-client/src/ui/toolbar/Header.tsx b/web-client/src/ui/toolbar/Header.tsx index 55582e08..4773ca96 100644 --- a/web-client/src/ui/toolbar/Header.tsx +++ b/web-client/src/ui/toolbar/Header.tsx @@ -30,7 +30,6 @@ import React, { PropsWithChildren, useMemo } from "react"; import { useSelector } from "react-redux"; import { documentSelector, settingsSelector, viewSelector } from "core/store"; -import { preloadedDocumentTitle } from "core/doc"; import type { ExecDoc } from "low/celerc"; import { getHeaderControls } from "./getHeaderControls"; @@ -121,7 +120,7 @@ function useTitle( } // viewer if (compileInProgress) { - return preloadedDocumentTitle || "Loading..."; + return window.document.title || "Loading..."; } // if in view mode, but is not loading (e.g. user cancelled the loading) // return the viewer title diff --git a/web-client/src/ui/toolbar/OpenCloseProject.tsx b/web-client/src/ui/toolbar/OpenCloseProject.tsx index a24b138b..53bef148 100644 --- a/web-client/src/ui/toolbar/OpenCloseProject.tsx +++ b/web-client/src/ui/toolbar/OpenCloseProject.tsx @@ -42,13 +42,13 @@ const useOpenCloseProjectControl = () => { const handler = useCallback(async () => { if (rootPath) { // close - const editor = kernel.getEditor(); + const editor = kernel.asEdit().getEditor(); if (!editor) { return; } if (await editor.hasUnsavedChanges()) { - const yes = await kernel.getAlertMgr().show({ + const yes = await kernel.alertMgr.show({ title: "Unsaved changes", message: "There are unsaved changes in the editor. Continue closing will discard all changes. Are you sure you want to continue?", @@ -60,18 +60,18 @@ const useOpenCloseProjectControl = () => { } } - await kernel.closeProjectFileSystem(); + await kernel.asEdit().closeProjectFileSystem(); } else { // open // only import editor when needed, since // header controls are initialized in view mode as well const { createRetryOpenHandler } = await import("core/editor"); - const retryHandler = createRetryOpenHandler(kernel.getAlertMgr()); + const retryHandler = createRetryOpenHandler(kernel.alertMgr); const fs = await fsOpenReadWrite(retryHandler); if (fs.err) { return; } - await kernel.openProjectFileSystem(fs.val); + await kernel.asEdit().openProjectFileSystem(fs.val); } }, [kernel, rootPath]); diff --git a/web-client/src/ui/toolbar/SaveProject.tsx b/web-client/src/ui/toolbar/SaveProject.tsx index c9933b5c..60ff02b4 100644 --- a/web-client/src/ui/toolbar/SaveProject.tsx +++ b/web-client/src/ui/toolbar/SaveProject.tsx @@ -77,7 +77,7 @@ const useSaveProjectControl = () => { ); const handler = useCallback(async () => { - const editor = kernel.getEditor(); + const editor = kernel.asEdit().getEditor(); if (!editor) { return; } diff --git a/web-client/src/ui/toolbar/SyncProject.tsx b/web-client/src/ui/toolbar/SyncProject.tsx index ea6b4e39..f28b28c6 100644 --- a/web-client/src/ui/toolbar/SyncProject.tsx +++ b/web-client/src/ui/toolbar/SyncProject.tsx @@ -59,7 +59,7 @@ const useSyncProjectControl = () => { const tooltip = getTooltip(isOpened, loadInProgress, lastLoadError); const handler = useCallback(async () => { - const editor = kernel.getEditor(); + const editor = kernel.asEdit().getEditor(); if (!editor) { return; } diff --git a/web-client/src/ui/toolbar/settings/EditorSettings.tsx b/web-client/src/ui/toolbar/settings/EditorSettings.tsx index 77b89d72..cfcb25d6 100644 --- a/web-client/src/ui/toolbar/settings/EditorSettings.tsx +++ b/web-client/src/ui/toolbar/settings/EditorSettings.tsx @@ -55,7 +55,7 @@ export const EditorSettings: React.FC = () => { setEntryPoints([]); return; } - const compiler = await kernel.getCompiler(); + const compiler = await kernel.asEdit().ensureCompiler(); const result = await compiler.getEntryPoints(); if ("err" in result) { setEntryPoints([]); diff --git a/web-client/src/ui/toolbar/settings/MetaSettings.tsx b/web-client/src/ui/toolbar/settings/MetaSettings.tsx index 3d9931f1..5bc06326 100644 --- a/web-client/src/ui/toolbar/settings/MetaSettings.tsx +++ b/web-client/src/ui/toolbar/settings/MetaSettings.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import { useSelector } from "react-redux"; import { documentSelector, settingsActions, viewSelector } from "core/store"; import { fetchAsString, getApiUrl } from "low/fetch"; +import { console } from "low/utils"; import { useActions } from "low/store"; import { SettingsSection } from "./SettingsSection"; @@ -25,16 +26,18 @@ export const MetaSettings: React.FC = () => { const [serverVersion, setServerVersion] = useState("Loading..."); useEffect(() => { const fetchVersion = async () => { - try { - const version = await fetchAsString(getApiUrl("/version")); - if (version.split(" ", 3).length === 3) { - setServerVersion("Cannot read version"); - } else { - setServerVersion(version); - } - } catch { + const version = await fetchAsString(getApiUrl("/version")); + if ("err" in version) { + console.error(version.err); setServerVersion("Cannot read version"); + return; } + const { val } = version; + if (val.split(" ", 3).length === 3) { + setServerVersion("Cannot read version"); + return; + } + setServerVersion(val); }; fetchVersion(); }, [stageMode]); diff --git a/web-client/src/ui/toolbar/settings/PluginSettings.tsx b/web-client/src/ui/toolbar/settings/PluginSettings.tsx index 20a490f6..857f803c 100644 --- a/web-client/src/ui/toolbar/settings/PluginSettings.tsx +++ b/web-client/src/ui/toolbar/settings/PluginSettings.tsx @@ -181,7 +181,7 @@ const editUserPluginConfig = async ( let config = userPluginConfig; let { err } = parseUserConfigOptions(config, document?.project.title); while (true) { - const response = await kernel.getAlertMgr().showRich({ + const response = await kernel.alertMgr.showRich({ title: "User Plugins", component: () => { return ( @@ -189,7 +189,7 @@ const editUserPluginConfig = async ( initialError={err} initialValue={config} onChange={(x) => { - kernel.getAlertMgr().modifyActions({ + kernel.alertMgr.modifyActions({ extraActions: [], }); config = x;