@vibe-x/dsh-html-workbench 0.1.1 → 0.1.2
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/lib/client.js +4 -1
- package/package.json +1 -1
- package/scripts/workbench.py +76 -21
- package/src/client.js +4 -1
package/lib/client.js
CHANGED
|
@@ -88,7 +88,10 @@ body[data-hwb-dragging] { user-select: none; }
|
|
|
88
88
|
.hwb-item-btn { border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 6px; padding: 2px 9px; cursor: pointer; font-size: 12px; flex: none; }
|
|
89
89
|
.hwb-item-btn:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
|
90
90
|
.hwb-dockempty { padding: 14px; color: var(--dsw-alias-label-tertiary); text-align: center; font-size: 12px; }
|
|
91
|
-
|
|
91
|
+
/* Keep the trigger below the DSH session header's title row (~44px tall: 12px
|
|
92
|
+
padding + 32px min-height) so it never covers the right-aligned header
|
|
93
|
+
utilities — e.g. the "Session Log" button — when a session is open. */
|
|
94
|
+
.hwb-trigger { position: fixed; top: 56px; right: 12px; z-index: 10000; width: 36px; height: 36px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; pointer-events: auto; }
|
|
92
95
|
.hwb-trigger:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
|
93
96
|
`)
|
|
94
97
|
|
package/package.json
CHANGED
package/scripts/workbench.py
CHANGED
|
@@ -9,6 +9,7 @@ import hashlib
|
|
|
9
9
|
import html
|
|
10
10
|
import io
|
|
11
11
|
import json
|
|
12
|
+
import logging
|
|
12
13
|
import mimetypes
|
|
13
14
|
import os
|
|
14
15
|
import re
|
|
@@ -22,6 +23,7 @@ import urllib.parse
|
|
|
22
23
|
import urllib.request
|
|
23
24
|
from dataclasses import dataclass, field
|
|
24
25
|
from html.parser import HTMLParser
|
|
26
|
+
from logging.handlers import RotatingFileHandler
|
|
25
27
|
from http import HTTPStatus
|
|
26
28
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
27
29
|
from pathlib import Path
|
|
@@ -74,6 +76,60 @@ def default_vendor_cache() -> Path:
|
|
|
74
76
|
return base / "html-workbench" / "vendor" / "grapesjs" / GRAPESJS_VERSION
|
|
75
77
|
|
|
76
78
|
|
|
79
|
+
LOG_MAX_BYTES = 1_000_000
|
|
80
|
+
LOG_BACKUP_COUNT = 5
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def default_log_dir() -> Path:
|
|
84
|
+
"""Fixed per-user log directory (stable across runs; survives tmp cleanup)."""
|
|
85
|
+
if os.name == "nt":
|
|
86
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
87
|
+
else:
|
|
88
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
89
|
+
return base / "html-workbench" / "logs"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_logger: logging.Logger | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_logger() -> logging.Logger:
|
|
96
|
+
"""Return the module logger, lazily initialised with a no-op handler.
|
|
97
|
+
|
|
98
|
+
Library callers and tests that never run `command_serve` still get a safe
|
|
99
|
+
logger; `setup_logging` attaches the rotating file handler when the service
|
|
100
|
+
actually starts.
|
|
101
|
+
"""
|
|
102
|
+
global _logger
|
|
103
|
+
if _logger is None:
|
|
104
|
+
_logger = logging.getLogger(SERVICE_NAME)
|
|
105
|
+
_logger.setLevel(logging.INFO)
|
|
106
|
+
_logger.addHandler(logging.NullHandler())
|
|
107
|
+
return _logger
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def setup_logging(log_dir: Path | str, port: int | None = None) -> logging.Logger:
|
|
111
|
+
"""Attach the rotating file handler and return the module logger."""
|
|
112
|
+
global _logger
|
|
113
|
+
directory = Path(log_dir).expanduser()
|
|
114
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
filename = f"workbench-{port}.log" if port is not None else "workbench.log"
|
|
116
|
+
handler = RotatingFileHandler(
|
|
117
|
+
directory / filename,
|
|
118
|
+
maxBytes=LOG_MAX_BYTES,
|
|
119
|
+
backupCount=LOG_BACKUP_COUNT,
|
|
120
|
+
encoding="utf-8",
|
|
121
|
+
)
|
|
122
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
123
|
+
logger = get_logger()
|
|
124
|
+
for existing in list(logger.handlers):
|
|
125
|
+
if isinstance(existing, RotatingFileHandler):
|
|
126
|
+
existing.close()
|
|
127
|
+
logger.removeHandler(existing)
|
|
128
|
+
logger.addHandler(handler)
|
|
129
|
+
logger.setLevel(logging.INFO)
|
|
130
|
+
return logger
|
|
131
|
+
|
|
132
|
+
|
|
77
133
|
def sha256_bytes(content: bytes) -> str:
|
|
78
134
|
return hashlib.sha256(content).hexdigest()
|
|
79
135
|
|
|
@@ -409,15 +465,7 @@ def revision_of(content: str) -> str:
|
|
|
409
465
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
410
466
|
|
|
411
467
|
|
|
412
|
-
def
|
|
413
|
-
try:
|
|
414
|
-
target.relative_to(root)
|
|
415
|
-
return True
|
|
416
|
-
except ValueError:
|
|
417
|
-
return False
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def resolve_html_file(raw_file: str, editor_root: Path) -> Path:
|
|
468
|
+
def resolve_html_file(raw_file: str) -> Path:
|
|
421
469
|
if not raw_file.strip():
|
|
422
470
|
raise WorkbenchError("FILE_REQUIRED", "请通过 file 参数指定 HTML 文件。", 400)
|
|
423
471
|
requested = Path(raw_file).expanduser()
|
|
@@ -427,8 +475,6 @@ def resolve_html_file(raw_file: str, editor_root: Path) -> Path:
|
|
|
427
475
|
raise WorkbenchError("FILE_NOT_FOUND", f"找不到文件:{requested.resolve()}", 404) from caught
|
|
428
476
|
if target.suffix.lower() != ".html":
|
|
429
477
|
raise WorkbenchError("INVALID_DOCUMENT", "只支持本地 .html 文件。", 400)
|
|
430
|
-
if not inside_root(target, editor_root):
|
|
431
|
-
raise WorkbenchError("OUTSIDE_EDITOR_ROOT", f"文件不在允许目录中:{target}", 403)
|
|
432
478
|
return target
|
|
433
479
|
|
|
434
480
|
|
|
@@ -532,7 +578,9 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
532
578
|
protocol_version = "HTTP/1.1"
|
|
533
579
|
|
|
534
580
|
def log_message(self, format_string: str, *args: Any) -> None:
|
|
535
|
-
|
|
581
|
+
message = format_string % args
|
|
582
|
+
get_logger().info("%s %s", self.log_date_time_string(), message)
|
|
583
|
+
sys.stderr.write(f"{self.log_date_time_string()} {message}\n")
|
|
536
584
|
|
|
537
585
|
def send_bytes(self, status: int, content: bytes, content_type: str, cache: str = "no-store") -> None:
|
|
538
586
|
self.send_response(status)
|
|
@@ -554,12 +602,14 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
554
602
|
|
|
555
603
|
def send_error_payload(self, caught: Exception) -> None:
|
|
556
604
|
if isinstance(caught, WorkbenchError):
|
|
605
|
+
get_logger().warning("request failed: %s (%s)", caught.code, caught)
|
|
557
606
|
self.send_json(caught.status, {"error": caught.code, "message": str(caught), **caught.extra})
|
|
558
607
|
else:
|
|
608
|
+
get_logger().exception("unhandled request error")
|
|
559
609
|
self.send_json(500, {"error": "SERVER_ERROR", "message": str(caught)})
|
|
560
610
|
|
|
561
611
|
def query_file(self, query: dict[str, list[str]]) -> Path:
|
|
562
|
-
return resolve_html_file(query.get("file", [""])[0]
|
|
612
|
+
return resolve_html_file(query.get("file", [""])[0])
|
|
563
613
|
|
|
564
614
|
def do_GET(self) -> None:
|
|
565
615
|
parsed = urllib.parse.urlsplit(self.path)
|
|
@@ -617,11 +667,9 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
617
667
|
if len(parts) < 4:
|
|
618
668
|
raise WorkbenchError("INVALID_ASSET", "资源地址无效。", 400)
|
|
619
669
|
token = parts[2]
|
|
620
|
-
source_file = resolve_html_file(decode_asset_token(token)
|
|
670
|
+
source_file = resolve_html_file(decode_asset_token(token))
|
|
621
671
|
relative = urllib.parse.unquote(parts[3])
|
|
622
672
|
resource = (source_file.parent / relative).resolve(strict=True)
|
|
623
|
-
if not inside_root(resource, self.server.editor_root):
|
|
624
|
-
raise WorkbenchError("OUTSIDE_EDITOR_ROOT", "资源超出允许目录。", 403)
|
|
625
673
|
content_type = mimetypes.guess_type(resource.name)[0] or "application/octet-stream"
|
|
626
674
|
self.send_bytes(200, resource.read_bytes(), content_type, "private, max-age=60")
|
|
627
675
|
|
|
@@ -662,9 +710,11 @@ def health(port: int, timeout: float = 0.5) -> dict[str, Any] | None:
|
|
|
662
710
|
return None
|
|
663
711
|
|
|
664
712
|
|
|
665
|
-
def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path) -> tuple[int, Path]:
|
|
666
|
-
|
|
667
|
-
|
|
713
|
+
def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path, log_dir: Path) -> tuple[int, Path]:
|
|
714
|
+
directory = Path(log_dir).expanduser()
|
|
715
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
716
|
+
log_file = directory / f"html-workbench-{port}.log"
|
|
717
|
+
command = [sys.executable, str(script_file), "serve", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache), "--log-dir", str(directory)]
|
|
668
718
|
if asset_file is not None:
|
|
669
719
|
command.extend(["--asset", str(asset_file)])
|
|
670
720
|
flags = 0
|
|
@@ -679,10 +729,12 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
|
|
|
679
729
|
|
|
680
730
|
|
|
681
731
|
def command_serve(args: argparse.Namespace, script_file: Path) -> int:
|
|
732
|
+
logger = setup_logging(args.log_dir, args.port)
|
|
682
733
|
asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else default_asset_file(script_file)
|
|
683
734
|
editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
|
|
684
735
|
vendor_files = ensure_vendor_assets(Path(args.vendor_cache).expanduser())
|
|
685
736
|
server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files)
|
|
737
|
+
logger.info("service started: port=%s editorRoot=%s logDir=%s", args.port, editor_root, Path(args.log_dir).expanduser())
|
|
686
738
|
print(json.dumps({"ok": True, "url": f"http://127.0.0.1:{args.port}", "editorRoot": str(editor_root)}, ensure_ascii=False), flush=True)
|
|
687
739
|
try:
|
|
688
740
|
server.serve_forever(poll_interval=0.25)
|
|
@@ -690,20 +742,21 @@ def command_serve(args: argparse.Namespace, script_file: Path) -> int:
|
|
|
690
742
|
pass
|
|
691
743
|
finally:
|
|
692
744
|
server.server_close()
|
|
745
|
+
logger.info("service stopped: port=%s", args.port)
|
|
693
746
|
return 0
|
|
694
747
|
|
|
695
748
|
|
|
696
749
|
def command_open(args: argparse.Namespace, script_file: Path) -> int:
|
|
697
750
|
target = Path(args.file).expanduser().resolve(strict=True)
|
|
698
751
|
editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
|
|
699
|
-
resolve_html_file(str(target)
|
|
752
|
+
resolve_html_file(str(target))
|
|
700
753
|
existing = health(args.port)
|
|
701
754
|
reused = existing is not None
|
|
702
755
|
pid: int | None = None
|
|
703
756
|
log_file: Path | None = None
|
|
704
757
|
if not reused:
|
|
705
758
|
asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else None
|
|
706
|
-
pid, log_file = start_detached(script_file, args.port, editor_root, asset_file, Path(args.vendor_cache).expanduser())
|
|
759
|
+
pid, log_file = start_detached(script_file, args.port, editor_root, asset_file, Path(args.vendor_cache).expanduser(), Path(args.log_dir).expanduser())
|
|
707
760
|
deadline = time.monotonic() + args.wait
|
|
708
761
|
while time.monotonic() < deadline:
|
|
709
762
|
if health(args.port):
|
|
@@ -730,6 +783,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
730
783
|
serve.add_argument("--editor-root", default=str(Path.home()))
|
|
731
784
|
serve.add_argument("--asset", help="override the bundled workbench.html path")
|
|
732
785
|
serve.add_argument("--vendor-cache", default=str(default_vendor_cache()), help="directory for verified GrapesJS files")
|
|
786
|
+
serve.add_argument("--log-dir", default=str(default_log_dir()), help="directory for rotating service logs")
|
|
733
787
|
|
|
734
788
|
open_command = subparsers.add_parser("open", help="start or reuse the service and print an editor URL")
|
|
735
789
|
open_command.add_argument("file")
|
|
@@ -737,6 +791,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
737
791
|
open_command.add_argument("--editor-root", default=str(Path.home()))
|
|
738
792
|
open_command.add_argument("--asset", help="override the bundled workbench.html path")
|
|
739
793
|
open_command.add_argument("--vendor-cache", default=str(default_vendor_cache()), help="directory for verified GrapesJS files")
|
|
794
|
+
open_command.add_argument("--log-dir", default=str(default_log_dir()), help="directory for rotating service logs")
|
|
740
795
|
open_command.add_argument("--wait", type=float, default=8.0)
|
|
741
796
|
|
|
742
797
|
health_command = subparsers.add_parser("health", help="check the local service")
|
package/src/client.js
CHANGED
|
@@ -58,7 +58,10 @@ body[data-hwb-dragging] { user-select: none; }
|
|
|
58
58
|
.hwb-item-btn { border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 6px; padding: 2px 9px; cursor: pointer; font-size: 12px; flex: none; }
|
|
59
59
|
.hwb-item-btn:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
|
60
60
|
.hwb-dockempty { padding: 14px; color: var(--dsw-alias-label-tertiary); text-align: center; font-size: 12px; }
|
|
61
|
-
|
|
61
|
+
/* Keep the trigger below the DSH session header's title row (~44px tall: 12px
|
|
62
|
+
padding + 32px min-height) so it never covers the right-aligned header
|
|
63
|
+
utilities — e.g. the "Session Log" button — when a session is open. */
|
|
64
|
+
.hwb-trigger { position: fixed; top: 56px; right: 12px; z-index: 10000; width: 36px; height: 36px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; pointer-events: auto; }
|
|
62
65
|
.hwb-trigger:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
|
63
66
|
`)
|
|
64
67
|
|