@modularizer/plat 0.16.1 → 0.17.1
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/README.md +28 -0
- package/dist/args/index.d.ts +2 -1
- package/dist/args/index.d.ts.map +1 -1
- package/dist/args/index.js +1 -0
- package/dist/args/index.js.map +1 -1
- package/dist/args/method_name.d.ts +27 -0
- package/dist/args/method_name.d.ts.map +1 -0
- package/dist/args/method_name.js +52 -0
- package/dist/args/method_name.js.map +1 -0
- package/dist/args/validate.d.ts +1 -1
- package/dist/args/z2.d.ts +2 -2
- package/dist/bridge/http-forwarder.d.ts +2 -2
- package/dist/bridge/index.d.ts +3 -3
- package/dist/cli/commands/api-proxy.d.ts.map +1 -1
- package/dist/cli/commands/api-proxy.js +23 -2
- package/dist/cli/commands/api-proxy.js.map +1 -1
- package/dist/cli/core.d.ts +103 -0
- package/dist/cli/core.d.ts.map +1 -0
- package/dist/cli/core.js +177 -0
- package/dist/cli/core.js.map +1 -0
- package/dist/cli/format.d.ts +13 -0
- package/dist/cli/format.d.ts.map +1 -0
- package/dist/cli/format.js +160 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/http.d.ts +25 -0
- package/dist/cli/http.d.ts.map +1 -0
- package/dist/cli/http.js +56 -0
- package/dist/cli/http.js.map +1 -0
- package/dist/cli/index.d.ts +10 -10
- package/dist/cli/proxy.d.ts +2 -2
- package/dist/cli/runtime.d.ts +9 -2
- package/dist/cli/runtime.d.ts.map +1 -1
- package/dist/cli/runtime.js +24 -275
- package/dist/cli/runtime.js.map +1 -1
- package/dist/cli-bin.d.ts +3 -0
- package/dist/cli-bin.d.ts.map +1 -0
- package/dist/cli-bin.js +21 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-entry.d.ts +5 -0
- package/dist/cli-entry.d.ts.map +1 -0
- package/dist/cli-entry.js +6 -0
- package/dist/cli-entry.js.map +1 -0
- package/dist/client/css-transport-plugin.d.ts +4 -4
- package/dist/client/file-transport-plugin.d.ts +1 -1
- package/dist/client/http-transport-plugin.d.ts +3 -3
- package/dist/client/index.d.ts +6 -6
- package/dist/client/openapi-client.d.ts +16 -8
- package/dist/client/openapi-client.d.ts.map +1 -1
- package/dist/client/openapi-client.js +59 -24
- package/dist/client/openapi-client.js.map +1 -1
- package/dist/client/proxy.d.ts +2 -2
- package/dist/client/request-builder.d.ts +2 -2
- package/dist/client/rpc-transport-plugin.d.ts +2 -2
- package/dist/client/tools.d.ts +1 -1
- package/dist/client/transport-plugin.d.ts +4 -4
- package/dist/client-entry.d.ts +29 -29
- package/dist/client-lite-entry.d.ts +6 -0
- package/dist/client-lite-entry.d.ts.map +1 -0
- package/dist/client-lite-entry.js +8 -0
- package/dist/client-lite-entry.js.map +1 -0
- package/dist/client-server-entry.d.ts +19 -19
- package/dist/client-side-python/runtime.d.ts +3 -3
- package/dist/client-side-server/bootstrap.d.ts +1 -1
- package/dist/client-side-server/channel.d.ts +1 -1
- package/dist/client-side-server/mqtt-webrtc.d.ts +6 -6
- package/dist/client-side-server/mqtt-webrtc.d.ts.map +1 -1
- package/dist/client-side-server/mqtt-webrtc.js +11 -3
- package/dist/client-side-server/mqtt-webrtc.js.map +1 -1
- package/dist/client-side-server/plat-bridge.d.ts +1 -1
- package/dist/client-side-server/plat-fetch.d.ts +2 -2
- package/dist/client-side-server/plat-service-worker-client.d.ts +1 -1
- package/dist/client-side-server/plat-service-worker.d.ts +1 -1
- package/dist/client-side-server/protocol.d.ts +2 -2
- package/dist/client-side-server/runtime.d.ts +5 -5
- package/dist/client-side-server/server.d.ts +8 -8
- package/dist/client-side-server/server.js +1 -1
- package/dist/client-side-server/worker-pool.d.ts +3 -3
- package/dist/generated/python-browser-sources.js +1 -1
- package/dist/generated/python-browser-sources.js.map +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/python-browser-entry.d.ts +1 -1
- package/dist/server/auth/index.d.ts +1 -1
- package/dist/server/auth/jwt.d.ts +1 -1
- package/dist/server/authority-server.d.ts +1 -1
- package/dist/server/cache/index.d.ts +1 -1
- package/dist/server/cache/utils.d.ts +1 -1
- package/dist/server/config/bucket.d.ts +1 -1
- package/dist/server/config/defaults.d.ts +1 -1
- package/dist/server/config/index.d.ts +4 -4
- package/dist/server/config/logger.d.ts +1 -1
- package/dist/server/config/types.d.ts +6 -6
- package/dist/server/core.d.ts +3 -3
- package/dist/server/file-queue-protocol-plugin.d.ts +2 -2
- package/dist/server/help.d.ts +1 -1
- package/dist/server/index.d.ts +17 -17
- package/dist/server/operation-registry.d.ts +1 -1
- package/dist/server/protocol-plugin.d.ts +3 -3
- package/dist/server/rate-limit/index.d.ts +1 -1
- package/dist/server/rate-limit/utils.d.ts +1 -1
- package/dist/server/rpc-protocol-plugin.d.ts +1 -1
- package/dist/server/server.d.ts +2 -2
- package/dist/server/server.js +1 -1
- package/dist/server/token-limit/index.d.ts +1 -1
- package/dist/server/token-limit/utils.d.ts +1 -1
- package/dist/server/tools.d.ts +2 -2
- package/dist/server/transports.d.ts +1 -1
- package/dist/server/webrtc-plugin.d.ts +3 -3
- package/dist/server/websocket-plugin.d.ts +1 -1
- package/dist/shared/tools.d.ts +1 -1
- package/dist/spec/decorators.d.ts +3 -3
- package/dist/spec/index.d.ts +2 -2
- package/dist/spec/metadata.d.ts +1 -1
- package/dist/static/index.d.ts +5 -5
- package/dist/static/plat-static-folders.d.ts +1 -1
- package/dist/static/static-folder.d.ts +2 -2
- package/dist/types/client.d.ts +3 -3
- package/dist/types/endpoints.d.ts +2 -2
- package/dist/types/errors.d.ts +1 -1
- package/dist/types/index.d.ts +8 -8
- package/package.json +5 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { ClientSideServerMessage } from './protocol';
|
|
2
|
-
import type { ClientSideServerAddress } from './signaling';
|
|
3
|
-
import type { ClientSideServerMQTTWebRTCOptions, ClientSideServerWorkerState } from './mqtt-webrtc';
|
|
1
|
+
import type { ClientSideServerMessage } from './protocol.js';
|
|
2
|
+
import type { ClientSideServerAddress } from './signaling.js';
|
|
3
|
+
import type { ClientSideServerMQTTWebRTCOptions, ClientSideServerWorkerState } from './mqtt-webrtc.js';
|
|
4
4
|
export interface ClientSideServerWorkerPoolSession {
|
|
5
5
|
readonly address: ClientSideServerAddress;
|
|
6
6
|
readonly workers: ReadonlyArray<ClientSideServerWorkerState>;
|
|
@@ -6,7 +6,7 @@ export const PYTHON_BROWSER_SOURCES = {
|
|
|
6
6
|
"plat_browser/errors.py": "from __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom typing import Any\n\n\n@dataclass\nclass HttpResponse(Exception):\n status_code: int\n body: Any = None\n headers: dict[str, str] = field(default_factory=dict)\n\n def __post_init__(self) -> None:\n super().__init__(f\"HTTP {self.status_code}\")\n\n\nclass HttpError(HttpResponse):\n def __init__(\n self,\n status_code: int,\n message: str,\n data: Any = None,\n headers: dict[str, str] | None = None,\n ) -> None:\n body: dict[str, Any] = {\"error\": message}\n if data is not None:\n body[\"data\"] = data\n super().__init__(status_code=status_code, body=body, headers=headers or {})\n self.message = message\n self.data = data\n",
|
|
7
7
|
"plat_browser/metadata.py": "from __future__ import annotations\n\nfrom .types import ControllerMeta\n\n\nCONTROLLER_METADATA_KEY = \"__plat_browser_controller_meta__\"\n\n\ndef get_controller_meta(ctor: type) -> ControllerMeta | None:\n return getattr(ctor, CONTROLLER_METADATA_KEY, None)\n\n\ndef ensure_controller_meta(ctor: type) -> ControllerMeta:\n meta = get_controller_meta(ctor)\n if meta is None:\n meta = ControllerMeta()\n setattr(ctor, CONTROLLER_METADATA_KEY, meta)\n return meta\n",
|
|
8
8
|
"plat_browser/plugins.py": "from __future__ import annotations\n\nimport json\nimport math\nimport time\nfrom dataclasses import dataclass, field\nfrom typing import Any, Callable, Protocol\n\nfrom .errors import HttpError\n\n\n@dataclass\nclass BucketConfig:\n max_balance: float\n fill_interval: int\n fill_amount: float\n refunded_status_codes: list[int] | None = None\n refund_successful: bool | None = None\n min_balance: float = 0\n\n\n@dataclass\nclass RateLimitEntry:\n key: str | None = None\n cost: float | None = None\n config: BucketConfig | None = None\n\n\nRateLimitMeta = RateLimitEntry | list[RateLimitEntry]\n\n\n@dataclass\nclass ResolvedRateLimitEntry:\n key: str\n cost: float\n config: BucketConfig\n\n\n@dataclass\nclass RateLimitContext:\n entries: list[ResolvedRateLimitEntry]\n remaining_balances: list[float]\n\n\nclass RateLimitController(Protocol):\n def check(self, key: str, config: BucketConfig) -> float:\n ...\n\n def deduct(self, key: str, cost: float, config: BucketConfig) -> float:\n ...\n\n def refund(self, key: str, cost: float, config: BucketConfig) -> None:\n ...\n\n\n@dataclass\nclass TokenCallCostFormula:\n initial: float | None = None\n per_limit: float | None = None\n per_char: float | None = None\n\n\n@dataclass\nclass TokenResponseCostFormula:\n per_ms: float | None = None\n per_item: float | None = None\n per_char: float | None = None\n per_key: float | None = None\n\n\nTokenCallCostSpec = float | int | TokenCallCostFormula | Callable[[Any, Any], float]\nTokenResponseCostSpec = float | int | TokenResponseCostFormula | Callable[[Any, Any, Any], float]\nTokenFailureCostSpec = float | int | Callable[[Exception, Any, Any], float]\n\n\n@dataclass\nclass TokenLimitEntry:\n key: str | None = None\n call_cost: TokenCallCostSpec | None = None\n response_cost: TokenResponseCostSpec | None = None\n failure_cost: TokenFailureCostSpec | None = None\n config: BucketConfig | None = None\n\n\nTokenLimitMeta = TokenLimitEntry | list[TokenLimitEntry]\n\n\n@dataclass\nclass TokenLimitTiming:\n start_ms: int\n end_ms: int\n duration_ms: int\n\n\n@dataclass\nclass ResolvedTokenLimitEntry:\n key: str\n call_cost: float\n response_cost_spec: TokenResponseCostSpec | None\n failure_cost_spec: TokenFailureCostSpec | None\n config: BucketConfig\n\n\n@dataclass\nclass TokenLimitContext:\n entries: list[ResolvedTokenLimitEntry]\n remaining_balances: list[float]\n response_costs: list[float] = field(default_factory=list)\n failure_costs: list[float] = field(default_factory=list)\n timing: TokenLimitTiming | None = None\n\n\nclass TokenLimitController(Protocol):\n def check(self, key: str, config: BucketConfig) -> float:\n ...\n\n def deduct(self, key: str, cost: float, config: BucketConfig) -> float:\n ...\n\n def refund(self, key: str, cost: float, config: BucketConfig) -> None:\n ...\n\n\n@dataclass\nclass CacheEntry:\n key: str\n ttl: int | None = None\n methods: list[str] | None = None\n\n\nCacheMeta = CacheEntry | list[CacheEntry]\n\n\n@dataclass\nclass CacheContext:\n key: str | None\n hit: bool\n stored: bool\n\n\nclass CacheController(Protocol):\n def get(self, key: str) -> Any:\n ...\n\n def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:\n ...\n\n def clear(self, key: str) -> None:\n ...\n\n\nRateLimitConfigs = dict[str, BucketConfig]\nTokenLimitConfigs = dict[str, BucketConfig]\n\n\ndef resolve_rate_limit_key(raw: str, method_name: str, base_path: str, user: Any = None) -> str:\n return _resolve_common_key(raw, method_name, base_path, user)\n\n\ndef resolve_token_limit_key(raw: str, method_name: str, base_path: str, user: Any = None) -> str:\n return _resolve_common_key(raw, method_name, base_path, user)\n\n\ndef resolve_cache_key(template: str, params: dict[str, Any], method_name: str, base_path: str, user: Any = None) -> str:\n key = _resolve_common_key(template, method_name, base_path, user)\n for match in set(part for part in _find_template_matches(key, \"{\", \"}\")):\n name = match[1:-1]\n value = params.get(name)\n if value is not None:\n key = key.replace(match, str(value))\n return key\n\n\ndef create_in_memory_rate_limit() -> RateLimitController:\n buckets: dict[str, dict[str, float]] = {}\n return _create_in_memory_bucket_controller(buckets, \"Rate limit exceeded\")\n\n\ndef create_in_memory_token_limit() -> TokenLimitController:\n buckets: dict[str, dict[str, float]] = {}\n return _create_in_memory_bucket_controller(buckets, \"Token limit exceeded\")\n\n\ndef create_in_memory_cache() -> CacheController:\n store: dict[str, dict[str, Any]] = {}\n\n class Controller:\n def get(self, key: str) -> Any:\n entry = store.get(key)\n if entry is None:\n return None\n expires_at = entry.get(\"expires_at\")\n if expires_at is not None and time.time() * 1000 > expires_at:\n store.pop(key, None)\n return None\n return entry[\"value\"]\n\n def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:\n expires_at = None\n if ttl_seconds is not None:\n expires_at = time.time() * 1000 + ttl_seconds * 1000\n store[key] = {\"value\": value, \"expires_at\": expires_at}\n\n def clear(self, key: str) -> None:\n store.pop(key, None)\n\n return Controller()\n\n\ndef apply_rate_limit_check(\n meta: RateLimitMeta | None,\n controller: RateLimitController,\n configs: RateLimitConfigs,\n method_name: str,\n base_path: str,\n user: Any = None,\n) -> tuple[list[ResolvedRateLimitEntry], list[float]]:\n if meta is None:\n return [], []\n\n entries = meta if isinstance(meta, list) else [meta]\n resolved: list[ResolvedRateLimitEntry] = []\n remaining_balances: list[float] = []\n for entry in entries:\n raw_key = entry.key or \":route\"\n key = resolve_rate_limit_key(raw_key, method_name, base_path, user)\n cost = float(entry.cost if entry.cost is not None else 1)\n config = entry.config or configs.get(key)\n if config is None:\n raise ValueError(f'Rate limit config not found for key \"{key}\".')\n remaining = controller.deduct(key, cost, config)\n resolved.append(ResolvedRateLimitEntry(key=key, cost=cost, config=config))\n remaining_balances.append(remaining)\n return resolved, remaining_balances\n\n\ndef apply_rate_limit_refund(\n entries: list[ResolvedRateLimitEntry],\n controller: RateLimitController,\n status_code: int,\n) -> None:\n for entry in entries:\n config = entry.config\n should_refund = (\n (config.refunded_status_codes and status_code in config.refunded_status_codes)\n or (config.refund_successful and 200 <= status_code < 300)\n )\n if should_refund:\n controller.refund(entry.key, entry.cost, config)\n\n\ndef apply_token_limit_check(\n meta: TokenLimitMeta | None,\n controller: TokenLimitController,\n configs: TokenLimitConfigs,\n method_name: str,\n base_path: str,\n params: Any,\n ctx: Any,\n user: Any = None,\n) -> tuple[list[ResolvedTokenLimitEntry], list[float]]:\n if meta is None:\n return [], []\n\n entries = meta if isinstance(meta, list) else [meta]\n resolved: list[ResolvedTokenLimitEntry] = []\n remaining_balances: list[float] = []\n for entry in entries:\n raw_key = entry.key or \":route\"\n key = resolve_token_limit_key(raw_key, method_name, base_path, user)\n call_cost = resolve_call_cost(entry.call_cost if entry.call_cost is not None else 1, params, ctx)\n config = entry.config or configs.get(key)\n if config is None:\n raise ValueError(f'Token limit config not found for key \"{key}\".')\n remaining = controller.deduct(key, call_cost, config)\n resolved.append(\n ResolvedTokenLimitEntry(\n key=key,\n call_cost=call_cost,\n response_cost_spec=entry.response_cost,\n failure_cost_spec=entry.failure_cost,\n config=config,\n )\n )\n remaining_balances.append(remaining)\n return resolved, remaining_balances\n\n\ndef apply_token_limit_response(\n entries: list[ResolvedTokenLimitEntry],\n controller: TokenLimitController,\n result: Any,\n timing: TokenLimitTiming,\n params: Any,\n status_code: int,\n) -> list[float]:\n response_costs: list[float] = []\n for entry in entries:\n config = entry.config\n should_refund_call = (\n (config.refunded_status_codes and status_code in config.refunded_status_codes)\n or (config.refund_successful and 200 <= status_code < 300)\n )\n if should_refund_call:\n controller.refund(entry.key, entry.call_cost, config)\n continue\n response_cost = resolve_response_cost(entry.response_cost_spec, result, timing, params)\n if response_cost > 0:\n controller.deduct(entry.key, response_cost, config)\n response_costs.append(response_cost)\n return response_costs\n\n\ndef apply_token_limit_failure(\n entries: list[ResolvedTokenLimitEntry],\n controller: TokenLimitController,\n error: Exception,\n timing: TokenLimitTiming,\n params: Any,\n status_code: int,\n) -> list[float]:\n failure_costs: list[float] = []\n for entry in entries:\n failure_cost = resolve_failure_cost(entry.failure_cost_spec, error, timing, params)\n if failure_cost > 0:\n controller.deduct(entry.key, failure_cost, entry.config)\n elif failure_cost < 0:\n controller.refund(entry.key, -failure_cost, entry.config)\n else:\n config = entry.config\n should_refund_call = (\n (config.refunded_status_codes and status_code in config.refunded_status_codes)\n or (config.refund_successful and 200 <= status_code < 300)\n )\n if should_refund_call:\n controller.refund(entry.key, entry.call_cost, config)\n failure_costs.append(failure_cost)\n return failure_costs\n\n\ndef apply_cache_check(\n meta: CacheMeta | None,\n controller: CacheController,\n params: dict[str, Any],\n http_method: str,\n method_name: str,\n base_path: str,\n user: Any = None,\n) -> tuple[str | None, bool, Any, CacheEntry | None]:\n if meta is None:\n return None, False, None, None\n\n entries = meta if isinstance(meta, list) else [meta]\n for entry in entries:\n methods = entry.methods or [\"GET\"]\n if http_method not in methods:\n continue\n cache_key = resolve_cache_key(entry.key, params, method_name, base_path, user)\n cached_value = controller.get(cache_key)\n if cached_value is not None:\n return cache_key, True, cached_value, entry\n return cache_key, False, None, entry\n return None, False, None, None\n\n\ndef apply_cache_store(cache_key: str | None, entry: CacheEntry | None, controller: CacheController, result: Any) -> None:\n if cache_key is not None and entry is not None:\n controller.set(cache_key, result, entry.ttl)\n\n\ndef resolve_call_cost(spec: TokenCallCostSpec | None, params: Any, ctx: Any) -> float:\n if spec is None:\n return 1\n if isinstance(spec, (int, float)):\n return float(spec)\n if isinstance(spec, TokenCallCostFormula):\n limit = _get_value(params, \"limit\", 0)\n param_chars = len(json.dumps(params, default=str))\n return (\n float(spec.initial or 0)\n + float(spec.per_limit or 0) * float(limit)\n + float(spec.per_char or 0) * param_chars\n )\n return float(spec(params, ctx))\n\n\ndef resolve_response_cost(spec: TokenResponseCostSpec | None, result: Any, timing: TokenLimitTiming, params: Any) -> float:\n if spec in {None, 0}:\n return 0\n if isinstance(spec, (int, float)):\n return float(spec)\n if isinstance(spec, TokenResponseCostFormula):\n return (\n float(spec.per_ms or 0) * timing.duration_ms\n + float(spec.per_item or 0) * _count_items(result)\n + float(spec.per_char or 0) * len(json.dumps(result, default=str))\n + float(spec.per_key or 0) * _count_keys(result)\n )\n return float(spec(result, timing, params))\n\n\ndef resolve_failure_cost(spec: TokenFailureCostSpec | None, error: Exception, timing: TokenLimitTiming, params: Any) -> float:\n if spec in {None, 0}:\n return 0\n if isinstance(spec, (int, float)):\n return float(spec)\n return float(spec(error, timing, params))\n\n\ndef _resolve_common_key(raw: str, method_name: str, base_path: str, user: Any = None) -> str:\n key = raw.replace(\":route\", method_name).replace(\":parent\", base_path)\n if user is not None:\n user_id = _get_value(user, \"sub\") or _get_value(user, \"id\")\n if user_id is not None:\n key = key.replace(\":user\", str(user_id))\n tier = _get_value(user, \"tier\") or _get_value(user, \"plan\")\n if tier is not None:\n key = key.replace(\":tier\", str(tier))\n for match in set(part for part in _find_user_tokens(key)):\n field = match[6:]\n value = _get_value(user, field)\n if value is not None:\n key = key.replace(match, str(value))\n return key\n\n\ndef _find_user_tokens(value: str) -> list[str]:\n parts: list[str] = []\n start = 0\n token = \":user:\"\n while True:\n idx = value.find(token, start)\n if idx < 0:\n break\n end = idx + len(token)\n while end < len(value) and (value[end].isalnum() or value[end] == \"_\"):\n end += 1\n parts.append(value[idx:end])\n start = end\n return parts\n\n\ndef _find_template_matches(value: str, open_char: str, close_char: str) -> list[str]:\n parts: list[str] = []\n start = 0\n while True:\n left = value.find(open_char, start)\n if left < 0:\n break\n right = value.find(close_char, left + 1)\n if right < 0:\n break\n parts.append(value[left : right + 1])\n start = right + 1\n return parts\n\n\ndef _get_value(obj: Any, key: str, default: Any = None) -> Any:\n if isinstance(obj, dict):\n return obj.get(key, default)\n return getattr(obj, key, default)\n\n\ndef _count_items(value: Any) -> int:\n if isinstance(value, list):\n return len(value)\n if isinstance(value, dict):\n return 1\n return 0\n\n\ndef _count_keys(value: Any) -> int:\n if isinstance(value, dict):\n return len(value.keys())\n return 0\n\n\ndef _create_in_memory_bucket_controller(\n buckets: dict[str, dict[str, float]],\n message: str,\n):\n class Controller:\n def check(self, key: str, config: BucketConfig) -> float:\n return _refill_bucket(buckets, key, config)\n\n def deduct(self, key: str, cost: float, config: BucketConfig) -> float:\n balance = _refill_bucket(buckets, key, config)\n new_balance = balance - cost\n min_balance = config.min_balance\n if new_balance < min_balance:\n deficit = min_balance - new_balance\n intervals_needed = math.ceil(deficit / config.fill_amount)\n retry_after_ms = intervals_needed * config.fill_interval\n raise HttpError(429, message, {\"retry_after_ms\": retry_after_ms})\n buckets[key][\"balance\"] = new_balance\n return new_balance\n\n def refund(self, key: str, cost: float, config: BucketConfig) -> None:\n if key not in buckets:\n buckets[key] = {\n \"balance\": min(config.max_balance, cost),\n \"last_refill_ms\": time.time() * 1000,\n }\n else:\n buckets[key][\"balance\"] = min(config.max_balance, buckets[key][\"balance\"] + cost)\n\n return Controller()\n\n\ndef _refill_bucket(buckets: dict[str, dict[str, float]], key: str, config: BucketConfig) -> float:\n now = time.time() * 1000\n bucket = buckets.get(key)\n if bucket is None:\n buckets[key] = {\"balance\": config.max_balance, \"last_refill_ms\": now}\n return config.max_balance\n\n elapsed_ms = now - bucket[\"last_refill_ms\"]\n intervals_elapsed = math.floor(elapsed_ms / config.fill_interval)\n if intervals_elapsed > 0:\n generated = intervals_elapsed * config.fill_amount\n bucket[\"balance\"] = min(config.max_balance, bucket[\"balance\"] + generated)\n bucket[\"last_refill_ms\"] = now\n return bucket[\"balance\"]\n",
|
|
9
|
-
"plat_browser/server.py": "from __future__ import annotations\n\nimport ast\nimport inspect\nimport logging\nimport re\nfrom dataclasses import dataclass, field\nimport time\nimport sys\nfrom typing import Any, Awaitable, Callable, Literal, Mapping, get_args, get_origin, get_type_hints\n\nfrom .decorators import ROUTE_METADATA_KEY\nfrom .errors import HttpError\nfrom .metadata import get_controller_meta\nfrom .plugins import (\n BucketConfig,\n CacheEntry,\n CacheMeta,\n CacheContext,\n RateLimitContext,\n RateLimitEntry,\n RateLimitMeta,\n TokenLimitContext,\n TokenLimitEntry,\n TokenLimitMeta,\n TokenLimitTiming,\n apply_cache_check,\n apply_cache_store,\n apply_rate_limit_check,\n apply_rate_limit_refund,\n apply_token_limit_check,\n apply_token_limit_failure,\n apply_token_limit_response,\n create_in_memory_cache,\n create_in_memory_rate_limit,\n create_in_memory_token_limit,\n)\nfrom .types import ControllerMeta, RouteContext, RouteMeta\n\n\nUndecoratedMode = Literal[\"GET\", \"POST\", \"private\"]\nServerMessage = Mapping[str, Any]\nRESERVED_METHOD_NAMES = {\"tools\", \"routes\", \"endpoints\", \"help\", \"openapi\"}\nLEGACY_PAYLOAD_PARAM_NAMES = {\"input\", \"payload\", \"body\", \"data\"}\nPYTHON_BROWSER_STDLIB_MODULES = {\n \"__future__\",\n \"abc\",\n \"argparse\",\n \"asyncio\",\n \"base64\",\n \"collections\",\n \"copy\",\n \"csv\",\n \"dataclasses\",\n \"datetime\",\n \"decimal\",\n \"enum\",\n \"functools\",\n \"hashlib\",\n \"itertools\",\n \"json\",\n \"logging\",\n \"math\",\n \"pathlib\",\n \"random\",\n \"re\",\n \"statistics\",\n \"string\",\n \"sys\",\n \"textwrap\",\n \"time\",\n \"typing\",\n \"uuid\",\n}\n\n\n@dataclass(slots=True)\nclass BrowserPackagePlan:\n python_source: str\n requested_packages: list[str] = field(default_factory=list)\n imported_modules: list[str] = field(default_factory=list)\n import_rewrites: list[str] = field(default_factory=list)\n\n\n@dataclass(slots=True)\nclass BrowserServerDefinition:\n server_name: str\n controllers: list[type[Any]]\n requested_packages: list[str] = field(default_factory=list)\n imported_modules: list[str] = field(default_factory=list)\n options: dict[str, Any] = field(default_factory=dict)\n\n\n@dataclass(slots=True)\nclass BrowserResolvedOperation:\n method_name: str\n method: str\n path: str\n bound_method: Any\n route_meta: RouteMeta\n controller_meta: ControllerMeta\n input_annotation: Any\n output_annotation: Any\n params: list[inspect.Parameter]\n\n\nBrowserMiddlewareContext = dict[str, Any]\nBrowserMiddleware = Callable[[BrowserMiddlewareContext, Callable[[], Awaitable[Any]]], Awaitable[Any] | Any]\n\n\nclass CallBridge:\n def __init__(self, emit=None):\n self._emit = emit\n\n def emit(self, event: str, data: Any) -> None:\n if callable(self._emit):\n self._emit({\"event\": event, \"data\": data})\n\n def progress(self, data: Any) -> None:\n self.emit(\"progress\", data)\n\n def log(self, data: Any) -> None:\n self.emit(\"log\", data)\n\n def chunk(self, data: Any) -> None:\n self.emit(\"chunk\", data)\n\n def message(self, data: Any) -> None:\n self.emit(\"message\", data)\n\n\nclass BrowserPLATServer:\n def __init__(\n self,\n options: Mapping[str, Any] | None = None,\n *controller_classes: type[Any],\n server_name: str | None = None,\n undecorated_mode: UndecoratedMode = \"POST\",\n ) -> None:\n self.options = dict(options or {})\n self.server_name = server_name or self.options.get(\"server_name\") or \"browser-python-server\"\n self.undecorated_mode = cast_undecorated_mode(self.options.get(\"undecorated_mode\"), undecorated_mode)\n self.logger = self.options.get(\"logger\") or logging.getLogger(\"plat_browser.server\")\n self.middleware: list[BrowserMiddleware] = list(self.options.get(\"middleware\") or [])\n self.routes: list[dict[str, str]] = []\n self.tools: dict[str, dict[str, Any]] = {}\n self.operations_by_id: dict[str, BrowserResolvedOperation] = {}\n self.operations_by_route: dict[str, BrowserResolvedOperation] = {}\n self.registered_method_names: set[str] = set()\n self.registered_controller_names: set[str] = set()\n self._setup_policy_defaults()\n if controller_classes:\n self.register(*controller_classes)\n\n def use(self, middleware: BrowserMiddleware) -> \"BrowserPLATServer\":\n self.middleware.append(middleware)\n return self\n\n @property\n def openapi(self) -> dict[str, Any]:\n return self.generate_openapi()\n\n def _setup_policy_defaults(self) -> None:\n if self.options.get(\"rate_limit\") is not None and not self.options[\"rate_limit\"].get(\"controller\"):\n self.options[\"rate_limit\"][\"controller\"] = create_in_memory_rate_limit()\n if self.options.get(\"token_limit\") is not None and not self.options[\"token_limit\"].get(\"controller\"):\n self.options[\"token_limit\"][\"controller\"] = create_in_memory_token_limit()\n if self.options.get(\"cache\") is not None and not self.options[\"cache\"].get(\"controller\"):\n self.options[\"cache\"][\"controller\"] = create_in_memory_cache()\n\n def register(self, *controller_classes: type[Any]) -> \"BrowserPLATServer\":\n for controller_class in controller_classes:\n meta = get_controller_meta(controller_class) or ControllerMeta(\n base_path=controller_class.__name__,\n tag=controller_class.__name__,\n )\n instance = controller_class()\n controller_tag = meta.tag or meta.base_path or controller_class.__name__\n lower_controller_tag = controller_tag.lower()\n if lower_controller_tag in RESERVED_METHOD_NAMES:\n raise ValueError(f\"Controller '{controller_tag}' uses a reserved plat system name.\")\n if controller_tag in self.registered_method_names:\n raise ValueError(f\"Controller '{controller_tag}' conflicts with an existing method name.\")\n self.registered_controller_names.add(controller_tag)\n\n methods = inspect.getmembers(controller_class, predicate=inspect.isfunction)\n for method_name, method_fn in methods:\n route_meta = getattr(method_fn, ROUTE_METADATA_KEY, None)\n if route_meta is None:\n route_meta = self._build_implicit_route_meta(method_name)\n if route_meta is None:\n continue\n self._hydrate_route_meta(route_meta, method_fn)\n self._validate_method_name(method_name, controller_class.__name__)\n if method_name in self.registered_controller_names:\n raise ValueError(f\"Method '{method_name}' in {controller_class.__name__} conflicts with controller name '{method_name}'.\")\n if method_name in self.registered_method_names:\n raise ValueError(f'Duplicate operationId: method \"{method_name}\" is defined in multiple controllers.')\n self.registered_method_names.add(method_name)\n\n bound_method = getattr(instance, method_name)\n full_path = route_meta.path or f\"/{method_name}\"\n operation = BrowserResolvedOperation(\n method_name=method_name,\n method=(route_meta.method or \"POST\").upper(),\n path=full_path,\n bound_method=bound_method,\n route_meta=route_meta,\n controller_meta=meta,\n input_annotation=route_meta.input_model,\n output_annotation=route_meta.output_model,\n params=list(inspect.signature(method_fn).parameters.values())[1:],\n )\n self.operations_by_id[method_name] = operation\n self.operations_by_route[f\"{operation.method} {operation.path}\"] = operation\n self.routes.append({\"method\": operation.method, \"path\": operation.path, \"methodName\": method_name})\n self.tools[method_name] = {\n \"name\": method_name,\n \"summary\": route_meta.summary,\n \"description\": route_meta.description or f\"{operation.method} {full_path}\",\n \"method\": operation.method,\n \"path\": full_path,\n \"controller\": controller_tag,\n \"tags\": [controller_tag],\n \"input_schema\": self._build_schema(route_meta.input_model),\n \"response_schema\": self._build_schema(route_meta.output_model),\n }\n return self\n\n def generate_openapi(self) -> dict[str, Any]:\n paths: dict[str, dict[str, Any]] = {}\n for operation in self.operations_by_id.values():\n path_item = paths.setdefault(operation.path, {})\n entry: dict[str, Any] = {\n \"operationId\": operation.method_name,\n \"summary\": operation.route_meta.summary,\n \"description\": operation.route_meta.description,\n \"responses\": {\"200\": {\"description\": \"ok\"}},\n }\n input_schema = self._build_schema(operation.input_annotation)\n if input_schema is not None:\n entry[\"requestBody\"] = {\n \"required\": True,\n \"content\": {\"application/json\": {\"schema\": input_schema}},\n }\n response_schema = self._build_schema(operation.output_annotation)\n if response_schema is not None:\n entry[\"responses\"][\"200\"][\"content\"] = {\"application/json\": {\"schema\": response_schema}}\n path_item[operation.method.lower()] = trim_none(entry)\n return {\n \"openapi\": \"3.1.0\",\n \"info\": {\"title\": \"plat browser python server\", \"version\": \"0.1.0\"},\n \"paths\": paths,\n }\n\n async def handle_request(self, message: ServerMessage, emit=None) -> Any:\n message = normalize_server_message(message)\n operation = self._resolve_operation(\n operation_id=coerce_str(message.get(\"operationId\")),\n method=coerce_str(message.get(\"method\")) or \"POST\",\n path=coerce_str(message.get(\"path\")) or \"\",\n )\n if operation is None:\n raise KeyError(\"No browser operation matched the incoming request.\")\n\n payload = (\n message.get(\"input\")\n if \"input\" in message\n else message.get(\"body\")\n if \"body\" in message\n else message.get(\"params\")\n if \"params\" in message\n else {}\n )\n ctx = RouteContext(\n method=operation.method,\n url=operation.path,\n headers=dict(message.get(\"headers\") or {}),\n call=CallBridge(emit),\n opts={\"transport\": \"browser-python\"},\n request=dict(message),\n )\n status_code = 200\n rate_limit_entries = []\n token_limit_entries = []\n token_limit_start_ms = int(time.time() * 1000)\n handler_called = False\n on_request = self.options.get(\"on_request\") or self.options.get(\"onRequest\")\n on_response = self.options.get(\"on_response\") or self.options.get(\"onResponse\")\n on_error = self.options.get(\"on_error\") or self.options.get(\"onError\")\n if callable(on_request):\n maybe = on_request(message, ctx)\n if inspect.isawaitable(maybe):\n await maybe\n auth_mode = operation.route_meta.auth or operation.controller_meta.auth or self.options.get(\"default_auth\")\n auth_handler = self.options.get(\"auth\")\n if auth_mode and auth_handler and callable(auth_handler.get(\"verify\")):\n auth_result = auth_handler[\"verify\"](auth_mode, message, ctx)\n if inspect.isawaitable(auth_result):\n auth_result = await auth_result\n ctx.auth = auth_result\n rate_limit_meta = self._get_rate_limit_meta(operation.route_meta, operation.controller_meta)\n if rate_limit_meta is not None and self.options.get(\"rate_limit\", {}).get(\"controller\"):\n entries, balances = apply_rate_limit_check(\n rate_limit_meta,\n self.options[\"rate_limit\"][\"controller\"],\n self.options[\"rate_limit\"].get(\"configs\", {}),\n operation.method_name,\n operation.controller_meta.base_path,\n ctx.auth,\n )\n rate_limit_entries = entries\n ctx.rate_limit = RateLimitContext(entries=entries, remaining_balances=balances)\n token_limit_meta = self._get_token_limit_meta(operation.route_meta, operation.controller_meta)\n if token_limit_meta is not None and self.options.get(\"token_limit\", {}).get(\"controller\"):\n entries, balances = apply_token_limit_check(\n token_limit_meta,\n self.options[\"token_limit\"][\"controller\"],\n self.options[\"token_limit\"].get(\"configs\", {}),\n operation.method_name,\n operation.controller_meta.base_path,\n payload,\n ctx,\n ctx.auth,\n )\n token_limit_entries = entries\n ctx.token_limit = TokenLimitContext(entries=entries, remaining_balances=balances)\n cache_key = None\n cache_entry = None\n cache_meta = self._get_cache_meta(operation.route_meta, operation.controller_meta)\n if cache_meta is not None and self.options.get(\"cache\", {}).get(\"controller\"):\n cache_key, hit, cached_value, cache_entry = apply_cache_check(\n cache_meta,\n self.options[\"cache\"][\"controller\"],\n dict(payload) if isinstance(payload, Mapping) else {},\n operation.method,\n operation.method_name,\n operation.controller_meta.base_path,\n ctx.auth,\n )\n ctx.cache = CacheContext(key=cache_key, hit=hit, stored=False)\n if hit:\n return cached_value\n middleware_context: BrowserMiddlewareContext = {\n \"request\": dict(message),\n \"operation\": operation,\n \"ctx\": ctx,\n \"input\": payload,\n \"logger\": self.logger,\n }\n try:\n handler_called = True\n result = await self._run_middleware_chain(\n middleware_context,\n lambda: self._invoke_handler(operation.bound_method, operation.params, payload, ctx),\n )\n if token_limit_entries and self.options.get(\"token_limit\", {}).get(\"controller\"):\n timing = TokenLimitTiming(\n start_ms=token_limit_start_ms,\n end_ms=int(time.time() * 1000),\n duration_ms=max(0, int(time.time() * 1000) - token_limit_start_ms),\n )\n response_costs = apply_token_limit_response(\n token_limit_entries,\n self.options[\"token_limit\"][\"controller\"],\n result,\n timing,\n payload,\n status_code,\n )\n if ctx.token_limit:\n ctx.token_limit.timing = timing\n ctx.token_limit.response_costs = response_costs\n if rate_limit_entries and self.options.get(\"rate_limit\", {}).get(\"controller\"):\n apply_rate_limit_refund(rate_limit_entries, self.options[\"rate_limit\"][\"controller\"], status_code)\n if cache_entry is not None and self.options.get(\"cache\", {}).get(\"controller\"):\n apply_cache_store(cache_key, cache_entry, self.options[\"cache\"][\"controller\"], result)\n if ctx.cache:\n ctx.cache.stored = True\n if callable(on_response):\n maybe = on_response(message, ctx, result)\n if inspect.isawaitable(maybe):\n await maybe\n return result\n except Exception as error:\n status_code = getattr(error, \"status_code\", 500)\n if token_limit_entries and self.options.get(\"token_limit\", {}).get(\"controller\") and handler_called:\n timing = TokenLimitTiming(\n start_ms=token_limit_start_ms,\n end_ms=int(time.time() * 1000),\n duration_ms=max(0, int(time.time() * 1000) - token_limit_start_ms),\n )\n failure_costs = apply_token_limit_failure(\n token_limit_entries,\n self.options[\"token_limit\"][\"controller\"],\n error,\n timing,\n payload,\n status_code,\n )\n if ctx.token_limit:\n ctx.token_limit.timing = timing\n ctx.token_limit.failure_costs = failure_costs\n if rate_limit_entries and self.options.get(\"rate_limit\", {}).get(\"controller\"):\n apply_rate_limit_refund(rate_limit_entries, self.options[\"rate_limit\"][\"controller\"], status_code)\n self.logger.error(\"BrowserPLATServer request failed\", exc_info=error)\n if callable(on_error):\n maybe = on_error(message, ctx, error)\n if inspect.isawaitable(maybe):\n await maybe\n raise\n\n def _resolve_operation(self, *, operation_id: str | None, method: str, path: str) -> BrowserResolvedOperation | None:\n if operation_id and operation_id in self.operations_by_id:\n return self.operations_by_id[operation_id]\n return self.operations_by_route.get(f\"{method.upper()} {path}\")\n\n async def _run_middleware_chain(\n self,\n context: BrowserMiddlewareContext,\n final_handler: Callable[[], Any],\n ) -> Any:\n index = -1\n\n async def dispatch(next_index: int) -> Any:\n nonlocal index\n if next_index <= index:\n raise RuntimeError(\"Browser middleware called next() multiple times\")\n index = next_index\n if next_index >= len(self.middleware):\n result = final_handler()\n if inspect.isawaitable(result):\n return await result\n return result\n middleware = self.middleware[next_index]\n result = middleware(context, lambda: dispatch(next_index + 1))\n if inspect.isawaitable(result):\n return await result\n return result\n\n return await dispatch(0)\n\n def _invoke_handler(self, bound_method: Any, params: list[inspect.Parameter], parsed_input: Any, ctx: RouteContext) -> Any:\n if not params:\n return bound_method()\n non_context_params = [param for param in params if not self._is_context_parameter(param)]\n use_legacy_payload_param = (\n len(non_context_params) == 1\n and non_context_params[0].name in LEGACY_PAYLOAD_PARAM_NAMES\n )\n is_mapping_payload = isinstance(parsed_input, Mapping)\n\n if len(params) == 1:\n only_param = params[0]\n if self._is_context_parameter(only_param):\n return bound_method(ctx)\n if use_legacy_payload_param or not is_mapping_payload:\n return bound_method(parsed_input)\n if only_param.kind is inspect.Parameter.VAR_KEYWORD:\n return bound_method(**dict(parsed_input))\n return bound_method(**{only_param.name: parsed_input.get(only_param.name, parsed_input)})\n\n args: list[Any] = []\n kwargs: dict[str, Any] = {}\n payload_mapping = dict(parsed_input) if is_mapping_payload else {}\n\n for param in params:\n if self._is_context_parameter(param):\n if param.kind is inspect.Parameter.POSITIONAL_ONLY:\n args.append(ctx)\n else:\n kwargs[param.name] = ctx\n continue\n\n if use_legacy_payload_param:\n if param.kind is inspect.Parameter.POSITIONAL_ONLY:\n args.append(parsed_input)\n else:\n kwargs[param.name] = parsed_input\n continue\n\n if param.kind is inspect.Parameter.VAR_KEYWORD:\n kwargs.update(payload_mapping)\n continue\n\n if not is_mapping_payload:\n if param.kind is inspect.Parameter.POSITIONAL_ONLY:\n args.append(parsed_input)\n else:\n kwargs[param.name] = parsed_input\n continue\n\n if param.name not in payload_mapping:\n continue\n\n if param.kind is inspect.Parameter.POSITIONAL_ONLY:\n args.append(payload_mapping[param.name])\n else:\n kwargs[param.name] = payload_mapping[param.name]\n\n return bound_method(*args, **kwargs)\n\n def _is_context_parameter(self, param: inspect.Parameter) -> bool:\n return param.annotation is RouteContext or param.name in {\"ctx\", \"context\"}\n\n def _build_implicit_route_meta(self, method_name: str) -> RouteMeta | None:\n if self.undecorated_mode == \"private\" or method_name.startswith(\"_\"):\n return None\n return RouteMeta(name=method_name, method=self.undecorated_mode, path=f\"/{method_name}\")\n\n def _hydrate_route_meta(self, route_meta: RouteMeta, method_fn: Any) -> None:\n signature = inspect.signature(method_fn)\n params = list(signature.parameters.values())[1:]\n module = sys.modules.get(getattr(method_fn, \"__module__\", \"\"))\n try:\n resolved_hints = get_type_hints(\n method_fn,\n globalns=vars(module) if module is not None else None,\n )\n except Exception:\n resolved_hints = {}\n input_params = [\n param.replace(annotation=resolved_hints.get(param.name, param.annotation))\n for param in params\n if not self._is_context_parameter(param)\n ]\n route_meta.input_model = build_input_model_from_params(input_params)\n route_meta.output_model = resolved_hints.get(\"return\", signature.return_annotation)\n if route_meta.path is None:\n route_meta.path = f\"/{route_meta.name}\"\n if route_meta.method is None:\n route_meta.method = self.undecorated_mode if self.undecorated_mode != \"private\" else \"POST\"\n docstring = inspect.getdoc(method_fn)\n if docstring and not route_meta.summary:\n first_line, *_ = docstring.splitlines()\n route_meta.summary = first_line.strip() or None\n if len(docstring.splitlines()) > 1 and not route_meta.description:\n route_meta.description = docstring.strip()\n\n def _validate_method_name(self, method_name: str, controller_name: str) -> None:\n if not method_name:\n return\n if method_name[0].isalpha() and method_name[0].isupper():\n raise ValueError(f\"Method '{method_name}' in {controller_name} violates plat naming convention: method names must start with a lowercase letter.\")\n if \"_\" in method_name:\n raise ValueError(f\"Method '{method_name}' in {controller_name} violates plat naming convention: underscores are not allowed.\")\n if method_name.lower() in RESERVED_METHOD_NAMES:\n raise ValueError(f\"Method '{method_name}' in {controller_name} uses a reserved plat system name.\")\n\n def _build_schema(self, annotation: Any) -> dict[str, Any] | None:\n if isinstance(annotation, dict) and \"type\" in annotation:\n return annotation\n if annotation in {None, inspect.Signature.empty, Any}:\n return None\n return annotation_to_schema(annotation)\n\n def _get_rate_limit_meta(self, route_meta: RouteMeta, controller_meta: ControllerMeta):\n route_value = (route_meta.opts or {}).get(\"rateLimit\", route_meta.rate_limit)\n controller_value = getattr(controller_meta, \"rate_limit\", None)\n return self._coerce_rate_limit_meta(route_value or controller_value)\n\n def _get_token_limit_meta(self, route_meta: RouteMeta, controller_meta: ControllerMeta):\n route_value = (route_meta.opts or {}).get(\"tokenLimit\", route_meta.token_limit)\n controller_value = getattr(controller_meta, \"token_limit\", None)\n return self._coerce_token_limit_meta(route_value or controller_value)\n\n def _get_cache_meta(self, route_meta: RouteMeta, controller_meta: ControllerMeta):\n route_value = (route_meta.opts or {}).get(\"cache\", route_meta.cache)\n controller_value = getattr(controller_meta, \"cache\", None)\n return self._coerce_cache_meta(route_value or controller_value)\n\n def _coerce_rate_limit_meta(self, value: Any):\n if value is None:\n return None\n if isinstance(value, list):\n return [self._coerce_rate_limit_entry(item) for item in value]\n return self._coerce_rate_limit_entry(value)\n\n def _coerce_token_limit_meta(self, value: Any):\n if value is None:\n return None\n if isinstance(value, list):\n return [self._coerce_token_limit_entry(item) for item in value]\n return self._coerce_token_limit_entry(value)\n\n def _coerce_cache_meta(self, value: Any):\n if value is None:\n return None\n if isinstance(value, list):\n return [self._coerce_cache_entry(item) for item in value]\n return self._coerce_cache_entry(value)\n\n def _coerce_rate_limit_entry(self, value: Any) -> RateLimitEntry:\n if isinstance(value, RateLimitEntry):\n return value\n data = dict(value)\n if isinstance(data.get(\"config\"), dict):\n data[\"config\"] = self._coerce_bucket_config(data[\"config\"])\n return RateLimitEntry(\n key=data.get(\"key\"),\n cost=data.get(\"cost\"),\n config=data.get(\"config\"),\n )\n\n def _coerce_token_limit_entry(self, value: Any) -> TokenLimitEntry:\n if isinstance(value, TokenLimitEntry):\n return value\n data = dict(value)\n if isinstance(data.get(\"config\"), dict):\n data[\"config\"] = self._coerce_bucket_config(data[\"config\"])\n return TokenLimitEntry(\n key=data.get(\"key\"),\n call_cost=data.get(\"callCost\", data.get(\"call_cost\")),\n response_cost=data.get(\"responseCost\", data.get(\"response_cost\")),\n failure_cost=data.get(\"failureCost\", data.get(\"failure_cost\")),\n config=data.get(\"config\"),\n )\n\n def _coerce_cache_entry(self, value: Any) -> CacheEntry:\n if isinstance(value, CacheEntry):\n return value\n data = dict(value)\n return CacheEntry(\n key=data[\"key\"],\n ttl=data.get(\"ttl\"),\n methods=data.get(\"methods\"),\n )\n\n def _coerce_bucket_config(self, value: Any) -> BucketConfig:\n if isinstance(value, BucketConfig):\n return value\n data = dict(value)\n return BucketConfig(\n max_balance=data[\"maxBalance\"] if \"maxBalance\" in data else data[\"max_balance\"],\n fill_interval=data[\"fillInterval\"] if \"fillInterval\" in data else data[\"fill_interval\"],\n fill_amount=data[\"fillAmount\"] if \"fillAmount\" in data else data[\"fill_amount\"],\n refunded_status_codes=data.get(\"refundedStatusCodes\", data.get(\"refunded_status_codes\")),\n refund_successful=data.get(\"refundSuccessful\", data.get(\"refund_successful\")),\n min_balance=data.get(\"minBalance\", data.get(\"min_balance\", 0)),\n )\n\n\ndef create_browser_server(\n options: Mapping[str, Any] | None = None,\n *controller_classes: type[Any],\n server_name: str | None = None,\n undecorated_mode: UndecoratedMode = \"POST\",\n) -> BrowserPLATServer:\n return BrowserPLATServer(options, *controller_classes, server_name=server_name, undecorated_mode=undecorated_mode)\n\n\n# Server-style parity alias.\ncreate_server = create_browser_server\n\n\ndef serve_client_side_server(\n server_name: str,\n controllers: list[type[Any]] | tuple[type[Any], ...],\n **options: Any,\n) -> BrowserServerDefinition:\n definition = BrowserServerDefinition(\n server_name=server_name,\n controllers=list(controllers),\n requested_packages=list(options.pop(\"requested_packages\", []) or []),\n imported_modules=list(options.pop(\"imported_modules\", []) or []),\n options=dict(options),\n )\n frame = inspect.currentframe()\n caller_globals = frame.f_back.f_globals if frame and frame.f_back else None\n if isinstance(caller_globals, dict):\n caller_globals[\"__plat_browser_server_definition__\"] = definition\n caller_globals.setdefault(\"client_side_server\", definition)\n return definition\n\n\n# Server-style parity alias.\nserve_server = serve_client_side_server\n\n\ndef prepare_python_source(source: str) -> BrowserPackagePlan:\n requested_packages: list[str] = []\n rewrites: list[str] = []\n python_lines: list[str] = []\n for raw_line in source.splitlines():\n stripped = raw_line.strip()\n if stripped.startswith(\"!pip install \"):\n package_args = stripped[len(\"!pip install \") :].strip().split()\n requested_packages.extend(arg for arg in package_args if arg and not arg.startswith(\"-\"))\n continue\n fixed_line, note = _rewrite_server_side_import_for_browser(raw_line)\n if note is not None:\n rewrites.append(note)\n python_lines.append(fixed_line)\n python_source = \"\\n\".join(python_lines)\n imported_modules = [\n module\n for module in detect_imports(python_source)\n if module not in PYTHON_BROWSER_STDLIB_MODULES and module != \"plat\" and module != \"plat_browser\"\n ]\n return BrowserPackagePlan(\n python_source=python_source,\n requested_packages=dedupe_keep_order(requested_packages),\n imported_modules=dedupe_keep_order(imported_modules),\n import_rewrites=rewrites,\n )\n\n\ndef _rewrite_server_side_import_for_browser(raw_line: str) -> tuple[str, str | None]:\n \"\"\"\n Best-effort autofix for browser runtime code that accidentally imports from `plat`.\n Rewrites imports to `plat_browser` while preserving alias style where possible.\n \"\"\"\n from_import = re.match(r\"^(\\s*)from\\s+plat\\s+import\\s+(.+?)\\s*$\", raw_line)\n if from_import:\n indent = from_import.group(1)\n names = from_import.group(2)\n fixed = f\"{indent}from plat_browser import {names}\"\n return fixed, \"rewrote: from plat import ... -> from plat_browser import ...\"\n\n import_alias = re.match(r\"^(\\s*)import\\s+plat\\s+as\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*$\", raw_line)\n if import_alias:\n indent = import_alias.group(1)\n alias = import_alias.group(2)\n fixed = f\"{indent}import plat_browser as {alias}\"\n return fixed, \"rewrote: import plat as ... -> import plat_browser as ...\"\n\n import_plain = re.match(r\"^(\\s*)import\\s+plat\\s*$\", raw_line)\n if import_plain:\n indent = import_plain.group(1)\n fixed = f\"{indent}import plat_browser as plat\"\n return fixed, \"rewrote: import plat -> import plat_browser as plat\"\n\n return raw_line, None\n\n\ndef detect_imports(source: str) -> list[str]:\n tree = ast.parse(source)\n modules: list[str] = []\n for node in ast.walk(tree):\n if isinstance(node, ast.Import):\n for alias in node.names:\n modules.append(alias.name.split(\".\")[0])\n elif isinstance(node, ast.ImportFrom):\n if node.module:\n modules.append(node.module.split(\".\")[0])\n return dedupe_keep_order(modules)\n\n\ndef annotation_to_schema(annotation: Any) -> dict[str, Any]:\n origin = get_origin(annotation)\n args = get_args(annotation)\n if annotation is str:\n return {\"type\": \"string\"}\n if annotation is int:\n return {\"type\": \"integer\"}\n if annotation is float:\n return {\"type\": \"number\"}\n if annotation is bool:\n return {\"type\": \"boolean\"}\n if annotation in {dict, Mapping}:\n return {\"type\": \"object\", \"additionalProperties\": True}\n if annotation in {list, tuple}:\n return {\"type\": \"array\", \"items\": {}}\n if origin is Literal:\n values = list(args)\n schema: dict[str, Any] = {\"enum\": values}\n if values:\n schema.update(annotation_to_schema(type(values[0])))\n return schema\n if origin in {list, tuple}:\n item_annotation = args[0] if args else Any\n return {\"type\": \"array\", \"items\": annotation_to_schema(item_annotation)}\n if origin in {dict, Mapping}:\n value_annotation = args[1] if len(args) > 1 else Any\n return {\"type\": \"object\", \"additionalProperties\": annotation_to_schema(value_annotation) if value_annotation is not Any else True}\n if origin is not None and str(origin) == \"typing.Union\":\n variants = [annotation_to_schema(arg) for arg in args if arg is not type(None)]\n if len(variants) == 1:\n schema = variants[0]\n if len(args) != len(variants):\n schema = {**schema, \"nullable\": True}\n return schema\n return {\"anyOf\": variants}\n if hasattr(annotation, \"__annotations__\") and isinstance(getattr(annotation, \"__annotations__\"), dict):\n return typed_mapping_schema(annotation.__annotations__)\n return {\"type\": \"object\", \"additionalProperties\": True}\n\n\ndef build_input_model_from_params(params: list[inspect.Parameter]) -> Any:\n if not params:\n return None\n if len(params) == 1 and params[0].name in LEGACY_PAYLOAD_PARAM_NAMES:\n return params[0].annotation\n\n properties: dict[str, Any] = {}\n required: list[str] = []\n for param in params:\n if param.kind is inspect.Parameter.VAR_POSITIONAL:\n continue\n if param.kind is inspect.Parameter.VAR_KEYWORD:\n return {\n \"type\": \"object\",\n \"properties\": properties,\n \"required\": required,\n \"additionalProperties\": True,\n }\n annotation = param.annotation\n properties[param.name] = annotation_to_schema(annotation) if annotation is not inspect.Signature.empty else {\"type\": \"object\", \"additionalProperties\": True}\n if param.default is inspect.Signature.empty:\n required.append(param.name)\n\n return {\n \"type\": \"object\",\n \"properties\": properties,\n \"required\": required,\n }\n\n\ndef typed_mapping_schema(annotations: Mapping[str, Any]) -> dict[str, Any]:\n properties: dict[str, Any] = {}\n required: list[str] = []\n for key, value in annotations.items():\n properties[key] = annotation_to_schema(value)\n required.append(key)\n return {\"type\": \"object\", \"properties\": properties, \"required\": required}\n\n\ndef dedupe_keep_order(values: list[str]) -> list[str]:\n seen: set[str] = set()\n deduped: list[str] = []\n for value in values:\n if value in seen:\n continue\n seen.add(value)\n deduped.append(value)\n return deduped\n\n\ndef trim_none(value: Any) -> Any:\n if isinstance(value, dict):\n return {key: trim_none(inner) for key, inner in value.items() if inner is not None}\n if isinstance(value, list):\n return [trim_none(item) for item in value]\n return value\n\n\ndef coerce_str(value: Any) -> str | None:\n return value if isinstance(value, str) else None\n\n\ndef cast_undecorated_mode(value: Any, fallback: UndecoratedMode) -> UndecoratedMode:\n if value in {\"GET\", \"POST\", \"private\"}:\n return value\n return fallback\n\n\ndef normalize_server_message(message: Any) -> dict[str, Any]:\n if isinstance(message, dict):\n return message\n if isinstance(message, Mapping):\n return dict(message)\n\n to_py = getattr(message, \"to_py\", None)\n if callable(to_py):\n try:\n converted = to_py()\n except TypeError:\n converted = to_py(depth=-1)\n return normalize_server_message(converted)\n\n keys = (\"operationId\", \"method\", \"path\", \"input\", \"body\", \"params\", \"headers\")\n normalized: dict[str, Any] = {}\n for key in keys:\n value = None\n if hasattr(message, key):\n value = getattr(message, key)\n else:\n try:\n value = message[key]\n except Exception:\n value = None\n if value is not None:\n normalized[key] = value\n return normalized\n",
|
|
9
|
+
"plat_browser/server.py": "from __future__ import annotations\n\nimport ast\nimport inspect\nimport logging\nimport re\nfrom dataclasses import dataclass, field\nimport time\nimport sys\nfrom typing import Any, Awaitable, Callable, Literal, Mapping, get_args, get_origin, get_type_hints\n\nfrom .decorators import ROUTE_METADATA_KEY\nfrom .errors import HttpError\nfrom .metadata import get_controller_meta\nfrom .plugins import (\n BucketConfig,\n CacheEntry,\n CacheMeta,\n CacheContext,\n RateLimitContext,\n RateLimitEntry,\n RateLimitMeta,\n TokenLimitContext,\n TokenLimitEntry,\n TokenLimitMeta,\n TokenLimitTiming,\n apply_cache_check,\n apply_cache_store,\n apply_rate_limit_check,\n apply_rate_limit_refund,\n apply_token_limit_check,\n apply_token_limit_failure,\n apply_token_limit_response,\n create_in_memory_cache,\n create_in_memory_rate_limit,\n create_in_memory_token_limit,\n)\nfrom .types import ControllerMeta, RouteContext, RouteMeta\n\n\nUndecoratedMode = Literal[\"GET\", \"POST\", \"private\"]\nServerMessage = Mapping[str, Any]\nRESERVED_METHOD_NAMES = {\"tools\", \"routes\", \"endpoints\", \"help\", \"openapi\"}\nLEGACY_PAYLOAD_PARAM_NAMES = {\"input\", \"payload\", \"body\", \"data\"}\nPYTHON_BROWSER_STDLIB_MODULES = {\n \"__future__\",\n \"abc\",\n \"argparse\",\n \"asyncio\",\n \"base64\",\n \"collections\",\n \"copy\",\n \"csv\",\n \"dataclasses\",\n \"datetime\",\n \"decimal\",\n \"enum\",\n \"functools\",\n \"hashlib\",\n \"itertools\",\n \"json\",\n \"logging\",\n \"math\",\n \"pathlib\",\n \"random\",\n \"re\",\n \"statistics\",\n \"string\",\n \"sys\",\n \"textwrap\",\n \"time\",\n \"typing\",\n \"uuid\",\n}\n\n\n@dataclass(slots=True)\nclass BrowserPackagePlan:\n python_source: str\n requested_packages: list[str] = field(default_factory=list)\n imported_modules: list[str] = field(default_factory=list)\n import_rewrites: list[str] = field(default_factory=list)\n\n\n@dataclass(slots=True)\nclass BrowserServerDefinition:\n server_name: str\n controllers: list[type[Any]]\n requested_packages: list[str] = field(default_factory=list)\n imported_modules: list[str] = field(default_factory=list)\n options: dict[str, Any] = field(default_factory=dict)\n\n\n@dataclass(slots=True)\nclass BrowserResolvedOperation:\n method_name: str\n method: str\n path: str\n bound_method: Any\n route_meta: RouteMeta\n controller_meta: ControllerMeta\n input_annotation: Any\n output_annotation: Any\n params: list[inspect.Parameter]\n\n\nBrowserMiddlewareContext = dict[str, Any]\nBrowserMiddleware = Callable[[BrowserMiddlewareContext, Callable[[], Awaitable[Any]]], Awaitable[Any] | Any]\n\n\nclass CallBridge:\n def __init__(self, emit=None):\n self._emit = emit\n\n def emit(self, event: str, data: Any) -> None:\n if callable(self._emit):\n self._emit({\"event\": event, \"data\": data})\n\n def progress(self, data: Any) -> None:\n self.emit(\"progress\", data)\n\n def log(self, data: Any) -> None:\n self.emit(\"log\", data)\n\n def chunk(self, data: Any) -> None:\n self.emit(\"chunk\", data)\n\n def message(self, data: Any) -> None:\n self.emit(\"message\", data)\n\n\nclass BrowserPLATServer:\n def __init__(\n self,\n options: Mapping[str, Any] | None = None,\n *controller_classes: type[Any],\n server_name: str | None = None,\n undecorated_mode: UndecoratedMode = \"POST\",\n ) -> None:\n self.options = dict(options or {})\n self.server_name = server_name or self.options.get(\"server_name\") or \"browser-python-server\"\n self.undecorated_mode = cast_undecorated_mode(self.options.get(\"undecorated_mode\"), undecorated_mode)\n self.logger = self.options.get(\"logger\") or logging.getLogger(\"plat_browser.server\")\n self.middleware: list[BrowserMiddleware] = list(self.options.get(\"middleware\") or [])\n self.routes: list[dict[str, str]] = []\n self.tools: dict[str, dict[str, Any]] = {}\n self.operations_by_id: dict[str, BrowserResolvedOperation] = {}\n self.operations_by_route: dict[str, BrowserResolvedOperation] = {}\n self.registered_method_names: set[str] = set()\n self.registered_controller_names: set[str] = set()\n self._setup_policy_defaults()\n if controller_classes:\n self.register(*controller_classes)\n\n def use(self, middleware: BrowserMiddleware) -> \"BrowserPLATServer\":\n self.middleware.append(middleware)\n return self\n\n @property\n def openapi(self) -> dict[str, Any]:\n return self.generate_openapi()\n\n def _setup_policy_defaults(self) -> None:\n if self.options.get(\"rate_limit\") is not None and not self.options[\"rate_limit\"].get(\"controller\"):\n self.options[\"rate_limit\"][\"controller\"] = create_in_memory_rate_limit()\n if self.options.get(\"token_limit\") is not None and not self.options[\"token_limit\"].get(\"controller\"):\n self.options[\"token_limit\"][\"controller\"] = create_in_memory_token_limit()\n if self.options.get(\"cache\") is not None and not self.options[\"cache\"].get(\"controller\"):\n self.options[\"cache\"][\"controller\"] = create_in_memory_cache()\n\n def register(self, *controller_classes: type[Any]) -> \"BrowserPLATServer\":\n for controller_class in controller_classes:\n meta = get_controller_meta(controller_class) or ControllerMeta(\n base_path=controller_class.__name__,\n tag=controller_class.__name__,\n )\n instance = controller_class()\n controller_tag = meta.tag or meta.base_path or controller_class.__name__\n lower_controller_tag = controller_tag.lower()\n if lower_controller_tag in RESERVED_METHOD_NAMES:\n raise ValueError(f\"Controller '{controller_tag}' uses a reserved plat system name.\")\n if controller_tag in self.registered_method_names:\n raise ValueError(f\"Controller '{controller_tag}' conflicts with an existing method name.\")\n self.registered_controller_names.add(controller_tag)\n\n methods = inspect.getmembers(controller_class, predicate=inspect.isfunction)\n for method_name, method_fn in methods:\n route_meta = getattr(method_fn, ROUTE_METADATA_KEY, None)\n if route_meta is None:\n route_meta = self._build_implicit_route_meta(method_name)\n if route_meta is None:\n continue\n self._hydrate_route_meta(route_meta, method_fn)\n self._validate_method_name(method_name, controller_class.__name__)\n if method_name in self.registered_controller_names:\n raise ValueError(f\"Method '{method_name}' in {controller_class.__name__} conflicts with controller name '{method_name}'.\")\n if method_name in self.registered_method_names:\n raise ValueError(f'Duplicate operationId: method \"{method_name}\" is defined in multiple controllers.')\n self.registered_method_names.add(method_name)\n\n bound_method = getattr(instance, method_name)\n full_path = route_meta.path or f\"/{method_name}\"\n operation = BrowserResolvedOperation(\n method_name=method_name,\n method=(route_meta.method or \"POST\").upper(),\n path=full_path,\n bound_method=bound_method,\n route_meta=route_meta,\n controller_meta=meta,\n input_annotation=route_meta.input_model,\n output_annotation=route_meta.output_model,\n params=list(inspect.signature(method_fn).parameters.values())[1:],\n )\n self.operations_by_id[method_name] = operation\n self.operations_by_route[f\"{operation.method} {operation.path}\"] = operation\n self.routes.append({\"method\": operation.method, \"path\": operation.path, \"methodName\": method_name})\n self.tools[method_name] = {\n \"name\": method_name,\n \"summary\": route_meta.summary,\n \"description\": route_meta.description or f\"{operation.method} {full_path}\",\n \"method\": operation.method,\n \"path\": full_path,\n \"controller\": controller_tag,\n \"tags\": [controller_tag],\n \"input_schema\": self._build_schema(route_meta.input_model),\n \"response_schema\": self._build_schema(route_meta.output_model),\n }\n return self\n\n def generate_openapi(self) -> dict[str, Any]:\n paths: dict[str, dict[str, Any]] = {}\n for operation in self.operations_by_id.values():\n path_item = paths.setdefault(operation.path, {})\n entry: dict[str, Any] = {\n \"operationId\": operation.method_name,\n \"summary\": operation.route_meta.summary,\n \"description\": operation.route_meta.description,\n \"responses\": {\"200\": {\"description\": \"ok\"}},\n }\n input_schema = self._build_schema(operation.input_annotation)\n if input_schema is not None:\n entry[\"requestBody\"] = {\n \"required\": True,\n \"content\": {\"application/json\": {\"schema\": input_schema}},\n }\n response_schema = self._build_schema(operation.output_annotation)\n if response_schema is not None:\n entry[\"responses\"][\"200\"][\"content\"] = {\"application/json\": {\"schema\": response_schema}}\n path_item[operation.method.lower()] = trim_none(entry)\n return {\n \"openapi\": \"3.1.0\",\n \"info\": {\"title\": \"plat browser python server\", \"version\": \"0.17.1\"},\n \"paths\": paths,\n }\n\n async def handle_request(self, message: ServerMessage, emit=None) -> Any:\n message = normalize_server_message(message)\n operation = self._resolve_operation(\n operation_id=coerce_str(message.get(\"operationId\")),\n method=coerce_str(message.get(\"method\")) or \"POST\",\n path=coerce_str(message.get(\"path\")) or \"\",\n )\n if operation is None:\n raise KeyError(\"No browser operation matched the incoming request.\")\n\n payload = (\n message.get(\"input\")\n if \"input\" in message\n else message.get(\"body\")\n if \"body\" in message\n else message.get(\"params\")\n if \"params\" in message\n else {}\n )\n ctx = RouteContext(\n method=operation.method,\n url=operation.path,\n headers=dict(message.get(\"headers\") or {}),\n call=CallBridge(emit),\n opts={\"transport\": \"browser-python\"},\n request=dict(message),\n )\n status_code = 200\n rate_limit_entries = []\n token_limit_entries = []\n token_limit_start_ms = int(time.time() * 1000)\n handler_called = False\n on_request = self.options.get(\"on_request\") or self.options.get(\"onRequest\")\n on_response = self.options.get(\"on_response\") or self.options.get(\"onResponse\")\n on_error = self.options.get(\"on_error\") or self.options.get(\"onError\")\n if callable(on_request):\n maybe = on_request(message, ctx)\n if inspect.isawaitable(maybe):\n await maybe\n auth_mode = operation.route_meta.auth or operation.controller_meta.auth or self.options.get(\"default_auth\")\n auth_handler = self.options.get(\"auth\")\n if auth_mode and auth_handler and callable(auth_handler.get(\"verify\")):\n auth_result = auth_handler[\"verify\"](auth_mode, message, ctx)\n if inspect.isawaitable(auth_result):\n auth_result = await auth_result\n ctx.auth = auth_result\n rate_limit_meta = self._get_rate_limit_meta(operation.route_meta, operation.controller_meta)\n if rate_limit_meta is not None and self.options.get(\"rate_limit\", {}).get(\"controller\"):\n entries, balances = apply_rate_limit_check(\n rate_limit_meta,\n self.options[\"rate_limit\"][\"controller\"],\n self.options[\"rate_limit\"].get(\"configs\", {}),\n operation.method_name,\n operation.controller_meta.base_path,\n ctx.auth,\n )\n rate_limit_entries = entries\n ctx.rate_limit = RateLimitContext(entries=entries, remaining_balances=balances)\n token_limit_meta = self._get_token_limit_meta(operation.route_meta, operation.controller_meta)\n if token_limit_meta is not None and self.options.get(\"token_limit\", {}).get(\"controller\"):\n entries, balances = apply_token_limit_check(\n token_limit_meta,\n self.options[\"token_limit\"][\"controller\"],\n self.options[\"token_limit\"].get(\"configs\", {}),\n operation.method_name,\n operation.controller_meta.base_path,\n payload,\n ctx,\n ctx.auth,\n )\n token_limit_entries = entries\n ctx.token_limit = TokenLimitContext(entries=entries, remaining_balances=balances)\n cache_key = None\n cache_entry = None\n cache_meta = self._get_cache_meta(operation.route_meta, operation.controller_meta)\n if cache_meta is not None and self.options.get(\"cache\", {}).get(\"controller\"):\n cache_key, hit, cached_value, cache_entry = apply_cache_check(\n cache_meta,\n self.options[\"cache\"][\"controller\"],\n dict(payload) if isinstance(payload, Mapping) else {},\n operation.method,\n operation.method_name,\n operation.controller_meta.base_path,\n ctx.auth,\n )\n ctx.cache = CacheContext(key=cache_key, hit=hit, stored=False)\n if hit:\n return cached_value\n middleware_context: BrowserMiddlewareContext = {\n \"request\": dict(message),\n \"operation\": operation,\n \"ctx\": ctx,\n \"input\": payload,\n \"logger\": self.logger,\n }\n try:\n handler_called = True\n result = await self._run_middleware_chain(\n middleware_context,\n lambda: self._invoke_handler(operation.bound_method, operation.params, payload, ctx),\n )\n if token_limit_entries and self.options.get(\"token_limit\", {}).get(\"controller\"):\n timing = TokenLimitTiming(\n start_ms=token_limit_start_ms,\n end_ms=int(time.time() * 1000),\n duration_ms=max(0, int(time.time() * 1000) - token_limit_start_ms),\n )\n response_costs = apply_token_limit_response(\n token_limit_entries,\n self.options[\"token_limit\"][\"controller\"],\n result,\n timing,\n payload,\n status_code,\n )\n if ctx.token_limit:\n ctx.token_limit.timing = timing\n ctx.token_limit.response_costs = response_costs\n if rate_limit_entries and self.options.get(\"rate_limit\", {}).get(\"controller\"):\n apply_rate_limit_refund(rate_limit_entries, self.options[\"rate_limit\"][\"controller\"], status_code)\n if cache_entry is not None and self.options.get(\"cache\", {}).get(\"controller\"):\n apply_cache_store(cache_key, cache_entry, self.options[\"cache\"][\"controller\"], result)\n if ctx.cache:\n ctx.cache.stored = True\n if callable(on_response):\n maybe = on_response(message, ctx, result)\n if inspect.isawaitable(maybe):\n await maybe\n return result\n except Exception as error:\n status_code = getattr(error, \"status_code\", 500)\n if token_limit_entries and self.options.get(\"token_limit\", {}).get(\"controller\") and handler_called:\n timing = TokenLimitTiming(\n start_ms=token_limit_start_ms,\n end_ms=int(time.time() * 1000),\n duration_ms=max(0, int(time.time() * 1000) - token_limit_start_ms),\n )\n failure_costs = apply_token_limit_failure(\n token_limit_entries,\n self.options[\"token_limit\"][\"controller\"],\n error,\n timing,\n payload,\n status_code,\n )\n if ctx.token_limit:\n ctx.token_limit.timing = timing\n ctx.token_limit.failure_costs = failure_costs\n if rate_limit_entries and self.options.get(\"rate_limit\", {}).get(\"controller\"):\n apply_rate_limit_refund(rate_limit_entries, self.options[\"rate_limit\"][\"controller\"], status_code)\n self.logger.error(\"BrowserPLATServer request failed\", exc_info=error)\n if callable(on_error):\n maybe = on_error(message, ctx, error)\n if inspect.isawaitable(maybe):\n await maybe\n raise\n\n def _resolve_operation(self, *, operation_id: str | None, method: str, path: str) -> BrowserResolvedOperation | None:\n if operation_id and operation_id in self.operations_by_id:\n return self.operations_by_id[operation_id]\n return self.operations_by_route.get(f\"{method.upper()} {path}\")\n\n async def _run_middleware_chain(\n self,\n context: BrowserMiddlewareContext,\n final_handler: Callable[[], Any],\n ) -> Any:\n index = -1\n\n async def dispatch(next_index: int) -> Any:\n nonlocal index\n if next_index <= index:\n raise RuntimeError(\"Browser middleware called next() multiple times\")\n index = next_index\n if next_index >= len(self.middleware):\n result = final_handler()\n if inspect.isawaitable(result):\n return await result\n return result\n middleware = self.middleware[next_index]\n result = middleware(context, lambda: dispatch(next_index + 1))\n if inspect.isawaitable(result):\n return await result\n return result\n\n return await dispatch(0)\n\n def _invoke_handler(self, bound_method: Any, params: list[inspect.Parameter], parsed_input: Any, ctx: RouteContext) -> Any:\n if not params:\n return bound_method()\n non_context_params = [param for param in params if not self._is_context_parameter(param)]\n use_legacy_payload_param = (\n len(non_context_params) == 1\n and non_context_params[0].name in LEGACY_PAYLOAD_PARAM_NAMES\n )\n is_mapping_payload = isinstance(parsed_input, Mapping)\n\n if len(params) == 1:\n only_param = params[0]\n if self._is_context_parameter(only_param):\n return bound_method(ctx)\n if use_legacy_payload_param or not is_mapping_payload:\n return bound_method(parsed_input)\n if only_param.kind is inspect.Parameter.VAR_KEYWORD:\n return bound_method(**dict(parsed_input))\n return bound_method(**{only_param.name: parsed_input.get(only_param.name, parsed_input)})\n\n args: list[Any] = []\n kwargs: dict[str, Any] = {}\n payload_mapping = dict(parsed_input) if is_mapping_payload else {}\n\n for param in params:\n if self._is_context_parameter(param):\n if param.kind is inspect.Parameter.POSITIONAL_ONLY:\n args.append(ctx)\n else:\n kwargs[param.name] = ctx\n continue\n\n if use_legacy_payload_param:\n if param.kind is inspect.Parameter.POSITIONAL_ONLY:\n args.append(parsed_input)\n else:\n kwargs[param.name] = parsed_input\n continue\n\n if param.kind is inspect.Parameter.VAR_KEYWORD:\n kwargs.update(payload_mapping)\n continue\n\n if not is_mapping_payload:\n if param.kind is inspect.Parameter.POSITIONAL_ONLY:\n args.append(parsed_input)\n else:\n kwargs[param.name] = parsed_input\n continue\n\n if param.name not in payload_mapping:\n continue\n\n if param.kind is inspect.Parameter.POSITIONAL_ONLY:\n args.append(payload_mapping[param.name])\n else:\n kwargs[param.name] = payload_mapping[param.name]\n\n return bound_method(*args, **kwargs)\n\n def _is_context_parameter(self, param: inspect.Parameter) -> bool:\n return param.annotation is RouteContext or param.name in {\"ctx\", \"context\"}\n\n def _build_implicit_route_meta(self, method_name: str) -> RouteMeta | None:\n if self.undecorated_mode == \"private\" or method_name.startswith(\"_\"):\n return None\n return RouteMeta(name=method_name, method=self.undecorated_mode, path=f\"/{method_name}\")\n\n def _hydrate_route_meta(self, route_meta: RouteMeta, method_fn: Any) -> None:\n signature = inspect.signature(method_fn)\n params = list(signature.parameters.values())[1:]\n module = sys.modules.get(getattr(method_fn, \"__module__\", \"\"))\n try:\n resolved_hints = get_type_hints(\n method_fn,\n globalns=vars(module) if module is not None else None,\n )\n except Exception:\n resolved_hints = {}\n input_params = [\n param.replace(annotation=resolved_hints.get(param.name, param.annotation))\n for param in params\n if not self._is_context_parameter(param)\n ]\n route_meta.input_model = build_input_model_from_params(input_params)\n route_meta.output_model = resolved_hints.get(\"return\", signature.return_annotation)\n if route_meta.path is None:\n route_meta.path = f\"/{route_meta.name}\"\n if route_meta.method is None:\n route_meta.method = self.undecorated_mode if self.undecorated_mode != \"private\" else \"POST\"\n docstring = inspect.getdoc(method_fn)\n if docstring and not route_meta.summary:\n first_line, *_ = docstring.splitlines()\n route_meta.summary = first_line.strip() or None\n if len(docstring.splitlines()) > 1 and not route_meta.description:\n route_meta.description = docstring.strip()\n\n def _validate_method_name(self, method_name: str, controller_name: str) -> None:\n if not method_name:\n return\n if method_name[0].isalpha() and method_name[0].isupper():\n raise ValueError(f\"Method '{method_name}' in {controller_name} violates plat naming convention: method names must start with a lowercase letter.\")\n if \"_\" in method_name:\n raise ValueError(f\"Method '{method_name}' in {controller_name} violates plat naming convention: underscores are not allowed.\")\n if method_name.lower() in RESERVED_METHOD_NAMES:\n raise ValueError(f\"Method '{method_name}' in {controller_name} uses a reserved plat system name.\")\n\n def _build_schema(self, annotation: Any) -> dict[str, Any] | None:\n if isinstance(annotation, dict) and \"type\" in annotation:\n return annotation\n if annotation in {None, inspect.Signature.empty, Any}:\n return None\n return annotation_to_schema(annotation)\n\n def _get_rate_limit_meta(self, route_meta: RouteMeta, controller_meta: ControllerMeta):\n route_value = (route_meta.opts or {}).get(\"rateLimit\", route_meta.rate_limit)\n controller_value = getattr(controller_meta, \"rate_limit\", None)\n return self._coerce_rate_limit_meta(route_value or controller_value)\n\n def _get_token_limit_meta(self, route_meta: RouteMeta, controller_meta: ControllerMeta):\n route_value = (route_meta.opts or {}).get(\"tokenLimit\", route_meta.token_limit)\n controller_value = getattr(controller_meta, \"token_limit\", None)\n return self._coerce_token_limit_meta(route_value or controller_value)\n\n def _get_cache_meta(self, route_meta: RouteMeta, controller_meta: ControllerMeta):\n route_value = (route_meta.opts or {}).get(\"cache\", route_meta.cache)\n controller_value = getattr(controller_meta, \"cache\", None)\n return self._coerce_cache_meta(route_value or controller_value)\n\n def _coerce_rate_limit_meta(self, value: Any):\n if value is None:\n return None\n if isinstance(value, list):\n return [self._coerce_rate_limit_entry(item) for item in value]\n return self._coerce_rate_limit_entry(value)\n\n def _coerce_token_limit_meta(self, value: Any):\n if value is None:\n return None\n if isinstance(value, list):\n return [self._coerce_token_limit_entry(item) for item in value]\n return self._coerce_token_limit_entry(value)\n\n def _coerce_cache_meta(self, value: Any):\n if value is None:\n return None\n if isinstance(value, list):\n return [self._coerce_cache_entry(item) for item in value]\n return self._coerce_cache_entry(value)\n\n def _coerce_rate_limit_entry(self, value: Any) -> RateLimitEntry:\n if isinstance(value, RateLimitEntry):\n return value\n data = dict(value)\n if isinstance(data.get(\"config\"), dict):\n data[\"config\"] = self._coerce_bucket_config(data[\"config\"])\n return RateLimitEntry(\n key=data.get(\"key\"),\n cost=data.get(\"cost\"),\n config=data.get(\"config\"),\n )\n\n def _coerce_token_limit_entry(self, value: Any) -> TokenLimitEntry:\n if isinstance(value, TokenLimitEntry):\n return value\n data = dict(value)\n if isinstance(data.get(\"config\"), dict):\n data[\"config\"] = self._coerce_bucket_config(data[\"config\"])\n return TokenLimitEntry(\n key=data.get(\"key\"),\n call_cost=data.get(\"callCost\", data.get(\"call_cost\")),\n response_cost=data.get(\"responseCost\", data.get(\"response_cost\")),\n failure_cost=data.get(\"failureCost\", data.get(\"failure_cost\")),\n config=data.get(\"config\"),\n )\n\n def _coerce_cache_entry(self, value: Any) -> CacheEntry:\n if isinstance(value, CacheEntry):\n return value\n data = dict(value)\n return CacheEntry(\n key=data[\"key\"],\n ttl=data.get(\"ttl\"),\n methods=data.get(\"methods\"),\n )\n\n def _coerce_bucket_config(self, value: Any) -> BucketConfig:\n if isinstance(value, BucketConfig):\n return value\n data = dict(value)\n return BucketConfig(\n max_balance=data[\"maxBalance\"] if \"maxBalance\" in data else data[\"max_balance\"],\n fill_interval=data[\"fillInterval\"] if \"fillInterval\" in data else data[\"fill_interval\"],\n fill_amount=data[\"fillAmount\"] if \"fillAmount\" in data else data[\"fill_amount\"],\n refunded_status_codes=data.get(\"refundedStatusCodes\", data.get(\"refunded_status_codes\")),\n refund_successful=data.get(\"refundSuccessful\", data.get(\"refund_successful\")),\n min_balance=data.get(\"minBalance\", data.get(\"min_balance\", 0)),\n )\n\n\ndef create_browser_server(\n options: Mapping[str, Any] | None = None,\n *controller_classes: type[Any],\n server_name: str | None = None,\n undecorated_mode: UndecoratedMode = \"POST\",\n) -> BrowserPLATServer:\n return BrowserPLATServer(options, *controller_classes, server_name=server_name, undecorated_mode=undecorated_mode)\n\n\n# Server-style parity alias.\ncreate_server = create_browser_server\n\n\ndef serve_client_side_server(\n server_name: str,\n controllers: list[type[Any]] | tuple[type[Any], ...],\n **options: Any,\n) -> BrowserServerDefinition:\n definition = BrowserServerDefinition(\n server_name=server_name,\n controllers=list(controllers),\n requested_packages=list(options.pop(\"requested_packages\", []) or []),\n imported_modules=list(options.pop(\"imported_modules\", []) or []),\n options=dict(options),\n )\n frame = inspect.currentframe()\n caller_globals = frame.f_back.f_globals if frame and frame.f_back else None\n if isinstance(caller_globals, dict):\n caller_globals[\"__plat_browser_server_definition__\"] = definition\n caller_globals.setdefault(\"client_side_server\", definition)\n return definition\n\n\n# Server-style parity alias.\nserve_server = serve_client_side_server\n\n\ndef prepare_python_source(source: str) -> BrowserPackagePlan:\n requested_packages: list[str] = []\n rewrites: list[str] = []\n python_lines: list[str] = []\n for raw_line in source.splitlines():\n stripped = raw_line.strip()\n if stripped.startswith(\"!pip install \"):\n package_args = stripped[len(\"!pip install \") :].strip().split()\n requested_packages.extend(arg for arg in package_args if arg and not arg.startswith(\"-\"))\n continue\n fixed_line, note = _rewrite_server_side_import_for_browser(raw_line)\n if note is not None:\n rewrites.append(note)\n python_lines.append(fixed_line)\n python_source = \"\\n\".join(python_lines)\n imported_modules = [\n module\n for module in detect_imports(python_source)\n if module not in PYTHON_BROWSER_STDLIB_MODULES and module != \"plat\" and module != \"plat_browser\"\n ]\n return BrowserPackagePlan(\n python_source=python_source,\n requested_packages=dedupe_keep_order(requested_packages),\n imported_modules=dedupe_keep_order(imported_modules),\n import_rewrites=rewrites,\n )\n\n\ndef _rewrite_server_side_import_for_browser(raw_line: str) -> tuple[str, str | None]:\n \"\"\"\n Best-effort autofix for browser runtime code that accidentally imports from `plat`.\n Rewrites imports to `plat_browser` while preserving alias style where possible.\n \"\"\"\n from_import = re.match(r\"^(\\s*)from\\s+plat\\s+import\\s+(.+?)\\s*$\", raw_line)\n if from_import:\n indent = from_import.group(1)\n names = from_import.group(2)\n fixed = f\"{indent}from plat_browser import {names}\"\n return fixed, \"rewrote: from plat import ... -> from plat_browser import ...\"\n\n import_alias = re.match(r\"^(\\s*)import\\s+plat\\s+as\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*$\", raw_line)\n if import_alias:\n indent = import_alias.group(1)\n alias = import_alias.group(2)\n fixed = f\"{indent}import plat_browser as {alias}\"\n return fixed, \"rewrote: import plat as ... -> import plat_browser as ...\"\n\n import_plain = re.match(r\"^(\\s*)import\\s+plat\\s*$\", raw_line)\n if import_plain:\n indent = import_plain.group(1)\n fixed = f\"{indent}import plat_browser as plat\"\n return fixed, \"rewrote: import plat -> import plat_browser as plat\"\n\n return raw_line, None\n\n\ndef detect_imports(source: str) -> list[str]:\n tree = ast.parse(source)\n modules: list[str] = []\n for node in ast.walk(tree):\n if isinstance(node, ast.Import):\n for alias in node.names:\n modules.append(alias.name.split(\".\")[0])\n elif isinstance(node, ast.ImportFrom):\n if node.module:\n modules.append(node.module.split(\".\")[0])\n return dedupe_keep_order(modules)\n\n\ndef annotation_to_schema(annotation: Any) -> dict[str, Any]:\n origin = get_origin(annotation)\n args = get_args(annotation)\n if annotation is str:\n return {\"type\": \"string\"}\n if annotation is int:\n return {\"type\": \"integer\"}\n if annotation is float:\n return {\"type\": \"number\"}\n if annotation is bool:\n return {\"type\": \"boolean\"}\n if annotation in {dict, Mapping}:\n return {\"type\": \"object\", \"additionalProperties\": True}\n if annotation in {list, tuple}:\n return {\"type\": \"array\", \"items\": {}}\n if origin is Literal:\n values = list(args)\n schema: dict[str, Any] = {\"enum\": values}\n if values:\n schema.update(annotation_to_schema(type(values[0])))\n return schema\n if origin in {list, tuple}:\n item_annotation = args[0] if args else Any\n return {\"type\": \"array\", \"items\": annotation_to_schema(item_annotation)}\n if origin in {dict, Mapping}:\n value_annotation = args[1] if len(args) > 1 else Any\n return {\"type\": \"object\", \"additionalProperties\": annotation_to_schema(value_annotation) if value_annotation is not Any else True}\n if origin is not None and str(origin) == \"typing.Union\":\n variants = [annotation_to_schema(arg) for arg in args if arg is not type(None)]\n if len(variants) == 1:\n schema = variants[0]\n if len(args) != len(variants):\n schema = {**schema, \"nullable\": True}\n return schema\n return {\"anyOf\": variants}\n if hasattr(annotation, \"__annotations__\") and isinstance(getattr(annotation, \"__annotations__\"), dict):\n return typed_mapping_schema(annotation.__annotations__)\n return {\"type\": \"object\", \"additionalProperties\": True}\n\n\ndef build_input_model_from_params(params: list[inspect.Parameter]) -> Any:\n if not params:\n return None\n if len(params) == 1 and params[0].name in LEGACY_PAYLOAD_PARAM_NAMES:\n return params[0].annotation\n\n properties: dict[str, Any] = {}\n required: list[str] = []\n for param in params:\n if param.kind is inspect.Parameter.VAR_POSITIONAL:\n continue\n if param.kind is inspect.Parameter.VAR_KEYWORD:\n return {\n \"type\": \"object\",\n \"properties\": properties,\n \"required\": required,\n \"additionalProperties\": True,\n }\n annotation = param.annotation\n properties[param.name] = annotation_to_schema(annotation) if annotation is not inspect.Signature.empty else {\"type\": \"object\", \"additionalProperties\": True}\n if param.default is inspect.Signature.empty:\n required.append(param.name)\n\n return {\n \"type\": \"object\",\n \"properties\": properties,\n \"required\": required,\n }\n\n\ndef typed_mapping_schema(annotations: Mapping[str, Any]) -> dict[str, Any]:\n properties: dict[str, Any] = {}\n required: list[str] = []\n for key, value in annotations.items():\n properties[key] = annotation_to_schema(value)\n required.append(key)\n return {\"type\": \"object\", \"properties\": properties, \"required\": required}\n\n\ndef dedupe_keep_order(values: list[str]) -> list[str]:\n seen: set[str] = set()\n deduped: list[str] = []\n for value in values:\n if value in seen:\n continue\n seen.add(value)\n deduped.append(value)\n return deduped\n\n\ndef trim_none(value: Any) -> Any:\n if isinstance(value, dict):\n return {key: trim_none(inner) for key, inner in value.items() if inner is not None}\n if isinstance(value, list):\n return [trim_none(item) for item in value]\n return value\n\n\ndef coerce_str(value: Any) -> str | None:\n return value if isinstance(value, str) else None\n\n\ndef cast_undecorated_mode(value: Any, fallback: UndecoratedMode) -> UndecoratedMode:\n if value in {\"GET\", \"POST\", \"private\"}:\n return value\n return fallback\n\n\ndef normalize_server_message(message: Any) -> dict[str, Any]:\n if isinstance(message, dict):\n return message\n if isinstance(message, Mapping):\n return dict(message)\n\n to_py = getattr(message, \"to_py\", None)\n if callable(to_py):\n try:\n converted = to_py()\n except TypeError:\n converted = to_py(depth=-1)\n return normalize_server_message(converted)\n\n keys = (\"operationId\", \"method\", \"path\", \"input\", \"body\", \"params\", \"headers\")\n normalized: dict[str, Any] = {}\n for key in keys:\n value = None\n if hasattr(message, key):\n value = getattr(message, key)\n else:\n try:\n value = message[key]\n except Exception:\n value = None\n if value is not None:\n normalized[key] = value\n return normalized\n",
|
|
10
10
|
"plat_browser/types.py": "from __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom typing import Any, Literal\n\n\nHttpMethod = Literal[\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"]\n\n\n@dataclass\nclass RouteMeta:\n name: str\n method: HttpMethod | None = None\n path: str | None = None\n decorator_path: str | None = None\n auth: str | None = None\n summary: str | None = None\n description: str | None = None\n rate_limit: Any = None\n token_limit: Any = None\n cache: Any = None\n opts: dict[str, Any] | None = None\n input_model: Any = None\n output_model: Any = None\n\n\n@dataclass\nclass ControllerMeta:\n base_path: str = \"\"\n tag: str | None = None\n auth: str | None = None\n rate_limit: Any = None\n token_limit: Any = None\n cache: Any = None\n routes: dict[str, RouteMeta] = field(default_factory=dict)\n\n\n@dataclass\nclass RouteContext:\n method: str | None = None\n url: str | None = None\n headers: dict[str, str] = field(default_factory=dict)\n auth: dict[str, Any] | None = None\n rate_limit: Any = None\n token_limit: Any = None\n cache: Any = None\n opts: dict[str, Any] | None = None\n call: Any = None\n rpc: Any = None\n request: Any = None\n",
|
|
11
11
|
"tests/test_plat_browser.py": "from __future__ import annotations\n\nimport asyncio\nimport textwrap\nimport unittest\n\nfrom plat_browser import (\n BrowserPLATServer,\n BucketConfig,\n Controller,\n HttpError,\n POST,\n RouteContext,\n connect_client_side_server,\n connect_server,\n create_browser_server,\n create_server,\n prepare_python_source,\n run_python_client_source,\n serve_server,\n serve_client_side_server,\n)\nfrom plat_browser.client import _set_browser_client_bridge\n\n\nclass PlatBrowserTests(unittest.TestCase):\n def setUp(self) -> None:\n async def missing_connect(_: str):\n raise RuntimeError(\"connect bridge not installed\")\n\n async def missing_call(_: int, __: str, ___):\n raise RuntimeError(\"call bridge not installed\")\n\n _set_browser_client_bridge(missing_connect, missing_call)\n\n def test_prepare_python_source_hides_pip_lines_and_detects_imports(self) -> None:\n plan = prepare_python_source(\n textwrap.dedent(\n \"\"\"\n !pip install pandas fastapi\n import pandas as pd\n import json\n from plat_browser import serve_client_side_server\n from numpy.random import rand\n \"\"\"\n ).strip()\n )\n\n self.assertEqual(plan.requested_packages, [\"pandas\", \"fastapi\"])\n self.assertEqual(plan.imported_modules, [\"pandas\", \"numpy\"])\n self.assertNotIn(\"!pip install\", plan.python_source)\n\n def test_prepare_python_source_rewrites_accidental_plat_imports(self) -> None:\n plan = prepare_python_source(\n textwrap.dedent(\n \"\"\"\n from plat import Controller, POST, serve_client_side_server\n import plat\n import plat as platform_api\n \"\"\"\n ).strip()\n )\n\n self.assertIn(\"from plat_browser import Controller, POST, serve_client_side_server\", plan.python_source)\n self.assertIn(\"import plat_browser as plat\", plan.python_source)\n self.assertIn(\"import plat_browser as platform_api\", plan.python_source)\n self.assertGreaterEqual(len(plan.import_rewrites), 3)\n\n def test_parity_aliases_point_to_same_callables(self) -> None:\n self.assertIs(connect_server, connect_client_side_server)\n self.assertIs(create_server, create_browser_server)\n self.assertIs(serve_server, serve_client_side_server)\n\n def test_serve_client_side_server_returns_definition(self) -> None:\n class DemoApi:\n def add(self, a, b, ctx):\n return a + b\n\n definition = serve_client_side_server(\"browser-math\", [DemoApi], undecorated_mode=\"POST\")\n\n self.assertEqual(definition.server_name, \"browser-math\")\n self.assertEqual(definition.controllers, [DemoApi])\n self.assertEqual(definition.options[\"undecorated_mode\"], \"POST\")\n\n def test_browser_server_registers_undecorated_public_methods_as_post(self) -> None:\n class DemoApi:\n def add(self, a: int, b: int, ctx: RouteContext) -> int:\n return a + b\n\n def _private(self, input, ctx):\n return None\n\n server = BrowserPLATServer({}, DemoApi)\n\n self.assertIn({\"method\": \"POST\", \"path\": \"/add\", \"methodName\": \"add\"}, server.routes)\n self.assertNotIn(\"_private\", server.operations_by_id)\n self.assertEqual(server.openapi[\"paths\"][\"/add\"][\"post\"][\"operationId\"], \"add\")\n schema = server.openapi[\"paths\"][\"/add\"][\"post\"][\"requestBody\"][\"content\"][\"application/json\"][\"schema\"]\n self.assertEqual(schema[\"properties\"][\"a\"][\"type\"], \"integer\")\n self.assertEqual(schema[\"properties\"][\"b\"][\"type\"], \"integer\")\n self.assertEqual(schema[\"required\"], [\"a\", \"b\"])\n\n def test_browser_server_uses_decorated_metadata_and_docstrings(self) -> None:\n @Controller()\n class OrdersApi:\n @POST()\n def createOrder(self, input: dict[str, str], ctx: RouteContext) -> dict[str, str]:\n \"\"\"Create one order.\n\n Longer description for docs.\n \"\"\"\n\n return {\"orderId\": \"ord_123\"}\n\n server = BrowserPLATServer({}, OrdersApi)\n operation = server.openapi[\"paths\"][\"/createOrder\"][\"post\"]\n\n self.assertEqual(operation[\"summary\"], \"Create one order.\")\n self.assertIn(\"Longer description\", operation[\"description\"])\n\n def test_browser_server_dispatches_requests_and_emits_progress(self) -> None:\n class JobsApi:\n async def countTo(self, end: int, ctx: RouteContext) -> dict[str, int]:\n for index in range(1, end + 1):\n ctx.call.progress({\"current\": index, \"end\": end})\n return {\"done\": end}\n\n server = BrowserPLATServer({}, JobsApi)\n events: list[dict[str, object]] = []\n\n result = asyncio.run(\n server.handle_request(\n {\n \"operationId\": \"countTo\",\n \"method\": \"POST\",\n \"path\": \"/countTo\",\n \"input\": {\"end\": 3},\n },\n emit=events.append,\n )\n )\n\n self.assertEqual(result, {\"done\": 3})\n self.assertEqual(\n events,\n [\n {\"event\": \"progress\", \"data\": {\"current\": 1, \"end\": 3}},\n {\"event\": \"progress\", \"data\": {\"current\": 2, \"end\": 3}},\n {\"event\": \"progress\", \"data\": {\"current\": 3, \"end\": 3}},\n ],\n )\n\n def test_browser_server_keeps_legacy_single_input_param_working(self) -> None:\n class LegacyApi:\n def add(self, input: dict[str, int], ctx: RouteContext) -> int:\n return input[\"a\"] + input[\"b\"]\n\n server = BrowserPLATServer({}, LegacyApi)\n result = asyncio.run(\n server.handle_request(\n {\n \"operationId\": \"add\",\n \"method\": \"POST\",\n \"path\": \"/add\",\n \"input\": {\"a\": 2, \"b\": 5},\n }\n )\n )\n self.assertEqual(result, 7)\n\n def test_browser_server_normalizes_proxy_like_request_objects(self) -> None:\n class DemoApi:\n def add(self, a: int, b: int, ctx: RouteContext) -> int:\n return a + b\n\n class FakeProxy:\n def __init__(self, payload):\n self._payload = payload\n\n def to_py(self):\n return self._payload\n\n server = BrowserPLATServer({}, DemoApi)\n result = asyncio.run(\n server.handle_request(\n FakeProxy(\n {\n \"operationId\": \"add\",\n \"method\": \"POST\",\n \"path\": \"/add\",\n \"input\": {\"a\": 9, \"b\": 4},\n }\n )\n )\n )\n self.assertEqual(result, 13)\n\n def test_browser_server_enforces_auth_and_populates_ctx(self) -> None:\n class SecureApi:\n def secret(self, ctx: RouteContext) -> str:\n return f\"hello {ctx.auth['sub']}\"\n\n async def verify(mode, request, ctx):\n self.assertEqual(mode, \"user\")\n token = request.get(\"headers\", {}).get(\"authorization\")\n if token != \"Bearer good\":\n raise HttpError(401, \"Missing or invalid authorization token\")\n return {\"sub\": \"demo-user\"}\n\n server = BrowserPLATServer(\n {\n \"auth\": {\"verify\": verify},\n \"default_auth\": \"user\",\n },\n SecureApi,\n )\n\n result = asyncio.run(\n server.handle_request(\n {\n \"operationId\": \"secret\",\n \"method\": \"POST\",\n \"path\": \"/secret\",\n \"headers\": {\"authorization\": \"Bearer good\"},\n }\n )\n )\n\n self.assertEqual(result, \"hello demo-user\")\n with self.assertRaises(HttpError) as error:\n asyncio.run(\n server.handle_request(\n {\n \"operationId\": \"secret\",\n \"method\": \"POST\",\n \"path\": \"/secret\",\n \"headers\": {},\n }\n )\n )\n self.assertEqual(error.exception.status_code, 401)\n\n def test_browser_server_supports_cache(self) -> None:\n calls = 0\n\n class DemoApi:\n def add(self, a: int, b: int, ctx: RouteContext) -> int:\n nonlocal calls\n calls += 1\n return a + b\n\n server = BrowserPLATServer(\n {\n \"cache\": {\n \"controller\": None,\n },\n },\n DemoApi,\n )\n operation = server.operations_by_id[\"add\"]\n operation.route_meta.cache = {\"key\": \"sum:{a}:{b}\", \"methods\": [\"POST\"], \"ttl\": 60}\n\n first = asyncio.run(\n server.handle_request(\n {\n \"operationId\": \"add\",\n \"method\": \"POST\",\n \"path\": \"/add\",\n \"input\": {\"a\": 1, \"b\": 4},\n }\n )\n )\n second = asyncio.run(\n server.handle_request(\n {\n \"operationId\": \"add\",\n \"method\": \"POST\",\n \"path\": \"/add\",\n \"input\": {\"a\": 1, \"b\": 4},\n }\n )\n )\n\n self.assertEqual(first, 5)\n self.assertEqual(second, 5)\n self.assertEqual(calls, 1)\n\n def test_browser_server_supports_rate_limit(self) -> None:\n class DemoApi:\n def add(self, a: int, b: int, ctx: RouteContext) -> int:\n return a + b\n\n server = BrowserPLATServer(\n {\n \"rate_limit\": {\n \"configs\": {\n \"add\": BucketConfig(max_balance=1, fill_interval=60_000, fill_amount=1),\n },\n },\n },\n DemoApi,\n )\n operation = server.operations_by_id[\"add\"]\n operation.route_meta.rate_limit = {\"key\": \"add\", \"cost\": 1}\n\n self.assertEqual(\n asyncio.run(\n server.handle_request(\n {\n \"operationId\": \"add\",\n \"method\": \"POST\",\n \"path\": \"/add\",\n \"input\": {\"a\": 2, \"b\": 3},\n }\n )\n ),\n 5,\n )\n with self.assertRaises(HttpError) as error:\n asyncio.run(\n server.handle_request(\n {\n \"operationId\": \"add\",\n \"method\": \"POST\",\n \"path\": \"/add\",\n \"input\": {\"a\": 2, \"b\": 3},\n }\n )\n )\n self.assertEqual(error.exception.status_code, 429)\n\n def test_browser_server_supports_token_limit(self) -> None:\n class DemoApi:\n def add(self, a: int, b: int, ctx: RouteContext) -> int:\n return a + b\n\n server = BrowserPLATServer(\n {\n \"token_limit\": {\n \"configs\": {\n \"tokens\": BucketConfig(max_balance=2, fill_interval=60_000, fill_amount=1),\n },\n },\n },\n DemoApi,\n )\n operation = server.operations_by_id[\"add\"]\n operation.route_meta.token_limit = {\"key\": \"tokens\", \"call_cost\": 2}\n\n self.assertEqual(\n asyncio.run(\n server.handle_request(\n {\n \"operationId\": \"add\",\n \"method\": \"POST\",\n \"path\": \"/add\",\n \"input\": {\"a\": 10, \"b\": 5},\n }\n )\n ),\n 15,\n )\n with self.assertRaises(HttpError) as error:\n asyncio.run(\n server.handle_request(\n {\n \"operationId\": \"add\",\n \"method\": \"POST\",\n \"path\": \"/add\",\n \"input\": {\"a\": 10, \"b\": 5},\n }\n )\n )\n self.assertEqual(error.exception.status_code, 429)\n\n def test_browser_python_client_connects_and_calls_with_kwargs(self) -> None:\n async def fake_connect(base_url: str, options=None):\n return {\"client_id\": 7, \"base_url\": base_url, \"openapi\": {\"paths\": {\"/add\": {\"post\": {}}}}}\n\n async def fake_call(client_id: int, method_name: str, payload):\n self.assertEqual(client_id, 7)\n self.assertEqual(method_name, \"add\")\n self.assertEqual(payload, {\"a\": 20, \"b\": 22})\n return 42\n\n _set_browser_client_bridge(fake_connect, fake_call)\n\n async def scenario():\n client = await connect_client_side_server(\"css://browser-python-math\")\n return await client.add(a=20, b=22)\n\n self.assertEqual(asyncio.run(scenario()), 42)\n\n def test_run_python_client_source_returns_last_expression(self) -> None:\n async def fake_connect(base_url: str, options=None):\n return {\"client_id\": 11, \"base_url\": base_url}\n\n async def fake_call(client_id: int, method_name: str, payload):\n self.assertEqual(client_id, 11)\n self.assertEqual(method_name, \"add\")\n self.assertEqual(payload, {\"a\": 3, \"b\": 9})\n return 12\n\n _set_browser_client_bridge(fake_connect, fake_call)\n\n result = asyncio.run(\n run_python_client_source(\n \"\"\"\nfrom plat_browser import connect_client_side_server\n\nclient = await connect_client_side_server(\"css://browser-python-math\")\nawait client.add(a=3, b=9)\n\"\"\".strip()\n )\n )\n self.assertEqual(result, 12)\n"
|
|
12
12
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"python-browser-sources.js","sourceRoot":"","sources":["../../src/generated/python-browser-sources.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAC1D,MAAM,CAAC,MAAM,sBAAsB,GAA2B;IAC5D,0BAA0B,EAAE,gkCAAgkC;IAC5lC,wBAAwB,EAAE,2wIAA2wI;IACryI,4BAA4B,EAAE,y5FAAy5F;IACv7F,wBAAwB,EAAE,yzBAAyzB;IACn1B,0BAA0B,EAAE,8eAA8e;IAC1gB,yBAAyB,EAAE,o0hBAAo0hB;IAC/1hB,wBAAwB,EAAE,
|
|
1
|
+
{"version":3,"file":"python-browser-sources.js","sourceRoot":"","sources":["../../src/generated/python-browser-sources.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAC1D,MAAM,CAAC,MAAM,sBAAsB,GAA2B;IAC5D,0BAA0B,EAAE,gkCAAgkC;IAC5lC,wBAAwB,EAAE,2wIAA2wI;IACryI,4BAA4B,EAAE,y5FAAy5F;IACv7F,wBAAwB,EAAE,yzBAAyzB;IACn1B,0BAA0B,EAAE,8eAA8e;IAC1gB,yBAAyB,EAAE,o0hBAAo0hB;IAC/1hB,wBAAwB,EAAE,82qCAA82qC;IACx4qC,uBAAuB,EAAE,yvCAAyvC;IAClxC,4BAA4B,EAAE,itdAAitd;CAChvd,CAAA"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export * from './args';
|
|
2
|
-
export * from './rpc';
|
|
3
|
-
export * from './spec';
|
|
4
|
-
export * from './client';
|
|
5
|
-
export * from './server';
|
|
6
|
-
export * from './types';
|
|
7
|
-
export { runCli } from './cli/runtime';
|
|
1
|
+
export * from './args/index.js';
|
|
2
|
+
export * from './rpc.js';
|
|
3
|
+
export * from './spec/index.js';
|
|
4
|
+
export * from './client/index.js';
|
|
5
|
+
export * from './server/index.js';
|
|
6
|
+
export * from './types/index.js';
|
|
7
|
+
export { runCli } from './cli/runtime.js';
|
|
8
8
|
export type { RunCliOptions } from './cli/runtime';
|
|
9
9
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from './client-side-python/runtime';
|
|
1
|
+
export * from './client-side-python/runtime.js';
|
|
2
2
|
//# sourceMappingURL=python-browser-entry.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createJwtAuth, signToken, signRefreshToken } from './jwt';
|
|
1
|
+
export { createJwtAuth, signToken, signRefreshToken } from './jwt.js';
|
|
2
2
|
export type { AuthHandler, JwtAuthConfig } from './jwt';
|
|
3
3
|
export type { AuthMode } from '../../types/endpoints';
|
|
4
4
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ClientSideServerAnyAuthorityRecord, ClientSideServerAnyTrustedServerRecord, ClientSideServerExportedKeyPair } from '../client-side-server/identity';
|
|
1
|
+
import type { ClientSideServerAnyAuthorityRecord, ClientSideServerAnyTrustedServerRecord, ClientSideServerExportedKeyPair } from '../client-side-server/identity.js';
|
|
2
2
|
export interface AuthorityHostSummary {
|
|
3
3
|
serverName: string;
|
|
4
4
|
keyId?: string;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from './utils';
|
|
1
|
+
export * from './utils.js';
|
|
2
2
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BucketConfig } from "../../types";
|
|
1
|
+
import { BucketConfig } from "../../types/index.js";
|
|
2
2
|
export declare const bucket: (maxBalance: number, fillAmount?: number, fillIntervalSeconds?: number, minBalance?: number, refundedStatusCodes?: never[], refundSuccessful?: boolean) => BucketConfig;
|
|
3
3
|
export declare const minBucket: (maxBalance: number, fillAmount?: number, fillIntervalMinutes?: number, minBalance?: number, refundedStatusCodes?: never[], refundSuccessful?: boolean) => BucketConfig;
|
|
4
4
|
export declare const hourBucket: (maxBalance: number, fillAmount?: number, fillIntervalHours?: number, minBalance?: number, refundedStatusCodes?: never[], refundSuccessful?: boolean) => BucketConfig;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export * from './logger';
|
|
2
|
-
export * from './defaults';
|
|
3
|
-
export * from './types';
|
|
4
|
-
export * from './bucket';
|
|
1
|
+
export * from './logger.js';
|
|
2
|
+
export * from './defaults.js';
|
|
3
|
+
export * from './types.js';
|
|
4
|
+
export * from './bucket.js';
|
|
5
5
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { AuthMode, CacheController, RateLimitConfigs, RateLimitController, TokenLimitConfigs, TokenLimitController } from "../../types";
|
|
2
|
-
import { Logger } from "./logger";
|
|
3
|
-
import type { InMemoryCallSessionController } from "../call-sessions";
|
|
4
|
-
import type { PLATServerProtocolPlugin } from "../protocol-plugin";
|
|
5
|
-
import type { PLATAuthorityServerOptions } from "../authority-server";
|
|
6
|
-
import type { PLATServerWebRTCOptions } from "../webrtc-plugin";
|
|
1
|
+
import { AuthMode, CacheController, RateLimitConfigs, RateLimitController, TokenLimitConfigs, TokenLimitController } from "../../types/index.js";
|
|
2
|
+
import { Logger } from "./logger.js";
|
|
3
|
+
import type { InMemoryCallSessionController } from "../call-sessions.js";
|
|
4
|
+
import type { PLATServerProtocolPlugin } from "../protocol-plugin.js";
|
|
5
|
+
import type { PLATAuthorityServerOptions } from "../authority-server.js";
|
|
6
|
+
import type { PLATServerWebRTCOptions } from "../webrtc-plugin.js";
|
|
7
7
|
export interface AuthHandler {
|
|
8
8
|
verify(mode: AuthMode, req: any, ctx: any): Promise<any> | any;
|
|
9
9
|
}
|
package/dist/server/core.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { PLATServerResolvedOperation } from './transports';
|
|
2
|
-
import { PLATOperationRegistry } from './operation-registry';
|
|
3
|
-
import { type StaticFolder } from '../static/static-folder';
|
|
1
|
+
import type { PLATServerResolvedOperation } from './transports.js';
|
|
2
|
+
import { PLATOperationRegistry } from './operation-registry.js';
|
|
3
|
+
import { type StaticFolder } from '../static/static-folder.js';
|
|
4
4
|
interface PLATServerCoreOptions {
|
|
5
5
|
undecoratedMode?: 'GET' | 'POST' | 'private';
|
|
6
6
|
allowedMethodPrefixes?: '*' | string[];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { PLATServerProtocolPlugin } from './protocol-plugin';
|
|
2
|
-
import type { FileQueueOptions } from './config/types';
|
|
1
|
+
import type { PLATServerProtocolPlugin } from './protocol-plugin.js';
|
|
2
|
+
import type { FileQueueOptions } from './config/types.js';
|
|
3
3
|
export interface FileQueueProtocolPluginOptions {
|
|
4
4
|
config: FileQueueOptions | false | undefined;
|
|
5
5
|
}
|
package/dist/server/help.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { RouteMeta } from '../types';
|
|
2
|
+
import type { RouteMeta } from '../types/index.js';
|
|
3
3
|
/**
|
|
4
4
|
* Check if a query parameter should trigger help mode
|
|
5
5
|
* Returns true if help parameter is present with a truthy value or without a value
|
package/dist/server/index.d.ts
CHANGED
|
@@ -11,21 +11,21 @@
|
|
|
11
11
|
* - Auth decorator system with pluggable handlers
|
|
12
12
|
* - Rate limiting, token limiting, and caching plugins
|
|
13
13
|
*/
|
|
14
|
-
export * from './server';
|
|
15
|
-
export * from './core';
|
|
16
|
-
export * from './operation-registry';
|
|
17
|
-
export * from './transports';
|
|
18
|
-
export * from './protocol-plugin';
|
|
19
|
-
export * from './websocket-plugin';
|
|
20
|
-
export * from './param-aliases';
|
|
21
|
-
export * from './routing';
|
|
22
|
-
export * from './tools';
|
|
23
|
-
export * from './cache';
|
|
24
|
-
export * from './rate-limit';
|
|
25
|
-
export * from './token-limit';
|
|
26
|
-
export * from './auth';
|
|
27
|
-
export * from './authority-server';
|
|
28
|
-
export * from './help';
|
|
29
|
-
export * from './env';
|
|
30
|
-
export * from './config/bucket';
|
|
14
|
+
export * from './server.js';
|
|
15
|
+
export * from './core.js';
|
|
16
|
+
export * from './operation-registry.js';
|
|
17
|
+
export * from './transports.js';
|
|
18
|
+
export * from './protocol-plugin.js';
|
|
19
|
+
export * from './websocket-plugin.js';
|
|
20
|
+
export * from './param-aliases.js';
|
|
21
|
+
export * from './routing.js';
|
|
22
|
+
export * from './tools.js';
|
|
23
|
+
export * from './cache/index.js';
|
|
24
|
+
export * from './rate-limit/index.js';
|
|
25
|
+
export * from './token-limit/index.js';
|
|
26
|
+
export * from './auth/index.js';
|
|
27
|
+
export * from './authority-server.js';
|
|
28
|
+
export * from './help.js';
|
|
29
|
+
export * from './env.js';
|
|
30
|
+
export * from './config/bucket.js';
|
|
31
31
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PLATServerCallEnvelope, PLATServerResolvedOperation } from './transports';
|
|
1
|
+
import type { PLATServerCallEnvelope, PLATServerResolvedOperation } from './transports.js';
|
|
2
2
|
export declare class PLATOperationRegistry {
|
|
3
3
|
private operationsById;
|
|
4
4
|
private operationsByRoute;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Logger } from './config/logger';
|
|
2
|
-
import type { PLATServerCallEnvelope, PLATServerResolvedOperation } from './transports';
|
|
3
|
-
import type { RouteContext } from '../types';
|
|
1
|
+
import type { Logger } from './config/logger.js';
|
|
2
|
+
import type { PLATServerCallEnvelope, PLATServerResolvedOperation } from './transports.js';
|
|
3
|
+
import type { RouteContext } from '../types/index.js';
|
|
4
4
|
export interface PLATServerConnectionRequest {
|
|
5
5
|
protocol: string;
|
|
6
6
|
meta?: Record<string, unknown>;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from './utils';
|
|
1
|
+
export * from './utils.js';
|
|
2
2
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RateLimitController, RateLimitMeta, RateLimitConfigs, ResolvedRateLimitEntry } from '../../types/plugins';
|
|
1
|
+
import { RateLimitController, RateLimitMeta, RateLimitConfigs, ResolvedRateLimitEntry } from '../../types/plugins.js';
|
|
2
2
|
/**
|
|
3
3
|
* Resolve rate limit key with substitutions:
|
|
4
4
|
* - :route → methodName
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PLATServerHostContext, PLATServerProtocolPlugin } from './protocol-plugin';
|
|
1
|
+
import type { PLATServerHostContext, PLATServerProtocolPlugin } from './protocol-plugin.js';
|
|
2
2
|
export interface RpcProtocolPluginOptions {
|
|
3
3
|
/** true, false, or a custom path like '/ws' */
|
|
4
4
|
enabled: boolean | string;
|
package/dist/server/server.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Express } from "express";
|
|
2
|
-
import { PLATServerOptions } from "./config";
|
|
3
|
-
import type { PLATServerTransportRuntime } from './protocol-plugin';
|
|
2
|
+
import { PLATServerOptions } from "./config/index.js";
|
|
3
|
+
import type { PLATServerTransportRuntime } from './protocol-plugin.js';
|
|
4
4
|
export declare class PLATServer {
|
|
5
5
|
private app;
|
|
6
6
|
private options;
|
package/dist/server/server.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from './utils';
|
|
1
|
+
export * from './utils.js';
|
|
2
2
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TokenLimitController, TokenLimitEntry, TokenLimitMeta, TokenLimitTiming, TokenLimitConfigs, ResolvedTokenLimitEntry } from '../../types/plugins';
|
|
1
|
+
import { TokenLimitController, TokenLimitEntry, TokenLimitMeta, TokenLimitTiming, TokenLimitConfigs, ResolvedTokenLimitEntry } from '../../types/plugins.js';
|
|
2
2
|
/**
|
|
3
3
|
* Resolve token limit key with substitutions:
|
|
4
4
|
* - :route → methodName
|
package/dist/server/tools.d.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* Supports multiple formats: Anthropic (Claude), OpenAI, generic JSON Schema
|
|
6
6
|
*/
|
|
7
7
|
import { ZodSchema } from 'zod';
|
|
8
|
-
import type { ToolDefinition, ToolFormat, AnthropicTool, OpenAITool } from '../types/tools';
|
|
9
|
-
import { buildInputSchemaFromOpenAPIOperation, buildResponseSchemaFromOpenAPIOperation, emptyInputSchema, normalizeInputSchema, toolDefinitionFromOpenAPIOperation, type ToolDefinitionInit } from '../shared/tools';
|
|
8
|
+
import type { ToolDefinition, ToolFormat, AnthropicTool, OpenAITool } from '../types/tools.js';
|
|
9
|
+
import { buildInputSchemaFromOpenAPIOperation, buildResponseSchemaFromOpenAPIOperation, emptyInputSchema, normalizeInputSchema, toolDefinitionFromOpenAPIOperation, type ToolDefinitionInit } from '../shared/tools.js';
|
|
10
10
|
/**
|
|
11
11
|
* Convert a Zod schema to JSON Schema
|
|
12
12
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type ClientSideServerMQTTWebRTCOptions, type ClientSideServerWorkerInfo } from '../client-side-server/mqtt-webrtc';
|
|
2
|
-
import { type ClientSideServerInstanceInfo } from '../client-side-server/protocol';
|
|
3
|
-
import type { PLATServerProtocolPlugin } from './protocol-plugin';
|
|
1
|
+
import { type ClientSideServerMQTTWebRTCOptions, type ClientSideServerWorkerInfo } from '../client-side-server/mqtt-webrtc.js';
|
|
2
|
+
import { type ClientSideServerInstanceInfo } from '../client-side-server/protocol.js';
|
|
3
|
+
import type { PLATServerProtocolPlugin } from './protocol-plugin.js';
|
|
4
4
|
export interface PLATServerWebRTCOptions extends ClientSideServerMQTTWebRTCOptions {
|
|
5
5
|
/** css:// name this server is reachable at (e.g. "dmz/my-api" or "authority.com/my-api"). */
|
|
6
6
|
name: string;
|