Skip to content
Closed
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e39cbac
chore: base configs, to move from lap
holaontiveros Feb 17, 2026
43c6e81
chore: temp to chanto to mac
holaontiveros Feb 18, 2026
76af687
chore: change computers
holaontiveros Feb 19, 2026
7a40e2d
chore: working caddy file (BASE_PATH in site needs /template-site)
holaontiveros Feb 19, 2026
ec75e8a
chore: added unique frontend-apps
holaontiveros Feb 19, 2026
4621a8b
chore: remote patch that won't be erquired with prepare
holaontiveros Feb 19, 2026
31fc159
chore: remove debug
holaontiveros Feb 19, 2026
6b13d73
chore: added base changes for alternative version of mfe build
holaontiveros Mar 5, 2026
aa55054
chore: push to change computer
holaontiveros Mar 10, 2026
5a403a6
fix: bad return get_frontend_apps
holaontiveros Mar 10, 2026
2afc49b
feat: frontend-base build with workspaces
holaontiveros Mar 26, 2026
0bcc14d
chore: cleanup
holaontiveros Mar 26, 2026
16c4908
chore: added missing cache and registry for build
holaontiveros Mar 27, 2026
788f347
chore: remove index.html while in site paths to focus on the assets
holaontiveros Mar 27, 2026
6c2c3da
chore: updated package.json to new version standar
holaontiveros Mar 27, 2026
0ccf4a1
chore: added @app to be more explicit
holaontiveros Mar 27, 2026
ece781a
chore: add {{ PLATFORM_NAME }} to allow title config
holaontiveros Mar 27, 2026
c88c6fa
feat: add appEntryPoints to better configure the default site
holaontiveros Mar 27, 2026
a39733c
chore: cleanup and improvements
holaontiveros Mar 30, 2026
9580bf9
feat: added site level configs
holaontiveros Mar 31, 2026
e2f903d
chore: update app_name to site_name for better semantics
holaontiveros Mar 31, 2026
ac86f38
chore: add small clarification to the site routes
holaontiveros Mar 31, 2026
e3b3658
feat: added config for frontendbase version
holaontiveros Mar 31, 2026
327d455
feat: add paragon version / alias config
holaontiveros Mar 31, 2026
46fa576
chore: better semantic for dockerfile
holaontiveros Mar 31, 2026
7483129
chore: updated site.condfg.dev with latest of the build one
holaontiveros Mar 31, 2026
0eeef3a
feat: add single file config frontend slots
holaontiveros Apr 1, 2026
24d54a0
feat: optimized site layers for better caching
holaontiveros Apr 1, 2026
17a5a67
chore: add missing patch locations
holaontiveros Apr 1, 2026
b52cee6
chore: remove configured apps build if site is not local, improved se…
holaontiveros Apr 2, 2026
065a084
chore: added patch for MICROFRONTEND_URL and updated names for better…
holaontiveros Apr 2, 2026
2b9a182
chore: added patch for MICROFRONTEND_URL and updated names for better…
holaontiveros Apr 2, 2026
7c07801
Merge remote-tracking branch 'refs/remotes/fork/frontend-base-c-side'…
holaontiveros Apr 2, 2026
972d2c5
chore: fixed typings
holaontiveros Apr 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions tutormfe/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@
from tutor.core.hooks import Filter

MFE_ATTRS_TYPE = t.Dict[t.Literal["repository", "port", "version"], t.Union["str", int]]
FRONTEND_APP_ATTRS_TYPE = t.Dict[
t.Literal["repository", "version", "site"], t.Union["str", int]
]

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_APP_ATTRS_TYPE], []] = Filter()

PLUGIN_SLOTS: Filter[list[tuple[str, str, str]], []] = Filter()
2 changes: 2 additions & 0 deletions tutormfe/patches/local-docker-compose-dev-services
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
6 changes: 6 additions & 0 deletions tutormfe/patches/openedx-lms-development-settings
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,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") }}
113 changes: 107 additions & 6 deletions tutormfe/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,22 @@
from glob import glob

import importlib_resources
from tutor import fmt
from tutor import fmt, config as tutor_config
from tutor import hooks as tutor_hooks
from tutor.__about__ import __version_suffix__
from tutor.bindmount import iter_mounts
from tutor.hooks import priorities
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,
)

# Handle version suffix in main mode, just like tutor core
if __version_suffix__:
Expand Down Expand Up @@ -78,6 +85,13 @@
},
}

CORE_FRONTEND_SITES: dict[str, MFE_ATTRS_TYPE] = {
"default": {
"repository": "local",
"port": 8080,
},
}


# The core MFEs are added with a high priority, such that other users can override or
# remove them.
Expand All @@ -87,6 +101,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, MFE_ATTRS_TYPE],
) -> dict[str, MFE_ATTRS_TYPE]:
sites.update(CORE_FRONTEND_SITES)
return sites


@tutor_hooks.lru_cache
def get_mfes() -> dict[str, MFE_ATTRS_TYPE]:
"""
Expand All @@ -95,6 +117,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_APP_ATTRS_TYPE]:
"""
This function is cached for performance.
"""
return FRONTEND_SITES.apply({})


class MFEMountData:
"""Stores categorized mounted and unmounted MFEs."""

Expand Down Expand Up @@ -130,6 +168,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_APP_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:
Expand All @@ -143,6 +222,10 @@ def is_mfe_enabled(mfe_name: str) -> bool:
return mfe_name in get_mfes()


def is_frontend_app(app_name: str) -> bool:
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, {})

Expand All @@ -152,8 +235,12 @@ def get_mfe(mfe_name: str) -> t.Union[MFE_ATTRS_TYPE, t.Any]:
[
("get_mfe", get_mfe),
("iter_mfes", iter_mfes),
("iter_paths", iter_paths),
("iter_frontend_apps", iter_frontend_apps),
("iter_frontend_sites", iter_frontend_sites),
("iter_plugin_slots", iter_plugin_slots),
("is_mfe_enabled", is_mfe_enabled),
("is_frontend_app", is_frontend_app),
("MFEMountData", MFEMountData),
]
)
Expand Down Expand Up @@ -217,6 +304,8 @@ def _mounted_mfe_image_management() -> None:
tutor_hooks.Filters.CLI_DO_INIT_TASKS.add_item(("lms", task_file.read()))

REPO_PREFIX = "frontend-app-"
FRONTEND_SITE_PREFIX = "frontend-site-"
# TODO: figure out the whole mount thing not only for sites but also for the possible apps under it
Comment thread
holaontiveros marked this conversation as resolved.
Outdated


@tutor_hooks.Filters.COMPOSE_MOUNTS.add()
Expand All @@ -229,12 +318,18 @@ def _mount_frontend_apps(
in dev mode, because in production, all MFEs are built and hosted on the
singular 'mfe' service container.
"""
if path_basename.startswith(REPO_PREFIX):
if path_basename.startswith(REPO_PREFIX) or path_basename.startswith(
FRONTEND_SITE_PREFIX
):
# Assumption:
# For each repo named frontend-app-APPNAME, there is an associated
# docker-compose service named APPNAME. If this assumption is broken,
# then Tutor will try to mount the repo in a service that doesn't exist.
app_name = path_basename[len(REPO_PREFIX) :]
app_name = (
path_basename[len(REPO_PREFIX) :]
if path_basename.startswith(REPO_PREFIX)
else path_basename[len(FRONTEND_SITE_PREFIX) :]
)
volumes += [(app_name, "/openedx/app")]
return volumes

Expand All @@ -244,9 +339,15 @@ def _mount_frontend_apps_on_build(
mounts: list[tuple[str, str]], host_path: str
) -> list[tuple[str, str]]:
path_basename = os.path.basename(host_path)
if path_basename.startswith(REPO_PREFIX):
if path_basename.startswith(REPO_PREFIX) or path_basename.startswith(
FRONTEND_SITE_PREFIX
):
# Bind-mount repo at build-time, both for prod and dev images
app_name = path_basename[len(REPO_PREFIX) :]
app_name = (
path_basename[len(REPO_PREFIX) :]
if path_basename.startswith(REPO_PREFIX)
else path_basename[len(FRONTEND_SITE_PREFIX) :]
)
mounts.append(("mfe", f"{app_name}-src"))
mounts.append((f"{app_name}-dev", f"{app_name}-src"))
return mounts
Expand Down
21 changes: 20 additions & 1 deletion tutormfe/templates/mfe/apps/mfe/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,34 @@
redir @authoring /authoring/{re.authoring.1} permanent
{% endif %}

{% for app_name, app in iter_mfes() %}
{% for app_name, app in iter_paths() %}
@mfe_{{ app_name }} {
path /{{ app_name }} /{{ app_name }}/*
}
handle @mfe_{{ app_name }} {
uri strip_prefix /{{ app_name }}
{%- if is_frontend_app(app_name) %}
Comment thread
holaontiveros marked this conversation as resolved.
Outdated
# {{ app_name }} - using frontend-apps approach
root * /openedx/dist/site-{{ app.get("site", "default") }}
{%- else %}
# {{ app_name }} - using traditional MFE approach
root * /openedx/dist/{{ app_name }}
{%- endif %}
try_files /{path} /index.html
file_server
}
{% endfor %}

{% for app_name, app in iter_frontend_sites() %}
Comment thread
holaontiveros marked this conversation as resolved.
Outdated
@site_{{ app_name }} {
path /site-{{ app_name }} /site-{{ app_name }}/*
}
handle @site_{{ app_name }} {
uri strip_prefix /site-{{ app_name }}
root * /openedx/dist/site-{{ app_name }}
# no index.html because we want to focus on assets in these ones
try_files /{path}
file_server
}
{% endfor %}
}
31 changes: 31 additions & 0 deletions tutormfe/templates/mfe/build/mfe/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,33 @@ RUN npm run build
{{ patch("mfe-dockerfile-post-npm-build-{}".format(app_name)) }}
{% endfor %}


{%- for app_name, app in iter_frontend_sites() %}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{%- for app_name, app in iter_frontend_sites() %}
{%- for site_name, site in iter_frontend_sites() %}

######## build of site-{{ app_name }}
FROM base AS site-{{ app_name }}-common
WORKDIR /openedx/app
Comment thread
holaontiveros marked this conversation as resolved.
Outdated

{% if app.get("repository") and app["repository"] == "local" %}
Comment thread
holaontiveros marked this conversation as resolved.
Outdated
COPY frontend-site/ /openedx/app
{% else %}
RUN git clone --depth 1 --branch {{ app.get("version", MFE_COMMON_VERSION) }} {{ app["repository"] }} .
Comment thread
holaontiveros marked this conversation as resolved.
Outdated
{% endif %}

WORKDIR /openedx/app/packages
# Git clone all frontend apps that should be built
{% 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 }}
{% endif %}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you mentioned elsewhere, we're missing the volume mounts.

{% endfor %}

WORKDIR /openedx/app
ARG NPM_REGISTRY={{ NPM_REGISTRY }}
RUN --mount=type=cache,target=/root/.npm-sites,sharing=shared npm install --no-audit --no-fund --registry=$NPM_REGISTRY
RUN npm run build:packages
Comment thread
holaontiveros marked this conversation as resolved.
RUN PUBLIC_PATH="/site-{{ app.get("site", "default") }}/" npm run build
{% endfor %}

####### final production image with all static assets
FROM {{ MFE_CADDY_DOCKER_IMAGE }} AS production

Expand All @@ -92,4 +119,8 @@ RUN mkdir -p /openedx/dist
COPY --from={{ app_name }}-prod /openedx/app/dist /openedx/dist/{{ app_name }}
{% endfor %}

{% for app_name, app in iter_frontend_sites() %}
COPY --from=site-{{ app_name }}-common /openedx/app/dist /openedx/dist/site-{{ app_name }}
{% endfor %}

{{ patch("mfe-dockerfile-production-final") }}
23 changes: 23 additions & 0 deletions tutormfe/templates/mfe/build/mfe/frontend-site/Makefile
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions tutormfe/templates/mfe/build/mfe/frontend-site/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@openedx/frontend-template-site",
"version": "1.0.0",
"description": "Frontend site template",
Comment thread
holaontiveros marked this conversation as resolved.
Outdated
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-template-site.git"
},
Comment thread
holaontiveros marked this conversation as resolved.
Outdated
"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"
},
"author": "Open edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-template-site#readme",
"bugs": {
"url": "https://github.com/openedx/frontend-template-site/issues"
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
Comment thread
holaontiveros marked this conversation as resolved.
Outdated
{%- for app_name, app_attrs in iter_frontend_apps() %}
"@openedx/frontend-app-{{ app_name }}": "^1.0.0-alpha || 0.0.0-dev",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the version is going to have to be configurable per app as a string property, so that it can be configured by operators. app_npm_version, or something like that to distinguish it from the app_git_version.

I'm also not sure what to do about || 0.0.0-dev in particular. If we leave this as a per-app string, it's up to the operator to configure correctly - which I guess is good enough. They should know if their own app is semantically released or not, and what the miminum version is.

{%- endfor %}
"@openedx/frontend-base": "^1.0.0-alpha || 0.0.0-dev"
Comment thread
holaontiveros marked this conversation as resolved.
Outdated
},
"devDependencies": {
"@edx/browserslist-config": "^1.5.0",
Comment thread
holaontiveros marked this conversation as resolved.
Outdated
"turbo": "^2.8.16"
},
"peerDependencies": {
"@openedx/paragon": "^23",
Comment thread
holaontiveros marked this conversation as resolved.
Outdated
"@tanstack/react-query": "^5",
"react": "^18",
"react-dom": "^18",
"react-router": "^6",
"react-router-dom": "^6"
}
}
Loading