pulse-framework 0.1.38a5__py3-none-any.whl → 0.1.38a7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse/__init__.py +4 -0
- pulse/app.py +150 -56
- pulse/codegen/templates/layout.py +18 -3
- pulse/messages.py +22 -0
- pulse/middleware.py +64 -3
- pulse/plugin.py +1 -0
- {pulse_framework-0.1.38a5.dist-info → pulse_framework-0.1.38a7.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.38a5.dist-info → pulse_framework-0.1.38a7.dist-info}/RECORD +10 -10
- {pulse_framework-0.1.38a5.dist-info → pulse_framework-0.1.38a7.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.38a5.dist-info → pulse_framework-0.1.38a7.dist-info}/entry_points.txt +0 -0
pulse/__init__.py
CHANGED
|
@@ -1300,6 +1300,10 @@ from pulse.html.tags import (
|
|
|
1300
1300
|
from pulse.html.tags import (
|
|
1301
1301
|
wbr as wbr,
|
|
1302
1302
|
)
|
|
1303
|
+
from pulse.messages import Directives as Directives
|
|
1304
|
+
from pulse.messages import PrerenderPayload as PrerenderPayload
|
|
1305
|
+
from pulse.messages import PrerenderResult as PrerenderResult
|
|
1306
|
+
from pulse.messages import SocketIODirectives as SocketIODirectives
|
|
1303
1307
|
|
|
1304
1308
|
# Middleware
|
|
1305
1309
|
from pulse.middleware import (
|
pulse/app.py
CHANGED
|
@@ -5,13 +5,14 @@ This module provides the main App class that users instantiate in their main.py
|
|
|
5
5
|
to define routes and configure their Pulse application.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import asyncio
|
|
8
9
|
import logging
|
|
9
10
|
import os
|
|
10
11
|
from collections import defaultdict
|
|
11
12
|
from collections.abc import Awaitable, Sequence
|
|
12
13
|
from contextlib import asynccontextmanager
|
|
13
14
|
from enum import IntEnum
|
|
14
|
-
from typing import Any, Callable, Literal,
|
|
15
|
+
from typing import Any, Callable, Literal, TypeVar, cast
|
|
15
16
|
|
|
16
17
|
import socketio
|
|
17
18
|
import uvicorn
|
|
@@ -56,7 +57,8 @@ from pulse.messages import (
|
|
|
56
57
|
ClientChannelResponseMessage,
|
|
57
58
|
ClientMessage,
|
|
58
59
|
ClientPulseMessage,
|
|
59
|
-
|
|
60
|
+
PrerenderPayload,
|
|
61
|
+
PrerenderResult,
|
|
60
62
|
ServerMessage,
|
|
61
63
|
)
|
|
62
64
|
from pulse.middleware import (
|
|
@@ -73,7 +75,7 @@ from pulse.proxy import PulseProxy
|
|
|
73
75
|
from pulse.react_component import ReactComponent, registered_react_components
|
|
74
76
|
from pulse.render_session import RenderSession
|
|
75
77
|
from pulse.request import PulseRequest
|
|
76
|
-
from pulse.routing import Layout, Route,
|
|
78
|
+
from pulse.routing import Layout, Route, RouteTree
|
|
77
79
|
from pulse.serializer import Serialized, deserialize, serialize
|
|
78
80
|
from pulse.user_session import (
|
|
79
81
|
CookieSessionStore,
|
|
@@ -91,24 +93,13 @@ class AppStatus(IntEnum):
|
|
|
91
93
|
created = 0
|
|
92
94
|
initialized = 1
|
|
93
95
|
running = 2
|
|
94
|
-
|
|
96
|
+
draining = 3
|
|
97
|
+
stopped = 4
|
|
95
98
|
|
|
96
99
|
|
|
97
100
|
PulseMode = Literal["subdomains", "single-server"]
|
|
98
101
|
|
|
99
102
|
|
|
100
|
-
class PrerenderPayload(TypedDict):
|
|
101
|
-
paths: list[str]
|
|
102
|
-
routeInfo: RouteInfo
|
|
103
|
-
ttlSeconds: NotRequired[float | int]
|
|
104
|
-
renderId: NotRequired[str]
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
class PrerenderResult(TypedDict):
|
|
108
|
-
renderId: str
|
|
109
|
-
views: dict[str, ServerInitMessage | None]
|
|
110
|
-
|
|
111
|
-
|
|
112
103
|
class App:
|
|
113
104
|
"""
|
|
114
105
|
Pulse UI Application - the main entry point for defining your app.
|
|
@@ -151,6 +142,8 @@ class App:
|
|
|
151
142
|
_render_to_user: dict[str, str]
|
|
152
143
|
_sessions_in_request: dict[str, int]
|
|
153
144
|
_socket_to_render: dict[str, str]
|
|
145
|
+
_render_cleanups: dict[str, asyncio.TimerHandle]
|
|
146
|
+
session_timeout: float
|
|
154
147
|
|
|
155
148
|
def __init__(
|
|
156
149
|
self,
|
|
@@ -170,6 +163,7 @@ class App:
|
|
|
170
163
|
api_prefix: str = "/_pulse",
|
|
171
164
|
cors: CORSOptions | None = None,
|
|
172
165
|
fastapi: dict[str, Any] | None = None,
|
|
166
|
+
session_timeout: float = 60.0,
|
|
173
167
|
):
|
|
174
168
|
"""
|
|
175
169
|
Initialize a new Pulse App.
|
|
@@ -225,6 +219,9 @@ class App:
|
|
|
225
219
|
self._sessions_in_request = {}
|
|
226
220
|
# Map websocket sid -> renderId for message routing
|
|
227
221
|
self._socket_to_render = {}
|
|
222
|
+
# Map render_id -> cleanup timer handle for timeout-based expiry
|
|
223
|
+
self._render_cleanups = {}
|
|
224
|
+
self.session_timeout = session_timeout
|
|
228
225
|
|
|
229
226
|
self.codegen = Codegen(
|
|
230
227
|
self.routes,
|
|
@@ -277,6 +274,11 @@ class App:
|
|
|
277
274
|
try:
|
|
278
275
|
yield
|
|
279
276
|
finally:
|
|
277
|
+
try:
|
|
278
|
+
await self.close()
|
|
279
|
+
except Exception:
|
|
280
|
+
logger.exception("Error during App.close()")
|
|
281
|
+
|
|
280
282
|
try:
|
|
281
283
|
if isinstance(self.session_store, SessionStore):
|
|
282
284
|
await self.session_store.close()
|
|
@@ -372,7 +374,6 @@ class App:
|
|
|
372
374
|
else:
|
|
373
375
|
# Use deployment-specific CORS settings
|
|
374
376
|
cors_config = cors_options(self.mode, self.server_address)
|
|
375
|
-
print(f"CORS config: {cors_config}")
|
|
376
377
|
self.fastapi.add_middleware(
|
|
377
378
|
CORSMiddleware,
|
|
378
379
|
**cors_config,
|
|
@@ -380,7 +381,7 @@ class App:
|
|
|
380
381
|
|
|
381
382
|
# Debug middleware to log CORS-related request details
|
|
382
383
|
# @self.fastapi.middleware("http")
|
|
383
|
-
# async def cors_debug_middleware(
|
|
384
|
+
# async def cors_debug_middleware(
|
|
384
385
|
# request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
385
386
|
# ):
|
|
386
387
|
# origin = request.headers.get("origin")
|
|
@@ -417,10 +418,9 @@ class App:
|
|
|
417
418
|
self._sessions_in_request.get(session.sid, 0) + 1
|
|
418
419
|
)
|
|
419
420
|
render_id = request.headers.get("x-pulse-render-id")
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
render = None
|
|
421
|
+
render = self._get_render_for_session(render_id, session)
|
|
422
|
+
if render:
|
|
423
|
+
print(f"Reusing render session {render_id}")
|
|
424
424
|
with PulseContext.update(session=session, render=render):
|
|
425
425
|
res: Response = await call_next(request)
|
|
426
426
|
session.handle_response(res)
|
|
@@ -447,7 +447,8 @@ class App:
|
|
|
447
447
|
async def prerender(payload: PrerenderPayload, request: Request): # pyright: ignore[reportUnusedFunction]
|
|
448
448
|
"""
|
|
449
449
|
POST /prerender
|
|
450
|
-
Body: { paths: string[], routeInfo: RouteInfo, ttlSeconds?: number
|
|
450
|
+
Body: { paths: string[], routeInfo: RouteInfo, ttlSeconds?: number }
|
|
451
|
+
Headers: X-Pulse-Render-Id (optional, for render session reuse)
|
|
451
452
|
Returns: { renderId: string, <path>: VDOM, ... }
|
|
452
453
|
"""
|
|
453
454
|
session = PulseContext.get().session
|
|
@@ -459,31 +460,29 @@ class App:
|
|
|
459
460
|
status_code=400, detail="'paths' must be a non-empty list"
|
|
460
461
|
)
|
|
461
462
|
route_info = payload.get("routeInfo")
|
|
462
|
-
ttl = payload.get("ttlSeconds")
|
|
463
|
-
if not isinstance(ttl, (int, float)):
|
|
464
|
-
ttl = 15
|
|
465
463
|
|
|
466
464
|
client_addr: str | None = get_client_address(request)
|
|
467
|
-
#
|
|
468
|
-
|
|
469
|
-
if
|
|
470
|
-
|
|
471
|
-
existing = self.render_sessions.get(render_id)
|
|
472
|
-
if existing is None:
|
|
473
|
-
raise HTTPException(status_code=400, detail="Unknown renderId")
|
|
474
|
-
owner = self._render_to_user.get(render_id)
|
|
475
|
-
if owner != session.sid:
|
|
476
|
-
raise HTTPException(status_code=403, detail="Forbidden renderId")
|
|
477
|
-
render = existing
|
|
478
|
-
cleanup = False
|
|
465
|
+
# Reuse render session from header (set by middleware) or create new one
|
|
466
|
+
render = PulseContext.get().render
|
|
467
|
+
if render is not None:
|
|
468
|
+
render_id = render.id
|
|
479
469
|
else:
|
|
470
|
+
# Create new render session
|
|
480
471
|
render_id = new_sid()
|
|
481
472
|
render = self.create_render(
|
|
482
473
|
render_id, session, client_address=client_addr
|
|
483
474
|
)
|
|
484
|
-
cleanup = True
|
|
485
475
|
|
|
486
|
-
|
|
476
|
+
# Schedule cleanup timeout (will cancel/reschedule on activity)
|
|
477
|
+
self._schedule_render_cleanup(render_id)
|
|
478
|
+
|
|
479
|
+
initial_result: PrerenderResult = {
|
|
480
|
+
"views": {},
|
|
481
|
+
"directives": {
|
|
482
|
+
"headers": {"X-Pulse-Render-Id": render_id},
|
|
483
|
+
"socketio": {"auth": {"render_id": render_id}, "headers": {}},
|
|
484
|
+
},
|
|
485
|
+
}
|
|
487
486
|
|
|
488
487
|
def _prerender_one(path: str):
|
|
489
488
|
captured = render.prerender_mount_capture(path, route_info)
|
|
@@ -499,10 +498,12 @@ class App:
|
|
|
499
498
|
# Fallback: shouldn't happen, return not found to be safe
|
|
500
499
|
return NotFound()
|
|
501
500
|
|
|
501
|
+
result = initial_result.copy()
|
|
502
|
+
|
|
502
503
|
with PulseContext.update(render=render):
|
|
503
504
|
for p in paths:
|
|
504
505
|
try:
|
|
505
|
-
res = self.middleware.
|
|
506
|
+
res = self.middleware.prerender_route(
|
|
506
507
|
path=p,
|
|
507
508
|
route_info=route_info,
|
|
508
509
|
request=PulseRequest.from_fastapi(request),
|
|
@@ -533,19 +534,19 @@ class App:
|
|
|
533
534
|
session.handle_response(resp)
|
|
534
535
|
return resp
|
|
535
536
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
if r is None:
|
|
540
|
-
return
|
|
541
|
-
if r.connected:
|
|
542
|
-
return
|
|
543
|
-
self.close_render(rid)
|
|
537
|
+
# Call top-level batch prerender middleware to modify final result
|
|
538
|
+
def _return_result() -> PrerenderResult:
|
|
539
|
+
return result
|
|
544
540
|
|
|
545
|
-
|
|
546
|
-
|
|
541
|
+
final_result = self.middleware.prerender(
|
|
542
|
+
payload=payload,
|
|
543
|
+
result=result,
|
|
544
|
+
request=PulseRequest.from_fastapi(request),
|
|
545
|
+
session=session.data,
|
|
546
|
+
next=_return_result,
|
|
547
|
+
)
|
|
547
548
|
|
|
548
|
-
resp = JSONResponse(serialize(
|
|
549
|
+
resp = JSONResponse(serialize(final_result))
|
|
549
550
|
session.handle_response(resp)
|
|
550
551
|
return resp
|
|
551
552
|
|
|
@@ -572,7 +573,7 @@ class App:
|
|
|
572
573
|
sid: str, environ: dict[str, Any], auth: dict[str, str] | None
|
|
573
574
|
):
|
|
574
575
|
# Expect renderId during websocket auth and require a valid user session
|
|
575
|
-
rid = auth.get("
|
|
576
|
+
rid = auth.get("render_id") if auth else None
|
|
576
577
|
|
|
577
578
|
# Parse cookies from environ and ensure a session exists
|
|
578
579
|
cookie = self.cookie.get_from_socketio(environ)
|
|
@@ -605,6 +606,9 @@ class App:
|
|
|
605
606
|
# Map socket sid to renderId for message routing
|
|
606
607
|
self._socket_to_render[sid] = rid
|
|
607
608
|
|
|
609
|
+
# Cancel any pending cleanup since session is now connected
|
|
610
|
+
self._cancel_render_cleanup(rid)
|
|
611
|
+
|
|
608
612
|
with PulseContext.update(session=session, render=render):
|
|
609
613
|
|
|
610
614
|
def _next():
|
|
@@ -620,6 +624,7 @@ class App:
|
|
|
620
624
|
render.report_error("/", "connect", exc)
|
|
621
625
|
res = Ok(None)
|
|
622
626
|
if isinstance(res, Deny):
|
|
627
|
+
print(f"Denying connection, closing RenderSession {rid}")
|
|
623
628
|
# Tear down the created session if denied
|
|
624
629
|
self.close_render(rid)
|
|
625
630
|
|
|
@@ -627,8 +632,12 @@ class App:
|
|
|
627
632
|
def disconnect(sid: str): # pyright: ignore[reportUnusedFunction]
|
|
628
633
|
rid = self._socket_to_render.pop(sid, None)
|
|
629
634
|
if rid is not None:
|
|
630
|
-
|
|
631
|
-
self.
|
|
635
|
+
print(f"Disconnecting WebSocket for RenderSession {rid}")
|
|
636
|
+
render = self.render_sessions.get(rid)
|
|
637
|
+
if render:
|
|
638
|
+
render.connected = False
|
|
639
|
+
# Schedule cleanup after timeout (will keep session alive for reuse)
|
|
640
|
+
self._schedule_render_cleanup(rid)
|
|
632
641
|
|
|
633
642
|
@self.sio.event
|
|
634
643
|
def message(sid: str, data: Serialized): # pyright: ignore[reportUnusedFunction]
|
|
@@ -638,6 +647,8 @@ class App:
|
|
|
638
647
|
render = self.render_sessions.get(rid)
|
|
639
648
|
if render is None:
|
|
640
649
|
return
|
|
650
|
+
# Cancel any pending cleanup for active sessions (connected sessions stay alive)
|
|
651
|
+
self._cancel_render_cleanup(rid)
|
|
641
652
|
# Use renderId mapping to user session
|
|
642
653
|
session = self.user_sessions[self._render_to_user[rid]]
|
|
643
654
|
# Make sure to properly deserialize the message contents
|
|
@@ -653,6 +664,39 @@ class App:
|
|
|
653
664
|
|
|
654
665
|
self.status = AppStatus.initialized
|
|
655
666
|
|
|
667
|
+
def _cancel_render_cleanup(self, rid: str):
|
|
668
|
+
"""Cancel any pending cleanup task for a render session."""
|
|
669
|
+
cleanup_handle = self._render_cleanups.pop(rid, None)
|
|
670
|
+
if cleanup_handle and not cleanup_handle.cancelled():
|
|
671
|
+
cleanup_handle.cancel()
|
|
672
|
+
|
|
673
|
+
def _schedule_render_cleanup(self, rid: str):
|
|
674
|
+
"""Schedule cleanup of a RenderSession after the configured timeout."""
|
|
675
|
+
render = self.render_sessions.get(rid)
|
|
676
|
+
if render is None:
|
|
677
|
+
return
|
|
678
|
+
# Don't schedule cleanup for connected sessions (they stay alive)
|
|
679
|
+
if render.connected:
|
|
680
|
+
return
|
|
681
|
+
|
|
682
|
+
# Cancel any existing cleanup task for this render session
|
|
683
|
+
self._cancel_render_cleanup(rid)
|
|
684
|
+
|
|
685
|
+
# Schedule new cleanup task
|
|
686
|
+
def _cleanup():
|
|
687
|
+
render = self.render_sessions.get(rid)
|
|
688
|
+
if render is None:
|
|
689
|
+
return
|
|
690
|
+
# Only cleanup if not connected (if connected, keep it alive)
|
|
691
|
+
if not render.connected:
|
|
692
|
+
logger.info(
|
|
693
|
+
f"RenderSession {rid} expired after {self.session_timeout}s timeout"
|
|
694
|
+
)
|
|
695
|
+
self.close_render(rid)
|
|
696
|
+
|
|
697
|
+
handle = later(self.session_timeout, _cleanup)
|
|
698
|
+
self._render_cleanups[rid] = handle
|
|
699
|
+
|
|
656
700
|
def _handle_pulse_message(
|
|
657
701
|
self, render: RenderSession, session: UserSession, msg: ClientPulseMessage
|
|
658
702
|
) -> None:
|
|
@@ -787,11 +831,29 @@ class App:
|
|
|
787
831
|
self.user_sessions[sid] = session
|
|
788
832
|
return session
|
|
789
833
|
|
|
834
|
+
def _get_render_for_session(
|
|
835
|
+
self, render_id: str | None, session: UserSession
|
|
836
|
+
) -> RenderSession | None:
|
|
837
|
+
"""
|
|
838
|
+
Get an existing render session for the given session, validating ownership.
|
|
839
|
+
Returns None if render_id is None, render doesn't exist, or doesn't belong to session.
|
|
840
|
+
"""
|
|
841
|
+
if not render_id:
|
|
842
|
+
return None
|
|
843
|
+
render = self.render_sessions.get(render_id)
|
|
844
|
+
if render is None:
|
|
845
|
+
return None
|
|
846
|
+
owner = self._render_to_user.get(render_id)
|
|
847
|
+
if owner != session.sid:
|
|
848
|
+
return None
|
|
849
|
+
return render
|
|
850
|
+
|
|
790
851
|
def create_render(
|
|
791
852
|
self, rid: str, session: UserSession, *, client_address: str | None = None
|
|
792
853
|
):
|
|
793
854
|
if rid in self.render_sessions:
|
|
794
855
|
raise ValueError(f"RenderSession {rid} already exists")
|
|
856
|
+
print(f"Creating RenderSession {rid}")
|
|
795
857
|
render = RenderSession(
|
|
796
858
|
rid,
|
|
797
859
|
self.routes,
|
|
@@ -804,9 +866,13 @@ class App:
|
|
|
804
866
|
return render
|
|
805
867
|
|
|
806
868
|
def close_render(self, rid: str):
|
|
869
|
+
# Cancel any pending cleanup task
|
|
870
|
+
self._cancel_render_cleanup(rid)
|
|
871
|
+
|
|
807
872
|
render = self.render_sessions.pop(rid, None)
|
|
808
873
|
if not render:
|
|
809
874
|
return
|
|
875
|
+
print(f"Closing RenderSession {rid}")
|
|
810
876
|
sid = self._render_to_user.pop(rid)
|
|
811
877
|
session = self.user_sessions[sid]
|
|
812
878
|
render.close()
|
|
@@ -825,6 +891,34 @@ class App:
|
|
|
825
891
|
if len(self._user_to_render[sid]) == 0:
|
|
826
892
|
self.close_session(sid)
|
|
827
893
|
|
|
894
|
+
async def close(self):
|
|
895
|
+
"""
|
|
896
|
+
Close the app and clean up all sessions.
|
|
897
|
+
This method is called automatically during shutdown.
|
|
898
|
+
"""
|
|
899
|
+
|
|
900
|
+
# Cancel all pending cleanup tasks
|
|
901
|
+
for rid in list(self._render_cleanups.keys()):
|
|
902
|
+
self._cancel_render_cleanup(rid)
|
|
903
|
+
|
|
904
|
+
# Close all render sessions
|
|
905
|
+
print("Closing app")
|
|
906
|
+
for rid in list(self.render_sessions.keys()):
|
|
907
|
+
self.close_render(rid)
|
|
908
|
+
|
|
909
|
+
# Close all user sessions
|
|
910
|
+
for sid in list(self.user_sessions.keys()):
|
|
911
|
+
self.close_session(sid)
|
|
912
|
+
|
|
913
|
+
# Update status
|
|
914
|
+
self.status = AppStatus.stopped
|
|
915
|
+
# Call plugin on_shutdown hooks before closing
|
|
916
|
+
for plugin in self.plugins:
|
|
917
|
+
try:
|
|
918
|
+
plugin.on_shutdown(self)
|
|
919
|
+
except Exception:
|
|
920
|
+
logger.exception("Error during plugin.on_shutdown()")
|
|
921
|
+
|
|
828
922
|
def refresh_cookies(self, sid: str):
|
|
829
923
|
# If the session is currently inside an HTTP request, we don't need to schedule
|
|
830
924
|
# set-cookies via WS; cookies will be attached on the HTTP response.
|
|
@@ -845,7 +939,7 @@ class App:
|
|
|
845
939
|
return # no active render for this user session
|
|
846
940
|
|
|
847
941
|
# We don't want to wait for this to resolve
|
|
848
|
-
create_task(render.call_api("/set-cookies", method="GET"))
|
|
942
|
+
create_task(render.call_api(f"/{self.api_prefix}/set-cookies", method="GET"))
|
|
849
943
|
sess.scheduled_cookie_refresh = True
|
|
850
944
|
|
|
851
945
|
|
|
@@ -54,9 +54,19 @@ export async function clientLoader(args: ClientLoaderFunctionArgs) {
|
|
|
54
54
|
typeof window !== "undefined" && typeof sessionStorage !== "undefined"
|
|
55
55
|
? (sessionStorage.getItem("__PULSE_RENDER_ID") ?? undefined)
|
|
56
56
|
: undefined;
|
|
57
|
+
const directives =
|
|
58
|
+
typeof window !== "undefined" && typeof sessionStorage !== "undefined"
|
|
59
|
+
? (JSON.parse(sessionStorage.getItem("__PULSE_DIRECTIVES") ?? "{}"))
|
|
60
|
+
: {};
|
|
61
|
+
const headers: HeadersInit = { "content-type": "application/json" };
|
|
62
|
+
if (directives?.headers) {
|
|
63
|
+
for (const [key, value] of Object.entries(directives.headers)) {
|
|
64
|
+
headers[key] = value as string;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
57
67
|
const res = await fetch(`$${"{"}config.serverAddress}$${"{"}config.apiPrefix}/prerender`, {
|
|
58
68
|
method: "POST",
|
|
59
|
-
headers
|
|
69
|
+
headers,
|
|
60
70
|
credentials: "include",
|
|
61
71
|
body: JSON.stringify({ paths, routeInfo: extractServerRouteInfo(args), renderId }),
|
|
62
72
|
});
|
|
@@ -64,13 +74,18 @@ export async function clientLoader(args: ClientLoaderFunctionArgs) {
|
|
|
64
74
|
const body = await res.json();
|
|
65
75
|
if (body.redirect) return new Response(null, { status: 302, headers: { Location: body.redirect } });
|
|
66
76
|
if (body.notFound) return new Response(null, { status: 404 });
|
|
67
|
-
|
|
77
|
+
const prerenderData = deserialize(body) as PulsePrerender;
|
|
78
|
+
if (typeof window !== "undefined" && typeof sessionStorage !== "undefined" && prerenderData.directives) {
|
|
79
|
+
sessionStorage.setItem("__PULSE_DIRECTIVES", JSON.stringify(prerenderData.directives));
|
|
80
|
+
}
|
|
81
|
+
return prerenderData as PulsePrerender;
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
export default function PulseLayout() {
|
|
71
85
|
const data = useLoaderData<typeof loader>();
|
|
72
86
|
if (typeof window !== "undefined" && typeof sessionStorage !== "undefined") {
|
|
73
87
|
sessionStorage.setItem("__PULSE_RENDER_ID", data.renderId);
|
|
88
|
+
sessionStorage.setItem("__PULSE_DIRECTIVES", JSON.stringify(data.directives));
|
|
74
89
|
}
|
|
75
90
|
return (
|
|
76
91
|
<PulseProvider config={config} prerender={data}>
|
|
@@ -78,6 +93,6 @@ export default function PulseLayout() {
|
|
|
78
93
|
</PulseProvider>
|
|
79
94
|
);
|
|
80
95
|
}
|
|
81
|
-
// Persist renderId in sessionStorage for reuse in clientLoader is handled within the component
|
|
96
|
+
// Persist renderId and directives in sessionStorage for reuse in clientLoader is handled within the component
|
|
82
97
|
"""
|
|
83
98
|
)
|
pulse/messages.py
CHANGED
|
@@ -156,3 +156,25 @@ ClientPulseMessage = (
|
|
|
156
156
|
)
|
|
157
157
|
ClientChannelMessage = ClientChannelRequestMessage | ClientChannelResponseMessage
|
|
158
158
|
ClientMessage = ClientPulseMessage | ClientChannelMessage
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class PrerenderPayload(TypedDict):
|
|
162
|
+
paths: list[str]
|
|
163
|
+
routeInfo: RouteInfo
|
|
164
|
+
ttlSeconds: NotRequired[float | int]
|
|
165
|
+
renderId: NotRequired[str]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class SocketIODirectives(TypedDict):
|
|
169
|
+
headers: dict[str, str]
|
|
170
|
+
auth: dict[str, str]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Directives(TypedDict):
|
|
174
|
+
headers: dict[str, str]
|
|
175
|
+
socketio: SocketIODirectives
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class PrerenderResult(TypedDict):
|
|
179
|
+
views: dict[str, ServerInitMessage | None]
|
|
180
|
+
directives: Directives
|
pulse/middleware.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections.abc import Callable, Sequence
|
|
4
|
-
from typing import Any, Generic, TypeVar, overload, override
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload, override
|
|
5
5
|
|
|
6
6
|
from pulse.messages import ClientMessage, ServerInitMessage
|
|
7
7
|
from pulse.request import PulseRequest
|
|
8
8
|
from pulse.routing import RouteInfo
|
|
9
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pulse.app import PrerenderPayload, PrerenderResult
|
|
12
|
+
|
|
10
13
|
T = TypeVar("T")
|
|
11
14
|
|
|
12
15
|
|
|
@@ -46,6 +49,22 @@ class PulseMiddleware:
|
|
|
46
49
|
"""
|
|
47
50
|
|
|
48
51
|
def prerender(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
payload: "PrerenderPayload",
|
|
55
|
+
result: "PrerenderResult",
|
|
56
|
+
request: PulseRequest,
|
|
57
|
+
session: dict[str, Any],
|
|
58
|
+
next: Callable[[], "PrerenderResult"],
|
|
59
|
+
) -> "PrerenderResult":
|
|
60
|
+
"""Handle batch prerender at the top level.
|
|
61
|
+
|
|
62
|
+
Receives the full PrerenderPayload and can modify the PrerenderResult
|
|
63
|
+
(views and directives) before it's returned to the client.
|
|
64
|
+
"""
|
|
65
|
+
return next()
|
|
66
|
+
|
|
67
|
+
def prerender_route(
|
|
49
68
|
self,
|
|
50
69
|
*,
|
|
51
70
|
path: str,
|
|
@@ -103,6 +122,34 @@ class MiddlewareStack(PulseMiddleware):
|
|
|
103
122
|
|
|
104
123
|
@override
|
|
105
124
|
def prerender(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
payload: "PrerenderPayload",
|
|
128
|
+
result: "PrerenderResult",
|
|
129
|
+
request: PulseRequest,
|
|
130
|
+
session: dict[str, Any],
|
|
131
|
+
next: Callable[[], "PrerenderResult"],
|
|
132
|
+
) -> "PrerenderResult":
|
|
133
|
+
def dispatch(index: int) -> "PrerenderResult":
|
|
134
|
+
if index >= len(self._middlewares):
|
|
135
|
+
return next()
|
|
136
|
+
mw = self._middlewares[index]
|
|
137
|
+
|
|
138
|
+
def _next() -> "PrerenderResult":
|
|
139
|
+
return dispatch(index + 1)
|
|
140
|
+
|
|
141
|
+
return mw.prerender(
|
|
142
|
+
payload=payload,
|
|
143
|
+
result=result,
|
|
144
|
+
request=request,
|
|
145
|
+
session=session,
|
|
146
|
+
next=_next,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return dispatch(0)
|
|
150
|
+
|
|
151
|
+
@override
|
|
152
|
+
def prerender_route(
|
|
106
153
|
self,
|
|
107
154
|
*,
|
|
108
155
|
path: str,
|
|
@@ -119,7 +166,7 @@ class MiddlewareStack(PulseMiddleware):
|
|
|
119
166
|
def _next() -> PrerenderResponse:
|
|
120
167
|
return dispatch(index + 1)
|
|
121
168
|
|
|
122
|
-
return mw.
|
|
169
|
+
return mw.prerender_route(
|
|
123
170
|
path=path,
|
|
124
171
|
route_info=route_info,
|
|
125
172
|
request=request,
|
|
@@ -216,6 +263,20 @@ class PulseCoreMiddleware(PulseMiddleware):
|
|
|
216
263
|
run, and finally returns their response unchanged.
|
|
217
264
|
"""
|
|
218
265
|
|
|
266
|
+
@override
|
|
267
|
+
def prerender(
|
|
268
|
+
self,
|
|
269
|
+
*,
|
|
270
|
+
payload: "PrerenderPayload",
|
|
271
|
+
result: "PrerenderResult",
|
|
272
|
+
request: PulseRequest,
|
|
273
|
+
session: dict[str, Any],
|
|
274
|
+
next: Callable[[], "PrerenderResult"],
|
|
275
|
+
) -> "PrerenderResult":
|
|
276
|
+
res = next()
|
|
277
|
+
# Return the result as-is (no normalization needed)
|
|
278
|
+
return res
|
|
279
|
+
|
|
219
280
|
# --- Normalization helpers -------------------------------------------------
|
|
220
281
|
def _normalize_prerender_response(self, res: Any) -> PrerenderResponse:
|
|
221
282
|
if isinstance(res, (Ok, Redirect, NotFound)):
|
|
@@ -236,7 +297,7 @@ class PulseCoreMiddleware(PulseMiddleware):
|
|
|
236
297
|
return Ok(None)
|
|
237
298
|
|
|
238
299
|
@override
|
|
239
|
-
def
|
|
300
|
+
def prerender_route(
|
|
240
301
|
self,
|
|
241
302
|
*,
|
|
242
303
|
path: str,
|
pulse/plugin.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
pulse/__init__.py,sha256=
|
|
2
|
-
pulse/app.py,sha256=
|
|
1
|
+
pulse/__init__.py,sha256=P2CLvFxP8IPiUsy3Z-hZD-1tbsvhd4eVlMNYg5WOPjE,31709
|
|
2
|
+
pulse/app.py,sha256=a-fHuX1MfA0Whjyy8l2JxroRkJd5VG0Aba31xfKcuFY,29871
|
|
3
3
|
pulse/channel.py,sha256=DuD1mg_xWvkpAWSKZ-EtBYdUzJ8IuKH0fxdgGOvFXpg,13041
|
|
4
4
|
pulse/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
5
|
pulse/cli/cmd.py,sha256=PpThGpbvDNn9c9I2sPK8O-XMU1P0eHDoIODed9Q8rQg,13548
|
|
@@ -16,7 +16,7 @@ pulse/codegen/codegen.py,sha256=RMc2NkldX0dmxRG59gdulCMEzywvHMo2akeiHQMTu4I,1068
|
|
|
16
16
|
pulse/codegen/imports.py,sha256=13f0uzJsotw069aP_COUUPMuTXXhRKRwUzfxsSCq_6A,6070
|
|
17
17
|
pulse/codegen/js.py,sha256=7MuiECSJ-DulSqKuMZ8z1q_d7e3AbK6MYiNTYALZCLA,881
|
|
18
18
|
pulse/codegen/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
-
pulse/codegen/templates/layout.py,sha256=
|
|
19
|
+
pulse/codegen/templates/layout.py,sha256=Et9e8zZ6xN5vhxlhwDVOFcdAP1Odl8oHz4eYO3iyhc4,4396
|
|
20
20
|
pulse/codegen/templates/route.py,sha256=cwmNHYkuecZ5M986hmm6SxisIoVSc656dtpqAvPjMjM,7824
|
|
21
21
|
pulse/codegen/templates/routes_ts.py,sha256=nPgKCvU0gzue2k6KlOL1TJgrBqqRLmyy7K_qKAI8zAE,1129
|
|
22
22
|
pulse/codegen/utils.py,sha256=QoXcV-h-DLLmq_t03hDNUePS0fNnofUQLoR-TXzDFCY,539
|
|
@@ -45,9 +45,9 @@ pulse/html/props.py,sha256=XatI6N4Hyef3MAql7jCxCIm6iuusgUXKkwHwIGm_dcc,26646
|
|
|
45
45
|
pulse/html/svg.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
46
46
|
pulse/html/tags.py,sha256=dyG4BY9qthBbO-ihcy9F8mLY6WqQxKFXfpqNYcSMKN0,5182
|
|
47
47
|
pulse/html/tags.pyi,sha256=I8dFoft9w4RvneZ3li1weAdijY1krj9jfO_p2SU6e04,13953
|
|
48
|
-
pulse/messages.py,sha256=
|
|
49
|
-
pulse/middleware.py,sha256=
|
|
50
|
-
pulse/plugin.py,sha256=
|
|
48
|
+
pulse/messages.py,sha256=Vz6pXUcBlQxHVEzP8jtA4ZBwn0P30oJzU07lzESP2JI,3625
|
|
49
|
+
pulse/middleware.py,sha256=xNPUkeKVCEUJDIHoe5-GT7EvgLxZwWz1rWb6mEchkWY,7921
|
|
50
|
+
pulse/plugin.py,sha256=T1HLucOJekRfWMGF17arI3z7qfH-rBw_zPOQEV8v2mw,640
|
|
51
51
|
pulse/proxy.py,sha256=lIt1W8FpItpwl85IxzdzgTwPN8G7kvM5OFYB7YW9iaE,5158
|
|
52
52
|
pulse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
53
|
pulse/query.py,sha256=u0KVFNt0d36sfmoxpQXvJ8DjctIHZaM_szlEonRiyRg,12849
|
|
@@ -65,7 +65,7 @@ pulse/types/event_handler.py,sha256=OF7sOgYBb6iUs59RH1vQIH7aOrGPfs3nAaF7how-4PQ,
|
|
|
65
65
|
pulse/user_session.py,sha256=kCZtQpYZe2keDXzusd6jsjjw075am0dXrb25jKLg5JU,7578
|
|
66
66
|
pulse/vdom.py,sha256=KTNBh2dVvDy9eXRzhneBJgk7F35MyWec8R_puQ4tSRY,12420
|
|
67
67
|
pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
|
|
68
|
-
pulse_framework-0.1.
|
|
69
|
-
pulse_framework-0.1.
|
|
70
|
-
pulse_framework-0.1.
|
|
71
|
-
pulse_framework-0.1.
|
|
68
|
+
pulse_framework-0.1.38a7.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
69
|
+
pulse_framework-0.1.38a7.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
|
|
70
|
+
pulse_framework-0.1.38a7.dist-info/METADATA,sha256=fxno0AsA_hH9_w_nI7Vd5dQemHgDn6rxyNKaFdfW5fQ,582
|
|
71
|
+
pulse_framework-0.1.38a7.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|