Browse Source

GUI - Frontend Decoupling (#446) (#613)

dinhlongnguyen 1 year ago
parent
commit
e0ba951e10

+ 1 - 0
frontend/taipy-gui/.gitignore

@@ -30,3 +30,4 @@ yarn-error.log*
 
 # types generation
 /extension-types
+.env

+ 1 - 0
frontend/taipy-gui/base/.gitignore

@@ -0,0 +1 @@
+dist

+ 102 - 0
frontend/taipy-gui/base/src/app.ts

@@ -0,0 +1,102 @@
+import { sendWsMessage } from "../../src/context/wsUtils";
+
+import { Socket, io } from "socket.io-client";
+import { VariableManager } from "./variableManager";
+import { initSocket } from "./utils";
+
+export type OnInitHandler = (appManager: TaipyApp) => void;
+export type OnChangeHandler = (appManager: TaipyApp, encodedName: string, value: unknown) => void;
+
+export class TaipyApp {
+    socket: Socket;
+    _onInit: OnInitHandler | undefined;
+    _onChange: OnChangeHandler | undefined;
+    variableManager: VariableManager | undefined;
+    clientId: string;
+    context: string;
+    path: string | undefined;
+
+    constructor(
+        onInit: OnInitHandler | undefined = undefined,
+        onChange: OnChangeHandler | undefined = undefined,
+        path: string | undefined = undefined,
+        socket: Socket | undefined = undefined
+    ) {
+        socket = socket || io("/", { autoConnect: false });
+        this.onInit = onInit;
+        this.onChange = onChange;
+        this.variableManager = undefined;
+        this.clientId = "";
+        this.context = "";
+        this.path = path;
+        this.socket = socket;
+        initSocket(socket, this);
+    }
+
+    // Getter and setter
+    get onInit() {
+        return this._onInit;
+    }
+    set onInit(handler: OnInitHandler | undefined) {
+        if (handler !== undefined && handler?.length !== 1) {
+            throw new Error("onInit function requires 1 parameter")
+        }
+        this._onInit = handler
+    }
+
+    get onChange() {
+        return this._onChange;
+    }
+    set onChange(handler: OnChangeHandler | undefined) {
+        if (handler !== undefined && handler?.length !== 3) {
+            throw new Error("onChange function requires 3 parameters")
+        }
+        this._onChange = handler
+    }
+
+    // Public methods
+    getEncodedName(varName: string, module: string) {
+        return this.variableManager?.getEncodedName(varName, module);
+    }
+
+    getName(encodedName: string) {
+        return this.variableManager?.getName(encodedName);
+    }
+
+    get(varName: string) {
+        return this.variableManager?.get(varName);
+    }
+
+    getInfo(encodedName: string) {
+        return this.variableManager?.getInfo(encodedName);
+    }
+
+    getDataTree() {
+        return this.variableManager?.getDataTree();
+    }
+
+    getAllData() {
+        return this.variableManager?.getAllData();
+    }
+
+    // This update will only send the request to Taipy Gui backend
+    // the actual update will be handled when the backend responds
+    update(encodedName: string, value: unknown) {
+        sendWsMessage(this.socket, "U", encodedName, { value: value }, this.clientId, this.context);
+    }
+
+    getContext() {
+        return this.context;
+    }
+
+    updateContext(path: string | undefined = "") {
+        if (!path || path === "") {
+            path = window.location.pathname.slice(1);
+        }
+        sendWsMessage(this.socket, "GMC", "get_module_context", { path: path }, this.clientId);
+    }
+}
+
+export const createApp = (onInit?: OnInitHandler, onChange?: OnChangeHandler, path?: string, socket?: Socket) => {
+    return new TaipyApp(onInit, onChange, path, socket);
+};

+ 6 - 0
frontend/taipy-gui/base/src/index.ts

@@ -0,0 +1,6 @@
+import { TaipyApp, createApp, OnChangeHandler, OnInitHandler } from "./app";
+import { VariableModuleData } from "./variableManager";
+
+export default TaipyApp;
+export { TaipyApp, createApp };
+export type { OnChangeHandler, OnInitHandler, VariableModuleData };

+ 72 - 0
frontend/taipy-gui/base/src/utils.ts

@@ -0,0 +1,72 @@
+import { Socket } from "socket.io-client";
+import { IdMessage, getLocalStorageValue, storeClientId } from "../../src/context/utils";
+import { TAIPY_CLIENT_ID, WsMessage, sendWsMessage } from "../../src/context/wsUtils";
+import { TaipyApp } from "./app";
+import { VariableManager, VariableModuleData } from "./variableManager";
+
+interface MultipleUpdatePayload {
+    name: string;
+    payload: { value: unknown };
+}
+
+export const initSocket = (socket: Socket, appManager: TaipyApp) => {
+    socket.on("connect", () => {
+        const id = getLocalStorageValue(TAIPY_CLIENT_ID, "");
+        sendWsMessage(socket, "ID", TAIPY_CLIENT_ID, id, id, undefined, false);
+        if (id !== "") {
+            appManager.clientId = id;
+            appManager.updateContext(appManager.path);
+        }
+    });
+    // try to reconnect on connect_error
+    socket.on("connect_error", () => {
+        setTimeout(() => {
+            socket && socket.connect();
+        }, 500);
+    });
+    // try to reconnect on server disconnection
+    socket.on("disconnect", (reason) => {
+        if (reason === "io server disconnect") {
+            socket && socket.connect();
+        }
+    });
+    // handle message data from backend
+    socket.on("message", (message: WsMessage) => {
+        processIncomingMessage(message, appManager);
+    });
+    // only now does the socket tries to open/connect
+    if (!socket.connected) {
+        socket.connect();
+    }
+};
+
+export const processIncomingMessage = (message: WsMessage, appManager: TaipyApp) => {
+    if (message.type) {
+        if (message.type === "MU" && Array.isArray(message.payload)) {
+            for (const muPayload of message.payload as [MultipleUpdatePayload]) {
+                const encodedName = muPayload.name;
+                const value = muPayload.payload.value;
+                appManager.variableManager?.update(encodedName, value);
+                appManager.onChange && appManager.onChange(appManager, encodedName, value);
+            }
+        } else if (message.type === "ID") {
+            const id = (message as unknown as IdMessage).id;
+            storeClientId(id);
+            appManager.clientId = id;
+            appManager.updateContext(appManager.path);
+        } else if (message.type === "GMC") {
+            const mc = (message.payload as Record<string, unknown>).data as string;
+            window.localStorage.setItem("ModuleContext", mc);
+            appManager.context = mc;
+            sendWsMessage(appManager.socket, "GVS", "get_variables", {}, appManager.clientId, appManager.context);
+        } else if (message.type === "GVS") {
+            const variableData = (message.payload as Record<string, unknown>).data as VariableModuleData;
+            if (appManager.variableManager) {
+                appManager.variableManager.resetInitData(variableData);
+            } else {
+                appManager.variableManager = new VariableManager(variableData);
+                appManager.onInit && appManager.onInit(appManager);
+            }
+        }
+    }
+};

+ 92 - 0
frontend/taipy-gui/base/src/variableManager.ts

@@ -0,0 +1,92 @@
+export interface VariableModuleData {
+    [key: string]: VariableName;
+}
+
+interface VariableName {
+    [key: string]: VariableData;
+}
+
+interface VariableData {
+    type: string;
+    value: unknown;
+    encoded_name: string;
+}
+
+// This class hold the information of variables and real-time value of variables
+export class VariableManager {
+    // key: encoded name, value: real-time value
+    _data: Record<string, unknown>;
+    // Initial data fetched from taipy-gui backend
+    _variables: VariableModuleData;
+
+    constructor(variableModuleData: VariableModuleData) {
+        this._data = {};
+        this._variables = {};
+        this.resetInitData(variableModuleData);
+    }
+
+    resetInitData(variableModuleData: VariableModuleData) {
+        this._variables = variableModuleData;
+        this._data = {};
+        for (const context in this._variables) {
+            for (const variable in this._variables[context]) {
+                const vData = this._variables[context][variable];
+                this._data[vData["encoded_name"]] = vData.value;
+            }
+        }
+    }
+
+    getEncodedName(varName: string, module: string): string | undefined {
+        if (module in this._variables && varName in this._variables[module]) {
+            return this._variables[module][varName].encoded_name;
+        }
+        return undefined;
+    }
+
+    // return [name, moduleName]
+    getName(encodedName: string): [string, string] | undefined {
+        for (const context in this._variables) {
+            for (const variable in this._variables[context]) {
+                const vData = this._variables[context][variable];
+                if (vData.encoded_name === encodedName) {
+                    return [variable, context];
+                }
+            }
+        }
+        return undefined;
+    }
+
+    get(encodedName: string): unknown {
+        if (!(encodedName in this._data)) {
+            throw new Error(`${encodedName} is not available in Taipy Gui`);
+        }
+        return this._data[encodedName];
+    }
+
+    getInfo(encodedName: string): VariableData | undefined {
+        for (const context in this._variables) {
+            for (const variable in this._variables[context]) {
+                const vData = this._variables[context][variable];
+                if (vData.encoded_name === encodedName) {
+                    return { ...vData, value: this._data[encodedName] };
+                }
+            }
+        }
+        return undefined;
+    }
+
+    getDataTree(): VariableModuleData {
+        return this._variables;
+    }
+
+    getAllData(): Record<string, unknown> {
+        return this._data;
+    }
+
+    update(encodedName: string, value: unknown) {
+        if (!(encodedName in this._data)) {
+            throw new Error(`${encodedName} is not available in Taipy Gui`);
+        }
+        this._data[encodedName] = value;
+    }
+}

+ 28 - 0
frontend/taipy-gui/base/tsconfig.json

@@ -0,0 +1,28 @@
+{
+    "compilerOptions": {
+      "target": "es2015",
+      "lib": [
+        "dom",
+        "dom.iterable",
+        "esnext"
+      ],
+      "outDir": "./dist/",
+      "sourceMap": true,
+      "allowJs": true,
+      "skipLibCheck": true,
+      "esModuleInterop": true,
+      "allowSyntheticDefaultImports": true,
+      "strict": true,
+      "forceConsistentCasingInFileNames": true,
+      "noFallthroughCasesInSwitch": true,
+      "module": "esnext",
+      "moduleResolution": "node",
+      "resolveJsonModule": true,
+      "isolatedModules": true,
+      "noEmit": false,
+      "jsx": "react-jsx",
+    },
+    "include": [
+      "src"
+    ]
+  }

+ 43 - 0
frontend/taipy-gui/base/webpack.config.js

@@ -0,0 +1,43 @@
+const path = require("path");
+const webpack = require("webpack");
+
+const moduleName = "TaipyGuiBase";
+
+module.exports = {
+    target: "web",
+    entry: "./base/src/index.ts",
+    output: {
+        filename: "taipy-gui-base.js",
+        path: path.resolve(__dirname, "dist"),
+        globalObject: "this",
+        library: {
+            name: moduleName,
+            type: "umd",
+        },
+    },
+    plugins: [
+        new webpack.optimize.LimitChunkCountPlugin({
+            maxChunks: 1,
+        }),
+    ],
+    module: {
+        rules: [
+            {
+                test: /\.tsx?$/,
+                use: "ts-loader",
+                exclude: /node_modules/,
+            },
+        ],
+    },
+    resolve: {
+        extensions: [".tsx", ".ts", ".js", ".tsx"],
+    },
+    // externals: {
+    //     "socket.io-client": {
+    //         commonjs: "socket.io-client",
+    //         commonjs2: "socket.io-client",
+    //         amd: "socket.io-client",
+    //         root: "_",
+    //     },
+    // },
+};

+ 1 - 0
frontend/taipy-gui/dom/package-lock.json

@@ -14,6 +14,7 @@
       }
     },
     "../packaging": {
+      "name": "taipy-gui",
       "version": "3.1.0"
     },
     "node_modules/js-tokens": {

+ 1 - 0
frontend/taipy-gui/package.json

@@ -40,6 +40,7 @@
     "start": "echo no start see python",
     "build:dev": "webpack --mode development",
     "build": "webpack --mode production",
+    "build-base": "webpack --mode production --config ./base/webpack.config.js",
     "test": "cross-env TZ=UTC jest",
     "lint": "eslint --ext .ts,.tsx",
     "lint:fix": "npm run lint -- --fix",

+ 3 - 0
frontend/taipy-gui/src/components/Taipy/Navigate.tsx

@@ -38,6 +38,9 @@ const Navigate = ({ to, params, tab, force }: NavigateProps) => {
                     navigate(0);
                 } else {
                     navigate({ pathname: to, search: `?${searchParams.toString()}` });
+                    if (searchParams.has("tprh")) {
+                        navigate(0);
+                    }
                 }
             } else {
                 window.open(`${to}?${searchParams.toString()}`, tab || "_blank")?.focus();

+ 3 - 2
frontend/taipy-gui/src/components/Taipy/ThemeToggle.tsx

@@ -22,8 +22,9 @@ import Brightness3 from "@mui/icons-material/Brightness3";
 
 import { TaipyActiveProps } from "./utils";
 import { TaipyContext } from "../../context/taipyContext";
-import { createThemeAction, getLocalStorageValue } from "../../context/taipyReducers";
+import { createThemeAction } from "../../context/taipyReducers";
 import { useClassNames } from "../../utils/hooks";
+import { getLocalStorageValue } from "../../context/utils";
 
 interface ThemeToggleProps extends TaipyActiveProps {
     style?: CSSProperties;
@@ -42,7 +43,7 @@ const boxSx = {
     },
 } as CSSProperties;
 
-const groupSx = {verticalAlign: "middle"};
+const groupSx = { verticalAlign: "middle" };
 
 const ThemeToggle = (props: ThemeToggleProps) => {
     const { id, label = "Mode", style = {}, active = true } = props;

+ 2 - 50
frontend/taipy-gui/src/context/taipyReducers.ts

@@ -15,14 +15,15 @@ import { Dispatch } from "react";
 import { PaletteMode } from "@mui/material";
 import { createTheme, Theme } from "@mui/material/styles";
 import { io, Socket } from "socket.io-client";
-import { v4 as uuidv4 } from "uuid";
 import merge from "lodash/merge";
 
+import { TAIPY_CLIENT_ID, WsMessage, sendWsMessage } from "./wsUtils";
 import { getBaseURL, TIMEZONE_CLIENT } from "../utils";
 import { parseData } from "../utils/dataFormat";
 import { MenuProps } from "../utils/lov";
 import { FilterDesc } from "../components/Taipy/TableFilter";
 import { stylekitModeThemes, stylekitTheme } from "../themes/stylekit";
+import { getLocalStorageValue, storeClientId, IdMessage } from "./utils";
 
 enum Types {
     SocketConnected = "SOCKET_CONNECTED",
@@ -126,10 +127,6 @@ interface NavigateMessage {
 
 interface TaipyNavigateAction extends TaipyBaseAction, NavigateMessage {}
 
-interface IdMessage {
-    id: string;
-}
-
 export interface FileDownloadProps {
     content?: string;
     name?: string;
@@ -185,13 +182,6 @@ const themes = {
     dark: getUserTheme("dark"),
 };
 
-export const getLocalStorageValue = <T = string>(key: string, defaultValue: T, values?: T[]) => {
-    const val = localStorage && (localStorage.getItem(key) as unknown as T);
-    return !val ? defaultValue : !values ? val : values.indexOf(val) == -1 ? defaultValue : val;
-};
-
-const TAIPY_CLIENT_ID = "TaipyClientId";
-
 export const INITIAL_STATE: TaipyState = {
     data: {},
     theme: window.taipyConfig?.darkMode ? themes.dark : themes.light,
@@ -213,8 +203,6 @@ export const taipyInitialize = (initialState: TaipyState): TaipyState => ({
     socket: io("/", { autoConnect: false, path: `${getBaseURL()}socket.io` }),
 });
 
-const storeClientId = (id: string) => localStorage && localStorage.setItem(TAIPY_CLIENT_ID, id);
-
 const messageToAction = (message: WsMessage) => {
     if (message.type) {
         if (message.type === "MU" && Array.isArray(message.payload)) {
@@ -822,39 +810,3 @@ export const createPartialAction = (name: string, create: boolean): TaipyPartial
     name,
     create,
 });
-
-type WsMessageType = "A" | "U" | "DU" | "MU" | "RU" | "AL" | "BL" | "NA" | "ID" | "MS" | "DF" | "PR" | "ACK";
-
-interface WsMessage {
-    type: WsMessageType;
-    name: string;
-    payload: Record<string, unknown> | unknown;
-    propagate: boolean;
-    client_id: string;
-    module_context: string;
-    ack_id?: string;
-}
-
-const sendWsMessage = (
-    socket: Socket | undefined,
-    type: WsMessageType,
-    name: string,
-    payload: Record<string, unknown> | unknown,
-    id: string,
-    moduleContext = "",
-    propagate = true,
-    serverAck?: (val: unknown) => void
-): string => {
-    const ackId = uuidv4();
-    const msg: WsMessage = {
-        type: type,
-        name: name,
-        payload: payload,
-        propagate: propagate,
-        client_id: id,
-        ack_id: ackId,
-        module_context: moduleContext,
-    };
-    socket?.emit("message", msg, serverAck);
-    return ackId;
-};

+ 12 - 0
frontend/taipy-gui/src/context/utils.ts

@@ -0,0 +1,12 @@
+import { TAIPY_CLIENT_ID } from "./wsUtils";
+
+export const getLocalStorageValue = <T = string>(key: string, defaultValue: T, values?: T[]) => {
+    const val = localStorage && (localStorage.getItem(key) as unknown as T);
+    return !val ? defaultValue : !values ? val : values.indexOf(val) == -1 ? defaultValue : val;
+};
+
+export const storeClientId = (id: string) => localStorage && localStorage.setItem(TAIPY_CLIENT_ID, id);
+
+export interface IdMessage {
+    id: string;
+}

+ 40 - 0
frontend/taipy-gui/src/context/wsUtils.ts

@@ -0,0 +1,40 @@
+import { Socket } from "socket.io-client";
+import { v4 as uuidv4 } from "uuid";
+
+export const TAIPY_CLIENT_ID = "TaipyClientId";
+
+export type WsMessageType = "A" | "U" | "DU" | "MU" | "RU" | "AL" | "BL" | "NA" | "ID" | "MS" | "DF" | "PR" | "ACK" | "GMC" | "GVS";
+
+export interface WsMessage {
+    type: WsMessageType;
+    name: string;
+    payload: Record<string, unknown> | unknown;
+    propagate: boolean;
+    client_id: string;
+    module_context: string;
+    ack_id?: string;
+}
+
+export const sendWsMessage = (
+    socket: Socket | undefined,
+    type: WsMessageType,
+    name: string,
+    payload: Record<string, unknown> | unknown,
+    id: string,
+    moduleContext = "",
+    propagate = true,
+    serverAck?: (val: unknown) => void
+): string => {
+    const ackId = uuidv4();
+    const msg: WsMessage = {
+        type: type,
+        name: name,
+        payload: payload,
+        propagate: propagate,
+        client_id: id,
+        ack_id: ackId,
+        module_context: moduleContext,
+    };
+    socket?.emit("message", msg, serverAck);
+    return ackId;
+};

+ 39 - 0
frontend/taipy-gui/webpack.config.js

@@ -27,6 +27,7 @@ const taipyBundle = "taipy-gui"
 
 const reactBundleName = "TaipyGuiDependencies"
 const taipyBundleName = "TaipyGui"
+const taipyGuiBaseBundleName = "TaipyGuiBase"
 
 const basePath = "../../taipy/gui/webapp";
 const webAppPath = resolveApp(basePath);
@@ -167,5 +168,43 @@ module.exports = (env, options) => {
                     hash: true
                 }]),
             ],
+    },
+    {
+        mode: options.mode,
+        entry: ["./base/src/index.ts"],
+        output: {
+            filename: "taipy-gui-base.js",
+            path: webAppPath,
+            globalObject: "this",
+            library: {
+                name: taipyGuiBaseBundleName,
+                type: "umd",
+            },
+        },
+        plugins: [
+            new webpack.optimize.LimitChunkCountPlugin({
+                maxChunks: 1,
+            }),
+        ],
+        module: {
+            rules: [
+                {
+                    test: /\.tsx?$/,
+                    use: "ts-loader",
+                    exclude: /node_modules/,
+                },
+            ],
+        },
+        resolve: {
+            extensions: [".tsx", ".ts", ".js", ".tsx"],
+        },
+        // externals: {
+        //     "socket.io-client": {
+        //         commonjs: "socket.io-client",
+        //         commonjs2: "socket.io-client",
+        //         amd: "socket.io-client",
+        //         root: "_",
+        //     },
+        // },
     }];
 };

+ 12 - 0
taipy/gui/custom/__init__.py

@@ -0,0 +1,12 @@
+# Copyright 2023 Avaiga Private Limited
+#
+# 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.
+
+from ._page import Page, ResourceHandler

+ 87 - 0
taipy/gui/custom/_page.py

@@ -0,0 +1,87 @@
+# Copyright 2023 Avaiga Private Limited
+#
+# 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.
+
+from __future__ import annotations
+
+import typing as t
+from abc import ABC, abstractmethod
+
+from ..page import Page as BasePage
+from ..utils.singleton import _Singleton
+
+if t.TYPE_CHECKING:
+    from ..gui import Gui
+
+
+class Page(BasePage):
+    """NOT DOCUMENTED
+    A custom page for external application that can be added to Taipy GUI"""
+
+    def __init__(self, resource_handler: ResourceHandler, binding_variables: t.Optional[t.List[str]] = None, **kwargs) -> None:
+        if binding_variables is None:
+            binding_variables = []
+        super().__init__(**kwargs)
+        self._resource_handler = resource_handler
+        self._binding_variables = binding_variables
+
+
+class ResourceHandler(ABC):
+    """NOT DOCUMENTED
+    Resource handler for custom pages.
+
+    User can implement this class to provide custom resources for the custom pages
+    """
+
+    id: str = ""
+
+    def __init__(self) -> None:
+        _ExternalResourceHandlerManager().register(self)
+
+    def get_id(self) -> str:
+        return self.id if id != "" else str(id(self))
+
+    @abstractmethod
+    def get_resources(self, path: str, base_bundle_path: str) -> t.Any:
+        raise NotImplementedError
+
+
+class _ExternalResourceHandlerManager(object, metaclass=_Singleton):
+    """NOT DOCUMENTED
+    Manager for resource handlers.
+
+    This class is used to manage resource handlers for custom pages
+    """
+
+    def __init__(self) -> None:
+        self.__handlers: t.Dict[str, ResourceHandler] = {}
+
+    def register(self, handler: ResourceHandler) -> None:
+        """Register a resource handler
+        Arguments:
+            handler (ResourceHandler): The resource handler to register
+        """
+        self.__handlers[handler.get_id()] = handler
+
+    def get(self, id: str) -> t.Optional[ResourceHandler]:
+        """Get a resource handler by its id
+        Arguments:
+            id (str): The id of the resource handler
+        Returns:
+            ResourceHandler: The resource handler
+        """
+        return self.__handlers.get(id, None)
+
+    def get_all(self) -> t.List[ResourceHandler]:
+        """Get all resource handlers
+        Returns:
+            List[ResourceHandler]: The list of resource handlers
+        """
+        return list(self.__handlers.values())

+ 98 - 8
taipy/gui/gui.py

@@ -25,12 +25,12 @@ import typing as t
 import warnings
 from importlib import metadata, util
 from importlib.util import find_spec
-from types import FrameType, SimpleNamespace
+from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
 from urllib.parse import unquote, urlencode, urlparse
 
 import markdown as md_lib
 import tzlocal
-from flask import Blueprint, Flask, g, jsonify, request, send_file, send_from_directory
+from flask import Blueprint, Flask, g, has_app_context, jsonify, request, send_file, send_from_directory
 from werkzeug.utils import secure_filename
 
 import __main__  # noqa: F401
@@ -49,6 +49,7 @@ from ._renderers.utils import _get_columns_dict
 from ._warnings import TaipyGuiWarning, _warn
 from .builder import _ElementApiGenerator
 from .config import Config, ConfigParameter, _Config
+from .custom import Page as CustomPage
 from .data.content_accessor import _ContentAccessor
 from .data.data_accessor import _DataAccessor, _DataAccessors
 from .data.data_format import _DataFormat
@@ -541,6 +542,8 @@ class Gui:
     def __set_client_id_in_context(self, client_id: t.Optional[str] = None, force=False):
         if not client_id and request:
             client_id = request.args.get(Gui.__ARG_CLIENT_ID, "")
+        if not client_id and (ws_client_id := getattr(g, "ws_client_id", None)):
+            client_id = ws_client_id
         if not client_id and force:
             res = self._bindings()._get_or_create_scope("")
             client_id = res[0] if res[1] else None
@@ -587,10 +590,12 @@ class Gui:
             if msg_type == _WsType.CLIENT_ID.value:
                 res = self._bindings()._get_or_create_scope(message.get("payload", ""))
                 client_id = res[0] if res[1] else None
-            self.__set_client_id_in_context(client_id or message.get(Gui.__ARG_CLIENT_ID))
+            expected_client_id = client_id or message.get(Gui.__ARG_CLIENT_ID)
+            self.__set_client_id_in_context(expected_client_id)
+            g.ws_client_id = expected_client_id
             with self._set_locals_context(message.get("module_context") or None):
+                payload = message.get("payload", {})
                 if msg_type == _WsType.UPDATE.value:
-                    payload = message.get("payload", {})
                     self.__front_end_update(
                         str(message.get("name")),
                         payload.get("value"),
@@ -604,6 +609,10 @@ class Gui:
                     self.__request_data_update(str(message.get("name")), message.get("payload"))
                 elif msg_type == _WsType.REQUEST_UPDATE.value:
                     self.__request_var_update(message.get("payload"))
+                elif msg_type == _WsType.GET_MODULE_CONTEXT.value:
+                    self.__handle_ws_get_module_context(payload)
+                elif msg_type == _WsType.GET_VARIABLES.value:
+                    self.__handle_ws_get_variables()
             self.__send_ack(message.get("ack_id"))
         except Exception as e:  # pragma: no cover
             _warn(f"Decoding Message has failed: {message}", e)
@@ -1030,6 +1039,54 @@ class Gui:
                     )
             self.__send_var_list_update(payload["names"])
 
+    def __handle_ws_get_module_context(self, payload: t.Any):
+        if isinstance(payload, dict):
+            # Get Module Context
+            if mc := self._get_page_context(str(payload.get("path"))):
+                self._bind_custom_page_variables(
+                    self._get_page(str(payload.get("path")))._renderer, self._get_client_id()
+                )
+                self.__send_ws(
+                    {
+                        "type": _WsType.GET_MODULE_CONTEXT.value,
+                        "payload": {"data": mc},
+                    }
+                )
+
+    def __handle_ws_get_variables(self):
+        # Get Variables
+        self.__pre_render_pages()
+        # Module Context -> Variable -> Variable data (name, type, initial_value)
+        variable_tree: t.Dict[str, t.Dict[str, t.Dict[str, t.Any]]] = {}
+        data = vars(self._bindings()._get_data_scope())
+        data = {
+            k: v
+            for k, v in data.items()
+            if not k.startswith("_")
+            and not callable(v)
+            and "TpExPr" not in k
+            and not isinstance(v, (ModuleType, FunctionType, LambdaType, type, Page))
+        }
+        for k, v in data.items():
+            if isinstance(v, _TaipyBase):
+                data[k] = v.get()
+            var_name, var_module_name = _variable_decode(k)
+            if var_module_name == "" or var_module_name is None:
+                var_module_name = "__main__"
+            if var_module_name not in variable_tree:
+                variable_tree[var_module_name] = {}
+            variable_tree[var_module_name][var_name] = {
+                "type": type(v).__name__,
+                "value": data[k],
+                "encoded_name": k,
+            }
+        self.__send_ws(
+            {
+                "type": _WsType.GET_VARIABLES.value,
+                "payload": {"data": variable_tree},
+            }
+        )
+
     def __send_ws(self, payload: dict, allow_grouping=True) -> None:
         grouping_message = self.__get_message_grouping() if allow_grouping else None
         if grouping_message is None:
@@ -1871,10 +1928,12 @@ class Gui:
         for page in self._config.pages:
             if page is not None:
                 with contextlib.suppress(Exception):
-                    page.render(self)
+                    if isinstance(page._renderer, CustomPage):
+                        self._bind_custom_page_variables(page._renderer, self._get_client_id())
+                    else:
+                        page.render(self)
 
-    def __render_page(self, page_name: str) -> t.Any:
-        self.__set_client_id_in_context()
+    def _get_navigated_page(self, page_name: str) -> t.Any:
         nav_page = page_name
         if hasattr(self, "on_navigate") and callable(self.on_navigate):
             try:
@@ -1895,8 +1954,26 @@ class Gui:
             except Exception as e:  # pragma: no cover
                 if not self._call_on_exception("on_navigate", e):
                     _warn("Exception raised in on_navigate()", e)
-        page = next((page_i for page_i in self._config.pages if page_i._route == nav_page), None)
+        return nav_page
+
+    def _get_page(self, page_name: str):
+        return next((page_i for page_i in self._config.pages if page_i._route == page_name), None)
 
+    def _bind_custom_page_variables(self, page: CustomPage, client_id: t.Optional[str]):
+        """Handle the bindings of custom page variables"""
+        with self.get_flask_app().app_context() if has_app_context() else contextlib.nullcontext():
+            self.__set_client_id_in_context(client_id)
+            with self._set_locals_context(page._get_module_name()):
+                for k in self._get_locals_bind().keys():
+                    if (not page._binding_variables or k in page._binding_variables) and not k.startswith("_"):
+                        self._bind_var(k)
+
+    def __render_page(self, page_name: str) -> t.Any:
+        self.__set_client_id_in_context()
+        nav_page = self._get_navigated_page(page_name)
+        if not isinstance(nav_page, str):
+            return nav_page
+        page = self._get_page(nav_page)
         # Try partials
         if page is None:
             page = self._get_partial(nav_page)
@@ -1907,6 +1984,19 @@ class Gui:
                 400,
                 {"Content-Type": "application/json; charset=utf-8"},
             )
+        # Handle custom pages
+        if (pr := page._renderer) is not None and isinstance(pr, CustomPage):
+            if self._navigate(
+                to=page_name,
+                params={
+                    _Server._RESOURCE_HANDLER_ARG: pr._resource_handler.get_id(),
+                },
+            ):
+                # Proactively handle the bindings of custom page variables
+                self._bind_custom_page_variables(pr, self._get_client_id())
+                return ("Successfully redirect to custom resource handler", 200)
+            return ("Failed to navigate to custom resource handler", 500)
+        # Handle page rendering
         context = page.render(self)
         if (
             nav_page == Gui.__root_page_name

+ 2 - 0
taipy/gui/gui_types.py

@@ -46,6 +46,8 @@ class _WsType(Enum):
     DOWNLOAD_FILE = "DF"
     PARTIAL = "PR"
     ACKNOWLEDGEMENT = "ACK"
+    GET_MODULE_CONTEXT = "GMC"
+    GET_VARIABLES = "GVS"
 
 
 NumberTypes = {"int", "int64", "float", "float64"}

+ 4 - 0
taipy/gui/page.py

@@ -34,6 +34,8 @@ class Page:
     """
 
     def __init__(self, **kwargs) -> None:
+        from .custom import Page as CustomPage
+
         self._class_module_name = ""
         self._class_locals: t.Dict[str, t.Any] = {}
         self._frame: t.Optional[FrameType] = None
@@ -42,6 +44,8 @@ class Page:
             self._frame = kwargs.get("frame")
         elif self._renderer:
             self._frame = self._renderer._frame
+        elif isinstance(self, CustomPage):
+            self._frame = t.cast(FrameType, t.cast(FrameType, inspect.stack()[2].frame))
         elif len(inspect.stack()) < 4:
             raise RuntimeError(f"Can't resolve module. Page '{type(self).__name__}' is not registered.")
         else:

+ 17 - 1
taipy/gui/server.py

@@ -22,8 +22,9 @@ import typing as t
 import webbrowser
 from importlib import util
 from random import randint
+from urllib.parse import parse_qsl, urlparse
 
-from flask import Blueprint, Flask, json, jsonify, render_template, send_from_directory
+from flask import Blueprint, Flask, json, jsonify, render_template, request, send_from_directory
 from flask_cors import CORS
 from flask_socketio import SocketIO
 from gitignore_parser import parse_gitignore
@@ -35,6 +36,7 @@ from taipy.logger._taipy_logger import _TaipyLogger
 
 from ._renderers.json import _TaipyJsonProvider
 from .config import ServerConfig
+from .custom._page import _ExternalResourceHandlerManager
 from .utils import _is_in_notebook, _is_port_open, _RuntimeManager
 
 if t.TYPE_CHECKING:
@@ -46,6 +48,8 @@ class _Server:
     __RE_CLOSING_CURLY = re.compile(r"(\})([^\"])")
     __OPENING_CURLY = r"\1&#x7B;"
     __CLOSING_CURLY = r"&#x7D;\2"
+    _RESOURCE_HANDLER_ARG = "tprh"
+    __BASE_FILE_NAME = "taipy-gui-base.js"
 
     def __init__(
         self,
@@ -145,6 +149,18 @@ class _Server:
         @taipy_bp.route("/", defaults={"path": ""})
         @taipy_bp.route("/<path:path>")
         def my_index(path):
+            resource_handler_id = dict(parse_qsl(urlparse(request.referrer or "").query)).get(
+                _Server._RESOURCE_HANDLER_ARG
+            )
+            resource_handler_id = resource_handler_id or request.args.get(_Server._RESOURCE_HANDLER_ARG, None)
+            if resource_handler_id is not None:
+                resource_handler = _ExternalResourceHandlerManager().get(resource_handler_id)
+                if resource_handler is None:
+                    return (f"Invalid value for query {_Server._RESOURCE_HANDLER_ARG}", 404)
+                try:
+                    return resource_handler.get_resources(path, f"{static_folder}{os.path.sep}{_Server.__BASE_FILE_NAME}")
+                except Exception:
+                    raise RuntimeError("Can't get resources from custom resource handler")
             if path == "" or path == "index.html" or "." not in path:
                 try:
                     return render_template(