@vibe-x/dsh-html-workbench 0.1.0

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.
@@ -0,0 +1,774 @@
1
+ #!/usr/bin/env python3
2
+ """Dependency-free local service for the HTML visual workbench."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import base64
8
+ import hashlib
9
+ import html
10
+ import io
11
+ import json
12
+ import mimetypes
13
+ import os
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ import tarfile
18
+ import tempfile
19
+ import time
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from dataclasses import dataclass, field
24
+ from html.parser import HTMLParser
25
+ from http import HTTPStatus
26
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+
31
+ SERVICE_NAME = "html-workbench"
32
+ SERVICE_VERSION = "2.0.0"
33
+ DEFAULT_PORT = 4317
34
+ MAX_REQUEST_BYTES = 8 * 1024 * 1024
35
+ SCRIPT_PATTERN = re.compile(r"^<script\b[\s\S]*</script\s*>$", re.IGNORECASE)
36
+ GRAPESJS_VERSION = "0.23.4"
37
+ VENDOR_ASSETS = {
38
+ "grapes.min.js": {
39
+ "route": "/vendor/grapesjs/grapes.min.js",
40
+ "archive": "package/dist/grapes.min.js",
41
+ "sha256": "66155421db3a640add8eaf77391b6a744d36af80833cd91d44f8d3220fb76231",
42
+ "content_type": "text/javascript; charset=utf-8",
43
+ },
44
+ "grapes.min.css": {
45
+ "route": "/vendor/grapesjs/css/grapes.min.css",
46
+ "archive": "package/dist/css/grapes.min.css",
47
+ "sha256": "fb55e939b3349c280d68c0617dc87e56baa3eab55ea56a1855db9f5efcc7268d",
48
+ "content_type": "text/css; charset=utf-8",
49
+ },
50
+ }
51
+ VENDOR_ARCHIVES = [
52
+ ("npmmirror", f"https://registry.npmmirror.com/grapesjs/-/grapesjs-{GRAPESJS_VERSION}.tgz"),
53
+ ]
54
+ VENDOR_CDNS = [
55
+ ("cdnjs", f"https://cdnjs.cloudflare.com/ajax/libs/grapesjs/{GRAPESJS_VERSION}"),
56
+ ("jsdelivr", f"https://cdn.jsdelivr.net/npm/grapesjs@{GRAPESJS_VERSION}/dist"),
57
+ ("unpkg", f"https://unpkg.com/grapesjs@{GRAPESJS_VERSION}/dist"),
58
+ ]
59
+
60
+
61
+ class WorkbenchError(Exception):
62
+ def __init__(self, code: str, message: str, status: int = 500, **extra: Any) -> None:
63
+ super().__init__(message)
64
+ self.code = code
65
+ self.status = status
66
+ self.extra = extra
67
+
68
+
69
+ def default_vendor_cache() -> Path:
70
+ if os.name == "nt":
71
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
72
+ else:
73
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
74
+ return base / "html-workbench" / "vendor" / "grapesjs" / GRAPESJS_VERSION
75
+
76
+
77
+ def sha256_bytes(content: bytes) -> str:
78
+ return hashlib.sha256(content).hexdigest()
79
+
80
+
81
+ def verify_vendor_content(name: str, content: bytes) -> bytes:
82
+ expected = str(VENDOR_ASSETS[name]["sha256"])
83
+ actual = sha256_bytes(content)
84
+ if actual != expected:
85
+ raise ValueError(f"{name} 校验失败(预期 {expected},实际 {actual})")
86
+ return content
87
+
88
+
89
+ def download_bytes(url: str, limit: int = 5_000_000, timeout: float = 20.0) -> bytes:
90
+ request = urllib.request.Request(url, headers={"User-Agent": f"html-workbench/{SERVICE_VERSION}"})
91
+ with urllib.request.urlopen(request, timeout=timeout) as response:
92
+ content = response.read(limit + 1)
93
+ if len(content) > limit:
94
+ raise ValueError(f"下载内容超过 {limit} 字节")
95
+ return content
96
+
97
+
98
+ def vendor_from_archive(url: str) -> dict[str, bytes]:
99
+ archive = download_bytes(url)
100
+ files: dict[str, bytes] = {}
101
+ with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as package:
102
+ members = {member.name: member for member in package.getmembers() if member.isfile()}
103
+ for name, metadata in VENDOR_ASSETS.items():
104
+ archive_name = str(metadata["archive"])
105
+ member = members.get(archive_name)
106
+ if member is None:
107
+ raise ValueError(f"压缩包缺少 {archive_name}")
108
+ source = package.extractfile(member)
109
+ if source is None:
110
+ raise ValueError(f"无法读取 {archive_name}")
111
+ files[name] = verify_vendor_content(name, source.read())
112
+ return files
113
+
114
+
115
+ def vendor_from_cdn(base_url: str) -> dict[str, bytes]:
116
+ return {
117
+ name: verify_vendor_content(
118
+ name,
119
+ download_bytes(f"{base_url}/{'css/' if name.endswith('.css') else ''}{name}"),
120
+ )
121
+ for name in VENDOR_ASSETS
122
+ }
123
+
124
+
125
+ def write_vendor_cache(cache_dir: Path, files: dict[str, bytes]) -> None:
126
+ cache_dir.mkdir(parents=True, exist_ok=True)
127
+ for name, content in files.items():
128
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{name}.", dir=cache_dir)
129
+ try:
130
+ with os.fdopen(descriptor, "wb") as output:
131
+ output.write(content)
132
+ output.flush()
133
+ os.fsync(output.fileno())
134
+ os.replace(temporary_name, cache_dir / name)
135
+ finally:
136
+ if os.path.exists(temporary_name):
137
+ os.unlink(temporary_name)
138
+
139
+
140
+ def ensure_vendor_assets(cache_dir: Path) -> dict[str, Path]:
141
+ paths = {name: cache_dir / name for name in VENDOR_ASSETS}
142
+ try:
143
+ for name, path in paths.items():
144
+ verify_vendor_content(name, path.read_bytes())
145
+ return paths
146
+ except (OSError, ValueError):
147
+ pass
148
+
149
+ failures: list[str] = []
150
+ sources = [
151
+ *((name, url, vendor_from_archive) for name, url in VENDOR_ARCHIVES),
152
+ *((name, url, vendor_from_cdn) for name, url in VENDOR_CDNS),
153
+ ]
154
+ for source_name, url, loader in sources:
155
+ try:
156
+ files = loader(url)
157
+ write_vendor_cache(cache_dir, files)
158
+ return paths
159
+ except (OSError, ValueError, tarfile.TarError, urllib.error.URLError) as caught:
160
+ failures.append(f"{source_name}: {caught}")
161
+ raise WorkbenchError(
162
+ "VENDOR_DOWNLOAD_FAILED",
163
+ "首次启动需要下载约 1.2 MB 的 GrapesJS,但所有下载源均不可用。请检查网络后重试。",
164
+ 503,
165
+ attempts=failures,
166
+ )
167
+
168
+
169
+ @dataclass
170
+ class HtmlNode:
171
+ tag: str
172
+ attrs: dict[str, str]
173
+ start: int
174
+ start_end: int
175
+ parent: "HtmlNode | None" = None
176
+ end_start: int | None = None
177
+ end_end: int | None = None
178
+ children: list["HtmlNode"] = field(default_factory=list)
179
+
180
+ def is_inside(self, ancestor: "HtmlNode") -> bool:
181
+ current = self.parent
182
+ while current is not None:
183
+ if current is ancestor:
184
+ return True
185
+ current = current.parent
186
+ return False
187
+
188
+
189
+ class SourceHtmlParser(HTMLParser):
190
+ def __init__(self, source: str) -> None:
191
+ super().__init__(convert_charrefs=False)
192
+ self.source = source
193
+ self.line_offsets: list[int] = [0]
194
+ for match in re.finditer(r"\n", source):
195
+ self.line_offsets.append(match.end())
196
+ self.nodes: list[HtmlNode] = []
197
+ self.stack: list[HtmlNode] = []
198
+
199
+ def source_offset(self) -> int:
200
+ line, column = self.getpos()
201
+ return self.line_offsets[line - 1] + column
202
+
203
+ @staticmethod
204
+ def normalize_attrs(attrs: list[tuple[str, str | None]]) -> dict[str, str]:
205
+ return {name: value if value is not None else "" for name, value in attrs}
206
+
207
+ def add_node(self, tag: str, attrs: list[tuple[str, str | None]], closed: bool) -> None:
208
+ start = self.source_offset()
209
+ raw = self.get_starttag_text() or ""
210
+ parent = self.stack[-1] if self.stack else None
211
+ node = HtmlNode(tag.lower(), self.normalize_attrs(attrs), start, start + len(raw), parent)
212
+ if parent is not None:
213
+ parent.children.append(node)
214
+ self.nodes.append(node)
215
+ if closed:
216
+ node.end_start = node.start_end
217
+ node.end_end = node.start_end
218
+ else:
219
+ self.stack.append(node)
220
+
221
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
222
+ self.add_node(tag, attrs, tag.lower() in {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"})
223
+
224
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
225
+ self.add_node(tag, attrs, True)
226
+
227
+ def handle_endtag(self, tag: str) -> None:
228
+ target = tag.lower()
229
+ index = next((i for i in range(len(self.stack) - 1, -1, -1) if self.stack[i].tag == target), None)
230
+ if index is None:
231
+ return
232
+ start = self.source_offset()
233
+ match = re.match(r"</\s*[^>]+>", self.source[start:], re.IGNORECASE)
234
+ end = start + (len(match.group(0)) if match else len(tag) + 3)
235
+ node = self.stack[index]
236
+ node.end_start = start
237
+ node.end_end = end
238
+ del self.stack[index:]
239
+
240
+
241
+ def parse_source(source: str) -> dict[str, Any]:
242
+ parser = SourceHtmlParser(source)
243
+ try:
244
+ parser.feed(source)
245
+ parser.close()
246
+ except Exception as caught:
247
+ raise WorkbenchError("INVALID_DOCUMENT", f"HTML 解析失败:{caught}", 400) from caught
248
+
249
+ def first(tag: str) -> HtmlNode | None:
250
+ return next((node for node in parser.nodes if node.tag == tag), None)
251
+
252
+ html_node = first("html")
253
+ head_node = first("head")
254
+ body_node = first("body")
255
+ if not html_node or not head_node or not body_node or body_node.end_start is None or head_node.end_start is None:
256
+ raise WorkbenchError("INVALID_DOCUMENT", "目标必须是包含 html、head、body 的完整 HTML 文档。", 400)
257
+
258
+ head_children = [node for node in parser.nodes if node.parent is head_node]
259
+ override = next(
260
+ (node for node in head_children if node.tag == "style" and "data-grapesjs-overrides" in node.attrs),
261
+ None,
262
+ )
263
+
264
+ def inner(node: HtmlNode) -> str:
265
+ if node.end_start is None:
266
+ return ""
267
+ return source[node.start_end : node.end_start]
268
+
269
+ source_css = "\n".join(inner(node) for node in head_children if node.tag == "style" and node is not override)
270
+ links = [
271
+ node.attrs.get("href", "")
272
+ for node in head_children
273
+ if node.tag == "link" and node.attrs.get("rel", "").lower() == "stylesheet" and node.attrs.get("href")
274
+ ]
275
+ head_scripts = [
276
+ {"attributes": node.attrs, "content": inner(node)}
277
+ for node in head_children
278
+ if node.tag == "script" and node.end_start is not None
279
+ ]
280
+
281
+ raw_body = source[body_node.start_end : body_node.end_start]
282
+ body_script_nodes = [
283
+ node
284
+ for node in parser.nodes
285
+ if node.tag == "script" and node.end_end is not None and node.is_inside(body_node)
286
+ ]
287
+ body_scripts = [source[node.start : node.end_end] for node in body_script_nodes]
288
+ clean_body = raw_body
289
+ for node in sorted(body_script_nodes, key=lambda item: item.start, reverse=True):
290
+ relative_start = node.start - body_node.start_end
291
+ relative_end = node.end_end - body_node.start_end
292
+ clean_body = clean_body[:relative_start] + clean_body[relative_end:]
293
+
294
+ return {
295
+ "bodyHtml": clean_body,
296
+ "bodyScripts": body_scripts,
297
+ "sourceCss": source_css,
298
+ "overrideCss": inner(override) if override else "",
299
+ "links": links,
300
+ "headScripts": head_scripts,
301
+ "htmlAttributes": html_node.attrs,
302
+ "bodyAttributes": body_node.attrs,
303
+ "locations": {
304
+ "bodyStart": body_node.start_end,
305
+ "bodyEnd": body_node.end_start,
306
+ "headEnd": head_node.end_start,
307
+ "overrideStart": override.start if override else None,
308
+ "overrideEnd": override.end_end if override else None,
309
+ },
310
+ }
311
+
312
+
313
+ def inline_event_attributes(source: str) -> list[tuple[str, tuple[str, str] | None, tuple[int, ...], dict[str, str]]]:
314
+ """Extract body `on*` attributes with stable identity and structural fallback.
315
+
316
+ GrapesJS intentionally strips inline event attributes from component models.
317
+ The workbench keeps the original source as the behavioral authority, so these
318
+ attributes must survive a visual-only save. Prefer id/data-wb-id/data-mode;
319
+ the sibling-index path only applies when the element tree was not rearranged.
320
+ """
321
+ parser = SourceHtmlParser(f"<body>{source}</body>")
322
+ parser.feed(parser.source)
323
+ parser.close()
324
+ body = next((node for node in parser.nodes if node.tag == "body"), None)
325
+ if body is None:
326
+ return []
327
+
328
+ def path_for(node: HtmlNode) -> tuple[int, ...]:
329
+ path: list[int] = []
330
+ current = node
331
+ while current.parent is not None and current.parent is not body:
332
+ path.append(current.parent.children.index(current))
333
+ current = current.parent
334
+ path.append(body.children.index(current))
335
+ return tuple(reversed(path))
336
+
337
+ events: list[tuple[str, tuple[str, str] | None, tuple[int, ...], dict[str, str]]] = []
338
+ for node in parser.nodes:
339
+ if not node.is_inside(body):
340
+ continue
341
+ attrs = {name: value for name, value in node.attrs.items() if name.lower().startswith("on")}
342
+ if not attrs:
343
+ continue
344
+ identity = next(
345
+ ((name, node.attrs[name]) for name in ("id", "data-wb-id", "data-mode") if node.attrs.get(name)),
346
+ None,
347
+ )
348
+ events.append((node.tag, identity, path_for(node), attrs))
349
+ return events
350
+
351
+
352
+ def restore_inline_event_attributes(body_html: str, source_body_html: str) -> str:
353
+ """Restore source inline handlers omitted by GrapesJS during serialization."""
354
+ source_events = inline_event_attributes(source_body_html)
355
+ if not source_events:
356
+ return body_html
357
+
358
+ parser = SourceHtmlParser(f"<body>{body_html}</body>")
359
+ parser.feed(parser.source)
360
+ parser.close()
361
+ body = next((node for node in parser.nodes if node.tag == "body"), None)
362
+ if body is None:
363
+ return body_html
364
+
365
+ def path_for(node: HtmlNode) -> tuple[int, ...]:
366
+ path: list[int] = []
367
+ current = node
368
+ while current.parent is not None and current.parent is not body:
369
+ path.append(current.parent.children.index(current))
370
+ current = current.parent
371
+ path.append(body.children.index(current))
372
+ return tuple(reversed(path))
373
+
374
+ by_identity: dict[tuple[str, str], list[HtmlNode]] = {}
375
+ by_path: dict[tuple[int, ...], HtmlNode] = {}
376
+ for node in parser.nodes:
377
+ if not node.is_inside(body):
378
+ continue
379
+ for name in ("id", "data-wb-id", "data-mode"):
380
+ if node.attrs.get(name):
381
+ by_identity.setdefault((name, node.attrs[name]), []).append(node)
382
+ by_path[path_for(node)] = node
383
+
384
+ additions: list[tuple[int, str]] = []
385
+ for tag, identity, path, attributes in source_events:
386
+ candidates = by_identity.get(identity, []) if identity else []
387
+ target = candidates[0] if len(candidates) == 1 else by_path.get(path)
388
+ if target is None or target.tag != tag:
389
+ continue
390
+ missing = {
391
+ name: value
392
+ for name, value in attributes.items()
393
+ if name.lower() not in {existing.lower() for existing in target.attrs}
394
+ }
395
+ if missing:
396
+ serialized = "".join(f' {name}="{html.escape(value, quote=True)}"' for name, value in missing.items())
397
+ additions.append((target.start_end - 1, serialized))
398
+
399
+ restored = body_html
400
+ # Offsets include the synthetic `<body>` prefix; apply from right to left.
401
+ synthetic_prefix = len("<body>")
402
+ for offset, serialized in sorted(additions, reverse=True):
403
+ index = offset - synthetic_prefix
404
+ restored = restored[:index] + serialized + restored[index:]
405
+ return restored
406
+
407
+
408
+ def revision_of(content: str) -> str:
409
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
410
+
411
+
412
+ def inside_root(target: Path, root: Path) -> bool:
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:
421
+ if not raw_file.strip():
422
+ raise WorkbenchError("FILE_REQUIRED", "请通过 file 参数指定 HTML 文件。", 400)
423
+ requested = Path(raw_file).expanduser()
424
+ try:
425
+ target = requested.resolve(strict=True)
426
+ except FileNotFoundError as caught:
427
+ raise WorkbenchError("FILE_NOT_FOUND", f"找不到文件:{requested.resolve()}", 404) from caught
428
+ if target.suffix.lower() != ".html":
429
+ raise WorkbenchError("INVALID_DOCUMENT", "只支持本地 .html 文件。", 400)
430
+ if not inside_root(target, editor_root):
431
+ raise WorkbenchError("OUTSIDE_EDITOR_ROOT", f"文件不在允许目录中:{target}", 403)
432
+ return target
433
+
434
+
435
+ def encode_asset_token(target: Path) -> str:
436
+ return base64.urlsafe_b64encode(str(target).encode("utf-8")).decode("ascii").rstrip("=")
437
+
438
+
439
+ def decode_asset_token(token: str) -> str:
440
+ padding = "=" * (-len(token) % 4)
441
+ try:
442
+ return base64.urlsafe_b64decode(token + padding).decode("utf-8")
443
+ except (ValueError, UnicodeDecodeError) as caught:
444
+ raise WorkbenchError("INVALID_ASSET", "资源地址无效。", 400) from caught
445
+
446
+
447
+ def read_document(target: Path) -> dict[str, Any]:
448
+ try:
449
+ source = target.read_text(encoding="utf-8")
450
+ except UnicodeDecodeError as caught:
451
+ raise WorkbenchError("INVALID_DOCUMENT", "HTML 文件必须使用 UTF-8 编码。", 400) from caught
452
+ parsed = parse_source(source)
453
+ stat = target.stat()
454
+ return {
455
+ **parsed,
456
+ "html": source,
457
+ "revision": revision_of(source),
458
+ "mtimeMs": stat.st_mtime * 1000,
459
+ "filePath": str(target),
460
+ "fileName": target.name,
461
+ "assetBase": f"/assets/{encode_asset_token(target)}/",
462
+ }
463
+
464
+
465
+ def save_document(target: Path, body: dict[str, Any]) -> dict[str, Any]:
466
+ base_revision = body.get("baseRevision")
467
+ body_html = body.get("bodyHtml")
468
+ css = body.get("css")
469
+ if not isinstance(base_revision, str) or not isinstance(body_html, str) or not isinstance(css, str):
470
+ raise WorkbenchError("INVALID_SAVE", "保存请求必须包含 baseRevision、bodyHtml 和 css。", 400)
471
+ if len(body_html) > 6_000_000 or len(css) > 1_000_000:
472
+ raise WorkbenchError("INVALID_SAVE", "保存内容超过大小限制。", 400)
473
+ if re.search(r"<(?:html|head|body)\b", body_html, re.IGNORECASE) or re.search(r"<script\b", body_html, re.IGNORECASE):
474
+ raise WorkbenchError("INVALID_SAVE", "bodyHtml 不允许包含 html、head、body 或 script。", 400)
475
+
476
+ current = read_document(target)
477
+ if current["revision"] != base_revision:
478
+ raise WorkbenchError("REVISION_CONFLICT", "磁盘文件已被外部修改,未覆盖最新版。", 409, document=current)
479
+
480
+ source = current["html"]
481
+ body_html = restore_inline_event_attributes(body_html, current["bodyHtml"])
482
+ locations = current["locations"]
483
+ override = f"<style data-grapesjs-overrides>\n{css}\n</style>\n"
484
+ if locations["overrideStart"] is not None:
485
+ source = source[: locations["overrideStart"]] + override + source[locations["overrideEnd"] :]
486
+ else:
487
+ source = source[: locations["headEnd"]] + override + source[locations["headEnd"] :]
488
+
489
+ refreshed = parse_source(source)
490
+ scripts = body.get("bodyScripts")
491
+ if isinstance(scripts, list) and all(isinstance(item, str) and SCRIPT_PATTERN.match(item.strip()) for item in scripts):
492
+ preserved_scripts = "\n".join(scripts)
493
+ else:
494
+ preserved_scripts = "\n".join(current["bodyScripts"])
495
+ next_body = f"{body_html}\n{preserved_scripts}" if preserved_scripts else body_html
496
+ body_start = refreshed["locations"]["bodyStart"]
497
+ body_end = refreshed["locations"]["bodyEnd"]
498
+ source = source[:body_start] + "\n" + next_body + "\n" + source[body_end:]
499
+
500
+ latest = read_document(target)
501
+ if latest["revision"] != base_revision:
502
+ raise WorkbenchError("REVISION_CONFLICT", "保存期间文件发生外部修改,未覆盖最新版。", 409, document=latest)
503
+
504
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.workbench-", suffix=".tmp", dir=target.parent)
505
+ try:
506
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
507
+ output.write(source)
508
+ output.flush()
509
+ os.fsync(output.fileno())
510
+ os.chmod(temporary_name, target.stat().st_mode)
511
+ os.replace(temporary_name, target)
512
+ finally:
513
+ if os.path.exists(temporary_name):
514
+ os.unlink(temporary_name)
515
+ return read_document(target)
516
+
517
+
518
+ class WorkbenchServer(ThreadingHTTPServer):
519
+ daemon_threads = True
520
+ allow_reuse_address = True
521
+
522
+ def __init__(self, address: tuple[str, int], asset_file: Path, editor_root: Path, vendor_files: dict[str, Path]) -> None:
523
+ self.asset_file = asset_file.resolve(strict=True)
524
+ self.editor_root = editor_root.resolve(strict=True)
525
+ self.vendor_files = {name: path.resolve(strict=True) for name, path in vendor_files.items()}
526
+ self.vendor_routes = {str(metadata["route"]): name for name, metadata in VENDOR_ASSETS.items()}
527
+ super().__init__(address, WorkbenchHandler)
528
+
529
+
530
+ class WorkbenchHandler(BaseHTTPRequestHandler):
531
+ server: WorkbenchServer
532
+ protocol_version = "HTTP/1.1"
533
+
534
+ def log_message(self, format_string: str, *args: Any) -> None:
535
+ sys.stderr.write(f"{self.log_date_time_string()} {format_string % args}\n")
536
+
537
+ def send_bytes(self, status: int, content: bytes, content_type: str, cache: str = "no-store") -> None:
538
+ self.send_response(status)
539
+ self.send_header("Content-Type", content_type)
540
+ self.send_header("Content-Length", str(len(content)))
541
+ if cache == "no-store":
542
+ self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
543
+ self.send_header("Pragma", "no-cache")
544
+ self.send_header("Expires", "0")
545
+ else:
546
+ self.send_header("Cache-Control", cache)
547
+ self.send_header("X-Content-Type-Options", "nosniff")
548
+ self.end_headers()
549
+ self.wfile.write(content)
550
+
551
+ def send_json(self, status: int, payload: dict[str, Any]) -> None:
552
+ content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
553
+ self.send_bytes(status, content, "application/json; charset=utf-8")
554
+
555
+ def send_error_payload(self, caught: Exception) -> None:
556
+ if isinstance(caught, WorkbenchError):
557
+ self.send_json(caught.status, {"error": caught.code, "message": str(caught), **caught.extra})
558
+ else:
559
+ self.send_json(500, {"error": "SERVER_ERROR", "message": str(caught)})
560
+
561
+ def query_file(self, query: dict[str, list[str]]) -> Path:
562
+ return resolve_html_file(query.get("file", [""])[0], self.server.editor_root)
563
+
564
+ def do_GET(self) -> None:
565
+ parsed = urllib.parse.urlsplit(self.path)
566
+ query = urllib.parse.parse_qs(parsed.query)
567
+ try:
568
+ if parsed.path == "/api/health":
569
+ self.send_json(200, {
570
+ "ok": True,
571
+ "service": SERVICE_NAME,
572
+ "version": SERVICE_VERSION,
573
+ "port": self.server.server_port,
574
+ "editorRoot": str(self.server.editor_root),
575
+ "capabilities": ["grapesjs-canvas", "autosave", "file-query", "file-watch", "stdlib-python"],
576
+ })
577
+ return
578
+ if parsed.path == "/api/document":
579
+ self.send_json(200, read_document(self.query_file(query)))
580
+ return
581
+ if parsed.path == "/api/events":
582
+ self.stream_events(self.query_file(query))
583
+ return
584
+ if parsed.path.startswith("/assets/"):
585
+ self.send_asset(parsed.path)
586
+ return
587
+ if parsed.path in self.server.vendor_routes:
588
+ name = self.server.vendor_routes[parsed.path]
589
+ metadata = VENDOR_ASSETS[name]
590
+ self.send_bytes(200, self.server.vendor_files[name].read_bytes(), str(metadata["content_type"]), "public, max-age=31536000, immutable")
591
+ return
592
+ self.send_bytes(200, self.server.asset_file.read_bytes(), "text/html; charset=utf-8")
593
+ except (BrokenPipeError, ConnectionResetError):
594
+ return
595
+ except Exception as caught:
596
+ self.send_error_payload(caught)
597
+
598
+ def do_PUT(self) -> None:
599
+ parsed = urllib.parse.urlsplit(self.path)
600
+ if parsed.path != "/api/document":
601
+ self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
602
+ return
603
+ try:
604
+ length = int(self.headers.get("Content-Length", "0"))
605
+ if length <= 0 or length > MAX_REQUEST_BYTES:
606
+ raise WorkbenchError("INVALID_SAVE", "保存请求大小无效。", 400)
607
+ body = json.loads(self.rfile.read(length).decode("utf-8"))
608
+ query = urllib.parse.parse_qs(parsed.query)
609
+ self.send_json(200, save_document(self.query_file(query), body))
610
+ except json.JSONDecodeError as caught:
611
+ self.send_error_payload(WorkbenchError("INVALID_SAVE", f"保存请求不是有效 JSON:{caught}", 400))
612
+ except Exception as caught:
613
+ self.send_error_payload(caught)
614
+
615
+ def send_asset(self, route: str) -> None:
616
+ parts = route.split("/", 3)
617
+ if len(parts) < 4:
618
+ raise WorkbenchError("INVALID_ASSET", "资源地址无效。", 400)
619
+ token = parts[2]
620
+ source_file = resolve_html_file(decode_asset_token(token), self.server.editor_root)
621
+ relative = urllib.parse.unquote(parts[3])
622
+ 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
+ content_type = mimetypes.guess_type(resource.name)[0] or "application/octet-stream"
626
+ self.send_bytes(200, resource.read_bytes(), content_type, "private, max-age=60")
627
+
628
+ def stream_events(self, target: Path) -> None:
629
+ self.send_response(200)
630
+ self.send_header("Content-Type", "text/event-stream")
631
+ self.send_header("Cache-Control", "no-cache")
632
+ self.send_header("Connection", "keep-alive")
633
+ self.end_headers()
634
+ last_revision = ""
635
+ while True:
636
+ try:
637
+ document = read_document(target)
638
+ if document["revision"] != last_revision:
639
+ last_revision = document["revision"]
640
+ payload = json.dumps({"revision": last_revision, "mtimeMs": document["mtimeMs"]}, separators=(",", ":"))
641
+ self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
642
+ self.wfile.flush()
643
+ except FileNotFoundError:
644
+ self.wfile.write(b'event: error\ndata: {"error":"FILE_NOT_FOUND"}\n\n')
645
+ self.wfile.flush()
646
+ time.sleep(0.5)
647
+
648
+
649
+ def default_asset_file(script_file: Path) -> Path:
650
+ packaged = script_file.parent.parent / "assets" / "workbench.html"
651
+ if packaged.is_file():
652
+ return packaged
653
+ raise WorkbenchError("ASSET_NOT_FOUND", f"找不到前端资源:{packaged}", 500)
654
+
655
+
656
+ def health(port: int, timeout: float = 0.5) -> dict[str, Any] | None:
657
+ try:
658
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=timeout) as response:
659
+ payload = json.loads(response.read().decode("utf-8"))
660
+ return payload if payload.get("service") == SERVICE_NAME else None
661
+ except (OSError, ValueError, urllib.error.URLError):
662
+ return None
663
+
664
+
665
+ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path) -> tuple[int, Path]:
666
+ log_file = Path(tempfile.gettempdir()) / f"html-workbench-{port}.log"
667
+ command = [sys.executable, str(script_file), "serve", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache)]
668
+ if asset_file is not None:
669
+ command.extend(["--asset", str(asset_file)])
670
+ flags = 0
671
+ kwargs: dict[str, Any] = {"stdin": subprocess.DEVNULL, "start_new_session": os.name != "nt"}
672
+ if os.name == "nt":
673
+ flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS # type: ignore[attr-defined]
674
+ kwargs["creationflags"] = flags
675
+ kwargs.pop("start_new_session", None)
676
+ with log_file.open("ab") as log:
677
+ process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, close_fds=True, **kwargs)
678
+ return process.pid, log_file
679
+
680
+
681
+ def command_serve(args: argparse.Namespace, script_file: Path) -> int:
682
+ asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else default_asset_file(script_file)
683
+ editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
684
+ vendor_files = ensure_vendor_assets(Path(args.vendor_cache).expanduser())
685
+ server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files)
686
+ print(json.dumps({"ok": True, "url": f"http://127.0.0.1:{args.port}", "editorRoot": str(editor_root)}, ensure_ascii=False), flush=True)
687
+ try:
688
+ server.serve_forever(poll_interval=0.25)
689
+ except KeyboardInterrupt:
690
+ pass
691
+ finally:
692
+ server.server_close()
693
+ return 0
694
+
695
+
696
+ def command_open(args: argparse.Namespace, script_file: Path) -> int:
697
+ target = Path(args.file).expanduser().resolve(strict=True)
698
+ editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
699
+ resolve_html_file(str(target), editor_root)
700
+ existing = health(args.port)
701
+ reused = existing is not None
702
+ pid: int | None = None
703
+ log_file: Path | None = None
704
+ if not reused:
705
+ 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())
707
+ deadline = time.monotonic() + args.wait
708
+ while time.monotonic() < deadline:
709
+ if health(args.port):
710
+ break
711
+ time.sleep(0.1)
712
+ else:
713
+ raise WorkbenchError("SERVER_UNAVAILABLE", f"服务启动失败,请查看日志:{log_file}", 500)
714
+ query = urllib.parse.urlencode({"file": str(target)})
715
+ print(json.dumps({
716
+ "ok": True,
717
+ "url": f"http://127.0.0.1:{args.port}/?{query}",
718
+ "reused": reused,
719
+ **({"pid": pid} if pid else {}),
720
+ }, ensure_ascii=False))
721
+ return 0
722
+
723
+
724
+ def build_parser() -> argparse.ArgumentParser:
725
+ parser = argparse.ArgumentParser(description="Start and use the local HTML visual workbench.")
726
+ subparsers = parser.add_subparsers(dest="command", required=True)
727
+
728
+ serve = subparsers.add_parser("serve", help="run the service in the foreground")
729
+ serve.add_argument("--port", type=int, default=DEFAULT_PORT)
730
+ serve.add_argument("--editor-root", default=str(Path.home()))
731
+ serve.add_argument("--asset", help="override the bundled workbench.html path")
732
+ serve.add_argument("--vendor-cache", default=str(default_vendor_cache()), help="directory for verified GrapesJS files")
733
+
734
+ open_command = subparsers.add_parser("open", help="start or reuse the service and print an editor URL")
735
+ open_command.add_argument("file")
736
+ open_command.add_argument("--port", type=int, default=DEFAULT_PORT)
737
+ open_command.add_argument("--editor-root", default=str(Path.home()))
738
+ open_command.add_argument("--asset", help="override the bundled workbench.html path")
739
+ open_command.add_argument("--vendor-cache", default=str(default_vendor_cache()), help="directory for verified GrapesJS files")
740
+ open_command.add_argument("--wait", type=float, default=8.0)
741
+
742
+ health_command = subparsers.add_parser("health", help="check the local service")
743
+ health_command.add_argument("--port", type=int, default=DEFAULT_PORT)
744
+ return parser
745
+
746
+
747
+ def main() -> int:
748
+ if sys.version_info < (3, 9):
749
+ print(json.dumps({"ok": False, "error": "PYTHON_TOO_OLD", "message": "需要 Python 3.9 或更高版本。"}, ensure_ascii=False), file=sys.stderr)
750
+ return 2
751
+ args = build_parser().parse_args()
752
+ script_file = Path(__file__).resolve()
753
+ try:
754
+ if args.command == "serve":
755
+ return command_serve(args, script_file)
756
+ if args.command == "open":
757
+ return command_open(args, script_file)
758
+ payload = health(args.port)
759
+ print(json.dumps(payload or {"ok": False, "error": "SERVER_UNAVAILABLE"}, ensure_ascii=False))
760
+ return 0 if payload else 1
761
+ except WorkbenchError as caught:
762
+ print(json.dumps({"ok": False, "error": caught.code, "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
763
+ return 1
764
+ except FileNotFoundError as caught:
765
+ print(json.dumps({"ok": False, "error": "FILE_NOT_FOUND", "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
766
+ return 1
767
+ except OSError as caught:
768
+ code = "PORT_IN_USE" if getattr(caught, "errno", None) in {48, 98, 10048} else "SERVER_UNAVAILABLE"
769
+ print(json.dumps({"ok": False, "error": code, "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
770
+ return 1
771
+
772
+
773
+ if __name__ == "__main__":
774
+ raise SystemExit(main())