Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Load JS modules for web components as-needed [BREAKING CHANGE] #723

Merged
merged 2 commits into from
Aug 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ class State:
"https://cdnjs.cloudflare.com",
"*.fonts.gstatic.com",
],
allowed_script_srcs=["https://cdnjs.cloudflare.com", "*.fonts.gstatic.com"],
allowed_script_srcs=[
"https://cdnjs.cloudflare.com",
"*.fonts.gstatic.com",
"https://cdn.jsdelivr.net",
],
),
)
def page():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@

@me.page(
path="/web_component/copy_to_clipboard/copy_to_clipboard_app",
security_policy=me.SecurityPolicy(
allowed_script_srcs=[
"https://cdn.jsdelivr.net",
]
),
)
def page():
with me.box(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
stylesheets=[
"https://fonts.googleapis.com/css2?family=Inter+Tight:ital,wght@0,100..900;1,100..900&family=Inter:[email protected]&display=swap",
],
security_policy=me.SecurityPolicy(
allowed_script_srcs=[
"https://cdn.jsdelivr.net",
]
),
)
def page():
me.text("Custom font: Inter Tight", style=me.Style(font_family="Inter Tight"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
security_policy=me.SecurityPolicy(
dangerously_disable_trusted_types=True,
allowed_connect_srcs=["*.googleapis.com"],
allowed_script_srcs=["*.google.com"],
allowed_script_srcs=[
"*.google.com",
"https://www.gstatic.com",
"https://cdn.jsdelivr.net",
],
),
)
def page():
Expand Down
8 changes: 7 additions & 1 deletion mesop/examples/web_component/plotly/plotly_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@
#
# Disabling trusted types because plotly uses DomParser#parseFromString
# which violates TrustedHTML assignment.
security_policy=me.SecurityPolicy(dangerously_disable_trusted_types=True),
security_policy=me.SecurityPolicy(
allowed_script_srcs=[
"https://cdn.jsdelivr.net",
"https://cdn.plot.ly",
],
dangerously_disable_trusted_types=True,
),
)
def page():
plotly_component()
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@

@me.page(
path="/web_component/quickstart/counter_component_app",
security_policy=me.SecurityPolicy(
allowed_script_srcs=[
"https://cdn.jsdelivr.net",
]
),
)
def page():
counter_component(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

@me.page(
path="/web_component/shared_js_module/shared_js_module_app",
security_policy=me.SecurityPolicy(
allowed_script_srcs=[
"https://cdn.jsdelivr.net",
]
),
)
def page():
me.text("Loaded")
Expand Down
5 changes: 5 additions & 0 deletions mesop/examples/web_component/slot/slot_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@

@me.page(
path="/web_component/slot/slot_app",
security_policy=me.SecurityPolicy(
allowed_script_srcs=[
"https://cdn.jsdelivr.net",
]
),
)
def page():
with outer_component(
Expand Down
6 changes: 4 additions & 2 deletions mesop/labs/web_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ def web_component(*, path: str, skip_validation: bool = False):
path: The path to the JavaScript file of the web component.
skip_validation: If set to True, skips validation. Defaults to False.
"""
runtime().check_register_web_component_is_valid()

current_frame = inspect.currentframe()
assert current_frame
previous_frame = current_frame.f_back
Expand All @@ -31,14 +33,14 @@ def web_component(*, path: str, skip_validation: bool = False):
full_path = os.path.normpath(os.path.join(caller_module_dir, path))
if not full_path.startswith("/"):
full_path = "/" + full_path

runtime().register_js_module(full_path)
js_module_path = full_path

def component_wrapper(fn: C) -> C:
validated_fn = fn if skip_validation else validate(fn)

@wraps(fn)
def wrapper(*args: Any, **kw_args: Any):
runtime().context().register_js_module(js_module_path)
return validated_fn(*args, **kw_args)

return cast(C, wrapper)
Expand Down
2 changes: 2 additions & 0 deletions mesop/protos/ui.proto
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ message RenderEvent {
optional string title = 3;
repeated Command commands = 4;

repeated string js_modules = 6;

// Only sent in editor mode:
repeated ComponentConfig component_configs = 5;
}
Expand Down
10 changes: 10 additions & 0 deletions mesop/runtime/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ def __init__(
self._node_slot_children_count: int | None = None
self._viewport_size: pb.ViewportSize | None = None
self._theme_settings: pb.ThemeSettings | None = None
self._js_modules: set[str] = set()

def register_js_module(self, js_module_path: str) -> None:
self._js_modules.add(js_module_path)

def js_modules(self) -> set[str]:
return self._js_modules

def clear_js_modules(self):
self._js_modules = set()

def commands(self) -> list[pb.Command]:
return self._commands
Expand Down
6 changes: 2 additions & 4 deletions mesop/runtime/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ def __init__(self):
self.hot_reload_counter = 0
self._path_to_page_config: dict[str, PageConfig] = {}
self.component_fns: set[Callable[..., Any]] = set()
self.js_modules: set[str] = set()
self._handlers: dict[str, Handler] = {}
self.event_mappers: dict[Type[Any], Callable[[pb.UserEvent, Key], Any]] = {}
self._state_classes: list[type[Any]] = []
Expand Down Expand Up @@ -146,12 +145,11 @@ def get_loading_errors(self) -> list[pb.ServerError]:
def register_native_component_fn(self, component_fn: Callable[..., Any]):
self.component_fns.add(component_fn)

def register_js_module(self, js_module: str) -> None:
def check_register_web_component_is_valid(self) -> None:
if self._has_served_traffic:
raise MesopDeveloperException(
"Cannot register a JS module after traffic has been served. You must define all web components upon server startup before any traffic has been served. This prevents security issues."
"Cannot register a web component after traffic has been served. You must define all web components upon server startup before any traffic has been served. This prevents security issues."
)
self.js_modules.add(js_module)

def get_component_fns(self) -> set[Callable[..., Any]]:
return self.component_fns
Expand Down
2 changes: 2 additions & 0 deletions mesop/server/constants.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
EDITOR_PACKAGE_PATH = "mesop/mesop/web/src/app/editor/web_package"
PROD_PACKAGE_PATH = "mesop/mesop/web/src/app/prod/web_package"

WEB_COMPONENTS_PATH_SEGMENT = "__web-components-module__"
10 changes: 10 additions & 0 deletions mesop/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from mesop.exceptions import format_traceback
from mesop.runtime import runtime
from mesop.server.config import app_config
from mesop.server.constants import WEB_COMPONENTS_PATH_SEGMENT
from mesop.warn import warn

LOCALHOSTS = (
Expand Down Expand Up @@ -58,6 +59,11 @@ def render_loop(
# (e.g. scroll into view) for the same context (e.g. multiple render loops
# when processing a generator handler function)
runtime().context().clear_commands()
js_modules = runtime().context().js_modules()
# Similar to above, clear JS modules after sending it once to minimize payload.
# Although it shouldn't cause any issue because client-side, each js module
# should only be imported once.
runtime().context().clear_js_modules()
data = pb.UiResponse(
render=pb.RenderEvent(
root_component=root_component,
Expand All @@ -67,6 +73,10 @@ def render_loop(
if prod_mode or not init_request
else get_component_configs(),
title=title,
js_modules=[
f"/{WEB_COMPONENTS_PATH_SEGMENT}{js_module}"
for js_module in js_modules
],
)
)
yield serialize(data)
Expand Down
14 changes: 1 addition & 13 deletions mesop/server/static_file_serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@

from mesop.exceptions import MesopException
from mesop.runtime import runtime
from mesop.server.constants import WEB_COMPONENTS_PATH_SEGMENT
from mesop.utils.runfiles import get_runfile_location, has_runfiles
from mesop.utils.url_utils import sanitize_url_for_csp

WEB_COMPONENTS_PATH_SEGMENT = "__web-components-module__"


# mimetypes are not always set correctly, thus manually
# setting the mimetype here.
# See: https://github.com/google/mesop/issues/441
Expand Down Expand Up @@ -51,16 +49,6 @@ def retrieve_index_html() -> io.BytesIO | str:
for i, line in enumerate(lines):
if "$$INSERT_CSP_NONCE$$" in line:
lines[i] = lines[i].replace("$$INSERT_CSP_NONCE$$", g.csp_nonce)
if (
runtime().js_modules
and line.strip() == "<!-- Inject web components modules (if needed) -->"
):
lines[i] = "\n".join(
[
f"<script type='module' nonce={g.csp_nonce} src='/{WEB_COMPONENTS_PATH_SEGMENT}{js_module}'></script>"
for js_module in runtime().js_modules
]
)
if (
livereload_script_url
and line.strip() == "<!-- Inject livereload script (if needed) -->"
Expand Down
24 changes: 24 additions & 0 deletions mesop/tests/e2e/web_components/csp_violations_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {test, expect} from '@playwright/test';

// Prevent regression where JS modules were loaded on every page,
// and not just pages where it was needed.
test('test CSP violations (from web components) are not shown on home page', async ({
page,
}) => {
const cspViolations: string[] = [];

// Listen for CSP violations
page.on('console', (msg) => {
if (
msg.type() === 'error' &&
msg.text().includes('Content Security Policy')
) {
cspViolations.push(msg.text());
}
});

await page.goto('/');
await page.waitForLoadState('networkidle');

expect(cspViolations).toHaveLength(0);
});
4 changes: 2 additions & 2 deletions mesop/tests/e2e/web_components/shared_js_module_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import {test, expect} from '@playwright/test';

test('web components - shared JS module', async ({page}) => {
await page.goto('/web_component/shared_js_module/shared_js_module_app');
expect(page.getByText('Loaded')).toBeVisible();
expect(
await expect(page.getByText('Loaded')).toBeVisible();
await expect(
page.getByText('value from shared module: shared_module.js'),
).toBeVisible();
});
1 change: 0 additions & 1 deletion mesop/web/src/app/editor/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,5 @@
<!-- Inject livereload script (if needed) -->
<script src="zone.js/bundles/zone.umd.js"></script>
<script src="editor_bundle/bundle.js" type="module"></script>
<!-- Inject web components modules (if needed) -->
</body>
</html>
1 change: 0 additions & 1 deletion mesop/web/src/app/prod/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,5 @@
<!-- Inject livereload script (if needed) -->
<script src="zone.js/bundles/zone.umd.js"></script>
<script src="prod_bundle.js" type="module"></script>
<!-- Inject web components modules (if needed) -->
</body>
</html>
7 changes: 6 additions & 1 deletion mesop/web/src/services/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface InitParams {
onRender: (
rootComponent: ComponentProto,
componentConfigs: readonly ComponentConfig[],
jsModules: readonly string[],
) => void;
onError: (error: ServerError) => void;
onCommand: (command: Command) => void;
Expand Down Expand Up @@ -200,7 +201,11 @@ export class Channel {
this.componentConfigs = uiResponse
.getRender()!
.getComponentConfigsList();
onRender(this.rootComponent, this.componentConfigs);
onRender(
this.rootComponent,
this.componentConfigs,
uiResponse.getRender()!.getJsModulesList(),
);
this.logger.log({
type: 'RenderLog',
states: this.states,
Expand Down
21 changes: 20 additions & 1 deletion mesop/web/src/shell/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,26 @@ export class Shell {
this.channel.init(
{
zone: this.zone,
onRender: (rootComponent, componentConfigs) => {
onRender: async (rootComponent, componentConfigs, jsModules) => {
// Import all JS modules concurrently
await Promise.all(
jsModules.map((modulePath) =>
import(modulePath)
.then(() =>
console.debug(
`Successfully imported JS module: ${modulePath}`,
),
)
.catch((error) =>
console.error(
`Failed to import JS module: ${modulePath}`,
error,
),
),
),
).then(() => {
console.debug('All JS modules imported');
});
this.rootComponent = rootComponent;
// Component configs are only sent for the first response.
// For subsequent reponses, use the component configs previously
Expand Down
Loading