From 4548f29f0cc0bbeca239233f67fdd0660fcf6db4 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Mon, 9 Dec 2024 15:24:58 +0100 Subject: [PATCH 1/3] feat: implement local storage --- src/ui/src/core/index.ts | 18 +++++++++++++++++- src/ui/src/writerTypes.ts | 9 +++++++++ src/writer/app_runner.py | 2 +- src/writer/core.py | 19 ++++++++++++++++--- src/writer/serve.py | 3 ++- src/writer/ss_types.py | 2 ++ 6 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/ui/src/core/index.ts b/src/ui/src/core/index.ts index 7a1e33f70..f483d8f22 100644 --- a/src/ui/src/core/index.ts +++ b/src/ui/src/core/index.ts @@ -5,7 +5,7 @@ import { AbstractTemplate, Component, ComponentMap, - InstancePath, + InstancePath, LocalStorageRemoveItemEvent, LocalStorageSetItemEvent, MailItem, UserFunction, } from "@/writerTypes"; @@ -59,6 +59,12 @@ export function generateCore() { addMailSubscription("pageChange", (pageKey: string) => { setActivePageFromKey(pageKey); }); + addMailSubscription("localStorageSetItem", (event) => { + localStorage.setItem("wf." + event.key, JSON.stringify(event.value)); + }); + addMailSubscription("localStorageRemoveItem", (event) => { + localStorage.removeItem("wf." + event.key); + }); sendKeepAliveMessage(); if (mode.value != "edit") return; } @@ -69,6 +75,15 @@ export function generateCore() { * @returns */ async function initSession() { + const localStorageItems = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith("wf.")) { + const value = localStorage.getItem(key); + localStorageItems[key.replace("wf.", "")] = JSON.parse(value); + } + } + const response = await fetch("./api/init", { method: "post", cache: "no-store", @@ -77,6 +92,7 @@ export function generateCore() { }, body: JSON.stringify({ proposedSessionId: sessionId, + localStorage: localStorageItems, }), }); const initData = await response.json(); diff --git a/src/ui/src/writerTypes.ts b/src/ui/src/writerTypes.ts index 4eb9d554a..d47c80c28 100644 --- a/src/ui/src/writerTypes.ts +++ b/src/ui/src/writerTypes.ts @@ -164,3 +164,12 @@ export type AbstractTemplate = { }; export type TemplateMap = Record; + +export type LocalStorageSetItemEvent = { + key: string; + value: string; +}; + +export type LocalStorageRemoveItemEvent = { + key: string; +}; diff --git a/src/writer/app_runner.py b/src/writer/app_runner.py index 5531a59ce..6cd25b48d 100644 --- a/src/writer/app_runner.py +++ b/src/writer/app_runner.py @@ -145,7 +145,7 @@ def _handle_session_init(self, payload: InitSessionRequestPayload) -> InitSessio session = writer.session_manager.get_session(payload.proposedSessionId, restore_initial_mail=True) if session is None: - session = writer.session_manager.get_new_session(payload.cookies, payload.headers, payload.proposedSessionId) + session = writer.session_manager.get_new_session(payload.cookies, payload.headers, payload.localStorage, payload.proposedSessionId) if session is None: raise MessageHandlingException("Session rejected.") diff --git a/src/writer/core.py b/src/writer/core.py index 9c8fb56fa..bf4279ab6 100644 --- a/src/writer/core.py +++ b/src/writer/core.py @@ -138,10 +138,11 @@ class WriterSession: Represents a session. """ - def __init__(self, session_id: str, cookies: Optional[Dict[str, str]], headers: Optional[Dict[str, str]]) -> None: + def __init__(self, session_id: str, cookies: Optional[Dict[str, str]], headers: Optional[Dict[str, str]], local_storage: Optional[Dict[str, Any]]) -> None: self.session_id = session_id self.cookies = cookies self.headers = headers + self.local_storage = local_storage self.last_active_timestamp: int = int(time.time()) new_state = WriterState.get_new() new_state.user_state.mutated = set() @@ -1078,6 +1079,17 @@ def call_frontend_function(self, module_key: str, function_name: str, args: List "args": args }) + def local_storage_set_item(self, key: str, value: Any) -> None: + self.add_mail("localStorageSetItem", { + "key": key, + "value": value + }) + + def local_storage_remove_item(self, key: str, value: Any) -> None: + self.add_mail("localStorageRemoveItem", { + "key": key + }) + class MiddlewareExecutor(): """ A MiddlewareExecutor executes middleware in a controlled context. It allows writer framework @@ -1520,7 +1532,7 @@ def _check_proposed_session_id(self, proposed_session_id: Optional[str]) -> bool return True return False - def get_new_session(self, cookies: Optional[Dict] = None, headers: Optional[Dict] = None, proposed_session_id: Optional[str] = None) -> Optional[WriterSession]: + def get_new_session(self, cookies: Optional[Dict] = None, headers: Optional[Dict] = None, local_storage: Optional[Dict[str, Any]] = None, proposed_session_id: Optional[str] = None) -> Optional[WriterSession]: if not self._check_proposed_session_id(proposed_session_id): return None if not self._verify_before_new_session(cookies, headers): @@ -1530,7 +1542,7 @@ def get_new_session(self, cookies: Optional[Dict] = None, headers: Optional[Dict new_id = self._generate_session_id() else: new_id = proposed_session_id - new_session = WriterSession(new_id, cookies, headers) + new_session = WriterSession(new_id, cookies, headers, local_storage) self.sessions[new_id] = new_session return new_session @@ -2040,6 +2052,7 @@ def _event_handler_session_info() -> Dict[str, Any]: session_info['cookies'] = current_session.cookies session_info['headers'] = current_session.headers session_info['userinfo'] = current_session.userinfo or {} + session_info['local_storage'] = current_session.local_storage or {} return session_info diff --git a/src/writer/serve.py b/src/writer/serve.py index caa310726..37cd9a0c9 100644 --- a/src/writer/serve.py +++ b/src/writer/serve.py @@ -293,7 +293,8 @@ async def init(initBody: InitRequestBody, request: Request, response: Response) app_response = await app_runner.init_session(InitSessionRequestPayload( cookies=dict(request.cookies), headers=dict(request.headers), - proposedSessionId=initBody.proposedSessionId + proposedSessionId=initBody.proposedSessionId, + localStorage=initBody.localStorage, )) status = app_response.status diff --git a/src/writer/ss_types.py b/src/writer/ss_types.py index 31191f960..46209a4a8 100644 --- a/src/writer/ss_types.py +++ b/src/writer/ss_types.py @@ -38,6 +38,7 @@ class AbstractTemplate(BaseModel): class InitRequestBody(BaseModel): proposedSessionId: Optional[str] = None + localStorage: Optional[Dict[str, Any]] = None class InitResponseBody(BaseModel): @@ -83,6 +84,7 @@ class AppProcessServerRequest(BaseModel): class InitSessionRequestPayload(BaseModel): cookies: Optional[Dict[str, str]] = None headers: Optional[Dict[str, str]] = None + localStorage: Optional[Dict[str, Any]] = None proposedSessionId: Optional[str] = None class InitSessionRequest(AppProcessServerRequest): From e57612cf1f0ae161a90295383856caf8b9e3fa73 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Wed, 11 Dec 2024 08:30:43 +0100 Subject: [PATCH 2/3] feat: implement local storage * feat: propagate local storage into session to keep it into sync --- src/ui/src/core/index.ts | 25 +++++++----- src/writer/app_runner.py | 1 - src/writer/core.py | 36 ++++++++++++++++- tests/backend/fixtures/app_runner_fixtures.py | 24 ++++++++++- tests/backend/test_core.py | 40 ++++++++++++++++++- 5 files changed, 110 insertions(+), 16 deletions(-) diff --git a/src/ui/src/core/index.ts b/src/ui/src/core/index.ts index f483d8f22..0db3830b9 100644 --- a/src/ui/src/core/index.ts +++ b/src/ui/src/core/index.ts @@ -59,12 +59,20 @@ export function generateCore() { addMailSubscription("pageChange", (pageKey: string) => { setActivePageFromKey(pageKey); }); - addMailSubscription("localStorageSetItem", (event) => { - localStorage.setItem("wf." + event.key, JSON.stringify(event.value)); - }); - addMailSubscription("localStorageRemoveItem", (event) => { - localStorage.removeItem("wf." + event.key); - }); + addMailSubscription( + "localStorageSetItem", + (event: LocalStorageSetItemEvent) => { + localStorage.setItem(event.key, event.value); + }, + ); + + addMailSubscription( + "localStorageRemoveItem", + (event: LocalStorageRemoveItemEvent) => { + localStorage.removeItem(event.key); + }, + ); + sendKeepAliveMessage(); if (mode.value != "edit") return; } @@ -78,10 +86,7 @@ export function generateCore() { const localStorageItems = {}; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key.startsWith("wf.")) { - const value = localStorage.getItem(key); - localStorageItems[key.replace("wf.", "")] = JSON.parse(value); - } + localStorageItems[key] = localStorage.getItem(key); } const response = await fetch("./api/init", { diff --git a/src/writer/app_runner.py b/src/writer/app_runner.py index 6cd25b48d..ff5651d64 100644 --- a/src/writer/app_runner.py +++ b/src/writer/app_runner.py @@ -177,7 +177,6 @@ def _handle_event(self, session: WriterSession, event: WriterEvent) -> EventResp import traceback as tb result = session.event_handler.handle(event) - mutations = {} try: diff --git a/src/writer/core.py b/src/writer/core.py index bf4279ab6..a48f01521 100644 --- a/src/writer/core.py +++ b/src/writer/core.py @@ -139,6 +139,9 @@ class WriterSession: """ def __init__(self, session_id: str, cookies: Optional[Dict[str, str]], headers: Optional[Dict[str, str]], local_storage: Optional[Dict[str, Any]]) -> None: + if local_storage is None: + local_storage = {} + self.session_id = session_id self.cookies = cookies self.headers = headers @@ -1079,17 +1082,46 @@ def call_frontend_function(self, module_key: str, function_name: str, args: List "args": args }) - def local_storage_set_item(self, key: str, value: Any) -> None: + def local_storage_set_item(self, key: str, value: str) -> None: + """ + Saves a value to the browser's local storage. + + >>> state.local_storage_set_item("my_key", "value") + + The value must be a string. If it is another type, it will be converted to a character string without serialization. + The browser's local storage values as text. + + It is recommended to serialize the data upstream, for example in JSON. + + >>> state.local_storage_set_item("my_key", json.dumps({"value": 1})) + """ + if not isinstance(value, str): + value = str(value) + self.add_mail("localStorageSetItem", { "key": key, "value": value }) - def local_storage_remove_item(self, key: str, value: Any) -> None: + _session = get_session() + assert _session is not None, "local_storage_set_item must be used within a user request." + _session.local_storage[key] = value + + def local_storage_remove_item(self, key: str) -> None: + """ + Removes a value from the browser's local storage. + + >>> state.local_storage_remove_item("my_key") + """ self.add_mail("localStorageRemoveItem", { "key": key }) + _session = get_session() + assert _session is not None, "local_storage_remove_item must be used within a user request." + if key in _session.local_storage: + del _session.local_storage[key] + class MiddlewareExecutor(): """ A MiddlewareExecutor executes middleware in a controlled context. It allows writer framework diff --git a/tests/backend/fixtures/app_runner_fixtures.py b/tests/backend/fixtures/app_runner_fixtures.py index 45f4a64de..27ea502cd 100644 --- a/tests/backend/fixtures/app_runner_fixtures.py +++ b/tests/backend/fixtures/app_runner_fixtures.py @@ -1,7 +1,9 @@ +import contextlib from typing import Optional from writer.app_runner import AppRunner -from writer.ss_types import InitSessionRequestPayload +from writer.core import session_manager, use_request_context +from writer.ss_types import AppProcessServerRequest, InitSessionRequestPayload FIXED_SESSION_ID = "0000000000000000000000000000000000000000000000000000000000000000" # Compliant session number @@ -32,3 +34,23 @@ async def init_app_session(app_runner: AppRunner, result = await app_runner.init_session(init_session_payload) return result.payload.model_dump().get("sessionId") + + +@contextlib.contextmanager +def within_message_request(session_id=FIXED_SESSION_ID, request: AppProcessServerRequest=None): + """ + This fixture starts a session and emulates the context of an event message. + + >>> with within_message_request(): + >>> _session = core.get_session() + >>> _session.local_storage['key'] = "value" + + :param session_id: the session identifier + :param request: the request to create + """ + if request is None: + request = AppProcessServerRequest(type="event") + + session_manager.get_new_session(proposed_session_id=session_id) + with use_request_context(session_id=session_id, request=request): + yield diff --git a/tests/backend/test_core.py b/tests/backend/test_core.py index bb1d1e04c..de9ba5dd2 100644 --- a/tests/backend/test_core.py +++ b/tests/backend/test_core.py @@ -15,7 +15,7 @@ import pyarrow as pa import pytest import writer as wf -from writer import audit_and_fix, wf_project +from writer import audit_and_fix, core, wf_project from writer.core import ( BytesWrapper, EventDeserialiser, @@ -31,6 +31,7 @@ ) from writer.ss_types import WriterEvent +from backend.fixtures import app_runner_fixtures from tests.backend import test_app_dir from tests.backend.fixtures import ( writer_fixtures, @@ -155,7 +156,7 @@ def test_apply_mutation_marker(self) -> None: '+name': 'Robert', '+state\\.with\\.dots': None, '+utfࠀ': 23, - '+a\.b': 3 + r'+a\.b': 3 } self.sp_simple_dict.apply_mutation_marker() @@ -751,6 +752,40 @@ def test_unpickable_members(self) -> None: json.dumps(cloned.user_state.to_dict()) json.dumps(cloned.mail) + def test_local_storage_set_item_should_update_backend_localstorage(self): + """ + Tests that setting an item in the local storage updates the backend local storage + """ + with app_runner_fixtures.within_message_request(): + # Assign + _state = WriterState({"a": 1, "b": 2}) + + # Acts + _state.local_storage_set_item("tab", "tab1") + + # Assert + _session = core.get_session() + assert _session.local_storage['tab'] == "tab1" + assert _state.mail[0].get("type") == "localStorageSetItem" + assert _state.mail[0].get("payload") == {"key": "tab", "value": "tab1"} + + def test_local_storage_set_item_should_set_value_as_string(self): + """ + Tests that using local_storage_set_item forces the value to be a string. + """ + with app_runner_fixtures.within_message_request(): + # Assign + _state = WriterState({"a": 1, "b": 2}) + + # Acts + _state.local_storage_set_item("tab", 0) + + # Assert + _session = core.get_session() + assert _session.local_storage['tab'] == "0" + assert _state.mail[0].get("type") == "localStorageSetItem" + assert _state.mail[0].get("payload") == {"key": "tab", "value": "0"} + class TestEventDeserialiser: @@ -1222,6 +1257,7 @@ def test_get_new_session_proposed(self) -> None: self.sm.get_new_session( {"testCookie": "yes"}, {"origin": "example.com"}, + {}, self.proposed_session_id ) self.sm.get_session(self.proposed_session_id) From 5aa4b0b017bedadc7c359a6848c87197401c1bbc Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Thu, 26 Dec 2024 09:07:36 +0100 Subject: [PATCH 3/3] feat: implement local storage * doc: write documentation about interaction with local storage --- docs/framework/frontend-scripts.mdx | 42 +++++++++++++++++++++++++++-- docs/framework/sessions.mdx | 2 +- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/docs/framework/frontend-scripts.mdx b/docs/framework/frontend-scripts.mdx index b1fa0fa11..96a121c84 100644 --- a/docs/framework/frontend-scripts.mdx +++ b/docs/framework/frontend-scripts.mdx @@ -1,8 +1,8 @@ --- -title: "Frontend scripts" +title: "Frontend actions" --- -Framework can import custom JavaScript/ES6 modules from the front-end. Module functions can be triggered from the back-end. +Framework can interact with frontend to import custom JavaScript/ES6 modules, set data into local storage, trigger module functions, ... ## Importing an ES6 module @@ -85,6 +85,44 @@ initial_state = wf.init_state({ initial_state.import_script("lodash", "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.js") ``` +## Local storage + +Framework provides functions to interact with browser's local storage. This mechanism allows information such as user preferences to be persisted between several sessions. + +* `state.local_storage_set_item(key, value)`: set a value in local storage. +* `state.local_storage_remove_item(key)`: remove a key from local storage. + +```python +# Event handler register on root:wf-app-open +def on_app_open(state, session): + state['last_visit'] = session['local_storage'].get('last_visit', "") + state['dark_mode'] = session['local_storage'].get('dark_mode', "False") == "True" + state.local_storage_set_item("last_visit", str(datetime.now())) + + +def on_dark_mode_toggle(state, payload): + state['dark_mode'] = payload + state.local_storage_set_item("dark_mode", str(state['dark_mode'])) + + +initial_state = wf.init_state({ + "last_visit": "", + "dark_mode": False +}) +``` + + + Framework propose a binding on [Local storage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). It support only raw value. Your application has to serialize the data before storing it. Framework won't do it for you. + + + + Local storage is not secure and should not be used to store sensitive data without encryption. It's accessible to anyone with access to the user's device. + + + + Content of local storage is loaded in `session` on the init request. If a JS function change a value inside, it won't be reflected. Change from the backend are reflected. + + ## Frontend core diff --git a/docs/framework/sessions.mdx b/docs/framework/sessions.mdx index d9784a133..af822a7b1 100644 --- a/docs/framework/sessions.mdx +++ b/docs/framework/sessions.mdx @@ -8,7 +8,7 @@ Sessions are designed for advanced use cases, being most relevant when Framework You can access the session's unique id, HTTP headers and cookies from event handlers via the `session` argument —similarly to how `state` and `payload` are accessed. The data made available here is captured in the HTTP request that initialised the session. -The `session` argument will contain a dictionary with the following keys: `id`, `cookies` and `headers`. Values for the last two are themselves dictionaries. +The `session` argument will contain a dictionary with the following keys: `id`, `cookies`, `headers` and `local_storage`. Values for the last three are themselves dictionaries. ```py # The following will output a dictionary