diff --git a/tutormfe/hooks.py b/tutormfe/hooks.py index d88c7b44..db26bbc2 100644 --- a/tutormfe/hooks.py +++ b/tutormfe/hooks.py @@ -12,6 +12,24 @@ MFE_ATTRS_TYPE = t.Dict[t.Literal["repository", "port", "version"], t.Union["str", int]] +# Extended MFE type that includes externalRoutes +FRONTEND_SITE_ATTRS_TYPE = t.Dict[ + t.Literal["repository", "port", "version", "siteConfig"], + t.Union[str, int, dict], +] + +FRONTEND_APP_ATTRS_TYPE = t.Dict[ + t.Literal["repository", "version", "site", "appEntryPoints", "appId"], + t.Union["str", int, dict], +] + MFE_APPS: Filter[dict[str, MFE_ATTRS_TYPE], []] = Filter() +# This holds which apps are enabled and if they will build from a custom repo +FRONTEND_APPS: Filter[dict[str, FRONTEND_APP_ATTRS_TYPE], []] = Filter() +# This holds all the possible frontend-sites which by default it's the internal one. +FRONTEND_SITES: Filter[dict[str, FRONTEND_SITE_ATTRS_TYPE], []] = Filter() + PLUGIN_SLOTS: Filter[list[tuple[str, str, str]], []] = Filter() + +FRONTEND_SLOTS: Filter[list[tuple[str, str]], []] = Filter() diff --git a/tutormfe/patches/local-docker-compose-dev-services b/tutormfe/patches/local-docker-compose-dev-services index 352ae1f8..0e4282e2 100644 --- a/tutormfe/patches/local-docker-compose-dev-services +++ b/tutormfe/patches/local-docker-compose-dev-services @@ -26,7 +26,9 @@ mfe: {%- if MFE_HOST_EXTRA_FILES %} - 8002:8002 {%- endif %} +{# TODO: Add here the ports for unmounted site-configs #} {%- for app_name, app in mfe_data.unmounted %} - {{ app["port"] }}:8002 # {{ app_name }} {%- endfor %} + - 8080:8002 {% endif %} diff --git a/tutormfe/patches/openedx-lms-development-settings b/tutormfe/patches/openedx-lms-development-settings index e0e0eabc..2aadb1c5 100644 --- a/tutormfe/patches/openedx-lms-development-settings +++ b/tutormfe/patches/openedx-lms-development-settings @@ -87,6 +87,18 @@ MFE_CONFIG["ADMIN_CONSOLE_URL"] = ADMIN_CONSOLE_MICROFRONTEND_URL CATALOG_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ get_mfe("catalog")["port"] }}/catalog" {% endif %} +{% if is_frontend_app("authn") %} +{% set app = get_frontend_app("authn") %} +{% set site = get_frontend_site(app.get("site", "default")) %} +AUTHN_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ site["port"] }}/authn" +{% endif %} + +{% if is_frontend_app("learner-dashboard") %} +{% set app = get_frontend_app("learner-dashboard") %} +{% set site = get_frontend_site(app.get("site", "default")) %} +LEARNER_HOME_MICROFRONTEND_URL = "http://{{ MFE_HOST }}:{{ site["port"] }}/learner-dashboard" +{% endif %} + # Cors configuration {% for app_name, app in iter_mfes() %} # {{ app_name }} MFE @@ -95,5 +107,11 @@ LOGIN_REDIRECT_WHITELIST.append("{{ MFE_HOST }}:{{ app["port"] }}") CSRF_TRUSTED_ORIGINS.append("http://{{ MFE_HOST }}:{{ app["port"] }}") {% endfor %} +{% for app_name, app in iter_frontend_sites() %} +CORS_ORIGIN_WHITELIST.append("http://{{ MFE_HOST }}:{{ app["port"] }}") +LOGIN_REDIRECT_WHITELIST.append("{{ MFE_HOST }}:{{ app["port"] }}") +CSRF_TRUSTED_ORIGINS.append("http://{{ MFE_HOST }}:{{ app["port"] }}") +{% endfor %} + {{ patch("mfe-lms-common-settings") }} {{ patch("mfe-lms-development-settings") }} diff --git a/tutormfe/plugin.py b/tutormfe/plugin.py index 4314a6ae..92489fde 100644 --- a/tutormfe/plugin.py +++ b/tutormfe/plugin.py @@ -13,7 +13,16 @@ from tutor.types import Config, get_typed from .__about__ import __version__ -from .hooks import MFE_APPS, MFE_ATTRS_TYPE, PLUGIN_SLOTS +from .hooks import ( + FRONTEND_SITES, + MFE_APPS, + MFE_ATTRS_TYPE, + FRONTEND_APPS, + FRONTEND_APP_ATTRS_TYPE, + PLUGIN_SLOTS, + FRONTEND_SLOTS, + FRONTEND_SITE_ATTRS_TYPE, +) # Handle version suffix in main mode, just like tutor core if __version_suffix__: @@ -82,6 +91,35 @@ }, } +CORE_FRONTEND_SITES: dict[str, FRONTEND_SITE_ATTRS_TYPE] = { + "default": { + "repository": "local", + "port": 8080, + "siteConfig": { + "siteId": "tutor-frontend-site", + "siteName": "Frontend Template Site", + "accessTokenCookieName": "edx-jwt-cookie-header-payload", + "redirectRoleId": "org.openedx.frontend.role.dashboard", + "frontendBaseVersion": "^1.0.0-alpha", + "paragonVersion": "^23", + "externalRoutes": [ + { + "role": "org.openedx.frontend.role.profile", + "url": ":1995/profile/", + }, + { + "role": "org.openedx.frontend.role.account", + "url": ":1997/account/", + }, + { + "role": "org.openedx.frontend.role.logout", + "url": ":8000/logout", + }, + ], + }, + }, +} + # The core MFEs are added with a high priority, such that other users can override or # remove them. @@ -91,6 +129,14 @@ def _add_core_mfe_apps(apps: dict[str, MFE_ATTRS_TYPE]) -> dict[str, MFE_ATTRS_T return apps +@FRONTEND_SITES.add(priority=tutor_hooks.priorities.HIGH) +def _add_core_frontend_sites( + sites: dict[str, FRONTEND_SITE_ATTRS_TYPE], +) -> dict[str, FRONTEND_SITE_ATTRS_TYPE]: + sites.update(CORE_FRONTEND_SITES) + return sites + + @tutor_hooks.lru_cache def get_mfes() -> dict[str, MFE_ATTRS_TYPE]: """ @@ -99,6 +145,22 @@ def get_mfes() -> dict[str, MFE_ATTRS_TYPE]: return MFE_APPS.apply({}) +@tutor_hooks.lru_cache +def get_frontend_apps() -> dict[str, FRONTEND_APP_ATTRS_TYPE]: + """ + This function is cached for performance. + """ + return FRONTEND_APPS.apply({}) + + +@tutor_hooks.lru_cache +def get_frontend_sites() -> dict[str, FRONTEND_SITE_ATTRS_TYPE]: + """ + This function is cached for performance. + """ + return FRONTEND_SITES.apply({}) + + class MFEMountData: """Stores categorized mounted and unmounted MFEs.""" @@ -125,6 +187,14 @@ def get_plugin_slots(mfe_name: str) -> list[tuple[str, str]]: return [i[-2:] for i in PLUGIN_SLOTS.iterate() if i[0] == mfe_name] +@tutor_hooks.lru_cache +def get_frontend_slots() -> list[tuple[str, str]]: + """ + This function is cached for performance. + """ + return FRONTEND_SLOTS.apply([]) + + def iter_mfes() -> t.Iterable[tuple[str, MFE_ATTRS_TYPE]]: """ Yield: @@ -134,6 +204,47 @@ def iter_mfes() -> t.Iterable[tuple[str, MFE_ATTRS_TYPE]]: yield from get_mfes().items() +def iter_frontend_apps() -> t.Iterable[tuple[str, FRONTEND_APP_ATTRS_TYPE]]: + """ + Yield: + + (name, dict) + """ + frontend_apps = get_frontend_apps() + for name, attrs in frontend_apps.items(): + yield (name, attrs) + + +def iter_frontend_sites() -> t.Iterable[tuple[str, FRONTEND_SITE_ATTRS_TYPE]]: + """ + Yield: + + (name, dict) + """ + frontend_sites = get_frontend_sites() + for name, attrs in frontend_sites.items(): + yield (name, attrs) + + +def iter_paths() -> t.Iterable[tuple[str, FRONTEND_APP_ATTRS_TYPE]]: + """ + Yield: + + (name, dict) + """ + mfes = get_mfes() + frontend_apps = get_frontend_apps() + + # First yield all MFEs + for name, attrs in mfes.items(): + yield (name, attrs) + + # Then yield frontend apps that are not already MFEs + for name, attrs in frontend_apps.items(): + if name not in mfes: + yield (name, attrs) + + def iter_plugin_slots(mfe_name: str) -> t.Iterable[tuple[str, str]]: """ Yield: @@ -143,21 +254,58 @@ def iter_plugin_slots(mfe_name: str) -> t.Iterable[tuple[str, str]]: yield from get_plugin_slots(mfe_name) +def iter_frontend_slots() -> t.Iterable[tuple[str, str]]: + """ + Yield: + + (slot_name, plugin_config) + """ + yield from get_frontend_slots() + + def is_mfe_enabled(mfe_name: str) -> bool: return mfe_name in get_mfes() +def is_frontend_app(app_name: str) -> bool: + """ + Returns True if the given app_name corresponds to a configured frontend app. + """ + return app_name in get_frontend_apps() + + def get_mfe(mfe_name: str) -> t.Union[MFE_ATTRS_TYPE, t.Any]: return get_mfes().get(mfe_name, {}) +def get_frontend_site(site_name: str) -> t.Union[FRONTEND_SITE_ATTRS_TYPE, t.Any]: + """ + Returns the attributes of a configured frontend site. + """ + return get_frontend_sites().get(site_name, {}) + + +def get_frontend_app(app_name: str) -> t.Union[FRONTEND_APP_ATTRS_TYPE, t.Any]: + """ + Returns the attributes of a configured frontend app. + """ + return get_frontend_apps().get(app_name, {}) + + # Make the mfe functions available within templates tutor_hooks.Filters.ENV_TEMPLATE_VARIABLES.add_items( [ ("get_mfe", get_mfe), ("iter_mfes", iter_mfes), + ("iter_paths", iter_paths), + ("iter_frontend_apps", iter_frontend_apps), + ("iter_frontend_sites", iter_frontend_sites), + ("get_frontend_site", get_frontend_site), + ("get_frontend_app", get_frontend_app), ("iter_plugin_slots", iter_plugin_slots), + ("iter_frontend_slots", iter_frontend_slots), ("is_mfe_enabled", is_mfe_enabled), + ("is_frontend_app", is_frontend_app), ("MFEMountData", MFEMountData), ] ) diff --git a/tutormfe/templates/mfe/apps/mfe/Caddyfile b/tutormfe/templates/mfe/apps/mfe/Caddyfile index 115d57c5..e85f2f20 100644 --- a/tutormfe/templates/mfe/apps/mfe/Caddyfile +++ b/tutormfe/templates/mfe/apps/mfe/Caddyfile @@ -26,7 +26,20 @@ redir @authoring /authoring/{re.authoring.1} permanent {% endif %} - {% for app_name, app in iter_mfes() %} + {% for app_name, app in iter_paths() %} + + {%- if is_frontend_app(app_name) %} + @app_{{ app_name }} { + path /{{ app_name }} /{{ app_name }}/* + } + handle @app_{{ app_name }} { + uri strip_prefix /{{ app_name }} + root * /openedx/dist/site-{{ app.get("site", "default") }} + try_files /{path} /index.html + file_server + } + {%- else %} + @mfe_{{ app_name }} { path /{{ app_name }} /{{ app_name }}/* } @@ -36,5 +49,23 @@ try_files /{path} /index.html file_server } + {%- endif %} + {% endfor %} + + # Handle frontend sites, which are meant to serve only static assets, without an index.html. + # When fetched from the proper url handle they'll be returned with a index.html, + # but if someone tries to access them directly (like the chunk links or general assets) + # they'll get the files directy from this block + {% for site_name, app in iter_frontend_sites() %} + @site_{{ site_name }} { + path /site-{{ site_name }} /site-{{ site_name }}/* + } + handle @site_{{ site_name }} { + uri strip_prefix /site-{{ site_name }} + root * /openedx/dist/site-{{ site_name }} + # no index.html because we want to focus on assets in these ones + try_files /{path} + file_server + } {% endfor %} } diff --git a/tutormfe/templates/mfe/build/mfe/Dockerfile b/tutormfe/templates/mfe/build/mfe/Dockerfile index 7a470822..35f98fc7 100644 --- a/tutormfe/templates/mfe/build/mfe/Dockerfile +++ b/tutormfe/templates/mfe/build/mfe/Dockerfile @@ -82,6 +82,66 @@ RUN npm run build {{ patch("mfe-dockerfile-post-npm-build-{}".format(app_name)) }} {% endfor %} + +{%- for site_name, site in iter_frontend_sites() %} +######## build of site-{{ site_name }}-git +FROM base AS site-{{ site_name }}-git +WORKDIR /openedx/site + +# Either use site-local or the configured repository +{%- if site.get("repository") and site["repository"] == "local" %} +COPY frontend-site/ /openedx/site +{%- else %} +ADD --keep-git-dir=true {{ site["repository"] }}#{{ site.get("version", MFE_COMMON_VERSION) }} . +{%- endif %} +{%- endfor %} + + +{%- set ns = namespace(repos_exists=0) %} +{% for site_name, site in iter_frontend_sites() %} +######## build of site-{{ site_name }}-src +FROM base AS site-{{ site_name }}-src + +{%- if site.get("repository") and site["repository"] == "local" %} +# Git clone all frontend apps that should be built +WORKDIR /openedx/site/packages +{%- for app_name, app in iter_frontend_apps() %} +{%- if app.get("repository") %} +RUN git clone --depth 1 --branch {{ app.get("version", MFE_COMMON_VERSION) }} {{ app["repository"] }} frontend-app-{{ app_name }} +{%- set ns.repos_exists = 1 %} +{%- endif %} +{%- endfor %} +{%- endif %} +WORKDIR /openedx/site + +COPY --from=site-{{ site_name }}-git /openedx/site/package.json /openedx/site + +ARG NPM_REGISTRY={{ NPM_REGISTRY }} +{{ patch("frontend-site-dockerfile-pre-npm-install") }} +RUN --mount=type=cache,target=/root/.npm-sites,sharing=shared npm install --no-audit --no-fund --registry=$NPM_REGISTRY +{{ patch("frontend-site-dockerfile-post-npm-install") }} +{%- endfor %} + + +{%- for site_name, site in iter_frontend_sites() %} +######## build of site-{{ site_name }}-common +FROM base AS site-{{ site_name }}-common +WORKDIR /openedx/site + +COPY --from=site-{{ site_name }}-git /openedx/site/ /openedx/site +COPY --from=site-{{ site_name }}-src /openedx/site /openedx/site + +{%- if ns.repos_exists %} +RUN npm run build:packages +{%- endif %} + +{{ patch("frontend-site-dockerfile-pre-npm-build") }} +RUN PUBLIC_PATH="/site-{{ site.get("site", "default") }}/" npm run build +{{ patch("frontend-site-dockerfile-post-npm-build") }} + +{%- endfor %} + + ####### final production image with all static assets FROM {{ MFE_CADDY_DOCKER_IMAGE }} AS production @@ -92,4 +152,8 @@ RUN mkdir -p /openedx/dist COPY --from={{ app_name }}-prod /openedx/app/dist /openedx/dist/{{ app_name }} {% endfor %} +{%- for site_name, site in iter_frontend_sites() %} +COPY --from=site-{{ site_name }}-common /openedx/site/dist /openedx/dist/site-{{ site_name }} +{% endfor %} + {{ patch("mfe-dockerfile-production-final") }} diff --git a/tutormfe/templates/mfe/build/mfe/frontend-site/Makefile b/tutormfe/templates/mfe/build/mfe/frontend-site/Makefile new file mode 100644 index 00000000..0cd35229 --- /dev/null +++ b/tutormfe/templates/mfe/build/mfe/frontend-site/Makefile @@ -0,0 +1,23 @@ +TURBO = TURBO_TELEMETRY_DISABLED=1 turbo --dangerously-disable-package-manager-check + +.PHONY: bin-link build-packages clean-packages clean dev-packages + +# NPM doesn't bin-link workspace packages during install, so it must be done manually. +bin-link: + [ -f packages/frontend-base/package.json ] && npm rebuild --ignore-scripts @openedx/frontend-base || true + +build-packages: + $(TURBO) run build + $(MAKE) bin-link + +clean-packages: + $(TURBO) run clean + +dev-packages: + $(TURBO) run watch:build dev:site + +dev-site: bin-link + npm run dev + +clean: + rm -rf dist \ No newline at end of file diff --git a/tutormfe/templates/mfe/build/mfe/frontend-site/package.json b/tutormfe/templates/mfe/build/mfe/frontend-site/package.json new file mode 100644 index 00000000..31796d27 --- /dev/null +++ b/tutormfe/templates/mfe/build/mfe/frontend-site/package.json @@ -0,0 +1,47 @@ +{%- set defaultSite = get_frontend_site('default') %} +{ + "version": "1.0.0", + "workspaces": [ + "packages/*" + ], + "browserslist": [ + "extends @edx/browserslist-config" + ], + "sideEffects": [ + "*.css", + "*.scss" + ], + "scripts": { + "build": "openedx build", + "build:packages": "make build-packages", + "clean:packages": "make clean-packages", + "dev": "PORT=8080 openedx dev", + "dev:site": "make dev-site", + "dev:packages": "make dev-packages", + "clean": "make clean", + "serve": "openedx serve" + }, + "dependencies": { + "@edx/brand": "npm:@openedx/brand-openedx@latest", +{%- for app_name, app_attrs in iter_frontend_apps() %} +{%- set entry_points = app_attrs.get('appEntryPoints', {}).get('packageName', False) %} +{%- if entry_points %} + "{{ app_attrs.get('appEntryPoints', {}).get('packageName', app_name) }}": "{{ app_attrs.get('appEntryPoints', {}).get('packageVersion', '^1.0.0-alpha || 0.0.0-dev') }}", +{%- endif %} +{%- endfor %} +{#- This allows version config and alias config #} + "@openedx/frontend-base": "{{ defaultSite.get('siteConfig', {}).get('frontendBaseVersion', '^1.0.0-alpha || 0.0.0-dev') }}" + }, + "devDependencies": { + "@edx/browserslist-config": "latest", + "turbo": "^2.8.16" + }, + "peerDependencies": { + "@openedx/paragon": "{{ defaultSite.get('siteConfig', {}).get('paragonVersion', '^23') }}", + "@tanstack/react-query": "^5", + "react": "^18", + "react-dom": "^18", + "react-router": "^6", + "react-router-dom": "^6" + } +} diff --git a/tutormfe/templates/mfe/build/mfe/frontend-site/public/index.html b/tutormfe/templates/mfe/build/mfe/frontend-site/public/index.html new file mode 100644 index 00000000..ed856d49 --- /dev/null +++ b/tutormfe/templates/mfe/build/mfe/frontend-site/public/index.html @@ -0,0 +1,14 @@ + + + +
+