wsbuilder 0.1.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wsbuilder-0.2.2/PKG-INFO +127 -0
- wsbuilder-0.2.2/README.md +111 -0
- {wsbuilder-0.1.0 → wsbuilder-0.2.2}/pyproject.toml +1 -1
- {wsbuilder-0.1.0 → wsbuilder-0.2.2}/src/wsbuilder/__init__.py +1 -2
- wsbuilder-0.2.2/src/wsbuilder/app.py +115 -0
- wsbuilder-0.2.2/src/wsbuilder/constants.py +67 -0
- wsbuilder-0.2.2/src/wsbuilder/framework.py +44 -0
- wsbuilder-0.2.2/src/wsbuilder/http.py +190 -0
- wsbuilder-0.2.2/src/wsbuilder/server.py +150 -0
- wsbuilder-0.2.2/src/wsbuilder/ws.py +216 -0
- wsbuilder-0.2.2/src/wsbuilder.egg-info/PKG-INFO +127 -0
- {wsbuilder-0.1.0 → wsbuilder-0.2.2}/src/wsbuilder.egg-info/SOURCES.txt +5 -0
- wsbuilder-0.1.0/PKG-INFO +0 -77
- wsbuilder-0.1.0/README.md +0 -61
- wsbuilder-0.1.0/src/wsbuilder/framework.py +0 -662
- wsbuilder-0.1.0/src/wsbuilder.egg-info/PKG-INFO +0 -77
- {wsbuilder-0.1.0 → wsbuilder-0.2.2}/LICENSE +0 -0
- {wsbuilder-0.1.0 → wsbuilder-0.2.2}/setup.cfg +0 -0
- {wsbuilder-0.1.0 → wsbuilder-0.2.2}/src/wsbuilder/__main__.py +0 -0
- {wsbuilder-0.1.0 → wsbuilder-0.2.2}/src/wsbuilder/ws_demo.py +0 -0
- {wsbuilder-0.1.0 → wsbuilder-0.2.2}/src/wsbuilder.egg-info/dependency_links.txt +0 -0
- {wsbuilder-0.1.0 → wsbuilder-0.2.2}/src/wsbuilder.egg-info/entry_points.txt +0 -0
- {wsbuilder-0.1.0 → wsbuilder-0.2.2}/src/wsbuilder.egg-info/top_level.txt +0 -0
wsbuilder-0.2.2/PKG-INFO
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wsbuilder
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Framework ligero HTTP + WebSocket extraido de PortHound4.
|
|
5
|
+
Author: wsbuilder Authors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: websocket,http,framework,server,python
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# wsbuilder
|
|
18
|
+
|
|
19
|
+
`wsbuilder` es un paquete Python extraido de `PortHound4` para construir servidores HTTP + WebSocket sin dependencias externas.
|
|
20
|
+
|
|
21
|
+
Lightweight Python HTTP + WebSocket framework for building real-time APIs and custom servers.
|
|
22
|
+
|
|
23
|
+
**Keywords:** `python`, `http-server`, `websocket`, `framework`, `real-time`, `api`, `socket`.
|
|
24
|
+
|
|
25
|
+
## Incluye
|
|
26
|
+
|
|
27
|
+
- `wsbuilder.framework`: router, request/response, servidor HTTP, handshake WS y utilidades de frames.
|
|
28
|
+
- `wsbuilder.ws_demo`: demo completo HTTP + WebSocket + REST + SQLite.
|
|
29
|
+
|
|
30
|
+
## Instalacion local
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python -m pip install -e .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Uso rapido del framework
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from wsbuilder import App, Response, parse_close_payload
|
|
40
|
+
|
|
41
|
+
app = App()
|
|
42
|
+
|
|
43
|
+
@app.view("/")
|
|
44
|
+
def home(_request):
|
|
45
|
+
return Response.html("<h1>wsbuilder</h1>")
|
|
46
|
+
|
|
47
|
+
@app.api("/api/health")
|
|
48
|
+
def health(_request):
|
|
49
|
+
return {"ok": True}
|
|
50
|
+
|
|
51
|
+
@app.ws("/ws/")
|
|
52
|
+
def ws_handler(ws, _request):
|
|
53
|
+
while True:
|
|
54
|
+
fin, opcode, payload, _masked, _mask = ws.recv_frame()
|
|
55
|
+
if opcode == 0x8:
|
|
56
|
+
code, reason = parse_close_payload(payload)
|
|
57
|
+
ws.close(code or 1000, reason or "")
|
|
58
|
+
break
|
|
59
|
+
if opcode == 0x9:
|
|
60
|
+
ws.send_pong(payload)
|
|
61
|
+
continue
|
|
62
|
+
if opcode == 0x1:
|
|
63
|
+
ws.send_text(payload.decode("utf-8", errors="ignore"))
|
|
64
|
+
elif opcode == 0x2:
|
|
65
|
+
ws.send_binary(payload)
|
|
66
|
+
|
|
67
|
+
app.run("0.0.0.0", 8765)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Para CORS sin variables de entorno:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
app = App(cors_allow_origin="*")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## HTTP streaming (chunked)
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import time
|
|
80
|
+
from wsbuilder import App, Response
|
|
81
|
+
|
|
82
|
+
app = App()
|
|
83
|
+
|
|
84
|
+
@app.view("/stream")
|
|
85
|
+
def stream(_request):
|
|
86
|
+
def chunks():
|
|
87
|
+
for i in range(5):
|
|
88
|
+
yield f"chunk {i}\n"
|
|
89
|
+
time.sleep(1)
|
|
90
|
+
return Response.stream(chunks(), content_type="text/plain; charset=utf-8")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Ejecutar demo incluido
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python -m wsbuilder --host 0.0.0.0 --port 8765
|
|
97
|
+
# o
|
|
98
|
+
wsbuilder --host 0.0.0.0 --port 8765
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Configuracion CORS
|
|
102
|
+
|
|
103
|
+
- Usa `App(cors_allow_origin="https://tu-dominio.com")`.
|
|
104
|
+
- Usa `App(cors_allow_origin="*")` para permitir cualquier origen.
|
|
105
|
+
|
|
106
|
+
## CI/CD (GitHub Actions)
|
|
107
|
+
|
|
108
|
+
- `package-build.yml`: construye `sdist` + `wheel`, valida metadata y prueba instalacion/import.
|
|
109
|
+
- `release-from-main.yml` en push a `main`: calcula `semver` automaticamente, crea rama `release/v<version>` desde `main`, actualiza version del paquete, crea tag `v<version>` y publica GitHub Release.
|
|
110
|
+
- `publish-packages.yml` en push de tag `v*`: construye y publica a PyPI/TestPyPI (instalable con `pip`).
|
|
111
|
+
- `publish-packages.yml` en tags `v*`: adjunta los paquetes al GitHub Release.
|
|
112
|
+
- `main-only-from-develop.yml` (workflow `main-pr-source-check`): valida metadata minima del PR hacia `main`.
|
|
113
|
+
|
|
114
|
+
### Reglas de versionado automatico
|
|
115
|
+
|
|
116
|
+
- `major`: si algun commit trae `BREAKING CHANGE`, `type(scope)!:` o ramas/commits marcados como `breaking`/`major`.
|
|
117
|
+
- `minor`: si hay suficiente peso de `feat`/`feature` (proporcional frente a cambios `patch`) y no hay `major`.
|
|
118
|
+
- `patch`: cualquier otro caso.
|
|
119
|
+
|
|
120
|
+
Recomendado usar Conventional Commits para que el calculo de version sea preciso.
|
|
121
|
+
|
|
122
|
+
### Secrets requeridos
|
|
123
|
+
|
|
124
|
+
- `RELEASE_BOT_TOKEN` (recomendado): PAT/fine-grained token para crear rama `release/*`, crear tag y publicar GitHub Release.
|
|
125
|
+
- `RULESET_ADMIN_TOKEN` (fallback): usado automaticamente si no existe `RELEASE_BOT_TOKEN`.
|
|
126
|
+
- `PYPI_API_TOKEN`: token de publicacion para PyPI.
|
|
127
|
+
- `TEST_PYPI_API_TOKEN` (opcional): token de publicacion para TestPyPI.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# wsbuilder
|
|
2
|
+
|
|
3
|
+
`wsbuilder` es un paquete Python extraido de `PortHound4` para construir servidores HTTP + WebSocket sin dependencias externas.
|
|
4
|
+
|
|
5
|
+
Lightweight Python HTTP + WebSocket framework for building real-time APIs and custom servers.
|
|
6
|
+
|
|
7
|
+
**Keywords:** `python`, `http-server`, `websocket`, `framework`, `real-time`, `api`, `socket`.
|
|
8
|
+
|
|
9
|
+
## Incluye
|
|
10
|
+
|
|
11
|
+
- `wsbuilder.framework`: router, request/response, servidor HTTP, handshake WS y utilidades de frames.
|
|
12
|
+
- `wsbuilder.ws_demo`: demo completo HTTP + WebSocket + REST + SQLite.
|
|
13
|
+
|
|
14
|
+
## Instalacion local
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
python -m pip install -e .
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Uso rapido del framework
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from wsbuilder import App, Response, parse_close_payload
|
|
24
|
+
|
|
25
|
+
app = App()
|
|
26
|
+
|
|
27
|
+
@app.view("/")
|
|
28
|
+
def home(_request):
|
|
29
|
+
return Response.html("<h1>wsbuilder</h1>")
|
|
30
|
+
|
|
31
|
+
@app.api("/api/health")
|
|
32
|
+
def health(_request):
|
|
33
|
+
return {"ok": True}
|
|
34
|
+
|
|
35
|
+
@app.ws("/ws/")
|
|
36
|
+
def ws_handler(ws, _request):
|
|
37
|
+
while True:
|
|
38
|
+
fin, opcode, payload, _masked, _mask = ws.recv_frame()
|
|
39
|
+
if opcode == 0x8:
|
|
40
|
+
code, reason = parse_close_payload(payload)
|
|
41
|
+
ws.close(code or 1000, reason or "")
|
|
42
|
+
break
|
|
43
|
+
if opcode == 0x9:
|
|
44
|
+
ws.send_pong(payload)
|
|
45
|
+
continue
|
|
46
|
+
if opcode == 0x1:
|
|
47
|
+
ws.send_text(payload.decode("utf-8", errors="ignore"))
|
|
48
|
+
elif opcode == 0x2:
|
|
49
|
+
ws.send_binary(payload)
|
|
50
|
+
|
|
51
|
+
app.run("0.0.0.0", 8765)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Para CORS sin variables de entorno:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
app = App(cors_allow_origin="*")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## HTTP streaming (chunked)
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import time
|
|
64
|
+
from wsbuilder import App, Response
|
|
65
|
+
|
|
66
|
+
app = App()
|
|
67
|
+
|
|
68
|
+
@app.view("/stream")
|
|
69
|
+
def stream(_request):
|
|
70
|
+
def chunks():
|
|
71
|
+
for i in range(5):
|
|
72
|
+
yield f"chunk {i}\n"
|
|
73
|
+
time.sleep(1)
|
|
74
|
+
return Response.stream(chunks(), content_type="text/plain; charset=utf-8")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Ejecutar demo incluido
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python -m wsbuilder --host 0.0.0.0 --port 8765
|
|
81
|
+
# o
|
|
82
|
+
wsbuilder --host 0.0.0.0 --port 8765
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Configuracion CORS
|
|
86
|
+
|
|
87
|
+
- Usa `App(cors_allow_origin="https://tu-dominio.com")`.
|
|
88
|
+
- Usa `App(cors_allow_origin="*")` para permitir cualquier origen.
|
|
89
|
+
|
|
90
|
+
## CI/CD (GitHub Actions)
|
|
91
|
+
|
|
92
|
+
- `package-build.yml`: construye `sdist` + `wheel`, valida metadata y prueba instalacion/import.
|
|
93
|
+
- `release-from-main.yml` en push a `main`: calcula `semver` automaticamente, crea rama `release/v<version>` desde `main`, actualiza version del paquete, crea tag `v<version>` y publica GitHub Release.
|
|
94
|
+
- `publish-packages.yml` en push de tag `v*`: construye y publica a PyPI/TestPyPI (instalable con `pip`).
|
|
95
|
+
- `publish-packages.yml` en tags `v*`: adjunta los paquetes al GitHub Release.
|
|
96
|
+
- `main-only-from-develop.yml` (workflow `main-pr-source-check`): valida metadata minima del PR hacia `main`.
|
|
97
|
+
|
|
98
|
+
### Reglas de versionado automatico
|
|
99
|
+
|
|
100
|
+
- `major`: si algun commit trae `BREAKING CHANGE`, `type(scope)!:` o ramas/commits marcados como `breaking`/`major`.
|
|
101
|
+
- `minor`: si hay suficiente peso de `feat`/`feature` (proporcional frente a cambios `patch`) y no hay `major`.
|
|
102
|
+
- `patch`: cualquier otro caso.
|
|
103
|
+
|
|
104
|
+
Recomendado usar Conventional Commits para que el calculo de version sea preciso.
|
|
105
|
+
|
|
106
|
+
### Secrets requeridos
|
|
107
|
+
|
|
108
|
+
- `RELEASE_BOT_TOKEN` (recomendado): PAT/fine-grained token para crear rama `release/*`, crear tag y publicar GitHub Release.
|
|
109
|
+
- `RULESET_ADMIN_TOKEN` (fallback): usado automaticamente si no existe `RELEASE_BOT_TOKEN`.
|
|
110
|
+
- `PYPI_API_TOKEN`: token de publicacion para PyPI.
|
|
111
|
+
- `TEST_PYPI_API_TOKEN` (opcional): token de publicacion para TestPyPI.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from .constants import DEFAULT_CORS_ALLOW_ORIGIN
|
|
2
|
+
from .http import Response
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Route:
|
|
6
|
+
def __init__(self, path, methods, handler, kind):
|
|
7
|
+
self.path = path
|
|
8
|
+
self.methods = {m.upper() for m in methods}
|
|
9
|
+
self.handler = handler
|
|
10
|
+
self.kind = kind
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Router:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.routes = []
|
|
16
|
+
|
|
17
|
+
def add(self, route):
|
|
18
|
+
self.routes.append(route)
|
|
19
|
+
|
|
20
|
+
def resolve(self, path, method=None):
|
|
21
|
+
for route in self.routes:
|
|
22
|
+
if route.path != path:
|
|
23
|
+
continue
|
|
24
|
+
if method is None or method.upper() in route.methods:
|
|
25
|
+
return route
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class App:
|
|
30
|
+
def __init__(self, cors_allow_origin=DEFAULT_CORS_ALLOW_ORIGIN):
|
|
31
|
+
self.router = Router()
|
|
32
|
+
self.ws_routes = {}
|
|
33
|
+
self.startup_hooks = []
|
|
34
|
+
self.cors_allow_origin = (cors_allow_origin or "").strip()
|
|
35
|
+
|
|
36
|
+
def route(self, path, methods=("GET",), kind="plain"):
|
|
37
|
+
def decorator(func):
|
|
38
|
+
self.router.add(Route(path, methods, func, kind))
|
|
39
|
+
return func
|
|
40
|
+
|
|
41
|
+
return decorator
|
|
42
|
+
|
|
43
|
+
def view(self, path, methods=("GET",)):
|
|
44
|
+
return self.route(path, methods=methods, kind="plain")
|
|
45
|
+
|
|
46
|
+
def api(self, path, methods=("GET",)):
|
|
47
|
+
return self.route(path, methods=methods, kind="api")
|
|
48
|
+
|
|
49
|
+
def ws(self, path):
|
|
50
|
+
def decorator(func):
|
|
51
|
+
self.ws_routes[path] = func
|
|
52
|
+
return func
|
|
53
|
+
|
|
54
|
+
return decorator
|
|
55
|
+
|
|
56
|
+
def add_startup(self, func):
|
|
57
|
+
self.startup_hooks.append(func)
|
|
58
|
+
|
|
59
|
+
def dispatch(self, request):
|
|
60
|
+
cors_allow_origin = self.cors_allow_origin
|
|
61
|
+
if request.method == "OPTIONS":
|
|
62
|
+
route = self.router.resolve(request.path, method=None)
|
|
63
|
+
if route:
|
|
64
|
+
allow = sorted(route.methods | {"OPTIONS"})
|
|
65
|
+
headers = {
|
|
66
|
+
"Access-Control-Allow-Methods": ", ".join(allow),
|
|
67
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key",
|
|
68
|
+
}
|
|
69
|
+
if cors_allow_origin:
|
|
70
|
+
headers["Access-Control-Allow-Origin"] = cors_allow_origin
|
|
71
|
+
if cors_allow_origin != "*":
|
|
72
|
+
headers["Vary"] = "Origin"
|
|
73
|
+
return Response(status=200, body=b"", headers=headers)
|
|
74
|
+
|
|
75
|
+
route = self.router.resolve(request.path, request.method)
|
|
76
|
+
if not route:
|
|
77
|
+
return Response.text("Not Found", status=404)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
result = route.handler(request)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
print(f"[http] handler error {request.method} {request.path}: {e}")
|
|
83
|
+
if route.kind == "api":
|
|
84
|
+
headers = {}
|
|
85
|
+
if cors_allow_origin:
|
|
86
|
+
headers["Access-Control-Allow-Origin"] = cors_allow_origin
|
|
87
|
+
if cors_allow_origin != "*":
|
|
88
|
+
headers["Vary"] = "Origin"
|
|
89
|
+
return Response.json(
|
|
90
|
+
{"status": "error", "message": "Internal Server Error"},
|
|
91
|
+
status=500,
|
|
92
|
+
headers=headers,
|
|
93
|
+
)
|
|
94
|
+
return Response.text("Internal Server Error", status=500)
|
|
95
|
+
|
|
96
|
+
if isinstance(result, Response):
|
|
97
|
+
resp = result
|
|
98
|
+
elif route.kind == "api" and isinstance(result, (dict, list)):
|
|
99
|
+
resp = Response.json(result)
|
|
100
|
+
else:
|
|
101
|
+
resp = Response.text("" if result is None else str(result))
|
|
102
|
+
|
|
103
|
+
if route.kind == "api":
|
|
104
|
+
if cors_allow_origin:
|
|
105
|
+
resp.headers.setdefault("Access-Control-Allow-Origin", cors_allow_origin)
|
|
106
|
+
if cors_allow_origin != "*":
|
|
107
|
+
resp.headers.setdefault("Vary", "Origin")
|
|
108
|
+
|
|
109
|
+
return resp
|
|
110
|
+
|
|
111
|
+
def run(self, host, port, ssl_context=None):
|
|
112
|
+
from .server import HTTPServer
|
|
113
|
+
|
|
114
|
+
server = HTTPServer(host, port, self, ssl_context=ssl_context)
|
|
115
|
+
server.serve_forever()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
MAGIC_WS = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
2
|
+
DEFAULT_CORS_ALLOW_ORIGIN = ""
|
|
3
|
+
|
|
4
|
+
STATUS_MESSAGES = {
|
|
5
|
+
100: "Continue",
|
|
6
|
+
101: "Switching Protocols",
|
|
7
|
+
102: "Processing",
|
|
8
|
+
103: "Early Hints",
|
|
9
|
+
200: "OK",
|
|
10
|
+
201: "Created",
|
|
11
|
+
202: "Accepted",
|
|
12
|
+
203: "Non-Authoritative Information",
|
|
13
|
+
204: "No Content",
|
|
14
|
+
205: "Reset Content",
|
|
15
|
+
206: "Partial Content",
|
|
16
|
+
207: "Multi-Status",
|
|
17
|
+
208: "Already Reported",
|
|
18
|
+
226: "IM Used",
|
|
19
|
+
300: "Multiple Choices",
|
|
20
|
+
301: "Moved Permanently",
|
|
21
|
+
302: "Found",
|
|
22
|
+
303: "See Other",
|
|
23
|
+
304: "Not Modified",
|
|
24
|
+
305: "Use Proxy",
|
|
25
|
+
307: "Temporary Redirect",
|
|
26
|
+
308: "Permanent Redirect",
|
|
27
|
+
400: "Bad Request",
|
|
28
|
+
401: "Unauthorized",
|
|
29
|
+
402: "Payment Required",
|
|
30
|
+
403: "Forbidden",
|
|
31
|
+
404: "Not Found",
|
|
32
|
+
405: "Method Not Allowed",
|
|
33
|
+
406: "Not Acceptable",
|
|
34
|
+
407: "Proxy Authentication Required",
|
|
35
|
+
408: "Request Timeout",
|
|
36
|
+
409: "Conflict",
|
|
37
|
+
410: "Gone",
|
|
38
|
+
411: "Length Required",
|
|
39
|
+
412: "Precondition Failed",
|
|
40
|
+
413: "Payload Too Large",
|
|
41
|
+
414: "URI Too Long",
|
|
42
|
+
415: "Unsupported Media Type",
|
|
43
|
+
416: "Range Not Satisfiable",
|
|
44
|
+
417: "Expectation Failed",
|
|
45
|
+
418: "I'm a teapot",
|
|
46
|
+
421: "Misdirected Request",
|
|
47
|
+
422: "Unprocessable Entity",
|
|
48
|
+
423: "Locked",
|
|
49
|
+
424: "Failed Dependency",
|
|
50
|
+
425: "Too Early",
|
|
51
|
+
426: "Upgrade Required",
|
|
52
|
+
428: "Precondition Required",
|
|
53
|
+
429: "Too Many Requests",
|
|
54
|
+
431: "Request Header Fields Too Large",
|
|
55
|
+
451: "Unavailable For Legal Reasons",
|
|
56
|
+
500: "Internal Server Error",
|
|
57
|
+
501: "Not Implemented",
|
|
58
|
+
502: "Bad Gateway",
|
|
59
|
+
503: "Service Unavailable",
|
|
60
|
+
504: "Gateway Timeout",
|
|
61
|
+
505: "HTTP Version Not Supported",
|
|
62
|
+
506: "Variant Also Negotiates",
|
|
63
|
+
507: "Insufficient Storage",
|
|
64
|
+
508: "Loop Detected",
|
|
65
|
+
510: "Not Extended",
|
|
66
|
+
511: "Network Authentication Required",
|
|
67
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Compatibility facade for the original single-file framework module."""
|
|
2
|
+
|
|
3
|
+
from .app import App, Route, Router
|
|
4
|
+
from .constants import DEFAULT_CORS_ALLOW_ORIGIN, MAGIC_WS, STATUS_MESSAGES
|
|
5
|
+
from .http import Request, Response, parse_http_request, parse_query_string, send_http_response
|
|
6
|
+
from .server import HTTPServer
|
|
7
|
+
from .ws import (
|
|
8
|
+
B64_ALPHABET,
|
|
9
|
+
WebSocket,
|
|
10
|
+
base64_encode,
|
|
11
|
+
handshake_websocket,
|
|
12
|
+
is_ws_request,
|
|
13
|
+
make_ws_frame_bytes,
|
|
14
|
+
parse_close_payload,
|
|
15
|
+
read_ws_frame_raw,
|
|
16
|
+
recv_exact,
|
|
17
|
+
sha1,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"App",
|
|
22
|
+
"HTTPServer",
|
|
23
|
+
"Request",
|
|
24
|
+
"Response",
|
|
25
|
+
"Route",
|
|
26
|
+
"Router",
|
|
27
|
+
"WebSocket",
|
|
28
|
+
"DEFAULT_CORS_ALLOW_ORIGIN",
|
|
29
|
+
"MAGIC_WS",
|
|
30
|
+
"STATUS_MESSAGES",
|
|
31
|
+
"B64_ALPHABET",
|
|
32
|
+
"base64_encode",
|
|
33
|
+
"handshake_websocket",
|
|
34
|
+
"is_ws_request",
|
|
35
|
+
"make_ws_frame_bytes",
|
|
36
|
+
"parse_close_payload",
|
|
37
|
+
"parse_http_request",
|
|
38
|
+
"parse_query_string",
|
|
39
|
+
"read_ws_frame_raw",
|
|
40
|
+
"recv_exact",
|
|
41
|
+
"send_http_response",
|
|
42
|
+
"sha1",
|
|
43
|
+
]
|
|
44
|
+
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from .constants import STATUS_MESSAGES
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_query_string(qs):
|
|
7
|
+
params = {}
|
|
8
|
+
if not qs:
|
|
9
|
+
return params
|
|
10
|
+
parts = qs.split("&")
|
|
11
|
+
for part in parts:
|
|
12
|
+
if not part:
|
|
13
|
+
continue
|
|
14
|
+
if "=" in part:
|
|
15
|
+
k, v = part.split("=", 1)
|
|
16
|
+
else:
|
|
17
|
+
k, v = part, ""
|
|
18
|
+
params[k] = v
|
|
19
|
+
return params
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Request:
|
|
23
|
+
def __init__(self, method, path, query_string, headers, body, client, tls=None):
|
|
24
|
+
self.method = (method or "").upper()
|
|
25
|
+
self.path = path or "/"
|
|
26
|
+
self.query_string = query_string or ""
|
|
27
|
+
self.query = parse_query_string(self.query_string)
|
|
28
|
+
self.headers = headers or {}
|
|
29
|
+
self.body = body or b""
|
|
30
|
+
self.client = client
|
|
31
|
+
self.tls = tls or {}
|
|
32
|
+
|
|
33
|
+
def text(self, encoding="utf-8"):
|
|
34
|
+
return self.body.decode(encoding, errors="ignore")
|
|
35
|
+
|
|
36
|
+
def json(self):
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(self.text())
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Response:
|
|
44
|
+
def __init__(self, status=200, body=b"", headers=None, reason=None, stream=None):
|
|
45
|
+
self.status = status
|
|
46
|
+
self.reason = reason
|
|
47
|
+
self.stream = stream
|
|
48
|
+
self.is_stream = stream is not None
|
|
49
|
+
if self.is_stream:
|
|
50
|
+
self.body = b""
|
|
51
|
+
elif isinstance(body, (bytes, bytearray)):
|
|
52
|
+
self.body = bytes(body)
|
|
53
|
+
else:
|
|
54
|
+
self.body = str(body).encode("utf-8")
|
|
55
|
+
self.headers = headers or {}
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def json(cls, data, status=200, headers=None):
|
|
59
|
+
body = json.dumps(data).encode("utf-8")
|
|
60
|
+
hdrs = {"Content-Type": "application/json; charset=utf-8"}
|
|
61
|
+
if headers:
|
|
62
|
+
hdrs.update(headers)
|
|
63
|
+
return cls(status=status, body=body, headers=hdrs)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def text(cls, text, status=200, headers=None):
|
|
67
|
+
hdrs = {"Content-Type": "text/plain; charset=utf-8"}
|
|
68
|
+
if headers:
|
|
69
|
+
hdrs.update(headers)
|
|
70
|
+
return cls(status=status, body=text, headers=hdrs)
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def html(cls, html, status=200, headers=None):
|
|
74
|
+
hdrs = {"Content-Type": "text/html; charset=utf-8"}
|
|
75
|
+
if headers:
|
|
76
|
+
hdrs.update(headers)
|
|
77
|
+
return cls(status=status, body=html, headers=hdrs)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def stream(cls, chunks, status=200, headers=None, content_type=None):
|
|
81
|
+
hdrs = {}
|
|
82
|
+
if content_type:
|
|
83
|
+
hdrs["Content-Type"] = content_type
|
|
84
|
+
if headers:
|
|
85
|
+
hdrs.update(headers)
|
|
86
|
+
return cls(status=status, headers=hdrs, stream=chunks)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_http_request(conn, max_header_bytes=65536):
|
|
90
|
+
data = b""
|
|
91
|
+
while b"\r\n\r\n" not in data:
|
|
92
|
+
chunk = conn.recv(1024)
|
|
93
|
+
if not chunk:
|
|
94
|
+
break
|
|
95
|
+
data += chunk
|
|
96
|
+
if len(data) > int(max_header_bytes):
|
|
97
|
+
raise ValueError("Request headers too large")
|
|
98
|
+
header_text, _, remainder = data.partition(b"\r\n\r\n")
|
|
99
|
+
lines = header_text.decode("utf-8", errors="ignore").split("\r\n")
|
|
100
|
+
if not lines:
|
|
101
|
+
return None
|
|
102
|
+
request_line = lines[0]
|
|
103
|
+
parts = request_line.split()
|
|
104
|
+
if len(parts) != 3:
|
|
105
|
+
return None
|
|
106
|
+
method, path, version = parts
|
|
107
|
+
if not version.startswith("HTTP/"):
|
|
108
|
+
return None
|
|
109
|
+
headers = {}
|
|
110
|
+
for line in lines[1:]:
|
|
111
|
+
if ":" in line:
|
|
112
|
+
k, v = line.split(":", 1)
|
|
113
|
+
headers[k.strip().lower()] = v.strip()
|
|
114
|
+
return {
|
|
115
|
+
"method": method,
|
|
116
|
+
"path": path,
|
|
117
|
+
"version": version,
|
|
118
|
+
"headers": headers,
|
|
119
|
+
"remainder": remainder,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def send_http_response(conn, response):
|
|
124
|
+
status_code = response.status
|
|
125
|
+
reason = response.reason or STATUS_MESSAGES.get(status_code, "OK")
|
|
126
|
+
headers = dict(response.headers or {})
|
|
127
|
+
lowermap = {k.lower(): v for k, v in headers.items()}
|
|
128
|
+
if getattr(response, "is_stream", False):
|
|
129
|
+
if "transfer-encoding" not in lowermap and "content-length" not in lowermap:
|
|
130
|
+
headers["Transfer-Encoding"] = "chunked"
|
|
131
|
+
lowermap = {k.lower(): v for k, v in headers.items()}
|
|
132
|
+
elif "content-length" not in lowermap:
|
|
133
|
+
headers["Content-Length"] = str(len(response.body))
|
|
134
|
+
lowermap = {k.lower(): v for k, v in headers.items()}
|
|
135
|
+
if "connection" not in lowermap:
|
|
136
|
+
headers["Connection"] = "close"
|
|
137
|
+
lowermap = {k.lower(): v for k, v in headers.items()}
|
|
138
|
+
status_line = f"HTTP/1.1 {status_code} {reason}\r\n"
|
|
139
|
+
hdrs = ""
|
|
140
|
+
for k, v in headers.items():
|
|
141
|
+
hdrs += f"{k}: {v}\r\n"
|
|
142
|
+
resp = status_line + hdrs + "\r\n"
|
|
143
|
+
try:
|
|
144
|
+
conn.sendall(resp.encode("utf-8"))
|
|
145
|
+
if getattr(response, "is_stream", False):
|
|
146
|
+
use_chunked = "chunked" in lowermap.get("transfer-encoding", "").lower()
|
|
147
|
+
for chunk in _iter_stream_chunks(response.stream):
|
|
148
|
+
if use_chunked:
|
|
149
|
+
conn.sendall(f"{len(chunk):X}\r\n".encode("utf-8"))
|
|
150
|
+
conn.sendall(chunk)
|
|
151
|
+
conn.sendall(b"\r\n")
|
|
152
|
+
else:
|
|
153
|
+
conn.sendall(chunk)
|
|
154
|
+
if use_chunked:
|
|
155
|
+
conn.sendall(b"0\r\n\r\n")
|
|
156
|
+
else:
|
|
157
|
+
conn.sendall(response.body)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
print(f"[http] send error {status_code}: {e}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _iter_stream_chunks(source):
|
|
163
|
+
if source is None:
|
|
164
|
+
return
|
|
165
|
+
if isinstance(source, (bytes, bytearray, str)):
|
|
166
|
+
normalized = _normalize_chunk(source)
|
|
167
|
+
if normalized:
|
|
168
|
+
yield normalized
|
|
169
|
+
return
|
|
170
|
+
if hasattr(source, "read") and callable(source.read):
|
|
171
|
+
while True:
|
|
172
|
+
chunk = source.read(8192)
|
|
173
|
+
if not chunk:
|
|
174
|
+
break
|
|
175
|
+
normalized = _normalize_chunk(chunk)
|
|
176
|
+
if normalized:
|
|
177
|
+
yield normalized
|
|
178
|
+
return
|
|
179
|
+
for chunk in source:
|
|
180
|
+
normalized = _normalize_chunk(chunk)
|
|
181
|
+
if normalized:
|
|
182
|
+
yield normalized
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _normalize_chunk(chunk):
|
|
186
|
+
if chunk is None:
|
|
187
|
+
return b""
|
|
188
|
+
if isinstance(chunk, (bytes, bytearray)):
|
|
189
|
+
return bytes(chunk)
|
|
190
|
+
return str(chunk).encode("utf-8")
|