189 lines
8.2 KiB
Python
189 lines
8.2 KiB
Python
"""Build the content-addressed browser shell served by the dashboard."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import gzip
|
|
import hashlib
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import rjsmin
|
|
|
|
|
|
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
|
COMMONJS_EXPORT_LINE = re.compile(
|
|
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$(?!\n\s*else)", re.MULTILINE
|
|
)
|
|
COMMONJS_BROWSER_BRANCH = re.compile(
|
|
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*\n\s*else\s*\{",
|
|
re.MULTILINE,
|
|
)
|
|
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
|
FEATURE_SOURCES = {
|
|
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
|
"issue-capture": (
|
|
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
|
),
|
|
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
|
"push-notifications": ("static/push-notifications.js",),
|
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
|
"security-center": ("static/security-center.js",),
|
|
"today-timer": (
|
|
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
|
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
|
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
|
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
|
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
|
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
|
),
|
|
}
|
|
CACHE_DECLARATION = re.compile(
|
|
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RuntimeBundle:
|
|
runtime_bytes: bytes
|
|
runtime_gzip_bytes: bytes
|
|
runtime_digest: str
|
|
runtime_name: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FrontendBuild:
|
|
dashboard_html: str
|
|
runtime_bytes: bytes
|
|
runtime_gzip_bytes: bytes
|
|
runtime_digest: str
|
|
runtime_name: str
|
|
shell_digest: str
|
|
page_sources: tuple[str, ...]
|
|
feature_bundles: dict[str, RuntimeBundle]
|
|
service_worker_source: str
|
|
|
|
|
|
def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
|
chunks = []
|
|
for source in sources:
|
|
path = frontend_dir / source.removeprefix("static/")
|
|
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
|
|
source = b"".join(chunks)
|
|
# Node-only export shims support source-level unit tests but are unreachable
|
|
# in the browser. Strip the simple one-line form from shipped bundles.
|
|
source = COMMONJS_BROWSER_BRANCH.sub(b"{", source)
|
|
source = COMMONJS_EXPORT_LINE.sub(b"", source)
|
|
revision = hashlib.sha256(source).hexdigest()
|
|
minified = rjsmin.jsmin(source.decode()).encode()
|
|
return minified + f';"source-sha256:{revision}";'.encode()
|
|
|
|
|
|
def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle:
|
|
content = _bundle(frontend_dir, sources)
|
|
digest = hashlib.sha256(content).hexdigest()[:16]
|
|
return RuntimeBundle(
|
|
runtime_bytes=content,
|
|
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
|
|
runtime_digest=digest,
|
|
runtime_name=f"{prefix}-{digest}.js",
|
|
)
|
|
|
|
|
|
def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
|
"""Return one internally consistent HTML, runtime, and offline-worker build."""
|
|
source_html = (frontend_dir / "index.html").read_text()
|
|
sources = tuple(SCRIPT_TAG.findall(source_html))
|
|
if not sources:
|
|
raise ValueError("dashboard entry document has no page scripts")
|
|
|
|
feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group}
|
|
core_sources = tuple(source for source in sources if source not in feature_source_names)
|
|
core = _runtime(frontend_dir, core_sources, "runtime")
|
|
feature_bundles = {
|
|
name: _runtime(frontend_dir, feature_sources, f"feature-{name}")
|
|
for name, feature_sources in FEATURE_SOURCES.items()
|
|
}
|
|
dashboard_html = SCRIPT_TAG.sub("", source_html)
|
|
feature_metadata = "\n".join(
|
|
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
|
|
for name, bundle in feature_bundles.items()
|
|
)
|
|
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
|
dashboard_html = dashboard_html.replace(
|
|
"</body>",
|
|
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
|
|
f'<script src="{core.runtime_name}"></script>\n</body>',
|
|
)
|
|
|
|
worker = (frontend_dir / "service-worker.js").read_text()
|
|
for source in sources:
|
|
if source != WORKER_RUNTIME_SOURCE:
|
|
worker = worker.replace(f" BASE + '{source}',\n", "")
|
|
eager_feature = feature_bundles["today-timer"]
|
|
optional_features = {
|
|
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
|
|
}
|
|
worker = worker.replace(
|
|
" BASE + 'static/dashboard.css',\n",
|
|
" BASE + 'static/dashboard.css',\n"
|
|
+ f" BASE + '{core.runtime_name}',\n"
|
|
+ f" BASE + '{eager_feature.runtime_name}',\n",
|
|
)
|
|
worker = worker.replace(
|
|
"const OPTIONAL_FEATURES = [\n",
|
|
"const OPTIONAL_FEATURES = [\n"
|
|
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
|
)
|
|
worker = CACHE_DECLARATION.sub(
|
|
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
|
)
|
|
|
|
shell_hasher = hashlib.sha256()
|
|
shell_inputs = (
|
|
("dashboard.html", dashboard_html.encode()),
|
|
(core.runtime_name, core.runtime_bytes),
|
|
*((bundle.runtime_name, bundle.runtime_bytes) for bundle in feature_bundles.values()),
|
|
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
|
|
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
|
|
(
|
|
"static/icons/stackchain-192.png",
|
|
(frontend_dir / "icons/stackchain-192.png").read_bytes(),
|
|
),
|
|
(
|
|
"static/icons/stackchain-512.png",
|
|
(frontend_dir / "icons/stackchain-512.png").read_bytes(),
|
|
),
|
|
(
|
|
WORKER_RUNTIME_SOURCE,
|
|
(
|
|
frontend_dir / WORKER_RUNTIME_SOURCE.removeprefix("static/")
|
|
).read_bytes(),
|
|
),
|
|
("service-worker.js", worker.encode()),
|
|
)
|
|
for name, content in shell_inputs:
|
|
shell_hasher.update(len(name).to_bytes(4, "big"))
|
|
shell_hasher.update(name.encode())
|
|
shell_hasher.update(len(content).to_bytes(8, "big"))
|
|
shell_hasher.update(content)
|
|
shell_digest = shell_hasher.hexdigest()[:16]
|
|
worker = worker.replace(
|
|
"const CACHE = 'stackchain-dashboard-shell-BUILD';",
|
|
f"const CACHE = 'stackchain-dashboard-shell-{shell_digest}';",
|
|
1,
|
|
)
|
|
|
|
return FrontendBuild(
|
|
dashboard_html=dashboard_html,
|
|
runtime_bytes=core.runtime_bytes,
|
|
runtime_gzip_bytes=core.runtime_gzip_bytes,
|
|
runtime_digest=core.runtime_digest,
|
|
runtime_name=core.runtime_name,
|
|
shell_digest=shell_digest,
|
|
page_sources=sources,
|
|
feature_bundles=feature_bundles,
|
|
service_worker_source=worker,
|
|
)
|