create-caspian-app 0.2.0-beta.49 → 0.2.0-beta.50

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.
package/dist/main.py CHANGED
@@ -6,6 +6,7 @@ import importlib.util
6
6
  import mimetypes
7
7
  import secrets
8
8
  import traceback
9
+ import json
9
10
  from pathlib import Path
10
11
  from fastapi import FastAPI, Request, Response
11
12
  from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
@@ -35,7 +36,6 @@ from casp.layout import (
35
36
  import hashlib
36
37
  from casp.streaming import SSE
37
38
  from typing import Any, Optional, get_args, get_origin, Union
38
- import re
39
39
  from src.lib.auth.auth_config import build_auth_settings
40
40
 
41
41
  load_dotenv()
@@ -70,6 +70,37 @@ IS_PRODUCTION = os.getenv('APP_ENV') == 'production'
70
70
  CACHE_ENABLED = os.getenv('CACHE_ENABLED', 'false').lower() == 'true'
71
71
  DEFAULT_TTL = int(os.getenv('CACHE_TTL', 600))
72
72
 
73
+
74
+ def _dev_cookie_scope() -> str:
75
+ if IS_PRODUCTION:
76
+ return ""
77
+
78
+ scope = os.getenv("CASPIAN_BROWSER_SYNC_PORT")
79
+ if not scope:
80
+ bs_config_path = Path("settings/bs-config.json")
81
+ if bs_config_path.exists():
82
+ try:
83
+ local_url = json.loads(
84
+ bs_config_path.read_text(encoding="utf-8")
85
+ ).get("local", "")
86
+ scope = local_url.rsplit(":", 1)[-1].strip("/")
87
+ except (OSError, json.JSONDecodeError):
88
+ scope = ""
89
+
90
+ scope = scope or os.getenv("PORT", "")
91
+ return scope if scope.isdigit() else ""
92
+
93
+
94
+ def _scoped_cookie_name(base_name: str) -> str:
95
+ scope = _dev_cookie_scope()
96
+ return f"{base_name}_{scope}" if scope else base_name
97
+
98
+
99
+ CSRF_COOKIE_NAME = _scoped_cookie_name("pp_csrf")
100
+ SESSION_COOKIE_NAME = _scoped_cookie_name(
101
+ os.getenv('AUTH_COOKIE_NAME', 'session')
102
+ )
103
+
73
104
  # ====
74
105
  # Static File Routes
75
106
  # ====
@@ -129,7 +160,7 @@ class CSRFMiddleware:
129
160
 
130
161
  async def send_wrapper(message):
131
162
  if message["type"] == "http.response.start":
132
- cookie_value = f"pp_csrf={csrf_token}; Path=/; SameSite=Lax"
163
+ cookie_value = f"{CSRF_COOKIE_NAME}={csrf_token}; Path=/; SameSite=Lax"
133
164
  if IS_PRODUCTION:
134
165
  cookie_value += "; Secure"
135
166
  new_headers = list(message.get("headers", []))
@@ -159,7 +190,7 @@ class AuthMiddleware:
159
190
  providers = Auth.get_providers()
160
191
 
161
192
  if providers:
162
- oauth_response = auth_inst.auth_providers(*providers)
193
+ oauth_response = await auth_inst.auth_providers(*providers)
163
194
  if oauth_response:
164
195
  await oauth_response(scope, receive, send)
165
196
  return
@@ -217,40 +248,44 @@ class RPCMiddleware:
217
248
  # Route Registration
218
249
  # ====
219
250
 
251
+ _route_module_cache = {}
252
+ _route_signature_cache = {}
220
253
 
221
- _VOID_TAGS_PATTERN = r"(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)"
222
- _VOID_END_TAG_RE = re.compile(rf"</\s*{_VOID_TAGS_PATTERN}\s*>", re.IGNORECASE)
223
- _VOID_OPEN_TAG_RE = re.compile(
224
- rf"<\s*({_VOID_TAGS_PATTERN})(\b[^>]*)>", re.IGNORECASE)
225
254
 
255
+ def load_route_module(file_path: str):
256
+ abs_path = os.path.abspath(file_path)
257
+ try:
258
+ mtime_ns = os.stat(abs_path).st_mtime_ns
259
+ except OSError:
260
+ raise FileNotFoundError(f"Route module not found: {abs_path}")
226
261
 
227
- def normalize_void_tags(html: str) -> str:
228
- html = _VOID_END_TAG_RE.sub("", html)
229
-
230
- def _open_repl(m: re.Match) -> str:
231
- tag = m.group(1)
232
- attrs = m.group(2) or ""
233
- full = m.group(0)
234
- if full.rstrip().endswith("/>"):
235
- return full
236
- if attrs and not attrs.startswith(" "):
237
- attrs = " " + attrs
238
- return f"<{tag}{attrs} />"
239
-
240
- return _VOID_OPEN_TAG_RE.sub(_open_repl, html)
241
-
262
+ cached = _route_module_cache.get(abs_path)
263
+ if cached is not None and cached[0] == mtime_ns:
264
+ return cached[1]
242
265
 
243
- def load_route_module(file_path: str):
244
- unique_id = hashlib.md5(file_path.encode()).hexdigest()[:8]
266
+ unique_id = hashlib.md5(abs_path.encode()).hexdigest()[:8]
245
267
  module_name = f"page_{unique_id}"
246
- spec = importlib.util.spec_from_file_location(module_name, file_path)
268
+ spec = importlib.util.spec_from_file_location(module_name, abs_path)
247
269
  assert spec is not None and spec.loader is not None, f"Cannot load spec for {file_path}"
248
270
  module = importlib.util.module_from_spec(spec)
249
271
  spec.loader.exec_module(module)
250
272
  setattr(module, 'render_page', render_page)
273
+ _route_module_cache[abs_path] = (mtime_ns, module)
274
+ _route_signature_cache.pop(abs_path, None)
251
275
  return module
252
276
 
253
277
 
278
+ def get_page_signature(file_path: str, page_func):
279
+ abs_path = os.path.abspath(file_path)
280
+ cached = _route_signature_cache.get(abs_path)
281
+ if cached is not None and cached[0] is page_func:
282
+ return cached[1]
283
+
284
+ sig = inspect.signature(page_func)
285
+ _route_signature_cache[abs_path] = (page_func, sig)
286
+ return sig
287
+
288
+
254
289
  def _unwrap_optional(annotation: Any) -> Any:
255
290
  """
256
291
  Optional[T] is Union[T, NoneType]. Return T when applicable.
@@ -338,7 +373,7 @@ def register_single_route(url_pattern: str, file_path: str):
338
373
  current_uri = request.url.path
339
374
 
340
375
  # 1. Cache Check (Fast Path)
341
- if request.method == 'GET':
376
+ if CACHE_ENABLED and request.method == 'GET':
342
377
  cached_resp = CacheHandler.serve_cache(current_uri, DEFAULT_TTL)
343
378
  if cached_resp:
344
379
  return HTMLResponse(content=cached_resp)
@@ -356,7 +391,7 @@ def register_single_route(url_pattern: str, file_path: str):
356
391
  if not hasattr(module, 'page'):
357
392
  raise AttributeError(f"Missing 'def page():' in {file_path}")
358
393
 
359
- sig = inspect.signature(module.page)
394
+ sig = get_page_signature(file_path, module.page)
360
395
  call_kwargs = {}
361
396
  call_args = []
362
397
 
@@ -434,7 +469,6 @@ def register_single_route(url_pattern: str, file_path: str):
434
469
  )
435
470
 
436
471
  html_output = transform_scripts(html_output)
437
- html_output = normalize_void_tags(html_output)
438
472
  response = HTMLResponse(content=html_output)
439
473
  response.headers['X-PP-Root-Layout'] = root_layout_id
440
474
 
@@ -540,7 +574,7 @@ app.add_middleware(CSRFMiddleware)
540
574
  app.add_middleware(
541
575
  SessionMiddleware,
542
576
  secret_key=os.getenv('AUTH_SECRET', 'change-me'),
543
- session_cookie=os.getenv('AUTH_COOKIE_NAME', 'session'),
577
+ session_cookie=SESSION_COOKIE_NAME,
544
578
  max_age=SESSION_LIFETIME_HOURS * 3600,
545
579
  same_site='lax',
546
580
  https_only=IS_PRODUCTION,
@@ -165,7 +165,7 @@ const pipeline = new DebouncedWorker(
165
165
  !changedFile.includes("__pycache__");
166
166
 
167
167
  if (needsPythonRestart) {
168
- await restartPythonServer(pythonPort);
168
+ await restartPythonServer(pythonPort, bsPort);
169
169
  updateRouteFilesCache();
170
170
 
171
171
  const isReady = await waitForPort(pythonPort);
@@ -201,7 +201,7 @@ const pipeline = new DebouncedWorker(
201
201
  "→ Structure changed (New/Deleted file), restarting Python server...",
202
202
  ),
203
203
  );
204
- await restartPythonServer(pythonPort);
204
+ await restartPythonServer(pythonPort, bsPort);
205
205
  const isReady = await waitForPort(pythonPort);
206
206
  if (isReady && bs.active) bs.reload();
207
207
  } else if (bs.active) {
@@ -295,7 +295,7 @@ const publicPipeline = new DebouncedWorker(
295
295
  createSrcWatcher(join(__dirname, "..", "utils", "**", "*.py"), {
296
296
  onEvent: async (_ev, _abs, _) => {
297
297
  if (_abs.includes("__pycache__")) return;
298
- await restartPythonServer(pythonPort);
298
+ await restartPythonServer(pythonPort, bsPort);
299
299
  const isReady = await waitForPort(pythonPort);
300
300
  if (isReady && bs.active) bs.reload();
301
301
  },
@@ -308,7 +308,7 @@ const publicPipeline = new DebouncedWorker(
308
308
  createSrcWatcher(join(__dirname, "..", "main.py"), {
309
309
  onEvent: async (_ev, _abs, _) => {
310
310
  if (_abs.includes("__pycache__")) return;
311
- await restartPythonServer(pythonPort);
311
+ await restartPythonServer(pythonPort, bsPort);
312
312
  const isReady = await waitForPort(pythonPort);
313
313
  if (isReady && bs.active) bs.reload();
314
314
  },
@@ -318,7 +318,7 @@ const publicPipeline = new DebouncedWorker(
318
318
  interval: 1000,
319
319
  });
320
320
 
321
- startPythonServer(pythonPort);
321
+ startPythonServer(pythonPort, bsPort);
322
322
 
323
323
  bs.init(
324
324
  {
@@ -53,7 +53,7 @@ export function waitForPort(port: number, timeout = 10000): Promise<boolean> {
53
53
 
54
54
  export function waitForPortRelease(
55
55
  port: number,
56
- timeout = 5000
56
+ timeout = 5000,
57
57
  ): Promise<boolean> {
58
58
  const start = Date.now();
59
59
  return new Promise((resolve) => {
@@ -114,29 +114,44 @@ async function killProcessTree(child: ChildProcess): Promise<void> {
114
114
  }
115
115
  }
116
116
 
117
- function spawnPython(port: number): ChildProcess {
117
+ function spawnPython(port: number, browserSyncPort?: number): ChildProcess {
118
118
  const pythonPath = getVenvPythonPath();
119
119
  const args = ["-u", "main.py"];
120
120
 
121
121
  console.log(`→ Starting Python server on port ${port}...`);
122
122
 
123
+ const env = {
124
+ ...process.env,
125
+ PYTHONUNBUFFERED: "1",
126
+ PORT: String(port),
127
+ ...(browserSyncPort
128
+ ? { CASPIAN_BROWSER_SYNC_PORT: String(browserSyncPort) }
129
+ : {}),
130
+ };
131
+
123
132
  const child = spawn(pythonPath, args, {
124
133
  stdio: "inherit",
125
134
  shell: false,
126
135
  detached: !isWindows(),
127
- env: { ...process.env, PYTHONUNBUFFERED: "1", PORT: String(port) },
136
+ env,
128
137
  });
129
138
 
130
139
  child.on("error", (err) => console.error("Failed to start Python:", err));
131
140
  return child;
132
141
  }
133
142
 
134
- export function startPythonServer(port: number): void {
143
+ export function startPythonServer(
144
+ port: number,
145
+ browserSyncPort?: number,
146
+ ): void {
135
147
  if (pythonProcess && pythonProcess.exitCode === null) return;
136
- pythonProcess = spawnPython(port);
148
+ pythonProcess = spawnPython(port, browserSyncPort);
137
149
  }
138
150
 
139
- export async function restartPythonServer(port: number): Promise<void> {
151
+ export async function restartPythonServer(
152
+ port: number,
153
+ browserSyncPort?: number,
154
+ ): Promise<void> {
140
155
  if (isRestarting) return;
141
156
  isRestarting = true;
142
157
 
@@ -150,7 +165,7 @@ export async function restartPythonServer(port: number): Promise<void> {
150
165
  await waitForPortRelease(port);
151
166
  }
152
167
 
153
- pythonProcess = spawnPython(port);
168
+ pythonProcess = spawnPython(port, browserSyncPort);
154
169
  } finally {
155
170
  isRestarting = false;
156
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "0.2.0-beta.49",
3
+ "version": "0.2.0-beta.50",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",