@vibe-x/dsh-html-workbench 0.3.1 → 0.3.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/README.md +55 -6
- package/assets/workbench.html +51 -14
- package/dsh.plugin.json +1 -1
- package/lib/client.js +480 -199
- package/package.json +2 -1
- package/scripts/workbench.py +275 -53
- package/src/client.js +479 -198
- package/src/host.js +260 -111
- package/src/index.js +32 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibe-x/dsh-html-workbench",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "DeepSeek Harness 插件:在右侧栏预览并可视化编辑 agent 生成的 HTML(复用/拉起本地 html-workbench 服务)。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"node": ">=20"
|
|
47
47
|
},
|
|
48
48
|
"publishConfig": {
|
|
49
|
+
"registry": "https://registry.npmjs.org/",
|
|
49
50
|
"access": "public"
|
|
50
51
|
}
|
|
51
52
|
}
|
package/scripts/workbench.py
CHANGED
|
@@ -14,6 +14,7 @@ import logging
|
|
|
14
14
|
import mimetypes
|
|
15
15
|
import os
|
|
16
16
|
import re
|
|
17
|
+
import secrets
|
|
17
18
|
import signal
|
|
18
19
|
import subprocess
|
|
19
20
|
import sys
|
|
@@ -21,6 +22,7 @@ import tarfile
|
|
|
21
22
|
import tempfile
|
|
22
23
|
import threading
|
|
23
24
|
import time
|
|
25
|
+
import traceback
|
|
24
26
|
import urllib.error
|
|
25
27
|
import urllib.parse
|
|
26
28
|
import urllib.request
|
|
@@ -113,8 +115,10 @@ def default_vendor_cache() -> Path:
|
|
|
113
115
|
return base / "html-workbench" / "vendor" / "grapesjs" / GRAPESJS_VERSION
|
|
114
116
|
|
|
115
117
|
|
|
116
|
-
LOG_MAX_BYTES =
|
|
118
|
+
LOG_MAX_BYTES = 1024 * 1024
|
|
117
119
|
LOG_BACKUP_COUNT = 5
|
|
120
|
+
LOG_RECORD_MAX_BYTES = 16 * 1024
|
|
121
|
+
BOOTSTRAP_MAX_BYTES = 256 * 1024
|
|
118
122
|
|
|
119
123
|
|
|
120
124
|
def default_log_dir() -> Path:
|
|
@@ -198,7 +202,7 @@ def self_ignore(directory: Path) -> None:
|
|
|
198
202
|
pass
|
|
199
203
|
|
|
200
204
|
|
|
201
|
-
def pick_writable_dir(*candidates: Path) -> Path:
|
|
205
|
+
def pick_writable_dir(*candidates: Path, failures: list[str] | None = None) -> Path:
|
|
202
206
|
"""Return the first candidate directory this process can really write into.
|
|
203
207
|
|
|
204
208
|
Existence proves nothing: the sandbox DACL looks writable on paper while the
|
|
@@ -209,18 +213,15 @@ def pick_writable_dir(*candidates: Path) -> Path:
|
|
|
209
213
|
still gets a deterministic path (and a clear error at the real write).
|
|
210
214
|
"""
|
|
211
215
|
fallback: Path | None = None
|
|
212
|
-
for candidate in candidates:
|
|
213
|
-
if candidate is None:
|
|
214
|
-
continue
|
|
216
|
+
for candidate in dict.fromkeys(candidates):
|
|
215
217
|
if fallback is None:
|
|
216
218
|
fallback = candidate
|
|
217
219
|
try:
|
|
218
220
|
candidate.mkdir(parents=True, exist_ok=True)
|
|
219
|
-
except OSError:
|
|
220
|
-
continue
|
|
221
|
-
try:
|
|
222
221
|
descriptor, probe = make_temp_file(candidate, prefix=".wb-write-probe.")
|
|
223
|
-
except OSError:
|
|
222
|
+
except OSError as caught:
|
|
223
|
+
if failures is not None:
|
|
224
|
+
failures.append(f"{candidate}: {caught}")
|
|
224
225
|
continue
|
|
225
226
|
os.close(descriptor)
|
|
226
227
|
try:
|
|
@@ -237,7 +238,7 @@ def log_candidates(log_dir: Path | str) -> tuple[Path, ...]:
|
|
|
237
238
|
return (
|
|
238
239
|
Path(log_dir).expanduser(),
|
|
239
240
|
runtime_directory() / "logs",
|
|
240
|
-
|
|
241
|
+
temporary_runtime_directory() / "logs",
|
|
241
242
|
)
|
|
242
243
|
|
|
243
244
|
|
|
@@ -251,9 +252,95 @@ def vendor_candidates(vendor_cache: Path | str, log_dir: Path) -> tuple[Path, ..
|
|
|
251
252
|
Path(vendor_cache).expanduser(),
|
|
252
253
|
Path(log_dir).parent / "vendor",
|
|
253
254
|
runtime_directory() / "vendor",
|
|
254
|
-
|
|
255
|
+
temporary_runtime_directory() / "vendor",
|
|
255
256
|
)
|
|
256
257
|
|
|
258
|
+
def temporary_runtime_directory() -> Path:
|
|
259
|
+
try:
|
|
260
|
+
return Path(tempfile.gettempdir()) / RUNTIME_DIR_NAME
|
|
261
|
+
except OSError:
|
|
262
|
+
# A denied TEMP must not prevent trying the explicitly supplied directory.
|
|
263
|
+
return runtime_directory()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def bounded_utf8(text: str, limit: int) -> bytes:
|
|
267
|
+
data = text.encode("utf-8", errors="backslashreplace")
|
|
268
|
+
if len(data) <= limit:
|
|
269
|
+
return data
|
|
270
|
+
marker = b"\n...[truncated]...\n"
|
|
271
|
+
remaining = limit - len(marker)
|
|
272
|
+
head = data[:remaining // 3].decode("utf-8", errors="ignore").encode("utf-8")
|
|
273
|
+
tail = data[-(remaining - remaining // 3):].decode("utf-8", errors="ignore").encode("utf-8")
|
|
274
|
+
return head + marker + tail
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class BoundedRotatingFileHandler(RotatingFileHandler):
|
|
278
|
+
"""Bound UTF-8 bytes, including exception stacks; back off on disk failures."""
|
|
279
|
+
|
|
280
|
+
last_error: str | None = None
|
|
281
|
+
dropped_records = 0
|
|
282
|
+
retry_at = 0.0
|
|
283
|
+
|
|
284
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
285
|
+
if time.monotonic() < self.retry_at:
|
|
286
|
+
self.dropped_records += 1
|
|
287
|
+
return
|
|
288
|
+
try:
|
|
289
|
+
data = bounded_utf8(self.format(record), min(LOG_RECORD_MAX_BYTES, self.maxBytes) - 1) + b"\n"
|
|
290
|
+
if self.stream is None:
|
|
291
|
+
self.stream = self._open()
|
|
292
|
+
if os.fstat(self.stream.fileno()).st_size + len(data) > self.maxBytes:
|
|
293
|
+
self.doRollover()
|
|
294
|
+
# Binary writes avoid Windows newline translation and count CJK bytes.
|
|
295
|
+
self.stream.buffer.write(data)
|
|
296
|
+
self.stream.flush()
|
|
297
|
+
self.last_error = None
|
|
298
|
+
except Exception as caught:
|
|
299
|
+
self.last_error = str(caught)[:500]
|
|
300
|
+
self.dropped_records += 1
|
|
301
|
+
self.retry_at = time.monotonic() + 60
|
|
302
|
+
try:
|
|
303
|
+
print(json.dumps({"event": "log_write_failed", "message": self.last_error}), file=sys.stderr, flush=True)
|
|
304
|
+
except OSError:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class BoundedOutput(io.TextIOBase):
|
|
309
|
+
"""Cap the detached child's combined stdout/stderr, including library output."""
|
|
310
|
+
|
|
311
|
+
def __init__(self, output: Any) -> None:
|
|
312
|
+
self.output = output
|
|
313
|
+
self.lock = threading.Lock()
|
|
314
|
+
self.full = False
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def encoding(self) -> str:
|
|
318
|
+
return "utf-8"
|
|
319
|
+
|
|
320
|
+
def write(self, text: str) -> int:
|
|
321
|
+
with self.lock:
|
|
322
|
+
if self.full:
|
|
323
|
+
return len(text)
|
|
324
|
+
try:
|
|
325
|
+
remaining = BOOTSTRAP_MAX_BYTES - os.fstat(self.output.fileno()).st_size
|
|
326
|
+
data = text.encode("utf-8", errors="backslashreplace")
|
|
327
|
+
marker = b"\n[bootstrap output limit reached]\n"
|
|
328
|
+
if len(data) + len(marker) > remaining:
|
|
329
|
+
self.full = True
|
|
330
|
+
data = data[:max(0, remaining - len(marker))].decode("utf-8", errors="ignore").encode("utf-8")
|
|
331
|
+
data += marker[:max(0, remaining - len(data))]
|
|
332
|
+
self.output.write(data)
|
|
333
|
+
self.output.flush()
|
|
334
|
+
except OSError:
|
|
335
|
+
self.full = True
|
|
336
|
+
return len(text)
|
|
337
|
+
|
|
338
|
+
def flush(self) -> None:
|
|
339
|
+
try:
|
|
340
|
+
self.output.flush()
|
|
341
|
+
except (OSError, ValueError):
|
|
342
|
+
pass
|
|
343
|
+
|
|
257
344
|
|
|
258
345
|
_logger: logging.Logger | None = None
|
|
259
346
|
|
|
@@ -286,8 +373,8 @@ def setup_logging(log_dir: Path | str, port: int | None = None) -> tuple[logging
|
|
|
286
373
|
swallowed and the service runs without a file log rather than never binding
|
|
287
374
|
its port.
|
|
288
375
|
"""
|
|
289
|
-
|
|
290
|
-
directory = pick_writable_dir(*log_candidates(log_dir))
|
|
376
|
+
failures: list[str] = []
|
|
377
|
+
directory = pick_writable_dir(*log_candidates(log_dir), failures=failures)
|
|
291
378
|
filename = f"workbench-{port}.log" if port is not None else "workbench.log"
|
|
292
379
|
logger = get_logger()
|
|
293
380
|
for existing in list(logger.handlers):
|
|
@@ -296,16 +383,24 @@ def setup_logging(log_dir: Path | str, port: int | None = None) -> tuple[logging
|
|
|
296
383
|
logger.removeHandler(existing)
|
|
297
384
|
logger.setLevel(logging.INFO)
|
|
298
385
|
try:
|
|
299
|
-
handler =
|
|
386
|
+
handler = BoundedRotatingFileHandler(
|
|
300
387
|
directory / filename,
|
|
301
388
|
maxBytes=LOG_MAX_BYTES,
|
|
302
389
|
backupCount=LOG_BACKUP_COUNT,
|
|
303
390
|
encoding="utf-8",
|
|
304
391
|
)
|
|
305
|
-
except OSError:
|
|
392
|
+
except OSError as caught:
|
|
393
|
+
print(json.dumps({
|
|
394
|
+
"level": "warning", "event": "log_file_unavailable",
|
|
395
|
+
"message": str(caught), "attempts": failures,
|
|
396
|
+
}, ensure_ascii=False), file=sys.stderr, flush=True)
|
|
306
397
|
return logger, directory
|
|
307
|
-
|
|
398
|
+
formatter = logging.Formatter("%(asctime)s.%(msecs)03dZ %(levelname)s pid=%(process)d %(message)s", "%Y-%m-%dT%H:%M:%S")
|
|
399
|
+
formatter.converter = time.gmtime
|
|
400
|
+
handler.setFormatter(formatter)
|
|
308
401
|
logger.addHandler(handler)
|
|
402
|
+
for failure in failures:
|
|
403
|
+
logger.warning("log_directory_rejected %s", failure)
|
|
309
404
|
return logger, directory
|
|
310
405
|
|
|
311
406
|
|
|
@@ -373,13 +468,15 @@ def write_vendor_cache(cache_dir: Path, files: dict[str, bytes]) -> None:
|
|
|
373
468
|
|
|
374
469
|
|
|
375
470
|
def ensure_vendor_assets(cache_dir: Path) -> dict[str, Path]:
|
|
471
|
+
logger = get_logger()
|
|
376
472
|
paths = {name: cache_dir / name for name in VENDOR_ASSETS}
|
|
377
473
|
try:
|
|
378
474
|
for name, path in paths.items():
|
|
379
475
|
verify_vendor_content(name, path.read_bytes())
|
|
476
|
+
logger.info("vendor_cache_ready grapesjs=%s", GRAPESJS_VERSION)
|
|
380
477
|
return paths
|
|
381
|
-
except (OSError, ValueError):
|
|
382
|
-
|
|
478
|
+
except (OSError, ValueError) as caught:
|
|
479
|
+
logger.info("vendor_cache_miss grapesjs=%s reason=%s", GRAPESJS_VERSION, caught)
|
|
383
480
|
|
|
384
481
|
failures: list[str] = []
|
|
385
482
|
sources = [
|
|
@@ -387,12 +484,23 @@ def ensure_vendor_assets(cache_dir: Path) -> dict[str, Path]:
|
|
|
387
484
|
*((name, url, vendor_from_cdn) for name, url in VENDOR_CDNS),
|
|
388
485
|
]
|
|
389
486
|
for source_name, url, loader in sources:
|
|
487
|
+
started = time.monotonic()
|
|
390
488
|
try:
|
|
489
|
+
logger.info("vendor_download_start source=%s", source_name)
|
|
391
490
|
files = loader(url)
|
|
392
|
-
write_vendor_cache(cache_dir, files)
|
|
393
|
-
return paths
|
|
394
491
|
except (OSError, ValueError, tarfile.TarError, urllib.error.URLError) as caught:
|
|
395
492
|
failures.append(f"{source_name}: {caught}")
|
|
493
|
+
logger.warning("vendor_download_failed source=%s elapsed_ms=%.0f reason=%s",
|
|
494
|
+
source_name, (time.monotonic() - started) * 1000, caught)
|
|
495
|
+
continue
|
|
496
|
+
try:
|
|
497
|
+
write_vendor_cache(cache_dir, files)
|
|
498
|
+
except (OSError, ValueError) as caught:
|
|
499
|
+
failures.append(f"{source_name} cache write: {caught}")
|
|
500
|
+
logger.warning("vendor_cache_write_failed source=%s reason=%s", source_name, caught)
|
|
501
|
+
continue
|
|
502
|
+
logger.info("vendor_download_ready source=%s elapsed_ms=%.0f", source_name, (time.monotonic() - started) * 1000)
|
|
503
|
+
return paths
|
|
396
504
|
raise WorkbenchError(
|
|
397
505
|
"VENDOR_DOWNLOAD_FAILED",
|
|
398
506
|
"首次启动需要下载约 1.2 MB 的 GrapesJS,但所有下载源均不可用。请检查网络后重试。",
|
|
@@ -1399,6 +1507,7 @@ class WorkbenchServer(ThreadingHTTPServer):
|
|
|
1399
1507
|
vendor_files: dict[str, Path],
|
|
1400
1508
|
log_dir: Path | None = None,
|
|
1401
1509
|
vendor_cache: Path | None = None,
|
|
1510
|
+
log_file: Path | None = None,
|
|
1402
1511
|
) -> None:
|
|
1403
1512
|
self.asset_file = asset_file.resolve(strict=True)
|
|
1404
1513
|
self.editor_root = editor_root.resolve(strict=True)
|
|
@@ -1409,6 +1518,7 @@ class WorkbenchServer(ThreadingHTTPServer):
|
|
|
1409
1518
|
# startup output to read them from.
|
|
1410
1519
|
self.log_dir = Path(log_dir) if log_dir is not None else None
|
|
1411
1520
|
self.vendor_cache = Path(vendor_cache) if vendor_cache is not None else None
|
|
1521
|
+
self.log_file = log_file
|
|
1412
1522
|
super().__init__(address, WorkbenchHandler)
|
|
1413
1523
|
|
|
1414
1524
|
|
|
@@ -1416,10 +1526,42 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
1416
1526
|
server: WorkbenchServer
|
|
1417
1527
|
protocol_version = "HTTP/1.1"
|
|
1418
1528
|
|
|
1419
|
-
def
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1529
|
+
def handle_one_request(self) -> None:
|
|
1530
|
+
self.request_id = secrets.token_hex(6)
|
|
1531
|
+
self.request_started = time.monotonic()
|
|
1532
|
+
self.error_code = None
|
|
1533
|
+
self.file_ref = "-"
|
|
1534
|
+
super().handle_one_request()
|
|
1535
|
+
|
|
1536
|
+
def parse_request(self) -> bool:
|
|
1537
|
+
# The connection may sit idle before its next request line arrives.
|
|
1538
|
+
self.request_started = time.monotonic()
|
|
1539
|
+
return super().parse_request()
|
|
1540
|
+
|
|
1541
|
+
def log_request(self, code: int | str = "-", size: int | str = "-") -> None:
|
|
1542
|
+
route = getattr(self, "path", "").partition("?")[0]
|
|
1543
|
+
if route == "/api/health" and code == 200:
|
|
1544
|
+
return
|
|
1545
|
+
if route.startswith("/assets/"):
|
|
1546
|
+
route = "/assets/*"
|
|
1547
|
+
elif route not in {"/", "/api/health", "/api/document", "/api/events", "/api/context", "/api/anchor", "/api/shutdown"}:
|
|
1548
|
+
route = route if route in self.server.vendor_routes else "other"
|
|
1549
|
+
status = int(code) if str(code).isdigit() else 0
|
|
1550
|
+
level = logging.ERROR if status >= 500 else logging.WARNING if status >= 400 else logging.INFO
|
|
1551
|
+
method = self.command if self.command in {"GET", "PUT", "POST", "HEAD", "OPTIONS"} else "other"
|
|
1552
|
+
get_logger().log(level, "http_request id=%s method=%s route=%s status=%s elapsed_ms=%.1f code=%s file_ref=%s",
|
|
1553
|
+
self.request_id, method, route, code,
|
|
1554
|
+
(time.monotonic() - self.request_started) * 1000, self.error_code or "-", self.file_ref)
|
|
1555
|
+
|
|
1556
|
+
def log_error(self, format_string: str, *args: Any) -> None:
|
|
1557
|
+
# Base HTTP errors also pass through log_request. Skip the raw message,
|
|
1558
|
+
# which can contain user-controlled request text.
|
|
1559
|
+
if format_string.startswith("Request timed out"):
|
|
1560
|
+
get_logger().warning("http_timeout id=%s", self.request_id)
|
|
1561
|
+
|
|
1562
|
+
def end_headers(self) -> None:
|
|
1563
|
+
self.send_header("X-Workbench-Request-Id", self.request_id)
|
|
1564
|
+
super().end_headers()
|
|
1423
1565
|
|
|
1424
1566
|
def send_bytes(self, status: int, content: bytes, content_type: str, cache: str = "no-store") -> None:
|
|
1425
1567
|
self.send_response(status)
|
|
@@ -1436,29 +1578,38 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
1436
1578
|
self.wfile.write(content)
|
|
1437
1579
|
|
|
1438
1580
|
def send_json(self, status: int, payload: dict[str, Any]) -> None:
|
|
1581
|
+
if status >= 400:
|
|
1582
|
+
self.error_code = payload.get("error")
|
|
1439
1583
|
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
1440
1584
|
self.send_bytes(status, content, "application/json; charset=utf-8")
|
|
1441
1585
|
|
|
1442
1586
|
def send_error_payload(self, caught: Exception) -> None:
|
|
1443
1587
|
if isinstance(caught, WorkbenchError):
|
|
1444
|
-
get_logger().warning("
|
|
1588
|
+
get_logger().warning("request_failed id=%s code=%s reason=%s", self.request_id, caught.code, caught)
|
|
1445
1589
|
self.send_json(caught.status, {"error": caught.code, "message": str(caught), **caught.extra})
|
|
1446
1590
|
else:
|
|
1447
|
-
get_logger().exception("
|
|
1591
|
+
get_logger().exception("request_failed id=%s code=SERVER_ERROR", self.request_id)
|
|
1448
1592
|
self.send_json(500, {"error": "SERVER_ERROR", "message": str(caught)})
|
|
1449
1593
|
|
|
1450
1594
|
def query_file(self, query: dict[str, list[str]]) -> Path:
|
|
1451
|
-
|
|
1595
|
+
raw = query.get("file", [""])[0]
|
|
1596
|
+
self.file_ref = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
|
|
1597
|
+
return resolve_html_file(raw)
|
|
1452
1598
|
|
|
1453
1599
|
def do_GET(self) -> None:
|
|
1454
|
-
parsed = urllib.parse.urlsplit(self.path)
|
|
1455
|
-
query = urllib.parse.parse_qs(parsed.query)
|
|
1456
1600
|
try:
|
|
1601
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
1602
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
1457
1603
|
if parsed.path == "/api/health":
|
|
1458
1604
|
payload: dict[str, Any] = {
|
|
1459
1605
|
"ok": True,
|
|
1460
1606
|
"service": SERVICE_NAME,
|
|
1461
1607
|
"version": SERVICE_VERSION,
|
|
1608
|
+
"pythonVersion": ".".join(map(str, sys.version_info[:3])),
|
|
1609
|
+
"pythonExecutable": sys.executable,
|
|
1610
|
+
"platform": sys.platform,
|
|
1611
|
+
"pid": os.getpid(),
|
|
1612
|
+
"logging": logging_status(),
|
|
1462
1613
|
"port": self.server.server_port,
|
|
1463
1614
|
"editorRoot": str(self.server.editor_root),
|
|
1464
1615
|
"capabilities": list(SERVICE_CAPABILITIES),
|
|
@@ -1469,6 +1620,7 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
1469
1620
|
# be exactly what the sandbox refused.
|
|
1470
1621
|
if self.server.log_dir is not None:
|
|
1471
1622
|
payload["logDir"] = str(self.server.log_dir)
|
|
1623
|
+
payload["logFile"] = str(self.server.log_file) if self.server.log_file is not None else None
|
|
1472
1624
|
if self.server.vendor_cache is not None:
|
|
1473
1625
|
payload["vendorCache"] = str(self.server.vendor_cache)
|
|
1474
1626
|
self.send_json(200, payload)
|
|
@@ -1494,23 +1646,25 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
1494
1646
|
self.send_error_payload(caught)
|
|
1495
1647
|
|
|
1496
1648
|
def do_PUT(self) -> None:
|
|
1497
|
-
parsed = urllib.parse.urlsplit(self.path)
|
|
1498
|
-
if parsed.path != "/api/document":
|
|
1499
|
-
self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
|
|
1500
|
-
return
|
|
1501
1649
|
try:
|
|
1650
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
1651
|
+
if parsed.path != "/api/document":
|
|
1652
|
+
self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
|
|
1653
|
+
return
|
|
1502
1654
|
body = self.read_json_body()
|
|
1503
1655
|
query = urllib.parse.parse_qs(parsed.query)
|
|
1504
1656
|
self.send_json(200, save_document(self.query_file(query), body))
|
|
1657
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
1658
|
+
return
|
|
1505
1659
|
except json.JSONDecodeError as caught:
|
|
1506
1660
|
self.send_error_payload(WorkbenchError("INVALID_SAVE", f"保存请求不是有效 JSON:{caught}", 400))
|
|
1507
1661
|
except Exception as caught:
|
|
1508
1662
|
self.send_error_payload(caught)
|
|
1509
1663
|
|
|
1510
1664
|
def do_POST(self) -> None:
|
|
1511
|
-
parsed = urllib.parse.urlsplit(self.path)
|
|
1512
|
-
query = urllib.parse.parse_qs(parsed.query)
|
|
1513
1665
|
try:
|
|
1666
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
1667
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
1514
1668
|
if parsed.path == "/api/shutdown":
|
|
1515
1669
|
# Lets a newer `open` retire this process cleanly. The service
|
|
1516
1670
|
# holds no state of its own, so exiting is always safe.
|
|
@@ -1533,6 +1687,8 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
1533
1687
|
self.send_json(200, promote_identities(target, selections, str(body.get("baseRevision") or "")))
|
|
1534
1688
|
return
|
|
1535
1689
|
self.send_json(200, build_selection_context(target, selections))
|
|
1690
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
1691
|
+
return
|
|
1536
1692
|
except json.JSONDecodeError as caught:
|
|
1537
1693
|
self.send_error_payload(WorkbenchError("INVALID_ANCHOR", f"请求不是有效 JSON:{caught}", 400))
|
|
1538
1694
|
except Exception as caught:
|
|
@@ -1565,15 +1721,20 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
1565
1721
|
self.send_header("Connection", "keep-alive")
|
|
1566
1722
|
self.end_headers()
|
|
1567
1723
|
last_revision = ""
|
|
1724
|
+
missing = False
|
|
1568
1725
|
while True:
|
|
1569
1726
|
try:
|
|
1570
1727
|
document = read_document(target)
|
|
1728
|
+
missing = False
|
|
1571
1729
|
if document["revision"] != last_revision:
|
|
1572
1730
|
last_revision = document["revision"]
|
|
1573
1731
|
payload = json.dumps({"revision": last_revision, "mtimeMs": document["mtimeMs"]}, separators=(",", ":"))
|
|
1574
1732
|
self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
|
|
1575
1733
|
self.wfile.flush()
|
|
1576
1734
|
except FileNotFoundError:
|
|
1735
|
+
if not missing:
|
|
1736
|
+
get_logger().warning("file_watch_missing id=%s code=FILE_NOT_FOUND", self.request_id)
|
|
1737
|
+
missing = True
|
|
1577
1738
|
self.wfile.write(b'event: error\ndata: {"error":"FILE_NOT_FOUND"}\n\n')
|
|
1578
1739
|
self.wfile.flush()
|
|
1579
1740
|
time.sleep(0.5)
|
|
@@ -1586,12 +1747,19 @@ def default_asset_file(script_file: Path) -> Path:
|
|
|
1586
1747
|
raise WorkbenchError("ASSET_NOT_FOUND", f"找不到前端资源:{packaged}", 500)
|
|
1587
1748
|
|
|
1588
1749
|
|
|
1589
|
-
def health(port: int, timeout: float = 0.5) -> dict[str, Any] | None:
|
|
1750
|
+
def health(port: int, timeout: float = 0.5, failures: list[str] | None = None) -> dict[str, Any] | None:
|
|
1590
1751
|
try:
|
|
1591
1752
|
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=timeout) as response:
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1753
|
+
content = response.read(64 * 1024 + 1)
|
|
1754
|
+
if len(content) > 64 * 1024:
|
|
1755
|
+
raise ValueError("health response exceeds 64 KiB")
|
|
1756
|
+
payload = json.loads(content.decode("utf-8"))
|
|
1757
|
+
if not isinstance(payload, dict) or payload.get("service") != SERVICE_NAME:
|
|
1758
|
+
raise ValueError("port responded with a different service identity")
|
|
1759
|
+
return payload
|
|
1760
|
+
except (OSError, ValueError, urllib.error.URLError) as caught:
|
|
1761
|
+
if failures is not None:
|
|
1762
|
+
failures.append(type(caught).__name__ + ": " + str(caught)[:1000])
|
|
1595
1763
|
return None
|
|
1596
1764
|
|
|
1597
1765
|
|
|
@@ -1709,7 +1877,7 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
|
|
|
1709
1877
|
"""
|
|
1710
1878
|
directory = pick_writable_dir(*log_candidates(log_dir))
|
|
1711
1879
|
log_file: Path | None = directory / f"html-workbench-{port}.log"
|
|
1712
|
-
command = [sys.executable, str(script_file), "serve", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache), "--log-dir", str(directory)]
|
|
1880
|
+
command = [sys.executable, str(script_file), "serve", "--bounded-output", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache), "--log-dir", str(directory)]
|
|
1713
1881
|
if asset_file is not None:
|
|
1714
1882
|
command.extend(["--asset", str(asset_file)])
|
|
1715
1883
|
flags = 0
|
|
@@ -1719,7 +1887,14 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
|
|
|
1719
1887
|
kwargs["creationflags"] = flags
|
|
1720
1888
|
kwargs.pop("start_new_session", None)
|
|
1721
1889
|
try:
|
|
1722
|
-
|
|
1890
|
+
if log_file.is_file():
|
|
1891
|
+
# Migrate historical unbounded output using a bounded tail read.
|
|
1892
|
+
with log_file.open("rb") as previous:
|
|
1893
|
+
previous.seek(max(0, os.fstat(previous.fileno()).st_size - BOOTSTRAP_MAX_BYTES))
|
|
1894
|
+
# A tail offset can bisect a multibyte character in old logs.
|
|
1895
|
+
tail = previous.read(BOOTSTRAP_MAX_BYTES).decode("utf-8", errors="replace")
|
|
1896
|
+
log_file.with_suffix(".log.1").write_bytes(bounded_utf8(tail, BOOTSTRAP_MAX_BYTES))
|
|
1897
|
+
log = log_file.open("wb")
|
|
1723
1898
|
except OSError:
|
|
1724
1899
|
log_file = None
|
|
1725
1900
|
log = None
|
|
@@ -1739,14 +1914,20 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
|
|
|
1739
1914
|
|
|
1740
1915
|
def command_serve(args: argparse.Namespace, script_file: Path) -> int:
|
|
1741
1916
|
logger, log_directory = setup_logging(args.log_dir, args.port)
|
|
1917
|
+
log_file = service_log_file(logger)
|
|
1918
|
+
logger.info("service_starting version=%s python=%s platform=%s port=%s executable=%s cwd=%s",
|
|
1919
|
+
SERVICE_VERSION, sys.version.split()[0], sys.platform, args.port, sys.executable, Path.cwd())
|
|
1742
1920
|
asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else default_asset_file(script_file)
|
|
1743
1921
|
editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
|
|
1744
1922
|
# Logs and cache are both disposable, so they follow the writable location the
|
|
1745
1923
|
# sandbox really granted instead of the path the parent requested.
|
|
1746
|
-
|
|
1924
|
+
failures: list[str] = []
|
|
1925
|
+
vendor_cache = pick_writable_dir(*vendor_candidates(args.vendor_cache, log_directory), failures=failures)
|
|
1926
|
+
for failure in failures:
|
|
1927
|
+
logger.warning("vendor_directory_rejected %s", failure)
|
|
1747
1928
|
vendor_files = ensure_vendor_assets(vendor_cache)
|
|
1748
|
-
server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files, log_directory, vendor_cache)
|
|
1749
|
-
logger.info("
|
|
1929
|
+
server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files, log_directory, vendor_cache, log_file)
|
|
1930
|
+
logger.info("service_ready port=%s editorRoot=%s logFile=%s vendorCache=%s", args.port, editor_root, log_file, vendor_cache)
|
|
1750
1931
|
# The host surfaces these two paths in its diagnostics, so the panel shows the
|
|
1751
1932
|
# directories actually in use rather than echoing the ones it asked for — the
|
|
1752
1933
|
# requested ones may be exactly what the sandbox refused.
|
|
@@ -1755,6 +1936,7 @@ def command_serve(args: argparse.Namespace, script_file: Path) -> int:
|
|
|
1755
1936
|
"url": f"http://127.0.0.1:{args.port}",
|
|
1756
1937
|
"editorRoot": str(editor_root),
|
|
1757
1938
|
"logDir": str(log_directory),
|
|
1939
|
+
"logFile": str(log_file) if log_file is not None else None,
|
|
1758
1940
|
"vendorCache": str(vendor_cache),
|
|
1759
1941
|
}, ensure_ascii=False), flush=True)
|
|
1760
1942
|
try:
|
|
@@ -1763,7 +1945,7 @@ def command_serve(args: argparse.Namespace, script_file: Path) -> int:
|
|
|
1763
1945
|
pass
|
|
1764
1946
|
finally:
|
|
1765
1947
|
server.server_close()
|
|
1766
|
-
logger.info("
|
|
1948
|
+
logger.info("service_stopped port=%s", args.port)
|
|
1767
1949
|
return 0
|
|
1768
1950
|
|
|
1769
1951
|
|
|
@@ -1817,6 +1999,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1817
1999
|
serve.add_argument("--asset", help="override the bundled workbench.html path")
|
|
1818
2000
|
serve.add_argument("--vendor-cache", default=str(default_vendor_cache()), help="directory for verified GrapesJS files")
|
|
1819
2001
|
serve.add_argument("--log-dir", default=str(default_log_dir()), help="directory for rotating service logs")
|
|
2002
|
+
serve.add_argument("--bounded-output", action="store_true", help=argparse.SUPPRESS)
|
|
1820
2003
|
|
|
1821
2004
|
open_command = subparsers.add_parser("open", help="start or reuse the service and print an editor URL")
|
|
1822
2005
|
open_command.add_argument("file")
|
|
@@ -1851,11 +2034,48 @@ def command_stop(args: argparse.Namespace) -> int:
|
|
|
1851
2034
|
raise WorkbenchError("SERVER_OUTDATED", f"端口 {args.port} 上的进程无法停止,请手动结束后重试。", 500)
|
|
1852
2035
|
|
|
1853
2036
|
|
|
2037
|
+
def service_log_file(logger: logging.Logger) -> Path | None:
|
|
2038
|
+
for handler in logger.handlers:
|
|
2039
|
+
if isinstance(handler, RotatingFileHandler):
|
|
2040
|
+
return Path(handler.baseFilename)
|
|
2041
|
+
return None
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
def logging_status() -> dict[str, Any]:
|
|
2045
|
+
for handler in get_logger().handlers:
|
|
2046
|
+
if isinstance(handler, BoundedRotatingFileHandler):
|
|
2047
|
+
return {
|
|
2048
|
+
"file": handler.baseFilename, "error": handler.last_error,
|
|
2049
|
+
"droppedRecords": handler.dropped_records,
|
|
2050
|
+
"maxBytes": LOG_MAX_BYTES, "backups": LOG_BACKUP_COUNT,
|
|
2051
|
+
"recordMaxBytes": LOG_RECORD_MAX_BYTES,
|
|
2052
|
+
}
|
|
2053
|
+
return {"file": None, "error": "file logging unavailable"}
|
|
2054
|
+
|
|
2055
|
+
|
|
2056
|
+
def report_cli_error(command: str, code: str, caught: Exception, **details: Any) -> int:
|
|
2057
|
+
payload = {"ok": False, "error": code, "message": str(caught), **details}
|
|
2058
|
+
if command == "serve":
|
|
2059
|
+
logger = get_logger()
|
|
2060
|
+
log_file = service_log_file(logger)
|
|
2061
|
+
payload["logFile"] = str(log_file) if log_file is not None else None
|
|
2062
|
+
logger.exception("service_failed code=%s", code)
|
|
2063
|
+
if log_file is None or logging_status().get("error"):
|
|
2064
|
+
print(bounded_utf8(traceback.format_exc(), LOG_RECORD_MAX_BYTES).decode("utf-8"), file=sys.stderr)
|
|
2065
|
+
print(json.dumps(payload, ensure_ascii=False), file=sys.stderr, flush=True)
|
|
2066
|
+
return 1
|
|
2067
|
+
|
|
2068
|
+
|
|
1854
2069
|
def main() -> int:
|
|
2070
|
+
for stream in (sys.stdout, sys.stderr):
|
|
2071
|
+
if hasattr(stream, "reconfigure"):
|
|
2072
|
+
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
1855
2073
|
if sys.version_info < (3, 9):
|
|
1856
2074
|
print(json.dumps({"ok": False, "error": "PYTHON_TOO_OLD", "message": "需要 Python 3.9 或更高版本。"}, ensure_ascii=False), file=sys.stderr)
|
|
1857
2075
|
return 2
|
|
1858
2076
|
args = build_parser().parse_args()
|
|
2077
|
+
if getattr(args, "bounded_output", False):
|
|
2078
|
+
sys.stdout = sys.stderr = BoundedOutput(sys.stdout.buffer)
|
|
1859
2079
|
script_file = Path(__file__).resolve()
|
|
1860
2080
|
try:
|
|
1861
2081
|
if args.command == "serve":
|
|
@@ -1864,19 +2084,21 @@ def main() -> int:
|
|
|
1864
2084
|
return command_open(args, script_file)
|
|
1865
2085
|
if args.command == "stop":
|
|
1866
2086
|
return command_stop(args)
|
|
1867
|
-
|
|
1868
|
-
|
|
2087
|
+
failures: list[str] = []
|
|
2088
|
+
payload = health(args.port, failures=failures)
|
|
2089
|
+
print(json.dumps(payload or {"ok": False, "error": "SERVER_UNAVAILABLE", "detail": failures}, ensure_ascii=False))
|
|
1869
2090
|
return 0 if payload else 1
|
|
1870
2091
|
except WorkbenchError as caught:
|
|
1871
|
-
|
|
1872
|
-
return 1
|
|
2092
|
+
return report_cli_error(args.command, caught.code, caught, **caught.extra)
|
|
1873
2093
|
except FileNotFoundError as caught:
|
|
1874
|
-
|
|
1875
|
-
return 1
|
|
2094
|
+
return report_cli_error(args.command, "FILE_NOT_FOUND", caught)
|
|
1876
2095
|
except OSError as caught:
|
|
1877
2096
|
code = "PORT_IN_USE" if getattr(caught, "errno", None) in {48, 98, 10048} else "SERVER_UNAVAILABLE"
|
|
1878
|
-
|
|
1879
|
-
|
|
2097
|
+
return report_cli_error(args.command, code, caught)
|
|
2098
|
+
except Exception as caught:
|
|
2099
|
+
if args.command != "serve":
|
|
2100
|
+
raise
|
|
2101
|
+
return report_cli_error(args.command, "SERVER_ERROR", caught)
|
|
1880
2102
|
|
|
1881
2103
|
|
|
1882
2104
|
if __name__ == "__main__":
|