pulse-framework 0.1.38a5__tar.gz → 0.1.38a7__tar.gz

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.
Files changed (70) hide show
  1. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/pyproject.toml +1 -1
  3. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/__init__.py +4 -0
  4. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/app.py +150 -56
  5. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/codegen/templates/layout.py +18 -3
  6. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/messages.py +22 -0
  7. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/middleware.py +64 -3
  8. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/plugin.py +1 -0
  9. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/README.md +0 -0
  10. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/channel.py +0 -0
  11. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/cli/__init__.py +0 -0
  12. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/cli/cmd.py +0 -0
  13. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/cli/dependencies.py +0 -0
  14. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/cli/folder_lock.py +0 -0
  15. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/cli/helpers.py +0 -0
  16. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/cli/models.py +0 -0
  17. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/cli/packages.py +0 -0
  18. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/cli/processes.py +0 -0
  19. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/cli/secrets.py +0 -0
  20. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/cli/uvicorn_log_config.py +0 -0
  21. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/codegen/__init__.py +0 -0
  22. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/codegen/codegen.py +0 -0
  23. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/codegen/imports.py +0 -0
  24. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/codegen/js.py +0 -0
  25. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/codegen/templates/__init__.py +0 -0
  26. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/codegen/templates/route.py +0 -0
  27. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/codegen/templates/routes_ts.py +0 -0
  28. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/codegen/utils.py +0 -0
  29. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/components/__init__.py +0 -0
  30. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/components/for_.py +0 -0
  31. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/components/if_.py +0 -0
  32. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/components/react_router.py +0 -0
  33. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/context.py +0 -0
  34. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/cookies.py +0 -0
  35. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/css.py +0 -0
  36. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/decorators.py +0 -0
  37. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/env.py +0 -0
  38. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/form.py +0 -0
  39. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/helpers.py +0 -0
  40. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/hooks/__init__.py +0 -0
  41. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/hooks/core.py +0 -0
  42. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/hooks/effects.py +0 -0
  43. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/hooks/runtime.py +0 -0
  44. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/hooks/setup.py +0 -0
  45. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/hooks/stable.py +0 -0
  46. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/hooks/states.py +0 -0
  47. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/html/__init__.py +0 -0
  48. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/html/elements.py +0 -0
  49. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/html/events.py +0 -0
  50. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/html/props.py +0 -0
  51. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/html/svg.py +0 -0
  52. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/html/tags.py +0 -0
  53. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/html/tags.pyi +0 -0
  54. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/proxy.py +0 -0
  55. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/py.typed +0 -0
  56. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/query.py +0 -0
  57. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/react_component.py +0 -0
  58. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/reactive.py +0 -0
  59. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/reactive_extensions.py +0 -0
  60. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/render_session.py +0 -0
  61. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/renderer.py +0 -0
  62. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/request.py +0 -0
  63. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/routing.py +0 -0
  64. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/serializer.py +0 -0
  65. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/state.py +0 -0
  66. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/types/__init__.py +0 -0
  67. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/types/event_handler.py +0 -0
  68. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/user_session.py +0 -0
  69. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/vdom.py +0 -0
  70. {pulse_framework-0.1.38a5 → pulse_framework-0.1.38a7}/src/pulse/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.38a5
3
+ Version: 0.1.38a7
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: websockets>=12.0
6
6
  Requires-Dist: fastapi>=0.104.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pulse-framework"
3
- version = "0.1.38a5"
3
+ version = "0.1.38a7"
4
4
  description = "Pulse - Full-stack framework for building real-time React applications in Python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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 (
@@ -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, NotRequired, TypedDict, TypeVar, cast
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
- ServerInitMessage,
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, RouteInfo, RouteTree
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
- stopped = 3
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( # pyright: ignore[reportUnusedFunction]
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
- if render_id:
421
- render = self.render_sessions.get(render_id)
422
- else:
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, renderId?: string }
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
- # Optional reuse of existing RenderSession
468
- render_id = payload.get("renderId")
469
- if isinstance(render_id, str):
470
- # Validate render exists and belongs to this user session
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
- result: PrerenderResult = {"renderId": render_id, "views": {}}
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.prerender(
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
- # schedule TTL cleanup if never connected
537
- def _gc_if_unadopted(rid: str):
538
- r = self.render_sessions.get(rid)
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
- if cleanup:
546
- later(float(ttl), _gc_if_unadopted, render_id)
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(result))
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("renderId") if auth else None
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
- # Close the RenderSession entirely to avoid lingering effects/tasks
631
- self.close_render(rid)
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: { "content-type": "application/json" },
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
- return deserialize(body) as PulsePrerender;
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
  )
@@ -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
@@ -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.prerender(
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 prerender(
300
+ def prerender_route(
240
301
  self,
241
302
  *,
242
303
  path: str,
@@ -25,3 +25,4 @@ class Plugin:
25
25
  # Optional lifecycle
26
26
  def on_setup(self, app: App) -> None: ...
27
27
  def on_startup(self, app: App) -> None: ...
28
+ def on_shutdown(self, app: App) -> None: ...