pulse-framework 0.1.37__py3-none-any.whl → 0.1.38a2__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 +2 -2
- pulse/app.py +224 -75
- pulse/cli/cmd.py +294 -394
- pulse/cli/dependencies.py +212 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +82 -43
- pulse/cli/models.py +33 -0
- pulse/cli/processes.py +225 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/codegen/codegen.py +16 -3
- pulse/codegen/templates/layout.py +3 -2
- pulse/cookies.py +17 -16
- pulse/env.py +8 -18
- pulse/helpers.py +19 -122
- pulse/proxy.py +192 -0
- pulse/user_session.py +2 -2
- {pulse_framework-0.1.37.dist-info → pulse_framework-0.1.38a2.dist-info}/METADATA +2 -1
- {pulse_framework-0.1.37.dist-info → pulse_framework-0.1.38a2.dist-info}/RECORD +21 -14
- {pulse_framework-0.1.37.dist-info → pulse_framework-0.1.38a2.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.37.dist-info → pulse_framework-0.1.38a2.dist-info}/entry_points.txt +0 -0
pulse/cli/processes.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import pty
|
|
6
|
+
import select
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from io import TextIOBase
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from pulse.cli.helpers import os_family
|
|
17
|
+
from pulse.cli.models import CommandSpec
|
|
18
|
+
|
|
19
|
+
ANSI_CODES = {
|
|
20
|
+
"cyan": "\033[36m",
|
|
21
|
+
"orange1": "\033[38;5;208m",
|
|
22
|
+
"default": "\033[90m",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def execute_commands(
|
|
27
|
+
commands: Sequence[CommandSpec],
|
|
28
|
+
*,
|
|
29
|
+
console: Console,
|
|
30
|
+
tag_colors: Mapping[str, str] | None = None,
|
|
31
|
+
) -> int:
|
|
32
|
+
"""Run the provided commands, streaming tagged output to stdout."""
|
|
33
|
+
if not commands:
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
color_lookup = dict(tag_colors or {})
|
|
37
|
+
|
|
38
|
+
# Avoid pty.fork() in multi-threaded environments (like pytest) to prevent
|
|
39
|
+
# "DeprecationWarning: This process is multi-threaded, use of forkpty() may lead to deadlocks"
|
|
40
|
+
# Also skip pty on Windows or if fork is unavailable
|
|
41
|
+
in_pytest = "pytest" in sys.modules
|
|
42
|
+
if os_family() == "windows" or not hasattr(pty, "fork") or in_pytest:
|
|
43
|
+
return _run_without_pty(commands, console=console, colors=color_lookup)
|
|
44
|
+
|
|
45
|
+
return _run_with_pty(commands, console=console, colors=color_lookup)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _run_with_pty(
|
|
49
|
+
commands: Sequence[CommandSpec],
|
|
50
|
+
*,
|
|
51
|
+
console: Console,
|
|
52
|
+
colors: Mapping[str, str],
|
|
53
|
+
) -> int:
|
|
54
|
+
procs: list[tuple[str, int, int]] = []
|
|
55
|
+
fd_to_name: dict[int, str] = {}
|
|
56
|
+
buffers: dict[int, bytearray] = {}
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
for spec in commands:
|
|
60
|
+
pid, fd = pty.fork()
|
|
61
|
+
if pid == 0:
|
|
62
|
+
if spec.cwd:
|
|
63
|
+
os.chdir(spec.cwd)
|
|
64
|
+
os.execvpe(spec.args[0], spec.args, spec.env)
|
|
65
|
+
else:
|
|
66
|
+
fcntl = __import__("fcntl")
|
|
67
|
+
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
|
|
68
|
+
procs.append((spec.name, pid, fd))
|
|
69
|
+
fd_to_name[fd] = spec.name
|
|
70
|
+
buffers[fd] = bytearray()
|
|
71
|
+
if spec.on_spawn:
|
|
72
|
+
try:
|
|
73
|
+
spec.on_spawn()
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
while procs:
|
|
78
|
+
for tag, pid, fd in list(procs):
|
|
79
|
+
try:
|
|
80
|
+
wpid, status = os.waitpid(pid, os.WNOHANG)
|
|
81
|
+
if wpid == pid:
|
|
82
|
+
procs.remove((tag, pid, fd))
|
|
83
|
+
_close_fd(fd)
|
|
84
|
+
except ChildProcessError:
|
|
85
|
+
procs.remove((tag, pid, fd))
|
|
86
|
+
_close_fd(fd)
|
|
87
|
+
|
|
88
|
+
if not procs:
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
readable = [fd for _, _, fd in procs]
|
|
92
|
+
try:
|
|
93
|
+
ready, _, _ = select.select(readable, [], [], 0.1)
|
|
94
|
+
except (OSError, ValueError):
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
for fd in ready:
|
|
98
|
+
try:
|
|
99
|
+
data = os.read(fd, 4096)
|
|
100
|
+
if not data:
|
|
101
|
+
continue
|
|
102
|
+
buffers[fd].extend(data)
|
|
103
|
+
while b"\n" in buffers[fd]:
|
|
104
|
+
line, remainder = buffers[fd].split(b"\n", 1)
|
|
105
|
+
buffers[fd] = remainder
|
|
106
|
+
decoded = line.decode(errors="replace")
|
|
107
|
+
if decoded:
|
|
108
|
+
_write_tagged_line(fd_to_name[fd], decoded, colors)
|
|
109
|
+
except OSError:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
exit_codes: list[int] = []
|
|
113
|
+
for _tag, pid, fd in procs:
|
|
114
|
+
try:
|
|
115
|
+
_, status = os.waitpid(pid, 0)
|
|
116
|
+
exit_codes.append(os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
_close_fd(fd)
|
|
120
|
+
|
|
121
|
+
return max(exit_codes) if exit_codes else 0
|
|
122
|
+
|
|
123
|
+
except KeyboardInterrupt:
|
|
124
|
+
for _tag, pid, _fd in procs:
|
|
125
|
+
try:
|
|
126
|
+
os.kill(pid, signal.SIGTERM)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
return 130
|
|
130
|
+
finally:
|
|
131
|
+
for _tag, pid, fd in procs:
|
|
132
|
+
try:
|
|
133
|
+
os.kill(pid, signal.SIGKILL)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
_close_fd(fd)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _run_without_pty(
|
|
140
|
+
commands: Sequence[CommandSpec],
|
|
141
|
+
*,
|
|
142
|
+
console: Console,
|
|
143
|
+
colors: Mapping[str, str],
|
|
144
|
+
) -> int:
|
|
145
|
+
from selectors import EVENT_READ, DefaultSelector
|
|
146
|
+
|
|
147
|
+
procs: list[tuple[str, subprocess.Popen[str]]] = []
|
|
148
|
+
completed_codes: list[int] = []
|
|
149
|
+
selector = DefaultSelector()
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
for spec in commands:
|
|
153
|
+
proc = subprocess.Popen(
|
|
154
|
+
spec.args,
|
|
155
|
+
cwd=spec.cwd,
|
|
156
|
+
env=spec.env,
|
|
157
|
+
stdout=subprocess.PIPE,
|
|
158
|
+
stderr=subprocess.STDOUT,
|
|
159
|
+
text=True,
|
|
160
|
+
bufsize=1,
|
|
161
|
+
universal_newlines=True,
|
|
162
|
+
)
|
|
163
|
+
if spec.on_spawn:
|
|
164
|
+
try:
|
|
165
|
+
spec.on_spawn()
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
if proc.stdout:
|
|
169
|
+
selector.register(proc.stdout, EVENT_READ, data=spec.name)
|
|
170
|
+
procs.append((spec.name, proc))
|
|
171
|
+
|
|
172
|
+
while procs:
|
|
173
|
+
events = selector.select(timeout=0.1)
|
|
174
|
+
for key, _mask in events:
|
|
175
|
+
name = key.data
|
|
176
|
+
stream = key.fileobj
|
|
177
|
+
if isinstance(stream, int):
|
|
178
|
+
continue
|
|
179
|
+
# stream is now guaranteed to be a file-like object
|
|
180
|
+
line = cast(TextIOBase, stream).readline()
|
|
181
|
+
if line:
|
|
182
|
+
_write_tagged_line(name, line.rstrip("\n"), colors)
|
|
183
|
+
else:
|
|
184
|
+
selector.unregister(stream)
|
|
185
|
+
remaining: list[tuple[str, subprocess.Popen[str]]] = []
|
|
186
|
+
for name, proc in procs:
|
|
187
|
+
code = proc.poll()
|
|
188
|
+
if code is None:
|
|
189
|
+
remaining.append((name, proc))
|
|
190
|
+
else:
|
|
191
|
+
completed_codes.append(code)
|
|
192
|
+
if proc.stdout:
|
|
193
|
+
with contextlib.suppress(Exception):
|
|
194
|
+
selector.unregister(proc.stdout)
|
|
195
|
+
proc.stdout.close()
|
|
196
|
+
procs = remaining
|
|
197
|
+
except KeyboardInterrupt:
|
|
198
|
+
for _name, proc in procs:
|
|
199
|
+
with contextlib.suppress(Exception):
|
|
200
|
+
proc.terminate()
|
|
201
|
+
return 130
|
|
202
|
+
finally:
|
|
203
|
+
for _name, proc in procs:
|
|
204
|
+
with contextlib.suppress(Exception):
|
|
205
|
+
proc.terminate()
|
|
206
|
+
with contextlib.suppress(Exception):
|
|
207
|
+
proc.wait(timeout=1)
|
|
208
|
+
for key in list(selector.get_map().values()):
|
|
209
|
+
with contextlib.suppress(Exception):
|
|
210
|
+
selector.unregister(key.fileobj)
|
|
211
|
+
selector.close()
|
|
212
|
+
|
|
213
|
+
exit_codes = completed_codes + [proc.returncode or 0 for _name, proc in procs]
|
|
214
|
+
return max(exit_codes) if exit_codes else 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _write_tagged_line(name: str, message: str, colors: Mapping[str, str]) -> None:
|
|
218
|
+
color = ANSI_CODES.get(colors.get(name, ""), ANSI_CODES["default"])
|
|
219
|
+
sys.stdout.write(f"{color}[{name}]\033[0m {message}\n")
|
|
220
|
+
sys.stdout.flush()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _close_fd(fd: int) -> None:
|
|
224
|
+
with contextlib.suppress(Exception):
|
|
225
|
+
os.close(fd)
|
pulse/cli/secrets.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pulse.cli.helpers import ensure_gitignore_has
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_dev_secret(app_path: Path | None) -> str | None:
|
|
10
|
+
"""Return or create a persisted development secret for the given app path."""
|
|
11
|
+
if app_path is None:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
root = app_path if app_path.is_dir() else app_path.parent
|
|
16
|
+
secret_dir = root / ".pulse"
|
|
17
|
+
secret_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
secret_file = secret_dir / "secret"
|
|
20
|
+
if secret_file.exists():
|
|
21
|
+
try:
|
|
22
|
+
content = secret_file.read_text().strip()
|
|
23
|
+
if content:
|
|
24
|
+
return content
|
|
25
|
+
except Exception:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
secret_value = secrets.token_urlsafe(32)
|
|
29
|
+
try:
|
|
30
|
+
secret_file.write_text(secret_value)
|
|
31
|
+
except Exception:
|
|
32
|
+
# Best effort; secret still returned for current session
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
ensure_gitignore_has(root, ".pulse/")
|
|
36
|
+
|
|
37
|
+
return secret_value
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Custom logging configuration for uvicorn to filter noisy requests."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, override
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FilterNoisyRequests(logging.Filter):
|
|
8
|
+
"""Filter out noisy requests from uvicorn access logs."""
|
|
9
|
+
|
|
10
|
+
# Patterns to suppress (static assets, node_modules, vite internals)
|
|
11
|
+
SUPPRESS_PATTERNS: tuple[str, ...] = (
|
|
12
|
+
"/node_modules/",
|
|
13
|
+
"/@vite/",
|
|
14
|
+
"/%40vite/", # URL-encoded @vite
|
|
15
|
+
"/@fs/",
|
|
16
|
+
"/%40fs/", # URL-encoded @fs
|
|
17
|
+
"/@id/",
|
|
18
|
+
"/%40id/", # URL-encoded @id
|
|
19
|
+
"/@react-refresh",
|
|
20
|
+
"/app/", # React Router source files served by Vite
|
|
21
|
+
".js?v=",
|
|
22
|
+
".css?v=",
|
|
23
|
+
".css ", # CSS files (space indicates end of path in log)
|
|
24
|
+
".tsx ", # TSX source files
|
|
25
|
+
".ts ", # TS source files
|
|
26
|
+
".js ", # JS files
|
|
27
|
+
".map",
|
|
28
|
+
"/favicon.ico",
|
|
29
|
+
"/.well-known/", # Browser/DevTools well-known endpoints
|
|
30
|
+
"connection open",
|
|
31
|
+
"connection closed",
|
|
32
|
+
"connection rejected",
|
|
33
|
+
"304 Not Modified", # Usually just cache hits, not interesting
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@override
|
|
37
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
38
|
+
"""Return False to suppress log records matching noise patterns."""
|
|
39
|
+
message = record.getMessage()
|
|
40
|
+
# Suppress if message contains any noise pattern
|
|
41
|
+
return not any(pattern in message for pattern in self.SUPPRESS_PATTERNS)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_log_config(default_level: str = "info") -> dict[str, Any]:
|
|
45
|
+
"""Get uvicorn logging config with noise filter."""
|
|
46
|
+
return {
|
|
47
|
+
"version": 1,
|
|
48
|
+
"disable_existing_loggers": False,
|
|
49
|
+
"filters": {
|
|
50
|
+
"filter_noisy_requests": {
|
|
51
|
+
"()": "pulse.cli.uvicorn_log_config.FilterNoisyRequests",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
"formatters": {
|
|
55
|
+
"default": {
|
|
56
|
+
"()": "uvicorn.logging.DefaultFormatter",
|
|
57
|
+
"fmt": "%(levelprefix)s %(message)s",
|
|
58
|
+
"use_colors": None,
|
|
59
|
+
},
|
|
60
|
+
"access": {
|
|
61
|
+
"()": "uvicorn.logging.AccessFormatter",
|
|
62
|
+
"fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
"handlers": {
|
|
66
|
+
"default": {
|
|
67
|
+
"formatter": "default",
|
|
68
|
+
"class": "logging.StreamHandler",
|
|
69
|
+
"stream": "ext://sys.stderr",
|
|
70
|
+
},
|
|
71
|
+
"access": {
|
|
72
|
+
"formatter": "access",
|
|
73
|
+
"class": "logging.StreamHandler",
|
|
74
|
+
"stream": "ext://sys.stdout",
|
|
75
|
+
"filters": ["filter_noisy_requests"],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
"loggers": {
|
|
79
|
+
"uvicorn": {"handlers": ["default"], "level": default_level.upper()},
|
|
80
|
+
"uvicorn.error": {"level": "INFO"},
|
|
81
|
+
"uvicorn.access": {
|
|
82
|
+
"handlers": ["access"],
|
|
83
|
+
"level": "INFO",
|
|
84
|
+
"propagate": False,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}
|
pulse/codegen/codegen.py
CHANGED
|
@@ -4,6 +4,7 @@ from collections.abc import Sequence
|
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
from pulse.cli.helpers import ensure_gitignore_has
|
|
7
8
|
from pulse.codegen.templates.layout import LAYOUT_TEMPLATE
|
|
8
9
|
from pulse.codegen.templates.route import CssModuleImport, render_route
|
|
9
10
|
from pulse.codegen.templates.routes_ts import (
|
|
@@ -107,8 +108,14 @@ class Codegen:
|
|
|
107
108
|
return self.cfg.pulse_path
|
|
108
109
|
|
|
109
110
|
def generate_all(
|
|
110
|
-
self,
|
|
111
|
+
self,
|
|
112
|
+
server_address: str,
|
|
113
|
+
internal_server_address: str | None = None,
|
|
114
|
+
api_prefix: str = "",
|
|
111
115
|
):
|
|
116
|
+
# Ensure generated files are gitignored
|
|
117
|
+
ensure_gitignore_has(self.cfg.web_root, f"app/{self.cfg.pulse_dir}/")
|
|
118
|
+
|
|
112
119
|
self._copied_css_modules = set()
|
|
113
120
|
self._css_module_dest = {}
|
|
114
121
|
self._css_name_registry = NameRegistry()
|
|
@@ -116,7 +123,9 @@ class Codegen:
|
|
|
116
123
|
# Keep track of all generated files
|
|
117
124
|
generated_files = set(
|
|
118
125
|
[
|
|
119
|
-
self.generate_layout_tsx(
|
|
126
|
+
self.generate_layout_tsx(
|
|
127
|
+
server_address, internal_server_address, api_prefix
|
|
128
|
+
),
|
|
120
129
|
self.generate_routes_ts(),
|
|
121
130
|
self.generate_routes_runtime_ts(),
|
|
122
131
|
*(
|
|
@@ -137,13 +146,17 @@ class Codegen:
|
|
|
137
146
|
logger.warning(f"Could not remove stale file {path}: {e}")
|
|
138
147
|
|
|
139
148
|
def generate_layout_tsx(
|
|
140
|
-
self,
|
|
149
|
+
self,
|
|
150
|
+
server_address: str,
|
|
151
|
+
internal_server_address: str | None = None,
|
|
152
|
+
api_prefix: str = "",
|
|
141
153
|
):
|
|
142
154
|
"""Generates the content of _layout.tsx"""
|
|
143
155
|
content = str(
|
|
144
156
|
LAYOUT_TEMPLATE.render_unicode(
|
|
145
157
|
server_address=server_address,
|
|
146
158
|
internal_server_address=internal_server_address or server_address,
|
|
159
|
+
api_prefix=api_prefix,
|
|
147
160
|
)
|
|
148
161
|
)
|
|
149
162
|
# The underscore avoids an eventual naming conflict with a generated
|
|
@@ -10,6 +10,7 @@ import { useLoaderData } from "react-router";
|
|
|
10
10
|
// This config is used to initialize the client
|
|
11
11
|
export const config: PulseConfig = {
|
|
12
12
|
serverAddress: "${server_address}",
|
|
13
|
+
apiPrefix: "${api_prefix}",
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
|
|
@@ -26,7 +27,7 @@ export async function loader(args: LoaderFunctionArgs) {
|
|
|
26
27
|
if (cookie) fwd.set("cookie", cookie);
|
|
27
28
|
if (authorization) fwd.set("authorization", authorization);
|
|
28
29
|
fwd.set("content-type", "application/json");
|
|
29
|
-
const res = await fetch(
|
|
30
|
+
const res = await fetch(`${internal_server_address}$${"{"}config.apiPrefix}/prerender`, {
|
|
30
31
|
method: "POST",
|
|
31
32
|
headers: fwd,
|
|
32
33
|
body: JSON.stringify({ paths, routeInfo: extractServerRouteInfo(args) }),
|
|
@@ -53,7 +54,7 @@ export async function clientLoader(args: ClientLoaderFunctionArgs) {
|
|
|
53
54
|
typeof window !== "undefined" && typeof sessionStorage !== "undefined"
|
|
54
55
|
? (sessionStorage.getItem("__PULSE_RENDER_ID") ?? undefined)
|
|
55
56
|
: undefined;
|
|
56
|
-
const res = await fetch("
|
|
57
|
+
const res = await fetch(`$${"{"}config.serverAddress}$${"{"}config.apiPrefix}/prerender`, {
|
|
57
58
|
method: "POST",
|
|
58
59
|
headers: { "content-type": "application/json" },
|
|
59
60
|
credentials: "include",
|
pulse/cookies.py
CHANGED
|
@@ -8,7 +8,7 @@ from fastapi import Request, Response
|
|
|
8
8
|
from pulse.hooks.runtime import set_cookie
|
|
9
9
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
11
|
-
from pulse.app import
|
|
11
|
+
from pulse.app import PulseMode
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@dataclass
|
|
@@ -73,11 +73,11 @@ class SetCookie(Cookie):
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
def session_cookie(
|
|
76
|
-
mode: "
|
|
76
|
+
mode: "PulseMode",
|
|
77
77
|
name: str = "pulse.sid",
|
|
78
78
|
max_age_seconds: int = 7 * 24 * 3600,
|
|
79
79
|
):
|
|
80
|
-
if mode == "
|
|
80
|
+
if mode == "single-server":
|
|
81
81
|
return Cookie(
|
|
82
82
|
name,
|
|
83
83
|
domain=None,
|
|
@@ -85,7 +85,7 @@ def session_cookie(
|
|
|
85
85
|
samesite="lax",
|
|
86
86
|
max_age_seconds=max_age_seconds,
|
|
87
87
|
)
|
|
88
|
-
elif mode == "
|
|
88
|
+
elif mode == "subdomains":
|
|
89
89
|
return Cookie(
|
|
90
90
|
name,
|
|
91
91
|
domain=None, # to be set later
|
|
@@ -137,34 +137,35 @@ def _base_domain(host: str) -> str:
|
|
|
137
137
|
return host[i + 1 :] if i != -1 else host
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
def compute_cookie_domain(mode: "
|
|
140
|
+
def compute_cookie_domain(mode: "PulseMode", server_address: str) -> str | None:
|
|
141
141
|
host = _parse_host(server_address)
|
|
142
|
-
if mode == "
|
|
142
|
+
if mode == "single-server" or not host:
|
|
143
143
|
return None
|
|
144
|
-
if mode == "same_host":
|
|
145
|
-
return host
|
|
146
144
|
if mode == "subdomains":
|
|
147
145
|
return "." + _base_domain(host)
|
|
148
146
|
return None
|
|
149
147
|
|
|
150
148
|
|
|
151
|
-
def cors_options(mode: "
|
|
149
|
+
def cors_options(mode: "PulseMode", server_address: str) -> CORSOptions:
|
|
152
150
|
host = _parse_host(server_address) or "localhost"
|
|
153
151
|
opts: CORSOptions = {
|
|
154
152
|
"allow_credentials": True,
|
|
155
153
|
"allow_methods": ["*"],
|
|
156
154
|
"allow_headers": ["*"],
|
|
157
155
|
}
|
|
158
|
-
if mode == "
|
|
159
|
-
opts["allow_origin_regex"] = ".*"
|
|
160
|
-
return opts
|
|
161
|
-
elif mode == "same_host":
|
|
162
|
-
opts["allow_origin_regex"] = rf"^https?://{host}(:\\d+)?$"
|
|
163
|
-
return opts
|
|
164
|
-
elif mode == "subdomains":
|
|
156
|
+
if mode == "subdomains":
|
|
165
157
|
base = _base_domain(host)
|
|
158
|
+
# Escape dots in base domain for regex (doesn't affect localhost since it has no dots)
|
|
159
|
+
base = base.replace(".", r"\.")
|
|
160
|
+
# Allow any subdomain and any port for the base domain
|
|
166
161
|
opts["allow_origin_regex"] = rf"^https?://([a-z0-9-]+\\.)?{base}(:\\d+)?$"
|
|
167
162
|
return opts
|
|
163
|
+
elif mode == "single-server":
|
|
164
|
+
# For single-server mode, allow same origin
|
|
165
|
+
# Escape dots in host for regex (doesn't affect localhost since it has no dots)
|
|
166
|
+
host = host.replace(".", r"\.")
|
|
167
|
+
opts["allow_origin_regex"] = rf"^https?://{host}(:\\d+)?$"
|
|
168
|
+
return opts
|
|
168
169
|
else:
|
|
169
170
|
raise ValueError(f"Unsupported deployment mode '{mode}'")
|
|
170
171
|
|
pulse/env.py
CHANGED
|
@@ -17,7 +17,7 @@ import os
|
|
|
17
17
|
from typing import Literal
|
|
18
18
|
|
|
19
19
|
# Types
|
|
20
|
-
|
|
20
|
+
PulseEnv = Literal["dev", "ci", "prod"]
|
|
21
21
|
|
|
22
22
|
# Keys
|
|
23
23
|
ENV_PULSE_MODE = "PULSE_MODE"
|
|
@@ -26,11 +26,10 @@ ENV_PULSE_APP_DIR = "PULSE_APP_DIR"
|
|
|
26
26
|
ENV_PULSE_HOST = "PULSE_HOST"
|
|
27
27
|
ENV_PULSE_PORT = "PULSE_PORT"
|
|
28
28
|
ENV_PULSE_SECRET = "PULSE_SECRET"
|
|
29
|
-
ENV_PULSE_LOCK_MANAGED_BY_CLI = "PULSE_LOCK_MANAGED_BY_CLI"
|
|
30
29
|
ENV_PULSE_DISABLE_CODEGEN = "PULSE_DISABLE_CODEGEN"
|
|
31
30
|
|
|
32
31
|
|
|
33
|
-
class
|
|
32
|
+
class EnvVars:
|
|
34
33
|
def _get(self, key: str) -> str | None:
|
|
35
34
|
return os.environ.get(key)
|
|
36
35
|
|
|
@@ -40,16 +39,15 @@ class Env:
|
|
|
40
39
|
else:
|
|
41
40
|
os.environ[key] = value
|
|
42
41
|
|
|
43
|
-
# Pulse mode
|
|
44
42
|
@property
|
|
45
|
-
def
|
|
43
|
+
def pulse_env(self) -> PulseEnv:
|
|
46
44
|
value = (self._get(ENV_PULSE_MODE) or "dev").lower()
|
|
47
45
|
if value not in ("dev", "ci", "prod"):
|
|
48
46
|
value = "dev"
|
|
49
|
-
return value
|
|
47
|
+
return value
|
|
50
48
|
|
|
51
|
-
@
|
|
52
|
-
def
|
|
49
|
+
@pulse_env.setter
|
|
50
|
+
def pulse_env(self, value: PulseEnv) -> None:
|
|
53
51
|
self._set(ENV_PULSE_MODE, value)
|
|
54
52
|
|
|
55
53
|
# App file/dir
|
|
@@ -99,14 +97,6 @@ class Env:
|
|
|
99
97
|
self._set(ENV_PULSE_SECRET, value)
|
|
100
98
|
|
|
101
99
|
# Flags
|
|
102
|
-
@property
|
|
103
|
-
def lock_managed_by_cli(self) -> bool:
|
|
104
|
-
return self._get(ENV_PULSE_LOCK_MANAGED_BY_CLI) == "1"
|
|
105
|
-
|
|
106
|
-
@lock_managed_by_cli.setter
|
|
107
|
-
def lock_managed_by_cli(self, value: bool) -> None:
|
|
108
|
-
self._set(ENV_PULSE_LOCK_MANAGED_BY_CLI, "1" if value else None)
|
|
109
|
-
|
|
110
100
|
@property
|
|
111
101
|
def codegen_disabled(self) -> bool:
|
|
112
102
|
return self._get(ENV_PULSE_DISABLE_CODEGEN) == "1"
|
|
@@ -117,9 +107,9 @@ class Env:
|
|
|
117
107
|
|
|
118
108
|
|
|
119
109
|
# Singleton
|
|
120
|
-
env =
|
|
110
|
+
env = EnvVars()
|
|
121
111
|
|
|
122
112
|
|
|
123
113
|
# Commonly used helpesr
|
|
124
114
|
def mode():
|
|
125
|
-
return env.
|
|
115
|
+
return env.pulse_env
|