localghost 4.2.1__tar.gz → 4.3.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.
- {localghost-4.2.1 → localghost-4.3.0}/PKG-INFO +5 -4
- {localghost-4.2.1 → localghost-4.3.0}/README.md +4 -3
- {localghost-4.2.1 → localghost-4.3.0}/pyproject.toml +1 -1
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/cli.py +1 -1
- localghost-4.3.0/src/localghost/html_server.js +29 -0
- localghost-4.3.0/src/localghost/html_server.py +207 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/runner.py +25 -1
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/tailscale.py +2 -2
- {localghost-4.2.1 → localghost-4.3.0}/LICENSE +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/.dockerignore +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/Dockerfile +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/__init__.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/compose.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/config.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/detached.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/feedback.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/forwarder.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/generator.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/init.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/paths.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/picker.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/ports.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/proxy_compose.yaml +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/proxy_compose_https.yaml +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/proxy_compose_tailscale.yaml +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/proxy_compose_tailscale_https.yaml +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/registry.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/roots.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/routes.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/sessions.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/statusbar.py +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/tailscale_gateway/Dockerfile +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/tailscale_gateway/go.mod +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/tailscale_gateway/go.sum +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/tailscale_gateway/identity.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/tailscale_gateway/identity_test.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/tailscale_gateway/main.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/tailscale_gateway/main_test.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/tailscale_gateway/proxy_test.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/.traefik.yml +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/ca.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/ca_test.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/cmd/bootstrap/main.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/docker.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/docker_test.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/go.mod +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/go.sum +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/provider.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/provider_test.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/storage.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/LICENSE +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/config.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/http_config.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/marshaler.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/middlewares.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/plugins.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tcp_config.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tcp_middlewares.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tls/certificate.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tls/tls.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/types/domains.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/types/tls.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/udp_config.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/modules.txt +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/.traefik.yml +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/client.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/client_test.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/go.mod +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/identity.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/identity_test.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/.traefik.yml +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/fallback.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/fallback_test.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/go.mod +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/logo.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/.traefik.yml +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/go.mod +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/provider.go +0 -0
- {localghost-4.2.1 → localghost-4.3.0}/src/localghost/trust.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: localghost
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.3.0
|
|
4
4
|
Summary: Localghost serves local applications at friendly .localhost URLs
|
|
5
5
|
Keywords: localghost,localhost,docker,compose,traefik,development,proxy
|
|
6
6
|
Author: SmileyChris
|
|
@@ -73,9 +73,10 @@ port 80 available. From an application directory, run:
|
|
|
73
73
|
uvx localghost run
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
Localghost detects Django, Vite, Astro, CakePHP, Laravel, plain PHP,
|
|
77
|
-
Compose projects, starts the shared hub when needed, and
|
|
78
|
-
visible while the application runs. Press Ctrl+C to stop
|
|
76
|
+
Localghost detects Django, Vite, Astro, CakePHP, Laravel, plain PHP, static
|
|
77
|
+
HTML, and Docker Compose projects, starts the shared hub when needed, and
|
|
78
|
+
keeps the local URL visible while the application runs. Press Ctrl+C to stop
|
|
79
|
+
the application.
|
|
79
80
|
|
|
80
81
|
An unconfigured Compose project asks you to save its routing setup first:
|
|
81
82
|
|
|
@@ -44,9 +44,10 @@ port 80 available. From an application directory, run:
|
|
|
44
44
|
uvx localghost run
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
Localghost detects Django, Vite, Astro, CakePHP, Laravel, plain PHP,
|
|
48
|
-
Compose projects, starts the shared hub when needed, and
|
|
49
|
-
visible while the application runs. Press Ctrl+C to stop
|
|
47
|
+
Localghost detects Django, Vite, Astro, CakePHP, Laravel, plain PHP, static
|
|
48
|
+
HTML, and Docker Compose projects, starts the shared hub when needed, and
|
|
49
|
+
keeps the local URL visible while the application runs. Press Ctrl+C to stop
|
|
50
|
+
the application.
|
|
50
51
|
|
|
51
52
|
An unconfigured Compose project asks you to save its routing setup first:
|
|
52
53
|
|
|
@@ -1424,7 +1424,7 @@ def _forget_tailscale_credential() -> None:
|
|
|
1424
1424
|
if delete_tailscale_credential():
|
|
1425
1425
|
info("The OAuth credential was removed from the system keyring.")
|
|
1426
1426
|
info(
|
|
1427
|
-
"The OAuth
|
|
1427
|
+
"The OAuth credential still exists in Tailscale; revoke it on the Trust "
|
|
1428
1428
|
f"credentials page ({OAUTH_CONSOLE_URL}) if no other machine uses it."
|
|
1429
1429
|
)
|
|
1430
1430
|
else:
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Served by html_server.py as /__localghost/reload.js. Each event lists the
|
|
2
|
+
// changed files' URL paths, one per line. Changes this page never loaded are
|
|
3
|
+
// ignored; changed stylesheets it links are swapped in place, keeping scroll,
|
|
4
|
+
// form and script state; anything else reloads the page.
|
|
5
|
+
performance.setResourceTimingBufferSize(1000);
|
|
6
|
+
new EventSource("/__localghost/reload").onmessage = (event) => {
|
|
7
|
+
const page = location.pathname;
|
|
8
|
+
const used = new Set(
|
|
9
|
+
page.endsWith("/") ? [page + "index.html", page + "index.htm"] : [page],
|
|
10
|
+
);
|
|
11
|
+
for (const entry of performance.getEntriesByType("resource")) {
|
|
12
|
+
const url = new URL(entry.name);
|
|
13
|
+
if (url.origin === location.origin) used.add(url.pathname);
|
|
14
|
+
}
|
|
15
|
+
const changed = event.data.split("\n").filter((path) => used.has(path));
|
|
16
|
+
if (!changed.length) return;
|
|
17
|
+
const links = [...document.querySelectorAll("link[rel=stylesheet]")].filter(
|
|
18
|
+
(link) => changed.includes(new URL(link.href).pathname),
|
|
19
|
+
);
|
|
20
|
+
const linked = new Set(links.map((link) => new URL(link.href).pathname));
|
|
21
|
+
if (!changed.every((path) => linked.has(path))) return location.reload();
|
|
22
|
+
for (const old of links) {
|
|
23
|
+
// The new sheet loads before the old one goes, so nothing flashes unstyled.
|
|
24
|
+
const link = old.cloneNode();
|
|
25
|
+
link.href = new URL(old.href).pathname + "?" + Date.now();
|
|
26
|
+
link.onload = () => old.remove();
|
|
27
|
+
old.after(link);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Serve the current directory, updating open pages when a file changes.
|
|
2
|
+
|
|
3
|
+
Run as `python -I -m localghost.html_server PORT BIND`. HTML pages get a
|
|
4
|
+
script (`/__localghost/reload.js`, from html_server.js) that holds an event
|
|
5
|
+
stream open; while one is open the server checks file modification times and
|
|
6
|
+
sends the paths of any that change, and the page swaps changed stylesheets or
|
|
7
|
+
reloads. With no page open, nothing is checked. Dotfiles and dot-directories
|
|
8
|
+
are never served.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import urllib.parse
|
|
20
|
+
from functools import partial
|
|
21
|
+
from http import HTTPStatus
|
|
22
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
RELOAD_PATH = "/__localghost/reload"
|
|
26
|
+
# Served from a path rather than inline, so a `script-src 'self'` policy
|
|
27
|
+
# still lets it run.
|
|
28
|
+
RELOAD_SCRIPT_PATH = "/__localghost/reload.js"
|
|
29
|
+
RELOAD_SCRIPT = Path(__file__).with_name("html_server.js").read_bytes()
|
|
30
|
+
SCRIPT = f'<script src="{RELOAD_SCRIPT_PATH}"></script>'.encode()
|
|
31
|
+
_BODY_END = re.compile(rb"</body\s*>", re.IGNORECASE)
|
|
32
|
+
_INTERVAL = 0.5
|
|
33
|
+
# A comment line this often notices a closed tab, so its check stops.
|
|
34
|
+
_HEARTBEAT = 15.0
|
|
35
|
+
_SKIPPED_DIRECTORY = "node_modules"
|
|
36
|
+
# The stdlib server's own index names; its `index_pages` is 3.12+.
|
|
37
|
+
_INDEX_PAGES = ("index.html", "index.htm")
|
|
38
|
+
# Every open page shares one scan per interval rather than walking alone.
|
|
39
|
+
_scan_lock = threading.Lock()
|
|
40
|
+
_last_scan: tuple[Path, float, dict[str, int]] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def snapshot(root: Path) -> dict[str, int]:
|
|
44
|
+
"""Modification times of every file under root.
|
|
45
|
+
|
|
46
|
+
Dot-directories and node_modules are skipped: neither is served content
|
|
47
|
+
anyone edits by hand, and node_modules can hold a hundred thousand files.
|
|
48
|
+
"""
|
|
49
|
+
times = {}
|
|
50
|
+
for directory, subdirectories, files in os.walk(root):
|
|
51
|
+
subdirectories[:] = [
|
|
52
|
+
name
|
|
53
|
+
for name in subdirectories
|
|
54
|
+
if not name.startswith(".") and name != _SKIPPED_DIRECTORY
|
|
55
|
+
]
|
|
56
|
+
for name in files:
|
|
57
|
+
path = os.path.join(directory, name)
|
|
58
|
+
# A file removed between the listing and the stat is just gone.
|
|
59
|
+
with contextlib.suppress(OSError):
|
|
60
|
+
times[path] = os.stat(path).st_mtime_ns
|
|
61
|
+
return times
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def shared_snapshot(root: Path) -> dict[str, int]:
|
|
65
|
+
"""A snapshot at most one interval old, so open pages share the walk.
|
|
66
|
+
|
|
67
|
+
A reused snapshot can only predate a change, which errs toward one extra
|
|
68
|
+
reload rather than a missed one.
|
|
69
|
+
"""
|
|
70
|
+
global _last_scan
|
|
71
|
+
with _scan_lock:
|
|
72
|
+
now = time.monotonic()
|
|
73
|
+
stale = _last_scan is None or now - _last_scan[1] >= _INTERVAL
|
|
74
|
+
if stale or _last_scan[0] != root:
|
|
75
|
+
_last_scan = (root, now, snapshot(root))
|
|
76
|
+
return _last_scan[2]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def changed_paths(
|
|
80
|
+
root: Path, before: dict[str, int], after: dict[str, int]
|
|
81
|
+
) -> list[str]:
|
|
82
|
+
"""URL paths of every file added, removed or modified between snapshots."""
|
|
83
|
+
changed = {
|
|
84
|
+
path
|
|
85
|
+
for path in before.keys() | after.keys()
|
|
86
|
+
if before.get(path) != after.get(path)
|
|
87
|
+
}
|
|
88
|
+
return sorted(
|
|
89
|
+
urllib.parse.quote("/" + Path(path).relative_to(root).as_posix())
|
|
90
|
+
for path in changed
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def inject(page: bytes) -> bytes:
|
|
95
|
+
"""Put the reload script before the last </body>, or at the end without one."""
|
|
96
|
+
matches = list(_BODY_END.finditer(page))
|
|
97
|
+
if not matches:
|
|
98
|
+
return page + SCRIPT
|
|
99
|
+
at = matches[-1].start()
|
|
100
|
+
return page[:at] + SCRIPT + page[at:]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Handler(SimpleHTTPRequestHandler):
|
|
104
|
+
def do_GET(self) -> None:
|
|
105
|
+
route = self.path.split("?", 1)[0]
|
|
106
|
+
if route == RELOAD_PATH:
|
|
107
|
+
self._stream_reloads()
|
|
108
|
+
return
|
|
109
|
+
if route == RELOAD_SCRIPT_PATH:
|
|
110
|
+
self._send(RELOAD_SCRIPT, "text/javascript")
|
|
111
|
+
return
|
|
112
|
+
self._serve()
|
|
113
|
+
|
|
114
|
+
def do_HEAD(self) -> None:
|
|
115
|
+
self._serve()
|
|
116
|
+
|
|
117
|
+
def _serve(self) -> None:
|
|
118
|
+
"""GET and HEAD alike, so both report the injected page's length."""
|
|
119
|
+
if self._hidden():
|
|
120
|
+
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
|
|
121
|
+
return
|
|
122
|
+
page = self._html_file()
|
|
123
|
+
if page is None:
|
|
124
|
+
if self.command == "HEAD":
|
|
125
|
+
super().do_HEAD()
|
|
126
|
+
else:
|
|
127
|
+
super().do_GET()
|
|
128
|
+
return
|
|
129
|
+
try:
|
|
130
|
+
body = inject(page.read_bytes())
|
|
131
|
+
except OSError:
|
|
132
|
+
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
|
|
133
|
+
return
|
|
134
|
+
# No charset: the page's own <meta charset> or BOM decides, as it
|
|
135
|
+
# would from the stdlib server. The tag is ASCII, so it fits any.
|
|
136
|
+
self._send(body, self.guess_type(str(page)))
|
|
137
|
+
|
|
138
|
+
def _send(self, body: bytes, content_type: str) -> None:
|
|
139
|
+
self.send_response(HTTPStatus.OK)
|
|
140
|
+
self.send_header("Content-Type", content_type)
|
|
141
|
+
self.send_header("Content-Length", str(len(body)))
|
|
142
|
+
self.send_header("Cache-Control", "no-cache")
|
|
143
|
+
self.end_headers()
|
|
144
|
+
if self.command != "HEAD":
|
|
145
|
+
self.wfile.write(body)
|
|
146
|
+
|
|
147
|
+
def _hidden(self) -> bool:
|
|
148
|
+
"""Does the path name a dotfile or anything inside a dot-directory?"""
|
|
149
|
+
path = urllib.parse.unquote(self.path.split("?", 1)[0].split("#", 1)[0])
|
|
150
|
+
return any(part.startswith(".") for part in path.split("/"))
|
|
151
|
+
|
|
152
|
+
def _html_file(self) -> Path | None:
|
|
153
|
+
"""The HTML file this request serves, leaving redirects and listings alone."""
|
|
154
|
+
path = Path(self.translate_path(self.path))
|
|
155
|
+
if path.is_dir():
|
|
156
|
+
if not self.path.split("?", 1)[0].endswith("/"):
|
|
157
|
+
return None
|
|
158
|
+
path = next(
|
|
159
|
+
(path / name for name in _INDEX_PAGES if (path / name).is_file()),
|
|
160
|
+
path / "index.html",
|
|
161
|
+
)
|
|
162
|
+
if path.suffix.lower() in (".html", ".htm") and path.is_file():
|
|
163
|
+
return path
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def _stream_reloads(self) -> None:
|
|
167
|
+
# Before the headers, so a save the page races to see is still a change.
|
|
168
|
+
root = Path(self.directory)
|
|
169
|
+
before = shared_snapshot(root)
|
|
170
|
+
self.send_response(HTTPStatus.OK)
|
|
171
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
172
|
+
self.send_header("Cache-Control", "no-cache")
|
|
173
|
+
self.end_headers()
|
|
174
|
+
quiet_since = time.monotonic()
|
|
175
|
+
# The stream stays open: a page that swaps a stylesheet rather than
|
|
176
|
+
# reloading would otherwise miss edits made while it reconnected.
|
|
177
|
+
try:
|
|
178
|
+
while True:
|
|
179
|
+
time.sleep(_INTERVAL)
|
|
180
|
+
after = shared_snapshot(root)
|
|
181
|
+
if after != before:
|
|
182
|
+
paths = changed_paths(root, before, after)
|
|
183
|
+
event = "".join(f"data: {path}\n" for path in paths) + "\n"
|
|
184
|
+
before = after
|
|
185
|
+
elif time.monotonic() - quiet_since > _HEARTBEAT:
|
|
186
|
+
event = ": still here\n\n"
|
|
187
|
+
else:
|
|
188
|
+
continue
|
|
189
|
+
self.wfile.write(event.encode())
|
|
190
|
+
self.wfile.flush()
|
|
191
|
+
quiet_since = time.monotonic()
|
|
192
|
+
except OSError: # The tab closed, or the connection otherwise went.
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main() -> None:
|
|
197
|
+
port, bind = int(sys.argv[1]), sys.argv[2]
|
|
198
|
+
handler = partial(Handler, directory=os.getcwd())
|
|
199
|
+
with (
|
|
200
|
+
ThreadingHTTPServer((bind, port), handler) as server,
|
|
201
|
+
contextlib.suppress(KeyboardInterrupt),
|
|
202
|
+
):
|
|
203
|
+
server.serve_forever()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
main()
|
|
@@ -68,6 +68,7 @@ SUPPORTED_TYPES = (
|
|
|
68
68
|
"cakephp",
|
|
69
69
|
"laravel",
|
|
70
70
|
"php",
|
|
71
|
+
"html",
|
|
71
72
|
)
|
|
72
73
|
RUN_TYPES = tuple(item for item in SUPPORTED_TYPES if item != "dockerfile")
|
|
73
74
|
# Types the non-Compose save path can persist. Compose is handled separately
|
|
@@ -93,6 +94,7 @@ DEFAULT_PORTS = {
|
|
|
93
94
|
"cakephp": 8765,
|
|
94
95
|
"laravel": 8000,
|
|
95
96
|
"php": 8080,
|
|
97
|
+
"html": 8080,
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
COMPOSE_FILENAMES = (
|
|
@@ -299,6 +301,8 @@ def build_plan(
|
|
|
299
301
|
default_port, selected_command, working_directory = php_command(
|
|
300
302
|
project_root, port
|
|
301
303
|
)
|
|
304
|
+
elif selected_type == "html":
|
|
305
|
+
default_port, selected_command = html_command(port)
|
|
302
306
|
else: # Click validates the public option; retain this for direct callers.
|
|
303
307
|
raise click.ClickException(type_choices("--type"))
|
|
304
308
|
wanted_port = port or default_port
|
|
@@ -383,7 +387,9 @@ def discover_type(
|
|
|
383
387
|
|
|
384
388
|
Ambiguity is computed after filtering to `allowed`, so a type another
|
|
385
389
|
command handles never blocks this one. Generic `php` defers to anything
|
|
386
|
-
stronger found further up; see `_php_project_strength`.
|
|
390
|
+
stronger found further up; see `_php_project_strength`. `html` defers
|
|
391
|
+
to any other type at the same directory, and to any but a weak `php`
|
|
392
|
+
further up: the nearer of two fallbacks wins.
|
|
387
393
|
"""
|
|
388
394
|
if requested is not None and requested not in SUPPORTED_TYPES:
|
|
389
395
|
raise click.ClickException(type_choices("--type"))
|
|
@@ -400,6 +406,10 @@ def discover_type(
|
|
|
400
406
|
fallback: tuple[str, Path] | None = None
|
|
401
407
|
for candidate in _search_path(start):
|
|
402
408
|
detected = [item for item in _types_at(candidate) if item in allowed]
|
|
409
|
+
if requested is None and "html" in detected:
|
|
410
|
+
detected.remove("html")
|
|
411
|
+
if not detected and fallback is None:
|
|
412
|
+
fallback = ("html", candidate)
|
|
403
413
|
if requested is not None:
|
|
404
414
|
if requested in detected:
|
|
405
415
|
if requested == "php" and _php_project_strength(candidate) != "strong":
|
|
@@ -531,6 +541,8 @@ def _types_at(cwd: Path) -> list[str]:
|
|
|
531
541
|
php = _php_type(cwd, _composer_manifest(cwd))
|
|
532
542
|
if php is not None:
|
|
533
543
|
detected.append(php)
|
|
544
|
+
if (cwd / "index.html").is_file():
|
|
545
|
+
detected.append("html")
|
|
534
546
|
return detected
|
|
535
547
|
|
|
536
548
|
|
|
@@ -642,6 +654,18 @@ def php_command(
|
|
|
642
654
|
)
|
|
643
655
|
|
|
644
656
|
|
|
657
|
+
def html_command(requested_port: int | None) -> tuple[int, tuple[str, ...]]:
|
|
658
|
+
"""Serve the directory, reloading open pages on change; see `html_server`.
|
|
659
|
+
|
|
660
|
+
-I keeps a stray module in the served directory from being imported.
|
|
661
|
+
"""
|
|
662
|
+
bind = "127.0.0.1" if forwarder.available() else "0.0.0.0"
|
|
663
|
+
return (
|
|
664
|
+
requested_port or DEFAULT_PORTS["html"],
|
|
665
|
+
(sys.executable, "-I", "-m", "localghost.html_server", "{port}", bind),
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
645
669
|
def astro_command(cwd: Path, requested_port: int | None) -> tuple[int, tuple[str, ...]]:
|
|
646
670
|
manifest = _astro_manifest(cwd)
|
|
647
671
|
if manifest is None:
|
|
@@ -330,7 +330,7 @@ class API:
|
|
|
330
330
|
) from exc
|
|
331
331
|
if exc.code == 403:
|
|
332
332
|
raise TailscaleError(
|
|
333
|
-
f"the OAuth
|
|
333
|
+
f"the OAuth credential may not create auth keys: it needs the "
|
|
334
334
|
f"Auth Keys: Write scope with {tag} selected (Trust "
|
|
335
335
|
f"credentials page, {OAUTH_CONSOLE_URL})"
|
|
336
336
|
) from exc
|
|
@@ -344,7 +344,7 @@ class API:
|
|
|
344
344
|
def _dns_scope_guidance(exc: APIError) -> TailscaleError:
|
|
345
345
|
if exc.code == 403:
|
|
346
346
|
return TailscaleError(
|
|
347
|
-
"the OAuth
|
|
347
|
+
"the OAuth credential lacks the DNS: Write scope; recreate it on the "
|
|
348
348
|
f"Trust credentials page ({OAUTH_CONSOLE_URL})"
|
|
349
349
|
)
|
|
350
350
|
return exc
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|