ssserve 0.1.0__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.
ssserve-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: ssserve
3
+ Version: 0.1.0
4
+ Summary: Static file serving and directory listing — Python port of vercel/serve
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: click>=8.0
@@ -0,0 +1,148 @@
1
+ # ssserve
2
+
3
+ Python port of [vercel/serve](https://github.com/vercel/serve) — static file serving and directory listing. (vibecoded)
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ uv tool install ssserve
9
+ ```
10
+
11
+ Or run directly without installing:
12
+
13
+ ```bash
14
+ uv run --from ssserve ssserve
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ ssserve [path] [options]
21
+ ```
22
+
23
+ Defaults to current directory and port 3000.
24
+
25
+ ### Options
26
+
27
+ | Flag | Description |
28
+ |---|---|
29
+ | `-l, --listen URI` | Listen endpoint (default: `tcp://0.0.0.0:3000`, multi-allowed) |
30
+ | `-s, --single` | SPA mode — rewrite 404s to `index.html` |
31
+ | `-C, --cors` | Enable CORS headers |
32
+ | `-u, --no-compression` | Disable gzip compression |
33
+ | `-S, --symlinks` | Resolve symlinks |
34
+ | `-L, --no-request-logging` | Disable request logging |
35
+ | `-c, --config PATH` | Path to `serve.json` |
36
+ | `--no-etag` | Disable ETag (use `Last-Modified`) |
37
+ | `--ssl-cert FILE` | SSL certificate (PEM) |
38
+ | `--ssl-key FILE` | SSL private key (PEM) |
39
+ | `--ssl-pass FILE` | SSL passphrase file |
40
+ | `--no-port-switching` | Don't auto-switch if port is taken |
41
+ | `-d, --debug` | Debug output |
42
+ | `--version` | Show version |
43
+ | `--help` | Show help |
44
+
45
+ ### Examples
46
+
47
+ ```bash
48
+ # Serve current directory on port 3000
49
+ ssserve
50
+
51
+ # Serve specific directory on port 5000 with CORS
52
+ ssserve -l 5000 -C ./my-site
53
+
54
+ # Serve with HTTPS
55
+ ssserve --ssl-cert cert.pem --ssl-key key.pem
56
+
57
+ # SPA mode for React/Vue apps
58
+ ssserve -s dist
59
+
60
+ # Multiple listeners
61
+ ssserve -l tcp://0.0.0.0:3000 -l unix:/tmp/serve.sock
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ Create a `serve.json` file in the served directory:
67
+
68
+ ```json
69
+ {
70
+ "public": "_site",
71
+ "cleanUrls": true,
72
+ "rewrites": [
73
+ { "source": "/api/**", "destination": "/index.html" }
74
+ ],
75
+ "redirects": [
76
+ { "source": "/old", "destination": "/new", "type": 302 }
77
+ ],
78
+ "headers": [
79
+ {
80
+ "source": "**/*.@(jpg|png)",
81
+ "headers": [
82
+ { "key": "Cache-Control", "value": "max-age=7200" }
83
+ ]
84
+ }
85
+ ],
86
+ "directoryListing": false,
87
+ "trailingSlash": true,
88
+ "etag": true,
89
+ "symlinks": false,
90
+ "renderSingle": false
91
+ }
92
+ ```
93
+
94
+ ## Testing
95
+
96
+ E2E tests verify behaviour, performance, and resource consumption against a live server process.
97
+
98
+ ```bash
99
+ # Run all tests
100
+ uv run pytest tests/ -v
101
+
102
+ # Run only e2e tests
103
+ uv run pytest tests/e2e/ -v
104
+ ```
105
+
106
+ ### Test layout
107
+
108
+ | File | Tests | What it covers |
109
+ |---|---|---|
110
+ | `tests/e2e/test_behavior.py` | 47 | File serving, directory listing, clean URLs, redirects, rewrites, CORS, gzip, ETag/304, Range requests, SPA mode, custom headers, symlinks, path traversal |
111
+ | `tests/e2e/test_performance.py` | 8 | Latency (p50/p95/p99), TTFB, gzip vs raw, concurrent throughput — measure-only |
112
+ | `tests/e2e/test_resources.py` | 7 | RSS memory, CPU usage, file descriptor count — measure-only |
113
+
114
+ Dependencies: `pytest`, `psutil` (see `[dependency-groups]` in `pyproject.toml`).
115
+
116
+ Tests run automatically on CI via `.github/workflows/tests.yml`.
117
+
118
+ ## Benchmarks
119
+
120
+ Micro-benchmarks using [airspeed velocity](https://asv.readthedocs.io/) track performance of core operations across commits:
121
+
122
+ | Benchmark | What it measures |
123
+ |---|---|
124
+ | `TimeRouteToRegex` | Pattern compilation for route matching |
125
+ | `TimeMatchGlob` | Glob matching and exclusion lists |
126
+ | `TimeParseByteRange` | HTTP Range header parsing |
127
+ | `TimeFormatSize` | File size formatting |
128
+ | `TimeFormatDate` | Date formatting |
129
+ | `TimeParseListen` | Listen URI parsing |
130
+ | `TimeMergeConfig` | Config merging from `serve.json` |
131
+ | `TimeRenderListing` | HTML directory listing rendering (100 files) |
132
+
133
+ ```bash
134
+ # Validate benchmarks
135
+ uv run asv check --python=same
136
+
137
+ # Quick run
138
+ uv run asv run --python=same --quick
139
+
140
+ # Full run across configured pythons
141
+ uv run asv run
142
+ ```
143
+
144
+ Benchmarks run automatically on CI via `.github/workflows/benchmarks.yml`.
145
+
146
+ ## License
147
+
148
+ MIT
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "ssserve"
3
+ version = "0.1.0"
4
+ description = "Static file serving and directory listing — Python port of vercel/serve"
5
+ requires-python = ">=3.10"
6
+ dependencies = ["click>=8.0"]
7
+ license = { text = "MIT" }
8
+
9
+ [project.scripts]
10
+ ssserve = "ssserve.cli:main"
11
+
12
+ [build-system]
13
+ requires = ["setuptools>=64"]
14
+ build-backend = "setuptools.build_meta"
15
+
16
+ [tool.setuptools]
17
+ packages = ["ssserve"]
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=8",
22
+ "psutil>=6",
23
+ "asv>=0.6.5",
24
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from ssserve.cli import main
2
+
3
+ main()
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import socket
5
+ import ssl
6
+ import socketserver
7
+ import sys
8
+ import time
9
+ from http.server import HTTPServer
10
+
11
+ import click
12
+
13
+ from ssserve import __version__
14
+ from ssserve.config import load_config
15
+ from ssserve.handler import ServeHandler
16
+ from ssserve.network import Address, find_free_port, get_lan_ip, parse_listen
17
+
18
+
19
+ def _create_server(
20
+ addr: Address,
21
+ handler_class: type,
22
+ ssl_cert: str | None = None,
23
+ ssl_key: str | None = None,
24
+ ssl_pass: str | None = None,
25
+ ) -> HTTPServer:
26
+ if addr.scheme == "unix":
27
+ if os.path.exists(addr.path):
28
+ os.unlink(addr.path)
29
+ server = HTTPServer(addr.path, handler_class)
30
+ server.server_address = addr.path
31
+ else:
32
+ host = addr.host or "0.0.0.0"
33
+ server = HTTPServer((host, addr.port), handler_class)
34
+ server.server_address = (host, addr.port)
35
+
36
+ if ssl_cert and ssl_key:
37
+ passphrase = None
38
+ if ssl_pass:
39
+ with open(ssl_pass) as f:
40
+ passphrase = f.read().strip()
41
+ ctx = ssl.SSLContext(ssl.Purpose.CLIENT_AUTH)
42
+ ctx.load_cert_chain(ssl_cert, ssl_key, passphrase if passphrase else None)
43
+ server.socket = ctx.wrap_socket(server.socket, server_side=True)
44
+
45
+ return server
46
+
47
+
48
+ def _print_startup(
49
+ addr: Address,
50
+ cors: bool,
51
+ ssl_active: bool,
52
+ no_port_switching: bool,
53
+ no_compression: bool,
54
+ port_switched: bool = False,
55
+ ) -> None:
56
+ scheme = "https" if ssl_active else "http"
57
+ host = addr.host or "0.0.0.0"
58
+
59
+ click.echo("")
60
+ click.echo(f" ssserve v{__version__}")
61
+ click.echo("")
62
+
63
+ if addr.scheme == "unix":
64
+ click.echo(f" ➜ Local: unix:{addr.path}")
65
+ else:
66
+ local_url = f" ➜ Local: {scheme}://localhost:{addr.port}"
67
+ if port_switched:
68
+ local_url += f" (port {addr.port} was in use, switched)"
69
+ click.echo(local_url)
70
+
71
+ lan_ip = get_lan_ip()
72
+ if lan_ip:
73
+ click.echo(f" ➜ Network: {scheme}://{lan_ip}:{addr.port}")
74
+
75
+ click.echo("")
76
+
77
+ if cors:
78
+ click.echo(" ➜ CORS enabled")
79
+ if ssl_active:
80
+ click.echo(" ➜ SSL enabled")
81
+ if not no_compression:
82
+ click.echo(" ➜ Compression enabled (gzip)")
83
+
84
+ click.echo("")
85
+
86
+
87
+ @click.command()
88
+ @click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=".", required=False)
89
+ @click.option("-l", "--listen", type=str, multiple=True, default=["tcp://0.0.0.0:3000"], help="Listen endpoint")
90
+ @click.option("-s", "--single", is_flag=True, help="Single page application mode (rewrite 404 to index.html)")
91
+ @click.option("-d", "--debug", is_flag=True, help="Show debugging information")
92
+ @click.option("-c", "--config", type=click.Path(exists=True, dir_okay=False), help="Path to serve.json config")
93
+ @click.option("-L", "--no-request-logging", is_flag=True, help="Disable request logging")
94
+ @click.option("-C", "--cors", is_flag=True, help="Enable CORS")
95
+ @click.option("-u", "--no-compression", is_flag=True, help="Disable compression")
96
+ @click.option("--no-etag", is_flag=True, help="Disable ETag (use Last-Modified)")
97
+ @click.option("-S", "--symlinks", is_flag=True, help="Resolve symlinks")
98
+ @click.option("--ssl-cert", type=click.Path(exists=True, dir_okay=False), help="SSL/TLS certificate (PEM)")
99
+ @click.option("--ssl-key", type=click.Path(exists=True, dir_okay=False), help="SSL/TLS private key (PEM)")
100
+ @click.option("--ssl-pass", type=click.Path(exists=True, dir_okay=False), help="SSL/TLS passphrase file")
101
+ @click.option("--no-port-switching", is_flag=True, help="Don't switch to another port when port is taken")
102
+ @click.version_option(version=__version__, prog_name="ssserve")
103
+ def main(
104
+ path: str,
105
+ listen: tuple[str, ...],
106
+ single: bool,
107
+ debug: bool,
108
+ config: str | None,
109
+ no_request_logging: bool,
110
+ cors: bool,
111
+ no_compression: bool,
112
+ no_etag: bool,
113
+ symlinks: bool,
114
+ ssl_cert: str | None,
115
+ ssl_key: str | None,
116
+ ssl_pass: str | None,
117
+ no_port_switching: bool,
118
+ ) -> None:
119
+ root_dir = os.path.abspath(path) if path else os.getcwd()
120
+
121
+ if not os.path.isdir(root_dir):
122
+ click.echo(f"Error: {path} is not a directory", err=True)
123
+ sys.exit(1)
124
+
125
+ cfg = load_config(config, root_dir)
126
+
127
+ if ssl_cert and not ssl_key:
128
+ click.echo("Error: --ssl-key is required when --ssl-cert is provided", err=True)
129
+ sys.exit(1)
130
+
131
+ if no_etag:
132
+ cfg.etag = False
133
+
134
+ if symlinks:
135
+ cfg.symlinks = True
136
+
137
+ ServeHandler.config = cfg
138
+ ServeHandler.cors = cors
139
+ ServeHandler.single = single
140
+ ServeHandler.debug = debug
141
+ ServeHandler.logging_enabled = not no_request_logging
142
+ ServeHandler.no_compression = no_compression
143
+ ServeHandler.no_port_switching = no_port_switching
144
+ ServeHandler.root_dir = root_dir
145
+
146
+ listeners = []
147
+ for listen_val in listen:
148
+ addr = parse_listen(listen_val)
149
+ port_switched = False
150
+
151
+ if addr.scheme == "tcp" and not no_port_switching:
152
+ try:
153
+ with socket.create_connection(("localhost", addr.port), timeout=0.5):
154
+ new_port = find_free_port(addr.port + 1)
155
+ click.echo(f" Port {addr.port} is in use, using port {new_port} instead", err=True)
156
+ addr.port = new_port
157
+ port_switched = True
158
+ except (ConnectionRefusedError, OSError, socket.timeout):
159
+ pass
160
+
161
+ listeners.append((addr, port_switched))
162
+
163
+ ssl_active = ssl_cert is not None and ssl_key is not None
164
+
165
+ if len(listeners) == 1:
166
+ addr, port_switched = listeners[0]
167
+ _print_startup(addr, cors, ssl_active, no_port_switching, no_compression, port_switched)
168
+ server = _create_server(addr, ServeHandler, ssl_cert, ssl_key, ssl_pass)
169
+ try:
170
+ server.serve_forever()
171
+ except KeyboardInterrupt:
172
+ click.echo("\n Shutting down...")
173
+ server.shutdown()
174
+ else:
175
+ servers = []
176
+ for addr, port_switched in listeners:
177
+ _print_startup(addr, cors, ssl_active, no_port_switching, no_compression, port_switched)
178
+ server = _create_server(addr, ServeHandler, ssl_cert, ssl_key, ssl_pass)
179
+ servers.append(server)
180
+
181
+ click.echo(f" Serving {len(servers)} listeners")
182
+ click.echo("")
183
+
184
+ try:
185
+ for server in servers:
186
+ import threading
187
+ t = threading.Thread(target=server.serve_forever, daemon=True)
188
+ t.start()
189
+ while True:
190
+ time.sleep(3600)
191
+ except KeyboardInterrupt:
192
+ click.echo("\n Shutting down...")
193
+ for server in servers:
194
+ server.shutdown()
195
+
196
+
197
+ if __name__ == "__main__":
198
+ main()
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class Rewrite:
11
+ source: str
12
+ destination: str
13
+
14
+
15
+ @dataclass
16
+ class Redirect:
17
+ source: str
18
+ destination: str
19
+ type: int = 301
20
+
21
+
22
+ @dataclass
23
+ class HeaderRule:
24
+ source: str
25
+ headers: list[dict[str, str | None]]
26
+
27
+
28
+ @dataclass
29
+ class Config:
30
+ public: str | None = None
31
+ clean_urls: bool | list[str] = True
32
+ rewrites: list[Rewrite] = field(default_factory=list)
33
+ redirects: list[Redirect] = field(default_factory=list)
34
+ headers: list[HeaderRule] = field(default_factory=list)
35
+ directory_listing: bool | list[str] = True
36
+ unlisted: list[str] = field(default_factory=lambda: [".DS_Store", ".git"])
37
+ trailing_slash: bool | None = None
38
+ render_single: bool = False
39
+ symlinks: bool = False
40
+ etag: bool = False
41
+
42
+ @classmethod
43
+ def defaults(cls) -> Config:
44
+ return cls()
45
+
46
+
47
+ def merge_config(base: Config, overrides: dict) -> Config:
48
+ kw = {}
49
+ for key, val in overrides.items():
50
+ match key:
51
+ case "public":
52
+ kw["public"] = str(val) if val else None
53
+ case "cleanUrls":
54
+ if isinstance(val, bool):
55
+ kw["clean_urls"] = val
56
+ elif isinstance(val, list):
57
+ kw["clean_urls"] = val
58
+ case "rewrites":
59
+ kw["rewrites"] = [Rewrite(**r) for r in val]
60
+ case "redirects":
61
+ kw["redirects"] = [Redirect(**r) for r in val]
62
+ case "headers":
63
+ kw["headers"] = [HeaderRule(**h) for h in val]
64
+ case "directoryListing":
65
+ if isinstance(val, bool):
66
+ kw["directory_listing"] = val
67
+ elif isinstance(val, list):
68
+ kw["directory_listing"] = val
69
+ case "unlisted":
70
+ kw["unlisted"] = list(val)
71
+ case "trailingSlash":
72
+ kw["trailing_slash"] = bool(val)
73
+ case "renderSingle":
74
+ kw["render_single"] = bool(val)
75
+ case "symlinks":
76
+ kw["symlinks"] = bool(val)
77
+ case "etag":
78
+ kw["etag"] = bool(val)
79
+ return Config(**kw)
80
+
81
+
82
+ def load_config(path: str | None, base_dir: str | None = None) -> Config:
83
+ if path:
84
+ config_path = Path(path)
85
+ else:
86
+ search_dir = base_dir or os.getcwd()
87
+ for name in ("serve.json",):
88
+ p = Path(search_dir) / name
89
+ if p.exists():
90
+ config_path = p
91
+ break
92
+ else:
93
+ return Config.defaults()
94
+
95
+ if not config_path.exists():
96
+ return Config.defaults()
97
+
98
+ with open(config_path) as f:
99
+ data = json.load(f)
100
+
101
+ return merge_config(Config.defaults(), data)
@@ -0,0 +1,466 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import hashlib
5
+ import html
6
+ import io
7
+ import mimetypes
8
+ import os
9
+ import re
10
+ import time
11
+ import urllib.parse
12
+ from email.utils import formatdate, parsedate
13
+ from fnmatch import fnmatch
14
+ from http import HTTPStatus
15
+ from http.server import BaseHTTPRequestHandler
16
+
17
+ from ssserve.config import Config
18
+ from ssserve.listing import render_listing
19
+
20
+
21
+ def _route_to_regex(pattern: str) -> re.Pattern:
22
+ parts = []
23
+ for segment in pattern.split("/"):
24
+ if segment.startswith(":"):
25
+ parts.append(f"(?P<{segment[1:]}>[^/]+)")
26
+ elif "*" in segment:
27
+ parts.append(re.escape(segment).replace(r"\*\*", ".*").replace(r"\*", "[^/]*"))
28
+ else:
29
+ parts.append(re.escape(segment))
30
+ return re.compile(f"^{'/'.join(parts)}$")
31
+
32
+
33
+ def _apply_segments(template: str, groups: dict[str, str]) -> str:
34
+ result = template
35
+ for key, val in groups.items():
36
+ result = result.replace(f":{key}", val)
37
+ return result
38
+
39
+
40
+ def _match_glob(path: str, pattern: str) -> bool:
41
+ if pattern.startswith("!"):
42
+ return not fnmatch(path, pattern[1:])
43
+ return fnmatch(path, pattern)
44
+
45
+
46
+ def _match_glob_list(path: str, patterns: list[str]) -> bool:
47
+ result = False
48
+ for p in patterns:
49
+ if p.startswith("!"):
50
+ if fnmatch(path, p[1:]):
51
+ return False
52
+ else:
53
+ if fnmatch(path, p):
54
+ result = True
55
+ return result
56
+
57
+
58
+ def _parse_byte_range(range_header: str, file_size: int) -> tuple[int, int] | None:
59
+ if not range_header or not range_header.startswith("bytes="):
60
+ return None
61
+ parts = range_header[6:].split("-", 1)
62
+ if len(parts) != 2:
63
+ return None
64
+ start_str, end_str = parts
65
+ try:
66
+ if start_str == "":
67
+ start = file_size - int(end_str)
68
+ end = file_size - 1
69
+ else:
70
+ start = int(start_str)
71
+ end = int(end_str) if end_str else file_size - 1
72
+ if start < 0 or end >= file_size or start > end:
73
+ return None
74
+ return start, end
75
+ except ValueError:
76
+ return None
77
+
78
+
79
+ class ServeHandler(BaseHTTPRequestHandler):
80
+ server_version = "ssserve/0.1.0"
81
+ default_request_version = "HTTP/1.1"
82
+
83
+ config: Config = Config.defaults()
84
+ cors: bool = False
85
+ single: bool = False
86
+ debug: bool = False
87
+ logging_enabled: bool = True
88
+ no_compression: bool = False
89
+ no_port_switching: bool = False
90
+ root_dir: str = os.getcwd()
91
+
92
+ def log_message(self, format: str, *args) -> None:
93
+ if self.logging_enabled:
94
+ super().log_message(format, *args)
95
+
96
+ def _log_request(self, status: int, size: int = 0) -> None:
97
+ if not self.logging_enabled:
98
+ return
99
+ self.log_message('"%s %s %s" %d %d', self.command, self.path, self.request_version, status, size)
100
+
101
+ def _send_redirect(self, location: str, status: int = 301) -> None:
102
+ self.send_response(status)
103
+ self.send_header("Location", location)
104
+ self.send_header("Content-Length", "0")
105
+ self._apply_common_headers()
106
+ self.end_headers()
107
+ self._log_request(status)
108
+
109
+ def _send_error_page(self, status: int, message: str = "") -> None:
110
+ status_code = int(status)
111
+ error_page = os.path.join(self.root_dir, f"{status_code}.html")
112
+ content = f"<h1>{status_code} {HTTPStatus(status_code).phrase}</h1>"
113
+ if os.path.isfile(error_page):
114
+ with open(error_page, "rb") as f:
115
+ body = f.read()
116
+ content = body.decode("utf-8", errors="replace")
117
+ else:
118
+ content = f"<!doctype html><html><head><meta charset='utf-8'><title>{status_code}</title><style>body{{font-family:sans-serif;padding:40px;text-align:center}}h1{{font-weight:400;color:#333}}p{{color:#666}}</style></head><body><h1>{status_code}</h1><p>{html.escape(message)}</p></body></html>"
119
+ if status_code == 404:
120
+ content = f"<!doctype html><html><head><meta charset='utf-8'><title>404 Not Found</title><style>body{{font-family:sans-serif;padding:40px;text-align:center}}h1{{font-weight:400;color:#333}}</style></head><body><h1>404</h1><p>Not Found</p></body></html>"
121
+
122
+ body_bytes = content.encode("utf-8")
123
+ self.send_response(status)
124
+ self.send_header("Content-Type", "text/html; charset=utf-8")
125
+ self.send_header("Content-Length", str(len(body_bytes)))
126
+ self._apply_common_headers()
127
+ self.end_headers()
128
+ self.wfile.write(body_bytes)
129
+ self._log_request(status, len(body_bytes))
130
+
131
+ def _apply_common_headers(self) -> None:
132
+ if self.cors:
133
+ self.send_header("Access-Control-Allow-Origin", "*")
134
+ self.send_header("Access-Control-Allow-Headers", "*")
135
+ self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
136
+ self.send_header("Access-Control-Allow-Credentials", "true")
137
+
138
+ def _get_mime(self, path: str) -> str:
139
+ mime, _ = mimetypes.guess_type(path)
140
+ return mime or "application/octet-stream"
141
+
142
+ def _normalize_path(self, url_path: str) -> str:
143
+ parsed = urllib.parse.urlsplit(url_path)
144
+ path = urllib.parse.unquote(parsed.path)
145
+ has_trailing_slash = path.endswith("/") and path != "/"
146
+ path = os.path.normpath(path).replace("\\", "/")
147
+ if not path.startswith("/"):
148
+ path = "/" + path
149
+ if has_trailing_slash and not path.endswith("/"):
150
+ path += "/"
151
+ return path
152
+
153
+ def _resolve_path(self, url_path: str) -> str | None:
154
+ normalized = self._normalize_path(url_path)
155
+ if self.config.public:
156
+ base = os.path.join(self.root_dir, self.config.public)
157
+ else:
158
+ base = self.root_dir
159
+ fs_path = os.path.normpath(os.path.join(base, normalized.lstrip("/")))
160
+
161
+ if not fs_path.startswith(os.path.normpath(base)):
162
+ return None
163
+
164
+ if not self.config.symlinks and os.path.islink(fs_path):
165
+ return None
166
+
167
+ if not os.path.exists(fs_path):
168
+ return None
169
+
170
+ return fs_path
171
+
172
+ def _check_clean_urls(self, url_path: str) -> str | None:
173
+ if isinstance(self.config.clean_urls, bool) and not self.config.clean_urls:
174
+ return None
175
+ if url_path.endswith(".html"):
176
+ stripped = url_path[: -len(".html")] if url_path != "/index.html" else "/"
177
+ if stripped == "":
178
+ stripped = "/"
179
+ fs_path = self._resolve_with_clean_urls(stripped)
180
+ if fs_path:
181
+ return stripped
182
+ return None
183
+ return None
184
+
185
+ def _resolve_with_clean_urls(self, url_path: str) -> str | None:
186
+ fs_path = self._resolve_path(url_path)
187
+ if fs_path:
188
+ return fs_path
189
+ if self.config.clean_urls is not False:
190
+ html_path = url_path.rstrip("/") + ".html"
191
+ if url_path == "/":
192
+ html_path = "/index.html"
193
+ fs_path = self._resolve_path(html_path)
194
+ if fs_path and os.path.isfile(fs_path):
195
+ return fs_path
196
+ return None
197
+
198
+ def _check_trailing_slash(self, url_path: str) -> str | None:
199
+ if self.config.trailing_slash is None:
200
+ return None
201
+ fs_path = self._resolve_path(url_path)
202
+ if fs_path and os.path.isdir(fs_path):
203
+ if not url_path.endswith("/") and self.config.trailing_slash:
204
+ return url_path + "/"
205
+ if url_path.endswith("/") and not self.config.trailing_slash:
206
+ return url_path.rstrip("/") or "/"
207
+ if fs_path and os.path.isfile(fs_path):
208
+ if url_path.endswith("/") and not self.config.trailing_slash:
209
+ return url_path.rstrip("/") or "/"
210
+ return None
211
+
212
+ def _check_redirects(self, url_path: str) -> tuple[str, int] | None:
213
+ for rule in self.config.redirects:
214
+ regex = _route_to_regex(rule.source)
215
+ m = regex.match(url_path)
216
+ if m:
217
+ dest = _apply_segments(rule.destination, m.groupdict())
218
+ return dest, rule.type
219
+ return None
220
+
221
+ def _check_rewrites(self, url_path: str) -> str | None:
222
+ for rule in self.config.rewrites:
223
+ regex = _route_to_regex(rule.source)
224
+ m = regex.match(url_path)
225
+ if m:
226
+ return _apply_segments(rule.destination, m.groupdict())
227
+ return None
228
+
229
+ def _get_custom_headers(self, url_path: str) -> list[tuple[str, str | None]]:
230
+ result = []
231
+ for rule in self.config.headers:
232
+ if _match_glob(url_path, rule.source):
233
+ for h in rule.headers:
234
+ result.append((h["key"], h.get("value")))
235
+ return result
236
+
237
+ def _is_listing_allowed(self, url_path: str) -> bool:
238
+ val = self.config.directory_listing
239
+ if isinstance(val, bool):
240
+ return val
241
+ return _match_glob_list(url_path, val)
242
+
243
+ def _is_unlisted(self, name: str) -> bool:
244
+ return _match_glob_list(name, self.config.unlisted)
245
+
246
+ def _serve_file(self, fs_path: str, url_path: str, byte_range: tuple[int, int] | None = None) -> None:
247
+ try:
248
+ stat = os.stat(fs_path)
249
+ file_size = stat.st_size
250
+ mtime = stat.st_mtime
251
+ except OSError:
252
+ self._send_error_page(500)
253
+ return
254
+
255
+ mime = self._get_mime(fs_path)
256
+ etag_val = None
257
+ if self.config.etag:
258
+ hasher = hashlib.sha256()
259
+ try:
260
+ with open(fs_path, "rb") as f:
261
+ for chunk in iter(lambda: f.read(65536), b""):
262
+ hasher.update(chunk)
263
+ etag_val = f'"{hasher.hexdigest()}"'
264
+ except OSError:
265
+ etag_val = None
266
+
267
+ if byte_range:
268
+ status = HTTPStatus.PARTIAL_CONTENT
269
+ start, end = byte_range
270
+ content_length = end - start + 1
271
+ else:
272
+ status = HTTPStatus.OK
273
+ content_length = file_size
274
+
275
+ if etag_val:
276
+ if_none_match = self.headers.get("If-None-Match")
277
+ if if_none_match and if_none_match.strip('" ') == etag_val.strip('" '):
278
+ self.send_response(HTTPStatus.NOT_MODIFIED)
279
+ self._apply_common_headers()
280
+ if etag_val:
281
+ self.send_header("ETag", etag_val)
282
+ self.end_headers()
283
+ self._log_request(304)
284
+ return
285
+ else:
286
+ ims = self.headers.get("If-Modified-Since")
287
+ if ims:
288
+ try:
289
+ ims_time = time.mktime(parsedate(ims))
290
+ if int(mtime) <= int(ims_time):
291
+ self.send_response(HTTPStatus.NOT_MODIFIED)
292
+ self._apply_common_headers()
293
+ self.end_headers()
294
+ self._log_request(304)
295
+ return
296
+ except (TypeError, OSError):
297
+ pass
298
+
299
+ accept_encoding = self.headers.get("Accept-Encoding", "")
300
+ use_gzip = (
301
+ not self.no_compression
302
+ and "gzip" in accept_encoding
303
+ )
304
+
305
+ gzipped_data = None
306
+ if use_gzip and not byte_range:
307
+ try:
308
+ with open(fs_path, "rb") as f:
309
+ raw = f.read()
310
+ gzipped_data = gzip.compress(raw)
311
+ content_length = len(gzipped_data)
312
+ except OSError:
313
+ gzipped_data = None
314
+ else:
315
+ gzipped_data = None
316
+
317
+ self.send_response(status)
318
+ self.send_header("Content-Type", mime)
319
+ if content_length is not None:
320
+ self.send_header("Content-Length", str(content_length))
321
+ if etag_val:
322
+ self.send_header("ETag", etag_val)
323
+ else:
324
+ self.send_header("Last-Modified", formatdate(mtime, usegmt=True))
325
+ if byte_range:
326
+ self.send_header("Content-Range", f"bytes {byte_range[0]}-{byte_range[1]}/{file_size}")
327
+ if not self.config.etag:
328
+ self.send_header("Accept-Ranges", "bytes")
329
+ if gzipped_data is not None:
330
+ self.send_header("Content-Encoding", "gzip")
331
+
332
+ for key, val in self._get_custom_headers(url_path):
333
+ if val is None:
334
+ self.send_header(key, "")
335
+ else:
336
+ self.send_header(key, val)
337
+
338
+ self._apply_common_headers()
339
+ self.end_headers()
340
+
341
+ if self.command == "HEAD":
342
+ self._log_request(status, file_size)
343
+ return
344
+
345
+ try:
346
+ if gzipped_data is not None:
347
+ self.wfile.write(gzipped_data)
348
+ elif byte_range:
349
+ with open(fs_path, "rb") as f:
350
+ f.seek(byte_range[0])
351
+ remaining = byte_range[1] - byte_range[0] + 1
352
+ while remaining > 0:
353
+ chunk_size = min(65536, remaining)
354
+ data = f.read(chunk_size)
355
+ if not data:
356
+ break
357
+ self.wfile.write(data)
358
+ remaining -= len(data)
359
+ else:
360
+ with open(fs_path, "rb") as f:
361
+ self.copyfile(f, self.wfile)
362
+ except OSError:
363
+ pass
364
+
365
+ self._log_request(status, file_size)
366
+
367
+ def _handle_request(self) -> None:
368
+ url_path = self._normalize_path(self.path)
369
+
370
+ if url_path != self.path:
371
+ self._send_redirect(url_path, 301)
372
+ return
373
+
374
+ cu_result = self._check_clean_urls(url_path)
375
+ if cu_result:
376
+ self._send_redirect(cu_result, 301)
377
+ return
378
+
379
+ ts_result = self._check_trailing_slash(url_path)
380
+ if ts_result:
381
+ self._send_redirect(ts_result, 301)
382
+ return
383
+
384
+ redirect = self._check_redirects(url_path)
385
+ if redirect:
386
+ dest, status = redirect
387
+ self._send_redirect(dest, status)
388
+ return
389
+
390
+ rewritten = self._check_rewrites(url_path)
391
+ if rewritten:
392
+ url_path = rewritten
393
+
394
+ fs_path = self._resolve_with_clean_urls(url_path)
395
+
396
+ if fs_path and os.path.isdir(fs_path):
397
+ for index in ("index.html", "index.htm"):
398
+ index_path = os.path.join(fs_path, index)
399
+ if os.path.isfile(index_path):
400
+ self._serve_file(index_path, url_path.rstrip("/") + "/" + index)
401
+ return
402
+
403
+ if self.config.render_single:
404
+ entries = [e for e in os.scandir(fs_path) if not self._is_unlisted(e.name)]
405
+ if len(entries) == 1:
406
+ entry = entries[0]
407
+ file_path = os.path.join(fs_path, entry.name)
408
+ if entry.is_file():
409
+ sub_url = url_path.rstrip("/") + "/" + entry.name
410
+ self._serve_file(file_path, sub_url)
411
+ return
412
+
413
+ if self._is_listing_allowed(url_path):
414
+ try:
415
+ entries = sorted(os.scandir(fs_path), key=lambda e: (not e.is_dir(), e.name.lower()))
416
+ entries = [e for e in entries if not self._is_unlisted(e.name)]
417
+ if not url_path.endswith("/"):
418
+ self._send_redirect(url_path + "/", 301)
419
+ return
420
+ html = render_listing(url_path, fs_path, entries)
421
+ body = html.encode("utf-8")
422
+ self.send_response(HTTPStatus.OK)
423
+ self.send_header("Content-Type", "text/html; charset=utf-8")
424
+ self.send_header("Content-Length", str(len(body)))
425
+ self._apply_common_headers()
426
+ self.end_headers()
427
+ if self.command != "HEAD":
428
+ self.wfile.write(body)
429
+ self._log_request(200, len(body))
430
+ return
431
+ except OSError:
432
+ pass
433
+
434
+ self._send_error_page(404)
435
+ return
436
+
437
+ if fs_path and os.path.isfile(fs_path):
438
+ range_header = self.headers.get("Range", "")
439
+ byte_range = _parse_byte_range(range_header, os.path.getsize(fs_path))
440
+ self._serve_file(fs_path, url_path, byte_range)
441
+ return
442
+
443
+ if self.single:
444
+ index_path = os.path.join(self.root_dir, "index.html")
445
+ if os.path.isfile(index_path):
446
+ self._serve_file(index_path, "/index.html")
447
+ return
448
+
449
+ self._send_error_page(404)
450
+
451
+ def do_GET(self) -> None:
452
+ self._handle_request()
453
+
454
+ def do_HEAD(self) -> None:
455
+ self._handle_request()
456
+
457
+ def do_OPTIONS(self) -> None:
458
+ self.send_response(HTTPStatus.NO_CONTENT)
459
+ self._apply_common_headers()
460
+ self.end_headers()
461
+
462
+ def copyfile(self, source: io.IOBase, output: io.IOBase) -> None:
463
+ buf = source.read(65536)
464
+ while buf:
465
+ output.write(buf)
466
+ buf = source.read(65536)
@@ -0,0 +1,111 @@
1
+ import html
2
+ import math
3
+ import os
4
+ import time
5
+
6
+
7
+ def format_size(size: int) -> str:
8
+ if size == 0:
9
+ return "0 B"
10
+ units = ["B", "KB", "MB", "GB", "TB"]
11
+ i = int(math.floor(math.log(size, 1024))) if size > 0 else 0
12
+ i = min(i, len(units) - 1)
13
+ s = size / (1024**i)
14
+ return f"{s:.1f} {units[i]}" if i > 0 else f"{s:.0f} B"
15
+
16
+
17
+ def format_date(timestamp: float) -> str:
18
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(timestamp))
19
+
20
+
21
+ LISTING_TEMPLATE = """\
22
+ <!doctype html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="utf-8">
26
+ <title>Index of {path}</title>
27
+ <style>
28
+ *{{margin:0;padding:0;box-sizing:border-box}}
29
+ body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;font-size:14px;color:#333;background:#fafafa;padding:20px}}
30
+ h1{{font-weight:500;font-size:18px;margin-bottom:16px;color:#111}}
31
+ table{{width:100%;border-collapse:collapse}}
32
+ th{{text-align:left;font-weight:500;font-size:12px;text-transform:uppercase;color:#888;padding:8px 12px;border-bottom:2px solid #eaeaea}}
33
+ td{{padding:8px 12px;border-bottom:1px solid #eee}}
34
+ tr:hover td{{background:#f5f5f5}}
35
+ a{{color:#067df7;text-decoration:none}}
36
+ a:hover{{text-decoration:underline}}
37
+ .icon{{width:20px;display:inline-block;text-align:center;margin-right:8px;color:#888}}
38
+ .size{{text-align:right;font-variant-numeric:tabular-nums;color:#666}}
39
+ .date{{color:#888}}
40
+ .folder .icon{{color:#f5a623}}
41
+ .file .icon{{color:#666}}
42
+ .parent{{font-weight:500}}
43
+ .footer{{margin-top:20px;font-size:12px;color:#999;text-align:center}}
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <h1>Index of {path}</h1>
48
+ <table>
49
+ <tr><th>Name</th><th class="size">Size</th><th class="date">Modified</th></tr>
50
+ {parent_row}
51
+ {entries}
52
+ </table>
53
+ <div class="footer">ssserve · Python port of vercel/serve</div>
54
+ </body>
55
+ </html>"""
56
+
57
+
58
+ def render_listing(path: str, base_dir: str, entries: list[os.DirEntry]) -> str:
59
+ normalized = "/" + path.strip("/")
60
+ rows = []
61
+
62
+ if path != "/":
63
+ parent = "/" if normalized == "/" else normalized.rsplit("/", 1)[0] or "/"
64
+ rows.append(
65
+ '<tr class="parent">'
66
+ f'<td><a href="{html.escape(parent)}"><span class="icon">&#128193;</span>..</a></td>'
67
+ '<td class="size">-</td>'
68
+ '<td class="date">-</td>'
69
+ "</tr>"
70
+ )
71
+
72
+ for entry in sorted(entries, key=lambda e: (not e.is_dir(), e.name.lower())):
73
+ name = entry.name
74
+ href = normalized.rstrip("/") + "/" + name
75
+ is_dir = entry.is_dir()
76
+
77
+ if is_dir:
78
+ icon = "&#128193;"
79
+ size_display = "-"
80
+ cls = "folder"
81
+ else:
82
+ icon = "&#128196;"
83
+ try:
84
+ stat = entry.stat()
85
+ size_display = format_size(stat.st_size)
86
+ date_display = format_date(stat.st_mtime)
87
+ except OSError:
88
+ size_display = "?"
89
+ date_display = "-"
90
+ cls = "file"
91
+
92
+ try:
93
+ stat = entry.stat()
94
+ date_display = format_date(stat.st_mtime)
95
+ except OSError:
96
+ date_display = "-"
97
+
98
+ dir_slash = "/" if is_dir else ""
99
+ rows.append(
100
+ f'<tr class="{cls}">'
101
+ f'<td><a href="{html.escape(href)}"><span class="icon">{icon}</span>{html.escape(name)}{dir_slash}</a></td>'
102
+ f'<td class="size">{size_display}</td>'
103
+ f'<td class="date">{date_display}</td>'
104
+ "</tr>"
105
+ )
106
+
107
+ return LISTING_TEMPLATE.format(
108
+ path=html.escape(normalized),
109
+ parent_row="\n".join(rows[:1]) if path != "/" else "",
110
+ entries="\n".join(rows[1:] if path != "/" else rows),
111
+ )
@@ -0,0 +1,96 @@
1
+ import os
2
+ import socket
3
+ import struct
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class Address:
9
+ scheme: str
10
+ host: str
11
+ port: int
12
+ path: str | None
13
+
14
+ def __str__(self) -> str:
15
+ if self.scheme == "unix":
16
+ return f"unix:{self.path}"
17
+ host = self.host or "0.0.0.0"
18
+ if ":" in host:
19
+ host = f"[{host}]"
20
+ return f"{host}:{self.port}"
21
+
22
+
23
+ def parse_listen(value: str) -> Address:
24
+ value = value.strip()
25
+
26
+ if value.startswith("unix:"):
27
+ return Address(scheme="unix", host="", port=0, path=value[5:])
28
+
29
+ if value.startswith("tcp://"):
30
+ rest = value[6:]
31
+ if rest.startswith("["):
32
+ bracket_end = rest.find("]")
33
+ if bracket_end == -1:
34
+ raise ValueError(f"Invalid IPv6 address in listen URI: {value}")
35
+ host = rest[1:bracket_end]
36
+ after = rest[bracket_end + 1 :]
37
+ if after.startswith(":"):
38
+ port = int(after[1:])
39
+ else:
40
+ port = 3000
41
+ elif ":" in rest:
42
+ host, port_str = rest.rsplit(":", 1)
43
+ port = int(port_str)
44
+ else:
45
+ host = rest
46
+ port = 3000
47
+ return Address(scheme="tcp", host=host, port=port, path=None)
48
+
49
+ if value.startswith("pipe:"):
50
+ raise ValueError("Windows named pipes are not supported")
51
+
52
+ try:
53
+ port = int(value)
54
+ return Address(scheme="tcp", host="", port=port, path=None)
55
+ except ValueError:
56
+ pass
57
+
58
+ if ":" in value:
59
+ host, port_str = value.rsplit(":", 1)
60
+ try:
61
+ port = int(port_str)
62
+ except ValueError:
63
+ raise ValueError(f"Invalid listen URI: {value}")
64
+ return Address(scheme="tcp", host=host, port=port, path=None)
65
+
66
+ return Address(scheme="tcp", host=value, port=3000, path=None)
67
+
68
+
69
+ def find_free_port(start: int = 3000, max_attempts: int = 100) -> int:
70
+ port = start
71
+ for _ in range(max_attempts):
72
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
73
+ try:
74
+ s.bind(("", port))
75
+ return port
76
+ except OSError:
77
+ port += 1
78
+ raise RuntimeError("Could not find a free port")
79
+
80
+
81
+ def get_lan_ip() -> str | None:
82
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
83
+ try:
84
+ s.connect(("10.255.255.255", 1))
85
+ ip = s.getsockname()[0]
86
+ return ip
87
+ except Exception:
88
+ pass
89
+ finally:
90
+ s.close()
91
+
92
+ try:
93
+ hostname = socket.gethostname()
94
+ return socket.gethostbyname(hostname)
95
+ except Exception:
96
+ return None
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: ssserve
3
+ Version: 0.1.0
4
+ Summary: Static file serving and directory listing — Python port of vercel/serve
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: click>=8.0
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ ssserve/__init__.py
4
+ ssserve/__main__.py
5
+ ssserve/cli.py
6
+ ssserve/config.py
7
+ ssserve/handler.py
8
+ ssserve/listing.py
9
+ ssserve/network.py
10
+ ssserve.egg-info/PKG-INFO
11
+ ssserve.egg-info/SOURCES.txt
12
+ ssserve.egg-info/dependency_links.txt
13
+ ssserve.egg-info/entry_points.txt
14
+ ssserve.egg-info/requires.txt
15
+ ssserve.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ssserve = ssserve.cli:main
@@ -0,0 +1 @@
1
+ click>=8.0
@@ -0,0 +1 @@
1
+ ssserve